@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
@@ -0,0 +1,434 @@
1
+ //#region src/services/tabs/tabs-broker-worker.ts
2
+ const PING_INTERVAL_MS = 5e3;
3
+ const PONG_TIMEOUT_MS = 15e3;
4
+ const FORCE_TAKEOVER_TIMEOUT_MS = 1e3;
5
+ const LEADER_FAILURE_BACKOFF_MS = 1e3;
6
+ const OPFS_FAILED_CYCLES_BEFORE_MEMORY = 3;
7
+ const brokerInstanceId = typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `bk_${Math.random().toString(36).slice(2)}`;
8
+ const namespaces = /* @__PURE__ */ new Map();
9
+ /** Reverse index: which namespace a port belongs to (for pong routing). */
10
+ const portTab = /* @__PURE__ */ new Map();
11
+ /** The FIRST fingerprint wins for the whole broker instance; a tab from a
12
+ * different build gets `unsupported` and runs solo. Bucket ids may differ. */
13
+ let canonicalFingerprint = null;
14
+ let pingTimer = null;
15
+ function post(port, msg, transfer) {
16
+ try {
17
+ if (transfer) port.postMessage(msg, transfer);
18
+ else port.postMessage(msg);
19
+ } catch {}
20
+ }
21
+ function nsKey(fingerprint, bucketId) {
22
+ return `${fingerprint}::${bucketId}`;
23
+ }
24
+ function getNamespace(fingerprint, bucketId) {
25
+ const key = nsKey(fingerprint, bucketId);
26
+ let ns = namespaces.get(key);
27
+ if (!ns) {
28
+ ns = {
29
+ key,
30
+ fingerprint,
31
+ bucketId,
32
+ tabs: /* @__PURE__ */ new Map(),
33
+ leader: null,
34
+ failedUntil: /* @__PURE__ */ new Map(),
35
+ opfsFailedCycles: 0,
36
+ electing: false,
37
+ attachRetry: /* @__PURE__ */ new Map(),
38
+ tabLockMonitor: null
39
+ };
40
+ namespaces.set(key, ns);
41
+ }
42
+ return ns;
43
+ }
44
+ let nextLeadershipId = 0;
45
+ /** Duplicated from protocol.ts selectLeaderCandidate; keep in sync. */
46
+ function pickCandidate(ns) {
47
+ const now = Date.now();
48
+ const eligible = [...ns.tabs.values()].filter((t) => {
49
+ if ((ns.failedUntil.get(t.tabId) ?? 0) > now) return false;
50
+ ns.failedUntil.delete(t.tabId);
51
+ return true;
52
+ });
53
+ if (eligible.length === 0) return null;
54
+ const holding = eligible.filter((t) => t.heldLeadership !== null);
55
+ const base = holding.length > 0 ? holding : eligible;
56
+ const visible = base.filter((t) => t.visibility === "visible");
57
+ return (visible.length > 0 ? visible : base).reduce((best, t) => {
58
+ if (t.lastVisibleAt !== best.lastVisibleAt) return t.lastVisibleAt > best.lastVisibleAt ? t : best;
59
+ return t.tabId > best.tabId ? t : best;
60
+ });
61
+ }
62
+ function clearLeader(ns, opts) {
63
+ const leader = ns.leader;
64
+ if (!leader) return null;
65
+ ns.leader = null;
66
+ ns.tabLockMonitor?.abort();
67
+ ns.tabLockMonitor = null;
68
+ const tab = ns.tabs.get(leader.tabId);
69
+ const workerLock = tab?.heldLeadership?.workerLockName ?? null;
70
+ if (tab && opts.demote) post(tab.port, {
71
+ type: "demote",
72
+ brokerInstanceId,
73
+ leadershipId: leader.leadershipId
74
+ });
75
+ if (opts.removeTab && tab) removeTab(ns, leader.tabId, { notifyLeader: false });
76
+ for (const t of ns.tabs.values()) if (t.tabId !== leader.tabId) post(t.port, {
77
+ type: "close-follower-ports",
78
+ brokerInstanceId,
79
+ leadershipId: leader.leadershipId
80
+ });
81
+ for (const [, retry] of ns.attachRetry) if (retry.timer) clearTimeout(retry.timer);
82
+ ns.attachRetry.clear();
83
+ return {
84
+ tabId: leader.tabId,
85
+ leadershipId: leader.leadershipId,
86
+ workerLock
87
+ };
88
+ }
89
+ /**
90
+ * Fast crash detection: queue a Web Lock request on the leader's tab lock.
91
+ * Locks release the instant their holder's tab dies, so being GRANTED means
92
+ * the leader is gone; release immediately (return from the callback) and
93
+ * re-elect. The leadershipId guard drops stale grants after a normal handoff.
94
+ */
95
+ function startTabLockMonitor(ns, leadershipId) {
96
+ const locks = navigator.locks;
97
+ if (!locks) return;
98
+ ns.tabLockMonitor?.abort();
99
+ const controller = new AbortController();
100
+ ns.tabLockMonitor = controller;
101
+ const name = `sp00ky-tabs:${ns.fingerprint}:${ns.bucketId}:tab`;
102
+ locks.request(name, {
103
+ mode: "exclusive",
104
+ signal: controller.signal
105
+ }, () => {
106
+ if (ns.leader?.leadershipId !== leadershipId) return;
107
+ electIfNeeded(ns, clearLeader(ns, {
108
+ demote: true,
109
+ removeTab: true
110
+ }));
111
+ }).catch(() => {});
112
+ }
113
+ /** Probe whether a lock name is free by acquiring-and-releasing it. */
114
+ async function lockIsFree(name) {
115
+ const locks = navigator.locks;
116
+ if (!locks) return true;
117
+ try {
118
+ return await locks.request(name, {
119
+ mode: "exclusive",
120
+ ifAvailable: true
121
+ }, (lock) => Promise.resolve(lock !== null));
122
+ } catch {
123
+ return true;
124
+ }
125
+ }
126
+ async function stealLock(name) {
127
+ const locks = navigator.locks;
128
+ if (!locks) return;
129
+ try {
130
+ await locks.request(name, {
131
+ mode: "exclusive",
132
+ steal: true
133
+ }, () => Promise.resolve());
134
+ } catch {}
135
+ }
136
+ /**
137
+ * Before promoting a replacement, give the previous leader's locks a chance to
138
+ * free naturally (dead tab: released instantly; frozen tab: never). Returns
139
+ * whether a steal was needed, which the new leader passes to its worker open.
140
+ */
141
+ async function waitForPreviousLeaderLocks(previous) {
142
+ if (!previous?.workerLock) return false;
143
+ if (await lockIsFree(previous.workerLock)) return false;
144
+ await new Promise((r) => setTimeout(r, FORCE_TAKEOVER_TIMEOUT_MS));
145
+ if (await lockIsFree(previous.workerLock)) return false;
146
+ await stealLock(previous.workerLock);
147
+ return true;
148
+ }
149
+ function electIfNeeded(ns, previous = null) {
150
+ if (ns.electing || ns.leader || ns.tabs.size === 0) return;
151
+ ns.electing = true;
152
+ (async () => {
153
+ try {
154
+ const forceTakeover = await waitForPreviousLeaderLocks(previous);
155
+ if (ns.leader || ns.tabs.size === 0) return;
156
+ const candidate = pickCandidate(ns);
157
+ if (!candidate) {
158
+ const soonest = Math.min(...[...ns.failedUntil.values()], Date.now() + LEADER_FAILURE_BACKOFF_MS);
159
+ setTimeout(() => electIfNeeded(ns), Math.max(50, soonest - Date.now()));
160
+ return;
161
+ }
162
+ const resumeHeld = candidate.heldLeadership !== null;
163
+ const leadershipId = ++nextLeadershipId;
164
+ ns.leader = {
165
+ tabId: candidate.tabId,
166
+ leadershipId,
167
+ ready: false
168
+ };
169
+ post(candidate.port, {
170
+ type: "become-leader",
171
+ brokerInstanceId,
172
+ leadershipId,
173
+ forceTakeover,
174
+ allowMemoryFallback: ns.opfsFailedCycles >= OPFS_FAILED_CYCLES_BEFORE_MEMORY,
175
+ resumeHeld
176
+ });
177
+ } finally {
178
+ ns.electing = false;
179
+ }
180
+ })();
181
+ }
182
+ function assignFollowerPorts(ns) {
183
+ const leader = ns.leader;
184
+ if (!leader || !leader.ready) return;
185
+ const leaderTab = ns.tabs.get(leader.tabId);
186
+ if (!leaderTab) return;
187
+ for (const tab of ns.tabs.values()) {
188
+ if (tab.tabId === leader.tabId) continue;
189
+ if (ns.attachRetry.has(tab.tabId)) continue;
190
+ mintPorts(ns, leaderTab, tab, leader.leadershipId);
191
+ }
192
+ }
193
+ function mintPorts(ns, leaderTab, follower, leadershipId) {
194
+ const db = new MessageChannel();
195
+ const sync = new MessageChannel();
196
+ post(leaderTab.port, {
197
+ type: "attach-follower-ports",
198
+ brokerInstanceId,
199
+ leadershipId,
200
+ followerTabId: follower.tabId
201
+ }, [db.port1, sync.port1]);
202
+ post(follower.port, {
203
+ type: "use-follower-ports",
204
+ brokerInstanceId,
205
+ leadershipId,
206
+ leaderTabId: leaderTab.tabId
207
+ }, [db.port2, sync.port2]);
208
+ const state = ns.attachRetry.get(follower.tabId) ?? {
209
+ count: 0,
210
+ timer: null
211
+ };
212
+ const delay = Math.min(1e3 * 2 ** state.count, 3e4);
213
+ state.count += 1;
214
+ state.timer = setTimeout(() => {
215
+ const cur = ns.attachRetry.get(follower.tabId);
216
+ if (!cur) return;
217
+ cur.timer = null;
218
+ const stillLeader = ns.leader && ns.leader.leadershipId === leadershipId && ns.leader.ready;
219
+ const stillHere = ns.tabs.get(follower.tabId);
220
+ const leaderNow = stillLeader ? ns.tabs.get(ns.leader.tabId) : void 0;
221
+ if (stillLeader && stillHere && leaderNow) mintPorts(ns, leaderNow, stillHere, leadershipId);
222
+ else ns.attachRetry.delete(follower.tabId);
223
+ }, delay);
224
+ ns.attachRetry.set(follower.tabId, state);
225
+ }
226
+ function followerAttached(ns, followerTabId) {
227
+ const retry = ns.attachRetry.get(followerTabId);
228
+ if (retry?.timer) clearTimeout(retry.timer);
229
+ ns.attachRetry.delete(followerTabId);
230
+ }
231
+ function ensurePingTimer() {
232
+ if (pingTimer) return;
233
+ pingTimer = setInterval(() => {
234
+ const now = Date.now();
235
+ for (const ns of namespaces.values()) for (const tab of [...ns.tabs.values()]) {
236
+ if (now - tab.lastPongAt > PONG_TIMEOUT_MS) {
237
+ evictTab(ns, tab.tabId, "missed pongs");
238
+ continue;
239
+ }
240
+ post(tab.port, {
241
+ type: "ping",
242
+ brokerInstanceId
243
+ });
244
+ }
245
+ stopPingTimerIfIdle();
246
+ }, PING_INTERVAL_MS);
247
+ }
248
+ function stopPingTimerIfIdle() {
249
+ if (pingTimer && [...namespaces.values()].every((ns) => ns.tabs.size === 0)) {
250
+ clearInterval(pingTimer);
251
+ pingTimer = null;
252
+ namespaces.clear();
253
+ canonicalFingerprint = null;
254
+ }
255
+ }
256
+ function removeTab(ns, tabId, opts) {
257
+ const tab = ns.tabs.get(tabId);
258
+ if (!tab) return;
259
+ ns.tabs.delete(tabId);
260
+ portTab.delete(tab.port);
261
+ if (opts.closePort !== false) try {
262
+ tab.port.close();
263
+ } catch {}
264
+ const retry = ns.attachRetry.get(tabId);
265
+ if (retry?.timer) clearTimeout(retry.timer);
266
+ ns.attachRetry.delete(tabId);
267
+ if (opts.notifyLeader && ns.leader && ns.leader.tabId !== tabId) {
268
+ const leaderTab = ns.tabs.get(ns.leader.tabId);
269
+ if (leaderTab) post(leaderTab.port, {
270
+ type: "close-follower-ports",
271
+ brokerInstanceId,
272
+ leadershipId: ns.leader.leadershipId
273
+ });
274
+ }
275
+ }
276
+ function evictTab(ns, tabId, reason, opts = {}) {
277
+ const wasLeader = ns.leader?.tabId === tabId;
278
+ const previous = wasLeader ? clearLeader(ns, {
279
+ demote: true,
280
+ removeTab: false
281
+ }) : null;
282
+ removeTab(ns, tabId, {
283
+ notifyLeader: !wasLeader,
284
+ closePort: opts.closePort
285
+ });
286
+ if (wasLeader) electIfNeeded(ns, previous);
287
+ stopPingTimerIfIdle();
288
+ }
289
+ function handleTabMessage(port, msg, ports) {
290
+ if (msg.type === "hello") {
291
+ if (canonicalFingerprint === null) canonicalFingerprint = msg.fingerprint;
292
+ if (msg.fingerprint !== canonicalFingerprint) {
293
+ post(port, {
294
+ type: "unsupported",
295
+ brokerInstanceId,
296
+ reason: "fingerprint-mismatch"
297
+ });
298
+ return;
299
+ }
300
+ const ns = getNamespace(msg.fingerprint, msg.bucketId);
301
+ for (const other of [...namespaces.values()]) if (other.tabs.has(msg.tabId)) evictTab(other, msg.tabId, "rehello", { closePort: false });
302
+ const tab = {
303
+ port,
304
+ tabId: msg.tabId,
305
+ fingerprint: msg.fingerprint,
306
+ bucketId: msg.bucketId,
307
+ visibility: msg.visibility,
308
+ lastVisibleAt: msg.visibility === "visible" ? Date.now() : 0,
309
+ lastPongAt: Date.now(),
310
+ heldLeadership: msg.heldLeadership
311
+ };
312
+ ns.tabs.set(msg.tabId, tab);
313
+ portTab.set(port, {
314
+ ns,
315
+ tabId: msg.tabId
316
+ });
317
+ ensurePingTimer();
318
+ post(port, {
319
+ type: "broker-hello",
320
+ brokerInstanceId
321
+ });
322
+ if (ns.leader?.ready) {
323
+ post(port, {
324
+ type: "leader-ready",
325
+ brokerInstanceId,
326
+ leadershipId: ns.leader.leadershipId,
327
+ leaderTabId: ns.leader.tabId
328
+ });
329
+ assignFollowerPorts(ns);
330
+ } else electIfNeeded(ns);
331
+ return;
332
+ }
333
+ const entry = portTab.get(port);
334
+ if (!entry) return;
335
+ const { ns } = entry;
336
+ switch (msg.type) {
337
+ case "pong": {
338
+ const tab = ns.tabs.get(msg.tabId);
339
+ if (tab) tab.lastPongAt = Date.now();
340
+ break;
341
+ }
342
+ case "visibility": {
343
+ const tab = ns.tabs.get(msg.tabId);
344
+ if (tab) {
345
+ tab.visibility = msg.visibility;
346
+ if (msg.visibility === "visible") tab.lastVisibleAt = Date.now();
347
+ tab.lastPongAt = Date.now();
348
+ }
349
+ break;
350
+ }
351
+ case "leader-ready": {
352
+ if (ns.leader?.tabId !== msg.tabId || ns.leader.leadershipId !== msg.leadershipId) {
353
+ post(port, {
354
+ type: "demote",
355
+ brokerInstanceId,
356
+ leadershipId: msg.leadershipId
357
+ });
358
+ break;
359
+ }
360
+ ns.leader.ready = true;
361
+ ns.opfsFailedCycles = 0;
362
+ startTabLockMonitor(ns, msg.leadershipId);
363
+ const tab = ns.tabs.get(msg.tabId);
364
+ if (tab) tab.heldLeadership = {
365
+ leadershipId: msg.leadershipId,
366
+ workerLockName: `sp00ky-tabs:${ns.fingerprint}:${ns.bucketId}:worker:${msg.leadershipId}`
367
+ };
368
+ for (const t of ns.tabs.values()) post(t.port, {
369
+ type: "leader-ready",
370
+ brokerInstanceId,
371
+ leadershipId: msg.leadershipId,
372
+ leaderTabId: msg.tabId
373
+ });
374
+ assignFollowerPorts(ns);
375
+ break;
376
+ }
377
+ case "leader-failed": {
378
+ if (ns.leader?.tabId !== msg.tabId || ns.leader.leadershipId !== msg.leadershipId) break;
379
+ if (msg.reason.includes("opfs-unavailable")) ns.opfsFailedCycles += 1;
380
+ ns.failedUntil.set(msg.tabId, Date.now() + LEADER_FAILURE_BACKOFF_MS);
381
+ const previous = clearLeader(ns, {
382
+ demote: false,
383
+ removeTab: false
384
+ });
385
+ const tab = ns.tabs.get(msg.tabId);
386
+ if (tab) tab.heldLeadership = null;
387
+ electIfNeeded(ns, previous);
388
+ break;
389
+ }
390
+ case "follower-port-attached":
391
+ if (ns.leader?.tabId === msg.tabId && ns.leader.leadershipId === msg.leadershipId) followerAttached(ns, msg.followerTabId);
392
+ break;
393
+ case "follower-port-closed": {
394
+ if (ns.leader?.tabId !== msg.tabId) break;
395
+ followerAttached(ns, msg.followerTabId);
396
+ const leaderTab = ns.tabs.get(ns.leader.tabId);
397
+ const follower = ns.tabs.get(msg.followerTabId);
398
+ if (leaderTab && follower && ns.leader.ready) mintPorts(ns, leaderTab, follower, ns.leader.leadershipId);
399
+ break;
400
+ }
401
+ case "shutdown":
402
+ evictTab(ns, msg.tabId, "shutdown");
403
+ break;
404
+ }
405
+ }
406
+ /** Attach a connecting tab's port. Exported for the node test harness, which
407
+ * drives the broker with fake MessagePort pairs instead of a SharedWorker. */
408
+ function handleConnect(port) {
409
+ port.onmessage = (ev) => {
410
+ handleTabMessage(port, ev.data, ev.ports ?? []);
411
+ };
412
+ port.onmessageerror = () => {
413
+ const entry = portTab.get(port);
414
+ if (entry) evictTab(entry.ns, entry.tabId, "messageerror");
415
+ };
416
+ port.start?.();
417
+ }
418
+ /** Test-only: wipe module state so each test starts with a fresh broker. */
419
+ function __resetBrokerForTests() {
420
+ if (pingTimer) clearInterval(pingTimer);
421
+ pingTimer = null;
422
+ for (const ns of namespaces.values()) for (const [, retry] of ns.attachRetry) if (retry.timer) clearTimeout(retry.timer);
423
+ namespaces.clear();
424
+ portTab.clear();
425
+ canonicalFingerprint = null;
426
+ nextLeadershipId = 0;
427
+ }
428
+ if (typeof self !== "undefined") self.onconnect = (event) => {
429
+ const port = event.ports[0];
430
+ if (port) handleConnect(port);
431
+ };
432
+
433
+ //#endregion
434
+ export { __resetBrokerForTests, handleConnect };
package/dist/types.d.ts CHANGED
@@ -470,6 +470,24 @@ interface Sp00kyConfig<S extends SchemaStructure> {
470
470
  * this engine. See `services/database/cache-engine.ts`.
471
471
  */
472
472
  localEngine?: LocalEngineChoice;
473
+ /**
474
+ * Share ONE durable local store across all tabs of this origin (default
475
+ * `false`). Requires `localEngine: 'sqlite'`. A SharedWorker broker elects a
476
+ * leader tab per bucket via Web Locks; the leader owns the OPFS SQLite
477
+ * worker and the sync loop, and follower tabs read/write the same store over
478
+ * MessagePorts, so every tab is durable instead of only the first one.
479
+ *
480
+ * Falls back to solo mode (exactly the flag-off behavior, including the
481
+ * later-tabs in-memory fallback reported via {@link StorageHealth}) whenever
482
+ * SharedWorker, Web Locks, or MessageChannel are unavailable, the engine is
483
+ * not sqlite, or the broker rejects the tab (mixed app versions).
484
+ *
485
+ * Failover: when the leader tab closes or freezes, a follower is promoted
486
+ * within seconds; queries briefly refetch and mutations are never lost once
487
+ * their local write resolved (the shared outbox survives in the store).
488
+ * Inspect via `window.__00__.getState().database.tabs` and `__sqliteStats`.
489
+ */
490
+ sharedTabs?: boolean;
473
491
  /** A pino browser transmit object for forwarding logs (e.g. via @spooky-sync/core/otel). */
474
492
  otelTransmit?: PinoTransmit;
475
493
  /**
@@ -593,6 +611,13 @@ interface StorageHealth {
593
611
  fallback: boolean;
594
612
  /** Reason durable storage failed (only set while `fallback` is `true`). */
595
613
  error?: string;
614
+ /**
615
+ * Shared-tabs role, set only when `sharedTabs` is active: `'leader'` owns
616
+ * the OPFS worker, `'follower'` shares it over a MessagePort (its data IS
617
+ * durable, hence `status: 'persistent'`), `'solo'` fell back to the
618
+ * single-tab behavior. Absent entirely when the feature is off.
619
+ */
620
+ role?: 'leader' | 'follower' | 'solo';
596
621
  }
597
622
  type QueryHash = string;
598
623
  type RecordVersionArray = Array<[string, number]>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.154",
3
+ "version": "0.0.1-canary.155",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -18,7 +18,7 @@
18
18
  "scripts": {
19
19
  "dev": "tsdown --watch",
20
20
  "dev:example": "pnpm dev",
21
- "build": "tsdown",
21
+ "build": "tsdown && node scripts/check-broker-bundle.mjs",
22
22
  "test": "vitest"
23
23
  },
24
24
  "repository": {
@@ -60,8 +60,8 @@
60
60
  }
61
61
  },
62
62
  "dependencies": {
63
- "@spooky-sync/query-builder": "0.0.1-canary.154",
64
- "@spooky-sync/ssp-wasm": "0.0.1-canary.154",
63
+ "@spooky-sync/query-builder": "0.0.1-canary.155",
64
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.155",
65
65
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
66
66
  "@surrealdb/wasm": "^3.0.3",
67
67
  "fast-json-patch": "^3.1.1",
@@ -0,0 +1,33 @@
1
+ // Build gate: dist/tabs-broker-worker.js must be self-contained ESM.
2
+ // Bundlers cannot trace `new SharedWorker(url)` module graphs, so any
3
+ // import statement surviving into the emitted broker file would 404 at
4
+ // runtime in consumers (the file is fetched as a bare URL, its relative
5
+ // chunk siblings are not copied along). Run after `tsdown` in the build.
6
+ import { readFileSync } from 'node:fs';
7
+ import { resolve, dirname } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ const dist = resolve(dirname(fileURLToPath(import.meta.url)), '../dist/tabs-broker-worker.js');
11
+
12
+ let code;
13
+ try {
14
+ code = readFileSync(dist, 'utf8');
15
+ } catch {
16
+ console.error(`check-broker-bundle: ${dist} missing; did tsdown run?`);
17
+ process.exit(1);
18
+ }
19
+
20
+ const problems = [];
21
+ // Static or dynamic imports of sibling chunks.
22
+ const importRe = /(?:^|\n)\s*import[\s(]/;
23
+ if (importRe.test(code)) problems.push('contains an import statement');
24
+ if (!code.includes('onconnect')) problems.push('missing onconnect handler');
25
+
26
+ if (problems.length > 0) {
27
+ console.error(`check-broker-bundle: dist/tabs-broker-worker.js ${problems.join('; ')}.`);
28
+ console.error(
29
+ 'The broker worker must stay free of runtime imports (type-only imports are fine in source).'
30
+ );
31
+ process.exit(1);
32
+ }
33
+ console.log('check-broker-bundle: ok');
@@ -18,10 +18,23 @@ export * from './types';
18
18
  * Single responsibility: Handle all local storage operations and DBSP ingestion.
19
19
  * This module acts as the bridge between data operations and persistence.
20
20
  */
21
+ /** One ingested change, in exactly the shape `ingestMany` consumes. Shared
22
+ * with the tabs protocol so a leader can relay its ingests to followers. */
23
+ export interface CacheIngestTuple {
24
+ table: string;
25
+ op: 'CREATE' | 'UPDATE' | 'DELETE';
26
+ id: string;
27
+ record: Record<string, unknown>;
28
+ }
29
+
21
30
  export class CacheModule implements StreamUpdateReceiver {
22
31
  private logger: Logger;
23
32
  private streamUpdateCallback: (update: StreamUpdate) => void;
24
33
  private versionLookups: Record<string, number> = {};
34
+ /** Shared-tabs leader: fan every committed ingest out to follower circuits.
35
+ * Fired AFTER the local tx (the rows are already in the shared store, so a
36
+ * follower only needs the circuit feed). Never set on followers. */
37
+ private ingestRelay: ((tuples: CacheIngestTuple[]) => void) | null = null;
25
38
 
26
39
  constructor(
27
40
  private local: LocalStore,
@@ -51,6 +64,26 @@ export class CacheModule implements StreamUpdateReceiver {
51
64
  this.streamUpdateCallback(update);
52
65
  }
53
66
 
67
+ setIngestRelay(cb: ((tuples: CacheIngestTuple[]) => void) | null): void {
68
+ this.ingestRelay = cb;
69
+ }
70
+
71
+ /**
72
+ * Shared-tabs follower: feed relayed tuples into THIS tab's circuit only.
73
+ * The rows are already in the shared store (the leader wrote them), so no
74
+ * local write happens here; the normal chain then runs: SSP -> stream update
75
+ * -> DataModule debounce -> materializeRecords (re-reads via the port
76
+ * transport) -> this tab's subscriptions fire with this tab's hashes.
77
+ */
78
+ applyRelayedIngest(tuples: CacheIngestTuple[]): void {
79
+ for (const t of tuples) {
80
+ const rv = (t.record as { _00_rv?: number } | undefined)?._00_rv;
81
+ if (t.op === 'DELETE') delete this.versionLookups[t.id];
82
+ else if (typeof rv === 'number') this.versionLookups[t.id] = rv;
83
+ }
84
+ this.streamProcessor.ingestMany(tuples as Parameters<StreamProcessorService['ingestMany']>[0]);
85
+ }
86
+
54
87
  public lookup(recordId: string): number {
55
88
  return this.versionLookups[recordId] ?? 0;
56
89
  }
@@ -146,6 +179,7 @@ export class CacheModule implements StreamUpdateReceiver {
146
179
  return { table: record.table, op: record.op, id: recordId, record: record.record };
147
180
  });
148
181
  this.streamProcessor.ingestMany(bulk);
182
+ this.ingestRelay?.(bulk as CacheIngestTuple[]);
149
183
 
150
184
  this.logger.debug(
151
185
  { count: records.length, Category: 'sp00ky-client::CacheModule::saveBatch' },
@@ -187,6 +221,7 @@ export class CacheModule implements StreamUpdateReceiver {
187
221
  // 2. Ingest deletion into DBSP (pass record data so predicates can be matched)
188
222
  delete this.versionLookups[id];
189
223
  this.streamProcessor.ingest(table, 'DELETE', id, recordData);
224
+ this.ingestRelay?.([{ table, op: 'DELETE', id, record: recordData }]);
190
225
 
191
226
  this.logger.debug(
192
227
  { table, id, Category: 'sp00ky-client::CacheModule::delete' },
@@ -44,6 +44,7 @@ import {
44
44
  import type { CreateEvent, DeleteEvent, UpdateEvent } from '../sync/index';
45
45
  import type { PushEventOptions } from '../../events/index';
46
46
  import { buildWindowMaterialization, buildWindowMaterializationPlan } from './window-query';
47
+ import { mintMutationId } from './mutation-id';
47
48
 
48
49
  /** Push a timing sample (ms) into a rolling window, capped at the sample window. */
49
50
  function pushSample(samples: number[], ms: number): void {
@@ -68,6 +69,9 @@ function phaseStatOf(samples: number[], lastMs: number | null): PhaseStat {
68
69
  * Uses CacheModule for all storage operations.
69
70
  */
70
71
  export class DataModule<S extends SchemaStructure> {
72
+ /** Tab identity baked into mutation ids (shared-tabs rollback routing);
73
+ * undefined in solo mode, where mutation-id falls back to a session id. */
74
+ private tabId: string | undefined;
71
75
  private activeQueries: Map<QueryHash, QueryState> = new Map();
72
76
  private pendingQueries: Map<QueryHash, Promise<QueryHash>> = new Map();
73
77
  private subscriptions: Map<QueryHash, Set<QueryUpdateCallback>> = new Map();
@@ -155,6 +159,12 @@ export class DataModule<S extends SchemaStructure> {
155
159
  this.sessionId = sessionId;
156
160
  }
157
161
 
162
+ /** Shared-tabs: bake this tab's identity into mutation ids so a rollback of
163
+ * a follower's mutation routes back to the tab that made it. */
164
+ setTabId(tabId: string): void {
165
+ this.tabId = tabId;
166
+ }
167
+
158
168
  /**
159
169
  * Update the authenticated user record id. Pass `null` on sign-out.
160
170
  * Read by `Sp00kySync.listRefTable()` so the LIVE subscription and
@@ -1246,7 +1256,7 @@ export class DataModule<S extends SchemaStructure> {
1246
1256
 
1247
1257
  const rid = parseRecordIdString(id);
1248
1258
  const params = parseParams(tableSchema.columns, data);
1249
- const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
1259
+ const mutationId = parseRecordIdString(mintMutationId(this.tabId));
1250
1260
 
1251
1261
  const dataKeys = Object.keys(params).map((key) => ({ key, variable: `data_${key}` }));
1252
1262
  const prefixedParams = Object.fromEntries(
@@ -1317,7 +1327,7 @@ export class DataModule<S extends SchemaStructure> {
1317
1327
 
1318
1328
  const rid = parseRecordIdString(id);
1319
1329
  const params = parseParams(tableSchema.columns, data);
1320
- const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
1330
+ const mutationId = parseRecordIdString(mintMutationId(this.tabId));
1321
1331
 
1322
1332
  // Note: CRDT state is pushed directly to the _00_crdt table by CrdtField.pushToRemote(),
1323
1333
  // NOT through the record update pipeline. This keeps the record data clean.
@@ -1404,7 +1414,7 @@ export class DataModule<S extends SchemaStructure> {
1404
1414
  }
1405
1415
 
1406
1416
  const rid = parseRecordIdString(id);
1407
- const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
1417
+ const mutationId = parseRecordIdString(mintMutationId(this.tabId));
1408
1418
 
1409
1419
  // Fetch the record before deleting so DBSP can match it against query predicates
1410
1420
  const [beforeRecords] = await this.local.query<[Record<string, any>[]]>(
@@ -0,0 +1,25 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { mintMutationId, mutationOwnerTabId } from './mutation-id';
3
+
4
+ // The old `_00_pending_mutations:${Date.now()}` collided within a millisecond
5
+ // (guaranteed once multiple tabs share a store) and never actually ordered the
6
+ // drain. These pin the new id's three properties: unique, sortable, routable.
7
+ describe('mutation ids', () => {
8
+ it('never collides within a burst', () => {
9
+ const ids = new Set(Array.from({ length: 1000 }, () => mintMutationId('tabx')));
10
+ expect(ids.size).toBe(1000);
11
+ });
12
+
13
+ it('sorts lexicographically in mint order (ORDER BY id is chronological)', () => {
14
+ const ids = Array.from({ length: 200 }, () => mintMutationId('tabx'));
15
+ expect([...ids].sort()).toEqual(ids);
16
+ });
17
+
18
+ it('routes back to the owning tab', () => {
19
+ expect(mutationOwnerTabId(mintMutationId('tab-abc'))).toBe('tab-abc');
20
+ });
21
+
22
+ it('returns null for a legacy numeric id instead of misrouting', () => {
23
+ expect(mutationOwnerTabId('_00_pending_mutations:1753875000000')).toBeNull();
24
+ });
25
+ });