|
| 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