@metaobjectsdev/cli 1.0.13 → 1.1.0

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 (186) hide show
  1. package/README.md +32 -22
  2. package/dist/bin/meta.js +14 -2
  3. package/dist/bin/meta.js.map +1 -1
  4. package/dist/src/commands/docs.d.ts +1 -14
  5. package/dist/src/commands/docs.d.ts.map +1 -1
  6. package/dist/src/commands/docs.js +11 -835
  7. package/dist/src/commands/docs.js.map +1 -1
  8. package/dist/src/commands/eject.d.ts +24 -1
  9. package/dist/src/commands/eject.d.ts.map +1 -1
  10. package/dist/src/commands/eject.js +240 -69
  11. package/dist/src/commands/eject.js.map +1 -1
  12. package/dist/src/commands/gen.d.ts +7 -0
  13. package/dist/src/commands/gen.d.ts.map +1 -1
  14. package/dist/src/commands/gen.js +91 -21
  15. package/dist/src/commands/gen.js.map +1 -1
  16. package/dist/src/commands/generator.js +2 -2
  17. package/dist/src/commands/generator.js.map +1 -1
  18. package/dist/src/commands/init.d.ts +7 -0
  19. package/dist/src/commands/init.d.ts.map +1 -1
  20. package/dist/src/commands/init.js +71 -36
  21. package/dist/src/commands/init.js.map +1 -1
  22. package/dist/src/commands/migrate.d.ts +1 -1
  23. package/dist/src/commands/migrate.d.ts.map +1 -1
  24. package/dist/src/commands/migrate.js +224 -125
  25. package/dist/src/commands/migrate.js.map +1 -1
  26. package/dist/src/commands/types.d.ts.map +1 -1
  27. package/dist/src/commands/types.js +16 -2
  28. package/dist/src/commands/types.js.map +1 -1
  29. package/dist/src/commands/upgrade.d.ts.map +1 -1
  30. package/dist/src/commands/upgrade.js +4 -1
  31. package/dist/src/commands/upgrade.js.map +1 -1
  32. package/dist/src/commands/verify.d.ts.map +1 -1
  33. package/dist/src/commands/verify.js +189 -125
  34. package/dist/src/commands/verify.js.map +1 -1
  35. package/dist/src/index.d.ts.map +1 -1
  36. package/dist/src/index.js +59 -72
  37. package/dist/src/index.js.map +1 -1
  38. package/dist/src/lib/agent-context-staleness.d.ts +8 -1
  39. package/dist/src/lib/agent-context-staleness.d.ts.map +1 -1
  40. package/dist/src/lib/agent-context-staleness.js +20 -5
  41. package/dist/src/lib/agent-context-staleness.js.map +1 -1
  42. package/dist/src/lib/agent-schema.d.ts +9 -0
  43. package/dist/src/lib/agent-schema.d.ts.map +1 -0
  44. package/dist/src/lib/agent-schema.js +61 -0
  45. package/dist/src/lib/agent-schema.js.map +1 -0
  46. package/dist/src/lib/allow.d.ts +6 -0
  47. package/dist/src/lib/allow.d.ts.map +1 -1
  48. package/dist/src/lib/allow.js +49 -5
  49. package/dist/src/lib/allow.js.map +1 -1
  50. package/dist/src/lib/anti-patterns.d.ts.map +1 -1
  51. package/dist/src/lib/anti-patterns.js +4 -2
  52. package/dist/src/lib/anti-patterns.js.map +1 -1
  53. package/dist/src/lib/args.d.ts +51 -10
  54. package/dist/src/lib/args.d.ts.map +1 -1
  55. package/dist/src/lib/args.js +44 -18
  56. package/dist/src/lib/args.js.map +1 -1
  57. package/dist/src/lib/catalog-listing.d.ts +21 -1
  58. package/dist/src/lib/catalog-listing.d.ts.map +1 -1
  59. package/dist/src/lib/catalog-listing.js +85 -16
  60. package/dist/src/lib/catalog-listing.js.map +1 -1
  61. package/dist/src/lib/catalog.d.ts +30 -0
  62. package/dist/src/lib/catalog.d.ts.map +1 -1
  63. package/dist/src/lib/catalog.js +40 -5
  64. package/dist/src/lib/catalog.js.map +1 -1
  65. package/dist/src/lib/codegen-drift.d.ts +31 -2
  66. package/dist/src/lib/codegen-drift.d.ts.map +1 -1
  67. package/dist/src/lib/codegen-drift.js +109 -4
  68. package/dist/src/lib/codegen-drift.js.map +1 -1
  69. package/dist/src/lib/collection-load-options.d.ts +1 -1
  70. package/dist/src/lib/collection-load-options.d.ts.map +1 -1
  71. package/dist/src/lib/collection-load-options.js +1 -0
  72. package/dist/src/lib/collection-load-options.js.map +1 -1
  73. package/dist/src/lib/config.d.ts +4 -2
  74. package/dist/src/lib/config.d.ts.map +1 -1
  75. package/dist/src/lib/config.js +4 -2
  76. package/dist/src/lib/config.js.map +1 -1
  77. package/dist/src/lib/decimal-precision-advisory.d.ts +10 -0
  78. package/dist/src/lib/decimal-precision-advisory.d.ts.map +1 -0
  79. package/dist/src/lib/decimal-precision-advisory.js +34 -0
  80. package/dist/src/lib/decimal-precision-advisory.js.map +1 -0
  81. package/dist/src/lib/deprecation-lint.d.ts.map +1 -1
  82. package/dist/src/lib/deprecation-lint.js +17 -26
  83. package/dist/src/lib/deprecation-lint.js.map +1 -1
  84. package/dist/src/lib/docs-removed.d.ts +13 -0
  85. package/dist/src/lib/docs-removed.d.ts.map +1 -0
  86. package/dist/src/lib/docs-removed.js +31 -0
  87. package/dist/src/lib/docs-removed.js.map +1 -0
  88. package/dist/src/lib/docs-site-eject.d.ts +13 -0
  89. package/dist/src/lib/docs-site-eject.d.ts.map +1 -0
  90. package/dist/src/lib/docs-site-eject.js +40 -0
  91. package/dist/src/lib/docs-site-eject.js.map +1 -0
  92. package/dist/src/lib/field-lint.d.ts +25 -0
  93. package/dist/src/lib/field-lint.d.ts.map +1 -0
  94. package/dist/src/lib/field-lint.js +102 -0
  95. package/dist/src/lib/field-lint.js.map +1 -0
  96. package/dist/src/lib/fk-index-advisory.d.ts +1 -2
  97. package/dist/src/lib/fk-index-advisory.d.ts.map +1 -1
  98. package/dist/src/lib/fk-index-advisory.js +4 -3
  99. package/dist/src/lib/fk-index-advisory.js.map +1 -1
  100. package/dist/src/lib/format.d.ts +7 -0
  101. package/dist/src/lib/format.d.ts.map +1 -1
  102. package/dist/src/lib/format.js +13 -0
  103. package/dist/src/lib/format.js.map +1 -1
  104. package/dist/src/lib/gen-migrate-advice.d.ts +3 -1
  105. package/dist/src/lib/gen-migrate-advice.d.ts.map +1 -1
  106. package/dist/src/lib/gen-migrate-advice.js +3 -4
  107. package/dist/src/lib/gen-migrate-advice.js.map +1 -1
  108. package/dist/src/lib/install-set.d.ts +10 -1
  109. package/dist/src/lib/install-set.d.ts.map +1 -1
  110. package/dist/src/lib/install-set.js +20 -2
  111. package/dist/src/lib/install-set.js.map +1 -1
  112. package/dist/src/lib/load-metaobjects-config.d.ts +13 -11
  113. package/dist/src/lib/load-metaobjects-config.d.ts.map +1 -1
  114. package/dist/src/lib/load-metaobjects-config.js +53 -75
  115. package/dist/src/lib/load-metaobjects-config.js.map +1 -1
  116. package/dist/src/lib/manifest-ignored-check.d.ts +4 -1
  117. package/dist/src/lib/manifest-ignored-check.d.ts.map +1 -1
  118. package/dist/src/lib/manifest-ignored-check.js +9 -4
  119. package/dist/src/lib/manifest-ignored-check.js.map +1 -1
  120. package/dist/src/lib/output.d.ts +15 -0
  121. package/dist/src/lib/output.d.ts.map +1 -1
  122. package/dist/src/lib/output.js +9 -1
  123. package/dist/src/lib/output.js.map +1 -1
  124. package/dist/src/lib/payload-field-tree.d.ts +4 -5
  125. package/dist/src/lib/payload-field-tree.d.ts.map +1 -1
  126. package/dist/src/lib/payload-field-tree.js +16 -31
  127. package/dist/src/lib/payload-field-tree.js.map +1 -1
  128. package/dist/src/lib/requirement-check.d.ts +7 -0
  129. package/dist/src/lib/requirement-check.d.ts.map +1 -1
  130. package/dist/src/lib/requirement-check.js +14 -7
  131. package/dist/src/lib/requirement-check.js.map +1 -1
  132. package/dist/src/lib/strict-args.d.ts +28 -0
  133. package/dist/src/lib/strict-args.d.ts.map +1 -0
  134. package/dist/src/lib/strict-args.js +49 -0
  135. package/dist/src/lib/strict-args.js.map +1 -0
  136. package/dist/src/lib/template-eject.d.ts +108 -0
  137. package/dist/src/lib/template-eject.d.ts.map +1 -0
  138. package/dist/src/lib/template-eject.js +438 -0
  139. package/dist/src/lib/template-eject.js.map +1 -0
  140. package/dist/src/lib/version.d.ts +9 -0
  141. package/dist/src/lib/version.d.ts.map +1 -1
  142. package/dist/src/lib/version.js +15 -0
  143. package/dist/src/lib/version.js.map +1 -1
  144. package/package.json +10 -10
  145. package/src/commands/docs.ts +11 -952
  146. package/src/commands/eject.ts +318 -73
  147. package/src/commands/gen.ts +94 -22
  148. package/src/commands/generator.ts +2 -2
  149. package/src/commands/init.ts +80 -38
  150. package/src/commands/migrate.ts +248 -117
  151. package/src/commands/types.ts +18 -2
  152. package/src/commands/upgrade.ts +5 -1
  153. package/src/commands/verify.ts +229 -136
  154. package/src/index.ts +59 -72
  155. package/src/lib/agent-context-staleness.ts +20 -5
  156. package/src/lib/agent-schema.ts +74 -0
  157. package/src/lib/allow.ts +57 -6
  158. package/src/lib/anti-patterns.ts +4 -2
  159. package/src/lib/args.ts +83 -28
  160. package/src/lib/catalog-listing.ts +105 -25
  161. package/src/lib/catalog.ts +51 -6
  162. package/src/lib/codegen-drift.ts +138 -3
  163. package/src/lib/collection-load-options.ts +2 -1
  164. package/src/lib/config.ts +5 -3
  165. package/src/lib/decimal-precision-advisory.ts +50 -0
  166. package/src/lib/deprecation-lint.ts +16 -24
  167. package/src/lib/docs-removed.ts +41 -0
  168. package/src/lib/docs-site-eject.ts +48 -0
  169. package/src/lib/field-lint.ts +122 -0
  170. package/src/lib/fk-index-advisory.ts +4 -4
  171. package/src/lib/format.ts +12 -0
  172. package/src/lib/gen-migrate-advice.ts +6 -4
  173. package/src/lib/install-set.ts +20 -1
  174. package/src/lib/load-metaobjects-config.ts +57 -78
  175. package/src/lib/manifest-ignored-check.ts +14 -10
  176. package/src/lib/output.ts +18 -1
  177. package/src/lib/payload-field-tree.ts +18 -35
  178. package/src/lib/requirement-check.ts +21 -6
  179. package/src/lib/strict-args.ts +52 -0
  180. package/src/lib/template-eject.ts +526 -0
  181. package/src/lib/version.ts +18 -0
  182. package/dist/src/lib/docs-drift.d.ts +0 -42
  183. package/dist/src/lib/docs-drift.d.ts.map +0 -1
  184. package/dist/src/lib/docs-drift.js +0 -264
  185. package/dist/src/lib/docs-drift.js.map +0 -1
  186. package/src/lib/docs-drift.ts +0 -312
@@ -5,14 +5,14 @@ import { spawn } from "node:child_process";
5
5
  import { parseMigrateArgs } from "../lib/args.js";
6
6
  import { resolveMigrateConfig, MIGRATE_DEFAULT_OUT_DIR } from "../lib/config.js";
7
7
  import type { ResolvedMigrateConfig } from "../lib/config.js";
8
- import { formatMigrateResult, formatMigrateResultToon, type BlockedEntry, type AmbiguousEntry } from "../lib/output.js";
8
+ import { formatMigrateResult, formatMigrateResultToon, type BlockedEntry, type AmbiguousEntry, type MigrateResultShape, migrateResultToData } from "../lib/output.js";
9
9
  import { formatMigrateResultJson } from "../lib/output-json.js";
10
10
  import type { OutputFormat } from "../lib/format.js";
11
- import { toonEncode } from "../lib/format.js";
12
- import { buildKyselyFromUrl, redactUrl } from "../lib/kysely.js";
11
+ import { emitStructured, narrate, toonEncode } from "../lib/format.js";
12
+ import { buildKyselyFromUrl, inferDialect, redactUrl } from "../lib/kysely.js";
13
13
  import { log } from "../lib/log.js";
14
14
  import { loadMemory, resolveCollection, resolveConfigDir, type Collection } from "@metaobjectsdev/sdk";
15
- import { loadMemoryOptionsFrom, loadMetaobjectsConfig, resolveGenConfigDir } from "../lib/load-metaobjects-config.js";
15
+ import { loadMemoryOptionsFrom, loadMetaobjectsConfig, RemovedNamingConfigError, resolveGenConfigDir } from "../lib/load-metaobjects-config.js";
16
16
  import { collectionLoadOptions } from "../lib/collection-load-options.js";
17
17
  import { exclusionNotes, importedOption, migrateScopeMismatch } from "../lib/migrate-scope.js";
18
18
  import {
@@ -39,6 +39,7 @@ import {
39
39
  writeMigrationD1,
40
40
  writeMigrationFlyway,
41
41
  introspectD1,
42
+ withoutFlywayHistory,
42
43
  applyPending,
43
44
  rollbackTo,
44
45
  findWranglerConfig,
@@ -54,6 +55,7 @@ import {
54
55
  type SchemaProvenance,
55
56
  type SchemaSnapshot,
56
57
  type TableDescriptor,
58
+ postgresViewReplaceProbe,
57
59
  } from "@metaobjectsdev/migrate-ts";
58
60
  import {
59
61
  buildWranglerExecuteArgs,
@@ -61,8 +63,9 @@ import {
61
63
  type WranglerRunner,
62
64
  } from "../lib/wrangler.js";
63
65
  import { buildProjectionViews } from "@metaobjectsdev/codegen-ts";
64
- import { tokensToAllowOptions, blockedEntriesFor, blockedHintLines } from "../lib/allow.js";
66
+ import { tokensToAllowOptions, blockedEntriesFor, blockedHintLines, viewRecreateNote } from "../lib/allow.js";
65
67
  import { reportLoadError } from "../lib/load-error.js";
68
+ import { decimalPrecisionWarnings } from "../lib/decimal-precision-advisory.js";
66
69
  import { scanForReferentialActionConflicts } from "../lib/referential-action-advisory.js";
67
70
  import type { MetaData } from "@metaobjectsdev/metadata";
68
71
  import { describeError } from "../lib/error-text.js";
@@ -104,7 +107,7 @@ MIGRATE FLAGS:
104
107
  .metaobjects/config.json.
105
108
  --out-dir <path> Migration directory (default: ./.metaobjects/migrations;
106
109
  flyway: src/main/resources/db/migration)
107
- --slug <name> Required when changes are present (e.g., --slug add-user-shipping)
110
+ --slug <name> Required to WRITE a migration (not for --dry-run) (e.g., --slug add-user-shipping)
108
111
  --allow <csv> Comma-separated destructive-change permissions:
109
112
  drop-column,drop-table,type-change,drop-index,drop-fk,
110
113
  drop-check,drop-view,drop-view-cascade,
@@ -135,7 +138,7 @@ MIGRATE FLAGS:
135
138
  --d1 <binding> D1 binding name from wrangler.toml (only with --dialect d1)
136
139
  --remote Target remote D1 instead of local (only with --dialect d1)
137
140
  --yes Skip the --remote --apply confirmation pause
138
- --dry-run Print SQL to stdout, don't write
141
+ --dry-run Print the SQL, don't write (json/toon: in the document's sql field)
139
142
  --help, -h Print this help
140
143
 
141
144
  EXAMPLES:
@@ -234,10 +237,39 @@ function logOutOfScope(
234
237
  fromDependencies: readonly string[],
235
238
  fmt: OutputFormat,
236
239
  ): void {
237
- for (const msg of exclusionNotes("migrate", names, fromDependencies)) {
238
- if (fmt === "text") log.info(msg);
239
- else log.warn(msg);
240
- }
240
+ for (const msg of exclusionNotes("migrate", names, fromDependencies)) narrate(fmt, msg);
241
+ }
242
+
243
+ /**
244
+ * End a migrate path that reports in prose. Text format prints the lines on stdout as it
245
+ * always has. A structured format sends the same lines to STDERR and puts exactly ONE
246
+ * document on stdout — `doc` — so `--format json | jq` always has something to parse
247
+ * and never a sentence in front of it (the rule `logOutOfScope` states above).
248
+ */
249
+ function finishMigrate(fmt: OutputFormat, lines: readonly string[], doc: Record<string, unknown>): void {
250
+ for (const line of lines) narrate(fmt, line);
251
+ if (fmt !== "text") emitStructured(doc, fmt);
252
+ }
253
+
254
+ /** The result fields both the offline and the d1 pipeline report when they have none of their own. */
255
+ function migrateResultDefaults(dryRun: boolean): Pick<MigrateResultShape, "blocked" | "ambiguous" | "writtenPaths" | "dryRun"> {
256
+ return { blocked: [], ambiguous: [], writtenPaths: [], dryRun };
257
+ }
258
+
259
+ /** `viewRecreateNote` as the `notes` list a result carries: empty when there is nothing to say. */
260
+ function viewRecreateNotes(changes: Change[]): string[] {
261
+ const note = viewRecreateNote(changes);
262
+ return note === undefined ? [] : [note];
263
+ }
264
+
265
+ /** A result's notes as prose lines, for the paths that report through `finishMigrate`. */
266
+ function narratedNotes(notes: readonly string[]): string[] {
267
+ return notes.map((n) => `migrate: ${n}`);
268
+ }
269
+
270
+ /** The `-- UP -- / -- DOWN --` preview a dry run prints in text format. */
271
+ function sqlPreview(sql: { up: string; down: string }): string {
272
+ return `-- UP --\n${sql.up}\n\n-- DOWN --\n${sql.down}`;
241
273
  }
242
274
 
243
275
  /**
@@ -256,6 +288,13 @@ function warnReferentialActionConflicts(metadata: MetaData, collection: Collecti
256
288
  for (const f of findings) log.warn(`migrate: ${f.message}`);
257
289
  }
258
290
 
291
+ /** W4 (ADR-0019 Amendment 1): warn once per wide decimal on a dialect that keeps 15 digits. */
292
+ function warnDecimalPrecision(metadata: MetaData, collection: Collection, dialect: string): void {
293
+ for (const w of decimalPrecisionWarnings(metadata, dialect, { skip: collection.imported })) {
294
+ log.warn(`migrate: ${w}`);
295
+ }
296
+ }
297
+
259
298
  function emitStructuredError(error: string, hint: string, fmt: OutputFormat): void {
260
299
  const payload = { error, hint };
261
300
  if (fmt === "json") {
@@ -369,15 +408,19 @@ function nextStepsDetail(dialect: string | undefined): string {
369
408
  }
370
409
 
371
410
  /** Table count of the target DB, or undefined when it can't be reached/introspected
372
- * (any failure ⇒ unknown ⇒ never block; the caller warns instead of refusing). */
411
+ * (any failure ⇒ unknown ⇒ never block; the caller warns instead of refusing). Under the
412
+ * flyway format Flyway's own history table is not counted: a database that holds only that
413
+ * table has none of the project's tables. */
373
414
  async function countLiveTables(
374
415
  url: string,
375
416
  dialect: ResolvedMigrateConfig["dialect"],
417
+ format: ResolvedMigrateConfig["format"],
376
418
  ): Promise<number | undefined> {
377
419
  try {
378
420
  const kysely = await buildKyselyFromUrl(url, dialect);
379
421
  try {
380
- return (await introspect(kysely.db, kysely.dialect)).tables.length;
422
+ const live = await introspect(kysely.db, kysely.dialect);
423
+ return (format === "flyway" ? withoutFlywayHistory(live) : live).tables.length;
381
424
  } finally {
382
425
  await kysely.close();
383
426
  }
@@ -509,14 +552,14 @@ export async function migrateCommand(
509
552
  // this run — the same directory `resolveCollection` resolves the metadata
510
553
  // from, found the same way (design §4.6.1: "Per-port generator config is then
511
554
  // read from that same directory"). Everything below is relative to it: the
512
- // `.metaobjects/config.json` operational block, `metaobjects.config.ts`'s
513
- // `columnNamingStrategy`, the migrations `outDir`, `wrangler.toml` discovery.
555
+ // `.metaobjects/config.json` operational block and its `naming` (ADR-0058), the
556
+ // migrations `outDir`, `wrangler.toml` discovery.
514
557
  //
515
558
  // Read from ambient cwd instead, as this did, they DIVERGE the moment the two
516
559
  // differ: run `meta migrate` from a subdirectory of a project whose root
517
- // declares `columnNamingStrategy: "literal"` and the metadata resolves from
518
- // the ancestor while the strategy silently defaults to snake_case — emitting a
519
- // migration that renames every column. Newly reachable, too: before metadata
560
+ // declares literal column naming and the metadata resolves from the ancestor
561
+ // while the rule silently defaults to snake_case — emitting a migration that
562
+ // renames every column. Newly reachable, too: before metadata
520
563
  // sources were resolvable that invocation just failed with "no metaobjects/
521
564
  // found".
522
565
  //
@@ -532,10 +575,8 @@ export async function migrateCommand(
532
575
  // answers a different question from `.metaobjects/config.json` (design §4.6 —
533
576
  // per-port "how is code generated here?" versus port-neutral "where does metadata
534
577
  // come from?"), and in a Maven- or pip-rooted monorepo the two legitimately sit in
535
- // different directories. Reading it from `metaRoot` there silently defaults
536
- // `columnNamingStrategy` for a package that declares one — the exact
537
- // rename-every-column failure the co-location was introduced to prevent, arrived
538
- // at from the other side (#326). Nearest wins, so a subdirectory declaring nothing
578
+ // different directories. Reading it from `metaRoot` there silently drops the
579
+ // providers and libraries a package declares (#326). Nearest wins, so a subdirectory declaring nothing
539
580
  // still walks up to the project root's config; identical to `metaRoot` in every
540
581
  // `meta init` project, where the two files sit together.
541
582
  const genRoot = resolveGenConfigDir(cwd, metaRoot);
@@ -671,16 +712,20 @@ export async function migrateCommand(
671
712
  // every applied migration NEWER than <target> (target retained), in reverse
672
713
  // order, ledger-tracked + advisory-locked. postgres/sqlite only.
673
714
  if (config.rollback !== undefined) {
674
- return await runRollback(config, metaRoot);
715
+ return await runRollback(config, metaRoot, fmt);
675
716
  }
676
717
 
677
718
  // Best-effort load of metaobjects.config.ts to pick up consumer-supplied
678
- // providers. migrate's postgres/sqlite path also reads the config later
679
- // for columnNamingStrategy; we load it once here and reuse below.
719
+ // providers. Column naming is not read here: it is `naming` in
720
+ // `.metaobjects/config.json`, set on the loaded root (ADR-0058).
680
721
  let postgresLoadOptions: ReturnType<typeof loadMemoryOptionsFrom> = {};
681
722
  try {
682
723
  postgresLoadOptions = loadMemoryOptionsFrom(await loadMetaobjectsConfig(genRoot));
683
- } catch {
724
+ } catch (e) {
725
+ if (e instanceof RemovedNamingConfigError) {
726
+ log.error(e.message);
727
+ return 2;
728
+ }
684
729
  postgresLoadOptions = {};
685
730
  }
686
731
 
@@ -705,7 +750,7 @@ export async function migrateCommand(
705
750
  });
706
751
  } catch (err) {
707
752
  reportLoadError(log, "failed to load metadata", err);
708
- return 2;
753
+ return 1;
709
754
  }
710
755
  warnReferentialActionConflicts(metadata, collection);
711
756
 
@@ -716,9 +761,13 @@ export async function migrateCommand(
716
761
  log.error(`migrate: ${describeError(err)}`);
717
762
  return 2;
718
763
  }
764
+ warnDecimalPrecision(metadata, collection, kysely.dialect);
719
765
 
720
766
  let exitCode = 0;
721
767
  let writtenPaths: string[] = [];
768
+ /** A dry run's SQL: printed in text format, carried in the document otherwise. */
769
+ let dryRunSql: { up: string; down: string } | undefined;
770
+ const notes: string[] = [];
722
771
  let appliedNames: string[] = [];
723
772
  let applyFailed = false;
724
773
  let blocked: BlockedEntry[] = [];
@@ -727,23 +776,16 @@ export async function migrateCommand(
727
776
  let changeCounts: Record<string, number> = {};
728
777
 
729
778
  try {
730
- // Column-naming strategy (from metaobjects.config) drives BOTH the table schema
731
- // and projection view DDL — derive it once, up front, so every view path agrees.
732
- let columnNamingStrategy: "snake_case" | "literal" | "kebab-case" = "snake_case";
733
- try {
734
- const cfg = await loadMetaobjectsConfig(genRoot);
735
- if (cfg.columnNamingStrategy) columnNamingStrategy = cfg.columnNamingStrategy;
736
- } catch {
737
- // metaobjects.config.ts absent or invalid — use default snake_case
738
- }
779
+ // Column naming (ADR-0058: `naming` in .metaobjects/config.json, carried by the loaded
780
+ // root) drives BOTH the table schema and projection view DDL — read it once, up front,
781
+ // so every view path agrees. A config.ts still setting the 1.0 key is refused, loudly.
739
782
  // Expected views from the SINGLE view-SQL source (codegen-ts emitViewDdl, via
740
783
  // buildProjectionViews). Threaded into the schema-diff so the diff produces all
741
784
  // view DDL (create/drop/replace + dependency-recreate) and emit() renders it —
742
785
  // there is no separate view-migration emitter.
743
- const expectedViews = buildProjectionViews(metadata, { dialect: kysely.dialect, columnNamingStrategy });
786
+ const expectedViews = buildProjectionViews(metadata, { dialect: kysely.dialect });
744
787
  const built = buildExpectedSchemaWithProvenance(metadata, {
745
788
  dialect: kysely.dialect,
746
- columnNamingStrategy,
747
789
  views: expectedViews,
748
790
  });
749
791
  const scopeRc = refuseScopeMismatch(collection, () => built.provenance, fmt);
@@ -758,6 +800,8 @@ export async function migrateCommand(
758
800
  let actual;
759
801
  try {
760
802
  actual = await introspect(kysely.db, kysely.dialect);
803
+ // Flyway owns its history table; diffing it proposes DROP TABLE flyway_schema_history.
804
+ if (config.format === "flyway") actual = withoutFlywayHistory(actual);
761
805
  } catch (err) {
762
806
  log.error(`migrate: failed to connect to ${kysely.displayUrl}: ${describeError(err)}`);
763
807
  await kysely.close();
@@ -778,6 +822,9 @@ export async function migrateCommand(
778
822
  ...scopedDiffInputs(scoped, collectUnmanagedNames(metadata)),
779
823
  actual,
780
824
  dialect: kysely.dialect,
825
+ // Postgres decides whether a changed view can be replaced in place: the probe
826
+ // creates the new definition as a temporary view in a transaction it rolls back.
827
+ ...(kysely.dialect === "postgres" ? { viewReplaceProbe: postgresViewReplaceProbe(kysely.db) } : {}),
781
828
  allow: tokensToAllowOptions(config.allow),
782
829
  renames: config.renames,
783
830
  // #258 — adopting a live DB whose PRIMARY KEY differs from the metadata identity
@@ -876,6 +923,7 @@ export async function migrateCommand(
876
923
  if (diffResult.changes.length === 0) {
877
924
  // no-op — output will say "No schema changes"
878
925
  } else {
926
+ notes.push(...viewRecreateNotes(diffResult.changes));
879
927
  let emitted: EmitResult | undefined;
880
928
  try {
881
929
  emitted = emit(diffResult.changes, {
@@ -894,23 +942,30 @@ export async function migrateCommand(
894
942
  }
895
943
 
896
944
  if (exitCode === 0 && emitted) {
897
- if (config.slug === undefined) {
898
- log.error(`migrate: --slug <name> required when there are changes (e.g., --slug add-user-shipping)`);
899
- // The common way to land here is a NEW database (a fresh clone, CI, another
900
- // environment) whose migrations are already committed: the diff against an empty
901
- // database is every table, so it asks to author a new migration. That database
902
- // needs the committed chain replayed, not a new migration.
903
- log.error(
904
- ` New database, migrations already committed? Apply them instead: ` +
905
- `meta migrate apply-pending --db <url>`,
906
- );
907
- await kysely.close();
908
- return 2;
909
- }
910
-
945
+ notes.push(...(emitted.notices ?? []));
911
946
  if (config.dryRun) {
912
- log.info(`-- UP --\n${emitted.up}\n\n-- DOWN --\n${emitted.down}`);
947
+ dryRunSql = { up: emitted.up, down: emitted.down };
948
+ if (fmt === "text") log.info(sqlPreview(dryRunSql));
913
949
  } else {
950
+ // A preview names no migration; only a write needs the slug.
951
+ if (config.slug === undefined) {
952
+ log.error(`migrate: --slug <name> required when there are changes (e.g., --slug add-user-shipping)`);
953
+ emitStructuredError(
954
+ "migrate: --slug <name> required when there are changes",
955
+ "re-run with --slug <name>; for a new database whose migrations are already committed, run `meta migrate apply-pending --db <url>`",
956
+ fmt,
957
+ );
958
+ // The common way to land here is a NEW database (a fresh clone, CI, another
959
+ // environment) whose migrations are already committed: the diff against an empty
960
+ // database is every table, so it asks to author a new migration. That database
961
+ // needs the committed chain replayed, not a new migration.
962
+ log.error(
963
+ ` New database, migrations already committed? Apply them instead: ` +
964
+ `meta migrate apply-pending --db <url>`,
965
+ );
966
+ await kysely.close();
967
+ return 2;
968
+ }
914
969
  const outDir = resolveFormatOutDir(config, metaRoot);
915
970
  await mkdir(outDir, { recursive: true });
916
971
  // #192 — only the envelope differs; the emitted SQL is identical.
@@ -1025,6 +1080,8 @@ export async function migrateCommand(
1025
1080
  applied: appliedNames,
1026
1081
  applyFailed,
1027
1082
  warnings: hazardWarnings,
1083
+ notes,
1084
+ ...(dryRunSql !== undefined ? { sql: dryRunSql } : {}),
1028
1085
  };
1029
1086
  const output =
1030
1087
  fmt === "toon" ? formatMigrateResultToon(migrateResult)
@@ -1033,10 +1090,12 @@ export async function migrateCommand(
1033
1090
 
1034
1091
  log.info(output);
1035
1092
  if (config.apply && exitCode === 0) {
1093
+ // The document's summary already says what was applied; this line is narration,
1094
+ // so a structured run sends it to stderr rather than after the document.
1036
1095
  if (appliedNames.length > 0) {
1037
- log.info(`migrate: applied ${appliedNames.length} migration(s): ${appliedNames.join(", ")}`);
1096
+ narrate(fmt, `migrate: applied ${appliedNames.length} migration(s): ${appliedNames.join(", ")}`);
1038
1097
  } else {
1039
- log.info(`migrate: no pending migrations to apply`);
1098
+ narrate(fmt, `migrate: no pending migrations to apply`);
1040
1099
  }
1041
1100
  }
1042
1101
  return exitCode;
@@ -1090,6 +1149,7 @@ export async function runBaseline(
1090
1149
  }
1091
1150
  try {
1092
1151
  snapshot = await introspect(kysely.db, kysely.dialect);
1152
+ if (config.format === "flyway") snapshot = withoutFlywayHistory(snapshot);
1093
1153
  } finally {
1094
1154
  await kysely.close();
1095
1155
  }
@@ -1102,7 +1162,7 @@ export async function runBaseline(
1102
1162
  // couldn't reach — we still write the snapshot but WARN: an offline baseline is
1103
1163
  // only correct when the database already matches the metadata, and emits no DDL.
1104
1164
  const liveTableCount = config.databaseUrl !== undefined
1105
- ? await countLiveTables(config.databaseUrl, config.dialect)
1165
+ ? await countLiveTables(config.databaseUrl, config.dialect, config.format)
1106
1166
  : undefined;
1107
1167
  if (liveTableCount === 0) {
1108
1168
  // `liveTableCount === 0` ⇒ databaseUrl was defined (countLiveTables only
@@ -1122,16 +1182,18 @@ export async function runBaseline(
1122
1182
  `your metadata; for a new/empty database run \`${greenfieldCreateCmd(config.dialect)}\` instead.`,
1123
1183
  );
1124
1184
  let metadata;
1125
- // Load metaobjects.config.ts ONCE, up front, for BOTH the consumer providers/libraries
1126
- // and the columnNamingStrategy — mirroring the DB path (and `meta gen`) so
1127
- // offline baseline resolves config-registered custom subtypes too (#157).
1185
+ // Load metaobjects.config.ts ONCE, up front, for the consumer providers/libraries —
1186
+ // mirroring the DB path (and `meta gen`) so offline baseline resolves
1187
+ // config-registered custom subtypes too (#157).
1128
1188
  let baselineLoadOptions: ReturnType<typeof loadMemoryOptionsFrom> = {};
1129
- let baselineStrategy: "snake_case" | "literal" | "kebab-case" = "snake_case";
1130
1189
  try {
1131
1190
  const cfg = await loadMetaobjectsConfig(genRoot);
1132
1191
  baselineLoadOptions = loadMemoryOptionsFrom(cfg);
1133
- if (cfg.columnNamingStrategy) baselineStrategy = cfg.columnNamingStrategy;
1134
- } catch {
1192
+ } catch (e) {
1193
+ if (e instanceof RemovedNamingConfigError) {
1194
+ log.error(e.message);
1195
+ return 2;
1196
+ }
1135
1197
  // config absent — no custom providers, no libraries, default snake_case
1136
1198
  }
1137
1199
  // `baseline` records a STARTING POINT, so it is deliberately NOT scoped: the
@@ -1151,19 +1213,23 @@ export async function runBaseline(
1151
1213
  });
1152
1214
  } catch (err) {
1153
1215
  reportLoadError(log, "migrate baseline: failed to load metadata", err);
1154
- return 2;
1216
+ return 1;
1155
1217
  }
1156
- const baselineViews = buildProjectionViews(metadata, { dialect: config.dialect, columnNamingStrategy: baselineStrategy });
1157
- snapshot = baselineFromMetadata(metadata, config.dialect, baselineStrategy, baselineViews);
1218
+ const baselineViews = buildProjectionViews(metadata, { dialect: config.dialect });
1219
+ snapshot = baselineFromMetadata(metadata, config.dialect, baselineViews);
1158
1220
  }
1159
1221
 
1160
1222
  if (config.dryRun) {
1161
- log.info(`migrate baseline (dry-run): would write schema snapshot ${path}`);
1223
+ finishMigrate(fmt, [`migrate baseline (dry-run): would write schema snapshot ${path}`], {
1224
+ snapshot: path, written: [], summary: "baseline preview only (nothing written)", help: ["re-run without --dry-run to write the snapshot"],
1225
+ });
1162
1226
  return 0;
1163
1227
  }
1164
1228
 
1165
1229
  await writeSnapshot(path, snapshot);
1166
- log.info(`migrate: wrote schema snapshot ${path}`);
1230
+ finishMigrate(fmt, [`migrate: wrote schema snapshot ${path}`], {
1231
+ snapshot: path, written: [path], summary: "wrote schema snapshot", help: ["commit the snapshot; later runs diff against it"],
1232
+ });
1167
1233
  return 0;
1168
1234
  }
1169
1235
 
@@ -1263,20 +1329,40 @@ export async function runOfflineGenerate(
1263
1329
  * `genRoot`. Defaults to `metaRoot`, the co-located case. */
1264
1330
  genRoot: string = metaRoot,
1265
1331
  ): Promise<number> {
1332
+ // `--dialect` is optional wherever a URL is known: the offline path reads the
1333
+ // committed snapshot rather than the database, but `--db` (or DATABASE_URL /
1334
+ // migrate.databaseUrl) still names which dialect that snapshot is for. The help has
1335
+ // always said "auto-detected from URL scheme"; this path used to refuse instead.
1336
+ if (config.dialect === undefined && config.databaseUrl !== undefined) {
1337
+ try {
1338
+ config = { ...config, dialect: inferDialect(config.databaseUrl) };
1339
+ } catch (err) {
1340
+ log.error(`migrate: ${describeError(err)}`);
1341
+ emitStructuredError(`migrate: ${describeError(err)}`, "pass --dialect sqlite|postgres|d1", fmt);
1342
+ return 2;
1343
+ }
1344
+ }
1266
1345
  if (config.dialect === undefined) {
1267
- log.error(`migrate: --dialect required for offline generation (or use --from-db)`);
1346
+ log.error(`migrate: --dialect required for offline generation — pass --dialect sqlite|postgres, or --db <url> to infer it (or use --from-db)`);
1347
+ emitStructuredError(
1348
+ "migrate: --dialect required for offline generation",
1349
+ "pass --dialect sqlite|postgres, or --db <url> to infer it from the URL scheme",
1350
+ fmt,
1351
+ );
1268
1352
  return 2;
1269
1353
  }
1270
- // Load metaobjects.config.ts ONCE, up front, for BOTH the consumer providers/libraries
1271
- // and the columnNamingStrategy — mirroring the DB path (and `meta gen`) so
1272
- // offline generate resolves config-registered custom subtypes too (#157).
1354
+ // Load metaobjects.config.ts ONCE, up front, for the consumer providers/libraries —
1355
+ // mirroring the DB path (and `meta gen`) so offline generate resolves
1356
+ // config-registered custom subtypes too (#157).
1273
1357
  let offlineLoadOptions: ReturnType<typeof loadMemoryOptionsFrom> = {};
1274
- let offlineStrategy: "snake_case" | "literal" | "kebab-case" = "snake_case";
1275
1358
  try {
1276
1359
  const cfg = await loadMetaobjectsConfig(genRoot);
1277
1360
  offlineLoadOptions = loadMemoryOptionsFrom(cfg);
1278
- if (cfg.columnNamingStrategy) offlineStrategy = cfg.columnNamingStrategy;
1279
- } catch {
1361
+ } catch (e) {
1362
+ if (e instanceof RemovedNamingConfigError) {
1363
+ log.error(e.message);
1364
+ return 2;
1365
+ }
1280
1366
  // config absent — no custom providers, no libraries, default snake_case
1281
1367
  }
1282
1368
 
@@ -1290,17 +1376,17 @@ export async function runOfflineGenerate(
1290
1376
  });
1291
1377
  } catch (err) {
1292
1378
  reportLoadError(log, "migrate: failed to load metadata", err);
1293
- return 2;
1379
+ return 1;
1294
1380
  }
1295
1381
  warnReferentialActionConflicts(metadata, collection);
1382
+ warnDecimalPrecision(metadata, collection, config.dialect);
1296
1383
 
1297
1384
  const offlineDialect = config.dialect;
1298
- const offlineViews = buildProjectionViews(metadata, { dialect: offlineDialect, columnNamingStrategy: offlineStrategy });
1385
+ const offlineViews = buildProjectionViews(metadata, { dialect: offlineDialect });
1299
1386
  const scopeRc = refuseScopeMismatch(
1300
1387
  collection,
1301
1388
  () => buildExpectedSchemaWithProvenance(metadata, {
1302
1389
  dialect: offlineDialect,
1303
- columnNamingStrategy: offlineStrategy,
1304
1390
  views: offlineViews,
1305
1391
  }).provenance,
1306
1392
  fmt,
@@ -1330,6 +1416,7 @@ export async function runOfflineGenerate(
1330
1416
  emitStructuredError("no schema snapshot", nextStepsDetail(config.dialect), fmt);
1331
1417
  return 2;
1332
1418
  }
1419
+ if (config.format === "flyway") snapshot = withoutFlywayHistory(snapshot);
1333
1420
 
1334
1421
  const collectedAmbiguous: AmbiguousChange[] = [];
1335
1422
  const onAmbiguousResolution = mapOnAmbiguous(config.onAmbiguous);
@@ -1342,7 +1429,6 @@ export async function runOfflineGenerate(
1342
1429
  metadata,
1343
1430
  dialect: config.dialect,
1344
1431
  snapshot,
1345
- columnNamingStrategy: offlineStrategy,
1346
1432
  views: offlineViews,
1347
1433
  // Per-command scope — narrows BOTH sides of the offline diff (see planOffline).
1348
1434
  ...(offlineScope !== undefined ? { inScope: offlineScope } : {}),
@@ -1374,19 +1460,27 @@ export async function runOfflineGenerate(
1374
1460
  const { diff: diffResult, nextSnapshot, expected: governedExpected } = plan;
1375
1461
  logOutOfScope(plan.outOfScope, plan.importedOutOfScope ?? [], fmt);
1376
1462
 
1463
+ const offlineNotes = viewRecreateNotes(diffResult.changes);
1464
+ const offlineResult = (extra: Partial<MigrateResultShape>): MigrateResultShape => ({
1465
+ dialect: offlineDialect,
1466
+ displayUrl: "",
1467
+ changeCounts: summarizeChanges(diffResult.changes),
1468
+ ...migrateResultDefaults(config.dryRun),
1469
+ format: config.format,
1470
+ notes: offlineNotes,
1471
+ ...extra,
1472
+ });
1377
1473
  if (diffResult.blocked.length > 0) {
1378
- logBlocked(blockedEntriesFor(diffResult.blocked, diffResult.changes));
1474
+ const blocked = blockedEntriesFor(diffResult.blocked, diffResult.changes);
1475
+ logBlocked(blocked);
1476
+ if (fmt !== "text") emitStructured(migrateResultToData(offlineResult({ blocked })), fmt);
1379
1477
  return 1;
1380
1478
  }
1381
1479
  if (diffResult.changes.length === 0) {
1382
- log.info(`migrate: no changes`);
1480
+ finishMigrate(fmt, [`migrate: no changes`], migrateResultToData(offlineResult({})));
1383
1481
  return 0;
1384
1482
  }
1385
- if (config.slug === undefined) {
1386
- log.error(`migrate: --slug <name> required when there are changes (e.g., --slug add-user-shipping)`);
1387
- return 2;
1388
- }
1389
- warnDataHazards(diffResult.hazards);
1483
+ const offlineWarnings = warnDataHazards(diffResult.hazards);
1390
1484
 
1391
1485
  const emitResult = emit(diffResult.changes, {
1392
1486
  dialect: config.dialect,
@@ -1395,11 +1489,26 @@ export async function runOfflineGenerate(
1395
1489
  ...(snapshot.meta ? { actualMeta: snapshot.meta } : {}),
1396
1490
  });
1397
1491
 
1492
+ // The emitter's own notices (e.g. a view dropped and recreated, its grants carried over).
1493
+ offlineNotes.push(...(emitResult.notices ?? []));
1494
+
1398
1495
  if (config.dryRun) {
1399
- log.info(`-- UP --\n${emitResult.up}\n\n-- DOWN --\n${emitResult.down}`);
1496
+ const sql = { up: emitResult.up, down: emitResult.down };
1497
+ finishMigrate(
1498
+ fmt,
1499
+ [...narratedNotes(offlineNotes), sqlPreview(sql)],
1500
+ migrateResultToData(offlineResult({ sql, warnings: offlineWarnings })),
1501
+ );
1400
1502
  return 0;
1401
1503
  }
1402
1504
 
1505
+ // A preview (above) names no migration; only a write needs the slug.
1506
+ if (config.slug === undefined) {
1507
+ log.error(`migrate: --slug <name> required when there are changes (e.g., --slug add-user-shipping)`);
1508
+ emitStructuredError("migrate: --slug <name> required when there are changes", "re-run with --slug <name>", fmt);
1509
+ return 2;
1510
+ }
1511
+
1403
1512
  // #192 — the MIGRATION FILES follow the active format's layout, while the
1404
1513
  // snapshot stays in `outDir` (it is metaobjects' own state). With the default
1405
1514
  // out-dir that separates them: snapshot in .metaobjects/migrations/, migrations
@@ -1417,8 +1526,11 @@ export async function runOfflineGenerate(
1417
1526
  { dir: writeDir, slug: config.slug },
1418
1527
  );
1419
1528
  await writeSnapshot(path, nextSnapshot);
1420
- log.info(`migrate: wrote ${res.upPath}`);
1421
- log.info(`migrate: wrote ${res.downPath}`);
1529
+ finishMigrate(
1530
+ fmt,
1531
+ [`migrate: wrote ${res.upPath}`, `migrate: wrote ${res.downPath}`, ...narratedNotes(offlineNotes)],
1532
+ migrateResultToData(offlineResult({ writtenPaths: [res.upPath, res.downPath], warnings: offlineWarnings })),
1533
+ );
1422
1534
  return 0;
1423
1535
  }
1424
1536
 
@@ -1432,6 +1544,7 @@ export async function runOfflineGenerate(
1432
1544
  async function runRollback(
1433
1545
  config: ResolvedMigrateConfig,
1434
1546
  metaRoot: string,
1547
+ fmt: OutputFormat,
1435
1548
  ): Promise<number> {
1436
1549
  // databaseUrl is guaranteed defined by the caller's guard above.
1437
1550
  const databaseUrl = config.databaseUrl as string;
@@ -1459,11 +1572,10 @@ async function runRollback(
1459
1572
 
1460
1573
  try {
1461
1574
  const result = await rollbackTo(kysely.db, outDir, target, { dialect });
1462
- if (result.rolledBack.length > 0) {
1463
- log.info(`migrate: rolled back ${result.rolledBack.length} migration(s): ${result.rolledBack.join(", ")}`);
1464
- } else {
1465
- log.info(`migrate: nothing to roll back${target ? ` newer than '${target}'` : ""}`);
1466
- }
1575
+ const summary = result.rolledBack.length > 0
1576
+ ? `rolled back ${result.rolledBack.length} migration(s): ${result.rolledBack.join(", ")}`
1577
+ : `nothing to roll back${target ? ` newer than '${target}'` : ""}`;
1578
+ finishMigrate(fmt, [`migrate: ${summary}`], { rolledBack: result.rolledBack, summary });
1467
1579
  return 0;
1468
1580
  } catch (err) {
1469
1581
  log.error(`migrate: rollback failed: ${describeError(err)}`);
@@ -1537,7 +1649,11 @@ async function runD1Migrate(
1537
1649
  let d1LoadOptions: ReturnType<typeof loadMemoryOptionsFrom> = {};
1538
1650
  try {
1539
1651
  d1LoadOptions = loadMemoryOptionsFrom(await loadMetaobjectsConfig(genRoot));
1540
- } catch {
1652
+ } catch (e) {
1653
+ if (e instanceof RemovedNamingConfigError) {
1654
+ log.error(e.message);
1655
+ return 2;
1656
+ }
1541
1657
  d1LoadOptions = {};
1542
1658
  }
1543
1659
 
@@ -1560,20 +1676,14 @@ async function runD1Migrate(
1560
1676
  });
1561
1677
  } catch (err) {
1562
1678
  reportLoadError(log, "migrate: failed to load metadata", err);
1563
- return 2;
1679
+ return 1;
1564
1680
  }
1565
1681
  warnReferentialActionConflicts(metadata, collection);
1682
+ warnDecimalPrecision(metadata, collection, "d1");
1566
1683
 
1567
1684
  // 4. Build expected schema + introspect actual.
1568
- let columnNamingStrategy: "snake_case" | "literal" | "kebab-case" = "snake_case";
1569
- try {
1570
- const cfg = await loadMetaobjectsConfig(genRoot);
1571
- if (cfg.columnNamingStrategy) columnNamingStrategy = cfg.columnNamingStrategy;
1572
- } catch {
1573
- // metaobjects.config.ts absent or invalid — use default snake_case
1574
- }
1575
- const expectedViews = buildProjectionViews(metadata, { dialect: "d1", columnNamingStrategy });
1576
- const built = buildExpectedSchemaWithProvenance(metadata, { dialect: "d1", columnNamingStrategy, views: expectedViews });
1685
+ const expectedViews = buildProjectionViews(metadata, { dialect: "d1" });
1686
+ const built = buildExpectedSchemaWithProvenance(metadata, { dialect: "d1", views: expectedViews });
1577
1687
  const scopeRc = refuseScopeMismatch(collection, () => built.provenance, fmt);
1578
1688
  if (scopeRc !== undefined) return scopeRc;
1579
1689
  // Per-command scope — both-sided, exactly as on the Kysely path above.
@@ -1634,6 +1744,7 @@ async function runD1Migrate(
1634
1744
  }
1635
1745
 
1636
1746
  const changeCounts = summarizeChanges(diffResult.changes);
1747
+ const d1Notes = viewRecreateNotes(diffResult.changes);
1637
1748
  warnDataHazards(diffResult.hazards);
1638
1749
 
1639
1750
  // Views are emitted by the one schema-diff path: renderD1 = renderSqlite (which
@@ -1641,16 +1752,20 @@ async function runD1Migrate(
1641
1752
  // BEGIN/COMMIT + PRAGMA that recreate-and-copy emits). There is no separate
1642
1753
  // view-migration emitter; introspectD1 now reads view bodies so unchanged views
1643
1754
  // produce no change and body changes emit a DROP+CREATE.
1755
+ const d1Result = (extra: Partial<MigrateResultShape>): MigrateResultShape => ({
1756
+ dialect: "d1",
1757
+ displayUrl: binding.binding,
1758
+ changeCounts,
1759
+ // No `format` key here on purpose: every d1 output this build ships was produced
1760
+ // without it; adding it would change bytes the tests pin.
1761
+ ...migrateResultDefaults(config.dryRun),
1762
+ ...extra,
1763
+ });
1644
1764
  if (diffResult.changes.length === 0) {
1645
- log.info(`migrate: no schema changes for d1 binding '${binding.binding}'`);
1765
+ finishMigrate(fmt, [`migrate: no schema changes for d1 binding '${binding.binding}'`], migrateResultToData(d1Result({})));
1646
1766
  return 0;
1647
1767
  }
1648
1768
 
1649
- if (config.slug === undefined) {
1650
- log.error(`migrate: --slug <name> required when there are changes`);
1651
- return 2;
1652
- }
1653
-
1654
1769
  // 6. Emit (with D1 safety pass) + write Wrangler migration files.
1655
1770
  let emitResult;
1656
1771
  try {
@@ -1663,6 +1778,7 @@ async function runD1Migrate(
1663
1778
  throw err;
1664
1779
  }
1665
1780
 
1781
+ d1Notes.push(...(emitResult.notices ?? []));
1666
1782
  const combinedUp = emitResult.up;
1667
1783
  const combinedDown = emitResult.down;
1668
1784
 
@@ -1671,19 +1787,32 @@ async function runD1Migrate(
1671
1787
  const migrationsDir = resolveD1OutDir(config, metaRoot, binding.migrations_dir);
1672
1788
 
1673
1789
  if (config.dryRun) {
1674
- log.info(`-- UP --\n${combinedUp}\n\n-- DOWN --\n${combinedDown}`);
1790
+ const sql = { up: combinedUp, down: combinedDown };
1791
+ finishMigrate(fmt, [...narratedNotes(d1Notes), sqlPreview(sql)], migrateResultToData(d1Result({ sql, notes: d1Notes })));
1675
1792
  return 0;
1676
1793
  }
1677
1794
 
1795
+ // A preview (above) names no migration; only a write needs the slug.
1796
+ if (config.slug === undefined) {
1797
+ log.error(`migrate: --slug <name> required when there are changes`);
1798
+ emitStructuredError("migrate: --slug <name> required when there are changes", "re-run with --slug <name>", fmt);
1799
+ return 2;
1800
+ }
1801
+
1678
1802
  const writeResult = await writeMigrationD1(
1679
1803
  { up: combinedUp, down: combinedDown },
1680
1804
  { dir: migrationsDir, slug: config.slug },
1681
1805
  );
1682
- log.info(`migrate: wrote ${writeResult.upPath}`);
1683
- log.info(`migrate: wrote ${writeResult.downPath}`);
1684
- for (const [kind, count] of Object.entries(changeCounts)) {
1685
- log.info(` ${kind}: ${count}`);
1686
- }
1806
+ finishMigrate(
1807
+ fmt,
1808
+ [
1809
+ `migrate: wrote ${writeResult.upPath}`,
1810
+ `migrate: wrote ${writeResult.downPath}`,
1811
+ ...Object.entries(changeCounts).map(([kind, count]) => ` ${kind}: ${count}`),
1812
+ ...narratedNotes(d1Notes),
1813
+ ],
1814
+ migrateResultToData(d1Result({ writtenPaths: [writeResult.upPath, writeResult.downPath], notes: d1Notes })),
1815
+ );
1687
1816
 
1688
1817
  // 7. Optional --apply: run `wrangler d1 migrations apply`.
1689
1818
  if (config.d1.autoApply) {
@@ -1707,7 +1836,8 @@ async function runWranglerApply(
1707
1836
  yes: boolean,
1708
1837
  ): Promise<number> {
1709
1838
  if (remote && !yes) {
1710
- log.info(
1839
+ // Progress, not output: stderr, so it never lands in front of a document on stdout.
1840
+ log.warn(
1711
1841
  `Applying to remote D1 '${databaseName}' (binding=${bindingName}) in 2s — Ctrl+C to abort or pass --yes to skip this pause.`,
1712
1842
  );
1713
1843
  await new Promise<void>((r) => setTimeout(r, 2000));
@@ -1724,3 +1854,4 @@ async function runWranglerApply(
1724
1854
  child.on("close", (code) => resolve(code ?? 1));
1725
1855
  });
1726
1856
  }
1857
+