Skip to content

Commit 352a2fc

Browse files
committed
fix(tables): preserve virtual table paging and keyboard access
1 parent 965fb6b commit 352a2fc

8 files changed

Lines changed: 235 additions & 22 deletions

File tree

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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { hybridAuthMockFns } from '@sim/testing'
55
import { NextRequest } from 'next/server'
66
import { beforeEach, describe, expect, it, vi } from 'vitest'
77
import type { TableDefinition } from '@/lib/table'
8+
import { encodeCursor } from '@/lib/table/rows/cursor'
89

910
const { mockCheckAccess, mockQueryRows } = vi.hoisted(() => ({
1011
mockCheckAccess: vi.fn(),
@@ -107,4 +108,51 @@ describe('table export route — id→name translation', () => {
107108
expect(mockCheckAccess).toHaveBeenCalledWith('tbl_1', 'user-1', 'read')
108109
expect(mockQueryRows).not.toHaveBeenCalled()
109110
})
111+
112+
it('continues a virtual export with the keyset cursor returned by the previous page', async () => {
113+
const firstRow = {
114+
id: 'r1',
115+
data: { col_email: 'first@b.c', legacy: 'x' },
116+
executions: {},
117+
position: 0,
118+
orderKey: '2026-01-02T00:00:00.000Z',
119+
}
120+
const nextCursor = encodeCursor({
121+
lastRow: firstRow,
122+
keysetValid: true,
123+
nextOffset: 1,
124+
})
125+
mockQueryRows
126+
.mockResolvedValueOnce({
127+
rows: [firstRow],
128+
rowCount: 1,
129+
totalCount: null,
130+
limit: 1000,
131+
offset: 0,
132+
nextCursor,
133+
})
134+
.mockResolvedValueOnce({
135+
rows: [],
136+
rowCount: 0,
137+
totalCount: null,
138+
limit: 1000,
139+
offset: 0,
140+
nextCursor: null,
141+
})
142+
143+
const res = await callGet('csv')
144+
await res.text()
145+
146+
expect(mockQueryRows).toHaveBeenNthCalledWith(
147+
2,
148+
expect.anything(),
149+
{
150+
limit: 1000,
151+
offset: 0,
152+
after: { orderKey: firstRow.orderKey, id: firstRow.id },
153+
includeTotal: false,
154+
},
155+
expect.any(String)
156+
)
157+
})
110158
})

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { captureServerEvent } from '@/lib/posthog/server'
1111
import { namedRowMapper } from '@/lib/table/cell-format'
1212
import { getColumnId } from '@/lib/table/column-keys'
1313
import { formatCsvCell } from '@/lib/table/export-format'
14+
import { decodeCursor } from '@/lib/table/rows/cursor'
1415
import { queryRows } from '@/lib/table/rows/service'
1516
import { accessError, checkAccess } from '@/app/api/table/utils'
1617

@@ -106,11 +107,12 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou
106107
}
107108

108109
let offset = 0
110+
let after: { orderKey: string | null; id: string } | undefined
109111
let firstJsonRow = true
110112
while (true) {
111113
const result = await queryRows(
112114
table,
113-
{ limit: EXPORT_BATCH_SIZE, offset, includeTotal: false },
115+
{ limit: EXPORT_BATCH_SIZE, offset, after, includeTotal: false },
114116
requestId
115117
)
116118

@@ -128,7 +130,9 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou
128130
// A page can be cut by the byte budget before reaching EXPORT_BATCH_SIZE,
129131
// so a short page does NOT mean the export is done — only a null cursor does.
130132
if (!result.nextCursor) break
131-
offset += result.rows.length
133+
const decoded = decodeCursor(result.nextCursor)
134+
after = decoded.after
135+
offset = decoded.offset ?? 0
132136
}
133137

134138
if (format === 'json') controller.enqueue(encoder.encode(']'))

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.test.tsx

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,11 +82,17 @@ vi.mock('./table-primitives', () => ({
8282
vi.mock('./data-row', () => ({
8383
DataRow: ({
8484
onDoubleClick,
85+
onCellMouseDown,
8586
}: {
8687
onDoubleClick: (rowId: string, name: string, key: string) => void
88+
onCellMouseDown: (rowIndex: number, colIndex: number, shiftKey: boolean) => void
8789
}) => (
8890
<tr>
89-
<td data-testid='cell' onDoubleClick={() => onDoubleClick('row-1', 'value', 'value')}>
91+
<td
92+
data-testid='cell'
93+
onMouseDown={() => onCellMouseDown(0, 0, false)}
94+
onDoubleClick={() => onDoubleClick('row-1', 'value', 'value')}
95+
>
9096
Cell
9197
</td>
9298
</tr>
@@ -204,6 +210,69 @@ describe('TableGrid virtual cells', () => {
204210
container.remove()
205211
})
206212

213+
it.each(['Enter', 'F2', ' '])('opens the read-only viewer with the %s keyboard path', (key) => {
214+
const container = document.createElement('div')
215+
document.body.appendChild(container)
216+
const root = createRoot(container)
217+
218+
act(() => {
219+
root.render(
220+
<TableGrid
221+
workspaceId='workspace-1'
222+
tableId='virtual-table'
223+
remoteSelections={[]}
224+
emitCellSelection={vi.fn()}
225+
locks={{
226+
schemaLocked: true,
227+
insertLocked: true,
228+
updateLocked: true,
229+
deleteLocked: true,
230+
}}
231+
onBlockedAction={mockBlockedAction}
232+
sidebarReservedPx={0}
233+
onOpenColumnConfig={vi.fn()}
234+
onOpenWorkflowConfig={vi.fn()}
235+
onOpenEnrichments={vi.fn()}
236+
onOpenEnrichmentConfig={vi.fn()}
237+
onOpenExecutionDetails={vi.fn()}
238+
onOpenEnrichmentDetails={vi.fn()}
239+
onOpenRowModal={vi.fn()}
240+
onRequestDeleteRows={vi.fn()}
241+
onRequestDeleteAllByFilter={vi.fn()}
242+
onRequestDeleteColumns={vi.fn()}
243+
onRunColumn={vi.fn()}
244+
onRunRow={vi.fn()}
245+
onRunRows={vi.fn()}
246+
onStopRows={vi.fn()}
247+
onStopAllRows={vi.fn()}
248+
onStopRow={vi.fn()}
249+
onSelectionChange={vi.fn()}
250+
queryOptions={{}}
251+
columnRenameSinkRef={{ current: null }}
252+
afterDeleteRowsSinkRef={{ current: null }}
253+
afterDeleteAllSinkRef={{ current: null }}
254+
confirmDeleteColumnsSinkRef={{ current: null }}
255+
pushTableRenameUndoSinkRef={{ current: null }}
256+
/>
257+
)
258+
})
259+
260+
const cell = container.querySelector<HTMLElement>('[data-testid="cell"]')
261+
const scroll = container.querySelector<HTMLElement>('[data-table-scroll]')
262+
act(() => {
263+
cell?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
264+
})
265+
act(() => {
266+
scroll?.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }))
267+
})
268+
269+
expect(container.querySelector('[data-testid="expanded-cell"]')?.textContent).toBe('row-1')
270+
expect(mockBlockedAction).not.toHaveBeenCalled()
271+
272+
act(() => root.unmount())
273+
container.remove()
274+
})
275+
207276
it('shows row-query failures instead of presenting an empty result', () => {
208277
const rowsError = new Error('Transcript filtering and sorting are not supported for this table')
209278
mockUseTable.mockReturnValue(createUseTableResult(rowsError))

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2538,6 +2538,16 @@ export function TableGrid({
25382538
}
25392539

25402540
if (e.key === 'Enter' || e.key === 'F2') {
2541+
if (isVirtualTableRef.current) {
2542+
e.preventDefault()
2543+
const col = cols[anchor.colIndex]
2544+
const row = currentRows[anchor.rowIndex]
2545+
if (!col || !row) return
2546+
setSelectionFocus(null)
2547+
setIsColumnSelection(false)
2548+
setExpandedCell({ rowId: row.id, columnName: col.key, columnKey: col.key })
2549+
return
2550+
}
25412551
if (!canEditRef.current) return
25422552
e.preventDefault()
25432553
// The primary keyboard edit path — same lock notice as double-click and
@@ -2563,6 +2573,16 @@ export function TableGrid({
25632573
}
25642574

25652575
if (e.key === ' ' && !e.shiftKey) {
2576+
if (isVirtualTableRef.current) {
2577+
e.preventDefault()
2578+
const col = cols[anchor.colIndex]
2579+
const row = currentRows[anchor.rowIndex]
2580+
if (!col || !row) return
2581+
setSelectionFocus(null)
2582+
setIsColumnSelection(false)
2583+
setExpandedCell({ rowId: row.id, columnName: col.key, columnKey: col.key })
2584+
return
2585+
}
25662586
if (!canEditRef.current) return
25672587
e.preventDefault()
25682588
// Space opens the same row editor as double-click, so it follows the

apps/sim/lib/virtual-tables/memory-virtual-table.server.test.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -333,7 +333,7 @@ describe('Memory virtual table', () => {
333333
expect(result.hasMore).toBe(true)
334334
})
335335

336-
it('still returns one transcript that alone exceeds the query byte budget', async () => {
336+
it('rejects a transcript that alone exceeds the query byte budget', async () => {
337337
const oversized = {
338338
id: 'memory-1',
339339
key: 'conversation-1',
@@ -344,17 +344,41 @@ describe('Memory virtual table', () => {
344344
rowBytes: 5 * 1024 * 1024 + 1,
345345
}
346346
queueTableRows(schemaMock.memory, [oversized])
347-
queueTableRows(schemaMock.memory, [{ id: oversized.id, data: oversized.data }])
347+
await expect(
348+
queryMemoryTableRows({
349+
workspaceId: 'workspace-1',
350+
limit: 1000,
351+
includeTotal: false,
352+
})
353+
).rejects.toThrow('Memory transcript exceeds the 5MB table query limit')
354+
355+
expect(dbChainMockFns.select).toHaveBeenCalledTimes(2)
356+
})
357+
358+
it('returns a continuation anchor when every selected transcript disappears during hydration', async () => {
359+
const candidate = {
360+
id: 'memory-1',
361+
key: 'conversation-1',
362+
createdAt: CREATED_AT,
363+
updatedAt: UPDATED_AT,
364+
messageCount: 1,
365+
rowBytes: 100,
366+
}
367+
queueTableRows(schemaMock.memory, [candidate])
368+
queueTableRows(schemaMock.memory, [])
348369

349370
const result = await queryMemoryTableRows({
350371
workspaceId: 'workspace-1',
351-
limit: 1000,
372+
limit: 1,
352373
includeTotal: false,
353374
})
354375

355-
expect(result.rows).toHaveLength(1)
356-
expect(result.rows[0]?.id).toBe(oversized.id)
357-
expect(result.hasMore).toBe(false)
376+
expect(result.rows).toEqual([])
377+
expect(result.hasMore).toBe(true)
378+
expect(result.continuation).toEqual({
379+
lastRow: { id: candidate.id, orderKey: UPDATED_AT.toISOString() },
380+
nextOffset: 1,
381+
})
358382
})
359383

360384
it('finds matching cells in one storage query and preserves filtered sort ordinals', async () => {

apps/sim/lib/virtual-tables/memory-virtual-table.server.ts

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -200,22 +200,25 @@ export async function queryMemoryTableRows({
200200
? db.select({ value: count() }).from(memoryRows).where(baseWhere)
201201
: Promise.resolve(null)
202202
const [candidates, totalRows] = await Promise.all([candidatePromise, totalPromise])
203-
const pageByteBudget = getMaxPageBytes() ?? TABLE_LIMITS.MAX_QUERY_RESULT_BYTES
203+
const pageByteBudget = Math.min(
204+
getMaxPageBytes() ?? TABLE_LIMITS.MAX_QUERY_RESULT_BYTES,
205+
TABLE_LIMITS.MAX_QUERY_RESULT_BYTES
206+
)
204207
const selectedCandidates: typeof candidates = []
205208
let selectedBytes = 0
206-
let hasMore = false
209+
let hasMore = candidates.length === limit
207210

208211
for (const candidate of candidates) {
209212
const rowBytes = Number(candidate.rowBytes)
210213
if (!Number.isFinite(rowBytes) || rowBytes < 0) {
211214
throw new TableQueryValidationError('Memory table returned an invalid row size')
212215
}
213-
// Matches `fetchRowsBounded` on the persisted path: a bounded page always
214-
// yields at least one row, even one that alone exceeds the budget. Memory
215-
// transcripts have no write-time size cap (unlike persisted rows, which are
216-
// held to MAX_ROW_SIZE_BYTES), so refusing an over-budget first row would
217-
// make one long conversation render the whole table unreadable with no way
218-
// to page past it.
216+
if (rowBytes > pageByteBudget) {
217+
throw new TableQueryValidationError(
218+
`Memory transcript exceeds the ${Math.floor(pageByteBudget / (1024 * 1024))}MB table query limit`,
219+
'TABLE_QUERY_RESULT_TOO_LARGE'
220+
)
221+
}
219222
if (selectedCandidates.length > 0 && selectedBytes + rowBytes > pageByteBudget) {
220223
hasMore = true
221224
break
@@ -257,12 +260,23 @@ export async function queryMemoryTableRows({
257260
),
258261
]
259262
})
263+
const lastSelectedCandidate = selectedCandidates[selectedCandidates.length - 1]
260264

261265
return {
262266
rows,
263267
totalCount: totalRows ? Number(totalRows[0].value) : null,
264268
keysetValid: !sort,
265269
hasMore,
270+
continuation:
271+
hasMore && lastSelectedCandidate
272+
? {
273+
lastRow: {
274+
id: lastSelectedCandidate.id,
275+
orderKey: lastSelectedCandidate.updatedAt.toISOString(),
276+
},
277+
nextOffset: offset + selectedCandidates.length,
278+
}
279+
: undefined,
266280
}
267281
}
268282
/** Searches Memory cells in PostgreSQL while preserving the active view's row ordinals. */

apps/sim/lib/virtual-tables/service.server.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,30 @@ describe('virtual table service', () => {
152152
})
153153
})
154154

155+
it('continues after scanned candidates when hydration drops every returned row', async () => {
156+
mockQueryMemoryRows.mockResolvedValue({
157+
rows: [],
158+
totalCount: 2,
159+
keysetValid: true,
160+
hasMore: true,
161+
continuation: {
162+
lastRow: { id: 'memory-1', orderKey: '2026-01-02T00:00:00.000Z' },
163+
nextOffset: 1,
164+
},
165+
})
166+
167+
const result = await queryVirtualTableRows(MEMORY_TABLE, {
168+
limit: 1000,
169+
offset: 0,
170+
includeTotal: true,
171+
})
172+
173+
expect(result.rows).toEqual([])
174+
expect(decodeCursor(result.nextCursor as string)).toEqual({
175+
after: { orderKey: '2026-01-02T00:00:00.000Z', id: 'memory-1' },
176+
})
177+
})
178+
155179
it('emits a sort-bound offset cursor when a provider cannot use a keyset', async () => {
156180
const firstRow = createRow('memory-1', '2026-01-02T00:00:00.000Z')
157181
const witnessRow = createRow('memory-2', '2026-01-01T00:00:00.000Z')

0 commit comments

Comments
 (0)