diff --git a/.changeset/29397-overlay-pair-fix-one-predicate.md b/.changeset/29397-overlay-pair-fix-one-predicate.md new file mode 100644 index 00000000000..be6b3182a46 --- /dev/null +++ b/.changeset/29397-overlay-pair-fix-one-predicate.md @@ -0,0 +1,10 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +refactor(metadata-protocol): the two overlay-write closes ask one "the fix can be taken" predicate + +Clause-②: no +Metadata-protocol: none + +The close of a write naming the read-only base itself and the close of the package-less write each judged, step for step, whether the fix their refusal names can be taken: the base has a pair id, the ensure door would answer or create the pair, the base records a version, and the override write gate admits the declared body into the pair. Those four steps now live once (`overlayPairFixOf` and `overlayPairFixAdmits`, internal to the package), and each close keeps only its own difference: the named close reads no row and accepts the named package among several shippers; the package-less close requires exactly one shipper and classifies the stored row. No refusal, status, code or sentence changes, and no write that was open or closed before changes side. diff --git a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts index 410bf525f92..5956fab0741 100644 --- a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts +++ b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts @@ -339,6 +339,82 @@ export function namedBaseOverlayWriteRefusal( return closedOverlayWriteError(message); } +// ───────────────────────────────────────────────────────────────────────── +// ADR-0146 D1, D11 — "the fix the close names can be taken": one predicate +// ───────────────────────────────────────────────────────────────────────── + +/** The pair an overlay-write close sends the write into, and the `basedOn` its declaration records. */ +export interface OverlayPairFix { + readonly base: string; + readonly pair: string; + readonly basedOn: string; +} + +/** + * [ADR-0146 D1, D11] The registry half of whether the fix both overlay-write + * closes name — write it into `base`'s pair, declaring the override — can be + * taken, or `null` when it cannot: read from the steps that write takes. + * + * - the base has a pair id (`customizationPackageId`; an id ending in + * `.custom` has none); + * - the ensure door would answer or create the pair + * ({@link customizationPairEnsureRefusal}); + * - the base records a version (`basedOn`, its `manifest.version`, the value + * the ceremony records too). + * + * The other half is {@link overlayPairFixAdmits}, the override write gate on + * the declared body. Two halves of one predicate so that a close may read its + * own subject between them — the package-less close reads the item's stored + * rows only for a name the registry has not already left open — and ⛔ each + * close keeps only its own difference: which base it judges, and which body. + * {@link namedBaseCloseSubject} and `packagelessCloseSubject` + * (`protocol-packageless-overlay-migration.ts`) ask both. Registry reads only. + */ +export function overlayPairFixOf( + host: { readonly engine: ObjectStackProtocolImplementation['engine'] }, + base: string, +): OverlayPairFix | null { + const pairId = customizationPackageId(base); + if (!pairId.ok) return null; // no pair to name (the census's `no_pair`) + const pair = pairId.value; + const registry = (host.engine as { registry?: PairRegistry } | undefined)?.registry; + if (!registry || typeof registry.getPackage !== 'function') return null; + if (customizationPairEnsureRefusal(host.engine, registry, base, pair) !== undefined) return null; // the ensure door would refuse + const basedOn = registry.getPackage(base)?.manifest?.version; + if (typeof basedOn !== 'string' || basedOn === '') return null; // no `basedOn` to declare + return { base, pair, basedOn }; +} + +/** + * [ADR-0146 D1, D2, D11] The gate half of {@link overlayPairFixOf}: whether + * the override write gate admits the body the fix writes into the pair — the + * close's `body` (a save's, or the stored row's), else the item's bare + * identity at `member`, declaring `overrides: { package: base, basedOn }`. An + * `email_template` member at a locale the base does not ship is refused + * there, so its write stays open. + */ +export function overlayPairFixAdmits( + host: { readonly overrideWriteVerdict: (request: OverrideWriteSubject) => OverrideWriteVerdict }, + fix: OverlayPairFix, + subject: { + /** The singular type. */ + readonly type: string; + readonly name: string; + readonly body: Record | undefined; + /** The bundle member, as `OVERLAY_MEMBER_COLUMN` holds it. */ + readonly member: string | null | undefined; + }, +): boolean { + const { type, name, body, member } = subject; + const disc = member === undefined ? undefined : overlayMemberField(type); + const declaredBody = { + ...(body ?? { name, ...(disc && member !== null ? { [disc]: member } : {}) }), + overrides: { package: fix.base, basedOn: fix.basedOn }, + }; + const verdict = host.overrideWriteVerdict({ type, name, packageId: fix.pair, item: declaredBody }); + return verdict.verdict !== 'refuse'; +} + /** The class members {@link namedBaseCloseSubject} reads, typed as the class declares them. */ export interface NamedBaseCloseHost { readonly engine: ObjectStackProtocolImplementation['engine']; @@ -367,16 +443,15 @@ export interface NamedBaseWriteSubject { * overlay types — the package-less close's reading of "packaged" * (`overlayBaseShippers`, the reading `os migrate packageless-overlays` * classifies by), with the named package among the shippers — and where the - * fix the refusal names can be taken, read from the steps that write takes: + * fix the refusal names can be taken: {@link overlayPairFixOf} and + * {@link overlayPairFixAdmits}, the predicate the package-less close asks too + * (a pair id, the ensure door, a base version, the override write gate + * admitting the declared body — an `email_template` member at a locale the + * base does not ship stays open, as the package-less one does). * - * - the base has a pair id (`customizationPackageId`); - * - the ensure door would answer or create the pair - * ({@link customizationPairEnsureRefusal}); - * - the base records a version (`basedOn`, its `manifest.version`, the value - * the ceremony records too); - * - the override write gate admits the declared body into the pair (an - * `email_template` member at a locale the base does not ship is refused - * there, so its write stays open, as the package-less one does). + * This close's own difference: it reads no row (the declared body is the + * save's, else the item's identity), and the named package may be one of + * several shippers. * * A body carrying `overrides` is not judged here: the D2 gate answers a * declaration outside the pair, the schema gate a malformed one, as before. @@ -398,14 +473,8 @@ export function namedBaseCloseSubject( // a malformed one (`422 INVALID_METADATA`). if (isPlainRecord(request.item) && 'overrides' in request.item) return null; if (!this.overlayBaseShippers(request.type, request.name).includes(base)) return null; - const pairId = customizationPackageId(base); - if (!pairId.ok) return null; // no pair to name - const pair = pairId.value; - const registry = (this.engine as { registry?: PairRegistry } | undefined)?.registry; - if (!registry || typeof registry.getPackage !== 'function') return null; - if (customizationPairEnsureRefusal(this.engine, registry, base, pair) !== undefined) return null; - const basedOn = registry.getPackage(base)?.manifest?.version; - if (typeof basedOn !== 'string' || basedOn === '') return null; // no `basedOn` to declare + const fix = overlayPairFixOf(this, base); + if (fix === null) return null; // The gate's verdict on the declared body, written into the pair. const type = PLURAL_TO_SINGULAR[request.type] ?? request.type; @@ -413,14 +482,8 @@ export function namedBaseCloseSubject( const member = written !== undefined && request.member === undefined ? overlayMemberOfBody(type, written) : overlayMemberValue(type, request.member); - const disc = member === undefined ? undefined : overlayMemberField(type); - const declaredBody = { - ...(written ?? { name: request.name, ...(disc && member !== null ? { [disc]: member } : {}) }), - overrides: { package: base, basedOn }, - }; - const verdict = this.overrideWriteVerdict({ type, name: request.name, packageId: pair, item: declaredBody }); - if (verdict.verdict === 'refuse') return null; - return { base, pair }; + if (!overlayPairFixAdmits(this, fix, { type, name: request.name, body: written, member })) return null; + return { base, pair: fix.pair }; } // ───────────────────────────────────────────────────────────────────────── diff --git a/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts b/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts index c95c95c7e6c..6cf4c7f8b28 100644 --- a/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts +++ b/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts @@ -131,10 +131,15 @@ import { } from '@objectstack/spec/kernel'; import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared'; import { storedRowDocument } from './item-lock.js'; -import { OVERLAY_MEMBER_COLUMN, overlayMemberField, overlayMemberOfBody, overlayMemberValue } from './migrations/overlay-index.js'; +import { OVERLAY_MEMBER_COLUMN, overlayMemberOfBody, overlayMemberValue } from './migrations/overlay-index.js'; import type { ObjectStackProtocolImplementation } from './protocol.js'; -import { customizationPairEnsureRefusal, type PairRegistry } from './protocol-customization-package.js'; -import { overrideWriteMemberOf, type OverrideWriteSubject, type OverrideWriteVerdict } from './protocol-packaged-base-refusal.js'; +import { + overlayPairFixAdmits, + overlayPairFixOf, + overrideWriteMemberOf, + type OverrideWriteSubject, + type OverrideWriteVerdict, +} from './protocol-packaged-base-refusal.js'; import { readOnlyShippingPackages, shippingPackageIds } from './protocol-environment-rows.js'; import { isServedOverrideRowOf } from './served-override.js'; import { SysMetadataRepository } from './sys-metadata-repository.js'; @@ -710,14 +715,15 @@ export interface PackagelessWriteSubject { * read-only package ships). `shadowed` applies to a stored row only: a * first write has nothing in the pair to be shadowed by; * - its move must be able to succeed, read from the steps the ceremony - * takes: the ensure door would ensure the pair - * ({@link customizationPairEnsureRefusal}), the base records a version - * (`basedOn`), and the override write gate admits the declared body into - * the pair. An `email_template` member at a locale the base does not ship - * is refused there (#28777) and stays open: the ceremony moves such a row - * as the pair's own member, declaring nothing (#28904), and the close - * reaches only the declared-override moves, so its package-less write is - * the one it had before the close. + * takes: `overlayPairFixOf` and `overlayPairFixAdmits` + * (`protocol-packaged-base-refusal.ts`), the predicate the close of the + * write naming the base asks too — the ensure door would ensure the pair, + * the base records a version (`basedOn`), and the override write gate + * admits the declared body into the pair. An `email_template` member at a + * locale the base does not ship is refused there (#28777) and stays open: + * the ceremony moves such a row as the pair's own member, declaring + * nothing (#28904), and the close reaches only the declared-override + * moves, so its package-less write is the one it had before the close. * * Registry reads first; the one store read (the item's rows) only for a name * the registry has not already left open. @@ -731,14 +737,9 @@ export async function packagelessCloseSubject( const shippers = this.overlayBaseShippers(request.type, request.name); if (shippers.length !== 1) return null; // environment metadata, or `multi_shipped` const base = shippers[0]!; - const pairId = customizationPackageId(base); - if (!pairId.ok) return null; // `no_pair` - const pair = pairId.value; - const registry = (this.engine as { registry?: PairRegistry } | undefined)?.registry; - if (!registry || typeof registry.getPackage !== 'function') return null; - if (customizationPairEnsureRefusal(this.engine, registry, base, pair) !== undefined) return null; // step 1 fails - const basedOn = baseVersionOf(this, base); - if (basedOn === undefined) return null; // no `basedOn` to record + // `no_pair`, a pair step 1 cannot ensure, or no `basedOn` to record. + const fix = overlayPairFixOf(this, base); + if (fix === null) return null; // The item's stored rows, both spellings, every package: the census's read. const spellings = [...new Set([type, SINGULAR_TO_PLURAL[type] ?? type])]; @@ -768,12 +769,6 @@ export async function packagelessCloseSubject( if (classified.disposition !== 'movable') return null; // Step 2's gate: the declared body, into the pair. - const disc = member === undefined ? undefined : overlayMemberField(type); - const declaredBody = { - ...(body ?? { name: request.name, ...(disc && member !== null ? { [disc]: member } : {}) }), - overrides: { package: base, basedOn }, - }; - const verdict = this.overrideWriteVerdict({ type, name: request.name, packageId: pair, item: declaredBody }); - if (verdict.verdict === 'refuse') return null; - return { base, pair }; + if (!overlayPairFixAdmits(this, fix, { type, name: request.name, body, member })) return null; + return { base, pair: fix.pair }; } diff --git a/packages/metadata-protocol/src/protocol.named-base-overlay-write.test.ts b/packages/metadata-protocol/src/protocol.named-base-overlay-write.test.ts index 4a79bb8f1c1..5ea6dbf4b8e 100644 --- a/packages/metadata-protocol/src/protocol.named-base-overlay-write.test.ts +++ b/packages/metadata-protocol/src/protocol.named-base-overlay-write.test.ts @@ -52,12 +52,20 @@ const BASE_VERSION = '3.2.0'; const NO_PAIR_BASE = 'com.legacy.app'; /** A writable runtime package: no manifest booted it. */ const WRITABLE = 'com.acme.workspace'; +/** A read-only code package whose id forms no pair: an id ending in `.custom` (ADR-0146 D3). */ +const NO_PAIR_ID_BASE = 'com.vendor.custom'; +/** A read-only code package that records no version, with its pair already installed. */ +const NO_VERSION_BASE = 'com.nover.app'; +const NO_VERSION_PAIR = 'com.nover.app.custom'; const MANIFESTS: Record> = { [BASE]: { dependencies: {}, version: BASE_VERSION, namespace: 'acme' }, [PAIR]: { dependencies: { [BASE]: '^3' }, version: '0.1.0', namespace: 'acme_custom' }, [NO_PAIR_BASE]: { dependencies: {}, version: '1.0.0' }, [WRITABLE]: { dependencies: {} }, + [NO_PAIR_ID_BASE]: { dependencies: {}, version: '1.0.0', namespace: 'vendor' }, + [NO_VERSION_BASE]: { dependencies: {}, namespace: 'nover' }, + [NO_VERSION_PAIR]: { dependencies: { [NO_VERSION_BASE]: '*' }, version: '0.1.0', namespace: 'nover_custom' }, }; /** One item of each overlay type the base ships, and a body a write carries. */ @@ -78,6 +86,8 @@ const VIEW = ITEMS[0]!; const SHIPPED = [ ...ITEMS.map((i) => ({ type: i.type, name: i.name, pkg: BASE })), { type: 'dashboard', name: 'legacy_board', pkg: NO_PAIR_BASE }, + { type: 'dashboard', name: 'vendor_board', pkg: NO_PAIR_ID_BASE }, + { type: 'dashboard', name: 'nover_board', pkg: NO_VERSION_BASE }, ]; const PACKAGED = 'Packaged'; @@ -109,7 +119,7 @@ function makeProtocol() { const packageRecord = (id: string) => (MANIFESTS[id] ? { manifest: { id, ...MANIFESTS[id] } } : undefined); const engine: any = { // A booted code package is read-only (`isWritablePackage` reads `manifests` first). - manifests: new Map([[BASE, {}], [NO_PAIR_BASE, {}]]), + manifests: new Map([[BASE, {}], [NO_PAIR_BASE, {}], [NO_PAIR_ID_BASE, {}], [NO_VERSION_BASE, {}]]), async find(t: string, q?: { where?: Record; limit?: number }) { const rows = table(t).filter((r) => matches(r, q?.where)); return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; @@ -377,6 +387,52 @@ describe('CONTROLS: the writes the close leaves open, as on main', () => { * the commit CREATED is reverted by removing the row, which writes nothing and * stays open. */ +/** + * Each step of "the fix the refusal names can be taken" (the pair id, the + * ensure door, the base's version, the override write gate admitting the + * declared body) leaves the write naming the base open where it fails: the + * refusal would name a door the author cannot take. The ensure door's step is + * the CONTROL above (`a read-only base whose pair cannot be ensured`); a + * shipped member's closed save is the five-type case at the top. + */ +describe('CONTROLS: every step the fix takes, failing, leaves the write naming the base open (ADR-0146 D1, D11)', () => { + it('a read-only base whose id forms no pair (an id ending in `.custom`) keeps the write naming it: there is no pair to name', async () => { + const { protocol, rows } = makeProtocol(); + + const saved = await protocol.saveMetaItem({ + type: 'dashboard', name: 'vendor_board', item: { name: 'vendor_board', label: 'Vendor', widgets: [] }, packageId: NO_PAIR_ID_BASE, + }); + + expect(saved).toMatchObject({ success: true }); + expect(rows('dashboard', 'vendor_board')).toEqual([{ package_id: NO_PAIR_ID_BASE, state: 'active', label: 'Vendor' }]); + }); + + it('a read-only base that records no version, its pair installed, keeps the write naming it: there is no `basedOn` to declare', async () => { + // The ensure door answers an installed pair without reading the base's + // version, so this is the version step's own case. + const { protocol, rows } = makeProtocol(); + + const saved = await protocol.saveMetaItem({ + type: 'dashboard', name: 'nover_board', item: { name: 'nover_board', label: 'Nover', widgets: [] }, packageId: NO_VERSION_BASE, + }); + + expect(saved).toMatchObject({ success: true }); + expect(rows('dashboard', 'nover_board')).toEqual([{ package_id: NO_VERSION_BASE, state: 'active', label: 'Nover' }]); + }); + + it('an email template at a locale the base does not ship keeps the write naming the base: the override write gate refuses its declared body', async () => { + const { protocol, rows } = makeProtocol(); + const email = ITEMS.find((i) => i.type === 'email_template')!; + + const saved = await protocol.saveMetaItem({ + type: email.type, name: email.name, item: { ...email.body('Bienvenue'), locale: 'fr-FR' }, packageId: BASE, + }); + + expect(saved).toMatchObject({ success: true }); + expect(rows(email.type, email.name)).toEqual([{ package_id: BASE, state: 'active', label: 'Bienvenue' }]); + }); +}); + describe('the commit doors: restoring a row bound to the base is closed, removing one stays open (#29390)', () => { /** Each commit a second apart, so the package rollback's "strictly newer" reads an order. */ let clock = Date.parse('2026-11-02T00:00:00.000Z');