@mocanvas/sync 1.0.0 → 4.0.1

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.js CHANGED
@@ -1,11 +1,499 @@
1
1
  import { react, atom } from '@mocanvas/state';
2
- import { uniqueId, isRecordsDiffEmpty } from '@mocanvas/store';
2
+ import { uniqueId, isRecordsDiffEmpty, createEmptyRecordsDiff } from '@mocanvas/store';
3
3
  import { InstancePresenceRecordType } from '@mocanvas/editor';
4
4
  import { track, useValue } from '@mocanvas/state/react';
5
5
  import { useRef, useState, useEffect } from 'react';
6
6
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
7
7
 
8
8
  // src/SyncClient.ts
9
+ var CRDT_STATE_VERSION = 1;
10
+ var DEFAULT_TOMBSTONE_LIMIT = 5e3;
11
+ var DEFAULT_TOMBSTONE_MAX_AGE_MS = 60 * 60 * 1e3;
12
+ function compareStamps(a, b) {
13
+ if (a.lamport !== b.lamport) return a.lamport < b.lamport ? -1 : 1;
14
+ if (a.client === b.client) return 0;
15
+ return a.client < b.client ? -1 : 1;
16
+ }
17
+ function isStamp(value) {
18
+ if (typeof value !== "object" || value === null) return false;
19
+ const stamp = value;
20
+ return typeof stamp.lamport === "number" && Number.isFinite(stamp.lamport) && typeof stamp.client === "string";
21
+ }
22
+ function createLamportClock(start = 0) {
23
+ let value = start;
24
+ return {
25
+ get: () => value,
26
+ tick: () => value += 1,
27
+ observe(remote) {
28
+ if (!Number.isFinite(remote)) return;
29
+ value = Math.max(value, remote) + 1;
30
+ }
31
+ };
32
+ }
33
+ function createEmptyStampedDiff() {
34
+ return { puts: [], removes: [] };
35
+ }
36
+ function isStampedDiffEmpty(diff) {
37
+ return diff.puts.length === 0 && diff.removes.length === 0;
38
+ }
39
+ function isStampedDiffLike(value) {
40
+ if (typeof value !== "object" || value === null) return false;
41
+ const diff = value;
42
+ if (!Array.isArray(diff.puts) || !Array.isArray(diff.removes)) return false;
43
+ for (const put of diff.puts) {
44
+ if (typeof put !== "object" || put === null) return false;
45
+ const { record, fields, base } = put;
46
+ if (typeof record !== "object" || record === null) return false;
47
+ if (typeof record.id !== "string") return false;
48
+ if (typeof record.typeName !== "string") return false;
49
+ if (!isStampMap(fields)) return false;
50
+ if (base !== void 0 && !isStamp(base)) return false;
51
+ }
52
+ for (const remove of diff.removes) {
53
+ if (typeof remove !== "object" || remove === null) return false;
54
+ const { id, stamp } = remove;
55
+ if (typeof id !== "string" || !isStamp(stamp)) return false;
56
+ }
57
+ return true;
58
+ }
59
+ function createEmptyCrdtState() {
60
+ return { version: CRDT_STATE_VERSION, clock: 0, records: {} };
61
+ }
62
+ function isCrdtStateLike(value) {
63
+ if (typeof value !== "object" || value === null) return false;
64
+ const state = value;
65
+ if (typeof state.version !== "number") return false;
66
+ if (typeof state.clock !== "number") return false;
67
+ if (typeof state.records !== "object" || state.records === null || Array.isArray(state.records)) return false;
68
+ for (const key of Object.keys(state.records)) {
69
+ const record = state.records[key];
70
+ if (typeof record !== "object" || record === null) return false;
71
+ const { fields, deleted, base } = record;
72
+ if (!isStampMap(fields)) return false;
73
+ if (deleted !== void 0 && !isStamp(deleted)) return false;
74
+ if (base !== void 0 && !isStamp(base)) return false;
75
+ }
76
+ return true;
77
+ }
78
+ function isStampMap(value) {
79
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
80
+ for (const key of Object.keys(value)) {
81
+ if (!isStamp(value[key])) return false;
82
+ }
83
+ return true;
84
+ }
85
+ function maxStamp(a, b) {
86
+ if (!a) return b;
87
+ if (!b) return a;
88
+ return compareStamps(a, b) >= 0 ? a : b;
89
+ }
90
+ function greatestStamp(fields) {
91
+ let best;
92
+ for (const path of Object.keys(fields)) best = maxStamp(best, fields[path]);
93
+ return best;
94
+ }
95
+ function ancestorPaths(path) {
96
+ const out = [];
97
+ let index = path.indexOf(".");
98
+ while (index !== -1) {
99
+ out.push(path.slice(0, index));
100
+ index = path.indexOf(".", index + 1);
101
+ }
102
+ return out;
103
+ }
104
+ function claimPath(state, path, stamp) {
105
+ const own = state.fields.get(path);
106
+ if (own && compareStamps(stamp, own) <= 0) return null;
107
+ for (const ancestor of ancestorPaths(path)) {
108
+ const above = state.fields.get(ancestor);
109
+ if (above && compareStamps(stamp, above) <= 0) return null;
110
+ }
111
+ const survivors = [];
112
+ const prefix = `${path}.`;
113
+ for (const [other, existing] of Array.from(state.fields)) {
114
+ if (!other.startsWith(prefix)) continue;
115
+ if (compareStamps(existing, stamp) <= 0) state.fields.delete(other);
116
+ else survivors.push(other);
117
+ }
118
+ state.fields.set(path, stamp);
119
+ return survivors;
120
+ }
121
+ function isStamped(state, path) {
122
+ if (state.fields.has(path)) return true;
123
+ for (const ancestor of ancestorPaths(path)) {
124
+ if (state.fields.has(ancestor)) return true;
125
+ }
126
+ return false;
127
+ }
128
+ function highestAbsent(body, state, path) {
129
+ for (const ancestor of ancestorPaths(path)) {
130
+ if (readPath(body, ancestor) !== ABSENT) continue;
131
+ if (isStamped(state, ancestor) || hasStampedDescendant(state, ancestor)) continue;
132
+ return ancestor;
133
+ }
134
+ return path;
135
+ }
136
+ function hasStampedDescendant(state, path) {
137
+ const prefix = `${path}.`;
138
+ for (const other of state.fields.keys()) {
139
+ if (other.startsWith(prefix)) return true;
140
+ }
141
+ return false;
142
+ }
143
+ function createCrdt(options) {
144
+ const { clientId } = options;
145
+ const getRecord = options.getRecord ?? (() => void 0);
146
+ const tombstoneLimit = options.tombstoneLimit ?? DEFAULT_TOMBSTONE_LIMIT;
147
+ const tombstoneMaxAgeMs = options.tombstoneMaxAgeMs ?? DEFAULT_TOMBSTONE_MAX_AGE_MS;
148
+ const now = options.now ?? (() => Date.now());
149
+ const clock = createLamportClock();
150
+ const states = /* @__PURE__ */ new Map();
151
+ let outgoing = createEmptyStampedDiff();
152
+ const stateFor = (id) => {
153
+ let state = states.get(id);
154
+ if (!state) {
155
+ state = { fields: /* @__PURE__ */ new Map() };
156
+ states.set(id, state);
157
+ }
158
+ return state;
159
+ };
160
+ const startPass = () => ({ before: /* @__PURE__ */ new Map(), after: /* @__PURE__ */ new Map() });
161
+ const readBefore = (pass, id) => {
162
+ if (!pass.before.has(id)) pass.before.set(id, getRecord(id));
163
+ return pass.before.get(id);
164
+ };
165
+ const readCurrent = (pass, id) => pass.after.has(id) ? pass.after.get(id) : readBefore(pass, id);
166
+ const write = (pass, id, value) => {
167
+ readBefore(pass, id);
168
+ pass.after.set(id, value);
169
+ };
170
+ const finishPass = (pass) => {
171
+ const result = createEmptyRecordsDiff();
172
+ for (const [id, next] of pass.after) {
173
+ const prev = pass.before.get(id);
174
+ if (prev === next) continue;
175
+ const key = id;
176
+ if (prev === void 0) {
177
+ if (next !== void 0) result.added[key] = next;
178
+ } else if (next === void 0) {
179
+ result.removed[key] = prev;
180
+ } else {
181
+ result.updated[key] = [prev, next];
182
+ }
183
+ }
184
+ return result;
185
+ };
186
+ const applyRemove = (pass, id, stamp) => {
187
+ const state = stateFor(id);
188
+ if (state.deleted && compareStamps(stamp, state.deleted) <= 0) return;
189
+ state.deleted = stamp;
190
+ state.deletedAt = now();
191
+ state.base = maxStamp(state.base, stamp);
192
+ let alive = false;
193
+ for (const [path, existing] of Array.from(state.fields)) {
194
+ if (compareStamps(existing, stamp) > 0) alive = true;
195
+ else state.fields.delete(path);
196
+ }
197
+ if (!alive) {
198
+ if (readCurrent(pass, id) !== void 0) write(pass, id, void 0);
199
+ return;
200
+ }
201
+ const record = readCurrent(pass, id);
202
+ if (!record) return;
203
+ const revival = { lamport: clock.tick(), client: clientId };
204
+ const fields = {};
205
+ for (const path of leafPaths(record)) {
206
+ fields[path] = revival;
207
+ claimPath(state, path, revival);
208
+ }
209
+ state.base = revival;
210
+ outgoing.puts.push({ record, fields, base: revival });
211
+ };
212
+ const adoptBody = (record, body, state) => {
213
+ const paths = /* @__PURE__ */ new Set([...leafPaths(body), ...leafPaths(record)]);
214
+ let next = record;
215
+ for (const path of Array.from(paths).sort()) {
216
+ if (path === "id" || path === "typeName") continue;
217
+ if (isStamped(state, path) || hasStampedDescendant(state, path)) continue;
218
+ const value = readPath(body, path);
219
+ next = writePath(next, value === ABSENT ? highestAbsent(body, state, path) : path, value);
220
+ }
221
+ return next;
222
+ };
223
+ const applyPut = (pass, put) => {
224
+ const id = put.record.id;
225
+ const state = stateFor(id);
226
+ const claims = [];
227
+ for (const path of Object.keys(put.fields)) {
228
+ const stamp = put.fields[path];
229
+ if (!stamp) continue;
230
+ if (state.deleted && compareStamps(stamp, state.deleted) <= 0) continue;
231
+ const survivors = claimPath(state, path, stamp);
232
+ if (survivors === null) continue;
233
+ claims.push({ path, survivors });
234
+ }
235
+ const base = put.base ?? greatestStamp(put.fields);
236
+ const bodyWins = base !== void 0 && (state.base === void 0 || compareStamps(base, state.base) > 0);
237
+ if (claims.length === 0 && !bodyWins) return;
238
+ const current = readCurrent(pass, id);
239
+ if (current === void 0) {
240
+ if (claims.length === 0) return;
241
+ write(pass, id, put.record);
242
+ state.base = maxStamp(state.base, base);
243
+ return;
244
+ }
245
+ let next = current;
246
+ for (const { path, survivors } of claims) {
247
+ if (path === "id") continue;
248
+ const before = next;
249
+ next = writePath(next, path, readPath(put.record, path));
250
+ for (const survivor of survivors) next = writePath(next, survivor, readPath(before, survivor));
251
+ }
252
+ if (bodyWins) {
253
+ next = adoptBody(next, put.record, state);
254
+ state.base = base;
255
+ }
256
+ if (next !== current) write(pass, id, next);
257
+ };
258
+ const observeStamps = (stamped) => {
259
+ let max = -1;
260
+ for (const put of stamped.puts) {
261
+ for (const path of Object.keys(put.fields)) {
262
+ const stamp = put.fields[path];
263
+ if (stamp && stamp.lamport > max) max = stamp.lamport;
264
+ }
265
+ if (put.base && put.base.lamport > max) max = put.base.lamport;
266
+ }
267
+ for (const remove of stamped.removes) {
268
+ if (remove.stamp.lamport > max) max = remove.stamp.lamport;
269
+ }
270
+ if (max >= 0) clock.observe(max);
271
+ };
272
+ const collectTombstones = () => {
273
+ const gone = [];
274
+ for (const [id, state] of states) {
275
+ if (state.deleted && state.fields.size === 0) gone.push({ id, at: state.deletedAt ?? 0 });
276
+ }
277
+ if (gone.length === 0) return;
278
+ const cutoff = now() - tombstoneMaxAgeMs;
279
+ let overflow = gone.length - tombstoneLimit;
280
+ if (overflow <= 0 && gone.every((entry) => entry.at >= cutoff)) return;
281
+ gone.sort((a, b) => a.at - b.at);
282
+ for (const entry of gone) {
283
+ if (overflow > 0) {
284
+ states.delete(entry.id);
285
+ overflow--;
286
+ continue;
287
+ }
288
+ if (entry.at < cutoff) states.delete(entry.id);
289
+ }
290
+ };
291
+ return {
292
+ clientId,
293
+ clock,
294
+ stampLocal(diff) {
295
+ const stamp = { lamport: clock.tick(), client: clientId };
296
+ const result = createEmptyStampedDiff();
297
+ for (const id in diff.added) {
298
+ const record = diff.added[id];
299
+ if (!record) continue;
300
+ const fields = {};
301
+ const state = stateFor(id);
302
+ for (const path of leafPaths(record)) {
303
+ fields[path] = stamp;
304
+ claimPath(state, path, stamp);
305
+ }
306
+ state.base = stamp;
307
+ result.puts.push({ record, fields, base: stamp });
308
+ }
309
+ for (const id in diff.updated) {
310
+ const pair = diff.updated[id];
311
+ if (!pair) continue;
312
+ const [from, to] = pair;
313
+ const paths = changedPaths(from, to);
314
+ if (paths.length === 0) continue;
315
+ const fields = {};
316
+ const state = stateFor(id);
317
+ for (const path of paths) {
318
+ fields[path] = stamp;
319
+ claimPath(state, path, stamp);
320
+ }
321
+ state.base = stamp;
322
+ result.puts.push({ record: to, fields, base: stamp });
323
+ }
324
+ for (const id in diff.removed) {
325
+ const state = stateFor(id);
326
+ state.deleted = stamp;
327
+ state.deletedAt = now();
328
+ state.fields.clear();
329
+ state.base = stamp;
330
+ result.removes.push({ id, stamp });
331
+ }
332
+ collectTombstones();
333
+ return result;
334
+ },
335
+ mergeRemote(stamped) {
336
+ observeStamps(stamped);
337
+ const pass = startPass();
338
+ for (const remove of stamped.removes) applyRemove(pass, remove.id, remove.stamp);
339
+ for (const put of stamped.puts) applyPut(pass, put);
340
+ collectTombstones();
341
+ return finishPass(pass);
342
+ },
343
+ getState() {
344
+ const records = {};
345
+ for (const [id, state] of states) {
346
+ const fields = {};
347
+ for (const [path, stamp] of state.fields) fields[path] = stamp;
348
+ records[id] = {
349
+ fields,
350
+ ...state.base ? { base: state.base } : {},
351
+ ...state.deleted ? { deleted: state.deleted, deletedAt: state.deletedAt ?? 0 } : {}
352
+ };
353
+ }
354
+ return { version: CRDT_STATE_VERSION, clock: clock.get(), records };
355
+ },
356
+ applyState(state) {
357
+ const pass = startPass();
358
+ if (state.version !== CRDT_STATE_VERSION) return finishPass(pass);
359
+ clock.observe(state.clock);
360
+ for (const id of Object.keys(state.records)) {
361
+ const incoming = state.records[id];
362
+ if (!incoming) continue;
363
+ if (incoming.deleted) applyRemove(pass, id, incoming.deleted);
364
+ const local = stateFor(id);
365
+ for (const path of Object.keys(incoming.fields)) {
366
+ const stamp = incoming.fields[path];
367
+ if (!stamp) continue;
368
+ clock.observe(stamp.lamport);
369
+ if (local.deleted && compareStamps(stamp, local.deleted) <= 0) continue;
370
+ claimPath(local, path, stamp);
371
+ }
372
+ if (incoming.base) {
373
+ clock.observe(incoming.base.lamport);
374
+ local.base = maxStamp(local.base, incoming.base);
375
+ }
376
+ }
377
+ collectTombstones();
378
+ return finishPass(pass);
379
+ },
380
+ takeOutgoing() {
381
+ if (isStampedDiffEmpty(outgoing)) return null;
382
+ const result = outgoing;
383
+ outgoing = createEmptyStampedDiff();
384
+ return result;
385
+ },
386
+ size: () => states.size,
387
+ collectTombstones
388
+ };
389
+ }
390
+ function stampedDiffFromSnapshot(records, state) {
391
+ const result = createEmptyStampedDiff();
392
+ for (const record of records) {
393
+ const entry = state.records[record.id];
394
+ const fields = entry?.fields ?? {};
395
+ const base = entry?.base ?? greatestStamp(fields);
396
+ result.puts.push(base ? { record, fields, base } : { record, fields });
397
+ }
398
+ for (const id of Object.keys(state.records)) {
399
+ const deleted = state.records[id]?.deleted;
400
+ if (deleted) result.removes.push({ id, stamp: deleted });
401
+ }
402
+ return result;
403
+ }
404
+ var ABSENT = /* @__PURE__ */ Symbol("absent");
405
+ function isPlainObject(value) {
406
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
407
+ const proto = Object.getPrototypeOf(value);
408
+ return proto === Object.prototype || proto === null;
409
+ }
410
+ function leafPaths(value, prefix = "") {
411
+ const out = [];
412
+ collectLeaves(value, prefix, out);
413
+ return out;
414
+ }
415
+ function collectLeaves(value, prefix, out) {
416
+ if (isPlainObject(value)) {
417
+ const keys = Object.keys(value);
418
+ if (keys.length > 0) {
419
+ for (const key of keys) collectLeaves(value[key], prefix ? `${prefix}.${key}` : key, out);
420
+ return;
421
+ }
422
+ }
423
+ if (prefix !== "") out.push(prefix);
424
+ }
425
+ function changedPaths(from, to) {
426
+ const out = [];
427
+ collectChanges(from, to, "", out);
428
+ return out;
429
+ }
430
+ function collectChanges(from, to, prefix, out) {
431
+ if (isPlainObject(from) && isPlainObject(to)) {
432
+ for (const key of Object.keys(from)) {
433
+ const path = prefix ? `${prefix}.${key}` : key;
434
+ if (!(key in to)) out.push(path);
435
+ else collectChanges(from[key], to[key], path, out);
436
+ }
437
+ for (const key of Object.keys(to)) {
438
+ if (key in from) continue;
439
+ collectLeaves(to[key], prefix ? `${prefix}.${key}` : key, out);
440
+ }
441
+ return;
442
+ }
443
+ if (!deepEqual(from, to) && prefix !== "") out.push(prefix);
444
+ }
445
+ function readPath(source, path) {
446
+ let current = source;
447
+ for (const segment of path.split(".")) {
448
+ if (!isPlainObject(current) || !(segment in current)) return ABSENT;
449
+ current = current[segment];
450
+ }
451
+ return current;
452
+ }
453
+ function writePath(record, path, value) {
454
+ const current = readPath(record, path);
455
+ if (value === ABSENT) {
456
+ if (current === ABSENT) return record;
457
+ } else if (current !== ABSENT && deepEqual(current, value)) {
458
+ return record;
459
+ }
460
+ const segments = path.split(".");
461
+ return setIn(record, segments, 0, value);
462
+ }
463
+ function setIn(target, segments, index, value) {
464
+ const key = segments[index];
465
+ if (key === void 0) return target;
466
+ const next = { ...target };
467
+ if (index === segments.length - 1) {
468
+ if (value === ABSENT) delete next[key];
469
+ else next[key] = value;
470
+ return next;
471
+ }
472
+ const child = next[key];
473
+ next[key] = setIn(isPlainObject(child) ? child : {}, segments, index + 1, value);
474
+ return next;
475
+ }
476
+ function deepEqual(a, b) {
477
+ if (Object.is(a, b)) return true;
478
+ if (Array.isArray(a)) {
479
+ if (!Array.isArray(b) || a.length !== b.length) return false;
480
+ for (let i = 0; i < a.length; i++) {
481
+ if (!deepEqual(a[i], b[i])) return false;
482
+ }
483
+ return true;
484
+ }
485
+ if (isPlainObject(a) && isPlainObject(b)) {
486
+ const keysA = Object.keys(a);
487
+ const keysB = Object.keys(b);
488
+ if (keysA.length !== keysB.length) return false;
489
+ for (const key of keysA) {
490
+ if (!(key in b)) return false;
491
+ if (!deepEqual(a[key], b[key])) return false;
492
+ }
493
+ return true;
494
+ }
495
+ return false;
496
+ }
9
497
  var DEFAULT_PRESENCE_THROTTLE_MS = Math.ceil(1e3 / 30);
10
498
  var DEFAULT_PRESENCE_HEARTBEAT_MS = 3e3;
11
499
  var DEFAULT_PRESENCE_TIMEOUT_MS = 1e4;
@@ -98,7 +586,15 @@ function createPresenceSync(options) {
98
586
  };
99
587
  }
100
588
  function isSamePresence(a, b) {
101
- return a.userId === b.userId && a.userName === b.userName && a.color === b.color && a.currentPageId === b.currentPageId && a.chatMessage === b.chatMessage && a.followingUserId === b.followingUserId && a.cursor.x === b.cursor.x && a.cursor.y === b.cursor.y && a.cursor.type === b.cursor.type && a.cursor.rotation === b.cursor.rotation && a.camera.x === b.camera.x && a.camera.y === b.camera.y && a.camera.z === b.camera.z && sameIds(a.selectedShapeIds, b.selectedShapeIds) && sameBrush(a.brush, b.brush) && a.scribbles.length === b.scribbles.length && a.scribbles.every((s, i) => s === b.scribbles[i]);
589
+ return a.userId === b.userId && a.userName === b.userName && a.color === b.color && a.currentPageId === b.currentPageId && a.chatMessage === b.chatMessage && a.followingUserId === b.followingUserId && sameCursor(a.cursor, b.cursor) && sameCamera(a.camera, b.camera) && sameIds(a.selectedShapeIds, b.selectedShapeIds) && sameBrush(a.brush, b.brush) && a.scribbles.length === b.scribbles.length && a.scribbles.every((s, i) => s === b.scribbles[i]);
590
+ }
591
+ function sameCamera(a, b) {
592
+ if (a === null || b === null) return a === b;
593
+ return a.x === b.x && a.y === b.y && a.z === b.z;
594
+ }
595
+ function sameCursor(a, b) {
596
+ if (a === null || b === null) return a === b;
597
+ return a.x === b.x && a.y === b.y && a.type === b.type && a.rotation === b.rotation;
102
598
  }
103
599
  function sameIds(a, b) {
104
600
  return a.length === b.length && a.every((id, i) => id === b[i]);
@@ -166,9 +662,9 @@ var PresenceRoom = class {
166
662
  };
167
663
 
168
664
  // src/protocol.ts
169
- var PROTOCOL_VERSION = 1;
665
+ var PROTOCOL_VERSION = 3;
170
666
  function encodeMessage(message) {
171
- return JSON.stringify(message);
667
+ return JSON.stringify({ ...message, version: PROTOCOL_VERSION });
172
668
  }
173
669
  function decodeMessage(data) {
174
670
  let value = data;
@@ -181,6 +677,11 @@ function decodeMessage(data) {
181
677
  }
182
678
  if (typeof value !== "object" || value === null) return null;
183
679
  const message = value;
680
+ const version = message["version"];
681
+ if (typeof version === "number" && version !== PROTOCOL_VERSION) {
682
+ const clientId = message["clientId"];
683
+ return typeof clientId === "string" ? { type: "unsupported", version, clientId } : { type: "unsupported", version };
684
+ }
184
685
  switch (message["type"]) {
185
686
  case "hello":
186
687
  if (typeof message["clientId"] !== "string") return null;
@@ -190,12 +691,14 @@ function decodeMessage(data) {
190
691
  const records = message["records"];
191
692
  if (!Array.isArray(records)) return null;
192
693
  if (!records.every(isRecordLike)) return null;
193
- return { type: "snapshot", records };
694
+ const state = message["state"];
695
+ if (!isCrdtStateLike(state)) return null;
696
+ return { type: "snapshot", records, state };
194
697
  }
195
698
  case "diff": {
196
699
  if (typeof message["clientId"] !== "string") return null;
197
700
  if (typeof message["seq"] !== "number") return null;
198
- if (!isDiffLike(message["diff"])) return null;
701
+ if (!isStampedDiffLike(message["diff"])) return null;
199
702
  return {
200
703
  type: "diff",
201
704
  clientId: message["clientId"],
@@ -218,15 +721,6 @@ function decodeMessage(data) {
218
721
  function isRecordLike(value) {
219
722
  return typeof value === "object" && value !== null && typeof value.id === "string" && typeof value.typeName === "string";
220
723
  }
221
- function isDiffLike(value) {
222
- if (typeof value !== "object" || value === null) return false;
223
- const diff = value;
224
- for (const key of ["added", "updated", "removed"]) {
225
- const part = diff[key];
226
- if (typeof part !== "object" || part === null || Array.isArray(part)) return false;
227
- }
228
- return true;
229
- }
230
724
 
231
725
  // src/SyncClient.ts
232
726
  function createSyncClient(options) {
@@ -234,7 +728,14 @@ function createSyncClient(options) {
234
728
  const clientId = options.clientId ?? uniqueId(12);
235
729
  const status = atom(`sync.status:${roomId}`, "offline");
236
730
  const room = new PresenceRoom(store, options.presenceTimeoutMs ?? DEFAULT_PRESENCE_TIMEOUT_MS);
731
+ const crdt = createCrdt({
732
+ clientId,
733
+ getRecord: (id) => store.unsafeGetWithoutCapture(id),
734
+ ...options.tombstoneLimit !== void 0 ? { tombstoneLimit: options.tombstoneLimit } : {},
735
+ ...options.tombstoneMaxAgeMs !== void 0 ? { tombstoneMaxAgeMs: options.tombstoneMaxAgeMs } : {}
736
+ });
237
737
  const unsubscribes = [];
738
+ const reportedVersions = /* @__PURE__ */ new Set();
238
739
  let presence = null;
239
740
  let seq = 0;
240
741
  let awaitingSnapshot = false;
@@ -247,9 +748,78 @@ function createSyncClient(options) {
247
748
  options.onError?.(error instanceof Error ? error : new Error(String(error)));
248
749
  }
249
750
  };
751
+ const broadcast = (stamped) => {
752
+ if (isStampedDiffEmpty(stamped)) return;
753
+ send({ type: "diff", clientId, seq: seq++, diff: stamped });
754
+ };
755
+ const flushCrdt = () => {
756
+ const outgoing = crdt.takeOutgoing();
757
+ if (outgoing) broadcast(outgoing);
758
+ };
759
+ const sendSnapshot = () => {
760
+ send({
761
+ type: "snapshot",
762
+ records: Object.values(store.serialize("document")),
763
+ state: crdt.getState()
764
+ });
765
+ };
766
+ const applyRemote = (diff) => {
767
+ if (isRecordsDiffEmpty(diff)) return;
768
+ const actual = store.extractingChanges(() => {
769
+ store.mergeRemoteChanges(() => {
770
+ store.applyDiff(diff);
771
+ });
772
+ });
773
+ reportRewrites(diff, actual);
774
+ };
775
+ const reportRewrites = (asked, actual) => {
776
+ const rewrite = createEmptyRecordsDiff();
777
+ const intended = (id) => asked.added[id] ?? asked.updated[id]?.[1];
778
+ const isDocument = (record) => store.getScope(record.typeName) === "document";
779
+ for (const key in actual.added) {
780
+ const id = key;
781
+ const after = actual.added[id];
782
+ if (!after || !isDocument(after)) continue;
783
+ const want = intended(id);
784
+ if (!want) rewrite.added[id] = after;
785
+ else if (!deepEqual(want, after)) rewrite.updated[id] = [want, after];
786
+ }
787
+ for (const key in actual.updated) {
788
+ const id = key;
789
+ const pair = actual.updated[id];
790
+ if (!pair || !isDocument(pair[1])) continue;
791
+ const want = intended(id);
792
+ if (!want) rewrite.updated[id] = pair;
793
+ else if (!deepEqual(want, pair[1])) rewrite.updated[id] = [want, pair[1]];
794
+ }
795
+ for (const key in actual.removed) {
796
+ const id = key;
797
+ const before = actual.removed[id];
798
+ if (!before || !isDocument(before)) continue;
799
+ if (!asked.removed[id]) rewrite.removed[id] = before;
800
+ }
801
+ if (isRecordsDiffEmpty(rewrite)) return;
802
+ broadcast(crdt.stampLocal(rewrite));
803
+ };
804
+ const stopListening = store.listen(
805
+ (entry) => {
806
+ if (isRecordsDiffEmpty(entry.changes)) return;
807
+ const stamped = crdt.stampLocal(entry.changes);
808
+ if (connected) broadcast(stamped);
809
+ },
810
+ { source: "user", scope: "document" }
811
+ );
250
812
  const handle = (message) => {
251
813
  if (disposed) return;
252
814
  switch (message.type) {
815
+ case "unsupported": {
816
+ if (reportedVersions.has(message.version)) return;
817
+ reportedVersions.add(message.version);
818
+ options.onError?.(
819
+ new Error(`A peer speaks protocol ${message.version}, we speak ${PROTOCOL_VERSION}`)
820
+ );
821
+ return;
822
+ }
253
823
  case "hello": {
254
824
  if (message.clientId === clientId) return;
255
825
  if (message.version !== PROTOCOL_VERSION) {
@@ -259,24 +829,36 @@ function createSyncClient(options) {
259
829
  return;
260
830
  }
261
831
  awaitingSnapshot = false;
262
- send({ type: "snapshot", records: Object.values(store.serialize("document")) });
832
+ sendSnapshot();
263
833
  presence?.poke();
264
834
  return;
265
835
  }
266
836
  case "snapshot": {
267
- if (!awaitingSnapshot) return;
837
+ if (awaitingSnapshot && crdt.size() === 0) {
838
+ awaitingSnapshot = false;
839
+ const removals = crdt.applyState(message.state);
840
+ const actual = store.extractingChanges(() => {
841
+ store.mergeRemoteChanges(() => {
842
+ store.put(message.records);
843
+ store.applyDiff(removals);
844
+ });
845
+ });
846
+ const asked = createEmptyRecordsDiff();
847
+ for (const record of message.records) asked.added[record.id] = record;
848
+ reportRewrites(asked, actual);
849
+ flushCrdt();
850
+ return;
851
+ }
268
852
  awaitingSnapshot = false;
269
- store.mergeRemoteChanges(() => {
270
- store.put(message.records);
271
- });
853
+ applyRemote(crdt.mergeRemote(stampedDiffFromSnapshot(message.records, message.state)));
854
+ flushCrdt();
272
855
  return;
273
856
  }
274
857
  case "diff": {
275
858
  if (message.clientId === clientId) return;
276
- if (isRecordsDiffEmpty(message.diff)) return;
277
- store.mergeRemoteChanges(() => {
278
- store.applyDiff(message.diff);
279
- });
859
+ if (isStampedDiffEmpty(message.diff)) return;
860
+ applyRemote(crdt.mergeRemote(message.diff));
861
+ flushCrdt();
280
862
  return;
281
863
  }
282
864
  case "presence": {
@@ -295,6 +877,7 @@ function createSyncClient(options) {
295
877
  if (disposed || !connected) return;
296
878
  status.set("online");
297
879
  send({ type: "hello", clientId, version: PROTOCOL_VERSION });
880
+ if (!awaitingSnapshot) sendSnapshot();
298
881
  presence?.poke();
299
882
  };
300
883
  const onClose = () => {
@@ -307,20 +890,11 @@ function createSyncClient(options) {
307
890
  connect() {
308
891
  if (disposed || connected) return;
309
892
  connected = true;
310
- awaitingSnapshot = true;
893
+ awaitingSnapshot = crdt.size() === 0;
311
894
  status.set("connecting");
312
895
  unsubscribes.push(transport.onMessage(handle));
313
896
  if (transport.onOpen) unsubscribes.push(transport.onOpen(onOpen));
314
897
  if (transport.onClose) unsubscribes.push(transport.onClose(onClose));
315
- unsubscribes.push(
316
- store.listen(
317
- (entry) => {
318
- if (isRecordsDiffEmpty(entry.changes)) return;
319
- send({ type: "diff", clientId, seq: seq++, diff: entry.changes });
320
- },
321
- { source: "user", scope: "document" }
322
- )
323
- );
324
898
  if (options.presence) {
325
899
  presence = createPresenceSync({
326
900
  editor: options.presence.editor,
@@ -343,7 +917,6 @@ function createSyncClient(options) {
343
917
  room.stopSweeping();
344
918
  room.clear();
345
919
  for (const off of unsubscribes.splice(0)) off();
346
- awaitingSnapshot = false;
347
920
  status.set("offline");
348
921
  },
349
922
  getStatus() {
@@ -353,6 +926,7 @@ function createSyncClient(options) {
353
926
  if (disposed) return;
354
927
  this.disconnect();
355
928
  disposed = true;
929
+ stopListening();
356
930
  transport.close();
357
931
  }
358
932
  };
@@ -576,7 +1150,8 @@ var CollaboratorCursor = track(function CollaboratorCursor2({
576
1150
  presence,
577
1151
  showName
578
1152
  }) {
579
- const point = editor.pageToScreen(presence.cursor);
1153
+ if (presence.cursor === null) return null;
1154
+ const point = editor.pageToViewport(presence.cursor);
580
1155
  const name = presence.userName || "Anonymous";
581
1156
  return /* @__PURE__ */ jsxs("g", { transform: `translate(${point.x}, ${point.y}) rotate(${presence.cursor.rotation})`, children: [
582
1157
  /* @__PURE__ */ jsx(
@@ -613,8 +1188,8 @@ var CollaboratorSelection = track(function CollaboratorSelection2({
613
1188
  [bounds.x, bounds.maxY]
614
1189
  ];
615
1190
  const points = corners.map(([x, y]) => {
616
- const screen = editor.pageToScreen({ x: m.a * x + m.c * y + m.e, y: m.b * x + m.d * y + m.f });
617
- return `${screen.x},${screen.y}`;
1191
+ const p = editor.pageToViewport({ x: m.a * x + m.c * y + m.e, y: m.b * x + m.d * y + m.f });
1192
+ return `${p.x},${p.y}`;
618
1193
  });
619
1194
  outlines.push(points.join(" "));
620
1195
  }
@@ -655,6 +1230,6 @@ function useSync(editor, options) {
655
1230
  return { status, client };
656
1231
  }
657
1232
 
658
- export { CollaboratorCursors, DEFAULT_PRESENCE_HEARTBEAT_MS, DEFAULT_PRESENCE_THROTTLE_MS, DEFAULT_PRESENCE_TIMEOUT_MS, PROTOCOL_VERSION, PresenceRoom, createBroadcastChannelTransport, createMemoryHub, createMemoryTransportPair, createPresenceSync, createSyncClient, createWebSocketTransport, decodeMessage, encodeMessage, isSamePresence, presenceIdForClient, useSync };
1233
+ export { CRDT_STATE_VERSION, CollaboratorCursors, DEFAULT_PRESENCE_HEARTBEAT_MS, DEFAULT_PRESENCE_THROTTLE_MS, DEFAULT_PRESENCE_TIMEOUT_MS, DEFAULT_TOMBSTONE_LIMIT, DEFAULT_TOMBSTONE_MAX_AGE_MS, PROTOCOL_VERSION, PresenceRoom, compareStamps, createBroadcastChannelTransport, createCrdt, createEmptyCrdtState, createEmptyStampedDiff, createLamportClock, createMemoryHub, createMemoryTransportPair, createPresenceSync, createSyncClient, createWebSocketTransport, decodeMessage, encodeMessage, isSamePresence, isStampedDiffEmpty, presenceIdForClient, stampedDiffFromSnapshot, useSync };
659
1234
  //# sourceMappingURL=index.js.map
660
1235
  //# sourceMappingURL=index.js.map