Skip to content

Commit ab9f0a8

Browse files
committed
test(metadata): the real door boots a ^18 prerelease-built artifact and names the conversion (#23001)
Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1de05c1 commit ab9f0a8

1 file changed

Lines changed: 128 additions & 0 deletions

File tree

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#23001] A `^18` artifact built during the v18 prerelease boots on a later
5+
* runtime, and the boot log names the conversion that let it.
6+
*
7+
* The fixture carries the shape a prerelease build from before a step-18
8+
* retirement carries: a declared index with bare `unique: true`, under
9+
* `engines.protocol: '^18'`. `declared-index-unique-scope` retires that shape
10+
* (stamped at the prerelease label, like every retirement landing in the
11+
* prerelease), and the strict parse in `_parseAndRegisterArtifact` refuses it.
12+
* A `^18` floor is 18.0.0, above every stamp of the 17.x line, so the door's
13+
* per-entry half never opened for it and the boot rolled back. The major-step
14+
* half replays every retired entry of step 18 for that floor.
15+
*
16+
* The window's version boundaries — the prerelease labels, GA, 18.0.1, a
17+
* non-first-release floor — and both directions over every step-18 entry are
18+
* pinned in `@objectstack/metadata-core`'s `artifact-forward-conversion.test.ts`
19+
* with injected labels. This file pins the real door on this tree's installed
20+
* label: the artifact boots, the conversion is named once, and the same
21+
* artifact built after the retirement boots with nothing converted.
22+
*/
23+
24+
import { describe, it, expect, vi } from 'vitest';
25+
import { resolveInstalledSpecVersion } from '@objectstack/metadata-core';
26+
import { MetadataPlugin } from './plugin.js';
27+
28+
const SUBJECT = 'declared-index-unique-scope';
29+
30+
/** A `^18` artifact; `unique: true` is the shape the subject retires, `'global'` its canonical spelling. */
31+
function artifact(unique: unknown): any {
32+
return {
33+
manifest: {
34+
id: 'com.example.prerelease-probe', namespace: 'pre', name: 'prerelease_probe', version: '1.0.0', type: 'app',
35+
engines: { protocol: '^18' },
36+
},
37+
objects: [{
38+
name: 'pre_contact', label: 'Contact',
39+
fields: { email: { type: 'text', label: 'Email' } },
40+
indexes: [{ name: 'pre_contact_email', fields: ['email'], unique }],
41+
}],
42+
};
43+
}
44+
45+
function fakeCtx() {
46+
return {
47+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
48+
registerService: vi.fn(),
49+
getService: vi.fn(() => undefined),
50+
trigger: vi.fn(),
51+
} as any;
52+
}
53+
54+
function newPlugin(): any {
55+
return new MetadataPlugin({ watch: false, config: { bootstrap: 'lazy' } });
56+
}
57+
58+
/** Order two versions by their leading `x.y.z` (a prerelease tail is ignored). */
59+
function compareVersions(a: string, b: string): number {
60+
const triple = (v: string) => v.split(/[.-]/).slice(0, 3).map((n) => Number.parseInt(n, 10));
61+
const [x, y] = [triple(a), triple(b)];
62+
for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i]! < y[i]! ? -1 : 1;
63+
return 0;
64+
}
65+
66+
/** The conversion summary lines the door logged, keyed by conversion id. */
67+
function conversionWarns(ctx: any): Map<string, string[]> {
68+
const byId = new Map<string, string[]>();
69+
for (const [line] of ctx.logger.warn.mock.calls as [string][]) {
70+
const id = /ADR-0087 conversion '([a-z0-9-]+)'/.exec(String(line))?.[1];
71+
if (!id) continue;
72+
byId.set(id, [...(byId.get(id) ?? []), String(line)]);
73+
}
74+
return byId;
75+
}
76+
77+
describe('[#23001] artifact door — a ^18 artifact built during the v18 prerelease boots on a later runtime', () => {
78+
it('boots: the object registers with the retired spelling converted to its canonical scope', async () => {
79+
const plugin = newPlugin();
80+
const total = await plugin._parseAndRegisterArtifact(fakeCtx(), artifact(true), 'prerelease-probe');
81+
expect(total).toBeGreaterThan(0);
82+
83+
const object = await plugin.manager.get('object', 'pre_contact');
84+
expect(object, 'the object registers').toBeDefined();
85+
expect((object as any).indexes).toEqual([{ name: 'pre_contact_email', fields: ['email'], unique: 'global' }]);
86+
});
87+
88+
it('logs one summary naming the conversion and its site count, with the clause of the half that opened it', async () => {
89+
const plugin = newPlugin();
90+
const ctx = fakeCtx();
91+
await plugin._parseAndRegisterArtifact(ctx, artifact(true), 'prerelease-probe');
92+
93+
const warns = conversionWarns(ctx);
94+
expect([...warns.keys()]).toEqual([SUBJECT]);
95+
expect(warns.get(SUBJECT)).toHaveLength(1);
96+
const [line] = warns.get(SUBJECT)!;
97+
expect(line).toContain('1 site(s)');
98+
expect(line).toContain('objects[0].indexes[0].unique');
99+
100+
// WHICH half opened it is decided by the installed label, and the
101+
// version pass moves that label, so the clause is pinned per half. At or
102+
// below 18.0.0 (this tree's 17.x label, every 18.0.0 prerelease, GA) the
103+
// floor is not below the label: the line names the floor as the first
104+
// release of protocol 18 and never claims the artifact "predates" a
105+
// runtime printed below it. From 18.0.1 on the label half opened it, and
106+
// "predates" is exactly the claim the line owes.
107+
const installed = resolveInstalledSpecVersion();
108+
expect(installed).toMatch(/^\d+\.\d+\.\d+/); // spec is always resolvable here
109+
if (compareVersions(installed!, '18.0.0') > 0) {
110+
expect(line).toContain(`predates this runtime's spec (authored engines.protocol floor 18.0.0, runtime spec ${installed})`);
111+
} else {
112+
expect(line).not.toContain("predates this runtime's spec");
113+
expect(line).toContain('declares the first release of protocol 18 (authored engines.protocol floor 18.0.0)');
114+
expect(line).toContain(`runtime spec ${installed}`);
115+
}
116+
});
117+
118+
it('the same artifact built after the retirement boots with nothing converted and nothing logged', async () => {
119+
const plugin = newPlugin();
120+
const ctx = fakeCtx();
121+
const total = await plugin._parseAndRegisterArtifact(ctx, artifact('global'), 'prerelease-probe-current');
122+
expect(total).toBeGreaterThan(0);
123+
expect([...conversionWarns(ctx).keys()]).toEqual([]);
124+
125+
const object = await plugin.manager.get('object', 'pre_contact');
126+
expect((object as any).indexes).toEqual([{ name: 'pre_contact_email', fields: ['email'], unique: 'global' }]);
127+
});
128+
});

0 commit comments

Comments
 (0)