Skip to content

Commit 9d8fa6c

Browse files
fix(table): close the review findings on the dual-grammar boundaries
From the PR #6067 review (greptile + bugbot), all verified before fixing: 1. Hybrid nodes on the async destructive routes (P1/High). The dual union's legacy branch accepts any non-empty object WITHOUT stripping, so a node carrying both a group key and leaf keys reached toLegacyFilter Zod-approved — and predicateToFilter converted it group-first, silently DROPPING the leaf and widening a select-all delete. predicateToFilter now throws on hybrids (lossless-or-throw, like its other rules), toLegacyFilter shape-validates first, and the GET native-predicate path shape-validates too. 2. A column literally named all/any (P1). NAME_PATTERN allows it, and isTablePredicate routed any object with those keys to the predicate compiler. It now requires the group value to be an ARRAY: the legacy equality shorthand and operator objects on such a column keep compiling as legacy, and an array-valued legacy condition was always a dropped no-op, so predicate precedence on arrays regresses nothing. 3. Bulk keying (P2). resolveBulkFilter validated predicates as NAME-keyed and translated unconditionally — wrong for the ID-keyed grid (session wire is identity). Validation now runs AFTER wire translation against STORAGE keys (new validateStoragePredicate), which is keying-correct for every caller and keeps the property that a typo'd column on a destructive path is a 400, not a silent match-nothing no-op. 4. 500s on downgrade rejection (Medium). delete-async called toLegacyFilter outside its try, and cancel-runs/columns-run mapped the throw to the generic 500. All three now return the validation message as a 400. 5. SortSpec broke the wire (High). requestJson threw on arrays of objects, so any active grid sort died client-side before the request — and the server contract rejected string-encoded values anyway, on both grammars. Arrays containing objects now travel as one JSON-string param (exactly what the serializer's own guard comment prescribed), and the rows/find query contracts decode JSON-string filter/sort/after before the union runs. Proven end-to-end with a real NextRequest for both grammars. Session bulk predicates are now id-keyed pass-through (matching the grid); name-keyed translation remains for INTERNAL_JWT workflow tools — tests updated to the corrected contract and extended for every finding. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011M563cbFy2S74GvSDf2C3R
1 parent d62ad32 commit 9d8fa6c

13 files changed

Lines changed: 298 additions & 36 deletions

File tree

apps/sim/app/api/table/[tableId]/cancel-runs/route.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { parseRequest } from '@/lib/api/server'
55
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
66
import { generateRequestId } from '@/lib/core/utils/request'
77
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8+
import { TableQueryValidationError } from '@/lib/table/errors'
89
import { toLegacyFilter } from '@/lib/table/query-builder/converters'
910
import { cancelWorkflowGroupRuns } from '@/lib/table/workflow-columns'
1011
import { accessError, checkAccess, tableFilterError } from '@/app/api/table/utils'
@@ -61,6 +62,11 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
6162

6263
return NextResponse.json({ success: true, data: { cancelled } })
6364
} catch (error) {
65+
// A predicate that Zod accepts but the downgrade rejects (hybrid node,
66+
// eq-with-array, valueless op) is caller error, not a server fault.
67+
if (error instanceof TableQueryValidationError) {
68+
return NextResponse.json({ error: error.message }, { status: 400 })
69+
}
6470
logger.error(`[${requestId}] cancel-runs failed:`, error)
6571
return NextResponse.json({ error: 'Failed to cancel runs' }, { status: 500 })
6672
}

apps/sim/app/api/table/[tableId]/columns/run/route.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { parseRequest } from '@/lib/api/server'
55
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
66
import { generateRequestId } from '@/lib/core/utils/request'
77
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8+
import { TableQueryValidationError } from '@/lib/table/errors'
89
import { toLegacyFilter } from '@/lib/table/query-builder/converters'
910
import { runWorkflowColumn } from '@/lib/table/workflow-columns'
1011
import { accessError, checkAccess, tableFilterError } from '@/app/api/table/utils'
@@ -60,6 +61,11 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
6061

6162
return NextResponse.json({ success: true, data: { dispatchId } })
6263
} catch (error) {
64+
// A predicate that Zod accepts but the downgrade rejects (hybrid node,
65+
// eq-with-array, valueless op) is caller error, not a server fault.
66+
if (error instanceof TableQueryValidationError) {
67+
return NextResponse.json({ error: error.message }, { status: 400 })
68+
}
6369
if (error instanceof Error && error.message === 'Invalid workspace ID') {
6470
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
6571
}

apps/sim/app/api/table/[tableId]/delete-async/route.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,4 +208,25 @@ describe('POST /api/table/[tableId]/delete-async', () => {
208208
expect(mockReleaseJobClaim).toHaveBeenCalledWith('tbl_1', 'job-id-xyz')
209209
expect(mockRunTableDelete).not.toHaveBeenCalled()
210210
})
211+
212+
/**
213+
* PR #6067 review finding (greptile P1 / bugbot High): a hybrid filter — group
214+
* key AND leaf keys on one node — passes the dual-grammar union via the
215+
* non-stripping legacy branch, and the downgrade used to convert group-first,
216+
* silently dropping the leaf and WIDENING an async select-all delete.
217+
*/
218+
it('rejects a hybrid group+leaf filter with 400 instead of widening the delete', async () => {
219+
const response = await makeRequest({
220+
workspaceId: 'workspace-1',
221+
filter: {
222+
all: [{ field: 'tenant_id', op: 'eq', value: 'acme' }],
223+
field: 'status',
224+
op: 'eq',
225+
value: 'archived',
226+
},
227+
})
228+
expect(response.status).toBe(400)
229+
const body = await response.json()
230+
expect(body.error).toMatch(/not both/)
231+
})
211232
})

apps/sim/app/api/table/[tableId]/delete-async/route.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import { isTriggerDevEnabled } from '@/lib/core/config/env-flags'
88
import { runDetached } from '@/lib/core/utils/background'
99
import { generateRequestId } from '@/lib/core/utils/request'
1010
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
11+
import type { Filter } from '@/lib/table'
1112
import { markTableDeleteFailed, runTableDelete } from '@/lib/table/delete-runner'
13+
import { TableQueryValidationError } from '@/lib/table/errors'
1214
import { markTableJobRunning, releaseJobClaim } from '@/lib/table/jobs/service'
1315
import { assertRowDelete } from '@/lib/table/mutation-locks'
1416
import { toLegacyFilter } from '@/lib/table/query-builder/converters'
@@ -46,8 +48,18 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
4648
const { tableId } = parsed.data.params
4749
const { workspaceId, filter: wireFilter, excludeRowIds, estimatedCount } = parsed.data.body
4850
// Dual-grammar wire: a predicate downgrades losslessly-or-throws to the
49-
// legacy Filter the runners/persisted payloads still compile.
50-
const filter = toLegacyFilter(wireFilter)
51+
// legacy Filter the runners/persisted payloads still compile. A shape the
52+
// union accepted but the downgrade rejects (hybrid node, eq-with-array) is
53+
// caller error — 400, never the generic 500.
54+
let filter: Filter | undefined
55+
try {
56+
filter = toLegacyFilter(wireFilter)
57+
} catch (error) {
58+
if (error instanceof TableQueryValidationError) {
59+
return NextResponse.json({ error: error.message }, { status: 400 })
60+
}
61+
throw error
62+
}
5163

5264
const access = await checkAccess(tableId, userId, 'write')
5365
if (!access.ok) return accessError(access, requestId, tableId)

apps/sim/app/api/table/[tableId]/rows/route.test.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,13 +290,43 @@ describe('PUT/DELETE /api/table/[tableId]/rows — predicate filters', () => {
290290
return DELETE(req, { params: Promise.resolve({ tableId: 'tbl_1' }) })
291291
}
292292

293-
it('PUT translates a name-keyed predicate to an id-keyed filter under SESSION auth', async () => {
293+
/**
294+
* Keying follows the caller (PR #6067 review): the grid authors ID-keyed
295+
* predicates and the session wire is identity, so ids pass through — and a
296+
* NAME under session auth is just an unknown storage key, rejected like any
297+
* other typo rather than half-translated.
298+
*/
299+
it('PUT passes an id-keyed predicate through untouched under SESSION auth', async () => {
300+
authAs('session')
301+
const res = await callPut({
302+
workspaceId: 'workspace-1',
303+
filter: { all: [{ field: 'col_aaa', op: 'eq', value: 'Ada' }] },
304+
data: { col_aaa: 'Grace' },
305+
})
306+
307+
expect(res.status).toBe(200)
308+
const args = mockUpdateRowsByFilter.mock.calls[0][1]
309+
expect(args.filter).toEqual({ $and: [{ col_aaa: 'Ada' }] })
310+
})
311+
312+
it('PUT rejects an unknown storage key under SESSION auth with 400', async () => {
294313
authAs('session')
295314
const res = await callPut({
296315
workspaceId: 'workspace-1',
297316
filter: { all: [{ field: 'Name', op: 'eq', value: 'Ada' }] },
298317
data: { col_aaa: 'Grace' },
299318
})
319+
expect(res.status).toBe(400)
320+
expect(mockUpdateRowsByFilter).not.toHaveBeenCalled()
321+
})
322+
323+
it('PUT translates a name-keyed predicate for INTERNAL_JWT callers', async () => {
324+
authAs('internal_jwt')
325+
const res = await callPut({
326+
workspaceId: 'workspace-1',
327+
filter: { all: [{ field: 'Name', op: 'eq', value: 'Ada' }] },
328+
data: { Name: 'Grace' },
329+
})
300330

301331
expect(res.status).toBe(200)
302332
const args = mockUpdateRowsByFilter.mock.calls[0][1]

apps/sim/app/api/table/[tableId]/rows/route.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,11 @@ import {
2727
} from '@/lib/table'
2828
import { TableQueryValidationError } from '@/lib/table/errors'
2929
import { isTablePredicate, predicateToFilter } from '@/lib/table/query-builder/converters'
30-
import { validatePredicate } from '@/lib/table/query-builder/validate'
30+
import {
31+
validatePredicateShape,
32+
validateStoragePredicate,
33+
} from '@/lib/table/query-builder/validate'
3134
import { queryRows } from '@/lib/table/rows/service'
32-
import { predicateToStorage } from '@/lib/table/select-values'
3335
import type { TablePredicate } from '@/lib/table/types'
3436
import { type RowWireTranslators, rowWireTranslators } from '@/app/api/table/row-wire'
3537
import { accessError, checkAccess, rowWriteErrorResponse } from '@/app/api/table/utils'
@@ -60,8 +62,15 @@ function resolveBulkFilter(
6062
wire: RowWireTranslators
6163
): Filter {
6264
if (isTablePredicate(raw)) {
63-
validatePredicate(raw, schema.columns)
64-
return predicateToFilter(predicateToStorage(raw, schema))
65+
// Shape first (keying-agnostic: hybrid nodes, leaf value rules), then let
66+
// the wire translate — identity for the ID-keyed grid, names→ids for
67+
// workflow tools — and validate the RESULT against storage keys. Post-
68+
// translation, any unresolved field is a typo in the caller's own keying,
69+
// and on a destructive path a typo must 400, not silently match nothing.
70+
validatePredicateShape(raw)
71+
const translated = wire.predicateIn(raw)
72+
validateStoragePredicate(translated, schema.columns)
73+
return predicateToFilter(translated)
6574
}
6675
return wire.filterIn(raw)
6776
}
@@ -294,7 +303,14 @@ export const GET = withRouteHandler(
294303
table,
295304
{
296305
...(validated.filter && isTablePredicate(validated.filter as Filter | TablePredicate)
297-
? { predicate: wire.predicateIn(validated.filter as TablePredicate) }
306+
? {
307+
predicate: (() => {
308+
// Shape-check first: nothing upstream validates this branch, and
309+
// an unchecked hybrid node would silently widen the result.
310+
validatePredicateShape(validated.filter as TablePredicate)
311+
return wire.predicateIn(validated.filter as TablePredicate)
312+
})(),
313+
}
298314
: { filter: validated.filter ? wire.filterIn(validated.filter as Filter) : undefined }),
299315
sort: resolveWireSort(validated.sort as Sort | SortSpec | undefined, wire),
300316
limit: validated.limit,

apps/sim/lib/api/client/request.test.ts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -54,18 +54,36 @@ describe('requestJson query serialization', () => {
5454
expect(calledUrl).not.toContain('[object Object]')
5555
})
5656

57-
it('throws instead of silently corrupting an array-of-objects query param', async () => {
58-
mockFetchReturning({ ok: true })
57+
it('JSON-encodes an array-of-objects query param instead of corrupting or throwing', async () => {
58+
// A SortSpec ([{field, direction}]) is the everyday case: repeat-append
59+
// would send "[object Object]", so the whole array travels as ONE JSON
60+
// string param, mirroring plain objects; the server contract decodes it.
61+
const fetchMock = mockFetchReturning({ ok: true })
5962

60-
const badContract = defineRouteContract({
63+
const contract = defineRouteContract({
6164
method: 'GET',
6265
path: '/api/test',
6366
query: z.object({ items: z.array(z.object({ a: z.string() })) }),
6467
response: { mode: 'json', schema: z.object({ ok: z.boolean() }) },
6568
})
6669

67-
await expect(requestJson(badContract, { query: { items: [{ a: 'x' }] } })).rejects.toThrow(
68-
/arrays of objects are not URL-safe/
69-
)
70+
await requestJson(contract, { query: { items: [{ a: 'x' }] } })
71+
const url = String(fetchMock.mock.calls[0][0])
72+
expect(decodeURIComponent(url)).toContain('items=[{"a":"x"}]')
73+
})
74+
75+
it('keeps repeat-append for scalar arrays', async () => {
76+
const fetchMock = mockFetchReturning({ ok: true })
77+
78+
const contract = defineRouteContract({
79+
method: 'GET',
80+
path: '/api/test',
81+
query: z.object({ tags: z.array(z.string()) }),
82+
response: { mode: 'json', schema: z.object({ ok: z.boolean() }) },
83+
})
84+
85+
await requestJson(contract, { query: { tags: ['a', 'b'] } })
86+
const url = String(fetchMock.mock.calls[0][0])
87+
expect(url).toContain('tags=a&tags=b')
7088
})
7189
})

apps/sim/lib/api/client/request.ts

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -67,19 +67,17 @@ function appendQuery(path: string, query: unknown): string {
6767
if (value === undefined || value === null || value === '') continue
6868

6969
if (Array.isArray(value)) {
70+
// An array of objects (e.g. a SortSpec) is not repeat-append-able — each
71+
// item would stringify to "[object Object]" and silently corrupt the
72+
// request (the knowledge tagFilters bug). Encode the WHOLE array as one
73+
// JSON string param, mirroring how plain objects are sent below; the
74+
// server-side contract decodes it. Scalar arrays keep repeat-append.
75+
if (value.some((item) => item !== null && typeof item === 'object')) {
76+
searchParams.set(key, JSON.stringify(value))
77+
continue
78+
}
7079
for (const item of value) {
7180
if (item === undefined || item === null || item === '') continue
72-
// A non-scalar in a query array would stringify to "[object Object]" and
73-
// silently corrupt the request. Encode such values as a single JSON
74-
// string param and decode them server-side instead. Failing loudly here
75-
// keeps the boundary honest (this is how the knowledge tagFilters bug
76-
// shipped undetected).
77-
if (typeof item === 'object') {
78-
throw new Error(
79-
`Cannot serialize query param "${key}": arrays of objects are not URL-safe — ` +
80-
'encode the value as a JSON string param and decode it server-side.'
81-
)
82-
}
8381
searchParams.append(key, String(item))
8482
}
8583
continue

apps/sim/lib/api/contracts/tables-predicate.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
deleteTableRowsBodySchema,
1111
predicateSchema,
1212
rowQueryBodySchema,
13+
tableRowsQuerySchema,
1314
updateRowsByFilterBodySchema,
1415
} from '@/lib/api/contracts/tables'
1516
import { validatePredicate } from '@/lib/table/query-builder/validate'
@@ -223,3 +224,38 @@ describe('hybrid group+leaf nodes are rejected, not silently narrowed', () => {
223224
).toBe(true)
224225
})
225226
})
227+
228+
/**
229+
* Wire transport: requestJson serializes structured query params as JSON
230+
* strings; jsonQueryValue decodes them before the union runs. Without it, a
231+
* sorted or filtered grid request 400s at the boundary.
232+
*/
233+
describe('query-string JSON transport (jsonQueryValue)', () => {
234+
it('decodes string-encoded predicate, legacy filter, and sort spec', () => {
235+
const parsed = rowQueryStringSchemaProbe({
236+
workspaceId: 'ws-1',
237+
filter: JSON.stringify({ all: [{ field: 'a', op: 'eq', value: 1 }] }),
238+
sort: JSON.stringify([{ field: 'a', direction: 'asc' }]),
239+
})
240+
expect(parsed.filter).toEqual({ all: [{ field: 'a', op: 'eq', value: 1 }] })
241+
expect(parsed.sort).toEqual([{ field: 'a', direction: 'asc' }])
242+
243+
const legacy = rowQueryStringSchemaProbe({
244+
workspaceId: 'ws-1',
245+
filter: JSON.stringify({ status: { $eq: 'x' } }),
246+
sort: JSON.stringify({ status: 'desc' }),
247+
})
248+
expect(legacy.filter).toEqual({ status: { $eq: 'x' } })
249+
expect(legacy.sort).toEqual({ status: 'desc' })
250+
})
251+
252+
it('still rejects a non-JSON garbage string with the real schema error', () => {
253+
expect(() => rowQueryStringSchemaProbe({ workspaceId: 'ws-1', filter: 'not json' })).toThrow()
254+
})
255+
})
256+
257+
function rowQueryStringSchemaProbe(input: Record<string, unknown>) {
258+
const result = tableRowsQuerySchema.safeParse(input)
259+
if (!result.success) throw new Error(JSON.stringify(result.error.issues[0]))
260+
return result.data
261+
}

apps/sim/lib/api/contracts/tables.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -545,20 +545,37 @@ export const deleteTableRowsBodySchema = z
545545
message: 'Provide either filter or rowIds, but not both',
546546
})
547547

548+
/**
549+
* Query-param transport for a structured value: `requestJson` serializes
550+
* objects/arrays into a single JSON-string param, and this decodes it before
551+
* the real schema runs. Non-JSON strings pass through untouched so the inner
552+
* schema produces the real error; already-parsed values (POST bodies reusing a
553+
* schema) are untouched too.
554+
*/
555+
const jsonQueryValue = <S extends z.ZodType>(schema: S) =>
556+
z.preprocess((value) => {
557+
if (typeof value !== 'string' || value === '') return value
558+
try {
559+
return JSON.parse(value)
560+
} catch {
561+
return value
562+
}
563+
}, schema)
564+
548565
/** Unrefined base so v1 contracts can `.extend()` — consumers use {@link tableRowsQuerySchema}. */
549566
export const tableRowsQueryBaseSchema = z.object({
550567
workspaceId: workspaceIdSchema,
551568
// Dual-grammar during the v2 transition: the strict predicate tree wins the
552569
// union; anything else falls through to the legacy `$`-object. Same for sort:
553570
// an ordered spec array vs the legacy `{col: dir}` record.
554-
filter: z.union([predicateSchema, domainObjectSchema<Filter>()]).optional(),
555-
sort: z.union([sortSpecSchema, domainObjectSchema<Sort>()]).optional(),
571+
filter: jsonQueryValue(z.union([predicateSchema, domainObjectSchema<Filter>()])).optional(),
572+
sort: jsonQueryValue(z.union([sortSpecSchema, domainObjectSchema<Sort>()])).optional(),
556573
/**
557574
* Keyset cursor `(orderKey, id)` for the default row order — each page is an index seek
558575
* instead of OFFSET's scan-and-discard. Mutually exclusive with `sort` (cursors only make
559576
* sense on the default order); takes precedence over `offset`.
560577
*/
561-
after: domainObjectSchema<TableRowsCursor>().optional(),
578+
after: jsonQueryValue(domainObjectSchema<TableRowsCursor>()).optional(),
562579
limit: z
563580
.preprocess(
564581
(value) =>
@@ -865,8 +882,8 @@ export type RowQueryResponse = ContractJsonResponse<typeof rowQueryContract>
865882
export const findTableRowsQuerySchema = z.object({
866883
workspaceId: workspaceIdSchema,
867884
q: requiredFieldSchema('Search query is required'),
868-
filter: z.union([predicateSchema, domainObjectSchema<Filter>()]).optional(),
869-
sort: z.union([sortSpecSchema, domainObjectSchema<Sort>()]).optional(),
885+
filter: jsonQueryValue(z.union([predicateSchema, domainObjectSchema<Filter>()])).optional(),
886+
sort: jsonQueryValue(z.union([sortSpecSchema, domainObjectSchema<Sort>()])).optional(),
870887
})
871888

872889
/** One matching cell: its 0-based ordinal in the filtered+sorted view, its row id, and the column name. */

0 commit comments

Comments
 (0)