diff --git a/.changeset/20390-conversion-retired-after-window.md b/.changeset/20390-conversion-retired-after-window.md new file mode 100644 index 00000000000..c96c4700994 --- /dev/null +++ b/.changeset/20390-conversion-retired-after-window.md @@ -0,0 +1,23 @@ +--- +'@objectstack/spec': minor +'@objectstack/metadata-core': minor +'@objectstack/metadata': patch +--- + +feat(spec,metadata-core)!: every retired ADR-0087 conversion carries `retiredAfter`, and the artifact door opens its window per entry (#20390) + +Clause-②: yes + + + +**BREAKING** for code that implements `MetadataConversion` itself — shipped as `minor` under the launch-window convention (`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by this banner and the ADR-0087 disposition above). `MetadataConversion` is now a type alias of a live-or-retired union: an entry with `retiredFromLoadPath: true` must also carry `retiredAfter`, a stable `x.y.z` string, and a live entry carries neither. tsc names the missing member (`Property 'retiredAfter' is missing`). No in-repo conversion is left unstamped, and no metadata an author writes changes. + +**What the field means.** `retiredAfter` is the last published `@objectstack/spec` version whose authoring surface still accepted the entry's old shape. It is a fact when the entry lands: the package's own version label at that moment, because `main` carries the last release's label until the next release is cut. Every published retired entry is stamped from the published tarballs — the stable release just before the first tarball that carries it retired — and each entry not yet in any published tarball carries the current label, `17.4.0`. + +**Why the artifact door needed it.** Between two releases, `main` refuses keys that the next release retires while its label still reads the last release. The artifact-ingestion door (`applyArtifactForwardConversions`) compared an artifact's `engines.protocol` floor with that label alone, so an artifact built by the last published CLI — floor `^17.4.0`, dashboard `chartConfig.type`/`xAxis`/`yAxis` and page `assignedProfiles` — read as "authored current": nothing was converted and the strict parse refused the boot. The door now replays a registry entry when the floor is below the runtime label, **or** at or below that entry's `retiredAfter`. After a release the rule reduces to the old one, and an artifact whose floor is above an entry's `retiredAfter` still meets that entry's tombstone — a floor of `^17.5.0` on a 17.5.0 runtime is refused, not converted. `DEFAULT_FLIPS_NOT_REPLAYED_HERE` is still read first. + +**`@objectstack/metadata-core`.** `ArtifactForwardConversionVerdict` gains `'converted-retired-after'`: the floor is at or above the runtime label, but at or below the `retiredAfter` of at least one retired entry, and only those entries are replayed. `ArtifactForwardConversionResult` gains `replayedRetirements` (exported element type `ArtifactReplayedRetirement`): under that verdict, each retirement this runtime enforces past the artifact's floor, with its `retiredAfter`; empty for every other verdict. A consumer that switches exhaustively over the verdict adds that arm. + +**`@objectstack/metadata`, the artifact door — the arm added.** `MetadataPlugin` now reads which verdicts open the window from one total table over `ArtifactForwardConversionVerdict`, with `'converted-retired-after'` on the open side. The #12915 unbound form-predicate notice rides that same reading, so a 17.4.0-built artifact carrying a bare-root form predicate on `main` is announced now, rather than only once the package label moves past 17.4.0. A verdict added later fails to compile until it is placed on one side of the window. Under the new verdict the conversion summary no longer says the artifact "predates this runtime's spec" beside a runtime version equal to its floor: it names the retirement this runtime enforces past the artifact's floor, with the release that last accepted the shape, and says the artifact converts again on every boot until it is rebuilt with tooling from a release that ships the retirement. Summaries are still one per conversion per artifact, naming the site count. + +**Census.** 94 retired entries when this landed: 73 published (first retired in 15.1.0: 5, 17.0.0: 45, 17.1.0: 5, 17.2.0: 2, 17.3.0: 8, 17.4.0: 8) and 21 unpublished. `packages/spec/src/conversions/retired-after.census.json` holds the raw per-release facts, and `retired-after.census.test.ts` pins every value against it, offline. `packages/spec/scripts/build-retired-after-census.ts` re-derives the census from the npm registry (tarball integrity checked). Run it after each stable publish; `docs/releases-maintenance.md` lists that step in the GA release flow. diff --git a/docs/releases-maintenance.md b/docs/releases-maintenance.md index 563195acb2b..a2b11361f00 100644 --- a/docs/releases-maintenance.md +++ b/docs/releases-maintenance.md @@ -447,6 +447,8 @@ Wait for the refreshed PR's CI, then merge it. That merge is still the decision release, and the `release` environment approval is still the authorisation — neither is changed by where the refresh came from. +**After a stable `@objectstack/spec` publish, refresh the retired-after census** (#20390): run `pnpm --filter @objectstack/spec exec tsx scripts/build-retired-after-census.ts` (prefix `NODE_USE_ENV_PROXY=1` behind a proxy) and commit the rewritten `packages/spec/src/conversions/retired-after.census.json` in an ordinary PR — until it lands, the census test holds an unpublished entry's `retiredAfter` only to the range from the last censused release to the label, not to the label exactly. + ## Drift guard `scripts/check-release-notes.mjs` (run in CI as `pnpm check:release-notes`) fails the diff --git a/packages/metadata-core/src/artifact-forward-conversion.test.ts b/packages/metadata-core/src/artifact-forward-conversion.test.ts index 0a748a28e54..d700c536fb2 100644 --- a/packages/metadata-core/src/artifact-forward-conversion.test.ts +++ b/packages/metadata-core/src/artifact-forward-conversion.test.ts @@ -98,8 +98,12 @@ describe('applyArtifactForwardConversions — the versioned window (#12772)', () }); it('REFUSES the amnesty for an artifact authored at the current spec version — no blanket strip', () => { - const def = legacyPermissionDefinition('^17.2.0'); - const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.2.0' }); + // "Current" for THIS registry: every retirement it carries is stamped + // `retiredAfter` 17.4.0 or earlier, so a 17.5.0 floor on a 17.5.0 runtime + // predates none of them. (A floor at the label that DOES predate one opens + // the per-entry window instead — the #20390 block below.) + const def = legacyPermissionDefinition('^17.5.0'); + const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.5.0' }); expect(result.verdict).toBe('authored-current'); expect(result.notices).toEqual([]); @@ -401,6 +405,155 @@ describe('the artifact door never turns an authored `hidden: true` into an unpub }); }); +/** + * [#20390] The per-entry window — `retiredAfter` (ruling 5865890672, letter A). + * + * Between two releases `main` refuses keys the NEXT release retires while its + * package label still reads the LAST release. A label-only window therefore + * read an artifact built by that last release as "authored current" and let + * the strict parse refuse it — the measured cloud re-cut: a 17.4.0-built + * artifact with dashboard charts and page `assignedProfiles` could not boot on + * a runtime built from `main` (label 17.4.0, retirements stamped for 17.5.0). + * + * The rule: entry E replays when `floor < runtime` OR `floor <= E.retiredAfter`. + * The runtime label is injected so each leg names the release it models; the + * registry is always this tree's real one, whose 17.5.0 retirements carry + * `retiredAfter: '17.4.0'` (pinned against the tarballs in spec's census test). + */ +describe('[#20390] the per-entry window — an artifact built by the last release boots on unreleased main', () => { + /** The shape the published 17.4.0 CLI emits for a chart widget and an assigned page. */ + const builtBy174 = (protocolRange: string) => ({ + manifest: { + id: 'com.example.forward-probe', namespace: 'fwd', name: 'forward_probe', version: '1.0.0', type: 'app', + engines: { protocol: protocolRange }, + }, + objects: [{ + name: 'fwd_deal', label: 'Deal', sharingModel: 'private', + fields: { stage: { type: 'text', label: 'Stage' }, amount: { type: 'number', label: 'Amount' } }, + }], + datasets: [{ + name: 'fwd_deal_metrics', label: 'Deal metrics', object: 'fwd_deal', + dimensions: [{ name: 'stage', field: 'stage' }], + measures: [{ name: 'amount', aggregate: 'sum', field: 'amount' }], + }], + dashboards: [{ + name: 'fwd_pipeline', label: 'Pipeline', + widgets: [{ + id: 'amount_by_stage', title: 'Amount by stage', type: 'bar', + dataset: 'fwd_deal_metrics', dimensions: ['stage'], values: ['amount'], + chartConfig: { + type: 'bar', + xAxis: { field: 'stage', showGridLines: true, logarithmic: false }, + yAxis: [{ field: 'amount', showGridLines: true, logarithmic: false }], + showLegend: true, showDataLabels: false, + }, + layout: { x: 0, y: 0, w: 6, h: 4 }, + }], + }], + pages: [{ + name: 'fwd_deal_desk', label: 'Deal Desk', type: 'app', template: 'default', regions: [], + isDefault: false, assignedProfiles: ['sales_manager'], kind: 'full', + }], + }); + + /** The retired-key sites the 17.5.0 cohort refuses in {@link builtBy174}. */ + const RETIRED_SITES = [ + 'dashboards.0.widgets.0.chartConfig.type', + 'dashboards.0.widgets.0.chartConfig.xAxis', + 'dashboards.0.widgets.0.chartConfig.yAxis', + 'pages.0.assignedProfiles', + ]; + + const issuePaths = (value: unknown): string[] => { + const parsed = ObjectStackDefinitionSchema.safeParse(value); + return parsed.success ? [] : parsed.error.issues.map((i) => i.path.join('.')).sort(); + }; + + const byConversion = (notices: readonly ArtifactConversionNotice[]) => { + const counts: Record = {}; + for (const n of notices) counts[n.conversionId] = (counts[n.conversionId] ?? 0) + 1; + return counts; + }; + + it('premise: unconverted, this tree refuses the 17.4.0-built shape at exactly the retired sites', () => { + expect(issuePaths(builtBy174('^17.4.0'))).toEqual(RETIRED_SITES); + }); + + // Pin (4): the regression case from the card's acceptance. + it('unreleased main (label 17.4.0), artifact at the last release (^17.4.0): the 17.5.0 retirements replay and the parse passes', () => { + const def = builtBy174('^17.4.0'); + const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.4.0' }); + + expect(result.verdict).toBe('converted-retired-after'); + expect(result.authoredFloor).toBe('17.4.0'); + expect(byConversion(result.notices)).toEqual({ + 'page-assigned-profiles-removed': 1, + 'dashboard-widget-chart-config-structure-removed': 3, + }); + expect(result.notices.map((n) => n.path).sort()).toEqual([ + 'dashboards[0].widgets[0].chartConfig.type', + 'dashboards[0].widgets[0].chartConfig.xAxis', + 'dashboards[0].widgets[0].chartConfig.yAxis', + 'pages[0].assignedProfiles', + ]); + // What the door hands the strict parse now boots. + expect(issuePaths(result.definition)).toEqual([]); + // The door names what opened it: each retirement this runtime enforces past + // the floor, with the release it retired after — never a default flip. + const replayed = new Map(result.replayedRetirements.map((r) => [r.conversionId, r.retiredAfter])); + expect(replayed.get('page-assigned-profiles-removed')).toBe('17.4.0'); + expect(replayed.get('dashboard-widget-chart-config-structure-removed')).toBe('17.4.0'); + expect(replayed.has('flow-decision-mode-inclusive-explicit')).toBe(false); + expect([...new Set(replayed.values())]).toEqual(['17.4.0']); + }); + + // Pin (3): the boundary the per-entry rule must keep. + it('an artifact whose floor is exactly 17.5.0 on a 17.5.0-labelled runtime is refused, not converted', () => { + const def = builtBy174('^17.5.0'); + const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.5.0' }); + + expect(result.verdict).toBe('authored-current'); + expect(result.notices).toEqual([]); + expect(result.replayedRetirements).toEqual([]); + expect(result.definition).toBe(def); + // The strict parse the door feeds refuses every retired site, tombstones included. + expect(issuePaths(result.definition)).toEqual(RETIRED_SITES); + }); + + it('after the release (label 17.5.0) the same ^17.4.0 artifact converts through the label half — the rule reduces to the old one', () => { + const result = applyArtifactForwardConversions(builtBy174('^17.4.0'), { runtimeSpecVersion: '17.5.0' }); + expect(result.verdict).toBe('converted-forward'); + // The label half names no per-entry reason: the whole chain replays on one. + expect(result.replayedRetirements).toEqual([]); + expect(byConversion(result.notices)).toEqual({ + 'page-assigned-profiles-removed': 1, + 'dashboard-widget-chart-config-structure-removed': 3, + }); + expect(issuePaths(result.definition)).toEqual([]); + }); + + /** + * Inside the open per-entry window, an entry the floor post-dates still + * refuses: `permission-allow-restore-purge-removed` shipped retired in 17.2.0 + * (`retiredAfter` 17.1.0), so a ^17.4.0 artifact carrying `allowRestore: true` + * meets its tombstone although the 17.5.0 entries replay beside it. A key + * retired at V stays a loud refusal for anything authored at >= V. + */ + it('replays only the entries the floor predates — an older retirement still meets its tombstone', () => { + const def = { + ...builtBy174('^17.4.0'), + permissions: [{ name: 'fwd_agent', label: 'Agent', objects: { fwd_deal: { allowRead: true, allowRestore: true } } }], + }; + const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.4.0' }); + + expect(result.verdict).toBe('converted-retired-after'); + expect(result.notices.map((n) => n.conversionId)).not.toContain('permission-allow-restore-purge-removed'); + const grant = (result.definition as typeof def).permissions[0]!.objects.fwd_deal; + expect(grant.allowRestore, 'the 17.2.0 retirement is not replayed for a 17.4.0 floor').toBe(true); + expect(issuePaths(result.definition)).toEqual(['permissions.0.objects.fwd_deal.allowRestore']); + }); +}); + /** * #15429 — the second member of the DEFAULT-FLIP class this door refuses. * @@ -476,6 +629,22 @@ describe('the artifact door never writes `mode: inclusive` onto an authored excl expect(Object.keys(registered.config ?? {}), 'what registration receives').not.toContain('mode'); }); + /** + * [#20390] The per-entry window does not reopen it either. The entry is + * stamped `retiredAfter: '17.4.0'`, so a ^17.4.0 floor on a runtime still + * labelled 17.4.0 is inside ITS per-entry window — and the door's refusal + * list is still read first, before any version is. + */ + it('stays refused inside the per-entry window too — the refusal list is read before retiredAfter', () => { + const def = twoBranchDecisionDefinition('^17.4.0'); + const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.4.0' }); + + // ⭐ ANTI-VACUITY: the per-entry window really is open on this input. + expect(result.verdict).toBe('converted-retired-after'); + expect(verdictNodeOf(result.definition).config).toBeUndefined(); + expect(result.notices.map((n) => n.conversionId)).not.toContain(ID); + }); + 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 16742f49e72..bd42214b795 100644 --- a/packages/metadata-core/src/artifact-forward-conversion.ts +++ b/packages/metadata-core/src/artifact-forward-conversion.ts @@ -31,20 +31,34 @@ * * Let `floor` be the lowest version the artifact's declared protocol range * admits (the leading version token of `engines.protocol`), and `runtime` the - * `@objectstack/spec` version this process actually runs. + * `@objectstack/spec` version this process actually runs — its package label. + * The window is decided PER ENTRY: a registry entry E is replayed when + * + * floor < runtime OR floor ≤ E.retiredAfter + * + * 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: * * - **`floor < runtime`** → the artifact predates this runtime's authoring * surface. Replay the full conversion chain (retired entries included) * before the strict parse — the artifact is the "consumer arriving late" * ADR-0087 D3 keeps every conversion around for. * - **`floor >= runtime`** → the artifact claims the current (or a newer) - * surface. Nothing is replayed; the strict parse — tombstones included — - * is the authority. This is what keeps the conversion **versioned rather - * than a blanket amnesty**: a key retired at version V stays a loud refusal - * for anything authored at ≥ V, and when a retired key 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. + * surface as the label spells it. Only the retired entries whose + * `retiredAfter` the floor does not exceed are replayed — retirements the + * running spec enforces although its label has not moved past the release + * the artifact was built by. `main` is exactly that runtime between two + * releases: it refuses keys the next release retires while still carrying + * the last release's label, so a label-only comparison read an artifact + * built by that last release as "current" and refused it outright. When no + * entry is that recent, nothing is replayed and the strict parse — + * tombstones included — is the authority. This is what keeps the conversion + * **versioned rather than a blanket amnesty**: a key retired at version V + * stays a loud refusal for anything authored at ≥ V, and when a retired key + * 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. * - **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 @@ -57,6 +71,11 @@ * tombstone's prescription). Unreachable in practice — `@objectstack/spec` * is a hard dependency — and injectable for tests either way. * + * Below the label the label still stands in for every entry's own version, so + * an artifact authored between an entry's retirement and the running release + * is replayed rather than refused; narrowing that to the per-entry version + * alone is a separate decision, not taken here. + * * The comparison uses the full `x.y.z`, not the major: within-line * retirements (17.1 → 17.2) are exactly the case that created this module. * Cross-major gaps are the protocol *handshake*'s jurisdiction @@ -88,9 +107,10 @@ * ## What this deliberately is NOT * * - Not a second conversion table: the ADR-0087 registry in - * `@objectstack/spec` stays the single authority on *what* converts; this - * module only decides *whether the retired window opens* for one artifact — - * now per entry for the one named class above, rather than all-or-nothing. + * `@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. * - 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 @@ -116,7 +136,7 @@ import { createRequire } from 'node:module'; // to declaration emit once no exported type references the root — the public // surface speaks {@link ArtifactConversionNotice}, a structural mirror pinned // against the real thing in this module's test. -import { applyConversions } from '@objectstack/spec'; +import { ALL_CONVERSIONS, applyConversions } from '@objectstack/spec'; import { resolveDeclaredRange, type ProtocolHandshakeManifest } from './protocol-handshake.js'; /** @@ -152,9 +172,18 @@ export interface ArtifactConversionNotice { export type ArtifactForwardConversionVerdict = /** Declared floor predates the runtime spec — full chain replayed. */ | 'converted-forward' + /** + * Declared floor is at or above the runtime spec's label, but at or below the + * `retiredAfter` of one or more retired entries this runtime enforces — only + * those entries replayed (see the module doc's per-entry rule). + */ + | 'converted-retired-after' /** No declared range — treated as old data at rest, full chain replayed. */ | 'converted-undeclared' - /** Declared floor is current-or-newer — nothing replayed, the strict parse decides. */ + /** + * Declared floor is current-or-newer, and newer than every retirement's + * `retiredAfter` — nothing replayed, the strict parse decides. + */ | 'authored-current' /** Runtime spec version unresolvable — nothing replayed (see module doc). */ | 'runtime-version-unknown' @@ -189,6 +218,22 @@ export interface ArtifactForwardConversionResult { runtimeSpecVersion: string | null; /** 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. + */ + replayedRetirements: ArtifactReplayedRetirement[]; +} + +/** One retirement the per-entry half of the window replayed (see `'converted-retired-after'`). */ +export interface ArtifactReplayedRetirement { + /** The conversion id (`MetadataConversion.id`). */ + conversionId: string; + /** Its `retiredAfter`: the last spec release whose authoring surface still accepted the old shape. */ + retiredAfter: string; } /** @@ -312,6 +357,34 @@ const DEFAULT_FLIPS_NOT_REPLAYED_HERE: readonly string[] = [ 'flow-decision-mode-inclusive-explicit', ]; +/** + * The per-entry half of the window, for a floor at or above the runtime label. + * `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. + * + * A `retiredAfter` this cannot read closes its entry: 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 { + const closed = [...DEFAULT_FLIPS_NOT_REPLAYED_HERE]; + const opened: ArtifactReplayedRetirement[] = []; + 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 (retiredAfter && compareTriples(floor, retiredAfter) <= 0) { + opened.push({ conversionId: conversion.id, retiredAfter: retiredAfter.join('.') }); + } else { + closed.push(conversion.id); + } + } + return opened.length > 0 ? { closed, opened } : null; +} + /** * Apply the versioned forward conversion to one compiled-artifact definition. * @@ -330,7 +403,7 @@ export function applyArtifactForwardConversions( : resolveInstalledSpecVersion(); if (definition === null || typeof definition !== 'object' || Array.isArray(definition)) { - return { definition, verdict: 'not-an-object', authoredFloor: null, runtimeSpecVersion, notices: [] }; + return { definition, verdict: 'not-an-object', authoredFloor: null, runtimeSpecVersion, notices: [], replayedRetirements: [] }; } const manifest = (definition as { manifest?: unknown }).manifest; @@ -343,27 +416,40 @@ export function applyArtifactForwardConversions( const runtime = runtimeSpecVersion ? parseVersion(runtimeSpecVersion) : null; if (!runtime) { - return { definition, verdict: 'runtime-version-unknown', authoredFloor, runtimeSpecVersion, notices: [] }; + return { definition, verdict: 'runtime-version-unknown', authoredFloor, runtimeSpecVersion, notices: [], replayedRetirements: [] }; } let verdict: ArtifactForwardConversionVerdict; + let excludeConversionIds: readonly string[] = DEFAULT_FLIPS_NOT_REPLAYED_HERE; + let replayedRetirements: ArtifactReplayedRetirement[] = []; if (!floor) { verdict = 'converted-undeclared'; } else if (compareTriples(floor, runtime) < 0) { verdict = 'converted-forward'; } else { - return { definition, verdict: 'authored-current', authoredFloor, runtimeSpecVersion, notices: [] }; + const perEntry = idsTheFloorPostdates(floor); + if (perEntry === null) { + return { definition, verdict: 'authored-current', authoredFloor, runtimeSpecVersion, notices: [], replayedRetirements: [] }; + } + verdict = '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). + excludeConversionIds = perEntry.closed; } const notices: ArtifactConversionNotice[] = []; const converted = applyConversions(definition as Record, { includeRetired: true, - excludeConversionIds: DEFAULT_FLIPS_NOT_REPLAYED_HERE, + excludeConversionIds, onNotice: (n) => { notices.push(n); options.onNotice?.(n); }, }) as T; - return { definition: converted, verdict, authoredFloor, runtimeSpecVersion, notices }; + return { definition: converted, verdict, authoredFloor, runtimeSpecVersion, notices, replayedRetirements }; } diff --git a/packages/metadata/src/__fixtures__/forward-probe-17.4-built.artifact.json b/packages/metadata/src/__fixtures__/forward-probe-17.4-built.artifact.json new file mode 100644 index 00000000000..015ceca9bb1 --- /dev/null +++ b/packages/metadata/src/__fixtures__/forward-probe-17.4-built.artifact.json @@ -0,0 +1,138 @@ +{ + "manifest": { + "id": "com.example.forward-probe", + "namespace": "fwd", + "defaultDatasource": "default", + "version": "1.0.0", + "type": "app", + "scope": "project", + "name": "forward_probe", + "engines": { + "protocol": "^17.4.0" + } + }, + "objects": [ + { + "name": "fwd_deal", + "label": "Deal", + "isSystem": false, + "datasource": "default", + "fields": { + "name": { + "label": "Name", + "type": "text", + "required": false, + "searchable": false, + "multiple": false, + "unique": false, + "hidden": false, + "readonly": false, + "sortable": true, + "externalId": false + }, + "stage": { + "label": "Stage", + "type": "text", + "required": false, + "searchable": false, + "multiple": false, + "unique": false, + "hidden": false, + "readonly": false, + "sortable": true, + "externalId": false + }, + "amount": { + "label": "Amount", + "type": "number", + "required": false, + "searchable": false, + "multiple": false, + "unique": false, + "hidden": false, + "readonly": false, + "sortable": true, + "externalId": false + } + }, + "sharingModel": "private" + } + ], + "pages": [ + { + "name": "fwd_deal_desk", + "label": "Deal Desk", + "type": "app", + "template": "default", + "regions": [], + "isDefault": false, + "assignedProfiles": [ + "sales_manager" + ], + "kind": "full" + } + ], + "dashboards": [ + { + "name": "fwd_pipeline", + "label": "Pipeline", + "widgets": [ + { + "id": "amount_by_stage", + "title": "Amount by stage", + "type": "bar", + "chartConfig": { + "type": "bar", + "xAxis": { + "field": "stage", + "showGridLines": true, + "logarithmic": false + }, + "yAxis": [ + { + "field": "amount", + "showGridLines": true, + "logarithmic": false + } + ], + "showLegend": true, + "showDataLabels": false + }, + "dataset": "fwd_deal_metrics", + "dimensions": [ + "stage" + ], + "values": [ + "amount" + ], + "layout": { + "x": 0, + "y": 0, + "w": 6, + "h": 4 + } + } + ] + } + ], + "datasets": [ + { + "name": "fwd_deal_metrics", + "label": "Deal metrics", + "object": "fwd_deal", + "dimensions": [ + { + "name": "stage", + "field": "stage" + } + ], + "measures": [ + { + "name": "amount", + "aggregate": "sum", + "field": "amount" + } + ] + } + ] +} \ No newline at end of file diff --git a/packages/metadata/src/plugin-artifact-forward-conversion-retired-after.test.ts b/packages/metadata/src/plugin-artifact-forward-conversion-retired-after.test.ts new file mode 100644 index 00000000000..be1ca4f222b --- /dev/null +++ b/packages/metadata/src/plugin-artifact-forward-conversion-retired-after.test.ts @@ -0,0 +1,209 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20390] An artifact built by the LAST release boots on unreleased `main`. + * + * `__fixtures__/forward-probe-17.4-built.artifact.json` is `dist/objectstack.json` + * verbatim, as built by the published `@objectstack/cli` 17.4.0 (`os build`, + * resolving `@objectstack/spec` 17.4.0 from npm) from a one-object source: a + * dataset-bound bar-chart widget whose `chartConfig` carries `type`, `xAxis` + * and `yAxis` — `type` was REQUIRED at 17.4.0 — and a page with + * `assignedProfiles`, declaring `engines.protocol: '^17.4.0'`. + * + * `main` retires all four keys for 17.5.0 while `packages/spec` still carries + * the 17.4.0 label, so before this fix the door's label-only window read the + * artifact as "authored current", replayed nothing, and the strict parse in + * `_parseAndRegisterArtifact` refused the boot. The registry now stamps each of + * those retirements `retiredAfter: '17.4.0'`, and the door replays an entry + * whenever the artifact's floor is at or below it. + * + * Two acceptance pins live here, on the real door: (1) the built artifact + * boots and the conversion notices are logged; (2) a newly AUTHORED source + * using the same retired keys is still refused loudly by the authoring funnel. + * The window's two version boundaries — floor 17.5.0 on a 17.5.0 runtime + * refused, and the label-17.4.0 regression case with an injected label — are + * pinned in `@objectstack/metadata-core`'s `artifact-forward-conversion.test.ts`. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineStack } from '@objectstack/spec'; +import { MetadataPlugin } from './plugin.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const FIXTURE_PATH = join(HERE, '__fixtures__/forward-probe-17.4-built.artifact.json'); + +/** Fresh parse per test — `_parseAndRegisterArtifact` mutates items in place. */ +function loadFixture(): any { + return JSON.parse(readFileSync(FIXTURE_PATH, 'utf8')); +} + +/** A fresh fixture carrying a fresh copy of the given `views`. */ +function loadFixtureWith(views: unknown): any { + return { ...loadFixture(), views: JSON.parse(JSON.stringify(views)) }; +} + +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; +} + +describe('[#20390] artifact door — a 17.4.0-built artifact boots on unreleased main', () => { + it('the fixture carries the shape the published 17.4.0 CLI emitted (premise guard)', () => { + const fixture = loadFixture(); + expect(fixture.manifest.engines.protocol).toBe('^17.4.0'); + const chartConfig = fixture.dashboards[0].widgets[0].chartConfig; + expect(Object.keys(chartConfig)).toEqual(expect.arrayContaining(['type', 'xAxis', 'yAxis'])); + expect(fixture.pages[0].assignedProfiles).toEqual(['sales_manager']); + }); + + // Pin (1). + it('boots: the dashboard and the page register with the retired keys converted away', async () => { + const plugin = newPlugin(); + const total = await plugin._parseAndRegisterArtifact(fakeCtx(), loadFixture(), 'forward-probe-17.4'); + expect(total).toBeGreaterThan(0); + + const dashboard = await plugin.manager.get('dashboard', 'fwd_pipeline'); + expect(dashboard, 'the dashboard registers').toBeDefined(); + const chartConfig = (dashboard as any).widgets[0].chartConfig ?? {}; + for (const key of ['type', 'xAxis', 'yAxis']) expect(chartConfig).not.toHaveProperty(key); + // The widget keeps its dataset binding — the selection the chart renders from. + expect((dashboard as any).widgets[0]).toMatchObject({ type: 'bar', dataset: 'fwd_deal_metrics', dimensions: ['stage'], values: ['amount'] }); + + const page = await plugin.manager.get('page', 'fwd_deal_desk'); + expect(page, 'the page registers').toBeDefined(); + expect(page).not.toHaveProperty('assignedProfiles'); + }); + + // Pin (1), the other half: loud, once per conversion per artifact. + it('logs one conversion summary per retired entry it replayed, naming the site count', async () => { + const plugin = newPlugin(); + const ctx = fakeCtx(); + await plugin._parseAndRegisterArtifact(ctx, loadFixture(), 'forward-probe-17.4'); + + const warns = conversionWarns(ctx); + expect([...warns.keys()].sort()).toEqual([ + 'dashboard-widget-chart-config-structure-removed', + 'page-assigned-profiles-removed', + ]); + expect(warns.get('dashboard-widget-chart-config-structure-removed')).toHaveLength(1); + expect(warns.get('dashboard-widget-chart-config-structure-removed')![0]).toContain('3 site(s)'); + expect(warns.get('page-assigned-profiles-removed')).toHaveLength(1); + expect(warns.get('page-assigned-profiles-removed')![0]).toContain('1 site(s)'); + // Under the per-entry half the floor is not below the runtime's label, + // so the line must not claim the artifact "predates" a runtime printed + // at the same version — it names the retirement that opened it instead. + for (const line of [...warns.values()].flat()) expect(line).not.toContain("predates this runtime's spec"); + }); + + /** + * #12915 scope C rides the same window. The unbound-root notice is read off + * the forward-conversion pass's own verdict, so on unreleased `main` a + * 17.4.0-built artifact is "old" for it exactly as it is for the replay — + * the notice must not wait for the package label to move. + */ + it('announces a bare-root form predicate once — the #12915 notice follows the per-entry window', async () => { + const views = [{ + form: { + type: 'simple', + data: { provider: 'object', object: 'fwd_deal' }, + sections: [{ + name: 'deal', + fields: [ + { field: 'stage' }, + // Bare root: `stage`, not `record.stage` — unbound where it evaluates. + { field: 'amount', required: true, visibleWhen: { dialect: 'cel', source: 'stage == "won"' } }, + ], + }], + }, + }]; + expect(loadFixtureWith(views).manifest.engines.protocol).toBe('^17.4.0'); + + const plugin = newPlugin(); + const ctx = fakeCtx(); + await plugin._parseAndRegisterArtifact(ctx, loadFixtureWith(views), 'forward-probe-17.4-bare-root'); + // The HMR watcher replays the same artifact: still once. + await plugin._parseAndRegisterArtifact(ctx, loadFixtureWith(views), 'forward-probe-17.4-bare-root'); + + const unbound = (ctx.logger.warn.mock.calls as [string][]) + .map(([line]) => String(line)) + .filter((line) => line.includes('root identifier is NOT bound')); + expect(unbound).toHaveLength(1); + expect(unbound[0]).toContain("'stage'"); + expect(unbound[0]).toContain('1 view(s): fwd_deal'); + }); +}); + +describe('[#20390] authoring funnel — a NEW source using the retired keys is still refused loudly', () => { + // Pin (2). The source the fixture was built from, verbatim, authored today: + // the authoring funnel never replays a retired entry, whatever the floor says. + const source = () => ({ + manifest: { + id: 'com.example.forward-probe', namespace: 'fwd', name: 'forward_probe', version: '1.0.0', type: 'app', + engines: { protocol: '^17.4.0' }, + }, + objects: [{ + name: 'fwd_deal', label: 'Deal', sharingModel: 'private', + fields: { + name: { type: 'text', label: 'Name' }, + stage: { type: 'text', label: 'Stage' }, + amount: { type: 'number', label: 'Amount' }, + }, + }], + datasets: [{ + name: 'fwd_deal_metrics', label: 'Deal metrics', object: 'fwd_deal', + dimensions: [{ name: 'stage', field: 'stage' }], + measures: [{ name: 'amount', field: 'amount', aggregate: 'sum' }], + }], + dashboards: [{ + name: 'fwd_pipeline', label: 'Pipeline', + widgets: [{ + id: 'amount_by_stage', title: 'Amount by stage', type: 'bar', + dataset: 'fwd_deal_metrics', dimensions: ['stage'], values: ['amount'], + chartConfig: { type: 'bar', xAxis: { field: 'stage' }, yAxis: [{ field: 'amount' }] }, + layout: { x: 0, y: 0, w: 6, h: 4 }, + }], + }], + pages: [{ name: 'fwd_deal_desk', label: 'Deal Desk', type: 'app', assignedProfiles: ['sales_manager'], regions: [] }], + }); + + it('defineStack refuses with STACK_SCHEMA_INVALID / 422, one issue per retired site', () => { + let refused: any = null; + try { + defineStack(source() as never); + } catch (e) { + refused = e; + } + expect(refused, 'defineStack must refuse').not.toBeNull(); + expect(refused.code).toBe('STACK_SCHEMA_INVALID'); + expect(refused.status).toBe(422); + const paths = (refused.issues as { path: PropertyKey[] }[]).map((i) => i.path.join('.')).sort(); + expect(paths).toEqual([ + 'dashboards.0.widgets.0.chartConfig.type', + 'dashboards.0.widgets.0.chartConfig.xAxis', + 'dashboards.0.widgets.0.chartConfig.yAxis', + 'pages.0.assignedProfiles', + ]); + }); +}); diff --git a/packages/metadata/src/plugin-unbound-form-predicate-roots.test.ts b/packages/metadata/src/plugin-unbound-form-predicate-roots.test.ts index ff7d68c2f4a..ebff6af382c 100644 --- a/packages/metadata/src/plugin-unbound-form-predicate-roots.test.ts +++ b/packages/metadata/src/plugin-unbound-form-predicate-roots.test.ts @@ -25,6 +25,7 @@ import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveInstalledSpecVersion } from '@objectstack/metadata-core'; +import { ALL_CONVERSIONS } from '@objectstack/spec'; import { MetadataPlugin } from './plugin.js'; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -48,6 +49,21 @@ function newPlugin(): any { return new MetadataPlugin({ watch: false, config: { bootstrap: 'lazy' } }); } +/** + * The first `x.y.z` past both the installed spec's label and every retired + * entry's `retiredAfter` — the floor of an artifact authored against the + * surface this runtime actually enforces, which no window opens for. + */ +function currentSurfaceFloor(): string { + const installed = resolveInstalledSpecVersion(); + if (!installed) return ''; + const triples = [installed, ...ALL_CONVERSIONS.flatMap((c) => (c.retiredFromLoadPath === true ? [c.retiredAfter] : []))] + .map((v) => v.split('.').slice(0, 3).map((n) => Number.parseInt(n, 10)) as [number, number, number]) + .sort((a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2]); + const [major, minor, patch] = triples[triples.length - 1]!; + return `${major}.${minor}.${patch + 1}`; +} + /** Just the notices this feature emits — never the #12772 conversion summaries. */ function unboundRootWarnings(ctx: any): string[] { return (ctx.logger.warn.mock.calls as any[]) @@ -137,7 +153,12 @@ describe('artifact door — unbound form-predicate roots are announced to the op // declaring the current floor gets zero notices even carrying the very // same bare-root predicates. Derived from the installed spec rather than // hardcoded, so the pin cannot rot into vacuity on the next spec bump. - const current = resolveInstalledSpecVersion(); + // + // "Current" is past BOTH the package label and every retirement the + // registry enforces (#20390): while `main` carries retirements its label + // has not moved past, `^