@ultimat3/db 11.1.0 → 11.2.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
@@ -137,6 +137,41 @@ that would start attempt two answers `25P01 ROLLBACK TO SAVEPOINT can only be us
137
137
  blocks`. There is nothing to retry into, and an author who believes they hold a budget they do not
138
138
  is worse off than one who is told.
139
139
 
140
+ **A re-run waits first, `As of 2026-08-23`, and the default still waits not at all.** The loop had
141
+ NO backoff: two transactions that deadlocked woke in the same microsecond, took the same locks in
142
+ the same order, and one of them lost again — so a `retry: 8` budget was spent inside one round
143
+ trip's worth of wall clock, which is the deadlock reproduced rather than resolved.
144
+ `transaction-backoff.ts` is the schedule: `@ultimat3/core`'s `backoffDelay`, exponential from
145
+ **10ms**, capped at **500ms**, **full** jitter. The constants are contention's, not an outage's — the
146
+ winner of the race is already committing, and this loop holds a connection on a request's critical
147
+ path, so ai's 500ms base and jobs' one-second base would turn a recovered transaction into a
148
+ timed-out one. Full jitter because the two callers whose retries must not re-collide are, by
149
+ construction, scheduled at the same offset from the same event. Nothing waits when `retry` is 0 or
150
+ absent, and nothing waits after the LAST attempt. `{ sleep, random }` on `TransactionOptions` are
151
+ the injection seams and production passes neither.
152
+
153
+ **Four codes are classified `retryable`, and the terminal ones are deliberately NOT classified,
154
+ `As of 2026-08-23`.** `DB_ERROR_RETRY` registers `X_DB_SERIALIZATION_FAILURE`, `X_DB_LOCK_TIMEOUT`,
155
+ `X_DB_POOL_EXHAUSTED` and `X_MIGRATE_CONCURRENT` — each is a resource that frees. Before it, this
156
+ package classified nothing, so `X_DB_SERIALIZATION_FAILURE` rendered `retry: "terminal"` in every
157
+ problem document while its own `fix:` line read `withTransaction(fn, { retry: 3 })`.
158
+
159
+ The half that needs the argument is the codes left OUT. Core's shape (`CORE_ERROR_RETRY` lists only
160
+ the exceptions) rather than `@ultimat3/scraping`'s exhaustive one, because a REGISTERED `terminal` is
161
+ not the same as an unclassified code: `@ultimat3/jobs`' `nextRetryForError` dead-letters the first on
162
+ attempt 1 and keeps the attempt count for the second. `X_DB_UNAVAILABLE: 'terminal'` is defensible
163
+ for an HTTP client — four of its six throw sites are permanent config faults — and would dead-letter
164
+ every in-flight job the moment Postgres fails over. A code that means two things to two readers stays
165
+ unclassified until it is two codes. `errors-retry.test.ts` asserts the absence, so adding one is a
166
+ failing test first.
167
+
168
+ **What did NOT move down is core's `retry()` executor.** It stops on a `terminal` classification and
169
+ retries everything else, so an UNCLASSIFIED throw is retried — and the value caught here is a raw
170
+ driver error carrying a SQLSTATE, which core cannot see and nobody classified. Adopting it would
171
+ have re-run `fn` on a unique violation, a statement timeout and a throw from `fn` itself.
172
+ `isRetryableState` stays the guard, `40001`/`40P01` stays this package's Postgres knowledge, and only
173
+ the arithmetic is core's.
174
+
140
175
  **`BEGIN` re-derives its isolation level from the closed set, `As of 2026-08-23`.** `BEGIN` takes
141
176
  no parameters, so `beginStatement` is one of the two statements here built as TEXT — and the level
142
177
  was `options.isolation.toUpperCase()` spliced into it. The TYPE is not the guard: the value reaches
package/README.md CHANGED
@@ -28,7 +28,7 @@ await withTransaction(async (tx) => {
28
28
  | `sql` / `raw` / `identifier` / `literal` / `join` | fragment builders |
29
29
  | `db()` / `baseClient()` / `setDbClient()` | the ambient client; `db()` returns the open tx if any |
30
30
  | `DbTx.origin` | `As of 2026-08`: the client the transaction was **opened on** — `options.client` or `baseClient()`, never the reservation it runs statements through. `@ultimat3/entity` compares a pinned repository's client against it, so a pinned repo joins its own shard's transaction instead of being refused |
31
- | `withTransaction()` / `currentTx()` | transaction scope; `currentTx()` is the outbox seam. `{ retry: n }` (`As of 2026-08`) re-runs `fn` from the top on a `40001`/`40P01` and on nothing else — default 0, so `fn` must be idempotent before you ask for it |
31
+ | `withTransaction()` / `currentTx()` | transaction scope; `currentTx()` is the outbox seam. `{ retry: n }` (`As of 2026-08`) re-runs `fn` from the top on a `40001`/`40P01` and on nothing else — default 0, so `fn` must be idempotent before you ask for it. Each re-run **waits first**, `As of 2026-08-23`: exponential from 10ms, capped at 500ms, full jitter (`@ultimat3/core`'s `backoffDelay`). A budget of 0 waits not at all |
32
32
  | `sqlState()` / `sqlStateCode()` / `isRetryableState()` / `SQLSTATE` | `As of 2026-08`: the SQLSTATE a driver error carries, and the closed table from it to a code. `Bun.SQL` puts it on `errno`; PGlite puts it on `code`; **one** reader answers for both |
33
33
  | `migrate()` / `rollback()` / `readLedger()` | the `x_migrations` ledger |
34
34
  | `statementsOf()` | `As of 2026-08`: a SQL script → the statements a driver sends one at a time. One send is one statement, so `migrate()` splits with this — a `;` inside a literal, an identifier, a dollar-quoted body or a comment is data |
@@ -316,6 +316,14 @@ always on the error and nothing read it, so a `23505` from two clicks racing a s
316
316
  "cannot reach the database" and paged on-call for an outage that never happened. The table
317
317
  (`sqlstate.ts`) is closed; everything outside it is still `X_DB_UNAVAILABLE`, unchanged.
318
318
 
319
+ **Four of them carry `retry: "retryable"` in `--json`, `As of 2026-08-23`** — `DB_ERROR_RETRY`:
320
+ `X_DB_SERIALIZATION_FAILURE`, `X_DB_LOCK_TIMEOUT`, `X_DB_POOL_EXHAUSTED`, `X_MIGRATE_CONCURRENT`.
321
+ Each is a resource that frees, so the same call has a real chance of a different answer with no edit
322
+ in between. Everything else keeps core's fail-closed `terminal`, and is deliberately left
323
+ UNREGISTERED rather than registered as terminal: `@ultimat3/jobs` dead-letters a registered
324
+ `terminal` on attempt 1, so classifying `X_DB_UNAVAILABLE` that way would dead-letter every in-flight
325
+ job the moment Postgres fails over.
326
+
319
327
  | Code | Meaning |
320
328
  |---|---|
321
329
  | `X_DB_UNAVAILABLE` | no reachable database, or a SQLSTATE the table does not name; `fix:` names `DATABASE_URL` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/db",
3
- "version": "11.1.0",
3
+ "version": "11.2.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": "11.1.0"
34
+ "@ultimat3/core": "11.2.0"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "@electric-sql/pglite": ">=0.5.0"
package/src/errors.ts CHANGED
@@ -2,9 +2,11 @@
2
2
  // fixes the situation — `X_DB_DRIFT` is the flagship and its rendering is byte-for-byte
3
3
  // pinned by the framework contract, so change its strings only with the contract.
4
4
 
5
+ import type { ErrorRetry } from '@ultimat3/core';
5
6
  import {
6
7
  describeValue,
7
8
  registerErrorCodes,
9
+ registerErrorRetry,
8
10
  renderThrowable,
9
11
  stringField,
10
12
  UltimateError,
@@ -80,6 +82,60 @@ registerErrorCodes(
80
82
  Object.fromEntries(Object.entries(DB_ERROR_TITLES).map(([code, title]) => [code, { title }])),
81
83
  );
82
84
 
85
+ /**
86
+ * May a client run this again? Unclassified means `terminal` — core fails closed — and until
87
+ * 2026-08-23 this package classified nothing, so `X_DB_SERIALIZATION_FAILURE` reached every HTTP
88
+ * client carrying `retry: "terminal"` while its own `fix:` line read
89
+ * `withTransaction(fn, { retry: 3 })`. The document contradicted the instruction beside it.
90
+ *
91
+ * The rule, stated once: **retryable means the same call, made again, has a real chance of a
92
+ * different answer, with no edit in between.** A resource that frees qualifies — a lock, a
93
+ * connection, a serialization race, another migrator's advisory lock. A statement about the data or
94
+ * about this deployment's configuration does not.
95
+ *
96
+ * **Only the exceptions are listed**, which is core's own `CORE_ERROR_RETRY` shape and not
97
+ * `@ultimat3/scraping`'s exhaustive one, for a reason specific to this package: a REGISTERED
98
+ * `terminal` is read by `@ultimat3/jobs` as "dead-letter on attempt 1"
99
+ * (`retry-classification.ts`), where an unclassified code keeps the attempt count. Registering
100
+ * `X_DB_UNAVAILABLE: 'terminal'` — defensible on the client side, since four of its six throw sites
101
+ * are permanent config faults — would therefore dead-letter every in-flight job the moment Postgres
102
+ * fails over, which is the opposite of what that code means to a worker. A code that means two
103
+ * things to two readers stays unclassified until it is two codes.
104
+ */
105
+ export const DB_ERROR_RETRY = {
106
+ // The canonical try-again. Postgres aborted one of two transactions precisely SO one of them can
107
+ // be re-run, and `withTransaction(fn, { retry })` is the framework's answer to it.
108
+ X_DB_SERIALIZATION_FAILURE: 'retryable',
109
+ // `55P03 lock_not_available` — `lock_timeout` fired while somebody else held the lock. The
110
+ // blocker commits or rolls back and the lock is free; nothing about the statement was refused.
111
+ X_DB_LOCK_TIMEOUT: 'retryable',
112
+ // Both halves are "no connection RIGHT NOW": the server's `53300`/`53200` and this pool's own
113
+ // acquire timeout. A slot frees when an in-flight unit of work finishes. Not `retry-after`, which
114
+ // is the spelling for a responder that NAMED a time — nothing here carries `retryAfterSeconds`,
115
+ // and inventing a number for `Retry-After` would be a guess presented as an answer.
116
+ X_DB_POOL_EXHAUSTED: 'retryable',
117
+ // Another migrator holds the advisory lock and will let it go when it finishes. This is the one
118
+ // code here a deploy pipeline reads: a `ROLE=migrate` job that exits non-zero on it should come
119
+ // back, which is what `backoffLimit` already does, and `terminal` said not to.
120
+ X_MIGRATE_CONCURRENT: 'retryable',
121
+ } as const satisfies Readonly<Partial<Record<DbOwnedErrorCode, ErrorRetry>>>;
122
+
123
+ // `Partial`, so the table may be a SUBSET — but every key is still checked against the owned set, so
124
+ // a typo or a renamed code is a build error rather than a classification for a code nothing throws.
125
+ //
126
+ // Left to the fail-closed default, deliberately, each for its own reason:
127
+ // X_DB_UNAVAILABLE two failures in one code — see the note above
128
+ // X_DB_STATEMENT_TIMEOUT `57014`, and this package's fix for it is "add the index": an edit.
129
+ // The queued-behind-a-lock case has its own code, above
130
+ // X_DB_UNIQUE_VIOLATION the same row, the same constraint, the same refusal
131
+ // X_DB_FOREIGN_KEY_VIOLATION the parent it names is still gone
132
+ // X_DB_DRIFT, X_MIGRATION_*, X_SQL_UNSAFE, X_BRANCH_EXISTS
133
+ // author-time and deploy-time faults; every fix is a command or an edit
134
+ //
135
+ // The borrowed codes are absent for a different reason: `registerErrorRetry` refuses a code the
136
+ // framework owns, and `X_ENV_MISSING` / `X_INVARIANT` belong to core, which classifies its own.
137
+ registerErrorRetry(DB_ERROR_RETRY);
138
+
83
139
  export interface DbErrorInit {
84
140
  readonly code: DbErrorCode;
85
141
  readonly cause: string;
package/src/index.ts CHANGED
@@ -61,6 +61,7 @@ export {
61
61
  branchExists,
62
62
  branchNameInvalid,
63
63
  DB_ERROR_CODES,
64
+ DB_ERROR_RETRY,
64
65
  DB_ERROR_TITLES,
65
66
  DbError,
66
67
  dbDrift,
@@ -0,0 +1,43 @@
1
+ // Single responsibility: how long a lost serialization race waits before `withTransaction` re-runs
2
+ // `fn`. The curve is `@ultimat3/core`'s — this file is the two constants, and the case for them.
3
+
4
+ import { backoffDelay, type Random } from '@ultimat3/core';
5
+
6
+ /**
7
+ * A serialization conflict is CONTENTION, not overload, and the constants say so.
8
+ *
9
+ * `base: 10` — the transaction that won the race is already committing, and one round trip to a
10
+ * Postgres in the same network is under 2ms. A provider-outage base (`@ultimat3/ai` uses 500ms,
11
+ * `@ultimat3/jobs` a second) would put a `retry: 8` budget seconds past the deadline of the request
12
+ * it is serving, which turns a recovered transaction into a timed-out one.
13
+ *
14
+ * `max: 500` — this loop runs on a request's critical path, holding a connection nothing else can
15
+ * use. The eight-attempt worst case is under a second of waiting; a minute-long ceiling would be a
16
+ * queue's, and a queue can afford one because nobody is waiting on the other end.
17
+ *
18
+ * `full` jitter — the only mode that decorrelates. Two transactions that just deadlocked are, by
19
+ * construction, two callers whose retries would otherwise be scheduled at the same offset from the
20
+ * same event, and re-colliding is the failure this wait exists to prevent. `equal` keeps a latency
21
+ * floor for a client that must not be starved, which is not this.
22
+ */
23
+ export const TRANSACTION_RETRY_BACKOFF = {
24
+ base: 10,
25
+ max: 500,
26
+ curve: 'exponential',
27
+ jitter: 'full',
28
+ } as const;
29
+
30
+ /**
31
+ * Milliseconds before attempt `attempt + 1`. 1-based, like core's, and `random` is injected so the
32
+ * schedule is a list a test can assert rather than a range it can only sample.
33
+ */
34
+ export function serializationRetryDelayMs(attempt: number, random?: Random): number {
35
+ return backoffDelay({
36
+ attempt,
37
+ base: TRANSACTION_RETRY_BACKOFF.base,
38
+ max: TRANSACTION_RETRY_BACKOFF.max,
39
+ curve: TRANSACTION_RETRY_BACKOFF.curve,
40
+ jitter: TRANSACTION_RETRY_BACKOFF.jitter,
41
+ random,
42
+ });
43
+ }
@@ -3,11 +3,13 @@
3
3
  // the transactional outbox is only atomic because `currentTx()` finds this store. Nesting maps
4
4
  // to SAVEPOINTs, so an inner failure never silently aborts the outer unit of work.
5
5
 
6
+ import type { Random } from '@ultimat3/core';
6
7
  import { assert, asyncContext, nanoid } from '@ultimat3/core';
7
8
  import { baseClient, type DbClient, type DbConnection, isReservable } from './client';
8
9
  import { isolationLevelInvalid, serializationExhausted } from './errors';
9
10
  import { raw, type SqlFragment } from './sql';
10
11
  import { isRetryableState } from './sqlstate';
12
+ import { serializationRetryDelayMs } from './transaction-backoff';
11
13
 
12
14
  export interface DbTx extends DbClient {
13
15
  readonly id: string;
@@ -56,8 +58,17 @@ export interface TransactionOptions {
56
58
  *
57
59
  * **`fn` re-runs from the top, so it must be idempotent** — the same contract `job.handle` has.
58
60
  * `onRollback` undos fire before each retry, in reverse registration order.
61
+ *
62
+ * Each re-run waits first (`transaction-backoff.ts`). A budget of 0 waits not at all.
59
63
  */
60
64
  readonly retry?: number | undefined;
65
+ /**
66
+ * The wait between attempts, and the roll behind its jitter. Injected for one reason — a schedule
67
+ * provable only by waiting for it is a schedule no test pins — and production passes neither.
68
+ * They are only ever read when `retry` is 1 or more.
69
+ */
70
+ readonly sleep?: ((ms: number) => Promise<void>) | undefined;
71
+ readonly random?: Random | undefined;
61
72
  }
62
73
 
63
74
  interface TxState {
@@ -263,6 +274,9 @@ export async function withTransaction<T>(
263
274
  }
264
275
 
265
276
  const attempts = (options.retry ?? 0) + 1;
277
+ // `Bun.sleep`, never a `node:timers` import: this is the runtime's own, and `migrate.ts` polls
278
+ // the advisory lock through the same call.
279
+ const sleep = options.sleep ?? ((ms: number): Promise<void> => Bun.sleep(ms));
266
280
  let last: unknown;
267
281
  for (let attempt = 1; attempt <= attempts; attempt += 1) {
268
282
  try {
@@ -278,6 +292,12 @@ export async function withTransaction<T>(
278
292
  // who has no budget.
279
293
  if (attempts === 1) throw error;
280
294
  last = error;
295
+ // Jittered, and only between attempts. Re-running instantly is what this loop did until
296
+ // 2026-08-23, and it is the deadlock reproduced rather than resolved: both losers wake in the
297
+ // same microsecond, take the same locks in the same order, and one of them loses again — so a
298
+ // budget of 8 was spent inside a single round trip's worth of wall clock. Nothing waits after
299
+ // the LAST attempt: there is nothing behind it to give the contention room for.
300
+ if (attempt < attempts) await sleep(serializationRetryDelayMs(attempt, options.random));
281
301
  }
282
302
  }
283
303
  throw serializationExhausted(attempts, last);