From f1dfda02394eed7c4a28a48a3275103c279b1d5a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 12:10:31 +0000 Subject: [PATCH 01/15] feat(metadata-core): appOf resolves a package to the App that carries it A member package's pair is now its App's pair, read from the installed manifests (ADR-0146 D3, merge question 11). pairsOf/isPairOf keep a per-package pair an earlier version created served while it is installed (merge question 12). Kernel packages keep their own answer. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2dM9PvPPB9BMVdhf2R --- .../src/customization-pair-lookup.test.ts | 178 +++++++++++++----- .../src/customization-pair-lookup.ts | 163 +++++++++++++--- 2 files changed, 263 insertions(+), 78 deletions(-) diff --git a/packages/metadata-core/src/customization-pair-lookup.test.ts b/packages/metadata-core/src/customization-pair-lookup.test.ts index edb009b539f..fa0d94ddc2e 100644 --- a/packages/metadata-core/src/customization-pair-lookup.test.ts +++ b/packages/metadata-core/src/customization-pair-lookup.test.ts @@ -7,69 +7,95 @@ import { installedManifestsOf, isPairOf, pairOf, + pairsOf, type InstalledManifestLookup, } from './customization-pair-lookup.js'; /** - * The lookup answers exactly what the pure id rule answered for every package - * shape on `main` — an App, a module its artifact brings, a standalone - * package, a `sys` kernel package, a manifest with no namespace — because - * `appOf` is the identity until the ensure door resolves a member to its App. - * Each row pins the literal pair, so a resolver that moves one package to - * another App reds that row by name. Every row is asked twice: with nothing - * installed, and with all the rows installed — the module declaring its App - * the way HotCRM's modules do — so the lookup the callers now hand in is - * proven to move nothing while `appOf` stays the identity. + * The App lookup over the package shapes ADR-0146 D3 merge question 11 names. + * Every row is asked twice: with nothing installed — every package is then + * absent, so its own unit, and the lookup answers exactly the old per-package + * derivation — and with every row installed, where a member answers its App's + * pair. Each row pins the literal pair, so a resolver that moves a package to + * another App reds that row by name. */ -const ROWS: ReadonlyArray<{ - shape: string; - manifest: { id: string; type?: string; scope?: string; namespace?: string; dependencies?: Record }; - pair: string; -}> = [ +type Manifest = { id: string; type?: string; scope?: string; namespace?: string; dependencies?: Record }; +const ROWS: ReadonlyArray<{ shape: string; manifest: Manifest; app: string | undefined }> = [ { shape: 'an App package', manifest: { id: 'app.objectstack.hotcrm', type: 'app', namespace: 'crm' }, - pair: 'app.objectstack.hotcrm.custom', + app: 'app.objectstack.hotcrm', }, { - shape: 'a module an App artifact brings (still its own pair today)', - manifest: { - id: 'app.objectstack.hotcrm.service', - type: 'module', - namespace: 'crm', - dependencies: { 'app.objectstack.hotcrm': '^6.1.0' }, - }, - pair: 'app.objectstack.hotcrm.service.custom', + shape: 'a module declaring its App (HotCRM)', + manifest: { id: 'app.objectstack.hotcrm.service', type: 'module', namespace: 'crm', dependencies: { 'app.objectstack.hotcrm': '^6.1.0' } }, + app: 'app.objectstack.hotcrm', }, { - shape: 'a standalone package', + shape: 'a module declaring its App and a kernel package', + manifest: { id: 'app.objectstack.hotcrm.o2c', type: 'module', namespace: 'crm', dependencies: { 'app.objectstack.hotcrm': '^6.1.0', 'com.objectstack.audit': '^1' } }, + app: 'app.objectstack.hotcrm', + }, + { + shape: 'a module reaching its App through a declared module', + manifest: { id: 'app.objectstack.hotcrm.service.sla', type: 'module', namespace: 'crm', dependencies: { 'app.objectstack.hotcrm.service': '^6' } }, + app: 'app.objectstack.hotcrm', + }, + { + shape: 'a plugin declaring two Apps, one holding its namespace', + manifest: { id: 'com.acme.bridge', type: 'plugin', namespace: 'crm', dependencies: { 'app.objectstack.hotcrm': '^6', 'com.example.showcase': '^1' } }, + app: 'app.objectstack.hotcrm', + }, + { + shape: 'a plugin declaring two Apps, neither holding its namespace', + manifest: { id: 'com.acme.glue', type: 'plugin', namespace: 'glue', dependencies: { 'app.objectstack.hotcrm': '^6', 'com.example.showcase': '^1' } }, + app: undefined, + }, + { + shape: 'a standalone App', manifest: { id: 'com.example.showcase', type: 'app', namespace: 'showcase' }, - pair: 'com.example.showcase.custom', + app: 'com.example.showcase', + }, + { + shape: 'a plugin declaring no App (its own unit)', + manifest: { id: 'com.acme.solo', type: 'plugin', namespace: 'solo', dependencies: { 'com.acme.ring': '^1' } }, + app: 'com.acme.solo', + }, + { + shape: 'a module in a declared cycle that reaches no App', + manifest: { id: 'com.acme.ring', type: 'module', namespace: 'ring', dependencies: { 'com.acme.solo': '^1' } }, + app: 'com.acme.ring', + }, + { + shape: 'a module declaring an App that is not installed', + manifest: { id: 'com.acme.orphan', type: 'module', namespace: 'orph', dependencies: { 'com.acme.absent': '^1' } }, + app: 'com.acme.orphan', }, { shape: 'a sys kernel package', manifest: { id: 'com.objectstack.plugin-auth', type: 'plugin', scope: 'system', namespace: 'sys' }, - pair: 'com.objectstack.plugin-auth.custom', + app: 'com.objectstack.plugin-auth', + }, + { + shape: 'a kernel package declaring an App (kernel keeps its own answer)', + manifest: { id: 'com.objectstack.audit', type: 'plugin', scope: 'cloud', namespace: 'sys', dependencies: { 'app.objectstack.hotcrm': '^6' } }, + app: 'com.objectstack.audit', }, { shape: 'a manifest with no namespace', manifest: { id: 'hotcrm', type: 'app' }, - pair: 'hotcrm.custom', + app: 'hotcrm', }, ]; const NOTHING_INSTALLED = installedManifestsOf(undefined); -const ALL_INSTALLED = installedManifestsOf({ +const lookupOf = (manifests: readonly Manifest[]): InstalledManifestLookup => installedManifestsOf({ getPackage: (id: string) => { - const row = ROWS.find((r) => r.manifest.id === id); - return row ? { manifest: row.manifest } : undefined; + const manifest = manifests.find((m) => m.id === id); + return manifest ? { manifest } : undefined; }, }); -const LOOKUPS: ReadonlyArray<{ installed: string; lookup: InstalledManifestLookup }> = [ - { installed: 'nothing installed', lookup: NOTHING_INSTALLED }, - { installed: 'every row installed', lookup: ALL_INSTALLED }, -]; -const ROWS_BY_LOOKUP = LOOKUPS.flatMap(({ installed, lookup }) => ROWS.map((row) => ({ ...row, installed, lookup }))); +const ALL_INSTALLED = lookupOf(ROWS.map((r) => r.manifest)); describe('installedManifestsOf — the registry read the lookup is', () => { it('reads the installed manifest by id, and nothing for an id not installed', () => { @@ -90,11 +116,27 @@ describe('installedManifestsOf — the registry read the lookup is', () => { }); }); -describe('pairOf — the pair of a package, looked up through its App', () => { - it.each(ROWS_BY_LOOKUP)('$shape, $installed: answers the old derivation, $pair', ({ manifest, pair, lookup }) => { - expect(appOf(manifest.id, lookup)).toBe(manifest.id); - expect(pairOf(manifest.id, lookup)).toEqual(customizationPackageId(manifest.id)); - expect(pairOf(manifest.id, lookup)).toEqual({ ok: true, value: pair }); +describe('appOf / pairOf — the pair of a package is the pair of its App (ADR-0146 D3 merge question 11)', () => { + it.each(ROWS)('$shape, nothing installed: its own unit, the old derivation', ({ manifest }) => { + expect(appOf(manifest.id, NOTHING_INSTALLED)).toBe(manifest.id); + expect(pairOf(manifest.id, NOTHING_INSTALLED)).toEqual(customizationPackageId(manifest.id)); + }); + + it.each(ROWS)('$shape, every row installed: App $app', ({ manifest, app }) => { + expect(appOf(manifest.id, ALL_INSTALLED)).toBe(app); + expect(pairOf(manifest.id, ALL_INSTALLED)).toEqual(app === undefined + ? { ok: false, reason: 'no_single_app' } + : { ok: true, value: `${app}.custom` }); + }); + + it('HotCRM: the App and its four modules have one pair', () => { + const app = { id: 'app.objectstack.hotcrm', type: 'app', namespace: 'crm' }; + const modules = ['service', 'revenue', 'marketing', 'o2c'].map((m) => ({ + id: `app.objectstack.hotcrm.${m}`, type: 'module', namespace: 'crm', dependencies: { 'app.objectstack.hotcrm': '^6.1.0' }, + })); + const installed = lookupOf([app, ...modules]); + const pairs = new Set([app, ...modules].map((m) => pairOf(m.id, installed)).map((p) => (p.ok ? p.value : p.reason))); + expect([...pairs]).toEqual(['app.objectstack.hotcrm.custom']); }); it.each([ @@ -104,26 +146,60 @@ describe('pairOf — the pair of a package, looked up through its App', () => { { input: 'hotcrm.custom', reason: 'base_is_customization_package' }, { input: 'Com.Acme', reason: 'base_id_invalid' }, ])('refuses $input as the id rule does: $reason', ({ input, reason }) => { - for (const { lookup } of LOOKUPS) { + for (const lookup of [NOTHING_INSTALLED, ALL_INSTALLED]) { expect(pairOf(input, lookup)).toEqual(customizationPackageId(input)); expect(pairOf(input, lookup)).toEqual({ ok: false, reason }); } }); + + it('a pair id answers itself, installed or not', () => { + const pair = { id: 'app.objectstack.hotcrm.custom', type: 'module', namespace: 'crm_custom', dependencies: { 'app.objectstack.hotcrm': '^6' } }; + const installed = lookupOf([...ROWS.map((r) => r.manifest), pair]); + expect(appOf(pair.id, installed)).toBe(pair.id); + expect(pairOf(pair.id, installed)).toEqual({ ok: false, reason: 'base_is_customization_package' }); + }); }); -describe('isPairOf — is this THE pair of that package', () => { - it.each(ROWS_BY_LOOKUP)('$shape, $installed: only $pair is its pair', ({ manifest, pair, lookup }) => { - const candidates = [pair, manifest.id, `${pair}.custom`, 'app.objectstack.hotcrm.custom', 'partner.ext', null, undefined]; +describe('isPairOf / pairsOf — which packages hold a package\'s customizations', () => { + it.each(ROWS)('$shape, nothing installed: only the old derivation is its pair', ({ manifest }) => { + const own = customizationPackageId(manifest.id); + const candidates = [own.ok ? own.value : null, manifest.id, 'app.objectstack.hotcrm.custom', 'partner.ext', null, undefined]; for (const candidate of candidates) { - expect(isPairOf(candidate, manifest.id, lookup)).toBe(isCustomizationPairOf(candidate, manifest.id)); + expect(isPairOf(candidate, manifest.id, NOTHING_INSTALLED)).toBe(isCustomizationPairOf(candidate, manifest.id)); } - expect(isPairOf(pair, manifest.id, lookup)).toBe(true); - expect(isPairOf(manifest.id, manifest.id, lookup)).toBe(false); }); - it.each(LOOKUPS)('$installed: a pair has no pair, and a missing base has none', ({ lookup }) => { - expect(isPairOf('hotcrm.custom.custom', 'hotcrm.custom', lookup)).toBe(false); - expect(isPairOf('.custom', '', lookup)).toBe(false); - expect(isPairOf('x.custom', undefined, lookup)).toBe(false); + it('a member: its App\'s pair is its pair; its own derivation is not, while nothing holds it', () => { + const service = 'app.objectstack.hotcrm.service'; + expect(pairsOf(service, ALL_INSTALLED)).toEqual(['app.objectstack.hotcrm.custom']); + expect(isPairOf('app.objectstack.hotcrm.custom', service, ALL_INSTALLED)).toBe(true); + expect(isPairOf(`${service}.custom`, service, ALL_INSTALLED)).toBe(false); + expect(isPairOf(service, service, ALL_INSTALLED)).toBe(false); + }); + + it('a member: the per-package pair an earlier version created stays its pair while installed (merge question 12)', () => { + const service = 'app.objectstack.hotcrm.service'; + const legacy = { id: `${service}.custom`, type: 'module', namespace: 'crm_custom', dependencies: { [service]: '^6' } }; + const installed = lookupOf([...ROWS.map((r) => r.manifest), legacy]); + expect(pairOf(service, installed)).toEqual({ ok: true, value: 'app.objectstack.hotcrm.custom' }); + expect(pairsOf(service, installed)).toEqual(['app.objectstack.hotcrm.custom', legacy.id]); + expect(isPairOf(legacy.id, service, installed)).toBe(true); + // …and of its own member only: the App and a sibling do not read it. + expect(isPairOf(legacy.id, 'app.objectstack.hotcrm', installed)).toBe(false); + expect(isPairOf(legacy.id, 'app.objectstack.hotcrm.o2c', installed)).toBe(false); + }); + + it('a package with no single App: no pair to land in, a legacy pair still served', () => { + expect(pairsOf('com.acme.glue', ALL_INSTALLED)).toEqual([]); + const legacy = { id: 'com.acme.glue.custom', type: 'module', namespace: 'glue_custom', dependencies: { 'com.acme.glue': '^1' } }; + expect(pairsOf('com.acme.glue', lookupOf([...ROWS.map((r) => r.manifest), legacy]))).toEqual([legacy.id]); + }); + + it('a pair has no pair, and a missing base has none', () => { + for (const lookup of [NOTHING_INSTALLED, ALL_INSTALLED]) { + expect(isPairOf('hotcrm.custom.custom', 'hotcrm.custom', lookup)).toBe(false); + expect(isPairOf('.custom', '', lookup)).toBe(false); + expect(isPairOf('x.custom', undefined, lookup)).toBe(false); + } }); }); diff --git a/packages/metadata-core/src/customization-pair-lookup.ts b/packages/metadata-core/src/customization-pair-lookup.ts index 77f87a5e289..16a7bd55e08 100644 --- a/packages/metadata-core/src/customization-pair-lookup.ts +++ b/packages/metadata-core/src/customization-pair-lookup.ts @@ -25,13 +25,27 @@ * * Every question carries the caller's {@link InstalledManifestLookup} — the * installed manifests of the registry it serves ({@link installedManifestsOf}), - * the only record of which App a member belongs to. Today {@link appOf} reads - * none of it and is the identity: every package is its own App for - * customization, so {@link pairOf} answers exactly what - * `customizationPackageId` answers, refusals included. Resolving a member - * package to its App from that lookup is the next change (ADR-0146 D3 as - * amended, merge question 11), and it lands in {@link appOf} alone because - * every caller already hands the lookup in. + * the only record of which App a member belongs to. {@link appOf} reads it the + * way ADR-0146 D3 merge question 11 says: a `type: 'app'` package is its own + * App; any other package's App is the installed `type: 'app'` package it + * declares in `manifest.dependencies`, directly or through its declared + * parents; a package that declares none is its own unit. A kernel package + * (scope `system` / `cloud`) is not resolved here: its pair would be the + * platform pair, which no door creates in this version (ruling record + * 6691217245), so it keeps the per-package answer its legacy pair is served + * under (merge question 12). + * + * Two questions, kept apart: + * + * - {@link pairOf} — where a customization of the package LANDS: its App's + * pair. Every door that writes or names "the pair" asks it. + * - {@link isPairOf} / {@link pairsOf} — which packages' rows ARE the + * package's customizations: its App's pair and, while one is installed, + * the per-package pair an earlier version created for it (`.custom` + * of a member — ADR-0146 D3 merge question 12: such a pair is served as it + * is until the migration ceremony re-identifies it, so nothing a customer + * built goes dark on upgrade). Every read that serves or lists a package's + * customizations asks these. * * Why this package: every caller that asks the question — * `@objectstack/metadata-protocol`, `@objectstack/objectql`, @@ -44,6 +58,7 @@ import { customizationPackageId, + isCustomizationPackage, type CustomizationDerivation, type CustomizationPackageIdRefusal, } from '@objectstack/spec/kernel'; @@ -93,35 +108,129 @@ export function installedManifestsOf(registry: InstalledPackageSource | null | u } /** - * The App that carries `packageId` for customization: the id whose pair holds - * every customization of `packageId`'s items, read through `installed` (the - * registry the caller serves). + * The manifest scopes of a kernel package — the platform's own (`system`) and + * the control plane's (`cloud`). The same list as + * `@objectstack/metadata-protocol`'s `READ_ONLY_PACKAGE_SCOPES`, which depends + * on this package and so cannot be imported here; a pin in that package holds + * the two equal. + */ +const KERNEL_PACKAGE_SCOPES: readonly string[] = ['system', 'cloud']; + +/** Why {@link appOf} names no App: the package reaches several, and its namespace picks none or more than one. */ +export type AppOfRefusal = 'no_single_app'; + +/** Why {@link pairOf} names no pair: the id rule's refusal, or {@link AppOfRefusal}. */ +export type PairOfRefusal = CustomizationPackageIdRefusal | AppOfRefusal; + +function isKernelManifest(manifest: AppLookupManifest): boolean { + return typeof manifest.scope === 'string' && KERNEL_PACKAGE_SCOPES.includes(manifest.scope); +} + +function declaredDependencyIds(manifest: AppLookupManifest): string[] { + const dependencies = manifest.dependencies; + if (typeof dependencies !== 'object' || dependencies === null || Array.isArray(dependencies)) return []; + return Object.keys(dependencies); +} + +/** + * The installed `type: 'app'` packages `manifest` declares, directly — or, + * when it declares none directly, through the closure of its declared + * dependencies (ADR-0146 D3 merge question 11): each package visited once, so + * a cycle ends; the walk stops at an App (an App's own dependencies are + * another App's business, ADR-0141 D1); a kernel package or a package not + * installed is skipped. + */ +function declaredApps(packageId: string, manifest: AppLookupManifest, installed: InstalledManifestLookup): string[] { + const isApp = (id: string): boolean => { + const m = installed(id); + return m !== undefined && m.type === 'app' && !isKernelManifest(m); + }; + const direct = declaredDependencyIds(manifest).filter(isApp); + if (direct.length > 0) return direct; + const apps: string[] = []; + const visited = new Set([packageId]); + const queue = declaredDependencyIds(manifest); + while (queue.length > 0) { + const id = queue.shift()!; + if (visited.has(id)) continue; + visited.add(id); + const m = installed(id); + if (m === undefined || isKernelManifest(m)) continue; + if (m.type === 'app') { + apps.push(id); + continue; + } + queue.push(...declaredDependencyIds(m)); + } + return apps; +} + +/** + * The App that carries `packageId` for customization — the id whose pair + * holds every customization of `packageId`'s items — read through + * `installed` (the registry the caller serves), or `undefined` when the + * package reaches several Apps and its namespace picks no single one of them + * (ADR-0146 D3 merge question 11: the readable verdict, never a guess and + * never an id prefix). * - * Today the identity — every package is its own App for this purpose, and - * `installed` is not read yet. The lookup is the seam the App-unit ruling - * moves; its callers never assume the identity, and each already hands it the - * registry it will read. + * | package | App | + * |:--|:--| + * | not installed, or a pair id | itself | + * | `type: 'app'` | itself | + * | a kernel package (scope `system` / `cloud`) | itself — see the module header | + * | declares one installed App, directly or through its declared parents | that App | + * | declares several | the one holding the package's namespace; none or several ⇒ `undefined` | + * | declares none | itself — its own unit | */ -export function appOf(packageId: string, installed: InstalledManifestLookup): string { - void installed; - return packageId; +export function appOf(packageId: string, installed: InstalledManifestLookup): string | undefined { + const manifest = installed(packageId); + if (manifest === undefined || isCustomizationPackage(packageId)) return packageId; + if (manifest.type === 'app' || isKernelManifest(manifest)) return packageId; + const apps = declaredApps(packageId, manifest, installed); + if (apps.length === 0) return packageId; + if (apps.length === 1) return apps[0]; + const namespace = manifest.namespace; + const holding = apps.filter((id) => typeof namespace === 'string' && installed(id)?.namespace === namespace); + return holding.length === 1 ? holding[0] : undefined; } /** - * The paired customization package of `base`: the pair of the App that carries - * it, or the refusal `customizationPackageId` names (`base_id_missing`, - * `base_is_customization_package`, `base_id_invalid`). + * The paired customization package of `base` — where a customization of its + * items lands: the pair of the App that carries it, or the refusal + * `customizationPackageId` names (`base_id_missing`, + * `base_is_customization_package`, `base_id_invalid`), or `no_single_app` + * ({@link appOf}). */ -export function pairOf(base: unknown, installed: InstalledManifestLookup): CustomizationDerivation { - return customizationPackageId(typeof base === 'string' && base.length > 0 ? appOf(base, installed) : base); +export function pairOf(base: unknown, installed: InstalledManifestLookup): CustomizationDerivation { + if (typeof base !== 'string' || base.length === 0) return customizationPackageId(base); + const own = customizationPackageId(base); + if (!own.ok) return own; + const app = appOf(base, installed); + if (app === undefined) return { ok: false, reason: 'no_single_app' }; + return customizationPackageId(app); } /** - * Whether `pairId` is THE paired customization package of `base` — the only - * package that declares an override of `base`'s items (ADR-0146, ruling A on - * its amendment card). + * Every package whose rows are `base`'s customizations: its App's pair + * ({@link pairOf}) first, then — while it is installed and is not that pair — + * the per-package pair `.custom` an earlier version created for a + * member (ADR-0146 D3 merge question 12: served as it is until the migration + * ceremony re-identifies it). Empty when `base` has no pair of either kind. */ -export function isPairOf(pairId: unknown, base: unknown, installed: InstalledManifestLookup): boolean { +export function pairsOf(base: unknown, installed: InstalledManifestLookup): string[] { + const pairs: string[] = []; const pair = pairOf(base, installed); - return pair.ok && pair.value === pairId; + if (pair.ok) pairs.push(pair.value); + const legacy = customizationPackageId(base); + if (legacy.ok && !pairs.includes(legacy.value) && installed(legacy.value) !== undefined) pairs.push(legacy.value); + return pairs; +} + +/** + * Whether `pairId` is a paired customization package of `base` — a package + * that may declare an override of `base`'s items (ADR-0146, ruling A on its + * amendment card): one of {@link pairsOf}. + */ +export function isPairOf(pairId: unknown, base: unknown, installed: InstalledManifestLookup): boolean { + return typeof pairId === 'string' && pairsOf(base, installed).includes(pairId); } From 9ceb1f475f518d8efea9b823fb4d234f5e179e50 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 12:15:07 +0000 Subject: [PATCH 02/15] wip: ensure door answers the App pair; reads serve legacy per-package pairs Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2dM9PvPPB9BMVdhf2R --- packages/lint/package.json | 1 + .../src/validate-customization-namespace.ts | 20 +++- .../src/protocol-customization-package.ts | 104 +++++++++++++----- .../src/protocol-discovery-and-bases.ts | 11 +- .../src/protocol-environment-rows.ts | 5 +- .../src/protocol-meta-layered-reads.ts | 13 ++- .../src/protocol-meta-list-reads.ts | 21 ++-- .../src/protocol-overlay-merge.ts | 12 +- .../src/protocol-packaged-base-refusal.ts | 15 ++- .../src/protocol-served-overrides.ts | 10 +- .../src/protocol-write-package-scope.ts | 52 ++++++++- packages/metadata-protocol/src/protocol.ts | 26 +++-- packages/objectql/src/registry.ts | 23 ++-- pnpm-lock.yaml | 3 + 14 files changed, 227 insertions(+), 89 deletions(-) diff --git a/packages/lint/package.json b/packages/lint/package.json index 050ed2726ee..cc5ef5ba115 100644 --- a/packages/lint/package.json +++ b/packages/lint/package.json @@ -51,6 +51,7 @@ }, "dependencies": { "@objectstack/formula": "workspace:*", + "@objectstack/metadata-core": "workspace:*", "@objectstack/sdui-parser": "workspace:*", "@objectstack/spec": "workspace:*", "ajv": "^8.20.0", diff --git a/packages/lint/src/validate-customization-namespace.ts b/packages/lint/src/validate-customization-namespace.ts index 3c04712027c..09c6a6070ba 100644 --- a/packages/lint/src/validate-customization-namespace.ts +++ b/packages/lint/src/validate-customization-namespace.ts @@ -5,8 +5,9 @@ * customization namespace — so its installers can never customize it — is * told so while it is still being authored. * - * Every installed base gets one paired writable package, `.custom`, the - * first time an installer customizes it; its namespace is the base's followed + * Every installed App gets one paired writable package, `.custom`, + * the first time an installer customizes any of its packages (ADR-0146 D3 as + * amended, ruling record 6691022506); its namespace is the App's followed * by a suffix, and a base namespace too long for that suffix gets NO pair * namespace (the #28404 ruling, point 1: a named refusal, no truncation and no * hashing). The ensure door then refuses to create the pair, so the author of @@ -36,6 +37,10 @@ * - A package that is itself a pair (`isCustomizationPackage`, the one * spelling of "some base derives this id"): a pair is never paired again, and its * namespace is the pair namespace, longer than a base's by construction. + * - A member of a delivered App (`appOf`, `@objectstack/metadata-core`, read + * over the delivered manifests): its customizations land in the App's pair, + * whose namespace is the App's, so its own namespace has no pair to fit — + * the App's row is judged instead. * - The helper's other two refusals. A missing namespace is not this rule's * question, and a namespace the manifest grammar refuses for another reason * (an uppercase letter, a leading digit) is `os validate`'s refusal, with @@ -55,6 +60,7 @@ import { customizationNamespace, isCustomizationPackage, } from '@objectstack/spec/kernel'; +import { appOf, installedManifestsOf } from '@objectstack/metadata-core'; import type { AnyRec } from './page-walk.js'; @@ -104,8 +110,16 @@ export function validateCustomizationNamespace(stack: AnyRec): CustomizationName const findings: CustomizationNamespaceFinding[] = []; if (!isRec(stack)) return findings; - for (const { manifest, at } of deliveredManifests(stack)) { + const delivered = deliveredManifests(stack); + const installed = installedManifestsOf({ + getPackage: (id: string) => { + const found = delivered.find(({ manifest }) => manifest.id === id); + return found ? { manifest: found.manifest } : undefined; + }, + }); + for (const { manifest, at } of delivered) { if (isCustomizationPackage(manifest.id)) continue; + if (typeof manifest.id === 'string' && manifest.id !== '' && appOf(manifest.id, installed) !== manifest.id) continue; const pairNamespace = customizationNamespace(manifest.namespace); if (pairNamespace.ok || pairNamespace.reason !== 'base_namespace_too_long') continue; diff --git a/packages/metadata-protocol/src/protocol-customization-package.ts b/packages/metadata-protocol/src/protocol-customization-package.ts index 34444f6fcb5..8f868ff7c6b 100644 --- a/packages/metadata-protocol/src/protocol-customization-package.ts +++ b/packages/metadata-protocol/src/protocol-customization-package.ts @@ -7,6 +7,13 @@ * * ## The door * + * The unit is the App (ADR-0146 D3 as amended, ruling record 6691022506): the + * pair of a base is the pair of the App that carries it ({@link pairOf}, + * `@objectstack/metadata-core`), so the door asked for any package of an App + * answers that App's one pair, `.custom` — creating it on the first + * ask and answering it after, never `422` for a package of the same App. A + * package that declares no App is its own unit, as before. + * * {@link ensureCustomizationPackage} is the body of * `ObjectStackProtocolImplementation#ensureCustomizationPackage`, served over * HTTP as `POST /api/v1/packages/:id/ensure-customization` @@ -15,17 +22,18 @@ * the pair (ADR-0146 D3: "no second door builds the pair"), and the one Studio, * the clone door and the migration call on a customer's first customization. * - * Everything the pair is made of is the base's, through the pairing rules of - * `@objectstack/spec/kernel` (`customization-package.ts`), ⛔ never a local - * spelling: + * Everything the pair is made of is its App's — the asked base's App, which + * is the base itself for an App or an own-unit package — through the pairing + * rules of `@objectstack/spec/kernel` (`customization-package.ts`), ⛔ never a + * local spelling: * - * - id — `customizationPackageId`, asked through {@link pairOf}: `.custom`; - * - namespace — {@link customizationNamespace}: `_custom`, SET + * - id — `customizationPackageId`, asked through {@link pairOf}: `.custom`; + * - namespace — {@link customizationNamespace}: `_custom`, SET * EXPLICITLY on the manifest so `installPackage`'s id-derived default * (`deriveNamespaceFromPackageId('hotcrm.custom')` = `custom`) never runs * (ADR-0146 D3, Q4 「全部同意」); - * - dependency — {@link customizationDependencyRange}: `^` of the base's - * `manifest.version` at creation, declared on the base in + * - dependency — {@link customizationDependencyRange}: `^` of the App's + * `manifest.version` at creation, declared on the App in * `manifest.dependencies` (ADR-0141 D1); * - writable — a scope-less authored package, the same record `POST * /packages` writes for a Studio-created base. @@ -59,6 +67,9 @@ * * - `400` — the request is malformed, or the base id has no pair (it is * itself a pair, or `.custom` is not a valid package id); + * - `409` — the base reaches several Apps and its namespace picks none or + * more than one of them (ADR-0146 D3 merge question 11: no single App, so + * no pair, never a guess); * - `404` — the base is not installed; * - `409` — the base's state rules the pair out: the base is a kernel * package ({@link kernelPackageScopeOf}), whose objects are not @@ -68,8 +79,12 @@ * pair namespace fails the manifest grammar (longer than * {@link MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH}, no truncation and no * hashing); its version is not SemVer, so no range can be declared; or an - * installed `.custom` lacks the namespace or the dependency; - * - `422 NAMESPACE_CONFLICT` — another package owns the pair's namespace: the + * installed pair lacks the namespace or the dependency; or the pair's + * namespace is held by the per-package pair an earlier version created for + * a member of the same App (ADR-0146 D3 merge question 12: that pair stays + * served, and the App's pair waits for it to be re-identified — never + * `422` for a package of the same App); + * - `422 NAMESPACE_CONFLICT` — a package outside the App owns the pair's namespace: the * code and status `SchemaRegistry.installPackage` stamps for the same * condition (`@objectstack/objectql`; that package depends on this one, so * the string is spelled in each). @@ -102,10 +117,11 @@ import type { import { MAX_CUSTOMIZATION_BASE_NAMESPACE_LENGTH, customizationBasePackageId, + customizationPackageId, customizationDependencyRange, customizationNamespace, } from '@objectstack/spec/kernel'; -import { pairOf } from '@objectstack/metadata-core'; +import { appOf, pairOf } from '@objectstack/metadata-core'; import { installedManifestsOfEngine } from './installed-manifests.js'; import type { CustomizationNamespaceRefusal } from '@objectstack/spec/kernel'; @@ -287,6 +303,11 @@ export async function ensureCustomizationPackage( const baseId = declared.data.basePackageId; const pair = pairOf(baseId, installedManifestsOfEngine(this.engine)); + if (!pair.ok && pair.reason === 'no_single_app') { + throw refusal(409, `Package '${baseId}' declares several Apps and its namespace picks no single one of them, ` + + 'so it has no paired customization package: a customization lands in the pair of the one App that ' + + 'carries the package, and none is chosen by guess (ADR-0146 D3).'); + } if (!pair.ok) { throw refusal(400, pair.reason === 'base_is_customization_package' ? `Package '${baseId}' is itself a paired customization package, and a pair is never paired again: ` @@ -374,9 +395,18 @@ export function customizationPairEnsureRefusal( + `('${pairId}') holds the changes to a read-only base — one provided by code or an installed app ` + '(ADR-0146 D3).'); } - const namespace = customizationNamespace(base.manifest?.namespace); + // [ruling record 6691022506] The pair is its App's: its namespace, its + // dependency and its range are read off the App (the base itself for an + // App or an own-unit package). + const appId = customizationBasePackageId(pairId) ?? baseId; + const app = appId === baseId ? base : registry.getPackage(appId); + if (!app) { + return refusal(404, `Package '${appId}', the App that carries '${baseId}', is not installed: its paired ` + + `customization package '${pairId}' is ensured for an installed App (ADR-0146 D3).`); + } + const namespace = customizationNamespace(app.manifest?.namespace); if (!namespace.ok) { - return refusal(409, `Cannot ensure '${pairId}': ${noPairNamespaceClause(baseId, base.manifest?.namespace, namespace.reason)} ` + return refusal(409, `Cannot ensure '${pairId}': ${noPairNamespaceClause(appId, app.manifest?.namespace, namespace.reason)} ` + '(ADR-0146 D3).'); } // An existing pair is returned unchanged, or refused naming what it lacks. @@ -387,28 +417,42 @@ export function customizationPairEnsureRefusal( if (declaredNamespace !== namespace.value) { lacks.push(`namespace '${namespace.value}' (it declares ${typeof declaredNamespace === 'string' && declaredNamespace !== '' ? `'${declaredNamespace}'` : 'none'})`); } - const declaredRange = declaredRangeOn(existing.manifest, baseId); - if (declaredRange === undefined) lacks.push(`a dependency on '${baseId}' in manifest.dependencies`); + const declaredRange = declaredRangeOn(existing.manifest, appId); + if (declaredRange === undefined) lacks.push(`a dependency on '${appId}' in manifest.dependencies`); if (lacks.length > 0) { return refusal(409, `Package '${pairId}' is installed but is not the paired customization package of ` - + `'${baseId}': it lacks ${lacks.join(', and ')}. It is not adopted or rewritten — delete it, or ` + + `'${appId}': it lacks ${lacks.join(', and ')}. It is not adopted or rewritten — delete it, or ` + 'install a manifest over it that declares both, then ensure the pair again (ADR-0146 D3).'); } return undefined; } - const range = customizationDependencyRange(base.manifest?.version); + const range = customizationDependencyRange(app.manifest?.version); if (!range.ok) { - return refusal(409, `Cannot ensure '${pairId}': package '${baseId}' is at version ` - + `'${String(base.manifest?.version)}', which is not a SemVer 2.0.0 version, so the pair's dependency ` - + 'range — the major of the base\'s version — cannot be declared (ADR-0146 D3).'); + return refusal(409, `Cannot ensure '${pairId}': package '${appId}' is at version ` + + `'${String(app.manifest?.version)}', which is not a SemVer 2.0.0 version, so the pair's dependency ` + + 'range — the major of the App\'s version — cannot be declared (ADR-0146 D3).'); } // The pair's namespace is the pair's alone: an owner other than the pair // is refused here, before the install, whatever co-ownership the registry // would read. const owner = registry.getNamespaceOwners?.(namespace.value)?.find((id) => id !== pairId); if (owner !== undefined) { + // [ADR-0146 D3 merge question 12] The per-package pair an earlier + // version created for a member of this App holds the namespace: it + // stays served as it is, and the App's pair waits for it to be + // re-identified as the App's pair — a resource-state conflict inside + // the App, never the `422` kept for a package outside it. + const ownerBase = customizationBasePackageId(owner); + const installed = installedManifestsOfEngine(engine); + if (ownerBase !== null && customizationPackageId(ownerBase).ok && appOf(ownerBase, installed) === appId) { + return refusal(409, `Cannot ensure '${pairId}' yet: its namespace '${namespace.value}' is held by ` + + `'${owner}', the paired customization package an earlier version created for '${ownerBase}', a ` + + `package of the App '${appId}'. That package stays installed and its customizations stay served; ` + + `'${pairId}' is created once '${owner}' is re-identified as the App's pair, and is not created ` + + 'beside it (ADR-0146 D3).'); + } return refusal(422, `Cannot ensure '${pairId}': its namespace '${namespace.value}' is already owned by package ` - + `'${owner}'. The pair's namespace is the base's followed by '_custom' and has no alternative; uninstall ` + + `'${owner}'. The pair's namespace is the App's followed by '_custom' and has no alternative; uninstall ` + `'${owner}' or move it off that namespace, then ensure the pair again (ADR-0146 D3).`, 'NAMESPACE_CONFLICT'); } @@ -425,8 +469,10 @@ async function ensurePairOnce( // Every refusal the registry decides, read once ({@link customizationPairEnsureRefusal}). const refused = customizationPairEnsureRefusal(this.engine, registry, baseId, pairId); if (refused) throw refused; - const base = registry.getPackage(baseId)!; - const namespace = customizationNamespace(base.manifest?.namespace); + // The pair is its App's (the refusal above read the same App). + const appId = customizationBasePackageId(pairId) ?? baseId; + const app = registry.getPackage(appId)!; + const namespace = customizationNamespace(app.manifest?.namespace); if (!namespace.ok) throw new Error('unreachable: the namespace was judged above'); // Idempotent: an existing pair is returned unchanged — ⛔ never adopted, @@ -434,29 +480,29 @@ async function ensurePairOnce( const existing = registry.getPackage(pairId); if (existing) { return { - basePackageId: baseId, + basePackageId: appId, packageId: pairId, namespace: namespace.value, - dependencyRange: declaredRangeOn(existing.manifest, baseId)!, + dependencyRange: declaredRangeOn(existing.manifest, appId)!, created: false, writable: isWritablePackage(this.engine, pairId), }; } - const range = customizationDependencyRange(base.manifest?.version); + const range = customizationDependencyRange(app.manifest?.version); if (!range.ok) throw new Error('unreachable: the version was judged above'); - const baseName = typeof base.manifest?.name === 'string' && base.manifest.name !== '' ? base.manifest.name : baseId; + const appName = typeof app.manifest?.name === 'string' && app.manifest.name !== '' ? app.manifest.name : appId; const manifest = { id: pairId, - name: `${baseName} (custom)`, + name: `${appName} (custom)`, version: CREATED_PAIR_VERSION, type: CREATED_PAIR_TYPE, namespace: namespace.value, - dependencies: { [baseId]: range.value }, + dependencies: { [appId]: range.value }, }; await this.installPackage({ manifest } as InstallPackageRequest); return { - basePackageId: baseId, + basePackageId: appId, packageId: pairId, namespace: namespace.value, dependencyRange: range.value, diff --git a/packages/metadata-protocol/src/protocol-discovery-and-bases.ts b/packages/metadata-protocol/src/protocol-discovery-and-bases.ts index 4ad531afa4a..2d7e78e8df6 100644 --- a/packages/metadata-protocol/src/protocol-discovery-and-bases.ts +++ b/packages/metadata-protocol/src/protocol-discovery-and-bases.ts @@ -46,7 +46,7 @@ import { getMetadataTypeActions, getMetadataTypeSchema, } from '@objectstack/spec/kernel'; -import { pairOf } from '@objectstack/metadata-core'; +import { pairsOf } from '@objectstack/metadata-core'; import { installedManifestsOfEngine } from './installed-manifests.js'; import { storedBodyForCarryForward } from './protocol-credential-carry-forward.js'; import type { Equal, MetaTypesListing } from './protocol-meta-types-listing.js'; @@ -408,11 +408,14 @@ export class ProtocolDiscoveryAndBasesFacet { */ static async getPairTranslationBundle(host: Pick, basePackageId: string): Promise { if (typeof basePackageId !== 'string' || basePackageId === '') return undefined; - const pair = pairOf(basePackageId, installedManifestsOfEngine(host.engine)); - if (!pair.ok) return undefined; + // [ADR-0146 D3 merge question 12] The App's pair, and a per-package + // pair an earlier version created while it is installed (`pairsOf`). + const pairs = pairsOf(basePackageId, installedManifestsOfEngine(host.engine)); + if (pairs.length === 0) return undefined; + const binding = pairs.length === 1 ? pairs[0] : { $in: pairs }; const layer = await readAuthoredTranslationLayer({ find: (table: string, opts: { where?: Record; [key: string]: unknown } = {}) => - host.findEnvironmentRows(table, { ...opts, where: { ...opts.where, package_id: pair.value } }), + host.findEnvironmentRows(table, { ...opts, where: { ...opts.where, package_id: binding } }), }); // `null` is a read that failed: the layer's own rule is "keep what you // had", and a translation input has nothing to keep, so the caller diff --git a/packages/metadata-protocol/src/protocol-environment-rows.ts b/packages/metadata-protocol/src/protocol-environment-rows.ts index 96d30a0cef4..039b808949d 100644 --- a/packages/metadata-protocol/src/protocol-environment-rows.ts +++ b/packages/metadata-protocol/src/protocol-environment-rows.ts @@ -44,7 +44,7 @@ * not move. */ -import { pairOf, type InstalledManifestLookup } from '@objectstack/metadata-core'; +import { isPairOf, type InstalledManifestLookup } from '@objectstack/metadata-core'; import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared'; import type { StoredOverlayRow } from './item-lock.js'; import { overlayMemberWhere } from './migrations/overlay-index.js'; @@ -300,6 +300,5 @@ export function baseAddressServesMember( ): boolean { if (bound === undefined || bound === base) return true; if (baseShipsMember) return false; - const pair = pairOf(base, installed); - return pair.ok && pair.value === bound; + return isPairOf(bound, base, installed); } diff --git a/packages/metadata-protocol/src/protocol-meta-layered-reads.ts b/packages/metadata-protocol/src/protocol-meta-layered-reads.ts index 3076466658e..f5700f90c8c 100644 --- a/packages/metadata-protocol/src/protocol-meta-layered-reads.ts +++ b/packages/metadata-protocol/src/protocol-meta-layered-reads.ts @@ -26,7 +26,8 @@ import { // {@link ObjectStackProtocolImplementation.getMetaItemLayered}'s code-layer // fallback so a hydrated row is never answered as the code layer. isTenantAuthored, - pairOf, + isPairOf, + pairsOf, } from '@objectstack/metadata-core'; import { type MetadataLock, @@ -407,13 +408,15 @@ export async function getMetaItemLayered(host: Pick { - const pair = pairOf(base, installed); - const rank = (row: StoredOverlayRow) => (pair.ok && boundTo(row) === pair.value ? 0 : 1); + const pairs = pairsOf(base, installed); + const rank = (row: StoredOverlayRow) => { + const at = pairs.indexOf(boundTo(row) ?? ''); + return at === -1 ? pairs.length : at; + }; return rank(a) - rank(b) || String(boundTo(a)).localeCompare(String(boundTo(b))); })[0]; if (orphan !== undefined) { diff --git a/packages/metadata-protocol/src/protocol-meta-list-reads.ts b/packages/metadata-protocol/src/protocol-meta-list-reads.ts index 22974ca3404..c6b4c52fd3a 100644 --- a/packages/metadata-protocol/src/protocol-meta-list-reads.ts +++ b/packages/metadata-protocol/src/protocol-meta-list-reads.ts @@ -18,7 +18,7 @@ * not move. */ -import { ITEM_KEY_DISCRIMINATORS, isTenantAuthored, pairOf } from '@objectstack/metadata-core'; +import { ITEM_KEY_DISCRIMINATORS, isTenantAuthored, pairsOf } from '@objectstack/metadata-core'; import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared'; import { isAggregatedViewContainer } from '@objectstack/spec/ui'; import { @@ -197,9 +197,11 @@ export async function readFlattenedMetaItems(host: Pick row?.package_id === pairOfScope.value) + // [ADR-0146 D3 merge question 12] The App's pair, and a per-package + // pair an earlier version created while it is installed (`pairsOf`). + const pairsOfScope = servedOverride && packageId ? pairsOf(packageId, installed) : []; + const overrideRows = pairsOfScope.length > 0 + ? lockRows.filter((row) => pairsOfScope.includes(row?.package_id)) : []; if ((records && records.length > 0) || standInRows.length > 0 || overrideRows.length > 0) { const isView = (PLURAL_TO_SINGULAR[request.type] ?? request.type) === 'view'; @@ -531,9 +533,9 @@ export async function readFlattenedMetaItems(host: Pick record?.package_id === pairOfDraftScope.value) + const pairsOfDraftScope = servedOverride && packageId ? pairsOf(packageId, installed) : []; + const overrideDraftRecords = pairsOfDraftScope.length > 0 + ? packageAgnosticDrafts.filter((record) => pairsOfDraftScope.includes(record?.package_id)) : []; if (draftRecords.length > 0 || standInDraftRecords.length > 0 || overrideDraftRecords.length > 0) { // ADR-0048 (#1828) — package-aware draft overlay (parity with @@ -675,11 +677,12 @@ export async function readFlattenedMetaItems(host: Pick> = base === null ? [{ packageId: null }] : base ? [ - ...(pair?.ok ? [{ packageId: pair.value, overrideOf: base }] : []), + ...pairs.map((pair) => ({ packageId: pair, overrideOf: base })), { packageId: base }, { packageId: null }, ] diff --git a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts index 2e24bfb8453..a51726e0338 100644 --- a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts +++ b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts @@ -48,6 +48,7 @@ import { OBJECT_EXTENSION_TYPE } from './protocol-object-extension-item.js'; import { customizationPairEnsureRefusal, kernelPackageScope, type PairRegistry } from './protocol-customization-package.js'; import { resolveOverlaySchema } from './protocol-meta-type-schema.js'; import { resolvePackageClosure } from '@objectstack/lint/runtime'; +import { widenPairClosureToAppMembers } from './protocol-write-package-scope.js'; import { overlayMemberField, overlayMemberOfBody, overlayMemberValue } from './migrations/overlay-index.js'; import { customizationBasePackageId, @@ -500,10 +501,16 @@ export function overlayPairFixOf( const kernelScope = kernelPackageScope(host.engine, base); const kernel = kernelScope === undefined ? {} : { kernelScope }; if (registry.getPackage(pair)) return { base, pair, basedOn, ...kernel }; - // The pair the ensure door would create: one dependency, the base. - const baseClosure = resolvePackageClosure(base, (id) => registry.getPackage(id) as { manifest?: unknown } | undefined); - const dependencies = [base, ...(baseClosure?.dependencies ?? []).filter((id) => id !== pair)]; - return { base, pair, basedOn, pendingPairScope: { packageId: pair, dependencies }, ...kernel }; + // The pair the ensure door would create: one dependency, the App that + // carries the base (the base itself for an App or an own-unit package), + // and the App's members through it ({@link widenPairClosureToAppMembers}). + const app = customizationBasePackageId(pair) ?? base; + const appClosure = resolvePackageClosure(app, (id) => registry.getPackage(id) as { manifest?: unknown } | undefined); + const pendingPairScope = widenPairClosureToAppMembers({ + packageId: pair, + dependencies: [app, ...(appClosure?.dependencies ?? []).filter((id) => id !== pair)], + }, registry as Parameters[1]); + return { base, pair, basedOn, pendingPairScope, ...kernel }; } /** diff --git a/packages/metadata-protocol/src/protocol-served-overrides.ts b/packages/metadata-protocol/src/protocol-served-overrides.ts index 0d548f4c8dd..4168c3c1e18 100644 --- a/packages/metadata-protocol/src/protocol-served-overrides.ts +++ b/packages/metadata-protocol/src/protocol-served-overrides.ts @@ -51,7 +51,7 @@ * {@link ServedOverridesHost} members. */ -import { pairOf } from '@objectstack/metadata-core'; +import { pairsOf } from '@objectstack/metadata-core'; import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; import { storedRowDocument } from './item-lock.js'; import type { ObjectStackProtocolImplementation } from './protocol.js'; @@ -98,12 +98,14 @@ export async function listServedOverrides( ): Promise { const base = request.packageId; const installed = installedManifestsOfEngine(this.engine); - const pair = pairOf(base, installed); - if (!pair.ok) return []; + // [ADR-0146 D3 merge question 12] The App's pair, and a per-package pair + // an earlier version created while it is installed (`pairsOf`). + const pairs = pairsOf(base, installed); + if (pairs.length === 0) return []; let rows: any[] = []; try { rows = await this.findEnvironmentRows('sys_metadata', { - where: { package_id: pair.value, state: 'active' }, + where: { package_id: pairs.length === 1 ? pairs[0] : { $in: pairs }, state: 'active' }, context: { isSystem: true }, }); } catch (error) { diff --git a/packages/metadata-protocol/src/protocol-write-package-scope.ts b/packages/metadata-protocol/src/protocol-write-package-scope.ts index aacd0082e44..99748c2e3b6 100644 --- a/packages/metadata-protocol/src/protocol-write-package-scope.ts +++ b/packages/metadata-protocol/src/protocol-write-package-scope.ts @@ -31,8 +31,50 @@ */ import { resolvePackageClosure } from '@objectstack/lint/runtime'; +import { installedManifestsOf, pairOf } from '@objectstack/metadata-core'; +import { customizationBasePackageId } from '@objectstack/spec/kernel'; import type { ObjectStackProtocolImplementation } from './protocol.js'; +/** The registry slice {@link widenPairClosureToAppMembers} reads. */ +interface MemberRegistry { + getPackage?: (id: string) => { manifest?: unknown } | undefined; + getAllPackages?: () => ReadonlyArray<{ manifest?: unknown }>; +} + +/** + * [ADR-0146 D3, ruling record 6691022506] The write closure of an App's pair: + * `closure` — the pair and what it declares, the App — widened by every + * installed package whose pair it is (`pairOf`, `@objectstack/metadata-core`) + * and their own declared closures. A customization of any package of the App + * lands in the App's pair, and the pair declares only the App (its range is + * the App's), so the members it overrides and extends are reached through the + * App's membership, not through a dependency the pair would have to restate + * for every module the App delivers. Any other package's closure is returned + * as it is. + */ +export function widenPairClosureToAppMembers( + closure: { packageId: string; dependencies: string[] }, + registry: MemberRegistry | undefined, +): { packageId: string; dependencies: string[] } { + const pairId = closure.packageId; + if (customizationBasePackageId(pairId) === null) return closure; + if (!registry || typeof registry.getPackage !== 'function' || typeof registry.getAllPackages !== 'function') return closure; + const installed = installedManifestsOf(registry as { getPackage(id: string): { manifest?: unknown } | undefined }); + const getPackage = (id: string) => registry.getPackage!.call(registry, id); + const dependencies = new Set(closure.dependencies); + for (const row of registry.getAllPackages.call(registry)) { + const id = (row?.manifest as { id?: unknown } | undefined)?.id; + if (typeof id !== 'string' || id === pairId || dependencies.has(id)) continue; + const pair = pairOf(id, installed); + if (!pair.ok || pair.value !== pairId) continue; + dependencies.add(id); + for (const dep of resolvePackageClosure(id, getPackage)?.dependencies ?? []) { + if (dep !== pairId) dependencies.add(dep); + } + } + return { packageId: pairId, dependencies: [...dependencies] }; +} + /** * The `ObjectStackProtocolImplementation` members the function below reads — * every one of them, and no other; the function's `this` is the `Pick` of the @@ -51,19 +93,19 @@ export interface WritePackageScopeHost { * `@objectstack/lint`, the same function Studio's object pickers call over * the served package list, so the picker offers exactly what this gate * admits. This body keeps the two host guards: a registry with no - * `getPackage`, and the silent `catch` answering `undefined`. + * `getPackage`, and the silent `catch` answering `undefined`. An App's pair's + * closure is widened by the App's members ({@link widenPairClosureToAppMembers}). */ export function resolveWritePackageScope( this: Pick, packageId: string | null | undefined, ): { packageId: string; dependencies: string[] } | undefined { try { - const registry = this.engine.registry as - | { getPackage?: (id: string) => { manifest?: unknown } | undefined } - | undefined; + const registry = this.engine.registry as MemberRegistry | undefined; const getPackage = registry?.getPackage; if (typeof getPackage !== 'function') return undefined; - return resolvePackageClosure(packageId, (id) => getPackage.call(registry, id)); + const closure = resolvePackageClosure(packageId, (id) => getPackage.call(registry, id)); + return closure === undefined ? undefined : widenPairClosureToAppMembers(closure, registry); } catch { // Context gathering must never fail a write (the same contract // `listCollection` above holds). An unreadable registry means an diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 3d39d62d1b0..bc04ece0101 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -12408,17 +12408,19 @@ export class ObjectStackProtocolImplementation implements } /** - * ADR-0146 D3 — ensure the paired customization package `.custom` of - * an installed, read-only base: answer it when it is installed, create it - * when it is absent. The ONE creation path of the pair ("no second door - * builds the pair"), served over HTTP as `POST + * ADR-0146 D3 — ensure the paired customization package of an installed, + * read-only base: the pair of the App that carries it, `.custom` + * (the base's own `.custom` for an App or a package that declares + * no App; ruling record 6691022506) — answer it when it is installed, + * create it when it is absent. The ONE creation path of the pair ("no + * second door builds the pair"), served over HTTP as `POST * /api/v1/packages/:id/ensure-customization` and called in-process by the * doors that customize a base. * * The created pair is a writable package whose id, namespace - * (`_custom`, set explicitly and never derived from the - * pair's id) and dependency (`^` of the base's version) are the - * base's, through the pairing rules of `@objectstack/spec/kernel`; it is + * (`_custom`, set explicitly and never derived from the + * pair's id) and dependency (`^` of the App's version) are the + * App's, through the pairing rules of `@objectstack/spec/kernel`; it is * installed through {@link installPackage}, so it lands in the registry * and in `sys_packages` together. An existing pair is returned unchanged, * or refused when it lacks the namespace or the dependency — never @@ -12426,10 +12428,12 @@ export class ObjectStackProtocolImplementation implements * call in flight and judges afresh. * * Refused, before anything is written: a malformed request or a base id - * with no pair (`400`), a base not installed (`404`), a writable base, a - * base with no pair namespace or no SemVer version, an existing pair that - * lacks the namespace or the dependency (`409`), and a pair namespace - * another package owns (`422 NAMESPACE_CONFLICT`). The request and the + * with no pair (`400`), a base not installed (`404`), a kernel base, a + * base with no single App, a writable base, an App with no pair namespace + * or no SemVer version, an existing pair that lacks the namespace or the + * dependency, a pair namespace held by the per-package pair an earlier + * version created for a member of the same App (`409`), and a pair + * namespace a package outside the App owns (`422 NAMESPACE_CONFLICT`). The request and the * answer are `EnsureCustomizationPackageRequestSchema` / * `EnsureCustomizationPackageResponseSchema` (`@objectstack/spec/api`). */ diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 2bed68a7104..2437b962da5 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -45,7 +45,7 @@ import { // [ADR-0146 D3 · ruling record 6691022506] The pair of a package, looked up // through the App that carries it — the uninstall refusal's "is its pair // installed?" asks it, over this registry's installed manifests. - pairOf, + pairsOf, installedManifestsOf, } from '@objectstack/metadata-core'; // [ADR-0029 D9.2a] `scalarOverridesPackagedBase` is the #8284 comparison, imported rather @@ -3336,8 +3336,9 @@ const EXTENDED_OBJECTS_LISTED = 5; * so one answer is the whole way out rather than the first step of several. * * [#28408, ADR-0146 D3] …and the uninstall refusal of a base whose paired - * customization package is installed: `.custom`, the id - * {@link pairOf} looks up, held by this registry. That package + * customization package is installed and stands on it: the App's pair of an + * App, or the per-package pair an earlier version created for a package + * ({@link pairsOf}), held by this registry. That package * holds every customization of the base, so uninstalling the base under it * would strand them all, with nothing serving them and nothing saying why. * ADR-0146 puts this refusal in this family and this shape, so it is this @@ -7352,8 +7353,9 @@ export class SchemaRegistry { * package is refused exactly when the first such object exists. * * [#28408, ADR-0146 D3] It also refuses while this registry holds the - * package's paired customization package — the id - * {@link pairOf} looks up for `packageId`, so the pair is + * package's paired customization package that declares `packageId` — one + * of the ids {@link pairsOf} looks up for it (the App's pair stands on the + * App, not on a member) — so the pair is * found by id alone, whichever door created or installed it. The same * refusal, carrying both causes when both hold. Uninstalling the pair itself * asks nothing of this kind: a `.custom` id derives no pair of its own. An @@ -7384,8 +7386,15 @@ export class SchemaRegistry { extended.push({ objectName: fqn, extenders: otherExtenders.map(c => String(c.packageId)) }); } } - const pair = pairOf(packageId, installedManifestsOf(this)); - const installedPair = pair.ok && this.getPackage(pair.value) !== undefined ? pair.value : undefined; + // [ADR-0146 D3 · merge questions 11, 12] The installed pair that stands + // on this package: the App's pair when this is the App (a member's + // uninstall keeps and marks its overrides in the App's pair, and is not + // refused for it), or the per-package pair an earlier version created + // for this package — each declares the package it stands on. + const installedPair = pairsOf(packageId, installedManifestsOf(this)).find((id) => { + const dependencies = (this.getPackage(id)?.manifest as { dependencies?: unknown } | undefined)?.dependencies; + return typeof dependencies === 'object' && dependencies !== null && Object.prototype.hasOwnProperty.call(dependencies, packageId); + }); if (extended.length > 0 || installedPair !== undefined) { throw new PackageUninstallExtendedError(packageId, extended, installedPair); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad8942b5c3c..551257386f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1360,6 +1360,9 @@ importers: '@objectstack/formula': specifier: workspace:* version: link:../formula + '@objectstack/metadata-core': + specifier: workspace:* + version: link:../metadata-core '@objectstack/sdui-parser': specifier: workspace:* version: link:../sdui-parser From fff54e39376db6d9f818a7d9172fa849c03360fd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 12:21:36 +0000 Subject: [PATCH 03/15] wip: widen only a pair that declares its App Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2dM9PvPPB9BMVdhf2R --- .../src/protocol-write-package-scope.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/metadata-protocol/src/protocol-write-package-scope.ts b/packages/metadata-protocol/src/protocol-write-package-scope.ts index 99748c2e3b6..4a9e85d36b4 100644 --- a/packages/metadata-protocol/src/protocol-write-package-scope.ts +++ b/packages/metadata-protocol/src/protocol-write-package-scope.ts @@ -43,8 +43,8 @@ interface MemberRegistry { /** * [ADR-0146 D3, ruling record 6691022506] The write closure of an App's pair: - * `closure` — the pair and what it declares, the App — widened by every - * installed package whose pair it is (`pairOf`, `@objectstack/metadata-core`) + * `closure` — the pair and what it declares, the App — widened, when it + * declares its App, by every other installed package whose pair it is (`pairOf`, `@objectstack/metadata-core`) * and their own declared closures. A customization of any package of the App * lands in the App's pair, and the pair declares only the App (its range is * the App's), so the members it overrides and extends are reached through the @@ -57,14 +57,18 @@ export function widenPairClosureToAppMembers( registry: MemberRegistry | undefined, ): { packageId: string; dependencies: string[] } { const pairId = closure.packageId; - if (customizationBasePackageId(pairId) === null) return closure; + const appId = customizationBasePackageId(pairId); + // Members are reached THROUGH the App: a pair that does not declare its + // App (an installed copy that dropped the dependency) is widened by + // nothing, and the App itself is only ever in the closure by declaration. + if (appId === null || !closure.dependencies.includes(appId)) return closure; if (!registry || typeof registry.getPackage !== 'function' || typeof registry.getAllPackages !== 'function') return closure; const installed = installedManifestsOf(registry as { getPackage(id: string): { manifest?: unknown } | undefined }); const getPackage = (id: string) => registry.getPackage!.call(registry, id); const dependencies = new Set(closure.dependencies); for (const row of registry.getAllPackages.call(registry)) { const id = (row?.manifest as { id?: unknown } | undefined)?.id; - if (typeof id !== 'string' || id === pairId || dependencies.has(id)) continue; + if (typeof id !== 'string' || id === pairId || id === appId || dependencies.has(id)) continue; const pair = pairOf(id, installed); if (!pair.ok || pair.value !== pairId) continue; dependencies.add(id); From 84a1ca23db74caca925d9bd00588cac405373a42 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 12:39:23 +0000 Subject: [PATCH 04/15] test(runtime): pin the App unit at the ensure door and the HotCRM-shape rehearsal Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2dM9PvPPB9BMVdhf2R --- .../meta-object-extension-item.test.ts | 91 ++++++++++++++++ .../packages-ensure-customization.test.ts | 102 ++++++++++++++++++ 2 files changed, 193 insertions(+) diff --git a/packages/runtime/src/domains/meta-object-extension-item.test.ts b/packages/runtime/src/domains/meta-object-extension-item.test.ts index 4aa5609cf96..a0f32a74dcb 100644 --- a/packages/runtime/src/domains/meta-object-extension-item.test.ts +++ b/packages/runtime/src/domains/meta-object-extension-item.test.ts @@ -404,3 +404,94 @@ describe('[ADR-0146 D10] a module\'s extension that re-declares the base\'s fiel expect(rig.extenders()).toEqual([MODULE]); }, 60_000); }); + +/** + * [ruling record 6691022506, ADR-0146 D3 as amended] The HotCRM rehearsal, on + * an in-repo fixture of HotCRM's shape (the HotCRM repository is not part of + * this tree): the App `app.objectstack.hotcrm` and three modules that declare + * it, all `namespace: 'crm'`, all held as code. The ensure door is asked for + * each — one `app.objectstack.hotcrm.custom` — and a MODULE's object is then + * extended in that pair and read back on every object read. + */ +describe('[#29594] the App unit: HotCRM and three modules share one pair, and a module object is customized in it', () => { + const APP = 'app.objectstack.hotcrm'; + const APP_PAIR = `${APP}.custom`; + const MODULES = ['service', 'revenue', 'marketing'] as const; + const CASE = 'crm_service_case'; + const ADMIN_CTX: any = { + request: {}, + environmentId: 'hotcrm-app-unit', + executionContext: { userId: 'u_admin', systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'] }, + }; + + async function hotcrm() { + const engine = new ObjectQL(); + liveEngines.push(engine); + engine.registerDriver(new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }), true); + await engine.init(); + engine.registry.registerObject(SysMetadata as any, TEST_PACKAGE_ID); + engine.registry.registerObject(SysMetadataHistoryObject as any, TEST_PACKAGE_ID); + engine.registry.registerObject(SysMetadataAuditObject as any, TEST_PACKAGE_ID); + (engine.registry as unknown as { logLevel: string }).logLevel = 'silent'; + engine.registerApp({ + id: APP, name: 'hotcrm', version: '6.1.0', type: 'app', namespace: 'crm', + objects: [{ name: 'crm_account', label: 'Account', fields: { name: { name: 'name', label: 'Name', type: 'text' } } }], + }); + for (const m of MODULES) { + engine.registerApp({ + id: `${APP}.${m}`, name: `hotcrm-${m}`, version: '6.1.0', type: 'module', namespace: 'crm', + dependencies: { [APP]: '^6.1.0' }, + objects: [{ name: `crm_${m}_case`, label: `${m} case`, fields: { subject: { name: 'subject', label: 'Subject', type: 'text' } } }], + }); + } + await engine.syncSchemas(); + const protocol: any = new ObjectStackProtocolImplementation(engine as any, () => new Map()); + const named: Record = { protocol, objectql: engine }; + const get = (name: string) => named[name] ?? null; + const kernel: any = { context: { getService: get }, getService: get, getServiceAsync: async (name: string) => get(name) }; + const dispatcher = new HttpDispatcher(kernel); + const ensure = (id: string) => + dispatcher.handlePackages(`/${encodeURIComponent(id)}/ensure-customization`, 'POST', undefined, {}, ADMIN_CTX) as Promise; + return { engine, protocol, ensure }; + } + + it('ensure on the App and each module answers one app.objectstack.hotcrm.custom; a module object extended there reads back', async () => { + const { engine, protocol, ensure } = await hotcrm(); + const answers: Array<[string, number, string]> = []; + for (const id of [APP, ...MODULES.map((m) => `${APP}.${m}`)]) { + const r = await ensure(id); + answers.push([id, r.response.status, r.response.body?.data?.packageId]); + } + expect(answers).toEqual([ + [APP, 201, APP_PAIR], + [`${APP}.service`, 200, APP_PAIR], + [`${APP}.revenue`, 200, APP_PAIR], + [`${APP}.marketing`, 200, APP_PAIR], + ]); + const pairs = engine.registry.getAllPackages().map((p: any) => p.manifest?.id).filter((id: string) => id.endsWith('.custom')); + expect(pairs).toEqual([APP_PAIR]); + + // Customize a MODULE's object in the App's pair: Studio's add-field body. + await protocol.saveMetaItem({ + type: TYPE, name: CASE, mode: 'draft', packageId: APP_PAIR, + item: { extend: CASE, fields: { sla_tier__c: { type: 'text', label: 'SLA tier' } } }, + }); + await protocol.publishMetaItem({ type: TYPE, name: CASE, packageId: APP_PAIR }); + + const byName = (await protocol.getMetaItem({ type: 'object', name: CASE }))?.item; + const layered = await protocol.getMetaItemLayered({ type: 'object', name: CASE }); + expect(Object.keys(byName?.fields ?? {})).toEqual(expect.arrayContaining(['subject', 'sla_tier__c'])); + expect(Object.keys(layered?.effective?.fields ?? {})).toContain('sla_tier__c'); + expect(Object.keys((engine.registry.getObject(CASE) as any)?.fields ?? {})).toContain('sla_tier__c'); + expect(engine.registry.getObjectContributors(CASE).filter((c: any) => c.ownership === 'extend').map((c: any) => c.packageId)) + .toEqual([APP_PAIR]); + // A record stores a value in the field. + const created: any = await engine.insert(CASE, { subject: 'Outage', sla_tier__c: 'gold' }, { context: { isSystem: true } }); + const back: any = await engine.findOne(CASE, { where: { id: created.id }, context: { isSystem: true } }); + expect(back?.sla_tier__c).toBe('gold'); + }, 60_000); +}); diff --git a/packages/runtime/src/domains/packages-ensure-customization.test.ts b/packages/runtime/src/domains/packages-ensure-customization.test.ts index 2159692f5a8..9063118739c 100644 --- a/packages/runtime/src/domains/packages-ensure-customization.test.ts +++ b/packages/runtime/src/domains/packages-ensure-customization.test.ts @@ -490,3 +490,105 @@ describe('POST /api/v1/packages/:id/duplicate: a copy into a pair id is refused, expect(d.published).toHaveLength(writes); }); }); + +/** + * [ruling record 6691022506, ADR-0146 D3 as amended] The customization unit is + * the App: the door asked for any package of an App answers the App's one + * pair, `.custom`, created on the first ask and answered after — + * never `422` for a package of the same App. The App is read from what the + * installed manifests declare (merge question 11); a per-package pair an + * earlier version created stays installed and served (merge question 12). + */ +describe(`${ENSURE_PATH.replace(CRM.id, ':id')}: the App unit — one pair for an App and its modules`, () => { + const APP = { id: 'app.objectstack.hotcrm', name: 'HotCRM', version: '6.1.0', type: 'app', namespace: 'crm' }; + const MODULES = ['service', 'revenue', 'marketing'].map((m) => ({ + id: `${APP.id}.${m}`, name: `HotCRM ${m}`, version: '6.1.0', type: 'module', namespace: 'crm', + dependencies: { [APP.id]: '^6.1.0' }, + })); + const APP_PAIR = `${APP.id}.custom`; + + /** HotCRM as `dev:crm` boots it: the App and its modules, held as code. */ + function hotcrm(): Door { + const d = door(); + for (const manifest of [APP, ...MODULES]) withBase(d, manifest); + return d; + } + const pairsIn = (d: Door) => ids(d).filter((id) => id.endsWith('.custom')); + + it('a module first, then the App and the other modules: one app.objectstack.hotcrm.custom, 201 then 200', async () => { + const d = hotcrm(); + const asked = [MODULES[0]!.id, APP.id, MODULES[1]!.id, MODULES[2]!.id]; + const answers: any[] = []; + for (const id of asked) { + const r = await ensure(d, id); + answers.push({ id, status: r.response.status, data: r.response.body.data }); + } + expect(answers.map((a) => a.status), JSON.stringify(answers)).toEqual([201, 200, 200, 200]); + for (const a of answers) { + expect(EnsureCustomizationPackageResponseSchema.parse(a.data)).toEqual({ + basePackageId: APP.id, + packageId: APP_PAIR, + namespace: 'crm_custom', + dependencyRange: '^6', + created: a.status === 201, + writable: true, + }); + } + expect(pairsIn(d)).toEqual([APP_PAIR]); + expect(d.registry.getPackage(APP_PAIR)?.manifest).toMatchObject({ + id: APP_PAIR, name: 'HotCRM (custom)', namespace: 'crm_custom', dependencies: { [APP.id]: '^6' }, + }); + expect(d.published).toHaveLength(1); + }); + + it('the namespace held by a package outside the App: 422 NAMESPACE_CONFLICT still, asked from a module', async () => { + const d = hotcrm(); + register(d, { id: 'com.other.squatter', name: 'Squatter', version: '1.0.0', type: 'app', namespace: 'crm_custom' }, 'code'); + const before = ids(d); + const r = await ensure(d, MODULES[1]!.id); + expectRefusal(r, 422, 'NAMESPACE_CONFLICT', "its namespace 'crm_custom' is already owned by package 'com.other.squatter'"); + expect(ids(d)).toEqual(before); + expect(d.published).toHaveLength(0); + }); + + it('a per-package pair an earlier version created for a module holds the namespace: 409 naming it, never 422; it stays installed (merge question 12)', async () => { + const d = hotcrm(); + const legacy = { + id: `${MODULES[0]!.id}.custom`, name: 'Service (custom)', version: '0.1.0', type: 'module', namespace: 'crm_custom', + dependencies: { [MODULES[0]!.id]: '^6' }, + }; + register(d, legacy); + const before = structuredClone(d.registry.getPackage(legacy.id)); + for (const asked of [APP.id, MODULES[0]!.id, MODULES[2]!.id]) { + const r = await ensure(d, asked); + expectRefusal(r, 409, 'RESOURCE_CONFLICT', + `Cannot ensure '${APP_PAIR}' yet`, `held by '${legacy.id}'`, 'stays installed', 'is not created beside it'); + } + expect(pairsIn(d)).toEqual([legacy.id]); + expect(d.registry.getPackage(legacy.id)).toEqual(before); + expect(d.published).toHaveLength(0); + }); + + it('a package declaring two Apps whose namespace picks neither: 409, no single App, nothing created', async () => { + const d = hotcrm(); + const other = { id: 'com.example.showcase', name: 'Showcase', version: '1.0.0', type: 'app', namespace: 'showcase' }; + withBase(d, other); + const bridge = { + id: 'com.acme.bridge', name: 'Bridge', version: '1.0.0', type: 'plugin', namespace: 'bridge', + dependencies: { [APP.id]: '^6', [other.id]: '^1' }, + }; + withBase(d, bridge); + const r = await ensure(d, bridge.id); + expectRefusal(r, 409, 'RESOURCE_CONFLICT', `Package '${bridge.id}' declares several Apps`); + expect(pairsIn(d)).toEqual([]); + }); + + it('a package that declares no App is its own unit: its own pair, as before', async () => { + const d = hotcrm(); + const solo = { id: 'com.acme.solo', name: 'Solo', version: '2.0.0', type: 'plugin', namespace: 'solo' }; + withBase(d, solo); + const r = await ensure(d, solo.id); + expect(r.response.status, JSON.stringify(r.response.body)).toBe(201); + expect(r.response.body.data).toMatchObject({ basePackageId: solo.id, packageId: 'com.acme.solo.custom', namespace: 'solo_custom', dependencyRange: '^2' }); + }); +}); From 7467e2ede18303bed7a3b05a31fbd68b275a500f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 12:42:11 +0000 Subject: [PATCH 05/15] feat: census reads the declared base; docs and dogfood helper follow the App unit; changeset Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2dM9PvPPB9BMVdhf2R --- .changeset/29594-app-customization-pair.md | 25 +++++++++++++++++++ content/docs/api/metadata-api.mdx | 3 ++- content/docs/concepts/metadata-lifecycle.mdx | 9 ++++++- content/docs/deployment/install-modes.mdx | 4 ++- .../protocol-packageless-overlay-migration.ts | 10 +++++--- ...ocol-packageless-overlay-migration.test.ts | 17 +++++++++++++ .../qa/dogfood/test/customization-pair.ts | 15 ++++++++--- .../meta-object-extension-item.test.ts | 2 +- 8 files changed, 75 insertions(+), 10 deletions(-) create mode 100644 .changeset/29594-app-customization-pair.md diff --git a/.changeset/29594-app-customization-pair.md b/.changeset/29594-app-customization-pair.md new file mode 100644 index 00000000000..5e32a8a28a9 --- /dev/null +++ b/.changeset/29594-app-customization-pair.md @@ -0,0 +1,25 @@ +--- +'@objectstack/metadata-core': minor +'@objectstack/metadata-protocol': minor +'@objectstack/objectql': minor +'@objectstack/lint': minor +--- + +feat(metadata-core, metadata-protocol, objectql, lint): an App and the modules that declare it share one customization pair + +Clause-②: no +Metadata-protocol: semantic + +The customization unit is the App (ADR-0146 D3 as amended, ruling record 6691022506). `appOf` in `@objectstack/metadata-core` now reads the installed manifests: a `type: 'app'` package is its own App; any other package's App is the installed `type: 'app'` package it declares in `manifest.dependencies`, directly or through its declared parents (where it reaches several, the one holding its namespace); a package that declares no App is its own unit; a kernel package (scope `system` / `cloud`) keeps its own answer, because no platform pair is created in this version. + +What changes for a caller: + +- `POST /api/v1/packages/:id/ensure-customization` asked for a module of an App answers the App's one pair, `.custom` (namespace `_custom`, a dependency on the App at `^`): `201` on the first ask from any member, `200` after, never `422` for a package of the same App. The answer's `basePackageId` is the App. HotCRM: the App and its modules all answer `app.objectstack.hotcrm.custom`. +- `422 NAMESPACE_CONFLICT` stays for the pair namespace held by a package outside the App. +- A per-package pair an earlier version created for a module (`.custom`) stays installed and served: its declared overrides, list rows, translations and served-overrides census are read beside the App's pair (`pairsOf` / `isPairOf`). While it holds the App's pair namespace, the ensure door answers `409 RESOURCE_CONFLICT` naming it instead of creating the App's pair beside it (merge question 12). +- A package that reaches several Apps and whose namespace picks none of them has no pair: the ensure door answers `409` (merge question 11). +- The App's pair's write closure reaches every member of the App, so an override or an object extension of a module's item saved into the App's pair is admitted. +- Uninstalling a module is no longer refused because the App's pair is installed; uninstalling the App still is. +- `os validate`'s `namespace-no-customization-pair` warning skips a member of a delivered App: its customizations land in the App's pair. + +TypeScript: `appOf` now answers `string | undefined` (`undefined` = no single App), and `pairOf`'s refusal union gains `no_single_app` (`PairOfRefusal`); `pairsOf` and the `AppOfRefusal` / `PairOfRefusal` types are new exports of `@objectstack/metadata-core`. diff --git a/content/docs/api/metadata-api.mdx b/content/docs/api/metadata-api.mdx index f3880d37198..67bf40e4112 100644 --- a/content/docs/api/metadata-api.mdx +++ b/content/docs/api/metadata-api.mdx @@ -111,7 +111,8 @@ capability changes only with a new version of the package that declares it. An sentence names that type's own route. What the reads then serve is in [Metadata lifecycle](/docs/concepts/metadata-lifecycle#customizing-an-installed-package-the-paired-custom-override-adr-0146). `POST /packages/hotcrm/ensure-customization` creates the pair when absent (Studio calls -it on the first change); a kernel package (scope `system` or `cloud`) has no pair in this +it on the first change); asked for a module that declares an App, it answers the App's one +pair (`.custom`, `200` once it exists); a kernel package (scope `system` or `cloud`) has no pair in this version and answers `409 RESOURCE_CONFLICT`, though a pair an earlier version created keeps serving what it holds. `GET /packages/hotcrm/customizations` lists its overrides, extensions and switched-off items in one read, beside `contributions`: the fields (kind `field`) diff --git a/content/docs/concepts/metadata-lifecycle.mdx b/content/docs/concepts/metadata-lifecycle.mdx index 17e11e909ae..24d21b01c88 100644 --- a/content/docs/concepts/metadata-lifecycle.mdx +++ b/content/docs/concepts/metadata-lifecycle.mdx @@ -115,7 +115,14 @@ later ships. See [ADR-0070](https://github.com/objectstack-ai/objectstack/blob/m A customer's change to an installed base package — HotCRM, id `hotcrm` — is an item of the base's paired writable package **`hotcrm.custom`**, which depends on the base; the -base's own items stay read-only. An item of the pair that bears a base item's name — for an +base's own items stay read-only. **The unit is the App**: an App and every module or plugin +that declares it (HotCRM's `app.objectstack.hotcrm` and its modules) share **one** pair, +`.custom` (`app.objectstack.hotcrm.custom`, namespace `_custom`, depending on +the App at `^`), and the ensure door asked for any of them answers that one pair. A package that +declares no App is its own unit, and a kernel package (scope `system` or `cloud`) has no pair in this +version. A per-package pair an earlier version created for a module stays installed and keeps serving +what it holds; while it holds the App's pair namespace, the ensure door answers `409` naming it rather +than create the App's pair beside it. An item of the pair that bears a base item's name — for an `email_template`, one item per `(name, locale)`, the name **and** a locale the base ships — and declares in its body `overrides: { package: 'hotcrm', basedOn: '3.2.0' }` (the base's `manifest.id` and `manifest.version`; `basedOn` is recorded and never judged; schema: diff --git a/content/docs/deployment/install-modes.mdx b/content/docs/deployment/install-modes.mdx index 695c751f6d1..3704db0e2ec 100644 --- a/content/docs/deployment/install-modes.mdx +++ b/content/docs/deployment/install-modes.mdx @@ -112,7 +112,9 @@ the package's paired `.custom` package ([how](/docs/concepts/metadata-lifecycle#customizing-an-installed-package-the-paired-custom-override-adr-0146)). `POST /api/v1/packages//ensure-customization` creates the pair, or answers it when it already exists, and a save naming it -(`?package=.custom`) lands there: +(`?package=.custom`) lands there. A module that declares its App is +customized in the App's pair, `.custom`: the same door, asked for the +module, answers that pair (its `packageId`), and a save names it: - an item the package ships, of a type that takes an override (a `view`, `dashboard`, `flow` and the other types ADR-0146 lists), is replaced by diff --git a/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts b/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts index f6457a8107f..4d005f5099e 100644 --- a/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts +++ b/packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts @@ -474,9 +474,13 @@ export async function listPackagelessOverlays( // Every type a declaration applies to, not only the five the move acts on. const incompleteDeclarations: IncompleteOverrideDeclarationRow[] = []; for (const row of await environmentRowsOfTypes.call(this, OVERRIDABLE_TYPE_SPELLINGS)) { - if (isUnbound(row)) continue; - const base = customizationBasePackageId(row.package_id); - if (base === null || !isServedOverrideRowOf(row, base, installedManifestsOfEngine(this.engine))) continue; + if (isUnbound(row) || customizationBasePackageId(row.package_id) === null) continue; + // [ADR-0146 D3 as amended] The base is the one the row DECLARES: an + // App's pair overrides the items of every package of the App, so the + // pair's own id names only the App, never the module it overrides. + const declared = (bodyOf(row) as { overrides?: { package?: unknown } } | undefined)?.overrides?.package; + if (typeof declared !== 'string' || !isServedOverrideRowOf(row, declared, installedManifestsOfEngine(this.engine))) continue; + const base = declared; const basedOn = (bodyOf(row) as { overrides?: { basedOn?: unknown } } | undefined)?.overrides?.basedOn; if (typeof basedOn === 'string' && basedOn !== '') continue; const member = memberOf(row); diff --git a/packages/objectql/src/protocol-packageless-overlay-migration.test.ts b/packages/objectql/src/protocol-packageless-overlay-migration.test.ts index 74c4f43ee91..1012e3cc17d 100644 --- a/packages/objectql/src/protocol-packageless-overlay-migration.test.ts +++ b/packages/objectql/src/protocol-packageless-overlay-migration.test.ts @@ -353,6 +353,23 @@ describe('[#28413] the census and the move', () => { expect((await census(protocol)).incompleteDeclarations).toEqual([]); }); + it('[#29594] a module\'s override in its App\'s pair is listed under the module it declares, not the App, and completed', async () => { + const { engine, protocol } = await boot(); + // The base as an App, and a module that declares it: their pair is the App's (ADR-0146 D3 as amended). + const MODULE = `${BASE}.ops`; + const TICKET_VIEW = 'showcase_ticket.default'; + engine.registerApp({ id: BASE, name: 'showcase', version: '1.2.0', type: 'app', namespace: 'showcase' } as any); + engine.registerApp({ + id: MODULE, name: 'showcase-ops', version: '1.2.0', type: 'module', namespace: 'showcase', + dependencies: { [BASE]: '^1.2.0' }, + objects: [objectOf('showcase_ticket', BASE_COLUMNS)], views: [listView('showcase_ticket', BASE_COLUMNS)], + } as any); + expect(((await (protocol as any).ensureCustomizationPackage({ basePackageId: MODULE })) as any).packageId).toBe(PAIR); + await storeRow(engine, TICKET_VIEW, 'showcase_ticket', EDITED_COLUMNS, PAIR, 'active', { overrides: { package: MODULE } }); + expect((await census(protocol)).incompleteDeclarations.map((d: any) => [d.name, d.pair, d.base])) + .toEqual([[TICKET_VIEW, PAIR, MODULE]]); + }); + it('…on every overridable type, not only the five overlay types (a page)', async () => { const { engine, protocol, stores } = await boot(); engine.registerApp({ id: BASE, name: 'showcase', version: '1.2.0', namespace: 'showcase', pages: [pageBody(PAGE, 'Home')] } as any); diff --git a/packages/qa/dogfood/test/customization-pair.ts b/packages/qa/dogfood/test/customization-pair.ts index 31aa285fd9c..44010b14716 100644 --- a/packages/qa/dogfood/test/customization-pair.ts +++ b/packages/qa/dogfood/test/customization-pair.ts @@ -11,14 +11,23 @@ import { expect } from 'vitest'; import type { VerifyStack } from '@objectstack/verify'; -/** The `.custom` pair of a base package. */ +/** + * The `.custom` pair of a base package that is its own customization unit — + * an App, or a package that declares no App. Every base these suites + * customize is one. A module's pair is its App's (ADR-0146 D3 as amended), + * which only the server answers: {@link ensurePair} returns the server's + * answer and asserts it is this one, so a suite that customizes a module + * fails here, by name, instead of addressing a pair that does not exist. + */ export const pairOf = (base: string): string => `${base}.custom`; -/** Ensure the base's pair exists (`201` the first time, `200` after). */ +/** Ensure the base's pair exists (`201` the first time, `200` after); the pair the server answers. */ export async function ensurePair(stack: VerifyStack, token: string, base: string): Promise { const res = await stack.apiAs(token, 'POST', `/packages/${base}/ensure-customization`); expect([200, 201], await res.clone().text()).toContain(res.status); - return pairOf(base); + const answered = ((await res.json()) as { data?: { packageId?: unknown } })?.data?.packageId; + expect(answered, `the pair the ensure door answers for '${base}'`).toBe(pairOf(base)); + return answered as string; } /** `path` addressed to the base's pair (`package=.custom` added to its query). */ diff --git a/packages/runtime/src/domains/meta-object-extension-item.test.ts b/packages/runtime/src/domains/meta-object-extension-item.test.ts index a0f32a74dcb..7e452bad1ba 100644 --- a/packages/runtime/src/domains/meta-object-extension-item.test.ts +++ b/packages/runtime/src/domains/meta-object-extension-item.test.ts @@ -490,7 +490,7 @@ describe('[#29594] the App unit: HotCRM and three modules share one pair, and a expect(engine.registry.getObjectContributors(CASE).filter((c: any) => c.ownership === 'extend').map((c: any) => c.packageId)) .toEqual([APP_PAIR]); // A record stores a value in the field. - const created: any = await engine.insert(CASE, { subject: 'Outage', sla_tier__c: 'gold' }, { context: { isSystem: true } }); + const created: any = await engine.insert(CASE, { subject: 'Outage', sla_tier__c: 'gold', organization_id: 'org_1' }, { context: { isSystem: true } }); const back: any = await engine.findOne(CASE, { where: { id: created.id }, context: { isSystem: true } }); expect(back?.sla_tier__c).toBe('gold'); }, 60_000); From c27d47731458bdd1d616f743a6a91a20808f5eb7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 13:07:20 +0000 Subject: [PATCH 06/15] fix: uninstall refusal reads the pair named for the package; pin appOf's kernel scopes Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2dM9PvPPB9BMVdhf2R --- .../package-writability-kernel-scopes.test.ts | 40 +++++++++++++++++++ ...istry-assert-package-uninstallable.test.ts | 36 +++++++++++++++++ packages/objectql/src/registry.ts | 23 +++++------ 3 files changed, 87 insertions(+), 12 deletions(-) create mode 100644 packages/metadata-protocol/src/package-writability-kernel-scopes.test.ts diff --git a/packages/metadata-protocol/src/package-writability-kernel-scopes.test.ts b/packages/metadata-protocol/src/package-writability-kernel-scopes.test.ts new file mode 100644 index 00000000000..38fd359cf67 --- /dev/null +++ b/packages/metadata-protocol/src/package-writability-kernel-scopes.test.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29594, ADR-0146 D3 as amended] `@objectstack/metadata-core`'s `appOf` + * keeps its own copy of the kernel scopes — that package sits below this one + * and cannot import {@link READ_ONLY_PACKAGE_SCOPES}. This pin holds the two + * equal from the side that can see both: a package of every read-only scope + * is its own App reading (the kernel is not customizable this version, ruling + * record 6691217245), even when it declares an App; a package of any other + * scope declaring one App reads that App. + */ +import { describe, expect, it } from 'vitest'; +import { appOf, type InstalledManifestLookup } from '@objectstack/metadata-core'; +import { READ_ONLY_PACKAGE_SCOPES } from './package-writability.js'; + +const APP = 'com.acme.app'; + +function installedWith(id: string, scope: string): InstalledManifestLookup { + const manifests: Record> = { + [APP]: { id: APP, type: 'app', namespace: 'acme' }, + [id]: { id, type: 'module', namespace: 'acme', scope, dependencies: { [APP]: '^1' } }, + }; + return (packageId) => manifests[packageId] as never; +} + +describe('[#29594] appOf\'s kernel scopes are READ_ONLY_PACKAGE_SCOPES', () => { + it('a package of every read-only scope is its own unit, even when it declares an App', () => { + expect(READ_ONLY_PACKAGE_SCOPES.length).toBeGreaterThan(0); + for (const scope of READ_ONLY_PACKAGE_SCOPES) { + expect(appOf('com.acme.kernelish', installedWith('com.acme.kernelish', scope)), scope).toBe('com.acme.kernelish'); + } + }); + + it('a package of a writable scope declaring one App reads that App', () => { + for (const scope of ['project', 'environment', 'platform']) { + if (READ_ONLY_PACKAGE_SCOPES.includes(scope)) continue; + expect(appOf('com.acme.member', installedWith('com.acme.member', scope)), scope).toBe(APP); + } + }); +}); diff --git a/packages/objectql/src/registry-assert-package-uninstallable.test.ts b/packages/objectql/src/registry-assert-package-uninstallable.test.ts index c71c9b4ab9a..a9bd6bf305b 100644 --- a/packages/objectql/src/registry-assert-package-uninstallable.test.ts +++ b/packages/objectql/src/registry-assert-package-uninstallable.test.ts @@ -280,3 +280,39 @@ describe('#28408 (ADR-0146 D3) a base whose paired customization package is inst expect(forced.getObject('account')).toBeUndefined(); }); }); + +describe('[#29594] ADR-0146 D3 (as amended): the App is the unit — a member is not refused for the App\'s pair', () => { + const APP = 'app.objectstack.hotcrm'; + const MODULE = 'app.objectstack.hotcrm.service'; + const APP_PAIR = 'app.objectstack.hotcrm.custom'; + const LEGACY_PAIR = 'app.objectstack.hotcrm.service.custom'; + + function hotcrm(opts: { legacy?: boolean } = {}) { + const r = new SchemaRegistry({ multiTenant: false }); + (r as any).logLevel = 'silent'; + r.installPackage({ id: APP, name: 'HotCRM', version: '6.0.0', type: 'app', namespace: 'crm', dependencies: { [MODULE]: '^6' } } as any, 'marketplace'); + r.installPackage({ id: MODULE, name: 'HotCRM Service', version: '6.0.0', type: 'module', namespace: 'crm', dependencies: { [APP]: '^6' } } as any, 'marketplace'); + r.registerObject({ name: 'service_case', fields: {} } as any, MODULE, 'crm', 'own'); + r.installPackage({ id: APP_PAIR, name: 'HotCRM (custom)', version: '0.1.0', type: 'module', namespace: 'crm_custom', dependencies: { [APP]: '^6' } } as any, 'authored'); + if (opts.legacy) { + r.installPackage({ id: LEGACY_PAIR, name: 'HotCRM Service (custom)', version: '0.1.0', type: 'module', namespace: 'crm_svc_custom', dependencies: { [MODULE]: '^6' } } as any, 'authored'); + } + return r; + } + + it('the App is refused naming the App\'s pair; a member module is not', () => { + const r = hotcrm(); + expect(thrownBy(() => r.assertPackageUninstallable(APP)).customizationPackageId).toBe(APP_PAIR); + expect(() => r.assertPackageUninstallable(MODULE)).not.toThrow(); + }); + + it('a member that still has the pair an earlier version created for it is refused naming that pair (merge question 12)', () => { + const r = hotcrm({ legacy: true }); + const err = thrownBy(() => r.assertPackageUninstallable(MODULE)); + expect({ code: err.code, status: err.status, pair: err.customizationPackageId }).toEqual({ + code: 'RESOURCE_CONFLICT', + status: 409, + pair: LEGACY_PAIR, + }); + }); +}); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 2437b962da5..c8604970d97 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -56,7 +56,7 @@ import { SystemFieldName, scalarOverridesPackagedBase } from '@objectstack/spec/ import { resolveTenancyPosture, resolveSearchPinyinEnabled } from '@objectstack/types'; import { postureEnforcesWall } from '@objectstack/spec/security'; import { provisionSearchCompanion, SEARCH_COMPANION_FIELD } from './search-companion.js'; -import { ObjectStackManifest, ManifestSchema, InstalledPackage, InstalledPackageSchema, InstalledPackageSourceSchema, type InstalledPackageSource, checkFieldCompleteness } from '@objectstack/spec/kernel'; +import { ObjectStackManifest, ManifestSchema, InstalledPackage, InstalledPackageSchema, InstalledPackageSourceSchema, type InstalledPackageSource, checkFieldCompleteness, customizationBasePackageId } from '@objectstack/spec/kernel'; import { DEFAULT_FLOW_FUNCTION_EFFECT } from '@objectstack/spec/automation'; import { AppSchema, ActionSchema } from '@objectstack/spec/ui'; import { applyProtection } from '@objectstack/spec/shared'; @@ -3336,7 +3336,7 @@ const EXTENDED_OBJECTS_LISTED = 5; * so one answer is the whole way out rather than the first step of several. * * [#28408, ADR-0146 D3] …and the uninstall refusal of a base whose paired - * customization package is installed and stands on it: the App's pair of an + * customization package is installed and named for it: the App's pair of an * App, or the per-package pair an earlier version created for a package * ({@link pairsOf}), held by this registry. That package * holds every customization of the base, so uninstalling the base under it @@ -7353,9 +7353,9 @@ export class SchemaRegistry { * package is refused exactly when the first such object exists. * * [#28408, ADR-0146 D3] It also refuses while this registry holds the - * package's paired customization package that declares `packageId` — one - * of the ids {@link pairsOf} looks up for it (the App's pair stands on the - * App, not on a member) — so the pair is + * package's paired customization package named for `packageId` — one + * of the ids {@link pairsOf} looks up for it (the App's pair is named for + * the App, not for a member) — so the pair is * found by id alone, whichever door created or installed it. The same * refusal, carrying both causes when both hold. Uninstalling the pair itself * asks nothing of this kind: a `.custom` id derives no pair of its own. An @@ -7386,15 +7386,14 @@ export class SchemaRegistry { extended.push({ objectName: fqn, extenders: otherExtenders.map(c => String(c.packageId)) }); } } - // [ADR-0146 D3 · merge questions 11, 12] The installed pair that stands - // on this package: the App's pair when this is the App (a member's + // [ADR-0146 D3 · merge questions 11, 12] The installed pair named for + // this package: the App's pair when this is the App (a member's // uninstall keeps and marks its overrides in the App's pair, and is not // refused for it), or the per-package pair an earlier version created - // for this package — each declares the package it stands on. - const installedPair = pairsOf(packageId, installedManifestsOf(this)).find((id) => { - const dependencies = (this.getPackage(id)?.manifest as { dependencies?: unknown } | undefined)?.dependencies; - return typeof dependencies === 'object' && dependencies !== null && Object.prototype.hasOwnProperty.call(dependencies, packageId); - }); + // for this package. + const installedPair = pairsOf(packageId, installedManifestsOf(this)).find( + (id) => customizationBasePackageId(id) === packageId && this.getPackage(id) !== undefined, + ); if (extended.length > 0 || installedPair !== undefined) { throw new PackageUninstallExtendedError(packageId, extended, installedPair); } From b358e88b232bdc49ef7827c2249f92c28deaf969 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 13:18:52 +0000 Subject: [PATCH 07/15] docs(changeset): say what the uninstall refusal reads for an App and its modules Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2dM9PvPPB9BMVdhf2R --- .changeset/29594-app-customization-pair.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/29594-app-customization-pair.md b/.changeset/29594-app-customization-pair.md index 5e32a8a28a9..9c08c46f1df 100644 --- a/.changeset/29594-app-customization-pair.md +++ b/.changeset/29594-app-customization-pair.md @@ -19,7 +19,7 @@ What changes for a caller: - A per-package pair an earlier version created for a module (`.custom`) stays installed and served: its declared overrides, list rows, translations and served-overrides census are read beside the App's pair (`pairsOf` / `isPairOf`). While it holds the App's pair namespace, the ensure door answers `409 RESOURCE_CONFLICT` naming it instead of creating the App's pair beside it (merge question 12). - A package that reaches several Apps and whose namespace picks none of them has no pair: the ensure door answers `409` (merge question 11). - The App's pair's write closure reaches every member of the App, so an override or an object extension of a module's item saved into the App's pair is admitted. -- Uninstalling a module is no longer refused because the App's pair is installed; uninstalling the App still is. +- The uninstall refusal for an installed pair reads the pair named for the package: uninstalling the App is refused while the App's pair is installed, uninstalling a module is not (its customizations are the App pair's), and a module whose own earlier per-package pair is installed is still refused naming that pair. - `os validate`'s `namespace-no-customization-pair` warning skips a member of a delivered App: its customizations land in the App's pair. TypeScript: `appOf` now answers `string | undefined` (`undefined` = no single App), and `pairOf`'s refusal union gains `no_single_app` (`PairOfRefusal`); `pairsOf` and the `AppOfRefusal` / `PairOfRefusal` types are new exports of `@objectstack/metadata-core`. From 5ac6ed0f29999f74909c2ac536f3ab342de6a52e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 13:37:30 +0000 Subject: [PATCH 08/15] test: lint reads metadata-core from source; the rehearsal pays the metadata load at collection Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2dM9PvPPB9BMVdhf2R --- packages/lint/vitest.config.ts | 13 ++++++++++++- .../src/domains/meta-object-extension-item.test.ts | 4 ++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/lint/vitest.config.ts b/packages/lint/vitest.config.ts index 4ecbe245f44..97406fbe3e9 100644 --- a/packages/lint/vitest.config.ts +++ b/packages/lint/vitest.config.ts @@ -1,14 +1,25 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// This config exists for exactly two settings; everything else stays on +// This config exists for exactly three settings; everything else stays on // vitest's defaults, deliberately — a key added here re-specifies behaviour // for every test file in the package (packages/cli/vitest.config.ts's header // records the incident that taught that). Both settings are package-wide on // purpose: each removes a mechanism every file here is exposed to, rather // than tuning one file. import { defineConfig } from 'vitest/config'; +import path from 'path'; export default defineConfig({ + // `@objectstack/metadata-core` is read from source, never its dist/, so a + // unit test here is a verdict about the checkout + // (scripts/check-test-source-alias.mjs). It is the one workspace package + // lint's source imports that is not spec: `appOf`, which the customization + // namespace advisory asks. Anchored, so it cannot swallow a subpath. + resolve: { + alias: [ + { find: /^@objectstack\/metadata-core$/, replacement: path.resolve(__dirname, '../metadata-core/src/index.ts') }, + ], + }, test: { // A late console.* must not redden a green suite (#10374): vitest's worker // forwards console output over RPC and discards the promise, and a write diff --git a/packages/runtime/src/domains/meta-object-extension-item.test.ts b/packages/runtime/src/domains/meta-object-extension-item.test.ts index 7e452bad1ba..77b6e553d51 100644 --- a/packages/runtime/src/domains/meta-object-extension-item.test.ts +++ b/packages/runtime/src/domains/meta-object-extension-item.test.ts @@ -36,6 +36,10 @@ import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protoco import { SysMetadata, SysMetadataHistoryObject, SysMetadataAuditObject } from '@objectstack/platform-objects/metadata'; import { ObjectExtensionSchema } from '@objectstack/spec/data'; import { HttpDispatcher } from '../http-dispatcher.js'; +// The [#29594] rehearsal boots the real dispatcher, whose package door reaches +// `import('@objectstack/metadata')` in standalone-stack.ts; pay that load +// during collection, never inside a clocked case (check:test-source-alias). +import '@objectstack/metadata'; let home: string; let priorHome: string | undefined; From 775c97b55e9e5c06f3c29fc6c0c7d4e96a790637 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 14:00:29 +0000 Subject: [PATCH 09/15] docs(changeset): declare the narrowing, the BREAKING banner with its remedies, and the ADR-0087 disposition Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2dM9PvPPB9BMVdhf2R --- .changeset/29594-app-customization-pair.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.changeset/29594-app-customization-pair.md b/.changeset/29594-app-customization-pair.md index 9c08c46f1df..c8f9fcc40cd 100644 --- a/.changeset/29594-app-customization-pair.md +++ b/.changeset/29594-app-customization-pair.md @@ -5,11 +5,13 @@ '@objectstack/lint': minor --- -feat(metadata-core, metadata-protocol, objectql, lint): an App and the modules that declare it share one customization pair +feat(metadata-core, metadata-protocol, objectql, lint)!: an App and the modules that declare it share one customization pair -Clause-②: no +Clause-②: yes (narrowing) — the ensure door refuses two asks it accepted: a package that reaches several Apps and whose namespace picks none of them (was `201` with its own pair, now `409`, merge question 11), and a module whose earlier per-package pair holds the App's pair namespace (was `201`/`200`, now `409 RESOURCE_CONFLICT` naming that pair, merge question 12); `appOf` now returns `string | undefined`. The widening half: a module of an App gets the App's pair (`200`, where it was `422 NAMESPACE_CONFLICT` when the App held the namespace), and `@objectstack/metadata-core` exports `pairsOf`, `AppOfRefusal` and `PairOfRefusal`. Ruling 6691022506; ADR-0146 D3 as amended by #31027. Metadata-protocol: semantic + + The customization unit is the App (ADR-0146 D3 as amended, ruling record 6691022506). `appOf` in `@objectstack/metadata-core` now reads the installed manifests: a `type: 'app'` package is its own App; any other package's App is the installed `type: 'app'` package it declares in `manifest.dependencies`, directly or through its declared parents (where it reaches several, the one holding its namespace); a package that declares no App is its own unit; a kernel package (scope `system` / `cloud`) keeps its own answer, because no platform pair is created in this version. What changes for a caller: @@ -23,3 +25,9 @@ What changes for a caller: - `os validate`'s `namespace-no-customization-pair` warning skips a member of a delivered App: its customizations land in the App's pair. TypeScript: `appOf` now answers `string | undefined` (`undefined` = no single App), and `pairOf`'s refusal union gains `no_single_app` (`PairOfRefusal`); `pairsOf` and the `AppOfRefusal` / `PairOfRefusal` types are new exports of `@objectstack/metadata-core`. + +**BREAKING** accept-set narrowing at `POST /api/v1/packages/:id/ensure-customization` (`@objectstack/metadata-protocol`) and a return-type change in `@objectstack/metadata-core`. + +- **A package that reaches several Apps and whose namespace picks none.** Before: `201` with its own pair `.custom`. Now: `409 RESOURCE_CONFLICT` saying the package declares several Apps and its namespace picks no single one of them, and nothing is created. Remedy: declare the one App the package belongs to in `manifest.dependencies`, or give it the namespace of the App it belongs to. +- **A module whose earlier per-package pair holds the App's pair namespace.** Before: the module's own `.custom` (`201`, then `200`). Now: `409 RESOURCE_CONFLICT` naming that pair, from the App and from every member; the pair stays installed and its customizations stay served. Remedy: keep customizing in the existing pair (its id is in the refusal), or export it (`GET /api/v1/packages//export`), delete it, and ensure again to get the App's pair. +- **`appOf` returns `string | undefined`** (was `string`). `undefined` means no single App. Remedy: handle `undefined` (`appOf(id, installed) ?? id` keeps the old reading); `pairOf`'s refusal reason may now also be `'no_single_app'` (`PairOfRefusal`). From c0172ddddcbd1a4b9338f690d68cd4064e749b58 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 14:55:41 +0000 Subject: [PATCH 10/15] fix: a legacy per-package pair removes its own override and its added members and extensions stay read; pin merge question 12 end to end Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2dM9PvPPB9BMVdhf2R --- .../metadata-protocol/src/protocol-host.ts | 2 +- .../src/protocol-packaged-base-refusal.ts | 32 ++-- .../src/protocol-write-gates.ts | 41 +++-- packages/metadata-protocol/src/protocol.ts | 8 +- .../domains/meta-legacy-pair-served.test.ts | 163 ++++++++++++++++++ packages/runtime/src/domains/packages.ts | 20 ++- 6 files changed, 226 insertions(+), 40 deletions(-) create mode 100644 packages/runtime/src/domains/meta-legacy-pair-served.test.ts diff --git a/packages/metadata-protocol/src/protocol-host.ts b/packages/metadata-protocol/src/protocol-host.ts index 8355ced2bc6..5728ba5e084 100644 --- a/packages/metadata-protocol/src/protocol-host.ts +++ b/packages/metadata-protocol/src/protocol-host.ts @@ -176,7 +176,7 @@ export interface ProtocolHost { readonly packagelessDraftAddress: ObjectStackProtocolImplementation['packagelessDraftAddress']; readonly packagelessOverlayWriteRefusal: ObjectStackProtocolImplementation['packagelessOverlayWriteRefusal']; readonly packagelessReadAddress: ObjectStackProtocolImplementation['packagelessReadAddress']; - readonly pairOfAddedMember: ObjectStackProtocolImplementation['pairOfAddedMember']; + readonly pairsOfAddedMember: ObjectStackProtocolImplementation['pairsOfAddedMember']; readonly parseCommitItems: ObjectStackProtocolImplementation['parseCommitItems']; readonly permissionSetNameCollisionRefusal: ObjectStackProtocolImplementation['permissionSetNameCollisionRefusal']; readonly probeRecord: ObjectStackProtocolImplementation['probeRecord']; diff --git a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts index a51726e0338..cca50ea0d34 100644 --- a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts +++ b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts @@ -204,10 +204,13 @@ function sealedDeclareOrRenameTail( const pair = pairOf(basePackageId, installed); if (!pair.ok) return ''; const writer = typeof writerPackageId === 'string' && writerPackageId.length > 0 ? writerPackageId : undefined; - if (writer !== undefined && writer !== pair.value) { + // [#29594, merge question 12] A per-package pair an earlier version + // created for the base still declares its overrides: it is told so. + const writerIsPair = writer !== undefined && isPairOf(writer, basePackageId, installed); + if (writer !== undefined && !writerIsPair) { return ` To change it, rename the item: only '${pair.value}' may declare an override of '${basePackageId}'.`; } - return ` To change it, declare an override from '${pair.value}' ` + return ` To change it, declare an override from '${writerIsPair ? writer : pair.value}' ` + `(overrides: { package: '${basePackageId}', basedOn }), or rename the item.`; } @@ -229,10 +232,11 @@ function sealedDeclareOrRenameClause( const pair = pairOf(basePackageId, installed); if (!pair.ok) return undefined; const writer = typeof writerPackageId === 'string' && writerPackageId.length > 0 ? writerPackageId : undefined; - if (writer !== undefined && writer !== pair.value) { + const writerIsPair = writer !== undefined && isPairOf(writer, basePackageId, installed); + if (writer !== undefined && !writerIsPair) { return `Rename it to customize it (only '${pair.value}' may declare an override of '${basePackageId}')`; } - return `Declare an override from '${pair.value}' or rename it to customize it`; + return `Declare an override from '${writerIsPair ? writer : pair.value}' or rename it to customize it`; } // ───────────────────────────────────────────────────────────────────────── @@ -752,7 +756,8 @@ function isOpenOverrideType(singular: string): boolean { * overlay channel or the #6960 carve-out); the removal is now told to the * store as runtime-only for all of them, as the declared save wrote the row. * - * ⛔ Only the pair, named: a removal naming no package, the base, or any + * ⛔ Only a pair of the base, named ({@link isPairOf}: the App's pair, or the + * per-package pair an earlier version created for the base): a removal naming no package, the base, or any * other package, keeps the refusal it had — a base's own item is never * removed. ⛔ Only an open type: every excluded type keeps main's delete * verdict. [#30065] `action` is open, so a pair's removal of its declared @@ -768,8 +773,10 @@ export function isPairRowRemoval( if (!isOpenOverrideType(singular)) return false; if (typeof basePackageId !== 'string') return false; if (typeof packageId !== 'string' || packageId.length === 0) return false; - const pair = pairOf(basePackageId, installed); - return pair.ok && pair.value === packageId; + // [#29594, ADR-0146 D3 merge question 12] Any pair of the base: the App's + // pair, or the per-package pair an earlier version created for it, which + // still removes its own declared override as it did before. + return isPairOf(packageId, basePackageId, installed); } /** @@ -1204,15 +1211,18 @@ export function judgeOverrideWrite( if (packageId === undefined || closure.shippers.length === 0) return NOT_CONCERNED; if (getOverrideTypeClassification(type)?.disposition !== 'overridable') return NOT_CONCERNED; - const pairBase = customizationBasePackageId(packageId); - const base = pairBase !== null && closure.shippers.includes(pairBase) ? pairBase : closure.shippers[0]; - if (pairBase === base && isOpenOverrideType(type)) { + // [#29594, ADR-0146 D3] The shipper `packageId` is a pair of: the App's + // pair is a pair of every member of the App, and a per-package pair an + // earlier version created is its base's (merge question 12). + const pairedShipper = closure.shippers.find((shipper) => isPairOf(packageId, shipper, closure.installed)); + const base = pairedShipper ?? closure.shippers[0]; + if (pairedShipper !== undefined && isOpenOverrideType(type)) { return refuse('undeclared_same_name', `${item} in '${packageId}' bears the name of the ${type} '${base}' ships but declares no override. ` + `Declare it, adding overrides: { package: '${base}', basedOn } with the version of '${base}' it was ` + 'taken from, or rename the item (ADR-0146 D2).'); } - const notYet = pairBase === base ? ` ${type} customization is not yet available.` : ''; + const notYet = pairedShipper !== undefined ? ` ${type} customization is not yet available.` : ''; return refuse('undeclared_same_name', `${item} in package '${packageId}' bears the name of the ${type} '${base}' ships, inside its dependency ` + `closure.${notYet} A dependent package adds items under its own names and never replaces a base item. ` diff --git a/packages/metadata-protocol/src/protocol-write-gates.ts b/packages/metadata-protocol/src/protocol-write-gates.ts index 24b162223d6..b9477b0cc19 100644 --- a/packages/metadata-protocol/src/protocol-write-gates.ts +++ b/packages/metadata-protocol/src/protocol-write-gates.ts @@ -11,7 +11,7 @@ * `packagedArtifactOwner`, `storedFlowBindingAgrees`, * `packagelessOverlayWriteRefusal`, `namedBaseOverlayWriteRefusal`, * `refusePackagedBaseRemoval`, `shipsBundleName`, `baseServesBundleMember`, - * `pairOfAddedMember`, `findPairAddedMemberRow`, `registryMemberServedAt`, + * `pairsOfAddedMember`, `findPairAddedMemberRow`, `registryMemberServedAt`, * `recordOptimisticConflictAudit`, `envWritableTypes`, * `organizationScopedWriteRefusal`, `requestedOrganization`, * `isPackagedBaseRefusal`, `optimisticConflictAuditEntry` moved out of @@ -37,7 +37,7 @@ import { resolveTenancyPosture } from '@objectstack/types'; import { envWritableMetadataTypes } from './sys-metadata-repository.js'; import { managedItemSealedSentence } from './packaged-base-regime.js'; import type { StoredOverlayRow } from './item-lock.js'; -import { itemDiscriminator, pairOf } from '@objectstack/metadata-core'; +import { itemDiscriminator, pairsOf } from '@objectstack/metadata-core'; import { installedManifestsOfEngine } from './installed-manifests.js'; import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; @@ -646,33 +646,42 @@ export class ProtocolWriteGatesFacet { } /** - * [#28802, #28390 ruling A] The pair of `base` when the bundle member - * `member` (its discriminator value) of the name `name` is one only the + * [#28802, #28390 ruling A] The pairs of `base` when the bundle member + * `member` (its discriminator value) of the name `name` is one only a * pair can add on `base`'s address: `base` ships the name and not that * member. Else `undefined`, and the by-name read serves as before. + * + * [#29594, ADR-0146 D3 merge question 12] Every pair of `base` + * ({@link pairsOf}), the App's pair first: a member the per-package pair + * an earlier version created for `base` added is served as it was. */ - static pairOfAddedMember(host: Pick, type: string, name: string, base: string, member: string): string | undefined { + static pairsOfAddedMember(host: Pick, type: string, name: string, base: string, member: string): readonly string[] | undefined { const shipping = host.shippingPackagesOf(type); if (!host.shipsBundleName(type, name, base, shipping)) return undefined; if (shippingPackageIds(host.shippedArtifactsOf(type, name, shipping, member)).has(base)) return undefined; - const pair = pairOf(base, installedManifestsOfEngine(host.engine)); - return pair.ok ? pair.value : undefined; + const pairs = pairsOf(base, installedManifestsOfEngine(host.engine)); + return pairs.length > 0 ? pairs : undefined; } /** * [#28802, #28830] The pair's own stored row at the bundle member `member` * (`wantedMember` its discriminator value) of the name `name`, when only - * the pair can add that member on `base`'s address - * ({@link pairOfAddedMember}); else `undefined`. The one read of it the - * by-name read and the layered read share. + * a pair can add that member on `base`'s address + * ({@link pairsOfAddedMember}); else `undefined`. The one read of it the + * by-name read and the layered read share. [#29594] The first pair, in + * {@link pairsOf} order, that holds the row. */ - static async findPairAddedMemberRow(host: Pick, type: string, name: string, base: string, member: string | undefined, wantedMember: string, + static async findPairAddedMemberRow(host: Pick, type: string, name: string, base: string, member: string | undefined, wantedMember: string, activeRows?: readonly StoredOverlayRow[],): Promise { - const pairOfAddition = host.pairOfAddedMember(type, name, base, wantedMember); - if (pairOfAddition === undefined) return undefined; - return (await host.findServedOverlayRow({ - type, name, state: 'active', packageId: pairOfAddition, ...memberOption(member), - }, activeRows))?.row; + const pairsOfAddition = host.pairsOfAddedMember(type, name, base, wantedMember); + if (pairsOfAddition === undefined) return undefined; + for (const packageId of pairsOfAddition) { + const row = (await host.findServedOverlayRow({ + type, name, state: 'active', packageId, ...memberOption(member), + }, activeRows))?.row; + if (row !== undefined && row !== null) return row; + } + return undefined; } /** diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index bc04ece0101..7c43e707e7a 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -1300,7 +1300,7 @@ export class ObjectStackProtocolImplementation implements packagelessDraftAddress: (...a) => protocol.packagelessDraftAddress(...a), packagelessOverlayWriteRefusal: (...a) => protocol.packagelessOverlayWriteRefusal(...a), packagelessReadAddress: (...a) => protocol.packagelessReadAddress(...a), - pairOfAddedMember: (...a) => protocol.pairOfAddedMember(...a), + pairsOfAddedMember: (...a) => protocol.pairsOfAddedMember(...a), parseCommitItems: (...a) => protocol.parseCommitItems(...a), permissionSetNameCollisionRefusal: (...a) => protocol.permissionSetNameCollisionRefusal(...a), probeRecord: (...a) => protocol.probeRecord(...a), @@ -7420,10 +7420,10 @@ export class ObjectStackProtocolImplementation implements return ProtocolWriteGatesFacet.baseServesBundleMember(protocolHost(this), type, name, base, item, shipping); } - /** Docblock and body: {@link ProtocolWriteGatesFacet.pairOfAddedMember} in `protocol-write-gates.ts` [#29985 codemod]. */ - private pairOfAddedMember(type: string, name: string, base: string, member: string): string | undefined { + /** Docblock and body: {@link ProtocolWriteGatesFacet.pairsOfAddedMember} in `protocol-write-gates.ts` [#29985 codemod]. */ + private pairsOfAddedMember(type: string, name: string, base: string, member: string): readonly string[] | undefined { // [#29985 codemod] The body moved to `protocol-write-gates.ts`; it reaches this class through `protocol-host.ts`. - return ProtocolWriteGatesFacet.pairOfAddedMember(protocolHost(this), type, name, base, member); + return ProtocolWriteGatesFacet.pairsOfAddedMember(protocolHost(this), type, name, base, member); } /** Docblock and body: {@link ProtocolWriteGatesFacet.findPairAddedMemberRow} in `protocol-write-gates.ts` [#29985 codemod]. */ diff --git a/packages/runtime/src/domains/meta-legacy-pair-served.test.ts b/packages/runtime/src/domains/meta-legacy-pair-served.test.ts new file mode 100644 index 00000000000..1974226306f --- /dev/null +++ b/packages/runtime/src/domains/meta-legacy-pair-served.test.ts @@ -0,0 +1,163 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29594, ADR-0146 D3 as amended, merge question 12] A per-package pair an + * earlier version created for a module of an App (`.custom`) is + * honoured as it was: the declared override it holds of an item the module + * ships is served at the module's address — by name, in the list, and in the + * layered read — and the pair can still remove that override, after which + * the module's own item is served again ("Restore package original"). + * + * Through the doors a client calls: the reads through `HttpDispatcher`'s + * catch-all (the `@objectstack/hono` path), the removal through `RestServer`'s + * `DELETE /meta/:type/:name` (the catch-all answers `405` for a delete). One + * real engine, SQL store and protocol behind both; the App lookup is the real + * `appOf` over the registry's installed manifests, nothing stubbed. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SysMetadata, SysMetadataHistoryObject, SysMetadataAuditObject } from '@objectstack/platform-objects/metadata'; +import { RestServer } from '@objectstack/rest'; +import { HttpDispatcher } from '../http-dispatcher.js'; +// The dispatcher's package door reaches `import('@objectstack/metadata')` in +// standalone-stack.ts; pay that load during collection, never inside a +// clocked case (check:test-source-alias). +import '@objectstack/metadata'; + +const PREFIX = '/api/v1'; +const META = `${PREFIX}/meta`; +const TEST_PACKAGE_ID = 'objectstack-test'; +const APP = 'app.objectstack.hotcrm'; +const MODULE = `${APP}.service`; +const LEGACY_PAIR = `${MODULE}.custom`; +const BOARD = 'crm_service_board'; +const SHIPPED = 'Service board (shipped)'; +const CUSTOMIZED = 'Service board (customized in the legacy pair)'; +const AUTHOR = { userId: 'u_author', isSystem: false, systemPermissions: ['manage_metadata'] }; + +const board = (label: string) => ({ name: BOARD, label, columns: 12, widgets: [] }); +const clone = (v: T): T => JSON.parse(JSON.stringify(v)); + +const liveEngines: ObjectQL[] = []; +afterEach(async () => { + while (liveEngines.length) { + try { await liveEngines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +function makeRes() { + const res: any = { statusCode: 200, body: undefined, headers: {} as Record }; + res.status = (code: number) => { res.statusCode = code; return res; }; + res.json = (body: any) => { res.body = body; return res; }; + res.send = () => res; + res.end = () => res; + res.header = (k: string, v: string) => { res.headers[k] = v; return res; }; + res.setHeader = () => {}; + res.write = () => true; + return res; +} + +async function boot() { + const engine = new ObjectQL(); + liveEngines.push(engine); + engine.registerDriver(new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }), true); + await engine.init(); + engine.registry.registerObject(SysMetadata as any, TEST_PACKAGE_ID); + engine.registry.registerObject(SysMetadataHistoryObject as any, TEST_PACKAGE_ID); + engine.registry.registerObject(SysMetadataAuditObject as any, TEST_PACKAGE_ID); + (engine.registry as unknown as { logLevel: string }).logLevel = 'silent'; + // HotCRM's shape: the App, and a module that declares it and ships a dashboard. + engine.registerApp({ id: APP, name: 'hotcrm', version: '6.1.0', type: 'app', namespace: 'crm' }); + engine.registerApp({ + id: MODULE, name: 'hotcrm-service', version: '6.1.0', type: 'module', namespace: 'crm', + dependencies: { [APP]: '^6.1.0' }, + objects: [{ name: 'crm_service_case', label: 'Case', fields: { subject: { name: 'subject', label: 'Subject', type: 'text' } } }], + dashboards: [board(SHIPPED)], + }); + // The per-package pair an earlier version's ensure door created for the module. + engine.registry.installPackage({ + id: LEGACY_PAIR, name: 'hotcrm-service (custom)', version: '0.1.0', type: 'module', namespace: 'crm_svc_custom', + dependencies: { [MODULE]: '^6' }, + } as any, 'authored'); + await engine.syncSchemas(); + const protocol: any = new ObjectStackProtocolImplementation(engine as any, () => new Map()); + + // The reads: the dispatcher's catch-all. + const get = (n: string) => (n === 'protocol' ? protocol : n === 'objectql' ? engine : null); + const kernel: any = { context: { getService: get }, getService: get, getServiceAsync: async (n: string) => get(n) }; + const dispatcher = new HttpDispatcher(kernel); + (dispatcher as any).timedResolveExecutionContext = async () => clone(AUTHOR); + const read = async (path: string, query: Record = {}) => { + const res = await dispatcher.dispatch('GET', path, undefined, query, { request: new Request(`http://localhost${PREFIX}${path}`) }, PREFIX); + return { status: res.response?.status ?? 0, body: res.response?.body }; + }; + + // The removal: RestServer's DELETE route. + const noop = () => {}; + const server = { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; + const rest: any = new RestServer(server as any, protocol as any, { api: { requireAuth: false } } as any); + rest.resolveExecCtx = async () => clone(AUTHOR); + rest.registerRoutes(); + const remove = async (type: string, name: string, query: Record) => { + const entry = rest.getRoutes().find((r: any) => r.method === 'DELETE' && r.path === `${META}/:type/:name`); + if (!entry) throw new Error('DELETE /meta/:type/:name is not registered'); + const res = makeRes(); + await entry.handler({ + method: 'DELETE', path: `${META}/${type}/${name}`, params: { type, name }, query: { ...query }, headers: {}, body: {}, + }, res); + return { status: res.statusCode, body: res.body }; + }; + return { engine, protocol, read, remove }; +} + +const itemOf = (body: any) => body?.data?.item ?? body?.data ?? body; +const labelOf = (body: any) => itemOf(body)?.label; +const listedLabel = (body: any) => { + const data = body?.data ?? body; + const items: any[] = Array.isArray(data) ? data : Array.isArray(data?.items) ? data.items : []; + return items.find((i: any) => i?.name === BOARD)?.label; +}; +const effectiveLabel = (body: any) => (body?.data ?? body)?.effective?.label; + +describe('[#29594] merge question 12: a legacy per-package pair of an App\'s module is honoured as before', () => { + it('its declared override is served at the module\'s address (by name, list, layered); it removes it, and the module\'s item is served again', async () => { + const { protocol, read, remove } = await boot(); + + // Precondition: the module ships the board. + const shipped = await read(`/meta/dashboard/${BOARD}`, { package: MODULE }); + expect([shipped.status, labelOf(shipped.body)], JSON.stringify(shipped.body).slice(0, 400)).toEqual([200, SHIPPED]); + + // The legacy pair holds its declared override of the module's board. + await protocol.saveMetaItem({ + type: 'dashboard', name: BOARD, packageId: LEGACY_PAIR, + item: { ...board(CUSTOMIZED), overrides: { package: MODULE, basedOn: '6.1.0' } }, + }); + + const byName = await read(`/meta/dashboard/${BOARD}`, { package: MODULE }); + const list = await read('/meta/dashboard', { package: MODULE }); + const layered = await read(`/meta/dashboard/${BOARD}/layers`, { package: MODULE }); + expect({ + byName: [byName.status, labelOf(byName.body)], + list: [list.status, listedLabel(list.body)], + layered: [layered.status, effectiveLabel(layered.body)], + }, JSON.stringify({ byName: byName.body, layered: layered.body }).slice(0, 800)).toEqual({ + byName: [200, CUSTOMIZED], + list: [200, CUSTOMIZED], + layered: [200, CUSTOMIZED], + }); + + // "Restore package original": the legacy pair removes its own override. + const removed = await remove('dashboard', BOARD, { package: LEGACY_PAIR }); + expect(removed.status, JSON.stringify(removed.body).slice(0, 600)).toBe(200); + + const after = await read(`/meta/dashboard/${BOARD}`, { package: MODULE }); + const layeredAfter = await read(`/meta/dashboard/${BOARD}/layers`, { package: MODULE }); + expect([labelOf(after.body), effectiveLabel(layeredAfter.body)]).toEqual([SHIPPED, SHIPPED]); + }, 60_000); +}); diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index d796baff1bd..424e0af063a 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -61,6 +61,7 @@ import { // through the App that carries it, over the engine registry's manifests. installedManifestsOf, pairOf, + pairsOf, type InstalledPackageSource, } from '@objectstack/metadata-core'; // [#7560] ADR-0070's read-only-package rule — the SAME predicate the metadata @@ -3990,15 +3991,17 @@ async function readPackageCustomizations( const overrides = await listServedOverrides({ packageId: baseId }); const registry = ql?.registry; - const pair = pairOf(baseId, installedManifestsOf(registry)); + // [#29594, ADR-0146 D3 merge question 12] Every pair of the base: the + // App's pair, then the per-package pair an earlier version created for it. + const pairs = pairsOf(baseId, installedManifestsOf(registry)); type Extension = GetPackageCustomizationsResponse['extensions'][number]; const extensions: Extension[] = []; - if (pair.ok && typeof registry?.getAllObjects === 'function') { - for (const object of registry.getAllObjects(pair.value) as Array<{ name?: unknown }>) { + if (typeof registry?.getAllObjects === 'function') for (const pairId of pairs) { + for (const object of registry.getAllObjects(pairId) as Array<{ name?: unknown }>) { const name = object?.name; if (typeof name !== 'string' || registry.getObjectOwner(name)?.packageId !== baseId) continue; for (const contributor of registry.getObjectContributors(name) as Array<{ packageId?: unknown; ownership?: unknown; definition?: any }>) { - if (contributor.packageId !== pair.value || contributor.ownership !== 'extend') continue; + if (contributor.packageId !== pairId || contributor.ownership !== 'extend') continue; const body = contributor.definition ?? {}; for (const field of Object.keys(body.fields ?? {})) extensions.push({ object: name, kind: 'field', name: field }); for (const rule of Array.isArray(body.validations) ? body.validations : []) { @@ -4033,7 +4036,7 @@ async function readPackageCustomizations( .map((row) => ({ type: row.metadataType, name: row.name })) .sort((a, b) => a.type.localeCompare(b.type) || a.name.localeCompare(b.name)); - const contributions = readBaseContributions(baseId, pair.ok ? pair.value : undefined, registry); + const contributions = readBaseContributions(baseId, pairs, registry); return { packageId: baseId, @@ -4095,7 +4098,7 @@ async function readPackageCustomizations( */ function readBaseContributions( baseId: string, - pairId: string | undefined, + pairIds: readonly string[], registry: any, ): GetPackageCustomizationsResponse['contributions'] { type Contribution = GetPackageCustomizationsResponse['contributions'][number]; @@ -4105,9 +4108,10 @@ function readBaseContributions( const navs: NavContribution[] = []; // The one stamp reading both kinds share: the package `packageId` names // when `body` is a code package's item and the package is neither the base - // nor its pair; `undefined` otherwise. + // nor one of its pairs ([#29594] the App's pair, or the per-package pair + // an earlier version created for it); `undefined` otherwise. const codePackageOf = (packageId: unknown, body: unknown): string | undefined => { - if (typeof packageId !== 'string' || packageId === baseId || packageId === pairId) return undefined; + if (typeof packageId !== 'string' || packageId === baseId || pairIds.includes(packageId)) return undefined; const stamped = { _packageId: packageId, _provenance: (body as { _provenance?: unknown } | null | undefined)?._provenance }; return isCodeArtifactBody(stamped) ? packageId : undefined; }; From f446e8a01117392efc9b2c150c112f2c93d7416b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 14:58:00 +0000 Subject: [PATCH 11/15] test(runtime): a legacy pair's doc override is served and removed through the doors Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2dM9PvPPB9BMVdhf2R --- .../domains/meta-legacy-pair-served.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/runtime/src/domains/meta-legacy-pair-served.test.ts b/packages/runtime/src/domains/meta-legacy-pair-served.test.ts index 1974226306f..fb86f236a2a 100644 --- a/packages/runtime/src/domains/meta-legacy-pair-served.test.ts +++ b/packages/runtime/src/domains/meta-legacy-pair-served.test.ts @@ -37,7 +37,11 @@ const SHIPPED = 'Service board (shipped)'; const CUSTOMIZED = 'Service board (customized in the legacy pair)'; const AUTHOR = { userId: 'u_author', isSystem: false, systemPermissions: ['manage_metadata'] }; +const GUIDE = 'crm_service_guide'; const board = (label: string) => ({ name: BOARD, label, columns: 12, widgets: [] }); +// A doc opens no overlay channel: its removal passes the sealed-removal +// refusal only as a pair's removal of its own declared override. +const guide = (label: string) => ({ name: GUIDE, label, content: '# Service guide' }); const clone = (v: T): T => JSON.parse(JSON.stringify(v)); const liveEngines: ObjectQL[] = []; @@ -79,6 +83,7 @@ async function boot() { dependencies: { [APP]: '^6.1.0' }, objects: [{ name: 'crm_service_case', label: 'Case', fields: { subject: { name: 'subject', label: 'Subject', type: 'text' } } }], dashboards: [board(SHIPPED)], + docs: [guide(SHIPPED)], }); // The per-package pair an earlier version's ensure door created for the module. engine.registry.installPackage({ @@ -160,4 +165,19 @@ describe('[#29594] merge question 12: a legacy per-package pair of an App\'s mod const layeredAfter = await read(`/meta/dashboard/${BOARD}/layers`, { package: MODULE }); expect([labelOf(after.body), effectiveLabel(layeredAfter.body)]).toEqual([SHIPPED, SHIPPED]); }, 60_000); + + it('a doc (no overlay channel) the legacy pair overrides is served, and its removal passes the sealed-removal refusal as the pair\'s own', async () => { + const { protocol, read, remove } = await boot(); + await protocol.saveMetaItem({ + type: 'doc', name: GUIDE, packageId: LEGACY_PAIR, + item: { ...guide(CUSTOMIZED), overrides: { package: MODULE, basedOn: '6.1.0' } }, + }); + const byName = await read(`/meta/doc/${GUIDE}`, { package: MODULE }); + expect([byName.status, labelOf(byName.body)], JSON.stringify(byName.body).slice(0, 400)).toEqual([200, CUSTOMIZED]); + + const removed = await remove('doc', GUIDE, { package: LEGACY_PAIR }); + expect(removed.status, JSON.stringify(removed.body).slice(0, 600)).toBe(200); + const after = await read(`/meta/doc/${GUIDE}`, { package: MODULE }); + expect([after.status, labelOf(after.body)]).toEqual([200, SHIPPED]); + }, 60_000); }); From da5e49929909743bb1736e57cc87c295c8896861 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 15:00:27 +0000 Subject: [PATCH 12/15] test(runtime): judge the legacy pair's reads and removal on a row an earlier version stored Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2dM9PvPPB9BMVdhf2R --- .../domains/meta-legacy-pair-served.test.ts | 37 +++++++++++++------ 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/packages/runtime/src/domains/meta-legacy-pair-served.test.ts b/packages/runtime/src/domains/meta-legacy-pair-served.test.ts index fb86f236a2a..51dcbba72ca 100644 --- a/packages/runtime/src/domains/meta-legacy-pair-served.test.ts +++ b/packages/runtime/src/domains/meta-legacy-pair-served.test.ts @@ -118,7 +118,18 @@ async function boot() { }, res); return { status: res.statusCode, body: res.body }; }; - return { engine, protocol, read, remove }; + /** + * The legacy pair's declared override as an earlier version stored it: the + * row itself, written straight to the store, so the reads and the removal + * below are judged on a row no write gate of this version admitted. + */ + const seedLegacyOverride = (type: string, body: Record) => + engine.insert('sys_metadata', { + name: body.name, type, namespace: 'default', package_id: LEGACY_PAIR, scope: 'platform', strategy: 'merge', + state: 'active', version: 1, + metadata: JSON.stringify({ ...body, overrides: { package: MODULE, basedOn: '6.1.0' } }), + }, { context: { isSystem: true } } as any); + return { engine, protocol, read, remove, seedLegacyOverride }; } const itemOf = (body: any) => body?.data?.item ?? body?.data ?? body; @@ -131,18 +142,25 @@ const listedLabel = (body: any) => { const effectiveLabel = (body: any) => (body?.data ?? body)?.effective?.label; describe('[#29594] merge question 12: a legacy per-package pair of an App\'s module is honoured as before', () => { + it('the pair still saves a declared override of the module\'s item (the write gate admits it)', async () => { + const { protocol, read } = await boot(); + await protocol.saveMetaItem({ + type: 'dashboard', name: BOARD, packageId: LEGACY_PAIR, + item: { ...board(CUSTOMIZED), overrides: { package: MODULE, basedOn: '6.1.0' } }, + }); + const byName = await read(`/meta/dashboard/${BOARD}`, { package: MODULE }); + expect([byName.status, labelOf(byName.body)]).toEqual([200, CUSTOMIZED]); + }, 60_000); + it('its declared override is served at the module\'s address (by name, list, layered); it removes it, and the module\'s item is served again', async () => { - const { protocol, read, remove } = await boot(); + const { read, remove, seedLegacyOverride } = await boot(); // Precondition: the module ships the board. const shipped = await read(`/meta/dashboard/${BOARD}`, { package: MODULE }); expect([shipped.status, labelOf(shipped.body)], JSON.stringify(shipped.body).slice(0, 400)).toEqual([200, SHIPPED]); // The legacy pair holds its declared override of the module's board. - await protocol.saveMetaItem({ - type: 'dashboard', name: BOARD, packageId: LEGACY_PAIR, - item: { ...board(CUSTOMIZED), overrides: { package: MODULE, basedOn: '6.1.0' } }, - }); + await seedLegacyOverride('dashboard', board(CUSTOMIZED)); const byName = await read(`/meta/dashboard/${BOARD}`, { package: MODULE }); const list = await read('/meta/dashboard', { package: MODULE }); @@ -167,11 +185,8 @@ describe('[#29594] merge question 12: a legacy per-package pair of an App\'s mod }, 60_000); it('a doc (no overlay channel) the legacy pair overrides is served, and its removal passes the sealed-removal refusal as the pair\'s own', async () => { - const { protocol, read, remove } = await boot(); - await protocol.saveMetaItem({ - type: 'doc', name: GUIDE, packageId: LEGACY_PAIR, - item: { ...guide(CUSTOMIZED), overrides: { package: MODULE, basedOn: '6.1.0' } }, - }); + const { read, remove, seedLegacyOverride } = await boot(); + await seedLegacyOverride('doc', guide(CUSTOMIZED)); const byName = await read(`/meta/doc/${GUIDE}`, { package: MODULE }); expect([byName.status, labelOf(byName.body)], JSON.stringify(byName.body).slice(0, 400)).toEqual([200, CUSTOMIZED]); From 787f842cb7405746212324323ad4a45d5f9357e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 15:01:40 +0000 Subject: [PATCH 13/15] docs(changeset): every reader honours a legacy per-package pair Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2dM9PvPPB9BMVdhf2R --- .changeset/29594-app-customization-pair.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/29594-app-customization-pair.md b/.changeset/29594-app-customization-pair.md index c8f9fcc40cd..c46e8f7c126 100644 --- a/.changeset/29594-app-customization-pair.md +++ b/.changeset/29594-app-customization-pair.md @@ -18,7 +18,7 @@ What changes for a caller: - `POST /api/v1/packages/:id/ensure-customization` asked for a module of an App answers the App's one pair, `.custom` (namespace `_custom`, a dependency on the App at `^`): `201` on the first ask from any member, `200` after, never `422` for a package of the same App. The answer's `basePackageId` is the App. HotCRM: the App and its modules all answer `app.objectstack.hotcrm.custom`. - `422 NAMESPACE_CONFLICT` stays for the pair namespace held by a package outside the App. -- A per-package pair an earlier version created for a module (`.custom`) stays installed and served: its declared overrides, list rows, translations and served-overrides census are read beside the App's pair (`pairsOf` / `isPairOf`). While it holds the App's pair namespace, the ensure door answers `409 RESOURCE_CONFLICT` naming it instead of creating the App's pair beside it (merge question 12). +- A per-package pair an earlier version created for a module (`.custom`) stays installed and honoured as before: its declared overrides are served at the module's address (by name, in the list and in the layered read), its list rows, translations, bundle members it added, object extensions and the served-overrides census are read beside the App's pair (`pairsOf` / `isPairOf`), it may still declare and remove its own overrides of the module's items ("Restore package original" included, for a type with no overlay channel too), and the customizations read (`GET /api/v1/packages/:id/customizations`) does not list it as another package's contribution. No reader is App-pair-only. While it holds the App's pair namespace, the ensure door answers `409 RESOURCE_CONFLICT` naming it instead of creating the App's pair beside it (merge question 12). - A package that reaches several Apps and whose namespace picks none of them has no pair: the ensure door answers `409` (merge question 11). - The App's pair's write closure reaches every member of the App, so an override or an object extension of a module's item saved into the App's pair is admitted. - The uninstall refusal for an installed pair reads the pair named for the package: uninstalling the App is refused while the App's pair is installed, uninstalling a module is not (its customizations are the App pair's), and a module whose own earlier per-package pair is installed is still refused naming that pair. From b1ea4bc76389d8473004dc7bf353defa0d636569 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 16:34:07 +0000 Subject: [PATCH 14/15] test(runtime): the legacy pair's removal on both topologies; changeset names runtime Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2dM9PvPPB9BMVdhf2R --- .changeset/29594-app-customization-pair.md | 3 +- .../domains/meta-legacy-pair-served.test.ts | 121 +++++++++--------- packages/runtime/src/domains/packages.ts | 3 +- 3 files changed, 68 insertions(+), 59 deletions(-) diff --git a/.changeset/29594-app-customization-pair.md b/.changeset/29594-app-customization-pair.md index c46e8f7c126..ce3a6d8ecf6 100644 --- a/.changeset/29594-app-customization-pair.md +++ b/.changeset/29594-app-customization-pair.md @@ -3,9 +3,10 @@ '@objectstack/metadata-protocol': minor '@objectstack/objectql': minor '@objectstack/lint': minor +'@objectstack/runtime': minor --- -feat(metadata-core, metadata-protocol, objectql, lint)!: an App and the modules that declare it share one customization pair +feat(metadata-core, metadata-protocol, objectql, lint, runtime)!: an App and the modules that declare it share one customization pair Clause-②: yes (narrowing) — the ensure door refuses two asks it accepted: a package that reaches several Apps and whose namespace picks none of them (was `201` with its own pair, now `409`, merge question 11), and a module whose earlier per-package pair holds the App's pair namespace (was `201`/`200`, now `409 RESOURCE_CONFLICT` naming that pair, merge question 12); `appOf` now returns `string | undefined`. The widening half: a module of an App gets the App's pair (`200`, where it was `422 NAMESPACE_CONFLICT` when the App held the namespace), and `@objectstack/metadata-core` exports `pairsOf`, `AppOfRefusal` and `PairOfRefusal`. Ruling 6691022506; ADR-0146 D3 as amended by #31027. Metadata-protocol: semantic diff --git a/packages/runtime/src/domains/meta-legacy-pair-served.test.ts b/packages/runtime/src/domains/meta-legacy-pair-served.test.ts index 51dcbba72ca..dc7f9ce0803 100644 --- a/packages/runtime/src/domains/meta-legacy-pair-served.test.ts +++ b/packages/runtime/src/domains/meta-legacy-pair-served.test.ts @@ -63,7 +63,7 @@ function makeRes() { return res; } -async function boot() { +async function boot(environmentId?: string) { const engine = new ObjectQL(); liveEngines.push(engine); engine.registerDriver(new SqlDriver({ @@ -91,7 +91,7 @@ async function boot() { dependencies: { [MODULE]: '^6' }, } as any, 'authored'); await engine.syncSchemas(); - const protocol: any = new ObjectStackProtocolImplementation(engine as any, () => new Map()); + const protocol: any = new ObjectStackProtocolImplementation(engine as any, () => new Map(), environmentId); // The reads: the dispatcher's catch-all. const get = (n: string) => (n === 'protocol' ? protocol : n === 'objectql' ? engine : null); @@ -141,58 +141,65 @@ const listedLabel = (body: any) => { }; const effectiveLabel = (body: any) => (body?.data ?? body)?.effective?.label; -describe('[#29594] merge question 12: a legacy per-package pair of an App\'s module is honoured as before', () => { - it('the pair still saves a declared override of the module\'s item (the write gate admits it)', async () => { - const { protocol, read } = await boot(); - await protocol.saveMetaItem({ - type: 'dashboard', name: BOARD, packageId: LEGACY_PAIR, - item: { ...board(CUSTOMIZED), overrides: { package: MODULE, basedOn: '6.1.0' } }, - }); - const byName = await read(`/meta/dashboard/${BOARD}`, { package: MODULE }); - expect([byName.status, labelOf(byName.body)]).toEqual([200, CUSTOMIZED]); - }, 60_000); - - it('its declared override is served at the module\'s address (by name, list, layered); it removes it, and the module\'s item is served again', async () => { - const { read, remove, seedLegacyOverride } = await boot(); - - // Precondition: the module ships the board. - const shipped = await read(`/meta/dashboard/${BOARD}`, { package: MODULE }); - expect([shipped.status, labelOf(shipped.body)], JSON.stringify(shipped.body).slice(0, 400)).toEqual([200, SHIPPED]); - - // The legacy pair holds its declared override of the module's board. - await seedLegacyOverride('dashboard', board(CUSTOMIZED)); - - const byName = await read(`/meta/dashboard/${BOARD}`, { package: MODULE }); - const list = await read('/meta/dashboard', { package: MODULE }); - const layered = await read(`/meta/dashboard/${BOARD}/layers`, { package: MODULE }); - expect({ - byName: [byName.status, labelOf(byName.body)], - list: [list.status, listedLabel(list.body)], - layered: [layered.status, effectiveLabel(layered.body)], - }, JSON.stringify({ byName: byName.body, layered: layered.body }).slice(0, 800)).toEqual({ - byName: [200, CUSTOMIZED], - list: [200, CUSTOMIZED], - layered: [200, CUSTOMIZED], - }); - - // "Restore package original": the legacy pair removes its own override. - const removed = await remove('dashboard', BOARD, { package: LEGACY_PAIR }); - expect(removed.status, JSON.stringify(removed.body).slice(0, 600)).toBe(200); - - const after = await read(`/meta/dashboard/${BOARD}`, { package: MODULE }); - const layeredAfter = await read(`/meta/dashboard/${BOARD}/layers`, { package: MODULE }); - expect([labelOf(after.body), effectiveLabel(layeredAfter.body)]).toEqual([SHIPPED, SHIPPED]); - }, 60_000); - - it('a doc (no overlay channel) the legacy pair overrides is served, and its removal passes the sealed-removal refusal as the pair\'s own', async () => { - const { read, remove, seedLegacyOverride } = await boot(); - await seedLegacyOverride('doc', guide(CUSTOMIZED)); - const byName = await read(`/meta/doc/${GUIDE}`, { package: MODULE }); - expect([byName.status, labelOf(byName.body)], JSON.stringify(byName.body).slice(0, 400)).toEqual([200, CUSTOMIZED]); - - const removed = await remove('doc', GUIDE, { package: LEGACY_PAIR }); - expect(removed.status, JSON.stringify(removed.body).slice(0, 600)).toBe(200); - const after = await read(`/meta/doc/${GUIDE}`, { package: MODULE }); - expect([after.status, labelOf(after.body)]).toEqual([200, SHIPPED]); - }, 60_000); -}); +// Both topologies: an environment kernel (`environmentId` set) asks the +// protocol's removal door — `refusePackagedBaseRemoval` and the pair-row +// removal's pass past it — and a host-config kernel only the repository's +// delete gate. +for (const environmentId of [undefined, 'env_hotcrm'] as const) { + const topology = environmentId === undefined ? 'host-config kernel' : 'environment kernel'; + describe(`[#29594] merge question 12 (${topology}): a legacy per-package pair of an App's module is honoured as before`, () => { + it('the pair still saves a declared override of the module\'s item (the write gate admits it)', async () => { + const { protocol, read } = await boot(environmentId); + await protocol.saveMetaItem({ + type: 'dashboard', name: BOARD, packageId: LEGACY_PAIR, + item: { ...board(CUSTOMIZED), overrides: { package: MODULE, basedOn: '6.1.0' } }, + }); + const byName = await read(`/meta/dashboard/${BOARD}`, { package: MODULE }); + expect([byName.status, labelOf(byName.body)]).toEqual([200, CUSTOMIZED]); + }, 60_000); + + it('its declared override is served at the module\'s address (by name, list, layered); it removes it, and the module\'s item is served again', async () => { + const { read, remove, seedLegacyOverride } = await boot(environmentId); + + // Precondition: the module ships the board. + const shipped = await read(`/meta/dashboard/${BOARD}`, { package: MODULE }); + expect([shipped.status, labelOf(shipped.body)], JSON.stringify(shipped.body).slice(0, 400)).toEqual([200, SHIPPED]); + + // The legacy pair holds its declared override of the module's board. + await seedLegacyOverride('dashboard', board(CUSTOMIZED)); + + const byName = await read(`/meta/dashboard/${BOARD}`, { package: MODULE }); + const list = await read('/meta/dashboard', { package: MODULE }); + const layered = await read(`/meta/dashboard/${BOARD}/layers`, { package: MODULE }); + expect({ + byName: [byName.status, labelOf(byName.body)], + list: [list.status, listedLabel(list.body)], + layered: [layered.status, effectiveLabel(layered.body)], + }, JSON.stringify({ byName: byName.body, layered: layered.body }).slice(0, 800)).toEqual({ + byName: [200, CUSTOMIZED], + list: [200, CUSTOMIZED], + layered: [200, CUSTOMIZED], + }); + + // "Restore package original": the legacy pair removes its own override. + const removed = await remove('dashboard', BOARD, { package: LEGACY_PAIR }); + expect(removed.status, JSON.stringify(removed.body).slice(0, 600)).toBe(200); + + const after = await read(`/meta/dashboard/${BOARD}`, { package: MODULE }); + const layeredAfter = await read(`/meta/dashboard/${BOARD}/layers`, { package: MODULE }); + expect([labelOf(after.body), effectiveLabel(layeredAfter.body)]).toEqual([SHIPPED, SHIPPED]); + }, 60_000); + + it('a doc (no overlay channel) the legacy pair overrides is served, and its removal passes the sealed-removal refusal as the pair\'s own', async () => { + const { read, remove, seedLegacyOverride } = await boot(environmentId); + await seedLegacyOverride('doc', guide(CUSTOMIZED)); + const byName = await read(`/meta/doc/${GUIDE}`, { package: MODULE }); + expect([byName.status, labelOf(byName.body)], JSON.stringify(byName.body).slice(0, 400)).toEqual([200, CUSTOMIZED]); + + const removed = await remove('doc', GUIDE, { package: LEGACY_PAIR }); + expect(removed.status, JSON.stringify(removed.body).slice(0, 600)).toBe(200); + const after = await read(`/meta/doc/${GUIDE}`, { package: MODULE }); + expect([after.status, labelOf(after.body)]).toEqual([200, SHIPPED]); + }, 60_000); + }); +} diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index 424e0af063a..30d10c7eefd 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -3996,7 +3996,8 @@ async function readPackageCustomizations( const pairs = pairsOf(baseId, installedManifestsOf(registry)); type Extension = GetPackageCustomizationsResponse['extensions'][number]; const extensions: Extension[] = []; - if (typeof registry?.getAllObjects === 'function') for (const pairId of pairs) { + const pairIdsToRead = typeof registry?.getAllObjects === 'function' ? pairs : []; + for (const pairId of pairIdsToRead) { for (const object of registry.getAllObjects(pairId) as Array<{ name?: unknown }>) { const name = object?.name; if (typeof name !== 'string' || registry.getObjectOwner(name)?.packageId !== baseId) continue; From ef5fdb4439a9fb00a83c17f8a388d151484569fd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 16:34:11 +0000 Subject: [PATCH 15/15] test(runtime): indent Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2dM9PvPPB9BMVdhf2R --- packages/runtime/src/domains/meta-legacy-pair-served.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime/src/domains/meta-legacy-pair-served.test.ts b/packages/runtime/src/domains/meta-legacy-pair-served.test.ts index dc7f9ce0803..1fa4ac5500b 100644 --- a/packages/runtime/src/domains/meta-legacy-pair-served.test.ts +++ b/packages/runtime/src/domains/meta-legacy-pair-served.test.ts @@ -146,7 +146,7 @@ const effectiveLabel = (body: any) => (body?.data ?? body)?.effective?.label; // removal's pass past it — and a host-config kernel only the repository's // delete gate. for (const environmentId of [undefined, 'env_hotcrm'] as const) { - const topology = environmentId === undefined ? 'host-config kernel' : 'environment kernel'; + const topology = environmentId === undefined ? 'host-config kernel' : 'environment kernel'; describe(`[#29594] merge question 12 (${topology}): a legacy per-package pair of an App's module is honoured as before`, () => { it('the pair still saves a declared override of the module\'s item (the write gate admits it)', async () => { const { protocol, read } = await boot(environmentId);