Skip to content

Commit 8430f0f

Browse files
fix(tables): make the agent tool picker version-aware and enrich the v2 query (#7328)
* fix(tables): make the agent tool picker version-aware and enrich the v2 query The Agent block's tool picker gated on a hardcoded `CORE_AGENT_TOOL_TYPES` set naming `'table'` with no version resolution. Both Table blocks are `category: 'blocks'`, so that set is their only route into the picker — meaning `table_v2` would never appear there, even once it ships and the toolbar has moved to it. Store base types and match after stripping the `_vN` suffix so a cutover never has to re-list a block here. The existing `hideFromToolbar` guard already excludes superseded versions (file v1-v4) and unrevealed preview blocks, so broadening `file_v5` to `file` admits nothing new. `BUILT_IN_TOOL_TYPES` had the same gap and would have filed v2 under Integrations. `table_query_rows_v2` also shipped with no `toolEnrichment` while all seven other table tools have one, so an agent using it never saw the table's column names and had to guess field values. The enrichment module only knew v1's MongoDB grammar, so adding the id to the existing sets would have taught the wrong syntax — v2 gets its own branch emitting predicate examples, `order` rather than `sort`, and cursor paging guidance. Unlike v1, a v2 query with no filter is valid and returns every row, so the new branch deliberately does not push `filter` into `required`. The shared bulk tools stay on `$eq`: the rows route accepts either grammar via `resolveBulkFilter`, and enrichment is keyed on tool id alone, so it cannot tell which block invoked it. * fix(tables): add ncontains to the v2 operator lists and guard the enrichment wiring Review findings. The operator list the v2 surfaces advertise omitted `ncontains`, which `FILTER_OPS` declares and `sql.ts` compiles to a negated ILIKE. An agent reading the tool schema therefore never knew the operator existed. The list is spelled out verbatim in several places and had already drifted from its source of truth on arrival; deriving it from `FILTER_OPS` is the real fix and is left as follow-up. The `toolEnrichment` block this branch adds had no test, which is the same gap that let v2 ship without enrichment in the first place — a refactor could silently drop it again, and a typo in the tool-id literal degrades to the generic description with no failure. Pin both halves: the tool declares the wiring, and the enricher answers in predicate grammar with `filter` left optional. Also cover the two `v2PredicateExample` arms an all-numeric or all-text table takes, and assert the empty-order case the previous test only executed without checking. * fix(tables): teach the v2 enrichment which operators a select column accepts Greptile and cubic both flagged this independently, and they were right. A select column accepts only a subset of the filter operators — eq/ne/in/nin when single, contains/ncontains when multi — and `buildFilterConditions` THROWS on anything else rather than returning no rows. The new v2 enrichment advertised the full operator list for every column and, worse, asserted "there are no array columns - for substring matching use ilike with *x*". A multi-select cell holds a list, so that sentence is false and it steers the model straight into a rejected query on exactly the columns it describes. The enricher could not tell single from multi because `readTableSchemaAsExecutor` dropped `multiple` when projecting the summary. Carry it through — for select columns only, where it means something — and annotate each select column in the description with the operators it actually takes. The block's bestPractices carried the same false claim, so the model reached it a second way even when enrichment was absent. Corrected there too. Also regenerate the tool metadata bundle, which is what CI was failing on: the ncontains commit changed the v2 tool description without regenerating the derived artifact. * fix(tables): derive select operator guidance from the validator's own sets Round 2 review. Both bots were right, and the second finding corrected me. The first fix annotated the column list but left the `filter` PARAMETER description advertising every operator for every column. That description is what a model reads when deciding the filter's shape, so the restriction has to appear there too — it now names the restricted columns inline. More importantly, the annotation itself was wrong: I omitted isNull and isNotNull. I had traced the v2 grammar through `predicateToFilter` to the `$`-prefixed sets in `column-types/select.ts`, which genuinely lack them — but the v2 query path never goes through that conversion. It compiles predicates directly in `fieldPredicate`, which gates on a SEPARATE pair of `FilterOp` sets that do allow both. Two allowlists for one concept is what made a hand-copied list wrong, so the v2 sets move next to their v1 siblings in the column-type registry — where per-type knowledge belongs — and the enrichment now derives its text from the exact sets the validator gates on. It can no longer drift. Same correction applied to the block's bestPractices, which carried the short list too. * feat(tables): GA table_v2 and mark the v1 Table block legacy Follows the gmail_v2 / github_v2 / confluence_v2 / cursor_v2 cutovers: v1 is renamed "(Legacy)", hidden from discovery, and points at its successor; v2 drops the preview gate. Both edits must land together — the registry check fails a sunset block whose replacedBy is still preview, verified by splitting them locally. v1's `triggers.enabled` deliberately stays true. Webhook execution gates on it at runtime rather than on discovery, so flipping it would break every deployed v1 table-trigger workflow. Both versions host the same `table_new_row` trigger and dispatch is provider-keyed, so hiding v1 changes nothing for triggers. No BLOCK_META_REGISTRY entry is needed — meta coverage is required only for `category: 'tools'` blocks and both Table blocks are `category: 'blocks'`. Docs regenerate from v2 now that v1 is skipped as a source, so the generated page moves to the predicate grammar and cursor pagination. The hand-written regions did not, and are updated here: the operator reference and combining examples, the workflow guide's field walkthrough, and the pagination advice — which previously told readers to advance an offset while looping on nextCursor, mixing both versions. Verified in the browser: the toolbar yields a `table_v2` block whose Query Rows shows Cursor/Order; the Agent tool picker stores `type: table_v2, toolId: table_query_rows_v2` and groups it under built-ins; and a pre-existing v1 block still renders its own Offset/Sort fields behind an amber legacy badge. * fix(tables): restore the docs hero and correct the operator reference Review findings, all consequences of v2 becoming the docs source. `BlockPreview` looks the block type up in a hand-maintained map and renders nothing when it misses, so pointing the trigger page at `table_v2` silently deleted its hero. Move the entry to the new type; the trigger config it displays is identical across both versions. The docs generator extracts `longDescription` with a single-literal regex, so v2's `+`-concatenated string published as its first fragment alone — the page lost every mention of the predicate grammar and cursor pagination. Join it into one literal. v1's was a single literal, which is why this only surfaced now. The hand-written operator table called `contains` case-sensitive. It compiles to ILIKE, as do `startsWith` and `endsWith`; the pre-cutover page had this right, so the rewrite regressed it. Restore the qualifiers and document `ncontains` alongside `contains`. Drop `'table'` from BUILT_IN_TOOL_TYPES: it now only ever reaches blocks that already passed `isAgentToolBlock`, which excludes hidden ones, so the entry is dead. Matches the `file`/`file_v5` precedent the sibling test already asserts. * docs(tables): finish the v2 cutover's documentation trail Regenerate the two artifacts the `ncontains` commit left stale: the tool metadata bundle and the generated Table integration page both still published the operator list without it, so `bun run tool-metadata:check` failed at HEAD. Point the remaining docs previews at `table_v2`. `BLOCK_DISPLAY_WORKFLOWS` already moved, but the two hand-written table example workflows and the OutputBundle on the workflows guide still named the v1 type, so every table example in the docs described the block the toolbar no longer yields. They render identically today only because both types share an icon-map entry. The workflows guide told readers the log shows "the filter and sort it sent"; v2 sends `order`. Document the `sunset` step in the three skills that describe the v1 -> v2 cutover. All three stopped at `(Legacy)` + `hideFromToolbar`, but `check-block-registry` fails a legacy block with no `replacedBy`, and the amber badge and its click-to-upgrade action read from that field - following the procedure verbatim produced a build failure. Also record the ordering constraint this cutover hit: the v1 `sunset` edit and the v2 `preview` removal must land together, since the check rejects a `replacedBy` that is still preview. Left alone: the academy video previews still show v1 labels. They mirror recorded footage, so correcting the label without a re-record would only make the still disagree with the video it claims to depict. * docs(tables): restore the select-operator reference lost in the rebase The regenerated page reverted to the "there are no array columns" claim that the select-operator fix corrected. Restore the allowed-operator table. * fix(tables): address review on the cutover Greptile: convert the v1 lifecycle comment to TSDoc. The repo rule is TSDoc-only, and none of the four reference cutover blocks comment here at all, so the `//` form was not following a local convention either. The note is worth keeping as declaration documentation — it records why `triggers.enabled` must stay true. Cubic: fix a link to `#tips`, an anchor that does not exist on the page. The pagination guidance lives under Variations. * docs(skills): require a GA target before adding sunset replacedBy Review finding. The add-integration cutover steps said to add `sunset.replacedBy` without stating the precondition, so an author following them while v2 was still preview-gated would get a `check-block-registry` failure — the exact constraint this cutover hit. The sibling add-block and add-block-preview skills already spell this out; this brings the third in line. * fix(tables): name a real column in the wildcard example Review finding. Instruction 5 hardcoded `{"field":"name",...}` while every other example in the block is derived from the table's own columns. An unknown field is rejected outright — `Unknown filter column "name". It is not a column on this table.` — so on a table without that column the example invited the model to write a query that cannot run. Derived from a real text column now, and dropped entirely when the table has none rather than falling back to a placeholder. * fix(tables): drop the (Legacy) name suffix from the v1 block The amber legacy badge and its click-to-upgrade action already mark a sunset block in the UI, so the suffix restated it — a placed v1 block rendered a "Table (Legacy)" type tag next to a "legacy" badge saying the same thing. The four earlier cutovers (gmail, github, confluence, cursor) carry the suffix, but the most recent one does not: the slack_v2 GA left v1 named "Slack" and leaned on the badge. Following that. * fix(tables): cover all four pattern operators and make the filter optional Review findings, both in the instruction text. The wildcard rule named only like/ilike, but nlike and nilike route through the same `buildPatternClause`. That matters more than an omission: the builder ESCAPES `%` before translating `*`, so a model reaching for `%` on a negative pattern gets a literal-percent match and silently wrong rows rather than an error. The rule now names all four and says never to use `%`. Instructions 1 and 11 together pushed the model to invent a predicate for a question that carries no condition — "the 5 most recent rows" is answered with order and limit, and v2 accepts an omitted filter. Both now say so. * Update apps/sim/lib/table/llm/enrichment.ts Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * Update apps/sim/lib/table/llm/enrichment.ts Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --------- Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
1 parent fde8cf9 commit 8430f0f

28 files changed

Lines changed: 803 additions & 122 deletions

File tree

‎.agents/skills/add-block-preview/SKILL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ A revealed block that is not globally GA (`enabled !== true`, or env-revealed) r
4141
- GA via config (code cleanup pending): `{ "enabled": true }` — suffix disappears everywhere within ~30s (AppConfig TTL) + client refetch.
4242

4343
Same runbook as `feature-flags`: edit the hosted document, `aws appconfig start-deployment` with the `sim-<env>-fast` strategy (see the infra README).
44-
5. **GA cleanup:** delete `preview: true` from the block (now visible to self-hosters on their next upgrade), add its `BlockMeta` + regen docs, and drop the AppConfig entry. For a v2 upgrade, this is also when v1 gets `hideFromToolbar: true` (the superseded-version paradigm).
44+
5. **GA cleanup:** delete `preview: true` from the block (now visible to self-hosters on their next upgrade), add its `BlockMeta` + regen docs, and drop the AppConfig entry. For a v2 upgrade, this is also when v1 gets `hideFromToolbar: true` **and** `sunset: { status: 'legacy', replacedBy: '<v2-type>' }` (the superseded-version paradigm). Both edits must land in the **same commit** as the `preview: true` removal — `check-block-registry` fails a sunset block whose `replacedBy` is still `preview`, so splitting them breaks the build in between. Also move the block's `BLOCK_DISPLAY_WORKFLOWS` entry (`apps/docs/components/workflow-preview/block-display-workflows.ts`) to the new type, or `BlockPreview` silently renders nothing on the docs page.
4545

4646
## Kill switch (shipped blocks)
4747

‎.agents/skills/add-block/SKILL.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -695,6 +695,10 @@ export const ServiceBlock: BlockConfig = {
695695
type: 'service',
696696
name: 'Service (Legacy)',
697697
hideFromToolbar: true, // Hide from toolbar
698+
// Required: drives the amber legacy badge and its click-to-upgrade action.
699+
// `check-block-registry` fails a legacy block with no `replacedBy`, one whose
700+
// target does not exist, or one whose target is itself sunset or still `preview`.
701+
sunset: { status: 'legacy', replacedBy: 'service_v2' },
698702
// ... rest of config
699703
}
700704

‎.agents/skills/add-integration/SKILL.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -598,7 +598,15 @@ If creating V2 versions (API-aligned outputs):
598598

599599
1. **V2 Tools** - Add `_v2` suffix, version `2.0.0`, flat outputs
600600
2. **V2 Block** - Add `_v2` type, use `createVersionedToolSelector`
601-
3. **V1 Block** - Add `(Legacy)` to name, set `hideFromToolbar: true`
601+
3. **V1 Block** - Add `(Legacy)` to name, set `hideFromToolbar: true`, and add
602+
`sunset: { status: 'legacy', replacedBy: '{service}_v2' }` — `check-block-registry`
603+
fails a legacy block with no `replacedBy`, and the amber legacy badge plus its
604+
click-to-upgrade action read from that field.
605+
606+
**Only add `replacedBy` once the target is GA.** The same check also fails when
607+
the target is unregistered, itself sunset, or still `preview: true`. If v2 is
608+
preview-gated, leave v1 alone until GA and drop `preview` in the *same commit*
609+
that adds the sunset — splitting them breaks the build in between.
602610
4. **Registry** - Register both versions
603611

604612
```typescript

‎apps/docs/components/ui/icon-mapping.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -546,6 +546,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
546546
stt_v2: STTIcon,
547547
supabase: SupabaseIcon,
548548
table: Table,
549+
table_v2: Table,
549550
tailscale: TailscaleIcon,
550551
tavily: TavilyIcon,
551552
telegram: TelegramIcon,

‎apps/docs/components/workflow-preview/block-display-workflows.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -349,14 +349,14 @@ export const BLOCK_DISPLAY_WORKFLOWS: Record<string, PreviewWorkflow> = {
349349
],
350350
edges: [],
351351
},
352-
table: {
353-
id: 'table',
352+
table_v2: {
353+
id: 'table_v2',
354354
name: 'Table',
355355
blocks: [
356356
{
357-
id: 'table',
357+
id: 'table_v2',
358358
name: 'Table',
359-
type: 'table',
359+
type: 'table_v2',
360360
bgColor: '#10B981',
361361
position: { x: 0, y: 0 },
362362
hideTargetHandle: true,

‎apps/docs/components/workflow-preview/examples.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ export const TABLE_ENRICH_WORKFLOW: PreviewWorkflow = {
139139
{
140140
id: 'table1',
141141
name: 'Table 1',
142-
type: 'table',
142+
type: 'table_v2',
143143
bgColor: '#10B981',
144144
position: { x: 0, y: 0 },
145145
hideTargetHandle: true,
@@ -162,7 +162,7 @@ export const TABLE_ENRICH_WORKFLOW: PreviewWorkflow = {
162162
{
163163
id: 'table2',
164164
name: 'Table 2',
165-
type: 'table',
165+
type: 'table_v2',
166166
bgColor: '#10B981',
167167
position: { x: 660, y: 0 },
168168
rows: [
@@ -1644,7 +1644,7 @@ export const TABLE_ROUNDTRIP_WORKFLOW: PreviewWorkflow = {
16441644
{
16451645
id: 'query',
16461646
name: 'Table',
1647-
type: 'table',
1647+
type: 'table_v2',
16481648
bgColor: '#10B981',
16491649
position: { x: 0, y: 0 },
16501650
hideTargetHandle: true,
@@ -1664,7 +1664,7 @@ export const TABLE_ROUNDTRIP_WORKFLOW: PreviewWorkflow = {
16641664
{
16651665
id: 'update',
16661666
name: 'Table',
1667-
type: 'table',
1667+
type: 'table_v2',
16681668
bgColor: '#10B981',
16691669
position: { x: 680, y: 0 },
16701670
rows: [

‎apps/docs/content/docs/integrations/table.mdx‎

Lines changed: 81 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ description: User-defined data tables
66
import { BlockInfoCard } from "@/components/ui/block-info-card"
77

88
<BlockInfoCard
9-
type="table"
9+
type="table_v2"
1010
color="#10B981"
1111
/>
1212

@@ -17,7 +17,7 @@ Tables allow you to create and manage custom data tables directly within Sim. St
1717
- **No external setup**: Create tables instantly without configuring external databases
1818
- **Workflow-native**: Data persists across workflow executions and is accessible from any workflow in your workspace
1919
- **Flexible schema**: Define columns with types (string, number, currency, boolean, date, json, select) and constraints (required, unique)
20-
- **Powerful querying**: Filter, sort, and paginate data using MongoDB-style operators
20+
- **Powerful querying**: Filter, sort, and paginate data using a typed predicate grammar
2121
- **Agent-friendly**: Tables can be used as tools by AI agents for dynamic data storage and retrieval
2222

2323
**Key Features:**
@@ -54,7 +54,7 @@ Tables are created from the **Tables** section in the sidebar. Each table requir
5454

5555
## Usage Instructions
5656

57-
Create and manage custom data tables. Store, query, and manipulate structured data within workflows. Query Rows returns every matching row when Limit is omitted and fails if the result exceeds 5MB.
57+
Create and manage custom data tables. Store, query, and manipulate structured data within workflows. Query Rows accepts a plain predicate — `{"field":"wins","op":"gte","value":10}` — for one condition. Use `all` (AND) or `any` (OR) groups for multiple or nested conditions. Operators: eq, ne, gt, gte, lt, lte, in, nin, like, ilike, nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. Order is a sort spec `[{"field":"wins","direction":"desc"}]`. Query Rows returns every matching row when Limit is omitted (fails if the result exceeds 5MB — add a filter or a Limit). With a Limit, responses page: a non-null nextCursor means more rows exist — pass it back as the cursor. Columns to Return narrows each row to the selected columns (by stable id or name; one that no longer exists is skipped); leave it empty for every column.
5858

5959

6060

@@ -204,29 +204,29 @@ Delete multiple rows that match filter criteria. Use with caution - supports opt
204204

205205
### Query Rows
206206

207-
Query rows from a table with filtering, sorting, and pagination
207+
Query rows with a typed predicate filter and cursor pagination. A single filter can be a plain condition: `\{"field":"wins","op":"gte","value":10\}`. Use `all` (AND) or `any` (OR) groups for multiple or nested conditions. Operators: eq, ne, gt, gte, lt, lte, in, nin, like, ilike, nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. Order is a sort spec, e.g. `[\{"field":"wins","direction":"desc"\}]`. Omit limit to return the entire result — the query fails if it exceeds the 5MB budget (narrow with a filter or set a limit). With a limit, a page can end early at the byte budget: a non-null nextCursor means more rows exist — pass it back as cursor to continue; never infer completion from page size.
208208

209209
#### Input
210210

211211
| Parameter | Type | Required | Description |
212212
| --------- | ---- | -------- | ----------- |
213213
| `tableId` | string | Yes | Table ID |
214-
| `filter` | object | No | Filter conditions \(MongoDB-style operators: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $contains, $ncontains, $startsWith, $endsWith, $empty\) |
215-
| `sort` | object | No | Sort order as \{field: "asc"\|"desc"\} |
216-
| `limit` | number | No | Maximum rows to return. Omit to return every matching row; the query fails if the result exceeds the 5MB response budget. |
217-
| `offset` | number | No | Number of rows to skip \(default: 0\) |
214+
| `filter` | json | No | Predicate condition, e.g. `\{"field":"wins","op":"gte","value":10\}`. Use `all` or `any` for multiple conditions; omit to match all rows. |
215+
| `columns` | array | No | Stable column IDs or table column names to include in each row data object. Omit or pass an empty array to return all columns. A reference that matches no column is ignored. |
216+
| `order` | json | No | Sort spec, e.g. `\[\{"field":"wins","direction":"desc"\}\]`. |
217+
| `limit` | number | No | Maximum rows per page. Omit to return the entire matching result — fails if it exceeds the 5MB budget. With a limit, pages may byte-cut early and set nextCursor when more remain. |
218+
| `cursor` | string | No | Opaque pagination cursor returned by a prior query. Omit for the first page. |
218219

219220
#### Output
220221

221222
| Parameter | Type | Description |
222223
| --------- | ---- | ----------- |
223-
| `success` | boolean | Whether query succeeded |
224+
| `success` | boolean | Whether the query succeeded |
224225
| `rows` | array | Query result rows |
225226
| `rowCount` | number | Number of rows returned |
226-
| `totalCount` | number | Total rows matching filter |
227-
| `limit` | number | Limit used in query |
228-
| `offset` | number | Offset used in query |
229-
| `nextCursor` | string | Non-null when more rows match past this page. A page can end early at the byte budget, so this — not a short rowCount — is what says whether more remain. To page, advance offset by rowCount and stop when this is null. |
227+
| `totalCount` | number | Total rows matching the predicate \(computed on the first page only\) |
228+
| `limit` | number | Limit used in the query |
229+
| `nextCursor` | string | Cursor to fetch the next page, or null on the last page |
230230

231231
### Get Row
232232

@@ -272,65 +272,104 @@ Get the schema configuration of a table
272272
{/* MANUAL-CONTENT-START:notes */}
273273
## Filter Operators
274274

275-
Filters use MongoDB-style operators for flexible querying:
275+
A filter is a predicate. One condition is an object naming a column, an operator, and a value:
276+
277+
```json
278+
{"field": "status", "op": "eq", "value": "active"}
279+
```
276280

277281
| Operator | Description | Example |
278282
|----------|-------------|---------|
279-
| `$eq` | Equals | `{"status": {"$eq": "active"}}` or `{"status": "active"}` |
280-
| `$ne` | Not equals | `{"status": {"$ne": "deleted"}}` |
281-
| `$gt` | Greater than | `{"age": {"$gt": 18}}` |
282-
| `$gte` | Greater than or equal | `{"score": {"$gte": 80}}` |
283-
| `$lt` | Less than | `{"price": {"$lt": 100}}` |
284-
| `$lte` | Less than or equal | `{"quantity": {"$lte": 10}}` |
285-
| `$in` | In array | `{"status": {"$in": ["active", "pending"]}}` |
286-
| `$nin` | Not in array | `{"type": {"$nin": ["spam", "blocked"]}}` |
287-
| `$contains` | String contains (case-insensitive) | `{"email": {"$contains": "@gmail.com"}}` |
288-
| `$ncontains` | Does not contain (case-insensitive; matches empty cells) | `{"email": {"$ncontains": "@spam.com"}}` |
289-
| `$startsWith` | Starts with (case-insensitive) | `{"name": {"$startsWith": "Dr."}}` |
290-
| `$endsWith` | Ends with (case-insensitive) | `{"file": {"$endsWith": ".pdf"}}` |
291-
| `$empty` | Cell is empty (`true`) or non-empty (`false`) | `{"phone": {"$empty": true}}` |
283+
| `eq` | Equals | `{"field": "status", "op": "eq", "value": "active"}` |
284+
| `ne` | Not equals | `{"field": "status", "op": "ne", "value": "deleted"}` |
285+
| `gt` | Greater than | `{"field": "age", "op": "gt", "value": 18}` |
286+
| `gte` | Greater than or equal | `{"field": "score", "op": "gte", "value": 80}` |
287+
| `lt` | Less than | `{"field": "price", "op": "lt", "value": 100}` |
288+
| `lte` | Less than or equal | `{"field": "quantity", "op": "lte", "value": 10}` |
289+
| `in` | In array | `{"field": "status", "op": "in", "value": ["active", "pending"]}` |
290+
| `nin` | Not in array | `{"field": "type", "op": "nin", "value": ["spam", "blocked"]}` |
291+
| `contains` / `ncontains` | Contains, or does not contain (case-insensitive) | `{"field": "email", "op": "contains", "value": "@gmail.com"}` |
292+
| `like` / `nlike` | Pattern match, `*` wildcard (case-sensitive) | `{"field": "name", "op": "like", "value": "Dr.*"}` |
293+
| `ilike` / `nilike` | Pattern match, `*` wildcard (case-insensitive) | `{"field": "name", "op": "ilike", "value": "*jo*"}` |
294+
| `startsWith` | Starts with (case-insensitive) | `{"field": "name", "op": "startsWith", "value": "Dr."}` |
295+
| `endsWith` | Ends with (case-insensitive) | `{"field": "file", "op": "endsWith", "value": ".pdf"}` |
296+
| `isNull` / `isNotNull` | Cell is (not) null | `{"field": "phone", "op": "isNull"}` |
297+
| `isEmpty` / `isNotEmpty` | Cell is (not) empty | `{"field": "phone", "op": "isEmpty"}` |
298+
299+
Most columns are scalar (string, number, boolean, date) or opaque JSON; use `ilike` with `*value*` for substring matching on text.
300+
301+
**Select columns accept only a subset of these operators**, and a query using any other operator on one is rejected rather than returning no rows:
302+
303+
| Column | Allowed operators |
304+
|--------|-------------------|
305+
| Single-select | `eq`, `ne`, `in`, `nin`, `isEmpty`, `isNotEmpty` |
306+
| Multi-select | `contains`, `ncontains`, `isEmpty`, `isNotEmpty` |
307+
308+
A multi-select cell holds a list of options, so match it with `contains` (by option name) rather than `ilike`.
292309

293310
### Combining Filters
294311

295-
Multiple field conditions are combined with AND logic:
312+
Wrap conditions in `all` for AND:
296313

297314
```json
298315
{
299-
"status": "active",
300-
"age": {"$gte": 18}
316+
"all": [
317+
{"field": "status", "op": "eq", "value": "active"},
318+
{"field": "age", "op": "gte", "value": 18}
319+
]
301320
}
302321
```
303322

304-
Use `$or` for OR logic:
323+
Use `any` for OR:
305324

306325
```json
307326
{
308-
"$or": [
309-
{"status": "active"},
310-
{"status": "pending"}
327+
"any": [
328+
{"field": "status", "op": "eq", "value": "active"},
329+
{"field": "status", "op": "eq", "value": "pending"}
311330
]
312331
}
313332
```
314333

315-
## Sort Specification
316-
317-
Specify sort order with column names and direction:
334+
Groups nest, so mixed logic is a group inside a group:
318335

319336
```json
320337
{
321-
"createdAt": "desc"
338+
"all": [
339+
{"field": "status", "op": "eq", "value": "active"},
340+
{"any": [
341+
{"field": "plan", "op": "eq", "value": "pro"},
342+
{"field": "score", "op": "gte", "value": 90}
343+
]}
344+
]
322345
}
323346
```
324347

348+
Omit the filter entirely to match every row.
349+
350+
## Sort Specification
351+
352+
Order is a list of column/direction pairs, applied in order:
353+
354+
```json
355+
[{"field": "createdAt", "direction": "desc"}]
356+
```
357+
325358
Multi-column sorting:
326359

327360
```json
328-
{
329-
"priority": "desc",
330-
"name": "asc"
331-
}
361+
[
362+
{"field": "priority", "direction": "desc"},
363+
{"field": "name", "direction": "asc"}
364+
]
332365
```
333366

367+
## Pagination
368+
369+
Omit **Limit** to return every matching row in one response; the query fails if the result exceeds 5MB, so narrow with a filter rather than guessing a limit.
370+
371+
With a **Limit**, results page. A page can end at the limit *or* at the 5MB byte budget, whichever comes first, so a short page does not mean the end. Pass the returned `nextCursor` back as **Cursor** to fetch the next page and stop only when `nextCursor` is null — never infer completion from the row count.
372+
334373
## Built-in Columns
335374

336375
Every row automatically includes:

0 commit comments

Comments
 (0)