@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.
- package/AGENTS.md +57 -0
- package/dist/index.d.ts +2514 -58
- package/dist/index.js +12561 -2449
- package/dist/otel/index.d.ts +2 -2
- package/dist/otel/index.js +6 -6
- package/dist/sqlite-open.js +303 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +439 -0
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +472 -0
- package/dist/types.d.ts +751 -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 +36 -2
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +201 -0
- package/src/modules/auth/auth.local-first.test.ts +101 -0
- package/src/modules/auth/events/index.ts +2 -1
- package/src/modules/auth/index.ts +127 -24
- package/src/modules/cache/cache.relay.test.ts +95 -0
- package/src/modules/cache/index.ts +163 -43
- 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 +523 -0
- package/src/modules/data/data.notify-table.test.ts +41 -0
- package/src/modules/data/data.pending-ids.test.ts +199 -0
- package/src/modules/data/data.rebind.test.ts +170 -0
- package/src/modules/data/data.rematerialize.test.ts +114 -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 +1815 -151
- 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 +450 -46
- package/src/modules/devtools/notify-throttle.test.ts +154 -0
- package/src/modules/devtools/state-shape.test.ts +146 -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 +164 -82
- package/src/modules/sync/events/index.ts +9 -2
- package/src/modules/sync/queue/queue-down.test.ts +180 -0
- package/src/modules/sync/queue/queue-down.ts +80 -13
- 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 +237 -0
- package/src/modules/sync/scheduler.ts +215 -13
- 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 +175 -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.tabs.test.ts +249 -0
- package/src/modules/sync/sync.ts +1726 -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 +193 -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 +41 -12
- package/src/services/database/engine-factory.ts +33 -0
- package/src/services/database/errors.ts +34 -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 +484 -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 +616 -0
- package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
- package/src/services/database/sqlite-cache-engine.ts +1358 -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-lock-verify.test.ts +33 -0
- package/src/services/database/sqlite-lock-verify.ts +45 -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 +138 -0
- package/src/services/database/sqlite-projection.test.ts +99 -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 +131 -0
- package/src/services/database/sqlite-transport.fixture.ts +30 -0
- package/src/services/database/sqlite-transport.ts +224 -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 +161 -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 +796 -84
- 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 +186 -0
- package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
- package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +59 -3
- package/src/services/tabs/broker-client.ts +283 -0
- package/src/services/tabs/broker.test.ts +327 -0
- package/src/services/tabs/coordinator.test.ts +365 -0
- package/src/services/tabs/coordinator.ts +633 -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 +258 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +640 -0
- package/src/sp00ky.auth-order.test.ts +92 -0
- package/src/sp00ky.init-query.test.ts +183 -0
- package/src/sp00ky.local-first.test.ts +60 -0
- package/src/sp00ky.ts +1693 -0
- package/src/types.ts +528 -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 +79 -13
- package/src/utils/parser.test.ts +49 -120
- package/src/utils/parser.ts +32 -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,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
|
+
}
|