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

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,633 @@
1
+ /**
2
+ * TabsCoordinator: the per-tab role state machine for shared-tabs mode. Sits
3
+ * between the broker client (election events, ports) and the rest of the
4
+ * client (engine + sync), which it drives exclusively through
5
+ * {@link CoordinatorHooks} so this module depends on neither concrete class.
6
+ *
7
+ * Roles:
8
+ * - leader: owns the sqlite worker (OPFS) and the sync loop; serves follower
9
+ * ports handed over by the broker.
10
+ * - follower: LocalStore ops go over dbPort into the leader's worker; sync
11
+ * concerns are forwarded over syncPort (see {@link SyncForwarder}).
12
+ * - solo: broker unavailable/rejected; exactly the pre-shared-tabs behavior.
13
+ *
14
+ * Bucket switches are namespace moves: `moveToBucket` re-hellos under the new
15
+ * bucketId and the ordinary election machinery assigns the new role there.
16
+ */
17
+ import type { Logger } from '../logger/index';
18
+ import type { StorageHealth } from '../../types';
19
+ import { TabBrokerClient } from './broker-client';
20
+ import { acquireLeaderTabLock, type LeaderLockHandle } from './leader-locks';
21
+ import {
22
+ tabLockName,
23
+ workerLockName,
24
+ type FollowerToLeaderMessage,
25
+ type IngestTuple,
26
+ type LeaderToFollowerMessage,
27
+ type TabId,
28
+ type TabRole,
29
+ } from './protocol';
30
+
31
+ /** How long boot waits for a role (election + attach) before going solo. */
32
+ const START_TIMEOUT_MS = 15_000;
33
+
34
+ export interface CoordinatorHooks {
35
+ /** Become the store owner: spawn the worker, open the pool under the given
36
+ * per-leadership lock. Returns the resulting health for db-ready relays.
37
+ * `resumeHeld` = keep the existing live worker (broker-restart path). */
38
+ adoptOwner(
39
+ bucketId: string,
40
+ opts: { workerLockName: string; allowMemoryFallback: boolean; forceTakeover: boolean; resumeHeld: boolean }
41
+ ): Promise<StorageHealth>;
42
+ /** Attach to a leader's worker through `dbPort`. */
43
+ adoptAttached(
44
+ dbPort: MessagePort,
45
+ snapshot: { bucketId: string; storageHealth: StorageHealth; leadershipId: number }
46
+ ): Promise<void>;
47
+ /** We were leader and got demoted (zombie thaw / stale promotion): tear the
48
+ * owned worker down so its SAH handles free up. */
49
+ releaseOwnership(): Promise<void>;
50
+ /** The leader (or its port) is gone; queue/park ops until a new role lands. */
51
+ onLeaderLost(reason: string): void;
52
+ /** Leader side: forward a follower's dbPort into the owned worker. */
53
+ exposeClientPort(clientId: string, port: MessagePort): Promise<void>;
54
+ removeClientPort(clientId: string): Promise<void>;
55
+ /** Sync layer role changes (implemented in the sync module). */
56
+ /** Synchronous leader WIRING. Must not block: the role is published and
57
+ * `leader-ready` is sent right after it returns. */
58
+ becomeSyncLeader(hub: LeaderSyncHub): void;
59
+ /** The blocking half of the leader handover (outbox drain, LIVE restart),
60
+ * run in the background once the tab is already a working leader. */
61
+ resumeSyncLeaderDuties(): Promise<void>;
62
+ becomeSyncFollower(forwarder: SyncForwarder): void;
63
+ becomeSyncSolo(): void;
64
+ /** Current storage health, for db-ready sent to late-joining followers. */
65
+ currentStorageHealth(): StorageHealth;
66
+ }
67
+
68
+ // ---- leader side: per-follower syncPort hub ---------------------------------
69
+
70
+ export interface FollowerChannel {
71
+ tabId: TabId;
72
+ send(msg: LeaderToFollowerMessage): void;
73
+ }
74
+
75
+ /** Leader-side fan-out surface handed to the sync layer. The sync router
76
+ * (modules/sync/tab-router.ts) registers itself as the message handler. */
77
+ export class LeaderSyncHub {
78
+ private followers = new Map<TabId, MessagePort>();
79
+ private seq = 0;
80
+ onFollowerMessage:
81
+ | ((tabId: TabId, msg: FollowerToLeaderMessage) => void)
82
+ | null = null;
83
+ onFollowerDetached: ((tabId: TabId) => void) | null = null;
84
+
85
+ constructor(
86
+ readonly leadershipId: number,
87
+ private logger: Logger
88
+ ) {}
89
+
90
+ attach(tabId: TabId, port: MessagePort): void {
91
+ this.detach(tabId);
92
+ this.followers.set(tabId, port);
93
+ port.onmessage = (ev: MessageEvent) => {
94
+ this.onFollowerMessage?.(tabId, ev.data as FollowerToLeaderMessage);
95
+ };
96
+ port.onmessageerror = () => this.detach(tabId);
97
+ port.start?.();
98
+ }
99
+
100
+ detach(tabId: TabId): void {
101
+ const port = this.followers.get(tabId);
102
+ if (!port) return;
103
+ this.followers.delete(tabId);
104
+ try {
105
+ port.close();
106
+ } catch {
107
+ /* ignore */
108
+ }
109
+ this.onFollowerDetached?.(tabId);
110
+ }
111
+
112
+ detachAll(): void {
113
+ for (const tabId of [...this.followers.keys()]) this.detach(tabId);
114
+ }
115
+
116
+ sendTo(tabId: TabId, msg: LeaderToFollowerMessage): void {
117
+ try {
118
+ this.followers.get(tabId)?.postMessage(msg);
119
+ } catch {
120
+ /* dead port; broker re-mints */
121
+ }
122
+ }
123
+
124
+ broadcast(msg: LeaderToFollowerMessage, exceptTabId?: TabId): void {
125
+ for (const [tabId, port] of this.followers) {
126
+ if (tabId === exceptTabId) continue;
127
+ try {
128
+ port.postMessage(msg);
129
+ } catch {
130
+ /* ignore */
131
+ }
132
+ }
133
+ }
134
+
135
+ /** Stamped ingest relay; seq lets followers detect gaps. */
136
+ relayIngest(tuples: IngestTuple[], exceptTabId?: TabId): void {
137
+ if (this.followers.size === 0) return;
138
+ this.broadcast(
139
+ { type: 'ingest-relay', tuples, leadershipId: this.leadershipId, seq: ++this.seq },
140
+ exceptTabId
141
+ );
142
+ }
143
+
144
+ get followerCount(): number {
145
+ return this.followers.size;
146
+ }
147
+
148
+ get relayedBatches(): number {
149
+ return this.seq;
150
+ }
151
+ }
152
+
153
+ // ---- follower side: syncPort forwarder ---------------------------------------
154
+
155
+ /** Follower half of the syncPort. Queues while detached (leaderless window)
156
+ * and flushes on rebind; a lost-in-flight mutation notify is additionally
157
+ * backstopped by the new leader reloading the shared outbox from the store. */
158
+ export class SyncForwarder {
159
+ private port: MessagePort | null = null;
160
+ private queued: FollowerToLeaderMessage[] = [];
161
+ onLeaderMessage: ((msg: LeaderToFollowerMessage) => void) | null = null;
162
+
163
+ constructor(private tabId: TabId) {}
164
+
165
+ rebind(port: MessagePort): void {
166
+ this.unbind();
167
+ this.port = port;
168
+ port.onmessage = (ev: MessageEvent) => {
169
+ this.onLeaderMessage?.(ev.data as LeaderToFollowerMessage);
170
+ };
171
+ port.start?.();
172
+ this.post({ type: 'sync-hello', tabId: this.tabId });
173
+ const backlog = this.queued;
174
+ this.queued = [];
175
+ for (const msg of backlog) this.post(msg);
176
+ }
177
+
178
+ unbind(): void {
179
+ if (this.port) {
180
+ try {
181
+ this.port.close();
182
+ } catch {
183
+ /* ignore */
184
+ }
185
+ }
186
+ this.port = null;
187
+ }
188
+
189
+ private post(msg: FollowerToLeaderMessage): void {
190
+ if (!this.port) {
191
+ this.queued.push(msg);
192
+ return;
193
+ }
194
+ try {
195
+ this.port.postMessage(msg);
196
+ } catch {
197
+ this.queued.push(msg);
198
+ }
199
+ }
200
+
201
+ mutationEnqueued(mutationId: string): void {
202
+ this.post({ type: 'mutation-enqueued', mutationId });
203
+ }
204
+ /** An optimistic write this tab just ingested. Deliberately NOT queued while
205
+ * detached: a new leader primes its circuit from the shared store, which
206
+ * already holds the row, and replaying a stale tuple at it later would put
207
+ * an older `_00_rv` in its version memo. */
208
+ ingest(tuples: IngestTuple[]): void {
209
+ if (!this.port) return;
210
+ try {
211
+ this.port.postMessage({ type: 'ingest', tuples });
212
+ } catch {
213
+ /* dead port; broker re-mints */
214
+ }
215
+ }
216
+ requestPoll(): void {
217
+ this.post({ type: 'request-poll' });
218
+ }
219
+ }
220
+
221
+ // ---- the coordinator ----------------------------------------------------------
222
+
223
+ export class TabsCoordinator {
224
+ role: TabRole = 'solo';
225
+ leadershipId = 0;
226
+ leaderTabId: TabId | null = null;
227
+ brokerRole: 'pending' | TabRole = 'pending';
228
+ /** Wall time from `become-leader` to `leader-ready` on the last promotion.
229
+ * Reported in the DevTools role-change event: a slow store adopt (OPFS pool
230
+ * contention after the previous leader died) shows up here as seconds. */
231
+ lastPromotionMs: number | null = null;
232
+ private fingerprint: string;
233
+ private bucketId: string;
234
+ private broker: TabBrokerClient;
235
+ private hub: LeaderSyncHub | null = null;
236
+ private forwarder: SyncForwarder | null = null;
237
+ private roleListeners = new Set<(role: TabRole) => void>();
238
+ private startResolve: ((role: TabRole) => void) | null = null;
239
+ private startReject: ((e: Error) => void) | null = null;
240
+ /** Ports received before start() resolves or between roles. */
241
+ private promotionChain: Promise<void> = Promise.resolve();
242
+ private closed = false;
243
+
244
+ constructor(
245
+ private deps: {
246
+ tabId: TabId;
247
+ fingerprint: string;
248
+ hooks: CoordinatorHooks;
249
+ logger: Logger;
250
+ /** Fired on pagehide while this tab leads: last-chance OPFS release. */
251
+ onLeaderPageHide?: () => void;
252
+ }
253
+ ) {
254
+ this.fingerprint = deps.fingerprint;
255
+ this.bucketId = 'anon';
256
+ // The URL is built HERE (same directory as the worker source) so the
257
+ // published flat bundle's rewritten './tabs-broker-worker.js' resolves at
258
+ // the dist top level, exactly like the sqlite worker URL does.
259
+ this.broker = new TabBrokerClient(
260
+ new URL('./tabs-broker-worker.ts', import.meta.url),
261
+ deps.tabId,
262
+ {
263
+ onBecomeLeader: (msg) => this.enqueue(() => this.promote(msg)),
264
+ onDemote: (leadershipId) => this.enqueue(() => this.demote(leadershipId)),
265
+ onLeaderReady: (leadershipId, leaderTabId) => {
266
+ this.leaderTabId = leaderTabId;
267
+ void leadershipId;
268
+ },
269
+ onAttachFollowerPorts: (followerTabId, leadershipId, dbPort, syncPort) =>
270
+ this.enqueue(() => this.serveFollower(followerTabId, leadershipId, dbPort, syncPort)),
271
+ onUseFollowerPorts: (leaderTabId, leadershipId, dbPort, syncPort) =>
272
+ this.enqueue(() => this.attachToLeader(leaderTabId, leadershipId, dbPort, syncPort)),
273
+ onCloseFollowerPorts: (leadershipId) =>
274
+ this.enqueue(() => this.handleLeaderGone(leadershipId)),
275
+ onUnsupported: () => this.fallbackToSolo('broker rejected this tab'),
276
+ onBrokerRestarted: () => {
277
+ // Direct MessageChannels survive a broker restart; roles get
278
+ // re-confirmed by the fresh election (heldLeadership fast path).
279
+ },
280
+ },
281
+ deps.logger
282
+ );
283
+ if (typeof window !== 'undefined' && deps.onLeaderPageHide) {
284
+ window.addEventListener('pagehide', () => {
285
+ if (this.role === 'leader') deps.onLeaderPageHide?.();
286
+ });
287
+ }
288
+ }
289
+
290
+ /** Serialize role transitions; each is small but async (worker opens). */
291
+ private enqueue(fn: () => Promise<void>): void {
292
+ this.promotionChain = this.promotionChain.then(fn, fn).catch((e) => {
293
+ this.deps.logger.error(
294
+ { err: e, Category: 'sp00ky-client::TabsCoordinator' },
295
+ 'Role transition failed'
296
+ );
297
+ });
298
+ }
299
+
300
+ onRoleChange(cb: (role: TabRole) => void): () => void {
301
+ this.roleListeners.add(cb);
302
+ return () => this.roleListeners.delete(cb);
303
+ }
304
+
305
+ private setRole(role: TabRole): void {
306
+ if (this.role !== role) {
307
+ this.role = role;
308
+ this.deps.logger.info(
309
+ {
310
+ role,
311
+ leadershipId: this.leadershipId,
312
+ promotionMs: role === 'leader' ? this.lastPromotionMs : undefined,
313
+ Category: 'sp00ky-client::TabsCoordinator',
314
+ },
315
+ 'Tab role changed'
316
+ );
317
+ for (const cb of this.roleListeners) cb(role);
318
+ }
319
+ if (this.startResolve && role !== 'solo') {
320
+ this.startResolve(role);
321
+ this.startResolve = null;
322
+ }
323
+ }
324
+
325
+ /** Connect the broker and resolve once this tab has a usable store role.
326
+ * Rejects when no role lands in time; the caller then boots solo. */
327
+ start(bucketId: string): Promise<TabRole> {
328
+ this.bucketId = bucketId;
329
+ return new Promise<TabRole>((resolve, reject) => {
330
+ const timeout = setTimeout(() => {
331
+ this.startResolve = null;
332
+ this.startReject = null;
333
+ reject(new Error('shared-tabs: no role assigned in time'));
334
+ }, START_TIMEOUT_MS);
335
+ this.startResolve = (role) => {
336
+ clearTimeout(timeout);
337
+ this.startReject = null;
338
+ resolve(role);
339
+ };
340
+ this.startReject = (e) => {
341
+ clearTimeout(timeout);
342
+ this.startResolve = null;
343
+ reject(e);
344
+ };
345
+ this.broker
346
+ .connect({
347
+ fingerprint: this.fingerprint,
348
+ bucketId,
349
+ heldLeadership: () =>
350
+ this.role === 'leader'
351
+ ? {
352
+ leadershipId: this.leadershipId,
353
+ workerLockName: workerLockName(this.fingerprint, this.bucketId, this.leadershipId),
354
+ }
355
+ : null,
356
+ })
357
+ .catch((e) => {
358
+ this.startReject?.(e instanceof Error ? e : new Error(String(e)));
359
+ });
360
+ });
361
+ }
362
+
363
+ /** Bucket switch: leave the old namespace, join the new one. Resolves when
364
+ * a role lands in the new namespace. */
365
+ moveToBucket(bucketId: string): Promise<TabRole> {
366
+ return new Promise<TabRole>((resolve, reject) => {
367
+ this.enqueue(async () => {
368
+ // Tear down the old role locally; the broker's rehello handling evicts
369
+ // us from the old namespace and re-elects there.
370
+ if (this.role === 'leader') await this.teardownLeader();
371
+ else if (this.role === 'follower') this.teardownFollower('bucket switch');
372
+ this.bucketId = bucketId;
373
+ const timeout = setTimeout(() => {
374
+ this.startResolve = null;
375
+ this.startReject = null;
376
+ reject(new Error('shared-tabs: no role assigned after bucket switch'));
377
+ }, START_TIMEOUT_MS);
378
+ this.startResolve = (role) => {
379
+ clearTimeout(timeout);
380
+ this.startReject = null;
381
+ resolve(role);
382
+ };
383
+ this.startReject = (e) => {
384
+ clearTimeout(timeout);
385
+ this.startResolve = null;
386
+ reject(e);
387
+ };
388
+ await this.broker.rehello({
389
+ fingerprint: this.fingerprint,
390
+ bucketId,
391
+ heldLeadership: () => null,
392
+ });
393
+ });
394
+ });
395
+ }
396
+
397
+ // ---- transitions -----------------------------------------------------------
398
+
399
+ private async promote(msg: {
400
+ leadershipId: number;
401
+ forceTakeover: boolean;
402
+ allowMemoryFallback: boolean;
403
+ resumeHeld: boolean;
404
+ }): Promise<void> {
405
+ if (this.closed) return;
406
+ if (msg.leadershipId <= this.leadershipId && !msg.resumeHeld) return;
407
+ const previousRole = this.role;
408
+ const startedAt = Date.now();
409
+ try {
410
+ if (previousRole === 'follower') this.teardownFollower('promoted');
411
+ this.leadershipId = msg.leadershipId;
412
+ // The tab lock is the broker's CRASH detector: it queues a request on
413
+ // this name, and being granted means this tab died (locks release on tab
414
+ // death instantly, unlike the 15s pong timeout). Steal only when the
415
+ // broker said the previous holder is a frozen zombie.
416
+ const lock = await acquireLeaderTabLock(tabLockName(this.fingerprint, this.bucketId), {
417
+ steal: msg.forceTakeover,
418
+ });
419
+ if (!lock) throw new Error('leader tab lock unavailable');
420
+ this.tabLock?.release();
421
+ this.tabLock = lock;
422
+ lock.onLost(() => {
423
+ // Stolen from under us (we were presumed dead): resign.
424
+ this.enqueue(async () => {
425
+ if (this.leadershipId !== msg.leadershipId || this.role !== 'leader') return;
426
+ await this.teardownLeader();
427
+ this.deps.hooks.onLeaderLost('tab lock stolen');
428
+ });
429
+ });
430
+ const health = await this.deps.hooks.adoptOwner(this.bucketId, {
431
+ workerLockName: workerLockName(this.fingerprint, this.bucketId, msg.leadershipId),
432
+ allowMemoryFallback: msg.allowMemoryFallback,
433
+ forceTakeover: msg.forceTakeover,
434
+ resumeHeld: msg.resumeHeld,
435
+ });
436
+ void health;
437
+ this.hub = new LeaderSyncHub(msg.leadershipId, this.deps.logger);
438
+ // Owning the store IS being the leader: it unparks this tab's ops and is
439
+ // everything a follower's ports need. Publish the role and tell the
440
+ // broker BEFORE any sync work. Waiting on the outbox drain and the LIVE
441
+ // restart here once left a tab that owned the OPFS pool but never sent
442
+ // `leader-ready`, so the broker minted no follower ports and re-elected
443
+ // no one (`assignFollowerPorts` and `electIfNeeded` both bail on that
444
+ // state) and the whole namespace stayed leaderless until a reload. Those
445
+ // duties are best-effort anyway: the list_ref poll covers a missing LIVE.
446
+ this.deps.hooks.becomeSyncLeader(this.hub);
447
+ this.lastPromotionMs = Date.now() - startedAt;
448
+ this.setRole('leader');
449
+ this.broker.send({
450
+ type: 'leader-ready',
451
+ tabId: this.deps.tabId,
452
+ bucketId: this.bucketId,
453
+ leadershipId: msg.leadershipId,
454
+ });
455
+ // Off the promotion chain on purpose: a hang here must not block the
456
+ // next role transition (demote, bucket switch) for the life of the tab.
457
+ void this.deps.hooks.resumeSyncLeaderDuties().catch((e) => {
458
+ this.deps.logger.error(
459
+ { err: e, Category: 'sp00ky-client::TabsCoordinator' },
460
+ 'Leader duties failed after promotion'
461
+ );
462
+ });
463
+ } catch (e) {
464
+ const reason = e instanceof Error ? e.message : String(e);
465
+ this.deps.logger.error(
466
+ { err: e, Category: 'sp00ky-client::TabsCoordinator' },
467
+ 'Promotion failed'
468
+ );
469
+ this.hub?.detachAll();
470
+ this.hub = null;
471
+ // Give back everything this attempt claimed. The broker does NOT demote a
472
+ // tab whose promotion failed (leader-failed clears leadership without a
473
+ // demote), so nothing else ever frees these. The tab lock name is shared
474
+ // per namespace, so keeping it after failing to lead makes EVERY later
475
+ // election in this namespace fail with 'leader tab lock unavailable' —
476
+ // one OPFS-busy promotion would wedge the whole app into solo mode.
477
+ if (previousRole === 'leader') await this.deps.hooks.releaseOwnership();
478
+ this.tabLock?.release();
479
+ this.tabLock = null;
480
+ this.broker.send({
481
+ type: 'leader-failed',
482
+ tabId: this.deps.tabId,
483
+ bucketId: this.bucketId,
484
+ leadershipId: msg.leadershipId,
485
+ reason,
486
+ });
487
+ }
488
+ }
489
+
490
+ private async demote(leadershipId: number): Promise<void> {
491
+ if (this.role !== 'leader' || leadershipId !== this.leadershipId) return;
492
+ await this.teardownLeader();
493
+ this.deps.hooks.onLeaderLost('demoted');
494
+ // Stay roleless; the broker sends use-follower-ports (or become-leader)
495
+ // for whatever comes next.
496
+ }
497
+
498
+ private tabLock: LeaderLockHandle | null = null;
499
+
500
+ private async teardownLeader(): Promise<void> {
501
+ this.hub?.detachAll();
502
+ this.hub = null;
503
+ await this.deps.hooks.releaseOwnership();
504
+ this.tabLock?.release();
505
+ this.tabLock = null;
506
+ }
507
+
508
+ private teardownFollower(reason: string): void {
509
+ this.forwarder?.unbind();
510
+ this.deps.hooks.onLeaderLost(reason);
511
+ }
512
+
513
+ private async serveFollower(
514
+ followerTabId: TabId,
515
+ leadershipId: number,
516
+ dbPort: MessagePort,
517
+ syncPort: MessagePort
518
+ ): Promise<void> {
519
+ if (this.role !== 'leader' || leadershipId !== this.leadershipId || !this.hub) {
520
+ dbPort.close();
521
+ syncPort.close();
522
+ return;
523
+ }
524
+ await this.deps.hooks.exposeClientPort(followerTabId, dbPort);
525
+ this.hub.attach(followerTabId, syncPort);
526
+ this.hub.sendTo(followerTabId, {
527
+ type: 'db-ready',
528
+ leadershipId,
529
+ bucketId: this.bucketId,
530
+ storageHealth: this.deps.hooks.currentStorageHealth(),
531
+ });
532
+ this.broker.send({
533
+ type: 'follower-port-attached',
534
+ tabId: this.deps.tabId,
535
+ bucketId: this.bucketId,
536
+ leadershipId,
537
+ followerTabId,
538
+ });
539
+ }
540
+
541
+ private async attachToLeader(
542
+ leaderTabId: TabId,
543
+ leadershipId: number,
544
+ dbPort: MessagePort,
545
+ syncPort: MessagePort
546
+ ): Promise<void> {
547
+ if (this.closed || this.role === 'leader') {
548
+ dbPort.close();
549
+ syncPort.close();
550
+ return;
551
+ }
552
+ this.leaderTabId = leaderTabId;
553
+ this.leadershipId = leadershipId;
554
+ if (!this.forwarder) this.forwarder = new SyncForwarder(this.deps.tabId);
555
+ // db-ready arrives on the syncPort and carries the snapshot the engine
556
+ // needs; bind sync first, adopt the store on receipt.
557
+ const forwarder = this.forwarder;
558
+ await new Promise<void>((resolve) => {
559
+ let adopted = false;
560
+ let attached = false;
561
+ // Relay traffic that lands between `db-ready` and the sync hooks being
562
+ // installed (the store adopt is async). Held and replayed, not dropped:
563
+ // on a first attach there is no previous handler, so an ingest-relay or
564
+ // settled notice in that window used to vanish.
565
+ const pending: LeaderToFollowerMessage[] = [];
566
+ const previousHandler = forwarder.onLeaderMessage;
567
+ forwarder.onLeaderMessage = (msg) => {
568
+ if (msg.type === 'db-ready' && !adopted) {
569
+ adopted = true;
570
+ void this.deps.hooks
571
+ .adoptAttached(dbPort, {
572
+ bucketId: msg.bucketId,
573
+ storageHealth: msg.storageHealth,
574
+ leadershipId: msg.leadershipId,
575
+ })
576
+ .then(() => {
577
+ this.deps.hooks.becomeSyncFollower(forwarder);
578
+ attached = true;
579
+ const backlog = pending.splice(0);
580
+ for (const m of backlog) forwarder.onLeaderMessage?.(m);
581
+ this.setRole('follower');
582
+ resolve();
583
+ });
584
+ return;
585
+ }
586
+ if (!attached) {
587
+ pending.push(msg);
588
+ return;
589
+ }
590
+ previousHandler?.(msg);
591
+ };
592
+ forwarder.rebind(syncPort);
593
+ });
594
+ }
595
+
596
+ private async handleLeaderGone(leadershipId: number): Promise<void> {
597
+ if (this.role === 'leader') return;
598
+ if (leadershipId < this.leadershipId) return;
599
+ // Detached limbo: the engine parks ops via onLeaderLost and the role stays
600
+ // 'follower' (this tab is still in shared mode, just between leaders). The
601
+ // next use-follower-ports or become-leader resolves it either way.
602
+ this.teardownFollower('leader gone');
603
+ }
604
+
605
+ private fallbackToSolo(reason: string): void {
606
+ this.deps.logger.warn(
607
+ { reason, Category: 'sp00ky-client::TabsCoordinator' },
608
+ 'Shared-tabs unavailable; running solo'
609
+ );
610
+ this.deps.hooks.becomeSyncSolo();
611
+ this.setRole('solo');
612
+ // start() treats solo as a rejection so the caller boots the plain path.
613
+ this.startReject?.(new Error(`shared-tabs unavailable: ${reason}`));
614
+ }
615
+
616
+ get syncHub(): LeaderSyncHub | null {
617
+ return this.hub;
618
+ }
619
+ get syncForwarder(): SyncForwarder | null {
620
+ return this.forwarder;
621
+ }
622
+ get tabId(): TabId {
623
+ return this.deps.tabId;
624
+ }
625
+
626
+ async stop(): Promise<void> {
627
+ this.closed = true;
628
+ if (this.role === 'leader') await this.teardownLeader();
629
+ else this.forwarder?.unbind();
630
+ this.broker.send({ type: 'shutdown', tabId: this.deps.tabId, bucketId: this.bucketId });
631
+ this.broker.close();
632
+ }
633
+ }