@ultimat3/db 2.0.0 → 3.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/CLAUDE.md CHANGED
@@ -11,6 +11,7 @@ reaches down to this package for it. **Never** import `entity`, `jobs`, `http` o
11
11
  | SQL | `sql` binds `$n`; anything non-scalar and non-fragment throws `X_SQL_UNSAFE` |
12
12
  | Escape hatches | `raw()`, `identifier()`, `literal()` — each call is an audit point |
13
13
  | SQLSTATE | one reader, `sqlState()` (`sqlstate.ts`). Never read `error.code` for a SQLSTATE |
14
+ | Reading a caught value | `renderThrowable()` from core; never `error instanceof Error ? error.message : String(error)` — both halves RUN app code (a `Proxy` trap, `Symbol.toPrimitive`) and `checkDb` backs `/readyz`, where a render that throws is an exception in place of the report the kubelet asked for |
14
15
  | Errors | subclass `DbError`; never `throw new Error` **in source**. A test simulating a *database* failure throws `dbUnavailable()`; a test simulating the *caller's body* failing throws a bare `Error` on purpose — an arbitrary throw is exactly what rollback and disposal must survive, and a `DbError` there would prove the narrower thing |
15
16
  | New code | add to `DB_ERROR_CODES` **and** `DB_ERROR_TITLES` in `errors.ts` |
16
17
  | Exports | explicit in `src/index.ts`; no `export *` |
@@ -318,6 +319,29 @@ block is the join of that fix with the engine it ships through — an entity des
318
319
  `generateMigration`, applied by `migrate()` itself against a real server, columns confirmed against
319
320
  `pg_indexes`, rather than either half alone.
320
321
 
322
+ **The ledger audit asks one question — does this build ship every migration the ledger records?**
323
+ `auditLedger`'s `foreign` filter is `!known.has(row.id)` and nothing else, `As of 2026-08`. It used
324
+ to also require `row.app_version !== appVersion`, which switched the audit OFF wherever the two
325
+ agree: `runningAppVersion()` answers `dev` for every development build, so a migration applied by an
326
+ earlier `dev` build and since deleted was invisible, and `expectedSchema` (`drift.ts`) then dropped
327
+ its table from the comparison — `x db drift` answering `ok: true` against a database that still has
328
+ the table. The version is a detail of the ANSWER and lives in the cause, never in the predicate.
329
+
330
+ **`rollback({ steps })` refuses anything that is not a positive safe integer, before the lock.**
331
+ `steps` reaches `slice(0, steps)`, where a negative count counts from the END: `steps: -1` selected
332
+ every applied migration but the newest and reversed four of five. `X_INVARIANT` (core's generic
333
+ code, borrowed in `DB_BORROWED_ERROR_CODES` the way `@ultimat3/money`'s `roundRatio` borrows it —
334
+ a bad argument is not a fact about the ledger), thrown by `rollbackStepsInvalid` before the advisory
335
+ lock is taken and before the ledger is read. Same discipline as `poolMaxInvalid`: a number this
336
+ build cannot honour is refused, never reinterpreted.
337
+
338
+ **`reapBranches` skips a `createdAt` it cannot parse; it never reads one as infinitely old.**
339
+ `NaN > cutoff` is `false`, which is the same answer "older than the cutoff" gives — so a
340
+ `COMMENT ON DATABASE` that was truncated or hand-edited used to be a database DROPPED on the next
341
+ nightly sweep whatever `maxAgeMs` said. `Date.parse` + `Number.isFinite`, the discipline
342
+ `@ultimat3/seo`'s `feed-dates.ts` applies to the same question. (Distinct from the open
343
+ source-blindness of the reaper, issue #133.)
344
+
321
345
  **One send is one statement, so `migrate()` and `rollback()` split the script.** `tx.execute(raw(
322
346
  migration.up))` on a text holding two commands is where the two drivers disagreed, and the
323
347
  disagreement is the whole reason this is a bug rather than a preference: `pglite.ts` calls
@@ -562,6 +586,20 @@ itself all reach the caller, and every caller must handle that. Layers 3–4 (pr
562
586
  policy) live in `@ultimat3/mcp`, which must still never import this package — the CLI wires the
563
587
  two together.
564
588
 
589
+ **`libpq-options.ts` merges the framework's `options` into the operator's, and `connectionUrl` may
590
+ not `set` that key again.** `DATABASE_URL` is the operator's file: `url.searchParams.set('options',
591
+ …)` REPLACED whatever they had written, and only on the roles whose `statementTimeoutMs` is
592
+ non-zero — so `?options=-c search_path=app` survived on `migrate` and `replicator` and was dropped
593
+ on `web`, `sync`, `worker` and `scheduler`, i.e. the role that runs the migrations and the role that
594
+ serves the traffic looked at different schemas with nothing reporting it. Precedence is **the
595
+ framework wins on the names it sets, the operator keeps every other flag**, and it is enforced by
596
+ removing those names from the operator's tokens before appending, never by position: "the last `-c`
597
+ wins" is backend argument-order behaviour nobody here measured. The bound is emitted for all six
598
+ roles including the two whose value is `0` — `0` is `migrate` saying it may take as long as it
599
+ takes, and left unsaid an `alter database … set statement_timeout` on the server kills the one role
600
+ that must outlive it. The splitter honours libpq's backslash escape, so a `search_path=two\ words`
601
+ survives the round trip whole.
602
+
565
603
  ```bash
566
604
  bun test # from packages/db
567
605
  bun run typecheck
package/README.md CHANGED
@@ -137,10 +137,12 @@ X_DB_DRIFT: schema differs from migrations
137
137
  | live table, no migration | `table "T" is not present in any migration` | `x db gen "add T"` |
138
138
  | migrated table, not live | `table "T" is declared by migrations but does not exist` | `x db migrate` |
139
139
 
140
- `checkDrift()` returns every difference; `assertNoDrift()` throws the first. `x db migrate` reports
141
- them and exits non-zero; a `ROLE=migrate` container logs them and still exits 0, because its
142
- contract is "apply every migration, then exit" and a schema difference after a clean apply is a
143
- diagnostic, not a failed migration.
140
+ `checkDrift()` returns every difference; `assertNoDrift()` throws the first. `x db migrate` renders
141
+ them all as findings and exits non-zero; a `ROLE=migrate` container throws the first one
142
+ (`assertNoDrift`, in `runRole`) and exits non-zero too, because the release phase has one channel
143
+ the exit code — and a deploy that rolled on past a schema nobody can reconstruct is the failure
144
+ drift exists to catch. There is no `x db drift`, and `x verify`'s `drift` step is the *source*
145
+ detector (`checkSourceDrift`), which needs no database and never calls this.
144
146
 
145
147
  ## The embedded database
146
148
 
@@ -198,6 +200,16 @@ does reach the backend: `client.live.test.ts` asserts `current_setting('statemen
198
200
  is the only reading a DSN test cannot fake. `Bun.SQL` is reached lazily, so importing this package
199
201
  never opens a socket.
200
202
 
203
+ **That setting is MERGED into the operator's own `options`, never assigned over them** (`As of
204
+ 2026-08`). `?options=-c search_path=app` in `DATABASE_URL` survives on every role, and the role's
205
+ `statement_timeout` is appended to it; if the URL sets `statement_timeout` itself, the **role
206
+ wins** — it is a bound the pool is sized around — and every other flag is kept. It is emitted for
207
+ all six roles, `migrate`'s and `replicator`'s `0` included: `0` is "this role may take as long as
208
+ it takes", and left unsaid a server-side `alter database … set statement_timeout` would kill the
209
+ one role that has to outlive it. Before this, `set` replaced the whole value and only on the roles
210
+ with a non-zero timeout, so a `search_path` survived on `migrate` and vanished on `web` — the role
211
+ that runs the migrations and the role that serves the traffic reading different schemas.
212
+
201
213
  **`DATABASE_POOL_MAX` overrides `max`** (`As of 2026-08`), and it is the only pool knob an operator
202
214
  can turn without shipping an image — 400 `web` pods × the frozen `max: 20` is 8,000 backends. A
203
215
  value that is not a positive integer refuses at boot rather than falling back.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/db",
3
- "version": "2.0.0",
3
+ "version": "3.0.0",
4
4
  "description": "Postgres access, transactions, migrations and drift detection",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,7 +31,7 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/core": "2.0.0"
34
+ "@ultimat3/core": "3.0.0"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "@electric-sql/pglite": ">=0.5.0"
package/src/branch.ts CHANGED
@@ -134,7 +134,17 @@ export async function reapBranches(options: ReapOptions): Promise<readonly strin
134
134
  const dropped: string[] = [];
135
135
  for (const branch of branches) {
136
136
  if (branch.createdAt === null) continue;
137
- if (new Date(branch.createdAt).getTime() > cutoff) continue;
137
+ const createdAtMs = Date.parse(branch.createdAt);
138
+ // `NaN > cutoff` is `false`, which is the same answer "older than the cutoff" gives — so a
139
+ // truncated or hand-edited comment used to be a database DROPPED on the next sweep, whatever
140
+ // `maxAgeMs` said. An age nothing can read is not an old age.
141
+ if (!Number.isFinite(createdAtMs)) continue;
142
+ // Finite is not enough: `'2026-08-18T10:00'` parses as LOCAL time, so a truncated comment
143
+ // names an instant hours from the one it reads as, and the sweep acts on a date nobody wrote.
144
+ // `createBranch` writes `toISOString()` and nothing else does, so a value that does not round
145
+ // trip through it is not ours — there is no legitimate non-canonical comment to strand.
146
+ if (new Date(createdAtMs).toISOString() !== branch.createdAt) continue;
147
+ if (createdAtMs > cutoff) continue;
138
148
  await dropBranch(branch.name, options);
139
149
  dropped.push(branch.name);
140
150
  }
package/src/client.ts CHANGED
@@ -3,10 +3,11 @@
3
3
  // pool like a `web` process behind a CDN. `Bun.SQL` is reached lazily so importing this module
4
4
  // never opens a socket (the CLI imports it to print help).
5
5
 
6
- import { type Role, resolveRole } from '@ultimat3/core';
6
+ import { type Role, renderThrowable, resolveRole } from '@ultimat3/core';
7
7
  import { statementAttribution } from './attribution';
8
8
  import { DbError, dbUnavailable, driverError, poolAcquireTimeout, poolMaxInvalid } from './errors';
9
9
  import { expectedQueryLoopReason } from './expected-loop';
10
+ import { mergeLibpqOptions } from './libpq-options';
10
11
  import { statementObserver } from './observe';
11
12
  import { type SqlFragment, sql } from './sql';
12
13
  import { withStatementSpan } from './statement-span';
@@ -172,10 +173,19 @@ function connectionUrl(options: PostgresClientOptions, profile: PoolProfile): st
172
173
  } catch (error) {
173
174
  throw dbUnavailable(`DATABASE_URL is not a valid url: ${raw}`, error);
174
175
  }
175
- // libpq `options` is the portable way to pin a statement timeout for every pooled connection.
176
- if (profile.statementTimeoutMs > 0) {
177
- url.searchParams.set('options', `-c statement_timeout=${profile.statementTimeoutMs}`);
178
- }
176
+ // libpq `options` is the portable way to pin a statement timeout for every pooled connection
177
+ // MERGED into the operator's own, never assigned over it, and emitted for every role including
178
+ // the two whose bound is 0. `set` here dropped a `?options=-c search_path=app` on `web`, `sync`,
179
+ // `worker` and `scheduler` and kept it on `migrate` and `replicator`, so the role that runs the
180
+ // migrations and the role that serves the traffic read different schemas. 0 is a value, not a
181
+ // silence: it is `migrate` saying it may take as long as it takes, and left unsaid a server-side
182
+ // `alter database ... set statement_timeout` kills the one role that must outlive it.
183
+ url.searchParams.set(
184
+ 'options',
185
+ mergeLibpqOptions(url.searchParams.get('options'), {
186
+ statement_timeout: String(profile.statementTimeoutMs),
187
+ }),
188
+ );
179
189
  url.searchParams.set('application_name', options.applicationName ?? 'ultimate');
180
190
  return url.toString();
181
191
  }
@@ -447,7 +457,9 @@ export async function checkDb(client: DbClient = baseClient()): Promise<DbHealth
447
457
  return {
448
458
  ok: false,
449
459
  latencyMs: Math.round(performance.now() - started),
450
- error: error instanceof Error ? error.message : String(error),
460
+ // `renderThrowable`, never `error.message`: the probe wants a report, and a render that
461
+ // throws is an exception out of `/readyz` — the one caller that cannot catch it.
462
+ error: renderThrowable(error),
451
463
  };
452
464
  }
453
465
  }
package/src/drift.ts CHANGED
@@ -294,7 +294,13 @@ export function driftError(difference: DriftDifference): DbError {
294
294
  });
295
295
  }
296
296
 
297
- /** Throws the first difference. `x verify` calls this; `x db drift --json` reads the report. */
297
+ /**
298
+ * Throws the first difference. The one caller is the release phase — `runRole` in `@ultimat3/cli`
299
+ * under `ROLE=migrate`, where the exit code is the only channel a container has. `x db migrate`
300
+ * and `x db reset` hold the same report and render every difference as a finding instead
301
+ * (`driftFindings`), and `x verify`'s `drift` step is the *source* detector (`checkSourceDrift`),
302
+ * which never reaches this function. There is no `x db drift` command.
303
+ */
298
304
  export function assertNoDrift(report: DriftReport): void {
299
305
  const first = report.differences[0];
300
306
  if (first !== undefined) throw driftError(first);
package/src/errors.ts CHANGED
@@ -32,8 +32,17 @@ export const DB_OWNED_ERROR_CODES = [
32
32
  * `@ultimat3/core`'s. Never titled here, never registered here. `X_ENV_MISSING` is core's word for
33
33
  * "a variable this process was given is missing or invalid", and `DATABASE_POOL_MAX` is one — a
34
34
  * db-local code for it would be a second answer to a question core already answers.
35
+ *
36
+ * `X_INVARIANT` is core's own "the generic code, for checks that have no dedicated code yet"
37
+ * (`assert()` in `core/src/assert.ts`), borrowed the same way `@ultimat3/money`'s `roundRatio`
38
+ * borrows it: an argument a caller built wrong is not a fact about the ledger or the schema, so
39
+ * none of the `X_MIGRATION_*` codes above describes one.
35
40
  */
36
- export const DB_BORROWED_ERROR_CODES = ['X_NOT_IMPLEMENTED', 'X_ENV_MISSING'] as const;
41
+ export const DB_BORROWED_ERROR_CODES = [
42
+ 'X_NOT_IMPLEMENTED',
43
+ 'X_ENV_MISSING',
44
+ 'X_INVARIANT',
45
+ ] as const;
37
46
 
38
47
  /** Every code db can throw: the ones it owns plus the ones it borrows. */
39
48
  export const DB_ERROR_CODES = [...DB_OWNED_ERROR_CODES, ...DB_BORROWED_ERROR_CODES] as const;
@@ -249,6 +258,21 @@ export const migrationConflict = (cause: string, fix: string): DbError =>
249
258
  export const migrationIrreversible = (cause: string, fix: string): DbError =>
250
259
  new DbError({ code: 'X_MIGRATION_IRREVERSIBLE', cause, fix });
251
260
 
261
+ /**
262
+ * A rollback step count this build cannot honour. `steps` reaches `Array.prototype.slice`, where a
263
+ * negative count counts from the END: `steps: -1` selected every applied migration except the
264
+ * newest and reversed four of five, which is the one class of mistake a rollback cannot undo.
265
+ * Refused rather than coerced, exactly as `DATABASE_POOL_MAX` is — a number silently reinterpreted
266
+ * as a different one is the failure a validated argument exists to prevent.
267
+ */
268
+ export const rollbackStepsInvalid = (received: number): DbError =>
269
+ new DbError({
270
+ code: 'X_INVARIANT',
271
+ cause: `rollback was asked to reverse ${String(received)} migrations, which is not a positive integer`,
272
+ fix: 'rollback({ migrations, steps: 1 }) # a whole number of migrations, newest first',
273
+ meta: { steps: received },
274
+ });
275
+
252
276
  /**
253
277
  * `packages/db/migrations/0000_initial.snapshot.json` → `packages/db/migrations/0000_initial.*` —
254
278
  * every file that one migration owns, as one `rm` argument. Derived from the path the caller passed
package/src/index.ts CHANGED
@@ -71,6 +71,7 @@ export {
71
71
  multipleStatements,
72
72
  poolAcquireTimeout,
73
73
  poolMaxInvalid,
74
+ rollbackStepsInvalid,
74
75
  serializationExhausted,
75
76
  sqlUnsafe,
76
77
  } from './errors';
@@ -0,0 +1,76 @@
1
+ // Single responsibility: merging the framework's own libpq `options` into whatever the operator
2
+ // already put in `DATABASE_URL`. A connection string is the operator's file, not the framework's,
3
+ // and `searchParams.set` on a key they may have written is a silent overwrite of their setting.
4
+
5
+ /**
6
+ * libpq hands `options` to the backend as command-line arguments, split on whitespace with a
7
+ * backslash escaping the next character. Escapes are kept intact, so re-joining the tokens
8
+ * reproduces the operator's string byte for byte.
9
+ */
10
+ export function splitLibpqOptions(options: string): readonly string[] {
11
+ const tokens: string[] = [];
12
+ let current = '';
13
+ let open = false;
14
+ for (let index = 0; index < options.length; index += 1) {
15
+ const char = options[index] ?? '';
16
+ const escaped = options[index + 1];
17
+ if (char === '\\' && escaped !== undefined) {
18
+ current += `\\${escaped}`;
19
+ open = true;
20
+ index += 1;
21
+ continue;
22
+ }
23
+ if (char.trim() === '') {
24
+ if (open) tokens.push(current);
25
+ current = '';
26
+ open = false;
27
+ continue;
28
+ }
29
+ current += char;
30
+ open = true;
31
+ }
32
+ if (open) tokens.push(current);
33
+ return tokens;
34
+ }
35
+
36
+ /**
37
+ * The three spellings a backend accepts for one GUC on the command line: `-c name=value` as two
38
+ * arguments, `-cname=value` as one, and `--name=value` (where a hyphen in the name reads as an
39
+ * underscore). A bare `name=value` token is the second half of the first spelling.
40
+ */
41
+ const ASSIGNS = (name: string): RegExp => new RegExp(`^(?:-c|--)?${name.replaceAll('_', '[_-]')}=`);
42
+
43
+ /** Drops every assignment of `name`, and the `-c` that introduced it. */
44
+ function without(tokens: readonly string[], name: string): readonly string[] {
45
+ const assigns = ASSIGNS(name);
46
+ const kept: string[] = [];
47
+ for (const token of tokens) {
48
+ if (!assigns.test(token)) {
49
+ kept.push(token);
50
+ continue;
51
+ }
52
+ if (kept.at(-1) === '-c') kept.pop();
53
+ }
54
+ return kept;
55
+ }
56
+
57
+ /**
58
+ * The operator's `options` with the framework's settings merged in.
59
+ *
60
+ * **Precedence: the framework wins on the settings it names, the operator keeps everything else.**
61
+ * A role's `statement_timeout` is a safety bound the role is sized around — `web`'s 10s is what
62
+ * stops a slow endpoint holding all 20 pool slots — so a value in the URL may not raise it; but a
63
+ * `search_path`, an `application_name` or a `-c` an operator added is theirs and must survive.
64
+ * Enforced by removing the framework's own names before appending, never by position: relying on
65
+ * "the last `-c` wins" would make the bound depend on backend argument order nobody here measured.
66
+ */
67
+ export function mergeLibpqOptions(
68
+ existing: string | null,
69
+ settings: Readonly<Record<string, string>>,
70
+ ): string {
71
+ let tokens = splitLibpqOptions(existing ?? '');
72
+ for (const [name, value] of Object.entries(settings)) {
73
+ tokens = [...without(tokens, name), '-c', `${name}=${value}`];
74
+ }
75
+ return tokens.join(' ');
76
+ }
package/src/migrate.ts CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  isReservable,
12
12
  poolProfileFor,
13
13
  } from './client';
14
- import { migrateConcurrent, migrationConflict } from './errors';
14
+ import { migrateConcurrent, migrationConflict, rollbackStepsInvalid } from './errors';
15
15
  import { expectedQueryLoop } from './expected-loop';
16
16
  import type { SchemaDescription } from './introspect';
17
17
  import { raw, sql } from './sql';
@@ -149,7 +149,13 @@ export function auditLedger(
149
149
  ): void {
150
150
  const known = new Map(migrations.map((migration) => [migration.id, migration]));
151
151
 
152
- const foreign = ledger.filter((row) => !known.has(row.id) && row.app_version !== appVersion);
152
+ // The predicate is "this build does not ship it" and NOTHING else. It used to also require
153
+ // `row.app_version !== appVersion`, which switched the audit off wherever the two agree —
154
+ // `runningAppVersion()` answers `dev` for every development build, so a migration applied by an
155
+ // earlier `dev` build and since deleted was invisible here, and `expectedSchema` then dropped
156
+ // its table from the drift comparison: `ok: true` against a database that still has the table.
157
+ // The version is a detail of the ANSWER, so it moved into the cause.
158
+ const foreign = ledger.filter((row) => !known.has(row.id));
153
159
  const first = foreign[0];
154
160
  if (first !== undefined) {
155
161
  throw migrationConflict(
@@ -374,6 +380,7 @@ export async function migrate(options: MigrateOptions): Promise<MigrationReport>
374
380
  export interface RollbackOptions {
375
381
  readonly migrations: readonly Migration[];
376
382
  readonly client?: DbClient | undefined;
383
+ /** How many applied migrations to reverse, newest first. A positive integer; defaults to 1. */
377
384
  readonly steps?: number | undefined;
378
385
  /** Skip the advisory lock. Only `x db branch` does this, against a private database. */
379
386
  readonly lock?: boolean | undefined;
@@ -387,6 +394,9 @@ export interface RollbackOptions {
387
394
  export async function rollback(options: RollbackOptions): Promise<readonly string[]> {
388
395
  const client = options.client ?? baseClient();
389
396
  const steps = options.steps ?? 1;
397
+ // Before the lock and before the ledger read: `slice(0, -1)` is "all but the newest", not
398
+ // "one fewer", so an unvalidated count reverses migrations nobody asked about.
399
+ if (!Number.isSafeInteger(steps) || steps < 1) throw rollbackStepsInvalid(steps);
390
400
  const lockTimeoutMs = migrationLockTimeoutMs(options.lockTimeoutMs);
391
401
  const known = new Map(options.migrations.map((migration) => [migration.id, migration]));
392
402