@ultimat3/db 15.0.0 → 17.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 +249 -22
- package/README.md +3 -2
- package/package.json +2 -2
- package/src/bun-sql.ts +32 -0
- package/src/client.ts +14 -232
- package/src/connection-url.ts +60 -0
- package/src/db-health.ts +31 -0
- package/src/default-client.ts +2 -1
- package/src/dependent-view.ts +224 -0
- package/src/drift-errors.ts +36 -0
- package/src/drift-findings.ts +63 -6
- package/src/drift-fixtures.ts +23 -0
- package/src/errors.ts +2 -109
- package/src/foreign-key-plan.ts +35 -14
- package/src/foreign-key.ts +33 -0
- package/src/generate.ts +36 -48
- package/src/generated-column.ts +31 -6
- package/src/index-ddl.ts +51 -0
- package/src/index-plan.ts +119 -0
- package/src/index.ts +24 -21
- package/src/migrate.ts +30 -12
- package/src/migration-errors.ts +132 -0
- package/src/pool-profile.ts +125 -0
- package/src/pool-reserve.ts +50 -0
- package/src/readonly-query.ts +21 -8
- package/src/replica-client.ts +16 -3
- package/src/retype-keys.ts +139 -0
- package/src/sql-type.ts +35 -0
- package/src/sql.ts +64 -10
package/src/generated-column.ts
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
import { assert } from '@ultimat3/core';
|
|
7
7
|
import type { ColumnDescriptionLike } from './entity-shape';
|
|
8
8
|
import type { Plan } from './foreign-key-plan';
|
|
9
|
-
import type { ColumnDescription } from './introspect';
|
|
9
|
+
import type { ColumnDescription, TableDescription } from './introspect';
|
|
10
|
+
import type { MovedAside } from './retype-dependents';
|
|
11
|
+
import { moveDependentsAside } from './retype-dependents';
|
|
10
12
|
import { identifier } from './sql';
|
|
11
13
|
|
|
12
14
|
/** How a column that moved was brought back into line — what the caller has to do next, if anything. */
|
|
@@ -68,14 +70,26 @@ export const isGenerated = (column: ColumnDescriptionLike): boolean =>
|
|
|
68
70
|
* plain → generated (there is no `set expression` for a column that has none) and a column whose
|
|
69
71
|
* recorded expression is unknown. `drop expression` is the one that HAS a statement, and it is the
|
|
70
72
|
* generated → plain direction, which keeps the values it computed.
|
|
73
|
+
*
|
|
74
|
+
* **The rebuild moves the column's dependents aside first, and it is `retype-dependents.ts` that
|
|
75
|
+
* says which — never a second answer written here.** The `rebuilt`
|
|
76
|
+
* set `diffTable` carries into its index loop is keyed on an index's COLUMNS, so a partial index
|
|
77
|
+
* whose `where` names this column and whose key columns do not was dropped with the column by
|
|
78
|
+
* `drop column` and re-created by nothing: measured, the table came back with the index gone, the
|
|
79
|
+
* snapshot still recording it, and `down` unable to restore it. An invariant's CHECK reading the
|
|
80
|
+
* column is the same loss one arm over. `moveDependentsAside` drops each of them explicitly,
|
|
81
|
+
* restores them in `down`, and puts the name in `moved` — which is what makes the ordinary diff
|
|
82
|
+
* CREATE the declared one instead of comparing a definition that never moved.
|
|
71
83
|
*/
|
|
72
84
|
export function regenerate(
|
|
73
|
-
|
|
85
|
+
live: TableDescription,
|
|
74
86
|
column: ColumnDescriptionLike,
|
|
75
87
|
wantedType: string,
|
|
76
88
|
recorded: ColumnDescription,
|
|
77
89
|
plan: Plan,
|
|
90
|
+
moved: MovedAside,
|
|
78
91
|
): Regeneration {
|
|
92
|
+
const table = live.name;
|
|
79
93
|
const wanted = column.generated ?? null;
|
|
80
94
|
const held = recorded.generated ?? null;
|
|
81
95
|
if (wanted === null && held === null) return 'unchanged';
|
|
@@ -89,6 +103,7 @@ export function regenerate(
|
|
|
89
103
|
// column again. Reported as `rebuilt` so the caller can put the indexes back — an `add column`
|
|
90
104
|
// implies none of them.
|
|
91
105
|
if (held === null) {
|
|
106
|
+
moveDependentsAside(live, column.column, plan, moved);
|
|
92
107
|
const dropColumn = `alter table ${identifier(table).text} drop column ${identifier(column.column).text};`;
|
|
93
108
|
plan.up.push(
|
|
94
109
|
dropColumn,
|
|
@@ -104,16 +119,26 @@ export function regenerate(
|
|
|
104
119
|
);
|
|
105
120
|
return 'rebuilt';
|
|
106
121
|
}
|
|
107
|
-
let
|
|
122
|
+
let changed = false;
|
|
123
|
+
// NOT `moveDependentsAside`, and the reason is measured rather than assumed. This ALTER trips the
|
|
124
|
+
// same `42883` (`operator does not exist: text > integer`, on a generated `integer` column under
|
|
125
|
+
// `where (doubled > 0)`) — but moving the index aside only relocates the failure to the
|
|
126
|
+
// `create index` that puts it back, because a predicate whose operator the NEW type has no
|
|
127
|
+
// resolution for cannot be written either. The plain path's dependents survive precisely because
|
|
128
|
+
// an untyped literal re-resolves (`status = 'published'` under an enum and under `text`), and a
|
|
129
|
+
// generated column reaching that shape needs its EXPRESSION changed in the same migration, which
|
|
130
|
+
// `regenerate` emits AFTER this statement. Left open deliberately, with the failure named.
|
|
108
131
|
if (recorded.dataType !== wantedType) {
|
|
109
132
|
plan.up.push(`${alterColumn(table, column.column)} type ${wantedType};`);
|
|
110
133
|
plan.down.push(`${alterColumn(table, column.column)} type ${recorded.dataType};`);
|
|
111
|
-
|
|
134
|
+
changed = true;
|
|
112
135
|
}
|
|
136
|
+
// Nothing moves for this one either, and here it is free: `set expression` recomputes every value
|
|
137
|
+
// and leaves the type alone, so nothing compiled against the type has anything to recompile.
|
|
113
138
|
if (held !== wanted) {
|
|
114
139
|
plan.up.push(`${alterColumn(table, column.column)} set expression as (${wanted});`);
|
|
115
140
|
plan.down.push(`${alterColumn(table, column.column)} set expression as (${held});`);
|
|
116
|
-
|
|
141
|
+
changed = true;
|
|
117
142
|
}
|
|
118
|
-
return
|
|
143
|
+
return changed ? 'altered' : 'unchanged';
|
|
119
144
|
}
|
package/src/index-ddl.ts
CHANGED
|
@@ -84,6 +84,57 @@ export function createIndex(table: string, index: IndexDescriptionLike): string
|
|
|
84
84
|
/** `drop index "n";` — the one spelling, so a drop and its recreate cannot name it differently. */
|
|
85
85
|
export const dropIndex = (name: string): string => `drop index ${identifier(name).text};`;
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Whether Postgres could be backing this RECORDED index with a UNIQUE constraint rather than
|
|
89
|
+
* holding it as an index of its own — which decides the only question `dropIndex` cannot answer.
|
|
90
|
+
*
|
|
91
|
+
* A UNIQUE constraint's index is unique, total, unordered and btree; `add constraint … unique`
|
|
92
|
+
* and a `unique` column clause can produce nothing else. So an index missing any one of those is
|
|
93
|
+
* provably an index, and `drop index` on it is right. Everything else is genuinely ambiguous —
|
|
94
|
+
* see `dropRecordedIndex`.
|
|
95
|
+
*/
|
|
96
|
+
export function mayBeConstraintBacked(index: IndexDescription): boolean {
|
|
97
|
+
return (
|
|
98
|
+
index.unique &&
|
|
99
|
+
!index.primary &&
|
|
100
|
+
index.where === null &&
|
|
101
|
+
index.order === null &&
|
|
102
|
+
indexMethodOf(index) === 'btree'
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Remove a RECORDED index whose kind this generator cannot know, in statements that are correct
|
|
108
|
+
* on both databases it cannot tell apart.
|
|
109
|
+
*
|
|
110
|
+
* `TableDescription` carries no discriminator, and it cannot be given one that would help: the
|
|
111
|
+
* SAME declaration reaches the server as a CONSTRAINT or as an INDEX depending on which migration
|
|
112
|
+
* created it. A `unique` column on a table `createTable` writes emits `create table … slug text
|
|
113
|
+
* unique`, and Postgres backs that with a constraint named `posts_slug_key`; the same column
|
|
114
|
+
* gaining `unique` later takes `diffTable`'s `create unique index "posts_slug_key"` and is a plain
|
|
115
|
+
* index. `snapshotOf` records both as `{ unique: true, primary: false }`, and every sidecar already
|
|
116
|
+
* on disk was written that way — a new field could not classify one of them retroactively.
|
|
117
|
+
*
|
|
118
|
+
* Measured on 18.4 (`index-removal.live.test.ts`), which is why the pair and not a guess:
|
|
119
|
+
*
|
|
120
|
+
* | statement | on a constraint's index | on a plain index |
|
|
121
|
+
* |--------------------------------------------|-------------------------|------------------|
|
|
122
|
+
* | `drop index "n"` | **2BP01** | ok |
|
|
123
|
+
* | `drop index if exists "n"` | **2BP01** — not suppressed | ok |
|
|
124
|
+
* | `alter table … drop constraint if exists` | drops it, index and all | notice, no-op |
|
|
125
|
+
*
|
|
126
|
+
* Constraint first, then the index: reversed, the `drop index` reaches a constraint's index and is
|
|
127
|
+
* the 2BP01 this exists to avoid. Both halves carry `if exists`, so whichever one did nothing says
|
|
128
|
+
* so with a notice rather than 42704.
|
|
129
|
+
*/
|
|
130
|
+
export function dropRecordedIndex(table: string, index: IndexDescription): readonly string[] {
|
|
131
|
+
if (!mayBeConstraintBacked(index)) return [dropIndex(index.name)];
|
|
132
|
+
return [
|
|
133
|
+
`alter table ${identifier(table).text} drop constraint if exists ${identifier(index.name).text};`,
|
|
134
|
+
`drop index if exists ${identifier(index.name).text};`,
|
|
135
|
+
];
|
|
136
|
+
}
|
|
137
|
+
|
|
87
138
|
/**
|
|
88
139
|
* A RECORDED index as a declaration this generator can emit again — `declaredMethod`, never a
|
|
89
140
|
* cast: the recorded side is typed open because the catalog shares the shape, and a method this
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Single responsibility: which indexes an existing table gains, has rebuilt, or LOSES. Split out
|
|
2
|
+
// of `generate.ts` along the seam `check-ddl.ts` and `index-ddl.ts` already drew — `generate.ts`
|
|
3
|
+
// assembles a plan, `index-ddl.ts` writes the statements, and this file decides which of them go
|
|
4
|
+
// in. `checkPlan` is its shape, deliberately: "what does the record hold that the declaration does
|
|
5
|
+
// not" is one question, and a fourth spelling of it is the split axiom 1 refuses.
|
|
6
|
+
//
|
|
7
|
+
// The removal arm is why this file exists. `diffTable` walked `declaredIndexes(entity)` and matched
|
|
8
|
+
// by name, with no reverse pass, so an index the entities stopped declaring stayed on the database
|
|
9
|
+
// forever while the sidecar beside it stopped recording it — `examples/dummy` carried
|
|
10
|
+
// `member_unique_per_org`, `members_tz_idx` and `post_slug_unique_per_org` through every
|
|
11
|
+
// regeneration, and `x verify`'s drift step was green over all three because drift judges the
|
|
12
|
+
// declared side. The same defect `foreignKeyPlan` closed on 2026-08-19, one arm over.
|
|
13
|
+
//
|
|
14
|
+
// KNOWN LIMIT, named rather than half-built: a UNIQUE index that a foreign key on ANOTHER table
|
|
15
|
+
// still references cannot be dropped (2BP01), and this arm sees one table at a time. That
|
|
16
|
+
// declaration is already broken — the key has nothing to point at — and the failure arrives with
|
|
17
|
+
// the server's own words naming both ends.
|
|
18
|
+
|
|
19
|
+
import type { EntityDescriptionLike } from './entity-shape';
|
|
20
|
+
import type { Plan } from './foreign-key-plan';
|
|
21
|
+
import {
|
|
22
|
+
asDeclared,
|
|
23
|
+
createIndex,
|
|
24
|
+
dropIndex,
|
|
25
|
+
dropRecordedIndex,
|
|
26
|
+
impliedByColumnClause,
|
|
27
|
+
redefineIndex,
|
|
28
|
+
} from './index-ddl';
|
|
29
|
+
import type { TableDescription } from './introspect';
|
|
30
|
+
import { declaredIndexes } from './invariant-ddl';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* What the rest of this migration has already done to the columns underneath the indexes — every
|
|
34
|
+
* field is a set of names some other arm produced, and each answers "this index is already gone".
|
|
35
|
+
*/
|
|
36
|
+
export interface IndexPlanContext {
|
|
37
|
+
/** Columns this migration ADDS, whose own `unique` clause brings an index Postgres names. */
|
|
38
|
+
readonly added: ReadonlySet<string>;
|
|
39
|
+
/** Columns `regenerate` dropped and re-added outright — every index over one went with it. */
|
|
40
|
+
readonly rebuilt: ReadonlySet<string>;
|
|
41
|
+
/** Indexes a retype already dropped ahead of its ALTER (`moveDependentsAside`). */
|
|
42
|
+
readonly moved: ReadonlySet<string>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Which indexes an existing table gains, has rebuilt, or loses.
|
|
47
|
+
*
|
|
48
|
+
* Declared first and removed last, the order `checkPlan` uses. Both orders are safe — two indexes
|
|
49
|
+
* over the same columns may coexist for the length of one migration — so the tie goes to the file
|
|
50
|
+
* this one is a copy of.
|
|
51
|
+
*
|
|
52
|
+
* `down` is pushed FORWARDS and read backwards, because assembly reverses it: the restore of a
|
|
53
|
+
* removed index therefore lands after the drop of everything created beside it.
|
|
54
|
+
*/
|
|
55
|
+
export function indexPlan(
|
|
56
|
+
entity: EntityDescriptionLike,
|
|
57
|
+
live: TableDescription,
|
|
58
|
+
plan: Plan,
|
|
59
|
+
context: IndexPlanContext,
|
|
60
|
+
): void {
|
|
61
|
+
const indexed = new Map(live.indexes.map((index) => [index.name, index]));
|
|
62
|
+
const declared = new Set<string>();
|
|
63
|
+
for (const index of declaredIndexes(entity)) {
|
|
64
|
+
declared.add(index.name);
|
|
65
|
+
const recorded = indexed.get(index.name);
|
|
66
|
+
// A rebuilt column took its indexes down with it, and a retype dropped the ones whose
|
|
67
|
+
// predicate it could not survive — either way this one is CREATED rather than compared:
|
|
68
|
+
// `redefineIndex` sees a definition that never moved and would emit nothing at all.
|
|
69
|
+
const gone =
|
|
70
|
+
context.moved.has(index.name) || index.columns.some((each) => context.rebuilt.has(each));
|
|
71
|
+
if (recorded !== undefined && !gone) {
|
|
72
|
+
redefineIndex(entity.table, index, recorded, plan);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
// `added` only: an index over a column that was already there is implied by no clause this
|
|
76
|
+
// migration emits, so it still needs a statement of its own.
|
|
77
|
+
if (impliedByColumnClause(entity, index, context.added)) continue;
|
|
78
|
+
plan.up.push(createIndex(entity.table, index));
|
|
79
|
+
// The plain drop, always: this migration CREATED it, with `create index`, so it is an index
|
|
80
|
+
// and never a constraint's — `dropRecordedIndex`'s ambiguity is about the recorded side only.
|
|
81
|
+
plan.down.push(dropIndex(index.name));
|
|
82
|
+
}
|
|
83
|
+
removeUndeclared(entity, live, plan, context, declared);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Every recorded index this entity no longer declares, dropped — and restored in `down` from what
|
|
88
|
+
* the SNAPSHOT recorded, never from what the entity declares, since the entity is precisely what
|
|
89
|
+
* stopped describing it. The rule the retype path already states.
|
|
90
|
+
*
|
|
91
|
+
* Four names are skipped, and each one is a statement Postgres would refuse or repeat:
|
|
92
|
+
*
|
|
93
|
+
* | skipped | because |
|
|
94
|
+
* |------------------------------------------|---------|
|
|
95
|
+
* | `primary` | `drop index` on a primary key's index is 2BP01; the key is `TableDescription.primaryKey`, a different question |
|
|
96
|
+
* | already in `context.moved` | a retype dropped it ahead of the ALTER — a second drop is 42704 |
|
|
97
|
+
* | over a column in `context.rebuilt` | it went with the `drop column` half of `regenerate` — 42704 |
|
|
98
|
+
* | over a column this migration DROPS | `alter table … drop column` takes it, so a drop beside it says nothing new. The rule `foreignKeyPlan` applies to a constraint on a dropped column |
|
|
99
|
+
*
|
|
100
|
+
* A doomed TABLE needs no arm: `generate.ts` only reaches a diff for a table an entity still
|
|
101
|
+
* declares, so `drop table` and this function never meet.
|
|
102
|
+
*/
|
|
103
|
+
function removeUndeclared(
|
|
104
|
+
entity: EntityDescriptionLike,
|
|
105
|
+
live: TableDescription,
|
|
106
|
+
plan: Plan,
|
|
107
|
+
context: IndexPlanContext,
|
|
108
|
+
declared: ReadonlySet<string>,
|
|
109
|
+
): void {
|
|
110
|
+
const columns = new Set(entity.columns.map((column) => column.column));
|
|
111
|
+
for (const recorded of live.indexes) {
|
|
112
|
+
if (recorded.primary || declared.has(recorded.name)) continue;
|
|
113
|
+
if (context.moved.has(recorded.name)) continue;
|
|
114
|
+
if (recorded.columns.some((column) => context.rebuilt.has(column))) continue;
|
|
115
|
+
if (!recorded.columns.every((column) => columns.has(column))) continue;
|
|
116
|
+
plan.up.push(...dropRecordedIndex(live.name, recorded));
|
|
117
|
+
plan.down.push(createIndex(live.name, asDeclared(recorded)));
|
|
118
|
+
}
|
|
119
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -23,25 +23,15 @@ export {
|
|
|
23
23
|
export type {
|
|
24
24
|
DbClient,
|
|
25
25
|
DbConnection,
|
|
26
|
-
DbHealthReport,
|
|
27
|
-
PoolProfile,
|
|
28
26
|
PostgresClient,
|
|
29
27
|
PostgresClientOptions,
|
|
30
28
|
ReservableClient,
|
|
31
29
|
} from './client';
|
|
32
|
-
export {
|
|
33
|
-
baseClient,
|
|
34
|
-
checkDb,
|
|
35
|
-
createPostgresClient,
|
|
36
|
-
db,
|
|
37
|
-
isReservable,
|
|
38
|
-
POOL_MAX_ENV,
|
|
39
|
-
POOL_PROFILES,
|
|
40
|
-
poolProfileFor,
|
|
41
|
-
setDbClient,
|
|
42
|
-
} from './client';
|
|
30
|
+
export { baseClient, createPostgresClient, db, isReservable, setDbClient } from './client';
|
|
43
31
|
export type { ColumnDefaultLike } from './column-default';
|
|
44
32
|
export { defaultExpression } from './column-default';
|
|
33
|
+
export type { DbHealthReport } from './db-health';
|
|
34
|
+
export { checkDb } from './db-health';
|
|
45
35
|
export { defaultClient, REPLICA_URL_ENV } from './default-client';
|
|
46
36
|
export type { DestructiveKind, DestructiveStatement } from './destructive';
|
|
47
37
|
export {
|
|
@@ -62,6 +52,7 @@ export {
|
|
|
62
52
|
expectedSchema,
|
|
63
53
|
FRAMEWORK_TABLE_PREFIX,
|
|
64
54
|
} from './drift';
|
|
55
|
+
export { dbDrift } from './drift-errors';
|
|
65
56
|
export type {
|
|
66
57
|
ColumnDescriptionLike,
|
|
67
58
|
EntityDescriptionLike,
|
|
@@ -76,20 +67,13 @@ export {
|
|
|
76
67
|
DB_ERROR_RETRY,
|
|
77
68
|
DB_ERROR_TITLES,
|
|
78
69
|
DbError,
|
|
79
|
-
dbDrift,
|
|
80
70
|
dbNotImplemented,
|
|
81
71
|
dbUnavailable,
|
|
82
72
|
driverError,
|
|
83
73
|
identifierUnsafe,
|
|
84
|
-
migrateConcurrent,
|
|
85
|
-
migrationConflict,
|
|
86
|
-
migrationDestructive,
|
|
87
|
-
migrationIrreversible,
|
|
88
|
-
migrationSnapshotMissing,
|
|
89
74
|
multipleStatements,
|
|
90
75
|
poolAcquireTimeout,
|
|
91
76
|
poolMaxInvalid,
|
|
92
|
-
rollbackStepsInvalid,
|
|
93
77
|
serializationExhausted,
|
|
94
78
|
sqlUnsafe,
|
|
95
79
|
} from './errors';
|
|
@@ -147,6 +131,15 @@ export {
|
|
|
147
131
|
rollback,
|
|
148
132
|
runningAppVersion,
|
|
149
133
|
} from './migrate';
|
|
134
|
+
export {
|
|
135
|
+
migrateConcurrent,
|
|
136
|
+
migrationConflict,
|
|
137
|
+
migrationDestructive,
|
|
138
|
+
migrationIrreversible,
|
|
139
|
+
migrationSnapshotMissing,
|
|
140
|
+
migrationViewDepends,
|
|
141
|
+
rollbackStepsInvalid,
|
|
142
|
+
} from './migration-errors';
|
|
150
143
|
export type { StatementAttribution, StatementEvent, StatementObserver } from './observe';
|
|
151
144
|
export { setStatementObserver, statementObserver } from './observe';
|
|
152
145
|
export type {
|
|
@@ -166,6 +159,8 @@ export {
|
|
|
166
159
|
} from './pglite';
|
|
167
160
|
export type { PgliteBranchInfo, PgliteBranchOptions } from './pglite-branch';
|
|
168
161
|
export { branchPglite, pgliteBranchDir } from './pglite-branch';
|
|
162
|
+
export type { PoolProfile } from './pool-profile';
|
|
163
|
+
export { POOL_MAX_ENV, POOL_PROFILES, poolProfileFor } from './pool-profile';
|
|
169
164
|
export type { ReadOnlyQueryOptions, ReadOnlyQueryResult } from './readonly-query';
|
|
170
165
|
export { READONLY_TIMEOUT_MS, readOnlyQuery } from './readonly-query';
|
|
171
166
|
export type { ReadOnlyRoleOptions } from './readonly-role';
|
|
@@ -182,7 +177,15 @@ export { markScopeWrote, replicaScope, withReplicaReads } from './replica-scope'
|
|
|
182
177
|
export { snapshotJson } from './snapshot-json';
|
|
183
178
|
export { parseSnapshot } from './snapshot-parse';
|
|
184
179
|
export type { SqlFragment } from './sql';
|
|
185
|
-
export {
|
|
180
|
+
export {
|
|
181
|
+
identifier,
|
|
182
|
+
isSqlFragment,
|
|
183
|
+
join,
|
|
184
|
+
literal,
|
|
185
|
+
raw,
|
|
186
|
+
shellInertIdentifier,
|
|
187
|
+
sql,
|
|
188
|
+
} from './sql';
|
|
186
189
|
export { stripSqlNoise } from './sql-noise';
|
|
187
190
|
export type { DbSqlStateCode } from './sqlstate';
|
|
188
191
|
export { DB_SQLSTATE_CODES, isRetryableState, SQLSTATE, sqlState, sqlStateCode } from './sqlstate';
|
package/src/migrate.ts
CHANGED
|
@@ -3,17 +3,13 @@
|
|
|
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 { appVersion } from '@ultimat3/core';
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
type DbClient,
|
|
10
|
-
type DbConnection,
|
|
11
|
-
isReservable,
|
|
12
|
-
poolProfileFor,
|
|
13
|
-
} from './client';
|
|
14
|
-
import { migrateConcurrent, migrationConflict, rollbackStepsInvalid } from './errors';
|
|
6
|
+
import { appVersion, finiteCount } from '@ultimat3/core';
|
|
7
|
+
import { baseClient, type DbClient, type DbConnection, isReservable } from './client';
|
|
8
|
+
import { refuseDependentViews } from './dependent-view';
|
|
15
9
|
import { expectedQueryLoop } from './expected-loop';
|
|
16
10
|
import type { SchemaDescription } from './introspect';
|
|
11
|
+
import { migrateConcurrent, migrationConflict, rollbackStepsInvalid } from './migration-errors';
|
|
12
|
+
import { poolProfileFor } from './pool-profile';
|
|
17
13
|
import { raw, sql } from './sql';
|
|
18
14
|
import { SQLSTATE, sqlState } from './sqlstate';
|
|
19
15
|
import { statementsOf } from './statement-split';
|
|
@@ -236,6 +232,9 @@ async function acquireLock(session: DbClient, waitMs: number): Promise<void> {
|
|
|
236
232
|
sql`select pg_try_advisory_lock(${MIGRATION_LOCK_KEY}) as locked`,
|
|
237
233
|
);
|
|
238
234
|
if (row?.locked === true) return;
|
|
235
|
+
// `waitMs` is screened at both call sites: `NaN - elapsed` is `NaN`, `NaN <= 0` is false
|
|
236
|
+
// and `Bun.sleep(Math.min(POLL, NaN))` does not sleep — so an unbounded wait is not what a
|
|
237
|
+
// non-finite one produced, a tight spin re-taking `pg_try_advisory_lock` was.
|
|
239
238
|
const remaining = waitMs - (performance.now() - started);
|
|
240
239
|
if (remaining <= 0) {
|
|
241
240
|
throw migrateConcurrent(MIGRATION_LOCK_KEY, Math.round(performance.now() - started));
|
|
@@ -316,7 +315,17 @@ async function setLockTimeout(tx: DbTx, lockTimeoutMs: number): Promise<void> {
|
|
|
316
315
|
* same `ACCESS EXCLUSIVE` locks against the same tables.
|
|
317
316
|
*/
|
|
318
317
|
function migrationLockTimeoutMs(explicit: number | undefined): number {
|
|
319
|
-
|
|
318
|
+
// Screened here, where BOTH `migrate()` and `rollback()` resolve it, and before either takes the
|
|
319
|
+
// advisory lock. `lockWaitMs` beside it was screened from the start and this one was not, so
|
|
320
|
+
// `Number(process.env.…)` on an unset variable travelled all the way to `SET LOCAL lock_timeout
|
|
321
|
+
// = NaN`, which the server rejects inside the migration's own transaction — the option that was
|
|
322
|
+
// wrong appears nowhere in what the deploy prints. `finiteCount`, never a second bound checker.
|
|
323
|
+
return finiteCount(
|
|
324
|
+
'migrate',
|
|
325
|
+
'lockTimeoutMs',
|
|
326
|
+
explicit ?? poolProfileFor('migrate').lockTimeoutMs,
|
|
327
|
+
0,
|
|
328
|
+
);
|
|
320
329
|
}
|
|
321
330
|
|
|
322
331
|
export async function migrate(options: MigrateOptions): Promise<MigrationReport> {
|
|
@@ -328,7 +337,7 @@ export async function migrate(options: MigrateOptions): Promise<MigrationReport>
|
|
|
328
337
|
return withAdvisoryLock(
|
|
329
338
|
client,
|
|
330
339
|
options.lock !== false,
|
|
331
|
-
options.lockWaitMs ?? MIGRATION_LOCK_WAIT_MS,
|
|
340
|
+
finiteCount('migrate', 'lockWaitMs', options.lockWaitMs ?? MIGRATION_LOCK_WAIT_MS, 0),
|
|
332
341
|
async (session) => {
|
|
333
342
|
await ensureLedger(session);
|
|
334
343
|
const ledger = await readLedger(session);
|
|
@@ -348,6 +357,12 @@ export async function migrate(options: MigrateOptions): Promise<MigrationReport>
|
|
|
348
357
|
await withTransaction(
|
|
349
358
|
async (tx) => {
|
|
350
359
|
await setLockTimeout(tx, lockTimeoutMs);
|
|
360
|
+
// Before the first statement, never after the failure: a view compiled against a
|
|
361
|
+
// column this script retypes is `0A000` from the server with the view named only in
|
|
362
|
+
// a DETAIL field nothing prints, surfaced as "cannot reach the database". Costs one
|
|
363
|
+
// text scan and no round trip on a migration that retypes nothing, which is nearly
|
|
364
|
+
// all of them (`dependent-view.ts`).
|
|
365
|
+
await refuseDependentViews(tx, migration.up);
|
|
351
366
|
await applyScript(tx, migration.up);
|
|
352
367
|
const durationMs = Math.round(performance.now() - at);
|
|
353
368
|
await tx.execute(sql`
|
|
@@ -412,7 +427,7 @@ export async function rollback(options: RollbackOptions): Promise<readonly strin
|
|
|
412
427
|
return withAdvisoryLock(
|
|
413
428
|
client,
|
|
414
429
|
options.lock !== false,
|
|
415
|
-
options.lockWaitMs ?? MIGRATION_LOCK_WAIT_MS,
|
|
430
|
+
finiteCount('migrate', 'lockWaitMs', options.lockWaitMs ?? MIGRATION_LOCK_WAIT_MS, 0),
|
|
416
431
|
async (session) => {
|
|
417
432
|
const ledger = await readLedger(session);
|
|
418
433
|
const targets = [...ledger].reverse().slice(0, steps);
|
|
@@ -438,6 +453,9 @@ export async function rollback(options: RollbackOptions): Promise<readonly strin
|
|
|
438
453
|
await withTransaction(
|
|
439
454
|
async (tx) => {
|
|
440
455
|
await setLockTimeout(tx, lockTimeoutMs);
|
|
456
|
+
// Both directions: a reversal retypes the same column back, and a view created
|
|
457
|
+
// since the migration applied blocks it exactly as one created before would.
|
|
458
|
+
await refuseDependentViews(tx, migration.down);
|
|
441
459
|
await applyScript(tx, migration.down);
|
|
442
460
|
await tx.execute(sql`delete from ${raw(LEDGER_TABLE)} where id = ${row.id}`);
|
|
443
461
|
},
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Single responsibility: the refusals a MIGRATION earns — the lock it could not take, a ledger that
|
|
2
|
+
// disagrees with this build, a plan that cannot be reversed, a plan that destroys rows without
|
|
3
|
+
// saying so, a snapshot that was never written, and a view standing in a retype's way. Split out of
|
|
4
|
+
// `errors.ts` only because that file reached the 500-line ceiling, exactly as `invariant-errors.ts`
|
|
5
|
+
// was: every code below is still declared, titled and registered there, and `DbError` is still the
|
|
6
|
+
// one class. One direction only — nothing here is imported back.
|
|
7
|
+
|
|
8
|
+
import { DESTRUCTIVE_CAUSE, DESTRUCTIVE_MARKER, type DestructiveStatement } from './destructive';
|
|
9
|
+
import { DbError } from './errors';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The migration advisory lock was still held when the wait ran out. `pg_advisory_lock` blocks with
|
|
13
|
+
* no timeout, so a migrator wedged on a partition — or OOM-killed with its backend still alive —
|
|
14
|
+
* left `helm upgrade --wait` sitting inside one statement, printing nothing, with the job never
|
|
15
|
+
* failing so `backoffLimit` never fired. A bounded `pg_try_advisory_lock` poll turns that into an
|
|
16
|
+
* exit code.
|
|
17
|
+
*/
|
|
18
|
+
export const migrateConcurrent = (lockKey: number, waitedMs: number): DbError =>
|
|
19
|
+
new DbError({
|
|
20
|
+
code: 'X_MIGRATE_CONCURRENT',
|
|
21
|
+
cause:
|
|
22
|
+
`another session still holds pg_advisory_lock(${lockKey}) after waiting ${waitedMs}ms, ` +
|
|
23
|
+
'so this migrator refused rather than block a deploy forever',
|
|
24
|
+
fix:
|
|
25
|
+
'psql "$DATABASE_URL" -c "select pid, application_name, state from pg_stat_activity ' +
|
|
26
|
+
"join pg_locks using (pid) where locktype = 'advisory'\"" +
|
|
27
|
+
' # pg_terminate_backend(pid) the wedged migrator, then: x db migrate',
|
|
28
|
+
meta: { lockKey, waitedMs },
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export const migrationConflict = (cause: string, fix: string): DbError =>
|
|
32
|
+
new DbError({ code: 'X_MIGRATION_CONFLICT', cause, fix });
|
|
33
|
+
|
|
34
|
+
export const migrationIrreversible = (cause: string, fix: string): DbError =>
|
|
35
|
+
new DbError({ code: 'X_MIGRATION_IRREVERSIBLE', cause, fix });
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A view standing in the way of a retype, refused one statement before Postgres would have.
|
|
39
|
+
*
|
|
40
|
+
* `restore` is the caller's, the way `migrationIrreversible`'s `fix:` is: it is a pair of
|
|
41
|
+
* `psql "$DATABASE_URL" -c '…'` invocations — the shape `migrationConflict` above already writes —
|
|
42
|
+
* built out of live catalog values through `identifier()`. This file may not import `sql.ts`: that
|
|
43
|
+
* module imports `identifierUnsafe` from here, and an import cycle around the module whose
|
|
44
|
+
* evaluation REGISTERS every code is not a cycle worth having for one quoted name.
|
|
45
|
+
*/
|
|
46
|
+
export const migrationViewDepends = (
|
|
47
|
+
view: string,
|
|
48
|
+
table: string,
|
|
49
|
+
column: string,
|
|
50
|
+
restore: string,
|
|
51
|
+
): DbError =>
|
|
52
|
+
new DbError({
|
|
53
|
+
code: 'X_MIGRATION_VIEW_DEPENDS',
|
|
54
|
+
cause:
|
|
55
|
+
`view "${view}" is compiled against "${table}"."${column}", which this migration retypes; ` +
|
|
56
|
+
'Postgres answers 0A000 and rolls the whole migration back',
|
|
57
|
+
fix: restore,
|
|
58
|
+
meta: { view, table, column },
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A rollback step count this build cannot honour. `steps` reaches `Array.prototype.slice`, where a
|
|
63
|
+
* negative count counts from the END: `steps: -1` selected every applied migration except the
|
|
64
|
+
* newest and reversed four of five, which is the one class of mistake a rollback cannot undo.
|
|
65
|
+
* Refused rather than coerced, exactly as `DATABASE_POOL_MAX` is — a number silently reinterpreted
|
|
66
|
+
* as a different one is the failure a validated argument exists to prevent.
|
|
67
|
+
*/
|
|
68
|
+
export const rollbackStepsInvalid = (received: number): DbError =>
|
|
69
|
+
new DbError({
|
|
70
|
+
code: 'X_INVARIANT',
|
|
71
|
+
cause: `rollback was asked to reverse ${String(received)} migrations, which is not a positive integer`,
|
|
72
|
+
fix: 'rollback({ migrations, steps: 1 }) # a whole number of migrations, newest first',
|
|
73
|
+
meta: { steps: received },
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* `packages/db/migrations/0000_initial.snapshot.json` → `packages/db/migrations/0000_initial.*` —
|
|
78
|
+
* every file that one migration owns, as one `rm` argument. Derived from the path the caller passed
|
|
79
|
+
* rather than rebuilt from a directory this package does not know: `db` is tier 1 and where an app
|
|
80
|
+
* keeps its migrations is `@ultimat3/cli`'s answer, not this one's.
|
|
81
|
+
*/
|
|
82
|
+
const snapshotSiblings = (file: string): string => file.replace(/\.snapshot\.json$/, '.*');
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* `20260817120000_add_posts` → `add_posts`, the argument `x db gen` takes. The name is free text
|
|
86
|
+
* and only ever labels a *new* id, so an id carrying no stamp answers with itself rather than with
|
|
87
|
+
* the empty string — a `fix:` ending in `x db gen ""` is a command that cannot be run.
|
|
88
|
+
*/
|
|
89
|
+
const migrationNameOf = (id: string): string => id.replace(/^\d+_/, '') || id;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The sidecar every generated migration writes is what the *next* generation diffs against, so a
|
|
93
|
+
* newest migration without one leaves nothing to diff. Refused rather than defaulted to the empty
|
|
94
|
+
* schema, which would generate `create table` for every table the database already holds.
|
|
95
|
+
*/
|
|
96
|
+
export const migrationSnapshotMissing = (id: string, file: string): DbError =>
|
|
97
|
+
new DbError({
|
|
98
|
+
code: 'X_MIGRATION_SNAPSHOT_MISSING',
|
|
99
|
+
cause: `migration "${id}" records no schema snapshot (${file}), so there is nothing to diff against`,
|
|
100
|
+
// Two remedies, both commands, in the order they are safe to try. "restore from version
|
|
101
|
+
// control" alone was neither: on a scaffolded app the sidecar was never written, so there is
|
|
102
|
+
// nothing to restore — and the drift this refusal answers named `x db gen` as *its* fix, so
|
|
103
|
+
// the two errors pointed at each other and an app's first migration had no way out.
|
|
104
|
+
// `x db gen` is named only *after* the files it would trip over are gone.
|
|
105
|
+
fix:
|
|
106
|
+
`git checkout -- ${file} # or, if it was never written: ` +
|
|
107
|
+
`rm ${snapshotSiblings(file)} && x db gen "${migrationNameOf(id)}"`,
|
|
108
|
+
meta: { id, file },
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* One error per file, never one per statement: the marker declares the whole migration, so a
|
|
113
|
+
* second finding would repeat an instruction the first already gave. `file` is app-relative and
|
|
114
|
+
* arrives from the caller — `db` is tier 1 and does not know where an app keeps its migrations.
|
|
115
|
+
*
|
|
116
|
+
* Irreversible and destructive are two questions. `X_MIGRATION_IRREVERSIBLE` refuses to *generate*
|
|
117
|
+
* a plan whose `down` cannot restore the rows; this one refuses to *ship* a plan whose `up`
|
|
118
|
+
* destroys them without saying so — a retype is reversible in DDL and still rewrites every row.
|
|
119
|
+
*/
|
|
120
|
+
export const migrationDestructive = (
|
|
121
|
+
file: string,
|
|
122
|
+
first: DestructiveStatement,
|
|
123
|
+
more = 0,
|
|
124
|
+
): DbError =>
|
|
125
|
+
new DbError({
|
|
126
|
+
code: 'X_MIGRATION_DESTRUCTIVE',
|
|
127
|
+
cause:
|
|
128
|
+
`${file} ${DESTRUCTIVE_CAUSE[first.kind]} and does not declare it` +
|
|
129
|
+
`${more === 0 ? '' : ` (and ${more} more destructive)`}: ${first.statement}`,
|
|
130
|
+
fix: `add the line "${DESTRUCTIVE_MARKER}" to ${file}, or regenerate it: x db gen "<name>" --allow-destructive`,
|
|
131
|
+
meta: { file, kind: first.kind, statements: more + 1 },
|
|
132
|
+
});
|