@ultimat3/db 29.3.2 → 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 (98) hide show
  1. package/CLAUDE.md +78 -116
  2. package/README.md +199 -16
  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 +157 -0
  14. package/src/concurrent-plan.ts +294 -0
  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 +21 -35
  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 +75 -0
  30. package/src/errors.ts +80 -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 +99 -0
  40. package/src/generate-triggers.ts +119 -0
  41. package/src/generate-view.ts +97 -0
  42. package/src/generate.ts +128 -89
  43. package/src/index-ddl.ts +23 -35
  44. package/src/index-plan.ts +35 -14
  45. package/src/index.ts +136 -6
  46. package/src/introspect-catalog.ts +26 -6
  47. package/src/introspect-rows.ts +82 -0
  48. package/src/introspect.ts +108 -50
  49. package/src/keyset.ts +182 -0
  50. package/src/lifecycle-errors.ts +15 -0
  51. package/src/local-settings.ts +38 -0
  52. package/src/marker-line.ts +47 -0
  53. package/src/migrate-lock-retry.ts +60 -0
  54. package/src/migrate-rollback.ts +45 -6
  55. package/src/migrate.ts +67 -69
  56. package/src/migration-errors.ts +48 -1
  57. package/src/migration-ledger.ts +38 -4
  58. package/src/migration-step.ts +192 -0
  59. package/src/partition-order.ts +56 -0
  60. package/src/partitioned-tables.ts +24 -0
  61. package/src/pool-gate.ts +120 -0
  62. package/src/pool-listeners.ts +143 -0
  63. package/src/pool-pins.ts +68 -0
  64. package/src/pool-profile.ts +23 -7
  65. package/src/pool-reserve.ts +11 -4
  66. package/src/pool-retire.ts +22 -0
  67. package/src/pool-sweep.ts +115 -0
  68. package/src/pool-timer.ts +10 -0
  69. package/src/pooler-fix.ts +53 -0
  70. package/src/pooler.ts +161 -0
  71. package/src/read-transaction.ts +59 -0
  72. package/src/readonly-query.ts +4 -4
  73. package/src/replica-client.ts +54 -22
  74. package/src/replica-position.ts +89 -0
  75. package/src/replica-scope.ts +25 -4
  76. package/src/retired.ts +131 -0
  77. package/src/retype-dependents.ts +2 -0
  78. package/src/rolling-errors.ts +97 -0
  79. package/src/rolling.ts +212 -0
  80. package/src/row-secure-client.ts +54 -0
  81. package/src/row-security-catalog.ts +26 -0
  82. package/src/row-security-migrator.ts +48 -0
  83. package/src/row-security-plan.ts +58 -0
  84. package/src/row-security-role.ts +60 -0
  85. package/src/row-security-roles.ts +64 -0
  86. package/src/row-security.ts +59 -0
  87. package/src/same-tenant.ts +173 -0
  88. package/src/schema-dump-policies.ts +26 -0
  89. package/src/schema-dump-table.ts +21 -6
  90. package/src/schema-dump.ts +13 -2
  91. package/src/snapshot-parse.ts +42 -2
  92. package/src/sqlstate.ts +4 -0
  93. package/src/statement-bound.ts +82 -0
  94. package/src/transaction-errors.ts +14 -0
  95. package/src/transaction-guard.ts +40 -0
  96. package/src/transaction-settings.ts +24 -0
  97. package/src/transaction.ts +74 -19
  98. package/src/ungeneratable.ts +30 -0
@@ -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
+ }
@@ -0,0 +1,88 @@
1
+ // Single responsibility: `X_DB_ROW_SECURITY_DRIFT` — a tenant table and `database.rowSecurity`
2
+ // disagreeing about its policy, read two ways. Against the migrations (no database: the gate's
3
+ // `drift` step) and against the live catalog (`checkDrift`, after `x db migrate`).
4
+
5
+ import type { DriftDifference } from './drift-findings';
6
+ import type { EntityDescriptionLike } from './entity-shape';
7
+ import { DbError } from './errors';
8
+ import { type RowSecurityOption, rowSecurityInput } from './generate-row-security';
9
+ import type { SchemaDescription, TableDescription } from './introspect';
10
+ import { ROW_SECURITY_POLICY } from './row-security';
11
+ import { rowSecurityAfter } from './row-security-plan';
12
+ import { shellInertIdentifier } from './sql';
13
+
14
+ export const ROW_SECURITY_DRIFT_CODE = 'X_DB_ROW_SECURITY_DRIFT';
15
+
16
+ /** `missing`: the flag wants a policy nothing put there. `stray`: one is there the flag does not want. */
17
+ export interface RowSecurityDifference {
18
+ readonly table: string;
19
+ readonly direction: 'missing' | 'stray';
20
+ }
21
+
22
+ /**
23
+ * The flag and the tenant columns against what the newest snapshot recorded — the same input
24
+ * `x db gen` plans from, so the gate never expects a policy the generator would not write.
25
+ */
26
+ export function rowSecurityDifferences(
27
+ entities: readonly EntityDescriptionLike[],
28
+ recorded: SchemaDescription,
29
+ option: RowSecurityOption,
30
+ ): readonly RowSecurityDifference[] {
31
+ const input = rowSecurityInput(entities, recorded, option);
32
+ const wanted = rowSecurityAfter(input);
33
+ return [
34
+ ...[...wanted]
35
+ .filter((table) => !input.recorded.has(table))
36
+ .map((table) => ({ table, direction: 'missing' as const })),
37
+ ...[...input.recorded]
38
+ .filter((table) => !wanted.has(table))
39
+ .map((table) => ({ table, direction: 'stray' as const })),
40
+ ].sort((a, b) => (a.table < b.table ? -1 : 1));
41
+ }
42
+
43
+ /** The migrations' reading as an error. The repair is the generation the flag is waiting on. */
44
+ export const rowSecurityDrift = (difference: RowSecurityDifference): DbError =>
45
+ new DbError({
46
+ code: ROW_SECURITY_DRIFT_CODE,
47
+ cause:
48
+ difference.direction === 'missing'
49
+ ? `database.rowSecurity is on and no migration gave tenant table "${difference.table}" its policy: a raw sql read of it still sees every tenant's rows`
50
+ : `database.rowSecurity is off and a migration left tenant table "${difference.table}" its policy: every read without a tenant set sees no rows`,
51
+ fix: 'x db gen "row security"',
52
+ meta: { table: difference.table, direction: difference.direction },
53
+ });
54
+
55
+ /** Where the statements are, as a command that prints them: the ledger already holds the migration. */
56
+ const statementsOf = (table: string): string => {
57
+ const name = shellInertIdentifier(table);
58
+ const pattern = name === null ? ROW_SECURITY_POLICY : `on ${name}`;
59
+ return `grep -n '${pattern}' packages/db/migrations/*.sql`;
60
+ };
61
+
62
+ /**
63
+ * The catalog's reading, judged only where both halves were asked (`compareAppendOnly`'s rule):
64
+ * a snapshot that predates the field, or a description nobody introspected, says nothing.
65
+ * `policyNames` holds only an enabled AND forced table's policies, so a disabled or unforced
66
+ * table reads as missing — an owner or nobody would be bound by it.
67
+ */
68
+ export function compareRowSecurity(
69
+ live: TableDescription,
70
+ expected: TableDescription,
71
+ ): DriftDifference[] {
72
+ if (live.policyNames === undefined) return [];
73
+ const present = live.policyNames.includes(ROW_SECURITY_POLICY);
74
+ const wanted = expected.rowSecurity === true;
75
+ if (present === wanted) return [];
76
+ const table = live.name;
77
+ return [
78
+ {
79
+ kind: wanted ? 'missing-row-security' : 'unexpected-row-security',
80
+ table,
81
+ column: null,
82
+ cause: wanted
83
+ ? `table "${table}" should carry the forced tenant policy ${ROW_SECURITY_POLICY} and does not, or its row security is disabled or not forced: raw sql sees every tenant's rows`
84
+ : `table "${table}" carries the tenant policy ${ROW_SECURITY_POLICY} no migration recorded`,
85
+ fix: `${statementsOf(table)} # run the row-security statements it prints against this database with psql "$DATABASE_URL", then x db migrate, which re-checks`,
86
+ },
87
+ ];
88
+ }
@@ -0,0 +1,54 @@
1
+ // Single responsibility: whether the lifecycle triggers a migration installed — history, its guard,
2
+ // counters — are still enabled on the table, and whether a `retain` table became partitioned. The
3
+ // repository never sees either, so only the catalog can say. `drift-append-only.ts` is the model.
4
+
5
+ import { byHand, type DriftDifference, pathTo, repair } from './drift-findings';
6
+ import type { TableDescription, TriggerRecord } from './introspect';
7
+ import { shellInertIdentifier } from './sql';
8
+
9
+ /** The codes `driftError` raises for these kinds, exported so the tests assert the one literal. */
10
+ export const LIFECYCLE_DRIFT_CODE = 'X_LIFECYCLE_TRIGGER_MISSING';
11
+ export const RETAIN_DRIFT_CODE = 'X_ENTITY_RETAIN_INVALID';
12
+
13
+ /** The fix is the statements `x db gen` wrote, against THIS database: the ledger already holds them. */
14
+ function missingTrigger(live: TableDescription, trigger: TriggerRecord): DriftDifference {
15
+ const path = pathTo(live.schema);
16
+ return {
17
+ kind: 'missing-lifecycle-trigger',
18
+ table: live.name,
19
+ column: null,
20
+ cause:
21
+ `table "${live.name}" was given trigger ${trigger.name} by a migration, and it is missing or ` +
22
+ 'disabled — writes here no longer record history or keep a counter',
23
+ fix:
24
+ path === null || shellInertIdentifier(live.name) === null
25
+ ? byHand(`re-create trigger ${trigger.name} as the newest migration that names it does`)
26
+ : repair(path, trigger.install.join(' ')),
27
+ };
28
+ }
29
+
30
+ function partitionedRetain(live: TableDescription): DriftDifference {
31
+ return {
32
+ kind: 'retain-partitioned',
33
+ table: live.name,
34
+ column: null,
35
+ cause:
36
+ `table "${live.name}" declares retain and the catalog holds it partitioned: a partitioned ` +
37
+ "table's retention is detaching its old partitions, never a row-by-row sweep",
38
+ fix: `x db gen "drop retain" # after removing retain from the entity on ${live.name}; detach old partitions in a task`,
39
+ };
40
+ }
41
+
42
+ /** Judged only where both halves were asked, exactly as `compareAppendOnly` reads its pair. */
43
+ export function compareTriggers(
44
+ live: TableDescription,
45
+ expected: TableDescription,
46
+ ): DriftDifference[] {
47
+ const out: DriftDifference[] = [];
48
+ if (expected.retain === true && live.partitioned === true) out.push(partitionedRetain(live));
49
+ if (live.triggerNames === undefined) return out;
50
+ for (const trigger of expected.triggers ?? []) {
51
+ if (!live.triggerNames.includes(trigger.name)) out.push(missingTrigger(live, trigger));
52
+ }
53
+ return out;
54
+ }