@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.
@@ -0,0 +1,154 @@
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
+
83
+ return {
84
+ schema,
85
+ extensions: (await extensionRows(client))
86
+ .map((row) => ({ name: row.name }))
87
+ .sort(by((extension) => extension.name)),
88
+ types: await typesOf(client, schema),
89
+ sequences: sequences
90
+ .filter((sequence) => sequence.ownership === null)
91
+ .map(sequenceOf)
92
+ .sort(by((sequence) => sequence.name)),
93
+ tables: tables
94
+ .map((table) => tableOf(table, columns, constraints, sequences))
95
+ .sort(by((table) => table.name)),
96
+ // An index on a relation this reading does not render (a partition) has nowhere to load.
97
+ indexes: indexes
98
+ .filter((index) => known.has(index.table_name) || materialized.has(index.table_name))
99
+ .map((row) => ({ table: row.table_name, name: row.name, definition: row.definition }))
100
+ .sort(
101
+ by(
102
+ (index) => index.table,
103
+ (index) => index.name,
104
+ ),
105
+ ),
106
+ foreignKeys: constraints
107
+ .filter((constraint) => constraint.type === 'f' && known.has(constraint.table_name))
108
+ .map((row) => ({ table: row.table_name, name: row.name, definition: row.definition }))
109
+ .sort(
110
+ by(
111
+ (key) => key.table,
112
+ (key) => key.name,
113
+ ),
114
+ ),
115
+ views: views
116
+ .map((row) => ({
117
+ name: row.name,
118
+ materialized: row.kind === 'm',
119
+ options: row.options,
120
+ definition: row.definition,
121
+ }))
122
+ .sort(by((view) => view.name)),
123
+ functions: functions
124
+ .map((row) => ({ name: row.name, arguments: row.arguments, definition: row.definition }))
125
+ .sort(
126
+ by(
127
+ (fn) => fn.name,
128
+ (fn) => fn.arguments,
129
+ ),
130
+ ),
131
+ triggers: triggers
132
+ .map((row) => ({
133
+ table: row.table_name,
134
+ name: row.name,
135
+ definition: row.definition,
136
+ enabled: row.enabled,
137
+ }))
138
+ .sort(
139
+ by(
140
+ (trigger) => trigger.table,
141
+ (trigger) => trigger.name,
142
+ ),
143
+ ),
144
+ unrendered: unrendered
145
+ .map((row) => ({ kind: row.kind, name: row.name, table: row.table_name }))
146
+ .sort(
147
+ by(
148
+ (object) => object.kind,
149
+ (object) => object.table ?? '',
150
+ (object) => object.name,
151
+ ),
152
+ ),
153
+ };
154
+ }
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
+ }
@@ -0,0 +1,105 @@
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 { FRAMEWORK_TABLE_PREFIX } from './drift';
8
+ import type { DriftDifference } from './drift-findings';
9
+ import { shellInertIdentifier } from './sql';
10
+
11
+ interface ObjectIdentity {
12
+ /** The word `drop` takes: `trigger`, `function`, `view`, `materialized view`, `type`, `sequence`. */
13
+ readonly kind: string;
14
+ readonly name: string;
15
+ /** What tells two objects of one name apart: a function's arguments. */
16
+ readonly signature: string;
17
+ readonly table: string | null;
18
+ }
19
+
20
+ /**
21
+ * Identity only. Two servers spell one definition differently (`pg_get_viewdef` moved between
22
+ * majors), and the live database is the operator's Postgres while the expected side is a replay
23
+ * on the embedded one — a text comparison would report every view in a correct database.
24
+ *
25
+ * Tables are `diffSchema`'s (`unexpected-table`). Extensions are left out on purpose: an operator
26
+ * installs `pg_stat_statements` on the server, and that is not the app's schema.
27
+ */
28
+ function identities(catalog: CatalogDescription): readonly ObjectIdentity[] {
29
+ const plain = (kind: string, name: string): ObjectIdentity => ({
30
+ kind,
31
+ name,
32
+ signature: '',
33
+ table: null,
34
+ });
35
+ return [
36
+ ...catalog.types.map((type) => plain('type', type.name)),
37
+ ...catalog.sequences.map((sequence) => plain('sequence', sequence.name)),
38
+ ...catalog.views.map((view) =>
39
+ plain(view.materialized ? 'materialized view' : 'view', view.name),
40
+ ),
41
+ ...catalog.functions.map((fn) => ({ ...plain('function', fn.name), signature: fn.arguments })),
42
+ ...catalog.triggers.map((trigger) => ({
43
+ ...plain('trigger', trigger.name),
44
+ table: trigger.table,
45
+ })),
46
+ ];
47
+ }
48
+
49
+ /** What `shellInertIdentifier` refuses in a name, plus the controls a pasted line must not carry. */
50
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: matching control characters is the point.
51
+ const SIGNATURE_ACTIVE = /[`$\\\u0000-\u001f\u007f]/;
52
+
53
+ const keyOf = (object: ObjectIdentity): string =>
54
+ [object.kind, object.table ?? '', object.name, object.signature].join('\u0000');
55
+
56
+ /**
57
+ * The `drop` comes FIRST in the fix, and that order is the instruction: a migration that creates
58
+ * an object the database already holds fails on `already exists`, so the hand-made copy has to go
59
+ * before the migration that owns it can apply.
60
+ */
61
+ function unexpectedObject(object: ObjectIdentity): DriftDifference {
62
+ const name = shellInertIdentifier(object.name);
63
+ const table = object.table === null ? null : shellInertIdentifier(object.table);
64
+ const where = object.table === null ? '' : ` on table "${object.table}"`;
65
+ const signature = object.signature === '' ? '' : `(${object.signature})`;
66
+ // A function is dropped by its argument list, empty included: `drop function "add";` is
67
+ // `42725 function name is not unique` while an overload lives beside it. The list is catalog
68
+ // text (`pg_get_function_identity_arguments`), already quoted for SQL, so it is screened for
69
+ // what a shell or a pasted line would read and never escaped.
70
+ const args = object.kind === 'function' ? `(${object.signature})` : '';
71
+ const spellable =
72
+ name !== null &&
73
+ (object.table === null || table !== null) &&
74
+ !SIGNATURE_ACTIVE.test(object.signature);
75
+ const drop = `drop ${object.kind} ${name}${args}${table === null ? '' : ` on ${table}`};`;
76
+ return {
77
+ kind: 'unexpected-object',
78
+ table: object.table ?? object.name,
79
+ column: null,
80
+ cause: `${object.kind} "${object.name}"${signature}${where} exists in this database and no migration creates it`,
81
+ fix: spellable
82
+ ? `run ${drop} inside psql "$DATABASE_URL", then write its create statement into a ` +
83
+ 'migration and run x db migrate — or leave it dropped if nothing owns it'
84
+ : 'drop it by hand, then write its create statement into a migration and run x db migrate ' +
85
+ '— its name or arguments carry a backtick, a dollar sign, a quote, a backslash or ' +
86
+ 'whitespace, so ' +
87
+ 'no statement here can spell it',
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Everything `live` holds that `expected` does not, framework bookkeeping excluded on the rule
93
+ * `appTables()` states. One direction only: an object the migrations create and the database
94
+ * lacks is a migration that has not run, which the ledger already reports.
95
+ */
96
+ export function unexpectedObjects(
97
+ live: CatalogDescription,
98
+ expected: CatalogDescription,
99
+ ): readonly DriftDifference[] {
100
+ const known = new Set(identities(expected).map(keyOf));
101
+ return identities(live)
102
+ .filter((object) => !(object.table ?? object.name).startsWith(FRAMEWORK_TABLE_PREFIX))
103
+ .filter((object) => !known.has(keyOf(object)))
104
+ .map(unexpectedObject);
105
+ }
@@ -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
+ }