@everystack/cli 0.4.84 → 0.4.86

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.
@@ -29,6 +29,7 @@ import type {
29
29
  TableSchema,
30
30
  ColumnSchema,
31
31
  EnumType,
32
+ ForeignKey,
32
33
  IndexSchema,
33
34
  SequenceSchema,
34
35
  } from './schema-introspect';
@@ -41,7 +42,18 @@ import type {
41
42
  export type ConstraintSpec =
42
43
  | { type: 'unique'; name: string; columns: string[] }
43
44
  | { type: 'check'; name: string; expr: string }
44
- | { type: 'foreignKey'; name: string; columns: string[]; refTable: string; refColumns: string[]; onDelete?: string; onUpdate?: string; nameDeclared?: boolean };
45
+ | { type: 'foreignKey'; name: string; columns: string[]; refTable: string; refColumns: string[]; onDelete?: string; onUpdate?: string; nameDeclared?: boolean;
46
+ /**
47
+ * The key is installed but unchecked — on the LIVE side an observation, on the DESIRED
48
+ * side the author's `validate: false` (see {@link ForeignKey.notValid}).
49
+ *
50
+ * It is never part of {@link constraintContentKey}: a NOT VALID key IS the declared key,
51
+ * one step short, so validity can never make a key match a DIFFERENT key. What it decides
52
+ * is the SECOND statement — a desired `notValid` key emits `ADD … NOT VALID` alone, and a
53
+ * live `notValid` key is finished with a `VALIDATE` only when the desired side asked for
54
+ * one.
55
+ */
56
+ notValid?: boolean };
45
57
 
46
58
  export type SchemaChange =
47
59
  | { kind: 'createTable'; table: string; schema: TableSchema }
@@ -64,6 +76,15 @@ export type SchemaChange =
64
76
  /** A type change → real `ALTER COLUMN ... TYPE`. `risk` decides USING + a data-loss warning. */
65
77
  | { kind: 'alterType'; table: string; column: string; from: string; to: string; risk: TypeChangeRisk }
66
78
  | { kind: 'addConstraint'; table: string; constraint: ConstraintSpec; note?: string }
79
+ /**
80
+ * A foreign key that is already installed on the live table but has never been checked
81
+ * against its rows (`NOT VALID`) → the second half of the two-step add, on its own.
82
+ *
83
+ * It is a DISTINCT kind from `addConstraint` because the ADD must not be re-emitted: the
84
+ * constraint is there, and a second `ADD CONSTRAINT` under the same name fails outright.
85
+ * This is what the state after an interrupted two-step add re-plans to — VALIDATE only.
86
+ */
87
+ | { kind: 'validateConstraint'; table: string; name: string }
67
88
  | { kind: 'dropConstraint'; table: string; name: string }
68
89
  | { kind: 'addPrimaryKey'; table: string; columns: string[] }
69
90
  /** A PK change other than first-time add — a notice (the drop needs its constraint name). */
@@ -499,7 +520,7 @@ export function diffSchema(desired: SchemaSnapshot, current: SchemaSnapshot, ren
499
520
  addConstraints.push({
500
521
  kind: 'addConstraint',
501
522
  table: d.table,
502
- constraint: { type: 'foreignKey', name: fk.name, columns: fk.columns, refTable: fk.refTable, refColumns: fk.refColumns, onDelete: fk.onDelete, onUpdate: fk.onUpdate },
523
+ constraint: { type: 'foreignKey', name: fk.name, columns: fk.columns, refTable: fk.refTable, refColumns: fk.refColumns, onDelete: fk.onDelete, onUpdate: fk.onUpdate, ...(fk.notValid ? { notValid: true } : {}) },
503
524
  });
504
525
  }
505
526
  for (const ix of d.indexes) createIndexes.push(createIndexChange(d.table, ix));
@@ -791,7 +812,7 @@ function diffColumns(d: TableSchema, c: TableSchema, tableRenames: Record<string
791
812
  function tableConstraintSpecs(t: TableSchema): ConstraintSpec[] {
792
813
  return [
793
814
  ...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 } : {}) })),
815
+ ...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
816
  ];
796
817
  }
797
818
 
@@ -804,10 +825,25 @@ function tableConstraintSpecs(t: TableSchema): ConstraintSpec[] {
804
825
  */
805
826
  function constraintContentKey(c: ConstraintSpec): string {
806
827
  if (c.type === 'unique') return `u|${c.columns.join(',')}`;
807
- if (c.type === 'foreignKey') return `f|${c.columns.join(',')}|${c.refTable}|${c.refColumns.join(',')}|${c.onDelete ?? ''}|${c.onUpdate ?? ''}`;
828
+ if (c.type === 'foreignKey') return foreignKeyContentKey(c);
808
829
  return `c|${normalizeCheck(c.expr)}`; // checks take their own predicate-matched path (diffChecks)
809
830
  }
810
831
 
832
+ /**
833
+ * A foreign key's content identity — columns, target, target columns, referential actions.
834
+ * VALIDITY IS NOT IN IT (see {@link ConstraintSpec}), and neither is the name.
835
+ *
836
+ * Exported because a second surface asks the same question: the plan's predicted endpoint has to
837
+ * find the live counterpart of a declared key to carry its validity through. Two spellings of
838
+ * "are these the same key" is how the differ and the prediction end up disagreeing about whether
839
+ * an edge changed anything.
840
+ */
841
+ export function foreignKeyContentKey(
842
+ fk: Pick<ForeignKey, 'columns' | 'refTable' | 'refColumns' | 'onDelete' | 'onUpdate'>,
843
+ ): string {
844
+ return `f|${fk.columns.join(',')}|${fk.refTable}|${fk.refColumns.join(',')}|${fk.onDelete ?? ''}|${fk.onUpdate ?? ''}`;
845
+ }
846
+
811
847
  /**
812
848
  * Diff CHECK constraints by their normalized predicate, not their name: a check with the
813
849
  * same predicate is the same check no matter what it's called, so a pulled check (whose DB
@@ -857,6 +893,26 @@ function diffConstraints(d: TableSchema, c: TableSchema, dropConstraints: Schema
857
893
  if (des.type === 'foreignKey' && des.nameDeclared && matched.name !== des.name) {
858
894
  renames.push({ kind: 'renameConstraint', table: d.table, from: matched.name, to: des.name });
859
895
  }
896
+ // The live key is there but was never checked against the rows already in the table —
897
+ // what an interrupted two-step add leaves behind, and what a hand-run
898
+ // `ADD CONSTRAINT … NOT VALID` leaves behind. When the declared side is a VALIDATED key
899
+ // that is a real difference and the edge finishes it: VALIDATE alone, never a second
900
+ // ADD (which would fail on the name that is already taken). Under the live name, which
901
+ // may not be the declared one — a rename emitted just above lands FIRST, so the
902
+ // validate must speak the name the constraint will have by then.
903
+ //
904
+ // …UNLESS the declared side ASKED for NOT VALID (`validate: false`). Then live and
905
+ // declared agree and there is nothing to emit: validating would be the edge doing the
906
+ // one thing the author declared it must never do. Note the direction — a live key that
907
+ // is fully VALID under a `validate: false` declaration also falls through here with no
908
+ // statement, because `matched.notValid` is absent. That is the accept-either rule: there
909
+ // is no `ALTER TABLE … INVALIDATE CONSTRAINT`, so a key proven MORE than the model asked
910
+ // for is not a difference anyone could act on.
911
+ const desiredNotValid = des.type === 'foreignKey' && des.notValid === true;
912
+ if (matched.type === 'foreignKey' && matched.notValid && !desiredNotValid) {
913
+ const name = des.type === 'foreignKey' && des.nameDeclared ? des.name : matched.name;
914
+ addConstraints.push({ kind: 'validateConstraint', table: d.table, name });
915
+ }
860
916
  } else {
861
917
  addConstraints.push({ kind: 'addConstraint', table: d.table, constraint: des });
862
918
  }
@@ -934,11 +990,43 @@ function createTableSql(t: TableSchema): string {
934
990
  return `CREATE TABLE ${t.table} (\n${lines.join(',\n')}\n);`;
935
991
  }
936
992
 
937
- function addConstraintSql(table: string, c: ConstraintSpec): string {
993
+ /**
994
+ * `ALTER TABLE … VALIDATE CONSTRAINT …` — the second half of a two-step foreign key add.
995
+ *
996
+ * ONE definition, read by the FK add (which always emits the pair) and by the change kind that
997
+ * finishes a key some earlier apply left NOT VALID. Two spellings of the same statement is how
998
+ * the "did this already run?" question ends up with two answers.
999
+ */
1000
+ export function validateConstraintSql(table: string, name: string): string {
1001
+ return `ALTER TABLE ${table} VALIDATE CONSTRAINT ${quote(name)};`;
1002
+ }
1003
+
1004
+ /**
1005
+ * Rendering an `ADD CONSTRAINT`: ONE statement, except a foreign key, which is TWO.
1006
+ *
1007
+ * A validated FK add takes an ACCESS EXCLUSIVE lock on the table for the length of a full scan
1008
+ * of every row already in it — on a large, live table that is a write outage measured in
1009
+ * minutes. PostgreSQL's own answer is to split the work:
1010
+ *
1011
+ * 1. `ADD CONSTRAINT … NOT VALID` — catalog-only and near-instant. The key is enforced on
1012
+ * every row written from this moment on; the rows already there are simply unchecked.
1013
+ * 2. `VALIDATE CONSTRAINT …` — the scan, under a SHARE UPDATE EXCLUSIVE lock that readers
1014
+ * and writers both pass through.
1015
+ *
1016
+ * The split only buys that if the two run in SEPARATE transactions (step 1's lock is held to
1017
+ * commit), which is why the apply path runs every VALIDATE after the edge's batch commits — see
1018
+ * `splitValidations` in state-apply. The text is produced HERE for both `db:diff` (no database)
1019
+ * and `db:plan` (against a live one), so the two can never render the same edge differently.
1020
+ *
1021
+ * ONE statement again for a key the model declared `validate: false`: step 1 alone IS the whole
1022
+ * declared state there, so step 2 is not deferred, it does not exist. See
1023
+ * `FieldSpec.referencesValidate` for why a schema needs to be able to say that.
1024
+ */
1025
+ function addConstraintSql(table: string, c: ConstraintSpec): string[] {
938
1026
  const head = `ALTER TABLE ${table} ADD CONSTRAINT ${quote(c.name)}`;
939
1027
  switch (c.type) {
940
- case 'unique': return `${head} UNIQUE (${colList(c.columns)});`;
941
- case 'check': return `${head} CHECK ${wrapOnce(c.expr)};`;
1028
+ case 'unique': return [`${head} UNIQUE (${colList(c.columns)});`];
1029
+ case 'check': return [`${head} CHECK ${wrapOnce(c.expr)};`];
942
1030
  case 'foreignKey': {
943
1031
  // refTable may be schema-qualified (`auth.users`) for a cross-schema FK — quote each part,
944
1032
  // not the whole string, or `"auth.users"` reads as one identifier.
@@ -946,7 +1034,8 @@ function addConstraintSql(table: string, c: ConstraintSpec): string {
946
1034
  let s = `${head} FOREIGN KEY (${colList(c.columns)}) REFERENCES ${refTable}(${colList(c.refColumns)})`;
947
1035
  if (c.onDelete) s += ` ON DELETE ${c.onDelete.toUpperCase()}`;
948
1036
  if (c.onUpdate) s += ` ON UPDATE ${c.onUpdate.toUpperCase()}`;
949
- return `${s};`;
1037
+ const add = `${s} NOT VALID;`;
1038
+ return c.notValid === true ? [add] : [add, validateConstraintSql(table, c.name)];
950
1039
  }
951
1040
  }
952
1041
  }
@@ -959,8 +1048,16 @@ export interface EmitOptions {
959
1048
  createTable?: (change: Extract<SchemaChange, { kind: 'createTable' }>) => string;
960
1049
  }
961
1050
 
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 {
1051
+ /**
1052
+ * Render one change to SQL — or a `-- NOTICE:` comment for the cases held for a follow-up.
1053
+ *
1054
+ * Returns a STRING for the one-statement kinds and an ARRAY for the kinds that are genuinely
1055
+ * more than one statement (today: a foreign-key add, which is `NOT VALID` + `VALIDATE`).
1056
+ * `emitSchemaStatements` flattens it and keeps each statement paired with the change it came
1057
+ * from, so the callers that decide something per change (the held-drop gate) never have to
1058
+ * assume one-statement-per-change.
1059
+ */
1060
+ function emitOne(change: SchemaChange, opts: EmitOptions): string | string[] {
964
1061
  switch (change.kind) {
965
1062
  case 'createTable':
966
1063
  return opts.createTable ? opts.createTable(change) : createTableSql(change.schema);
@@ -1011,8 +1108,14 @@ function emitOne(change: SchemaChange, opts: EmitOptions): string {
1011
1108
  : `-- 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
1109
  return `${warn}\n${ddl}`;
1013
1110
  }
1014
- case 'addConstraint':
1015
- return (change.note ? `${change.note}\n` : '') + addConstraintSql(change.table, change.constraint);
1111
+ case 'addConstraint': {
1112
+ const [first, ...rest] = addConstraintSql(change.table, change.constraint);
1113
+ // The note rides the FIRST statement only — it documents the change, and repeating it on
1114
+ // the VALIDATE would read as a second finding.
1115
+ return [(change.note ? `${change.note}\n` : '') + first, ...rest];
1116
+ }
1117
+ case 'validateConstraint':
1118
+ return validateConstraintSql(change.table, change.name);
1016
1119
  case 'dropConstraint':
1017
1120
  return `ALTER TABLE ${change.table} DROP CONSTRAINT IF EXISTS ${quote(change.name)};`;
1018
1121
  case 'addPrimaryKey':
@@ -1088,7 +1191,28 @@ function emitOne(change: SchemaChange, opts: EmitOptions): string {
1088
1191
  }
1089
1192
  }
1090
1193
 
1194
+ /** One emitted statement and the change that produced it. */
1195
+ export interface EmittedStatement {
1196
+ change: SchemaChange;
1197
+ sql: string;
1198
+ }
1199
+
1200
+ /**
1201
+ * The change set as an ordered list of statements, each still carrying its change.
1202
+ *
1203
+ * A change is NOT one statement any more (a foreign-key add is `NOT VALID` + `VALIDATE`), so
1204
+ * callers that decide something per change — db:generate's held-drop gate is the only one —
1205
+ * read the pairing from here instead of indexing a parallel array. Index alignment was a real
1206
+ * invariant right up until it silently was not.
1207
+ */
1208
+ export function emitSchemaStatements(changes: SchemaChange[], opts: EmitOptions = {}): EmittedStatement[] {
1209
+ return changes.flatMap((change) => {
1210
+ const emitted = emitOne(change, opts);
1211
+ return (Array.isArray(emitted) ? emitted : [emitted]).map((sql) => ({ change, sql }));
1212
+ });
1213
+ }
1214
+
1091
1215
  /** The change set as an ordered list of SQL statements (and notice comments). */
1092
1216
  export function emitSchemaSql(changes: SchemaChange[], opts: EmitOptions = {}): string[] {
1093
- return changes.map((c) => emitOne(c, opts));
1217
+ return emitSchemaStatements(changes, opts).map((e) => e.sql);
1094
1218
  }
@@ -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,41 @@ 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
+ //
159
+ // NO v14 FOR `validate: false` — worked out here so the next reader does not ask again.
160
+ // The Model can now DECLARE a foreign key permanently NOT VALID (`references(…, { validate:
161
+ // false })` → `ForeignKey.notValid` on the COMPILED side, which v13 said only introspection ever
162
+ // set). A bump is required when the CANONICAL FORM changes — when the same database would hash
163
+ // differently before and after. Neither half of that happens here:
164
+ //
165
+ // 1. The form is byte-identical. `canonicalTable` below already reads `f.notValid` and already
166
+ // writes it only when true. No key is added, removed, or normalized differently; the shape
167
+ // that hashes a live snapshot is untouched.
168
+ // 2. Every hash anyone RECORDED is a live hash. `RETAINED_CANONS`/`fingerprintLiveAtVersion`
169
+ // recompute from a SchemaSnapshot read out of the database, and this feature does not change
170
+ // what introspection reports about any existing key. So every schema_log row still descends.
171
+ //
172
+ // What moves is the DECLARED hash of a model that newly writes `validate: false` — which is a
173
+ // state change the author made on purpose, exactly like adding a column. That is the format
174
+ // working, not the format changing. (Deliberately kept in step: the from-scratch build emits
175
+ // `ADD … NOT VALID` for such a key too — schema-diff's `addConstraintSql` and schema-compile's
176
+ // `foreignKeySql` — so building a database FROM that declaration lands on the declared hash
177
+ // rather than one step past it. Without that half the declared side would hash a state no build
178
+ // of it could produce, and `db:check` could never reach MATCH.)
179
+ export const FINGERPRINT_VERSION = 13;
145
180
 
146
181
  // ---------------------------------------------------------------------------
147
182
  // Canonical form.
@@ -178,11 +213,23 @@ function canonicalTable(table: TableSchema): Record<string, unknown> {
178
213
  // Predicates hash as the diff compares them — one layer of pg's wrapping parens dropped
179
214
  // (`(scope = ANY (...))` == `scope = ANY (...)`), whitespace collapsed.
180
215
  checks: byKey(table.checks.map((c) => ({ expr: normalizeCheck(c.expr) })), (c) => c.expr),
216
+ // v13: a key pg has NEVER CHECKED against the rows already in the table is not the key the
217
+ // models declare — it is that key one step short, and the difference is exactly the step a
218
+ // two-step add can be interrupted between. Without the flag a half-applied foreign key
219
+ // hashed identically to a validated one, so `db:fingerprint` reported MATCH on a database
220
+ // whose data no constraint had ever been proved against. Written only when true, so every
221
+ // fully-valid database keeps the hash it had.
222
+ //
223
+ // The flag now arrives from EITHER side — introspection's observation, or a Model's
224
+ // `validate: false` (see the FINGERPRINT_VERSION note above for why that needed no bump).
225
+ // The canonical form does not care which: one bit, one meaning — this key has never been
226
+ // checked against the rows that were already there.
181
227
  foreignKeys: byKey(
182
228
  table.foreignKeys.map((f) => ({
183
229
  columns: f.columns, refTable: f.refTable, refColumns: f.refColumns,
184
230
  ...(f.onDelete ? { onDelete: f.onDelete } : {}),
185
231
  ...(f.onUpdate ? { onUpdate: f.onUpdate } : {}),
232
+ ...(f.notValid ? { notValid: true } : {}),
186
233
  })),
187
234
  (f) => stableStringify(f),
188
235
  ),
@@ -500,13 +547,15 @@ export type CanonHasher = (
500
547
  * absent from this map is unrecomputable: `fingerprintLiveAtVersion` returns null and descent
501
548
  * SKIPS that row (fails closed), never guesses a hash.
502
549
  *
503
- * Retired entries: `fingerprintV10` (no command fan, direct-grant deadness, no sequence ACLs) and
550
+ * Retired entries: `fingerprintV10` (no command fan, direct-grant deadness, no sequence ACLs),
504
551
  * `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).
552
+ * gave an INSERT-holder SELECT + UPDATE + USAGE on its sequence), and `fingerprintV12` (everything
553
+ * v13 has except the foreign-key validity flag, so a NOT VALID key hashed as a validated one).
506
554
  */
507
555
  export const RETAINED_CANONS: Readonly<Record<number, CanonHasher>> = {
508
556
  10: (snapshot, tables, opts) => fingerprintV10(snapshot, tables, opts),
509
557
  11: (snapshot, tables, opts) => fingerprintV11(snapshot, tables, opts),
558
+ 12: (snapshot, tables, opts) => fingerprintV12(snapshot, tables, opts),
510
559
  [FINGERPRINT_VERSION]: (snapshot, tables, opts) => fingerprintState(snapshot, tables, opts).hash,
511
560
  };
512
561
 
@@ -52,6 +52,31 @@ 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 key is enforced on every new write but has NOT been checked against the rows already
57
+ * in the table (`convalidated = false`; `pg_get_constraintdef` spells it `… NOT VALID`).
58
+ *
59
+ * BOTH SIDES SET THIS, and they mean two different things by it:
60
+ *
61
+ * LIVE-OBSERVED — introspection read `convalidated = false`. It says nothing about
62
+ * WHY: an interrupted two-step add, a hand-run `ADD … NOT VALID`, or
63
+ * a key someone deliberately left unproven years ago.
64
+ * DECLARED-INTENTIONAL — the Model said `validate: false` on the reference, which is the
65
+ * author declaring "enforce forward, grandfather legacy" as the
66
+ * PERMANENT state of this key (see `FieldSpec.referencesValidate`).
67
+ * The differ then emits `ADD CONSTRAINT … NOT VALID` and NEVER the
68
+ * paired `VALIDATE`.
69
+ *
70
+ * HOW THE TWO MEET. A declared-intentional key is satisfied by EITHER a live NOT VALID key
71
+ * or a live FULLY-VALID one: PostgreSQL has no "un-validate", so "more proven than you asked
72
+ * for" is not a difference anybody can act on and must not read as drift. The asymmetry only
73
+ * runs the other way — a live NOT VALID key under a declaration that did NOT say
74
+ * `validate: false` IS a real difference, and the edge closes it with a `VALIDATE` alone.
75
+ *
76
+ * Omitted (not `false`) when the key is validated, on both sides, so every already-recorded
77
+ * hash for a fully-valid database is untouched.
78
+ */
79
+ notValid?: boolean;
55
80
  }
56
81
 
57
82
  export interface UniqueConstraint {
@@ -416,6 +441,8 @@ export interface ConstraintDescriptor {
416
441
  /** FK referential actions, lowercased; omitted when the Postgres default (`no action`). */
417
442
  onDelete?: string;
418
443
  onUpdate?: string;
444
+ /** FK installed but never checked against the existing rows (`… NOT VALID`); omitted when valid. */
445
+ notValid?: boolean;
419
446
  }
420
447
 
421
448
  /** Parse one `pg_get_constraintdef` row into a structured descriptor. */
@@ -448,6 +475,12 @@ export function constraintRowToDescriptor(row: ConstraintRow): ConstraintDescrip
448
475
  refColumns: m ? parseColumnList(m[3]) : [],
449
476
  onDelete: action('DELETE'),
450
477
  onUpdate: action('UPDATE'),
478
+ // `pg_get_constraintdef` appends NOT VALID to a constraint pg has never checked against
479
+ // the rows already in the table — the state a two-step FK add leaves behind between its
480
+ // ADD and its VALIDATE. Read from the SAME deparsed text as everything else here, so no
481
+ // second catalog column has to be threaded through every caller of CONSTRAINTS_SQL.
482
+ // Omitted when valid: absence is what keeps a fully-valid snapshot byte-identical.
483
+ ...(/\bNOT\s+VALID\s*$/i.test(def.trim()) ? { notValid: true } : {}),
451
484
  };
452
485
  }
453
486
  // check — keep the predicate text inside the outer CHECK ( ... ), normalized.
@@ -662,6 +695,9 @@ export function assembleSchema(rows: SchemaRows): SchemaSnapshot {
662
695
  name: d.name, columns: d.columns ?? [], refTable: d.refTable ?? '', refColumns: d.refColumns ?? [],
663
696
  ...(d.onDelete ? { onDelete: d.onDelete } : {}),
664
697
  ...(d.onUpdate ? { onUpdate: d.onUpdate } : {}),
698
+ // Carried, not dropped: an unchecked key is a DIFFERENT state from a checked one, and
699
+ // the snapshot is what the differ and the fingerprint both read.
700
+ ...(d.notValid ? { notValid: true } : {}),
665
701
  });
666
702
  break;
667
703
  }