@mocanvas/sync 1.0.0 → 4.0.0
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/ARCHITECTURE.md +421 -0
- package/BENCHMARK.md +519 -0
- package/CLEAN_ROOM.md +50 -0
- package/COMPAT.md +282 -0
- package/CUSTOM_SHAPES.md +880 -0
- package/LICENSE +110 -16
- package/MIGRATION.md +807 -0
- package/README.md +164 -35
- package/UI.md +256 -0
- package/dist/index.d.ts +215 -19
- package/dist/index.js +614 -39
- package/dist/index.js.map +1 -1
- package/package.json +14 -16
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Signal } from '@mocanvas/state';
|
|
2
2
|
import { Store, UnknownRecord, RecordsDiff } from '@mocanvas/store';
|
|
3
|
-
import { InstancePresence, InstancePresenceId, Editor } from '@mocanvas/editor';
|
|
3
|
+
import { UserId, InstancePresence, InstancePresenceId, Editor } from '@mocanvas/editor';
|
|
4
4
|
import * as react from 'react';
|
|
5
5
|
|
|
6
6
|
/** Presence is sent at most this often (30 Hz). */
|
|
@@ -22,7 +22,7 @@ interface PresenceEditor {
|
|
|
22
22
|
};
|
|
23
23
|
};
|
|
24
24
|
readonly user: {
|
|
25
|
-
getId():
|
|
25
|
+
getId(): UserId;
|
|
26
26
|
getName(): string;
|
|
27
27
|
getColor(): string;
|
|
28
28
|
};
|
|
@@ -45,7 +45,7 @@ interface PresenceEditor {
|
|
|
45
45
|
h: number;
|
|
46
46
|
} | null;
|
|
47
47
|
scribbles: readonly unknown[];
|
|
48
|
-
followingUserId:
|
|
48
|
+
followingUserId: UserId | null;
|
|
49
49
|
};
|
|
50
50
|
/** Optional: used to sample the cursor, which is not itself a signal. */
|
|
51
51
|
on?(name: "event", fn: () => void): () => void;
|
|
@@ -99,20 +99,196 @@ declare class PresenceRoom<R extends UnknownRecord> {
|
|
|
99
99
|
private removeRecord;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
/** Bumped when the wire format changes incompatibly. */
|
|
103
|
-
declare const PROTOCOL_VERSION = 1;
|
|
104
102
|
/**
|
|
105
|
-
*
|
|
103
|
+
* A state-based CRDT over record fields.
|
|
104
|
+
*
|
|
105
|
+
* Every leaf field of every record is its own last-writer-wins register,
|
|
106
|
+
* keyed by a dotted path (`x`, `props.w`, `meta.notes.title`). A write carries
|
|
107
|
+
* a Lamport stamp; the greater stamp wins, and equal Lamport values are broken
|
|
108
|
+
* by client id so that both sides of a race reach the same answer from the
|
|
109
|
+
* same pair. Deletion is a stamped tombstone rather than a removal, so a late
|
|
110
|
+
* message cannot resurrect a record that was deleted after it was written.
|
|
111
|
+
*
|
|
112
|
+
* Consequences worth knowing before reading the code:
|
|
113
|
+
*
|
|
114
|
+
* - Arrays are opaque. `props.points` is one register holding the whole array,
|
|
115
|
+
* not one register per element. Two concurrent edits to one array pick a
|
|
116
|
+
* winner instead of merging; splitting arrays into elements would need
|
|
117
|
+
* element identity, which record data does not carry.
|
|
118
|
+
* - A stamp covers a whole subtree. Writing `meta` (setting it, or deleting
|
|
119
|
+
* the key) dominates `meta.a`: the claim loses to any stamp at or above its
|
|
120
|
+
* own path, and dropping a subtree keeps descendants stamped later than the
|
|
121
|
+
* write. Without that, `delete meta` racing `set meta.a` lands differently
|
|
122
|
+
* depending on arrival order.
|
|
123
|
+
* - A record also has one `base` register: the stamp at which some sender's
|
|
124
|
+
* whole record body was current. Fields nobody holds a stamp for — a record
|
|
125
|
+
* materialized without its creation message, or one that came back from
|
|
126
|
+
* under a tombstone, which erases field stamps — are governed by that single
|
|
127
|
+
* register, so every replica takes its body from the same message instead of
|
|
128
|
+
* from whichever one happened to arrive first.
|
|
129
|
+
* - Merging is idempotent (the same message twice is a no-op the second time)
|
|
130
|
+
* and commutative (per path the result is the maximum stamp, which does not
|
|
131
|
+
* depend on arrival order).
|
|
132
|
+
* - The merge is a pure function of the stamps; values only ever ride along.
|
|
133
|
+
*/
|
|
134
|
+
/** Version of the `CrdtState` blob on the wire. */
|
|
135
|
+
declare const CRDT_STATE_VERSION = 1;
|
|
136
|
+
/** How many collected-and-gone records keep a tombstone before it is dropped. */
|
|
137
|
+
declare const DEFAULT_TOMBSTONE_LIMIT = 5000;
|
|
138
|
+
/** How long a tombstone for a vanished record is kept. */
|
|
139
|
+
declare const DEFAULT_TOMBSTONE_MAX_AGE_MS: number;
|
|
140
|
+
/** A point in the partial order of writes, made total by the client tiebreak. */
|
|
141
|
+
interface Stamp {
|
|
142
|
+
readonly lamport: number;
|
|
143
|
+
readonly client: string;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* A total order on stamps: Lamport first, then client id. Two peers holding
|
|
147
|
+
* the same pair always agree, which is what makes the merge deterministic.
|
|
148
|
+
*/
|
|
149
|
+
declare function compareStamps(a: Stamp, b: Stamp): number;
|
|
150
|
+
interface LamportClock {
|
|
151
|
+
/** The current value. */
|
|
152
|
+
get(): number;
|
|
153
|
+
/** Advance for a local write and return the new value. */
|
|
154
|
+
tick(): number;
|
|
155
|
+
/** Fold in a stamp seen from a peer: `max(local, remote) + 1`. */
|
|
156
|
+
observe(remote: number): void;
|
|
157
|
+
}
|
|
158
|
+
declare function createLamportClock(start?: number): LamportClock;
|
|
159
|
+
/**
|
|
160
|
+
* One record's changed fields. `record` is the whole record as the sender saw
|
|
161
|
+
* it after the change; `fields` says which paths this message actually claims.
|
|
162
|
+
* A receiver that already has the record takes only the winning paths out of
|
|
163
|
+
* `record`; a receiver that does not have it materializes from `record` whole.
|
|
164
|
+
*/
|
|
165
|
+
interface StampedPut<R extends UnknownRecord = UnknownRecord> {
|
|
166
|
+
record: R;
|
|
167
|
+
fields: Record<string, Stamp>;
|
|
168
|
+
/**
|
|
169
|
+
* The stamp at which `record` was the sender's whole view of that record.
|
|
170
|
+
* Paths the receiver holds no stamp for are taken from `record` when this is
|
|
171
|
+
* the greatest such stamp it has seen, and left alone otherwise.
|
|
172
|
+
*/
|
|
173
|
+
base?: Stamp;
|
|
174
|
+
}
|
|
175
|
+
interface StampedRemove {
|
|
176
|
+
id: string;
|
|
177
|
+
stamp: Stamp;
|
|
178
|
+
}
|
|
179
|
+
/** What a `diff` message carries: puts and removes, each field stamped. */
|
|
180
|
+
interface StampedDiff<R extends UnknownRecord = UnknownRecord> {
|
|
181
|
+
puts: StampedPut<R>[];
|
|
182
|
+
removes: StampedRemove[];
|
|
183
|
+
}
|
|
184
|
+
declare function createEmptyStampedDiff<R extends UnknownRecord = UnknownRecord>(): StampedDiff<R>;
|
|
185
|
+
declare function isStampedDiffEmpty<R extends UnknownRecord>(diff: StampedDiff<R>): boolean;
|
|
186
|
+
/** What one record's registers look like once serialized. */
|
|
187
|
+
interface CrdtRecordState {
|
|
188
|
+
fields: Record<string, Stamp>;
|
|
189
|
+
/** The stamp of the message this record's unstamped fields came from. */
|
|
190
|
+
base?: Stamp;
|
|
191
|
+
deleted?: Stamp;
|
|
192
|
+
/** Local wall clock at the time the tombstone was taken, for collection. */
|
|
193
|
+
deletedAt?: number;
|
|
194
|
+
}
|
|
195
|
+
/** The whole replica's stamps, as carried by a `snapshot` message. */
|
|
196
|
+
interface CrdtState {
|
|
197
|
+
version: number;
|
|
198
|
+
clock: number;
|
|
199
|
+
records: Record<string, CrdtRecordState>;
|
|
200
|
+
}
|
|
201
|
+
declare function createEmptyCrdtState(): CrdtState;
|
|
202
|
+
interface CrdtOptions<R extends UnknownRecord = UnknownRecord> {
|
|
203
|
+
/** Identifies this replica; also the tiebreak in `compareStamps`. */
|
|
204
|
+
clientId: string;
|
|
205
|
+
/**
|
|
206
|
+
* Read the replica's current value for a record. The CRDT owns the stamps,
|
|
207
|
+
* never the values: the store stays the source of truth and a merge is
|
|
208
|
+
* expressed as a diff against whatever `getRecord` reports.
|
|
209
|
+
*/
|
|
210
|
+
getRecord?: ((id: string) => R | undefined) | undefined;
|
|
211
|
+
/** Tombstones kept for records that are gone. Default 5000. */
|
|
212
|
+
tombstoneLimit?: number | undefined;
|
|
213
|
+
/** How long such a tombstone lives. Default one hour. */
|
|
214
|
+
tombstoneMaxAgeMs?: number | undefined;
|
|
215
|
+
/** Injectable wall clock, for tests. */
|
|
216
|
+
now?: (() => number) | undefined;
|
|
217
|
+
}
|
|
218
|
+
interface Crdt<R extends UnknownRecord = UnknownRecord> {
|
|
219
|
+
readonly clientId: string;
|
|
220
|
+
readonly clock: LamportClock;
|
|
221
|
+
/**
|
|
222
|
+
* Stamp a diff the local user just made and return the message to send.
|
|
223
|
+
* The whole diff shares one stamp: it was one operation.
|
|
224
|
+
*/
|
|
225
|
+
stampLocal(diff: RecordsDiff<R>): StampedDiff<R>;
|
|
226
|
+
/**
|
|
227
|
+
* Merge a peer's message. The returned diff holds only the fields that won,
|
|
228
|
+
* as whole records the store can apply — never a blind overwrite.
|
|
229
|
+
*/
|
|
230
|
+
mergeRemote(stamped: StampedDiff<R>): RecordsDiff<R>;
|
|
231
|
+
/** This replica's stamps, for a `snapshot` message. */
|
|
232
|
+
getState(): CrdtState;
|
|
233
|
+
/**
|
|
234
|
+
* Adopt a peer's stamps wholesale. This is what a replica that is joining
|
|
235
|
+
* with no state of its own does, together with that peer's records: it takes
|
|
236
|
+
* the peer's stamps instead of starting blank, so its own later writes sort
|
|
237
|
+
* after everything already in the room. The returned diff removes whatever
|
|
238
|
+
* the peer has tombstoned and we still hold. Only for a replica holding no
|
|
239
|
+
* stamps at all: the caller adopts the peer's records wholesale alongside it,
|
|
240
|
+
* so a replica with work of its own must merge the snapshot through
|
|
241
|
+
* `stampedDiffFromSnapshot` instead of taking it.
|
|
242
|
+
*/
|
|
243
|
+
applyState(state: CrdtState): RecordsDiff<R>;
|
|
244
|
+
/**
|
|
245
|
+
* Writes this replica minted while merging, which the caller must broadcast.
|
|
246
|
+
* A record kept alive by an edit that outranks a delete is re-claimed whole
|
|
247
|
+
* under a fresh stamp: the tombstone leaves the fields it dominates with no
|
|
248
|
+
* provenance at all, and only a stamped body can put every replica on the
|
|
249
|
+
* same one. Empty when there is nothing to send.
|
|
250
|
+
*/
|
|
251
|
+
takeOutgoing(): StampedDiff<R> | null;
|
|
252
|
+
/** Number of records the state currently tracks (tests and diagnostics). */
|
|
253
|
+
size(): number;
|
|
254
|
+
/** Drop tombstones past the count or age bound. Called after every merge. */
|
|
255
|
+
collectTombstones(): void;
|
|
256
|
+
}
|
|
257
|
+
declare function createCrdt<R extends UnknownRecord = UnknownRecord>(options: CrdtOptions<R>): Crdt<R>;
|
|
258
|
+
/**
|
|
259
|
+
* Turn a peer's snapshot into a message the ordinary merge can eat: its
|
|
260
|
+
* records supply the values, its state supplies the stamps. Paths the state
|
|
261
|
+
* says nothing about are not claimed, so a snapshot can never overwrite a
|
|
262
|
+
* field the receiver has a stamp for.
|
|
263
|
+
*/
|
|
264
|
+
declare function stampedDiffFromSnapshot<R extends UnknownRecord>(records: readonly R[], state: CrdtState): StampedDiff<R>;
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Bumped when the wire format changes incompatibly.
|
|
268
|
+
*
|
|
269
|
+
* 2: `diff` carries a `StampedDiff` (per-field Lamport stamps) instead of a
|
|
270
|
+
* plain `RecordsDiff`, `snapshot` carries the CRDT state next to the
|
|
271
|
+
* records, and every message carries `version`.
|
|
272
|
+
* 3: a put carries `base`, the stamp its whole record body is current as of,
|
|
273
|
+
* and a record kept alive by an edit that outranks a delete is re-claimed
|
|
274
|
+
* whole under a fresh stamp. A version 2 peer merges the same messages to a
|
|
275
|
+
* different answer, so the two must not share a room.
|
|
276
|
+
*/
|
|
277
|
+
declare const PROTOCOL_VERSION = 3;
|
|
278
|
+
/**
|
|
279
|
+
* Every message is one JSON object with a `type` discriminator and a
|
|
280
|
+
* `version`, which `encodeMessage` stamps on and `decodeMessage` checks.
|
|
106
281
|
*
|
|
107
282
|
* ```
|
|
108
|
-
* hello
|
|
109
|
-
* snapshot
|
|
110
|
-
* diff
|
|
111
|
-
* presence
|
|
112
|
-
* bye
|
|
283
|
+
* hello a client joined; existing peers answer with a snapshot
|
|
284
|
+
* snapshot the document-scope records plus the sender's CRDT stamps
|
|
285
|
+
* diff one squashed store diff, stamped field by field
|
|
286
|
+
* presence one presence record (cursor, camera, selection)
|
|
287
|
+
* bye the client is leaving; drop its presence
|
|
288
|
+
* unsupported synthesised locally for a peer on another protocol version
|
|
113
289
|
* ```
|
|
114
290
|
*/
|
|
115
|
-
type SyncMessage<R extends UnknownRecord = UnknownRecord> = HelloMessage | SnapshotMessage<R> | DiffMessage<R> | PresenceMessage<R> | ByeMessage;
|
|
291
|
+
type SyncMessage<R extends UnknownRecord = UnknownRecord> = HelloMessage | SnapshotMessage<R> | DiffMessage<R> | PresenceMessage<R> | ByeMessage | UnsupportedMessage;
|
|
116
292
|
interface HelloMessage {
|
|
117
293
|
type: "hello";
|
|
118
294
|
clientId: string;
|
|
@@ -121,13 +297,15 @@ interface HelloMessage {
|
|
|
121
297
|
interface SnapshotMessage<R extends UnknownRecord = UnknownRecord> {
|
|
122
298
|
type: "snapshot";
|
|
123
299
|
records: R[];
|
|
300
|
+
/** The stamps that go with those records, so a joiner does not start blank. */
|
|
301
|
+
state: CrdtState;
|
|
124
302
|
}
|
|
125
303
|
interface DiffMessage<R extends UnknownRecord = UnknownRecord> {
|
|
126
304
|
type: "diff";
|
|
127
305
|
clientId: string;
|
|
128
306
|
/** Per-client counter; only used for logging and de-duplication. */
|
|
129
307
|
seq: number;
|
|
130
|
-
diff:
|
|
308
|
+
diff: StampedDiff<R>;
|
|
131
309
|
}
|
|
132
310
|
interface PresenceMessage<R extends UnknownRecord = UnknownRecord> {
|
|
133
311
|
type: "presence";
|
|
@@ -138,10 +316,21 @@ interface ByeMessage {
|
|
|
138
316
|
type: "bye";
|
|
139
317
|
clientId: string;
|
|
140
318
|
}
|
|
319
|
+
/**
|
|
320
|
+
* Never sent: `decodeMessage` returns this instead of guessing at a message
|
|
321
|
+
* whose `version` is not ours, so a peer on another protocol is reported once
|
|
322
|
+
* rather than half-parsed.
|
|
323
|
+
*/
|
|
324
|
+
interface UnsupportedMessage {
|
|
325
|
+
type: "unsupported";
|
|
326
|
+
version: number;
|
|
327
|
+
clientId?: string;
|
|
328
|
+
}
|
|
141
329
|
declare function encodeMessage<R extends UnknownRecord>(message: SyncMessage<R>): string;
|
|
142
330
|
/**
|
|
143
|
-
* Parse a message off the wire. Returns `null` for anything malformed
|
|
144
|
-
*
|
|
331
|
+
* Parse a message off the wire. Returns `null` for anything malformed and an
|
|
332
|
+
* `unsupported` message for a peer on another protocol version: neither can
|
|
333
|
+
* crash this client, and neither is ever mistaken for a message we understand.
|
|
145
334
|
*/
|
|
146
335
|
declare function decodeMessage<R extends UnknownRecord = UnknownRecord>(data: unknown): SyncMessage<R> | null;
|
|
147
336
|
|
|
@@ -216,6 +405,10 @@ interface SyncClientOptions<R extends UnknownRecord = UnknownRecord> {
|
|
|
216
405
|
/** Upper bound on presence send rate. Default 34ms (~30 Hz). */
|
|
217
406
|
presenceThrottleMs?: number | undefined;
|
|
218
407
|
presenceHeartbeatMs?: number | undefined;
|
|
408
|
+
/** How many tombstones for vanished records to keep. See `crdt.ts`. */
|
|
409
|
+
tombstoneLimit?: number | undefined;
|
|
410
|
+
/** How long such a tombstone lives, in ms. */
|
|
411
|
+
tombstoneMaxAgeMs?: number | undefined;
|
|
219
412
|
/** Called for protocol-level problems (a peer on another version, say). */
|
|
220
413
|
onError?: ((error: Error) => void) | undefined;
|
|
221
414
|
}
|
|
@@ -231,9 +424,12 @@ interface SyncClient {
|
|
|
231
424
|
/**
|
|
232
425
|
* Joins one room over `transport` and keeps `store` in step with its peers.
|
|
233
426
|
*
|
|
234
|
-
* Document changes made locally by the user are
|
|
235
|
-
*
|
|
236
|
-
* the
|
|
427
|
+
* Document changes made locally by the user are stamped by the CRDT (see
|
|
428
|
+
* `crdt.ts`) and broadcast; incoming messages are merged field by field and
|
|
429
|
+
* only the fields that won are applied, as remote changes, so they neither
|
|
430
|
+
* echo back nor land in the undo stack. Concurrent edits to different fields
|
|
431
|
+
* of one record all survive, and replicas converge whatever order messages
|
|
432
|
+
* arrive in.
|
|
237
433
|
*/
|
|
238
434
|
declare function createSyncClient<R extends UnknownRecord = UnknownRecord>(options: SyncClientOptions<R>): SyncClient;
|
|
239
435
|
|
|
@@ -275,4 +471,4 @@ interface UseSyncResult {
|
|
|
275
471
|
*/
|
|
276
472
|
declare function useSync(editor: Editor | null, options: UseSyncOptions): UseSyncResult;
|
|
277
473
|
|
|
278
|
-
export { type BroadcastChannelTransportOptions, type ByeMessage, CollaboratorCursors, type CollaboratorCursorsProps, DEFAULT_PRESENCE_HEARTBEAT_MS, DEFAULT_PRESENCE_THROTTLE_MS, DEFAULT_PRESENCE_TIMEOUT_MS, type DiffMessage, type HelloMessage, type MemoryHub, PROTOCOL_VERSION, type PresenceEditor, type PresenceMessage, PresenceRoom, type PresenceSync, type PresenceSyncOptions, type SnapshotMessage, type SyncClient, type SyncClientOptions, type SyncMessage, type SyncStatus, type Transport, type UseSyncOptions, type UseSyncResult, type WebSocketTransportOptions, createBroadcastChannelTransport, createMemoryHub, createMemoryTransportPair, createPresenceSync, createSyncClient, createWebSocketTransport, decodeMessage, encodeMessage, isSamePresence, presenceIdForClient, useSync };
|
|
474
|
+
export { type BroadcastChannelTransportOptions, type ByeMessage, CRDT_STATE_VERSION, CollaboratorCursors, type CollaboratorCursorsProps, type Crdt, type CrdtOptions, type CrdtRecordState, type CrdtState, DEFAULT_PRESENCE_HEARTBEAT_MS, DEFAULT_PRESENCE_THROTTLE_MS, DEFAULT_PRESENCE_TIMEOUT_MS, DEFAULT_TOMBSTONE_LIMIT, DEFAULT_TOMBSTONE_MAX_AGE_MS, type DiffMessage, type HelloMessage, type LamportClock, type MemoryHub, PROTOCOL_VERSION, type PresenceEditor, type PresenceMessage, PresenceRoom, type PresenceSync, type PresenceSyncOptions, type SnapshotMessage, type Stamp, type StampedDiff, type StampedPut, type StampedRemove, type SyncClient, type SyncClientOptions, type SyncMessage, type SyncStatus, type Transport, type UnsupportedMessage, type UseSyncOptions, type UseSyncResult, type WebSocketTransportOptions, compareStamps, createBroadcastChannelTransport, createCrdt, createEmptyCrdtState, createEmptyStampedDiff, createLamportClock, createMemoryHub, createMemoryTransportPair, createPresenceSync, createSyncClient, createWebSocketTransport, decodeMessage, encodeMessage, isSamePresence, isStampedDiffEmpty, presenceIdForClient, stampedDiffFromSnapshot, useSync };
|