Skip to content

feat(tables): per-table mutation locks (schema/insert/update/delete) - #5960

Merged
TheodoreSpeaks merged 15 commits into
stagingfrom
feat/append-only-table
Jul 25, 2026
Merged

feat(tables): per-table mutation locks (schema/insert/update/delete)#5960
TheodoreSpeaks merged 15 commits into
stagingfrom
feat/append-only-table

Conversation

@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator

Summary

  • Adds four independent per-table locks — schema / insert / update / delete — so a table can be made append-only, read-only, or schema-frozen
  • Enforced at the lib/table service layer, not the routes: Mothership calls the services directly, so route-level asserts would miss it entirely. API, workflow blocks, and Mothership all covered by one assert
  • Lock violation returns 423; changing a lock requires workspace admin (which already includes org owners/admins) and returns 403
  • Admin-only "Table locks" panel off the table breadcrumb, plus a header chip that names the current mode (Append-only / Read-only / Locked)
  • Locked controls stay visible and explain themselves in a modal instead of silently disappearing — admins get a one-click route to the settings
  • Compile-time guard: the low-level row-write primitives in rows/ordering.ts now require a branded proof, so a new write path can't be added without asserting first
  • Feature-flagged behind table-locks / TABLE_LOCKS (off by default). Enforcement of already-set locks always runs; the flag gates setting them
  • Fixes a latent hole found on the way: the v1 DELETE /rows/[rowId] route did a raw db.delete and would have returned 200 on a locked table
  • Forks reset locks on the copy, matching workflow.locked — forking requires admin on the source, the same bar as unlocking, so it can't launder a lock

Type of Change

  • New feature

Testing

Tested manually. bun run lint:check, type-check, check:migrations, check:api-validation:strict, and the rest of the CI audit suite all pass. 506 table/hook/route tests pass, including new unit tests for each assert and its exemptions (executions-only patches and workflow-group output columns stay writable under an update lock).

Not yet exercised against a running app — worth a click-through on an append-only table before merge.

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)

@vercel

vercel Bot commented Jul 25, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview, Comment Jul 25, 2026 5:51pm

Request Review

@cursor

cursor Bot commented Jul 25, 2026

Copy link
Copy Markdown

PR Summary

High Risk
Changes core table write paths (rows, schema, import/delete jobs, v1 API, copilot tools) and adds governance that can block mutations cluster-wide if misconfigured; mid-job lock behavior leaves partial deletes/imports committed by design.

Overview
Introduces per-table mutation locks (schema, insert, update, delete) so tables can be append-only, read-only, or schema-frozen. Locks are enforced in lib/table services (not only HTTP routes), with violations throwing TableLockedError → HTTP 423 via shared tableLockErrorResponse; workflow/enrichment output writes stay allowed under an update lock via computedWrite.

API & contracts: Table GET/list responses now include locks. PATCH on /api/table/[tableId] expands from rename-only to updateTableContract (optional rename + partial lock toggles). Turning a lock on requires workspace admin and the table-locks feature flag; clearing locks stays allowed if the flag is off. Async import and delete jobs assert locks before claiming the job slot; runners re-assert inside batch transactions and cancel mid-run if a lock is enabled.

Clients: Admin Lock settings modal, header lock chip, grid/context-menu gating with blocked-action toasts (not hidden controls), CSV import UI respecting replace/schema locks, React Query useUpdateTableLocks plus 423 self-heal invalidation. SSE definition events refresh stale lock state. Workspace forks reset locks on copied tables. v1 row DELETE now routes through deleteRow instead of raw SQL.

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

Comment thread apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts
Comment thread apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx
@greptile-apps

greptile-apps Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds independently configurable schema, insert, update, and delete locks for tables.

  • Enforces locks across table services, synchronous operations, background jobs, imports, API routes, workflow writes, and Copilot tools.
  • Serializes background batch revalidation with lock changes and cancels jobs when newly enabled locks prohibit subsequent batches.
  • Adds admin-only lock controls, client-side affordance gating, realtime definition refreshes, lock-aware undo behavior, and API error mapping.
  • Persists lock state through four new table-definition columns while resetting locks on workspace forks.

Confidence Score: 5/5

The PR appears safe to merge because no blocking failures remain within the eligible follow-up scope.

No blocking failure remains.

Important Files Changed

Filename Overview
apps/sim/lib/table/mutation-locks.ts Defines the four lock assertions, update exemptions, lock errors, and branded mutation proofs.
apps/sim/lib/table/rows/ordering.ts Adds proof requirements and transaction-scoped advisory-lock revalidation to low-level batch writes.
apps/sim/lib/table/delete-runner.ts Revalidates delete locks for each batch and cancels jobs when a lock blocks further deletion.
apps/sim/lib/table/update-runner.ts Revalidates update locks for each batch using the patch’s actual columns and cancels blocked jobs.
apps/sim/lib/table/import-runner.ts Enforces insert, delete, and schema locks at import startup and within corresponding write transactions.
apps/sim/lib/table/import-data.ts Moves import writes behind transaction-scoped lock guards and fresh table-definition reads.
apps/sim/lib/table/rows/service.ts Threads lock assertions and mutation proofs through synchronous row operations.
apps/sim/app/api/table/[tableId]/route.ts Adds admin-gated lock updates, feature-flag checks, and lock state to table responses.
packages/db/migrations/0270_table_mutation_locks.sql Adds non-null schema, insert, update, and delete lock columns with unlocked defaults.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Admin[Workspace admin] --> API[Table lock API]
  API --> Locks[(Table lock columns)]
  Locks --> Services[Table service assertions]
  Services --> Sync[Sync mutations]
  Services --> Jobs[Background jobs]
  Jobs --> Guard[Transactional batch revalidation]
  Guard --> Writes[Row and schema writes]
  Locks --> UI[Lock-aware table UI]
Loading

Reviews (13): Last reviewed commit: "fix(tables): restore the workspace owner..." | Re-trigger Greptile

Comment thread apps/sim/lib/table/delete-runner.ts Outdated
@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@greptile

@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/lib/table/import-runner.ts Outdated
Comment thread apps/sim/hooks/use-table-undo.ts Outdated
Comment thread apps/sim/lib/table/update-runner.ts
Comment thread apps/sim/lib/table/import-runner.ts Outdated
Comment thread apps/sim/hooks/use-table-undo.ts Outdated
Comment thread apps/sim/lib/table/update-runner.ts Outdated
Comment thread apps/sim/lib/table/delete-runner.ts Outdated
@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

On the remaining summary point — "both worker-start checks mint unversioned proofs before later page writes, allowing a lock-state change in that interval to be bypassed" — the two concrete findings there are fixed in 0344bc9 (the update worker now applies assertRowUpdate exemptions, and both workers assert via the shared helpers). The residual check-to-write window is deliberate, and I want to be explicit about why rather than leave it looking overlooked:

It is not specific to the workers. Every mutation path in this feature loads the table, then writes: a single-row PATCH resolves table in checkAccess and writes some milliseconds later. Closing the window in the runners alone would not make the system race-free, it would just make one path inconsistent with the rest.

The two ways to actually close it are both known-harmful here. Re-reading the definition inside the write transaction means either SELECT … FOR UPDATE on the definition row — which reintroduces the per-insert serialization hotspot migration 0198 was written to remove — or getTableById without { tx }, which takes a second pool connection while holding the first, the exact shape of a prior production pool-starvation incident. A per-page re-check would add a definition read to every page of every bulk job to shrink, not eliminate, the window.

The window is bounded by design. Locks gate starting a mutation; workspace write remains the security boundary. A job that has already committed pages finishes on purpose — committed batches are never rolled back anywhere in this system (markJobCanceled is documented as leaving committed work in place), so aborting mid-flight produces the same half-done state as a cancel nobody requested. An admin who wants an in-flight job stopped uses POST /api/table/[tableId]/job/cancel.

This is recorded as an explicit non-goal in the mutation-locks.ts module docblock, including the 0198 and pool-starvation rationale, so a future contributor does not "fix" it back into an incident. Happy to revisit if you see a cheaper mitigation I have missed.

@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@greptile

@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx Outdated
Comment thread apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx
Comment thread apps/sim/app/api/table/[tableId]/delete-async/route.ts
Comment thread apps/sim/lib/table/workflow-groups/service.ts Outdated
@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@greptile

@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/app/api/table/[tableId]/import-async/route.ts
Comment thread apps/sim/app/api/table/[tableId]/route.ts
Comment thread apps/sim/lib/table/mutation-locks.ts Outdated
@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@greptile

@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 24ca513. Configure here.

Comment thread apps/sim/lib/table/delete-runner.ts Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit ba4beb6. Configure here.

Comment thread apps/sim/lib/copilot/tools/server/table/user-table.ts
Comment thread apps/sim/lib/table/rows/service.ts
@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@greptile

@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 3a1b8a6. Configure here.

Adds four independent, admin-only locks to a table so a workspace can make it
append-only, read-only, or schema-frozen. Enforced at the lib/table service
layer rather than the routes, so the API, workflow blocks, and Mothership (which
calls the services directly) are all covered by one assert. Violations return
423; changing a lock requires workspace admin and returns 403.
- Add the drizzle meta snapshot the hand-written migration was missing, which
  made CI regenerate the lock columns as a phantom 0271
- Stop a queued or retried delete/update job that starts after its lock is
  enabled; a run that has already committed pages still finishes, since pages
  are never rolled back and aborting would leave an uncancelled half-done state
- Map TableLockedError to 423 on the internal single-row DELETE (was a 500)
- Gate the lock settings UI behind NEXT_PUBLIC_TABLE_LOCKS so it can't open
  onto a Save that 403s against the server-side flag
- Respect the delete lock on column drops in the grid, matching
  assertColumnDestructive, instead of failing only on click
- Assert insert (and delete for replace) at the start of the import worker.
  Replace deleted every row before the first insert assert, so an insert-locked
  table was wiped and then failed, leaving it empty.
- Run the delete/update worker start checks through assertRowDelete /
  assertRowUpdate instead of reading the lock flags directly, so the worker and
  its enqueue site apply identical rules — including the workflow-column
  exemption a bulk update was being cancelled despite.
- Make undo/redo verb mapping direction-aware for column ops: dropping or
  retyping a column needs the delete lock clear too, matching
  assertColumnDestructive, so those steps are skipped instead of stranded.
…mports

- Run / Re-run / Stop no longer inherit the cell-edit gate: they write only
  workflow-output columns (which the update lock exempts) and Stop is a cancel
- Duplicate needs only the insert lock — it inserts a full copied row, so it
  stays available on an append-only table
- updateWorkflowGroup asserts the destructive rule only when a patch actually
  drops or remaps output columns; rename / autoRun / mapping edits need just
  the schema lock
- Gate the import-async enqueue on insert (and delete for replace) so a locked
  table 423s instead of claiming the write-job slot and failing in the worker
- Disable Import CSV under an insert lock, and wire the expanded cell editor
  and the previously-unused stable add-row handler to the lock-aware flags
- Pass the blocked-delete handler instead of undefined so ColumnOptionsMenu
  still mounts on a delete-locked table; withholding it hid the entire menu,
  including Insert column, which the delete lock must not affect
- Restore ColumnHeaderMenu readOnly to permission-only: it swaps the header for
  a static label, which was also disabling pin, column-select and open-config
- Assert the schema lock at the import-async enqueue when createColumns is set
- Allow a locks PATCH that only clears locks even when the feature flag is off,
  and keep the settings entry reachable on a locked table, so flipping the kill
  switch can't strand a table with locks nobody can remove
- Make the workflow-output carve-out opt-in via `computedWrite`, set only by
  the cell-write path. It was caller-agnostic, so an ordinary API caller could
  PATCH a workflow-output column on an update-locked table
- Re-read the lock before every page in the delete and update runners. The
  single worker-start check went stale immediately, so enabling a lock could
  not stop a job already deleting or overwriting rows. Committed pages still
  stay committed, exactly as with an explicit cancel. Import keeps its
  one-shot check: a half-imported table needs the deletes the lock now forbids
- Route Insert column to the locked-action modal under a schema lock instead
  of leaving it live (regular header) or hiding the whole menu (group header)
…artial unlock

- Re-assert the lock inside each page-write transaction, under the same
  advisory lock updateTableLocks takes, so check-then-write is atomic against
  a lock change. The per-page check alone still left the page-selection await
  between the assert and the write
- Pass computedWrite from the backfill runner: batchUpdateRows is the
  workflow-output backfill path, so scoping the exemption had made rebuilding
  outputs 423 on an update-locked table while live cell writes still worked
- Gate the feature flag on a lock actually going off->on rather than on an
  all-false payload. The settings UI always submits all four flags, so with
  the flag off an admin could not clear one lock while another stayed on
- Hoist the lock-kind -> flag map to lib/table/types as TABLE_LOCK_FLAGS
- Re-assert the insert lock inside each import batch's insert transaction,
  under the same advisory lock updateTableLocks takes. Reversing the earlier
  "let the file finish" call: an admin can lift the lock to clean up a partial
  import, so honouring it beats letting the rest of the file land
- Gate paste per verb. Overwriting existing rows is an update and extending
  past the last row is a full-row insert, so a paste-append still works on an
  append-only table while an overwrite explains itself. Refuses the whole
  paste rather than applying half of it
- Space opens the same row editor as double-click, so it now follows the
  update lock instead of filling in a form that 423s on save
…ift+Enter

- Route the replace-mode wipe, the inferred-schema write and createColumns
  through the same guardBatch transaction as the batch inserts, so a delete or
  schema lock committed while the file is downloading or being sampled is seen
  instead of the job-start snapshot
- Shift+Enter takes the manual-add path, so it now opens the lock modal like
  the Add row button instead of returning silently
…odal

- Replace TableLockedModal with a warning toast carrying a "Lock settings"
  action button for admins. Being told you can't edit shouldn't cost a dismiss
  click, and the button still routes admins straight to the panel
- Dedupe by id so repeated attempts on a locked cell replace one notice
  instead of stacking a column of them
- Move the copy into lock-copy.ts alongside the rest of the lock vocabulary
  and tighten it for toast length
… access

Double-click checked the update lock before any permission check, so a
read-only member on a locked table got "Editing rows is locked" — misleading
(the lock isn't why they can't edit) and it swallowed the expanded-cell
viewer, which is a legitimate read-only affordance.
… with permissions

- The sync append branch returns instead of rethrowing, so the outer catch's
  mapper never saw a TableLockedError and every lock violation became a 500.
  Map it in that catch, with a regression test (replace mode already rethrows)
- Stop mounting the workflow-group column menu for users without edit access:
  passing the blocked handlers unconditionally made it appear for read-only
  members and report a lock even on an unlocked table
- Enter/F2 now raises the same lock notice as double-click and Space instead
  of silently doing nothing
- guardBatch returns the freshly-read definition so addTableColumnsWithTx
  asserts live state; a schema lock cleared mid-import no longer fails the
  createColumns step. The snapshot pre-asserts now only run as the fallback
  for callers that pass no revalidator
- Append-only no longer labels a table whose schema is also locked, which
  claimed columns were mutable when they weren't
- Import dialog withholds Replace on a delete-locked table and create-column
  on a schema-locked one, instead of offering a configuration that only 423s
… every blocked key

- The sync append/replace paths asserted only the request-start snapshot, so a
  lock committed while the CSV was parsed still let the write through. Both now
  re-read under the schema advisory lock at the top of their own transaction,
  taken before acquireRowOrderLock so the order stays advisory -> rows_pos ->
  definitions
- Delete/Backspace, Cmd+D, typeahead and cut raised no notice on an
  update-locked table; they now explain the lock, and stay silent for users
  without write access
- The multipart import fetch threw a plain Error, dropping the 423 status, so
  the lock self-heal never ran and the stale detail cache survived. It throws
  ApiClientError now and both import-into-table hooks call
  handleTableLockRejection
- Force append at submit when the delete lock landed while the dialog was open
  with Replace already selected
…etes

Switching deleteRow/deleteRowsByIds to take a TableDefinition dropped the
workspaceId argument that previously scoped the query, and the two branches
I added loaded the table without the ownership comparison every other
operation in the tool performs — letting a caller delete rows from a table
in another workspace. Both now reject a foreign table as not found.
@TheodoreSpeaks
TheodoreSpeaks force-pushed the feat/append-only-table branch from 3a1b8a6 to 43e497d Compare July 25, 2026 17:44
@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@greptile

@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 43e497d. Configure here.

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