Skip to content

Commit bf6ab96

Browse files
improvement(tables): order export + snapshot rows by order_key so the CSV matches the grid under fractional ordering
1 parent 9ea1b23 commit bf6ab96

5 files changed

Lines changed: 21 additions & 21 deletions

File tree

apps/sim/lib/table/export-runner.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ describe('runTableExport', () => {
9393
return Promise.resolve(handle)
9494
})
9595
mockSelectExportRowPage.mockResolvedValue([
96-
{ id: 'r1', data: { col_name: 'Ada' }, position: 0 },
96+
{ id: 'r1', data: { col_name: 'Ada' }, orderKey: 'a0' },
9797
])
9898
})
9999

apps/sim/lib/table/export-runner.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ export async function runTableExport(payload: TableExportPayload): Promise<void>
7878

7979
let exported = 0
8080
let firstJsonRow = true
81-
let after: { position: number; id: string } | null = null
81+
let after: { orderKey: string; id: string } | null = null
8282
while (true) {
8383
// Ownership gate before every page: a canceled job stops within one batch.
8484
const owns = await updateJobProgress(tableId, exported, jobId)
@@ -103,7 +103,7 @@ export async function runTableExport(payload: TableExportPayload): Promise<void>
103103

104104
exported += page.length
105105
const last = page[page.length - 1]
106-
after = { position: last.position, id: last.id }
106+
after = { orderKey: last.orderKey, id: last.id }
107107
if (page.length < EXPORT_BATCH_SIZE) break
108108
}
109109
if (format === 'json') await handle.write(']')

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

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -207,35 +207,35 @@ export async function getJobProgress(tableId: string, jobId: string): Promise<nu
207207
}
208208

209209
/**
210-
* One keyset page of rows for the export worker, ordered by `(position, id)`. Keyset (not
211-
* OFFSET) keeps each page O(page) — offset paging re-scans every prior row per page, which is
212-
* O(N²) across a large export. `(position, id)` is total (position exists on every row; id breaks
213-
* ties) and served by the `(table_id, position)` index; under fractional ordering a manually
214-
* reordered table may export in near-grid rather than exact grid order — the right trade for a
215-
* bulk dump. The delete-job visibility mask applies, like every user-facing read.
210+
* One keyset page of rows for the export worker, ordered by `(order_key, id)` — the same
211+
* authoritative visual order the grid (`queryRows`) uses, so exports and snapshots match what the
212+
* user sees even after manual reorders. Keyset (not OFFSET) keeps each page O(page); `order_key` is
213+
* present on every row (always assigned on insert, backfilled for legacy rows) with `id` as the
214+
* tiebreaker, and the `(table_id, order_key, id)` index serves it. The delete-job visibility mask
215+
* applies, like every user-facing read.
216216
*/
217217
export async function selectExportRowPage(
218218
table: TableDefinition,
219-
after: { position: number; id: string } | null,
219+
after: { orderKey: string; id: string } | null,
220220
limit: number
221-
): Promise<Array<{ id: string; data: RowData; position: number }>> {
221+
): Promise<Array<{ id: string; data: RowData; orderKey: string }>> {
222222
const deleteMask = await pendingDeleteMask(table)
223223
const rows = await db
224-
.select({ id: userTableRows.id, data: userTableRows.data, position: userTableRows.position })
224+
.select({ id: userTableRows.id, data: userTableRows.data, orderKey: userTableRows.orderKey })
225225
.from(userTableRows)
226226
.where(
227227
and(
228228
eq(userTableRows.tableId, table.id),
229229
eq(userTableRows.workspaceId, table.workspaceId),
230230
deleteMask,
231231
after
232-
? sql`(${userTableRows.position}, ${userTableRows.id}) > (${after.position}, ${after.id})`
232+
? sql`(${userTableRows.orderKey}, ${userTableRows.id}) > (${after.orderKey}, ${after.id})`
233233
: undefined
234234
)
235235
)
236-
.orderBy(asc(userTableRows.position), asc(userTableRows.id))
236+
.orderBy(asc(userTableRows.orderKey), asc(userTableRows.id))
237237
.limit(limit)
238-
return rows as Array<{ id: string; data: RowData; position: number }>
238+
return rows as Array<{ id: string; data: RowData; orderKey: string }>
239239
}
240240

241241
/** How long a terminal export stays listable (and re-downloadable from the tray). */

apps/sim/lib/table/snapshot-cache.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ describe('getOrCreateTableSnapshot', () => {
5454
lastHandle = null
5555
mockDeleteFile.mockResolvedValue(undefined)
5656
mockSelectExportRowPage.mockResolvedValueOnce([
57-
{ id: 'r1', data: { col_name: 'Ada' }, position: 0 },
57+
{ id: 'r1', data: { col_name: 'Ada' }, orderKey: 'a0' },
5858
])
5959
mockSelectExportRowPage.mockResolvedValue([])
6060
mockCreateMultipartUpload.mockImplementation(({ key }: { key: string }) => {
@@ -151,9 +151,9 @@ describe('getOrCreateTableSnapshot', () => {
151151
// second materialize needs its own page sequence
152152
mockSelectExportRowPage.mockReset()
153153
mockSelectExportRowPage
154-
.mockResolvedValueOnce([{ id: 'r1', data: { col_name: 'Ada' }, position: 0 }])
154+
.mockResolvedValueOnce([{ id: 'r1', data: { col_name: 'Ada' }, orderKey: 'a0' }])
155155
.mockResolvedValueOnce([])
156-
.mockResolvedValueOnce([{ id: 'r1', data: { col_name: 'Ada' }, position: 0 }])
156+
.mockResolvedValueOnce([{ id: 'r1', data: { col_name: 'Ada' }, orderKey: 'a0' }])
157157
.mockResolvedValueOnce([])
158158

159159
const ref = await getOrCreateTableSnapshot(table, 'req')
@@ -175,7 +175,7 @@ describe('getOrCreateTableSnapshot', () => {
175175
mockSelectExportRowPage.mockReset()
176176
// A full batch of wide rows on every page → the materialize loop keeps paging until the running
177177
// byte count crosses the cap, then aborts. Peak memory stays at one page (~MBs), not the cap.
178-
const wideRow = { id: 'r', data: { col_name: 'x'.repeat(1000) }, position: 0 }
178+
const wideRow = { id: 'r', data: { col_name: 'x'.repeat(1000) }, orderKey: 'a0' }
179179
const fullPage = Array.from({ length: 10000 }, () => wideRow)
180180
mockSelectExportRowPage.mockResolvedValue(fullPage)
181181

apps/sim/lib/table/snapshot-cache.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ async function materialize(table: TableDefinition, key: string): Promise<number>
103103
bytes += Buffer.byteLength(header)
104104
await handle.write(header)
105105

106-
let after: { position: number; id: string } | null = null
106+
let after: { orderKey: string; id: string } | null = null
107107
while (true) {
108108
const page = await selectExportRowPage(table, after, SNAPSHOT_BATCH_SIZE)
109109
if (page.length === 0) break
@@ -116,7 +116,7 @@ async function materialize(table: TableDefinition, key: string): Promise<number>
116116
await handle.write(chunk)
117117

118118
const last = page[page.length - 1]
119-
after = { position: last.position, id: last.id }
119+
after = { orderKey: last.orderKey, id: last.id }
120120
if (page.length < SNAPSHOT_BATCH_SIZE) break
121121
}
122122

0 commit comments

Comments
 (0)