@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.
- package/CHANGELOG.md +7 -1
- package/package.json +2 -2
- package/src/cli/apply-execute.ts +61 -1
- package/src/cli/apply-refusals.ts +74 -0
- package/src/cli/commands/db-apply.ts +20 -4
- package/src/cli/commands/db-plan.ts +1 -0
- package/src/cli/derived-apply.ts +17 -0
- package/src/cli/edge-plan.ts +10 -1
- package/src/cli/fk-preflight.ts +331 -0
- package/src/cli/migration-generate.ts +8 -7
- package/src/cli/plain-output.ts +10 -2
- package/src/cli/schema-diff.ts +98 -11
- package/src/cli/schema-fingerprint-v12.ts +239 -0
- package/src/cli/schema-fingerprint.ts +26 -3
- package/src/cli/schema-introspect.ts +23 -0
- package/src/cli/state-apply.ts +160 -13
|
@@ -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,
|
|
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 =
|
|
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`.
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
|
|
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((
|
|
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
|
package/src/cli/plain-output.ts
CHANGED
|
@@ -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
|
|
package/src/cli/schema-diff.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
963
|
-
|
|
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
|
-
|
|
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((
|
|
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
|
-
|
|
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)
|
|
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
|
}
|