Skip to content

Commit f05eaa7

Browse files
mverzilliaztec-bot
authored andcommitted
feat: weblock controlled opfs pool (fwd-port #24740)
1 parent eade347 commit f05eaa7

8 files changed

Lines changed: 380 additions & 21 deletions

File tree

yarn-project/kv-store/src/sqlite-opfs/errors.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,56 @@ const SQLITE3MC_DECRYPT_ERROR_PATTERNS: readonly RegExp[] = [
4242
export function isDecryptFailureMessage(message: string): boolean {
4343
return SQLITE3MC_DECRYPT_ERROR_PATTERNS.some(p => p.test(message));
4444
}
45+
<<<<<<< HEAD
46+
=======
47+
48+
/**
49+
* Error thrown by sqlite-opfs when the on-disk database image is corrupt
50+
* (`SQLITE_CORRUPT`, result code 11 — "database disk image is malformed").
51+
*
52+
* Distinct from {@link SqliteEncryptionError}: no key recovers a corrupt image,
53+
* so there is nothing to retry. The only escape is to delete the store and start
54+
* fresh, so consumers pattern-match on `instanceof SqliteCorruptionError` to wipe
55+
* and reopen rather than surfacing a dead-end error.
56+
**/
57+
export class SqliteCorruptionError extends Error {
58+
constructor(message: string, opts?: { cause?: unknown }) {
59+
super(message, opts?.cause !== undefined ? { cause: opts.cause } : undefined);
60+
this.name = 'SqliteCorruptionError';
61+
}
62+
}
63+
64+
/** Error thrown when another browser context already owns a store's OPFS pool. */
65+
export class SqlitePoolBusyError extends Error {
66+
constructor(public readonly poolDirectory: string) {
67+
super(`SQLite-OPFS pool "${poolDirectory}" is already in use by another store instance`);
68+
this.name = 'SqlitePoolBusyError';
69+
}
70+
}
71+
72+
/** Error thrown when the browser context does not expose the Web Locks API required by the OPFS SAH pool. */
73+
export class SqliteWebLocksUnavailableError extends Error {
74+
constructor() {
75+
super('SQLite-OPFS requires the Web Locks API, but it is unavailable in this browser context');
76+
this.name = 'SqliteWebLocksUnavailableError';
77+
}
78+
}
79+
80+
/**
81+
* Strings/codes raised by SQLite when a database image is corrupt. Kept disjoint
82+
* from {@link SQLITE3MC_DECRYPT_ERROR_PATTERNS}: "file is not a database"
83+
* (SQLITE_NOTADB) is the decrypt signal, whereas a malformed image is the
84+
* genuinely-unrecoverable SQLITE_CORRUPT.
85+
**/
86+
const SQLITE_CORRUPTION_ERROR_PATTERNS: readonly RegExp[] = [
87+
/database disk image is malformed/i,
88+
/\bSQLITE_CORRUPT\b/i,
89+
];
90+
91+
/**
92+
* Returns `true` if `message` matches one of the known SQLite corruption strings.
93+
**/
94+
export function isCorruptionMessage(message: string): boolean {
95+
return SQLITE_CORRUPTION_ERROR_PATTERNS.some(p => p.test(message));
96+
}
97+
>>>>>>> 386f120fb2 (feat: weblock controlled opfs pool (#24740))

yarn-project/kv-store/src/sqlite-opfs/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { initStoreForRollupAndSchemaVersion } from '../utils.js';
55
import { AztecSQLiteOPFSStore } from './store.js';
66

77
export { AztecSQLiteOPFSStore } from './store.js';
8+
<<<<<<< HEAD
89
export { SqliteEncryptionError } from './errors.js';
910
export type { SqliteEncryptionErrorCode } from './errors.js';
1011

@@ -23,6 +24,16 @@ export async function createStore(
2324
const store = await AztecSQLiteOPFSStore.open(createLogger('kv-store:sqlite-opfs'), name, false);
2425
return initStoreForRollupAndSchemaVersion(store, schemaVersion, config.rollupAddress, log);
2526
}
27+
=======
28+
export {
29+
SqliteCorruptionError,
30+
SqliteEncryptionError,
31+
SqlitePoolBusyError,
32+
SqliteWebLocksUnavailableError,
33+
} from './errors.js';
34+
export type { SqliteEncryptionErrorCode } from './errors.js';
35+
export { OPFS_POOL_DIR_PREFIX, deletePoolDirectory, deleteStore, listStores, storePoolDirectory } from './manage.js';
36+
>>>>>>> 386f120fb2 (feat: weblock controlled opfs pool (#24740))
2637

2738
export function openTmpStore(ephemeral: boolean = false): Promise<AztecSQLiteOPFSStore> {
2839
return AztecSQLiteOPFSStore.open(createLogger('kv-store:sqlite-opfs'), undefined, ephemeral);
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { normalizePoolDirectory, withPoolLock } from './pool_lock.js';
2+
3+
/** Prefix for the per-store OPFS SAH pool directories owned by this package. */
4+
export const OPFS_POOL_DIR_PREFIX = '.aztec-kv-';
5+
6+
/**
7+
* OPFS directory holding a store's SAH pool. One directory per store: the SAH-pool VFS allows only one
8+
* concurrent instance per directory and its capacity does not grow automatically, so sharing a pool across
9+
* stores would make concurrently opened stores contend for locks and orphaned stores exhaust pool slots.
10+
*/
11+
export function storePoolDirectory(effectiveName: string): string {
12+
return `${OPFS_POOL_DIR_PREFIX}${effectiveName}`;
13+
}
14+
15+
/**
16+
* Lists the names of every persistent sqlite-opfs store in this origin, by enumerating the per-store pool
17+
* directories. Includes stores other than the current one, so wallets can surface and clean up data for
18+
* networks/versions no longer in use.
19+
*/
20+
export async function listStores(): Promise<string[]> {
21+
const root = await navigator.storage.getDirectory();
22+
const names: string[] = [];
23+
for await (const [entryName, handle] of root.entries()) {
24+
if (handle.kind === 'directory' && entryName.startsWith(OPFS_POOL_DIR_PREFIX)) {
25+
names.push(entryName.slice(OPFS_POOL_DIR_PREFIX.length));
26+
}
27+
}
28+
return names;
29+
}
30+
31+
/**
32+
* Permanently deletes a store by effective name (as returned by {@link listStores}). The store must be closed:
33+
* an open store holds the directory's Web Lock and the removal will reject with `SqlitePoolBusyError`.
34+
*/
35+
export async function deleteStore(effectiveName: string): Promise<void> {
36+
await deletePoolDirectory(storePoolDirectory(effectiveName));
37+
}
38+
39+
/** Permanently deletes one OPFS SAH-pool directory after exclusively locking it. */
40+
export async function deletePoolDirectory(poolDirectory: string): Promise<void> {
41+
poolDirectory = normalizePoolDirectory(poolDirectory);
42+
await withPoolLock(poolDirectory, async () => {
43+
const root = await navigator.storage.getDirectory();
44+
await root.removeEntry(poolDirectory, { recursive: true });
45+
});
46+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { SqlitePoolBusyError, SqliteWebLocksUnavailableError } from './errors.js';
2+
3+
export const DEFAULT_SAH_POOL_DIRECTORY = '.aztec-kv';
4+
5+
const WEB_LOCK_PREFIX = 'aztec.sqlite-opfs.pool:';
6+
7+
export interface PoolLockLease {
8+
release(): Promise<void>;
9+
}
10+
11+
export function normalizePoolDirectory(poolDirectory?: string): string {
12+
const path = poolDirectory
13+
?.split('/')
14+
.filter(segment => segment.length > 0)
15+
.join('/');
16+
return path || DEFAULT_SAH_POOL_DIRECTORY;
17+
}
18+
19+
export async function acquirePoolLock(poolDirectory?: string): Promise<PoolLockLease> {
20+
if (!navigator.locks) {
21+
throw new SqliteWebLocksUnavailableError();
22+
}
23+
const normalizedDirectory = normalizePoolDirectory(poolDirectory);
24+
const acquired = Promise.withResolvers<Lock | null>();
25+
const hold = Promise.withResolvers<void>();
26+
const request = navigator.locks.request(
27+
`${WEB_LOCK_PREFIX}${normalizedDirectory}`,
28+
{ mode: 'exclusive', ifAvailable: true },
29+
async lock => {
30+
acquired.resolve(lock);
31+
if (lock) {
32+
await hold.promise;
33+
}
34+
},
35+
);
36+
request.catch(acquired.reject);
37+
38+
if (!(await acquired.promise)) {
39+
await request;
40+
throw new SqlitePoolBusyError(normalizedDirectory);
41+
}
42+
43+
let released = false;
44+
return {
45+
release: async () => {
46+
if (!released) {
47+
released = true;
48+
hold.resolve();
49+
}
50+
await request;
51+
},
52+
};
53+
}
54+
55+
export async function withPoolLock<T>(poolDirectory: string, callback: () => Promise<T>): Promise<T> {
56+
const lease = await acquirePoolLock(poolDirectory);
57+
try {
58+
return await callback();
59+
} finally {
60+
await lease.release();
61+
}
62+
}

yarn-project/kv-store/src/sqlite-opfs/store.ts

Lines changed: 42 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { SqliteEncryptionError } from './errors.js';
1414
import { SQLiteOPFSAztecMap } from './map.js';
1515
import type { ResultRow, SqlValue, WorkerRequest, WorkerResponse } from './messages.js';
1616
import { SQLiteOPFSAztecMultiMap } from './multi_map.js';
17+
import { type PoolLockLease, acquirePoolLock, normalizePoolDirectory } from './pool_lock.js';
1718
import { SQLiteOPFSAztecSet } from './set.js';
1819
import { SQLiteOPFSAztecSingleton } from './singleton.js';
1920

@@ -36,12 +37,14 @@ export class AztecSQLiteOPFSStore implements AztecAsyncKVStore {
3637
#nextId = 0;
3738
#inTx = false;
3839
#closed = false;
40+
#workerFailed = false;
3941

4042
private constructor(
4143
worker: Worker,
4244
name: string,
4345
log: Logger,
4446
public readonly isEphemeral: boolean,
47+
private readonly poolLock?: PoolLockLease,
4548
) {
4649
this.#worker = worker;
4750
this.#name = name;
@@ -57,6 +60,7 @@ export class AztecSQLiteOPFSStore implements AztecAsyncKVStore {
5760
handler.resolve(ev.data);
5861
};
5962
this.#worker.onerror = ev => {
63+
this.#workerFailed = true;
6064
this.#log.error(`SQLite worker crashed: ${ev.message}`);
6165
this.#rejectPending(`SQLite worker crashed: ${ev.message}`);
6266
};
@@ -70,6 +74,10 @@ export class AztecSQLiteOPFSStore implements AztecAsyncKVStore {
7074
* required when multiple stores coexist in the same tab, because the SAH Pool holds
7175
* an exclusive lock on its directory.
7276
*
77+
* Persistent stores hold an origin-wide Web Lock for the pool directory until close
78+
* or delete. If another store instance already owns it, open fails immediately with
79+
* `SqlitePoolBusyError`.
80+
*
7381
* Pass `encryptionKey` (exactly 32 bytes) to enable at-rest encryption via sqlite3mc's
7482
* ChaCha20 page cipher. The key buffer is **transferred** to the worker — its
7583
* ArrayBuffer detaches on the caller side after `postMessage`. This is intentional:
@@ -102,22 +110,26 @@ export class AztecSQLiteOPFSStore implements AztecAsyncKVStore {
102110
log.debug(
103111
`Opening SQLite-OPFS ${ephemeral ? 'ephemeral ' : ''}${encryptionKey ? 'encrypted ' : ''}database ${dbName}`,
104112
);
105-
const worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
106-
const store = new AztecSQLiteOPFSStore(worker, dbName, log, ephemeral);
107-
// Transfer (not clone) the key buffer to the worker so we don't leave a
108-
// second copy on the main thread. Caveat: this detaches the caller's
109-
// encryptionKey.buffer — subsequent reads from the same Uint8Array are empty.
110-
const transfer = encryptionKey ? [encryptionKey.buffer as ArrayBuffer] : undefined;
113+
const effectivePoolDirectory = ephemeral ? undefined : normalizePoolDirectory(poolDirectory);
114+
const poolLock = effectivePoolDirectory ? await acquirePoolLock(effectivePoolDirectory) : undefined;
115+
let worker: Worker | undefined;
111116
try {
117+
worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
118+
const store = new AztecSQLiteOPFSStore(worker, dbName, log, ephemeral, poolLock);
119+
// Transfer (not clone) the key buffer to the worker so we don't leave a
120+
// second copy on the main thread. Caveat: this detaches the caller's
121+
// encryptionKey.buffer — subsequent reads from the same Uint8Array are empty.
122+
const transfer = encryptionKey ? [encryptionKey.buffer as ArrayBuffer] : undefined;
112123
await store.#sendRequest(
113-
{ type: 'init', id: store.#allocId(), dbName, ephemeral, poolDirectory, encryptionKey },
124+
{ type: 'init', id: store.#allocId(), dbName, ephemeral, poolDirectory: effectivePoolDirectory, encryptionKey },
114125
transfer,
115126
);
127+
return store;
116128
} catch (err) {
117-
worker.terminate();
129+
worker?.terminate();
130+
await poolLock?.release();
118131
throw err;
119132
}
120-
return store;
121133
}
122134

123135
openMap<K extends Key, V extends Value>(name: string): AztecAsyncMap<K, V> {
@@ -179,12 +191,16 @@ export class AztecSQLiteOPFSStore implements AztecAsyncKVStore {
179191
return;
180192
}
181193
this.#closed = true;
182-
await this.#txQueue.end();
183-
await this.#sendRequest({ type: 'deleteDb', id: this.#allocId(), dbName: this.#name }).catch(err =>
184-
this.#log.warn(`SQLite deleteDb failed: ${err instanceof Error ? err.message : err}`),
185-
);
186-
this.#worker.terminate();
187-
this.#rejectPending('SQLite store deleted');
194+
try {
195+
await this.#txQueue.end();
196+
await this.#sendRequest({ type: 'deleteDb', id: this.#allocId(), dbName: this.#name }).catch(err =>
197+
this.#log.warn(`SQLite deleteDb failed: ${err instanceof Error ? err.message : err}`),
198+
);
199+
} finally {
200+
this.#worker.terminate();
201+
this.#rejectPending('SQLite store deleted');
202+
await this.poolLock?.release();
203+
}
188204
}
189205

190206
/**
@@ -204,10 +220,14 @@ export class AztecSQLiteOPFSStore implements AztecAsyncKVStore {
204220
return;
205221
}
206222
this.#closed = true;
207-
await this.#txQueue.end();
208-
await this.#sendRequest({ type: 'close', id: this.#allocId() }).catch(() => {});
209-
this.#worker.terminate();
210-
this.#rejectPending('SQLite store closed');
223+
try {
224+
await this.#txQueue.end();
225+
await this.#sendRequest({ type: 'close', id: this.#allocId() }).catch(() => {});
226+
} finally {
227+
this.#worker.terminate();
228+
this.#rejectPending('SQLite store closed');
229+
await this.poolLock?.release();
230+
}
211231
}
212232

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

270290
#sendRequest(req: WorkerRequest, transfer?: Transferable[]): Promise<WorkerResponse> {
291+
if (this.#workerFailed) {
292+
return Promise.reject(new Error('SQLite worker has crashed'));
293+
}
271294
return new Promise<WorkerResponse>((resolve, reject) => {
272295
this.#pending.set(req.id, {
273296
resolve: resp => {

0 commit comments

Comments
 (0)