Skip to content

Commit 24a6086

Browse files
feat(tables): fractional order keys for O(log n) row insert/delete (flag-gated, default off) (#4890)
* feat(tables): add order_key column, fractional-indexing util, and ordering flag (off) * feat(tables): write order_key on insert, flag-gate delete reindex + query ordering, add backfill Flag off (default) = identical behavior. Single-insert assigns a fractional order_key; queryRows orders by order_key when the flag is on; deletes skip the O(N) reindex when on. Per-table-atomic backfill script populates existing rows. * feat(tables): write order_key on all insert paths (batch, upsert, replace, import, create, copilot) Completes the always-write-keys prerequisite: every row insert now assigns a fractional order_key consistent with position order, so the flag can be flipped safely after backfill. Flag off (default) still = identical behavior. * feat(tables): insert-by-neighbor-id + orderKey on wire + client order-by-key Inserts express intent as afterRowId/beforeRowId (O(1) key mint via the (table_id,order_key,id) index); orderKey is returned on every row; client reconcile/undo place by orderKey (no neighbor bump) with position fallback. Flag off = unchanged. 205 table tests pass. * feat(tables): resolve position-based inserts by key ordinal under the flag Position-based callers (mothership tool, v1 API, undo fallback, transient old clients) resolve their insert neighbor by order_key ordinal (OFFSET) when the flag is on — positions are gappy then, so WHERE position=N would miss. Flag off keeps the indexed position lookup. The mothership tool itself is unchanged. * test(tables): flag-on coverage — delete skips reindex, insert mints key + no shift * fix lint * chore(db): regenerate order_key migration with default drizzle name * fix(tables): address review — guard neighbor insert + mutual-exclusion + safe reconcile - resolveInsertByNeighbor throws when the anchor row is missing (was silently inserting at the front) and when its order_key is null under the flag. - insert contract: afterRowId/beforeRowId are mutually exclusive (refine). - reconcileCreatedRow only key-sorts when every cached row is keyed, so mid- backfill un-keyed rows aren't yanked to the front. * fix(kb): restore non-null guard in storage-key filter (unsafe-lint regression) * refactor(tables): extract maxOrderKey + thread import append key - Extract maxOrderKey(executor, tableId) helper; replaces three identical max(order_key) selects (single/batch insert append + import). - Import: read the append anchor once up front and thread each batch's last key forward (nextImportStartOrderKey + afterOrderKey) instead of re-scanning max(order_key) per batch over a growing table — one scan per import, not one per 1k-row batch. * fix(tables): keep insert body base omittable for v1 contract The afterRowId/beforeRowId mutual-exclusion .refine() turned the schema into a ZodEffects, which Zod forbids .omit() on — v1's insertTableRowBodySchema.omit({ position }) threw at module load (runtime-only; tsc misses it). Split the plain object base out, apply the shared refine on top, and have v1 omit from the base then re-apply it. * fix(tables): chunk backfill order-key writes A single UPDATE … FROM (VALUES …) over a whole large table overflows the JS call stack while drizzle assembles the VALUES list (and would blow past Postgres's 65535 bound-param limit at ~32k rows) — large tables failed with 'Maximum call stack size exceeded'. Write in 1000-row chunks inside the same per-table transaction so keying stays atomic. * fix(tables): emit orderKey in insert responses The single-row and batch insert handlers dropped orderKey from the JSON response even though the service returns it, so reconcileCreatedRow always fell back to position-sorting and could place neighbor inserts wrong under the fractional-ordering flag. Serialize orderKey alongside position. * fix(tables): restore by orderKey, not position, under fractional flag A saved position is the gappy column value, but under the flag insert reads position as a visual rank (OFFSET) — so position-based restore misplaces rows. - create-row redo now goes through the batch path carrying the saved orderKey (the single-insert API has no orderKey field); drop the now-unused single create mutation. - resolveBatchInsertOrderKeys appends under the flag instead of feeding gappy positions to resolveInsertOrderKey; positions remain the flag-off path. * perf(tables): backfill writes 5000 rows/chunk (was 1000) 5x fewer round-trips per table; ~10k bound params stays well under Postgres's 65535 ceiling and far below the single-statement size that overflows the stack. * fix(tables): drop rowNumber from table trigger payload position is gappy under the fractional-ordering flag, so rowNumber (= row.position) no longer reflects a contiguous visual rank. Rather than compute-on-read, remove it from the trigger payload, output schema, and column-execution input. Also pin isTablesFractionalOrderingEnabled=false in update-row.test.ts so its flag-off position-shift assertions are deterministic regardless of local env. * chore(db): format generated 0226 migration metadata biome check . flagged the drizzle-generated _journal.json and 0226_snapshot.json; apply the formatter so packages/db lint:check passes in CI. * docs(triggers): drop rowNumber from table trigger outputs rowNumber was removed from the table trigger payload; remove it from the documented output fields to match. * test(tables): remove flag-on fractional-ordering unit suite Flag-on behavior is covered by manual large-table verification; the heavily- mocked DB-chain suite added little signal.
1 parent f7f7840 commit 24a6086

27 files changed

Lines changed: 17897 additions & 64 deletions

File tree

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ Triggers when rows are inserted or updated in a table
3838
| `changedColumns` | json | List of column names that changed \(empty for inserts\) |
3939
| `rowId` | string | The unique row ID |
4040
| `headers` | json | Column names from the table schema |
41-
| `rowNumber` | number | The position of the row in the table |
4241
| `tableId` | string | The table ID |
4342
| `tableName` | string | The table name |
4443
| `timestamp` | string | Event timestamp in ISO format |

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ async function handleBatchInsert(
7171
workspaceId: validated.workspaceId,
7272
userId,
7373
positions: validated.positions,
74+
orderKeys: validated.orderKeys,
7475
},
7576
table,
7677
requestId
@@ -83,6 +84,7 @@ async function handleBatchInsert(
8384
id: r.id,
8485
data: r.data,
8586
position: r.position,
87+
orderKey: r.orderKey ?? undefined,
8688
createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : r.createdAt,
8789
updatedAt: r.updatedAt instanceof Date ? r.updatedAt.toISOString() : r.updatedAt,
8890
})),
@@ -162,6 +164,8 @@ export const POST = withRouteHandler(
162164
workspaceId: validated.workspaceId,
163165
userId: authResult.userId,
164166
position: validated.position,
167+
afterRowId: validated.afterRowId,
168+
beforeRowId: validated.beforeRowId,
165169
},
166170
table,
167171
requestId
@@ -174,9 +178,11 @@ export const POST = withRouteHandler(
174178
id: row.id,
175179
data: row.data,
176180
position: row.position,
181+
orderKey: row.orderKey ?? undefined,
177182
createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : row.createdAt,
178183
updatedAt: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : row.updatedAt,
179184
},
185+
180186
message: 'Row inserted successfully',
181187
},
182188
})

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -837,9 +837,12 @@ export function TableGrid({
837837

838838
function handleInsertRow(offset: 0 | 1) {
839839
if (!contextMenu.row) return
840+
const anchorId = contextMenu.row.id
841+
// Fractional ordering: express intent by neighbor id, not integer position.
842+
const intent = offset === 0 ? { beforeRowId: anchorId } : { afterRowId: anchorId }
840843
const position = contextMenu.row.position + offset
841844
createRef.current(
842-
{ data: {}, position },
845+
{ data: {}, ...intent },
843846
{
844847
onSuccess: (response: Record<string, unknown>) => {
845848
const newRowId = extractCreatedRowId(response)
@@ -904,7 +907,7 @@ export function TableGrid({
904907
const sourceArrayIndex = rowsRef.current.findIndex((r) => r.id === contextRow.id)
905908
closeContextMenu()
906909
createRef.current(
907-
{ data: rowData, position },
910+
{ data: rowData, afterRowId: contextRow.id },
908911
{
909912
onSuccess: (response: Record<string, unknown>) => {
910913
const newRowId = extractCreatedRowId(response)

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,12 @@ export function computeNormalizedSelection(
302302
export function collectRowSnapshots(rows: Iterable<TableRowType>): DeletedRowSnapshot[] {
303303
const snapshots: DeletedRowSnapshot[] = []
304304
for (const row of rows) {
305-
snapshots.push({ rowId: row.id, data: { ...row.data }, position: row.position })
305+
snapshots.push({
306+
rowId: row.id,
307+
data: { ...row.data },
308+
position: row.position,
309+
orderKey: row.orderKey,
310+
})
306311
}
307312
return snapshots
308313
}

apps/sim/background/workflow-column-execution.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -589,7 +589,6 @@ async function runWorkflowAndWriteTerminal(
589589
changedColumns: [],
590590
rowId,
591591
headers,
592-
rowNumber: row.position,
593592
tableId,
594593
tableName,
595594
timestamp: new Date().toISOString(),

apps/sim/hooks/queries/tables.ts

Lines changed: 39 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -512,7 +512,13 @@ export function useCreateTableRow({ workspaceId, tableId }: RowMutationContext)
512512
) => {
513513
return requestJson(createTableRowContract, {
514514
params: { tableId },
515-
body: { workspaceId, data: variables.data as RowData, position: variables.position },
515+
body: {
516+
workspaceId,
517+
data: variables.data as RowData,
518+
position: variables.position,
519+
afterRowId: variables.afterRowId,
520+
beforeRowId: variables.beforeRowId,
521+
},
516522
})
517523
},
518524
onSuccess: (response) => {
@@ -592,35 +598,47 @@ function reconcileCreatedRow(
592598
if (!old) return old
593599
if (old.pages.some((p) => p.rows.some((r) => r.id === row.id))) return old
594600

595-
const pages = old.pages.map((page) =>
596-
page.rows.some((r) => r.position >= row.position)
597-
? {
598-
...page,
599-
rows: page.rows.map((r) =>
600-
r.position >= row.position ? { ...r, position: r.position + 1 } : r
601-
),
602-
}
603-
: page
604-
)
601+
// Use key-ordering only when the new row AND every cached row have an
602+
// `orderKey` — then no neighbor bump is needed and order is exact. If any
603+
// cached row is un-keyed (mid-backfill), fall back to the legacy `position`
604+
// path so un-keyed rows aren't yanked to the front by an empty-string sort.
605+
const byKey =
606+
row.orderKey != null && old.pages.every((p) => p.rows.every((r) => r.orderKey != null))
607+
const sortRows = (rows: TableRow[]) =>
608+
byKey
609+
? [...rows].sort((a, b) => (a.orderKey as string).localeCompare(b.orderKey as string))
610+
: [...rows].sort((a, b) => a.position - b.position)
611+
const fitsAfter = (last: TableRow | undefined) =>
612+
last === undefined ||
613+
(byKey
614+
? (last.orderKey as string) >= (row.orderKey as string)
615+
: last.position >= row.position)
616+
617+
const pages = byKey
618+
? old.pages
619+
: old.pages.map((page) =>
620+
page.rows.some((r) => r.position >= row.position)
621+
? {
622+
...page,
623+
rows: page.rows.map((r) =>
624+
r.position >= row.position ? { ...r, position: r.position + 1 } : r
625+
),
626+
}
627+
: page
628+
)
605629

606630
let inserted = false
607631
const nextPages = pages.map((page) => {
608632
if (inserted) return page
609-
const last = page.rows[page.rows.length - 1]
610-
const fits = last === undefined || last.position >= row.position
611-
if (!fits) return page
633+
if (!fitsAfter(page.rows[page.rows.length - 1])) return page
612634
inserted = true
613-
const merged = [...page.rows, row].sort((a, b) => a.position - b.position)
614-
return { ...page, rows: merged }
635+
return { ...page, rows: sortRows([...page.rows, row]) }
615636
})
616637

617638
if (!inserted && nextPages.length > 0) {
618639
const lastIdx = nextPages.length - 1
619640
const lastPage = nextPages[lastIdx]
620-
nextPages[lastIdx] = {
621-
...lastPage,
622-
rows: [...lastPage.rows, row].sort((a, b) => a.position - b.position),
623-
}
641+
nextPages[lastIdx] = { ...lastPage, rows: sortRows([...lastPage.rows, row]) }
624642
}
625643

626644
const firstPage = nextPages[0]
@@ -655,6 +673,7 @@ export function useBatchCreateTableRows({ workspaceId, tableId }: RowMutationCon
655673
workspaceId,
656674
rows: variables.rows as RowData[],
657675
positions: variables.positions,
676+
orderKeys: variables.orderKeys,
658677
},
659678
})
660679
},

apps/sim/hooks/use-table-undo.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import {
55
useAddTableColumn,
66
useBatchCreateTableRows,
77
useBatchUpdateTableRows,
8-
useCreateTableRow,
98
useDeleteColumn,
109
useDeleteTableRow,
1110
useDeleteTableRows,
@@ -56,7 +55,6 @@ export function useTableUndo({
5655
const canRedo = useTableUndoStore((s) => (s.stacks[tableId]?.redo.length ?? 0) > 0)
5756

5857
const updateRowMutation = useUpdateTableRow({ workspaceId, tableId })
59-
const createRowMutation = useCreateTableRow({ workspaceId, tableId })
6058
const batchCreateRowsMutation = useBatchCreateTableRows({ workspaceId, tableId })
6159
const batchUpdateRowsMutation = useBatchUpdateTableRows({ workspaceId, tableId })
6260
const deleteRowMutation = useDeleteTableRow({ workspaceId, tableId })
@@ -137,11 +135,18 @@ export function useTableUndo({
137135
if (direction === 'undo') {
138136
deleteRowMutation.mutate(action.rowId)
139137
} else {
140-
createRowMutation.mutate(
141-
{ data: action.data ?? {}, position: action.position },
138+
// Redo via the batch path so the saved orderKey restores exact placement.
139+
// The single-insert API has no orderKey field, and under the fractional-ordering
140+
// flag its `position` is read as a rank — a gappy saved position misplaces.
141+
batchCreateRowsMutation.mutate(
142+
{
143+
rows: [action.data ?? {}],
144+
positions: [action.position],
145+
orderKeys: action.orderKey ? [action.orderKey] : undefined,
146+
},
142147
{
143148
onSuccess: (response) => {
144-
const newRowId = extractCreatedRowId(response as Record<string, unknown>)
149+
const newRowId = response?.data?.rows?.[0]?.id
145150
if (newRowId && newRowId !== action.rowId) {
146151
patchUndoRowId(tableId, action.rowId, newRowId)
147152
}
@@ -165,6 +170,9 @@ export function useTableUndo({
165170
{
166171
rows: action.rows.map((r) => r.data),
167172
positions: action.rows.map((r) => r.position),
173+
orderKeys: action.rows.every((r) => r.orderKey)
174+
? action.rows.map((r) => r.orderKey as string)
175+
: undefined,
168176
},
169177
{
170178
onSuccess: (response) => {
@@ -187,6 +195,9 @@ export function useTableUndo({
187195
{
188196
rows: action.rows.map((row) => row.data),
189197
positions: action.rows.map((row) => row.position),
198+
orderKeys: action.rows.every((row) => row.orderKey)
199+
? action.rows.map((row) => row.orderKey as string)
200+
: undefined,
190201
},
191202
{
192203
onSuccess: (response) => {

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

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,12 +147,29 @@ export const rowDataSchema = domainObjectSchema<RowData>()
147147
export const tableDefinitionSchema = domainObjectSchema<TableDefinition>()
148148
export const tableRowSchema = domainObjectSchema<TableRow>()
149149

150-
export const insertTableRowBodySchema = z.object({
150+
/**
151+
* Plain-object base for the single-row insert body. Kept un-refined so callers
152+
* (e.g. the v1 public contract) can `.omit()` fields before applying
153+
* {@link rowAnchorMutexRefine} — Zod forbids `.omit()` on a refined schema.
154+
*/
155+
export const insertTableRowBodyBaseSchema = z.object({
151156
workspaceId: z.string().min(1, 'Workspace ID is required'),
152157
data: rowDataSchema,
153158
position: z.number().int().min(0).optional(),
159+
/** Fractional ordering: insert directly after this row id. Takes precedence over `position`. */
160+
afterRowId: z.string().min(1).optional(),
161+
/** Fractional ordering: insert directly before this row id. Takes precedence over `position`. */
162+
beforeRowId: z.string().min(1).optional(),
154163
})
155164

165+
/** `afterRowId` and `beforeRowId` are mutually exclusive insert anchors. */
166+
export const rowAnchorMutexRefine = [
167+
(data: { afterRowId?: string; beforeRowId?: string }) => !data.afterRowId || !data.beforeRowId,
168+
{ message: 'afterRowId and beforeRowId are mutually exclusive' },
169+
] as const
170+
171+
export const insertTableRowBodySchema = insertTableRowBodyBaseSchema.refine(...rowAnchorMutexRefine)
172+
156173
/**
157174
* POST `/api/table/[tableId]/rows/upsert` body — insert-or-update keyed by a
158175
* unique column name. `conflictTarget` is optional (server picks a single
@@ -175,13 +192,18 @@ export const batchInsertTableRowsBodySchema = z
175192
`Cannot insert more than ${TABLE_LIMITS.MAX_BATCH_INSERT_SIZE} rows per batch`
176193
),
177194
positions: z.array(z.number().int().min(0)).max(TABLE_LIMITS.MAX_BATCH_INSERT_SIZE).optional(),
195+
/** Fractional ordering: exact per-row order keys (undo restore). Takes precedence over `positions`. */
196+
orderKeys: z.array(z.string().min(1)).max(TABLE_LIMITS.MAX_BATCH_INSERT_SIZE).optional(),
178197
})
179198
.refine((data) => !data.positions || data.positions.length === data.rows.length, {
180199
message: 'positions array length must match rows array length',
181200
})
182201
.refine((data) => !data.positions || new Set(data.positions).size === data.positions.length, {
183202
message: 'positions must not contain duplicates',
184203
})
204+
.refine((data) => !data.orderKeys || data.orderKeys.length === data.rows.length, {
205+
message: 'orderKeys array length must match rows array length',
206+
})
185207

186208
/**
187209
* POST `/api/table/[tableId]/rows` body — accepts either a batch payload

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import {
44
createTableColumnBodySchema,
55
deleteTableColumnBodySchema,
66
deleteTableRowsBodySchema,
7-
insertTableRowBodySchema,
7+
insertTableRowBodyBaseSchema,
8+
rowAnchorMutexRefine,
89
rowDataSchema,
910
tableIdParamsSchema,
1011
tableRowParamsSchema,
@@ -60,7 +61,9 @@ export const v1CreateTableBodySchema = createTableBodySchema.omit({
6061
* Public API insert row body — no caller-controlled `position`. Server places
6162
* new rows at the tail; ordering by index is an in-app affordance only.
6263
*/
63-
export const v1InsertTableRowBodySchema = insertTableRowBodySchema.omit({ position: true })
64+
export const v1InsertTableRowBodySchema = insertTableRowBodyBaseSchema
65+
.omit({ position: true })
66+
.refine(...rowAnchorMutexRefine)
6467

6568
/**
6669
* Public API batch insert body — no `positions`. Same rationale as above.

apps/sim/lib/copilot/request/tools/tables.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
1313
import { withCopilotSpan } from '@/lib/copilot/request/otel'
1414
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
1515
import type { RowData } from '@/lib/table'
16+
import { nKeysBetween } from '@/lib/table/order-key'
1617
import { buildOrderedRowValues, getTableById } from '@/lib/table/service'
1718

1819
const logger = createLogger('CopilotToolResultTables')
@@ -103,6 +104,8 @@ export async function maybeWriteOutputToTable(
103104
await tx.delete(userTableRows).where(eq(userTableRows.tableId, outputTable))
104105

105106
const now = new Date()
107+
// Replace-all: table was just cleared — mint a fresh contiguous key run.
108+
const orderKeys = nKeysBetween(null, null, rows.length)
106109
for (let i = 0; i < rows.length; i += BATCH_CHUNK_SIZE) {
107110
if (context.abortSignal?.aborted) {
108111
throw new Error('Request aborted before tool mutation could be applied')
@@ -113,6 +116,7 @@ export async function maybeWriteOutputToTable(
113116
workspaceId: context.workspaceId!,
114117
rows: chunk as RowData[],
115118
startPosition: i,
119+
orderKeys: orderKeys.slice(i, i + BATCH_CHUNK_SIZE),
116120
now,
117121
createdBy: context.userId,
118122
makeId: () => `row_${generateId().replace(/-/g, '')}`,
@@ -246,6 +250,8 @@ export async function maybeWriteReadCsvToTable(
246250
await tx.delete(userTableRows).where(eq(userTableRows.tableId, outputTable))
247251

248252
const now = new Date()
253+
// Replace-all: table was just cleared — mint a fresh contiguous key run.
254+
const orderKeys = nKeysBetween(null, null, rows.length)
249255
for (let i = 0; i < rows.length; i += BATCH_CHUNK_SIZE) {
250256
if (context.abortSignal?.aborted) {
251257
throw new Error('Request aborted before tool mutation could be applied')
@@ -256,6 +262,7 @@ export async function maybeWriteReadCsvToTable(
256262
workspaceId: context.workspaceId!,
257263
rows: chunk as RowData[],
258264
startPosition: i,
265+
orderKeys: orderKeys.slice(i, i + BATCH_CHUNK_SIZE),
259266
now,
260267
createdBy: context.userId,
261268
makeId: () => `row_${generateId().replace(/-/g, '')}`,

0 commit comments

Comments
 (0)