@cocreate/crdt 1.31.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/CONTRIBUTING.md +1 -1
- package/package.json +6 -6
- package/src/index.js +716 -716
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
|
|
33
|
-
* @property {number} [start] - The starting index of the change.
|
|
34
|
-
* @property {number} [length] - The length of the text being
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|