@cocreate/crdt 1.32.0 → 1.34.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,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://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 modified.
33
- * @property {number} [start] - The starting index of the change.
34
- * @property {number} [length] - The length of the text being modified.
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
- broadcastBrowser: 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
- broadcastBrowser: 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
- broadcastBrowser: 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://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 };