Skip to content

Commit ee1fc37

Browse files
fix(tables): allow unbounded v1 row queries (#6713)
* fix(tables): allow unbounded v1 row queries * fix(tables): drain under-budget queries fully * fix(tables): bound expanded query metadata * fix(tables): always return query totals
1 parent daff022 commit ee1fc37

12 files changed

Lines changed: 149 additions & 36 deletions

File tree

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
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.
5858

5959

6060

@@ -213,7 +213,7 @@ Query rows from a table with filtering, sorting, and pagination
213213
| `tableId` | string | Yes | Table ID |
214214
| `filter` | object | No | Filter conditions \(MongoDB-style operators: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $contains, $ncontains, $startsWith, $endsWith, $empty\) |
215215
| `sort` | object | No | Sort order as \{field: "asc"\|"desc"\} |
216-
| `limit` | number | No | Maximum rows to return \(default: $\{TABLE_LIMITS.DEFAULT_QUERY_LIMIT\}, max: $\{TABLE_LIMITS.MAX_QUERY_LIMIT\}\) |
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. |
217217
| `offset` | number | No | Number of rows to skip \(default: 0\) |
218218

219219
#### Output

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,33 @@ describe('GET /api/table/[tableId]/rows', () => {
208208
expect(body.data.rows[0].data).toEqual({ Name: 'Ada', Age: 36 })
209209
})
210210

211+
it('keeps counts but skips execution metadata for an omitted or expanded limit', async () => {
212+
authAs('internal_jwt')
213+
214+
const omitted = await callGet({ workspaceId: 'workspace-1' })
215+
expect(omitted.status).toBe(200)
216+
expect(mockQueryRows.mock.calls[0][1]).toEqual(
217+
expect.objectContaining({ limit: undefined, includeTotal: true, withExecutions: false })
218+
)
219+
220+
const expanded = await callGet({ workspaceId: 'workspace-1', limit: '1000000' })
221+
expect(expanded.status).toBe(200)
222+
expect(mockQueryRows.mock.calls[1][1]).toEqual(
223+
expect.objectContaining({ limit: 1000000, includeTotal: true, withExecutions: false })
224+
)
225+
})
226+
227+
it('retains metadata loading within the former query limit', async () => {
228+
authAs('internal_jwt')
229+
230+
const res = await callGet({ workspaceId: 'workspace-1', limit: '1000' })
231+
232+
expect(res.status).toBe(200)
233+
expect(mockQueryRows.mock.calls[0][1]).toEqual(
234+
expect.objectContaining({ limit: 1000, includeTotal: true, withExecutions: true })
235+
)
236+
})
237+
211238
it('passes id-keyed filter and rows through untouched for session callers', async () => {
212239
authAs('session')
213240

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
validateRowData,
2727
validateRowSize,
2828
} from '@/lib/table'
29+
import { TABLE_LIMITS } from '@/lib/table/constants'
2930
import { TableQueryValidationError } from '@/lib/table/errors'
3031
import { signalTableRowsChanged, signalTableRowsChangedByActor } from '@/lib/table/events'
3132
import { isTablePredicate, predicateToFilter } from '@/lib/table/query-builder/converters'
@@ -358,6 +359,13 @@ export const GET = withRouteHandler(
358359
}
359360

360361
const wire = rowWireTranslators(authResult.authType, table.schema as TableSchema)
362+
/**
363+
* The newly expanded path can return up to the byte budget, so skip the
364+
* per-row execution-sidecar load. Keep the count behavior unchanged so
365+
* Query Rows continues to return totalCount for workflow callers.
366+
*/
367+
const isExpandedQuery =
368+
validated.limit === undefined || validated.limit > TABLE_LIMITS.MAX_QUERY_LIMIT
361369
const result = await queryRows(
362370
table,
363371
{
@@ -381,6 +389,7 @@ export const GET = withRouteHandler(
381389
offset: validated.offset,
382390
after: validated.after,
383391
includeTotal: validated.includeTotal,
392+
withExecutions: !isExpandedQuery,
384393
},
385394
requestId
386395
)
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it, vi } from 'vitest'
5+
6+
vi.mock('@/triggers', () => ({
7+
getTrigger: vi.fn(() => ({ subBlocks: [] })),
8+
}))
9+
10+
import { TableBlock } from '@/blocks/blocks/table'
11+
12+
function params(input: Record<string, unknown>): Record<string, unknown> {
13+
return TableBlock.tools.config?.params?.(input as never) as Record<string, unknown>
14+
}
15+
16+
describe('table query_rows transformer', () => {
17+
it('keeps an omitted limit unbounded', () => {
18+
expect(params({ operation: 'query_rows', tableId: 'table-1' }).limit).toBeUndefined()
19+
})
20+
21+
it('parses and validates an explicit limit', () => {
22+
expect(params({ operation: 'query_rows', tableId: 'table-1', limit: '25' }).limit).toBe(25)
23+
expect(params({ operation: 'query_rows', tableId: 'table-1', limit: '1000000' }).limit).toBe(
24+
1000000
25+
)
26+
expect(() => params({ operation: 'query_rows', tableId: 'table-1', limit: 'abc' })).toThrow(
27+
/Invalid number for Limit/
28+
)
29+
})
30+
})

apps/sim/blocks/blocks/table.ts

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { toError } from '@sim/utils/errors'
33
import { TABLE_LIMITS } from '@/lib/table/constants'
44
import { filterRulesToFilter, sortRulesToSort } from '@/lib/table/query-builder/converters'
55
import type { BlockConfig } from '@/blocks/types'
6+
import { parseOptionalNumberInput } from '@/blocks/utils'
67
import type { TableQueryResponse } from '@/tools/table/types'
78
import { getTrigger } from '@/triggers'
89

@@ -113,7 +114,11 @@ const paramTransformers: Record<string, (params: TableBlockParams) => ParsedPara
113114
tableId: params.tableId,
114115
filter,
115116
data: parseJSON(params.data, 'Row Data'),
116-
limit: params.limit ? Number.parseInt(params.limit) : undefined,
117+
limit: parseOptionalNumberInput(params.limit, 'Limit', {
118+
integer: true,
119+
min: 1,
120+
max: TABLE_LIMITS.MAX_BULK_OPERATION_SIZE,
121+
}),
117122
}
118123
},
119124

@@ -136,7 +141,11 @@ const paramTransformers: Record<string, (params: TableBlockParams) => ParsedPara
136141
return {
137142
tableId: params.tableId,
138143
filter,
139-
limit: params.limit ? Number.parseInt(params.limit) : undefined,
144+
limit: parseOptionalNumberInput(params.limit, 'Limit', {
145+
integer: true,
146+
min: 1,
147+
max: TABLE_LIMITS.MAX_BULK_OPERATION_SIZE,
148+
}),
140149
}
141150
},
142151

@@ -171,8 +180,11 @@ const paramTransformers: Record<string, (params: TableBlockParams) => ParsedPara
171180
tableId: params.tableId,
172181
filter,
173182
sort,
174-
limit: params.limit ? Number.parseInt(params.limit) : 100,
175-
offset: params.offset ? Number.parseInt(params.offset) : 0,
183+
limit: parseOptionalNumberInput(params.limit, 'Limit', {
184+
integer: true,
185+
min: 1,
186+
}),
187+
offset: parseOptionalNumberInput(params.offset, 'Offset', { integer: true, min: 0 }) ?? 0,
176188
}
177189
},
178190
}
@@ -197,7 +209,7 @@ export const TableBlock: BlockConfig<TableQueryResponse> = {
197209
name: 'Table',
198210
description: 'User-defined data tables',
199211
longDescription:
200-
'Create and manage custom data tables. Store, query, and manipulate structured data within workflows.',
212+
'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.',
201213
docsLink: 'https://docs.sim.ai/integrations/table',
202214
category: 'blocks',
203215
bgColor: '#10B981',
@@ -652,7 +664,7 @@ Return ONLY the sort JSON:`,
652664
id: 'limit',
653665
title: 'Limit',
654666
type: 'short-input',
655-
placeholder: '100',
667+
placeholder: 'Leave empty for all rows (fails over 5MB)',
656668
condition: {
657669
field: 'operation',
658670
value: ['query_rows', 'update_rows_by_filter', 'delete_rows_by_filter'],
@@ -726,7 +738,11 @@ Return ONLY the sort JSON:`,
726738
description: 'Visual filter builder conditions for bulk operations',
727739
},
728740
filter: { type: 'json', description: 'Filter criteria for query/update/delete operations' },
729-
limit: { type: 'number', description: 'Query or bulk operation limit' },
741+
limit: {
742+
type: 'number',
743+
description:
744+
'Optional query row limit; omit to return every matching row (fails over 5MB). Also caps bulk update/delete operations.',
745+
},
730746
builderMode: {
731747
type: 'string',
732748
description: 'Input mode for filter and sort (builder or json)',

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,20 @@ describe('tableRowsQuerySchema includeTotal', () => {
4343
})
4444
})
4545

46+
describe('tableRowsQuerySchema limit', () => {
47+
it('leaves an omitted or empty limit unbounded', () => {
48+
expect(tableRowsQuerySchema.parse({ workspaceId: 'ws-1' }).limit).toBeUndefined()
49+
expect(tableRowsQuerySchema.parse({ workspaceId: 'ws-1', limit: '' }).limit).toBeUndefined()
50+
})
51+
52+
it('still parses and validates an explicit limit', () => {
53+
expect(tableRowsQuerySchema.parse({ workspaceId: 'ws-1', limit: '25' }).limit).toBe(25)
54+
expect(tableRowsQuerySchema.parse({ workspaceId: 'ws-1', limit: '1000000' }).limit).toBe(
55+
1000000
56+
)
57+
})
58+
})
59+
4660
describe('tableEventStreamQuerySchema', () => {
4761
it('parses an explicit cursor', () => {
4862
expect(tableEventStreamQuerySchema.parse({ from: '7' })).toEqual({ from: 7 })

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

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -820,11 +820,21 @@ export const tableRowsQueryBaseSchema = z.object({
820820
.default(true),
821821
})
822822

823-
export const tableRowsQuerySchema = tableRowsQueryBaseSchema.refine(
824-
(data) => !(data.after && data.sort),
825-
{ message: 'after cursor cannot be combined with sort — cursors paginate the default order' }
823+
const unboundedTableRowsLimitSchema = z.preprocess(
824+
(value) => (value === null || value === undefined || value === '' ? undefined : Number(value)),
825+
z
826+
.number({ error: 'Limit must be a number' })
827+
.int('Limit must be an integer')
828+
.min(1, 'Limit must be at least 1')
829+
.optional()
826830
)
827831

832+
export const tableRowsQuerySchema = tableRowsQueryBaseSchema
833+
.extend({ limit: unboundedTableRowsLimitSchema })
834+
.refine((data) => !(data.after && data.sort), {
835+
message: 'after cursor cannot be combined with sort — cursors paginate the default order',
836+
})
837+
828838
export const updateRowsByFilterBodySchema = z.object({
829839
workspaceId: workspaceIdSchema,
830840
filter: bulkFilterSchema,
@@ -1063,14 +1073,7 @@ export const rowQueryBodySchema = z.object({
10631073
// Omitted limit returns the ENTIRE matching result, failing fast (400) when
10641074
// it exceeds the response byte budget. An explicit limit caps the page row
10651075
// count; the byte budget may still end a page early with nextCursor set.
1066-
limit: z.preprocess(
1067-
(value) => (value === null || value === undefined || value === '' ? undefined : Number(value)),
1068-
z
1069-
.number({ error: 'Limit must be a number' })
1070-
.int('Limit must be an integer')
1071-
.min(1, 'Limit must be at least 1')
1072-
.optional()
1073-
),
1076+
limit: unboundedTableRowsLimitSchema,
10741077
cursor: z.string().min(1, 'cursor must be a non-empty token').optional(),
10751078
})
10761079

apps/sim/lib/table/__tests__/service-filter-threading.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,21 @@ describe('queryRows byte budget', () => {
219219
updatedAt: new Date('2024-01-01'),
220220
})
221221

222+
const mockRowsPastFormerBatchSafetyLimit = () => {
223+
const largeRow = row(1, TABLE_LIMITS.MAX_ROW_SIZE_BYTES)
224+
const smallRow = row(2, 0)
225+
const state = { drainBatch: 0 }
226+
dbChainMockFns.limit.mockResolvedValueOnce([])
227+
dbChainMockFns.limit.mockImplementation(async (ask: number) => {
228+
state.drainBatch++
229+
if (state.drainBatch > 1001) return []
230+
const rows = Array.from({ length: ask }, () => smallRow)
231+
if (state.drainBatch === 1) rows[0] = largeRow
232+
return rows
233+
})
234+
return state
235+
}
236+
222237
it('returns an empty page with a null cursor', async () => {
223238
const result = await queryRows(TABLE, { includeTotal: false, withExecutions: false }, 'req-1')
224239
expect(result.rows).toEqual([])
@@ -246,6 +261,16 @@ describe('queryRows byte budget', () => {
246261
expect(result.nextCursor).toBeNull()
247262
})
248263

264+
it('returns an entire under-budget result past the former batch safety limit', async () => {
265+
const state = mockRowsPastFormerBatchSafetyLimit()
266+
267+
const result = await queryRows(TABLE, { includeTotal: false, withExecutions: false }, 'req-1')
268+
269+
expect(state.drainBatch).toBe(1002)
270+
expect(result.rows.length).toBeGreaterThan(TABLE_LIMITS.MAX_QUERY_LIMIT)
271+
expect(result.nextCursor).toBeNull()
272+
})
273+
249274
it('byte-cuts a BOUNDED page and returns a resume cursor instead of throwing', async () => {
250275
const perRow = Math.floor(TABLE_LIMITS.MAX_QUERY_RESULT_BYTES * 0.6)
251276
dbChainMockFns.limit.mockResolvedValueOnce([])

apps/sim/lib/table/llm/enrichment.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ export function enrichTableToolParameters(
161161
if (enrichedProperties.limit && toolId === 'table_query_rows') {
162162
enrichedProperties.limit = {
163163
...enrichedProperties.limit,
164-
description: `Maximum rows to return (min: 1, max: 1000, default: 100). For ranking queries: use limit=1 for highest/lowest, limit=2 for second highest, etc.`,
164+
description: `Maximum rows to return (min: 1). Omit to return every matching row; the query fails if the result exceeds 5MB. For ranking queries: use limit=1 for highest/lowest, limit=2 for second highest, etc.`,
165165
}
166166
}
167167

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

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1270,17 +1270,6 @@ interface BoundedFetchResult {
12701270
anchorOffset: number
12711271
}
12721272

1273-
/**
1274-
* Belt-and-braces bound on drain iterations.
1275-
*
1276-
* Unreachable only because every iteration either consumes at least one row or cuts, and a bounded
1277-
* page's `limit` is capped at {@link TABLE_LIMITS.MAX_QUERY_LIMIT} — so the limit cut always fires
1278-
* first. That makes the two constants exactly tight: raising `MAX_QUERY_LIMIT` above this bound
1279-
* would let the loop exit with rows still unread and `hasMore: false`, which clients now trust as
1280-
* end-of-table (they terminate on `nextCursor`, which this decides). Raise both together.
1281-
*/
1282-
const MAX_QUERY_BATCHES = 1000
1283-
12841273
/**
12851274
* Drains rows in adaptively-sized bounded batches until the caller's `limit`
12861275
* or the byte ceiling ends the page. Never issues an unbounded SELECT: the
@@ -1364,7 +1353,7 @@ async function fetchRowsBounded(params: BoundedFetchParams): Promise<BoundedFetc
13641353
return withReadGuards(async (trx) => buildQuery(trx), { seqscanOff: sorted })
13651354
}
13661355

1367-
for (let iteration = 0; iteration < MAX_QUERY_BATCHES; iteration++) {
1356+
while (true) {
13681357
const limitRemaining = limit === undefined ? Number.POSITIVE_INFINITY : limit - rows.length
13691358
const target = Math.min(nextBatchRows(), limitRemaining)
13701359
const ask = target + 1 // +1 = witness row proving more data exists past a cut

0 commit comments

Comments
 (0)