@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,875 @@
1
+ import { FacadeVFS } from '../FacadeVFS.js';
2
+ import * as VFS from '../VFS.js';
3
+
4
+ // Options for navigator.locks.request().
5
+ /** @type {LockOptions} */ const SHARED = { mode: 'shared' };
6
+ /** @type {LockOptions} */ const POLL_SHARED = { ifAvailable: true, mode: 'shared' };
7
+ /** @type {LockOptions} */ const POLL_EXCLUSIVE = { ifAvailable: true, mode: 'exclusive' };
8
+
9
+ // Used only for debug logging.
10
+ const contextId = Math.random().toString(36).slice(2);
11
+
12
+ /**
13
+ * @typedef {Object} Transaction
14
+ * @property {string} [path]
15
+ * @property {number} txId
16
+ * @property {Map<number, Uint8Array?>} [blocks]
17
+ * @property {number} [fileSize]
18
+ */
19
+
20
+ class File {
21
+ /** @type {string} */ path;
22
+ /** @type {number} */ flags;
23
+
24
+ /** @type {number} */ blockSize;
25
+ /** @type {Map<number, Uint8Array>} */ blocks;
26
+
27
+ // Members below are only used for SQLITE_OPEN_MAIN_DB.
28
+
29
+ /** @type {Transaction} */ viewTx; // last transaction incorporated
30
+ /** @type {function?} */ viewReleaser;
31
+
32
+ /** @type {BroadcastChannel} */ broadcastChannel;
33
+ /** @type {Transaction[]} */ broadcastReceived;
34
+
35
+ /** @type {number} */ lockState;
36
+ /** @type {{write?: function, reserved?: function, hint?: function}} */ locks;
37
+
38
+ /** @type {AbortController} */ abortController;
39
+
40
+ /** @type {Transaction?} */ txActive;
41
+ /** @type {boolean} */ txWriteHint;
42
+ /** @type {boolean} */ txOverwrite;
43
+
44
+ /** @type {string} */ synchronous;
45
+
46
+ constructor(pathname, flags) {
47
+ this.path = pathname;
48
+ this.flags = flags;
49
+
50
+ this.blockSize = 0;
51
+ this.blocks = new Map();
52
+ if (flags & VFS.SQLITE_OPEN_MAIN_DB) {
53
+ this.viewTx = null;
54
+ this.viewReleaser = null;
55
+ this.broadcastChannel = new BroadcastChannel('mirror:' + pathname);
56
+ this.broadcastReceived = [];
57
+ this.lockState = VFS.SQLITE_LOCK_NONE;
58
+ this.locks = {};
59
+ this.txActive = null;
60
+ this.txWriteHint = false;
61
+ this.txOverwrite = false;
62
+ this.synchronous = 'full';
63
+ }
64
+ }
65
+ }
66
+
67
+ export class IDBMirrorVFS extends FacadeVFS {
68
+ /** @type {Map<number, File>} */ #mapIdToFile = new Map();
69
+ /** @type {Map<string, File>} */ #mapPathToFile = new Map();
70
+ #lastError = null;
71
+
72
+ /** @type {IDBDatabase} */ #idb;
73
+
74
+ log = null; // console.log;
75
+
76
+ /** @type {Promise} */ #isReady;
77
+
78
+ static async create(name, module, options) {
79
+ const instance = new IDBMirrorVFS(name, module, options);
80
+ await instance.isReady();
81
+ return instance;
82
+ }
83
+
84
+ constructor(name, module, options = {}) {
85
+ super(name, module);
86
+ this.#isReady = this.#initialize(name);
87
+ }
88
+
89
+ async #initialize(name) {
90
+ // Open IndexedDB database, creating it if necessary.
91
+ this.#idb = await new Promise((resolve, reject) => {
92
+ const request = indexedDB.open(name, 1);
93
+ request.onupgradeneeded = (event) => {
94
+ const db = request.result;
95
+ switch (event.oldVersion) {
96
+ case 0:
97
+ db.createObjectStore('blocks', { keyPath: ['path', 'offset'] });
98
+ db.createObjectStore('tx', { keyPath: ['path', 'txId'] });
99
+ break;
100
+ }
101
+ };
102
+ request.onsuccess = () => resolve(request.result);
103
+ request.onerror = () => reject(request.error);
104
+ });
105
+ }
106
+
107
+ close() {
108
+ return this.#idb.close();
109
+ }
110
+
111
+ async isReady() {
112
+ await super.isReady();
113
+ return this.#isReady;
114
+ }
115
+
116
+ /**
117
+ * @param {string?} zName
118
+ * @param {number} fileId
119
+ * @param {number} flags
120
+ * @param {DataView} pOutFlags
121
+ * @returns {Promise<number>}
122
+ */
123
+ async jOpen(zName, fileId, flags, pOutFlags) {
124
+ try {
125
+ const url = new URL(zName || Math.random().toString(36).slice(2), 'file://');
126
+ const path = url.pathname;
127
+
128
+ let file;
129
+ if (flags & VFS.SQLITE_OPEN_MAIN_DB) {
130
+ // TODO
131
+ file = new File(path, flags);
132
+
133
+ const idbTx = this.#idb.transaction(['blocks', 'tx'], 'readwrite');
134
+ const blocks = idbTx.objectStore('blocks');
135
+ if (await idbX(blocks.count([path, 0])) === 0) {
136
+ // File does not yet exist.
137
+ if (flags & VFS.SQLITE_OPEN_CREATE) {
138
+ await idbX(blocks.put({ path, offset: 0, data: new Uint8Array(0) }));
139
+ } else {
140
+ throw new Error('File not found');
141
+ }
142
+ }
143
+
144
+ // Load pages into memory from IndexedDB.
145
+ await new Promise((resolve, reject) => {
146
+ const range = IDBKeyRange.bound([path, 0], [path, Infinity]);
147
+ const request = blocks.openCursor(range);
148
+ request.onsuccess = () => {
149
+ const cursor = request.result;
150
+ if (cursor) {
151
+ const { offset, data } = cursor.value;
152
+ file.blocks.set(offset, data);
153
+ cursor.continue();
154
+ } else {
155
+ resolve();
156
+ }
157
+ };
158
+ request.onerror = () => reject(request.error);
159
+ });
160
+ file.blockSize = file.blocks.get(0)?.byteLength ?? 0;
161
+
162
+ // Get the last transaction id.
163
+ const transactions = idbTx.objectStore('tx');
164
+ file.viewTx = await new Promise((resolve, reject) => {
165
+ const range = IDBKeyRange.bound([path, 0], [path, Infinity]);
166
+ const request = transactions.openCursor(range, 'prev');
167
+ request.onsuccess = () => {
168
+ const cursor = request.result;
169
+ if (cursor) {
170
+ resolve(cursor.value);
171
+ } else {
172
+ resolve({ txId: 0 });
173
+ }
174
+ };
175
+ request.onerror = () => reject(request.error);
176
+ });
177
+
178
+ // Publish our view of the database. This prevents other connections
179
+ // from overwriting file data we still need.
180
+ await this.#setView(file, file.viewTx);
181
+
182
+ // Listen for broadcasts. Messages are cached until the database
183
+ // is unlocked.
184
+ file.broadcastChannel.addEventListener('message', event => {
185
+ file.broadcastReceived.push(event.data);
186
+ if (file.lockState === VFS.SQLITE_LOCK_NONE) {
187
+ this.#processBroadcasts(file);
188
+ }
189
+ });
190
+ } else {
191
+ // Not a main database so not stored in IndexedDB.
192
+ file = this.#mapPathToFile.get(path);
193
+ if (!file) {
194
+ if (flags & VFS.SQLITE_OPEN_CREATE) {
195
+ file = new File(path, flags);
196
+ file.blocks.set(0, new Uint8Array(0));
197
+ } else {
198
+ throw new Error('File not found');
199
+ }
200
+ }
201
+ }
202
+
203
+ pOutFlags.setInt32(0, flags, true);
204
+ this.#mapIdToFile.set(fileId, file);
205
+ this.#mapPathToFile.set(path, file);
206
+ return VFS.SQLITE_OK;
207
+ } catch (e) {
208
+ this.#lastError = e;
209
+ return VFS.SQLITE_CANTOPEN;
210
+ }
211
+ }
212
+
213
+ /**
214
+ * @param {string} zName
215
+ * @param {number} syncDir
216
+ * @returns {Promise<number>}
217
+ */
218
+ async jDelete(zName, syncDir) {
219
+ try {
220
+ const url = new URL(zName, 'file://');
221
+ const pathname = url.pathname;
222
+
223
+ const result = await this.#deleteFile(pathname);
224
+ if (syncDir) {
225
+ await result;
226
+ }
227
+ return VFS.SQLITE_OK;
228
+ } catch (e) {
229
+ return VFS.SQLITE_IOERR_DELETE;
230
+ }
231
+ }
232
+
233
+ /**
234
+ * @param {string} zName
235
+ * @param {number} flags
236
+ * @param {DataView} pResOut
237
+ * @returns {Promise<number>}
238
+ */
239
+ async jAccess(zName, flags, pResOut) {
240
+ try {
241
+ const url = new URL(zName, 'file://');
242
+ const pathname = url.pathname;
243
+
244
+ // This test ignores main database files that have not been opened
245
+ // with this connection. SQLite does not call jAccess() on main
246
+ // database files, so avoiding an IndexedDB test saves time.
247
+ const exists = this.#mapPathToFile.has(pathname);
248
+ pResOut.setInt32(0, exists ? 1 : 0, true);
249
+ return VFS.SQLITE_OK;
250
+ } catch (e) {
251
+ this.#lastError = e;
252
+ return VFS.SQLITE_IOERR_ACCESS;
253
+ }
254
+ }
255
+
256
+ /**
257
+ * @param {number} fileId
258
+ * @returns {Promise<number>}
259
+ */
260
+ async jClose(fileId) {
261
+ try {
262
+ const file = this.#mapIdToFile.get(fileId);
263
+ this.#mapIdToFile.delete(fileId);
264
+
265
+ if (file?.flags & VFS.SQLITE_OPEN_MAIN_DB) {
266
+ file.broadcastChannel.close();
267
+ file.viewReleaser?.();
268
+ }
269
+
270
+ if (file?.flags & VFS.SQLITE_OPEN_DELETEONCLOSE) {
271
+ this.#deleteFile(file.path);
272
+ }
273
+ return VFS.SQLITE_OK;
274
+ } catch (e) {
275
+ return VFS.SQLITE_IOERR_CLOSE;
276
+ }
277
+ }
278
+
279
+ /**
280
+ * @param {number} fileId
281
+ * @param {Uint8Array} pData
282
+ * @param {number} iOffset
283
+ * @returns {number}
284
+ */
285
+ jRead(fileId, pData, iOffset) {
286
+ try {
287
+ const file = this.#mapIdToFile.get(fileId);
288
+
289
+ let bytesRead = 0;
290
+ let pDataOffset = 0;
291
+ while (pDataOffset < pData.byteLength) {
292
+ // File data is stored in fixed-size blocks. Get the next block
293
+ // needed.
294
+ const fileOffset = iOffset + pDataOffset;
295
+ const blockIndex = Math.floor(fileOffset / file.blockSize);
296
+ const blockOffset = fileOffset % file.blockSize;
297
+ const block =
298
+ file.txActive?.blocks.get(blockIndex * file.blockSize) ??
299
+ file.blocks.get(blockIndex * file.blockSize);
300
+ if (!block) {
301
+ break;
302
+ }
303
+
304
+ // Copy block data to the read buffer.
305
+ const blockLength = Math.min(
306
+ block.byteLength - blockOffset,
307
+ pData.byteLength - pDataOffset);
308
+ pData.set(block.subarray(blockOffset, blockOffset + blockLength), pDataOffset);
309
+ pDataOffset += blockLength;
310
+ bytesRead += blockLength;
311
+ }
312
+
313
+ if (bytesRead < pData.byteLength) {
314
+ pData.fill(0, bytesRead);
315
+ return VFS.SQLITE_IOERR_SHORT_READ;
316
+ }
317
+ return VFS.SQLITE_OK;
318
+ } catch (e) {
319
+ this.#lastError = e;
320
+ return VFS.SQLITE_IOERR_READ;
321
+ }
322
+ }
323
+
324
+ /**
325
+ * @param {number} fileId
326
+ * @param {Uint8Array} pData
327
+ * @param {number} iOffset
328
+ * @returns {number}
329
+ */
330
+ jWrite(fileId, pData, iOffset) {
331
+ try {
332
+ const file = this.#mapIdToFile.get(fileId);
333
+
334
+ if (file.flags & VFS.SQLITE_OPEN_MAIN_DB) {
335
+ if (!file.txActive) {
336
+ file.txActive = {
337
+ path: file.path,
338
+ txId: file.viewTx.txId + 1,
339
+ blocks: new Map(),
340
+ fileSize: file.blockSize * file.blocks.size,
341
+ };
342
+ }
343
+ file.txActive.blocks.set(iOffset, pData.slice());
344
+ file.txActive.fileSize = Math.max(file.txActive.fileSize, iOffset + pData.byteLength);
345
+ file.blockSize = pData.byteLength;
346
+ } else {
347
+ // All files that are not main databases are stored in a single
348
+ // block.
349
+ let block = file.blocks.get(0);
350
+ if (iOffset + pData.byteLength > block.byteLength) {
351
+ // Resize the block buffer.
352
+ const newSize = Math.max(iOffset + pData.byteLength, 2 * block.byteLength);
353
+ const newBlock = new Uint8Array(newSize);
354
+ newBlock.set(block);
355
+ file.blocks.set(0, newBlock);
356
+ block = newBlock;
357
+ }
358
+ block.set(pData, iOffset);
359
+ file.blockSize = Math.max(file.blockSize, iOffset + pData.byteLength);
360
+ }
361
+ return VFS.SQLITE_OK;
362
+ } catch (e) {
363
+ this.lastError = e;
364
+ return VFS.SQLITE_IOERR_WRITE;
365
+ }
366
+ }
367
+
368
+ /**
369
+ * @param {number} fileId
370
+ * @param {number} iSize
371
+ * @returns {number}
372
+ */
373
+ jTruncate(fileId, iSize) {
374
+ try {
375
+ const file = this.#mapIdToFile.get(fileId);
376
+
377
+ if (file.flags & VFS.SQLITE_OPEN_MAIN_DB) {
378
+ file.txActive.fileSize = iSize;
379
+ } else {
380
+ // All files that are not main databases are stored in a single
381
+ // block.
382
+ if (iSize < file.blockSize) {
383
+ const block = file.blocks.get(0);
384
+ file.blocks.set(0, block.subarray(0, iSize));
385
+ file.blockSize = iSize;
386
+ }
387
+ }
388
+ return VFS.SQLITE_OK;
389
+ } catch (e) {
390
+ console.error(e);
391
+ this.lastError = e;
392
+ return VFS.SQLITE_IOERR_TRUNCATE;
393
+ }
394
+ }
395
+
396
+ /**
397
+ * @param {number} fileId
398
+ * @param {DataView} pSize64
399
+ * @returns {number|Promise<number>}
400
+ */
401
+ jFileSize(fileId, pSize64) {
402
+ const file = this.#mapIdToFile.get(fileId);
403
+ const size = file.txActive?.fileSize ?? file.blockSize * file.blocks.size;
404
+ pSize64.setBigInt64(0, BigInt(size), true);
405
+ return VFS.SQLITE_OK;
406
+ }
407
+
408
+ /**
409
+ * @param {number} fileId
410
+ * @param {number} lockType
411
+ * @returns {Promise<number>}
412
+ */
413
+ async jLock(fileId, lockType) {
414
+ const file = this.#mapIdToFile.get(fileId);
415
+ if (lockType <= file.lockState) return VFS.SQLITE_OK;
416
+ switch (lockType) {
417
+ case VFS.SQLITE_LOCK_SHARED:
418
+ if (file.txWriteHint) {
419
+ // xFileControl() has hinted that this transaction will
420
+ // write. Acquire the hint lock, which is required to reach
421
+ // the RESERVED state.
422
+ if (!await this.#lock(file, 'hint')) {
423
+ return VFS.SQLITE_BUSY;
424
+ }
425
+ }
426
+ break;
427
+ case VFS.SQLITE_LOCK_RESERVED:
428
+ // Ideally we should already have the hint lock, but if not
429
+ // poll for it here.
430
+ if (!file.locks.hint && !await this.#lock(file, 'hint', POLL_EXCLUSIVE)) {
431
+ return VFS.SQLITE_BUSY;
432
+ }
433
+
434
+ if (!await this.#lock(file, 'reserved', POLL_EXCLUSIVE)) {
435
+ file.locks.hint();
436
+ return VFS.SQLITE_BUSY;
437
+ }
438
+
439
+ // In order to write, our view of the database must be up to date.
440
+ // To check this, first fetch all transactions in IndexedDB equal to
441
+ // or greater than our view.
442
+ const idbTx = this.#idb.transaction(['blocks', 'tx']);
443
+ const range = IDBKeyRange.bound(
444
+ [file.path, file.viewTx.txId],
445
+ [file.path, Infinity]);
446
+
447
+ /** @type {Transaction[]} */
448
+ const entries = await idbX(idbTx.objectStore('tx').getAll(range));
449
+
450
+ // Ideally the fetched list of transactions should contain one
451
+ // entry matching our view. If not then our view is out of date.
452
+ if (entries.length && entries.at(-1).txId > file.viewTx.txId) {
453
+ // There are newer transactions in IndexedDB that we haven't
454
+ // seen via broadcast. Ensure that they are incorporated on unlock,
455
+ // and force the application to retry.
456
+ const blocks = idbTx.objectStore('blocks');
457
+ for (const entry of entries) {
458
+ // When transactions are stored to IndexedDB, the page data is
459
+ // stripped to save time and space. Restore the page data here.
460
+ for (const offset of Array.from(entry.blocks.keys())) {
461
+ const value = await idbX(blocks.get([file.path, offset]));
462
+ entry.blocks.set(offset, value.data);
463
+ }
464
+ }
465
+ file.broadcastReceived.push(...entries);
466
+ file.locks.reserved();
467
+ return VFS.SQLITE_BUSY
468
+ }
469
+
470
+ console.assert(entries[0]?.txId === file.viewTx.txId || !file.viewTx.txId);
471
+ break;
472
+ case VFS.SQLITE_LOCK_EXCLUSIVE:
473
+ await this.#lock(file, 'write');
474
+ break;
475
+ }
476
+ file.lockState = lockType;
477
+ return VFS.SQLITE_OK;
478
+ }
479
+
480
+ /**
481
+ * @param {number} fileId
482
+ * @param {number} lockType
483
+ * @returns {number}
484
+ */
485
+ jUnlock(fileId, lockType) {
486
+ const file = this.#mapIdToFile.get(fileId);
487
+ if (lockType >= file.lockState) return VFS.SQLITE_OK;
488
+ switch (lockType) {
489
+ case VFS.SQLITE_LOCK_SHARED:
490
+ file.locks.write?.();
491
+ file.locks.reserved?.();
492
+ file.locks.hint?.();
493
+ break;
494
+ case VFS.SQLITE_LOCK_NONE:
495
+ // Don't release the read lock here. It will be released on demand
496
+ // when a broadcast notifies us that another connections wants to
497
+ // VACUUM.
498
+ this.#processBroadcasts(file);
499
+ file.locks.write?.();
500
+ file.locks.reserved?.();
501
+ file.locks.hint?.();
502
+ break;
503
+ }
504
+ file.lockState = lockType;
505
+ return VFS.SQLITE_OK;
506
+ }
507
+
508
+ /**
509
+ * @param {number} fileId
510
+ * @param {DataView} pResOut
511
+ * @returns {Promise<number>}
512
+ */
513
+ async jCheckReservedLock(fileId, pResOut) {
514
+ try {
515
+ const file = this.#mapIdToFile.get(fileId);
516
+ console.assert(file.flags & VFS.SQLITE_OPEN_MAIN_DB);
517
+ if (await this.#lock(file, 'reserved', POLL_SHARED)) {
518
+ // This looks backwards, but if we get the lock then no one
519
+ // else had it.
520
+ pResOut.setInt32(0, 0, true);
521
+ file.locks.reserved();
522
+ } else {
523
+ pResOut.setInt32(0, 1, true);
524
+ }
525
+ return VFS.SQLITE_OK;
526
+ } catch (e) {
527
+ console.error(e);
528
+ this.lastError = e;
529
+ return VFS.SQLITE_IOERR_LOCK;
530
+ }
531
+ }
532
+
533
+ /**
534
+ * @param {number} fileId
535
+ * @param {number} op
536
+ * @param {DataView} pArg
537
+ * @returns {Promise<number>}
538
+ */
539
+ async jFileControl(fileId, op, pArg) {
540
+ try {
541
+ const file = this.#mapIdToFile.get(fileId);
542
+ switch (op) {
543
+ case VFS.SQLITE_FCNTL_PRAGMA:
544
+ const key = cvtString(pArg, 4);
545
+ const value = cvtString(pArg, 8);
546
+ this.log?.('xFileControl', file.path, 'PRAGMA', key, value);
547
+ switch (key.toLowerCase()) {
548
+ case 'page_size':
549
+ // Don't allow changing the page size.
550
+ if (value && file.blockSize && Number(value) !== file.blockSize) {
551
+ return VFS.SQLITE_ERROR;
552
+ }
553
+ break;
554
+ case 'synchronous':
555
+ // This VFS only recognizes 'full' and not 'full'.
556
+ if (value) {
557
+ switch (value.toLowerCase()) {
558
+ case 'full':
559
+ case '2':
560
+ case 'extra':
561
+ case '3':
562
+ file.synchronous = 'full';
563
+ break;
564
+ case 'normal':
565
+ case '1':
566
+ file.synchronous = 'normal';
567
+ break;
568
+ default:
569
+ console.warn(`unsupported synchronous mode: ${value}`);
570
+ return VFS.SQLITE_ERROR;
571
+ }
572
+ }
573
+ break;
574
+ }
575
+ break;
576
+ case VFS.SQLITE_FCNTL_BEGIN_ATOMIC_WRITE:
577
+ this.log?.('xFileControl', 'BEGIN_ATOMIC_WRITE', file.path);
578
+ return VFS.SQLITE_OK;
579
+ case VFS.SQLITE_FCNTL_COMMIT_ATOMIC_WRITE:
580
+ this.log?.('xFileControl', 'COMMIT_ATOMIC_WRITE', file.path);
581
+ return VFS.SQLITE_OK;
582
+ case VFS.SQLITE_FCNTL_ROLLBACK_ATOMIC_WRITE:
583
+ this.#dropTx(file);
584
+ return VFS.SQLITE_OK;
585
+ case VFS.SQLITE_FCNTL_SYNC:
586
+ // Propagate database writes to IndexedDB and other clients. Most
587
+ // often this is a SQLite transaction, but it can also be a
588
+ // journal rollback.
589
+ //
590
+ // If SQLITE_FCNTL_OVERWRITE has been received then propagation is
591
+ // deferred until SQLITE_FCNTL_COMMIT_PHASETWO for file truncation.
592
+ this.log?.('xFileControl', 'SYNC', file.path);
593
+ if (file.txActive && !file.txOverwrite) {
594
+ await this.#commitTx(file);
595
+ }
596
+ break;
597
+ case VFS.SQLITE_FCNTL_OVERWRITE:
598
+ // Marks the beginning of a VACUUM.
599
+ file.txOverwrite = true;
600
+ break;
601
+ case VFS.SQLITE_FCNTL_COMMIT_PHASETWO:
602
+ // Commit database writes for VACUUM. Other writes will already
603
+ // be propagated by SQLITE_FCNTL_SYNC.
604
+ this.log?.('xFileControl', 'COMMIT_PHASETWO', file.path);
605
+ if (file.txActive) {
606
+ await this.#commitTx(file);
607
+ }
608
+ file.txOverwrite = false;
609
+ break;
610
+ }
611
+ } catch (e) {
612
+ this.#lastError = e;
613
+ return VFS.SQLITE_IOERR;
614
+ }
615
+ return VFS.SQLITE_NOTFOUND;
616
+ }
617
+
618
+ /**
619
+ * @param {number} fileId
620
+ * @returns {number|Promise<number>}
621
+ */
622
+ jDeviceCharacteristics(fileId) {
623
+ return 0
624
+ | VFS.SQLITE_IOCAP_BATCH_ATOMIC
625
+ | VFS.SQLITE_IOCAP_UNDELETABLE_WHEN_OPEN;
626
+ }
627
+
628
+ /**
629
+ * @param {Uint8Array} zBuf
630
+ * @returns {number}
631
+ */
632
+ jGetLastError(zBuf) {
633
+ if (this.#lastError) {
634
+ console.error(this.#lastError);
635
+ const outputArray = zBuf.subarray(0, zBuf.byteLength - 1);
636
+ const { written } = new TextEncoder().encodeInto(this.#lastError.message, outputArray);
637
+ zBuf[written] = 0;
638
+ }
639
+ return VFS.SQLITE_OK
640
+ }
641
+
642
+ /**
643
+ *
644
+ * @param {File} file
645
+ * @param {Transaction} tx
646
+ */
647
+ #acceptTx(file, tx) {
648
+ // Add/update transaction pages.
649
+ for (const [offset, data] of tx.blocks) {
650
+ file.blocks.set(offset, data);
651
+ if (file.blockSize === 0) {
652
+ file.blockSize = data.byteLength;
653
+ }
654
+ }
655
+
656
+ let truncated = tx.fileSize + file.blockSize;
657
+ while (file.blocks.delete(truncated)) {
658
+ truncated += file.blockSize;
659
+ }
660
+
661
+ file.viewTx = tx;
662
+ }
663
+
664
+ /**
665
+ * @param {File} file
666
+ */
667
+ async #commitTx(file) {
668
+ // Advance our own view. Even if we received our own broadcasts (we
669
+ // don't), we want our view to be updated synchronously.
670
+ this.#acceptTx(file, file.txActive);
671
+ this.#setView(file, file.txActive);
672
+
673
+ const oldestTxId = await this.#getOldestTxInUse(file);
674
+
675
+ // Update IndexedDB page data.
676
+ const idbTx = this.#idb.transaction(['blocks', 'tx'], 'readwrite');
677
+ const blocks = idbTx.objectStore('blocks');
678
+ for (const [offset, data] of file.txActive.blocks) {
679
+ blocks.put({ path: file.path, offset, data });
680
+ }
681
+
682
+ // Delete obsolete transactions no longer needed.
683
+ const oldRange = IDBKeyRange.bound(
684
+ [file.path, -Infinity], [file.path, oldestTxId],
685
+ false, true);
686
+ idbTx.objectStore('tx').delete(oldRange);
687
+
688
+ // Save transaction object. Omit page data as an optimization.
689
+ const txSansData = Object.assign({}, file.txActive);
690
+ txSansData.blocks = new Map(Array.from(file.txActive.blocks, ([k]) => [k, null]));
691
+ idbTx.objectStore('tx').put(txSansData);
692
+
693
+ // Broadcast transaction once it commits.
694
+ const complete = new Promise((resolve, reject) => {
695
+ const message = file.txActive;
696
+ idbTx.oncomplete = () => {
697
+ file.broadcastChannel.postMessage(message);
698
+ resolve();
699
+ };
700
+ idbTx.onabort = () => reject(idbTx.error);
701
+ idbTx.commit();
702
+ });
703
+
704
+ if (file.synchronous === 'full') {
705
+ await complete;
706
+ }
707
+
708
+ file.txActive = null;
709
+ file.txWriteHint = false;
710
+ }
711
+
712
+ /**
713
+ * @param {File} file
714
+ */
715
+ #dropTx(file) {
716
+ file.txActive = null;
717
+ file.txWriteHint = false;
718
+ }
719
+
720
+ /**
721
+ * @param {string} path
722
+ * @returns {Promise}
723
+ */
724
+ async #deleteFile(path) {
725
+ this.#mapPathToFile.delete(path);
726
+
727
+ // Only main databases are stored in IndexedDB and SQLite never
728
+ // deletes main databases, but delete blocks here anyway for
729
+ // standalone use.
730
+ const request = this.#idb.transaction(['blocks'], 'readwrite')
731
+ .objectStore('blocks')
732
+ .delete(IDBKeyRange.bound([path, 0], [path, Infinity]));
733
+ await new Promise((resolve, reject) => {
734
+ const idbTx = request.transaction;
735
+ idbTx.oncomplete = resolve;
736
+ idbTx.onerror = () => reject(idbTx.error);
737
+ });
738
+ }
739
+
740
+ /**
741
+ * @param {File} file
742
+ * @returns {Promise<number>}
743
+ */
744
+ async #getOldestTxInUse(file) {
745
+ // Each connection holds a shared Web Lock with a name that encodes
746
+ // the latest transaction it knows about. We can find the oldest
747
+ // transaction by listing the those locks and extracting the earliest
748
+ // transaction id.
749
+ const TX_LOCK_REGEX = /^(.*)@@\[(\d+)\]$/;
750
+ let oldestTxId = file.viewTx.txId;
751
+ const locks = await navigator.locks.query();
752
+ for (const { name } of locks.held) {
753
+ const m = TX_LOCK_REGEX.exec(name);
754
+ if (m && m[1] === file.path) {
755
+ oldestTxId = Math.min(oldestTxId, Number(m[2]));
756
+ }
757
+ }
758
+ return oldestTxId;
759
+ }
760
+
761
+ /**
762
+ * Acquire one of the database file internal Web Locks.
763
+ * @param {File} file
764
+ * @param {'write'|'reserved'|'hint'} name
765
+ * @param {LockOptions} options
766
+ * @returns {Promise<boolean>}
767
+ */
768
+ #lock(file, name, options = {}) {
769
+ return new Promise(resolve => {
770
+ const lockName = `${file.path}@@${name}`;
771
+ navigator.locks.request(lockName, options, lock => {
772
+ if (lock) {
773
+ return new Promise(release => {
774
+ file.locks[name] = () => {
775
+ release();
776
+ file.locks[name] = null;
777
+ };
778
+ resolve(true);
779
+ });
780
+ } else {
781
+ file.locks[name] = null;
782
+ resolve(false);
783
+ }
784
+ }).catch(e => {
785
+ if (e.name !== 'AbortError') throw e;
786
+ });
787
+ });
788
+ }
789
+
790
+ /**
791
+ * Handle prevously received messages from other connections.
792
+ * @param {File} file
793
+ */
794
+ #processBroadcasts(file) {
795
+ // Sort transaction messages by id.
796
+ file.broadcastReceived.sort((a, b) => a.txId - b.txId);
797
+
798
+ let nHandled = 0;
799
+ let newTx = file.viewTx;
800
+ for (const message of file.broadcastReceived) {
801
+ if (message.txId <= newTx.txId) {
802
+ // This transaction is already incorporated into our view.
803
+ } else if (message.txId === newTx.txId + 1) {
804
+ // This is the next expected transaction.
805
+ this.log?.(`accept tx ${message.txId}`);
806
+ this.#acceptTx(file, message);
807
+ newTx = message;
808
+ } else {
809
+ // There is a gap in the transaction sequence.
810
+ console.warn(`missing tx ${newTx.txId + 1} (got ${message.txId})`);
811
+ break;
812
+ }
813
+ nHandled++;
814
+ }
815
+
816
+ // Remove handled messages from the list.
817
+ file.broadcastReceived.splice(0, nHandled);
818
+
819
+ // Tell other connections about a change in our view.
820
+ if (newTx.txId > file.viewTx.txId) {
821
+ // No need to await here.
822
+ this.#setView(file, newTx);
823
+ }
824
+ }
825
+
826
+ /**
827
+ * @param {File} file
828
+ * @param {Transaction} tx
829
+ */
830
+ async #setView(file, tx) {
831
+ // Publish our view of the database with a lock name that includes
832
+ // the transaction id. As long as we hold the lock, no other connection
833
+ // will overwrite data we are using.
834
+ file.viewTx = tx;
835
+ const lockName = `${file.path}@@[${tx.txId}]`;
836
+ const newReleaser = await new Promise(resolve => {
837
+ navigator.locks.request(lockName, SHARED, lock => {
838
+ return new Promise(release => {
839
+ resolve(release);
840
+ });
841
+ });
842
+ });
843
+
844
+ // The new lock is acquired so release the old one.
845
+ file.viewReleaser?.();
846
+ file.viewReleaser = newReleaser;
847
+ }
848
+ }
849
+
850
+ /**
851
+ * Wrap IndexedDB request with a Promise.
852
+ * @param {IDBRequest} request
853
+ * @returns
854
+ */
855
+ function idbX(request) {
856
+ return new Promise((resolve, reject) => {
857
+ request.onsuccess = () => resolve(request.result);
858
+ request.onerror = () => reject(request.error);
859
+ });
860
+ }
861
+
862
+ /**
863
+ * Extract a C string from WebAssembly memory.
864
+ * @param {DataView} dataView
865
+ * @param {number} offset
866
+ * @returns
867
+ */
868
+ function cvtString(dataView, offset) {
869
+ const p = dataView.getUint32(offset, true);
870
+ if (p) {
871
+ const chars = new Uint8Array(dataView.buffer, p);
872
+ return new TextDecoder().decode(chars.subarray(0, chars.indexOf(0)));
873
+ }
874
+ return null;
875
+ }