@pylonsync/loro 0.22.38 → 0.22.40
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/package.json +14 -6
- package/src/index.ts +97 -42
- package/src/outbox.test.ts +312 -0
- package/src/outbox.ts +214 -0
- package/src/{prosemirror.test.ts → prosemirror/index.test.ts} +1 -1
- package/src/{prosemirror.ts → prosemirror/index.ts} +1 -1
- package/src/prosemirror/react.ts +31 -0
- package/src/prosemirror/sync.test.ts +193 -0
- package/src/prosemirror/sync.ts +402 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pylonsync/loro",
|
|
3
|
-
"version": "0.22.
|
|
3
|
+
"version": "0.22.40",
|
|
4
4
|
"description": "Pylon's local-first React layer — wraps loro-crdt with a registry, useLoroDoc hook, and the binary WS wire-format decoder that mirrors crates/router/encode_crdt_frame.",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -12,21 +12,24 @@
|
|
|
12
12
|
".": "./src/index.ts",
|
|
13
13
|
"./wire": "./src/wire.ts",
|
|
14
14
|
"./registry": "./src/registry.ts",
|
|
15
|
-
"./prosemirror": "./src/prosemirror.ts"
|
|
15
|
+
"./prosemirror": "./src/prosemirror/index.ts",
|
|
16
|
+
"./prosemirror/react": "./src/prosemirror/react.ts",
|
|
17
|
+
"./prosemirror/sync": "./src/prosemirror/sync.ts"
|
|
16
18
|
},
|
|
17
19
|
"scripts": {
|
|
18
20
|
"check": "tsc -p tsconfig.json --noEmit"
|
|
19
21
|
},
|
|
20
22
|
"dependencies": {
|
|
21
|
-
"@pylonsync/react": "0.22.
|
|
22
|
-
"@pylonsync/sync": "0.22.
|
|
23
|
+
"@pylonsync/react": "0.22.40",
|
|
24
|
+
"@pylonsync/sync": "0.22.40",
|
|
23
25
|
"loro-crdt": "^1.10.6"
|
|
24
26
|
},
|
|
25
27
|
"peerDependencies": {
|
|
26
28
|
"react": ">=19.0.0",
|
|
27
29
|
"loro-prosemirror": ">=0.4.4 <1.0.0",
|
|
28
30
|
"prosemirror-model": "^1.22.0",
|
|
29
|
-
"prosemirror-state": "^1.4.0"
|
|
31
|
+
"prosemirror-state": "^1.4.0",
|
|
32
|
+
"prosemirror-view": "^1.33.0"
|
|
30
33
|
},
|
|
31
34
|
"devDependencies": {
|
|
32
35
|
"@types/react": "^19.0.0",
|
|
@@ -34,7 +37,9 @@
|
|
|
34
37
|
"loro-prosemirror": "^0.4.4",
|
|
35
38
|
"prosemirror-model": "^1.25.0",
|
|
36
39
|
"prosemirror-state": "^1.4.3",
|
|
37
|
-
"prosemirror-schema-basic": "^1.2.4"
|
|
40
|
+
"prosemirror-schema-basic": "^1.2.4",
|
|
41
|
+
"prosemirror-view": "^1.42.3",
|
|
42
|
+
"@happy-dom/global-registrator": "^20.10.0"
|
|
38
43
|
},
|
|
39
44
|
"peerDependenciesMeta": {
|
|
40
45
|
"loro-prosemirror": {
|
|
@@ -45,6 +50,9 @@
|
|
|
45
50
|
},
|
|
46
51
|
"prosemirror-state": {
|
|
47
52
|
"optional": true
|
|
53
|
+
},
|
|
54
|
+
"prosemirror-view": {
|
|
55
|
+
"optional": true
|
|
48
56
|
}
|
|
49
57
|
},
|
|
50
58
|
"license": "MIT"
|
package/src/index.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
import { db, getBaseUrl, getReactStorage, storageKey } from "@pylonsync/react";
|
|
28
28
|
import { pylonFetch, type SyncEngine } from "@pylonsync/sync";
|
|
29
29
|
import { globalRegistry, LoroRegistry } from "./registry";
|
|
30
|
+
import { CrdtOutbox, type OutboxState } from "./outbox";
|
|
30
31
|
|
|
31
32
|
export { LoroRegistry, globalRegistry } from "./registry";
|
|
32
33
|
export {
|
|
@@ -36,6 +37,8 @@ export {
|
|
|
36
37
|
CRDT_FRAME_UPDATE,
|
|
37
38
|
} from "./wire";
|
|
38
39
|
export type { CrdtFrame } from "./wire";
|
|
40
|
+
export { CrdtOutbox, isRetryable } from "./outbox";
|
|
41
|
+
export type { OutboxSend, OutboxState, OutboxOptions } from "./outbox";
|
|
39
42
|
|
|
40
43
|
// ---------------------------------------------------------------------------
|
|
41
44
|
// Sync engine ↔ registry wiring
|
|
@@ -73,12 +76,61 @@ function ensureAttached(): void {
|
|
|
73
76
|
// lingering with stale state until the tab closes.
|
|
74
77
|
unsubscribeRowEviction = sync.addRowEvictionListener(
|
|
75
78
|
(entity: string, rowId: string) => {
|
|
79
|
+
disposeOutbox(entity, rowId);
|
|
76
80
|
globalRegistry.evict(entity, rowId);
|
|
77
81
|
},
|
|
78
82
|
);
|
|
83
|
+
attachPageListeners();
|
|
79
84
|
attachedSync = sync;
|
|
80
85
|
}
|
|
81
86
|
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Outboxes — one per row doc, sending its local edits (see ./outbox.ts)
|
|
89
|
+
//
|
|
90
|
+
// Created the first time a component reads the row's doc and kept as long
|
|
91
|
+
// as the doc: an editor that unmounts must not drop edits still in flight.
|
|
92
|
+
// A row eviction (policy revoked) disposes both.
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
const outboxes = new Map<string, { doc: LoroDoc; outbox: CrdtOutbox }>();
|
|
96
|
+
|
|
97
|
+
function outboxKey(entity: string, rowId: string): string {
|
|
98
|
+
return `${entity}:${rowId}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function outboxFor(entity: string, rowId: string, doc: LoroDoc): CrdtOutbox {
|
|
102
|
+
const key = outboxKey(entity, rowId);
|
|
103
|
+
const existing = outboxes.get(key);
|
|
104
|
+
if (existing && existing.doc === doc) return existing.outbox;
|
|
105
|
+
existing?.outbox.dispose();
|
|
106
|
+
const outbox = new CrdtOutbox(doc, (update, { keepalive }) =>
|
|
107
|
+
pushCrdtUpdate(entity, rowId, update, keepalive),
|
|
108
|
+
);
|
|
109
|
+
// A state change re-renders the row's readers (useLoroSendState).
|
|
110
|
+
outbox.subscribe(() => globalRegistry.touch(entity, rowId));
|
|
111
|
+
outboxes.set(key, { doc, outbox });
|
|
112
|
+
return outbox;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function disposeOutbox(entity: string, rowId: string): void {
|
|
116
|
+
const key = outboxKey(entity, rowId);
|
|
117
|
+
outboxes.get(key)?.outbox.dispose();
|
|
118
|
+
outboxes.delete(key);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let pageListenersAttached = false;
|
|
122
|
+
|
|
123
|
+
function attachPageListeners(): void {
|
|
124
|
+
if (pageListenersAttached || typeof window === "undefined") return;
|
|
125
|
+
pageListenersAttached = true;
|
|
126
|
+
window.addEventListener("online", () => {
|
|
127
|
+
for (const { outbox } of outboxes.values()) outbox.retryNow();
|
|
128
|
+
});
|
|
129
|
+
window.addEventListener("pagehide", () => {
|
|
130
|
+
for (const { outbox } of outboxes.values()) outbox.flushOnHide();
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
82
134
|
// ---------------------------------------------------------------------------
|
|
83
135
|
// React hooks
|
|
84
136
|
// ---------------------------------------------------------------------------
|
|
@@ -133,7 +185,24 @@ export function useLoroDoc(entity: string, id: string): LoroDoc {
|
|
|
133
185
|
globalRegistry.subscribe(entity, id, notify);
|
|
134
186
|
const getVersion = () => globalRegistry.version(entity, id);
|
|
135
187
|
useSyncExternalStore(subscribe, getVersion, getVersion);
|
|
136
|
-
|
|
188
|
+
const doc = globalRegistry.doc(entity, id);
|
|
189
|
+
// Local edits (an editor binding's included) reach the server through
|
|
190
|
+
// the row's outbox.
|
|
191
|
+
useEffect(() => {
|
|
192
|
+
outboxFor(entity, id, doc);
|
|
193
|
+
}, [entity, id, doc]);
|
|
194
|
+
return doc;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Whether this tab's edits to a row have reached the server, for a
|
|
199
|
+
* "Saving" indicator or an error notice. See {@link OutboxState}.
|
|
200
|
+
* `rejected` means the server refused them (for example the person lost
|
|
201
|
+
* edit access); they stay in this tab only.
|
|
202
|
+
*/
|
|
203
|
+
export function useLoroSendState(entity: string, id: string): OutboxState {
|
|
204
|
+
useLoroDoc(entity, id);
|
|
205
|
+
return outboxes.get(outboxKey(entity, id))?.outbox.state ?? "idle";
|
|
137
206
|
}
|
|
138
207
|
|
|
139
208
|
/**
|
|
@@ -235,11 +304,6 @@ export function useCollabText(
|
|
|
235
304
|
const value = resolveLoroText(doc, field)?.toString() ?? "";
|
|
236
305
|
const setValue = (next: string): void => {
|
|
237
306
|
const text = getLoroText(doc, field);
|
|
238
|
-
// Capture the version vector BEFORE the mutation so we can ship
|
|
239
|
-
// exactly the new ops to the server (incremental delta, not the
|
|
240
|
-
// whole snapshot). Loro's `export({mode: "update", from: vv})`
|
|
241
|
-
// returns the bytes the server hasn't seen.
|
|
242
|
-
const beforeVv = doc.oplogVersion();
|
|
243
307
|
const len = text.length;
|
|
244
308
|
if (len > 0) {
|
|
245
309
|
text.delete(0, len);
|
|
@@ -248,14 +312,9 @@ export function useCollabText(
|
|
|
248
312
|
text.insert(0, next);
|
|
249
313
|
}
|
|
250
314
|
doc.commit();
|
|
251
|
-
// Optimistic render: notify every consumer of this row NOW.
|
|
315
|
+
// Optimistic render: notify every consumer of this row NOW. The
|
|
316
|
+
// row's outbox sends the edit.
|
|
252
317
|
globalRegistry.touch(entity, id);
|
|
253
|
-
|
|
254
|
-
const update = doc.export({ mode: "update", from: beforeVv });
|
|
255
|
-
if (update.length === 0) {
|
|
256
|
-
return; // No-op (e.g. setValue called with the same value)
|
|
257
|
-
}
|
|
258
|
-
void pushCrdtUpdate(entity, id, update);
|
|
259
318
|
};
|
|
260
319
|
return [value, setValue];
|
|
261
320
|
}
|
|
@@ -343,20 +402,16 @@ export function useCollabTextarea<
|
|
|
343
402
|
const prev = domValue.current;
|
|
344
403
|
if (next === prev) return;
|
|
345
404
|
const { index, deleteCount, insert } = spliceDiff(prev, next);
|
|
346
|
-
// Resolve the container per keystroke (see resolveLoroText)
|
|
347
|
-
// capture the version vector BEFORE mutating so we ship exactly the
|
|
348
|
-
// new ops.
|
|
405
|
+
// Resolve the container per keystroke (see resolveLoroText).
|
|
349
406
|
const text = getLoroText(doc, field);
|
|
350
|
-
const beforeVv = doc.oplogVersion();
|
|
351
407
|
if (deleteCount > 0) text.delete(index, deleteCount);
|
|
352
408
|
if (insert.length > 0) text.insert(index, insert);
|
|
353
409
|
doc.commit();
|
|
354
410
|
domValue.current = next;
|
|
355
411
|
// Optimistic render: the preview (and any other consumer of this
|
|
356
|
-
// row) updates on the local commit, not on the server echo.
|
|
412
|
+
// row) updates on the local commit, not on the server echo. The row's
|
|
413
|
+
// outbox sends the edit.
|
|
357
414
|
globalRegistry.touch(entity, id);
|
|
358
|
-
const update = doc.export({ mode: "update", from: beforeVv });
|
|
359
|
-
if (update.length > 0) void pushCrdtUpdate(entity, id, update);
|
|
360
415
|
};
|
|
361
416
|
|
|
362
417
|
return { ref, value, onInput };
|
|
@@ -416,29 +471,27 @@ async function pushCrdtUpdate(
|
|
|
416
471
|
entity: string,
|
|
417
472
|
id: string,
|
|
418
473
|
update: Uint8Array,
|
|
474
|
+
keepalive: boolean,
|
|
419
475
|
): Promise<void> {
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
} catch (err) {
|
|
440
|
-
console.warn(`[loro] CRDT push failed for ${entity}/${id}:`, err);
|
|
441
|
-
}
|
|
476
|
+
// Server acknowledges with {ok: true}. The merged-state broadcast
|
|
477
|
+
// arrives over the WS, applied automatically by the registry. A
|
|
478
|
+
// failure throws (PylonHttpError carries the status); the outbox
|
|
479
|
+
// decides whether to retry.
|
|
480
|
+
await pylonFetch(
|
|
481
|
+
{
|
|
482
|
+
baseUrl: getBaseUrl(),
|
|
483
|
+
getToken: () =>
|
|
484
|
+
(getReactStorage().get(storageKey("token")) ?? undefined) as
|
|
485
|
+
| string
|
|
486
|
+
| undefined,
|
|
487
|
+
},
|
|
488
|
+
`/api/crdt/${encodeURIComponent(entity)}/${encodeURIComponent(id)}`,
|
|
489
|
+
{
|
|
490
|
+
method: "POST",
|
|
491
|
+
json: { update: bytesToHex(update) },
|
|
492
|
+
keepalive,
|
|
493
|
+
},
|
|
494
|
+
);
|
|
442
495
|
}
|
|
443
496
|
|
|
444
497
|
function bytesToHex(bytes: Uint8Array): string {
|
|
@@ -455,6 +508,8 @@ function bytesToHex(bytes: Uint8Array): string {
|
|
|
455
508
|
* need to call it.
|
|
456
509
|
*/
|
|
457
510
|
export function detachLoro(): void {
|
|
511
|
+
for (const { outbox } of outboxes.values()) outbox.dispose();
|
|
512
|
+
outboxes.clear();
|
|
458
513
|
if (unsubscribeBinaryHandler) {
|
|
459
514
|
unsubscribeBinaryHandler();
|
|
460
515
|
unsubscribeBinaryHandler = null;
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// CrdtOutbox against real LoroDocs. A fake server holds its own doc, merges
|
|
2
|
+
// what clients send, and relays each merge to the other clients, the way
|
|
3
|
+
// POST /api/crdt and the binary broadcast do.
|
|
4
|
+
import { describe, expect, test } from "bun:test";
|
|
5
|
+
import { LoroDoc, LoroMap } from "loro-crdt";
|
|
6
|
+
import { Schema } from "prosemirror-model";
|
|
7
|
+
import { schema as basic } from "prosemirror-schema-basic";
|
|
8
|
+
import { CrdtOutbox, isRetryable, type OutboxSend } from "./outbox";
|
|
9
|
+
import { readProseMirror, writeProseMirror } from "./prosemirror/index";
|
|
10
|
+
|
|
11
|
+
const tick = () => new Promise((r) => setTimeout(r, 0));
|
|
12
|
+
|
|
13
|
+
async function settle(): Promise<void> {
|
|
14
|
+
for (let i = 0; i < 20; i++) await tick();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function httpError(status: number): Error & { status: number } {
|
|
18
|
+
return Object.assign(new Error(`status ${status}`), { status });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
class FakeServer {
|
|
22
|
+
readonly doc = new LoroDoc();
|
|
23
|
+
readonly received: Uint8Array[] = [];
|
|
24
|
+
private readonly clients = new Set<LoroDoc>();
|
|
25
|
+
|
|
26
|
+
connect(client: LoroDoc): void {
|
|
27
|
+
client.import(this.doc.export({ mode: "snapshot" }));
|
|
28
|
+
this.clients.add(client);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A send that merges into the server doc and relays to every client. */
|
|
32
|
+
sender(): OutboxSend {
|
|
33
|
+
return async (update) => {
|
|
34
|
+
this.received.push(update);
|
|
35
|
+
this.doc.import(update);
|
|
36
|
+
const merged = this.doc.export({ mode: "snapshot" });
|
|
37
|
+
for (const c of this.clients) c.import(merged);
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function text(doc: LoroDoc): string {
|
|
43
|
+
return doc.getText("t").toString();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("CrdtOutbox", () => {
|
|
47
|
+
test("a local edit reaches the server and the other client", async () => {
|
|
48
|
+
const server = new FakeServer();
|
|
49
|
+
const a = new LoroDoc();
|
|
50
|
+
const b = new LoroDoc();
|
|
51
|
+
server.connect(a);
|
|
52
|
+
server.connect(b);
|
|
53
|
+
const outbox = new CrdtOutbox(a, server.sender());
|
|
54
|
+
a.getText("t").insert(0, "hello");
|
|
55
|
+
a.commit();
|
|
56
|
+
await settle();
|
|
57
|
+
expect(text(server.doc)).toBe("hello");
|
|
58
|
+
expect(text(b)).toBe("hello");
|
|
59
|
+
expect(outbox.state).toBe("idle");
|
|
60
|
+
expect(outbox.hasUnsent()).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("two clients editing at once converge", async () => {
|
|
64
|
+
const server = new FakeServer();
|
|
65
|
+
const a = new LoroDoc();
|
|
66
|
+
const b = new LoroDoc();
|
|
67
|
+
server.connect(a);
|
|
68
|
+
server.connect(b);
|
|
69
|
+
new CrdtOutbox(a, server.sender());
|
|
70
|
+
new CrdtOutbox(b, server.sender());
|
|
71
|
+
a.getText("t").insert(0, "left ");
|
|
72
|
+
a.commit();
|
|
73
|
+
b.getText("t").insert(0, "right");
|
|
74
|
+
b.commit();
|
|
75
|
+
await settle();
|
|
76
|
+
expect(text(a)).toBe(text(b));
|
|
77
|
+
expect(text(server.doc)).toBe(text(a));
|
|
78
|
+
expect(text(a)).toContain("left");
|
|
79
|
+
expect(text(a)).toContain("right");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("commits made during a request go out together in the next one", async () => {
|
|
83
|
+
const server = new FakeServer();
|
|
84
|
+
const a = new LoroDoc();
|
|
85
|
+
server.connect(a);
|
|
86
|
+
let release: () => void = () => {};
|
|
87
|
+
let calls = 0;
|
|
88
|
+
const send: OutboxSend = async (update, opts) => {
|
|
89
|
+
calls += 1;
|
|
90
|
+
if (calls === 1) await new Promise<void>((r) => (release = r));
|
|
91
|
+
await server.sender()(update, opts);
|
|
92
|
+
};
|
|
93
|
+
const outbox = new CrdtOutbox(a, send);
|
|
94
|
+
a.getText("t").insert(0, "a");
|
|
95
|
+
a.commit();
|
|
96
|
+
await tick();
|
|
97
|
+
expect(outbox.state).toBe("sending");
|
|
98
|
+
for (const ch of ["b", "c", "d"]) {
|
|
99
|
+
a.getText("t").insert(a.getText("t").length, ch);
|
|
100
|
+
a.commit();
|
|
101
|
+
}
|
|
102
|
+
await tick();
|
|
103
|
+
expect(calls).toBe(1);
|
|
104
|
+
release();
|
|
105
|
+
await settle();
|
|
106
|
+
expect(calls).toBe(2);
|
|
107
|
+
expect(text(server.doc)).toBe("abcd");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("a failed request is sent again, with the edits made since", async () => {
|
|
111
|
+
const server = new FakeServer();
|
|
112
|
+
const a = new LoroDoc();
|
|
113
|
+
server.connect(a);
|
|
114
|
+
const timers: Array<() => void> = [];
|
|
115
|
+
let fail = true;
|
|
116
|
+
const send: OutboxSend = async (update, opts) => {
|
|
117
|
+
if (fail) throw new Error("network down");
|
|
118
|
+
await server.sender()(update, opts);
|
|
119
|
+
};
|
|
120
|
+
const outbox = new CrdtOutbox(a, send, {
|
|
121
|
+
setTimer: (fn) => timers.push(fn),
|
|
122
|
+
clearTimer: () => {},
|
|
123
|
+
});
|
|
124
|
+
a.getText("t").insert(0, "one ");
|
|
125
|
+
a.commit();
|
|
126
|
+
await settle();
|
|
127
|
+
expect(outbox.state).toBe("retrying");
|
|
128
|
+
expect(text(server.doc)).toBe("");
|
|
129
|
+
a.getText("t").insert(4, "two");
|
|
130
|
+
a.commit();
|
|
131
|
+
await settle();
|
|
132
|
+
expect(timers.length).toBe(1); // no second request while waiting
|
|
133
|
+
fail = false;
|
|
134
|
+
timers.shift()!();
|
|
135
|
+
await settle();
|
|
136
|
+
expect(text(server.doc)).toBe("one two");
|
|
137
|
+
expect(outbox.state).toBe("idle");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("retry delays double up to the limit", async () => {
|
|
141
|
+
const a = new LoroDoc();
|
|
142
|
+
const delays: number[] = [];
|
|
143
|
+
const timers: Array<() => void> = [];
|
|
144
|
+
const outbox = new CrdtOutbox(a, async () => Promise.reject(httpError(503)), {
|
|
145
|
+
retryBaseMs: 100,
|
|
146
|
+
retryMaxMs: 300,
|
|
147
|
+
setTimer: (fn, ms) => {
|
|
148
|
+
delays.push(ms);
|
|
149
|
+
timers.push(fn);
|
|
150
|
+
},
|
|
151
|
+
clearTimer: () => {},
|
|
152
|
+
});
|
|
153
|
+
a.getText("t").insert(0, "x");
|
|
154
|
+
a.commit();
|
|
155
|
+
for (let i = 0; i < 4; i++) {
|
|
156
|
+
await settle();
|
|
157
|
+
timers.shift()!();
|
|
158
|
+
}
|
|
159
|
+
await settle();
|
|
160
|
+
expect(delays).toEqual([100, 200, 300, 300, 300]);
|
|
161
|
+
expect(outbox.state).toBe("retrying");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("retryNow skips the wait", async () => {
|
|
165
|
+
const server = new FakeServer();
|
|
166
|
+
const a = new LoroDoc();
|
|
167
|
+
server.connect(a);
|
|
168
|
+
let fail = true;
|
|
169
|
+
const outbox = new CrdtOutbox(
|
|
170
|
+
a,
|
|
171
|
+
async (u, o) => {
|
|
172
|
+
if (fail) throw new Error("offline");
|
|
173
|
+
await server.sender()(u, o);
|
|
174
|
+
},
|
|
175
|
+
{ setTimer: () => "handle", clearTimer: () => {} },
|
|
176
|
+
);
|
|
177
|
+
a.getText("t").insert(0, "x");
|
|
178
|
+
a.commit();
|
|
179
|
+
await settle();
|
|
180
|
+
fail = false;
|
|
181
|
+
outbox.retryNow();
|
|
182
|
+
await settle();
|
|
183
|
+
expect(text(server.doc)).toBe("x");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("a refused edit stops the outbox", async () => {
|
|
187
|
+
const a = new LoroDoc();
|
|
188
|
+
let calls = 0;
|
|
189
|
+
const outbox = new CrdtOutbox(a, async () => {
|
|
190
|
+
calls += 1;
|
|
191
|
+
throw httpError(403);
|
|
192
|
+
});
|
|
193
|
+
a.getText("t").insert(0, "x");
|
|
194
|
+
a.commit();
|
|
195
|
+
await settle();
|
|
196
|
+
expect(outbox.state).toBe("rejected");
|
|
197
|
+
expect((outbox.error as { status: number }).status).toBe(403);
|
|
198
|
+
a.getText("t").insert(1, "y");
|
|
199
|
+
a.commit();
|
|
200
|
+
await settle();
|
|
201
|
+
expect(calls).toBe(1);
|
|
202
|
+
expect(outbox.hasUnsent()).toBe(true);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("edits that came from the server are not sent back", async () => {
|
|
206
|
+
const server = new FakeServer();
|
|
207
|
+
const a = new LoroDoc();
|
|
208
|
+
const b = new LoroDoc();
|
|
209
|
+
server.connect(a);
|
|
210
|
+
server.connect(b);
|
|
211
|
+
new CrdtOutbox(b, server.sender());
|
|
212
|
+
// A large edit from b reaches a through the server.
|
|
213
|
+
b.getText("t").insert(0, "x".repeat(5000));
|
|
214
|
+
b.commit();
|
|
215
|
+
await settle();
|
|
216
|
+
const sends: Uint8Array[] = [];
|
|
217
|
+
const send = server.sender();
|
|
218
|
+
new CrdtOutbox(a, async (u, o) => {
|
|
219
|
+
sends.push(u);
|
|
220
|
+
await send(u, o);
|
|
221
|
+
});
|
|
222
|
+
a.getText("t").insert(0, "!");
|
|
223
|
+
a.commit();
|
|
224
|
+
await settle();
|
|
225
|
+
expect(sends.length).toBe(1);
|
|
226
|
+
expect(sends[0].length).toBeLessThan(200);
|
|
227
|
+
expect(text(b).startsWith("!")).toBe(true);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("edits made before the outbox existed are sent", async () => {
|
|
231
|
+
const server = new FakeServer();
|
|
232
|
+
const a = new LoroDoc();
|
|
233
|
+
server.connect(a);
|
|
234
|
+
a.getText("t").insert(0, "early");
|
|
235
|
+
a.commit();
|
|
236
|
+
new CrdtOutbox(a, server.sender());
|
|
237
|
+
await settle();
|
|
238
|
+
expect(text(server.doc)).toBe("early");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("flushOnHide sends with keepalive while a request is in flight", async () => {
|
|
242
|
+
const a = new LoroDoc();
|
|
243
|
+
const seen: boolean[] = [];
|
|
244
|
+
let release: () => void = () => {};
|
|
245
|
+
const outbox = new CrdtOutbox(a, async (_u, { keepalive }) => {
|
|
246
|
+
seen.push(keepalive);
|
|
247
|
+
if (!keepalive) await new Promise<void>((r) => (release = r));
|
|
248
|
+
});
|
|
249
|
+
a.getText("t").insert(0, "x");
|
|
250
|
+
a.commit();
|
|
251
|
+
await tick();
|
|
252
|
+
outbox.flushOnHide();
|
|
253
|
+
await settle();
|
|
254
|
+
expect(seen).toEqual([false, true]);
|
|
255
|
+
expect(outbox.hasUnsent()).toBe(false);
|
|
256
|
+
release();
|
|
257
|
+
await settle();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("dispose stops sending", async () => {
|
|
261
|
+
const a = new LoroDoc();
|
|
262
|
+
let calls = 0;
|
|
263
|
+
const outbox = new CrdtOutbox(a, async () => {
|
|
264
|
+
calls += 1;
|
|
265
|
+
});
|
|
266
|
+
outbox.dispose();
|
|
267
|
+
a.getText("t").insert(0, "x");
|
|
268
|
+
a.commit();
|
|
269
|
+
await settle();
|
|
270
|
+
expect(calls).toBe(0);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("a ProseMirror document edited in two copies converges", async () => {
|
|
274
|
+
const schema = new Schema({ nodes: basic.spec.nodes, marks: basic.spec.marks });
|
|
275
|
+
const server = new FakeServer();
|
|
276
|
+
// The server creates the field's container on insert.
|
|
277
|
+
server.doc.getMap("row").setContainer("body", new LoroMap());
|
|
278
|
+
server.doc.commit();
|
|
279
|
+
const a = new LoroDoc();
|
|
280
|
+
const b = new LoroDoc();
|
|
281
|
+
server.connect(a);
|
|
282
|
+
server.connect(b);
|
|
283
|
+
new CrdtOutbox(a, server.sender());
|
|
284
|
+
new CrdtOutbox(b, server.sender());
|
|
285
|
+
const para = (s: string) => schema.node("paragraph", null, [schema.text(s)]);
|
|
286
|
+
writeProseMirror(a, "body", schema, schema.node("doc", null, [para("from a")]));
|
|
287
|
+
await settle();
|
|
288
|
+
const current = readProseMirror(b, "body", schema)!;
|
|
289
|
+
writeProseMirror(b, "body", schema, current.copy(current.content.append(schema.node("doc", null, [para("from b")]).content)));
|
|
290
|
+
await settle();
|
|
291
|
+
const expected = ["from a", "from b"];
|
|
292
|
+
for (const d of [a, b, server.doc]) {
|
|
293
|
+
const node = readProseMirror(d, "body", schema)!;
|
|
294
|
+
const paragraphs: string[] = [];
|
|
295
|
+
node.forEach((n) => paragraphs.push(n.textContent));
|
|
296
|
+
expect(paragraphs).toEqual(expected);
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
describe("isRetryable", () => {
|
|
302
|
+
test("network errors, 5xx, 408 and 429 retry; other 4xx do not", () => {
|
|
303
|
+
expect(isRetryable(new Error("x"))).toBe(true);
|
|
304
|
+
expect(isRetryable(httpError(500))).toBe(true);
|
|
305
|
+
expect(isRetryable(httpError(503))).toBe(true);
|
|
306
|
+
expect(isRetryable(httpError(408))).toBe(true);
|
|
307
|
+
expect(isRetryable(httpError(429))).toBe(true);
|
|
308
|
+
expect(isRetryable(httpError(400))).toBe(false);
|
|
309
|
+
expect(isRetryable(httpError(403))).toBe(false);
|
|
310
|
+
expect(isRetryable(httpError(404))).toBe(false);
|
|
311
|
+
});
|
|
312
|
+
});
|