@livestore/wa-sqlite 1.0.1-dev.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.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +78 -0
  3. package/dist/wa-sqlite-async.mjs +16 -0
  4. package/dist/wa-sqlite-async.wasm +0 -0
  5. package/dist/wa-sqlite-jspi.mjs +16 -0
  6. package/dist/wa-sqlite-jspi.wasm +0 -0
  7. package/dist/wa-sqlite.mjs +16 -0
  8. package/dist/wa-sqlite.wasm +0 -0
  9. package/package.json +45 -0
  10. package/src/FacadeVFS.js +508 -0
  11. package/src/VFS.js +222 -0
  12. package/src/WebLocksMixin.js +412 -0
  13. package/src/examples/AccessHandlePoolVFS.js +458 -0
  14. package/src/examples/IDBBatchAtomicVFS.js +820 -0
  15. package/src/examples/IDBMirrorVFS.js +875 -0
  16. package/src/examples/MemoryAsyncVFS.js +100 -0
  17. package/src/examples/MemoryVFS.js +176 -0
  18. package/src/examples/OPFSAdaptiveVFS.js +437 -0
  19. package/src/examples/OPFSAnyContextVFS.js +300 -0
  20. package/src/examples/OPFSCoopSyncVFS.js +590 -0
  21. package/src/examples/OPFSPermutedVFS.js +1214 -0
  22. package/src/examples/README.md +89 -0
  23. package/src/examples/tag.js +82 -0
  24. package/src/sqlite-api.js +914 -0
  25. package/src/sqlite-constants.js +275 -0
  26. package/src/types/globals.d.ts +60 -0
  27. package/src/types/index.d.ts +1302 -0
  28. package/src/types/tsconfig.json +6 -0
  29. package/test/AccessHandlePoolVFS.test.js +27 -0
  30. package/test/IDBBatchAtomicVFS.test.js +97 -0
  31. package/test/IDBMirrorVFS.test.js +27 -0
  32. package/test/MemoryAsyncVFS.test.js +27 -0
  33. package/test/MemoryVFS.test.js +27 -0
  34. package/test/OPFSAdaptiveVFS.test.js +27 -0
  35. package/test/OPFSAnyContextVFS.test.js +27 -0
  36. package/test/OPFSCoopSyncVFS.test.js +27 -0
  37. package/test/OPFSPermutedVFS.test.js +27 -0
  38. package/test/TestContext.js +96 -0
  39. package/test/WebLocksMixin.test.js +521 -0
  40. package/test/api.test.js +49 -0
  41. package/test/api_exec.js +89 -0
  42. package/test/api_misc.js +63 -0
  43. package/test/api_statements.js +426 -0
  44. package/test/callbacks.test.js +373 -0
  45. package/test/sql.test.js +64 -0
  46. package/test/sql_0001.js +49 -0
  47. package/test/sql_0002.js +52 -0
  48. package/test/sql_0003.js +83 -0
  49. package/test/sql_0004.js +81 -0
  50. package/test/sql_0005.js +76 -0
  51. package/test/test-worker.js +204 -0
  52. package/test/vfs_xAccess.js +2 -0
  53. package/test/vfs_xClose.js +52 -0
  54. package/test/vfs_xOpen.js +91 -0
  55. package/test/vfs_xRead.js +38 -0
  56. package/test/vfs_xWrite.js +36 -0
@@ -0,0 +1,820 @@
1
+ // Copyright 2024 Roy T. Hashimoto. All Rights Reserved.
2
+ import { FacadeVFS } from '../FacadeVFS.js';
3
+ import * as VFS from '../VFS.js';
4
+ import { WebLocksMixin } from '../WebLocksMixin.js';
5
+
6
+ /**
7
+ * @typedef Metadata
8
+ * @property {string} name
9
+ * @property {number} fileSize
10
+ * @property {number} version
11
+ * @property {number} [pendingVersion]
12
+ */
13
+
14
+ class File {
15
+ /** @type {string} */ path;
16
+ /** @type {number} */ flags;
17
+
18
+ /** @type {Metadata} */ metadata;
19
+ /** @type {number} */ fileSize = 0;
20
+
21
+ /** @type {boolean} */ needsMetadataSync = false;
22
+ /** @type {Metadata} */ rollback = null;
23
+ /** @type {Set<number>} */ changedPages = new Set();
24
+
25
+ /** @type {string} */ synchronous = 'full';
26
+ /** @type {IDBTransactionOptions} */ txOptions = { durability: 'strict' };
27
+
28
+ constructor(path, flags, metadata) {
29
+ this.path = path;
30
+ this.flags = flags;
31
+ this.metadata = metadata;
32
+ }
33
+ }
34
+
35
+ export class IDBBatchAtomicVFS extends WebLocksMixin(FacadeVFS) {
36
+ /** @type {Map<number, File>} */ mapIdToFile = new Map();
37
+ lastError = null;
38
+
39
+ log = null; // console.log
40
+
41
+ /** @type {Promise} */ #isReady;
42
+ /** @type {IDBContext} */ #idb;
43
+
44
+ static async create(name, module, options) {
45
+ const vfs = new IDBBatchAtomicVFS(name, module, options);
46
+ await vfs.isReady();
47
+ return vfs;
48
+ }
49
+
50
+ constructor(name, module, options = {}) {
51
+ super(name, module, options);
52
+ this.#isReady = this.#initialize(options.idbName ?? name);
53
+ }
54
+
55
+ async #initialize(name) {
56
+ this.#idb = await IDBContext.create(name);
57
+ }
58
+
59
+ close() {
60
+ this.#idb.close();
61
+ }
62
+
63
+ async isReady() {
64
+ await super.isReady();
65
+ await this.#isReady;
66
+ }
67
+
68
+ getFilename(fileId) {
69
+ const pathname = this.mapIdToFile.get(fileId).path;
70
+ return `IDB(${this.name}):${pathname}`
71
+ }
72
+
73
+ /**
74
+ * @param {string?} zName
75
+ * @param {number} fileId
76
+ * @param {number} flags
77
+ * @param {DataView} pOutFlags
78
+ * @returns {Promise<number>}
79
+ */
80
+ async jOpen(zName, fileId, flags, pOutFlags) {
81
+ try {
82
+ const url = new URL(zName || Math.random().toString(36).slice(2), 'file://');
83
+ const path = url.pathname;
84
+
85
+ let meta = await this.#idb.q(({ metadata }) => metadata.get(path));
86
+ if (!meta && (flags & VFS.SQLITE_OPEN_CREATE)) {
87
+ meta = {
88
+ name: path,
89
+ fileSize: 0,
90
+ version: 0
91
+ };
92
+ await this.#idb.q(({ metadata }) => metadata.put(meta), 'rw');
93
+ }
94
+
95
+ if (!meta) {
96
+ throw new Error(`File ${path} not found`);
97
+ }
98
+
99
+ const file = new File(path, flags, meta);
100
+ this.mapIdToFile.set(fileId, file);
101
+ pOutFlags.setInt32(0, flags, true);
102
+ return VFS.SQLITE_OK;
103
+ } catch (e) {
104
+ this.lastError = e;
105
+ return VFS.SQLITE_CANTOPEN;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * @param {string} zName
111
+ * @param {number} syncDir
112
+ * @returns {Promise<number>}
113
+ */
114
+ async jDelete(zName, syncDir) {
115
+ try {
116
+ const url = new URL(zName, 'file://');
117
+ const path = url.pathname;
118
+
119
+ this.#idb.q(({ metadata, blocks }) => {
120
+ const range = IDBKeyRange.bound([path, -Infinity], [path, Infinity]);
121
+ blocks.delete(range);
122
+ metadata.delete(path);
123
+ }, 'rw');
124
+
125
+ if (syncDir) {
126
+ await this.#idb.sync(false);
127
+ }
128
+ return VFS.SQLITE_OK;
129
+ } catch (e) {
130
+ this.lastError = e;
131
+ return VFS.SQLITE_IOERR_DELETE;
132
+ }
133
+ }
134
+
135
+ /**
136
+ * @param {string} zName
137
+ * @param {number} flags
138
+ * @param {DataView} pResOut
139
+ * @returns {Promise<number>}
140
+ */
141
+ async jAccess(zName, flags, pResOut) {
142
+ try {
143
+ const url = new URL(zName, 'file://');
144
+ const path = url.pathname;
145
+
146
+ const meta = await this.#idb.q(({ metadata }) => metadata.get(path));
147
+ pResOut.setInt32(0, meta ? 1 : 0, true);
148
+ return VFS.SQLITE_OK;
149
+ } catch (e) {
150
+ this.lastError = e;
151
+ return VFS.SQLITE_IOERR_ACCESS;
152
+ }
153
+ }
154
+
155
+ /**
156
+ * @param {number} fileId
157
+ * @returns {Promise<number>}
158
+ */
159
+ async jClose(fileId) {
160
+ try {
161
+ const file = this.mapIdToFile.get(fileId);
162
+ this.mapIdToFile.delete(fileId);
163
+
164
+ if (file.flags & VFS.SQLITE_OPEN_DELETEONCLOSE) {
165
+ await this.#idb.q(({ metadata, blocks }) => {
166
+ metadata.delete(file.path);
167
+ blocks.delete(IDBKeyRange.bound([file.path, 0], [file.path, Infinity]));
168
+ }, 'rw');
169
+ }
170
+
171
+ if (file.needsMetadataSync) {
172
+ this.#idb.q(({ metadata }) => metadata.put(file.metadata), 'rw');
173
+ }
174
+ await this.#idb.sync(file.synchronous === 'full');
175
+ return VFS.SQLITE_OK;
176
+ } catch (e) {
177
+ this.lastError = e;
178
+ return VFS.SQLITE_IOERR_CLOSE;
179
+ }
180
+ }
181
+
182
+ /**
183
+ * @param {number} fileId
184
+ * @param {Uint8Array} pData
185
+ * @param {number} iOffset
186
+ * @returns {Promise<number>}
187
+ */
188
+ async jRead(fileId, pData, iOffset) {
189
+ try {
190
+ const file = this.mapIdToFile.get(fileId);
191
+
192
+ let pDataOffset = 0;
193
+ while (pDataOffset < pData.byteLength) {
194
+ // Fetch the IndexedDB block for this file location.
195
+ const fileOffset = iOffset + pDataOffset;
196
+ const block = await this.#idb.q(({ blocks }) => {
197
+ const range = IDBKeyRange.bound([file.path, -fileOffset], [file.path, Infinity]);
198
+ return blocks.get(range);
199
+ });
200
+
201
+ if (!block || block.data.byteLength - block.offset <= fileOffset) {
202
+ pData.fill(0, pDataOffset);
203
+ return VFS.SQLITE_IOERR_SHORT_READ;
204
+ }
205
+
206
+ // Copy block data.
207
+ const dst = pData.subarray(pDataOffset);
208
+ const srcOffset = fileOffset + block.offset;
209
+ const nBytesToCopy = Math.min(
210
+ Math.max(block.data.byteLength - srcOffset, 0),
211
+ dst.byteLength);
212
+ dst.set(block.data.subarray(srcOffset, srcOffset + nBytesToCopy));
213
+ pDataOffset += nBytesToCopy;
214
+ }
215
+ return VFS.SQLITE_OK;
216
+ } catch (e) {
217
+ this.lastError = e;
218
+ return VFS.SQLITE_IOERR_READ;
219
+ }
220
+ }
221
+
222
+ /**
223
+ * @param {number} fileId
224
+ * @param {Uint8Array} pData
225
+ * @param {number} iOffset
226
+ * @returns {number}
227
+ */
228
+ jWrite(fileId, pData, iOffset) {
229
+ try {
230
+ const file = this.mapIdToFile.get(fileId);
231
+ if (file.flags & VFS.SQLITE_OPEN_MAIN_DB) {
232
+ if (!file.rollback) {
233
+ // Begin a new write transaction.
234
+ // Add pendingVersion to the metadata in IndexedDB. If we crash
235
+ // during the transaction, this lets subsequent connections
236
+ // know to remove blocks from the failed transaction.
237
+ const pending = Object.assign(
238
+ { pendingVersion: file.metadata.version - 1 },
239
+ file.metadata);
240
+ this.#idb.q(({ metadata }) => metadata.put(pending), 'rw', file.txOptions);
241
+
242
+ file.rollback = Object.assign({}, file.metadata);
243
+ file.metadata.version--;
244
+ }
245
+ }
246
+
247
+ if (file.flags & VFS.SQLITE_OPEN_MAIN_DB) {
248
+ file.changedPages.add(iOffset);
249
+ }
250
+
251
+ const data = pData.slice();
252
+ const version = file.metadata.version;
253
+ const isOverwrite = iOffset < file.metadata.fileSize;
254
+ if (!isOverwrite ||
255
+ file.flags & VFS.SQLITE_OPEN_MAIN_DB ||
256
+ file.flags & VFS.SQLITE_OPEN_TEMP_DB) {
257
+ const block = {
258
+ path: file.path,
259
+ offset: -iOffset,
260
+ version: version,
261
+ data: pData.slice()
262
+ };
263
+ this.#idb.q(({ blocks }) => {
264
+ blocks.put(block);
265
+ file.changedPages.add(iOffset);
266
+ }, 'rw', file.txOptions);
267
+ } else {
268
+ this.#idb.q(async ({ blocks }) => {
269
+ // Read the existing block.
270
+ const range = IDBKeyRange.bound(
271
+ [file.path, -iOffset],
272
+ [file.path, Infinity]);
273
+ const block = await blocks.get(range);
274
+
275
+ // Modify the block data.
276
+ // @ts-ignore
277
+ block.data.subarray(iOffset + block.offset).set(data);
278
+
279
+ // Write back.
280
+ blocks.put(block);
281
+ }, 'rw', file.txOptions);
282
+
283
+ }
284
+
285
+ if (file.metadata.fileSize < iOffset + pData.length) {
286
+ file.metadata.fileSize = iOffset + pData.length;
287
+ file.needsMetadataSync = true;
288
+ }
289
+ return VFS.SQLITE_OK;
290
+ } catch (e) {
291
+ this.lastError = e;
292
+ return VFS.SQLITE_IOERR_WRITE;
293
+ }
294
+ }
295
+
296
+ /**
297
+ * @param {number} fileId
298
+ * @param {number} iSize
299
+ * @returns {number}
300
+ */
301
+ jTruncate(fileId, iSize) {
302
+ try {
303
+ const file = this.mapIdToFile.get(fileId);
304
+ if (iSize < file.metadata.fileSize) {
305
+ this.#idb.q(({ blocks }) => {
306
+ const range = IDBKeyRange.bound(
307
+ [file.path, -Infinity],
308
+ [file.path, -iSize, Infinity]);
309
+ blocks.delete(range);
310
+ }, 'rw', file.txOptions);
311
+ file.metadata.fileSize = iSize;
312
+ file.needsMetadataSync = true;
313
+ }
314
+ return VFS.SQLITE_OK;
315
+ } catch (e) {
316
+ this.lastError = e;
317
+ return VFS.SQLITE_IOERR_TRUNCATE;
318
+ }
319
+ }
320
+
321
+ /**
322
+ * @param {number} fileId
323
+ * @param {number} flags
324
+ * @returns {Promise<number>}
325
+ */
326
+ async jSync(fileId, flags) {
327
+ try {
328
+ const file = this.mapIdToFile.get(fileId);
329
+ if (file.needsMetadataSync) {
330
+ this.#idb.q(({ metadata }) => metadata.put(file.metadata), 'rw', file.txOptions);
331
+ file.needsMetadataSync = false;
332
+ }
333
+
334
+ if (file.flags & VFS.SQLITE_OPEN_MAIN_DB) {
335
+ // Sync is only needed here for durability. Visibility for other
336
+ // connections is ensured in jUnlock().
337
+ if (file.synchronous === 'full') {
338
+ await this.#idb.sync(true);
339
+ }
340
+ } else {
341
+ await this.#idb.sync(file.synchronous === 'full');
342
+ }
343
+ return VFS.SQLITE_OK;
344
+ } catch (e) {
345
+ this.lastError = e;
346
+ return VFS.SQLITE_IOERR_FSYNC;
347
+ }
348
+ }
349
+
350
+ /**
351
+ * @param {number} fileId
352
+ * @param {DataView} pSize64
353
+ * @returns {number}
354
+ */
355
+ jFileSize(fileId, pSize64) {
356
+ try {
357
+ const file = this.mapIdToFile.get(fileId);
358
+ pSize64.setBigInt64(0, BigInt(file.metadata.fileSize), true);
359
+ return VFS.SQLITE_OK;
360
+ } catch (e) {
361
+ this.lastError = e;
362
+ return VFS.SQLITE_IOERR_FSTAT;
363
+ }
364
+ }
365
+
366
+ /**
367
+ * @param {number} fileId
368
+ * @param {number} lockType
369
+ * @returns {Promise<number>}
370
+ */
371
+ async jLock(fileId, lockType) {
372
+ // Call the actual lock implementation.
373
+ const file = this.mapIdToFile.get(fileId);
374
+ const result = await super.jLock(fileId, lockType);
375
+
376
+ if (lockType === VFS.SQLITE_LOCK_SHARED) {
377
+ // Update metadata.
378
+ file.metadata = await this.#idb.q(async ({ metadata, blocks }) => {
379
+ // @ts-ignore
380
+ /** @type {Metadata} */ const m = await metadata.get(file.path);
381
+ if (m.pendingVersion) {
382
+ console.warn(`removing failed transaction ${m.pendingVersion}`);
383
+ await new Promise((resolve, reject) => {
384
+ const range = IDBKeyRange.bound([m.name, -Infinity], [m.name, Infinity]);
385
+ const request = blocks.openCursor(range);
386
+ request.onsuccess = () => {
387
+ const cursor = request.result;
388
+ if (cursor) {
389
+ const block = cursor.value;
390
+ if (block.version < m.version) {
391
+ cursor.delete();
392
+ }
393
+ cursor.continue();
394
+ } else {
395
+ resolve();
396
+ }
397
+ };
398
+ request.onerror = () => reject(request.error);
399
+ })
400
+
401
+ delete m.pendingVersion;
402
+ metadata.put(m);
403
+ }
404
+ return m;
405
+ }, 'rw', file.txOptions);
406
+ }
407
+ return result;
408
+ }
409
+
410
+ /**
411
+ * @param {number} fileId
412
+ * @param {number} lockType
413
+ * @returns {Promise<number>}
414
+ */
415
+ async jUnlock(fileId, lockType) {
416
+ if (lockType === VFS.SQLITE_LOCK_NONE) {
417
+ const file = this.mapIdToFile.get(fileId);
418
+ await this.#idb.sync(file.synchronous === 'full');
419
+ }
420
+
421
+ // Call the actual unlock implementation.
422
+ return super.jUnlock(fileId, lockType);
423
+ }
424
+
425
+ /**
426
+ * @param {number} fileId
427
+ * @param {number} op
428
+ * @param {DataView} pArg
429
+ * @returns {number|Promise<number>}
430
+ */
431
+ jFileControl(fileId, op, pArg) {
432
+ try {
433
+ const file = this.mapIdToFile.get(fileId);
434
+ switch (op) {
435
+ case VFS.SQLITE_FCNTL_PRAGMA:
436
+ const key = extractString(pArg, 4);
437
+ const value = extractString(pArg, 8);
438
+ this.log?.('xFileControl', file.path, 'PRAGMA', key, value);
439
+ const setPragmaResponse = response => {
440
+ const encoded = new TextEncoder().encode(response);
441
+ const out = this._module._sqlite3_malloc(encoded.byteLength);
442
+ const outArray = this._module.HEAPU8.subarray(out, out + encoded.byteLength);
443
+ outArray.set(encoded);
444
+ pArg.setUint32(0, out, true);
445
+ return VFS.SQLITE_ERROR;
446
+ };
447
+ switch (key.toLowerCase()) {
448
+ case 'page_size':
449
+ if (file.flags & VFS.SQLITE_OPEN_MAIN_DB) {
450
+ // Don't allow changing the page size.
451
+ if (value && file.metadata.fileSize) {
452
+ return VFS.SQLITE_ERROR;
453
+ }
454
+ }
455
+ break;
456
+ case 'synchronous':
457
+ if (value) {
458
+ switch (value.toLowerCase()) {
459
+ case '0':
460
+ case 'off':
461
+ file.synchronous = 'off';
462
+ file.txOptions = { durability: 'relaxed' };
463
+ break;
464
+ case '1':
465
+ case 'normal':
466
+ file.synchronous = 'normal';
467
+ file.txOptions = { durability: 'relaxed' };
468
+ break;
469
+ case '2':
470
+ case '3':
471
+ case 'full':
472
+ case 'extra':
473
+ file.synchronous = 'full';
474
+ file.txOptions = { durability: 'strict' };
475
+ break;
476
+ }
477
+ }
478
+ break;
479
+ case 'write_hint':
480
+ return super.jFileControl(fileId, WebLocksMixin.WRITE_HINT_OP_CODE, null);
481
+ }
482
+ break;
483
+ case VFS.SQLITE_FCNTL_SYNC:
484
+ this.log?.('xFileControl', file.path, 'SYNC');
485
+ const commitMetadata = Object.assign({}, file.metadata);
486
+ const prevFileSize = file.rollback.fileSize
487
+ this.#idb.q(({ metadata, blocks }) => {
488
+ metadata.put(commitMetadata);
489
+
490
+ // Remove old page versions.
491
+ for (const offset of file.changedPages) {
492
+ if (offset < prevFileSize) {
493
+ const range = IDBKeyRange.bound(
494
+ [file.path, -offset, commitMetadata.version],
495
+ [file.path, -offset, Infinity],
496
+ true);
497
+ blocks.delete(range);
498
+ }
499
+ }
500
+ file.changedPages.clear();
501
+ }, 'rw', file.txOptions);
502
+ file.needsMetadataSync = false;
503
+ file.rollback = null;
504
+ break;
505
+ case VFS.SQLITE_FCNTL_BEGIN_ATOMIC_WRITE:
506
+ // Every write transaction is atomic, so this is a no-op.
507
+ this.log?.('xFileControl', file.path, 'BEGIN_ATOMIC_WRITE');
508
+ return VFS.SQLITE_OK;
509
+ case VFS.SQLITE_FCNTL_COMMIT_ATOMIC_WRITE:
510
+ // Every write transaction is atomic, so this is a no-op.
511
+ this.log?.('xFileControl', file.path, 'COMMIT_ATOMIC_WRITE');
512
+ return VFS.SQLITE_OK;
513
+ case VFS.SQLITE_FCNTL_ROLLBACK_ATOMIC_WRITE:
514
+ this.log?.('xFileControl', file.path, 'ROLLBACK_ATOMIC_WRITE');
515
+ file.metadata = file.rollback;
516
+ const rollbackMetadata = Object.assign({}, file.metadata);
517
+ this.#idb.q(({ metadata, blocks }) => {
518
+ metadata.put(rollbackMetadata);
519
+
520
+ // Remove pages.
521
+ for (const offset of file.changedPages) {
522
+ blocks.delete([file.path, -offset, rollbackMetadata.version - 1]);
523
+ }
524
+ file.changedPages.clear();
525
+ }, 'rw', file.txOptions);
526
+ file.needsMetadataSync = false;
527
+ file.rollback = null;
528
+ return VFS.SQLITE_OK;
529
+ }
530
+ } catch (e) {
531
+ this.lastError = e;
532
+ return VFS.SQLITE_IOERR;
533
+ }
534
+ return super.jFileControl(fileId, op, pArg);
535
+ }
536
+
537
+ /**
538
+ * @param {number} pFile
539
+ * @returns {number|Promise<number>}
540
+ */
541
+ jDeviceCharacteristics(pFile) {
542
+ return 0
543
+ | VFS.SQLITE_IOCAP_BATCH_ATOMIC
544
+ | VFS.SQLITE_IOCAP_UNDELETABLE_WHEN_OPEN;
545
+ }
546
+
547
+ /**
548
+ * @param {Uint8Array} zBuf
549
+ * @returns {number|Promise<number>}
550
+ */
551
+ jGetLastError(zBuf) {
552
+ if (this.lastError) {
553
+ console.error(this.lastError);
554
+ const outputArray = zBuf.subarray(0, zBuf.byteLength - 1);
555
+ const { written } = new TextEncoder().encodeInto(this.lastError.message, outputArray);
556
+ zBuf[written] = 0;
557
+ }
558
+ return VFS.SQLITE_OK
559
+ }
560
+ }
561
+
562
+ function extractString(dataView, offset) {
563
+ const p = dataView.getUint32(offset, true);
564
+ if (p) {
565
+ const chars = new Uint8Array(dataView.buffer, p);
566
+ return new TextDecoder().decode(chars.subarray(0, chars.indexOf(0)));
567
+ }
568
+ return null;
569
+ }
570
+
571
+ export class IDBContext {
572
+ /** @type {IDBDatabase} */ #database;
573
+
574
+ /** @type {Promise} */ #chain = null;
575
+ /** @type {Promise<any>} */ #txComplete = Promise.resolve();
576
+ /** @type {IDBRequest?} */ #request = null;
577
+ /** @type {WeakSet<IDBTransaction>} */ #txPending = new WeakSet();
578
+
579
+ log = null;
580
+
581
+ static async create(name) {
582
+ const database = await new Promise((resolve, reject) => {
583
+ const request = indexedDB.open(name, 6);
584
+ request.onupgradeneeded = async event => {
585
+ const db = request.result;
586
+ if (event.oldVersion) {
587
+ console.log(`Upgrading IndexedDB from version ${event.oldVersion}`);
588
+ }
589
+ switch (event.oldVersion) {
590
+ case 0:
591
+ // Start with the original schema.
592
+ db.createObjectStore('blocks', { keyPath: ['path', 'offset', 'version']})
593
+ .createIndex('version', ['path', 'version']);
594
+ // fall through intentionally
595
+ case 5:
596
+ const tx = request.transaction;
597
+ const blocks = tx.objectStore('blocks');
598
+ blocks.deleteIndex('version');
599
+ const metadata = db.createObjectStore('metadata', { keyPath: 'name' });
600
+
601
+ await new Promise((resolve, reject) => {
602
+ // Iterate over all the blocks.
603
+ let lastBlock = {};
604
+ const request = tx.objectStore('blocks').openCursor();
605
+ request.onsuccess = () => {
606
+ const cursor = request.result;
607
+ if (cursor) {
608
+ const block = cursor.value;
609
+ if (typeof block.offset !== 'number' ||
610
+ (block.path === lastBlock.path && block.offset === lastBlock.offset)) {
611
+ // Remove superceded block (or the "purge" info).
612
+ cursor.delete();
613
+ } else if (block.offset === 0) {
614
+ // Move metadata to its own store.
615
+ metadata.put({
616
+ name: block.path,
617
+ fileSize: block.fileSize,
618
+ version: block.version
619
+ });
620
+
621
+ delete block.fileSize;
622
+ cursor.update(block);
623
+ }
624
+ lastBlock = block;
625
+ cursor.continue();
626
+ } else {
627
+ resolve();
628
+ }
629
+ };
630
+ request.onerror = () => reject(request.error);
631
+ });
632
+ break;
633
+ }
634
+ };
635
+ request.onsuccess = () => resolve(request.result);
636
+ request.onerror = () => reject(request.error);
637
+ });
638
+ return new IDBContext(database);
639
+ }
640
+
641
+ constructor(database) {
642
+ this.#database = database;
643
+ }
644
+
645
+ close() {
646
+ this.#database.close();
647
+ }
648
+
649
+ /**
650
+ * @param {(stores: Object.<string, IDBObjectStore>) => any} f
651
+ * @param {'ro'|'rw'} mode
652
+ * @returns {Promise<any>}
653
+ */
654
+ q(f, mode = 'ro', options = {}) {
655
+ /** @type {IDBTransactionMode} */
656
+ const txMode = mode === 'ro' ? 'readonly' : 'readwrite';
657
+ const txOptions = Object.assign({
658
+ /** @type {IDBTransactionDurability} */ durability: 'default'
659
+ }, options);
660
+
661
+ // Ensure that queries run sequentially. If any function rejects,
662
+ // or any request has an error, or the transaction does not commit,
663
+ // then no subsequent functions will run until sync() or reset().
664
+ this.#chain = (this.#chain || Promise.resolve())
665
+ .then(() => this.#q(f, txMode, txOptions));
666
+ return this.#chain;
667
+ }
668
+
669
+ /**
670
+ * @param {(stores: Object.<string, IDBObjectStore>) => any} f
671
+ * @param {IDBTransactionMode} mode
672
+ * @param {IDBTransactionOptions} options
673
+ * @returns {Promise<any>}
674
+ */
675
+ async #q(f, mode, options) {
676
+ /** @type {IDBTransaction} */ let tx;
677
+ if (this.#request &&
678
+ this.#txPending.has(this.#request.transaction) &&
679
+ this.#request.transaction.mode >= mode &&
680
+ this.#request.transaction.durability === options.durability) {
681
+ // The previous request transaction is compatible and has
682
+ // not yet completed.
683
+ tx = this.#request.transaction;
684
+
685
+ // If the previous request is pending, wait for it to complete.
686
+ // This ensures that the transaction will be active.
687
+ if (this.#request.readyState === 'pending') {
688
+ await new Promise(resolve => {
689
+ this.#request.addEventListener('success', resolve, { once: true });
690
+ this.#request.addEventListener('error', resolve, { once: true });
691
+ });
692
+ }
693
+ }
694
+
695
+ for (let i = 0; i < 2; ++i) {
696
+ if (!tx) {
697
+ // The current transaction is missing or doesn't match so
698
+ // replace it with a new one. wait for the previous
699
+ // transaction to complete so the lifetimes do not overlap.
700
+ await this.#txComplete;
701
+
702
+ // Create the new transaction.
703
+ // @ts-ignore
704
+ tx = this.#database.transaction(this.#database.objectStoreNames, mode, options);
705
+ this.log?.('IDBTransaction open', mode);
706
+ this.#txPending.add(tx);
707
+ this.#txComplete = new Promise((resolve, reject) => {
708
+ tx.addEventListener('complete', () => {
709
+ this.log?.('IDBTransaction complete');
710
+ this.#txPending.delete(tx);
711
+ resolve();
712
+ });
713
+ tx.addEventListener('abort', () => {
714
+ this.#txPending.delete(tx);
715
+ reject(new Error('transaction aborted'));
716
+ });
717
+ });
718
+ }
719
+
720
+ // @ts-ignore
721
+ // Create object store proxies.
722
+ const objectStores = [...tx.objectStoreNames].map(name => {
723
+ return [name, this.proxyStoreOrIndex(tx.objectStore(name))];
724
+ });
725
+
726
+ try {
727
+ // Execute the function.
728
+ return await f(Object.fromEntries(objectStores));
729
+ } catch (e) {
730
+ // Use a new transaction if this one was inactive. This will
731
+ // happen if the last request in the transaction completed
732
+ // in a previous task but the transaction has not yet committed.
733
+ if (!i && e.name === 'TransactionInactiveError') {
734
+ this.log?.('TransactionInactiveError, retrying');
735
+ tx = null;
736
+ continue;
737
+ }
738
+ throw e;
739
+ }
740
+ }
741
+ }
742
+
743
+ /**
744
+ * Object store methods that return an IDBRequest, except for cursor
745
+ * creation, are wrapped to return a Promise. In addition, the
746
+ * request is used internally for chaining.
747
+ * @param {IDBObjectStore} objectStore
748
+ * @returns
749
+ */
750
+ proxyStoreOrIndex(objectStore) {
751
+ return new Proxy(objectStore, {
752
+ get: (target, property, receiver) => {
753
+ const result = Reflect.get(target, property, receiver);
754
+ if (typeof result === 'function') {
755
+ return (...args) => {
756
+ const maybeRequest = Reflect.apply(result, target, args);
757
+ // @ts-ignore
758
+ if (maybeRequest instanceof IDBRequest && !property.endsWith('Cursor')) {
759
+ // // Debug logging.
760
+ // this.log?.(`${target.name}.${String(property)}`, args);
761
+ // maybeRequest.addEventListener('success', () => {
762
+ // this.log?.(`${target.name}.${String(property)} success`, maybeRequest.result);
763
+ // });
764
+ // maybeRequest.addEventListener('error', () => {
765
+ // this.log?.(`${target.name}.${String(property)} error`, maybeRequest.error);
766
+ // });
767
+
768
+ // Save the request.
769
+ this.#request = maybeRequest;
770
+
771
+ // Abort the transaction on error.
772
+ maybeRequest.addEventListener('error', () => {
773
+ console.error(maybeRequest.error);
774
+ maybeRequest.transaction.abort();
775
+ }, { once: true });
776
+
777
+ // Return a Promise.
778
+ return wrap(maybeRequest);
779
+ }
780
+ return maybeRequest;
781
+ }
782
+ }
783
+ return result;
784
+ }
785
+ });
786
+ }
787
+
788
+ /**
789
+ * @param {boolean} durable
790
+ */
791
+ async sync(durable) {
792
+ if (this.#chain) {
793
+ // This waits for all IndexedDB calls to be made.
794
+ await this.#chain;
795
+ if (durable) {
796
+ // This waits for the final transaction to commit.
797
+ await this.#txComplete;
798
+ }
799
+ this.reset();
800
+ }
801
+ }
802
+
803
+ reset() {
804
+ this.#chain = null;
805
+ this.#txComplete = Promise.resolve();
806
+ this.#request = null;
807
+ }
808
+ }
809
+
810
+ /**
811
+ * @param {IDBRequest} request
812
+ * @returns {Promise}
813
+ */
814
+ function wrap(request) {
815
+ return new Promise((resolve, reject) => {
816
+ request.onsuccess = () => resolve(request.result);
817
+ request.onerror = () => reject(request.error);
818
+ });
819
+ }
820
+