@ultimat3/db 23.0.0 → 24.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 +62 -65
- package/README.md +42 -8
- package/package.json +2 -2
- package/src/array-parameter.ts +38 -1
- package/src/bound-parameters.ts +22 -3
- package/src/catalog-fold.ts +4 -1
- package/src/catalog-objects.ts +29 -0
- package/src/catalog.ts +10 -2
- package/src/commit-tag.ts +21 -0
- package/src/dependent-view.ts +6 -4
- package/src/drift-errors.ts +3 -3
- package/src/drift-findings.ts +209 -59
- package/src/drift.ts +19 -13
- package/src/errors.ts +6 -7
- package/src/foreign-key.ts +0 -34
- package/src/generate.ts +5 -0
- package/src/index.ts +5 -3
- package/src/introspect-catalog.ts +18 -1
- package/src/introspect.ts +45 -8
- package/src/migrate.ts +3 -3
- package/src/object-drift.ts +77 -20
- package/src/pglite-branch.ts +6 -6
- package/src/pglite.ts +13 -1
- package/src/primary-key.ts +180 -0
- package/src/schema-dump-table.ts +4 -1
- package/src/sibling-turn.ts +49 -0
- package/src/sqlstate.ts +30 -10
- package/src/statement-funnel.ts +16 -5
- package/src/transaction-errors.ts +66 -0
- package/src/transaction-options.ts +122 -0
- package/src/transaction.ts +131 -121
package/src/introspect.ts
CHANGED
|
@@ -27,7 +27,11 @@ export interface ColumnDescription {
|
|
|
27
27
|
|
|
28
28
|
export interface IndexDescription {
|
|
29
29
|
readonly name: string;
|
|
30
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* Key columns in **index key order** — the order the planner sorts by, never `attnum`. An
|
|
32
|
+
* EXPRESSION key read from the catalog is its definition in parentheses (`(lower(title))`), in
|
|
33
|
+
* its own position: marked, so it can never be taken for a column, and never dropped.
|
|
34
|
+
*/
|
|
31
35
|
readonly columns: readonly string[];
|
|
32
36
|
readonly unique: boolean;
|
|
33
37
|
readonly primary: boolean;
|
|
@@ -188,17 +192,45 @@ export async function introspect(options: IntrospectOptions = {}): Promise<Schem
|
|
|
188
192
|
...(await nonAppRelations(client, schema)),
|
|
189
193
|
];
|
|
190
194
|
|
|
195
|
+
// The type is `format_type`, as `catalog-relations.ts` reads it: `information_schema.data_type`
|
|
196
|
+
// answers `numeric` for `numeric(12,2)`, `ARRAY` for `text[]` and `USER-DEFINED` for an enum, and
|
|
197
|
+
// `ColumnDescription.dataType` has always been documented as the first of each pair. Still FROM
|
|
198
|
+
// the view, which decides which columns this role may see.
|
|
191
199
|
const columns = await client.query<ColumnRow>(sql`
|
|
192
|
-
select
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
200
|
+
select
|
|
201
|
+
c.table_name,
|
|
202
|
+
c.column_name,
|
|
203
|
+
coalesce(
|
|
204
|
+
(
|
|
205
|
+
select format_type(a.atttypid, a.atttypmod)
|
|
206
|
+
from pg_attribute a
|
|
207
|
+
join pg_class r on r.oid = a.attrelid
|
|
208
|
+
join pg_namespace n on n.oid = r.relnamespace
|
|
209
|
+
where n.nspname = c.table_schema
|
|
210
|
+
and r.relname = c.table_name
|
|
211
|
+
and a.attname = c.column_name
|
|
212
|
+
and a.attnum > 0
|
|
213
|
+
and not a.attisdropped
|
|
214
|
+
),
|
|
215
|
+
c.data_type
|
|
216
|
+
) as data_type,
|
|
217
|
+
c.is_nullable,
|
|
218
|
+
c.column_default,
|
|
219
|
+
c.ordinal_position
|
|
220
|
+
from information_schema.columns c
|
|
221
|
+
where c.table_schema = ${schema}
|
|
222
|
+
order by c.table_name, c.ordinal_position
|
|
196
223
|
`);
|
|
197
224
|
|
|
198
225
|
// Ordered by the index's own key position, never by `attnum`: `indkey` IS the order the planner
|
|
199
226
|
// sorts by, and a composite index on `(created_at, org_id)` whose columns were declared the
|
|
200
227
|
// other way round came back reversed — a description that reads correct and compares wrong.
|
|
201
228
|
// `indnkeyatts` drops INCLUDE payload columns, which are stored, not keyed.
|
|
229
|
+
//
|
|
230
|
+
// A LEFT join on `pg_attribute`: an expression key has `attnum = 0` and no attribute row, so an
|
|
231
|
+
// inner join dropped it and `(id, lower(title))` read back as `(id)` — an index rebuilt by hand
|
|
232
|
+
// with an extra expression key compared equal to the declared one. It is kept in its position
|
|
233
|
+
// and MARKED by its parentheses, which no declared column name carries.
|
|
202
234
|
const indexes = await client.query<IndexRow>(sql`
|
|
203
235
|
select
|
|
204
236
|
t.relname as table_name,
|
|
@@ -207,7 +239,10 @@ export async function introspect(options: IntrospectOptions = {}): Promise<Schem
|
|
|
207
239
|
ix.indisprimary as is_primary,
|
|
208
240
|
pg_get_expr(ix.indpred, ix.indrelid) as predicate,
|
|
209
241
|
am.amname as method,
|
|
210
|
-
array_agg(
|
|
242
|
+
array_agg(
|
|
243
|
+
coalesce(a.attname::text, '(' || pg_get_indexdef(ix.indexrelid, k.ord::int, false) || ')')
|
|
244
|
+
order by k.ord
|
|
245
|
+
) as columns,
|
|
211
246
|
bool_and((ix.indoption[k.ord - 1] & 1) = 1) as descending
|
|
212
247
|
from pg_class t
|
|
213
248
|
join pg_namespace n on n.oid = t.relnamespace
|
|
@@ -215,9 +250,11 @@ export async function introspect(options: IntrospectOptions = {}): Promise<Schem
|
|
|
215
250
|
join pg_class i on i.oid = ix.indexrelid
|
|
216
251
|
join pg_am am on am.oid = i.relam
|
|
217
252
|
cross join lateral unnest(ix.indkey::smallint[]) with ordinality as k(attnum, ord)
|
|
218
|
-
join pg_attribute a on a.attrelid = t.oid and a.attnum = k.attnum
|
|
253
|
+
left join pg_attribute a on a.attrelid = t.oid and a.attnum = k.attnum and k.attnum > 0
|
|
219
254
|
where n.nspname = ${schema} and t.relkind = 'r' and k.ord <= ix.indnkeyatts
|
|
220
|
-
group by
|
|
255
|
+
group by
|
|
256
|
+
t.relname, i.relname, ix.indisunique, ix.indisprimary, ix.indpred, ix.indrelid,
|
|
257
|
+
ix.indexrelid, am.amname
|
|
221
258
|
order by t.relname, i.relname
|
|
222
259
|
`);
|
|
223
260
|
|
package/src/migrate.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { poolProfileFor } from './pool-profile';
|
|
|
13
13
|
import { raw, sql } from './sql';
|
|
14
14
|
import { SQLSTATE, sqlState } from './sqlstate';
|
|
15
15
|
import { statementsOf } from './statement-split';
|
|
16
|
-
import {
|
|
16
|
+
import { withTransaction } from './transaction';
|
|
17
17
|
|
|
18
18
|
export const LEDGER_TABLE = 'x_migrations';
|
|
19
19
|
|
|
@@ -312,7 +312,7 @@ async function withAdvisoryLock<T>(
|
|
|
312
312
|
* migration loop, and nesting here would replace that reason with a narrower one for no gain. An
|
|
313
313
|
* empty script sends nothing at all, which is how a no-op migration reaches its ledger row.
|
|
314
314
|
*/
|
|
315
|
-
async function applyScript(tx:
|
|
315
|
+
async function applyScript(tx: DbClient, script: string): Promise<void> {
|
|
316
316
|
for (const statement of statementsOf(script)) await tx.execute(raw(statement));
|
|
317
317
|
}
|
|
318
318
|
|
|
@@ -331,7 +331,7 @@ async function applyScript(tx: DbTx, script: string): Promise<void> {
|
|
|
331
331
|
* it, exactly like `statementTimeoutMs`. The failure it produces is `55P03`, typed as
|
|
332
332
|
* `X_DB_LOCK_TIMEOUT` by `driverError` with the `pg_stat_activity` read as its fix.
|
|
333
333
|
*/
|
|
334
|
-
async function setLockTimeout(tx:
|
|
334
|
+
async function setLockTimeout(tx: DbClient, lockTimeoutMs: number): Promise<void> {
|
|
335
335
|
if (lockTimeoutMs <= 0) return;
|
|
336
336
|
// `SET LOCAL` takes no parameter placeholder, and the value is a validated integer of ours.
|
|
337
337
|
await tx.execute(raw(`SET LOCAL lock_timeout = ${Math.round(lockTimeoutMs)}`));
|
package/src/object-drift.ts
CHANGED
|
@@ -4,12 +4,18 @@
|
|
|
4
4
|
// so everything else is compared here, catalog against catalog, by identity and never by text.
|
|
5
5
|
|
|
6
6
|
import type { CatalogDescription } from './catalog';
|
|
7
|
+
import { psqlCommand } from './dependent-view';
|
|
7
8
|
import { FRAMEWORK_TABLE_PREFIX } from './drift';
|
|
8
|
-
import type
|
|
9
|
-
import { shellInertIdentifier } from './sql';
|
|
9
|
+
import { byHand, type DriftDifference } from './drift-findings';
|
|
10
|
+
import { literal, shellInertIdentifier } from './sql';
|
|
10
11
|
|
|
11
12
|
interface ObjectIdentity {
|
|
12
|
-
/** The
|
|
13
|
+
/** The schema the catalog was read from — a new psql session's search_path need not hold it. */
|
|
14
|
+
readonly schema: string;
|
|
15
|
+
/**
|
|
16
|
+
* The word `drop` takes: `trigger`, `function`, `view`, `materialized view`, `type`, `domain`,
|
|
17
|
+
* `sequence`. A domain keeps its own word: it is inspected by a different psql command.
|
|
18
|
+
*/
|
|
13
19
|
readonly kind: string;
|
|
14
20
|
readonly name: string;
|
|
15
21
|
/** What tells two objects of one name apart: a function's arguments. */
|
|
@@ -27,13 +33,14 @@ interface ObjectIdentity {
|
|
|
27
33
|
*/
|
|
28
34
|
function identities(catalog: CatalogDescription): readonly ObjectIdentity[] {
|
|
29
35
|
const plain = (kind: string, name: string): ObjectIdentity => ({
|
|
36
|
+
schema: catalog.schema,
|
|
30
37
|
kind,
|
|
31
38
|
name,
|
|
32
39
|
signature: '',
|
|
33
40
|
table: null,
|
|
34
41
|
});
|
|
35
42
|
return [
|
|
36
|
-
...catalog.types.map((type) => plain('type', type.name)),
|
|
43
|
+
...catalog.types.map((type) => plain(type.kind === 'domain' ? 'domain' : 'type', type.name)),
|
|
37
44
|
...catalog.sequences.map((sequence) => plain('sequence', sequence.name)),
|
|
38
45
|
...catalog.views.map((view) =>
|
|
39
46
|
plain(view.materialized ? 'materialized view' : 'view', view.name),
|
|
@@ -53,38 +60,88 @@ const SIGNATURE_ACTIVE = /[`$\\\u0000-\u001f\u007f]/;
|
|
|
53
60
|
const keyOf = (object: ObjectIdentity): string =>
|
|
54
61
|
[object.kind, object.table ?? '', object.name, object.signature].join('\u0000');
|
|
55
62
|
|
|
63
|
+
/** The psql command that PRINTS a kind's definition — what a migration's create statement is copied from. */
|
|
64
|
+
const SHOW = Object.freeze<Record<string, string>>({
|
|
65
|
+
view: '\\d+',
|
|
66
|
+
'materialized view': '\\d+',
|
|
67
|
+
type: '\\dT+',
|
|
68
|
+
// `\dT+` LISTS a domain and shows none of it; its base type and CHECKs are `\dD+`'s (measured, 17).
|
|
69
|
+
domain: '\\dD+',
|
|
70
|
+
sequence: '\\d',
|
|
71
|
+
// A trigger has no command of its own: `\d` on its table lists it, definition included.
|
|
72
|
+
trigger: '\\d',
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A function's definition, asked for by the three facts the catalog gave: its schema, its name and
|
|
77
|
+
* its identity arguments. Not `\\sf name(args)`: that parses a TYPE list, and the identity arguments
|
|
78
|
+
* carry the parameter NAMES (`a text`), which it answers with a syntax error — measured on 17. By
|
|
79
|
+
* NAMESPACE and never `pg_function_is_visible`: visibility is the session's search_path, which can
|
|
80
|
+
* hide this function or answer a same-named one from another schema. All three are data, so all
|
|
81
|
+
* three go through `literal()`.
|
|
82
|
+
*/
|
|
83
|
+
const showFunction = (object: ObjectIdentity): string =>
|
|
84
|
+
'select pg_get_functiondef(p.oid) from pg_proc p join pg_namespace n on n.oid = ' +
|
|
85
|
+
`p.pronamespace where n.nspname = ${literal(object.schema).text} and ` +
|
|
86
|
+
`p.proname = ${literal(object.name).text} and ` +
|
|
87
|
+
`pg_get_function_identity_arguments(p.oid) = ${literal(object.signature).text}`;
|
|
88
|
+
|
|
56
89
|
/**
|
|
57
|
-
* The
|
|
58
|
-
*
|
|
59
|
-
*
|
|
90
|
+
* The fix is ONE command a shell runs, and it is the harmless one: it prints the object's
|
|
91
|
+
* definition. The repair is the comment, in the order it has to happen — copy the definition into
|
|
92
|
+
* a migration, drop the hand-made copy (a migration creating an object the database already holds
|
|
93
|
+
* fails on `already exists`), then migrate. It used to lead with `run drop …; inside psql`: prose
|
|
94
|
+
* no shell runs, whose first step destroyed the definition the second step needed.
|
|
60
95
|
*/
|
|
61
96
|
function unexpectedObject(object: ObjectIdentity): DriftDifference {
|
|
97
|
+
const schema = shellInertIdentifier(object.schema);
|
|
62
98
|
const name = shellInertIdentifier(object.name);
|
|
63
99
|
const table = object.table === null ? null : shellInertIdentifier(object.table);
|
|
64
100
|
const where = object.table === null ? '' : ` on table "${object.table}"`;
|
|
65
101
|
const signature = object.signature === '' ? '' : `(${object.signature})`;
|
|
66
|
-
// A function is
|
|
102
|
+
// A function is named by its argument list, empty included: `drop function "add";` is
|
|
67
103
|
// `42725 function name is not unique` while an overload lives beside it. The list is catalog
|
|
68
104
|
// text (`pg_get_function_identity_arguments`), already quoted for SQL, so it is screened for
|
|
69
105
|
// what a shell or a pasted line would read and never escaped.
|
|
70
106
|
const args = object.kind === 'function' ? `(${object.signature})` : '';
|
|
71
107
|
const spellable =
|
|
108
|
+
schema !== null &&
|
|
72
109
|
name !== null &&
|
|
73
110
|
(object.table === null || table !== null) &&
|
|
74
111
|
!SIGNATURE_ACTIVE.test(object.signature);
|
|
75
|
-
const
|
|
112
|
+
const cause = `${object.kind} "${object.name}"${signature}${where} exists in this database and no migration creates it`;
|
|
113
|
+
const kind = 'unexpected-object';
|
|
114
|
+
const base = { kind, table: object.table ?? object.name, column: null, cause } as const;
|
|
115
|
+
if (!spellable) {
|
|
116
|
+
return {
|
|
117
|
+
...base,
|
|
118
|
+
fix: byHand(
|
|
119
|
+
'copy the definition of the object this difference names into a migration as a create ' +
|
|
120
|
+
'statement, then drop it',
|
|
121
|
+
'its schema, name or arguments carry a backtick, a dollar sign, a quote, a backslash or ' +
|
|
122
|
+
'whitespace, so no statement here can spell it',
|
|
123
|
+
),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
// Every target is qualified by the catalog's schema: unqualified, `\d "posts"` in a session
|
|
127
|
+
// whose search_path lacks that schema answers "Did not find any relation" — and exits 0.
|
|
128
|
+
const drop =
|
|
129
|
+
table === null
|
|
130
|
+
? `drop ${object.kind} ${schema}.${name}${args};`
|
|
131
|
+
: `drop ${object.kind} ${name} on ${schema}.${table};`;
|
|
132
|
+
// The comment repeats the statement only when it holds no `'`: a shell that does not read `#`
|
|
133
|
+
// as a comment would open a quote on one. The command is safe either way (`psqlCommand`).
|
|
134
|
+
const spoken = drop.includes("'") ? `drop ${object.kind} on it` : drop;
|
|
135
|
+
const show = psqlCommand(
|
|
136
|
+
object.kind === 'function'
|
|
137
|
+
? showFunction(object)
|
|
138
|
+
: `${SHOW[object.kind] ?? '\\d'} ${schema}.${table ?? name}`,
|
|
139
|
+
);
|
|
76
140
|
return {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
fix: spellable
|
|
82
|
-
? `run ${drop} inside psql "$DATABASE_URL", then write its create statement into a ` +
|
|
83
|
-
'migration and run x db migrate — or leave it dropped if nothing owns it'
|
|
84
|
-
: 'drop it by hand, then write its create statement into a migration and run x db migrate ' +
|
|
85
|
-
'— its name or arguments carry a backtick, a dollar sign, a quote, a backslash or ' +
|
|
86
|
-
'whitespace, so ' +
|
|
87
|
-
'no statement here can spell it',
|
|
141
|
+
...base,
|
|
142
|
+
fix:
|
|
143
|
+
`${show} # no migration creates it: copy its definition into a migration as a create ` +
|
|
144
|
+
`statement, run ${spoken} here, then x db migrate — or only drop it if nothing owns it`,
|
|
88
145
|
};
|
|
89
146
|
}
|
|
90
147
|
|
package/src/pglite-branch.ts
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
|
|
6
6
|
import { cp, mkdir, rm, stat } from 'node:fs/promises';
|
|
7
7
|
import { basename, dirname, join } from 'node:path';
|
|
8
|
-
import { systemClock } from '@ultimat3/core';
|
|
8
|
+
import { NotImplementedError, systemClock } from '@ultimat3/core';
|
|
9
9
|
import type { BranchInfo } from './branch';
|
|
10
10
|
import { assertBranchName } from './branch';
|
|
11
|
-
import { branchExists,
|
|
11
|
+
import { branchExists, dbUnavailable } from './errors';
|
|
12
12
|
import { PGLITE_MEMORY, pgliteDataDir } from './pglite';
|
|
13
13
|
|
|
14
14
|
export interface PgliteBranchOptions {
|
|
@@ -56,10 +56,10 @@ export async function branchPglite(
|
|
|
56
56
|
assertBranchName(branch);
|
|
57
57
|
const from = pgliteDataDir(options.from);
|
|
58
58
|
if (from === PGLITE_MEMORY) {
|
|
59
|
-
throw
|
|
60
|
-
'branching an in-memory PGlite',
|
|
61
|
-
'x dev # a branch copies .x/pgdata, so the database has to be on disk first',
|
|
62
|
-
);
|
|
59
|
+
throw new NotImplementedError({
|
|
60
|
+
cause: 'branching an in-memory PGlite is not implemented by this driver',
|
|
61
|
+
fix: 'x dev # a branch copies .x/pgdata, so the database has to be on disk first',
|
|
62
|
+
});
|
|
63
63
|
}
|
|
64
64
|
if (!(await isDirectory(from))) {
|
|
65
65
|
throw dbUnavailable(`there is no PGlite data directory at ${from}, so nothing to branch`);
|
package/src/pglite.ts
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
// that only ever talks to a managed Postgres must not carry 26 MB of WASM it will never load.
|
|
5
5
|
|
|
6
6
|
import { statementAttribution } from './attribution';
|
|
7
|
+
import { refuseUnsendable } from './bound-parameters';
|
|
7
8
|
import type { DbClient, DbConnection, ReservableClient } from './client';
|
|
9
|
+
import { refuseRolledBackCommit } from './commit-tag';
|
|
8
10
|
import { DbError, driverError } from './errors';
|
|
9
11
|
import { expectedQueryLoopReason } from './expected-loop';
|
|
10
12
|
import {
|
|
@@ -36,6 +38,8 @@ export interface PgliteResult {
|
|
|
36
38
|
readonly rows: readonly unknown[];
|
|
37
39
|
/** Postgres' command-tag count — the only truthful answer for INSERT/UPDATE/DELETE. */
|
|
38
40
|
readonly affectedRows?: number | undefined;
|
|
41
|
+
/** The command tag's verb. `ROLLBACK` in answer to a `COMMIT` is how an aborted one reads. */
|
|
42
|
+
readonly command?: string | undefined;
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
/** The slice of PGlite we need. Declared structurally — this package has no dependencies. */
|
|
@@ -266,8 +270,12 @@ export function createPgliteClient(options: PgliteOptions = {}): PgliteClient {
|
|
|
266
270
|
|
|
267
271
|
/** The send itself: one statement on the session, every driver failure typed on the way out. */
|
|
268
272
|
async function send(driver: PgliteDriver, fragment: SqlFragment): Promise<PgliteResult> {
|
|
273
|
+
// Above the `try`, as `sendOn` encodes above its own: a value this package refuses to send is
|
|
274
|
+
// not a driver failure.
|
|
275
|
+
refuseUnsendable(fragment.values);
|
|
276
|
+
let result: PgliteResult;
|
|
269
277
|
try {
|
|
270
|
-
|
|
278
|
+
result = await driver.query(fragment.text, fragment.values);
|
|
271
279
|
} catch (error) {
|
|
272
280
|
// `driverError`, as `statement-funnel.ts` already does for Bun's driver: this site passed
|
|
273
281
|
// every failure to `dbUnavailable`, so under `x dev` — which IS this driver when no
|
|
@@ -276,6 +284,10 @@ export function createPgliteClient(options: PgliteOptions = {}): PgliteClient {
|
|
|
276
284
|
// answering fine (measured 2026-09-05). PGlite carries the SQLSTATE on `code`.
|
|
277
285
|
throw driverError(statementExcerpt(fragment.text), error);
|
|
278
286
|
}
|
|
287
|
+
// Outside the `try`, as `sendOn` does it: a COMMIT the server answered with ROLLBACK is a
|
|
288
|
+
// refusal of its own, and `driverError` would re-wrap it as unavailability.
|
|
289
|
+
refuseRolledBackCommit(fragment.text, result);
|
|
290
|
+
return result;
|
|
279
291
|
}
|
|
280
292
|
|
|
281
293
|
/**
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Single responsibility: a table's PRIMARY KEY as DDL — the two statements that move one, the
|
|
2
|
+
// generator's arm that decides when they are due, and the refusal for a key another table still
|
|
3
|
+
// points at. `diffTable` had no arm for it, so a changed `primaryKey` wrote no statement while the
|
|
4
|
+
// snapshot beside it recorded the new key, and drift had no comparison to notice with.
|
|
5
|
+
|
|
6
|
+
import { assert } from '@ultimat3/core';
|
|
7
|
+
import { defaultExpression } from './column-default';
|
|
8
|
+
import type { EntityDescriptionLike } from './entity-shape';
|
|
9
|
+
import type { Plan } from './foreign-key-plan';
|
|
10
|
+
import { isGenerated } from './generated-column';
|
|
11
|
+
import type { SchemaDescription, TableDescription } from './introspect';
|
|
12
|
+
import { MAX_IDENTIFIER_BYTES } from './invariant-ddl';
|
|
13
|
+
import { migrationIrreversible } from './migration-errors';
|
|
14
|
+
import { identifier } from './sql';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `<table>_pkey` — what Postgres names the constraint an inline `primary key (…)` creates, which is
|
|
18
|
+
* how `createTable` has always written one. Written out by name on every `add` here, so the name a
|
|
19
|
+
* later migration drops is one a migration chose.
|
|
20
|
+
*
|
|
21
|
+
* Bounded in bytes: past 63 the server truncates the TABLE part to make room for `_pkey`, so the
|
|
22
|
+
* name it holds is no longer this string and a `drop constraint` built from it would miss.
|
|
23
|
+
*/
|
|
24
|
+
export function primaryKeyName(table: string): string {
|
|
25
|
+
const name = `${table}_pkey`;
|
|
26
|
+
const bytes = new TextEncoder().encode(name).length;
|
|
27
|
+
assert(
|
|
28
|
+
bytes <= MAX_IDENTIFIER_BYTES,
|
|
29
|
+
`primary key constraint "${name}" is ${bytes} bytes; Postgres truncates at ${MAX_IDENTIFIER_BYTES}, so the name the database holds is not this one`,
|
|
30
|
+
`psql "$DATABASE_URL" -c "select conname from pg_constraint where contype = 'p' and conrelid = '${table}'::regclass" # then write the drop constraint / add primary key pair by hand in a new migration`,
|
|
31
|
+
);
|
|
32
|
+
return name;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function addPrimaryKey(table: string, columns: readonly string[]): string {
|
|
36
|
+
const key = columns.map((column) => identifier(column).text).join(', ');
|
|
37
|
+
return (
|
|
38
|
+
`alter table ${identifier(table).text} add constraint ` +
|
|
39
|
+
`${identifier(primaryKeyName(table)).text} primary key (${key});`
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* `if exists` for the generator, and for a reason the server supplies: dropping a COLUMN drops
|
|
45
|
+
* every constraint written over it, so a key whose column this same migration removes — in either
|
|
46
|
+
* direction — may already be gone by the time this statement runs.
|
|
47
|
+
*/
|
|
48
|
+
export function dropPrimaryKey(table: string, constraint: string, ifExists: boolean): string {
|
|
49
|
+
return (
|
|
50
|
+
`alter table ${identifier(table).text} drop constraint ` +
|
|
51
|
+
`${ifExists ? 'if exists ' : ''}${identifier(constraint).text};`
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Postgres marks every key column NOT NULL and dropping the key does not undo it (measured on 17),
|
|
57
|
+
* so a column the declaration allows NULL in needs the constraint taken off by name.
|
|
58
|
+
*/
|
|
59
|
+
const dropNotNull = (table: string, column: string): string =>
|
|
60
|
+
`alter table ${identifier(table).text} alter column ${identifier(column).text} drop not null;`;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* No default, or the default `null` — `.default(null)` renders that expression, and it fills an
|
|
64
|
+
* added column with exactly what no default does.
|
|
65
|
+
*/
|
|
66
|
+
const fillsNothing = (expression: string | null): boolean =>
|
|
67
|
+
expression === null || expression === 'null';
|
|
68
|
+
|
|
69
|
+
/** Two column lists, equal in ORDER — the one copy; `drift.ts` compares the live key through it. */
|
|
70
|
+
export const sameColumns = (a: readonly string[], b: readonly string[]): boolean =>
|
|
71
|
+
a.length === b.length && a.every((column, index) => column === b[index]);
|
|
72
|
+
|
|
73
|
+
/** ORDER is part of a key: `(org_id, id)` and `(id, org_id)` are two different indexes. */
|
|
74
|
+
export function keyChanged(entity: EntityDescriptionLike, live: TableDescription): boolean {
|
|
75
|
+
return !sameColumns(entity.primaryKey, live.primaryKey);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The recorded foreign keys written against the key being replaced. Postgres refuses to drop a
|
|
80
|
+
* constraint another one depends on (`2BP01`), and re-pointing someone else's key is not a diff
|
|
81
|
+
* this generator can derive: the referencing table's own columns would have to change with it.
|
|
82
|
+
*/
|
|
83
|
+
function inboundKeys(current: SchemaDescription, live: TableDescription): readonly string[] {
|
|
84
|
+
const key = new Set(live.primaryKey);
|
|
85
|
+
return current.tables.flatMap((table) =>
|
|
86
|
+
table.foreignKeys
|
|
87
|
+
.filter(
|
|
88
|
+
(foreign) =>
|
|
89
|
+
foreign.referencedTable === live.name &&
|
|
90
|
+
foreign.referencedColumns.length === key.size &&
|
|
91
|
+
foreign.referencedColumns.every((column) => key.has(column)),
|
|
92
|
+
)
|
|
93
|
+
.map((foreign) => foreign.name),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The first half, ahead of every column statement of the table: the old key goes. `down` is
|
|
99
|
+
* reversed at assembly, so the statement pushed here runs LAST on the way back — the old key is
|
|
100
|
+
* restored only once every column it names is back.
|
|
101
|
+
*/
|
|
102
|
+
export function dropChangedKey(
|
|
103
|
+
entity: EntityDescriptionLike,
|
|
104
|
+
live: TableDescription,
|
|
105
|
+
current: SchemaDescription,
|
|
106
|
+
plan: Plan,
|
|
107
|
+
migration: string,
|
|
108
|
+
): void {
|
|
109
|
+
if (!keyChanged(entity, live) || live.primaryKey.length === 0) return;
|
|
110
|
+
const inbound = inboundKeys(current, live);
|
|
111
|
+
if (inbound.length > 0) {
|
|
112
|
+
throw migrationIrreversible(
|
|
113
|
+
`changing the primary key of "${entity.table}" drops the constraint ${inbound.map((name) => `"${name}"`).join(', ')} ${inbound.length === 1 ? 'is' : 'are'} written against, and re-pointing another table's foreign key is not a change this generator can derive`,
|
|
114
|
+
`x db gen "${migration}" # after removing the references() to "${entity.table}" behind ${inbound.join(', ')} — drop the keys in one migration, change the primary key in the next, restore them in a third`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
plan.up.push(dropPrimaryKey(entity.table, primaryKeyName(entity.table), true));
|
|
118
|
+
const declared = new Map(entity.columns.map((column) => [column.column, column]));
|
|
119
|
+
const kept = new Set(entity.primaryKey);
|
|
120
|
+
for (const name of live.primaryKey) {
|
|
121
|
+
// Leaving the key, still on the table, and declared nullable: the key's NOT NULL goes with it.
|
|
122
|
+
if (!kept.has(name) && declared.get(name)?.notNull === false) {
|
|
123
|
+
plan.up.push(dropNotNull(entity.table, name));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// A key column this migration DROPS comes back empty on the way down (`-- data is not
|
|
127
|
+
// restored`), and a primary key over NULLs cannot be added to a table holding a row. The
|
|
128
|
+
// statement is named as the follow-up rather than emitted as one that cannot apply — the form
|
|
129
|
+
// `diffTable` already uses for a NOT NULL add.
|
|
130
|
+
const restored = live.primaryKey.filter((name) => !declared.has(name));
|
|
131
|
+
const restore = addPrimaryKey(entity.table, live.primaryKey);
|
|
132
|
+
plan.down.push(
|
|
133
|
+
restored.length === 0
|
|
134
|
+
? restore
|
|
135
|
+
: `-- backfill ${restored.map((name) => identifier(name).text).join(', ')}, then: ${restore}`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The second half, after the table's last column statement — the `drop column`s included: every
|
|
141
|
+
* column the new key names exists by now, and none it no longer names is still in the way.
|
|
142
|
+
*
|
|
143
|
+
* Refused when the key names a column this same migration ADDS with nothing to fill it (no
|
|
144
|
+
* default, or the default `null`): `add
|
|
145
|
+
* column` lands NULL in every existing row and a primary key refuses a NULL, so the generated `up`
|
|
146
|
+
* could not apply to any table holding a row. A default or a generation expression fills it.
|
|
147
|
+
*/
|
|
148
|
+
export function addChangedKey(
|
|
149
|
+
entity: EntityDescriptionLike,
|
|
150
|
+
live: TableDescription,
|
|
151
|
+
plan: Plan,
|
|
152
|
+
migration: string,
|
|
153
|
+
): void {
|
|
154
|
+
if (!keyChanged(entity, live) || entity.primaryKey.length === 0) return;
|
|
155
|
+
const recorded = new Map(live.columns.map((column) => [column.name, column]));
|
|
156
|
+
const empty = entity.columns.filter(
|
|
157
|
+
(column) =>
|
|
158
|
+
entity.primaryKey.includes(column.column) &&
|
|
159
|
+
!recorded.has(column.column) &&
|
|
160
|
+
!isGenerated(column) &&
|
|
161
|
+
fillsNothing(defaultExpression(column)),
|
|
162
|
+
);
|
|
163
|
+
if (empty.length > 0) {
|
|
164
|
+
const names = empty.map((column) => `"${column.column}"`).join(', ');
|
|
165
|
+
throw migrationIrreversible(
|
|
166
|
+
`the new primary key of "${entity.table}" names ${names}, which this same migration adds with no default that fills it: every existing row would hold NULL there, and a primary key cannot be added over a NULL`,
|
|
167
|
+
`x db gen "${migration}" # with ${names} declared but left OUT of primaryKey — apply it, backfill the column, then put it in the key and run x db gen again`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
plan.up.push(addPrimaryKey(entity.table, entity.primaryKey));
|
|
171
|
+
const old = new Set(live.primaryKey);
|
|
172
|
+
for (const name of entity.primaryKey) {
|
|
173
|
+
// Pushed BEFORE the drop so it runs AFTER it on the way down: a column that was nullable
|
|
174
|
+
// before this migration keyed it is nullable again once the key is gone.
|
|
175
|
+
if (!old.has(name) && recorded.get(name)?.nullable === true) {
|
|
176
|
+
plan.down.push(dropNotNull(entity.table, name));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
plan.down.push(dropPrimaryKey(entity.table, primaryKeyName(entity.table), true));
|
|
180
|
+
}
|
package/src/schema-dump-table.ts
CHANGED
|
@@ -34,7 +34,10 @@ function columnClause(column: CatalogColumn): string {
|
|
|
34
34
|
const parts = [quoted(column.name), column.type];
|
|
35
35
|
if (column.collation !== null) parts.push(`collate ${quoted(column.collation)}`);
|
|
36
36
|
if (column.default !== null) parts.push(`default ${column.default}`);
|
|
37
|
-
if (column.generated !== null)
|
|
37
|
+
if (column.generated !== null) {
|
|
38
|
+
const { expression, storage } = column.generated;
|
|
39
|
+
parts.push(`generated always as (${expression}) ${storage}`);
|
|
40
|
+
}
|
|
38
41
|
if (column.identity !== null) {
|
|
39
42
|
const { mode, sequence } = column.identity;
|
|
40
43
|
parts.push(
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Single responsibility: a nested scope's wait for its turn among its siblings, under a deadline.
|
|
2
|
+
// Sibling scopes run one after the other (savepoints are a stack), so a body that awaits a sibling
|
|
3
|
+
// queued BEHIND it is a cycle nothing can break from inside — and an unbounded wait made it a
|
|
4
|
+
// silent, permanent hang. Same shape as `pool-reserve.ts`: a late turn is given back, never dropped.
|
|
5
|
+
|
|
6
|
+
import type { DbError } from './errors';
|
|
7
|
+
import type { Turn, TurnQueue } from './pglite-turns';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The default wait, in milliseconds. Above the serving roles' `statement_timeout` (10–15 s,
|
|
11
|
+
* `pool-profile.ts`), so a sibling that is merely inside one slow statement finishes or fails
|
|
12
|
+
* first; `{ siblingWaitMs }` moves it and `0` removes it.
|
|
13
|
+
*/
|
|
14
|
+
export const SIBLING_SCOPE_WAIT_MS = 30_000;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `queue.take()` under a deadline. The place in the queue is claimed the moment `take()` is called
|
|
18
|
+
* and cannot be withdrawn, so a wait that gives up must still hand the turn straight on when it
|
|
19
|
+
* arrives — otherwise every later sibling waits behind a scope that no longer exists.
|
|
20
|
+
*/
|
|
21
|
+
export async function siblingTurn(
|
|
22
|
+
queue: TurnQueue,
|
|
23
|
+
waitMs: number,
|
|
24
|
+
refusal: () => DbError,
|
|
25
|
+
): Promise<Turn> {
|
|
26
|
+
const pending = queue.take();
|
|
27
|
+
if (waitMs === 0) return pending;
|
|
28
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
29
|
+
let expired = false;
|
|
30
|
+
try {
|
|
31
|
+
return await Promise.race([
|
|
32
|
+
pending,
|
|
33
|
+
new Promise<never>((_resolve, reject) => {
|
|
34
|
+
timer = setTimeout(() => {
|
|
35
|
+
expired = true;
|
|
36
|
+
// Built at expiry, so it names the scope holding the turn NOW.
|
|
37
|
+
reject(refusal());
|
|
38
|
+
}, waitMs);
|
|
39
|
+
// The deadline must not be what keeps a finished process alive.
|
|
40
|
+
timer.unref?.();
|
|
41
|
+
}),
|
|
42
|
+
]);
|
|
43
|
+
} finally {
|
|
44
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
45
|
+
void pending.then((late) => {
|
|
46
|
+
if (expired) late.release();
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/sqlstate.ts
CHANGED
|
@@ -31,12 +31,6 @@ export const SQLSTATE = Object.freeze({
|
|
|
31
31
|
outOfMemory: '53200',
|
|
32
32
|
} as const);
|
|
33
33
|
|
|
34
|
-
/** Five characters, digits and uppercase letters — `42P01`, never `ERR_POSTGRES_SERVER_ERROR`. */
|
|
35
|
-
const SQLSTATE_SHAPE = /^[0-9A-Z]{5}$/;
|
|
36
|
-
|
|
37
|
-
/** How deep a wrap may nest before we stop looking. `DbError` adds exactly one level. */
|
|
38
|
-
const MAX_WRAPS = 4;
|
|
39
|
-
|
|
40
34
|
/** A field off a value that may fight being read — `stringField`'s shape, for a non-string. */
|
|
41
35
|
function unknownField(value: unknown, key: string): unknown {
|
|
42
36
|
if (typeof value !== 'object' || value === null) return undefined;
|
|
@@ -47,6 +41,32 @@ function unknownField(value: unknown, key: string): unknown {
|
|
|
47
41
|
}
|
|
48
42
|
}
|
|
49
43
|
|
|
44
|
+
/** Five characters, digits and uppercase letters — `42P01`, never `ERR_POSTGRES_SERVER_ERROR`. */
|
|
45
|
+
const SQLSTATE_SHAPE = /^[0-9A-Z]{5}$/;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Whether five characters of that shape are a SQLSTATE, decided by where the object CAME FROM —
|
|
49
|
+
* the shape alone cannot say. `EPIPE` and `E2BIG` are errno names of exactly that shape, and
|
|
50
|
+
* `raise exception … using errcode = 'ABCDE'` is a legal state with no digit in it, so a rule
|
|
51
|
+
* about letters and digits is wrong in both directions.
|
|
52
|
+
*
|
|
53
|
+
* Measured on Bun.SQL against Postgres 17 and on PGlite: a server ErrorResponse carries
|
|
54
|
+
* `severity` on both drivers, and nothing the socket layer throws does. A syscall error carries
|
|
55
|
+
* `syscall` and a NUMERIC `errno`. An object marked as neither — a fake, a wrapper, a driver this
|
|
56
|
+
* package has not measured — keeps the old reading only for a state that carries a digit, which
|
|
57
|
+
* is every state Postgres itself defines and no bare errno name this package has been handed.
|
|
58
|
+
*/
|
|
59
|
+
function isState(holder: unknown, candidate: string): boolean {
|
|
60
|
+
if (!SQLSTATE_SHAPE.test(candidate)) return false;
|
|
61
|
+
if (stringField(holder, 'severity') !== undefined) return true;
|
|
62
|
+
if (stringField(holder, 'syscall') !== undefined) return false;
|
|
63
|
+
if (typeof unknownField(holder, 'errno') === 'number') return false;
|
|
64
|
+
return /[0-9]/.test(candidate);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** How deep a wrap may nest before we stop looking. `DbError` adds exactly one level. */
|
|
68
|
+
const MAX_WRAPS = 4;
|
|
69
|
+
|
|
50
70
|
/**
|
|
51
71
|
* The SQLSTATE a driver error carries, unwrapping `DbError.sourceError` on the way, or `undefined`
|
|
52
72
|
* when the failure never reached the server — a refused socket, a closed pool, a DNS miss.
|
|
@@ -57,17 +77,17 @@ function unknownField(value: unknown, key: string): unknown {
|
|
|
57
77
|
* has no `errno` at all. Reading `code` alone is correct on the embedded driver and wrong on every
|
|
58
78
|
* production one, which is exactly the split `isLedgerMissing` was living on.
|
|
59
79
|
*
|
|
60
|
-
* The shape test
|
|
61
|
-
*
|
|
80
|
+
* The shape test keeps `ERR_POSTGRES_SERVER_ERROR` and `X_DB_UNAVAILABLE` out — neither is five
|
|
81
|
+
* characters of `[0-9A-Z]` — and `isState` keeps an errno NAME out, which the shape cannot.
|
|
62
82
|
*/
|
|
63
83
|
export function sqlState(error: unknown): string | undefined {
|
|
64
84
|
let value = error;
|
|
65
85
|
for (let depth = 0; depth < MAX_WRAPS; depth += 1) {
|
|
66
86
|
if (value === undefined || value === null) return undefined;
|
|
67
87
|
const errno = stringField(value, 'errno');
|
|
68
|
-
if (errno !== undefined &&
|
|
88
|
+
if (errno !== undefined && isState(value, errno)) return errno;
|
|
69
89
|
const code = stringField(value, 'code');
|
|
70
|
-
if (code !== undefined &&
|
|
90
|
+
if (code !== undefined && isState(value, code)) return code;
|
|
71
91
|
value = unknownField(value, 'sourceError');
|
|
72
92
|
}
|
|
73
93
|
return undefined;
|