feat(tables): typed predicate filter grammar, cursor pagination, and the v2 table surface by TheodoreSpeaks · Pull Request #6067 · simstudioai/sim · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
47492ab
feat(table): v2 table block — predicate grammar, unified predicate en…
TheodoreSpeaks Jun 27, 2026
4c63dcf
Merge remote-tracking branch 'origin/staging' into feat/table-block-v2
TheodoreSpeaks Jun 27, 2026
d74c0c0
feat(table): switch v2 filter grammar to PostgREST strings + unbounde…
TheodoreSpeaks Jul 3, 2026
a048d02
Merge remote-tracking branch 'origin/staging' into feat/table-block-v2
TheodoreSpeaks Jul 3, 2026
da06034
feat(table): byte-bounded query drain (5MB) + PostgREST round-trip an…
TheodoreSpeaks Jul 4, 2026
0696861
Merge remote-tracking branch 'origin/staging' into feat/table-block-v2
TheodoreSpeaks Jul 4, 2026
ded16c4
feat(table): predicate-object filter grammar + cursor pagination
TheodoreSpeaks Jul 8, 2026
8f68622
Merge origin/staging into feat/table-block-v2
TheodoreSpeaks Jul 8, 2026
5d11720
feat(table): gate the v2 tables API behind the tables-v2-api flag
TheodoreSpeaks Jul 25, 2026
740ca2c
Merge origin/staging into feat/table-block-v2
TheodoreSpeaks Jul 25, 2026
2106332
feat(table): ship table_v2 as a preview block, unhide v1 Table
TheodoreSpeaks Jul 25, 2026
295d98f
fix(table): stop keyset paging from silently dropping unkeyed rows
TheodoreSpeaks Jul 25, 2026
de74b64
fix(table): close the filter paths that widen a bulk delete to the wh…
TheodoreSpeaks Jul 25, 2026
eaf4179
fix(table): reject ambiguous and mixed-grammar filter nodes
TheodoreSpeaks Jul 25, 2026
9b5fe21
fix(table): system columns, rollout safety, and DoS bounds for v2
TheodoreSpeaks Jul 25, 2026
ead6391
fix(table): name the grammar mistake when a legacy $-filter reaches t…
TheodoreSpeaks Jul 25, 2026
705ea6f
fix(table): reject hybrid predicate nodes at the wire instead of sile…
TheodoreSpeaks Jul 25, 2026
0df4d58
fix(table): reject a v2 predicate at the legacy filter compiler
TheodoreSpeaks Jul 28, 2026
b3f3196
fix(table): treat a blank table_v2 filter/order editor field as absent
TheodoreSpeaks Jul 28, 2026
1564fdd
Merge origin/staging into feat/table-block-v2
TheodoreSpeaks Jul 28, 2026
4f9c104
fix(table): resolve select operands for contains/ncontains on multi-s…
TheodoreSpeaks Jul 28, 2026
e885854
fix(tools): stop normalizeToolId eating the _v2 version suffix
TheodoreSpeaks Jul 29, 2026
f2d964f
fix(table): make name→storage predicate translation one operation
TheodoreSpeaks Jul 29, 2026
79d0dd0
Merge origin/staging into feat/table-block-v2
TheodoreSpeaks Jul 29, 2026
e268562
fix(merge): restore staging's env-flags exports dropped by the merge …
TheodoreSpeaks Jul 29, 2026
1d50208
refactor(table): store saved views in the v2 predicate grammar
TheodoreSpeaks Jul 29, 2026
2546a09
refactor(table): move the grid onto the v2 predicate grammar (Track C)
TheodoreSpeaks Jul 29, 2026
3dfaaad
chore(tools): carry every Sim table op in the minimal dev registry
TheodoreSpeaks Jul 30, 2026
d62ad32
docs(tables): OpenAPI spec for the v2 tables read API
TheodoreSpeaks Jul 30, 2026
9d8fa6c
fix(table): close the review findings on the dual-grammar boundaries
TheodoreSpeaks Jul 30, 2026
3ebb639
fix(table): reject empty filter groups and bind cursors to their sort
TheodoreSpeaks Jul 30, 2026
c66ecef
fix(table): storage-validate async-route filters, reject dual-group n…
TheodoreSpeaks Jul 30, 2026
808bc0d
fix(table): storage-validate read-path predicates on GET rows and find
TheodoreSpeaks Jul 30, 2026
96d49b8
Merge remote-tracking branch 'origin/staging' into feat/table-block-v2
TheodoreSpeaks Jul 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
feat(table): byte-bounded query drain (5MB) + PostgREST round-trip an…
…d cursor hardening

queryRows now drains rows in adaptively-sized bounded batches instead of one
unbounded SELECT. Omitted limit returns the entire result and fails fast (400)
past the 5MB byte budget; bounded pages byte-cut early and signal remaining
rows via nextCursor (witnessed by a peek row, never inferred from page size).
Cursor codec gains a compound {k,i,o} shape for unkeyed-row resumes and a
keysetValid gate closing the fractional-flag-off keyset/order mismatch.

Council must-fix cluster:
- Serializer round-trip: whole-pattern quoting (contains with dots), backslash
  quote escaping, nlike/nilike engine ops (ncontains parses again), isEmpty
  desugars to or=(f.is.null,f.eq."") preserving null-or-empty semantics
- Unconditional name→id translation on PostgREST string paths (session-auth
  filters no longer silently match zero rows)
- Cursor decode hardening (object guard, o>=0), 400 on keyset cursor + order
- Block: cursor "null" artifact guard, NaN limit fails fast, filterBuilder
  required-flag removed (serializer hard-block with agent-set filter string)
- Parser: reject empty or=()/and=()/in.(), Number.isFinite, strict sort dirs
- TableQueryValidationError moved to client-safe lib/table/errors.ts (drops
  the drizzle-orm edge from client-bundled block defs)

Consumers: export stops on nextCursor (byte-cut pages no longer truncate),
legacy/v1 routes expose nextCursor additively, v2 route drops executions,
copilot query_rows reports partial pages with a continue offset, v1 Table
block tools registered in the minimal registry. Agent catalog regenerated
from the copilot fork (PostgREST string filter, order param, new pagination
semantics).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bd25zCzh21omjFhRz32ov6
  • Loading branch information
TheodoreSpeaks and claude committed Jul 4, 2026
commit da06034d9c8a2c547687e4f587e080e32e04d980
4 changes: 3 additions & 1 deletion apps/sim/app/api/table/[tableId]/export/route.ts
167 changes: 167 additions & 0 deletions apps/sim/app/api/table/[tableId]/query/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/**
* @vitest-environment node
*
* v2 query route: PostgREST string parsing, unconditional name→id translation
* (session auth included — the string grammar is name-keyed for every caller),
* cursor validation, and the response envelope.
*/
import { hybridAuthMockFns } from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { TableDefinition } from '@/lib/table/types'

const { mockCheckAccess, mockQueryRows } = vi.hoisted(() => ({
mockCheckAccess: 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 () => {
// row-wire pulls the column-keys helpers through this barrel.
const columnKeys = await import('@/lib/table/column-keys')
return { ...columnKeys }
})

vi.mock('@/lib/table/rows/service', () => ({
queryRows: mockQueryRows,
}))

import { encodeCursor } from '@/lib/table/rows/cursor'
import { POST } from '@/app/api/table/[tableId]/query/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: 'wins', 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 callQuery(body: Record<string, unknown>) {
const req = new NextRequest('http://localhost:3000/api/table/tbl_1/query', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
return POST(req, { params: Promise.resolve({ tableId: 'tbl_1' }) })
}

const EMPTY_RESULT = {
rows: [],
rowCount: 0,
totalCount: 0,
limit: 0,
offset: 0,
nextCursor: null,
}

describe('POST /api/table/[tableId]/query', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() })
mockQueryRows.mockResolvedValue(EMPTY_RESULT)
})

it('translates filter/order column names to storage ids for SESSION auth too', async () => {
authAs('session')
const res = await callQuery({
workspaceId: 'workspace-1',
filter: 'name=eq.John&wins=gte.10',
order: 'wins.desc',
})

expect(res.status).toBe(200)
const options = mockQueryRows.mock.calls[0][1]
expect(options.predicate).toEqual({
all: [
{ field: 'col_aaa', op: 'eq', value: 'John' },
{ field: 'col_bbb', op: 'gte', value: 10 },
],
})
expect(options.sort).toEqual({ col_bbb: 'desc' })
expect(options.withExecutions).toBe(false)
})

it('rejects a keyset cursor combined with a custom order', async () => {
authAs('internal_jwt')
const cursor = encodeCursor({
lastRow: { id: 'row_1', orderKey: 'a1' },
keysetValid: true,
nextOffset: 1,
})
const res = await callQuery({ workspaceId: 'workspace-1', order: 'wins.desc', cursor })

expect(res.status).toBe(400)
const body = await res.json()
expect(body.error).toMatch(/not valid for a sorted query/)
expect(mockQueryRows).not.toHaveBeenCalled()
})

it('returns 400 (not 500) for a cursor that decodes to a JSON primitive', async () => {
authAs('internal_jwt')
const res = await callQuery({
workspaceId: 'workspace-1',
cursor: Buffer.from('42').toString('base64url'),
})

expect(res.status).toBe(400)
expect((await res.json()).error).toBe('Invalid cursor')
})

it('returns 400 with the parser message for an invalid filter', async () => {
authAs('internal_jwt')
const res = await callQuery({ workspaceId: 'workspace-1', filter: 'wins=bogus.1' })

expect(res.status).toBe(400)
expect((await res.json()).error).toMatch(/Unknown filter operator/)
})

it('passes nextCursor through the response envelope and skips the count on later pages', async () => {
authAs('internal_jwt')
mockQueryRows.mockResolvedValue({ ...EMPTY_RESULT, nextCursor: 'tok' })
const cursor = encodeCursor({
lastRow: { id: 'row_1', orderKey: 'a1' },
keysetValid: true,
nextOffset: 1,
})

const res = await callQuery({ workspaceId: 'workspace-1', cursor })

expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.nextCursor).toBe('tok')
const options = mockQueryRows.mock.calls[0][1]
expect(options.includeTotal).toBe(false)
expect(options.after).toEqual({ orderKey: 'a1', id: 'row_1' })
})
})
26 changes: 21 additions & 5 deletions apps/sim/app/api/table/[tableId]/query/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import type { Sort, TableSchema } from '@/lib/table'
import { buildIdByName, predicateNamesToIds, sortSpecNamesToIds } from '@/lib/table/column-keys'
import { TableQueryValidationError } from '@/lib/table/errors'
import { parsePostgrestFilter, parsePostgrestOrder } from '@/lib/table/query-builder/postgrest'
import { decodeCursor } from '@/lib/table/rows/cursor'
import { queryRows } from '@/lib/table/rows/service'
import { TableQueryValidationError } from '@/lib/table/sql'
import { rowWireTranslators } from '@/app/api/table/row-wire'
import { accessError, checkAccess } from '@/app/api/table/utils'

Expand Down Expand Up @@ -55,13 +56,25 @@ export const POST = withRouteHandler(
const wire = rowWireTranslators(authResult.authType, schema)
const cursor = body.cursor ? decodeCursor(body.cursor) : undefined

// PostgREST filter/order arrive as strings (column NAMES); parse with the
// schema for type coercion, then translate names → storage ids.
// A keyset cursor encodes a position on the DEFAULT `(order_key, id)`
// order; combining it with a custom order would silently return page 1
// of the sorted view relabeled as a next page.
if (cursor?.after && body.order) {
return NextResponse.json(
{ error: 'Cursor is not valid for a sorted query. Restart paging without the cursor.' },
{ status: 400 }
)
}

// PostgREST filter/order strings are column-NAME-keyed by construction
// (the parser validates against names), so translate names → storage ids
// unconditionally — unlike row data, this is not authType-dependent.
const idByName = buildIdByName(schema)
const predicate = body.filter
? wire.predicateIn(parsePostgrestFilter(body.filter, schema.columns))
? predicateNamesToIds(parsePostgrestFilter(body.filter, schema.columns), idByName)
: undefined
const sortSpec = body.order
? wire.sortSpecIn(parsePostgrestOrder(body.order, schema.columns))
? sortSpecNamesToIds(parsePostgrestOrder(body.order, schema.columns), idByName)
: undefined
const sort: Sort | undefined = sortSpec?.length
? Object.fromEntries(sortSpec.map((s) => [s.field, s.direction]))
Expand All @@ -77,6 +90,9 @@ export const POST = withRouteHandler(
offset: cursor?.offset,
// Only the first page (no inbound cursor) pays for the total count.
includeTotal: !body.cursor,
// Executions are grid UI state; the v2 surface returns row data only
// and the byte budget deliberately measures just `data`.
withExecutions: false,
},
requestId
)
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/app/api/table/[tableId]/rows/find/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ describe('GET /api/table/[tableId]/rows/find', () => {
})

it('maps a TableQueryValidationError to 400', async () => {
const { TableQueryValidationError } = await import('@/lib/table/sql')
const { TableQueryValidationError } = await import('@/lib/table/errors')
mockFindRowMatches.mockRejectedValueOnce(new TableQueryValidationError('Invalid field name'))
const res = await callGet({ workspaceId: 'workspace-1', q: 'foo' })
expect(res.status).toBe(400)
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/app/api/table/[tableId]/rows/find/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import type { Sort } from '@/lib/table'
import { TableQueryValidationError } from '@/lib/table/errors'
import { findRowMatches } from '@/lib/table/rows/service'
import { TableQueryValidationError } from '@/lib/table/sql'
import { accessError, checkAccess } from '@/app/api/table/utils'

const logger = createLogger('TableRowsFindAPI')
Expand Down
69 changes: 65 additions & 4 deletions apps/sim/app/api/table/[tableId]/rows/route.test.ts
Loading