@hanzo/base 0.2.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.
@@ -0,0 +1,1011 @@
1
+ // src/crdt/sync.ts
2
+ var CRDTSync = class {
3
+ constructor() {
4
+ this._ws = null;
5
+ this._state = "disconnected";
6
+ this._document = null;
7
+ this._wsUrl = null;
8
+ /** Flush interval for batching local ops. */
9
+ this._flushTimer = null;
10
+ this._flushIntervalMs = 50;
11
+ /** Reconnection. */
12
+ this._reconnectTimer = null;
13
+ this._reconnectAttempts = 0;
14
+ this._maxReconnectDelay = 3e4;
15
+ this._baseReconnectDelay = 500;
16
+ this._intentionalClose = false;
17
+ /** Presence. */
18
+ this._peers = /* @__PURE__ */ new Map();
19
+ this._localPresence = {};
20
+ this._presenceTimer = null;
21
+ this._presenceIntervalMs = 2e3;
22
+ this._peerTimeoutMs = 1e4;
23
+ /** Listeners. */
24
+ this._stateListeners = /* @__PURE__ */ new Set();
25
+ this._peerListeners = /* @__PURE__ */ new Set();
26
+ this._remoteChangeListeners = /* @__PURE__ */ new Set();
27
+ }
28
+ // ---- Public API ---------------------------------------------------------
29
+ get state() {
30
+ return this._state;
31
+ }
32
+ get peers() {
33
+ return this._peers;
34
+ }
35
+ /**
36
+ * Connect to the CRDT sync endpoint and start syncing a document.
37
+ *
38
+ * @param wsUrl - WebSocket URL, e.g. "wss://myapp.hanzo.ai/api/crdt"
39
+ * @param document - The CRDTDocument to sync.
40
+ * @param token - Optional auth token.
41
+ */
42
+ connect(wsUrl, document, token) {
43
+ if (this._ws) {
44
+ this.disconnect();
45
+ }
46
+ this._wsUrl = wsUrl;
47
+ this._document = document;
48
+ this._intentionalClose = false;
49
+ this._setState("connecting");
50
+ const url = new URL(wsUrl);
51
+ url.searchParams.set("documentId", document.id);
52
+ url.searchParams.set("siteId", document.clock.siteId);
53
+ if (token) {
54
+ url.searchParams.set("token", token);
55
+ }
56
+ this._ws = new WebSocket(url.toString());
57
+ this._ws.binaryType = "arraybuffer";
58
+ this._ws.onopen = () => {
59
+ this._reconnectAttempts = 0;
60
+ this._setState("connected");
61
+ if (document.getUnsyncedOps().length === 0) {
62
+ this._send({
63
+ type: "state_request",
64
+ documentId: document.id,
65
+ siteId: document.clock.siteId,
66
+ payload: {}
67
+ });
68
+ } else {
69
+ this._sendOps(Array.from(document.getUnsyncedOps()));
70
+ }
71
+ this._startFlush();
72
+ this._startPresence();
73
+ };
74
+ this._ws.onmessage = (e) => {
75
+ this._handleMessage(e.data);
76
+ };
77
+ this._ws.onclose = () => {
78
+ this._stopFlush();
79
+ this._stopPresence();
80
+ this._ws = null;
81
+ this._setState("disconnected");
82
+ if (!this._intentionalClose) {
83
+ this._scheduleReconnect();
84
+ }
85
+ };
86
+ this._ws.onerror = () => {
87
+ };
88
+ }
89
+ disconnect() {
90
+ this._intentionalClose = true;
91
+ this._stopFlush();
92
+ this._stopPresence();
93
+ this._clearReconnect();
94
+ if (this._ws) {
95
+ this._ws.close();
96
+ this._ws = null;
97
+ }
98
+ this._peers.clear();
99
+ this._setState("disconnected");
100
+ }
101
+ /** Update local presence metadata (cursor, name, etc.). */
102
+ updatePresence(meta) {
103
+ this._localPresence = meta;
104
+ this._broadcastPresence();
105
+ }
106
+ // ---- Listeners ----------------------------------------------------------
107
+ onStateChange(cb) {
108
+ this._stateListeners.add(cb);
109
+ return () => {
110
+ this._stateListeners.delete(cb);
111
+ };
112
+ }
113
+ onPeersChange(cb) {
114
+ this._peerListeners.add(cb);
115
+ return () => {
116
+ this._peerListeners.delete(cb);
117
+ };
118
+ }
119
+ onRemoteChange(cb) {
120
+ this._remoteChangeListeners.add(cb);
121
+ return () => {
122
+ this._remoteChangeListeners.delete(cb);
123
+ };
124
+ }
125
+ // ---- Message handling ---------------------------------------------------
126
+ _handleMessage(raw) {
127
+ let text;
128
+ if (raw instanceof ArrayBuffer) {
129
+ text = new TextDecoder().decode(raw);
130
+ } else {
131
+ text = raw;
132
+ }
133
+ let msg;
134
+ try {
135
+ msg = JSON.parse(text);
136
+ } catch {
137
+ return;
138
+ }
139
+ if (!this._document || msg.documentId !== this._document.id) return;
140
+ switch (msg.type) {
141
+ case "ops": {
142
+ const payload = msg.payload;
143
+ if (!payload.ops || payload.ops.length === 0) return;
144
+ const remoteOps = payload.ops.filter(
145
+ (op) => op.hlc.siteId !== this._document.clock.siteId
146
+ );
147
+ if (remoteOps.length > 0) {
148
+ this._document.applyRemoteOps(remoteOps);
149
+ for (const cb of this._remoteChangeListeners) {
150
+ try {
151
+ cb(remoteOps);
152
+ } catch {
153
+ }
154
+ }
155
+ }
156
+ break;
157
+ }
158
+ case "ack": {
159
+ const payload = msg.payload;
160
+ this._document.acknowledge(payload.lastOpId);
161
+ if (this._state === "syncing" && this._document.getUnsyncedOps().length === 0) {
162
+ this._setState("connected");
163
+ }
164
+ break;
165
+ }
166
+ case "presence": {
167
+ const payload = msg.payload;
168
+ if (msg.siteId === this._document.clock.siteId) return;
169
+ this._peers.set(msg.siteId, {
170
+ siteId: msg.siteId,
171
+ meta: payload.meta,
172
+ lastSeen: Date.now()
173
+ });
174
+ this._notifyPeers();
175
+ break;
176
+ }
177
+ case "state_response": {
178
+ const payload = msg.payload;
179
+ if (payload.ops && payload.ops.length > 0) {
180
+ this._document.applyRemoteOps(payload.ops);
181
+ }
182
+ break;
183
+ }
184
+ }
185
+ }
186
+ // ---- Flush (batched sending of local ops) -------------------------------
187
+ _startFlush() {
188
+ this._stopFlush();
189
+ this._flushTimer = setInterval(() => {
190
+ this._flush();
191
+ }, this._flushIntervalMs);
192
+ }
193
+ _stopFlush() {
194
+ if (this._flushTimer !== null) {
195
+ clearInterval(this._flushTimer);
196
+ this._flushTimer = null;
197
+ }
198
+ }
199
+ _flush() {
200
+ if (!this._document || !this._ws || this._ws.readyState !== WebSocket.OPEN) return;
201
+ const ops = this._document.collectOps();
202
+ if (ops.length === 0) return;
203
+ this._sendOps(ops);
204
+ this._setState("syncing");
205
+ }
206
+ _sendOps(ops) {
207
+ if (!this._document || !this._ws || this._ws.readyState !== WebSocket.OPEN) return;
208
+ this._send({
209
+ type: "ops",
210
+ documentId: this._document.id,
211
+ siteId: this._document.clock.siteId,
212
+ payload: { ops }
213
+ });
214
+ }
215
+ // ---- Presence -----------------------------------------------------------
216
+ _startPresence() {
217
+ this._stopPresence();
218
+ this._broadcastPresence();
219
+ this._presenceTimer = setInterval(() => {
220
+ this._broadcastPresence();
221
+ this._pruneStalePresence();
222
+ }, this._presenceIntervalMs);
223
+ }
224
+ _stopPresence() {
225
+ if (this._presenceTimer !== null) {
226
+ clearInterval(this._presenceTimer);
227
+ this._presenceTimer = null;
228
+ }
229
+ }
230
+ _broadcastPresence() {
231
+ if (!this._document || !this._ws || this._ws.readyState !== WebSocket.OPEN) return;
232
+ this._send({
233
+ type: "presence",
234
+ documentId: this._document.id,
235
+ siteId: this._document.clock.siteId,
236
+ payload: { meta: this._localPresence }
237
+ });
238
+ }
239
+ _pruneStalePresence() {
240
+ const now = Date.now();
241
+ let changed = false;
242
+ for (const [siteId, peer] of this._peers) {
243
+ if (now - peer.lastSeen > this._peerTimeoutMs) {
244
+ this._peers.delete(siteId);
245
+ changed = true;
246
+ }
247
+ }
248
+ if (changed) {
249
+ this._notifyPeers();
250
+ }
251
+ }
252
+ // ---- Reconnection -------------------------------------------------------
253
+ _scheduleReconnect() {
254
+ this._clearReconnect();
255
+ const delay = Math.min(
256
+ this._baseReconnectDelay * Math.pow(2, this._reconnectAttempts),
257
+ this._maxReconnectDelay
258
+ );
259
+ const jitter = delay * (0.75 + Math.random() * 0.5);
260
+ this._reconnectAttempts++;
261
+ this._reconnectTimer = setTimeout(() => {
262
+ this._reconnectTimer = null;
263
+ if (this._document && this._wsUrl) {
264
+ this.connect(this._wsUrl, this._document);
265
+ }
266
+ }, jitter);
267
+ }
268
+ _clearReconnect() {
269
+ if (this._reconnectTimer !== null) {
270
+ clearTimeout(this._reconnectTimer);
271
+ this._reconnectTimer = null;
272
+ }
273
+ }
274
+ // ---- Internal helpers ---------------------------------------------------
275
+ _send(msg) {
276
+ if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
277
+ this._ws.send(JSON.stringify(msg));
278
+ }
279
+ _setState(state) {
280
+ if (this._state === state) return;
281
+ this._state = state;
282
+ for (const cb of this._stateListeners) {
283
+ try {
284
+ cb(state);
285
+ } catch {
286
+ }
287
+ }
288
+ }
289
+ _notifyPeers() {
290
+ for (const cb of this._peerListeners) {
291
+ try {
292
+ cb(this._peers);
293
+ } catch {
294
+ }
295
+ }
296
+ }
297
+ };
298
+
299
+ // src/crdt/clock.ts
300
+ var HybridLogicalClock = class {
301
+ constructor(siteId) {
302
+ this.siteId = siteId ?? randomSiteId();
303
+ this._ts = Date.now();
304
+ this._counter = 0;
305
+ }
306
+ /** Generate a new timestamp, guaranteed > any previous. */
307
+ now() {
308
+ const wall = Date.now();
309
+ if (wall > this._ts) {
310
+ this._ts = wall;
311
+ this._counter = 0;
312
+ } else {
313
+ this._counter++;
314
+ }
315
+ return { ts: this._ts, counter: this._counter, siteId: this.siteId };
316
+ }
317
+ /** Receive a remote timestamp and merge with local clock. */
318
+ receive(remote) {
319
+ const wall = Date.now();
320
+ if (wall > this._ts && wall > remote.ts) {
321
+ this._ts = wall;
322
+ this._counter = 0;
323
+ } else if (remote.ts > this._ts) {
324
+ this._ts = remote.ts;
325
+ this._counter = remote.counter + 1;
326
+ } else if (this._ts > remote.ts) {
327
+ this._counter++;
328
+ } else {
329
+ this._counter = Math.max(this._counter, remote.counter) + 1;
330
+ }
331
+ return { ts: this._ts, counter: this._counter, siteId: this.siteId };
332
+ }
333
+ };
334
+ function compareHLC(a, b) {
335
+ if (a.ts !== b.ts) return a.ts - b.ts;
336
+ if (a.counter !== b.counter) return a.counter - b.counter;
337
+ if (a.siteId < b.siteId) return -1;
338
+ if (a.siteId > b.siteId) return 1;
339
+ return 0;
340
+ }
341
+ function randomSiteId() {
342
+ const buf = new Uint8Array(8);
343
+ if (typeof crypto !== "undefined" && crypto.getRandomValues) {
344
+ crypto.getRandomValues(buf);
345
+ } else {
346
+ for (let i = 0; i < buf.length; i++) {
347
+ buf[i] = Math.random() * 256 | 0;
348
+ }
349
+ }
350
+ return Array.from(buf, (b) => b.toString(16).padStart(2, "0")).join("");
351
+ }
352
+
353
+ // src/crdt/operations.ts
354
+ function makeOpId(hlc) {
355
+ return `${hlc.siteId}:${hlc.ts}:${hlc.counter}`;
356
+ }
357
+ function makePositionId(hlc, charIndex) {
358
+ return `${hlc.siteId}:${hlc.ts}:${hlc.counter}:${charIndex}`;
359
+ }
360
+
361
+ // src/crdt/text.ts
362
+ var CRDTText = class {
363
+ constructor(documentId, field, clock) {
364
+ this._nodes = [];
365
+ this._pendingOps = [];
366
+ this._listeners = /* @__PURE__ */ new Set();
367
+ /** Index from position id to array index for O(1) lookup. */
368
+ this._index = /* @__PURE__ */ new Map();
369
+ this._documentId = documentId;
370
+ this._field = field;
371
+ this._clock = clock;
372
+ }
373
+ // ---- Public API ---------------------------------------------------------
374
+ /** Insert text at a visible character position (0-based). */
375
+ insert(position, text) {
376
+ if (text.length === 0) {
377
+ throw new Error("CRDTText.insert: empty text");
378
+ }
379
+ const afterId = this._visibleIdAtPosition(position - 1);
380
+ const hlc = this._clock.now();
381
+ const positionIds = [];
382
+ let prevId = afterId;
383
+ for (let i = 0; i < text.length; i++) {
384
+ const posId = makePositionId(hlc, i);
385
+ positionIds.push(posId);
386
+ const node = {
387
+ id: posId,
388
+ char: text[i],
389
+ hlc: { ...hlc, counter: hlc.counter + i },
390
+ tombstone: false,
391
+ afterId: prevId
392
+ };
393
+ this._insertNode(node);
394
+ prevId = posId;
395
+ }
396
+ const op = {
397
+ id: makeOpId(hlc),
398
+ documentId: this._documentId,
399
+ field: this._field,
400
+ hlc,
401
+ type: "text.insert",
402
+ payload: { afterId, content: text, positionIds }
403
+ };
404
+ this._pendingOps.push(op);
405
+ this._notifyChange();
406
+ return op;
407
+ }
408
+ /** Delete `length` visible characters starting at `position` (0-based). */
409
+ delete(position, length) {
410
+ const ids = this._visibleIdsInRange(position, length);
411
+ if (ids.length === 0) {
412
+ throw new Error("CRDTText.delete: nothing to delete");
413
+ }
414
+ for (const id of ids) {
415
+ const idx = this._index.get(id);
416
+ if (idx !== void 0) {
417
+ this._nodes[idx].tombstone = true;
418
+ }
419
+ }
420
+ const hlc = this._clock.now();
421
+ const op = {
422
+ id: makeOpId(hlc),
423
+ documentId: this._documentId,
424
+ field: this._field,
425
+ hlc,
426
+ type: "text.delete",
427
+ payload: { positionIds: ids }
428
+ };
429
+ this._pendingOps.push(op);
430
+ this._notifyChange();
431
+ return op;
432
+ }
433
+ /** Apply a remote operation. */
434
+ applyRemote(op) {
435
+ this._clock.receive(op.hlc);
436
+ if (op.type === "text.insert") {
437
+ const payload = op.payload;
438
+ for (let i = 0; i < payload.content.length; i++) {
439
+ const node = {
440
+ id: payload.positionIds[i],
441
+ char: payload.content[i],
442
+ hlc: { ...op.hlc, counter: op.hlc.counter + i },
443
+ tombstone: false,
444
+ afterId: i === 0 ? payload.afterId : payload.positionIds[i - 1]
445
+ };
446
+ this._insertNode(node);
447
+ }
448
+ } else if (op.type === "text.delete") {
449
+ const payload = op.payload;
450
+ for (const posId of payload.positionIds) {
451
+ const idx = this._index.get(posId);
452
+ if (idx !== void 0) {
453
+ this._nodes[idx].tombstone = true;
454
+ }
455
+ }
456
+ }
457
+ this._notifyChange();
458
+ }
459
+ /** Get the current visible text. */
460
+ toString() {
461
+ let result = "";
462
+ for (const node of this._nodes) {
463
+ if (!node.tombstone) {
464
+ result += node.char;
465
+ }
466
+ }
467
+ return result;
468
+ }
469
+ /** Number of visible characters. */
470
+ get length() {
471
+ let count = 0;
472
+ for (const node of this._nodes) {
473
+ if (!node.tombstone) count++;
474
+ }
475
+ return count;
476
+ }
477
+ /** Drain pending local operations. */
478
+ drainOps() {
479
+ return this._pendingOps.splice(0, this._pendingOps.length);
480
+ }
481
+ onChange(callback) {
482
+ this._listeners.add(callback);
483
+ return () => {
484
+ this._listeners.delete(callback);
485
+ };
486
+ }
487
+ // ---- Internal -----------------------------------------------------------
488
+ /**
489
+ * Insert a node into the correct position in the array.
490
+ * RGA rule: among siblings sharing the same afterId, order by HLC descending
491
+ * (newer inserts appear first, pushing older ones right).
492
+ */
493
+ _insertNode(node) {
494
+ let parentIdx;
495
+ if (node.afterId === null) {
496
+ parentIdx = -1;
497
+ } else {
498
+ const idx = this._index.get(node.afterId);
499
+ if (idx === void 0) {
500
+ parentIdx = this._nodes.length - 1;
501
+ } else {
502
+ parentIdx = idx;
503
+ }
504
+ }
505
+ let insertIdx = parentIdx + 1;
506
+ while (insertIdx < this._nodes.length) {
507
+ const existing = this._nodes[insertIdx];
508
+ if (existing.afterId !== node.afterId) break;
509
+ if (compareHLC(existing.hlc, node.hlc) <= 0) break;
510
+ insertIdx++;
511
+ }
512
+ this._nodes.splice(insertIdx, 0, node);
513
+ this._rebuildIndex(insertIdx);
514
+ }
515
+ /** Rebuild the id->index map from `startIdx` onward. */
516
+ _rebuildIndex(startIdx) {
517
+ for (let i = startIdx; i < this._nodes.length; i++) {
518
+ this._index.set(this._nodes[i].id, i);
519
+ }
520
+ }
521
+ /** Get the position id of the visible character at position `pos`, or null for before-head. */
522
+ _visibleIdAtPosition(pos) {
523
+ if (pos < 0) return null;
524
+ let visible = -1;
525
+ for (const node of this._nodes) {
526
+ if (!node.tombstone) {
527
+ visible++;
528
+ if (visible === pos) return node.id;
529
+ }
530
+ }
531
+ for (let i = this._nodes.length - 1; i >= 0; i--) {
532
+ if (!this._nodes[i].tombstone) return this._nodes[i].id;
533
+ }
534
+ return null;
535
+ }
536
+ /** Get position ids for `length` visible characters starting at `position`. */
537
+ _visibleIdsInRange(position, length) {
538
+ const ids = [];
539
+ let visible = -1;
540
+ for (const node of this._nodes) {
541
+ if (node.tombstone) continue;
542
+ visible++;
543
+ if (visible >= position && visible < position + length) {
544
+ ids.push(node.id);
545
+ }
546
+ if (ids.length === length) break;
547
+ }
548
+ return ids;
549
+ }
550
+ _notifyChange() {
551
+ if (this._listeners.size === 0) return;
552
+ const text = this.toString();
553
+ for (const cb of this._listeners) {
554
+ try {
555
+ cb(text);
556
+ } catch {
557
+ }
558
+ }
559
+ }
560
+ };
561
+
562
+ // src/crdt/counter.ts
563
+ var CRDTCounter = class {
564
+ constructor(documentId, field, clock) {
565
+ /** Per-site positive accumulator. */
566
+ this._positive = /* @__PURE__ */ new Map();
567
+ /** Per-site negative accumulator. */
568
+ this._negative = /* @__PURE__ */ new Map();
569
+ this._pendingOps = [];
570
+ this._listeners = /* @__PURE__ */ new Set();
571
+ this._documentId = documentId;
572
+ this._field = field;
573
+ this._clock = clock;
574
+ }
575
+ // ---- Public API ---------------------------------------------------------
576
+ get value() {
577
+ let pos = 0;
578
+ let neg = 0;
579
+ for (const v of this._positive.values()) pos += v;
580
+ for (const v of this._negative.values()) neg += v;
581
+ return pos - neg;
582
+ }
583
+ increment(amount = 1) {
584
+ if (amount === 0) throw new Error("CRDTCounter: amount must be nonzero");
585
+ const siteId = this._clock.siteId;
586
+ if (amount > 0) {
587
+ this._positive.set(siteId, (this._positive.get(siteId) ?? 0) + amount);
588
+ } else {
589
+ this._negative.set(siteId, (this._negative.get(siteId) ?? 0) + Math.abs(amount));
590
+ }
591
+ const hlc = this._clock.now();
592
+ const op = {
593
+ id: makeOpId(hlc),
594
+ documentId: this._documentId,
595
+ field: this._field,
596
+ hlc,
597
+ type: "counter.increment",
598
+ payload: { delta: amount }
599
+ };
600
+ this._pendingOps.push(op);
601
+ this._notifyChange();
602
+ return op;
603
+ }
604
+ decrement(amount = 1) {
605
+ return this.increment(-amount);
606
+ }
607
+ /** Apply a remote increment operation. */
608
+ applyRemote(op) {
609
+ if (op.type !== "counter.increment") return;
610
+ this._clock.receive(op.hlc);
611
+ const payload = op.payload;
612
+ const siteId = op.hlc.siteId;
613
+ if (payload.delta > 0) {
614
+ this._positive.set(siteId, (this._positive.get(siteId) ?? 0) + payload.delta);
615
+ } else {
616
+ this._negative.set(siteId, (this._negative.get(siteId) ?? 0) + Math.abs(payload.delta));
617
+ }
618
+ this._notifyChange();
619
+ }
620
+ /** Merge with a remote counter state (for full-state sync). */
621
+ mergeState(positives, negatives) {
622
+ for (const [site, val] of Object.entries(positives)) {
623
+ this._positive.set(site, Math.max(this._positive.get(site) ?? 0, val));
624
+ }
625
+ for (const [site, val] of Object.entries(negatives)) {
626
+ this._negative.set(site, Math.max(this._negative.get(site) ?? 0, val));
627
+ }
628
+ this._notifyChange();
629
+ }
630
+ /** Export state for full-state sync. */
631
+ exportState() {
632
+ return {
633
+ positive: Object.fromEntries(this._positive),
634
+ negative: Object.fromEntries(this._negative)
635
+ };
636
+ }
637
+ drainOps() {
638
+ return this._pendingOps.splice(0, this._pendingOps.length);
639
+ }
640
+ onChange(callback) {
641
+ this._listeners.add(callback);
642
+ return () => {
643
+ this._listeners.delete(callback);
644
+ };
645
+ }
646
+ _notifyChange() {
647
+ if (this._listeners.size === 0) return;
648
+ const val = this.value;
649
+ for (const cb of this._listeners) {
650
+ try {
651
+ cb(val);
652
+ } catch {
653
+ }
654
+ }
655
+ }
656
+ };
657
+
658
+ // src/crdt/set.ts
659
+ var CRDTSet = class {
660
+ constructor(documentId, field, clock) {
661
+ /** Map from serialized value key to tagged entry. */
662
+ this._entries = /* @__PURE__ */ new Map();
663
+ this._pendingOps = [];
664
+ this._listeners = /* @__PURE__ */ new Set();
665
+ this._documentId = documentId;
666
+ this._field = field;
667
+ this._clock = clock;
668
+ }
669
+ // ---- Public API ---------------------------------------------------------
670
+ get values() {
671
+ const result = [];
672
+ for (const entry of this._entries.values()) {
673
+ if (entry.tags.size > 0) {
674
+ result.push(entry.value);
675
+ }
676
+ }
677
+ return result;
678
+ }
679
+ get size() {
680
+ let count = 0;
681
+ for (const entry of this._entries.values()) {
682
+ if (entry.tags.size > 0) count++;
683
+ }
684
+ return count;
685
+ }
686
+ has(item) {
687
+ const key = this._keyOf(item);
688
+ const entry = this._entries.get(key);
689
+ return entry !== void 0 && entry.tags.size > 0;
690
+ }
691
+ add(item) {
692
+ const hlc = this._clock.now();
693
+ const tag = makeOpId(hlc);
694
+ const key = this._keyOf(item);
695
+ let entry = this._entries.get(key);
696
+ if (!entry) {
697
+ entry = { value: item, tags: /* @__PURE__ */ new Set() };
698
+ this._entries.set(key, entry);
699
+ }
700
+ entry.tags.add(tag);
701
+ const op = {
702
+ id: tag,
703
+ documentId: this._documentId,
704
+ field: this._field,
705
+ hlc,
706
+ type: "set.add",
707
+ payload: { value: item, tag }
708
+ };
709
+ this._pendingOps.push(op);
710
+ this._notifyChange();
711
+ return op;
712
+ }
713
+ remove(item) {
714
+ const key = this._keyOf(item);
715
+ const entry = this._entries.get(key);
716
+ const observedTags = entry ? Array.from(entry.tags) : [];
717
+ if (entry) {
718
+ entry.tags.clear();
719
+ }
720
+ const hlc = this._clock.now();
721
+ const op = {
722
+ id: makeOpId(hlc),
723
+ documentId: this._documentId,
724
+ field: this._field,
725
+ hlc,
726
+ type: "set.remove",
727
+ payload: { value: item, tags: observedTags }
728
+ };
729
+ this._pendingOps.push(op);
730
+ this._notifyChange();
731
+ return op;
732
+ }
733
+ /** Apply a remote operation. */
734
+ applyRemote(op) {
735
+ this._clock.receive(op.hlc);
736
+ if (op.type === "set.add") {
737
+ const payload = op.payload;
738
+ const key = this._keyOf(payload.value);
739
+ let entry = this._entries.get(key);
740
+ if (!entry) {
741
+ entry = { value: payload.value, tags: /* @__PURE__ */ new Set() };
742
+ this._entries.set(key, entry);
743
+ }
744
+ entry.tags.add(payload.tag);
745
+ } else if (op.type === "set.remove") {
746
+ const payload = op.payload;
747
+ const key = this._keyOf(payload.value);
748
+ const entry = this._entries.get(key);
749
+ if (entry) {
750
+ for (const tag of payload.tags) {
751
+ entry.tags.delete(tag);
752
+ }
753
+ }
754
+ }
755
+ this._notifyChange();
756
+ }
757
+ drainOps() {
758
+ return this._pendingOps.splice(0, this._pendingOps.length);
759
+ }
760
+ onChange(callback) {
761
+ this._listeners.add(callback);
762
+ return () => {
763
+ this._listeners.delete(callback);
764
+ };
765
+ }
766
+ // ---- Internal -----------------------------------------------------------
767
+ _keyOf(value) {
768
+ return JSON.stringify(value);
769
+ }
770
+ _notifyChange() {
771
+ if (this._listeners.size === 0) return;
772
+ const vals = this.values;
773
+ for (const cb of this._listeners) {
774
+ try {
775
+ cb(vals);
776
+ } catch {
777
+ }
778
+ }
779
+ }
780
+ };
781
+
782
+ // src/crdt/register.ts
783
+ var CRDTRegister = class {
784
+ constructor(documentId, field, clock) {
785
+ this._value = void 0;
786
+ this._hlc = null;
787
+ this._pendingOps = [];
788
+ this._listeners = /* @__PURE__ */ new Set();
789
+ this._documentId = documentId;
790
+ this._field = field;
791
+ this._clock = clock;
792
+ }
793
+ // ---- Public API ---------------------------------------------------------
794
+ get value() {
795
+ return this._value;
796
+ }
797
+ set(value) {
798
+ const hlc = this._clock.now();
799
+ this._value = value;
800
+ this._hlc = hlc;
801
+ const op = {
802
+ id: makeOpId(hlc),
803
+ documentId: this._documentId,
804
+ field: this._field,
805
+ hlc,
806
+ type: "register.set",
807
+ payload: { value }
808
+ };
809
+ this._pendingOps.push(op);
810
+ this._notifyChange();
811
+ return op;
812
+ }
813
+ /** Apply a remote set operation. LWW: only apply if remote HLC > current. */
814
+ applyRemote(op) {
815
+ if (op.type !== "register.set") return;
816
+ this._clock.receive(op.hlc);
817
+ const payload = op.payload;
818
+ if (this._hlc === null || compareHLC(op.hlc, this._hlc) > 0) {
819
+ this._value = payload.value;
820
+ this._hlc = op.hlc;
821
+ this._notifyChange();
822
+ }
823
+ }
824
+ /** Export current state for full-state sync. */
825
+ exportState() {
826
+ return { value: this._value, hlc: this._hlc };
827
+ }
828
+ /** Import state from full-state sync. LWW merge. */
829
+ importState(value, hlc) {
830
+ if (this._hlc === null || compareHLC(hlc, this._hlc) > 0) {
831
+ this._value = value;
832
+ this._hlc = hlc;
833
+ this._notifyChange();
834
+ }
835
+ }
836
+ drainOps() {
837
+ return this._pendingOps.splice(0, this._pendingOps.length);
838
+ }
839
+ onChange(callback) {
840
+ this._listeners.add(callback);
841
+ return () => {
842
+ this._listeners.delete(callback);
843
+ };
844
+ }
845
+ _notifyChange() {
846
+ if (this._listeners.size === 0) return;
847
+ const val = this._value;
848
+ for (const cb of this._listeners) {
849
+ try {
850
+ cb(val);
851
+ } catch {
852
+ }
853
+ }
854
+ }
855
+ };
856
+
857
+ // src/crdt/document.ts
858
+ var CRDTDocument = class {
859
+ constructor(id, siteId) {
860
+ this._texts = /* @__PURE__ */ new Map();
861
+ this._counters = /* @__PURE__ */ new Map();
862
+ this._sets = /* @__PURE__ */ new Map();
863
+ this._registers = /* @__PURE__ */ new Map();
864
+ this._listeners = /* @__PURE__ */ new Set();
865
+ /** Buffer for ops not yet synced. */
866
+ this._unsyncedOps = [];
867
+ this.id = id;
868
+ this.clock = new HybridLogicalClock(siteId);
869
+ }
870
+ // ---- Field accessors ----------------------------------------------------
871
+ getText(field) {
872
+ let t = this._texts.get(field);
873
+ if (!t) {
874
+ t = new CRDTText(this.id, field, this.clock);
875
+ this._texts.set(field, t);
876
+ }
877
+ return t;
878
+ }
879
+ getCounter(field) {
880
+ let c = this._counters.get(field);
881
+ if (!c) {
882
+ c = new CRDTCounter(this.id, field, this.clock);
883
+ this._counters.set(field, c);
884
+ }
885
+ return c;
886
+ }
887
+ getSet(field) {
888
+ let s = this._sets.get(field);
889
+ if (!s) {
890
+ s = new CRDTSet(this.id, field, this.clock);
891
+ this._sets.set(field, s);
892
+ }
893
+ return s;
894
+ }
895
+ getRegister(field) {
896
+ let r = this._registers.get(field);
897
+ if (!r) {
898
+ r = new CRDTRegister(this.id, field, this.clock);
899
+ this._registers.set(field, r);
900
+ }
901
+ return r;
902
+ }
903
+ // ---- Operation collection -----------------------------------------------
904
+ /**
905
+ * Collect all pending local operations from all fields.
906
+ * Drains the per-field op buffers and returns them.
907
+ */
908
+ collectOps() {
909
+ const ops = [];
910
+ for (const t of this._texts.values()) ops.push(...t.drainOps());
911
+ for (const c of this._counters.values()) ops.push(...c.drainOps());
912
+ for (const s of this._sets.values()) ops.push(...s.drainOps());
913
+ for (const r of this._registers.values()) ops.push(...r.drainOps());
914
+ this._unsyncedOps.push(...ops);
915
+ return ops;
916
+ }
917
+ /**
918
+ * Acknowledge that operations up to and including `opId` have been
919
+ * persisted by the server. Removes them from the unsynced buffer.
920
+ */
921
+ acknowledge(opId) {
922
+ const idx = this._unsyncedOps.findIndex((op) => op.id === opId);
923
+ if (idx >= 0) {
924
+ this._unsyncedOps.splice(0, idx + 1);
925
+ }
926
+ }
927
+ /** Get operations that have not been acknowledged by the server. */
928
+ getUnsyncedOps() {
929
+ return this._unsyncedOps;
930
+ }
931
+ // ---- Remote operation application ---------------------------------------
932
+ /**
933
+ * Apply a batch of remote operations to the appropriate CRDT fields.
934
+ */
935
+ applyRemoteOps(ops) {
936
+ for (const op of ops) {
937
+ if (op.documentId !== this.id) continue;
938
+ this._applyRemoteOp(op);
939
+ }
940
+ }
941
+ _applyRemoteOp(op) {
942
+ switch (op.type) {
943
+ case "text.insert":
944
+ case "text.delete": {
945
+ const t = this.getText(op.field);
946
+ t.applyRemote(op);
947
+ break;
948
+ }
949
+ case "counter.increment": {
950
+ const c = this.getCounter(op.field);
951
+ c.applyRemote(op);
952
+ break;
953
+ }
954
+ case "set.add":
955
+ case "set.remove": {
956
+ const s = this.getSet(op.field);
957
+ s.applyRemote(op);
958
+ break;
959
+ }
960
+ case "register.set": {
961
+ const r = this.getRegister(op.field);
962
+ r.applyRemote(op);
963
+ break;
964
+ }
965
+ }
966
+ }
967
+ // ---- Serialization ------------------------------------------------------
968
+ /**
969
+ * Encode all pending operations as a Uint8Array (JSON wire format).
970
+ * Used for WebSocket transmission.
971
+ */
972
+ encode() {
973
+ const ops = this.collectOps();
974
+ const json = JSON.stringify({
975
+ documentId: this.id,
976
+ siteId: this.clock.siteId,
977
+ ops
978
+ });
979
+ return new TextEncoder().encode(json);
980
+ }
981
+ /**
982
+ * Decode and apply a remote message (Uint8Array or string).
983
+ */
984
+ decode(data) {
985
+ const text = typeof data === "string" ? data : new TextDecoder().decode(data);
986
+ const msg = JSON.parse(text);
987
+ if (msg.documentId !== this.id) return;
988
+ if (msg.siteId === this.clock.siteId) return;
989
+ this.applyRemoteOps(msg.ops);
990
+ this._notifyChange(msg.ops);
991
+ }
992
+ // ---- Change notifications -----------------------------------------------
993
+ onChange(callback) {
994
+ this._listeners.add(callback);
995
+ return () => {
996
+ this._listeners.delete(callback);
997
+ };
998
+ }
999
+ _notifyChange(ops) {
1000
+ for (const cb of this._listeners) {
1001
+ try {
1002
+ cb(ops);
1003
+ } catch {
1004
+ }
1005
+ }
1006
+ }
1007
+ };
1008
+
1009
+ export { CRDTCounter, CRDTDocument, CRDTRegister, CRDTSet, CRDTSync, CRDTText, HybridLogicalClock, compareHLC, makeOpId, makePositionId };
1010
+ //# sourceMappingURL=chunk-5NHFZRMO.js.map
1011
+ //# sourceMappingURL=chunk-5NHFZRMO.js.map