Skip to content

feat(api,console): compare-and-swap on attribute and saved-view writes - #621

Merged
zkrebbekx merged 1 commit into
mainfrom
fix/597-optimistic-concurrency
Aug 10, 2026
Merged

feat(api,console): compare-and-swap on attribute and saved-view writes#621
zkrebbekx merged 1 commit into
mainfrom
fix/597-optimistic-concurrency

Conversation

@zkrebbekx

Copy link
Copy Markdown
Owner

Closes #597.

The defect

PATCH /attributes/{id} and PATCH /saved-views/{id} replace the whole editable record. A field the request omits is cleared.

  • The attribute update had no concurrency control at all.
  • The saved view had a compare-and-swap on the server, but the console never sent a version, so that path was dead code from the console's side.

Two operators editing one record each sent the whole record. The later write erased the earlier one — including fields that operator never looked at — and nothing reported it. The drawer even told the operator "Saving bumps the definition to version N+1" while sending nothing that used the version.

The fix

Both requests take version, the version the caller read.

Server (application/attribute/interactor.go): inside the update transaction, after the row lock, in.Version is compared with the loaded version. A mismatch is a CONFLICT (409). Omitting version keeps last-write-wins, the contract SavedViewPatch already documented, so an existing caller is unaffected.

The swap is checked before every other rule. A conflict means the record being replaced is not the record the caller loaded, so every validation below it would be judging the wrong baseline — the operator would be told about a structural-change refusal computed against a state they never saw.

Console: SavedView/SavedViewInput gained version; EntitiesPage captures it in openSave() and sends it; AttributeDrawer carries it through carriedFields/buildCarriedUpdate in web/src/lib/attribute-edit.ts.

The version is captured at load time, not save time. Both lists refetch in the background, so a version read at save time would describe a change the author never saw and guard nothing. This is the part that makes the difference between a real check and a decorative one.

On a conflict the console invalidates the query so the other writer's version is pulled in, and reports it through the existing friendlyError path (new isConflict helper). The attribute drawer deliberately does not re-arm the swap against the version it just fetched — saving again without seeing the other change is exactly the lost update this fixes — so it keeps the operator's edits in the form and says to reopen the drawer, which reloads the record.

SDKs: Go client UpdateAttributeInput.Version *int; TypeScript client picks it up from the regenerated OpenAPI types, with the doc comment updated. api/openapi.yaml documents the field and adds the 409 and 422 responses the PATCH already returned.

Docs: new "Concurrent edits" section in docs/clients.md — read the version, send it back, re-read on 409, and never read the version late.

Tests

attribute_lost_update_test.go (goconvey, in-memory + Postgres, 5 scenarios each):

  • Second writer with a stale version is refused; the first operator's edit survives.
  • The refused write changed nothing — the version did not move and no half-applied field landed.
  • Re-reading lets the second operator re-apply on top.
  • No version is still last-write-wins.
  • A stale version with an also-invalid body reports the conflict, not the validation error.

Console: 2 tests in attribute-edit.test.ts (version carried; absent version omitted), 4 in api.test.ts (both endpoints put version on the wire; a 409 is recognised and rendered; another failure is not mistaken for one).

Go SDK: 2 tests asserting version reaches the wire when set and is absent when unset.

Mutation-verified: making the server check unconditional (if false) fails 5 assertions.

Green: full Go suite (686 tests, 60 packages), web 76, client-ts 141, Go SDK 22, vue-tsc and tsc clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DaPmWmgaGYqLsjW8DCU3P1

`PATCH /attributes/{id}` and `PATCH /saved-views/{id}` replace the whole
editable record. Neither had working concurrency control from a client.
The attribute update had none at all. The saved view had a
compare-and-swap on the server that no client ever exercised, because the
console never sent a version.

Two operators editing one record each sent the whole record, so the later
write erased the earlier one — including fields that operator never
looked at — and nothing reported it.

Both requests now take `version`, the version the caller read. A write
against a record somebody else has since changed answers CONFLICT rather
than overwriting it. Omitting `version` keeps last-write-wins, which is
the contract the saved-view patch already documented, so an existing
caller is unaffected.

The attribute swap is checked before every other rule. A conflict means
the record being replaced is not the record the caller loaded, so every
validation below it would be judging the wrong baseline.

The console captures the version at LOAD time, not at save time. Both the
view list and the attribute list refetch in the background, so a version
read at save time would describe a change the author never saw and guard
nothing. On a conflict the console pulls the other writer's version into
the cache and says what to do. The attribute drawer does not re-arm the
swap against the version it just fetched: saving again without seeing the
other change is the lost update this fixes.

Closes #597

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DaPmWmgaGYqLsjW8DCU3P1
@zkrebbekx
zkrebbekx force-pushed the fix/597-optimistic-concurrency branch from 748bd77 to f5bf1b1 Compare August 10, 2026 03:22
@zkrebbekx
zkrebbekx merged commit f4b6e57 into main Aug 10, 2026
10 checks passed
@zkrebbekx
zkrebbekx deleted the fix/597-optimistic-concurrency branch August 10, 2026 03:34
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.

Console writes have no optimistic concurrency; saved-view CAS is unused

1 participant