Skip to content

feat(tables): fractional order keys for O(log n) row insert/delete (flag-gated, default off)#4890

Merged
TheodoreSpeaks merged 20 commits into
stagingfrom
feat/tables-fractional-ordering
Jun 5, 2026
Merged

feat(tables): fractional order keys for O(log n) row insert/delete (flag-gated, default off)#4890
TheodoreSpeaks merged 20 commits into
stagingfrom
feat/tables-fractional-ordering

Conversation

@TheodoreSpeaks
Copy link
Copy Markdown
Collaborator

@TheodoreSpeaks TheodoreSpeaks commented Jun 5, 2026

Summary

Replaces the contiguous integer position ordering of user_table_rows with fractional string order keys so insert and delete stop the O(N) position reshift/recompact. Gated behind the TABLES_FRACTIONAL_ORDERING env flag (default off → identical behavior today), rolled out expand/contract.

  • order-key.ts util (keyBetween/nKeysBetween) over fractional-indexing; nullable order_key column + (table_id, order_key, id) index (migration 0226); per-table-atomic, idempotent backfill script (chunked writes).
  • Server (flag-gated): queryRows orders by order_key, id; deletes skip the reindex; inserts mint a key between neighbors (afterRowId/beforeRowId, O(log n) via the index, no O(N) reshift). All 8 insert paths always write a key, so the flag can flip safely after backfill.
  • Wire/client: row response carries orderKey; insert intent is afterRowId/beforeRowId; client reconcile/undo place by key (no neighbor bump), with position fallback for un-backfilled rows.
  • Breaking: removes rowNumber from the table-trigger output — it was position-based and goes gappy under the flag. Workflows referencing rowNumber lose the field.

Rollout

Deploy (flag off) → run backfill → flip TABLES_FRACTIONAL_ORDERING on + restart (reversible). A later PR drops the position column.

Type of Change

  • Feature (flag-gated, behavior-preserving when off)

Testing

Manual verification on a local DB with large seeded tables, flag on:

  • Applied migration 0226; ran the backfill across 16 tables incl. 500k–1M rows — per-table-atomic, idempotent re-runs, chunked writes (1M-row tables key without overflowing the JS stack / the 65535 param limit).
  • Enabled TABLES_FRACTIONAL_ORDERING + restarted; reads via the grid and the table-query block both order by order_key, id.
  • Inspected a 521k-row table: every row keyed, all keys unique and compact (≤5 chars); post-backfill inserts mint a key strictly between neighbors with no other row's key changed and no collisions.
  • EXPLAIN confirms ORDER BY order_key, id uses the (table_id, order_key, id) index (Index Scan, no Sort) — sub-ms on 500k+ rows.
  • Confirmed the max_rows cap still rejects inserts on a full table.

Automated: table / copilot / route unit tests, type-check, check:api-validation:strict, and full lint:check all pass.

Follow-ups (separate PRs)

  • Drop the position column once the flag has baked on.
  • Keyset pagination — deep-page OFFSET is O(offset); cursor on (order_key, id) makes it O(log n).

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

…uery 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.
…lace, 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.
…-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.
@vercel
Copy link
Copy Markdown

vercel Bot commented Jun 5, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Jun 5, 2026 9:03pm

Request Review

… 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.
@TheodoreSpeaks
Copy link
Copy Markdown
Collaborator Author

Status update — insert half + verification done

All planned work for this PR is now in:

  • ✅ Delete + insert wins (flag-gated), reads (incl. Mothership) via service.queryRows.
  • ✅ Position-based inserts (mothership/v1/old-client/undo-fallback) resolve by key ordinal under the flag (the tool itself unchanged).
  • rowNumber kept as the stable position ordinal (no compute-on-read — better semantics; trigger output description can be footnoted later).
  • Flag-on unit tests added: delete skips the reindex (no shift / no recompaction), insert mints a key with no shift.

10 commits; type-check, lint:check, check:api-validation:strict, and ~197 table + 205 route/copilot tests all green.

Activation runbook (when ready to flip)

  1. Merge with the flag off (zero behavior change).
  2. Run the backfill (populates order_key for existing rows, per-table-atomic + idempotent):
    DATABASE_URL=… bun run apps/sim/scripts/backfill-table-order-keys.ts (dry-run with --dry-run first).
  3. Sanity-check: SELECT * FROM user_table_rows WHERE table_id=… ORDER BY order_key, id matches the prior ORDER BY position.
  4. Set TABLES_FRACTIONAL_ORDERING=true + restart → fractional ordering active (reversible: unset + restart).
  5. Manual check on a large table: insert-at-top / mid / delete / bulk-delete return ~instantly and land in the right spot; grid order, pagination, undo correct.

Follow-up PR

Drop the position column + (table_id, position) index once the flag has baked on.

@TheodoreSpeaks
Copy link
Copy Markdown
Collaborator Author

@greptile review

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Jun 5, 2026

Greptile Summary

This PR replaces contiguous integer position ordering on user_table_rows with fractional string order_keys, making row insert and delete O(1) instead of O(N). The change is gated behind TABLES_FRACTIONAL_ORDERING (default off), with a documented expand/contract rollout: deploy → backfill → flip flag.

  • DB/migration: adds a nullable order_key column and a (table_id, order_key, id) btree index (migration 0226); a new idempotent backfill script keys existing rows from their current position order under the same advisory lock the app uses.
  • Server (flag-gated): queryRows orders by order_key, id when the flag is on; deletes skip the O(N) position recompaction; inserts mint a key between neighbors via afterRowId/beforeRowId in O(1).
  • Client: cache reconciliation in reconcileCreatedRow switches to key-based sorting when all cached rows carry an orderKey; undo/redo routes through the batch API to preserve exact key placement.

Confidence Score: 4/5

Safe to deploy with the flag off (the default); the flag-on path has a client-side sort inconsistency that would produce wrong row ordering in some locales.

The flag-off path is purely additive and behaviorally identical to today. The flag-on path has a mismatch in reconcileCreatedRow where localeCompare and native >= use different comparison semantics for fractional-indexing keys — rows can be placed at the wrong position in the optimistic cache in locales where uppercase and lowercase letters sort differently.

apps/sim/hooks/queries/tables.ts — the sortRows / fitsAfter comparison mismatch

Important Files Changed

Filename Overview
apps/sim/lib/table/service.ts Core of the feature: adds fractional key generation, flag-gated ordering, lock-safe O(1) inserts/deletes, and neighbor-based insert resolution. Correct advisory-lock usage across all insert paths.
apps/sim/hooks/queries/tables.ts Client-side cache reconciliation for fractional ordering. Contains a localeCompare vs native >= inconsistency in reconcileCreatedRow that can produce wrong row ordering in some locales.
apps/sim/lib/table/order-key.ts Thin wrapper around fractional-indexing. Simple and correct.
packages/db/migrations/0226_sparkling_sheva_callister.sql Adds nullable order_key column and (table_id, order_key, id) btree index. Non-breaking, additive migration.
apps/sim/scripts/backfill-table-order-keys.ts Idempotent, per-table-atomic backfill using the same advisory lock as the app. Handles chunked UPDATEs to avoid exceeding Postgres's param limit.
apps/sim/hooks/use-table-undo.ts Undo/redo migrated to batch path to carry orderKey for exact placement; falls back to append for un-backfilled rows.
apps/sim/lib/api/contracts/tables.ts Adds afterRowId/beforeRowId with mutex refinement; rowAnchorMutexRefine correctly extracted for reuse in the v1 public contract.
apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx Insert-row UI now sends neighbor-id intent instead of integer position. Contains a dead position variable left over after the refactor.

Reviews (5): Last reviewed commit: "docs(triggers): drop rowNumber from tabl..." | Re-trigger Greptile

Comment thread apps/sim/lib/table/service.ts
Comment thread apps/sim/lib/table/service.ts
Comment thread apps/sim/lib/api/contracts/tables.ts
Comment thread apps/sim/hooks/queries/tables.ts Outdated
…n + 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.
@TheodoreSpeaks
Copy link
Copy Markdown
Collaborator Author

Thanks @greptile — all four addressed in c6b8375:

  • P1 (missing anchor): `resolveInsertByNeighbor` now throws `Row not found: ${anchorId}` instead of silently inserting at the front.
  • P1 (null anchor key): added a guard — with the flag on (key authoritative) a null `order_key` on the anchor now throws `… not backfilled` rather than minting a wrong key via the NULL tuple comparison. (Flag off keeps `position` authoritative, so a best-effort key there is fine — the backfill re-keys before the flip.)
  • P2 (mutual exclusion): added `.refine` rejecting both `afterRowId` and `beforeRowId`.
  • P2 (mixed-key reconcile): `reconcileCreatedRow` now key-sorts only when every cached row has an `orderKey`; otherwise it falls back to the `position` path, so un-keyed rows aren't yanked to the front by an empty-string sort during the backfill window.

(Also fixed an unrelated key?.startsWith type-guard regression an earlier --unsafe lint left in knowledge/documents/service.ts.)

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Jun 5, 2026

All four look solid. A few notes from reviewing the commit:

The .refine for mutual exclusion in contracts/tables.ts is correct — the error message is clear. One minor observation: no path is specified on the refine, so it surfaces as a form-level (not field-level) error. That's fine for the API, but if a UI form ever renders these errors per-field, it would need { path: ['afterRowId'] } (or similar). Not a blocker, just worth keeping in mind if the insert dialog ever shows inline field errors.

The null anchor-key guard — the "flag-on throws, flag-off is best-effort" split is the right invariant. One question: when resolveInsertByNeighbor throws the … not backfilled error, how does that propagate to the caller? If it bubbles up as an unhandled exception it'll be a 500; ideally it's caught and re-thrown as a typed BadRequestError / NotFoundError so the client gets a useful 4xx instead of a generic server error. Worth checking the error-handling layer around that call site.

The "every cached row has orderKey" check in reconcileCreatedRow handles the backfill-window mixed state correctly — conservative but sound. One edge case to be aware of: a client that was loaded before backfill, receives an SSE push for a newly-inserted row (which will always carry an orderKey now), but still has old rows in cache without one. The all-keyed check will correctly fall back to position for that entire reconcile, which is the right behavior.

Everything else looks well-reasoned. The expand/contract rollout (deploy → backfill → flip → future drop) is clean. Marking the previous concerns resolved.

- 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.
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.
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.
@TheodoreSpeaks TheodoreSpeaks marked this pull request as ready for review June 5, 2026 02:13
@TheodoreSpeaks
Copy link
Copy Markdown
Collaborator Author

@greptile review

@cursor
Copy link
Copy Markdown

cursor Bot commented Jun 5, 2026

PR Summary

High Risk
Touches core table ordering, migrations, and many insert/delete/read paths; incorrect rollout or partial backfill could mis-order rows or break undo until keys are consistent.

Overview
Introduces fractional order_key row ordering behind TABLES_FRACTIONAL_ORDERING (default off). Adds order-key.ts, DB migration 0226 (order_key + index), dependency fractional-indexing, and a per-table backfill script so existing rows get keys before the flag is enabled.

Server: Inserts always persist an orderKey; with the flag on, reads sort by order_key, id and deletes skip O(N) position shift/recompact. Single/batch APIs accept afterRowId/beforeRowId and batch orderKeys (undo restore); responses include orderKey. Imports, copilot table writes, and replace-all paths mint contiguous keys.

Client: Grid insert/duplicate uses neighbor ids instead of integer positions; React Query cache merge sorts by orderKey when fully keyed, else legacy position bump. Undo/redo restores rows via batch create with saved orderKeys.

Triggers/docs: rowNumber is removed from table trigger payloads and docs.

Reviewed by Cursor Bugbot for commit d85448d. Bugbot is set up for automated code reviews on this repo. Configure here.

Comment thread apps/sim/app/api/table/[tableId]/rows/route.ts
Comment thread apps/sim/hooks/use-table-undo.ts
Comment thread apps/sim/lib/table/service.ts
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.
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.
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.
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.
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

There are 3 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 21ea14c. Configure here.

Comment thread apps/sim/lib/table/trigger.ts
@TheodoreSpeaks
Copy link
Copy Markdown
Collaborator Author

@greptile review

biome check . flagged the drizzle-generated _journal.json and 0226_snapshot.json;
apply the formatter so packages/db lint:check passes in CI.
rowNumber was removed from the table trigger payload; remove it from the
documented output fields to match.
@TheodoreSpeaks
Copy link
Copy Markdown
Collaborator Author

@greptile review

@TheodoreSpeaks TheodoreSpeaks changed the title feat(tables): fractional order keys for O(1) row insert/delete (flag-gated, default off) feat(tables): fractional order keys for O(log(n)) row insert/delete (flag-gated, default off) Jun 5, 2026
Flag-on behavior is covered by manual large-table verification; the heavily-
mocked DB-chain suite added little signal.
@TheodoreSpeaks TheodoreSpeaks changed the title feat(tables): fractional order keys for O(log(n)) row insert/delete (flag-gated, default off) feat(tables): fractional order keys for O(log n) row insert/delete (flag-gated, default off) Jun 5, 2026
@TheodoreSpeaks TheodoreSpeaks merged commit 24a6086 into staging Jun 5, 2026
14 checks passed
@TheodoreSpeaks TheodoreSpeaks deleted the feat/tables-fractional-ordering branch June 5, 2026 21:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant