From 1dbbf16997a72cf4015d8b69323489585c9685b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 12:27:19 +0000 Subject: [PATCH 1/6] fix(metadata-core): the artifact forward conversion walks every packages[].manifest body An option-B artifact (ADR-0130 D4) carries its collections in package bodies, which the strict parse gates entry by entry. The ADR-0087 conversion walked only the definition's own collections, so a retired key inside a body refused the boot. The same walk (same registry, same window, decided once from the artifact's own engines.protocol) now runs over each body as a second root, and a body's notices are located under its package. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- ...ct-forward-conversion-package-body.test.ts | 338 ++++++++++++++++++ .../src/artifact-forward-conversion.ts | 100 +++++- ...ct-forward-conversion-package-body.test.ts | 146 ++++++++ 3 files changed, 576 insertions(+), 8 deletions(-) create mode 100644 packages/metadata-core/src/artifact-forward-conversion-package-body.test.ts create mode 100644 packages/metadata/src/plugin-artifact-forward-conversion-package-body.test.ts diff --git a/packages/metadata-core/src/artifact-forward-conversion-package-body.test.ts b/packages/metadata-core/src/artifact-forward-conversion-package-body.test.ts new file mode 100644 index 00000000000..9279e113112 --- /dev/null +++ b/packages/metadata-core/src/artifact-forward-conversion-package-body.test.ts @@ -0,0 +1,338 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23027] The conversion reaches every package body of an option-B artifact. + * + * A multi-package artifact (ADR-0130 D4, option B) carries its collections in + * `packages[i].manifest`, not in the definition's own collections. The strict + * parse gates each body — `ObjectStackDefinitionSchema` and the entry gate + * `resolveArtifactPackageOrder` applies (`ArtifactPackageSchema`) alike — so a + * retired key inside a body refused the boot when the conversion walked only + * the definition's top level. Measured at `os dev -a dist/objectstack.json + * --fresh`: "`packages[1]` is not a package entry (ADR-0130 D4): + * manifest.dashboards.0.refreshInterval: `dashboard.refreshInterval` was renamed". + * + * The fixture is the shape `os build` writes for a two-package project, + * measured on `examples/app-multi-package`: the artifact's own `manifest` is + * its LAST package's manifest, and every collection sits in a body that also + * carries that package's manifest fields (its own `engines` included). + * + * The window's version boundaries are pinned in `artifact-forward-conversion.test.ts`; + * this file pins the REACH: the same walk, the same entry list and the same + * window over each body. + */ + +import { describe, it, expect } from 'vitest'; +import { + ALL_CONVERSIONS, + ArtifactPackageSchema, + ObjectStackDefinitionSchema, + applyConversions, + type ConversionNotice, +} from '@objectstack/spec'; +import { applyArtifactForwardConversions } from './artifact-forward-conversion.js'; + +const SUBJECT = 'dashboard-refresh-interval-to-refresh-interval-seconds'; + +/** The door's DEFAULT-FLIP refusals (`DEFAULT_FLIPS_NOT_REPLAYED_HERE`, module-local by design). */ +const DOOR_DEFAULT_FLIPS: readonly string[] = ['app-hidden-to-unpublished', 'flow-decision-mode-inclusive-explicit']; + +const versionTriple = (v: string): [number, number, number] => + v.split(/[.-]/).slice(0, 3).map((n) => Number.parseInt(n, 10)) as [number, number, number]; + +function compareVersions(a: string, b: string): number { + const x = versionTriple(a); + const y = versionTriple(b); + for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i]! < y[i]! ? -1 : 1; + return 0; +} + +/** The first `x.y.z` past `label` and every `retiredAfter`: a floor no window opens for. */ +function currentSurfaceFloor(label: string): string { + const all = [label, ...ALL_CONVERSIONS.flatMap((c) => (c.retiredFromLoadPath === true ? [c.retiredAfter] : []))]; + const sorted = [...all].sort(compareVersions); + const [major, minor, patch] = versionTriple(sorted[sorted.length - 1]!); + return `${major}.${minor}.${patch + 1}`; +} + +type Body = Record; +interface OptionB { + manifest: Body; + packages: { manifest: Body }[]; + [key: string]: unknown; +} + +const CORE = { id: 'com.example.ob.core', namespace: 'ob', name: 'ob_core', version: '1.0.0', type: 'app' }; +const ORDERS = { + id: 'com.example.ob.orders', namespace: 'ob', name: 'ob_orders', version: '2.0.0', type: 'module', + dependencies: { 'com.example.ob.core': '^1.0.0' }, +}; +const engines = (range: string | undefined) => (range === undefined ? {} : { engines: { protocol: range } }); + +/** + * An option-B artifact: `orders` first and `core` last, as the example lists + * them, so the artifact's own manifest is `core`'s. Each body carries its + * package's manifest fields; `bodyRange` overrides what the bodies declare. + */ +function optionB( + range: string | undefined, + core: Body, + orders: Body = {}, + bodyRange: string | undefined = range, +): OptionB { + return { + manifest: { ...CORE, ...engines(range) }, + packages: [ + { manifest: { ...ORDERS, ...engines(bodyRange), ...orders } }, + { manifest: { ...CORE, ...engines(bodyRange), ...core } }, + ], + }; +} + +/** A dashboard as an artifact built before the rename carries it. */ +const legacyDashboard = (name: string, seconds: number) => ({ name, label: 'Overview', widgets: [], refreshInterval: seconds }); + +const issuePaths = (value: unknown): string[] => { + const parsed = ObjectStackDefinitionSchema.safeParse(value); + return parsed.success ? [] : parsed.error.issues.map((i) => i.path.join('.')).sort(); +}; + +/** What `resolveArtifactPackageOrder`'s entry gate answers for each entry (`[]` = admitted). */ +const entryGate = (definition: unknown): string[][] => + (definition as OptionB).packages.map((entry) => { + const parsed = ArtifactPackageSchema.safeParse(entry); + return parsed.success ? [] : parsed.error.issues.map((i) => i.path.join('.')).sort(); + }); + +describe('[#23027] the conversion reaches every package body — ADR-0130 D4 option-B artifacts', () => { + it('premise: unconverted, the definition parse and the package-entry gate both refuse the body at the retired key', () => { + const def = optionB('^18', { dashboards: [legacyDashboard('ob_overview', 300)] }); + expect(issuePaths(def)).toEqual(['packages.1.manifest.dashboards.0.refreshInterval']); + expect(entryGate(def)).toEqual([[], ['manifest.dashboards.0.refreshInterval']]); + }); + + // Pin 1: the option-B dashboard boots, served as `refreshIntervalSeconds: 300`. + // Three windows that open the subject: undeclared, the major-step half + // (`^18` on a 17.x label), and the label half (`^18` on 18.0.1). + const OPEN: readonly [string | undefined, string, string][] = [ + [undefined, '17.7.0', 'converted-undeclared'], + ['^18', '17.7.0', 'converted-major-step'], + ['^18', '18.0.1', 'converted-forward'], + ]; + for (const [range, label, verdict] of OPEN) { + it(`${range ?? 'no declared range'} on a runtime labelled ${label}: the body converts, the notice names the body, and both gates admit it`, () => { + const def = optionB(range, { dashboards: [legacyDashboard('ob_overview', 300)] }); + const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: label }); + const out = result.definition as OptionB; + + expect(result.verdict).toBe(verdict); + expect(out.packages[1]!.manifest.dashboards).toEqual([ + { name: 'ob_overview', label: 'Overview', widgets: [], refreshIntervalSeconds: 300 }, + ]); + expect(result.notices.map((n) => [n.conversionId, n.path, n.from, n.to])).toEqual([ + [SUBJECT, 'packages[1].manifest.dashboards[0].refreshIntervalSeconds', 'refreshInterval', 'refreshIntervalSeconds'], + ]); + // The one place the message quotes the path names the body too. + expect(result.notices[0]!.message).toContain(' at packages[1].manifest.dashboards[0].refreshIntervalSeconds: '); + expect(issuePaths(out)).toEqual([]); + expect(entryGate(out)).toEqual([[], []]); + // Copy-on-write: the untouched body and the artifact's own manifest keep their references. + expect(out.packages[0]).toBe(def.packages[0]); + expect(out.manifest).toBe(def.manifest); + expect(def.packages[1]!.manifest.dashboards).toEqual([legacyDashboard('ob_overview', 300)]); + }); + } + + // Pin 2: a multi-package `packages[]` is converted per body. + it('converts each body on its own, by the whole chain — each notice located under its own package', () => { + const def = optionB( + '^18', + { dashboards: [legacyDashboard('ob_overview', 300)] }, + { + dashboards: [legacyDashboard('ob_orders_overview', 120)], + // A second step-18 retirement in the same body: the chain, not one entry. + objects: [{ + name: 'ob_order', label: 'Order', + fields: { number: { type: 'text', label: 'Number' } }, + indexes: [{ name: 'ob_order_number', fields: ['number'], unique: true }], + }], + }, + ); + def.packages.push({ manifest: { id: 'com.example.ob.clean', name: 'ob_clean', version: '1.0.0', type: 'module' } }); + const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.7.0' }); + const out = result.definition as OptionB; + + expect(result.verdict).toBe('converted-major-step'); + // Body by body; inside one body, in registry order. + expect(result.notices.map((n) => `${n.conversionId} at ${n.path}`)).toEqual([ + `${SUBJECT} at packages[0].manifest.dashboards[0].refreshIntervalSeconds`, + 'declared-index-unique-scope at packages[0].manifest.objects[0].indexes[0].unique', + `${SUBJECT} at packages[1].manifest.dashboards[0].refreshIntervalSeconds`, + ]); + expect((out.packages[0]!.manifest.dashboards as Body[])[0]!.refreshIntervalSeconds).toBe(120); + expect((out.packages[1]!.manifest.dashboards as Body[])[0]!.refreshIntervalSeconds).toBe(300); + expect(issuePaths(out)).toEqual([]); + expect(entryGate(out)).toEqual([[], [], []]); + // The body with nothing to convert comes back by reference. + expect(out.packages[2]).toBe(def.packages[2]); + }); + + // Pin 3: the definition-collection control stays as it is. + it('CONTROL — a definition without `packages[]` converts exactly as the primitive does under the same window: same notices, same paths, same messages', () => { + const def = { + manifest: { ...CORE }, + dashboards: [legacyDashboard('ob_overview', 300)], + }; + const primitive: ConversionNotice[] = []; + const expected = applyConversions(def, { + includeRetired: true, + excludeConversionIds: DOOR_DEFAULT_FLIPS, + onNotice: (n) => primitive.push(n), + }); + const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.7.0' }); + + expect(result.verdict).toBe('converted-undeclared'); + expect(result.definition).toEqual(expected); + expect(result.notices).toEqual(primitive); + expect(result.notices.map((n) => n.path)).toEqual(['dashboards[0].refreshIntervalSeconds']); + }); + + it('CONTROL — an artifact carrying the same definition at its top level AND in a body converts both copies to one answer, each notice at its own site', () => { + const def = { + ...optionB(undefined, { dashboards: [legacyDashboard('ob_overview', 300)] }), + dashboards: [legacyDashboard('ob_overview', 300)], + }; + const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.7.0' }); + const out = result.definition as OptionB & { dashboards: Body[] }; + + expect(result.notices.map((n) => n.path)).toEqual([ + 'dashboards[0].refreshIntervalSeconds', + 'packages[1].manifest.dashboards[0].refreshIntervalSeconds', + ]); + expect(out.packages[1]!.manifest.dashboards).toEqual(out.dashboards); + expect(issuePaths(out)).toEqual([]); + }); + + // The window-closed controls: a body answers to the same window the definition does. + it('WINDOW SHUT — a floor past every retirement replays nothing in any body: the reference comes back and the strict parse refuses the body', () => { + const current = currentSurfaceFloor('17.7.0'); + const def = optionB(`^${current}`, { dashboards: [legacyDashboard('ob_overview', 300)] }); + const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: current }); + + expect(result.verdict).toBe('authored-current'); + expect(result.notices).toEqual([]); + expect(result.definition).toBe(def); + expect(issuePaths(result.definition)).toEqual(['packages.1.manifest.dashboards.0.refreshInterval']); + }); + + it('WINDOW PER ENTRY — inside an open per-entry window, a body replays the entries the floor predates and keeps a retirement the floor post-dates for its tombstone', () => { + // `^17.4.0` on a runtime labelled 17.4.0: the 17.5.0 cohort (`retiredAfter` + // 17.4.0) opens, while the rename (`retiredAfter` 17.3.0) was retired before + // the artifact's floor and stays a refusal — exactly as at the top level. + const core = { + objects: [{ + name: 'ob_deal', label: 'Deal', sharingModel: 'private', + fields: { stage: { type: 'text', label: 'Stage' }, amount: { type: 'number', label: 'Amount' } }, + }], + datasets: [{ + name: 'ob_deal_metrics', label: 'Deal metrics', object: 'ob_deal', + dimensions: [{ name: 'stage', field: 'stage' }], + measures: [{ name: 'amount', aggregate: 'sum', field: 'amount' }], + }], + dashboards: [{ + ...legacyDashboard('ob_pipeline', 300), + widgets: [{ + id: 'amount_by_stage', title: 'Amount by stage', type: 'bar', + dataset: 'ob_deal_metrics', dimensions: ['stage'], values: ['amount'], + chartConfig: { type: 'bar', xAxis: { field: 'stage' }, showLegend: true }, + layout: { x: 0, y: 0, w: 6, h: 4 }, + }], + }], + }; + const def = optionB('^17.4.0', core); + const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.4.0' }); + + expect(result.verdict).toBe('converted-retired-after'); + expect(result.notices.map((n) => `${n.conversionId} at ${n.path}`).sort()).toEqual([ + 'dashboard-widget-chart-config-structure-removed at packages[1].manifest.dashboards[0].widgets[0].chartConfig.type', + 'dashboard-widget-chart-config-structure-removed at packages[1].manifest.dashboards[0].widgets[0].chartConfig.xAxis', + ]); + expect(issuePaths(result.definition)).toEqual(['packages.1.manifest.dashboards.0.refreshInterval']); + }); + + it('the ARTIFACT\'s window governs every body — a body\'s own `engines.protocol` is not read, in either direction', () => { + const label = '17.7.0'; + const current = currentSurfaceFloor(label); + + // Artifact `^18` (window open), bodies declaring a floor no window opens for: converted. + const opened = applyArtifactForwardConversions( + optionB('^18', { dashboards: [legacyDashboard('ob_overview', 300)] }, {}, `^${current}`), + { runtimeSpecVersion: label }, + ); + expect(opened.verdict).toBe('converted-major-step'); + expect(opened.notices.map((n) => n.path)).toEqual(['packages[1].manifest.dashboards[0].refreshIntervalSeconds']); + + // Artifact past every retirement (window shut), bodies declaring no range at all: unchanged. + const shut = optionB(`^${current}`, { dashboards: [legacyDashboard('ob_overview', 300)] }, {}, undefined); + const closed = applyArtifactForwardConversions(shut, { runtimeSpecVersion: label }); + expect(closed.verdict).toBe('authored-current'); + expect(closed.definition).toBe(shut); + }); + + it('never gates: a `packages` that is not an array, and entries or bodies that are not objects, come back exactly as given', () => { + for (const packages of ['x', null, 3, [null, 'y', 3, { manifest: 'z' }, { manifest: [] }, {}]]) { + const def = { manifest: { ...CORE }, packages }; + const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.7.0' }); + expect(result.definition, JSON.stringify(packages)).toBe(def); + expect(result.notices).toEqual([]); + } + }); + + /** + * The whole step over a package body, both directions — every retired + * step-18 entry's own fixture pair carried as `packages[0].manifest` of a + * `^18` artifact on a runtime labelled 17.7.0 (the major-step half), so no + * entry is a sample. `artifact-forward-conversion.test.ts` pins the same step + * over the definition's own collections. + */ + describe('the whole of step 18 over a package body — every retired entry, both directions', () => { + const STEP_18 = ALL_CONVERSIONS.filter((c) => c.toMajor === 18 && c.retiredFromLoadPath === true); + const inBody = (body: Body): OptionB => ({ manifest: { ...CORE, ...engines('^18') }, packages: [{ manifest: body }] }); + + it('the step carries the subject', () => { + expect(STEP_18.map((c) => c.id)).toContain(SUBJECT); + }); + + for (const entry of STEP_18) { + const flip = DOOR_DEFAULT_FLIPS.includes(entry.id); + it(`${entry.id}: ${flip ? 'a default flip — never replayed in a body either' : 'built before it, the body converts to its canonical fixture'}`, () => { + const def = inBody(structuredClone(entry.fixture.before)); + const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.7.0' }); + expect(result.verdict).toBe('converted-major-step'); + if (flip) { + expect(result.notices.map((n) => n.conversionId)).not.toContain(entry.id); + expect(result.definition).toBe(def); + return; + } + const out = result.definition as OptionB; + expect(out.packages[0]!.manifest).toEqual(structuredClone(entry.fixture.after)); + expect(out.manifest).toBe(def.manifest); + expect(result.notices).toHaveLength(entry.fixture.expectedNotices); + for (const n of result.notices) { + expect(n.conversionId).toBe(entry.id); + expect(n.path.startsWith('packages[0].manifest.'), n.path).toBe(true); + } + }); + } + + for (const entry of STEP_18) { + it(`${entry.id}: built after it, the body comes back by reference with the whole step replayed over it`, () => { + const def = inBody(structuredClone(entry.fixture.after)); + const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.7.0' }); + expect(result.verdict).toBe('converted-major-step'); + expect(result.notices.map((n) => `${n.conversionId} at ${n.path}`)).toEqual([]); + expect(result.definition).toBe(def); + }); + } + }); +}); diff --git a/packages/metadata-core/src/artifact-forward-conversion.ts b/packages/metadata-core/src/artifact-forward-conversion.ts index 3082b968000..1ac3ee48d27 100644 --- a/packages/metadata-core/src/artifact-forward-conversion.ts +++ b/packages/metadata-core/src/artifact-forward-conversion.ts @@ -111,6 +111,38 @@ * Cross-major gaps are the protocol *handshake*'s jurisdiction * (`checkProtocolCompat`) and refuse before conversion could matter. * + * ## Reach — the definition's own collections AND every package body + * + * A multi-package artifact (ADR-0130 D4, option B) carries its collections in + * `packages[i].manifest`, each an ASSEMBLED package body + * (`AssembledPackageBodySchema`) that the strict parse gates entry by entry — + * in `ObjectStackDefinitionSchema` and in `resolveArtifactPackageOrder` alike. + * Since the 2026-09-22 addendum to D4 the definition's top level carries no + * copy of them. So the same walk runs over each body as a second root: the + * same `applyConversions` call, the same registry, the same window. ⛔ Never a + * second walk with its own entry list — a body would then answer a different + * question than the definition it travels in. + * + * A body is a stack-shaped root: where its manifest half and its collection + * half declare the same key (`objects`, `datasources`, `permissions`), the + * collection wins (the precedence table on `AssembledPackageBodySchema`), and + * those are exactly the keys the registry walks at a definition's top level. + * The one entry that reads a manifest (`manifest-permissions-string-list-removed`) + * reaches `packages[i].manifest` from the definition root itself, which runs + * first; a body has no `manifest` key for it to read again. + * + * The window is the ARTIFACT's — decided once from the definition's own + * `engines.protocol` — and a body's own `engines.protocol` is not read. The + * floor stands in for when the artifact was built (see above), and one + * `os build` serializes every body of one artifact together (ADR-0130 D1: one + * artifact, one atomic delivery). The same reading gates the boot: the + * artifact-boot handshake judges the artifact's own `manifest` (`AppPlugin`, + * `assertArtifactProtocolCompatible` in `@objectstack/runtime`). A per-body + * window would also split the two copies of one definition that an artifact + * built before that addendum carries (top level and body) into two answers. + * Notices from a body are located from the definition root + * (`packages[1].manifest.dashboards[0].refreshIntervalSeconds`). + * * ## The one class the window does NOT admit — default flips * * The window above is a RESCUE: without it an artifact carrying a key the @@ -500,14 +532,66 @@ export function applyArtifactForwardConversions( } const notices: ArtifactConversionNotice[] = []; - const converted = applyConversions(definition as Record, { - includeRetired: true, - excludeConversionIds, - onNotice: (n) => { - notices.push(n); - options.onNotice?.(n); - }, - }) as T; + // ONE walk — the registry's whole chain, under the one window decided above — + // run over every root this definition carries: its own collections first, + // then each `packages[i].manifest` body (see "Reach" in the module doc). A + // body's notice is located under its package, so a boot log names the body. + const replay = (root: Record, at: string): Record => + applyConversions(root, { + includeRetired: true, + excludeConversionIds, + onNotice: (n) => { + const notice = at === '' ? n : locateUnder(n, at); + notices.push(notice); + options.onNotice?.(notice); + }, + }); + const converted = replayPackageBodies(replay(definition as Record, ''), replay) as T; return { definition: converted, verdict, authoredFloor, runtimeSpecVersion, notices, replayedRetirements }; } + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * Run `replay` over each `packages[i].manifest` body (ADR-0130 D4) of an + * already-converted definition. Copy-on-write at both levels: an entry whose + * body converts nothing keeps its reference, and the definition keeps its own + * when no body converted. A `packages` that is not an array, an entry that is + * not an object, and a body that is not an object are left exactly as given — + * this never gates; `resolveArtifactPackageOrder` (`@objectstack/core`) is the + * refusal for each of those shapes. + */ +function replayPackageBodies( + definition: Record, + replay: (root: Record, at: string) => Record, +): Record { + const packages = definition.packages; + if (!Array.isArray(packages)) return definition; + let touched = false; + const next = packages.map((entry: unknown, i: number) => { + if (!isRecord(entry) || !isRecord(entry.manifest)) return entry; + const body = replay(entry.manifest, `packages[${i}].manifest`); + if (body === entry.manifest) return entry; + touched = true; + return { ...entry, manifest: body }; + }); + return touched ? { ...definition, packages: next } : definition; +} + +/** + * A notice emitted inside a package body, located from the definition's root: + * `path` gains the body's prefix, and so does the one place `message` quotes + * that path (`applyConversions` writes it as ` at : `). Every other field + * is the conversion's own. + */ +function locateUnder(notice: ArtifactConversionNotice, at: string): ArtifactConversionNotice { + const path = `${at}.${notice.path}`; + return { + ...notice, + path, + message: notice.message.replace(` at ${notice.path}: `, () => ` at ${path}: `), + }; +} diff --git a/packages/metadata/src/plugin-artifact-forward-conversion-package-body.test.ts b/packages/metadata/src/plugin-artifact-forward-conversion-package-body.test.ts new file mode 100644 index 00000000000..d0b9d9e37dd --- /dev/null +++ b/packages/metadata/src/plugin-artifact-forward-conversion-package-body.test.ts @@ -0,0 +1,146 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23027] An option-B artifact built before a retirement boots at the + * metadata door, and the boot log names the package body that carried it. + * + * A multi-package artifact (ADR-0130 D4, option B) carries its collections in + * `packages[i].manifest`. The door's strict parse (`ObjectStackDefinitionSchema`) + * gates every body, so a dashboard still spelled `refreshInterval` inside a body + * refused the whole artifact (`invalid_type` at + * `packages.1.manifest.dashboards.0.refreshInterval`) while the same dashboard + * in the definition's own `dashboards` converted. The conversion now runs the + * same walk over each body (`applyArtifactForwardConversions`, + * `@objectstack/metadata-core`), where the reach, the window and the whole + * step over a body are pinned with injected labels. This file pins the real + * door on this tree's installed label. + * + * The fixture is the shape `os build` writes for `examples/app-multi-package`: + * the artifact's own `manifest` is its last package's, both packages declare + * `engines.protocol: '^18'`, and every collection sits in a body. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { ALL_CONVERSIONS } from '@objectstack/spec'; +import { resolveInstalledSpecVersion } from '@objectstack/metadata-core'; +import { MetadataPlugin } from './plugin.js'; + +const SUBJECT = 'dashboard-refresh-interval-to-refresh-interval-seconds'; +const CORE_ID = 'com.example.ob.core'; +const ORDERS_ID = 'com.example.ob.orders'; + +const core = (range: string) => ({ + id: CORE_ID, namespace: 'ob', name: 'ob_core', version: '1.0.0', type: 'app', engines: { protocol: range }, +}); +const orders = (range: string) => ({ + id: ORDERS_ID, namespace: 'ob', name: 'ob_orders', version: '2.0.0', type: 'module', engines: { protocol: range }, + dependencies: { [CORE_ID]: '^1.0.0' }, +}); +const dashboard = (name: string, refresh: Record) => ({ name, label: name, widgets: [], ...refresh }); + +/** An option-B artifact; `ordersDashboard` adds a second body's dashboard. */ +function artifact(range: string, refresh: Record, ordersDashboard = false): any { + return { + manifest: core(range), + packages: [ + { manifest: { ...orders(range), ...(ordersDashboard ? { dashboards: [dashboard('ob_orders_overview', { refreshInterval: 120 })] } : {}) } }, + { manifest: { ...core(range), dashboards: [dashboard('ob_overview', refresh)] } }, + ], + }; +} + +function fakeCtx() { + return { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: vi.fn(), + getService: vi.fn(() => undefined), + trigger: vi.fn(), + } as any; +} + +function newPlugin(): any { + return new MetadataPlugin({ watch: false, config: { bootstrap: 'lazy' } }); +} + +/** The conversion summary lines the door logged, keyed by conversion id. */ +function conversionWarns(ctx: any): Map { + const byId = new Map(); + for (const [line] of ctx.logger.warn.mock.calls as [string][]) { + const id = /ADR-0087 conversion '([a-z0-9-]+)'/.exec(String(line))?.[1]; + if (!id) continue; + byId.set(id, [...(byId.get(id) ?? []), String(line)]); + } + return byId; +} + +const triple = (v: string) => v.split(/[.-]/).slice(0, 3).map((n) => Number.parseInt(n, 10)); +function compareVersions(a: string, b: string): number { + const [x, y] = [triple(a), triple(b)]; + for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i]! < y[i]! ? -1 : 1; + return 0; +} + +describe('[#23027] artifact door — an option-B artifact built before a retirement boots', () => { + it('boots: the body\'s dashboard registers as `refreshIntervalSeconds: 300`, owned by its package', async () => { + const plugin = newPlugin(); + const total = await plugin._parseAndRegisterArtifact(fakeCtx(), artifact('^18', { refreshInterval: 300 }), 'option-b'); + expect(total).toBeGreaterThan(0); + + const registered = (await plugin.manager.get('dashboard', 'ob_overview')) as any; + expect(registered, 'the dashboard registers').toBeDefined(); + expect(registered.refreshIntervalSeconds).toBe(300); + expect(registered).not.toHaveProperty('refreshInterval'); + expect(registered._packageId).toBe(CORE_ID); + }); + + it('converts per body: each package\'s dashboard registers converted under its own package', async () => { + const plugin = newPlugin(); + await plugin._parseAndRegisterArtifact(fakeCtx(), artifact('^18', { refreshInterval: 300 }, true), 'option-b-multi'); + + const overview = (await plugin.manager.get('dashboard', 'ob_overview')) as any; + const ordersOverview = (await plugin.manager.get('dashboard', 'ob_orders_overview')) as any; + expect([overview.refreshIntervalSeconds, overview._packageId]).toEqual([300, CORE_ID]); + expect([ordersOverview.refreshIntervalSeconds, ordersOverview._packageId]).toEqual([120, ORDERS_ID]); + expect(ordersOverview).not.toHaveProperty('refreshInterval'); + }); + + it('logs one summary per conversion, counting every body\'s sites and naming the first body\'s path', async () => { + const plugin = newPlugin(); + const ctx = fakeCtx(); + await plugin._parseAndRegisterArtifact(ctx, artifact('^18', { refreshInterval: 300 }, true), 'option-b-multi'); + + const warns = conversionWarns(ctx); + expect([...warns.keys()]).toEqual([SUBJECT]); + expect(warns.get(SUBJECT)).toHaveLength(1); + const [line] = warns.get(SUBJECT)!; + expect(line).toContain('2 site(s)'); + expect(line).toContain('first at packages[0].manifest.dashboards[0].refreshIntervalSeconds'); + expect(line).toContain(' at packages[0].manifest.dashboards[0].refreshIntervalSeconds: '); + }); + + it('the same artifact built after the rename boots with nothing converted and nothing logged', async () => { + const plugin = newPlugin(); + const ctx = fakeCtx(); + await plugin._parseAndRegisterArtifact(ctx, artifact('^18', { refreshIntervalSeconds: 300 }), 'option-b-current'); + expect([...conversionWarns(ctx).keys()]).toEqual([]); + expect(((await plugin.manager.get('dashboard', 'ob_overview')) as any).refreshIntervalSeconds).toBe(300); + }); + + it('WINDOW SHUT — an artifact authored past every retirement still meets the tombstone inside its body', async () => { + // The first `x.y.z` past the installed label and every `retiredAfter`: + // a floor no window opens for, whatever label the version pass sets. + const installed = resolveInstalledSpecVersion(); + expect(installed).toMatch(/^\d+\.\d+\.\d+/); + const stamps = [installed!, ...ALL_CONVERSIONS.flatMap((c) => (c.retiredFromLoadPath === true ? [c.retiredAfter] : []))]; + const top = [...stamps].sort(compareVersions)[stamps.length - 1]!; + const [major, minor, patch] = triple(top); + const current = `^${major}.${minor}.${patch! + 1}`; + + const refusal = await newPlugin() + ._parseAndRegisterArtifact(fakeCtx(), artifact(current, { refreshInterval: 300 }), 'option-b-current-floor') + .then(() => undefined, (e: any) => e); + expect(refusal, 'the strict parse refuses').toBeDefined(); + expect((refusal.issues as { path: (string | number)[] }[]).map((i) => i.path.join('.'))) + .toEqual(['packages.1.manifest.dashboards.0.refreshInterval']); + }); +}); From c1883e9883ce5c9864ca030831ba75de7059e599 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 12:34:33 +0000 Subject: [PATCH 2/6] chore(metadata-core): changeset for the package-body conversion; notice path doc names the body form Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- .changeset/23027-package-body-conversion.md | 15 +++++++++++++++ .../src/artifact-forward-conversion.ts | 5 ++++- 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 .changeset/23027-package-body-conversion.md diff --git a/.changeset/23027-package-body-conversion.md b/.changeset/23027-package-body-conversion.md new file mode 100644 index 00000000000..e478844d5f2 --- /dev/null +++ b/.changeset/23027-package-body-conversion.md @@ -0,0 +1,15 @@ +--- +'@objectstack/metadata-core': patch +--- + +fix(metadata-core): the artifact forward conversion walks every `packages[].manifest` body of a multi-package artifact, so a retired key inside a package body is converted instead of refused at the metadata door + +Clause-②: no + +**What an operator saw.** A multi-package release artifact (ADR-0130 D4: `os build` of a `composeStacks(…, { manifest: 'preserve' })` project) carries its collections in `packages[i].manifest`. When such an artifact was built before a retirement, for example a dashboard still spelled `refreshInterval`, the metadata plugin's artifact door refused it at the strict parse (`invalid_type` at `packages.1.manifest.dashboards.0.refreshInterval`). The ADR-0087 conversion converted the same dashboard when it sat in the artifact's own `dashboards`, but it never read a package body. + +**What an operator sees now.** `applyArtifactForwardConversions` runs the same conversion over each package body as over the artifact's own collections: the same registry, and the same window, decided once from the artifact's own `engines.protocol` (a body's own `engines.protocol` is not read). The metadata plugin's artifact door registers the converted body, each item still owned by its own package, and its one-per-conversion warning names the body: `… converted 2 site(s) forward via ADR-0087 conversion 'dashboard-refresh-interval-to-refresh-interval-seconds' (first at packages[0].manifest.dashboards[0].refreshIntervalSeconds) …`. A body built after the retirement converts nothing and logs nothing. An artifact authored past every retirement still meets the tombstone inside its body. + +**Not yet.** The default host (`os dev -a`, `os start` from an artifact) reads `packages[]` raw in `@objectstack/runtime` before the metadata door runs, so such an artifact is still refused there with "`packages[N]` is not a package entry (ADR-0130 D4)". That reader is not changed here. + +**Unchanged.** An artifact without `packages[]` converts exactly as before, with the same notices, paths and messages. diff --git a/packages/metadata-core/src/artifact-forward-conversion.ts b/packages/metadata-core/src/artifact-forward-conversion.ts index 1ac3ee48d27..7a1bd2df8c2 100644 --- a/packages/metadata-core/src/artifact-forward-conversion.ts +++ b/packages/metadata-core/src/artifact-forward-conversion.ts @@ -225,7 +225,10 @@ export interface ArtifactConversionNotice { from: string; /** The canonical token/shape it was converted to. */ to: string; - /** Where in the stack it applied, e.g. `permissions[0].objects.crm_ticket.allowPurge`. */ + /** + * Where in the stack it applied, e.g. `permissions[0].objects.crm_ticket.allowPurge`; + * inside a package body, from the definition's root (`packages[1].manifest.dashboards[0]…`). + */ path: string; /** Derived, human-facing one-liner. */ message: string; From 3972d9a167a8d32404af964203afc70ee727a0cf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 13:34:08 +0000 Subject: [PATCH 3/6] fix(runtime): loadArtifactBundle forward-converts the loaded artifact once, before mergeRuntimeModule and every reader An option-B artifact built before a retirement was refused by the runtime's first reader of packages[] (createStandaloneStack, through resolveArtifactCollections), before the metadata door's own conversion ran. The loader now runs applyArtifactForwardConversions once on the loaded bundle, so every runtime reader sees the converted package bodies. The AppPlugin comment that said no conversion reaches the bodies is corrected. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- packages/runtime/src/app-plugin.ts | 12 ++--- packages/runtime/src/load-artifact-bundle.ts | 47 ++++++++++++++++++++ 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index 39d6bb3b695..6af0f90f7ed 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -498,11 +498,13 @@ export class AppPlugin implements Plugin { // served the artifact door's converted copy of it, and what handed the // email loader a template it refuses to send. // - // A bundle carrying `packages[]` is handed over as authored, as before: - // the `manifest` service registers its package BODIES, which no - // conversion reaches (the ADR-0087 pass walks the definition's own - // collections, at the artifact door too), and `forwardConverted` would - // resolve `packages[]` here, ahead of the `manifest` service (#15292). + // A bundle carrying `packages[]` is handed over as it arrived, as before: + // `forwardConverted` would resolve `packages[]` here, ahead of the + // `manifest` service (#15292). Its package BODIES are already converted + // when the bundle came through `loadArtifactBundle`, which runs the + // ADR-0087 pass once over every body at load (#23027); a bundle handed + // to this plugin by any other route reaches the `manifest` service as + // authored. const servicePayload = (this.bundle as { packages?: unknown }).packages === undefined ? this.forwardConverted(ctx).definition : this.bundle.manifest diff --git a/packages/runtime/src/load-artifact-bundle.ts b/packages/runtime/src/load-artifact-bundle.ts index c78b131e624..e49e71c0032 100644 --- a/packages/runtime/src/load-artifact-bundle.ts +++ b/packages/runtime/src/load-artifact-bundle.ts @@ -30,8 +30,51 @@ import { readFile } from 'node:fs/promises'; import { resolve as resolvePath, isAbsolute, dirname } from 'node:path'; import { pathToFileURL } from 'node:url'; +import { applyArtifactForwardConversions } from '@objectstack/metadata-core'; + import { resolveArtifactCollections } from './artifact-collections.js'; +/** + * The ADR-0087 D2 forward conversion at the runtime's artifact load (#23027): + * one call of `applyArtifactForwardConversions`, under the window the artifact's + * own `engines.protocol` opens, over the definition and every + * `packages[i].manifest` body. + * + * A loaded artifact is data at rest. Every runtime reader of a multi-package + * one parses each package body strictly: `resolveArtifactPackageOrder` gates + * each entry, reached through `resolveArtifactCollections` + * (`createStandaloneStack`'s surfaced stack, `AppPlugin`'s `collections`, + * `mergeRuntimeModule`) and through the `manifest` service's `register()`. So + * an artifact built before a retirement was refused by the first of those + * readers, before the metadata door's own conversion could run. Converting the + * bundle once, where it is read from disk, gives every reader the same + * converted bytes. ⛔ Never a conversion per reader: that is N copies of one + * policy, and the readers could then disagree about which artifacts load. + * + * The one other place on the boot path that reads an artifact FILE, + * `readConfigDeclaredDefault` (`resolve-project-database.ts`), calls this same + * function on what it read. + * + * Silent by design. On an artifact boot `createStandaloneStack` composes + * `MetadataPlugin` with `artifactSource` naming this same file, and its door + * converts the raw file again and prints the one operator summary per + * conversion. A line here as well would name each conversion twice per boot. + * Converting before the protocol handshake changes no verdict: the handshake + * reads `manifest.engines`, which no conversion rewrites. + * + * An envelope that was not unwrapped (`{ schemaVersion, metadata }`) has its + * `metadata` converted, the position the metadata door converts. Copy-on-write: + * the input comes back by reference when nothing converted. + */ +export function convertArtifactAtLoad(bundle: any): any { + if (bundle === null || typeof bundle !== 'object' || Array.isArray(bundle)) return bundle; + if (bundle.schemaVersion != null && bundle.metadata !== undefined) { + const metadata = applyArtifactForwardConversions(bundle.metadata).definition; + return metadata === bundle.metadata ? bundle : { ...bundle, metadata }; + } + return applyArtifactForwardConversions(bundle).definition; +} + export interface LoadArtifactBundleOptions { /** Optional log tag for warnings (defaults to `[loadArtifactBundle]`). */ tag?: string; @@ -128,6 +171,10 @@ export async function loadArtifactBundle( return null; } + // [#23027] Converted HERE, once, before `mergeRuntimeModule` and before any + // reader is handed the bundle — see `convertArtifactAtLoad`. + bundle = convertArtifactAtLoad(bundle); + if (isUrl) { // Remote artifacts cannot dynamic-import a sibling ESM runtime module // safely (Node does not allow importing arbitrary URLs and we never From 8a4bf29bec9e803953330eb1692e2e75d92d106c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 13:59:44 +0000 Subject: [PATCH 4/6] fix(runtime): the database resolver's own artifact read converts through the loader's function; door test for option-B boots readConfigDeclaredDefault reads the artifact file itself. With the loader now converting, a body carrying a retired key was declined there and booted by the loader against the unified default database instead of the declared one; the resolver now calls convertArtifactAtLoad on what it read. The runtime door test pins the option-B boot (single and multi-package), the definition-collection and window-shut controls, the conversion's idempotence on the loader's output, and the resolver. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- .../runtime/src/resolve-project-database.ts | 10 +- ...-stack-option-b-forward-conversion.test.ts | 233 ++++++++++++++++++ 2 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 packages/runtime/src/standalone-stack-option-b-forward-conversion.test.ts diff --git a/packages/runtime/src/resolve-project-database.ts b/packages/runtime/src/resolve-project-database.ts index 29d78f67250..a970de19be5 100644 --- a/packages/runtime/src/resolve-project-database.ts +++ b/packages/runtime/src/resolve-project-database.ts @@ -48,6 +48,7 @@ import { resolve as resolvePath, isAbsolute } from 'node:path'; import { existsSync, readFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { resolveArtifactCollections } from './artifact-collections.js'; +import { convertArtifactAtLoad } from './load-artifact-bundle.js'; import { resolveDriverId } from '@objectstack/spec/data'; /** The unified default database filename (`/data/objectstack.db`). */ @@ -253,7 +254,14 @@ function readConfigDeclaredDefault(opts: { // ADR-0112 refusal `resolveArtifactPackageOrder` owns, and this // resolver's posture for an artifact it cannot read is to DECLINE (the // boot's own loader reports that loudly), never to brick a URL lookup. - bundle = resolveArtifactCollections(unwrapped); + // + // [#23027] Converted first, by the loader's own function: this read and + // `loadArtifactBundle` are the boot's two reads of the artifact file, and + // the loader converts what it reads. A body carrying a retired key would + // otherwise be refused HERE (and declined) while the loader converts it + // and boots it — against the unified default database, not the declared + // one. + bundle = resolveArtifactCollections(convertArtifactAtLoad(unwrapped)); } catch { // Unreadable/malformed artifact — the boot's own loader reports that // loudly; the URL resolver just cannot consult it. diff --git a/packages/runtime/src/standalone-stack-option-b-forward-conversion.test.ts b/packages/runtime/src/standalone-stack-option-b-forward-conversion.test.ts new file mode 100644 index 00000000000..859fbbb55ee --- /dev/null +++ b/packages/runtime/src/standalone-stack-option-b-forward-conversion.test.ts @@ -0,0 +1,233 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23027] An option-B artifact built before a retirement boots through the + * runtime's own loader, the composition `os dev -a` and `os start` use. + * + * A multi-package artifact (ADR-0130 D4, option B) carries its collections in + * `packages[i].manifest`, and every runtime reader parses each body strictly + * (`resolveArtifactPackageOrder`, reached through `resolveArtifactCollections` + * and the `manifest` service). The first of them, `createStandaloneStack`'s + * surfaced stack, refused a body still spelling a retired key before the + * metadata door's own conversion could run. Measured at `os dev -a … --fresh`: + * health `000`, "`packages[1]` is not a package entry (ADR-0130 D4): + * manifest.dashboards.0.refreshInterval". `loadArtifactBundle` now converts the + * loaded bundle once (`convertArtifactAtLoad`), so every reader is handed the + * converted bodies; the database resolver, which reads the file itself, calls + * the same function. + * + * The reach, the window and the whole step over a body are pinned in + * `@objectstack/metadata-core` (`artifact-forward-conversion-package-body.test.ts`), + * and the metadata door in `@objectstack/metadata`. This file pins the boot. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { ALL_CONVERSIONS } from '@objectstack/spec'; +import { applyArtifactForwardConversions, resolveInstalledSpecVersion } from '@objectstack/metadata-core'; +import { Runtime } from './runtime.js'; +import { createStandaloneStack } from './standalone-stack.js'; +import { loadArtifactBundle } from './load-artifact-bundle.js'; +import { resolveProjectDatabaseUrl } from './resolve-project-database.js'; + +// [#10126] Pay the first transform of the dist-resolved workspace deps the boot +// reaches through a dynamic `import()` at MODULE LOAD, never inside a clocked `it`. +import '@objectstack/metadata'; +import '@objectstack/objectql'; +import '@objectstack/service-datasource'; + +const BOOT_TIMEOUT = 120_000; +const ORDERS_ID = 'com.test.ob.orders'; +const CORE_ID = 'com.test.ob.core'; + +type Bag = Record; + +const orders = (range: string): Bag => ({ + id: ORDERS_ID, namespace: 'ob', name: 'ob_orders', version: '2.0.0', type: 'module', engines: { protocol: range }, + dependencies: { [CORE_ID]: '^1.0.0' }, +}); +const core = (range: string): Bag => ({ + id: CORE_ID, namespace: 'ob', name: 'ob_core', version: '1.0.0', type: 'app', engines: { protocol: range }, +}); +const dashboard = (name: string, refresh: Record) => ({ name, label: name, widgets: [], ...refresh }); + +/** + * The shape `os build` writes for a two-package project: the artifact's own + * `manifest` is its last package's, every collection sits in a body. + */ +function optionB(range: string, ordersBody: Bag, coreBody: Bag = {}): Bag { + return { + manifest: core(range), + packages: [ + { manifest: { ...orders(range), ...ordersBody } }, + { manifest: { ...core(range), ...coreBody } }, + ], + }; +} + +/** The first `x.y.z` past the installed label and every `retiredAfter`: a floor no window opens for. */ +function currentSurfaceRange(): string { + const triple = (v: string) => v.split(/[.-]/).slice(0, 3).map((n) => Number.parseInt(n, 10)); + const compare = (a: string, b: string) => { + const [x, y] = [triple(a), triple(b)]; + for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i]! < y[i]! ? -1 : 1; + return 0; + }; + const installed = resolveInstalledSpecVersion(); + if (!installed) throw new Error('the installed @objectstack/spec version is unresolvable'); + const stamps = [installed, ...ALL_CONVERSIONS.flatMap((c) => (c.retiredFromLoadPath === true ? [c.retiredAfter] : []))]; + const [major, minor, patch] = triple([...stamps].sort(compare)[stamps.length - 1]!); + return `^${major}.${minor}.${patch! + 1}`; +} + +const dirs: string[] = []; + +/** Write `artifact` as `objectstack.json` in a fresh project directory. */ +function writeArtifact(tag: string, artifact: unknown): { dir: string; artifactPath: string } { + const dir = mkdtempSync(join(tmpdir(), `os-23027-${tag}-`)); + dirs.push(dir); + const artifactPath = join(dir, 'objectstack.json'); + writeFileSync(artifactPath, JSON.stringify(artifact), 'utf-8'); + return { dir, artifactPath }; +} + +/** Boot `artifact` the way `os dev -a` composes it. */ +async function bootArtifact(tag: string, artifact: unknown): Promise { + const { dir, artifactPath } = writeArtifact(tag, artifact); + const stack = await createStandaloneStack({ + artifactPath, + projectRoot: dir, + databaseUrl: ':memory:', + skipSeedData: true, + runPlatformMigrations: false, + }); + const kernel = new Runtime({ cluster: false }).getKernel(); + for (const p of stack.plugins) await kernel.use(p as any); + await kernel.bootstrap(); + return kernel; +} + +/** The one dashboard named `name` that `GET /meta/dashboard` lists. */ +async function listed(kernel: any, name: string): Promise { + const answer = await kernel.getService('protocol').getMetaItems({ type: 'dashboard' }); + const hits = (answer?.items as Bag[] | undefined)?.filter((i) => i?.name === name) ?? []; + expect(hits, `GET /meta/dashboard must list exactly one ${name}`).toHaveLength(1); + return hits[0]!; +} + +/** The dashboard `GET /meta/dashboard/NAME` serves. */ +async function served(kernel: any, name: string): Promise { + const answer = await kernel.getService('protocol').getMetaItem({ type: 'dashboard', name }); + expect(answer?.item, `GET /meta/dashboard/${name} must serve an item`).toBeDefined(); + return answer.item as Bag; +} + +/** Both reads serve `name` as `refreshIntervalSeconds: seconds`, valid, owned by `packageId`. */ +async function expectServedConverted(kernel: any, name: string, seconds: number, packageId: string) { + for (const item of [await listed(kernel, name), await served(kernel, name)]) { + expect(item.refreshIntervalSeconds).toBe(seconds); + expect(item).not.toHaveProperty('refreshInterval'); + expect(item._diagnostics).toEqual({ valid: true }); + expect(item._packageId).toBe(packageId); + } +} + +afterAll(() => { + for (const d of dirs.splice(0)) { + try { rmSync(d, { recursive: true, force: true }); } catch { /* noop */ } + } +}); + +describe('[#23027] the runtime\'s artifact load converts every package body once', () => { + it('loadArtifactBundle hands back the converted body, and a second conversion (the metadata door\'s) finds nothing left', async () => { + const { artifactPath } = writeArtifact('loader', optionB('^18', { dashboards: [dashboard('ob_overview', { refreshInterval: 300 })] })); + const loaded = (await loadArtifactBundle(artifactPath, { unwrapEnvelope: true })) as Bag; + + expect(loaded.packages[0].manifest.dashboards).toEqual([dashboard('ob_overview', { refreshIntervalSeconds: 300 })]); + // Idempotence, pinned once: what the door converts again on these bytes + // comes back by reference, with no notice to print a second time. + const again = applyArtifactForwardConversions(loaded); + expect(again.verdict, 'the window is open on this input').not.toBe('authored-current'); + expect(again.notices).toEqual([]); + expect(again.definition).toBe(loaded); + }); + + describe('boots: the body\'s dashboard is served as `refreshIntervalSeconds: 300` under its own package', () => { + let kernel: any; + beforeAll(async () => { + kernel = await bootArtifact('subject', optionB('^18', { dashboards: [dashboard('ob_overview', { refreshInterval: 300 })] })); + }, BOOT_TIMEOUT); + afterAll(async () => { try { await kernel?.shutdown(); } catch { /* noop */ } }); + + it('GET /meta/dashboard and GET /meta/dashboard/NAME serve the converted copy, valid, owned by the orders package', async () => { + await expectServedConverted(kernel, 'ob_overview', 300, ORDERS_ID); + }); + }); + + describe('a multi-package artifact converts per body', () => { + let kernel: any; + beforeAll(async () => { + kernel = await bootArtifact('multi', optionB( + '^18', + { dashboards: [dashboard('ob_overview', { refreshInterval: 300 })] }, + { dashboards: [dashboard('ob_core_overview', { refreshInterval: 120 })] }, + )); + }, BOOT_TIMEOUT); + afterAll(async () => { try { await kernel?.shutdown(); } catch { /* noop */ } }); + + it('each body\'s dashboard is served converted under its own package', async () => { + await expectServedConverted(kernel, 'ob_overview', 300, ORDERS_ID); + await expectServedConverted(kernel, 'ob_core_overview', 120, CORE_ID); + }); + }); + + describe('CONTROL — a definition-collection artifact (no packages[]) is served exactly as before', () => { + let kernel: any; + beforeAll(async () => { + kernel = await bootArtifact('control', { + manifest: core('^18'), + dashboards: [dashboard('ob_overview', { refreshInterval: 300 })], + }); + }, BOOT_TIMEOUT); + afterAll(async () => { try { await kernel?.shutdown(); } catch { /* noop */ } }); + + it('GET /meta/dashboard and GET /meta/dashboard/NAME serve the converted copy, valid, owned by the artifact\'s package', async () => { + await expectServedConverted(kernel, 'ob_overview', 300, CORE_ID); + }); + }); + + it('WINDOW SHUT — an artifact authored past every retirement is still refused, at the first reader, naming the body\'s site', async () => { + const { dir, artifactPath } = writeArtifact('shut', optionB(currentSurfaceRange(), { dashboards: [dashboard('ob_overview', { refreshInterval: 300 })] })); + const refusal = await createStandaloneStack({ + artifactPath, + projectRoot: dir, + databaseUrl: ':memory:', + skipSeedData: true, + runPlatformMigrations: false, + }).then(() => undefined, (e: any) => e); + + expect(refusal, 'the boot is refused').toBeDefined(); + expect(refusal.code).toBe('INVALID_ARTIFACT_PACKAGE_ENTRY'); + expect(refusal.status).toBe(422); + expect(String(refusal.message)).toContain('packages[0]'); + expect(String(refusal.message)).toContain('manifest.dashboards.0.refreshInterval'); + }); + + it('the database resolver, which reads the artifact file itself, resolves the declared default the body names', () => { + // Before the resolver called the loader's conversion, this body was + // refused there and the lookup DECLINED, while the loader converted the + // same body and booted it, against the unified default database. + const declared = { + datasources: [{ name: 'ob_db', label: 'OB DB', driver: 'sqlite', config: { filename: './ob.db' } }], + datasourceMapping: [{ default: true, datasource: 'ob_db' }], + }; + for (const refresh of [{ refreshInterval: 300 }, { refreshIntervalSeconds: 300 }]) { + const { dir, artifactPath } = writeArtifact('db', optionB('^18', { ...declared, dashboards: [dashboard('ob_overview', refresh)] })); + const resolved = resolveProjectDatabaseUrl({ artifactPath, projectRoot: dir, env: {} }); + expect([resolved.source, resolved.url, resolved.datasourceName], JSON.stringify(refresh)) + .toEqual(['config-datasource', `file:${join(dir, 'ob.db')}`, 'ob_db']); + } + }); +}); From d8a9cec8db86a70d80afdffea0970e1d75af1553 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 14:04:01 +0000 Subject: [PATCH 5/6] chore(changeset): runtime patch; operators now boot option-B artifacts at os dev -a and os start Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- .changeset/23027-package-body-conversion.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.changeset/23027-package-body-conversion.md b/.changeset/23027-package-body-conversion.md index e478844d5f2..ec32a6b82d0 100644 --- a/.changeset/23027-package-body-conversion.md +++ b/.changeset/23027-package-body-conversion.md @@ -1,15 +1,17 @@ --- '@objectstack/metadata-core': patch +'@objectstack/runtime': patch --- -fix(metadata-core): the artifact forward conversion walks every `packages[].manifest` body of a multi-package artifact, so a retired key inside a package body is converted instead of refused at the metadata door +fix(metadata-core, runtime): a multi-package artifact built before a retirement boots at `os dev -a` and `os start`. The ADR-0087 forward conversion walks every `packages[].manifest` body, and the runtime's artifact load converts the bundle once, before any reader sees it Clause-②: no -**What an operator saw.** A multi-package release artifact (ADR-0130 D4: `os build` of a `composeStacks(…, { manifest: 'preserve' })` project) carries its collections in `packages[i].manifest`. When such an artifact was built before a retirement, for example a dashboard still spelled `refreshInterval`, the metadata plugin's artifact door refused it at the strict parse (`invalid_type` at `packages.1.manifest.dashboards.0.refreshInterval`). The ADR-0087 conversion converted the same dashboard when it sat in the artifact's own `dashboards`, but it never read a package body. +**What an operator saw.** A multi-package release artifact (ADR-0130 D4: `os build` of a `composeStacks(…, { manifest: 'preserve' })` project) carries its collections in `packages[i].manifest`. Take such an artifact built before a retirement, for example with a dashboard still spelled `refreshInterval`. `os dev -a dist/objectstack.json` and `os start` exited at boot with "`packages[1]` is not a package entry (ADR-0130 D4): manifest.dashboards.0.refreshInterval: `dashboard.refreshInterval` was renamed to `refreshIntervalSeconds`…". The metadata plugin's artifact door refused the same artifact at its strict parse. The ADR-0087 conversion converted that dashboard when it sat in the artifact's own `dashboards`, but it never read a package body, and the runtime read the bundle raw before any conversion ran. -**What an operator sees now.** `applyArtifactForwardConversions` runs the same conversion over each package body as over the artifact's own collections: the same registry, and the same window, decided once from the artifact's own `engines.protocol` (a body's own `engines.protocol` is not read). The metadata plugin's artifact door registers the converted body, each item still owned by its own package, and its one-per-conversion warning names the body: `… converted 2 site(s) forward via ADR-0087 conversion 'dashboard-refresh-interval-to-refresh-interval-seconds' (first at packages[0].manifest.dashboards[0].refreshIntervalSeconds) …`. A body built after the retirement converts nothing and logs nothing. An artifact authored past every retirement still meets the tombstone inside its body. +**What an operator sees now.** The same artifact boots at `os dev -a` and at `os start`. Each body's items are served converted (`refreshIntervalSeconds: 300`, valid), each owned by its own package. The boot log names each conversion once, with the body it found it in: `[MetadataPlugin] artifact '…' … converted 2 site(s) forward via ADR-0087 conversion 'dashboard-refresh-interval-to-refresh-interval-seconds' (first at packages[0].manifest.dashboards[0].refreshIntervalSeconds) …`. The artifact file itself is unchanged, so it converts again on every boot until it is rebuilt with `os build`. A body built after the retirement converts nothing and logs nothing. An artifact authored past every retirement is still refused with the tombstone inside its body. -**Not yet.** The default host (`os dev -a`, `os start` from an artifact) reads `packages[]` raw in `@objectstack/runtime` before the metadata door runs, so such an artifact is still refused there with "`packages[N]` is not a package entry (ADR-0130 D4)". That reader is not changed here. +- **`@objectstack/metadata-core`.** `applyArtifactForwardConversions` runs the same conversion over each package body as over the artifact's own collections. It uses the same registry and the same window, decided once from the artifact's own `engines.protocol`; a body's own `engines.protocol` is not read. A notice from a body is located from the artifact root (`packages[1].manifest.…`). +- **`@objectstack/runtime`.** `loadArtifactBundle` converts the loaded artifact once, before it merges the runtime module and before the stack, `AppPlugin` or the `manifest` service read it. The database resolver reads the artifact file itself, and it runs the same conversion on what it reads. So a multi-package artifact that declares its default datasource in a package body opens that database, not the unified default. -**Unchanged.** An artifact without `packages[]` converts exactly as before, with the same notices, paths and messages. +**Unchanged.** An artifact without `packages[]` converts exactly as before, with the same notices, paths and messages, and it is served the same way. From 20a5c41cfb130c7a36778b85f8d5701af92c63c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 14:40:46 +0000 Subject: [PATCH 6/6] test(runtime): the option-B door test's refresh list is typed, so the test layer compiles under tsconfig.test.json Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude --- .../src/standalone-stack-option-b-forward-conversion.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/runtime/src/standalone-stack-option-b-forward-conversion.test.ts b/packages/runtime/src/standalone-stack-option-b-forward-conversion.test.ts index 859fbbb55ee..6fb674bf9e3 100644 --- a/packages/runtime/src/standalone-stack-option-b-forward-conversion.test.ts +++ b/packages/runtime/src/standalone-stack-option-b-forward-conversion.test.ts @@ -223,7 +223,8 @@ describe('[#23027] the runtime\'s artifact load converts every package body once datasources: [{ name: 'ob_db', label: 'OB DB', driver: 'sqlite', config: { filename: './ob.db' } }], datasourceMapping: [{ default: true, datasource: 'ob_db' }], }; - for (const refresh of [{ refreshInterval: 300 }, { refreshIntervalSeconds: 300 }]) { + const refreshes: Record[] = [{ refreshInterval: 300 }, { refreshIntervalSeconds: 300 }]; + for (const refresh of refreshes) { const { dir, artifactPath } = writeArtifact('db', optionB('^18', { ...declared, dashboards: [dashboard('ob_overview', refresh)] })); const resolved = resolveProjectDatabaseUrl({ artifactPath, projectRoot: dir, env: {} }); expect([resolved.source, resolved.url, resolved.datasourceName], JSON.stringify(refresh))