@ultimat3/db 29.3.2 → 30.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 +78 -116
- package/README.md +199 -16
- package/package.json +13 -2
- package/src/app-relation.ts +7 -2
- package/src/catalog-fold.ts +5 -0
- package/src/catalog-objects.ts +20 -24
- package/src/catalog-policies.ts +81 -0
- package/src/catalog-relations.ts +31 -10
- package/src/catalog.ts +40 -1
- package/src/check-ddl.ts +34 -7
- package/src/client.ts +132 -48
- package/src/column-alter.ts +42 -1
- package/src/concurrent-index.ts +157 -0
- package/src/concurrent-plan.ts +294 -0
- package/src/connection-url.ts +16 -4
- package/src/db-diagnostics-deadline.ts +43 -0
- package/src/db-diagnostics-redact.ts +58 -0
- package/src/db-diagnostics-rows.ts +166 -0
- package/src/db-diagnostics-sql.ts +92 -0
- package/src/db-diagnostics.ts +178 -0
- package/src/destructive.ts +21 -35
- package/src/diagnostics-entry.ts +15 -0
- package/src/drift-errors.ts +9 -4
- package/src/drift-findings.ts +47 -62
- package/src/drift-missing.ts +203 -0
- package/src/drift-row-security.ts +88 -0
- package/src/drift-triggers.ts +54 -0
- package/src/drift.ts +88 -21
- package/src/entity-shape.ts +75 -0
- package/src/errors.ts +80 -9
- package/src/expected-loop.ts +34 -4
- package/src/failure-store-pg.ts +157 -0
- package/src/foreign-key-plan.ts +75 -23
- package/src/foreign-key.ts +76 -5
- package/src/generate-counter.ts +85 -0
- package/src/generate-history.ts +142 -0
- package/src/generate-row-security.ts +51 -0
- package/src/generate-snapshot.ts +82 -0
- package/src/generate-steps.ts +99 -0
- package/src/generate-triggers.ts +119 -0
- package/src/generate-view.ts +97 -0
- package/src/generate.ts +128 -89
- package/src/index-ddl.ts +23 -35
- package/src/index-plan.ts +35 -14
- package/src/index.ts +136 -6
- package/src/introspect-catalog.ts +26 -6
- package/src/introspect-rows.ts +82 -0
- package/src/introspect.ts +108 -50
- package/src/keyset.ts +182 -0
- package/src/lifecycle-errors.ts +15 -0
- package/src/local-settings.ts +38 -0
- package/src/marker-line.ts +47 -0
- package/src/migrate-lock-retry.ts +60 -0
- package/src/migrate-rollback.ts +45 -6
- package/src/migrate.ts +67 -69
- package/src/migration-errors.ts +48 -1
- package/src/migration-ledger.ts +38 -4
- package/src/migration-step.ts +192 -0
- package/src/partition-order.ts +56 -0
- package/src/partitioned-tables.ts +24 -0
- package/src/pool-gate.ts +120 -0
- package/src/pool-listeners.ts +143 -0
- package/src/pool-pins.ts +68 -0
- package/src/pool-profile.ts +23 -7
- package/src/pool-reserve.ts +11 -4
- package/src/pool-retire.ts +22 -0
- package/src/pool-sweep.ts +115 -0
- package/src/pool-timer.ts +10 -0
- package/src/pooler-fix.ts +53 -0
- package/src/pooler.ts +161 -0
- package/src/read-transaction.ts +59 -0
- package/src/readonly-query.ts +4 -4
- package/src/replica-client.ts +54 -22
- package/src/replica-position.ts +89 -0
- package/src/replica-scope.ts +25 -4
- package/src/retired.ts +131 -0
- package/src/retype-dependents.ts +2 -0
- package/src/rolling-errors.ts +97 -0
- package/src/rolling.ts +212 -0
- package/src/row-secure-client.ts +54 -0
- package/src/row-security-catalog.ts +26 -0
- package/src/row-security-migrator.ts +48 -0
- package/src/row-security-plan.ts +58 -0
- package/src/row-security-role.ts +60 -0
- package/src/row-security-roles.ts +64 -0
- package/src/row-security.ts +59 -0
- package/src/same-tenant.ts +173 -0
- package/src/schema-dump-policies.ts +26 -0
- package/src/schema-dump-table.ts +21 -6
- package/src/schema-dump.ts +13 -2
- package/src/snapshot-parse.ts +42 -2
- package/src/sqlstate.ts +4 -0
- package/src/statement-bound.ts +82 -0
- package/src/transaction-errors.ts +14 -0
- package/src/transaction-guard.ts +40 -0
- package/src/transaction-settings.ts +24 -0
- package/src/transaction.ts +74 -19
- package/src/ungeneratable.ts +30 -0
package/src/keyset.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Single responsibility: keyset paging for a raw read — a join the typed handle cannot express.
|
|
2
|
+
// The handle's `.after()` keeps its own seek; this is the same position, carried as the column's
|
|
3
|
+
// TEXT and bound back cast by kind, so a microsecond the JS `Date` would drop is never dropped.
|
|
4
|
+
|
|
5
|
+
import type { Page } from '@ultimat3/core';
|
|
6
|
+
import {
|
|
7
|
+
assert,
|
|
8
|
+
CursorInvalidError,
|
|
9
|
+
decodeCursor,
|
|
10
|
+
encodeCursor,
|
|
11
|
+
finiteCount,
|
|
12
|
+
pageOf,
|
|
13
|
+
} from '@ultimat3/core';
|
|
14
|
+
import { DbError } from './errors';
|
|
15
|
+
import { join, raw, type SqlFragment, sql } from './sql';
|
|
16
|
+
|
|
17
|
+
/** The kinds a position can be bound back as, each with its own cast. A closed list. */
|
|
18
|
+
export type KeysetKind = 'timestamptz' | 'uuid' | 'text' | 'integer' | 'bigint';
|
|
19
|
+
|
|
20
|
+
export interface KeysetKey {
|
|
21
|
+
/** The ordered expression as written in the statement: `raw('p.created_at')`, or a `sql` one. */
|
|
22
|
+
readonly column: SqlFragment;
|
|
23
|
+
readonly kind: KeysetKind;
|
|
24
|
+
readonly direction: 'asc' | 'desc';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface KeysetOptions {
|
|
28
|
+
/** This read and its arguments: a cursor from another scope is `X_CURSOR_INVALID`. */
|
|
29
|
+
readonly scope: string;
|
|
30
|
+
/** The order, last key the tiebreak. Every key must be not null on every row. */
|
|
31
|
+
readonly keys: readonly KeysetKey[];
|
|
32
|
+
/** The previous page's `nextCursor`; `null` for the first page. */
|
|
33
|
+
readonly after: string | null;
|
|
34
|
+
readonly limit: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A row as `page` answers it: the `_keyset_<n>` aliases `select` added are gone. */
|
|
38
|
+
export type KeysetRow<Row> = Omit<Row, `_keyset_${number}`>;
|
|
39
|
+
|
|
40
|
+
/** Four fragments to splice — `select …, ${k.select}`, `where ${k.where}`, `order by`, `limit`. */
|
|
41
|
+
export interface Keyset {
|
|
42
|
+
readonly select: SqlFragment;
|
|
43
|
+
readonly where: SqlFragment;
|
|
44
|
+
readonly orderBy: SqlFragment;
|
|
45
|
+
/** `limit + 1`: the extra row is how `page` knows there is a next page. */
|
|
46
|
+
readonly limit: SqlFragment;
|
|
47
|
+
/** The rows the statement answered, cut to `limit`, the key aliases stripped. */
|
|
48
|
+
page<Row extends object>(rows: readonly Row[]): Page<KeysetRow<Row>>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const CASTS: ReadonlyMap<KeysetKind, string> = new Map<KeysetKind, string>([
|
|
52
|
+
['timestamptz', 'timestamptz'],
|
|
53
|
+
['uuid', 'uuid'],
|
|
54
|
+
['text', 'text'],
|
|
55
|
+
['integer', 'integer'],
|
|
56
|
+
['bigint', 'bigint'],
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
const NULL_KEY_FIX =
|
|
60
|
+
"keyset({ keys: [{ column: sql`coalesce(p.published_at, p.created_at)`, kind: 'timestamptz', direction: 'desc' }, …] }) # or order by a not-null column";
|
|
61
|
+
|
|
62
|
+
const aliasOf = (index: number): string => `_keyset_${String(index)}`;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The scope the cursor is signed under: the caller's, plus the order itself — so a cursor minted
|
|
66
|
+
* under `desc` cannot seek an `asc` read, nor a `uuid` key be bound back as `text`. Hashed: the
|
|
67
|
+
* key expressions are the statement's, not the client's to read.
|
|
68
|
+
*/
|
|
69
|
+
const scopeOf = (scope: string, keys: readonly KeysetKey[]): string => {
|
|
70
|
+
const order = keys
|
|
71
|
+
.map(
|
|
72
|
+
(key) =>
|
|
73
|
+
`${key.column.text}${JSON.stringify(key.column.values.map(String))} ${key.kind} ${key.direction}`,
|
|
74
|
+
)
|
|
75
|
+
.join(',');
|
|
76
|
+
const digest = new Bun.CryptoHasher('sha256').update(`${scope}|${order}`).digest('hex');
|
|
77
|
+
return `keyset:${digest.slice(0, 16)}`;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const bind = (key: KeysetKey, text: string): SqlFragment =>
|
|
81
|
+
sql`${text}::${raw(CASTS.get(key.kind) ?? 'text')}`;
|
|
82
|
+
|
|
83
|
+
const past = (key: KeysetKey, value: SqlFragment): SqlFragment =>
|
|
84
|
+
sql`${key.column} ${raw(key.direction === 'desc' ? '<' : '>')} ${value}`;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Uniform direction is ONE row comparison, the shape Postgres pushes into a multicolumn index as
|
|
88
|
+
* an Index Cond. A mixed order has none, so it is the or-chain — the same split as the entity
|
|
89
|
+
* driver's `seekSql`. No null branch in either: `page` refuses a null key before it is a cursor.
|
|
90
|
+
*/
|
|
91
|
+
const seek = (keys: readonly KeysetKey[], position: readonly string[]): SqlFragment => {
|
|
92
|
+
const binds = (): SqlFragment[] => keys.map((key, index) => bind(key, position[index] ?? ''));
|
|
93
|
+
const [first] = keys;
|
|
94
|
+
if (first === undefined) return sql`true`;
|
|
95
|
+
if (keys.length === 1) return past(first, bind(first, position[0] ?? ''));
|
|
96
|
+
if (keys.every((key) => key.direction === first.direction)) {
|
|
97
|
+
const op = raw(first.direction === 'desc' ? '<' : '>');
|
|
98
|
+
return sql`(${join(keys.map((key) => key.column))}) ${op} (${join(binds())})`;
|
|
99
|
+
}
|
|
100
|
+
const terms = keys.map((key, index) => {
|
|
101
|
+
const equal = keys
|
|
102
|
+
.slice(0, index)
|
|
103
|
+
.map((prior, at) => sql`${prior.column} = ${bind(prior, position[at] ?? '')}`);
|
|
104
|
+
const term = past(key, bind(key, position[index] ?? ''));
|
|
105
|
+
return equal.length === 0 ? term : sql`(${join([...equal, term], ' and ')})`;
|
|
106
|
+
});
|
|
107
|
+
return sql`(${join(terms, ' or ')})`;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const positionOf = (cursor: string, scope: string, keys: readonly KeysetKey[]): string[] => {
|
|
111
|
+
const { key } = decodeCursor(cursor, scope);
|
|
112
|
+
if (key.length !== keys.length || !key.every((part) => typeof part === 'string')) {
|
|
113
|
+
throw new CursorInvalidError(
|
|
114
|
+
`it carries ${key.length} key values, this order needs ${keys.length}`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return key as string[];
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/** Every key of `row`, as the text the server answered — refused when one is null. */
|
|
121
|
+
const keyText = (row: object, keys: readonly KeysetKey[], index: number): string[] =>
|
|
122
|
+
keys.map((key, at) => {
|
|
123
|
+
const value: unknown = Reflect.get(row, aliasOf(at));
|
|
124
|
+
if (typeof value === 'string') return value;
|
|
125
|
+
if (value === null) {
|
|
126
|
+
throw new DbError({
|
|
127
|
+
code: 'X_KEYSET_NULL_KEY',
|
|
128
|
+
cause: `row ${String(index)} holds null in keyset key ${String(at)} (${key.column.text}): a row comparison has no null order, so that row and every one like it would fall off the next page`,
|
|
129
|
+
fix: NULL_KEY_FIX,
|
|
130
|
+
meta: { key: at, row: index },
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
throw new DbError({
|
|
134
|
+
code: 'X_INVARIANT',
|
|
135
|
+
cause: `row ${String(index)} carries no ${aliasOf(at)}: the statement did not select keyset()'s keys`,
|
|
136
|
+
fix: "db().query(sql`select p.*, <k.select> from … where <k.where> order by <k.orderBy> limit <k.limit>`) # each <…> an interpolation of keyset()'s answer",
|
|
137
|
+
meta: { key: at, row: index },
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const stripped = <Row extends object>(row: Row, aliases: ReadonlySet<string>): KeysetRow<Row> =>
|
|
142
|
+
Object.fromEntries(Object.entries(row).filter(([name]) => !aliases.has(name))) as KeysetRow<Row>;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* A keyset page over any statement. The key is selected as `col::text` and bound back as
|
|
146
|
+
* `$n::<kind>`: exact, no arithmetic, and the `order by` stays on the bare column, so an index on
|
|
147
|
+
* `(created_at desc, id desc)` answers it as an Index Only Scan.
|
|
148
|
+
*/
|
|
149
|
+
export function keyset(options: KeysetOptions): Keyset {
|
|
150
|
+
const { keys } = options;
|
|
151
|
+
assert(
|
|
152
|
+
keys.length > 0,
|
|
153
|
+
'keyset() was given no keys, so it has no order to page by',
|
|
154
|
+
"keyset({ keys: [{ column: raw('p.created_at'), kind: 'timestamptz', direction: 'desc' }, { column: raw('p.id'), kind: 'uuid', direction: 'desc' }], … })",
|
|
155
|
+
);
|
|
156
|
+
assert(
|
|
157
|
+
options.scope.length > 0,
|
|
158
|
+
'keyset() was given an empty scope, so a cursor from any other raw read would page this one',
|
|
159
|
+
"keyset({ scope: 'posts.feed', … }) # one name per read, plus its filter arguments",
|
|
160
|
+
);
|
|
161
|
+
const limit = finiteCount('keyset', 'limit', options.limit, 1);
|
|
162
|
+
const scope = scopeOf(options.scope, keys);
|
|
163
|
+
const where =
|
|
164
|
+
options.after === null ? sql`true` : seek(keys, positionOf(options.after, scope, keys));
|
|
165
|
+
return {
|
|
166
|
+
select: join(keys.map((key, at) => sql`${key.column}::text as ${raw(`"${aliasOf(at)}"`)}`)),
|
|
167
|
+
where,
|
|
168
|
+
orderBy: join(keys.map((key) => sql`${key.column} ${raw(key.direction)}`)),
|
|
169
|
+
limit: sql`${limit + 1}`,
|
|
170
|
+
page<Row extends object>(rows: readonly Row[]): Page<KeysetRow<Row>> {
|
|
171
|
+
const positions = rows.map((row, index) => keyText(row, keys, index));
|
|
172
|
+
const aliases = new Set(keys.map((_key, at) => aliasOf(at)));
|
|
173
|
+
const kept = rows.slice(0, limit).map((row) => stripped(row, aliases));
|
|
174
|
+
const last = positions[limit - 1];
|
|
175
|
+
const next =
|
|
176
|
+
rows.length > limit && last !== undefined
|
|
177
|
+
? encodeCursor({ scope, key: last, id: '' })
|
|
178
|
+
: null;
|
|
179
|
+
return pageOf(kept, next);
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Single responsibility: the refusal `entity({ retain })` raises at declaration. Here because the
|
|
2
|
+
// code is this package's — the drift step raises it too, for a partitioned table — and
|
|
3
|
+
// `@ultimat3/entity` (tier 2) reaches down for it rather than registering the same code twice.
|
|
4
|
+
|
|
5
|
+
import { renderFixShellArg } from '@ultimat3/core';
|
|
6
|
+
import { DbError } from './errors';
|
|
7
|
+
|
|
8
|
+
/** `retain` with no window, beside `appendOnly`, or otherwise unsweepable. */
|
|
9
|
+
export const retainInvalid = (entity: string, reason: string): DbError =>
|
|
10
|
+
new DbError({
|
|
11
|
+
code: 'X_ENTITY_RETAIN_INVALID',
|
|
12
|
+
cause: `entity "${entity}" declares retain, and ${reason}`,
|
|
13
|
+
fix: `x db gen "retention" # after entity('${renderFixShellArg(entity, '<entity>')}', { retain: { after: '90d', on: 'createdAt' } }) names a window and appendOnly is gone`,
|
|
14
|
+
meta: { entity },
|
|
15
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Single responsibility: the framework's one writer of a transaction-local setting. Every
|
|
2
|
+
// `set_config` the framework sends is this file's: parameter-bound and always local, so a value
|
|
3
|
+
// ends with the transaction that set it and a pooled connection hands the next caller nothing.
|
|
4
|
+
|
|
5
|
+
import type { DbClient } from './client';
|
|
6
|
+
import { sql } from './sql';
|
|
7
|
+
|
|
8
|
+
/** A setting's name and its value, both text — what `set_config` takes. */
|
|
9
|
+
export type LocalSettings = Readonly<Record<string, string>>;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `select set_config($1, $2, true)` per setting, on `tx`. `true` is "local": the value lives until
|
|
13
|
+
* the transaction ends, which is the only lifetime safe behind a transaction-mode pooler. Never
|
|
14
|
+
* `set`/`set local` text: those take no parameters, so a value would have to be spliced in.
|
|
15
|
+
*/
|
|
16
|
+
export async function setLocal(tx: DbClient, settings: LocalSettings): Promise<void> {
|
|
17
|
+
for (const [name, value] of Object.entries(settings)) {
|
|
18
|
+
await tx.execute(sql`select set_config(${name}, ${value}, true)`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface SettingRow {
|
|
23
|
+
readonly value: string | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* What each named setting reads as now, on `tx`, for a caller that must put it back. An unset custom
|
|
28
|
+
* setting (`x.tenant` before anything set it) reads `''`, which `set_config` restores to the same
|
|
29
|
+
* "unset" every policy reads through `nullif(…, '')`.
|
|
30
|
+
*/
|
|
31
|
+
export async function readLocal(tx: DbClient, names: readonly string[]): Promise<LocalSettings> {
|
|
32
|
+
const prior = new Map<string, string>();
|
|
33
|
+
for (const name of names) {
|
|
34
|
+
const row = await tx.one<SettingRow>(sql`select current_setting(${name}, true) as value`);
|
|
35
|
+
prior.set(name, row?.value ?? '');
|
|
36
|
+
}
|
|
37
|
+
return Object.fromEntries(prior);
|
|
38
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Single responsibility: whether a migration DECLARES something with a marker line — a top-level
|
|
2
|
+
// `--` comment of its own, and nothing that merely looks like one. Several markers ask this question
|
|
3
|
+
// (`-- destructive: true`, `-- transaction: false`, `rolling.ts`'s), so the scan is written once.
|
|
4
|
+
|
|
5
|
+
import { noiseAt } from './sql-scan';
|
|
6
|
+
|
|
7
|
+
/** Whether only spaces and tabs separate `index` from the start of its line. */
|
|
8
|
+
function startsLine(sql: string, index: number): boolean {
|
|
9
|
+
for (let at = index - 1; at >= 0; at -= 1) {
|
|
10
|
+
const char = sql[at];
|
|
11
|
+
if (char === '\n') return true;
|
|
12
|
+
if (char !== ' ' && char !== '\t') return false;
|
|
13
|
+
}
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Declared only where a reader would see it: a top-level `--` line of its own, whose WHOLE text
|
|
19
|
+
* `marker` matches.
|
|
20
|
+
*
|
|
21
|
+
* A regex over the raw file matched a marker inside `/* … *\/` and inside a dollar-quoted body,
|
|
22
|
+
* where it declares nothing and no reviewer reading the diff would call it a declaration. Scanning
|
|
23
|
+
* is what tells a comment from a comment about a comment; a marker is a lexical fact, not a
|
|
24
|
+
* substring.
|
|
25
|
+
*/
|
|
26
|
+
export function hasMarkerLine(sql: string, marker: RegExp): boolean {
|
|
27
|
+
return markerLines(sql, marker).length > 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Every such line's text, in source order: a marker that names something is read off these. */
|
|
31
|
+
export function markerLines(sql: string, marker: RegExp): readonly string[] {
|
|
32
|
+
const found: string[] = [];
|
|
33
|
+
let index = 0;
|
|
34
|
+
while (index < sql.length) {
|
|
35
|
+
const noise = noiseAt(sql, index);
|
|
36
|
+
if (noise === null) {
|
|
37
|
+
index += 1;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const text = sql.slice(index, noise.end);
|
|
41
|
+
if (noise.kind === 'line-comment' && startsLine(sql, index) && marker.test(text)) {
|
|
42
|
+
found.push(text);
|
|
43
|
+
}
|
|
44
|
+
index = noise.end;
|
|
45
|
+
}
|
|
46
|
+
return found;
|
|
47
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Single responsibility: send one unit of a migration again when `lock_timeout` fires. A deploy that
|
|
2
|
+
// dies because one reader held a table for 5 s is a wedged rollout for a thing that heals by itself.
|
|
3
|
+
// `migration-step.ts` picks the unit: a transactional step whole, since it rolled back whole, and a
|
|
4
|
+
// non-transactional step one statement at a time, since what already ran stays run.
|
|
5
|
+
|
|
6
|
+
import { isUltimateError, jitterStatedDelay, logger, type Random } from '@ultimat3/core';
|
|
7
|
+
import { DbError } from './errors';
|
|
8
|
+
|
|
9
|
+
/** Retries after the first attempt, so six tries: at 3 s each, near half a minute of patience. */
|
|
10
|
+
export const MIGRATION_LOCK_RETRIES = 5;
|
|
11
|
+
|
|
12
|
+
export interface LockRetryPolicy {
|
|
13
|
+
readonly retries: number;
|
|
14
|
+
readonly lockTimeoutMs: number;
|
|
15
|
+
/** Seams; a test asserts the schedule rather than sleeping through it. */
|
|
16
|
+
readonly sleep?: ((ms: number) => Promise<void>) | undefined;
|
|
17
|
+
readonly random?: Random | undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* At least one `lockTimeoutMs` between attempts. While the ALTER queued, every query on that table
|
|
22
|
+
* queued behind it, and going straight back in would stack the same queue on a table that has had
|
|
23
|
+
* no time to serve it. Jitter upward only, so that floor is a floor.
|
|
24
|
+
*/
|
|
25
|
+
export async function withLockRetry(
|
|
26
|
+
id: string,
|
|
27
|
+
policy: LockRetryPolicy,
|
|
28
|
+
attempt: () => Promise<void>,
|
|
29
|
+
): Promise<void> {
|
|
30
|
+
const sleep = policy.sleep ?? Bun.sleep;
|
|
31
|
+
const random = policy.random ?? Math.random;
|
|
32
|
+
for (let done = 1; ; done += 1) {
|
|
33
|
+
try {
|
|
34
|
+
await attempt();
|
|
35
|
+
return;
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (!isUltimateError(error) || error.code !== 'X_DB_LOCK_TIMEOUT') throw error;
|
|
38
|
+
const { cause, fix } = error;
|
|
39
|
+
if (done > policy.retries) {
|
|
40
|
+
throw new DbError({
|
|
41
|
+
code: 'X_DB_LOCK_TIMEOUT',
|
|
42
|
+
cause: `migration "${id}" gave up after ${done} attempts, ${policy.lockTimeoutMs} ms of lock wait each: ${cause}`,
|
|
43
|
+
fix,
|
|
44
|
+
meta: { id, attempts: done },
|
|
45
|
+
sourceError: error,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
const waitMs = jitterStatedDelay(policy.lockTimeoutMs, policy.lockTimeoutMs, random);
|
|
49
|
+
logger.warn('ultimate migrate lock wait timed out, trying again', {
|
|
50
|
+
id,
|
|
51
|
+
attempt: done,
|
|
52
|
+
of: policy.retries + 1,
|
|
53
|
+
waitMs,
|
|
54
|
+
cause,
|
|
55
|
+
fix,
|
|
56
|
+
});
|
|
57
|
+
await sleep(waitMs);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/migrate-rollback.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
// Single responsibility: recognise a ledger an OLDER build is being rolled back onto — rows the
|
|
2
|
-
// newer build applied, every one of them newer than anything this build ships —
|
|
3
|
-
// role lets
|
|
2
|
+
// newer build applied, every one of them newer than anything this build ships — and decide whether
|
|
3
|
+
// the migrate role lets that rollback through: yes over additive rows, no over destructive ones.
|
|
4
4
|
|
|
5
|
+
import { renderFixShellArg } from '@ultimat3/core';
|
|
6
|
+
import { isDestructiveMigration } from './destructive';
|
|
7
|
+
import { DbError } from './errors';
|
|
5
8
|
import type { LedgerRow, Migration } from './migration-ledger';
|
|
6
9
|
import { pendingMigrations } from './migration-ledger';
|
|
7
10
|
|
|
11
|
+
/** The operator's written override: the destructive migration ids they checked, comma-separated. */
|
|
12
|
+
export const ROLLBACK_ALLOW_DESTRUCTIVE_ENV = 'ROLLBACK_ALLOW_DESTRUCTIVE';
|
|
13
|
+
|
|
8
14
|
/** A rollback: the rows this build does not know (all newer), and the ledger it does. */
|
|
9
15
|
export interface LedgerAhead {
|
|
10
16
|
/** The rows a newer build applied, oldest first. Never empty. */
|
|
@@ -13,6 +19,15 @@ export interface LedgerAhead {
|
|
|
13
19
|
readonly known: readonly LedgerRow[];
|
|
14
20
|
}
|
|
15
21
|
|
|
22
|
+
export interface LedgerAheadOptions {
|
|
23
|
+
/** `ROLLBACK_ALLOW_DESTRUCTIVE`'s value; read from the environment when not given. */
|
|
24
|
+
readonly allow?: string | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** What the ledger records at apply: whether this migration's `up` destroys data. */
|
|
28
|
+
export const destructiveBit = (migration: Migration): boolean =>
|
|
29
|
+
isDestructiveMigration(migration.up);
|
|
30
|
+
|
|
16
31
|
/**
|
|
17
32
|
* The ledger split, when it is exactly a rollback, else `undefined` and the caller's audit refuses
|
|
18
33
|
* the unknown rows as it always has. Three conditions, all required:
|
|
@@ -25,13 +40,15 @@ export interface LedgerAhead {
|
|
|
25
40
|
* - the build ships at least one migration. An image that lost its migrations directory is not
|
|
26
41
|
* "older than every row", and reading it as one would wave through the image that is broken.
|
|
27
42
|
*
|
|
28
|
-
* Nothing is applied either way
|
|
29
|
-
*
|
|
30
|
-
*
|
|
43
|
+
* Nothing is applied either way. Over ADDITIVE rows the older build runs on the newer schema
|
|
44
|
+
* (expand/contract, docs/ops/06-runbooks.md). Over a row the newer build recorded as destructive
|
|
45
|
+
* it may not — a dropped column it still selects is the second outage — so that rollback is
|
|
46
|
+
* refused (`X_MIGRATION_ROLLBACK_DESTRUCTIVE`) unless the operator named every such row.
|
|
31
47
|
*/
|
|
32
48
|
export function ledgerAheadOfBuild(
|
|
33
49
|
ledger: readonly LedgerRow[],
|
|
34
50
|
migrations: readonly Migration[],
|
|
51
|
+
options: LedgerAheadOptions = {},
|
|
35
52
|
): LedgerAhead | undefined {
|
|
36
53
|
if (migrations.length === 0) return undefined;
|
|
37
54
|
const shipped = new Set(migrations.map((migration) => migration.id));
|
|
@@ -40,5 +57,27 @@ export function ledgerAheadOfBuild(
|
|
|
40
57
|
if (unknown.length === 0 || unknown.some((row) => row.id <= newest)) return undefined;
|
|
41
58
|
const known = ledger.filter((row) => shipped.has(row.id));
|
|
42
59
|
if (pendingMigrations(known, migrations).length > 0) return undefined;
|
|
43
|
-
|
|
60
|
+
const ahead = [...unknown].sort((a, b) => (a.id < b.id ? -1 : 1));
|
|
61
|
+
assertRollbackSafe(ahead, options.allow ?? Bun.env[ROLLBACK_ALLOW_DESTRUCTIVE_ENV]);
|
|
62
|
+
return { ahead, known };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Every destructive row ahead must be named by the override; a null bit is not refused. */
|
|
66
|
+
function assertRollbackSafe(ahead: readonly LedgerRow[], allow: string | undefined): void {
|
|
67
|
+
const named = new Set(
|
|
68
|
+
(allow ?? '')
|
|
69
|
+
.split(',')
|
|
70
|
+
.map((id) => id.trim())
|
|
71
|
+
.filter((id) => id !== ''),
|
|
72
|
+
);
|
|
73
|
+
const refused = ahead.filter((row) => row.destructive === true && !named.has(row.id));
|
|
74
|
+
if (refused.length === 0) return;
|
|
75
|
+
const ids = refused.map((row) => row.id);
|
|
76
|
+
const all = ahead.filter((row) => row.destructive === true).map((row) => row.id);
|
|
77
|
+
throw new DbError({
|
|
78
|
+
code: 'X_MIGRATION_ROLLBACK_DESTRUCTIVE',
|
|
79
|
+
cause: `the ledger holds ${ids.length} destructive migration(s) a newer build applied (${ids.join(', ')}) and this build predates them — it may read a column or table that no longer exists`,
|
|
80
|
+
fix: `x deploy --image <the newer image> --json # roll forward; or, after checking this build runs on the newer schema: ${ROLLBACK_ALLOW_DESTRUCTIVE_ENV}=${renderFixShellArg(all.join(','), '<migration id>')} on the migrate role`,
|
|
81
|
+
meta: { refused: ids, appVersions: [...new Set(refused.map((row) => row.app_version))] },
|
|
82
|
+
});
|
|
44
83
|
}
|
package/src/migrate.ts
CHANGED
|
@@ -3,11 +3,18 @@
|
|
|
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 {
|
|
6
|
+
import {
|
|
7
|
+
appVersion,
|
|
8
|
+
finiteCount,
|
|
9
|
+
isProduction,
|
|
10
|
+
logger,
|
|
11
|
+
type Random,
|
|
12
|
+
renderFixShellArg,
|
|
13
|
+
} from '@ultimat3/core';
|
|
7
14
|
import { baseClient, type DbClient, type DbConnection, isReservable } from './client';
|
|
8
|
-
import { refuseDependentViews } from './dependent-view';
|
|
9
15
|
import { expectedQueryLoop } from './expected-loop';
|
|
10
|
-
import {
|
|
16
|
+
import { MIGRATION_LOCK_RETRIES } from './migrate-lock-retry';
|
|
17
|
+
import { destructiveBit, ledgerAheadOfBuild } from './migrate-rollback';
|
|
11
18
|
import { migrateConcurrent, migrationConflict, rollbackStepsInvalid } from './migration-errors';
|
|
12
19
|
import type { Migration } from './migration-ledger';
|
|
13
20
|
import {
|
|
@@ -18,10 +25,11 @@ import {
|
|
|
18
25
|
pendingMigrations,
|
|
19
26
|
readLedger,
|
|
20
27
|
} from './migration-ledger';
|
|
28
|
+
import { applyMigrationStep } from './migration-step';
|
|
21
29
|
import { poolProfileFor } from './pool-profile';
|
|
30
|
+
import { retireAndContractPending } from './rolling';
|
|
31
|
+
import { retireAndContractTogether } from './rolling-errors';
|
|
22
32
|
import { raw, sql } from './sql';
|
|
23
|
-
import { statementsOf } from './statement-split';
|
|
24
|
-
import { withTransaction } from './transaction';
|
|
25
33
|
|
|
26
34
|
export type { LedgerRow, Migration } from './migration-ledger';
|
|
27
35
|
export {
|
|
@@ -83,6 +91,13 @@ export interface MigrateOptions {
|
|
|
83
91
|
readonly lockWaitMs?: number | undefined;
|
|
84
92
|
/** `SET LOCAL lock_timeout` per migration. Defaults to the `migrate` role's profile. */
|
|
85
93
|
readonly lockTimeoutMs?: number | undefined;
|
|
94
|
+
/** Attempts past the first when `lock_timeout` fires (`migrate-lock-retry.ts`). Defaults to 5. */
|
|
95
|
+
readonly lockRetries?: number | undefined;
|
|
96
|
+
/** Seams for the wait between those attempts; a test reads the schedule. */
|
|
97
|
+
readonly lockRetrySleep?: ((ms: number) => Promise<void>) | undefined;
|
|
98
|
+
readonly lockRetryRandom?: Random | undefined;
|
|
99
|
+
/** Whether a retire and its drop may land in one deploy. Defaults to core's `isProduction()`. */
|
|
100
|
+
readonly production?: boolean | undefined;
|
|
86
101
|
}
|
|
87
102
|
|
|
88
103
|
/**
|
|
@@ -175,43 +190,6 @@ async function withAdvisoryLock<T>(
|
|
|
175
190
|
}
|
|
176
191
|
}
|
|
177
192
|
|
|
178
|
-
/**
|
|
179
|
-
* A migration's `up` and `down` are **scripts**, and one send is one statement — because the two
|
|
180
|
-
* drivers disagree about anything else. PGlite's `query()` is the extended protocol always and
|
|
181
|
-
* answers `cannot insert multiple commands into a prepared statement`; `Bun.SQL.unsafe` degrades
|
|
182
|
-
* to the simple protocol when no value is bound and applies the same script, which is a fact about
|
|
183
|
-
* bun 1.3.14 and not a contract. `createTable` emits the table *and* every index it carries, and
|
|
184
|
-
* `x dev` runs on the embedded driver, so the refusing side was the common path.
|
|
185
|
-
*
|
|
186
|
-
* No `expectedQueryLoop` of its own: both call sites already run inside the one declared for the
|
|
187
|
-
* migration loop, and nesting here would replace that reason with a narrower one for no gain. An
|
|
188
|
-
* empty script sends nothing at all, which is how a no-op migration reaches its ledger row.
|
|
189
|
-
*/
|
|
190
|
-
async function applyScript(tx: DbClient, script: string): Promise<void> {
|
|
191
|
-
for (const statement of statementsOf(script)) await tx.execute(raw(statement));
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* Bound how long this migration will queue behind a lock it cannot take.
|
|
196
|
-
*
|
|
197
|
-
* `alter table … add column` needs `ACCESS EXCLUSIVE`. A long `SELECT` holding `ACCESS SHARE`
|
|
198
|
-
* makes it wait — and because Postgres' lock queue is FIFO, **every subsequent query on that table
|
|
199
|
-
* queues behind the ALTER**. The `migrate` profile runs `statement_timeout = 0` deliberately, so
|
|
200
|
-
* without this the migrator waits forever and the app is down on one table for as long as the
|
|
201
|
-
* reporting query runs. `lock_timeout` bounds the *wait* alone and never the work, which is why it
|
|
202
|
-
* is the right knob where `statement_timeout` is not.
|
|
203
|
-
*
|
|
204
|
-
* `SET LOCAL`, inside the migration's own transaction: it reverts at COMMIT, so a value chosen for
|
|
205
|
-
* DDL never leaks onto the session the ledger insert or the next migration runs on. A 0 disables
|
|
206
|
-
* it, exactly like `statementTimeoutMs`. The failure it produces is `55P03`, typed as
|
|
207
|
-
* `X_DB_LOCK_TIMEOUT` by `driverError` with the `pg_stat_activity` read as its fix.
|
|
208
|
-
*/
|
|
209
|
-
async function setLockTimeout(tx: DbClient, lockTimeoutMs: number): Promise<void> {
|
|
210
|
-
if (lockTimeoutMs <= 0) return;
|
|
211
|
-
// `SET LOCAL` takes no parameter placeholder, and the value is a validated integer of ours.
|
|
212
|
-
await tx.execute(raw(`SET LOCAL lock_timeout = ${Math.round(lockTimeoutMs)}`));
|
|
213
|
-
}
|
|
214
|
-
|
|
215
193
|
/**
|
|
216
194
|
* `migrate` role's profile whatever role is running, because the statement is a migration whatever
|
|
217
195
|
* process issues it: `x db migrate` from a laptop, `x dev`'s boot and `ROLE=migrate` all take the
|
|
@@ -231,10 +209,15 @@ function migrationLockTimeoutMs(explicit: number | undefined): number {
|
|
|
231
209
|
);
|
|
232
210
|
}
|
|
233
211
|
|
|
212
|
+
/** Screened beside `lockTimeoutMs`, before the advisory lock, for the reason given there. */
|
|
213
|
+
const migrationLockRetries = (explicit: number | undefined): number =>
|
|
214
|
+
finiteCount('migrate', 'lockRetries', explicit ?? MIGRATION_LOCK_RETRIES, 0);
|
|
215
|
+
|
|
234
216
|
export async function migrate(options: MigrateOptions): Promise<MigrationReport> {
|
|
235
217
|
const client = options.client ?? baseClient();
|
|
236
218
|
const appVersion = runningAppVersion(options.appVersion);
|
|
237
219
|
const lockTimeoutMs = migrationLockTimeoutMs(options.lockTimeoutMs);
|
|
220
|
+
const retries = migrationLockRetries(options.lockRetries);
|
|
238
221
|
const started = performance.now();
|
|
239
222
|
|
|
240
223
|
return withAdvisoryLock(
|
|
@@ -261,6 +244,13 @@ export async function migrate(options: MigrateOptions): Promise<MigrationReport>
|
|
|
261
244
|
}
|
|
262
245
|
|
|
263
246
|
const pending = pendingMigrations(ledger, options.migrations);
|
|
247
|
+
// Before the first BEGIN, so nothing is half done. Only where a previous release can be
|
|
248
|
+
// running: a non-empty ledger in production. A fresh database, a template or a branch has no
|
|
249
|
+
// old pods to break and replays both freely.
|
|
250
|
+
if (ledger.length > 0 && (options.production ?? isProduction())) {
|
|
251
|
+
const pair = retireAndContractPending(pending);
|
|
252
|
+
if (pair !== undefined) throw retireAndContractTogether(pair);
|
|
253
|
+
}
|
|
264
254
|
// A statement per migration and a transaction per migration is the point, not an N+1 to batch:
|
|
265
255
|
// one failed `up` must leave the ledger describing exactly the migrations that did run, and a
|
|
266
256
|
// batch commits or loses all of them together. Declared here so a diagnostic reports the loops
|
|
@@ -271,27 +261,31 @@ export async function migrate(options: MigrateOptions): Promise<MigrationReport>
|
|
|
271
261
|
const done: AppliedMigration[] = [];
|
|
272
262
|
for (const migration of pending) {
|
|
273
263
|
const at = performance.now();
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
264
|
+
// Inside a transaction, or — a migration marked `-- transaction: false` — statement by
|
|
265
|
+
// statement outside one (`migration-step.ts`). Either way the ledger row is written
|
|
266
|
+
// only after the last statement succeeded, on the lock's own session.
|
|
267
|
+
await applyMigrationStep(session, {
|
|
268
|
+
id: migration.id,
|
|
269
|
+
up: migration.up,
|
|
270
|
+
script: migration.up,
|
|
271
|
+
lockTimeoutMs,
|
|
272
|
+
lockRetry: {
|
|
273
|
+
retries,
|
|
274
|
+
sleep: options.lockRetrySleep,
|
|
275
|
+
random: options.lockRetryRandom,
|
|
276
|
+
},
|
|
277
|
+
// The bit an OLDER build reads on a rollback (`migrate-rollback.ts`): recorded here,
|
|
278
|
+
// by the build that knows the SQL, because the one rolled back to never sees it.
|
|
279
|
+
record: async (client) => {
|
|
284
280
|
const durationMs = Math.round(performance.now() - at);
|
|
285
|
-
await
|
|
286
|
-
insert into ${raw(LEDGER_TABLE)}
|
|
281
|
+
await client.execute(sql`
|
|
282
|
+
insert into ${raw(LEDGER_TABLE)}
|
|
283
|
+
(id, name, checksum, app_version, duration_ms, destructive)
|
|
287
284
|
values (${migration.id}, ${migration.name}, ${migrationChecksum(migration)},
|
|
288
|
-
${appVersion}, ${durationMs})
|
|
285
|
+
${appVersion}, ${durationMs}, ${destructiveBit(migration)})
|
|
289
286
|
`);
|
|
290
287
|
},
|
|
291
|
-
|
|
292
|
-
// the lock at all, and on `ROLE=migrate` there is no other connection to apply it on.
|
|
293
|
-
{ client: session },
|
|
294
|
-
);
|
|
288
|
+
});
|
|
295
289
|
done.push({
|
|
296
290
|
id: migration.id,
|
|
297
291
|
name: migration.name,
|
|
@@ -327,6 +321,8 @@ export interface RollbackOptions {
|
|
|
327
321
|
readonly lockWaitMs?: number | undefined;
|
|
328
322
|
/** `SET LOCAL lock_timeout` per reversal. Defaults to the `migrate` role's profile. */
|
|
329
323
|
readonly lockTimeoutMs?: number | undefined;
|
|
324
|
+
/** Attempts past the first when `lock_timeout` fires. Defaults to 5, as `migrate`'s does. */
|
|
325
|
+
readonly lockRetries?: number | undefined;
|
|
330
326
|
}
|
|
331
327
|
|
|
332
328
|
/** Reverse the newest `steps` applied migrations. `x db rollback`. */
|
|
@@ -337,6 +333,7 @@ export async function rollback(options: RollbackOptions): Promise<readonly strin
|
|
|
337
333
|
// "one fewer", so an unvalidated count reverses migrations nobody asked about.
|
|
338
334
|
if (!Number.isSafeInteger(steps) || steps < 1) throw rollbackStepsInvalid(steps);
|
|
339
335
|
const lockTimeoutMs = migrationLockTimeoutMs(options.lockTimeoutMs);
|
|
336
|
+
const retries = migrationLockRetries(options.lockRetries);
|
|
340
337
|
const known = new Map(options.migrations.map((migration) => [migration.id, migration]));
|
|
341
338
|
|
|
342
339
|
// The same lock `migrate` takes, because the race is the same one: a rollback reversing the id a
|
|
@@ -369,17 +366,18 @@ export async function rollback(options: RollbackOptions): Promise<readonly strin
|
|
|
369
366
|
'and roll back there — its down SQL exists nowhere else',
|
|
370
367
|
);
|
|
371
368
|
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
369
|
+
// The marker on `up` governs `down` too: the reversal of a concurrent build is a
|
|
370
|
+
// concurrent drop, refused inside a transaction just the same.
|
|
371
|
+
await applyMigrationStep(session, {
|
|
372
|
+
id: migration.id,
|
|
373
|
+
up: migration.up,
|
|
374
|
+
script: migration.down,
|
|
375
|
+
lockTimeoutMs,
|
|
376
|
+
lockRetry: { retries },
|
|
377
|
+
record: async (client) => {
|
|
378
|
+
await client.execute(sql`delete from ${raw(LEDGER_TABLE)} where id = ${row.id}`);
|
|
380
379
|
},
|
|
381
|
-
|
|
382
|
-
);
|
|
380
|
+
});
|
|
383
381
|
reverted.push(row.id);
|
|
384
382
|
}
|
|
385
383
|
return reverted;
|