@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.
@@ -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
- checks.push({ name: `${model.table}_check_${checkIndex++}`, expr: con.predicate });
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
- const nameParts = cols.map((c) => c.replace(/\W+/g, '_').replace(/^_+|_+$/g, ''));
60
- let name = `${model.table}_${nameParts.join('_')}_index`;
61
- for (let n = 2; usedIndexNames.has(name); n++) name = `${model.table}_${nameParts.join('_')}_index_${n}`;
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: `${model.table}_${col}_${target.table}_${targetPk}_fk`,
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: `${model.table}_${cols.join('_')}_${target.table}_${refCols.join('_')}_fk`,
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
- const def = serial ? null : defaultExpr(spec);
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
- notNull: field.spec.isNotNull || isSerial,
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
- : defaultExpr(field.spec),
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
- return pred ? [{ name: `${model.table}_${col}_check`, expr: pred }] : [];
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
 
@@ -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
- ...dropConstraints, ...addConstraints, ...createIndexes,
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 content, not name: an index with the same columns, uniqueness, and
530
- * partial predicate is the same index whatever it's called, so a pulled index (whose DB name we
531
- * didn't choose) re-generates clean. A dropped index is a removal, gated like the others.
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(d: TableSchema, c: TableSchema, creates: SchemaChange[], drops: SchemaChange[]): void {
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 (!desiredByKey.has(k)) drops.push({ kind: 'dropIndex', table: d.table, name: ix.name });
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
- if (!currentByKey.has(k)) creates.push(createIndexChange(d.table, ix));
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) bucket.pop();
707
- else addConstraints.push({ kind: 'addConstraint', table: d.table, constraint: des });
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) so partial coverage reads as a report, never as
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 = 9;
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 (serial-owned ones are
330
- * column defaults, already fingerprinted), triggers, domains, composite
331
- * types, foreign tables, partitioned tables, and installed extensions.
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'