@everystack/cli 0.4.55 → 0.4.57

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,7 @@
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
25
  import { normalizeDeparsedExpr } from './deparse-normal.js';
26
26
 
27
27
  /** `authorId` -> `author_id`. SQL identifiers are snake_case. */
@@ -36,6 +36,23 @@ export function toSnakeCase(name: string): string {
36
36
  * two never disagree. Field keys are snake_cased to SQL columns; names are deterministic
37
37
  * (a composite unique by its columns, a check by its position) so they round-trip.
38
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
+
39
56
  export function modelConstraintSpecs(model: ModelDescriptor): { uniques: UniqueConstraint[]; checks: CheckConstraint[]; indexes: IndexSchema[] } {
40
57
  const uniques: UniqueConstraint[] = [];
41
58
  const checks: CheckConstraint[] = [];
@@ -60,12 +77,23 @@ export function modelConstraintSpecs(model: ModelDescriptor): { uniques: UniqueC
60
77
  // unique within the table for CREATE to succeed. The diff matches indexes by content
61
78
  // (entries + uniqueness + predicate + method + INCLUDE), never by name, so the suffix
62
79
  // never affects the round-trip. Raw entries sanitize to identifier-safe name parts.
63
- const nameParts = cols.map((c) => c.replace(/\W+/g, '_').replace(/^_+|_+$/g, ''));
64
- let name = `${model.table}_${nameParts.join('_')}_index`;
65
- 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
+ }
66
93
  usedIndexNames.add(name);
67
94
  indexes.push({
68
95
  name,
96
+ ...(con.indexName !== undefined ? { nameDeclared: true } : {}),
69
97
  columns: cols,
70
98
  unique: con.isUnique,
71
99
  ...(con.predicate ? { where: normalizeDeparsedExpr(con.predicate) } : {}),
@@ -170,7 +198,11 @@ export function modelForeignKeys(model: ModelDescriptor): ForeignKey[] {
170
198
  const col = toSnakeCase(name);
171
199
  const targetPk = toSnakeCase(target.primaryKey[0] ?? 'id');
172
200
  out.push({
173
- 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 } : {}),
174
206
  columns: [col],
175
207
  refTable: refTableName(target),
176
208
  refColumns: [targetPk],
@@ -184,7 +216,8 @@ export function modelForeignKeys(model: ModelDescriptor): ForeignKey[] {
184
216
  const cols = con.columns.map(toSnakeCase);
185
217
  const refCols = con.refColumns.map(toSnakeCase);
186
218
  out.push({
187
- 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 } : {}),
188
221
  columns: cols,
189
222
  refTable: refTableName(target),
190
223
  refColumns: refCols,
@@ -195,6 +228,18 @@ export function modelForeignKeys(model: ModelDescriptor): ForeignKey[] {
195
228
  return out;
196
229
  }
197
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
+
198
243
  /**
199
244
  * The referenced table's name as the FK should carry it — schema-qualified for a non-public
200
245
  * target (`auth.users`), bare for a public one (`uploads`). A cross-schema FK (auth → auth, or
@@ -288,8 +333,13 @@ function serialDefault(table: string, sqlName: string): string {
288
333
  export function fieldColumnSql(sqlName: string, spec: FieldSpec, inlinePrimaryKey: boolean): string {
289
334
  const serial = SERIAL_DDL[spec.type];
290
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)}`;
291
339
  if (inlinePrimaryKey) s += ' PRIMARY KEY';
292
- 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);
293
343
  // A `defaultSql` nextval draws from an EXISTING sequence, possibly owned by a table
294
344
  // created later in the same migration — held out of the column line (compileMigration
295
345
  // re-attaches it as a trailing SET DEFAULT). Never the serial shorthand: that would
@@ -465,15 +515,22 @@ export function compileTableSchema(model: ModelDescriptor, opts: { schema?: stri
465
515
  const columns = entries.map(([name, field]) => {
466
516
  const sqlName = toSnakeCase(name);
467
517
  const isSerial = field.spec.type in SERIAL_DDL;
518
+ const identity = field.spec.identity;
468
519
  return {
469
520
  name: sqlName,
470
521
  type: pgType(field.spec),
471
- 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,
472
525
  // The deparser qualifies the sequence only when its schema is off the search_path,
473
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.
474
528
  default: isSerial
475
529
  ? serialDefault(schema === 'public' ? model.table : `${schema}.${model.table}`, sqlName)
476
- : defaultExpr(field.spec),
530
+ : identity
531
+ ? null
532
+ : defaultExpr(field.spec),
533
+ ...(identity ? { identity } : {}),
477
534
  };
478
535
  });
479
536
 
@@ -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';
@@ -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'
@@ -29,10 +29,23 @@ export interface ColumnSchema {
29
29
  notNull: boolean;
30
30
  /** The DEFAULT expression (deparsed), or null. e.g. `now()`, `gen_random_uuid()`, `'draft'::text`. */
31
31
  default: string | null;
32
+ /**
33
+ * `GENERATED ALWAYS AS IDENTITY` / `GENERATED BY DEFAULT AS IDENTITY`, absent on a plain column.
34
+ *
35
+ * An identity column's auto-value lives in `pg_attribute.attidentity`, NOT in a default — so a
36
+ * read that only collects `pg_attrdef` sees a bare `bigint` and a rebuild produces a column with
37
+ * no auto-value at all. Every INSERT that omits the column then fails on NOT NULL. Measured on a
38
+ * consumer's `refresh_tokens.id`: token issuance died on a database built from the models.
39
+ *
40
+ * Identity implies NOT NULL in Postgres, so `notNull` is always true alongside it.
41
+ */
42
+ identity?: 'always' | 'byDefault';
32
43
  }
33
44
 
34
45
  export interface ForeignKey {
35
46
  name: string;
47
+ /** The name was DECLARED, not generated — see {@link IndexSchema.nameDeclared}, same rule. */
48
+ nameDeclared?: boolean;
36
49
  columns: string[];
37
50
  refTable: string;
38
51
  refColumns: string[];
@@ -55,6 +68,16 @@ export interface CheckConstraint {
55
68
  /** A standalone index (NOT one backing a PK or UNIQUE constraint — those are excluded). */
56
69
  export interface IndexSchema {
57
70
  name: string;
71
+ /**
72
+ * The name was DECLARED by the model, not generated from the columns.
73
+ *
74
+ * Declared-side only: introspection never sets it (the database has a name but no opinion
75
+ * about whether anyone chose it), and it is absent from `indexKey`, so content matching and
76
+ * the fingerprint are untouched. The diff reads it to decide whether a name difference is an
77
+ * intended `ALTER INDEX … RENAME TO` or just the generated fallback, which must never rename
78
+ * an adopted database to a name nobody chose.
79
+ */
80
+ nameDeclared?: boolean;
58
81
  /** Canonical key text per position: a plain column name, or anything richer verbatim —
59
82
  * `lower(email)`, `created_at DESC`, `title text_pattern_ops` (Brick E: expression
60
83
  * indexes, direction, and opclass are IDENTITY, not noise). Default ASC is stripped. */
@@ -127,6 +150,9 @@ SELECT
127
150
  format_type(a.atttypid, a.atttypmod) AS type,
128
151
  a.attnotnull AS not_null,
129
152
  pg_get_expr(d.adbin, d.adrelid) AS "default",
153
+ -- 'a' = GENERATED ALWAYS AS IDENTITY, 'd' = GENERATED BY DEFAULT, '' = a plain column.
154
+ -- An identity column carries NO pg_attrdef row, so without this the auto-value is invisible.
155
+ a.attidentity AS identity,
130
156
  a.attnum AS position
131
157
  FROM pg_attribute a
132
158
  JOIN pg_class c ON c.oid = a.attrelid
@@ -302,9 +328,19 @@ export interface ColumnRow {
302
328
  type: unknown;
303
329
  not_null: unknown;
304
330
  default: unknown;
331
+ /** `pg_attribute.attidentity` — 'a', 'd', or '' / absent on a read that predates the column. */
332
+ identity?: unknown;
305
333
  position: unknown;
306
334
  }
307
335
 
336
+ /** `attidentity` → the IR's spelling. Anything else (including '') is a plain column. */
337
+ export function identityKind(attidentity: unknown): 'always' | 'byDefault' | undefined {
338
+ const c = attidentity == null ? '' : String(attidentity);
339
+ if (c === 'a') return 'always';
340
+ if (c === 'd') return 'byDefault';
341
+ return undefined;
342
+ }
343
+
308
344
  /**
309
345
  * Drop a precision qualifier that only restates the default.
310
346
  *
@@ -334,6 +370,7 @@ export { canonicalColumnType };
334
370
 
335
371
  export function columnRowToDescriptor(row: ColumnRow): { table: string; column: ColumnSchema; position: number } {
336
372
  const def = row.default == null ? null : String(row.default);
373
+ const identity = identityKind(row.identity);
337
374
  return {
338
375
  table: `${row.schema}.${row.table}`,
339
376
  position: Number(row.position),
@@ -342,6 +379,9 @@ export function columnRowToDescriptor(row: ColumnRow): { table: string; column:
342
379
  type: canonicalColumnType(String(row.type)),
343
380
  notNull: coerceBool(row.not_null),
344
381
  default: def && def.length > 0 ? def : null,
382
+ // Omitted, never `undefined`-valued: the key is absent on a plain column so every
383
+ // structural comparison of a pre-identity snapshot against a fresh one stays equal.
384
+ ...(identity ? { identity } : {}),
345
385
  },
346
386
  };
347
387
  }
@@ -141,6 +141,18 @@ function baseColumnSource(columnName: string, spec: FieldSpec): { call: string;
141
141
  }
142
142
  }
143
143
 
144
+ /**
145
+ * Whether the drizzle builder for this column carries `generatedAlwaysAsIdentity`.
146
+ *
147
+ * Only the integer family does (`PgIntColumnBaseBuilder`). A `field.pgType('smallint')`
148
+ * identity column compiles to `customType`, which has no identity builder — the DDL still
149
+ * carries the clause (that comes from the compiler, not from here), so the DATABASE is right
150
+ * and only the generated TS type is imprecise: it will ask for a value the database supplies.
151
+ */
152
+ function identitySupported(builder?: string): boolean {
153
+ return builder === 'integer' || builder === 'bigint' || builder === 'smallint';
154
+ }
155
+
144
156
  /** The hoisted const name for an enum type. `status` → `statusEnum`. */
145
157
  function enumConstName(enumName: string): string {
146
158
  return `${toCamelCase(enumName)}Enum`;
@@ -174,7 +186,13 @@ function columnModifiers(
174
186
  ): string {
175
187
  let s = '';
176
188
  if (spec.isArray) s += '.array()';
177
- if (spec.isNotNull) s += '.notNull()';
189
+ // Identity comes FIRST and takes `.notNull()` with it: drizzle's builder sets notNull and
190
+ // hasDefault itself, and `hasDefault` is what makes the column OPTIONAL on insert. Without
191
+ // it the generated type demands an `id` the database supplies — which is how a correct
192
+ // database still fails to compile at the call site.
193
+ const identity = identitySupported(ctx.builder) ? spec.identity : undefined;
194
+ if (identity) s += identity === 'always' ? '.generatedAlwaysAsIdentity()' : '.generatedByDefaultAsIdentity()';
195
+ else if (spec.isNotNull) s += '.notNull()';
178
196
  if (spec.isUnique) s += '.unique()';
179
197
 
180
198
  if (spec.defaultKind === 'now') s += '.defaultNow()';