@mocanvas/editor 4.8.3 → 4.8.6
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 +476 -0
- package/CUSTOM_SHAPES.md +7 -2
- package/FILES.md +554 -0
- package/MIGRATION.md +1 -1
- package/SYNC.md +490 -0
- package/TOOLS.md +655 -0
- package/package.json +8 -4
package/SYNC.md
ADDED
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
# Multiplayer
|
|
2
|
+
|
|
3
|
+
How two or more people end up on one document with `@mocanvas/sync`. By the end
|
|
4
|
+
you will know which records travel and which stay put, how to hand the client a
|
|
5
|
+
channel (including one that joins two browser tabs with no server at all), what
|
|
6
|
+
presence carries and how often, and exactly what the merge does and does not
|
|
7
|
+
promise when two edits race.
|
|
8
|
+
|
|
9
|
+
Everything here is public API from `@mocanvas/sync`, `@mocanvas/editor` and
|
|
10
|
+
`@mocanvas/store`. [ARCHITECTURE.md](ARCHITECTURE.md) explains the store and the
|
|
11
|
+
rendering split underneath it; [CUSTOM_SHAPES.md](CUSTOM_SHAPES.md) covers
|
|
12
|
+
writing the records this document is made of.
|
|
13
|
+
|
|
14
|
+
Contents:
|
|
15
|
+
|
|
16
|
+
1. [What travels, and what does not](#1-what-travels-and-what-does-not)
|
|
17
|
+
2. [Wiring a client to a transport](#2-wiring-a-client-to-a-transport)
|
|
18
|
+
3. [Presence: cursors, camera, selection](#3-presence-cursors-camera-selection)
|
|
19
|
+
4. [What the merge guarantees](#4-what-the-merge-guarantees)
|
|
20
|
+
5. [What a server has to do](#5-what-a-server-has-to-do)
|
|
21
|
+
6. [Things that bite](#6-things-that-bite)
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## 1. What travels, and what does not
|
|
26
|
+
|
|
27
|
+
Every record type in the store declares a **scope**, one of three
|
|
28
|
+
(`RecordScope` in `@mocanvas/store`):
|
|
29
|
+
|
|
30
|
+
| Scope | Record types | Persisted to `.tldr` | Sent to peers |
|
|
31
|
+
| --- | --- | --- | --- |
|
|
32
|
+
| `document` | `document`, `page`, `shape`, `binding`, `asset`, comments | yes | yes, as stamped diffs |
|
|
33
|
+
| `session` | `camera`, `instance`, `instance_page_state`, `pointer` | no | no |
|
|
34
|
+
| `presence` | `instance_presence`, `user` | no | yes, as whole records |
|
|
35
|
+
|
|
36
|
+
A custom record declares its own scope (`CustomRecordInfo.scope`), so it travels
|
|
37
|
+
or does not by the same rule.
|
|
38
|
+
|
|
39
|
+
The sync client only ever listens to one scope:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
store.listen(handler, { source: "user", scope: "document" })
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
That single filter does two jobs. `scope: "document"` keeps your camera, your
|
|
46
|
+
selection highlight and your hover state off the wire entirely — they are
|
|
47
|
+
`session` records, and nobody else's editor should be moved by your scroll
|
|
48
|
+
wheel. `source: "user"` keeps changes you applied *from* a peer from going
|
|
49
|
+
straight back out again; see §6. The snapshot a joiner receives is
|
|
50
|
+
`store.serialize("document")`, so it carries the same set.
|
|
51
|
+
|
|
52
|
+
### Why a cursor is not in the document
|
|
53
|
+
|
|
54
|
+
A cursor moves about thirty times a second. Put it in a `document` record and
|
|
55
|
+
every one of those moves becomes a history entry you can undo, a field the CRDT
|
|
56
|
+
has to stamp, a byte in the saved file, and a tombstone when the person leaves.
|
|
57
|
+
So presence is a record type of its own, `instance_presence`, in the `presence`
|
|
58
|
+
scope: never written to a `.tldr` file, never in the undo stack (the editor's
|
|
59
|
+
history manager listens with `{ source: "user", scope: "document" }` too), never
|
|
60
|
+
stamped or merged. It travels as a `presence` message holding one whole record,
|
|
61
|
+
the newest replaces the last by id, and a peer unheard from for ten seconds is
|
|
62
|
+
dropped.
|
|
63
|
+
|
|
64
|
+
`session` records sit at the other extreme: yours, unsaved, unshared. A camera
|
|
65
|
+
is the clearest case — sharing it would mean two people fighting over one
|
|
66
|
+
viewport. What you want instead is *following*, which is a `presence` field
|
|
67
|
+
(`followingUserId`) plus `editor.startFollowingUser(userId)`, not a shared
|
|
68
|
+
camera record.
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## 2. Wiring a client to a transport
|
|
73
|
+
|
|
74
|
+
### The contract
|
|
75
|
+
|
|
76
|
+
A transport is a duplex channel carrying `SyncMessage`s for one room. It owns
|
|
77
|
+
serialisation and reconnection; the client above it only sees decoded messages.
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
interface Transport<R extends UnknownRecord = UnknownRecord> {
|
|
81
|
+
send(message: SyncMessage<R>): void
|
|
82
|
+
onMessage(callback: (message: SyncMessage<R>) => void): () => void
|
|
83
|
+
onOpen?(callback: () => void): () => void
|
|
84
|
+
onClose?(callback: () => void): () => void
|
|
85
|
+
close(): void
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`onOpen` fires every time the channel becomes usable, including after a
|
|
90
|
+
reconnect; `onClose` every time it stops being. Both are optional, and a
|
|
91
|
+
transport with neither is treated as open the moment it is connected. Anything
|
|
92
|
+
satisfying this interface works — a WebSocket, a `BroadcastChannel`, a WebRTC
|
|
93
|
+
data channel, a shared worker, a mock in a test.
|
|
94
|
+
|
|
95
|
+
There are five messages, all JSON: `hello` (I joined), `snapshot` (the document
|
|
96
|
+
records plus the sender's stamps), `diff` (one squashed store diff, stamped
|
|
97
|
+
field by field), `presence`, `bye`. `encodeMessage` stamps
|
|
98
|
+
`version: PROTOCOL_VERSION` (3) onto every one and `decodeMessage` checks it,
|
|
99
|
+
returning `null` for anything malformed and a synthetic
|
|
100
|
+
`{ type: "unsupported", version }` for a peer on another protocol — reported
|
|
101
|
+
through `onError` rather than half-parsed.
|
|
102
|
+
|
|
103
|
+
### The client
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
import { createSyncClient, createBroadcastChannelTransport } from "@mocanvas/sync"
|
|
107
|
+
import type { EditorRecord } from "@mocanvas/editor"
|
|
108
|
+
|
|
109
|
+
const client = createSyncClient<EditorRecord>({
|
|
110
|
+
store: editor.store,
|
|
111
|
+
roomId: "my-doc",
|
|
112
|
+
transport: createBroadcastChannelTransport<EditorRecord>("my-doc"),
|
|
113
|
+
presence: { editor },
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
client.connect()
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`client.getStatus()` returns a signal of `"offline" | "connecting" | "online"`,
|
|
120
|
+
readable from a reactor or with `useValue`. `disconnect()` sends `bye` and stops
|
|
121
|
+
listening; `dispose()` disconnects and closes the transport.
|
|
122
|
+
|
|
123
|
+
In React, `useSync` does the whole lifecycle for as long as the component is
|
|
124
|
+
mounted:
|
|
125
|
+
|
|
126
|
+
```tsx
|
|
127
|
+
import { CollaboratorCursors, createBroadcastChannelTransport, useSync } from "@mocanvas/sync"
|
|
128
|
+
|
|
129
|
+
const { status } = useSync(editor, {
|
|
130
|
+
roomId,
|
|
131
|
+
transport: () => createBroadcastChannelTransport(roomId),
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
// inside <Mocanvas onMount={setEditor}>:
|
|
135
|
+
{editor ? <CollaboratorCursors editor={editor} /> : null}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Pass a **factory**, not a transport: it is called once per connection, so a
|
|
139
|
+
transport is never reused across a reconnect or a room change. `enabled: false`
|
|
140
|
+
keeps the editor offline (a read-only view, say).
|
|
141
|
+
|
|
142
|
+
### A BroadcastChannel, with no server at all
|
|
143
|
+
|
|
144
|
+
`createBroadcastChannelTransport(roomId, { prefix })` is shipped, and it is the
|
|
145
|
+
whole of the multiplayer demo on the website: two tabs of one browser, one
|
|
146
|
+
origin, nothing deployed and nothing to keep running. Written out, it is small
|
|
147
|
+
enough to read:
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
import { decodeMessage, encodeMessage, type SyncMessage, type Transport } from "@mocanvas/sync"
|
|
151
|
+
import type { EditorRecord } from "@mocanvas/editor"
|
|
152
|
+
|
|
153
|
+
export function createTabTransport(roomId: string): Transport<EditorRecord> {
|
|
154
|
+
const channel = new BroadcastChannel(`my-app:${roomId}`)
|
|
155
|
+
const listeners = new Set<(message: SyncMessage<EditorRecord>) => void>()
|
|
156
|
+
let closed = false
|
|
157
|
+
|
|
158
|
+
channel.onmessage = (event: MessageEvent) => {
|
|
159
|
+
const message = decodeMessage<EditorRecord>(event.data)
|
|
160
|
+
if (!message) return
|
|
161
|
+
for (const listener of Array.from(listeners)) listener(message)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
send(message) {
|
|
166
|
+
if (!closed) channel.postMessage(encodeMessage(message))
|
|
167
|
+
},
|
|
168
|
+
onMessage(callback) {
|
|
169
|
+
listeners.add(callback)
|
|
170
|
+
return () => void listeners.delete(callback)
|
|
171
|
+
},
|
|
172
|
+
onOpen(callback) {
|
|
173
|
+
// Usable already — but hand control back first, so that connect() has
|
|
174
|
+
// finished subscribing before the hello goes out.
|
|
175
|
+
let cancelled = false
|
|
176
|
+
queueMicrotask(() => {
|
|
177
|
+
if (!cancelled && !closed) callback()
|
|
178
|
+
})
|
|
179
|
+
return () => void (cancelled = true)
|
|
180
|
+
},
|
|
181
|
+
close() {
|
|
182
|
+
closed = true
|
|
183
|
+
listeners.clear()
|
|
184
|
+
channel.onmessage = null
|
|
185
|
+
channel.close()
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Two details are doing real work. The `queueMicrotask` in `onOpen` is not
|
|
192
|
+
decoration: `connect()` subscribes to messages and *then* to opens, and a
|
|
193
|
+
callback that fires synchronously would send `hello` before anything was
|
|
194
|
+
listening for the answer. And `BroadcastChannel` never delivers to the context
|
|
195
|
+
that posted, which is why there is no self-filter here — over a relay that
|
|
196
|
+
echoes, the client's own `clientId` check on `diff` and `presence` messages is
|
|
197
|
+
what saves you.
|
|
198
|
+
|
|
199
|
+
Two tabs joining the same room also need to be looking at the same page. A blank
|
|
200
|
+
document's first page has a fixed id (`DEFAULT_PAGE_ID`), so two editors that
|
|
201
|
+
each built their own store meet there by default. If one of them pins or loads a
|
|
202
|
+
page of its own, everything reports `online`, every message arrives, and nothing
|
|
203
|
+
is drawn — so the client says so once, in development, rather than leaving you
|
|
204
|
+
hunting a transport bug that is not there.
|
|
205
|
+
|
|
206
|
+
Tabs are *not* deduplicated by person: presence is published under
|
|
207
|
+
`editor.getInstancePresenceId()`, which is per editor instance, so two tabs of
|
|
208
|
+
one browser are two collaborators and see each other. You do not need to invent
|
|
209
|
+
a fake per-tab identity.
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
## 3. Presence: cursors, camera, selection
|
|
214
|
+
|
|
215
|
+
`presence: { editor }` turns on a `PresenceSync`, which builds one
|
|
216
|
+
`InstancePresence` record from the editor and pushes it out. It carries:
|
|
217
|
+
|
|
218
|
+
`cursor` from `editor.inputs.currentPagePoint` and the cursor type and rotation
|
|
219
|
+
on `getInstanceState()`; `camera` from `editor.getCamera()`; `selectedShapeIds`,
|
|
220
|
+
`currentPageId`, `brush`, `scribbles` and `followingUserId` from the editor's
|
|
221
|
+
own getters; `userId`, `userName` and `color` from `editor.user`; and
|
|
222
|
+
`lastActivityTimestamp`, which is `Date.now()` on the sender.
|
|
223
|
+
|
|
224
|
+
The cursor point is in page space; `CollaboratorCursors` converts it with
|
|
225
|
+
`editor.pageToViewport` when it draws. The camera is there so that somebody can
|
|
226
|
+
*follow* you, not so that your scrolling moves their viewport.
|
|
227
|
+
|
|
228
|
+
### The rate
|
|
229
|
+
|
|
230
|
+
| Constant | Default | Meaning |
|
|
231
|
+
| --- | --- | --- |
|
|
232
|
+
| `DEFAULT_PRESENCE_THROTTLE_MS` | 34 ms (~30 Hz) | upper bound on the send rate |
|
|
233
|
+
| `DEFAULT_PRESENCE_HEARTBEAT_MS` | 3000 ms | re-send even when nothing changed |
|
|
234
|
+
| `DEFAULT_PRESENCE_TIMEOUT_MS` | 10 000 ms | drop a peer we have not heard from |
|
|
235
|
+
|
|
236
|
+
Camera, page, selection and instance state are signals, so a reactor covers
|
|
237
|
+
them; the pointer is not, so the editor's `event` is sampled as well. Both only
|
|
238
|
+
*ask* for a send. The request is collapsed into the throttle window, and when it
|
|
239
|
+
fires the record is compared with the last one sent (`isSamePresence`, which
|
|
240
|
+
ignores `lastActivityTimestamp`): unchanged means nothing goes out. So a still
|
|
241
|
+
pointer costs one message every three seconds, not thirty a second.
|
|
242
|
+
|
|
243
|
+
Three things force a send regardless: starting, the heartbeat, and a `hello`
|
|
244
|
+
from a newcomer. That last one is a re-announcement — what changed is who is
|
|
245
|
+
listening, not the record — and without it somebody who has been sitting still
|
|
246
|
+
stays invisible to a joiner until the next heartbeat.
|
|
247
|
+
|
|
248
|
+
Override any of it per client: `presenceThrottleMs`, `presenceHeartbeatMs`,
|
|
249
|
+
`presenceTimeoutMs` on `createSyncClient` (`useSync` exposes the throttle and the
|
|
250
|
+
timeout).
|
|
251
|
+
|
|
252
|
+
### Reading the room
|
|
253
|
+
|
|
254
|
+
```ts
|
|
255
|
+
editor.getCollaborators() // everyone else, however stale
|
|
256
|
+
editor.getCollaboratorsOnCurrentPage() // …narrowed to this page
|
|
257
|
+
editor.collaborators.getVisibleCollaboratorsOnCurrentPage() // …and actually here
|
|
258
|
+
editor.collaborators.isCollaboratorIdle(presence) // present, but not doing anything
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Draw from the *visible* list: a tab left open overnight keeps writing presence
|
|
262
|
+
long after the person went home. `collaboratorInactiveTimeoutMs` (60 s) decides
|
|
263
|
+
who is still there, `collaboratorIdleTimeoutMs` (3 s) who is merely idle —
|
|
264
|
+
dimmed, not removed. The flip side is that a long-running participant which is
|
|
265
|
+
not a human, such as an agent working through a tool call, must keep its
|
|
266
|
+
`lastActivityTimestamp` fresh or it will vanish mid-turn.
|
|
267
|
+
|
|
268
|
+
---
|
|
269
|
+
|
|
270
|
+
## 4. What the merge guarantees
|
|
271
|
+
|
|
272
|
+
The merge lives in `packages/sync/src/crdt.ts`; this is what it actually does.
|
|
273
|
+
|
|
274
|
+
**Every leaf field of every record is its own last-writer-wins register**, keyed
|
|
275
|
+
by a dotted path: `x`, `props.w`, `meta.notes.title`. A local change carries one
|
|
276
|
+
Lamport stamp, `{ lamport, client }`. A field is overwritten only when the
|
|
277
|
+
incoming stamp is greater than the one held for that exact path, and equal
|
|
278
|
+
Lamport values are broken by comparing client ids — so the order is total and
|
|
279
|
+
both sides of a race reach the same answer.
|
|
280
|
+
|
|
281
|
+
A stamp covers a **subtree**, not just a leaf. Writing `meta` — setting it, or
|
|
282
|
+
deleting the key — dominates `meta.a`, and a write that drops a subtree keeps
|
|
283
|
+
descendants stamped later than itself. Treating `meta` and `meta.a` as unrelated
|
|
284
|
+
registers is exactly how "delete the key" racing "edit its child" lands
|
|
285
|
+
differently on each replica.
|
|
286
|
+
|
|
287
|
+
That gives two properties, and they are the whole guarantee:
|
|
288
|
+
|
|
289
|
+
- **Idempotent** — the same message twice changes nothing the second time.
|
|
290
|
+
- **Commutative** — per path the result is the maximum stamp, and a maximum does
|
|
291
|
+
not care about arrival order.
|
|
292
|
+
|
|
293
|
+
Together: replicas converge however the transport reorders, duplicates or drops
|
|
294
|
+
messages, as long as everything is eventually delivered. `src/fuzz.test.ts` is
|
|
295
|
+
the evidence — random schedules of creates, nested edits, deletes, re-creates,
|
|
296
|
+
reordering, duplication, drops, disconnects, reconnects and snapshot joins over
|
|
297
|
+
an in-memory network, then a demand that every replica's document be identical
|
|
298
|
+
under a key-sorted serialisation.
|
|
299
|
+
|
|
300
|
+
In practice: two people editing **different fields of one shape** — one moves
|
|
301
|
+
it, one recolours it — keep both edits; two people editing the **same** field
|
|
302
|
+
pick the same winner everywhere; **creating** the same record id on two peers
|
|
303
|
+
merges field by field, like an edit. **Deleting** leaves a stamped tombstone, so
|
|
304
|
+
a late message cannot resurrect the record — a delete beats a concurrent edit
|
|
305
|
+
only when its stamp is greater, and an edit that outranks the delete keeps the
|
|
306
|
+
record alive, re-claimed whole under a fresh stamp so that every replica agrees
|
|
307
|
+
on which body survived. Work done while **disconnected** survives: local changes
|
|
308
|
+
are stamped by the store listener, which is wired from construction until
|
|
309
|
+
`dispose()`, so an edit made before `connect()` or after `disconnect()` is
|
|
310
|
+
stamped when it happens and merged on the next connection.
|
|
311
|
+
|
|
312
|
+
### What it does not cover
|
|
313
|
+
|
|
314
|
+
Field-level and order-independent is a real guarantee, and it is not the same as
|
|
315
|
+
"nothing is ever lost". These limits are structural, not bugs waiting on a fix:
|
|
316
|
+
|
|
317
|
+
- **Two writes to one field cannot both survive.** The register holds one value;
|
|
318
|
+
the greater stamp takes it and the other is gone.
|
|
319
|
+
- **Arrays are opaque.** `props.points`, `selectedShapeIds` and every other array
|
|
320
|
+
is *one* register holding the whole array. Two concurrent edits to one array
|
|
321
|
+
pick a winner rather than merging. Per-element registers would need element
|
|
322
|
+
identity, which record data does not carry.
|
|
323
|
+
- **The fractional `index` is one register too**, so a concurrent reorder of the
|
|
324
|
+
same shape lands on one of the two orders deterministically. Reordering
|
|
325
|
+
*different* shapes is fine — different records.
|
|
326
|
+
- **No text merge.** Two people typing into one text field pick a winner rather
|
|
327
|
+
than interleaving characters. There is no operational transform and no
|
|
328
|
+
character-level CRDT here.
|
|
329
|
+
- **Tombstones are bounded**: one hour, 5000 of them, oldest first
|
|
330
|
+
(`tombstoneMaxAgeMs`, `tombstoneLimit`). That bound is a statement about how
|
|
331
|
+
long a peer may be partitioned. Within it, deletes are safe against
|
|
332
|
+
arbitrarily late messages; past it, a straggler's edit resurrects the record.
|
|
333
|
+
- **A replica that has never connected starts its clock at zero.** Its offline
|
|
334
|
+
edits are real writes and its own new records always survive, but an edit to a
|
|
335
|
+
field the room has written several times can sort below what is already there.
|
|
336
|
+
One that has been in the room and comes back has observed the room's clock and
|
|
337
|
+
does not have this problem.
|
|
338
|
+
- **A body that arrives without its stamps can be stale.** A put claims stamps
|
|
339
|
+
only for the paths it touches, so a replica that missed a record's creation
|
|
340
|
+
and then sees an edit to one field adopts the rest of that body without
|
|
341
|
+
provenance. The next snapshot exchange repairs it, and every `hello`,
|
|
342
|
+
`connect` and reconnect triggers one.
|
|
343
|
+
|
|
344
|
+
---
|
|
345
|
+
|
|
346
|
+
## 5. What a server has to do
|
|
347
|
+
|
|
348
|
+
Two tabs need no server. More than two, or two on different machines, need
|
|
349
|
+
something in the middle — and the minimum is genuinely small.
|
|
350
|
+
|
|
351
|
+
### Relay diffs
|
|
352
|
+
|
|
353
|
+
The relay forwards every message it receives on a socket to the other sockets in
|
|
354
|
+
the same room, verbatim. It keeps no document state at all: peers hand each
|
|
355
|
+
other the document with the `hello`/`snapshot` exchange, so restarting the relay
|
|
356
|
+
is harmless. `packages/sync/scripts/relay.mjs` is that, in about fifty lines; the
|
|
357
|
+
room is the URL path.
|
|
358
|
+
|
|
359
|
+
```sh
|
|
360
|
+
pnpm --filter @mocanvas/sync relay # ws://localhost:5858/<room>
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
```ts
|
|
364
|
+
createWebSocketTransport(`ws://localhost:5858/${roomId}`, { reconnect: true })
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
`createWebSocketTransport` reconnects with exponential backoff and full jitter,
|
|
368
|
+
so a relay restart does not get a thundering herd from every open tab. Messages
|
|
369
|
+
sent while the socket is down are dropped rather than queued — the next
|
|
370
|
+
`hello`/`snapshot` re-establishes the document anyway, and presence is re-sent
|
|
371
|
+
on the heartbeat. The script ships in the package but `ws` does not: it is a
|
|
372
|
+
devDependency, because the relay is a development tool rather than part of the
|
|
373
|
+
client. Install `ws` yourself to run it elsewhere.
|
|
374
|
+
|
|
375
|
+
### Keep the authoritative document
|
|
376
|
+
|
|
377
|
+
A pure relay means the document lives only in the connected peers. When the last
|
|
378
|
+
one leaves, nothing holds it. If you want a copy that outlives the room, the
|
|
379
|
+
cheapest correct answer is to run **an ordinary peer on the server**: a store, a
|
|
380
|
+
sync client, and no presence.
|
|
381
|
+
|
|
382
|
+
```ts
|
|
383
|
+
import { createStore, type EditorRecord } from "@mocanvas/editor"
|
|
384
|
+
import { createSyncClient } from "@mocanvas/sync"
|
|
385
|
+
|
|
386
|
+
const store = createStore()
|
|
387
|
+
createSyncClient<EditorRecord>({ store, roomId, transport }).connect()
|
|
388
|
+
|
|
389
|
+
const snapshot = store.getStoreSnapshot("document") // persist this
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
It merges like everyone else, and `store.loadStoreSnapshot(snapshot)` brings it
|
|
393
|
+
back after a restart. Nothing on the wire privileges it: "authoritative" here
|
|
394
|
+
means "the peer that is always in the room", not a different role.
|
|
395
|
+
|
|
396
|
+
### Where `mergeRemoteChanges` fits
|
|
397
|
+
|
|
398
|
+
Inside the client, and you normally never call it. When a `diff` arrives, the
|
|
399
|
+
client asks the CRDT which fields won and applies only those:
|
|
400
|
+
|
|
401
|
+
```ts
|
|
402
|
+
store.mergeRemoteChanges(() => store.applyDiff(winningFields))
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
`mergeRemoteChanges` marks everything done inside it `source: "remote"`, which
|
|
406
|
+
is what stops the applied change from entering the local undo stack and from
|
|
407
|
+
being picked up by the outgoing listener and broadcast straight back.
|
|
408
|
+
|
|
409
|
+
If you are writing your own merging peer rather than using `createSyncClient` —
|
|
410
|
+
a server that wants the records without the client's lifecycle, say — that pair
|
|
411
|
+
is the whole shape of it: `createCrdt({ clientId, getRecord })`, then
|
|
412
|
+
`crdt.mergeRemote(diff)` for the winning fields, then
|
|
413
|
+
`store.mergeRemoteChanges(() => store.applyDiff(...))` to apply them. Anything
|
|
414
|
+
you write to the store *outside* `mergeRemoteChanges` counts as a local user
|
|
415
|
+
edit and will be stamped and broadcast.
|
|
416
|
+
|
|
417
|
+
---
|
|
418
|
+
|
|
419
|
+
## 6. Things that bite
|
|
420
|
+
|
|
421
|
+
### Echoing your own changes back
|
|
422
|
+
|
|
423
|
+
Two guards stop the obvious loop. Outgoing changes come from
|
|
424
|
+
`store.listen(cb, { source: "user", scope: "document" })`, and incoming ones are
|
|
425
|
+
applied inside `mergeRemoteChanges`, which reports them as `remote` — so they
|
|
426
|
+
never reach that listener. On top of that the client ignores `diff` and
|
|
427
|
+
`presence` messages carrying its own `clientId`, which matters on any transport
|
|
428
|
+
that echoes to the sender.
|
|
429
|
+
|
|
430
|
+
You meet the loop by writing to the store yourself on behalf of a peer —
|
|
431
|
+
restoring from your own persistence layer, or applying a message from a channel
|
|
432
|
+
of your own. Wrap it:
|
|
433
|
+
|
|
434
|
+
```ts
|
|
435
|
+
store.mergeRemoteChanges(() => store.applyDiff(diff))
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
Without the wrapper the change goes out as though you had drawn it, the peer
|
|
439
|
+
merges it and rewrites, and you have a round trip per edit.
|
|
440
|
+
|
|
441
|
+
### Undo across clients
|
|
442
|
+
|
|
443
|
+
Undo is local, and it is not a retraction. The history manager listens with
|
|
444
|
+
`{ source: "user", scope: "document" }`, so nothing a peer did ever enters your
|
|
445
|
+
stack — you cannot undo somebody else's work, which is what you want.
|
|
446
|
+
|
|
447
|
+
What surprises people is the other direction. `editor.undo()` applies the
|
|
448
|
+
reverse diff as an ordinary local change, so it is stamped with a *new*, greater
|
|
449
|
+
stamp and broadcast like any edit. Undo therefore means "write the old value
|
|
450
|
+
back, now", not "remove my edit from history everywhere". So undoing a move
|
|
451
|
+
after a peer has moved the same shape wins, because your stamp is newer — that
|
|
452
|
+
is a later write, not a merge conflict; and undoing a *create* deletes the
|
|
453
|
+
record for everyone, unless a peer's edit to it outranks the tombstone.
|
|
454
|
+
|
|
455
|
+
### Applying a remote change inside a side effect
|
|
456
|
+
|
|
457
|
+
Every side-effect handler is given the change source as its last argument:
|
|
458
|
+
|
|
459
|
+
```ts
|
|
460
|
+
editor.sideEffects.registerAfterChangeHandler("shape", (prev, next, source) => {
|
|
461
|
+
if (source !== "user") return
|
|
462
|
+
// …runs once, on the replica where the person actually did it
|
|
463
|
+
})
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
A handler that does not check `source` runs on every replica, for every merged
|
|
467
|
+
change. Sometimes that is right — a validator clamping a value should clamp
|
|
468
|
+
everywhere. Sometimes it is a duplicated side effect, a second network call, or
|
|
469
|
+
a second record created per peer.
|
|
470
|
+
|
|
471
|
+
The client copes with the honest case. It compares what it asked the store to do
|
|
472
|
+
with what the store actually did, and any difference — a validator rewriting a
|
|
473
|
+
merged record, a side effect touching another one — is stamped as a local write
|
|
474
|
+
and broadcast; otherwise that rewrite would exist on one replica only, since
|
|
475
|
+
changes made inside `mergeRemoteChanges` never leave. A rewrite that is a
|
|
476
|
+
function of the record and the document then settles in one round: the peers
|
|
477
|
+
apply a value they already hold and produce nothing further.
|
|
478
|
+
|
|
479
|
+
A side effect that is **not** a function of its inputs — one that reads the wall
|
|
480
|
+
clock, a random number, or per-user state — has no fixed point. It rewrites
|
|
481
|
+
differently on each replica, each rewrite is broadcast, and the replicas chase
|
|
482
|
+
each other. No design converges on that. Keep non-deterministic work out of side
|
|
483
|
+
effects on `document`-scope records, or gate it on `source === "user"`.
|
|
484
|
+
|
|
485
|
+
### A peer on another protocol version
|
|
486
|
+
|
|
487
|
+
A version 2 peer merges the same messages to a different answer, so the two must
|
|
488
|
+
not share a room. Such a message is reported once through `onError`; surface it
|
|
489
|
+
rather than swallowing it, because the symptom otherwise is a room where
|
|
490
|
+
everything looks connected and nothing arrives.
|