@spooky-sync/core 0.0.1-canary.16 → 0.0.1-canary.161

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 (123) hide show
  1. package/AGENTS.md +56 -0
  2. package/dist/index.d.ts +1406 -364
  3. package/dist/index.js +7773 -1391
  4. package/dist/otel/index.d.ts +21 -0
  5. package/dist/otel/index.js +86 -0
  6. package/dist/sqlite-open.js +276 -0
  7. package/dist/sqlite-worker.d.ts +1 -0
  8. package/dist/sqlite-worker.js +420 -0
  9. package/dist/tabs-broker-worker.d.ts +8 -0
  10. package/dist/tabs-broker-worker.js +434 -0
  11. package/dist/types.d.ts +874 -0
  12. package/package.json +20 -7
  13. package/scripts/check-broker-bundle.mjs +33 -0
  14. package/skills/sp00ky-core/SKILL.md +258 -0
  15. package/skills/sp00ky-core/references/auth.md +98 -0
  16. package/skills/sp00ky-core/references/config.md +76 -0
  17. package/src/build-globals.d.ts +12 -0
  18. package/src/events/events.test.ts +2 -1
  19. package/src/events/index.ts +3 -0
  20. package/src/index.ts +16 -2
  21. package/src/modules/app-release/index.test.ts +125 -0
  22. package/src/modules/app-release/index.ts +201 -0
  23. package/src/modules/auth/events/index.ts +2 -1
  24. package/src/modules/auth/index.ts +59 -20
  25. package/src/modules/cache/index.ts +112 -32
  26. package/src/modules/cache/types.ts +2 -2
  27. package/src/modules/crdt/crdt-field.ts +294 -0
  28. package/src/modules/crdt/crdt-hydration.test.ts +206 -0
  29. package/src/modules/crdt/index.ts +361 -0
  30. package/src/modules/crdt/loro-loader.ts +25 -0
  31. package/src/modules/data/data.hydration.test.ts +142 -0
  32. package/src/modules/data/data.rebind.test.ts +147 -0
  33. package/src/modules/data/data.run.test.ts +113 -0
  34. package/src/modules/data/data.status.test.ts +249 -0
  35. package/src/modules/data/index.ts +1151 -129
  36. package/src/modules/data/mutation-id.test.ts +25 -0
  37. package/src/modules/data/mutation-id.ts +35 -0
  38. package/src/modules/data/window-query.test.ts +52 -0
  39. package/src/modules/data/window-query.ts +154 -0
  40. package/src/modules/devtools/index.ts +325 -37
  41. package/src/modules/devtools/notify-throttle.test.ts +149 -0
  42. package/src/modules/devtools/storage-info.test.ts +79 -0
  43. package/src/modules/devtools/storage-info.ts +143 -0
  44. package/src/modules/devtools/versions.test.ts +74 -0
  45. package/src/modules/devtools/versions.ts +81 -0
  46. package/src/modules/feature-flag/index.test.ts +120 -0
  47. package/src/modules/feature-flag/index.ts +209 -0
  48. package/src/modules/ref-tables.test.ts +91 -0
  49. package/src/modules/ref-tables.ts +88 -0
  50. package/src/modules/sync/engine.ts +101 -37
  51. package/src/modules/sync/events/index.ts +9 -2
  52. package/src/modules/sync/queue/queue-down.ts +12 -5
  53. package/src/modules/sync/queue/queue-up.forwarded.test.ts +164 -0
  54. package/src/modules/sync/queue/queue-up.ts +216 -56
  55. package/src/modules/sync/scheduler.pause.test.ts +109 -0
  56. package/src/modules/sync/scheduler.ts +77 -9
  57. package/src/modules/sync/sync.health.test.ts +149 -0
  58. package/src/modules/sync/sync.subquery.test.ts +82 -0
  59. package/src/modules/sync/sync.ts +1333 -92
  60. package/src/modules/sync/utils.test.ts +269 -2
  61. package/src/modules/sync/utils.ts +182 -17
  62. package/src/otel/index.ts +127 -0
  63. package/src/services/database/cache-engine.ts +160 -0
  64. package/src/services/database/database.ts +11 -11
  65. package/src/services/database/engine-factory.ts +33 -0
  66. package/src/services/database/events/index.ts +2 -1
  67. package/src/services/database/index.ts +6 -0
  68. package/src/services/database/local-migrator.ts +28 -27
  69. package/src/services/database/local.test.ts +64 -0
  70. package/src/services/database/local.ts +478 -67
  71. package/src/services/database/plan-render.test.ts +159 -0
  72. package/src/services/database/plan-render.ts +108 -0
  73. package/src/services/database/relation-resolver.test.ts +413 -0
  74. package/src/services/database/relation-resolver.ts +0 -0
  75. package/src/services/database/remote.ts +13 -13
  76. package/src/services/database/sqlite-cache-engine.test.ts +464 -0
  77. package/src/services/database/sqlite-cache-engine.ts +1154 -0
  78. package/src/services/database/sqlite-open.test.ts +150 -0
  79. package/src/services/database/sqlite-open.ts +164 -0
  80. package/src/services/database/sqlite-plan-sql.test.ts +104 -0
  81. package/src/services/database/sqlite-plan-sql.ts +106 -0
  82. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  83. package/src/services/database/sqlite-select.test.ts +246 -0
  84. package/src/services/database/sqlite-select.ts +113 -0
  85. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  86. package/src/services/database/sqlite-transport.ts +219 -0
  87. package/src/services/database/sqlite-worker.ts +437 -0
  88. package/src/services/database/surql-translate.ts +291 -0
  89. package/src/services/database/surreal-cache-engine.ts +141 -0
  90. package/src/services/logger/index.ts +6 -109
  91. package/src/services/persistence/localstorage.ts +2 -2
  92. package/src/services/persistence/resilient.ts +11 -4
  93. package/src/services/persistence/surrealdb.ts +10 -10
  94. package/src/services/stream-processor/index.ts +444 -52
  95. package/src/services/stream-processor/permissions.test.ts +47 -0
  96. package/src/services/stream-processor/permissions.ts +53 -0
  97. package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
  98. package/src/services/stream-processor/stream-processor.reset.test.ts +216 -0
  99. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  100. package/src/services/stream-processor/wasm-types.ts +23 -2
  101. package/src/services/tabs/broker-client.ts +283 -0
  102. package/src/services/tabs/broker.test.ts +278 -0
  103. package/src/services/tabs/coordinator.test.ts +192 -0
  104. package/src/services/tabs/coordinator.ts +567 -0
  105. package/src/services/tabs/fake-ports.fixture.ts +70 -0
  106. package/src/services/tabs/leader-locks.ts +75 -0
  107. package/src/services/tabs/protocol.ts +239 -0
  108. package/src/services/tabs/support.ts +36 -0
  109. package/src/services/tabs/tabs-broker-worker.ts +581 -0
  110. package/src/sp00ky.auth-order.test.ts +92 -0
  111. package/src/sp00ky.init-query.test.ts +183 -0
  112. package/src/sp00ky.ts +1225 -0
  113. package/src/types.ts +342 -15
  114. package/src/utils/error-classification.test.ts +44 -0
  115. package/src/utils/error-classification.ts +7 -0
  116. package/src/utils/index.ts +35 -13
  117. package/src/utils/parser.ts +3 -2
  118. package/src/utils/semver.test.ts +32 -0
  119. package/src/utils/semver.ts +30 -0
  120. package/src/utils/surql.ts +30 -18
  121. package/src/utils/withRetry.test.ts +1 -1
  122. package/tsdown.config.ts +86 -1
  123. package/src/spooky.ts +0 -395
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Per-tab connection to the SharedWorker broker. Owns: the hello handshake,
3
+ * pong replies + the ping watchdog (a silent broker means the browser killed
4
+ * the SharedWorker; reconnect with a fresh one), visibility reporting, and the
5
+ * pagehide/freeze shutdown notifications. Role decisions and port handling are
6
+ * the coordinator's job; this class only surfaces them as events.
7
+ */
8
+ import type { Logger } from '../logger/index';
9
+ import {
10
+ PING_INTERVAL_MS,
11
+ PONG_TIMEOUT_MS,
12
+ type BrokerToTabMessage,
13
+ type HeldLeadership,
14
+ type TabId,
15
+ type TabToBrokerMessage,
16
+ type TabVisibility,
17
+ } from './protocol';
18
+
19
+ export interface BrokerClientEvents {
20
+ onBecomeLeader(msg: {
21
+ leadershipId: number;
22
+ forceTakeover: boolean;
23
+ allowMemoryFallback: boolean;
24
+ resumeHeld: boolean;
25
+ }): void;
26
+ onDemote(leadershipId: number): void;
27
+ onLeaderReady(leadershipId: number, leaderTabId: TabId): void;
28
+ /** Leader side: the broker minted ports for a follower. */
29
+ onAttachFollowerPorts(
30
+ followerTabId: TabId,
31
+ leadershipId: number,
32
+ dbPort: MessagePort,
33
+ syncPort: MessagePort
34
+ ): void;
35
+ /** Follower side: our ports into the current leader. */
36
+ onUseFollowerPorts(
37
+ leaderTabId: TabId,
38
+ leadershipId: number,
39
+ dbPort: MessagePort,
40
+ syncPort: MessagePort
41
+ ): void;
42
+ onCloseFollowerPorts(leadershipId: number): void;
43
+ onUnsupported(reason: string): void;
44
+ /** The broker restarted (instance id changed or pings stopped); the client
45
+ * already re-helloed. Roles survive only via become-leader/use-ports. */
46
+ onBrokerRestarted(): void;
47
+ }
48
+
49
+ /** What the tab reports about itself on (re)connect. */
50
+ export interface BrokerHello {
51
+ fingerprint: string;
52
+ bucketId: string;
53
+ heldLeadership: () => HeldLeadership | null;
54
+ }
55
+
56
+ export class TabBrokerClient {
57
+ private worker: SharedWorker | null = null;
58
+ private brokerInstanceId: string | null = null;
59
+ private watchdog: ReturnType<typeof setInterval> | null = null;
60
+ private lastPingAt = 0;
61
+ private closed = false;
62
+ private helloState: BrokerHello | null = null;
63
+
64
+ constructor(
65
+ private workerUrl: URL,
66
+ readonly tabId: TabId,
67
+ private events: BrokerClientEvents,
68
+ private logger: Logger
69
+ ) {}
70
+
71
+ /** Connect and hello. Resolves once broker-hello arrives (or rejects on
72
+ * timeout / SharedWorker error, in which case the caller goes solo). */
73
+ connect(hello: BrokerHello): Promise<void> {
74
+ this.helloState = hello;
75
+ this.installLifecycleListeners();
76
+ return this.openWorker();
77
+ }
78
+
79
+ private openWorker(): Promise<void> {
80
+ if (this.closed || !this.helloState) return Promise.reject(new Error('broker client closed'));
81
+ return new Promise<void>((resolve, reject) => {
82
+ let settled = false;
83
+ const settle = (fn: () => void) => {
84
+ if (!settled) {
85
+ settled = true;
86
+ fn();
87
+ }
88
+ };
89
+ let worker: SharedWorker;
90
+ try {
91
+ worker = new SharedWorker(this.workerUrl, { type: 'module', name: 'sp00ky-tabs-broker' });
92
+ } catch (e) {
93
+ reject(e);
94
+ return;
95
+ }
96
+ this.worker = worker;
97
+ const timeout = setTimeout(() => settle(() => reject(new Error('broker hello timeout'))), 5000);
98
+ worker.onerror = () => {
99
+ clearTimeout(timeout);
100
+ settle(() => reject(new Error('SharedWorker failed to start')));
101
+ };
102
+ worker.port.onmessage = (ev: MessageEvent) => {
103
+ const msg = ev.data as BrokerToTabMessage;
104
+ if (!msg || typeof msg !== 'object') return;
105
+ if (msg.type === 'broker-hello') {
106
+ clearTimeout(timeout);
107
+ this.noteInstance(msg.brokerInstanceId);
108
+ this.lastPingAt = Date.now();
109
+ this.startWatchdog();
110
+ settle(resolve);
111
+ return;
112
+ }
113
+ this.handleMessage(msg, (ev.ports ?? []) as readonly MessagePort[]);
114
+ };
115
+ worker.port.onmessageerror = () => this.reconnect('messageerror');
116
+ worker.port.start?.();
117
+ this.send({
118
+ type: 'hello',
119
+ tabId: this.tabId,
120
+ fingerprint: this.helloState!.fingerprint,
121
+ bucketId: this.helloState!.bucketId,
122
+ visibility: currentVisibility(),
123
+ heldLeadership: this.helloState!.heldLeadership(),
124
+ });
125
+ });
126
+ }
127
+
128
+ private noteInstance(id: string): void {
129
+ if (this.brokerInstanceId !== null && this.brokerInstanceId !== id) {
130
+ this.events.onBrokerRestarted();
131
+ }
132
+ this.brokerInstanceId = id;
133
+ }
134
+
135
+ private handleMessage(msg: BrokerToTabMessage, ports: readonly MessagePort[]): void {
136
+ // Any message from a NEW instance implies a restart happened while our
137
+ // watchdog had not fired yet.
138
+ if ('brokerInstanceId' in msg) this.noteInstance(msg.brokerInstanceId);
139
+ switch (msg.type) {
140
+ case 'ping':
141
+ this.lastPingAt = Date.now();
142
+ this.send({ type: 'pong', tabId: this.tabId });
143
+ break;
144
+ case 'become-leader':
145
+ this.events.onBecomeLeader({
146
+ leadershipId: msg.leadershipId,
147
+ forceTakeover: msg.forceTakeover,
148
+ allowMemoryFallback: msg.allowMemoryFallback,
149
+ resumeHeld: msg.resumeHeld,
150
+ });
151
+ break;
152
+ case 'demote':
153
+ this.events.onDemote(msg.leadershipId);
154
+ break;
155
+ case 'leader-ready':
156
+ this.events.onLeaderReady(msg.leadershipId, msg.leaderTabId);
157
+ break;
158
+ case 'attach-follower-ports':
159
+ if (ports.length >= 2) {
160
+ this.events.onAttachFollowerPorts(msg.followerTabId, msg.leadershipId, ports[0], ports[1]);
161
+ }
162
+ break;
163
+ case 'use-follower-ports':
164
+ if (ports.length >= 2) {
165
+ this.events.onUseFollowerPorts(msg.leaderTabId, msg.leadershipId, ports[0], ports[1]);
166
+ }
167
+ break;
168
+ case 'close-follower-ports':
169
+ this.events.onCloseFollowerPorts(msg.leadershipId);
170
+ break;
171
+ case 'unsupported':
172
+ this.logger.warn(
173
+ { reason: msg.reason, Category: 'sp00ky-client::BrokerClient' },
174
+ 'Broker rejected this tab; running solo'
175
+ );
176
+ this.events.onUnsupported(msg.reason);
177
+ this.close();
178
+ break;
179
+ }
180
+ }
181
+
182
+ send(msg: TabToBrokerMessage): void {
183
+ try {
184
+ this.worker?.port.postMessage(msg);
185
+ } catch {
186
+ /* dead port; watchdog reconnects */
187
+ }
188
+ }
189
+
190
+ /** The tab moved to a different bucket: re-hello under the new namespace.
191
+ * The broker treats a re-hello of a known tabId as leave + rejoin. */
192
+ rehello(hello: BrokerHello): Promise<void> {
193
+ this.helloState = hello;
194
+ if (!this.worker) return this.openWorker();
195
+ this.send({
196
+ type: 'hello',
197
+ tabId: this.tabId,
198
+ fingerprint: hello.fingerprint,
199
+ bucketId: hello.bucketId,
200
+ visibility: currentVisibility(),
201
+ heldLeadership: hello.heldLeadership(),
202
+ });
203
+ return Promise.resolve();
204
+ }
205
+
206
+ private startWatchdog(): void {
207
+ if (this.watchdog) return;
208
+ this.watchdog = setInterval(() => {
209
+ if (Date.now() - this.lastPingAt > PING_INTERVAL_MS + PONG_TIMEOUT_MS) {
210
+ this.reconnect('broker pings stopped');
211
+ }
212
+ }, PING_INTERVAL_MS);
213
+ }
214
+
215
+ private reconnect(reason: string): void {
216
+ if (this.closed) return;
217
+ this.logger.info(
218
+ { reason, Category: 'sp00ky-client::BrokerClient' },
219
+ 'Reconnecting to the tabs broker'
220
+ );
221
+ try {
222
+ this.worker?.port.close();
223
+ } catch {
224
+ /* ignore */
225
+ }
226
+ this.worker = null;
227
+ this.brokerInstanceId = null;
228
+ this.events.onBrokerRestarted();
229
+ void this.openWorker().catch((e) => {
230
+ this.logger.warn(
231
+ { err: e, Category: 'sp00ky-client::BrokerClient' },
232
+ 'Broker reconnect failed; staying detached until the next watchdog tick'
233
+ );
234
+ });
235
+ }
236
+
237
+ private installLifecycleListeners(): void {
238
+ if (typeof window === 'undefined' || typeof document === 'undefined') return;
239
+ document.addEventListener('visibilitychange', () => {
240
+ if (!this.helloState) return;
241
+ this.send({
242
+ type: 'visibility',
243
+ tabId: this.tabId,
244
+ bucketId: this.helloState.bucketId,
245
+ visibility: currentVisibility(),
246
+ });
247
+ });
248
+ // pagehide fires for both close and bfcache entry; treat both as a full
249
+ // departure (a bfcache restore re-hellos via pageshow below). freeze is
250
+ // Chromium's battery-saver signal; leaving proactively hands leadership
251
+ // over in ~0s instead of waiting out the pong timeout.
252
+ const leave = () => {
253
+ if (this.helloState) {
254
+ this.send({ type: 'shutdown', tabId: this.tabId, bucketId: this.helloState.bucketId });
255
+ }
256
+ };
257
+ window.addEventListener('pagehide', leave);
258
+ document.addEventListener('freeze', leave);
259
+ window.addEventListener('pageshow', (e: PageTransitionEvent) => {
260
+ if (e.persisted && this.helloState) void this.rehello(this.helloState);
261
+ });
262
+ document.addEventListener('resume', () => {
263
+ if (this.helloState) void this.rehello(this.helloState);
264
+ });
265
+ }
266
+
267
+ close(): void {
268
+ this.closed = true;
269
+ if (this.watchdog) clearInterval(this.watchdog);
270
+ this.watchdog = null;
271
+ try {
272
+ this.worker?.port.close();
273
+ } catch {
274
+ /* ignore */
275
+ }
276
+ this.worker = null;
277
+ }
278
+ }
279
+
280
+ function currentVisibility(): TabVisibility {
281
+ if (typeof document === 'undefined') return 'visible';
282
+ return document.visibilityState === 'hidden' ? 'hidden' : 'visible';
283
+ }
@@ -0,0 +1,278 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { handleConnect, __resetBrokerForTests } from './tabs-broker-worker';
3
+ import { fakeChannel, flush, FakePort } from './fake-ports.fixture';
4
+ import type { BrokerToTabMessage, TabToBrokerMessage } from './protocol';
5
+
6
+ /** A connected fake tab: records broker messages, exposes a send helper. */
7
+ interface TestTab {
8
+ tabId: string;
9
+ received: { msg: BrokerToTabMessage; ports: FakePort[] }[];
10
+ send: (msg: TabToBrokerMessage) => void;
11
+ port: FakePort;
12
+ /** Messages of one type, newest last. */
13
+ ofType: <T extends BrokerToTabMessage['type']>(
14
+ t: T
15
+ ) => Extract<BrokerToTabMessage, { type: T }>[];
16
+ }
17
+
18
+ function connectTab(tabId: string, opts: { bucket?: string; fingerprint?: string } = {}): TestTab {
19
+ const { port1, port2 } = fakeChannel();
20
+ handleConnect(port1 as unknown as MessagePort);
21
+ const received: TestTab['received'] = [];
22
+ port2.onmessage = (ev) => received.push({ msg: ev.data, ports: ev.ports });
23
+ const tab: TestTab = {
24
+ tabId,
25
+ received,
26
+ port: port2,
27
+ send: (msg) => port2.postMessage(msg),
28
+ ofType: (t) => received.filter((r) => r.msg.type === t).map((r) => r.msg) as any,
29
+ };
30
+ tab.send({
31
+ type: 'hello',
32
+ tabId,
33
+ fingerprint: opts.fingerprint ?? 'fp-test',
34
+ bucketId: opts.bucket ?? 'anon',
35
+ visibility: 'visible',
36
+ heldLeadership: null,
37
+ });
38
+ return tab;
39
+ }
40
+
41
+ /** Complete the promotion handshake for whichever tab got become-leader. */
42
+ async function ackLeadership(tab: TestTab, bucket = 'anon'): Promise<number> {
43
+ await flush();
44
+ const grants = tab.ofType('become-leader');
45
+ expect(grants.length).toBeGreaterThan(0);
46
+ const leadershipId = grants[grants.length - 1].leadershipId;
47
+ tab.send({ type: 'leader-ready', tabId: tab.tabId, bucketId: bucket, leadershipId });
48
+ await flush();
49
+ return leadershipId;
50
+ }
51
+
52
+ beforeEach(() => {
53
+ __resetBrokerForTests();
54
+ vi.useFakeTimers();
55
+ });
56
+ afterEach(() => {
57
+ __resetBrokerForTests();
58
+ vi.useRealTimers();
59
+ });
60
+
61
+ // The broker is the single arbiter of who owns the OPFS pool. These tests
62
+ // drive it with fake ports exactly like real tabs would.
63
+ describe('tabs broker: election', () => {
64
+ it('elects exactly one leader for the first tab and announces readiness', async () => {
65
+ const a = connectTab('tab-a');
66
+ await flush();
67
+ expect(a.ofType('broker-hello')).toHaveLength(1);
68
+ const id = await ackLeadership(a);
69
+ expect(a.ofType('leader-ready').map((m) => m.leaderTabId)).toContain('tab-a');
70
+ expect(id).toBeGreaterThan(0);
71
+ });
72
+
73
+ it('attaches a second tab as follower with a db+sync port pair', async () => {
74
+ const a = connectTab('tab-a');
75
+ await ackLeadership(a);
76
+ const b = connectTab('tab-b');
77
+ await flush();
78
+
79
+ // No second become-leader; instead ports flow both ways.
80
+ expect(b.ofType('become-leader')).toHaveLength(0);
81
+ const attach = a.received.filter((r) => r.msg.type === 'attach-follower-ports');
82
+ const use = b.received.filter((r) => r.msg.type === 'use-follower-ports');
83
+ expect(attach).toHaveLength(1);
84
+ expect(use).toHaveLength(1);
85
+ expect(attach[0].ports).toHaveLength(2);
86
+ expect(use[0].ports).toHaveLength(2);
87
+ // The two db ports are entangled ends of ONE channel: a message posted on
88
+ // the leader's end arrives on the follower's end. (The broker mints REAL
89
+ // node MessageChannels here; only the control-plane ports are fakes.)
90
+ const leaderDb = attach[0].ports[0] as unknown as MessagePort;
91
+ const followerDb = use[0].ports[0] as unknown as MessagePort;
92
+ const got = new Promise<unknown>((resolve) => {
93
+ followerDb.onmessage = (ev: MessageEvent) => resolve(ev.data);
94
+ });
95
+ leaderDb.postMessage('entangled?');
96
+ vi.useRealTimers();
97
+ await expect(got).resolves.toBe('entangled?');
98
+ leaderDb.close();
99
+ followerDb.close();
100
+ vi.useFakeTimers();
101
+ });
102
+
103
+ it('stops re-minting follower ports once the leader confirms attachment', async () => {
104
+ const a = connectTab('tab-a');
105
+ const leadershipId = await ackLeadership(a);
106
+ const b = connectTab('tab-b');
107
+ await flush();
108
+ a.send({
109
+ type: 'follower-port-attached',
110
+ tabId: 'tab-a',
111
+ bucketId: 'anon',
112
+ leadershipId,
113
+ followerTabId: 'tab-b',
114
+ });
115
+ await flush();
116
+ const before = b.ofType('use-follower-ports').length;
117
+ await vi.advanceTimersByTimeAsync(60_000);
118
+ expect(b.ofType('use-follower-ports').length).toBe(before);
119
+ });
120
+
121
+ it('re-mints follower ports with backoff while unconfirmed', async () => {
122
+ const a = connectTab('tab-a');
123
+ await ackLeadership(a);
124
+ const b = connectTab('tab-b');
125
+ await flush();
126
+ expect(b.ofType('use-follower-ports')).toHaveLength(1);
127
+ await vi.advanceTimersByTimeAsync(1000);
128
+ await flush();
129
+ expect(b.ofType('use-follower-ports')).toHaveLength(2);
130
+ await vi.advanceTimersByTimeAsync(2000);
131
+ await flush();
132
+ expect(b.ofType('use-follower-ports')).toHaveLength(3);
133
+ });
134
+
135
+ it('rejects a tab whose fingerprint differs from the first hello', async () => {
136
+ connectTab('tab-a', { fingerprint: 'fp-1' });
137
+ const b = connectTab('tab-b', { fingerprint: 'fp-2' });
138
+ await flush();
139
+ expect(b.ofType('unsupported').map((m) => m.reason)).toEqual(['fingerprint-mismatch']);
140
+ });
141
+
142
+ it('keeps leadership ids monotonic across handoffs', async () => {
143
+ const a = connectTab('tab-a');
144
+ const id1 = await ackLeadership(a);
145
+ const b = connectTab('tab-b');
146
+ await flush();
147
+ a.send({ type: 'shutdown', tabId: 'tab-a', bucketId: 'anon' });
148
+ await flush();
149
+ const id2 = await ackLeadership(b);
150
+ expect(id2).toBeGreaterThan(id1);
151
+ });
152
+ });
153
+
154
+ describe('tabs broker: failover', () => {
155
+ it('promotes the follower when the leader shuts down gracefully', async () => {
156
+ const a = connectTab('tab-a');
157
+ await ackLeadership(a);
158
+ const b = connectTab('tab-b');
159
+ await flush();
160
+ a.send({ type: 'shutdown', tabId: 'tab-a', bucketId: 'anon' });
161
+ await flush();
162
+ expect(b.ofType('become-leader').length).toBeGreaterThan(0);
163
+ });
164
+
165
+ it('evicts a leader that misses pongs and re-elects', async () => {
166
+ const a = connectTab('tab-a');
167
+ await ackLeadership(a);
168
+ const b = connectTab('tab-b');
169
+ await flush();
170
+ // b keeps answering pings, a goes silent.
171
+ b.port.onmessage = (ev) => {
172
+ b.received.push({ msg: ev.data, ports: ev.ports });
173
+ if (ev.data.type === 'ping') b.send({ type: 'pong', tabId: 'tab-b' });
174
+ };
175
+ await vi.advanceTimersByTimeAsync(21_000);
176
+ await flush();
177
+ expect(b.ofType('become-leader').length).toBeGreaterThan(0);
178
+ });
179
+
180
+ it('applies a failure backoff and grants allowMemoryFallback after 3 opfs cycles', async () => {
181
+ const a = connectTab('tab-a');
182
+ await flush();
183
+ for (let cycle = 0; cycle < 3; cycle++) {
184
+ const grants = a.ofType('become-leader');
185
+ const last = grants[grants.length - 1];
186
+ expect(last.allowMemoryFallback).toBe(false);
187
+ a.send({
188
+ type: 'leader-failed',
189
+ tabId: 'tab-a',
190
+ bucketId: 'anon',
191
+ leadershipId: last.leadershipId,
192
+ reason: 'opfs-unavailable: locked',
193
+ });
194
+ await flush();
195
+ // The 1s candidate backoff must pass before the next nomination.
196
+ await vi.advanceTimersByTimeAsync(1100);
197
+ await flush();
198
+ }
199
+ const grants = a.ofType('become-leader');
200
+ expect(grants[grants.length - 1].allowMemoryFallback).toBe(true);
201
+ });
202
+
203
+ it('demotes a stale leader-ready from a superseded promotion', async () => {
204
+ const a = connectTab('tab-a');
205
+ const staleId = await ackLeadership(a);
206
+ const b = connectTab('tab-b');
207
+ await flush();
208
+ a.send({ type: 'shutdown', tabId: 'tab-a', bucketId: 'anon' });
209
+ await flush();
210
+ await ackLeadership(b);
211
+ // The departed tab reconnects and replays its OLD leader-ready.
212
+ const a2 = connectTab('tab-a');
213
+ await flush();
214
+ a2.send({ type: 'leader-ready', tabId: 'tab-a', bucketId: 'anon', leadershipId: staleId });
215
+ await flush();
216
+ expect(a2.ofType('demote').map((m) => m.leadershipId)).toContain(staleId);
217
+ });
218
+
219
+ it('prefers a candidate that still holds a live worker (broker restart path)', async () => {
220
+ // Fresh broker instance, two tabs hello simultaneously; one held leadership
221
+ // from before the restart.
222
+ const a = connectTab('tab-a');
223
+ await flush();
224
+ const idA = await ackLeadership(a);
225
+ void idA;
226
+ __resetBrokerForTests();
227
+ const b2 = connectTab('tab-b');
228
+ void b2;
229
+ const { port1, port2 } = fakeChannel();
230
+ handleConnect(port1 as unknown as MessagePort);
231
+ const received: TestTab['received'] = [];
232
+ port2.onmessage = (ev) => received.push({ msg: ev.data, ports: ev.ports });
233
+ port2.postMessage({
234
+ type: 'hello',
235
+ tabId: 'tab-a',
236
+ fingerprint: 'fp-test',
237
+ bucketId: 'anon',
238
+ visibility: 'hidden',
239
+ heldLeadership: { leadershipId: 7, workerLockName: 'sp00ky-tabs:fp-test:anon:worker:7' },
240
+ });
241
+ await flush();
242
+ // Despite being hidden (b is visible), the holder wins with resumeHeld.
243
+ const grant = received.find((r) => r.msg.type === 'become-leader');
244
+ expect(grant).toBeDefined();
245
+ expect((grant!.msg as { resumeHeld: boolean }).resumeHeld).toBe(true);
246
+ });
247
+ });
248
+
249
+ describe('tabs broker: namespaces', () => {
250
+ it('separates tabs by bucket: one leader per bucket', async () => {
251
+ const a = connectTab('tab-a', { bucket: 'user1' });
252
+ await ackLeadership(a, 'user1');
253
+ const b = connectTab('tab-b', { bucket: 'user2' });
254
+ await flush();
255
+ expect(b.ofType('become-leader').length).toBeGreaterThan(0);
256
+ });
257
+
258
+ it('re-elects the old namespace immediately when a leader re-hellos into a new bucket', async () => {
259
+ const a = connectTab('tab-a');
260
+ await ackLeadership(a);
261
+ const b = connectTab('tab-b');
262
+ await flush();
263
+ // a switches buckets: same tabId, new bucket. b must be promoted in anon
264
+ // without waiting for a pong eviction.
265
+ a.send({
266
+ type: 'hello',
267
+ tabId: 'tab-a',
268
+ fingerprint: 'fp-test',
269
+ bucketId: 'user1',
270
+ visibility: 'visible',
271
+ heldLeadership: null,
272
+ });
273
+ await flush();
274
+ expect(b.ofType('become-leader').length).toBeGreaterThan(0);
275
+ // and a is offered leadership of the new bucket.
276
+ expect(a.ofType('become-leader').length).toBeGreaterThan(1);
277
+ });
278
+ });