fix(table): translate column name-keyed wire data for workflow tool calls on internal row routes (#4958)

This commit is contained in:
Theodore Li
2026-06-10 20:54:26 -04:00
committed by GitHub
parent 02529843f0
commit 6e5cce6c01
5 changed files with 306 additions and 22 deletions
@@ -13,8 +13,9 @@ import { isZodError, parseRequest, validationErrorResponse } from '@/lib/api/ser
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import type { RowData } from '@/lib/table'
import type { RowData, TableSchema } from '@/lib/table'
import { deleteRow, updateRow } from '@/lib/table'
import { rowWireTranslators } from '@/app/api/table/row-wire'
import { accessError, checkAccess } from '@/app/api/table/utils'
const logger = createLogger('TableRowAPI')
@@ -72,12 +73,14 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Row
logger.info(`[${requestId}] Retrieved row ${rowId} from table ${tableId}`)
const wire = rowWireTranslators(authResult.authType, table.schema as TableSchema)
return NextResponse.json({
success: true,
data: {
row: {
id: row.id,
data: row.data,
data: wire.dataOut(row.data as RowData),
position: row.position,
createdAt:
row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
@@ -123,11 +126,12 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const wire = rowWireTranslators(authResult.authType, table.schema as TableSchema)
const updatedRow = await updateRow(
{
tableId,
rowId,
data: validated.data as RowData,
data: wire.dataIn(validated.data as RowData),
workspaceId: validated.workspaceId,
actorUserId: authResult.userId,
},
@@ -148,7 +152,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR
data: {
row: {
id: updatedRow.id,
data: updatedRow.data,
data: wire.dataOut(updatedRow.data),
position: updatedRow.position,
createdAt:
updatedRow.createdAt instanceof Date
@@ -0,0 +1,220 @@
/**
* @vitest-environment node
*/
import { hybridAuthMockFns } from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { TableDefinition } from '@/lib/table'
const { mockCheckAccess, mockInsertRow, mockValidateRowData, mockQueryRows } = vi.hoisted(() => ({
mockCheckAccess: vi.fn(),
mockInsertRow: vi.fn(),
mockValidateRowData: vi.fn(),
mockQueryRows: vi.fn(),
}))
vi.mock('@/app/api/table/utils', async () => {
const { NextResponse } = await import('next/server')
return {
checkAccess: mockCheckAccess,
accessError: (result: { status: number }) =>
NextResponse.json({ error: 'Access denied' }, { status: result.status }),
}
})
vi.mock('@/lib/table', async () => {
// Real column-keys translation functions; the row-wire helper under test
// imports them from this barrel.
const columnKeys = await import('@/lib/table/column-keys')
return {
...columnKeys,
insertRow: mockInsertRow,
batchInsertRows: vi.fn(),
batchUpdateRows: vi.fn(),
deleteRowsByFilter: vi.fn(),
deleteRowsByIds: vi.fn(),
updateRowsByFilter: vi.fn(),
validateBatchRows: vi.fn(),
validateRowData: mockValidateRowData,
validateRowSize: vi.fn(() => ({ valid: true })),
}
})
vi.mock('@/lib/table/service', () => ({
queryRows: mockQueryRows,
}))
vi.mock('@/lib/table/sql', () => ({
TableQueryValidationError: class TableQueryValidationError extends Error {},
}))
import { GET, POST } from '@/app/api/table/[tableId]/rows/route'
function buildTable(): TableDefinition {
return {
id: 'tbl_1',
name: 'People',
description: null,
schema: {
columns: [
{ id: 'col_aaa', name: 'Name', type: 'string' },
{ id: 'col_bbb', name: 'Age', type: 'number' },
],
},
metadata: null,
rowCount: 0,
maxRows: 100,
workspaceId: 'workspace-1',
createdBy: 'user-1',
archivedAt: null,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
}
}
function authAs(authType: 'session' | 'internal_jwt') {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
authType,
})
}
function callPost(body: Record<string, unknown>) {
const req = new NextRequest('http://localhost:3000/api/table/tbl_1/rows', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
return POST(req, { params: Promise.resolve({ tableId: 'tbl_1' }) })
}
function callGet(query: Record<string, string>) {
const params = new URLSearchParams(query)
const req = new NextRequest(`http://localhost:3000/api/table/tbl_1/rows?${params}`, {
method: 'GET',
})
return GET(req, { params: Promise.resolve({ tableId: 'tbl_1' }) })
}
describe('POST /api/table/[tableId]/rows', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() })
mockValidateRowData.mockResolvedValue({ valid: true })
mockInsertRow.mockResolvedValue({
id: 'row_1',
data: { col_aaa: 'Ada', col_bbb: 36 },
position: 1,
orderKey: 'a0',
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
})
})
it('translates name-keyed data to column ids for internal-JWT (workflow tool) callers', async () => {
authAs('internal_jwt')
const res = await callPost({
workspaceId: 'workspace-1',
data: { Name: 'Ada', Age: 36 },
})
expect(res.status).toBe(200)
expect(mockValidateRowData).toHaveBeenCalledWith(
expect.objectContaining({ rowData: { col_aaa: 'Ada', col_bbb: 36 } })
)
expect(mockInsertRow).toHaveBeenCalledWith(
expect.objectContaining({ data: { col_aaa: 'Ada', col_bbb: 36 } }),
expect.anything(),
expect.any(String)
)
const body = await res.json()
expect(body.data.row.data).toEqual({ Name: 'Ada', Age: 36 })
})
it('passes id-keyed data through untouched for session (UI) callers', async () => {
authAs('session')
const res = await callPost({
workspaceId: 'workspace-1',
data: { col_aaa: 'Ada', col_bbb: 36 },
})
expect(res.status).toBe(200)
expect(mockInsertRow).toHaveBeenCalledWith(
expect.objectContaining({ data: { col_aaa: 'Ada', col_bbb: 36 } }),
expect.anything(),
expect.any(String)
)
const body = await res.json()
expect(body.data.row.data).toEqual({ col_aaa: 'Ada', col_bbb: 36 })
})
})
describe('GET /api/table/[tableId]/rows', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() })
mockQueryRows.mockResolvedValue({
rows: [
{
id: 'row_1',
data: { col_aaa: 'Ada', col_bbb: 36 },
position: 1,
orderKey: 'a0',
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
},
],
rowCount: 1,
totalCount: 1,
limit: 100,
offset: 0,
})
})
it('translates name-keyed filter/sort and returns name-keyed rows for internal-JWT callers', async () => {
authAs('internal_jwt')
const res = await callGet({
workspaceId: 'workspace-1',
filter: JSON.stringify({ Name: { $eq: 'Ada' } }),
sort: JSON.stringify({ Age: 'desc' }),
})
expect(res.status).toBe(200)
expect(mockQueryRows).toHaveBeenCalledWith(
expect.objectContaining({ id: 'tbl_1' }),
expect.objectContaining({
filter: { col_aaa: { $eq: 'Ada' } },
sort: { col_bbb: 'desc' },
}),
expect.any(String)
)
const body = await res.json()
expect(body.data.rows[0].data).toEqual({ Name: 'Ada', Age: 36 })
})
it('passes id-keyed filter and rows through untouched for session callers', async () => {
authAs('session')
const res = await callGet({
workspaceId: 'workspace-1',
filter: JSON.stringify({ col_aaa: { $eq: 'Ada' } }),
})
expect(res.status).toBe(200)
expect(mockQueryRows).toHaveBeenCalledWith(
expect.objectContaining({ id: 'tbl_1' }),
expect.objectContaining({ filter: { col_aaa: { $eq: 'Ada' } } }),
expect.any(String)
)
const body = await res.json()
expect(body.data.rows[0].data).toEqual({ col_aaa: 'Ada', col_bbb: 36 })
})
})
+26 -15
View File
@@ -11,7 +11,7 @@ import {
} from '@/lib/api/contracts/tables'
import { parseRequest } from '@/lib/api/server'
import { isZodError, validationErrorResponse } from '@/lib/api/server/validation'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { type AuthTypeValue, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import type { Filter, RowData, Sort, TableSchema } from '@/lib/table'
@@ -28,6 +28,7 @@ import {
} from '@/lib/table'
import { queryRows } from '@/lib/table/service'
import { TableQueryValidationError } from '@/lib/table/sql'
import { rowWireTranslators } from '@/app/api/table/row-wire'
import { accessError, checkAccess } from '@/app/api/table/utils'
const logger = createLogger('TableRowsAPI')
@@ -40,7 +41,8 @@ async function handleBatchInsert(
requestId: string,
tableId: string,
validated: BatchInsertTableRowsBodyInput,
userId: string
userId: string,
authType: AuthTypeValue | undefined
): Promise<NextResponse> {
const accessResult = await checkAccess(tableId, userId, 'write')
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
@@ -54,10 +56,13 @@ async function handleBatchInsert(
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const wire = rowWireTranslators(authType, table.schema as TableSchema)
const rows = (validated.rows as RowData[]).map((row) => wire.dataIn(row))
// Validate rows before calling service (service also validates, but route-level
// validation returns structured HTTP responses)
const validation = await validateBatchRows({
rows: validated.rows as RowData[],
rows,
schema: table.schema as TableSchema,
tableId,
})
@@ -67,7 +72,7 @@ async function handleBatchInsert(
const insertedRows = await batchInsertRows(
{
tableId,
rows: validated.rows as RowData[],
rows,
workspaceId: validated.workspaceId,
userId,
positions: validated.positions,
@@ -82,7 +87,7 @@ async function handleBatchInsert(
data: {
rows: insertedRows.map((r) => ({
id: r.id,
data: r.data,
data: wire.dataOut(r.data),
position: r.position,
orderKey: r.orderKey ?? undefined,
createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : r.createdAt,
@@ -129,7 +134,7 @@ export const POST = withRouteHandler(
const body = parsed.data.body
if ('rows' in body) {
return handleBatchInsert(requestId, tableId, body, authResult.userId)
return handleBatchInsert(requestId, tableId, body, authResult.userId, authResult.authType)
}
const validated = body
@@ -146,7 +151,8 @@ export const POST = withRouteHandler(
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const rowData = validated.data as RowData
const wire = rowWireTranslators(authResult.authType, table.schema as TableSchema)
const rowData = wire.dataIn(validated.data as RowData)
// Validate at route level for structured HTTP error responses
const validation = await validateRowData({
@@ -176,7 +182,7 @@ export const POST = withRouteHandler(
data: {
row: {
id: row.id,
data: row.data,
data: wire.dataOut(row.data),
position: row.position,
orderKey: row.orderKey ?? undefined,
createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : row.createdAt,
@@ -264,11 +270,12 @@ export const GET = withRouteHandler(
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const wire = rowWireTranslators(authResult.authType, table.schema as TableSchema)
const result = await queryRows(
table,
{
filter: validated.filter as Filter | undefined,
sort: validated.sort,
filter: validated.filter ? wire.filterIn(validated.filter as Filter) : undefined,
sort: validated.sort ? wire.sortIn(validated.sort) : undefined,
limit: validated.limit,
offset: validated.offset,
includeTotal: validated.includeTotal,
@@ -281,7 +288,7 @@ export const GET = withRouteHandler(
data: {
rows: result.rows.map((r) => ({
id: r.id,
data: r.data,
data: wire.dataOut(r.data),
executions: r.executions,
position: r.position,
orderKey: r.orderKey ?? undefined,
@@ -344,7 +351,10 @@ export const PUT = withRouteHandler(
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const sizeValidation = validateRowSize(validated.data as RowData)
const wire = rowWireTranslators(authResult.authType, table.schema as TableSchema)
const patchData = wire.dataIn(validated.data as RowData)
const sizeValidation = validateRowSize(patchData)
if (!sizeValidation.valid) {
return NextResponse.json(
{ error: 'Invalid row data', details: sizeValidation.errors },
@@ -355,8 +365,8 @@ export const PUT = withRouteHandler(
const result = await updateRowsByFilter(
table,
{
filter: validated.filter as Filter,
data: validated.data as RowData,
filter: wire.filterIn(validated.filter as Filter),
data: patchData,
limit: validated.limit,
actorUserId: authResult.userId,
},
@@ -466,10 +476,11 @@ export const DELETE = withRouteHandler(
})
}
const wire = rowWireTranslators(authResult.authType, table.schema as TableSchema)
const result = await deleteRowsByFilter(
table,
{
filter: validated.filter as Filter,
filter: wire.filterIn(validated.filter as Filter),
limit: validated.limit,
},
requestId
@@ -7,8 +7,9 @@ import { isZodError, validationErrorResponse } from '@/lib/api/server/validation
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import type { RowData } from '@/lib/table'
import type { RowData, TableSchema } from '@/lib/table'
import { upsertRow } from '@/lib/table'
import { rowWireTranslators } from '@/app/api/table/row-wire'
import { accessError, checkAccess } from '@/app/api/table/utils'
const logger = createLogger('TableUpsertAPI')
@@ -41,11 +42,13 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const wire = rowWireTranslators(authResult.authType, table.schema as TableSchema)
// conflictTarget passes through untranslated — upsertRow resolves it id-or-name.
const upsertResult = await upsertRow(
{
tableId,
workspaceId: validated.workspaceId,
data: validated.data as RowData,
data: wire.dataIn(validated.data as RowData),
userId: authResult.userId,
conflictTarget: validated.conflictTarget,
},
@@ -58,7 +61,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser
data: {
row: {
id: upsertResult.row.id,
data: upsertResult.row.data,
data: wire.dataOut(upsertResult.row.data),
createdAt:
upsertResult.row.createdAt instanceof Date
? upsertResult.row.createdAt.toISOString()
+46
View File
@@ -0,0 +1,46 @@
import { AuthType, type AuthTypeValue } from '@/lib/auth/hybrid'
import type { Filter, RowData, Sort, TableSchema } from '@/lib/table'
import {
buildIdByName,
buildNameById,
filterNamesToIds,
rowDataIdToName,
rowDataNameToId,
sortNamesToIds,
} from '@/lib/table'
export interface RowWireTranslators {
/** Inbound row data: wire keys → storage column ids. */
dataIn: (data: RowData) => RowData
/** Outbound row data: storage column ids → wire keys. */
dataOut: (data: RowData) => RowData
/** Inbound filter: wire field refs → storage column ids. */
filterIn: (filter: Filter) => Filter
/** Inbound sort: wire field refs → storage column ids. */
sortIn: (sort: Sort) => Sort
}
/**
* Wire-keying translators for the internal table row routes, which serve two
* caller kinds: the first-party UI (session auth) speaks stable column ids and
* passes through untouched, while workflow tool executions (internal JWT) speak
* column names — tool enrichment surfaces names to the LLM — and translate
* name↔id at this boundary, mirroring the public v1 routes.
*/
export function rowWireTranslators(
authType: AuthTypeValue | undefined,
schema: TableSchema
): RowWireTranslators {
if (authType !== AuthType.INTERNAL_JWT) {
const identity = <T>(value: T): T => value
return { dataIn: identity, dataOut: identity, filterIn: identity, sortIn: identity }
}
const idByName = buildIdByName(schema)
const nameById = buildNameById(schema)
return {
dataIn: (data) => rowDataNameToId(data, idByName),
dataOut: (data) => rowDataIdToName(data, nameById),
filterIn: (filter) => filterNamesToIds(filter, idByName),
sortIn: (sort) => sortNamesToIds(sort, idByName),
}
}