@taladb/web 0.2.11 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taladb/web",
3
- "version": "0.2.11",
3
+ "version": "0.3.0",
4
4
  "description": "TalaDB WASM bindings — document queries and vector search in the browser",
5
5
  "main": "pkg/taladb_web.js",
6
6
  "types": "pkg/taladb_web.d.ts",
package/pkg/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "thinkgrid-labs"
6
6
  ],
7
7
  "description": "TalaDB browser WASM bindings (wasm-bindgen + OPFS)",
8
- "version": "0.2.11",
8
+ "version": "0.3.0",
9
9
  "license": "MIT",
10
10
  "files": [
11
11
  "taladb_web_bg.wasm",
Binary file
@@ -1,15 +1,25 @@
1
1
  /**
2
- * TalaDB SharedWorker
2
+ * TalaDB Worker
3
3
  *
4
- * Owns the OPFS file handle and the WASM + redb database instance.
5
- * The main thread connects via SharedWorker and communicates through
6
- * a typed message protocol.
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.
7
8
  *
8
- * Why SharedWorker (not DedicatedWorker)?
9
+ * Why DedicatedWorker (not SharedWorker)?
9
10
  * ----------------------------------------
10
- * A SharedWorker persists as long as any tab/page from the same origin
11
- * has it open. This means multiple tabs share the same database instance
12
- * no write conflicts, no duplicate open files.
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).
13
23
  *
14
24
  * Message protocol
15
25
  * ----------------
@@ -17,9 +27,6 @@
17
27
  * Response → { id: number, result: unknown }
18
28
  * | { id: number, error: string }
19
29
  *
20
- * The `id` field lets the main thread match async responses to pending
21
- * Promise resolvers even when operations complete out of order.
22
- *
23
30
  * Supported ops
24
31
  * -------------
25
32
  * init { dbName }
@@ -50,10 +57,7 @@
50
57
  let db = null;
51
58
 
52
59
  /**
53
- * Maps dbName Promise<void> so that:
54
- * - Concurrent init calls for the same dbName share one promise (deduplicated).
55
- * - Init calls for different dbNames are rejected after the first succeeds,
56
- * because a SharedWorker instance owns exactly one database file.
60
+ * Deduplicates concurrent init calls for the same dbName within one worker.
57
61
  * @type {Map<string, Promise<void>>}
58
62
  */
59
63
  const initPromises = new Map();
@@ -61,24 +65,29 @@ const initPromises = new Map();
61
65
  /** The dbName that was successfully initialised (or is being initialised). */
62
66
  let activeDbName = null;
63
67
 
64
- // ---------------------------------------------------------------------------
65
- // SharedWorker connect handler
66
- // ---------------------------------------------------------------------------
68
+ const isDev = typeof location !== 'undefined' && (location.hostname === 'localhost' || location.hostname === '127.0.0.1');
69
+ const log = isDev ? console.log.bind(console, '[TalaDB Worker]') : () => {};
70
+ const warn = isDev ? console.warn.bind(console, '[TalaDB Worker]') : () => {};
67
71
 
68
- self.onconnect = (connectEvent) => {
69
- const port = connectEvent.ports[0];
72
+ /**
73
+ * Resolving this releases the Web Lock and closes the sync handle.
74
+ * Set inside doInit; called by the 'close' op or when the worker terminates.
75
+ * @type {(() => void) | null}
76
+ */
77
+ let releaseLock = null;
70
78
 
71
- port.onmessage = async (e) => {
72
- const { id, op, ...args } = e.data;
73
- try {
74
- const result = await dispatch(op, args);
75
- port.postMessage({ id, result: result ?? null });
76
- } catch (err) {
77
- port.postMessage({ id, error: String(err?.message ?? err) });
78
- }
79
- };
79
+ // ---------------------------------------------------------------------------
80
+ // Dedicated Worker message handler
81
+ // ---------------------------------------------------------------------------
80
82
 
81
- port.start();
83
+ self.onmessage = async (e) => {
84
+ const { id, op, ...args } = e.data;
85
+ try {
86
+ const result = await dispatch(op, args);
87
+ self.postMessage({ id, result: result ?? null });
88
+ } catch (err) {
89
+ self.postMessage({ id, error: String(err?.message ?? err) });
90
+ }
82
91
  };
83
92
 
84
93
  // ---------------------------------------------------------------------------
@@ -89,16 +98,13 @@ async function dispatch(op, args) {
89
98
  if (op === 'init') {
90
99
  const { dbName } = args;
91
100
 
92
- // Reject if a different database was already opened in this worker instance.
93
- // A SharedWorker owns exactly one OPFS file handle.
94
101
  if (activeDbName !== null && activeDbName !== dbName) {
95
102
  throw new Error(
96
103
  `TalaDB worker already initialised for "${activeDbName}". ` +
97
- `Cannot open "${dbName}" in the same SharedWorker instance.`
104
+ `Cannot open "${dbName}" in the same worker instance.`
98
105
  );
99
106
  }
100
107
 
101
- // Deduplicate concurrent init calls for the same dbName.
102
108
  if (!initPromises.has(dbName)) {
103
109
  activeDbName = dbName;
104
110
  initPromises.set(dbName, doInit(dbName));
@@ -171,6 +177,8 @@ async function dispatch(op, args) {
171
177
  );
172
178
 
173
179
  case 'close':
180
+ // Release the Web Lock and close the sync handle gracefully.
181
+ if (releaseLock) { releaseLock(); releaseLock = null; }
174
182
  db = null;
175
183
  return null;
176
184
 
@@ -180,41 +188,77 @@ async function dispatch(op, args) {
180
188
  }
181
189
 
182
190
  // ---------------------------------------------------------------------------
183
- // Initialisation — load WASM, open OPFS file, create WorkerDB
191
+ // Initialisation — load WASM, acquire lock, open OPFS file
184
192
  // ---------------------------------------------------------------------------
185
193
 
186
194
  async function doInit(dbName) {
187
- // Dynamic import of the WASM module (wasm-pack --target web output)
188
- // The bundler (Vite/Webpack) will resolve this path correctly.
189
195
  const wasm = await import('../pkg/taladb_web.js');
190
- await wasm.default(); // run wasm-bindgen init (sets up memory, panic hook, etc.)
196
+ await wasm.default();
191
197
 
192
198
  const { WorkerDB } = wasm;
193
199
 
194
200
  const opfsAvailable = await checkOpfs();
195
201
  if (!opfsAvailable) {
196
- console.warn('[TalaDB Worker] OPFS unavailable — falling back to in-memory');
202
+ warn('OPFS unavailable — falling back to in-memory');
197
203
  db = WorkerDB.openInMemory();
198
204
  return;
199
205
  }
200
206
 
201
- // Get the OPFS root directory
202
207
  const root = await navigator.storage.getDirectory();
203
-
204
- // Open (or create) the database file
205
208
  const fileName = `taladb_${dbName.replaceAll(/[/\\:]/g, '_')}.redb`;
206
209
  const fileHandle = await root.getFileHandle(fileName, { create: true });
207
210
 
208
- // createSyncAccessHandle — available in workers only
209
- const syncHandle = await fileHandle.createSyncAccessHandle();
211
+ if (!('locks' in navigator)) {
212
+ // Web Locks not available — open directly (single-tab safe only).
213
+ warn('Web Locks unavailable — multi-tab write safety disabled');
214
+ const syncHandle = await fileHandle.createSyncAccessHandle();
215
+ db = WorkerDB.openWithOpfs(syncHandle);
216
+ log(`Opened "${fileName}" via OPFS`);
217
+ return;
218
+ }
210
219
 
211
- db = WorkerDB.openWithOpfs(syncHandle);
212
- console.log(`[TalaDB Worker] Opened "${fileName}" via OPFS`);
220
+ // Acquire an exclusive lock on the database file.
221
+ // If another tab's worker already holds it, this call blocks until that
222
+ // worker calls close() or the tab is terminated (lock auto-released).
223
+ const lockName = `taladb:${fileName}`;
224
+ await new Promise((resolve, reject) => {
225
+ navigator.locks.request(lockName, async () => {
226
+ try {
227
+ const syncHandle = await fileHandle.createSyncAccessHandle();
228
+ db = WorkerDB.openWithOpfs(syncHandle);
229
+ log(`Opened "${fileName}" via OPFS (Web Locks)`);
230
+ resolve(); // signal doInit complete — caller can proceed
231
+
232
+ // Hold the lock by keeping this async callback alive.
233
+ // Resolved by the 'close' op or when the worker is terminated.
234
+ await new Promise(res => { releaseLock = res; });
235
+
236
+ syncHandle.close();
237
+ db = null;
238
+ } catch (e) {
239
+ reject(e);
240
+ }
241
+ });
242
+ });
213
243
  }
214
244
 
245
+ // ---------------------------------------------------------------------------
246
+ // OPFS capability probe
247
+ // ---------------------------------------------------------------------------
248
+
215
249
  async function checkOpfs() {
216
250
  try {
217
- await navigator.storage.getDirectory();
251
+ const root = await navigator.storage.getDirectory();
252
+ // Probe createSyncAccessHandle — only available in Dedicated Workers.
253
+ // getDirectory() succeeding alone is not sufficient.
254
+ // Use a unique filename so concurrent workers don't collide on the same
255
+ // probe file (each createSyncAccessHandle is exclusive).
256
+ const probeName = `_taladb_probe_${Date.now()}_${Math.random().toString(36).slice(2)}`;
257
+ const probe = await root.getFileHandle(probeName, { create: true });
258
+ if (typeof probe.createSyncAccessHandle !== 'function') return false;
259
+ const handle = await probe.createSyncAccessHandle();
260
+ handle.close();
261
+ await root.removeEntry(probeName);
218
262
  return true;
219
263
  } catch {
220
264
  return false;