UNPKG

@pgxsinkit/pgwasm

Version:
877 lines (802 loc) • 33.6 kB
// Began as a copy of `@electric-sql/pglite` (taken under its PostgreSQL License option, © ElectricSQL // — see NOTICE). Owned outright (ADR-0062); compatibility with PGlite is an anti-goal — evolve freely. import type { BuildCapabilities, BuildIdentity, DataDirEntry, DebugLevel, PostgresBuild, RunningPostgres, ServerExtension, StorageRequest, WireSession, } from "../build/seam"; import { DataDirExistsError, makeQueryError, PgwasmClosedError, PgwasmError, PgwasmFailedError, UnsupportedFeatureError, } from "../errors"; import type { DumpCompression, Extension, ParserOptions, Pgwasm, QueryOptions, Results, SerializerOptions, StorageDescription, Transaction, } from "../interface"; import { DatabaseError, NoticeMessage, NotificationResponseMessage, type BackendMessage, } from "../protocol/wire/messages"; import { Parser as WireParser } from "../protocol/wire/parser"; import { serialize } from "../protocol/wire/serializer"; import { query as queryTemplate } from "../templating"; import { arrayParser, arraySerializer, parsers as defaultParsers, serializers as defaultSerializers, toText, type Parser, type Serializer, } from "../types"; import { writeDataDirArchive } from "./data-dir-archive"; import { type ExecProtocolOptions, type ExecProtocolResult, type ExecProtocolStreamOptions, registerProtocolAccess, } from "./internals"; import { BUILD_MARKER_PATH, checkBuildMarker, encodeBuildMarker, PG_VERSION_PATH } from "./marker"; import { Mutex } from "./mutex"; import { quoteIdentifier, toPostgresName } from "./names"; import { parseDescribeStatementResults, parseResults } from "./parse"; import { backupBaseName, describeStorage } from "./storage"; /** Everything `createPgwasm` resolved and validated before booting. */ export interface BootPlan { readonly build: PostgresBuild; readonly storage: StorageRequest; readonly serverExtensions: readonly ServerExtension[]; readonly clientExtensions: readonly (readonly [key: string, extension: Extension])[]; /** A Store backup's entries to create the data directory from, marker included. */ readonly restore: readonly DataDirEntry[] | undefined; readonly settings: Readonly<Record<string, string>>; readonly user: string; readonly database: string; readonly username: string | undefined; readonly debug: DebugLevel; readonly relaxedDurability: boolean; readonly parsers: ParserOptions | undefined; readonly serializers: SerializerOptions | undefined; } interface CurrentQuery { results: BackendMessage[]; throwOnError: boolean; onNotice: ((notice: NoticeMessage) => void) | undefined; databaseError: DatabaseError | null; } function newQuery(throwOnError = false, onNotice?: (notice: NoticeMessage) => void): CurrentQuery { return { results: [], throwOnError, onNotice, databaseError: null }; } function describeException(error: unknown): string { if (typeof error === "object" && error !== null && "message" in error) { const { name, message } = error as { name?: unknown; message?: unknown }; return `${typeof name === "string" ? name : "Error"}: ${String(message)}`; } return String(error); } function concatChunks(chunks: readonly Uint8Array[]): Uint8Array { if (chunks.length === 1) return chunks[0] ?? new Uint8Array(0); const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0); const out = new Uint8Array(total); let offset = 0; for (const chunk of chunks) { out.set(chunk, offset); offset += chunk.byteLength; } return out; } /** * A running database over a Postgres build. Everything above the wire protocol lives here, once for * every build: queries and transactions, the persist scheduler and its latch, the failure latch, * notifications, array types, backups and the build marker. */ export class PgwasmInstance implements Pgwasm { readonly build: BuildIdentity; readonly storage: StorageDescription; readonly debug: DebugLevel; readonly waitReady: Promise<void>; readonly #capabilities: BuildCapabilities; readonly #relaxedDurability: boolean; #resolveReady: () => void = () => undefined; #rejectReady: (error: unknown) => void = () => undefined; #ready = false; #closing = false; #closed = false; // Set when the build failed underneath a statement: every later statement throws it (see #fail). #failure: { error: PgwasmFailedError } | undefined; // A background (relaxed) persist that failed: the next statement reports it. #persistFailure: { error: unknown } | undefined; #persistScheduled = false; readonly #queryMutex = new Mutex(); readonly #transactionMutex = new Mutex(); readonly #listenMutex = new Mutex(); readonly #persistMutex = new Mutex(); #running: RunningPostgres | undefined; #session: WireSession | undefined; readonly #extensionsClose: (() => Promise<void>)[] = []; #parser = new WireParser(); readonly #unsolicitedParser = new WireParser(); #currentQuery: CurrentQuery = newQuery(); readonly #notifyListeners = new Map<string, Set<(payload: string) => void>>(); readonly #globalNotifyListeners = new Set<(channel: string, payload: string) => void>(); readonly #parsers: Record<number | string, Parser>; readonly #serializers: Record<number | string, Serializer>; #arrayTypesInitialized = false; #inTransaction = false; constructor(plan: BootPlan) { this.build = plan.build.identity; this.#capabilities = plan.build.capabilities; this.storage = describeStorage(plan.storage); this.debug = plan.debug; this.#relaxedDurability = plan.relaxedDurability; this.#parsers = { ...defaultParsers, ...plan.parsers }; this.#serializers = { ...defaultSerializers, ...plan.serializers }; this.waitReady = new Promise<void>((resolve, reject) => { this.#resolveReady = resolve; this.#rejectReady = reject; }); // A failed boot is reported by createPgwasm; the rejection here must not also surface unhandled. this.waitReady.catch(() => undefined); registerProtocolAccess(this, { execProtocol: (message, options) => this.#execProtocol(message, options), execProtocolStream: (message, options) => this.#execProtocolStream(message, options), execProtocolRaw: (message, options) => this.#execProtocolRaw(message, options), execProtocolRawStream: (message, options) => this.#execProtocolRawStream(message, options), runExclusiveSession: (fn) => this.#runExclusiveSession(fn), capabilities: this.#capabilities, }); } // ─── boot ──────────────────────────────────────────────────────────────────── /** Boot the build, check the build marker, create or restore the data directory, and start. */ async boot(plan: BootPlan): Promise<void> { try { await this.#boot(plan); this.#resolveReady(); } catch (error) { this.#rejectReady(error); throw error; } } async #boot(plan: BootPlan): Promise<void> { const inits: (() => Promise<void>)[] = []; for (const [key, extension] of plan.clientExtensions) { const result = await extension.setup(this); if (result.namespace !== undefined) { if (key in this) { throw new PgwasmError(`The extension key "${key}" collides with a pgwasm member; use another key.`); } Object.defineProperty(this, key, { value: result.namespace, enumerable: true }); } if (result.init) inits.push(result.init); if (result.close) this.#extensionsClose.push(result.close); } const identity = plan.build.identity; const mounted = await plan.build.boot({ storage: plan.storage, extensions: plan.serverExtensions, user: plan.user, database: plan.database, debug: plan.debug, }); let running: RunningPostgres; try { // Build permanence (ADR-0063): refuse another build's directory before anything is written. const marker = await mounted.readFile(BUILD_MARKER_PATH); const hasCluster = (await mounted.readFile(PG_VERSION_PATH)) !== undefined; checkBuildMarker(identity, marker, hasCluster, "data-directory"); if (plan.restore !== undefined) { if (hasCluster) throw new DataDirExistsError(); this.#log("pgwasm: creating the data directory from a Store backup"); await mounted.writeEntries(plan.restore); await mounted.persist(); } else if (!hasCluster) { this.#log("pgwasm: no database in the data directory, creating one"); await mounted.createCluster(); if (marker === undefined) await mounted.writeFile(BUILD_MARKER_PATH, encodeBuildMarker(identity)); await mounted.persist(); } running = await mounted.start({ settings: plan.settings }); } catch (error) { try { await mounted.release(); } catch { // The boot already failed; its first cause is what the caller needs. } throw error; } this.#running = running; try { const session = await running.openSession(); session.onUnsolicited = (chunk) => this.#handleUnsolicited(chunk); this.#session = session; this.#ready = true; if (plan.username !== undefined) { await this.exec(`SET ROLE ${quoteIdentifier(plan.username)}`); } await this.#initArrayTypes(); for (const init of inits) { await init(); } } catch (error) { this.#ready = false; this.#closed = true; try { // A background persist a statement of the boot started (an extension's init) must settle first: // releasing tears its storage down under it. await this.#persistMutex.runExclusive(async () => {}); await running.release({ afterFailedBoot: true }); } catch { // Preserve the error that made the boot fail. } throw error; } } // ─── state ─────────────────────────────────────────────────────────────────── get ready(): boolean { return this.#ready && !this.#closing && !this.#closed && this.#failure === undefined; } get closed(): boolean { return this.#closed; } async #checkReady(): Promise<void> { if (this.#closing) throw new PgwasmClosedError("closing"); if (this.#closed) throw new PgwasmClosedError("closed"); if (!this.#ready) await this.waitReady; } #checkOpenState(): void { if (this.#closing) throw new PgwasmClosedError("closing"); if (this.#closed) throw new PgwasmClosedError("closed"); if (this.#failure) throw this.#failure.error; } #checkPersistLatch(): void { if (this.#persistFailure) throw this.#persistFailure.error; } /** * Latch the instance as failed and return the error the failing statement and every later one * throw. It names the exception and, when Postgres reported an error first (a PANIC does, before it * aborts), that error. */ #fail(cause: unknown): PgwasmFailedError { if (this.#failure) return this.#failure.error; const reported = this.#currentQuery.databaseError; const error = new PgwasmFailedError( "pgwasm failed and must be closed: " + (reported ? `Postgres reported ${reported.severity ?? "an error"}: ${reported.message}; the build then threw ` : "the build threw ") + describeException(cause), { cause }, ); this.#failure = { error }; return error; } #log(...args: unknown[]): void { if (this.debug > 0) console.log(...args); } // ─── the wire ──────────────────────────────────────────────────────────────── #exchange(message: Uint8Array, onData: (chunk: Uint8Array) => void): void | Promise<void> { this.#checkOpenState(); const session = this.#session; if (session === undefined) throw new PgwasmClosedError("closed"); let pending: void | Promise<void>; try { pending = session.exchange(message, onData); } catch (error) { throw this.#fail(error); } if (pending === undefined) return; return pending.then(undefined, (error: unknown) => { throw this.#fail(error); }); } #parseChunk(chunk: Uint8Array): void { this.#parser.parse(chunk, (message) => { const handled = this.#handleMessage(message); if (handled) this.#currentQuery.results.push(handled); }); } #handleUnsolicited(chunk: Uint8Array): void { this.#unsolicitedParser.parse(chunk, (message) => { if (message instanceof NotificationResponseMessage) this.#dispatchNotification(message); }); } #handleMessage(message: BackendMessage): BackendMessage | null { // Once a statement reported an error, the rest of its reply only needs acknowledging. if (this.#currentQuery.databaseError) return null; if (message instanceof DatabaseError) { if (this.#currentQuery.throwOnError) this.#currentQuery.databaseError = message; } else if (message instanceof NoticeMessage) { if (this.debug > 0) console.warn(message); this.#currentQuery.onNotice?.(message); } else if (message instanceof NotificationResponseMessage) { this.#dispatchNotification(message); } return message; } #dispatchNotification(message: NotificationResponseMessage): void { // Callbacks run after the synchronous code that received the notification. const listeners = this.#notifyListeners.get(message.channel); if (listeners) { for (const callback of listeners) queueMicrotask(() => callback(message.payload)); } for (const callback of this.#globalNotifyListeners) { queueMicrotask(() => callback(message.channel, message.payload)); } } async #execProtocolRaw(message: Uint8Array, { persist = true }: Pick<ExecProtocolOptions, "persist"> = {}) { // Checks only (no await) before the exchange, so a build that exchanges synchronously delivers the // reply inside this call's synchronous prefix. this.#checkPersistLatch(); const chunks: Uint8Array[] = []; const pending = this.#exchange(message, (chunk) => { chunks.push(chunk.slice()); this.#parseChunk(chunk); }); if (pending !== undefined) await pending; if (persist) await this.#persistAfterStatement(); return concatChunks(chunks); } async #execProtocolRawStream( message: Uint8Array, { persist = true, onRawData }: ExecProtocolStreamOptions, ): Promise<void> { // Same synchronous-prefix contract as #execProtocolRaw: pg_dump's write callback drives this from // inside a blocking wasm call and reads the reply back before it returns. this.#checkPersistLatch(); const pending = this.#exchange(message, onRawData); if (pending !== undefined) await pending; if (persist) await this.#persistAfterStatement(); } async #execProtocol( message: Uint8Array, { persist = true, throwOnError = true, onNotice }: ExecProtocolOptions = {}, ): Promise<ExecProtocolResult> { this.#currentQuery = newQuery(throwOnError, onNotice); try { const data = await this.#execProtocolRaw(message, { persist }); const databaseError = this.#currentQuery.databaseError; const result = { messages: this.#currentQuery.results, data }; if (throwOnError && databaseError) { this.#parser = new WireParser(); throw databaseError; } return result; } finally { this.#currentQuery = newQuery(); } } async #execProtocolStream( message: Uint8Array, { persist = true, throwOnError = true, onNotice }: ExecProtocolOptions = {}, ): Promise<BackendMessage[]> { this.#currentQuery = newQuery(throwOnError, onNotice); try { this.#checkPersistLatch(); const pending = this.#exchange(message, (chunk) => this.#parseChunk(chunk)); if (pending !== undefined) await pending; if (persist) await this.#persistAfterStatement(); const databaseError = this.#currentQuery.databaseError; const results = this.#currentQuery.results; if (throwOnError && databaseError) { this.#parser = new WireParser(); throw databaseError; } return results; } finally { this.#currentQuery = newQuery(); } } // ─── durability ────────────────────────────────────────────────────────────── /** * Persist storage after a statement. Awaited by default; with `relaxedDurability` it runs in the * background and a failure is latched for the next statement. An awaited failure is not latched: the * caller already has it, and a filesystem with its own failure policy decides what later calls see. */ async #persistAfterStatement(): Promise<void> { this.#checkOpenState(); this.#checkPersistLatch(); const running = this.#running; if (running === undefined || this.#persistScheduled) return; this.#persistScheduled = true; const run = () => this.#persistMutex.runExclusive(async () => { this.#persistScheduled = false; await running.persist(this.#relaxedDurability); }); if (this.#relaxedDurability) { run().catch((error: unknown) => { this.#persistFailure ??= { error }; }); } else { await run(); } } // ─── queries ───────────────────────────────────────────────────────────────── #runExclusiveQuery<T>(fn: () => Promise<T>): Promise<T> { return this.#queryMutex.runExclusive(async () => { this.#checkPersistLatch(); return await fn(); }); } async #execNoPersist(message: Uint8Array, options?: QueryOptions): Promise<BackendMessage[]> { return await this.#execProtocolStream(message, { persist: false, ...(options?.onNotice ? { onNotice: options.onNotice } : {}), }); } async #handleBlob(blob: Blob | undefined): Promise<void> { const device = this.#running?.blob; if (blob === undefined) { device?.setReadSource(undefined); return; } if (device === undefined) { throw new UnsupportedFeatureError( `The "${this.build.name}" Postgres build has no /dev/blob device, so the \`blob\` query option is not available.`, ); } device.setReadSource(new Uint8Array(await blob.arrayBuffer())); } #cleanupBlob(): void { this.#running?.blob?.setReadSource(undefined); } #takeWrittenBlob(): Blob | undefined { const chunks = this.#running?.blob?.takeWritten(); return chunks === undefined ? undefined : new Blob(chunks); } #serializeParams( params: readonly unknown[], dataTypeIDs: readonly number[], options?: QueryOptions, ): (string | null)[] { return params.map((param, index) => { if (param === null || param === undefined) return null; const oid = dataTypeIDs[index] ?? 0; const serializer = options?.serializers?.[oid] ?? this.#serializers[oid]; return serializer ? serializer(param) : toText(param); }); } async #runQuery<T>(sql: string, params: readonly unknown[] = [], options?: QueryOptions): Promise<Results<T>> { return await this.#runExclusiveQuery(async () => { this.#log("runQuery", sql, params, options); await this.#handleBlob(options?.blob); let results: BackendMessage[] = []; try { const parsed = await this.#execNoPersist( serialize.parse({ text: sql, ...(options?.paramTypes ? { types: options.paramTypes } : {}) }), options, ); const dataTypeIDs = parseDescribeStatementResults( await this.#execNoPersist(serialize.describe({ type: "S" }), options), ); const values = this.#serializeParams(params, dataTypeIDs, options); results = [ ...parsed, ...(await this.#execNoPersist(serialize.bind({ values }), options)), ...(await this.#execNoPersist(serialize.describe({ type: "P" }), options)), ...(await this.#execNoPersist(serialize.execute({}), options)), ]; } catch (error) { if (error instanceof DatabaseError) { throw makeQueryError({ error, query: sql, params, options }); } throw error; } finally { results.push(...(await this.#execNoPersist(serialize.sync(), options))); } this.#cleanupBlob(); if (!this.#inTransaction) await this.#persistAfterStatement(); const blob = this.#takeWrittenBlob(); // The row type is the caller's assertion, as with any driver. return parseResults(results, this.#parsers, options, blob)[0] as Results<T>; }); } async #runExec(sql: string, options?: QueryOptions): Promise<Results[]> { return await this.#runExclusiveQuery(async () => { this.#log("runExec", sql, options); await this.#handleBlob(options?.blob); let results: BackendMessage[] = []; try { results = await this.#execNoPersist(serialize.query(sql), options); } catch (error) { if (error instanceof DatabaseError) { throw makeQueryError({ error, query: sql, params: undefined, options }); } throw error; } finally { results.push(...(await this.#execNoPersist(serialize.sync(), options))); } this.#cleanupBlob(); if (!this.#inTransaction) await this.#persistAfterStatement(); const blob = this.#takeWrittenBlob(); return parseResults(results, this.#parsers, options, blob) as Results[]; }); } async query<T = Record<string, unknown>>( sql: string, params?: readonly unknown[], options?: QueryOptions, ): Promise<Results<T>> { await this.#checkReady(); // One statement at a time, never inside another caller's transaction. return await this.#transactionMutex.runExclusive(() => this.#runQuery<T>(sql, params, options)); } async sql<T = Record<string, unknown>>(sqlStrings: TemplateStringsArray, ...params: unknown[]): Promise<Results<T>> { const { query, params: actualParams } = queryTemplate(sqlStrings, ...params); return await this.query<T>(query, actualParams); } async exec(sql: string, options?: QueryOptions): Promise<Results[]> { await this.#checkReady(); return await this.#transactionMutex.runExclusive(() => this.#runExec(sql, options)); } async transaction<T>(callback: (tx: Transaction) => Promise<T>): Promise<T> { await this.#checkReady(); return await this.#transactionMutex.runExclusive(async () => { await this.#runExec("BEGIN"); this.#inTransaction = true; // A transaction handle throws once its transaction has ended. let closed = false; const checkClosed = () => { if (closed) throw new PgwasmError("Transaction is closed"); }; const tx: Transaction = { query: async <R>(sql: string, params?: readonly unknown[], options?: QueryOptions) => { checkClosed(); return await this.#runQuery<R>(sql, params, options); }, sql: async <R>(sqlStrings: TemplateStringsArray, ...params: unknown[]) => { checkClosed(); const { query, params: actualParams } = queryTemplate(sqlStrings, ...params); return await this.#runQuery<R>(query, actualParams); }, exec: async (sql: string, options?: QueryOptions) => { checkClosed(); return await this.#runExec(sql, options); }, rollback: async () => { checkClosed(); await this.#runExec("ROLLBACK"); closed = true; }, listen: async (channel: string, callback: (payload: string) => void) => { checkClosed(); return await this.listen(channel, callback, tx); }, get closed() { return closed; }, }; try { const result = await callback(tx); // Leave the in-transaction state before the terminal statement, so COMMIT persists like any // top-level statement instead of waiting for some later query. this.#inTransaction = false; if (!closed) { closed = true; await this.#runExec("COMMIT"); } else { // An explicit tx.rollback() ran inside the transaction; persist its outcome now. await this.#persistAfterStatement(); } return result; } catch (error) { this.#inTransaction = false; if (!closed) { closed = true; await this.#runExec("ROLLBACK"); } else { // The transaction ended without reaching a persist: an explicit rollback, or a COMMIT that // threw. End at a persist boundary anyway, but never mask the original error with a persist // failure; a failing storage reports again on the next statement. try { await this.#persistAfterStatement(); } catch { // the original error takes precedence } } throw error; } }); } async runExclusive<T>(fn: () => Promise<T>): Promise<T> { return await this.#runExclusiveQuery(fn); } /** `/protocol`'s exclusive session: the transaction lock, then the query lock, for `fn`'s whole run. */ async #runExclusiveSession<T>(fn: () => Promise<T>): Promise<T> { await this.#checkReady(); return await this.#transactionMutex.runExclusive(() => this.#queryMutex.runExclusive(async () => { this.#checkOpenState(); this.#checkPersistLatch(); return await fn(); }), ); } // ─── array types ───────────────────────────────────────────────────────────── async #initArrayTypes({ force = false } = {}): Promise<void> { if (this.#arrayTypesInitialized && !force) return; this.#arrayTypesInitialized = true; const types = await this.query<{ oid: number; typarray: number }>(` SELECT b.oid, b.typarray FROM pg_catalog.pg_type a LEFT JOIN pg_catalog.pg_type b ON b.oid = a.typelem WHERE a.typcategory = 'A' GROUP BY b.oid, b.typarray ORDER BY b.oid `); for (const type of types.rows) { this.#serializers[type.typarray] = (value) => arraySerializer(value, this.#serializers[type.oid], type.typarray); this.#parsers[type.typarray] = (value) => arrayParser(value, this.#parsers[type.oid], type.typarray); } } async refreshArrayTypes(): Promise<void> { await this.#initArrayTypes({ force: true }); } // ─── notifications ─────────────────────────────────────────────────────────── // Lock order: the transaction lock, then the listen lock, then the query lock. A transaction's // tx.listen() runs with the transaction lock already held, so a top-level listen must take the // transaction lock before the listen lock too, or each would hold the lock the other waits for. async listen( channel: string, callback: (payload: string) => void, tx?: Transaction, ): Promise<(tx?: Transaction) => Promise<void>> { return await this.#withListenLocks(tx, (exec) => this.#subscribe(channel, callback, exec)); } async unlisten(channel: string, callback?: (payload: string) => void, tx?: Transaction): Promise<void> { await this.#withListenLocks(tx, (exec) => this.#unsubscribe(channel, callback, exec)); } async #withListenLocks<T>( tx: Transaction | undefined, fn: (exec: (sql: string) => Promise<unknown>) => Promise<T>, ): Promise<T> { if (tx) return await this.#listenMutex.runExclusive(() => fn((sql) => tx.exec(sql))); await this.#checkReady(); return await this.#transactionMutex.runExclusive(() => this.#listenMutex.runExclusive(() => fn((sql) => this.#runExec(sql))), ); } async #subscribe( channel: string, callback: (payload: string) => void, exec: (sql: string) => Promise<unknown>, ): Promise<(tx?: Transaction) => Promise<void>> { const pgChannel = toPostgresName(channel); let listeners = this.#notifyListeners.get(pgChannel); if (!listeners) { listeners = new Set(); this.#notifyListeners.set(pgChannel, listeners); } listeners.add(callback); try { await exec(`LISTEN ${channel}`); } catch (error) { listeners.delete(callback); if (listeners.size === 0) this.#notifyListeners.delete(pgChannel); throw error; } // By the channel as the caller wrote it: unlisten() normalises it the same way listen() did. return async (unlistenTx?: Transaction) => { await this.unlisten(channel, callback, unlistenTx); }; } async #unsubscribe( channel: string, callback: ((payload: string) => void) | undefined, exec: (sql: string) => Promise<unknown>, ): Promise<void> { const pgChannel = toPostgresName(channel); const cleanUp = async () => { await exec(`UNLISTEN ${channel}`); // Another caller may have subscribed while UNLISTEN ran. if (this.#notifyListeners.get(pgChannel)?.size === 0) this.#notifyListeners.delete(pgChannel); }; if (callback) { this.#notifyListeners.get(pgChannel)?.delete(callback); if (this.#notifyListeners.get(pgChannel)?.size === 0) await cleanUp(); } else { await cleanUp(); } } onNotification(callback: (channel: string, payload: string) => void): () => void { this.#globalNotifyListeners.add(callback); return () => { this.#globalNotifyListeners.delete(callback); }; } // ─── backups ───────────────────────────────────────────────────────────────── /** * A Store backup. It reads the data directory under the query lock, so no statement runs during the * read; do not call it from inside `runExclusive`. */ async dumpDataDir(compression?: DumpCompression): Promise<File> { await this.#checkReady(); const running = this.#running; if (running === undefined) throw new PgwasmClosedError("closed"); const entries = await this.#queryMutex.runExclusive(async () => { this.#checkOpenState(); return await running.readEntries(); }); return await writeDataDirArchive(entries, backupBaseName(this.storage), compression); } // ─── close ─────────────────────────────────────────────────────────────────── async close(): Promise<void> { await this.#checkReady(); this.#closing = true; // After a failure the build is in an unknown state: running its shutdown or persisting what it left // would build on it. Close then only releases resources, and reports the failure it closed on. const failure = this.#failure; const running = this.#running; const session = this.#session; let closeFailure: { error: unknown } | undefined; let finalPersistFailure: { error: unknown } | undefined; try { await this.#transactionMutex.runExclusive(() => this.#queryMutex.runExclusive(async () => { for (const closeFn of this.#extensionsClose) { try { await closeFn(); } catch (error) { closeFailure ??= { error }; } } if (failure || running === undefined) return; try { await session?.close(); await running.shutdown(); } catch (error) { closeFailure ??= { error }; } // Persist shutdown's writes strictly. A successful final persist also recovers a prior // background failure. try { await this.#persistMutex.runExclusive(() => running.persist(false)); this.#persistFailure = undefined; } catch (error) { finalPersistFailure = { error }; this.#persistFailure = { error }; } }), ); } finally { try { // A background persist may still be running: a close after a failure skips the final persist // that would otherwise queue behind it. Releasing tears its storage down under it, so wait first. await this.#persistMutex.runExclusive(async () => {}); // Release even when shutdown or persisting failed, so exclusive storage ownership is released. await running?.release(); } finally { this.#closed = true; this.#closing = false; this.#ready = false; this.#session = undefined; this.#running = undefined; } } if (failure) throw failure.error; if (finalPersistFailure) throw finalPersistFailure.error; if (closeFailure) throw closeFailure.error; } async [Symbol.asyncDispose](): Promise<void> { await this.close(); } }