@aztec/kv-store 0.0.1-commit.a5db02d → 0.0.1-commit.aa0c64f

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.
@@ -1,8 +1,10 @@
1
1
  import { SerialQueue } from '@aztec/foundation/queue';
2
2
  import { SQLiteOPFSAztecArray } from './array.js';
3
- import { SqliteEncryptionError } from './errors.js';
3
+ import { SqliteCorruptionError, SqliteEncryptionError, isCorruptionMessage } from './errors.js';
4
4
  import { SQLiteOPFSAztecMap } from './map.js';
5
5
  import { SQLiteOPFSAztecMultiMap } from './multi_map.js';
6
+ import { quarantineDuplicatePool } from './pool_integrity.js';
7
+ import { acquirePoolLock, normalizePoolDirectory } from './pool_lock.js';
6
8
  import { SQLiteOPFSAztecSet } from './set.js';
7
9
  import { SQLiteOPFSAztecSingleton } from './singleton.js';
8
10
  /**
@@ -16,6 +18,7 @@ import { SQLiteOPFSAztecSingleton } from './singleton.js';
16
18
  * nested ops bypass it to avoid deadlock.
17
19
  */ export class AztecSQLiteOPFSStore {
18
20
  isEphemeral;
21
+ poolLock;
19
22
  #worker;
20
23
  #pending;
21
24
  #txQueue;
@@ -24,13 +27,16 @@ import { SQLiteOPFSAztecSingleton } from './singleton.js';
24
27
  #nextId;
25
28
  #inTx;
26
29
  #closed;
27
- constructor(worker, name, log, isEphemeral){
30
+ #workerFailed;
31
+ constructor(worker, name, log, isEphemeral, poolLock){
28
32
  this.isEphemeral = isEphemeral;
33
+ this.poolLock = poolLock;
29
34
  this.#pending = new Map();
30
35
  this.#txQueue = new SerialQueue();
31
36
  this.#nextId = 0;
32
37
  this.#inTx = false;
33
38
  this.#closed = false;
39
+ this.#workerFailed = false;
34
40
  this.#worker = worker;
35
41
  this.#name = name;
36
42
  this.#log = log;
@@ -45,6 +51,7 @@ import { SQLiteOPFSAztecSingleton } from './singleton.js';
45
51
  handler.resolve(ev.data);
46
52
  };
47
53
  this.#worker.onerror = (ev)=>{
54
+ this.#workerFailed = true;
48
55
  this.#log.error(`SQLite worker crashed: ${ev.message}`);
49
56
  this.#rejectPending(`SQLite worker crashed: ${ev.message}`);
50
57
  };
@@ -57,6 +64,10 @@ import { SQLiteOPFSAztecSingleton } from './singleton.js';
57
64
  * required when multiple stores coexist in the same tab, because the SAH Pool holds
58
65
  * an exclusive lock on its directory.
59
66
  *
67
+ * Persistent stores hold an origin-wide Web Lock for the pool directory until close
68
+ * or delete. If another store instance already owns it, open fails immediately with
69
+ * `SqlitePoolBusyError`.
70
+ *
60
71
  * Pass `encryptionKey` (exactly 32 bytes) to enable at-rest encryption via sqlite3mc's
61
72
  * ChaCha20 page cipher. The key buffer is **transferred** to the worker — its
62
73
  * ArrayBuffer detaches on the caller side after `postMessage`. This is intentional:
@@ -74,30 +85,40 @@ import { SQLiteOPFSAztecSingleton } from './singleton.js';
74
85
  }
75
86
  const dbName = name && !ephemeral ? name : `tmp-${globalThis.crypto.getRandomValues(new Uint8Array(8)).join('')}`;
76
87
  log.debug(`Opening SQLite-OPFS ${ephemeral ? 'ephemeral ' : ''}${encryptionKey ? 'encrypted ' : ''}database ${dbName}`);
77
- const worker = new Worker(new URL('./worker.js', import.meta.url), {
78
- type: 'module'
79
- });
80
- const store = new AztecSQLiteOPFSStore(worker, dbName, log, ephemeral);
81
- // Transfer (not clone) the key buffer to the worker so we don't leave a
82
- // second copy on the main thread. Caveat: this detaches the caller's
83
- // encryptionKey.buffer — subsequent reads from the same Uint8Array are empty.
84
- const transfer = encryptionKey ? [
85
- encryptionKey.buffer
86
- ] : undefined;
88
+ const effectivePoolDirectory = ephemeral ? undefined : normalizePoolDirectory(poolDirectory);
89
+ const poolLock = effectivePoolDirectory ? await acquirePoolLock(effectivePoolDirectory) : undefined;
90
+ let worker;
87
91
  try {
92
+ if (effectivePoolDirectory) {
93
+ const quarantine = await quarantineDuplicatePool(effectivePoolDirectory);
94
+ if (quarantine) {
95
+ log.warn(`Quarantined SQLite-OPFS pool with duplicate logical file mappings`, quarantine);
96
+ }
97
+ }
98
+ worker = new Worker(new URL('./worker.js', import.meta.url), {
99
+ type: 'module'
100
+ });
101
+ const store = new AztecSQLiteOPFSStore(worker, dbName, log, ephemeral, poolLock);
102
+ // Transfer (not clone) the key buffer to the worker so we don't leave a
103
+ // second copy on the main thread. Caveat: this detaches the caller's
104
+ // encryptionKey.buffer — subsequent reads from the same Uint8Array are empty.
105
+ const transfer = encryptionKey ? [
106
+ encryptionKey.buffer
107
+ ] : undefined;
88
108
  await store.#sendRequest({
89
109
  type: 'init',
90
110
  id: store.#allocId(),
91
111
  dbName,
92
112
  ephemeral,
93
- poolDirectory,
113
+ poolDirectory: effectivePoolDirectory,
94
114
  encryptionKey
95
115
  }, transfer);
116
+ return store;
96
117
  } catch (err) {
97
- worker.terminate();
118
+ worker?.terminate();
119
+ await poolLock?.release();
98
120
  throw err;
99
121
  }
100
- return store;
101
122
  }
102
123
  openMap(name) {
103
124
  return new SQLiteOPFSAztecMap(this, name);
@@ -157,14 +178,18 @@ import { SQLiteOPFSAztecSingleton } from './singleton.js';
157
178
  return;
158
179
  }
159
180
  this.#closed = true;
160
- await this.#txQueue.end();
161
- await this.#sendRequest({
162
- type: 'deleteDb',
163
- id: this.#allocId(),
164
- dbName: this.#name
165
- }).catch((err)=>this.#log.warn(`SQLite deleteDb failed: ${err instanceof Error ? err.message : err}`));
166
- this.#worker.terminate();
167
- this.#rejectPending('SQLite store deleted');
181
+ try {
182
+ await this.#txQueue.end();
183
+ await this.#sendRequest({
184
+ type: 'deleteDb',
185
+ id: this.#allocId(),
186
+ dbName: this.#name
187
+ }).catch((err)=>this.#log.warn(`SQLite deleteDb failed: ${err instanceof Error ? err.message : err}`));
188
+ } finally{
189
+ this.#worker.terminate();
190
+ this.#rejectPending('SQLite store deleted');
191
+ await this.poolLock?.release();
192
+ }
168
193
  }
169
194
  /**
170
195
  * Placeholder — returns zeros to mirror the IndexedDB backend. SQLite exposes real
@@ -186,13 +211,17 @@ import { SQLiteOPFSAztecSingleton } from './singleton.js';
186
211
  return;
187
212
  }
188
213
  this.#closed = true;
189
- await this.#txQueue.end();
190
- await this.#sendRequest({
191
- type: 'close',
192
- id: this.#allocId()
193
- }).catch(()=>{});
194
- this.#worker.terminate();
195
- this.#rejectPending('SQLite store closed');
214
+ try {
215
+ await this.#txQueue.end();
216
+ await this.#sendRequest({
217
+ type: 'close',
218
+ id: this.#allocId()
219
+ }).catch(()=>{});
220
+ } finally{
221
+ this.#worker.terminate();
222
+ this.#rejectPending('SQLite store closed');
223
+ await this.poolLock?.release();
224
+ }
196
225
  }
197
226
  backupTo(_dstPath, _compact) {
198
227
  throw new Error('Method not implemented.');
@@ -253,15 +282,22 @@ import { SQLiteOPFSAztecSingleton } from './singleton.js';
253
282
  this.#pending.clear();
254
283
  }
255
284
  #sendRequest(req, transfer) {
285
+ if (this.#workerFailed) {
286
+ return Promise.reject(new Error('SQLite worker has crashed'));
287
+ }
256
288
  return new Promise((resolve, reject)=>{
257
289
  this.#pending.set(req.id, {
258
290
  resolve: (resp)=>{
259
291
  if (resp.type === 'err') {
260
- // Re-hydrate encryption-shaped errors as the typed class so consumers
261
- // can pattern-match on `instanceof SqliteEncryptionError`. Plain
262
- // errors stay plain the wire protocol only tags encryption paths.
292
+ // Re-hydrate typed errors so consumers can pattern-match on
293
+ // `instanceof`. Encryption is tagged on the wire (some cases are
294
+ // pre-flight throws with no message to match); corruption is a
295
+ // single unambiguous message, so we classify it here rather than
296
+ // adding a redundant wire field. Everything else stays a plain Error.
263
297
  if (resp.encryptionCode !== undefined) {
264
298
  reject(new SqliteEncryptionError(resp.encryptionCode, resp.message));
299
+ } else if (isCorruptionMessage(resp.message)) {
300
+ reject(new SqliteCorruptionError(resp.message));
265
301
  } else {
266
302
  reject(new Error(resp.message));
267
303
  }
@@ -1,6 +1,7 @@
1
1
  /// <reference lib="webworker" />
2
2
  import sqlite3InitModule from '@aztec/sqlite3mc-wasm';
3
3
  import { SqliteEncryptionError, isDecryptFailureMessage } from './errors.js';
4
+ import { DEFAULT_SAH_POOL_DIRECTORY } from './pool_lock.js';
4
5
  const SCHEMA_SQL = `
5
6
  CREATE TABLE IF NOT EXISTS data (
6
7
  slot TEXT NOT NULL PRIMARY KEY,
@@ -15,7 +16,6 @@ const SCHEMA_SQL = `
15
16
  CREATE UNIQUE INDEX IF NOT EXISTS idx_container_key_count ON data(container, key, key_count);
16
17
  CREATE UNIQUE INDEX IF NOT EXISTS idx_container_key_hash ON data(container, key, hash);
17
18
  `;
18
- const DEFAULT_SAH_POOL_DIRECTORY = '.aztec-kv';
19
19
  const SAH_POOL_VFS_NAME = 'aztec-kv-opfs';
20
20
  const MC_SAH_POOL_VFS_NAME = `multipleciphers-${SAH_POOL_VFS_NAME}`;
21
21
  let sqlite3;
@@ -74,10 +74,13 @@ async function handleInit(dbName, ephemeral, directory, encryptionKey) {
74
74
  runSql(SCHEMA_SQL);
75
75
  }
76
76
  function handleClose() {
77
- db?.close();
78
- db = undefined;
79
- dbPath = undefined;
80
- releasePool();
77
+ try {
78
+ db?.close();
79
+ } finally{
80
+ db = undefined;
81
+ dbPath = undefined;
82
+ releasePool();
83
+ }
81
84
  }
82
85
  /**
83
86
  * Releases the SAH pool's OPFS sync access handles before the terminal RPC is acked. Worker
@@ -232,11 +235,19 @@ self.onmessage = async (ev)=>{
232
235
  }
233
236
  } catch (err) {
234
237
  const message = err instanceof Error ? err.message : String(err);
238
+ const encryptionCode = detectEncryptionCode(req, err, message);
239
+ if (req.type === 'init') {
240
+ try {
241
+ handleClose();
242
+ } catch {
243
+ // The main thread terminates this worker after a failed init, which releases any remaining OPFS handles.
244
+ }
245
+ }
235
246
  respond({
236
247
  type: 'err',
237
248
  id: req.id,
238
249
  message,
239
- encryptionCode: detectEncryptionCode(req, err, message)
250
+ encryptionCode
240
251
  });
241
252
  }
242
253
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aztec/kv-store",
3
- "version": "0.0.1-commit.a5db02d",
3
+ "version": "0.0.1-commit.aa0c64f",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./dest/interfaces/index.js",
@@ -36,12 +36,12 @@
36
36
  "./package.local.json"
37
37
  ],
38
38
  "dependencies": {
39
- "@aztec/constants": "0.0.1-commit.a5db02d",
40
- "@aztec/ethereum": "0.0.1-commit.a5db02d",
41
- "@aztec/foundation": "0.0.1-commit.a5db02d",
42
- "@aztec/native": "0.0.1-commit.a5db02d",
43
- "@aztec/sqlite3mc-wasm": "0.0.1-commit.a5db02d",
44
- "@aztec/stdlib": "0.0.1-commit.a5db02d",
39
+ "@aztec/constants": "0.0.1-commit.aa0c64f",
40
+ "@aztec/ethereum": "0.0.1-commit.aa0c64f",
41
+ "@aztec/foundation": "0.0.1-commit.aa0c64f",
42
+ "@aztec/native": "0.0.1-commit.aa0c64f",
43
+ "@aztec/sqlite3mc-wasm": "0.0.1-commit.aa0c64f",
44
+ "@aztec/stdlib": "0.0.1-commit.aa0c64f",
45
45
  "idb": "^8.0.0",
46
46
  "lmdb": "^3.2.0",
47
47
  "msgpackr": "^1.11.2",
@@ -42,3 +42,53 @@ const SQLITE3MC_DECRYPT_ERROR_PATTERNS: readonly RegExp[] = [
42
42
  export function isDecryptFailureMessage(message: string): boolean {
43
43
  return SQLITE3MC_DECRYPT_ERROR_PATTERNS.some(p => p.test(message));
44
44
  }
45
+
46
+ /**
47
+ * Error thrown by sqlite-opfs when the on-disk database image is corrupt
48
+ * (`SQLITE_CORRUPT`, result code 11 — "database disk image is malformed").
49
+ *
50
+ * Distinct from {@link SqliteEncryptionError}: no key recovers a corrupt image,
51
+ * so there is nothing to retry. The only escape is to delete the store and start
52
+ * fresh, so consumers pattern-match on `instanceof SqliteCorruptionError` to wipe
53
+ * and reopen rather than surfacing a dead-end error.
54
+ **/
55
+ export class SqliteCorruptionError extends Error {
56
+ constructor(message: string, opts?: { cause?: unknown }) {
57
+ super(message, opts?.cause !== undefined ? { cause: opts.cause } : undefined);
58
+ this.name = 'SqliteCorruptionError';
59
+ }
60
+ }
61
+
62
+ /** Error thrown when another browser context already owns a store's OPFS pool. */
63
+ export class SqlitePoolBusyError extends Error {
64
+ constructor(public readonly poolDirectory: string) {
65
+ super(`SQLite-OPFS pool "${poolDirectory}" is already in use by another store instance`);
66
+ this.name = 'SqlitePoolBusyError';
67
+ }
68
+ }
69
+
70
+ /** Error thrown when the browser context does not expose the Web Locks API required by the OPFS SAH pool. */
71
+ export class SqliteWebLocksUnavailableError extends Error {
72
+ constructor() {
73
+ super('SQLite-OPFS requires the Web Locks API, but it is unavailable in this browser context');
74
+ this.name = 'SqliteWebLocksUnavailableError';
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Strings/codes raised by SQLite when a database image is corrupt. Kept disjoint
80
+ * from {@link SQLITE3MC_DECRYPT_ERROR_PATTERNS}: "file is not a database"
81
+ * (SQLITE_NOTADB) is the decrypt signal, whereas a malformed image is the
82
+ * genuinely-unrecoverable SQLITE_CORRUPT.
83
+ **/
84
+ const SQLITE_CORRUPTION_ERROR_PATTERNS: readonly RegExp[] = [
85
+ /database disk image is malformed/i,
86
+ /\bSQLITE_CORRUPT\b/i,
87
+ ];
88
+
89
+ /**
90
+ * Returns `true` if `message` matches one of the known SQLite corruption strings.
91
+ **/
92
+ export function isCorruptionMessage(message: string): boolean {
93
+ return SQLITE_CORRUPTION_ERROR_PATTERNS.some(p => p.test(message));
94
+ }
@@ -3,9 +3,14 @@ import { createLogger } from '@aztec/foundation/log';
3
3
  import { AztecSQLiteOPFSStore } from './store.js';
4
4
 
5
5
  export { AztecSQLiteOPFSStore } from './store.js';
6
- export { SqliteEncryptionError } from './errors.js';
6
+ export {
7
+ SqliteCorruptionError,
8
+ SqliteEncryptionError,
9
+ SqlitePoolBusyError,
10
+ SqliteWebLocksUnavailableError,
11
+ } from './errors.js';
7
12
  export type { SqliteEncryptionErrorCode } from './errors.js';
8
- export { OPFS_POOL_DIR_PREFIX, deleteStore, listStores, storePoolDirectory } from './manage.js';
13
+ export { OPFS_POOL_DIR_PREFIX, deletePoolDirectory, deleteStore, listStores, storePoolDirectory } from './manage.js';
9
14
 
10
15
  export function openTmpStore(ephemeral: boolean = false): Promise<AztecSQLiteOPFSStore> {
11
16
  return AztecSQLiteOPFSStore.open(createLogger('kv-store:sqlite-opfs'), undefined, ephemeral);
@@ -1,3 +1,5 @@
1
+ import { normalizePoolDirectory, withPoolLock } from './pool_lock.js';
2
+
1
3
  /** Prefix for the per-store OPFS SAH pool directories owned by this package. */
2
4
  export const OPFS_POOL_DIR_PREFIX = '.aztec-kv-';
3
5
 
@@ -28,9 +30,17 @@ export async function listStores(): Promise<string[]> {
28
30
 
29
31
  /**
30
32
  * Permanently deletes a store by effective name (as returned by {@link listStores}). The store must be closed:
31
- * an open store's SAH pool holds locks on the directory and the removal will reject.
33
+ * an open store holds the directory's Web Lock and the removal will reject with `SqlitePoolBusyError`.
32
34
  */
33
35
  export async function deleteStore(effectiveName: string): Promise<void> {
34
- const root = await navigator.storage.getDirectory();
35
- await root.removeEntry(storePoolDirectory(effectiveName), { recursive: true });
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
+ });
36
46
  }