@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
|
@@ -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(
|
|
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
|
-
|
|
125
|
-
|
|
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<{
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/src/snapshot/plan.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
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
|
-
| {
|
|
179
|
-
|
|
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
|
+
}
|