@ultimat3/db 23.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.
Files changed (46) hide show
  1. package/CLAUDE.md +83 -85
  2. package/README.md +88 -27
  3. package/package.json +3 -3
  4. package/src/array-parameter.ts +38 -1
  5. package/src/bound-parameters.ts +22 -3
  6. package/src/catalog-fold.ts +4 -1
  7. package/src/catalog-objects.ts +29 -0
  8. package/src/catalog.ts +10 -2
  9. package/src/client.ts +2 -2
  10. package/src/column-alter.ts +9 -3
  11. package/src/commit-tag.ts +21 -0
  12. package/src/default-client.ts +4 -4
  13. package/src/dependent-view.ts +7 -5
  14. package/src/destructive.ts +1 -1
  15. package/src/drift-append-only.ts +53 -0
  16. package/src/drift-errors.ts +3 -3
  17. package/src/drift-findings.ts +211 -59
  18. package/src/drift.ts +33 -19
  19. package/src/entity-shape.ts +5 -0
  20. package/src/errors.ts +12 -8
  21. package/src/fake.ts +1 -1
  22. package/src/foreign-key.ts +0 -34
  23. package/src/generate-append-only.ts +146 -0
  24. package/src/generate.ts +20 -6
  25. package/src/index.ts +11 -9
  26. package/src/introspect-catalog.ts +19 -2
  27. package/src/introspect.ts +92 -12
  28. package/src/migrate-rollback.ts +44 -0
  29. package/src/migrate.ts +48 -158
  30. package/src/migration-ledger.ts +167 -0
  31. package/src/object-drift.ts +77 -20
  32. package/src/pglite-branch.ts +7 -7
  33. package/src/pglite.ts +16 -4
  34. package/src/pool-profile.ts +1 -1
  35. package/src/primary-key.ts +210 -0
  36. package/src/schema-dump-table.ts +4 -1
  37. package/src/sibling-turn.ts +49 -0
  38. package/src/snapshot-parse.ts +9 -3
  39. package/src/sqlstate.ts +30 -10
  40. package/src/statement-funnel.ts +16 -5
  41. package/src/transaction-errors.ts +66 -0
  42. package/src/transaction-options.ts +122 -0
  43. package/src/transaction.ts +131 -121
  44. package/src/drift-fixtures.ts +0 -23
  45. package/src/fake-pglite.ts +0 -32
  46. package/src/fake-reservable.ts +0 -50
@@ -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,6 +24,7 @@ import {
23
24
  } from './introspect';
24
25
  import { declaredIndexes } from './invariant-ddl';
25
26
  import { migrationIrreversible } from './migration-errors';
27
+ import { addChangedKey, dropChangedKey, migrationNameArg } from './primary-key';
26
28
  import type { ReplicaIdentityInput } from './replica-identity';
27
29
  import { replicaIdentityFullAfter, replicaIdentityPlan } from './replica-identity';
28
30
  import type { MovedAside } from './retype-dependents';
@@ -111,6 +113,8 @@ export function snapshotOf(
111
113
  // The same rule once more: `true` or absent, never `false`. This is what makes the ALTER
112
114
  // beside it a one-time statement rather than a line every `x db gen` writes again.
113
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 } : {}),
114
118
  };
115
119
  });
116
120
  return { tables };
@@ -198,7 +202,7 @@ function diffTable(
198
202
  } else {
199
203
  // After any retype, so a new default is set against the column's new type. A rebuilt
200
204
  // column was re-added carrying its whole clause, so it has nothing left to move.
201
- alterColumnInPlace(entity.table, column, recorded, plan);
205
+ alterColumnInPlace(entity, column, recorded, plan);
202
206
  }
203
207
  continue;
204
208
  }
@@ -211,6 +215,9 @@ function diffTable(
211
215
  // one statement — measured. Emitting it nullable would leave a `-- backfill` comment naming a
212
216
  // step nobody can perform, since a generated column cannot be written to.
213
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');
214
221
  const clause = nullable ? columnClause({ ...column, notNull: false }) : columnClause(column);
215
222
  plan.up.push(`alter table ${identifier(entity.table).text} add column ${clause};`);
216
223
  if (nullable) {
@@ -324,6 +331,9 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
324
331
  created.add(entity.table);
325
332
  continue;
326
333
  }
334
+ // The key first and last, around every column statement of the table (`primary-key.ts`): the
335
+ // snapshot below records `entity.primaryKey`, and until this arm existed nothing produced it.
336
+ dropChangedKey(entity, live, current, plan, options.name);
327
337
  diffTable(entity, live, plan, retypedIn(retyped, entity.table));
328
338
  const kept = new Set(entity.columns.map((column) => column.column));
329
339
  for (const column of live.columns) {
@@ -331,7 +341,7 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
331
341
  if (options.allowDestructive !== true) {
332
342
  throw migrationIrreversible(
333
343
  `dropping "${entity.table}"."${column.name}" discards its rows and cannot be undone`,
334
- `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`,
335
345
  );
336
346
  }
337
347
  plan.up.push(
@@ -343,6 +353,7 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
343
353
  ' -- data is not restored',
344
354
  );
345
355
  }
356
+ addChangedKey(entity, live, plan, options.name);
346
357
  }
347
358
 
348
359
  const order = dropOrder(current.tables.filter((table) => !wanted.has(table.name)));
@@ -350,7 +361,7 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
350
361
  if (options.allowDestructive !== true) {
351
362
  throw migrationIrreversible(
352
363
  `dropping table "${table.name}" discards every row and cannot be undone`,
353
- `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`,
354
365
  );
355
366
  }
356
367
  }
@@ -368,6 +379,9 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
368
379
  plan.up.push(...constraints.up);
369
380
  plan.down.push(...constraints.down);
370
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
+
371
385
  // Dead last in `up`, and it is the only placement that is right for every arm: the table has to
372
386
  // exist, and a `create table` in this same migration is the reason it might not. It is ordered
373
387
  // against nothing else — replica identity constrains no column, no index and no constraint — so
@@ -383,7 +397,7 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
383
397
  const id = `${migrationStamp(options.now ?? systemClock.now())}_${slugify(options.name)}`;
384
398
  // At the TOP of `up`, so what is MISSING is the first thing read — and a line comment, so it is
385
399
  // noise to every reader that matters: `statementsOf` drops a chunk of comments alone,
386
- // `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.
387
401
  //
388
402
  // Never onto an EMPTY diff. `@ultimat3/cli`'s `generateAppMigration` reads `up.trim().length` as
389
403
  // "nothing changed" and re-records the hash sidecar instead of writing a file; a comment there
@@ -406,7 +420,7 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
406
420
  // ends have been retyped back, which is every other statement in the script.
407
421
  down: [...preAlters.down, ...plan.down].reverse().join('\n'),
408
422
  snapshot: snapshotOf(options.entities, replicaIdentityFullAfter(replicaIdentity)),
409
- destructive: isDestructive(up),
423
+ destructive: isDestructiveMigration(up),
410
424
  unrendered,
411
425
  };
412
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,
@@ -72,7 +72,6 @@ export {
72
72
  DB_ERROR_RETRY,
73
73
  DB_ERROR_TITLES,
74
74
  DbError,
75
- dbNotImplemented,
76
75
  dbUnavailable,
77
76
  driverError,
78
77
  identifierUnsafe,
@@ -84,7 +83,7 @@ export {
84
83
  } from './errors';
85
84
  export { expectedQueryLoop, expectedQueryLoopReason } from './expected-loop';
86
85
  export type { RecordedStatement, RecordingClient, StubResponse } from './fake';
87
- export { createRecordingClient } from './fake';
86
+ export { recordingClient } from './fake';
88
87
  export type { GeneratedMigration, GenerateOptions } from './generate';
89
88
  export { generateMigration, migrationStamp, slugify, snapshotOf } from './generate';
90
89
  export type { IndexMethod } from './index-method';
@@ -104,7 +103,7 @@ export type {
104
103
  SchemaDescription,
105
104
  TableDescription,
106
105
  } from './introspect';
107
- export { buildSchema, findTable, introspect } from './introspect';
106
+ export { buildSchema, findTable, introspectSchema } from './introspect';
108
107
  export {
109
108
  constraintNameFor,
110
109
  declaredIndexes,
@@ -158,12 +157,12 @@ export type {
158
157
  PgliteResult,
159
158
  } from './pglite';
160
159
  export {
161
- createPgliteClient,
162
160
  loadPgliteDriver,
163
161
  PGLITE_FIX,
164
162
  PGLITE_MEMORY,
165
163
  PGLITE_MISSING,
166
164
  PGLITE_PACKAGE,
165
+ pgliteClient,
167
166
  pgliteDataDir,
168
167
  } from './pglite';
169
168
  export type { PgliteBranchInfo, PgliteBranchOptions } from './pglite-branch';
@@ -185,6 +184,7 @@ export { BREAKER_COOLDOWN_MS, BREAKER_FAILURES, replicatedClient } from './repli
185
184
  export { type DbNode, isPlainRead } from './replica-route';
186
185
  export type { ReplicaScope } from './replica-scope';
187
186
  export { markScopeWrote, replicaScope, withReplicaReads } from './replica-scope';
187
+ export { SIBLING_SCOPE_WAIT_MS } from './sibling-turn';
188
188
  export { snapshotJson } from './snapshot-json';
189
189
  export { parseSnapshot } from './snapshot-parse';
190
190
  export type { SqlFragment } from './sql';
@@ -203,8 +203,10 @@ export { DB_SQLSTATE_CODES, isRetryableState, SQLSTATE, sqlState, sqlStateCode }
203
203
  export { statementFingerprint, statementKind, statementVerb } from './statement-shape';
204
204
  export { STATEMENT_ATTRIBUTE } from './statement-span';
205
205
  export { statementsOf } from './statement-split';
206
- export type { DbTx, IsolationLevel, TransactionOptions } from './transaction';
207
- export { beginStatement, currentTx, withTransaction } from './transaction';
206
+ export { currentTx, liveTxConnection, withTransaction } from './transaction';
207
+ export { commitUnknown, siblingScopeTimeout, transactionAborted } from './transaction-errors';
208
+ export type { DbTx, IsolationLevel, TransactionOptions } from './transaction-options';
209
+ export { beginStatement } from './transaction-options';
208
210
  export type { GeneratableForm } from './ungeneratable';
209
211
  export { GENERATABLE_FORMS, ungeneratableStatements } from './ungeneratable';
210
212
  export type { UnrenderedDeclaration } from './unrendered';
@@ -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
 
@@ -79,6 +79,12 @@ export async function introspectCatalog(
79
79
  const unrendered = await unrenderedRows(client, schema);
80
80
  const known = new Set(tables.map((table) => table.name));
81
81
  const materialized = new Set(views.filter((view) => view.kind === 'm').map((view) => view.name));
82
+ // A trigger loads only onto a relation the dump creates: a plain table, or a view (`instead
83
+ // of`). One on a partitioned table or a partition was filed under `09_triggers/` for a table no
84
+ // file creates, and the load refused the whole dump with `X_SCHEMA_DUMP_DRIFT`.
85
+ const viewNames = new Set(views.map((view) => view.name));
86
+ const loadable = (trigger: { readonly table_name: string }): boolean =>
87
+ known.has(trigger.table_name) || viewNames.has(trigger.table_name);
82
88
 
83
89
  return {
84
90
  schema,
@@ -129,6 +135,7 @@ export async function introspectCatalog(
129
135
  ),
130
136
  ),
131
137
  triggers: triggers
138
+ .filter(loadable)
132
139
  .map((row) => ({
133
140
  table: row.table_name,
134
141
  name: row.name,
@@ -141,7 +148,17 @@ export async function introspectCatalog(
141
148
  (trigger) => trigger.name,
142
149
  ),
143
150
  ),
144
- unrendered: unrendered
151
+ unrendered: [
152
+ ...unrendered,
153
+ // Named rather than dropped, the rule `CatalogUnrendered` states.
154
+ ...triggers
155
+ .filter((trigger) => !loadable(trigger))
156
+ .map((trigger) => ({
157
+ kind: 'trigger',
158
+ name: trigger.name,
159
+ table_name: trigger.table_name,
160
+ })),
161
+ ]
145
162
  .map((row) => ({ kind: row.kind, name: row.name, table: row.table_name }))
146
163
  .sort(
147
164
  by(
package/src/introspect.ts CHANGED
@@ -27,7 +27,11 @@ export interface ColumnDescription {
27
27
 
28
28
  export interface IndexDescription {
29
29
  readonly name: string;
30
- /** Physical columns in **index key order** — the order the planner sorts by, never `attnum`. */
30
+ /**
31
+ * Key columns in **index key order** — the order the planner sorts by, never `attnum`. An
32
+ * EXPRESSION key read from the catalog is its definition in parentheses (`(lower(title))`), in
33
+ * its own position: marked, so it can never be taken for a column, and never dropped.
34
+ */
31
35
  readonly columns: readonly string[];
32
36
  readonly unique: boolean;
33
37
  readonly primary: boolean;
@@ -100,7 +104,7 @@ export interface TableDescription {
100
104
  * handed a catalog value by accident.
101
105
  *
102
106
  * `snapshotOf` never writes it and `parseSnapshot` never reads it, so a sidecar carries `checks`
103
- * alone. `introspect()` always answers with it, `[]` included: absent therefore means "nobody
107
+ * alone. `introspectSchema()` always answers with it, `[]` included: absent therefore means "nobody
104
108
  * asked the catalog", which is what keeps `compareChecks` silent on a description that never
105
109
  * read one instead of reporting every declared constraint as missing.
106
110
  */
@@ -115,12 +119,25 @@ export interface TableDescription {
115
119
  * table an app has would rewrite every sidecar in the tree on the next `x db gen` for a fact that
116
120
  * was already true — the argument `IndexDescription.using` makes about `btree`.
117
121
  *
118
- * `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
119
123
  * `@ultimat3/realtime`'s preflight already reads at the only moment it matters; a second reader
120
124
  * here would let a `diffSchema` compare a declaration against a catalog value, which is the
121
125
  * mistake `checks` and `checkNames` exist as two fields to prevent.
122
126
  */
123
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;
124
141
  }
125
142
 
126
143
  export interface SchemaDescription {
@@ -175,9 +192,17 @@ interface CheckRow {
175
192
  readonly constraint_name: string;
176
193
  }
177
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
+
178
201
  const byName = (a: { name: string }, b: { name: string }): number => (a.name < b.name ? -1 : 1);
179
202
 
180
- export async function introspect(options: IntrospectOptions = {}): Promise<SchemaDescription> {
203
+ export async function introspectSchema(
204
+ options: IntrospectOptions = {},
205
+ ): Promise<SchemaDescription> {
181
206
  const client = options.client ?? db();
182
207
  const schema = options.schema ?? 'public';
183
208
  // Asked first, and unconditionally: everything below reads `information_schema`, which admits a
@@ -188,17 +213,45 @@ export async function introspect(options: IntrospectOptions = {}): Promise<Schem
188
213
  ...(await nonAppRelations(client, schema)),
189
214
  ];
190
215
 
216
+ // The type is `format_type`, as `catalog-relations.ts` reads it: `information_schema.data_type`
217
+ // answers `numeric` for `numeric(12,2)`, `ARRAY` for `text[]` and `USER-DEFINED` for an enum, and
218
+ // `ColumnDescription.dataType` has always been documented as the first of each pair. Still FROM
219
+ // the view, which decides which columns this role may see.
191
220
  const columns = await client.query<ColumnRow>(sql`
192
- select table_name, column_name, data_type, is_nullable, column_default, ordinal_position
193
- from information_schema.columns
194
- where table_schema = ${schema}
195
- order by table_name, ordinal_position
221
+ select
222
+ c.table_name,
223
+ c.column_name,
224
+ coalesce(
225
+ (
226
+ select format_type(a.atttypid, a.atttypmod)
227
+ from pg_attribute a
228
+ join pg_class r on r.oid = a.attrelid
229
+ join pg_namespace n on n.oid = r.relnamespace
230
+ where n.nspname = c.table_schema
231
+ and r.relname = c.table_name
232
+ and a.attname = c.column_name
233
+ and a.attnum > 0
234
+ and not a.attisdropped
235
+ ),
236
+ c.data_type
237
+ ) as data_type,
238
+ c.is_nullable,
239
+ c.column_default,
240
+ c.ordinal_position
241
+ from information_schema.columns c
242
+ where c.table_schema = ${schema}
243
+ order by c.table_name, c.ordinal_position
196
244
  `);
197
245
 
198
246
  // Ordered by the index's own key position, never by `attnum`: `indkey` IS the order the planner
199
247
  // sorts by, and a composite index on `(created_at, org_id)` whose columns were declared the
200
248
  // other way round came back reversed — a description that reads correct and compares wrong.
201
249
  // `indnkeyatts` drops INCLUDE payload columns, which are stored, not keyed.
250
+ //
251
+ // A LEFT join on `pg_attribute`: an expression key has `attnum = 0` and no attribute row, so an
252
+ // inner join dropped it and `(id, lower(title))` read back as `(id)` — an index rebuilt by hand
253
+ // with an extra expression key compared equal to the declared one. It is kept in its position
254
+ // and MARKED by its parentheses, which no declared column name carries.
202
255
  const indexes = await client.query<IndexRow>(sql`
203
256
  select
204
257
  t.relname as table_name,
@@ -207,7 +260,10 @@ export async function introspect(options: IntrospectOptions = {}): Promise<Schem
207
260
  ix.indisprimary as is_primary,
208
261
  pg_get_expr(ix.indpred, ix.indrelid) as predicate,
209
262
  am.amname as method,
210
- array_agg(a.attname order by k.ord) as columns,
263
+ array_agg(
264
+ coalesce(a.attname::text, '(' || pg_get_indexdef(ix.indexrelid, k.ord::int, false) || ')')
265
+ order by k.ord
266
+ ) as columns,
211
267
  bool_and((ix.indoption[k.ord - 1] & 1) = 1) as descending
212
268
  from pg_class t
213
269
  join pg_namespace n on n.oid = t.relnamespace
@@ -215,9 +271,11 @@ export async function introspect(options: IntrospectOptions = {}): Promise<Schem
215
271
  join pg_class i on i.oid = ix.indexrelid
216
272
  join pg_am am on am.oid = i.relam
217
273
  cross join lateral unnest(ix.indkey::smallint[]) with ordinality as k(attnum, ord)
218
- join pg_attribute a on a.attrelid = t.oid and a.attnum = k.attnum
274
+ left join pg_attribute a on a.attrelid = t.oid and a.attnum = k.attnum and k.attnum > 0
219
275
  where n.nspname = ${schema} and t.relkind = 'r' and k.ord <= ix.indnkeyatts
220
- group by t.relname, i.relname, ix.indisunique, ix.indisprimary, ix.indpred, ix.indrelid, am.amname
276
+ group by
277
+ t.relname, i.relname, ix.indisunique, ix.indisprimary, ix.indpred, ix.indrelid,
278
+ ix.indexrelid, am.amname
221
279
  order by t.relname, i.relname
222
280
  `);
223
281
 
@@ -260,7 +318,19 @@ export async function introspect(options: IntrospectOptions = {}): Promise<Schem
260
318
  order by src.relname, c.conname
261
319
  `);
262
320
 
263
- 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);
264
334
  }
265
335
 
266
336
  /** Pure, so the row -> description mapping is testable without a database. */
@@ -271,6 +341,8 @@ export function buildSchema(
271
341
  indexes: readonly IndexRow[],
272
342
  foreignKeys: readonly ForeignKeyRow[],
273
343
  checks: readonly CheckRow[] = [],
344
+ /** Absent leaves `triggerNames` off every table: nobody asked the catalog. */
345
+ triggers?: readonly TriggerNameRow[],
274
346
  ): SchemaDescription {
275
347
  const names = [...new Set(columns.map((row) => row.table_name))]
276
348
  .filter((name) => !excluded.includes(name))
@@ -322,6 +394,14 @@ export function buildSchema(
322
394
  .filter((row) => row.table_name === name)
323
395
  .map((row) => row.constraint_name)
324
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
+ }),
325
405
  };
326
406
  });
327
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
+ }