feat(api,console): compare-and-swap on attribute and saved-view writes - #621
Merged
Conversation
`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
force-pushed
the
fix/597-optimistic-concurrency
branch
from
August 10, 2026 03:22
748bd77 to
f5bf1b1
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #597.
The defect
PATCH /attributes/{id}andPATCH /saved-views/{id}replace the whole editable record. A field the request omits is cleared.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.Versionis compared with the loaded version. A mismatch is aCONFLICT(409). Omittingversionkeeps last-write-wins, the contractSavedViewPatchalready 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/SavedViewInputgainedversion;EntitiesPagecaptures it inopenSave()and sends it;AttributeDrawercarries it throughcarriedFields/buildCarriedUpdateinweb/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
friendlyErrorpath (newisConflicthelper). 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.yamldocuments 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):Console: 2 tests in
attribute-edit.test.ts(version carried; absent version omitted), 4 inapi.test.ts(both endpoints putversionon the wire; a 409 is recognised and rendered; another failure is not mistaken for one).Go SDK: 2 tests asserting
versionreaches 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