* feat(tables): per-table mutation locks (schema/insert/update/delete)
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.
* fix(tables): address review — CI snapshot, lock-aware jobs, UI gating
- 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
* fix(tables): close lock gaps in async import and undo/redo
- 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.
* fix(tables): stop locks over-blocking workflow runs, duplicate, and imports
- 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
* fix(tables): keep column menu usable and let locks always be cleared
- 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
* fix(tables): scope the update-lock exemption and re-check locks per page
- 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)
* fix(tables): revalidate locks in-transaction and unbreak backfill + partial 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
* fix(tables): guard import batches in-transaction and split paste by verb
- 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
* fix(tables): guard the remaining import writes and explain blocked Shift+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
* improvement(tables): surface blocked lock actions as a toast, not a modal
- 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
* fix(tables): don't show the update-lock notice to users without write 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.
* improvement(tables): trim the update-lock toast copy
* fix(tables): map append-import locks to 423 and stop conflating locks 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
* fix(tables): revalidate sync imports under the advisory lock, explain 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
* fix(tables): restore the workspace ownership check on copilot row deletes
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.