@ultimat3/db 18.0.0 → 19.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
@@ -233,6 +233,30 @@ means the nested scope never opened, and a release that failed means its work is
233
233
  the outer one. Swallowing either would keep running against a transaction that is not the one the
234
234
  caller thinks it is in.
235
235
 
236
+ **`close()` is BOUNDED, `As of 2026-08-27`, and by the driver's OWN option rather than a race here**
237
+ (#394). `BunSqlDriver.close` has declared `{ timeout }` since this package's `Bun.SQL` slice was
238
+ written and **nothing ever passed it** — a capability sitting unused in the seam, the same shape as
239
+ `setOfflineMode` on the CDP port. Measured against a real server, three runs per case: `end()` waits
240
+ on an outstanding RESERVED connection and never returns, on Bun 1.3.14 **and** on 1.4.0, with the
241
+ database perfectly healthy; once that connection's backend has been terminated it becomes a race
242
+ 1.3.14 loses 3 of 3 and 1.4.0 loses 1 of 3. So the runtime was never the variable — an unbounded
243
+ await was, and `@ultimat3/cli`'s `releaseQueue` awaits this method. A container that will not drain
244
+ is drained by SIGKILL, and the operator's only signal is a pod that took its full grace period.
245
+
246
+ Three rules ride with it. **The unit is SECONDS** — `close({ timeout: profile.drainTimeoutMs /
247
+ 1000 })`, and `timeout: 5000` would be an eighty-three minute budget, which is the same hang with
248
+ extra steps. **`drainTimeoutMs: 0` sends no option at all**, rather than `{ timeout: 0 }`: `migrate`
249
+ and `replicator` mean "wait", for `acquireTimeoutMs`' reason, and a zero handed to the driver is an
250
+ instruction whose reading is the driver's. **The verdict is the elapsed time**, because the driver
251
+ RESOLVES when it gives up rather than rejecting — a drain that abandoned in-flight work looks exactly
252
+ like a clean one, so `X_DB_DRAIN_TIMEOUT` is raised on the clock or nothing is said at all. That
253
+ clock is `performance.now()` and never `Date.now()`, and the reason is this repo rather than NTP:
254
+ the framework preload freezes `Date` for every test in the tree (`installDeterminism`), so a duration
255
+ subtracted from `Date.now()` is 0 in all of them and the branch could not fire — a test asserting it
256
+ would have been one that cannot fail. `pool-drain.test.ts` pins what is ASKED for, against a fake
257
+ pool; `pool-drain.live.test.ts` pins that a real server's driver honours it, because a fake's
258
+ `close()` is whatever the fake decided and the finding is about the real one.
259
+
236
260
  `close()` reads its cached driver into a local, clears the field, **then** awaits the teardown —
237
261
  `client.ts` and `pglite.ts` both. A teardown that rejects has still torn the pool down, so clearing
238
262
  after the await left the corpse cached for the next `connect()`, and a second `close()` threw in
@@ -1312,7 +1336,7 @@ survives the round trip whole.
1312
1336
  is 0 and not 1.
1313
1337
 
1314
1338
  - **`client.ts` reached the 500-line ceiling on 2026-08-26, and shed the five jobs that were not
1315
- "open a connection and send a statement".** `pool-profile.ts` owns the five numbers a pool runs
1339
+ "open a connection and send a statement".** `pool-profile.ts` owns the six numbers a pool runs
1316
1340
  on — the per-role table, `DATABASE_POOL_MAX` and the screen every merged profile passes;
1317
1341
  `connection-url.ts` builds the connection string (the libpq `options` merge and the
1318
1342
  `application_name` label); `bun-sql.ts` declares the slice of `Bun.SQL` this package uses and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/db",
3
- "version": "18.0.0",
3
+ "version": "19.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": "18.0.0"
34
+ "@ultimat3/core": "19.0.0"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "@electric-sql/pglite": ">=0.5.0"
package/src/bun-sql.ts CHANGED
@@ -1,13 +1,55 @@
1
- // Single responsibility: the slice of `Bun.SQL` this package uses, declared structurally, and the
2
- // lazy lookup of the global that provides it. Reached through a function so importing the client
3
- // never touches `Bun` at module evaluation — the CLI imports it to print help.
1
+ // Single responsibility: the slice of `Bun.SQL` this package uses, declared structurally, the lazy
2
+ // lookup of the global that provides it, and the one safe way to hand a pinned connection back.
3
+ // Reached through a function so importing the client never touches `Bun` at module evaluation —
4
+ // the CLI imports it to print help.
4
5
 
6
+ import { logger, renderThrowable } from '@ultimat3/core';
5
7
  import { dbUnavailable } from './errors';
6
8
 
7
9
  /** One connection pinned out of `Bun.SQL`'s pool, released back by hand. */
8
10
  export interface BunSqlReserved {
9
11
  unsafe(text: string, values?: readonly unknown[]): Promise<unknown>;
10
- release(): void;
12
+ /**
13
+ * **Answers a PROMISE, and typing it `void` is what made both callers float it.** Measured on
14
+ * Bun 1.3.14 and 1.4.0 against a real server: `release()` returns a promise on both, and on
15
+ * 1.3.14 that promise REJECTS with `ERR_POSTGRES_CONNECTION_CLOSED` when the pool has already
16
+ * been closed. Nothing was attached to it, so it surfaced as an UNHANDLED REJECTION — which Bun
17
+ * takes the process down for. `unknown` rather than `Promise<void>` because a fake reserved
18
+ * connection returns nothing at all, and the caller has to handle both anyway.
19
+ */
20
+ release(): unknown;
21
+ }
22
+
23
+ /**
24
+ * Hand a pin back, totally. The one place that knows `release()` answers a promise, so neither
25
+ * caller can forget it (axiom 1) — `client.ts`'s `DbConnection.release` and `pool-reserve.ts`'s
26
+ * late arrival both route here.
27
+ *
28
+ * A failed release is **best-effort, exactly where a throw would mask the error that caused it** —
29
+ * the rule this package already applies to `ROLLBACK`. `[Symbol.dispose]` is `DbConnection.release`
30
+ * itself, so a throw there replaces whatever error reached the `using` block, or invents one where
31
+ * the body succeeded. And the news is unactionable: the connection this would hand back is gone
32
+ * either way.
33
+ *
34
+ * Reachable, and reachable BECAUSE `close()` is bounded: an abandoned drain leaves every
35
+ * still-pinned connection to be released against a pool that no longer exists.
36
+ */
37
+ export function releaseReserved(reserved: BunSqlReserved): void {
38
+ const report = (error: unknown): void => {
39
+ logger.debug('db.release_failed', { error: renderThrowable(error) });
40
+ };
41
+ let settled: unknown;
42
+ try {
43
+ settled = reserved.release();
44
+ } catch (error) {
45
+ report(error);
46
+ return;
47
+ }
48
+ // `then` and not `instanceof Promise`: the value comes from the driver, and a thenable is the
49
+ // contract every await in this package already relies on.
50
+ if (typeof (settled as PromiseLike<unknown> | undefined)?.then === 'function') {
51
+ void (settled as PromiseLike<unknown>).then(undefined, report);
52
+ }
11
53
  }
12
54
 
13
55
  /** The slice of `Bun.SQL` we use. Declared structurally so this package has no dependency. */
package/src/client.ts CHANGED
@@ -5,12 +5,12 @@
5
5
  // importing this module never opens a socket.
6
6
 
7
7
  import { type Role, resolveRole } from '@ultimat3/core';
8
- import { type BunSqlDriver, type BunSqlReserved, bunSqlFactory } from './bun-sql';
8
+ import { type BunSqlDriver, type BunSqlReserved, bunSqlFactory, releaseReserved } from './bun-sql';
9
9
  import { connectionUrl } from './connection-url';
10
10
  // Deliberate cycle, the same shape as `client.ts ⇄ transaction.ts`: nothing here is referenced at
11
11
  // module evaluation, and both sides are `function` declarations, so hoisting covers the TDZ.
12
12
  import { defaultClient } from './default-client';
13
- import { DbError, driverError } from './errors';
13
+ import { DbError, drainTimeout, driverError } from './errors';
14
14
  import { assertPoolProfile, type PoolProfile, poolProfileFor } from './pool-profile';
15
15
  import { reserveWithin } from './pool-reserve';
16
16
  import { type SqlFragment, sql } from './sql';
@@ -120,7 +120,8 @@ export function createPostgresClient(options: PostgresClientOptions = {}): Postg
120
120
  const release = (): void => {
121
121
  if (!held) return;
122
122
  held = false;
123
- reserved.release();
123
+ // Total by construction — `releaseReserved` owns the reason (`bun-sql.ts`).
124
+ releaseReserved(reserved);
124
125
  };
125
126
  return {
126
127
  query: async <T>(fragment: SqlFragment) => rowsOf<T>(await on(fragment)),
@@ -141,7 +142,40 @@ export function createPostgresClient(options: PostgresClientOptions = {}): Postg
141
142
  // rejection still reaches the caller — a shutdown that could not drain wants to know.
142
143
  const pool = driver;
143
144
  driver = undefined;
144
- await pool?.close();
145
+ if (pool === undefined) return;
146
+ // BOUNDED, `As of 2026-08-27`, and through the driver's OWN option rather than a race here.
147
+ // This was a bare `await pool.close()`, and `Bun.SQL`'s `end()` waits on an outstanding
148
+ // reserved connection without ever giving up — measured three runs per case on Bun 1.3.14
149
+ // AND 1.4.0, no database outage involved (#394). So a role whose database went away
150
+ // mid-shutdown never finished shutting down, and the operator's only signal was a container
151
+ // that burned its whole termination grace period before SIGKILL.
152
+ //
153
+ // `BunSqlDriver.close` has declared `{ timeout }` since this port was written and NOTHING
154
+ // ever passed it — the capability was in the seam, unused, exactly like `setOfflineMode` on
155
+ // the CDP port. Measured with a reserve outstanding: `close({ timeout: 1 })` returns in
156
+ // ~1002ms on 1.3.14, 1.4.0 and 1.4.1-canary alike, where a bare `close()` never returns.
157
+ //
158
+ // **The unit is SECONDS**, not milliseconds. `timeout: 5000` would be an eighty-three minute
159
+ // shutdown budget, which is the same hang with extra steps.
160
+ if (profile.drainTimeoutMs === 0) {
161
+ // `migrate` and `replicator`, for `acquireTimeoutMs`' reason: a run-once role cutting off
162
+ // its own session mid-statement is worse than a slow exit.
163
+ await pool.close();
164
+ return;
165
+ }
166
+ // `performance.now()`, never `Date.now()`, and the reason is this repo rather than NTP: the
167
+ // framework preload freezes `Date` for every test in the tree (`installDeterminism`), so a
168
+ // duration subtracted from `Date.now()` is 0 in all of them — the branch below could not
169
+ // fire, and the test asserting it would have been one that cannot fail.
170
+ const started = performance.now();
171
+ await pool.close({ timeout: profile.drainTimeoutMs / 1000 });
172
+ // The driver RESOLVES when it gives up — it does not reject — so the elapsed time is the only
173
+ // thing that separates "drained" from "abandoned". Reporting it is the point: a drain that
174
+ // silently gave up looks exactly like a clean one, and the work still in flight is lost with
175
+ // no line anywhere saying so. The pool is gone either way, which is why this is terminal.
176
+ if (performance.now() - started >= profile.drainTimeoutMs) {
177
+ throw drainTimeout(profile.drainTimeoutMs, role);
178
+ }
145
179
  },
146
180
  };
147
181
  return client;
package/src/errors.ts CHANGED
@@ -25,6 +25,7 @@ export const DB_OWNED_ERROR_CODES = [
25
25
  'X_DB_STATEMENT_TIMEOUT',
26
26
  'X_DB_LOCK_TIMEOUT',
27
27
  'X_DB_POOL_EXHAUSTED',
28
+ 'X_DB_DRAIN_TIMEOUT',
28
29
  'X_DB_DRIFT',
29
30
  'X_MIGRATION_CONFLICT',
30
31
  'X_MIGRATION_IRREVERSIBLE',
@@ -66,6 +67,7 @@ export const DB_ERROR_TITLES: Readonly<Record<DbOwnedErrorCode, string>> = {
66
67
  X_DB_STATEMENT_TIMEOUT: 'the statement ran past its statement_timeout',
67
68
  X_DB_LOCK_TIMEOUT: 'the statement waited past its lock_timeout',
68
69
  X_DB_POOL_EXHAUSTED: 'no connection was available',
70
+ X_DB_DRAIN_TIMEOUT: 'the pool did not drain inside its shutdown budget',
69
71
  X_DB_DRIFT: 'schema differs from migrations',
70
72
  X_MIGRATION_CONFLICT: 'the migration ledger disagrees with this build',
71
73
  X_MIGRATE_CONCURRENT: 'another migrator holds the migration lock',
@@ -238,6 +240,24 @@ export const driverError = (detail: string, sourceError: unknown): DbError => {
238
240
  });
239
241
  };
240
242
 
243
+ /**
244
+ * `close()` gave up waiting for the pool. TERMINAL, and deliberately not retryable: the pool is
245
+ * gone either way — `close()` clears the handle before it awaits — so a caller that retried would
246
+ * be closing a pool that no longer exists. What this reports is that connections were still held
247
+ * when the process stopped waiting, which is a fact about the shutdown an operator has to see.
248
+ *
249
+ * The alternative was to resolve quietly on the deadline, and that is the version that hides the
250
+ * bug: a drain that silently gave up looks exactly like a clean one, and the rows still in flight
251
+ * are lost with no line anywhere saying so.
252
+ */
253
+ export const drainTimeout = (ms: number, role: string): DbError =>
254
+ new DbError({
255
+ code: 'X_DB_DRAIN_TIMEOUT',
256
+ cause: `the ${role} pool still held connections after ${String(ms)}ms, so close() stopped waiting`,
257
+ fix: `find the statement that will not finish — psql "$DATABASE_URL" -c "select pid, state, query from pg_stat_activity where state <> 'idle'" — or raise drainTimeoutMs in createPostgresClient({ profile }) for the ${role} role`,
258
+ meta: { drainTimeoutMs: ms, role },
259
+ });
260
+
241
261
  /**
242
262
  * The pool answered nothing inside `acquireTimeoutMs`. Distinct from the server's own `53300` and
243
263
  * deliberately the same code: to a caller both mean "there was no connection for this unit of
@@ -1,4 +1,4 @@
1
- // Single responsibility: the five numbers a Postgres pool runs on — the per-role defaults, the one
1
+ // Single responsibility: the six numbers a Postgres pool runs on — the per-role defaults, the one
2
2
  // environment override an operator may layer over them, and the screen every resolved profile
3
3
  // passes. Split from `client.ts`, which now owns connecting and nothing about sizing.
4
4
 
@@ -25,6 +25,24 @@ export interface PoolProfile {
25
25
  * kills the pod, and the replacement inherits the same saturated database.
26
26
  */
27
27
  readonly acquireTimeoutMs: number;
28
+ /**
29
+ * How long `close()` may wait for the pool to drain before `X_DB_DRAIN_TIMEOUT`. 0 waits forever.
30
+ *
31
+ * **A drain that cannot finish is the failure this bounds, and it is not hypothetical.** Measured
32
+ * against a real Postgres, three runs per case: `Bun.SQL`'s `end()` waits on an outstanding
33
+ * RESERVED connection and never stops waiting — 3 of 3 on Bun 1.3.14 *and* 3 of 3 on 1.4.0, with
34
+ * no database outage involved at all. Once that connection's backend has been terminated it
35
+ * becomes a race, which 1.3.14 loses 3 of 3 and 1.4.0 loses 1 of 3. So the runtime is not the
36
+ * variable; an unbounded await is (#394).
37
+ *
38
+ * What that cost, before this: `releaseQueue` awaits `db.close()`, so a role whose database went
39
+ * away mid-shutdown never finished shutting down. A container that will not drain is drained by
40
+ * SIGKILL, and the operator's only signal is a pod that took its full termination grace period.
41
+ *
42
+ * `migrate` and `replicator` wait forever, deliberately, for `acquireTimeoutMs`' reason: a
43
+ * run-once role cutting off its own session mid-statement is worse than a slow exit.
44
+ */
45
+ readonly drainTimeoutMs: number;
28
46
  }
29
47
 
30
48
  /** Sized per role because the failure modes differ: RPS bursts vs. queue depth vs. run-once. */
@@ -35,6 +53,7 @@ export const POOL_PROFILES = Object.freeze<Record<Role, PoolProfile>>({
35
53
  idleTimeoutMs: 30_000,
36
54
  lockTimeoutMs: 0,
37
55
  acquireTimeoutMs: 5_000,
56
+ drainTimeoutMs: 5_000,
38
57
  },
39
58
  sync: {
40
59
  max: 10,
@@ -42,6 +61,7 @@ export const POOL_PROFILES = Object.freeze<Record<Role, PoolProfile>>({
42
61
  idleTimeoutMs: 60_000,
43
62
  lockTimeoutMs: 0,
44
63
  acquireTimeoutMs: 5_000,
64
+ drainTimeoutMs: 5_000,
45
65
  },
46
66
  worker: {
47
67
  max: 8,
@@ -49,6 +69,7 @@ export const POOL_PROFILES = Object.freeze<Record<Role, PoolProfile>>({
49
69
  idleTimeoutMs: 30_000,
50
70
  lockTimeoutMs: 0,
51
71
  acquireTimeoutMs: 10_000,
72
+ drainTimeoutMs: 15_000,
52
73
  },
53
74
  scheduler: {
54
75
  max: 2,
@@ -56,6 +77,7 @@ export const POOL_PROFILES = Object.freeze<Record<Role, PoolProfile>>({
56
77
  idleTimeoutMs: 60_000,
57
78
  lockTimeoutMs: 0,
58
79
  acquireTimeoutMs: 10_000,
80
+ drainTimeoutMs: 5_000,
59
81
  },
60
82
  // `migrate` waits: its pool is `max: 1` and the advisory-lock pin holds it for the whole run, so
61
83
  // a deadline here would refuse the migration's own session. The wait that needed bounding is the
@@ -66,6 +88,7 @@ export const POOL_PROFILES = Object.freeze<Record<Role, PoolProfile>>({
66
88
  idleTimeoutMs: 10_000,
67
89
  lockTimeoutMs: 3_000,
68
90
  acquireTimeoutMs: 0,
91
+ drainTimeoutMs: 0,
69
92
  },
70
93
  replicator: {
71
94
  max: 4,
@@ -73,6 +96,7 @@ export const POOL_PROFILES = Object.freeze<Record<Role, PoolProfile>>({
73
96
  idleTimeoutMs: 60_000,
74
97
  lockTimeoutMs: 0,
75
98
  acquireTimeoutMs: 0,
99
+ drainTimeoutMs: 0,
76
100
  },
77
101
  });
78
102
 
@@ -99,13 +123,13 @@ export function poolMaxFromEnv(): Partial<PoolProfile> {
99
123
  }
100
124
 
101
125
  /**
102
- * The five numbers a pool runs on, screened on the MERGED profile — an override is spread over a
126
+ * The six numbers a pool runs on, screened on the MERGED profile — an override is spread over a
103
127
  * role default the caller never restated, so the resolved object is the only one that can be
104
128
  * judged. Every one of them is a plausible `Number(process.env.…)`, which is `NaN` for an unset
105
- * variable and not nullish, so `??` and the spread both keep it. None of the five then fails
129
+ * variable and not nullish, so `??` and the spread both keep it. None of the six then fails
106
130
  * loudly: `idleTimeout: NaN` goes to `Bun.SQL`, `statement_timeout=NaN` goes into the libpq
107
131
  * options string for the SERVER to reject on connect, and a timer given `NaN` fires at 1ms in this
108
- * Bun — so a pool with free connections reports itself exhausted. `0` stays legal for the three
132
+ * Bun — so a pool with free connections reports itself exhausted. `0` stays legal for the five
109
133
  * budgets that document it as "no bound"; `max` is at least one connection, or nothing can run.
110
134
  */
111
135
  export function assertPoolProfile(profile: PoolProfile): PoolProfile {
@@ -121,5 +145,6 @@ export function assertPoolProfile(profile: PoolProfile): PoolProfile {
121
145
  whole('idleTimeoutMs', profile.idleTimeoutMs, 0);
122
146
  whole('lockTimeoutMs', profile.lockTimeoutMs, 0);
123
147
  whole('acquireTimeoutMs', profile.acquireTimeoutMs, 0);
148
+ whole('drainTimeoutMs', profile.drainTimeoutMs, 0);
124
149
  return profile;
125
150
  }
@@ -2,7 +2,7 @@
2
2
  // and giving back a reservation that arrives after the deadline has passed. Split from `client.ts`,
3
3
  // which now asks for a pin rather than owning what "waited too long" means.
4
4
 
5
- import type { BunSqlDriver, BunSqlReserved } from './bun-sql';
5
+ import { type BunSqlDriver, type BunSqlReserved, releaseReserved } from './bun-sql';
6
6
  import { poolAcquireTimeout } from './errors';
7
7
  import type { PoolProfile } from './pool-profile';
8
8
 
@@ -42,7 +42,7 @@ export async function reserveWithin(
42
42
  // Attached unconditionally so a rejection arriving after we gave up is handled, not unhandled.
43
43
  void pending.then(
44
44
  (late) => {
45
- if (expired) late.release();
45
+ if (expired) releaseReserved(late);
46
46
  },
47
47
  () => undefined,
48
48
  );