Skip to content

Commit 5ccb987

Browse files
committed
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.
1 parent f4ba31e commit 5ccb987

8 files changed

Lines changed: 128 additions & 55 deletions

File tree

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/content/docs/integrations/table.mdx

Lines changed: 72 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.
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, 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,95 @@ 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` | Contains (case-sensitive) | `{"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 | `{"field": "name", "op": "startsWith", "value": "Dr."}` |
295+
| `endsWith` | Ends with | `{"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+
Columns are scalar (string, number, boolean, date) or opaque JSON. There are no array columns, so use `ilike` with `*value*` for substring matching.
292300

293301
### Combining Filters
294302

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

297305
```json
298306
{
299-
"status": "active",
300-
"age": {"$gte": 18}
307+
"all": [
308+
{"field": "status", "op": "eq", "value": "active"},
309+
{"field": "age", "op": "gte", "value": 18}
310+
]
301311
}
302312
```
303313

304-
Use `$or` for OR logic:
314+
Use `any` for OR:
305315

306316
```json
307317
{
308-
"$or": [
309-
{"status": "active"},
310-
{"status": "pending"}
318+
"any": [
319+
{"field": "status", "op": "eq", "value": "active"},
320+
{"field": "status", "op": "eq", "value": "pending"}
311321
]
312322
}
313323
```
314324

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

319327
```json
320328
{
321-
"createdAt": "desc"
329+
"all": [
330+
{"field": "status", "op": "eq", "value": "active"},
331+
{"any": [
332+
{"field": "plan", "op": "eq", "value": "pro"},
333+
{"field": "score", "op": "gte", "value": 90}
334+
]}
335+
]
322336
}
323337
```
324338

339+
Omit the filter entirely to match every row.
340+
341+
## Sort Specification
342+
343+
Order is a list of column/direction pairs, applied in order:
344+
345+
```json
346+
[{"field": "createdAt", "direction": "desc"}]
347+
```
348+
325349
Multi-column sorting:
326350

327351
```json
328-
{
329-
"priority": "desc",
330-
"name": "asc"
331-
}
352+
[
353+
{"field": "priority", "direction": "desc"},
354+
{"field": "name", "direction": "asc"}
355+
]
332356
```
333357

358+
## Pagination
359+
360+
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.
361+
362+
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.
363+
334364
## Built-in Columns
335365

336366
Every row automatically includes:

apps/docs/content/docs/tables/using-in-workflows.mdx

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ Throughout this page the running example is a `leads` table with columns `compan
1919

2020
A **Table block** performs one operation against one table. The **Operation** dropdown picks the action; the **Table** selector picks the target. The fields below those two change based on the operation you choose.
2121

22-
{/* VISUAL: Table block UI showing the Operation dropdown open, plus the conditional fields that appear for Query Rows (Filter Conditions, Sort Order, Limit, Offset). */}
22+
{/* VISUAL: Table block UI showing the Operation dropdown open, plus the conditional fields that appear for Query Rows (Filter, Order, Columns to Return, Limit, Cursor). */}
2323

2424
The operations fall into three groups:
2525

@@ -65,15 +65,24 @@ Later blocks read these by name: `<table1.rows>` is the array, `<table1.rowCount
6565
]}
6666
/>
6767

68-
**Filter Conditions** narrow the result. In the default **Builder** input mode you add rules visually: pick a column, an operator, and a value. Switch the **Input Mode** to **Editor** to write the filter as an object instead, using operators like `$eq`, `$gt`, `$contains`, and `$in`:
68+
**Filter** narrows the result. You can build rules visually - pick a column, an operator, and a value - or write the filter directly as a predicate. One condition names a field, an operator, and a value:
6969

7070
```
71-
{ status: "unprocessed", createdAt: { $gte: "2026-06-01" } }
71+
{"field": "status", "op": "eq", "value": "unprocessed"}
7272
```
7373

74-
**Sort Order** orders the result, again visually in Builder mode or as an object in Editor mode, for example `{ createdAt: "desc" }`. **Limit** caps how many rows come back (default 100, max 1000) and **Offset** skips rows for pagination.
74+
Combine conditions with `all` (AND) or `any` (OR), and nest the groups for mixed logic:
7575

76-
{/* VISUAL: Filter Conditions and Sort Order builders, showing a status = unprocessed rule and a createdAt descending sort, with the equivalent Editor-mode object beside them. */}
76+
```
77+
{"all": [
78+
{"field": "status", "op": "eq", "value": "unprocessed"},
79+
{"field": "createdAt", "op": "gte", "value": "2026-06-01"}
80+
]}
81+
```
82+
83+
**Order** sorts the result as a list of column/direction pairs, for example `[{"field": "createdAt", "direction": "desc"}]`. **Columns to Return** narrows each row to the fields a downstream step actually needs. **Limit** caps how many rows come back per page, and **Cursor** continues a previous page - see [Paginate large reads](#tips) below.
84+
85+
{/* VISUAL: Filter and Order builders, showing a status = unprocessed rule and a createdAt descending sort, with the equivalent predicate JSON beside them. */}
7786

7887
For a one-off point lookup, use **Get Row by ID** with a single `Row ID`. **Get Schema** returns the table's column definitions, useful when a workflow needs to inspect structure before writing. The full operator list lives in the [Table block reference](/integrations/table).
7988

@@ -125,7 +134,7 @@ After the run, the table holds the enriched rows. The next run queries them agai
125134

126135
**Iterate row by row.** Wrap a Query → process → update cycle in a [Loop block](/workflows/blocks/loop) to handle one row at a time. This runs sequentially, slower than a batch update but useful when each row needs its own multi-step logic. Inside the loop the Agent reads the current row and an Update Row by ID writes its result.
127136

128-
**Paginate large reads.** Query Rows returns at most 1000 rows, and a page can also end early once its rows reach the response size budget — so a page may come back shorter than your **Limit** even when more rows match. Advance **Offset** by the `rowCount` you actually received, not by the Limit you asked for, and keep going while `nextCursor` is set. Stop when `nextCursor` is null. Stepping by the Limit instead skips whatever a short page left behind.
137+
**Paginate large reads.** Omit **Limit** to get every matching row in one response; the query fails if the result exceeds 5MB, so narrow with a filter rather than guessing a limit. With a **Limit**, a page can end at the limit *or* early once its rows reach the 5MB budget — so a short page does not mean the end. Pass the returned `nextCursor` back as **Cursor** and keep going while it is non-null. Stop only when `nextCursor` is null; never infer completion from the row count.
129138

130139
## Inspecting reads and writes
131140

apps/docs/content/docs/workflows/triggers/table.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { BlockPreview } from '@/components/workflow-preview'
77

88
The **Table trigger** runs a workflow when a row is inserted or updated in a [Sim table](/tables). Use it to react to data changes — enrich a row when it's added, or send a follow-up when a status column flips.
99

10-
<BlockPreview type="table" />
10+
<BlockPreview type="table_v2" />
1111

1212
## Configuration
1313

apps/sim/blocks/blocks.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -853,6 +853,37 @@ describe.concurrent('Blocks Module', () => {
853853
expect(replacement?.hideFromToolbar).not.toBe(true)
854854
})
855855

856+
it('should keep the legacy table block registered but out of discovery', () => {
857+
const legacy = getBlock('table')
858+
const replacement = getBlock('table_v2')
859+
860+
// Placed instances must keep resolving and executing.
861+
expect(legacy).toBeDefined()
862+
expect(legacy?.tools.access).toContain('table_query_rows')
863+
// ...while the block itself is gone from the toolbar, search, and mentions.
864+
expect(legacy?.hideFromToolbar).toBe(true)
865+
expect(legacy?.sunset).toEqual({ status: 'legacy', replacedBy: 'table_v2' })
866+
expect(replacement).toBeDefined()
867+
expect(replacement?.hideFromToolbar).not.toBe(true)
868+
// GA: the reveal gate is gone, so it no longer depends on block-visibility.
869+
expect(replacement?.preview).toBeUndefined()
870+
expect(replacement?.tools.access).toContain('table_query_rows_v2')
871+
})
872+
873+
/**
874+
* Webhook execution gates on `triggers.enabled` at runtime, not on
875+
* discovery, so hiding v1 must not disable the trigger it hosts — every
876+
* deployed v1 table-trigger workflow depends on it staying live. Both
877+
* versions host the same trigger id.
878+
*/
879+
it("should keep the legacy table block's trigger enabled", () => {
880+
expect(getBlock('table')?.triggers).toEqual({
881+
enabled: true,
882+
available: ['table_new_row'],
883+
})
884+
expect(getBlock('table_v2')?.triggers?.available).toContain('table_new_row')
885+
})
886+
856887
/**
857888
* `openai_embeddings` is an alias of `embeddings_openai`, so the legacy
858889
* block's runtime payload gained `provider` and `dimensions`. Undeclared,

apps/sim/blocks/blocks/table.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,12 +206,18 @@ const SORT_FIELD = ['sortBuilder', 'sort'] as const
206206

207207
export const TableBlock: BlockConfig<TableQueryResponse> = {
208208
type: 'table',
209-
name: 'Table',
209+
name: 'Table (Legacy)',
210210
description: 'User-defined data tables',
211211
longDescription:
212212
'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.',
213213
docsLink: 'https://docs.sim.ai/integrations/table',
214214
category: 'blocks',
215+
// Superseded by table_v2 (GA): hidden from discovery like other legacy _vN
216+
// blocks; existing workflows keep executing it. `triggers.enabled` below
217+
// deliberately stays true — webhook execution gates on it at runtime, so
218+
// flipping it would break every deployed v1 table-trigger workflow.
219+
hideFromToolbar: true,
220+
sunset: { status: 'legacy', replacedBy: 'table_v2' },
215221
bgColor: '#10B981',
216222
icon: Table,
217223
canvasPresentation: {

apps/sim/blocks/blocks/table_v2.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -246,11 +246,6 @@ export const TableV2Block: BlockConfig<TableQueryV2Response> = {
246246
- Use Columns to Return to keep only the fields a downstream step needs (e.g. ["col_email","name"]) — the 5MB budget counts only the returned columns, so narrowing columns is another way to fit a large table; leave it empty for every column.`,
247247
docsLink: 'https://docs.sim.ai/integrations/table',
248248
category: 'blocks',
249-
// Unreleased: hidden from every discovery surface until revealed via the hosted
250-
// `block-visibility` AppConfig document or the `PREVIEW_BLOCKS` env allowlist.
251-
// Placed instances always execute. At GA: drop this, add the BlockMeta + docs,
252-
// and mark v1 `table` superseded.
253-
preview: true,
254249
bgColor: '#10B981',
255250
icon: Table,
256251
canvasPresentation: {

apps/sim/lib/integrations/icon-mapping.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
525525
stt_v2: STTIcon,
526526
supabase: SupabaseIcon,
527527
table: Table,
528+
table_v2: Table,
528529
tailscale: TailscaleIcon,
529530
tavily: TavilyIcon,
530531
telegram: TelegramIcon,

0 commit comments

Comments
 (0)