@cocreate/crdt 1.34.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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://cocreate.app/licenses/> or contact us at sales@cocreate.app.
22
-
23
- import crud from '@cocreate/crud-client';
24
- import { uuid, ulid, ulidDecode } from '@cocreate/utils';
25
- import localStorage from '@cocreate/local-storage';
26
-
27
- /**
28
- * @typedef {Object} CRDTData
29
- * @property {string} array - The collection/array name.
30
- * @property {string} object - The document/object ID.
31
- * @property {string} key - The specific key/field being edited.
32
- * @property {string} [value] - The string value being inserted or updated.
33
- * @property {number} [start] - The starting index of the change.
34
- * @property {number} [length] - The length of the text being updated.
35
- * @property {string} [frameId] - The browser frame origin ID.
36
- * @property {string} [id] - The unique ULID for the change.
37
- */
38
-
39
- const objects = {};
40
- const clientId = crud.socket.clientId || uuid(12);
41
- const frameId = crud.socket.frameId || uuid(12);
42
- const checkedDb = new Map();
43
-
44
- /**
45
- * Pure helper function to replace String.prototype.customSplice.
46
- * Prevents global prototype pollution while allowing string mutation logic.
47
- *
48
- * @param {string} str - The original string.
49
- * @param {number} index - The zero-based index at which to start changing the string.
50
- * @param {number} count - The number of characters to remove.
51
- * @param {string} [add=""] - The string to insert.
52
- * @returns {string} The modified string.
53
- *
54
- * @example
55
- * spliceString("Hello World", 6, 5, "CRDT"); // Returns "Hello CRDT"
56
- */
57
- function spliceString(str, index, count, add) {
58
- if (index < 0) {
59
- index = str.length + index;
60
- if (index < 0) index = 0;
61
- }
62
- return str.slice(0, index) + (add || "") + str.slice(index + Math.abs(count));
63
- }
64
-
65
- /**
66
- * Initializes a CRDT session for a specific document field.
67
- * Fetches the initial text state and sets the local frame context.
68
- *
69
- * @param {CRDTData} data - The document tracking configuration.
70
- */
71
- function init(data) {
72
- getText(data).then(value => {
73
- data.value = value;
74
- data.start = 0;
75
- data['frameId'] = frameId;
76
- localChange(data);
77
- });
78
- }
79
-
80
- /**
81
- * Retrieves the internal CRDT tracking object for a specific field.
82
- * If it doesn't exist locally, it queries the database for the changelog.
83
- *
84
- * @param {CRDTData} data - The document tracking configuration.
85
- * @returns {Promise<Object|undefined>} The tracked object containing text and changelogs.
86
- */
87
- async function getObject(data) {
88
- try {
89
- if (['_id', 'organization_id'].includes(data.key)) return;
90
-
91
- let name = getName(data);
92
- let object = objects[name];
93
-
94
- if (!object) {
95
- objects[name] = object = {};
96
- }
97
-
98
- // Initialize logs if this is the first time touching this object
99
- if (!object.changeLog) {
100
- let changeLog = [];
101
- object.undoLog = new Map();
102
- object.redoLog = new Map();
103
-
104
- if (data.read !== 'false') {
105
- if (!data.newObject) {
106
- let response = await crud.send({
107
- method: "object.read",
108
- array: "crdt",
109
- object: {
110
- filter: {
111
- query: { name },
112
- limit: 1
113
- }
114
- }
115
- });
116
- if (response.object && response.object[0] && response.object[0].changeLog) {
117
- changeLog = response.object[0].changeLog;
118
- }
119
- }
120
- object.changeLog = changeLog;
121
-
122
- if (!object.text) {
123
- await generateText(data, true);
124
- }
125
- }
126
- } else if (!object.text) {
127
- await generateText(data, false);
128
- }
129
- return object;
130
- } catch (e) {
131
- console.log('Invalid param', e);
132
- }
133
- }
134
-
135
- /**
136
- * Rebuilds the current text state from the changeLog.
137
- * Now mathematically guaranteed by ULID chronological sorting.
138
- *
139
- * @param {CRDTData} data - The document tracking configuration.
140
- * @param {boolean} flag - Whether to fetch from DB if empty.
141
- * @returns {Promise<string>} The rebuilt text string.
142
- */
143
- async function generateText(data, flag) {
144
- try {
145
- let name = getName(data);
146
- let object = objects[name];
147
- object['text'] = '';
148
-
149
- // Guarantee the changelog is in perfect temporal order before replaying.
150
- // ULIDs are lexicographically sortable, allowing us to perfectly sequence events.
151
- object['changeLog'].sort((a, b) => a.id.localeCompare(b.id));
152
-
153
- for (let change of object['changeLog']) {
154
- if (change) {
155
- if (change.type === 'snapshot') {
156
- // Fast-forward: Skip rebuilding character-by-character and jump straight to the snapshot state
157
- object['text'] = change.value;
158
- } else {
159
- object['text'] = spliceString(object['text'], change.start, change.length, change.value);
160
- }
161
- }
162
- }
163
-
164
- if (object['text'] === '' && data.read !== 'false') {
165
- object['text'] = await checkDb(data, flag) || '';
166
- }
167
-
168
- return object['text'];
169
- } catch (e) {
170
- console.error(e);
171
- }
172
- }
173
-
174
- /**
175
- * Checks the database for the raw document value if no changelog is available.
176
- *
177
- * @param {CRDTData} data - The document tracking configuration.
178
- * @param {boolean|string} flag - Whether to insert the fetched text as a base change.
179
- * @returns {Promise<string>} The base text string.
180
- */
181
- async function checkDb(data, flag) {
182
- let { array, object, key } = data;
183
- if (checkedDb.get(`${array}${object}${key}`)) return;
184
-
185
- checkedDb.set(`${array}${object}${key}`, true);
186
-
187
- let string = '';
188
- if (data.newObject) {
189
- string = data.newObject;
190
- } else {
191
- let response = await crud.send({ method: 'object.read', array, object: { _id: object, key } });
192
- string = crud.getValueFromObject(response.object[0], key);
193
- }
194
-
195
- if (string && typeof string !== 'string') string = "";
196
-
197
- // Seed the CRDT with the existing string from the database
198
- if (string && typeof string === 'string' && flag !== false) {
199
- data.value = string;
200
- data.start = 0;
201
- data.frameId = frameId;
202
- insertChange(data);
203
- }
204
-
205
- return string || '';
206
- }
207
-
208
- /**
209
- * The core Collaborative Editing Engine.
210
- * Handles incoming characters, out-of-order resolution, and conflict management.
211
- *
212
- * @param {CRDTData} data - The payload containing the keystroke/change.
213
- * @param {string} [flag] - Pass 'replace' to completely overwrite the changelog.
214
- *
215
- * @example
216
- * insertChange({ array: "users", object: "123", key: "bio", value: "A", start: 0 });
217
- */
218
- function insertChange(data, flag) {
219
- let name = getName(data);
220
- let object = objects[name];
221
- let changeLog = object.changeLog;
222
-
223
- if (!changeLog) return;
224
-
225
- object.text = object.text || '';
226
-
227
- let type = 'insert';
228
- if (data.start == undefined) return;
229
- if (!data.value) type = 'delete';
230
-
231
- // Core CRDT Change Object
232
- let change = {
233
- id: data.id || ulid(),
234
- value: data.value || '',
235
- start: data.start,
236
- end: data.end,
237
- length: data.length || 0,
238
- clientId: data.clientId || clientId,
239
- frameId: data.frameId || frameId,
240
- user_id: data.user_id || localStorage.getItem("user_id"),
241
- type
242
- };
243
-
244
- if (!change || change == null) {
245
- console.log('null change');
246
- return;
247
- }
248
-
249
- // Store the removed value to enable undo functionality
250
- if (change.length > 0) {
251
- change.removedValue = object.text.substring(change.start, change.length);
252
- }
253
-
254
- if (flag === 'replace') {
255
- changeLog = [change];
256
- object.changeLog = changeLog;
257
- object.text = spliceString("", change.start, change.length, change.value);
258
- } else {
259
- // SECURITY / STALE CHECK: Ensure the incoming change isn't older than a snapshot
260
- let firstChange = changeLog[0];
261
- if (firstChange && firstChange.type === 'snapshot') {
262
- let changeTime = ulidDecode(change.id);
263
- let snapshotTime = ulidDecode(firstChange.id);
264
-
265
- if (changeTime < snapshotTime) {
266
- console.warn(`Rejected stale edit: User's change is older than the current snapshot boundary.`);
267
- if (!data.frameId || data.frameId === frameId) {
268
- // Force a local sync/refresh to the latest state
269
- localChange(data, object.text);
270
- }
271
- return; // Abort insertion
272
- }
273
- }
274
-
275
- let lastChange = changeLog[changeLog.length - 1];
276
- changeLog.push(change);
277
-
278
- data.frameId = change.frameId;
279
- broadcastChange(data);
280
-
281
- // Conflict Resolution: Fast path vs Rebuild
282
- if (!lastChange || change.id >= lastChange.id) {
283
- // In-order delivery: apply change directly to string for high performance
284
- object.text = spliceString(object.text, change.start, change.length, change.value);
285
- localChange(data, object.text);
286
- } else {
287
- // Out-of-order delivery: rebuild the text mathematically from the log
288
- generateText(data, false).then(text => {
289
- localChange(data, text);
290
- });
291
- }
292
- }
293
-
294
- if (data.frameId === frameId && data.save !== "false") {
295
- persistChange(data, change);
296
- }
297
- }
298
-
299
- /**
300
- * Broadcasts a local change to other connected browser clients via WebSocket.
301
- *
302
- * @param {CRDTData} data - The change payload.
303
- */
304
- function broadcastChange(data) {
305
- crud.socket.send({
306
- method: "crdt",
307
- broadcastSender: false,
308
- broadcastClient: true,
309
- data
310
- });
311
- }
312
-
313
- /**
314
- * Dispatches a DOM event to update the local UI (e.g., input fields, textareas).
315
- *
316
- * @param {CRDTData} data - The change payload.
317
- * @param {string} string - The newly calculated full text string.
318
- */
319
- function localChange(data, string) {
320
- const localChange = new CustomEvent('cocreate-crdt-update', {
321
- detail: { ...data, string },
322
- });
323
- window.dispatchEvent(localChange);
324
- }
325
-
326
- /**
327
- * Saves the changeLog operation to the backend database for permanent storage.
328
- *
329
- * @param {CRDTData} data - The change payload.
330
- * @param {Object} change - The generated ULID change object.
331
- */
332
- function persistChange(data, change) {
333
- let name = getName(data);
334
- let Data = {
335
- method: 'object.update',
336
- array: 'crdt',
337
- object: {
338
- name,
339
- '$push.changeLog': change,
340
- crud: {
341
- array: data.array,
342
- object: data.object,
343
- key: data.key
344
- }
345
- },
346
- filter: {
347
- query: { name },
348
- limit: 1
349
- },
350
- upsert: true,
351
- namespace: data.namespace,
352
- room: data.room,
353
- broadcast: data.broadcast,
354
- broadcastSender: data.broadcastSender,
355
- broadcastClient: false,
356
- metadata: 'crdt-change'
357
- };
358
-
359
- crud.send(Data);
360
- }
361
-
362
- // WebSocket listener for incoming remote CRDT changes
363
- crud.socket.listen('crdt', function (response) {
364
- let data = response.data;
365
- let name = getName(data);
366
- let object = objects[name];
367
-
368
- if (object) {
369
- // Only insert if it originated from a different frame/browser
370
- if (data.frameId !== frameId) {
371
- insertChange(data);
372
- }
373
- }
374
- });
375
-
376
- /**
377
- * Syncs entire CRDT states from a remote source, often used for initial connection.
378
- *
379
- * @param {Object} data - The sync payload from the server.
380
- */
381
- function sync(data) {
382
- if (data.clientId === clientId) return;
383
-
384
- if (data.array.includes('crdt')) {
385
- if (data.object && data.object[0]) {
386
- let Data = data.object[0];
387
- let name = Data.name;
388
- let object = objects[name];
389
-
390
- if (object && Data.crud) {
391
- Data.crud.value = Data.text;
392
- Data.crud.start = 0;
393
- Data.crud.length = object['text'].length;
394
-
395
- object['changeLog'] = Data.changeLog;
396
- object['text'] = Data.text;
397
-
398
- localChange(Data.crud, Data.text);
399
- console.log('crdtSync');
400
- }
401
- }
402
- }
403
- }
404
-
405
- /**
406
- * Helper to fetch the current full text of a tracked field.
407
- *
408
- * @param {CRDTData} data - The document tracking configuration.
409
- * @returns {Promise<string>} The current text value.
410
- */
411
- async function getText(data) {
412
- try {
413
- let object = await getObject(data);
414
- if (object) {
415
- return object.text;
416
- } else {
417
- console.log('undefined');
418
- }
419
- } catch (e) {
420
- console.error(e);
421
- return "";
422
- }
423
- }
424
-
425
- /**
426
- * Replaces the entire contents of the text state with a new string.
427
- * Used for copy/paste or programmatic overwrites.
428
- *
429
- * @param {CRDTData} data - Payload containing the new replacement string.
430
- */
431
- async function replaceText(data) {
432
- try {
433
- let object = await getObject(data);
434
- if (object) {
435
- let oldValue = await getText(data);
436
- if (oldValue) {
437
- data.length = oldValue.length;
438
- } else {
439
- data.length = 0;
440
- }
441
-
442
- data.start = 0;
443
- updateText(data, 'replace');
444
- }
445
- } catch (e) {
446
- console.error(e);
447
- }
448
- }
449
-
450
- /**
451
- * Wraps insertChange and additionally broadcasts a standard object update
452
- * for systems not listening to raw CRDT streams.
453
- *
454
- * @param {CRDTData} data - The change payload.
455
- * @param {string} [flag] - Flag to pass to insertChange (e.g., 'replace').
456
- */
457
- async function updateText(data, flag) {
458
- let object = await getObject(data);
459
- if (object) {
460
- insertChange(data, flag);
461
-
462
- if (data.crud !== 'false' && data.save !== 'false') {
463
- if (data.upsert === undefined) data.upsert = true;
464
-
465
- let wholestring = await getText(data);
466
- crud.send({
467
- method: 'object.update',
468
- array: data.array,
469
- object: {
470
- _id: data.object,
471
- [data.key]: wholestring
472
- },
473
- upsert: data.upsert,
474
- namespace: data.namespace,
475
- room: data.room,
476
- broadcast: data.broadcast,
477
- broadcastSender: false,
478
- broadcastClient: true,
479
- metadata: 'crdt-updateobject'
480
- });
481
- }
482
- }
483
- }
484
-
485
- /**
486
- * Inverts an operation to facilitate undo functionality.
487
- * A deletion becomes an insertion, and an insertion becomes a deletion.
488
- *
489
- * @param {CRDTData} data - The data context.
490
- * @param {Object} change - The change object to invert.
491
- * @returns {Object} An object containing the inverted data and change payloads.
492
- */
493
- function createChange(data, change) {
494
- if (change.value && change.length == 0) {
495
- change.length = change.value.length;
496
- change.removedValue = change.value;
497
- change.value = '';
498
- change.type = 'delete';
499
- } else {
500
- change.value = change.removedValue;
501
- change.length = 0;
502
- change.type = 'insert';
503
- }
504
-
505
- data = { ...data, ...change };
506
- delete data.frameId;
507
- delete data.id; // Strip ULID so a fresh one is generated for the undo action
508
- delete change.frameId;
509
- delete change.id;
510
- return { data, change };
511
- }
512
-
513
- /**
514
- * Reverts the last local change made by the user.
515
- * Skips remote changes originating from other users.
516
- *
517
- * @param {CRDTData} data - The document tracking configuration.
518
- */
519
- function undoText(data) {
520
- let name = getName(data);
521
- let object = objects[name];
522
- let changeLog = object.changeLog;
523
- let undoLog = object.undoLog;
524
-
525
- // Traverse changelog backwards to find the last local frame modification
526
- for (let index = changeLog.length - 1; index >= 0; index--) {
527
- let change = Object.assign({}, changeLog[index]);
528
- if (change && change.frameId == frameId) {
529
- let log = undoLog.get(index);
530
- if (!log) {
531
- if (log != 'undo') {
532
- undoLog.set(index, 'undo');
533
- change.index = changeLog.length += 1;
534
- let updated = createChange(data, change);
535
- undoLog.set(updated.change.index, updated.change);
536
- updateText(updated.data);
537
- return;
538
- }
539
- }
540
- }
541
- }
542
- }
543
-
544
- /**
545
- * Re-applies a previously undone local change.
546
- *
547
- * @param {CRDTData} data - The document tracking configuration.
548
- */
549
- function redoText(data) {
550
- let name = getName(data);
551
- let object = objects[name];
552
- let redoLog = object.redoLog;
553
- let undoLog = Array.from(object.undoLog.values());
554
-
555
- for (let index = undoLog.length - 1; index >= 0; index--) {
556
- let change = Object.assign({}, undoLog[index]);
557
- if (change && change != 'undo') {
558
- let log = redoLog.get(change.index);
559
-
560
- if (!log) {
561
- if (log != 'redo') {
562
- let updated = createChange(data, change);
563
- redoLog.set(updated.change.index, updated.change);
564
- updateText(updated.data);
565
- return;
566
- }
567
- }
568
- }
569
- }
570
- }
571
-
572
- /**
573
- * Reconstructs the document state up to a specific historical version index.
574
- * Useful for time-travel debugging and version history viewing.
575
- *
576
- * @param {CRDTData} data - Contains version number target.
577
- * @returns {Promise<Object>} The rebuilt document state at that point in time.
578
- */
579
- async function viewVersion(data) {
580
- try {
581
- let name = getName(data);
582
- let object = objects[name];
583
- let changeLog = object.changeLog;
584
- let string = '';
585
-
586
- let sortedLog = [...changeLog].sort((a, b) => a.id.localeCompare(b.id));
587
-
588
- // If a version is explicitly requested, slice the log up to that point
589
- let log = data.version !== undefined ? sortedLog.slice(0, data.version) : sortedLog;
590
-
591
- for (let change of log) {
592
- if (change || change !== null) {
593
- if (change.type === 'snapshot') {
594
- string = change.value;
595
- } else {
596
- string = spliceString(string, change.start, change.length, change.value);
597
- }
598
- }
599
- }
600
-
601
- data.value = string;
602
- return data;
603
- } catch (e) {
604
- console.error(e);
605
- }
606
- }
607
-
608
- /**
609
- * Intelligent Garbage Collection & Log Compaction (Watermark Strategy).
610
- * Compresses history safely by respecting active users' last known states,
611
- * falling back to a time limit for inactive users.
612
- *
613
- * @param {CRDTData} data - The document tracking configuration.
614
- * @param {number} [countThreshold=5000] - Minimum log size before triggering snapshot.
615
- * @param {number} [timeGracePeriodMs=604800000] - 7 days in ms. Older offline users lose their anchor.
616
- * @returns {Promise<Array|undefined>} The newly compressed changelog.
617
- */
618
- async function createSnapshot(data, countThreshold = 5000, timeGracePeriodMs = 7 * 24 * 60 * 60 * 1000) {
619
- let name = getName(data);
620
- let object = objects[name];
621
-
622
- // Only compress if the log is large enough to warrant it
623
- if (!object || !object.changeLog || object.changeLog.length < countThreshold) return;
624
-
625
- object.changeLog.sort((a, b) => a.id.localeCompare(b.id));
626
-
627
- const now = Date.now();
628
- const latestUserChanges = new Map();
629
-
630
- // 1. Find the high-water mark (latest change) for each unique user
631
- for (let i = 0; i < object.changeLog.length; i++) {
632
- let change = object.changeLog[i];
633
- if (change && change.type !== 'snapshot') {
634
- let identifier = change.user_id || change.clientId || 'system';
635
- // Overwrites previous entries, leaving only the very last known index per user
636
- latestUserChanges.set(identifier, { index: i, timestamp: ulidDecode(change.id) });
637
- }
638
- }
639
-
640
- // 2. Determine the safe boundary for compression.
641
- let safeCompressionIndex = object.changeLog.length - 1;
642
-
643
- for (const [userId, record] of latestUserChanges.entries()) {
644
- let isStale = (now - record.timestamp) > timeGracePeriodMs;
645
- if (!isStale) {
646
- // User is active! We must protect their last known anchor point.
647
- if (record.index < safeCompressionIndex) {
648
- safeCompressionIndex = record.index;
649
- }
650
- }
651
- }
652
-
653
- if (safeCompressionIndex <= 0) return;
654
-
655
- // 3. Split the log at the safe boundary
656
- let logToCompress = object.changeLog.slice(0, safeCompressionIndex);
657
- let retainedLog = object.changeLog.slice(safeCompressionIndex);
658
-
659
- // Roll up the compressed section into a single string
660
- let snapshotText = '';
661
- for (let change of logToCompress) {
662
- if (change) {
663
- if (change.type === 'snapshot') {
664
- snapshotText = change.value;
665
- } else {
666
- snapshotText = spliceString(snapshotText, change.start, change.length, change.value);
667
- }
668
- }
669
- }
670
-
671
- // 4. Create the new snapshot event, adopting the exact ID of the last compressed item
672
- let lastCompressedChange = logToCompress[logToCompress.length - 1];
673
- let snapshotChange = {
674
- id: lastCompressedChange.id, // Inherit the ULID timestamp of the boundary
675
- type: 'snapshot',
676
- value: snapshotText,
677
- start: 0,
678
- length: 0,
679
- clientId,
680
- frameId,
681
- user_id: localStorage.getItem("user_id") || "system",
682
- version: true // Flag to indicate this is a restorable state view
683
- };
684
-
685
- object.changeLog = [snapshotChange, ...retainedLog];
686
-
687
- // 5. Persist the newly compressed array to the database
688
- if (data.save !== "false") {
689
- crud.send({
690
- method: 'object.update',
691
- array: 'crdt',
692
- object: {
693
- name,
694
- changeLog: object.changeLog,
695
- crud: { array: data.array, object: data.object, key: data.key }
696
- },
697
- filter: { query: { name }, limit: 1 },
698
- upsert: true,
699
- metadata: 'crdt-snapshot'
700
- });
701
- }
702
-
703
- return object.changeLog;
704
- }
705
-
706
- /**
707
- * Helper to generate a unique string key representing a specific tracked document field.
708
- *
709
- * @param {CRDTData} data - The routing payload.
710
- * @returns {string} Concatenated namespace/document/key string.
711
- */
712
- function getName(data) {
713
- return `${data.array}${data.object}${data.key}`;
714
- }
715
-
716
- export default { init, objects, getName, getText, updateText, replaceText, undoText, redoText, viewVersion, createSnapshot };
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
+ */