@spooky-sync/core 0.0.1-canary.21 → 0.0.1-canary.210

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 (163) hide show
  1. package/AGENTS.md +57 -0
  2. package/dist/index.d.ts +2514 -58
  3. package/dist/index.js +12561 -2449
  4. package/dist/otel/index.d.ts +2 -2
  5. package/dist/otel/index.js +6 -6
  6. package/dist/sqlite-open.js +303 -0
  7. package/dist/sqlite-worker.d.ts +1 -0
  8. package/dist/sqlite-worker.js +439 -0
  9. package/dist/tabs-broker-worker.d.ts +8 -0
  10. package/dist/tabs-broker-worker.js +472 -0
  11. package/dist/types.d.ts +751 -11
  12. package/package.json +11 -7
  13. package/scripts/check-broker-bundle.mjs +33 -0
  14. package/skills/{spooky-core → sp00ky-core}/SKILL.md +12 -12
  15. package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
  16. package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
  17. package/src/bucket-blurhash.test.ts +148 -0
  18. package/src/build-globals.d.ts +12 -0
  19. package/src/events/events.test.ts +2 -1
  20. package/src/events/index.ts +3 -0
  21. package/src/index.ts +36 -2
  22. package/src/modules/app-release/index.test.ts +125 -0
  23. package/src/modules/app-release/index.ts +201 -0
  24. package/src/modules/auth/auth.local-first.test.ts +101 -0
  25. package/src/modules/auth/events/index.ts +2 -1
  26. package/src/modules/auth/index.ts +127 -24
  27. package/src/modules/cache/cache.relay.test.ts +95 -0
  28. package/src/modules/cache/index.ts +163 -43
  29. package/src/modules/cache/types.ts +2 -2
  30. package/src/modules/crdt/crdt-field.ts +294 -0
  31. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  32. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  33. package/src/modules/crdt/index.ts +463 -0
  34. package/src/modules/crdt/loro-loader.ts +25 -0
  35. package/src/modules/data/data.hydration.test.ts +142 -0
  36. package/src/modules/data/data.membership.test.ts +523 -0
  37. package/src/modules/data/data.notify-table.test.ts +41 -0
  38. package/src/modules/data/data.pending-ids.test.ts +199 -0
  39. package/src/modules/data/data.rebind.test.ts +170 -0
  40. package/src/modules/data/data.rematerialize.test.ts +114 -0
  41. package/src/modules/data/data.run.test.ts +113 -0
  42. package/src/modules/data/data.settled-writes.test.ts +206 -0
  43. package/src/modules/data/data.status.test.ts +249 -0
  44. package/src/modules/data/id-set-plan.test.ts +122 -0
  45. package/src/modules/data/index.ts +1815 -151
  46. package/src/modules/data/mutation-id.test.ts +25 -0
  47. package/src/modules/data/mutation-id.ts +35 -0
  48. package/src/modules/data/window-query.test.ts +52 -0
  49. package/src/modules/data/window-query.ts +194 -0
  50. package/src/modules/devtools/flags.ts +349 -0
  51. package/src/modules/devtools/index.ts +450 -46
  52. package/src/modules/devtools/notify-throttle.test.ts +154 -0
  53. package/src/modules/devtools/state-shape.test.ts +146 -0
  54. package/src/modules/devtools/storage-info.test.ts +79 -0
  55. package/src/modules/devtools/storage-info.ts +168 -0
  56. package/src/modules/devtools/versions.test.ts +74 -0
  57. package/src/modules/devtools/versions.ts +110 -0
  58. package/src/modules/feature-flag/index.test.ts +251 -0
  59. package/src/modules/feature-flag/index.ts +308 -0
  60. package/src/modules/ref-tables.test.ts +91 -0
  61. package/src/modules/ref-tables.ts +88 -0
  62. package/src/modules/sync/engine.ts +164 -82
  63. package/src/modules/sync/events/index.ts +9 -2
  64. package/src/modules/sync/queue/queue-down.test.ts +180 -0
  65. package/src/modules/sync/queue/queue-down.ts +80 -13
  66. package/src/modules/sync/queue/queue-up.forwarded.test.ts +164 -0
  67. package/src/modules/sync/queue/queue-up.ts +241 -57
  68. package/src/modules/sync/scheduler.pause.test.ts +109 -0
  69. package/src/modules/sync/scheduler.retry.test.ts +237 -0
  70. package/src/modules/sync/scheduler.ts +215 -13
  71. package/src/modules/sync/sync.cleanup.test.ts +116 -0
  72. package/src/modules/sync/sync.health.test.ts +149 -0
  73. package/src/modules/sync/sync.heartbeat.test.ts +80 -0
  74. package/src/modules/sync/sync.live-removal.test.ts +175 -0
  75. package/src/modules/sync/sync.reconnect.test.ts +145 -0
  76. package/src/modules/sync/sync.subquery.test.ts +82 -0
  77. package/src/modules/sync/sync.tabs.test.ts +249 -0
  78. package/src/modules/sync/sync.ts +1726 -99
  79. package/src/modules/sync/utils.test.ts +269 -2
  80. package/src/modules/sync/utils.ts +201 -17
  81. package/src/otel/index.ts +13 -10
  82. package/src/services/blobs/blob-cache.test.ts +359 -0
  83. package/src/services/blobs/blob-cache.ts +603 -0
  84. package/src/services/blobs/blob-manifest.ts +227 -0
  85. package/src/services/blobs/blob-store.test.ts +77 -0
  86. package/src/services/blobs/blob-store.ts +359 -0
  87. package/src/services/blobs/blob.fixture.ts +90 -0
  88. package/src/services/blobs/index.ts +70 -0
  89. package/src/services/database/cache-engine.ts +193 -0
  90. package/src/services/database/connection-supervisor.test.ts +289 -0
  91. package/src/services/database/connection-supervisor.ts +415 -0
  92. package/src/services/database/database.query-timeout.test.ts +83 -0
  93. package/src/services/database/database.ts +41 -12
  94. package/src/services/database/engine-factory.ts +33 -0
  95. package/src/services/database/errors.ts +34 -0
  96. package/src/services/database/events/index.ts +2 -1
  97. package/src/services/database/index.ts +7 -0
  98. package/src/services/database/local-migrator.ts +30 -27
  99. package/src/services/database/local.test.ts +64 -0
  100. package/src/services/database/local.ts +484 -67
  101. package/src/services/database/plan-render.test.ts +159 -0
  102. package/src/services/database/plan-render.ts +108 -0
  103. package/src/services/database/relation-resolver.test.ts +413 -0
  104. package/src/services/database/relation-resolver.ts +0 -0
  105. package/src/services/database/remote.ts +110 -14
  106. package/src/services/database/sqlite-cache-engine.test.ts +616 -0
  107. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  108. package/src/services/database/sqlite-cache-engine.ts +1358 -0
  109. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  110. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  111. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  112. package/src/services/database/sqlite-lock-verify.ts +45 -0
  113. package/src/services/database/sqlite-open.test.ts +150 -0
  114. package/src/services/database/sqlite-open.ts +164 -0
  115. package/src/services/database/sqlite-plan-sql.test.ts +104 -0
  116. package/src/services/database/sqlite-plan-sql.ts +138 -0
  117. package/src/services/database/sqlite-projection.test.ts +99 -0
  118. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  119. package/src/services/database/sqlite-select.test.ts +246 -0
  120. package/src/services/database/sqlite-select.ts +131 -0
  121. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  122. package/src/services/database/sqlite-transport.ts +224 -0
  123. package/src/services/database/sqlite-worker.ts +437 -0
  124. package/src/services/database/surql-translate.ts +416 -0
  125. package/src/services/database/surreal-cache-engine.ts +161 -0
  126. package/src/services/logger/index.ts +3 -2
  127. package/src/services/persistence/localstorage.ts +2 -2
  128. package/src/services/persistence/resilient.ts +11 -4
  129. package/src/services/persistence/surrealdb.ts +10 -10
  130. package/src/services/stream-processor/index.ts +796 -84
  131. package/src/services/stream-processor/permissions.test.ts +47 -0
  132. package/src/services/stream-processor/permissions.ts +53 -0
  133. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  134. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  135. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  136. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  137. package/src/services/stream-processor/wasm-types.ts +59 -3
  138. package/src/services/tabs/broker-client.ts +283 -0
  139. package/src/services/tabs/broker.test.ts +327 -0
  140. package/src/services/tabs/coordinator.test.ts +365 -0
  141. package/src/services/tabs/coordinator.ts +633 -0
  142. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  143. package/src/services/tabs/leader-locks.ts +75 -0
  144. package/src/services/tabs/protocol.ts +258 -0
  145. package/src/services/tabs/support.ts +36 -0
  146. package/src/services/tabs/tabs-broker-worker.ts +640 -0
  147. package/src/sp00ky.auth-order.test.ts +92 -0
  148. package/src/sp00ky.init-query.test.ts +183 -0
  149. package/src/sp00ky.local-first.test.ts +60 -0
  150. package/src/sp00ky.ts +1693 -0
  151. package/src/types.ts +528 -13
  152. package/src/utils/blurhash.ts +90 -0
  153. package/src/utils/error-classification.test.ts +44 -0
  154. package/src/utils/error-classification.ts +7 -0
  155. package/src/utils/index.ts +79 -13
  156. package/src/utils/parser.test.ts +49 -120
  157. package/src/utils/parser.ts +32 -2
  158. package/src/utils/semver.test.ts +32 -0
  159. package/src/utils/semver.ts +30 -0
  160. package/src/utils/surql.ts +30 -18
  161. package/src/utils/withRetry.test.ts +1 -1
  162. package/tsdown.config.ts +86 -1
  163. package/src/spooky.ts +0 -395
@@ -0,0 +1,327 @@
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('re-elects when a promoted tab never answers become-leader', async () => {
204
+ // The wedge this guards: a tab that hangs mid-promotion sends neither
205
+ // leader-ready nor leader-failed, and keeps answering pings, so without a
206
+ // deadline the namespace keeps a leader that is never `ready` - no follower
207
+ // ports are minted and no re-election ever runs.
208
+ const a = connectTab('tab-a');
209
+ const b = connectTab('tab-b');
210
+ await flush();
211
+ const grantedTo = a.ofType('become-leader').length > 0 ? a : b;
212
+ const other = grantedTo === a ? b : a;
213
+ expect(grantedTo.ofType('become-leader').length).toBe(1);
214
+ // Both tabs keep answering pings; the promoted one just never reports.
215
+ for (const t of [a, b]) {
216
+ t.port.onmessage = (ev) => {
217
+ t.received.push({ msg: ev.data, ports: ev.ports });
218
+ if (ev.data.type === 'ping') t.send({ type: 'pong', tabId: t.tabId });
219
+ };
220
+ }
221
+ await vi.advanceTimersByTimeAsync(21_000);
222
+ await flush();
223
+ // The other tab gets a shot, under a NEW leadership id.
224
+ const handover = other.ofType('become-leader');
225
+ expect(handover.length).toBeGreaterThan(0);
226
+ expect(handover[handover.length - 1].leadershipId).toBeGreaterThan(
227
+ grantedTo.ofType('become-leader')[0].leadershipId
228
+ );
229
+ });
230
+
231
+ it('stops the promotion deadline once leader-ready lands', async () => {
232
+ const a = connectTab('tab-a');
233
+ const id = await ackLeadership(a);
234
+ const b = connectTab('tab-b');
235
+ await flush();
236
+ a.port.onmessage = (ev) => {
237
+ a.received.push({ msg: ev.data, ports: ev.ports });
238
+ if (ev.data.type === 'ping') a.send({ type: 'pong', tabId: 'tab-a' });
239
+ };
240
+ b.port.onmessage = (ev) => {
241
+ b.received.push({ msg: ev.data, ports: ev.ports });
242
+ if (ev.data.type === 'ping') b.send({ type: 'pong', tabId: 'tab-b' });
243
+ };
244
+ await vi.advanceTimersByTimeAsync(30_000);
245
+ await flush();
246
+ // A confirmed leader is never torn down by the promotion deadline.
247
+ expect(a.ofType('demote')).toHaveLength(0);
248
+ expect(b.ofType('become-leader')).toHaveLength(0);
249
+ expect(a.ofType('become-leader')[0].leadershipId).toBe(id);
250
+ });
251
+
252
+ it('demotes a stale leader-ready from a superseded promotion', async () => {
253
+ const a = connectTab('tab-a');
254
+ const staleId = await ackLeadership(a);
255
+ const b = connectTab('tab-b');
256
+ await flush();
257
+ a.send({ type: 'shutdown', tabId: 'tab-a', bucketId: 'anon' });
258
+ await flush();
259
+ await ackLeadership(b);
260
+ // The departed tab reconnects and replays its OLD leader-ready.
261
+ const a2 = connectTab('tab-a');
262
+ await flush();
263
+ a2.send({ type: 'leader-ready', tabId: 'tab-a', bucketId: 'anon', leadershipId: staleId });
264
+ await flush();
265
+ expect(a2.ofType('demote').map((m) => m.leadershipId)).toContain(staleId);
266
+ });
267
+
268
+ it('prefers a candidate that still holds a live worker (broker restart path)', async () => {
269
+ // Fresh broker instance, two tabs hello simultaneously; one held leadership
270
+ // from before the restart.
271
+ const a = connectTab('tab-a');
272
+ await flush();
273
+ const idA = await ackLeadership(a);
274
+ void idA;
275
+ __resetBrokerForTests();
276
+ const b2 = connectTab('tab-b');
277
+ void b2;
278
+ const { port1, port2 } = fakeChannel();
279
+ handleConnect(port1 as unknown as MessagePort);
280
+ const received: TestTab['received'] = [];
281
+ port2.onmessage = (ev) => received.push({ msg: ev.data, ports: ev.ports });
282
+ port2.postMessage({
283
+ type: 'hello',
284
+ tabId: 'tab-a',
285
+ fingerprint: 'fp-test',
286
+ bucketId: 'anon',
287
+ visibility: 'hidden',
288
+ heldLeadership: { leadershipId: 7, workerLockName: 'sp00ky-tabs:fp-test:anon:worker:7' },
289
+ });
290
+ await flush();
291
+ // Despite being hidden (b is visible), the holder wins with resumeHeld.
292
+ const grant = received.find((r) => r.msg.type === 'become-leader');
293
+ expect(grant).toBeDefined();
294
+ expect((grant!.msg as { resumeHeld: boolean }).resumeHeld).toBe(true);
295
+ });
296
+ });
297
+
298
+ describe('tabs broker: namespaces', () => {
299
+ it('separates tabs by bucket: one leader per bucket', async () => {
300
+ const a = connectTab('tab-a', { bucket: 'user1' });
301
+ await ackLeadership(a, 'user1');
302
+ const b = connectTab('tab-b', { bucket: 'user2' });
303
+ await flush();
304
+ expect(b.ofType('become-leader').length).toBeGreaterThan(0);
305
+ });
306
+
307
+ it('re-elects the old namespace immediately when a leader re-hellos into a new bucket', async () => {
308
+ const a = connectTab('tab-a');
309
+ await ackLeadership(a);
310
+ const b = connectTab('tab-b');
311
+ await flush();
312
+ // a switches buckets: same tabId, new bucket. b must be promoted in anon
313
+ // without waiting for a pong eviction.
314
+ a.send({
315
+ type: 'hello',
316
+ tabId: 'tab-a',
317
+ fingerprint: 'fp-test',
318
+ bucketId: 'user1',
319
+ visibility: 'visible',
320
+ heldLeadership: null,
321
+ });
322
+ await flush();
323
+ expect(b.ofType('become-leader').length).toBeGreaterThan(0);
324
+ // and a is offered leadership of the new bucket.
325
+ expect(a.ofType('become-leader').length).toBeGreaterThan(1);
326
+ });
327
+ });
@@ -0,0 +1,365 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { handleConnect, __resetBrokerForTests } from './tabs-broker-worker';
3
+ import { fakeChannel, flush, installBrokerGlobals, installFakeLocks } from './fake-ports.fixture';
4
+ import {
5
+ TabsCoordinator,
6
+ SyncForwarder,
7
+ type CoordinatorHooks,
8
+ type LeaderSyncHub,
9
+ } from './coordinator';
10
+ import type { StorageHealth } from '../../types';
11
+ import type { LeaderToFollowerMessage } from './protocol';
12
+
13
+ function makeLogger(): any {
14
+ const noop = () => {};
15
+ const l: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
16
+ l.child = () => l;
17
+ return l;
18
+ }
19
+
20
+ interface HookLog {
21
+ adoptOwner: { bucketId: string; workerLockName: string; resumeHeld: boolean }[];
22
+ adoptAttached: { bucketId: string; storageHealth: StorageHealth }[];
23
+ released: number;
24
+ leaderLost: string[];
25
+ exposedPorts: string[];
26
+ hub: LeaderSyncHub | null;
27
+ forwarder: SyncForwarder | null;
28
+ leaderDuties: number;
29
+ }
30
+
31
+ function makeHooks(): { hooks: CoordinatorHooks; log: HookLog } {
32
+ const log: HookLog = {
33
+ adoptOwner: [],
34
+ adoptAttached: [],
35
+ released: 0,
36
+ leaderLost: [],
37
+ exposedPorts: [],
38
+ hub: null,
39
+ forwarder: null,
40
+ leaderDuties: 0,
41
+ };
42
+ const hooks: CoordinatorHooks = {
43
+ async adoptOwner(bucketId, opts) {
44
+ log.adoptOwner.push({ bucketId, workerLockName: opts.workerLockName, resumeHeld: opts.resumeHeld });
45
+ return { status: 'persistent', fallback: false, role: 'leader' };
46
+ },
47
+ async adoptAttached(_dbPort, snapshot) {
48
+ log.adoptAttached.push({ bucketId: snapshot.bucketId, storageHealth: snapshot.storageHealth });
49
+ },
50
+ async releaseOwnership() {
51
+ log.released++;
52
+ },
53
+ onLeaderLost(reason) {
54
+ log.leaderLost.push(reason);
55
+ },
56
+ async exposeClientPort(clientId) {
57
+ log.exposedPorts.push(clientId);
58
+ },
59
+ async removeClientPort() {},
60
+ becomeSyncLeader(hub) {
61
+ log.hub = hub;
62
+ },
63
+ async resumeSyncLeaderDuties() {
64
+ log.leaderDuties++;
65
+ },
66
+ becomeSyncFollower(forwarder) {
67
+ log.forwarder = forwarder;
68
+ },
69
+ becomeSyncSolo() {},
70
+ currentStorageHealth: () => ({ status: 'persistent', fallback: false, role: 'leader' }),
71
+ };
72
+ return { hooks, log };
73
+ }
74
+
75
+ function makeCoordinator(tabId: string, overrides: Partial<CoordinatorHooks> = {}) {
76
+ const { hooks, log } = makeHooks();
77
+ const coordinator = new TabsCoordinator({
78
+ tabId,
79
+ fingerprint: 'fp-coord-test',
80
+ hooks: { ...hooks, ...overrides },
81
+ logger: makeLogger(),
82
+ });
83
+ return { coordinator, log };
84
+ }
85
+
86
+ let restoreGlobals: () => void;
87
+ beforeEach(() => {
88
+ __resetBrokerForTests();
89
+ restoreGlobals = installBrokerGlobals(handleConnect);
90
+ });
91
+ afterEach(() => {
92
+ restoreGlobals();
93
+ __resetBrokerForTests();
94
+ });
95
+
96
+ // End-to-end role assignment: two real coordinators, the real broker module,
97
+ // fake SharedWorker/MessageChannel plumbing (node has no SharedWorker).
98
+ describe('TabsCoordinator integration', () => {
99
+ it('boots the first tab as leader with a per-leadership worker lock name', async () => {
100
+ const { coordinator, log } = makeCoordinator('tab-1');
101
+ const role = await coordinator.start('anon');
102
+ expect(role).toBe('leader');
103
+ expect(log.adoptOwner).toHaveLength(1);
104
+ expect(log.adoptOwner[0].bucketId).toBe('anon');
105
+ expect(log.adoptOwner[0].workerLockName).toMatch(/^sp00ky-tabs:fp-coord-test:anon:worker:\d+$/);
106
+ expect(log.hub).not.toBeNull();
107
+ await coordinator.stop();
108
+ });
109
+
110
+ it('attaches the second tab as follower with the leader-reported snapshot', async () => {
111
+ const a = makeCoordinator('tab-1');
112
+ await a.coordinator.start('anon');
113
+ const b = makeCoordinator('tab-2');
114
+ const role = await b.coordinator.start('anon');
115
+
116
+ expect(role).toBe('follower');
117
+ // Leader forwarded the follower's db port into its worker...
118
+ expect(a.log.exposedPorts).toEqual(['tab-2']);
119
+ // ...and the follower adopted the leader's store snapshot.
120
+ expect(b.log.adoptAttached).toHaveLength(1);
121
+ expect(b.log.adoptAttached[0].bucketId).toBe('anon');
122
+ expect(b.log.adoptAttached[0].storageHealth.status).toBe('persistent');
123
+ expect(b.log.forwarder).not.toBeNull();
124
+ await b.coordinator.stop();
125
+ await a.coordinator.stop();
126
+ });
127
+
128
+ it('relays hub messages to the follower forwarder (ingest fan-out)', async () => {
129
+ const a = makeCoordinator('tab-1');
130
+ await a.coordinator.start('anon');
131
+ const b = makeCoordinator('tab-2');
132
+ await b.coordinator.start('anon');
133
+
134
+ const seen: LeaderToFollowerMessage[] = [];
135
+ b.log.forwarder!.onLeaderMessage = (msg) => seen.push(msg);
136
+ a.log.hub!.relayIngest([{ table: 'game', op: 'CREATE', id: 'game:1', record: { id: 'game:1' } }]);
137
+ await new Promise((r) => setTimeout(r, 20));
138
+
139
+ const relay = seen.filter((m) => m.type === 'ingest-relay');
140
+ expect(relay).toHaveLength(1);
141
+ expect((relay[0] as { seq: number }).seq).toBe(1);
142
+ await b.coordinator.stop();
143
+ await a.coordinator.stop();
144
+ });
145
+
146
+ it('forwards follower messages to the leader hub (mutation notify)', async () => {
147
+ const a = makeCoordinator('tab-1');
148
+ await a.coordinator.start('anon');
149
+ const b = makeCoordinator('tab-2');
150
+ await b.coordinator.start('anon');
151
+
152
+ const seen: { tabId: string; msg: unknown }[] = [];
153
+ a.log.hub!.onFollowerMessage = (tabId, msg) => seen.push({ tabId, msg });
154
+ b.log.forwarder!.mutationEnqueued('_00_pending_mutations:0000000000123_0001_tab2');
155
+ await new Promise((r) => setTimeout(r, 20));
156
+
157
+ const notify = seen.filter((s) => (s.msg as { type: string }).type === 'mutation-enqueued');
158
+ expect(notify).toHaveLength(1);
159
+ expect(notify[0].tabId).toBe('tab-2');
160
+ await b.coordinator.stop();
161
+ await a.coordinator.stop();
162
+ });
163
+
164
+ // A follower's optimistic write must reach the leader (which ingests it and
165
+ // fans it out) in one hop. Before this, only the mutation id was forwarded
166
+ // and every other tab waited on the server round-trip + LIVE delivery.
167
+ it('forwards a follower ingest to the leader hub tagged with the origin tab', async () => {
168
+ const a = makeCoordinator('tab-1');
169
+ await a.coordinator.start('anon');
170
+ const b = makeCoordinator('tab-2');
171
+ await b.coordinator.start('anon');
172
+
173
+ const seen: { tabId: string; msg: unknown }[] = [];
174
+ a.log.hub!.onFollowerMessage = (tabId, msg) => seen.push({ tabId, msg });
175
+ const tuples = [{ table: 'game', op: 'CREATE' as const, id: 'game:9', record: { id: 'game:9' } }];
176
+ b.log.forwarder!.ingest(tuples);
177
+ await new Promise((r) => setTimeout(r, 20));
178
+
179
+ const ingest = seen.filter((s) => (s.msg as { type: string }).type === 'ingest');
180
+ expect(ingest).toHaveLength(1);
181
+ expect(ingest[0].tabId).toBe('tab-2');
182
+ expect((ingest[0].msg as { tuples: unknown }).tuples).toEqual(tuples);
183
+ await b.coordinator.stop();
184
+ await a.coordinator.stop();
185
+ });
186
+
187
+ it('fan-out with exceptTabId skips the origin follower and reaches the others', async () => {
188
+ const a = makeCoordinator('tab-1');
189
+ await a.coordinator.start('anon');
190
+ const b = makeCoordinator('tab-2');
191
+ await b.coordinator.start('anon');
192
+ const c = makeCoordinator('tab-3');
193
+ await c.coordinator.start('anon');
194
+
195
+ const seenB: LeaderToFollowerMessage[] = [];
196
+ const seenC: LeaderToFollowerMessage[] = [];
197
+ b.log.forwarder!.onLeaderMessage = (msg) => seenB.push(msg);
198
+ c.log.forwarder!.onLeaderMessage = (msg) => seenC.push(msg);
199
+ a.log.hub!.relayIngest(
200
+ [{ table: 'game', op: 'CREATE', id: 'game:9', record: { id: 'game:9' } }],
201
+ 'tab-2'
202
+ );
203
+ await new Promise((r) => setTimeout(r, 20));
204
+
205
+ expect(seenB.filter((m) => m.type === 'ingest-relay')).toHaveLength(0);
206
+ expect(seenC.filter((m) => m.type === 'ingest-relay')).toHaveLength(1);
207
+ await c.coordinator.stop();
208
+ await b.coordinator.stop();
209
+ await a.coordinator.stop();
210
+ });
211
+
212
+ // Unlike a mutation notify, an ingest is NOT queued across a leaderless
213
+ // window: the next leader primes its circuit from the shared store, which
214
+ // already holds the row, and a stale replay would put an older `_00_rv` in
215
+ // its version memo.
216
+ it('drops an ingest while detached instead of queueing it', async () => {
217
+ const forwarder = new SyncForwarder('tab-x');
218
+ forwarder.ingest([{ table: 'game', op: 'CREATE', id: 'game:1', record: { id: 'game:1' } }]);
219
+
220
+ const { port1, port2 } = fakeChannel();
221
+ const seen: { type: string }[] = [];
222
+ port2.onmessage = (ev) => {
223
+ seen.push(ev.data as { type: string });
224
+ };
225
+ forwarder.rebind(port1 as unknown as MessagePort);
226
+ await flush();
227
+
228
+ expect(seen.map((m) => m.type)).toEqual(['sync-hello']);
229
+ forwarder.unbind();
230
+ });
231
+
232
+ // Relay traffic that lands between `db-ready` and the sync hooks being
233
+ // installed (the store adopt is async) used to hit a null handler on a
234
+ // first attach and vanish.
235
+ it('buffers leader messages that land before the follower hooks are installed', async () => {
236
+ const a = makeCoordinator('tab-1');
237
+ await a.coordinator.start('anon');
238
+
239
+ let adoptStartedResolve!: () => void;
240
+ const adoptStartedP = new Promise<void>((r) => {
241
+ adoptStartedResolve = r;
242
+ });
243
+ let releaseAdopt!: () => void;
244
+ const adoptGate = new Promise<void>((r) => {
245
+ releaseAdopt = r;
246
+ });
247
+ const seen: LeaderToFollowerMessage[] = [];
248
+ const b = makeCoordinator('tab-2', {
249
+ async adoptAttached() {
250
+ adoptStartedResolve();
251
+ await adoptGate;
252
+ },
253
+ becomeSyncFollower(forwarder) {
254
+ forwarder.onLeaderMessage = (msg) => seen.push(msg);
255
+ },
256
+ });
257
+ const started = b.coordinator.start('anon');
258
+ await adoptStartedP;
259
+ a.log.hub!.broadcast({
260
+ type: 'mutation-settled',
261
+ mutationId: '_00_pending_mutations:1_0001_tab-1',
262
+ recordId: 'game:1',
263
+ eventType: 'create',
264
+ });
265
+ await new Promise((r) => setTimeout(r, 20));
266
+ expect(seen).toHaveLength(0);
267
+
268
+ releaseAdopt();
269
+ await started;
270
+ expect(seen.map((m) => m.type)).toEqual(['mutation-settled']);
271
+ await b.coordinator.stop();
272
+ await a.coordinator.stop();
273
+ });
274
+
275
+ it('promotes the follower when the leader stops (failover)', async () => {
276
+ const a = makeCoordinator('tab-1');
277
+ await a.coordinator.start('anon');
278
+ const b = makeCoordinator('tab-2');
279
+ await b.coordinator.start('anon');
280
+
281
+ const roles: string[] = [];
282
+ b.coordinator.onRoleChange((r) => roles.push(r));
283
+ await a.coordinator.stop();
284
+ // Election + promotion are async hops across the fake ports.
285
+ await new Promise((r) => setTimeout(r, 100));
286
+
287
+ expect(roles).toContain('leader');
288
+ expect(b.log.adoptOwner).toHaveLength(1);
289
+ // The new leadership got a HIGHER id, so a fresh worker lock name.
290
+ expect(b.log.adoptOwner[0].workerLockName).not.toBe(a.log.adoptOwner[0].workerLockName);
291
+ expect(b.log.hub).not.toBeNull();
292
+ await b.coordinator.stop();
293
+ });
294
+
295
+ // A failed promotion must give the leader TAB lock back. The name is shared
296
+ // per namespace, so a leaked one makes every later election fail with
297
+ // 'leader tab lock unavailable': all tabs time out in start(), boot solo, and
298
+ // then contend for the OPFS pool individually — one busy pool wedging the
299
+ // whole app into the pre-shared-tabs behavior it exists to replace.
300
+ describe('with Web Locks', () => {
301
+ let locks: ReturnType<typeof installFakeLocks>;
302
+ beforeEach(() => {
303
+ locks = installFakeLocks();
304
+ });
305
+ afterEach(() => locks.restore());
306
+
307
+ /** A tab whose store can never open. Resolves once it has failed a
308
+ * promotion, so tests don't wait out start()'s 15s timeout. */
309
+ function makeDoomedTab(tabId: string) {
310
+ let failed!: () => void;
311
+ const hasFailed = new Promise<void>((r) => {
312
+ failed = r;
313
+ });
314
+ const tab = makeCoordinator(tabId, {
315
+ async adoptOwner() {
316
+ queueMicrotask(failed);
317
+ throw new Error('opfs-unavailable: NoModificationAllowedError (after 10 attempts)');
318
+ },
319
+ });
320
+ // start() only settles on a role or the timeout; neither is the point.
321
+ void tab.coordinator.start('anon').catch(() => {});
322
+ return { ...tab, hasFailed };
323
+ }
324
+
325
+ it('releases the leader tab lock when adoptOwner throws', async () => {
326
+ const a = makeDoomedTab('tab-1');
327
+ await a.hasFailed;
328
+ // Well inside the 1s re-nomination backoff, so a held lock here is a leak
329
+ // and not the next attempt's legitimate acquisition.
330
+ await new Promise((r) => setTimeout(r, 50));
331
+ expect(locks.heldNames()).not.toContain('sp00ky-tabs:fp-coord-test:anon:tab');
332
+ await a.coordinator.stop();
333
+ });
334
+
335
+ it('lets a later tab lead after another tab failed to open the store', async () => {
336
+ const a = makeDoomedTab('tab-1');
337
+ await a.hasFailed;
338
+
339
+ const b = makeCoordinator('tab-2');
340
+ await expect(b.coordinator.start('anon')).resolves.toBe('leader');
341
+ expect(b.log.adoptOwner).toHaveLength(1);
342
+ await b.coordinator.stop();
343
+ await a.coordinator.stop();
344
+ });
345
+ });
346
+
347
+ it('moves buckets by leaving and rejoining: old namespace re-elects', async () => {
348
+ const a = makeCoordinator('tab-1');
349
+ await a.coordinator.start('anon');
350
+ const b = makeCoordinator('tab-2');
351
+ await b.coordinator.start('anon');
352
+
353
+ const rolesB: string[] = [];
354
+ b.coordinator.onRoleChange((r) => rolesB.push(r));
355
+ const newRole = await a.coordinator.moveToBucket('user1');
356
+
357
+ expect(newRole).toBe('leader');
358
+ expect(a.log.adoptOwner.map((o) => o.bucketId)).toEqual(['anon', 'user1']);
359
+ expect(a.log.released).toBeGreaterThan(0);
360
+ await new Promise((r) => setTimeout(r, 100));
361
+ expect(rolesB).toContain('leader');
362
+ await b.coordinator.stop();
363
+ await a.coordinator.stop();
364
+ });
365
+ });