Skip to content

Commit 2546a09

Browse files
refactor(table): move the grid onto the v2 predicate grammar (Track C)
The grid was the last surface authoring the legacy `$`-grammar, which forced views to downgrade on apply and upgrade on save, and kept five wire fields legacy-only. Its runtime state, filter bar, and every request it makes now speak `TablePredicate`/`SortSpec`. Wire: the five grid-carrying fields (rows GET filter+sort, find filter+sort, delete-async, cancel-runs, columns-run) accept a dual-grammar union — strict predicate tree first, legacy fallback — so external v1 callers are untouched. The rows read path takes predicates NATIVELY into queryRows (no downgrade); the job/dispatch routes downgrade via predicateToFilter at entry, which throws on any leaf the legacy compiler would silently discard, so persisted job payloads stay legacy and the runners are untouched. Grid: filter state is TablePredicate, the filter bar converts rules with filterRulesToPredicate — now select-aware (a numeric-looking option id is no longer scalar-coerced, matching filterRulesToFilter) — and stale-operator pruning uses a new prunePredicateForColumns that fails CLOSED to "no filter" on malformed values instead of taking the page down. The view apply/save boundary conversions added earlier are deleted: views and grid now share one grammar end to end. isTablePredicate moved from a route-local into converters as the shared dual-wire discriminator; toLegacyFilter/toLegacySort live there too (pure grammar code — keeping them in app/api/table/utils broke every test that wholesale-mocks that module). Legacy converters now have exactly one live consumer: the v1 table block, whose tools still speak the $-wire by contract. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011M563cbFy2S74GvSDf2C3R
1 parent 1d50208 commit 2546a09

17 files changed

Lines changed: 300 additions & 89 deletions

File tree

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

Lines changed: 5 additions & 1 deletion
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 { toLegacyFilter } from '@/lib/table/query-builder/converters'
89
import { cancelWorkflowGroupRuns } from '@/lib/table/workflow-columns'
910
import { accessError, checkAccess, tableFilterError } from '@/app/api/table/utils'
1011

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

3741
const result = await checkAccess(tableId, authResult.userId, 'write')
3842
if (!result.ok) return accessError(result, requestId, tableId)

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

Lines changed: 13 additions & 2 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 { toLegacyFilter } from '@/lib/table/query-builder/converters'
89
import { runWorkflowColumn } from '@/lib/table/workflow-columns'
910
import { accessError, checkAccess, tableFilterError } from '@/app/api/table/utils'
1011

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

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1111
import { markTableDeleteFailed, runTableDelete } from '@/lib/table/delete-runner'
1212
import { markTableJobRunning, releaseJobClaim } from '@/lib/table/jobs/service'
1313
import { assertRowDelete } from '@/lib/table/mutation-locks'
14+
import { toLegacyFilter } from '@/lib/table/query-builder/converters'
1415
import type { TableDeleteJobPayload } from '@/lib/table/types'
1516
import { accessError, checkAccess, tableFilterError } from '@/app/api/table/utils'
1617

@@ -43,7 +44,10 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
4344
const parsed = await parseRequest(deleteTableRowsAsyncContract, request, { params })
4445
if (!parsed.success) return parsed.response
4546
const { tableId } = parsed.data.params
46-
const { workspaceId, filter, excludeRowIds, estimatedCount } = parsed.data.body
47+
const { workspaceId, filter: wireFilter, excludeRowIds, estimatedCount } = parsed.data.body
48+
// 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)
4751

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

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@ import { isZodError, validationErrorResponse } from '@/lib/api/server/validation
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 type { Sort } from '@/lib/table'
8+
import type { Filter, Sort, SortSpec, TablePredicate } from '@/lib/table'
99
import { TableQueryValidationError } from '@/lib/table/errors'
10+
import { toLegacyFilter, toLegacySort } from '@/lib/table/query-builder/converters'
1011
import { findRowMatches } from '@/lib/table/rows/service'
1112
import { accessError, checkAccess } from '@/app/api/table/utils'
1213

@@ -60,7 +61,12 @@ export const GET = withRouteHandler(
6061

6162
const { matches, truncated } = await findRowMatches(
6263
table,
63-
{ q: validated.q, filter: validated.filter, sort: validated.sort },
64+
{
65+
q: validated.q,
66+
// Dual-grammar wire: findRowMatches compiles the legacy pair.
67+
filter: toLegacyFilter(validated.filter as Filter | TablePredicate | undefined),
68+
sort: toLegacySort(validated.sort as Sort | SortSpec | undefined),
69+
},
6470
requestId
6571
)
6672

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,42 @@ describe('GET /api/table/[tableId]/rows', () => {
226226
const body = await res.json()
227227
expect(body.data.rows[0].data).toEqual({ col_aaa: 'Ada', col_bbb: 36 })
228228
})
229+
230+
/**
231+
* The grid now speaks the v2 grammar on this route: a predicate-shaped filter
232+
* takes the NATIVE predicate path into queryRows (not a downgrade), and an
233+
* ordered sort spec compiles to the record the engine's sort builder takes.
234+
*/
235+
it('routes a predicate filter + spec sort natively for session callers', async () => {
236+
authAs('session')
237+
238+
const res = await callGet({
239+
workspaceId: 'workspace-1',
240+
filter: JSON.stringify({ all: [{ field: 'col_aaa', op: 'eq', value: 'Ada' }] }),
241+
sort: JSON.stringify([{ field: 'col_bbb', direction: 'desc' }]),
242+
})
243+
244+
expect(res.status).toBe(200)
245+
const options = mockQueryRows.mock.calls[0][1]
246+
expect(options.predicate).toEqual({ all: [{ field: 'col_aaa', op: 'eq', value: 'Ada' }] })
247+
expect(options.filter).toBeUndefined()
248+
expect(options.sort).toEqual({ col_bbb: 'desc' })
249+
})
250+
251+
it('translates a name-keyed predicate for internal-JWT callers', async () => {
252+
authAs('internal_jwt')
253+
254+
const res = await callGet({
255+
workspaceId: 'workspace-1',
256+
filter: JSON.stringify({ all: [{ field: 'Name', op: 'eq', value: 'Ada' }] }),
257+
sort: JSON.stringify([{ field: 'Age', direction: 'asc' }]),
258+
})
259+
260+
expect(res.status).toBe(200)
261+
const options = mockQueryRows.mock.calls[0][1]
262+
expect(options.predicate).toEqual({ all: [{ field: 'col_aaa', op: 'eq', value: 'Ada' }] })
263+
expect(options.sort).toEqual({ col_bbb: 'asc' })
264+
})
229265
})
230266

231267
describe('PUT/DELETE /api/table/[tableId]/rows — predicate filters', () => {

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

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { isZodError, parseJsonBody, validationErrorResponse } from '@/lib/api/se
1313
import { type AuthTypeValue, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
1414
import { generateRequestId } from '@/lib/core/utils/request'
1515
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
16-
import type { Filter, RowData, Sort, TableRowsCursor, TableSchema } from '@/lib/table'
16+
import type { Filter, RowData, Sort, SortSpec, TableRowsCursor, TableSchema } from '@/lib/table'
1717
import {
1818
batchInsertRows,
1919
batchUpdateRows,
@@ -26,7 +26,7 @@ import {
2626
validateRowSize,
2727
} from '@/lib/table'
2828
import { TableQueryValidationError } from '@/lib/table/errors'
29-
import { predicateToFilter } from '@/lib/table/query-builder/converters'
29+
import { isTablePredicate, predicateToFilter } from '@/lib/table/query-builder/converters'
3030
import { validatePredicate } from '@/lib/table/query-builder/validate'
3131
import { queryRows } from '@/lib/table/rows/service'
3232
import { predicateToStorage } from '@/lib/table/select-values'
@@ -36,8 +36,15 @@ import { accessError, checkAccess, rowWriteErrorResponse } from '@/app/api/table
3636

3737
const logger = createLogger('TableRowsAPI')
3838

39-
function isTablePredicate(raw: TablePredicate | Filter): raw is TablePredicate {
40-
return 'all' in raw || 'any' in raw
39+
/** Dual-grammar sort: an ordered spec (v2) or the legacy record, either keying. */
40+
function resolveWireSort(
41+
sort: Sort | SortSpec | undefined,
42+
wire: RowWireTranslators
43+
): Sort | undefined {
44+
if (!sort) return undefined
45+
if (!Array.isArray(sort)) return wire.sortIn(sort)
46+
const spec = wire.sortSpecIn(sort)
47+
return spec.length > 0 ? Object.fromEntries(spec.map((s) => [s.field, s.direction])) : undefined
4148
}
4249

4350
/**
@@ -286,8 +293,10 @@ export const GET = withRouteHandler(
286293
const result = await queryRows(
287294
table,
288295
{
289-
filter: validated.filter ? wire.filterIn(validated.filter as Filter) : undefined,
290-
sort: validated.sort ? wire.sortIn(validated.sort) : undefined,
296+
...(validated.filter && isTablePredicate(validated.filter as Filter | TablePredicate)
297+
? { predicate: wire.predicateIn(validated.filter as TablePredicate) }
298+
: { filter: validated.filter ? wire.filterIn(validated.filter as Filter) : undefined }),
299+
sort: resolveWireSort(validated.sort as Sort | SortSpec | undefined, wire),
291300
limit: validated.limit,
292301
offset: validated.offset,
293302
after: validated.after,

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,18 @@ import { memo, useCallback, useMemo, useRef, useState } from 'react'
44
import { Button, ChipDropdown, ChipInput } from '@sim/emcn'
55
import { Plus, X } from '@sim/emcn/icons'
66
import { generateShortId } from '@sim/utils/id'
7-
import type { ColumnDefinition, Filter, FilterRule } from '@/lib/table'
7+
import type { ColumnDefinition, FilterRule, TablePredicate } from '@/lib/table'
88
import { getColumnId } from '@/lib/table/column-keys'
99
import {
1010
COMPARISON_OPERATORS,
1111
MULTI_SELECT_FILTER_OPERATORS,
1212
SINGLE_SELECT_FILTER_OPERATORS,
1313
VALUELESS_OPERATORS,
1414
} from '@/lib/table/query-builder/constants'
15-
import { filterRulesToFilter, filterToRules } from '@/lib/table/query-builder/converters'
15+
import {
16+
filterRulesToPredicate,
17+
predicateToFilterRules,
18+
} from '@/lib/table/query-builder/converters'
1619

1720
const SINGLE_SELECT_COMPARISON_OPERATORS = COMPARISON_OPERATORS.filter((o) =>
1821
SINGLE_SELECT_FILTER_OPERATORS.has(o.value)
@@ -27,14 +30,14 @@ function selectFilterOperators(column: ColumnDefinition | undefined): Set<string
2730

2831
interface TableFilterProps {
2932
columns: ColumnDefinition[]
30-
filter: Filter | null
31-
onApply: (filter: Filter | null) => void
33+
filter: TablePredicate | null
34+
onApply: (filter: TablePredicate | null) => void
3235
onClose: () => void
3336
}
3437

3538
export function TableFilter({ columns, filter, onApply, onClose }: TableFilterProps) {
3639
const [rules, setRules] = useState<FilterRule[]>(() => {
37-
const fromFilter = filterToRules(filter)
40+
const fromFilter = predicateToFilterRules(filter)
3841
return fromFilter.length > 0 ? fromFilter : [createRule(columns)]
3942
})
4043

@@ -112,7 +115,7 @@ export function TableFilter({ columns, filter, onApply, onClose }: TableFilterPr
112115
const validRules = rulesRef.current.filter(
113116
(r) => r.column && (r.value || VALUELESS_OPERATORS.has(r.operator))
114117
)
115-
onApply(filterRulesToFilter(validRules, columns))
118+
onApply(filterRulesToPredicate(validRules, columns))
116119
}, [columns, onApply])
117120

118121
const handleClear = useCallback(() => {

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ import type { RunLimit, RunMode, TableFindMatch } from '@/lib/api/contracts/tabl
1313
import { captureEvent } from '@/lib/posthog/client'
1414
import type {
1515
ColumnDefinition,
16-
Filter,
1716
TableLocks,
1817
TableMetadata,
18+
TablePredicate,
1919
TableRow as TableRowType,
2020
WorkflowGroup,
2121
} from '@/lib/table'
@@ -121,7 +121,7 @@ export interface SelectionSnapshot {
121121
allRows: boolean
122122
rowCount: number
123123
/** Active filter when `allRows` is set — lets a filtered "select all" run only matching rows. */
124-
filter?: Filter
124+
filter?: TablePredicate
125125
/** Deselected rows when `allRows` is set — runs/stops skip them. */
126126
excludeRowIds?: string[]
127127
} | null
@@ -199,7 +199,7 @@ interface TableGridProps {
199199
runMode: RunMode,
200200
rowIds?: string[],
201201
limit?: RunLimit,
202-
filter?: Filter,
202+
filter?: TablePredicate,
203203
excludeRowIds?: string[]
204204
) => void
205205
/** Fire every runnable column on a single row (per-row gutter Play). */
@@ -209,14 +209,14 @@ interface TableGridProps {
209209
onRunRows: (
210210
rowIds: string[] | undefined,
211211
runMode: RunMode,
212-
filter?: Filter,
212+
filter?: TablePredicate,
213213
excludeRowIds?: string[]
214214
) => void
215215
/** Stop running workflows on `rowIds`. Per-row gutter Stop also funnels through here. */
216216
onStopRows: (rowIds: string[]) => void
217217
/** Select-all Stop: table-wide, or scoped to the active filter when one is set.
218218
* `excludeRowIds` (deselected rows) keep running. */
219-
onStopAllRows: (filter?: Filter, excludeRowIds?: string[]) => void
219+
onStopAllRows: (filter?: TablePredicate, excludeRowIds?: string[]) => void
220220
/** Single-row stop for the per-row gutter button. */
221221
onStopRow: (rowId: string) => void
222222
/**

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ describe('useTable – ensureAllRowsLoaded', () => {
218218
})
219219

220220
it('encodes queryOptions.filter into the queryKey passed to getQueryData', async () => {
221-
const filter = { column: 'name', operator: 'eq', value: 'Alice' } as never
221+
const filter = { all: [{ field: 'name', op: 'eq' as const, value: 'Alice' }] }
222222
mockGetQueryData.mockReturnValue({ pages: makePages([3], 3) })
223223
const { ensureAllRowsLoaded } = makeHook({ filter, sort: null })
224224
await ensureAllRowsLoaded()

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@ import { useCallback, useMemo } from 'react'
44
import { useQueryClient } from '@tanstack/react-query'
55
import type {
66
ColumnDefinition,
7-
Filter,
87
TableDefinition,
8+
TablePredicate,
99
TableRow,
1010
WorkflowGroup,
1111
} from '@/lib/table'
1212
import { TABLE_LIMITS } from '@/lib/table/constants'
13-
import { pruneFilterForColumns } from '@/lib/table/query-builder/converters'
13+
import { prunePredicateForColumns } from '@/lib/table/query-builder/converters'
1414
import type { FlattenOutputsBlockInput } from '@/lib/workflows/blocks/flatten-outputs'
1515
import { getBlock } from '@/blocks'
1616
import {
@@ -51,7 +51,7 @@ export interface UseTableReturn {
5151
* select-all run/stop/delete — must scope with THIS, not the raw filter, or
5252
* the action targets a predicate the grid isn't displaying.
5353
*/
54-
filter: Filter | null
54+
filter: TablePredicate | null
5555
isLoadingRows: boolean
5656
refetchRows: () => void
5757
/**
@@ -98,7 +98,7 @@ export function useTable({ workspaceId, tableId, queryOptions }: UseTableParams)
9898
// here, above every consumer of the rows query key, so the paged helpers below
9999
// can't rebuild the key from the unpruned filter and drift.
100100
const filter = useMemo(
101-
() => pruneFilterForColumns(queryOptions.filter ?? null, tableData?.schema?.columns ?? []),
101+
() => prunePredicateForColumns(queryOptions.filter ?? null, tableData?.schema?.columns ?? []),
102102
[queryOptions.filter, tableData?.schema?.columns]
103103
)
104104

0 commit comments

Comments
 (0)