Skip to content

Commit 17bad12

Browse files
os-zhuangclaude
andauthored
fix(metadata-protocol): package-less draft saves inherit the overlaid active row's package binding (#11087) (#11139)
* fix(metadata-protocol): a package-less draft save inherits the overlaid active row's package binding (#11087) A state='draft' save is a pending change OVER the published row, and every package-scoped consumer — listDrafts({packageId}), the console's pending-changes surfaces, publishPackageDrafts — keys drafts by package_id. A caller that names no base (the console's plain PUT …?mode=draft) stamped NULL even when the overlaid active row is package-bound, producing an orphan draft no package view counts and no per-package publish can promote. Measured live on a cloud tenant (cloud#1593): GET /meta/_drafts listed the draft, ?packageId= listed nothing, and the AI build surface's pending-changes bar stayed dark over a publishable change. put() now resolves the draft's package binding in this order: the caller's explicit opts.packageId (ADR-0048 — callers state their scope, never overridden), else the overlaid ACTIVE row's package_id, else NULL (a brand-new item drafted first keeps package-less semantics). Plus orphan adoption: when the binding was inherited and the scoped existing-row lookup misses, a pre-fix NULL-package draft for the same (org,type,name) is updated in place and adopted into the package — never forked into a second draft row. Four pins in sys-metadata-repository.draft-package-inherit.test.ts; full metadata-protocol suite 1857 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test: MetaRef org + required actor; changeset Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test: pin the fake engine's update/delete to the dispatch contract; ledger Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test: refuse unimplemented WHERE combinators in the fake matcher Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 78019bb commit 17bad12

4 files changed

Lines changed: 203 additions & 2 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
A package-less `state='draft'` save inherits the overlaid active row's `package_id` (#11087) — so package-scoped consumers (`listDrafts({packageId})`, per-package publish, pending-changes surfaces) count the draft instead of orphaning it — with in-place adoption of pre-fix NULL-package orphan drafts (never forked into a second row). Explicit `packageId` is never overridden; a brand-new item drafted first keeps package-less semantics.
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#11087] Draft-save package inheritance.
5+
*
6+
* A `state='draft'` save is a pending change OVER the published row, and every
7+
* package-scoped consumer (`listDrafts({ packageId })`, the console's
8+
* pending-changes surfaces, `publishPackageDrafts`) keys drafts by
9+
* `package_id`. A caller that names no base — the console's plain
10+
* `PUT …?mode=draft` — used to stamp NULL even when the overlaid active row is
11+
* package-bound, producing an "orphan draft" no package view counts and no
12+
* per-package publish can promote. Measured live on a cloud tenant
13+
* (cloud#1593): `GET /meta/_drafts` listed the draft, `?packageId=` listed
14+
* nothing, and the build surface's pending-changes bar stayed dark over a
15+
* publishable change.
16+
*
17+
* Pinned here:
18+
* 1. inheritance — a package-less draft save over a bound active row adopts
19+
* the active row's binding, and the scoped listDrafts counts it;
20+
* 2. an EXPLICIT packageId is never overridden (ADR-0048: callers state
21+
* their scope);
22+
* 3. a brand-new item drafted first (no active row) keeps package-less
23+
* semantics;
24+
* 4. orphan adoption — a pre-fix NULL-package draft for the same
25+
* (org, type, name) is UPDATED and adopted, never forked into a second
26+
* draft row.
27+
*/
28+
29+
import { describe, it, expect } from 'vitest';
30+
// The engine-double contract gate: a fake looser than ObjectQL's own verb
31+
// dispatch is how #4434 shipped a dead route with its suite green.
32+
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core';
33+
import { SysMetadataRepository } from './sys-metadata-repository.js';
34+
35+
interface Row {
36+
[k: string]: unknown;
37+
}
38+
39+
function makeFakeEngine(seed: Row[] = []) {
40+
let nextId = 1;
41+
const rows: Row[] = seed.map((r) => ({ id: `seed_${nextId++}`, ...r }));
42+
const history: Row[] = [];
43+
44+
const matches = (row: Row, where: Record<string, unknown>): boolean => {
45+
for (const [k, v] of Object.entries(where)) {
46+
if (k === '$or') {
47+
const branches = v as Array<Record<string, unknown>>;
48+
if (!branches.some((b) => matches(row, b))) return false;
49+
continue;
50+
}
51+
if (k.startsWith('$')) {
52+
// Refuse combinators this double does not implement — a silent
53+
// field-name read is how a fake matcher lies (WHERE-matcher gate).
54+
throw new Error(`fake matcher: unimplemented combinator ${k}`);
55+
}
56+
const rv = row[k] ?? null;
57+
if ((v ?? null) !== rv) return false;
58+
}
59+
return true;
60+
};
61+
62+
const tableOf = (name: string): Row[] => (name === 'sys_metadata' ? rows : history);
63+
64+
return {
65+
rows,
66+
history,
67+
async findOne(table: string, q: { where: Record<string, unknown> }) {
68+
return tableOf(table).find((r) => matches(r, q.where)) ?? null;
69+
},
70+
async find(table: string, q: { where: Record<string, unknown> }) {
71+
return tableOf(table).filter((r) => matches(r, q.where));
72+
},
73+
async insert(table: string, data: Row) {
74+
const row = { id: `row_${nextId++}`, ...data };
75+
tableOf(table).push(row);
76+
return row;
77+
},
78+
async update(table: string, data: Row, opts: { where: Record<string, unknown> }) {
79+
assertEngineUpdateDispatch(data, opts);
80+
const row = tableOf(table).find((r) => matches(r, opts.where));
81+
if (row) Object.assign(row, data);
82+
return row;
83+
},
84+
async delete(_table: string, opts: { where?: Record<string, unknown> }) {
85+
assertEngineDeleteDispatch(opts);
86+
/* not exercised here */
87+
},
88+
};
89+
}
90+
91+
const REF = { org: 'system', type: 'view' as const, name: 'k9qk_member.member_list' };
92+
93+
function makeRepo(engine: ReturnType<typeof makeFakeEngine>) {
94+
return new SysMetadataRepository({
95+
engine: engine as never,
96+
organizationId: null,
97+
orgLabel: 'env',
98+
} as never);
99+
}
100+
101+
describe('SysMetadataRepository draft-save package inheritance (#11087)', () => {
102+
it('a package-less draft save over a bound active row inherits the binding, and scoped listDrafts counts it', async () => {
103+
const engine = makeFakeEngine();
104+
const repo = makeRepo(engine);
105+
await repo.put(REF, { label: 'Member' }, { parentVersion: null, actor: 't', packageId: 'app.k9qk' });
106+
const active = engine.rows.find((r) => r.state === 'active')!;
107+
expect(active.package_id).toBe('app.k9qk');
108+
109+
await repo.put(REF, { label: 'Member', description: 'edited' }, { parentVersion: null, actor: 't', state: 'draft' as const });
110+
const draft = engine.rows.find((r) => r.state === 'draft')!;
111+
expect(draft.package_id).toBe('app.k9qk');
112+
113+
const scoped = await repo.listDrafts({ packageId: 'app.k9qk' });
114+
expect(scoped.map((d) => d.name)).toEqual(['k9qk_member.member_list']);
115+
});
116+
117+
it('an explicit packageId on the draft save is never overridden by inheritance', async () => {
118+
const engine = makeFakeEngine();
119+
const repo = makeRepo(engine);
120+
await repo.put(REF, { label: 'Member' }, { parentVersion: null, actor: 't', packageId: 'app.k9qk' });
121+
await repo.put(REF, { label: 'Member v2' }, { parentVersion: null, actor: 't', state: 'draft' as const, packageId: 'app.other' });
122+
const draft = engine.rows.find((r) => r.state === 'draft')!;
123+
expect(draft.package_id).toBe('app.other');
124+
});
125+
126+
it('a brand-new item drafted first keeps package-less semantics (nothing to inherit)', async () => {
127+
const engine = makeFakeEngine();
128+
const repo = makeRepo(engine);
129+
await repo.put(REF, { label: 'Member' }, { parentVersion: null, actor: 't', state: 'draft' as const });
130+
const draft = engine.rows.find((r) => r.state === 'draft')!;
131+
expect(draft.package_id ?? null).toBeNull();
132+
});
133+
134+
it('adopts a pre-fix orphan draft (NULL package) instead of forking a second draft row', async () => {
135+
const engine = makeFakeEngine([
136+
{
137+
type: REF.type, name: REF.name, organization_id: null, state: 'active',
138+
package_id: 'app.k9qk', metadata: '{"label":"Member"}', checksum: 'sha-active', version: 1,
139+
},
140+
{
141+
type: REF.type, name: REF.name, organization_id: null, state: 'draft',
142+
package_id: null, metadata: '{"label":"Member","description":"orphan"}', checksum: 'sha-orphan', version: 2,
143+
},
144+
]);
145+
const repo = makeRepo(engine);
146+
await repo.put(
147+
REF,
148+
{ label: 'Member', description: 'orphan edited' },
149+
{ parentVersion: 'sha-orphan', actor: 't', state: 'draft' as const },
150+
);
151+
const drafts = engine.rows.filter((r) => r.state === 'draft');
152+
expect(drafts).toHaveLength(1); // updated in place, never forked
153+
expect(drafts[0].package_id).toBe('app.k9qk'); // adopted into the package
154+
});
155+
});

‎packages/metadata-protocol/src/sys-metadata-repository.ts‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -450,18 +450,49 @@ export class SysMetadataRepository implements MetadataRepository {
450450
// overlay. Callers state their scope; this line no longer decides it
451451
// anywhere but for the documented `PutOptions.packageId` default
452452
// (omitted/undefined = the env-local, unbound row).
453-
const targetPackageId: string | null = opts.packageId ?? null;
453+
let targetPackageId: string | null = opts.packageId ?? null;
454+
// [#11087] Draft-save package inheritance. A `state='draft'` save is a
455+
// pending change OVER the published row, and every package-scoped consumer
456+
// — `listDrafts({ packageId })`, the console's pending-changes surfaces,
457+
// `publishPackageDrafts` — keys drafts by `package_id`. A caller that names
458+
// no base (the console's plain `?mode=draft` save) used to stamp NULL even
459+
// when the row it overlays is package-bound, producing an "orphan draft"
460+
// that no package view counts and no per-package publish can ever promote
461+
// (measured live: `_drafts` lists it, `_drafts?packageId=` does not).
462+
// Inherit the overlaid ACTIVE row's binding instead. Explicit
463+
// `opts.packageId` is untouched (a caller that states its base keeps it,
464+
// ADR-0048), and with no active row — a brand-new item drafted first —
465+
// there is nothing to inherit and the package-less semantics stand.
466+
if (state === 'draft' && opts.packageId == null) {
467+
const activeRow = await this.engine.findOne('sys_metadata', {
468+
where: this.whereFor(ref, 'active'),
469+
});
470+
const activePkg = (activeRow as { package_id?: string | null } | null)?.package_id ?? null;
471+
if (activePkg) targetPackageId = activePkg;
472+
}
454473

455474
// Run all reads + writes inside one transaction so the optimistic
456475
// lock, the parent-row mutation, and the history append are atomic.
457476
const result = await this.withTxn(async (ctx) => {
458477
// ADR-0048 — scope the existing-row lookup to the requested package so a
459478
// save for package B does not find (and overwrite) package A's same-name
460479
// overlay. A package-less save (packageId null) targets the global row.
461-
const existing = await this.engine.findOne('sys_metadata', {
480+
let existing = await this.engine.findOne('sys_metadata', {
462481
where: this.whereFor(ref, state, targetPackageId),
463482
context: ctx,
464483
});
484+
// [#11087] Orphan-draft adoption: when the package binding above was
485+
// INHERITED (caller named none), a pre-fix draft for the same
486+
// (org, type, name) sits at `package_id NULL` and the scoped lookup
487+
// misses it — creating a second draft row would fork the pending change.
488+
// Re-read the package-less row and update THAT one; the stamp below
489+
// (`existingPkg ?? targetPackageId`) then adopts it into the package.
490+
if (!existing && state === 'draft' && opts.packageId == null && targetPackageId !== null) {
491+
existing = await this.engine.findOne('sys_metadata', {
492+
where: this.whereFor(ref, state, null),
493+
context: ctx,
494+
});
495+
}
465496
const existingHash: string | null = existing?.checksum ?? null;
466497
if (opts.parentVersion !== existingHash) {
467498
throw new ConflictError(this.fullRef(ref), opts.parentVersion, existingHash);

‎scripts/engine-double-contract.pinned.json‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -871,6 +871,16 @@
871871
"verb": "update",
872872
"pinned": 1
873873
},
874+
{
875+
"file": "packages/metadata-protocol/src/sys-metadata-repository.draft-package-inherit.test.ts",
876+
"verb": "delete",
877+
"pinned": 1
878+
},
879+
{
880+
"file": "packages/metadata-protocol/src/sys-metadata-repository.draft-package-inherit.test.ts",
881+
"verb": "update",
882+
"pinned": 1
883+
},
874884
{
875885
"file": "packages/metadata-protocol/src/sys-metadata-repository.history-counters.test.ts",
876886
"verb": "delete",

0 commit comments

Comments
 (0)