From 3fc56a0750158687134e84556d752a0e952329c8 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 10 Jun 2026 17:45:22 -0700 Subject: [PATCH] fix(table): translate column name-keyed wire data for workflow tool calls on internal row routes --- .../api/table/[tableId]/rows/[rowId]/route.ts | 12 +- .../api/table/[tableId]/rows/route.test.ts | 220 ++++++++++++++++++ .../sim/app/api/table/[tableId]/rows/route.ts | 41 ++-- .../api/table/[tableId]/rows/upsert/route.ts | 9 +- apps/sim/app/api/table/row-wire.ts | 46 ++++ 5 files changed, 306 insertions(+), 22 deletions(-) create mode 100644 apps/sim/app/api/table/[tableId]/rows/route.test.ts create mode 100644 apps/sim/app/api/table/row-wire.ts diff --git a/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts index 13a0762c68b..18486c370f6 100644 --- a/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts @@ -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 diff --git a/apps/sim/app/api/table/[tableId]/rows/route.test.ts b/apps/sim/app/api/table/[tableId]/rows/route.test.ts new file mode 100644 index 00000000000..23a12376e03 --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/rows/route.test.ts @@ -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) { + 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) { + 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 }) + }) +}) diff --git a/apps/sim/app/api/table/[tableId]/rows/route.ts b/apps/sim/app/api/table/[tableId]/rows/route.ts index 7b27e463c5d..31708805ad2 100644 --- a/apps/sim/app/api/table/[tableId]/rows/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/route.ts @@ -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 { 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 diff --git a/apps/sim/app/api/table/[tableId]/rows/upsert/route.ts b/apps/sim/app/api/table/[tableId]/rows/upsert/route.ts index c34ae686c0b..c8d9184e8c3 100644 --- a/apps/sim/app/api/table/[tableId]/rows/upsert/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/upsert/route.ts @@ -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() diff --git a/apps/sim/app/api/table/row-wire.ts b/apps/sim/app/api/table/row-wire.ts new file mode 100644 index 00000000000..89c4cb93af7 --- /dev/null +++ b/apps/sim/app/api/table/row-wire.ts @@ -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 = (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), + } +}