@ultimat3/db 18.0.0 → 19.1.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.1.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.1.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
@@ -19,12 +19,15 @@ import { type DbSqlStateCode, sqlState, sqlStateCode } from './sqlstate';
19
19
  */
20
20
  export const DB_OWNED_ERROR_CODES = [
21
21
  'X_DB_UNAVAILABLE',
22
+ 'X_DB_SCHEMA_STALE',
23
+ 'X_DB_STATEMENT_FAILED',
22
24
  'X_DB_UNIQUE_VIOLATION',
23
25
  'X_DB_FOREIGN_KEY_VIOLATION',
24
26
  'X_DB_SERIALIZATION_FAILURE',
25
27
  'X_DB_STATEMENT_TIMEOUT',
26
28
  'X_DB_LOCK_TIMEOUT',
27
29
  'X_DB_POOL_EXHAUSTED',
30
+ 'X_DB_DRAIN_TIMEOUT',
28
31
  'X_DB_DRIFT',
29
32
  'X_MIGRATION_CONFLICT',
30
33
  'X_MIGRATION_IRREVERSIBLE',
@@ -60,12 +63,15 @@ export type DbErrorCode = (typeof DB_ERROR_CODES)[number];
60
63
 
61
64
  export const DB_ERROR_TITLES: Readonly<Record<DbOwnedErrorCode, string>> = {
62
65
  X_DB_UNAVAILABLE: 'cannot reach the database',
66
+ X_DB_SCHEMA_STALE: 'the statement names a table or column the database does not have',
67
+ X_DB_STATEMENT_FAILED: 'the database refused the statement',
63
68
  X_DB_UNIQUE_VIOLATION: 'a unique constraint rejected the row',
64
69
  X_DB_FOREIGN_KEY_VIOLATION: 'a foreign key constraint rejected the row',
65
70
  X_DB_SERIALIZATION_FAILURE: 'the transaction lost a serialization race',
66
71
  X_DB_STATEMENT_TIMEOUT: 'the statement ran past its statement_timeout',
67
72
  X_DB_LOCK_TIMEOUT: 'the statement waited past its lock_timeout',
68
73
  X_DB_POOL_EXHAUSTED: 'no connection was available',
74
+ X_DB_DRAIN_TIMEOUT: 'the pool did not drain inside its shutdown budget',
69
75
  X_DB_DRIFT: 'schema differs from migrations',
70
76
  X_MIGRATION_CONFLICT: 'the migration ledger disagrees with this build',
71
77
  X_MIGRATE_CONCURRENT: 'another migrator holds the migration lock',
@@ -125,7 +131,9 @@ export const DB_ERROR_RETRY = {
125
131
  // a typo or a renamed code is a build error rather than a classification for a code nothing throws.
126
132
  //
127
133
  // Left to the fail-closed default, deliberately, each for its own reason:
128
- // X_DB_UNAVAILABLE two failures in one code — see the note above
134
+ // X_DB_UNAVAILABLE a connection failure — see the note above
135
+ // X_DB_SCHEMA_STALE `42P01`/`42703`: the migration is the fix, and no attempt runs it
136
+ // X_DB_STATEMENT_FAILED the server's verdict on the SQL; the same SQL gets the same verdict
129
137
  // X_DB_STATEMENT_TIMEOUT `57014`, and this package's fix for it is "add the index": an edit.
130
138
  // The queued-behind-a-lock case has its own code, above
131
139
  // X_DB_UNIQUE_VIOLATION the same row, the same constraint, the same refusal
@@ -169,7 +177,9 @@ export class DbError extends UltimateError {
169
177
  export const dbUnavailable = (detail: string, sourceError?: unknown): DbError =>
170
178
  new DbError({
171
179
  code: 'X_DB_UNAVAILABLE',
172
- cause: detail,
180
+ // The driver's own words ride along when there are any: `ECONNREFUSED 127.0.0.1:5432` is the
181
+ // half of "cannot reach the database" an operator acts on, and it was dropped on the floor.
182
+ cause: sourceError === undefined ? detail : `${detail}: ${renderThrowable(sourceError)}`,
173
183
  fix: 'set DATABASE_URL to a reachable Postgres url, or run `x dev` to use the embedded PGlite',
174
184
  sourceError,
175
185
  });
@@ -184,6 +194,9 @@ export const dbUnavailable = (detail: string, sourceError?: unknown): DbError =>
184
194
  * of one; `driverError` substitutes the placeholder when the driver reported none.
185
195
  */
186
196
  const SQLSTATE_FIXES = Object.freeze<Record<DbSqlStateCode, string>>({
197
+ X_DB_SCHEMA_STALE:
198
+ 'x db gen "<what changed>" && x db migrate # the entity is ahead of the schema; ' +
199
+ 'if the migration already exists, only the migrate half is due',
187
200
  X_DB_UNIQUE_VIOLATION:
188
201
  'upsertAll(rows, { onConflict: [...] }) over the columns {constraint} covers — ' +
189
202
  'or catch X_DB_UNIQUE_VIOLATION and answer 409, which is what a raced signup is',
@@ -217,19 +230,31 @@ const UNNAMED_CONSTRAINT = 'the constraint named in cause';
217
230
  * given where it is true, and a new SQLSTATE arrives as a new row here rather than as a new
218
231
  * `catch` at a call site.
219
232
  */
220
- export const driverError = (detail: string, sourceError: unknown): DbError => {
221
- const code = sqlStateCode(sourceError);
222
- if (code === undefined) return dbUnavailable(detail, sourceError);
233
+ export const driverError = (statement: string, sourceError: unknown): DbError => {
223
234
  const state = sqlState(sourceError);
235
+ // No SQLSTATE means the failure never reached a server — a refused socket, a closed pool, a
236
+ // driver that would not load. THAT is unavailability, and it is the only thing that is.
237
+ if (state === undefined) return dbUnavailable(`statement failed: ${statement}`, sourceError);
238
+ // A state the table does not classify still proves the server answered: it read the statement
239
+ // and refused it. `X_DB_STATEMENT_FAILED` says so and carries the server's own words, where
240
+ // `X_DB_UNAVAILABLE` said "set DATABASE_URL" to a developer whose database was fine.
241
+ const code = sqlStateCode(sourceError) ?? 'X_DB_STATEMENT_FAILED';
224
242
  const constraint = stringField(sourceError, 'constraint');
225
243
  return new DbError({
226
244
  code,
227
- cause: `${detail}: ${renderThrowable(sourceError)} [SQLSTATE ${state ?? '?????'}]`,
245
+ // The server's SQLSTATE and message FIRST, the statement after: a cause is rendered on one
246
+ // line and cut when long, and a column list of any width put the one thing an author needs —
247
+ // `column "host_id" does not exist` — past the cut. The statement is `statementExcerpt`'d by
248
+ // the caller, so the whole line has a known ceiling.
249
+ cause: `[SQLSTATE ${state}] ${renderThrowable(sourceError)} — statement: ${statement}`,
228
250
  // A FUNCTION as the replacement, never the string: `String.replace` expands `$&`, `` $` ``,
229
251
  // `$'` and `$$` inside a replacement literal, and a constraint name is the server's, not
230
252
  // ours — `$` is legal in a Postgres identifier, so `posts_$&_key` would splice the matched
231
253
  // `{constraint}` back into the fix line an author is meant to paste.
232
- fix: SQLSTATE_FIXES[code].replace('{constraint}', () => constraint ?? UNNAMED_CONSTRAINT),
254
+ fix:
255
+ code === 'X_DB_STATEMENT_FAILED'
256
+ ? `psql "$DATABASE_URL" -c "<the statement in cause>" # SQLSTATE ${state} is the server's verdict on it: fix the SQL or the data it names, not the connection`
257
+ : SQLSTATE_FIXES[code].replace('{constraint}', () => constraint ?? UNNAMED_CONSTRAINT),
233
258
  meta: {
234
259
  sqlState: state,
235
260
  ...(constraint === undefined ? {} : { constraint }),
@@ -238,6 +263,24 @@ export const driverError = (detail: string, sourceError: unknown): DbError => {
238
263
  });
239
264
  };
240
265
 
266
+ /**
267
+ * `close()` gave up waiting for the pool. TERMINAL, and deliberately not retryable: the pool is
268
+ * gone either way — `close()` clears the handle before it awaits — so a caller that retried would
269
+ * be closing a pool that no longer exists. What this reports is that connections were still held
270
+ * when the process stopped waiting, which is a fact about the shutdown an operator has to see.
271
+ *
272
+ * The alternative was to resolve quietly on the deadline, and that is the version that hides the
273
+ * bug: a drain that silently gave up looks exactly like a clean one, and the rows still in flight
274
+ * are lost with no line anywhere saying so.
275
+ */
276
+ export const drainTimeout = (ms: number, role: string): DbError =>
277
+ new DbError({
278
+ code: 'X_DB_DRAIN_TIMEOUT',
279
+ cause: `the ${role} pool still held connections after ${String(ms)}ms, so close() stopped waiting`,
280
+ 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`,
281
+ meta: { drainTimeoutMs: ms, role },
282
+ });
283
+
241
284
  /**
242
285
  * The pool answered nothing inside `acquireTimeoutMs`. Distinct from the server's own `53300` and
243
286
  * deliberately the same code: to a caller both mean "there was no connection for this unit of
package/src/pglite.ts CHANGED
@@ -5,11 +5,12 @@
5
5
 
6
6
  import { statementAttribution } from './attribution';
7
7
  import type { DbConnection, ReservableClient } from './client';
8
- import { DbError, dbUnavailable } from './errors';
8
+ import { DbError, driverError } from './errors';
9
9
  import { expectedQueryLoopReason } from './expected-loop';
10
10
  import { statementObserver } from './observe';
11
11
  import { createTurnQueue } from './pglite-turns';
12
12
  import type { SqlFragment } from './sql';
13
+ import { statementExcerpt } from './statement-excerpt';
13
14
  import { withStatementSpan } from './statement-span';
14
15
  import { inLiveTx } from './transaction';
15
16
 
@@ -142,7 +143,12 @@ export function createPgliteClient(options: PgliteOptions = {}): PgliteClient {
142
143
  try {
143
144
  return await driver.query(fragment.text, fragment.values);
144
145
  } catch (error) {
145
- throw dbUnavailable(`statement failed: ${fragment.text.slice(0, 120)}`, error);
146
+ // `driverError`, as `statement-funnel.ts` already does for Bun's driver: this site passed
147
+ // every failure to `dbUnavailable`, so under `x dev` — which IS this driver when no
148
+ // `DATABASE_URL` is set — a `select` naming a column whose migration had not run answered
149
+ // "cannot reach the database" with the fix "set DATABASE_URL", against a database that was
150
+ // answering fine (measured 2026-09-05). PGlite carries the SQLSTATE on `code`.
151
+ throw driverError(statementExcerpt(fragment.text), error);
146
152
  }
147
153
  }
148
154
 
@@ -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
  );
package/src/sqlstate.ts CHANGED
@@ -14,6 +14,8 @@ import { stringField } from '@ultimat3/core';
14
14
  export const SQLSTATE = Object.freeze({
15
15
  /** `undefined_table` — the ledger's absence is a class, not a message to match on. */
16
16
  undefinedTable: '42P01',
17
+ /** `undefined_column` — an entity edited before the migration that adds the column ran. */
18
+ undefinedColumn: '42703',
17
19
  uniqueViolation: '23505',
18
20
  foreignKeyViolation: '23503',
19
21
  serializationFailure: '40001',
@@ -70,6 +72,7 @@ export function sqlState(error: unknown): string | undefined {
70
72
 
71
73
  /** The codes a SQLSTATE can classify into. `errors.ts` owns their titles and their fixes. */
72
74
  export type DbSqlStateCode =
75
+ | 'X_DB_SCHEMA_STALE'
73
76
  | 'X_DB_UNIQUE_VIOLATION'
74
77
  | 'X_DB_FOREIGN_KEY_VIOLATION'
75
78
  | 'X_DB_SERIALIZATION_FAILURE'
@@ -84,6 +87,11 @@ export type DbSqlStateCode =
84
87
  * class 53, insufficient resources, and both are answered by asking for fewer connections.
85
88
  */
86
89
  export const DB_SQLSTATE_CODES: Readonly<Record<string, DbSqlStateCode>> = Object.freeze({
90
+ // Both class 42 "the schema does not have what this statement names": a table or a column
91
+ // declared in code whose migration has not run. One code, because the instruction is one
92
+ // instruction — generate the migration and apply it — and the cause names which it was.
93
+ [SQLSTATE.undefinedTable]: 'X_DB_SCHEMA_STALE',
94
+ [SQLSTATE.undefinedColumn]: 'X_DB_SCHEMA_STALE',
87
95
  [SQLSTATE.uniqueViolation]: 'X_DB_UNIQUE_VIOLATION',
88
96
  [SQLSTATE.foreignKeyViolation]: 'X_DB_FOREIGN_KEY_VIOLATION',
89
97
  [SQLSTATE.serializationFailure]: 'X_DB_SERIALIZATION_FAILURE',
@@ -94,7 +102,11 @@ export const DB_SQLSTATE_CODES: Readonly<Record<string, DbSqlStateCode>> = Objec
94
102
  [SQLSTATE.outOfMemory]: 'X_DB_POOL_EXHAUSTED',
95
103
  } as const);
96
104
 
97
- /** `undefined` when the state is unknown or absent — the caller then reports unavailability. */
105
+ /**
106
+ * `undefined` when the state is absent or not in the table. What the caller does with that is
107
+ * `driverError`'s decision, not this file's: a state the table does not name still proves the
108
+ * statement REACHED a server, which is the opposite of unavailable.
109
+ */
98
110
  export function sqlStateCode(error: unknown): DbSqlStateCode | undefined {
99
111
  const state = sqlState(error);
100
112
  return state === undefined ? undefined : DB_SQLSTATE_CODES[state];
@@ -10,6 +10,7 @@ import { driverError } from './errors';
10
10
  import { expectedQueryLoopReason } from './expected-loop';
11
11
  import { statementObserver } from './observe';
12
12
  import type { SqlFragment } from './sql';
13
+ import { statementExcerpt } from './statement-excerpt';
13
14
  import { withStatementSpan } from './statement-span';
14
15
 
15
16
  export function rowsOf<T>(result: unknown): readonly T[] {
@@ -42,7 +43,7 @@ async function sendOn(
42
43
  // read it, so a `23505` from two clicks racing a signup told the operator the database was
43
44
  // unreachable and paged on-call for an outage that never happened. Everything the table does
44
45
  // not classify is still `X_DB_UNAVAILABLE`, byte for byte.
45
- throw driverError(`statement failed: ${fragment.text.slice(0, 120)}`, error);
46
+ throw driverError(statementExcerpt(fragment.text), error);
46
47
  }
47
48
  }
48
49