Skip to content

Commit 2dec305

Browse files
fix(app-shell): Studio draft saves send the version they were built on, and a stale save opens a reload / overwrite dialog (objectui#11773) (#11826)
Part of #11773 — the client half. The card stays open for the server half, named in the section on it below. Clause-②: no ## What changes Every Studio draft save of an existing metadata item now sends `If-Match` with the `version` its editor's previous save received, and holds the new receipt's `version`. When the `/meta` draft door refuses a stale version with `409 METADATA_CONFLICT`, the editor opens a conflict dialog with three choices: - **Reload saved version.** The editor re-runs its own load. Its unsaved edits on screen are dropped. - **Overwrite…** A second, explicit confirmation follows. The same body is then sent again without `If-Match`, and it wins. - **Keep editing.** Nothing is saved, and the refusal is shown in the editor's error strip. The stale version is kept, so the next save is refused again and cannot slip through. The door's other 409, `DESTRUCTIVE_CHANGE`, is judged before the version and passes through the guard untouched to each editor's existing confirmation flow. One guard per editing buffer, `useDraftSaveGuard` in the new `views/metadata-admin/DraftConflictDialog.tsx`, with the dialog beside it. A guard belongs to one buffer, not to an item: two surfaces in one tab that each hold their own copy of an item are two editors, and sharing one version between them would let the second one's stale copy through. The rules, in that file's header: 1. A draft save sends the version this guard holds for the same item (type, name, package), and nothing otherwise. 2. A save that lands holds the receipt's `version`. 3. A buffer installed from a server read (a load, an item switch, a reload after a publish or a discard) holds no version, because the read serves none (measured below). The caller says so with `forget()`. The read-back of the guard's OWN save does not forget. 4. A `METADATA_CONFLICT` opens the dialog (reload / overwrite / keep editing, as above). 5. Saves through one guard run one at a time, so each one sends the version the previous one received. An autosave never conflicts with the explicit save (column reorder, enable switch) the same buffer sent a moment earlier. This is the self-conflict risk named in Zone 2 item 2. Creates send no `If-Match`. The door cannot express "expect no row" over HTTP (below), so a create keeps calling the client directly. ## Measured first (Zone 2 item 1) Published `@objectstack/cli` 17.7.0 was installed into a scratch directory. `rest`, `metadata-protocol`, `runtime` and `spec` all read 17.7.0, the release this repo's lockfile resolves. It booted a one-object probe app with `objectstack dev --seed-admin --fresh --no-watch -p 4773`, signed in as the seeded admin, and created a writable authoring package `com.probe.studio`. Then it drove the `/api/v1/meta` door with curl. Readings, 2026-10-07T16:38Z to 16:41Z: | Step | Request | Answer | |---|---|---| | create | `PUT /meta/object/pst_ticket?mode=draft&package=com.probe.studio`, no `If-Match` | 200 `{"success":true,"version":"hmac-sha256:7102ff…f950","seq":1,"state":"draft","message":"Saved object 'pst_ticket' (env-wide, state=draft) [seq=1]"}`. No `ETag` header. | | draft read | `GET /meta/object/pst_ticket?state=draft&package=…` | 200 `{type, name, sortability, item}`. No version key in the envelope or the item, and no `ETag` header. | | active read (cached path) | `GET /meta/object/occprobe_ticket` | `ETag: "2d68dba9"`. That is the cache validator, not a version token (8 hex digits). | | editor A, fresh token | PUT draft, `If-Match:` the create's version | 200, new version (`seq` 2) | | editor B, stale token | PUT draft, `If-Match:` the create's version again | **409** `{"error":"object/pst_ticket has been modified since you loaded it. The version token sent is not the current version (current is hmac-sha256:b375…2f14).","code":"METADATA_CONFLICT"}`. The draft still held A's `pluralLabel`. | | quoted token | `If-Match: "TOKEN"` | 200. The quotes are stripped. | | garbage or empty token | `If-Match: nope`, or an empty `If-Match` | 409 `METADATA_CONFLICT` | | after a publish | `POST …/publish`, then a PUT draft with the last draft version or with the publish receipt's version | 409 `METADATA_CONFLICT`, "current is null". The publish dropped the draft row, so any token is refused. | | first draft after a publish | PUT draft, no `If-Match` | 200 | | destructive, with any token | PUT draft dropping a field that holds data | 409 `{"error":"… would drop or transform existing data …","code":"DESTRUCTIVE_CHANGE","issues":[…]}`, both with a stale token and with a fresh one | | destructive plus force, stale token | `?force=true`, stale `If-Match` | 409 `METADATA_CONFLICT`. Destructive is judged first and the version second. | | data door | `GET /api/v1/data/sys_metadata` | The draft row's `checksum` column is served keyed, and equals the last receipt's `version`. Not used: it would mean re-deriving the door's served-row resolution in the client. | So: - **The server does enforce `If-Match` on `mode=draft` writes.** Zone 2's stop condition did not fire. - **The token is the save receipt's `version`, a keyed digest, and only the receipt serves it.** The draft read serves none. The client docblock on `MetadataClientSaveOptions.ifMatch` ("the `checksum` returned by the last read") names a token no `/meta` read serves. That is reported as a finding, and `packages/data-objectstack` is untouched here. - **The two 409s are told apart by `code`.** `isDraftVersionConflict` reads `status` plus `code` off the client's parsed error and never reads the prose. - **Overwrite does not re-send "the server's current token".** The 409 body carries it only inside the error sentence, and the door serializes no structured field for it. The guard does not parse prose, so overwrite re-sends without `If-Match` after the confirmation. That is still a last-writer-wins write: a third writer landing between the refusal and the confirmed overwrite is overwritten. The author chose that write knowing the draft had moved. A structured current version on the 409 is part of the server finding below. ## Every save call site (Zone 2 item 6) Read on `9990f9e` by `git grep "\.save("` over `packages/app-shell/src`, tests excluded. Sites are named by function, not by line. | File and function | Decision | |---|---| | `StudioDesignSurface` Data pillar `doSave` (object autosave) | **OCC-guarded.** Its load forgets. Reload re-runs the load for the open object. | | `StudioDesignSurface` Data pillar `doReorderFields` (grid column drag) | **OCC-guarded**, on the same guard as the autosave, so the two are serialized | | `StudioDesignSurface` Data pillar `doCreateObject` | **Create.** No `If-Match`. | | `StudioDesignSurface` Automations pillar `doSave` (flow autosave) | **OCC-guarded.** Its load forgets. | | `StudioDesignSurface` Automations pillar `toggleEnabled` | **OCC-guarded**, same guard as the flow autosave. On a refusal the existing rollback of the optimistic flip still runs. | | `StudioDesignSurface` Automations pillar `doCreateFlow` | **Create.** | | `StudioDesignSurface` Interfaces pillar `doSave` (page, dashboard and other leaves) | **OCC-guarded.** The leaf load forgets, including the empty-buffer branch. | | `StudioDesignSurface` Interfaces pillar `doNavSave` (app navigation) | **OCC-guarded.** The app load re-reads after every draft save in the package. The re-read that follows this pillar's own nav save keeps the version. Any other install forgets it, and so does an install after a publish. | | `StudioDesignSurface` shell `doCreateApp` | **Create.** | | `StudioDesignSurface` Access pillar permission create (`buildPermissionSkeleton`) | **Create.** | | `ResourceEditPage` `doSave` | **OCC-guarded** in edit mode, and a **create** in create mode. Its load effect forgets, and so do `doPublish`, `doDiscardDraft` and `doReset`. Its post-save read-back is the echo of its own save and keeps the version. The destructive-change dialog is unchanged and is a separate dialog. | | `PermissionMatrixEditor` `doSave` | **OCC-guarded** at the package door (`mode: 'draft'`). The environment door's live write passes through unpinned, as before. That is a non-draft write, outside this card. | | `ObjectHooksPanel` `save` | **OCC-guarded.** A package publish forgets (the panel now receives `publishNonce`). Its list re-read after its own save keeps the version. | | `ObjectHooksPanel` `addHook` | **Create.** | | `PackageOwdOverviewPanel` `doSave` | **Not guarded.** It reads each object fresh, inside the same click, immediately before patching only the two OWD keys. It holds no long-lived buffer of the document, and the read serves no version to pin. The lost-update window is that one read-to-write round trip. A long-lived editor of the same object (the Data pillar) is protected by its own version: its next save is refused after this panel moved the draft. | | `EmbeddedItemEditor` `doSave` | **Not a draft save.** It passes no `mode`, so it is a live write of the parent after a fresh `layered` read in the same click. | | `DatasourceResourcePage` (external object import) | **Not a draft save.** It passes no `mode`: a live create of the imported object. | | `runtime-metadata-persistence` `createRuntimeMetadata` | **Create.** | | `runtime-metadata-persistence` `persistRuntimeMetadata` | **Not guarded here.** Its callers hold the buffer: the console's runtime view editor (`ObjectView`'s view-config Save) and `ReportView`'s Save. Both are explicit-Save editors outside Studio and outside this card's file surface. Pinning them means giving those callers a guard. That is named as the remaining client follow-up on this card, not done here. | Outside the claimed surface and not draft saves, recorded for completeness: `preview/UnpublishedAppBar` (a live PUT of the app, the ADR-0045 visibility flip) and `metadata-admin/external/api` `importObjectDraft` (a live PUT create). ## What this does not fix: the server half The card's own reproduction is not fixed by this PR. Tabs A and B both open the item, then each saves once. B's first save has no version to send, because the draft read serves none on 17.7.0, so it is still last-writer-wins. What changes is that the loss is no longer permanent and silent. A's next save is refused with the dialog (A holds a version B's write moved), so A sees it and chooses. After each editor's first save, every later save is protected. Closing the first-save window needs the server to: - serve the version on the `/meta` item read (a body field declared in `GetMetaItemResponseSchema`, or an `ETag` equal to the token) for `state=draft` and stored-row reads; - let a client pin "no draft yet" over HTTP (for example `If-None-Match: *`), for the first draft after a publish and for creates; - name the current version structurally on the `METADATA_CONFLICT` body, not only in the sentence. That is reported to the seat as a cross-repo finding. With the server half, the guard also records the version from each read. That is a one-line change at each load that today calls `forget()`. ## Tests Server double modelled on the measured door. The unit suite runs the **real** `MetadataClient` over a fetch double. The Studio and designer suites throw refusals parsed by the real client's error parser. - `views/metadata-admin/DraftConflictDialog.test.tsx` (12 tests). Token advance; serialized back-to-back saves; `forget()`; one version per item and package; non-draft passthrough; reload (plus a queued save dropped on reload); overwrite; keep editing (refused again); after-publish control; `DESTRUCTIVE_CHANGE` passthrough with the forced retry keeping the version; the code-not-prose predicate. - `views/studio-design/StudioDesignSurface.draftVersionConflict-11773.test.tsx` (5 tests, two mounted Data pillars over one server). One editor's consecutive autosaves all succeed. Two editors: the stale save gets the dialog and the other editor's change survives, then reload, then overwrite. A create sends no `If-Match`. - `views/metadata-admin/ResourceEditPage.draftVersionConflict-11773.test.tsx` (3 tests). Token advance. The conflict dialog, not the destructive one. Control: a destructive change still opens its own confirmation, and its forced retry keeps the version. Runs (all through the shared verify lock; seconds are shared-box readings): - `pnpm --filter @object-ui/app-shell type-check` at `16c92cf`: echoed `tsc --noEmit && tsc -p tsconfig.test.json`, `TYPECHECK_EXIT=0`. - `pnpm exec vitest run packages/app-shell/ --maxWorkers=3` at `16c92cf`: `Test Files 1061 passed | 1 skipped (1062)`, `Tests 10383 passed | 9 skipped (10392)`, `VITEST_EXIT=0`. - The three new files: `Test Files 3 passed (3)`, `Tests 20 passed (20)`. - `pnpm exec eslint` over the 9 touched `.ts`/`.tsx` files: 0 errors. Per-file warnings equal the base or lower: `StudioDesignSurface.tsx` drops from 17 to 14, because three `useCallback`s gained their missing `packageId`. The new `DraftConflictDialog.tsx` carries 3 `react-refresh/only-export-components` warnings, from exporting the guard and its hook beside the component (the provider-plus-hook shape several app-shell files already use). This narrowing is a measurement, not a skipped run. The population is the 9 files, read from `--format json`. The config is not type-aware (no `parserOptions.project`) and no repo rule reads other files, so this diff cannot move a verdict on an untouched file. The repo-wide lint is CI's. - Gates, each run on the tree at `16c92cf`, each exit 0, with the gate's own line quoted: `check:control-bytes` "OK (scanned 7783 tracked text file(s)…)". `check:test-path-roots` "OK". `check:changeset-claims` "No pending changeset names a file this change touches." `check:pending-changeset-literals` "No test source names a pending changeset." `check:i18n-keys` "Every in-scope call-site key resolves…". `check:i18n-drift` "No designer-table en value changed in this range." (10 keys added). `check:i18n-designer-parity` "Every en row has a zh row, and every shared row carries the same placeholders." `check:new-line-citations` "VERDICT new-cross-file-line-citations: 0 new citation(s)". `check:vi-mock-specifiers`, `check:vi-mock-inherit` and `check:vi-mock-override-shape` "OK". `check:metadata-write-doors` "OK 17 metadata write door(s) derived…". `check:unreferenced-sources` "OK Every shipped source file in every covered package is reachable." `check-changeset-presence.mjs` "9 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)". `check:i18n-dead-keys` (a report) lists none of the new keys. ### Ablation: stop sending `If-Match` The fix was committed first (`16c92cf`). Then `node ../objectstack/scripts/ablation-replace.mjs` replaced `pinned ? { ...options, ifMatch: pinned } : options` in `DraftVersionGuard.run` with `{ ...options } /* ABLATED-11773: ifMatch never sent */`. The tool's own evidence: anchor `x1 -> x0`, replacement `x0 -> x1`, blob `866eec3fa710 -> 5e0c9f9dfa9e`. Inside the locked run, `MARKER_COUNT=1 ANCHOR_COUNT=0`. Result: **`Tests 16 failed | 4 passed (20)`**. The two-editor pin times out waiting for the dialog. The 4 that stayed green never depend on a pin: one version per item, `forget()`, non-draft passthrough, and the code-not-prose predicate. The restore was proven by the tool: blob after restore == blob at `HEAD` (`866eec3fa710`), and `git diff HEAD` empty. The direction was red, as expected. A first attempt was refused by the tool before it ran anything, because its replacement (`options`) was a substring of the anchor and the count could not move. That attempt was a no-op, restored and proven, and is not a reading. ## Acceptance notes - The dialog offers reload and overwrite, which is the triage direction. The card's "review" (a diff of theirs against mine) is not built. - A dev server restart re-keys versions when no crypto provider is registered (the server's ephemeral key). The first save after a restart is then refused once with the dialog, by the server's design. - A draft dropped by a path that does not reload this editor (a discard from the Packages page, a publish from another tab) leaves the editor holding a version of a row that no longer exists. Its next save is refused with the dialog, and reload resolves it. The refusal was measured after a publish; after a discard it follows from the same rule and was not measured separately. - The pillars read the draft with `getDraft(type, name)` and no `packageId`, while they save with the package. That is unchanged here and is not measured. Implemented by the dispatched os-dev subagent of the `domain:ui#3` seat, session `https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8`. --- _Generated by [Claude Code](https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2b30d39 commit 2dec305

10 files changed

Lines changed: 1430 additions & 25 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
Studio's metadata draft saves send the version they were built on (objectui#11773). Once an editor has saved, its later saves no longer replace a draft that was saved elsewhere in the meantime without saying so.
6+
7+
Each guarded draft save of an existing item now sends `If-Match` with the `version` the editor's previous save received, and records the new receipt's version. The `/meta` draft door refuses a stale version with `409 METADATA_CONFLICT`. The editor then shows a dialog with three choices: reload the saved version (unsaved edits on screen are dropped), overwrite it after a second confirmation (the save is sent again without `If-Match`), or keep editing (nothing is saved, and the next save is refused again). The destructive-change confirmation (`409 DESTRUCTIVE_CHANGE`) is a separate flow and is unchanged.
8+
9+
The guarded saves are the Data pillar's object autosave and column reorder, the Automations pillar's flow autosave and enable switch, the Interfaces pillar's autosave of the open item (a page, dashboard or other item it edits) and its navigation autosave, the metadata designer's draft save, the Access pillar's package-scoped permission-set save, and the object Hooks panel's save. Creating an item sends no `If-Match`.
10+
11+
The protection starts at an editor's second save. A draft read serves no version, so the first save after an editor loads, reloads or switches items is still sent without `If-Match`.
12+
13+
Nothing on the package entry changes. The guard (`useDraftSaveGuard`) and its dialog are not exported from `@object-ui/app-shell`, and the new strings are rows in the designer's module-local string table, not language-pack keys.
Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectui#11773 — the draft-save version guard, against a door double that
5+
* answers the way the `/meta` PUT door of `@objectstack/*` 17.7.0 was MEASURED
6+
* to answer (the readings are on the pull request):
7+
*
8+
* - a `?mode=draft` save returns `{ success, version, seq, state, message }`;
9+
* `version` is the token, keyed (`hmac-sha256:…`);
10+
* - an `If-Match` that is not the current draft's token — including any token
11+
* while no draft row exists — is `409 { error, code: 'METADATA_CONFLICT' }`,
12+
* with the current token only inside the prose;
13+
* - a destructive change is `409 { error, code: 'DESTRUCTIVE_CHANGE', issues }`
14+
* unless `?force=true`, judged BEFORE the version.
15+
*
16+
* The client is the REAL `MetadataClient`, so the header on the wire and the
17+
* parsed refusal are production's, not a hand-built error object.
18+
*/
19+
20+
import { describe, it, expect, vi } from 'vitest';
21+
import { MetadataClient } from '@object-ui/data-objectstack';
22+
import {
23+
DraftVersionGuard,
24+
isDraftVersionConflict,
25+
type DraftConflictChoice,
26+
} from './DraftConflictDialog';
27+
28+
interface SentPut {
29+
type: string;
30+
name: string;
31+
draft: boolean;
32+
force: boolean;
33+
ifMatch: string | null;
34+
body: Record<string, unknown>;
35+
}
36+
37+
function json(status: number, body: unknown): Response {
38+
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
39+
}
40+
41+
/** The measured draft-write door, over `fetch`. */
42+
function measuredDoor() {
43+
const rows = new Map<string, { body: Record<string, unknown>; version: string }>();
44+
const puts: SentPut[] = [];
45+
let seq = 0;
46+
const rowKey = (type: string, name: string, pkg: string | null, draft: boolean) =>
47+
`${draft ? 'draft' : 'active'}:${type}/${name}@${pkg ?? ''}`;
48+
const write = (key: string, body: Record<string, unknown>) => {
49+
seq += 1;
50+
const version = `hmac-sha256:${seq.toString(16).padStart(64, '0')}`;
51+
rows.set(key, { body, version });
52+
return version;
53+
};
54+
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
55+
const url = new URL(String(input));
56+
const [, , , , type = '', name = ''] = url.pathname.split('/').map(decodeURIComponent);
57+
if (init?.method !== 'PUT') return json(404, { error: 'not modelled' });
58+
const draft = url.searchParams.get('mode') === 'draft';
59+
const force = url.searchParams.get('force') === 'true';
60+
const ifMatch = new Headers(init.headers).get('If-Match');
61+
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
62+
puts.push({ type, name, draft, force, ifMatch, body });
63+
if (body.dropsAField && !force) {
64+
return json(409, {
65+
error: `${type}/${name} would drop or transform existing data: Field 'subject' removed. — re-submit with ?force=true to proceed.`,
66+
code: 'DESTRUCTIVE_CHANGE',
67+
issues: [{ code: 'field_removed', field: 'subject', message: "Field 'subject' removed." }],
68+
});
69+
}
70+
const key = rowKey(type, name, url.searchParams.get('package'), draft);
71+
const head = rows.get(key)?.version ?? null;
72+
if (ifMatch !== null && (head === null || ifMatch.replace(/^"|"$/g, '') !== head)) {
73+
return json(409, {
74+
error: `${type}/${name} has been modified since you loaded it. The version token sent is not the current version (current is ${head}).`,
75+
code: 'METADATA_CONFLICT',
76+
});
77+
}
78+
const version = write(key, body);
79+
return json(200, {
80+
success: true,
81+
version,
82+
seq,
83+
state: draft ? 'draft' : 'active',
84+
message: `Saved ${type} '${name}' [seq=${seq}]`,
85+
});
86+
}) as typeof fetch;
87+
return {
88+
client: new MetadataClient({ baseUrl: 'http://localhost:3000', fetch: fetchImpl }),
89+
puts,
90+
/** Another editor's save of the same draft row, outside this guard. */
91+
savedElsewhere(type: string, name: string, pkg: string, body: Record<string, unknown>) {
92+
write(rowKey(type, name, pkg, true), body);
93+
},
94+
draftBody(type: string, name: string, pkg: string) {
95+
return rows.get(rowKey(type, name, pkg, true))?.body;
96+
},
97+
/** A publish promotes the draft row and drops it (`current is null` after). */
98+
publishDraft(type: string, name: string, pkg: string) {
99+
const key = rowKey(type, name, pkg, true);
100+
const row = rows.get(key);
101+
if (row) write(rowKey(type, name, pkg, false), row.body);
102+
rows.delete(key);
103+
},
104+
};
105+
}
106+
107+
function guardOver(door: ReturnType<typeof measuredDoor>, answer: DraftConflictChoice = 'cancel') {
108+
const ask = vi.fn(async () => answer);
109+
const reload = vi.fn();
110+
const guard = new DraftVersionGuard({
111+
client: () => door.client,
112+
ask,
113+
reload,
114+
notSaved: (c) => new Error(`not saved: ${c.type}/${c.name}`),
115+
});
116+
return { guard, ask, reload };
117+
}
118+
119+
const DRAFT = { mode: 'draft' as const, packageId: 'com.acme.app' };
120+
121+
describe('DraftVersionGuard — each draft save sends the version the last one received (objectui#11773)', () => {
122+
it('a first save sends no If-Match; every later save sends the previous receipt\'s version', async () => {
123+
const door = measuredDoor();
124+
const { guard, ask } = guardOver(door);
125+
expect(await guard.save('object', 'acme_task', { label: 'A' }, DRAFT)).toBe('saved');
126+
expect(await guard.save('object', 'acme_task', { label: 'B' }, DRAFT)).toBe('saved');
127+
expect(await guard.save('object', 'acme_task', { label: 'C' }, DRAFT)).toBe('saved');
128+
expect(door.puts.map((p) => p.ifMatch)).toEqual([
129+
null,
130+
`hmac-sha256:${'1'.padStart(64, '0')}`,
131+
`hmac-sha256:${'2'.padStart(64, '0')}`,
132+
]);
133+
expect(ask).not.toHaveBeenCalled();
134+
});
135+
136+
it('saves sent back to back go one at a time, so the second never conflicts with the first', async () => {
137+
const door = measuredDoor();
138+
const { guard, ask } = guardOver(door);
139+
await guard.save('object', 'acme_task', { label: 'A' }, DRAFT);
140+
// An autosave and an explicit save (a column reorder) fired together.
141+
const [a, b] = await Promise.all([
142+
guard.save('object', 'acme_task', { label: 'B' }, DRAFT),
143+
guard.save('object', 'acme_task', { label: 'C' }, DRAFT),
144+
]);
145+
expect([a, b]).toEqual(['saved', 'saved']);
146+
expect(ask).not.toHaveBeenCalled();
147+
expect(door.puts[2]!.ifMatch).toBe(`hmac-sha256:${'2'.padStart(64, '0')}`);
148+
expect(door.draftBody('object', 'acme_task', 'com.acme.app')).toEqual({ label: 'C' });
149+
});
150+
151+
it('forget(): a buffer installed from a read holds no version, so its next save is unpinned', async () => {
152+
const door = measuredDoor();
153+
const { guard } = guardOver(door);
154+
await guard.save('object', 'acme_task', { label: 'A' }, DRAFT);
155+
guard.forget();
156+
await guard.save('object', 'acme_task', { label: 'B' }, DRAFT);
157+
expect(door.puts.map((p) => p.ifMatch)).toEqual([null, null]);
158+
});
159+
160+
it('a version belongs to one item: a save of another item (or package) sends none', async () => {
161+
const door = measuredDoor();
162+
const { guard } = guardOver(door);
163+
await guard.save('object', 'acme_task', { label: 'A' }, DRAFT);
164+
await guard.save('object', 'acme_note', { label: 'N' }, DRAFT);
165+
await guard.save('object', 'acme_task', { label: 'B' }, { mode: 'draft', packageId: 'com.acme.other' });
166+
expect(door.puts.map((p) => p.ifMatch)).toEqual([null, null, null]);
167+
});
168+
169+
it('a non-draft save passes straight through, unpinned and unrecorded', async () => {
170+
const door = measuredDoor();
171+
const { guard } = guardOver(door);
172+
await guard.save('permission', 'sales', { a: 1 }, {});
173+
await guard.save('permission', 'sales', { a: 2 }, {});
174+
expect(door.puts.map((p) => [p.draft, p.ifMatch])).toEqual([
175+
[false, null],
176+
[false, null],
177+
]);
178+
});
179+
});
180+
181+
describe('DraftVersionGuard — a draft saved elsewhere is not overwritten in silence (objectui#11773)', () => {
182+
it('the stale save is refused, nothing else is sent, and "reload" hands the buffer back to the caller', async () => {
183+
const door = measuredDoor();
184+
const { guard, ask, reload } = guardOver(door, 'reload');
185+
await guard.save('object', 'acme_task', { label: 'mine' }, DRAFT);
186+
door.savedElsewhere('object', 'acme_task', 'com.acme.app', { label: 'mine', description: 'theirs' });
187+
188+
expect(await guard.save('object', 'acme_task', { label: 'mine, again' }, DRAFT)).toBe('reloaded');
189+
expect(ask).toHaveBeenCalledWith({ type: 'object', name: 'acme_task' });
190+
expect(reload).toHaveBeenCalledTimes(1);
191+
// Their field survives: the refused body was never written.
192+
expect(door.draftBody('object', 'acme_task', 'com.acme.app')).toEqual({ label: 'mine', description: 'theirs' });
193+
expect(door.puts).toHaveLength(2);
194+
195+
// The reloaded buffer was read, not saved: its first save is unpinned.
196+
await guard.save('object', 'acme_task', { label: 'mine', description: 'theirs', icon: 'x' }, DRAFT);
197+
expect(door.puts[2]!.ifMatch).toBeNull();
198+
});
199+
200+
it('a save queued behind the refused one carries the replaced buffer and is dropped on "reload"', async () => {
201+
const door = measuredDoor();
202+
const { guard } = guardOver(door, 'reload');
203+
await guard.save('object', 'acme_task', { label: 'mine' }, DRAFT);
204+
door.savedElsewhere('object', 'acme_task', 'com.acme.app', { label: 'theirs' });
205+
const [first, queued] = await Promise.all([
206+
guard.save('object', 'acme_task', { label: 'stale 1' }, DRAFT),
207+
guard.save('object', 'acme_task', { label: 'stale 2' }, DRAFT),
208+
]);
209+
expect([first, queued]).toEqual(['reloaded', 'reloaded']);
210+
expect(door.puts).toHaveLength(2);
211+
expect(door.draftBody('object', 'acme_task', 'com.acme.app')).toEqual({ label: 'theirs' });
212+
});
213+
214+
it('"overwrite" re-sends the same body without If-Match, wins, and pins the next save to its receipt', async () => {
215+
const door = measuredDoor();
216+
const { guard } = guardOver(door, 'overwrite');
217+
await guard.save('object', 'acme_task', { label: 'mine' }, DRAFT);
218+
door.savedElsewhere('object', 'acme_task', 'com.acme.app', { label: 'theirs' });
219+
220+
expect(await guard.save('object', 'acme_task', { label: 'mine, kept' }, DRAFT)).toBe('saved');
221+
expect(door.puts.slice(1).map((p) => [p.ifMatch === null, p.body])).toEqual([
222+
[false, { label: 'mine, kept' }],
223+
[true, { label: 'mine, kept' }],
224+
]);
225+
expect(door.draftBody('object', 'acme_task', 'com.acme.app')).toEqual({ label: 'mine, kept' });
226+
227+
await guard.save('object', 'acme_task', { label: 'next' }, DRAFT);
228+
// seq 1 mine, seq 2 theirs, seq 3 the overwrite.
229+
expect(door.puts[3]!.ifMatch).toBe(`hmac-sha256:${'3'.padStart(64, '0')}`);
230+
});
231+
232+
it('"keep editing" sends nothing, rejects, and keeps the stale version so the next save is refused again', async () => {
233+
const door = measuredDoor();
234+
const { guard, ask } = guardOver(door, 'cancel');
235+
await guard.save('object', 'acme_task', { label: 'mine' }, DRAFT);
236+
door.savedElsewhere('object', 'acme_task', 'com.acme.app', { label: 'theirs' });
237+
238+
await expect(guard.save('object', 'acme_task', { label: 'stale' }, DRAFT)).rejects.toThrow(
239+
'not saved: object/acme_task',
240+
);
241+
await expect(guard.save('object', 'acme_task', { label: 'still stale' }, DRAFT)).rejects.toThrow(
242+
'not saved: object/acme_task',
243+
);
244+
expect(ask).toHaveBeenCalledTimes(2);
245+
expect(door.puts.slice(1).every((p) => p.ifMatch === `hmac-sha256:${'1'.padStart(64, '0')}`)).toBe(true);
246+
expect(door.draftBody('object', 'acme_task', 'com.acme.app')).toEqual({ label: 'theirs' });
247+
});
248+
249+
it('control: once a publish dropped the draft, ANY version is refused — which is why an install must forget()', async () => {
250+
const door = measuredDoor();
251+
const { guard, ask } = guardOver(door, 'cancel');
252+
await guard.save('object', 'acme_task', { label: 'mine' }, DRAFT);
253+
door.publishDraft('object', 'acme_task', 'com.acme.app');
254+
255+
await expect(guard.save('object', 'acme_task', { label: 'after publish' }, DRAFT)).rejects.toThrow('not saved');
256+
expect(ask).toHaveBeenCalledTimes(1);
257+
// What every pillar does when the publish reload installs the buffer.
258+
guard.forget();
259+
expect(await guard.save('object', 'acme_task', { label: 'after publish' }, DRAFT)).toBe('saved');
260+
expect(door.puts.map((p) => p.ifMatch === null)).toEqual([true, false, true]);
261+
});
262+
});
263+
264+
describe('DraftVersionGuard — the door\'s two 409s stay apart (objectui#11773)', () => {
265+
it('DESTRUCTIVE_CHANGE passes through untouched, and the forced retry carries the same If-Match', async () => {
266+
const door = measuredDoor();
267+
const { guard, ask } = guardOver(door, 'overwrite');
268+
await guard.save('object', 'acme_task', { label: 'mine' }, DRAFT);
269+
270+
const refusal = await guard
271+
.save('object', 'acme_task', { label: 'mine', dropsAField: true }, DRAFT)
272+
.catch((e: unknown) => e);
273+
expect(refusal).toMatchObject({ status: 409, code: 'DESTRUCTIVE_CHANGE' });
274+
expect(isDraftVersionConflict(refusal)).toBe(false);
275+
expect(ask).not.toHaveBeenCalled();
276+
277+
// The caller's own confirmation flow re-sends with `force`.
278+
expect(await guard.save('object', 'acme_task', { label: 'mine', dropsAField: true }, { ...DRAFT, force: true })).toBe(
279+
'saved',
280+
);
281+
const pinned = `hmac-sha256:${'1'.padStart(64, '0')}`;
282+
expect(door.puts.slice(1).map((p) => [p.force, p.ifMatch])).toEqual([
283+
[false, pinned],
284+
[true, pinned],
285+
]);
286+
});
287+
288+
it('isDraftVersionConflict reads the code on the parsed refusal, never the prose', async () => {
289+
const door = measuredDoor();
290+
const { guard } = guardOver(door, 'cancel');
291+
await guard.save('object', 'acme_task', { label: 'mine' }, DRAFT);
292+
door.savedElsewhere('object', 'acme_task', 'com.acme.app', { label: 'theirs' });
293+
const raw = await door.client
294+
.save('object', 'acme_task', { label: 'x' }, { ...DRAFT, ifMatch: 'hmac-sha256:stale' })
295+
.catch((e: unknown) => e);
296+
expect(raw).toMatchObject({ status: 409, code: 'METADATA_CONFLICT' });
297+
expect(isDraftVersionConflict(raw)).toBe(true);
298+
expect(isDraftVersionConflict(Object.assign(new Error('has been modified since you loaded it'), { status: 409 }))).toBe(
299+
false,
300+
);
301+
});
302+
});

0 commit comments

Comments
 (0)