@ultimat3/db 1.2.0 → 3.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/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
- | `withTransaction()` / `currentTx()` | transaction scope; `currentTx()` is the outbox seam |
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
- | `checkDrift()` / `diffSchema()` / `assertNoDrift()` | drift, with a `--json` report |
33
- | `generateMigration()` | `x db gen "<name>"` — reversible up/down SQL |
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
- `x db drift` compares `introspect()` against the snapshot the newest applied migration carries.
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,12 @@ 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 verify` fails on it.
140
+ `checkDrift()` returns every difference; `assertNoDrift()` throws the first. `x db migrate` renders
141
+ them all as findings and exits non-zero; a `ROLE=migrate` container throws the first one
142
+ (`assertNoDrift`, in `runRole`) and exits non-zero too, because the release phase has one channel —
143
+ the exit code — and a deploy that rolled on past a schema nobody can reconstruct is the failure
144
+ drift exists to catch. There is no `x db drift`, and `x verify`'s `drift` step is the *source*
145
+ detector (`checkSourceDrift`), which needs no database and never calls this.
106
146
 
107
147
  ## The embedded database
108
148
 
@@ -146,21 +186,53 @@ scope is already holding, because waiting would be a deadlock with no error to e
146
186
 
147
187
  `ROLE` picks the profile — a `worker` draining a queue must not size like a `web` process.
148
188
 
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=`. `Bun.SQL` is
159
- reached lazily, so importing this package never opens a socket.
189
+ | Role | max | statement timeout | idle timeout | lock timeout | acquire timeout |
190
+ |---|---|---|---|---|---|
191
+ | `web` | 20 | 10s | 30s | none | 5s |
192
+ | `sync` | 10 | 10s | 60s | none | 5s |
193
+ | `worker` | 8 | 120s | 30s | none | 10s |
194
+ | `scheduler` | 2 | 15s | 60s | none | 10s |
195
+ | `migrate` | 1 | none | 10s | 3s | none |
196
+ | `replicator` | 4 | none | 60s | none | none |
197
+
198
+ The statement timeout is pinned per connection via libpq `options=-c statement_timeout=`, and it
199
+ does reach the backend: `client.live.test.ts` asserts `current_setting('statement_timeout')`, which
200
+ is the only reading a DSN test cannot fake. `Bun.SQL` is reached lazily, so importing this package
201
+ never opens a socket.
202
+
203
+ **That setting is MERGED into the operator's own `options`, never assigned over them** (`As of
204
+ 2026-08`). `?options=-c search_path=app` in `DATABASE_URL` survives on every role, and the role's
205
+ `statement_timeout` is appended to it; if the URL sets `statement_timeout` itself, the **role
206
+ wins** — it is a bound the pool is sized around — and every other flag is kept. It is emitted for
207
+ all six roles, `migrate`'s and `replicator`'s `0` included: `0` is "this role may take as long as
208
+ it takes", and left unsaid a server-side `alter database … set statement_timeout` would kill the
209
+ one role that has to outlive it. Before this, `set` replaced the whole value and only on the roles
210
+ with a non-zero timeout, so a `search_path` survived on `migrate` and vanished on `web` — the role
211
+ that runs the migrations and the role that serves the traffic reading different schemas.
212
+
213
+ **`DATABASE_POOL_MAX` overrides `max`** (`As of 2026-08`), and it is the only pool knob an operator
214
+ can turn without shipping an image — 400 `web` pods × the frozen `max: 20` is 8,000 backends. A
215
+ value that is not a positive integer refuses at boot rather than falling back.
216
+
217
+ **`acquireTimeoutMs` bounds `reserve()`**, because queueing turns exhaustion into a hang: `/readyz`'s
218
+ `select 1` joins the same queue, the kubelet kills the pod, and the replacement inherits the same
219
+ saturated database. 0 waits, which is what a run-once role wants.
220
+
221
+ **`lockTimeoutMs` bounds a *wait*, never the work.** `alter table … add column` takes `ACCESS
222
+ EXCLUSIVE`; a long `SELECT` holding `ACCESS SHARE` makes it queue, and Postgres' lock queue is FIFO,
223
+ so every later query on that table queues behind the ALTER. `migrate` runs `statement_timeout = 0`,
224
+ so nothing else would ever end that wait. `migrate()` emits it as `SET LOCAL lock_timeout` inside
225
+ each migration's own transaction — it reverts at COMMIT, so a DDL value never leaks onto the session
226
+ the ledger insert runs on.
160
227
 
161
228
  ## Migrations
162
229
 
163
- `migrate()` takes an advisory lock (`pg_advisory_lock(4919202607)`), ensures `x_migrations`
230
+ `migrate()` takes the advisory lock by **polling** `pg_try_advisory_lock(4919202607)` every 500ms
231
+ until `lockWaitMs` (default 60s) and then throwing `X_MIGRATE_CONCURRENT` (`As of 2026-08`).
232
+ `pg_advisory_lock` has no timeout, and a predecessor OOM-killed on a partition keeps its backend —
233
+ and the lock — for hours, so a deploy hook sat inside one statement printing nothing while
234
+ `helm upgrade --wait` blocked and `backoffLimit` never fired, because a job that never finishes
235
+ never fails. It then ensures `x_migrations`
164
236
  (`id, name, checksum, applied_at, app_version, duration_ms`), audits, then applies each pending
165
237
  migration inside its own transaction. It refuses **before applying anything** when:
166
238
 
@@ -170,22 +242,88 @@ migration inside its own transaction. It refuses **before applying anything** wh
170
242
 
171
243
  Report (`--json`): `{ applied: [{ id, name, durationMs }], skipped: [id], durationMs, appVersion }`.
172
244
 
245
+ ## A loop of queries that is deliberate says so
246
+
247
+ `As of 2026-08`:
248
+
249
+ ```ts
250
+ return expectedQueryLoop('one indexed lookup per text field beats one unindexed OR', async () => {
251
+ for (const field of fields) hits.push(...(await repo.list({ where: [match(field)] })));
252
+ return hits;
253
+ });
254
+ ```
255
+
256
+ One mechanism, and only one: no comment pragma, no config list of exempt call sites (axiom 1).
257
+ `reason` is required and non-blank — an exemption with no argument is a pragma, and the next
258
+ reader cannot tell a considered loop from a silenced one.
259
+
260
+ | | |
261
+ |---|---|
262
+ | 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 |
263
+ | 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 |
264
+ | 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 |
265
+ | What it costs | nothing without a diagnostic — the reason is read inside the branch that already checks for an installed observer |
266
+
267
+ The framework's own deliberate loops declare themselves at source: `migrate()` and `rollback()`
268
+ (one transaction per migration, so a failure leaves an exact ledger) and `@ultimat3/admin`'s
269
+ cross-entity search (one indexed lookup per text field).
270
+
271
+ ## A statement knows who compiled it
272
+
273
+ `As of 2026-08`: `StatementEvent.attribution` is no longer always `undefined`.
274
+
275
+ ```ts
276
+ return withStatementAttribution('members', 'findById', () =>
277
+ client.query(sql`select * from members where id = any(${ids})`),
278
+ );
279
+ ```
280
+
281
+ | | |
282
+ |---|---|
283
+ | Scope | an `AsyncLocalStorage`, `expectedQueryLoop()`'s own shape: it survives every `await` at any depth, and nesting keeps the innermost pair |
284
+ | What it carries | `StatementEvent.attribution`, stamped by both funnels at settle time, next to `expected` |
285
+ | Producer | `@ultimat3/entity`'s `postgresRepo` — the last caller that still knows the entity and the operation once the SQL exists |
286
+ | What it costs | nothing uninstalled — `statementObserver()` is read first, and with nothing installed `fn` runs directly; no scope entered, no object allocated |
287
+
288
+ Hand-written SQL, a migration, a health probe, `x db` commands and `@ultimat3/jobs`' own queue
289
+ statements still carry no `attribution` — nothing above them knows an entity to name, so the field
290
+ is optional and a detector must fall back to the statement text either way.
291
+
173
292
  ## Error codes
174
293
 
294
+ Every driver failure is typed by the **SQLSTATE the server sent**, `As of 2026-08`. The state was
295
+ always on the error and nothing read it, so a `23505` from two clicks racing a signup answered
296
+ "cannot reach the database" and paged on-call for an outage that never happened. The table
297
+ (`sqlstate.ts`) is closed; everything outside it is still `X_DB_UNAVAILABLE`, unchanged.
298
+
175
299
  | Code | Meaning |
176
300
  |---|---|
177
- | `X_DB_UNAVAILABLE` | no reachable database; `fix:` names `DATABASE_URL` |
301
+ | `X_DB_UNAVAILABLE` | no reachable database, or a SQLSTATE the table does not name; `fix:` names `DATABASE_URL` |
302
+ | `X_DB_UNIQUE_VIOLATION` | `23505` — `fix:` names `upsertAll(rows, { onConflict: [...] })` and the constraint the server named |
303
+ | `X_DB_FOREIGN_KEY_VIOLATION` | `23503` |
304
+ | `X_DB_SERIALIZATION_FAILURE` | `40001` / `40P01`, and an exhausted `withTransaction(fn, { retry: n })` budget |
305
+ | `X_DB_STATEMENT_TIMEOUT` | `57014` — the statement ran past `statement_timeout` |
306
+ | `X_DB_LOCK_TIMEOUT` | `55P03` — it waited past `lock_timeout` for a lock it never got |
307
+ | `X_DB_POOL_EXHAUSTED` | `53300` / `53200`, or `reserve()` past `acquireTimeoutMs` |
178
308
  | `X_DB_DRIFT` | live schema differs from migrations |
179
309
  | `X_MIGRATION_CONFLICT` | ledger app-version fence or checksum mismatch |
310
+ | `X_MIGRATE_CONCURRENT` | another migrator still held the lock when the wait ran out |
180
311
  | `X_MIGRATION_IRREVERSIBLE` | generated `down` would lose data |
181
312
  | `X_SQL_UNSAFE` | non-bindable interpolation, or an unsafe identifier/branch name |
182
313
  | `X_BRANCH_EXISTS` | branch database already exists (or is the connected one) |
183
- | `X_READONLY_VIOLATION` | a mutating statement reached a `readOnly()` client |
184
314
  | `X_NOT_IMPLEMENTED` | branching an in-memory PGlite — a copy needs a directory |
315
+ | `X_ENV_MISSING` | core's — `DATABASE_POOL_MAX` is set to something that is not a positive integer |
185
316
 
186
317
  ```bash
187
318
  x db migrate --json
188
- x db drift --json
189
319
  x db gen "add publish_at"
320
+ x db branch ls --json
190
321
  x db branch create feature_x
322
+ x db branch drop feature_x
191
323
  ```
324
+
325
+ **Drift has no subcommand of its own.** The database half runs *inside* `x db migrate`, which calls
326
+ `checkDrift()` on the connection it already holds and exits non-zero on a difference; the source
327
+ half is the `drift` step of `x verify`, which hashes entity source against what `x db gen` recorded
328
+ and opens no database. Two questions, two owners, and no `drift` subcommand under `x db` — the
329
+ `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": "1.2.0",
3
+ "version": "3.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": "1.2.0"
34
+ "@ultimat3/core": "3.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 ?? new Date()).toISOString();
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
- const affected = await client.execute(sql`drop database if exists ${identifier(branch)}`);
117
- return affected >= 0;
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,12 +129,22 @@ 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 ?? new Date()).getTime() - options.maxAgeMs;
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) {
130
136
  if (branch.createdAt === null) continue;
131
- if (new Date(branch.createdAt).getTime() > cutoff) continue;
137
+ const createdAtMs = Date.parse(branch.createdAt);
138
+ // `NaN > cutoff` is `false`, which is the same answer "older than the cutoff" gives — so a
139
+ // truncated or hand-edited comment used to be a database DROPPED on the next sweep, whatever
140
+ // `maxAgeMs` said. An age nothing can read is not an old age.
141
+ if (!Number.isFinite(createdAtMs)) continue;
142
+ // Finite is not enough: `'2026-08-18T10:00'` parses as LOCAL time, so a truncated comment
143
+ // names an instant hours from the one it reads as, and the sweep acts on a date nobody wrote.
144
+ // `createBranch` writes `toISOString()` and nothing else does, so a value that does not round
145
+ // trip through it is not ours — there is no legitimate non-canonical comment to strand.
146
+ if (new Date(createdAtMs).toISOString() !== branch.createdAt) continue;
147
+ if (createdAtMs > cutoff) continue;
132
148
  await dropBranch(branch.name, options);
133
149
  dropped.push(branch.name);
134
150
  }