@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.
- package/dist/diff/index.d.ts.map +1 -1
- package/dist/diff/index.js +141 -16
- package/dist/diff/index.js.map +1 -1
- package/dist/diff/status.js +28 -2
- package/dist/diff/status.js.map +1 -1
- package/dist/drift/drift.d.ts +2 -2
- package/dist/drift/drift.d.ts.map +1 -1
- package/dist/emit/postgres.d.ts.map +1 -1
- package/dist/emit/postgres.js +94 -4
- package/dist/emit/postgres.js.map +1 -1
- package/dist/expected-schema.d.ts +19 -2
- package/dist/expected-schema.d.ts.map +1 -1
- package/dist/expected-schema.js +26 -2
- package/dist/expected-schema.js.map +1 -1
- package/dist/introspect/postgres.d.ts +15 -1
- package/dist/introspect/postgres.d.ts.map +1 -1
- package/dist/introspect/postgres.js +132 -11
- package/dist/introspect/postgres.js.map +1 -1
- package/dist/snapshot/plan.d.ts +4 -3
- package/dist/snapshot/plan.d.ts.map +1 -1
- package/dist/snapshot/plan.js.map +1 -1
- package/dist/snapshot/serialize.d.ts +15 -1
- package/dist/snapshot/serialize.d.ts.map +1 -1
- package/dist/snapshot/serialize.js +15 -1
- package/dist/snapshot/serialize.js.map +1 -1
- package/dist/types.d.ts +95 -7
- package/dist/types.d.ts.map +1 -1
- package/dist/view-column-types.d.ts +40 -0
- package/dist/view-column-types.d.ts.map +1 -0
- package/dist/view-column-types.js +100 -0
- package/dist/view-column-types.js.map +1 -0
- package/dist/view-fingerprint.d.ts +40 -0
- package/dist/view-fingerprint.d.ts.map +1 -0
- package/dist/view-fingerprint.js +88 -0
- package/dist/view-fingerprint.js.map +1 -0
- package/dist/view-sql-compare.d.ts.map +1 -1
- package/dist/view-sql-compare.js +24 -19
- package/dist/view-sql-compare.js.map +1 -1
- package/package.json +2 -2
- package/src/diff/index.ts +151 -19
- package/src/diff/status.ts +29 -2
- package/src/drift/drift.ts +2 -2
- package/src/emit/postgres.ts +102 -4
- package/src/expected-schema.ts +44 -3
- package/src/introspect/postgres.ts +148 -10
- package/src/snapshot/plan.ts +4 -3
- package/src/snapshot/serialize.ts +15 -1
- package/src/types.ts +109 -9
- package/src/view-column-types.ts +110 -0
- package/src/view-fingerprint.ts +97 -0
- 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).
|
|
208
|
-
//
|
|
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
|
-
|
|
212
|
-
|
|
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
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
//
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
}
|
package/src/diff/status.ts
CHANGED
|
@@ -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
|
}
|
package/src/drift/drift.ts
CHANGED
|
@@ -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
|
|
40
|
+
* itself; pass these so view drift is detected. Defaults to none.
|
|
41
41
|
*/
|
|
42
|
-
views?: readonly import("../
|
|
42
|
+
views?: readonly import("../expected-schema.js").ExpectedViewInput[];
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
/**
|
package/src/emit/postgres.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
132
|
-
case "
|
|
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
|
-
|
|
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
|
}
|
package/src/expected-schema.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
}
|