@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 +21 -4
- package/README.md +20 -2
- package/package.json +2 -2
- package/src/attribution.ts +8 -6
- package/src/client.ts +1 -1
- package/src/errors.ts +1 -1
- package/src/expected-loop.ts +8 -7
- package/src/transaction.ts +9 -6
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 |
|
|
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 |
|
|
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": "
|
|
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": "
|
|
34
|
+
"@ultimat3/core": "8.0.0"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
37
|
"@electric-sql/pglite": ">=0.5.0"
|
package/src/attribution.ts
CHANGED
|
@@ -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
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
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 =
|
|
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.
|
|
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
|
|
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
|
|
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',
|
package/src/expected-loop.ts
CHANGED
|
@@ -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
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
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 =
|
|
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.
|
|
53
|
+
return storage.get();
|
|
53
54
|
}
|
package/src/transaction.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|