Skip to content

Commit 9159d1e

Browse files
committed
fix(tables): stop every table paginating forever on a null totalCount
1 parent b9a70e4 commit 9159d1e

6 files changed

Lines changed: 123 additions & 20 deletions

File tree

apps/sim/hooks/queries/tables.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ type TableRowsParams = Omit<TableRowsQueryInput, 'filter' | 'sort'> &
140140

141141
export type TableRowsResponse = Pick<
142142
ContractJsonResponse<typeof listTableRowsContract>['data'],
143-
'rows' | 'totalCount'
143+
'rows' | 'totalCount' | 'nextCursor'
144144
>
145145

146146
interface RowMutationContext {
@@ -195,8 +195,13 @@ async function fetchTableRows({
195195
},
196196
signal,
197197
})
198-
const { rows, totalCount } = response.data
199-
return { rows, totalCount }
198+
const { rows, totalCount, nextCursor } = response.data
199+
/**
200+
* `nextCursor` is kept because it is the only authoritative end-of-table signal: the server
201+
* sets it exactly when the drain proved an unreturned witness row, so it covers a page cut by
202+
* the byte budget as well as one cut by `limit`. See {@link hasMoreTableRows}.
203+
*/
204+
return { rows, totalCount, nextCursor }
200205
}
201206

202207
function invalidateRowCount(queryClient: ReturnType<typeof useQueryClient>, tableId: string) {

apps/sim/hooks/queries/utils/table-rows-pagination.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,35 @@ describe('hasMoreTableRows', () => {
5151
it('returns false when a stale-low count is already exceeded', () => {
5252
expect(hasMoreTableRows([makePage(10, 5)])).toBe(false)
5353
})
54+
55+
/**
56+
* The server sets `nextCursor` exactly when the drain proved an unreturned witness row, so it
57+
* answers correctly for a page cut by the byte budget — where both page fullness and the count
58+
* mislead. It therefore wins over the count rules whenever it is present.
59+
*/
60+
describe('nextCursor', () => {
61+
it('ends the drain on a null cursor even when the count claims more rows', () => {
62+
expect(hasMoreTableRows([{ ...makePage(36, 100), nextCursor: null }])).toBe(false)
63+
})
64+
65+
it('continues on a non-null cursor even when the count is already covered', () => {
66+
// A byte-cut page: fewer rows than asked for, and the advisory count disagrees.
67+
expect(hasMoreTableRows([{ ...makePage(3, 3), nextCursor: 'c1' }])).toBe(true)
68+
})
69+
70+
it('reads the cursor from the last page, not page 0', () => {
71+
const pages = [
72+
{ ...makePage(1000, null), nextCursor: 'c1' },
73+
{ ...makePage(12, null, 1000), nextCursor: null },
74+
]
75+
expect(hasMoreTableRows(pages)).toBe(false)
76+
})
77+
78+
it('falls back to the count rules for pages cached before the cursor was threaded through', () => {
79+
expect(hasMoreTableRows([makePage(36, 100)])).toBe(true)
80+
expect(hasMoreTableRows([makePage(3, 3)])).toBe(false)
81+
})
82+
})
5483
})
5584

5685
describe('getNextTableRowsPageParam', () => {

apps/sim/hooks/queries/utils/table-rows-pagination.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ export type TableRowsPageParam = number | TableRowsCursor
99
interface TableRowsPageLike {
1010
rows: ReadonlyArray<{ id: string; orderKey?: string }>
1111
totalCount: number | null
12+
/**
13+
* Optional because pages cached before this field was threaded through predate it; those fall
14+
* back to the count rules below.
15+
*/
16+
nextCursor?: string | null
1217
}
1318

1419
/** Rows loaded across all fetched pages. */
@@ -17,18 +22,23 @@ export function countLoadedTableRows(pages: readonly TableRowsPageLike[]): numbe
1722
}
1823

1924
/**
20-
* Whether more rows may exist past the fetched pages. A page is terminal only when it is
21-
* empty or when page 0's `COUNT(*)` is already covered — never when it is merely shorter
22-
* than the requested page size, so a short server page can never be misread as end-of-table.
25+
* Whether more rows may exist past the fetched pages.
2326
*
24-
* `totalCount` is advisory (computed in a separate transaction from the page read). A
25-
* stale-high count self-corrects via the empty-page rule at the cost of one extra request;
26-
* a stale-low count (rows deleted after page 0's COUNT) stops the drain early — accepted,
27-
* since the view is already stale and the run-stream/interval invalidations refetch it.
27+
* `nextCursor` is the authoritative answer and is preferred whenever the server sent one: it is
28+
* non-null exactly when the drain proved an unreturned witness row, so it is correct for a page
29+
* cut by the byte budget as well as one cut by `limit`. Page fullness cannot answer this — a
30+
* byte-cut page is legitimately shorter than the requested size.
31+
*
32+
* The count rules remain as a fallback for pages cached before `nextCursor` was threaded through.
33+
* They are weaker: `totalCount` is advisory (computed in a separate transaction from the page
34+
* read), so a stale-high count self-corrects via the empty-page rule at the cost of one extra
35+
* request, and a stale-low count stops the drain early. A null `totalCount` is read as "unknown,
36+
* assume more" — which is why the `includeTotal` coercion bug made every table page forever.
2837
*/
2938
export function hasMoreTableRows(pages: readonly TableRowsPageLike[]): boolean {
3039
const lastPage = pages[pages.length - 1]
3140
if (!lastPage || lastPage.rows.length === 0) return false
41+
if (lastPage.nextCursor !== undefined) return lastPage.nextCursor !== null
3242
const totalCount = pages[0].totalCount
3343
return totalCount == null || countLoadedTableRows(pages) < totalCount
3444
}

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

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,42 @@
22
* @vitest-environment node
33
*/
44
import { describe, expect, it } from 'vitest'
5-
import { tableEventStreamQuerySchema } from '@/lib/api/contracts/tables'
5+
import { tableEventStreamQuerySchema, tableRowsQuerySchema } from '@/lib/api/contracts/tables'
6+
7+
/**
8+
* `requestJson` parses the query through this schema on the CLIENT before building the URL, so
9+
* these values arrive as the caller's real types, not as URL strings. A string-only coercion
10+
* therefore read the grid's `includeTotal: param === 0` boolean as `false`, page 0 came back with
11+
* `totalCount: null`, and `hasMoreTableRows` — which treats a null total as "more may exist" —
12+
* reported `hasNextPage` forever. Every table then paid a wasted extra page fetch on mount and
13+
* before every row insert.
14+
*/
15+
describe('tableRowsQuerySchema includeTotal', () => {
16+
it('accepts a real boolean, which is what the client passes', () => {
17+
expect(
18+
tableRowsQuerySchema.parse({ workspaceId: 'ws-1', includeTotal: true }).includeTotal
19+
).toBe(true)
20+
expect(
21+
tableRowsQuerySchema.parse({ workspaceId: 'ws-1', includeTotal: false }).includeTotal
22+
).toBe(false)
23+
})
24+
25+
it('still accepts the URL strings a direct API caller sends', () => {
26+
expect(
27+
tableRowsQuerySchema.parse({ workspaceId: 'ws-1', includeTotal: 'true' }).includeTotal
28+
).toBe(true)
29+
expect(
30+
tableRowsQuerySchema.parse({ workspaceId: 'ws-1', includeTotal: 'false' }).includeTotal
31+
).toBe(false)
32+
})
33+
34+
it('defaults to true when absent or empty, so a bare request still gets its count', () => {
35+
expect(tableRowsQuerySchema.parse({ workspaceId: 'ws-1' }).includeTotal).toBe(true)
36+
expect(tableRowsQuerySchema.parse({ workspaceId: 'ws-1', includeTotal: '' }).includeTotal).toBe(
37+
true
38+
)
39+
})
40+
})
641

742
describe('tableEventStreamQuerySchema', () => {
843
it('parses an explicit cursor', () => {

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

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { isRecordLike } from '@sim/utils/object'
22
import { z } from 'zod'
33
import {
4+
booleanQueryFlagSchema,
45
folderIdSchema,
56
privateSecretProvenanceBundleSchema,
67
requiredFieldSchema,
@@ -800,11 +801,17 @@ export const tableRowsQueryBaseSchema = z.object({
800801
.optional()
801802
)
802803
.default(0),
804+
/**
805+
* Absent, null, and empty all fall through to the `true` default, so a bare request still
806+
* gets its count. Everything else goes to {@link booleanQueryFlagSchema}, which accepts a real
807+
* boolean as well as the URL strings — `requestJson` parses this schema on the CLIENT before
808+
* building the URL, so the value arrives as the caller's own type, and a string-only coercion
809+
* silently read the grid's `includeTotal: param === 0` as `false`.
810+
*/
803811
includeTotal: z
804812
.preprocess(
805-
(value) =>
806-
value === null || value === undefined || value === '' ? undefined : value === 'true',
807-
z.boolean().optional()
813+
(value) => (value === null || value === undefined || value === '' ? undefined : value),
814+
booleanQueryFlagSchema.optional()
808815
)
809816
.default(true),
810817
})

apps/sim/lib/table/planner.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,32 @@ export type DbTransaction = Parameters<Parameters<typeof db.transaction>[0]>[0]
1515
const READ_STATEMENT_TIMEOUT_MS = 15_000
1616
const READ_LOCK_TIMEOUT_MS = 3_000
1717

18-
async function setReadTimeouts(trx: DbTransaction): Promise<void> {
19-
await trx.execute(sql.raw(`SET LOCAL statement_timeout = '${READ_STATEMENT_TIMEOUT_MS}ms'`))
20-
await trx.execute(sql.raw(`SET LOCAL lock_timeout = '${READ_LOCK_TIMEOUT_MS}ms'`))
18+
/**
19+
* Applies every guard in ONE round-trip. Each `trx.execute` is its own serial round-trip (the
20+
* driver runs `prepare: false`), and every user-table read opens a transaction, so issuing these
21+
* separately cost 2–3 round-trips on every page, count, and drain batch.
22+
*
23+
* `set_config(name, value, is_local => true)` is exactly `SET LOCAL` — transaction-scoped, dying
24+
* with the commit — but it is a function call, so several fit in a single `SELECT`. Semicolon-
25+
* joining `SET LOCAL` statements would not work here: the driver sends this over the extended
26+
* protocol, which rejects multiple commands in one message.
27+
*/
28+
async function setReadGuards(trx: DbTransaction, seqscanOff: boolean): Promise<void> {
29+
/**
30+
* Only ever set to `off`, never explicitly to `on` — the unflagged path must leave whatever
31+
* the server default is, exactly as the separate `SET LOCAL enable_seqscan = off` did.
32+
*/
33+
const seqscan = seqscanOff ? sql`, set_config('enable_seqscan', 'off', true)` : sql``
34+
await trx.execute(sql`
35+
select
36+
set_config('statement_timeout', ${`${READ_STATEMENT_TIMEOUT_MS}ms`}, true),
37+
set_config('lock_timeout', ${`${READ_LOCK_TIMEOUT_MS}ms`}, true)${seqscan}
38+
`)
2139
}
2240

2341
/**
2442
* Runs a user-table read inside a transaction that always caps `statement_timeout`
25-
* / `lock_timeout` (see {@link setReadTimeouts}). Pass `seqscanOff` for queries
43+
* / `lock_timeout` (see {@link setReadGuards}). Pass `seqscanOff` for queries
2644
* with no tenant-bounded index plan — custom column sorts and filtered counts —
2745
* where the planner otherwise seq-scans the whole shared `user_table_rows`
2846
* relation (every tenant's rows); see {@link withSeqscanOff} for the measured
@@ -34,8 +52,7 @@ export async function withReadGuards<T>(
3452
opts?: { seqscanOff?: boolean }
3553
): Promise<T> {
3654
return db.transaction(async (trx) => {
37-
await setReadTimeouts(trx)
38-
if (opts?.seqscanOff) await trx.execute(sql`SET LOCAL enable_seqscan = off`)
55+
await setReadGuards(trx, opts?.seqscanOff ?? false)
3956
return fn(trx)
4057
})
4158
}

0 commit comments

Comments
 (0)