@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/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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
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
|
|
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
|
|
123
|
-
join pg_attribute
|
|
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 {
|
|
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 } 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
|
|
@@ -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
|
|
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
|
-
|
|
243
|
+
waitMs: number,
|
|
244
|
+
fn: (session: DbClient) => Promise<T>,
|
|
141
245
|
): Promise<T> {
|
|
142
|
-
if (!enabled) return fn();
|
|
143
|
-
|
|
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
|
-
|
|
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(
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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
|
-
});
|
|
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
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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
|
}
|