Skip to content

Commit d221611

Browse files
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.
1 parent 20d7810 commit d221611

9 files changed

Lines changed: 209 additions & 39 deletions

File tree

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

Lines changed: 3 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
@@ -162,6 +163,8 @@ export const POST = withRouteHandler(
162163
workspaceId: validated.workspaceId,
163164
userId: authResult.userId,
164165
position: validated.position,
166+
afterRowId: validated.afterRowId,
167+
beforeRowId: validated.beforeRowId,
165168
},
166169
table,
167170
requestId

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/hooks/queries/tables.ts

Lines changed: 35 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) => {
@@ -586,41 +592,49 @@ function reconcileCreatedRow(
586592
tableId: string,
587593
row: TableRow
588594
) {
595+
// Fractional ordering: the new row carries an `orderKey` and no other row's
596+
// key changes, so we splice it in by key with no neighbor bump. Falls back to
597+
// the legacy `position` path (bump + sort) for rows without a key.
598+
const byKey = row.orderKey != null
599+
const sortRows = (rows: TableRow[]) =>
600+
byKey
601+
? [...rows].sort((a, b) => (a.orderKey ?? '').localeCompare(b.orderKey ?? ''))
602+
: [...rows].sort((a, b) => a.position - b.position)
603+
const fitsAfter = (last: TableRow | undefined) =>
604+
last === undefined ||
605+
(byKey ? (last.orderKey ?? '') >= row.orderKey! : last.position >= row.position)
606+
589607
queryClient.setQueriesData<InfiniteData<TableRowsResponse, number>>(
590608
{ queryKey: tableKeys.rowsRoot(tableId), exact: false },
591609
(old) => {
592610
if (!old) return old
593611
if (old.pages.some((p) => p.rows.some((r) => r.id === row.id))) return old
594612

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-
)
613+
const pages = byKey
614+
? old.pages
615+
: old.pages.map((page) =>
616+
page.rows.some((r) => r.position >= row.position)
617+
? {
618+
...page,
619+
rows: page.rows.map((r) =>
620+
r.position >= row.position ? { ...r, position: r.position + 1 } : r
621+
),
622+
}
623+
: page
624+
)
605625

606626
let inserted = false
607627
const nextPages = pages.map((page) => {
608628
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
629+
if (!fitsAfter(page.rows[page.rows.length - 1])) return page
612630
inserted = true
613-
const merged = [...page.rows, row].sort((a, b) => a.position - b.position)
614-
return { ...page, rows: merged }
631+
return { ...page, rows: sortRows([...page.rows, row]) }
615632
})
616633

617634
if (!inserted && nextPages.length > 0) {
618635
const lastIdx = nextPages.length - 1
619636
const lastPage = nextPages[lastIdx]
620-
nextPages[lastIdx] = {
621-
...lastPage,
622-
rows: [...lastPage.rows, row].sort((a, b) => a.position - b.position),
623-
}
637+
nextPages[lastIdx] = { ...lastPage, rows: sortRows([...lastPage.rows, row]) }
624638
}
625639

626640
const firstPage = nextPages[0]
@@ -655,6 +669,7 @@ export function useBatchCreateTableRows({ workspaceId, tableId }: RowMutationCon
655669
workspaceId,
656670
rows: variables.rows as RowData[],
657671
positions: variables.positions,
672+
orderKeys: variables.orderKeys,
658673
},
659674
})
660675
},

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,9 @@ export function useTableUndo({
165165
{
166166
rows: action.rows.map((r) => r.data),
167167
positions: action.rows.map((r) => r.position),
168+
orderKeys: action.rows.every((r) => r.orderKey)
169+
? action.rows.map((r) => r.orderKey as string)
170+
: undefined,
168171
},
169172
{
170173
onSuccess: (response) => {
@@ -187,6 +190,9 @@ export function useTableUndo({
187190
{
188191
rows: action.rows.map((row) => row.data),
189192
positions: action.rows.map((row) => row.position),
193+
orderKeys: action.rows.every((row) => row.orderKey)
194+
? action.rows.map((row) => row.orderKey as string)
195+
: undefined,
190196
},
191197
{
192198
onSuccess: (response) => {

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,10 @@ export const insertTableRowBodySchema = z.object({
151151
workspaceId: z.string().min(1, 'Workspace ID is required'),
152152
data: rowDataSchema,
153153
position: z.number().int().min(0).optional(),
154+
/** Fractional ordering: insert directly after this row id. Takes precedence over `position`. */
155+
afterRowId: z.string().min(1).optional(),
156+
/** Fractional ordering: insert directly before this row id. Takes precedence over `position`. */
157+
beforeRowId: z.string().min(1).optional(),
154158
})
155159

156160
/**
@@ -175,13 +179,18 @@ export const batchInsertTableRowsBodySchema = z
175179
`Cannot insert more than ${TABLE_LIMITS.MAX_BATCH_INSERT_SIZE} rows per batch`
176180
),
177181
positions: z.array(z.number().int().min(0)).max(TABLE_LIMITS.MAX_BATCH_INSERT_SIZE).optional(),
182+
/** Fractional ordering: exact per-row order keys (undo restore). Takes precedence over `positions`. */
183+
orderKeys: z.array(z.string().min(1)).max(TABLE_LIMITS.MAX_BATCH_INSERT_SIZE).optional(),
178184
})
179185
.refine((data) => !data.positions || data.positions.length === data.rows.length, {
180186
message: 'positions array length must match rows array length',
181187
})
182188
.refine((data) => !data.positions || new Set(data.positions).size === data.positions.length, {
183189
message: 'positions must not contain duplicates',
184190
})
191+
.refine((data) => !data.orderKeys || data.orderKeys.length === data.rows.length, {
192+
message: 'orderKeys array length must match rows array length',
193+
})
185194

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

0 commit comments

Comments
 (0)