Repository navigation
fix(plugin-form): an open edit form's second save sends the version its first save returned (objectui#10565) - #10641
Conversation
… last save returned useOccSave now remembers, per object and record, the updated_at that its last successful write returned (the guarded save and the Overwrite retry alike) and sends it as the next save's ifMatch while the form still holds the same read. A newer read's token wins; a read without updated_at stays unguarded; a write result without a version falls back to the read. Before, every layout re-sent the first read's updated_at, so the second save from a form that stays open was refused 409 CONCURRENT_UPDATE over the user's own first save. Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC
…instead of timing out Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC
…e now sends; the pin types its form props Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC
|
changeset-claim-re-read
|
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Fixes #10565
Clause-②: no
What this changes
useOccSave(inpackages/plugin-form/src/occSave.tsx) now remembers, per object and record, theupdated_atthat its last successful write returned. It sends that version as the next save'sifMatchwhile the form still holds the same read. This covers the guarded save and the Overwrite retry alike. Every edit layout (simple, modal, drawer, tabbed, split, wizard) calls this one hook, so all six are covered without touching any call site.baseRecordwith a different token is a newer read of the record, and its token wins over anything remembered.updated_atstays unguarded. The change is in which version a guarded save sends, never in whether a save is guarded.ObjectForm.tsxor to any of the sixbaseRecordcall sites, so no overlap with open PR objectui#10620.Measurement first: the defect, against the real enforcement
Enforcement point:
updateDatain objectstack'spackages/metadata-protocol/src/protocol.ts(assertVersionOf, which compares instants). REST maps it to409 CONCURRENT_UPDATE. objectui's adapter forwardsifMatchas anIf-Matchheader through@objectstack/client.Rig: objectstack
origin/mainat949e99bed9in a scratch worktree. The dependency closure was built, then one app object was booted with@objectstack/verify'sbootStack: the real kernel, the ObjectQL engine, the sqlite-wasm driver, REST, Hono, auth and security. It was exposed on a loopback HTTP listener. The objectui side used the realObjectStackAdapter(@objectstack/client17.4.0) over real HTTP. No mock enforced anything. Nothing from the rig is committed.Before the fix (objectui base
e0f820246):updated_at= V0. PATCH withIf-Match: V0answered 200, and the saved record it returned carried a newerupdated_at(V1). A second PATCH with the sameIf-Match: V0answered 409CONCURRENT_UPDATEwithcurrentVersion= V1 ("Record occm_device/ID was modified by another user (current version V1, expected V0)"). Control: PATCH withIf-Match: V1answered 200.useOccSaveover the real adapter: the first save landed and its result carried V1. The second save from the samebaseRecordsent V0, was refused 409, and opened the conflict dialog against the user's own save.ObjectFormin edit mode with the real adapter: after two submits, the second sent the first read's token, got 409, and the dialog opened.⇒ The first write's response does carry the new version, so the fix takes it from there. No refetch after save is needed.
After the fix, on the same rig: the second hook save sent V1 and landed, with no dialog. The mounted
ObjectForm's second submit sent the first write's version and landed, with no dialog. When another writer changed the record between the two saves, the second save still answered 409 and opened the dialog, so the guard is still live.Pins
New
packages/plugin-form/src/occSave.secondSave.test.tsx, beside the existing OCC suites. It has 9 hook rows and 1ObjectFormrow. The data source double is shaped from the measurement:updated_atand returns the whole record;Errorthe way@objectstack/clientbuilds one, then passed through the adapter's realnormaliseClientError.The rows: single-save control · second save sends the first write's version · third save sends the second's · another writer still earns the dialog · after Overwrite the next save sends the overwrite's version · a newer read wins · per-record keying · an unguarded read stays unguarded · a result without a version falls back to the read · two submits from one mounted
ObjectForm.Ablation (the remembered-token read removed,
versionToSendreturning the read's token), at head7e1e06d0f, throughablation-replace.mjs(the anchor hit 1 to 0, the marker 0 to 1, and the blob moved fromf0d66a02to02c4b75a). Over the three OCC suites: 5 failed | 16 passed (21). Red: the second save, third save, another-writer, after-Overwrite andObjectFormrows. Green: the single-save control, the four other new rows, and all 11 existing OCC rows. Restore was proven by blob:f0d66a02equals HEAD,git diff HEADis empty, and the marker count is 0.Gates (at
7e1e06d0f)pnpm exec vitest run packages/plugin-form/(repo root): Test Files 122 passed (122) · Tests 1309 passed | 1 skipped (1310).pnpm --filter @object-ui/plugin-form run type-check(the script name echoed; the closure was built first withpnpm --workspace-concurrency=2 --filter '@object-ui/plugin-form^...' run build): exit 0.tsconfig.test.json --listFilesincludes the new test file.pnpm check:control-bytesOK ·pnpm check:new-line-citations: 0 new ·node scripts/check-changeset-presence.mjs: 1 changeset for 1 released package ·check-changeset-no-majorOK ·check-changeset-overwrite: reports the one deliberate edit below.pnpm check:changeset-claims: the self-contradiction reading is clean. It asked for a re-read of.changeset/6237-wizard-step-config-split.md, because that file namescontent/docs/plugins/plugin-form.mdx. I read that paragraph: it is about the section-predicate support table and is still true.--no-inline-config, per rule, head against base.occSave.tsx: 3@typescript-eslint/no-explicit-anywarnings at both head and base, unchanged, and 0react-hooks/exhaustive-depsat both. The new test file: 0 findings.react-hooks/exhaustive-depsis on (warn) for these files, and the config is not type-aware, so this diff cannot move a verdict on an untouched file.check-governed-queue-guard --test: NOT GOVERNED (6 paths).File surface: beyond the claim, and why
The claim declared
occSave.tsx, the pins and one changeset. This change also edits three prose files, because it made a sentence in each of them false:packages/plugin-form/README.mdandcontent/docs/plugins/plugin-form.mdxsaid an edit save carries theupdated_atthe form read. Both now say what a save sends once the open form has already saved..changeset/10156-edit-form-writes-only-changed-fields.md(pending, so it publishes verbatim in the same release) said "The update still carriesifMatch= theupdated_atthe form read". One clause names the objectui#10565 behaviour. It is a prose-only change, and its front matter is untouched.Acceptance notes
bootStacklogsPort 0 is in use, using port N insteadfrom the Hono server plugin. It is a log line only. Carrier: none.Session:
https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNCGenerated by Claude Code