mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-12 23:33:47 +08:00
fix(table): translate column name-keyed wire data for workflow tool calls on internal row routes (#4958)
This commit is contained in:
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user