Skip to content

feat(tables): typed predicate filter grammar, cursor pagination, and the v2 table surface - #6067

Merged
TheodoreSpeaks merged 34 commits into
stagingfrom
feat/table-block-v2
Jul 30, 2026
Merged

feat(tables): typed predicate filter grammar, cursor pagination, and the v2 table surface#6067
TheodoreSpeaks merged 34 commits into
stagingfrom
feat/table-block-v2

Conversation

@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator

Summary

  • New typed predicate filter grammar — {all|any: [{field, op, value}]} — replacing Mongo-style $-objects across the engine, contracts, routes, block, and copilot. Both grammars compile through one shared SQL leaf, so semantics are identical; the $-grammar remains only on the v1 public API / v1 block (contract-frozen) and as the fallback branch of dual-grammar wire fields
  • Public v2 read API: GET /api/v2/tables + POST /api/v2/tables/[tableId]/query, gated behind a new tables-v2-api feature flag (404 when off, gate runs after authz). Internal POST /api/table/[tableId]/query for first-party callers
  • Cursor pagination (opaque keyset on (order_key, id)) replacing offset on the new surfaces; limit omitted returns the entire result and fails fast past a 5MB budget instead of truncating
  • New table_v2 block (preview-gated via PREVIEW_BLOCKS/AppConfig) with canonical Builder ⟷ JSON toggles for Filter and Order; v1 Table block unhidden and unchanged
  • Grid + saved views now author the predicate grammar natively (Track C) — views store it, the filter bar emits it, and the legacy converters have exactly one remaining consumer (the v1 block)

Fixes folded in

  • Keyset paging silently dropped rows with NULL order_key (reproduced: 52 of 121 rows returned) — seek now admits the unkeyed tail; same fix applied to CSV export and the snapshot cache
  • Closes [BUG] Filtering on built-in columns (createdAt, updatedAt) silently returns zero rows #5920: id is now a real filterable/sortable system column, and timestamp bounds normalize via AT TIME ZONE 'UTC' so results no longer shift with the session timezone
  • Bulk delete could compile an uninterpretable filter to no WHERE clause and wipe a table past the 1000-row threshold — guarded at the shared compiler choke point, plus a cross-version guard that fails loudly if a predicate ever reaches a server that predates the grammar
  • Select-column operands resolve option names → stored ids in both grammars (contains/ncontains on multi-select included); name→storage translation is now one operation so the pair can't be half-applied again
  • normalizeToolId stripped _v2 as a resource suffix, silently executing the v1 tool under the v2 tool's name
  • DoS bounds: predicate depth/node caps (stack-overflow 500 → 400), in-list cap, 1MB query body cap, strict-object predicate nodes (Zod key-stripping could silently widen a bulk delete)

Rollout notes

  • TABLES_V2_API off by default; table_v2 hidden everywhere until revealed. Kill switch verified: flag off → all three routes 404, v1 untouched
  • The copilot (Go) grammar change must merge + deploy before this flag flips; until then agents get a loud grammar-naming 400, never silent data issues
  • Case-sensitive uniqueness: lower() removed from unique-constraint checks, so upserts distinguish case-variant values — tables already holding case-variant duplicates keep working, but the previous silent case-folding is gone

Type of Change

  • New feature

Testing

Type-check clean, 3147 tests passing across the table/contract/route/hook suites, check:api-validation:strict + full audit suite green. Live HTTP suite against a real DB: 18/18 (NULL-order_key paging returns all 121 rows, #5920 ranges match SQL ground truth, DoS bounds, flag gating). Manually drove the block + grid + mothership agent against a local Go build on the new grammar.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

🤖 Generated with Claude Code

TheodoreSpeaks and others added 27 commits June 27, 2026 13:44
…gine, cursor pagination

- Unify row-matching on one `fieldPredicate` leaf (filter compiler, upsert conflict
  probe, unique checks) — fixes the upsert wedge on case-mismatched unique values;
  equality/in is case-sensitive everywhere, text matches stay ILIKE
- v2 nestable all/any predicate grammar: types, `buildPredicateClause`, structured
  contract schema, query-builder converters (+ `predicateToFilter` bridge)
- Cursor pagination: opaque codec + `QueryResult.nextCursor` (offset gone on v2 surface)
- `table_v2` block + `table_query_rows_v2` tool + POST /api/table/[tableId]/query route;
  v1 `table` hidden from toolbar; bulk update/delete author predicates too
- Agent grammar: regenerate copilot tool-catalog TS; `user_table` server tool parses
  predicates (query → predicate, bulk → predicateToFilter)
- Fix `replaceTableRowsWithTx` row[col.name] → row[getColumnId(col)] keying bug

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
# Conflicts:
#	apps/sim/blocks/registry.ts
#	scripts/check-api-validation-contracts.ts
…d query with byte guard

- New parser lib/table/query-builder/postgrest.ts: PostgREST querystring
  (wins=gte.10&status=in.(active,pending), or=()/and=() groups, not. prefix)
  -> TablePredicate IR; serializers for the visual builder path
- Contract/tool/route/copilot user_table: filter/order are PostgREST strings,
  parsed + validated server-side
- table_v2 block: Builder/Editor filter mode dropdown (visual builders
  serialize to PostgREST), builder-only fields hidden from the LLM
- queryRows: no default row limit; 10MB result byte guard; engine like/ilike
  ops; bulk update/delete deterministic (order_key, id) ordering under limit

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bd25zCzh21omjFhRz32ov6
# Conflicts:
#	apps/sim/blocks/registry-maps.minimal.ts
#	apps/sim/lib/table/rows/service.ts
#	apps/sim/lib/table/validation.ts
#	scripts/check-api-validation-contracts.ts
…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
Replace the PostgREST querystring wire with the typed
{all|any:[{field,op,value}]} predicate object across the engine, contracts,
internal query/bulk routes, the public v2 read API (GET /api/v2/tables + POST
.../query), the table_v2 block (canonical Builder/JSON filter toggle), the
query_rows_v2 tool, and the copilot user_table tool. Adds validatePredicate
schema-aware validation. Track A engine hardening: read-path statement timeout,
comparator negation (not.gt family), createdAt/updatedAt filtering, machine
error codes, cursor version field. Mothership pagination is now cursor-only
(offset removed from the agent surface).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UBRahKrk4BV23spVMckskA
Brings 97 commits incl. #5503 (remove tables-fractional-ordering flag —
fractional ordering now unconditional), #5465 (date canonicalization +
effective-timezone render), #5492 (server-authoritative run badge).

Conflicts resolved keeping our predicate-object grammar + cursor pagination:
- rows/service.ts: dropped staging's offset buildPageQuery (we use the
  byte-bounded cursor drain); made fractional ordering unconditional per #5503
  (removed fractionalOrdering flag threads + isFeatureEnabled calls).
- sql.ts: legacy simple-equality routes through the unified fieldPredicate leaf
  (+ staging's JsonValue cast).
- validation.ts: kept both USER_TABLE_ROWS_SQL_NAME (our fieldPredicate) and
  normalizeDateCellValue (staging dates).
- index.ts: export both dates + errors.
- check-api-validation baseline 919 -> 922 (+v2 list/query + internal query).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UBRahKrk4BV23spVMckskA
Adds a runtime feature flag gating the three v2 HTTP surfaces (GET /api/v2/tables,
POST /api/v2/tables/[tableId]/query, POST /api/table/[tableId]/query), returning 404
when off. Gated by userId + the workspace's org cohort via AppConfig; off-AppConfig
falls back to the TABLES_V2_API secret (off by default).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011M563cbFy2S74GvSDf2C3R
Brings 326 commits. Conflict resolutions:
- registry.minimal.ts: union of our table tools and staging's slack tools.
- database.mock.ts: took staging's chain-spy rewrite (already supports the
  .limit(n).offset(m) chain the drain loop needs).
- service-filter-threading.test.ts: dropped the dead tables-fractional-ordering
  feature-flag mock (#5503 removed the flag) and the redundant @sim/db mock.
- env.ts: kept TABLE_MAX_PAGE_BYTES plus staging's dispatch-concurrency vars.
- tool-schemas-v1.ts: took staging's generated output; the table grammar is
  regenerated from the Go contract once that branch lands.
- feature-flags.test.ts: ported the tables-v2-api tests to setEnvFlags.
- check-api-validation baseline 975 -> 978 (+v2 list/query + internal query).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011M563cbFy2S74GvSDf2C3R
table_v2 was registered ungated in the toolbar while v1 Table was hidden, so a
deployment with tables-v2-api off left users with only a Table block whose
default Query Rows operation 404s behind the flag.

Marks table_v2 `preview: true` — hidden from every discovery surface until
revealed via the hosted block-visibility AppConfig document or PREVIEW_BLOCKS,
fail-closed, with execution of placed instances never gated. Drops the premature
`hideFromToolbar` from v1 Table so it stays available during rollout; v1 gets
marked superseded at table_v2 GA, alongside its BlockMeta and docs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011M563cbFy2S74GvSDf2C3R
`order_key` is nullable — rows predating the backfill script-migration, and
forked rows that inherit a NULL key. A bare `(order_key, id) > (:k, :i)` row
comparison evaluates to NULL for those rows, so WHERE drops them; because NULLs
sort LAST, the whole unkeyed tail became unreachable and the drain terminated
early reporting `hasMore: false`. Reproduced on a 121-row table: 52 rows
returned, `nextCursor: null`, no error. Affected the grid, CSV export, the
snapshot cache, the v2 API, and copilot queries.

Admits `order_key IS NULL` in both seeks (`fetchRowsBounded`'s drain and
`selectExportRowPage`), which restores the tail and makes the compound
`{k,i,o}` cursor's offsetFromAnchor accounting resolve — it was unreachable
before. `selectExportRowPage` additionally seeks by anchor kind, since its
anchor is the previous page's last row and can itself be unkeyed; its return
type no longer casts the nullable column to `string`.

Also stops workspace forking minting new NULLs: it spread `...row` into a fresh
tableId that the one-shot backfill never revisits, so copied rows now get keys
appended after the source's max, preserving visual order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011M563cbFy2S74GvSDf2C3R
…ole table

`runTableDelete` built its WHERE as `filter ? buildFilterClause(...) : undefined`
with no guard, unlike `runTableUpdate` and the inline paths. A filter that was
supplied but compiled to nothing therefore deleted every row past the cutoff —
`and()` drops an undefined clause silently. Tables at or under the inline bulk
cap 400'd, so only larger tables were affected.

Three inputs reached that state while passing every check:
- `predicateToFilter` claimed to be lossless but emitted leaves that
  `buildFilterClause` discards: `op:'eq'` with an array value (the realistic
  trigger — an LLM reaching for `in` and writing `eq`) and a value-taking op with
  no value. It now throws instead.
- `predicateSchema` allowed an empty `all`/`any` group; both now require `.min(1)`.
- `validateLeaf` only checked `in`/`nin` emptiness. It now also rejects a missing
  value and an array on a scalar op, so the copilot path — which has no Zod —
  fails the same way the HTTP boundary does, and caps `in`/`nin` list length at
  1000 (each element becomes its own containment clause).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011M563cbFy2S74GvSDf2C3R
Two shapes let a destructive filter execute differently than it validated.

A node carrying both a group key and `field` was read group-first by the engine
and validator but leaf-first by predicateToFilter/predicateNamesToIds, so the
gate validated one predicate while the bulk-write path executed another —
bypassing the unknown-column, json-op, and empty-list checks on the copilot's
delete/update path. validateNode now rejects it, and both converters discriminate
group-first so unvalidated callers can't disagree either.

filterRulesToPredicate skipped any builder rule without `column`, which silently
dropped predicate-shaped members when the two grammars were mixed — turning
"delete archived rows for tenant acme" into "delete archived rows for every
tenant". It now throws and points at the predicate object; a genuinely blank
builder row is still ignored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011M563cbFy2S74GvSDf2C3R
Closes #5920 and applies the pre-ship remediation from the branch review.

Built-in columns (#5920)
- Add `id` as a system column alongside `createdAt`/`updatedAt`. It previously
  fell through to `data->>'id'` — a JSONB key that never exists — so filtering
  by id matched nothing and sorting by id ordered by NULL, despite the docs
  advertising all three as filterable and sortable.
- Normalize timestamp bounds with `::timestamptz AT TIME ZONE 'UTC'`.
  `created_at`/`updated_at` are `timestamp WITHOUT time zone` holding UTC, so a
  bare `::timestamptz` promoted the column using the session TimeZone GUC and
  shifted every bound by the server's offset. Verified on a real 121-row table:
  the reporter's UTC-3 day range returned 114 rows under America/Sao_Paulo vs
  112 under UTC; the fix returns 112 in every session timezone.
- Range operators on `string` columns now compare as text instead of falling
  back to a `::numeric` cast that produced the misleading
  `... (string) requires a number, got string`. `boolean`/`json` ranges are
  rejected with a message naming the real type.

Rollout safety
- Move the `tables-v2-api` flag gate below the authz check on all three routes.
  Ahead of authz it did a primary-DB read on a caller-supplied workspaceId and
  its 404-vs-403 split leaked which orgs are in the rollout cohort.
- Remove `match`/`imatch` entirely. They were newly added to the legacy
  `$`-allowlist on this branch, making POSIX regex reachable on the *ungated*
  v1 public API while the gated v2 route rejected it as a pool-pinning risk.
  Nothing shipped depends on them; the route-local `assertNoRegexOps` goes away
  with them.
- Restore the bounded-page byte cut as opt-in (`TABLE_MAX_PAGE_BYTES`, default
  off) to match staging. A short page is only safe for clients terminating on
  `nextCursor === null`; a pre-existing v1 pager stopping at
  `rows.length < limit` would read the cut as end-of-data. Unbounded queries
  still fail fast at the 5MB budget rather than return a partial result.

DoS bounding
- Cap predicate nesting depth (10) and total nodes (500) with an iterative
  pre-check. The recursive `z.lazy` union overflowed the stack inside
  `safeParse` on a deeply nested tree — a RangeError escaping a parser is a 500,
  not a 400.
- Cap the row-query body at 1MB (the 50MB platform default let a caller buffer
  two orders of magnitude more before any schema check ran).
- Route the bulk PUT/DELETE bodies through `parseJsonBody` so the destructive
  surface gets the platform body cap instead of a raw `request.json()`.

Hygiene
- Rename the query-builder's `SORT_DIRECTIONS` option list to
  `SORT_DIRECTION_OPTIONS`; it collided with the wire-level tuple and both were
  star-exported through `lib/table/index.ts`, so the name resolved to nothing.
- Drop stale PostgREST references from comments and error messages.

Tests
- System-column coverage for id (filter, sort, pattern ops), UTC normalization,
  and the legacy `$`-grammar path.
- The NULL-admitting keyset seek — asserted to fail against the pre-fix
  comparison — plus cursor round-trip continuity across pages.
- `nlike`/`nilike` SQL, predicate depth/size rejection, flag-off 404 and
  gate-ordering on all three v2 routes, and the byte cut being off by default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011M563cbFy2S74GvSDf2C3R
…he v2 gate

A `{ status: { $eq: 'x' } }` filter is neither a group nor a leaf, so it fell
through to `validateLeaf` with `field: undefined` and came back as
`Unknown filter column "undefined"` — a message that sends an LLM caller
retrying column names instead of switching grammars.

This is reachable today: the copilot's `query_user_table` catalog entry still
advertises `filter: MongoDB-style filter for query_rows` plus `offset`/`sort`,
while the tool now routes through `validatePredicate` (rejects the $-grammar)
and reads only `order`/`cursor` (so `offset`/`sort` silently no-op). Fixing the
catalog belongs upstream in the mothership repo; this makes the failure legible
in the meantime.

Also:
- Point both table blocks' docsLink at docs.sim.ai/integrations/table. They were
  the last two blocks in the repo still on the dead docs.simstudio.ai domain.
- Fix the openapi `sort` example, which showed `{"created_at": "desc"}`. The
  built-in column is `createdAt`; the snake_case form is treated as a user
  column, so anyone copying the example sorted by a JSONB key that never exists
  and got NULL ordering — the same silent-wrong-answer class as #5920, on the
  already-shipped v1 surface.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011M563cbFy2S74GvSDf2C3R
…ntly narrowing

Found by running the HTTP suite: a node carrying BOTH a group key and a leaf's
`field`/`op`/`value` returned 200, not 400.

Zod strips unrecognized keys by default, so `{ all: [...], field, op, value }`
parsed clean against the group branch with the leaf half quietly deleted. The
hybrid guard added in eaf4179 could never fire — the keys were gone before
`validatePredicate` ran. On the bulk paths that turns "delete archived rows for
tenant acme" into "delete EVERY row for tenant acme".

Both node shapes are now `strictObject`. Strict on the group alone would be
worse than the bug: the union would fall through to the leaf branch, which is
the more dangerous reading of the two.

The bulk schemas are unaffected by design — their legacy `$`-object branch
accepts any non-empty object, so it absorbs the hybrid WITHOUT stripping, the
route's `isTablePredicate` check routes it back to `validatePredicate`, and the
runtime guard rejects it there. Tests now pin both layers so removing either
one fails loudly.

Verified against a running server on the `hello` table: 18/18 HTTP checks pass,
including the previously-failing hybrid case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011M563cbFy2S74GvSDf2C3R
Cross-version safety net. If a client speaking the predicate grammar reaches a
server that predates it, the predicate arrives at the legacy `$`-compiler as
`{ all: [...] }`. That was skipped as "an array on a regular field", so the
filter compiled to NO WHERE CLAUSE — which on a bulk delete means every row
rather than none. `update-runner` has always had an `if (!filterClause) throw`;
`delete-runner` does not, so the background delete path (tables over 1000 rows)
was the one that could actually wipe a table.

`buildFilterClause` is the single choke point every filter path shares —
`queryRows`, `update-runner`, `delete-runner`, inline and background — so one
guard there covers all of them, and it names the mismatch instead of failing
with a generic "filter required".

Scoped to the `all`/`any` discriminators specifically: an ordinary column that
happens to hold an array stays a silent skip, so no working legacy filter
changes behaviour.

This matters for deploy ordering. The copilot and sim deploy independently, and
if the copilot ships the new grammar first it starts sending predicates to a sim
that cannot parse them. With this guard that is a loud 400 on every path instead
of a silent table wipe on one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011M563cbFy2S74GvSDf2C3R
Clearing the Filter or Order field in Editor mode is the ordinary way to say
'no filter'. `JSON.parse('')` throws, so it surfaced at run time as
`Invalid JSON in Filter: Unexpected end of JSON input` plus a quoting hint
that has nothing to do with the actual problem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011M563cbFy2S74GvSDf2C3R
37 commits. Staging landed two table features that had to be woven into the v2
work rather than merged around it:

**`select` columns.** Staging replaced `rowDataIdToName` with `namedRowMapper`,
which fuses the id→name key remap with select-cell VALUE formatting, and added
`resolveFilterSelectValues` for the inbound direction. Both v2 read paths were
still on the old key-only mapper, so a select cell would have surfaced its
stored option id instead of the option name.

The inbound half had no predicate-grammar equivalent at all, so a v2 filter like
`{field:'status', op:'eq', value:'Open'}` compared the option NAME against the
stored option ID and silently matched nothing. Added
`resolvePredicateSelectValues` beside its `$`-grammar sibling and wired it into
`row-wire`, the v2 query route, and the copilot executor.

Select-column operator gating and the multi-select array-membership clause moved
from `buildFieldCondition` down into `fieldPredicate`, so the predicate grammar
gets them too rather than only the `$` grammar. `fieldPredicate` now takes the
full `ColumnDefinition` instead of just its type — `options`/`multiple` are what
the select branches need. The equality shorthand on a multi-select still maps to
membership while an explicit `eq` still errors, matching staging.

**Per-table mutation locks.** Additive; `tables-v2-api` and `table-locks` sit
side by side in the flag registry.

Also: `TableQueryValidationError` moved to `lib/table/errors` on our side, so
staging's v1 rows route import needed repointing, and `formatCsvValue` was
renamed `formatCsvCell` upstream.

Route-count baseline 978 + staging's 979 → recomputed, not added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011M563cbFy2S74GvSDf2C3R
…elect

Caught by driving mothership at a real multi-select table. It sent a correctly
formed predicate — {field:'Color', op:'contains', value:'Teal'} — and got zero
rows with success, against a table where 15 rows hold Teal.

resolvePredicateSelectValues only resolved eq/ne/in/nin. I excluded
contains/ncontains as 'pattern ops that match the raw stored cell', which holds
for a string column but not for a multi-select: there the cell is an array of
option ids and those two ops express MEMBERSHIP, so their operand is an option
name that has to become an option id. It is the primary way to filter a
multi-select, so the one op that mattered most was the one left out.

The $-grammar sibling resolveFilterSelectValues has always handled
$contains/$ncontains for this exact reason; the omission was mine, porting it.

The remaining pattern ops (like/startsWith/...) never reach here — fieldPredicate's
select allowlist rejects them on select columns first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011M563cbFy2S74GvSDf2C3R
table_query_rows_v2 starts with 'table_query_rows_', so the resource-suffix
strip normalized it to the v1 tool. The executor then logged the v2 id while
issuing v1's request shape — GET /rows?filter=<predicate> instead of
POST /query with a predicate body — so a correctly configured table_v2 block
400'd with 'Filter looks like a v2 predicate tree but reached the legacy filter
compiler'. The guard was right; the tool resolution was wrong.

A trailing _v<n> is a version marker, not a resource id, so it is no longer
stripped. Versioned ops are matched longest-first and listed alongside their
unversioned form, so table_query_rows_v2_<tableId> still normalizes to
table_query_rows_v2 rather than collapsing to v1.

Applies to the knowledge ops too — same loop shape, same latent trap the first
time one of them is versioned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011M563cbFy2S74GvSDf2C3R
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
30 commits: saved table views (#5961), the generic folder engine, desktop app,
and the raw-sql Date-bind fix. Two conflicts: bulk-filter contract fields keep
our dual-grammar bulkFilterSchema while adopting staging's workspaceIdSchema
primitive; route-count baseline recomputed by running the audit (996 = staging's
993 + our 3 v2 routes).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011M563cbFy2S74GvSDf2C3R
…commit

The 79d0dd0 merge recorded the pre-merge env-flags.ts: the path was reset out
of the index mid-merge to keep a local debug edit unstaged, which also discarded
staging's isSessionPoliciesEnabled / isCopilotToolPermissionsEnabled exports and
broke six importers added by the desktop-app PR (#5998).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011M563cbFy2S74GvSDf2C3R
Views shipped (#5961) storing the legacy `$`-object filter and `{col: dir}`
sort record — a brand-new persistent store of the grammar this branch is
retiring, created days before the wire moved to predicates. The feature is
still dark (`table-views` is UI-only and off), so the stored shape can change
now without a data migration; once the flag flips, it cannot.

`TableViewConfig` now carries `TablePredicate` + `SortSpec`. The wire contract
uses `predicateSchema`/`sortSpecSchema`, which also brings the strict-object
node shapes and depth/size bounds to the view routes — previously a view's
filter was accepted as an arbitrary domain object.

The grid still runs on the legacy pair internally; translation happens at the
view boundary. Apply: `predicateToFilter` (total here — stored predicates are
builder-authored). Save: `filterToRules ∘ filterRulesToPredicate`, the
builder round-trip. SortSpec keeps priority order the record never could.

Dev-era rows written before the switch are normalized on read: legacy filters
convert through the builder round-trip and are dropped if the result's leaf
fields fail the column-name pattern — the rule converters accept garbage
(`{$bogus: …}` becomes a rule on a column literally named `$bogus`), so the
conversion is validated rather than trusted.

Also folds `sortQuery`'s single-entry record out of the save path in favour of
the sort params directly, so a saved view records the same thing the URL says.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011M563cbFy2S74GvSDf2C3R
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
@vercel

vercel Bot commented Jul 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview Jul 30, 2026 5:49am

Request Review

@cursor

cursor Bot commented Jul 29, 2026

Copy link
Copy Markdown

PR Summary

High Risk
Large cross-cutting change to table querying, bulk delete/update/run scoping, and public API semantics; incorrect filter downgrade or validation could widen destructive operations or leak rollout cohort signals if gate ordering regresses.

Overview
Introduces a typed predicate filter grammar ({all|any: [{field, op, value}]}) across the table engine, API contracts, grid, saved views, and workflows, while keeping legacy $-operator objects on v1 public routes and as a dual-grammar fallback on bulk/async wire fields.

New read surfaces (feature-gated tables-v2-api, 404 after authz when off): public GET /api/v2/tables and POST /api/v2/tables/[tableId]/query, plus internal POST /api/table/[tableId]/query. Queries use opaque cursor pagination (nextCursor), optional unbounded results with a 5MB fail-fast budget, and name-keyed row payloads on the public API. Legacy list routes gain nextCursor; export loops stop only when the cursor is null (not on short pages).

Safety and validation tighten destructive paths: strict predicate schemas, depth/node caps, hybrid group+leaf rejection, wire-filter validation before toLegacyFilter downgrade, TableQueryValidationError400 on async delete/run/cancel, body size limits on bulk writes, and JSON encoding for array-of-object query params (e.g. sort specs).

UI and blocks: filter bar, views, and hooks use TablePredicate / SortSpec; preview table_v2 block compiles builder or JSON filters to predicates and calls table_query_rows_v2. OpenAPI adds openapi-v2-tables.json; workspace fork copy mints order_key for unkeyed rows when cloning tables.

Reviewed by Cursor Bugbot for commit 96d49b8. Bugbot is set up for automated code reviews on this repo. Configure here.

@gitguardian

gitguardian Bot commented Jul 29, 2026

Copy link
Copy Markdown

⚠️ GitGuardian has uncovered 1 secret following the scan of your pull request.

Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

🔎 Detected hardcoded secret in your pull request
GitGuardian id GitGuardian status Secret Commit Filename
35187658 Triggered Username Password 79d0dd0 apps/desktop/src/main/browser-credentials/vault.test.ts View secret
🛠 Guidelines to remediate hardcoded secrets
  1. Understand the implications of revoking this secret by investigating where it is used in your code.
  2. Replace and store your secret safely. Learn here the best practices.
  3. Revoke and rotate this secret.
  4. If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.

To avoid such incidents in the future consider


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

Comment thread apps/sim/app/api/table/[tableId]/delete-async/route.ts Outdated
Comment thread apps/sim/lib/table/query-builder/converters.ts
Comment thread apps/sim/app/api/table/[tableId]/rows/route.ts
@greptile-apps

greptile-apps Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Follow-up re-check of the three prior dual-grammar review threads against HEAD.

  • Hybrid bulk filters are rejected via validatePredicateShape / predicateToFilter on delete-async, cancel-runs, and columns/run (toLegacyFilter), with regression coverage.
  • isTablePredicate requires array-valued all/any, so equality shorthand on columns named all/any stays on the legacy path.
  • Sync bulk and GET rows validate storage-keyed predicates after wire translation (resolveBulkFilter / predicateIn + validateStoragePredicate), so grid id-keyed filters no longer fail name-only validation.

Confidence Score: 5/5

The PR appears safe to merge with respect to the previously reported dual-grammar issues; those fixes hold at HEAD.

No blocking failure remains from the prior hybrid-guard, all/any discrimination, or bulk name-vs-id keying threads; current code and tests establish each is addressed.

Important Files Changed

Filename Overview
apps/sim/lib/table/query-builder/converters.ts Hybrid lossless-or-throw conversion and array-based predicate discrimination land the prior hybrid and all/any fixes.
apps/sim/lib/table/query-builder/validate.ts Shape and storage-key validation choke points cover hybrid nodes, empty groups, and post-wire field checks.
apps/sim/app/api/table/[tableId]/rows/route.ts Bulk PUT/DELETE and GET resolve predicates through wire translation then storage validation as required by the prior bulk-keying thread.
apps/sim/app/api/table/[tableId]/delete-async/route.ts Async delete downgrades with toLegacyFilter and shape/storage validation so hybrids 400 instead of widening.
apps/sim/app/api/table/utils.ts Shared tableFilterError validates wire predicates against storage keys for the async destructive routes.

Reviews (5): Last reviewed commit: "Merge remote-tracking branch 'origin/sta..." | Re-trigger Greptile

Comment thread apps/sim/app/api/table/[tableId]/delete-async/route.ts Outdated
Comment thread apps/sim/app/api/table/[tableId]/cancel-runs/route.ts
table_create and table_list were the only two of the 13 Sim table tools absent
from minimal mode. No block routes to them today, but minimal mode should not
silently lack table operations the full registry has — that asymmetry is
exactly how the earlier 'table block missing in minimal registry' round trips
happened.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011M563cbFy2S74GvSDf2C3R
Comment thread apps/sim/hooks/queries/tables.ts
Comment thread apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx
Documents the two public v2 endpoints exactly as implemented — the predicate
grammar (full operator enum with per-type and select-cardinality semantics, the
strict-node and depth/size bounds), cursor pagination with its null-only
termination contract, limit=0 unbounded semantics with the 5MB fail-fast, the
camelCase built-in columns, and the flag-off 404 behaviour.

Deliberately NOT wired into the docs site loader: the surface is dark behind
tables-v2-api, and publishing reference docs for an endpoint that 404s would be
premature. The filename matches PR #5273's multi-spec layout so adoption is a
one-line loader change at GA.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011M563cbFy2S74GvSDf2C3R
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
@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@greptile review

Comment thread apps/sim/app/api/table/[tableId]/columns/run/route.ts
Comment thread apps/sim/app/api/v2/tables/[tableId]/query/route.ts Outdated
@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@cursor review

@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@greptile review

Comment thread apps/sim/app/api/table/[tableId]/delete-async/route.ts
Comment thread apps/sim/lib/table/query-builder/validate.ts
Comment thread apps/sim/blocks/blocks/table_v2.ts
Bugbot round 2 on PR #6067, both verified:

Empty groups (High). `{all: []}` fails the strict predicate branch (.min(1))
but slips the dual union via the legacy branch — an empty ARRAY inside a
non-empty OBJECT — then downgraded to `{$and: []}`, which compiles to no WHERE
clause: a run/cancel/delete scope silently widened to every row.
validatePredicateShape now mirrors the contract's .min(1), which closes it at
every dual-grammar boundary at once (toLegacyFilter, resolveBulkFilter, the
GET native path).

Cursor↔sort binding (Medium). CURSOR_SORT_CONFLICT only fired for keyset
cursors; offset cursors — the shape sorted views actually emit — carried no
record of their ordering, so one minted under sort A replayed under sort B (or
none) silently paged the wrong sequence. Offset cursors are now stamped with a
canonical fingerprint of their sort at mint (queryRows), and a shared
assertCursorSortBinding enforces the match at all three consumers (both query
routes and the copilot executor), replacing the three hand-rolled keyset-only
checks. Keyset/compound cursors stay default-order-only by construction. The
OpenAPI cursor wording now states the binding rather than overclaiming.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011M563cbFy2S74GvSDf2C3R
…odes, resolve coerced select names

Bugbot round 3 on PR #6067, all three verified:

Async routes (Medium). delete-async / cancel-runs / columns/run validated only
the toLegacyFilter downgrade, which compiles a typo'd field into a clause that
silently matches nothing — a filtered delete/run/stop no-ops where the sync
bulk routes 400. tableFilterError is now grammar-aware and takes the WIRE
filter: predicates go through validateStoragePredicate (same keying the sync
routes enforce), legacy filters keep the buildFilterClause check.

Dual all/any node (High). {all:[...], any:[...]} fails both strictObject
branches, survives the legacy union, and every group-first traversal reads
`all` and silently DROPS `any` — half the conditions vanish, widening a bulk
delete/update. validateNode (covering every boundary and the copilot tool's
validatePredicate) and predicateToFilter (lossless-or-throw) both reject it;
nesting expresses the same intent unambiguously.

Coerced select names (Medium). The block builder serializes without schema
access, so an option NAME that looks numeric/boolean ("123") arrives
scalar-coerced and resolveSelectOptionId bailed on non-strings — the filter
compared 123 against the stored option id and matched nothing.
Stringify scalar operands before matching, fixing every name-keyed caller
(v1 and v2 blocks) at the resolution seam instead of per-surface.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011M563cbFy2S74GvSDf2C3R
@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@greptile

@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/app/api/table/[tableId]/rows/route.ts
TheodoreSpeaks and others added 2 commits July 29, 2026 22:40
Bugbot round 4: the native predicate path on GET /rows ran only the shape
check before wire translation, and find ran none before its toLegacyFilter
downgrade — so a typo'd field compiled to a clause matching nothing and read
back as a plausible empty page, where the bulk write paths 400. GET now runs
validateStoragePredicate post-translation (name-keyed JWT callers validate
their translated form, same recipe as resolveBulkFilter); find reuses the
grammar-aware tableFilterError gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011M563cbFy2S74GvSDf2C3R
# Conflicts:
#	scripts/check-api-validation-contracts.ts
@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@greptile

@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 96d49b8. Configure here.

@TheodoreSpeaks
TheodoreSpeaks merged commit 32293f4 into staging Jul 30, 2026
26 of 27 checks passed
@waleedlatif1
waleedlatif1 deleted the feat/table-block-v2 branch July 30, 2026 07:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant