Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/23715-strict-draft-read-address.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/metadata-protocol': minor
'@objectstack/spec': patch
---

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)

Clause-②: yes

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.

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.

`@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.
2 changes: 1 addition & 1 deletion content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2442,7 +2442,7 @@ Installed package with runtime lifecycle state
| **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`. |
| **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. |
| **message** | `string` | optional | Optional human-readable note recorded with the publish history event. |
| **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`. |
| **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`. |
| **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. |


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1240,3 +1240,121 @@ describe('[#23495] the draft doors naming no package act on the draft the draft
});
});
});

describe('[#23715] the strict draft read naming no package serves its content from the draft its version names', () => {
const P = WRITABLE;
const Q = OTHER_WRITABLE;
const R = 'com.qa.thirdapp';
type Fixture = ReturnType<typeof makeProtocol> & { name: string };

/** `label@binding` of every draft row of the item, in storage order. */
const drafts = (f: Fixture) =>
f.rows('app', f.name, 'draft').map((r) => `${JSON.parse(r.metadata).label}@${r.package_id ?? 'null'}`);
const readDraft = (f: Fixture) => f.protocol.getMetaItem({ type: 'app', name: f.name, state: 'draft' });
/** The draft read naming the row's own package (`null`: none), the read whose content and `version` are one row's. */
const readDraftAt = (f: Fixture, packageId: string) =>
f.protocol.getMetaItem({ type: 'app', name: f.name, state: 'draft', packageId });
const unboundDraft = (f: Fixture, label: string) => f.seedRow({
type: 'app', name: f.name, state: 'draft', package_id: null,
metadata: JSON.stringify(app(f.name, { label })), checksum: `sha-${label}`,
});

/**
* No active row; drafts `[package-unbound, Q]` in either storage order.
* The package-unbound draft is reached through the door when it is stored
* first (a draft save naming no package of a new item); stored second it
* is seeded, as an older build left it (#23657: that save now updates Q's
* only draft).
*/
async function unboundBesideQ(name: string, unboundFirst: boolean): Promise<Fixture> {
const f = { ...makeProtocol(), name };
if (unboundFirst) await f.protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'unbound' }), mode: 'draft' });
await f.protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'q' }), packageId: Q, mode: 'draft' });
if (!unboundFirst) unboundDraft(f, 'unbound');
return f;
}

for (const unboundFirst of [true, false]) {
const order = unboundFirst ? 'the package-unbound draft stored first' : 'Q\'s draft stored first';

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 () => {
const f = await unboundBesideQ(`u23715_${unboundFirst ? 'unbound_first' : 'q_first'}`, unboundFirst);
expect(drafts(f)).toEqual(unboundFirst ? ['unbound@null', `q@${Q}`] : [`q@${Q}`, 'unbound@null']);
expect(f.rows('app', f.name, 'active')).toEqual([]);

const read = await readDraft(f);
expect(read.item.label).toBe('unbound');
expect(read.item._packageId).toBeUndefined();
expect(read.version).toEqual(expect.any(String));
// Q's draft is still reachable by naming Q, under its own token.
const q = await readDraftAt(f, Q);
expect(q.item.label).toBe('q');
expect(q.version).not.toBe(read.version);

// The token names the row read: a save pinned to it edits that row, and Q's draft is untouched.
const saved = await f.protocol.saveMetaItem({
type: 'app', name: f.name, item: app(f.name, { label: 'edited' }), mode: 'draft', parentVersion: read.version,
});
expect(saved.success).toBe(true);
expect(drafts(f)).toEqual(unboundFirst ? ['edited@null', `q@${Q}`] : [`q@${Q}`, 'edited@null']);
const again = await readDraft(f);
expect(again.item.label).toBe('edited');
expect(again.version).toBe(saved.version);
// The draft-overlay preview naming no package serves the same draft.
expect((await f.protocol.getMetaItem({ type: 'app', name: f.name, previewDrafts: true })).item)
.toMatchObject({ label: 'edited', _draft: true });
});
}

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 () => {
for (const unboundFirst of [true, false]) {
const name = `u23715_two_active_${unboundFirst ? 'unbound_first' : 'p_first'}`;
const f = { ...makeProtocol(), name };
if (unboundFirst) await f.protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'unbound' }), mode: 'draft' });
await f.protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'live P' }), packageId: P });
await f.protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'live Q' }), packageId: Q });
await f.protocol.saveMetaItem({ type: 'app', name, item: app(name, { label: 'p' }), packageId: P, mode: 'draft' });
if (!unboundFirst) unboundDraft(f, 'unbound');
expect(drafts(f), name).toEqual(unboundFirst ? ['unbound@null', `p@${P}`] : [`p@${P}`, 'unbound@null']);

const read = await readDraft(f);
expect(read.item.label, name).toBe('unbound');
expect(read.version, name).toEqual(expect.any(String));
expect(read.version, name).not.toBe((await readDraftAt(f, P)).version);
}
});

it('CONTROL: a single draft is served as before — the package-unbound one, or the one package\'s', async () => {
const u = { ...makeProtocol(), name: 'u23715_only_unbound' };
const saved = await u.protocol.saveMetaItem({ type: 'app', name: u.name, item: app(u.name, { label: 'unbound' }), mode: 'draft' });
const ur = await readDraft(u);
expect(ur.item.label).toBe('unbound');
expect(ur.version).toBe(saved.version);

const q = { ...makeProtocol(), name: 'u23715_only_q' };
const qSaved = await q.protocol.saveMetaItem({ type: 'app', name: q.name, item: app(q.name, { label: 'q' }), packageId: Q, mode: 'draft' });
const qr = await readDraft(q);
expect(qr.item).toMatchObject({ label: 'q', _packageId: Q });
expect(qr.version).toBe(qSaved.version);
});

it('CONTROL: an item with an active binding serves the draft of its package, beside another package\'s draft', async () => {
const f = { ...makeProtocol(), name: 'u23715_bound' };
await f.protocol.saveMetaItem({ type: 'app', name: f.name, item: app(f.name, { label: 'q' }), packageId: Q, mode: 'draft' });
await f.protocol.saveMetaItem({ type: 'app', name: f.name, item: app(f.name, { label: 'live' }), packageId: P });
await f.protocol.saveMetaItem({ type: 'app', name: f.name, item: app(f.name, { label: 'p' }), mode: 'draft' });
expect(drafts(f)).toEqual([`q@${Q}`, `p@${P}`]);
const read = await readDraft(f);
expect(read.item).toMatchObject({ label: 'p', _packageId: P });
expect(read.version).toBe((await readDraftAt(f, P)).version);
});

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 () => {
const f = { ...makeProtocol(), name: 'u23715_two_drafts' };
await f.protocol.saveMetaItem({ type: 'app', name: f.name, item: app(f.name, { label: 'q' }), packageId: Q, mode: 'draft' });
await f.protocol.saveMetaItem({ type: 'app', name: f.name, item: app(f.name, { label: 'r' }), packageId: R, mode: 'draft' });
const read = await readDraft(f);
expect(['q', 'r']).toContain(read.item.label);
expect(read.version).toBeNull();
});
});
Loading
Loading