@ultimat3/db 1.2.0 → 2.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 +580 -0
- package/README.md +147 -21
- package/package.json +3 -2
- package/src/attribution.ts +45 -0
- package/src/branch.ts +10 -4
- package/src/client.ts +241 -25
- package/src/destructive.ts +126 -0
- package/src/drift.ts +256 -13
- package/src/errors.ts +232 -13
- package/src/expected-loop.ts +53 -0
- package/src/fake-pglite.ts +32 -0
- package/src/fake-reservable.ts +50 -0
- package/src/fake.ts +16 -2
- package/src/foreign-key.ts +41 -0
- package/src/generate.ts +192 -39
- package/src/index.ts +37 -11
- package/src/introspect.ts +34 -8
- package/src/migrate.ts +272 -63
- package/src/observe.ts +90 -0
- package/src/pglite-branch.ts +2 -1
- package/src/pglite-turns.ts +13 -10
- package/src/pglite.ts +85 -15
- package/src/readonly-query.ts +20 -8
- package/src/snapshot-json.ts +84 -0
- package/src/snapshot-parse.ts +99 -0
- package/src/sql-noise.ts +40 -0
- package/src/sql-scan.ts +159 -0
- package/src/sqlstate.ts +107 -0
- package/src/statement-shape.ts +58 -0
- package/src/statement-span.ts +40 -0
- package/src/statement-split.ts +51 -0
- package/src/transaction.ts +138 -16
- package/src/type-pins.ts +29 -0
- package/src/readonly.ts +0 -111
package/README.md
CHANGED
|
@@ -27,16 +27,30 @@ await withTransaction(async (tx) => {
|
|
|
27
27
|
|---|---|
|
|
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 |
|
|
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 |
|
|
31
33
|
| `migrate()` / `rollback()` / `readLedger()` | the `x_migrations` ledger |
|
|
32
|
-
| `
|
|
33
|
-
| `
|
|
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 |
|
|
35
|
+
| `checkDrift()` / `diffSchema()` / `assertNoDrift()` | drift, with a `--json` report. `checkDrift()` is the **post-migrate verification** — the live database against the ledger: columns, declared indexes, and declared foreign keys, matched on where the key points and not on its constraint name |
|
|
36
|
+
| `declaredSchema()` / `expectedSchema()` | `As of 2026-08`: the schema the migrations write down, or `undefined` when the newest one carries no snapshot — never an older snapshot standing in for it |
|
|
37
|
+
| `parseSnapshot()` | `As of 2026-08`: a `<id>.snapshot.json` sidecar validated to the last nested field, or `undefined`. `{"tables":[null]}` is valid JSON and is not a schema |
|
|
38
|
+
| `snapshotJson()` | `As of 2026-08`: the sidecar's **bytes** — the JSON Biome would have printed, trailing newline included. The one writer of a `<id>.snapshot.json`, because `JSON.stringify(…, null, 2)` is not formatter-clean and an app's `lint` step rejected the file `x db gen` had just written |
|
|
39
|
+
| `isLedgerMissing()` | `As of 2026-08`: whether an error is Postgres' `undefined_table` for `x_migrations` — the one condition a caller may read as "nothing applied" |
|
|
40
|
+
| `appTables()` / `FRAMEWORK_TABLE_PREFIX` | `As of 2026-08`: the live schema minus the `x_` namespace — no migration declares the ledger, the queue, the outbox or an auth table, so none of them is drift |
|
|
41
|
+
| `generateMigration()` | `x db gen "<name>"` — reversible up/down SQL, and `destructive` for the marker the file must carry. `As of 2026-08` a foreign key is its own `alter table … add constraint`, emitted after every table statement: inline, a `references()` had to point at a table entity registration order happened to create first, and `down` had to drop them in an order it did not control |
|
|
42
|
+
| `destructiveStatements()` / `hasDestructiveMarker()` / `isDestructive()` / `DESTRUCTIVE_MARKER` | `As of 2026-08`: the destructive-SQL rail — does this `up` drop, truncate or retype, and does the file declare it with `-- destructive: true`? One classifier, read by `x db gen` when it writes the marker and by `x verify` when it demands one |
|
|
43
|
+
| `stripSqlNoise()` | comments, literals, dollar-quoted bodies and quoted identifiers blanked **in source order**, so a reader sees the operation and not the prose. Shared by `readOnlyQuery()` and the destructive rail |
|
|
34
44
|
| `introspect()` | live schema → `SchemaDescription` |
|
|
35
45
|
| `createBranch()` / `dropBranch()` / `reapBranches()` | copy-on-write branch databases |
|
|
36
46
|
| `createPgliteClient()` / `branchPglite()` | the embedded database — Postgres in this process |
|
|
37
|
-
| `readOnly()` | mutation-rejecting wrapper |
|
|
38
47
|
| `ensureReadOnlyRole()` / `grantReadOnlySql()` / `READONLY_ROLE` | a `NOLOGIN`, SELECT-only Postgres role — layer 1 of `db.query`'s defence |
|
|
39
48
|
| `readOnlyQuery()` / `READONLY_TIMEOUT_MS` | one statement inside `BEGIN READ ONLY` with a statement timeout — layer 2 |
|
|
49
|
+
| `setStatementObserver()` / `statementObserver()` | `As of 2026-08`: one event **and one `db.<verb>` span** per settled statement, both drivers; uninstalled is one branch |
|
|
50
|
+
| `expectedQueryLoop()` / `expectedQueryLoopReason()` | `As of 2026-08`: the one way to declare a loop of queries deliberate — the reason rides on every statement it issues as `StatementEvent.expected` |
|
|
51
|
+
| `withStatementAttribution()` / `statementAttribution()` | `As of 2026-08`: the `{ entity, op }` pair on `StatementEvent.attribution`, scoped exactly like `expectedQueryLoop()` — `@ultimat3/entity`'s `postgresRepo` is the one producer |
|
|
52
|
+
| `STATEMENT_ATTRIBUTE` | `As of 2026-08`: `db.statement`, the OTel attribute each span carries its text under — declared here, read by `x dev`'s timeline |
|
|
53
|
+
| `statementFingerprint()` / `statementKind()` / `statementVerb()` | `As of 2026-08`: what shape a statement is — `entity.op` when attributed else its own collapsed text, read or write from the leading verb. One rule, so two detectors group identically |
|
|
40
54
|
| `createRecordingClient()` | in-memory `DbClient` that records SQL, for tests |
|
|
41
55
|
|
|
42
56
|
## `sql` is parameters-only
|
|
@@ -86,7 +100,28 @@ layer 1 covers only what existed at grant time.
|
|
|
86
100
|
|
|
87
101
|
## The drift contract
|
|
88
102
|
|
|
89
|
-
`
|
|
103
|
+
`checkDrift()` compares `introspect()` against the snapshot the newest applied migration carries —
|
|
104
|
+
`expectedSchema(migrations, ledger)`. `declaredSchema(migrations)` is the same read with the ledger
|
|
105
|
+
left out: the schema the files *declare*, applied or not, which is what `x db gen` diffs the app's
|
|
106
|
+
entities against so generation needs no database at all. One implementation, two callers — a
|
|
107
|
+
snapshot that meant one thing to the generator and another to drift is exactly the divergence the
|
|
108
|
+
ledger exists to prevent.
|
|
109
|
+
|
|
110
|
+
**One `X_DB_DRIFT`, two detectors, and which is which matters.** `checkDrift()` is the post-migrate
|
|
111
|
+
verification: it needs a database, so it runs where one is open — `runMigrations` in
|
|
112
|
+
`@ultimat3/cli`, which is `x db migrate`, `x db reset` and `ROLE=migrate` alike. It is the only one
|
|
113
|
+
that can see a column added by hand. The other, `checkSourceDrift()` (also `@ultimat3/cli`), hashes
|
|
114
|
+
the entity source against what `x db gen` recorded, opens nothing, and is `x verify`'s `drift` step
|
|
115
|
+
— the gate runs in CI with no database, so a check that needed one could not run at all.
|
|
116
|
+
|
|
117
|
+
A migration the ledger has not recorded is **not** drift: `expectedSchema` reads the ledger's own
|
|
118
|
+
subset, so a database that simply has not migrated yet is pending, not divergent. Neither is a
|
|
119
|
+
table in the `x_` namespace — `x_migrations`, the queue's tables, the outbox and every
|
|
120
|
+
`@ultimat3/auth` table are created by `create table if not exists` at boot and appear in no
|
|
121
|
+
snapshot, so `appTables()` drops them before the diff. `introspect()` keeps its own narrower
|
|
122
|
+
exclusion (the ledger alone), because the admin schema view and the MCP `schema.describe` tool
|
|
123
|
+
legitimately show `x_users`.
|
|
124
|
+
|
|
90
125
|
Rendered output is pinned byte-for-byte:
|
|
91
126
|
|
|
92
127
|
```
|
|
@@ -102,7 +137,10 @@ X_DB_DRIFT: schema differs from migrations
|
|
|
102
137
|
| live table, no migration | `table "T" is not present in any migration` | `x db gen "add T"` |
|
|
103
138
|
| migrated table, not live | `table "T" is declared by migrations but does not exist` | `x db migrate` |
|
|
104
139
|
|
|
105
|
-
`checkDrift()` returns every difference; `assertNoDrift()` throws the first. `x
|
|
140
|
+
`checkDrift()` returns every difference; `assertNoDrift()` throws the first. `x db migrate` reports
|
|
141
|
+
them and exits non-zero; a `ROLE=migrate` container logs them and still exits 0, because its
|
|
142
|
+
contract is "apply every migration, then exit" and a schema difference after a clean apply is a
|
|
143
|
+
diagnostic, not a failed migration.
|
|
106
144
|
|
|
107
145
|
## The embedded database
|
|
108
146
|
|
|
@@ -146,21 +184,43 @@ scope is already holding, because waiting would be a deadlock with no error to e
|
|
|
146
184
|
|
|
147
185
|
`ROLE` picks the profile — a `worker` draining a queue must not size like a `web` process.
|
|
148
186
|
|
|
149
|
-
| Role | max | statement timeout | idle timeout |
|
|
150
|
-
|
|
151
|
-
| `web` | 20 | 10s | 30s |
|
|
152
|
-
| `sync` | 10 | 10s | 60s |
|
|
153
|
-
| `worker` | 8 | 120s | 30s |
|
|
154
|
-
| `scheduler` | 2 | 15s | 60s |
|
|
155
|
-
| `migrate` | 1 | none | 10s |
|
|
156
|
-
| `replicator` | 4 | none | 60s |
|
|
157
|
-
|
|
158
|
-
The timeout is pinned per connection via libpq `options=-c statement_timeout
|
|
159
|
-
|
|
187
|
+
| Role | max | statement timeout | idle timeout | lock timeout | acquire timeout |
|
|
188
|
+
|---|---|---|---|---|---|
|
|
189
|
+
| `web` | 20 | 10s | 30s | none | 5s |
|
|
190
|
+
| `sync` | 10 | 10s | 60s | none | 5s |
|
|
191
|
+
| `worker` | 8 | 120s | 30s | none | 10s |
|
|
192
|
+
| `scheduler` | 2 | 15s | 60s | none | 10s |
|
|
193
|
+
| `migrate` | 1 | none | 10s | 3s | none |
|
|
194
|
+
| `replicator` | 4 | none | 60s | none | none |
|
|
195
|
+
|
|
196
|
+
The statement timeout is pinned per connection via libpq `options=-c statement_timeout=`, and it
|
|
197
|
+
does reach the backend: `client.live.test.ts` asserts `current_setting('statement_timeout')`, which
|
|
198
|
+
is the only reading a DSN test cannot fake. `Bun.SQL` is reached lazily, so importing this package
|
|
199
|
+
never opens a socket.
|
|
200
|
+
|
|
201
|
+
**`DATABASE_POOL_MAX` overrides `max`** (`As of 2026-08`), and it is the only pool knob an operator
|
|
202
|
+
can turn without shipping an image — 400 `web` pods × the frozen `max: 20` is 8,000 backends. A
|
|
203
|
+
value that is not a positive integer refuses at boot rather than falling back.
|
|
204
|
+
|
|
205
|
+
**`acquireTimeoutMs` bounds `reserve()`**, because queueing turns exhaustion into a hang: `/readyz`'s
|
|
206
|
+
`select 1` joins the same queue, the kubelet kills the pod, and the replacement inherits the same
|
|
207
|
+
saturated database. 0 waits, which is what a run-once role wants.
|
|
208
|
+
|
|
209
|
+
**`lockTimeoutMs` bounds a *wait*, never the work.** `alter table … add column` takes `ACCESS
|
|
210
|
+
EXCLUSIVE`; a long `SELECT` holding `ACCESS SHARE` makes it queue, and Postgres' lock queue is FIFO,
|
|
211
|
+
so every later query on that table queues behind the ALTER. `migrate` runs `statement_timeout = 0`,
|
|
212
|
+
so nothing else would ever end that wait. `migrate()` emits it as `SET LOCAL lock_timeout` inside
|
|
213
|
+
each migration's own transaction — it reverts at COMMIT, so a DDL value never leaks onto the session
|
|
214
|
+
the ledger insert runs on.
|
|
160
215
|
|
|
161
216
|
## Migrations
|
|
162
217
|
|
|
163
|
-
`migrate()` takes
|
|
218
|
+
`migrate()` takes the advisory lock by **polling** `pg_try_advisory_lock(4919202607)` every 500ms
|
|
219
|
+
until `lockWaitMs` (default 60s) and then throwing `X_MIGRATE_CONCURRENT` (`As of 2026-08`).
|
|
220
|
+
`pg_advisory_lock` has no timeout, and a predecessor OOM-killed on a partition keeps its backend —
|
|
221
|
+
and the lock — for hours, so a deploy hook sat inside one statement printing nothing while
|
|
222
|
+
`helm upgrade --wait` blocked and `backoffLimit` never fired, because a job that never finishes
|
|
223
|
+
never fails. It then ensures `x_migrations`
|
|
164
224
|
(`id, name, checksum, applied_at, app_version, duration_ms`), audits, then applies each pending
|
|
165
225
|
migration inside its own transaction. It refuses **before applying anything** when:
|
|
166
226
|
|
|
@@ -170,22 +230,88 @@ migration inside its own transaction. It refuses **before applying anything** wh
|
|
|
170
230
|
|
|
171
231
|
Report (`--json`): `{ applied: [{ id, name, durationMs }], skipped: [id], durationMs, appVersion }`.
|
|
172
232
|
|
|
233
|
+
## A loop of queries that is deliberate says so
|
|
234
|
+
|
|
235
|
+
`As of 2026-08`:
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
return expectedQueryLoop('one indexed lookup per text field beats one unindexed OR', async () => {
|
|
239
|
+
for (const field of fields) hits.push(...(await repo.list({ where: [match(field)] })));
|
|
240
|
+
return hits;
|
|
241
|
+
});
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
One mechanism, and only one: no comment pragma, no config list of exempt call sites (axiom 1).
|
|
245
|
+
`reason` is required and non-blank — an exemption with no argument is a pragma, and the next
|
|
246
|
+
reader cannot tell a considered loop from a silenced one.
|
|
247
|
+
|
|
248
|
+
| | |
|
|
249
|
+
|---|---|
|
|
250
|
+
| 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 |
|
|
251
|
+
| 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 |
|
|
252
|
+
| 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 |
|
|
253
|
+
| What it costs | nothing without a diagnostic — the reason is read inside the branch that already checks for an installed observer |
|
|
254
|
+
|
|
255
|
+
The framework's own deliberate loops declare themselves at source: `migrate()` and `rollback()`
|
|
256
|
+
(one transaction per migration, so a failure leaves an exact ledger) and `@ultimat3/admin`'s
|
|
257
|
+
cross-entity search (one indexed lookup per text field).
|
|
258
|
+
|
|
259
|
+
## A statement knows who compiled it
|
|
260
|
+
|
|
261
|
+
`As of 2026-08`: `StatementEvent.attribution` is no longer always `undefined`.
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
return withStatementAttribution('members', 'findById', () =>
|
|
265
|
+
client.query(sql`select * from members where id = any(${ids})`),
|
|
266
|
+
);
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
| | |
|
|
270
|
+
|---|---|
|
|
271
|
+
| Scope | an `AsyncLocalStorage`, `expectedQueryLoop()`'s own shape: it survives every `await` at any depth, and nesting keeps the innermost pair |
|
|
272
|
+
| What it carries | `StatementEvent.attribution`, stamped by both funnels at settle time, next to `expected` |
|
|
273
|
+
| Producer | `@ultimat3/entity`'s `postgresRepo` — the last caller that still knows the entity and the operation once the SQL exists |
|
|
274
|
+
| What it costs | nothing uninstalled — `statementObserver()` is read first, and with nothing installed `fn` runs directly; no scope entered, no object allocated |
|
|
275
|
+
|
|
276
|
+
Hand-written SQL, a migration, a health probe, `x db` commands and `@ultimat3/jobs`' own queue
|
|
277
|
+
statements still carry no `attribution` — nothing above them knows an entity to name, so the field
|
|
278
|
+
is optional and a detector must fall back to the statement text either way.
|
|
279
|
+
|
|
173
280
|
## Error codes
|
|
174
281
|
|
|
282
|
+
Every driver failure is typed by the **SQLSTATE the server sent**, `As of 2026-08`. The state was
|
|
283
|
+
always on the error and nothing read it, so a `23505` from two clicks racing a signup answered
|
|
284
|
+
"cannot reach the database" and paged on-call for an outage that never happened. The table
|
|
285
|
+
(`sqlstate.ts`) is closed; everything outside it is still `X_DB_UNAVAILABLE`, unchanged.
|
|
286
|
+
|
|
175
287
|
| Code | Meaning |
|
|
176
288
|
|---|---|
|
|
177
|
-
| `X_DB_UNAVAILABLE` | no reachable database; `fix:` names `DATABASE_URL` |
|
|
289
|
+
| `X_DB_UNAVAILABLE` | no reachable database, or a SQLSTATE the table does not name; `fix:` names `DATABASE_URL` |
|
|
290
|
+
| `X_DB_UNIQUE_VIOLATION` | `23505` — `fix:` names `upsertAll(rows, { onConflict: [...] })` and the constraint the server named |
|
|
291
|
+
| `X_DB_FOREIGN_KEY_VIOLATION` | `23503` |
|
|
292
|
+
| `X_DB_SERIALIZATION_FAILURE` | `40001` / `40P01`, and an exhausted `withTransaction(fn, { retry: n })` budget |
|
|
293
|
+
| `X_DB_STATEMENT_TIMEOUT` | `57014` — the statement ran past `statement_timeout` |
|
|
294
|
+
| `X_DB_LOCK_TIMEOUT` | `55P03` — it waited past `lock_timeout` for a lock it never got |
|
|
295
|
+
| `X_DB_POOL_EXHAUSTED` | `53300` / `53200`, or `reserve()` past `acquireTimeoutMs` |
|
|
178
296
|
| `X_DB_DRIFT` | live schema differs from migrations |
|
|
179
297
|
| `X_MIGRATION_CONFLICT` | ledger app-version fence or checksum mismatch |
|
|
298
|
+
| `X_MIGRATE_CONCURRENT` | another migrator still held the lock when the wait ran out |
|
|
180
299
|
| `X_MIGRATION_IRREVERSIBLE` | generated `down` would lose data |
|
|
181
300
|
| `X_SQL_UNSAFE` | non-bindable interpolation, or an unsafe identifier/branch name |
|
|
182
301
|
| `X_BRANCH_EXISTS` | branch database already exists (or is the connected one) |
|
|
183
|
-
| `X_READONLY_VIOLATION` | a mutating statement reached a `readOnly()` client |
|
|
184
302
|
| `X_NOT_IMPLEMENTED` | branching an in-memory PGlite — a copy needs a directory |
|
|
303
|
+
| `X_ENV_MISSING` | core's — `DATABASE_POOL_MAX` is set to something that is not a positive integer |
|
|
185
304
|
|
|
186
305
|
```bash
|
|
187
306
|
x db migrate --json
|
|
188
|
-
x db drift --json
|
|
189
307
|
x db gen "add publish_at"
|
|
308
|
+
x db branch ls --json
|
|
190
309
|
x db branch create feature_x
|
|
310
|
+
x db branch drop feature_x
|
|
191
311
|
```
|
|
312
|
+
|
|
313
|
+
**Drift has no subcommand of its own.** The database half runs *inside* `x db migrate`, which calls
|
|
314
|
+
`checkDrift()` on the connection it already holds and exits non-zero on a difference; the source
|
|
315
|
+
half is the `drift` step of `x verify`, which hashes entity source against what `x db gen` recorded
|
|
316
|
+
and opens no database. Two questions, two owners, and no `drift` subcommand under `x db` — the
|
|
317
|
+
`DB_SUBCOMMANDS` set is `gen`, `migrate`, `reset`, `studio`, `branch`, `backfill`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/db",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Postgres access, transactions, migrations and drift detection",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"files": [
|
|
20
20
|
"src",
|
|
21
21
|
"!src/**/*.test.ts",
|
|
22
|
+
"CLAUDE.md",
|
|
22
23
|
"README.md",
|
|
23
24
|
"LICENSE"
|
|
24
25
|
],
|
|
@@ -30,7 +31,7 @@
|
|
|
30
31
|
"test": "bun test"
|
|
31
32
|
},
|
|
32
33
|
"dependencies": {
|
|
33
|
-
"@ultimat3/core": "
|
|
34
|
+
"@ultimat3/core": "2.0.0"
|
|
34
35
|
},
|
|
35
36
|
"peerDependencies": {
|
|
36
37
|
"@electric-sql/pglite": ">=0.5.0"
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Single responsibility: carry "which entity, which operation" from the layer that compiled a
|
|
2
|
+
// statement down to the funnel that sends it, so a diagnostic reads `50× findById on members`
|
|
3
|
+
// instead of fifty copies of one `select`. A scope, not a parameter: the statement leaves several
|
|
4
|
+
// frames and at least one microtask below the repository call that caused it.
|
|
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';
|
|
10
|
+
import { type StatementAttribution, statementObserver } from './observe';
|
|
11
|
+
|
|
12
|
+
const storage = new AsyncLocalStorage<StatementAttribution>();
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Run `fn` with every statement it issues — at any depth, across every `await` — attributed to
|
|
16
|
+
* `entity` and `op`. `@ultimat3/entity`'s `postgresRepo` is the one producer: it is the last caller
|
|
17
|
+
* that still knows both once the SQL exists, and it wraps each repository method rather than each
|
|
18
|
+
* `client()` call because the statement is sent below it — inside the coalescer's microtask flush,
|
|
19
|
+
* inside a chunked write loop, inside `readByIds` for a preload.
|
|
20
|
+
*
|
|
21
|
+
* Nesting keeps the innermost pair, exactly as `expectedQueryLoop` keeps the innermost reason: a
|
|
22
|
+
* relation preloaded during `findMany` reads through the *related* repository, and that read is
|
|
23
|
+
* what its own statement is.
|
|
24
|
+
*
|
|
25
|
+
* **With no observer installed this enters no scope at all** — one property read, one branch, no
|
|
26
|
+
* object allocated (axiom 6). Which is also why the pair arrives as two strings rather than as a
|
|
27
|
+
* `StatementAttribution`: a literal at the call site would be allocated before the branch could
|
|
28
|
+
* decline it, on the path every statement in the process takes. An observer installed *during*
|
|
29
|
+
* `fn` therefore sees the statements that follow unattributed; installation happens once, at boot,
|
|
30
|
+
* and paying for the scope on every production statement to close that window is the wrong trade.
|
|
31
|
+
*/
|
|
32
|
+
export function withStatementAttribution<T>(entity: string, op: string, fn: () => T): T {
|
|
33
|
+
if (statementObserver() === undefined) return fn();
|
|
34
|
+
return storage.run({ entity, op }, fn);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The innermost enclosing pair, or `undefined` outside every scope — hand-written SQL, a migration,
|
|
39
|
+
* a health probe, the job queue's own statements. Read by the two funnels when an observer is
|
|
40
|
+
* installed, and by nothing else: a diagnostic reads `StatementEvent.attribution`, which is this
|
|
41
|
+
* same answer captured at the moment the statement settled.
|
|
42
|
+
*/
|
|
43
|
+
export function statementAttribution(): StatementAttribution | undefined {
|
|
44
|
+
return storage.getStore();
|
|
45
|
+
}
|
package/src/branch.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// it is unsure about, a data backfill, a DROP — never happens against the shared database.
|
|
4
4
|
// Branches are cheap and forgettable, so `reapBranches()` is part of the design, not an add-on.
|
|
5
5
|
|
|
6
|
+
import { systemClock } from '@ultimat3/core';
|
|
6
7
|
import { baseClient, type DbClient } from './client';
|
|
7
8
|
import { branchExists, branchNameInvalid, DbError } from './errors';
|
|
8
9
|
import { identifier, literal, sql } from './sql';
|
|
@@ -51,7 +52,7 @@ export async function createBranch(
|
|
|
51
52
|
|
|
52
53
|
const base = options.base ?? (await currentDatabase(client));
|
|
53
54
|
await client.execute(sql`create database ${identifier(branch)} template ${identifier(base)}`);
|
|
54
|
-
const createdAt = (options.now ??
|
|
55
|
+
const createdAt = (options.now ?? systemClock.now()).toISOString();
|
|
55
56
|
await client.execute(
|
|
56
57
|
sql`comment on database ${identifier(branch)} is ${literal(`${BRANCH_MARKER}${createdAt}`)}`,
|
|
57
58
|
);
|
|
@@ -113,8 +114,13 @@ export async function dropBranch(
|
|
|
113
114
|
select pg_terminate_backend(pid) from pg_stat_activity where datname = ${branch}
|
|
114
115
|
`);
|
|
115
116
|
}
|
|
116
|
-
|
|
117
|
-
|
|
117
|
+
// Asked BEFORE the statement, because `drop database if exists` answers with the same command
|
|
118
|
+
// tag either way and `execute` counts no rows for it: `affected >= 0` was `true` by
|
|
119
|
+
// construction, so the boolean could not tell a branch that was dropped from a name that was
|
|
120
|
+
// never a database — which is the one question a reaper or a preview teardown asks it.
|
|
121
|
+
const existed = await exists(client, branch);
|
|
122
|
+
await client.execute(sql`drop database if exists ${identifier(branch)}`);
|
|
123
|
+
return existed;
|
|
118
124
|
}
|
|
119
125
|
|
|
120
126
|
export interface ReapOptions extends DropBranchOptions {
|
|
@@ -123,7 +129,7 @@ export interface ReapOptions extends DropBranchOptions {
|
|
|
123
129
|
|
|
124
130
|
/** Preview environments leak branches; this is what the nightly `reapBranches` task calls. */
|
|
125
131
|
export async function reapBranches(options: ReapOptions): Promise<readonly string[]> {
|
|
126
|
-
const cutoff = (options.now ??
|
|
132
|
+
const cutoff = (options.now ?? systemClock.now()).getTime() - options.maxAgeMs;
|
|
127
133
|
const branches = await listBranches(options);
|
|
128
134
|
const dropped: string[] = [];
|
|
129
135
|
for (const branch of branches) {
|