fix(tables): make the atomic per-cell merge the only row-write path (fix row-level LWW) - #5970
Conversation
…w-level LWW) A table row stores every cell in one jsonb `data` column, and the row-edit paths called updateRow in the default replace mode — a full-object read-modify-write. Two users (or a user and an agent/copilot) editing DIFFERENT cells of the same row concurrently would clobber each other: whichever write committed last overwrote the whole row from its stale snapshot. Pass dataWriteMode: 'patch' (Postgres `data = data || patch::jsonb`) from the four partial-update callers — the UI row route, the v1 API row route, the copilot table tool, and the workflow-group cancel-write — so each cell edit merges atomically under the row lock. upsertRow (whole-row by design) and updateRowsByFilter (already uses ||) are unchanged. computedWrite stays unset on the user paths, preserving the update lock. Adds a route test asserting the UI route opts into patch mode; the updateRow patch mechanism itself is already covered.
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
PR SummaryMedium Risk Overview
Tests in Reviewed by Cursor Bugbot for commit e7c903b. Configure here. |
Greptile SummaryThe PR makes atomic JSONB cell merging the sole row-update strategy.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains.
|
| Filename | Overview |
|---|---|
| apps/sim/lib/table/rows/service.ts | Centralizes partial JSONB row writes and applies them consistently to single and batched updates. |
| apps/sim/lib/table/tests/update-row.test.ts | Verifies single-row and batch paths persist only changed cells through JSONB concatenation. |
| apps/sim/lib/table/cell-write.ts | Removes the obsolete write-mode option while preserving the computed-write lock exemption. |
| apps/sim/lib/table/workflow-columns.ts | Documents that execution-only updates now produce an empty, non-destructive cell patch. |
Sequence Diagram
sequenceDiagram
participant A as Editor A
participant B as Editor B
participant DB as PostgreSQL row
A->>DB: "UPDATE data = data || {cellA}"
DB->>DB: Lock row and merge cellA
B->>DB: "UPDATE data = data || {cellB}"
DB->>DB: Wait, then merge cellB into committed row
DB-->>A: Updated
DB-->>B: Updated
Note over DB: Both distinct-cell changes survive
Reviews (3): Last reviewed commit: "refactor(tables): make the atomic cell-m..." | Re-trigger Greptile
|
@cursor review |
There was a problem hiding this comment.
✅ 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 7aff1ee. Configure here.
Audit of the opt-in-per-caller approach found it incomplete and the wrong shape:
- `replace` mode never actually replaces — updateRow computes mergedData =
{...existing, ...patch}, which only ever adds/overwrites keys, never deletes. So
replace is content-identical to patch and differs ONLY by being racy (full-object
read-modify-write = row-level last-write-wins). No caller benefits from it.
- Two structurally identical `data:{}` exec-clears in background/workflow-column-
execution.ts were left in replace mode, exposed to the same clobber the PR fixes.
- batchUpdateRows (the multi-cell grid/copilot path) still full-replaced with no
patch option at all, so the PR's own goal was only half-met.
Remove the dataWriteMode option entirely and make updateRow ALWAYS write the changed
cells via a shared jsonbMergePatch helper (data = data || {changed}::jsonb). Apply the
same helper per-row in batchUpdateRows. This auto-covers every updateRow caller —
including the two exec-clears — and closes the grid path, with the user-facing route
and copilot callers now unchanged vs staging (the fix lives entirely in the service
layer). computedWrite (the workflow-engine lock exemption) is untouched. Tests updated
to assert the always-on JSONB merge for both updateRow and batchUpdateRows.
|
@cursor review |
There was a problem hiding this comment.
✅ 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 e7c903b. Configure here.
Summary
datacolumn. Row updates ran a full-object read-modify-write (write the wholedata), which is row-level last-write-wins: two users — or a user and an agent/copilot — editing different cells of the same row concurrently clobbered each other.updateRownow always writes the changed cells via a sharedjsonbMergePatchhelper (data = data || {changed}::jsonb), which Postgres evaluates against the current committed row under its write lock — so concurrent edits to different cells both survive. ThedataWriteMode: 'replace' | 'patch'option is removed:replacenever actually replaced (the merge only ever adds/overwrites keys, never deletes), so it was content-identical topatchand differed only by being racy — no caller benefited from it.batchUpdateRows(the multi-cell grid / copilot batch path) applies the same per-row merge, so batch edits can't clobber a concurrent single-cell edit either.data:{}exec-clear writes inbackground/workflow-column-execution.tsare covered automatically.upsertRow(whole-row by design) andupdateRowsByFilter(already||-concat) are untouched.computedWrite(the workflow-engine lock exemption) is unaffected.Type of Change
Testing
update-row.test.ts: assertsupdateRowandbatchUpdateRowseach writedata || {changed}::jsonbcarrying only the changed cells (never the whole row).cell-write.ts.check:api-validation:strict,check:utils,tsc --noEmit, biome, and the table + copilot suites (481 + 111 + 46) pass.Checklist