@ultimat3/db 13.0.0 → 14.0.0

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.
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { assert } from '@ultimat3/core';
6
6
  import type { ForeignKeyDescription } from './introspect';
7
+ import { identifier } from './sql';
7
8
 
8
9
  /**
9
10
  * `pg_constraint.confdeltype`. The catalog's vocabulary; a description holds the rule's name.
@@ -51,7 +52,16 @@ export function foreignKeyTarget(key: ForeignKeyDescription): string {
51
52
  return JSON.stringify([[...key.columns], key.referencedTable, [...key.referencedColumns]]);
52
53
  }
53
54
 
54
- const quoted = (names: readonly string[]): string => names.map((name) => `"${name}"`).join(', ');
55
+ /**
56
+ * Through `identifier`, never `"${…}"` — the package's one rule, which every name this file writes
57
+ * now goes through. A name that closes its own quote produced a real `drop table` through
58
+ * `generateMigration` once already, out of `columnClause`, and every name below arrives the same
59
+ * way: from a projection this package cannot typecheck, or from a `.snapshot.json` on disk that
60
+ * anything may edit. Being unreachable with a hostile name today is a property of the CALLERS, not
61
+ * of this file, and it survives exactly until the next refactor.
62
+ */
63
+ const quoted = (names: readonly string[]): string =>
64
+ names.map((name) => identifier(name).text).join(', ');
55
65
 
56
66
  /**
57
67
  * A statement of its own, never a clause inside `create table`. Inline, the constraint is created
@@ -76,14 +86,48 @@ export function addForeignKey(table: string, key: ForeignKeyDescription): string
76
86
  `references(() => target.id, { onDelete: 'cascade' }) # cascade | restrict | set null`,
77
87
  );
78
88
  return (
79
- `alter table "${table}" add constraint "${key.name}" ` +
89
+ `alter table ${identifier(table).text} add constraint ${identifier(key.name).text} ` +
80
90
  `foreign key (${quoted(key.columns)}) ` +
81
- `references "${key.referencedTable}" (${quoted(key.referencedColumns)})` +
91
+ `references ${identifier(key.referencedTable).text} (${quoted(key.referencedColumns)})` +
82
92
  `${rule === null ? '' : ` on delete ${rule}`};`
83
93
  );
84
94
  }
85
95
 
86
96
  /** The reverse. Dropping a constraint loses nothing the database cannot rebuild. */
87
97
  export function dropForeignKey(table: string, constraint: string): string {
88
- return `alter table "${table}" drop constraint "${constraint}";`;
98
+ return `alter table ${identifier(table).text} drop constraint ${identifier(constraint).text};`;
99
+ }
100
+
101
+ /**
102
+ * The drop/add pair that moves a key's `on delete` rule — a rebuild, because Postgres has no
103
+ * `alter constraint` for it — for a `fix:` line an author pastes into a new migration.
104
+ *
105
+ * It lives here, beside the two writers, because it is the one caller reading values neither of
106
+ * them may assume: `held` is the **live catalog's** and `declared` is a `.snapshot.json`'s. Both
107
+ * writers refuse rather than guess — `identifier()` on a name holding a quote, a space or a
108
+ * backslash (all three legal inside a quoted Postgres name), and `addForeignKey` on an `on delete`
109
+ * rule Postgres does not have. That is exactly right for DDL this package SENDS and wrong for a
110
+ * `fix:` line: `diffSchema` is documented pure and total, so a pair it cannot write is a sentence,
111
+ * never a throw — a drift check that raises in place of its report hands the caller an exception
112
+ * where a verdict was asked for. The constraint is still named, because it is the only thing
113
+ * identifying which one, quoted by `JSON.stringify`, which escapes rather than refuses; nothing
114
+ * runs this string either way.
115
+ */
116
+ export function rebuildForeignKey(
117
+ table: string,
118
+ declared: ForeignKeyDescription,
119
+ held: ForeignKeyDescription,
120
+ ): string {
121
+ // The writers are ASKED whether they can write the pair — never a second copy of their rules
122
+ // beside them, which is the copy that drifts. A refusal is the answer, and nothing here reads
123
+ // the thrown value.
124
+ try {
125
+ return `${dropForeignKey(table, held.name)} ${addForeignKey(table, declared)}`;
126
+ } catch {
127
+ return (
128
+ `drop constraint ${JSON.stringify(held.name)} on table ${JSON.stringify(table)} and add ` +
129
+ 'it back with the on delete rule the migrations declare — by hand: x db gen cannot ' +
130
+ 'write this pair'
131
+ );
132
+ }
89
133
  }
package/src/generate.ts CHANGED
@@ -4,6 +4,8 @@
4
4
  // field. Every generated migration must be reversible; a drop that loses data refuses instead.
5
5
 
6
6
  import { assert, systemClock } from '@ultimat3/core';
7
+ import { checkClauses, checkPlan, declaredChecks } from './check-ddl';
8
+ import { defaultExpression } from './column-default';
7
9
  import { isDestructive } from './destructive';
8
10
  import { dropOrder } from './drop-order';
9
11
  import type {
@@ -23,6 +25,9 @@ import {
23
25
  type SchemaDescription,
24
26
  type TableDescription,
25
27
  } from './introspect';
28
+ import { declaredIndexes } from './invariant-ddl';
29
+ import { identifier } from './sql';
30
+ import { type UnrenderedDeclaration, unrenderedComment, unrenderedOf } from './unrendered';
26
31
 
27
32
  const SQL_TYPES: Readonly<Record<string, string>> = {
28
33
  uuid: 'uuid',
@@ -44,29 +49,27 @@ function sqlType(kind: string): string {
44
49
  return SQL_TYPES[kind] ?? kind;
45
50
  }
46
51
 
47
- /**
48
- * Entity descriptions carry `hasDefault` but not the expression, so the two generated defaults
49
- * are inferred from the blessed column helpers. Anything else is left to a follow-up migration.
50
- */
51
- function defaultExpression(column: ColumnDescriptionLike): string | null {
52
- if (!column.hasDefault) return null;
53
- if (column.kind === 'uuid' && column.primaryKey) return 'gen_random_uuid()';
54
- if (column.kind === 'timestamptz') return 'now()';
55
- return null;
56
- }
57
-
58
52
  function columnClause(column: ColumnDescriptionLike): string {
59
53
  // The generation clause sits directly after the type, and `generatedClause` refuses the pairs
60
54
  // Postgres has no column for. Every other part below is unchanged and unreachable for a
61
55
  // generated column: it may carry no default, and `hasDefault` is what the refusal reads.
62
- const parts = [`"${column.column}"`, `${sqlType(column.kind)}${generatedClause(column)}`];
56
+ //
57
+ // Through `identifier`, never `"${…}"`: the name arrives from a projection this package cannot
58
+ // typecheck and a name that closes its own quote produced a real `drop table` through
59
+ // `generateMigration` once already. It is also what makes an unrendered report safe to write
60
+ // into a `--` comment, since generation refuses the dangerous name before the comment exists.
61
+ const parts = [
62
+ identifier(column.column).text,
63
+ `${sqlType(column.kind)}${generatedClause(column)}`,
64
+ ];
63
65
  const expression = defaultExpression(column);
64
66
  if (expression !== null) parts.push(`default ${expression}`);
65
67
  if (column.notNull) parts.push('not null');
66
68
  if (column.unique && !column.primaryKey) parts.push('unique');
67
- if (column.check !== null) parts.push(`check (${column.check})`);
68
- // No `references` clause. A foreign key is `alter table add constraint`, emitted after every
69
- // table exists (`foreignKeyPlan`) inline it must point at a table that already exists, and
69
+ // No `check` clause written here it was ANONYMOUS and reached `create table` alone, so
70
+ // regenerating dropped the value set `enumerated()` declares; `check-ddl.ts` owns every CHECK now.
71
+ // No `references` clause either: a foreign key is `alter table add constraint`, emitted after
72
+ // every table exists (`foreignKeyPlan`) — inline it must point at a table that already exists, and
70
73
  // entity registration order is the app's import order, which says nothing about that.
71
74
  return parts.join(' ');
72
75
  }
@@ -115,6 +118,7 @@ export function snapshotOf(entities: readonly EntityDescriptionLike[]): SchemaDe
115
118
  // field existed gains a key and no app's sidecar regenerates over a fact already true.
116
119
  ...(column.generated === undefined ? {} : { generated: column.generated }),
117
120
  }));
121
+ const checks = declaredChecks(entity);
118
122
  return {
119
123
  schema: 'public',
120
124
  name: entity.table,
@@ -123,7 +127,10 @@ export function snapshotOf(entities: readonly EntityDescriptionLike[]): SchemaDe
123
127
  // Whole, never partly: a snapshot that recorded the name and dropped the predicate made
124
128
  // the next generation blind to a `where` or an `order` changing, and a partial index
125
129
  // silently kept as a total one is a constraint the entity no longer declares.
126
- indexes: entity.indexes.map((index) => ({
130
+ //
131
+ // `declaredIndexes`, so a `unique` invariant's index is recorded exactly like an entity's
132
+ // own — a statement emitted and not recorded is `42P07` on the very next `x db gen`.
133
+ indexes: declaredIndexes(entity).map((index) => ({
127
134
  name: index.name,
128
135
  columns: [...index.columns],
129
136
  unique: index.unique,
@@ -136,6 +143,10 @@ export function snapshotOf(entities: readonly EntityDescriptionLike[]): SchemaDe
136
143
  ...(index.using === undefined ? {} : { using: index.using }),
137
144
  })),
138
145
  foreignKeys: foreignKeysOf(entity),
146
+ // Absent, never `[]`, on a table declaring none: a sidecar that predates this field must
147
+ // read as "nothing recorded" so the next generation adds the constraints the database is
148
+ // genuinely missing — the rule `using` and `generated` already state one field up.
149
+ ...(checks.length === 0 ? {} : { checks }),
139
150
  };
140
151
  });
141
152
  return { tables };
@@ -144,12 +155,17 @@ export function snapshotOf(entities: readonly EntityDescriptionLike[]): SchemaDe
144
155
  function createTable(entity: EntityDescriptionLike): readonly string[] {
145
156
  const clauses = entity.columns.map(columnClause);
146
157
  if (entity.primaryKey.length > 0) {
147
- clauses.push(`primary key (${entity.primaryKey.map((key) => `"${key}"`).join(', ')})`);
158
+ const key = entity.primaryKey.map((column) => identifier(column).text).join(', ');
159
+ clauses.push(`primary key (${key})`);
148
160
  }
149
- const statements = [`create table "${entity.table}" (\n ${clauses.join(',\n ')}\n);`];
161
+ // After the key, so a table declaring no invariant emits the statement it always emitted.
162
+ clauses.push(...checkClauses(entity));
163
+ const statements = [
164
+ `create table ${identifier(entity.table).text} (\n ${clauses.join(',\n ')}\n);`,
165
+ ];
150
166
  // Every column of a new table carries its own clause, so every `unique` one brings its index.
151
167
  const added = new Set(entity.columns.map((column) => column.column));
152
- for (const index of entity.indexes) {
168
+ for (const index of declaredIndexes(entity)) {
153
169
  if (impliedByColumnClause(entity, index, added)) continue;
154
170
  statements.push(createIndex(entity.table, index));
155
171
  }
@@ -185,12 +201,17 @@ function createIndex(table: string, index: IndexDescriptionLike): string {
185
201
  );
186
202
  const kind = index.unique ? 'create unique index' : 'create index';
187
203
  const direction = index.order === null ? '' : ` ${index.order}`;
188
- const columns = index.columns.map((column) => `"${column}"${direction}`).join(', ');
204
+ const columns = index.columns
205
+ .map((column) => `${identifier(column).text}${direction}`)
206
+ .join(', ');
189
207
  const predicate = index.where === null ? '' : ` where (${index.where})`;
190
208
  // Re-derived from the closed set, never spliced: `indexMethodSql` answers `''` for a btree, so
191
209
  // an index that declared no method emits the statement this generator always emitted, byte for
192
210
  // byte, and one that declared a method Postgres does not have is refused instead of built.
193
- return `${kind} "${index.name}" on "${table}"${indexMethodSql(method)} (${columns})${predicate};`;
211
+ return (
212
+ `${kind} ${identifier(index.name).text} on ${identifier(table).text}` +
213
+ `${indexMethodSql(method)} (${columns})${predicate};`
214
+ );
194
215
  }
195
216
 
196
217
  /**
@@ -214,8 +235,8 @@ function retypeColumn(
214
235
  }
215
236
  if (recorded.dataType === wanted) return 'unchanged';
216
237
  const alter = (type: string): string =>
217
- `alter table "${table}" alter column "${column.column}" type ${type} ` +
218
- `using "${column.column}"::${type};`;
238
+ `alter table ${identifier(table).text} alter column ${identifier(column.column).text} ` +
239
+ `type ${type} using ${identifier(column.column).text}::${type};`;
219
240
  plan.up.push(alter(wanted));
220
241
  plan.down.push(alter(recorded.dataType));
221
242
  return 'altered';
@@ -250,7 +271,7 @@ function redefineIndex(
250
271
  plan: Plan,
251
272
  ): void {
252
273
  if (indexShape(index) === indexShape(recorded)) return;
253
- plan.up.push(`drop index "${index.name}";`, createIndex(table, index));
274
+ plan.up.push(`drop index ${identifier(index.name).text};`, createIndex(table, index));
254
275
  // `down` is reversed at assembly, so the pair is pushed forwards and read backwards: recreating
255
276
  // the recorded definition is what must land last, after the new one is dropped.
256
277
  plan.down.push(
@@ -265,7 +286,7 @@ function redefineIndex(
265
286
  // rebuilt as a btree — a `down` that recreates the wrong structure is worse than none.
266
287
  ...(recorded.using === undefined ? {} : { using: declaredMethod(recorded.using) }),
267
288
  }),
268
- `drop index "${index.name}";`,
289
+ `drop index ${identifier(index.name).text};`,
269
290
  );
270
291
  }
271
292
 
@@ -293,18 +314,20 @@ function diffTable(entity: EntityDescriptionLike, live: TableDescription, plan:
293
314
  // step nobody can perform, since a generated column cannot be written to.
294
315
  const nullable = column.notNull && !isGenerated(column) && defaultExpression(column) === null;
295
316
  const clause = nullable ? columnClause({ ...column, notNull: false }) : columnClause(column);
296
- plan.up.push(`alter table "${entity.table}" add column ${clause};`);
317
+ plan.up.push(`alter table ${identifier(entity.table).text} add column ${clause};`);
297
318
  if (nullable) {
298
319
  plan.up.push(
299
- `-- backfill "${column.column}", then: ` +
300
- `alter table "${entity.table}" alter column "${column.column}" set not null;`,
320
+ `-- backfill ${identifier(column.column).text}, then: alter table ` +
321
+ `${identifier(entity.table).text} alter column ${identifier(column.column).text} set not null;`,
301
322
  );
302
323
  }
303
- plan.down.push(`alter table "${entity.table}" drop column "${column.column}";`);
324
+ plan.down.push(
325
+ `alter table ${identifier(entity.table).text} drop column ${identifier(column.column).text};`,
326
+ );
304
327
  }
305
328
 
306
329
  const indexed = new Map(live.indexes.map((index) => [index.name, index]));
307
- for (const index of entity.indexes) {
330
+ for (const index of declaredIndexes(entity)) {
308
331
  const recorded = indexed.get(index.name);
309
332
  // A rebuilt column took its indexes down with it, so this one is CREATED rather than compared:
310
333
  // `redefineIndex` sees a definition that never moved and would emit nothing at all.
@@ -316,8 +339,13 @@ function diffTable(entity: EntityDescriptionLike, live: TableDescription, plan:
316
339
  // migration emits, so it still needs a statement of its own.
317
340
  if (impliedByColumnClause(entity, index, added)) continue;
318
341
  plan.up.push(createIndex(entity.table, index));
319
- plan.down.push(`drop index "${index.name}";`);
342
+ plan.down.push(`drop index ${identifier(index.name).text};`);
320
343
  }
344
+
345
+ // Last: a CHECK may read a column this migration just added, and `add constraint` on a column
346
+ // that does not exist yet is `42703`. `check-ddl.ts` owns which of them move; `rebuilt` because a
347
+ // column dropped and re-added lost its constraint while the snapshot still records it.
348
+ checkPlan(entity, live, plan, rebuilt);
321
349
  }
322
350
 
323
351
  export interface GenerateOptions {
@@ -343,6 +371,13 @@ export interface GeneratedMigration {
343
371
  * cannot be written unmarked and then refused by `x verify` for lacking the mark.
344
372
  */
345
373
  readonly destructive: boolean;
374
+ /**
375
+ * Every declaration this generator could not write down. Empty on a migration that carries the
376
+ * whole schema, which is what makes it readable as a verdict rather than as noise — and the same
377
+ * list `unrenderedComment` writes into the top of `up`, so a caller that would rather refuse
378
+ * (`x db gen`) and a reviewer reading the committed file are looking at one answer.
379
+ */
380
+ readonly unrendered: readonly UnrenderedDeclaration[];
346
381
  }
347
382
 
348
383
  export function migrationStamp(now: Date): string {
@@ -376,7 +411,7 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
376
411
  foreignKeyPlan(entity, live, plans);
377
412
  if (live === undefined) {
378
413
  plan.up.push(...createTable(entity));
379
- plan.down.push(`drop table "${entity.table}";`);
414
+ plan.down.push(`drop table ${identifier(entity.table).text};`);
380
415
  continue;
381
416
  }
382
417
  diffTable(entity, live, plan);
@@ -389,9 +424,12 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
389
424
  `x db gen "${options.name}" --allow-destructive # or keep the column and deprecate it`,
390
425
  );
391
426
  }
392
- plan.up.push(`alter table "${entity.table}" drop column "${column.name}";`);
427
+ plan.up.push(
428
+ `alter table ${identifier(entity.table).text} drop column ${identifier(column.name).text};`,
429
+ );
393
430
  plan.down.push(
394
- `alter table "${entity.table}" add column "${column.name}" ${column.dataType};` +
431
+ `alter table ${identifier(entity.table).text} add column ` +
432
+ `${identifier(column.name).text} ${column.dataType};` +
395
433
  ' -- data is not restored',
396
434
  );
397
435
  }
@@ -409,15 +447,33 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
409
447
  plan.up.push(...preDrops.up, ...order.constraints);
410
448
  plan.down.push(...preDrops.down, ...order.constraints.map(() => '-- constraint not restored'));
411
449
  for (const table of order.tables) {
412
- plan.up.push(`drop table "${table.name}";`);
413
- plan.down.push(`-- "${table.name}" cannot be restored; recover it from a backup`);
450
+ plan.up.push(`drop table ${identifier(table.name).text};`);
451
+ // `identifier` in a comment too: a `--` ends at the first newline, so a name holding one
452
+ // would put a second command on the next line of `down`.
453
+ plan.down.push(
454
+ `-- ${identifier(table.name).text} cannot be restored; recover it from a backup`,
455
+ );
414
456
  }
415
457
 
416
458
  plan.up.push(...constraints.up);
417
459
  plan.down.push(...constraints.down);
418
460
 
419
461
  const id = `${migrationStamp(options.now ?? systemClock.now())}_${slugify(options.name)}`;
420
- const up = plan.up.join('\n');
462
+ // At the TOP of `up`, so what is MISSING is the first thing read — and a line comment, so it is
463
+ // noise to every reader that matters: `statementsOf` drops a chunk of comments alone,
464
+ // `stripSqlNoise` blanks it before `isDestructive` looks for a verb, and the server ignores it.
465
+ //
466
+ // Never onto an EMPTY diff. `@ultimat3/cli`'s `generateAppMigration` reads `up.trim().length` as
467
+ // "nothing changed" and re-records the hash sidecar instead of writing a file; a comment there
468
+ // would make every `x db gen` on an app with an unrendered default write a migration holding no
469
+ // statement — a ledger row, a checksum and a place in the apply order for nothing. The list is
470
+ // still on `GeneratedMigration.unrendered`, which is where a caller with no file reads it.
471
+ //
472
+ // `current`, not the entities alone: an `assert` whose CHECK a previous migration recorded is a
473
+ // loss only because THIS plan drops it, and the recorded schema is the only thing that knows.
474
+ const unrendered = unrenderedOf(options.entities, current);
475
+ const body = plan.up.join('\n');
476
+ const up = body.length === 0 ? body : unrenderedComment(unrendered) + body;
421
477
  return {
422
478
  id,
423
479
  name: options.name,
@@ -427,5 +483,6 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
427
483
  down: [...plan.down].reverse().join('\n'),
428
484
  snapshot: snapshotOf(options.entities),
429
485
  destructive: isDestructive(up),
486
+ unrendered,
430
487
  };
431
488
  }
@@ -7,12 +7,19 @@ import { assert } from '@ultimat3/core';
7
7
  import type { ColumnDescriptionLike } from './entity-shape';
8
8
  import type { Plan } from './foreign-key-plan';
9
9
  import type { ColumnDescription } from './introspect';
10
+ import { identifier } from './sql';
10
11
 
11
12
  /** How a column that moved was brought back into line — what the caller has to do next, if anything. */
12
13
  export type Regeneration = 'unchanged' | 'altered' | 'rebuilt';
13
14
 
15
+ /**
16
+ * Through `identifier`, never `"${…}"`, for the reason `columnClause` states — and this path is
17
+ * the one `columnClause` does NOT cover: `regenerate` is reached from `retypeColumn` for a column
18
+ * that ALREADY exists, so nothing on the way here has looked at the name. Both operands arrive
19
+ * from a projection this package cannot typecheck and from a `.snapshot.json` anything may edit.
20
+ */
14
21
  const alterColumn = (table: string, column: string): string =>
15
- `alter table "${table}" alter column "${column}"`;
22
+ `alter table ${identifier(table).text} alter column ${identifier(column).text}`;
16
23
 
17
24
  /**
18
25
  * The `generated always as (…) stored` clause, or `''` for every ordinary column — so a description
@@ -82,16 +89,18 @@ export function regenerate(
82
89
  // column again. Reported as `rebuilt` so the caller can put the indexes back — an `add column`
83
90
  // implies none of them.
84
91
  if (held === null) {
92
+ const dropColumn = `alter table ${identifier(table).text} drop column ${identifier(column.column).text};`;
85
93
  plan.up.push(
86
- `alter table "${table}" drop column "${column.column}";`,
87
- `alter table "${table}" add column "${column.column}" ${wantedType}` +
88
- `${generatedClause(column)}${column.notNull ? ' not null' : ''};`,
94
+ dropColumn,
95
+ `alter table ${identifier(table).text} add column ${identifier(column.column).text} ` +
96
+ `${wantedType}${generatedClause(column)}${column.notNull ? ' not null' : ''};`,
89
97
  );
90
98
  // Pushed forwards and read backwards — `down` is reversed at assembly.
91
99
  plan.down.push(
92
- `alter table "${table}" add column "${column.column}" ${recorded.dataType};` +
100
+ `alter table ${identifier(table).text} add column ${identifier(column.column).text} ` +
101
+ `${recorded.dataType};` +
93
102
  ' -- was not a generated column',
94
- `alter table "${table}" drop column "${column.column}";`,
103
+ dropColumn,
95
104
  );
96
105
  return 'rebuilt';
97
106
  }
package/src/index.ts CHANGED
@@ -12,6 +12,14 @@ export {
12
12
  listBranches,
13
13
  reapBranches,
14
14
  } from './branch';
15
+ export {
16
+ checkClauses,
17
+ checkPlan,
18
+ columnCheckName,
19
+ columnChecks,
20
+ columnNamesConstraint,
21
+ declaredChecks,
22
+ } from './check-ddl';
15
23
  export type {
16
24
  DbClient,
17
25
  DbConnection,
@@ -32,6 +40,8 @@ export {
32
40
  poolProfileFor,
33
41
  setDbClient,
34
42
  } from './client';
43
+ export type { ColumnDefaultLike } from './column-default';
44
+ export { defaultExpression } from './column-default';
35
45
  export { defaultClient, REPLICA_URL_ENV } from './default-client';
36
46
  export type { DestructiveKind, DestructiveStatement } from './destructive';
37
47
  export {
@@ -56,6 +66,7 @@ export type {
56
66
  ColumnDescriptionLike,
57
67
  EntityDescriptionLike,
58
68
  IndexDescriptionLike,
69
+ InvariantDescriptionLike,
59
70
  } from './entity-shape';
60
71
  export type { DbErrorCode, DbErrorInit } from './errors';
61
72
  export {
@@ -96,6 +107,7 @@ export {
96
107
  isIndexMethod,
97
108
  } from './index-method';
98
109
  export type {
110
+ CheckDescription,
99
111
  ColumnDescription,
100
112
  ForeignKeyDescription,
101
113
  IndexDescription,
@@ -104,6 +116,13 @@ export type {
104
116
  TableDescription,
105
117
  } from './introspect';
106
118
  export { buildSchema, findTable, introspect } from './introspect';
119
+ export {
120
+ constraintNameFor,
121
+ declaredIndexes,
122
+ invariantChecks,
123
+ uniqueColumns,
124
+ } from './invariant-ddl';
125
+ export { constraintExpressionUnsafe, constraintNameUnsafe } from './invariant-errors';
107
126
  export type {
108
127
  AppliedMigration,
109
128
  LedgerRow,
@@ -172,3 +191,7 @@ export { STATEMENT_ATTRIBUTE } from './statement-span';
172
191
  export { statementsOf } from './statement-split';
173
192
  export type { DbTx, IsolationLevel, TransactionOptions } from './transaction';
174
193
  export { beginStatement, currentTx, withTransaction } from './transaction';
194
+ export type { GeneratableForm } from './ungeneratable';
195
+ export { GENERATABLE_FORMS, ungeneratableStatements } from './ungeneratable';
196
+ export type { UnrenderedDeclaration } from './unrendered';
197
+ export { unrenderedComment, unrenderedOf } from './unrendered';
package/src/introspect.ts CHANGED
@@ -57,6 +57,21 @@ export interface ForeignKeyDescription {
57
57
  readonly onDelete: string | null;
58
58
  }
59
59
 
60
+ /**
61
+ * A named CHECK constraint, as the SNAPSHOT spells it — an entity invariant of kind `check`.
62
+ *
63
+ * Absent from every row this module reads out of the live catalog, deliberately and for the reason
64
+ * `ColumnDescription.generated` gives one field up: `pg_get_constraintdef` answers Postgres' own
65
+ * rewriting of the expression, so a catalog value could never compare equal to a generated one and
66
+ * drift would report a correct database forever. The diff that DOES read it is `x db gen`'s, where
67
+ * both sides are this generator's own spellings.
68
+ */
69
+ export interface CheckDescription {
70
+ readonly name: string;
71
+ /** The predicate, exactly as the entity's invariant spells it. */
72
+ readonly expression: string;
73
+ }
74
+
60
75
  export interface TableDescription {
61
76
  readonly schema: string;
62
77
  readonly name: string;
@@ -64,6 +79,14 @@ export interface TableDescription {
64
79
  readonly primaryKey: readonly string[];
65
80
  readonly indexes: readonly IndexDescription[];
66
81
  readonly foreignKeys: readonly ForeignKeyDescription[];
82
+ /**
83
+ * The CHECK constraints migrations declare. Absent — never `[]` — on a table that declares none
84
+ * and in every sidecar written before this field existed, matching `IndexDescription.using`: a
85
+ * snapshot that predates it must read as "nothing recorded" so the next `x db gen` emits the
86
+ * `add constraint` the database is genuinely missing, rather than as "recorded none", which
87
+ * would leave every already-generated app's invariants unenforced forever.
88
+ */
89
+ readonly checks?: readonly CheckDescription[] | undefined;
67
90
  }
68
91
 
69
92
  export interface SchemaDescription {