@ultimat3/db 22.15.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 +91 -56
- package/README.md +121 -6
- package/package.json +5 -3
- package/src/array-parameter.ts +38 -1
- package/src/bound-parameters.ts +22 -3
- package/src/bun-sql.ts +12 -0
- package/src/catalog-fold.ts +116 -0
- package/src/catalog-objects.ts +229 -0
- package/src/catalog-relations.ts +184 -0
- package/src/catalog.ts +174 -0
- package/src/client.ts +57 -9
- 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 +213 -60
- package/src/drift.ts +19 -13
- package/src/dump-drift.ts +142 -0
- package/src/errors.ts +8 -7
- package/src/foreign-key.ts +0 -34
- package/src/generate.ts +5 -0
- package/src/index.ts +9 -3
- package/src/introspect-catalog.ts +171 -0
- package/src/introspect.ts +45 -8
- package/src/listen.ts +62 -0
- package/src/migrate.ts +3 -3
- package/src/object-drift.ts +162 -0
- package/src/pglite-branch.ts +6 -6
- package/src/pglite-extensions.ts +112 -0
- package/src/pglite-package.ts +11 -0
- package/src/pglite-snapshot.ts +121 -0
- package/src/pglite.ts +146 -11
- package/src/pool-gauge.ts +70 -0
- package/src/primary-key.ts +180 -0
- package/src/schema-dump-entry.ts +39 -0
- package/src/schema-dump-table.ts +75 -0
- package/src/schema-dump.ts +192 -0
- package/src/schema-load.ts +119 -0
- package/src/sibling-turn.ts +49 -0
- package/src/sqlstate.ts +33 -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
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// Single responsibility: a `CatalogDescription` as files — the schema dump. Pure: the same catalog
|
|
2
|
+
// is the same bytes, so the gate can compare what is committed with what the migrations produce.
|
|
3
|
+
// It decides which file a statement belongs to; `schema-dump-table.ts` spells a table, and every
|
|
4
|
+
// other object is the catalog's own `pg_get_*def` text with a terminator.
|
|
5
|
+
|
|
6
|
+
import type { CatalogDescription, CatalogType } from './catalog';
|
|
7
|
+
import { FRAMEWORK_TABLE_PREFIX } from './drift';
|
|
8
|
+
import { constraintClause, createSequence, quoted, tableStatements } from './schema-dump-table';
|
|
9
|
+
import { literal } from './sql';
|
|
10
|
+
|
|
11
|
+
export interface SchemaDumpFile {
|
|
12
|
+
/** Relative to the dump directory, POSIX: `04_tables/posts.sql`, `framework/04_tables/x_jobs.sql`. */
|
|
13
|
+
readonly path: string;
|
|
14
|
+
readonly content: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** One directory per object kind, numbered in the order a database can be built in. */
|
|
18
|
+
export const SCHEMA_DUMP_KINDS = [
|
|
19
|
+
'01_extensions',
|
|
20
|
+
'02_types',
|
|
21
|
+
'03_sequences',
|
|
22
|
+
'04_tables',
|
|
23
|
+
'05_indexes',
|
|
24
|
+
'06_foreign_keys',
|
|
25
|
+
'07_views',
|
|
26
|
+
'08_functions',
|
|
27
|
+
'09_triggers',
|
|
28
|
+
] as const;
|
|
29
|
+
|
|
30
|
+
export type SchemaDumpKind = (typeof SCHEMA_DUMP_KINDS)[number];
|
|
31
|
+
|
|
32
|
+
/** The framework's own tables (`x_jobs`, `x_migrations`, …) live under a twin of the same layout. */
|
|
33
|
+
export const FRAMEWORK_DUMP_DIR = 'framework';
|
|
34
|
+
|
|
35
|
+
/** What the dump could not spell — comments only, so it loads as nothing. */
|
|
36
|
+
export const UNRENDERED_DUMP_FILE = 'unrendered.sql';
|
|
37
|
+
|
|
38
|
+
/** The ceiling the `filesize` step holds for source. A longer file is split, never exempted. */
|
|
39
|
+
export const SCHEMA_DUMP_MAX_LINES = 500;
|
|
40
|
+
|
|
41
|
+
export const SCHEMA_DUMP_HEADER =
|
|
42
|
+
'-- generated by `x db gen` from the migrations; never edited by hand (X_SCHEMA_DUMP_DRIFT)';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A name as a file name: anything outside `[A-Za-z0-9_-]` becomes `%XX` per UTF-8 byte. A `.` is
|
|
46
|
+
* encoded too, which is what makes the `.<n>.sql` suffix of a split file unambiguous.
|
|
47
|
+
*/
|
|
48
|
+
export function dumpFileName(name: string): string {
|
|
49
|
+
return [...new TextEncoder().encode(name)]
|
|
50
|
+
.map((byte) => {
|
|
51
|
+
const char = String.fromCharCode(byte);
|
|
52
|
+
return /[A-Za-z0-9_-]/.test(char)
|
|
53
|
+
? char
|
|
54
|
+
: `%${byte.toString(16).toUpperCase().padStart(2, '0')}`;
|
|
55
|
+
})
|
|
56
|
+
.join('');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The `x_` rule `appTables()` already holds: the owner (a table, else the object) decides the twin. */
|
|
60
|
+
const twinOf = (owner: string): string =>
|
|
61
|
+
owner.startsWith(FRAMEWORK_TABLE_PREFIX) ? `${FRAMEWORK_DUMP_DIR}/` : '';
|
|
62
|
+
|
|
63
|
+
function typeStatement(type: CatalogType): string {
|
|
64
|
+
if (type.kind === 'enum') {
|
|
65
|
+
const labels = type.labels.map((label) => literal(label).text).join(', ');
|
|
66
|
+
return `create type ${quoted(type.name)} as enum (${labels});`;
|
|
67
|
+
}
|
|
68
|
+
const clauses = [`create domain ${quoted(type.name)} as ${type.baseType}`];
|
|
69
|
+
if (type.default !== null) clauses.push(`default ${type.default}`);
|
|
70
|
+
if (type.notNull) clauses.push('not null');
|
|
71
|
+
clauses.push(...type.checks.map(constraintClause));
|
|
72
|
+
return `${clauses.join(' ')};`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** `pg_get_*def` text with exactly one terminator, whether or not the catalog wrote one. */
|
|
76
|
+
const terminated = (definition: string): string => `${definition.trim().replace(/;$/, '')};`;
|
|
77
|
+
|
|
78
|
+
const TRIGGER_STATE: ReadonlyMap<string, string> = new Map([
|
|
79
|
+
['D', 'disable trigger'],
|
|
80
|
+
['R', 'enable replica trigger'],
|
|
81
|
+
['A', 'enable always trigger'],
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
/** Statements per file path, in insertion order within a file. */
|
|
85
|
+
class Buckets {
|
|
86
|
+
private readonly files = new Map<string, string[]>();
|
|
87
|
+
|
|
88
|
+
add(kind: SchemaDumpKind, owner: string, file: string, ...statements: readonly string[]): void {
|
|
89
|
+
const path = `${twinOf(owner)}${kind}/${dumpFileName(file)}`;
|
|
90
|
+
const bucket = this.files.get(path) ?? [];
|
|
91
|
+
bucket.push(...statements);
|
|
92
|
+
this.files.set(path, bucket);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
entries(): readonly (readonly [string, readonly string[]])[] {
|
|
96
|
+
return [...this.files.entries()];
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function collect(catalog: CatalogDescription): Buckets {
|
|
101
|
+
const out = new Buckets();
|
|
102
|
+
const materialized = new Set(catalog.views.filter((v) => v.materialized).map((v) => v.name));
|
|
103
|
+
for (const extension of catalog.extensions) {
|
|
104
|
+
const statement = `create extension if not exists ${quoted(extension.name)};`;
|
|
105
|
+
out.add('01_extensions', extension.name, extension.name, statement);
|
|
106
|
+
}
|
|
107
|
+
for (const type of catalog.types) out.add('02_types', type.name, type.name, typeStatement(type));
|
|
108
|
+
for (const sequence of catalog.sequences) {
|
|
109
|
+
out.add('03_sequences', sequence.name, sequence.name, createSequence(sequence));
|
|
110
|
+
}
|
|
111
|
+
for (const table of catalog.tables) {
|
|
112
|
+
out.add('04_tables', table.name, table.name, ...tableStatements(table));
|
|
113
|
+
}
|
|
114
|
+
for (const view of catalog.views) {
|
|
115
|
+
const head = view.materialized ? 'create materialized view' : 'create view';
|
|
116
|
+
const options = view.options === null ? '' : ` with (${view.options})`;
|
|
117
|
+
const statement = `${head} ${quoted(view.name)}${options} as\n${terminated(view.definition)}`;
|
|
118
|
+
out.add('07_views', view.name, view.name, statement);
|
|
119
|
+
}
|
|
120
|
+
for (const index of catalog.indexes) {
|
|
121
|
+
// A materialized view's index rides in the view's own file: the view is built in a later
|
|
122
|
+
// directory than `05_indexes`, so an index filed there would name a relation not yet created.
|
|
123
|
+
const kind = materialized.has(index.table) ? '07_views' : '05_indexes';
|
|
124
|
+
out.add(kind, index.table, index.table, terminated(index.definition));
|
|
125
|
+
}
|
|
126
|
+
for (const table of catalog.tables) {
|
|
127
|
+
if (table.replicaIdentity.kind !== 'index') continue;
|
|
128
|
+
const using = `replica identity using index ${quoted(table.replicaIdentity.index)}`;
|
|
129
|
+
out.add('05_indexes', table.name, table.name, `alter table ${quoted(table.name)} ${using};`);
|
|
130
|
+
}
|
|
131
|
+
for (const key of catalog.foreignKeys) {
|
|
132
|
+
const statement = `alter table ${quoted(key.table)} add ${constraintClause(key)};`;
|
|
133
|
+
out.add('06_foreign_keys', key.table, key.table, statement);
|
|
134
|
+
}
|
|
135
|
+
for (const fn of catalog.functions) {
|
|
136
|
+
out.add('08_functions', fn.name, fn.name, terminated(fn.definition));
|
|
137
|
+
}
|
|
138
|
+
for (const trigger of catalog.triggers) {
|
|
139
|
+
const state = TRIGGER_STATE.get(trigger.enabled);
|
|
140
|
+
const on = quoted(trigger.table);
|
|
141
|
+
out.add('09_triggers', trigger.table, trigger.table, terminated(trigger.definition));
|
|
142
|
+
if (state !== undefined) {
|
|
143
|
+
const statement = `alter table ${on} ${state} ${quoted(trigger.name)};`;
|
|
144
|
+
out.add('09_triggers', trigger.table, trigger.table, statement);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** One file, or `<name>.1.sql`, `<name>.2.sql`, … when it would pass the ceiling. */
|
|
151
|
+
function split(path: string, content: string): readonly SchemaDumpFile[] {
|
|
152
|
+
// The trailing newline is the file's terminator, not a line of its own.
|
|
153
|
+
const lines = content.replace(/\n$/, '').split('\n');
|
|
154
|
+
if (lines.length <= SCHEMA_DUMP_MAX_LINES) return [{ path: `${path}.sql`, content }];
|
|
155
|
+
const parts: SchemaDumpFile[] = [];
|
|
156
|
+
for (let at = 0; at < lines.length; at += SCHEMA_DUMP_MAX_LINES) {
|
|
157
|
+
const chunk = lines.slice(at, at + SCHEMA_DUMP_MAX_LINES).join('\n');
|
|
158
|
+
parts.push({ path: `${path}.${parts.length + 1}.sql`, content: `${chunk}\n` });
|
|
159
|
+
}
|
|
160
|
+
return parts;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function unrenderedFiles(catalog: CatalogDescription): readonly SchemaDumpFile[] {
|
|
164
|
+
const twins = new Map<string, string[]>();
|
|
165
|
+
for (const object of catalog.unrendered) {
|
|
166
|
+
const twin = twinOf(object.table ?? object.name);
|
|
167
|
+
const on = object.table === null ? '' : ` on ${quoted(object.table)}`;
|
|
168
|
+
const lines = twins.get(twin) ?? [];
|
|
169
|
+
lines.push(`-- not rendered: ${object.kind} ${quoted(object.name)}${on}`);
|
|
170
|
+
twins.set(twin, lines);
|
|
171
|
+
}
|
|
172
|
+
return [...twins.entries()].map(([twin, lines]) => ({
|
|
173
|
+
path: `${twin}${UNRENDERED_DUMP_FILE}`,
|
|
174
|
+
content: `${SCHEMA_DUMP_HEADER}\n\n${lines.join('\n')}\n`,
|
|
175
|
+
}));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The whole dump, sorted by path. Deterministic by construction: the catalog arrives sorted
|
|
180
|
+
* (`introspectCatalog`), nothing here reads a clock, a version or an owner, and every file ends
|
|
181
|
+
* in exactly one newline.
|
|
182
|
+
*/
|
|
183
|
+
export function renderSchemaDump(catalog: CatalogDescription): readonly SchemaDumpFile[] {
|
|
184
|
+
const files = collect(catalog)
|
|
185
|
+
.entries()
|
|
186
|
+
.flatMap(([path, statements]) =>
|
|
187
|
+
split(path, `${SCHEMA_DUMP_HEADER}\n\n${statements.join('\n\n')}\n`),
|
|
188
|
+
);
|
|
189
|
+
return [...files, ...unrenderedFiles(catalog)].sort((a, b) =>
|
|
190
|
+
a.path < b.path ? -1 : a.path > b.path ? 1 : 0,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Single responsibility: build a schema from the dump `schema-dump.ts` wrote — every file's
|
|
2
|
+
// statements, in kind order, inside one transaction. The inverse of the dump and the half of
|
|
3
|
+
// "load equals replay" that proves the files are a cache of the migrations rather than a second
|
|
4
|
+
// source: load, introspect, render again, and the bytes must be the ones that were loaded.
|
|
5
|
+
|
|
6
|
+
import { renderThrowable } from '@ultimat3/core';
|
|
7
|
+
import type { DbClient } from './client';
|
|
8
|
+
import { baseClient } from './client';
|
|
9
|
+
import { schemaDumpDrift, unloadableDump } from './dump-drift';
|
|
10
|
+
import { DbError } from './errors';
|
|
11
|
+
import { FRAMEWORK_DUMP_DIR, type SchemaDumpFile } from './schema-dump';
|
|
12
|
+
import { raw } from './sql';
|
|
13
|
+
import { SQLSTATE, sqlState } from './sqlstate';
|
|
14
|
+
import { statementsOf } from './statement-split';
|
|
15
|
+
import { withTransaction } from './transaction';
|
|
16
|
+
|
|
17
|
+
export interface LoadSchemaDumpOptions {
|
|
18
|
+
readonly files: readonly SchemaDumpFile[];
|
|
19
|
+
readonly client?: DbClient | undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SchemaLoadReport {
|
|
23
|
+
readonly files: number;
|
|
24
|
+
readonly statements: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** One loadable unit: a whole file, or the numbered parts of a split one joined back together. */
|
|
28
|
+
interface Unit {
|
|
29
|
+
readonly path: string;
|
|
30
|
+
readonly kind: string;
|
|
31
|
+
readonly framework: boolean;
|
|
32
|
+
readonly script: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const SPLIT_PART = /^(.*)\.(\d+)\.sql$/;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Files regrouped into units and put in load order: by kind directory, the framework twin first
|
|
39
|
+
* within a kind (an app's foreign key may point at `x_users`; nothing of the framework's points at
|
|
40
|
+
* the app), then by name. A split file's parts are joined in NUMERIC order — `.10.sql` after
|
|
41
|
+
* `.9.sql` — because a statement may straddle the cut.
|
|
42
|
+
*/
|
|
43
|
+
export function loadOrder(files: readonly SchemaDumpFile[]): readonly Unit[] {
|
|
44
|
+
const parts = new Map<string, { part: number; content: string }[]>();
|
|
45
|
+
for (const file of files) {
|
|
46
|
+
const match = SPLIT_PART.exec(file.path);
|
|
47
|
+
const base = match === null ? file.path : `${match[1] ?? ''}.sql`;
|
|
48
|
+
const list = parts.get(base) ?? [];
|
|
49
|
+
list.push({ part: match === null ? 0 : Number(match[2]), content: file.content });
|
|
50
|
+
parts.set(base, list);
|
|
51
|
+
}
|
|
52
|
+
const units = [...parts.entries()].map(([path, list]): Unit => {
|
|
53
|
+
const framework = path.startsWith(`${FRAMEWORK_DUMP_DIR}/`);
|
|
54
|
+
const local = framework ? path.slice(FRAMEWORK_DUMP_DIR.length + 1) : path;
|
|
55
|
+
return {
|
|
56
|
+
path,
|
|
57
|
+
kind: local.includes('/') ? (local.split('/')[0] ?? '') : '',
|
|
58
|
+
framework,
|
|
59
|
+
script: list
|
|
60
|
+
.sort((a, b) => a.part - b.part)
|
|
61
|
+
.map((part) => part.content)
|
|
62
|
+
.join(''),
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
const key = (unit: Unit): string => `${unit.kind}/${unit.framework ? '0' : '1'}/${unit.path}`;
|
|
66
|
+
return units.sort((a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** "Not created YET" — the three states a statement raises when it names something later in the order. */
|
|
70
|
+
const NOT_YET: ReadonlySet<string> = new Set([
|
|
71
|
+
SQLSTATE.undefinedTable,
|
|
72
|
+
SQLSTATE.undefinedFunction,
|
|
73
|
+
SQLSTATE.undefinedObject,
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
const detailOf = (error: unknown): string =>
|
|
77
|
+
error instanceof DbError ? error.cause : renderThrowable(error);
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Load every file. A unit that fails because something it names does not exist yet is rolled back
|
|
81
|
+
* to its savepoint and retried after the rest — kind order is right for nearly every schema, and
|
|
82
|
+
* the exceptions (a view over a view, a column default calling a function) are dependencies no
|
|
83
|
+
* directory layout can order. A pass that loads nothing ends the loop with the last refusal.
|
|
84
|
+
*
|
|
85
|
+
* `check_function_bodies` is off for the transaction: a SQL-language function body is validated
|
|
86
|
+
* at creation, and it may name a table an alphabetical neighbour has not created.
|
|
87
|
+
*/
|
|
88
|
+
export async function loadSchemaDump(options: LoadSchemaDumpOptions): Promise<SchemaLoadReport> {
|
|
89
|
+
const client = options.client ?? baseClient();
|
|
90
|
+
let pending = loadOrder(options.files);
|
|
91
|
+
const files = pending.length;
|
|
92
|
+
let statements = 0;
|
|
93
|
+
await withTransaction(
|
|
94
|
+
async (tx) => {
|
|
95
|
+
await tx.execute(raw('set local check_function_bodies = off'));
|
|
96
|
+
while (pending.length > 0) {
|
|
97
|
+
const deferred: Unit[] = [];
|
|
98
|
+
let refusal: DbError | undefined;
|
|
99
|
+
for (const unit of pending) {
|
|
100
|
+
const script = statementsOf(unit.script);
|
|
101
|
+
try {
|
|
102
|
+
await withTransaction(async (savepoint) => {
|
|
103
|
+
for (const statement of script) await savepoint.execute(raw(statement));
|
|
104
|
+
});
|
|
105
|
+
statements += script.length;
|
|
106
|
+
} catch (error) {
|
|
107
|
+
refusal = schemaDumpDrift(unloadableDump(unit.path, detailOf(error)));
|
|
108
|
+
if (!NOT_YET.has(sqlState(error) ?? '')) throw refusal;
|
|
109
|
+
deferred.push(unit);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (deferred.length === pending.length && refusal !== undefined) throw refusal;
|
|
113
|
+
pending = deferred;
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
{ client },
|
|
117
|
+
);
|
|
118
|
+
return { files, statements };
|
|
119
|
+
}
|
|
@@ -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
|
@@ -16,6 +16,9 @@ export const SQLSTATE = Object.freeze({
|
|
|
16
16
|
undefinedTable: '42P01',
|
|
17
17
|
/** `undefined_column` — an entity edited before the migration that adds the column ran. */
|
|
18
18
|
undefinedColumn: '42703',
|
|
19
|
+
/** `undefined_function` and `undefined_object` — with `undefined_table`, "not created YET". */
|
|
20
|
+
undefinedFunction: '42883',
|
|
21
|
+
undefinedObject: '42704',
|
|
19
22
|
uniqueViolation: '23505',
|
|
20
23
|
foreignKeyViolation: '23503',
|
|
21
24
|
serializationFailure: '40001',
|
|
@@ -28,12 +31,6 @@ export const SQLSTATE = Object.freeze({
|
|
|
28
31
|
outOfMemory: '53200',
|
|
29
32
|
} as const);
|
|
30
33
|
|
|
31
|
-
/** Five characters, digits and uppercase letters — `42P01`, never `ERR_POSTGRES_SERVER_ERROR`. */
|
|
32
|
-
const SQLSTATE_SHAPE = /^[0-9A-Z]{5}$/;
|
|
33
|
-
|
|
34
|
-
/** How deep a wrap may nest before we stop looking. `DbError` adds exactly one level. */
|
|
35
|
-
const MAX_WRAPS = 4;
|
|
36
|
-
|
|
37
34
|
/** A field off a value that may fight being read — `stringField`'s shape, for a non-string. */
|
|
38
35
|
function unknownField(value: unknown, key: string): unknown {
|
|
39
36
|
if (typeof value !== 'object' || value === null) return undefined;
|
|
@@ -44,6 +41,32 @@ function unknownField(value: unknown, key: string): unknown {
|
|
|
44
41
|
}
|
|
45
42
|
}
|
|
46
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
|
+
|
|
47
70
|
/**
|
|
48
71
|
* The SQLSTATE a driver error carries, unwrapping `DbError.sourceError` on the way, or `undefined`
|
|
49
72
|
* when the failure never reached the server — a refused socket, a closed pool, a DNS miss.
|
|
@@ -54,17 +77,17 @@ function unknownField(value: unknown, key: string): unknown {
|
|
|
54
77
|
* has no `errno` at all. Reading `code` alone is correct on the embedded driver and wrong on every
|
|
55
78
|
* production one, which is exactly the split `isLedgerMissing` was living on.
|
|
56
79
|
*
|
|
57
|
-
* The shape test
|
|
58
|
-
*
|
|
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.
|
|
59
82
|
*/
|
|
60
83
|
export function sqlState(error: unknown): string | undefined {
|
|
61
84
|
let value = error;
|
|
62
85
|
for (let depth = 0; depth < MAX_WRAPS; depth += 1) {
|
|
63
86
|
if (value === undefined || value === null) return undefined;
|
|
64
87
|
const errno = stringField(value, 'errno');
|
|
65
|
-
if (errno !== undefined &&
|
|
88
|
+
if (errno !== undefined && isState(value, errno)) return errno;
|
|
66
89
|
const code = stringField(value, 'code');
|
|
67
|
-
if (code !== undefined &&
|
|
90
|
+
if (code !== undefined && isState(value, code)) return code;
|
|
68
91
|
value = unknownField(value, 'sourceError');
|
|
69
92
|
}
|
|
70
93
|
return undefined;
|
package/src/statement-funnel.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { statementAttribution } from './attribution';
|
|
7
7
|
import { encodeBoundParameters } from './bound-parameters';
|
|
8
8
|
import type { BunSqlDriver } from './bun-sql';
|
|
9
|
+
import { refuseRolledBackCommit } from './commit-tag';
|
|
9
10
|
import { driverError } from './errors';
|
|
10
11
|
import { expectedQueryLoopReason } from './expected-loop';
|
|
11
12
|
import { statementObserver } from './observe';
|
|
@@ -32,12 +33,18 @@ async function sendOn(
|
|
|
32
33
|
driver: Pick<BunSqlDriver, 'unsafe'>,
|
|
33
34
|
fragment: SqlFragment,
|
|
34
35
|
): Promise<unknown> {
|
|
36
|
+
// `encodeBoundParameters`, never `fragment.values` raw: `Bun.SQL` joins a JS array's elements
|
|
37
|
+
// with commas (#384), and on the pool's unnamed statements sends a `Date` as its local-zone
|
|
38
|
+
// `toString()`. One encoder here rather than one import per call site, because this is the
|
|
39
|
+
// only place this driver's `unsafe` is called.
|
|
40
|
+
//
|
|
41
|
+
// ABOVE the `try`: its refusals are this package's own and already coded. Inside it, a ragged
|
|
42
|
+
// array or an Invalid Date came back as `X_DB_UNAVAILABLE` — "set DATABASE_URL" — from a driver
|
|
43
|
+
// that was never called.
|
|
44
|
+
const values = encodeBoundParameters(fragment.values);
|
|
45
|
+
let result: unknown;
|
|
35
46
|
try {
|
|
36
|
-
|
|
37
|
-
// with commas (#384), and on the pool's unnamed statements sends a `Date` as its local-zone
|
|
38
|
-
// `toString()`. One encoder here rather than one import per call site, because this is the
|
|
39
|
-
// only place this driver's `unsafe` is called.
|
|
40
|
-
return await driver.unsafe(fragment.text, encodeBoundParameters(fragment.values));
|
|
47
|
+
result = await driver.unsafe(fragment.text, values);
|
|
41
48
|
} catch (error) {
|
|
42
49
|
// `driverError`, not `dbUnavailable`: the SQLSTATE has always been on this error and nothing
|
|
43
50
|
// read it, so a `23505` from two clicks racing a signup told the operator the database was
|
|
@@ -45,6 +52,10 @@ async function sendOn(
|
|
|
45
52
|
// not classify is still `X_DB_UNAVAILABLE`, byte for byte.
|
|
46
53
|
throw driverError(statementExcerpt(fragment.text), error);
|
|
47
54
|
}
|
|
55
|
+
// Outside the `try`: this refusal is already typed, and `driverError` would re-wrap it as a
|
|
56
|
+
// database nobody could reach.
|
|
57
|
+
refuseRolledBackCommit(fragment.text, result);
|
|
58
|
+
return result;
|
|
48
59
|
}
|
|
49
60
|
|
|
50
61
|
/**
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Single responsibility: the two refusals a transaction's END can raise — the server rolled it back
|
|
2
|
+
// while the body carried on, and the answer to COMMIT never arrived. Split from `errors.ts` at the
|
|
3
|
+
// file-size rule; both are thrown by `transaction.ts`, the first by the two statement funnels too.
|
|
4
|
+
|
|
5
|
+
import { renderThrowable } from '@ultimat3/core';
|
|
6
|
+
import { DbError } from './errors';
|
|
7
|
+
import { sqlState } from './sqlstate';
|
|
8
|
+
|
|
9
|
+
const ABORTED_FIX =
|
|
10
|
+
'await withTransaction(() => fallible()).catch(fallback) # a nested scope is a SAVEPOINT, so ' +
|
|
11
|
+
'only it rolls back — or rethrow the statement error instead of catching it';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Postgres aborts the WHOLE transaction on any statement error and answers the `COMMIT` that
|
|
15
|
+
* follows with the tag `ROLLBACK` and no error, so a body that caught the failure and returned was
|
|
16
|
+
* reported committed with nothing stored. `first` is the statement the body threw away — rendered
|
|
17
|
+
* into the cause, deliberately NOT chained as `sourceError`: `sqlState()` unwraps that chain, and a
|
|
18
|
+
* caller asking "was this a unique violation" would be answered yes by an error that means the
|
|
19
|
+
* whole unit of work is gone.
|
|
20
|
+
*/
|
|
21
|
+
export const transactionAborted = (first?: unknown): DbError => {
|
|
22
|
+
const state = sqlState(first);
|
|
23
|
+
return new DbError({
|
|
24
|
+
code: 'X_DB_TRANSACTION_ABORTED',
|
|
25
|
+
cause:
|
|
26
|
+
first === undefined
|
|
27
|
+
? 'COMMIT was answered with ROLLBACK: a statement failed earlier in this transaction, its error was caught, and the server had already rolled the whole unit of work back'
|
|
28
|
+
: `a statement failed inside the transaction and the error was caught rather than rethrown, so the server rolled the whole unit of work back: ${renderThrowable(first)}`,
|
|
29
|
+
fix: ABORTED_FIX,
|
|
30
|
+
...(state === undefined ? {} : { meta: { sqlState: state } }),
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A nested scope gave up waiting for the sibling holding the turn. Names both: the scope that
|
|
36
|
+
* waited is identified by its parent (it never opened, so it has no savepoint of its own), and the
|
|
37
|
+
* holder by the savepoint it is inside. Terminal: the usual cause is a body awaiting a sibling
|
|
38
|
+
* queued behind it, and the same call made again waits for the same cycle.
|
|
39
|
+
*/
|
|
40
|
+
export const siblingScopeTimeout = (
|
|
41
|
+
parent: string,
|
|
42
|
+
holder: string | undefined,
|
|
43
|
+
waitedMs: number,
|
|
44
|
+
): DbError =>
|
|
45
|
+
new DbError({
|
|
46
|
+
code: 'X_DB_SIBLING_SCOPE_TIMEOUT',
|
|
47
|
+
cause: `a nested scope under ${parent} waited ${waitedMs}ms for its sibling ${holder ?? 'scope'} to finish and never got a turn — sibling scopes run one after the other, so a body that awaits a sibling started after it waits for itself`,
|
|
48
|
+
fix: 'await withTransaction(first); await withTransaction(second) # one after the other, never a nested body awaiting a sibling — or raise the wait: withTransaction(fn, { siblingWaitMs: 120000 })',
|
|
49
|
+
meta: { parent, waitedMs, ...(holder === undefined ? {} : { holder }) },
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `COMMIT` was sent and rejected with no SQLSTATE — the socket went before the answer did. The
|
|
54
|
+
* transaction is durable or it is not, and nothing on this side can say which, so neither list
|
|
55
|
+
* runs: an `onRollback` undo would revert state the database may have kept, and an `onCommit`
|
|
56
|
+
* effect would announce rows it may not have.
|
|
57
|
+
*/
|
|
58
|
+
export const commitUnknown = (sourceError: unknown): DbError =>
|
|
59
|
+
new DbError({
|
|
60
|
+
code: 'X_DB_COMMIT_UNKNOWN',
|
|
61
|
+
cause: `the connection failed while COMMIT was in flight, so the transaction is either durable or rolled back and this process cannot tell which; neither onCommit effects nor onRollback undos ran. Only the data can say which — a row the transaction wrote is present if it committed and absent if it did not: ${renderThrowable(sourceError)}`,
|
|
62
|
+
// A session, never a `-c "<placeholder>"`: which row proves it is the caller's knowledge, and
|
|
63
|
+
// a placeholder inside a command is a command that does not run.
|
|
64
|
+
fix: 'psql "$DATABASE_URL" # a session on that database: select a row the transaction wrote, and re-run the unit of work only when it is absent',
|
|
65
|
+
sourceError,
|
|
66
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Single responsibility: what a transaction scope is ASKED for and what it hands back — the `DbTx`
|
|
2
|
+
// handle, `TransactionOptions`, and the `BEGIN` text those options spell. Split from
|
|
3
|
+
// `transaction.ts` at the file-size rule; the scope itself (pin, savepoints, COMMIT) stays there.
|
|
4
|
+
|
|
5
|
+
import type { Random } from '@ultimat3/core';
|
|
6
|
+
import type { DbClient } from './client';
|
|
7
|
+
import { isolationLevelInvalid } from './errors';
|
|
8
|
+
|
|
9
|
+
export interface DbTx extends DbClient {
|
|
10
|
+
readonly id: string;
|
|
11
|
+
/**
|
|
12
|
+
* The client this transaction was **opened on** — `options.client`, or `baseClient()`. Not the
|
|
13
|
+
* reservation the statements run on: what a caller needs to know is which database and which
|
|
14
|
+
* pool this scope belongs to, and the pin is an implementation detail of that.
|
|
15
|
+
*
|
|
16
|
+
* It exists because the answer was unanswerable from above. `@ultimat3/entity`'s repositories
|
|
17
|
+
* can be pinned to a specific client (`database(shard)`), and a pinned repository inside
|
|
18
|
+
* `withTransaction` sends its statements to *its own pool* while the `BEGIN` sits on a
|
|
19
|
+
* connection this scope reserved — so the write commits immediately and survives the rollback,
|
|
20
|
+
* and reads inside the transaction cannot see it. `withTransaction(fn, { client: shard })` does
|
|
21
|
+
* not fix it either: the transaction runs on a *reservation* of the shard and the repository
|
|
22
|
+
* still sends to the pool. With nothing to compare against, tier 2's only honest answer was to
|
|
23
|
+
* refuse (`X_REPO_CLIENT_PINNED`). `tx.origin === thePinnedClient` turns that refusal into the
|
|
24
|
+
* case working — the repository joins its own shard's transaction — and leaves the refusal for
|
|
25
|
+
* what it should always have been: a genuine mix of two databases in one scope.
|
|
26
|
+
*
|
|
27
|
+
* A nested scope reports the root's, because a SAVEPOINT belongs to the transaction that opened.
|
|
28
|
+
*/
|
|
29
|
+
readonly origin: DbClient;
|
|
30
|
+
/**
|
|
31
|
+
* Fired in reverse registration order when this scope rolls back. Never on commit, and never
|
|
32
|
+
* when the answer to COMMIT was lost: the write it would undo may be durable.
|
|
33
|
+
*/
|
|
34
|
+
onRollback(undo: () => void): void;
|
|
35
|
+
/**
|
|
36
|
+
* Fired in registration order once the ROOT transaction has COMMITTED — never on rollback, and
|
|
37
|
+
* never when the answer to COMMIT was lost (`X_DB_COMMIT_UNKNOWN`). A
|
|
38
|
+
* nested scope's effects are handed to its parent on `RELEASE` and dropped on `ROLLBACK TO`, so
|
|
39
|
+
* nothing fires for a write that is not durable. What a change feed, a cache purge or a dev row
|
|
40
|
+
* observer needs: reporting a write before COMMIT reports rows a rollback then erases. An effect
|
|
41
|
+
* that throws is swallowed — the transaction already committed, and nothing can un-commit it.
|
|
42
|
+
*/
|
|
43
|
+
onCommit(effect: () => void): void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type IsolationLevel = 'read committed' | 'repeatable read' | 'serializable';
|
|
47
|
+
|
|
48
|
+
export interface TransactionOptions {
|
|
49
|
+
readonly isolation?: IsolationLevel | undefined;
|
|
50
|
+
readonly readOnly?: boolean | undefined;
|
|
51
|
+
/** Only meaningful with `serializable` + `readOnly`; lets Postgres wait instead of retrying. */
|
|
52
|
+
readonly deferrable?: boolean | undefined;
|
|
53
|
+
/** Override the ambient pool — tests and `x db branch` run against a specific client. */
|
|
54
|
+
readonly client?: DbClient | undefined;
|
|
55
|
+
/**
|
|
56
|
+
* Extra attempts after a `40001`/`40P01`, and **only** after one. Default 0, so adding the option
|
|
57
|
+
* changed no existing transaction's behaviour (axiom 1) — a retry that ran without being asked
|
|
58
|
+
* for would silently double every non-idempotent handler in the framework.
|
|
59
|
+
*
|
|
60
|
+
* Opt in wherever `isolation: 'serializable'` is set: under SERIALIZABLE a serialization failure
|
|
61
|
+
* is normal traffic, not an exception, and until this existed a payments team choosing it for
|
|
62
|
+
* ledger correctness got ~3% of transactions surfacing to the user as "cannot reach the
|
|
63
|
+
* database" with no way to write their own retry, because nothing distinguished `40001` from a
|
|
64
|
+
* dead socket.
|
|
65
|
+
*
|
|
66
|
+
* **`fn` re-runs from the top, so it must be idempotent** — the same contract `job.handle` has.
|
|
67
|
+
* `onRollback` undos fire before each retry, in reverse registration order.
|
|
68
|
+
*
|
|
69
|
+
* Each re-run waits first (`transaction-backoff.ts`). A budget of 0 waits not at all.
|
|
70
|
+
*/
|
|
71
|
+
readonly retry?: number | undefined;
|
|
72
|
+
/**
|
|
73
|
+
* The wait between attempts, and the roll behind its jitter. Injected for one reason — a schedule
|
|
74
|
+
* provable only by waiting for it is a schedule no test pins — and production passes neither.
|
|
75
|
+
* They are only ever read when `retry` is 1 or more.
|
|
76
|
+
*/
|
|
77
|
+
readonly sleep?: ((ms: number) => Promise<void>) | undefined;
|
|
78
|
+
readonly random?: Random | undefined;
|
|
79
|
+
/**
|
|
80
|
+
* How long a NESTED scope waits for a sibling scope to finish before it may open, in
|
|
81
|
+
* milliseconds. Sibling scopes under one parent run one after the other, so a body that awaits
|
|
82
|
+
* a sibling started after it waits for itself; past this the waiting call rejects with
|
|
83
|
+
* `X_DB_SIBLING_SCOPE_TIMEOUT` instead of hanging. Default `SIBLING_SCOPE_WAIT_MS` (30 s); `0`
|
|
84
|
+
* waits without a deadline. Read only by a nested scope — the outermost one has no sibling.
|
|
85
|
+
*/
|
|
86
|
+
readonly siblingWaitMs?: number | undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The SQL for one isolation level, RE-DERIVED from the closed set rather than built out of the
|
|
91
|
+
* value — the same rule `pg-sql.ts` follows for `asc|desc`, and for the same reason: `BEGIN` takes
|
|
92
|
+
* no parameters, so this is one of the two statements here built as text, and a level spliced into
|
|
93
|
+
* it is whatever the caller passed. `isolation` is typed, and a type is not a runtime guard: the
|
|
94
|
+
* value reaches `withTransaction` from an app's config, a JSON body or a CLI flag —
|
|
95
|
+
* `{ isolation: 'read committed; drop table x; --' }` became exactly that statement, and a
|
|
96
|
+
* non-string became an uncoded `TypeError` inside a template literal.
|
|
97
|
+
*
|
|
98
|
+
* The `default` arm is `never`, so a fourth member added to `IsolationLevel` with no SQL beside it
|
|
99
|
+
* is a type error here rather than a refusal at runtime.
|
|
100
|
+
*/
|
|
101
|
+
const isolationMode = (declared: IsolationLevel): string => {
|
|
102
|
+
switch (declared) {
|
|
103
|
+
case 'read committed':
|
|
104
|
+
return 'ISOLATION LEVEL READ COMMITTED';
|
|
105
|
+
case 'repeatable read':
|
|
106
|
+
return 'ISOLATION LEVEL REPEATABLE READ';
|
|
107
|
+
case 'serializable':
|
|
108
|
+
return 'ISOLATION LEVEL SERIALIZABLE';
|
|
109
|
+
default: {
|
|
110
|
+
const unhandled: never = declared;
|
|
111
|
+
throw isolationLevelInvalid(unhandled);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export function beginStatement(options: TransactionOptions): string {
|
|
117
|
+
const modes: string[] = [];
|
|
118
|
+
if (options.isolation !== undefined) modes.push(isolationMode(options.isolation));
|
|
119
|
+
if (options.readOnly === true) modes.push('READ ONLY');
|
|
120
|
+
if (options.deferrable === true) modes.push('DEFERRABLE');
|
|
121
|
+
return modes.length === 0 ? 'BEGIN' : `BEGIN ${modes.join(' ')}`;
|
|
122
|
+
}
|