@everystack/cli 0.4.53 → 0.4.56
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/package.json +4 -3
- package/src/cli/authz-contract.ts +155 -3
- package/src/cli/authz-reconcile.ts +146 -1
- package/src/cli/authz-render.ts +26 -4
- package/src/cli/commands/db-build.ts +153 -0
- package/src/cli/commands/db-check.ts +20 -3
- package/src/cli/commands/db-fingerprint.ts +16 -2
- package/src/cli/commands/db-generate.ts +3 -3
- package/src/cli/commands/db-plan.ts +7 -1
- package/src/cli/commands/db-pull.ts +6 -1
- package/src/cli/commands/db-reconcile.ts +41 -2
- package/src/cli/commands/db-sync.ts +5 -2
- package/src/cli/commands/security.ts +24 -3
- package/src/cli/db-build.ts +36 -1
- package/src/cli/declared-derived.ts +11 -0
- package/src/cli/derived-apply.ts +249 -18
- package/src/cli/derived-compile.ts +25 -7
- package/src/cli/derived-grants.ts +96 -3
- package/src/cli/derived-introspect.ts +98 -1
- package/src/cli/derived-lint.ts +56 -0
- package/src/cli/derived-plan.ts +48 -1
- package/src/cli/derived-render.ts +153 -3
- package/src/cli/derived-source.ts +32 -2
- package/src/cli/edge-plan.ts +7 -0
- package/src/cli/index.ts +5 -0
- package/src/cli/migration-generate.ts +24 -1
- package/src/cli/model-render.ts +28 -3
- package/src/cli/schema-compile.ts +75 -12
- package/src/cli/schema-diff.ts +133 -14
- package/src/cli/schema-fingerprint.ts +59 -6
- package/src/cli/schema-introspect.ts +53 -3
- package/src/cli/schema-source.ts +54 -8
- package/src/cli/security-audit.ts +131 -2
- package/src/cli/security-catalog.ts +18 -3
- package/src/cli/state-apply.ts +11 -2
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
|
|
22
22
|
import type { ModelDescriptor, FieldSpec, SequenceDescriptor } from '@everystack/model';
|
|
23
23
|
import type { TableSchema, EnumType, SequenceSchema, UniqueConstraint, CheckConstraint, IndexSchema, ForeignKey } from './schema-introspect.js';
|
|
24
|
-
import { nextvalSequence, type RenameMap } from './schema-diff.js';
|
|
24
|
+
import { nextvalSequence, identityClause, type RenameMap } from './schema-diff.js';
|
|
25
|
+
import { normalizeDeparsedExpr } from './deparse-normal.js';
|
|
25
26
|
|
|
26
27
|
/** `authorId` -> `author_id`. SQL identifiers are snake_case. */
|
|
27
28
|
export function toSnakeCase(name: string): string {
|
|
@@ -35,6 +36,23 @@ export function toSnakeCase(name: string): string {
|
|
|
35
36
|
* two never disagree. Field keys are snake_cased to SQL columns; names are deterministic
|
|
36
37
|
* (a composite unique by its columns, a check by its position) so they round-trip.
|
|
37
38
|
*/
|
|
39
|
+
/**
|
|
40
|
+
* The generated index name — `<table>_<cols>_index`, the FALLBACK when the model declares none.
|
|
41
|
+
*
|
|
42
|
+
* Exported because `db:pull` needs the same answer to decide whether a live name is worth
|
|
43
|
+
* declaring: it renders `.name(...)` exactly when the database's name is NOT the one this would
|
|
44
|
+
* produce, so a greenfield model stays clean and an adopted one keeps every name it arrived with.
|
|
45
|
+
* One implementation, because two copies of a naming rule is how these surfaces drift — the same
|
|
46
|
+
* lesson policy equality already learned here.
|
|
47
|
+
*
|
|
48
|
+
* `table` is the BARE table name; `columns` are the SQL-side key texts (snake_cased plain
|
|
49
|
+
* entries, or verbatim expression text), which sanitize to identifier-safe parts.
|
|
50
|
+
*/
|
|
51
|
+
export function generatedIndexName(table: string, columns: string[]): string {
|
|
52
|
+
const parts = columns.map((c) => c.replace(/\W+/g, '_').replace(/^_+|_+$/g, ''));
|
|
53
|
+
return `${table}_${parts.join('_')}_index`;
|
|
54
|
+
}
|
|
55
|
+
|
|
38
56
|
export function modelConstraintSpecs(model: ModelDescriptor): { uniques: UniqueConstraint[]; checks: CheckConstraint[]; indexes: IndexSchema[] } {
|
|
39
57
|
const uniques: UniqueConstraint[] = [];
|
|
40
58
|
const checks: CheckConstraint[] = [];
|
|
@@ -46,7 +64,10 @@ export function modelConstraintSpecs(model: ModelDescriptor): { uniques: UniqueC
|
|
|
46
64
|
const cols = con.columns.map(toSnakeCase);
|
|
47
65
|
uniques.push({ name: `${model.table}_${cols.join('_')}_unique`, columns: cols });
|
|
48
66
|
} else if (con.kind === 'check') {
|
|
49
|
-
|
|
67
|
+
// Normalized like the live producer (schema-introspect): a model that transcribes a
|
|
68
|
+
// pulled predicate must compare equal to the database it came from, whichever of the
|
|
69
|
+
// two equivalent deparse spellings each side happens to hold. See deparse-normal.ts.
|
|
70
|
+
checks.push({ name: `${model.table}_check_${checkIndex++}`, expr: normalizeDeparsedExpr(con.predicate) });
|
|
50
71
|
} else if (con.kind === 'index') {
|
|
51
72
|
// Plain entries are field keys → snake_cased; raw sql`` entries (expressions,
|
|
52
73
|
// DESC, opclass) pass verbatim — they are already SQL (Brick E).
|
|
@@ -56,15 +77,26 @@ export function modelConstraintSpecs(model: ModelDescriptor): { uniques: UniqueC
|
|
|
56
77
|
// unique within the table for CREATE to succeed. The diff matches indexes by content
|
|
57
78
|
// (entries + uniqueness + predicate + method + INCLUDE), never by name, so the suffix
|
|
58
79
|
// never affects the round-trip. Raw entries sanitize to identifier-safe name parts.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
80
|
+
// A DECLARED name wins outright — the generated one is the fallback, never the default.
|
|
81
|
+
// An adopted database arrives with names its previous tooling chose, and rebuilding under
|
|
82
|
+
// ours renamed ~200 of one consumer's indexes: counts and definitions matched, so nothing
|
|
83
|
+
// failed loudly, but a planner regression test, runbooks, pg_stat_user_indexes history and
|
|
84
|
+
// REINDEX scripts all name indexes. The declared name is validated at declaration
|
|
85
|
+
// (IndexBuilder.name), never here.
|
|
86
|
+
let name = con.indexName ?? generatedIndexName(model.table, cols);
|
|
87
|
+
// The uniquifying suffix applies to GENERATED names only. A declared name is the
|
|
88
|
+
// operator's word about what the database calls this index; silently making it
|
|
89
|
+
// `..._2` would rename the very thing they declared to stop renaming.
|
|
90
|
+
if (con.indexName === undefined) {
|
|
91
|
+
for (let n = 2; usedIndexNames.has(name); n++) name = `${generatedIndexName(model.table, cols)}_${n}`;
|
|
92
|
+
}
|
|
62
93
|
usedIndexNames.add(name);
|
|
63
94
|
indexes.push({
|
|
64
95
|
name,
|
|
96
|
+
...(con.indexName !== undefined ? { nameDeclared: true } : {}),
|
|
65
97
|
columns: cols,
|
|
66
98
|
unique: con.isUnique,
|
|
67
|
-
...(con.predicate ? { where: con.predicate } : {}),
|
|
99
|
+
...(con.predicate ? { where: normalizeDeparsedExpr(con.predicate) } : {}),
|
|
68
100
|
...(con.method ? { using: con.method } : {}),
|
|
69
101
|
...(con.includeColumns?.length ? { include: con.includeColumns.map(toSnakeCase) } : {}),
|
|
70
102
|
});
|
|
@@ -166,7 +198,11 @@ export function modelForeignKeys(model: ModelDescriptor): ForeignKey[] {
|
|
|
166
198
|
const col = toSnakeCase(name);
|
|
167
199
|
const targetPk = toSnakeCase(target.primaryKey[0] ?? 'id');
|
|
168
200
|
out.push({
|
|
169
|
-
name
|
|
201
|
+
// A DECLARED name wins; the generated one is the fallback. An adopted database keeps the
|
|
202
|
+
// FK names its previous tooling chose (`fk_rails_9a1b2c3d4e`) instead of being renamed
|
|
203
|
+
// wholesale by a rebuild the differ could not see. Validated at declaration.
|
|
204
|
+
name: field.spec.constraintName ?? generatedForeignKeyName(model.table, [col], target.table, [targetPk]),
|
|
205
|
+
...(field.spec.constraintName !== undefined ? { nameDeclared: true } : {}),
|
|
170
206
|
columns: [col],
|
|
171
207
|
refTable: refTableName(target),
|
|
172
208
|
refColumns: [targetPk],
|
|
@@ -180,7 +216,8 @@ export function modelForeignKeys(model: ModelDescriptor): ForeignKey[] {
|
|
|
180
216
|
const cols = con.columns.map(toSnakeCase);
|
|
181
217
|
const refCols = con.refColumns.map(toSnakeCase);
|
|
182
218
|
out.push({
|
|
183
|
-
name:
|
|
219
|
+
name: con.constraintName ?? generatedForeignKeyName(model.table, cols, target.table, refCols),
|
|
220
|
+
...(con.constraintName !== undefined ? { nameDeclared: true } : {}),
|
|
184
221
|
columns: cols,
|
|
185
222
|
refTable: refTableName(target),
|
|
186
223
|
refColumns: refCols,
|
|
@@ -191,6 +228,18 @@ export function modelForeignKeys(model: ModelDescriptor): ForeignKey[] {
|
|
|
191
228
|
return out;
|
|
192
229
|
}
|
|
193
230
|
|
|
231
|
+
/**
|
|
232
|
+
* The generated FK constraint name — `<table>_<cols>_<target>_<refcols>_fk`, drizzle's shape and
|
|
233
|
+
* the FALLBACK when the model declares none.
|
|
234
|
+
*
|
|
235
|
+
* Exported for the same reason {@link generatedIndexName} is: `db:pull` needs the same answer to
|
|
236
|
+
* decide whether a live name is worth declaring, and two copies of a naming rule is how these
|
|
237
|
+
* surfaces drift.
|
|
238
|
+
*/
|
|
239
|
+
export function generatedForeignKeyName(table: string, columns: string[], refTable: string, refColumns: string[]): string {
|
|
240
|
+
return `${table}_${columns.join('_')}_${refTable}_${refColumns.join('_')}_fk`;
|
|
241
|
+
}
|
|
242
|
+
|
|
194
243
|
/**
|
|
195
244
|
* The referenced table's name as the FK should carry it — schema-qualified for a non-public
|
|
196
245
|
* target (`auth.users`), bare for a public one (`uploads`). A cross-schema FK (auth → auth, or
|
|
@@ -284,8 +333,13 @@ function serialDefault(table: string, sqlName: string): string {
|
|
|
284
333
|
export function fieldColumnSql(sqlName: string, spec: FieldSpec, inlinePrimaryKey: boolean): string {
|
|
285
334
|
const serial = SERIAL_DDL[spec.type];
|
|
286
335
|
let s = `"${sqlName}" ${serial ?? pgType(spec)}`;
|
|
336
|
+
// An identity column's auto-value is the column's own property, not a default — the clause
|
|
337
|
+
// has to ride the CREATE, because there is no expression a later SET DEFAULT could carry.
|
|
338
|
+
if (spec.identity) s += ` ${identityClause(spec.identity)}`;
|
|
287
339
|
if (inlinePrimaryKey) s += ' PRIMARY KEY';
|
|
288
|
-
|
|
340
|
+
// Identity and DEFAULT are mutually exclusive in Postgres (`CREATE TABLE` refuses both), and
|
|
341
|
+
// identity is the one that carries the auto-value — so it wins and the default is dropped.
|
|
342
|
+
const def = serial || spec.identity ? null : defaultExpr(spec);
|
|
289
343
|
// A `defaultSql` nextval draws from an EXISTING sequence, possibly owned by a table
|
|
290
344
|
// created later in the same migration — held out of the column line (compileMigration
|
|
291
345
|
// re-attaches it as a trailing SET DEFAULT). Never the serial shorthand: that would
|
|
@@ -461,15 +515,22 @@ export function compileTableSchema(model: ModelDescriptor, opts: { schema?: stri
|
|
|
461
515
|
const columns = entries.map(([name, field]) => {
|
|
462
516
|
const sqlName = toSnakeCase(name);
|
|
463
517
|
const isSerial = field.spec.type in SERIAL_DDL;
|
|
518
|
+
const identity = field.spec.identity;
|
|
464
519
|
return {
|
|
465
520
|
name: sqlName,
|
|
466
521
|
type: pgType(field.spec),
|
|
467
|
-
|
|
522
|
+
// Postgres makes an identity column NOT NULL, exactly as it does a serial one — so the
|
|
523
|
+
// IR says so, or an unchanged identity column diffs to a spurious SET NOT NULL.
|
|
524
|
+
notNull: field.spec.isNotNull || isSerial || identity != null,
|
|
468
525
|
// The deparser qualifies the sequence only when its schema is off the search_path,
|
|
469
526
|
// so a public table's sequence stays bare and a package-schema one is qualified.
|
|
527
|
+
// An identity column has NO pg_attrdef row, so its IR default is null on both sides.
|
|
470
528
|
default: isSerial
|
|
471
529
|
? serialDefault(schema === 'public' ? model.table : `${schema}.${model.table}`, sqlName)
|
|
472
|
-
:
|
|
530
|
+
: identity
|
|
531
|
+
? null
|
|
532
|
+
: defaultExpr(field.spec),
|
|
533
|
+
...(identity ? { identity } : {}),
|
|
473
534
|
};
|
|
474
535
|
});
|
|
475
536
|
|
|
@@ -486,7 +547,9 @@ export function compileTableSchema(model: ModelDescriptor, opts: { schema?: stri
|
|
|
486
547
|
const fieldChecks = entries.flatMap(([name, field]) => {
|
|
487
548
|
const col = toSnakeCase(name);
|
|
488
549
|
const pred = fieldCheckPredicate(col, field.spec);
|
|
489
|
-
|
|
550
|
+
// Through the same funnel as every other predicate: a `z.enum([...])` compiles to an
|
|
551
|
+
// IN-list, which is exactly the shape whose deparse is unstable across a dump/restore.
|
|
552
|
+
return pred ? [{ name: `${model.table}_${col}_check`, expr: normalizeDeparsedExpr(pred) }] : [];
|
|
490
553
|
});
|
|
491
554
|
const checks = [...fieldChecks, ...tableConstraints.checks];
|
|
492
555
|
|
package/src/cli/schema-diff.ts
CHANGED
|
@@ -41,7 +41,7 @@ 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 };
|
|
44
|
+
| { type: 'foreignKey'; name: string; columns: string[]; refTable: string; refColumns: string[]; onDelete?: string; onUpdate?: string; nameDeclared?: boolean };
|
|
45
45
|
|
|
46
46
|
export type SchemaChange =
|
|
47
47
|
| { kind: 'createTable'; table: string; schema: TableSchema }
|
|
@@ -51,6 +51,16 @@ export type SchemaChange =
|
|
|
51
51
|
| { kind: 'setNotNull'; table: string; column: string; notNull: boolean }
|
|
52
52
|
/** `expr: null` means DROP DEFAULT. */
|
|
53
53
|
| { kind: 'setDefault'; table: string; column: string; expr: string | null }
|
|
54
|
+
/**
|
|
55
|
+
* `GENERATED … AS IDENTITY` added, changed, or removed. `identity: null` means DROP IDENTITY,
|
|
56
|
+
* which destroys the backing sequence's counter — so it is gated with the other removals.
|
|
57
|
+
*
|
|
58
|
+
* `from` is the live side, and it is load-bearing rather than informational: Postgres has
|
|
59
|
+
* ADD GENERATED (which refuses a column that already has one) and SET GENERATED (which
|
|
60
|
+
* refuses a column that does not), with no idempotent spelling of either. The emitter picks
|
|
61
|
+
* from `from`, so it can only ever emit the one statement that applies.
|
|
62
|
+
*/
|
|
63
|
+
| { kind: 'setIdentity'; table: string; column: string; identity: 'always' | 'byDefault' | null; from: 'always' | 'byDefault' | null }
|
|
54
64
|
/** A type change → real `ALTER COLUMN ... TYPE`. `risk` decides USING + a data-loss warning. */
|
|
55
65
|
| { kind: 'alterType'; table: string; column: string; from: string; to: string; risk: TypeChangeRisk }
|
|
56
66
|
| { kind: 'addConstraint'; table: string; constraint: ConstraintSpec }
|
|
@@ -90,6 +100,17 @@ export type SchemaChange =
|
|
|
90
100
|
| { kind: 'createIndex'; table: string; name: string; columns: string[]; unique: boolean; where?: string; using?: string; include?: string[] }
|
|
91
101
|
/** A standalone index present in the DB but no longer declared → `DROP INDEX` (a removal — gated). */
|
|
92
102
|
| { kind: 'dropIndex'; table: string; name: string }
|
|
103
|
+
/** A content-identical index the model names differently → `ALTER INDEX … RENAME TO` (B3). */
|
|
104
|
+
| { kind: 'renameIndex'; table: string; from: string; to: string }
|
|
105
|
+
/** A content-identical FK the model names differently → `ALTER TABLE … RENAME CONSTRAINT` (A8). */
|
|
106
|
+
| { kind: 'renameConstraint'; table: string; from: string; to: string }
|
|
107
|
+
/**
|
|
108
|
+
* A live index REDEFINED under the same name (predicate/method/keys changed) → `DROP INDEX`
|
|
109
|
+
* ordered BEFORE its create. Distinct from `dropIndex`, which is a removal: this one loses
|
|
110
|
+
* nothing, the create immediately follows, and gating it behind `--allow-drops` would hold
|
|
111
|
+
* back half of a pair that can only fail apart.
|
|
112
|
+
*/
|
|
113
|
+
| { kind: 'redefineIndex'; table: string; name: string }
|
|
93
114
|
/** A declared standalone sequence missing live → `CREATE SEQUENCE`, before any table. */
|
|
94
115
|
| { kind: 'createSequence'; sequence: SequenceSchema }
|
|
95
116
|
/** A declared sequence whose live properties differ — a NOTICE, never a silent ALTER (it holds state). */
|
|
@@ -340,6 +361,10 @@ export function diffSchema(desired: SchemaSnapshot, current: SchemaSnapshot, ren
|
|
|
340
361
|
const dropConstraints: SchemaChange[] = [];
|
|
341
362
|
const addConstraints: SchemaChange[] = [];
|
|
342
363
|
const createIndexes: SchemaChange[] = [];
|
|
364
|
+
/** Catalog-only renames — indexes (B3) and FK constraints (A8). */
|
|
365
|
+
const catalogRenames: SchemaChange[] = [];
|
|
366
|
+
/** Same-name index redefinitions — their DROP must precede the CREATE that replaces it. */
|
|
367
|
+
const redefineIndexes: SchemaChange[] = [];
|
|
343
368
|
const dropIndexes: SchemaChange[] = [];
|
|
344
369
|
const dropColumns: SchemaChange[] = [];
|
|
345
370
|
const dropTables: SchemaChange[] = [];
|
|
@@ -441,8 +466,8 @@ export function diffSchema(desired: SchemaSnapshot, current: SchemaSnapshot, ren
|
|
|
441
466
|
alters.push(...cd.alters);
|
|
442
467
|
dropColumns.push(...cd.dropColumns);
|
|
443
468
|
notices.push(...cd.notices);
|
|
444
|
-
diffConstraints(d, c, dropConstraints, addConstraints, alters);
|
|
445
|
-
diffIndexes(d, c, createIndexes, dropIndexes);
|
|
469
|
+
diffConstraints(d, c, dropConstraints, addConstraints, alters, catalogRenames);
|
|
470
|
+
diffIndexes(d, c, createIndexes, dropIndexes, catalogRenames, redefineIndexes);
|
|
446
471
|
}
|
|
447
472
|
|
|
448
473
|
// Sweep the POST-RENAME view: a renamed-away old name is not a drop.
|
|
@@ -497,7 +522,11 @@ export function diffSchema(desired: SchemaSnapshot, current: SchemaSnapshot, ren
|
|
|
497
522
|
...moveTables,
|
|
498
523
|
...renameTables,
|
|
499
524
|
...creates, ...renameColumns, ...addColumns, ...alters, ...trailingDefaults,
|
|
500
|
-
|
|
525
|
+
// Renames are catalog-only and precede the creates, so a declared name freed by renaming an
|
|
526
|
+
// existing object is never claimed by a fresh CREATE first.
|
|
527
|
+
// redefineIndexes immediately precede createIndexes: PostgreSQL has no CREATE OR REPLACE
|
|
528
|
+
// INDEX, so the old one must be gone before its replacement claims the name.
|
|
529
|
+
...catalogRenames, ...dropConstraints, ...addConstraints, ...redefineIndexes, ...createIndexes,
|
|
501
530
|
...dropColumns, ...dropIndexes, ...dropTables,
|
|
502
531
|
...e.drops, ...notices, ...e.notices, ...sequenceNotices,
|
|
503
532
|
];
|
|
@@ -526,18 +555,52 @@ export function indexKey(ix: IndexSchema): string {
|
|
|
526
555
|
}
|
|
527
556
|
|
|
528
557
|
/**
|
|
529
|
-
* Diff standalone indexes by
|
|
530
|
-
*
|
|
531
|
-
*
|
|
558
|
+
* Diff standalone indexes by CONTENT — an index with the same key entries, uniqueness, predicate,
|
|
559
|
+
* method and INCLUDE is the same index whatever it is called, so a pulled index re-generates
|
|
560
|
+
* clean rather than churning drop/create.
|
|
561
|
+
*
|
|
562
|
+
* B3: matching by content is right, being BLIND to the name was not. A content match that
|
|
563
|
+
* disagrees on the name is the SAME index under a different name, which is exactly an
|
|
564
|
+
* `ALTER INDEX … RENAME TO` — cheap, non-destructive, and it converges in one apply. Previously
|
|
565
|
+
* the difference produced no statement at all, so a rebuild silently renamed ~200 of one
|
|
566
|
+
* consumer's indexes and every gate stayed green: counts matched, definitions matched, and
|
|
567
|
+
* nothing compared the names.
|
|
568
|
+
*
|
|
569
|
+
* A rename is emitted only when the DECLARED name is deliberate. A model that declares no name
|
|
570
|
+
* gets the generated fallback, and renaming a database to match a name nobody chose would be
|
|
571
|
+
* churn — worse, it would fight any adopted database forever.
|
|
532
572
|
*/
|
|
533
|
-
function diffIndexes(
|
|
573
|
+
function diffIndexes(
|
|
574
|
+
d: TableSchema,
|
|
575
|
+
c: TableSchema,
|
|
576
|
+
creates: SchemaChange[],
|
|
577
|
+
drops: SchemaChange[],
|
|
578
|
+
renames: SchemaChange[] = [],
|
|
579
|
+
redefines: SchemaChange[] = [],
|
|
580
|
+
): void {
|
|
534
581
|
const desiredByKey = new Map(d.indexes.map((ix) => [indexKey(ix), ix]));
|
|
535
582
|
const currentByKey = new Map(c.indexes.map((ix) => [indexKey(ix), ix]));
|
|
583
|
+
// A same-NAME index whose CONTENT changed is a redefinition, not a removal, and PostgreSQL has
|
|
584
|
+
// no CREATE OR REPLACE INDEX — it must drop first. Reported by GridironDB: a full unique index
|
|
585
|
+
// became two partial ones, the new partial index derived the SAME generated name as the live
|
|
586
|
+
// one, and the emitted order was CREATE then DROP, so the create failed with "already exists".
|
|
587
|
+
// Worse without `--allow-drops`: the drop was held back as a comment and the create was emitted
|
|
588
|
+
// anyway, a pair that can only fail. Constraints already got this right (dropConstraints
|
|
589
|
+
// precede addConstraints); indexes did not, and that inconsistency was the bug.
|
|
590
|
+
const desiredNames = new Set([...desiredByKey.values()].filter((ix) => !currentByKey.has(indexKey(ix))).map((ix) => ix.name));
|
|
536
591
|
for (const [k, ix] of currentByKey) {
|
|
537
|
-
if (
|
|
592
|
+
if (desiredByKey.has(k)) continue;
|
|
593
|
+
// Redefinition: something DESIRED wants this exact name and does not exist yet.
|
|
594
|
+
if (desiredNames.has(ix.name)) redefines.push({ kind: 'redefineIndex', table: d.table, name: ix.name });
|
|
595
|
+
else drops.push({ kind: 'dropIndex', table: d.table, name: ix.name });
|
|
538
596
|
}
|
|
539
597
|
for (const [k, ix] of desiredByKey) {
|
|
540
|
-
|
|
598
|
+
const live = currentByKey.get(k);
|
|
599
|
+
if (!live) {
|
|
600
|
+
creates.push(createIndexChange(d.table, ix));
|
|
601
|
+
} else if (ix.nameDeclared && live.name !== ix.name) {
|
|
602
|
+
renames.push({ kind: 'renameIndex', table: d.table, from: live.name, to: ix.name });
|
|
603
|
+
}
|
|
541
604
|
}
|
|
542
605
|
}
|
|
543
606
|
|
|
@@ -597,6 +660,11 @@ function columnAlters(table: string, desired: ColumnSchema, source: ColumnSchema
|
|
|
597
660
|
if (desired.type !== source.type) into.push({ kind: 'alterType', table, column: desired.name, from: source.type, to: desired.type, risk: classifyTypeChange(source.type, desired.type) });
|
|
598
661
|
if (desired.notNull !== source.notNull) into.push({ kind: 'setNotNull', table, column: desired.name, notNull: desired.notNull });
|
|
599
662
|
if (normalizeDefault(desired.default) !== normalizeDefault(source.default)) into.push({ kind: 'setDefault', table, column: desired.name, expr: desired.default });
|
|
663
|
+
// Emitted AFTER setDefault deliberately: Postgres refuses ADD GENERATED on a column that
|
|
664
|
+
// still holds a default, which is exactly the shape of a serial → identity migration.
|
|
665
|
+
if ((desired.identity ?? null) !== (source.identity ?? null)) {
|
|
666
|
+
into.push({ kind: 'setIdentity', table, column: desired.name, identity: desired.identity ?? null, from: source.identity ?? null });
|
|
667
|
+
}
|
|
600
668
|
}
|
|
601
669
|
|
|
602
670
|
function diffColumns(d: TableSchema, c: TableSchema, tableRenames: Record<string, string>): ColumnDiff {
|
|
@@ -655,7 +723,7 @@ function diffColumns(d: TableSchema, c: TableSchema, tableRenames: Record<string
|
|
|
655
723
|
function tableConstraintSpecs(t: TableSchema): ConstraintSpec[] {
|
|
656
724
|
return [
|
|
657
725
|
...t.uniques.map((u): ConstraintSpec => ({ type: 'unique', name: u.name, columns: u.columns })),
|
|
658
|
-
...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 })),
|
|
726
|
+
...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 } : {}) })),
|
|
659
727
|
];
|
|
660
728
|
}
|
|
661
729
|
|
|
@@ -689,7 +757,7 @@ function diffChecks(d: TableSchema, c: TableSchema, dropConstraints: SchemaChang
|
|
|
689
757
|
}
|
|
690
758
|
}
|
|
691
759
|
|
|
692
|
-
function diffConstraints(d: TableSchema, c: TableSchema, dropConstraints: SchemaChange[], addConstraints: SchemaChange[], alters: SchemaChange[]): void {
|
|
760
|
+
function diffConstraints(d: TableSchema, c: TableSchema, dropConstraints: SchemaChange[], addConstraints: SchemaChange[], alters: SchemaChange[], renames: SchemaChange[] = []): void {
|
|
693
761
|
// Multiset-match uniques and FKs by content: a desired constraint that finds a
|
|
694
762
|
// same-content current one is unchanged (whatever either is named); an unmatched
|
|
695
763
|
// desired is an add; a leftover current is dropped by its real name. A *changed*
|
|
@@ -703,8 +771,19 @@ function diffConstraints(d: TableSchema, c: TableSchema, dropConstraints: Schema
|
|
|
703
771
|
}
|
|
704
772
|
for (const des of tableConstraintSpecs(d)) {
|
|
705
773
|
const bucket = currentByKey.get(constraintContentKey(des));
|
|
706
|
-
if (bucket?.length)
|
|
707
|
-
|
|
774
|
+
if (bucket?.length) {
|
|
775
|
+
// A8, the FK half of B3: matching by content is right, being BLIND to the name was not.
|
|
776
|
+
// A content match whose DECLARED name disagrees is the same constraint under a different
|
|
777
|
+
// name — an ALTER TABLE … RENAME CONSTRAINT, not silence. Measured: a live
|
|
778
|
+
// `fk_rails_9a1b2c3d4e` against a compiled `kids_parent_id_parents_id_fk` produced ZERO
|
|
779
|
+
// changes, so a rebuild renamed every FK and no gate noticed.
|
|
780
|
+
const matched = bucket.pop()!;
|
|
781
|
+
if (des.type === 'foreignKey' && des.nameDeclared && matched.name !== des.name) {
|
|
782
|
+
renames.push({ kind: 'renameConstraint', table: d.table, from: matched.name, to: des.name });
|
|
783
|
+
}
|
|
784
|
+
} else {
|
|
785
|
+
addConstraints.push({ kind: 'addConstraint', table: d.table, constraint: des });
|
|
786
|
+
}
|
|
708
787
|
}
|
|
709
788
|
for (const bucket of currentByKey.values()) {
|
|
710
789
|
for (const cur of bucket) dropConstraints.push({ kind: 'dropConstraint', table: d.table, name: cur.name });
|
|
@@ -737,6 +816,15 @@ const enumLiteral = (value: string): string => `'${value.replace(/'/g, "''")}'`;
|
|
|
737
816
|
* column), or null. CREATE/ADD COLUMN must use the shorthand — it creates the backing
|
|
738
817
|
* sequence, which a bare `DEFAULT nextval(...)` would reference before it exists.
|
|
739
818
|
*/
|
|
819
|
+
/**
|
|
820
|
+
* The `GENERATED … AS IDENTITY` clause text. ONE definition, read by this emitter and by the
|
|
821
|
+
* greenfield compiler (`fieldColumnSql`) — two copies of a clause spelling is how the CREATE
|
|
822
|
+
* and the ALTER paths drift into producing different columns from the same declaration.
|
|
823
|
+
*/
|
|
824
|
+
export function identityClause(kind: 'always' | 'byDefault'): string {
|
|
825
|
+
return `GENERATED ${kind === 'always' ? 'ALWAYS' : 'BY DEFAULT'} AS IDENTITY`;
|
|
826
|
+
}
|
|
827
|
+
|
|
740
828
|
function serialSpelling(col: ColumnSchema, table: string): string | null {
|
|
741
829
|
// ONLY the column's own conventional sequence is a serial declaration — the shorthand
|
|
742
830
|
// creates a sequence, so spelling it for a foreign `nextval` would mint a duplicate
|
|
@@ -752,6 +840,9 @@ function serialSpelling(col: ColumnSchema, table: string): string | null {
|
|
|
752
840
|
function columnSql(col: ColumnSchema, table: string): string {
|
|
753
841
|
const serial = serialSpelling(col, table);
|
|
754
842
|
let s = `${quote(col.name)} ${serial ?? col.type}`;
|
|
843
|
+
// The auto-value is the column's own property, not a default — it rides the CREATE/ADD
|
|
844
|
+
// COLUMN or the rebuilt column has none at all, and every INSERT that omits it fails.
|
|
845
|
+
if (col.identity) s += ` ${identityClause(col.identity)}`;
|
|
755
846
|
// A foreign-sequence default is held out — diffSchema appends the trailing SET DEFAULT
|
|
756
847
|
// (after every create), so the CREATE never references a sequence that doesn't exist yet.
|
|
757
848
|
if (!serial && col.default != null && !isForeignSequenceDefault(table, col)) s += ` DEFAULT ${col.default}`;
|
|
@@ -822,6 +913,20 @@ function emitOne(change: SchemaChange, opts: EmitOptions): string {
|
|
|
822
913
|
return change.expr == null
|
|
823
914
|
? `ALTER TABLE ${change.table} ALTER COLUMN ${quote(change.column)} DROP DEFAULT;`
|
|
824
915
|
: `ALTER TABLE ${change.table} ALTER COLUMN ${quote(change.column)} SET DEFAULT ${change.expr};`;
|
|
916
|
+
case 'setIdentity': {
|
|
917
|
+
const head = `ALTER TABLE ${change.table} ALTER COLUMN ${quote(change.column)}`;
|
|
918
|
+
// DROP IDENTITY takes the backing sequence with it, and a sequence's counter is state no
|
|
919
|
+
// re-run recovers — so it warns and is held with the other removals unless --allow-drops.
|
|
920
|
+
if (change.identity == null) {
|
|
921
|
+
return `-- WARNING: DROP IDENTITY on ${quote(change.column)} (${change.table}) DROPS the backing sequence and its counter; the column keeps its rows but gains no auto-value.\n${head} DROP IDENTITY;`;
|
|
922
|
+
}
|
|
923
|
+
// A column that already has an identity takes SET GENERATED (ADD would refuse it); one
|
|
924
|
+
// that has none takes ADD GENERATED (SET would refuse it). Neither has an IF-NOT-EXISTS
|
|
925
|
+
// spelling, so the live side decides — that is what `from` is carried for.
|
|
926
|
+
return change.from == null
|
|
927
|
+
? `${head} ADD ${identityClause(change.identity)};`
|
|
928
|
+
: `${head} SET GENERATED ${change.identity === 'always' ? 'ALWAYS' : 'BY DEFAULT'};`;
|
|
929
|
+
}
|
|
825
930
|
case 'alterType': {
|
|
826
931
|
const setType = `ALTER TABLE ${change.table} ALTER COLUMN ${quote(change.column)} SET DATA TYPE ${change.to}`;
|
|
827
932
|
if (change.risk === 'safe') return `${setType};`;
|
|
@@ -884,6 +989,20 @@ function emitOne(change: SchemaChange, opts: EmitOptions): string {
|
|
|
884
989
|
const schema = change.table.includes('.') ? `${change.table.split('.')[0]}.` : '';
|
|
885
990
|
return `DROP INDEX ${schema}${quote(change.name)};`;
|
|
886
991
|
}
|
|
992
|
+
case 'renameConstraint':
|
|
993
|
+
// Catalog-only and instant — the constraint and its backing index keep their identity.
|
|
994
|
+
return `ALTER TABLE ${change.table} RENAME CONSTRAINT ${quote(change.from)} TO ${quote(change.to)};`;
|
|
995
|
+
case 'redefineIndex': {
|
|
996
|
+
// Same statement as a removal; a DIFFERENT decision, which is why it is a different kind.
|
|
997
|
+
const schema = change.table.includes('.') ? `${change.table.split('.')[0]}.` : '';
|
|
998
|
+
return `DROP INDEX ${schema}${quote(change.name)};`;
|
|
999
|
+
}
|
|
1000
|
+
case 'renameIndex': {
|
|
1001
|
+
// Non-destructive and instant — a catalog rename, no rebuild. The schema qualifier comes
|
|
1002
|
+
// from the table; ALTER INDEX takes it on the OLD name only.
|
|
1003
|
+
const schema = change.table.includes('.') ? `${change.table.split('.')[0]}.` : '';
|
|
1004
|
+
return `ALTER INDEX ${schema}${quote(change.from)} RENAME TO ${quote(change.to)};`;
|
|
1005
|
+
}
|
|
887
1006
|
case 'enumValuesChanged':
|
|
888
1007
|
return `-- NOTICE: enum ${quote(change.name)} values changed ([${change.from.join(', ')}] → [${change.to.join(', ')}]) in a way Postgres can't apply in place (a removal, reorder, or rename). ADD VALUE only appends; reshaping an enum needs a manual type swap (create new type, ALTER COLUMN ... TYPE using a cast, drop old).`;
|
|
889
1008
|
case 'createSequence':
|
|
@@ -26,7 +26,9 @@
|
|
|
26
26
|
* — it is not a database fact.
|
|
27
27
|
* - FUNCTIONS are excluded — they belong to the derived layer, whose own
|
|
28
28
|
* content hashes (derived-introspect) cover them. Base fingerprint + derived
|
|
29
|
-
* manifest together are the full schema state.
|
|
29
|
+
* manifest together are the full schema state. One caveat the report carries:
|
|
30
|
+
* a function's OWNER is in the derived hash only when the model DECLARES it,
|
|
31
|
+
* so an undeclared owner is covered by neither and is named as unfingerprinted.
|
|
30
32
|
* - Expressions (column defaults, check predicates, partial-index WHERE) hash
|
|
31
33
|
* through the SAME normalizers the diff uses (`normalizeDefault` /
|
|
32
34
|
* `normalizeCheck`), applied to both sides in `canonicalizeState`. Without
|
|
@@ -41,8 +43,10 @@
|
|
|
41
43
|
* Coverage is partial and SAYS SO: `UNFINGERPRINTED_SQL` enumerates what the
|
|
42
44
|
* base fingerprint does not see (triggers — drift-checked separately by the
|
|
43
45
|
* reconciler's provenance — domains, composite types, foreign/partitioned
|
|
44
|
-
* tables, extensions
|
|
46
|
+
* tables, extensions, SEQUENCE ACLs, and the OWNER of a SECURITY DEFINER
|
|
47
|
+
* function that declares none) so partial coverage reads as a report, never as
|
|
45
48
|
* "covered everything". Standalone sequences left this list in v3: covered.
|
|
49
|
+
* Sequence ACLs and the schema posture are scheduled to leave it at v11.
|
|
46
50
|
*/
|
|
47
51
|
|
|
48
52
|
import { createHash } from 'node:crypto';
|
|
@@ -110,7 +114,7 @@ import { normalizeDefault, normalizeCheck, indexKey } from './schema-diff.js';
|
|
|
110
114
|
// it from the canonical form AND the reconciler leaves it alone, before and after. Restrictive
|
|
111
115
|
// policies are never subsumed (they AND, so removing one would WIDEN), and the rule must match
|
|
112
116
|
// exactly including the effective WITH CHECK.
|
|
113
|
-
export const FINGERPRINT_VERSION =
|
|
117
|
+
export const FINGERPRINT_VERSION = 10;
|
|
114
118
|
|
|
115
119
|
// ---------------------------------------------------------------------------
|
|
116
120
|
// Canonical form.
|
|
@@ -326,9 +330,12 @@ export function fingerprintLive(
|
|
|
326
330
|
|
|
327
331
|
/**
|
|
328
332
|
* Objects outside both the base fingerprint (this module) and the derived
|
|
329
|
-
* manifest (derived-introspect): standalone sequences
|
|
330
|
-
*
|
|
331
|
-
*
|
|
333
|
+
* manifest (derived-introspect): standalone sequences, triggers, domains, composite
|
|
334
|
+
* types, foreign tables, partitioned tables, installed extensions — sequence
|
|
335
|
+
* ACLs, whose SHAPE is fingerprinted (a serial sequence rides its column default)
|
|
336
|
+
* while the PRIVILEGES on it are not — and identity columns, which the models can
|
|
337
|
+
* now say and the builder now builds, but which the hash does not yet cover.
|
|
338
|
+
* Both of those last two enter the hash at v11.
|
|
332
339
|
*/
|
|
333
340
|
export const UNFINGERPRINTED_SQL = `
|
|
334
341
|
SELECT 'trigger' AS kind, n.nspname || '.' || c.relname || '.' || t.tgname AS identity
|
|
@@ -354,6 +361,52 @@ JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
|
354
361
|
WHERE c.relkind IN ('f', 'p')
|
|
355
362
|
AND n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg_%'
|
|
356
363
|
UNION ALL
|
|
364
|
+
-- A6/B4, the interim before v11. A serial/identity sequence's SHAPE is fingerprinted (it rides
|
|
365
|
+
-- the column default), but its ACL is not — and that ACL is load-bearing: a role that may INSERT
|
|
366
|
+
-- into the table and holds nothing on the sequence fails every INSERT with "permission denied for
|
|
367
|
+
-- sequence". Only sequences that actually CARRY an ACL are named; an ungranted one has nothing
|
|
368
|
+
-- unfingerprinted to say. Enters the hash at v11, batched with the schema posture.
|
|
369
|
+
SELECT 'sequence ACL', n.nspname || '.' || c.relname
|
|
370
|
+
FROM pg_class c
|
|
371
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
372
|
+
WHERE c.relkind = 'S' AND c.relacl IS NOT NULL
|
|
373
|
+
AND n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg_%'
|
|
374
|
+
AND NOT EXISTS (SELECT 1 FROM pg_depend d WHERE d.objid = c.oid AND d.deptype = 'e')
|
|
375
|
+
UNION ALL
|
|
376
|
+
-- B5. A SECURITY DEFINER function executes with its OWNER's rights, so ownership is a privilege
|
|
377
|
+
-- boundary. It is hashed ONLY WHEN DECLARED (derived-source's hashSourceContent prefixes the
|
|
378
|
+
-- owner, so an object without one hashes byte-identically to before the feature existed), and
|
|
379
|
+
-- the base fingerprint never sees functions at all. An UNDECLARED owner is therefore covered by
|
|
380
|
+
-- nothing: it can change and every gate stays green. Named here so partial coverage is visible.
|
|
381
|
+
--
|
|
382
|
+
-- The identity carries the owner so the report SAYS who it is — "unfingerprinted" without the
|
|
383
|
+
-- value is the shape of finding that gets skimmed past. SQL cannot know which functions declare
|
|
384
|
+
-- an owner, so the caller filters the declared ones out (see computeFingerprintStatus).
|
|
385
|
+
SELECT 'function owner', n.nspname || '.' || p.proname || ' → ' || pg_get_userbyid(p.proowner)
|
|
386
|
+
FROM pg_proc p
|
|
387
|
+
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
388
|
+
WHERE p.prosecdef
|
|
389
|
+
AND n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg_%'
|
|
390
|
+
AND NOT EXISTS (SELECT 1 FROM pg_depend d WHERE d.objid = p.oid AND d.deptype = 'e')
|
|
391
|
+
UNION ALL
|
|
392
|
+
-- A14, the interim before v11. A column's GENERATED … AS IDENTITY is now READ, DECLARED and
|
|
393
|
+
-- BUILT, but it is not yet hashed: the fingerprint's coverage is a version contract, and a
|
|
394
|
+
-- property entering the hash inside a version makes two operators on different CLI builds
|
|
395
|
+
-- disagree about the same database. So it is named here until v11 carries it, batched with the
|
|
396
|
+
-- sequence ACLs and the schema posture. The gap is real and narrow: an identity that is silently
|
|
397
|
+
-- dropped or flipped ALWAYS to BY DEFAULT leaves every fingerprint gate green. db:generate sees
|
|
398
|
+
-- it (the differ compares it), and the round-trip oracle's pg_dump comparison catches it.
|
|
399
|
+
SELECT 'identity column',
|
|
400
|
+
n.nspname || '.' || c.relname || '.' || a.attname ||
|
|
401
|
+
' → ' || CASE a.attidentity WHEN 'a' THEN 'always' ELSE 'by default' END
|
|
402
|
+
FROM pg_attribute a
|
|
403
|
+
JOIN pg_class c ON c.oid = a.attrelid
|
|
404
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
405
|
+
WHERE a.attidentity IN ('a', 'd')
|
|
406
|
+
AND a.attnum > 0 AND NOT a.attisdropped
|
|
407
|
+
AND n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg_%'
|
|
408
|
+
AND NOT EXISTS (SELECT 1 FROM pg_depend d WHERE d.objid = c.oid AND d.deptype = 'e')
|
|
409
|
+
UNION ALL
|
|
357
410
|
SELECT 'extension', e.extname
|
|
358
411
|
FROM pg_extension e
|
|
359
412
|
WHERE e.extname <> 'plpgsql'
|