@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/listen.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Single responsibility: the LISTEN seam — the one capability a pooled statement cannot carry. A
|
|
2
|
+
// subscription is a property of a SESSION, and a pooled client has none to name: the next
|
|
3
|
+
// statement runs on another connection. So the client holds one connection of its own for it,
|
|
4
|
+
// outside the pool, and this file is the shape both drivers answer with.
|
|
5
|
+
|
|
6
|
+
import { DbError } from './errors';
|
|
7
|
+
|
|
8
|
+
/** One live `LISTEN`. Idempotent: a second `unlisten()` is the first one's promise. */
|
|
9
|
+
export interface DbSubscription {
|
|
10
|
+
unlisten(): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ListeningClient {
|
|
14
|
+
/**
|
|
15
|
+
* `LISTEN <channel>` on a connection this client owns for it — never one out of the pool.
|
|
16
|
+
*
|
|
17
|
+
* `onListening` fires every time the subscription is (re-)established: once when this resolves,
|
|
18
|
+
* and again after the driver has re-dialled a connection that died. A notification sent while
|
|
19
|
+
* it was down is LOST — Postgres queues nothing for a session that is gone — so a caller that
|
|
20
|
+
* must not miss one re-reads its source of truth there.
|
|
21
|
+
*
|
|
22
|
+
* Not through a transaction-pooling proxy (PgBouncer `pool_mode = transaction`): the `LISTEN`
|
|
23
|
+
* lands on a server connection the proxy takes back at once, and nothing is ever delivered.
|
|
24
|
+
* This resolves all the same; only a notification that arrives proves the path.
|
|
25
|
+
*/
|
|
26
|
+
listen(
|
|
27
|
+
channel: string,
|
|
28
|
+
onNotify: (payload: string) => void,
|
|
29
|
+
onListening?: () => void,
|
|
30
|
+
): Promise<DbSubscription>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function canListen(client: object): client is ListeningClient {
|
|
34
|
+
return typeof (client as Partial<ListeningClient>).listen === 'function';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** What `LISTEN` takes unquoted: a channel is an identifier, and Postgres truncates past 63. */
|
|
38
|
+
const CHANNEL = /^[a-z_][a-z0-9_]{0,62}$/;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Refused before it reaches a driver: PGlite quotes the name and `Bun.SQL` validates it, and two
|
|
42
|
+
* drivers reading one string two ways is a channel that works under `x dev` and not in production.
|
|
43
|
+
* `X_SQL_UNSAFE`, the code an identifier that cannot be spliced already answers with
|
|
44
|
+
* (`identifierUnsafe`): the argument is the fix, never the database's reachability.
|
|
45
|
+
*/
|
|
46
|
+
export function assertListenChannel(channel: string): void {
|
|
47
|
+
if (CHANNEL.test(channel)) return;
|
|
48
|
+
throw new DbError({
|
|
49
|
+
code: 'X_SQL_UNSAFE',
|
|
50
|
+
cause: 'the LISTEN channel is not a lower-case identifier of at most 63 characters',
|
|
51
|
+
fix: "pass a channel matching [a-z_][a-z0-9_]*, e.g. client.listen('x_jobs_wake', onNotify)",
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The driver has no `listen` — a fake, or a runtime older than the one this package requires. */
|
|
56
|
+
export function listenUnsupported(driver: string): DbError {
|
|
57
|
+
return new DbError({
|
|
58
|
+
code: 'X_DB_UNAVAILABLE',
|
|
59
|
+
cause: `${driver} has no listen(), so this client cannot hold a LISTEN`,
|
|
60
|
+
fix: 'bun upgrade # Bun.SQL.listen ships with the Bun this package requires (>= 1.4.0)',
|
|
61
|
+
});
|
|
62
|
+
}
|
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)}`));
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Single responsibility: the objects a live database holds that replaying the migrations does not
|
|
2
|
+
// create — a trigger, function, view, type or sequence made by hand. `drift.ts` asks the same
|
|
3
|
+
// question of tables and columns against a snapshot; a snapshot records only what entities declare,
|
|
4
|
+
// so everything else is compared here, catalog against catalog, by identity and never by text.
|
|
5
|
+
|
|
6
|
+
import type { CatalogDescription } from './catalog';
|
|
7
|
+
import { psqlCommand } from './dependent-view';
|
|
8
|
+
import { FRAMEWORK_TABLE_PREFIX } from './drift';
|
|
9
|
+
import { byHand, type DriftDifference } from './drift-findings';
|
|
10
|
+
import { literal, shellInertIdentifier } from './sql';
|
|
11
|
+
|
|
12
|
+
interface ObjectIdentity {
|
|
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
|
+
*/
|
|
19
|
+
readonly kind: string;
|
|
20
|
+
readonly name: string;
|
|
21
|
+
/** What tells two objects of one name apart: a function's arguments. */
|
|
22
|
+
readonly signature: string;
|
|
23
|
+
readonly table: string | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Identity only. Two servers spell one definition differently (`pg_get_viewdef` moved between
|
|
28
|
+
* majors), and the live database is the operator's Postgres while the expected side is a replay
|
|
29
|
+
* on the embedded one — a text comparison would report every view in a correct database.
|
|
30
|
+
*
|
|
31
|
+
* Tables are `diffSchema`'s (`unexpected-table`). Extensions are left out on purpose: an operator
|
|
32
|
+
* installs `pg_stat_statements` on the server, and that is not the app's schema.
|
|
33
|
+
*/
|
|
34
|
+
function identities(catalog: CatalogDescription): readonly ObjectIdentity[] {
|
|
35
|
+
const plain = (kind: string, name: string): ObjectIdentity => ({
|
|
36
|
+
schema: catalog.schema,
|
|
37
|
+
kind,
|
|
38
|
+
name,
|
|
39
|
+
signature: '',
|
|
40
|
+
table: null,
|
|
41
|
+
});
|
|
42
|
+
return [
|
|
43
|
+
...catalog.types.map((type) => plain(type.kind === 'domain' ? 'domain' : 'type', type.name)),
|
|
44
|
+
...catalog.sequences.map((sequence) => plain('sequence', sequence.name)),
|
|
45
|
+
...catalog.views.map((view) =>
|
|
46
|
+
plain(view.materialized ? 'materialized view' : 'view', view.name),
|
|
47
|
+
),
|
|
48
|
+
...catalog.functions.map((fn) => ({ ...plain('function', fn.name), signature: fn.arguments })),
|
|
49
|
+
...catalog.triggers.map((trigger) => ({
|
|
50
|
+
...plain('trigger', trigger.name),
|
|
51
|
+
table: trigger.table,
|
|
52
|
+
})),
|
|
53
|
+
];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** What `shellInertIdentifier` refuses in a name, plus the controls a pasted line must not carry. */
|
|
57
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: matching control characters is the point.
|
|
58
|
+
const SIGNATURE_ACTIVE = /[`$\\\u0000-\u001f\u007f]/;
|
|
59
|
+
|
|
60
|
+
const keyOf = (object: ObjectIdentity): string =>
|
|
61
|
+
[object.kind, object.table ?? '', object.name, object.signature].join('\u0000');
|
|
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
|
+
|
|
89
|
+
/**
|
|
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.
|
|
95
|
+
*/
|
|
96
|
+
function unexpectedObject(object: ObjectIdentity): DriftDifference {
|
|
97
|
+
const schema = shellInertIdentifier(object.schema);
|
|
98
|
+
const name = shellInertIdentifier(object.name);
|
|
99
|
+
const table = object.table === null ? null : shellInertIdentifier(object.table);
|
|
100
|
+
const where = object.table === null ? '' : ` on table "${object.table}"`;
|
|
101
|
+
const signature = object.signature === '' ? '' : `(${object.signature})`;
|
|
102
|
+
// A function is named by its argument list, empty included: `drop function "add";` is
|
|
103
|
+
// `42725 function name is not unique` while an overload lives beside it. The list is catalog
|
|
104
|
+
// text (`pg_get_function_identity_arguments`), already quoted for SQL, so it is screened for
|
|
105
|
+
// what a shell or a pasted line would read and never escaped.
|
|
106
|
+
const args = object.kind === 'function' ? `(${object.signature})` : '';
|
|
107
|
+
const spellable =
|
|
108
|
+
schema !== null &&
|
|
109
|
+
name !== null &&
|
|
110
|
+
(object.table === null || table !== null) &&
|
|
111
|
+
!SIGNATURE_ACTIVE.test(object.signature);
|
|
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
|
+
);
|
|
140
|
+
return {
|
|
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`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Everything `live` holds that `expected` does not, framework bookkeeping excluded on the rule
|
|
150
|
+
* `appTables()` states. One direction only: an object the migrations create and the database
|
|
151
|
+
* lacks is a migration that has not run, which the ledger already reports.
|
|
152
|
+
*/
|
|
153
|
+
export function unexpectedObjects(
|
|
154
|
+
live: CatalogDescription,
|
|
155
|
+
expected: CatalogDescription,
|
|
156
|
+
): readonly DriftDifference[] {
|
|
157
|
+
const known = new Set(identities(expected).map(keyOf));
|
|
158
|
+
return identities(live)
|
|
159
|
+
.filter((object) => !(object.table ?? object.name).startsWith(FRAMEWORK_TABLE_PREFIX))
|
|
160
|
+
.filter((object) => !known.has(keyOf(object)))
|
|
161
|
+
.map(unexpectedObject);
|
|
162
|
+
}
|
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`);
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Single responsibility: which PGlite bundle a Postgres extension name resolves to, and which
|
|
2
|
+
// names resolve to none. PGlite links an extension only when it is handed over at boot, so every
|
|
3
|
+
// embedded boot — `x dev`'s database and the schema dump's scratch replay — asks this one linker,
|
|
4
|
+
// and a caller that must choose another engine asks it what is `missing`.
|
|
5
|
+
|
|
6
|
+
import { renderThrowable, stringField } from '@ultimat3/core';
|
|
7
|
+
import { DbError } from './errors';
|
|
8
|
+
import { PGLITE_PACKAGE } from './pglite-package';
|
|
9
|
+
|
|
10
|
+
export type PgliteExtensionLoader = (specifier: string) => Promise<unknown>;
|
|
11
|
+
|
|
12
|
+
export interface LinkedExtensions {
|
|
13
|
+
/** Keyed by the bundle's own export name — what PGlite's `extensions` option takes. */
|
|
14
|
+
readonly linked: Readonly<Record<string, unknown>>;
|
|
15
|
+
/** Postgres names no installed bundle answers for, sorted. */
|
|
16
|
+
readonly missing: readonly string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The name reaches a module specifier, and it is DATA — it is read out of an app's migration
|
|
21
|
+
* text. Lowercase letters, digits, `_` and `-` only, so no name can leave the package.
|
|
22
|
+
*/
|
|
23
|
+
const EXTENSION_NAME = /^[a-z][a-z0-9_-]*$/;
|
|
24
|
+
|
|
25
|
+
/** `uuid-ossp` ships as `contrib/uuid_ossp`, exporting `uuid_ossp`. */
|
|
26
|
+
export const pgliteExtensionExport = (name: string): string | undefined =>
|
|
27
|
+
EXTENSION_NAME.test(name) ? name.replaceAll('-', '_') : undefined;
|
|
28
|
+
|
|
29
|
+
/** Compiled into every Postgres: `create extension plpgsql` needs no bundle and is never missing. */
|
|
30
|
+
const BUILT_IN: ReadonlySet<string> = new Set(['plpgsql']);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Entry points of the package that are not Postgres extensions. Only the second specifier below
|
|
34
|
+
* could reach one, and `live` exports an object PGlite would accept as a plugin.
|
|
35
|
+
*/
|
|
36
|
+
const NOT_EXTENSIONS: ReadonlySet<string> = new Set([
|
|
37
|
+
'template',
|
|
38
|
+
'live',
|
|
39
|
+
'worker',
|
|
40
|
+
'nodefs',
|
|
41
|
+
'opfs_ahp',
|
|
42
|
+
'basefs',
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* `contrib/<name>` is where 0.5 ships every extension it has. `<name>` at the package root is
|
|
47
|
+
* where earlier and later lines ship the ones that are not contrib (`vector`); asked second, so
|
|
48
|
+
* an install that has it is linked without this file learning a version table.
|
|
49
|
+
*/
|
|
50
|
+
const specifiersFor = (exported: string): readonly string[] =>
|
|
51
|
+
NOT_EXTENSIONS.has(exported)
|
|
52
|
+
? [`${PGLITE_PACKAGE}/contrib/${exported}`]
|
|
53
|
+
: [`${PGLITE_PACKAGE}/contrib/${exported}`, `${PGLITE_PACKAGE}/${exported}`];
|
|
54
|
+
|
|
55
|
+
const importBundle: PgliteExtensionLoader = (specifier) => import(specifier);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* What `import()` answers for a path nothing ships — Bun says `ERR_MODULE_NOT_FOUND` for an
|
|
59
|
+
* unexported subpath too (measured on 1.4, 2026-10-01). Anything else is a bundle that IS there
|
|
60
|
+
* and failed to evaluate, and reading that as absent would boot without it and lose the reason.
|
|
61
|
+
*/
|
|
62
|
+
const NOT_SHIPPED: ReadonlySet<string> = new Set([
|
|
63
|
+
'ERR_MODULE_NOT_FOUND',
|
|
64
|
+
'MODULE_NOT_FOUND',
|
|
65
|
+
'ERR_PACKAGE_PATH_NOT_EXPORTED',
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
const bundleBroken = (specifier: string, sourceError: unknown): DbError =>
|
|
69
|
+
new DbError({
|
|
70
|
+
code: 'X_DB_UNAVAILABLE',
|
|
71
|
+
cause: `${specifier} is installed and failed to load: ${renderThrowable(sourceError)}`,
|
|
72
|
+
fix: `bun install --force # reinstall ${PGLITE_PACKAGE}; its extension bundle does not evaluate`,
|
|
73
|
+
sourceError,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
async function bundleFor(exported: string, load: PgliteExtensionLoader): Promise<unknown> {
|
|
77
|
+
for (const specifier of specifiersFor(exported)) {
|
|
78
|
+
let bundle: unknown;
|
|
79
|
+
try {
|
|
80
|
+
bundle = await load(specifier);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
// Not at this specifier: deliberately not an error — the answer is `missing`, and the
|
|
83
|
+
// caller decides what an extension the embedded database cannot link means.
|
|
84
|
+
if (NOT_SHIPPED.has(stringField(error, 'code') ?? '')) continue;
|
|
85
|
+
throw bundleBroken(specifier, error);
|
|
86
|
+
}
|
|
87
|
+
const extension = (bundle as Readonly<Record<string, unknown>> | null | undefined)?.[exported];
|
|
88
|
+
if (extension !== undefined) return extension;
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Resolve every name. Never boots anything: a bundle is a small module beside a tarball PGlite
|
|
95
|
+
* reads only when `create extension` runs. Throws only for a bundle that is installed and fails
|
|
96
|
+
* to evaluate (`X_DB_UNAVAILABLE`) — an absent one is `missing`.
|
|
97
|
+
*/
|
|
98
|
+
export async function linkPgliteExtensions(
|
|
99
|
+
names: readonly string[],
|
|
100
|
+
load: PgliteExtensionLoader = importBundle,
|
|
101
|
+
): Promise<LinkedExtensions> {
|
|
102
|
+
const linked: Record<string, unknown> = {};
|
|
103
|
+
const missing = new Set<string>();
|
|
104
|
+
for (const name of names) {
|
|
105
|
+
if (BUILT_IN.has(name)) continue;
|
|
106
|
+
const exported = pgliteExtensionExport(name);
|
|
107
|
+
const extension = exported === undefined ? undefined : await bundleFor(exported, load);
|
|
108
|
+
if (exported === undefined || extension === undefined) missing.add(name);
|
|
109
|
+
else linked[exported] = extension;
|
|
110
|
+
}
|
|
111
|
+
return { linked, missing: [...missing].sort() };
|
|
112
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Single responsibility: the optional peer's NAME. A leaf, so the modules that build specifiers
|
|
2
|
+
// from it (`pglite.ts`, `pglite-extensions.ts`, `pglite-snapshot.ts`) share one spelling without
|
|
3
|
+
// importing each other.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The optional peer's specifier. Exported because `x doctor` asks whether it RESOLVES — a resolve,
|
|
7
|
+
* never an import, since loading it boots the WASM build and takes the single-writer lock — and a
|
|
8
|
+
* diagnostic that spelled the package name a second time is a diagnostic that can name the wrong
|
|
9
|
+
* one after a rename.
|
|
10
|
+
*/
|
|
11
|
+
export const PGLITE_PACKAGE = '@electric-sql/pglite';
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Single responsibility: the cache that turns an in-memory embedded boot from an `initdb` into a
|
|
2
|
+
// restore — a post-`initdb` data-directory tarball, keyed on what produced it, kept in a file the
|
|
3
|
+
// caller names and in this process's memory. Never trusted: a file is read only through its own
|
|
4
|
+
// checksum, and anything that does not verify is deleted and rebuilt.
|
|
5
|
+
|
|
6
|
+
// why: Bun has no rename and no recursive remove; the write is a temp name then an atomic rename.
|
|
7
|
+
import { rename, rm } from 'node:fs/promises';
|
|
8
|
+
// why: Bun ships no path joiner.
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
import { PGLITE_PACKAGE } from './pglite-package';
|
|
11
|
+
|
|
12
|
+
/** Bumped when the file layout below changes, so an old file is a miss rather than a misread. */
|
|
13
|
+
const SNAPSHOT_FORMAT = 1;
|
|
14
|
+
const MAGIC = `x-pglite-snapshot:${SNAPSHOT_FORMAT}:`;
|
|
15
|
+
/** `MAGIC`, 64 hex characters, a newline — then the tarball. */
|
|
16
|
+
const HEADER_BYTES = MAGIC.length + 64 + 1;
|
|
17
|
+
|
|
18
|
+
const sha256 = (bytes: Uint8Array): string =>
|
|
19
|
+
new Bun.CryptoHasher('sha256').update(bytes).digest('hex');
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The installed PGlite's version, read off its own `package.json` — the package exports no
|
|
23
|
+
* version and no `./package.json`, so the entry point is resolved and its manifest read beside
|
|
24
|
+
* it. `undefined` when either step fails, which a caller reads as "do not cache": a snapshot that
|
|
25
|
+
* cannot name what produced it must not outlive an upgrade.
|
|
26
|
+
*/
|
|
27
|
+
export async function pgliteVersion(
|
|
28
|
+
resolve: (specifier: string) => string = (specifier) => import.meta.resolve(specifier),
|
|
29
|
+
): Promise<string | undefined> {
|
|
30
|
+
try {
|
|
31
|
+
const entry = Bun.fileURLToPath(resolve(PGLITE_PACKAGE));
|
|
32
|
+
const manifest: unknown = await Bun.file(join(dirname(entry), '..', 'package.json')).json();
|
|
33
|
+
const version = (manifest as { readonly version?: unknown } | null)?.version;
|
|
34
|
+
return typeof version === 'string' && version.length > 0 ? version : undefined;
|
|
35
|
+
} catch {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* What a snapshot is a snapshot OF: the PGlite build, and this file's own layout. NOT the linked
|
|
42
|
+
* extensions — `initdb` never sees them. An extension is files PGlite puts beside the data
|
|
43
|
+
* directory at boot, so one snapshot serves every extension set, and an app that adds `citext`
|
|
44
|
+
* does not pay a second `initdb` (`schema-dump.test.ts` restores a plain snapshot with one
|
|
45
|
+
* linked and creates it).
|
|
46
|
+
*/
|
|
47
|
+
export const snapshotKey = (version: string): string => `${version}-f${SNAPSHOT_FORMAT}`;
|
|
48
|
+
|
|
49
|
+
export const snapshotFile = (dir: string, key: string): string =>
|
|
50
|
+
join(dir, `pglite-${key}.snapshot`);
|
|
51
|
+
|
|
52
|
+
/** One snapshot per key for the life of the process: the second scratch boot never touches disk. */
|
|
53
|
+
const memo = new Map<string, Blob>();
|
|
54
|
+
|
|
55
|
+
export const rememberSnapshot = (key: string, blob: Blob): void => {
|
|
56
|
+
memo.set(key, blob);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const forgetSnapshot = (key: string): void => {
|
|
60
|
+
memo.delete(key);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The tarball, or `undefined` — for a file that is absent, unreadable, short, mislabelled or whose
|
|
65
|
+
* bytes do not hash to what its header says. A file that fails any of those is DELETED: it will never
|
|
66
|
+
* verify, and leaving it costs every later boot the same read.
|
|
67
|
+
*/
|
|
68
|
+
export async function readSnapshot(file: string, key: string): Promise<Blob | undefined> {
|
|
69
|
+
const held = memo.get(key);
|
|
70
|
+
if (held !== undefined) return held;
|
|
71
|
+
const onDisk = Bun.file(file);
|
|
72
|
+
if (!(await onDisk.exists())) return undefined;
|
|
73
|
+
let bytes: Uint8Array<ArrayBuffer>;
|
|
74
|
+
try {
|
|
75
|
+
bytes = new Uint8Array(await onDisk.arrayBuffer());
|
|
76
|
+
} catch {
|
|
77
|
+
// `exists()` and the read are two calls: a racing `discardSnapshot` (ENOENT) or a file this
|
|
78
|
+
// user cannot read (EACCES) lands between them. A cache that cannot be read is a miss, never
|
|
79
|
+
// a failed boot — and it is not deleted, since this process could not read it to judge it.
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
const header = new TextDecoder().decode(bytes.subarray(0, HEADER_BYTES));
|
|
83
|
+
const body = bytes.subarray(HEADER_BYTES);
|
|
84
|
+
const sound =
|
|
85
|
+
header.startsWith(MAGIC) &&
|
|
86
|
+
header.endsWith('\n') &&
|
|
87
|
+
body.length > 0 &&
|
|
88
|
+
header.slice(MAGIC.length, -1) === sha256(body);
|
|
89
|
+
if (!sound) {
|
|
90
|
+
await discardSnapshot(file, key);
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
const blob = new Blob([body]);
|
|
94
|
+
memo.set(key, blob);
|
|
95
|
+
return blob;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Written under a name no other process shares, then renamed over the target. A rename within one
|
|
100
|
+
* directory is atomic, so a reader sees the old file or the new one and never half of either, and
|
|
101
|
+
* two writers racing each leave a whole file — the last one wins, and both are correct.
|
|
102
|
+
*
|
|
103
|
+
* Best effort: a cache that cannot be written (a read-only checkout, a full disk) costs the next
|
|
104
|
+
* boot an `initdb`, which is what it cost before there was a cache.
|
|
105
|
+
*/
|
|
106
|
+
export async function writeSnapshot(file: string, key: string, blob: Blob): Promise<void> {
|
|
107
|
+
memo.set(key, blob);
|
|
108
|
+
const body = new Uint8Array(await blob.arrayBuffer());
|
|
109
|
+
const temp = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
110
|
+
try {
|
|
111
|
+
await Bun.write(temp, new Blob([`${MAGIC}${sha256(body)}\n`, body]), { createPath: true });
|
|
112
|
+
await rename(temp, file);
|
|
113
|
+
} catch {
|
|
114
|
+
await rm(temp, { force: true }).catch(() => undefined);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function discardSnapshot(file: string, key: string): Promise<void> {
|
|
119
|
+
memo.delete(key);
|
|
120
|
+
await rm(file, { force: true }).catch(() => undefined);
|
|
121
|
+
}
|