@ultimat3/db 1.1.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/src/index.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  // `@ultimat3/auth`, `@ultimat3/entity`, `@ultimat3/jobs` and the CLI are written against this
3
3
  // list, so anything not here is an implementation detail and may change.
4
4
 
5
+ export { statementAttribution, withStatementAttribution } from './attribution';
5
6
  export type { BranchInfo, BranchOptions, DropBranchOptions, ReapOptions } from './branch';
6
7
  export {
7
8
  assertBranchName,
@@ -26,17 +27,29 @@ export {
26
27
  createPostgresClient,
27
28
  db,
28
29
  isReservable,
30
+ POOL_MAX_ENV,
29
31
  POOL_PROFILES,
30
32
  poolProfileFor,
31
33
  setDbClient,
32
34
  } from './client';
35
+ export type { DestructiveKind, DestructiveStatement } from './destructive';
36
+ export {
37
+ DESTRUCTIVE_CAUSE,
38
+ DESTRUCTIVE_MARKER,
39
+ destructiveStatements,
40
+ hasDestructiveMarker,
41
+ isDestructive,
42
+ } from './destructive';
33
43
  export type { DriftDifference, DriftKind, DriftOptions, DriftReport } from './drift';
34
44
  export {
45
+ appTables,
35
46
  assertNoDrift,
36
47
  checkDrift,
48
+ declaredSchema,
37
49
  diffSchema,
38
50
  driftError,
39
51
  expectedSchema,
52
+ FRAMEWORK_TABLE_PREFIX,
40
53
  } from './drift';
41
54
  export type { DbErrorCode, DbErrorInit } from './errors';
42
55
  export {
@@ -48,12 +61,20 @@ export {
48
61
  dbDrift,
49
62
  dbNotImplemented,
50
63
  dbUnavailable,
64
+ driverError,
51
65
  identifierUnsafe,
66
+ migrateConcurrent,
52
67
  migrationConflict,
68
+ migrationDestructive,
53
69
  migrationIrreversible,
54
- readonlyViolation,
70
+ migrationSnapshotMissing,
71
+ multipleStatements,
72
+ poolAcquireTimeout,
73
+ poolMaxInvalid,
74
+ serializationExhausted,
55
75
  sqlUnsafe,
56
76
  } from './errors';
77
+ export { expectedQueryLoop, expectedQueryLoopReason } from './expected-loop';
57
78
  export type { RecordedStatement, RecordingClient, StubResponse } from './fake';
58
79
  export { createRecordingClient } from './fake';
59
80
  export type {
@@ -61,15 +82,9 @@ export type {
61
82
  EntityDescriptionLike,
62
83
  GeneratedMigration,
63
84
  GenerateOptions,
64
- ParsedIndex,
65
- } from './generate';
66
- export {
67
- generateMigration,
68
- migrationStamp,
69
- parseIndexName,
70
- slugify,
71
- snapshotOf,
85
+ IndexDescriptionLike,
72
86
  } from './generate';
87
+ export { generateMigration, migrationStamp, slugify, snapshotOf } from './generate';
73
88
  export type {
74
89
  ColumnDescription,
75
90
  ForeignKeyDescription,
@@ -91,8 +106,11 @@ export {
91
106
  auditLedger,
92
107
  checksumOf,
93
108
  ensureLedger,
109
+ isLedgerMissing,
94
110
  LEDGER_TABLE,
95
111
  MIGRATION_LOCK_KEY,
112
+ MIGRATION_LOCK_POLL_MS,
113
+ MIGRATION_LOCK_WAIT_MS,
96
114
  migrate,
97
115
  migrationChecksum,
98
116
  pendingMigrations,
@@ -100,6 +118,8 @@ export {
100
118
  rollback,
101
119
  runningAppVersion,
102
120
  } from './migrate';
121
+ export type { StatementAttribution, StatementEvent, StatementObserver } from './observe';
122
+ export { setStatementObserver, statementObserver } from './observe';
103
123
  export type {
104
124
  PgliteClient,
105
125
  PgliteDriver,
@@ -117,13 +137,19 @@ export {
117
137
  } from './pglite';
118
138
  export type { PgliteBranchInfo, PgliteBranchOptions } from './pglite-branch';
119
139
  export { branchPglite, pgliteBranchDir } from './pglite-branch';
120
- export type { MutationVerdict, ReadOnlyOptions } from './readonly';
121
- export { assertReadOnly, inspectStatement, readOnly, stripSqlNoise } from './readonly';
122
140
  export type { ReadOnlyQueryOptions, ReadOnlyQueryResult } from './readonly-query';
123
141
  export { READONLY_TIMEOUT_MS, readOnlyQuery } from './readonly-query';
124
142
  export type { ReadOnlyRoleOptions } from './readonly-role';
125
143
  export { ensureReadOnlyRole, grantReadOnlySql, READONLY_ROLE } from './readonly-role';
144
+ export { snapshotJson } from './snapshot-json';
145
+ export { parseSnapshot } from './snapshot-parse';
126
146
  export type { SqlFragment } from './sql';
127
147
  export { identifier, isSqlFragment, join, literal, raw, sql } from './sql';
148
+ export { stripSqlNoise } from './sql-noise';
149
+ export type { DbSqlStateCode } from './sqlstate';
150
+ export { DB_SQLSTATE_CODES, isRetryableState, SQLSTATE, sqlState, sqlStateCode } from './sqlstate';
151
+ export { statementFingerprint, statementKind, statementVerb } from './statement-shape';
152
+ export { STATEMENT_ATTRIBUTE } from './statement-span';
153
+ export { statementsOf } from './statement-split';
128
154
  export type { DbTx, IsolationLevel, TransactionOptions } from './transaction';
129
155
  export { beginStatement, currentTx, withTransaction } from './transaction';
package/src/introspect.ts CHANGED
@@ -17,9 +17,18 @@ export interface ColumnDescription {
17
17
 
18
18
  export interface IndexDescription {
19
19
  readonly name: string;
20
+ /** Physical columns in **index key order** — the order the planner sorts by, never `attnum`. */
20
21
  readonly columns: readonly string[];
21
22
  readonly unique: boolean;
22
23
  readonly primary: boolean;
24
+ /**
25
+ * Partial index predicate as SQL, `null` when the index covers every row. The catalog returns
26
+ * its own rewriting of the expression (`(deleted_at IS NULL)`), never the author's spelling, so
27
+ * this is readable and comparable to *itself* — never to a snapshot's text. See `drift.ts`.
28
+ */
29
+ readonly where: string | null;
30
+ /** `desc` only when every key column is descending; `null` is Postgres' own default. */
31
+ readonly order: 'asc' | 'desc' | null;
23
32
  }
24
33
 
25
34
  export interface ForeignKeyDescription {
@@ -65,6 +74,8 @@ interface IndexRow {
65
74
  readonly is_unique: boolean;
66
75
  readonly is_primary: boolean;
67
76
  readonly columns: readonly string[];
77
+ readonly predicate: string | null;
78
+ readonly descending: boolean;
68
79
  }
69
80
 
70
81
  interface ForeignKeyRow {
@@ -90,37 +101,50 @@ export async function introspect(options: IntrospectOptions = {}): Promise<Schem
90
101
  order by table_name, ordinal_position
91
102
  `);
92
103
 
104
+ // Ordered by the index's own key position, never by `attnum`: `indkey` IS the order the planner
105
+ // sorts by, and a composite index on `(created_at, org_id)` whose columns were declared the
106
+ // other way round came back reversed — a description that reads correct and compares wrong.
107
+ // `indnkeyatts` drops INCLUDE payload columns, which are stored, not keyed.
93
108
  const indexes = await client.query<IndexRow>(sql`
94
109
  select
95
110
  t.relname as table_name,
96
111
  i.relname as index_name,
97
112
  ix.indisunique as is_unique,
98
113
  ix.indisprimary as is_primary,
99
- array_agg(a.attname order by a.attnum) as columns
114
+ pg_get_expr(ix.indpred, ix.indrelid) as predicate,
115
+ array_agg(a.attname order by k.ord) as columns,
116
+ bool_and((ix.indoption[k.ord - 1] & 1) = 1) as descending
100
117
  from pg_class t
101
118
  join pg_namespace n on n.oid = t.relnamespace
102
119
  join pg_index ix on ix.indrelid = t.oid
103
120
  join pg_class i on i.oid = ix.indexrelid
104
- join pg_attribute a on a.attrelid = t.oid and a.attnum = any(ix.indkey)
105
- where n.nspname = ${schema} and t.relkind = 'r'
106
- group by t.relname, i.relname, ix.indisunique, ix.indisprimary
121
+ cross join lateral unnest(ix.indkey::smallint[]) with ordinality as k(attnum, ord)
122
+ join pg_attribute a on a.attrelid = t.oid and a.attnum = k.attnum
123
+ where n.nspname = ${schema} and t.relkind = 'r' and k.ord <= ix.indnkeyatts
124
+ group by t.relname, i.relname, ix.indisunique, ix.indisprimary, ix.indpred, ix.indrelid
107
125
  order by t.relname, i.relname
108
126
  `);
109
127
 
128
+ // `conkey` and `confkey` are unnested TOGETHER, by shared ordinality: they are two halves of one
129
+ // ordered pairing, and matching each independently with `= any(...)` is a cross product — a
130
+ // two-column key came back as four source columns against four referenced ones, duplicated and
131
+ // misaligned. Ordered by `k.ord` (the constraint's own key position), never by `attnum`, for the
132
+ // same reason `indkey` orders the index query: `references t (y, x)` is not `references t (x, y)`.
110
133
  const foreignKeys = await client.query<ForeignKeyRow>(sql`
111
134
  select
112
135
  src.relname as table_name,
113
136
  c.conname as constraint_name,
114
- array_agg(sa.attname order by sa.attnum) as columns,
137
+ array_agg(sa.attname order by k.ord) as columns,
115
138
  tgt.relname as referenced_table,
116
- array_agg(ta.attname order by ta.attnum) as referenced_columns,
139
+ array_agg(ta.attname order by k.ord) as referenced_columns,
117
140
  c.confdeltype as on_delete
118
141
  from pg_constraint c
119
142
  join pg_class src on src.oid = c.conrelid
120
143
  join pg_class tgt on tgt.oid = c.confrelid
121
144
  join pg_namespace n on n.oid = src.relnamespace
122
- join pg_attribute sa on sa.attrelid = src.oid and sa.attnum = any(c.conkey)
123
- join pg_attribute ta on ta.attrelid = tgt.oid and ta.attnum = any(c.confkey)
145
+ cross join lateral unnest(c.conkey, c.confkey) with ordinality as k(src_attnum, tgt_attnum, ord)
146
+ join pg_attribute sa on sa.attrelid = src.oid and sa.attnum = k.src_attnum
147
+ join pg_attribute ta on ta.attrelid = tgt.oid and ta.attnum = k.tgt_attnum
124
148
  where c.contype = 'f' and n.nspname = ${schema}
125
149
  group by src.relname, c.conname, tgt.relname, c.confdeltype
126
150
  order by src.relname, c.conname
@@ -149,6 +173,8 @@ export function buildSchema(
149
173
  columns: [...row.columns],
150
174
  unique: row.is_unique,
151
175
  primary: row.is_primary,
176
+ where: row.predicate,
177
+ order: row.descending ? ('desc' as const) : null,
152
178
  }))
153
179
  .sort(byName);
154
180
  return {
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 { baseClient, type DbClient } from './client';
7
- import { migrationConflict } from './errors';
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 } from './errors';
15
+ import { expectedQueryLoop } from './expected-loop';
8
16
  import type { SchemaDescription } from './introspect';
9
17
  import { raw, sql } from './sql';
10
- import { withTransaction } from './transaction';
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 ?? process.env['APP_VERSION'] ?? 'dev';
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
@@ -108,7 +155,12 @@ export function auditLedger(
108
155
  throw migrationConflict(
109
156
  `the ledger records migration "${first.id}" applied by app version "${first.app_version}" ` +
110
157
  `but this build is "${appVersion}" and does not ship it`,
111
- `x db status --json # then deploy app version "${first.app_version}", or roll the ledger`,
158
+ // `x db status` has never existed the subcommands are gen, migrate, reset, studio, branch
159
+ // and backfill — and this is one of the two errors most likely to fire during a real deploy.
160
+ // A `fix:` is copied and run verbatim, so it names the ledger read that works anywhere psql
161
+ // does, and the one edit that resolves the disagreement.
162
+ `deploy app version "${first.app_version}" — or, if that build is gone, drop its row: ` +
163
+ `psql "$DATABASE_URL" -c "delete from ${LEDGER_TABLE} where id = '${first.id}'"`,
112
164
  );
113
165
  }
114
166
 
@@ -134,95 +186,252 @@ export function pendingMigrations(
134
186
  .filter((migration) => !applied.has(migration.id));
135
187
  }
136
188
 
189
+ /**
190
+ * Hold the migration lock on **one** session for the whole of `fn`, which runs on that session.
191
+ *
192
+ * `pg_advisory_lock` is scoped to a Postgres session, not to a statement, so taking it on a pooled
193
+ * handle locks whichever connection the pool lent for that one statement and then hands the
194
+ * session back. Two things follow, and both were live: the unlock lands on a *different*
195
+ * connection, answers `false` and leaves the lock held until that backend dies — so the next
196
+ * migrator waits forever rather than for the migration; and the locking session, now idle for the
197
+ * whole run, is closed by the pool's idle timeout (`migrate`'s is 10s), which releases the lock
198
+ * mid-migration and lets a second deploy in. `ROLE=migrate` hid the first half by accident — its
199
+ * pool is `max: 1`, so every statement found the same connection. No other role and no test has
200
+ * that.
201
+ *
202
+ * `fn` receives the pinned session and must run every statement on it, for the same `max: 1`
203
+ * reason read the other way: a statement sent to the pool while the pin is held waits for a
204
+ * connection that cannot come back until the migration blocking on it has finished.
205
+ */
206
+
207
+ /**
208
+ * Take the lock, or refuse — never block forever.
209
+ *
210
+ * `pg_advisory_lock` has no timeout, and that is a deploy outage waiting for its trigger: a
211
+ * predecessor OOM-killed on a network partition keeps its backend, and with it the lock, for hours.
212
+ * The new `ROLE=migrate` pod then sits inside one statement printing nothing, `helm upgrade --wait`
213
+ * blocks on a pod that is `Running` and healthy, and because the job never *fails*, `backoffLimit`
214
+ * never fires. `pg_try_advisory_lock` answers immediately, so the wait becomes ours to bound and
215
+ * the wedge becomes an exit code carrying the lock key and the pid to terminate.
216
+ *
217
+ * Declared as an expected loop: a poll is a loop the framework argued for, and a diagnostic that
218
+ * reported it would teach an author to ignore the ones nobody argued for.
219
+ */
220
+ async function acquireLock(session: DbClient, waitMs: number): Promise<void> {
221
+ const started = performance.now();
222
+ await expectedQueryLoop(
223
+ 'the migration lock is polled, not waited on, so a wedged migrator fails a deploy instead of hanging it',
224
+ async () => {
225
+ for (;;) {
226
+ const row = await session.one<{ locked: boolean }>(
227
+ sql`select pg_try_advisory_lock(${MIGRATION_LOCK_KEY}) as locked`,
228
+ );
229
+ if (row?.locked === true) return;
230
+ const remaining = waitMs - (performance.now() - started);
231
+ if (remaining <= 0) {
232
+ throw migrateConcurrent(MIGRATION_LOCK_KEY, Math.round(performance.now() - started));
233
+ }
234
+ await Bun.sleep(Math.min(MIGRATION_LOCK_POLL_MS, remaining));
235
+ }
236
+ },
237
+ );
238
+ }
239
+
137
240
  async function withAdvisoryLock<T>(
138
241
  client: DbClient,
139
242
  enabled: boolean,
140
- fn: () => Promise<T>,
243
+ waitMs: number,
244
+ fn: (session: DbClient) => Promise<T>,
141
245
  ): Promise<T> {
142
- if (!enabled) return fn();
143
- await client.execute(sql`select pg_advisory_lock(${MIGRATION_LOCK_KEY})`);
246
+ if (!enabled) return fn(client);
247
+ // Held by a `using` declaration, like every other pin in this package: the lock is taken after
248
+ // the guard exists, so a rejecting `pg_advisory_lock` gives the connection back too.
249
+ using pinned: DbConnection | undefined = isReservable(client)
250
+ ? await client.reserve()
251
+ : undefined;
252
+ const session: DbClient = pinned ?? client;
253
+ await acquireLock(session, waitMs);
144
254
  try {
145
- return await fn();
255
+ return await fn(session);
146
256
  } finally {
147
- await client
257
+ // Best-effort, and only here: an unlock that rejects would mask the failure that ended the
258
+ // migration, and it can only reject on a session that is already broken — whose locks Postgres
259
+ // drops when it ends. It runs before the pin is disposed, so the unlock reaches the session
260
+ // that took the lock.
261
+ await session
148
262
  .execute(sql`select pg_advisory_unlock(${MIGRATION_LOCK_KEY})`)
149
263
  .catch(() => undefined);
150
264
  }
151
265
  }
152
266
 
267
+ /**
268
+ * A migration's `up` and `down` are **scripts**, and one send is one statement — because the two
269
+ * drivers disagree about anything else. PGlite's `query()` is the extended protocol always and
270
+ * answers `cannot insert multiple commands into a prepared statement`; `Bun.SQL.unsafe` degrades
271
+ * to the simple protocol when no value is bound and applies the same script, which is a fact about
272
+ * bun 1.3.14 and not a contract. `createTable` emits the table *and* every index it carries, and
273
+ * `x dev` runs on the embedded driver, so the refusing side was the common path.
274
+ *
275
+ * No `expectedQueryLoop` of its own: both call sites already run inside the one declared for the
276
+ * migration loop, and nesting here would replace that reason with a narrower one for no gain. An
277
+ * empty script sends nothing at all, which is how a no-op migration reaches its ledger row.
278
+ */
279
+ async function applyScript(tx: DbTx, script: string): Promise<void> {
280
+ for (const statement of statementsOf(script)) await tx.execute(raw(statement));
281
+ }
282
+
283
+ /**
284
+ * Bound how long this migration will queue behind a lock it cannot take.
285
+ *
286
+ * `alter table … add column` needs `ACCESS EXCLUSIVE`. A long `SELECT` holding `ACCESS SHARE`
287
+ * makes it wait — and because Postgres' lock queue is FIFO, **every subsequent query on that table
288
+ * queues behind the ALTER**. The `migrate` profile runs `statement_timeout = 0` deliberately, so
289
+ * without this the migrator waits forever and the app is down on one table for as long as the
290
+ * reporting query runs. `lock_timeout` bounds the *wait* alone and never the work, which is why it
291
+ * is the right knob where `statement_timeout` is not.
292
+ *
293
+ * `SET LOCAL`, inside the migration's own transaction: it reverts at COMMIT, so a value chosen for
294
+ * DDL never leaks onto the session the ledger insert or the next migration runs on. A 0 disables
295
+ * it, exactly like `statementTimeoutMs`. The failure it produces is `55P03`, typed as
296
+ * `X_DB_LOCK_TIMEOUT` by `driverError` with the `pg_stat_activity` read as its fix.
297
+ */
298
+ async function setLockTimeout(tx: DbTx, lockTimeoutMs: number): Promise<void> {
299
+ if (lockTimeoutMs <= 0) return;
300
+ // `SET LOCAL` takes no parameter placeholder, and the value is a validated integer of ours.
301
+ await tx.execute(raw(`SET LOCAL lock_timeout = ${Math.round(lockTimeoutMs)}`));
302
+ }
303
+
304
+ /**
305
+ * `migrate` role's profile whatever role is running, because the statement is a migration whatever
306
+ * process issues it: `x db migrate` from a laptop, `x dev`'s boot and `ROLE=migrate` all take the
307
+ * same `ACCESS EXCLUSIVE` locks against the same tables.
308
+ */
309
+ function migrationLockTimeoutMs(explicit: number | undefined): number {
310
+ return explicit ?? poolProfileFor('migrate').lockTimeoutMs;
311
+ }
312
+
153
313
  export async function migrate(options: MigrateOptions): Promise<MigrationReport> {
154
314
  const client = options.client ?? baseClient();
155
315
  const appVersion = runningAppVersion(options.appVersion);
316
+ const lockTimeoutMs = migrationLockTimeoutMs(options.lockTimeoutMs);
156
317
  const started = performance.now();
157
318
 
158
- return withAdvisoryLock(client, options.lock !== false, async () => {
159
- await ensureLedger(client);
160
- const ledger = await readLedger(client);
161
- auditLedger(ledger, options.migrations, appVersion);
162
-
163
- const pending = pendingMigrations(ledger, options.migrations);
164
- const applied: AppliedMigration[] = [];
165
- for (const migration of pending) {
166
- const at = performance.now();
167
- await withTransaction(
168
- async (tx) => {
169
- await tx.execute(raw(migration.up));
170
- const durationMs = Math.round(performance.now() - at);
171
- await tx.execute(sql`
172
- insert into ${raw(LEDGER_TABLE)} (id, name, checksum, app_version, duration_ms)
173
- values (${migration.id}, ${migration.name}, ${migrationChecksum(migration)},
174
- ${appVersion}, ${durationMs})
175
- `);
319
+ return withAdvisoryLock(
320
+ client,
321
+ options.lock !== false,
322
+ options.lockWaitMs ?? MIGRATION_LOCK_WAIT_MS,
323
+ async (session) => {
324
+ await ensureLedger(session);
325
+ const ledger = await readLedger(session);
326
+ auditLedger(ledger, options.migrations, appVersion);
327
+
328
+ const pending = pendingMigrations(ledger, options.migrations);
329
+ // A statement per migration and a transaction per migration is the point, not an N+1 to batch:
330
+ // one failed `up` must leave the ledger describing exactly the migrations that did run, and a
331
+ // batch commits or loses all of them together. Declared here so a diagnostic reports the loops
332
+ // nobody argued for and stays quiet about this one.
333
+ const applied = await expectedQueryLoop(
334
+ 'each migration applies in its own transaction, so a failure leaves an exact ledger',
335
+ async () => {
336
+ const done: AppliedMigration[] = [];
337
+ for (const migration of pending) {
338
+ const at = performance.now();
339
+ await withTransaction(
340
+ async (tx) => {
341
+ await setLockTimeout(tx, lockTimeoutMs);
342
+ await applyScript(tx, migration.up);
343
+ const durationMs = Math.round(performance.now() - at);
344
+ await tx.execute(sql`
345
+ insert into ${raw(LEDGER_TABLE)} (id, name, checksum, app_version, duration_ms)
346
+ values (${migration.id}, ${migration.name}, ${migrationChecksum(migration)},
347
+ ${appVersion}, ${durationMs})
348
+ `);
349
+ },
350
+ // The lock's own session: a migration applied on another connection is not covered by
351
+ // the lock at all, and on `ROLE=migrate` there is no other connection to apply it on.
352
+ { client: session },
353
+ );
354
+ done.push({
355
+ id: migration.id,
356
+ name: migration.name,
357
+ durationMs: Math.round(performance.now() - at),
358
+ });
359
+ }
360
+ return done;
176
361
  },
177
- { client },
178
362
  );
179
- applied.push({
180
- id: migration.id,
181
- name: migration.name,
182
- durationMs: Math.round(performance.now() - at),
183
- });
184
- }
185
-
186
- return {
187
- applied,
188
- skipped: ledger.map((row) => row.id),
189
- durationMs: Math.round(performance.now() - started),
190
- appVersion,
191
- };
192
- });
363
+
364
+ return {
365
+ applied,
366
+ skipped: ledger.map((row) => row.id),
367
+ durationMs: Math.round(performance.now() - started),
368
+ appVersion,
369
+ };
370
+ },
371
+ );
193
372
  }
194
373
 
195
374
  export interface RollbackOptions {
196
375
  readonly migrations: readonly Migration[];
197
376
  readonly client?: DbClient | undefined;
198
377
  readonly steps?: number | undefined;
378
+ /** Skip the advisory lock. Only `x db branch` does this, against a private database. */
379
+ readonly lock?: boolean | undefined;
380
+ /** How long to wait for the lock before `X_MIGRATE_CONCURRENT`. Defaults to 60s. */
381
+ readonly lockWaitMs?: number | undefined;
382
+ /** `SET LOCAL lock_timeout` per reversal. Defaults to the `migrate` role's profile. */
383
+ readonly lockTimeoutMs?: number | undefined;
199
384
  }
200
385
 
201
386
  /** Reverse the newest `steps` applied migrations. `x db rollback`. */
202
387
  export async function rollback(options: RollbackOptions): Promise<readonly string[]> {
203
388
  const client = options.client ?? baseClient();
204
389
  const steps = options.steps ?? 1;
205
- const ledger = await readLedger(client);
390
+ const lockTimeoutMs = migrationLockTimeoutMs(options.lockTimeoutMs);
206
391
  const known = new Map(options.migrations.map((migration) => [migration.id, migration]));
207
- const targets = [...ledger].reverse().slice(0, steps);
208
- const reverted: string[] = [];
209
392
 
210
- for (const row of targets) {
211
- const migration = known.get(row.id);
212
- if (migration === undefined) {
213
- throw migrationConflict(
214
- `migration "${row.id}" is in the ledger but not in this build, so its down SQL is unknown`,
215
- `x db status --json # deploy the build that shipped "${row.id}" and roll back there`,
393
+ // The same lock `migrate` takes, because the race is the same one: a rollback reversing the id a
394
+ // migrator is applying leaves a ledger that describes neither, and the ledger read below decides
395
+ // what to reverse — outside the lock it can be stale before the first `down` runs.
396
+ return withAdvisoryLock(
397
+ client,
398
+ options.lock !== false,
399
+ options.lockWaitMs ?? MIGRATION_LOCK_WAIT_MS,
400
+ async (session) => {
401
+ const ledger = await readLedger(session);
402
+ const targets = [...ledger].reverse().slice(0, steps);
403
+
404
+ // The same deliberate loop as `migrate`, read backwards: one transaction per `down`, newest
405
+ // first, so a `down` that fails leaves every migration before it still applied and recorded.
406
+ return expectedQueryLoop(
407
+ 'each migration reverses in its own transaction, newest first, so a failure stops exactly there',
408
+ async () => {
409
+ const reverted: string[] = [];
410
+ for (const row of targets) {
411
+ const migration = known.get(row.id);
412
+ if (migration === undefined) {
413
+ throw migrationConflict(
414
+ `migration "${row.id}" is in the ledger but not in this build, so its down SQL is unknown`,
415
+ // Same reason as `auditLedger`'s: `x db status` does not exist. The `down` SQL only
416
+ // exists in the build that shipped it, so the fix is the read that names that build.
417
+ `psql "$DATABASE_URL" -c "select id, app_version from ${LEDGER_TABLE} ` +
418
+ `order by id desc limit 5" # deploy the build that shipped "${row.id}", ` +
419
+ 'and roll back there — its down SQL exists nowhere else',
420
+ );
421
+ }
422
+ await withTransaction(
423
+ async (tx) => {
424
+ await setLockTimeout(tx, lockTimeoutMs);
425
+ await applyScript(tx, migration.down);
426
+ await tx.execute(sql`delete from ${raw(LEDGER_TABLE)} where id = ${row.id}`);
427
+ },
428
+ { client: session },
429
+ );
430
+ reverted.push(row.id);
431
+ }
432
+ return reverted;
433
+ },
216
434
  );
217
- }
218
- await withTransaction(
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;
435
+ },
436
+ );
228
437
  }