diff --git a/.changeset/23027-package-body-conversion.md b/.changeset/23027-package-body-conversion.md new file mode 100644 index 00000000000..ec32a6b82d0 --- /dev/null +++ b/.changeset/23027-package-body-conversion.md @@ -0,0 +1,17 @@ +--- +'@objectstack/metadata-core': patch +'@objectstack/runtime': patch +--- + +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`. 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.** 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. + +- **`@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, and it is served the same way. 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..7a1bd2df8c2 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 @@ -193,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; @@ -500,14 +535,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']); + }); +}); 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 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..6fb674bf9e3 --- /dev/null +++ b/packages/runtime/src/standalone-stack-option-b-forward-conversion.test.ts @@ -0,0 +1,234 @@ +// 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' }], + }; + 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)) + .toEqual(['config-datasource', `file:${join(dir, 'ob.db')}`, 'ob_db']); + } + }); +});