@ultimat3/db
Advanced tools
+21
-4
@@ -17,2 +17,3 @@ # @ultimat3/db — agent notes | ||
| | New code | add to `DB_ERROR_CODES` **and** `DB_ERROR_TITLES` in `errors.ts` | | ||
| | 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` | | ||
| | Exports | explicit in `src/index.ts`; no `export *` | | ||
@@ -32,2 +33,18 @@ | Files | < 200 LOC, one responsibility, `kebab-case.ts`, test beside source | | ||
| **The three ambient scopes open through core's one lazy seam, and that is a build error rather than | ||
| a convention, `As of 2026-08`.** `transaction.ts` (`TxState`), `attribution.ts` (the entity/op | ||
| pair) and `expected-loop.ts` (the reason) each constructed a module-scope `AsyncLocalStorage` until | ||
| issue #255 closed it. A bundler stubs `node:async_hooks` to `{}` — Bun's `target: 'browser'` emits | ||
| `var { AsyncLocalStorage } = (() => ({}))` — so the `new` threw | ||
| `TypeError: undefined is not a constructor` at module **evaluation**, before any app code ran, and | ||
| took every importer of that file down with it. Through `asyncContext<T>(subject)` the module | ||
| evaluates, `get()` answers `undefined` (in a browser nothing IS in flight, so that is the true | ||
| answer) and `run()` throws `X_ASYNC_CONTEXT_UNAVAILABLE` naming the scope. Deferring the | ||
| construction changes nothing a server can observe: the storage is built on the first `get()` or | ||
| `run()` rather than at module load, and `getStore()` outside a scope answers `undefined` either | ||
| way — one object per scope, on first use, in place of one at module evaluation. | ||
| `scripts/async-context-guard.ts` refuses a `new AsyncLocalStorage` — and the import that binds the | ||
| class, aliased or namespaced — anywhere but `packages/core/src/async-context.ts`, and | ||
| `scripts/async-context-guard.test.ts` runs it over the tree in the gate's `unit` step. | ||
| `pglite.ts` is a pool of exactly one: PGlite is a single session, so `reserve()` (backed by | ||
@@ -50,3 +67,3 @@ `pglite-turns.ts`) is what stops two concurrent `BEGIN`s becoming one transaction. Three rules | ||
| different questions and reading the second as the first was a cross-transaction write. The | ||
| `AsyncLocalStorage` store rides into every promise chain started inside `withTransaction`, so a | ||
| async-context store rides into every promise chain started inside `withTransaction`, so a | ||
| statement the app forgot to `await` still found a store after COMMIT, skipped the turn queue, and | ||
@@ -219,3 +236,3 @@ landed inside whichever unit of work held the single session next: measured `BEGIN`, `select 'inside | ||
| fn)` runs `fn` with every statement it issues — at any depth, across every `await` — attributed to | ||
| that pair, on an `AsyncLocalStorage` the same shape `expected-loop.ts` already uses. Four rules, | ||
| that pair, on an async context the same shape `expected-loop.ts` already uses. Four rules, | ||
| none optional. **Guard first** — it reads `statementObserver()` before touching the scope at all | ||
@@ -270,3 +287,3 @@ and, with nothing installed, hands straight to `fn`: one property read, one branch, no object | ||
| a pragma or a list is the same reason `observe.ts` is one observer: a second path is the tax | ||
| (axiom 1). `expectedQueryLoop(reason, fn)` rides an `AsyncLocalStorage`, so it survives every | ||
| (axiom 1). `expectedQueryLoop(reason, fn)` rides an async context, so it survives every | ||
| `await` at any depth and two loops running concurrently never read each other; nesting keeps the | ||
@@ -278,3 +295,3 @@ innermost reason, because the closest scope is the one describing this loop. A blank reason is | ||
| put the answer on the event as `expected`; a detector that judges a whole request runs long after | ||
| every scope in it closed, so reading the ALS later would find nothing. **It suppresses a verdict, | ||
| every scope in it closed, so reading the scope later would find nothing. **It suppresses a verdict, | ||
| not a statement** — the SQL is still sent, still observed, and the span still opens, so anything | ||
@@ -281,0 +298,0 @@ that measures still sees the loop and only the thing that warns is told the author already |
+2
-2
| { | ||
| "name": "@ultimat3/db", | ||
| "version": "6.0.0", | ||
| "version": "7.0.0", | ||
| "description": "Postgres access, transactions, migrations and drift detection", | ||
@@ -34,3 +34,3 @@ "license": "MIT", | ||
| "dependencies": { | ||
| "@ultimat3/core": "6.0.0" | ||
| "@ultimat3/core": "7.0.0" | ||
| }, | ||
@@ -37,0 +37,0 @@ "peerDependencies": { |
+20
-2
@@ -264,3 +264,3 @@ # @ultimat3/db 🐘 | ||
| |---|---| | ||
| | 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 | | ||
| | 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 | | ||
| | 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 | | ||
@@ -274,2 +274,20 @@ | 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 | | ||
| **Every ambient scope in this package opens through `asyncContext<T>(subject)` from | ||
| `@ultimat3/core`** — the transaction store, the attribution pair and this reason — and none of the | ||
| three constructs an `AsyncLocalStorage`, `As of 2026-08`. What changed is what a browser bundle | ||
| does with these three modules: a bundler stubs `node:async_hooks` to `{}`, so the module-scope `new` | ||
| threw `TypeError: undefined is not a constructor` at module **evaluation** — before a line of app | ||
| code ran, and taking every importer of the file with it. Now the module evaluates, a read answers | ||
| `undefined` (nothing is in flight in a browser, so that is the true answer), and a write throws | ||
| `X_ASYNC_CONTEXT_UNAVAILABLE` naming the scope it could not open. A server saves no allocation — | ||
| the store is built on the first `get()` **or** `run()`, so a read constructs it too. What the | ||
| laziness costs is nothing observable: `getStore()` outside a scope answers `undefined` whether the | ||
| storage was ever constructed or not. Not a claim that the whole package | ||
| bundles: `pglite-branch.ts` imports `node:fs/promises`, which is a separate question. | ||
| The rule is a **build error**, not a convention: `scripts/async-context-guard.ts` refuses a | ||
| `new AsyncLocalStorage` — and the import that binds the class, aliased or namespaced — anywhere but | ||
| `packages/core/src/async-context.ts`, and `scripts/async-context-guard.test.ts` runs it over the | ||
| tree in the gate's `unit` step. | ||
| ## A statement knows who compiled it | ||
@@ -287,3 +305,3 @@ | ||
| |---|---| | ||
| | Scope | an `AsyncLocalStorage`, `expectedQueryLoop()`'s own shape: it survives every `await` at any depth, and nesting keeps the innermost pair | | ||
| | Scope | core's `asyncContext<StatementAttribution>()`, `expectedQueryLoop()`'s own shape: it survives every `await` at any depth, and nesting keeps the innermost pair | | ||
| | What it carries | `StatementEvent.attribution`, stamped by both funnels at settle time, next to `expected` | | ||
@@ -290,0 +308,0 @@ | Producer | `@ultimat3/entity`'s `postgresRepo` — the last caller that still knows the entity and the operation once the SQL exists | |
@@ -6,9 +6,11 @@ // Single responsibility: carry "which entity, which operation" from the layer that compiled a | ||
| // `node:` for the same reason `expected-loop.ts` needs it — Bun exposes no native async-context | ||
| // primitive, and the pair has to survive every `await` between the repository call and the | ||
| // statement it causes. A module-scope variable would be shared by two concurrent requests. | ||
| import { AsyncLocalStorage } from 'node:async_hooks'; | ||
| // The pair has to survive every `await` between the repository call and the statement it causes, | ||
| // and a module-scope variable would be shared by two concurrent requests — so it needs an async | ||
| // context. It opens through core's seam for the same reason `expected-loop.ts` does: constructing | ||
| // an `AsyncLocalStorage` here threw at module EVALUATION in a browser bundle, where | ||
| // `node:async_hooks` is stubbed to `{}`, taking every importer of `@ultimat3/db` down with it. | ||
| import { asyncContext } from '@ultimat3/core'; | ||
| import { type StatementAttribution, statementObserver } from './observe'; | ||
| const storage = new AsyncLocalStorage<StatementAttribution>(); | ||
| const storage = asyncContext<StatementAttribution>('the statement attribution'); | ||
@@ -45,3 +47,3 @@ /** | ||
| export function statementAttribution(): StatementAttribution | undefined { | ||
| return storage.getStore(); | ||
| return storage.get(); | ||
| } |
+1
-1
@@ -64,3 +64,3 @@ // Single responsibility: the Postgres connection and the ambient `db()` handle. Pool size and | ||
| /** Sized per role because the failure modes differ: RPS bursts vs. queue depth vs. run-once. */ | ||
| export const POOL_PROFILES: Readonly<Record<Role, PoolProfile>> = Object.freeze({ | ||
| export const POOL_PROFILES = Object.freeze<Record<Role, PoolProfile>>({ | ||
| web: { | ||
@@ -67,0 +67,0 @@ max: 20, |
+1
-1
@@ -124,3 +124,3 @@ // The database layer's stable error codes. Every factory produces the exact command that | ||
| */ | ||
| const SQLSTATE_FIXES: Readonly<Record<DbSqlStateCode, string>> = Object.freeze({ | ||
| const SQLSTATE_FIXES = Object.freeze<Record<DbSqlStateCode, string>>({ | ||
| X_DB_UNIQUE_VIOLATION: | ||
@@ -127,0 +127,0 @@ 'upsertAll(rows, { onConflict: [...] }) over the columns {constraint} covers — ' + |
@@ -6,9 +6,10 @@ // Single responsibility: the one way to declare that a loop of statements is deliberate, so a | ||
| // `node:` because Bun exposes no native async-context primitive: the reason has to outlive every | ||
| // `await` inside the scope, and `AsyncLocalStorage` is the only thing that carries a value across | ||
| // them. A module-scope variable would be shared by two concurrent loops. | ||
| import { AsyncLocalStorage } from 'node:async_hooks'; | ||
| import { assert } from '@ultimat3/core'; | ||
| // The reason has to outlive every `await` inside the scope and a module-scope variable would be | ||
| // shared by two concurrent loops, so it needs an async context — opened through core's one lazy | ||
| // seam rather than a `node:async_hooks` construction here, which threw at module EVALUATION in a | ||
| // browser bundle (the bundler stubs the module to `{}`) and took every importer of `@ultimat3/db` | ||
| // with it. | ||
| import { assert, asyncContext } from '@ultimat3/core'; | ||
| const storage = new AsyncLocalStorage<string>(); | ||
| const storage = asyncContext<string>('the expected-loop reason'); | ||
@@ -53,3 +54,3 @@ /** | ||
| export function expectedQueryLoopReason(): string | undefined { | ||
| return storage.getStore(); | ||
| return storage.get(); | ||
| } |
@@ -6,4 +6,3 @@ // Single responsibility: transaction scope. The open `DbTx` rides an AsyncLocalStorage rather | ||
| import { AsyncLocalStorage } from 'node:async_hooks'; | ||
| import { assert, nanoid } from '@ultimat3/core'; | ||
| import { assert, asyncContext, nanoid } from '@ultimat3/core'; | ||
| import { baseClient, type DbClient, type DbConnection, isReservable } from './client'; | ||
@@ -83,7 +82,11 @@ import { serializationExhausted } from './errors'; | ||
| const storage = new AsyncLocalStorage<TxState>(); | ||
| // Core's one lazy seam, never a construction here: a module-scope `new` threw at EVALUATION in a | ||
| // browser bundle, where the bundler stubs `node:async_hooks` to `{}`, and took every importer of | ||
| // `@ultimat3/db` with it. `get()` still answers `undefined` outside a scope, so the server pays | ||
| // nothing for the deferral. | ||
| const storage = asyncContext<TxState>('a database transaction'); | ||
| /** The open transaction, or `undefined` outside one. `@ultimat3/jobs` calls this per enqueue. */ | ||
| export function currentTx(): DbTx | undefined { | ||
| return storage.getStore()?.tx; | ||
| return storage.get()?.tx; | ||
| } | ||
@@ -101,3 +104,3 @@ | ||
| export function inLiveTx(): boolean { | ||
| return storage.getStore()?.live.value === true; | ||
| return storage.get()?.live.value === true; | ||
| } | ||
@@ -223,3 +226,3 @@ | ||
| ); | ||
| const outer = storage.getStore(); | ||
| const outer = storage.get(); | ||
| if (outer !== undefined) { | ||
@@ -226,0 +229,0 @@ // A nested scope is a SAVEPOINT, and a savepoint cannot survive the thing `retry` exists for: |
Debug access
Supply chain riskUses debug, reflection and dynamic code execution features.
323745
1.16%4883
0.12%350
5.42%4
-42.86%+ Added
- Removed
Updated