@spooky-sync/core 0.0.1-canary.154 → 0.0.1-canary.155

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/index.d.ts +151 -0
  2. package/dist/index.js +1509 -120
  3. package/dist/{sqlite-plan-sql.js → sqlite-open.js} +83 -1
  4. package/dist/sqlite-worker.js +268 -108
  5. package/dist/tabs-broker-worker.d.ts +8 -0
  6. package/dist/tabs-broker-worker.js +434 -0
  7. package/dist/types.d.ts +25 -0
  8. package/package.json +4 -4
  9. package/scripts/check-broker-bundle.mjs +33 -0
  10. package/src/modules/cache/index.ts +35 -0
  11. package/src/modules/data/index.ts +13 -3
  12. package/src/modules/data/mutation-id.test.ts +25 -0
  13. package/src/modules/data/mutation-id.ts +35 -0
  14. package/src/modules/devtools/index.ts +9 -0
  15. package/src/modules/sync/queue/queue-up.forwarded.test.ts +51 -0
  16. package/src/modules/sync/queue/queue-up.ts +74 -38
  17. package/src/modules/sync/scheduler.ts +4 -2
  18. package/src/modules/sync/sync.ts +186 -8
  19. package/src/services/database/engine-factory.ts +3 -2
  20. package/src/services/database/sqlite-cache-engine.test.ts +160 -103
  21. package/src/services/database/sqlite-cache-engine.ts +285 -72
  22. package/src/services/database/sqlite-open.ts +36 -1
  23. package/src/services/database/sqlite-select.test.ts +13 -46
  24. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  25. package/src/services/database/sqlite-transport.ts +219 -0
  26. package/src/services/database/sqlite-worker.ts +340 -55
  27. package/src/services/stream-processor/index.ts +11 -2
  28. package/src/services/tabs/broker-client.ts +283 -0
  29. package/src/services/tabs/broker.test.ts +278 -0
  30. package/src/services/tabs/coordinator.test.ts +192 -0
  31. package/src/services/tabs/coordinator.ts +567 -0
  32. package/src/services/tabs/fake-ports.fixture.ts +70 -0
  33. package/src/services/tabs/leader-locks.ts +75 -0
  34. package/src/services/tabs/protocol.ts +239 -0
  35. package/src/services/tabs/support.ts +36 -0
  36. package/src/services/tabs/tabs-broker-worker.ts +581 -0
  37. package/src/sp00ky.ts +164 -6
  38. package/src/types.ts +25 -0
  39. package/tsdown.config.ts +19 -10
@@ -191,4 +191,86 @@ function fromBase64(b64) {
191
191
  }
192
192
 
193
193
  //#endregion
194
- export { serializeRow as a, reviveRow as i, renderOrderSql as n, resolveRelations as o, renderWhereSql as r, stableKey as s, project as t };
194
+ //#region src/services/database/sqlite-open.ts
195
+ const DEFAULT_MAX_ATTEMPTS = 3;
196
+ /** Bounded on purpose: this runs on the boot path, before the first query. */
197
+ const DEFAULT_BACKOFF_MS = [250, 500];
198
+ /**
199
+ * Leader-promotion profile (~5.8s worst case). A dead leader's sync access
200
+ * handles release when the browser garbage-collects its worker, typically
201
+ * well under a second but not synchronously with the Web Lock release the
202
+ * election observed, so promotion retries longer than a cold boot.
203
+ */
204
+ const PROMOTION_OPEN_OPTIONS = {
205
+ maxAttempts: 10,
206
+ backoffMs: [
207
+ 50,
208
+ 100,
209
+ 200,
210
+ 400,
211
+ 800,
212
+ 1e3
213
+ ]
214
+ };
215
+ /** Failures no retry can fix: the APIs aren't there at all (insecure context,
216
+ * or a browser without sync access handles). Fall back immediately. */
217
+ const UNRETRYABLE = ["Missing required OPFS APIs"];
218
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
219
+ /** Keep the DOMException name (e.g. `NoModificationAllowedError` for a pool
220
+ * locked by another tab): it is the most diagnostic part of the failure. */
221
+ function errMessage(e) {
222
+ if (e instanceof Error) return e.name && e.name !== "Error" ? `${e.name}: ${e.message}` : e.message;
223
+ return String(e);
224
+ }
225
+ function fallbackToMemory(sqlite3, dbName, reason, attempts) {
226
+ const tried = attempts > 0 ? ` after ${attempts} attempt${attempts === 1 ? "" : "s"}` : "";
227
+ console.error(`[sp00ky] OPFS persistence unavailable for "${dbName}"${tried}: ${reason}. The local SQLite cache is running IN MEMORY, which keeps the whole dataset in RAM and loses every local write on reload. The usual cause is another tab of this app holding the storage lock, so closing the other tabs and reloading restores persistence.`);
228
+ return {
229
+ db: new sqlite3.oo1.DB(":memory:", "c"),
230
+ persisted: false,
231
+ opfsError: reason
232
+ };
233
+ }
234
+ /**
235
+ * Open `dbName`'s handle. Never throws for a storage problem: a caller that
236
+ * asked for persistence and can't have it gets a working in-memory handle plus
237
+ * `persisted: false` and an `opfsError` to report.
238
+ */
239
+ async function openDb(sqlite3, dbName, useOpfs, opts = {}) {
240
+ if (!useOpfs) return {
241
+ db: new sqlite3.oo1.DB(":memory:", "c"),
242
+ persisted: false
243
+ };
244
+ if (!sqlite3.installOpfsSAHPoolVfs) {
245
+ if (opts.disallowMemoryFallback) throw new Error("opfs-unavailable: sqlite-wasm build has no installOpfsSAHPoolVfs");
246
+ return fallbackToMemory(sqlite3, dbName, "sqlite-wasm build has no installOpfsSAHPoolVfs", 0);
247
+ }
248
+ const maxAttempts = Math.max(1, opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS);
249
+ const backoffMs = opts.backoffMs ?? DEFAULT_BACKOFF_MS;
250
+ const sleep = opts.sleep ?? defaultSleep;
251
+ let lastError = "unknown error";
252
+ let attempts = 0;
253
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
254
+ attempts = attempt;
255
+ try {
256
+ const pool = await sqlite3.installOpfsSAHPoolVfs({
257
+ name: `sp00ky-${dbName}`,
258
+ ...attempt > 1 ? { forceReinitIfPreviouslyFailed: true } : {}
259
+ });
260
+ return {
261
+ db: new pool.OpfsSAHPoolDb(`/${dbName}.sqlite3`),
262
+ persisted: true,
263
+ pool
264
+ };
265
+ } catch (e) {
266
+ lastError = errMessage(e);
267
+ if (attempt === maxAttempts || UNRETRYABLE.some((m) => lastError.includes(m))) break;
268
+ await sleep(backoffMs[Math.min(attempt - 1, backoffMs.length - 1)] ?? 0);
269
+ }
270
+ }
271
+ if (opts.disallowMemoryFallback) throw new Error(`opfs-unavailable: ${lastError} (after ${attempts} attempts)`);
272
+ return fallbackToMemory(sqlite3, dbName, lastError, attempts);
273
+ }
274
+
275
+ //#endregion
276
+ export { renderWhereSql as a, resolveRelations as c, renderOrderSql as i, stableKey as l, openDb as n, reviveRow as o, project as r, serializeRow as s, PROMOTION_OPEN_OPTIONS as t };
@@ -1,65 +1,6 @@
1
- import { i as reviveRow, n as renderOrderSql, o as resolveRelations, r as renderWhereSql, s as stableKey, t as project } from "./sqlite-plan-sql.js";
1
+ import { a as renderWhereSql, c as resolveRelations, i as renderOrderSql, l as stableKey, n as openDb, o as reviveRow, r as project } from "./sqlite-open.js";
2
2
  import sqlite3InitModule from "@sqlite.org/sqlite-wasm";
3
3
 
4
- //#region src/services/database/sqlite-open.ts
5
- const DEFAULT_MAX_ATTEMPTS = 3;
6
- /** Bounded on purpose: this runs on the boot path, before the first query. */
7
- const DEFAULT_BACKOFF_MS = [250, 500];
8
- /** Failures no retry can fix: the APIs aren't there at all (insecure context,
9
- * or a browser without sync access handles). Fall back immediately. */
10
- const UNRETRYABLE = ["Missing required OPFS APIs"];
11
- const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
12
- /** Keep the DOMException name (e.g. `NoModificationAllowedError` for a pool
13
- * locked by another tab): it is the most diagnostic part of the failure. */
14
- function errMessage(e) {
15
- if (e instanceof Error) return e.name && e.name !== "Error" ? `${e.name}: ${e.message}` : e.message;
16
- return String(e);
17
- }
18
- function fallbackToMemory(sqlite3, dbName, reason, attempts) {
19
- const tried = attempts > 0 ? ` after ${attempts} attempt${attempts === 1 ? "" : "s"}` : "";
20
- console.error(`[sp00ky] OPFS persistence unavailable for "${dbName}"${tried}: ${reason}. The local SQLite cache is running IN MEMORY, which keeps the whole dataset in RAM and loses every local write on reload. The usual cause is another tab of this app holding the storage lock, so closing the other tabs and reloading restores persistence.`);
21
- return {
22
- db: new sqlite3.oo1.DB(":memory:", "c"),
23
- persisted: false,
24
- opfsError: reason
25
- };
26
- }
27
- /**
28
- * Open `dbName`'s handle. Never throws for a storage problem: a caller that
29
- * asked for persistence and can't have it gets a working in-memory handle plus
30
- * `persisted: false` and an `opfsError` to report.
31
- */
32
- async function openDb(sqlite3, dbName, useOpfs, opts = {}) {
33
- if (!useOpfs) return {
34
- db: new sqlite3.oo1.DB(":memory:", "c"),
35
- persisted: false
36
- };
37
- if (!sqlite3.installOpfsSAHPoolVfs) return fallbackToMemory(sqlite3, dbName, "sqlite-wasm build has no installOpfsSAHPoolVfs", 0);
38
- const maxAttempts = Math.max(1, opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS);
39
- const backoffMs = opts.backoffMs ?? DEFAULT_BACKOFF_MS;
40
- const sleep = opts.sleep ?? defaultSleep;
41
- let lastError = "unknown error";
42
- let attempts = 0;
43
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
44
- attempts = attempt;
45
- try {
46
- return {
47
- db: new (await (sqlite3.installOpfsSAHPoolVfs({
48
- name: `sp00ky-${dbName}`,
49
- ...attempt > 1 ? { forceReinitIfPreviouslyFailed: true } : {}
50
- }))).OpfsSAHPoolDb(`/${dbName}.sqlite3`),
51
- persisted: true
52
- };
53
- } catch (e) {
54
- lastError = errMessage(e);
55
- if (attempt === maxAttempts || UNRETRYABLE.some((m) => lastError.includes(m))) break;
56
- await sleep(backoffMs[Math.min(attempt - 1, backoffMs.length - 1)] ?? 0);
57
- }
58
- }
59
- return fallbackToMemory(sqlite3, dbName, lastError, attempts);
60
- }
61
-
62
- //#endregion
63
4
  //#region src/services/database/sqlite-select.ts
64
5
  function ensureTable(db, table) {
65
6
  if (db.knownTables.has(table)) return;
@@ -142,13 +83,37 @@ async function executeSelect(plan, params, db) {
142
83
  * is unavailable it retries, then falls back to an in-memory DB and REPORTS the
143
84
  * loss of durability (see `sqlite-open.ts`) instead of degrading silently.
144
85
  *
145
- * Message protocol (request/response keyed by `id`):
146
- * { id, type: 'open', payload: { dbName, useOpfs } } -> { id, ok, persisted, opfsError? }
86
+ * Message protocol (request/response keyed by `id`; replies go to the channel
87
+ * the request arrived on):
88
+ * { id, type: 'open', payload: { dbName, useOpfs, workerLockName? } }
89
+ * -> { id, ok, persisted, opfsError? }
147
90
  * { id, type: 'exec', payload: { sql, bind } } -> { id, ok, rows }
148
91
  * { id, type: 'run', payload: { sql, bind } } -> { id, ok }
149
92
  * { id, type: 'batch', payload: [{ sql, bind }] } (atomic BEGIN/COMMIT)
150
93
  * { id, type: 'select', payload: { plan, params } } -> { id, ok, rows, relationFetches }
151
94
  * { id, type: 'close' }
95
+ * { id, type: 'shutdown' } (owner only: close + pauseVfs + self.close)
96
+ * { id, type: 'relock', payload: { workerLockName } } (owner only)
97
+ * { id, type: 'add-client', payload: { clientId } } + ev.ports[0] (owner only)
98
+ * { id, type: 'remove-client', payload: { clientId } } (owner only)
99
+ *
100
+ * Multi-client (shared-tabs mode): the OWNER (the leader tab's engine) speaks
101
+ * on the worker's own channel and controls the lifecycle. Follower tabs get a
102
+ * MessagePort each (`add-client`) and may only issue data ops
103
+ * (exec/run/batch/select). Every op, from any channel, runs through ONE op
104
+ * chain so an async `select` from one client can never interleave with another
105
+ * client's `batch` at the VFS layer.
106
+ *
107
+ * Leader fencing (shared-tabs mode): `open` may carry `workerLockName`, a Web
108
+ * Lock this worker acquires BEFORE opening the pool and holds for the DB's
109
+ * lifetime. The name is unique per leadership, so the lock being gone has
110
+ * exactly one meaning: the broker stole it because this tab was presumed dead
111
+ * (usually frozen). When that happens the worker FENCES itself: closes the DB,
112
+ * releases the OPFS sync access handles (pauseVfs), tells the owner, and
113
+ * refuses every further op. A frozen-then-thawed ex-leader therefore can never
114
+ * write the durable file after a new leader took over. The thaw gate below
115
+ * closes the wake-up ordering race where a queued write could run before the
116
+ * lock-loss callback fires.
152
117
  *
153
118
  * `select` executes a whole QueryPlan — table creation, base select, the full
154
119
  * `.related()` tree (shared `resolveRelations`), and JSON row parsing — in ONE
@@ -156,18 +121,145 @@ async function executeSelect(plan, params, db) {
156
121
  * hot path; the per-statement ops remain for the write/shim paths.
157
122
  */
158
123
  let db = null;
159
- async function open(dbName, useOpfs, systemTables = []) {
160
- const { db: handle, persisted, opfsError } = await openDb(await sqlite3InitModule(), dbName, useOpfs);
161
- db = handle;
124
+ let pool = null;
125
+ /** Once fenced, every op is refused and the DB stays closed. Terminal. */
126
+ let fenced = false;
127
+ /** The per-leadership lock currently held (null = no fencing active). A
128
+ * relock (same-leader re-promotion after a broker restart) briefly holds two
129
+ * locks: the new one is installed here first, then the old one's own release
130
+ * is called, so there is no window where neither is held. */
131
+ let currentLock = null;
132
+ function workerLocks() {
133
+ const nav = globalThis.navigator;
134
+ return nav?.locks && typeof nav.locks.request === "function" ? nav.locks : null;
135
+ }
136
+ /**
137
+ * Acquire `name` exclusively without waiting. Resolves with a handle when
138
+ * granted, null when someone else holds it. If the request settles while the
139
+ * holder still believes it owns the lock, the broker stole it: fence.
140
+ */
141
+ function acquireWorkerLock(name) {
142
+ const locks = workerLocks();
143
+ if (!locks) return Promise.resolve({
144
+ name,
145
+ release() {}
146
+ });
147
+ return new Promise((resolve) => {
148
+ let granted = false;
149
+ let intentional = false;
150
+ let releaseGrant = null;
151
+ locks.request(name, {
152
+ mode: "exclusive",
153
+ ifAvailable: true
154
+ }, (lock) => {
155
+ if (!lock) {
156
+ resolve(null);
157
+ return;
158
+ }
159
+ granted = true;
160
+ resolve({
161
+ name,
162
+ release() {
163
+ intentional = true;
164
+ releaseGrant?.();
165
+ }
166
+ });
167
+ return new Promise((release) => {
168
+ releaseGrant = release;
169
+ });
170
+ }).then(() => {
171
+ if (granted && !intentional) fence("worker lock stolen");
172
+ }, () => {
173
+ if (granted && !intentional) fence("worker lock request failed");
174
+ });
175
+ });
176
+ }
177
+ function releaseHeldLock() {
178
+ currentLock?.release();
179
+ currentLock = null;
180
+ }
181
+ /**
182
+ * Terminal teardown after leadership loss. Closing the DB first makes
183
+ * pauseVfs legal (it throws while files are open); pausing releases the sync
184
+ * access handles so the NEW leader's open succeeds without waiting for this
185
+ * worker to be garbage collected.
186
+ */
187
+ async function fence(reason) {
188
+ if (fenced) return;
189
+ fenced = true;
190
+ console.error(`[sp00ky] sqlite worker fenced (${reason}): leadership was taken over, closing the database and refusing further ops.`);
191
+ try {
192
+ db?.close();
193
+ } catch {}
194
+ db = null;
195
+ try {
196
+ pool?.pauseVfs?.();
197
+ } catch {}
198
+ pool = null;
199
+ try {
200
+ self.postMessage({
201
+ type: "lock-lost",
202
+ reason
203
+ });
204
+ } catch {}
205
+ releaseHeldLock();
206
+ self.close();
207
+ }
208
+ /**
209
+ * A frozen tab's worker resumes with its message queue intact, and a queued
210
+ * write could run BEFORE the lock-steal callback fires. Both the 1s interval
211
+ * and every op call `noteTick()`; whichever runs first after a long gap kicks
212
+ * off a lock verification, and ops await it before touching the DB. The gap
213
+ * threshold must stay BELOW the broker's pong timeout: a freeze shorter than
214
+ * the threshold cannot have triggered a steal yet, so skipping the check for
215
+ * small gaps is safe.
216
+ */
217
+ const FREEZE_SUSPECT_MS = 1e4;
218
+ let lastTickAt = performance.now();
219
+ let thawVerification = null;
220
+ function noteTick() {
221
+ const now = performance.now();
222
+ const gap = now - lastTickAt;
223
+ lastTickAt = now;
224
+ if (gap > FREEZE_SUSPECT_MS && currentLock && !fenced && !thawVerification) thawVerification = verifyLockStillHeld().finally(() => {
225
+ thawVerification = null;
226
+ });
227
+ }
228
+ setInterval(noteTick, 1e3);
229
+ async function verifyLockStillHeld() {
230
+ const locks = workerLocks();
231
+ const name = currentLock?.name;
232
+ if (!locks || !name || typeof locks.query !== "function") return;
233
+ try {
234
+ if (!((await locks.query()).held ?? []).some((l) => l.name === name)) await fence("lock missing after suspected freeze");
235
+ } catch {}
236
+ }
237
+ async function open(dbName, useOpfs, systemTables = [], workerLockName, openOptions) {
238
+ if (workerLockName) {
239
+ const handle = await acquireWorkerLock(workerLockName);
240
+ if (!handle) throw new Error("worker-lock-unavailable");
241
+ releaseHeldLock();
242
+ currentLock = handle;
243
+ }
244
+ const result = await openDb(await sqlite3InitModule(), dbName, useOpfs, openOptions ?? {});
245
+ db = result.db;
246
+ pool = result.pool ?? null;
162
247
  for (const t of systemTables) db.exec({ sql: `CREATE TABLE IF NOT EXISTS "${t}" (id TEXT PRIMARY KEY, data TEXT NOT NULL)` });
163
248
  try {
164
249
  db.exec({ sql: "PRAGMA busy_timeout = 5000; PRAGMA cache_size = -32000;" });
165
250
  } catch {}
166
251
  return {
167
- persisted,
168
- opfsError
252
+ persisted: result.persisted,
253
+ opfsError: result.opfsError
169
254
  };
170
255
  }
256
+ function closeDb() {
257
+ db?.close();
258
+ db = null;
259
+ pool = null;
260
+ selectDb.knownTables.clear();
261
+ releaseHeldLock();
262
+ }
171
263
  function exec(sql, bind) {
172
264
  if (!db) throw new Error("sqlite: DB not open");
173
265
  return db.exec({
@@ -201,59 +293,127 @@ function batch(stmts) {
201
293
  }
202
294
  }
203
295
  /** DB handle for `executeSelect` (see `sqlite-select.ts`, the unit-testable
204
- * plan executor). `knownTables` is cleared on open/close — a bucket switch
205
- * spawns a fresh worker anyway. */
296
+ * plan executor). `knownTables` is cleared on open/close, which covers both
297
+ * the solo fresh-worker path and the shared-mode same-worker bucket switch. */
206
298
  const selectDb = {
207
299
  exec: (sql, bind) => exec(sql, bind),
208
300
  run,
209
301
  knownTables: /* @__PURE__ */ new Set()
210
302
  };
211
- self.onmessage = async (ev) => {
212
- const { id, type, payload } = ev.data ?? {};
213
- const t0 = performance.now();
214
- try {
215
- let result;
216
- switch (type) {
217
- case "open":
218
- selectDb.knownTables.clear();
219
- result = await open(payload.dbName, payload.useOpfs, payload.systemTables);
303
+ /** Follower client ports, keyed by the clientId the owner assigned. */
304
+ const clients = /* @__PURE__ */ new Map();
305
+ /** Data ops a follower client port may issue; lifecycle stays owner-only. */
306
+ const CLIENT_OPS = new Set([
307
+ "exec",
308
+ "run",
309
+ "batch",
310
+ "select"
311
+ ]);
312
+ /**
313
+ * ONE chain for every op from every channel. The handlers for `open` and
314
+ * `select` are async; without this, a follower's select could interleave with
315
+ * the owner's batch mid-transaction at the VFS layer. (Solo mode kept this
316
+ * invariant on the main thread via the engine's opQueue; with multiple client
317
+ * ports only the worker can.)
318
+ */
319
+ let opChain = Promise.resolve();
320
+ async function handle(type, payload, source) {
321
+ if (fenced) throw new Error("sqlite: fenced, leadership was taken over");
322
+ if (source === "client" && !CLIENT_OPS.has(type)) throw new Error(`sqlite worker: op ${type} is not allowed on a client port`);
323
+ switch (type) {
324
+ case "open":
325
+ selectDb.knownTables.clear();
326
+ {
327
+ const result = await open(payload.dbName, payload.useOpfs, payload.systemTables, payload.workerLockName, payload.openOptions);
220
328
  for (const t of payload.systemTables ?? []) selectDb.knownTables.add(t);
221
- break;
222
- case "select":
223
- result = await executeSelect(payload.plan, payload.params ?? {}, selectDb);
224
- break;
225
- case "exec":
226
- result = { rows: exec(payload.sql, payload.bind) };
227
- break;
228
- case "run":
229
- run(payload.sql, payload.bind);
230
- result = {};
231
- break;
232
- case "batch":
233
- batch(payload);
234
- result = {};
235
- break;
236
- case "close":
329
+ return result;
330
+ }
331
+ case "relock": {
332
+ const handle = await acquireWorkerLock(payload.workerLockName);
333
+ if (!handle) throw new Error("worker-lock-unavailable");
334
+ releaseHeldLock();
335
+ currentLock = handle;
336
+ return {};
337
+ }
338
+ case "select": return executeSelect(payload.plan, payload.params ?? {}, selectDb);
339
+ case "exec": return { rows: exec(payload.sql, payload.bind) };
340
+ case "run":
341
+ run(payload.sql, payload.bind);
342
+ return {};
343
+ case "batch":
344
+ batch(payload);
345
+ return {};
346
+ case "close":
347
+ closeDb();
348
+ return {};
349
+ case "shutdown":
350
+ try {
237
351
  db?.close();
238
- db = null;
239
- selectDb.knownTables.clear();
240
- result = {};
241
- break;
242
- default: throw new Error(`sqlite worker: unknown message ${type}`);
352
+ } catch {}
353
+ db = null;
354
+ try {
355
+ pool?.pauseVfs?.();
356
+ } catch {}
357
+ pool = null;
358
+ releaseHeldLock();
359
+ queueMicrotask(() => self.close());
360
+ return {};
361
+ case "remove-client":
362
+ clients.get(payload.clientId)?.close();
363
+ clients.delete(payload.clientId);
364
+ return {};
365
+ default: throw new Error(`sqlite worker: unknown message ${type}`);
366
+ }
367
+ }
368
+ function dispatch(target, data, source) {
369
+ const { id, type, payload } = data ?? {};
370
+ noteTick();
371
+ const t0 = performance.now();
372
+ const runOp = async () => {
373
+ try {
374
+ if (thawVerification) await thawVerification;
375
+ const result = await handle(type, payload, source);
376
+ target.postMessage({
377
+ id,
378
+ ok: true,
379
+ wt: performance.now() - t0,
380
+ ...result
381
+ });
382
+ } catch (err) {
383
+ target.postMessage({
384
+ id,
385
+ ok: false,
386
+ error: err instanceof Error ? err.message : String(err)
387
+ });
243
388
  }
389
+ };
390
+ opChain = opChain.then(runOp, runOp);
391
+ }
392
+ self.onmessage = (ev) => {
393
+ const data = ev.data ?? {};
394
+ if (data.type === "add-client") {
395
+ const port = ev.ports?.[0];
396
+ const clientId = data.payload?.clientId;
397
+ if (!port || !clientId) {
398
+ self.postMessage({
399
+ id: data.id,
400
+ ok: false,
401
+ error: "sqlite worker: add-client needs a clientId and a transferred port"
402
+ });
403
+ return;
404
+ }
405
+ clients.get(clientId)?.close();
406
+ clients.set(clientId, port);
407
+ port.onmessage = (pe) => dispatch(port, pe.data, "client");
408
+ port.start?.();
244
409
  self.postMessage({
245
- id,
410
+ id: data.id,
246
411
  ok: true,
247
- wt: performance.now() - t0,
248
- ...result
249
- });
250
- } catch (err) {
251
- self.postMessage({
252
- id,
253
- ok: false,
254
- error: err instanceof Error ? err.message : String(err)
412
+ wt: 0
255
413
  });
414
+ return;
256
415
  }
416
+ dispatch(self, data, "owner");
257
417
  };
258
418
 
259
419
  //#endregion
@@ -0,0 +1,8 @@
1
+ //#region src/services/tabs/tabs-broker-worker.d.ts
2
+ /** Attach a connecting tab's port. Exported for the node test harness, which
3
+ * drives the broker with fake MessagePort pairs instead of a SharedWorker. */
4
+ declare function handleConnect(port: MessagePort): void;
5
+ /** Test-only: wipe module state so each test starts with a fresh broker. */
6
+ declare function __resetBrokerForTests(): void;
7
+ //#endregion
8
+ export { __resetBrokerForTests, handleConnect };