@cocreate/crdt 1.29.1 → 1.30.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/.github/workflows/automated.yml +1 -1
- package/CHANGELOG.md +7 -0
- package/package.json +6 -7
- package/release.config.js +1 -1
- package/src/index.js +386 -173
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
## [1.29.2](https://github.com/CoCreate-app/CoCreate-crdt/compare/v1.29.1...v1.29.2) (2026-07-19)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* set default branch to main ([3fa967f](https://github.com/CoCreate-app/CoCreate-crdt/commit/3fa967f58aa040a66e1eec1df8791df806a33828))
|
|
7
|
+
|
|
1
8
|
## [1.29.1](https://github.com/CoCreate-app/CoCreate-crdt/compare/v1.29.0...v1.29.1) (2026-07-17)
|
|
2
9
|
|
|
3
10
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cocreate/crdt",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.30.0",
|
|
4
4
|
"description": "CoCreate crdt",
|
|
5
5
|
"author": "CoCreate LLC",
|
|
6
6
|
"license": "AGPL-3.0",
|
|
@@ -26,12 +26,12 @@
|
|
|
26
26
|
},
|
|
27
27
|
"main": "./src/index.js",
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@cocreate/crud-client": "^1.35.
|
|
30
|
-
"@cocreate/local-storage": "^1.17.
|
|
31
|
-
"@cocreate/utils": "^1.44.
|
|
29
|
+
"@cocreate/crud-client": "^1.35.2",
|
|
30
|
+
"@cocreate/local-storage": "^1.17.2",
|
|
31
|
+
"@cocreate/utils": "^1.44.3"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
|
-
"@cocreate/webpack": "^1.6.
|
|
34
|
+
"@cocreate/webpack": "^1.6.2"
|
|
35
35
|
},
|
|
36
36
|
"allowScripts": {
|
|
37
37
|
"@cocreate/actions@1.21.4": true,
|
|
@@ -48,7 +48,6 @@
|
|
|
48
48
|
"@cocreate/render@1.46.1": true,
|
|
49
49
|
"@cocreate/socket-client@1.40.5": true,
|
|
50
50
|
"@cocreate/utils@1.42.2": true,
|
|
51
|
-
"@cocreate/uuid@1.12.4": true,
|
|
52
51
|
"@cocreate/webpack@1.4.3": true
|
|
53
52
|
}
|
|
54
|
-
}
|
|
53
|
+
}
|
package/release.config.js
CHANGED
package/src/index.js
CHANGED
|
@@ -20,16 +20,54 @@
|
|
|
20
20
|
// you must obtain a commercial license from CoCreate LLC.
|
|
21
21
|
// For details, visit <https://cocreate.app/licenses/> or contact us at sales@cocreate.app.
|
|
22
22
|
|
|
23
|
-
/*globals config, atob, btoa, localStorage, CustomEvent*/
|
|
24
23
|
import crud from '@cocreate/crud-client';
|
|
25
|
-
import uuid from '@cocreate/
|
|
24
|
+
import { uuid, ulid, ulidDecode } from '@cocreate/utils';
|
|
26
25
|
import localStorage from '@cocreate/local-storage';
|
|
27
26
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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);
|
|
31
42
|
const checkedDb = new Map();
|
|
32
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
|
+
*/
|
|
33
71
|
function init(data) {
|
|
34
72
|
getText(data).then(value => {
|
|
35
73
|
data.value = value;
|
|
@@ -39,24 +77,31 @@ function init(data) {
|
|
|
39
77
|
});
|
|
40
78
|
}
|
|
41
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
|
+
*/
|
|
42
87
|
async function getObject(data) {
|
|
43
88
|
try {
|
|
44
|
-
if (['_id', 'organization_id'].includes(data.key))
|
|
45
|
-
|
|
89
|
+
if (['_id', 'organization_id'].includes(data.key)) return;
|
|
90
|
+
|
|
46
91
|
let name = getName(data);
|
|
47
92
|
let object = objects[name];
|
|
48
93
|
|
|
49
94
|
if (!object) {
|
|
50
|
-
objects[name] = object = {}
|
|
95
|
+
objects[name] = object = {};
|
|
51
96
|
}
|
|
52
97
|
|
|
98
|
+
// Initialize logs if this is the first time touching this object
|
|
53
99
|
if (!object.changeLog) {
|
|
54
100
|
let changeLog = [];
|
|
55
|
-
object.undoLog = new Map()
|
|
56
|
-
object.redoLog = new Map()
|
|
101
|
+
object.undoLog = new Map();
|
|
102
|
+
object.redoLog = new Map();
|
|
57
103
|
|
|
58
|
-
|
|
59
|
-
if (data.read != 'false') {
|
|
104
|
+
if (data.read !== 'false') {
|
|
60
105
|
if (!data.newObject) {
|
|
61
106
|
let response = await crud.send({
|
|
62
107
|
method: "object.read",
|
|
@@ -72,8 +117,8 @@ async function getObject(data) {
|
|
|
72
117
|
changeLog = response.object[0].changeLog;
|
|
73
118
|
}
|
|
74
119
|
}
|
|
75
|
-
object.changeLog = changeLog
|
|
76
|
-
|
|
120
|
+
object.changeLog = changeLog;
|
|
121
|
+
|
|
77
122
|
if (!object.text) {
|
|
78
123
|
await generateText(data, true);
|
|
79
124
|
}
|
|
@@ -87,23 +132,37 @@ async function getObject(data) {
|
|
|
87
132
|
}
|
|
88
133
|
}
|
|
89
134
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
+
*/
|
|
94
143
|
async function generateText(data, flag) {
|
|
95
144
|
try {
|
|
96
|
-
let name = getName(data)
|
|
97
|
-
let object = objects[name]
|
|
98
|
-
object['text'] = ''
|
|
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
|
+
|
|
99
153
|
for (let change of object['changeLog']) {
|
|
100
154
|
if (change) {
|
|
101
|
-
|
|
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
|
+
}
|
|
102
161
|
}
|
|
103
162
|
}
|
|
104
163
|
|
|
105
164
|
if (object['text'] === '' && data.read !== 'false') {
|
|
106
|
-
object['text'] = await checkDb(data, flag) || ''
|
|
165
|
+
object['text'] = await checkDb(data, flag) || '';
|
|
107
166
|
}
|
|
108
167
|
|
|
109
168
|
return object['text'];
|
|
@@ -112,23 +171,31 @@ async function generateText(data, flag) {
|
|
|
112
171
|
}
|
|
113
172
|
}
|
|
114
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
|
+
*/
|
|
115
181
|
async function checkDb(data, flag) {
|
|
116
182
|
let { array, object, key } = data;
|
|
117
|
-
if (checkedDb.get(`${array}${object}${key}`))
|
|
118
|
-
|
|
183
|
+
if (checkedDb.get(`${array}${object}${key}`)) return;
|
|
184
|
+
|
|
119
185
|
checkedDb.set(`${array}${object}${key}`, true);
|
|
120
186
|
|
|
121
|
-
let string = ''
|
|
122
|
-
if (data.newObject)
|
|
123
|
-
string = data.newObject
|
|
124
|
-
else {
|
|
187
|
+
let string = '';
|
|
188
|
+
if (data.newObject) {
|
|
189
|
+
string = data.newObject;
|
|
190
|
+
} else {
|
|
125
191
|
let response = await crud.send({ method: 'object.read', array, object: { _id: object, key } });
|
|
126
192
|
string = crud.getValueFromObject(response.object[0], key);
|
|
127
193
|
}
|
|
128
194
|
|
|
129
|
-
if (string && typeof string !== 'string')
|
|
130
|
-
|
|
131
|
-
|
|
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) {
|
|
132
199
|
data.value = string;
|
|
133
200
|
data.start = 0;
|
|
134
201
|
data.frameId = frameId;
|
|
@@ -138,22 +205,32 @@ async function checkDb(data, flag) {
|
|
|
138
205
|
return string || '';
|
|
139
206
|
}
|
|
140
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
|
+
*/
|
|
141
218
|
function insertChange(data, flag) {
|
|
142
219
|
let name = getName(data);
|
|
143
220
|
let object = objects[name];
|
|
144
221
|
let changeLog = object.changeLog;
|
|
145
|
-
|
|
146
|
-
|
|
222
|
+
|
|
223
|
+
if (!changeLog) return;
|
|
147
224
|
|
|
148
|
-
object.text = object.text || ''
|
|
225
|
+
object.text = object.text || '';
|
|
149
226
|
|
|
150
227
|
let type = 'insert';
|
|
151
228
|
if (data.start == undefined) return;
|
|
152
|
-
if (!data.value)
|
|
153
|
-
type = 'delete';
|
|
229
|
+
if (!data.value) type = 'delete';
|
|
154
230
|
|
|
231
|
+
// Core CRDT Change Object
|
|
155
232
|
let change = {
|
|
156
|
-
|
|
233
|
+
id: data.id || ulid(),
|
|
157
234
|
value: data.value || '',
|
|
158
235
|
start: data.start,
|
|
159
236
|
end: data.end,
|
|
@@ -164,59 +241,66 @@ function insertChange(data, flag) {
|
|
|
164
241
|
type
|
|
165
242
|
};
|
|
166
243
|
|
|
167
|
-
let lastChange = changeLog[changeLog.length - 1];
|
|
168
|
-
if (lastChange && change.datetime && lastChange.datetime) {
|
|
169
|
-
if (change.datetime < lastChange.datetime) {
|
|
170
|
-
// TODO: insert change at index position and update start and end postions for every element after insert
|
|
171
|
-
console.log('requires changeLog rebuild');
|
|
172
|
-
}
|
|
173
|
-
if (flag != 'replace') {
|
|
174
|
-
if (lastChange && change.start == lastChange.start) {
|
|
175
|
-
let date1 = new Date(lastChange.datetime);
|
|
176
|
-
let date2 = new Date(change.datetime);
|
|
177
|
-
let diff = date2.getTime() - date1.getTime();
|
|
178
|
-
if (diff < 500) {
|
|
179
|
-
if (change.value.length == 1) {
|
|
180
|
-
change.start = lastChange.start + lastChange.value.length;
|
|
181
|
-
data.start = change.start;
|
|
182
|
-
}
|
|
183
|
-
if (change.length == 1) {
|
|
184
|
-
change.start = lastChange.start - lastChange.length;
|
|
185
|
-
data.start = change.start;
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
244
|
if (!change || change == null) {
|
|
192
|
-
console.log('null change')
|
|
193
|
-
return
|
|
245
|
+
console.log('null change');
|
|
246
|
+
return;
|
|
194
247
|
}
|
|
195
|
-
|
|
248
|
+
|
|
249
|
+
// Store the removed value to enable undo functionality
|
|
250
|
+
if (change.length > 0) {
|
|
196
251
|
change.removedValue = object.text.substring(change.start, change.length);
|
|
252
|
+
}
|
|
197
253
|
|
|
198
|
-
if (flag
|
|
199
|
-
// TODO get current string and create new changeLog array then push new change
|
|
254
|
+
if (flag === 'replace') {
|
|
200
255
|
changeLog = [change];
|
|
256
|
+
object.changeLog = changeLog;
|
|
257
|
+
object.text = spliceString("", change.start, change.length, change.value);
|
|
201
258
|
} else {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
+
}
|
|
206
274
|
|
|
207
|
-
|
|
208
|
-
|
|
275
|
+
let lastChange = changeLog[changeLog.length - 1];
|
|
276
|
+
changeLog.push(change);
|
|
277
|
+
|
|
209
278
|
data.frameId = change.frameId;
|
|
210
|
-
|
|
211
279
|
broadcastChange(data);
|
|
212
|
-
localChange(data, object.text);
|
|
213
|
-
} else
|
|
214
|
-
localChange(data, object.text);
|
|
215
280
|
|
|
216
|
-
|
|
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") {
|
|
217
295
|
persistChange(data, change);
|
|
296
|
+
}
|
|
218
297
|
}
|
|
219
298
|
|
|
299
|
+
/**
|
|
300
|
+
* Broadcasts a local change to other connected browser clients via WebSocket.
|
|
301
|
+
*
|
|
302
|
+
* @param {CRDTData} data - The change payload.
|
|
303
|
+
*/
|
|
220
304
|
function broadcastChange(data) {
|
|
221
305
|
crud.socket.send({
|
|
222
306
|
method: "crdt",
|
|
@@ -226,7 +310,12 @@ function broadcastChange(data) {
|
|
|
226
310
|
});
|
|
227
311
|
}
|
|
228
312
|
|
|
229
|
-
|
|
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
|
+
*/
|
|
230
319
|
function localChange(data, string) {
|
|
231
320
|
const localChange = new CustomEvent('cocreate-crdt-update', {
|
|
232
321
|
detail: { ...data, string },
|
|
@@ -234,7 +323,12 @@ function localChange(data, string) {
|
|
|
234
323
|
window.dispatchEvent(localChange);
|
|
235
324
|
}
|
|
236
325
|
|
|
237
|
-
|
|
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
|
+
*/
|
|
238
332
|
function persistChange(data, change) {
|
|
239
333
|
let name = getName(data);
|
|
240
334
|
let Data = {
|
|
@@ -260,74 +354,67 @@ function persistChange(data, change) {
|
|
|
260
354
|
broadcastSender: data.broadcastSender,
|
|
261
355
|
broadcastBrowser: false,
|
|
262
356
|
metadata: 'crdt-change'
|
|
263
|
-
}
|
|
357
|
+
};
|
|
264
358
|
|
|
265
359
|
crud.send(Data);
|
|
266
360
|
}
|
|
267
361
|
|
|
268
|
-
|
|
362
|
+
// WebSocket listener for incoming remote CRDT changes
|
|
269
363
|
crud.socket.listen('crdt', function (response) {
|
|
270
|
-
let data = response.data
|
|
364
|
+
let data = response.data;
|
|
271
365
|
let name = getName(data);
|
|
272
|
-
let object = objects[name]
|
|
366
|
+
let object = objects[name];
|
|
273
367
|
|
|
274
368
|
if (object) {
|
|
369
|
+
// Only insert if it originated from a different frame/browser
|
|
275
370
|
if (data.frameId !== frameId) {
|
|
276
371
|
insertChange(data);
|
|
277
372
|
}
|
|
278
373
|
}
|
|
279
374
|
});
|
|
280
375
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
+
*/
|
|
284
381
|
function sync(data) {
|
|
285
|
-
if (data.clientId === clientId)
|
|
286
|
-
|
|
382
|
+
if (data.clientId === clientId) return;
|
|
383
|
+
|
|
287
384
|
if (data.array.includes('crdt')) {
|
|
288
385
|
if (data.object && data.object[0]) {
|
|
289
386
|
let Data = data.object[0];
|
|
290
387
|
let name = Data.name;
|
|
291
388
|
let object = objects[name];
|
|
389
|
+
|
|
292
390
|
if (object && Data.crud) {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
Data.crud
|
|
301
|
-
|
|
302
|
-
Data.crud.length = object['text'].length
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
object['changeLog'] = Data.changeLog
|
|
306
|
-
object['text'] = Data.text // TODO: compare modified dates to check if arrays need to merged and orderd by date or if we just use server
|
|
307
|
-
localChange(Data.crud, Data.text)
|
|
308
|
-
console.log('crdtSync')
|
|
309
|
-
// }
|
|
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');
|
|
310
400
|
}
|
|
311
401
|
}
|
|
312
402
|
}
|
|
313
|
-
|
|
314
403
|
}
|
|
315
404
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
*/
|
|
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
|
+
*/
|
|
323
411
|
async function getText(data) {
|
|
324
412
|
try {
|
|
325
413
|
let object = await getObject(data);
|
|
326
414
|
if (object) {
|
|
327
|
-
|
|
328
|
-
return value;
|
|
415
|
+
return object.text;
|
|
329
416
|
} else {
|
|
330
|
-
console.log('undefined')
|
|
417
|
+
console.log('undefined');
|
|
331
418
|
}
|
|
332
419
|
} catch (e) {
|
|
333
420
|
console.error(e);
|
|
@@ -335,27 +422,22 @@ async function getText(data) {
|
|
|
335
422
|
}
|
|
336
423
|
}
|
|
337
424
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
value: "",
|
|
345
|
-
crud: true | false,
|
|
346
|
-
element: dom_object,
|
|
347
|
-
metadata: "xxxx"
|
|
348
|
-
})
|
|
349
|
-
*/
|
|
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
|
+
*/
|
|
350
431
|
async function replaceText(data) {
|
|
351
432
|
try {
|
|
352
433
|
let object = await getObject(data);
|
|
353
434
|
if (object) {
|
|
354
435
|
let oldValue = await getText(data);
|
|
355
|
-
if (oldValue)
|
|
436
|
+
if (oldValue) {
|
|
356
437
|
data.length = oldValue.length;
|
|
357
|
-
else
|
|
438
|
+
} else {
|
|
358
439
|
data.length = 0;
|
|
440
|
+
}
|
|
359
441
|
|
|
360
442
|
data.start = 0;
|
|
361
443
|
updateText(data, 'replace');
|
|
@@ -365,26 +447,21 @@ async function replaceText(data) {
|
|
|
365
447
|
}
|
|
366
448
|
}
|
|
367
449
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
attributes: {bold: true}
|
|
376
|
-
length: 2, // length is used to define charcters that will be deleted
|
|
377
|
-
})
|
|
378
|
-
*/
|
|
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
|
+
*/
|
|
379
457
|
async function updateText(data, flag) {
|
|
380
458
|
let object = await getObject(data);
|
|
381
459
|
if (object) {
|
|
382
|
-
|
|
383
460
|
insertChange(data, flag);
|
|
384
461
|
|
|
385
|
-
if (data.crud
|
|
386
|
-
if (data.upsert === undefined)
|
|
387
|
-
|
|
462
|
+
if (data.crud !== 'false' && data.save !== 'false') {
|
|
463
|
+
if (data.upsert === undefined) data.upsert = true;
|
|
464
|
+
|
|
388
465
|
let wholestring = await getText(data);
|
|
389
466
|
crud.send({
|
|
390
467
|
method: 'object.update',
|
|
@@ -405,72 +482,100 @@ async function updateText(data, flag) {
|
|
|
405
482
|
}
|
|
406
483
|
}
|
|
407
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
|
+
*/
|
|
408
493
|
function createChange(data, change) {
|
|
409
494
|
if (change.value && change.length == 0) {
|
|
410
|
-
change.length = change.value.length
|
|
411
|
-
change.removedValue = change.value
|
|
495
|
+
change.length = change.value.length;
|
|
496
|
+
change.removedValue = change.value;
|
|
412
497
|
change.value = '';
|
|
413
498
|
change.type = 'delete';
|
|
414
499
|
} else {
|
|
415
|
-
change.value = change.removedValue
|
|
500
|
+
change.value = change.removedValue;
|
|
416
501
|
change.length = 0;
|
|
417
502
|
change.type = 'insert';
|
|
418
503
|
}
|
|
504
|
+
|
|
419
505
|
data = { ...data, ...change };
|
|
420
|
-
delete data.frameId
|
|
421
|
-
delete data.
|
|
422
|
-
delete change.frameId
|
|
423
|
-
delete change.
|
|
424
|
-
return { 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 };
|
|
425
511
|
}
|
|
426
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
|
+
*/
|
|
427
519
|
function undoText(data) {
|
|
428
520
|
let name = getName(data);
|
|
429
521
|
let object = objects[name];
|
|
430
522
|
let changeLog = object.changeLog;
|
|
431
523
|
let undoLog = object.undoLog;
|
|
432
524
|
|
|
525
|
+
// Traverse changelog backwards to find the last local frame modification
|
|
433
526
|
for (let index = changeLog.length - 1; index >= 0; index--) {
|
|
434
527
|
let change = Object.assign({}, changeLog[index]);
|
|
435
528
|
if (change && change.frameId == frameId) {
|
|
436
|
-
let log = undoLog.get(index)
|
|
529
|
+
let log = undoLog.get(index);
|
|
437
530
|
if (!log) {
|
|
438
531
|
if (log != 'undo') {
|
|
439
|
-
undoLog.set(index, 'undo')
|
|
532
|
+
undoLog.set(index, 'undo');
|
|
440
533
|
change.index = changeLog.length += 1;
|
|
441
534
|
let updated = createChange(data, change);
|
|
442
|
-
undoLog.set(updated.change.index, updated.change)
|
|
443
|
-
updateText(updated.data)
|
|
444
|
-
return
|
|
535
|
+
undoLog.set(updated.change.index, updated.change);
|
|
536
|
+
updateText(updated.data);
|
|
537
|
+
return;
|
|
445
538
|
}
|
|
446
539
|
}
|
|
447
540
|
}
|
|
448
541
|
}
|
|
449
542
|
}
|
|
450
543
|
|
|
544
|
+
/**
|
|
545
|
+
* Re-applies a previously undone local change.
|
|
546
|
+
*
|
|
547
|
+
* @param {CRDTData} data - The document tracking configuration.
|
|
548
|
+
*/
|
|
451
549
|
function redoText(data) {
|
|
452
550
|
let name = getName(data);
|
|
453
551
|
let object = objects[name];
|
|
454
|
-
let redoLog = object.redoLog
|
|
552
|
+
let redoLog = object.redoLog;
|
|
455
553
|
let undoLog = Array.from(object.undoLog.values());
|
|
456
554
|
|
|
457
555
|
for (let index = undoLog.length - 1; index >= 0; index--) {
|
|
458
556
|
let change = Object.assign({}, undoLog[index]);
|
|
459
557
|
if (change && change != 'undo') {
|
|
460
|
-
let log = redoLog.get(change.index)
|
|
558
|
+
let log = redoLog.get(change.index);
|
|
461
559
|
|
|
462
560
|
if (!log) {
|
|
463
561
|
if (log != 'redo') {
|
|
464
562
|
let updated = createChange(data, change);
|
|
465
|
-
redoLog.set(updated.change.index, updated.change)
|
|
466
|
-
updateText(updated.data)
|
|
467
|
-
return
|
|
563
|
+
redoLog.set(updated.change.index, updated.change);
|
|
564
|
+
updateText(updated.data);
|
|
565
|
+
return;
|
|
468
566
|
}
|
|
469
567
|
}
|
|
470
568
|
}
|
|
471
569
|
}
|
|
472
570
|
}
|
|
473
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
|
+
*/
|
|
474
579
|
async function viewVersion(data) {
|
|
475
580
|
try {
|
|
476
581
|
let name = getName(data);
|
|
@@ -478,26 +583,134 @@ async function viewVersion(data) {
|
|
|
478
583
|
let changeLog = object.changeLog;
|
|
479
584
|
let string = '';
|
|
480
585
|
|
|
481
|
-
let
|
|
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
|
+
|
|
482
591
|
for (let change of log) {
|
|
483
592
|
if (change || change !== null) {
|
|
484
|
-
|
|
593
|
+
if (change.type === 'snapshot') {
|
|
594
|
+
string = change.value;
|
|
595
|
+
} else {
|
|
596
|
+
string = spliceString(string, change.start, change.length, change.value);
|
|
597
|
+
}
|
|
485
598
|
}
|
|
486
599
|
}
|
|
487
|
-
|
|
600
|
+
|
|
601
|
+
data.value = string;
|
|
602
|
+
return data;
|
|
488
603
|
} catch (e) {
|
|
489
604
|
console.error(e);
|
|
490
605
|
}
|
|
491
606
|
}
|
|
492
607
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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
|
+
}
|
|
498
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
|
+
*/
|
|
499
712
|
function getName(data) {
|
|
500
713
|
return `${data.array}${data.object}${data.key}`;
|
|
501
714
|
}
|
|
502
715
|
|
|
503
|
-
export default { init, objects, getName, getText, updateText, replaceText, undoText, redoText, viewVersion };
|
|
716
|
+
export default { init, objects, getName, getText, updateText, replaceText, undoText, redoText, viewVersion, createSnapshot };
|