Skip to content

Commit 93f5249

Browse files
committed
test(cli): pin migrate meta over an accepted stack carrying a load-path conversion
A driver: 'mongo' source the schema accepts is listed, written and stops converting at load; the authored argument is read before the named-export merge; a twice-defined stack converts once; a canonical source writes nothing and keeps its summary; a stack with one refused and one load-path spelling applies each conversion once. Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU Co-Authored-By: Claude <noreply@anthropic.com>
1 parent c95b09b commit 93f5249

1 file changed

Lines changed: 322 additions & 0 deletions

File tree

Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `os migrate meta` on a stack whose `defineStack` call the current schema
5+
* ACCEPTS, carrying a spelling the load still converts (`driver: 'mongo'`).
6+
*
7+
* ## The defect
8+
*
9+
* An accepted `defineStack` call runs the producer's load-time ADR-0087 D2
10+
* conversion pass before it returns. The authored-source shim handed that
11+
* result to the chain, so the chain started from a stack that was already
12+
* converted: `applied` came back empty, `--write` wrote nothing, and every
13+
* later load still printed the notice that sends the author to this command.
14+
* The fix keeps the call's argument beside its result, and the load starts
15+
* the default export from it.
16+
*
17+
* ## What is pinned
18+
*
19+
* 1. The fix: the conversion is listed, `--write` writes it, the re-run
20+
* applies nothing, and a strict load converts nothing any more.
21+
* 2. Where the argument is read: off the default export, before the
22+
* named-export merge (which spreads it into a new object), and only for
23+
* the authored-source load.
24+
* 3. A stack defined twice starts from the innermost argument, once.
25+
* 4. The control: an already-canonical source writes nothing, and its
26+
* `--json` summary equals the one the built stack gives.
27+
* 5. A stack the schema refuses for one spelling while carrying a load-path
28+
* spelling too: each conversion is applied once, and both are written.
29+
*
30+
* The strict reload is read through `stackConversions`, the producer's own
31+
* record of what it converted, and not through the stderr notice: that notice
32+
* is printed once per process, so a second load in this file would be silent
33+
* whether or not it converted.
34+
*
35+
* In-process over the real command (`MigrateMeta.run`), against temp projects
36+
* that link the real `@objectstack/spec`: no process is spawned and no kernel
37+
* is booted, so this file sits in the `unit` tier.
38+
*/
39+
40+
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
41+
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
42+
import { tmpdir } from 'node:os';
43+
import { dirname, join, relative, resolve } from 'node:path';
44+
import { createRequire } from 'node:module';
45+
import { fileURLToPath } from 'node:url';
46+
import { stripVTControlCharacters } from 'node:util';
47+
import { ObjectStackDefinitionSchema, normalizeStackInput } from '@objectstack/spec';
48+
import MigrateMeta, { applyMetaMigrationsToPackages } from '../src/commands/migrate/meta.js';
49+
import { loadConfig } from '../src/utils/config.js';
50+
51+
const CLI_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..');
52+
const RUN_TIMEOUT = 120_000;
53+
54+
/** `packages/cli` depends on `@objectstack/spec`; resolved as a package, not a source path. */
55+
const requireFromCli = createRequire(import.meta.url);
56+
const SPEC_PACKAGE_ROOT = dirname(requireFromCli.resolve('@objectstack/spec/package.json'));
57+
58+
let root: string;
59+
let specLink: string;
60+
let caseSeq = 0;
61+
62+
function writeProject(files: Record<string, string>): string {
63+
const dir = join(root, `case-${++caseSeq}`);
64+
for (const [rel, text] of Object.entries(files)) {
65+
const path = join(dir, rel);
66+
mkdirSync(dirname(path), { recursive: true });
67+
writeFileSync(path, text);
68+
}
69+
return dir;
70+
}
71+
72+
/** Every file under a project, relative path → bytes. */
73+
function snapshot(dir: string): Record<string, string> {
74+
const out: Record<string, string> = {};
75+
const walk = (d: string) => {
76+
for (const name of readdirSync(d)) {
77+
const p = join(d, name);
78+
if (statSync(p).isDirectory()) walk(p);
79+
else out[relative(dir, p).split('\\').join('/')] = readFileSync(p, 'utf8');
80+
}
81+
};
82+
walk(dir);
83+
return out;
84+
}
85+
86+
/** `text` with `from` replaced by `to` — `from` must occur exactly once. */
87+
function edit(text: string, from: string, to: string): string {
88+
const parts = text.split(from);
89+
expect(parts.length, `expected exactly one occurrence of ${JSON.stringify(from)}`).toBe(2);
90+
return parts.join(to);
91+
}
92+
93+
interface Run {
94+
stdout: string;
95+
stderr: string;
96+
exitCode: number | undefined;
97+
}
98+
99+
/** Run the real command in-process, capturing both streams and any exit. */
100+
async function runMeta(dir: string, flags: string[]): Promise<Run> {
101+
const out: string[] = [];
102+
const err: string[] = [];
103+
const priorExitCode = process.exitCode;
104+
const write = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown, ...rest: unknown[]) => {
105+
out.push(String(chunk));
106+
const done = rest.find((r) => typeof r === 'function') as (() => void) | undefined;
107+
done?.();
108+
return true;
109+
}) as never);
110+
const log = vi.spyOn(console, 'log').mockImplementation((...a: unknown[]) => { out.push(a.join(' ')); });
111+
const warn = vi.spyOn(console, 'warn').mockImplementation((...a: unknown[]) => { err.push(a.join(' ')); });
112+
const error = vi.spyOn(console, 'error').mockImplementation((...a: unknown[]) => { err.push(a.join(' ')); });
113+
let exitCode: number | undefined;
114+
try {
115+
await MigrateMeta.run([join(dir, 'objectstack.config.ts'), ...flags], { root: CLI_ROOT });
116+
} catch (e: any) {
117+
if (typeof e?.oclif?.exit !== 'number') throw e;
118+
exitCode = e.oclif.exit;
119+
} finally {
120+
write.mockRestore();
121+
log.mockRestore();
122+
warn.mockRestore();
123+
error.mockRestore();
124+
if (exitCode === undefined && typeof process.exitCode === 'number' && process.exitCode !== 0) {
125+
exitCode = process.exitCode;
126+
}
127+
process.exitCode = priorExitCode;
128+
}
129+
return {
130+
stdout: stripVTControlCharacters(out.join('\n')),
131+
stderr: stripVTControlCharacters(err.join('\n')),
132+
exitCode,
133+
};
134+
}
135+
136+
/** A load with its stderr swallowed: the producer's notices are not what these pins read. */
137+
async function quietLoad(dir: string, authoredSource = false) {
138+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
139+
try {
140+
return await loadConfig(join(dir, 'objectstack.config.ts'), authoredSource ? { authoredSource: true } : undefined);
141+
} finally {
142+
warn.mockRestore();
143+
}
144+
}
145+
146+
const json = (run: Run): any => JSON.parse(run.stdout);
147+
const sites = (list: Array<{ conversionId: string; path: string; from?: string; to?: string }>) =>
148+
list.map(({ conversionId, path, from, to }) => ({ conversionId, path, from, to }));
149+
const conversionIds = (list: readonly { conversionId: string }[]) => list.map((c) => c.conversionId);
150+
151+
beforeAll(() => {
152+
root = mkdtempSync(join(tmpdir(), 'os-migrate-meta-load-conversions-'));
153+
mkdirSync(join(root, 'node_modules', '@objectstack'), { recursive: true });
154+
specLink = join(root, 'node_modules', '@objectstack', 'spec');
155+
symlinkSync(SPEC_PACKAGE_ROOT, specLink, 'dir');
156+
});
157+
158+
afterAll(() => {
159+
// Unlinked BEFORE the recursive remove: the symlink points at the real
160+
// `packages/spec`, and a cleanup must never follow it.
161+
try { unlinkSync(specLink); } catch { /* already gone */ }
162+
try { rmSync(root, { recursive: true, force: true }); } catch { /* ignore */ }
163+
});
164+
165+
/**
166+
* A stack the current schema accepts. Its one non-canonical spelling is a
167+
* datasource driver id the load still converts (`retiredFromLoadPath: false`);
168+
* the `url` is what makes a mongo datasource valid, so the call is accepted.
169+
*/
170+
const MONGO_SOURCE = `import { defineStack } from '@objectstack/spec';
171+
172+
export default defineStack({
173+
manifest: { id: 'com.probe.svc', name: 'Probe Service', namespace: 'probe', version: '1.0.0', type: 'module' },
174+
objects: [
175+
{
176+
name: 'probe_slot',
177+
label: 'Slot',
178+
fields: {
179+
name: { type: 'text', label: 'Name' },
180+
},
181+
},
182+
],
183+
datasources: [{ name: 'docs', label: 'Docs', driver: 'mongo', config: { url: 'mongodb://mongo.internal:27017/docs' } }],
184+
});
185+
`;
186+
187+
const MONGO_CONVERSION = 'datasource-driver-mongo-to-mongodb';
188+
const MONGO_SITE = { conversionId: MONGO_CONVERSION, path: 'datasources[0].driver', from: 'mongo', to: 'mongodb' };
189+
const TIME_SITE = {
190+
conversionId: 'time-default-utc-suffix-dropped',
191+
path: 'objects[0].fields.starts_at.defaultValue',
192+
from: '"10:00Z"',
193+
to: '"10:00"',
194+
};
195+
196+
describe('an accepted stack: the chain starts from what the author wrote', () => {
197+
it('lists a conversion the load already applied; --write writes it, and the load stops converting', async () => {
198+
const dir = writeProject({ 'objectstack.config.ts': MONGO_SOURCE });
199+
200+
// The control for the last reading below: today the strict load converts it.
201+
const before = await quietLoad(dir);
202+
expect(before.stackProvenance).toBe(true);
203+
expect(conversionIds(before.stackConversions)).toEqual([MONGO_CONVERSION]);
204+
205+
const dry = json(await runMeta(dir, ['--from', '16', '--json']));
206+
expect(sites(dry.applied)).toEqual([MONGO_SITE]);
207+
expect(dry.schemaValid).toBe(true);
208+
209+
const run = await runMeta(dir, ['--from', '16', '--write', '--json']);
210+
expect(run.exitCode, run.stdout + run.stderr).toBeUndefined();
211+
const out = json(run);
212+
expect(sites(out.applied)).toEqual([MONGO_SITE]);
213+
expect(out.write.status).toBe('written');
214+
expect(out.write.files).toEqual([{ file: 'objectstack.config.ts', sites: 1 }]);
215+
expect(out.write.manual).toEqual([]);
216+
expect(out.write.unexplained).toEqual([]);
217+
expect(readFileSync(join(dir, 'objectstack.config.ts'), 'utf8')).toBe(
218+
edit(MONGO_SOURCE, "driver: 'mongo'", "driver: 'mongodb'"),
219+
);
220+
221+
const again = json(await runMeta(dir, ['--from', '16', '--json']));
222+
expect(again.applied).toEqual([]);
223+
expect(again.schemaValid).toBe(true);
224+
225+
// The load every other command uses — strict, no shim — converts nothing now.
226+
const after = await quietLoad(dir);
227+
expect(after.stackProvenance).toBe(true);
228+
expect(after.stackConversions).toEqual([]);
229+
}, RUN_TIMEOUT);
230+
231+
it('the authored-source load reads the argument off the default export, before the named exports are merged', async () => {
232+
const dir = writeProject({
233+
'objectstack.config.ts': `${MONGO_SOURCE}\nexport const onEnable = async () => {};\n`,
234+
});
235+
236+
const authored = await quietLoad(dir, true);
237+
expect(authored.config.datasources[0].driver).toBe('mongo');
238+
expect(authored.namedExports).toEqual(['onEnable']);
239+
expect(typeof authored.config.onEnable).toBe('function');
240+
// The producer's mark and record are still read off the stack it built.
241+
expect(authored.stackProvenance).toBe(true);
242+
expect(conversionIds(authored.stackConversions)).toEqual([MONGO_CONVERSION]);
243+
244+
// Every other command still reads the stack `defineStack` built.
245+
const strict = await quietLoad(dir);
246+
expect(strict.config.datasources[0].driver).toBe('mongodb');
247+
}, RUN_TIMEOUT);
248+
249+
it('a stack defined twice starts from the innermost argument, and converts it once', async () => {
250+
const dir = writeProject({
251+
'objectstack.config.ts': edit(
252+
edit(MONGO_SOURCE, 'export default defineStack({', 'export default defineStack(defineStack({'),
253+
'\n});\n',
254+
'\n}));\n',
255+
),
256+
});
257+
const out = json(await runMeta(dir, ['--from', '16', '--json']));
258+
expect(sites(out.applied)).toEqual([MONGO_SITE]);
259+
expect(out.schemaValid).toBe(true);
260+
}, RUN_TIMEOUT);
261+
});
262+
263+
describe('control: an already-canonical source', () => {
264+
const CANONICAL = edit(MONGO_SOURCE, "driver: 'mongo'", "driver: 'mongodb'");
265+
266+
it('writes nothing, and its --json summary is the one the built stack gives', async () => {
267+
const dir = writeProject({ 'objectstack.config.ts': CANONICAL });
268+
const before = snapshot(dir);
269+
270+
const run = await runMeta(dir, ['--from', '16', '--write', '--json']);
271+
expect(run.exitCode, run.stdout + run.stderr).toBeUndefined();
272+
const out = json(run);
273+
expect(out.applied).toEqual([]);
274+
expect(out.write).toEqual({ status: 'written', files: [], written: [], manual: [], unexplained: [] });
275+
expect(snapshot(dir)).toEqual(before);
276+
277+
// The chain over the stack `defineStack` built — where it started before
278+
// this change — reaches the same summary.
279+
const built = (await quietLoad(dir)).config as Record<string, unknown>;
280+
const old = applyMetaMigrationsToPackages(normalizeStackInput(built, { convert: false }), out.from, out.to);
281+
const plain = (value: unknown) => JSON.parse(JSON.stringify(value));
282+
expect(out.applied).toEqual(plain(old.applied));
283+
expect(out.todos).toEqual(plain(old.todos));
284+
expect(out.absentTodos).toEqual(plain(old.absentTodos));
285+
expect(out.schemaValid).toBe(ObjectStackDefinitionSchema.safeParse(old.stack).success);
286+
}, RUN_TIMEOUT);
287+
});
288+
289+
describe('a stack with one refused spelling and one load-path spelling', () => {
290+
// The `time` default with the UTC suffix is refused by the current schema,
291+
// so this call hands its argument through; the datasource is valid, so the
292+
// driver id is the only load-path conversion in it.
293+
const MIXED = edit(
294+
MONGO_SOURCE,
295+
" name: { type: 'text', label: 'Name' },\n",
296+
" name: { type: 'text', label: 'Name' },\n starts_at: { type: 'time', label: 'Starts', defaultValue: '10:00Z' },\n",
297+
);
298+
299+
it('applies each conversion once, and --write writes both', async () => {
300+
const dir = writeProject({ 'objectstack.config.ts': MIXED });
301+
302+
const dry = json(await runMeta(dir, ['--from', '16', '--json']));
303+
expect(sites(dry.applied)).toEqual([MONGO_SITE, TIME_SITE]);
304+
305+
const run = await runMeta(dir, ['--from', '16', '--write', '--json']);
306+
expect(run.exitCode, run.stdout + run.stderr).toBeUndefined();
307+
const out = json(run);
308+
expect(sites(out.applied)).toEqual([MONGO_SITE, TIME_SITE]);
309+
expect(out.write.status).toBe('written');
310+
expect(out.write.files).toEqual([{ file: 'objectstack.config.ts', sites: 2 }]);
311+
expect(readFileSync(join(dir, 'objectstack.config.ts'), 'utf8')).toBe(
312+
edit(edit(MIXED, "defaultValue: '10:00Z'", "defaultValue: '10:00'"), "driver: 'mongo'", "driver: 'mongodb'"),
313+
);
314+
315+
const again = json(await runMeta(dir, ['--from', '16', '--json']));
316+
expect(again.applied).toEqual([]);
317+
318+
const strict = await quietLoad(dir);
319+
expect(strict.stackProvenance).toBe(true);
320+
expect(strict.stackConversions).toEqual([]);
321+
}, RUN_TIMEOUT);
322+
});

0 commit comments

Comments
 (0)