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

vercelBot commented Jul 25, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
docsReadyReadyPreview, CommentJul 25, 2026 5:51pm

Request Review

@cursor

cursorBot 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 threadapps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts
@greptile-apps

greptile-appsBot 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

FilenameOverview
apps/sim/lib/table/mutation-locks.tsDefines the four lock assertions, update exemptions, lock errors, and branded mutation proofs.
apps/sim/lib/table/rows/ordering.tsAdds proof requirements and transaction-scoped advisory-lock revalidation to low-level batch writes.
apps/sim/lib/table/delete-runner.tsRevalidates delete locks for each batch and cancels jobs when a lock blocks further deletion.
apps/sim/lib/table/update-runner.tsRevalidates update locks for each batch using the patch’s actual columns and cancels blocked jobs.
apps/sim/lib/table/import-runner.tsEnforces insert, delete, and schema locks at import startup and within corresponding write transactions.
apps/sim/lib/table/import-data.tsMoves import writes behind transaction-scoped lock guards and fresh table-definition reads.
apps/sim/lib/table/rows/service.tsThreads lock assertions and mutation proofs through synchronous row operations.
apps/sim/app/api/table/[tableId]/route.tsAdds admin-gated lock updates, feature-flag checks, and lock state to table responses.
packages/db/migrations/0270_table_mutation_locks.sqlAdds 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 threadapps/sim/lib/table/delete-runner.ts Outdated
@TheodoreSpeaks

Copy link
Copy Markdown
CollaboratorAuthor

@greptile

@TheodoreSpeaks

Copy link
Copy Markdown
CollaboratorAuthor

@cursor review

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

Copy link
Copy Markdown
CollaboratorAuthor

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
CollaboratorAuthor

@greptile

@TheodoreSpeaks

Copy link
Copy Markdown
CollaboratorAuthor

@cursor review

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

Copy link
Copy Markdown
CollaboratorAuthor

@greptile

@TheodoreSpeaks

Copy link
Copy Markdown
CollaboratorAuthor

@cursor review

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

Copy link
Copy Markdown
CollaboratorAuthor

@greptile

@TheodoreSpeaks

Copy link
Copy Markdown
CollaboratorAuthor

@cursor review

@cursorcursorBot 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 threadapps/sim/lib/table/delete-runner.ts Outdated

@cursorcursorBot 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 threadapps/sim/lib/copilot/tools/server/table/user-table.ts
Comment threadapps/sim/lib/table/rows/service.ts
@TheodoreSpeaks

Copy link
Copy Markdown
CollaboratorAuthor

@greptile

@TheodoreSpeaks

Copy link
Copy Markdown
CollaboratorAuthor

@cursor review

@cursorcursorBot 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
TheodoreSpeaksforce-pushed the feat/append-only-table branch from 3a1b8a6 to 43e497dCompareJuly 25, 2026 17:44
@TheodoreSpeaks

Copy link
Copy Markdown
CollaboratorAuthor

@greptile

@TheodoreSpeaks

Copy link
Copy Markdown
CollaboratorAuthor

@cursor review

@cursorcursorBot 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 freeto 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

@TheodoreSpeaks