@everystack/cli 0.4.84 → 0.4.85

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.
@@ -21,7 +21,7 @@ import type { AuthzContract, SequenceGrantContract } from './authz-contract';
21
21
  import { compileTableSchema, compileRenames, compileTableRenames, compileTableMoves, compileCreateTable, compileEnums, compileSequences } from './schema-compile';
22
22
  import { compileTableContract } from './authz-compile';
23
23
  import { emitReconcileSql, governedRoleSet } from './authz-reconcile';
24
- import { diffSchema, emitSchemaSql, ownSequenceName, type SchemaChange } from './schema-diff';
24
+ import { diffSchema, emitSchemaStatements, ownSequenceName, type SchemaChange } from './schema-diff';
25
25
  import { schemaOf } from './schema-fingerprint';
26
26
 
27
27
  export interface GenerateOptions {
@@ -195,7 +195,7 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
195
195
  const changes = diffSchema(desired, scopedCurrent, renames, tableRenames, tableMoves);
196
196
 
197
197
  const modelByTable = new Map(models.map((m) => [`${schemaOf(m)}.${m.table}`, m]));
198
- const emitted = emitSchemaSql(changes, {
198
+ const emitted = emitSchemaStatements(changes, {
199
199
  createTable: (change) => {
200
200
  const model = modelByTable.get(change.table);
201
201
  if (!model) throw new Error(`db:generate: no model found for new table ${change.table}`);
@@ -205,17 +205,18 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
205
205
 
206
206
  // Non-destructive by default: a column/constraint/table in the database but absent
207
207
  // from the Models would otherwise become a DROP — silent data loss for a partial or
208
- // lossy Model set. Hold removals back as comments unless `allowDrops`. emitSchemaSql
209
- // returns one statement per change, index-aligned, so the gate stays a pure
210
- // post-pass — `diffSchema`/`emitSchemaSql` remain honest (they always emit the DROP).
211
- let dataPhase = emitted;
208
+ // lossy Model set. Hold removals back as comments unless `allowDrops`. Each emitted
209
+ // statement carries the change it came from (a foreign-key add is TWO statements, so a
210
+ // parallel index is not a pairing), which keeps the gate a pure post-pass —
211
+ // `diffSchema`/`emitSchemaSql` remain honest (they always emit the DROP).
212
+ let dataPhase = emitted.map((e) => e.sql);
212
213
  if (!opts.allowDrops) {
213
214
  const replacedConstraints = new Set(
214
215
  changes
215
216
  .filter((c): c is Extract<SchemaChange, { kind: 'addConstraint' }> => c.kind === 'addConstraint')
216
217
  .map((c) => `${c.table}::${c.constraint.name}`),
217
218
  );
218
- dataPhase = emitted.map((sql, i) => (isRemoval(changes[i], replacedConstraints) ? holdDrop(sql) : sql));
219
+ dataPhase = emitted.map(({ change, sql }) => (isRemoval(change, replacedConstraints) ? holdDrop(sql) : sql));
219
220
  }
220
221
 
221
222
  // Authz phase — appended after the data phase so a policy/grant references columns the
@@ -157,6 +157,12 @@ export function describeChange(statement: string, opts: DescribeOptions = {}): P
157
157
  if ((a = /^DROP\s+CONSTRAINT\s+(?:IF\s+EXISTS\s+)?("[^"]+"|[\w$]+)/i.exec(rest))) {
158
158
  return { symbol: '-', text: `remove constraint ${unquote(a[1])} on ${table}` };
159
159
  }
160
+ // The second half of a two-step foreign-key add (and the whole of a re-plan that finds one
161
+ // half-applied). It adds nothing and removes nothing — it CHECKS the rows already there —
162
+ // so without its own line it fell through to the raw-statement fallback.
163
+ if ((a = /^VALIDATE\s+CONSTRAINT\s+("[^"]+"|[\w$]+)/i.exec(rest))) {
164
+ return { symbol: '+', text: `check existing rows against constraint ${unquote(a[1])} on ${table}` };
165
+ }
160
166
  if ((a = /^ALTER\s+COLUMN\s+("[^"]+"|[\w$]+)\s+([\s\S]+)$/i.exec(rest))) {
161
167
  const column = `${table}.${unquote(a[1])}`;
162
168
  const what = a[2].trim();
@@ -394,18 +400,20 @@ export interface PlanReportInput {
394
400
  stage?: string | null;
395
401
  /** The plan file to name in the Run line; null when the plan went to stdout. */
396
402
  planPath: string | null;
403
+ /** --verbose: the promise behind "(--verbose for the full list)" — show every line, uncapped. */
404
+ verbose?: boolean;
397
405
  }
398
406
 
399
407
  /** db:plan's default output — the whole thing. */
400
408
  export function renderPlanReport(input: PlanReportInput): string[] {
401
- const { executable, target, stage, planPath } = input;
409
+ const { executable, target, stage, planPath, verbose = false } = input;
402
410
  if (executable.length === 0) return ['Up to date. Nothing to plan.'];
403
411
 
404
412
  const destructive = new Set(input.destructive);
405
413
  const lines: string[] = [
406
414
  `Plan: ${executable.length} ${plural(executable.length, 'change', 'changes')} (${target})`,
407
415
  '',
408
- ...changeLines(executable, destructive, MAX_LISTED_CHANGES),
416
+ ...changeLines(executable, destructive, verbose ? executable.length : MAX_LISTED_CHANGES),
409
417
  '',
410
418
  ];
411
419
 
@@ -41,7 +41,11 @@ import type {
41
41
  export type ConstraintSpec =
42
42
  | { type: 'unique'; name: string; columns: string[] }
43
43
  | { type: 'check'; name: string; expr: string }
44
- | { type: 'foreignKey'; name: string; columns: string[]; refTable: string; refColumns: string[]; onDelete?: string; onUpdate?: string; nameDeclared?: boolean };
44
+ | { type: 'foreignKey'; name: string; columns: string[]; refTable: string; refColumns: string[]; onDelete?: string; onUpdate?: string; nameDeclared?: boolean;
45
+ /** LIVE-SIDE ONLY: the key is installed but unchecked (see {@link ForeignKey.notValid}).
46
+ * It is never part of {@link constraintContentKey} — a NOT VALID key IS the declared key,
47
+ * one step short — and the emitter ignores it, because an ADD is always emitted NOT VALID. */
48
+ notValid?: boolean };
45
49
 
46
50
  export type SchemaChange =
47
51
  | { kind: 'createTable'; table: string; schema: TableSchema }
@@ -64,6 +68,15 @@ export type SchemaChange =
64
68
  /** A type change → real `ALTER COLUMN ... TYPE`. `risk` decides USING + a data-loss warning. */
65
69
  | { kind: 'alterType'; table: string; column: string; from: string; to: string; risk: TypeChangeRisk }
66
70
  | { kind: 'addConstraint'; table: string; constraint: ConstraintSpec; note?: string }
71
+ /**
72
+ * A foreign key that is already installed on the live table but has never been checked
73
+ * against its rows (`NOT VALID`) → the second half of the two-step add, on its own.
74
+ *
75
+ * It is a DISTINCT kind from `addConstraint` because the ADD must not be re-emitted: the
76
+ * constraint is there, and a second `ADD CONSTRAINT` under the same name fails outright.
77
+ * This is what the state after an interrupted two-step add re-plans to — VALIDATE only.
78
+ */
79
+ | { kind: 'validateConstraint'; table: string; name: string }
67
80
  | { kind: 'dropConstraint'; table: string; name: string }
68
81
  | { kind: 'addPrimaryKey'; table: string; columns: string[] }
69
82
  /** A PK change other than first-time add — a notice (the drop needs its constraint name). */
@@ -791,7 +804,7 @@ function diffColumns(d: TableSchema, c: TableSchema, tableRenames: Record<string
791
804
  function tableConstraintSpecs(t: TableSchema): ConstraintSpec[] {
792
805
  return [
793
806
  ...t.uniques.map((u): ConstraintSpec => ({ type: 'unique', name: u.name, columns: u.columns })),
794
- ...t.foreignKeys.map((fk): ConstraintSpec => ({ type: 'foreignKey', name: fk.name, columns: fk.columns, refTable: fk.refTable, refColumns: fk.refColumns, onDelete: fk.onDelete, onUpdate: fk.onUpdate, ...(fk.nameDeclared ? { nameDeclared: true } : {}) })),
807
+ ...t.foreignKeys.map((fk): ConstraintSpec => ({ type: 'foreignKey', name: fk.name, columns: fk.columns, refTable: fk.refTable, refColumns: fk.refColumns, onDelete: fk.onDelete, onUpdate: fk.onUpdate, ...(fk.nameDeclared ? { nameDeclared: true } : {}), ...(fk.notValid ? { notValid: true } : {}) })),
795
808
  ];
796
809
  }
797
810
 
@@ -857,6 +870,17 @@ function diffConstraints(d: TableSchema, c: TableSchema, dropConstraints: Schema
857
870
  if (des.type === 'foreignKey' && des.nameDeclared && matched.name !== des.name) {
858
871
  renames.push({ kind: 'renameConstraint', table: d.table, from: matched.name, to: des.name });
859
872
  }
873
+ // The live key is there but was never checked against the rows already in the table —
874
+ // the state an interrupted two-step add leaves behind, and the state a hand-run
875
+ // `ADD CONSTRAINT … NOT VALID` leaves behind. The declared side is a VALIDATED key, so
876
+ // this is a real difference and the edge finishes it: VALIDATE alone, never a second
877
+ // ADD (which would fail on the name that is already taken). Under the live name, which
878
+ // may not be the declared one — a rename emitted just above lands FIRST, so the
879
+ // validate must speak the name the constraint will have by then.
880
+ if (matched.type === 'foreignKey' && matched.notValid) {
881
+ const name = des.type === 'foreignKey' && des.nameDeclared ? des.name : matched.name;
882
+ addConstraints.push({ kind: 'validateConstraint', table: d.table, name });
883
+ }
860
884
  } else {
861
885
  addConstraints.push({ kind: 'addConstraint', table: d.table, constraint: des });
862
886
  }
@@ -934,11 +958,39 @@ function createTableSql(t: TableSchema): string {
934
958
  return `CREATE TABLE ${t.table} (\n${lines.join(',\n')}\n);`;
935
959
  }
936
960
 
937
- function addConstraintSql(table: string, c: ConstraintSpec): string {
961
+ /**
962
+ * `ALTER TABLE … VALIDATE CONSTRAINT …` — the second half of a two-step foreign key add.
963
+ *
964
+ * ONE definition, read by the FK add (which always emits the pair) and by the change kind that
965
+ * finishes a key some earlier apply left NOT VALID. Two spellings of the same statement is how
966
+ * the "did this already run?" question ends up with two answers.
967
+ */
968
+ export function validateConstraintSql(table: string, name: string): string {
969
+ return `ALTER TABLE ${table} VALIDATE CONSTRAINT ${quote(name)};`;
970
+ }
971
+
972
+ /**
973
+ * Rendering an `ADD CONSTRAINT`: ONE statement, except a foreign key, which is TWO.
974
+ *
975
+ * A validated FK add takes an ACCESS EXCLUSIVE lock on the table for the length of a full scan
976
+ * of every row already in it — on a large, live table that is a write outage measured in
977
+ * minutes. PostgreSQL's own answer is to split the work:
978
+ *
979
+ * 1. `ADD CONSTRAINT … NOT VALID` — catalog-only and near-instant. The key is enforced on
980
+ * every row written from this moment on; the rows already there are simply unchecked.
981
+ * 2. `VALIDATE CONSTRAINT …` — the scan, under a SHARE UPDATE EXCLUSIVE lock that readers
982
+ * and writers both pass through.
983
+ *
984
+ * The split only buys that if the two run in SEPARATE transactions (step 1's lock is held to
985
+ * commit), which is why the apply path runs every VALIDATE after the edge's batch commits — see
986
+ * `splitValidations` in state-apply. The text is produced HERE for both `db:diff` (no database)
987
+ * and `db:plan` (against a live one), so the two can never render the same edge differently.
988
+ */
989
+ function addConstraintSql(table: string, c: ConstraintSpec): string[] {
938
990
  const head = `ALTER TABLE ${table} ADD CONSTRAINT ${quote(c.name)}`;
939
991
  switch (c.type) {
940
- case 'unique': return `${head} UNIQUE (${colList(c.columns)});`;
941
- case 'check': return `${head} CHECK ${wrapOnce(c.expr)};`;
992
+ case 'unique': return [`${head} UNIQUE (${colList(c.columns)});`];
993
+ case 'check': return [`${head} CHECK ${wrapOnce(c.expr)};`];
942
994
  case 'foreignKey': {
943
995
  // refTable may be schema-qualified (`auth.users`) for a cross-schema FK — quote each part,
944
996
  // not the whole string, or `"auth.users"` reads as one identifier.
@@ -946,7 +998,7 @@ function addConstraintSql(table: string, c: ConstraintSpec): string {
946
998
  let s = `${head} FOREIGN KEY (${colList(c.columns)}) REFERENCES ${refTable}(${colList(c.refColumns)})`;
947
999
  if (c.onDelete) s += ` ON DELETE ${c.onDelete.toUpperCase()}`;
948
1000
  if (c.onUpdate) s += ` ON UPDATE ${c.onUpdate.toUpperCase()}`;
949
- return `${s};`;
1001
+ return [`${s} NOT VALID;`, validateConstraintSql(table, c.name)];
950
1002
  }
951
1003
  }
952
1004
  }
@@ -959,8 +1011,16 @@ export interface EmitOptions {
959
1011
  createTable?: (change: Extract<SchemaChange, { kind: 'createTable' }>) => string;
960
1012
  }
961
1013
 
962
- /** Render one change to SQL — or a `-- NOTICE:` comment for the cases held for a follow-up. */
963
- function emitOne(change: SchemaChange, opts: EmitOptions): string {
1014
+ /**
1015
+ * Render one change to SQL — or a `-- NOTICE:` comment for the cases held for a follow-up.
1016
+ *
1017
+ * Returns a STRING for the one-statement kinds and an ARRAY for the kinds that are genuinely
1018
+ * more than one statement (today: a foreign-key add, which is `NOT VALID` + `VALIDATE`).
1019
+ * `emitSchemaStatements` flattens it and keeps each statement paired with the change it came
1020
+ * from, so the callers that decide something per change (the held-drop gate) never have to
1021
+ * assume one-statement-per-change.
1022
+ */
1023
+ function emitOne(change: SchemaChange, opts: EmitOptions): string | string[] {
964
1024
  switch (change.kind) {
965
1025
  case 'createTable':
966
1026
  return opts.createTable ? opts.createTable(change) : createTableSql(change.schema);
@@ -1011,8 +1071,14 @@ function emitOne(change: SchemaChange, opts: EmitOptions): string {
1011
1071
  : `-- WARNING: ${change.from} → ${change.to} on column ${quote(change.column)} (${change.table}) casts with USING and may FAIL on rows that don't convert; review before running.`;
1012
1072
  return `${warn}\n${ddl}`;
1013
1073
  }
1014
- case 'addConstraint':
1015
- return (change.note ? `${change.note}\n` : '') + addConstraintSql(change.table, change.constraint);
1074
+ case 'addConstraint': {
1075
+ const [first, ...rest] = addConstraintSql(change.table, change.constraint);
1076
+ // The note rides the FIRST statement only — it documents the change, and repeating it on
1077
+ // the VALIDATE would read as a second finding.
1078
+ return [(change.note ? `${change.note}\n` : '') + first, ...rest];
1079
+ }
1080
+ case 'validateConstraint':
1081
+ return validateConstraintSql(change.table, change.name);
1016
1082
  case 'dropConstraint':
1017
1083
  return `ALTER TABLE ${change.table} DROP CONSTRAINT IF EXISTS ${quote(change.name)};`;
1018
1084
  case 'addPrimaryKey':
@@ -1088,7 +1154,28 @@ function emitOne(change: SchemaChange, opts: EmitOptions): string {
1088
1154
  }
1089
1155
  }
1090
1156
 
1157
+ /** One emitted statement and the change that produced it. */
1158
+ export interface EmittedStatement {
1159
+ change: SchemaChange;
1160
+ sql: string;
1161
+ }
1162
+
1163
+ /**
1164
+ * The change set as an ordered list of statements, each still carrying its change.
1165
+ *
1166
+ * A change is NOT one statement any more (a foreign-key add is `NOT VALID` + `VALIDATE`), so
1167
+ * callers that decide something per change — db:generate's held-drop gate is the only one —
1168
+ * read the pairing from here instead of indexing a parallel array. Index alignment was a real
1169
+ * invariant right up until it silently was not.
1170
+ */
1171
+ export function emitSchemaStatements(changes: SchemaChange[], opts: EmitOptions = {}): EmittedStatement[] {
1172
+ return changes.flatMap((change) => {
1173
+ const emitted = emitOne(change, opts);
1174
+ return (Array.isArray(emitted) ? emitted : [emitted]).map((sql) => ({ change, sql }));
1175
+ });
1176
+ }
1177
+
1091
1178
  /** The change set as an ordered list of SQL statements (and notice comments). */
1092
1179
  export function emitSchemaSql(changes: SchemaChange[], opts: EmitOptions = {}): string[] {
1093
- return changes.map((c) => emitOne(c, opts));
1180
+ return emitSchemaStatements(changes, opts).map((e) => e.sql);
1094
1181
  }
@@ -0,0 +1,239 @@
1
+ /**
2
+ * FROZEN v12 canonicalizer — the exact live-side hash the CLI produced under
3
+ * FINGERPRINT_VERSION 12, before a NOT VALID foreign key entered the state at v13.
4
+ *
5
+ * DO NOT EDIT this to track new behaviour. It exists for ONE purpose: `db:apply`'s
6
+ * fast-forward proof recomputes a `schema_log` row recorded at `fp_version = 12` against a
7
+ * CURRENT live read, and comparing an old-format hash against a current-format one reports a
8
+ * phantom mismatch. So the descent registry recomputes the LIVE side under the ROW's canon —
9
+ * this function — per the standing rule in `RETAINED_CANONS`.
10
+ *
11
+ * It is a SELF-CONTAINED copy of the v12 canonical form. The ONLY behavioural difference from
12
+ * v13 is the foreign-key slice: v12 hashed a key's columns, target and referential actions and
13
+ * nothing else, so a key pg had never checked against the rows already in the table (`NOT
14
+ * VALID`) hashed IDENTICALLY to the validated key the models declare — a half-applied key
15
+ * reported MATCH. v13 adds the flag. Everything else (the command fan, effective-privilege
16
+ * deadness, the USAGE-only sequence derivation) is byte-identical to v13 and frozen here so a
17
+ * future change to the live form cannot silently move a recorded v12 hash. It reuses only the
18
+ * stable leaf normalizers (deparse + structure) and the authz-contract privilege oracles, whose
19
+ * behaviour is equal across every version.
20
+ */
21
+
22
+ import { createHash } from 'node:crypto';
23
+ import type { PolicyContract, TableContract, SequenceGrantContract } from './authz-contract';
24
+ import { holdsPrivilegeEffective, policyCommands } from './authz-contract';
25
+ import type { SchemaSnapshot, TableSchema } from './schema-introspect';
26
+ import { normalizeDefault, normalizeCheck, indexKey, nextvalSequence } from './schema-diff';
27
+ import { stableStringify, schemaOf, type CanonicalizeOptions } from './schema-fingerprint';
28
+
29
+ const PUBLIC_ROLE = 'public';
30
+
31
+ const byKey = <T>(items: T[], key: (item: T) => string): T[] =>
32
+ [...items].sort((a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0));
33
+
34
+ // --- v12 authz canonical form (frozen: the command fan, identical to v13) ---
35
+
36
+ interface PolicyArm {
37
+ role: string;
38
+ command: string;
39
+ permissive: boolean;
40
+ using: string;
41
+ check: string;
42
+ }
43
+
44
+ function policyArms(p: PolicyContract): PolicyArm[] {
45
+ const roles = p.roles.includes(PUBLIC_ROLE) ? [PUBLIC_ROLE] : [...p.roles].sort();
46
+ const arms: PolicyArm[] = [];
47
+ for (const command of policyCommands(p)) {
48
+ const using = command === 'INSERT' ? '' : (p.using ?? '');
49
+ const check = command === 'SELECT' || command === 'DELETE' ? '' : ((p.check ?? p.using) ?? '');
50
+ for (const role of roles) arms.push({ role, command, permissive: p.permissive, using, check });
51
+ }
52
+ return arms;
53
+ }
54
+
55
+ function armKey(a: PolicyArm): string {
56
+ return `${a.role}|${JSON.stringify([a.command, a.permissive, a.using, a.check])}`;
57
+ }
58
+
59
+ function multiset(entries: string[]): Array<[string, number]> {
60
+ const counts = new Map<string, number>();
61
+ for (const e of entries) counts.set(e, (counts.get(e) ?? 0) + 1);
62
+ return [...counts.entries()].sort(([a], [b]) => (a < b ? -1 : 1));
63
+ }
64
+
65
+ function governedArm(governed: ReadonlySet<string>, role: string): boolean {
66
+ return governed.has(role) || role === PUBLIC_ROLE || (role.toUpperCase() === 'PUBLIC' && governed.has('PUBLIC'));
67
+ }
68
+
69
+ function armDead(t: TableContract, a: PolicyArm): boolean {
70
+ if (a.role === PUBLIC_ROLE || a.role.toUpperCase() === 'PUBLIC') {
71
+ const grantees = t.effectivePrivileges
72
+ ? Object.keys(t.effectivePrivileges)
73
+ : [...new Set([...Object.keys(t.grants), ...Object.keys(t.columnGrants ?? {})])];
74
+ return !grantees.some((r) => holdsPrivilegeEffective(t, r, a.command));
75
+ }
76
+ return !holdsPrivilegeEffective(t, a.role, a.command);
77
+ }
78
+
79
+ function armSubsumed(a: PolicyArm, alive: readonly PolicyArm[]): boolean {
80
+ if (!a.permissive || a.role === PUBLIC_ROLE || a.role.toUpperCase() === 'PUBLIC') return false;
81
+ return alive.some((o) =>
82
+ (o.role === PUBLIC_ROLE || o.role.toUpperCase() === 'PUBLIC')
83
+ && o.permissive && o.command === a.command && o.using === a.using && o.check === a.check);
84
+ }
85
+
86
+ function canonicalPolicyArms(contract: TableContract, governed?: ReadonlySet<string>): Array<[string, number]> {
87
+ const alive = contract.policies.flatMap(policyArms).filter((a) => !armDead(contract, a));
88
+ const kept = alive
89
+ .filter((a) => !armSubsumed(a, alive))
90
+ .filter((a) => (governed ? governedArm(governed, a.role) : true));
91
+ return multiset(kept.map(armKey));
92
+ }
93
+
94
+ function governedOnly<T>(map: Record<string, T> | undefined, governed: ReadonlySet<string> | undefined): Record<string, T> {
95
+ if (!map) return {};
96
+ if (!governed) return map;
97
+ return Object.fromEntries(
98
+ Object.entries(map).filter(([grantee]) => governed.has(grantee) || (grantee.toUpperCase() === 'PUBLIC' && governed.has('PUBLIC'))),
99
+ );
100
+ }
101
+
102
+ function canonicalAuthzV12(contract: TableContract, governed?: ReadonlySet<string>): Record<string, unknown> {
103
+ const grants = governedOnly(contract.grants, governed);
104
+ const columnGrants = governedOnly(contract.columnGrants, governed);
105
+ return {
106
+ table: contract.table,
107
+ rls: { enabled: contract.rls.enabled, forced: contract.rls.forced },
108
+ grants: Object.fromEntries(
109
+ Object.entries(grants).map(([role, privs]) => [role, [...(privs as string[])].sort()] as const).sort(([a], [b]) => (a < b ? -1 : 1)),
110
+ ),
111
+ ...(Object.keys(columnGrants).length > 0
112
+ ? {
113
+ columnGrants: Object.fromEntries(
114
+ Object.entries(columnGrants)
115
+ .map(([role, byPriv]) => [
116
+ role,
117
+ Object.fromEntries(
118
+ Object.entries(byPriv as Record<string, string[]>).map(([priv, cols]) => [priv, [...cols].sort()] as const).sort(([a], [b]) => (a < b ? -1 : 1)),
119
+ ),
120
+ ] as const)
121
+ .sort(([a], [b]) => (a < b ? -1 : 1)),
122
+ ),
123
+ }
124
+ : {}),
125
+ policies: canonicalPolicyArms(contract, governed),
126
+ };
127
+ }
128
+
129
+ // --- v12 base-schema canonical form (v13 adds the NOT VALID flag to the FK slice; everything
130
+ // else is identical, frozen here for self-containment) ---
131
+
132
+ function canonicalTableV12(table: TableSchema): Record<string, unknown> {
133
+ return {
134
+ table: table.table,
135
+ columns: byKey(
136
+ table.columns.map((c) => ({ name: c.name, type: c.type, notNull: c.notNull, default: normalizeDefault(c.default) })),
137
+ (c) => c.name,
138
+ ),
139
+ primaryKey: table.primaryKey,
140
+ uniques: byKey(table.uniques.map((u) => ({ columns: u.columns })), (u) => u.columns.join(',')),
141
+ checks: byKey(table.checks.map((c) => ({ expr: normalizeCheck(c.expr) })), (c) => c.expr),
142
+ foreignKeys: byKey(
143
+ table.foreignKeys.map((f) => ({
144
+ columns: f.columns, refTable: f.refTable, refColumns: f.refColumns,
145
+ ...(f.onDelete ? { onDelete: f.onDelete } : {}),
146
+ ...(f.onUpdate ? { onUpdate: f.onUpdate } : {}),
147
+ })),
148
+ (f) => stableStringify(f),
149
+ ),
150
+ indexes: [...new Set(table.indexes.map(indexKey))].sort(),
151
+ };
152
+ }
153
+
154
+ // --- v12 serial-sequence ACL slice (frozen: USAGE only, exactly as v13 derives it) ---
155
+
156
+ /**
157
+ * The v12 inheritance rule: an INSERT-holder inherits USAGE on the table's serial sequence —
158
+ * exactly what `nextval` needs, and exactly what v13 still derives. Pinned as a literal here
159
+ * (rather than imported) so a LATER narrowing or widening of the live rule cannot move a hash
160
+ * this function is supposed to reproduce forever.
161
+ */
162
+ function inheritedSequenceGrantsV12(table: TableContract): Record<string, string[]> {
163
+ const out: Record<string, string[]> = {};
164
+ for (const [grantee, privileges] of Object.entries(table.grants)) {
165
+ if (privileges.includes('INSERT')) out[grantee] = ['USAGE'];
166
+ }
167
+ return out;
168
+ }
169
+
170
+ function isGovernedGrantee(governed: ReadonlySet<string> | undefined, grantee: string): boolean {
171
+ if (!governed) return true;
172
+ return governed.has(grantee) || (grantee.toUpperCase() === 'PUBLIC' && governed.has('PUBLIC'));
173
+ }
174
+
175
+ function seqShape(grants: Record<string, string[]>, governed?: ReadonlySet<string>): string {
176
+ const kept = Object.entries(grants)
177
+ .filter(([grantee, privs]) => privs.length > 0 && isGovernedGrantee(governed, grantee))
178
+ .map(([grantee, privs]) => [grantee, [...privs].sort()] as const);
179
+ return stableStringify(Object.fromEntries(kept));
180
+ }
181
+
182
+ function canonicalSequenceAuthzV12(
183
+ tables: TableSchema[],
184
+ authzTables: TableContract[],
185
+ measured: SequenceGrantContract[] | undefined,
186
+ governed: ReadonlySet<string> | undefined,
187
+ keepTable: (name: string) => boolean,
188
+ ): Array<[string, string[]]> {
189
+ const snapshotByTable = new Map(tables.map((t) => [t.table, t]));
190
+ const measuredByTable = new Map<string, SequenceGrantContract[]>();
191
+ for (const s of measured ?? []) {
192
+ (measuredByTable.get(s.table) ?? measuredByTable.set(s.table, []).get(s.table)!).push(s);
193
+ }
194
+ const out: Array<[string, string[]]> = [];
195
+ for (const az of authzTables) {
196
+ if (!keepTable(az.table)) continue;
197
+ let shapes: string[];
198
+ if (measuredByTable.has(az.table)) {
199
+ shapes = measuredByTable.get(az.table)!.filter((s) => !s.identity).map((s) => seqShape(s.grants, governed));
200
+ } else {
201
+ const snap = snapshotByTable.get(az.table);
202
+ const serialCount = (snap?.columns ?? []).filter((c) => nextvalSequence(normalizeDefault(c.default)) != null).length;
203
+ if (serialCount === 0) continue;
204
+ const inherited = seqShape(inheritedSequenceGrantsV12(az), governed);
205
+ shapes = Array.from({ length: serialCount }, () => inherited);
206
+ }
207
+ if (shapes.length === 0) continue;
208
+ out.push([az.table, shapes.sort()]);
209
+ }
210
+ return out.sort(([a], [b]) => (a < b ? -1 : 1));
211
+ }
212
+
213
+ /** The frozen v12 live hash for (snapshot, authzTables, opts). */
214
+ export function fingerprintV12(snapshot: SchemaSnapshot, authzTables: TableContract[], opts: CanonicalizeOptions = {}): string {
215
+ const inScope = opts.schemas ? new Set(opts.schemas) : null;
216
+ const keepTable = (name: string) => inScope === null || inScope.has(schemaOf(name));
217
+ const tables = snapshot.tables.filter((t) => keepTable(t.table));
218
+ const usedEnum = new Set<string>();
219
+ for (const t of tables) for (const c of t.columns) usedEnum.add(schemaOf(c.type) === 'public' ? c.type : c.type.slice(c.type.indexOf('.') + 1));
220
+ const sequences = byKey(
221
+ (snapshot.sequences ?? [])
222
+ .filter((s) => inScope === null || inScope.has(schemaOf(s.name)))
223
+ .map((s) => ({ name: s.name, as: s.as, ...(s.start != null ? { start: s.start } : {}), ...(s.increment != null ? { increment: s.increment } : {}) })),
224
+ (s) => String(s.name),
225
+ );
226
+ const sequenceAuthz = canonicalSequenceAuthzV12(tables, authzTables, opts.sequenceGrants, opts.governedRoles, keepTable);
227
+ const canonical = {
228
+ v: 12,
229
+ tables: byKey(tables.map(canonicalTableV12), (t) => String(t.table)),
230
+ enums: byKey(
231
+ (snapshot.enums ?? []).filter((e) => inScope === null || usedEnum.has(e.name)).map((e) => ({ name: e.name, values: e.values })),
232
+ (e) => e.name,
233
+ ),
234
+ authz: byKey(authzTables.filter((t) => keepTable(t.table)).map((t) => canonicalAuthzV12(t, opts.governedRoles)), (t) => String(t.table)),
235
+ ...(sequences.length ? { sequences } : {}),
236
+ ...(sequenceAuthz.length ? { sequenceAuthz } : {}),
237
+ };
238
+ return createHash('sha256').update(stableStringify(canonical)).digest('hex');
239
+ }
@@ -61,6 +61,7 @@ import { governedRoleSet, inheritedSequenceGrants } from './authz-reconcile';
61
61
  import { normalizeDefault, normalizeCheck, indexKey, nextvalSequence } from './schema-diff';
62
62
  import { fingerprintV10 } from './schema-fingerprint-v10';
63
63
  import { fingerprintV11 } from './schema-fingerprint-v11';
64
+ import { fingerprintV12 } from './schema-fingerprint-v12';
64
65
 
65
66
  // v2: expression fields (defaults, checks, partial-index WHERE) normalize through
66
67
  // the diff's pg-deparse normalizers before hashing, so the model form and the live
@@ -141,7 +142,20 @@ import { fingerprintV11 } from './schema-fingerprint-v11';
141
142
  // On every deployed stage the next db:plan mints a REVOKE narrowing those sequence grants to USAGE
142
143
  // (the report classifies it as narrowing access). The v11 canon is frozen in schema-fingerprint-v11
143
144
  // and registered in RETAINED_CANONS so a schema_log row recorded under it still descends.
144
- export const FINGERPRINT_VERSION = 12;
145
+ // v13 — a NOT VALID foreign key is its OWN state. A foreign key added `NOT VALID` exists and is
146
+ // enforced on every new write, but has never been checked against the rows already in the table:
147
+ // it is the declared key one step short, and that step is precisely where a two-step add
148
+ // (`ADD … NOT VALID` then `VALIDATE`) can be interrupted. The v12 form hashed a key's columns,
149
+ // target and referential actions only, so the half-applied key and the validated one the models
150
+ // declare were the SAME hash — `db:fingerprint` reported MATCH on a database whose rows no
151
+ // constraint had ever been proved against, and a re-plan had nothing to emit. The flag now rides
152
+ // the canonical form (written only when true, so a fully-valid database's hash is unmoved by the
153
+ // field itself; the `v` stamp moves it, like every bump). Same consequence as every bump: a plan
154
+ // or baseline minted under v12 refuses as a FORMAT CHANGE and must be re-minted (db:plan) or
155
+ // re-baselined (db:reconcile --rebaseline) — no DDL, the fingerprint is a content address. The
156
+ // v12 canon is frozen in schema-fingerprint-v12 and registered in RETAINED_CANONS so a schema_log
157
+ // row recorded under it still descends.
158
+ export const FINGERPRINT_VERSION = 13;
145
159
 
146
160
  // ---------------------------------------------------------------------------
147
161
  // Canonical form.
@@ -178,11 +192,18 @@ function canonicalTable(table: TableSchema): Record<string, unknown> {
178
192
  // Predicates hash as the diff compares them — one layer of pg's wrapping parens dropped
179
193
  // (`(scope = ANY (...))` == `scope = ANY (...)`), whitespace collapsed.
180
194
  checks: byKey(table.checks.map((c) => ({ expr: normalizeCheck(c.expr) })), (c) => c.expr),
195
+ // v13: a key pg has NEVER CHECKED against the rows already in the table is not the key the
196
+ // models declare — it is that key one step short, and the difference is exactly the step a
197
+ // two-step add can be interrupted between. Without the flag a half-applied foreign key
198
+ // hashed identically to a validated one, so `db:fingerprint` reported MATCH on a database
199
+ // whose data no constraint had ever been proved against. Written only when true, so every
200
+ // fully-valid database keeps the hash it had.
181
201
  foreignKeys: byKey(
182
202
  table.foreignKeys.map((f) => ({
183
203
  columns: f.columns, refTable: f.refTable, refColumns: f.refColumns,
184
204
  ...(f.onDelete ? { onDelete: f.onDelete } : {}),
185
205
  ...(f.onUpdate ? { onUpdate: f.onUpdate } : {}),
206
+ ...(f.notValid ? { notValid: true } : {}),
186
207
  })),
187
208
  (f) => stableStringify(f),
188
209
  ),
@@ -500,13 +521,15 @@ export type CanonHasher = (
500
521
  * absent from this map is unrecomputable: `fingerprintLiveAtVersion` returns null and descent
501
522
  * SKIPS that row (fails closed), never guesses a hash.
502
523
  *
503
- * Retired entries: `fingerprintV10` (no command fan, direct-grant deadness, no sequence ACLs) and
524
+ * Retired entries: `fingerprintV10` (no command fan, direct-grant deadness, no sequence ACLs),
504
525
  * `fingerprintV11` (the command fan and sequence ACLs, but the pre-least-privilege derivation that
505
- * gave an INSERT-holder SELECT + UPDATE + USAGE on its sequence).
526
+ * gave an INSERT-holder SELECT + UPDATE + USAGE on its sequence), and `fingerprintV12` (everything
527
+ * v13 has except the foreign-key validity flag, so a NOT VALID key hashed as a validated one).
506
528
  */
507
529
  export const RETAINED_CANONS: Readonly<Record<number, CanonHasher>> = {
508
530
  10: (snapshot, tables, opts) => fingerprintV10(snapshot, tables, opts),
509
531
  11: (snapshot, tables, opts) => fingerprintV11(snapshot, tables, opts),
532
+ 12: (snapshot, tables, opts) => fingerprintV12(snapshot, tables, opts),
510
533
  [FINGERPRINT_VERSION]: (snapshot, tables, opts) => fingerprintState(snapshot, tables, opts).hash,
511
534
  };
512
535
 
@@ -52,6 +52,18 @@ export interface ForeignKey {
52
52
  /** Lowercased referential actions, omitted when the Postgres default (`no action`). */
53
53
  onDelete?: string;
54
54
  onUpdate?: string;
55
+ /**
56
+ * The constraint exists but has NOT been checked against the rows already in the table
57
+ * (`convalidated = false`; `pg_get_constraintdef` spells it `… NOT VALID`).
58
+ *
59
+ * LIVE-SIDE ONLY. A Model declares a foreign key, never a half-installed one, so the
60
+ * compiled side never sets this — which is exactly what makes it visible as drift: a live
61
+ * NOT VALID key is a DIFFERENT state from the declared (validated) one. It rides the
62
+ * canonical fingerprint form for the same reason, so a half-applied key can never report
63
+ * MATCH. Omitted (not `false`) when the constraint is validated, so every already-recorded
64
+ * hash for a fully-valid database is untouched.
65
+ */
66
+ notValid?: boolean;
55
67
  }
56
68
 
57
69
  export interface UniqueConstraint {
@@ -416,6 +428,8 @@ export interface ConstraintDescriptor {
416
428
  /** FK referential actions, lowercased; omitted when the Postgres default (`no action`). */
417
429
  onDelete?: string;
418
430
  onUpdate?: string;
431
+ /** FK installed but never checked against the existing rows (`… NOT VALID`); omitted when valid. */
432
+ notValid?: boolean;
419
433
  }
420
434
 
421
435
  /** Parse one `pg_get_constraintdef` row into a structured descriptor. */
@@ -448,6 +462,12 @@ export function constraintRowToDescriptor(row: ConstraintRow): ConstraintDescrip
448
462
  refColumns: m ? parseColumnList(m[3]) : [],
449
463
  onDelete: action('DELETE'),
450
464
  onUpdate: action('UPDATE'),
465
+ // `pg_get_constraintdef` appends NOT VALID to a constraint pg has never checked against
466
+ // the rows already in the table — the state a two-step FK add leaves behind between its
467
+ // ADD and its VALIDATE. Read from the SAME deparsed text as everything else here, so no
468
+ // second catalog column has to be threaded through every caller of CONSTRAINTS_SQL.
469
+ // Omitted when valid: absence is what keeps a fully-valid snapshot byte-identical.
470
+ ...(/\bNOT\s+VALID\s*$/i.test(def.trim()) ? { notValid: true } : {}),
451
471
  };
452
472
  }
453
473
  // check — keep the predicate text inside the outer CHECK ( ... ), normalized.
@@ -662,6 +682,9 @@ export function assembleSchema(rows: SchemaRows): SchemaSnapshot {
662
682
  name: d.name, columns: d.columns ?? [], refTable: d.refTable ?? '', refColumns: d.refColumns ?? [],
663
683
  ...(d.onDelete ? { onDelete: d.onDelete } : {}),
664
684
  ...(d.onUpdate ? { onUpdate: d.onUpdate } : {}),
685
+ // Carried, not dropped: an unchecked key is a DIFFERENT state from a checked one, and
686
+ // the snapshot is what the differ and the fingerprint both read.
687
+ ...(d.notValid ? { notValid: true } : {}),
665
688
  });
666
689
  break;
667
690
  }