@taladb/web 0.11.2 → 0.11.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,589 +1,295 @@
1
1
  /**
2
- * TalaDB Worker
3
- *
4
- * Runs as a Dedicated Worker. Each tab spawns its own worker instance.
5
- * Multi-tab write safety is provided by the Web Locks API only one worker
6
- * holds the exclusive lock on the OPFS file at a time. Other workers queue up
7
- * and acquire the lock automatically when the current holder closes.
8
- *
9
- * Why DedicatedWorker (not SharedWorker)?
10
- * ----------------------------------------
11
- * createSyncAccessHandle() — required for synchronous OPFS I/O — is only
12
- * available in DedicatedWorkerGlobalScope per the WHATWG File System spec.
13
- * SharedWorker cannot call it; Chrome throws "is not a function".
14
- *
15
- * Why Web Locks?
16
- * --------------
17
- * Without coordination, two tabs opening the same OPFS file would race.
18
- * navigator.locks.request() gives us an exclusive named lock. The first tab
19
- * acquires it immediately; subsequent tabs block until the holder's worker is
20
- * terminated (tab closed / navigated) or db.close() is called explicitly.
21
- * If Web Locks is unavailable the worker opens the file directly and logs a
22
- * warning (safe for single-tab use).
23
- *
24
- * Message protocol
25
- * ----------------
26
- * Request → { id: number, op: string, ...args }
27
- * Response → { id: number, result: unknown }
28
- * | { id: number, error: string }
29
- *
30
- * Supported ops
31
- * -------------
32
- * init { dbName }
33
- * insert { collection, docJson }
34
- * insertMany { collection, docsJson }
35
- * find { collection, filterJson }
36
- * findOne { collection, filterJson }
37
- * updateOne { collection, filterJson, updateJson }
38
- * updateMany { collection, filterJson, updateJson }
39
- * deleteOne { collection, filterJson }
40
- * deleteMany { collection, filterJson }
41
- * count { collection, filterJson }
42
- * aggregate { collection, pipelineJson } → JSON array of result docs
43
- * createIndex { collection, field }
44
- * dropIndex { collection, field }
45
- * createFtsIndex { collection, field }
46
- * dropFtsIndex { collection, field }
47
- * listIndexes { collection } → JSON { btree, fts, vector }
48
- * createVectorIndex { collection, field, dimensions, metric?, indexType?, hnswM?, hnswEfConstruction? }
49
- * dropVectorIndex { collection, field }
50
- * upgradeVectorIndex { collection, field }
51
- * findNearest { collection, field, queryJson, topK, filterJson? }
52
- * searchText { collection, field, query, topK, filterJson?, optionsJson? }
53
- * hybridSearch { collection, textField, text, vectorField, vectorJson, topK, filterJson?, optionsJson? }
54
- * listCollections {} → JSON string[]
55
- * compact {} → null
56
- * close {}
57
- *
58
- * Multi-tab live queries (BroadcastChannel)
59
- * -----------------------------------------
60
- * When a write op (insert/insertMany/updateOne/updateMany/deleteOne/deleteMany) commits, the worker
61
- * posts a `"taladb:changed"` message on a BroadcastChannel named
62
- * `"taladb:<dbName>"`. Other tabs listening on the same channel re-trigger
63
- * their active `subscribe()` pollers immediately, bypassing the 300 ms tick.
64
- *
65
- * Cross-tab write forwarding
66
- * --------------------------
67
- * A tab that cannot take the OPFS lock runs an in-memory database and does NOT
68
- * publish the IndexedDB snapshot, so it has no durable storage of its own.
69
- * After every write it forwards the change on the BroadcastChannel as
70
- * `{ type: 'taladb:tab-write', … }`; the tab holding the lock applies it and
71
- * runs the normal onWriteCommitted() path, so every tab is notified and the
72
- * OPFS file stays authoritative.
73
- *
74
- * Inserts forward whole documents with their `_id`s (upserted by id, so an id
75
- * the application already holds stays valid); filter-based updates and deletes
76
- * forward the filter and update, re-evaluated by the primary against
77
- * authoritative data. Ordering is arrival order at the primary — two tabs on
78
- * one device share a clock, so there is no skew to arbitrate.
79
- *
80
- * The primary acknowledges each forwarded write. Unacknowledged writes are kept
81
- * so that a tab promoted to owner (below) can replay exactly the ones that were
82
- * never persisted.
83
- *
84
- * Lock takeover
85
- * -------------
86
- * A tab that loses the OPFS lock queues for it in the background. When the
87
- * holder closes its tab or calls `db.close()`, the lock passes to a waiting tab,
88
- * which opens the OPFS file, adopts it as authoritative, and replays its own
89
- * unacknowledged writes. Without this, closing the tab that owned the file left
90
- * every other tab writing into memory that nothing would ever persist.
91
- *
92
- * IndexedDB fallback (no OPFS)
93
- * ----------------------------
94
- * When OPFS is unavailable (cross-origin iframes, Firefox without storage
95
- * access) the worker opens an in-memory database seeded from the last snapshot
96
- * stored in IndexedDB. After every write it flushes a new snapshot back to
97
- * IndexedDB so data survives page reloads.
2
+ * One DedicatedWorker owns each database under a Web Lock. Other tabs send
3
+ * requests to that owner and receive its actual result. They never keep a
4
+ * writable snapshot. Requests are not automatically retried: a timeout can
5
+ * mean the owner committed but its response was lost.
98
6
  */
99
-
100
- // ---------------------------------------------------------------------------
101
- // State
102
- // ---------------------------------------------------------------------------
103
-
104
- /** @type {import('../pkg/taladb_web').WorkerDB | null} */
105
- let db = null;
106
-
107
- /**
108
- * WorkerDB constructor — hoisted to module scope so snapshot reloads in
109
- * IDB-fallback mode can call WorkerDB.openWithSnapshot without re-importing.
110
- * @type {typeof import('../pkg/taladb_web').WorkerDB | null}
111
- */
112
- let WorkerDB = null;
113
-
114
- /**
115
- * Set to true by the BroadcastChannel listener (fallback mode) when the
116
- * primary tab commits a write. Cleared at the start of the next dispatch.
117
- * @type {boolean}
118
- */
119
- let snapshotDirty = false;
120
-
121
- /**
122
- * Resolve function set when a fallback tab is waiting for the primary tab to
123
- * export a snapshot via BroadcastChannel. Cleared once resolved or timed out.
124
- * @type {(() => void) | null}
125
- */
126
- let pendingSnapshotResolve = null;
127
-
128
- /**
129
- * Deduplicates concurrent init calls for the same dbName within one worker.
130
- * @type {Map<string, Promise<void>>}
131
- */
132
- const initPromises = new Map();
133
-
134
- /**
135
- * Identifies this worker on the BroadcastChannel, so a forwarded write's
136
- * acknowledgement can be routed back to the tab that sent it.
137
- */
138
- const TAB_ID = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
139
-
140
- /**
141
- * Forwarded writes this tab has sent but the primary has not acknowledged.
142
- *
143
- * A transient tab's writes live only in its own RAM until the primary applies
144
- * them. If the primary goes away — the tab holding the OPFS lock is closed —
145
- * nothing acknowledges them, and they used to be lost outright. Keeping them
146
- * here lets `promoteToPrimary` replay exactly the writes that never landed,
147
- * without replaying any that did (a replayed `$inc` would double-count).
148
- * @type {Map<number, object>}
149
- */
150
- const unackedWrites = new Map();
151
- let writeSeq = 0;
152
-
153
- /** Bound on `unackedWrites`, so an absent primary cannot grow it without limit. */
154
- const MAX_UNACKED = 1000;
155
-
156
- /**
157
- * Per-collection offsets keeping `writeGeneration` monotonic across the
158
- * database instance swaps a snapshot reload performs.
159
- *
160
- * The generation is an in-memory counter owned by the `Database` instance, so
161
- * reloading a snapshot resets it to zero. Live-query pollers treat "same
162
- * generation as last tick" as "nothing changed and the query can be skipped" —
163
- * and a reload only ever happens *because* another tab wrote. Returning the raw
164
- * counter therefore made a fallback tab's live queries go permanently silent:
165
- * every reload put the generation back to the value the poller had already seen.
166
- * @type {Map<string, number>}
167
- */
168
- const generationBase = new Map();
169
- /** Last raw engine generation observed per collection, for the offset above. */
170
- const generationRaw = new Map();
171
-
172
- /** Fold the outgoing instance's generations into the offsets, before a swap. */
173
- function carryGenerationsForward() {
174
- for (const [collection, raw] of generationRaw) {
175
- // `+ 1` so the next reading differs even when the new instance starts at
176
- // the same raw value the old one ended on.
177
- generationBase.set(collection, (generationBase.get(collection) ?? 0) + raw + 1);
178
- }
179
- generationRaw.clear();
180
- }
181
-
182
- /**
183
- * WASM fetch + compile, started at module scope rather than inside `doInit`.
184
- *
185
- * The worker is spawned and its module evaluated well before the main thread's
186
- * `init` message arrives. Waiting for that message to *begin* downloading ~1 MB
187
- * of WASM left the whole spawn-and-post round trip idle; kicking it off here
188
- * overlaps the two. `doInit` simply awaits this.
189
- *
190
- * A rejection here is not unhandled: `doInit` awaits it and surfaces the error
191
- * to the caller as a failed `init`. The no-op catch keeps the runtime from
192
- * reporting it as unhandled in the window between module eval and that await.
193
- * @type {Promise<typeof import('../pkg/taladb_web')>}
194
- */
195
- const wasmReady = (async () => {
7
+ let wasmReady;
8
+ function loadWasm() { return wasmReady ??= (async () => {
196
9
  const wasm = await import(/* @vite-ignore */ '../pkg/taladb_web.js');
197
10
  await wasm.default();
198
11
  return wasm;
199
- })();
200
- wasmReady.catch(() => {});
201
-
202
- /** The dbName that was successfully initialised (or is being initialised). */
12
+ })(); }
13
+ let db = null;
14
+ let WorkerDB = null;
203
15
  let activeDbName = null;
204
-
205
- /** The configJson passed during init (stored so snapshot reloads can use it). */
206
16
  let activeConfigJson = null;
207
-
208
- const isDev = typeof location !== 'undefined' && (location.hostname === 'localhost' || location.hostname === '127.0.0.1');
209
- const log = isDev ? console.log.bind(console, '[TalaDB Worker]') : () => {};
210
- const warn = isDev ? console.warn.bind(console, '[TalaDB Worker]') : () => {};
211
-
212
- /**
213
- * Origin of the page that spawned this DedicatedWorker. Messages from any
214
- * other origin are silently dropped. Null only in edge environments where
215
- * `location` is unavailable, in which case the check is skipped.
216
- */
217
- const WORKER_ORIGIN = typeof location !== 'undefined' ? location.origin : null;
218
-
219
- /**
220
- * Resolving this releases the Web Lock and closes the sync handle.
221
- * Set inside doInit; called by the 'close' op or when the worker terminates.
222
- * @type {(() => void) | null}
223
- */
224
- let releaseLock = null;
225
-
226
- /**
227
- * BroadcastChannel used to notify sibling tabs of writes.
228
- * Created in doInit; null until the channel name is known.
229
- * @type {BroadcastChannel | null}
230
- */
231
- let broadcastChannel = null;
232
-
233
- /**
234
- * True when running in IDB-fallback mode (OPFS unavailable).
235
- * In this mode every write flushes a snapshot back to IndexedDB.
236
- * @type {boolean}
237
- */
238
- let idbFallback = false;
239
-
240
- /** Whether this worker is allowed to publish the authoritative IDB snapshot. */
241
- let snapshotWriter = false;
242
-
243
- /**
244
- * True when the database is opened with a passphrase (encrypted at rest in the
245
- * OPFS file). Encrypted databases NEVER write a snapshot to IndexedDB — an
246
- * exported snapshot is decrypted plaintext, so persisting it would defeat
247
- * encryption. Encrypted mode therefore requires exclusive OPFS and is
248
- * single-tab: the multi-tab IDB-snapshot fallback is refused, not silently
249
- * downgraded to plaintext.
250
- * @type {boolean}
251
- */
252
17
  let encrypted = false;
18
+ let owner = false;
19
+ let epoch = null;
20
+ let ownerEpoch = null;
21
+ let channel = null;
22
+ let releaseLock = null;
23
+ let syncHandle = null;
24
+ let lockAbort = null;
25
+ let closed = false;
26
+ let backend = null;
27
+ let immediate = true;
28
+ let flushMs = 500;
29
+ let dirty = false;
30
+ let flushTimer = null;
31
+ let storageError = null;
32
+ let queue = Promise.resolve();
33
+ let queued = 0;
34
+ let sequence = 0;
35
+ const clientId = `${Date.now()}-${Math.random()}`;
36
+ const pending = new Map();
37
+ const MAX_PENDING = 128;
38
+ const MAX_REQUEST_BYTES = 32 * 1024 * 1024;
39
+ const MAX_SNAPSHOT_BYTES = 32 * 1024 * 1024;
40
+ const mutations = new Set(['insert', 'insertMany', 'updateOne', 'updateMany', 'deleteOne', 'deleteMany',
41
+ 'createIndex', 'dropIndex', 'createCompoundIndex', 'dropCompoundIndex', 'createFtsIndex', 'dropFtsIndex',
42
+ 'createVectorIndex', 'dropVectorIndex', 'upgradeVectorIndex', 'setUserVersion']);
43
+
44
+ function enqueue(work) {
45
+ if (queued >= MAX_PENDING) return Promise.reject(new Error('TalaDB request queue is full'));
46
+ queued++;
47
+ const result = queue.then(work);
48
+ queue = result.catch(() => {}).finally(() => { queued--; });
49
+ return result;
50
+ }
51
+ function rejectPending(message) {
52
+ for (const p of pending.values()) { clearTimeout(p.timer); p.reject(new Error(message)); }
53
+ pending.clear();
54
+ }
55
+ function announce() {
56
+ channel?.postMessage({ type: 'taladb:owner', epoch, encrypted });
57
+ }
58
+ function remote(op, args) {
59
+ if (!channel || closed) return Promise.reject(new Error('TalaDB storage owner is unavailable'));
60
+ if (pending.size >= MAX_PENDING) return Promise.reject(new Error('TalaDB remote request queue is full'));
61
+ const id = ++sequence;
62
+ return new Promise((resolve, reject) => {
63
+ const timer = setTimeout(() => {
64
+ pending.delete(id);
65
+ reject(new Error('TalaDB owner response timed out; mutation outcome is unknown. Do not blindly retry non-idempotent writes.'));
66
+ }, 30000);
67
+ pending.set(id, { resolve, reject, timer });
68
+ try { channel.postMessage({ type: 'taladb:request', client: clientId, id, epoch: ownerEpoch, op, args }); }
69
+ catch (error) { pending.delete(id); clearTimeout(timer); reject(error); }
70
+ });
71
+ }
72
+ function connectChannel() {
73
+ if (typeof BroadcastChannel === 'undefined') return;
74
+ channel = new BroadcastChannel(`taladb:${activeDbName}`);
75
+ channel.onmessage = ({ data }) => {
76
+ if (!data || typeof data !== 'object') return;
77
+ if (data.type === 'taladb:owner' && !owner) {
78
+ if (ownerEpoch && ownerEpoch !== data.epoch) rejectPending('TalaDB storage owner changed; outstanding mutation outcomes are unknown');
79
+ ownerEpoch = data.epoch;
80
+ return;
81
+ }
82
+ if (data.type === 'taladb:response' && data.client === clientId) {
83
+ const p = pending.get(data.id);
84
+ if (!p) return;
85
+ pending.delete(data.id); clearTimeout(p.timer);
86
+ if (data.error) p.reject(new Error(data.error)); else p.resolve(data.result);
87
+ return;
88
+ }
89
+ if (data.type !== 'taladb:request' || !owner || closed) return;
90
+ const respond = result => channel?.postMessage({ type: 'taladb:response', client: data.client, id: data.id, ...result });
91
+ // Encrypted stores intentionally remain single-tab; never return their data
92
+ // through the unauthenticated same-origin BroadcastChannel.
93
+ if (encrypted) { respond({ error: 'Encrypted TalaDB databases are single-tab' }); return; }
94
+ enqueue(async () => {
95
+ if (!owner || closed || (data.epoch && data.epoch !== epoch)) throw new Error('TalaDB storage owner changed; request was not executed');
96
+ if (['init', 'close'].includes(data.op)) throw new Error('Remote lifecycle operation is not allowed');
97
+ return executeOwned(data.op, data.args ?? {});
98
+ }).then(result => respond({ result }), error => respond({ error: String(error.message ?? error) }));
99
+ };
100
+ }
253
101
 
254
- // ---------------------------------------------------------------------------
255
- // IndexedDB helpers (used only when OPFS is unavailable)
256
- // ---------------------------------------------------------------------------
257
-
258
- const IDB_DB_NAME = 'taladb';
259
- const IDB_STORE = 'snapshots';
260
- const IDB_VERSION = 1;
261
-
262
- /** Open (or upgrade) the "taladb" IDB database and return the IDBDatabase. */
263
102
  function idbOpen() {
264
103
  return new Promise((resolve, reject) => {
265
- const req = self.indexedDB.open(IDB_DB_NAME, IDB_VERSION);
104
+ if (!self.indexedDB) { reject(new Error('IndexedDB is unavailable')); return; }
105
+ const req = self.indexedDB.open('taladb', 1);
106
+ let blocked = false;
266
107
  req.onupgradeneeded = () => {
267
- // Create the object store the very first time.
268
- if (!req.result.objectStoreNames.contains(IDB_STORE)) {
269
- req.result.createObjectStore(IDB_STORE);
270
- }
108
+ if (!req.result.objectStoreNames.contains('snapshots')) req.result.createObjectStore('snapshots');
109
+ };
110
+ req.onsuccess = () => {
111
+ if (blocked) req.result.close();
112
+ else resolve(req.result);
113
+ };
114
+ req.onerror = () => reject(req.error ?? new Error('IndexedDB open failed'));
115
+ req.onblocked = () => {
116
+ blocked = true;
117
+ reject(new Error('IndexedDB open is blocked'));
271
118
  };
272
- req.onsuccess = () => resolve(req.result);
273
- req.onerror = () => reject(req.error);
274
119
  });
275
120
  }
276
-
277
- /**
278
- * Load a snapshot Uint8Array from IndexedDB for `dbName`.
279
- * Returns null if no snapshot is stored yet.
280
- * @param {string} dbName
281
- * @returns {Promise<Uint8Array | null>}
282
- */
283
- async function idbLoadSnapshot(dbName) {
284
- if (!self.indexedDB) return null;
121
+ async function idbLoadSnapshot(name) {
122
+ const idb = await idbOpen();
285
123
  try {
286
- const idb = await idbOpen();
287
- return new Promise((resolve) => {
288
- const tx = idb.transaction(IDB_STORE, 'readonly');
289
- const store = tx.objectStore(IDB_STORE);
290
- const req = store.get(dbName);
291
- req.onsuccess = () => resolve(req.result ?? null);
292
- req.onerror = () => resolve(null);
124
+ return await new Promise((resolve, reject) => {
125
+ const tx = idb.transaction('snapshots', 'readonly');
126
+ const req = tx.objectStore('snapshots').get(name);
127
+ let value = null;
128
+ req.onsuccess = () => { value = req.result ?? null; };
129
+ tx.oncomplete = () => resolve(value);
130
+ tx.onerror = tx.onabort = () => reject(tx.error ?? new Error('IndexedDB snapshot read failed'));
293
131
  });
294
- } catch {
295
- return null;
296
- }
132
+ } finally { idb.close(); }
297
133
  }
298
-
299
- /**
300
- * Persist a snapshot Uint8Array to IndexedDB for `dbName` (fire-and-forget).
301
- * @param {string} dbName
302
- * @param {Uint8Array} bytes
303
- */
304
- async function idbSaveSnapshot(dbName, bytes) {
305
- if (!self.indexedDB) return;
134
+ async function idbSaveSnapshot(name, bytes) {
135
+ const idb = await idbOpen();
306
136
  try {
307
- const idb = await idbOpen();
308
137
  await new Promise((resolve, reject) => {
309
- const tx = idb.transaction(IDB_STORE, 'readwrite');
310
- tx.objectStore(IDB_STORE).put(bytes, dbName);
138
+ const tx = idb.transaction('snapshots', 'readwrite');
139
+ tx.objectStore('snapshots').put(bytes, name);
311
140
  tx.oncomplete = resolve;
312
- tx.onerror = reject;
141
+ tx.onerror = tx.onabort = () => reject(tx.error ?? new Error('IndexedDB snapshot write failed'));
313
142
  });
314
- } catch { /* best-effort persistence — ignore failures */ }
143
+ } finally { idb.close(); }
315
144
  }
316
-
317
- // ---------------------------------------------------------------------------
318
- // Debounced IDB snapshot
319
- // ---------------------------------------------------------------------------
320
-
321
- /**
322
- * Debounce + max-interval parameters for IDB snapshot persistence.
323
- *
324
- * On every write we notify sibling tabs immediately via BroadcastChannel,
325
- * but we defer the actual IDB persistence so that bulk inserts (insertMany,
326
- * rapid sequential inserts) only produce a single IDB write rather than one
327
- * per document. A max-interval cap ensures data is never more than 5 s stale
328
- * in IDB even under continuous write load.
329
- */
330
- /** IDB snapshot debounce, in ms. Overridable via config `durability.flush_ms`
331
- * at init (the OPFS/redb path uses `durability.flush_every_write` instead). */
332
- let snapshotDebounceMs = 500;
333
- const SNAPSHOT_MAX_INTERVAL_MS = 5000;
334
-
335
- /** setTimeout handle for the pending debounced flush. */
336
- let snapshotTimer = null;
337
-
338
- /** Timestamp of the last completed IDB flush (ms since epoch). */
339
- let lastSnapshotMs = 0;
340
-
341
- /** Perform the IDB snapshot write and reset state. */
342
145
  async function flushSnapshot() {
343
- clearTimeout(snapshotTimer);
344
- snapshotTimer = null;
345
- lastSnapshotMs = Date.now();
346
- if (db && activeDbName && snapshotWriter) {
347
- try {
348
- const bytes = db.exportSnapshot();
349
- await idbSaveSnapshot(activeDbName, bytes);
350
- broadcastChannel?.postMessage('taladb:snapshot-ready');
351
- } catch { /* best-effort — ignore failures */ }
352
- }
353
- }
354
-
355
- /**
356
- * Schedule (or immediately trigger) an IDB snapshot write.
357
- *
358
- * - If the last flush was more than SNAPSHOT_MAX_INTERVAL_MS ago, flush now.
359
- * - Otherwise debounce: reset the timer to fire snapshotDebounceMs from now.
360
- */
361
- function scheduleSnapshot() {
362
- const now = Date.now();
363
- if (now - lastSnapshotMs > SNAPSHOT_MAX_INTERVAL_MS) {
364
- // Overdue — flush synchronously in the microtask queue.
365
- flushSnapshot().catch(() => {});
366
- return;
367
- }
368
- clearTimeout(snapshotTimer);
369
- snapshotTimer = setTimeout(() => { flushSnapshot().catch(() => {}); }, snapshotDebounceMs);
370
- }
371
-
372
- /** Apply `durability` config to a freshly-opened WorkerDB instance: OPFS/redb
373
- * durability from `flush_every_write`, and the IDB-fallback debounce from
374
- * `flush_ms`. Returns the instance for chaining. */
375
- function applyDurability(inst) {
376
- let flushEveryWrite = true;
146
+ clearTimeout(flushTimer); flushTimer = null;
147
+ if (!dirty || backend !== 'indexeddb') return;
377
148
  try {
378
- const d = activeConfigJson ? JSON.parse(activeConfigJson)?.durability : null;
379
- if (d) {
380
- if (typeof d.flush_every_write === 'boolean') flushEveryWrite = d.flush_every_write;
381
- if (typeof d.flush_ms === 'number' && d.flush_ms >= 0) snapshotDebounceMs = d.flush_ms;
382
- }
383
- } catch { /* ignore malformed config */ }
384
- inst?.setDurability?.(!flushEveryWrite); // feature-detect (older WASM lacks it)
385
- return inst;
149
+ const bytes = db.exportSnapshot(MAX_SNAPSHOT_BYTES);
150
+ if (bytes.byteLength > MAX_SNAPSHOT_BYTES) throw new Error('TalaDB IndexedDB fallback is limited to 32 MiB; use OPFS for larger databases');
151
+ await idbSaveSnapshot(activeDbName, bytes);
152
+ dirty = false; storageError = null;
153
+ } catch (e) { storageError = String(e.message ?? e); throw e; }
386
154
  }
387
-
388
- /**
389
- * True when this tab writes into an in-memory database that nothing else
390
- * persists — i.e. another tab holds the OPFS lock (`snapshotWriter === false`).
391
- *
392
- * Such a tab has no durable storage of its own: it does not own the OPFS file
393
- * and does not publish the IndexedDB snapshot. Every write it makes must be
394
- * forwarded to the tab that does, or it exists only in RAM and is discarded on
395
- * the next snapshot reload.
396
- */
397
- function isTransientTab() {
398
- return idbFallback && !snapshotWriter;
155
+ function scheduleFlush() {
156
+ // A fixed timer from the first dirty write cannot be postponed indefinitely.
157
+ if (flushTimer !== null) return;
158
+ flushTimer = setTimeout(() => {
159
+ flushTimer = null;
160
+ enqueue(flushSnapshot).catch(e => { storageError = String(e.message ?? e); });
161
+ }, flushMs);
399
162
  }
400
-
401
- /**
402
- * Forward a committed write to the tab holding the OPFS lock.
403
- *
404
- * Inserts travel as whole documents **with their `_id`s** so the primary can
405
- * preserve the ids the application is already holding. They remain inserts,
406
- * not upserts: a caller-supplied id collision is rejected at the authoritative
407
- * database instead of overwriting its document. Filter-based updates and
408
- * deletes travel as the filter and update themselves, and the primary
409
- * re-evaluates them against authoritative data, which is more accurate than
410
- * resolving ids here against a possibly-stale in-memory copy.
411
- *
412
- * Ordering is arrival order at the primary. Unlike the changeset merge this
413
- * replaces, there is no timestamp comparison — two tabs on one device share a
414
- * clock and an origin, so there is no skew for Last-Write-Wins to arbitrate.
415
- */
416
- function forwardWriteToPrimary(op, args, result) {
417
- if (!isTransientTab() || !broadcastChannel || !db) return;
418
- try {
419
- let payload = null;
420
- if (op === 'insert' || op === 'insertMany') {
421
- // Read the documents back so they carry the `_id`s just assigned.
422
- // `insert` answers with a bare ULID, `insertMany` with a JSON array —
423
- // parsing the bare id as JSON threw, and the catch below turned every
424
- // single-document insert in a non-primary tab into a silent data loss.
425
- const ids = op === 'insert' ? [result] : JSON.parse(result);
426
- const docs = ids
427
- .map((id) => JSON.parse(db.findOne(args.collection, JSON.stringify({ _id: id }))))
428
- .filter((d) => d !== null);
429
- if (docs.length === 0) return;
430
- // Preserve create semantics at the authoritative database. An upsert here
431
- // lets two tabs inserting the same caller-supplied id overwrite each other,
432
- // even though Collection::insert promises DuplicateId and never replace.
433
- payload = { kind: 'insert', collection: args.collection, docsJson: JSON.stringify(docs) };
434
- } else {
435
- payload = {
436
- kind: 'replay',
437
- op,
438
- collection: args.collection,
439
- filterJson: args.filterJson,
440
- updateJson: args.updateJson,
441
- };
442
- }
443
- // Held until the primary acknowledges, so `promoteToPrimary` can replay
444
- // whatever never landed. Dropping the oldest keeps a tab that has been
445
- // running without a primary from growing this without bound.
446
- const seq = ++writeSeq;
447
- if (unackedWrites.size >= MAX_UNACKED) {
448
- const oldest = unackedWrites.keys().next().value;
449
- unackedWrites.delete(oldest);
450
- warn('Unacknowledged write buffer is full — the oldest forwarded write was dropped');
163
+ async function executeOwned(op, args) {
164
+ if (!owner || !db) throw new Error('TalaDB storage owner is unavailable');
165
+ if (op === 'hello') return { epoch, encrypted, config: activeConfigJson };
166
+ if (op === 'capabilities') return { storage: backend, durableWrites: immediate,
167
+ maxSnapshotBytes: backend === 'indexeddb' ? MAX_SNAPSHOT_BYTES : null, storageError,
168
+ hnsw: false, owner: true };
169
+ if (op === 'flush') { db.flush(); await flushSnapshot(); return null; }
170
+ if (storageError && immediate) throw new Error(`TalaDB persistence failed; reopen the database: ${storageError}`);
171
+ if (JSON.stringify(args).length > MAX_REQUEST_BYTES) throw new Error('TalaDB request exceeds 32 MiB; split the batch');
172
+ const result = executeOp(op, args);
173
+ if (mutations.has(op)) {
174
+ if (backend === 'indexeddb') {
175
+ dirty = true;
176
+ if (immediate) await flushSnapshot(); else scheduleFlush();
451
177
  }
452
- unackedWrites.set(seq, payload);
453
- broadcastChannel.postMessage({ type: 'taladb:tab-write', origin: TAB_ID, seq, ...payload });
454
- } catch (err) {
455
- // A failed forward must not fail the caller's write — it already committed
456
- // locally. Surface it loudly, because the write is now at risk of loss.
457
- warn('Failed to forward write to the primary tab — it may not be persisted:', err);
178
+ channel?.postMessage('taladb:changed');
458
179
  }
180
+ return result;
459
181
  }
460
-
461
- /**
462
- * Apply one forwarded write payload to the local (authoritative) database.
463
- * Returns the number of documents affected.
464
- */
465
- function applyForwardedWrite(payload) {
466
- if (payload.kind === 'insert') {
467
- const docs = JSON.parse(payload.docsJson);
468
- // A collision is a terminal evaluation, not a transient forwarding error.
469
- // Return zero so the primary acknowledges it and the secondary cannot later
470
- // replay/resurrect its rejected document after the authoritative one is
471
- // deleted. The worker handles BroadcastChannel messages serially, so this
472
- // preflight and the synchronous insert below cannot race another message.
473
- for (const doc of docs) {
474
- if (typeof doc?._id !== 'string') continue;
475
- const existing = db.findOne(
476
- payload.collection,
477
- JSON.stringify({ _id: doc._id }),
478
- );
479
- if (JSON.parse(existing) !== null) {
480
- warn(`Rejected forwarded insert with duplicate _id ${doc._id}`);
481
- return 0;
482
- }
483
- }
484
- return JSON.parse(db.insertMany(payload.collection, payload.docsJson)).length;
485
- }
486
- if (payload.kind !== 'replay') return 0;
487
- switch (payload.op) {
488
- case 'updateOne':
489
- return db.updateOne(payload.collection, payload.filterJson, payload.updateJson) ? 1 : 0;
490
- case 'updateMany':
491
- return db.updateMany(payload.collection, payload.filterJson, payload.updateJson);
492
- case 'deleteOne':
493
- return db.deleteOne(payload.collection, payload.filterJson) ? 1 : 0;
494
- case 'deleteMany':
495
- return db.deleteMany(payload.collection, payload.filterJson);
496
- default:
497
- warn('Unknown forwarded write op:', payload.op);
498
- return 0;
182
+ async function openOwner(passphrase) {
183
+ WorkerDB = (await loadWasm()).WorkerDB;
184
+ const durability = JSON.parse(activeConfigJson ?? '{}').durability ?? {};
185
+ immediate = durability.flush_every_write !== false;
186
+ flushMs = durability.flush_ms ?? 500;
187
+ if (!Number.isFinite(flushMs) || flushMs < 0) throw new Error('durability.flush_ms must be a nonnegative finite number');
188
+ // Fall back only when the API is absent. Permission, quota, and handle
189
+ // errors must not silently select a different (possibly empty) database.
190
+ const root = await navigator.storage?.getDirectory?.();
191
+ const file = root ? await root.getFileHandle(`taladb_${activeDbName}.redb`, { create: true }) : null;
192
+ if (file?.createSyncAccessHandle) syncHandle = await file.createSyncAccessHandle();
193
+ if (syncHandle) {
194
+ try {
195
+ const salt = encrypted ? await loadOrCreateSalt(root, `taladb_${activeDbName}.redb.salt`) : null;
196
+ db = WorkerDB.openWithConfigAndOpfs(syncHandle, activeConfigJson, passphrase, salt);
197
+ backend = 'opfs';
198
+ } catch (e) { syncHandle.close(); syncHandle = null; throw e; }
199
+ } else {
200
+ if (encrypted) throw new Error('TalaDB encryption requires OPFS; the IndexedDB fallback cannot encrypt at rest');
201
+ const bytes = await idbLoadSnapshot(activeDbName);
202
+ if (bytes && bytes.byteLength > MAX_SNAPSHOT_BYTES) throw new Error('TalaDB IndexedDB snapshot exceeds 32 MiB');
203
+ db = activeConfigJson ? WorkerDB.openWithConfigAndSnapshot(bytes, activeConfigJson) : WorkerDB.openWithSnapshot(bytes);
204
+ backend = 'indexeddb';
499
205
  }
206
+ db.setDurability(!immediate);
207
+ owner = true; epoch = `${clientId}-${++sequence}`; ownerEpoch = epoch;
208
+ storageError = null; dirty = false;
209
+ announce();
500
210
  }
501
-
502
- /**
503
- * Notify sibling tabs of a write and schedule IDB persistence.
504
- * Must be called after every mutating op.
505
- */
506
- function onWriteCommitted(op, args, result) {
507
- broadcastChannel?.postMessage('taladb:changed');
508
- if (op) forwardWriteToPrimary(op, args, result);
509
- // Debounced IDB flush — keeps other tabs' fallback instances in sync via
510
- // BroadcastChannel + snapshotDirty reload without writing to IDB on every op.
511
- if (snapshotWriter) scheduleSnapshot();
211
+ function disposeOwner() {
212
+ owner = false;
213
+ // Rust must drop its database while the OPFS handle is still usable.
214
+ db?.free(); db = null;
215
+ syncHandle?.close(); syncHandle = null;
512
216
  }
513
-
514
- // ---------------------------------------------------------------------------
515
- // Dedicated Worker message handler
516
- // ---------------------------------------------------------------------------
517
-
518
- self.onmessage = async (e) => {
519
- // Reject messages from unexpected origins. In a DedicatedWorker only the
520
- // creating page can post messages (browser-enforced), but this guard is a
521
- // defence-in-depth measure in case the worker is ever repurposed.
522
- if (WORKER_ORIGIN && e.origin && e.origin !== WORKER_ORIGIN) {
523
- return;
524
- }
525
- const { id, op, ...args } = e.data;
526
- try {
527
- const result = await dispatch(op, args);
528
- self.postMessage({ id, result: result ?? null });
529
- } catch (err) {
530
- self.postMessage({ id, error: String(err?.message ?? err) });
217
+ async function init(args) {
218
+ if (activeDbName !== null) throw new Error('TalaDB worker is already initialized');
219
+ if (typeof args.dbName !== 'string' || !args.dbName || /[/\\:]/.test(args.dbName)) {
220
+ throw new Error('TalaDB browser database name must be nonempty and cannot contain /, backslash, or :');
531
221
  }
532
- };
533
-
534
- // ---------------------------------------------------------------------------
535
- // Dispatcher
536
- // ---------------------------------------------------------------------------
537
-
222
+ if (!navigator.locks?.request) throw new Error('TalaDB persistent storage requires Web Locks for safe ownership');
223
+ activeDbName = args.dbName; activeConfigJson = args.configJson ?? null;
224
+ encrypted = typeof args.passphrase === 'string' && args.passphrase.length > 0;
225
+ connectChannel();
226
+ let acquired = false;
227
+ await new Promise((resolve, reject) => {
228
+ navigator.locks.request(`taladb:taladb_${activeDbName}.redb`, { ifAvailable: true }, async lock => {
229
+ if (!lock) { resolve(); return; }
230
+ try {
231
+ await openOwner(args.passphrase ?? null);
232
+ acquired = true;
233
+ const held = new Promise(r => { releaseLock = r; });
234
+ resolve(); await held;
235
+ } catch (e) { reject(e); }
236
+ finally { disposeOwner(); }
237
+ }).catch(reject);
238
+ });
239
+ if (acquired) return null;
240
+ if (encrypted) throw new Error('Encrypted TalaDB databases are single-tab');
241
+ if (!channel) throw new Error('TalaDB multi-tab access requires BroadcastChannel');
242
+ const hello = await remote('hello', {});
243
+ ownerEpoch = hello.epoch;
244
+ if (hello.encrypted) throw new Error('Encrypted TalaDB databases are single-tab');
245
+ if (hello.config !== activeConfigJson) throw new Error('TalaDB tabs must use the same database configuration');
246
+ lockAbort = new AbortController();
247
+ // Keep the ownership request pending. No speculative writes are replayed.
248
+ navigator.locks.request(`taladb:taladb_${activeDbName}.redb`, { signal: lockAbort.signal }, async () => {
249
+ rejectPending('TalaDB owner changed; outstanding mutation outcomes are unknown');
250
+ if (closed) return;
251
+ await enqueue(() => openOwner(null));
252
+ const held = new Promise(r => { releaseLock = r; });
253
+ if (closed) releaseLock();
254
+ await held; disposeOwner();
255
+ }).catch(e => { if (!closed) storageError = String(e.message ?? e); });
256
+ return null;
257
+ }
538
258
  async function dispatch(op, args) {
539
- // In IDB-fallback mode, reload the snapshot before each op when the primary
540
- // tab has signalled a write via BroadcastChannel. This keeps reads fresh.
541
- if (idbFallback && snapshotDirty && op !== 'init' && db && activeDbName && WorkerDB) {
542
- snapshotDirty = false;
543
- try {
544
- const fresh = await idbLoadSnapshot(activeDbName);
545
- if (fresh) {
546
- // The new instance's generation counter starts from zero; fold the old
547
- // one's into the offsets so live-query pollers still see a change.
548
- carryGenerationsForward();
549
- db = activeConfigJson
550
- ? WorkerDB.openWithConfigAndSnapshot(fresh, activeConfigJson)
551
- : WorkerDB.openWithSnapshot(fresh);
552
- }
553
- } catch { /* ignore reload errors — stale read is acceptable */ }
554
- }
555
-
556
- if (op === 'init') {
557
- const { dbName, configJson, passphrase } = args;
558
-
559
- if (activeDbName !== null && activeDbName !== dbName) {
560
- throw new Error(
561
- `TalaDB worker already initialised for "${activeDbName}". ` +
562
- `Cannot open "${dbName}" in the same worker instance.`
563
- );
564
- }
565
-
566
- if (!initPromises.has(dbName)) {
567
- activeDbName = dbName;
568
- activeConfigJson = configJson ?? null;
569
- initPromises.set(dbName, doInit(dbName, configJson ?? null, passphrase ?? null));
570
- }
571
- await initPromises.get(dbName);
259
+ if (op === 'init') return init(args);
260
+ if (closed || activeDbName === null) throw new Error('TalaDB database is closed or not initialized');
261
+ if (op === 'isPrimary') return owner;
262
+ if (op === 'close') {
263
+ let failure;
264
+ try { if (owner) { db.flush(); await flushSnapshot(); } }
265
+ catch (e) { failure = e; }
266
+ closed = true; clearTimeout(flushTimer); lockAbort?.abort();
267
+ rejectPending('TalaDB database closed; outstanding mutation outcomes are unknown');
268
+ channel?.close(); channel = null;
269
+ if (releaseLock) { releaseLock(); releaseLock = null; }
270
+ else disposeOwner();
271
+ if (failure) throw failure;
572
272
  return null;
573
273
  }
574
-
575
- if (!db) throw new Error('TalaDB worker not initialised call init first');
576
-
274
+ const result = await (owner ? executeOwned(op, args) : remote(op, args));
275
+ return op === 'capabilities' ? { ...result, owner } : result;
276
+ }
277
+ self.onmessage = ({ data }) => {
278
+ const { id, op, ...args } = data;
279
+ enqueue(() => dispatch(op, args)).then(
280
+ result => self.postMessage({ id, result: result ?? null }),
281
+ error => self.postMessage({ id, error: String(error.message ?? error) }),
282
+ );
283
+ };
284
+ function executeOp(op, args) {
577
285
  switch (op) {
578
286
  case 'insert': {
579
287
  const result = db.insert(args.collection, args.docJson);
580
- onWriteCommitted('insert', args, result);
581
288
  return result;
582
289
  }
583
290
 
584
291
  case 'insertMany': {
585
292
  const result = db.insertMany(args.collection, args.docsJson);
586
- onWriteCommitted('insertMany', args, result);
587
293
  return result;
588
294
  }
589
295
 
@@ -595,25 +301,21 @@ async function dispatch(op, args) {
595
301
 
596
302
  case 'updateOne': {
597
303
  const result = db.updateOne(args.collection, args.filterJson, args.updateJson);
598
- onWriteCommitted('updateOne', args, result);
599
304
  return result;
600
305
  }
601
306
 
602
307
  case 'updateMany': {
603
308
  const result = db.updateMany(args.collection, args.filterJson, args.updateJson);
604
- onWriteCommitted('updateMany', args, result);
605
309
  return result;
606
310
  }
607
311
 
608
312
  case 'deleteOne': {
609
313
  const result = db.deleteOne(args.collection, args.filterJson);
610
- onWriteCommitted('deleteOne', args, result);
611
314
  return result;
612
315
  }
613
316
 
614
317
  case 'deleteMany': {
615
318
  const result = db.deleteMany(args.collection, args.filterJson);
616
- onWriteCommitted('deleteMany', args, result);
617
319
  return result;
618
320
  }
619
321
 
@@ -621,11 +323,7 @@ async function dispatch(op, args) {
621
323
  return db.count(args.collection, args.filterJson ?? 'null');
622
324
 
623
325
  // Cheap change-detection for subscribe(): one integer, no query.
624
- case 'writeGeneration': {
625
- const raw = db.writeGeneration(args.collection);
626
- generationRaw.set(args.collection, raw);
627
- return (generationBase.get(args.collection) ?? 0) + raw;
628
- }
326
+ case 'writeGeneration': return `${epoch}:${db.writeGeneration(args.collection)}`;
629
327
 
630
328
  case 'aggregate':
631
329
  return db.aggregate(args.collection, args.pipelineJson ?? '[]');
@@ -711,61 +409,13 @@ async function dispatch(op, args) {
711
409
  case 'listCollections':
712
410
  return db.listCollections();
713
411
 
714
- case 'compact':
715
- // Compact the storage file, reclaiming freed space. No-op on IDB fallback.
716
- db.compact();
717
- return null;
718
-
719
- case 'flush':
720
- // "Save now": force batched OPFS writes durable (no-op under immediate
721
- // durability) and write the IDB fallback snapshot immediately.
722
- db.flush?.();
723
- await flushSnapshot();
724
- return null;
725
-
726
- case 'isPrimary':
727
- // Do this tab's writes land authoritatively, or are they forwarded?
728
- // Both the OPFS lock holder and the IDB-fallback snapshot writer own
729
- // their storage; only a transient tab has to forward.
730
- return !isTransientTab();
731
-
732
- case 'userVersion':
733
- // Current application migration version (backs openDB({ migrations })).
734
- return db.userVersion();
735
-
736
- case 'setUserVersion':
737
- db.setUserVersion(args.version);
738
- return null;
739
-
740
- case 'close':
741
- // Flush any pending debounced snapshot before releasing the lock so
742
- // no writes are lost when the tab closes or navigates away.
743
- await flushSnapshot();
744
- // Release the Web Lock and close the sync handle gracefully.
745
- if (releaseLock) { releaseLock(); releaseLock = null; }
746
- broadcastChannel?.close();
747
- broadcastChannel = null;
748
- // Cleared before `db`: a promotion waiting on the lock reads these to
749
- // decide whether this worker still wants the file.
750
- idbFallback = false;
751
- snapshotWriter = false;
752
- unackedWrites.clear();
753
- generationBase.clear();
754
- generationRaw.clear();
755
- db = null;
756
- return null;
757
-
758
- default:
759
- throw new Error(`Unknown op: ${op}`);
412
+ case 'compact': db.compact(); return null;
413
+ case 'userVersion': return db.userVersion();
414
+ case 'setUserVersion': db.setUserVersion(args.version); return null;
415
+ default: throw new Error(`Unknown TalaDB operation: ${op}`);
760
416
  }
761
417
  }
762
418
 
763
- /**
764
- * Load the 16-byte key-derivation salt from an OPFS sidecar file, or create it
765
- * on first open. The salt is not secret (it defends against precomputed-hash
766
- * attacks) but must be stable across opens, so it lives beside the DB file.
767
- * @returns {Promise<Uint8Array>} the 16-byte salt
768
- */
769
419
  async function loadOrCreateSalt(root, saltFileName) {
770
420
  const fh = await root.getFileHandle(saltFileName, { create: true });
771
421
  const h = await fh.createSyncAccessHandle();
@@ -773,13 +423,13 @@ async function loadOrCreateSalt(root, saltFileName) {
773
423
  const size = h.getSize();
774
424
  if (size === 16) {
775
425
  const salt = new Uint8Array(16);
776
- h.read(salt, { at: 0 });
426
+ if (h.read(salt, { at: 0 }) !== 16) throw new Error('Incomplete TalaDB salt read');
777
427
  return salt;
778
428
  }
779
429
  if (size === 0) {
780
430
  const salt = new Uint8Array(16);
781
431
  self.crypto.getRandomValues(salt);
782
- h.write(salt, { at: 0 });
432
+ if (h.write(salt, { at: 0 }) !== 16) throw new Error('Incomplete TalaDB salt write');
783
433
  h.flush();
784
434
  return salt;
785
435
  }
@@ -788,380 +438,3 @@ async function loadOrCreateSalt(root, saltFileName) {
788
438
  h.close();
789
439
  }
790
440
  }
791
-
792
- // ---------------------------------------------------------------------------
793
- // Initialisation — load WASM, acquire lock, open OPFS file
794
- // ---------------------------------------------------------------------------
795
-
796
- /**
797
- * Wait for the OPFS lock this tab lost at open, and take over when it frees.
798
- *
799
- * ## Why a tab has to be able to take over
800
- *
801
- * A tab that loses the lock runs an in-memory copy and forwards every write to
802
- * the tab that owns the file. That arrangement has one failure mode, and it is
803
- * total: when the owning tab goes away, the survivors keep accepting writes
804
- * that nobody persists. Closing the last "real" tab silently turned the others
805
- * into scratch memory.
806
- *
807
- * ## What happens to writes made while there was no primary
808
- *
809
- * The OPFS file is authoritative on promotion — this tab's in-memory copy is
810
- * discarded rather than merged, because merging two divergent document sets has
811
- * no correct answer here. What is *not* discarded is `unackedWrites`: writes
812
- * the primary never acknowledged, which are therefore absent from the file.
813
- * Those are replayed, in order, against the freshly-opened OPFS database. A
814
- * write the old primary *did* apply was acknowledged and is not in the map, so
815
- * nothing is applied twice — which matters for `$inc`, where a double apply is
816
- * silently wrong rather than merely redundant.
817
- */
818
- async function waitForPromotion(lockName, fileHandle, openWithOpfs) {
819
- try {
820
- await navigator.locks.request(lockName, async () => {
821
- // A close() between losing the race and winning the lock means this
822
- // worker is done — take the lock only to release it again.
823
- if (!idbFallback || !db || !activeDbName) return;
824
-
825
- let syncHandle;
826
- try {
827
- syncHandle = await fileHandle.createSyncAccessHandle();
828
- } catch (e) {
829
- warn('Promoted to OPFS owner but the sync handle could not be opened:', e);
830
- return;
831
- }
832
-
833
- const pending = [...unackedWrites.entries()].sort((a, b) => a[0] - b[0]);
834
- try {
835
- // The instance swap resets the engine's generation counter.
836
- carryGenerationsForward();
837
- db = openWithOpfs(syncHandle);
838
- idbFallback = false;
839
- snapshotWriter = true;
840
- snapshotDirty = false;
841
- unackedWrites.clear();
842
- let replayed = 0;
843
- for (const [, payload] of pending) {
844
- try {
845
- replayed += applyForwardedWrite(payload);
846
- } catch (err) {
847
- warn('Failed to replay a write during promotion:', err);
848
- }
849
- }
850
- log(
851
- `Promoted to OPFS owner${replayed ? ` (replayed ${replayed} unacknowledged write(s))` : ''}`,
852
- );
853
- // Tell the other tabs to re-read: this tab's data is now the file's.
854
- onWriteCommitted();
855
- } catch (e) {
856
- try { syncHandle.close(); } catch { /* best-effort */ }
857
- warn('Promotion to OPFS owner failed — staying in fallback mode:', e);
858
- return;
859
- }
860
-
861
- // Hold the lock for as long as this worker owns the file.
862
- await new Promise((res) => { releaseLock = res; });
863
- try { syncHandle.close(); } catch { /* best-effort */ }
864
- db = null;
865
- });
866
- } catch (e) {
867
- warn('Waiting for the OPFS lock failed:', e);
868
- }
869
- }
870
-
871
- async function doInit(dbName, configJson, passphrase = null) {
872
- encrypted = typeof passphrase === 'string' && passphrase.length > 0;
873
-
874
- const opfsAvailable = canUseOpfs();
875
-
876
- // Compiling ~1 MB of WASM and setting up OPFS are independent, and OPFS setup
877
- // is four awaited round trips (getDirectory → getFileHandle → lock →
878
- // createSyncAccessHandle). Starting both now lets the storage latency hide
879
- // behind the compile instead of following it.
880
- //
881
- // `wasmReady` was already kicked off at module scope — see its declaration.
882
- const opfsSetup = opfsAvailable
883
- ? (async () => {
884
- const root = await navigator.storage.getDirectory();
885
- const fileName = `taladb_${dbName.replaceAll(/[/\\:]/g, '_')}.redb`;
886
- return { root, fileName, fileHandle: await root.getFileHandle(fileName, { create: true }) };
887
- })().catch(() => null)
888
- : Promise.resolve(null);
889
-
890
- const wasm = await wasmReady;
891
-
892
- // Hoist to module scope so snapshot reloads in dispatch() can use it.
893
- WorkerDB = wasm.WorkerDB;
894
-
895
- // Open the BroadcastChannel now that we know the db name.
896
- if (typeof BroadcastChannel !== 'undefined') {
897
- broadcastChannel = new BroadcastChannel(`taladb:${dbName}`);
898
- broadcastChannel.onmessage = async (e) => {
899
- if (e.data === 'taladb:changed' && idbFallback) {
900
- // Wait for snapshot-ready: changed is emitted before the primary's
901
- // asynchronous IDB transaction has committed.
902
- } else if (e.data === 'taladb:request-snapshot' && !idbFallback && !encrypted && db && activeDbName) {
903
- // Primary (OPFS) tab: a new tab asked for a snapshot — export and save it.
904
- // Never for encrypted DBs: an exported snapshot is decrypted plaintext.
905
- try {
906
- const bytes = db.exportSnapshot();
907
- await idbSaveSnapshot(activeDbName, bytes);
908
- broadcastChannel.postMessage('taladb:snapshot-ready');
909
- log('Exported snapshot for waiting tab');
910
- } catch { /* ignore */ }
911
- } else if (e.data === 'taladb:snapshot-ready' && pendingSnapshotResolve) {
912
- // Fallback tab: primary tab finished saving — wake up the waiting doInit.
913
- const resolve = pendingSnapshotResolve;
914
- pendingSnapshotResolve = null;
915
- resolve();
916
- } else if (e.data === 'taladb:snapshot-ready' && idbFallback) {
917
- snapshotDirty = true;
918
- } else if (e.data?.type === 'taladb:tab-write-ack' && e.data.origin === TAB_ID) {
919
- // The primary applied one of our forwarded writes: it is now somebody
920
- // else's durable responsibility, so stop holding it for replay.
921
- unackedWrites.delete(e.data.seq);
922
- } else if (e.data?.type === 'taladb:tab-write' && db && snapshotWriter && !encrypted) {
923
- // Apply a write forwarded by a tab that has no durable storage of its
924
- // own. Guarded on `snapshotWriter` so only the tab that actually owns
925
- // persistence applies it — otherwise several fallback tabs would each
926
- // apply the same write to their private in-memory copies.
927
- //
928
- // `!encrypted`: encrypted databases are single-tab by construction, so
929
- // there is no legitimate forwarded write. Accepting one would let any
930
- // same-origin script inject documents without knowing the passphrase.
931
- try {
932
- const n = applyForwardedWrite(e.data);
933
- // Acknowledged whatever the count — the write has been evaluated
934
- // against authoritative data, and a filter that matched nothing here
935
- // will not match anything on a replay either. Sent before the
936
- // notification below so the originating tab can release it promptly.
937
- if (e.data.origin !== undefined) {
938
- broadcastChannel.postMessage({
939
- type: 'taladb:tab-write-ack',
940
- origin: e.data.origin,
941
- seq: e.data.seq,
942
- });
943
- }
944
- if (n > 0) {
945
- log(`Applied ${n} forwarded write(s) from another tab`);
946
- // No op/args: this write must not be forwarded onward. Notifying
947
- // and snapshotting is the whole point — it is what lets the
948
- // originating tab read its own write back.
949
- onWriteCommitted();
950
- }
951
- } catch (err) {
952
- warn('Failed to apply a forwarded write from another tab:', err);
953
- }
954
- }
955
- };
956
- log('BroadcastChannel opened:', `taladb:${dbName}`);
957
- }
958
-
959
- // Helpers to open DB with or without a config. The config-aware
960
- // constructors carry durability and encryption settings.
961
- function openWithSnapshot(snapshot) {
962
- const inst = configJson
963
- ? WorkerDB.openWithConfigAndSnapshot(snapshot, configJson)
964
- : WorkerDB.openWithSnapshot(snapshot);
965
- return applyDurability(inst);
966
- }
967
- // Salt for key derivation, loaded/created in an OPFS sidecar (encrypted mode
968
- // only). Passed to the WASM open so the derived key is stable across opens.
969
- let salt = null;
970
- function openWithOpfs(syncHandle) {
971
- // openWithConfigAndOpfs(handle, configJson, passphrase?, salt?)
972
- return applyDurability(
973
- WorkerDB.openWithConfigAndOpfs(syncHandle, configJson ?? null, passphrase, salt),
974
- );
975
- }
976
-
977
- /**
978
- * Open an in-memory database seeded from the last IndexedDB snapshot, and
979
- * keep flushing snapshots back. The degradation path whenever OPFS is
980
- * unusable. Never valid for encrypted databases — the snapshot is plaintext.
981
- */
982
- async function openIdbFallback(name) {
983
- const snapshot = await idbLoadSnapshot(name);
984
- db = openWithSnapshot(snapshot);
985
- idbFallback = true;
986
- snapshotWriter = true;
987
- if (snapshot) {
988
- log(`Restored from IndexedDB snapshot (${snapshot.byteLength} bytes)`);
989
- } else {
990
- log('New in-memory database — writes will be persisted to IndexedDB');
991
- }
992
- }
993
-
994
- // `opfsSetup` resolves to null when OPFS is unusable — either the capability
995
- // check said so up front, or getDirectory/getFileHandle actually failed.
996
- const opfs = await opfsSetup;
997
- if (!opfs) {
998
- if (encrypted) {
999
- throw new Error(
1000
- 'TalaDB encryption requires OPFS, which is unavailable in this browser context. ' +
1001
- 'Refusing to open — the in-memory/IndexedDB fallback cannot encrypt at rest.'
1002
- );
1003
- }
1004
- warn('OPFS unavailable — falling back to IndexedDB-backed in-memory');
1005
- await openIdbFallback(dbName);
1006
- return;
1007
- }
1008
-
1009
- const { root, fileName, fileHandle } = opfs;
1010
-
1011
- // Encrypted mode: the 16-byte key-derivation salt lives in an OPFS sidecar
1012
- // file next to the DB. It is loaded/created only AFTER this tab has won the
1013
- // exclusive lock (or determined no locking applies), so two tabs racing to
1014
- // open never collide on the salt file's exclusive access handle.
1015
-
1016
- if (!('locks' in navigator)) {
1017
- // Web Locks not available — open directly (single-tab safe only).
1018
- warn('Web Locks unavailable — multi-tab write safety disabled');
1019
- if (encrypted) salt = await loadOrCreateSalt(root, `${fileName}.salt`);
1020
- let syncHandle;
1021
- try {
1022
- syncHandle = await fileHandle.createSyncAccessHandle();
1023
- } catch (e) {
1024
- // `createSyncAccessHandle` can exist on the prototype and still throw —
1025
- // Firefox without storage access, a cross-origin iframe, a revoked
1026
- // permission. The removed probe file used to surface that as "OPFS
1027
- // unavailable"; catching it here keeps the same graceful degradation
1028
- // rather than failing the open outright.
1029
- if (encrypted) throw e; // the IDB fallback cannot encrypt at rest
1030
- warn('OPFS sync access unavailable — falling back to IndexedDB:', e);
1031
- await openIdbFallback(dbName);
1032
- return;
1033
- }
1034
- try {
1035
- db = openWithOpfs(syncHandle);
1036
- } catch (e) {
1037
- // A failed open (e.g. wrong passphrase) must release the exclusive OPFS
1038
- // access handle, or every retry fails with "Access Handles cannot be
1039
- // created" until the page reloads.
1040
- try { syncHandle.close(); } catch { /* best-effort */ }
1041
- throw e;
1042
- }
1043
- snapshotWriter = !encrypted; // encrypted DBs never write a plaintext IDB snapshot
1044
- log(`Opened "${fileName}" via OPFS`);
1045
- return;
1046
- }
1047
-
1048
- // Try to acquire the exclusive OPFS lock immediately (ifAvailable).
1049
- // If another tab already holds it, fall back to IDB snapshot right away so
1050
- // this tab is immediately usable instead of blocking until the other tab closes.
1051
- // The primary (OPFS) tab flushes a snapshot to IDB after every write, and
1052
- // this tab's BroadcastChannel listener sets snapshotDirty so dispatch()
1053
- // reloads the snapshot before the next read — keeping data fresh across tabs.
1054
- const lockName = `taladb:${fileName}`;
1055
- await new Promise((resolve, reject) => {
1056
- navigator.locks.request(lockName, { ifAvailable: true }, async (lock) => {
1057
- if (lock === null) {
1058
- // Lock is held by another tab.
1059
- if (encrypted) {
1060
- // The IDB-snapshot fallback stores decrypted plaintext, so it's
1061
- // refused for encrypted databases — they're single-tab. Reject
1062
- // rather than downgrade.
1063
- reject(new Error(
1064
- 'This encrypted TalaDB database is already open in another tab. ' +
1065
- 'Encrypted browser databases are single-tab (the multi-tab fallback would store plaintext).'
1066
- ));
1067
- return;
1068
- }
1069
- // Unencrypted: use IDB snapshot so this tab loads immediately.
1070
- warn('OPFS lock held by another tab — falling back to IndexedDB snapshot (live-sync via BroadcastChannel)');
1071
- let snapshot = await idbLoadSnapshot(dbName);
1072
-
1073
- if (!snapshot && broadcastChannel) {
1074
- // No IDB snapshot yet — ask the primary tab to export one now.
1075
- log('No IDB snapshot — requesting one from the primary tab...');
1076
- const gotIt = await new Promise(res => {
1077
- pendingSnapshotResolve = res;
1078
- setTimeout(() => { pendingSnapshotResolve = null; res(false); }, 2000);
1079
- broadcastChannel.postMessage('taladb:request-snapshot');
1080
- });
1081
- if (gotIt !== false) snapshot = await idbLoadSnapshot(dbName);
1082
- }
1083
-
1084
- db = openWithSnapshot(snapshot ?? null);
1085
- idbFallback = true;
1086
- snapshotWriter = false;
1087
- if (snapshot) {
1088
- log(`Restored from IDB snapshot (${snapshot.byteLength} bytes)`);
1089
- } else {
1090
- log('No IDB snapshot yet — starting with empty in-memory database');
1091
- }
1092
- // Queue for the lock in the background. The holder releases it when its
1093
- // tab closes or calls db.close(), and this tab is then promoted to the
1094
- // OPFS file. Without this a tab that lost the race stayed transient for
1095
- // its whole life: once the primary went away, every write it made had
1096
- // nowhere to be persisted and was silently discarded.
1097
- void waitForPromotion(lockName, fileHandle, openWithOpfs);
1098
- resolve();
1099
- return; // Do not hold the lock; returning releases it back to the queue.
1100
- }
1101
-
1102
- // Acquired the lock — use OPFS.
1103
- let syncHandle = null;
1104
- try {
1105
- if (encrypted) salt = await loadOrCreateSalt(root, `${fileName}.salt`);
1106
- try {
1107
- syncHandle = await fileHandle.createSyncAccessHandle();
1108
- } catch (e) {
1109
- // See the no-locks branch: the method can exist and still throw, and
1110
- // that used to be caught by the probe file. Degrade to IndexedDB
1111
- // instead of failing the open.
1112
- if (encrypted) throw e; // the IDB fallback cannot encrypt at rest
1113
- warn('OPFS sync access unavailable — falling back to IndexedDB:', e);
1114
- await openIdbFallback(dbName);
1115
- resolve();
1116
- return; // releases the Web Lock — this tab is not the OPFS holder
1117
- }
1118
- db = openWithOpfs(syncHandle);
1119
- snapshotWriter = !encrypted; // encrypted DBs never write a plaintext IDB snapshot
1120
- log(`Opened "${fileName}" via OPFS (Web Locks)`);
1121
- resolve(); // signal doInit complete — caller can proceed
1122
-
1123
- // Hold the lock by keeping this async callback alive.
1124
- // Resolved by the 'close' op or when the worker is terminated.
1125
- await new Promise(res => { releaseLock = res; });
1126
-
1127
- syncHandle.close();
1128
- db = null;
1129
- } catch (e) {
1130
- // A failed open (e.g. wrong passphrase) must release the exclusive
1131
- // OPFS access handle, or every retry fails with "Access Handles
1132
- // cannot be created" until the page reloads. Returning from this
1133
- // callback also releases the Web Lock.
1134
- if (syncHandle) {
1135
- try { syncHandle.close(); } catch { /* best-effort */ }
1136
- }
1137
- reject(e);
1138
- }
1139
- });
1140
- });
1141
- }
1142
-
1143
- // ---------------------------------------------------------------------------
1144
- // OPFS capability probe
1145
- // ---------------------------------------------------------------------------
1146
-
1147
- /**
1148
- * Whether this context can do synchronous OPFS I/O.
1149
- *
1150
- * This used to create a uniquely-named probe file, open a sync access handle on
1151
- * it, close it, and delete it — four sequential OPFS round trips on every cold
1152
- * start, immediately before the real open performed the identical sequence on
1153
- * the actual database file and would have failed the same way. The capability
1154
- * check below is synchronous and free; anything the probe would have caught
1155
- * (permissions, cross-origin isolation, a non-Worker scope) surfaces from the
1156
- * real open, which `doInit` already handles by falling back to IndexedDB.
1157
- *
1158
- * @returns {boolean}
1159
- */
1160
- function canUseOpfs() {
1161
- return (
1162
- typeof navigator !== 'undefined' &&
1163
- typeof navigator.storage?.getDirectory === 'function' &&
1164
- typeof FileSystemFileHandle !== 'undefined' &&
1165
- typeof FileSystemFileHandle.prototype?.createSyncAccessHandle === 'function'
1166
- );
1167
- }