Skip to content

Commit 8a4bf29

Browse files
committed
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 <noreply@anthropic.com>
1 parent 3972d9a commit 8a4bf29

2 files changed

Lines changed: 242 additions & 1 deletion

File tree

‎packages/runtime/src/resolve-project-database.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import { resolve as resolvePath, isAbsolute } from 'node:path';
4848
import { existsSync, readFileSync } from 'node:fs';
4949
import { homedir } from 'node:os';
5050
import { resolveArtifactCollections } from './artifact-collections.js';
51+
import { convertArtifactAtLoad } from './load-artifact-bundle.js';
5152
import { resolveDriverId } from '@objectstack/spec/data';
5253

5354
/** The unified default database filename (`<state dir>/data/objectstack.db`). */
@@ -253,7 +254,14 @@ function readConfigDeclaredDefault(opts: {
253254
// ADR-0112 refusal `resolveArtifactPackageOrder` owns, and this
254255
// resolver's posture for an artifact it cannot read is to DECLINE (the
255256
// boot's own loader reports that loudly), never to brick a URL lookup.
256-
bundle = resolveArtifactCollections(unwrapped);
257+
//
258+
// [#23027] Converted first, by the loader's own function: this read and
259+
// `loadArtifactBundle` are the boot's two reads of the artifact file, and
260+
// the loader converts what it reads. A body carrying a retired key would
261+
// otherwise be refused HERE (and declined) while the loader converts it
262+
// and boots it — against the unified default database, not the declared
263+
// one.
264+
bundle = resolveArtifactCollections(convertArtifactAtLoad(unwrapped));
257265
} catch {
258266
// Unreadable/malformed artifact — the boot's own loader reports that
259267
// loudly; the URL resolver just cannot consult it.
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#23027] An option-B artifact built before a retirement boots through the
5+
* runtime's own loader, the composition `os dev -a` and `os start` use.
6+
*
7+
* A multi-package artifact (ADR-0130 D4, option B) carries its collections in
8+
* `packages[i].manifest`, and every runtime reader parses each body strictly
9+
* (`resolveArtifactPackageOrder`, reached through `resolveArtifactCollections`
10+
* and the `manifest` service). The first of them, `createStandaloneStack`'s
11+
* surfaced stack, refused a body still spelling a retired key before the
12+
* metadata door's own conversion could run. Measured at `os dev -a … --fresh`:
13+
* health `000`, "`packages[1]` is not a package entry (ADR-0130 D4):
14+
* manifest.dashboards.0.refreshInterval". `loadArtifactBundle` now converts the
15+
* loaded bundle once (`convertArtifactAtLoad`), so every reader is handed the
16+
* converted bodies; the database resolver, which reads the file itself, calls
17+
* the same function.
18+
*
19+
* The reach, the window and the whole step over a body are pinned in
20+
* `@objectstack/metadata-core` (`artifact-forward-conversion-package-body.test.ts`),
21+
* and the metadata door in `@objectstack/metadata`. This file pins the boot.
22+
*/
23+
24+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
25+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
26+
import { tmpdir } from 'node:os';
27+
import { join } from 'node:path';
28+
import { ALL_CONVERSIONS } from '@objectstack/spec';
29+
import { applyArtifactForwardConversions, resolveInstalledSpecVersion } from '@objectstack/metadata-core';
30+
import { Runtime } from './runtime.js';
31+
import { createStandaloneStack } from './standalone-stack.js';
32+
import { loadArtifactBundle } from './load-artifact-bundle.js';
33+
import { resolveProjectDatabaseUrl } from './resolve-project-database.js';
34+
35+
// [#10126] Pay the first transform of the dist-resolved workspace deps the boot
36+
// reaches through a dynamic `import()` at MODULE LOAD, never inside a clocked `it`.
37+
import '@objectstack/metadata';
38+
import '@objectstack/objectql';
39+
import '@objectstack/service-datasource';
40+
41+
const BOOT_TIMEOUT = 120_000;
42+
const ORDERS_ID = 'com.test.ob.orders';
43+
const CORE_ID = 'com.test.ob.core';
44+
45+
type Bag = Record<string, any>;
46+
47+
const orders = (range: string): Bag => ({
48+
id: ORDERS_ID, namespace: 'ob', name: 'ob_orders', version: '2.0.0', type: 'module', engines: { protocol: range },
49+
dependencies: { [CORE_ID]: '^1.0.0' },
50+
});
51+
const core = (range: string): Bag => ({
52+
id: CORE_ID, namespace: 'ob', name: 'ob_core', version: '1.0.0', type: 'app', engines: { protocol: range },
53+
});
54+
const dashboard = (name: string, refresh: Record<string, number>) => ({ name, label: name, widgets: [], ...refresh });
55+
56+
/**
57+
* The shape `os build` writes for a two-package project: the artifact's own
58+
* `manifest` is its last package's, every collection sits in a body.
59+
*/
60+
function optionB(range: string, ordersBody: Bag, coreBody: Bag = {}): Bag {
61+
return {
62+
manifest: core(range),
63+
packages: [
64+
{ manifest: { ...orders(range), ...ordersBody } },
65+
{ manifest: { ...core(range), ...coreBody } },
66+
],
67+
};
68+
}
69+
70+
/** The first `x.y.z` past the installed label and every `retiredAfter`: a floor no window opens for. */
71+
function currentSurfaceRange(): string {
72+
const triple = (v: string) => v.split(/[.-]/).slice(0, 3).map((n) => Number.parseInt(n, 10));
73+
const compare = (a: string, b: string) => {
74+
const [x, y] = [triple(a), triple(b)];
75+
for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i]! < y[i]! ? -1 : 1;
76+
return 0;
77+
};
78+
const installed = resolveInstalledSpecVersion();
79+
if (!installed) throw new Error('the installed @objectstack/spec version is unresolvable');
80+
const stamps = [installed, ...ALL_CONVERSIONS.flatMap((c) => (c.retiredFromLoadPath === true ? [c.retiredAfter] : []))];
81+
const [major, minor, patch] = triple([...stamps].sort(compare)[stamps.length - 1]!);
82+
return `^${major}.${minor}.${patch! + 1}`;
83+
}
84+
85+
const dirs: string[] = [];
86+
87+
/** Write `artifact` as `objectstack.json` in a fresh project directory. */
88+
function writeArtifact(tag: string, artifact: unknown): { dir: string; artifactPath: string } {
89+
const dir = mkdtempSync(join(tmpdir(), `os-23027-${tag}-`));
90+
dirs.push(dir);
91+
const artifactPath = join(dir, 'objectstack.json');
92+
writeFileSync(artifactPath, JSON.stringify(artifact), 'utf-8');
93+
return { dir, artifactPath };
94+
}
95+
96+
/** Boot `artifact` the way `os dev -a` composes it. */
97+
async function bootArtifact(tag: string, artifact: unknown): Promise<any> {
98+
const { dir, artifactPath } = writeArtifact(tag, artifact);
99+
const stack = await createStandaloneStack({
100+
artifactPath,
101+
projectRoot: dir,
102+
databaseUrl: ':memory:',
103+
skipSeedData: true,
104+
runPlatformMigrations: false,
105+
});
106+
const kernel = new Runtime({ cluster: false }).getKernel();
107+
for (const p of stack.plugins) await kernel.use(p as any);
108+
await kernel.bootstrap();
109+
return kernel;
110+
}
111+
112+
/** The one dashboard named `name` that `GET /meta/dashboard` lists. */
113+
async function listed(kernel: any, name: string): Promise<Bag> {
114+
const answer = await kernel.getService('protocol').getMetaItems({ type: 'dashboard' });
115+
const hits = (answer?.items as Bag[] | undefined)?.filter((i) => i?.name === name) ?? [];
116+
expect(hits, `GET /meta/dashboard must list exactly one ${name}`).toHaveLength(1);
117+
return hits[0]!;
118+
}
119+
120+
/** The dashboard `GET /meta/dashboard/NAME` serves. */
121+
async function served(kernel: any, name: string): Promise<Bag> {
122+
const answer = await kernel.getService('protocol').getMetaItem({ type: 'dashboard', name });
123+
expect(answer?.item, `GET /meta/dashboard/${name} must serve an item`).toBeDefined();
124+
return answer.item as Bag;
125+
}
126+
127+
/** Both reads serve `name` as `refreshIntervalSeconds: seconds`, valid, owned by `packageId`. */
128+
async function expectServedConverted(kernel: any, name: string, seconds: number, packageId: string) {
129+
for (const item of [await listed(kernel, name), await served(kernel, name)]) {
130+
expect(item.refreshIntervalSeconds).toBe(seconds);
131+
expect(item).not.toHaveProperty('refreshInterval');
132+
expect(item._diagnostics).toEqual({ valid: true });
133+
expect(item._packageId).toBe(packageId);
134+
}
135+
}
136+
137+
afterAll(() => {
138+
for (const d of dirs.splice(0)) {
139+
try { rmSync(d, { recursive: true, force: true }); } catch { /* noop */ }
140+
}
141+
});
142+
143+
describe('[#23027] the runtime\'s artifact load converts every package body once', () => {
144+
it('loadArtifactBundle hands back the converted body, and a second conversion (the metadata door\'s) finds nothing left', async () => {
145+
const { artifactPath } = writeArtifact('loader', optionB('^18', { dashboards: [dashboard('ob_overview', { refreshInterval: 300 })] }));
146+
const loaded = (await loadArtifactBundle(artifactPath, { unwrapEnvelope: true })) as Bag;
147+
148+
expect(loaded.packages[0].manifest.dashboards).toEqual([dashboard('ob_overview', { refreshIntervalSeconds: 300 })]);
149+
// Idempotence, pinned once: what the door converts again on these bytes
150+
// comes back by reference, with no notice to print a second time.
151+
const again = applyArtifactForwardConversions(loaded);
152+
expect(again.verdict, 'the window is open on this input').not.toBe('authored-current');
153+
expect(again.notices).toEqual([]);
154+
expect(again.definition).toBe(loaded);
155+
});
156+
157+
describe('boots: the body\'s dashboard is served as `refreshIntervalSeconds: 300` under its own package', () => {
158+
let kernel: any;
159+
beforeAll(async () => {
160+
kernel = await bootArtifact('subject', optionB('^18', { dashboards: [dashboard('ob_overview', { refreshInterval: 300 })] }));
161+
}, BOOT_TIMEOUT);
162+
afterAll(async () => { try { await kernel?.shutdown(); } catch { /* noop */ } });
163+
164+
it('GET /meta/dashboard and GET /meta/dashboard/NAME serve the converted copy, valid, owned by the orders package', async () => {
165+
await expectServedConverted(kernel, 'ob_overview', 300, ORDERS_ID);
166+
});
167+
});
168+
169+
describe('a multi-package artifact converts per body', () => {
170+
let kernel: any;
171+
beforeAll(async () => {
172+
kernel = await bootArtifact('multi', optionB(
173+
'^18',
174+
{ dashboards: [dashboard('ob_overview', { refreshInterval: 300 })] },
175+
{ dashboards: [dashboard('ob_core_overview', { refreshInterval: 120 })] },
176+
));
177+
}, BOOT_TIMEOUT);
178+
afterAll(async () => { try { await kernel?.shutdown(); } catch { /* noop */ } });
179+
180+
it('each body\'s dashboard is served converted under its own package', async () => {
181+
await expectServedConverted(kernel, 'ob_overview', 300, ORDERS_ID);
182+
await expectServedConverted(kernel, 'ob_core_overview', 120, CORE_ID);
183+
});
184+
});
185+
186+
describe('CONTROL — a definition-collection artifact (no packages[]) is served exactly as before', () => {
187+
let kernel: any;
188+
beforeAll(async () => {
189+
kernel = await bootArtifact('control', {
190+
manifest: core('^18'),
191+
dashboards: [dashboard('ob_overview', { refreshInterval: 300 })],
192+
});
193+
}, BOOT_TIMEOUT);
194+
afterAll(async () => { try { await kernel?.shutdown(); } catch { /* noop */ } });
195+
196+
it('GET /meta/dashboard and GET /meta/dashboard/NAME serve the converted copy, valid, owned by the artifact\'s package', async () => {
197+
await expectServedConverted(kernel, 'ob_overview', 300, CORE_ID);
198+
});
199+
});
200+
201+
it('WINDOW SHUT — an artifact authored past every retirement is still refused, at the first reader, naming the body\'s site', async () => {
202+
const { dir, artifactPath } = writeArtifact('shut', optionB(currentSurfaceRange(), { dashboards: [dashboard('ob_overview', { refreshInterval: 300 })] }));
203+
const refusal = await createStandaloneStack({
204+
artifactPath,
205+
projectRoot: dir,
206+
databaseUrl: ':memory:',
207+
skipSeedData: true,
208+
runPlatformMigrations: false,
209+
}).then(() => undefined, (e: any) => e);
210+
211+
expect(refusal, 'the boot is refused').toBeDefined();
212+
expect(refusal.code).toBe('INVALID_ARTIFACT_PACKAGE_ENTRY');
213+
expect(refusal.status).toBe(422);
214+
expect(String(refusal.message)).toContain('packages[0]');
215+
expect(String(refusal.message)).toContain('manifest.dashboards.0.refreshInterval');
216+
});
217+
218+
it('the database resolver, which reads the artifact file itself, resolves the declared default the body names', () => {
219+
// Before the resolver called the loader's conversion, this body was
220+
// refused there and the lookup DECLINED, while the loader converted the
221+
// same body and booted it, against the unified default database.
222+
const declared = {
223+
datasources: [{ name: 'ob_db', label: 'OB DB', driver: 'sqlite', config: { filename: './ob.db' } }],
224+
datasourceMapping: [{ default: true, datasource: 'ob_db' }],
225+
};
226+
for (const refresh of [{ refreshInterval: 300 }, { refreshIntervalSeconds: 300 }]) {
227+
const { dir, artifactPath } = writeArtifact('db', optionB('^18', { ...declared, dashboards: [dashboard('ob_overview', refresh)] }));
228+
const resolved = resolveProjectDatabaseUrl({ artifactPath, projectRoot: dir, env: {} });
229+
expect([resolved.source, resolved.url, resolved.datasourceName], JSON.stringify(refresh))
230+
.toEqual(['config-datasource', `file:${join(dir, 'ob.db')}`, 'ob_db']);
231+
}
232+
});
233+
});

0 commit comments

Comments
 (0)