@spooky-sync/core 0.0.1-canary.165 → 0.0.1-canary.167

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/dist/index.js CHANGED
@@ -6543,8 +6543,8 @@ async function walkOpfs(maxEntries = 2e3, maxDepth = 8) {
6543
6543
 
6544
6544
  //#endregion
6545
6545
  //#region src/modules/devtools/index.ts
6546
- const CORE_VERSION = "0.0.1-canary.165";
6547
- const WASM_VERSION = "0.0.1-canary.165";
6546
+ const CORE_VERSION = "0.0.1-canary.167";
6547
+ const WASM_VERSION = "0.0.1-canary.167";
6548
6548
  const SURREAL_VERSION = "3.0.3";
6549
6549
  var DevToolsService = class DevToolsService {
6550
6550
  eventsHistory = [];
@@ -9536,6 +9536,9 @@ var TabsCoordinator = class {
9536
9536
  }, "Promotion failed");
9537
9537
  this.hub?.detachAll();
9538
9538
  this.hub = null;
9539
+ if (previousRole === "leader") await this.deps.hooks.releaseOwnership();
9540
+ this.tabLock?.release();
9541
+ this.tabLock = null;
9539
9542
  this.broker.send({
9540
9543
  type: "leader-failed",
9541
9544
  tabId: this.deps.tabId,
@@ -10884,7 +10887,7 @@ var Sp00kyClient = class {
10884
10887
  return new TabsCoordinator({
10885
10888
  tabId,
10886
10889
  fingerprint: computeTabsFingerprint({
10887
- coreVersion: "0.0.1-canary.165",
10890
+ coreVersion: "0.0.1-canary.167",
10888
10891
  schemaHash: hash53(this.config.schemaSurql),
10889
10892
  endpoint: this.config.database.endpoint ?? "",
10890
10893
  namespace: this.config.database.namespace,
@@ -10998,23 +11001,27 @@ var Sp00kyClient = class {
10998
11001
  this.logger.info({ Category: "sp00ky-client::Sp00kyClient::init" }, "Sp00kyClient initialization started");
10999
11002
  try {
11000
11003
  const bootBucket = readBootBucketHint() ?? ANON_USER_ID;
11001
- if (this.tabsCoordinator) try {
11002
- const role = await this.tabsCoordinator.start(bootBucket);
11003
- this.sharedActive = true;
11004
- this.logger.info({
11005
- role,
11006
- bootBucket,
11007
- Category: "sp00ky-client::Sp00kyClient::init"
11008
- }, "Shared-tabs role assigned");
11009
- } catch (e) {
11010
- this.logger.warn({
11011
- err: e,
11012
- Category: "sp00ky-client::Sp00kyClient::init"
11013
- }, "Shared-tabs unavailable; booting solo");
11014
- this.sharedActive = false;
11015
- await this.local.connect(bootBucket);
11016
- }
11017
- else await this.local.connect(bootBucket);
11004
+ if (this.tabsCoordinator) {
11005
+ this.tabsCoordinator.onRoleChange((role) => {
11006
+ this.sharedActive = role !== "solo";
11007
+ });
11008
+ try {
11009
+ const role = await this.tabsCoordinator.start(bootBucket);
11010
+ this.sharedActive = true;
11011
+ this.logger.info({
11012
+ role,
11013
+ bootBucket,
11014
+ Category: "sp00ky-client::Sp00kyClient::init"
11015
+ }, "Shared-tabs role assigned");
11016
+ } catch (e) {
11017
+ this.logger.warn({
11018
+ err: e,
11019
+ Category: "sp00ky-client::Sp00kyClient::init"
11020
+ }, "Shared-tabs unavailable; booting solo");
11021
+ this.sharedActive = false;
11022
+ await this.local.connect(bootBucket);
11023
+ }
11024
+ } else await this.local.connect(bootBucket);
11018
11025
  this.logger.debug({
11019
11026
  bootBucket,
11020
11027
  Category: "sp00ky-client::Sp00kyClient::init"
@@ -3,7 +3,7 @@ const PING_INTERVAL_MS = 5e3;
3
3
  const PONG_TIMEOUT_MS = 15e3;
4
4
  const FORCE_TAKEOVER_TIMEOUT_MS = 1e3;
5
5
  const LEADER_FAILURE_BACKOFF_MS = 1e3;
6
- const OPFS_FAILED_CYCLES_BEFORE_MEMORY = 3;
6
+ const FAILED_CYCLES_BEFORE_MEMORY = 3;
7
7
  const brokerInstanceId = typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `bk_${Math.random().toString(36).slice(2)}`;
8
8
  const namespaces = /* @__PURE__ */ new Map();
9
9
  /** Reverse index: which namespace a port belongs to (for pong routing). */
@@ -32,7 +32,7 @@ function getNamespace(fingerprint, bucketId) {
32
32
  tabs: /* @__PURE__ */ new Map(),
33
33
  leader: null,
34
34
  failedUntil: /* @__PURE__ */ new Map(),
35
- opfsFailedCycles: 0,
35
+ failedCycles: 0,
36
36
  electing: false,
37
37
  attachRetry: /* @__PURE__ */ new Map(),
38
38
  tabLockMonitor: null
@@ -171,7 +171,7 @@ function electIfNeeded(ns, previous = null) {
171
171
  brokerInstanceId,
172
172
  leadershipId,
173
173
  forceTakeover,
174
- allowMemoryFallback: ns.opfsFailedCycles >= OPFS_FAILED_CYCLES_BEFORE_MEMORY,
174
+ allowMemoryFallback: ns.failedCycles >= FAILED_CYCLES_BEFORE_MEMORY,
175
175
  resumeHeld
176
176
  });
177
177
  } finally {
@@ -358,7 +358,7 @@ function handleTabMessage(port, msg, ports) {
358
358
  break;
359
359
  }
360
360
  ns.leader.ready = true;
361
- ns.opfsFailedCycles = 0;
361
+ ns.failedCycles = 0;
362
362
  startTabLockMonitor(ns, msg.leadershipId);
363
363
  const tab = ns.tabs.get(msg.tabId);
364
364
  if (tab) tab.heldLeadership = {
@@ -376,7 +376,7 @@ function handleTabMessage(port, msg, ports) {
376
376
  }
377
377
  case "leader-failed": {
378
378
  if (ns.leader?.tabId !== msg.tabId || ns.leader.leadershipId !== msg.leadershipId) break;
379
- if (msg.reason.includes("opfs-unavailable")) ns.opfsFailedCycles += 1;
379
+ ns.failedCycles += 1;
380
380
  ns.failedUntil.set(msg.tabId, Date.now() + LEADER_FAILURE_BACKOFF_MS);
381
381
  const previous = clearLeader(ns, {
382
382
  demote: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.165",
3
+ "version": "0.0.1-canary.167",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -60,8 +60,8 @@
60
60
  }
61
61
  },
62
62
  "dependencies": {
63
- "@spooky-sync/query-builder": "0.0.1-canary.165",
64
- "@spooky-sync/ssp-wasm": "0.0.1-canary.165",
63
+ "@spooky-sync/query-builder": "0.0.1-canary.167",
64
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.167",
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",
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
2
  import { handleConnect, __resetBrokerForTests } from './tabs-broker-worker';
3
- import { installBrokerGlobals } from './fake-ports.fixture';
3
+ import { installBrokerGlobals, installFakeLocks } from './fake-ports.fixture';
4
4
  import { TabsCoordinator, type CoordinatorHooks, type LeaderSyncHub, type SyncForwarder } from './coordinator';
5
5
  import type { StorageHealth } from '../../types';
6
6
  import type { LeaderToFollowerMessage } from './protocol';
@@ -62,12 +62,12 @@ function makeHooks(): { hooks: CoordinatorHooks; log: HookLog } {
62
62
  return { hooks, log };
63
63
  }
64
64
 
65
- function makeCoordinator(tabId: string) {
65
+ function makeCoordinator(tabId: string, overrides: Partial<CoordinatorHooks> = {}) {
66
66
  const { hooks, log } = makeHooks();
67
67
  const coordinator = new TabsCoordinator({
68
68
  tabId,
69
69
  fingerprint: 'fp-coord-test',
70
- hooks,
70
+ hooks: { ...hooks, ...overrides },
71
71
  logger: makeLogger(),
72
72
  });
73
73
  return { coordinator, log };
@@ -171,6 +171,58 @@ describe('TabsCoordinator integration', () => {
171
171
  await b.coordinator.stop();
172
172
  });
173
173
 
174
+ // A failed promotion must give the leader TAB lock back. The name is shared
175
+ // per namespace, so a leaked one makes every later election fail with
176
+ // 'leader tab lock unavailable': all tabs time out in start(), boot solo, and
177
+ // then contend for the OPFS pool individually — one busy pool wedging the
178
+ // whole app into the pre-shared-tabs behavior it exists to replace.
179
+ describe('with Web Locks', () => {
180
+ let locks: ReturnType<typeof installFakeLocks>;
181
+ beforeEach(() => {
182
+ locks = installFakeLocks();
183
+ });
184
+ afterEach(() => locks.restore());
185
+
186
+ /** A tab whose store can never open. Resolves once it has failed a
187
+ * promotion, so tests don't wait out start()'s 15s timeout. */
188
+ function makeDoomedTab(tabId: string) {
189
+ let failed!: () => void;
190
+ const hasFailed = new Promise<void>((r) => {
191
+ failed = r;
192
+ });
193
+ const tab = makeCoordinator(tabId, {
194
+ async adoptOwner() {
195
+ queueMicrotask(failed);
196
+ throw new Error('opfs-unavailable: NoModificationAllowedError (after 10 attempts)');
197
+ },
198
+ });
199
+ // start() only settles on a role or the timeout; neither is the point.
200
+ void tab.coordinator.start('anon').catch(() => {});
201
+ return { ...tab, hasFailed };
202
+ }
203
+
204
+ it('releases the leader tab lock when adoptOwner throws', async () => {
205
+ const a = makeDoomedTab('tab-1');
206
+ await a.hasFailed;
207
+ // Well inside the 1s re-nomination backoff, so a held lock here is a leak
208
+ // and not the next attempt's legitimate acquisition.
209
+ await new Promise((r) => setTimeout(r, 50));
210
+ expect(locks.heldNames()).not.toContain('sp00ky-tabs:fp-coord-test:anon:tab');
211
+ await a.coordinator.stop();
212
+ });
213
+
214
+ it('lets a later tab lead after another tab failed to open the store', async () => {
215
+ const a = makeDoomedTab('tab-1');
216
+ await a.hasFailed;
217
+
218
+ const b = makeCoordinator('tab-2');
219
+ await expect(b.coordinator.start('anon')).resolves.toBe('leader');
220
+ expect(b.log.adoptOwner).toHaveLength(1);
221
+ await b.coordinator.stop();
222
+ await a.coordinator.stop();
223
+ });
224
+ });
225
+
174
226
  it('moves buckets by leaving and rejoining: old namespace re-elects', async () => {
175
227
  const a = makeCoordinator('tab-1');
176
228
  await a.coordinator.start('anon');
@@ -424,6 +424,15 @@ export class TabsCoordinator {
424
424
  );
425
425
  this.hub?.detachAll();
426
426
  this.hub = null;
427
+ // Give back everything this attempt claimed. The broker does NOT demote a
428
+ // tab whose promotion failed (leader-failed clears leadership without a
429
+ // demote), so nothing else ever frees these. The tab lock name is shared
430
+ // per namespace, so keeping it after failing to lead makes EVERY later
431
+ // election in this namespace fail with 'leader tab lock unavailable' —
432
+ // one OPFS-busy promotion would wedge the whole app into solo mode.
433
+ if (previousRole === 'leader') await this.deps.hooks.releaseOwnership();
434
+ this.tabLock?.release();
435
+ this.tabLock = null;
427
436
  this.broker.send({
428
437
  type: 'leader-failed',
429
438
  tabId: this.deps.tabId,
@@ -39,6 +39,48 @@ export async function flush(times = 10): Promise<void> {
39
39
  for (let i = 0; i < times; i++) await Promise.resolve();
40
40
  }
41
41
 
42
+ /**
43
+ * Install a minimal exclusive-only `navigator.locks` on globalThis. Node has no
44
+ * Web Locks, and `acquireLeaderTabLock` treats a missing LockManager as "always
45
+ * granted", so without this the whole leader-tab-lock path is a no-op in tests
46
+ * and lock leaks are invisible. `ifAvailable` resolves null while held (what a
47
+ * losing tab sees), `steal` evicts the holder, and a plain request queues
48
+ * forever — the same shapes the real API produces.
49
+ */
50
+ export function installFakeLocks(): {
51
+ restore: () => void;
52
+ heldNames: () => string[];
53
+ } {
54
+ const g = globalThis as Record<string, unknown>;
55
+ const previous = g.navigator;
56
+ const held = new Map<string, () => void>();
57
+ const locks = {
58
+ async request(name: string, opts: any, cb: any) {
59
+ if (opts?.steal) held.get(name)?.();
60
+ if (held.has(name) && !opts?.steal) {
61
+ if (opts?.ifAvailable) return cb(null);
62
+ return new Promise(() => {});
63
+ }
64
+ // The holder keeps the lock until the callback's promise settles (the
65
+ // real contract) or someone steals it out from under them.
66
+ let stolen!: () => void;
67
+ const stealSignal = new Promise<void>((r) => {
68
+ stolen = r as () => void;
69
+ });
70
+ held.set(name, stolen);
71
+ try {
72
+ await Promise.race([Promise.resolve(cb({ name, mode: 'exclusive' })), stealSignal]);
73
+ } finally {
74
+ held.delete(name);
75
+ }
76
+ },
77
+ };
78
+ const define = (value: unknown) =>
79
+ Object.defineProperty(g, 'navigator', { value, configurable: true, writable: true });
80
+ define({ ...(previous ?? {}), locks });
81
+ return { restore: () => define(previous), heldNames: () => [...held.keys()] };
82
+ }
83
+
42
84
  /** Install `MessageChannel` + `SharedWorker` fakes on globalThis; the fake
43
85
  * SharedWorker pipes its port into `handleConnect`. Returns a restore fn. */
44
86
  export function installBrokerGlobals(handleConnect: (port: MessagePort) => void): () => void {
@@ -44,10 +44,13 @@ export const LEADER_FAILURE_BACKOFF_MS = 1000;
44
44
  /** Follower attachment retry: initial delay, doubling per attempt, capped. */
45
45
  export const ATTACH_RETRY_INITIAL_MS = 1000;
46
46
  export const ATTACH_RETRY_MAX_MS = 30_000;
47
- /** After this many consecutive elections failing with opfs-unavailable, the
48
- * broker allows the next leader to open in memory (reported as degraded)
49
- * rather than leaving the namespace leaderless forever. */
50
- export const OPFS_FAILED_CYCLES_BEFORE_MEMORY = 3;
47
+ /** After this many consecutive failed elections, the broker allows the next
48
+ * leader to open in memory (reported as degraded) rather than leaving the
49
+ * namespace leaderless forever. Counts EVERY failure reason, not just
50
+ * opfs-unavailable: any reason that keeps recurring leaves every tab timing
51
+ * out in `start()` and falling back to solo, which is strictly worse than one
52
+ * shared in-memory store. */
53
+ export const FAILED_CYCLES_BEFORE_MEMORY = 3;
51
54
 
52
55
  // ---- identity ---------------------------------------------------------------
53
56
 
@@ -35,7 +35,7 @@ const PING_INTERVAL_MS = 5000;
35
35
  const PONG_TIMEOUT_MS = 15_000;
36
36
  const FORCE_TAKEOVER_TIMEOUT_MS = 1000;
37
37
  const LEADER_FAILURE_BACKOFF_MS = 1000;
38
- const OPFS_FAILED_CYCLES_BEFORE_MEMORY = 3;
38
+ const FAILED_CYCLES_BEFORE_MEMORY = 3;
39
39
 
40
40
  interface BrokerTab {
41
41
  port: MessagePort;
@@ -62,8 +62,8 @@ interface Namespace {
62
62
  leader: Leader | null;
63
63
  /** Tabs whose promotion recently failed: tabId -> retry-not-before. */
64
64
  failedUntil: Map<string, number>;
65
- /** Consecutive elections that failed with opfs-unavailable. */
66
- opfsFailedCycles: number;
65
+ /** Consecutive elections whose promotion failed, for any reason. */
66
+ failedCycles: number;
67
67
  /** Single-flight guard for the async election path. */
68
68
  electing: boolean;
69
69
  /** Pending follower attach state: followerTabId -> retry bookkeeping. */
@@ -112,7 +112,7 @@ function getNamespace(fingerprint: string, bucketId: string): Namespace {
112
112
  tabs: new Map(),
113
113
  leader: null,
114
114
  failedUntil: new Map(),
115
- opfsFailedCycles: 0,
115
+ failedCycles: 0,
116
116
  electing: false,
117
117
  attachRetry: new Map(),
118
118
  tabLockMonitor: null,
@@ -274,7 +274,7 @@ function electIfNeeded(ns: Namespace, previous: ClearedLeader | null = null): vo
274
274
  brokerInstanceId,
275
275
  leadershipId,
276
276
  forceTakeover,
277
- allowMemoryFallback: ns.opfsFailedCycles >= OPFS_FAILED_CYCLES_BEFORE_MEMORY,
277
+ allowMemoryFallback: ns.failedCycles >= FAILED_CYCLES_BEFORE_MEMORY,
278
278
  resumeHeld,
279
279
  });
280
280
  } finally {
@@ -488,7 +488,7 @@ function handleTabMessage(port: MessagePort, msg: TabToBrokerMessage, ports: rea
488
488
  break;
489
489
  }
490
490
  ns.leader.ready = true;
491
- ns.opfsFailedCycles = 0;
491
+ ns.failedCycles = 0;
492
492
  startTabLockMonitor(ns, msg.leadershipId);
493
493
  const tab = ns.tabs.get(msg.tabId);
494
494
  if (tab) {
@@ -512,7 +512,12 @@ function handleTabMessage(port: MessagePort, msg: TabToBrokerMessage, ports: rea
512
512
  }
513
513
  case 'leader-failed': {
514
514
  if (ns.leader?.tabId !== msg.tabId || ns.leader.leadershipId !== msg.leadershipId) break;
515
- if (msg.reason.includes('opfs-unavailable')) ns.opfsFailedCycles += 1;
515
+ // Every reason counts. Gating this on 'opfs-unavailable' left any other
516
+ // recurring failure (e.g. a tab lock nobody frees) looping at the backoff
517
+ // interval forever, with allowMemoryFallback never granted, so every tab
518
+ // timed out in start() and booted solo — each one then contending for the
519
+ // OPFS pool on its own, which is exactly what shared-tabs exists to stop.
520
+ ns.failedCycles += 1;
516
521
  ns.failedUntil.set(msg.tabId, Date.now() + LEADER_FAILURE_BACKOFF_MS);
517
522
  const previous = clearLeader(ns, { demote: false, removeTab: false });
518
523
  const tab = ns.tabs.get(msg.tabId);
package/src/sp00ky.ts CHANGED
@@ -676,6 +676,13 @@ export class Sp00kyClient<S extends SchemaStructure> {
676
676
  // Any failure here (no SharedWorker start, election timeout, rejected
677
677
  // fingerprint) falls back to plain solo boot: exactly the flag-off
678
678
  // path, including the second-tab memory fallback + its warning.
679
+ // A role can still land AFTER start() gave up (the election that timed
680
+ // out here keeps running), and when it does this tab really is sharing
681
+ // the leader's store. Track every transition so the reported state is
682
+ // the current one instead of frozen at whatever boot saw.
683
+ this.tabsCoordinator.onRoleChange((role) => {
684
+ this.sharedActive = role !== 'solo';
685
+ });
679
686
  try {
680
687
  const role = await this.tabsCoordinator.start(bootBucket);
681
688
  this.sharedActive = true;