@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.
- package/CHANGELOG.md +18 -1
- package/package.json +3 -3
- package/src/cli/apply-execute.ts +123 -2
- package/src/cli/apply-refusals.ts +112 -0
- package/src/cli/commands/db-apply.ts +104 -7
- package/src/cli/commands/db-plan.ts +30 -0
- package/src/cli/derived-apply.ts +17 -0
- package/src/cli/edge-plan.ts +64 -13
- package/src/cli/fk-orphan-advisory.ts +170 -0
- package/src/cli/fk-preflight.ts +414 -0
- package/src/cli/migration-generate.ts +8 -7
- package/src/cli/model-render.ts +10 -0
- package/src/cli/plain-output.ts +10 -2
- package/src/cli/schema-compile.ts +14 -1
- package/src/cli/schema-diff.ts +137 -13
- package/src/cli/schema-fingerprint-v12.ts +239 -0
- package/src/cli/schema-fingerprint.ts +52 -3
- package/src/cli/schema-introspect.ts +36 -0
- package/src/cli/state-apply.ts +227 -13
package/src/cli/schema-diff.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
963
|
-
|
|
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
|
-
|
|
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((
|
|
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
|
-
|
|
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)
|
|
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
|
}
|