@metaobjectsdev/migrate-ts 0.15.21 → 0.16.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 (51) hide show
  1. package/dist/diff/index.d.ts.map +1 -1
  2. package/dist/diff/index.js +141 -16
  3. package/dist/diff/index.js.map +1 -1
  4. package/dist/diff/status.js +28 -2
  5. package/dist/diff/status.js.map +1 -1
  6. package/dist/drift/drift.d.ts +2 -2
  7. package/dist/drift/drift.d.ts.map +1 -1
  8. package/dist/emit/postgres.d.ts.map +1 -1
  9. package/dist/emit/postgres.js +94 -4
  10. package/dist/emit/postgres.js.map +1 -1
  11. package/dist/expected-schema.d.ts +19 -2
  12. package/dist/expected-schema.d.ts.map +1 -1
  13. package/dist/expected-schema.js +26 -2
  14. package/dist/expected-schema.js.map +1 -1
  15. package/dist/introspect/postgres.d.ts +15 -1
  16. package/dist/introspect/postgres.d.ts.map +1 -1
  17. package/dist/introspect/postgres.js +132 -11
  18. package/dist/introspect/postgres.js.map +1 -1
  19. package/dist/snapshot/plan.d.ts +4 -3
  20. package/dist/snapshot/plan.d.ts.map +1 -1
  21. package/dist/snapshot/plan.js.map +1 -1
  22. package/dist/snapshot/serialize.d.ts +15 -1
  23. package/dist/snapshot/serialize.d.ts.map +1 -1
  24. package/dist/snapshot/serialize.js +15 -1
  25. package/dist/snapshot/serialize.js.map +1 -1
  26. package/dist/types.d.ts +95 -7
  27. package/dist/types.d.ts.map +1 -1
  28. package/dist/view-column-types.d.ts +40 -0
  29. package/dist/view-column-types.d.ts.map +1 -0
  30. package/dist/view-column-types.js +100 -0
  31. package/dist/view-column-types.js.map +1 -0
  32. package/dist/view-fingerprint.d.ts +40 -0
  33. package/dist/view-fingerprint.d.ts.map +1 -0
  34. package/dist/view-fingerprint.js +88 -0
  35. package/dist/view-fingerprint.js.map +1 -0
  36. package/dist/view-sql-compare.d.ts.map +1 -1
  37. package/dist/view-sql-compare.js +24 -19
  38. package/dist/view-sql-compare.js.map +1 -1
  39. package/package.json +2 -2
  40. package/src/diff/index.ts +151 -19
  41. package/src/diff/status.ts +29 -2
  42. package/src/drift/drift.ts +2 -2
  43. package/src/emit/postgres.ts +102 -4
  44. package/src/expected-schema.ts +44 -3
  45. package/src/introspect/postgres.ts +148 -10
  46. package/src/snapshot/plan.ts +4 -3
  47. package/src/snapshot/serialize.ts +15 -1
  48. package/src/types.ts +109 -9
  49. package/src/view-column-types.ts +110 -0
  50. package/src/view-fingerprint.ts +97 -0
  51. package/src/view-sql-compare.ts +24 -19
package/src/diff/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  SchemaSnapshot, TableDescriptor, ColumnDescriptor, IndexDescriptor, FkDescriptor,
3
3
  ViewDescriptor,
4
+ DependentRelation,
4
5
  Change, ChangeStatus, DiffResult, AllowOptions, AmbiguousCallback, Dialect,
5
6
  } from "../types.js";
6
7
  import type { SqlType } from "../sql-type.js";
@@ -8,6 +9,7 @@ import { sqlTypeEquals } from "../sql-type.js";
8
9
  import { applyStatus } from "./status.js";
9
10
  import { detectColumnRenames, detectTableRenames } from "./rename-heuristic.js";
10
11
  import { viewSqlEquals } from "../view-sql-compare.js";
12
+ import { viewReplaceIsLegal } from "../view-column-types.js";
11
13
  import { checkExprEquals, normalizeCheckExpr } from "../check-expr-compare.js";
12
14
  import { DEFAULT_DB_SCHEMA_POSTGRES } from "@metaobjectsdev/metadata";
13
15
 
@@ -204,21 +206,26 @@ export async function diff(
204
206
  if (args.dialect !== undefined) diffTableChecks(expectedTable, actualTable, changes);
205
207
  }
206
208
 
207
- // Pass 2b: views. Identity is (schema, name). A name present on both sides
208
- // with a divergent body (whitespace-/wrapper-normalized) emits replace-view;
209
- // introspect now reads the actual body so view-body drift is visible.
209
+ // Pass 2b: views. Identity is (schema, name). How "changed" is decided is
210
+ // DIALECT-DEPENDENT see diffViews.
210
211
  const expectedViewsInScope = args.expected.views.filter((v) => inScope(v.schema));
211
- diffViews(
212
- expectedViewsInScope,
213
- args.actual.views.filter((v) => inScope(v.schema)),
214
- changes,
215
- );
212
+ const actualViewsInScope = args.actual.views.filter((v) => inScope(v.schema));
213
+ diffViews(expectedViewsInScope, actualViewsInScope, changes, args.dialect);
216
214
 
217
215
  // Pass 2c: a column-altering change to a table a view reads forces the view to be
218
216
  // dropped before and recreated after — postgres blocks ALTER on a column a view
219
217
  // depends on, and sqlite rebuilds the table via recreate-and-copy. Body-unchanged
220
218
  // dependent views get no change from Pass 2b, so this is where they're picked up.
221
- recreateViewsDependingOnChangedTables(expectedViewsInScope, changes);
219
+ recreateViewsDependingOnChangedTables(expectedViewsInScope, actualViewsInScope, changes);
220
+
221
+ // Any drop-view (from Pass 2b or 2c) that would destroy relations we do NOT manage
222
+ // must say so — a plain DROP fails at apply, and a CASCADE destroys them for good.
223
+ //
224
+ // Deliberately fed the UNSCOPED actual views: CASCADE does not respect our schema
225
+ // scoping. A downstream application's view lives in ITS OWN schema — precisely the
226
+ // schema the model never declares, and therefore the one scoping filters out — and
227
+ // that is exactly the object a cascade must not destroy silently.
228
+ annotateViewDropDependents(args.actual.views, changes);
222
229
 
223
230
  // Pass 3: detect table renames BEFORE column renames — so a renamed table's
224
231
  // columns are not scanned as orphaned drop/add pairs.
@@ -434,29 +441,147 @@ function viewIdentity(v: { name: string; schema?: string }): string {
434
441
  return (v.schema ?? DEFAULT_DB_SCHEMA_POSTGRES) + "." + v.name;
435
442
  }
436
443
 
444
+ /**
445
+ * Decide, per view, whether the DB matches the model.
446
+ *
447
+ * TWO comparison strategies, because the engines differ in kind, not degree:
448
+ *
449
+ * - SQLite/D1 store the view's SQL verbatim, so comparing the normalized body is
450
+ * exact. Unchanged behavior.
451
+ *
452
+ * - Postgres stores a PARSE TREE. `pg_get_viewdef()` deparses it into Postgres's own
453
+ * style (`LEFT OUTER JOIN` → `LEFT JOIN`, parenthesized FROM items, lowercased
454
+ * functions, dropped aliases), so the text we wrote can NEVER come back. Comparing
455
+ * it reported a difference every single time — which is exactly why replace-view
456
+ * fired on every migrate, forever, and why `verify --db` was permanently red for
457
+ * any project with a projection. Postgres therefore compares FINGERPRINTS: a hash
458
+ * of the body we generated, stamped into the view's COMMENT at emit time. Both
459
+ * sides of that comparison come from our emitter; the deparser is never consulted.
460
+ *
461
+ * An unknown dialect keeps the old body comparison (legacy positional callers hold
462
+ * hand-built snapshots; changing their semantics silently is worse than leaving them).
463
+ */
437
464
  function diffViews(
438
465
  expected: ViewDescriptor[], actual: ViewDescriptor[], changes: Change[],
466
+ dialect: Dialect | undefined,
439
467
  ): void {
440
468
  const exp = new Map(expected.map((v) => [viewIdentity(v), v] as const));
441
469
  const act = new Map(actual.map((v) => [viewIdentity(v), v] as const));
470
+
442
471
  for (const [id, v] of exp) {
443
472
  const a = act.get(id);
444
473
  if (a === undefined) {
445
474
  changes.push({ kind: "create-view", view: v, ...schemaSpread(v.schema), status: ALLOWED });
446
- } else if (
447
- // Both bodies known and divergent → replace-view. When either body is
448
- // absent (e.g. expected projection unresolvable, or an introspector that
449
- // couldn't read the body) we cannot prove a change, so we leave it alone
450
- // rather than emit a spurious replace.
451
- v.sql !== undefined && a.sql !== undefined && !viewSqlEquals(v.sql, a.sql)
452
- ) {
453
- changes.push({ kind: "replace-view", view: v, ...schemaSpread(v.schema), status: ALLOWED });
475
+ continue;
476
+ }
477
+
478
+ if (dialect === "postgres") {
479
+ // No expected fingerprint (an unresolvable projection body) → we cannot prove a
480
+ // change. Leave it alone rather than propose a spurious replace.
481
+ if (v.fingerprint === undefined) continue;
482
+
483
+ // The DB view carries no stamp. It is EITHER a view created before fingerprinting
484
+ // existed, OR somebody's hand-written SQL sitting at a projection's name — and on
485
+ // Postgres those are indistinguishable, because the deparser destroyed the text
486
+ // evidence. Overwriting the second destroys work nothing can restore, so fail
487
+ // closed: propose the replace but BLOCK it pending `allow.adoptView`.
488
+ if (a.fingerprint === undefined) {
489
+ changes.push({
490
+ kind: "replace-view", view: v, ...schemaSpread(v.schema),
491
+ restore: a, unmanagedActual: true, status: ALLOWED,
492
+ });
493
+ continue;
494
+ }
495
+
496
+ // Stamped and equal → converged. THIS is the line that makes `meta migrate` a
497
+ // no-op on an unchanged schema.
498
+ if (a.fingerprint === v.fingerprint) continue;
499
+
500
+ // Stamped and different → the view genuinely changed. Prefer a non-destructive
501
+ // CREATE OR REPLACE; fall back to drop+create only when Postgres would refuse it.
502
+ pushViewUpdate(v, a, changes);
503
+ continue;
504
+ }
505
+
506
+ // SQLite/D1 (and unknown dialects): verbatim body comparison.
507
+ if (v.sql !== undefined && a.sql !== undefined && !viewSqlEquals(v.sql, a.sql)) {
508
+ changes.push({
509
+ kind: "replace-view", view: v, ...schemaSpread(v.schema), restore: a, status: ALLOWED,
510
+ });
454
511
  }
455
512
  }
513
+
456
514
  for (const [id, v] of act) {
457
515
  if (!exp.has(id)) {
458
- changes.push({ kind: "drop-view", view: v.name, ...schemaSpread(v.schema), status: ALLOWED });
516
+ changes.push({
517
+ kind: "drop-view", view: v.name, ...schemaSpread(v.schema), restore: v, status: ALLOWED,
518
+ });
519
+ }
520
+ }
521
+ }
522
+
523
+ /**
524
+ * Emit either a non-destructive replace, or the drop+create pair Postgres forces.
525
+ *
526
+ * Replace is strictly better when it is legal: dependent views, grants, and the
527
+ * object's OID all survive. And it IS legal for the common case by construction — a
528
+ * view's columns come out in projection DECLARATION order, so a field APPENDED to a
529
+ * projection lands last, which is exactly what Postgres's prefix rule permits.
530
+ */
531
+ function pushViewUpdate(expected: ViewDescriptor, actual: ViewDescriptor, changes: Change[]): void {
532
+ const sx = schemaSpread(expected.schema);
533
+ if (viewReplaceIsLegal(expected.columns, actual.columns)) {
534
+ changes.push({ kind: "replace-view", view: expected, ...sx, restore: actual, status: ALLOWED });
535
+ return;
536
+ }
537
+ // The column list changed shape (removed / renamed / reordered / retyped), so
538
+ // Postgres refuses OR REPLACE. The view must be dropped and rebuilt — which is
539
+ // destructive to anything depending on it (annotateViewDropDependents makes that loud).
540
+ changes.push({ kind: "drop-view", view: expected.name, ...sx, restore: actual, status: ALLOWED });
541
+ changes.push({ kind: "create-view", view: expected, ...sx, status: ALLOWED });
542
+ }
543
+
544
+ /**
545
+ * Attach, to every planned `drop-view`, the relations a CASCADE would destroy.
546
+ *
547
+ * Only EXTERNAL dependents count. A managed view that this same migration drops and
548
+ * recreates is not a loss — it comes back. Everything else is: an unmanaged view, a
549
+ * materialized view, or a managed view not part of this migration. Those belong to
550
+ * someone else, a CASCADE destroys them irrecoverably, and this tool cannot restore
551
+ * what it does not manage.
552
+ *
553
+ * Dependencies are transitive: dropping A cascades to B which cascades to C.
554
+ */
555
+ function annotateViewDropDependents(actual: readonly ViewDescriptor[], changes: Change[]): void {
556
+ const byId = new Map(actual.map((v) => [viewIdentity(v), v] as const));
557
+ // Views this migration drops AND recreates — they survive the migration, so a
558
+ // dependent that is one of them is not destroyed.
559
+ const recreated = new Set(
560
+ changes
561
+ .filter((c): c is Extract<Change, { kind: "create-view" }> => c.kind === "create-view")
562
+ .map((c) => viewIdentity(c.view)),
563
+ );
564
+
565
+ for (const c of changes) {
566
+ if (c.kind !== "drop-view") continue;
567
+ const dropped = viewIdentity({ name: c.view, ...(c.schema !== undefined ? { schema: c.schema } : {}) });
568
+
569
+ // Transitive closure over the dependency edges introspection gave us.
570
+ const seen = new Set<string>([dropped]);
571
+ const queue = [dropped];
572
+ const external: DependentRelation[] = [];
573
+ while (queue.length > 0) {
574
+ const cur = queue.shift()!;
575
+ for (const dep of byId.get(cur)?.dependents ?? []) {
576
+ const depId = viewIdentity(dep);
577
+ if (seen.has(depId)) continue;
578
+ seen.add(depId);
579
+ queue.push(depId);
580
+ if (dep.managed && recreated.has(depId)) continue; // comes back — not a loss
581
+ external.push(dep);
582
+ }
459
583
  }
584
+ if (external.length > 0) c.dependents = external;
460
585
  }
461
586
  }
462
587
 
@@ -468,8 +593,10 @@ const VIEW_RECREATE_TRIGGERS = new Set<Change["kind"]>([
468
593
 
469
594
  function recreateViewsDependingOnChangedTables(
470
595
  expectedViews: ViewDescriptor[],
596
+ actualViews: ViewDescriptor[],
471
597
  changes: Change[],
472
598
  ): void {
599
+ const actualById = new Map(actualViews.map((v) => [viewIdentity(v), v] as const));
473
600
  const alteredTables = new Set<string>();
474
601
  for (const c of changes) {
475
602
  if (VIEW_RECREATE_TRIGGERS.has(c.kind)) {
@@ -494,7 +621,12 @@ function recreateViewsDependingOnChangedTables(
494
621
  const c = changes[i]!;
495
622
  if (c.kind === "replace-view" && viewIdentity(c.view) === id) changes.splice(i, 1);
496
623
  }
497
- changes.push({ kind: "drop-view", view: v.name, ...schemaSpread(v.schema), status: ALLOWED });
624
+ const prior = actualById.get(id);
625
+ changes.push({
626
+ kind: "drop-view", view: v.name, ...schemaSpread(v.schema),
627
+ ...(prior !== undefined ? { restore: prior } : {}),
628
+ status: ALLOWED,
629
+ });
498
630
  changes.push({ kind: "create-view", view: v, ...schemaSpread(v.schema), status: ALLOWED });
499
631
  }
500
632
  }
@@ -57,10 +57,38 @@ function blockedReasonFor(
57
57
  return allow.dropFk ? null : "destructive: drop-fk not allowed (pass allow.dropFk)";
58
58
  case "drop-check":
59
59
  return allow.dropCheck ? null : "destructive: drop-check not allowed (pass allow.dropCheck)";
60
- case "drop-view":
60
+ case "drop-view": {
61
+ // A drop that would destroy relations we do NOT manage is gated INDEPENDENTLY of
62
+ // whether our own view survives. Even the recreate pair — where our view comes
63
+ // back — cascades through to somebody else's dependent view and destroys it for
64
+ // good, and this tool cannot restore what it does not manage. So check dependents
65
+ // FIRST: it is the one thing `allow.dropView` must never be able to wave through.
66
+ const external = c.dependents ?? [];
67
+ if (external.length > 0 && !allow.dropViewCascade) {
68
+ return `destructive: dropping view "${c.view}" would CASCADE into ${external.length} object(s) `
69
+ + `MetaObjects does not manage and cannot restore `
70
+ + `(${external.map((d) => `${d.schema}.${d.name}`).join(", ")}) `
71
+ + `— pass allow.dropViewCascade to destroy them, or migrate them off this view first`;
72
+ }
61
73
  // Identity, not bare name — see viewIdentity().
62
74
  if (recreatedViews.has(viewIdentity(c.view, c.schema))) return null; // recreate pair — view survives
63
75
  return allow.dropView ? null : "destructive: drop-view not allowed (pass allow.dropView)";
76
+ }
77
+
78
+ case "replace-view":
79
+ // The DB view carries no MetaObjects fingerprint, so it is either hand-written or
80
+ // predates fingerprinting — and Postgres deparses view SQL, so the two are
81
+ // indistinguishable. Overwriting a hand-written view destroys SQL no down
82
+ // migration can recover, so fail closed and make the operator say yes.
83
+ //
84
+ // Every environment upgrading from a pre-fingerprint toolchain hits this exactly
85
+ // once, then its views are stamped and it never fires again.
86
+ if (c.unmanagedActual && !allow.adoptView) {
87
+ return `existing view "${c.view.name}" carries no MetaObjects fingerprint — it is hand-written, `
88
+ + `or was created before view fingerprinting. Overwriting it takes ownership and cannot be undone. `
89
+ + `Pass allow.adoptView to adopt it`;
90
+ }
91
+ return null;
64
92
 
65
93
  case "change-column-type":
66
94
  if (isWidening(c.from, c.to)) return null; // widening always allowed
@@ -83,7 +111,6 @@ function blockedReasonFor(
83
111
  case "add-fk":
84
112
  case "add-check":
85
113
  case "create-view":
86
- case "replace-view":
87
114
  return null;
88
115
  }
89
116
  }
@@ -37,9 +37,9 @@ export interface ComputeDriftOptions {
37
37
  /**
38
38
  * Expected views (projection → CREATE VIEW body), computed by the caller via
39
39
  * codegen-ts's `buildProjectionViews`. migrate-ts no longer generates view DDL
40
- * itself; pass these so view-body drift is detected. Defaults to none.
40
+ * itself; pass these so view drift is detected. Defaults to none.
41
41
  */
42
- views?: readonly import("../types.js").ViewDescriptor[];
42
+ views?: readonly import("../expected-schema.js").ExpectedViewInput[];
43
43
  }
44
44
 
45
45
  /**
@@ -4,6 +4,8 @@ import type {
4
4
  } from "../types.js";
5
5
  import type { SqlType } from "../sql-type.js";
6
6
  import { DEFAULT_DB_SCHEMA_POSTGRES } from "@metaobjectsdev/metadata";
7
+ import { renderFingerprintMarker, viewFingerprint } from "../view-fingerprint.js";
8
+ import { viewReplaceIsLegal } from "../view-column-types.js";
7
9
 
8
10
  // Stages run low → high. drop-view + drop-fk run BEFORE drop-table so a view
9
11
  // that depends on a soon-to-be-dropped table is removed first. create-view
@@ -69,7 +71,7 @@ function renderUp(c: Change): string {
69
71
  case "add-check": return `ALTER TABLE ${quoteQualified(c.table, c.schema)} ADD CONSTRAINT ${quote(c.check.name)} CHECK (${c.check.expression});`;
70
72
  case "drop-check": return `ALTER TABLE ${quoteQualified(c.table, c.schema)} DROP CONSTRAINT ${quote(c.check)};`;
71
73
  case "create-view": return renderCreateView(c.view, c.schema, /* orReplace */ false);
72
- case "drop-view": return `DROP VIEW ${quoteQualifiedView(c.view, c.schema)};`;
74
+ case "drop-view": return renderDropView(c);
73
75
  case "replace-view": return renderCreateView(c.view, c.schema, /* orReplace */ true);
74
76
  }
75
77
  }
@@ -128,8 +130,9 @@ function renderDown(c: Change): string {
128
130
  ? `ALTER TABLE ${quoteQualified(c.table, c.schema)} ADD CONSTRAINT ${quote(c.restore.name)} CHECK (${c.restore.expression});`
129
131
  : `-- WARNING: down migration cannot restore the original CHECK definition`;
130
132
  case "create-view": return `DROP VIEW ${quoteQualifiedView(c.view.name, c.schema)};`;
131
- case "drop-view": return `-- WARNING: down migration cannot restore the original view definition`;
132
- case "replace-view": return `-- WARNING: down migration cannot restore the original view definition`;
133
+ // The deparsed body introspection captured IS a valid restore payload.
134
+ case "drop-view": return renderRestoreView(c.restore, c.view, c.schema);
135
+ case "replace-view": return renderRestoreView(c.restore, c.view.name, c.schema, c.view);
133
136
  }
134
137
  }
135
138
 
@@ -283,10 +286,105 @@ function quoteQualifiedView(view: string, schema: string | undefined): string {
283
286
  return quoteQualified(view, schema);
284
287
  }
285
288
 
289
+ /**
290
+ * Emit the view AND stamp it with the fingerprint of the body we just wrote.
291
+ *
292
+ * The stamp is not decoration — it is the ONLY way a later migrate can tell whether
293
+ * this view is up to date. Postgres does not store view SQL (it deparses it from the
294
+ * parse tree), so the text can never be read back and compared; the fingerprint in the
295
+ * view's COMMENT can. Drop the stamp and every migrate re-proposes every view forever.
296
+ *
297
+ * `CREATE OR REPLACE VIEW` does NOT clear an existing comment, so re-stamping on every
298
+ * replace is both required (the body changed → the hash changed) and sufficient.
299
+ */
286
300
  function renderCreateView(v: ViewDescriptor, schema: string | undefined, orReplace: boolean): string {
287
301
  if (v.sql === undefined || v.sql.trim().length === 0) {
288
302
  throw new Error(`view "${v.name}" has no sql body — buildExpectedSchema must populate it before emit`);
289
303
  }
290
304
  const prefix = orReplace ? "CREATE OR REPLACE VIEW" : "CREATE VIEW";
291
- return `${prefix} ${quoteQualifiedView(v.name, schema)} AS\n${v.sql};`;
305
+ const qualified = quoteQualifiedView(v.name, schema);
306
+ const create = `${prefix} ${qualified} AS\n${v.sql};`;
307
+ const fingerprint = v.fingerprint ?? viewFingerprint(v.sql);
308
+ return `${create}\n${renderViewComment(qualified, renderFingerprintMarker(fingerprint))}`;
309
+ }
310
+
311
+ function renderViewComment(qualifiedView: string, comment: string | null): string {
312
+ if (comment === null) return `COMMENT ON VIEW ${qualifiedView} IS NULL;`;
313
+ return `COMMENT ON VIEW ${qualifiedView} IS '${comment.replace(/'/g, "''")}';`;
314
+ }
315
+
316
+ /**
317
+ * DROP VIEW, plus — when the drop would cascade into relations we do not manage — a
318
+ * banner naming every one of them.
319
+ *
320
+ * The banner lives in the emitted SQL rather than only in CLI output on purpose: the
321
+ * migration file is committed and code-reviewed, and "this statement destroys three
322
+ * objects belonging to another application" is exactly the thing a reviewer must see.
323
+ *
324
+ * CASCADE is emitted ONLY when explicitly allowed. Otherwise a plain DROP VIEW is
325
+ * emitted even if dependents are known — so if a dependent appeared between introspect
326
+ * and apply, Postgres itself refuses the drop rather than silently destroying it.
327
+ */
328
+ function renderDropView(c: Extract<Change, { kind: "drop-view" }>): string {
329
+ const qualified = quoteQualifiedView(c.view, c.schema);
330
+ const dependents = c.dependents ?? [];
331
+ if (dependents.length === 0) return `DROP VIEW ${qualified};`;
332
+
333
+ const listed = dependents
334
+ .map((d) => `-- ${d.schema}.${d.name} (${d.relkind === "m" ? "materialized view" : "view"})`)
335
+ .join("\n");
336
+ const rule = "-- " + "=".repeat(74);
337
+ return [
338
+ rule,
339
+ "-- WARNING: CASCADE DROP. The following dependent objects are DESTROYED by this",
340
+ "-- statement. MetaObjects does not manage them and the down migration does NOT",
341
+ "-- restore them:",
342
+ listed,
343
+ rule,
344
+ `DROP VIEW ${qualified} CASCADE;`,
345
+ ].join("\n");
346
+ }
347
+
348
+ /**
349
+ * Restore a view the up migration dropped or replaced.
350
+ *
351
+ * The payload is Postgres's own deparsed body (`pg_get_viewdef`) — useless for
352
+ * COMPARISON, but perfectly valid SQL that reproduces the view. So the thing that made
353
+ * the bug (the deparser) is what makes down migrations restorable. The prior stamp is
354
+ * replayed verbatim: the restored parse tree IS the pre-migration view, so its old
355
+ * fingerprint is still the truthful one.
356
+ */
357
+ function renderRestoreView(
358
+ restore: ViewDescriptor | undefined,
359
+ name: string,
360
+ schema: string | undefined,
361
+ /** The view the UP migration left in place — i.e. what the down is replacing. */
362
+ current?: ViewDescriptor,
363
+ ): string {
364
+ if (restore?.sql === undefined || restore.sql.trim().length === 0) {
365
+ return `-- WARNING: down migration cannot restore the original view definition`;
366
+ }
367
+ const qualified = quoteQualifiedView(name, schema);
368
+ const body = restore.sql.trim().replace(/;\s*$/, "");
369
+
370
+ // Postgres's OR-REPLACE prefix rule applies to the down migration too, and usually
371
+ // REFUSES: undoing an appended field means REMOVING a view column, and
372
+ // `CREATE OR REPLACE VIEW` cannot drop columns ("cannot drop columns from view").
373
+ // So ask the same question the forward path asks, with the arguments swapped — and
374
+ // fall back to DROP + CREATE when the answer is no.
375
+ //
376
+ // The fallback DROP is deliberately NOT `CASCADE`: if a dependent has since been built
377
+ // on the newer shape, Postgres refuses the down migration rather than silently
378
+ // destroying that dependent. Loud beats convenient.
379
+ const stamp = restore.fingerprint !== undefined
380
+ ? renderViewComment(qualified, renderFingerprintMarker(restore.fingerprint))
381
+ // The view being restored carried NO fingerprint (hand-written, or pre-stamping).
382
+ // Clear ours — otherwise the restored view would advertise a stamp for a body it
383
+ // does not have, and the next migrate would believe the stamp and skip it.
384
+ : renderViewComment(qualified, null);
385
+
386
+ if (current !== undefined && !viewReplaceIsLegal(restore.columns, current.columns)) {
387
+ return `DROP VIEW ${qualified};\nCREATE VIEW ${qualified} AS\n${body};\n${stamp}`;
388
+ }
389
+ return `CREATE OR REPLACE VIEW ${qualified} AS\n${body};\n${stamp}`;
292
390
  }
@@ -59,6 +59,8 @@ import type {
59
59
  Dialect, SchemaSnapshot, TableDescriptor, ColumnDescriptor, IndexDescriptor, FkDescriptor,
60
60
  CheckDescriptor, ViewDescriptor,
61
61
  } from "./types.js";
62
+ import { viewFingerprint } from "./view-fingerprint.js";
63
+ import { resolveViewColumns, type ExpectedViewColumnInput } from "./view-column-types.js";
62
64
  import {
63
65
  resolveReferentialActions,
64
66
  validateSetNullNullability,
@@ -89,8 +91,25 @@ export interface BuildExpectedSchemaOptions {
89
91
  * generate view DDL itself (it stays dependency-pure — never importing the
90
92
  * code generator); view SQL has a single source, `emitViewDdl` in codegen-ts.
91
93
  * Defaults to none.
94
+ *
95
+ * The caller supplies each view's output columns PHYSICALLY but untyped
96
+ * (codegen-ts knows nothing of SqlType); Pass 4 resolves them against the
97
+ * expected tables and computes the view's fingerprint.
92
98
  */
93
- views?: readonly ViewDescriptor[];
99
+ views?: readonly ExpectedViewInput[];
100
+ }
101
+
102
+ /**
103
+ * A view as the caller (codegen-ts `buildProjectionViews`) produces it — structurally
104
+ * an `ExpectedView`. It becomes a full `ViewDescriptor` in Pass 4, which is where the
105
+ * fingerprint is computed and the column types are resolved.
106
+ */
107
+ export interface ExpectedViewInput {
108
+ name: string;
109
+ schema?: string;
110
+ sql?: string;
111
+ dependsOn?: readonly string[];
112
+ columns?: readonly ExpectedViewColumnInput[];
94
113
  }
95
114
 
96
115
  export function buildExpectedSchema(
@@ -209,7 +228,24 @@ export function buildExpectedSchema(
209
228
  // Pass 4: views from read-only projections — supplied by the caller (computed
210
229
  // via codegen-ts's buildProjectionViews, the single view-SQL source). migrate-ts
211
230
  // never generates view DDL itself, keeping it free of a codegen-ts dependency.
212
- const views = (opts?.views ?? []) as ViewDescriptor[];
231
+ //
232
+ // Two things are derived here rather than by the caller:
233
+ // - the FINGERPRINT, a hash of the generated body. It is the only sound way to
234
+ // compare a view against Postgres, which deparses view SQL and so can never
235
+ // hand back the text we wrote (see view-fingerprint.ts). Computed here so the
236
+ // producer and the parser of the marker live in one package.
237
+ // - the column SQL TYPES, resolved against the tables built above. They decide
238
+ // whether a view change can use a non-destructive CREATE OR REPLACE.
239
+ const views: ViewDescriptor[] = (opts?.views ?? []).map((v) => {
240
+ const columns = resolveViewColumns(v.columns, tables);
241
+ return {
242
+ name: v.name,
243
+ ...(v.schema !== undefined ? { schema: v.schema } : {}),
244
+ ...(v.sql !== undefined ? { sql: v.sql, fingerprint: viewFingerprint(v.sql) } : {}),
245
+ ...(v.dependsOn !== undefined ? { dependsOn: v.dependsOn } : {}),
246
+ ...(columns !== undefined ? { columns } : {}),
247
+ };
248
+ });
213
249
 
214
250
  return { tables, views };
215
251
  }
@@ -928,8 +964,13 @@ function subtypeToSqlType(field: MetaData): SqlType {
928
964
  if (typeof precision === "number" && typeof scale === "number") {
929
965
  return { kind: "numeric", precision, scale };
930
966
  }
967
+ // NUMERIC(p) IS NUMERIC(p,0) — the SQL standard defaults an omitted scale
968
+ // to zero, and Postgres stores it that way (information_schema reports
969
+ // numeric_scale = 0, not NULL). Model the scale the database will actually
970
+ // hold, or expected (bare p) and introspected (p, 0) can never converge and
971
+ // the column churns change-column-type on every migrate.
931
972
  if (typeof precision === "number") {
932
- return { kind: "numeric", precision };
973
+ return { kind: "numeric", precision, scale: 0 };
933
974
  }
934
975
  return { kind: "numeric" };
935
976
  }