@metaobjectsdev/cli 0.11.6 → 0.12.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +17 -3
  2. package/dist/src/commands/gen.d.ts +2 -1
  3. package/dist/src/commands/gen.d.ts.map +1 -1
  4. package/dist/src/commands/gen.js +8 -4
  5. package/dist/src/commands/gen.js.map +1 -1
  6. package/dist/src/commands/migrate.d.ts +4 -3
  7. package/dist/src/commands/migrate.d.ts.map +1 -1
  8. package/dist/src/commands/migrate.js +304 -206
  9. package/dist/src/commands/migrate.js.map +1 -1
  10. package/dist/src/index.d.ts.map +1 -1
  11. package/dist/src/index.js +182 -7
  12. package/dist/src/index.js.map +1 -1
  13. package/dist/src/lib/args.d.ts +8 -0
  14. package/dist/src/lib/args.d.ts.map +1 -1
  15. package/dist/src/lib/args.js +21 -0
  16. package/dist/src/lib/args.js.map +1 -1
  17. package/dist/src/lib/format.d.ts +8 -0
  18. package/dist/src/lib/format.d.ts.map +1 -0
  19. package/dist/src/lib/format.js +18 -0
  20. package/dist/src/lib/format.js.map +1 -0
  21. package/dist/src/lib/kysely.d.ts.map +1 -1
  22. package/dist/src/lib/kysely.js +5 -2
  23. package/dist/src/lib/kysely.js.map +1 -1
  24. package/dist/src/lib/output-json.d.ts +4 -0
  25. package/dist/src/lib/output-json.d.ts.map +1 -0
  26. package/dist/src/lib/output-json.js +8 -0
  27. package/dist/src/lib/output-json.js.map +1 -0
  28. package/dist/src/lib/output.d.ts +23 -0
  29. package/dist/src/lib/output.d.ts.map +1 -1
  30. package/dist/src/lib/output.js +88 -0
  31. package/dist/src/lib/output.js.map +1 -1
  32. package/dist/src/lib/pm-detect.d.ts +12 -0
  33. package/dist/src/lib/pm-detect.d.ts.map +1 -0
  34. package/dist/src/lib/pm-detect.js +52 -0
  35. package/dist/src/lib/pm-detect.js.map +1 -0
  36. package/package.json +17 -20
  37. package/src/commands/gen.ts +10 -4
  38. package/src/commands/migrate.ts +134 -10
  39. package/src/index.ts +183 -7
  40. package/src/lib/args.ts +34 -0
  41. package/src/lib/format.ts +23 -0
  42. package/src/lib/kysely.ts +5 -2
  43. package/src/lib/output-json.ts +10 -0
  44. package/src/lib/output.ts +100 -0
  45. package/src/lib/pm-detect.ts +53 -0
@@ -3,7 +3,9 @@ import { mkdir } from "node:fs/promises";
3
3
  import { spawn } from "node:child_process";
4
4
  import { parseMigrateArgs } from "../lib/args.js";
5
5
  import { resolveMigrateConfig, MIGRATE_DEFAULT_OUT_DIR } from "../lib/config.js";
6
- import { formatMigrateResult } from "../lib/output.js";
6
+ import { formatMigrateResult, formatMigrateResultToon } from "../lib/output.js";
7
+ import { formatMigrateResultJson } from "../lib/output-json.js";
8
+ import { toonEncode } from "../lib/format.js";
7
9
  import { buildKyselyFromUrl } from "../lib/kysely.js";
8
10
  import { log } from "../lib/log.js";
9
11
  import { loadMemory } from "@metaobjectsdev/sdk";
@@ -12,6 +14,64 @@ import { buildExpectedSchema, introspect, diff, emit, writeMigration, baselineFr
12
14
  import { buildWranglerExecuteArgs, defaultWranglerRunner, } from "../lib/wrangler.js";
13
15
  import { buildProjectionViews } from "@metaobjectsdev/codegen-ts";
14
16
  import { tokensToAllowOptions, describeChange } from "../lib/allow.js";
17
+ const MIGRATE_HELP_TEXT = `meta migrate — diff metadata vs live DB; emit migration SQL files
18
+
19
+ USAGE:
20
+ meta migrate [baseline] [flags]
21
+
22
+ SUBCOMMANDS:
23
+ baseline Seed the committed reference snapshot (no migration emitted).
24
+ Required before the first offline generate.
25
+
26
+ MIGRATE FLAGS:
27
+ --db <url> DB connection URL (required for live-introspect / --apply / --rollback)
28
+ Supports: file:, libsql:, postgres:, postgresql:
29
+ --dialect sqlite|postgres|d1
30
+ Optional dialect override (auto-detected from URL scheme)
31
+ --out-dir <path> Migration directory (default: ./.metaobjects/migrations)
32
+ --slug <name> Required when changes are present (e.g., --slug add-user-shipping)
33
+ --allow <csv> Comma-separated destructive-change permissions:
34
+ drop-column,drop-table,type-change,drop-index,drop-fk,nullable-to-not-null
35
+ --on-ambiguous abort|rename|drop-add
36
+ How to handle ambiguous renames (default: abort)
37
+ --from-db Introspect live DB instead of using the committed snapshot
38
+ --apply Run pending migration files against the DB after writing
39
+ --rollback <target> Roll back applied migrations newer than <target>
40
+ --d1 <binding> D1 binding name from wrangler.toml (only with --dialect d1)
41
+ --remote Target remote D1 instead of local (only with --dialect d1)
42
+ --yes Skip the --remote --apply confirmation pause
43
+ --dry-run Print SQL to stdout, don't write
44
+ --help, -h Print this help
45
+
46
+ EXAMPLES:
47
+ meta migrate baseline --dialect sqlite
48
+ meta migrate --dialect sqlite --slug add-users
49
+ meta migrate --db file:local.db --slug add-orders
50
+ meta migrate --db postgresql://localhost/mydb --slug add-index --apply
51
+ `;
52
+ /** Emit a structured error on stdout (not stderr) in the active format, per axi. */
53
+ function emitStructuredError(error, hint, fmt) {
54
+ const payload = { error, hint };
55
+ if (fmt === "json") {
56
+ log.info(JSON.stringify(payload, null, 2));
57
+ }
58
+ else if (fmt === "toon") {
59
+ log.info(toonEncode(payload));
60
+ }
61
+ // text format: errors go to stderr via log.error() — the caller handles that path
62
+ }
63
+ /**
64
+ * Sentinel thrown by sub-functions that have already emitted a structured error
65
+ * via emitStructuredError(). The top-level catch in migrateCommand re-throws
66
+ * this as-is without double-emitting.
67
+ */
68
+ class AlreadyEmittedError extends Error {
69
+ exitCode;
70
+ constructor(exitCode) {
71
+ super("already-emitted");
72
+ this.exitCode = exitCode;
73
+ }
74
+ }
15
75
  function mapOnAmbiguous(v) {
16
76
  return v === "drop-add" ? "drop+add" : v;
17
77
  }
@@ -58,264 +118,300 @@ function ambiguousToEntries(amb) {
58
118
  }
59
119
  export async function migrateCommand(args, cwd,
60
120
  /** Injectable wrangler runner — tests pass a mock; production uses the default. */
61
- wranglerRunner) {
121
+ wranglerRunner, fmt = "text") {
122
+ // Intercept --help / -h before parseMigrateArgs (parseArgs strict mode rejects them).
123
+ if (args.includes("--help") || args.includes("-h")) {
124
+ log.info(MIGRATE_HELP_TEXT);
125
+ return 0;
126
+ }
62
127
  let flags;
63
128
  try {
64
129
  flags = parseMigrateArgs(args);
65
130
  }
66
131
  catch (err) {
67
- log.error(`migrate: ${err.message}`);
132
+ const msg = err.message;
133
+ log.error(`migrate: ${msg}`);
134
+ emitStructuredError(`migrate: ${msg}`, "run `meta migrate --help` for usage", fmt);
68
135
  return 2;
69
136
  }
70
137
  const metaRoot = cwd;
71
138
  const config = await resolveMigrateConfig(flags, metaRoot);
72
- if (config.dialect === "d1") {
139
+ try {
140
+ if (config.dialect === "d1") {
141
+ if (config.baseline) {
142
+ log.error(`migrate baseline is not supported for dialect 'd1' (snapshots are a postgres/sqlite concept)`);
143
+ emitStructuredError(`migrate baseline is not supported for dialect 'd1'`, "drop 'baseline' for d1 — snapshots are a postgres/sqlite concept", fmt);
144
+ return 2;
145
+ }
146
+ if (config.databaseUrl !== undefined) {
147
+ log.error(`migrate: --db / DATABASE_URL is not used for dialect 'd1' — wrangler.toml owns connection`);
148
+ emitStructuredError(`migrate: --db / DATABASE_URL is not used for dialect 'd1'`, "remove --db / DATABASE_URL for d1 — wrangler.toml owns the connection", fmt);
149
+ return 2;
150
+ }
151
+ if (config.rollback !== undefined) {
152
+ log.error(`migrate: --rollback is not supported for dialect 'd1' (use 'wrangler d1 migrations' tooling)`);
153
+ emitStructuredError(`migrate: --rollback is not supported for dialect 'd1'`, "use 'wrangler d1 migrations' tooling to roll back d1", fmt);
154
+ return 2;
155
+ }
156
+ return await runD1Migrate(config, metaRoot, wranglerRunner ?? defaultWranglerRunner, fmt);
157
+ }
158
+ // `migrate baseline` — seed the committed reference snapshot, emit no migration.
73
159
  if (config.baseline) {
74
- log.error(`migrate baseline is not supported for dialect 'd1' (snapshots are a postgres/sqlite concept)`);
75
- return 2;
160
+ return await runBaseline(config, metaRoot, fmt);
161
+ }
162
+ // Default = offline snapshot generation. The live-introspection path runs only
163
+ // when explicitly requested via --from-db, when --apply needs a connection, or
164
+ // for --rollback (which runs hand-authored down.sql against the live DB).
165
+ if (!config.fromDb && !config.apply && config.rollback === undefined) {
166
+ return await runOfflineGenerate(config, metaRoot, fmt);
76
167
  }
77
- if (config.databaseUrl !== undefined) {
78
- log.error(`migrate: --db / DATABASE_URL is not used for dialect 'd1' wrangler.toml owns connection`);
168
+ if (config.databaseUrl === undefined) {
169
+ log.error(`migrate: --db <url> required (or set DATABASE_URL, or add migrate.databaseUrl to .metaobjects/config.json)`);
170
+ emitStructuredError(`migrate: --db <url> required`, "pass --db <url>, set DATABASE_URL, or add migrate.databaseUrl to .metaobjects/config.json", fmt);
79
171
  return 2;
80
172
  }
173
+ // --rollback short-circuits the diff/emit pipeline: it runs the down.sql of
174
+ // every applied migration NEWER than <target> (target retained), in reverse
175
+ // order, ledger-tracked + advisory-locked. postgres/sqlite only.
81
176
  if (config.rollback !== undefined) {
82
- log.error(`migrate: --rollback is not supported for dialect 'd1' (use 'wrangler d1 migrations' tooling)`);
83
- return 2;
177
+ return await runRollback(config, metaRoot);
84
178
  }
85
- return await runD1Migrate(config, metaRoot, wranglerRunner ?? defaultWranglerRunner);
86
- }
87
- // `migrate baseline` seed the committed reference snapshot, emit no migration.
88
- if (config.baseline) {
89
- return await runBaseline(config, metaRoot);
90
- }
91
- // Default = offline snapshot generation. The live-introspection path runs only
92
- // when explicitly requested via --from-db, when --apply needs a connection, or
93
- // for --rollback (which runs hand-authored down.sql against the live DB).
94
- if (!config.fromDb && !config.apply && config.rollback === undefined) {
95
- return await runOfflineGenerate(config, metaRoot);
96
- }
97
- if (config.databaseUrl === undefined) {
98
- log.error(`migrate: --db <url> required (or set DATABASE_URL, or add migrate.databaseUrl to .metaobjects/config.json)`);
99
- return 2;
100
- }
101
- // --rollback short-circuits the diff/emit pipeline: it runs the down.sql of
102
- // every applied migration NEWER than <target> (target retained), in reverse
103
- // order, ledger-tracked + advisory-locked. postgres/sqlite only.
104
- if (config.rollback !== undefined) {
105
- return await runRollback(config, metaRoot);
106
- }
107
- // Best-effort load of metaobjects.config.ts to pick up consumer-supplied
108
- // providers. migrate's postgres/sqlite path also reads the config later
109
- // for columnNamingStrategy; we load it once here and reuse below.
110
- let postgresConfigProviders;
111
- try {
112
- const forgeConfig = await loadMetaobjectsConfig(metaRoot);
113
- postgresConfigProviders = forgeConfig.providers;
114
- }
115
- catch {
116
- postgresConfigProviders = undefined;
117
- }
118
- let metadata;
119
- try {
120
- metadata = await loadMemory(metaRoot, {
121
- ...(postgresConfigProviders !== undefined ? { providers: postgresConfigProviders } : {}),
122
- });
123
- }
124
- catch (err) {
125
- const msg = err.message;
126
- if (msg.includes("ENOENT") || msg.includes("no such") || msg.includes("cannot read")) {
127
- log.error(`no metaobjects/ found in ${metaRoot}; run 'meta init' to scaffold`);
179
+ // Best-effort load of metaobjects.config.ts to pick up consumer-supplied
180
+ // providers. migrate's postgres/sqlite path also reads the config later
181
+ // for columnNamingStrategy; we load it once here and reuse below.
182
+ let postgresConfigProviders;
183
+ try {
184
+ const forgeConfig = await loadMetaobjectsConfig(metaRoot);
185
+ postgresConfigProviders = forgeConfig.providers;
128
186
  }
129
- else {
130
- log.error(`failed to load metadata: ${msg}`);
187
+ catch {
188
+ postgresConfigProviders = undefined;
131
189
  }
132
- return 2;
133
- }
134
- let kysely;
135
- try {
136
- kysely = await buildKyselyFromUrl(config.databaseUrl, config.dialect);
137
- }
138
- catch (err) {
139
- log.error(`migrate: ${err.message}`);
140
- return 2;
141
- }
142
- let exitCode = 0;
143
- let writtenPaths = [];
144
- let appliedNames = [];
145
- let blocked = [];
146
- let ambiguous = [];
147
- let changeCounts = {};
148
- try {
149
- // Column-naming strategy (from metaobjects.config) drives BOTH the table schema
150
- // and projection view DDL — derive it once, up front, so every view path agrees.
151
- let columnNamingStrategy = "snake_case";
190
+ let metadata;
152
191
  try {
153
- const cfg = await loadMetaobjectsConfig(metaRoot);
154
- if (cfg.columnNamingStrategy)
155
- columnNamingStrategy = cfg.columnNamingStrategy;
192
+ metadata = await loadMemory(metaRoot, {
193
+ ...(postgresConfigProviders !== undefined ? { providers: postgresConfigProviders } : {}),
194
+ });
156
195
  }
157
- catch {
158
- // metaobjects.config.ts absent or invalid — use default snake_case
159
- }
160
- // Expected views from the SINGLE view-SQL source (codegen-ts emitViewDdl, via
161
- // buildProjectionViews). Threaded into the schema-diff so the diff produces all
162
- // view DDL (create/drop/replace + dependency-recreate) and emit() renders it —
163
- // there is no separate view-migration emitter.
164
- const expectedViews = buildProjectionViews(metadata, { dialect: kysely.dialect, columnNamingStrategy });
165
- const expected = buildExpectedSchema(metadata, {
166
- dialect: kysely.dialect,
167
- columnNamingStrategy,
168
- views: expectedViews,
169
- });
170
- let actual;
196
+ catch (err) {
197
+ const msg = err.message;
198
+ if (msg.includes("ENOENT") || msg.includes("no such") || msg.includes("cannot read")) {
199
+ log.error(`no metaobjects/ found in ${metaRoot}; run 'meta init' to scaffold`);
200
+ }
201
+ else {
202
+ log.error(`failed to load metadata: ${msg}`);
203
+ }
204
+ return 2;
205
+ }
206
+ let kysely;
171
207
  try {
172
- actual = await introspect(kysely.db, kysely.dialect);
208
+ kysely = await buildKyselyFromUrl(config.databaseUrl, config.dialect);
173
209
  }
174
210
  catch (err) {
175
- log.error(`migrate: failed to connect to ${kysely.displayUrl}: ${err.message}`);
176
- await kysely.close();
211
+ log.error(`migrate: ${err.message}`);
177
212
  return 2;
178
213
  }
179
- const collectedAmbiguous = [];
180
- const onAmbiguousResolution = mapOnAmbiguous(config.onAmbiguous);
181
- let diffResult;
214
+ let exitCode = 0;
215
+ let writtenPaths = [];
216
+ let appliedNames = [];
217
+ let applyFailed = false;
218
+ let blocked = [];
219
+ let ambiguous = [];
220
+ let changeCounts = {};
182
221
  try {
183
- diffResult = await diff({
184
- expected,
185
- actual,
222
+ // Column-naming strategy (from metaobjects.config) drives BOTH the table schema
223
+ // and projection view DDL — derive it once, up front, so every view path agrees.
224
+ let columnNamingStrategy = "snake_case";
225
+ try {
226
+ const cfg = await loadMetaobjectsConfig(metaRoot);
227
+ if (cfg.columnNamingStrategy)
228
+ columnNamingStrategy = cfg.columnNamingStrategy;
229
+ }
230
+ catch {
231
+ // metaobjects.config.ts absent or invalid — use default snake_case
232
+ }
233
+ // Expected views from the SINGLE view-SQL source (codegen-ts emitViewDdl, via
234
+ // buildProjectionViews). Threaded into the schema-diff so the diff produces all
235
+ // view DDL (create/drop/replace + dependency-recreate) and emit() renders it —
236
+ // there is no separate view-migration emitter.
237
+ const expectedViews = buildProjectionViews(metadata, { dialect: kysely.dialect, columnNamingStrategy });
238
+ const expected = buildExpectedSchema(metadata, {
186
239
  dialect: kysely.dialect,
187
- allow: tokensToAllowOptions(config.allow),
188
- onAmbiguous: async (a) => {
189
- collectedAmbiguous.push(a);
190
- return onAmbiguousResolution;
191
- },
240
+ columnNamingStrategy,
241
+ views: expectedViews,
192
242
  });
193
- }
194
- catch (err) {
195
- // diff() throws when onAmbiguous returns "abort" — surface as exit 1
196
- // with the collected ambiguity list.
197
- if (err.message.includes("aborted by onAmbiguous")) {
198
- ambiguous = ambiguousToEntries(collectedAmbiguous);
199
- const output = formatMigrateResult({
200
- dialect: kysely.dialect,
201
- displayUrl: kysely.displayUrl,
202
- changeCounts: {},
203
- blocked: [],
204
- ambiguous,
205
- writtenPaths: [],
206
- dryRun: config.dryRun,
207
- }, { isTTY: !!process.stdout.isTTY });
208
- log.info(output);
243
+ let actual;
244
+ try {
245
+ actual = await introspect(kysely.db, kysely.dialect);
246
+ }
247
+ catch (err) {
248
+ log.error(`migrate: failed to connect to ${kysely.displayUrl}: ${err.message}`);
209
249
  await kysely.close();
210
- return 1;
250
+ return 2;
211
251
  }
212
- throw err;
213
- }
214
- changeCounts = summarizeChanges(diffResult.changes);
215
- // All changes — tables AND views — are emitted by the one schema-diff path.
216
- // View DDL (create/drop/replace) is produced by diff()'s view passes (2b body
217
- // comparison, 2c dependency-recreate) and rendered by every dialect's emitter;
218
- // STAGE_ORDER sequences drop-view before and create-view after any column change
219
- // a view reads. There is no separate view-migration emitter, and unchanged views
220
- // produce no change (introspect reads the actual body, diff compares it).
221
- if (diffResult.changes.length === 0) {
222
- // no-op — output will say "No schema changes"
223
- }
224
- else {
225
- let emitted;
252
+ const collectedAmbiguous = [];
253
+ const onAmbiguousResolution = mapOnAmbiguous(config.onAmbiguous);
254
+ let diffResult;
226
255
  try {
227
- emitted = emit(diffResult.changes, {
256
+ diffResult = await diff({
257
+ expected,
258
+ actual,
228
259
  dialect: kysely.dialect,
229
- expectedSchema: expected,
230
- ...(actual.meta !== undefined ? { actualMeta: actual.meta } : {}),
260
+ allow: tokensToAllowOptions(config.allow),
261
+ onAmbiguous: async (a) => {
262
+ collectedAmbiguous.push(a);
263
+ return onAmbiguousResolution;
264
+ },
231
265
  });
232
266
  }
233
267
  catch (err) {
234
- if (err instanceof BlockedChangesError) {
235
- blocked = blockedToEntries(err);
236
- exitCode = 1;
237
- }
238
- else {
239
- throw err;
268
+ // diff() throws when onAmbiguous returns "abort" — surface as exit 1
269
+ // with the collected ambiguity list.
270
+ if (err.message.includes("aborted by onAmbiguous")) {
271
+ ambiguous = ambiguousToEntries(collectedAmbiguous);
272
+ const migrateResult = {
273
+ dialect: kysely.dialect,
274
+ displayUrl: kysely.displayUrl,
275
+ changeCounts: {},
276
+ blocked: [],
277
+ ambiguous,
278
+ writtenPaths: [],
279
+ dryRun: config.dryRun,
280
+ applied: [],
281
+ applyFailed: false,
282
+ };
283
+ const output = fmt === "toon" ? formatMigrateResultToon(migrateResult)
284
+ : fmt === "json" ? formatMigrateResultJson(migrateResult)
285
+ : formatMigrateResult(migrateResult, { isTTY: !!process.stdout.isTTY });
286
+ log.info(output);
287
+ await kysely.close();
288
+ return 1;
240
289
  }
290
+ throw err;
241
291
  }
242
- if (exitCode === 0 && emitted) {
243
- if (config.slug === undefined) {
244
- log.error(`migrate: --slug <name> required when there are changes (e.g., --slug add-user-shipping)`);
245
- await kysely.close();
246
- return 2;
292
+ changeCounts = summarizeChanges(diffResult.changes);
293
+ // All changes tables AND views — are emitted by the one schema-diff path.
294
+ // View DDL (create/drop/replace) is produced by diff()'s view passes (2b body
295
+ // comparison, 2c dependency-recreate) and rendered by every dialect's emitter;
296
+ // STAGE_ORDER sequences drop-view before and create-view after any column change
297
+ // a view reads. There is no separate view-migration emitter, and unchanged views
298
+ // produce no change (introspect reads the actual body, diff compares it).
299
+ if (diffResult.changes.length === 0) {
300
+ // no-op — output will say "No schema changes"
301
+ }
302
+ else {
303
+ let emitted;
304
+ try {
305
+ emitted = emit(diffResult.changes, {
306
+ dialect: kysely.dialect,
307
+ expectedSchema: expected,
308
+ ...(actual.meta !== undefined ? { actualMeta: actual.meta } : {}),
309
+ });
247
310
  }
248
- if (config.dryRun) {
249
- log.info(`-- UP --\n${emitted.up}\n\n-- DOWN --\n${emitted.down}`);
311
+ catch (err) {
312
+ if (err instanceof BlockedChangesError) {
313
+ blocked = blockedToEntries(err);
314
+ exitCode = 1;
315
+ }
316
+ else {
317
+ throw err;
318
+ }
250
319
  }
251
- else {
252
- const outDir = resolvePath(metaRoot, config.outDir);
253
- await mkdir(outDir, { recursive: true });
254
- const res = await writeMigration({ up: emitted.up, down: emitted.down }, { dir: outDir, slug: config.slug });
255
- writtenPaths = [res.upPath, res.downPath];
256
- if (config.fromDb) {
257
- log.info(`migrate: --from-db did not advance the committed snapshot; run 'meta migrate baseline --from-db' to re-sync`);
320
+ if (exitCode === 0 && emitted) {
321
+ if (config.slug === undefined) {
322
+ log.error(`migrate: --slug <name> required when there are changes (e.g., --slug add-user-shipping)`);
323
+ await kysely.close();
324
+ return 2;
325
+ }
326
+ if (config.dryRun) {
327
+ log.info(`-- UP --\n${emitted.up}\n\n-- DOWN --\n${emitted.down}`);
258
328
  }
329
+ else {
330
+ const outDir = resolvePath(metaRoot, config.outDir);
331
+ await mkdir(outDir, { recursive: true });
332
+ const res = await writeMigration({ up: emitted.up, down: emitted.down }, { dir: outDir, slug: config.slug });
333
+ writtenPaths = [res.upPath, res.downPath];
334
+ if (config.fromDb) {
335
+ log.info(`migrate: --from-db did not advance the committed snapshot; run 'meta migrate baseline --from-db' to re-sync`);
336
+ }
337
+ }
338
+ }
339
+ }
340
+ // --apply: run pending committed migration files against the DB, tracked by
341
+ // the migration-history ledger, transactionally. Idempotency comes from the
342
+ // ledger (skip already-applied), NOT from re-diffing — so this also applies
343
+ // any previously-written-but-unapplied files in this run. Skipped on dry-run
344
+ // and when a prior step set a non-zero exit (e.g. blocked changes).
345
+ if (config.apply && exitCode === 0 && !config.dryRun) {
346
+ const outDir = resolvePath(metaRoot, config.outDir);
347
+ try {
348
+ // applyPending calls ensureLedger internally (idempotent), so no need
349
+ // to ensure it here. Pass the dialect so postgres gets schema-qualified
350
+ // ledger DDL + the session advisory lock (sqlite is a no-op there).
351
+ const result = await applyPending(kysely.db, outDir, {
352
+ dryRun: false,
353
+ dialect: kysely.dialect,
354
+ });
355
+ appliedNames = [...result.applied];
356
+ }
357
+ catch (err) {
358
+ log.error(`migrate: apply failed: ${err.message}`);
359
+ exitCode = 1;
360
+ applyFailed = true;
259
361
  }
260
362
  }
261
363
  }
262
- // --apply: run pending committed migration files against the DB, tracked by
263
- // the migration-history ledger, transactionally. Idempotency comes from the
264
- // ledger (skip already-applied), NOT from re-diffing — so this also applies
265
- // any previously-written-but-unapplied files in this run. Skipped on dry-run
266
- // and when a prior step set a non-zero exit (e.g. blocked changes).
267
- if (config.apply && exitCode === 0 && !config.dryRun) {
268
- const outDir = resolvePath(metaRoot, config.outDir);
364
+ finally {
269
365
  try {
270
- // applyPending calls ensureLedger internally (idempotent), so no need
271
- // to ensure it here. Pass the dialect so postgres gets schema-qualified
272
- // ledger DDL + the session advisory lock (sqlite is a no-op there).
273
- const result = await applyPending(kysely.db, outDir, {
274
- dryRun: false,
275
- dialect: kysely.dialect,
276
- });
277
- appliedNames = [...result.applied];
366
+ await kysely.close();
278
367
  }
279
368
  catch (err) {
280
- log.error(`migrate: apply failed: ${err.message}`);
281
- exitCode = 1;
369
+ log.warn(`migrate: failed to close DB cleanly: ${err.message}`);
282
370
  }
283
371
  }
284
- }
285
- finally {
286
- try {
287
- await kysely.close();
288
- }
289
- catch (err) {
290
- log.warn(`migrate: failed to close DB cleanly: ${err.message}`);
372
+ const migrateResult = {
373
+ dialect: kysely.dialect,
374
+ displayUrl: kysely.displayUrl,
375
+ changeCounts,
376
+ blocked,
377
+ ambiguous,
378
+ writtenPaths,
379
+ dryRun: config.dryRun,
380
+ applied: appliedNames,
381
+ applyFailed,
382
+ };
383
+ const output = fmt === "toon" ? formatMigrateResultToon(migrateResult)
384
+ : fmt === "json" ? formatMigrateResultJson(migrateResult)
385
+ : formatMigrateResult(migrateResult, { isTTY: !!process.stdout.isTTY });
386
+ log.info(output);
387
+ if (config.apply && exitCode === 0) {
388
+ if (appliedNames.length > 0) {
389
+ log.info(`migrate: applied ${appliedNames.length} migration(s): ${appliedNames.join(", ")}`);
390
+ }
391
+ else {
392
+ log.info(`migrate: no pending migrations to apply`);
393
+ }
291
394
  }
395
+ return exitCode;
292
396
  }
293
- const output = formatMigrateResult({
294
- dialect: kysely.dialect,
295
- displayUrl: kysely.displayUrl,
296
- changeCounts,
297
- blocked,
298
- ambiguous,
299
- writtenPaths,
300
- dryRun: config.dryRun,
301
- }, { isTTY: !!process.stdout.isTTY });
302
- log.info(output);
303
- if (config.apply && exitCode === 0) {
304
- if (appliedNames.length > 0) {
305
- log.info(`migrate: applied ${appliedNames.length} migration(s): ${appliedNames.join(", ")}`);
306
- }
307
- else {
308
- log.info(`migrate: no pending migrations to apply`);
309
- }
397
+ catch (err) {
398
+ // AlreadyEmittedError: sub-function already called emitStructuredError — just
399
+ // propagate the exit code without double-emitting.
400
+ if (err instanceof AlreadyEmittedError)
401
+ return err.exitCode;
402
+ // Unexpected error: emit structured error on stdout in the active format, then exit 1.
403
+ const msg = err.message ?? String(err);
404
+ log.error(`migrate: unexpected error: ${msg}`);
405
+ emitStructuredError(`migrate: unexpected error: ${msg}`, "run `meta migrate --help` for usage", fmt);
406
+ return 1;
310
407
  }
311
- return exitCode;
312
408
  }
313
409
  /**
314
410
  * `meta migrate baseline [--from-db]` — seed the committed reference snapshot.
315
411
  * `--from-metadata` (default) derives it from metadata; `--from-db` introspects
316
412
  * an existing database once. Emits no migration.
317
413
  */
318
- export async function runBaseline(config, metaRoot) {
414
+ export async function runBaseline(config, metaRoot, _fmt = "text") {
319
415
  if (config.dialect === undefined) {
320
416
  log.error(`migrate baseline: --dialect required (or set migrate.dialect in .metaobjects/config.json)`);
321
417
  return 2;
@@ -380,7 +476,7 @@ export async function runBaseline(config, metaRoot) {
380
476
  * Scope: table/column/index/FK changes. Projection-view migrations stay on the
381
477
  * introspection path (offline-view parity is a follow-up).
382
478
  */
383
- export async function runOfflineGenerate(config, metaRoot) {
479
+ export async function runOfflineGenerate(config, metaRoot, fmt = "text") {
384
480
  if (config.dialect === undefined) {
385
481
  log.error(`migrate: --dialect required for offline generation (or use --from-db)`);
386
482
  return 2;
@@ -404,7 +500,9 @@ export async function runOfflineGenerate(config, metaRoot) {
404
500
  return 2;
405
501
  }
406
502
  if (snapshot === null) {
407
- log.error(`migrate: no schema snapshot at ${path}; run 'meta migrate baseline' first`);
503
+ log.error(`migrate: no schema snapshot at ${path}; run \`meta migrate baseline --dialect ${config.dialect}\` first`);
504
+ // Structured next-step on stdout so callers / agents can parse it, in the active format.
505
+ emitStructuredError("no schema snapshot", `first run \`meta migrate baseline --dialect ${config.dialect}\``, fmt);
408
506
  return 2;
409
507
  }
410
508
  const collectedAmbiguous = [];
@@ -522,7 +620,7 @@ async function runRollback(config, metaRoot) {
522
620
  }
523
621
  }
524
622
  }
525
- async function runD1Migrate(config, metaRoot, runner) {
623
+ async function runD1Migrate(config, metaRoot, runner, _fmt = "text") {
526
624
  // 1. Resolve wrangler.toml + binding.
527
625
  const wranglerConfigPath = config.d1.wranglerConfigPath
528
626
  ? resolvePath(metaRoot, config.d1.wranglerConfigPath)