@ultimat3/db 22.14.0 → 23.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 +39 -1
- package/README.md +81 -0
- package/package.json +5 -3
- package/src/bun-sql.ts +12 -0
- package/src/catalog-fold.ts +113 -0
- package/src/catalog-objects.ts +200 -0
- package/src/catalog-relations.ts +184 -0
- package/src/catalog.ts +166 -0
- package/src/client.ts +57 -9
- package/src/drift-findings.ts +4 -1
- package/src/dump-drift.ts +142 -0
- package/src/errors.ts +2 -0
- package/src/index.ts +4 -0
- package/src/introspect-catalog.ts +154 -0
- package/src/listen.ts +62 -0
- package/src/object-drift.ts +105 -0
- 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 +133 -10
- package/src/pool-gauge.ts +70 -0
- package/src/schema-dump-entry.ts +39 -0
- package/src/schema-dump-table.ts +72 -0
- package/src/schema-dump.ts +192 -0
- package/src/schema-load.ts +119 -0
- package/src/sqlstate.ts +3 -0
|
@@ -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
|
+
}
|
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',
|