From c95b09b0078579ff034ab08a08a6a9f9d383b5b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 19:30:36 +0000 Subject: [PATCH 1/4] fix(cli): migrate meta starts the chain from an accepted defineStack call's argument The authored-source shim now keeps, beside the stack an accepted defineStack call returns, the argument the call was given, under a CLI-owned non-enumerable key. loadConfig({ authoredSource: true }) starts the default export from that argument before the named-export merge, so the chain no longer sees a stack the load-time conversion pass already converted. Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU Co-Authored-By: Claude --- packages/cli/src/commands/migrate/meta.ts | 7 ++ packages/cli/src/utils/config.ts | 122 ++++++++++++++++++++-- 2 files changed, 120 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/commands/migrate/meta.ts b/packages/cli/src/commands/migrate/meta.ts index 9b58f0ba8a1..e6a151d8715 100644 --- a/packages/cli/src/commands/migrate/meta.ts +++ b/packages/cli/src/commands/migrate/meta.ts @@ -1162,6 +1162,13 @@ export default class MigrateMeta extends Command { // conversions itself against the raw authored source so each rewrite is // attributed to a chain hop, not silently pre-applied by the load-time // D2 pass. Running the D2 pass here would leave the chain's diff empty. + // + // `convert: false` reaches only THIS call. The stack's own `defineStack` + // call runs the D2 pass inside the config module whenever the schema + // accepts its input, so `config` is the raw source only because the + // load starts it from the argument that call was given (`loadConfig`, + // `authoredSource`). That holds for a one-package stack; a composed + // project's package bodies are assembled from converted stacks. const normalized = normalizeStackInput(config as Record, { convert: false }); if (!flags.json) printStep(`Replaying chain: protocol ${fromMajor} → ${toMajor}…`); diff --git a/packages/cli/src/utils/config.ts b/packages/cli/src/utils/config.ts index 982786a6180..0488d7e55d3 100644 --- a/packages/cli/src/utils/config.ts +++ b/packages/cli/src/utils/config.ts @@ -297,6 +297,33 @@ const HANDED_THROUGH_MODULE = 'objectstack:authored-source/handed-through'; const HANDED_THROUGH_FILTER = /^objectstack:authored-source\/handed-through$/; const HANDED_THROUGH_NAMESPACE = 'objectstack-authored-source-record'; +/** + * The global-registry symbol name under which the shim keeps, on the stack an + * ACCEPTED {@link STACK_PRODUCER} call returned, the argument that call was + * given — and the one name {@link authoredArgumentOf} reads it back by. + * + * Owned by this CLI: ⛔ never the producer's own provenance key. The record + * is not a claim about who built the stack (that mark is the producer's to + * write); it is this loader's note of what the author wrote, read once, off + * the default export, by {@link loadConfig}. + * + * ## Why a property on the stack, and not the load's hand-through record + * + * The record has to cross from the bundled shim to {@link loadConfig}. The + * only values that cross are the config module's exports, and the default + * export IS the stack the call returned, so a property on it crosses with it + * — with no state outside the value, and no lifetime but the value's. The + * hand-through record ({@link HANDED_THROUGH_MODULE}) is bundled inside the + * load and is reachable from nothing outside it. A `Symbol.for` key because + * the shim's code and this module are two module graphs in one process, and + * the global registry is what both resolve the same symbol from. + * + * Non-enumerable, like the producer's own mark: a spread, `JSON.stringify` + * and a schema parse all leave it behind, and {@link loadConfig} reads it + * BEFORE its named-export merge spreads the default export into a new object. + */ +const AUTHORED_ARGUMENT_KEY = '@objectstack/cli:authored-source/accepted-argument'; + /** * One strict authoring factory that is not a `define*` helper: the function * `member` of the exported value `owner`, which validates its argument AT THE @@ -425,6 +452,12 @@ async function authoredSourceHelpersOf( * `composeStacks` wrap maps its inputs through: a recorded stack is produced * again by the real `defineStack` in its `strict: false` mode, and every other * input reaches the real `composeStacks` as it was passed. + * + * `__keepAuthored` is the other `defineStack`-only hook, for the call that + * SUCCEEDS: it keeps the argument beside the stack the real call returned, + * under {@link AUTHORED_ARGUMENT_KEY}. It runs outside the `try`, so it can + * never turn an accepted call into a refused one. A call takes exactly one of + * the two arms, so no argument is both handed through and kept. */ const AUTHORED_SOURCE_PRELUDE: readonly string[] = [ `const __refusal = (label, error) =>`, @@ -432,9 +465,10 @@ const AUTHORED_SOURCE_PRELUDE: readonly string[] = [ ` && typeof __specRoot.formatZodError === 'function'`, ` ? __specRoot.formatZodError(error, label + ' validation failed')`, ` : (error && error.message) || String(error);`, - `const __tolerant = (label, call, handOver) => (...authored) => {`, + `const __tolerant = (label, call, handOver, keep) => (...authored) => {`, + ` let built;`, ` try {`, - ` return call(...authored);`, + ` built = call(...authored);`, ` } catch (error) {`, ` console.warn(`, ` '[authored-source] ' + label + '(): the current schema refuses this '`, @@ -444,10 +478,20 @@ const AUTHORED_SOURCE_PRELUDE: readonly string[] = [ ` if (handOver) handOver(authored);`, ` return authored[0];`, ` }`, + ` if (keep) keep(built, authored);`, + ` return built;`, `};`, `const __recordStack = (authored) => {`, ` if (authored[0] !== null && typeof authored[0] === 'object') __handedThrough.set(authored[0], authored[1]);`, `};`, + `const __authoredKey = Symbol.for(${JSON.stringify(AUTHORED_ARGUMENT_KEY)});`, + `const __keepAuthored = (built, authored) => {`, + ` const source = authored[0];`, + ` if (built === null || typeof built !== 'object' || source === null || typeof source !== 'object') return;`, + ` if (built === source || !Object.isExtensible(built)) return;`, + ` if (Object.prototype.hasOwnProperty.call(built, __authoredKey)) return;`, + ` Object.defineProperty(built, __authoredKey, { value: source, enumerable: false, writable: false, configurable: false });`, + `};`, `const __composable = (stack) => (__handedThrough.has(stack)`, ` ? __specRoot.${STACK_PRODUCER}(stack, { ...__handedThrough.get(stack), strict: false })`, ` : stack);`, @@ -506,10 +550,12 @@ const AUTHORED_SOURCE_PRELUDE: readonly string[] = [ * The narrowness is the point, and it is what keeps this a restoration rather * than a widening of what the command accepts: * - * - **A source that loads today loads identically.** The real helper runs, so - * its defaults and transforms still apply (`defineForm` moves `schemaId` - * into `data`, `defineStack` merges actions into objects, …). Nothing about - * the existing happy path is re-decided. + * - **A source that loads today evaluates identically.** The real helper + * runs, so every value the module builds is the value it builds today, with + * each helper's defaults and transforms (`defineForm` moves `schemaId` into + * `data`, …). The one difference is which value the chain starts from: an + * accepted `defineStack` call's ARGUMENT, not its result (see "An accepted + * `defineStack` call" below). * - **A source the current schema refuses reaches the chain as authored** — * which is precisely the codemod's input. `defineX(config: z.input)` means the authored argument is by construction a shape @@ -525,6 +571,31 @@ const AUTHORED_SOURCE_PRELUDE: readonly string[] = [ * author deserves to know an artifact bypassed the parse, and stderr keeps a * `--json` run's stdout a single parseable document. * + * ## An accepted `defineStack` call: the chain starts from its argument + * + * A call the current schema ACCEPTS has already run the producer's load-time + * ADR-0087 D2 conversion pass when it returns. That pass runs in both of its + * modes, and no option skips it. So its result is canonical already. Handed + * to the chain as it was, a conversion the load still applies + * (`driver: 'mongo'`) had already happened: `applied` came back empty, + * `--write` wrote nothing, and every later load still printed the notice + * that sends the author to this command. + * + * So the shim keeps the argument beside the stack the call returned + * ({@link AUTHORED_ARGUMENT_KEY}), and {@link loadConfig} starts the default + * export from it. That argument is what `defineStack(config: z.input<…>)` + * accepted, so it is a well-formed authoring tree. The command still parses + * the MIGRATED stack and reports `schemaValid`. A refused call already hands + * its argument through as it is, so the two arms leave the chain one input + * shape: the stack the author wrote. + * + * ⚠️ A composed input does not get this. `composeStacks` builds each package + * body from the stack the input's `defineStack` call RETURNED, so the body is + * converted before the chain sees it. Recovering the authored body would mean + * assembling it again outside the producer: a second copy of composition's + * rule. Inside a composed project, a conversion the load still applies is + * still applied before the chain runs. + * * ## A composed project: the handed-through stack is produced again * * `composeStacks` is not a `define*` helper, so it runs for real, and its @@ -594,7 +665,7 @@ function authoredSourcePlugin(configPath: string): Plugin { ...AUTHORED_SOURCE_PRELUDE, ]; for (const name of defineHelpers) { - const handOver = name === STACK_PRODUCER ? ', __recordStack' : ''; + const handOver = name === STACK_PRODUCER ? ', __recordStack, __keepAuthored' : ''; lines.push( `export const ${name} = __tolerant(${JSON.stringify(name)}, (...authored) => __real.${name}(...authored)${handOver});`, ); @@ -621,11 +692,39 @@ export interface LoadConfigOptions { * Read the config as AUTHORED rather than as the current schema would have * it — see {@link authoredSourcePlugin}. Set by `os migrate meta` only. * + * Two things follow. A refused helper call hands its argument through. And a + * default export that an ACCEPTED `defineStack` call built is replaced by the + * argument that call was given ({@link authoredArgumentOf}), before the + * named exports are merged onto it, so `config` is the stack as written. The + * producer's mark and conversion record are still read off the built stack. + * * @default false */ authoredSource?: boolean; } +/** + * The argument an accepted `defineStack` call was given, read off the stack + * it returned — or `value` itself when the authored-source shim kept none + * there (a refused call's hand-through, a composed stack, a plain object, a + * load without the shim). + * + * Followed to the end, so `defineStack(defineStack({ … }))` answers the inner + * literal: each accepted call's result carries its own argument, and only the + * innermost one is what the author wrote. + */ +function authoredArgumentOf(value: unknown): unknown { + const key = Symbol.for(AUTHORED_ARGUMENT_KEY); + let current = value; + const seen = new Set(); + while (current !== null && typeof current === 'object' && !seen.has(current)) { + seen.add(current); + if (!Object.prototype.hasOwnProperty.call(current, key)) break; + current = (current as Record)[key]; + } + return current; +} + /** * Load and bundle a config file using bundle-require. * Returns the resolved config object, its load time, and the provenance of @@ -693,6 +792,11 @@ export async function loadConfig(source?: string, options?: LoadConfigOptions): // The producer's conversion record rides beside the mark and is dropped by // the same spread, so it is read here too, off the same value. const stackConversions = stackConversionsOf(baseConfig); + // `authoredSource`: the stack as WRITTEN. An accepted `defineStack` call has + // already converted its result at load, so the chain starts from the + // argument the shim kept beside it. Read off the same value for the same + // reason: the spread below drops the non-enumerable record too. + const authoredBase = options?.authoredSource ? authoredArgumentOf(baseConfig) : baseConfig; // Preserve named exports (e.g. the `onEnable` runtime hook and `functions`) // alongside the default-exported stack. Module-namespace named exports are @@ -707,9 +811,9 @@ export async function loadConfig(source?: string, options?: LoadConfigOptions): const namedExports: string[] = []; const shadowedNamedExports: string[] = []; const config = (baseConfig === mod || mod.default == null) - ? baseConfig + ? authoredBase : (() => { - const merged: any = { ...baseConfig }; + const merged: any = { ...(authoredBase as Record) }; for (const key of Object.keys(mod)) { if (key === 'default') continue; // ⛔ `hasOwnProperty`, never `key in merged` (#18419). `in` walks the From 93f524998a7683e00d0dc01fce1d32105f5b10bd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 19:35:20 +0000 Subject: [PATCH 2/4] 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 --- .../migrate-meta-load-conversions.test.ts | 322 ++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 packages/cli/test/migrate-meta-load-conversions.test.ts diff --git a/packages/cli/test/migrate-meta-load-conversions.test.ts b/packages/cli/test/migrate-meta-load-conversions.test.ts new file mode 100644 index 00000000000..6843d7ee08c --- /dev/null +++ b/packages/cli/test/migrate-meta-load-conversions.test.ts @@ -0,0 +1,322 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os migrate meta` on a stack whose `defineStack` call the current schema + * ACCEPTS, carrying a spelling the load still converts (`driver: 'mongo'`). + * + * ## The defect + * + * An accepted `defineStack` call runs the producer's load-time ADR-0087 D2 + * conversion pass before it returns. The authored-source shim handed that + * result to the chain, so the chain started from a stack that was already + * converted: `applied` came back empty, `--write` wrote nothing, and every + * later load still printed the notice that sends the author to this command. + * The fix keeps the call's argument beside its result, and the load starts + * the default export from it. + * + * ## What is pinned + * + * 1. The fix: the conversion is listed, `--write` writes it, the re-run + * applies nothing, and a strict load converts nothing any more. + * 2. Where the argument is read: off the default export, before the + * named-export merge (which spreads it into a new object), and only for + * the authored-source load. + * 3. A stack defined twice starts from the innermost argument, once. + * 4. The control: an already-canonical source writes nothing, and its + * `--json` summary equals the one the built stack gives. + * 5. A stack the schema refuses for one spelling while carrying a load-path + * spelling too: each conversion is applied once, and both are written. + * + * The strict reload is read through `stackConversions`, the producer's own + * record of what it converted, and not through the stderr notice: that notice + * is printed once per process, so a second load in this file would be silent + * whether or not it converted. + * + * In-process over the real command (`MigrateMeta.run`), against temp projects + * that link the real `@objectstack/spec`: no process is spawned and no kernel + * is booted, so this file sits in the `unit` tier. + */ + +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, relative, resolve } from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { stripVTControlCharacters } from 'node:util'; +import { ObjectStackDefinitionSchema, normalizeStackInput } from '@objectstack/spec'; +import MigrateMeta, { applyMetaMigrationsToPackages } from '../src/commands/migrate/meta.js'; +import { loadConfig } from '../src/utils/config.js'; + +const CLI_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..'); +const RUN_TIMEOUT = 120_000; + +/** `packages/cli` depends on `@objectstack/spec`; resolved as a package, not a source path. */ +const requireFromCli = createRequire(import.meta.url); +const SPEC_PACKAGE_ROOT = dirname(requireFromCli.resolve('@objectstack/spec/package.json')); + +let root: string; +let specLink: string; +let caseSeq = 0; + +function writeProject(files: Record): string { + const dir = join(root, `case-${++caseSeq}`); + for (const [rel, text] of Object.entries(files)) { + const path = join(dir, rel); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, text); + } + return dir; +} + +/** Every file under a project, relative path → bytes. */ +function snapshot(dir: string): Record { + const out: Record = {}; + const walk = (d: string) => { + for (const name of readdirSync(d)) { + const p = join(d, name); + if (statSync(p).isDirectory()) walk(p); + else out[relative(dir, p).split('\\').join('/')] = readFileSync(p, 'utf8'); + } + }; + walk(dir); + return out; +} + +/** `text` with `from` replaced by `to` — `from` must occur exactly once. */ +function edit(text: string, from: string, to: string): string { + const parts = text.split(from); + expect(parts.length, `expected exactly one occurrence of ${JSON.stringify(from)}`).toBe(2); + return parts.join(to); +} + +interface Run { + stdout: string; + stderr: string; + exitCode: number | undefined; +} + +/** Run the real command in-process, capturing both streams and any exit. */ +async function runMeta(dir: string, flags: string[]): Promise { + const out: string[] = []; + const err: string[] = []; + const priorExitCode = process.exitCode; + const write = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown, ...rest: unknown[]) => { + out.push(String(chunk)); + const done = rest.find((r) => typeof r === 'function') as (() => void) | undefined; + done?.(); + return true; + }) as never); + const log = vi.spyOn(console, 'log').mockImplementation((...a: unknown[]) => { out.push(a.join(' ')); }); + const warn = vi.spyOn(console, 'warn').mockImplementation((...a: unknown[]) => { err.push(a.join(' ')); }); + const error = vi.spyOn(console, 'error').mockImplementation((...a: unknown[]) => { err.push(a.join(' ')); }); + let exitCode: number | undefined; + try { + await MigrateMeta.run([join(dir, 'objectstack.config.ts'), ...flags], { root: CLI_ROOT }); + } catch (e: any) { + if (typeof e?.oclif?.exit !== 'number') throw e; + exitCode = e.oclif.exit; + } finally { + write.mockRestore(); + log.mockRestore(); + warn.mockRestore(); + error.mockRestore(); + if (exitCode === undefined && typeof process.exitCode === 'number' && process.exitCode !== 0) { + exitCode = process.exitCode; + } + process.exitCode = priorExitCode; + } + return { + stdout: stripVTControlCharacters(out.join('\n')), + stderr: stripVTControlCharacters(err.join('\n')), + exitCode, + }; +} + +/** A load with its stderr swallowed: the producer's notices are not what these pins read. */ +async function quietLoad(dir: string, authoredSource = false) { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + return await loadConfig(join(dir, 'objectstack.config.ts'), authoredSource ? { authoredSource: true } : undefined); + } finally { + warn.mockRestore(); + } +} + +const json = (run: Run): any => JSON.parse(run.stdout); +const sites = (list: Array<{ conversionId: string; path: string; from?: string; to?: string }>) => + list.map(({ conversionId, path, from, to }) => ({ conversionId, path, from, to })); +const conversionIds = (list: readonly { conversionId: string }[]) => list.map((c) => c.conversionId); + +beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'os-migrate-meta-load-conversions-')); + mkdirSync(join(root, 'node_modules', '@objectstack'), { recursive: true }); + specLink = join(root, 'node_modules', '@objectstack', 'spec'); + symlinkSync(SPEC_PACKAGE_ROOT, specLink, 'dir'); +}); + +afterAll(() => { + // Unlinked BEFORE the recursive remove: the symlink points at the real + // `packages/spec`, and a cleanup must never follow it. + try { unlinkSync(specLink); } catch { /* already gone */ } + try { rmSync(root, { recursive: true, force: true }); } catch { /* ignore */ } +}); + +/** + * A stack the current schema accepts. Its one non-canonical spelling is a + * datasource driver id the load still converts (`retiredFromLoadPath: false`); + * the `url` is what makes a mongo datasource valid, so the call is accepted. + */ +const MONGO_SOURCE = `import { defineStack } from '@objectstack/spec'; + +export default defineStack({ + manifest: { id: 'com.probe.svc', name: 'Probe Service', namespace: 'probe', version: '1.0.0', type: 'module' }, + objects: [ + { + name: 'probe_slot', + label: 'Slot', + fields: { + name: { type: 'text', label: 'Name' }, + }, + }, + ], + datasources: [{ name: 'docs', label: 'Docs', driver: 'mongo', config: { url: 'mongodb://mongo.internal:27017/docs' } }], +}); +`; + +const MONGO_CONVERSION = 'datasource-driver-mongo-to-mongodb'; +const MONGO_SITE = { conversionId: MONGO_CONVERSION, path: 'datasources[0].driver', from: 'mongo', to: 'mongodb' }; +const TIME_SITE = { + conversionId: 'time-default-utc-suffix-dropped', + path: 'objects[0].fields.starts_at.defaultValue', + from: '"10:00Z"', + to: '"10:00"', +}; + +describe('an accepted stack: the chain starts from what the author wrote', () => { + it('lists a conversion the load already applied; --write writes it, and the load stops converting', async () => { + const dir = writeProject({ 'objectstack.config.ts': MONGO_SOURCE }); + + // The control for the last reading below: today the strict load converts it. + const before = await quietLoad(dir); + expect(before.stackProvenance).toBe(true); + expect(conversionIds(before.stackConversions)).toEqual([MONGO_CONVERSION]); + + const dry = json(await runMeta(dir, ['--from', '16', '--json'])); + expect(sites(dry.applied)).toEqual([MONGO_SITE]); + expect(dry.schemaValid).toBe(true); + + const run = await runMeta(dir, ['--from', '16', '--write', '--json']); + expect(run.exitCode, run.stdout + run.stderr).toBeUndefined(); + const out = json(run); + expect(sites(out.applied)).toEqual([MONGO_SITE]); + expect(out.write.status).toBe('written'); + expect(out.write.files).toEqual([{ file: 'objectstack.config.ts', sites: 1 }]); + expect(out.write.manual).toEqual([]); + expect(out.write.unexplained).toEqual([]); + expect(readFileSync(join(dir, 'objectstack.config.ts'), 'utf8')).toBe( + edit(MONGO_SOURCE, "driver: 'mongo'", "driver: 'mongodb'"), + ); + + const again = json(await runMeta(dir, ['--from', '16', '--json'])); + expect(again.applied).toEqual([]); + expect(again.schemaValid).toBe(true); + + // The load every other command uses — strict, no shim — converts nothing now. + const after = await quietLoad(dir); + expect(after.stackProvenance).toBe(true); + expect(after.stackConversions).toEqual([]); + }, RUN_TIMEOUT); + + it('the authored-source load reads the argument off the default export, before the named exports are merged', async () => { + const dir = writeProject({ + 'objectstack.config.ts': `${MONGO_SOURCE}\nexport const onEnable = async () => {};\n`, + }); + + const authored = await quietLoad(dir, true); + expect(authored.config.datasources[0].driver).toBe('mongo'); + expect(authored.namedExports).toEqual(['onEnable']); + expect(typeof authored.config.onEnable).toBe('function'); + // The producer's mark and record are still read off the stack it built. + expect(authored.stackProvenance).toBe(true); + expect(conversionIds(authored.stackConversions)).toEqual([MONGO_CONVERSION]); + + // Every other command still reads the stack `defineStack` built. + const strict = await quietLoad(dir); + expect(strict.config.datasources[0].driver).toBe('mongodb'); + }, RUN_TIMEOUT); + + it('a stack defined twice starts from the innermost argument, and converts it once', async () => { + const dir = writeProject({ + 'objectstack.config.ts': edit( + edit(MONGO_SOURCE, 'export default defineStack({', 'export default defineStack(defineStack({'), + '\n});\n', + '\n}));\n', + ), + }); + const out = json(await runMeta(dir, ['--from', '16', '--json'])); + expect(sites(out.applied)).toEqual([MONGO_SITE]); + expect(out.schemaValid).toBe(true); + }, RUN_TIMEOUT); +}); + +describe('control: an already-canonical source', () => { + const CANONICAL = edit(MONGO_SOURCE, "driver: 'mongo'", "driver: 'mongodb'"); + + it('writes nothing, and its --json summary is the one the built stack gives', async () => { + const dir = writeProject({ 'objectstack.config.ts': CANONICAL }); + const before = snapshot(dir); + + const run = await runMeta(dir, ['--from', '16', '--write', '--json']); + expect(run.exitCode, run.stdout + run.stderr).toBeUndefined(); + const out = json(run); + expect(out.applied).toEqual([]); + expect(out.write).toEqual({ status: 'written', files: [], written: [], manual: [], unexplained: [] }); + expect(snapshot(dir)).toEqual(before); + + // The chain over the stack `defineStack` built — where it started before + // this change — reaches the same summary. + const built = (await quietLoad(dir)).config as Record; + const old = applyMetaMigrationsToPackages(normalizeStackInput(built, { convert: false }), out.from, out.to); + const plain = (value: unknown) => JSON.parse(JSON.stringify(value)); + expect(out.applied).toEqual(plain(old.applied)); + expect(out.todos).toEqual(plain(old.todos)); + expect(out.absentTodos).toEqual(plain(old.absentTodos)); + expect(out.schemaValid).toBe(ObjectStackDefinitionSchema.safeParse(old.stack).success); + }, RUN_TIMEOUT); +}); + +describe('a stack with one refused spelling and one load-path spelling', () => { + // The `time` default with the UTC suffix is refused by the current schema, + // so this call hands its argument through; the datasource is valid, so the + // driver id is the only load-path conversion in it. + const MIXED = edit( + MONGO_SOURCE, + " name: { type: 'text', label: 'Name' },\n", + " name: { type: 'text', label: 'Name' },\n starts_at: { type: 'time', label: 'Starts', defaultValue: '10:00Z' },\n", + ); + + it('applies each conversion once, and --write writes both', async () => { + const dir = writeProject({ 'objectstack.config.ts': MIXED }); + + const dry = json(await runMeta(dir, ['--from', '16', '--json'])); + expect(sites(dry.applied)).toEqual([MONGO_SITE, TIME_SITE]); + + const run = await runMeta(dir, ['--from', '16', '--write', '--json']); + expect(run.exitCode, run.stdout + run.stderr).toBeUndefined(); + const out = json(run); + expect(sites(out.applied)).toEqual([MONGO_SITE, TIME_SITE]); + expect(out.write.status).toBe('written'); + expect(out.write.files).toEqual([{ file: 'objectstack.config.ts', sites: 2 }]); + expect(readFileSync(join(dir, 'objectstack.config.ts'), 'utf8')).toBe( + edit(edit(MIXED, "defaultValue: '10:00Z'", "defaultValue: '10:00'"), "driver: 'mongo'", "driver: 'mongodb'"), + ); + + const again = json(await runMeta(dir, ['--from', '16', '--json'])); + expect(again.applied).toEqual([]); + + const strict = await quietLoad(dir); + expect(strict.stackProvenance).toBe(true); + expect(strict.stackConversions).toEqual([]); + }, RUN_TIMEOUT); +}); From 038ef735db2a12a40af332456cbc88b11663fd2e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 19:39:31 +0000 Subject: [PATCH 3/4] chore(changeset): @objectstack/cli patch for migrate meta over load-path conversions Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU Co-Authored-By: Claude --- .../22256-migrate-meta-load-path-conversions.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/22256-migrate-meta-load-path-conversions.md diff --git a/.changeset/22256-migrate-meta-load-path-conversions.md b/.changeset/22256-migrate-meta-load-path-conversions.md new file mode 100644 index 00000000000..a6119093069 --- /dev/null +++ b/.changeset/22256-migrate-meta-load-path-conversions.md @@ -0,0 +1,13 @@ +--- +'@objectstack/cli': patch +--- + +fix(cli): `os migrate meta` lists and writes a conversion the load already applies, on a stack the current schema accepts (#22256) + +Clause-②: no + +- **What was wrong.** When the current schema accepts a stack, `defineStack` runs its load-time ADR-0087 conversions while the config loads. `os migrate meta` then replayed its chain over a stack that was already converted. So a spelling the load still converts, such as `datasources[].driver: 'mongo'`, was never listed in `applied`, and `--write` never wrote it. Every later load kept printing `converted at load … Update the source to the canonical shape`, the notice that sends the author to this command, and the run exited 0. +- **What changed.** The chain now starts from the argument the stack's `defineStack` call was given. It already did this for a stack the schema refuses. The conversion is listed, `--write` writes it into the source, and the next load converts nothing. +- **What did not change.** A stack with nothing to convert gives the same `--json` summary and the same `--write` result as before. This was measured on the four example apps at `--from 16` and `--from 17`. Every other command still reads the stack `defineStack` builds. +- **The `--out` snapshot.** It is now the stack as written, with the chain's changes applied. That is what it already was for a stack the schema refuses. It no longer carries values the schema's parse fills in and the source never wrote, such as default keys and actions merged into their objects. +- **Known limit, unchanged.** Inside a `composeStacks([…])` project, each package body is assembled from the stack its input's `defineStack` call returned, whether that call accepted its input or was produced again in `strict: false` mode. So a conversion the load still applies inside a package body is still not listed, and `--write` still does not write it. From 2f8e479a39d79405f7c3b4e31a4a4e5430410f00 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 20:20:53 +0000 Subject: [PATCH 4/4] chore(changeset): drop a comparison the one-package fix makes false PR 22326's pending changeset ended its "Known limit" bullet by likening the composed case to an input the current schema accepts. A one-package accepted input's load-path conversion is now listed and written, so the clause is removed; the rest of the bullet is unchanged. Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU Co-Authored-By: Claude --- .changeset/22289-migrate-meta-composed-project.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/22289-migrate-meta-composed-project.md b/.changeset/22289-migrate-meta-composed-project.md index 0827fc9b3db..632bfbf7ec8 100644 --- a/.changeset/22289-migrate-meta-composed-project.md +++ b/.changeset/22289-migrate-meta-composed-project.md @@ -9,4 +9,4 @@ Clause-②: no - **What changed.** On a project whose config exports `composeStacks([defineStack({ … }), …], { manifest: 'preserve' })`, `os migrate meta --from N` used to exit 1 with `STACK_PROVENANCE_MISSING`, telling the author to wrap each input in `defineStack`, as soon as any input carried a spelling the current schema refuses. Every input already was wrapped. The command now loads such a project: an input whose `defineStack` call the current schema refuses is handed to `composeStacks` as `defineStack(input, { strict: false })` returns it, and composition runs as it does at build time. - **The package bodies are migrated.** A composed artifact keeps each definition under the package that owns it (`packages[i].manifest`), and the migration chain used to reach only the stack's own top level. It now also runs over each package body, and lists each change under the body's path, such as `packages[0].manifest.objects[0].fields.starts_at.defaultValue`. `--write` writes a change in package i into the file that authored input i only when input i and every input before it in the `composeStacks([…])` list is a stack literal (a `defineStack({ … })` call on an object literal, written in place or reached through a `const` or an import) that writes its own `manifest` and no `packages`. Only then is package i that one input's body. Otherwise `--write` lists the change with the reason, as it does for any site it cannot trace. - **What did not change.** A one-package project loads, migrates and writes exactly as before. An input that was never built by `defineStack` (a plain object, or a spread of a built stack) is still refused with `STACK_PROVENANCE_MISSING`. -- **Known limit.** Inside a composed project, a conversion the load still applies (for example `datasources[].driver: 'mongo'`) is applied while the input is composed. So the chain does not list it and `--write` does not write it, the same as for an input the current schema accepts. +- **Known limit.** Inside a composed project, a conversion the load still applies (for example `datasources[].driver: 'mongo'`) is applied while the input is composed. So the chain does not list it and `--write` does not write it.