@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/src/outbox.ts ADDED
@@ -0,0 +1,214 @@
1
+ // ---------------------------------------------------------------------------
2
+ // CrdtOutbox — sends a doc's local edits to the server
3
+ //
4
+ // Every edit made on this copy of a row's doc (by an editor binding, a
5
+ // hook, or app code) has to reach `POST /api/crdt/<entity>/<id>`. The
6
+ // outbox listens for local commits and sends the edits the server has not
7
+ // confirmed:
8
+ //
9
+ // - One request at a time, so edits arrive in order. Commits made while
10
+ // a request is in flight go out together in the next one.
11
+ // - What it sends is every op of this peer after the last confirmed one,
12
+ // so a failed request loses nothing: the next send carries it again.
13
+ // Ops of other peers are never sent back; they came from the server.
14
+ // - A network error or a 5xx, 408 or 429 retries with backoff. Any other
15
+ // 4xx (policy denied, row gone, bad update) stops the outbox: sending
16
+ // the same edits again would fail the same way.
17
+ //
18
+ // The server merges idempotently, so an edit sent twice (a response lost
19
+ // after the server applied it) does no harm.
20
+ // ---------------------------------------------------------------------------
21
+
22
+ import { VersionVector, type LoroDoc } from "loro-crdt";
23
+
24
+ /** Send one update. Resolve when the server accepted it; reject otherwise.
25
+ * A rejection with a numeric `status` is treated as the HTTP status. */
26
+ export type OutboxSend = (update: Uint8Array, options: { keepalive: boolean }) => Promise<void>;
27
+
28
+ /**
29
+ * - `idle`: the server has every local edit.
30
+ * - `sending`: a request is in flight.
31
+ * - `retrying`: the last request failed and a retry is scheduled.
32
+ * - `rejected`: the server refused the edits; nothing more is sent.
33
+ */
34
+ export type OutboxState = "idle" | "sending" | "retrying" | "rejected";
35
+
36
+ export interface OutboxOptions {
37
+ /** First retry delay. Doubles per failure up to `retryMaxMs`. */
38
+ retryBaseMs?: number;
39
+ retryMaxMs?: number;
40
+ /** Timer functions, replaceable in tests. */
41
+ setTimer?: (fn: () => void, ms: number) => unknown;
42
+ clearTimer?: (handle: unknown) => void;
43
+ }
44
+
45
+ /** True for an error the same request could get past by trying again. */
46
+ export function isRetryable(err: unknown): boolean {
47
+ const status = (err as { status?: unknown } | null)?.status;
48
+ if (typeof status !== "number") return true; // network error, abort, timeout
49
+ return status >= 500 || status === 408 || status === 429;
50
+ }
51
+
52
+ export class CrdtOutbox {
53
+ private readonly doc: LoroDoc;
54
+ private readonly send: OutboxSend;
55
+ private readonly retryBaseMs: number;
56
+ private readonly retryMaxMs: number;
57
+ private readonly setTimer: (fn: () => void, ms: number) => unknown;
58
+ private readonly clearTimer: (handle: unknown) => void;
59
+ private readonly stopListening: () => void;
60
+ private readonly listeners = new Set<() => void>();
61
+
62
+ /** This peer's op counter the server has confirmed (exclusive end). */
63
+ private confirmed = 0;
64
+ private inFlight = false;
65
+ private retryHandle: unknown = null;
66
+ private failures = 0;
67
+ private disposed = false;
68
+ private _state: OutboxState = "idle";
69
+ private _error: unknown = null;
70
+
71
+ constructor(doc: LoroDoc, send: OutboxSend, options: OutboxOptions = {}) {
72
+ this.doc = doc;
73
+ this.send = send;
74
+ this.retryBaseMs = options.retryBaseMs ?? 500;
75
+ this.retryMaxMs = options.retryMaxMs ?? 10_000;
76
+ this.setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
77
+ this.clearTimer = options.clearTimer ?? ((h) => clearTimeout(h as ReturnType<typeof setTimeout>));
78
+ this.stopListening = doc.subscribeLocalUpdates(() => this.schedule());
79
+ // Edits made before the outbox existed go out now.
80
+ this.schedule();
81
+ }
82
+
83
+ get state(): OutboxState {
84
+ return this._state;
85
+ }
86
+
87
+ /** The error that made the outbox `rejected` or `retrying`. */
88
+ get error(): unknown {
89
+ return this._error;
90
+ }
91
+
92
+ /** True while some local edit has not been confirmed by the server. */
93
+ hasUnsent(): boolean {
94
+ return this.ownCounter() > this.confirmed;
95
+ }
96
+
97
+ /** Call `listener` on every state change. Returns the unsubscribe. */
98
+ subscribe(listener: () => void): () => void {
99
+ this.listeners.add(listener);
100
+ return () => this.listeners.delete(listener);
101
+ }
102
+
103
+ /** Send unconfirmed edits now, unless a request is already in flight. */
104
+ schedule(): void {
105
+ if (this.disposed || this._state === "rejected") return;
106
+ if (this.inFlight || this.retryHandle !== null) return; // picked up when that settles
107
+ void this.flush(false);
108
+ }
109
+
110
+ /** Skip the backoff wait (the browser came back online). */
111
+ retryNow(): void {
112
+ if (this.retryHandle === null) return;
113
+ this.clearTimer(this.retryHandle);
114
+ this.retryHandle = null;
115
+ this.schedule();
116
+ }
117
+
118
+ /**
119
+ * Last chance before the page goes away: send what is unconfirmed with
120
+ * `keepalive`, so the browser finishes the request after the page is gone.
121
+ * Runs even while a request is in flight; the server merges duplicates.
122
+ */
123
+ flushOnHide(): void {
124
+ if (this.disposed || this._state === "rejected") return;
125
+ const update = this.unsent();
126
+ if (!update) return;
127
+ const target = this.ownCounter();
128
+ this.send(update, { keepalive: true }).then(
129
+ () => this.confirm(target),
130
+ () => {
131
+ /* the regular path retries if the page stays */
132
+ },
133
+ );
134
+ }
135
+
136
+ /** Stop listening and cancel a pending retry. Edits not sent are dropped. */
137
+ dispose(): void {
138
+ if (this.disposed) return;
139
+ this.disposed = true;
140
+ this.stopListening();
141
+ if (this.retryHandle !== null) {
142
+ this.clearTimer(this.retryHandle);
143
+ this.retryHandle = null;
144
+ }
145
+ this.listeners.clear();
146
+ }
147
+
148
+ private ownCounter(): number {
149
+ return this.doc.oplogVersion().get(this.doc.peerIdStr) ?? 0;
150
+ }
151
+
152
+ /** This peer's ops after `confirmed`, or null when there are none. */
153
+ private unsent(): Uint8Array | null {
154
+ if (!this.hasUnsent()) return null;
155
+ // Start from everything the doc has, then roll this peer back to what
156
+ // the server confirmed: the export is exactly this peer's new ops.
157
+ const from = this.doc.oplogVersion().toJSON();
158
+ if (this.confirmed > 0) from.set(this.doc.peerIdStr, this.confirmed);
159
+ else from.delete(this.doc.peerIdStr);
160
+ return this.doc.export({ mode: "update", from: VersionVector.parseJSON(from) });
161
+ }
162
+
163
+ private confirm(counter: number): void {
164
+ if (counter > this.confirmed) this.confirmed = counter;
165
+ }
166
+
167
+ private async flush(keepalive: boolean): Promise<void> {
168
+ const update = this.unsent();
169
+ if (!update) {
170
+ this.setState("idle", null);
171
+ return;
172
+ }
173
+ const target = this.ownCounter();
174
+ this.inFlight = true;
175
+ this.setState("sending", null);
176
+ try {
177
+ await this.send(update, { keepalive });
178
+ this.confirm(target);
179
+ this.failures = 0;
180
+ } catch (err) {
181
+ this.inFlight = false;
182
+ if (this.disposed) return;
183
+ if (!isRetryable(err)) {
184
+ this.setState("rejected", err);
185
+ return;
186
+ }
187
+ this.failures += 1;
188
+ const delay = Math.min(this.retryMaxMs, this.retryBaseMs * 2 ** (this.failures - 1));
189
+ this.setState("retrying", err);
190
+ this.retryHandle = this.setTimer(() => {
191
+ this.retryHandle = null;
192
+ this.schedule();
193
+ }, delay);
194
+ return;
195
+ }
196
+ this.inFlight = false;
197
+ if (this.disposed) return;
198
+ if (this.hasUnsent()) void this.flush(false);
199
+ else this.setState("idle", null);
200
+ }
201
+
202
+ private setState(state: OutboxState, error: unknown): void {
203
+ if (this._state === state && this._error === error) return;
204
+ this._state = state;
205
+ this._error = error;
206
+ for (const listener of this.listeners) {
207
+ try {
208
+ listener();
209
+ } catch (err) {
210
+ console.warn("[loro] outbox listener threw:", err);
211
+ }
212
+ }
213
+ }
214
+ }
@@ -11,7 +11,7 @@ import {
11
11
  readProseMirror,
12
12
  waitForProseMirrorContainer,
13
13
  writeProseMirror,
14
- } from "./prosemirror";
14
+ } from "./index";
15
15
 
16
16
  // The basic schema plus a mark with an id, like a suggestion.
17
17
  const schema = new Schema({
@@ -15,7 +15,7 @@
15
15
  * const doc = useLoroDoc("Doc", id);
16
16
  * configureProseMirrorMarks(doc, schema);
17
17
  * const containerId = await waitForProseMirrorContainer(doc, "body");
18
- * const plugin = LoroSyncPlugin({ doc, containerId });
18
+ * const plugin = ProseMirrorSyncPlugin({ doc, containerId }); // ./sync
19
19
  *
20
20
  * In a server function, read and change the document without an editor
21
21
  * with `readProseMirror` and `writeProseMirror`.
@@ -0,0 +1,31 @@
1
+ /**
2
+ * React binding for a ProseMirror (Tiptap) field.
3
+ *
4
+ * const { doc, containerId } = useProseMirrorDoc("Doc", id, "body", schema);
5
+ * if (!containerId) return <Loading />;
6
+ * const plugins = [ProseMirrorSyncPlugin({ doc, containerId }), ProseMirrorUndoPlugin({ doc })];
7
+ *
8
+ * `containerId` is null until the server's snapshot brings the field's
9
+ * container; mount the editor after that (see ./index.ts for why a client
10
+ * never creates it). Edits the editor makes reach the server through the
11
+ * row's outbox, like every edit on a `useLoroDoc` doc.
12
+ */
13
+ import type { ContainerID, LoroDoc } from "loro-crdt";
14
+ import type { Schema } from "prosemirror-model";
15
+ import { useMemo } from "react";
16
+ import { useLoroDoc } from "../index";
17
+ import { configureProseMirrorMarks, findProseMirrorContainer } from "./index";
18
+
19
+ export function useProseMirrorDoc(
20
+ entity: string,
21
+ id: string,
22
+ field: string,
23
+ schema: Schema,
24
+ ): { doc: LoroDoc; containerId: ContainerID | null } {
25
+ const doc = useLoroDoc(entity, id);
26
+ // Before any binding writes a mark. Idempotent, so once per doc is enough.
27
+ useMemo(() => configureProseMirrorMarks(doc, schema), [doc, schema]);
28
+ // useLoroDoc re-renders on every applied frame, so this picks up the
29
+ // container as soon as the snapshot lands.
30
+ return { doc, containerId: findProseMirrorContainer(doc, field) };
31
+ }
@@ -0,0 +1,193 @@
1
+ // ProseMirrorSyncPlugin with two real editors (happy-dom) on two LoroDocs.
2
+ // `sync()` stands in for the network: it moves each doc's new ops to the
3
+ // other, the way the outbox and the server broadcast do.
4
+ import { GlobalRegistrator } from "@happy-dom/global-registrator";
5
+ if (!globalThis.document) GlobalRegistrator.register();
6
+
7
+ import { afterEach, describe, expect, test } from "bun:test";
8
+ import { LoroDoc, LoroList, LoroMap, type ContainerID } from "loro-crdt";
9
+ import { undo } from "loro-prosemirror";
10
+ import { Schema } from "prosemirror-model";
11
+ import { schema as basic } from "prosemirror-schema-basic";
12
+ import { EditorState, TextSelection } from "prosemirror-state";
13
+ import { EditorView } from "prosemirror-view";
14
+ import { configureProseMirrorMarks, findProseMirrorContainer } from "./index";
15
+ import { ProseMirrorSyncPlugin, ProseMirrorUndoPlugin } from "./sync";
16
+
17
+ const schema = new Schema({ nodes: basic.spec.nodes, marks: basic.spec.marks });
18
+ const tick = () => new Promise((r) => setTimeout(r, 0));
19
+
20
+ const views: EditorView[] = [];
21
+ afterEach(() => {
22
+ for (const v of views.splice(0)) v.destroy();
23
+ });
24
+
25
+ /** A server doc with the field's container, and two clients synced to it. */
26
+ function setup() {
27
+ // The empty document the server makes on insert (crates/crdt prosemirror.rs).
28
+ const server = new LoroDoc();
29
+ const body = server.getMap("row").setContainer("body", new LoroMap());
30
+ body.set("nodeName", "doc");
31
+ body.setContainer("attributes", new LoroMap());
32
+ body.setContainer("children", new LoroList());
33
+ server.commit();
34
+ const docs = [new LoroDoc(), new LoroDoc()];
35
+ for (const d of docs) {
36
+ d.import(server.export({ mode: "snapshot" }));
37
+ configureProseMirrorMarks(d, schema);
38
+ }
39
+ const containerId = findProseMirrorContainer(docs[0], "body") as ContainerID;
40
+ const make = (doc: LoroDoc) => {
41
+ const view = new EditorView(document.createElement("div"), {
42
+ state: EditorState.create({
43
+ schema,
44
+ plugins: [ProseMirrorSyncPlugin({ doc, containerId }), ProseMirrorUndoPlugin({ doc })],
45
+ }),
46
+ });
47
+ views.push(view);
48
+ return view;
49
+ };
50
+ const [a, b] = [make(docs[0]), make(docs[1])];
51
+ const sync = () => {
52
+ for (const from of docs) {
53
+ const bytes = from.export({ mode: "update", from: server.oplogVersion() });
54
+ server.import(bytes);
55
+ }
56
+ const all = server.export({ mode: "snapshot" });
57
+ for (const to of docs) to.import(all);
58
+ };
59
+ return { a, b, docs, sync };
60
+ }
61
+
62
+ const text = (v: EditorView) => v.state.doc.textBetween(0, v.state.doc.content.size, "\n");
63
+ const type = (v: EditorView, s: string) => {
64
+ for (const ch of s) v.dispatch(v.state.tr.insertText(ch));
65
+ };
66
+ const caretAt = (v: EditorView, pos: number) =>
67
+ v.dispatch(v.state.tr.setSelection(TextSelection.create(v.state.doc, pos)));
68
+
69
+ describe("ProseMirrorSyncPlugin", () => {
70
+ test("typing in one editor reaches the other", async () => {
71
+ const { a, b, sync } = setup();
72
+ await tick();
73
+ type(a, "Hello");
74
+ sync();
75
+ expect(text(b)).toBe("Hello");
76
+ });
77
+
78
+ test("a remote edit before the caret keeps the caret on its words", async () => {
79
+ const { a, b, sync } = setup();
80
+ await tick();
81
+ type(a, "world");
82
+ sync();
83
+ caretAt(b, 6); // after "world"
84
+ caretAt(a, 1);
85
+ type(a, "hello ");
86
+ sync();
87
+ expect(text(b)).toBe("hello world");
88
+ expect(b.state.selection.head).toBe(12);
89
+ });
90
+
91
+ test("typing right after a remote change goes at the caret, not the end", async () => {
92
+ const { a, b, sync } = setup();
93
+ await tick();
94
+ type(a, "one three");
95
+ sync();
96
+ caretAt(b, 4); // after "one"
97
+ caretAt(a, 10);
98
+ type(a, " four");
99
+ sync();
100
+ type(b, " two"); // no timer in between
101
+ sync();
102
+ expect(text(a)).toBe("one two three four");
103
+ expect(text(b)).toBe("one two three four");
104
+ });
105
+
106
+ test("two people typing at the same spot keep their words whole", async () => {
107
+ const { a, b, sync } = setup();
108
+ await tick();
109
+ type(a, "Hi");
110
+ sync();
111
+ caretAt(a, 3);
112
+ caretAt(b, 3);
113
+ const left = " [from a]";
114
+ const right = " [from b]";
115
+ for (let i = 0; i < left.length; i++) {
116
+ type(a, left[i]);
117
+ type(b, right[i]);
118
+ sync();
119
+ }
120
+ expect(text(a)).toBe(text(b));
121
+ expect([`Hi${left}${right}`, `Hi${right}${left}`]).toContain(text(a));
122
+ });
123
+
124
+ test("a paragraph split elsewhere does not move the caret", async () => {
125
+ const { a, b, sync } = setup();
126
+ await tick();
127
+ type(a, "first second");
128
+ sync();
129
+ caretAt(b, 13); // end of "second"
130
+ caretAt(a, 6); // after "first"
131
+ a.dispatch(a.state.tr.split(6));
132
+ sync();
133
+ type(b, "!");
134
+ sync();
135
+ expect(text(a)).toBe("first\n second!");
136
+ expect(text(b)).toBe("first\n second!");
137
+ });
138
+
139
+ test("undo takes back a burst of typing as one step", async () => {
140
+ const { a, sync } = setup();
141
+ await tick();
142
+ type(a, "kept");
143
+ sync();
144
+ await new Promise((r) => setTimeout(r, 600));
145
+ type(a, " typed together");
146
+ undo(a.state, a.dispatch);
147
+ expect(text(a)).toBe("kept");
148
+ });
149
+
150
+ // Loro does not merge an undo step across a remote change in the same
151
+ // container (loro-internal undo.rs, push_with_merge), so while two people
152
+ // type in one paragraph each undo takes back a smaller step. Each step is
153
+ // still only this person's.
154
+ test("undo while someone else types removes only this person's typing", async () => {
155
+ const { a, b, sync } = setup();
156
+ await tick();
157
+ type(a, "x");
158
+ sync();
159
+ await new Promise((r) => setTimeout(r, 600));
160
+ caretAt(a, 2);
161
+ caretAt(b, 1);
162
+ for (const [ca, cb] of [["1", "a"], ["2", "b"], ["3", "c"], ["4", "d"]]) {
163
+ type(a, ca);
164
+ type(b, cb);
165
+ sync();
166
+ }
167
+ undo(b.state, b.dispatch);
168
+ sync();
169
+ expect(text(a)).toContain("1234");
170
+ expect(text(a)).not.toContain("d");
171
+ for (let i = 0; i < 4; i++) undo(b.state, b.dispatch);
172
+ sync();
173
+ expect(text(a)).toBe("x1234");
174
+ expect(text(b)).toBe("x1234");
175
+ });
176
+
177
+ test("undo removes only this editor's edit", async () => {
178
+ const { a, b, sync } = setup();
179
+ await tick();
180
+ type(a, "base");
181
+ sync();
182
+ caretAt(b, 5);
183
+ type(b, " by b");
184
+ sync();
185
+ caretAt(a, 1);
186
+ type(a, "A:");
187
+ sync();
188
+ undo(b.state, b.dispatch);
189
+ sync();
190
+ expect(text(a)).toBe("A:base");
191
+ expect(text(b)).toBe("A:base");
192
+ });
193
+ });