@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/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(): string;
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: string | null;
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
- * Every message is one JSON object with a `type` discriminator.
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 a client joined; existing peers answer with a snapshot
109
- * snapshot the document-scope records as the sender currently sees them
110
- * diff one squashed store diff, in document scope
111
- * presence one presence record (cursor, camera, selection)
112
- * bye the client is leaving; drop its presence
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: RecordsDiff<R>;
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: a peer
144
- * on a newer protocol must never be able to crash this one.
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 broadcast as diffs; incoming
235
- * diffs are applied as remote changes so they neither echo back nor land in
236
- * the undo stack. Conflicts are resolved last-writer-wins per record.
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 };