@spooky-sync/core 0.0.1-canary.20 → 0.0.1-canary.201
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +57 -0
- package/dist/index.d.ts +2184 -54
- package/dist/index.js +11515 -2399
- package/dist/otel/index.d.ts +2 -2
- package/dist/otel/index.js +6 -6
- package/dist/sqlite-open.js +276 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +421 -0
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +434 -0
- package/dist/types.d.ts +688 -11
- package/package.json +11 -7
- package/scripts/check-broker-bundle.mjs +33 -0
- package/skills/{spooky-core → sp00ky-core}/SKILL.md +12 -12
- package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
- package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
- package/src/bucket-blurhash.test.ts +148 -0
- package/src/build-globals.d.ts +12 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +35 -2
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +201 -0
- package/src/modules/auth/events/index.ts +2 -1
- package/src/modules/auth/index.ts +59 -20
- package/src/modules/cache/index.ts +112 -32
- package/src/modules/cache/types.ts +2 -2
- package/src/modules/crdt/crdt-field.ts +294 -0
- package/src/modules/crdt/crdt-hydration.test.ts +210 -0
- package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
- package/src/modules/crdt/index.ts +463 -0
- package/src/modules/crdt/loro-loader.ts +25 -0
- package/src/modules/data/data.hydration.test.ts +142 -0
- package/src/modules/data/data.membership.test.ts +462 -0
- package/src/modules/data/data.rebind.test.ts +147 -0
- package/src/modules/data/data.run.test.ts +113 -0
- package/src/modules/data/data.settled-writes.test.ts +206 -0
- package/src/modules/data/data.status.test.ts +249 -0
- package/src/modules/data/id-set-plan.test.ts +122 -0
- package/src/modules/data/index.ts +1580 -130
- package/src/modules/data/mutation-id.test.ts +25 -0
- package/src/modules/data/mutation-id.ts +35 -0
- package/src/modules/data/window-query.test.ts +52 -0
- package/src/modules/data/window-query.ts +194 -0
- package/src/modules/devtools/flags.ts +349 -0
- package/src/modules/devtools/index.ts +386 -37
- package/src/modules/devtools/notify-throttle.test.ts +149 -0
- package/src/modules/devtools/storage-info.test.ts +79 -0
- package/src/modules/devtools/storage-info.ts +168 -0
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +110 -0
- package/src/modules/feature-flag/index.test.ts +251 -0
- package/src/modules/feature-flag/index.ts +308 -0
- package/src/modules/ref-tables.test.ts +91 -0
- package/src/modules/ref-tables.ts +88 -0
- package/src/modules/sync/engine.ts +101 -37
- package/src/modules/sync/events/index.ts +9 -2
- package/src/modules/sync/queue/queue-down.test.ts +107 -0
- package/src/modules/sync/queue/queue-down.ts +35 -6
- package/src/modules/sync/queue/queue-up.forwarded.test.ts +164 -0
- package/src/modules/sync/queue/queue-up.ts +241 -57
- package/src/modules/sync/scheduler.pause.test.ts +109 -0
- package/src/modules/sync/scheduler.retry.test.ts +156 -0
- package/src/modules/sync/scheduler.ts +158 -11
- package/src/modules/sync/sync.cleanup.test.ts +116 -0
- package/src/modules/sync/sync.health.test.ts +149 -0
- package/src/modules/sync/sync.heartbeat.test.ts +80 -0
- package/src/modules/sync/sync.live-removal.test.ts +134 -0
- package/src/modules/sync/sync.reconnect.test.ts +145 -0
- package/src/modules/sync/sync.subquery.test.ts +82 -0
- package/src/modules/sync/sync.ts +1558 -99
- package/src/modules/sync/utils.test.ts +269 -2
- package/src/modules/sync/utils.ts +201 -17
- package/src/otel/index.ts +13 -10
- package/src/services/blobs/blob-cache.test.ts +359 -0
- package/src/services/blobs/blob-cache.ts +603 -0
- package/src/services/blobs/blob-manifest.ts +227 -0
- package/src/services/blobs/blob-store.test.ts +77 -0
- package/src/services/blobs/blob-store.ts +359 -0
- package/src/services/blobs/blob.fixture.ts +90 -0
- package/src/services/blobs/index.ts +70 -0
- package/src/services/database/cache-engine.ts +160 -0
- package/src/services/database/connection-supervisor.test.ts +289 -0
- package/src/services/database/connection-supervisor.ts +415 -0
- package/src/services/database/database.query-timeout.test.ts +83 -0
- package/src/services/database/database.ts +32 -12
- package/src/services/database/engine-factory.ts +33 -0
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/index.ts +7 -0
- package/src/services/database/local-migrator.ts +30 -27
- package/src/services/database/local.test.ts +64 -0
- package/src/services/database/local.ts +478 -67
- package/src/services/database/plan-render.test.ts +159 -0
- package/src/services/database/plan-render.ts +108 -0
- package/src/services/database/relation-resolver.test.ts +413 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +110 -14
- package/src/services/database/sqlite-cache-engine.test.ts +558 -0
- package/src/services/database/sqlite-cache-engine.ts +1257 -0
- package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
- package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
- package/src/services/database/sqlite-open.test.ts +150 -0
- package/src/services/database/sqlite-open.ts +164 -0
- package/src/services/database/sqlite-plan-sql.test.ts +104 -0
- package/src/services/database/sqlite-plan-sql.ts +106 -0
- package/src/services/database/sqlite-select.integration.test.ts +185 -0
- package/src/services/database/sqlite-select.test.ts +246 -0
- package/src/services/database/sqlite-select.ts +121 -0
- package/src/services/database/sqlite-transport.fixture.ts +30 -0
- package/src/services/database/sqlite-transport.ts +221 -0
- package/src/services/database/sqlite-worker.ts +437 -0
- package/src/services/database/surql-translate.ts +416 -0
- package/src/services/database/surreal-cache-engine.ts +141 -0
- package/src/services/logger/index.ts +3 -2
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +11 -4
- package/src/services/persistence/surrealdb.ts +10 -10
- package/src/services/stream-processor/index.ts +444 -52
- package/src/services/stream-processor/permissions.test.ts +47 -0
- package/src/services/stream-processor/permissions.ts +53 -0
- package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
- package/src/services/stream-processor/stream-processor.reset.test.ts +216 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +23 -2
- package/src/services/tabs/broker-client.ts +283 -0
- package/src/services/tabs/broker.test.ts +278 -0
- package/src/services/tabs/coordinator.test.ts +244 -0
- package/src/services/tabs/coordinator.ts +576 -0
- package/src/services/tabs/fake-ports.fixture.ts +112 -0
- package/src/services/tabs/leader-locks.ts +75 -0
- package/src/services/tabs/protocol.ts +242 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +586 -0
- package/src/sp00ky.auth-order.test.ts +92 -0
- package/src/sp00ky.init-query.test.ts +183 -0
- package/src/sp00ky.ts +1543 -0
- package/src/types.ts +496 -13
- package/src/utils/blurhash.ts +90 -0
- package/src/utils/error-classification.test.ts +44 -0
- package/src/utils/error-classification.ts +7 -0
- package/src/utils/index.ts +73 -13
- package/src/utils/parser.ts +3 -2
- package/src/utils/semver.test.ts +32 -0
- package/src/utils/semver.ts +30 -0
- package/src/utils/surql.ts +30 -18
- package/src/utils/withRetry.test.ts +1 -1
- package/tsdown.config.ts +86 -1
- package/src/spooky.ts +0 -395
|
@@ -0,0 +1,576 @@
|
|
|
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
|
+
becomeSyncLeader(hub: LeaderSyncHub): Promise<void>;
|
|
57
|
+
becomeSyncFollower(forwarder: SyncForwarder): void;
|
|
58
|
+
becomeSyncSolo(): void;
|
|
59
|
+
/** Current storage health, for db-ready sent to late-joining followers. */
|
|
60
|
+
currentStorageHealth(): StorageHealth;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---- leader side: per-follower syncPort hub ---------------------------------
|
|
64
|
+
|
|
65
|
+
export interface FollowerChannel {
|
|
66
|
+
tabId: TabId;
|
|
67
|
+
send(msg: LeaderToFollowerMessage): void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Leader-side fan-out surface handed to the sync layer. The sync router
|
|
71
|
+
* (modules/sync/tab-router.ts) registers itself as the message handler. */
|
|
72
|
+
export class LeaderSyncHub {
|
|
73
|
+
private followers = new Map<TabId, MessagePort>();
|
|
74
|
+
private seq = 0;
|
|
75
|
+
onFollowerMessage:
|
|
76
|
+
| ((tabId: TabId, msg: FollowerToLeaderMessage) => void)
|
|
77
|
+
| null = null;
|
|
78
|
+
onFollowerDetached: ((tabId: TabId) => void) | null = null;
|
|
79
|
+
|
|
80
|
+
constructor(
|
|
81
|
+
readonly leadershipId: number,
|
|
82
|
+
private logger: Logger
|
|
83
|
+
) {}
|
|
84
|
+
|
|
85
|
+
attach(tabId: TabId, port: MessagePort): void {
|
|
86
|
+
this.detach(tabId);
|
|
87
|
+
this.followers.set(tabId, port);
|
|
88
|
+
port.onmessage = (ev: MessageEvent) => {
|
|
89
|
+
this.onFollowerMessage?.(tabId, ev.data as FollowerToLeaderMessage);
|
|
90
|
+
};
|
|
91
|
+
port.onmessageerror = () => this.detach(tabId);
|
|
92
|
+
port.start?.();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
detach(tabId: TabId): void {
|
|
96
|
+
const port = this.followers.get(tabId);
|
|
97
|
+
if (!port) return;
|
|
98
|
+
this.followers.delete(tabId);
|
|
99
|
+
try {
|
|
100
|
+
port.close();
|
|
101
|
+
} catch {
|
|
102
|
+
/* ignore */
|
|
103
|
+
}
|
|
104
|
+
this.onFollowerDetached?.(tabId);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
detachAll(): void {
|
|
108
|
+
for (const tabId of [...this.followers.keys()]) this.detach(tabId);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
sendTo(tabId: TabId, msg: LeaderToFollowerMessage): void {
|
|
112
|
+
try {
|
|
113
|
+
this.followers.get(tabId)?.postMessage(msg);
|
|
114
|
+
} catch {
|
|
115
|
+
/* dead port; broker re-mints */
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
broadcast(msg: LeaderToFollowerMessage, exceptTabId?: TabId): void {
|
|
120
|
+
for (const [tabId, port] of this.followers) {
|
|
121
|
+
if (tabId === exceptTabId) continue;
|
|
122
|
+
try {
|
|
123
|
+
port.postMessage(msg);
|
|
124
|
+
} catch {
|
|
125
|
+
/* ignore */
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Stamped ingest relay; seq lets followers detect gaps. */
|
|
131
|
+
relayIngest(tuples: IngestTuple[], exceptTabId?: TabId): void {
|
|
132
|
+
if (this.followers.size === 0) return;
|
|
133
|
+
this.broadcast(
|
|
134
|
+
{ type: 'ingest-relay', tuples, leadershipId: this.leadershipId, seq: ++this.seq },
|
|
135
|
+
exceptTabId
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
get followerCount(): number {
|
|
140
|
+
return this.followers.size;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
get relayedBatches(): number {
|
|
144
|
+
return this.seq;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---- follower side: syncPort forwarder ---------------------------------------
|
|
149
|
+
|
|
150
|
+
/** Follower half of the syncPort. Queues while detached (leaderless window)
|
|
151
|
+
* and flushes on rebind; a lost-in-flight mutation notify is additionally
|
|
152
|
+
* backstopped by the new leader reloading the shared outbox from the store. */
|
|
153
|
+
export class SyncForwarder {
|
|
154
|
+
private port: MessagePort | null = null;
|
|
155
|
+
private queued: FollowerToLeaderMessage[] = [];
|
|
156
|
+
onLeaderMessage: ((msg: LeaderToFollowerMessage) => void) | null = null;
|
|
157
|
+
|
|
158
|
+
constructor(private tabId: TabId) {}
|
|
159
|
+
|
|
160
|
+
rebind(port: MessagePort): void {
|
|
161
|
+
this.unbind();
|
|
162
|
+
this.port = port;
|
|
163
|
+
port.onmessage = (ev: MessageEvent) => {
|
|
164
|
+
this.onLeaderMessage?.(ev.data as LeaderToFollowerMessage);
|
|
165
|
+
};
|
|
166
|
+
port.start?.();
|
|
167
|
+
this.post({ type: 'sync-hello', tabId: this.tabId });
|
|
168
|
+
const backlog = this.queued;
|
|
169
|
+
this.queued = [];
|
|
170
|
+
for (const msg of backlog) this.post(msg);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
unbind(): void {
|
|
174
|
+
if (this.port) {
|
|
175
|
+
try {
|
|
176
|
+
this.port.close();
|
|
177
|
+
} catch {
|
|
178
|
+
/* ignore */
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
this.port = null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private post(msg: FollowerToLeaderMessage): void {
|
|
185
|
+
if (!this.port) {
|
|
186
|
+
this.queued.push(msg);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
this.port.postMessage(msg);
|
|
191
|
+
} catch {
|
|
192
|
+
this.queued.push(msg);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
mutationEnqueued(mutationId: string): void {
|
|
197
|
+
this.post({ type: 'mutation-enqueued', mutationId });
|
|
198
|
+
}
|
|
199
|
+
requestPoll(): void {
|
|
200
|
+
this.post({ type: 'request-poll' });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ---- the coordinator ----------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
export class TabsCoordinator {
|
|
207
|
+
role: TabRole = 'solo';
|
|
208
|
+
leadershipId = 0;
|
|
209
|
+
leaderTabId: TabId | null = null;
|
|
210
|
+
brokerRole: 'pending' | TabRole = 'pending';
|
|
211
|
+
private fingerprint: string;
|
|
212
|
+
private bucketId: string;
|
|
213
|
+
private broker: TabBrokerClient;
|
|
214
|
+
private hub: LeaderSyncHub | null = null;
|
|
215
|
+
private forwarder: SyncForwarder | null = null;
|
|
216
|
+
private roleListeners = new Set<(role: TabRole) => void>();
|
|
217
|
+
private startResolve: ((role: TabRole) => void) | null = null;
|
|
218
|
+
private startReject: ((e: Error) => void) | null = null;
|
|
219
|
+
/** Ports received before start() resolves or between roles. */
|
|
220
|
+
private promotionChain: Promise<void> = Promise.resolve();
|
|
221
|
+
private closed = false;
|
|
222
|
+
|
|
223
|
+
constructor(
|
|
224
|
+
private deps: {
|
|
225
|
+
tabId: TabId;
|
|
226
|
+
fingerprint: string;
|
|
227
|
+
hooks: CoordinatorHooks;
|
|
228
|
+
logger: Logger;
|
|
229
|
+
/** Fired on pagehide while this tab leads: last-chance OPFS release. */
|
|
230
|
+
onLeaderPageHide?: () => void;
|
|
231
|
+
}
|
|
232
|
+
) {
|
|
233
|
+
this.fingerprint = deps.fingerprint;
|
|
234
|
+
this.bucketId = 'anon';
|
|
235
|
+
// The URL is built HERE (same directory as the worker source) so the
|
|
236
|
+
// published flat bundle's rewritten './tabs-broker-worker.js' resolves at
|
|
237
|
+
// the dist top level, exactly like the sqlite worker URL does.
|
|
238
|
+
this.broker = new TabBrokerClient(
|
|
239
|
+
new URL('./tabs-broker-worker.ts', import.meta.url),
|
|
240
|
+
deps.tabId,
|
|
241
|
+
{
|
|
242
|
+
onBecomeLeader: (msg) => this.enqueue(() => this.promote(msg)),
|
|
243
|
+
onDemote: (leadershipId) => this.enqueue(() => this.demote(leadershipId)),
|
|
244
|
+
onLeaderReady: (leadershipId, leaderTabId) => {
|
|
245
|
+
this.leaderTabId = leaderTabId;
|
|
246
|
+
void leadershipId;
|
|
247
|
+
},
|
|
248
|
+
onAttachFollowerPorts: (followerTabId, leadershipId, dbPort, syncPort) =>
|
|
249
|
+
this.enqueue(() => this.serveFollower(followerTabId, leadershipId, dbPort, syncPort)),
|
|
250
|
+
onUseFollowerPorts: (leaderTabId, leadershipId, dbPort, syncPort) =>
|
|
251
|
+
this.enqueue(() => this.attachToLeader(leaderTabId, leadershipId, dbPort, syncPort)),
|
|
252
|
+
onCloseFollowerPorts: (leadershipId) =>
|
|
253
|
+
this.enqueue(() => this.handleLeaderGone(leadershipId)),
|
|
254
|
+
onUnsupported: () => this.fallbackToSolo('broker rejected this tab'),
|
|
255
|
+
onBrokerRestarted: () => {
|
|
256
|
+
// Direct MessageChannels survive a broker restart; roles get
|
|
257
|
+
// re-confirmed by the fresh election (heldLeadership fast path).
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
deps.logger
|
|
261
|
+
);
|
|
262
|
+
if (typeof window !== 'undefined' && deps.onLeaderPageHide) {
|
|
263
|
+
window.addEventListener('pagehide', () => {
|
|
264
|
+
if (this.role === 'leader') deps.onLeaderPageHide?.();
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Serialize role transitions; each is small but async (worker opens). */
|
|
270
|
+
private enqueue(fn: () => Promise<void>): void {
|
|
271
|
+
this.promotionChain = this.promotionChain.then(fn, fn).catch((e) => {
|
|
272
|
+
this.deps.logger.error(
|
|
273
|
+
{ err: e, Category: 'sp00ky-client::TabsCoordinator' },
|
|
274
|
+
'Role transition failed'
|
|
275
|
+
);
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
onRoleChange(cb: (role: TabRole) => void): () => void {
|
|
280
|
+
this.roleListeners.add(cb);
|
|
281
|
+
return () => this.roleListeners.delete(cb);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private setRole(role: TabRole): void {
|
|
285
|
+
if (this.role !== role) {
|
|
286
|
+
this.role = role;
|
|
287
|
+
this.deps.logger.info(
|
|
288
|
+
{ role, leadershipId: this.leadershipId, Category: 'sp00ky-client::TabsCoordinator' },
|
|
289
|
+
'Tab role changed'
|
|
290
|
+
);
|
|
291
|
+
for (const cb of this.roleListeners) cb(role);
|
|
292
|
+
}
|
|
293
|
+
if (this.startResolve && role !== 'solo') {
|
|
294
|
+
this.startResolve(role);
|
|
295
|
+
this.startResolve = null;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Connect the broker and resolve once this tab has a usable store role.
|
|
300
|
+
* Rejects when no role lands in time; the caller then boots solo. */
|
|
301
|
+
start(bucketId: string): Promise<TabRole> {
|
|
302
|
+
this.bucketId = bucketId;
|
|
303
|
+
return new Promise<TabRole>((resolve, reject) => {
|
|
304
|
+
const timeout = setTimeout(() => {
|
|
305
|
+
this.startResolve = null;
|
|
306
|
+
this.startReject = null;
|
|
307
|
+
reject(new Error('shared-tabs: no role assigned in time'));
|
|
308
|
+
}, START_TIMEOUT_MS);
|
|
309
|
+
this.startResolve = (role) => {
|
|
310
|
+
clearTimeout(timeout);
|
|
311
|
+
this.startReject = null;
|
|
312
|
+
resolve(role);
|
|
313
|
+
};
|
|
314
|
+
this.startReject = (e) => {
|
|
315
|
+
clearTimeout(timeout);
|
|
316
|
+
this.startResolve = null;
|
|
317
|
+
reject(e);
|
|
318
|
+
};
|
|
319
|
+
this.broker
|
|
320
|
+
.connect({
|
|
321
|
+
fingerprint: this.fingerprint,
|
|
322
|
+
bucketId,
|
|
323
|
+
heldLeadership: () =>
|
|
324
|
+
this.role === 'leader'
|
|
325
|
+
? {
|
|
326
|
+
leadershipId: this.leadershipId,
|
|
327
|
+
workerLockName: workerLockName(this.fingerprint, this.bucketId, this.leadershipId),
|
|
328
|
+
}
|
|
329
|
+
: null,
|
|
330
|
+
})
|
|
331
|
+
.catch((e) => {
|
|
332
|
+
this.startReject?.(e instanceof Error ? e : new Error(String(e)));
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Bucket switch: leave the old namespace, join the new one. Resolves when
|
|
338
|
+
* a role lands in the new namespace. */
|
|
339
|
+
moveToBucket(bucketId: string): Promise<TabRole> {
|
|
340
|
+
return new Promise<TabRole>((resolve, reject) => {
|
|
341
|
+
this.enqueue(async () => {
|
|
342
|
+
// Tear down the old role locally; the broker's rehello handling evicts
|
|
343
|
+
// us from the old namespace and re-elects there.
|
|
344
|
+
if (this.role === 'leader') await this.teardownLeader();
|
|
345
|
+
else if (this.role === 'follower') this.teardownFollower('bucket switch');
|
|
346
|
+
this.bucketId = bucketId;
|
|
347
|
+
const timeout = setTimeout(() => {
|
|
348
|
+
this.startResolve = null;
|
|
349
|
+
this.startReject = null;
|
|
350
|
+
reject(new Error('shared-tabs: no role assigned after bucket switch'));
|
|
351
|
+
}, START_TIMEOUT_MS);
|
|
352
|
+
this.startResolve = (role) => {
|
|
353
|
+
clearTimeout(timeout);
|
|
354
|
+
this.startReject = null;
|
|
355
|
+
resolve(role);
|
|
356
|
+
};
|
|
357
|
+
this.startReject = (e) => {
|
|
358
|
+
clearTimeout(timeout);
|
|
359
|
+
this.startResolve = null;
|
|
360
|
+
reject(e);
|
|
361
|
+
};
|
|
362
|
+
await this.broker.rehello({
|
|
363
|
+
fingerprint: this.fingerprint,
|
|
364
|
+
bucketId,
|
|
365
|
+
heldLeadership: () => null,
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ---- transitions -----------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
private async promote(msg: {
|
|
374
|
+
leadershipId: number;
|
|
375
|
+
forceTakeover: boolean;
|
|
376
|
+
allowMemoryFallback: boolean;
|
|
377
|
+
resumeHeld: boolean;
|
|
378
|
+
}): Promise<void> {
|
|
379
|
+
if (this.closed) return;
|
|
380
|
+
if (msg.leadershipId <= this.leadershipId && !msg.resumeHeld) return;
|
|
381
|
+
const previousRole = this.role;
|
|
382
|
+
try {
|
|
383
|
+
if (previousRole === 'follower') this.teardownFollower('promoted');
|
|
384
|
+
this.leadershipId = msg.leadershipId;
|
|
385
|
+
// The tab lock is the broker's CRASH detector: it queues a request on
|
|
386
|
+
// this name, and being granted means this tab died (locks release on tab
|
|
387
|
+
// death instantly, unlike the 15s pong timeout). Steal only when the
|
|
388
|
+
// broker said the previous holder is a frozen zombie.
|
|
389
|
+
const lock = await acquireLeaderTabLock(tabLockName(this.fingerprint, this.bucketId), {
|
|
390
|
+
steal: msg.forceTakeover,
|
|
391
|
+
});
|
|
392
|
+
if (!lock) throw new Error('leader tab lock unavailable');
|
|
393
|
+
this.tabLock?.release();
|
|
394
|
+
this.tabLock = lock;
|
|
395
|
+
lock.onLost(() => {
|
|
396
|
+
// Stolen from under us (we were presumed dead): resign.
|
|
397
|
+
this.enqueue(async () => {
|
|
398
|
+
if (this.leadershipId !== msg.leadershipId || this.role !== 'leader') return;
|
|
399
|
+
await this.teardownLeader();
|
|
400
|
+
this.deps.hooks.onLeaderLost('tab lock stolen');
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
const health = await this.deps.hooks.adoptOwner(this.bucketId, {
|
|
404
|
+
workerLockName: workerLockName(this.fingerprint, this.bucketId, msg.leadershipId),
|
|
405
|
+
allowMemoryFallback: msg.allowMemoryFallback,
|
|
406
|
+
forceTakeover: msg.forceTakeover,
|
|
407
|
+
resumeHeld: msg.resumeHeld,
|
|
408
|
+
});
|
|
409
|
+
void health;
|
|
410
|
+
this.hub = new LeaderSyncHub(msg.leadershipId, this.deps.logger);
|
|
411
|
+
await this.deps.hooks.becomeSyncLeader(this.hub);
|
|
412
|
+
this.setRole('leader');
|
|
413
|
+
this.broker.send({
|
|
414
|
+
type: 'leader-ready',
|
|
415
|
+
tabId: this.deps.tabId,
|
|
416
|
+
bucketId: this.bucketId,
|
|
417
|
+
leadershipId: msg.leadershipId,
|
|
418
|
+
});
|
|
419
|
+
} catch (e) {
|
|
420
|
+
const reason = e instanceof Error ? e.message : String(e);
|
|
421
|
+
this.deps.logger.error(
|
|
422
|
+
{ err: e, Category: 'sp00ky-client::TabsCoordinator' },
|
|
423
|
+
'Promotion failed'
|
|
424
|
+
);
|
|
425
|
+
this.hub?.detachAll();
|
|
426
|
+
this.hub = null;
|
|
427
|
+
// Give back everything this attempt claimed. The broker does NOT demote a
|
|
428
|
+
// tab whose promotion failed (leader-failed clears leadership without a
|
|
429
|
+
// demote), so nothing else ever frees these. The tab lock name is shared
|
|
430
|
+
// per namespace, so keeping it after failing to lead makes EVERY later
|
|
431
|
+
// election in this namespace fail with 'leader tab lock unavailable' —
|
|
432
|
+
// one OPFS-busy promotion would wedge the whole app into solo mode.
|
|
433
|
+
if (previousRole === 'leader') await this.deps.hooks.releaseOwnership();
|
|
434
|
+
this.tabLock?.release();
|
|
435
|
+
this.tabLock = null;
|
|
436
|
+
this.broker.send({
|
|
437
|
+
type: 'leader-failed',
|
|
438
|
+
tabId: this.deps.tabId,
|
|
439
|
+
bucketId: this.bucketId,
|
|
440
|
+
leadershipId: msg.leadershipId,
|
|
441
|
+
reason,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
private async demote(leadershipId: number): Promise<void> {
|
|
447
|
+
if (this.role !== 'leader' || leadershipId !== this.leadershipId) return;
|
|
448
|
+
await this.teardownLeader();
|
|
449
|
+
this.deps.hooks.onLeaderLost('demoted');
|
|
450
|
+
// Stay roleless; the broker sends use-follower-ports (or become-leader)
|
|
451
|
+
// for whatever comes next.
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
private tabLock: LeaderLockHandle | null = null;
|
|
455
|
+
|
|
456
|
+
private async teardownLeader(): Promise<void> {
|
|
457
|
+
this.hub?.detachAll();
|
|
458
|
+
this.hub = null;
|
|
459
|
+
await this.deps.hooks.releaseOwnership();
|
|
460
|
+
this.tabLock?.release();
|
|
461
|
+
this.tabLock = null;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
private teardownFollower(reason: string): void {
|
|
465
|
+
this.forwarder?.unbind();
|
|
466
|
+
this.deps.hooks.onLeaderLost(reason);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
private async serveFollower(
|
|
470
|
+
followerTabId: TabId,
|
|
471
|
+
leadershipId: number,
|
|
472
|
+
dbPort: MessagePort,
|
|
473
|
+
syncPort: MessagePort
|
|
474
|
+
): Promise<void> {
|
|
475
|
+
if (this.role !== 'leader' || leadershipId !== this.leadershipId || !this.hub) {
|
|
476
|
+
dbPort.close();
|
|
477
|
+
syncPort.close();
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
await this.deps.hooks.exposeClientPort(followerTabId, dbPort);
|
|
481
|
+
this.hub.attach(followerTabId, syncPort);
|
|
482
|
+
this.hub.sendTo(followerTabId, {
|
|
483
|
+
type: 'db-ready',
|
|
484
|
+
leadershipId,
|
|
485
|
+
bucketId: this.bucketId,
|
|
486
|
+
storageHealth: this.deps.hooks.currentStorageHealth(),
|
|
487
|
+
});
|
|
488
|
+
this.broker.send({
|
|
489
|
+
type: 'follower-port-attached',
|
|
490
|
+
tabId: this.deps.tabId,
|
|
491
|
+
bucketId: this.bucketId,
|
|
492
|
+
leadershipId,
|
|
493
|
+
followerTabId,
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
private async attachToLeader(
|
|
498
|
+
leaderTabId: TabId,
|
|
499
|
+
leadershipId: number,
|
|
500
|
+
dbPort: MessagePort,
|
|
501
|
+
syncPort: MessagePort
|
|
502
|
+
): Promise<void> {
|
|
503
|
+
if (this.closed || this.role === 'leader') {
|
|
504
|
+
dbPort.close();
|
|
505
|
+
syncPort.close();
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
this.leaderTabId = leaderTabId;
|
|
509
|
+
this.leadershipId = leadershipId;
|
|
510
|
+
if (!this.forwarder) this.forwarder = new SyncForwarder(this.deps.tabId);
|
|
511
|
+
// db-ready arrives on the syncPort and carries the snapshot the engine
|
|
512
|
+
// needs; bind sync first, adopt the store on receipt.
|
|
513
|
+
const forwarder = this.forwarder;
|
|
514
|
+
await new Promise<void>((resolve) => {
|
|
515
|
+
let adopted = false;
|
|
516
|
+
const previousHandler = forwarder.onLeaderMessage;
|
|
517
|
+
forwarder.onLeaderMessage = (msg) => {
|
|
518
|
+
if (msg.type === 'db-ready' && !adopted) {
|
|
519
|
+
adopted = true;
|
|
520
|
+
void this.deps.hooks
|
|
521
|
+
.adoptAttached(dbPort, {
|
|
522
|
+
bucketId: msg.bucketId,
|
|
523
|
+
storageHealth: msg.storageHealth,
|
|
524
|
+
leadershipId: msg.leadershipId,
|
|
525
|
+
})
|
|
526
|
+
.then(() => {
|
|
527
|
+
this.deps.hooks.becomeSyncFollower(forwarder);
|
|
528
|
+
this.setRole('follower');
|
|
529
|
+
resolve();
|
|
530
|
+
});
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
previousHandler?.(msg);
|
|
534
|
+
};
|
|
535
|
+
forwarder.rebind(syncPort);
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
private async handleLeaderGone(leadershipId: number): Promise<void> {
|
|
540
|
+
if (this.role === 'leader') return;
|
|
541
|
+
if (leadershipId < this.leadershipId) return;
|
|
542
|
+
// Detached limbo: the engine parks ops via onLeaderLost and the role stays
|
|
543
|
+
// 'follower' (this tab is still in shared mode, just between leaders). The
|
|
544
|
+
// next use-follower-ports or become-leader resolves it either way.
|
|
545
|
+
this.teardownFollower('leader gone');
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
private fallbackToSolo(reason: string): void {
|
|
549
|
+
this.deps.logger.warn(
|
|
550
|
+
{ reason, Category: 'sp00ky-client::TabsCoordinator' },
|
|
551
|
+
'Shared-tabs unavailable; running solo'
|
|
552
|
+
);
|
|
553
|
+
this.deps.hooks.becomeSyncSolo();
|
|
554
|
+
this.setRole('solo');
|
|
555
|
+
// start() treats solo as a rejection so the caller boots the plain path.
|
|
556
|
+
this.startReject?.(new Error(`shared-tabs unavailable: ${reason}`));
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
get syncHub(): LeaderSyncHub | null {
|
|
560
|
+
return this.hub;
|
|
561
|
+
}
|
|
562
|
+
get syncForwarder(): SyncForwarder | null {
|
|
563
|
+
return this.forwarder;
|
|
564
|
+
}
|
|
565
|
+
get tabId(): TabId {
|
|
566
|
+
return this.deps.tabId;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async stop(): Promise<void> {
|
|
570
|
+
this.closed = true;
|
|
571
|
+
if (this.role === 'leader') await this.teardownLeader();
|
|
572
|
+
else this.forwarder?.unbind();
|
|
573
|
+
this.broker.send({ type: 'shutdown', tabId: this.deps.tabId, bucketId: this.bucketId });
|
|
574
|
+
this.broker.close();
|
|
575
|
+
}
|
|
576
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test-only fakes for the tabs broker: entangled MessagePort pairs with
|
|
3
|
+
* microtask-async delivery (mimicking real ports), and a SharedWorker stand-in
|
|
4
|
+
* that connects straight into the broker module's handleConnect. Imported only
|
|
5
|
+
* by *.test.ts files.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export class FakePort {
|
|
9
|
+
other: FakePort | null = null;
|
|
10
|
+
onmessage: ((ev: { data: any; ports: FakePort[] }) => void) | null = null;
|
|
11
|
+
onmessageerror: ((ev?: unknown) => void) | null = null;
|
|
12
|
+
closed = false;
|
|
13
|
+
|
|
14
|
+
postMessage(data: any, transfer?: unknown[]): void {
|
|
15
|
+
const target = this.other;
|
|
16
|
+
if (!target || target.closed || this.closed) return;
|
|
17
|
+
queueMicrotask(() => {
|
|
18
|
+
if (!target.closed) target.onmessage?.({ data, ports: (transfer ?? []) as FakePort[] });
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
start(): void {}
|
|
23
|
+
|
|
24
|
+
close(): void {
|
|
25
|
+
this.closed = true;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function fakeChannel(): { port1: FakePort; port2: FakePort } {
|
|
30
|
+
const port1 = new FakePort();
|
|
31
|
+
const port2 = new FakePort();
|
|
32
|
+
port1.other = port2;
|
|
33
|
+
port2.other = port1;
|
|
34
|
+
return { port1, port2 };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Drain queued microtasks (message deliveries chain through several hops). */
|
|
38
|
+
export async function flush(times = 10): Promise<void> {
|
|
39
|
+
for (let i = 0; i < times; i++) await Promise.resolve();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Install a minimal exclusive-only `navigator.locks` on globalThis. Node has no
|
|
44
|
+
* Web Locks, and `acquireLeaderTabLock` treats a missing LockManager as "always
|
|
45
|
+
* granted", so without this the whole leader-tab-lock path is a no-op in tests
|
|
46
|
+
* and lock leaks are invisible. `ifAvailable` resolves null while held (what a
|
|
47
|
+
* losing tab sees), `steal` evicts the holder, and a plain request queues
|
|
48
|
+
* forever — the same shapes the real API produces.
|
|
49
|
+
*/
|
|
50
|
+
export function installFakeLocks(): {
|
|
51
|
+
restore: () => void;
|
|
52
|
+
heldNames: () => string[];
|
|
53
|
+
} {
|
|
54
|
+
const g = globalThis as Record<string, unknown>;
|
|
55
|
+
const previous = g.navigator;
|
|
56
|
+
const held = new Map<string, () => void>();
|
|
57
|
+
const locks = {
|
|
58
|
+
async request(name: string, opts: any, cb: any) {
|
|
59
|
+
if (opts?.steal) held.get(name)?.();
|
|
60
|
+
if (held.has(name) && !opts?.steal) {
|
|
61
|
+
if (opts?.ifAvailable) return cb(null);
|
|
62
|
+
return new Promise(() => {});
|
|
63
|
+
}
|
|
64
|
+
// The holder keeps the lock until the callback's promise settles (the
|
|
65
|
+
// real contract) or someone steals it out from under them.
|
|
66
|
+
let stolen!: () => void;
|
|
67
|
+
const stealSignal = new Promise<void>((r) => {
|
|
68
|
+
stolen = r as () => void;
|
|
69
|
+
});
|
|
70
|
+
held.set(name, stolen);
|
|
71
|
+
try {
|
|
72
|
+
await Promise.race([Promise.resolve(cb({ name, mode: 'exclusive' })), stealSignal]);
|
|
73
|
+
} finally {
|
|
74
|
+
held.delete(name);
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
const define = (value: unknown) =>
|
|
79
|
+
Object.defineProperty(g, 'navigator', { value, configurable: true, writable: true });
|
|
80
|
+
define({ ...(previous ?? {}), locks });
|
|
81
|
+
return { restore: () => define(previous), heldNames: () => [...held.keys()] };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Install `MessageChannel` + `SharedWorker` fakes on globalThis; the fake
|
|
85
|
+
* SharedWorker pipes its port into `handleConnect`. Returns a restore fn. */
|
|
86
|
+
export function installBrokerGlobals(handleConnect: (port: MessagePort) => void): () => void {
|
|
87
|
+
const g = globalThis as Record<string, unknown>;
|
|
88
|
+
const prevMC = g.MessageChannel;
|
|
89
|
+
const prevSW = g.SharedWorker;
|
|
90
|
+
g.MessageChannel = class {
|
|
91
|
+
port1: FakePort;
|
|
92
|
+
port2: FakePort;
|
|
93
|
+
constructor() {
|
|
94
|
+
const { port1, port2 } = fakeChannel();
|
|
95
|
+
this.port1 = port1;
|
|
96
|
+
this.port2 = port2;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
g.SharedWorker = class {
|
|
100
|
+
port: FakePort;
|
|
101
|
+
onerror: ((e: unknown) => void) | null = null;
|
|
102
|
+
constructor() {
|
|
103
|
+
const { port1, port2 } = fakeChannel();
|
|
104
|
+
this.port = port2;
|
|
105
|
+
handleConnect(port1 as unknown as MessagePort);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
return () => {
|
|
109
|
+
g.MessageChannel = prevMC;
|
|
110
|
+
g.SharedWorker = prevSW;
|
|
111
|
+
};
|
|
112
|
+
}
|