@ultimat3/db 29.3.2 → 29.4.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 +16 -18
- package/README.md +36 -1
- package/package.json +2 -2
- package/src/concurrent-index.ts +137 -0
- package/src/concurrent-plan.ts +176 -0
- package/src/destructive.ts +4 -32
- package/src/entity-shape.ts +8 -0
- package/src/errors.ts +3 -0
- package/src/generate-steps.ts +94 -0
- package/src/generate.ts +25 -12
- package/src/index-ddl.ts +23 -35
- package/src/index-plan.ts +35 -14
- package/src/index.ts +7 -0
- package/src/marker-line.ts +39 -0
- package/src/migrate.ts +22 -65
- package/src/migration-errors.ts +21 -0
- package/src/migration-step.ts +164 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Single responsibility: apply ONE migration script and its ledger write — inside a transaction,
|
|
2
|
+
// or, for a migration marked `-- transaction: false`, statement by statement outside one. Split
|
|
3
|
+
// from `migrate.ts`, which keeps the lock, the ledger audit and the loops over pending migrations;
|
|
4
|
+
// `migrate()` and `rollback()` both come through here, so both directions take the same path.
|
|
5
|
+
|
|
6
|
+
import { logger } from '@ultimat3/core';
|
|
7
|
+
import type { DbClient } from './client';
|
|
8
|
+
import {
|
|
9
|
+
concurrentIndexTarget,
|
|
10
|
+
firstConcurrentStatement,
|
|
11
|
+
isConcurrentIndexStatement,
|
|
12
|
+
isNonTransactional,
|
|
13
|
+
} from './concurrent-index';
|
|
14
|
+
import { refuseDependentViews } from './dependent-view';
|
|
15
|
+
import { migrationConcurrentInTransaction } from './migration-errors';
|
|
16
|
+
import { raw, sql } from './sql';
|
|
17
|
+
import { statementsOf } from './statement-split';
|
|
18
|
+
import { withTransaction } from './transaction';
|
|
19
|
+
|
|
20
|
+
export interface MigrationStep {
|
|
21
|
+
readonly id: string;
|
|
22
|
+
/** The migration's `up` — where the marker lives, whichever direction runs. */
|
|
23
|
+
readonly up: string;
|
|
24
|
+
/** What runs: `up` forwards, `down` on a rollback. */
|
|
25
|
+
readonly script: string;
|
|
26
|
+
/** `SET LOCAL lock_timeout` — every transaction, and no concurrent statement. `0` sets none. */
|
|
27
|
+
readonly lockTimeoutMs: number;
|
|
28
|
+
/** The ledger write, on the client the statements ran on — after the last of them succeeded. */
|
|
29
|
+
readonly record: (client: DbClient) => Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A migration's `up` and `down` are **scripts**, and one send is one statement — because the two
|
|
34
|
+
* drivers disagree about anything else. PGlite's `query()` is the extended protocol always and
|
|
35
|
+
* answers `cannot insert multiple commands into a prepared statement`; `Bun.SQL.unsafe` degrades
|
|
36
|
+
* to the simple protocol when no value is bound and applies the same script, which is a fact about
|
|
37
|
+
* bun 1.3.14 and not a contract. An empty script sends nothing at all, which is how a no-op
|
|
38
|
+
* migration reaches its ledger row.
|
|
39
|
+
*/
|
|
40
|
+
async function applyScript(tx: DbClient, script: string): Promise<void> {
|
|
41
|
+
for (const statement of statementsOf(script)) await tx.execute(raw(statement));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Bound how long this migration will queue behind a lock it cannot take.
|
|
46
|
+
*
|
|
47
|
+
* `alter table … add column` needs `ACCESS EXCLUSIVE`. A long `SELECT` holding `ACCESS SHARE`
|
|
48
|
+
* makes it wait — and because Postgres' lock queue is FIFO, **every subsequent query on that table
|
|
49
|
+
* queues behind the ALTER**. The `migrate` profile runs `statement_timeout = 0` deliberately, so
|
|
50
|
+
* without this the migrator waits forever and the app is down on one table for as long as the
|
|
51
|
+
* reporting query runs. `lock_timeout` bounds the *wait* alone and never the work.
|
|
52
|
+
*
|
|
53
|
+
* `SET LOCAL`, inside the migration's own transaction: it reverts at COMMIT, so a value chosen for
|
|
54
|
+
* DDL never leaks onto the session the next migration runs on. The failure it produces is `55P03`,
|
|
55
|
+
* typed as `X_DB_LOCK_TIMEOUT` by `driverError` with the `pg_stat_activity` read as its fix.
|
|
56
|
+
*/
|
|
57
|
+
async function setLockTimeout(tx: DbClient, lockTimeoutMs: number): Promise<void> {
|
|
58
|
+
if (lockTimeoutMs <= 0) return;
|
|
59
|
+
// `SET LOCAL` takes no parameter placeholder, and the value is a validated integer of ours.
|
|
60
|
+
await tx.execute(raw(`SET LOCAL lock_timeout = ${Math.round(lockTimeoutMs)}`));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* An interrupted `create index concurrently` leaves the index behind marked INVALID: never used by
|
|
65
|
+
* the planner, still maintained on every write, and — the part that bites — still holding the
|
|
66
|
+
* name, so `if not exists` would skip the rebuild and record the step as done. So before each
|
|
67
|
+
* named concurrent build: an INVALID index under that name is dropped (concurrently) first.
|
|
68
|
+
*
|
|
69
|
+
* `to_regclass` resolves the name exactly as the statement will — quoting and `search_path`
|
|
70
|
+
* included — and answers `null` for a missing one. The name is the statement's own text, which
|
|
71
|
+
* this runner is about to send anyway, so the drop adds no surface the build did not have.
|
|
72
|
+
*/
|
|
73
|
+
async function dropInvalidLeftover(session: DbClient, statement: string): Promise<void> {
|
|
74
|
+
const name = concurrentIndexTarget(statement);
|
|
75
|
+
if (name === null) return;
|
|
76
|
+
const row = await session.one<{ valid: boolean }>(
|
|
77
|
+
sql`select i.indisvalid as valid from pg_index i where i.indexrelid = to_regclass(${name})`,
|
|
78
|
+
);
|
|
79
|
+
if (row === null || row.valid) return;
|
|
80
|
+
logger.warn('ultimate migrate dropping an invalid index left by an interrupted build', {
|
|
81
|
+
index: name,
|
|
82
|
+
cause: `index ${name} is INVALID — a concurrent build of it failed or was interrupted`,
|
|
83
|
+
fix: 'x db migrate --json # already running: the leftover is dropped and built again',
|
|
84
|
+
});
|
|
85
|
+
await session.execute(raw(`drop index concurrently if exists ${name}`));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Outside a transaction, one statement at a time. A concurrent index statement runs bare, with no
|
|
90
|
+
* `lock_timeout`: the concurrent forms take SHARE UPDATE EXCLUSIVE, which queues no read and no
|
|
91
|
+
* write, and the long waits they do make — for every transaction older than the build — are lock
|
|
92
|
+
* waits too, so the 3 s bound would turn a slow-but-harmless build into an INVALID index.
|
|
93
|
+
*
|
|
94
|
+
* Every other statement — the `alter table … drop constraint` a UNIQUE removal needs, a hand-written
|
|
95
|
+
* `drop table` — can queue the table, so it gets a transaction of its own under the same
|
|
96
|
+
* `SET LOCAL lock_timeout` a transactional migration runs with. Only what Postgres refuses inside a
|
|
97
|
+
* transaction (`vacuum`, `create database`, …: `AUTOCOMMIT_ONLY`) runs autocommitted.
|
|
98
|
+
*
|
|
99
|
+
* The step must be idempotent, since a failure leaves what already ran in place and no ledger
|
|
100
|
+
* row: `x db gen` writes only `if not exists` / `if exists` forms here.
|
|
101
|
+
*/
|
|
102
|
+
/**
|
|
103
|
+
* The statements Postgres refuses inside a transaction block (25001): they run autocommitted. Every
|
|
104
|
+
* other statement under the marker — an `alter table`, a `drop table`, any DDL that can queue the
|
|
105
|
+
* table behind a long reader — runs in a short transaction under the migration's `lock_timeout`.
|
|
106
|
+
*/
|
|
107
|
+
const AUTOCOMMIT_ONLY =
|
|
108
|
+
/^\s*(?:vacuum|create\s+database|drop\s+database|alter\s+system|create\s+tablespace|drop\s+tablespace|reindex\s+(?:\(.*\)\s*)?(?:system|database)\b|create\s+subscription|drop\s+subscription|alter\s+subscription)\b/i;
|
|
109
|
+
/** `cluster` with no table re-clusters every table: refused in a transaction. One table's is not. */
|
|
110
|
+
const BARE_CLUSTER = /^\s*cluster(?:\s+verbose)?\s*;?\s*$/i;
|
|
111
|
+
|
|
112
|
+
/** A statement's text past its leading `--` lines (the step's own marker rides the first one). */
|
|
113
|
+
const withoutLeadingComments = (statement: string): string =>
|
|
114
|
+
statement.replace(/^(?:\s*--[^\n]*(?:\n|$))+/, '');
|
|
115
|
+
|
|
116
|
+
async function applyOutsideTransaction(session: DbClient, step: MigrationStep): Promise<void> {
|
|
117
|
+
await refuseDependentViews(session, step.script);
|
|
118
|
+
for (const statement of statementsOf(step.script)) {
|
|
119
|
+
if (isConcurrentIndexStatement(statement)) {
|
|
120
|
+
await dropInvalidLeftover(session, statement);
|
|
121
|
+
await session.execute(raw(statement));
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
// Postgres refuses these inside a transaction (25001): autocommitted, as the marker promises.
|
|
125
|
+
const bare = withoutLeadingComments(statement);
|
|
126
|
+
if (AUTOCOMMIT_ONLY.test(bare) || BARE_CLUSTER.test(bare)) {
|
|
127
|
+
await session.execute(raw(statement));
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
await withTransaction(
|
|
131
|
+
async (tx) => {
|
|
132
|
+
await setLockTimeout(tx, step.lockTimeoutMs);
|
|
133
|
+
await tx.execute(raw(statement));
|
|
134
|
+
},
|
|
135
|
+
{ client: session },
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
await step.record(session);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Apply one migration script and record it, on the migrator's own pinned session. */
|
|
142
|
+
export async function applyMigrationStep(session: DbClient, step: MigrationStep): Promise<void> {
|
|
143
|
+
if (isNonTransactional(step.up)) return applyOutsideTransaction(session, step);
|
|
144
|
+
// Before `BEGIN`, never after the failure: inside the transaction Postgres' 25001 names neither
|
|
145
|
+
// the migration nor the marker that fixes it.
|
|
146
|
+
const refused = firstConcurrentStatement(step.script);
|
|
147
|
+
if (refused !== undefined) {
|
|
148
|
+
throw migrationConcurrentInTransaction(step.id, refused);
|
|
149
|
+
}
|
|
150
|
+
await withTransaction(
|
|
151
|
+
async (tx) => {
|
|
152
|
+
await setLockTimeout(tx, step.lockTimeoutMs);
|
|
153
|
+
// Before the first statement, never after the failure: a view compiled against a column
|
|
154
|
+
// this script retypes is `0A000` from the server with the view named only in a DETAIL field
|
|
155
|
+
// nothing prints (`dependent-view.ts`).
|
|
156
|
+
await refuseDependentViews(tx, step.script);
|
|
157
|
+
await applyScript(tx, step.script);
|
|
158
|
+
await step.record(tx);
|
|
159
|
+
},
|
|
160
|
+
// The lock's own session: a migration applied on another connection is not covered by the
|
|
161
|
+
// lock at all, and on `ROLE=migrate` there is no other connection to apply it on.
|
|
162
|
+
{ client: session },
|
|
163
|
+
);
|
|
164
|
+
}
|