@ultimat3/db 1.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/src/index.ts ADDED
@@ -0,0 +1,129 @@
1
+ // Single responsibility: the public API of @ultimat3/db. Explicit named exports only —
2
+ // `@ultimat3/auth`, `@ultimat3/entity`, `@ultimat3/jobs` and the CLI are written against this
3
+ // list, so anything not here is an implementation detail and may change.
4
+
5
+ export type { BranchInfo, BranchOptions, DropBranchOptions, ReapOptions } from './branch';
6
+ export {
7
+ assertBranchName,
8
+ createBranch,
9
+ currentDatabase,
10
+ dropBranch,
11
+ listBranches,
12
+ reapBranches,
13
+ } from './branch';
14
+ export type {
15
+ DbClient,
16
+ DbConnection,
17
+ DbHealthReport,
18
+ PoolProfile,
19
+ PostgresClient,
20
+ PostgresClientOptions,
21
+ ReservableClient,
22
+ } from './client';
23
+ export {
24
+ baseClient,
25
+ checkDb,
26
+ createPostgresClient,
27
+ db,
28
+ isReservable,
29
+ POOL_PROFILES,
30
+ poolProfileFor,
31
+ setDbClient,
32
+ } from './client';
33
+ export type { DriftDifference, DriftKind, DriftOptions, DriftReport } from './drift';
34
+ export {
35
+ assertNoDrift,
36
+ checkDrift,
37
+ diffSchema,
38
+ driftError,
39
+ expectedSchema,
40
+ } from './drift';
41
+ export type { DbErrorCode, DbErrorInit } from './errors';
42
+ export {
43
+ branchExists,
44
+ branchNameInvalid,
45
+ DB_ERROR_CODES,
46
+ DB_ERROR_TITLES,
47
+ DbError,
48
+ dbDrift,
49
+ dbNotImplemented,
50
+ dbUnavailable,
51
+ identifierUnsafe,
52
+ migrationConflict,
53
+ migrationIrreversible,
54
+ readonlyViolation,
55
+ sqlUnsafe,
56
+ } from './errors';
57
+ export type { RecordedStatement, RecordingClient, StubResponse } from './fake';
58
+ export { createRecordingClient } from './fake';
59
+ export type {
60
+ ColumnDescriptionLike,
61
+ EntityDescriptionLike,
62
+ GeneratedMigration,
63
+ GenerateOptions,
64
+ ParsedIndex,
65
+ } from './generate';
66
+ export {
67
+ generateMigration,
68
+ migrationStamp,
69
+ parseIndexName,
70
+ slugify,
71
+ snapshotOf,
72
+ } from './generate';
73
+ export type {
74
+ ColumnDescription,
75
+ ForeignKeyDescription,
76
+ IndexDescription,
77
+ IntrospectOptions,
78
+ SchemaDescription,
79
+ TableDescription,
80
+ } from './introspect';
81
+ export { buildSchema, findTable, introspect } from './introspect';
82
+ export type {
83
+ AppliedMigration,
84
+ LedgerRow,
85
+ MigrateOptions,
86
+ Migration,
87
+ MigrationReport,
88
+ RollbackOptions,
89
+ } from './migrate';
90
+ export {
91
+ auditLedger,
92
+ checksumOf,
93
+ ensureLedger,
94
+ LEDGER_TABLE,
95
+ MIGRATION_LOCK_KEY,
96
+ migrate,
97
+ migrationChecksum,
98
+ pendingMigrations,
99
+ readLedger,
100
+ rollback,
101
+ runningAppVersion,
102
+ } from './migrate';
103
+ export type {
104
+ PgliteClient,
105
+ PgliteDriver,
106
+ PgliteLoader,
107
+ PgliteModule,
108
+ PgliteOptions,
109
+ PgliteResult,
110
+ } from './pglite';
111
+ export {
112
+ createPgliteClient,
113
+ loadPgliteDriver,
114
+ PGLITE_FIX,
115
+ PGLITE_MEMORY,
116
+ pgliteDataDir,
117
+ } from './pglite';
118
+ export type { PgliteBranchInfo, PgliteBranchOptions } from './pglite-branch';
119
+ export { branchPglite, pgliteBranchDir } from './pglite-branch';
120
+ export type { MutationVerdict, ReadOnlyOptions } from './readonly';
121
+ export { assertReadOnly, inspectStatement, readOnly, stripSqlNoise } from './readonly';
122
+ export type { ReadOnlyQueryOptions, ReadOnlyQueryResult } from './readonly-query';
123
+ export { READONLY_TIMEOUT_MS, readOnlyQuery } from './readonly-query';
124
+ export type { ReadOnlyRoleOptions } from './readonly-role';
125
+ export { ensureReadOnlyRole, grantReadOnlySql, READONLY_ROLE } from './readonly-role';
126
+ export type { SqlFragment } from './sql';
127
+ export { identifier, isSqlFragment, join, literal, raw, sql } from './sql';
128
+ export type { DbTx, IsolationLevel, TransactionOptions } from './transaction';
129
+ export { beginStatement, currentTx, withTransaction } from './transaction';
@@ -0,0 +1,187 @@
1
+ // Single responsibility: read the live schema out of `information_schema` / `pg_catalog` into a
2
+ // plain, sortable description. Three consumers depend on this exact shape: drift detection, the
3
+ // generated admin dashboard's schema view, and the MCP `schema.describe` tool. Keep it JSON-safe
4
+ // and deterministically ordered — it is diffed and it is serialised.
5
+
6
+ import { type DbClient, db } from './client';
7
+ import { sql } from './sql';
8
+
9
+ export interface ColumnDescription {
10
+ readonly name: string;
11
+ /** Postgres type name as reported by the catalog (`text`, `timestamptz`, `numeric(12,2)`). */
12
+ readonly dataType: string;
13
+ readonly nullable: boolean;
14
+ readonly default: string | null;
15
+ readonly position: number;
16
+ }
17
+
18
+ export interface IndexDescription {
19
+ readonly name: string;
20
+ readonly columns: readonly string[];
21
+ readonly unique: boolean;
22
+ readonly primary: boolean;
23
+ }
24
+
25
+ export interface ForeignKeyDescription {
26
+ readonly name: string;
27
+ readonly columns: readonly string[];
28
+ readonly referencedTable: string;
29
+ readonly referencedColumns: readonly string[];
30
+ readonly onDelete: string | null;
31
+ }
32
+
33
+ export interface TableDescription {
34
+ readonly schema: string;
35
+ readonly name: string;
36
+ readonly columns: readonly ColumnDescription[];
37
+ readonly primaryKey: readonly string[];
38
+ readonly indexes: readonly IndexDescription[];
39
+ readonly foreignKeys: readonly ForeignKeyDescription[];
40
+ }
41
+
42
+ export interface SchemaDescription {
43
+ readonly tables: readonly TableDescription[];
44
+ }
45
+
46
+ export interface IntrospectOptions {
47
+ readonly client?: DbClient | undefined;
48
+ readonly schema?: string | undefined;
49
+ /** The ledger is framework bookkeeping, not user schema — excluded so it never reads as drift. */
50
+ readonly exclude?: readonly string[] | undefined;
51
+ }
52
+
53
+ interface ColumnRow {
54
+ readonly table_name: string;
55
+ readonly column_name: string;
56
+ readonly data_type: string;
57
+ readonly is_nullable: string;
58
+ readonly column_default: string | null;
59
+ readonly ordinal_position: number;
60
+ }
61
+
62
+ interface IndexRow {
63
+ readonly table_name: string;
64
+ readonly index_name: string;
65
+ readonly is_unique: boolean;
66
+ readonly is_primary: boolean;
67
+ readonly columns: readonly string[];
68
+ }
69
+
70
+ interface ForeignKeyRow {
71
+ readonly table_name: string;
72
+ readonly constraint_name: string;
73
+ readonly columns: readonly string[];
74
+ readonly referenced_table: string;
75
+ readonly referenced_columns: readonly string[];
76
+ readonly on_delete: string | null;
77
+ }
78
+
79
+ const byName = (a: { name: string }, b: { name: string }): number => (a.name < b.name ? -1 : 1);
80
+
81
+ export async function introspect(options: IntrospectOptions = {}): Promise<SchemaDescription> {
82
+ const client = options.client ?? db();
83
+ const schema = options.schema ?? 'public';
84
+ const excluded = options.exclude ?? ['x_migrations'];
85
+
86
+ const columns = await client.query<ColumnRow>(sql`
87
+ select table_name, column_name, data_type, is_nullable, column_default, ordinal_position
88
+ from information_schema.columns
89
+ where table_schema = ${schema}
90
+ order by table_name, ordinal_position
91
+ `);
92
+
93
+ const indexes = await client.query<IndexRow>(sql`
94
+ select
95
+ t.relname as table_name,
96
+ i.relname as index_name,
97
+ ix.indisunique as is_unique,
98
+ ix.indisprimary as is_primary,
99
+ array_agg(a.attname order by a.attnum) as columns
100
+ from pg_class t
101
+ join pg_namespace n on n.oid = t.relnamespace
102
+ join pg_index ix on ix.indrelid = t.oid
103
+ join pg_class i on i.oid = ix.indexrelid
104
+ join pg_attribute a on a.attrelid = t.oid and a.attnum = any(ix.indkey)
105
+ where n.nspname = ${schema} and t.relkind = 'r'
106
+ group by t.relname, i.relname, ix.indisunique, ix.indisprimary
107
+ order by t.relname, i.relname
108
+ `);
109
+
110
+ const foreignKeys = await client.query<ForeignKeyRow>(sql`
111
+ select
112
+ src.relname as table_name,
113
+ c.conname as constraint_name,
114
+ array_agg(sa.attname order by sa.attnum) as columns,
115
+ tgt.relname as referenced_table,
116
+ array_agg(ta.attname order by ta.attnum) as referenced_columns,
117
+ c.confdeltype as on_delete
118
+ from pg_constraint c
119
+ join pg_class src on src.oid = c.conrelid
120
+ join pg_class tgt on tgt.oid = c.confrelid
121
+ join pg_namespace n on n.oid = src.relnamespace
122
+ join pg_attribute sa on sa.attrelid = src.oid and sa.attnum = any(c.conkey)
123
+ join pg_attribute ta on ta.attrelid = tgt.oid and ta.attnum = any(c.confkey)
124
+ where c.contype = 'f' and n.nspname = ${schema}
125
+ group by src.relname, c.conname, tgt.relname, c.confdeltype
126
+ order by src.relname, c.conname
127
+ `);
128
+
129
+ return buildSchema(schema, excluded, columns, indexes, foreignKeys);
130
+ }
131
+
132
+ /** Pure, so the row -> description mapping is testable without a database. */
133
+ export function buildSchema(
134
+ schema: string,
135
+ excluded: readonly string[],
136
+ columns: readonly ColumnRow[],
137
+ indexes: readonly IndexRow[],
138
+ foreignKeys: readonly ForeignKeyRow[],
139
+ ): SchemaDescription {
140
+ const names = [...new Set(columns.map((row) => row.table_name))]
141
+ .filter((name) => !excluded.includes(name))
142
+ .sort();
143
+
144
+ const tables = names.map((name): TableDescription => {
145
+ const tableIndexes = indexes
146
+ .filter((row) => row.table_name === name)
147
+ .map((row) => ({
148
+ name: row.index_name,
149
+ columns: [...row.columns],
150
+ unique: row.is_unique,
151
+ primary: row.is_primary,
152
+ }))
153
+ .sort(byName);
154
+ return {
155
+ schema,
156
+ name,
157
+ columns: columns
158
+ .filter((row) => row.table_name === name)
159
+ .map((row) => ({
160
+ name: row.column_name,
161
+ dataType: row.data_type,
162
+ nullable: row.is_nullable === 'YES',
163
+ default: row.column_default,
164
+ position: row.ordinal_position,
165
+ }))
166
+ .sort(byName),
167
+ primaryKey: tableIndexes.find((index) => index.primary)?.columns ?? [],
168
+ indexes: tableIndexes,
169
+ foreignKeys: foreignKeys
170
+ .filter((row) => row.table_name === name)
171
+ .map((row) => ({
172
+ name: row.constraint_name,
173
+ columns: [...row.columns],
174
+ referencedTable: row.referenced_table,
175
+ referencedColumns: [...row.referenced_columns],
176
+ onDelete: row.on_delete,
177
+ }))
178
+ .sort(byName),
179
+ };
180
+ });
181
+
182
+ return { tables };
183
+ }
184
+
185
+ export function findTable(schema: SchemaDescription, table: string): TableDescription | undefined {
186
+ return schema.tables.find((candidate) => candidate.name === table);
187
+ }
package/src/migrate.ts ADDED
@@ -0,0 +1,228 @@
1
+ // Single responsibility: apply pending migrations and keep the `x_migrations` ledger honest.
2
+ // An advisory lock serialises concurrent migrators; a checksum pins already-applied SQL; the
3
+ // app-version fence is the `migrate` role's contract — a pod must refuse to migrate a database
4
+ // another build already owns, because the alternative is two schemas racing during a rollout.
5
+
6
+ import { baseClient, type DbClient } from './client';
7
+ import { migrationConflict } from './errors';
8
+ import type { SchemaDescription } from './introspect';
9
+ import { raw, sql } from './sql';
10
+ import { withTransaction } from './transaction';
11
+
12
+ export const LEDGER_TABLE = 'x_migrations';
13
+
14
+ /** Stable, arbitrary: every Ultimate migrator contends on this one key. */
15
+ export const MIGRATION_LOCK_KEY = 4_919_202_607;
16
+
17
+ export interface Migration {
18
+ /** Sort key and primary key. `20260726120000_add_publish_at`. */
19
+ readonly id: string;
20
+ readonly name: string;
21
+ readonly up: string;
22
+ readonly down: string;
23
+ /** Computed from `up` when absent. */
24
+ readonly checksum?: string | undefined;
25
+ /** The schema this migration leaves behind. `drift.ts` compares the live DB against it. */
26
+ readonly snapshot?: SchemaDescription | undefined;
27
+ }
28
+
29
+ export interface LedgerRow {
30
+ readonly id: string;
31
+ readonly name: string;
32
+ readonly checksum: string;
33
+ readonly applied_at: string;
34
+ readonly app_version: string;
35
+ readonly duration_ms: number;
36
+ }
37
+
38
+ export interface AppliedMigration {
39
+ readonly id: string;
40
+ readonly name: string;
41
+ readonly durationMs: number;
42
+ }
43
+
44
+ /** The `--json` payload of `x db migrate`. */
45
+ export interface MigrationReport {
46
+ readonly applied: readonly AppliedMigration[];
47
+ readonly skipped: readonly string[];
48
+ readonly durationMs: number;
49
+ readonly appVersion: string;
50
+ }
51
+
52
+ export interface MigrateOptions {
53
+ readonly migrations: readonly Migration[];
54
+ /** The running build. Defaults to `APP_VERSION`, then `dev`. */
55
+ readonly appVersion?: string | undefined;
56
+ readonly client?: DbClient | undefined;
57
+ /** Skip the advisory lock. Only `x db branch` does this, against a private database. */
58
+ readonly lock?: boolean | undefined;
59
+ }
60
+
61
+ export function checksumOf(text: string): string {
62
+ return new Bun.CryptoHasher('sha256').update(text.trim()).digest('hex').slice(0, 32);
63
+ }
64
+
65
+ export function migrationChecksum(migration: Migration): string {
66
+ return migration.checksum ?? checksumOf(migration.up);
67
+ }
68
+
69
+ export function runningAppVersion(explicit?: string | undefined): string {
70
+ return explicit ?? process.env['APP_VERSION'] ?? 'dev';
71
+ }
72
+
73
+ export async function ensureLedger(client: DbClient): Promise<void> {
74
+ await client.execute(sql`
75
+ create table if not exists ${raw(LEDGER_TABLE)} (
76
+ id text primary key,
77
+ name text not null,
78
+ checksum text not null,
79
+ applied_at timestamptz not null default now(),
80
+ app_version text not null,
81
+ duration_ms integer not null
82
+ )
83
+ `);
84
+ }
85
+
86
+ export async function readLedger(client: DbClient): Promise<readonly LedgerRow[]> {
87
+ return client.query<LedgerRow>(sql`
88
+ select id, name, checksum, applied_at, app_version, duration_ms
89
+ from ${raw(LEDGER_TABLE)}
90
+ order by id
91
+ `);
92
+ }
93
+
94
+ /**
95
+ * Every reason a migrator must stop before touching the schema. Pure, so `x db status` can
96
+ * report the same verdict without holding the lock.
97
+ */
98
+ export function auditLedger(
99
+ ledger: readonly LedgerRow[],
100
+ migrations: readonly Migration[],
101
+ appVersion: string,
102
+ ): void {
103
+ const known = new Map(migrations.map((migration) => [migration.id, migration]));
104
+
105
+ const foreign = ledger.filter((row) => !known.has(row.id) && row.app_version !== appVersion);
106
+ const first = foreign[0];
107
+ if (first !== undefined) {
108
+ throw migrationConflict(
109
+ `the ledger records migration "${first.id}" applied by app version "${first.app_version}" ` +
110
+ `but this build is "${appVersion}" and does not ship it`,
111
+ `x db status --json # then deploy app version "${first.app_version}", or roll the ledger`,
112
+ );
113
+ }
114
+
115
+ for (const row of ledger) {
116
+ const migration = known.get(row.id);
117
+ if (migration === undefined) continue;
118
+ const checksum = migrationChecksum(migration);
119
+ if (checksum === row.checksum) continue;
120
+ throw migrationConflict(
121
+ `migration "${row.id}" was applied with checksum ${row.checksum} but now hashes ${checksum}`,
122
+ `x db gen "fix ${migration.name}" # never edit an applied migration, add a new one`,
123
+ );
124
+ }
125
+ }
126
+
127
+ export function pendingMigrations(
128
+ ledger: readonly LedgerRow[],
129
+ migrations: readonly Migration[],
130
+ ): readonly Migration[] {
131
+ const applied = new Set(ledger.map((row) => row.id));
132
+ return [...migrations]
133
+ .sort((a, b) => (a.id < b.id ? -1 : 1))
134
+ .filter((migration) => !applied.has(migration.id));
135
+ }
136
+
137
+ async function withAdvisoryLock<T>(
138
+ client: DbClient,
139
+ enabled: boolean,
140
+ fn: () => Promise<T>,
141
+ ): Promise<T> {
142
+ if (!enabled) return fn();
143
+ await client.execute(sql`select pg_advisory_lock(${MIGRATION_LOCK_KEY})`);
144
+ try {
145
+ return await fn();
146
+ } finally {
147
+ await client
148
+ .execute(sql`select pg_advisory_unlock(${MIGRATION_LOCK_KEY})`)
149
+ .catch(() => undefined);
150
+ }
151
+ }
152
+
153
+ export async function migrate(options: MigrateOptions): Promise<MigrationReport> {
154
+ const client = options.client ?? baseClient();
155
+ const appVersion = runningAppVersion(options.appVersion);
156
+ const started = performance.now();
157
+
158
+ return withAdvisoryLock(client, options.lock !== false, async () => {
159
+ await ensureLedger(client);
160
+ const ledger = await readLedger(client);
161
+ auditLedger(ledger, options.migrations, appVersion);
162
+
163
+ const pending = pendingMigrations(ledger, options.migrations);
164
+ const applied: AppliedMigration[] = [];
165
+ for (const migration of pending) {
166
+ const at = performance.now();
167
+ await withTransaction(
168
+ async (tx) => {
169
+ await tx.execute(raw(migration.up));
170
+ const durationMs = Math.round(performance.now() - at);
171
+ await tx.execute(sql`
172
+ insert into ${raw(LEDGER_TABLE)} (id, name, checksum, app_version, duration_ms)
173
+ values (${migration.id}, ${migration.name}, ${migrationChecksum(migration)},
174
+ ${appVersion}, ${durationMs})
175
+ `);
176
+ },
177
+ { client },
178
+ );
179
+ applied.push({
180
+ id: migration.id,
181
+ name: migration.name,
182
+ durationMs: Math.round(performance.now() - at),
183
+ });
184
+ }
185
+
186
+ return {
187
+ applied,
188
+ skipped: ledger.map((row) => row.id),
189
+ durationMs: Math.round(performance.now() - started),
190
+ appVersion,
191
+ };
192
+ });
193
+ }
194
+
195
+ export interface RollbackOptions {
196
+ readonly migrations: readonly Migration[];
197
+ readonly client?: DbClient | undefined;
198
+ readonly steps?: number | undefined;
199
+ }
200
+
201
+ /** Reverse the newest `steps` applied migrations. `x db rollback`. */
202
+ export async function rollback(options: RollbackOptions): Promise<readonly string[]> {
203
+ const client = options.client ?? baseClient();
204
+ const steps = options.steps ?? 1;
205
+ const ledger = await readLedger(client);
206
+ const known = new Map(options.migrations.map((migration) => [migration.id, migration]));
207
+ const targets = [...ledger].reverse().slice(0, steps);
208
+ const reverted: string[] = [];
209
+
210
+ for (const row of targets) {
211
+ const migration = known.get(row.id);
212
+ if (migration === undefined) {
213
+ throw migrationConflict(
214
+ `migration "${row.id}" is in the ledger but not in this build, so its down SQL is unknown`,
215
+ `x db status --json # deploy the build that shipped "${row.id}" and roll back there`,
216
+ );
217
+ }
218
+ await withTransaction(
219
+ async (tx) => {
220
+ await tx.execute(raw(migration.down));
221
+ await tx.execute(sql`delete from ${raw(LEDGER_TABLE)} where id = ${row.id}`);
222
+ },
223
+ { client },
224
+ );
225
+ reverted.push(row.id);
226
+ }
227
+ return reverted;
228
+ }
@@ -0,0 +1,84 @@
1
+ // Single responsibility: branching the embedded database. PGlite has no `CREATE DATABASE ...
2
+ // TEMPLATE`, so a branch is a copy of the data directory — which is why the branch name is
3
+ // validated before it ever reaches a path, and why the caller must have closed the source first:
4
+ // this copies files, it does not take a snapshot of a running instance.
5
+
6
+ import { cp, mkdir, rm, stat } from 'node:fs/promises';
7
+ import { basename, dirname, join } from 'node:path';
8
+ import type { BranchInfo } from './branch';
9
+ import { assertBranchName } from './branch';
10
+ import { branchExists, dbNotImplemented, dbUnavailable } from './errors';
11
+ import { PGLITE_MEMORY, pgliteDataDir } from './pglite';
12
+
13
+ export interface PgliteBranchOptions {
14
+ /** The data directory to copy — what `x dev` is running against. A `pglite://` url also works. */
15
+ readonly from: string;
16
+ /** Where to put the branch. Defaults to `<from>-<branch>`, beside the source. */
17
+ readonly to?: string | undefined;
18
+ /** Replace an existing branch directory instead of refusing. */
19
+ readonly force?: boolean | undefined;
20
+ readonly now?: Date | undefined;
21
+ }
22
+
23
+ export interface PgliteBranchInfo extends BranchInfo {
24
+ /** Hand this straight to `createPgliteClient({ dataDir })`. */
25
+ readonly dataDir: string;
26
+ }
27
+
28
+ /** One place decides the on-disk layout, so `x db branch` and `x db reset` agree about it. */
29
+ export const pgliteBranchDir = (from: string, branch: string): string =>
30
+ join(dirname(from), `${basename(from)}-${branch}`);
31
+
32
+ async function isDirectory(path: string): Promise<boolean> {
33
+ try {
34
+ return (await stat(path)).isDirectory();
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ async function directorySize(dir: string): Promise<number> {
41
+ let total = 0;
42
+ for await (const entry of new Bun.Glob('**/*').scan({ cwd: dir, absolute: true })) {
43
+ total += Bun.file(entry).size;
44
+ }
45
+ return total;
46
+ }
47
+
48
+ /** The embedded peer of `createBranch()`: same guarantees, a directory instead of a template. */
49
+ export async function branchPglite(
50
+ branch: string,
51
+ options: PgliteBranchOptions,
52
+ ): Promise<PgliteBranchInfo> {
53
+ // The name is spliced into a filesystem path, so an unvalidated one is traversal rather than a
54
+ // typo — the same reason `createBranch` validates before reaching `CREATE DATABASE`.
55
+ assertBranchName(branch);
56
+ const from = pgliteDataDir(options.from);
57
+ if (from === PGLITE_MEMORY) {
58
+ throw dbNotImplemented(
59
+ 'branching an in-memory PGlite',
60
+ 'x dev # a branch copies .x/pgdata, so the database has to be on disk first',
61
+ );
62
+ }
63
+ if (!(await isDirectory(from))) {
64
+ throw dbUnavailable(`there is no PGlite data directory at ${from}, so nothing to branch`);
65
+ }
66
+
67
+ const to = options.to ?? pgliteBranchDir(from, branch);
68
+ if (await isDirectory(to)) {
69
+ if (options.force !== true) throw branchExists(branch);
70
+ await rm(to, { recursive: true, force: true });
71
+ }
72
+ await mkdir(dirname(to), { recursive: true });
73
+ // `node:fs` because Bun has no recursive directory copy and PGlite has no TEMPLATE to ask
74
+ // instead. Portable on purpose: the CLI used to shell out to `cp --reflink=auto`, which is a
75
+ // GNU flag that macOS does not have.
76
+ await cp(from, to, { recursive: true });
77
+
78
+ return {
79
+ name: branch,
80
+ createdAt: (options.now ?? new Date()).toISOString(),
81
+ dataDir: to,
82
+ sizeBytes: await directorySize(to),
83
+ };
84
+ }
@@ -0,0 +1,51 @@
1
+ // Single responsibility: taking turns on PGlite's one connection. Embedded Postgres is a single
2
+ // session, not a pool, so two units of work that each `BEGIN` would be sharing one transaction —
3
+ // the second `COMMIT` commits the first's uncommitted rows and the first `ROLLBACK` finds nothing
4
+ // to undo. This queue makes them consecutive instead, which is what a pinned pool connection
5
+ // gives `withTransaction` and `readOnlyQuery` on a real server.
6
+
7
+ /**
8
+ * Gives the connection back. Idempotent for free: it is a settled promise's `resolve`, not a
9
+ * counter, so a second call cannot hand out a second turn — the next caller is already awake.
10
+ */
11
+ export type Turn = () => void;
12
+
13
+ export interface TurnQueue {
14
+ /** Wait for the connection, then keep it until the returned `Turn` is called. */
15
+ take(): Promise<Turn>;
16
+ /** Take a turn, run, give it back — the whole life of a single statement. */
17
+ run<T>(work: () => Promise<T>): Promise<T>;
18
+ }
19
+
20
+ /**
21
+ * FIFO, because the tail is the only thing a new caller waits on and every caller extends it in
22
+ * the order it arrived. The tail never rejects: a turn whose work threw must still be the turn
23
+ * the next caller waits for, or one failed statement strands the connection for the process.
24
+ */
25
+ export function createTurnQueue(): TurnQueue {
26
+ let tail: Promise<void> = Promise.resolve();
27
+
28
+ async function take(): Promise<Turn> {
29
+ let release!: () => void;
30
+ const held = new Promise<void>((resolve) => {
31
+ release = resolve;
32
+ });
33
+ const mine = tail;
34
+ // Claim the slot before awaiting: two synchronous `take()` calls must queue behind each
35
+ // other, not both read the same tail and run at once.
36
+ tail = mine.then(() => held);
37
+ await mine;
38
+ return release;
39
+ }
40
+
41
+ async function run<T>(work: () => Promise<T>): Promise<T> {
42
+ const turn = await take();
43
+ try {
44
+ return await work();
45
+ } finally {
46
+ turn();
47
+ }
48
+ }
49
+
50
+ return { take, run };
51
+ }