@taladb/web 0.11.2 → 0.11.4
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.
- package/README.md +2 -2
- package/package.json +1 -1
- package/pkg/README.md +2 -2
- package/pkg/package.json +1 -1
- package/pkg/taladb_web.d.ts +16 -6
- package/pkg/taladb_web.js +72 -7
- package/pkg/taladb_web_bg.wasm +0 -0
- package/pkg/taladb_web_bg.wasm.d.ts +3 -1
- package/worker/taladb.worker.js +257 -981
package/worker/taladb.worker.js
CHANGED
|
@@ -1,589 +1,295 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
}
|
|
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(
|
|
310
|
-
tx.objectStore(
|
|
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
|
-
}
|
|
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(
|
|
344
|
-
|
|
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
|
|
379
|
-
if (
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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: true, 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) || (op === 'vectorCommand' && ['create', 'beginBuild', 'stepBuild', 'cancelBuild'].includes(JSON.parse(args.requestJson).op))) {
|
|
174
|
+
if (backend === 'indexeddb') {
|
|
175
|
+
dirty = true;
|
|
176
|
+
if (immediate) await flushSnapshot(); else scheduleFlush();
|
|
451
177
|
}
|
|
452
|
-
|
|
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
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
504
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
540
|
-
|
|
541
|
-
if (
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
-
|
|
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 ?? '[]');
|
|
@@ -677,6 +375,9 @@ async function dispatch(op, args) {
|
|
|
677
375
|
db.upgradeVectorIndex(args.collection, args.field);
|
|
678
376
|
return null;
|
|
679
377
|
|
|
378
|
+
case 'vectorCommand':
|
|
379
|
+
return db.vectorCommand(args.collection, args.requestJson);
|
|
380
|
+
|
|
680
381
|
case 'findNearest':
|
|
681
382
|
return db.findNearest(
|
|
682
383
|
args.collection,
|
|
@@ -711,61 +412,13 @@ async function dispatch(op, args) {
|
|
|
711
412
|
case 'listCollections':
|
|
712
413
|
return db.listCollections();
|
|
713
414
|
|
|
714
|
-
case 'compact':
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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}`);
|
|
415
|
+
case 'compact': db.compact(); return null;
|
|
416
|
+
case 'userVersion': return db.userVersion();
|
|
417
|
+
case 'setUserVersion': db.setUserVersion(args.version); return null;
|
|
418
|
+
default: throw new Error(`Unknown TalaDB operation: ${op}`);
|
|
760
419
|
}
|
|
761
420
|
}
|
|
762
421
|
|
|
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
422
|
async function loadOrCreateSalt(root, saltFileName) {
|
|
770
423
|
const fh = await root.getFileHandle(saltFileName, { create: true });
|
|
771
424
|
const h = await fh.createSyncAccessHandle();
|
|
@@ -773,13 +426,13 @@ async function loadOrCreateSalt(root, saltFileName) {
|
|
|
773
426
|
const size = h.getSize();
|
|
774
427
|
if (size === 16) {
|
|
775
428
|
const salt = new Uint8Array(16);
|
|
776
|
-
h.read(salt, { at: 0 });
|
|
429
|
+
if (h.read(salt, { at: 0 }) !== 16) throw new Error('Incomplete TalaDB salt read');
|
|
777
430
|
return salt;
|
|
778
431
|
}
|
|
779
432
|
if (size === 0) {
|
|
780
433
|
const salt = new Uint8Array(16);
|
|
781
434
|
self.crypto.getRandomValues(salt);
|
|
782
|
-
h.write(salt, { at: 0 });
|
|
435
|
+
if (h.write(salt, { at: 0 }) !== 16) throw new Error('Incomplete TalaDB salt write');
|
|
783
436
|
h.flush();
|
|
784
437
|
return salt;
|
|
785
438
|
}
|
|
@@ -788,380 +441,3 @@ async function loadOrCreateSalt(root, saltFileName) {
|
|
|
788
441
|
h.close();
|
|
789
442
|
}
|
|
790
443
|
}
|
|
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
|
-
}
|