@ultimat3/db 6.0.0 → 8.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
@@ -14,6 +14,7 @@ reaches down to this package for it. **Never** import `entity`, `jobs`, `http` o
14
14
  | Reading a caught value | `renderThrowable()` from core; never `error instanceof Error ? error.message : String(error)` — both halves RUN app code (a `Proxy` trap, `Symbol.toPrimitive`) and `checkDb` backs `/readyz`, where a render that throws is an exception in place of the report the kubelet asked for |
15
15
  | Errors | subclass `DbError`; never `throw new Error` **in source**. A test simulating a *database* failure throws `dbUnavailable()`; a test simulating the *caller's body* failing throws a bare `Error` on purpose — an arbitrary throw is exactly what rollback and disposal must survive, and a `DbError` there would prove the narrower thing |
16
16
  | New code | add to `DB_ERROR_CODES` **and** `DB_ERROR_TITLES` in `errors.ts` |
17
+ | A value ambient across an `await` | `asyncContext<T>(subject)` from `@ultimat3/core` — never `new AsyncLocalStorage`. Three scopes here use it: `transaction.ts`, `attribution.ts`, `expected-loop.ts` |
17
18
  | Exports | explicit in `src/index.ts`; no `export *` |
18
19
  | Files | < 200 LOC, one responsibility, `kebab-case.ts`, test beside source |
19
20
 
@@ -28,6 +29,22 @@ Deliberate cycle (safe — nothing is referenced at module-evaluation time):
28
29
  consults `currentTx()`; `withTransaction` uses `baseClient()`, never `db()`, or it would re-enter
29
30
  itself. Keep both sides `function` declarations so hoisting covers the TDZ.
30
31
 
32
+ **The three ambient scopes open through core's one lazy seam, and that is a build error rather than
33
+ a convention, `As of 2026-08`.** `transaction.ts` (`TxState`), `attribution.ts` (the entity/op
34
+ pair) and `expected-loop.ts` (the reason) each constructed a module-scope `AsyncLocalStorage` until
35
+ issue #255 closed it. A bundler stubs `node:async_hooks` to `{}` — Bun's `target: 'browser'` emits
36
+ `var { AsyncLocalStorage } = (() => ({}))` — so the `new` threw
37
+ `TypeError: undefined is not a constructor` at module **evaluation**, before any app code ran, and
38
+ took every importer of that file down with it. Through `asyncContext<T>(subject)` the module
39
+ evaluates, `get()` answers `undefined` (in a browser nothing IS in flight, so that is the true
40
+ answer) and `run()` throws `X_ASYNC_CONTEXT_UNAVAILABLE` naming the scope. Deferring the
41
+ construction changes nothing a server can observe: the storage is built on the first `get()` or
42
+ `run()` rather than at module load, and `getStore()` outside a scope answers `undefined` either
43
+ way — one object per scope, on first use, in place of one at module evaluation.
44
+ `scripts/async-context-guard.ts` refuses a `new AsyncLocalStorage` — and the import that binds the
45
+ class, aliased or namespaced — anywhere but `packages/core/src/async-context.ts`, and
46
+ `scripts/async-context-guard.test.ts` runs it over the tree in the gate's `unit` step.
47
+
31
48
  `pglite.ts` is a pool of exactly one: PGlite is a single session, so `reserve()` (backed by
32
49
  `pglite-turns.ts`) is what stops two concurrent `BEGIN`s becoming one transaction. Three rules
33
50
  hold it together and none is optional — the plain path takes a turn; a statement issued while a
@@ -45,7 +62,7 @@ seam `observe.ts` already draws.
45
62
 
46
63
  **The second rule fences on `inLiveTx()`, never on `currentTx() !== undefined`** — the two are
47
64
  different questions and reading the second as the first was a cross-transaction write. The
48
- `AsyncLocalStorage` store rides into every promise chain started inside `withTransaction`, so a
65
+ async-context store rides into every promise chain started inside `withTransaction`, so a
49
66
  statement the app forgot to `await` still found a store after COMMIT, skipped the turn queue, and
50
67
  landed inside whichever unit of work held the single session next: measured `BEGIN`, `select 'inside
51
68
  tx'`, `COMMIT`, `BEGIN`, `select 'straggler'`, `select 'inside tx 2'`, `COMMIT` — committed by a
@@ -213,7 +230,7 @@ report and the return value cannot disagree about one statement.
213
230
 
214
231
  `attribution.ts` is `StatementEvent.attribution`'s producer: `withStatementAttribution(entity, op,
215
232
  fn)` runs `fn` with every statement it issues — at any depth, across every `await` — attributed to
216
- that pair, on an `AsyncLocalStorage` the same shape `expected-loop.ts` already uses. Four rules,
233
+ that pair, on an async context the same shape `expected-loop.ts` already uses. Four rules,
217
234
  none optional. **Guard first** — it reads `statementObserver()` before touching the scope at all
218
235
  and, with nothing installed, hands straight to `fn`: one property read, one branch, no object
219
236
  allocated, on the path every statement in the process takes (axiom 6) — which is also why the pair
@@ -263,14 +280,14 @@ the process. The OTel `kind` is `client`; the database is the remote peer.
263
280
 
264
281
  `expected-loop.ts` is the **only** suppression mechanism, and the reason it is a scope rather than
265
282
  a pragma or a list is the same reason `observe.ts` is one observer: a second path is the tax
266
- (axiom 1). `expectedQueryLoop(reason, fn)` rides an `AsyncLocalStorage`, so it survives every
283
+ (axiom 1). `expectedQueryLoop(reason, fn)` rides an async context, so it survives every
267
284
  `await` at any depth and two loops running concurrently never read each other; nesting keeps the
268
285
  innermost reason, because the closest scope is the one describing this loop. A blank reason is
269
286
  `X_INVARIANT` through core's `assert` — no new code for it, and an exemption with no argument is a
270
287
  pragma with extra steps. Three rules. **The funnel stamps, the consumer reads** — `runOn` and
271
288
  `statement()` call `expectedQueryLoopReason()` inside the branch that already found an observer and
272
289
  put the answer on the event as `expected`; a detector that judges a whole request runs long after
273
- every scope in it closed, so reading the ALS later would find nothing. **It suppresses a verdict,
290
+ every scope in it closed, so reading the scope later would find nothing. **It suppresses a verdict,
274
291
  not a statement** — the SQL is still sent, still observed, and the span still opens, so anything
275
292
  that measures still sees the loop and only the thing that warns is told the author already
276
293
  answered. **It costs nothing uninstalled** — the read lives inside the observer branch, so the
package/README.md CHANGED
@@ -261,7 +261,7 @@ reader cannot tell a considered loop from a silenced one.
261
261
 
262
262
  | | |
263
263
  |---|---|
264
- | Scope | an `AsyncLocalStorage`: it survives every `await` at any depth, and two loops running at once never read each other. Nesting keeps the innermost reason |
264
+ | Scope | core's `asyncContext<string>('the expected-loop reason')`, never a `new AsyncLocalStorage` here: it survives every `await` at any depth, and two loops running at once never read each other. Nesting keeps the innermost reason |
265
265
  | What it carries | `StatementEvent.expected`, stamped by both funnels at settle time — a diagnostic judging a whole request runs after every scope in it closed |
266
266
  | What it suppresses | a **verdict**, never a statement. The SQL is still sent, still observed, still a span: only the thing that warns is told the author already answered |
267
267
  | What it costs | nothing without a diagnostic — the reason is read inside the branch that already checks for an installed observer |
@@ -270,6 +270,24 @@ The framework's own deliberate loops declare themselves at source: `migrate()` a
270
270
  (one transaction per migration, so a failure leaves an exact ledger) and `@ultimat3/admin`'s
271
271
  cross-entity search (one indexed lookup per text field).
272
272
 
273
+ **Every ambient scope in this package opens through `asyncContext<T>(subject)` from
274
+ `@ultimat3/core`** — the transaction store, the attribution pair and this reason — and none of the
275
+ three constructs an `AsyncLocalStorage`, `As of 2026-08`. What changed is what a browser bundle
276
+ does with these three modules: a bundler stubs `node:async_hooks` to `{}`, so the module-scope `new`
277
+ threw `TypeError: undefined is not a constructor` at module **evaluation** — before a line of app
278
+ code ran, and taking every importer of the file with it. Now the module evaluates, a read answers
279
+ `undefined` (nothing is in flight in a browser, so that is the true answer), and a write throws
280
+ `X_ASYNC_CONTEXT_UNAVAILABLE` naming the scope it could not open. A server saves no allocation —
281
+ the store is built on the first `get()` **or** `run()`, so a read constructs it too. What the
282
+ laziness costs is nothing observable: `getStore()` outside a scope answers `undefined` whether the
283
+ storage was ever constructed or not. Not a claim that the whole package
284
+ bundles: `pglite-branch.ts` imports `node:fs/promises`, which is a separate question.
285
+
286
+ The rule is a **build error**, not a convention: `scripts/async-context-guard.ts` refuses a
287
+ `new AsyncLocalStorage` — and the import that binds the class, aliased or namespaced — anywhere but
288
+ `packages/core/src/async-context.ts`, and `scripts/async-context-guard.test.ts` runs it over the
289
+ tree in the gate's `unit` step.
290
+
273
291
  ## A statement knows who compiled it
274
292
 
275
293
  `As of 2026-08`: `StatementEvent.attribution` is no longer always `undefined`.
@@ -282,7 +300,7 @@ return withStatementAttribution('members', 'findById', () =>
282
300
 
283
301
  | | |
284
302
  |---|---|
285
- | Scope | an `AsyncLocalStorage`, `expectedQueryLoop()`'s own shape: it survives every `await` at any depth, and nesting keeps the innermost pair |
303
+ | Scope | core's `asyncContext<StatementAttribution>()`, `expectedQueryLoop()`'s own shape: it survives every `await` at any depth, and nesting keeps the innermost pair |
286
304
  | What it carries | `StatementEvent.attribution`, stamped by both funnels at settle time, next to `expected` |
287
305
  | Producer | `@ultimat3/entity`'s `postgresRepo` — the last caller that still knows the entity and the operation once the SQL exists |
288
306
  | What it costs | nothing uninstalled — `statementObserver()` is read first, and with nothing installed `fn` runs directly; no scope entered, no object allocated |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/db",
3
- "version": "6.0.0",
3
+ "version": "8.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": "6.0.0"
34
+ "@ultimat3/core": "8.0.0"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "@electric-sql/pglite": ">=0.5.0"
@@ -3,13 +3,15 @@
3
3
  // instead of fifty copies of one `select`. A scope, not a parameter: the statement leaves several
4
4
  // frames and at least one microtask below the repository call that caused it.
5
5
 
6
- // `node:` for the same reason `expected-loop.ts` needs it Bun exposes no native async-context
7
- // primitive, and the pair has to survive every `await` between the repository call and the
8
- // statement it causes. A module-scope variable would be shared by two concurrent requests.
9
- import { AsyncLocalStorage } from 'node:async_hooks';
6
+ // The pair has to survive every `await` between the repository call and the statement it causes,
7
+ // and a module-scope variable would be shared by two concurrent requests so it needs an async
8
+ // context. It opens through core's seam for the same reason `expected-loop.ts` does: constructing
9
+ // an `AsyncLocalStorage` here threw at module EVALUATION in a browser bundle, where
10
+ // `node:async_hooks` is stubbed to `{}`, taking every importer of `@ultimat3/db` down with it.
11
+ import { asyncContext } from '@ultimat3/core';
10
12
  import { type StatementAttribution, statementObserver } from './observe';
11
13
 
12
- const storage = new AsyncLocalStorage<StatementAttribution>();
14
+ const storage = asyncContext<StatementAttribution>('the statement attribution');
13
15
 
14
16
  /**
15
17
  * Run `fn` with every statement it issues — at any depth, across every `await` — attributed to
@@ -41,5 +43,5 @@ export function withStatementAttribution<T>(entity: string, op: string, fn: () =
41
43
  * same answer captured at the moment the statement settled.
42
44
  */
43
45
  export function statementAttribution(): StatementAttribution | undefined {
44
- return storage.getStore();
46
+ return storage.get();
45
47
  }
package/src/client.ts CHANGED
@@ -61,7 +61,7 @@ export interface PoolProfile {
61
61
  }
62
62
 
63
63
  /** Sized per role because the failure modes differ: RPS bursts vs. queue depth vs. run-once. */
64
- export const POOL_PROFILES: Readonly<Record<Role, PoolProfile>> = Object.freeze({
64
+ export const POOL_PROFILES = Object.freeze<Record<Role, PoolProfile>>({
65
65
  web: {
66
66
  max: 20,
67
67
  statementTimeoutMs: 10_000,
package/src/errors.ts CHANGED
@@ -121,7 +121,7 @@ export const dbUnavailable = (detail: string, sourceError?: unknown): DbError =>
121
121
  * named, so the fix points at the one index or key that refused the row rather than at the idea
122
122
  * of one; `driverError` substitutes the placeholder when the driver reported none.
123
123
  */
124
- const SQLSTATE_FIXES: Readonly<Record<DbSqlStateCode, string>> = Object.freeze({
124
+ const SQLSTATE_FIXES = Object.freeze<Record<DbSqlStateCode, string>>({
125
125
  X_DB_UNIQUE_VIOLATION:
126
126
  'upsertAll(rows, { onConflict: [...] }) over the columns {constraint} covers — ' +
127
127
  'or catch X_DB_UNIQUE_VIOLATION and answer 409, which is what a raced signup is',
@@ -3,13 +3,14 @@
3
3
  // A scope with a written reason — never a comment pragma and never a config list of exempt call
4
4
  // sites (axiom 1), because both put the argument somewhere other than the loop it defends.
5
5
 
6
- // `node:` because Bun exposes no native async-context primitive: the reason has to outlive every
7
- // `await` inside the scope, and `AsyncLocalStorage` is the only thing that carries a value across
8
- // them. A module-scope variable would be shared by two concurrent loops.
9
- import { AsyncLocalStorage } from 'node:async_hooks';
10
- import { assert } from '@ultimat3/core';
6
+ // The reason has to outlive every `await` inside the scope and a module-scope variable would be
7
+ // shared by two concurrent loops, so it needs an async context opened through core's one lazy
8
+ // seam rather than a `node:async_hooks` construction here, which threw at module EVALUATION in a
9
+ // browser bundle (the bundler stubs the module to `{}`) and took every importer of `@ultimat3/db`
10
+ // with it.
11
+ import { assert, asyncContext } from '@ultimat3/core';
11
12
 
12
- const storage = new AsyncLocalStorage<string>();
13
+ const storage = asyncContext<string>('the expected-loop reason');
13
14
 
14
15
  /**
15
16
  * Run `fn` with every statement it issues — at any depth, across every `await` — marked expected
@@ -49,5 +50,5 @@ export function expectedQueryLoop<T>(reason: string, fn: () => T): T {
49
50
  * answer captured at the moment the statement settled.
50
51
  */
51
52
  export function expectedQueryLoopReason(): string | undefined {
52
- return storage.getStore();
53
+ return storage.get();
53
54
  }
@@ -3,8 +3,7 @@
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 { AsyncLocalStorage } from 'node:async_hooks';
7
- import { assert, nanoid } from '@ultimat3/core';
6
+ import { assert, asyncContext, nanoid } from '@ultimat3/core';
8
7
  import { baseClient, type DbClient, type DbConnection, isReservable } from './client';
9
8
  import { serializationExhausted } from './errors';
10
9
  import { raw, type SqlFragment } from './sql';
@@ -79,11 +78,15 @@ interface TxState {
79
78
  readonly live: { value: boolean };
80
79
  }
81
80
 
82
- const storage = new AsyncLocalStorage<TxState>();
81
+ // Core's one lazy seam, never a construction here: a module-scope `new` threw at EVALUATION in a
82
+ // browser bundle, where the bundler stubs `node:async_hooks` to `{}`, and took every importer of
83
+ // `@ultimat3/db` with it. `get()` still answers `undefined` outside a scope, so the server pays
84
+ // nothing for the deferral.
85
+ const storage = asyncContext<TxState>('a database transaction');
83
86
 
84
87
  /** The open transaction, or `undefined` outside one. `@ultimat3/jobs` calls this per enqueue. */
85
88
  export function currentTx(): DbTx | undefined {
86
- return storage.getStore()?.tx;
89
+ return storage.get()?.tx;
87
90
  }
88
91
 
89
92
  /**
@@ -96,7 +99,7 @@ export function currentTx(): DbTx | undefined {
96
99
  * reservation, whose own `held` fence already re-queues them.
97
100
  */
98
101
  export function inLiveTx(): boolean {
99
- return storage.getStore()?.live.value === true;
102
+ return storage.get()?.live.value === true;
100
103
  }
101
104
 
102
105
  export function beginStatement(options: TransactionOptions): string {
@@ -217,7 +220,7 @@ export async function withTransaction<T>(
217
220
  `withTransaction({ retry }) needs a whole number of extra attempts, 0 or more; a budget that is not one opens nothing and runs fn zero times`,
218
221
  "pass an integer — withTransaction(fn, { retry: 3, isolation: 'serializable' }) — and parse it before you pass it: Number(process.env.DB_RETRY) is NaN when the variable is unset",
219
222
  );
220
- const outer = storage.getStore();
223
+ const outer = storage.get();
221
224
  if (outer !== undefined) {
222
225
  // A nested scope is a SAVEPOINT, and a savepoint cannot survive the thing `retry` exists for:
223
226
  // measured against Postgres 17, a `40001` aborts the **whole** transaction, so the