@ultimat3/db 29.4.0 → 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 +74 -110
- package/README.md +163 -15
- 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 +20 -0
- package/src/concurrent-plan.ts +135 -17
- 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 +17 -3
- 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 +67 -0
- package/src/errors.ts +77 -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 +6 -1
- package/src/generate-triggers.ts +119 -0
- package/src/generate-view.ts +97 -0
- package/src/generate.ts +105 -79
- package/src/index.ts +129 -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 +12 -4
- package/src/migrate-lock-retry.ts +60 -0
- package/src/migrate-rollback.ts +45 -6
- package/src/migrate.ts +45 -4
- package/src/migration-errors.ts +27 -1
- package/src/migration-ledger.ts +38 -4
- package/src/migration-step.ts +50 -22
- 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/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,10 +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
15
|
import { expectedQueryLoop } from './expected-loop';
|
|
9
|
-
import {
|
|
16
|
+
import { MIGRATION_LOCK_RETRIES } from './migrate-lock-retry';
|
|
17
|
+
import { destructiveBit, ledgerAheadOfBuild } from './migrate-rollback';
|
|
10
18
|
import { migrateConcurrent, migrationConflict, rollbackStepsInvalid } from './migration-errors';
|
|
11
19
|
import type { Migration } from './migration-ledger';
|
|
12
20
|
import {
|
|
@@ -19,6 +27,8 @@ import {
|
|
|
19
27
|
} from './migration-ledger';
|
|
20
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
33
|
|
|
24
34
|
export type { LedgerRow, Migration } from './migration-ledger';
|
|
@@ -81,6 +91,13 @@ export interface MigrateOptions {
|
|
|
81
91
|
readonly lockWaitMs?: number | undefined;
|
|
82
92
|
/** `SET LOCAL lock_timeout` per migration. Defaults to the `migrate` role's profile. */
|
|
83
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;
|
|
84
101
|
}
|
|
85
102
|
|
|
86
103
|
/**
|
|
@@ -192,10 +209,15 @@ function migrationLockTimeoutMs(explicit: number | undefined): number {
|
|
|
192
209
|
);
|
|
193
210
|
}
|
|
194
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
|
+
|
|
195
216
|
export async function migrate(options: MigrateOptions): Promise<MigrationReport> {
|
|
196
217
|
const client = options.client ?? baseClient();
|
|
197
218
|
const appVersion = runningAppVersion(options.appVersion);
|
|
198
219
|
const lockTimeoutMs = migrationLockTimeoutMs(options.lockTimeoutMs);
|
|
220
|
+
const retries = migrationLockRetries(options.lockRetries);
|
|
199
221
|
const started = performance.now();
|
|
200
222
|
|
|
201
223
|
return withAdvisoryLock(
|
|
@@ -222,6 +244,13 @@ export async function migrate(options: MigrateOptions): Promise<MigrationReport>
|
|
|
222
244
|
}
|
|
223
245
|
|
|
224
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
|
+
}
|
|
225
254
|
// A statement per migration and a transaction per migration is the point, not an N+1 to batch:
|
|
226
255
|
// one failed `up` must leave the ledger describing exactly the migrations that did run, and a
|
|
227
256
|
// batch commits or loses all of them together. Declared here so a diagnostic reports the loops
|
|
@@ -240,12 +269,20 @@ export async function migrate(options: MigrateOptions): Promise<MigrationReport>
|
|
|
240
269
|
up: migration.up,
|
|
241
270
|
script: migration.up,
|
|
242
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.
|
|
243
279
|
record: async (client) => {
|
|
244
280
|
const durationMs = Math.round(performance.now() - at);
|
|
245
281
|
await client.execute(sql`
|
|
246
|
-
insert into ${raw(LEDGER_TABLE)}
|
|
282
|
+
insert into ${raw(LEDGER_TABLE)}
|
|
283
|
+
(id, name, checksum, app_version, duration_ms, destructive)
|
|
247
284
|
values (${migration.id}, ${migration.name}, ${migrationChecksum(migration)},
|
|
248
|
-
${appVersion}, ${durationMs})
|
|
285
|
+
${appVersion}, ${durationMs}, ${destructiveBit(migration)})
|
|
249
286
|
`);
|
|
250
287
|
},
|
|
251
288
|
});
|
|
@@ -284,6 +321,8 @@ export interface RollbackOptions {
|
|
|
284
321
|
readonly lockWaitMs?: number | undefined;
|
|
285
322
|
/** `SET LOCAL lock_timeout` per reversal. Defaults to the `migrate` role's profile. */
|
|
286
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;
|
|
287
326
|
}
|
|
288
327
|
|
|
289
328
|
/** Reverse the newest `steps` applied migrations. `x db rollback`. */
|
|
@@ -294,6 +333,7 @@ export async function rollback(options: RollbackOptions): Promise<readonly strin
|
|
|
294
333
|
// "one fewer", so an unvalidated count reverses migrations nobody asked about.
|
|
295
334
|
if (!Number.isSafeInteger(steps) || steps < 1) throw rollbackStepsInvalid(steps);
|
|
296
335
|
const lockTimeoutMs = migrationLockTimeoutMs(options.lockTimeoutMs);
|
|
336
|
+
const retries = migrationLockRetries(options.lockRetries);
|
|
297
337
|
const known = new Map(options.migrations.map((migration) => [migration.id, migration]));
|
|
298
338
|
|
|
299
339
|
// The same lock `migrate` takes, because the race is the same one: a rollback reversing the id a
|
|
@@ -333,6 +373,7 @@ export async function rollback(options: RollbackOptions): Promise<readonly strin
|
|
|
333
373
|
up: migration.up,
|
|
334
374
|
script: migration.down,
|
|
335
375
|
lockTimeoutMs,
|
|
376
|
+
lockRetry: { retries },
|
|
336
377
|
record: async (client) => {
|
|
337
378
|
await client.execute(sql`delete from ${raw(LEDGER_TABLE)} where id = ${row.id}`);
|
|
338
379
|
},
|
package/src/migration-errors.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Single responsibility: the refusals a MIGRATION earns — the lock it could not take, a ledger that
|
|
2
2
|
// disagrees with this build, a plan that cannot be reversed, a plan that destroys rows without
|
|
3
|
-
// saying so, a snapshot
|
|
3
|
+
// saying so, a snapshot never written, a view in a retype's way, a concurrent build. Split out of
|
|
4
4
|
// `errors.ts` only because that file reached the 500-line ceiling, exactly as `invariant-errors.ts`
|
|
5
5
|
// was: every code below is still declared, titled and registered there, and `DbError` is still the
|
|
6
6
|
// one class. One direction only — nothing here is imported back.
|
|
@@ -176,3 +176,29 @@ export const migrationConcurrentInTransaction = (id: string, statement: string):
|
|
|
176
176
|
fix: `sed -i '1i -- transaction: false' packages/db/migrations/${renderFixShellArg(id, '<id>')}.sql # before it is applied — or move the statement into a migration of its own`,
|
|
177
177
|
meta: { id },
|
|
178
178
|
});
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* A concurrent index build on a PARTITIONED parent, which Postgres refuses (`0A000`) — reached as
|
|
182
|
+
* a bare `X_DB_STATEMENT_FAILED` that named the refusal and not the sequence that replaces it.
|
|
183
|
+
* `sourceError` keeps the server's own error, SQLSTATE included. The sequence is pg_dump's: each
|
|
184
|
+
* partition built concurrently, the parent's index `on only` (instant, no rows), then attached.
|
|
185
|
+
*/
|
|
186
|
+
export const migrationConcurrentPartitioned = (
|
|
187
|
+
id: string,
|
|
188
|
+
statement: string,
|
|
189
|
+
sourceError: unknown,
|
|
190
|
+
): DbError =>
|
|
191
|
+
new DbError({
|
|
192
|
+
code: 'X_MIGRATION_CONCURRENT_PARTITIONED',
|
|
193
|
+
cause:
|
|
194
|
+
`migration "${id}" builds an index concurrently on a partitioned table, which Postgres ` +
|
|
195
|
+
`refuses (0A000): ${statementExcerpt(statement)}`,
|
|
196
|
+
// Three pieces, each opening with the statement it is: the parent's index is attached to,
|
|
197
|
+
// never built over, so `on only` is instant and valid once every partition's is attached.
|
|
198
|
+
fix:
|
|
199
|
+
'CREATE INDEX CONCURRENTLY <index>_<partition> ON <partition> (<columns>); # per partition, then ' +
|
|
200
|
+
'CREATE INDEX <index> ON ONLY <parent> (<columns>); # then, per partition, ' +
|
|
201
|
+
'ALTER INDEX <index> ATTACH PARTITION <index>_<partition>; # in a -- transaction: false migration',
|
|
202
|
+
meta: { id, sqlState: '0A000' },
|
|
203
|
+
sourceError,
|
|
204
|
+
});
|
package/src/migration-ledger.ts
CHANGED
|
@@ -3,15 +3,18 @@
|
|
|
3
3
|
// Split from `migrate.ts`, which keeps the lock, the apply loop and the rollback; `migrate.ts`
|
|
4
4
|
// re-exports every name here, so its public surface is unchanged.
|
|
5
5
|
|
|
6
|
+
import { isFixShellSafe } from '@ultimat3/core';
|
|
6
7
|
import type { DbClient } from './client';
|
|
7
8
|
import type { SchemaDescription } from './introspect';
|
|
8
9
|
import { migrationConflict } from './migration-errors';
|
|
9
|
-
import { migrationNameArg } from './primary-key';
|
|
10
10
|
import { raw, sql } from './sql';
|
|
11
11
|
import { SQLSTATE, sqlState } from './sqlstate';
|
|
12
12
|
|
|
13
13
|
export const LEDGER_TABLE = 'x_migrations';
|
|
14
14
|
|
|
15
|
+
/** Where `x new` puts an app's migrations: this package reads rows, the CLI reads the directory. */
|
|
16
|
+
const APP_MIGRATIONS_DIR = 'packages/db/migrations';
|
|
17
|
+
|
|
15
18
|
export interface Migration {
|
|
16
19
|
/** Sort key and primary key. `20260726120000_add_publish_at`. */
|
|
17
20
|
readonly id: string;
|
|
@@ -31,6 +34,13 @@ export interface LedgerRow {
|
|
|
31
34
|
readonly applied_at: string;
|
|
32
35
|
readonly app_version: string;
|
|
33
36
|
readonly duration_ms: number;
|
|
37
|
+
/**
|
|
38
|
+
* Whether this migration's `up` destroys data, recorded at apply (`destructiveBit`). `null` on a
|
|
39
|
+
* row applied before the ledger kept the bit: the framework cannot know, and says so
|
|
40
|
+
* (`migrate-rollback.ts` warns rather than refuses over it). Optional so a row a caller builds
|
|
41
|
+
* by hand still types; `readLedger` always answers it.
|
|
42
|
+
*/
|
|
43
|
+
readonly destructive?: boolean | null | undefined;
|
|
34
44
|
}
|
|
35
45
|
|
|
36
46
|
/**
|
|
@@ -58,6 +68,11 @@ export async function ensureLedger(client: DbClient): Promise<void> {
|
|
|
58
68
|
duration_ms integer not null
|
|
59
69
|
)
|
|
60
70
|
`);
|
|
71
|
+
// Added, never part of the create: a ledger every earlier release made has no such column, and
|
|
72
|
+
// its rows keep `null` — "applied before the bit was recorded", never a guessed `false`.
|
|
73
|
+
await client.execute(sql`
|
|
74
|
+
alter table ${raw(LEDGER_TABLE)} add column if not exists destructive boolean
|
|
75
|
+
`);
|
|
61
76
|
}
|
|
62
77
|
|
|
63
78
|
/**
|
|
@@ -76,10 +91,16 @@ export function isLedgerMissing(error: unknown): boolean {
|
|
|
76
91
|
return sqlState(error) === SQLSTATE.undefinedTable;
|
|
77
92
|
}
|
|
78
93
|
|
|
94
|
+
/**
|
|
95
|
+
* The bit is read through the row's JSON, not by name: `x db status`, the drift step and the MCP
|
|
96
|
+
* dry run read a ledger `ensureLedger` has not touched, and naming a column an older release's
|
|
97
|
+
* ledger lacks would turn every one of those reads into `42703`. Absent reads `null`.
|
|
98
|
+
*/
|
|
79
99
|
export async function readLedger(client: DbClient): Promise<readonly LedgerRow[]> {
|
|
80
100
|
return client.query<LedgerRow>(sql`
|
|
81
|
-
select id, name, checksum, applied_at, app_version, duration_ms
|
|
82
|
-
|
|
101
|
+
select id, name, checksum, applied_at, app_version, duration_ms,
|
|
102
|
+
(to_jsonb(ledger) ->> 'destructive')::boolean as destructive
|
|
103
|
+
from ${raw(LEDGER_TABLE)} as ledger
|
|
83
104
|
order by id
|
|
84
105
|
`);
|
|
85
106
|
}
|
|
@@ -122,11 +143,24 @@ export function auditLedger(
|
|
|
122
143
|
if (checksum === row.checksum) continue;
|
|
123
144
|
throw migrationConflict(
|
|
124
145
|
`migration "${row.id}" was applied with checksum ${row.checksum} but now hashes ${checksum}`,
|
|
125
|
-
|
|
146
|
+
editedMigrationFix(row.id, row.checksum),
|
|
126
147
|
);
|
|
127
148
|
}
|
|
128
149
|
}
|
|
129
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Restore first. `x db gen` was this line, and it exits 0 and writes the edited SQL into the schema
|
|
153
|
+
* dump, with `drift` green over it. `git checkout -- <file>` was the next, and a COMMITTED edit
|
|
154
|
+
* restores the same wrong blob, so the line lists the file's revisions and the ledger's checksum
|
|
155
|
+
* names the one applied. The id is a file name and the checksum a ledger value: either one the
|
|
156
|
+
* shell would read is left out (the directory, then the cause) rather than spliced.
|
|
157
|
+
*/
|
|
158
|
+
const editedMigrationFix = (id: string, checksum: string): string => {
|
|
159
|
+
const path = isFixShellSafe(id) ? `${APP_MIGRATIONS_DIR}/${id}.sql` : APP_MIGRATIONS_DIR;
|
|
160
|
+
const applied = isFixShellSafe(checksum) ? `checksum ${checksum}` : 'the checksum in the cause';
|
|
161
|
+
return `git log --format='%h %ad %s' --date=short -- ${path} # restore the revision applied with ${applied}: git checkout <rev> -- ${path} and never edit an applied migration; then x db gen "<change>"`;
|
|
162
|
+
};
|
|
163
|
+
|
|
130
164
|
/**
|
|
131
165
|
* The migration id is the DATABASE's own text — whoever can write a ledger row picks what lands in
|
|
132
166
|
* a line an operator pastes — and it goes inside SHELL DOUBLE QUOTES, where `$(…)` and a backtick
|
package/src/migration-step.ts
CHANGED
|
@@ -10,9 +10,14 @@ import {
|
|
|
10
10
|
firstConcurrentStatement,
|
|
11
11
|
isConcurrentIndexStatement,
|
|
12
12
|
isNonTransactional,
|
|
13
|
+
isPartitionedBuildRefusal,
|
|
13
14
|
} from './concurrent-index';
|
|
14
15
|
import { refuseDependentViews } from './dependent-view';
|
|
15
|
-
import {
|
|
16
|
+
import { type LockRetryPolicy, withLockRetry } from './migrate-lock-retry';
|
|
17
|
+
import {
|
|
18
|
+
migrationConcurrentInTransaction,
|
|
19
|
+
migrationConcurrentPartitioned,
|
|
20
|
+
} from './migration-errors';
|
|
16
21
|
import { raw, sql } from './sql';
|
|
17
22
|
import { statementsOf } from './statement-split';
|
|
18
23
|
import { withTransaction } from './transaction';
|
|
@@ -25,6 +30,8 @@ export interface MigrationStep {
|
|
|
25
30
|
readonly script: string;
|
|
26
31
|
/** `SET LOCAL lock_timeout` — every transaction, and no concurrent statement. `0` sets none. */
|
|
27
32
|
readonly lockTimeoutMs: number;
|
|
33
|
+
/** What a `lock_timeout` firing is worth (`migrate-lock-retry.ts`). Absent is no second attempt. */
|
|
34
|
+
readonly lockRetry?: Omit<LockRetryPolicy, 'lockTimeoutMs'> | undefined;
|
|
28
35
|
/** The ledger write, on the client the statements ran on — after the last of them succeeded. */
|
|
29
36
|
readonly record: (client: DbClient) => Promise<void>;
|
|
30
37
|
}
|
|
@@ -52,7 +59,7 @@ async function applyScript(tx: DbClient, script: string): Promise<void> {
|
|
|
52
59
|
*
|
|
53
60
|
* `SET LOCAL`, inside the migration's own transaction: it reverts at COMMIT, so a value chosen for
|
|
54
61
|
* 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
|
|
62
|
+
* typed as `X_DB_LOCK_TIMEOUT` by `driverError`, whose fix is `x db diagnostics --json`.
|
|
56
63
|
*/
|
|
57
64
|
async function setLockTimeout(tx: DbClient, lockTimeoutMs: number): Promise<void> {
|
|
58
65
|
if (lockTimeoutMs <= 0) return;
|
|
@@ -113,12 +120,26 @@ const BARE_CLUSTER = /^\s*cluster(?:\s+verbose)?\s*;?\s*$/i;
|
|
|
113
120
|
const withoutLeadingComments = (statement: string): string =>
|
|
114
121
|
statement.replace(/^(?:\s*--[^\n]*(?:\n|$))+/, '');
|
|
115
122
|
|
|
123
|
+
/** The one retry wrap, whichever unit the step's kind makes safe to send again. */
|
|
124
|
+
const retrying = (step: MigrationStep, attempt: () => Promise<void>): Promise<void> =>
|
|
125
|
+
withLockRetry(
|
|
126
|
+
step.id,
|
|
127
|
+
{ ...(step.lockRetry ?? { retries: 0 }), lockTimeoutMs: step.lockTimeoutMs },
|
|
128
|
+
attempt,
|
|
129
|
+
);
|
|
130
|
+
|
|
116
131
|
async function applyOutsideTransaction(session: DbClient, step: MigrationStep): Promise<void> {
|
|
117
132
|
await refuseDependentViews(session, step.script);
|
|
118
133
|
for (const statement of statementsOf(step.script)) {
|
|
119
134
|
if (isConcurrentIndexStatement(statement)) {
|
|
120
135
|
await dropInvalidLeftover(session, statement);
|
|
121
|
-
await session.execute(raw(statement))
|
|
136
|
+
await session.execute(raw(statement)).catch((error: unknown) => {
|
|
137
|
+
// `0A000` on a partitioned parent: the instruction is the sequence that replaces it.
|
|
138
|
+
if (isPartitionedBuildRefusal(statement, error)) {
|
|
139
|
+
throw migrationConcurrentPartitioned(step.id, statement, error);
|
|
140
|
+
}
|
|
141
|
+
throw error;
|
|
142
|
+
});
|
|
122
143
|
continue;
|
|
123
144
|
}
|
|
124
145
|
// Postgres refuses these inside a transaction (25001): autocommitted, as the marker promises.
|
|
@@ -127,12 +148,16 @@ async function applyOutsideTransaction(session: DbClient, step: MigrationStep):
|
|
|
127
148
|
await session.execute(raw(statement));
|
|
128
149
|
continue;
|
|
129
150
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
151
|
+
// This statement and no earlier one: what already ran stays run. From the top would re-swap
|
|
152
|
+
// every validated key to `not valid` and scan it again, and repeat a hand-written first line.
|
|
153
|
+
await retrying(step, () =>
|
|
154
|
+
withTransaction(
|
|
155
|
+
async (tx) => {
|
|
156
|
+
await setLockTimeout(tx, step.lockTimeoutMs);
|
|
157
|
+
await tx.execute(raw(statement));
|
|
158
|
+
},
|
|
159
|
+
{ client: session },
|
|
160
|
+
),
|
|
136
161
|
);
|
|
137
162
|
}
|
|
138
163
|
await step.record(session);
|
|
@@ -147,18 +172,21 @@ export async function applyMigrationStep(session: DbClient, step: MigrationStep)
|
|
|
147
172
|
if (refused !== undefined) {
|
|
148
173
|
throw migrationConcurrentInTransaction(step.id, refused);
|
|
149
174
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
175
|
+
// Whole: the failed attempt rolled back, so nothing it did is left to repeat.
|
|
176
|
+
await retrying(step, () =>
|
|
177
|
+
withTransaction(
|
|
178
|
+
async (tx) => {
|
|
179
|
+
await setLockTimeout(tx, step.lockTimeoutMs);
|
|
180
|
+
// Before the first statement, never after the failure: a view compiled against a column
|
|
181
|
+
// this script retypes is `0A000` from the server with the view named only in a DETAIL field
|
|
182
|
+
// nothing prints (`dependent-view.ts`).
|
|
183
|
+
await refuseDependentViews(tx, step.script);
|
|
184
|
+
await applyScript(tx, step.script);
|
|
185
|
+
await step.record(tx);
|
|
186
|
+
},
|
|
187
|
+
// The lock's own session: a migration applied on another connection is not covered by the
|
|
188
|
+
// lock at all, and on `ROLE=migrate` there is no other connection to apply it on.
|
|
189
|
+
{ client: session },
|
|
190
|
+
),
|
|
163
191
|
);
|
|
164
192
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Single responsibility: the two facts a partitioned table changes about the schema dump — the
|
|
2
|
+
// order its tables load in (parent before partition, every one in the ROOT's file) and the index
|
|
3
|
+
// definition its parent's index can be rebuilt from. Pure; `schema-dump.ts` places the statements.
|
|
4
|
+
|
|
5
|
+
import type { CatalogTable } from './catalog';
|
|
6
|
+
|
|
7
|
+
/** A table and the file it is written to: its own, or its partition root's. */
|
|
8
|
+
export interface PlacedTable {
|
|
9
|
+
readonly table: CatalogTable;
|
|
10
|
+
readonly file: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Every table once, each partition after the table it is a partition of, and filed under its root
|
|
15
|
+
* — so `04_tables/events.sql` creates `events` and then each month, whatever the months are
|
|
16
|
+
* called. Name order would create `a_2026_11` before `events`. A partition whose parent is not in
|
|
17
|
+
* the list (another schema's) is filed under itself, after every family.
|
|
18
|
+
*/
|
|
19
|
+
export function inLoadOrder(tables: readonly CatalogTable[]): readonly PlacedTable[] {
|
|
20
|
+
const byName = new Map(tables.map((table) => [table.name, table]));
|
|
21
|
+
const children = new Map<string, CatalogTable[]>();
|
|
22
|
+
for (const table of tables) {
|
|
23
|
+
const parent = table.partitionOf?.table;
|
|
24
|
+
if (parent === undefined || !byName.has(parent)) continue;
|
|
25
|
+
const siblings = children.get(parent) ?? [];
|
|
26
|
+
siblings.push(table);
|
|
27
|
+
children.set(parent, siblings);
|
|
28
|
+
}
|
|
29
|
+
const placed: PlacedTable[] = [];
|
|
30
|
+
const seen = new Set<string>();
|
|
31
|
+
const family = (root: CatalogTable): void => {
|
|
32
|
+
// Breadth first: a sub-partition comes after every partition of the level above it.
|
|
33
|
+
const queue = [root];
|
|
34
|
+
for (let at = 0; at < queue.length; at += 1) {
|
|
35
|
+
const table = queue[at];
|
|
36
|
+
if (table === undefined || seen.has(table.name)) continue;
|
|
37
|
+
seen.add(table.name);
|
|
38
|
+
placed.push({ table, file: root.name });
|
|
39
|
+
queue.push(...(children.get(table.name) ?? []));
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
for (const table of tables) {
|
|
43
|
+
const parent = table.partitionOf?.table;
|
|
44
|
+
if (parent === undefined || !byName.has(parent)) family(table);
|
|
45
|
+
}
|
|
46
|
+
return placed;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `pg_get_indexdef` spells a partitioned table's index `ON ONLY` — the form that builds the
|
|
51
|
+
* parent's index alone, INVALID until every partition's is attached. The dump renders no
|
|
52
|
+
* partition's attached index, so it loads the parent's without `ONLY`, which builds them all.
|
|
53
|
+
*/
|
|
54
|
+
const ON_ONLY = /^(CREATE (?:UNIQUE )?INDEX (?:"(?:[^"]|"")*"|\S+) ON )ONLY /;
|
|
55
|
+
|
|
56
|
+
export const indexDefinitionOf = (definition: string): string => definition.replace(ON_ONLY, '$1');
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Single responsibility: name the PARTITIONED parents in a schema (`relkind = 'p'`), so a table
|
|
2
|
+
// description can say it is one. The partitions themselves are not app schema and never reach
|
|
3
|
+
// here: `nonAppRelations()` excludes every `relispartition` row, a sub-partition's parent included.
|
|
4
|
+
|
|
5
|
+
import type { DbClient } from './client';
|
|
6
|
+
import { sql } from './sql';
|
|
7
|
+
|
|
8
|
+
interface PartitionedRow {
|
|
9
|
+
readonly name: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The partitioned tables of `schema` that are not themselves a partition — the ones migrations declare. */
|
|
13
|
+
export async function partitionedTables(
|
|
14
|
+
client: DbClient,
|
|
15
|
+
schema: string,
|
|
16
|
+
): Promise<readonly string[]> {
|
|
17
|
+
const rows = await client.query<PartitionedRow>(sql`
|
|
18
|
+
select c.relname as name
|
|
19
|
+
from pg_class c
|
|
20
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
21
|
+
where n.nspname = ${schema} and c.relkind = 'p' and not c.relispartition
|
|
22
|
+
`);
|
|
23
|
+
return rows.map((row) => row.name);
|
|
24
|
+
}
|
package/src/pool-gate.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Single responsibility: how many units of work may be out on the pool at once, and how long the
|
|
2
|
+
// next one waits. `Bun.SQL` queues past `max` with no bound and publishes no occupancy, so this counts
|
|
3
|
+
// in front of it: a statement and a pin each hold one slot, and a full pool refuses on one deadline.
|
|
4
|
+
|
|
5
|
+
import type { Scheduler } from '@ultimat3/core';
|
|
6
|
+
import { type DbError, poolAcquireTimeout } from './errors';
|
|
7
|
+
import type { PoolDemand } from './pool-gauge';
|
|
8
|
+
import { unrefSchedule } from './pool-timer';
|
|
9
|
+
|
|
10
|
+
export interface PoolGate {
|
|
11
|
+
/**
|
|
12
|
+
* Take a slot. `undefined` when one was free, so the path that never waits pays no promise and
|
|
13
|
+
* sends in the caller's own turn. Otherwise resolves `true` when the slot arrives, `false` when
|
|
14
|
+
* this pool retired first, and rejects past `acquireTimeoutMs` (`X_DB_POOL_EXHAUSTED`). `0` waits
|
|
15
|
+
* forever.
|
|
16
|
+
*/
|
|
17
|
+
enter(acquireTimeoutMs: number): Promise<boolean> | undefined;
|
|
18
|
+
/** Give a slot back. The oldest waiter inherits it, so a newcomer never overtakes the queue. */
|
|
19
|
+
leave(): void;
|
|
20
|
+
/** This pool is gone but the database may not be: everyone queued asks the next pool. */
|
|
21
|
+
handOverWaiting(): void;
|
|
22
|
+
/** Reject everyone still queued. Slots already held are untouched. */
|
|
23
|
+
refuseWaiting(reason: DbError): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** What `takeSlot` needs of a pool: the gate that admits to it and the counter that reports it. */
|
|
27
|
+
export interface GatedPool {
|
|
28
|
+
readonly gate: PoolGate;
|
|
29
|
+
readonly demand: PoolDemand;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A slot on the current pool, counted from the ask: a statement queued behind a full pool is exactly
|
|
34
|
+
* what `db_pool_waiting` reports. Answers the pool itself when a slot was free, so a caller checking
|
|
35
|
+
* `instanceof Promise` sends in its own turn. A retirement during the wait re-asks the pool that
|
|
36
|
+
* replaced it, with a fresh deadline: the work was never sent, so it can only be late, never doubled.
|
|
37
|
+
*/
|
|
38
|
+
export function takeSlot<P extends GatedPool>(
|
|
39
|
+
connect: () => P,
|
|
40
|
+
acquireTimeoutMs: number,
|
|
41
|
+
): P | Promise<P> {
|
|
42
|
+
const asked = connect();
|
|
43
|
+
asked.demand.enter();
|
|
44
|
+
const wait = asked.gate.enter(acquireTimeoutMs);
|
|
45
|
+
if (wait === undefined) return asked;
|
|
46
|
+
return (async () => {
|
|
47
|
+
let admitted: boolean;
|
|
48
|
+
try {
|
|
49
|
+
admitted = await wait;
|
|
50
|
+
} catch (error) {
|
|
51
|
+
asked.demand.leave();
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
if (admitted) return asked;
|
|
55
|
+
asked.demand.leave();
|
|
56
|
+
return takeSlot(connect, acquireTimeoutMs);
|
|
57
|
+
})();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The other half of `takeSlot`. */
|
|
61
|
+
export function freeSlot(pool: GatedPool): void {
|
|
62
|
+
pool.gate.leave();
|
|
63
|
+
pool.demand.leave();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface Waiter {
|
|
67
|
+
readonly settle: (admitted: boolean) => void;
|
|
68
|
+
readonly refuse: (reason: DbError) => void;
|
|
69
|
+
disarm: () => void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function poolGate(max: number, schedule: Scheduler = unrefSchedule): PoolGate {
|
|
73
|
+
let out = 0;
|
|
74
|
+
// A Set: FIFO by insertion, and a waiter past its deadline leaves from the middle in O(1).
|
|
75
|
+
const waiting = new Set<Waiter>();
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
enter(acquireTimeoutMs) {
|
|
79
|
+
// `leave` hands a slot over rather than freeing it, so a free slot means nobody is queued.
|
|
80
|
+
if (out < max) {
|
|
81
|
+
out += 1;
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
return new Promise<boolean>((settle, refuse) => {
|
|
85
|
+
const waiter: Waiter = { settle, refuse, disarm: () => undefined };
|
|
86
|
+
waiting.add(waiter);
|
|
87
|
+
if (acquireTimeoutMs <= 0) return;
|
|
88
|
+
waiter.disarm = schedule(() => {
|
|
89
|
+
waiting.delete(waiter);
|
|
90
|
+
refuse(poolAcquireTimeout(acquireTimeoutMs, max));
|
|
91
|
+
}, acquireTimeoutMs);
|
|
92
|
+
});
|
|
93
|
+
},
|
|
94
|
+
leave() {
|
|
95
|
+
const next = waiting.values().next();
|
|
96
|
+
if (next.done === true) {
|
|
97
|
+
out = Math.max(0, out - 1);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
// Handed over: `out` does not move, the slot changes hands.
|
|
101
|
+
waiting.delete(next.value);
|
|
102
|
+
next.value.disarm();
|
|
103
|
+
next.value.settle(true);
|
|
104
|
+
},
|
|
105
|
+
handOverWaiting() {
|
|
106
|
+
for (const waiter of waiting) {
|
|
107
|
+
waiter.disarm();
|
|
108
|
+
waiter.settle(false);
|
|
109
|
+
}
|
|
110
|
+
waiting.clear();
|
|
111
|
+
},
|
|
112
|
+
refuseWaiting(reason) {
|
|
113
|
+
for (const waiter of waiting) {
|
|
114
|
+
waiter.disarm();
|
|
115
|
+
waiter.refuse(reason);
|
|
116
|
+
}
|
|
117
|
+
waiting.clear();
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|