Skip to content

Commit 99b16b2

Browse files
committed
test(cli): re-premise the #17134 default-range pins for protocol 18
The spawned pins ran `--from PROTOCOL_MAJOR`, which equalled the tombstone's authoring major only while the runtime was on it. At protocol 18 the range `--from 18` lies above the rename, so four cases read an empty chain. They now run the tombstone's own `--from N`, with N one below the toMajor of the step that carries the rename conversion, read off the registry. The new default (max of PROTOCOL_MAJOR and MIGRATION_MAJORS) differs from the old one (PROTOCOL_MAJOR) only while the registry carries a step past the runtime major. At protocol 18 with steps 17 and 18 registered and no toMajor 19 conversion, both are 18, so the spawned anti-vacuity line cannot hold. It moves unchanged to migrate-meta-default-range-window.test.ts. That file runs the real command in-process with only the kernel's protocol constants set to the authoring major, the window the card was measured in, so it tells the two defaults apart in every release. Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude <noreply@anthropic.com>
1 parent dd4ea17 commit 99b16b2

2 files changed

Lines changed: 261 additions & 20 deletions

File tree

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `os migrate meta`: the DEFAULT `--to`, pinned in the window #17134 was found
5+
* in, with that window held open on purpose.
6+
*
7+
* ## Why this file exists beside `migrate-meta-default-range.test.ts`
8+
*
9+
* The default under test is `Math.max(PROTOCOL_MAJOR, ...MIGRATION_MAJORS)`.
10+
* The default it replaced is `PROTOCOL_MAJOR`. The two differ only while the
11+
* registry carries a step PAST the runtime major, which is the window each
12+
* major's line spends accumulating the next major's conversions. Measured at
13+
* protocol 18 on #22130's branch: `MIGRATION_MAJORS` is `[17, 18]`, and no
14+
* conversion registers `toMajor: 19`. Both defaults are 18, so no run of the
15+
* installed CLI tells them apart. The spawned file's anti-vacuity line
16+
* (`TERMINUS > PROTOCOL_MAJOR`) would read `18 > 18` there.
17+
*
18+
* So the window is held open here rather than waited for. The real command
19+
* runs in-process over the real registry, and only the runtime's protocol
20+
* major is replaced: by the rename step's AUTHORING major (`toMajor - 1`).
21+
* That is the world `@objectstack/spec@17.4.0` shipped in and the card was
22+
* measured in. In it, the old default composes `N → N` and lists nothing,
23+
* while the default under test reaches the rename's step and lists all five
24+
* rewrites. The anti-vacuity assertion is the spawned file's own line, applied
25+
* to this window, where it holds in every release instead of only while the
26+
* live registry runs ahead.
27+
*
28+
* ⛔ The replacement is the module's two protocol constants and nothing else.
29+
* The registry, the conversions, the schemas and the loader are the installed
30+
* ones, so the five rewrites and the schema verdict are the real chain's.
31+
*
32+
* In-process over `MigrateMeta.run`: no process is spawned and no kernel is
33+
* booted, so this file sits in the `unit` tier by behaviour.
34+
*/
35+
36+
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
37+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
38+
import { tmpdir } from 'node:os';
39+
import { join, resolve } from 'node:path';
40+
import { fileURLToPath } from 'node:url';
41+
import { stripVTControlCharacters } from 'node:util';
42+
import { MIGRATIONS_BY_MAJOR, MIGRATION_MAJORS } from '@objectstack/spec/migrations';
43+
import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel';
44+
import MigrateMeta from '../src/commands/migrate/meta.js';
45+
46+
const { RENAME_CONVERSION } = vi.hoisted(() => ({
47+
RENAME_CONVERSION: 'dashboard-refresh-interval-to-refresh-interval-seconds',
48+
}));
49+
50+
/**
51+
* The window: the runtime still on the major the tombstone's source was
52+
* authored against, with the rename's step already registered. Derived from
53+
* the registry, never written down. A missing step is a loud failure here,
54+
* because the window would otherwise be undefined.
55+
*/
56+
vi.mock('@objectstack/spec/kernel', async (importOriginal) => {
57+
const actual = await importOriginal<typeof import('@objectstack/spec/kernel')>();
58+
const { MIGRATIONS_BY_MAJOR: steps } = await import('@objectstack/spec/migrations');
59+
const step = Object.values(steps).find((s) => s.conversionIds.includes(RENAME_CONVERSION));
60+
if (!step) throw new Error(`no registered migration step carries ${RENAME_CONVERSION}`);
61+
const major = step.toMajor - 1;
62+
return { ...actual, PROTOCOL_MAJOR: major, PROTOCOL_VERSION: `${major}.0.0` };
63+
});
64+
65+
const CLI_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..');
66+
67+
const RENAME_STEP = Object.values(MIGRATIONS_BY_MAJOR).find((s) => s.conversionIds.includes(RENAME_CONVERSION))!;
68+
69+
/** The tombstone's `N`, and in this window also the runtime's major. */
70+
const AUTHORED = RENAME_STEP.toMajor - 1;
71+
72+
/** The default under test, computed as the command computes it, in this window. */
73+
const TERMINUS = Math.max(PROTOCOL_MAJOR, ...MIGRATION_MAJORS);
74+
75+
/** The spawned file's reproduction: five dashboards authoring the retired key. */
76+
const RETIRED_KEY_CONFIG = `
77+
export default {
78+
manifest: { id: 'com.example.default-range-window', name: 'Default Range Window', version: '1.0.0', type: 'app' },
79+
objects: [{ name: 'dw_ticket', label: 'Ticket', fields: { title: { type: 'text', label: 'Title' } } }],
80+
dashboards: [
81+
{ name: 'kpi_a', label: 'KPI A', widgets: [], refreshInterval: 300 },
82+
{ name: 'kpi_b', label: 'KPI B', widgets: [], refreshInterval: 60 },
83+
{ name: 'kpi_c', label: 'KPI C', widgets: [], refreshInterval: 120 },
84+
{ name: 'kpi_d', label: 'KPI D', widgets: [], refreshInterval: 900 },
85+
{ name: 'kpi_e', label: 'KPI E', widgets: [], refreshInterval: 30 },
86+
],
87+
};
88+
`;
89+
90+
interface Run {
91+
stdout: string;
92+
stderr: string;
93+
exitCode: number | undefined;
94+
}
95+
96+
let dir: string;
97+
const runs = new Map<string, Promise<Run>>();
98+
99+
/** Run the real command in-process once per distinct invocation, capturing both streams and any exit. */
100+
function runMeta(flags: string[]): Promise<Run> {
101+
const key = flags.join(' ');
102+
const hit = runs.get(key);
103+
if (hit) return hit;
104+
const started = (async (): Promise<Run> => {
105+
const out: string[] = [];
106+
const err: string[] = [];
107+
const priorExitCode = process.exitCode;
108+
const write = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown, ...rest: unknown[]) => {
109+
out.push(String(chunk));
110+
const done = rest.find((r) => typeof r === 'function') as (() => void) | undefined;
111+
done?.();
112+
return true;
113+
}) as never);
114+
const log = vi.spyOn(console, 'log').mockImplementation((...a: unknown[]) => { out.push(a.join(' ')); });
115+
const warn = vi.spyOn(console, 'warn').mockImplementation((...a: unknown[]) => { err.push(a.join(' ')); });
116+
const error = vi.spyOn(console, 'error').mockImplementation((...a: unknown[]) => { err.push(a.join(' ')); });
117+
let exitCode: number | undefined;
118+
try {
119+
await MigrateMeta.run([join(dir, 'objectstack.config.ts'), ...flags], { root: CLI_ROOT });
120+
} catch (e: any) {
121+
if (typeof e?.oclif?.exit !== 'number') throw e;
122+
exitCode = e.oclif.exit;
123+
} finally {
124+
write.mockRestore();
125+
log.mockRestore();
126+
warn.mockRestore();
127+
error.mockRestore();
128+
if (exitCode === undefined && typeof process.exitCode === 'number' && process.exitCode !== 0) {
129+
exitCode = process.exitCode;
130+
}
131+
process.exitCode = priorExitCode;
132+
}
133+
return {
134+
stdout: stripVTControlCharacters(out.join('\n')),
135+
stderr: stripVTControlCharacters(err.join('\n')),
136+
exitCode,
137+
};
138+
})();
139+
runs.set(key, started);
140+
return started;
141+
}
142+
143+
beforeAll(() => {
144+
dir = mkdtempSync(join(tmpdir(), 'os-meta-default-window-'));
145+
writeFileSync(join(dir, 'objectstack.config.ts'), RETIRED_KEY_CONFIG);
146+
});
147+
148+
afterAll(() => {
149+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
150+
});
151+
152+
describe('os migrate meta: the default --to in the window the card was found in (#17134)', () => {
153+
it('runs in that window: the runtime major is the tombstone\'s authoring major', () => {
154+
// The replacement reached the module the command reads. Without this, a
155+
// mock that silently missed would leave the cases below on the live major.
156+
expect(PROTOCOL_MAJOR).toBe(AUTHORED);
157+
expect(MIGRATION_MAJORS).toContain(RENAME_STEP.toMajor);
158+
});
159+
160+
it('defaults --to to the highest major this build has a step for, not the runtime major', async () => {
161+
const { stdout, exitCode } = await runMeta(['--from', String(AUTHORED), '--json']);
162+
const parsed = JSON.parse(stdout);
163+
expect(exitCode).toBeUndefined();
164+
expect(parsed.from).toBe(AUTHORED);
165+
expect(parsed.to).toBe(TERMINUS);
166+
// ⛔ Anti-vacuity. If the terminus ever equalled PROTOCOL_MAJOR the line
167+
// above would hold for the very default this card exists to replace, so the
168+
// premise is asserted rather than assumed: this is the line that speaks up
169+
// when a major ships and the registry has no entry past it yet.
170+
expect(TERMINUS, 'the registry carries a step past the runtime major').toBeGreaterThan(PROTOCOL_MAJOR);
171+
}, 120_000);
172+
173+
it('lists every retired-key rewrite for the tombstone\'s `--from`, with no --to given', async () => {
174+
const parsed = JSON.parse((await runMeta(['--from', String(AUTHORED), '--json'])).stdout);
175+
const renames = parsed.applied.filter((a: any) => a.conversionId === RENAME_CONVERSION);
176+
expect(renames.map((a: any) => a.path)).toEqual([
177+
'dashboards[0].refreshIntervalSeconds',
178+
'dashboards[1].refreshIntervalSeconds',
179+
'dashboards[2].refreshIntervalSeconds',
180+
'dashboards[3].refreshIntervalSeconds',
181+
'dashboards[4].refreshIntervalSeconds',
182+
]);
183+
for (const r of renames) {
184+
expect(r.from).toBe('refreshInterval');
185+
expect(r.to).toBe('refreshIntervalSeconds');
186+
}
187+
expect(parsed.schemaValid).toBe(true);
188+
}, 120_000);
189+
190+
it('the human run prints the rewrites instead of the canonical verdict', async () => {
191+
const { stdout, exitCode } = await runMeta(['--from', String(AUTHORED)]);
192+
expect(exitCode).toBeUndefined();
193+
expect(stdout).toContain('Applied 5 mechanical change(s)');
194+
expect(stdout).toContain(RENAME_CONVERSION);
195+
// ⛔ The whole sentence, never the phrase: step-18 semantic entries open a
196+
// `replacement` with "Nothing to migrate to, because …".
197+
expect(stdout).not.toContain('Nothing to migrate — the metadata is already canonical');
198+
}, 120_000);
199+
});

‎packages/cli/test/migrate-meta-default-range.test.ts‎

Lines changed: 62 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,32 @@
4242
* what does not move is that the default terminus is the highest major the
4343
* installed build carries a step for, so every expectation is derived from
4444
* `MIGRATION_MAJORS` and `PROTOCOL_MAJOR` and stays true one major later.
45+
*
46+
* ## Which `--from`: the tombstone's, not the runtime's (#22130)
47+
*
48+
* The invocation under test is the one the tombstone prints, and its `N` is
49+
* the AUTHORING major: one below the `toMajor` of the step that carries the
50+
* rename conversion. That is {@link AUTHORED}, read off the registry. This
51+
* file used to spell it `String(PROTOCOL_MAJOR)`, which was the same number
52+
* only while the runtime was still on the authoring major. At protocol 18,
53+
* `--from 18` is a range above the rename, so every case here read an empty
54+
* chain and failed for a reason unrelated to the defect.
55+
*
56+
* ## Where the default is told apart from the old one
57+
*
58+
* The old default was `PROTOCOL_MAJOR`, and the new one differs from it only
59+
* while the registry carries a step past the runtime major. Measured at
60+
* protocol 18 on #22130's branch: `MIGRATION_MAJORS` is `[17, 18]`, and no
61+
* conversion registers `toMajor: 19` on that branch or on `main` at
62+
* `dc4a5c6308`. The two defaults are therefore the same number, 18, and no
63+
* run of the installed CLI can tell them apart. The anti-vacuity line this
64+
* file carried (`TERMINUS > PROTOCOL_MAJOR`) cannot hold at protocol 18. It
65+
* moved, unchanged, to `migrate-meta-default-range-window.test.ts`. That file
66+
* runs the real command in-process with the runtime major set to
67+
* {@link AUTHORED}, the window this card was found in, so it distinguishes the
68+
* two defaults in every release and not only while the registry runs ahead.
69+
* The cases below pin what a real terminal prints for the tombstone's
70+
* invocation, and that pin holds whichever default is in place.
4571
*/
4672

4773
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
@@ -60,9 +86,21 @@ const HERE = resolve(fileURLToPath(import.meta.url), '..');
6086
const CLI = resolve(HERE, '../bin/run-dev.js');
6187
const TSX = resolve(HERE, '../../../node_modules/.bin/tsx');
6288

89+
const RENAME_CONVERSION = 'dashboard-refresh-interval-to-refresh-interval-seconds';
90+
91+
/** The step that carries the rename: its `toMajor` is the tombstone's `N + 1`. */
92+
const RENAME_STEP = Object.values(MIGRATIONS_BY_MAJOR).find((s) => s.conversionIds.includes(RENAME_CONVERSION));
93+
6394
/** What the command must now default `--to` to — derived, never written down. */
6495
const TERMINUS = Math.max(PROTOCOL_MAJOR, ...MIGRATION_MAJORS);
65-
const INSTALLED = String(PROTOCOL_MAJOR);
96+
97+
/**
98+
* The `N` the tombstone prints in `--from N`: the major the source was
99+
* authored against, one below the rename step's `toMajor`. A missing step
100+
* yields `NaN`, and the guard case below fails on it before any spawn result
101+
* is read.
102+
*/
103+
const AUTHORED = String((RENAME_STEP?.toMajor ?? Number.NaN) - 1);
66104

67105
/**
68106
* The card's reproduction: a stack on the installed line authoring the
@@ -92,8 +130,6 @@ export default {
92130
};
93131
`;
94132

95-
const RENAME_CONVERSION = 'dashboard-refresh-interval-to-refresh-interval-seconds';
96-
97133
interface Run { stdout: string; code: number }
98134

99135
let retiredDir: string;
@@ -134,21 +170,26 @@ afterAll(() => {
134170
});
135171

136172
describe('os migrate meta — the invocation the tombstones prescribe (#17134)', () => {
137-
it('defaults --to to the highest major this build has a step for, not the runtime major', async () => {
138-
const { stdout, code } = await runMeta(['--from', INSTALLED, '--json'], retiredDir);
173+
it('defaults --to to the highest major this build has a step for, which holds the rename', async () => {
174+
// The subject first: without the step that carries the rename there is no
175+
// tombstone invocation to run, and `AUTHORED` reads `NaN`.
176+
expect(RENAME_STEP, `a registered migration step carries ${RENAME_CONVERSION}`).toBeDefined();
177+
const { stdout, code } = await runMeta(['--from', AUTHORED, '--json'], retiredDir);
139178
const parsed = JSON.parse(stdout);
140179
expect(code).toBe(0);
141-
expect(parsed.from).toBe(PROTOCOL_MAJOR);
180+
expect(parsed.from).toBe(Number(AUTHORED));
142181
expect(parsed.to).toBe(TERMINUS);
143-
// ⛔ Anti-vacuity. If the terminus ever equalled PROTOCOL_MAJOR the line
144-
// above would hold for the very default this card exists to replace, so the
145-
// premise is asserted rather than assumed: this is the line that speaks up
146-
// when a major ships and the registry has no entry past it yet.
147-
expect(TERMINUS, 'the registry carries a step past the runtime major').toBeGreaterThan(PROTOCOL_MAJOR);
182+
// The presumption the tombstone's template makes: the default terminus is
183+
// at least the conversion's own `toMajor`.
184+
expect(parsed.to).toBeGreaterThanOrEqual(RENAME_STEP!.toMajor);
185+
// ⛔ This run cannot tell the new default from the old one at protocol 18
186+
// (both are 18, measured; see the header). The anti-vacuity line that
187+
// asserts the difference lives in migrate-meta-default-range-window.test.ts,
188+
// where the runtime major is held one below the rename's step.
148189
}, 120_000);
149190

150191
it('lists every retired-key rewrite with no --to given at all', async () => {
151-
const { stdout } = await runMeta(['--from', INSTALLED, '--json'], retiredDir);
192+
const { stdout } = await runMeta(['--from', AUTHORED, '--json'], retiredDir);
152193
const parsed = JSON.parse(stdout);
153194

154195
const renames = parsed.applied.filter((a: any) => a.conversionId === RENAME_CONVERSION);
@@ -170,7 +211,7 @@ describe('os migrate meta — the invocation the tombstones prescribe (#17134)',
170211
}, 120_000);
171212

172213
it('the human run prints the rewrites instead of `Nothing to migrate`', async () => {
173-
const { stdout, code } = await runMeta(['--from', INSTALLED], retiredDir);
214+
const { stdout, code } = await runMeta(['--from', AUTHORED], retiredDir);
174215
expect(code).toBe(0);
175216
expect(stdout).toContain('Applied 5 mechanical change(s)');
176217
expect(stdout).toContain(RENAME_CONVERSION);
@@ -187,18 +228,19 @@ describe('os migrate meta — the invocation the tombstones prescribe (#17134)',
187228

188229
describe('os migrate meta — an empty range answers as an empty range (#17134)', () => {
189230
/**
190-
* The pre-fix default, now reachable only by typing it. The command is right
191-
* that this range holds no conversion; what it may not do is turn that into a
192-
* verdict about the metadata.
231+
* The range the pre-fix default composed in the card's world, where the
232+
* runtime was still on the authoring major. It is reachable now only by
233+
* typing it. The command is right that this range holds no conversion; what
234+
* it may not do is turn that into a verdict about the metadata.
193235
*/
194236
it('refuses to call an un-migrated stack canonical when the range holds no step', async () => {
195-
const { stdout, code } = await runMeta(['--from', INSTALLED, '--to', INSTALLED], retiredDir);
237+
const { stdout, code } = await runMeta(['--from', AUTHORED, '--to', AUTHORED], retiredDir);
196238

197239
expect(stdout).not.toContain('already canonical');
198-
expect(stdout).toContain(`No migration step exists for protocol ${INSTALLED} → ${INSTALLED}`);
240+
expect(stdout).toContain(`No migration step exists for protocol ${AUTHORED} → ${AUTHORED}`);
199241
// Triage's requirement: name the range that WOULD list them.
200242
expect(stdout).toContain(`--to ${TERMINUS}`);
201-
expect(stdout).toContain(`Protocol ${INSTALLED} → ${TERMINUS} has 5 mechanical`);
243+
expect(stdout).toContain(`Protocol ${AUTHORED} → ${TERMINUS} has 5 mechanical`);
202244
// ⛔ The exit code is deliberately unchanged. This command reports findings
203245
// rather than exiting on them — its schema-invalid arm beside this one has
204246
// always been a warning at exit 0. What changed is that the text no longer
@@ -207,7 +249,7 @@ describe('os migrate meta — an empty range answers as an empty range (#17134)'
207249
}, 120_000);
208250

209251
it('no longer returns past the schema verdict that contradicts it', async () => {
210-
const { stdout } = await runMeta(['--from', INSTALLED, '--to', INSTALLED], retiredDir);
252+
const { stdout } = await runMeta(['--from', AUTHORED, '--to', AUTHORED], retiredDir);
211253
// Unreachable before the fix: the zero-change branch returned first, so the
212254
// same run could report `schemaValid: false` in `--json` while the human
213255
// output claimed the metadata was canonical and stopped.

0 commit comments

Comments
 (0)