diff --git a/.changeset/23001-prerelease-artifact-window.md b/.changeset/23001-prerelease-artifact-window.md new file mode 100644 index 00000000000..2a8422852e2 --- /dev/null +++ b/.changeset/23001-prerelease-artifact-window.md @@ -0,0 +1,17 @@ +--- +'@objectstack/metadata-core': minor +'@objectstack/metadata': patch +--- + +fix(metadata-core): an artifact declaring `engines.protocol: '^18'` that was built during the v18 prerelease boots on a later runtime that retired one of its shapes — the artifact door replays every retired conversion of protocol 18's step for a floor of `18.0.0` + +Clause-②: yes + +**What an operator saw.** A compiled artifact (`os build` → `dist/objectstack.json`) that declares `engines.protocol: '^18'` and was built before a protocol-18 retirement landed carries the retired shape, for example a defaulted key that every earlier parse emitted. Booted on a runtime that enforces the retirement, the metadata plugin's startup refused it at the strict parse and the boot rolled back. The artifact door compared the floor `18.0.0` with each retirement's `retiredAfter` stamp, and every retirement that lands during the v18 prerelease is stamped with the last 17.x release, below that floor, so nothing was replayed. + +**What an operator sees now.** The same artifact boots. One warning per conversion names it and its site count: `[MetadataPlugin] artifact 'LABEL' declares the first release of protocol 18 (authored engines.protocol floor 18.0.0), which a build from any 18.0.0 prerelease declares too, so it may predate retirements in the conversion step into protocol 18 that this runtime enforces (runtime spec VERSION) — converted N site(s) forward via ADR-0087 conversion 'ID' …`. The artifact file itself is unchanged, so it converts again on every boot until it is rebuilt with `os build`. An artifact built after the retirement boots exactly as before: the replay finds nothing to convert, and nothing is logged. + +- **`@objectstack/metadata-core`.** `applyArtifactForwardConversions` opens its window on a third condition. A declared floor of `M.0.0` at or above the runtime label replays every retired entry whose `toMajor` is `M`, except the default flips the door already refuses. An artifact cannot say which of those entries it predates: it carries no field naming the spec or CLI that built it. Each entry rewrites only its own old shape, so the replay changes nothing in an artifact that lacks that shape. `ArtifactForwardConversionVerdict` gains `'converted-major-step'`, so a consumer that switches exhaustively over the verdict adds that arm. `replayedRetirements` still lists only the entries the per-entry rule opened. The rule holds for every 18.0.0 prerelease label and at 18.0.0 itself. From 18.0.1 on, the label half replays the whole chain for such an artifact, as before. +- **`@objectstack/metadata`.** The artifact door places the new verdict on the open side of its window table and gives it its own clause, so the summary never claims that a `^18` artifact "predates" a runtime labelled below it. The bare-root form-predicate notice uses the same window. + +**Unchanged.** The protocol handshake still refuses a `^17` artifact on a protocol-18 runtime and prescribes `objectstack migrate meta --from 17`. A floor above `M.0.0` (such as `^18.0.1`) and a 17.x floor keep their previous verdicts. diff --git a/packages/metadata-core/src/artifact-forward-conversion.test.ts b/packages/metadata-core/src/artifact-forward-conversion.test.ts index 5c27f58f8fd..f327b2c76b6 100644 --- a/packages/metadata-core/src/artifact-forward-conversion.test.ts +++ b/packages/metadata-core/src/artifact-forward-conversion.test.ts @@ -24,6 +24,7 @@ import { resolveInstalledSpecVersion, type ArtifactConversionNotice, } from './artifact-forward-conversion.js'; +import { checkProtocolCompat } from './protocol-handshake.js'; // ── Mirror pin ─────────────────────────────────────────────────────────────── // `ArtifactConversionNotice` is a structural mirror of the spec root's @@ -191,7 +192,14 @@ describe('applyArtifactForwardConversions — the versioned window (#12772)', () }); it('REFUSES the amnesty for an artifact authored at a NEWER spec than the runtime', () => { - const def = legacyPermissionDefinition('^18.0.0'); + // "Newer" is a floor past the label AND every `retiredAfter`, and not the + // first release of a major. This pin used to spell it `^18.0.0`, which was + // hypothetical when it was written; on this tree 18 is the runtime's own + // protocol, and a `^18` artifact on a runtime labelled below 18.0.0 is the + // v18-prerelease case the major-step half opens for (#23001, block below). + const newer = currentSurfaceFloor('17.2.0'); + expect(newer.endsWith('.0.0'), `${newer} must not be the first release of a major`).toBe(false); + const def = legacyPermissionDefinition(`^${newer}`); const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.2.0' }); expect(result.verdict).toBe('authored-current'); expect(result.definition).toBe(def); @@ -665,6 +673,190 @@ describe('[#20390] the per-entry window — an artifact built by the last releas }); }); +/** + * [#23001] The major-step half — a `^18` artifact built during the v18 + * prerelease boots on a later runtime. + * + * During the prerelease the protocol constant reads 18 while the package label + * still names the last 17.x release, and every retirement landing then is + * stamped at or below that label (spec's census). A `^18` floor is 18.0.0: + * above every one of those stamps, so the per-entry half never opened for it, + * and an artifact built before a prerelease retirement met the tombstone and + * rolled the boot back. + * + * Measured before choosing the rule: + * - Reading (i), a build stamp: the artifact carries none. `os build` writes + * the parsed stack, its docs and an optional `runtimeModule`, and nothing + * naming the spec or CLI that built it, so telling "built before this entry" + * apart would need a new artifact field. + * - Reading (ii), idempotence: every retired step-18 entry rewrites only its + * own old shape. That is what the door now relies on, and the whole-step + * block below pins both of its directions over every entry's own fixture pair. + * + * The subject is `declared-index-unique-scope`, a step-18 retirement stamped at + * the prerelease label, whose old shape — bare `unique: true` on a declared + * index — the strict parse at the door refuses. (#22990's email-template keys, + * the case #22994 measured at the boot, join step 18 with their own fixture + * pair, and the whole-step pins cover them the day they land.) + */ +describe('[#23001] the major-step half — a ^18 artifact built during the v18 prerelease boots on a later runtime', () => { + const SUBJECT = 'declared-index-unique-scope'; + + /** The subject's old shape under a `^18` floor: what a prerelease build from before the retirement carries. */ + const builtDuringPrerelease = (protocolRange: string, unique: unknown = true) => ({ + manifest: { + id: 'com.example.prerelease-probe', namespace: 'pre', name: 'prerelease_probe', version: '1.0.0', type: 'app', + engines: { protocol: protocolRange }, + }, + objects: [{ + name: 'pre_contact', label: 'Contact', + fields: { email: { type: 'text', label: 'Email' } }, + indexes: [{ name: 'pre_contact_email', fields: ['email'], unique }], + }], + }); + + const issuePaths = (value: unknown): string[] => { + const parsed = ObjectStackDefinitionSchema.safeParse(value); + return parsed.success ? [] : parsed.error.issues.map((i) => i.path.join('.')).sort(); + }; + + /** The prerelease labels (before and after the first version pass) and GA, each at or below the floor. */ + const AT_OR_BELOW_THE_FLOOR = ['17.7.0', '18.0.0-next.0', '18.0.0'] as const; + + it('premise: the subject belongs to step 18, the per-entry half alone cannot open it for ^18, and this tree refuses its old shape', () => { + const subject = ALL_CONVERSIONS.find((c) => c.id === SUBJECT); + expect(subject?.toMajor).toBe(18); + expect(subject?.retiredFromLoadPath).toBe(true); + expect(compareVersions('18.0.0', subject!.retiredAfter!), 'floor 18.0.0 is above the stamp').toBe(1); + expect(issuePaths(builtDuringPrerelease('^18'))).toEqual(['objects.0.indexes.0.unique']); + expect(issuePaths(builtDuringPrerelease('^18', 'global'))).toEqual([]); + }); + + // Done, first half: built before the retirement, it boots, the entry named. + for (const label of AT_OR_BELOW_THE_FLOOR) { + it(`runtime labelled ${label}: the subject replays, its notice names it, and the strict parse passes`, () => { + const def = builtDuringPrerelease('^18'); + const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: label }); + + expect(result.verdict).toBe('converted-major-step'); + expect(result.authoredFloor).toBe('18.0.0'); + expect(result.notices.map((n) => [n.conversionId, n.path, n.from, n.to])).toEqual([ + [SUBJECT, 'objects[0].indexes[0].unique', 'true', 'global'], + ]); + expect((result.definition as ReturnType).objects[0]!.indexes[0]!.unique).toBe('global'); + expect(issuePaths(result.definition)).toEqual([]); + // The per-entry half's own list: only entries stamped at or above the + // floor, never the step it opened on the floor's reason. + expect(new Map(result.replayedRetirements.map((r) => [r.conversionId, r.retiredAfter]))).toEqual(openedByRule('18.0.0')); + }); + } + + it('from 18.0.1 on, the same artifact converts through the label half — the rule reduces to the old one', () => { + const result = applyArtifactForwardConversions(builtDuringPrerelease('^18'), { runtimeSpecVersion: '18.0.1' }); + expect(result.verdict).toBe('converted-forward'); + expect(result.replayedRetirements).toEqual([]); + expect(result.notices.map((n) => n.conversionId)).toEqual([SUBJECT]); + expect(issuePaths(result.definition)).toEqual([]); + }); + + // Done, second half: built after the retirement, it is unchanged. + it('a ^18 artifact built after the retirement is unchanged: the window is open, nothing fires, the same reference comes back', () => { + for (const label of AT_OR_BELOW_THE_FLOOR) { + const def = builtDuringPrerelease('^18', 'global'); + const bytes = JSON.stringify(def); + const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: label }); + // ⭐ ANTI-VACUITY: the major-step half really is open on this input. + expect(result.verdict, label).toBe('converted-major-step'); + expect(result.notices, label).toEqual([]); + expect(result.definition, label).toBe(def); + expect(JSON.stringify(result.definition), label).toBe(bytes); + } + }); + + it('the half opens only for a first release: a ^18.0.1 floor on a prerelease runtime still meets the tombstone', () => { + const def = builtDuringPrerelease('^18.0.1'); + const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.7.0' }); + expect(result.verdict).toBe(openedByRule('18.0.1').size > 0 ? 'converted-retired-after' : 'authored-current'); + expect(result.notices.map((n) => n.conversionId)).not.toContain(SUBJECT); + expect(issuePaths(result.definition)).toEqual(['objects.0.indexes.0.unique']); + }); + + // CONTROL: a 17.x floor keeps #20390's per-entry verdict on this tree's own label. + it('CONTROL — a ^17.7.0 floor on a 17.7.0 label keeps the per-entry verdict, and an older step-18 retirement still meets its tombstone', () => { + const def = legacyPermissionDefinition('^17.7.0'); + const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.7.0' }); + const expected = openedByRule('17.7.0'); + expect(result.verdict).toBe(expected.size > 0 ? 'converted-retired-after' : 'authored-current'); + expect(new Map(result.replayedRetirements.map((r) => [r.conversionId, r.retiredAfter]))).toEqual(expected); + // `permission-allow-restore-purge-removed` is in step 18 too, retired long + // before 17.7.0: a 17.7.0 floor postdates it, so it is NOT replayed. + expect(result.notices).toEqual([]); + expect(result.definition).toBe(def); + expect(def.permissions[0]!.objects.crm_ticket).toHaveProperty('allowRestore'); + }); + + // CONTROL: the protocol-18 handshake, untouched — it refuses ^17 before any conversion could matter. + it('CONTROL — the protocol-18 handshake still refuses a ^17 artifact, prescribing the migration, and admits ^18', () => { + const refused = checkProtocolCompat({ id: 'com.example.prerelease-probe', engines: { protocol: '^17' } }, '18.0.0'); + expect(refused.status).toBe('incompatible'); + if (refused.status !== 'incompatible') return; + expect(refused.diagnostic.code).toBe('OS_PROTOCOL_INCOMPATIBLE'); + expect(refused.diagnostic.migrateCommand).toBe('objectstack migrate meta --from 17'); + expect(checkProtocolCompat({ id: 'com.example.prerelease-probe', engines: { protocol: '^18' } }, '18.0.0').status).toBe('ok'); + }); + + /** + * Reading (ii), both directions, over EVERY retired step-18 entry rather than + * a sample — each entry's own fixture pair, carried in a `^18` manifest (no + * conversion reads a manifest's `engines`), on a runtime labelled 17.7.0. + */ + describe('reading (ii) over the whole step — every retired step-18 entry, both directions', () => { + const STEP_18 = ALL_CONVERSIONS.filter((c) => c.toMajor === 18 && c.retiredFromLoadPath === true); + + const inCaretEighteen = (stack: Record): Record => { + const manifest = (stack.manifest ?? {}) as Record; + const engines = (manifest.engines ?? {}) as Record; + return { ...stack, manifest: { ...manifest, engines: { ...engines, protocol: '^18' } } }; + }; + + it('the step carries the subject, and the door refuses its default flips by id', () => { + expect(STEP_18.map((c) => c.id)).toContain(SUBJECT); + expect(STEP_18.filter((c) => DOOR_DEFAULT_FLIPS.includes(c.id)).map((c) => c.id)).toEqual([ + 'flow-decision-mode-inclusive-explicit', + ]); + }); + + // Built BEFORE: each entry's old shape replays to exactly its canonical shape. + for (const entry of STEP_18) { + const flip = DOOR_DEFAULT_FLIPS.includes(entry.id); + it(`${entry.id}: ${flip ? 'a default flip — never replayed, even with the step open' : 'built before it, a ^18 artifact converts to its canonical fixture'}`, () => { + const before = inCaretEighteen(structuredClone(entry.fixture.before)); + const result = applyArtifactForwardConversions(before, { 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(before); + return; + } + expect(result.definition).toEqual(inCaretEighteen(structuredClone(entry.fixture.after))); + expect(result.notices).toHaveLength(entry.fixture.expectedNotices); + for (const n of result.notices) expect(n.conversionId).toBe(entry.id); + }); + } + + // Built AFTER: over every entry's canonical fixture, the whole replayed step changes nothing. + for (const entry of STEP_18) { + it(`${entry.id}: built after it, a ^18 artifact comes back by reference with the whole step replayed over it`, () => { + const after = inCaretEighteen(structuredClone(entry.fixture.after)); + const result = applyArtifactForwardConversions(after, { 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(after); + }); + } + }); +}); + /** * #15429 — the second member of the DEFAULT-FLIP class this door refuses. * @@ -756,6 +948,24 @@ describe('the artifact door never writes `mode: inclusive` onto an authored excl expect(result.notices.map((n) => n.conversionId)).not.toContain(ID); }); + /** + * [#23001] Nor does the major-step half. The entry belongs to step 18 + * (`toMajor: 18`), so a `^18` floor on a runtime labelled below 18.0.0 opens + * every OTHER retired entry of that step — and the refusal list is read + * before `toMajor` exactly as it is before `retiredAfter`. + */ + it('stays refused inside the major-step half too — the refusal list is read before toMajor', () => { + expect(ALL_CONVERSIONS.find((c) => c.id === ID)?.toMajor, 'the entry is in step 18').toBe(18); + const def = twoBranchDecisionDefinition('^18'); + const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.7.0' }); + + // ⭐ ANTI-VACUITY: the major-step half really is open on this input. + expect(result.verdict).toBe('converted-major-step'); + expect(verdictNodeOf(result.definition).config).toBeUndefined(); + expect(result.notices.map((n) => n.conversionId)).not.toContain(ID); + expect(result.definition).toBe(def); + }); + it('floor ^99.0.0 — the window is shut and nothing is replayed at all', () => { const def = twoBranchDecisionDefinition('^99.0.0'); const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.4.0' }); diff --git a/packages/metadata-core/src/artifact-forward-conversion.ts b/packages/metadata-core/src/artifact-forward-conversion.ts index 9c3a61cae19..3082b968000 100644 --- a/packages/metadata-core/src/artifact-forward-conversion.ts +++ b/packages/metadata-core/src/artifact-forward-conversion.ts @@ -35,10 +35,14 @@ * The window is decided PER ENTRY: a registry entry E is replayed when * * floor < runtime OR floor ≤ E.retiredAfter + * OR (floor = M.0.0 AND E.toMajor = M) * * where `retiredAfter` is the version the registry stamps on every retired * entry — the last published spec whose authoring surface still accepted the - * old shape (`MetadataConversion`, `@objectstack/spec`). Spelled out: + * old shape (`MetadataConversion`, `@objectstack/spec`) — and `toMajor` is the + * protocol major whose conversion step the entry belongs to + * (`CONVERSIONS_BY_MAJOR[M]`, the step `objectstack migrate meta --from M-1` + * replays). Spelled out: * * - **`floor < runtime`** → the artifact predates this runtime's authoring * surface. Replay the full conversion chain (retired entries included) @@ -59,6 +63,32 @@ * later returns to the spec (the roadmap-M2 shape: `allowRestore`/`allowPurge` * come back with the lifecycle operations they gate), artifacts authored * against that surface are never stripped by history. + * - **`floor` = M.0.0, at or above the runtime label** → every retired entry + * of the conversion step INTO protocol M replays as well (verdict + * `'converted-major-step'`). M.0.0 is what a bare `^M` range reads as, and + * it is the floor every build made during M's prerelease declares: the + * protocol constant moves to M when the prerelease opens (ruling record + * 6049734955, Q1 → B), while the package label still names the last release + * of M-1 and every retirement landing in the prerelease is stamped at or below + * that label. So a prerelease-built `^M` artifact may predate any retirement in + * step M, and the per-entry comparison above can never say so: M.0.0 sits + * above every stamp of the M-1 line. Nor can the artifact: `os build` + * writes the parsed stack plus its docs and an optional `runtimeModule`, + * with no field naming the spec or CLI that built it, so no reading of the + * artifact tells "built before this entry" from "built after it". What makes + * the replay safe instead is that a conversion only rewrites the shape it + * positively recognizes: every retired entry of the step comes back by + * reference over a definition that lacks its old shape (pinned over each + * step-18 entry's own fixture pair in this module's test), so an artifact + * built after a retirement is unchanged and one built before it boots. The + * same holds at M.0.0 GA (label M.0.0, floor equal), where a prerelease-built + * artifact still declares that floor; from M.0.1 on the label half above + * replays the whole chain for it anyway. ⚠️ The M2 caveat: an entry whose old + * shape becomes legal again cannot be protected for a bare-major floor by + * this half — nor by the label half, which strips it for the same artifact on + * the next release — so the change that re-legalizes a retired key decides + * that entry's fate at this door, where the default-flip refusal below + * ({@link DEFAULT_FLIPS_NOT_REPLAYED_HERE}) is the existing mechanism. * - **No declared range** → replay the full chain. Same posture as the * protocol handshake (which grandfathers range-less packages with a warning, * ADR-0087 "never false-reject") and as the stored-row pass (whose rows @@ -109,8 +139,9 @@ * - Not a second conversion table: the ADR-0087 registry in * `@objectstack/spec` stays the single authority on *what* converts — and, * through each retired entry's `retiredAfter`, on *since when* it stopped - * being authorable; this module only decides *whether the retired window - * opens* for one artifact, reading those facts off the registry per entry. + * being authorable, and through its `toMajor`, on *which step* it belongs + * to; this module only decides *whether the retired window opens* for one + * artifact, reading those facts off the registry per entry. * - Not a validator: like `applyConversions` itself, this never throws and * never gates. Gating stays at the caller's schema parse. * - Not the flow-specific seam: flows convert here too (context-less, exactly @@ -178,11 +209,19 @@ export type ArtifactForwardConversionVerdict = * those entries replayed (see the module doc's per-entry rule). */ | 'converted-retired-after' + /** + * Declared floor is `M.0.0` — the first release of protocol major M, the + * floor every build from M's prerelease declares — at or above the runtime + * spec's label: every retired entry of the conversion step into M replayed, + * beside any the per-entry rule opens (see the module doc). + */ + | 'converted-major-step' /** No declared range — treated as old data at rest, full chain replayed. */ | 'converted-undeclared' /** - * Declared floor is current-or-newer, and newer than every retirement's - * `retiredAfter` — nothing replayed, the strict parse decides. + * Declared floor is current-or-newer, newer than every retirement's + * `retiredAfter`, and not the first release of a major whose conversion step + * carries retired entries — nothing replayed, the strict parse decides. */ | 'authored-current' /** Runtime spec version unresolvable — nothing replayed (see module doc). */ @@ -219,11 +258,14 @@ export interface ArtifactForwardConversionResult { /** Every notice the replay emitted (empty when nothing converted). */ notices: ArtifactConversionNotice[]; /** - * Under `'converted-retired-after'` only: the retirements this runtime - * enforces past the artifact's floor, which the per-entry half of the window - * replayed — each with the `retiredAfter` the floor is at or below. Empty for - * every other verdict: the label half replays the whole chain on one reason - * for all of it, and a closed window replays nothing. + * Under `'converted-retired-after'` and `'converted-major-step'` only: the + * retirements this runtime enforces past the artifact's floor, which the + * per-entry half of the window replayed — each with the `retiredAfter` the + * floor is at or below. The entries the major-step half opens are NOT listed + * here (their `retiredAfter` is below the floor; one reason, the floor, opens + * all of them). Empty for every other verdict: the label half replays the + * whole chain on one reason for all of it, and a closed window replays + * nothing. */ replayedRetirements: ArtifactReplayedRetirement[]; } @@ -358,31 +400,46 @@ const DEFAULT_FLIPS_NOT_REPLAYED_HERE: readonly string[] = [ ]; /** - * The per-entry half of the window, for a floor at or above the runtime label. + * The window for a floor at or above the runtime label, decided per entry. * `closed` is the ids the door must NOT replay — {@link DEFAULT_FLIPS_NOT_REPLAYED_HERE} * (read first, whatever an entry's version says), every live entry, and every - * retired entry whose `retiredAfter` the floor exceeds; `opened` is the rest, - * each a retirement this runtime enforces past the floor. `null` when nothing - * opens, i.e. the floor predates no retirement the runtime enforces. + * retired entry the floor postdates. Two halves open the rest: + * + * - `opened` — the per-entry half: each retirement whose `retiredAfter` the + * floor is at or below, i.e. one this runtime enforces past the floor. + * - `majorStep` — for a floor of `M.0.0` only: every other retired entry whose + * `toMajor` is M, the step a prerelease build of M may have run only in part + * (see the module doc). Read AFTER the per-entry half, so an entry both open + * is reported once, with its `retiredAfter`. * - * A `retiredAfter` this cannot read closes its entry: the strict parse and its + * `null` when nothing opens, i.e. the floor postdates every retirement the + * runtime enforces. A `retiredAfter` this cannot read leaves its entry to the + * major-step half alone, and closed outside it: the strict parse and its * tombstone stay the authority, which is the loud direction. */ function idsTheFloorPostdates( floor: [number, number, number], -): { closed: string[]; opened: ArtifactReplayedRetirement[] } | null { +): { closed: string[]; opened: ArtifactReplayedRetirement[]; majorStep: string[] } | null { const closed = [...DEFAULT_FLIPS_NOT_REPLAYED_HERE]; const opened: ArtifactReplayedRetirement[] = []; + const majorStep: string[] = []; + const firstReleaseOfMajor = floor[1] === 0 && floor[2] === 0; for (const conversion of ALL_CONVERSIONS) { if (DEFAULT_FLIPS_NOT_REPLAYED_HERE.includes(conversion.id)) continue; - const retiredAfter = conversion.retiredFromLoadPath === true ? parseVersion(conversion.retiredAfter) : null; + if (conversion.retiredFromLoadPath !== true) { + closed.push(conversion.id); + continue; + } + const retiredAfter = parseVersion(conversion.retiredAfter); if (retiredAfter && compareTriples(floor, retiredAfter) <= 0) { opened.push({ conversionId: conversion.id, retiredAfter: retiredAfter.join('.') }); + } else if (firstReleaseOfMajor && conversion.toMajor === floor[0]) { + majorStep.push(conversion.id); } else { closed.push(conversion.id); } } - return opened.length > 0 ? { closed, opened } : null; + return opened.length > 0 || majorStep.length > 0 ? { closed, opened, majorStep } : null; } /** @@ -431,13 +488,14 @@ export function applyArtifactForwardConversions( if (perEntry === null) { return { definition, verdict: 'authored-current', authoredFloor, runtimeSpecVersion, notices: [], replayedRetirements: [] }; } - verdict = 'converted-retired-after'; + verdict = perEntry.majorStep.length > 0 ? 'converted-major-step' : 'converted-retired-after'; replayedRetirements = perEntry.opened; - // The per-entry half of the window: every entry the floor does NOT predate - // stays with the strict parse. Each id's reason is the same, read off the - // registry rather than written here — the floor is at or above the runtime - // label AND above the entry's own `retiredAfter` (or the entry is live, and - // a live entry has no retirement for the floor to predate). + // Every entry the floor postdates stays with the strict parse. Each id's + // reason is the same, read off the registry rather than written here — the + // floor is at or above the runtime label AND above the entry's own + // `retiredAfter`, and it is not `M.0.0` for the entry's own step M (or the + // entry is live, and a live entry has no retirement for the floor to + // predate). excludeConversionIds = perEntry.closed; } diff --git a/packages/metadata/src/plugin-artifact-forward-conversion-major-step.test.ts b/packages/metadata/src/plugin-artifact-forward-conversion-major-step.test.ts new file mode 100644 index 00000000000..c1b6b9c48fa --- /dev/null +++ b/packages/metadata/src/plugin-artifact-forward-conversion-major-step.test.ts @@ -0,0 +1,128 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23001] A `^18` artifact built during the v18 prerelease boots on a later + * runtime, and the boot log names the conversion that let it. + * + * The fixture carries the shape a prerelease build from before a step-18 + * retirement carries: a declared index with bare `unique: true`, under + * `engines.protocol: '^18'`. `declared-index-unique-scope` retires that shape + * (stamped at the prerelease label, like every retirement landing in the + * prerelease), and the strict parse in `_parseAndRegisterArtifact` refuses it. + * A `^18` floor is 18.0.0, above every stamp of the 17.x line, so the door's + * per-entry half never opened for it and the boot rolled back. The major-step + * half replays every retired entry of step 18 for that floor. + * + * The window's version boundaries — the prerelease labels, GA, 18.0.1, a + * non-first-release floor — and both directions over every step-18 entry are + * pinned in `@objectstack/metadata-core`'s `artifact-forward-conversion.test.ts` + * with injected labels. This file pins the real door on this tree's installed + * label: the artifact boots, the conversion is named once, and the same + * artifact built after the retirement boots with nothing converted. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { resolveInstalledSpecVersion } from '@objectstack/metadata-core'; +import { MetadataPlugin } from './plugin.js'; + +const SUBJECT = 'declared-index-unique-scope'; + +/** A `^18` artifact; `unique: true` is the shape the subject retires, `'global'` its canonical spelling. */ +function artifact(unique: unknown): any { + return { + manifest: { + id: 'com.example.prerelease-probe', namespace: 'pre', name: 'prerelease_probe', version: '1.0.0', type: 'app', + engines: { protocol: '^18' }, + }, + objects: [{ + name: 'pre_contact', label: 'Contact', + fields: { email: { type: 'text', label: 'Email' } }, + indexes: [{ name: 'pre_contact_email', fields: ['email'], unique }], + }], + }; +} + +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' } }); +} + +/** Order two versions by their leading `x.y.z` (a prerelease tail is ignored). */ +function compareVersions(a: string, b: string): number { + const triple = (v: string) => v.split(/[.-]/).slice(0, 3).map((n) => Number.parseInt(n, 10)); + 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; +} + +/** 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; +} + +describe('[#23001] artifact door — a ^18 artifact built during the v18 prerelease boots on a later runtime', () => { + it('boots: the object registers with the retired spelling converted to its canonical scope', async () => { + const plugin = newPlugin(); + const total = await plugin._parseAndRegisterArtifact(fakeCtx(), artifact(true), 'prerelease-probe'); + expect(total).toBeGreaterThan(0); + + const object = await plugin.manager.get('object', 'pre_contact'); + expect(object, 'the object registers').toBeDefined(); + expect((object as any).indexes).toEqual([{ name: 'pre_contact_email', fields: ['email'], unique: 'global' }]); + }); + + it('logs one summary naming the conversion and its site count, with the clause of the half that opened it', async () => { + const plugin = newPlugin(); + const ctx = fakeCtx(); + await plugin._parseAndRegisterArtifact(ctx, artifact(true), 'prerelease-probe'); + + const warns = conversionWarns(ctx); + expect([...warns.keys()]).toEqual([SUBJECT]); + expect(warns.get(SUBJECT)).toHaveLength(1); + const [line] = warns.get(SUBJECT)!; + expect(line).toContain('1 site(s)'); + expect(line).toContain('objects[0].indexes[0].unique'); + + // WHICH half opened it is decided by the installed label, and the + // version pass moves that label, so the clause is pinned per half. At or + // below 18.0.0 (this tree's 17.x label, every 18.0.0 prerelease, GA) the + // floor is not below the label: the line names the floor as the first + // release of protocol 18 and never claims the artifact "predates" a + // runtime printed below it. From 18.0.1 on the label half opened it, and + // "predates" is exactly the claim the line owes. + const installed = resolveInstalledSpecVersion(); + expect(installed).toMatch(/^\d+\.\d+\.\d+/); // spec is always resolvable here + if (compareVersions(installed!, '18.0.0') > 0) { + expect(line).toContain(`predates this runtime's spec (authored engines.protocol floor 18.0.0, runtime spec ${installed})`); + } else { + expect(line).not.toContain("predates this runtime's spec"); + expect(line).toContain('declares the first release of protocol 18 (authored engines.protocol floor 18.0.0)'); + expect(line).toContain(`runtime spec ${installed}`); + } + }); + + it('the same artifact built after the retirement boots with nothing converted and nothing logged', async () => { + const plugin = newPlugin(); + const ctx = fakeCtx(); + const total = await plugin._parseAndRegisterArtifact(ctx, artifact('global'), 'prerelease-probe-current'); + expect(total).toBeGreaterThan(0); + expect([...conversionWarns(ctx).keys()]).toEqual([]); + + const object = await plugin.manager.get('object', 'pre_contact'); + expect((object as any).indexes).toEqual([{ name: 'pre_contact_email', fields: ['email'], unique: 'global' }]); + }); +}); diff --git a/packages/metadata/src/plugin.ts b/packages/metadata/src/plugin.ts index 254c3dc0ed7..8904481552a 100644 --- a/packages/metadata/src/plugin.ts +++ b/packages/metadata/src/plugin.ts @@ -46,6 +46,7 @@ const FORWARD_WINDOW_OPENED: Readonly): string { const floor = result.authoredFloor ?? ''; + if (result.verdict === 'converted-major-step') { + const major = floor.split('.')[0]; + return `declares the first release of protocol ${major} (authored engines.protocol floor ${floor}), ` + + `which a build from any ${major}.0.0 prerelease declares too, so it may predate retirements in the ` + + `conversion step into protocol ${major} that this runtime enforces (runtime spec ${result.runtimeSpecVersion})`; + } if (result.verdict === 'converted-retired-after') { return `was built on a surface that still accepted shapes this runtime has since retired ` + `(authored engines.protocol floor ${floor}, at or below the last release that accepted them; `