@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
package/src/drift.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
changedColumn,
|
|
10
10
|
changedForeignKey,
|
|
11
11
|
changedIndex,
|
|
12
|
+
changedPrimaryKey,
|
|
12
13
|
missingCheck,
|
|
13
14
|
missingColumn,
|
|
14
15
|
missingForeignKey,
|
|
@@ -23,6 +24,7 @@ import { foreignKeyTarget, onDeleteRule } from './foreign-key';
|
|
|
23
24
|
import { indexMethodOf } from './index-method';
|
|
24
25
|
import { findTable, introspect, type SchemaDescription, type TableDescription } from './introspect';
|
|
25
26
|
import { type LedgerRow, type Migration, readLedger } from './migrate';
|
|
27
|
+
import { sameColumns } from './primary-key';
|
|
26
28
|
|
|
27
29
|
// Re-exported explicitly, never `export *`: `src/index.ts` publishes both from `'./drift'`, so the
|
|
28
30
|
// split is invisible to `@ultimat3/db`'s public surface and no consumer moves with it.
|
|
@@ -145,7 +147,7 @@ function compareForeignKeys(live: TableDescription, expected: TableDescription):
|
|
|
145
147
|
continue;
|
|
146
148
|
}
|
|
147
149
|
if (onDeleteRule(counterpart.onDelete) !== onDeleteRule(key.onDelete)) {
|
|
148
|
-
differences.push(changedForeignKey(live.name, key, counterpart));
|
|
150
|
+
differences.push(changedForeignKey(live.schema, live.name, key, counterpart));
|
|
149
151
|
}
|
|
150
152
|
}
|
|
151
153
|
return differences;
|
|
@@ -175,26 +177,29 @@ function compareChecks(live: TableDescription, expected: TableDescription): Drif
|
|
|
175
177
|
const present = new Set(held);
|
|
176
178
|
return declared
|
|
177
179
|
.filter((check) => !present.has(check.name))
|
|
178
|
-
.map((check) => missingCheck(live.name, check));
|
|
180
|
+
.map((check) => missingCheck(live.schema, live.name, check));
|
|
179
181
|
}
|
|
180
182
|
|
|
181
183
|
/**
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
* against a database that is exactly right and cannot be anything else. The union, not one side:
|
|
186
|
-
* a key present on only one of them is a difference the *key* comparison owns, and reporting it
|
|
187
|
-
* again as a nullability change would be one fault with two findings.
|
|
184
|
+
* The two key lists, compared in ORDER. The comment that used to stand here said a key on one side
|
|
185
|
+
* only "is a difference the key comparison owns" — and no key comparison existed, so a table
|
|
186
|
+
* re-keyed by hand, or one whose key no migration ever produced, read `ok: true`.
|
|
188
187
|
*/
|
|
189
|
-
function
|
|
190
|
-
|
|
188
|
+
function comparePrimaryKey(live: TableDescription, expected: TableDescription): DriftDifference[] {
|
|
189
|
+
if (sameColumns(live.primaryKey, expected.primaryKey)) return [];
|
|
190
|
+
const held = live.indexes.find((index) => index.primary)?.name;
|
|
191
|
+
return [changedPrimaryKey(live.schema, live.name, live.primaryKey, held, expected.primaryKey)];
|
|
191
192
|
}
|
|
192
193
|
|
|
193
194
|
function compareTable(live: TableDescription, expected: TableDescription): DriftDifference[] {
|
|
194
195
|
const differences: DriftDifference[] = [];
|
|
195
196
|
const expectedColumns = new Map(expected.columns.map((column) => [column.name, column]));
|
|
196
197
|
const liveColumns = new Map(live.columns.map((column) => [column.name, column]));
|
|
197
|
-
|
|
198
|
+
// A primary key column is `NOT NULL` in the catalog whether or not anything declared it —
|
|
199
|
+
// Postgres adds the constraint with the key — so a snapshot spelling its key column nullable is
|
|
200
|
+
// not drift. The DECLARED key only: a column the database alone keys stays NOT NULL after the
|
|
201
|
+
// stray constraint is dropped, which is a second fault and reported as one.
|
|
202
|
+
const keyColumns = new Set(expected.primaryKey);
|
|
198
203
|
for (const column of live.columns) {
|
|
199
204
|
if (expectedColumns.has(column.name)) continue;
|
|
200
205
|
differences.push(unexpectedColumn(live.name, column.name));
|
|
@@ -210,9 +215,10 @@ function compareTable(live: TableDescription, expected: TableDescription): Drift
|
|
|
210
215
|
// `retypeColumn` already owns that question where both sides are generated.
|
|
211
216
|
if (keyColumns.has(column.name)) continue;
|
|
212
217
|
if (column.nullable !== counterpart.nullable) {
|
|
213
|
-
differences.push(changedColumn(live.name, column.name, counterpart.nullable));
|
|
218
|
+
differences.push(changedColumn(live.schema, live.name, column.name, counterpart.nullable));
|
|
214
219
|
}
|
|
215
220
|
}
|
|
221
|
+
differences.push(...comparePrimaryKey(live, expected));
|
|
216
222
|
differences.push(...compareIndexes(live, expected));
|
|
217
223
|
differences.push(...compareChecks(live, expected));
|
|
218
224
|
differences.push(...compareForeignKeys(live, expected));
|
|
@@ -224,7 +230,7 @@ export function diffSchema(live: SchemaDescription, expected: SchemaDescription)
|
|
|
224
230
|
const differences: DriftDifference[] = [];
|
|
225
231
|
for (const table of live.tables) {
|
|
226
232
|
const counterpart = findTable(expected, table.name);
|
|
227
|
-
if (counterpart === undefined) differences.push(unexpectedTable(table.name));
|
|
233
|
+
if (counterpart === undefined) differences.push(unexpectedTable(table.schema, table.name));
|
|
228
234
|
else differences.push(...compareTable(table, counterpart));
|
|
229
235
|
}
|
|
230
236
|
for (const table of expected.tables) {
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Single responsibility: `X_SCHEMA_DUMP_DRIFT` — what it means for a committed schema dump to
|
|
2
|
+
// disagree with the one the migrations produce, and how each disagreement reads. Pure: files in,
|
|
3
|
+
// differences out. Which directory the dump lives in is the caller's fact; paths here are relative.
|
|
4
|
+
|
|
5
|
+
import { DbError } from './errors';
|
|
6
|
+
import type { SchemaDumpFile } from './schema-dump';
|
|
7
|
+
|
|
8
|
+
export type SchemaDumpDifferenceKind =
|
|
9
|
+
| 'missing-file'
|
|
10
|
+
| 'changed-file'
|
|
11
|
+
| 'unexpected-file'
|
|
12
|
+
| 'unloadable'
|
|
13
|
+
| 'reload-differs';
|
|
14
|
+
|
|
15
|
+
export interface SchemaDumpDifference {
|
|
16
|
+
readonly kind: SchemaDumpDifferenceKind;
|
|
17
|
+
/** Relative to the dump directory. */
|
|
18
|
+
readonly path: string;
|
|
19
|
+
readonly cause: string;
|
|
20
|
+
readonly fix: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Regenerating is the whole repair for a dump that is merely behind. */
|
|
24
|
+
export const SCHEMA_DUMP_FIX = 'x db gen';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* For a dump that regenerating cannot repair. It still starts with the command, because the first
|
|
28
|
+
* thing to rule out is a dump that is merely stale — and it says what the second failure means,
|
|
29
|
+
* since nothing the app's author can run fixes a statement the dump renders wrong.
|
|
30
|
+
*/
|
|
31
|
+
const roundTripFix = (path: string): string =>
|
|
32
|
+
`x db gen # and when the regenerated dump is refused the same way, ${path} holds an object the dump cannot round-trip: report it with that file and the migration that creates it`;
|
|
33
|
+
|
|
34
|
+
const byPath = (a: SchemaDumpDifference, b: SchemaDumpDifference): number =>
|
|
35
|
+
a.path < b.path ? -1 : a.path > b.path ? 1 : 0;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The committed files against the rendered ones, byte for byte, in both directions: a file the
|
|
39
|
+
* migrations produce and nobody committed, one whose bytes differ, and one on disk that nothing
|
|
40
|
+
* renders. The last matters as much as the first — a stray `04_tables/old.sql` is a table an
|
|
41
|
+
* agent reading the directory believes exists.
|
|
42
|
+
*/
|
|
43
|
+
export function compareSchemaDump(
|
|
44
|
+
committed: readonly SchemaDumpFile[],
|
|
45
|
+
rendered: readonly SchemaDumpFile[],
|
|
46
|
+
): readonly SchemaDumpDifference[] {
|
|
47
|
+
const onDisk = new Map(committed.map((file) => [file.path, file.content]));
|
|
48
|
+
const produced = new Set(rendered.map((file) => file.path));
|
|
49
|
+
const differences: SchemaDumpDifference[] = [];
|
|
50
|
+
for (const file of rendered) {
|
|
51
|
+
const held = onDisk.get(file.path);
|
|
52
|
+
if (held === file.content) continue;
|
|
53
|
+
differences.push(
|
|
54
|
+
held === undefined
|
|
55
|
+
? {
|
|
56
|
+
kind: 'missing-file',
|
|
57
|
+
path: file.path,
|
|
58
|
+
cause: `schema dump file ${file.path} is what the migrations produce and is not committed`,
|
|
59
|
+
fix: SCHEMA_DUMP_FIX,
|
|
60
|
+
}
|
|
61
|
+
: {
|
|
62
|
+
kind: 'changed-file',
|
|
63
|
+
path: file.path,
|
|
64
|
+
cause: `schema dump file ${file.path} differs from what the migrations produce`,
|
|
65
|
+
fix: SCHEMA_DUMP_FIX,
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
for (const file of committed) {
|
|
70
|
+
if (produced.has(file.path)) continue;
|
|
71
|
+
differences.push({
|
|
72
|
+
kind: 'unexpected-file',
|
|
73
|
+
path: file.path,
|
|
74
|
+
cause: `schema dump file ${file.path} describes nothing the migrations produce`,
|
|
75
|
+
fix: SCHEMA_DUMP_FIX,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return differences.sort(byPath);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Load equals replay, as a comparison: the dump of the migrated database against the dump of a
|
|
83
|
+
* database built by loading that dump. Any difference means the files are not a cache of the
|
|
84
|
+
* migrations but a second, lossy source. One finding, naming the first file that came back
|
|
85
|
+
* different — the rest follow from it.
|
|
86
|
+
*
|
|
87
|
+
* Asked only of a dump that claims to be whole. One that names objects in `unrendered.sql` has
|
|
88
|
+
* already said it is not, and its caller does not ask.
|
|
89
|
+
*/
|
|
90
|
+
export function reloadDifferences(
|
|
91
|
+
replayed: readonly SchemaDumpFile[],
|
|
92
|
+
reloaded: readonly SchemaDumpFile[],
|
|
93
|
+
): readonly SchemaDumpDifference[] {
|
|
94
|
+
const first = compareSchemaDump(reloaded, replayed)[0];
|
|
95
|
+
if (first === undefined) return [];
|
|
96
|
+
return [
|
|
97
|
+
{
|
|
98
|
+
kind: 'reload-differs',
|
|
99
|
+
path: first.path,
|
|
100
|
+
cause: `a database loaded from the schema dump is not the one the migrations build: ${first.path} comes back different`,
|
|
101
|
+
fix: roundTripFix(first.path),
|
|
102
|
+
},
|
|
103
|
+
];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A dump file the database refused, with what the database said about it. */
|
|
107
|
+
export const unloadableDump = (path: string, detail: string): SchemaDumpDifference => ({
|
|
108
|
+
kind: 'unloadable',
|
|
109
|
+
path,
|
|
110
|
+
cause: `schema dump file ${path} does not load: ${detail}`,
|
|
111
|
+
fix: roundTripFix(path),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
export const schemaDumpDrift = (difference: SchemaDumpDifference): DbError =>
|
|
115
|
+
new DbError({
|
|
116
|
+
code: 'X_SCHEMA_DUMP_DRIFT',
|
|
117
|
+
cause: difference.cause,
|
|
118
|
+
fix: difference.fix,
|
|
119
|
+
meta: { kind: difference.kind, path: difference.path },
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const DIFFERENCE_KINDS: readonly string[] = [
|
|
123
|
+
'missing-file',
|
|
124
|
+
'changed-file',
|
|
125
|
+
'unexpected-file',
|
|
126
|
+
'unloadable',
|
|
127
|
+
'reload-differs',
|
|
128
|
+
] satisfies readonly SchemaDumpDifferenceKind[];
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The difference a thrown `X_SCHEMA_DUMP_DRIFT` was built from, or `undefined` for anything else.
|
|
132
|
+
* `loadSchemaDump` throws; a caller that reports findings wants the value back, and reading it
|
|
133
|
+
* off the error here keeps `meta`'s shape a fact of this file alone.
|
|
134
|
+
*/
|
|
135
|
+
export function schemaDumpDifferenceOf(error: unknown): SchemaDumpDifference | undefined {
|
|
136
|
+
if (!(error instanceof DbError) || error.code !== 'X_SCHEMA_DUMP_DRIFT') return undefined;
|
|
137
|
+
const kind = error.meta?.['kind'];
|
|
138
|
+
const path = error.meta?.['path'];
|
|
139
|
+
if (typeof kind !== 'string' || typeof path !== 'string') return undefined;
|
|
140
|
+
if (!DIFFERENCE_KINDS.includes(kind)) return undefined;
|
|
141
|
+
return { kind: kind as SchemaDumpDifferenceKind, path, cause: error.cause, fix: error.fix };
|
|
142
|
+
}
|
package/src/errors.ts
CHANGED
|
@@ -37,6 +37,10 @@ export const DB_OWNED_ERROR_CODES = [
|
|
|
37
37
|
'X_MIGRATE_CONCURRENT',
|
|
38
38
|
'X_SQL_UNSAFE',
|
|
39
39
|
'X_BRANCH_EXISTS',
|
|
40
|
+
'X_SCHEMA_DUMP_DRIFT',
|
|
41
|
+
'X_DB_TRANSACTION_ABORTED',
|
|
42
|
+
'X_DB_COMMIT_UNKNOWN',
|
|
43
|
+
'X_DB_SIBLING_SCOPE_TIMEOUT',
|
|
40
44
|
] as const;
|
|
41
45
|
|
|
42
46
|
/**
|
|
@@ -81,6 +85,10 @@ export const DB_ERROR_TITLES: Readonly<Record<DbOwnedErrorCode, string>> = {
|
|
|
81
85
|
X_MIGRATION_VIEW_DEPENDS: 'a view is compiled against a column this migration retypes',
|
|
82
86
|
X_SQL_UNSAFE: 'SQL was built by string interpolation',
|
|
83
87
|
X_BRANCH_EXISTS: 'that branch database already exists',
|
|
88
|
+
X_SCHEMA_DUMP_DRIFT: 'the committed schema dump is not what the migrations produce',
|
|
89
|
+
X_DB_TRANSACTION_ABORTED: 'the server rolled the transaction back',
|
|
90
|
+
X_DB_COMMIT_UNKNOWN: 'the connection was lost while COMMIT was in flight',
|
|
91
|
+
X_DB_SIBLING_SCOPE_TIMEOUT: 'a nested transaction scope waited too long for its sibling',
|
|
84
92
|
};
|
|
85
93
|
|
|
86
94
|
// Registered unconditionally, in one call, so a second package claiming one of db's codes fails
|
|
@@ -414,10 +422,3 @@ export const isolationLevelInvalid = (received: unknown): DbError =>
|
|
|
414
422
|
cause: `an isolation level must be one of 'read committed', 'repeatable read' or 'serializable'; got ${describeValue(received)}`,
|
|
415
423
|
fix: "withTransaction(fn, { isolation: 'serializable' }) # or 'repeatable read', or 'read committed'",
|
|
416
424
|
});
|
|
417
|
-
|
|
418
|
-
export const dbNotImplemented = (feature: string, fix: string): DbError =>
|
|
419
|
-
new DbError({
|
|
420
|
-
code: 'X_NOT_IMPLEMENTED',
|
|
421
|
-
cause: `${feature} is not implemented by this driver`,
|
|
422
|
-
fix,
|
|
423
|
-
});
|
package/src/foreign-key.ts
CHANGED
|
@@ -130,37 +130,3 @@ export function unrestorableNote(table: string, constraint: string, gone: string
|
|
|
130
130
|
`cannot be restored; ${identifier(gone).text} is gone`
|
|
131
131
|
);
|
|
132
132
|
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* The drop/add pair that moves a key's `on delete` rule — a rebuild, because Postgres has no
|
|
136
|
-
* `alter constraint` for it — for a `fix:` line an author pastes into a new migration.
|
|
137
|
-
*
|
|
138
|
-
* It lives here, beside the two writers, because it is the one caller reading values neither of
|
|
139
|
-
* them may assume: `held` is the **live catalog's** and `declared` is a `.snapshot.json`'s. Both
|
|
140
|
-
* writers refuse rather than guess — `identifier()` on a name holding a quote, a space or a
|
|
141
|
-
* backslash (all three legal inside a quoted Postgres name), and `addForeignKey` on an `on delete`
|
|
142
|
-
* rule Postgres does not have. That is exactly right for DDL this package SENDS and wrong for a
|
|
143
|
-
* `fix:` line: `diffSchema` is documented pure and total, so a pair it cannot write is a sentence,
|
|
144
|
-
* never a throw — a drift check that raises in place of its report hands the caller an exception
|
|
145
|
-
* where a verdict was asked for. The constraint is still named, because it is the only thing
|
|
146
|
-
* identifying which one, quoted by `JSON.stringify`, which escapes rather than refuses; nothing
|
|
147
|
-
* runs this string either way.
|
|
148
|
-
*/
|
|
149
|
-
export function rebuildForeignKey(
|
|
150
|
-
table: string,
|
|
151
|
-
declared: ForeignKeyDescription,
|
|
152
|
-
held: ForeignKeyDescription,
|
|
153
|
-
): string {
|
|
154
|
-
// The writers are ASKED whether they can write the pair — never a second copy of their rules
|
|
155
|
-
// beside them, which is the copy that drifts. A refusal is the answer, and nothing here reads
|
|
156
|
-
// the thrown value.
|
|
157
|
-
try {
|
|
158
|
-
return `${dropForeignKey(table, held.name)} ${addForeignKey(table, declared)}`;
|
|
159
|
-
} catch {
|
|
160
|
-
return (
|
|
161
|
-
`drop constraint ${JSON.stringify(held.name)} on table ${JSON.stringify(table)} and add ` +
|
|
162
|
-
'it back with the on delete rule the migrations declare — by hand: x db gen cannot ' +
|
|
163
|
-
'write this pair'
|
|
164
|
-
);
|
|
165
|
-
}
|
|
166
|
-
}
|
package/src/generate.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
} from './introspect';
|
|
24
24
|
import { declaredIndexes } from './invariant-ddl';
|
|
25
25
|
import { migrationIrreversible } from './migration-errors';
|
|
26
|
+
import { addChangedKey, dropChangedKey } from './primary-key';
|
|
26
27
|
import type { ReplicaIdentityInput } from './replica-identity';
|
|
27
28
|
import { replicaIdentityFullAfter, replicaIdentityPlan } from './replica-identity';
|
|
28
29
|
import type { MovedAside } from './retype-dependents';
|
|
@@ -324,6 +325,9 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
|
|
|
324
325
|
created.add(entity.table);
|
|
325
326
|
continue;
|
|
326
327
|
}
|
|
328
|
+
// The key first and last, around every column statement of the table (`primary-key.ts`): the
|
|
329
|
+
// snapshot below records `entity.primaryKey`, and until this arm existed nothing produced it.
|
|
330
|
+
dropChangedKey(entity, live, current, plan, options.name);
|
|
327
331
|
diffTable(entity, live, plan, retypedIn(retyped, entity.table));
|
|
328
332
|
const kept = new Set(entity.columns.map((column) => column.column));
|
|
329
333
|
for (const column of live.columns) {
|
|
@@ -343,6 +347,7 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
|
|
|
343
347
|
' -- data is not restored',
|
|
344
348
|
);
|
|
345
349
|
}
|
|
350
|
+
addChangedKey(entity, live, plan, options.name);
|
|
346
351
|
}
|
|
347
352
|
|
|
348
353
|
const order = dropOrder(current.tables.filter((table) => !wanted.has(table.name)));
|
package/src/index.ts
CHANGED
|
@@ -72,7 +72,6 @@ export {
|
|
|
72
72
|
DB_ERROR_RETRY,
|
|
73
73
|
DB_ERROR_TITLES,
|
|
74
74
|
DbError,
|
|
75
|
-
dbNotImplemented,
|
|
76
75
|
dbUnavailable,
|
|
77
76
|
driverError,
|
|
78
77
|
identifierUnsafe,
|
|
@@ -112,6 +111,8 @@ export {
|
|
|
112
111
|
uniqueColumns,
|
|
113
112
|
} from './invariant-ddl';
|
|
114
113
|
export { constraintExpressionUnsafe, constraintNameUnsafe } from './invariant-errors';
|
|
114
|
+
export type { DbSubscription, ListeningClient } from './listen';
|
|
115
|
+
export { canListen } from './listen';
|
|
115
116
|
export type {
|
|
116
117
|
AppliedMigration,
|
|
117
118
|
LedgerRow,
|
|
@@ -166,6 +167,8 @@ export {
|
|
|
166
167
|
} from './pglite';
|
|
167
168
|
export type { PgliteBranchInfo, PgliteBranchOptions } from './pglite-branch';
|
|
168
169
|
export { branchPglite, pgliteBranchDir } from './pglite-branch';
|
|
170
|
+
export type { LinkedExtensions, PgliteExtensionLoader } from './pglite-extensions';
|
|
171
|
+
export { linkPgliteExtensions } from './pglite-extensions';
|
|
169
172
|
export type { PoolProfile } from './pool-profile';
|
|
170
173
|
export { POOL_MAX_ENV, POOL_PROFILES, poolProfileFor } from './pool-profile';
|
|
171
174
|
export type { ReadOnlyQueryOptions, ReadOnlyQueryResult } from './readonly-query';
|
|
@@ -181,6 +184,7 @@ export { BREAKER_COOLDOWN_MS, BREAKER_FAILURES, replicatedClient } from './repli
|
|
|
181
184
|
export { type DbNode, isPlainRead } from './replica-route';
|
|
182
185
|
export type { ReplicaScope } from './replica-scope';
|
|
183
186
|
export { markScopeWrote, replicaScope, withReplicaReads } from './replica-scope';
|
|
187
|
+
export { SIBLING_SCOPE_WAIT_MS } from './sibling-turn';
|
|
184
188
|
export { snapshotJson } from './snapshot-json';
|
|
185
189
|
export { parseSnapshot } from './snapshot-parse';
|
|
186
190
|
export type { SqlFragment } from './sql';
|
|
@@ -199,8 +203,10 @@ export { DB_SQLSTATE_CODES, isRetryableState, SQLSTATE, sqlState, sqlStateCode }
|
|
|
199
203
|
export { statementFingerprint, statementKind, statementVerb } from './statement-shape';
|
|
200
204
|
export { STATEMENT_ATTRIBUTE } from './statement-span';
|
|
201
205
|
export { statementsOf } from './statement-split';
|
|
202
|
-
export
|
|
203
|
-
export {
|
|
206
|
+
export { currentTx, liveTxConnection, withTransaction } from './transaction';
|
|
207
|
+
export { commitUnknown, siblingScopeTimeout, transactionAborted } from './transaction-errors';
|
|
208
|
+
export type { DbTx, IsolationLevel, TransactionOptions } from './transaction-options';
|
|
209
|
+
export { beginStatement } from './transaction-options';
|
|
204
210
|
export type { GeneratableForm } from './ungeneratable';
|
|
205
211
|
export { GENERATABLE_FORMS, ungeneratableStatements } from './ungeneratable';
|
|
206
212
|
export type { UnrenderedDeclaration } from './unrendered';
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Single responsibility: read the WHOLE schema out of `pg_catalog` — tables and everything that is
|
|
2
|
+
// not a table — into one `CatalogDescription`, sorted in JS. `introspect()` beside it stays the
|
|
3
|
+
// entity-vocabulary reading drift compares to a snapshot; this is the catalog's own spelling, the
|
|
4
|
+
// input of the schema dump, and comparable only to another reading of itself.
|
|
5
|
+
|
|
6
|
+
import type { CatalogDescription, CatalogType } from './catalog';
|
|
7
|
+
import { by, sequenceOf, tableOf } from './catalog-fold';
|
|
8
|
+
import {
|
|
9
|
+
domainCheckRows,
|
|
10
|
+
domainRows,
|
|
11
|
+
enumRows,
|
|
12
|
+
extensionRows,
|
|
13
|
+
functionRows,
|
|
14
|
+
triggerRows,
|
|
15
|
+
unrenderedRows,
|
|
16
|
+
viewRows,
|
|
17
|
+
} from './catalog-objects';
|
|
18
|
+
import {
|
|
19
|
+
columnRows,
|
|
20
|
+
constraintRows,
|
|
21
|
+
indexRows,
|
|
22
|
+
sequenceRows,
|
|
23
|
+
tableRows,
|
|
24
|
+
} from './catalog-relations';
|
|
25
|
+
import { type DbClient, db } from './client';
|
|
26
|
+
|
|
27
|
+
export interface IntrospectCatalogOptions {
|
|
28
|
+
readonly client?: DbClient | undefined;
|
|
29
|
+
readonly schema?: string | undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function typesOf(client: DbClient, schema: string): Promise<readonly CatalogType[]> {
|
|
33
|
+
const labels = await enumRows(client, schema);
|
|
34
|
+
const domains = await domainRows(client, schema);
|
|
35
|
+
const checks = await domainCheckRows(client, schema);
|
|
36
|
+
const enums = [...new Set(labels.map((row) => row.name))].map(
|
|
37
|
+
(name): CatalogType => ({
|
|
38
|
+
kind: 'enum',
|
|
39
|
+
name,
|
|
40
|
+
labels: labels
|
|
41
|
+
.filter((row) => row.name === name)
|
|
42
|
+
.sort((a, b) => a.position - b.position)
|
|
43
|
+
.map((row) => row.label),
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
const described = domains.map(
|
|
47
|
+
(row): CatalogType => ({
|
|
48
|
+
kind: 'domain',
|
|
49
|
+
name: row.name,
|
|
50
|
+
baseType: row.base_type,
|
|
51
|
+
notNull: row.not_null,
|
|
52
|
+
default: row.expression,
|
|
53
|
+
checks: checks
|
|
54
|
+
.filter((check) => check.domain_name === row.name)
|
|
55
|
+
.sort(by((check) => check.name))
|
|
56
|
+
.map((check) => ({ name: check.name, definition: check.definition })),
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
return [...enums, ...described].sort(by((type) => type.name));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Eleven round trips, sequential: a pinned transaction connection answers one statement at a
|
|
64
|
+
* time, and this runs once per `x db gen`, never per request.
|
|
65
|
+
*/
|
|
66
|
+
export async function introspectCatalog(
|
|
67
|
+
options: IntrospectCatalogOptions = {},
|
|
68
|
+
): Promise<CatalogDescription> {
|
|
69
|
+
const client = options.client ?? db();
|
|
70
|
+
const schema = options.schema ?? 'public';
|
|
71
|
+
const tables = await tableRows(client, schema);
|
|
72
|
+
const columns = await columnRows(client, schema);
|
|
73
|
+
const constraints = await constraintRows(client, schema);
|
|
74
|
+
const sequences = await sequenceRows(client, schema);
|
|
75
|
+
const indexes = await indexRows(client, schema);
|
|
76
|
+
const views = await viewRows(client, schema);
|
|
77
|
+
const functions = await functionRows(client, schema);
|
|
78
|
+
const triggers = await triggerRows(client, schema);
|
|
79
|
+
const unrendered = await unrenderedRows(client, schema);
|
|
80
|
+
const known = new Set(tables.map((table) => table.name));
|
|
81
|
+
const materialized = new Set(views.filter((view) => view.kind === 'm').map((view) => view.name));
|
|
82
|
+
// A trigger loads only onto a relation the dump creates: a plain table, or a view (`instead
|
|
83
|
+
// of`). One on a partitioned table or a partition was filed under `09_triggers/` for a table no
|
|
84
|
+
// file creates, and the load refused the whole dump with `X_SCHEMA_DUMP_DRIFT`.
|
|
85
|
+
const viewNames = new Set(views.map((view) => view.name));
|
|
86
|
+
const loadable = (trigger: { readonly table_name: string }): boolean =>
|
|
87
|
+
known.has(trigger.table_name) || viewNames.has(trigger.table_name);
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
schema,
|
|
91
|
+
extensions: (await extensionRows(client))
|
|
92
|
+
.map((row) => ({ name: row.name }))
|
|
93
|
+
.sort(by((extension) => extension.name)),
|
|
94
|
+
types: await typesOf(client, schema),
|
|
95
|
+
sequences: sequences
|
|
96
|
+
.filter((sequence) => sequence.ownership === null)
|
|
97
|
+
.map(sequenceOf)
|
|
98
|
+
.sort(by((sequence) => sequence.name)),
|
|
99
|
+
tables: tables
|
|
100
|
+
.map((table) => tableOf(table, columns, constraints, sequences))
|
|
101
|
+
.sort(by((table) => table.name)),
|
|
102
|
+
// An index on a relation this reading does not render (a partition) has nowhere to load.
|
|
103
|
+
indexes: indexes
|
|
104
|
+
.filter((index) => known.has(index.table_name) || materialized.has(index.table_name))
|
|
105
|
+
.map((row) => ({ table: row.table_name, name: row.name, definition: row.definition }))
|
|
106
|
+
.sort(
|
|
107
|
+
by(
|
|
108
|
+
(index) => index.table,
|
|
109
|
+
(index) => index.name,
|
|
110
|
+
),
|
|
111
|
+
),
|
|
112
|
+
foreignKeys: constraints
|
|
113
|
+
.filter((constraint) => constraint.type === 'f' && known.has(constraint.table_name))
|
|
114
|
+
.map((row) => ({ table: row.table_name, name: row.name, definition: row.definition }))
|
|
115
|
+
.sort(
|
|
116
|
+
by(
|
|
117
|
+
(key) => key.table,
|
|
118
|
+
(key) => key.name,
|
|
119
|
+
),
|
|
120
|
+
),
|
|
121
|
+
views: views
|
|
122
|
+
.map((row) => ({
|
|
123
|
+
name: row.name,
|
|
124
|
+
materialized: row.kind === 'm',
|
|
125
|
+
options: row.options,
|
|
126
|
+
definition: row.definition,
|
|
127
|
+
}))
|
|
128
|
+
.sort(by((view) => view.name)),
|
|
129
|
+
functions: functions
|
|
130
|
+
.map((row) => ({ name: row.name, arguments: row.arguments, definition: row.definition }))
|
|
131
|
+
.sort(
|
|
132
|
+
by(
|
|
133
|
+
(fn) => fn.name,
|
|
134
|
+
(fn) => fn.arguments,
|
|
135
|
+
),
|
|
136
|
+
),
|
|
137
|
+
triggers: triggers
|
|
138
|
+
.filter(loadable)
|
|
139
|
+
.map((row) => ({
|
|
140
|
+
table: row.table_name,
|
|
141
|
+
name: row.name,
|
|
142
|
+
definition: row.definition,
|
|
143
|
+
enabled: row.enabled,
|
|
144
|
+
}))
|
|
145
|
+
.sort(
|
|
146
|
+
by(
|
|
147
|
+
(trigger) => trigger.table,
|
|
148
|
+
(trigger) => trigger.name,
|
|
149
|
+
),
|
|
150
|
+
),
|
|
151
|
+
unrendered: [
|
|
152
|
+
...unrendered,
|
|
153
|
+
// Named rather than dropped, the rule `CatalogUnrendered` states.
|
|
154
|
+
...triggers
|
|
155
|
+
.filter((trigger) => !loadable(trigger))
|
|
156
|
+
.map((trigger) => ({
|
|
157
|
+
kind: 'trigger',
|
|
158
|
+
name: trigger.name,
|
|
159
|
+
table_name: trigger.table_name,
|
|
160
|
+
})),
|
|
161
|
+
]
|
|
162
|
+
.map((row) => ({ kind: row.kind, name: row.name, table: row.table_name }))
|
|
163
|
+
.sort(
|
|
164
|
+
by(
|
|
165
|
+
(object) => object.kind,
|
|
166
|
+
(object) => object.table ?? '',
|
|
167
|
+
(object) => object.name,
|
|
168
|
+
),
|
|
169
|
+
),
|
|
170
|
+
};
|
|
171
|
+
}
|
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
|
|