@ultimat3/db 29.4.0 → 30.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.
Files changed (96) hide show
  1. package/CLAUDE.md +74 -110
  2. package/README.md +163 -15
  3. package/package.json +13 -2
  4. package/src/app-relation.ts +7 -2
  5. package/src/catalog-fold.ts +5 -0
  6. package/src/catalog-objects.ts +20 -24
  7. package/src/catalog-policies.ts +81 -0
  8. package/src/catalog-relations.ts +31 -10
  9. package/src/catalog.ts +40 -1
  10. package/src/check-ddl.ts +34 -7
  11. package/src/client.ts +132 -48
  12. package/src/column-alter.ts +42 -1
  13. package/src/concurrent-index.ts +20 -0
  14. package/src/concurrent-plan.ts +135 -17
  15. package/src/connection-url.ts +16 -4
  16. package/src/db-diagnostics-deadline.ts +43 -0
  17. package/src/db-diagnostics-redact.ts +58 -0
  18. package/src/db-diagnostics-rows.ts +166 -0
  19. package/src/db-diagnostics-sql.ts +92 -0
  20. package/src/db-diagnostics.ts +178 -0
  21. package/src/destructive.ts +17 -3
  22. package/src/diagnostics-entry.ts +15 -0
  23. package/src/drift-errors.ts +9 -4
  24. package/src/drift-findings.ts +47 -62
  25. package/src/drift-missing.ts +203 -0
  26. package/src/drift-row-security.ts +88 -0
  27. package/src/drift-triggers.ts +54 -0
  28. package/src/drift.ts +88 -21
  29. package/src/entity-shape.ts +67 -0
  30. package/src/errors.ts +77 -9
  31. package/src/expected-loop.ts +34 -4
  32. package/src/failure-store-pg.ts +157 -0
  33. package/src/foreign-key-plan.ts +75 -23
  34. package/src/foreign-key.ts +76 -5
  35. package/src/generate-counter.ts +85 -0
  36. package/src/generate-history.ts +142 -0
  37. package/src/generate-row-security.ts +51 -0
  38. package/src/generate-snapshot.ts +82 -0
  39. package/src/generate-steps.ts +6 -1
  40. package/src/generate-triggers.ts +119 -0
  41. package/src/generate-view.ts +97 -0
  42. package/src/generate.ts +105 -79
  43. package/src/index.ts +129 -6
  44. package/src/introspect-catalog.ts +26 -6
  45. package/src/introspect-rows.ts +82 -0
  46. package/src/introspect.ts +108 -50
  47. package/src/keyset.ts +182 -0
  48. package/src/lifecycle-errors.ts +15 -0
  49. package/src/local-settings.ts +38 -0
  50. package/src/marker-line.ts +12 -4
  51. package/src/migrate-lock-retry.ts +60 -0
  52. package/src/migrate-rollback.ts +45 -6
  53. package/src/migrate.ts +45 -4
  54. package/src/migration-errors.ts +27 -1
  55. package/src/migration-ledger.ts +38 -4
  56. package/src/migration-step.ts +50 -22
  57. package/src/partition-order.ts +56 -0
  58. package/src/partitioned-tables.ts +24 -0
  59. package/src/pool-gate.ts +120 -0
  60. package/src/pool-listeners.ts +143 -0
  61. package/src/pool-pins.ts +68 -0
  62. package/src/pool-profile.ts +23 -7
  63. package/src/pool-reserve.ts +11 -4
  64. package/src/pool-retire.ts +22 -0
  65. package/src/pool-sweep.ts +115 -0
  66. package/src/pool-timer.ts +10 -0
  67. package/src/pooler-fix.ts +53 -0
  68. package/src/pooler.ts +161 -0
  69. package/src/read-transaction.ts +59 -0
  70. package/src/readonly-query.ts +4 -4
  71. package/src/replica-client.ts +54 -22
  72. package/src/replica-position.ts +89 -0
  73. package/src/replica-scope.ts +25 -4
  74. package/src/retired.ts +131 -0
  75. package/src/retype-dependents.ts +2 -0
  76. package/src/rolling-errors.ts +97 -0
  77. package/src/rolling.ts +212 -0
  78. package/src/row-secure-client.ts +54 -0
  79. package/src/row-security-catalog.ts +26 -0
  80. package/src/row-security-migrator.ts +48 -0
  81. package/src/row-security-plan.ts +58 -0
  82. package/src/row-security-role.ts +60 -0
  83. package/src/row-security-roles.ts +64 -0
  84. package/src/row-security.ts +59 -0
  85. package/src/same-tenant.ts +173 -0
  86. package/src/schema-dump-policies.ts +26 -0
  87. package/src/schema-dump-table.ts +21 -6
  88. package/src/schema-dump.ts +13 -2
  89. package/src/snapshot-parse.ts +42 -2
  90. package/src/sqlstate.ts +4 -0
  91. package/src/statement-bound.ts +82 -0
  92. package/src/transaction-errors.ts +14 -0
  93. package/src/transaction-guard.ts +40 -0
  94. package/src/transaction-settings.ts +24 -0
  95. package/src/transaction.ts +74 -19
  96. package/src/ungeneratable.ts +30 -0
@@ -0,0 +1,178 @@
1
+ // Single responsibility: `dbDiagnostics()` — what the database is doing right now (blockers,
2
+ // activity, slow statements, unused and invalid indexes, unvalidated constraints, dead tuples), as
3
+ // facts and never a verdict. `db-health.ts` stays the readiness probe `/readyz` reads.
4
+
5
+ import { finiteCount, isUltimateError, renderThrowable, type Scheduler } from '@ultimat3/core';
6
+ import type { DbClient } from './client';
7
+ import { commandDeadline, EXPIRED, within } from './db-diagnostics-deadline';
8
+ import { redactQueryText } from './db-diagnostics-redact';
9
+ import {
10
+ type ActivityRow,
11
+ activityRow,
12
+ type BlockerRow,
13
+ blockerRow,
14
+ type DeadTupleRow,
15
+ deadTupleRow,
16
+ type InvalidIndexRow,
17
+ invalidIndexRow,
18
+ type NotValidConstraintRow,
19
+ notValidConstraintRow,
20
+ type SlowQueryRow,
21
+ slowQueryRow,
22
+ type UnusedIndexRow,
23
+ unusedIndexRow,
24
+ } from './db-diagnostics-rows';
25
+ import {
26
+ ACTIVITY_SQL,
27
+ BLOCKERS_SQL,
28
+ DEAD_TUPLES_SQL,
29
+ INVALID_INDEXES_SQL,
30
+ NOT_VALID_CONSTRAINTS_SQL,
31
+ SLOW_QUERIES_SQL,
32
+ UNUSED_INDEXES_SQL,
33
+ } from './db-diagnostics-sql';
34
+ import { dbUnavailable } from './errors';
35
+ import { unrefSchedule } from './pool-timer';
36
+ import { readOnlyQuery } from './readonly-query';
37
+
38
+ /** Per statement. A catalog read that takes longer is itself the finding, reported as its section. */
39
+ export const DIAGNOSTICS_TIMEOUT_MS = 2_000;
40
+ /** Rows per section, fetched through a cursor so the server stops there. */
41
+ export const DIAGNOSTICS_LIMIT = 20;
42
+ /** The client's own wait per statement: the server's bound plus a second for the answer to land. */
43
+ export const DIAGNOSTICS_SECTION_DEADLINE_MS = DIAGNOSTICS_TIMEOUT_MS + 1_000;
44
+ /** The whole command. Past it, the sections not yet asked are reported unsent. */
45
+ export const DIAGNOSTICS_DEADLINE_MS = 15_000;
46
+
47
+ export interface DbDiagnosticsOptions {
48
+ /** Override the ambient pool. */
49
+ readonly client?: DbClient | undefined;
50
+ /** Rows per section; a whole number ≥ 1. Default `DIAGNOSTICS_LIMIT`. */
51
+ readonly limit?: number | undefined;
52
+ /** Arms both client-side deadlines. A seam for tests; default an unref'd `setTimeout`. */
53
+ readonly schedule?: Scheduler | undefined;
54
+ }
55
+
56
+ /** A section answered, or why it could not be and what would make it answer. Never a throw. */
57
+ export type DiagnosticSection<Row> =
58
+ | { readonly available: true; readonly rows: readonly Row[] }
59
+ | {
60
+ readonly available: false;
61
+ readonly code: string;
62
+ readonly reason: string;
63
+ readonly fix: string;
64
+ };
65
+
66
+ export interface DbDiagnostics {
67
+ readonly blockers: DiagnosticSection<BlockerRow>;
68
+ readonly activity: DiagnosticSection<ActivityRow>;
69
+ readonly slowQueries: DiagnosticSection<SlowQueryRow>;
70
+ readonly unusedIndexes: DiagnosticSection<UnusedIndexRow>;
71
+ readonly invalidIndexes: DiagnosticSection<InvalidIndexRow>;
72
+ readonly notValidConstraints: DiagnosticSection<NotValidConstraintRow>;
73
+ readonly deadTuples: DiagnosticSection<DeadTupleRow>;
74
+ }
75
+
76
+ /** An operator's act, in the database: this framework never creates an extension for the app. */
77
+ export const SLOW_QUERIES_FIX =
78
+ `psql "$DATABASE_URL" -c 'create extension if not exists pg_stat_statements'` +
79
+ " # after shared_preload_libraries = 'pg_stat_statements' in postgresql.conf and a server restart";
80
+
81
+ /** A section the client stopped waiting for, or never sent: the server's own timeout code. */
82
+ const TIMED_OUT = 'X_DB_STATEMENT_TIMEOUT';
83
+
84
+ /** What a section without an `UltimateError` of its own hands out: the same read, by hand. */
85
+ const GENERIC_FIX =
86
+ 'x db diagnostics --json # re-run once the load that timed this read out passes';
87
+
88
+ /** `scheme://user:password@host` → `scheme://***@host`: a reason is printed, a credential never. */
89
+ const scrubCredentials = (text: string): string =>
90
+ text.replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/@'"]*@/gi, '$1***@');
91
+
92
+ /**
93
+ * Every section is one statement through `readOnlyQuery` — its own `BEGIN READ ONLY`, a 2 s
94
+ * `statement_timeout`, a cursor capped at `limit` — run one after another, so a diagnostic holds
95
+ * one connection and never seven. A section that fails is reported in place: one timeout does not
96
+ * lose the other six. The single throw is a probe that cannot open a connection at all.
97
+ */
98
+ export async function dbDiagnostics(options: DbDiagnosticsOptions = {}): Promise<DbDiagnostics> {
99
+ const limit = finiteCount('dbDiagnostics', 'limit', options.limit ?? DIAGNOSTICS_LIMIT, 1);
100
+ const read = { client: options.client, timeoutMs: DIAGNOSTICS_TIMEOUT_MS } as const;
101
+ const schedule = options.schedule ?? unrefSchedule;
102
+ const sectionMs = DIAGNOSTICS_SECTION_DEADLINE_MS;
103
+ try {
104
+ const probe = await within(readOnlyQuery('select 1', read), sectionMs, schedule);
105
+ if (probe === EXPIRED) {
106
+ throw dbUnavailable(`x db diagnostics had no answer to select 1 within ${sectionMs}ms`);
107
+ }
108
+ } catch (error) {
109
+ throw isUltimateError(error)
110
+ ? error
111
+ : dbUnavailable('x db diagnostics could not open a connection', error);
112
+ }
113
+ const deadline = commandDeadline(DIAGNOSTICS_DEADLINE_MS, schedule);
114
+ const section = async <Row>(
115
+ statement: string,
116
+ toRow: (raw: Readonly<Record<string, unknown>>) => Row,
117
+ fixFor?: (code: string, reason: string) => string | undefined,
118
+ ): Promise<DiagnosticSection<Row>> => {
119
+ if (deadline.passed()) {
120
+ const reason = `not sent: x db diagnostics passed its ${DIAGNOSTICS_DEADLINE_MS}ms deadline first`;
121
+ return { available: false, code: TIMED_OUT, reason, fix: GENERIC_FIX };
122
+ }
123
+ try {
124
+ const answer = await within(
125
+ readOnlyQuery<Readonly<Record<string, unknown>>>(statement, { ...read, maxRows: limit }),
126
+ sectionMs,
127
+ schedule,
128
+ );
129
+ if (answer === EXPIRED) {
130
+ // The embedded database cannot interrupt the statement, so it may still be running.
131
+ const reason = `no answer within ${sectionMs}ms; the client stopped waiting`;
132
+ return { available: false, code: TIMED_OUT, reason, fix: GENERIC_FIX };
133
+ }
134
+ return { available: true, rows: answer.rows.map(toRow) };
135
+ } catch (error) {
136
+ const code = isUltimateError(error) ? error.code : 'X_DB_STATEMENT_FAILED';
137
+ const reason = scrubCredentials(
138
+ isUltimateError(error) ? error.cause : renderThrowable(error),
139
+ );
140
+ const own = isUltimateError(error) ? error.fix : undefined;
141
+ const fix = fixFor?.(code, reason) ?? own ?? GENERIC_FIX;
142
+ return { available: false, code, reason, fix: scrubCredentials(fix) };
143
+ }
144
+ };
145
+ try {
146
+ return await readSections(section);
147
+ } finally {
148
+ deadline.release();
149
+ }
150
+ }
151
+
152
+ type Section = <Row>(
153
+ statement: string,
154
+ toRow: (raw: Readonly<Record<string, unknown>>) => Row,
155
+ fixFor?: (code: string, reason: string) => string | undefined,
156
+ ) => Promise<DiagnosticSection<Row>>;
157
+
158
+ /** The seven, in order, each through `section`. */
159
+ async function readSections(section: Section): Promise<DbDiagnostics> {
160
+ return {
161
+ blockers: await section(BLOCKERS_SQL, (raw) => blockerRow(raw, redactQueryText)),
162
+ activity: await section(ACTIVITY_SQL, (raw) => activityRow(raw, redactQueryText)),
163
+ // `42P01` (not installed) and `55000` (installed, not preloaded) both mean the extension is
164
+ // not answering — its own fix, never `X_DB_SCHEMA_STALE`'s `x db gen`.
165
+ slowQueries: await section(
166
+ SLOW_QUERIES_SQL,
167
+ (raw) => slowQueryRow(raw, redactQueryText),
168
+ (code, reason) =>
169
+ code === 'X_DB_SCHEMA_STALE' || reason.includes('pg_stat_statements')
170
+ ? SLOW_QUERIES_FIX
171
+ : undefined,
172
+ ),
173
+ unusedIndexes: await section(UNUSED_INDEXES_SQL, unusedIndexRow),
174
+ invalidIndexes: await section(INVALID_INDEXES_SQL, invalidIndexRow),
175
+ notValidConstraints: await section(NOT_VALID_CONSTRAINTS_SQL, notValidConstraintRow),
176
+ deadTuples: await section(DEAD_TUPLES_SQL, deadTupleRow),
177
+ };
178
+ }
@@ -41,6 +41,20 @@ export interface DestructiveStatement {
41
41
  readonly statement: string;
42
42
  }
43
43
 
44
+ /**
45
+ * Inside an `alter table`, a bare `drop <name>` is a column: every sub-clause that drops something
46
+ * the database can rebuild names itself, and all of them are listed here. One list, two readers:
47
+ * this file's regex and `rolling.ts`'s token walk.
48
+ */
49
+ export const REBUILDABLE_DROP_CLAUSES: readonly string[] = [
50
+ 'constraint',
51
+ 'default',
52
+ 'not',
53
+ 'identity',
54
+ 'expression',
55
+ 'generated',
56
+ ];
57
+
44
58
  /**
45
59
  * The closed list. Four kinds, because a rail that tried to enumerate every Postgres foot-gun
46
60
  * would be a second SQL parser competing with the server's own — and every one of these is a
@@ -54,11 +68,11 @@ const RULES: readonly (readonly [DestructiveKind, RegExp])[] = [
54
68
  // Statement-leading only: `before truncate` in a trigger and `grant/revoke truncate` name the
55
69
  // operation to guard or permit it, and the rail read them as the operation itself (#522).
56
70
  ['truncate', /^\s*truncate\b/],
57
- // Inside an `alter table`, a bare `drop <name>` is a column: every sub-clause that drops
58
- // something the database can rebuild names itself, and all of them are listed here.
59
71
  [
60
72
  'drop-column',
61
- /\balter\s+table\b[\s\S]*?\bdrop\s+(?!constraint\b|default\b|not\b|identity\b|expression\b|generated\b)/,
73
+ new RegExp(
74
+ `\\balter\\s+table\\b[\\s\\S]*?\\bdrop\\s+(?!${REBUILDABLE_DROP_CLAUSES.map((clause) => `${clause}\\b`).join('|')})`,
75
+ ),
62
76
  ],
63
77
  // Not "narrowing". Whether the new type is narrower is knowable only against the old one, and a
64
78
  // rewrite that fails on one row fails the whole migration whichever direction it went.
@@ -0,0 +1,15 @@
1
+ // `@ultimat3/db/diagnostics`: `dbDiagnostics()` and its row types. Its own entry because one caller
2
+ // runs it — `x db diagnostics` — while `@ultimat3/db` is imported by every role of every app: on the
3
+ // barrel these five modules were evaluated by every web, worker and migrate pod to serve nothing.
4
+
5
+ export type { DbDiagnostics, DbDiagnosticsOptions, DiagnosticSection } from './db-diagnostics';
6
+ export { DIAGNOSTICS_LIMIT, DIAGNOSTICS_TIMEOUT_MS, dbDiagnostics } from './db-diagnostics';
7
+ export type {
8
+ ActivityRow,
9
+ BlockerRow,
10
+ DeadTupleRow,
11
+ InvalidIndexRow,
12
+ NotValidConstraintRow,
13
+ SlowQueryRow,
14
+ UnusedIndexRow,
15
+ } from './db-diagnostics-rows';
@@ -9,6 +9,13 @@
9
9
  import { DbError } from './errors';
10
10
  import { shellInertIdentifier } from './sql';
11
11
 
12
+ /**
13
+ * What a `x db gen` fix owes after its `#`. It diffs entities against the newest snapshot, so a column
14
+ * only a migration holds is an empty diff: exit 0, the finding still there. Both ways out are named.
15
+ */
16
+ export const undeclaredColumnNext = (column: string): string =>
17
+ `declare ${column} on its entity first, or this writes nothing; no entity should hold it: restore the edited migration, then x db reset`;
18
+
12
19
  /**
13
20
  * The contract's pinned wording, and the ONE definition of it: `@ultimat3/entity` carried a second
14
21
  * `dbDrift()` held in step by a comment and a test, deleted `As of 2026-10-02`. Drift is this
@@ -28,9 +35,7 @@ export const dbDrift = (tableName: string, columnName: string): DbError =>
28
35
  cause: `table "${tableName}" has column "${columnName}" not present in any migration`,
29
36
  fix:
30
37
  shellInertIdentifier(columnName) === null
31
- ? 'x db gen "add the column named in this error" # its name carries a backtick, a ' +
32
- 'dollar sign, a quote, a backslash or whitespace, so it is in the cause and not in ' +
33
- 'this command'
34
- : `x db gen "add ${columnName}"`,
38
+ ? `x db gen "add the column named in this error" # its name carries shell syntax, so it is in the cause and not in this command; ${undeclaredColumnNext('it')}`
39
+ : `x db gen "add ${columnName}" # ${undeclaredColumnNext(columnName)}`,
35
40
  meta: { table: tableName, column: columnName },
36
41
  });
@@ -1,10 +1,11 @@
1
1
  // Single responsibility: what a schema difference is CALLED and what its `fix:` says — one
2
- // constructor per `DriftKind`, and nothing that compares anything (`drift.ts` decides whether two
3
- // schemas disagree). A `fix:` is ONE command a shell runs, and a difference names the declared
2
+ // constructor per `DriftKind` (the missing-or-rebuilt ones in `drift-missing.ts`), and nothing that
3
+ // compares anything (`drift.ts` decides whether two schemas disagree). A `fix:` is ONE command a shell runs, and a difference names the declared
4
4
  // side's spelling, never the catalog's — which is Postgres' rewriting.
5
5
 
6
6
  import { psqlCommand } from './dependent-view';
7
- import { addForeignKey, dropForeignKey, onDeleteRule } from './foreign-key';
7
+ import { undeclaredColumnNext } from './drift-errors';
8
+ import { addForeignKey, dropForeignKey, onDeleteRule, validateConstraint } from './foreign-key';
8
9
  import type { CheckDescription, ForeignKeyDescription } from './introspect';
9
10
  import type { Migration } from './migrate';
10
11
  import { addPrimaryKey, dropPrimaryKey } from './primary-key';
@@ -23,8 +24,17 @@ export type DriftKind =
23
24
  | 'missing-check'
24
25
  | 'missing-foreign-key'
25
26
  | 'changed-foreign-key'
27
+ // A declared constraint the catalog holds `NOT VALID`: the deferred step's validate never finished.
28
+ | 'not-valid-constraint'
26
29
  // Constructed in `drift-append-only.ts`: an `appendOnly` table whose refusing trigger is gone.
27
30
  | 'missing-append-only-trigger'
31
+ // Constructed in `drift-triggers.ts`: a history, guard or counter trigger gone, or a retain table
32
+ // the catalog holds partitioned.
33
+ | 'missing-lifecycle-trigger'
34
+ | 'retain-partitioned'
35
+ // Constructed in `drift-row-security.ts`: a tenant policy missing, or present unrecorded.
36
+ | 'missing-row-security'
37
+ | 'unexpected-row-security'
28
38
  // Constructed in `object-drift.ts`: a trigger, function, view, type or sequence in the live
29
39
  // database that replaying the migrations does not create.
30
40
  | 'unexpected-object';
@@ -42,10 +52,10 @@ export interface DriftReport {
42
52
  readonly differences: readonly DriftDifference[];
43
53
  }
44
54
 
45
- const RE_CHECK = 'then x db migrate, which re-checks';
55
+ export const RE_CHECK = 'then x db migrate, which re-checks';
46
56
 
47
57
  /** `set not null` is refused by the server while a row still holds NULL, and says so here. */
48
- const NULLS_FIRST = `refused while a row holds NULL there, so backfill those first; ${RE_CHECK}`;
58
+ export const NULLS_FIRST = `refused while a row holds NULL there, so backfill those first; ${RE_CHECK}`;
49
59
 
50
60
  const CARRIES =
51
61
  'a name in this difference carries a backtick, a dollar sign, a quote, a backslash or whitespace';
@@ -99,7 +109,7 @@ const qualified = (schema: string, table: string): string | null => {
99
109
  };
100
110
 
101
111
  /** Every name inert in a shell AND writable as an identifier — the one screen, asked of each. */
102
- const spellable = (names: readonly string[]): boolean =>
112
+ export const spellable = (names: readonly string[]): boolean =>
103
113
  names.every((name) => shellInertIdentifier(name) !== null);
104
114
 
105
115
  /**
@@ -120,20 +130,8 @@ export function unexpectedColumn(table: string, column: string): DriftDifference
120
130
  cause: `table "${table}" has column "${column}" not present in any migration`,
121
131
  fix:
122
132
  shellInertIdentifier(column) === null
123
- ? 'x db gen "add the undeclared column" # the live column name carries a backtick, a ' +
124
- 'dollar sign, a quote, a backslash or whitespace, so it is in the cause and not in ' +
125
- 'this command'
126
- : `x db gen "add ${column}"`,
127
- };
128
- }
129
-
130
- export function missingColumn(table: string, column: string): DriftDifference {
131
- return {
132
- kind: 'missing-column',
133
- table,
134
- column,
135
- cause: `table "${table}" is missing column "${column}" that migrations declare`,
136
- fix: 'x db migrate',
133
+ ? `x db gen "add the undeclared column" # the live column name carries shell syntax, so it is in the cause and not in this command; ${undeclaredColumnNext('it')}`
134
+ : `x db gen "add ${column}" # ${undeclaredColumnNext(column)}`,
137
135
  };
138
136
  }
139
137
 
@@ -222,16 +220,6 @@ export function unexpectedTable(schema: string, table: string): DriftDifference
222
220
  };
223
221
  }
224
222
 
225
- export function missingTable(table: string): DriftDifference {
226
- return {
227
- kind: 'missing-table',
228
- table,
229
- column: null,
230
- cause: `table "${table}" is declared by migrations but does not exist`,
231
- fix: 'x db migrate',
232
- };
233
- }
234
-
235
223
  /**
236
224
  * Not a difference between two schemas but the absence of one to compare against — reported
237
225
  * through the same channel so it reaches an operator, since a check that quietly answered "clean"
@@ -272,26 +260,6 @@ export function unknownSchema(migrations: readonly Migration[]): DriftDifference
272
260
  };
273
261
  }
274
262
 
275
- export function missingIndex(table: string, index: string): DriftDifference {
276
- return {
277
- kind: 'missing-index',
278
- table,
279
- column: null,
280
- cause: `table "${table}" is missing index "${index}" that migrations declare`,
281
- fix: 'x db migrate',
282
- };
283
- }
284
-
285
- export function changedIndex(table: string, index: string, detail: string): DriftDifference {
286
- return {
287
- kind: 'changed-index',
288
- table,
289
- column: null,
290
- cause: `index "${index}" on "${table}" ${detail}, not what migrations declare`,
291
- fix: 'x db migrate',
292
- };
293
- }
294
-
295
263
  /**
296
264
  * A CHECK a migration declares and the catalog does not hold.
297
265
  *
@@ -334,18 +302,6 @@ export function missingCheck(
334
302
  };
335
303
  }
336
304
 
337
- export function missingForeignKey(table: string, key: ForeignKeyDescription): DriftDifference {
338
- return {
339
- kind: 'missing-foreign-key',
340
- table,
341
- column: null,
342
- cause:
343
- `table "${table}" has no foreign key on (${key.columns.join(', ')}) to ` +
344
- `"${key.referencedTable}" (${key.referencedColumns.join(', ')}) that migrations declare`,
345
- fix: 'x db migrate',
346
- };
347
- }
348
-
349
305
  /**
350
306
  * The key points where it was declared to point and one side's `on delete` rule is not the other's
351
307
  * — reported apart from `missing-foreign-key` because it is a different repair: the constraint is
@@ -405,6 +361,35 @@ export function changedForeignKey(
405
361
  };
406
362
  }
407
363
 
364
+ /**
365
+ * The constraint is there and holds every new write; rows that predate it are unchecked. Left by a
366
+ * deferred step that stopped between its add and its validate, or by hand. The fix is the validate
367
+ * statement (`repair`), because a ledger that recorded the step will apply nothing on a re-run; a
368
+ * step still pending is re-run by `x db migrate`, whose validate is the same statement.
369
+ */
370
+ export function notValidConstraint(
371
+ schema: string,
372
+ table: string,
373
+ constraint: string,
374
+ ): DriftDifference {
375
+ const path = pathTo(schema);
376
+ const steps = 'alter table … validate constraint … on the constraint this difference names';
377
+ return {
378
+ kind: 'not-valid-constraint',
379
+ table,
380
+ column: null,
381
+ cause: `constraint "${constraint}" on "${table}" is NOT VALID: rows older than it were never checked`,
382
+ fix:
383
+ path === null || !spellable([table, constraint])
384
+ ? byHand(steps)
385
+ : repair(
386
+ path,
387
+ validateConstraint(table, constraint),
388
+ 'a row it finds is X_DB_FOREIGN_KEY_VIOLATION or a CHECK failure: repair that row, then run it again',
389
+ ),
390
+ };
391
+ }
392
+
408
393
  const PRIMARY_KEY_BY_HAND = byHand(
409
394
  'drop the primary key this database holds, add the one migrations declare',
410
395
  `${CARRIES} or is too long, so no statement here can spell it`,
@@ -0,0 +1,203 @@
1
+ // Single responsibility: the findings for something a migration declares that the catalog lacks
2
+ // or holds rebuilt — a table, a column, an index, a foreign key — each with the statement that puts
3
+ // it back, built from the snapshot. Split from `drift-findings.ts`, whose helpers these share.
4
+
5
+ import {
6
+ byHand,
7
+ type DriftDifference,
8
+ NULLS_FIRST,
9
+ pathTo,
10
+ RE_CHECK,
11
+ repair,
12
+ spellable,
13
+ } from './drift-findings';
14
+ import { addForeignKey } from './foreign-key';
15
+ import { asDeclared, createIndex, mayBeConstraintBacked } from './index-ddl';
16
+ import type { ColumnDescription, ForeignKeyDescription, IndexDescription } from './introspect';
17
+ import { identifier } from './sql';
18
+
19
+ /**
20
+ * Why none of these says `x db migrate`: `checkDrift` compares the catalog with the migrations the
21
+ * LEDGER holds, so the migration that declares the missing thing has already run and re-running the
22
+ * migrator applies nothing — the same finding comes back. The fix is the statement, against this
23
+ * database (`repair`), the rule `missingCheck` and `changedPrimaryKey` already follow.
24
+ */
25
+
26
+ /**
27
+ * A snapshot column as a column definition. `dataType`, `default` and `generated` are the DECLARED
28
+ * side's own text, out of the author's migration — the trust `missingCheck` gives its predicate —
29
+ * and `psqlCommand` closes the shell layer around all of it. `notNull` is the caller's: an `add
30
+ * column … not null` with no value to fill existing rows with is refused on a table holding any.
31
+ */
32
+ const columnClause = (column: ColumnDescription, notNull: boolean): string => {
33
+ const value =
34
+ column.generated !== undefined
35
+ ? ` generated always as (${column.generated}) stored`
36
+ : column.default === null
37
+ ? ''
38
+ : ` default ${column.default}`;
39
+ return `${identifier(column.name).text} ${column.dataType}${value}${notNull ? ' not null' : ''}`;
40
+ };
41
+
42
+ /** Every row an `add column` lands on gets a value, so `not null` can ride on the same statement. */
43
+ const filled = (column: ColumnDescription): boolean =>
44
+ column.default !== null || column.generated !== undefined;
45
+
46
+ const LATER_NOT_NULL = `then x db migrate, which re-checks and names the set not null — ${NULLS_FIRST}`;
47
+
48
+ /** The statement, or `byHand` when a name in it is refused or a writer refuses the declaration. */
49
+ const statementOr = (
50
+ schema: string,
51
+ names: readonly string[],
52
+ steps: string,
53
+ write: () => string,
54
+ note = RE_CHECK,
55
+ ): string => {
56
+ const path = pathTo(schema);
57
+ if (path === null || !spellable(names)) return byHand(steps);
58
+ try {
59
+ return repair(path, write(), note);
60
+ } catch {
61
+ return byHand(steps, 'migrations declare it in a form no statement here can write');
62
+ }
63
+ };
64
+
65
+ /**
66
+ * Added NULLABLE when nothing fills the rows already there — the strict form fails on a populated
67
+ * table — and the re-check then reports `changed-column`, whose fix is the `set not null`.
68
+ */
69
+ export function missingColumn(
70
+ schema: string,
71
+ table: string,
72
+ column: ColumnDescription,
73
+ ): DriftDifference {
74
+ const strict = !column.nullable && filled(column);
75
+ return {
76
+ kind: 'missing-column',
77
+ table,
78
+ column: column.name,
79
+ cause: `table "${table}" is missing column "${column.name}" that migrations declare`,
80
+ fix: statementOr(
81
+ schema,
82
+ [table, column.name],
83
+ 'add the column this difference names back, as the migration that declares it does',
84
+ () => `alter table ${identifier(table).text} add column ${columnClause(column, strict)};`,
85
+ strict || column.nullable ? RE_CHECK : LATER_NOT_NULL,
86
+ ),
87
+ };
88
+ }
89
+
90
+ /**
91
+ * The table and its primary key only. An index, a foreign key, a check or a trigger has a finding
92
+ * of its own with its own repair, which the re-check reports next — one statement per fact, so a
93
+ * key to a table that is also missing is never part of the statement that would fail on it.
94
+ */
95
+ export function missingTable(
96
+ schema: string,
97
+ table: string,
98
+ columns: readonly ColumnDescription[],
99
+ primaryKey: readonly string[],
100
+ ): DriftDifference {
101
+ const create = (): string => {
102
+ const clauses = [...columns]
103
+ .sort((a, b) => a.position - b.position)
104
+ .map((column) => columnClause(column, !column.nullable));
105
+ if (primaryKey.length > 0) {
106
+ clauses.push(`primary key (${primaryKey.map((name) => identifier(name).text).join(', ')})`);
107
+ }
108
+ return `create table ${identifier(table).text} (${clauses.join(', ')});`;
109
+ };
110
+ return {
111
+ kind: 'missing-table',
112
+ table,
113
+ column: null,
114
+ cause: `table "${table}" is declared by migrations but does not exist`,
115
+ fix: statementOr(
116
+ schema,
117
+ [table, ...columns.map((column) => column.name), ...primaryKey],
118
+ 'create the table this difference names, as the migration that declares it does',
119
+ create,
120
+ `${RE_CHECK} and names its indexes, keys, checks and triggers next`,
121
+ ),
122
+ };
123
+ }
124
+
125
+ const indexNames = (table: string, index: IndexDescription): readonly string[] => [
126
+ table,
127
+ index.name,
128
+ ...index.columns,
129
+ ];
130
+
131
+ export function missingIndex(
132
+ schema: string,
133
+ table: string,
134
+ index: IndexDescription,
135
+ ): DriftDifference {
136
+ return {
137
+ kind: 'missing-index',
138
+ table,
139
+ column: null,
140
+ cause: `table "${table}" is missing index "${index.name}" that migrations declare`,
141
+ fix: statementOr(
142
+ schema,
143
+ indexNames(table, index),
144
+ 'create the index this difference names, as the migration that declares it does',
145
+ () => createIndex(table, asDeclared(index)),
146
+ ),
147
+ };
148
+ }
149
+
150
+ /**
151
+ * Postgres has no `alter index` for any part drift compares, so the repair is a drop and the
152
+ * declared build, in one transaction. A live index a UNIQUE constraint may own refuses `drop index`
153
+ * (2BP01), so its constraint goes first — `if exists`, because it may be a plain index after all.
154
+ */
155
+ export function changedIndex(
156
+ schema: string,
157
+ table: string,
158
+ declared: IndexDescription,
159
+ held: IndexDescription,
160
+ detail: string,
161
+ ): DriftDifference {
162
+ const rebuild = (): string => {
163
+ const name = identifier(declared.name).text;
164
+ const owner = mayBeConstraintBacked(held)
165
+ ? `alter table ${identifier(table).text} drop constraint if exists ${name}; `
166
+ : '';
167
+ return `${owner}drop index if exists ${name}; ${createIndex(table, asDeclared(declared))}`;
168
+ };
169
+ return {
170
+ kind: 'changed-index',
171
+ table,
172
+ column: null,
173
+ cause: `index "${declared.name}" on "${table}" ${detail}, not what migrations declare`,
174
+ fix: statementOr(
175
+ schema,
176
+ indexNames(table, declared),
177
+ 'drop the index this difference names and create it as the migration that declares it does',
178
+ rebuild,
179
+ ),
180
+ };
181
+ }
182
+
183
+ export function missingForeignKey(
184
+ schema: string,
185
+ table: string,
186
+ key: ForeignKeyDescription,
187
+ ): DriftDifference {
188
+ return {
189
+ kind: 'missing-foreign-key',
190
+ table,
191
+ column: null,
192
+ cause:
193
+ `table "${table}" has no foreign key on (${key.columns.join(', ')}) to ` +
194
+ `"${key.referencedTable}" (${key.referencedColumns.join(', ')}) that migrations declare`,
195
+ fix: statementOr(
196
+ schema,
197
+ [table, key.name, key.referencedTable, ...key.columns, ...key.referencedColumns],
198
+ 'add the foreign key this difference names back, as the migration that declares it does',
199
+ () => addForeignKey(table, key),
200
+ 'a row it finds is X_DB_FOREIGN_KEY_VIOLATION: repair that row, run it again, then x db migrate, which re-checks',
201
+ ),
202
+ };
203
+ }