@loro-dev/flock-sqlite 0.8.0 → 0.9.1

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.
@@ -0,0 +1,468 @@
1
+ import { LruMap } from "./lru";
2
+ import {
3
+ isFlockChannelMessage,
4
+ serializeError,
5
+ type FlockCommit,
6
+ type FlockRpcRequest,
7
+ type FlockRpcResponse,
8
+ type FlockWriteRequest,
9
+ type RequestId,
10
+ type TabId,
11
+ } from "./multi-tab";
12
+ import type {
13
+ FlockRoleProvider,
14
+ FlockRuntime,
15
+ FlockTransport,
16
+ TimeoutHandle,
17
+ } from "./multi-tab-env";
18
+ import type { FlockSQLiteRole, RoleChangeListener } from "./types";
19
+
20
+ const DEFAULT_RPC_TIMEOUT_MS = 3000;
21
+ const MAX_REQUEST_CACHE = 1024;
22
+
23
+ export type WriteCoordinator = {
24
+ getRole(): FlockSQLiteRole;
25
+ isHost(): boolean;
26
+ subscribeRoleChange(listener: RoleChangeListener): () => void;
27
+ dispatchWriteRequest<T>(
28
+ payload: FlockWriteRequest,
29
+ ): Promise<{ commit: FlockCommit; result: T }>;
30
+ close(): void;
31
+ };
32
+
33
+ type CoordinatorOptions = {
34
+ runtime: FlockRuntime;
35
+ tabId: TabId;
36
+ transport: FlockTransport | undefined;
37
+ roleProvider: FlockRoleProvider;
38
+ ingestCommit: (commit: FlockCommit, bufferable: boolean) => void;
39
+ executeWriteRequest: (
40
+ payload: FlockWriteRequest,
41
+ origin: TabId,
42
+ requestId: RequestId,
43
+ ) => Promise<{ commit: FlockCommit; result: unknown }>;
44
+ };
45
+
46
+ function isRetryableForwardError(error: unknown): boolean {
47
+ if (!(error instanceof Error)) {
48
+ return false;
49
+ }
50
+ if (error.name === "HostLost") {
51
+ return true;
52
+ }
53
+ if (error.message.includes("Timed out waiting for host response")) {
54
+ return true;
55
+ }
56
+ return false;
57
+ }
58
+
59
+ function retryDelayMs(attempt: number): number {
60
+ const base = 25;
61
+ const max = 500;
62
+ if (attempt <= 0) {
63
+ return base;
64
+ }
65
+ const exp = Math.min(max, base * 2 ** (attempt - 1));
66
+ return exp;
67
+ }
68
+
69
+ function bufferableCommit(commit: FlockCommit, tabId: TabId): boolean {
70
+ return commit.origin === tabId && commit.source === "local";
71
+ }
72
+
73
+ function requestCacheKey(from: TabId, requestId: RequestId): string {
74
+ return `${from}|${requestId}`;
75
+ }
76
+
77
+ function deserializeRemoteError(serialized: {
78
+ name: string;
79
+ message: string;
80
+ stack?: string;
81
+ }): Error {
82
+ const error = new Error(serialized.message);
83
+ error.name = serialized.name;
84
+ if (serialized.stack) {
85
+ error.stack = serialized.stack;
86
+ }
87
+ return error;
88
+ }
89
+
90
+ class BaseCoordinator {
91
+ protected readonly runtime: FlockRuntime;
92
+ protected readonly tabId: TabId;
93
+ protected roleValue: FlockSQLiteRole;
94
+ protected readonly roleListeners: Set<RoleChangeListener>;
95
+ protected roleUnsubscribe: (() => void) | undefined;
96
+ protected closed: boolean;
97
+
98
+ constructor(runtime: FlockRuntime, tabId: TabId, roleProvider: FlockRoleProvider) {
99
+ this.runtime = runtime;
100
+ this.tabId = tabId;
101
+ this.roleValue = roleProvider.getRole();
102
+ this.roleListeners = new Set();
103
+ this.roleUnsubscribe = roleProvider.subscribeRoleChange?.((role) => {
104
+ this.setRole(role);
105
+ });
106
+ this.closed = false;
107
+ }
108
+
109
+ getRole(): FlockSQLiteRole {
110
+ return this.roleValue;
111
+ }
112
+
113
+ isHost(): boolean {
114
+ return this.roleValue === "host";
115
+ }
116
+
117
+ subscribeRoleChange(listener: RoleChangeListener): () => void {
118
+ this.roleListeners.add(listener);
119
+ try {
120
+ listener(this.roleValue);
121
+ } catch {
122
+ // Swallow listener errors to avoid breaking the CRDT/sync pipeline.
123
+ }
124
+ return () => {
125
+ this.roleListeners.delete(listener);
126
+ };
127
+ }
128
+
129
+ protected setRole(role: FlockSQLiteRole): void {
130
+ if (role === this.roleValue) {
131
+ return;
132
+ }
133
+ this.roleValue = role;
134
+ for (const listener of this.roleListeners) {
135
+ try {
136
+ listener(role);
137
+ } catch {
138
+ // Swallow listener errors to avoid breaking the CRDT/sync pipeline.
139
+ }
140
+ }
141
+ }
142
+
143
+ protected closeRole(): void {
144
+ this.roleUnsubscribe?.();
145
+ this.roleUnsubscribe = undefined;
146
+ this.setRole("unknown");
147
+ }
148
+ }
149
+
150
+ class DirectWriteCoordinator extends BaseCoordinator implements WriteCoordinator {
151
+ private writeQueue: Promise<void>;
152
+ private readonly ingestCommit: (commit: FlockCommit, bufferable: boolean) => void;
153
+ private readonly executeWriteRequest: (
154
+ payload: FlockWriteRequest,
155
+ origin: TabId,
156
+ requestId: RequestId,
157
+ ) => Promise<{ commit: FlockCommit; result: unknown }>;
158
+
159
+ constructor(options: CoordinatorOptions) {
160
+ super(options.runtime, options.tabId, options.roleProvider);
161
+ this.ingestCommit = options.ingestCommit;
162
+ this.executeWriteRequest = options.executeWriteRequest;
163
+ this.writeQueue = Promise.resolve();
164
+ }
165
+
166
+ private enqueueWrite<T>(task: () => Promise<T>): Promise<T> {
167
+ const scheduled = this.writeQueue.then(task, task);
168
+ this.writeQueue = scheduled.then(
169
+ () => undefined,
170
+ () => undefined,
171
+ );
172
+ return scheduled;
173
+ }
174
+
175
+ async dispatchWriteRequest<T>(
176
+ payload: FlockWriteRequest,
177
+ ): Promise<{ commit: FlockCommit; result: T }> {
178
+ if (this.closed) {
179
+ throw new Error("FlockSQLite is closed");
180
+ }
181
+ const requestId = this.runtime.randomUUID();
182
+ const { commit, result } = await this.enqueueWrite(() =>
183
+ this.executeWriteRequest(payload, this.tabId, requestId),
184
+ );
185
+ this.ingestCommit(commit, bufferableCommit(commit, this.tabId));
186
+ return { commit, result: result as T };
187
+ }
188
+
189
+ close(): void {
190
+ this.closed = true;
191
+ this.closeRole();
192
+ }
193
+ }
194
+
195
+ class MultiTabWriteCoordinator extends BaseCoordinator implements WriteCoordinator {
196
+ private readonly transport: FlockTransport;
197
+ private transportUnsubscribe: (() => void) | undefined;
198
+ private readonly pendingRequests: Map<
199
+ RequestId,
200
+ {
201
+ resolve: (response: FlockRpcResponse) => void;
202
+ reject: (error: unknown) => void;
203
+ timeoutId: TimeoutHandle;
204
+ }
205
+ >;
206
+ private readonly hostResponseCache: LruMap<string, FlockRpcResponse>;
207
+ private readonly hostInFlight: Map<string, Promise<FlockRpcResponse>>;
208
+ private writeQueue: Promise<void>;
209
+ private readonly ingestCommit: (commit: FlockCommit, bufferable: boolean) => void;
210
+ private readonly executeWriteRequest: (
211
+ payload: FlockWriteRequest,
212
+ origin: TabId,
213
+ requestId: RequestId,
214
+ ) => Promise<{ commit: FlockCommit; result: unknown }>;
215
+
216
+ constructor(options: CoordinatorOptions & { transport: FlockTransport }) {
217
+ super(options.runtime, options.tabId, options.roleProvider);
218
+ this.transport = options.transport;
219
+ this.transportUnsubscribe = this.transport.subscribe((message) => {
220
+ try {
221
+ this.handleTransportMessage(message);
222
+ } catch {
223
+ // ignore
224
+ }
225
+ });
226
+ this.pendingRequests = new Map();
227
+ this.hostResponseCache = new LruMap<string, FlockRpcResponse>(MAX_REQUEST_CACHE);
228
+ this.hostInFlight = new Map();
229
+ this.writeQueue = Promise.resolve();
230
+ this.ingestCommit = options.ingestCommit;
231
+ this.executeWriteRequest = options.executeWriteRequest;
232
+ }
233
+
234
+ private enqueueWrite<T>(task: () => Promise<T>): Promise<T> {
235
+ const scheduled = this.writeQueue.then(task, task);
236
+ this.writeQueue = scheduled.then(
237
+ () => undefined,
238
+ () => undefined,
239
+ );
240
+ return scheduled;
241
+ }
242
+
243
+ private shouldForwardWrites(): boolean {
244
+ return this.roleValue !== "host";
245
+ }
246
+
247
+ private handleTransportMessage(message: unknown): void {
248
+ if (!isFlockChannelMessage(message)) {
249
+ return;
250
+ }
251
+ if (message.t === "res") {
252
+ if (message.to !== this.tabId) {
253
+ return;
254
+ }
255
+ const pending = this.pendingRequests.get(message.id);
256
+ if (!pending) {
257
+ return;
258
+ }
259
+ this.runtime.clearTimeout(pending.timeoutId);
260
+ this.pendingRequests.delete(message.id);
261
+ pending.resolve(message);
262
+ return;
263
+ }
264
+
265
+ if (message.t === "commit") {
266
+ const commit = message.commit;
267
+ this.ingestCommit(commit, bufferableCommit(commit, this.tabId));
268
+ return;
269
+ }
270
+
271
+ if (message.t === "req") {
272
+ if (message.from === this.tabId) {
273
+ return;
274
+ }
275
+ if (this.roleValue !== "host" || this.closed) {
276
+ return;
277
+ }
278
+ void this.processHostRequest(message);
279
+ }
280
+ }
281
+
282
+ private broadcastCommit(commit: FlockCommit): void {
283
+ if (this.closed) {
284
+ return;
285
+ }
286
+ if (this.roleValue !== "host") {
287
+ return;
288
+ }
289
+ this.transport.postMessage({ t: "commit", commit });
290
+ }
291
+
292
+ private async forwardWriteRequest<T>(
293
+ payload: FlockWriteRequest,
294
+ requestId: RequestId,
295
+ timeoutMs = DEFAULT_RPC_TIMEOUT_MS,
296
+ ): Promise<{ commit: FlockCommit | undefined; result: T }> {
297
+ if (this.closed) {
298
+ throw new Error("FlockSQLite is closed");
299
+ }
300
+
301
+ const request: FlockRpcRequest = {
302
+ t: "req",
303
+ from: this.tabId,
304
+ id: requestId,
305
+ payload,
306
+ };
307
+
308
+ const response = await new Promise<FlockRpcResponse>((resolve, reject) => {
309
+ const timeoutId = this.runtime.setTimeout(() => {
310
+ this.pendingRequests.delete(requestId);
311
+ reject(new Error("Timed out waiting for host response"));
312
+ }, timeoutMs);
313
+
314
+ this.pendingRequests.set(requestId, { resolve, reject, timeoutId });
315
+ this.transport.postMessage(request);
316
+ });
317
+
318
+ if (!response.ok) {
319
+ if (response.error) {
320
+ throw deserializeRemoteError(response.error);
321
+ }
322
+ throw new Error("Host rejected request");
323
+ }
324
+
325
+ const commit = response.commit;
326
+ if (commit) {
327
+ this.ingestCommit(commit, bufferableCommit(commit, this.tabId));
328
+ }
329
+ return { commit, result: response.result as T };
330
+ }
331
+
332
+ async dispatchWriteRequest<T>(
333
+ payload: FlockWriteRequest,
334
+ ): Promise<{ commit: FlockCommit; result: T }> {
335
+ if (this.closed) {
336
+ throw new Error("FlockSQLite is closed");
337
+ }
338
+
339
+ const requestId = this.runtime.randomUUID();
340
+ const maxAttempts = 5;
341
+ let lastError: unknown;
342
+
343
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
344
+ if (!this.shouldForwardWrites()) {
345
+ const { commit, result } = await this.enqueueWrite(() =>
346
+ this.executeWriteRequest(payload, this.tabId, requestId),
347
+ );
348
+ this.ingestCommit(commit, bufferableCommit(commit, this.tabId));
349
+ this.broadcastCommit(commit);
350
+ return { commit, result: result as T };
351
+ }
352
+
353
+ try {
354
+ const forwarded = await this.forwardWriteRequest<T>(
355
+ payload,
356
+ requestId,
357
+ );
358
+ if (!forwarded.commit) {
359
+ throw new Error("Host did not return commit");
360
+ }
361
+ return { commit: forwarded.commit, result: forwarded.result };
362
+ } catch (error) {
363
+ lastError = error;
364
+ if (attempt >= maxAttempts || !isRetryableForwardError(error)) {
365
+ throw error;
366
+ }
367
+ await new Promise<void>((resolve) => {
368
+ this.runtime.setTimeout(() => resolve(), retryDelayMs(attempt));
369
+ });
370
+ }
371
+ }
372
+
373
+ throw lastError ?? new Error("Failed to dispatch write request");
374
+ }
375
+
376
+ private async processHostRequest(request: FlockRpcRequest): Promise<void> {
377
+ const cacheKey = requestCacheKey(request.from, request.id);
378
+ const cached = this.hostResponseCache.get(cacheKey);
379
+ if (cached) {
380
+ if (cached.ok && cached.commit) {
381
+ this.broadcastCommit(cached.commit);
382
+ }
383
+ this.transport.postMessage(cached);
384
+ return;
385
+ }
386
+
387
+ let inFlight = this.hostInFlight.get(cacheKey);
388
+ if (!inFlight) {
389
+ inFlight = this.enqueueWrite(async () => {
390
+ if (this.roleValue !== "host" || this.closed) {
391
+ return {
392
+ t: "res",
393
+ to: request.from,
394
+ id: request.id,
395
+ ok: false,
396
+ error: { name: "HostLost", message: "Host role was lost" },
397
+ } satisfies FlockRpcResponse;
398
+ }
399
+
400
+ try {
401
+ const { commit, result } = await this.executeWriteRequest(
402
+ request.payload,
403
+ request.from,
404
+ request.id,
405
+ );
406
+ this.ingestCommit(commit, bufferableCommit(commit, this.tabId));
407
+ this.broadcastCommit(commit);
408
+
409
+ return {
410
+ t: "res",
411
+ to: request.from,
412
+ id: request.id,
413
+ ok: true,
414
+ commit,
415
+ result,
416
+ } satisfies FlockRpcResponse;
417
+ } catch (error) {
418
+ return {
419
+ t: "res",
420
+ to: request.from,
421
+ id: request.id,
422
+ ok: false,
423
+ error: serializeError(error),
424
+ } satisfies FlockRpcResponse;
425
+ }
426
+ });
427
+
428
+ this.hostInFlight.set(cacheKey, inFlight);
429
+ void inFlight.finally(() => {
430
+ this.hostInFlight.delete(cacheKey);
431
+ });
432
+ }
433
+
434
+ const response = await inFlight;
435
+ this.hostResponseCache.set(cacheKey, response);
436
+ this.transport.postMessage(response);
437
+ }
438
+
439
+ close(): void {
440
+ if (this.closed) {
441
+ return;
442
+ }
443
+ this.closed = true;
444
+ this.transportUnsubscribe?.();
445
+ this.transportUnsubscribe = undefined;
446
+ this.transport.close?.();
447
+
448
+ for (const { reject, timeoutId } of this.pendingRequests.values()) {
449
+ this.runtime.clearTimeout(timeoutId);
450
+ try {
451
+ reject(new Error("FlockSQLite closed"));
452
+ } catch {
453
+ // ignore
454
+ }
455
+ }
456
+ this.pendingRequests.clear();
457
+
458
+ this.closeRole();
459
+ }
460
+ }
461
+
462
+ export function createWriteCoordinator(options: CoordinatorOptions): WriteCoordinator {
463
+ if (!options.transport) {
464
+ return new DirectWriteCoordinator(options);
465
+ }
466
+ return new MultiTabWriteCoordinator({ ...options, transport: options.transport });
467
+ }
468
+