@metaobjectsdev/migrate-ts 0.15.21-rc.1 → 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
@@ -30,6 +30,9 @@ import type { Kysely } from "kysely";
30
30
  import { sql } from "kysely";
31
31
  import type { SchemaSnapshot, TableDescriptor, ColumnDescriptor, ColumnDefault, IndexDescriptor, FkDescriptor, FkAction, ViewDescriptor, CheckDescriptor } from "../types.js";
32
32
  import type { SqlType } from "../sql-type.js";
33
+ import type { DependentRelation } from "../types.js";
34
+ import { DEFAULT_DB_SCHEMA_POSTGRES } from "@metaobjectsdev/metadata";
35
+ import { parseFingerprintMarker } from "../view-fingerprint.js";
33
36
  import { MIGRATIONS_TABLE } from "../apply/ledger.js";
34
37
  import { stripCheckWrapper } from "../check-expr-compare.js";
35
38
 
@@ -76,12 +79,32 @@ export async function introspectPostgres(db: Kysely<Record<string, unknown>>): P
76
79
  * before matching.
77
80
  *
78
81
  * If character_maximum_length is available, callers should pass `maxLength`.
82
+ *
83
+ * `numeric` carries its qualifier OUT-OF-BAND: `data_type` is a bare "numeric"
84
+ * for both NUMERIC and NUMERIC(9,4), so callers reading information_schema must
85
+ * pass `numeric.precision` / `numeric.scale` (from numeric_precision /
86
+ * numeric_scale) or the qualifier is lost. An inline "numeric(9,4)" string is
87
+ * still parsed — that form arrives from format_type()/pg_catalog callers and
88
+ * from unit tests — but it is NOT what information_schema produces.
89
+ *
90
+ * Only the numeric/decimal branch consults the qualifier: information_schema
91
+ * also populates numeric_precision for INTEGER columns (int4 → 32, radix 2),
92
+ * which has nothing to do with a NUMERIC qualifier.
79
93
  */
80
- export function pgTypeToSqlType(dataType: string, maxLength?: number | null, udtName?: string): SqlType {
94
+ export function pgTypeToSqlType(
95
+ dataType: string,
96
+ maxLength?: number | null,
97
+ udtName?: string,
98
+ numeric?: { precision: number | null; scale: number | null },
99
+ ): SqlType {
81
100
  const dt = dataType.toLowerCase().trim();
82
101
 
83
102
  // Array columns: information_schema reports data_type "ARRAY" and the element
84
103
  // type in udt_name with a leading underscore (e.g. "_uuid", "_text", "_varchar").
104
+ // The element is deliberately left UNQUALIFIED: information_schema reports no
105
+ // qualifiers for array elements (numeric_precision/character_maximum_length are
106
+ // NULL when data_type = 'ARRAY'), and the expected side builds unqualified
107
+ // elements to match (see arrayElementSqlType in expected-schema).
85
108
  if (dt === "array" && typeof udtName === "string") {
86
109
  const elemUdt = udtName.replace(/^_/, "").toLowerCase();
87
110
  return { kind: "array", element: pgTypeToSqlType(elemUdt, maxLength) };
@@ -117,12 +140,20 @@ export function pgTypeToSqlType(dataType: string, maxLength?: number | null, udt
117
140
  if (dt === "float4" || dt === "real") return { kind: "real4" };
118
141
  if (dt === "float8" || dt === "double precision") return { kind: "real" };
119
142
 
120
- // Arbitrary-precision numeric — "numeric(p,s)" or bare "numeric"/"decimal"
143
+ // Arbitrary-precision numeric — "numeric(p,s)" or bare "numeric"/"decimal".
144
+ // An inline qualifier wins when present; otherwise fall back to the
145
+ // out-of-band numeric_precision/numeric_scale, which is the only form
146
+ // information_schema ever gives us. An unconstrained NUMERIC reports both as
147
+ // NULL and stays a bare { kind: "numeric" }.
121
148
  const numMatch = /^(?:numeric|decimal)(?:\((\d+)(?:,\s*(\d+))?\))?$/.exec(dt);
122
149
  if (numMatch) {
123
150
  const out: SqlType = { kind: "numeric" };
124
- if (numMatch[1]) out.precision = parseInt(numMatch[1], 10);
125
- if (numMatch[2]) out.scale = parseInt(numMatch[2], 10);
151
+ const precision = numMatch[1] !== undefined ? parseInt(numMatch[1], 10) : numeric?.precision ?? null;
152
+ const scale = numMatch[1] !== undefined
153
+ ? (numMatch[2] !== undefined ? parseInt(numMatch[2], 10) : null)
154
+ : numeric?.scale ?? null;
155
+ if (precision !== null) out.precision = precision;
156
+ if (scale !== null) out.scale = scale;
126
157
  return out;
127
158
  }
128
159
 
@@ -257,10 +288,16 @@ async function readPgViews(k: RawKysely): Promise<ViewDescriptor[]> {
257
288
  // `public`) belongs to the extension, not the model — reporting it makes
258
289
  // the very next migrate propose `DROP VIEW "pg_stat_statements";`, and
259
290
  // dropping it would break the extension.
260
- const rows = await sql<{ table_name: string; table_schema: string; view_definition: string | null }>`
291
+ const rows = await sql<{
292
+ table_name: string;
293
+ table_schema: string;
294
+ view_definition: string | null;
295
+ view_comment: string | null;
296
+ }>`
261
297
  SELECT c.relname AS table_name,
262
298
  n.nspname AS table_schema,
263
- pg_get_viewdef(c.oid) AS view_definition
299
+ pg_get_viewdef(c.oid) AS view_definition,
300
+ obj_description(c.oid, 'pg_class') AS view_comment
264
301
  FROM pg_class c
265
302
  JOIN pg_namespace n ON n.oid = c.relnamespace
266
303
  WHERE c.relkind = 'v'
@@ -274,34 +311,132 @@ async function readPgViews(k: RawKysely): Promise<ViewDescriptor[]> {
274
311
  )
275
312
  ORDER BY n.nspname, c.relname
276
313
  `.execute(k);
277
- return rows.rows.map((r) => {
314
+
315
+ const dependents = await readPgViewDependents(k);
316
+
317
+ const views: ViewDescriptor[] = [];
318
+ for (const r of rows.rows) {
278
319
  const view: ViewDescriptor = { name: r.table_name, schema: r.table_schema };
320
+ // The deparsed body. NOT a comparison input on Postgres — pg_get_viewdef
321
+ // regenerates SQL from the parse tree and can never return the text we wrote.
322
+ // It IS valid SQL that reproduces the view, so it is the restore payload for a
323
+ // down migration.
279
324
  if (r.view_definition) view.sql = r.view_definition;
280
- return view;
281
- });
325
+ // The fingerprint is what the diff actually compares. Absent ⇒ the view carries
326
+ // no MetaObjects stamp ⇒ hand-written or pre-fingerprint ⇒ the diff fails closed.
327
+ const marker = parseFingerprintMarker(r.view_comment);
328
+ if (marker !== null) view.fingerprint = marker.fingerprint;
329
+ // A view is a relation: information_schema.columns describes its output columns
330
+ // exactly like a table's, so the same reader gives us the list (and the types)
331
+ // that decide whether a CREATE OR REPLACE is legal.
332
+ view.columns = (await readColumns(k, r.table_schema, r.table_name)).map((c) => ({
333
+ name: c.name,
334
+ sqlType: c.sqlType,
335
+ }));
336
+ views.push(view);
337
+ }
338
+
339
+ // Attach dependents, now that we know which views are managed (fingerprinted) —
340
+ // a CASCADE that destroys an UNMANAGED object is the dangerous case.
341
+ const managed = new Set(views.filter((v) => v.fingerprint !== undefined).map((v) => viewKey(v.schema, v.name)));
342
+ for (const view of views) {
343
+ const direct = dependents.get(viewKey(view.schema, view.name));
344
+ if (direct === undefined) continue;
345
+ view.dependents = direct.map((d) => ({ ...d, managed: managed.has(viewKey(d.schema, d.name)) }));
346
+ }
347
+ return views;
282
348
  } catch {
283
349
  // pg-mem: pg_class/pg_depend catalog introspection not supported — return empty view list.
284
350
  return [];
285
351
  }
286
352
  }
287
353
 
354
+ function viewKey(schema: string | undefined, name: string): string {
355
+ return `${schema ?? DEFAULT_DB_SCHEMA_POSTGRES}.${name}`;
356
+ }
357
+
358
+ /**
359
+ * Every view's DIRECT dependents, in one query (no per-view N+1). Keyed by the
360
+ * depended-ON view; the transitive closure is computed in the diff.
361
+ *
362
+ * The catalog gotcha: a view's dependency on the relations it reads is NOT recorded as
363
+ * view-depends-on-relation. It is recorded as the view's REWRITE RULE (its `_RETURN`
364
+ * rule in pg_rewrite, which holds the parse tree) depending on each referenced
365
+ * relation. So finding dependents means joining pg_depend → pg_rewrite → pg_class —
366
+ * and every view depends on ITSELF through its own `_RETURN` rule, which must be
367
+ * excluded or a dependency walk never terminates.
368
+ *
369
+ * Materialized views (relkind 'm') are included deliberately: migrate does not manage
370
+ * them, but they can depend on our views and a CASCADE would destroy them anyway.
371
+ */
372
+ async function readPgViewDependents(k: RawKysely): Promise<Map<string, Omit<DependentRelation, "managed">[]>> {
373
+ const out = new Map<string, Omit<DependentRelation, "managed">[]>();
374
+ try {
375
+ const rows = await sql<{
376
+ on_schema: string; on_name: string;
377
+ dep_schema: string; dep_name: string; dep_relkind: string;
378
+ }>`
379
+ SELECT ref_ns.nspname AS on_schema,
380
+ ref_cl.relname AS on_name,
381
+ dep_ns.nspname AS dep_schema,
382
+ dep_cl.relname AS dep_name,
383
+ dep_cl.relkind AS dep_relkind
384
+ FROM pg_depend d
385
+ JOIN pg_rewrite r ON r.oid = d.objid
386
+ JOIN pg_class dep_cl ON dep_cl.oid = r.ev_class
387
+ JOIN pg_namespace dep_ns ON dep_ns.oid = dep_cl.relnamespace
388
+ JOIN pg_class ref_cl ON ref_cl.oid = d.refobjid
389
+ JOIN pg_namespace ref_ns ON ref_ns.oid = ref_cl.relnamespace
390
+ WHERE d.classid = 'pg_rewrite'::regclass
391
+ AND d.refclassid = 'pg_class'::regclass
392
+ AND d.deptype = 'n'
393
+ AND ref_cl.relkind = 'v'
394
+ AND dep_cl.oid <> ref_cl.oid
395
+ AND ref_ns.nspname NOT IN ('pg_catalog', 'information_schema')
396
+ AND ref_ns.nspname NOT LIKE 'pg_%'
397
+ `.execute(k);
398
+ for (const r of rows.rows) {
399
+ if (r.dep_relkind !== "v" && r.dep_relkind !== "m") continue;
400
+ const key = viewKey(r.on_schema, r.on_name);
401
+ const list = out.get(key) ?? [];
402
+ list.push({ schema: r.dep_schema, name: r.dep_name, relkind: r.dep_relkind });
403
+ out.set(key, list);
404
+ }
405
+ } catch {
406
+ // pg-mem: no pg_rewrite. Dependents unknown → treated as none. The real-PG
407
+ // integration tests are the gate for this path.
408
+ }
409
+ return out;
410
+ }
411
+
288
412
  interface RawColumn {
289
413
  column_name: string;
290
414
  data_type: string;
291
415
  udt_name: string;
292
416
  character_maximum_length: number | null;
417
+ numeric_precision: number | null;
418
+ numeric_scale: number | null;
293
419
  is_nullable: string; // 'YES' | 'NO'
294
420
  column_default: string | null;
295
421
  }
296
422
 
297
423
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
298
424
  async function readColumns(k: Kysely<any>, schema: string, tableName: string): Promise<ColumnDescriptor[]> {
425
+ // numeric_precision/_scale are the ONLY place a NUMERIC(p,s) qualifier survives:
426
+ // information_schema.columns.data_type reports a bare "numeric" for every
427
+ // NUMERIC column regardless of its qualifier, and character_maximum_length is
428
+ // NULL for numerics. Selecting them is what lets a `field.decimal @precision
429
+ // @scale` column converge — without them the expected side says NUMERIC(9,4),
430
+ // introspection reads back a bare NUMERIC, and the diff reports a (lossy, so
431
+ // BLOCKED) change-column-type on every single migrate, forever.
299
432
  const rows = await sql<RawColumn>`
300
433
  SELECT
301
434
  column_name,
302
435
  data_type,
303
436
  udt_name,
304
437
  character_maximum_length,
438
+ numeric_precision,
439
+ numeric_scale,
305
440
  is_nullable,
306
441
  column_default
307
442
  FROM information_schema.columns
@@ -311,7 +446,10 @@ async function readColumns(k: Kysely<any>, schema: string, tableName: string): P
311
446
  `.execute(k);
312
447
 
313
448
  return rows.rows.map((r) => {
314
- const sqlType = pgTypeToSqlType(r.data_type, r.character_maximum_length, r.udt_name);
449
+ const sqlType = pgTypeToSqlType(r.data_type, r.character_maximum_length, r.udt_name, {
450
+ precision: r.numeric_precision,
451
+ scale: r.numeric_scale,
452
+ });
315
453
  const col: ColumnDescriptor = {
316
454
  name: r.column_name,
317
455
  sqlType,
@@ -2,7 +2,8 @@
2
2
  import type { ColumnNamingStrategy, MetaData } from "@metaobjectsdev/metadata";
3
3
  import { buildExpectedSchema } from "../expected-schema.js";
4
4
  import { diff, type DiffArgs } from "../diff/index.js";
5
- import type { Dialect, DiffResult, SchemaSnapshot, ViewDescriptor } from "../types.js";
5
+ import type { Dialect, DiffResult, SchemaSnapshot } from "../types.js";
6
+ import type { ExpectedViewInput } from "../expected-schema.js";
6
7
 
7
8
  export interface PlanOfflineArgs extends Pick<DiffArgs, "allow" | "onAmbiguous" | "ignoreTables"> {
8
9
  metadata: MetaData;
@@ -11,7 +12,7 @@ export interface PlanOfflineArgs extends Pick<DiffArgs, "allow" | "onAmbiguous"
11
12
  snapshot: SchemaSnapshot;
12
13
  columnNamingStrategy?: ColumnNamingStrategy;
13
14
  /** Expected views (via codegen-ts `buildProjectionViews`) — threaded into buildExpectedSchema. */
14
- views?: readonly ViewDescriptor[];
15
+ views?: readonly ExpectedViewInput[];
15
16
  }
16
17
 
17
18
  export interface PlanOfflineResult {
@@ -48,7 +49,7 @@ export function baselineFromMetadata(
48
49
  metadata: MetaData,
49
50
  dialect: Dialect,
50
51
  columnNamingStrategy?: ColumnNamingStrategy,
51
- views?: readonly ViewDescriptor[],
52
+ views?: readonly ExpectedViewInput[],
52
53
  ): SchemaSnapshot {
53
54
  return buildExpectedSchema(metadata, {
54
55
  dialect,
@@ -5,8 +5,22 @@ import type { SchemaSnapshot } from "../types.js";
5
5
  * On-disk format version for the committed schema snapshot. Bump when the
6
6
  * SchemaSnapshot descriptor gains a field (a DDL-coverage feature); add the
7
7
  * matching upgrade branch in parseSnapshot at the same time.
8
+ *
9
+ * v3 — ViewDescriptor gained `fingerprint`, `columns` and `dependents`. The
10
+ * fingerprint is how Postgres decides whether a view is up to date (Postgres deparses
11
+ * view SQL, so the emitted text can never be compared against what it stores). A
12
+ * toolchain that does not understand these fields would silently fall back to the old
13
+ * text comparison and re-propose every view on every migrate — so a v3 snapshot must
14
+ * hard-fail on an older reader rather than be quietly misread. Reading an OLDER (v2)
15
+ * snapshot stays fine: the missing fields read as `undefined`, which every consumer
16
+ * treats as "unknown" and fails safe on.
17
+ *
18
+ * NOTE for anyone touching canonicalize(): a view's `columns` array is ORDER-SENSITIVE.
19
+ * Postgres allows a non-destructive CREATE OR REPLACE only when the old column list is
20
+ * a PREFIX of the new one, so sorting that array would destroy the very information the
21
+ * decision rests on.
8
22
  */
9
- export const SNAPSHOT_FORMAT_VERSION = 2;
23
+ export const SNAPSHOT_FORMAT_VERSION = 3;
10
24
 
11
25
  interface SnapshotFile {
12
26
  formatVersion: number;
package/src/types.ts CHANGED
@@ -111,6 +111,27 @@ export interface FkDescriptor {
111
111
 
112
112
  export type FkAction = "cascade" | "set-null" | "restrict" | "no-action";
113
113
 
114
+ /** One output column of a view, in SELECT order. */
115
+ export interface ViewColumnDescriptor {
116
+ name: string;
117
+ sqlType: SqlType;
118
+ }
119
+
120
+ /** A relation that depends on a view — i.e. what a `DROP ... CASCADE` would destroy. */
121
+ export interface DependentRelation {
122
+ schema: string;
123
+ name: string;
124
+ /** 'v' = view, 'm' = materialized view. */
125
+ relkind: "v" | "m";
126
+ /**
127
+ * True when this dependent is a view MetaObjects manages (it carries our
128
+ * fingerprint). A managed dependent that the same migration recreates is
129
+ * harmless; an UNMANAGED one is somebody else's object and a CASCADE destroys
130
+ * it irrecoverably.
131
+ */
132
+ managed: boolean;
133
+ }
134
+
114
135
  export interface ViewDescriptor {
115
136
  name: string;
116
137
  /** Same semantics as TableDescriptor.schema. */
@@ -119,16 +140,49 @@ export interface ViewDescriptor {
119
140
  * View definition SQL.
120
141
  *
121
142
  * On the EXPECTED side (`buildExpectedSchema` / `buildExpectedViews`) this is
122
- * the view body — the SELECT clause through the FROM/WHERE/GROUP-BY tail.
143
+ * the view body — the SELECT clause through the FROM/WHERE/GROUP-BY tail. It is
144
+ * the input to `viewFingerprint()`.
145
+ *
146
+ * On the ACTUAL side (`introspect`) this is whatever the DB catalog stores.
147
+ * Its role is DIALECT-DEPENDENT, and this is load-bearing:
123
148
  *
124
- * On the ACTUAL side (`introspect`) this is whatever the DB catalog stores:
125
- * sqlite's `sqlite_master.sql` is the full `CREATE VIEW <name> AS <body>`
126
- * statement, while Postgres' `information_schema.views.view_definition` is the
127
- * body only. `diff`'s view-body comparator normalizes both sides (strips any
128
- * leading `CREATE VIEW ... AS`, collapses whitespace) before comparing, so a
129
- * body change triggers a `replace-view`.
149
+ * - SQLite/D1 store `sqlite_master.sql` VERBATIM the exact text we wrote —
150
+ * so there the body is a sound basis for comparison (`viewSqlEquals`).
151
+ *
152
+ * - Postgres does NOT store view SQL. It stores the parse tree, and
153
+ * `pg_get_viewdef()` DEPARSES it back into Postgres's own canonical style
154
+ * (lowercased functions, `LEFT OUTER JOIN` `LEFT JOIN`, parenthesized FROM
155
+ * items and ON predicates, dropped redundant aliases). It can never equal
156
+ * what we emitted, so on Postgres the body is NOT used for comparison —
157
+ * `fingerprint` is. The deparsed body is still carried, as the RESTORE
158
+ * payload for a down migration (it is valid SQL that reproduces the view).
130
159
  */
131
160
  sql?: string;
161
+ /**
162
+ * Content hash of the generated body — `metaobjects:v1:sha256:<hex>` — stamped
163
+ * into the view's `COMMENT ON VIEW` at emit time and read back at introspect
164
+ * time. This, not the body text, is how Postgres decides whether a view is
165
+ * up to date (see view-fingerprint.ts).
166
+ *
167
+ * Absent on the actual side means the view carries NO MetaObjects stamp: either
168
+ * it is hand-written, or it predates fingerprinting. The two are
169
+ * indistinguishable on Postgres, so the diff fails CLOSED and blocks pending
170
+ * `allow.adoptView`.
171
+ */
172
+ fingerprint?: string;
173
+ /**
174
+ * The view's output columns, in SELECT order. Drives the replace-vs-drop
175
+ * decision: Postgres permits `CREATE OR REPLACE VIEW` only when the existing
176
+ * columns are a PREFIX of the new ones (same names, same types, same order,
177
+ * additions at the end only). Undefined = unknown → fail safe to drop+create.
178
+ */
179
+ columns?: readonly ViewColumnDescriptor[];
180
+ /**
181
+ * Relations that depend on this view (transitively). Populated on the ACTUAL
182
+ * side by introspection. A `DROP VIEW` with dependents needs CASCADE, which
183
+ * destroys every one of them — including views owned by other applications.
184
+ */
185
+ dependents?: readonly DependentRelation[];
132
186
  /**
133
187
  * Physical tables this view reads (base + joined tables). Populated on the
134
188
  * EXPECTED side only (the view producer knows the join graph; introspection
@@ -175,8 +229,36 @@ export type Change =
175
229
  | { kind: "drop-check"; table: string; schema?: string; check: string; restore?: CheckDescriptor; status: ChangeStatus }
176
230
  // Declared for v0.3, never produced in v0.1:
177
231
  | { kind: "create-view"; view: ViewDescriptor; schema?: string; status: ChangeStatus }
178
- | { kind: "drop-view"; view: string; schema?: string; status: ChangeStatus }
179
- | { kind: "replace-view"; view: ViewDescriptor; schema?: string; status: ChangeStatus };
232
+ | {
233
+ kind: "drop-view";
234
+ view: string;
235
+ schema?: string;
236
+ status: ChangeStatus;
237
+ /** The view as it exists in the DB — lets the down migration recreate it. */
238
+ restore?: ViewDescriptor;
239
+ /**
240
+ * Relations a CASCADE would destroy. EXTERNAL dependents only: a managed view
241
+ * this same migration recreates is filtered out (it comes back). Non-empty ⇒
242
+ * a plain DROP VIEW would fail at apply, and CASCADE would destroy objects we
243
+ * do not manage — so this is blocked pending `allow.dropViewCascade`.
244
+ */
245
+ dependents?: readonly DependentRelation[];
246
+ }
247
+ | {
248
+ kind: "replace-view";
249
+ view: ViewDescriptor;
250
+ schema?: string;
251
+ status: ChangeStatus;
252
+ /** The view as it exists in the DB — lets the down migration restore the old body. */
253
+ restore?: ViewDescriptor;
254
+ /**
255
+ * The DB view carries no MetaObjects fingerprint — hand-written, or created
256
+ * before fingerprinting existed. We cannot tell which (Postgres deparses away
257
+ * the text evidence), and overwriting a hand-written view destroys SQL no down
258
+ * migration can recover. Blocked pending `allow.adoptView`.
259
+ */
260
+ unmanagedActual?: boolean;
261
+ };
180
262
 
181
263
  export type ChangeKind = Change["kind"];
182
264
 
@@ -204,6 +286,24 @@ export interface AllowOptions {
204
286
  * view is re-created in the same migration).
205
287
  */
206
288
  dropView?: boolean;
289
+ /**
290
+ * Gates `DROP VIEW ... CASCADE`. A view with dependents cannot be dropped plainly
291
+ * (Postgres refuses), and CASCADE destroys every dependent — including views and
292
+ * materialized views owned by OTHER applications, which this tool does not manage
293
+ * and cannot restore. Strictly ADDITIONAL to `dropView`: `--allow drop-view` alone
294
+ * never cascades, and a plain non-cascading DROP VIEW stays the emitted form
295
+ * whenever there are no dependents, so Postgres itself backstops a stale
296
+ * dependents snapshot rather than silently cascading.
297
+ */
298
+ dropViewCascade?: boolean;
299
+ /**
300
+ * Gates overwriting an existing view that carries NO MetaObjects fingerprint —
301
+ * i.e. taking ownership of it. It is either hand-written or predates
302
+ * fingerprinting, and on Postgres those are indistinguishable. Every environment
303
+ * upgrading from an older toolchain needs exactly one `--allow adopt-view` run to
304
+ * stamp its existing views; after that they are fingerprinted and silent.
305
+ */
306
+ adoptView?: boolean;
207
307
  /** Existing data must satisfy NOT NULL; diff cannot verify this. */
208
308
  nullableToNotNull?: boolean;
209
309
  }
@@ -0,0 +1,110 @@
1
+ // view-column-types.ts — resolve a view's output columns to SqlTypes.
2
+ //
3
+ // Needed to decide whether a view change is non-destructively REPLACEABLE. Postgres
4
+ // permits `CREATE OR REPLACE VIEW` only when the existing output columns are a prefix
5
+ // of the new ones on (name, type, position); getting the types wrong would make the
6
+ // diff propose an OR REPLACE that Postgres rejects at APPLY time
7
+ // ("cannot change data type of view column ..."), aborting the migration mid-flight
8
+ // with no plan-time warning. So every resolution failure here degrades to `undefined`
9
+ // — "unknown" — which the diff treats as not-replaceable and routes through a gated,
10
+ // loud drop+create. Wrong-but-confident is the only outcome we must never produce.
11
+
12
+ import type { SqlType } from "./sql-type.js";
13
+ import { sqlTypeEquals } from "./sql-type.js";
14
+ import type { TableDescriptor, ViewColumnDescriptor } from "./types.js";
15
+
16
+ /** The codegen-side description of a view column: physical, but untyped. */
17
+ export type ExpectedViewColumnInput =
18
+ | { kind: "passthrough"; name: string; sourceTable: string; sourceColumn: string }
19
+ | { kind: "aggregate"; name: string; sourceTable: string; sourceColumn: string; agg: string };
20
+
21
+ /**
22
+ * Can `existing` be turned into `target` with a `CREATE OR REPLACE VIEW`?
23
+ *
24
+ * Postgres permits it only when the new query produces the same columns as the existing
25
+ * view — same names, same types, same order — with additions allowed at the END.
26
+ * Formally: `existing` must be a PREFIX of `target`.
27
+ * https://www.postgresql.org/docs/current/sql-createview.html
28
+ *
29
+ * Used in BOTH directions. Forward (target = expected, existing = live) it decides
30
+ * replace-vs-drop. Backward, in a down migration (target = the old view, existing = the
31
+ * one we just created), it decides the same thing — and usually says NO, because undoing
32
+ * an append means REMOVING a column, which OR REPLACE cannot do.
33
+ *
34
+ * Fails SAFE: unknown columns on either side → false → the caller uses drop+create.
35
+ * A wrong "yes" is not a failed check, it is a statement Postgres rejects at APPLY time,
36
+ * aborting the migration with no plan-time warning.
37
+ */
38
+ export function viewReplaceIsLegal(
39
+ target: readonly ViewColumnDescriptor[] | undefined,
40
+ existing: readonly ViewColumnDescriptor[] | undefined,
41
+ ): boolean {
42
+ if (target === undefined || existing === undefined) return false;
43
+ if (existing.length > target.length) return false;
44
+ return existing.every((ec, i) => {
45
+ const tc = target[i]!;
46
+ return ec.name === tc.name && sqlTypeEquals(ec.sqlType, tc.sqlType);
47
+ });
48
+ }
49
+
50
+ /**
51
+ * Postgres aggregate result types. Not the argument type — `count(int)` is bigint and
52
+ * `avg(int)` is numeric — so a naive "same as input" rule would mis-type the column and
53
+ * mis-plan the replace.
54
+ *
55
+ * https://www.postgresql.org/docs/current/functions-aggregate.html
56
+ */
57
+ function aggregateResultType(agg: string, arg: SqlType | undefined): SqlType | undefined {
58
+ switch (agg) {
59
+ // count() is bigint regardless of its argument — it need not even resolve.
60
+ case "count":
61
+ return { kind: "integer", bits: 64 };
62
+ case "min":
63
+ case "max":
64
+ return arg; // same as the argument type
65
+ case "sum":
66
+ if (arg === undefined) return undefined;
67
+ if (arg.kind === "integer") {
68
+ // smallint/integer → bigint; bigint → numeric (sum can overflow).
69
+ return arg.bits === 64 ? { kind: "numeric" } : { kind: "integer", bits: 64 };
70
+ }
71
+ // numeric → numeric, real → real, double → double.
72
+ if (arg.kind === "numeric" || arg.kind === "real" || arg.kind === "real4") return arg;
73
+ return undefined;
74
+ case "avg":
75
+ if (arg === undefined) return undefined;
76
+ if (arg.kind === "integer" || arg.kind === "numeric") return { kind: "numeric" };
77
+ if (arg.kind === "real" || arg.kind === "real4") return { kind: "real" };
78
+ return undefined;
79
+ default:
80
+ return undefined;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Resolve every view column against the expected TABLE descriptors.
86
+ *
87
+ * Returns undefined if ANY column cannot be resolved — a partial list is worse than
88
+ * none, because the prefix comparison would silently compare the wrong positions.
89
+ */
90
+ export function resolveViewColumns(
91
+ inputs: readonly ExpectedViewColumnInput[] | undefined,
92
+ tables: readonly TableDescriptor[],
93
+ ): ViewColumnDescriptor[] | undefined {
94
+ if (inputs === undefined || inputs.length === 0) return undefined;
95
+
96
+ const byTable = new Map(tables.map((t) => [t.name, t] as const));
97
+ const columnType = (table: string, column: string): SqlType | undefined =>
98
+ byTable.get(table)?.columns.find((c) => c.name === column)?.sqlType;
99
+
100
+ const out: ViewColumnDescriptor[] = [];
101
+ for (const input of inputs) {
102
+ const arg = columnType(input.sourceTable, input.sourceColumn);
103
+ const sqlType = input.kind === "aggregate"
104
+ ? aggregateResultType(input.agg, arg)
105
+ : arg;
106
+ if (sqlType === undefined) return undefined;
107
+ out.push({ name: input.name, sqlType });
108
+ }
109
+ return out;
110
+ }
@@ -0,0 +1,97 @@
1
+ // view-fingerprint.ts — how Postgres decides whether a managed view is up to date.
2
+ //
3
+ // WHY THIS EXISTS
4
+ //
5
+ // Postgres does not store view SQL. It stores the parse tree (as the view's `_RETURN`
6
+ // rewrite rule), and `pg_get_viewdef()` DEPARSES that back into Postgres's own
7
+ // canonical style. For one of our generated projection views, live PG 16 returns:
8
+ //
9
+ // we emit : SELECT p.id AS "programId", COUNT(DISTINCT w.id) AS "weekCount"
10
+ // FROM programs p LEFT OUTER JOIN weeks w ON w."programId" = p.id
11
+ // PG says : SELECT p.id AS "programId", count(DISTINCT w.id) AS "weekCount"
12
+ // FROM (programs p LEFT JOIN weeks w ON ((w."programId" = p.id)))
13
+ //
14
+ // — lowercased functions, LEFT OUTER JOIN rewritten, FROM item and ON predicate
15
+ // parenthesized. No textual normalizer can bridge that without reimplementing the
16
+ // deparser, so comparing our text against PG's text ALWAYS reports a difference. That
17
+ // is what made `replace-view` fire on every migrate for every view, forever, and kept
18
+ // `verify --db` permanently red for any project with a projection.
19
+ //
20
+ // THE FIX: never compare against the deparser. Hash the body WE generate, stamp the
21
+ // hash into the view's COMMENT at emit time, and read the stamp back at introspect
22
+ // time. Both sides of the comparison then come from the same emitter, and the
23
+ // deparsed body is never an input.
24
+ //
25
+ // The deparsed body is still useful — it is valid SQL that reproduces the view — so it
26
+ // is carried as the RESTORE payload for down migrations.
27
+ //
28
+ // SQLite/D1 need none of this: `sqlite_master.sql` is the verbatim text we wrote, so
29
+ // the body comparator (view-sql-compare.ts) is exact there.
30
+
31
+ import { createHash } from "node:crypto";
32
+
33
+ /**
34
+ * Marker format version. Bump ONLY if the normalization rules or the marker grammar
35
+ * change — i.e. if the same body would hash differently. The algorithm is tagged
36
+ * separately (`sha256:`), so swapping algorithms does not need a version bump.
37
+ */
38
+ export const FINGERPRINT_FORMAT_VERSION = 1;
39
+
40
+ const MARKER_PREFIX = "metaobjects";
41
+
42
+ /** Trailing-line marker: any human comment text may sit ABOVE it. */
43
+ const MARKER_RE = /(?:^|\n)metaobjects:v(\d+):sha256:([0-9a-f]{64})\s*$/;
44
+
45
+ const CREATE_VIEW_PREFIX =
46
+ /^\s*create\s+(?:or\s+replace\s+)?(?:temp(?:orary)?\s+)?view\s+(?:if\s+not\s+exists\s+)?[^\s(]+(?:\s*\([^)]*\))?\s+as\s+/i;
47
+
48
+ /**
49
+ * Canonicalize a view body for hashing.
50
+ *
51
+ * Collapses whitespace (so reindenting the emitter does not re-stamp every deployed
52
+ * view) and strips an optional CREATE VIEW wrapper and trailing semicolon — and
53
+ * NOTHING else.
54
+ *
55
+ * Deliberately does NOT lowercase, unlike `normalizeViewSql`. That lowercasing exists
56
+ * only to chase Postgres's deparser, and it masks drift inside case-sensitive string
57
+ * literals — which `origin.aggregate @filter` predicates now carry (`status = 'Active'`
58
+ * and `status = 'active'` are different views). We never chase the deparser here, so
59
+ * there is nothing to buy and real drift to lose.
60
+ */
61
+ export function normalizeForFingerprint(body: string): string {
62
+ return body
63
+ .replace(CREATE_VIEW_PREFIX, "")
64
+ .replace(/\s+/g, " ")
65
+ .replace(/;\s*$/, "")
66
+ .trim();
67
+ }
68
+
69
+ /** sha256 of the normalized body, lowercase hex. */
70
+ export function viewFingerprint(body: string): string {
71
+ return createHash("sha256").update(normalizeForFingerprint(body), "utf8").digest("hex");
72
+ }
73
+
74
+ /** The marker line stamped into `COMMENT ON VIEW`. */
75
+ export function renderFingerprintMarker(fingerprint: string): string {
76
+ return `${MARKER_PREFIX}:v${FINGERPRINT_FORMAT_VERSION}:sha256:${fingerprint}`;
77
+ }
78
+
79
+ /**
80
+ * Read a fingerprint marker out of a view's comment.
81
+ *
82
+ * Returns null when there is no marker — meaning the view carries no MetaObjects
83
+ * stamp and is therefore either hand-written or older than fingerprinting. Callers
84
+ * MUST fail closed on null: on Postgres those two cases are indistinguishable, and
85
+ * overwriting the first destroys hand-written SQL nothing can restore.
86
+ *
87
+ * An unknown VERSION still parses (the view is ours, stamped by a different
88
+ * toolchain) — the caller re-stamps it, which makes format migration self-healing.
89
+ */
90
+ export function parseFingerprintMarker(
91
+ comment: string | null | undefined,
92
+ ): { version: number; fingerprint: string } | null {
93
+ if (typeof comment !== "string" || comment.length === 0) return null;
94
+ const m = MARKER_RE.exec(comment.trim());
95
+ if (m === null) return null;
96
+ return { version: parseInt(m[1]!, 10), fingerprint: m[2]! };
97
+ }