@ehegnes/wa-sqlite 2.0.0-beta.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 (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +78 -0
  3. package/dist/wa-sqlite-async.mjs +2 -0
  4. package/dist/wa-sqlite-async.wasm +0 -0
  5. package/dist/wa-sqlite-jspi.mjs +2 -0
  6. package/dist/wa-sqlite-jspi.wasm +0 -0
  7. package/dist/wa-sqlite.mjs +2 -0
  8. package/dist/wa-sqlite.wasm +0 -0
  9. package/package.json +44 -0
  10. package/src/FacadeVFS.js +681 -0
  11. package/src/VFS.js +222 -0
  12. package/src/WebLocksMixin.js +411 -0
  13. package/src/examples/AccessHandlePoolVFS.js +458 -0
  14. package/src/examples/IDBBatchAtomicVFS.js +827 -0
  15. package/src/examples/IDBMirrorVFS.js +889 -0
  16. package/src/examples/LazyLock.js +90 -0
  17. package/src/examples/Lock.js +69 -0
  18. package/src/examples/MemoryAsyncVFS.js +100 -0
  19. package/src/examples/MemoryVFS.js +176 -0
  20. package/src/examples/OPFSAdaptiveVFS.js +437 -0
  21. package/src/examples/OPFSAnyContextVFS.js +300 -0
  22. package/src/examples/OPFSCoopSyncVFS.js +597 -0
  23. package/src/examples/OPFSPermutedVFS.js +1217 -0
  24. package/src/examples/OPFSWriteAheadVFS.js +960 -0
  25. package/src/examples/README.md +81 -0
  26. package/src/examples/WriteAhead.js +1174 -0
  27. package/src/examples/tag.js +82 -0
  28. package/src/sqlite-api.js +924 -0
  29. package/src/sqlite-constants.js +275 -0
  30. package/src/types/globals.d.ts +60 -0
  31. package/src/types/index.d.ts +1228 -0
  32. package/src/types/tsconfig.json +6 -0
  33. package/test/AccessHandlePoolVFS.test.js +27 -0
  34. package/test/IDBBatchAtomicVFS.test.js +97 -0
  35. package/test/IDBMirrorVFS.test.js +27 -0
  36. package/test/MemoryAsyncVFS.test.js +27 -0
  37. package/test/MemoryVFS.test.js +27 -0
  38. package/test/OPFSAdaptiveVFS.test.js +27 -0
  39. package/test/OPFSAnyContextVFS.test.js +27 -0
  40. package/test/OPFSCoopSyncVFS.test.js +27 -0
  41. package/test/OPFSWriteAheadVFS.test.js +27 -0
  42. package/test/TestContext.js +96 -0
  43. package/test/WebLocksMixin.test.js +521 -0
  44. package/test/api.test.js +49 -0
  45. package/test/api_exec.js +89 -0
  46. package/test/api_misc.js +63 -0
  47. package/test/api_statements.js +447 -0
  48. package/test/callbacks.test.js +581 -0
  49. package/test/data/idbv5.json +1 -0
  50. package/test/sql.test.js +64 -0
  51. package/test/sql_0001.js +49 -0
  52. package/test/sql_0002.js +52 -0
  53. package/test/sql_0003.js +83 -0
  54. package/test/sql_0004.js +81 -0
  55. package/test/sql_0005.js +76 -0
  56. package/test/test-worker.js +204 -0
  57. package/test/vfs_xAccess.js +2 -0
  58. package/test/vfs_xClose.js +52 -0
  59. package/test/vfs_xOpen.js +91 -0
  60. package/test/vfs_xRead.js +38 -0
  61. package/test/vfs_xWrite.js +36 -0
@@ -0,0 +1,1174 @@
1
+ import { Lock } from './Lock.js';
2
+
3
+ const DEFAULT_JOURNAL_SIZE_LIMIT = 1000;
4
+ const DEFAULT_BACKSTOP_INTERVAL = 30_000;
5
+
6
+ const MAGIC = 0x377f0684;
7
+ const FILE_HEADER_SIZE = 32;
8
+ const FRAME_HEADER_SIZE = 32;
9
+ const FRAME_TYPE_PAGE = 0;
10
+ const FRAME_TYPE_COMMIT = 1;
11
+ const FRAME_TYPE_END = 2;
12
+
13
+ /**
14
+ * @typedef PageEntry
15
+ * @property {number} waOffset location in WAL file
16
+ * @property {number} waSalt1 WAL2 file identifier
17
+ * @property {number} pageSize
18
+ * @property {Uint8Array} [pageData]
19
+ */
20
+
21
+ /**
22
+ * @typedef Transaction
23
+ * @property {number} id
24
+ * @property {Map<number, PageEntry>} pages address to page data mapping
25
+ * @property {number} dbFileSize
26
+ * @property {number} [newPageSize]
27
+ * @property {number} waSalt1 WAL2 file identifier
28
+ * @property {number} waOffsetEnd
29
+ */
30
+
31
+ /**
32
+ * @typedef WriteAheadOptions
33
+ * @property {number} [autoCheckpoint]
34
+ * @property {number} [backstopInterval]
35
+ * @property {number} [journalSizeLimit]
36
+ */
37
+
38
+ export class WriteAhead {
39
+
40
+ log = null;
41
+ /** @type {WriteAheadOptions} */ options = {
42
+ autoCheckpoint: 1,
43
+ backstopInterval: DEFAULT_BACKSTOP_INTERVAL,
44
+ journalSizeLimit: DEFAULT_JOURNAL_SIZE_LIMIT,
45
+ };
46
+
47
+ #zName;
48
+ #dbHandle;
49
+
50
+ /** @type {FileSystemSyncAccessHandle[]} */ #waHandles;
51
+ /** @type {FileSystemSyncAccessHandle} */ #activeHandle;
52
+ /** @type {{nextTxId: number, salt1: number, salt2: number}} */ #activeHeader;
53
+ /** @type {number} */ #activeOffset;
54
+ /** @type {number} */ #txId = 0;
55
+ /** @type {Transaction} */ #txInProgress = null;
56
+
57
+ #dbFileSize = 0;
58
+
59
+ /** @type {Promise<any>} */ #ready;
60
+ /** @type {'read'|'write'} */ #isolationState = null;
61
+
62
+ /** @type {Lock} */ #txIdLock = null;
63
+
64
+ /** @type {Map<number, PageEntry>} */ #waOverlay = new Map();
65
+ /** @type {Map<number, Transaction>} */ #mapIdToTx = new Map();
66
+ /** @type {Map<number, Transaction>} */ #mapIdToPendingTx = new Map();
67
+ #approxPageCount = 0;
68
+
69
+ /** @type {BroadcastChannel} */ #broadcastChannel;
70
+
71
+ /** @type {number} */ #backstopTimer;
72
+ /** @type {number} */ #backstopTimestamp = 0;
73
+
74
+ #abortController = new AbortController();
75
+
76
+ /**
77
+ * @param {string} zName
78
+ * @param {FileSystemSyncAccessHandle} dbHandle
79
+ * @param {FileSystemSyncAccessHandle[]} waHandles
80
+ * @param {WriteAheadOptions} options
81
+ */
82
+ constructor(zName, dbHandle, waHandles, options = {}) {
83
+ this.#zName = zName;
84
+ this.#dbHandle = dbHandle;
85
+ this.#waHandles = waHandles;
86
+ this.options = Object.assign(this.options, options);
87
+
88
+ // All the asynchronous initialization is done here.
89
+ this.#ready = (async () => {
90
+ // Set our advertised txId to zero until we know the proper value.
91
+ await this.#updateTxIdLock();
92
+
93
+ // Listen for transactions and checkpoints from other connections.
94
+ this.#broadcastChannel = new BroadcastChannel(`${zName}#wa`);
95
+ this.#broadcastChannel.onmessage = (event) => {
96
+ this.#handleMessage(event);
97
+ };
98
+
99
+ // Read headers from both WAL files and use the one with the
100
+ // lower nextTxId. If neither header is valid, create a new header.
101
+ const fileHeader = this.#waHandles
102
+ .map(handle => this.#readFileHeader(handle))
103
+ .filter(h => h)
104
+ .sort((a, b) => a.nextTxId - b.nextTxId)[0]
105
+ ?? this.#writeFileHeader(Math.floor(Math.random() * 0xffffffff));
106
+
107
+ this.#activeHeader = fileHeader;
108
+ this.#activeHandle = this.#waHandles[fileHeader.salt1 & 1];
109
+ this.#activeOffset = FILE_HEADER_SIZE;
110
+ this.#txId = fileHeader.nextTxId - 1;
111
+
112
+ // Load all the transactions from the WAL.
113
+ for (const tx of this.#readAllTx()) {
114
+ this.#activateTx(tx);
115
+ }
116
+ this.#updateTxIdLock(); // doesn't need await
117
+
118
+ // Schedule backstop. The backstop is a guard against a crash in
119
+ // another context between persisting a transaction and broadcasting
120
+ // it.
121
+ this.#backstopTimestamp = performance.now();
122
+ this.#backstop();
123
+ })();
124
+ }
125
+
126
+ /**
127
+ * @returns {Promise<void>}
128
+ */
129
+ ready() {
130
+ return this.#ready;
131
+ }
132
+
133
+ close() {
134
+ this.#abortController.abort();
135
+
136
+ // Stop asynchronous maintenance.
137
+ this.#broadcastChannel.onmessage = null;
138
+ clearTimeout(this.#backstopTimer);
139
+
140
+ this.#txIdLock?.release();
141
+ this.#broadcastChannel.close();
142
+ }
143
+
144
+ /**
145
+ * Freeze our view of the database.
146
+ * The view includes the transactions received so far but is not
147
+ * guaranteed to be completely up to date. Unfreeze the view with rejoin().
148
+ */
149
+ isolateForRead() {
150
+ if (this.#isolationState !== null) {
151
+ throw new Error('Already in isolated state');
152
+ }
153
+ this.#isolationState = 'read';
154
+
155
+ // Disable backstop during isolation.
156
+ clearTimeout(this.#backstopTimer);
157
+ this.#backstopTimer = null;
158
+ }
159
+
160
+ /**
161
+ * Freeze our view of the database for writing.
162
+ * The view includes all transactions. Unfreeze the view with rejoin().
163
+ */
164
+ isolateForWrite() {
165
+ if (this.#isolationState !== null) {
166
+ throw new Error('Already in isolated state');
167
+ }
168
+ this.#isolationState = 'write';
169
+
170
+ // Disable backstop during isolation.
171
+ clearTimeout(this.#backstopTimer);
172
+ this.#backstopTimer = null;
173
+
174
+ // A writer needs all previous transactions assimilated.
175
+ this.#advanceTxId({ readToCurrent: true });
176
+ }
177
+
178
+ rejoin() {
179
+ if (this.#isolationState === 'read') {
180
+ // Catch up on new transactions that arrived while isolated.
181
+ this.#advanceTxId({ autoCheckpoint: true });
182
+ }
183
+ this.#isolationState = null;
184
+
185
+ // Resume backstop after isolation.
186
+ this.#backstop();
187
+ }
188
+
189
+ /**
190
+ * @param {number} offset
191
+ * @return {Uint8Array?}
192
+ */
193
+ read(offset) {
194
+ // First look for the page in any write transaction in progress.
195
+ // If the page is not found in the transaction overlay, look in the
196
+ // write-ahead overlay.
197
+ const pageEntry = this.#txInProgress?.pages.get(offset) ?? this.#waOverlay.get(offset);
198
+ if (pageEntry) {
199
+ if (pageEntry.pageData) {
200
+ // Page data is cached.
201
+ this.log?.(`%cread page at ${offset} from WAL ${pageEntry.waSalt1 & 1}:${pageEntry.waOffset} (cached)`, 'background-color: gold;');
202
+ return pageEntry.pageData;
203
+ }
204
+
205
+ // Read the page from the WAL file.
206
+ this.log?.(`%cread page at ${offset} from WAL ${pageEntry.waSalt1 & 1}:${pageEntry.waOffset}`, 'background-color: gold;');
207
+ return this.#fetchPage(pageEntry);
208
+ }
209
+ return null;
210
+ }
211
+
212
+ /**
213
+ * @param {number} offset
214
+ * @param {Uint8Array} data
215
+ * @param {{dstPageSize: number?}} options
216
+ */
217
+ write(offset, data, options) {
218
+ if (this.#isolationState !== 'write') {
219
+ throw new Error('Not in write isolated state');
220
+ }
221
+
222
+ if (!this.#txInProgress) {
223
+ // There is no active transaction so we need to create one. But
224
+ // first check whether to move to the other WAL file.
225
+ const nPageThreshold = this.options.journalSizeLimit > 0 ?
226
+ this.options.journalSizeLimit :
227
+ DEFAULT_JOURNAL_SIZE_LIMIT;
228
+ if (this.#approxPageCount >= nPageThreshold && this.#isInactiveFileEmpty()) {
229
+ this.log?.(`%cchange WAL file at ${this.#approxPageCount} pages`, 'background-color: lightskyblue;');
230
+ this.#swapActiveFile();
231
+ }
232
+
233
+ this.#beginTx();
234
+ if (options.dstPageSize !== data.byteLength) {
235
+ // This is a VACUUM to a new page size. The incoming writes are at
236
+ // the old page size, but we want to write to the WAL with the new
237
+ // size.
238
+ this.#txInProgress.newPageSize = options.dstPageSize;
239
+ }
240
+ }
241
+
242
+ if (this.#txInProgress.newPageSize) {
243
+ // The incoming data is not a single page because the page size
244
+ // is changing. The two cases are when the new page size is
245
+ // smaller or larger than the old page size.
246
+ const frameSize = FRAME_HEADER_SIZE + this.#txInProgress.newPageSize;
247
+ if (data.byteLength > this.#txInProgress.newPageSize) {
248
+ // New page size is smaller. Write multiple pages of the new
249
+ // page size.
250
+ for (let i = 0; i < data.byteLength; i += this.#txInProgress.newPageSize) {
251
+ const pageData = data.slice(i, i + this.#txInProgress.newPageSize);
252
+ const waOffset = this.#writePage(offset + i, pageData);
253
+ this.log?.(`%cwrite page at ${offset + i} to WAL ${this.#activeHeader.salt1 & 1}:${waOffset}`, 'background-color: lightskyblue;');
254
+ }
255
+ } else {
256
+ // New page size is larger. Save the page data to the WAL file
257
+ // so it can be read back and rewritten as frames with the new
258
+ // page size.
259
+ const pageOffset = offset % this.#txInProgress.newPageSize;
260
+ const waOffset = this.#activeOffset +
261
+ (offset - pageOffset) / this.#txInProgress.newPageSize * frameSize +
262
+ FRAME_HEADER_SIZE +
263
+ pageOffset;
264
+ this.#activeHandle.write(data.subarray(), { at: waOffset });
265
+ this.log?.(`%cwrite page at ${offset} to WAL ${this.#activeHeader.salt1 & 1}:${waOffset}`, 'background-color: lightskyblue;');
266
+ }
267
+ } else {
268
+ // This is the normal case without a page size change.
269
+ const waOffset = this.#writePage(offset, data.slice());
270
+ this.log?.(`%cwrite page at ${offset} to WAL ${this.#activeHeader.salt1 & 1}:${waOffset}`, 'background-color: lightskyblue;');
271
+ }
272
+ }
273
+
274
+ /**
275
+ * @param {number} newSize
276
+ */
277
+ truncate(newSize) {
278
+ // Ignore truncation that happens outside of a transaction. That
279
+ // only happens (e.g. post-VACUUM) to ensure the file size matches
280
+ // the database header.
281
+ if (this.#txInProgress) {
282
+ // Remove any pages past the truncation point.
283
+ for (const offset of this.#txInProgress.pages.keys()) {
284
+ if (offset >= newSize) {
285
+ this.#txInProgress.pages.delete(offset);
286
+ }
287
+ }
288
+ }
289
+ }
290
+
291
+ getFileSize() {
292
+ return this.#txInProgress?.dbFileSize ?? this.#dbFileSize;
293
+ }
294
+
295
+ commit() {
296
+ const tx = this.#txInProgress;
297
+ if (tx.newPageSize && tx.pages.size === 0) {
298
+ // This transaction is a VACUUM with a page size increase. All
299
+ // the database pages have been written to the WAL file at their
300
+ // new size with blank frame headers. Read the page data back
301
+ // from the WAL file and rewrite as frames.
302
+ let pageCount = 1; // to be replaced on the first iteration
303
+ for (let i = 0; i < pageCount; i++) {
304
+ // Read the page data.
305
+ const pageData = new Uint8Array(tx.newPageSize);
306
+ const waOffset = this.#activeOffset +
307
+ i * (FRAME_HEADER_SIZE + tx.newPageSize) +
308
+ FRAME_HEADER_SIZE;
309
+ this.#activeHandle.read(pageData, { at: waOffset });
310
+
311
+ if (i === 0) {
312
+ // Get the actual page count from the file header.
313
+ const headerView = new DataView(pageData.buffer);
314
+ pageCount = headerView.getUint32(28);
315
+ }
316
+
317
+ // Write back as a frame.
318
+ this.#writePage(i * tx.newPageSize, pageData);
319
+ }
320
+ }
321
+
322
+ const page1 = this.#txInProgress.pages.get(0)?.pageData;
323
+ if (page1) {
324
+ const page1View = new DataView(page1.buffer, page1.byteOffset, page1.byteLength);
325
+ const pageCount = page1View.getUint32(28);
326
+ this.#txInProgress.dbFileSize = pageCount * page1.byteLength;
327
+ } else {
328
+ // The transaction doesn't include page 1, so this must be a
329
+ // non-batch-atomic rollback.
330
+ this.rollback();
331
+ return;
332
+ }
333
+
334
+ // Persist the final pending transaction page with the database size.
335
+ this.#commitTx();
336
+
337
+ // Incorporate the transaction locally.
338
+ this.#activateTx(tx);
339
+ this.#updateTxIdLock();
340
+
341
+ // Send the transaction to other connections.
342
+ const payload = { type: 'tx', tx };
343
+ this.#broadcastChannel.postMessage(payload);
344
+
345
+ this.#autoCheckpoint();
346
+ this.#backstopTimestamp = performance.now();
347
+ }
348
+
349
+ rollback() {
350
+ // Discard transaction pages.
351
+ this.#abortTx();
352
+ }
353
+
354
+ /**
355
+ * @param {{durability: 'strict'|'relaxed'}} options
356
+ */
357
+ sync(options) {
358
+ if (options.durability === 'strict') {
359
+ this.#flushActiveFile();
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Move pages from write-ahead to main database file.
365
+ *
366
+ * @param {{isPassive: boolean}} options
367
+ */
368
+ async checkpoint(options = { isPassive: true }) {
369
+ // Passive checkpointing is abandoned if another connection is
370
+ // already checkpointing.
371
+ const lockOptions = {
372
+ ifAvailable: options.isPassive,
373
+ };
374
+
375
+ await navigator.locks.request(`${this.#zName}#ckpt`, lockOptions, async lock => {
376
+ if (!lock) return;
377
+ if (this.#abortController.signal.aborted) return;
378
+
379
+ let ckptId = this.#getActiveFileStartingTxId() - 1;
380
+ if (options.isPassive) {
381
+ if (!this.#mapIdToTx.has(ckptId)) {
382
+ // There are no transactions to checkpoint.
383
+ return;
384
+ }
385
+
386
+ // Scan the txId locks to find the oldest txId.
387
+ const busyTxId = (await this.#getTxIdLocks())
388
+ .reduce((min, value) => Math.min(min, value.maxTxId), this.#txId);
389
+
390
+ if (busyTxId < ckptId) {
391
+ // The inactive WAL file is still being used.
392
+ return;
393
+ }
394
+ } else {
395
+ // Wait for all connections to reach the current txId.
396
+ await this.#waitForTxIdLocks(value => value.maxTxId >= this.#txId);
397
+ ckptId = this.#txId;
398
+ }
399
+ this.log?.(`%ccheckpoint through txId ${ckptId}`, 'background-color: lightgreen;');
400
+
401
+ // Sync the WAL file. This ensures that if there is a crash after
402
+ // part of the WAL has been copied, the uncopied part will still be
403
+ // available afterwards.
404
+ this.#flushInactiveFile();
405
+ if (!options.isPassive) {
406
+ this.#flushActiveFile();
407
+ }
408
+
409
+ // Starting at ckptId and going backwards (higher to lower txId),
410
+ // write transaction pages to the main database file. Do not overwrite
411
+ // a page written by a more recent transaction.
412
+ const writtenOffsets = new Set();
413
+ let dbFileSize = this.#dbHandle.getSize();
414
+ for (let tx = this.#mapIdToTx.get(ckptId); tx; tx = this.#mapIdToTx.get(tx.id - 1)) {
415
+ if (tx.id === ckptId && dbFileSize !== tx.dbFileSize) {
416
+ // Set the file size from the latest transaction.
417
+ dbFileSize = tx.dbFileSize;
418
+ this.#dbHandle.truncate(dbFileSize);
419
+ }
420
+
421
+ for (const [offset, pageEntry] of tx.pages) {
422
+ if (offset < dbFileSize && !writtenOffsets.has(offset)) {
423
+ // Fetch the page data from the WAL file if not cached.
424
+ const pageData = pageEntry.pageData ?? this.#fetchPage(pageEntry);
425
+
426
+ // Write the page to the database file.
427
+ const nWritten = this.#dbHandle.write(pageData, { at: offset });
428
+ if (nWritten !== pageData.byteLength) {
429
+ throw new Error('Checkpoint write failed');
430
+ }
431
+ writtenOffsets.add(offset);
432
+ this.log?.(`%ccheckpoint wrote txId ${tx.id} page at ${offset} to database`, 'background-color: lightgreen;');
433
+ }
434
+ }
435
+
436
+ if (tx.newPageSize) {
437
+ // This transaction used a new page size to overwrite the entire
438
+ // database file so no older pages need to be written. This is
439
+ // not just an optimization; it prevents incorrectly writing
440
+ // older smaller pages at addresses that aren't multiples of
441
+ // the new page size.
442
+ break;
443
+ }
444
+ }
445
+
446
+ // Ensure that database writes are durable.
447
+ this.log?.(`%ccheckpoint flush database file`, 'background-color: lightgreen;');
448
+ this.#dbHandle.flush();
449
+
450
+ // Notify other connections and ourselves of the checkpoint.
451
+ this.#broadcastChannel.postMessage({
452
+ type: 'ckpt',
453
+ ckptId,
454
+ });
455
+ this.#handleCheckpoint(ckptId);
456
+
457
+ // Wait for all connections to update their overlay.
458
+ this.log?.(`%ccheckpoint waiting for connection updates`, 'background-color: lightgreen;');
459
+ await this.#waitForTxIdLocks(value => value.minTxId > ckptId);
460
+
461
+ // Truncate the inactive WAL file. This prevents new connections from
462
+ // unnecessarily reading checkpointed data, and allows writers to make
463
+ // it active when their conditions are met.
464
+ this.#truncateInactiveFile();
465
+ this.log?.(`%ccheckpoint complete`, 'background-color: lightgreen;');
466
+ });
467
+ }
468
+
469
+ /**
470
+ * Return the approximate number of write-ahead pages. This is the
471
+ * sum of the number of unique page indices for each transaction,
472
+ * so it can be fewer than the number of pages if any transaction
473
+ * contains multiple frames for the same page.
474
+ * @returns {number}
475
+ */
476
+ getWriteAheadSize() {
477
+ return this.#approxPageCount;
478
+ }
479
+
480
+ isTransactionPending() {
481
+ return !!this.#txInProgress;
482
+ }
483
+
484
+ setBackstopInterval(intervalMillis) {
485
+ this.options.backstopInterval = intervalMillis;
486
+ if (intervalMillis > 0 && this.#isolationState) {
487
+ this.#backstop();
488
+ }
489
+ }
490
+
491
+ /**
492
+ * Incorporate a transaction into our view of the database.
493
+ * @param {Transaction} tx
494
+ */
495
+ #activateTx(tx) {
496
+ // Transfer to the active collection of transactions.
497
+ this.#mapIdToTx.set(tx.id, tx);
498
+ this.#approxPageCount += tx.pages.size;
499
+
500
+ // Add transaction pages to the write-ahead overlay.
501
+ for (const [offset, pageEntry] of tx.pages) {
502
+ this.#waOverlay.set(offset, pageEntry);
503
+ }
504
+ this.#dbFileSize = tx.dbFileSize;
505
+ }
506
+
507
+ /**
508
+ * Advance the local view of the database. By default, advance to the
509
+ * last broadcast transaction. Optionally, also advance through any
510
+ * additional transactions in the WAL file to be fully current.
511
+ *
512
+ * @param {{readToCurrent?: boolean, autoCheckpoint?: boolean}} options
513
+ */
514
+ #advanceTxId(options = {}) {
515
+ let didAdvance = false;
516
+ while (this.#mapIdToPendingTx.size) {
517
+ // Fetch the next transaction in sequence. Usually this will come
518
+ // from pendingTx, but if it is missing then read it from the file.
519
+ const nextTxId = this.#txId + 1;
520
+ let tx;
521
+ if (this.#mapIdToPendingTx.has(nextTxId)) {
522
+ // This transaction arrived via message.
523
+ tx = this.#mapIdToPendingTx.get(nextTxId);
524
+ this.#mapIdToPendingTx.delete(tx.id);
525
+
526
+ // Move the WAL file offset past this transaction.
527
+ this.#skipTx(tx);
528
+ } else {
529
+ // Read the transaction from the WAL file.
530
+ tx = this.#readTx();
531
+ }
532
+
533
+ this.#activateTx(tx);
534
+ didAdvance = true;
535
+ }
536
+
537
+ if (options.readToCurrent) {
538
+ // Read all additional transactions from the WAL file.
539
+ for (const tx of this.#readAllTx()) {
540
+ this.#activateTx(tx);
541
+ didAdvance = true;
542
+ }
543
+ }
544
+
545
+ if (didAdvance) {
546
+ // Publish our new view txId.
547
+ this.#updateTxIdLock();
548
+
549
+ if (options.autoCheckpoint) {
550
+ this.#autoCheckpoint();
551
+ }
552
+ }
553
+
554
+ if (options.readToCurrent || didAdvance) {
555
+ // The WAL has been accessed, so reset the backstop.
556
+ // Calling #backstop() here is not necessary because if we are
557
+ // in an isolated state then rejoin() will schedule the next call,
558
+ // and if we are not in an isolated state then the next call
559
+ // should already be scheduled.
560
+ this.#backstopTimestamp = performance.now();
561
+ }
562
+ }
563
+
564
+ #autoCheckpoint() {
565
+ if (this.options.autoCheckpoint > 0) {
566
+ this.checkpoint({ isPassive: true });
567
+ }
568
+ }
569
+
570
+ /**
571
+ * After a checkpoint, remove checkpointed pages from write-ahead.
572
+ * The checkpoint may be been done locally or by another connection.
573
+ * @param {number} ckptId
574
+ */
575
+ #handleCheckpoint(ckptId) {
576
+ this.log?.(`%capply checkpoint through txId ${ckptId}`, 'background-color: lightgreen;');
577
+
578
+ // Loop backwards from ckptId.
579
+ for (let tx = this.#mapIdToTx.get(ckptId); tx; tx = this.#mapIdToTx.get(tx.id - 1)) {
580
+ // Remove pages from write-ahead overlay.
581
+ for (const [offset, pageEntry] of tx.pages.entries()) {
582
+ // Be sure not to remove a newer version of the page.
583
+ const overlayEntry = this.#waOverlay.get(offset);
584
+ if (overlayEntry === pageEntry) {
585
+ this.log?.(`%cremove txId ${tx.id} page at offset ${offset}`, 'background-color: lightgreen;');
586
+ this.#waOverlay.delete(offset);
587
+ }
588
+ }
589
+
590
+ // Remove transaction.
591
+ this.#mapIdToTx.delete(tx.id);
592
+ this.#approxPageCount -= tx.pages.size;
593
+ }
594
+ this.#updateTxIdLock();
595
+ }
596
+
597
+ /**
598
+ * @param {MessageEvent} event
599
+ */
600
+ #handleMessage(event) {
601
+ if (event.data.type === 'tx') {
602
+ // New transaction from another connection. Don't use it if we
603
+ // already have it.
604
+ /** @type {Transaction} */ const tx = event.data.tx;
605
+ if (tx.id > this.#txId) {
606
+ this.#mapIdToPendingTx.set(tx.id, tx);
607
+ if (this.#isolationState === null) {
608
+ // Not in an isolated state, so advance our view of the database.
609
+ this.#advanceTxId({ autoCheckpoint: true });
610
+ }
611
+ }
612
+ } else if (event.data.type === 'ckpt') {
613
+ // Checkpoint notification from another connection.
614
+ /** @type {number} */ const ckptId = event.data.ckptId;
615
+ this.#handleCheckpoint(ckptId);
616
+ }
617
+ }
618
+
619
+ /**
620
+ * Periodic check for recovering from lost transaction broadcasts.
621
+ */
622
+ #backstop() {
623
+ if (this.options.backstopInterval <= 0) {
624
+ // Backstop is disabled.
625
+ return;
626
+ }
627
+
628
+ if (this.#isolationState) {
629
+ throw new Error('Backstop was invoked in an isolated state');
630
+ }
631
+
632
+ const now = performance.now();
633
+ if (now >= this.#backstopTimestamp + this.options.backstopInterval) {
634
+ // The time since the last WAL access (read, write, or skip) has
635
+ // exceeded the backstop interval. Check for transactions in the
636
+ // write-ahead log that have not arrived via message.
637
+ const oldTxId = this.#txId;
638
+ this.#advanceTxId({ readToCurrent: true });
639
+ if (this.#txId > oldTxId) {
640
+ this.log?.(`%cbackstop txId ${oldTxId} -> ${this.#txId}`, 'background-color: lightyellow;');
641
+ }
642
+ this.#backstopTimestamp = performance.now();
643
+ }
644
+
645
+ // Schedule next backstop.
646
+ const delay = this.#backstopTimestamp + this.options.backstopInterval - performance.now();
647
+ clearTimeout(this.#backstopTimer);
648
+ this.#backstopTimer = self.setTimeout(() => {
649
+ this.#backstop();
650
+ }, delay);
651
+ }
652
+
653
+ /**
654
+ * Update the lock that publishes our current txId.
655
+ */
656
+ async #updateTxIdLock() {
657
+ // Our view of the database, i.e. the txId, is encoded into the name
658
+ // of a lock so other connections can see it. When our txId changes,
659
+ // we acquire a new lock and release the old one. We must not release
660
+ // the old lock until the new one is in place.
661
+ const oldLock = this.#txIdLock;
662
+ const newLockName = this.#encodeTxIdLockName();
663
+ if (oldLock?.name !== newLockName) {
664
+ this.#txIdLock = new Lock(newLockName);
665
+ await this.#txIdLock.acquire('shared').then(() => {
666
+ // The new lock is acquired.
667
+ oldLock?.release();
668
+ });
669
+
670
+ if (this.log) {
671
+ const { minTxId, maxTxId } = this.#decodeTxIdLockName(newLockName);
672
+ this.log?.(`%ctxId to ${minTxId}:${maxTxId}`, 'background-color: pink;');
673
+ }
674
+ }
675
+ }
676
+
677
+ /**
678
+ * Get all txId locks for this database.
679
+ * @returns {Promise<{name: string, minTxId: number, maxTxId: number, encoded: string}[]>}
680
+ */
681
+ async #getTxIdLocks() {
682
+ const { held } = await navigator.locks.query();
683
+ return held
684
+ .map(lock => this.#decodeTxIdLockName(lock.name))
685
+ .filter(value => value !== null);
686
+ }
687
+
688
+ /**
689
+ * @returns {string}
690
+ */
691
+ #encodeTxIdLockName() {
692
+ // The maxTxId is our current view of the database. The minTxId is
693
+ // the lowest txId we get pages from the WAL for, which is the lowest
694
+ // key in mapIdToTx. If mapIdToTx is empty then we aren't reading
695
+ // from the WAL at all - in this case we arbitrarily set minTxId to
696
+ // invalid value maxTxId + 1.
697
+ //
698
+ // Use radix 36 to encode integer values to reduce the lock name length.
699
+ const maxTxId = this.#txId;
700
+ const minTxId = this.#mapIdToTx.keys().next().value ?? (maxTxId + 1);
701
+ return `${this.#zName}-txId<${minTxId.toString(36)}:${maxTxId.toString(36)}>`;
702
+ }
703
+
704
+ /**
705
+ * @param {string} lockName
706
+ * @returns {{name: string, minTxId: number, maxTxId: number, encoded: string}?}
707
+ */
708
+ #decodeTxIdLockName(lockName) {
709
+ const match = lockName.match(/^(.*)-txId<([0-9a-z]+):([0-9a-z]+)>$/);
710
+ if (match?.[1] === this.#zName) {
711
+ // This txId lock is for this database.
712
+ return {
713
+ name: match[1],
714
+ minTxId: parseInt(match[2], 36),
715
+ maxTxId: parseInt(match[3], 36),
716
+ encoded: lockName
717
+ };
718
+ }
719
+ return null;
720
+ }
721
+
722
+ /**
723
+ * Wait for all txId locks that fail the provided predicate.
724
+ * @param {(lock: {name: string, minTxId: number, maxTxId: number}) => boolean} predicate
725
+ */
726
+ async #waitForTxIdLocks(predicate) {
727
+ /** @type {string[]} */ let failingLockNames = [];
728
+ do {
729
+ // Wait for all connections that fail the predicate.
730
+ if (failingLockNames.length > 0) {
731
+ await Promise.all(
732
+ failingLockNames.map(name => navigator.locks.request(name, async () => {}))
733
+ );
734
+ }
735
+
736
+ // Refresh the list of failing locks.
737
+ failingLockNames = (await this.#getTxIdLocks())
738
+ .filter(value => !predicate(value))
739
+ .map(value => value.encoded);
740
+ } while (failingLockNames.length > 0);
741
+ }
742
+
743
+ /**
744
+ * @param {PageEntry} pageEntry
745
+ * @returns {Uint8Array}
746
+ */
747
+ #fetchPage(pageEntry) {
748
+ // Get the appropriate access handle based on salt parity.
749
+ const accessHandle = this.#waHandles[pageEntry.waSalt1 & 1];
750
+
751
+ // Read the page.
752
+ const pageData = new Uint8Array(pageEntry.pageSize);
753
+ const nBytesRead = accessHandle.read(pageData, { at: pageEntry.waOffset });
754
+
755
+ if (nBytesRead !== pageEntry.pageSize) {
756
+ throw new Error(`Short WAL read: expected ${pageEntry.pageSize} bytes, got ${nBytesRead}`);
757
+ }
758
+ return pageData;
759
+ }
760
+
761
+ *#readAllTx() {
762
+ while (true) {
763
+ const tx = this.#readTx();
764
+ if (!tx) break;
765
+ yield tx;
766
+ }
767
+ }
768
+
769
+ /**
770
+ * @returns {Transaction?}
771
+ */
772
+ #readTx() {
773
+ // Read the next complete transaction or return null.
774
+ /** @type {Transaction} */ const tx = {
775
+ id: 0, // placeholder
776
+ pages: new Map(),
777
+ dbFileSize: 0, // placeholder
778
+ waSalt1: 0, // placeholder
779
+ waOffsetEnd: 0, // placeholder
780
+ };
781
+
782
+ // The property this.#activeOffset is only advanced on a successful
783
+ // transition to the other WAL file or on reading a complete
784
+ // transaction. Use a local variable to track our progress.
785
+ let offset = this.#activeOffset;
786
+ while (true) {
787
+ const frame = this.#readFrame(offset);
788
+ if (!frame) return null;
789
+
790
+ if (frame.frameType === FRAME_TYPE_PAGE) {
791
+ tx.pages.set(
792
+ frame.pageOffset,
793
+ {
794
+ pageSize: frame.pageData.byteLength,
795
+ waOffset: offset + FRAME_HEADER_SIZE,
796
+ waSalt1: this.#activeHeader.salt1,
797
+ }
798
+ );
799
+ } else if (frame.frameType === FRAME_TYPE_COMMIT) {
800
+ // The transaction is complete. Update the instance state.
801
+ this.#txId += 1;
802
+ this.#activeOffset = offset + frame.byteLength;
803
+
804
+ // Finalize the transaction fields and return it.
805
+ tx.id = this.#txId;
806
+ tx.dbFileSize = frame.dbFileSize;
807
+ tx.waSalt1 = this.#activeHeader.salt1;
808
+ tx.newPageSize = (frame.flags & 1) ? tx.pages.get(0).pageSize : null;
809
+ tx.waOffsetEnd = this.#activeOffset;
810
+ return tx;
811
+ } else if (frame.frameType === FRAME_TYPE_END) {
812
+ // No more transactions on the current WAL file. Switch to the
813
+ // other file.
814
+ this.#followFileChange(frame.fileHeader);
815
+ offset = this.#activeOffset;
816
+ continue;
817
+ }
818
+
819
+ offset += frame.byteLength;
820
+ }
821
+ }
822
+
823
+ /**
824
+ * This method is called when transaction(s) have been received by other
825
+ * means than readTx(), e.g. via BroadcastChannel.
826
+ *
827
+ * @param {Transaction} tx
828
+ */
829
+ #skipTx(tx) {
830
+ if (tx.waSalt1 !== this.#activeHeader.salt1) {
831
+ // This transaction is on the other WAL file.
832
+ if (!this.#followFileChange(null)) {
833
+ throw new Error('invalid WAL file');
834
+ }
835
+ }
836
+
837
+ this.#txId = tx.id;
838
+ this.#activeOffset = tx.waOffsetEnd;
839
+ }
840
+
841
+ /**
842
+ * @param {{overwrite?: boolean}} options
843
+ * @returns {Transaction}
844
+ */
845
+ #beginTx(options = {}) {
846
+ this.#txInProgress = {
847
+ id: this.#txId + 1,
848
+ pages: new Map(),
849
+ dbFileSize: this.#dbFileSize,
850
+ waSalt1: this.#activeHeader.salt1,
851
+ waOffsetEnd: this.#activeOffset,
852
+ };
853
+ return this.#txInProgress;
854
+ }
855
+
856
+ /**
857
+ * Write a page frame to the WAL file.
858
+ *
859
+ * @param {number} pageOffset
860
+ * @param {Uint8Array} pageData
861
+ */
862
+ #writePage(pageOffset, pageData) {
863
+ const headerView = new DataView(new ArrayBuffer(FRAME_HEADER_SIZE));
864
+ headerView.setUint8(0, FRAME_TYPE_PAGE);
865
+ headerView.setUint16(2, pageData.byteLength === 65536 ? 1 : pageData.byteLength);
866
+ headerView.setBigUint64(8, BigInt(pageOffset));
867
+ headerView.setUint32(16, this.#activeHeader.salt1);
868
+ headerView.setUint32(20, this.#activeHeader.salt2);
869
+
870
+ const checksum = new Checksum();
871
+ checksum.update(new Uint8Array(headerView.buffer, 0, FRAME_HEADER_SIZE - 8));
872
+ checksum.update(pageData);
873
+ headerView.setUint32(24, checksum.s0);
874
+ headerView.setUint32(28, checksum.s1);
875
+
876
+ const bytesWritten =
877
+ this.#activeHandle.write(headerView, { at: this.#txInProgress.waOffsetEnd }) +
878
+ this.#activeHandle.write(pageData, {
879
+ at: this.#txInProgress.waOffsetEnd + FRAME_HEADER_SIZE,
880
+ });
881
+ if (bytesWritten !== headerView.byteLength + pageData.byteLength) {
882
+ throw new Error('write failed');
883
+ }
884
+
885
+ // Cache page 1 as a performance optimization and to exercise the
886
+ // cache code path.
887
+ const pageEntry = {
888
+ pageSize: pageData.byteLength,
889
+ waOffset: this.#txInProgress.waOffsetEnd + FRAME_HEADER_SIZE,
890
+ waSalt1: this.#activeHeader.salt1,
891
+ pageData: pageOffset === 0 ? pageData : undefined
892
+ };
893
+ this.#txInProgress.pages.set(pageOffset, pageEntry);
894
+ this.#txInProgress.waOffsetEnd += bytesWritten;
895
+
896
+ return pageEntry.waOffset;
897
+ }
898
+
899
+ /**
900
+ * @returns {Transaction}
901
+ */
902
+ #commitTx() {
903
+ // Write a commit frame - which is a special frame header with no
904
+ // body - to the WAL file.
905
+ const headerView = new DataView(new ArrayBuffer(FRAME_HEADER_SIZE));
906
+ headerView.setUint8(0, FRAME_TYPE_COMMIT);
907
+ headerView.setUint8(1, this.#txInProgress.newPageSize ? 1 : 0);
908
+ headerView.setBigUint64(8, BigInt(this.#txInProgress.dbFileSize));
909
+ headerView.setUint32(16, this.#activeHeader.salt1);
910
+ headerView.setUint32(20, this.#activeHeader.salt2);
911
+
912
+ const checksum = new Checksum();
913
+ checksum.update(new Uint8Array(headerView.buffer, 0, FRAME_HEADER_SIZE - 8));
914
+ headerView.setUint32(24, checksum.s0);
915
+ headerView.setUint32(28, checksum.s1);
916
+
917
+ const bytesWritten = this.#activeHandle.write(headerView, {
918
+ at: this.#txInProgress.waOffsetEnd,
919
+ });
920
+ if (bytesWritten !== headerView.byteLength) {
921
+ throw new Error('write failed');
922
+ }
923
+ this.#txInProgress.waOffsetEnd += bytesWritten;
924
+
925
+ const tx = this.#txInProgress;
926
+ this.#txInProgress = null;
927
+ this.#activeOffset = tx.waOffsetEnd;
928
+ this.#txId = tx.id;
929
+ return tx;
930
+ }
931
+
932
+ #abortTx() {
933
+ this.#txInProgress = null;
934
+ this.#activeHandle.truncate(this.#activeOffset);
935
+ }
936
+
937
+ /**
938
+ * Switch the active WAL file prior to writing the next transaction.
939
+ */
940
+ #swapActiveFile() {
941
+ // Write an end frame to terminate the currently active WAL file.
942
+ const frameView = new DataView(new ArrayBuffer(FRAME_HEADER_SIZE));
943
+ frameView.setUint8(0, FRAME_TYPE_END);
944
+ frameView.setUint32(16, this.#activeHeader.salt1);
945
+ frameView.setUint32(20, this.#activeHeader.salt2);
946
+
947
+ const checksum = new Checksum();
948
+ checksum.update(new Uint8Array(frameView.buffer, 0, FRAME_HEADER_SIZE - 8));
949
+ frameView.setUint32(24, checksum.s0);
950
+ frameView.setUint32(28, checksum.s1);
951
+
952
+ const bytesWritten = this.#activeHandle.write(frameView, { at: this.#activeOffset });
953
+ if (bytesWritten !== frameView.byteLength) {
954
+ throw new Error('write failed');
955
+ }
956
+
957
+ // Initialize the other WAL file and make it active.
958
+ this.#activeHeader = this.#writeFileHeader();
959
+ this.#activeHandle = this.#getInactiveHandle();
960
+ this.#activeOffset = FILE_HEADER_SIZE;
961
+ }
962
+
963
+ #getActiveFileStartingTxId() {
964
+ return this.#activeHeader.nextTxId;
965
+ }
966
+
967
+ #flushActiveFile() {
968
+ this.#activeHandle.flush();
969
+ }
970
+
971
+ #flushInactiveFile() {
972
+ const accessHandle = this.#getInactiveHandle();
973
+ accessHandle.flush();
974
+ }
975
+
976
+ #isInactiveFileEmpty() {
977
+ if (this.#mapIdToTx.has(this.#activeHeader.nextTxId - 1)) {
978
+ // At least one transaction on the inactive file has not been
979
+ // checkpointed.
980
+ return false;
981
+ }
982
+
983
+ const inactiveHandle = this.#getInactiveHandle();
984
+ if (inactiveHandle.getSize() < FILE_HEADER_SIZE) {
985
+ // The inactive file is smaller than the minimum size for a valid
986
+ // WAL file.
987
+ return true;
988
+ }
989
+
990
+ // This test is sufficient by itself but the previous tests are
991
+ // less expensive.
992
+ return this.#readFileHeader(inactiveHandle) === null;
993
+ }
994
+
995
+ #truncateInactiveFile() {
996
+ const accessHandle = this.#getInactiveHandle();
997
+ accessHandle.truncate(0);
998
+ }
999
+
1000
+ /**
1001
+ * This method is called after reading an end frame to switch to the
1002
+ * other WAL file.
1003
+ * @param {{nextTxId: number, salt1: number, salt2: number}?} fileHeader
1004
+ */
1005
+ #followFileChange(fileHeader) {
1006
+ // As an optimization, the file header can be passed as an argument
1007
+ // if it has already been read and validated. Otherwise that is
1008
+ // done here.
1009
+ const accessHandle = this.#getInactiveHandle();
1010
+ if (!fileHeader) {
1011
+ fileHeader = this.#readFileHeader(accessHandle);
1012
+ if (fileHeader?.salt1 !== ((this.#activeHeader.salt1 + 1) >>> 0)) return null;
1013
+ }
1014
+
1015
+ this.#activeHandle = accessHandle;
1016
+ this.#activeHeader = fileHeader;
1017
+ this.#activeOffset = FILE_HEADER_SIZE;
1018
+ return fileHeader;
1019
+ }
1020
+
1021
+ #getInactiveHandle() {
1022
+ return this.#activeHandle !== this.#waHandles[0] ?
1023
+ this.#waHandles[0] :
1024
+ this.#waHandles[1];
1025
+ }
1026
+
1027
+ /**
1028
+ * @param {FileSystemSyncAccessHandle} accessHandle
1029
+ */
1030
+ #readFileHeader(accessHandle) {
1031
+ const headerView = new DataView(new ArrayBuffer(FILE_HEADER_SIZE));
1032
+ if (accessHandle.read(headerView, { at: 0 }) !== headerView.byteLength) {
1033
+ return null;
1034
+ }
1035
+
1036
+ if (headerView.getUint32(0) !== MAGIC) return null;
1037
+
1038
+ const checksum = new Checksum();
1039
+ checksum.update(new Uint8Array(headerView.buffer, 0, FILE_HEADER_SIZE - 8));
1040
+ if (!checksum.matches(headerView.getUint32(24), headerView.getUint32(28))) {
1041
+ return null;
1042
+ }
1043
+
1044
+ return {
1045
+ nextTxId: Number(headerView.getBigUint64(8)),
1046
+ salt1: headerView.getUint32(16),
1047
+ salt2: headerView.getUint32(20),
1048
+ };
1049
+ }
1050
+
1051
+ /**
1052
+ * @param {number} offset
1053
+ */
1054
+ #readFrame(offset) {
1055
+ const headerView = new DataView(new ArrayBuffer(FRAME_HEADER_SIZE));
1056
+ if (this.#activeHandle.read(headerView, { at: offset }) !== headerView.byteLength) {
1057
+ // EOF, not an error.
1058
+ return null;
1059
+ }
1060
+
1061
+ // Verify the frame header salt values match the file header.
1062
+ const frameSalt1 = headerView.getUint32(16);
1063
+ const frameSalt2 = headerView.getUint32(20);
1064
+ if (frameSalt1 !== this.#activeHeader.salt1 || frameSalt2 !== this.#activeHeader.salt2) {
1065
+ // Not necessarily an error, could be from a restart without truncation.
1066
+ return null;
1067
+ }
1068
+
1069
+ const payloadSize = (size => size === 1 ? 65536 : size)(headerView.getUint16(2));
1070
+ /** @type {Uint8Array} */ let payloadData;
1071
+ if (payloadSize) {
1072
+ payloadData = new Uint8Array(payloadSize);
1073
+ const payloadBytesRead = this.#activeHandle.read(
1074
+ payloadData,
1075
+ { at: offset + FRAME_HEADER_SIZE }
1076
+ );
1077
+ if (payloadBytesRead !== payloadSize) return null;
1078
+ }
1079
+
1080
+ const checksum = new Checksum();
1081
+ checksum.update(new Uint8Array(headerView.buffer, 0, FRAME_HEADER_SIZE - 8));
1082
+ if (payloadData) {
1083
+ checksum.update(payloadData);
1084
+ }
1085
+ if (!checksum.matches(headerView.getUint32(24), headerView.getUint32(28))) {
1086
+ // Not necessarily an error, could be from a restart without truncation.
1087
+ return null;
1088
+ }
1089
+
1090
+ const frameType = headerView.getUint8(0);
1091
+ if (frameType === FRAME_TYPE_PAGE) {
1092
+ return {
1093
+ frameType,
1094
+ byteLength: FRAME_HEADER_SIZE + payloadSize,
1095
+ pageOffset: Number(headerView.getBigUint64(8)),
1096
+ pageData: payloadData,
1097
+ };
1098
+ } else if (frameType === FRAME_TYPE_COMMIT) {
1099
+ return {
1100
+ frameType,
1101
+ byteLength: FRAME_HEADER_SIZE,
1102
+ flags: headerView.getUint8(1),
1103
+ dbFileSize: Number(headerView.getBigUint64(8)),
1104
+ };
1105
+ } else if (frameType === FRAME_TYPE_END) {
1106
+ // Handling the end frame and new file header must be atomic, so
1107
+ // we validate the new file header before returning the frame.
1108
+ // If the file header is corrupt, the end frame effectively does
1109
+ // not exist.
1110
+ //
1111
+ // A corrupt file header should be repaired by the next writer
1112
+ // that attempts to swap WAL files.
1113
+ const fileHeader = this.#readFileHeader(this.#getInactiveHandle());
1114
+ if (fileHeader?.salt1 !== ((this.#activeHeader.salt1 + 1) >>> 0)) return null;
1115
+
1116
+ return {
1117
+ frameType,
1118
+ byteLength: FRAME_HEADER_SIZE,
1119
+ fileHeader,
1120
+ };
1121
+ }
1122
+ throw new Error(`Invalid frame type: ${frameType}`);
1123
+ }
1124
+
1125
+ #writeFileHeader(prevSalt1 = this.#activeHeader.salt1) {
1126
+ // Derive new values from the previous values.
1127
+ const nextTxId = this.#txId + 1;
1128
+ const salt1 = (prevSalt1 + 1) >>> 0;
1129
+ const salt2 = Math.floor(Math.random() * 0xffffffff) >>> 0;
1130
+ const headerView = new DataView(new ArrayBuffer(FILE_HEADER_SIZE));
1131
+ headerView.setUint32(0, MAGIC);
1132
+ headerView.setBigUint64(8, BigInt(nextTxId));
1133
+ headerView.setUint32(16, salt1);
1134
+ headerView.setUint32(20, salt2);
1135
+
1136
+ const checksum = new Checksum();
1137
+ checksum.update(new Uint8Array(headerView.buffer, 0, FILE_HEADER_SIZE - 8));
1138
+ headerView.setUint32(24, checksum.s0);
1139
+ headerView.setUint32(28, checksum.s1);
1140
+
1141
+ // The even/odd parity of salt1 determines which file is written to.
1142
+ const accessHandle = this.#waHandles[salt1 & 1];
1143
+ const bytesWritten = accessHandle.write(headerView, { at: 0 });
1144
+ if (bytesWritten !== headerView.byteLength) {
1145
+ throw new Error('write failed');
1146
+ }
1147
+
1148
+ return { nextTxId, salt1, salt2 };
1149
+ }
1150
+ }
1151
+
1152
+ // https://www.sqlite.org/fileformat.html#checksum_algorithm
1153
+ class Checksum {
1154
+ /** @type {number} */ s0 = 0;
1155
+ /** @type {number} */ s1 = 0;
1156
+
1157
+ /**
1158
+ * @param {ArrayBuffer|ArrayBufferView} data
1159
+ */
1160
+ update(data) {
1161
+ if ((data.byteLength % 8) !== 0) throw new Error('Data must be a multiple of 8 bytes');
1162
+ const words = ArrayBuffer.isView(data) ?
1163
+ new Uint32Array(data.buffer, data.byteOffset, data.byteLength / 4) :
1164
+ new Uint32Array(data);
1165
+ for (let i = 0; i < words.length; i += 2) {
1166
+ this.s0 = (this.s0 + words[i] + this.s1) >>> 0;
1167
+ this.s1 = (this.s1 + words[i + 1] + this.s0) >>> 0;
1168
+ }
1169
+ }
1170
+
1171
+ matches(s0, s1) {
1172
+ return this.s0 === s0 && this.s1 === s1;
1173
+ }
1174
+ }