@ultimat3/db 1.2.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/src/migrate.ts CHANGED
@@ -3,17 +3,39 @@
3
3
  // app-version fence is the `migrate` role's contract — a pod must refuse to migrate a database
4
4
  // another build already owns, because the alternative is two schemas racing during a rollout.
5
5
 
6
- import { baseClient, type DbClient } from './client';
7
- import { migrationConflict } from './errors';
6
+ import { appVersion } from '@ultimat3/core';
7
+ import {
8
+ baseClient,
9
+ type DbClient,
10
+ type DbConnection,
11
+ isReservable,
12
+ poolProfileFor,
13
+ } from './client';
14
+ import { migrateConcurrent, migrationConflict, rollbackStepsInvalid } from './errors';
15
+ import { expectedQueryLoop } from './expected-loop';
8
16
  import type { SchemaDescription } from './introspect';
9
17
  import { raw, sql } from './sql';
10
- import { withTransaction } from './transaction';
18
+ import { SQLSTATE, sqlState } from './sqlstate';
19
+ import { statementsOf } from './statement-split';
20
+ import { type DbTx, withTransaction } from './transaction';
11
21
 
12
22
  export const LEDGER_TABLE = 'x_migrations';
13
23
 
14
24
  /** Stable, arbitrary: every Ultimate migrator contends on this one key. */
15
25
  export const MIGRATION_LOCK_KEY = 4_919_202_607;
16
26
 
27
+ /**
28
+ * How long a migrator waits for the lock before refusing. A deploy hook must fail rather than
29
+ * hang: `pg_advisory_lock` blocks with no timeout, so a wedged predecessor held `helm upgrade
30
+ * --wait` inside one statement with nothing in the logs and `backoffLimit` never reached. Long
31
+ * enough that a genuinely slow migration ahead of us is waited out, short enough that a stuck one
32
+ * becomes an exit code inside a deploy window.
33
+ */
34
+ export const MIGRATION_LOCK_WAIT_MS = 60_000;
35
+
36
+ /** One poll per half-second: cheap against a lock that is usually free on the first try. */
37
+ export const MIGRATION_LOCK_POLL_MS = 500;
38
+
17
39
  export interface Migration {
18
40
  /** Sort key and primary key. `20260726120000_add_publish_at`. */
19
41
  readonly id: string;
@@ -56,6 +78,10 @@ export interface MigrateOptions {
56
78
  readonly client?: DbClient | undefined;
57
79
  /** Skip the advisory lock. Only `x db branch` does this, against a private database. */
58
80
  readonly lock?: boolean | undefined;
81
+ /** How long to wait for the lock before `X_MIGRATE_CONCURRENT`. Defaults to 60s. */
82
+ readonly lockWaitMs?: number | undefined;
83
+ /** `SET LOCAL lock_timeout` per migration. Defaults to the `migrate` role's profile. */
84
+ readonly lockTimeoutMs?: number | undefined;
59
85
  }
60
86
 
61
87
  export function checksumOf(text: string): string {
@@ -66,8 +92,13 @@ export function migrationChecksum(migration: Migration): string {
66
92
  return migration.checksum ?? checksumOf(migration.up);
67
93
  }
68
94
 
95
+ /**
96
+ * Core's, never a second read of the key: `x_migrations.app_version` and `x_backfills.app_version`
97
+ * are two durable columns an operator reads side by side, and a package defaulting `APP_VERSION`
98
+ * its own way would put two names on one build.
99
+ */
69
100
  export function runningAppVersion(explicit?: string | undefined): string {
70
- return explicit ?? process.env['APP_VERSION'] ?? 'dev';
101
+ return explicit ?? appVersion();
71
102
  }
72
103
 
73
104
  export async function ensureLedger(client: DbClient): Promise<void> {
@@ -83,6 +114,22 @@ export async function ensureLedger(client: DbClient): Promise<void> {
83
114
  `);
84
115
  }
85
116
 
117
+ /**
118
+ * Whether `error` is "the ledger table does not exist" and nothing else.
119
+ *
120
+ * Everything else — a permission denied, a server in recovery, a timeout — is a failure to read the
121
+ * ledger, not an empty one, and a caller treating the two alike reports every migration as pending
122
+ * against a database it cannot see.
123
+ *
124
+ * The SQLSTATE comes from `sqlState()` and from nowhere else: this function used to read
125
+ * `sourceError.code` itself, which is the SQLSTATE on PGlite and the literal string
126
+ * `ERR_POSTGRES_SERVER_ERROR` on `Bun.SQL`, so it answered `false` for a genuinely missing ledger
127
+ * on every production driver. One reader, one answer (axiom 1).
128
+ */
129
+ export function isLedgerMissing(error: unknown): boolean {
130
+ return sqlState(error) === SQLSTATE.undefinedTable;
131
+ }
132
+
86
133
  export async function readLedger(client: DbClient): Promise<readonly LedgerRow[]> {
87
134
  return client.query<LedgerRow>(sql`
88
135
  select id, name, checksum, applied_at, app_version, duration_ms
@@ -102,13 +149,24 @@ export function auditLedger(
102
149
  ): void {
103
150
  const known = new Map(migrations.map((migration) => [migration.id, migration]));
104
151
 
105
- 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));
106
159
  const first = foreign[0];
107
160
  if (first !== undefined) {
108
161
  throw migrationConflict(
109
162
  `the ledger records migration "${first.id}" applied by app version "${first.app_version}" ` +
110
163
  `but this build is "${appVersion}" and does not ship it`,
111
- `x db status --json # then deploy app version "${first.app_version}", or roll the ledger`,
164
+ // `x db status` has never existed the subcommands are gen, migrate, reset, studio, branch
165
+ // and backfill — and this is one of the two errors most likely to fire during a real deploy.
166
+ // A `fix:` is copied and run verbatim, so it names the ledger read that works anywhere psql
167
+ // does, and the one edit that resolves the disagreement.
168
+ `deploy app version "${first.app_version}" — or, if that build is gone, drop its row: ` +
169
+ `psql "$DATABASE_URL" -c "delete from ${LEDGER_TABLE} where id = '${first.id}'"`,
112
170
  );
113
171
  }
114
172
 
@@ -134,95 +192,256 @@ export function pendingMigrations(
134
192
  .filter((migration) => !applied.has(migration.id));
135
193
  }
136
194
 
195
+ /**
196
+ * Hold the migration lock on **one** session for the whole of `fn`, which runs on that session.
197
+ *
198
+ * `pg_advisory_lock` is scoped to a Postgres session, not to a statement, so taking it on a pooled
199
+ * handle locks whichever connection the pool lent for that one statement and then hands the
200
+ * session back. Two things follow, and both were live: the unlock lands on a *different*
201
+ * connection, answers `false` and leaves the lock held until that backend dies — so the next
202
+ * migrator waits forever rather than for the migration; and the locking session, now idle for the
203
+ * whole run, is closed by the pool's idle timeout (`migrate`'s is 10s), which releases the lock
204
+ * mid-migration and lets a second deploy in. `ROLE=migrate` hid the first half by accident — its
205
+ * pool is `max: 1`, so every statement found the same connection. No other role and no test has
206
+ * that.
207
+ *
208
+ * `fn` receives the pinned session and must run every statement on it, for the same `max: 1`
209
+ * reason read the other way: a statement sent to the pool while the pin is held waits for a
210
+ * connection that cannot come back until the migration blocking on it has finished.
211
+ */
212
+
213
+ /**
214
+ * Take the lock, or refuse — never block forever.
215
+ *
216
+ * `pg_advisory_lock` has no timeout, and that is a deploy outage waiting for its trigger: a
217
+ * predecessor OOM-killed on a network partition keeps its backend, and with it the lock, for hours.
218
+ * The new `ROLE=migrate` pod then sits inside one statement printing nothing, `helm upgrade --wait`
219
+ * blocks on a pod that is `Running` and healthy, and because the job never *fails*, `backoffLimit`
220
+ * never fires. `pg_try_advisory_lock` answers immediately, so the wait becomes ours to bound and
221
+ * the wedge becomes an exit code carrying the lock key and the pid to terminate.
222
+ *
223
+ * Declared as an expected loop: a poll is a loop the framework argued for, and a diagnostic that
224
+ * reported it would teach an author to ignore the ones nobody argued for.
225
+ */
226
+ async function acquireLock(session: DbClient, waitMs: number): Promise<void> {
227
+ const started = performance.now();
228
+ await expectedQueryLoop(
229
+ 'the migration lock is polled, not waited on, so a wedged migrator fails a deploy instead of hanging it',
230
+ async () => {
231
+ for (;;) {
232
+ const row = await session.one<{ locked: boolean }>(
233
+ sql`select pg_try_advisory_lock(${MIGRATION_LOCK_KEY}) as locked`,
234
+ );
235
+ if (row?.locked === true) return;
236
+ const remaining = waitMs - (performance.now() - started);
237
+ if (remaining <= 0) {
238
+ throw migrateConcurrent(MIGRATION_LOCK_KEY, Math.round(performance.now() - started));
239
+ }
240
+ await Bun.sleep(Math.min(MIGRATION_LOCK_POLL_MS, remaining));
241
+ }
242
+ },
243
+ );
244
+ }
245
+
137
246
  async function withAdvisoryLock<T>(
138
247
  client: DbClient,
139
248
  enabled: boolean,
140
- fn: () => Promise<T>,
249
+ waitMs: number,
250
+ fn: (session: DbClient) => Promise<T>,
141
251
  ): Promise<T> {
142
- if (!enabled) return fn();
143
- await client.execute(sql`select pg_advisory_lock(${MIGRATION_LOCK_KEY})`);
252
+ if (!enabled) return fn(client);
253
+ // Held by a `using` declaration, like every other pin in this package: the lock is taken after
254
+ // the guard exists, so a rejecting `pg_advisory_lock` gives the connection back too.
255
+ using pinned: DbConnection | undefined = isReservable(client)
256
+ ? await client.reserve()
257
+ : undefined;
258
+ const session: DbClient = pinned ?? client;
259
+ await acquireLock(session, waitMs);
144
260
  try {
145
- return await fn();
261
+ return await fn(session);
146
262
  } finally {
147
- await client
263
+ // Best-effort, and only here: an unlock that rejects would mask the failure that ended the
264
+ // migration, and it can only reject on a session that is already broken — whose locks Postgres
265
+ // drops when it ends. It runs before the pin is disposed, so the unlock reaches the session
266
+ // that took the lock.
267
+ await session
148
268
  .execute(sql`select pg_advisory_unlock(${MIGRATION_LOCK_KEY})`)
149
269
  .catch(() => undefined);
150
270
  }
151
271
  }
152
272
 
273
+ /**
274
+ * A migration's `up` and `down` are **scripts**, and one send is one statement — because the two
275
+ * drivers disagree about anything else. PGlite's `query()` is the extended protocol always and
276
+ * answers `cannot insert multiple commands into a prepared statement`; `Bun.SQL.unsafe` degrades
277
+ * to the simple protocol when no value is bound and applies the same script, which is a fact about
278
+ * bun 1.3.14 and not a contract. `createTable` emits the table *and* every index it carries, and
279
+ * `x dev` runs on the embedded driver, so the refusing side was the common path.
280
+ *
281
+ * No `expectedQueryLoop` of its own: both call sites already run inside the one declared for the
282
+ * migration loop, and nesting here would replace that reason with a narrower one for no gain. An
283
+ * empty script sends nothing at all, which is how a no-op migration reaches its ledger row.
284
+ */
285
+ async function applyScript(tx: DbTx, script: string): Promise<void> {
286
+ for (const statement of statementsOf(script)) await tx.execute(raw(statement));
287
+ }
288
+
289
+ /**
290
+ * Bound how long this migration will queue behind a lock it cannot take.
291
+ *
292
+ * `alter table … add column` needs `ACCESS EXCLUSIVE`. A long `SELECT` holding `ACCESS SHARE`
293
+ * makes it wait — and because Postgres' lock queue is FIFO, **every subsequent query on that table
294
+ * queues behind the ALTER**. The `migrate` profile runs `statement_timeout = 0` deliberately, so
295
+ * without this the migrator waits forever and the app is down on one table for as long as the
296
+ * reporting query runs. `lock_timeout` bounds the *wait* alone and never the work, which is why it
297
+ * is the right knob where `statement_timeout` is not.
298
+ *
299
+ * `SET LOCAL`, inside the migration's own transaction: it reverts at COMMIT, so a value chosen for
300
+ * DDL never leaks onto the session the ledger insert or the next migration runs on. A 0 disables
301
+ * it, exactly like `statementTimeoutMs`. The failure it produces is `55P03`, typed as
302
+ * `X_DB_LOCK_TIMEOUT` by `driverError` with the `pg_stat_activity` read as its fix.
303
+ */
304
+ async function setLockTimeout(tx: DbTx, lockTimeoutMs: number): Promise<void> {
305
+ if (lockTimeoutMs <= 0) return;
306
+ // `SET LOCAL` takes no parameter placeholder, and the value is a validated integer of ours.
307
+ await tx.execute(raw(`SET LOCAL lock_timeout = ${Math.round(lockTimeoutMs)}`));
308
+ }
309
+
310
+ /**
311
+ * `migrate` role's profile whatever role is running, because the statement is a migration whatever
312
+ * process issues it: `x db migrate` from a laptop, `x dev`'s boot and `ROLE=migrate` all take the
313
+ * same `ACCESS EXCLUSIVE` locks against the same tables.
314
+ */
315
+ function migrationLockTimeoutMs(explicit: number | undefined): number {
316
+ return explicit ?? poolProfileFor('migrate').lockTimeoutMs;
317
+ }
318
+
153
319
  export async function migrate(options: MigrateOptions): Promise<MigrationReport> {
154
320
  const client = options.client ?? baseClient();
155
321
  const appVersion = runningAppVersion(options.appVersion);
322
+ const lockTimeoutMs = migrationLockTimeoutMs(options.lockTimeoutMs);
156
323
  const started = performance.now();
157
324
 
158
- return withAdvisoryLock(client, options.lock !== false, async () => {
159
- await ensureLedger(client);
160
- const ledger = await readLedger(client);
161
- auditLedger(ledger, options.migrations, appVersion);
162
-
163
- const pending = pendingMigrations(ledger, options.migrations);
164
- const applied: AppliedMigration[] = [];
165
- for (const migration of pending) {
166
- const at = performance.now();
167
- await withTransaction(
168
- async (tx) => {
169
- await tx.execute(raw(migration.up));
170
- const durationMs = Math.round(performance.now() - at);
171
- await tx.execute(sql`
172
- insert into ${raw(LEDGER_TABLE)} (id, name, checksum, app_version, duration_ms)
173
- values (${migration.id}, ${migration.name}, ${migrationChecksum(migration)},
174
- ${appVersion}, ${durationMs})
175
- `);
325
+ return withAdvisoryLock(
326
+ client,
327
+ options.lock !== false,
328
+ options.lockWaitMs ?? MIGRATION_LOCK_WAIT_MS,
329
+ async (session) => {
330
+ await ensureLedger(session);
331
+ const ledger = await readLedger(session);
332
+ auditLedger(ledger, options.migrations, appVersion);
333
+
334
+ const pending = pendingMigrations(ledger, options.migrations);
335
+ // A statement per migration and a transaction per migration is the point, not an N+1 to batch:
336
+ // one failed `up` must leave the ledger describing exactly the migrations that did run, and a
337
+ // batch commits or loses all of them together. Declared here so a diagnostic reports the loops
338
+ // nobody argued for and stays quiet about this one.
339
+ const applied = await expectedQueryLoop(
340
+ 'each migration applies in its own transaction, so a failure leaves an exact ledger',
341
+ async () => {
342
+ const done: AppliedMigration[] = [];
343
+ for (const migration of pending) {
344
+ const at = performance.now();
345
+ await withTransaction(
346
+ async (tx) => {
347
+ await setLockTimeout(tx, lockTimeoutMs);
348
+ await applyScript(tx, migration.up);
349
+ const durationMs = Math.round(performance.now() - at);
350
+ await tx.execute(sql`
351
+ insert into ${raw(LEDGER_TABLE)} (id, name, checksum, app_version, duration_ms)
352
+ values (${migration.id}, ${migration.name}, ${migrationChecksum(migration)},
353
+ ${appVersion}, ${durationMs})
354
+ `);
355
+ },
356
+ // The lock's own session: a migration applied on another connection is not covered by
357
+ // the lock at all, and on `ROLE=migrate` there is no other connection to apply it on.
358
+ { client: session },
359
+ );
360
+ done.push({
361
+ id: migration.id,
362
+ name: migration.name,
363
+ durationMs: Math.round(performance.now() - at),
364
+ });
365
+ }
366
+ return done;
176
367
  },
177
- { client },
178
368
  );
179
- applied.push({
180
- id: migration.id,
181
- name: migration.name,
182
- durationMs: Math.round(performance.now() - at),
183
- });
184
- }
185
-
186
- return {
187
- applied,
188
- skipped: ledger.map((row) => row.id),
189
- durationMs: Math.round(performance.now() - started),
190
- appVersion,
191
- };
192
- });
369
+
370
+ return {
371
+ applied,
372
+ skipped: ledger.map((row) => row.id),
373
+ durationMs: Math.round(performance.now() - started),
374
+ appVersion,
375
+ };
376
+ },
377
+ );
193
378
  }
194
379
 
195
380
  export interface RollbackOptions {
196
381
  readonly migrations: readonly Migration[];
197
382
  readonly client?: DbClient | undefined;
383
+ /** How many applied migrations to reverse, newest first. A positive integer; defaults to 1. */
198
384
  readonly steps?: number | undefined;
385
+ /** Skip the advisory lock. Only `x db branch` does this, against a private database. */
386
+ readonly lock?: boolean | undefined;
387
+ /** How long to wait for the lock before `X_MIGRATE_CONCURRENT`. Defaults to 60s. */
388
+ readonly lockWaitMs?: number | undefined;
389
+ /** `SET LOCAL lock_timeout` per reversal. Defaults to the `migrate` role's profile. */
390
+ readonly lockTimeoutMs?: number | undefined;
199
391
  }
200
392
 
201
393
  /** Reverse the newest `steps` applied migrations. `x db rollback`. */
202
394
  export async function rollback(options: RollbackOptions): Promise<readonly string[]> {
203
395
  const client = options.client ?? baseClient();
204
396
  const steps = options.steps ?? 1;
205
- const ledger = await readLedger(client);
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);
400
+ const lockTimeoutMs = migrationLockTimeoutMs(options.lockTimeoutMs);
206
401
  const known = new Map(options.migrations.map((migration) => [migration.id, migration]));
207
- const targets = [...ledger].reverse().slice(0, steps);
208
- const reverted: string[] = [];
209
402
 
210
- for (const row of targets) {
211
- const migration = known.get(row.id);
212
- if (migration === undefined) {
213
- throw migrationConflict(
214
- `migration "${row.id}" is in the ledger but not in this build, so its down SQL is unknown`,
215
- `x db status --json # deploy the build that shipped "${row.id}" and roll back there`,
403
+ // The same lock `migrate` takes, because the race is the same one: a rollback reversing the id a
404
+ // migrator is applying leaves a ledger that describes neither, and the ledger read below decides
405
+ // what to reverse — outside the lock it can be stale before the first `down` runs.
406
+ return withAdvisoryLock(
407
+ client,
408
+ options.lock !== false,
409
+ options.lockWaitMs ?? MIGRATION_LOCK_WAIT_MS,
410
+ async (session) => {
411
+ const ledger = await readLedger(session);
412
+ const targets = [...ledger].reverse().slice(0, steps);
413
+
414
+ // The same deliberate loop as `migrate`, read backwards: one transaction per `down`, newest
415
+ // first, so a `down` that fails leaves every migration before it still applied and recorded.
416
+ return expectedQueryLoop(
417
+ 'each migration reverses in its own transaction, newest first, so a failure stops exactly there',
418
+ async () => {
419
+ const reverted: string[] = [];
420
+ for (const row of targets) {
421
+ const migration = known.get(row.id);
422
+ if (migration === undefined) {
423
+ throw migrationConflict(
424
+ `migration "${row.id}" is in the ledger but not in this build, so its down SQL is unknown`,
425
+ // Same reason as `auditLedger`'s: `x db status` does not exist. The `down` SQL only
426
+ // exists in the build that shipped it, so the fix is the read that names that build.
427
+ `psql "$DATABASE_URL" -c "select id, app_version from ${LEDGER_TABLE} ` +
428
+ `order by id desc limit 5" # deploy the build that shipped "${row.id}", ` +
429
+ 'and roll back there — its down SQL exists nowhere else',
430
+ );
431
+ }
432
+ await withTransaction(
433
+ async (tx) => {
434
+ await setLockTimeout(tx, lockTimeoutMs);
435
+ await applyScript(tx, migration.down);
436
+ await tx.execute(sql`delete from ${raw(LEDGER_TABLE)} where id = ${row.id}`);
437
+ },
438
+ { client: session },
439
+ );
440
+ reverted.push(row.id);
441
+ }
442
+ return reverted;
443
+ },
216
444
  );
217
- }
218
- await withTransaction(
219
- async (tx) => {
220
- await tx.execute(raw(migration.down));
221
- await tx.execute(sql`delete from ${raw(LEDGER_TABLE)} where id = ${row.id}`);
222
- },
223
- { client },
224
- );
225
- reverted.push(row.id);
226
- }
227
- return reverted;
445
+ },
446
+ );
228
447
  }
package/src/observe.ts ADDED
@@ -0,0 +1,90 @@
1
+ // Single responsibility: the seam a diagnostic hangs statements off. One installed observer,
2
+ // process-wide, read through one accessor — so the funnels every statement already passes through
3
+ // pay a single `undefined` check when nothing is installed (axiom 6). Nothing here knows what an
4
+ // entity, a request or a span is: `db` is tier 1 and the detector that consumes this is tier 5.
5
+
6
+ /**
7
+ * What the layer that compiled the statement knows and the driver cannot: which entity and which
8
+ * repository operation. It is the difference between "50× `select … where id = $1`" and "50×
9
+ * `findById` on `members`" in a diagnostic's report.
10
+ *
11
+ * Produced by `@ultimat3/entity`'s `postgresRepo` (tier 2, so importing this is downward): it is
12
+ * the last caller that still knows the entity and the operation by the time the SQL exists, and it
13
+ * declares both through `withStatementAttribution` (`attribution.ts`) around each repository call.
14
+ * Hand-written SQL, a migration, a health probe and the job queue's own statements are unattributed
15
+ * — nothing above them knows an entity to name — which is why the field is optional rather than
16
+ * required, and why a diagnostic must fall back to the statement text.
17
+ */
18
+ export interface StatementAttribution {
19
+ /** Entity name as declared, e.g. `members` — never a table name. */
20
+ readonly entity: string;
21
+ /** Repository operation that compiled the statement, e.g. `findById`. */
22
+ readonly op: string;
23
+ }
24
+
25
+ /** One settled statement. Emitted after it resolved or rejected, never before it was sent. */
26
+ export interface StatementEvent {
27
+ /** The statement as sent, parameters still as `$1..$n`. Safe to log; values are separate. */
28
+ readonly text: string;
29
+ /** Bound parameters, in order. May carry user data — a consumer that logs must redact. */
30
+ readonly values: readonly unknown[];
31
+ /** Wall time from send to settle, from `performance.now()`. */
32
+ readonly durationMs: number;
33
+ /** Rows returned by a read, rows affected by a write, `0` when the statement threw. */
34
+ readonly rows: number;
35
+ /** The rejection, already wrapped as `X_DB_UNAVAILABLE` by the funnel. */
36
+ readonly error?: unknown;
37
+ /**
38
+ * Who compiled this statement, absent when nothing above the SQL knew — see
39
+ * `StatementAttribution`. Stamped by the funnel from the scope open at send time, for the same
40
+ * reason `expected` is: a diagnostic that judges a whole request runs long after every scope in
41
+ * it closed.
42
+ */
43
+ readonly attribution?: StatementAttribution | undefined;
44
+ /**
45
+ * The reason of the innermost `expectedQueryLoop()` this statement was issued inside, absent
46
+ * outside every such scope. Stamped by the funnel at settle time rather than read later, because
47
+ * a diagnostic that judges a whole request at the end of it runs long after the scope closed. A
48
+ * detector counting repeats must not warn about these; everything that only measures — the span,
49
+ * the timeline, a metric — treats them like any other statement.
50
+ */
51
+ readonly expected?: string | undefined;
52
+ }
53
+
54
+ /**
55
+ * A diagnostic. `onStatement` runs synchronously on the caller's stack once the statement has
56
+ * settled, so it must not await anything and must not issue SQL — a statement issued from here
57
+ * re-enters the funnel and observes itself.
58
+ *
59
+ * A throw propagates to whoever ran the statement, deliberately: strict test mode is an observer
60
+ * that fails the test the N+1 happened in, and swallowing here would make that impossible. An
61
+ * observer that only reports must therefore not throw.
62
+ */
63
+ export interface StatementObserver {
64
+ onStatement(event: StatementEvent): void;
65
+ }
66
+
67
+ let installed: StatementObserver | undefined;
68
+
69
+ /**
70
+ * Install the process-wide observer. `setStatementObserver(undefined)` uninstalls, which is the
71
+ * production state and the state every test must leave behind. The `setDbClient` shape, for the
72
+ * same reason: the thing being replaced is ambient, so the seam is a setter and not a parameter
73
+ * threaded through `db()`, `withTransaction()` and both drivers.
74
+ *
75
+ * One observer, not a list — a second registration replaces the first. A fan-out array would make
76
+ * "which diagnostic saw this statement" order-dependent, and the one consumer that needs several
77
+ * (the dev server) composes them itself, in its own order, where that order is reviewable.
78
+ */
79
+ export function setStatementObserver(observer: StatementObserver | undefined): void {
80
+ installed = observer;
81
+ }
82
+
83
+ /**
84
+ * The installed observer, or `undefined` when there is none. Read once per statement and guarded
85
+ * at the call site rather than notified through a wrapper, so an uninstalled seam costs one
86
+ * property read and one branch — no event object is allocated for nobody to receive.
87
+ */
88
+ export function statementObserver(): StatementObserver | undefined {
89
+ return installed;
90
+ }
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { cp, mkdir, rm, stat } from 'node:fs/promises';
7
7
  import { basename, dirname, join } from 'node:path';
8
+ import { systemClock } from '@ultimat3/core';
8
9
  import type { BranchInfo } from './branch';
9
10
  import { assertBranchName } from './branch';
10
11
  import { branchExists, dbNotImplemented, dbUnavailable } from './errors';
@@ -77,7 +78,7 @@ export async function branchPglite(
77
78
 
78
79
  return {
79
80
  name: branch,
80
- createdAt: (options.now ?? new Date()).toISOString(),
81
+ createdAt: (options.now ?? systemClock.now()).toISOString(),
81
82
  dataDir: to,
82
83
  sizeBytes: await directorySize(to),
83
84
  };
@@ -5,10 +5,15 @@
5
5
  // gives `withTransaction` and `readOnlyQuery` on a real server.
6
6
 
7
7
  /**
8
- * Gives the connection back. Idempotent for free: it is a settled promise's `resolve`, not a
9
- * counter, so a second call cannot hand out a second turn — the next caller is already awake.
8
+ * Gives the connection back. `release()` is idempotent for free: it is a settled promise's
9
+ * `resolve`, not a counter, so a second call cannot hand out a second turn — the next caller is
10
+ * already awake. `Disposable`, so `using turn = await queue.take()` gives it back on every exit
11
+ * path — the same shape as `DbConnection` in `client.ts`, and `[Symbol.dispose]` is `release()`
12
+ * itself, never a second code path.
10
13
  */
11
- export type Turn = () => void;
14
+ export interface Turn extends Disposable {
15
+ release(): void;
16
+ }
12
17
 
13
18
  export interface TurnQueue {
14
19
  /** Wait for the connection, then keep it until the returned `Turn` is called. */
@@ -35,16 +40,14 @@ export function createTurnQueue(): TurnQueue {
35
40
  // other, not both read the same tail and run at once.
36
41
  tail = mine.then(() => held);
37
42
  await mine;
38
- return release;
43
+ return { release, [Symbol.dispose]: release };
39
44
  }
40
45
 
41
46
  async function run<T>(work: () => Promise<T>): Promise<T> {
42
- const turn = await take();
43
- try {
44
- return await work();
45
- } finally {
46
- turn();
47
- }
47
+ // `using`, not `try`/`finally`: the turn must go back on every exit path, including one a
48
+ // future edit adds above a hand-rolled `finally` that forgot it — see `client.ts`.
49
+ using _turn = await take();
50
+ return await work();
48
51
  }
49
52
 
50
53
  return { take, run };