Repository navigation
fix(app-shell): Studio's first draft save after a load is pinned to the read, and a create sends If-None-Match (objectui#11773) - #12351
Conversation
…he read, and a create sends If-None-Match (objectui#11773) Each Studio editor's load reads the draft at the address its saves write (`?package=`) and records what that read served: the draft's `version` (If-Match), or "no draft row" (If-None-Match: *). A create sends If-None-Match: *. The conflict dialog's Overwrite re-sends pinned to the 409's `currentVersion`, parsed by the spec's MetadataConflictErrorSchema. `@object-ui/data-objectstack`: MetadataClientSaveOptions gains `ifNoneMatch?: '*'`, never sent beside `ifMatch`; the `ifMatch` docblock names the token's real sources. Claude-Session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z Co-authored-by: Claude <noreply@anthropic.com>
…list-view panel, and move create/first-save expectations to the pins (objectui#11773) Claude-Session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z Co-authored-by: Claude <noreply@anthropic.com>
…and ifNoneMatch (objectui#11773) Claude-Session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z Co-authored-by: Claude <noreply@anthropic.com>
…o pins (objectui#11773) Claude-Session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z Co-authored-by: Claude <noreply@anthropic.com>
…draft read is sent (objectui#11773) Claude-Session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z Co-authored-by: Claude <noreply@anthropic.com>
✅ 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
|
Contract reviewServed-tier: opus — maintainer waiver 6160996883, in place of CONTRACT_REVIEW_TIER Read: card objectui#11773 (body and all 11 comments), PR objectui#12351 (body, 23 files, the net diff ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
Seat ACCEPT: objectui PR #12351 at
|
Fixes #11773
Clause-②: yes —
@object-ui/data-objectstack'sMetadataClientSaveOptionsgains the published optionifNoneMatch?: '*'. A contract review is owed before the queue.What changes
PR objectui#11826 pinned every Studio draft save AFTER an editor's first one: a save sent the
versionthe previous save's receipt carried. The first save after a load stayed unpinned, because the/metadraft read served no version on 17.7.0. So two editors who each opened an item and saved it once still overwrote each other's draft in silence, which is the card's own reproduction. objectstack-ai/objectstack#22126 gave the read its version, and objectuimainnow resolves@objectstack/*18.0.0-next.0, which carries it.forget()now reads the draft throughreadDraftForSave(client, type, name, packageId)and records what it served with the guard's newrecordRead(type, name, packageId, pin), where it installs the buffer:If-Matchwith theversionits read served;NO_DRAFT), or a servedversion: null, pinsIf-None-Match: *;versionkey (a server from before the read token), pins nothing, and the save goes unpinned as before.versionnames the row a save to the SAME address compares against. Measured below: a draft read that names no package serves a package-bound draft's body withversion: null. A first save at?package=Ppinned with thatnullwould be refused against the package's own draft. So the Studio pillars, the list-view panel and the hooks panel, which read with no package and saved with one, now read at?package=the package they save to.ResourceEditPagereads atreadActivePackageBinding(), the folded?package=itsdoSavesends. Before, it read atownerPackageId, the same URL value withallunfolded, which the server folds the same way.If-None-Match: *. That covers every create in the guard's caller files:ResourceEditPagecreate mode, Studio's object, flow, app, page, dashboard, report and permission-set creates, and "+ New" hook. The door refuses it409 METADATA_CONFLICTwhere the name already holds a draft, instead of writing the skeleton over it. It also reads the pin as a create, so a sealed name gets the create remedy (metadata-protocol: a refused save of a package-held position name tells the author to "Edit the source artifact and redeploy", which is wrong for a create, where the remedy is a name no package or built-in holds objectstack#22591, relayed on the card).ResourceEditPage.doSave's comment, "a create sends noIf-Match(the door cannot pin 'no row yet')", is gone with it.currentVersion. The version is parsed by the spec'sMetadataConflictErrorSchemafrom the body the client kept on the error, with no hand-written reader. A string pinsIf-Match, andnull(a publish drained the draft) pinsIf-None-Match: *. A save that lands while the dialog is open is refused again and asked about, not overwritten. A refusal that carries nocurrentVersion(a server from before the field) is overwritten unpinned, as before.@object-ui/data-objectstack).MetadataClientSaveOptions.ifNoneMatch?: '*'is sent asIf-None-Match: *.save()throws before sending a request that sets bothifMatchandifNoneMatch. The door also refuses that pair with a 400, measured below. TheifMatchdocblock no longer calls the token "thechecksumreturned by the last read". It names the token's three real sources: the item read'sversion, a save receipt'sversion, and a conflict'scurrentVersion. The package README gains a section, "Saving Only Over the Version You Read".The guard holds one pin per item (type, name, package) instead of a single one. Every editor but one holds a single item at a time, so this changes nothing for them. The hooks panel installs its buffer from a list. Its list load records a pin per listed hook: the draft's version, "no draft row" when this package's drafts ledger does not list the hook, or nothing when the ledger could not be read. A hook that is saved, left and re-opened stays pinned to its last receipt. A list load that a package publish overtook records nothing.
Measured first (Zone 2)
Published
@objectstack/cli18.0.0-next.0 was installed with npm into a scratch directory.rest,metadata-protocol,runtimeandspecall read 18.0.0-next.0. It booted a one-object probe app withobjectstack dev --seed-admin --fresh --no-watch -p 4773, signed in as the seeded admin, and drove/api/v1/metawith curl.com.probe.studiois a bare authoring-workspace package id. Readings, 2026-10-14 from 02:56Z:PUT /meta/object/occ_note?mode=draft&package=com.probe.studio,If-None-Match: *{success, version: "hmac-sha256:a687…", seq: 1, state: "draft"}GET …/occ_note?state=draft&package=com.probe.studioversionequal to the create'sGET …/occ_note?state=draftversion: nullIf-Match:the create's versionIf-Match:the create's version again{error, code: "METADATA_CONFLICT", currentVersion},currentVersionequal to seq 2's versionIf-None-Match: *currentVersionthe draft's; the sentence reads "Expected parent null but current is …"If-MatchandIf-None-Match: *{error: {code: "VALIDATION_ERROR", message: "Send If-Match or If-None-Match, not both. …"}}currentVersionIf-Match:that versionPOST …/publish?package=…, thenGET …?state=draft&package=…{code: "NO_DRAFT"};GET /meta/_drafts→{drafts: []}If-Match:the uncached active read'sversioncurrentVersion: nullIf-None-Match: *If-None-Match: *currentVersionthe first editor's draftPUT /meta/object/occ_ticket?mode=draftNOT_OVERRIDABLE"… Edit the source artifact and redeploy."If-None-Match: *NOT_OVERRIDABLE"… so its name is taken. To author your own object, choose a name …"The probe server was stopped by its recorded PID. The shapes match the installed spec's declarations:
GetMetaItemResponseSchema.versionis optional, nullable string;MetadataConflictErrorSchemais{error, code: 'METADATA_CONFLICT', currentVersion: string | null}.Every load and save site of the guard's callers
Sites are named by function, not by line.
ResourceEditPageload effectreadActivePackageBinding(), the addressdoSavewrites.ResourceEditPagedoPublishResourceEditPagedoDiscardDraft,doResetforget(), unpinned as before. No draft read follows, and the discard names no package, so "no draft row" is not known at the save's address.ResourceEditPagedoSave, create modeIf-None-Match: *.PermissionMatrixEditorload, package doorPermissionMatrixEditor, environment doorStudioDesignSurfaceData pillar object load?package=.StudioDesignSurfaceAutomations pillar flow load?package=.StudioDesignSurfaceInterfaces pillar leaf load?package=. A leaf with no designer installs{}andforget()s, as before.StudioDesignSurfaceInterfaces pillar app (nav) load?package=. The read-back of its own nav save keeps the receipt's pin, as before.StudioDesignSurfacecreates: object, flow, app, page / dashboard / report, permission setIf-None-Match: *.ObjectListViewInspector(useObjectListViewDraft) load?package=.ObjectHooksPanellist load?package=.ObjectHooksPanel"+ New"If-None-Match: *.ObjectHooksPanel, a package publishforget(), every hook's pin, as before.Not on this card, unchanged:
PackageOwdOverviewPanel,EmbeddedItemEditorandDatasourceResourcePage(round 1's reasons stand), andruntime-metadata-persistence(persistRuntimeMetadata's callers stay triage's, per the release note on the card).Pins
Each pin runs on a real render or the real hook. The doors are doubles answering as measured above, and refusals are parsed by the real
MetadataClient.If-Matchwith the read's version:ResourceEditPage.draftVersionConflict-11773: a pending draft at load; the read named the package;StudioDesignSurface.draftVersionConflict-11773: the Data pillar on a pending draft;PermissionMatrixEditor.firstSavePinned-11773,ObjectListViewInspector.firstSavePinned-11773,ObjectHooksPanel.firstSavePinned-11773.If-None-Match: *, and the card's race: two editors who each load and save once. The later first save is refused with the dialog, and the earlier editor's field survives. Pinned in theResourceEditPageand Data pillar suites and in the guard's unit suite.If-None-Match: *:ResourceEditPage.createFirstWritePin-11773: create mode over a real client. A create over a name that already holds a draft is refused, and the draft is not written over;currentVersion: theResourceEditPageand Data pillar suites. The guard's unit suite also pinscurrentVersion: null→If-None-Match: *, a third save during the dialog being asked about again, and a refusal without the field overwriting unpinned (the control).metadata-client.firstWritePin-11773covers the header, the pair refused before any request, andgetDraftsurfacingversion.createAppOpensInterfaces-11794,interfacesCreate-11823,interfacesCreatePage-11823,listViewInspector-11823,sharedSelect-11865andObjectHooksPanel.designerRegistryMissing.Ablation (Zone 3)
The fix was committed first (
cff84f170). Thennode ../objectstack/scripts/ablation-replace.mjsreplaced the Data pillar load'srecordObjRead('object', current.name, packageId, draftRead.pin);withrecordObjRead('object', current.name, packageId, null); /* ABLATED-11773 … */, which is whatforget()did. The tool's evidence:x1 -> x0;188311f94248 -> 5b81c98db7b2.Result:
Tests 5 failed | 2 passed (7), the red direction expected:The tool proved the restore: blob after restore == blob at
HEAD(188311f94248), andgit diff HEADis empty. No dist rebuild was needed, because the suite imports app-shell source by relative path.Local verification
At head
47e75737eunless a line names another commit. Heavy runs went through the shared verify lock, slotui1-11773-dev. The lock prints its own verdict line, and the exit code was captured before any pipe.pnpm exec turbo run build --filter=@object-ui/app-shell^... --filter=@object-ui/data-objectstack^... --concurrency=2: exit 0,Tasks: 28 successful, 28 total. The rebuiltdata-objectstackdist/index.d.tscarriesifNoneMatch.pnpm --filter @object-ui/data-objectstack type-checkechoedtsc --noEmit: exit 0.pnpm --filter @object-ui/app-shell type-checkechoedtsc --noEmit && tsc -p tsconfig.test.json: exit 0.pnpm exec vitest run --maxWorkers=2 packages/app-shell/:Test Files 1332 passed | 1 skipped (1333),Tests 12814 passed | 9 skipped (12823), exit 0. It held the lock for 50m37s.Test Files 13 passed (13),Tests 158 passed (158).pnpm exec vitest run --maxWorkers=2 packages/data-objectstack/atcff84f170:Test Files 89 passed (89),Tests 1141 passed (1141). The package has not changed since that commit..ts/.tsxfiles;--format jsoncounts 21 files linted, none ignored. Result: 0 errors and 105 warnings. Each file's warning count equals its base count read withgit show c5406c2:FILE | eslint --stdin, exceptDraftConflictDialog.tsx, 3 → 5 (see the notes below). The config is not type-aware (noparserOptions.projectorprojectService), so the diff cannot move a verdict in an untouched file.check-changeset-presence.mjs: "21 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)".check:changeset-claims: "No pending changeset names a file this change touches."check:pending-changeset-literals: "No test source names a pending changeset."check:new-line-citations: "VERDICT new-cross-file-line-citations: 0 new citation(s)".check:control-bytes,check:test-path-roots,check:vi-mock-specifiers,check:vi-mock-inherit,check:vi-mock-override-shape: OK.check:metadata-write-doors: "OK 15 metadata write door(s) derived …".check:spec-symbols,check:unreferenced-sources,check:doc-fences,check:doc-types,check:doc-json,check:doc-example-readers: OK.check:doc-snippetsandcheck:readme-exportsstopped on their precondition: packages outside this build's closure have nodist/. In their place, the new README block was type-checked withtsc --ignoreConfig --strictagainst the rebuilt.d.ts: exit 0. A control that setsifNoneMatch: 'nope'exits 2 (TS2345).check:spec-floorscollapses onmainas well. Its range reader refuses the prerelease range^18.0.0-next.0, which every manifest has carried since objectui#12030, so it counts 0 floored packages and exits 1. The control^17.7.0reads. This branch touches no manifest. Reported, not fixed here.check:i18n-*family: not applicable, because no string was added.Acceptance notes
createRuntimeMetadata(inruntime-metadata-persistence, besidepersistRuntimeMetadata) is a create outside this claim's surface. It still sends no precondition.doDiscardDraftanddoResetinResourceEditPageleave the next save unpinned (see the table).If-None-Match: *as a create for the sealed-item remedy. The spec declares the same pin as "no row of this lifecycle", including a first draft after a publish. The readings' two sealed-object rows show the remedy change. The Studio editors gate sealed items read-only (editable: false), so none of them sends it. Reported, not filed here.react-refresh/only-export-componentswarns twice more onDraftConflictDialog.tsx, for the two new exportsdraftPinOfandreadDraftForSave. That is the same class round 1 accepted: the claim's surface has no second module for them.Session:
https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z(the dispatching seat's id; this implementation ran as its subagent).Generated by Claude Code