Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions yarn-project/kv-store/src/sqlite-opfs/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,22 @@ export class SqliteCorruptionError extends Error {
}
}

/** Error thrown when another browser context already owns a store's OPFS pool. */
export class SqlitePoolBusyError extends Error {
constructor(public readonly poolDirectory: string) {
super(`SQLite-OPFS pool "${poolDirectory}" is already in use by another store instance`);
this.name = 'SqlitePoolBusyError';
}
}

/** Error thrown when the browser context does not expose the Web Locks API required by the OPFS SAH pool. */
export class SqliteWebLocksUnavailableError extends Error {
constructor() {
super('SQLite-OPFS requires the Web Locks API, but it is unavailable in this browser context');
this.name = 'SqliteWebLocksUnavailableError';
}
}

/**
* Strings/codes raised by SQLite when a database image is corrupt. Kept disjoint
* from {@link SQLITE3MC_DECRYPT_ERROR_PATTERNS}: "file is not a database"
Expand Down
9 changes: 7 additions & 2 deletions yarn-project/kv-store/src/sqlite-opfs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,14 @@ import { createLogger } from '@aztec/foundation/log';
import { AztecSQLiteOPFSStore } from './store.js';

export { AztecSQLiteOPFSStore } from './store.js';
export { SqliteCorruptionError, SqliteEncryptionError } from './errors.js';
export {
SqliteCorruptionError,
SqliteEncryptionError,
SqlitePoolBusyError,
SqliteWebLocksUnavailableError,
} from './errors.js';
export type { SqliteEncryptionErrorCode } from './errors.js';
export { OPFS_POOL_DIR_PREFIX, deleteStore, listStores, storePoolDirectory } from './manage.js';
export { OPFS_POOL_DIR_PREFIX, deletePoolDirectory, deleteStore, listStores, storePoolDirectory } from './manage.js';

export function openTmpStore(ephemeral: boolean = false): Promise<AztecSQLiteOPFSStore> {
return AztecSQLiteOPFSStore.open(createLogger('kv-store:sqlite-opfs'), undefined, ephemeral);
Expand Down
16 changes: 13 additions & 3 deletions yarn-project/kv-store/src/sqlite-opfs/manage.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { normalizePoolDirectory, withPoolLock } from './pool_lock.js';

/** Prefix for the per-store OPFS SAH pool directories owned by this package. */
export const OPFS_POOL_DIR_PREFIX = '.aztec-kv-';

Expand Down Expand Up @@ -28,9 +30,17 @@ export async function listStores(): Promise<string[]> {

/**
* Permanently deletes a store by effective name (as returned by {@link listStores}). The store must be closed:
* an open store's SAH pool holds locks on the directory and the removal will reject.
* an open store holds the directory's Web Lock and the removal will reject with `SqlitePoolBusyError`.
*/
export async function deleteStore(effectiveName: string): Promise<void> {
const root = await navigator.storage.getDirectory();
await root.removeEntry(storePoolDirectory(effectiveName), { recursive: true });
await deletePoolDirectory(storePoolDirectory(effectiveName));
}

/** Permanently deletes one OPFS SAH-pool directory after exclusively locking it. */
export async function deletePoolDirectory(poolDirectory: string): Promise<void> {
poolDirectory = normalizePoolDirectory(poolDirectory);
await withPoolLock(poolDirectory, async () => {
const root = await navigator.storage.getDirectory();
await root.removeEntry(poolDirectory, { recursive: true });
});
}
62 changes: 62 additions & 0 deletions yarn-project/kv-store/src/sqlite-opfs/pool_lock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { SqlitePoolBusyError, SqliteWebLocksUnavailableError } from './errors.js';

export const DEFAULT_SAH_POOL_DIRECTORY = '.aztec-kv';

const WEB_LOCK_PREFIX = 'aztec.sqlite-opfs.pool:';

export interface PoolLockLease {
release(): Promise<void>;
}

export function normalizePoolDirectory(poolDirectory?: string): string {
const path = poolDirectory
?.split('/')
.filter(segment => segment.length > 0)
.join('/');
return path || DEFAULT_SAH_POOL_DIRECTORY;
}

export async function acquirePoolLock(poolDirectory?: string): Promise<PoolLockLease> {
if (!navigator.locks) {
throw new SqliteWebLocksUnavailableError();
}
const normalizedDirectory = normalizePoolDirectory(poolDirectory);
const acquired = Promise.withResolvers<Lock | null>();
const hold = Promise.withResolvers<void>();
const request = navigator.locks.request(
`${WEB_LOCK_PREFIX}${normalizedDirectory}`,
{ mode: 'exclusive', ifAvailable: true },
async lock => {
acquired.resolve(lock);
if (lock) {
await hold.promise;
}
},
);
request.catch(acquired.reject);

if (!(await acquired.promise)) {
await request;
throw new SqlitePoolBusyError(normalizedDirectory);
}

let released = false;
return {
release: async () => {
if (!released) {
released = true;
hold.resolve();
}
await request;
},
};
}

export async function withPoolLock<T>(poolDirectory: string, callback: () => Promise<T>): Promise<T> {
const lease = await acquirePoolLock(poolDirectory);
try {
return await callback();
} finally {
await lease.release();
}
}
61 changes: 42 additions & 19 deletions yarn-project/kv-store/src/sqlite-opfs/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { SqliteCorruptionError, SqliteEncryptionError, isCorruptionMessage } fro
import { SQLiteOPFSAztecMap } from './map.js';
import type { ResultRow, SqlValue, WorkerRequest, WorkerResponse } from './messages.js';
import { SQLiteOPFSAztecMultiMap } from './multi_map.js';
import { type PoolLockLease, acquirePoolLock, normalizePoolDirectory } from './pool_lock.js';
import { SQLiteOPFSAztecSet } from './set.js';
import { SQLiteOPFSAztecSingleton } from './singleton.js';

Expand All @@ -36,12 +37,14 @@ export class AztecSQLiteOPFSStore implements AztecAsyncKVStore {
#nextId = 0;
#inTx = false;
#closed = false;
#workerFailed = false;

private constructor(
worker: Worker,
name: string,
log: Logger,
public readonly isEphemeral: boolean,
private readonly poolLock?: PoolLockLease,
) {
this.#worker = worker;
this.#name = name;
Expand All @@ -57,6 +60,7 @@ export class AztecSQLiteOPFSStore implements AztecAsyncKVStore {
handler.resolve(ev.data);
};
this.#worker.onerror = ev => {
this.#workerFailed = true;
this.#log.error(`SQLite worker crashed: ${ev.message}`);
this.#rejectPending(`SQLite worker crashed: ${ev.message}`);
};
Expand All @@ -70,6 +74,10 @@ export class AztecSQLiteOPFSStore implements AztecAsyncKVStore {
* required when multiple stores coexist in the same tab, because the SAH Pool holds
* an exclusive lock on its directory.
*
* Persistent stores hold an origin-wide Web Lock for the pool directory until close
* or delete. If another store instance already owns it, open fails immediately with
* `SqlitePoolBusyError`.
*
* Pass `encryptionKey` (exactly 32 bytes) to enable at-rest encryption via sqlite3mc's
* ChaCha20 page cipher. The key buffer is **transferred** to the worker — its
* ArrayBuffer detaches on the caller side after `postMessage`. This is intentional:
Expand Down Expand Up @@ -102,22 +110,26 @@ export class AztecSQLiteOPFSStore implements AztecAsyncKVStore {
log.debug(
`Opening SQLite-OPFS ${ephemeral ? 'ephemeral ' : ''}${encryptionKey ? 'encrypted ' : ''}database ${dbName}`,
);
const worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
const store = new AztecSQLiteOPFSStore(worker, dbName, log, ephemeral);
// Transfer (not clone) the key buffer to the worker so we don't leave a
// second copy on the main thread. Caveat: this detaches the caller's
// encryptionKey.buffer — subsequent reads from the same Uint8Array are empty.
const transfer = encryptionKey ? [encryptionKey.buffer as ArrayBuffer] : undefined;
const effectivePoolDirectory = ephemeral ? undefined : normalizePoolDirectory(poolDirectory);
const poolLock = effectivePoolDirectory ? await acquirePoolLock(effectivePoolDirectory) : undefined;
let worker: Worker | undefined;
try {
worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
const store = new AztecSQLiteOPFSStore(worker, dbName, log, ephemeral, poolLock);
// Transfer (not clone) the key buffer to the worker so we don't leave a
// second copy on the main thread. Caveat: this detaches the caller's
// encryptionKey.buffer — subsequent reads from the same Uint8Array are empty.
const transfer = encryptionKey ? [encryptionKey.buffer as ArrayBuffer] : undefined;
await store.#sendRequest(
{ type: 'init', id: store.#allocId(), dbName, ephemeral, poolDirectory, encryptionKey },
{ type: 'init', id: store.#allocId(), dbName, ephemeral, poolDirectory: effectivePoolDirectory, encryptionKey },
transfer,
);
return store;
} catch (err) {
worker.terminate();
worker?.terminate();
await poolLock?.release();
throw err;
}
return store;
}

openMap<K extends Key, V extends Value>(name: string): AztecAsyncMap<K, V> {
Expand Down Expand Up @@ -179,12 +191,16 @@ export class AztecSQLiteOPFSStore implements AztecAsyncKVStore {
return;
}
this.#closed = true;
await this.#txQueue.end();
await this.#sendRequest({ type: 'deleteDb', id: this.#allocId(), dbName: this.#name }).catch(err =>
this.#log.warn(`SQLite deleteDb failed: ${err instanceof Error ? err.message : err}`),
);
this.#worker.terminate();
this.#rejectPending('SQLite store deleted');
try {
await this.#txQueue.end();
await this.#sendRequest({ type: 'deleteDb', id: this.#allocId(), dbName: this.#name }).catch(err =>
this.#log.warn(`SQLite deleteDb failed: ${err instanceof Error ? err.message : err}`),
);
} finally {
this.#worker.terminate();
this.#rejectPending('SQLite store deleted');
await this.poolLock?.release();
}
}

/**
Expand All @@ -204,10 +220,14 @@ export class AztecSQLiteOPFSStore implements AztecAsyncKVStore {
return;
}
this.#closed = true;
await this.#txQueue.end();
await this.#sendRequest({ type: 'close', id: this.#allocId() }).catch(() => {});
this.#worker.terminate();
this.#rejectPending('SQLite store closed');
try {
await this.#txQueue.end();
await this.#sendRequest({ type: 'close', id: this.#allocId() }).catch(() => {});
} finally {
this.#worker.terminate();
this.#rejectPending('SQLite store closed');
await this.poolLock?.release();
}
}

backupTo(_dstPath: string, _compact?: boolean): Promise<void> {
Expand Down Expand Up @@ -268,6 +288,9 @@ export class AztecSQLiteOPFSStore implements AztecAsyncKVStore {
}

#sendRequest(req: WorkerRequest, transfer?: Transferable[]): Promise<WorkerResponse> {
if (this.#workerFailed) {
return Promise.reject(new Error('SQLite worker has crashed'));
}
return new Promise<WorkerResponse>((resolve, reject) => {
this.#pending.set(req.id, {
resolve: resp => {
Expand Down
35 changes: 33 additions & 2 deletions yarn-project/kv-store/src/sqlite-opfs/store_management.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { mockLogger } from '../interfaces/utils.js';
import { AztecSQLiteOPFSStore } from './index.js';
import { AztecSQLiteOPFSStore, SqlitePoolBusyError, SqliteWebLocksUnavailableError } from './index.js';
import { deleteStore, listStores, storePoolDirectory } from './manage.js';
import { acquirePoolLock } from './pool_lock.js';

const openByName = (name: string) => AztecSQLiteOPFSStore.open(mockLogger, name, false, storePoolDirectory(name));

Expand Down Expand Up @@ -31,6 +32,36 @@ describe('sqlite-opfs store management', () => {
await deleteStore('mech_concurrent_b');
});

it('allows only one concurrent opener for a fresh store', async () => {
const name = 'mech_same_store';
const results = await Promise.allSettled([openByName(name), openByName(name)]);
const opened = results.filter(result => result.status === 'fulfilled').map(result => result.value);
const rejected = results.filter(result => result.status === 'rejected').map(result => result.reason);

expect(opened).toHaveLength(1);
expect(rejected).toHaveLength(1);
expect(rejected[0]).toBeInstanceOf(SqlitePoolBusyError);

await opened[0].close();
const reopened = await openByName(name);
await reopened.close();
await deleteStore(name);
});

it('fails clearly when Web Locks are unavailable', async () => {
const descriptor = Object.getOwnPropertyDescriptor(navigator, 'locks');
Object.defineProperty(navigator, 'locks', { configurable: true, value: undefined });
try {
await expect(acquirePoolLock('mech_no_web_locks')).rejects.toThrow(SqliteWebLocksUnavailableError);
} finally {
if (descriptor) {
Object.defineProperty(navigator, 'locks', descriptor);
} else {
delete (navigator as unknown as { locks?: LockManager }).locks;
}
}
});

it('lists created stores and deletes them', async () => {
const store = await openByName('mech_managed');
await store.openSingleton<string>('k').set('v');
Expand Down Expand Up @@ -61,7 +92,7 @@ describe('sqlite-opfs store management', () => {

it('refuses to delete a store that is currently open', async () => {
const store = await openByName('mech_locked');
await expect(deleteStore('mech_locked')).rejects.toThrow();
await expect(deleteStore('mech_locked')).rejects.toThrow(SqlitePoolBusyError);
await store.close();
await deleteStore('mech_locked');
});
Expand Down
23 changes: 17 additions & 6 deletions yarn-project/kv-store/src/sqlite-opfs/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import sqlite3InitModule, { type Database, type SAHPoolUtil, type Sqlite3Static

import { SqliteEncryptionError, type SqliteEncryptionErrorCode, isDecryptFailureMessage } from './errors.js';
import type { ResultRow, SqlValue, WorkerRequest, WorkerResponse } from './messages.js';
import { DEFAULT_SAH_POOL_DIRECTORY } from './pool_lock.js';

const SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS data (
Expand All @@ -19,7 +20,6 @@ const SCHEMA_SQL = `
CREATE UNIQUE INDEX IF NOT EXISTS idx_container_key_hash ON data(container, key, hash);
`;

const DEFAULT_SAH_POOL_DIRECTORY = '.aztec-kv';
const SAH_POOL_VFS_NAME = 'aztec-kv-opfs';
const MC_SAH_POOL_VFS_NAME = `multipleciphers-${SAH_POOL_VFS_NAME}`;

Expand Down Expand Up @@ -91,10 +91,13 @@ async function handleInit(
}

function handleClose(): void {
db?.close();
db = undefined;
dbPath = undefined;
releasePool();
try {
db?.close();
} finally {
db = undefined;
dbPath = undefined;
releasePool();
}
}

/**
Expand Down Expand Up @@ -215,7 +218,15 @@ function respond(msg: WorkerResponse): void {
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
respond({ type: 'err', id: req.id, message, encryptionCode: detectEncryptionCode(req, err, message) });
const encryptionCode = detectEncryptionCode(req, err, message);
if (req.type === 'init') {
try {
handleClose();
} catch {
// The main thread terminates this worker after a failed init, which releases any remaining OPFS handles.
}
}
respond({ type: 'err', id: req.id, message, encryptionCode });
}
};

Expand Down
10 changes: 7 additions & 3 deletions yarn-project/wallets/src/embedded/store_encryption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@
* surfaces, so callers don't leak the SAH Pool's OPFS lock.
*/
import type { Logger } from '@aztec/foundation/log';
import { AztecSQLiteOPFSStore, SqliteCorruptionError, SqliteEncryptionError } from '@aztec/kv-store/sqlite-opfs';
import {
AztecSQLiteOPFSStore,
SqliteCorruptionError,
SqliteEncryptionError,
deletePoolDirectory,
} from '@aztec/kv-store/sqlite-opfs';

/** Which of the embedded wallet's two stores failed to open. */
export type EmbeddedStoreName = 'pxe' | 'wallet';
Expand Down Expand Up @@ -66,8 +71,7 @@ const defaultWipeStore: WipeSqliteStoreFn = async poolDirectory => {
if (!poolDirectory) {
return;
}
const root = await navigator.storage.getDirectory();
await root.removeEntry(poolDirectory, { recursive: true }).catch(() => {
await deletePoolDirectory(poolDirectory).catch(() => {
// Already gone / never created — nothing to wipe.
});
};
Expand Down
Loading