Skip to content

Commit f2d964f

Browse files
fix(table): make name→storage predicate translation one operation
The block's own filter — {"all":[{"field":"Color","op":"contains","value":"Teal"}]} — returned 0 rows against a table where 15 rows hold Teal. Translating a name-keyed predicate to storage keys is two steps: column names → column ids, and select operands → option ids. Both are required, neither is useful alone, but they were two separate calls each boundary had to remember to pair. Three did not: the internal query route (the table_v2 block's own path), the bulk update/delete resolver, and — before this branch — nothing else needed it, so the gap was invisible until select columns landed. Replaced with a single `predicateToStorage(predicate, schema)` and migrated every call site, so the pair cannot be split again. `predicateNamesToIds` now has no direct callers outside it. Also fixes four type errors that a failed inference in contracts/tables.ts was masking — once the leaf schema type-checked, tsc surfaced the rest: - `ColumnType` was imported from lib/table/types but never exported there (it lived as a local alias in sql.ts). Now exported once, next to ColumnDefinition. - rows/service.ts referenced TableRowsCursor in three signatures without importing it. - export-runner and snapshot-cache still declared their paging cursor's orderKey as non-null, after selectExportRowPage was corrected to return the nullable it always had. - the predicate leaf's `z.unknown()` value infers wider than Predicate['value']; narrowed with an annotated cast, runtime unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011M563cbFy2S74GvSDf2C3R
1 parent e885854 commit f2d964f

12 files changed

Lines changed: 51 additions & 37 deletions

File tree

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
77
import { generateRequestId } from '@/lib/core/utils/request'
88
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
99
import type { Sort, TableSchema } from '@/lib/table'
10-
import { buildIdByName, predicateNamesToIds, sortSpecNamesToIds } from '@/lib/table/column-keys'
10+
import { buildIdByName, sortSpecNamesToIds } from '@/lib/table/column-keys'
1111
import { TableQueryValidationError } from '@/lib/table/errors'
1212
import { validatePredicate, validateSortSpec } from '@/lib/table/query-builder/validate'
1313
import { decodeCursor } from '@/lib/table/rows/cursor'
1414
import { queryRows } from '@/lib/table/rows/service'
15+
import { predicateToStorage } from '@/lib/table/select-values'
1516
import { rowWireTranslators } from '@/app/api/table/row-wire'
1617
import { accessError, checkAccess, tablesV2GateError } from '@/app/api/table/utils'
1718

@@ -82,7 +83,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: RowQu
8283
let predicate = body.predicate
8384
if (predicate) {
8485
validatePredicate(predicate, schema.columns)
85-
predicate = predicateNamesToIds(predicate, idByName)
86+
predicate = predicateToStorage(predicate, schema)
8687
}
8788
let sortSpec = body.sort
8889
if (sortSpec?.length) {

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,11 @@ import {
2525
validateRowData,
2626
validateRowSize,
2727
} from '@/lib/table'
28-
import { buildIdByName, predicateNamesToIds } from '@/lib/table/column-keys'
2928
import { TableQueryValidationError } from '@/lib/table/errors'
3029
import { predicateToFilter } from '@/lib/table/query-builder/converters'
3130
import { validatePredicate } from '@/lib/table/query-builder/validate'
3231
import { queryRows } from '@/lib/table/rows/service'
32+
import { predicateToStorage } from '@/lib/table/select-values'
3333
import type { TablePredicate } from '@/lib/table/types'
3434
import { type RowWireTranslators, rowWireTranslators } from '@/app/api/table/row-wire'
3535
import { accessError, checkAccess, rowWriteErrorResponse } from '@/app/api/table/utils'
@@ -54,7 +54,7 @@ function resolveBulkFilter(
5454
): Filter {
5555
if (isTablePredicate(raw)) {
5656
validatePredicate(raw, schema.columns)
57-
return predicateToFilter(predicateNamesToIds(raw, buildIdByName(schema)))
57+
return predicateToFilter(predicateToStorage(raw, schema))
5858
}
5959
return wire.filterIn(raw)
6060
}

apps/sim/app/api/table/row-wire.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,11 @@ import { namedRowMapper } from '@/lib/table/cell-format'
44
import {
55
buildIdByName,
66
filterNamesToIds,
7-
predicateNamesToIds,
87
rowDataNameToId,
98
sortNamesToIds,
109
sortSpecNamesToIds,
1110
} from '@/lib/table/column-keys'
12-
import { resolveFilterSelectValues, resolvePredicateSelectValues } from '@/lib/table/select-values'
11+
import { predicateToStorage, resolveFilterSelectValues } from '@/lib/table/select-values'
1312

1413
export interface RowWireTranslators {
1514
/** Inbound row data: wire keys → storage column ids. */
@@ -58,8 +57,7 @@ export function rowWireTranslators(
5857
filterIn: (filter) =>
5958
resolveFilterSelectValues(filterNamesToIds(filter, idByName), schema.columns),
6059
sortIn: (sort) => sortNamesToIds(sort, idByName),
61-
predicateIn: (predicate) =>
62-
resolvePredicateSelectValues(predicateNamesToIds(predicate, idByName), schema.columns),
60+
predicateIn: (predicate) => predicateToStorage(predicate, schema),
6361
sortSpecIn: (sort) => sortSpecNamesToIds(sort, idByName),
6462
}
6563
}

apps/sim/app/api/v2/tables/[tableId]/query/route.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@ import { parseRequest, validationErrorResponseFromError } from '@/lib/api/server
66
import { generateRequestId } from '@/lib/core/utils/request'
77
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
88
import type { Sort, TablePredicate, TableSchema } from '@/lib/table'
9-
import { buildIdByName, predicateNamesToIds, sortSpecNamesToIds } from '@/lib/table'
9+
import { buildIdByName, sortSpecNamesToIds } from '@/lib/table'
1010
import { namedRowMapper } from '@/lib/table/cell-format'
1111
import { TableQueryValidationError } from '@/lib/table/errors'
1212
import { validatePredicate, validateSortSpec } from '@/lib/table/query-builder/validate'
1313
import { decodeCursor } from '@/lib/table/rows/cursor'
1414
import { queryRows } from '@/lib/table/rows/service'
15-
import { resolvePredicateSelectValues } from '@/lib/table/select-values'
15+
import { predicateToStorage } from '@/lib/table/select-values'
1616
import { accessError, checkAccess, tablesV2GateError } from '@/app/api/table/utils'
1717
import {
1818
checkRateLimit,
@@ -91,10 +91,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Query
9191
let predicate: TablePredicate | undefined = parsed.data.body.predicate
9292
if (predicate) {
9393
validatePredicate(predicate, schema.columns)
94-
predicate = resolvePredicateSelectValues(
95-
predicateNamesToIds(predicate, idByName),
96-
schema.columns
97-
)
94+
predicate = predicateToStorage(predicate, schema)
9895
}
9996
let sortSpec = sort
10097
if (sortSpec?.length) {

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
CsvHeaderMapping,
77
EnrichmentRunDetail,
88
Filter,
9+
Predicate,
910
PredicateNode,
1011
RowData,
1112
Sort,
@@ -429,11 +430,14 @@ function predicateTreeTooLarge(root: unknown): string | null {
429430
* before it ran. Strict on BOTH branches is required: strict on the group alone
430431
* would just fall through to the leaf branch, which is the more dangerous reading.
431432
*/
433+
// double-cast-allowed: `z.unknown()` keeps the runtime permissive (a leaf value
434+
// is arbitrary JSON), but infers `unknown`, which is wider than
435+
// `Predicate['value']`. The narrowing is type-level only — nothing is coerced.
432436
const predicateLeafSchema = z.strictObject({
433437
field: z.string().min(1, 'field is required').max(128),
434438
op: z.enum(FILTER_OPS),
435439
value: z.unknown().optional(),
436-
})
440+
}) as unknown as z.ZodType<Predicate>
437441

438442
const predicateNodeSchema: z.ZodType<PredicateNode> = z.lazy(() =>
439443
z.union([predicateGroupSchema, predicateLeafSchema])

apps/sim/lib/copilot/tools/server/table/user-table.ts

Lines changed: 4 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ import { namedRowMapper } from '@/lib/table/cell-format'
3030
import {
3131
buildIdByName,
3232
columnMatchesRef,
33-
predicateNamesToIds,
3433
rowDataNameToId,
3534
sortSpecNamesToIds,
3635
} from '@/lib/table/column-keys'
@@ -64,7 +63,7 @@ import {
6463
updateRow,
6564
updateRowsByFilter,
6665
} from '@/lib/table/rows/service'
67-
import { resolvePredicateSelectValues } from '@/lib/table/select-values'
66+
import { predicateToStorage } from '@/lib/table/select-values'
6867
import { createTable, deleteTable, getTableById, renameTable } from '@/lib/table/service'
6968
import type {
7069
ColumnDefinition,
@@ -677,10 +676,7 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
677676
let predicate: TablePredicate | undefined
678677
if (args.filter) {
679678
validatePredicate(args.filter, table.schema.columns)
680-
predicate = resolvePredicateSelectValues(
681-
predicateNamesToIds(args.filter, idByName),
682-
table.schema.columns
683-
)
679+
predicate = predicateToStorage(args.filter, table.schema)
684680
}
685681
let orderSpec = args.order as SortSpec | undefined
686682
if (orderSpec?.length) {
@@ -859,12 +855,7 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
859855
// the bulk engine (same fieldPredicate leaf → identical SQL). Select
860856
// operands arrive as option NAMES and must resolve to stored ids.
861857
validatePredicate(args.filter, table.schema.columns)
862-
const idFilter = predicateToFilter(
863-
resolvePredicateSelectValues(
864-
predicateNamesToIds(args.filter, idByName),
865-
table.schema.columns
866-
)
867-
)
858+
const idFilter = predicateToFilter(predicateToStorage(args.filter, table.schema))
868859
const idData = rowDataNameToId(args.data, idByName)
869860

870861
// Inline handles up to MAX_BULK_OPERATION_SIZE rows in one request; a larger operation
@@ -966,12 +957,7 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
966957
// the bulk engine (same fieldPredicate leaf → identical SQL). Select
967958
// operands arrive as option NAMES and must resolve to stored ids.
968959
validatePredicate(args.filter, table.schema.columns)
969-
const idFilter = predicateToFilter(
970-
resolvePredicateSelectValues(
971-
predicateNamesToIds(args.filter, idByName),
972-
table.schema.columns
973-
)
974-
)
960+
const idFilter = predicateToFilter(predicateToStorage(args.filter, table.schema))
975961

976962
// Inline handles up to MAX_BULK_OPERATION_SIZE rows; a larger delete (an explicit limit
977963
// above the cap, or unbounded "delete everything matching") hands off to the background

apps/sim/lib/table/export-runner.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,9 @@ export async function runTableExport(payload: TableExportPayload): Promise<void>
8080

8181
let exported = 0
8282
let firstJsonRow = true
83-
let after: { orderKey: string; id: string } | null = null
83+
// `order_key` is nullable (rows predating the backfill), and the page query
84+
// seeks NULLs explicitly — so the cursor has to carry a null too.
85+
let after: { orderKey: string | null; id: string } | null = null
8486
while (true) {
8587
// Ownership gate before every page: a canceled job stops within one batch.
8688
const owns = await updateJobProgress(tableId, exported, jobId)

apps/sim/lib/table/jobs/service.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,9 @@ export async function selectExportRowPage(
242242
)
243243
.orderBy(asc(userTableRows.orderKey), asc(userTableRows.id))
244244
.limit(limit)
245-
return rows
245+
// drizzle types a jsonb column as `unknown`; every writer goes through the
246+
// row-data validators, so narrowing here is a projection, not an assumption.
247+
return rows.map((r) => ({ ...r, data: r.data as RowData }))
246248
}
247249

248250
/** How long a terminal export stays listable (and re-downloadable from the tray). */

apps/sim/lib/table/rows/service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ import type {
8787
TableDefinition,
8888
TableDeleteJobPayload,
8989
TableRow,
90+
TableRowsCursor,
9091
UpdateRowData,
9192
UpsertResult,
9293
UpsertRowData,

apps/sim/lib/table/select-values.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
* both the legacy `$` grammar and the v2 predicate tree.
1010
*/
1111

12-
import { getColumnId } from '@/lib/table/column-keys'
12+
import { buildIdByName, getColumnId, predicateNamesToIds } from '@/lib/table/column-keys'
1313
import type {
1414
ColumnDefinition,
1515
ConditionOperators,
@@ -19,6 +19,7 @@ import type {
1919
Predicate,
2020
PredicateNode,
2121
TablePredicate,
22+
TableSchema,
2223
} from '@/lib/table/types'
2324
import { resolveSelectOptionId } from '@/lib/table/validation'
2425

@@ -153,3 +154,20 @@ export function resolvePredicateSelectValues(
153154

154155
return walk(predicate) as TablePredicate
155156
}
157+
158+
/**
159+
* The complete name-keyed → storage-keyed translation for a v2 predicate:
160+
* column names become column ids AND select operands become option ids.
161+
*
162+
* Both halves are required and neither is useful alone, but they lived as two
163+
* separate calls that every boundary had to remember to pair — and three of them
164+
* did not, so a filter on a select column compared an option NAME against a
165+
* stored option ID and returned zero rows while reporting success. Call this
166+
* instead of `predicateNamesToIds` so the pair cannot be split again.
167+
*/
168+
export function predicateToStorage(predicate: TablePredicate, schema: TableSchema): TablePredicate {
169+
return resolvePredicateSelectValues(
170+
predicateNamesToIds(predicate, buildIdByName(schema)),
171+
schema.columns
172+
)
173+
}

0 commit comments

Comments
 (0)