Skip to content

Commit d0d3a42

Browse files
fix(tables): cap row pages and use native cursors
1 parent 7f39678 commit d0d3a42

12 files changed

Lines changed: 119 additions & 79 deletions

File tree

apps/docs/openapi-v2-tables.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -710,7 +710,7 @@
710710
"get": {
711711
"operationId": "listTableRows",
712712
"summary": "List Rows",
713-
"description": "List a plain cursor page in default row order. Use the query endpoint for predicate filtering and sorting.",
713+
"description": "List a plain cursor page in default row order. Pages are capped at 5MB by default and may contain fewer rows than the requested limit; continue until nextCursor is null. Use the query endpoint for predicate filtering and sorting.",
714714
"tags": ["Tables"],
715715
"parameters": [
716716
{
@@ -1383,7 +1383,7 @@
13831383
"post": {
13841384
"operationId": "queryTableRows",
13851385
"summary": "Query Rows",
1386-
"description": "Query rows with a typed predicate, ordered sort specification, and opaque cursor pagination.",
1386+
"description": "Query rows with a typed predicate, ordered sort specification, and opaque cursor pagination. Bounded pages are capped at 5MB by default and may contain fewer rows than the requested limit; continue until nextCursor is null.",
13871387
"tags": ["Tables"],
13881388
"parameters": [
13891389
{

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

Lines changed: 10 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ describe('/api/v2/tables/[tableId]/rows', () => {
9494
mocks.preauthRate.mockResolvedValue(RATE)
9595
mocks.operationRate.mockResolvedValue(RATE)
9696
mocks.gate.mockResolvedValue(null)
97-
mocks.listRows.mockResolvedValue({ table: TABLE, rows: [ROW], nextOffset: null })
97+
mocks.listRows.mockResolvedValue({ table: TABLE, rows: [ROW], nextCursor: null })
9898
mocks.createRows.mockResolvedValue({ kind: 'single', table: TABLE, row: ROW })
9999
mocks.updateRows.mockResolvedValue({
100100
table: TABLE,
@@ -111,31 +111,13 @@ describe('/api/v2/tables/[tableId]/rows', () => {
111111
})
112112
})
113113

114-
/**
115-
* Coercing an undecodable cursor to offset 0 re-served page one while the
116-
* client believed it was paging forward, which loops a paging client forever.
117-
* Every sibling v2 cursor list rejects instead, so this one does too.
118-
*/
119-
it.each([
120-
['undecodable base64-JSON', 'malformed'],
121-
['a payload with no offset', Buffer.from(JSON.stringify({ o: 5 })).toString('base64')],
122-
['a non-integer offset', Buffer.from(JSON.stringify({ offset: 1.5 })).toString('base64')],
123-
['a negative offset', Buffer.from(JSON.stringify({ offset: -1 })).toString('base64')],
124-
])('rejects a GET cursor with %s instead of restarting pagination', async (_label, cursor) => {
125-
const req = request(
126-
'GET',
127-
undefined,
128-
`?workspaceId=${WORKSPACE_ID}&limit=25&cursor=${encodeURIComponent(cursor)}`
129-
)
130-
const response = await GET(req, CONTEXT)
131-
132-
expect(response.status).toBe(400)
133-
expect((await response.json()).error).toMatchObject({ message: 'Invalid cursor' })
134-
expect(mocks.listRows).not.toHaveBeenCalled()
135-
})
136-
137-
it('resumes at the encoded offset for a well-formed cursor', async () => {
138-
const cursor = Buffer.from(JSON.stringify({ offset: 50 })).toString('base64')
114+
it('passes the opaque native row cursor through the route unchanged', async () => {
115+
const cursor = 'native-row-cursor'
116+
mocks.listRows.mockResolvedValue({
117+
table: TABLE,
118+
rows: [ROW],
119+
nextCursor: 'next-native-cursor',
120+
})
139121
const req = request(
140122
'GET',
141123
undefined,
@@ -150,10 +132,11 @@ describe('/api/v2/tables/[tableId]/rows', () => {
150132
tableId: 'table-1',
151133
assertedWorkspaceId: WORKSPACE_ID,
152134
limit: 25,
153-
offset: 50,
135+
cursor,
154136
},
155137
request: req,
156138
})
139+
expect((await response.json()).nextCursor).toBe('next-native-cursor')
157140
})
158141

159142
it('delegates single and batch creation through one semantic use case', async () => {

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

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import {
1414
updateTableRows,
1515
} from '@/lib/table/application/rows'
1616
import { namedRowMapper } from '@/lib/table/cell-format'
17-
import { decodeOffsetCursor, encodeCursor } from '@/app/api/v2/lib/response'
1817
import { toApiRow } from '@/app/api/v2/tables/utils'
1918

2019
export const dynamic = 'force-dynamic'
@@ -30,14 +29,14 @@ export const GET = defineV2JsonRoute({
3029
tableId: params.tableId,
3130
assertedWorkspaceId: query.workspaceId,
3231
limit: query.limit,
33-
offset: decodeOffsetCursor(query.cursor),
32+
cursor: query.cursor,
3433
}),
3534
useCase: listTableRows,
36-
present: ({ table, rows, nextOffset }) => {
35+
present: ({ table, rows, nextCursor }) => {
3736
const toNamedRow = namedRowMapper(table.schema.columns)
3837
return {
3938
data: rows.map((row) => toApiRow(row, toNamedRow)),
40-
nextCursor: nextOffset === null ? null : encodeCursor({ offset: nextOffset }),
39+
nextCursor,
4140
}
4241
},
4342
})

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,7 @@ const routes = [
358358
operationId: 'listTableRows',
359359
summary: 'List Rows',
360360
description:
361-
'List a plain cursor page in default row order. Use the query endpoint for predicate filtering and sorting.',
361+
'List a plain cursor page in default row order. Pages are capped at 5MB by default and may contain fewer rows than the requested limit; continue until nextCursor is null. Use the query endpoint for predicate filtering and sorting.',
362362
errors: RESOURCE_ERRORS,
363363
success: { description: 'A page of table rows.' },
364364
}),
@@ -619,7 +619,7 @@ const routes = [
619619
operationId: 'queryTableRows',
620620
summary: 'Query Rows',
621621
description:
622-
'Query rows with a typed predicate, ordered sort specification, and opaque cursor pagination.',
622+
'Query rows with a typed predicate, ordered sort specification, and opaque cursor pagination. Bounded pages are capped at 5MB by default and may contain fewer rows than the requested limit; continue until nextCursor is null.',
623623
errors: RESOURCE_ERRORS,
624624
success: { description: 'A page of matching table rows.' },
625625
}),

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -644,9 +644,9 @@ export const v2DeleteTableColumnContract = defineRouteContract({
644644
/**
645645
* Row list query: a plain cursor page over the default row order. Filtering and
646646
* sorting are NOT part of this surface — rich reads go through the dedicated
647-
* `POST /query` endpoint's predicate grammar. The opaque cursor encodes the
648-
* underlying offset today; it can move to a keyset implementation later without
649-
* an interface change. Total row count is available as `rowCount` on the table.
647+
* `POST /query` endpoint's predicate grammar. The opaque cursor uses the
648+
* `(order_key, id)` keyset when possible and handles legacy rows without an order
649+
* key internally. Total row count is available as `rowCount` on the table.
650650
*/
651651
export const v2TableRowsQuerySchema = tableRowsQueryBaseSchema
652652
.pick({ workspaceId: true, limit: true })

apps/sim/lib/core/config/env.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ export const env = createEnv({
138138
ENTERPRISE_TABLES_LIMIT: z.number().optional(), // Max user tables per workspace on enterprise tier (default: 10000)
139139
ENTERPRISE_TABLE_ROWS_LIMIT: z.number().optional(), // Max rows per table on enterprise tier (default: 1000000)
140140
TABLE_MAX_ROW_SIZE_BYTES: z.number().optional(), // Max serialized size in bytes of a single user-table row (default: 409600)
141-
TABLE_MAX_PAGE_BYTES: z.number().optional(), // Byte budget per row-page read; pages cut early past it (unset = disabled)
141+
TABLE_MAX_PAGE_BYTES: z.number().optional(), // Byte budget per row-page read; pages cut early past it (default: 5242880)
142142
TABLE_DISPATCH_CONCURRENCY_FREE: z.number().optional(), // Rows one table run executes in parallel on free tier (default: 20)
143143
TABLE_DISPATCH_CONCURRENCY_PAID: z.number().optional(), // Rows one table run executes in parallel on paid tiers (default: 50)
144144

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

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -204,9 +204,7 @@ describe('queryRows byte budget', () => {
204204
beforeEach(() => {
205205
vi.clearAllMocks()
206206
resetDbChainMock()
207-
// The bounded-page byte cut is opt-in; pin it on rather than inheriting
208-
// whatever the developer's local `.env` happens to set.
209-
setEnv({ TABLE_MAX_PAGE_BYTES: TABLE_LIMITS.MAX_QUERY_RESULT_BYTES })
207+
setEnv({ TABLE_MAX_PAGE_BYTES: undefined })
210208
})
211209

212210
const row = (i: number, blobBytes: number) => ({
@@ -265,12 +263,9 @@ describe('queryRows byte budget', () => {
265263
})
266264
})
267265

268-
it('does NOT byte-cut a bounded page when TABLE_MAX_PAGE_BYTES is unset', async () => {
269-
// Default-off: a short page is only safe for a client that terminates on
270-
// `nextCursor === null`. A pre-existing v1 pager stopping at
271-
// `rows.length < limit` would read the cut as end-of-data and truncate.
272-
setEnv({ TABLE_MAX_PAGE_BYTES: undefined })
273-
const perRow = Math.floor(TABLE_LIMITS.MAX_QUERY_RESULT_BYTES * 0.6)
266+
it('honors a smaller bounded-page byte override', async () => {
267+
setEnv({ TABLE_MAX_PAGE_BYTES: 3 * 1024 * 1024 })
268+
const perRow = 2 * 1024 * 1024
274269
dbChainMockFns.limit.mockResolvedValueOnce([])
275270
dbChainMockFns.limit.mockResolvedValueOnce([row(1, perRow), row(2, perRow)])
276271

@@ -280,8 +275,8 @@ describe('queryRows byte budget', () => {
280275
'req-1'
281276
)
282277

283-
expect(result.rows).toHaveLength(2)
284-
expect(result.nextCursor).toBeNull()
278+
expect(result.rows).toHaveLength(1)
279+
expect(result.nextCursor).not.toBeNull()
285280
})
286281

287282
it('still fails fast on an UNBOUNDED query with TABLE_MAX_PAGE_BYTES unset', async () => {

apps/sim/lib/table/application/rows.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ vi.mock('@/lib/table/events', () => ({
142142
import {
143143
createTableRows,
144144
deleteTableRows,
145+
listTableRows,
145146
ProjectedWireRowsValidationError,
146147
queryTableRows,
147148
replaceProjectedWireRows,
@@ -152,6 +153,7 @@ import {
152153
updateTableRows,
153154
upsertTableRow,
154155
} from '@/lib/table/application/rows'
156+
import { encodeCursor } from '@/lib/table/rows/cursor'
155157

156158
const TABLE: TableDefinition = {
157159
id: 'table-1',
@@ -584,6 +586,49 @@ describe('row query and upsert application semantics', () => {
584586
expect(mockLoadSecretProvenance).not.toHaveBeenCalled()
585587
})
586588

589+
it('rejects a malformed list cursor before querying storage', async () => {
590+
await expect(
591+
listTableRows.execute({
592+
principal: PRINCIPAL,
593+
input: { tableId: TABLE.id, limit: 25, cursor: 'malformed' },
594+
})
595+
).rejects.toMatchObject({ details: { code: 'INVALID_CURSOR' } })
596+
597+
expect(mockQueryRows).not.toHaveBeenCalled()
598+
})
599+
600+
it('passes the native row cursor through a short list page', async () => {
601+
const cursor = encodeCursor({
602+
lastRow: { id: 'row-50', orderKey: 'a50' },
603+
keysetValid: true,
604+
nextOffset: 50,
605+
})
606+
mockQueryRows.mockResolvedValueOnce({
607+
rows: [{ id: 'row-51', data: {} }],
608+
rowCount: 1,
609+
totalCount: null,
610+
nextCursor: 'native-next-cursor',
611+
})
612+
613+
const result = await listTableRows.execute({
614+
principal: PRINCIPAL,
615+
input: { tableId: TABLE.id, limit: 25, cursor },
616+
})
617+
618+
expect(mockQueryRows).toHaveBeenCalledWith(
619+
TABLE,
620+
{
621+
limit: 25,
622+
after: { id: 'row-50', orderKey: 'a50' },
623+
offset: undefined,
624+
includeTotal: false,
625+
withExecutions: false,
626+
},
627+
expect.any(String)
628+
)
629+
expect(result.nextCursor).toBe('native-next-cursor')
630+
})
631+
587632
it('loads requested persisted provenance inside the authorized application query', async () => {
588633
const row = {
589634
id: 'row-1',

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

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -192,38 +192,37 @@ function rethrowQueryValidation(error: unknown): never {
192192

193193
export interface ListTableRowsInput extends TableScopedInput {
194194
limit: number
195-
offset: number
195+
cursor?: string
196196
}
197197

198198
export interface ListTableRowsResult extends TableResult {
199199
rows: TableRow[]
200-
nextOffset: number | null
200+
nextCursor: string | null
201201
}
202202

203203
export const listTableRows = defineAuthorizedTableUseCase({
204204
operation: tableOperations.listRows,
205205
resolveContext: ({ input }: { input: ListTableRowsInput }) => resolveActiveTableContext(input),
206206
async execute({ input, context }): Promise<ListTableRowsResult> {
207207
requireIntegerInRange(input.limit, 1, TABLE_LIMITS.MAX_QUERY_LIMIT, 'Limit')
208-
if (!Number.isSafeInteger(input.offset) || input.offset < 0) {
209-
throw new TableRowsValidationError('Offset must be 0 or greater')
210-
}
211208
try {
209+
const cursor = input.cursor ? decodeCursor(input.cursor) : undefined
210+
if (cursor) assertCursorSortBinding(cursor, undefined)
212211
const result = await queryRows(
213212
context.table,
214213
{
215214
limit: input.limit,
216-
offset: input.offset,
217-
includeTotal: true,
215+
after: cursor?.after,
216+
offset: cursor?.offset,
217+
includeTotal: false,
218218
withExecutions: false,
219219
},
220220
requestId(input)
221221
)
222-
const total = result.totalCount ?? 0
223222
return {
224223
table: context.table,
225224
rows: result.rows,
226-
nextOffset: input.offset + result.rowCount < total ? input.offset + input.limit : null,
225+
nextCursor: result.nextCursor,
227226
}
228227
} catch (error) {
229228
rethrowQueryValidation(error)

apps/sim/lib/table/constants.test.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,11 @@ declare module '@/lib/table/constants?constants-test' {
3737
export * from '@/lib/table/constants'
3838
}
3939

40-
import { getBillingDisabledTableLimits } from '@/lib/table/constants?constants-test'
40+
import {
41+
getBillingDisabledTableLimits,
42+
getMaxPageBytes,
43+
TABLE_LIMITS,
44+
} from '@/lib/table/constants?constants-test'
4145

4246
describe('getBillingDisabledTableLimits', () => {
4347
beforeEach(() => {
@@ -66,3 +70,19 @@ describe('getBillingDisabledTableLimits', () => {
6670
})
6771
})
6872
})
73+
74+
describe('getMaxPageBytes', () => {
75+
beforeEach(() => {
76+
for (const key of Object.keys(mockEnv)) delete mockEnv[key]
77+
})
78+
79+
it('defaults bounded pages to the 5MB query-result budget', () => {
80+
expect(getMaxPageBytes()).toBe(TABLE_LIMITS.MAX_QUERY_RESULT_BYTES)
81+
})
82+
83+
it('allows a positive integer environment override', () => {
84+
mockEnv.TABLE_MAX_PAGE_BYTES = String(2 * 1024 * 1024)
85+
86+
expect(getMaxPageBytes()).toBe(2 * 1024 * 1024)
87+
})
88+
})

0 commit comments

Comments
 (0)