@cocreate/crdt 1.29.2 → 1.31.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.
@@ -9,33 +9,36 @@ jobs:
9
9
  about:
10
10
  runs-on: ubuntu-latest
11
11
  steps:
12
- - name: Checkout
13
- uses: actions/checkout@v3
14
- - name: Setup Node.js
15
- uses: actions/setup-node@v3
16
- with:
17
- node-version: 16
18
- - name: Jaid/action-sync-node-meta
19
- uses: jaid/action-sync-node-meta@v1.4.0
20
- with:
21
- direction: overwrite-github
22
- githubToken: "${{ secrets.GITHUB }}"
12
+ - name: Checkout repository
13
+ uses: actions/checkout@v7
14
+
15
+ - name: Sync package.json description to GitHub About
16
+ env:
17
+ GITHUB_TOKEN: ${{ secrets.GITHUB }}
18
+ run: |
19
+ DESC=$(jq -r '.description // empty' package.json)
20
+ if [ -n "$DESC" ]; then
21
+ echo "Updating repository description to: $DESC"
22
+ gh api -X PATCH repos/${{ github.repository }} -f description="$DESC"
23
+ else
24
+ echo "No description found in package.json."
25
+ fi
23
26
 
24
27
  release:
25
28
  runs-on: ubuntu-latest
26
29
  steps:
27
- - name: Checkout
28
- uses: actions/checkout@v4
30
+ - name: Checkout repository
31
+ uses: actions/checkout@v7
29
32
  with:
30
- fetch-depth: 0 # Required so semantic-release can trace git tags/history
33
+ fetch-depth: 0
34
+ token: ${{ secrets.GITHUB }} # Authenticates git CLI for tag/commit pushes
31
35
 
32
36
  - name: Setup Node.js
33
- uses: actions/setup-node@v4
37
+ uses: actions/setup-node@v7
34
38
  with:
35
- node-version: 22
39
+ node-version: '24'
36
40
 
37
41
  - name: Install Semantic Release & Plugins
38
- # Installs semantic-release and its plugins on the runner
39
42
  run: |
40
43
  npm install -g semantic-release \
41
44
  @semantic-release/changelog \
@@ -43,12 +46,11 @@ jobs:
43
46
  @semantic-release/github \
44
47
  @semantic-release/git
45
48
 
46
- - name: Run Semantic Release (Native)
49
+ - name: Run Semantic Release
47
50
  id: semantic
48
- # This will automatically pick up your export default config file in the repository root
49
51
  run: npx semantic-release
50
52
  env:
51
- GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
53
+ GITHUB_TOKEN: "${{ secrets.GITHUB }}"
52
54
  NPM_TOKEN: "${{ secrets.NPM_TOKEN }}"
53
55
 
54
56
  outputs:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocreate/crdt",
3
- "version": "1.29.2",
3
+ "version": "1.31.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.0",
30
- "@cocreate/local-storage": "^1.17.0",
31
- "@cocreate/utils": "^1.44.0"
29
+ "@cocreate/crud-client": "^1.36.0",
30
+ "@cocreate/local-storage": "^1.18.0",
31
+ "@cocreate/utils": "^1.50.3"
32
32
  },
33
33
  "devDependencies": {
34
- "@cocreate/webpack": "^1.6.0"
34
+ "@cocreate/webpack": "^1.7.0"
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/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/uuid';
24
+ import { uuid, ulid, ulidDecode } from '@cocreate/utils';
26
25
  import localStorage from '@cocreate/local-storage';
27
26
 
28
- const objects = {}
29
- const clientId = crud.socket.clientId || uuid.generate(12);
30
- const frameId = crud.socket.frameId || uuid.generate(12);
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
- return
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
- // await generateText(data, true);
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
- String.prototype.customSplice = function (index, absIndex, string) {
91
- return this.slice(0, index) + string + this.slice(index + Math.abs(absIndex));
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
- object['text'] = object['text'].customSplice(change.start, change.length, change.value);
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
- return;
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
- string = ""
131
- if (string && typeof string === 'string' && flag != false) {
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
- if (!changeLog)
146
- return
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
- datetime: data.datetime || new Date().toISOString(),
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
- if (change.length > 0)
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 == 'replace') {
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
- changeLog.push(change);
203
- }
204
-
205
- object.text = object.text.customSplice(change.start, change.length, change.value);
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
- if (!data.frameId) {
208
- data.datetime = change.datetime;
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
- if (data.frameId == frameId && data.save != "false")
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
- // crud.listen('object.update', (data) => sync(data))
282
- // crud.listen('object.delete', (data) => sync(data))
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
- return
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
- // let text = object['text']
294
- // if (!text && text !== '') {
295
- // setTimeout(function () {
296
- // console.log("text empty timout set");
297
- // sync(data)
298
- // }, 1000); // Delayed action after 2 seconds
299
- // } else {
300
- Data.crud.value = Data.text
301
- Data.crud.start = 0
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
- crdt.getText({
318
- array: 'modules',
319
- object: '5e4802ce3ed96d38e71fc7e5',
320
- key: 'name'
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
- let value = object.text
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
- crdt.replaceText({
341
- array: "module",
342
- object: "",
343
- key: "",
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
- crdt.updateText({
370
- array: 'module_activities',
371
- object: '5e4802ce3ed96d38e71fc7e5',
372
- key: 'name',
373
- value: 'T',
374
- start: '8',
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 != 'false' && data.save != 'false') {
386
- if (data.upsert === undefined)
387
- data.upsert = true
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.datetime
422
- delete change.frameId
423
- delete change.datetime
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 log = changeLog.slice(0, data.version)
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
- string = string.customSplice(change.start, change.length, change.value);
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
- return { ...data, ...string }
600
+
601
+ data.value = string;
602
+ return data;
488
603
  } catch (e) {
489
604
  console.error(e);
490
605
  }
491
606
  }
492
607
 
493
- // function deleteObject(name) {
494
- // if (objects[name]) {
495
- // delete objects[name];
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 };