Skip to content

Commit 069fd40

Browse files
fix(metadata-protocol): the strict draft read naming no package serves its content from the draft its version names (#23727)
Fixes #23715 Clause-②: yes Dev session `session_01HTBbFnY86QT5g7BxeMo3Ub`, dispatched by `domain:engine` seat 1 (`session_01GP3comALHRcRBixS2TEnLF`), claim 6201610005. Related, not closed here: #23657 / PR #23706 (the draft save's address, `unboundItemDraftAddress`) and #23495 / PR #23654 (the three draft doors' address). ## What this does The strict draft read that names no package (`GET /api/v1/meta/TYPE/NAME?state=draft`) now takes its **content** from the draft its **`version`** names: the draft `packagelessDraftAddress` gives the draft save, the per-item publish and the draft discard naming none. Before, the content address came from the item's ACTIVE rows (`packagelessReadAddress`), which name no package for an item never published into one, so `findServedOverlayRow` took the first draft row the store returned. | state (no `?package=`) | content before | content after | `version` (unchanged) | |---|---|---|---| | no active row, drafts `[package-unbound, Q]`, Q's stored first (in-memory double) | **Q's draft** | package-unbound draft | package-unbound draft | | no active row, drafts `[package-unbound, Q]`, unbound stored first | package-unbound | package-unbound | package-unbound | | active rows bound to two packages, drafts `[package-unbound, P]`, P's stored first | **P's draft** | package-unbound draft | package-unbound draft | | active rows bound to one package P | P's draft, else the package-unbound one | unchanged | unchanged | | no active row, only Q's draft / only a package-unbound draft | that draft | unchanged | unchanged | | no active row, drafts bound to Q and R, none package-unbound | best-effort first row, `version: null` | unchanged (best-effort fallback kept) | `null` | - `strictDraftReadAddress` (new private helper in `protocol.ts`) maps `packagelessDraftAddress` to a content address: the draft's binding, `null` for the package-unbound draft alone; an `ambiguous` address keeps the package-unbound draft, else the best-effort first match ADR-0048 §3.3 states. - `servedOverlayRowCandidates` / `findServedOverlayRow` accept `packageId: null` = the package-unbound row only. Every existing caller passes a string or `undefined`, so their candidate order is unchanged. - **H3, measured:** the draft-overlay preview naming no package (`previewDrafts`) shared the defective address: with Q's draft stored first it served `q` while the fixed strict read served `unbound`. It now takes the same draft address (it carries no `version`). The layered read (`getMetaItemLayered`) reads ACTIVE rows only, so it has no draft door to fix. - **Rider 6200550012:** `PublishMetaItemRequestSchema.packageId`'s description now carries the "no package-unbound draft" qualifier `SaveMetaItemRequestSchema.packageId`'s has (PR #23706). Description only; `content/docs/references/api/protocol.mdx` regenerated by `check:generated --fix`. - **No accept set narrows:** no request answered `200` before is refused now; `?package=` still reaches each package's draft. The write, publish and discard addresses are not touched. ## Verification (head `eaa4b0ed`) - **Negative control on `main` (`9e0945cf4`), in-memory double:** the new `[#23715]` pins, run before the fix: `Tests 2 failed | 4 passed` — `expected 'q' to be 'unbound'` (Q's draft first) and `expected 'p' to be 'unbound'` (two active packages, P's draft first). - **better-sqlite3 (REST harness):** all three orders (unbound saved first, Q's saved first, both seeded Q's first) were already green on `main`, and stay green: `sys_metadata`'s `(type, name, package_id, bundle_member)` index returns the NULL-bound row first, so on this store content and version agreed by index order alone. These pins are positive pins plus the `If-Match` round trip; the double is the store that diverged. - **Ablation** (`scripts/ablation-replace.mjs`, mutation restoring `main`'s address logic, the suite imports `./protocol.js` from `src`): `Tests 2 failed | 55 passed (57)` — exactly the two divergence pins; restore proven (`blob == HEAD`, `git diff HEAD` empty). - `pnpm --filter @objectstack/metadata-protocol test`: `Test Files 236 passed | 3 skipped`, `Tests 20572 passed | 19 skipped`. - Draft-door pins from PR #23706 and PR #23654: `protocol.packageless-save-address.test.ts` + `sys-metadata-repository.draft-package-inherit.test.ts` `Tests 81 passed`; `meta-draft-head-package-inheritance.test.ts` + `meta-draft-read-door-census.test.ts` `Tests 30 passed` (against a rebuilt `metadata-protocol` dist). - Typecheck: `metadata-protocol`, `spec`, `rest`, each exit 0. - `pnpm --filter @objectstack/spec check:generated`: `All 14 generated artifacts are up to date` after `--fix` regenerated `check:docs` alone. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `eaa4b0ed`: 111 commands; `--ran` with exit codes: `111 derived, 110 run, 1 NOT-MEASURED, 0 UNRUN`. NOT MEASURED: `pnpm check:dual-build-cjs-loads` (exit 3, PREREQUISITE NOT MET: needs every package built; CI runs it). Two first-pass exit-3 prerequisites were satisfied and re-run to exit 0: `check-plugin-teardown-shape.mjs --self-test` (fetched its pinned fixture commit) and `spec check:skill-examples` (built `client-react`). ## Acceptance notes - The list's draft-preview merge (`mergePackageAwareOverlay`, the list's other served-row reader) was not measured for the `[package-unbound, Q]` shape; it carries no per-item `version`. - `main` moved three commits after the branch point (rest analytics refactor, objectql, organizations); none touches this diff's files or behaviour, and GitHub reports no conflict, so `main` was not merged in. - Open PRs touching `protocol.ts` at opening: #23704 (D9.9, `saveMetaItem`) and #23724 (orphan adoption); different regions. --- _Generated by [Claude Code](https://claude.ai/code/session_01HTBbFnY86QT5g7BxeMo3Ub)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 534ca13 commit 069fd40

6 files changed

Lines changed: 292 additions & 13 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
'@objectstack/spec': patch
4+
---
5+
6+
fix(metadata-protocol): the strict draft read naming no package (`GET /api/v1/meta/TYPE/NAME?state=draft`) serves its content from the draft its `version` names, the draft the draft save, the per-item publish and the draft discard naming no package act on (ADR-0048, ADR-0070)
7+
8+
Clause-②: yes
9+
10+
A `sys_metadata` row is keyed by its package (ADR-0048), so an item never published into a package can hold a package-unbound draft beside package `Q`'s. The strict draft read naming no package answered its `version` from the package-unbound draft (the draft the save naming no package writes), but its content from whichever draft row the store returned first. On a store that returned `Q`'s row first, it served `Q`'s body under the package-unbound draft's token, and a save pinned to that token (`If-Match`) wrote the package-unbound draft, a different row from the one read.
11+
12+
The read now takes its content from the same address as its `version`, in every storage order: the package-unbound draft when one is stored, else the one package's draft; for an item whose active rows are bound to one package, that package's draft as before. The draft-overlay preview naming no package (`previewDrafts`) serves the same draft. Drafts bound to two or more packages with no package-unbound draft are still served best-effort, with `version: null`, as before (the save naming no package is refused there). No accept set changes: no request that was answered `200` is refused now, and `?package=` still reaches each package's draft.
13+
14+
`@objectstack/spec`: the description of `PublishMetaItemRequestSchema.packageId` now says, as `SaveMetaItemRequestSchema.packageId`'s does, that a publish naming no package is refused for drafts bound to two or more packages only when no package-unbound draft is stored. Description only.

‎content/docs/references/api/protocol.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2442,7 +2442,7 @@ Installed package with runtime lifecycle state
24422442
| **name** | `string` | ✅ | Item name — lowercase snake_case segments, optionally dot-qualified (`crm_lead`, `crm_lead.pipeline`). The promotion door enforces the same grammar as `saveMetaItem`. |
24432443
| **actor** | `string` | optional | Identity recorded on the `op='publish'` history event. On the REST door this is the request's authenticated identity (one producer) — never a caller-supplied header. |
24442444
| **message** | `string` | optional | Optional human-readable note recorded with the publish history event. |
2445-
| **packageId** | `string \| null` | optional | ADR-0048 — the software package the draft being promoted was listed under, when the caller has one to state (`?package=<id>` on the REST door). Named, the publish promotes that package's draft and no other. ABSENT names no package: the publish promotes the draft a draft save that names no package writes — the draft of the one package the item's active rows are bound to, else its package-unbound draft, promoted into that package — and answers `404 NO_DRAFT` when neither exists, as the strict draft read does. For an item never published into a package it is the package-unbound draft, else the one package's draft. It is refused `409 RESOURCE_CONFLICT` when the active rows (or, with none, the drafts) are bound to two or more packages. ⚠️ `null` is NOT the same as absent: the implementation branches on the KEY BEING PRESENT, and `null` pins the lookup to the package-UNBOUND row. Spread it in conditionally; a present-and-`undefined` key coerces to `null` downstream and makes a package-bound draft unfindable — a silent `no_draft`. |
2445+
| **packageId** | `string \| null` | optional | ADR-0048 — the software package the draft being promoted was listed under, when the caller has one to state (`?package=<id>` on the REST door). Named, the publish promotes that package's draft and no other. ABSENT names no package: the publish promotes the draft a draft save that names no package writes — the draft of the one package the item's active rows are bound to, else its package-unbound draft, promoted into that package — and answers `404 NO_DRAFT` when neither exists, as the strict draft read does. For an item never published into a package it is the package-unbound draft, else the one package's draft. It is refused `409 RESOURCE_CONFLICT` when the active rows (or, with none and no package-unbound draft, the drafts) are bound to two or more packages. ⚠️ `null` is NOT the same as absent: the implementation branches on the KEY BEING PRESENT, and `null` pins the lookup to the package-UNBOUND row. Spread it in conditionally; a present-and-`undefined` key coerces to `null` downstream and makes a package-bound draft unfindable — a silent `no_draft`. |
24462446
| **member** | `string` | optional | Bundle member selector, for a metadata type whose identity is a pair (today only `email_template`, identified by `(name, locale)`): the member this request addresses, spelled as its discriminator value (`zh-CN`). Absent, the canonical member (`en-US` for `email_template`), which is what a bare-name read answers; naming the canonical value is the same request. It is not the response translation locale, which `?locale=` and `Accept-Language` choose. Refused with 400 `VALIDATION_ERROR` on a type that has no members, and for a blank value or one longer than a stored member can be (100 characters). A well-formed member with nothing stored or shipped answers as that door answers an absent item. |
24472447

24482448

‎packages/metadata-protocol/src/protocol.packageless-save-address.test.ts‎

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1240,3 +1240,121 @@ describe('[#23495] the draft doors naming no package act on the draft the draft
12401240
});
12411241
});
12421242
});
1243+
1244+
describe('[#23715] the strict draft read naming no package serves its content from the draft its version names', () => {
1245+
const P = WRITABLE;
1246+
const Q = OTHER_WRITABLE;
1247+
const R = 'com.qa.thirdapp';
1248+
type Fixture = ReturnType<typeof makeProtocol> & { name: string };
1249+
1250+
/** `label@binding` of every draft row of the item, in storage order. */
1251+
const drafts = (f: Fixture) =>
1252+
f.rows('app', f.name, 'draft').map((r) => `${JSON.parse(r.metadata).label}@${r.package_id ?? 'null'}`);
1253+
const readDraft = (f: Fixture) => f.protocol.getMetaItem({ type: 'app', name: f.name, state: 'draft' });
1254+
/** The draft read naming the row's own package (`null`: none), the read whose content and `version` are one row's. */
1255+
const readDraftAt = (f: Fixture, packageId: string) =>
1256+
f.protocol.getMetaItem({ type: 'app', name: f.name, state: 'draft', packageId });
1257+
const unboundDraft = (f: Fixture, label: string) => f.seedRow({
1258+
type: 'app', name: f.name, state: 'draft', package_id: null,
1259+
metadata: JSON.stringify(app(f.name, { label })), checksum: `sha-${label}`,
1260+
});
1261+
1262+
/**
1263+
* No active row; drafts `[package-unbound, Q]` in either storage order.
1264+
* The package-unbound draft is reached through the door when it is stored
1265+
* first (a draft save naming no package of a new item); stored second it
1266+
* is seeded, as an older build left it (#23657: that save now updates Q's
1267+
* only draft).
1268+
*/
1269+
async function unboundBesideQ(name: string, unboundFirst: boolean): Promise<Fixture> {
1270+
const f = { ...makeProtocol(), name };
1271+
if (unboundFirst) await f.protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'unbound' }), mode: 'draft' });
1272+
await f.protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'q' }), packageId: Q, mode: 'draft' });
1273+
if (!unboundFirst) unboundDraft(f, 'unbound');
1274+
return f;
1275+
}
1276+
1277+
for (const unboundFirst of [true, false]) {
1278+
const order = unboundFirst ? 'the package-unbound draft stored first' : 'Q\'s draft stored first';
1279+
1280+
it(`drafts [package-unbound, Q], no active row (${order}): content and version are the package-unbound draft's, and an If-Match save edits the row read`, async () => {
1281+
const f = await unboundBesideQ(`u23715_${unboundFirst ? 'unbound_first' : 'q_first'}`, unboundFirst);
1282+
expect(drafts(f)).toEqual(unboundFirst ? ['unbound@null', `q@${Q}`] : [`q@${Q}`, 'unbound@null']);
1283+
expect(f.rows('app', f.name, 'active')).toEqual([]);
1284+
1285+
const read = await readDraft(f);
1286+
expect(read.item.label).toBe('unbound');
1287+
expect(read.item._packageId).toBeUndefined();
1288+
expect(read.version).toEqual(expect.any(String));
1289+
// Q's draft is still reachable by naming Q, under its own token.
1290+
const q = await readDraftAt(f, Q);
1291+
expect(q.item.label).toBe('q');
1292+
expect(q.version).not.toBe(read.version);
1293+
1294+
// The token names the row read: a save pinned to it edits that row, and Q's draft is untouched.
1295+
const saved = await f.protocol.saveMetaItem({
1296+
type: 'app', name: f.name, item: app(f.name, { label: 'edited' }), mode: 'draft', parentVersion: read.version,
1297+
});
1298+
expect(saved.success).toBe(true);
1299+
expect(drafts(f)).toEqual(unboundFirst ? ['edited@null', `q@${Q}`] : [`q@${Q}`, 'edited@null']);
1300+
const again = await readDraft(f);
1301+
expect(again.item.label).toBe('edited');
1302+
expect(again.version).toBe(saved.version);
1303+
// The draft-overlay preview naming no package serves the same draft.
1304+
expect((await f.protocol.getMetaItem({ type: 'app', name: f.name, previewDrafts: true })).item)
1305+
.toMatchObject({ label: 'edited', _draft: true });
1306+
});
1307+
}
1308+
1309+
it('active rows bound to two packages, drafts [package-unbound, P] in either storage order: the read serves the package-unbound draft its version names', async () => {
1310+
for (const unboundFirst of [true, false]) {
1311+
const name = `u23715_two_active_${unboundFirst ? 'unbound_first' : 'p_first'}`;
1312+
const f = { ...makeProtocol(), name };
1313+
if (unboundFirst) await f.protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'unbound' }), mode: 'draft' });
1314+
await f.protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'live P' }), packageId: P });
1315+
await f.protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'live Q' }), packageId: Q });
1316+
await f.protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'p' }), packageId: P, mode: 'draft' });
1317+
if (!unboundFirst) unboundDraft(f, 'unbound');
1318+
expect(drafts(f), name).toEqual(unboundFirst ? ['unbound@null', `p@${P}`] : [`p@${P}`, 'unbound@null']);
1319+
1320+
const read = await readDraft(f);
1321+
expect(read.item.label, name).toBe('unbound');
1322+
expect(read.version, name).toEqual(expect.any(String));
1323+
expect(read.version, name).not.toBe((await readDraftAt(f, P)).version);
1324+
}
1325+
});
1326+
1327+
it('CONTROL: a single draft is served as before — the package-unbound one, or the one package\'s', async () => {
1328+
const u = { ...makeProtocol(), name: 'u23715_only_unbound' };
1329+
const saved = await u.protocol.saveMetaItem({ type: 'app', name: u.name, item: app(u.name, { label: 'unbound' }), mode: 'draft' });
1330+
const ur = await readDraft(u);
1331+
expect(ur.item.label).toBe('unbound');
1332+
expect(ur.version).toBe(saved.version);
1333+
1334+
const q = { ...makeProtocol(), name: 'u23715_only_q' };
1335+
const qSaved = await q.protocol.saveMetaItem({ type: 'app', name: q.name, item: app(q.name, { label: 'q' }), packageId: Q, mode: 'draft' });
1336+
const qr = await readDraft(q);
1337+
expect(qr.item).toMatchObject({ label: 'q', _packageId: Q });
1338+
expect(qr.version).toBe(qSaved.version);
1339+
});
1340+
1341+
it('CONTROL: an item with an active binding serves the draft of its package, beside another package\'s draft', async () => {
1342+
const f = { ...makeProtocol(), name: 'u23715_bound' };
1343+
await f.protocol.saveMetaItem({ type: 'app', name: f.name, item: app(f.name, { label: 'q' }), packageId: Q, mode: 'draft' });
1344+
await f.protocol.saveMetaItem({ type: 'app', name: f.name, item: app(f.name, { label: 'live' }), packageId: P });
1345+
await f.protocol.saveMetaItem({ type: 'app', name: f.name, item: app(f.name, { label: 'p' }), mode: 'draft' });
1346+
expect(drafts(f)).toEqual([`q@${Q}`, `p@${P}`]);
1347+
const read = await readDraft(f);
1348+
expect(read.item).toMatchObject({ label: 'p', _packageId: P });
1349+
expect(read.version).toBe((await readDraftAt(f, P)).version);
1350+
});
1351+
1352+
it('CONTROL: drafts bound to two packages and none package-unbound are still served best-effort, with no version (the save naming none is refused there)', async () => {
1353+
const f = { ...makeProtocol(), name: 'u23715_two_drafts' };
1354+
await f.protocol.saveMetaItem({ type: 'app', name: f.name, item: app(f.name, { label: 'q' }), packageId: Q, mode: 'draft' });
1355+
await f.protocol.saveMetaItem({ type: 'app', name: f.name, item: app(f.name, { label: 'r' }), packageId: R, mode: 'draft' });
1356+
const read = await readDraft(f);
1357+
expect(['q', 'r']).toContain(read.item.label);
1358+
expect(read.version).toBeNull();
1359+
});
1360+
});

0 commit comments

Comments
 (0)