@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
@@ -28,18 +28,45 @@ import { assert } from '@ultimat3/core';
28
28
  * backslash, or leading/trailing whitespace the parser would strip — plus the empty string, which
29
29
  * unquoted is not an element at all.
30
30
  */
31
+ const hexOf = (bytes: Uint8Array): string =>
32
+ Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
33
+
34
+ /**
35
+ * A `Date` as the instant Postgres reads, or `X_INVARIANT` for one that holds no instant.
36
+ * `toISOString()` on an Invalid Date is a bare `RangeError`, and inside the funnel that became
37
+ * "cannot reach the database". `position` names the parameter when the caller knows it.
38
+ */
39
+ export function instantText(value: Date, position?: number): string {
40
+ assert(
41
+ !Number.isNaN(value.getTime()),
42
+ `${position === undefined ? 'an array element' : `parameter $${position}`} is an Invalid Date, which names no instant Postgres could store`,
43
+ 'new Date(input) answers Invalid Date for text it cannot parse — parse it with t.date first, or bind null: Number.isNaN(value.getTime()) ? null : value',
44
+ );
45
+ return value.toISOString();
46
+ }
47
+
31
48
  function element(value: unknown): string {
32
49
  if (value === null || value === undefined) return 'NULL';
33
50
  // A Date is ALWAYS quoted, even though an ISO-8601 instant carries no character the grammar
34
51
  // reads as structure. A timestamp element is conventionally quoted, and the alternative is a
35
52
  // rule that holds only while nothing ever renders a timestamp with a space in it.
36
- if (value instanceof Date) return `"${value.toISOString()}"`;
53
+ if (value instanceof Date) return `"${instantText(value)}"`;
54
+ // BYTEA's hex form, never `String(bytes)` — that is `1,2,3`, three elements where one was bound,
55
+ // and no error anywhere. The backslash is doubled because a quoted element reads `\\` as one.
56
+ if (value instanceof Uint8Array) return `"\\\\x${hexOf(value)}"`;
37
57
  const text = String(value);
38
58
  const structural = /[{},"\\\s]/.test(text) || text.length === 0 || text.toUpperCase() === 'NULL';
39
59
  if (!structural) return text;
40
60
  return `"${text.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
41
61
  }
42
62
 
63
+ /** The extents along a value's FIRST path, `2x3` for two rows of three — `''` for a scalar. */
64
+ function extentsOf(value: unknown): string {
65
+ if (!Array.isArray(value)) return '';
66
+ const inner = extentsOf(value[0]);
67
+ return inner === '' ? String(value.length) : `${value.length}x${inner}`;
68
+ }
69
+
43
70
  /**
44
71
  * The array literal for one parameter: `{a,b,c}`, elements escaped.
45
72
  *
@@ -71,5 +98,15 @@ export function pgArrayLiteral(values: readonly unknown[]): string {
71
98
  `a nested array parameter is ragged — its rows are ${nested.map((row) => row.length).join(', ')} long, and Postgres has no jagged array`,
72
99
  'give every row the same length, or bind one array per row',
73
100
  );
101
+ // Every extent, not only this level's: `[[['a']], [['b', 'c']]]` is two rows of one element each,
102
+ // each rectangular on its own, and `{{{a}},{{b,c}}}` is the same 22P02 — measured on 17. Each
103
+ // row is checked against itself by the recursion below, so comparing one path's extents per row
104
+ // is comparing all of them.
105
+ const depth = extentsOf(nested[0]);
106
+ assert(
107
+ nested.every((row) => extentsOf(row) === depth),
108
+ `a nested array parameter is ragged below its first level — its rows have the extents ${nested.map((row) => extentsOf(row)).join(' | ')}, and Postgres has no jagged array`,
109
+ 'give every branch the same length at every depth, or bind one array per row',
110
+ );
74
111
  return `{${values.map((value) => (Array.isArray(value) ? pgArrayLiteral(value) : element(value))).join(',')}}`;
75
112
  }
@@ -4,7 +4,7 @@
4
4
  // described type to go by the driver sent `Date.prototype.toString()`, a local-zone string
5
5
  // Postgres refuses (`22007`), so every entity write carrying a timestamp failed.
6
6
 
7
- import { pgArrayLiteral } from './array-parameter';
7
+ import { instantText, pgArrayLiteral } from './array-parameter';
8
8
 
9
9
  const needsEncoding = (value: unknown): boolean => Array.isArray(value) || value instanceof Date;
10
10
 
@@ -12,12 +12,31 @@ const needsEncoding = (value: unknown): boolean => Array.isArray(value) || value
12
12
  * A NEW ARRAY ONLY WHEN SOMETHING CHANGED — every statement in the process passes through here, so
13
13
  * the common path is one `some` and the caller's own array, byte for byte (axiom 6). A
14
14
  * `Uint8Array` is BYTEA, never an array: `Array.isArray` answers `false` for a typed array.
15
+ *
16
+ * It REFUSES what cannot be sent — a ragged array, an Invalid Date — with `X_INVARIANT`, so the
17
+ * funnel calls it BEFORE the driver's `try`: inside it, a refusal was re-wrapped as a driver
18
+ * failure and read "cannot reach the database".
15
19
  */
20
+ /**
21
+ * The refusals alone, for a driver that does its own encoding. PGlite renders an array and a
22
+ * `Date` correctly, so `pglite.ts` sends the caller's values untouched — but what cannot be sent
23
+ * must be refused alike on both funnels: an Invalid Date (its serializer answers a bare
24
+ * `RangeError`) and a ragged or mixed-depth array, which the pooled path refuses in
25
+ * `pgArrayLiteral`. That function IS the shape rule, so it is asked and its literal discarded
26
+ * rather than restated here; only a statement that binds an array pays for it.
27
+ */
28
+ export function refuseUnsendable(values: readonly unknown[]): void {
29
+ for (const [index, value] of values.entries()) {
30
+ if (value instanceof Date) instantText(value, index + 1);
31
+ else if (Array.isArray(value)) pgArrayLiteral(value);
32
+ }
33
+ }
34
+
16
35
  export function encodeBoundParameters(values: readonly unknown[]): readonly unknown[] {
17
36
  if (!values.some(needsEncoding)) return values;
18
- return values.map((value) => {
37
+ return values.map((value, index) => {
19
38
  if (Array.isArray(value)) return pgArrayLiteral(value);
20
- if (value instanceof Date) return value.toISOString();
39
+ if (value instanceof Date) return instantText(value, index + 1);
21
40
  return value;
22
41
  });
23
42
  }
@@ -51,7 +51,10 @@ function columnOf(row: ColumnRow, sequences: readonly SequenceRow[]): CatalogCol
51
51
  type: row.type,
52
52
  notNull: row.not_null,
53
53
  default: generated ? null : row.expression,
54
- generated: generated ? row.expression : null,
54
+ generated:
55
+ generated && row.expression !== null
56
+ ? { expression: row.expression, storage: row.generated === 'v' ? 'virtual' : 'stored' }
57
+ : null,
55
58
  identity:
56
59
  identity === undefined
57
60
  ? null
@@ -148,6 +148,15 @@ export const triggerRows = (client: DbClient, schema: string): Promise<readonly
148
148
  * What exists in the schema and the dump cannot spell. One query, one closed list — a kind added
149
149
  * here is a kind the dump admits it does not carry, and a kind rendered later leaves this list in
150
150
  * the same diff.
151
+ *
152
+ * The last four are facts ABOUT an object the dump does render, and each was absent from both the
153
+ * dump and this list: `create statistics`, `force row level security` (a second flag beside
154
+ * `relrowsecurity`), a column whose `set storage` departs from its type's own, and a materialized
155
+ * view created `with no data` — which the dump's `create materialized view` would populate. A
156
+ * load-equals-replay check cannot see any of them, because both sides are this same reading.
157
+ *
158
+ * A trigger on a relation the dump does not create is the one kind NOT read here: which relations
159
+ * are rendered is the fold's answer, so `introspectCatalog` names those itself.
151
160
  */
152
161
  export const unrenderedRows = (
153
162
  client: DbClient,
@@ -194,6 +203,26 @@ export const unrenderedRows = (
194
203
  from pg_rewrite r
195
204
  join pg_class c on c.oid = r.ev_class
196
205
  where r.rulename <> '_RETURN'
206
+ union all
207
+ select 'extended statistics', s.stxname, c.relname, s.stxnamespace
208
+ from pg_statistic_ext s
209
+ join pg_class c on c.oid = s.stxrelid
210
+ union all
211
+ select 'forced row security', c.relname, c.relname, c.relnamespace
212
+ from pg_class c
213
+ where c.relforcerowsecurity
214
+ union all
215
+ select 'column storage', a.attname, c.relname, c.relnamespace
216
+ from pg_attribute a
217
+ join pg_class c on c.oid = a.attrelid
218
+ join pg_type t on t.oid = a.atttypid
219
+ where c.relkind = 'r' and a.attnum > 0 and not a.attisdropped
220
+ and a.attstorage <> t.typstorage
221
+ and ${notExtensionOwned('pg_class', 'c.oid')}
222
+ union all
223
+ select 'unpopulated materialized view', c.relname, null::text, c.relnamespace
224
+ from pg_class c
225
+ where c.relkind = 'm' and not c.relispopulated
197
226
  ) objects
198
227
  join pg_namespace n on n.oid = objects.namespace
199
228
  where n.nspname = ${schema}
package/src/catalog.ts CHANGED
@@ -56,8 +56,16 @@ export interface CatalogColumn {
56
56
  readonly notNull: boolean;
57
57
  /** `pg_get_expr` of the default; `null` when there is none or the column is generated. */
58
58
  readonly default: string | null;
59
- /** The stored generation expression, when `attgenerated` says the column has one. */
60
- readonly generated: string | null;
59
+ /**
60
+ * The generation expression and HOW it is kept, when `attgenerated` says the column has one:
61
+ * `s` is `stored`, `v` (Postgres 18) is `virtual` — computed on read, nothing on disk. Both
62
+ * halves, because a dump that spelled every one `stored` loaded a virtual column as a stored
63
+ * one and round-tripped "equal": both sides of that comparison were this reading.
64
+ */
65
+ readonly generated: {
66
+ readonly expression: string;
67
+ readonly storage: 'stored' | 'virtual';
68
+ } | null;
61
69
  /** `always` / `by default`, with the identity sequence's own options. */
62
70
  readonly identity: {
63
71
  readonly mode: 'always' | 'by default';
package/src/client.ts CHANGED
@@ -69,7 +69,7 @@ export interface PostgresClient extends ReservableClient, ListeningClient {
69
69
  }
70
70
 
71
71
  /** Lazily connects: the pool opens on the first statement, never at import. */
72
- export function createPostgresClient(options: PostgresClientOptions = {}): PostgresClient {
72
+ export function postgresClient(options: PostgresClientOptions = {}): PostgresClient {
73
73
  const role = options.role ?? resolveRole();
74
74
  const profile: PoolProfile = assertPoolProfile({
75
75
  ...poolProfileFor(role),
@@ -247,7 +247,7 @@ export function setDbClient(client: DbClient | undefined): void {
247
247
  *
248
248
  * The role default is layered under `DATABASE_POOL_MAX`, because this is the one place the process
249
249
  * builds its own client and therefore the only place an operator's value can reach one:
250
- * `createPostgresClient` has always taken a `profile` override and nothing in a running app passed
250
+ * `postgresClient` has always taken a `profile` override and nothing in a running app passed
251
251
  * it, so `POOL_PROFILES` was the last word in a deployed image. `default-client.ts` owns what gets
252
252
  * built — one pool, or a primary and a replica when `DATABASE_REPLICA_URL` names one.
253
253
  */
@@ -6,8 +6,9 @@
6
6
 
7
7
  import { defaultExpression, hasUnrenderedDefault } from './column-default';
8
8
  import { columnDefaultUnsafe } from './ddl-errors';
9
- import type { ColumnDescriptionLike } from './entity-shape';
9
+ import type { ColumnDescriptionLike, EntityDescriptionLike } from './entity-shape';
10
10
  import type { Plan } from './foreign-key-plan';
11
+ import { appendOnlyBackfillRefused } from './generate-append-only';
11
12
  import type { ColumnDescription } from './introspect';
12
13
  import { identifier } from './sql';
13
14
  import { statementsOf } from './statement-split';
@@ -29,18 +30,20 @@ function screened(column: string, expression: string): string {
29
30
  * `set default` / `drop default` and `drop not null`, each with its reverse in `down` — the shape
30
31
  * `redefineIndex` (`index-ddl.ts`) gives a moved index. Becoming NOT NULL is deliberately NOT a
31
32
  * bare `set not null`: rows already holding `NULL` make it fail inside `ROLE=migrate`, so it gets
32
- * the expand/contract note `diffTable` writes for a NOT NULL column added to a populated table.
33
+ * the expand/contract note `diffTable` writes for a NOT NULL column added to a populated table —
34
+ * refused on an append-only table, whose backfill the trigger would refuse.
33
35
  *
34
36
  * A default this generator cannot render (`hasUnrenderedDefault`) moves nothing: dropping the one
35
37
  * the database holds would lose a rule the entity still states, and `unrenderedOf` already reports
36
38
  * it at the top of `up`.
37
39
  */
38
40
  export function alterColumnInPlace(
39
- table: string,
41
+ entity: EntityDescriptionLike,
40
42
  column: ColumnDescriptionLike,
41
43
  recorded: ColumnDescription,
42
44
  plan: Plan,
43
45
  ): void {
46
+ const { table } = entity;
44
47
  const alter = `alter table ${identifier(table).text} alter column ${identifier(column.column).text}`;
45
48
  const wanted = hasUnrenderedDefault(column) ? recorded.default : defaultExpression(column);
46
49
  const held = recorded.default;
@@ -59,6 +62,9 @@ export function alterColumnInPlace(
59
62
  plan.down.push(`${alter} set not null;`);
60
63
  return;
61
64
  }
65
+ // That backfill is an UPDATE, and an append-only table's trigger refuses every one. Refused even
66
+ // with a default now declared: `set default` fills no row that already holds NULL.
67
+ if (entity.appendOnly === true) throw appendOnlyBackfillRefused(entity, column, 'made-not-null');
62
68
  // `drop not null` in `down` is a no-op on a column that never became NOT NULL, so the reverse is
63
69
  // right whether or not the backfill and its `set not null` were ever run.
64
70
  plan.up.push(`-- backfill ${identifier(column.column).text}, then: ${alter} set not null;`);
@@ -0,0 +1,21 @@
1
+ // Single responsibility: the server's answer to `COMMIT`, read. Postgres answers a COMMIT on an
2
+ // aborted transaction with the command tag `ROLLBACK` and NO error, so a driver that reports only
3
+ // rejections calls a rolled-back unit of work committed. Both funnels (`statement-funnel.ts`,
4
+ // `pglite.ts`) ask here, so every COMMIT in the process is covered and not only `withTransaction`'s.
5
+
6
+ import { stringField } from '@ultimat3/core';
7
+ import { transactionAborted } from './transaction-errors';
8
+
9
+ /** `COMMIT` and its alias `END`, as the first word. Only consulted once the tag already disagrees. */
10
+ const COMMIT_STATEMENT = /^\s*(?:commit|end)\b/i;
11
+
12
+ /**
13
+ * Throws `X_DB_TRANSACTION_ABORTED` when `text` asked for a commit and the tag says `ROLLBACK`.
14
+ * The tag is read first: one property read per statement on the path every statement takes, and
15
+ * the regular expression runs only for a statement that really was answered `ROLLBACK`. Both
16
+ * drivers carry the tag on `command` — measured on Bun.SQL against Postgres 17 and on PGlite.
17
+ */
18
+ export function refuseRolledBackCommit(text: string, result: unknown): void {
19
+ if (stringField(result, 'command') !== 'ROLLBACK') return;
20
+ if (COMMIT_STATEMENT.test(text)) throw transactionAborted();
21
+ }
@@ -3,7 +3,7 @@
3
3
  // framework decides a process's database topology from the environment, and `client.ts` is at the
4
4
  // line ceiling.
5
5
 
6
- import { createPostgresClient, type DbClient } from './client';
6
+ import { type DbClient, postgresClient } from './client';
7
7
  import { poolMaxFromEnv } from './pool-profile';
8
8
  import { replicatedClient } from './replica-client';
9
9
 
@@ -18,7 +18,7 @@ import { replicatedClient } from './replica-client';
18
18
  export const REPLICA_URL_ENV = 'DATABASE_REPLICA_URL';
19
19
 
20
20
  /**
21
- * Composed rather than folded into `createPostgresClient`, on purpose: `migrate`, `x db branch` and
21
+ * Composed rather than folded into `postgresClient`, on purpose: `migrate`, `x db branch` and
22
22
  * every test build a client that must be exactly one pool, and a second pool reachable through the
23
23
  * same factory would be a second thing `reserve()`, `close()` and `ping()` each have to mean two
24
24
  * ways.
@@ -29,8 +29,8 @@ export const REPLICA_URL_ENV = 'DATABASE_REPLICA_URL';
29
29
  */
30
30
  export function defaultClient(): DbClient {
31
31
  const profile = poolMaxFromEnv();
32
- const primary = createPostgresClient({ profile });
32
+ const primary = postgresClient({ profile });
33
33
  const replicaUrl = process.env[REPLICA_URL_ENV];
34
34
  if (replicaUrl === undefined || replicaUrl.trim() === '') return primary;
35
- return replicatedClient(primary, createPostgresClient({ url: replicaUrl, profile }));
35
+ return replicatedClient(primary, postgresClient({ url: replicaUrl, profile }));
36
36
  }
@@ -2,7 +2,7 @@
2
2
  // before the statement is sent — and name the view, the column and the statement that recreates it.
3
3
  //
4
4
  // **This is the honest ceiling for views, and the reason it is not in the generator.** `x db gen`
5
- // runs with no database open; `SchemaDescription` has no field for a view; `introspect()` reads
5
+ // runs with no database open; `SchemaDescription` has no field for a view; `introspectSchema()` reads
6
6
  // none by construction (`app-relation.ts` excludes every non-table relation); and no `entity()` can
7
7
  // declare one. So nothing the generator reads knows a view exists, and a `GenerateOptions.views`
8
8
  // with no caller to fill it is the declared-and-never-wired defect this release exists to
@@ -139,6 +139,7 @@ async function dependentViews(
139
139
  join pg_attribute a on a.attrelid = c.oid and a.attnum = d.refobjsubid
140
140
  where v.relkind in ('v', 'm') and v.oid <> c.oid
141
141
  and c.relname in (${tables}) and a.attname in (${columns})
142
+ and pg_table_is_visible(c.oid)
142
143
  order by v.relname
143
144
  `);
144
145
  }
@@ -155,7 +156,8 @@ async function dependentViews(
155
156
  const shellArg = (statement: string): string => `'${statement.replaceAll("'", `'\\''`)}'`;
156
157
 
157
158
  /** The invocation `migrationConflict` already writes, with the statement as its own argv word. */
158
- const psql = (statement: string): string => `psql "$DATABASE_URL" -c ${shellArg(statement)}`;
159
+ export const psqlCommand = (statement: string): string =>
160
+ `psql "$DATABASE_URL" -c ${shellArg(statement)}`;
159
161
 
160
162
  /**
161
163
  * The two statements that unblock the deploy, as one line an operator pastes.
@@ -166,7 +168,7 @@ const psql = (statement: string): string => `psql "$DATABASE_URL" -c ${shellArg(
166
168
  * a shell read `drop` as a program that does not exist. Neither reader could run it (axiom 4).
167
169
  *
168
170
  * `identifier()` REFUSES a name holding a quote, a space or a backslash — all three legal inside a
169
- * quoted Postgres name — and a `fix:` may not throw: the rule `rebuildForeignKey` already states,
171
+ * quoted Postgres name — and a `fix:` may not throw: the rule `changedForeignKey` (`drift-findings.ts`) states,
170
172
  * with the same shape. A refusal that raised `X_SQL_UNSAFE` in place of the finding would hand the
171
173
  * operator an exception where a verdict was asked for, over a view name that is perfectly legal.
172
174
  * The fallback still leads with a command that runs — a psql session — because quoting that name
@@ -192,8 +194,8 @@ function restoreView(view: string, definition: string, relkind: string): string
192
194
  try {
193
195
  const name = identifier(view).text;
194
196
  return (
195
- `${psql(`drop ${kind} ${name}`)} # then x db migrate, then: ` +
196
- `${psql(`create ${kind} ${name} as ${body}`)}${note}`
197
+ `${psqlCommand(`drop ${kind} ${name}`)} # then x db migrate, then: ` +
198
+ `${psqlCommand(`create ${kind} ${name} as ${body}`)}${note}`
197
199
  );
198
200
  } catch {
199
201
  return (
@@ -112,4 +112,4 @@ export function destructiveStatements(up: string): readonly DestructiveStatement
112
112
  }
113
113
 
114
114
  /** Whether `up` destroys data at all — what `x db gen` writes the marker from. */
115
- export const isDestructive = (up: string): boolean => destructiveStatements(up).length > 0;
115
+ export const isDestructiveMigration = (up: string): boolean => destructiveStatements(up).length > 0;
@@ -0,0 +1,53 @@
1
+ // Single responsibility: whether an `appendOnly` table still carries the trigger that refuses UPDATE
2
+ // and DELETE, and what to say when it does not. The repository refuses above the driver whatever
3
+ // the database holds, so a dropped trigger is invisible to every test — only the catalog can say.
4
+
5
+ import { byHand, type DriftDifference, pathTo, repair } from './drift-findings';
6
+ import {
7
+ APPEND_ONLY_FUNCTION_SQL,
8
+ APPEND_ONLY_TRIGGER,
9
+ installAppendOnlyTriggerSql,
10
+ } from './generate-append-only';
11
+ import type { TableDescription } from './introspect';
12
+ import { shellInertIdentifier } from './sql';
13
+
14
+ /** The code `driftError` raises for this kind, exported so the tests assert the one literal. */
15
+ export const APPEND_ONLY_DRIFT_CODE = 'X_APPEND_ONLY_TRIGGER_MISSING';
16
+
17
+ /**
18
+ * The constructor. The fix is the two statements `x db gen` wrote, run against THIS database:
19
+ * the migration that added the trigger is already in the ledger, so `x db migrate` applies nothing
20
+ * (`repair`'s argument). Drop-if-exists before create, because a DISABLED trigger counts as missing
21
+ * here yet still exists, and a bare `create trigger` would fail on it. The function is redefined too — `create or replace` — because a hand that
22
+ * dropped the trigger may have dropped the function with it.
23
+ */
24
+ export function missingAppendOnlyTrigger(schema: string, table: string): DriftDifference {
25
+ const path = pathTo(schema);
26
+ const spellable = shellInertIdentifier(table) !== null;
27
+ return {
28
+ kind: 'missing-append-only-trigger',
29
+ table,
30
+ column: null,
31
+ cause:
32
+ `table "${table}" is declared appendOnly, and its trigger ${APPEND_ONLY_TRIGGER} is missing ` +
33
+ 'or disabled — raw SQL can UPDATE and DELETE its rows; only the repository still refuses',
34
+ fix:
35
+ path === null || !spellable
36
+ ? byHand('re-create the append-only function and trigger this difference names')
37
+ : repair(path, [APPEND_ONLY_FUNCTION_SQL, ...installAppendOnlyTriggerSql(table)].join(' ')),
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Judged only where both halves were asked: the snapshot declares the table append-only AND the
43
+ * catalog was read (`triggerNames` present). Either absent says nothing — a sidecar that predates
44
+ * the field, or a description nobody introspected — exactly as `compareChecks` reads its pair.
45
+ */
46
+ export function compareAppendOnly(
47
+ live: TableDescription,
48
+ expected: TableDescription,
49
+ ): DriftDifference[] {
50
+ if (expected.appendOnly !== true || live.triggerNames === undefined) return [];
51
+ if (live.triggerNames.includes(APPEND_ONLY_TRIGGER)) return [];
52
+ return [missingAppendOnlyTrigger(live.schema, live.name)];
53
+ }
@@ -10,9 +10,9 @@ import { DbError } from './errors';
10
10
  import { shellInertIdentifier } from './sql';
11
11
 
12
12
  /**
13
- * The contract's pinned wording. Mirror of `@ultimat3/entity`'s `dbDrift()` — keep in sync; that
14
- * one screens the column through the same `@ultimat3/db` export, so the two lines are the same
15
- * text on both sides of the tier seam.
13
+ * The contract's pinned wording, and the ONE definition of it: `@ultimat3/entity` carried a second
14
+ * `dbDrift()` held in step by a comment and a test, deleted `As of 2026-10-02`. Drift is this
15
+ * package's to raise.
16
16
  *
17
17
  * The column name is the CATALOG's, so it is data: whoever can add a column picks the text that
18
18
  * lands here, and `x db gen "add C"` puts it inside SHELL DOUBLE QUOTES, where `$(…)` and a