|
| 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