@ultimat3/db 24.0.0 → 25.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.
package/src/errors.ts CHANGED
@@ -41,6 +41,8 @@ export const DB_OWNED_ERROR_CODES = [
41
41
  'X_DB_TRANSACTION_ABORTED',
42
42
  'X_DB_COMMIT_UNKNOWN',
43
43
  'X_DB_SIBLING_SCOPE_TIMEOUT',
44
+ 'X_APPEND_ONLY_TRIGGER_MISSING',
45
+ 'X_MIGRATION_APPEND_ONLY_BACKFILL',
44
46
  ] as const;
45
47
 
46
48
  /**
@@ -89,6 +91,9 @@ export const DB_ERROR_TITLES: Readonly<Record<DbOwnedErrorCode, string>> = {
89
91
  X_DB_TRANSACTION_ABORTED: 'the server rolled the transaction back',
90
92
  X_DB_COMMIT_UNKNOWN: 'the connection was lost while COMMIT was in flight',
91
93
  X_DB_SIBLING_SCOPE_TIMEOUT: 'a nested transaction scope waited too long for its sibling',
94
+ X_APPEND_ONLY_TRIGGER_MISSING: 'an append-only table has lost its refusing trigger',
95
+ X_MIGRATION_APPEND_ONLY_BACKFILL:
96
+ 'a NOT NULL column added to an append-only table needs a default',
92
97
  };
93
98
 
94
99
  // Registered unconditionally, in one call, so a second package claiming one of db's codes fails
@@ -285,7 +290,7 @@ export const drainTimeout = (ms: number, role: string): DbError =>
285
290
  new DbError({
286
291
  code: 'X_DB_DRAIN_TIMEOUT',
287
292
  cause: `the ${role} pool still held connections after ${String(ms)}ms, so close() stopped waiting`,
288
- fix: `find the statement that will not finish — psql "$DATABASE_URL" -c "select pid, state, query from pg_stat_activity where state <> 'idle'" — or raise drainTimeoutMs in createPostgresClient({ profile }) for the ${role} role`,
293
+ fix: `find the statement that will not finish — psql "$DATABASE_URL" -c "select pid, state, query from pg_stat_activity where state <> 'idle'" — or raise drainTimeoutMs in postgresClient({ profile }) for the ${role} role`,
289
294
  meta: { drainTimeoutMs: ms, role },
290
295
  });
291
296
 
package/src/fake.ts CHANGED
@@ -49,7 +49,7 @@ const DEFAULT_STUBS: readonly Stub[] = [
49
49
  { match: /pg_try_advisory_lock/, response: { rows: [{ locked: true }] } },
50
50
  ];
51
51
 
52
- export function createRecordingClient(): RecordingClient {
52
+ export function recordingClient(): RecordingClient {
53
53
  const statements: RecordedStatement[] = [];
54
54
  const stubs: Stub[] = [...DEFAULT_STUBS];
55
55
 
@@ -0,0 +1,146 @@
1
+ // Single responsibility: the trigger an `entity({ appendOnly: true })` table carries, emitted once.
2
+ // The repository refuses update and delete above the driver; this is the same guarantee held by the
3
+ // DATABASE, so raw SQL, a second app on the same schema and a hand-written driver are refused too.
4
+ // Recorded on the snapshot (`appendOnly: true`), which is what stops it being re-emitted.
5
+
6
+ import { renderFixLiteral } from '@ultimat3/core';
7
+ import type { ColumnDescriptionLike, EntityDescriptionLike } from './entity-shape';
8
+ import { DbError } from './errors';
9
+ import type { Plan } from './foreign-key-plan';
10
+ import { findTable, type SchemaDescription } from './introspect';
11
+ import { identifier } from './sql';
12
+
13
+ /**
14
+ * The trigger's name — the SAME on every table, because a trigger name is unique per table, never
15
+ * per schema. A per-table name (`<table>_append_only`) would pass 63 bytes on a long table name,
16
+ * Postgres would truncate it in silence, and drift would compare the untruncated name forever.
17
+ */
18
+ export const APPEND_ONLY_TRIGGER = 'ultimate_append_only';
19
+
20
+ /**
21
+ * One function for every append-only table: `tg_table_name` says which table refused. Not `x_`
22
+ * prefixed — that namespace is framework bookkeeping created at boot (`FRAMEWORK_TABLE_PREFIX`), and
23
+ * this function is created by the app's own migrations, so the schema dump files it with them.
24
+ */
25
+ export const APPEND_ONLY_FUNCTION = 'ultimate_refuse_append_only';
26
+
27
+ /**
28
+ * `create or replace`, so every migration that adds a trigger may define it again: no migration has
29
+ * to know whether an earlier one already did, and a database where it was dropped by hand gets it
30
+ * back on the next one. One line, because the drift repair (`drift-append-only.ts`) is the same
31
+ * text as one `psql -c` word.
32
+ *
33
+ * The message LEADS with the code an app searches for, and the SQLSTATE is `23001`
34
+ * (`restrict_violation`): class 23 is "an integrity constraint refused this", which is what an
35
+ * append-only table is — so a caller that already treats class 23 as terminal, never retried,
36
+ * treats this the same. These bytes are in migrations on disk; changing them changes nothing that
37
+ * already shipped.
38
+ */
39
+ export const APPEND_ONLY_FUNCTION_SQL =
40
+ `create or replace function ${identifier(APPEND_ONLY_FUNCTION).text}() returns trigger ` +
41
+ 'language plpgsql as $append_only$ begin raise exception ' +
42
+ "'X_ENTITY_APPEND_ONLY: % on %.% is refused, the table is append-only', " +
43
+ "tg_op, tg_table_schema, tg_table_name using errcode = '23001', hint = 'insert a new row " +
44
+ "instead; to allow rewrites, remove appendOnly from the entity and run x db gen'; end; " +
45
+ '$append_only$;';
46
+
47
+ /**
48
+ * `before update or delete … for each row`: a row-level BEFORE trigger raises before the row moves,
49
+ * and it covers `insert … on conflict do update` too — the conflict arm IS an update. `truncate` is
50
+ * deliberately not refused: it is no row-level write, `destructive.ts` already gates it in a
51
+ * migration, and the testing package's reusable database empties every table with it.
52
+ */
53
+ export const appendOnlyTriggerSql = (table: string): string =>
54
+ `create trigger ${identifier(APPEND_ONLY_TRIGGER).text} before update or delete on ` +
55
+ `${identifier(table).text} for each row execute function ` +
56
+ `${identifier(APPEND_ONLY_FUNCTION).text}();`;
57
+
58
+ /** `if exists`: a revert must not fail on a database where the trigger was already dropped by hand. */
59
+ export const dropAppendOnlyTriggerSql = (table: string): string =>
60
+ `drop trigger if exists ${identifier(APPEND_ONLY_TRIGGER).text} on ${identifier(table).text};`;
61
+
62
+ /**
63
+ * Installing it on a table that may already hold one: drop-if-exists, then create. A DISABLED
64
+ * trigger of this name is drift (`drift-append-only.ts`) yet still exists, so a bare `create
65
+ * trigger` would fail on it (`42710`) and leave the table unprotected. Idempotent for both a
66
+ * missing and a disabled trigger — the drift repair and an existing table's migration both use it.
67
+ */
68
+ export const installAppendOnlyTriggerSql = (table: string): readonly string[] => [
69
+ dropAppendOnlyTriggerSql(table),
70
+ appendOnlyTriggerSql(table),
71
+ ];
72
+
73
+ /** Whether the recorded schema says this table carries the trigger. Absent is "not recorded". */
74
+ const recorded = (current: SchemaDescription, table: string): boolean =>
75
+ findTable(current, table)?.appendOnly === true;
76
+
77
+ /**
78
+ * Both directions. Declared and not recorded: define the function (once per migration) and add the
79
+ * trigger; its `down` drops the trigger, except on a table this migration creates, whose `down` is
80
+ * already `drop table`. Recorded and no longer declared: drop it, and `down` puts it back.
81
+ *
82
+ * A table dropped outright needs nothing — the trigger goes with it, and the snapshot stops naming
83
+ * the table. The function is never dropped: other tables may still call it, and a function left with
84
+ * no trigger refuses nothing.
85
+ */
86
+ export function appendOnlyPlan(
87
+ plan: Plan,
88
+ entities: readonly EntityDescriptionLike[],
89
+ current: SchemaDescription,
90
+ created: ReadonlySet<string>,
91
+ ): void {
92
+ const changed = entities.filter(
93
+ (entity) => (entity.appendOnly === true) !== recorded(current, entity.table),
94
+ );
95
+ const added = changed.filter((entity) => entity.appendOnly === true).map((e) => e.table);
96
+ const removed = changed.filter((entity) => entity.appendOnly !== true).map((e) => e.table);
97
+ if (added.length > 0) plan.up.push(APPEND_ONLY_FUNCTION_SQL);
98
+ for (const table of added) {
99
+ // A table this migration creates holds no trigger yet, and its `down` is `drop table`.
100
+ if (created.has(table)) {
101
+ plan.up.push(appendOnlyTriggerSql(table));
102
+ continue;
103
+ }
104
+ plan.up.push(...installAppendOnlyTriggerSql(table));
105
+ plan.down.push(dropAppendOnlyTriggerSql(table));
106
+ }
107
+ for (const table of removed) {
108
+ plan.up.push(dropAppendOnlyTriggerSql(table));
109
+ // Reversed whole: pushed create-then-drop so `down` runs drop-if-exists, then create.
110
+ plan.down.push(...[...installAppendOnlyTriggerSql(table)].reverse());
111
+ }
112
+ // `down` is reversed whole, so the function pushed LAST here runs FIRST there — before the
113
+ // triggers that call it are re-created.
114
+ if (removed.length > 0) plan.down.push(APPEND_ONLY_FUNCTION_SQL);
115
+ }
116
+
117
+ /** Which arm met the column: a new one, or an existing one turned NOT NULL. */
118
+ export type AppendOnlyBackfillArm = 'added' | 'made-not-null';
119
+
120
+ /**
121
+ * A column an append-only table must hold NOT NULL with no default to fill it. Both arms of the
122
+ * diff would emit `-- backfill …, then: set not null` — `diffTable` for a new column,
123
+ * `alterColumnInPlace` for an existing one — and on this table that backfill is an UPDATE the
124
+ * trigger refuses: an instruction nobody can carry out, leaving the column nullable forever.
125
+ * Refused at generation instead; a default is the one way every row gets a value with no UPDATE.
126
+ */
127
+ export const appendOnlyBackfillRefused = (
128
+ entity: EntityDescriptionLike,
129
+ column: ColumnDescriptionLike,
130
+ arm: AppendOnlyBackfillArm,
131
+ ): DbError =>
132
+ new DbError({
133
+ code: 'X_MIGRATION_APPEND_ONLY_BACKFILL',
134
+ cause:
135
+ `${identifier(entity.table).text} is append-only and ` +
136
+ `${arm === 'added' ? 'gains NOT NULL column' : 'turns NOT NULL its column'} ` +
137
+ `${identifier(column.column).text} with no default: existing rows could only be given a ` +
138
+ 'value by an UPDATE, which the append-only trigger refuses, so x db gen cannot write a ' +
139
+ 'migration that ends with the column NOT NULL',
140
+ // A default fills a column as it is ADDED; it fills no NULL an existing column already holds.
141
+ fix:
142
+ arm === 'added'
143
+ ? `entity(${renderFixLiteral(entity.name, "'<name>'")}, { columns: { ${renderFixLiteral(column.property, "'<col>'")}: <builder>.default(<value>) } }) # then re-run x db gen`
144
+ : `entity(${renderFixLiteral(entity.name, "'<name>'")}, { columns: { ${renderFixLiteral(column.property, "'<col>'")}: <builder>.nullable() } }) # keep it nullable, or add a NEW column with .default(<value>); then re-run x db gen`,
145
+ meta: { table: entity.table, column: column.column, arm },
146
+ });
package/src/generate.ts CHANGED
@@ -7,10 +7,11 @@ import { systemClock } from '@ultimat3/core';
7
7
  import { checkClauses, checkPlan, declaredChecks } from './check-ddl';
8
8
  import { alterColumnInPlace } from './column-alter';
9
9
  import { defaultExpression } from './column-default';
10
- import { isDestructive } from './destructive';
10
+ import { isDestructiveMigration } from './destructive';
11
11
  import { dropOrder } from './drop-order';
12
12
  import type { ColumnDescriptionLike, EntityDescriptionLike } from './entity-shape';
13
13
  import { type ConstraintPlans, foreignKeyPlan, foreignKeysOf, type Plan } from './foreign-key-plan';
14
+ import { appendOnlyBackfillRefused, appendOnlyPlan } from './generate-append-only';
14
15
  import type { Regeneration } from './generated-column';
15
16
  import { generatedClause, isGenerated, regenerate } from './generated-column';
16
17
  import { createIndex, impliedByColumnClause } from './index-ddl';
@@ -23,7 +24,7 @@ import {
23
24
  } from './introspect';
24
25
  import { declaredIndexes } from './invariant-ddl';
25
26
  import { migrationIrreversible } from './migration-errors';
26
- import { addChangedKey, dropChangedKey } from './primary-key';
27
+ import { addChangedKey, dropChangedKey, migrationNameArg } from './primary-key';
27
28
  import type { ReplicaIdentityInput } from './replica-identity';
28
29
  import { replicaIdentityFullAfter, replicaIdentityPlan } from './replica-identity';
29
30
  import type { MovedAside } from './retype-dependents';
@@ -112,6 +113,8 @@ export function snapshotOf(
112
113
  // The same rule once more: `true` or absent, never `false`. This is what makes the ALTER
113
114
  // beside it a one-time statement rather than a line every `x db gen` writes again.
114
115
  ...(replicaIdentityFull.has(entity.table) ? { replicaIdentityFull: true as const } : {}),
116
+ // `true` or absent once more — the record that makes the trigger a one-time statement.
117
+ ...(entity.appendOnly === true ? { appendOnly: true as const } : {}),
115
118
  };
116
119
  });
117
120
  return { tables };
@@ -199,7 +202,7 @@ function diffTable(
199
202
  } else {
200
203
  // After any retype, so a new default is set against the column's new type. A rebuilt
201
204
  // column was re-added carrying its whole clause, so it has nothing left to move.
202
- alterColumnInPlace(entity.table, column, recorded, plan);
205
+ alterColumnInPlace(entity, column, recorded, plan);
203
206
  }
204
207
  continue;
205
208
  }
@@ -212,6 +215,9 @@ function diffTable(
212
215
  // one statement — measured. Emitting it nullable would leave a `-- backfill` comment naming a
213
216
  // step nobody can perform, since a generated column cannot be written to.
214
217
  const nullable = column.notNull && !isGenerated(column) && defaultExpression(column) === null;
218
+ // That follow-up is an UPDATE, and an append-only table's trigger refuses every one.
219
+ if (nullable && entity.appendOnly === true)
220
+ throw appendOnlyBackfillRefused(entity, column, 'added');
215
221
  const clause = nullable ? columnClause({ ...column, notNull: false }) : columnClause(column);
216
222
  plan.up.push(`alter table ${identifier(entity.table).text} add column ${clause};`);
217
223
  if (nullable) {
@@ -335,7 +341,7 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
335
341
  if (options.allowDestructive !== true) {
336
342
  throw migrationIrreversible(
337
343
  `dropping "${entity.table}"."${column.name}" discards its rows and cannot be undone`,
338
- `x db gen "${options.name}" --allow-destructive # or keep the column and deprecate it`,
344
+ `x db gen ${migrationNameArg(options.name)} --allow-destructive # or keep the column and deprecate it`,
339
345
  );
340
346
  }
341
347
  plan.up.push(
@@ -355,7 +361,7 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
355
361
  if (options.allowDestructive !== true) {
356
362
  throw migrationIrreversible(
357
363
  `dropping table "${table.name}" discards every row and cannot be undone`,
358
- `x db gen "${options.name}" --allow-destructive # or delete the entity in two releases`,
364
+ `x db gen ${migrationNameArg(options.name)} --allow-destructive # or delete the entity in two releases`,
359
365
  );
360
366
  }
361
367
  }
@@ -373,6 +379,9 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
373
379
  plan.up.push(...constraints.up);
374
380
  plan.down.push(...constraints.down);
375
381
 
382
+ // After every table exists, so a `create table` in this same migration is already above it.
383
+ appendOnlyPlan(plan, options.entities, current, created);
384
+
376
385
  // Dead last in `up`, and it is the only placement that is right for every arm: the table has to
377
386
  // exist, and a `create table` in this same migration is the reason it might not. It is ordered
378
387
  // against nothing else — replica identity constrains no column, no index and no constraint — so
@@ -388,7 +397,7 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
388
397
  const id = `${migrationStamp(options.now ?? systemClock.now())}_${slugify(options.name)}`;
389
398
  // At the TOP of `up`, so what is MISSING is the first thing read — and a line comment, so it is
390
399
  // noise to every reader that matters: `statementsOf` drops a chunk of comments alone,
391
- // `stripSqlNoise` blanks it before `isDestructive` looks for a verb, and the server ignores it.
400
+ // `stripSqlNoise` blanks it before `isDestructiveMigration` looks for a verb, and the server ignores it.
392
401
  //
393
402
  // Never onto an EMPTY diff. `@ultimat3/cli`'s `generateAppMigration` reads `up.trim().length` as
394
403
  // "nothing changed" and re-records the hash sidecar instead of writing a file; a comment there
@@ -411,7 +420,7 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
411
420
  // ends have been retyped back, which is every other statement in the script.
412
421
  down: [...preAlters.down, ...plan.down].reverse().join('\n'),
413
422
  snapshot: snapshotOf(options.entities, replicaIdentityFullAfter(replicaIdentity)),
414
- destructive: isDestructive(up),
423
+ destructive: isDestructiveMigration(up),
415
424
  unrendered,
416
425
  };
417
426
  }
package/src/index.ts CHANGED
@@ -27,7 +27,7 @@ export type {
27
27
  PostgresClientOptions,
28
28
  ReservableClient,
29
29
  } from './client';
30
- export { baseClient, createPostgresClient, db, isReservable, setDbClient } from './client';
30
+ export { baseClient, db, isReservable, postgresClient, setDbClient } from './client';
31
31
  export type { ColumnDefaultLike } from './column-default';
32
32
  export { defaultExpression } from './column-default';
33
33
  export type { DbHealthReport } from './db-health';
@@ -44,12 +44,12 @@ export {
44
44
  DESTRUCTIVE_MARKER,
45
45
  destructiveStatements,
46
46
  hasDestructiveMarker,
47
- isDestructive,
47
+ isDestructiveMigration,
48
48
  } from './destructive';
49
49
  export type { DriftDifference, DriftKind, DriftOptions, DriftReport } from './drift';
50
50
  export {
51
51
  appTables,
52
- assertNoDrift,
52
+ assertNoSchemaDrift,
53
53
  checkDrift,
54
54
  declaredSchema,
55
55
  diffSchema,
@@ -83,7 +83,7 @@ export {
83
83
  } from './errors';
84
84
  export { expectedQueryLoop, expectedQueryLoopReason } from './expected-loop';
85
85
  export type { RecordedStatement, RecordingClient, StubResponse } from './fake';
86
- export { createRecordingClient } from './fake';
86
+ export { recordingClient } from './fake';
87
87
  export type { GeneratedMigration, GenerateOptions } from './generate';
88
88
  export { generateMigration, migrationStamp, slugify, snapshotOf } from './generate';
89
89
  export type { IndexMethod } from './index-method';
@@ -103,7 +103,7 @@ export type {
103
103
  SchemaDescription,
104
104
  TableDescription,
105
105
  } from './introspect';
106
- export { buildSchema, findTable, introspect } from './introspect';
106
+ export { buildSchema, findTable, introspectSchema } from './introspect';
107
107
  export {
108
108
  constraintNameFor,
109
109
  declaredIndexes,
@@ -157,12 +157,12 @@ export type {
157
157
  PgliteResult,
158
158
  } from './pglite';
159
159
  export {
160
- createPgliteClient,
161
160
  loadPgliteDriver,
162
161
  PGLITE_FIX,
163
162
  PGLITE_MEMORY,
164
163
  PGLITE_MISSING,
165
164
  PGLITE_PACKAGE,
165
+ pgliteClient,
166
166
  pgliteDataDir,
167
167
  } from './pglite';
168
168
  export type { PgliteBranchInfo, PgliteBranchOptions } from './pglite-branch';
@@ -1,5 +1,5 @@
1
1
  // Single responsibility: read the WHOLE schema out of `pg_catalog` — tables and everything that is
2
- // not a table — into one `CatalogDescription`, sorted in JS. `introspect()` beside it stays the
2
+ // not a table — into one `CatalogDescription`, sorted in JS. `introspectSchema()` beside it stays the
3
3
  // entity-vocabulary reading drift compares to a snapshot; this is the catalog's own spelling, the
4
4
  // input of the schema dump, and comparable only to another reading of itself.
5
5
 
package/src/introspect.ts CHANGED
@@ -104,7 +104,7 @@ export interface TableDescription {
104
104
  * handed a catalog value by accident.
105
105
  *
106
106
  * `snapshotOf` never writes it and `parseSnapshot` never reads it, so a sidecar carries `checks`
107
- * alone. `introspect()` always answers with it, `[]` included: absent therefore means "nobody
107
+ * alone. `introspectSchema()` always answers with it, `[]` included: absent therefore means "nobody
108
108
  * asked the catalog", which is what keeps `compareChecks` silent on a description that never
109
109
  * read one instead of reporting every declared constraint as missing.
110
110
  */
@@ -119,12 +119,25 @@ export interface TableDescription {
119
119
  * table an app has would rewrite every sidecar in the tree on the next `x db gen` for a fact that
120
120
  * was already true — the argument `IndexDescription.using` makes about `btree`.
121
121
  *
122
- * `introspect()` never answers it. The catalog's half is `pg_class.relreplident`, which
122
+ * `introspectSchema()` never answers it. The catalog's half is `pg_class.relreplident`, which
123
123
  * `@ultimat3/realtime`'s preflight already reads at the only moment it matters; a second reader
124
124
  * here would let a `diffSchema` compare a declaration against a catalog value, which is the
125
125
  * mistake `checks` and `checkNames` exist as two fields to prevent.
126
126
  */
127
127
  readonly replicaIdentityFull?: true | undefined;
128
+ /**
129
+ * That a migration gave this table the append-only trigger (`generate-append-only.ts`). The
130
+ * SNAPSHOT's half: `true` or absent, never `false`, for the reason `replicaIdentityFull` gives.
131
+ * `introspectSchema()` never writes it — the catalog's half is `triggerNames`.
132
+ */
133
+ readonly appendOnly?: true | undefined;
134
+ /**
135
+ * The CATALOG's half of `appendOnly`: the names of the ENABLED, non-internal triggers on the
136
+ * table (`tgenabled` `O` or `A` — a disabled or replica-only trigger refuses nothing in an
137
+ * ordinary session, so it is not held). A separate field for the reason `checkNames` is one.
138
+ * Written by `introspectSchema()` always, `[]` included; absent means nobody asked the catalog.
139
+ */
140
+ readonly triggerNames?: readonly string[] | undefined;
128
141
  }
129
142
 
130
143
  export interface SchemaDescription {
@@ -179,9 +192,17 @@ interface CheckRow {
179
192
  readonly constraint_name: string;
180
193
  }
181
194
 
195
+ /** An enabled trigger's NAME — the half of `appendOnly` the catalog can answer. */
196
+ interface TriggerNameRow {
197
+ readonly table_name: string;
198
+ readonly trigger_name: string;
199
+ }
200
+
182
201
  const byName = (a: { name: string }, b: { name: string }): number => (a.name < b.name ? -1 : 1);
183
202
 
184
- export async function introspect(options: IntrospectOptions = {}): Promise<SchemaDescription> {
203
+ export async function introspectSchema(
204
+ options: IntrospectOptions = {},
205
+ ): Promise<SchemaDescription> {
185
206
  const client = options.client ?? db();
186
207
  const schema = options.schema ?? 'public';
187
208
  // Asked first, and unconditionally: everything below reads `information_schema`, which admits a
@@ -297,7 +318,19 @@ export async function introspect(options: IntrospectOptions = {}): Promise<Schem
297
318
  order by src.relname, c.conname
298
319
  `);
299
320
 
300
- return buildSchema(schema, excluded, columns, indexes, foreignKeys, checks);
321
+ // Names only, as `checks` above: an append-only table is judged by whether its trigger is THERE
322
+ // and firing, never by `pg_get_triggerdef`'s text. `tgisinternal` is a foreign key's own.
323
+ const triggers = await client.query<TriggerNameRow>(sql`
324
+ select src.relname as table_name, t.tgname as trigger_name
325
+ from pg_trigger t
326
+ join pg_class src on src.oid = t.tgrelid
327
+ join pg_namespace n on n.oid = src.relnamespace
328
+ where not t.tgisinternal and t.tgenabled in ('O', 'A')
329
+ and n.nspname = ${schema} and src.relkind = 'r'
330
+ order by src.relname, t.tgname
331
+ `);
332
+
333
+ return buildSchema(schema, excluded, columns, indexes, foreignKeys, checks, triggers);
301
334
  }
302
335
 
303
336
  /** Pure, so the row -> description mapping is testable without a database. */
@@ -308,6 +341,8 @@ export function buildSchema(
308
341
  indexes: readonly IndexRow[],
309
342
  foreignKeys: readonly ForeignKeyRow[],
310
343
  checks: readonly CheckRow[] = [],
344
+ /** Absent leaves `triggerNames` off every table: nobody asked the catalog. */
345
+ triggers?: readonly TriggerNameRow[],
311
346
  ): SchemaDescription {
312
347
  const names = [...new Set(columns.map((row) => row.table_name))]
313
348
  .filter((name) => !excluded.includes(name))
@@ -359,6 +394,14 @@ export function buildSchema(
359
394
  .filter((row) => row.table_name === name)
360
395
  .map((row) => row.constraint_name)
361
396
  .sort(),
397
+ ...(triggers === undefined
398
+ ? {}
399
+ : {
400
+ triggerNames: triggers
401
+ .filter((row) => row.table_name === name)
402
+ .map((row) => row.trigger_name)
403
+ .sort(),
404
+ }),
362
405
  };
363
406
  });
364
407
 
@@ -0,0 +1,44 @@
1
+ // Single responsibility: recognise a ledger an OLDER build is being rolled back onto — rows the
2
+ // newer build applied, every one of them newer than anything this build ships — so the migrate
3
+ // role lets a rollback through instead of failing the deploy that is trying to undo a bad one.
4
+
5
+ import type { LedgerRow, Migration } from './migration-ledger';
6
+ import { pendingMigrations } from './migration-ledger';
7
+
8
+ /** A rollback: the rows this build does not know (all newer), and the ledger it does. */
9
+ export interface LedgerAhead {
10
+ /** The rows a newer build applied, oldest first. Never empty. */
11
+ readonly ahead: readonly LedgerRow[];
12
+ /** Every other row — the part this build's own audit still runs over. */
13
+ readonly known: readonly LedgerRow[];
14
+ }
15
+
16
+ /**
17
+ * The ledger split, when it is exactly a rollback, else `undefined` and the caller's audit refuses
18
+ * the unknown rows as it always has. Three conditions, all required:
19
+ *
20
+ * - every row this build does not ship sorts AFTER the newest migration it does ship — the ids are
21
+ * `<stamp>_<name>`, the order `pendingMigrations` applies them in. One unknown row between two
22
+ * shipped ones is a deleted or renamed migration, not a newer build's work;
23
+ * - this build has nothing left to apply. A pending migration beside newer rows means the database
24
+ * never held this build's schema: two branches, not a rollback;
25
+ * - the build ships at least one migration. An image that lost its migrations directory is not
26
+ * "older than every row", and reading it as one would wave through the image that is broken.
27
+ *
28
+ * Nothing is applied either way: the schema the newer build left is additive over this one when
29
+ * the release followed expand/contract (docs/ops/06-runbooks.md), and when it did not, no
30
+ * migration this build ships could put it back.
31
+ */
32
+ export function ledgerAheadOfBuild(
33
+ ledger: readonly LedgerRow[],
34
+ migrations: readonly Migration[],
35
+ ): LedgerAhead | undefined {
36
+ if (migrations.length === 0) return undefined;
37
+ const shipped = new Set(migrations.map((migration) => migration.id));
38
+ const newest = [...shipped].sort().at(-1) ?? '';
39
+ const unknown = ledger.filter((row) => !shipped.has(row.id));
40
+ if (unknown.length === 0 || unknown.some((row) => row.id <= newest)) return undefined;
41
+ const known = ledger.filter((row) => shipped.has(row.id));
42
+ if (pendingMigrations(known, migrations).length > 0) return undefined;
43
+ return { ahead: [...unknown].sort((a, b) => (a.id < b.id ? -1 : 1)), known };
44
+ }