@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.
- package/package.json +4 -3
- package/src/cli/authz-contract.ts +155 -3
- package/src/cli/authz-derive.ts +46 -5
- package/src/cli/authz-reconcile.ts +146 -1
- package/src/cli/authz-render.ts +26 -4
- package/src/cli/commands/db-apply.ts +11 -0
- package/src/cli/commands/db-check.ts +4 -1
- package/src/cli/commands/db-fingerprint.ts +16 -2
- package/src/cli/commands/db-plan.ts +20 -3
- package/src/cli/commands/db-pull.ts +6 -1
- package/src/cli/commands/db-reconcile.ts +41 -2
- package/src/cli/commands/security.ts +24 -3
- package/src/cli/db-build.ts +36 -1
- 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/git-descent.ts +91 -19
- package/src/cli/migration-generate.ts +3 -0
- package/src/cli/model-api.ts +24 -0
- package/src/cli/model-render.ts +100 -11
- package/src/cli/schema-compile.ts +66 -9
- package/src/cli/schema-diff.ts +133 -14
- package/src/cli/schema-fingerprint.ts +58 -5
- package/src/cli/schema-introspect.ts +40 -0
- package/src/cli/schema-source.ts +19 -1
- package/src/cli/security-audit.ts +131 -2
- package/src/cli/security-catalog.ts +18 -3
- package/src/cli/state-apply.ts +7 -1
|
@@ -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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
530
|
+
: identity
|
|
531
|
+
? null
|
|
532
|
+
: defaultExpr(field.spec),
|
|
533
|
+
...(identity ? { identity } : {}),
|
|
477
534
|
};
|
|
478
535
|
});
|
|
479
536
|
|
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';
|
|
@@ -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'
|
|
@@ -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
|
}
|
package/src/cli/schema-source.ts
CHANGED
|
@@ -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
|
-
|
|
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()';
|