Skip to content

Commit 56d0ee7

Browse files
committed
Merge remote-tracking branch 'origin/staging' into staging-v11
Resolves the route-count baseline in scripts/check-api-validation-contracts.ts, which git merged silently but wrongly. Both sides independently bumped BASELINE.totalRoutes/zodRoutes 994 -> 997, three routes each; the literal is identical on both sides, so git takes it as a convergent change with no conflict while the merged tree actually holds 1000 routes. Set to 1000/1000/0 -- merge-base plus both deltas -- and proved by the audit's own scan rather than the arithmetic: check:api-validation:strict reports total routes: 1000 against baseline: total=1000, and fails when the scan exceeds the baseline.
2 parents 4e21e64 + 32293f4 commit 56d0ee7

93 files changed

Lines changed: 7380 additions & 561 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/openapi-v2-tables.json

Lines changed: 381 additions & 0 deletions
Large diffs are not rendered by default.

apps/docs/openapi.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3405,7 +3405,7 @@
34053405
"name": "sort",
34063406
"in": "query",
34073407
"required": false,
3408-
"description": "JSON-encoded sort object. Example: {\"created_at\": \"desc\"}.",
3408+
"description": "JSON-encoded sort object. Example: {\"createdAt\": \"desc\"}. Built-in columns are camelCase: id, createdAt, updatedAt.",
34093409
"schema": {
34103410
"type": "string"
34113411
}

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ 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'
9+
import { toLegacyFilter } from '@/lib/table/query-builder/converters'
810
import { cancelWorkflowGroupRuns } from '@/lib/table/workflow-columns'
911
import { accessError, checkAccess, tableFilterError } from '@/app/api/table/utils'
1012

@@ -32,7 +34,10 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
3234
const parsed = await parseRequest(cancelTableRunsContract, request, { params })
3335
if (!parsed.success) return parsed.response
3436
const { tableId } = parsed.data.params
35-
const { workspaceId, scope, rowId, filter, excludeRowIds } = parsed.data.body
37+
const { workspaceId, scope, rowId, filter: wireFilter, excludeRowIds } = parsed.data.body
38+
// Dual-grammar wire: a predicate downgrades losslessly-or-throws to the
39+
// legacy Filter the runners/persisted payloads still compile.
40+
const filter = toLegacyFilter(wireFilter)
3641

3742
const result = await checkAccess(tableId, authResult.userId, 'write')
3843
if (!result.ok) return accessError(result, requestId, tableId)
@@ -42,7 +47,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
4247
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
4348
}
4449

45-
const filterError = tableFilterError(filter, table.schema.columns)
50+
const filterError = tableFilterError(wireFilter, table.schema.columns)
4651
if (filterError) return filterError
4752

4853
const cancelled = await cancelWorkflowGroupRuns(tableId, scope === 'row' ? rowId : undefined, {
@@ -57,6 +62,11 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
5762

5863
return NextResponse.json({ success: true, data: { cancelled } })
5964
} 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+
}
6070
logger.error(`[${requestId}] cancel-runs failed:`, error)
6171
return NextResponse.json({ error: 'Failed to cancel runs' }, { status: 500 })
6272
}

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

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ 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'
9+
import { toLegacyFilter } from '@/lib/table/query-builder/converters'
810
import { runWorkflowColumn } from '@/lib/table/workflow-columns'
911
import { accessError, checkAccess, tableFilterError } from '@/app/api/table/utils'
1012

@@ -25,13 +27,23 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
2527
const parsed = await parseRequest(runColumnContract, request, { params })
2628
if (!parsed.success) return parsed.response
2729
const { tableId } = parsed.data.params
28-
const { workspaceId, groupIds, runMode, rowIds, filter, excludeRowIds, limit } =
29-
parsed.data.body
30+
const {
31+
workspaceId,
32+
groupIds,
33+
runMode,
34+
rowIds,
35+
filter: wireFilter,
36+
excludeRowIds,
37+
limit,
38+
} = parsed.data.body
39+
// Dual-grammar wire: downgrade a predicate to the legacy Filter the
40+
// dispatcher and scheduled runs still compile.
41+
const filter = toLegacyFilter(wireFilter)
3042
const access = await checkAccess(tableId, auth.userId, 'write')
3143
if (!access.ok) return accessError(access, requestId, tableId)
3244

3345
// Validate the filter up front (the dispatcher reuses it) so a bad field fails fast.
34-
const filterError = tableFilterError(filter, access.table.schema.columns)
46+
const filterError = tableFilterError(wireFilter, access.table.schema.columns)
3547
if (filterError) return filterError
3648

3749
const { dispatchId } = await runWorkflowColumn({
@@ -49,6 +61,11 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
4961

5062
return NextResponse.json({ success: true, data: { dispatchId } })
5163
} 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+
}
5269
if (error instanceof Error && error.message === 'Invalid workspace ID') {
5370
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
5471
}

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: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@ 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'
16+
import { toLegacyFilter } from '@/lib/table/query-builder/converters'
1417
import type { TableDeleteJobPayload } from '@/lib/table/types'
1518
import { accessError, checkAccess, tableFilterError } from '@/app/api/table/utils'
1619

@@ -43,7 +46,20 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
4346
const parsed = await parseRequest(deleteTableRowsAsyncContract, request, { params })
4447
if (!parsed.success) return parsed.response
4548
const { tableId } = parsed.data.params
46-
const { workspaceId, filter, excludeRowIds, estimatedCount } = parsed.data.body
49+
const { workspaceId, filter: wireFilter, excludeRowIds, estimatedCount } = parsed.data.body
50+
// Dual-grammar wire: a predicate downgrades losslessly-or-throws to the
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+
}
4763

4864
const access = await checkAccess(tableId, userId, 'write')
4965
if (!access.ok) return accessError(access, requestId, tableId)
@@ -62,7 +78,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
6278
assertRowDelete(table)
6379

6480
// Validate the filter up front so the caller gets immediate feedback (the worker reuses it).
65-
const filterError = tableFilterError(filter, table.schema.columns)
81+
const filterError = tableFilterError(wireFilter, table.schema.columns)
6682
if (filterError) return filterError
6783

6884
// Rows inserted after this instant are spared (created_at <= cutoff in the worker).

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,9 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou
111111
}
112112
}
113113

114-
if (result.rows.length < EXPORT_BATCH_SIZE) break
114+
// A page can be cut by the byte budget before reaching EXPORT_BATCH_SIZE,
115+
// so a short page does NOT mean the export is done — only a null cursor does.
116+
if (!result.nextCursor) break
115117
offset += result.rows.length
116118
}
117119

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
/**
2+
* @vitest-environment node
3+
*
4+
* v2 query route: predicate parsing, unconditional name→id translation
5+
* (session auth included — the string grammar is name-keyed for every caller),
6+
* cursor validation, and the response envelope.
7+
*/
8+
import { hybridAuthMockFns } from '@sim/testing'
9+
import { NextRequest } from 'next/server'
10+
import { beforeEach, describe, expect, it, vi } from 'vitest'
11+
import type { TableDefinition } from '@/lib/table/types'
12+
13+
const { mockCheckAccess, mockQueryRows, mockGate } = vi.hoisted(() => ({
14+
mockCheckAccess: vi.fn(),
15+
mockQueryRows: vi.fn(),
16+
mockGate: vi.fn(),
17+
}))
18+
19+
vi.mock('@/app/api/table/utils', async () => {
20+
const { NextResponse } = await import('next/server')
21+
return {
22+
checkAccess: mockCheckAccess,
23+
accessError: (result: { status: number }) =>
24+
NextResponse.json({ error: 'Access denied' }, { status: result.status }),
25+
tablesV2GateError: mockGate,
26+
}
27+
})
28+
29+
vi.mock('@/lib/table', async () => {
30+
// row-wire pulls the column-keys helpers through this barrel.
31+
const columnKeys = await import('@/lib/table/column-keys')
32+
return { ...columnKeys }
33+
})
34+
35+
vi.mock('@/lib/table/rows/service', () => ({
36+
queryRows: mockQueryRows,
37+
}))
38+
39+
import { encodeCursor } from '@/lib/table/rows/cursor'
40+
import { POST } from '@/app/api/table/[tableId]/query/route'
41+
42+
function buildTable(): TableDefinition {
43+
return {
44+
id: 'tbl_1',
45+
name: 'People',
46+
description: null,
47+
schema: {
48+
columns: [
49+
{ id: 'col_aaa', name: 'name', type: 'string' },
50+
{ id: 'col_bbb', name: 'wins', type: 'number' },
51+
],
52+
},
53+
metadata: null,
54+
rowCount: 0,
55+
maxRows: 100,
56+
workspaceId: 'workspace-1',
57+
createdBy: 'user-1',
58+
archivedAt: null,
59+
createdAt: new Date('2024-01-01'),
60+
updatedAt: new Date('2024-01-01'),
61+
}
62+
}
63+
64+
function authAs(authType: 'session' | 'internal_jwt') {
65+
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
66+
success: true,
67+
userId: 'user-1',
68+
authType,
69+
})
70+
}
71+
72+
function callQuery(body: Record<string, unknown>) {
73+
const req = new NextRequest('http://localhost:3000/api/table/tbl_1/query', {
74+
method: 'POST',
75+
headers: { 'Content-Type': 'application/json' },
76+
body: JSON.stringify(body),
77+
})
78+
return POST(req, { params: Promise.resolve({ tableId: 'tbl_1' }) })
79+
}
80+
81+
const EMPTY_RESULT = {
82+
rows: [],
83+
rowCount: 0,
84+
totalCount: 0,
85+
limit: 0,
86+
offset: 0,
87+
nextCursor: null,
88+
}
89+
90+
describe('POST /api/table/[tableId]/query', () => {
91+
beforeEach(() => {
92+
vi.clearAllMocks()
93+
mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() })
94+
mockQueryRows.mockResolvedValue(EMPTY_RESULT)
95+
mockGate.mockResolvedValue(null)
96+
})
97+
98+
it('returns 404 when the tables-v2-api flag is off', async () => {
99+
const { NextResponse } = await import('next/server')
100+
authAs('session')
101+
mockGate.mockResolvedValue(NextResponse.json({ error: 'Not found' }, { status: 404 }))
102+
const res = await callQuery({ workspaceId: 'workspace-1' })
103+
expect(res.status).toBe(404)
104+
expect(mockQueryRows).not.toHaveBeenCalled()
105+
})
106+
107+
it('runs the flag gate only after the access check, so it cannot leak a cohort oracle', async () => {
108+
authAs('session')
109+
mockCheckAccess.mockResolvedValue({ ok: false, status: 403 })
110+
const res = await callQuery({ workspaceId: 'workspace-1' })
111+
expect(res.status).toBe(403)
112+
expect(mockGate).not.toHaveBeenCalled()
113+
})
114+
115+
it('translates predicate/sort column names to storage ids for SESSION auth too', async () => {
116+
authAs('session')
117+
const res = await callQuery({
118+
workspaceId: 'workspace-1',
119+
predicate: {
120+
all: [
121+
{ field: 'name', op: 'eq', value: 'John' },
122+
{ field: 'wins', op: 'gte', value: 10 },
123+
],
124+
},
125+
sort: [{ field: 'wins', direction: 'desc' }],
126+
})
127+
128+
expect(res.status).toBe(200)
129+
const options = mockQueryRows.mock.calls[0][1]
130+
expect(options.predicate).toEqual({
131+
all: [
132+
{ field: 'col_aaa', op: 'eq', value: 'John' },
133+
{ field: 'col_bbb', op: 'gte', value: 10 },
134+
],
135+
})
136+
expect(options.sort).toEqual({ col_bbb: 'desc' })
137+
expect(options.withExecutions).toBe(false)
138+
})
139+
140+
it('rejects a keyset cursor combined with a custom sort', async () => {
141+
authAs('internal_jwt')
142+
const cursor = encodeCursor({
143+
lastRow: { id: 'row_1', orderKey: 'a1' },
144+
keysetValid: true,
145+
nextOffset: 1,
146+
})
147+
const res = await callQuery({
148+
workspaceId: 'workspace-1',
149+
sort: [{ field: 'wins', direction: 'desc' }],
150+
cursor,
151+
})
152+
153+
expect(res.status).toBe(400)
154+
const body = await res.json()
155+
expect(body.error).toMatch(/not valid for a sorted query/)
156+
expect(body.code).toBe('CURSOR_SORT_CONFLICT')
157+
expect(mockQueryRows).not.toHaveBeenCalled()
158+
})
159+
160+
it('returns 400 (not 500) for a cursor that decodes to a JSON primitive', async () => {
161+
authAs('internal_jwt')
162+
const res = await callQuery({
163+
workspaceId: 'workspace-1',
164+
cursor: Buffer.from('42').toString('base64url'),
165+
})
166+
167+
expect(res.status).toBe(400)
168+
const body = await res.json()
169+
expect(body.error).toBe('Invalid cursor')
170+
expect(body.code).toBe('INVALID_CURSOR')
171+
})
172+
173+
it('returns 400 for a predicate referencing an unknown column', async () => {
174+
authAs('internal_jwt')
175+
const res = await callQuery({
176+
workspaceId: 'workspace-1',
177+
predicate: { all: [{ field: 'nope', op: 'eq', value: 1 }] },
178+
})
179+
180+
expect(res.status).toBe(400)
181+
expect((await res.json()).error).toMatch(/Unknown filter column/)
182+
})
183+
184+
it('passes nextCursor through the response envelope and skips the count on later pages', async () => {
185+
authAs('internal_jwt')
186+
mockQueryRows.mockResolvedValue({ ...EMPTY_RESULT, nextCursor: 'tok' })
187+
const cursor = encodeCursor({
188+
lastRow: { id: 'row_1', orderKey: 'a1' },
189+
keysetValid: true,
190+
nextOffset: 1,
191+
})
192+
193+
const res = await callQuery({ workspaceId: 'workspace-1', cursor })
194+
195+
expect(res.status).toBe(200)
196+
const body = await res.json()
197+
expect(body.data.nextCursor).toBe('tok')
198+
const options = mockQueryRows.mock.calls[0][1]
199+
expect(options.includeTotal).toBe(false)
200+
expect(options.after).toEqual({ orderKey: 'a1', id: 'row_1' })
201+
})
202+
})

0 commit comments

Comments
 (0)