@cocreate/crdt 1.34.0 → 2.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/.github/FUNDING.yml +1 -1
- package/.github/workflows/automated.yml +18 -7
- package/CHANGELOG.md +668 -634
- package/CONTRIBUTING.md +6 -6
- package/README.md +180 -92
- package/demo/index.html +2 -2
- package/docs/index.html +4 -4
- package/docs/operations.md +159 -0
- package/package.json +11 -10
- package/src/index.js +765 -716
- package/src/runs.js +46 -0
- package/src/selection.js +53 -0
- package/src/sequence.js +160 -0
- package/test/crdt.test.cjs +584 -0
- package/test/selection-adapters.test.cjs +77 -0
package/src/index.js
CHANGED
|
@@ -1,716 +1,765 @@
|
|
|
1
|
-
/********************************************************************************
|
|
2
|
-
* Copyright (C) 2023 CoCreate and Contributors.
|
|
3
|
-
*
|
|
4
|
-
* This program is free software: you can redistribute it and/or modify
|
|
5
|
-
* it under the terms of the GNU Affero General Public License as published
|
|
6
|
-
* by the Free Software Foundation, either version 3 of the License, or
|
|
7
|
-
* (at your option) any later version.
|
|
8
|
-
*
|
|
9
|
-
* This program is distributed in the hope that it will be useful,
|
|
10
|
-
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
-
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
-
* GNU Affero General Public License for more details.
|
|
13
|
-
*
|
|
14
|
-
* You should have received a copy of the GNU Affero General Public License
|
|
15
|
-
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
-
********************************************************************************/
|
|
17
|
-
|
|
18
|
-
// Commercial Licensing Information:
|
|
19
|
-
// For commercial use of this software without the copyleft provisions of the AGPLv3,
|
|
20
|
-
// you must obtain a commercial license from CoCreate LLC.
|
|
21
|
-
// For details, visit <https://
|
|
22
|
-
|
|
23
|
-
import crud from '@cocreate/crud-client';
|
|
24
|
-
import {
|
|
25
|
-
import localStorage from '@cocreate/local-storage';
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
* @
|
|
32
|
-
* @property {string}
|
|
33
|
-
* @property {
|
|
34
|
-
* @property {
|
|
35
|
-
* @property {string} [
|
|
36
|
-
* @property {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
function
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
*
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
let
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
*
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
if (!
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
* @
|
|
330
|
-
* @
|
|
331
|
-
*/
|
|
332
|
-
function
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
if (data.
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
*
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
1
|
+
/********************************************************************************
|
|
2
|
+
* Copyright (C) 2023 CoCreate and Contributors.
|
|
3
|
+
*
|
|
4
|
+
* This program is free software: you can redistribute it and/or modify
|
|
5
|
+
* it under the terms of the GNU Affero General Public License as published
|
|
6
|
+
* by the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
* (at your option) any later version.
|
|
8
|
+
*
|
|
9
|
+
* This program is distributed in the hope that it will be useful,
|
|
10
|
+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
* GNU Affero General Public License for more details.
|
|
13
|
+
*
|
|
14
|
+
* You should have received a copy of the GNU Affero General Public License
|
|
15
|
+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
********************************************************************************/
|
|
17
|
+
|
|
18
|
+
// Commercial Licensing Information:
|
|
19
|
+
// For commercial use of this software without the copyleft provisions of the AGPLv3,
|
|
20
|
+
// you must obtain a commercial license from CoCreate LLC.
|
|
21
|
+
// For details, visit <https://CoCreateJS.com/licenses/> or contact us at sales@CoCreateJS.com.
|
|
22
|
+
|
|
23
|
+
import crud from '@cocreate/crud-client';
|
|
24
|
+
import { ulid, ulidDecode } from '@cocreate/utils';
|
|
25
|
+
import localStorage from '@cocreate/local-storage';
|
|
26
|
+
import { prepareChange, replaySequence, inverseChange, textDiff } from './sequence.js';
|
|
27
|
+
import { anchorSelection, resolveSelection } from './selection.js';
|
|
28
|
+
import { assignRun, clearRun, reconcileRuns } from './runs.js';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {Object} CRDTData
|
|
32
|
+
* @property {string} array - The collection/array name.
|
|
33
|
+
* @property {string} object - The document/object ID.
|
|
34
|
+
* @property {string} key - The specific key/field being edited.
|
|
35
|
+
* @property {string} [value] - The string value being inserted or updated.
|
|
36
|
+
* @property {number} [start] - The starting index of the change.
|
|
37
|
+
* @property {number} [length] - The length of the text being updated.
|
|
38
|
+
* @property {string} [frameId] - The browser frame origin ID.
|
|
39
|
+
* @property {string} [id] - The unique ULID for the change.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
const objects = Object.create(null);
|
|
43
|
+
// Read browser identity when creating an operation, after Socket Client initializes.
|
|
44
|
+
const getClientId = () => crud.socket.clientId;
|
|
45
|
+
const frameId = crud.socket.frameId;
|
|
46
|
+
let lastLocalId = '';
|
|
47
|
+
let selectionClock = 0;
|
|
48
|
+
function nextId(minimum = '') {
|
|
49
|
+
const candidate = ulid();
|
|
50
|
+
const floor = minimum > lastLocalId ? minimum : lastLocalId;
|
|
51
|
+
if (candidate > floor) return (lastLocalId = candidate);
|
|
52
|
+
// Advance logical time while keeping this client's random suffix. Incrementing
|
|
53
|
+
// another client's complete ID could give two writers the same operation ID.
|
|
54
|
+
const alphabet = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
|
55
|
+
const time = floor.slice(0, 10).split('');
|
|
56
|
+
for (let i = time.length - 1; i >= 0; i--) {
|
|
57
|
+
const next = alphabet.indexOf(time[i]) + 1;
|
|
58
|
+
if (next < 32) {
|
|
59
|
+
time[i] = alphabet[next];
|
|
60
|
+
return (lastLocalId = time.join('') + candidate.slice(10));
|
|
61
|
+
}
|
|
62
|
+
time[i] = '0';
|
|
63
|
+
}
|
|
64
|
+
throw new Error('CRDT operation ID exhausted.');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const isRealtime = data => data.realtime === true || data.realtime === 'true';
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Pure helper function to replace String.prototype.customSplice.
|
|
72
|
+
* Prevents global prototype pollution while allowing string mutation logic.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} str - The original string.
|
|
75
|
+
* @param {number} index - The zero-based index at which to start changing the string.
|
|
76
|
+
* @param {number} count - The number of characters to remove.
|
|
77
|
+
* @param {string} [add=""] - The string to insert.
|
|
78
|
+
* @returns {string} The modified string.
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* spliceString("Hello World", 6, 5, "CRDT"); // Returns "Hello CRDT"
|
|
82
|
+
* @private
|
|
83
|
+
* @environment client
|
|
84
|
+
*/
|
|
85
|
+
function spliceString(str, index, count, add) {
|
|
86
|
+
if (index < 0) {
|
|
87
|
+
index = str.length + index;
|
|
88
|
+
if (index < 0) index = 0;
|
|
89
|
+
}
|
|
90
|
+
return str.slice(0, index) + (add || "") + str.slice(index + Math.abs(count));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Retrieves the internal CRDT tracking object for a specific field.
|
|
95
|
+
* If it doesn't exist locally, it queries the database for the changelog.
|
|
96
|
+
*
|
|
97
|
+
* @param {CRDTData} data - The document tracking configuration.
|
|
98
|
+
* @returns {Promise<Object|undefined>} The tracked object containing text and changelogs.
|
|
99
|
+
* @private
|
|
100
|
+
* @environment client
|
|
101
|
+
*/
|
|
102
|
+
async function getObject(data) {
|
|
103
|
+
if (['_id', 'organization_id'].includes(data.key)) return;
|
|
104
|
+
const name = getName(data);
|
|
105
|
+
const object = objects[name] ||= {};
|
|
106
|
+
if (object.loading) return object.loading;
|
|
107
|
+
if (object.changeLog) return object;
|
|
108
|
+
object.pendingChanges = [];
|
|
109
|
+
object.loading = (async () => {
|
|
110
|
+
let changeLog = [];
|
|
111
|
+
if (!data.scope && data.read !== 'false' && !data.newObject) {
|
|
112
|
+
const response = await crud.send({
|
|
113
|
+
method: 'object.read', array: 'crdt',
|
|
114
|
+
object: { filter: { query: { name }, limit: 1 } }
|
|
115
|
+
});
|
|
116
|
+
changeLog = response?.object?.[0]?.changeLog || [];
|
|
117
|
+
}
|
|
118
|
+
object.undoLog = new Map();
|
|
119
|
+
object.redoLog = new Map();
|
|
120
|
+
object.undoStack = [];
|
|
121
|
+
object.redoStack = [];
|
|
122
|
+
object.changeLog = [...new Map(changeLog.filter(Boolean).map(change => [change.id, { ...change }])).values()];
|
|
123
|
+
if (data.scope && data.initialValue && !object.changeLog.length) {
|
|
124
|
+
object.changeLog.push({
|
|
125
|
+
id: nextId(), start: 0, length: 0, value: String(data.initialValue),
|
|
126
|
+
type: 'insert', history: true, clientId: getClientId() || 'local',
|
|
127
|
+
frameId: frameId || 'local'
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
object.changeIds = new Set(object.changeLog.flatMap(change => [change.id, ...(change.seenIds || [])]));
|
|
131
|
+
await generateText(data, true);
|
|
132
|
+
for (const change of object.pendingChanges.splice(0)) receiveChange(change);
|
|
133
|
+
return object;
|
|
134
|
+
})();
|
|
135
|
+
try { return await object.loading; }
|
|
136
|
+
catch (error) { delete object.changeLog; throw error; }
|
|
137
|
+
finally { delete object.loading; }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Rebuilds the current text state from the changeLog.
|
|
142
|
+
* Replays stable character identities and tombstones; old logs use positional replay.
|
|
143
|
+
*
|
|
144
|
+
* @param {CRDTData} data - The document tracking configuration.
|
|
145
|
+
* @param {boolean} flag - Whether to fetch from DB if empty.
|
|
146
|
+
* @returns {Promise<string>} The rebuilt text string.
|
|
147
|
+
* @private
|
|
148
|
+
* @environment client
|
|
149
|
+
*/
|
|
150
|
+
async function generateText(data, flag) {
|
|
151
|
+
const object = objects[getName(data)];
|
|
152
|
+
object.changeLog.sort((a, b) => a.id.localeCompare(b.id));
|
|
153
|
+
object.sequence = replaySequence(object.changeLog);
|
|
154
|
+
object.text = object.sequence.text;
|
|
155
|
+
if (!data.scope && !object.changeLog.length && data.read !== 'false') {
|
|
156
|
+
await checkDb(data, flag);
|
|
157
|
+
}
|
|
158
|
+
return object.text;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Checks the database for the raw document value if no changelog is available.
|
|
163
|
+
*
|
|
164
|
+
* @param {CRDTData} data - The document tracking configuration.
|
|
165
|
+
* @param {boolean|string} flag - Whether to insert the fetched text as a base change.
|
|
166
|
+
* @returns {Promise<string>} The base text string.
|
|
167
|
+
* @private
|
|
168
|
+
* @environment client
|
|
169
|
+
*/
|
|
170
|
+
async function checkDb(data, flag) {
|
|
171
|
+
let { array, object, key } = data;
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
let string = '';
|
|
175
|
+
if (data.newObject) {
|
|
176
|
+
string = data.newObject;
|
|
177
|
+
} else {
|
|
178
|
+
let response = await crud.send({ method: 'object.read', array, object: { _id: object, key } });
|
|
179
|
+
string = crud.getValueFromObject(response.object[0], key);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (string && typeof string !== 'string') string = "";
|
|
183
|
+
|
|
184
|
+
// Seed the CRDT with the existing string from the database
|
|
185
|
+
if (string && typeof string === 'string' && flag !== false) {
|
|
186
|
+
// Concurrent readers of the same uninitialized field must identify its
|
|
187
|
+
// baseline characters identically. Scope and content determine this ID.
|
|
188
|
+
const bytes = new TextEncoder().encode(JSON.stringify([array, object, key, string]));
|
|
189
|
+
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes));
|
|
190
|
+
const alphabet = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
|
191
|
+
let suffix = '';
|
|
192
|
+
for (let i = 0; i < 16; i++) suffix += alphabet[digest[i] & 31];
|
|
193
|
+
insertChange({ ...data, id: '0000000000' + suffix, value: string,
|
|
194
|
+
start: 0, length: 0, frameId, history: true });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return string || '';
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The core Collaborative Editing Engine.
|
|
202
|
+
* Handles incoming characters, out-of-order resolution, and conflict management.
|
|
203
|
+
*
|
|
204
|
+
* @param {CRDTData} data - The payload containing the keystroke/change.
|
|
205
|
+
*
|
|
206
|
+
* @example
|
|
207
|
+
* insertChange({ array: "users", object: "123", key: "bio", value: "A", start: 0 });
|
|
208
|
+
* @private
|
|
209
|
+
* @environment client
|
|
210
|
+
*/
|
|
211
|
+
function insertChange(data) {
|
|
212
|
+
const { captureSelection, element, ...payloadData } = data;
|
|
213
|
+
data = payloadData;
|
|
214
|
+
const object = objects[getName(data)];
|
|
215
|
+
if (!object?.changeLog) return false;
|
|
216
|
+
const remote = !!data.frameId && data.frameId !== frameId;
|
|
217
|
+
let change = {
|
|
218
|
+
...data,
|
|
219
|
+
history: data.history,
|
|
220
|
+
id: data.id || nextId(object.changeLog.at(-1)?.id), value: data.value || '', start: data.start,
|
|
221
|
+
length: data.length || 0,
|
|
222
|
+
clientId: remote ? data.clientId : getClientId() || (data.scope && 'local'),
|
|
223
|
+
frameId: data.frameId || frameId || (data.scope && 'local'),
|
|
224
|
+
user_id: data.user_id || localStorage.getItem('user_id'),
|
|
225
|
+
type: data.value ? 'insert' : 'delete'
|
|
226
|
+
};
|
|
227
|
+
if (!Number.isInteger(change.start) || change.start < 0 ||
|
|
228
|
+
!Number.isInteger(change.length) || change.length < 0 ||
|
|
229
|
+
typeof change.value !== 'string') throw new RangeError('Invalid CRDT text operation.');
|
|
230
|
+
if (!remote && !data.scope && !change.history && (!change.clientId || !change.frameId))
|
|
231
|
+
throw new Error('Socket Client must initialize clientId and frameId before editing.');
|
|
232
|
+
const log = object.changeLog;
|
|
233
|
+
if (object.changeIds.has(change.id)) return false;
|
|
234
|
+
if (change.format !== 2 && log[0]?.type === 'snapshot' && change.id <= log[0].id) {
|
|
235
|
+
if (!remote) {
|
|
236
|
+
const detail = { ...data, start: 0, length: object.text.length, value: object.text, string: object.text };
|
|
237
|
+
if (element && !isRealtime(data))
|
|
238
|
+
localChange({ ...detail, elements: [element] }, object.text);
|
|
239
|
+
else if (isRealtime(data)) localChange(detail, object.text);
|
|
240
|
+
}
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
const before = object.text || '';
|
|
244
|
+
if (!remote) {
|
|
245
|
+
if (change.format !== 2) change = prepareChange(object.sequence, change);
|
|
246
|
+
change = assignRun(object, change);
|
|
247
|
+
}
|
|
248
|
+
const nextLog = [...log, change].sort((a, b) => a.id.localeCompare(b.id));
|
|
249
|
+
const sequence = replaySequence(nextLog);
|
|
250
|
+
// Validate explicit post-edit selections before committing a text operation.
|
|
251
|
+
if (data.selection !== undefined) {
|
|
252
|
+
try {
|
|
253
|
+
if (remote && !validSelectionSender(data)) throw new RangeError('Invalid selection sender.');
|
|
254
|
+
anchorSelection(sequence, data.selection);
|
|
255
|
+
} catch (error) {
|
|
256
|
+
if (!remote) throw error;
|
|
257
|
+
// Malformed ephemeral presence must not discard a valid text edit.
|
|
258
|
+
data = { ...data };
|
|
259
|
+
delete data.selection;
|
|
260
|
+
delete data.selectionClock;
|
|
261
|
+
delete change.selection;
|
|
262
|
+
delete change.selectionClock;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (remote) reconcileRuns(object, sequence.operations.get(change.id) || change, object.sequence, sequence);
|
|
266
|
+
object.changeIds.add(change.id);
|
|
267
|
+
object.changeLog = nextLog;
|
|
268
|
+
object.sequence = sequence;
|
|
269
|
+
object.text = object.sequence.text;
|
|
270
|
+
if (!remote) {
|
|
271
|
+
if (data.undoOf || data.redoOf) {
|
|
272
|
+
const from = data.undoOf ? object.undoStack : object.redoStack;
|
|
273
|
+
const to = data.undoOf ? object.redoStack : object.undoStack;
|
|
274
|
+
const index = from.indexOf(data.undoOf || data.redoOf);
|
|
275
|
+
if (index >= 0) from.splice(index, 1);
|
|
276
|
+
to.push(change.id);
|
|
277
|
+
} else if (!data.history) {
|
|
278
|
+
object.undoStack.push(change.id);
|
|
279
|
+
object.redoStack.length = 0;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
// Apply text first so editor adapters can report the actual resulting selection.
|
|
283
|
+
const payload = { ...data, ...change };
|
|
284
|
+
const detail = { ...payload, ...textDiff(before, object.text), operation: change,
|
|
285
|
+
string: object.text };
|
|
286
|
+
if (element && !remote && !isRealtime(data))
|
|
287
|
+
localChange({ ...detail, elements: [element] }, object.text);
|
|
288
|
+
else if (remote || isRealtime(data)) localChange(detail, object.text);
|
|
289
|
+
let selection = data.selection;
|
|
290
|
+
if (!remote && captureSelection) {
|
|
291
|
+
// An adapter failure must never prevent an already-applied edit reaching peers.
|
|
292
|
+
try { selection = anchorSelection(object.sequence, captureSelection()); }
|
|
293
|
+
catch (error) { console.error('Unable to capture CRDT selection:', error); }
|
|
294
|
+
}
|
|
295
|
+
if (selection !== undefined) {
|
|
296
|
+
payload.selection = anchorSelection(object.sequence, selection);
|
|
297
|
+
payload.selectionClock = remote ? data.selectionClock : ++selectionClock;
|
|
298
|
+
change.selection = payload.selection;
|
|
299
|
+
change.selectionClock = payload.selectionClock;
|
|
300
|
+
rememberSelection(object, payload);
|
|
301
|
+
}
|
|
302
|
+
if (!remote && isRealtime(data)) broadcastChange(payload);
|
|
303
|
+
if (remote || isRealtime(data)) publishSelections(object);
|
|
304
|
+
if (!remote && isRealtime(data) && data.save !== 'false') persistChange(data, change);
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Broadcasts a local change to other connected browser clients via WebSocket.
|
|
310
|
+
*
|
|
311
|
+
* @param {CRDTData} data - The change payload.
|
|
312
|
+
* @private
|
|
313
|
+
* @environment client
|
|
314
|
+
*/
|
|
315
|
+
function broadcastChange(data) {
|
|
316
|
+
crud.socket.send({
|
|
317
|
+
method: "crdt",
|
|
318
|
+
broadcastSender: false,
|
|
319
|
+
broadcastClient: true,
|
|
320
|
+
data
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Dispatches a DOM event to update the local UI (e.g., input fields, textareas).
|
|
326
|
+
*
|
|
327
|
+
* @param {CRDTData} data - The change payload.
|
|
328
|
+
* @param {string} string - The newly calculated full text string.
|
|
329
|
+
* @private
|
|
330
|
+
* @environment client
|
|
331
|
+
*/
|
|
332
|
+
function localChange(data, string) {
|
|
333
|
+
const localChange = new CustomEvent('cocreate-crdt-update', {
|
|
334
|
+
detail: { ...data, string },
|
|
335
|
+
});
|
|
336
|
+
window.dispatchEvent(localChange);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Saves the changeLog operation to the backend database for permanent storage.
|
|
341
|
+
*
|
|
342
|
+
* @param {CRDTData} data - The change payload.
|
|
343
|
+
* @param {Object} change - The generated ULID change object.
|
|
344
|
+
* @private
|
|
345
|
+
* @environment client
|
|
346
|
+
*/
|
|
347
|
+
function persistChange(data, change) {
|
|
348
|
+
let name = getName(data);
|
|
349
|
+
let Data = {
|
|
350
|
+
method: 'object.update',
|
|
351
|
+
array: 'crdt',
|
|
352
|
+
object: {
|
|
353
|
+
name,
|
|
354
|
+
'$push.changeLog': change,
|
|
355
|
+
crud: {
|
|
356
|
+
array: data.array,
|
|
357
|
+
object: data.object,
|
|
358
|
+
key: data.key
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
filter: {
|
|
362
|
+
query: { name },
|
|
363
|
+
limit: 1
|
|
364
|
+
},
|
|
365
|
+
upsert: true,
|
|
366
|
+
namespace: data.namespace,
|
|
367
|
+
room: data.room,
|
|
368
|
+
broadcast: data.broadcast,
|
|
369
|
+
broadcastSender: data.broadcastSender,
|
|
370
|
+
broadcastClient: false,
|
|
371
|
+
metadata: 'crdt-change'
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
crud.send(Data);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// WebSocket listener for incoming remote CRDT changes
|
|
378
|
+
crud.socket.listen('crdt', function (response) {
|
|
379
|
+
let data = { ...response.data, socketId: response.socketId || response.data.socketId };
|
|
380
|
+
let name = getName(data);
|
|
381
|
+
let object = objects[name];
|
|
382
|
+
|
|
383
|
+
if (object) {
|
|
384
|
+
// Only insert if it originated from a different frame/browser
|
|
385
|
+
if (data.frameId !== frameId) {
|
|
386
|
+
if (object.loading) object.pendingChanges.push({ ...data });
|
|
387
|
+
else receiveChange({ ...data });
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
function receiveChange(data) {
|
|
393
|
+
if (data.type === 'selection') {
|
|
394
|
+
const object = objects[getName(data)];
|
|
395
|
+
if (object?.sequence) {
|
|
396
|
+
if (rememberSelection(object, data)) publishSelections(object);
|
|
397
|
+
}
|
|
398
|
+
} else insertChange(data);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function validSelectionSender(data) {
|
|
402
|
+
return typeof data.clientId === 'string' && !!data.clientId &&
|
|
403
|
+
typeof data.frameId === 'string' && !!data.frameId &&
|
|
404
|
+
Number.isSafeInteger(data.selectionClock) && data.selectionClock > 0;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function rememberSelection(object, data) {
|
|
408
|
+
if (!validSelectionSender(data)) return false;
|
|
409
|
+
object.selections ||= new Map();
|
|
410
|
+
const sender = JSON.stringify([data.clientId, data.frameId]);
|
|
411
|
+
const previous = object.selections.get(sender);
|
|
412
|
+
if (previous && previous.selectionClock >= data.selectionClock) return false;
|
|
413
|
+
try {
|
|
414
|
+
object.selections.set(sender, { ...data,
|
|
415
|
+
selection: anchorSelection(object.sequence, data.selection),
|
|
416
|
+
presence: data.presence ? { ...data.presence } : undefined });
|
|
417
|
+
return true;
|
|
418
|
+
} catch {
|
|
419
|
+
return false;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** Current presence, resolved against the latest committed source. No DOM access. * @public
|
|
424
|
+
* @environment client
|
|
425
|
+
*/
|
|
426
|
+
function getSelections(data) {
|
|
427
|
+
const object = objects[getName(data)];
|
|
428
|
+
if (!object?.sequence) return [];
|
|
429
|
+
return selectionDetails(object);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function selectionDetails(object) {
|
|
433
|
+
const result = [];
|
|
434
|
+
for (const data of object.selections?.values() || []) {
|
|
435
|
+
const resolved = resolveSelection(object.sequence, data.selection);
|
|
436
|
+
if (resolved === undefined) continue;
|
|
437
|
+
result.push({ array: data.array, object: data.object, key: data.key,
|
|
438
|
+
clientId: data.clientId, frameId: data.frameId, socketId: data.socketId,
|
|
439
|
+
selection: anchorSelection(object.sequence, data.selection), resolved,
|
|
440
|
+
presence: data.presence ? { ...data.presence } : undefined });
|
|
441
|
+
}
|
|
442
|
+
return result;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Cached local selection; undefined until reported, null after clearing. * @public
|
|
446
|
+
* @environment client
|
|
447
|
+
*/
|
|
448
|
+
function getSelection(data) {
|
|
449
|
+
const object = objects[getName(data)];
|
|
450
|
+
const sender = JSON.stringify([data.clientId ?? getClientId(), data.frameId ?? frameId]);
|
|
451
|
+
const entry = object?.selections?.get(sender);
|
|
452
|
+
return entry ? resolveSelection(object.sequence, entry.selection) : undefined;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function publishSelections(object) {
|
|
456
|
+
for (const detail of selectionDetails(object)) {
|
|
457
|
+
window.dispatchEvent(new CustomEvent('cocreate-crdt-selection', {
|
|
458
|
+
detail
|
|
459
|
+
}));
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Share selection without modifying text, history, or persisted document data. * @public
|
|
464
|
+
* @environment client
|
|
465
|
+
*/
|
|
466
|
+
async function updateSelection(data) {
|
|
467
|
+
// Loaded fields anchor immediately, before a remote edit can change offsets.
|
|
468
|
+
let object = objects[getName(data)];
|
|
469
|
+
if (!object?.sequence || object.loading) object = await getObject(data);
|
|
470
|
+
if (!object) return;
|
|
471
|
+
if (isRealtime(data) && (!getClientId() || !frameId))
|
|
472
|
+
throw new Error('Socket Client must initialize before sharing selection.');
|
|
473
|
+
const payload = { array: data.array, object: data.object, key: data.key,
|
|
474
|
+
clientId: getClientId() || 'local', frameId: frameId || 'local',
|
|
475
|
+
realtime: data.realtime, type: 'selection', presence: data.presence,
|
|
476
|
+
selection: anchorSelection(object.sequence, data.selection) };
|
|
477
|
+
const previous = object.selections?.get(JSON.stringify([payload.clientId, payload.frameId]));
|
|
478
|
+
const samePosition = previous && JSON.stringify(previous.selection) === JSON.stringify(payload.selection);
|
|
479
|
+
if (samePosition && JSON.stringify(previous.presence) === JSON.stringify(payload.presence)) return;
|
|
480
|
+
payload.selectionClock = ++selectionClock;
|
|
481
|
+
if (!samePosition) clearRun(object, payload.clientId, payload.frameId);
|
|
482
|
+
rememberSelection(object, payload);
|
|
483
|
+
if (isRealtime(data)) {
|
|
484
|
+
broadcastChange(payload);
|
|
485
|
+
publishSelections(object);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Syncs entire CRDT states from a remote source, often used for initial connection.
|
|
491
|
+
*
|
|
492
|
+
* @param {Object} data - The sync payload from the server.
|
|
493
|
+
* @private
|
|
494
|
+
* @environment client
|
|
495
|
+
*/
|
|
496
|
+
function sync(data) {
|
|
497
|
+
if (data.clientId === getClientId()) return;
|
|
498
|
+
|
|
499
|
+
if (data.array.includes('crdt')) {
|
|
500
|
+
if (data.object && data.object[0]) {
|
|
501
|
+
let Data = data.object[0];
|
|
502
|
+
let name = Data.name;
|
|
503
|
+
let object = objects[name];
|
|
504
|
+
|
|
505
|
+
if (object && Data.crud) {
|
|
506
|
+
Data.crud.value = Data.text;
|
|
507
|
+
Data.crud.start = 0;
|
|
508
|
+
Data.crud.length = object['text'].length;
|
|
509
|
+
|
|
510
|
+
object['changeLog'] = Data.changeLog;
|
|
511
|
+
object['text'] = Data.text;
|
|
512
|
+
|
|
513
|
+
localChange(Data.crud, Data.text);
|
|
514
|
+
console.log('crdtSync');
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Helper to fetch the current full text of a tracked field.
|
|
522
|
+
*
|
|
523
|
+
* @param {CRDTData} data - The document tracking configuration.
|
|
524
|
+
* @returns {Promise<string>} The current text value.
|
|
525
|
+
* @public
|
|
526
|
+
* @environment client
|
|
527
|
+
*/
|
|
528
|
+
async function getText(data) {
|
|
529
|
+
const object = await getObject(data);
|
|
530
|
+
return object?.text;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Replaces the entire contents of the text state with a new string.
|
|
535
|
+
* Used for copy/paste or programmatic overwrites.
|
|
536
|
+
*
|
|
537
|
+
* @param {CRDTData} data - Payload containing the new replacement string.
|
|
538
|
+
* @public
|
|
539
|
+
* @environment client
|
|
540
|
+
*/
|
|
541
|
+
async function replaceText(data) {
|
|
542
|
+
const object = await getObject(data);
|
|
543
|
+
if (!object) return;
|
|
544
|
+
return updateText({ ...data, start: 0, length: object.text.length, runBreak: true, runEnd: true });
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Wraps insertChange and additionally broadcasts a standard object update
|
|
549
|
+
* for systems not listening to raw CRDT streams.
|
|
550
|
+
*
|
|
551
|
+
* @param {CRDTData} data - The change payload.
|
|
552
|
+
* @public
|
|
553
|
+
* @environment client
|
|
554
|
+
*/
|
|
555
|
+
async function updateText(data) {
|
|
556
|
+
let object = await getObject(data);
|
|
557
|
+
if (object) {
|
|
558
|
+
if (!insertChange({ ...data })) return;
|
|
559
|
+
|
|
560
|
+
if (isRealtime(data) && data.crud !== 'false' && data.save !== 'false') {
|
|
561
|
+
if (data.upsert === undefined) data.upsert = true;
|
|
562
|
+
|
|
563
|
+
let wholestring = await getText(data);
|
|
564
|
+
return crud.send({
|
|
565
|
+
method: 'object.update',
|
|
566
|
+
array: data.array,
|
|
567
|
+
object: {
|
|
568
|
+
_id: data.object,
|
|
569
|
+
[data.key]: wholestring
|
|
570
|
+
},
|
|
571
|
+
upsert: data.upsert,
|
|
572
|
+
namespace: data.namespace,
|
|
573
|
+
room: data.room,
|
|
574
|
+
broadcast: data.broadcast,
|
|
575
|
+
broadcastSender: false,
|
|
576
|
+
broadcastClient: true,
|
|
577
|
+
metadata: 'crdt-updateobject'
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Inverts an operation to facilitate undo functionality.
|
|
585
|
+
* A deletion becomes an insertion, and an insertion becomes a deletion.
|
|
586
|
+
*
|
|
587
|
+
* @param {CRDTData} data - The data context.
|
|
588
|
+
* @param {Object} change - The change object to invert.
|
|
589
|
+
* @returns {Object} An object containing the inverted data and change payloads.
|
|
590
|
+
* @private
|
|
591
|
+
* @environment client
|
|
592
|
+
*/
|
|
593
|
+
function createChange(data, object, change) {
|
|
594
|
+
return inverseChange(object.sequence, object.sequence.operations.get(change.id) || change,
|
|
595
|
+
nextId(object.changeLog.at(-1)?.id), data);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** Undo only characters and deletion markers owned by a local edit. * @public
|
|
599
|
+
* @environment client
|
|
600
|
+
*/
|
|
601
|
+
async function undoText(data) {
|
|
602
|
+
const object = await getObject(data);
|
|
603
|
+
const id = object?.undoStack.at(-1);
|
|
604
|
+
if (!id) return;
|
|
605
|
+
const change = object.changeLog.find(entry => entry.id === id);
|
|
606
|
+
if (!change) return;
|
|
607
|
+
const inverse = createChange(data, object, change);
|
|
608
|
+
return updateText({ ...inverse, undoOf: id });
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** Redo by inverting the previous undo operation. * @public
|
|
612
|
+
* @environment client
|
|
613
|
+
*/
|
|
614
|
+
async function redoText(data) {
|
|
615
|
+
const object = await getObject(data);
|
|
616
|
+
const id = object?.redoStack.at(-1);
|
|
617
|
+
if (!id) return;
|
|
618
|
+
const change = object.changeLog.find(entry => entry.id === id);
|
|
619
|
+
if (!change) return;
|
|
620
|
+
const inverse = createChange(data, object, change);
|
|
621
|
+
return updateText({ ...inverse, redoOf: id });
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Reconstructs the document state up to a specific historical version index.
|
|
626
|
+
* Useful for time-travel debugging and version history viewing.
|
|
627
|
+
*
|
|
628
|
+
* @param {CRDTData} data - Contains version number target.
|
|
629
|
+
* @returns {Promise<Object>} The rebuilt document state at that point in time.
|
|
630
|
+
* @private
|
|
631
|
+
* @environment client
|
|
632
|
+
*/
|
|
633
|
+
async function viewVersion(data) {
|
|
634
|
+
try {
|
|
635
|
+
let name = getName(data);
|
|
636
|
+
let object = objects[name];
|
|
637
|
+
let changeLog = object.changeLog;
|
|
638
|
+
const sortedLog = [...changeLog].sort((a, b) => a.id.localeCompare(b.id));
|
|
639
|
+
const log = data.version !== undefined ? sortedLog.slice(0, data.version) : sortedLog;
|
|
640
|
+
const string = replaySequence(log).text;
|
|
641
|
+
|
|
642
|
+
data.value = string;
|
|
643
|
+
return data;
|
|
644
|
+
} catch (e) {
|
|
645
|
+
console.error(e);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Intelligent Garbage Collection & Log Compaction (Watermark Strategy).
|
|
651
|
+
* Compresses history safely by respecting active users' last known states,
|
|
652
|
+
* falling back to a time limit for inactive users.
|
|
653
|
+
*
|
|
654
|
+
* @param {CRDTData} data - The document tracking configuration.
|
|
655
|
+
* @param {number} [countThreshold=5000] - Minimum log size before triggering snapshot.
|
|
656
|
+
* @param {number} [timeGracePeriodMs=604800000] - 7 days in ms. Older offline users lose their anchor.
|
|
657
|
+
* @returns {Promise<Array|undefined>} The newly compressed changelog.
|
|
658
|
+
* @private
|
|
659
|
+
* @environment client
|
|
660
|
+
*/
|
|
661
|
+
async function createSnapshot(data, countThreshold = 5000, timeGracePeriodMs = 7 * 24 * 60 * 60 * 1000) {
|
|
662
|
+
let name = getName(data);
|
|
663
|
+
let object = objects[name];
|
|
664
|
+
|
|
665
|
+
// Only compress if the log is large enough to warrant it
|
|
666
|
+
if (!object || !object.changeLog || object.changeLog.length < countThreshold) return;
|
|
667
|
+
|
|
668
|
+
object.changeLog.sort((a, b) => a.id.localeCompare(b.id));
|
|
669
|
+
|
|
670
|
+
const now = Date.now();
|
|
671
|
+
const latestUserChanges = new Map();
|
|
672
|
+
|
|
673
|
+
// 1. Find the high-water mark (latest change) for each unique user
|
|
674
|
+
for (let i = 0; i < object.changeLog.length; i++) {
|
|
675
|
+
let change = object.changeLog[i];
|
|
676
|
+
if (change && change.type !== 'snapshot') {
|
|
677
|
+
let identifier = change.user_id || change.clientId || 'system';
|
|
678
|
+
// Overwrites previous entries, leaving only the very last known index per user
|
|
679
|
+
latestUserChanges.set(identifier, { index: i, timestamp: ulidDecode(change.id) });
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// 2. Determine the safe boundary for compression.
|
|
684
|
+
let safeCompressionIndex = object.changeLog.length - 1;
|
|
685
|
+
|
|
686
|
+
for (const [userId, record] of latestUserChanges.entries()) {
|
|
687
|
+
let isStale = (now - record.timestamp) > timeGracePeriodMs;
|
|
688
|
+
if (!isStale) {
|
|
689
|
+
// User is active! We must protect their last known anchor point.
|
|
690
|
+
if (record.index < safeCompressionIndex) {
|
|
691
|
+
safeCompressionIndex = record.index;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const snapshotIndex = object.changeLog.findLastIndex(change => change.type === 'snapshot');
|
|
697
|
+
if (safeCompressionIndex <= 0 || safeCompressionIndex <= snapshotIndex) return;
|
|
698
|
+
|
|
699
|
+
// 3. Split the log at the safe boundary
|
|
700
|
+
let logToCompress = object.changeLog.slice(0, safeCompressionIndex);
|
|
701
|
+
let retainedLog = object.changeLog.slice(safeCompressionIndex);
|
|
702
|
+
|
|
703
|
+
const prefix = replaySequence(logToCompress);
|
|
704
|
+
const lastCompressedChange = logToCompress[logToCompress.length - 1];
|
|
705
|
+
const snapshotChange = {
|
|
706
|
+
id: lastCompressedChange.id, type: 'snapshot', format: 2,
|
|
707
|
+
value: prefix.text, start: 0, length: 0, clientId: getClientId(), frameId,
|
|
708
|
+
sequence: [...prefix.nodes.values()].map(node => ({ ...node, deletedBy: [...node.deletedBy] })),
|
|
709
|
+
seenIds: [...prefix.seenIds], version: true
|
|
710
|
+
};
|
|
711
|
+
object.changeLog = [snapshotChange, ...retainedLog];
|
|
712
|
+
object.changeIds = new Set([...prefix.seenIds, snapshotChange.id, ...retainedLog.map(change => change.id)]);
|
|
713
|
+
object.sequence = replaySequence(object.changeLog);
|
|
714
|
+
object.text = object.sequence.text;
|
|
715
|
+
object.undoStack.length = 0;
|
|
716
|
+
object.redoStack.length = 0;
|
|
717
|
+
|
|
718
|
+
// 5. Persist the newly compressed array to the database
|
|
719
|
+
if (isRealtime(data) && data.save !== "false") {
|
|
720
|
+
crud.send({
|
|
721
|
+
method: 'object.update',
|
|
722
|
+
array: 'crdt',
|
|
723
|
+
object: {
|
|
724
|
+
name,
|
|
725
|
+
'$push.changeLog': snapshotChange,
|
|
726
|
+
crud: { array: data.array, object: data.object, key: data.key }
|
|
727
|
+
},
|
|
728
|
+
filter: { query: { name }, limit: 1 },
|
|
729
|
+
upsert: true,
|
|
730
|
+
metadata: 'crdt-snapshot'
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
return object.changeLog;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Helper to generate a unique string key representing a specific tracked document field.
|
|
739
|
+
*
|
|
740
|
+
* @param {CRDTData} data - The routing payload.
|
|
741
|
+
* @returns {string} Concatenated namespace/document/key string.
|
|
742
|
+
* @private
|
|
743
|
+
* @environment client
|
|
744
|
+
*/
|
|
745
|
+
function getName(data) {
|
|
746
|
+
const name = `${data.array}${data.object}${data.key}`;
|
|
747
|
+
return data.scope ? `${name}\0${data.scope}` : name;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/** End the current local typing run after an explicit caret move, blur or action. * @public
|
|
751
|
+
* @environment client
|
|
752
|
+
*/
|
|
753
|
+
function endRun(data) {
|
|
754
|
+
clearRun(objects[getName(data)], getClientId() || (data.scope && 'local'),
|
|
755
|
+
frameId || (data.scope && 'local'));
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
export default { getText, updateText, replaceText, undoText, redoText, updateSelection, getSelection, getSelections, endRun };
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* CoCreate crdt
|
|
762
|
+
*
|
|
763
|
+
* @module crdt
|
|
764
|
+
* @environment client
|
|
765
|
+
*/
|