@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/CLAUDE.md +618 -0
- package/README.md +159 -21
- package/package.json +3 -2
- package/src/attribution.ts +45 -0
- package/src/branch.ts +21 -5
- package/src/client.ts +259 -31
- package/src/destructive.ts +126 -0
- package/src/drift.ts +263 -14
- package/src/errors.ts +256 -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 +38 -11
- package/src/introspect.ts +34 -8
- package/src/libpq-options.ts +76 -0
- package/src/migrate.ts +283 -64
- 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/src/migrate.ts
CHANGED
|
@@ -3,17 +3,39 @@
|
|
|
3
3
|
// app-version fence is the `migrate` role's contract — a pod must refuse to migrate a database
|
|
4
4
|
// another build already owns, because the alternative is two schemas racing during a rollout.
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { appVersion } from '@ultimat3/core';
|
|
7
|
+
import {
|
|
8
|
+
baseClient,
|
|
9
|
+
type DbClient,
|
|
10
|
+
type DbConnection,
|
|
11
|
+
isReservable,
|
|
12
|
+
poolProfileFor,
|
|
13
|
+
} from './client';
|
|
14
|
+
import { migrateConcurrent, migrationConflict, rollbackStepsInvalid } from './errors';
|
|
15
|
+
import { expectedQueryLoop } from './expected-loop';
|
|
8
16
|
import type { SchemaDescription } from './introspect';
|
|
9
17
|
import { raw, sql } from './sql';
|
|
10
|
-
import {
|
|
18
|
+
import { SQLSTATE, sqlState } from './sqlstate';
|
|
19
|
+
import { statementsOf } from './statement-split';
|
|
20
|
+
import { type DbTx, withTransaction } from './transaction';
|
|
11
21
|
|
|
12
22
|
export const LEDGER_TABLE = 'x_migrations';
|
|
13
23
|
|
|
14
24
|
/** Stable, arbitrary: every Ultimate migrator contends on this one key. */
|
|
15
25
|
export const MIGRATION_LOCK_KEY = 4_919_202_607;
|
|
16
26
|
|
|
27
|
+
/**
|
|
28
|
+
* How long a migrator waits for the lock before refusing. A deploy hook must fail rather than
|
|
29
|
+
* hang: `pg_advisory_lock` blocks with no timeout, so a wedged predecessor held `helm upgrade
|
|
30
|
+
* --wait` inside one statement with nothing in the logs and `backoffLimit` never reached. Long
|
|
31
|
+
* enough that a genuinely slow migration ahead of us is waited out, short enough that a stuck one
|
|
32
|
+
* becomes an exit code inside a deploy window.
|
|
33
|
+
*/
|
|
34
|
+
export const MIGRATION_LOCK_WAIT_MS = 60_000;
|
|
35
|
+
|
|
36
|
+
/** One poll per half-second: cheap against a lock that is usually free on the first try. */
|
|
37
|
+
export const MIGRATION_LOCK_POLL_MS = 500;
|
|
38
|
+
|
|
17
39
|
export interface Migration {
|
|
18
40
|
/** Sort key and primary key. `20260726120000_add_publish_at`. */
|
|
19
41
|
readonly id: string;
|
|
@@ -56,6 +78,10 @@ export interface MigrateOptions {
|
|
|
56
78
|
readonly client?: DbClient | undefined;
|
|
57
79
|
/** Skip the advisory lock. Only `x db branch` does this, against a private database. */
|
|
58
80
|
readonly lock?: boolean | undefined;
|
|
81
|
+
/** How long to wait for the lock before `X_MIGRATE_CONCURRENT`. Defaults to 60s. */
|
|
82
|
+
readonly lockWaitMs?: number | undefined;
|
|
83
|
+
/** `SET LOCAL lock_timeout` per migration. Defaults to the `migrate` role's profile. */
|
|
84
|
+
readonly lockTimeoutMs?: number | undefined;
|
|
59
85
|
}
|
|
60
86
|
|
|
61
87
|
export function checksumOf(text: string): string {
|
|
@@ -66,8 +92,13 @@ export function migrationChecksum(migration: Migration): string {
|
|
|
66
92
|
return migration.checksum ?? checksumOf(migration.up);
|
|
67
93
|
}
|
|
68
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Core's, never a second read of the key: `x_migrations.app_version` and `x_backfills.app_version`
|
|
97
|
+
* are two durable columns an operator reads side by side, and a package defaulting `APP_VERSION`
|
|
98
|
+
* its own way would put two names on one build.
|
|
99
|
+
*/
|
|
69
100
|
export function runningAppVersion(explicit?: string | undefined): string {
|
|
70
|
-
return explicit ??
|
|
101
|
+
return explicit ?? appVersion();
|
|
71
102
|
}
|
|
72
103
|
|
|
73
104
|
export async function ensureLedger(client: DbClient): Promise<void> {
|
|
@@ -83,6 +114,22 @@ export async function ensureLedger(client: DbClient): Promise<void> {
|
|
|
83
114
|
`);
|
|
84
115
|
}
|
|
85
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Whether `error` is "the ledger table does not exist" and nothing else.
|
|
119
|
+
*
|
|
120
|
+
* Everything else — a permission denied, a server in recovery, a timeout — is a failure to read the
|
|
121
|
+
* ledger, not an empty one, and a caller treating the two alike reports every migration as pending
|
|
122
|
+
* against a database it cannot see.
|
|
123
|
+
*
|
|
124
|
+
* The SQLSTATE comes from `sqlState()` and from nowhere else: this function used to read
|
|
125
|
+
* `sourceError.code` itself, which is the SQLSTATE on PGlite and the literal string
|
|
126
|
+
* `ERR_POSTGRES_SERVER_ERROR` on `Bun.SQL`, so it answered `false` for a genuinely missing ledger
|
|
127
|
+
* on every production driver. One reader, one answer (axiom 1).
|
|
128
|
+
*/
|
|
129
|
+
export function isLedgerMissing(error: unknown): boolean {
|
|
130
|
+
return sqlState(error) === SQLSTATE.undefinedTable;
|
|
131
|
+
}
|
|
132
|
+
|
|
86
133
|
export async function readLedger(client: DbClient): Promise<readonly LedgerRow[]> {
|
|
87
134
|
return client.query<LedgerRow>(sql`
|
|
88
135
|
select id, name, checksum, applied_at, app_version, duration_ms
|
|
@@ -102,13 +149,24 @@ export function auditLedger(
|
|
|
102
149
|
): void {
|
|
103
150
|
const known = new Map(migrations.map((migration) => [migration.id, migration]));
|
|
104
151
|
|
|
105
|
-
|
|
152
|
+
// The predicate is "this build does not ship it" and NOTHING else. It used to also require
|
|
153
|
+
// `row.app_version !== appVersion`, which switched the audit off wherever the two agree —
|
|
154
|
+
// `runningAppVersion()` answers `dev` for every development build, so a migration applied by an
|
|
155
|
+
// earlier `dev` build and since deleted was invisible here, and `expectedSchema` then dropped
|
|
156
|
+
// its table from the drift comparison: `ok: true` against a database that still has the table.
|
|
157
|
+
// The version is a detail of the ANSWER, so it moved into the cause.
|
|
158
|
+
const foreign = ledger.filter((row) => !known.has(row.id));
|
|
106
159
|
const first = foreign[0];
|
|
107
160
|
if (first !== undefined) {
|
|
108
161
|
throw migrationConflict(
|
|
109
162
|
`the ledger records migration "${first.id}" applied by app version "${first.app_version}" ` +
|
|
110
163
|
`but this build is "${appVersion}" and does not ship it`,
|
|
111
|
-
`x db status
|
|
164
|
+
// `x db status` has never existed — the subcommands are gen, migrate, reset, studio, branch
|
|
165
|
+
// and backfill — and this is one of the two errors most likely to fire during a real deploy.
|
|
166
|
+
// A `fix:` is copied and run verbatim, so it names the ledger read that works anywhere psql
|
|
167
|
+
// does, and the one edit that resolves the disagreement.
|
|
168
|
+
`deploy app version "${first.app_version}" — or, if that build is gone, drop its row: ` +
|
|
169
|
+
`psql "$DATABASE_URL" -c "delete from ${LEDGER_TABLE} where id = '${first.id}'"`,
|
|
112
170
|
);
|
|
113
171
|
}
|
|
114
172
|
|
|
@@ -134,95 +192,256 @@ export function pendingMigrations(
|
|
|
134
192
|
.filter((migration) => !applied.has(migration.id));
|
|
135
193
|
}
|
|
136
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Hold the migration lock on **one** session for the whole of `fn`, which runs on that session.
|
|
197
|
+
*
|
|
198
|
+
* `pg_advisory_lock` is scoped to a Postgres session, not to a statement, so taking it on a pooled
|
|
199
|
+
* handle locks whichever connection the pool lent for that one statement and then hands the
|
|
200
|
+
* session back. Two things follow, and both were live: the unlock lands on a *different*
|
|
201
|
+
* connection, answers `false` and leaves the lock held until that backend dies — so the next
|
|
202
|
+
* migrator waits forever rather than for the migration; and the locking session, now idle for the
|
|
203
|
+
* whole run, is closed by the pool's idle timeout (`migrate`'s is 10s), which releases the lock
|
|
204
|
+
* mid-migration and lets a second deploy in. `ROLE=migrate` hid the first half by accident — its
|
|
205
|
+
* pool is `max: 1`, so every statement found the same connection. No other role and no test has
|
|
206
|
+
* that.
|
|
207
|
+
*
|
|
208
|
+
* `fn` receives the pinned session and must run every statement on it, for the same `max: 1`
|
|
209
|
+
* reason read the other way: a statement sent to the pool while the pin is held waits for a
|
|
210
|
+
* connection that cannot come back until the migration blocking on it has finished.
|
|
211
|
+
*/
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Take the lock, or refuse — never block forever.
|
|
215
|
+
*
|
|
216
|
+
* `pg_advisory_lock` has no timeout, and that is a deploy outage waiting for its trigger: a
|
|
217
|
+
* predecessor OOM-killed on a network partition keeps its backend, and with it the lock, for hours.
|
|
218
|
+
* The new `ROLE=migrate` pod then sits inside one statement printing nothing, `helm upgrade --wait`
|
|
219
|
+
* blocks on a pod that is `Running` and healthy, and because the job never *fails*, `backoffLimit`
|
|
220
|
+
* never fires. `pg_try_advisory_lock` answers immediately, so the wait becomes ours to bound and
|
|
221
|
+
* the wedge becomes an exit code carrying the lock key and the pid to terminate.
|
|
222
|
+
*
|
|
223
|
+
* Declared as an expected loop: a poll is a loop the framework argued for, and a diagnostic that
|
|
224
|
+
* reported it would teach an author to ignore the ones nobody argued for.
|
|
225
|
+
*/
|
|
226
|
+
async function acquireLock(session: DbClient, waitMs: number): Promise<void> {
|
|
227
|
+
const started = performance.now();
|
|
228
|
+
await expectedQueryLoop(
|
|
229
|
+
'the migration lock is polled, not waited on, so a wedged migrator fails a deploy instead of hanging it',
|
|
230
|
+
async () => {
|
|
231
|
+
for (;;) {
|
|
232
|
+
const row = await session.one<{ locked: boolean }>(
|
|
233
|
+
sql`select pg_try_advisory_lock(${MIGRATION_LOCK_KEY}) as locked`,
|
|
234
|
+
);
|
|
235
|
+
if (row?.locked === true) return;
|
|
236
|
+
const remaining = waitMs - (performance.now() - started);
|
|
237
|
+
if (remaining <= 0) {
|
|
238
|
+
throw migrateConcurrent(MIGRATION_LOCK_KEY, Math.round(performance.now() - started));
|
|
239
|
+
}
|
|
240
|
+
await Bun.sleep(Math.min(MIGRATION_LOCK_POLL_MS, remaining));
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
137
246
|
async function withAdvisoryLock<T>(
|
|
138
247
|
client: DbClient,
|
|
139
248
|
enabled: boolean,
|
|
140
|
-
|
|
249
|
+
waitMs: number,
|
|
250
|
+
fn: (session: DbClient) => Promise<T>,
|
|
141
251
|
): Promise<T> {
|
|
142
|
-
if (!enabled) return fn();
|
|
143
|
-
|
|
252
|
+
if (!enabled) return fn(client);
|
|
253
|
+
// Held by a `using` declaration, like every other pin in this package: the lock is taken after
|
|
254
|
+
// the guard exists, so a rejecting `pg_advisory_lock` gives the connection back too.
|
|
255
|
+
using pinned: DbConnection | undefined = isReservable(client)
|
|
256
|
+
? await client.reserve()
|
|
257
|
+
: undefined;
|
|
258
|
+
const session: DbClient = pinned ?? client;
|
|
259
|
+
await acquireLock(session, waitMs);
|
|
144
260
|
try {
|
|
145
|
-
return await fn();
|
|
261
|
+
return await fn(session);
|
|
146
262
|
} finally {
|
|
147
|
-
|
|
263
|
+
// Best-effort, and only here: an unlock that rejects would mask the failure that ended the
|
|
264
|
+
// migration, and it can only reject on a session that is already broken — whose locks Postgres
|
|
265
|
+
// drops when it ends. It runs before the pin is disposed, so the unlock reaches the session
|
|
266
|
+
// that took the lock.
|
|
267
|
+
await session
|
|
148
268
|
.execute(sql`select pg_advisory_unlock(${MIGRATION_LOCK_KEY})`)
|
|
149
269
|
.catch(() => undefined);
|
|
150
270
|
}
|
|
151
271
|
}
|
|
152
272
|
|
|
273
|
+
/**
|
|
274
|
+
* A migration's `up` and `down` are **scripts**, and one send is one statement — because the two
|
|
275
|
+
* drivers disagree about anything else. PGlite's `query()` is the extended protocol always and
|
|
276
|
+
* answers `cannot insert multiple commands into a prepared statement`; `Bun.SQL.unsafe` degrades
|
|
277
|
+
* to the simple protocol when no value is bound and applies the same script, which is a fact about
|
|
278
|
+
* bun 1.3.14 and not a contract. `createTable` emits the table *and* every index it carries, and
|
|
279
|
+
* `x dev` runs on the embedded driver, so the refusing side was the common path.
|
|
280
|
+
*
|
|
281
|
+
* No `expectedQueryLoop` of its own: both call sites already run inside the one declared for the
|
|
282
|
+
* migration loop, and nesting here would replace that reason with a narrower one for no gain. An
|
|
283
|
+
* empty script sends nothing at all, which is how a no-op migration reaches its ledger row.
|
|
284
|
+
*/
|
|
285
|
+
async function applyScript(tx: DbTx, script: string): Promise<void> {
|
|
286
|
+
for (const statement of statementsOf(script)) await tx.execute(raw(statement));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Bound how long this migration will queue behind a lock it cannot take.
|
|
291
|
+
*
|
|
292
|
+
* `alter table … add column` needs `ACCESS EXCLUSIVE`. A long `SELECT` holding `ACCESS SHARE`
|
|
293
|
+
* makes it wait — and because Postgres' lock queue is FIFO, **every subsequent query on that table
|
|
294
|
+
* queues behind the ALTER**. The `migrate` profile runs `statement_timeout = 0` deliberately, so
|
|
295
|
+
* without this the migrator waits forever and the app is down on one table for as long as the
|
|
296
|
+
* reporting query runs. `lock_timeout` bounds the *wait* alone and never the work, which is why it
|
|
297
|
+
* is the right knob where `statement_timeout` is not.
|
|
298
|
+
*
|
|
299
|
+
* `SET LOCAL`, inside the migration's own transaction: it reverts at COMMIT, so a value chosen for
|
|
300
|
+
* DDL never leaks onto the session the ledger insert or the next migration runs on. A 0 disables
|
|
301
|
+
* it, exactly like `statementTimeoutMs`. The failure it produces is `55P03`, typed as
|
|
302
|
+
* `X_DB_LOCK_TIMEOUT` by `driverError` with the `pg_stat_activity` read as its fix.
|
|
303
|
+
*/
|
|
304
|
+
async function setLockTimeout(tx: DbTx, lockTimeoutMs: number): Promise<void> {
|
|
305
|
+
if (lockTimeoutMs <= 0) return;
|
|
306
|
+
// `SET LOCAL` takes no parameter placeholder, and the value is a validated integer of ours.
|
|
307
|
+
await tx.execute(raw(`SET LOCAL lock_timeout = ${Math.round(lockTimeoutMs)}`));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* `migrate` role's profile whatever role is running, because the statement is a migration whatever
|
|
312
|
+
* process issues it: `x db migrate` from a laptop, `x dev`'s boot and `ROLE=migrate` all take the
|
|
313
|
+
* same `ACCESS EXCLUSIVE` locks against the same tables.
|
|
314
|
+
*/
|
|
315
|
+
function migrationLockTimeoutMs(explicit: number | undefined): number {
|
|
316
|
+
return explicit ?? poolProfileFor('migrate').lockTimeoutMs;
|
|
317
|
+
}
|
|
318
|
+
|
|
153
319
|
export async function migrate(options: MigrateOptions): Promise<MigrationReport> {
|
|
154
320
|
const client = options.client ?? baseClient();
|
|
155
321
|
const appVersion = runningAppVersion(options.appVersion);
|
|
322
|
+
const lockTimeoutMs = migrationLockTimeoutMs(options.lockTimeoutMs);
|
|
156
323
|
const started = performance.now();
|
|
157
324
|
|
|
158
|
-
return withAdvisoryLock(
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
325
|
+
return withAdvisoryLock(
|
|
326
|
+
client,
|
|
327
|
+
options.lock !== false,
|
|
328
|
+
options.lockWaitMs ?? MIGRATION_LOCK_WAIT_MS,
|
|
329
|
+
async (session) => {
|
|
330
|
+
await ensureLedger(session);
|
|
331
|
+
const ledger = await readLedger(session);
|
|
332
|
+
auditLedger(ledger, options.migrations, appVersion);
|
|
333
|
+
|
|
334
|
+
const pending = pendingMigrations(ledger, options.migrations);
|
|
335
|
+
// A statement per migration and a transaction per migration is the point, not an N+1 to batch:
|
|
336
|
+
// one failed `up` must leave the ledger describing exactly the migrations that did run, and a
|
|
337
|
+
// batch commits or loses all of them together. Declared here so a diagnostic reports the loops
|
|
338
|
+
// nobody argued for and stays quiet about this one.
|
|
339
|
+
const applied = await expectedQueryLoop(
|
|
340
|
+
'each migration applies in its own transaction, so a failure leaves an exact ledger',
|
|
341
|
+
async () => {
|
|
342
|
+
const done: AppliedMigration[] = [];
|
|
343
|
+
for (const migration of pending) {
|
|
344
|
+
const at = performance.now();
|
|
345
|
+
await withTransaction(
|
|
346
|
+
async (tx) => {
|
|
347
|
+
await setLockTimeout(tx, lockTimeoutMs);
|
|
348
|
+
await applyScript(tx, migration.up);
|
|
349
|
+
const durationMs = Math.round(performance.now() - at);
|
|
350
|
+
await tx.execute(sql`
|
|
351
|
+
insert into ${raw(LEDGER_TABLE)} (id, name, checksum, app_version, duration_ms)
|
|
352
|
+
values (${migration.id}, ${migration.name}, ${migrationChecksum(migration)},
|
|
353
|
+
${appVersion}, ${durationMs})
|
|
354
|
+
`);
|
|
355
|
+
},
|
|
356
|
+
// The lock's own session: a migration applied on another connection is not covered by
|
|
357
|
+
// the lock at all, and on `ROLE=migrate` there is no other connection to apply it on.
|
|
358
|
+
{ client: session },
|
|
359
|
+
);
|
|
360
|
+
done.push({
|
|
361
|
+
id: migration.id,
|
|
362
|
+
name: migration.name,
|
|
363
|
+
durationMs: Math.round(performance.now() - at),
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
return done;
|
|
176
367
|
},
|
|
177
|
-
{ client },
|
|
178
368
|
);
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
skipped: ledger.map((row) => row.id),
|
|
189
|
-
durationMs: Math.round(performance.now() - started),
|
|
190
|
-
appVersion,
|
|
191
|
-
};
|
|
192
|
-
});
|
|
369
|
+
|
|
370
|
+
return {
|
|
371
|
+
applied,
|
|
372
|
+
skipped: ledger.map((row) => row.id),
|
|
373
|
+
durationMs: Math.round(performance.now() - started),
|
|
374
|
+
appVersion,
|
|
375
|
+
};
|
|
376
|
+
},
|
|
377
|
+
);
|
|
193
378
|
}
|
|
194
379
|
|
|
195
380
|
export interface RollbackOptions {
|
|
196
381
|
readonly migrations: readonly Migration[];
|
|
197
382
|
readonly client?: DbClient | undefined;
|
|
383
|
+
/** How many applied migrations to reverse, newest first. A positive integer; defaults to 1. */
|
|
198
384
|
readonly steps?: number | undefined;
|
|
385
|
+
/** Skip the advisory lock. Only `x db branch` does this, against a private database. */
|
|
386
|
+
readonly lock?: boolean | undefined;
|
|
387
|
+
/** How long to wait for the lock before `X_MIGRATE_CONCURRENT`. Defaults to 60s. */
|
|
388
|
+
readonly lockWaitMs?: number | undefined;
|
|
389
|
+
/** `SET LOCAL lock_timeout` per reversal. Defaults to the `migrate` role's profile. */
|
|
390
|
+
readonly lockTimeoutMs?: number | undefined;
|
|
199
391
|
}
|
|
200
392
|
|
|
201
393
|
/** Reverse the newest `steps` applied migrations. `x db rollback`. */
|
|
202
394
|
export async function rollback(options: RollbackOptions): Promise<readonly string[]> {
|
|
203
395
|
const client = options.client ?? baseClient();
|
|
204
396
|
const steps = options.steps ?? 1;
|
|
205
|
-
|
|
397
|
+
// Before the lock and before the ledger read: `slice(0, -1)` is "all but the newest", not
|
|
398
|
+
// "one fewer", so an unvalidated count reverses migrations nobody asked about.
|
|
399
|
+
if (!Number.isSafeInteger(steps) || steps < 1) throw rollbackStepsInvalid(steps);
|
|
400
|
+
const lockTimeoutMs = migrationLockTimeoutMs(options.lockTimeoutMs);
|
|
206
401
|
const known = new Map(options.migrations.map((migration) => [migration.id, migration]));
|
|
207
|
-
const targets = [...ledger].reverse().slice(0, steps);
|
|
208
|
-
const reverted: string[] = [];
|
|
209
402
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
403
|
+
// The same lock `migrate` takes, because the race is the same one: a rollback reversing the id a
|
|
404
|
+
// migrator is applying leaves a ledger that describes neither, and the ledger read below decides
|
|
405
|
+
// what to reverse — outside the lock it can be stale before the first `down` runs.
|
|
406
|
+
return withAdvisoryLock(
|
|
407
|
+
client,
|
|
408
|
+
options.lock !== false,
|
|
409
|
+
options.lockWaitMs ?? MIGRATION_LOCK_WAIT_MS,
|
|
410
|
+
async (session) => {
|
|
411
|
+
const ledger = await readLedger(session);
|
|
412
|
+
const targets = [...ledger].reverse().slice(0, steps);
|
|
413
|
+
|
|
414
|
+
// The same deliberate loop as `migrate`, read backwards: one transaction per `down`, newest
|
|
415
|
+
// first, so a `down` that fails leaves every migration before it still applied and recorded.
|
|
416
|
+
return expectedQueryLoop(
|
|
417
|
+
'each migration reverses in its own transaction, newest first, so a failure stops exactly there',
|
|
418
|
+
async () => {
|
|
419
|
+
const reverted: string[] = [];
|
|
420
|
+
for (const row of targets) {
|
|
421
|
+
const migration = known.get(row.id);
|
|
422
|
+
if (migration === undefined) {
|
|
423
|
+
throw migrationConflict(
|
|
424
|
+
`migration "${row.id}" is in the ledger but not in this build, so its down SQL is unknown`,
|
|
425
|
+
// Same reason as `auditLedger`'s: `x db status` does not exist. The `down` SQL only
|
|
426
|
+
// exists in the build that shipped it, so the fix is the read that names that build.
|
|
427
|
+
`psql "$DATABASE_URL" -c "select id, app_version from ${LEDGER_TABLE} ` +
|
|
428
|
+
`order by id desc limit 5" # deploy the build that shipped "${row.id}", ` +
|
|
429
|
+
'and roll back there — its down SQL exists nowhere else',
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
await withTransaction(
|
|
433
|
+
async (tx) => {
|
|
434
|
+
await setLockTimeout(tx, lockTimeoutMs);
|
|
435
|
+
await applyScript(tx, migration.down);
|
|
436
|
+
await tx.execute(sql`delete from ${raw(LEDGER_TABLE)} where id = ${row.id}`);
|
|
437
|
+
},
|
|
438
|
+
{ client: session },
|
|
439
|
+
);
|
|
440
|
+
reverted.push(row.id);
|
|
441
|
+
}
|
|
442
|
+
return reverted;
|
|
443
|
+
},
|
|
216
444
|
);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
async (tx) => {
|
|
220
|
-
await tx.execute(raw(migration.down));
|
|
221
|
-
await tx.execute(sql`delete from ${raw(LEDGER_TABLE)} where id = ${row.id}`);
|
|
222
|
-
},
|
|
223
|
-
{ client },
|
|
224
|
-
);
|
|
225
|
-
reverted.push(row.id);
|
|
226
|
-
}
|
|
227
|
-
return reverted;
|
|
445
|
+
},
|
|
446
|
+
);
|
|
228
447
|
}
|
package/src/observe.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Single responsibility: the seam a diagnostic hangs statements off. One installed observer,
|
|
2
|
+
// process-wide, read through one accessor — so the funnels every statement already passes through
|
|
3
|
+
// pay a single `undefined` check when nothing is installed (axiom 6). Nothing here knows what an
|
|
4
|
+
// entity, a request or a span is: `db` is tier 1 and the detector that consumes this is tier 5.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What the layer that compiled the statement knows and the driver cannot: which entity and which
|
|
8
|
+
* repository operation. It is the difference between "50× `select … where id = $1`" and "50×
|
|
9
|
+
* `findById` on `members`" in a diagnostic's report.
|
|
10
|
+
*
|
|
11
|
+
* Produced by `@ultimat3/entity`'s `postgresRepo` (tier 2, so importing this is downward): it is
|
|
12
|
+
* the last caller that still knows the entity and the operation by the time the SQL exists, and it
|
|
13
|
+
* declares both through `withStatementAttribution` (`attribution.ts`) around each repository call.
|
|
14
|
+
* Hand-written SQL, a migration, a health probe and the job queue's own statements are unattributed
|
|
15
|
+
* — nothing above them knows an entity to name — which is why the field is optional rather than
|
|
16
|
+
* required, and why a diagnostic must fall back to the statement text.
|
|
17
|
+
*/
|
|
18
|
+
export interface StatementAttribution {
|
|
19
|
+
/** Entity name as declared, e.g. `members` — never a table name. */
|
|
20
|
+
readonly entity: string;
|
|
21
|
+
/** Repository operation that compiled the statement, e.g. `findById`. */
|
|
22
|
+
readonly op: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** One settled statement. Emitted after it resolved or rejected, never before it was sent. */
|
|
26
|
+
export interface StatementEvent {
|
|
27
|
+
/** The statement as sent, parameters still as `$1..$n`. Safe to log; values are separate. */
|
|
28
|
+
readonly text: string;
|
|
29
|
+
/** Bound parameters, in order. May carry user data — a consumer that logs must redact. */
|
|
30
|
+
readonly values: readonly unknown[];
|
|
31
|
+
/** Wall time from send to settle, from `performance.now()`. */
|
|
32
|
+
readonly durationMs: number;
|
|
33
|
+
/** Rows returned by a read, rows affected by a write, `0` when the statement threw. */
|
|
34
|
+
readonly rows: number;
|
|
35
|
+
/** The rejection, already wrapped as `X_DB_UNAVAILABLE` by the funnel. */
|
|
36
|
+
readonly error?: unknown;
|
|
37
|
+
/**
|
|
38
|
+
* Who compiled this statement, absent when nothing above the SQL knew — see
|
|
39
|
+
* `StatementAttribution`. Stamped by the funnel from the scope open at send time, for the same
|
|
40
|
+
* reason `expected` is: a diagnostic that judges a whole request runs long after every scope in
|
|
41
|
+
* it closed.
|
|
42
|
+
*/
|
|
43
|
+
readonly attribution?: StatementAttribution | undefined;
|
|
44
|
+
/**
|
|
45
|
+
* The reason of the innermost `expectedQueryLoop()` this statement was issued inside, absent
|
|
46
|
+
* outside every such scope. Stamped by the funnel at settle time rather than read later, because
|
|
47
|
+
* a diagnostic that judges a whole request at the end of it runs long after the scope closed. A
|
|
48
|
+
* detector counting repeats must not warn about these; everything that only measures — the span,
|
|
49
|
+
* the timeline, a metric — treats them like any other statement.
|
|
50
|
+
*/
|
|
51
|
+
readonly expected?: string | undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A diagnostic. `onStatement` runs synchronously on the caller's stack once the statement has
|
|
56
|
+
* settled, so it must not await anything and must not issue SQL — a statement issued from here
|
|
57
|
+
* re-enters the funnel and observes itself.
|
|
58
|
+
*
|
|
59
|
+
* A throw propagates to whoever ran the statement, deliberately: strict test mode is an observer
|
|
60
|
+
* that fails the test the N+1 happened in, and swallowing here would make that impossible. An
|
|
61
|
+
* observer that only reports must therefore not throw.
|
|
62
|
+
*/
|
|
63
|
+
export interface StatementObserver {
|
|
64
|
+
onStatement(event: StatementEvent): void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let installed: StatementObserver | undefined;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Install the process-wide observer. `setStatementObserver(undefined)` uninstalls, which is the
|
|
71
|
+
* production state and the state every test must leave behind. The `setDbClient` shape, for the
|
|
72
|
+
* same reason: the thing being replaced is ambient, so the seam is a setter and not a parameter
|
|
73
|
+
* threaded through `db()`, `withTransaction()` and both drivers.
|
|
74
|
+
*
|
|
75
|
+
* One observer, not a list — a second registration replaces the first. A fan-out array would make
|
|
76
|
+
* "which diagnostic saw this statement" order-dependent, and the one consumer that needs several
|
|
77
|
+
* (the dev server) composes them itself, in its own order, where that order is reviewable.
|
|
78
|
+
*/
|
|
79
|
+
export function setStatementObserver(observer: StatementObserver | undefined): void {
|
|
80
|
+
installed = observer;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The installed observer, or `undefined` when there is none. Read once per statement and guarded
|
|
85
|
+
* at the call site rather than notified through a wrapper, so an uninstalled seam costs one
|
|
86
|
+
* property read and one branch — no event object is allocated for nobody to receive.
|
|
87
|
+
*/
|
|
88
|
+
export function statementObserver(): StatementObserver | undefined {
|
|
89
|
+
return installed;
|
|
90
|
+
}
|
package/src/pglite-branch.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { cp, mkdir, rm, stat } from 'node:fs/promises';
|
|
7
7
|
import { basename, dirname, join } from 'node:path';
|
|
8
|
+
import { systemClock } from '@ultimat3/core';
|
|
8
9
|
import type { BranchInfo } from './branch';
|
|
9
10
|
import { assertBranchName } from './branch';
|
|
10
11
|
import { branchExists, dbNotImplemented, dbUnavailable } from './errors';
|
|
@@ -77,7 +78,7 @@ export async function branchPglite(
|
|
|
77
78
|
|
|
78
79
|
return {
|
|
79
80
|
name: branch,
|
|
80
|
-
createdAt: (options.now ??
|
|
81
|
+
createdAt: (options.now ?? systemClock.now()).toISOString(),
|
|
81
82
|
dataDir: to,
|
|
82
83
|
sizeBytes: await directorySize(to),
|
|
83
84
|
};
|
package/src/pglite-turns.ts
CHANGED
|
@@ -5,10 +5,15 @@
|
|
|
5
5
|
// gives `withTransaction` and `readOnlyQuery` on a real server.
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* Gives the connection back.
|
|
9
|
-
* counter, so a second call cannot hand out a second turn — the next caller is
|
|
8
|
+
* Gives the connection back. `release()` is idempotent for free: it is a settled promise's
|
|
9
|
+
* `resolve`, not a counter, so a second call cannot hand out a second turn — the next caller is
|
|
10
|
+
* already awake. `Disposable`, so `using turn = await queue.take()` gives it back on every exit
|
|
11
|
+
* path — the same shape as `DbConnection` in `client.ts`, and `[Symbol.dispose]` is `release()`
|
|
12
|
+
* itself, never a second code path.
|
|
10
13
|
*/
|
|
11
|
-
export
|
|
14
|
+
export interface Turn extends Disposable {
|
|
15
|
+
release(): void;
|
|
16
|
+
}
|
|
12
17
|
|
|
13
18
|
export interface TurnQueue {
|
|
14
19
|
/** Wait for the connection, then keep it until the returned `Turn` is called. */
|
|
@@ -35,16 +40,14 @@ export function createTurnQueue(): TurnQueue {
|
|
|
35
40
|
// other, not both read the same tail and run at once.
|
|
36
41
|
tail = mine.then(() => held);
|
|
37
42
|
await mine;
|
|
38
|
-
return release;
|
|
43
|
+
return { release, [Symbol.dispose]: release };
|
|
39
44
|
}
|
|
40
45
|
|
|
41
46
|
async function run<T>(work: () => Promise<T>): Promise<T> {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
turn();
|
|
47
|
-
}
|
|
47
|
+
// `using`, not `try`/`finally`: the turn must go back on every exit path, including one a
|
|
48
|
+
// future edit adds above a hand-rolled `finally` that forgot it — see `client.ts`.
|
|
49
|
+
using _turn = await take();
|
|
50
|
+
return await work();
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
return { take, run };
|