diff --git a/.changeset/draft-package-inherit-11087.md b/.changeset/draft-package-inherit-11087.md new file mode 100644 index 0000000000..d6fa2eafec --- /dev/null +++ b/.changeset/draft-package-inherit-11087.md @@ -0,0 +1,5 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +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. diff --git a/packages/metadata-protocol/src/sys-metadata-repository.draft-package-inherit.test.ts b/packages/metadata-protocol/src/sys-metadata-repository.draft-package-inherit.test.ts new file mode 100644 index 0000000000..abf73c0b61 --- /dev/null +++ b/packages/metadata-protocol/src/sys-metadata-repository.draft-package-inherit.test.ts @@ -0,0 +1,155 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#11087] Draft-save package inheritance. + * + * 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` — used to stamp 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 build surface's pending-changes bar stayed dark over a + * publishable change. + * + * Pinned here: + * 1. inheritance — a package-less draft save over a bound active row adopts + * the active row's binding, and the scoped listDrafts counts it; + * 2. an EXPLICIT packageId is never overridden (ADR-0048: callers state + * their scope); + * 3. a brand-new item drafted first (no active row) keeps package-less + * semantics; + * 4. orphan adoption — a pre-fix NULL-package draft for the same + * (org, type, name) is UPDATED and adopted, never forked into a second + * draft row. + */ + +import { describe, it, expect } from 'vitest'; +// The engine-double contract gate: a fake looser than ObjectQL's own verb +// dispatch is how #4434 shipped a dead route with its suite green. +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; +import { SysMetadataRepository } from './sys-metadata-repository.js'; + +interface Row { + [k: string]: unknown; +} + +function makeFakeEngine(seed: Row[] = []) { + let nextId = 1; + const rows: Row[] = seed.map((r) => ({ id: `seed_${nextId++}`, ...r })); + const history: Row[] = []; + + const matches = (row: Row, where: Record): boolean => { + for (const [k, v] of Object.entries(where)) { + if (k === '$or') { + const branches = v as Array>; + if (!branches.some((b) => matches(row, b))) return false; + continue; + } + if (k.startsWith('$')) { + // Refuse combinators this double does not implement — a silent + // field-name read is how a fake matcher lies (WHERE-matcher gate). + throw new Error(`fake matcher: unimplemented combinator ${k}`); + } + const rv = row[k] ?? null; + if ((v ?? null) !== rv) return false; + } + return true; + }; + + const tableOf = (name: string): Row[] => (name === 'sys_metadata' ? rows : history); + + return { + rows, + history, + async findOne(table: string, q: { where: Record }) { + return tableOf(table).find((r) => matches(r, q.where)) ?? null; + }, + async find(table: string, q: { where: Record }) { + return tableOf(table).filter((r) => matches(r, q.where)); + }, + async insert(table: string, data: Row) { + const row = { id: `row_${nextId++}`, ...data }; + tableOf(table).push(row); + return row; + }, + async update(table: string, data: Row, opts: { where: Record }) { + assertEngineUpdateDispatch(data, opts); + const row = tableOf(table).find((r) => matches(r, opts.where)); + if (row) Object.assign(row, data); + return row; + }, + async delete(_table: string, opts: { where?: Record }) { + assertEngineDeleteDispatch(opts); + /* not exercised here */ + }, + }; +} + +const REF = { org: 'system', type: 'view' as const, name: 'k9qk_member.member_list' }; + +function makeRepo(engine: ReturnType) { + return new SysMetadataRepository({ + engine: engine as never, + organizationId: null, + orgLabel: 'env', + } as never); +} + +describe('SysMetadataRepository draft-save package inheritance (#11087)', () => { + it('a package-less draft save over a bound active row inherits the binding, and scoped listDrafts counts it', async () => { + const engine = makeFakeEngine(); + const repo = makeRepo(engine); + await repo.put(REF, { label: 'Member' }, { parentVersion: null, actor: 't', packageId: 'app.k9qk' }); + const active = engine.rows.find((r) => r.state === 'active')!; + expect(active.package_id).toBe('app.k9qk'); + + await repo.put(REF, { label: 'Member', description: 'edited' }, { parentVersion: null, actor: 't', state: 'draft' as const }); + const draft = engine.rows.find((r) => r.state === 'draft')!; + expect(draft.package_id).toBe('app.k9qk'); + + const scoped = await repo.listDrafts({ packageId: 'app.k9qk' }); + expect(scoped.map((d) => d.name)).toEqual(['k9qk_member.member_list']); + }); + + it('an explicit packageId on the draft save is never overridden by inheritance', async () => { + const engine = makeFakeEngine(); + const repo = makeRepo(engine); + await repo.put(REF, { label: 'Member' }, { parentVersion: null, actor: 't', packageId: 'app.k9qk' }); + await repo.put(REF, { label: 'Member v2' }, { parentVersion: null, actor: 't', state: 'draft' as const, packageId: 'app.other' }); + const draft = engine.rows.find((r) => r.state === 'draft')!; + expect(draft.package_id).toBe('app.other'); + }); + + it('a brand-new item drafted first keeps package-less semantics (nothing to inherit)', async () => { + const engine = makeFakeEngine(); + const repo = makeRepo(engine); + await repo.put(REF, { label: 'Member' }, { parentVersion: null, actor: 't', state: 'draft' as const }); + const draft = engine.rows.find((r) => r.state === 'draft')!; + expect(draft.package_id ?? null).toBeNull(); + }); + + it('adopts a pre-fix orphan draft (NULL package) instead of forking a second draft row', async () => { + const engine = makeFakeEngine([ + { + type: REF.type, name: REF.name, organization_id: null, state: 'active', + package_id: 'app.k9qk', metadata: '{"label":"Member"}', checksum: 'sha-active', version: 1, + }, + { + type: REF.type, name: REF.name, organization_id: null, state: 'draft', + package_id: null, metadata: '{"label":"Member","description":"orphan"}', checksum: 'sha-orphan', version: 2, + }, + ]); + const repo = makeRepo(engine); + await repo.put( + REF, + { label: 'Member', description: 'orphan edited' }, + { parentVersion: 'sha-orphan', actor: 't', state: 'draft' as const }, + ); + const drafts = engine.rows.filter((r) => r.state === 'draft'); + expect(drafts).toHaveLength(1); // updated in place, never forked + expect(drafts[0].package_id).toBe('app.k9qk'); // adopted into the package + }); +}); diff --git a/packages/metadata-protocol/src/sys-metadata-repository.ts b/packages/metadata-protocol/src/sys-metadata-repository.ts index f32891caca..cae2db7ba1 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.ts @@ -426,7 +426,26 @@ export class SysMetadataRepository implements MetadataRepository { // overlay. Callers state their scope; this line no longer decides it // anywhere but for the documented `PutOptions.packageId` default // (omitted/undefined = the env-local, unbound row). - const targetPackageId: string | null = opts.packageId ?? null; + let targetPackageId: string | null = opts.packageId ?? null; + // [#11087] Draft-save package inheritance. 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 `?mode=draft` save) used to stamp NULL even + // when the row it overlays is package-bound, producing an "orphan draft" + // that no package view counts and no per-package publish can ever promote + // (measured live: `_drafts` lists it, `_drafts?packageId=` does not). + // Inherit the overlaid ACTIVE row's binding instead. Explicit + // `opts.packageId` is untouched (a caller that states its base keeps it, + // ADR-0048), and with no active row — a brand-new item drafted first — + // there is nothing to inherit and the package-less semantics stand. + if (state === 'draft' && opts.packageId == null) { + const activeRow = await this.engine.findOne('sys_metadata', { + where: this.whereFor(ref, 'active'), + }); + const activePkg = (activeRow as { package_id?: string | null } | null)?.package_id ?? null; + if (activePkg) targetPackageId = activePkg; + } // Run all reads + writes inside one transaction so the optimistic // lock, the parent-row mutation, and the history append are atomic. @@ -434,10 +453,22 @@ export class SysMetadataRepository implements MetadataRepository { // ADR-0048 — scope the existing-row lookup to the requested package so a // save for package B does not find (and overwrite) package A's same-name // overlay. A package-less save (packageId null) targets the global row. - const existing = await this.engine.findOne('sys_metadata', { + let existing = await this.engine.findOne('sys_metadata', { where: this.whereFor(ref, state, targetPackageId), context: ctx, }); + // [#11087] Orphan-draft adoption: when the package binding above was + // INHERITED (caller named none), a pre-fix draft for the same + // (org, type, name) sits at `package_id NULL` and the scoped lookup + // misses it — creating a second draft row would fork the pending change. + // Re-read the package-less row and update THAT one; the stamp below + // (`existingPkg ?? targetPackageId`) then adopts it into the package. + if (!existing && state === 'draft' && opts.packageId == null && targetPackageId !== null) { + existing = await this.engine.findOne('sys_metadata', { + where: this.whereFor(ref, state, null), + context: ctx, + }); + } const existingHash: string | null = existing?.checksum ?? null; if (opts.parentVersion !== existingHash) { throw new ConflictError(this.fullRef(ref), opts.parentVersion, existingHash); diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 551ab0b547..c1ed6be8c8 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -871,6 +871,16 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/sys-metadata-repository.draft-package-inherit.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/sys-metadata-repository.draft-package-inherit.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/sys-metadata-repository.history-counters.test.ts", "verb": "delete",