@vuer-ai/vuer-rtc 0.7.0 → 0.8.2
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/CLAUDE.md +3 -2
- package/dist/client/EditBuffer.d.ts +4 -4
- package/dist/client/EditBuffer.d.ts.map +1 -1
- package/dist/client/EditBuffer.js +26 -25
- package/dist/client/EditBuffer.js.map +1 -1
- package/dist/client/actions.d.ts +3 -3
- package/dist/client/actions.d.ts.map +1 -1
- package/dist/client/actions.js +71 -70
- package/dist/client/actions.js.map +1 -1
- package/dist/client/coalesceGraphOps.d.ts +4 -4
- package/dist/client/coalesceGraphOps.js +4 -4
- package/dist/client/coalesceTextOperations.d.ts.map +1 -1
- package/dist/client/coalesceTextOperations.js +23 -20
- package/dist/client/coalesceTextOperations.js.map +1 -1
- package/dist/client/coalescence/lwwOperations.js +3 -3
- package/dist/client/coalescence/lwwOperations.js.map +1 -1
- package/dist/client/coalescence/numberOperations.js +2 -2
- package/dist/client/coalescence/numberOperations.js.map +1 -1
- package/dist/client/coalescence/registry.d.ts +3 -3
- package/dist/client/coalescence/registry.d.ts.map +1 -1
- package/dist/client/coalescence/registry.js +11 -11
- package/dist/client/coalescence/registry.js.map +1 -1
- package/dist/client/coalescence/textDeletes.d.ts +8 -7
- package/dist/client/coalescence/textDeletes.d.ts.map +1 -1
- package/dist/client/coalescence/textDeletes.js +11 -11
- package/dist/client/coalescence/textDeletes.js.map +1 -1
- package/dist/client/coalescence/textInserts.d.ts +8 -5
- package/dist/client/coalescence/textInserts.d.ts.map +1 -1
- package/dist/client/coalescence/textInserts.js +32 -12
- package/dist/client/coalescence/textInserts.js.map +1 -1
- package/dist/client/coalescence/utils.d.ts +3 -9
- package/dist/client/coalescence/utils.d.ts.map +1 -1
- package/dist/client/coalescence/utils.js +10 -8
- package/dist/client/coalescence/utils.js.map +1 -1
- package/dist/client/coalescence/vector3Operations.js +2 -2
- package/dist/client/coalescence/vector3Operations.js.map +1 -1
- package/dist/client/createGraph.d.ts +2 -2
- package/dist/client/createGraph.js +4 -4
- package/dist/client/createGraph.js.map +1 -1
- package/dist/client/createTextDocument.d.ts +1 -1
- package/dist/client/createTextDocument.js +3 -3
- package/dist/client/createTextDocument.js.map +1 -1
- package/dist/client/hooks.d.ts +3 -3
- package/dist/client/hooks.d.ts.map +1 -1
- package/dist/client/hooks.js +4 -4
- package/dist/client/hooks.js.map +1 -1
- package/dist/client/textActions.d.ts +2 -2
- package/dist/client/textActions.d.ts.map +1 -1
- package/dist/client/textActions.js +47 -47
- package/dist/client/textActions.js.map +1 -1
- package/dist/client/textTypes.d.ts +8 -8
- package/dist/client/textTypes.d.ts.map +1 -1
- package/dist/client/types.d.ts +4 -4
- package/dist/client/types.d.ts.map +1 -1
- package/dist/crdt/GraphTextCRDT.d.ts +2 -2
- package/dist/crdt/GraphTextCRDT.d.ts.map +1 -1
- package/dist/crdt/GraphTextCRDT.js +6 -6
- package/dist/crdt/GraphTextCRDT.js.map +1 -1
- package/dist/crdt/Rope.d.ts +13 -14
- package/dist/crdt/Rope.d.ts.map +1 -1
- package/dist/crdt/Rope.js +130 -59
- package/dist/crdt/Rope.js.map +1 -1
- package/dist/crdt/index.d.ts +1 -1
- package/dist/crdt/index.d.ts.map +1 -1
- package/dist/crdt/index.js +1 -1
- package/dist/crdt/index.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/operations/OperationTypes.d.ts +45 -48
- package/dist/operations/OperationTypes.d.ts.map +1 -1
- package/dist/operations/OperationValidator.js +11 -11
- package/dist/operations/OperationValidator.js.map +1 -1
- package/dist/operations/apply/node.js +3 -3
- package/dist/operations/apply/node.js.map +1 -1
- package/dist/operations/apply/text.d.ts.map +1 -1
- package/dist/operations/apply/text.js +35 -32
- package/dist/operations/apply/text.js.map +1 -1
- package/dist/operations/apply/types.d.ts +4 -4
- package/dist/operations/apply/types.d.ts.map +1 -1
- package/dist/operations/apply/types.js +8 -8
- package/dist/operations/apply/types.js.map +1 -1
- package/dist/operations/dispatcher.d.ts.map +1 -1
- package/dist/operations/dispatcher.js +52 -13
- package/dist/operations/dispatcher.js.map +1 -1
- package/dist/serdes.d.ts +1 -1
- package/dist/serdes.d.ts.map +1 -1
- package/dist/state/ConflictResolver.d.ts +9 -9
- package/dist/state/ConflictResolver.d.ts.map +1 -1
- package/dist/state/ConflictResolver.js +20 -20
- package/dist/state/ConflictResolver.js.map +1 -1
- package/dist/state/DType.d.ts +2 -2
- package/dist/state/DType.d.ts.map +1 -1
- package/dist/state/DType.js +14 -14
- package/dist/state/DType.js.map +1 -1
- package/dist/state/VectorClock.d.ts +6 -6
- package/dist/state/VectorClock.d.ts.map +1 -1
- package/dist/state/VectorClock.js +14 -14
- package/dist/state/VectorClock.js.map +1 -1
- package/dist/state/index.d.ts +1 -1
- package/dist/state/index.js +1 -1
- package/examples/01-basic-usage.ts +16 -16
- package/examples/02-concurrent-edits.ts +29 -29
- package/examples/03-scene-building.ts +28 -28
- package/examples/04-conflict-resolution.ts +56 -56
- package/examples/05-coalescence-usage.ts +23 -23
- package/examples/README.md +12 -12
- package/package.json +1 -1
- package/src/client/EditBuffer.ts +28 -27
- package/src/client/TEXT_DOCUMENT_API.md +9 -9
- package/src/client/actions.ts +74 -70
- package/src/client/coalesceGraphOps.ts +4 -4
- package/src/client/coalesceTextOperations.ts +26 -22
- package/src/client/coalescence/lwwOperations.ts +3 -3
- package/src/client/coalescence/numberOperations.ts +2 -2
- package/src/client/coalescence/registry.ts +13 -12
- package/src/client/coalescence/textDeletes.ts +22 -18
- package/src/client/coalescence/textInserts.ts +49 -25
- package/src/client/coalescence/utils.ts +14 -11
- package/src/client/coalescence/vector3Operations.ts +2 -2
- package/src/client/createGraph.ts +4 -4
- package/src/client/createTextDocument.ts +3 -3
- package/src/client/hooks.tsx +5 -5
- package/src/client/textActions.ts +47 -47
- package/src/client/textTypes.ts +8 -8
- package/src/client/types.ts +4 -4
- package/src/crdt/GraphTextCRDT.ts +6 -6
- package/src/crdt/Rope.ts +156 -71
- package/src/crdt/index.ts +2 -0
- package/src/index.ts +2 -0
- package/src/operations/OperationTypes.ts +47 -47
- package/src/operations/OperationValidator.ts +11 -11
- package/src/operations/apply/node.ts +3 -3
- package/src/operations/apply/text.ts +38 -32
- package/src/operations/apply/types.ts +11 -11
- package/src/operations/dispatcher.ts +57 -13
- package/src/serdes.ts +1 -1
- package/src/state/ConflictResolver.ts +23 -23
- package/src/state/DType.ts +16 -16
- package/src/state/VectorClock.ts +14 -14
- package/src/state/index.ts +1 -1
- package/tests/client/actions.test.ts +76 -76
- package/tests/client/coalesce-graph-operations.test.ts +84 -84
- package/tests/client/coalesce-text-operations.test.ts +91 -114
- package/tests/client/compaction.test.ts +18 -18
- package/tests/client/delete-coalescence-bug.test.ts +34 -34
- package/tests/client/edit-buffer.test.ts +27 -30
- package/tests/client/graph-coalescence-phase1.test.ts +66 -66
- package/tests/client/graph-coalescence.test.ts +50 -50
- package/tests/client/journal-benchmark.test.ts +5 -5
- package/tests/crdt/graph-text-crdt.test.ts +60 -64
- package/tests/crdt/rope.test.ts +9 -8
- package/tests/crdt/text-operations.test.ts +28 -28
- package/tests/fixtures/array-ops.jsonl +6 -6
- package/tests/fixtures/boolean-ops.jsonl +6 -6
- package/tests/fixtures/color-ops.jsonl +4 -4
- package/tests/fixtures/edit-buffer.jsonl +3 -3
- package/tests/fixtures/node-ops.jsonl +6 -6
- package/tests/fixtures/number-ops.jsonl +7 -7
- package/tests/fixtures/object-ops.jsonl +4 -4
- package/tests/fixtures/operations.jsonl +7 -7
- package/tests/fixtures/string-ops.jsonl +4 -4
- package/tests/fixtures/undo-redo.jsonl +3 -3
- package/tests/fixtures/vector-ops.jsonl +17 -17
- package/tests/operations/collections.test.ts +4 -4
- package/tests/operations/nodes.test.ts +5 -5
- package/tests/operations/operation-ordering.test.ts +406 -0
- package/tests/operations/primitives.test.ts +4 -4
- package/tests/operations/unified-schema.test.ts +27 -27
- package/tests/operations/vectors.test.ts +4 -4
- package/tests/sync/digest.test.ts +5 -5
package/src/client/actions.ts
CHANGED
|
@@ -29,12 +29,12 @@ export function generateUUID(): string {
|
|
|
29
29
|
/**
|
|
30
30
|
* Create initial client state
|
|
31
31
|
*/
|
|
32
|
-
export function createInitialState(
|
|
32
|
+
export function createInitialState(client: string, initialSnapshot?: Snapshot): ClientState {
|
|
33
33
|
const emptyGraph = createEmptyGraph();
|
|
34
34
|
const snapshot = initialSnapshot ?? {
|
|
35
35
|
graph: emptyGraph,
|
|
36
36
|
vectorClock: {},
|
|
37
|
-
|
|
37
|
+
lt: 0,
|
|
38
38
|
journalIndex: 0,
|
|
39
39
|
};
|
|
40
40
|
|
|
@@ -43,9 +43,9 @@ export function createInitialState(sessionId: string, initialSnapshot?: Snapshot
|
|
|
43
43
|
journal: [],
|
|
44
44
|
edits: { ops: [], baseGraph: null },
|
|
45
45
|
snapshot,
|
|
46
|
-
|
|
47
|
-
vectorClock: clockManager.create(
|
|
48
|
-
|
|
46
|
+
lt: snapshot.lt ?? 0,
|
|
47
|
+
vectorClock: clockManager.create(client),
|
|
48
|
+
client: client,
|
|
49
49
|
};
|
|
50
50
|
}
|
|
51
51
|
|
|
@@ -158,23 +158,26 @@ function reuseUnchangedNodes(oldGraph: SceneGraph, newGraph: SceneGraph): SceneG
|
|
|
158
158
|
export function rebuildGraph(
|
|
159
159
|
snapshot: Snapshot,
|
|
160
160
|
journal: JournalEntry[],
|
|
161
|
-
pendingOps: Operation[]
|
|
161
|
+
pendingOps: Operation[],
|
|
162
|
+
client: string,
|
|
163
|
+
vectorClock: VectorClock,
|
|
164
|
+
lt: number
|
|
162
165
|
): SceneGraph {
|
|
163
166
|
let graph = cloneSnapshotGraph(snapshot.graph);
|
|
164
167
|
|
|
165
168
|
// Sort journal by causal order so out-of-order deliveries
|
|
166
169
|
// (e.g. via sync retransmission) are replayed correctly.
|
|
167
170
|
const sorted = [...journal].sort((a, b) => {
|
|
168
|
-
const dt = a.msg.
|
|
171
|
+
const dt = a.msg.lt - b.msg.lt;
|
|
169
172
|
if (dt !== 0) return dt;
|
|
170
|
-
return a.msg.
|
|
173
|
+
return a.msg.client < b.msg.client ? -1 : a.msg.client > b.msg.client ? 1 : 0;
|
|
171
174
|
});
|
|
172
175
|
|
|
173
176
|
// Apply journal (skip deleted entries and meta ops)
|
|
174
177
|
for (const entry of sorted) {
|
|
175
178
|
if (entry.deletedAt) continue;
|
|
176
179
|
|
|
177
|
-
const realOps = entry.msg.ops.filter(op => !op.
|
|
180
|
+
const realOps = entry.msg.ops.filter(op => !op.ot.startsWith('meta.'));
|
|
178
181
|
if (realOps.length > 0) {
|
|
179
182
|
graph = applyMessage(graph, { ...entry.msg, ops: realOps });
|
|
180
183
|
}
|
|
@@ -184,10 +187,10 @@ export function rebuildGraph(
|
|
|
184
187
|
if (pendingOps.length > 0) {
|
|
185
188
|
for (const op of pendingOps) {
|
|
186
189
|
applyOperation(graph, op, {
|
|
187
|
-
|
|
188
|
-
clock:
|
|
189
|
-
|
|
190
|
-
|
|
190
|
+
client,
|
|
191
|
+
clock: vectorClock,
|
|
192
|
+
lt,
|
|
193
|
+
ts: Date.now() / 1000,
|
|
191
194
|
});
|
|
192
195
|
}
|
|
193
196
|
}
|
|
@@ -208,8 +211,8 @@ export function onEdit(state: ClientState, op: Operation): ClientState {
|
|
|
208
211
|
|
|
209
212
|
if (existingIdx >= 0) {
|
|
210
213
|
const existing = ops[existingIdx];
|
|
211
|
-
if (existing.
|
|
212
|
-
const mergedValue = mergeValues(op.
|
|
214
|
+
if (existing.ot === op.ot && isAdditiveOp(op.ot)) {
|
|
215
|
+
const mergedValue = mergeValues(op.ot, (existing as any).value, (op as any).value);
|
|
213
216
|
ops[existingIdx] = { ...existing, value: mergedValue } as Operation;
|
|
214
217
|
} else {
|
|
215
218
|
ops[existingIdx] = op;
|
|
@@ -226,12 +229,12 @@ export function onEdit(state: ClientState, op: Operation): ClientState {
|
|
|
226
229
|
if (typeof op.key === 'string' && op.key in graph.nodes) {
|
|
227
230
|
keysToClone.push(op.key);
|
|
228
231
|
}
|
|
229
|
-
if (op.
|
|
232
|
+
if (op.ot === 'node.move') {
|
|
230
233
|
const { nodeKey, newParent } = (op as any).value;
|
|
231
234
|
if (nodeKey && nodeKey in graph.nodes) keysToClone.push(nodeKey);
|
|
232
235
|
if (newParent && newParent in graph.nodes) keysToClone.push(newParent);
|
|
233
236
|
}
|
|
234
|
-
if (op.
|
|
237
|
+
if (op.ot === 'node.remove') {
|
|
235
238
|
const nodeKey = (op as any).value;
|
|
236
239
|
if (typeof nodeKey === 'string' && nodeKey in graph.nodes) keysToClone.push(nodeKey);
|
|
237
240
|
}
|
|
@@ -247,10 +250,10 @@ export function onEdit(state: ClientState, op: Operation): ClientState {
|
|
|
247
250
|
}
|
|
248
251
|
|
|
249
252
|
applyOperation(graph, op, {
|
|
250
|
-
|
|
253
|
+
client: state.client,
|
|
251
254
|
clock: state.vectorClock,
|
|
252
|
-
|
|
253
|
-
|
|
255
|
+
lt: state.lt + 1,
|
|
256
|
+
ts: Date.now() / 1000,
|
|
254
257
|
});
|
|
255
258
|
|
|
256
259
|
return {
|
|
@@ -276,8 +279,8 @@ export function commitEdits(
|
|
|
276
279
|
return { state, msg: null };
|
|
277
280
|
}
|
|
278
281
|
|
|
279
|
-
const newClock = clockManager.increment(state.vectorClock, state.
|
|
280
|
-
const newLamport = state.
|
|
282
|
+
const newClock = clockManager.increment(state.vectorClock, state.client);
|
|
283
|
+
const newLamport = state.lt + 1;
|
|
281
284
|
|
|
282
285
|
// Coalesce CRDT operations before sending (if threshold specified)
|
|
283
286
|
const coalescedOps = coalescingThresholdMs !== undefined
|
|
@@ -285,11 +288,11 @@ export function commitEdits(
|
|
|
285
288
|
: state.edits.ops;
|
|
286
289
|
|
|
287
290
|
const msg: CRDTMessage = {
|
|
288
|
-
id: `${state.
|
|
289
|
-
|
|
291
|
+
id: `${state.client}:${newLamport}`,
|
|
292
|
+
client: state.client,
|
|
290
293
|
clock: newClock,
|
|
291
|
-
|
|
292
|
-
|
|
294
|
+
lt: newLamport,
|
|
295
|
+
ts: Date.now() / 1000,
|
|
293
296
|
ops: coalescedOps,
|
|
294
297
|
};
|
|
295
298
|
|
|
@@ -298,7 +301,7 @@ export function commitEdits(
|
|
|
298
301
|
...state,
|
|
299
302
|
journal: [...state.journal, { msg, ack: false }],
|
|
300
303
|
edits: { ops: [], baseGraph: null },
|
|
301
|
-
|
|
304
|
+
lt: msg.lt,
|
|
302
305
|
vectorClock: newClock,
|
|
303
306
|
},
|
|
304
307
|
msg,
|
|
@@ -343,11 +346,11 @@ export function onRemoteMessage(state: ClientState, msg: CRDTMessage): ClientSta
|
|
|
343
346
|
|
|
344
347
|
// Process meta ops (undo/redo) — clone modified entries
|
|
345
348
|
for (const op of msg.ops) {
|
|
346
|
-
if (op.
|
|
349
|
+
if (op.ot === 'meta.undo') {
|
|
347
350
|
const targetMsgId = (op as any).targetMsgId;
|
|
348
351
|
const idx = journal.findIndex(e => e.msg.id === targetMsgId);
|
|
349
|
-
if (idx >= 0) journal[idx] = { ...journal[idx], deletedAt: msg.
|
|
350
|
-
} else if (op.
|
|
352
|
+
if (idx >= 0) journal[idx] = { ...journal[idx], deletedAt: msg.lt };
|
|
353
|
+
} else if (op.ot === 'meta.redo') {
|
|
351
354
|
const targetMsgId = (op as any).targetMsgId;
|
|
352
355
|
const idx = journal.findIndex(e => e.msg.id === targetMsgId);
|
|
353
356
|
if (idx >= 0) {
|
|
@@ -358,11 +361,11 @@ export function onRemoteMessage(state: ClientState, msg: CRDTMessage): ClientSta
|
|
|
358
361
|
}
|
|
359
362
|
|
|
360
363
|
const vectorClock = clockManager.merge(state.vectorClock, msg.clock);
|
|
361
|
-
const lamportTime = Math.max(state.
|
|
362
|
-
const rebuilt = rebuildGraph(state.snapshot, journal, state.edits.ops);
|
|
364
|
+
const lamportTime = Math.max(state.lt, msg.lt);
|
|
365
|
+
const rebuilt = rebuildGraph(state.snapshot, journal, state.edits.ops, state.client, vectorClock, lamportTime);
|
|
363
366
|
const graph = reuseUnchangedNodes(state.graph, rebuilt);
|
|
364
367
|
|
|
365
|
-
return { ...state, journal, vectorClock, lamportTime, graph };
|
|
368
|
+
return { ...state, journal, vectorClock, lt: lamportTime, graph };
|
|
366
369
|
}
|
|
367
370
|
|
|
368
371
|
/**
|
|
@@ -382,8 +385,8 @@ export function undo(state: ClientState): { state: ClientState; msg: CRDTMessage
|
|
|
382
385
|
const lastActive = [...currentState.journal]
|
|
383
386
|
.reverse()
|
|
384
387
|
.find(e => !e.deletedAt
|
|
385
|
-
&& e.msg.
|
|
386
|
-
&& !e.msg.ops.some(op => op.
|
|
388
|
+
&& e.msg.client === currentState.client
|
|
389
|
+
&& !e.msg.ops.some(op => op.ot.startsWith('meta.'))
|
|
387
390
|
);
|
|
388
391
|
|
|
389
392
|
if (!lastActive) return { state: currentState, msg: null };
|
|
@@ -391,15 +394,15 @@ export function undo(state: ClientState): { state: ClientState; msg: CRDTMessage
|
|
|
391
394
|
}
|
|
392
395
|
|
|
393
396
|
// Create undo message
|
|
394
|
-
const newClock = clockManager.increment(currentState.vectorClock, currentState.
|
|
395
|
-
const newLamport = currentState.
|
|
397
|
+
const newClock = clockManager.increment(currentState.vectorClock, currentState.client);
|
|
398
|
+
const newLamport = currentState.lt + 1;
|
|
396
399
|
const undoMsg: CRDTMessage = {
|
|
397
|
-
id: `${currentState.
|
|
398
|
-
|
|
400
|
+
id: `${currentState.client}:${newLamport}`,
|
|
401
|
+
client: currentState.client,
|
|
399
402
|
clock: newClock,
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
ops: [{
|
|
403
|
+
lt: newLamport,
|
|
404
|
+
ts: Date.now() / 1000,
|
|
405
|
+
ops: [{ ot: 'meta.undo', key: '_meta', path: '_meta', targetMsgId }],
|
|
403
406
|
};
|
|
404
407
|
|
|
405
408
|
// Build new journal with undo entry + target marked deleted
|
|
@@ -407,19 +410,19 @@ export function undo(state: ClientState): { state: ClientState; msg: CRDTMessage
|
|
|
407
410
|
// can reliably pick the most recently undone entry.
|
|
408
411
|
const journal: JournalEntry[] = currentState.journal.map(e => {
|
|
409
412
|
if (e.msg.id === targetMsgId) {
|
|
410
|
-
return { ...e, deletedAt: undoMsg.
|
|
413
|
+
return { ...e, deletedAt: undoMsg.lt, ack: false };
|
|
411
414
|
}
|
|
412
415
|
return e;
|
|
413
416
|
});
|
|
414
417
|
journal.push({ msg: undoMsg, ack: false });
|
|
415
418
|
|
|
416
|
-
const rebuilt = rebuildGraph(currentState.snapshot, journal, currentState.edits.ops);
|
|
419
|
+
const rebuilt = rebuildGraph(currentState.snapshot, journal, currentState.edits.ops, currentState.client, currentState.vectorClock, currentState.lt);
|
|
417
420
|
const graph = reuseUnchangedNodes(currentState.graph, rebuilt);
|
|
418
421
|
|
|
419
422
|
const newState: ClientState = {
|
|
420
423
|
...currentState,
|
|
421
424
|
journal,
|
|
422
|
-
|
|
425
|
+
lt: undoMsg.lt,
|
|
423
426
|
vectorClock: newClock,
|
|
424
427
|
graph,
|
|
425
428
|
};
|
|
@@ -437,8 +440,8 @@ export function redo(state: ClientState): { state: ClientState; msg: CRDTMessage
|
|
|
437
440
|
for (const e of state.journal) {
|
|
438
441
|
if (
|
|
439
442
|
e.deletedAt &&
|
|
440
|
-
e.msg.
|
|
441
|
-
!e.msg.ops.some(op => op.
|
|
443
|
+
e.msg.client === state.client &&
|
|
444
|
+
!e.msg.ops.some(op => op.ot.startsWith('meta.'))
|
|
442
445
|
) {
|
|
443
446
|
if (!lastDeleted || e.deletedAt > lastDeleted.deletedAt!) {
|
|
444
447
|
lastDeleted = e;
|
|
@@ -449,15 +452,15 @@ export function redo(state: ClientState): { state: ClientState; msg: CRDTMessage
|
|
|
449
452
|
if (!lastDeleted) return { state, msg: null };
|
|
450
453
|
|
|
451
454
|
// Create redo message
|
|
452
|
-
const newClock = clockManager.increment(state.vectorClock, state.
|
|
453
|
-
const newLamport = state.
|
|
455
|
+
const newClock = clockManager.increment(state.vectorClock, state.client);
|
|
456
|
+
const newLamport = state.lt + 1;
|
|
454
457
|
const redoMsg: CRDTMessage = {
|
|
455
|
-
id: `${state.
|
|
456
|
-
|
|
458
|
+
id: `${state.client}:${newLamport}`,
|
|
459
|
+
client: state.client,
|
|
457
460
|
clock: newClock,
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
ops: [{
|
|
461
|
+
lt: newLamport,
|
|
462
|
+
ts: Date.now() / 1000,
|
|
463
|
+
ops: [{ ot: 'meta.redo', key: '_meta', path: '_meta', targetMsgId: lastDeleted.msg.id }],
|
|
461
464
|
};
|
|
462
465
|
|
|
463
466
|
// Build new journal with redo entry + target un-deleted
|
|
@@ -470,13 +473,13 @@ export function redo(state: ClientState): { state: ClientState; msg: CRDTMessage
|
|
|
470
473
|
});
|
|
471
474
|
journal.push({ msg: redoMsg, ack: false });
|
|
472
475
|
|
|
473
|
-
const rebuilt = rebuildGraph(state.snapshot, journal, state.edits.ops);
|
|
476
|
+
const rebuilt = rebuildGraph(state.snapshot, journal, state.edits.ops, state.client, state.vectorClock, state.lt);
|
|
474
477
|
const graph = reuseUnchangedNodes(state.graph, rebuilt);
|
|
475
478
|
|
|
476
479
|
const newState: ClientState = {
|
|
477
480
|
...state,
|
|
478
481
|
journal,
|
|
479
|
-
|
|
482
|
+
lt: redoMsg.lt,
|
|
480
483
|
vectorClock: newClock,
|
|
481
484
|
graph,
|
|
482
485
|
};
|
|
@@ -543,7 +546,7 @@ export function compact(state: ClientState): ClientState {
|
|
|
543
546
|
const entry = state.journal[i];
|
|
544
547
|
if (entry.deletedAt) continue;
|
|
545
548
|
|
|
546
|
-
const realOps = entry.msg.ops.filter(op => !op.
|
|
549
|
+
const realOps = entry.msg.ops.filter(op => !op.ot.startsWith('meta.'));
|
|
547
550
|
if (realOps.length > 0) {
|
|
548
551
|
snapshotGraph = applyMessage(snapshotGraph, { ...entry.msg, ops: realOps });
|
|
549
552
|
}
|
|
@@ -557,7 +560,7 @@ export function compact(state: ClientState): ClientState {
|
|
|
557
560
|
snapshot: {
|
|
558
561
|
graph: snapshotGraph,
|
|
559
562
|
vectorClock: state.journal[lastAckedIdx].msg.clock,
|
|
560
|
-
|
|
563
|
+
lt: state.journal[lastAckedIdx].msg.lt,
|
|
561
564
|
journalIndex: state.snapshot.journalIndex + lastAckedIdx + 1,
|
|
562
565
|
},
|
|
563
566
|
journal: state.journal.slice(lastAckedIdx + 1),
|
|
@@ -601,7 +604,7 @@ export function compactToWatermark(state: ClientState, watermark: VectorClock):
|
|
|
601
604
|
const entry = state.journal[i];
|
|
602
605
|
if (entry.deletedAt) continue;
|
|
603
606
|
|
|
604
|
-
const realOps = entry.msg.ops.filter(op => !op.
|
|
607
|
+
const realOps = entry.msg.ops.filter(op => !op.ot.startsWith('meta.'));
|
|
605
608
|
if (realOps.length > 0) {
|
|
606
609
|
snapshotGraph = applyMessage(snapshotGraph, { ...entry.msg, ops: realOps });
|
|
607
610
|
}
|
|
@@ -612,7 +615,7 @@ export function compactToWatermark(state: ClientState, watermark: VectorClock):
|
|
|
612
615
|
snapshot: {
|
|
613
616
|
graph: snapshotGraph,
|
|
614
617
|
vectorClock: watermark,
|
|
615
|
-
|
|
618
|
+
lt: state.journal[lastIdx].msg.lt,
|
|
616
619
|
journalIndex: state.snapshot.journalIndex + lastIdx + 1,
|
|
617
620
|
},
|
|
618
621
|
journal: state.journal.slice(lastIdx + 1),
|
|
@@ -646,13 +649,13 @@ export function getPendingCount(state: ClientState): number {
|
|
|
646
649
|
* Initialize from server snapshot + journal
|
|
647
650
|
*/
|
|
648
651
|
export function initFromServer(
|
|
649
|
-
|
|
652
|
+
client: string,
|
|
650
653
|
snapshot: Snapshot,
|
|
651
654
|
journal: CRDTMessage[]
|
|
652
655
|
): ClientState {
|
|
653
656
|
let graph = snapshot.graph;
|
|
654
657
|
const journalEntries: JournalEntry[] = [];
|
|
655
|
-
let maxLamport = snapshot.
|
|
658
|
+
let maxLamport = snapshot.lt ?? 0;
|
|
656
659
|
let mergedClock: VectorClock = snapshot.vectorClock;
|
|
657
660
|
|
|
658
661
|
// Filter out journal entries already baked into the snapshot to
|
|
@@ -667,11 +670,11 @@ export function initFromServer(
|
|
|
667
670
|
for (const msg of filteredJournal) {
|
|
668
671
|
// Process meta ops
|
|
669
672
|
for (const op of msg.ops) {
|
|
670
|
-
if (op.
|
|
673
|
+
if (op.ot === 'meta.undo') {
|
|
671
674
|
const targetMsgId = (op as any).targetMsgId;
|
|
672
675
|
const target = journalEntries.find(e => e.msg.id === targetMsgId);
|
|
673
|
-
if (target) target.deletedAt = msg.
|
|
674
|
-
} else if (op.
|
|
676
|
+
if (target) target.deletedAt = msg.lt;
|
|
677
|
+
} else if (op.ot === 'meta.redo') {
|
|
675
678
|
const targetMsgId = (op as any).targetMsgId;
|
|
676
679
|
const target = journalEntries.find(e => e.msg.id === targetMsgId);
|
|
677
680
|
if (target) delete target.deletedAt;
|
|
@@ -679,20 +682,21 @@ export function initFromServer(
|
|
|
679
682
|
}
|
|
680
683
|
|
|
681
684
|
journalEntries.push({ msg, ack: true });
|
|
682
|
-
maxLamport = Math.max(maxLamport, msg.
|
|
685
|
+
maxLamport = Math.max(maxLamport, msg.lt);
|
|
683
686
|
mergedClock = clockManager.merge(mergedClock, msg.clock);
|
|
684
687
|
}
|
|
685
688
|
|
|
686
689
|
// Rebuild graph
|
|
687
|
-
|
|
690
|
+
const finalClock = clockManager.increment(mergedClock, client);
|
|
691
|
+
graph = rebuildGraph(snapshot, journalEntries, [], client, finalClock, maxLamport);
|
|
688
692
|
|
|
689
693
|
return {
|
|
690
694
|
graph,
|
|
691
695
|
journal: journalEntries,
|
|
692
696
|
edits: { ops: [], baseGraph: null },
|
|
693
697
|
snapshot,
|
|
694
|
-
|
|
695
|
-
vectorClock:
|
|
696
|
-
|
|
698
|
+
lt: maxLamport,
|
|
699
|
+
vectorClock: finalClock,
|
|
700
|
+
client: client,
|
|
697
701
|
};
|
|
698
702
|
}
|
|
@@ -19,13 +19,13 @@ import { coalesceOperations, type CoalesceOptions } from './coalescence/index.js
|
|
|
19
19
|
* Example:
|
|
20
20
|
* ```typescript
|
|
21
21
|
* const ops = [
|
|
22
|
-
* {
|
|
23
|
-
* {
|
|
24
|
-
* {
|
|
22
|
+
* { ot: 'text.insert', id: 'alice:1', content: 'h', ... },
|
|
23
|
+
* { ot: 'text.insert', id: 'alice:2', content: 'e', ... },
|
|
24
|
+
* { ot: 'text.insert', id: 'alice:3', content: 'l', ... },
|
|
25
25
|
* ];
|
|
26
26
|
*
|
|
27
27
|
* const coalesced = coalesceGraphOps(ops, { thresholdMs: 300 });
|
|
28
|
-
* // Result: [{
|
|
28
|
+
* // Result: [{ ot: 'text.insert', id: 'alice:1', content: 'hel', ... }]
|
|
29
29
|
* ```
|
|
30
30
|
*
|
|
31
31
|
* @param ops - Array of graph operations
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import type { TextOperation } from './textTypes.js';
|
|
15
15
|
import type { InsertOp, DeleteOp } from '../crdt/Rope.js';
|
|
16
|
-
import { parseItemId } from '../crdt/Rope.js';
|
|
16
|
+
import { parseItemId, createItemId } from '../crdt/Rope.js';
|
|
17
17
|
import { optimizeDeletions, parseItemId as parseId } from './coalescence/utils.js';
|
|
18
18
|
|
|
19
19
|
export interface CoalesceOptions {
|
|
@@ -21,17 +21,18 @@ export interface CoalesceOptions {
|
|
|
21
21
|
thresholdMs?: number;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
|
|
24
25
|
/**
|
|
25
26
|
* Sort and optimize deletions array.
|
|
26
27
|
* Deletions must be sorted in ascending order for optimizeDeletions to work correctly.
|
|
27
28
|
*/
|
|
28
|
-
function sortAndOptimizeDeletions(deletions: Array<
|
|
29
|
+
function sortAndOptimizeDeletions(deletions: Array<[string, number]>): Array<[string, number]> {
|
|
29
30
|
if (deletions.length === 0) return deletions;
|
|
30
31
|
|
|
31
32
|
// Sort deletions by agent, then by sequence number (ascending)
|
|
32
33
|
const sorted = [...deletions].sort((a, b) => {
|
|
33
|
-
const aId = parseId(a
|
|
34
|
-
const bId = parseId(b
|
|
34
|
+
const aId = parseId(a[0]); // id is at [0]
|
|
35
|
+
const bId = parseId(b[0]); // id is at [0]
|
|
35
36
|
if (aId.agent !== bId.agent) {
|
|
36
37
|
return aId.agent.localeCompare(bId.agent);
|
|
37
38
|
}
|
|
@@ -65,11 +66,11 @@ export function coalesceTextOperations(
|
|
|
65
66
|
let pendingDelete: DeleteOp | null = null;
|
|
66
67
|
|
|
67
68
|
for (const op of ops) {
|
|
68
|
-
if (op.
|
|
69
|
+
if (op.ot === 'insert') {
|
|
69
70
|
// Flush any pending delete before starting new insert
|
|
70
71
|
if (pendingDelete !== null) {
|
|
71
72
|
// Sort and optimize deletions array before flushing
|
|
72
|
-
pendingDelete.
|
|
73
|
+
pendingDelete.rm = sortAndOptimizeDeletions(pendingDelete.rm);
|
|
73
74
|
result.push(pendingDelete);
|
|
74
75
|
pendingDelete = null;
|
|
75
76
|
}
|
|
@@ -87,9 +88,10 @@ export function coalesceTextOperations(
|
|
|
87
88
|
// Check merge conditions
|
|
88
89
|
const sameAgent = prevId.agent === currId.agent;
|
|
89
90
|
|
|
90
|
-
// IDs must be sequential: next ID = prev ID + prev
|
|
91
|
-
// Example: prev="alice:5"
|
|
92
|
-
const
|
|
91
|
+
// IDs must be sequential: next ID = prev ID + prev value length
|
|
92
|
+
// Example: prev="alice:5" value="hel"(3 chars) → next="alice:8"
|
|
93
|
+
const prevContent = prevOp.value[1]; // content is at [1]
|
|
94
|
+
const sequentialIds = currId.seq === prevId.seq + prevContent.length;
|
|
93
95
|
|
|
94
96
|
// Time threshold: operations must be close in time (ts is in seconds)
|
|
95
97
|
const timeDiffMs = (currOp.ts - prevOp.ts) * 1000;
|
|
@@ -100,17 +102,19 @@ export function coalesceTextOperations(
|
|
|
100
102
|
// (not the first ID, which is what prevOp.id contains after merging)
|
|
101
103
|
// OR both should have the same parent (inserting at same position)
|
|
102
104
|
const prevLastId = (prevOp as any)._lastCharId || prevOp.id;
|
|
105
|
+
const currOpAnchor = currOp.value[0]; // anchor is at [0] (can be null)
|
|
106
|
+
const prevOpAnchor = prevOp.value[0]; // anchor is at [0] (can be null)
|
|
103
107
|
const formsChain =
|
|
104
|
-
|
|
105
|
-
(
|
|
108
|
+
currOpAnchor === prevLastId ||
|
|
109
|
+
(prevOpAnchor === currOpAnchor && prevOpAnchor !== null);
|
|
106
110
|
|
|
107
111
|
if (sameAgent && sequentialIds && withinThreshold && formsChain) {
|
|
108
112
|
// Merge operations
|
|
113
|
+
const currContent = currOp.value[1]; // content is at [1]
|
|
109
114
|
const mergedOp: InsertOp = {
|
|
110
|
-
|
|
115
|
+
ot: 'insert',
|
|
111
116
|
id: prevOp.id, // Keep first ID (anchor point)
|
|
112
|
-
|
|
113
|
-
parentId: prevOp.parentId, // Keep first parentId
|
|
117
|
+
value: [prevOpAnchor, prevContent + currContent], // [anchor, merged content]
|
|
114
118
|
seq: Math.max(prevOp.seq, currOp.seq), // Use max Lamport clock for ordering
|
|
115
119
|
ts: prevOp.ts, // Keep first timestamp (when sequence started)
|
|
116
120
|
// Track the last character ID for chain validation in next merge
|
|
@@ -120,16 +124,16 @@ export function coalesceTextOperations(
|
|
|
120
124
|
} else {
|
|
121
125
|
// Debug: Log why merge failed
|
|
122
126
|
if (!sameAgent) console.log('[coalesce] Different agents:', prevId.agent, 'vs', currId.agent);
|
|
123
|
-
if (!sequentialIds) console.log('[coalesce] Non-sequential IDs:', currId.seq, '!==', prevId.seq +
|
|
127
|
+
if (!sequentialIds) console.log('[coalesce] Non-sequential IDs:', currId.seq, '!==', prevId.seq + prevContent.length);
|
|
124
128
|
if (!withinThreshold) console.log('[coalesce] Time threshold exceeded:', timeDiffMs, 'ms >', thresholdMs, 'ms');
|
|
125
|
-
if (!formsChain) console.log('[coalesce] Not a YATA chain:', {
|
|
129
|
+
if (!formsChain) console.log('[coalesce] Not a YATA chain:', { currAnchor: currOpAnchor, prevId: prevOp.id, prevAnchor: prevOpAnchor, currOp: currOp.id });
|
|
126
130
|
|
|
127
131
|
// Can't merge - flush pending and start new
|
|
128
132
|
result.push(pendingInsert);
|
|
129
133
|
pendingInsert = { ...currOp };
|
|
130
134
|
}
|
|
131
135
|
}
|
|
132
|
-
} else if (op.
|
|
136
|
+
} else if (op.ot === 'delete') {
|
|
133
137
|
// Flush any pending insert before handling delete
|
|
134
138
|
if (pendingInsert !== null) {
|
|
135
139
|
result.push(pendingInsert);
|
|
@@ -138,7 +142,7 @@ export function coalesceTextOperations(
|
|
|
138
142
|
|
|
139
143
|
if (pendingDelete === null) {
|
|
140
144
|
// Start new pending delete
|
|
141
|
-
pendingDelete = { ...op,
|
|
145
|
+
pendingDelete = { ...op, rm: [...op.rm] };
|
|
142
146
|
} else {
|
|
143
147
|
// Try to merge consecutive delete operations
|
|
144
148
|
// Deletes can be merged if they're within the time threshold
|
|
@@ -147,11 +151,11 @@ export function coalesceTextOperations(
|
|
|
147
151
|
|
|
148
152
|
if (canMerge) {
|
|
149
153
|
// Merge by combining deletions
|
|
150
|
-
pendingDelete.
|
|
154
|
+
pendingDelete.rm.push(...op.rm);
|
|
151
155
|
} else {
|
|
152
156
|
// Can't merge - flush pending and start new
|
|
153
157
|
result.push(pendingDelete);
|
|
154
|
-
pendingDelete = { ...op,
|
|
158
|
+
pendingDelete = { ...op, rm: [...op.rm] };
|
|
155
159
|
}
|
|
156
160
|
}
|
|
157
161
|
} else {
|
|
@@ -162,7 +166,7 @@ export function coalesceTextOperations(
|
|
|
162
166
|
}
|
|
163
167
|
if (pendingDelete !== null) {
|
|
164
168
|
// Sort and optimize deletions array before flushing
|
|
165
|
-
pendingDelete.
|
|
169
|
+
pendingDelete.rm = sortAndOptimizeDeletions(pendingDelete.rm);
|
|
166
170
|
result.push(pendingDelete);
|
|
167
171
|
pendingDelete = null;
|
|
168
172
|
}
|
|
@@ -176,7 +180,7 @@ export function coalesceTextOperations(
|
|
|
176
180
|
}
|
|
177
181
|
if (pendingDelete !== null) {
|
|
178
182
|
// Sort and optimize deletions array before flushing
|
|
179
|
-
pendingDelete.
|
|
183
|
+
pendingDelete.rm = sortAndOptimizeDeletions(pendingDelete.rm);
|
|
180
184
|
result.push(pendingDelete);
|
|
181
185
|
}
|
|
182
186
|
|
|
@@ -41,8 +41,8 @@ export type LWWSetOp =
|
|
|
41
41
|
*/
|
|
42
42
|
export function isLWWSetOp(op: Operation): op is LWWSetOp {
|
|
43
43
|
return (
|
|
44
|
-
typeof op.
|
|
45
|
-
op.
|
|
44
|
+
typeof op.ot === 'string' &&
|
|
45
|
+
op.ot.endsWith('.set') &&
|
|
46
46
|
(op as any).value !== undefined
|
|
47
47
|
);
|
|
48
48
|
}
|
|
@@ -82,7 +82,7 @@ export function coalesceLWWSets(
|
|
|
82
82
|
const sameTarget =
|
|
83
83
|
pending.key === op.key &&
|
|
84
84
|
pending.path === op.path &&
|
|
85
|
-
pending.
|
|
85
|
+
pending.ot === op.ot;
|
|
86
86
|
|
|
87
87
|
if (sameTarget) {
|
|
88
88
|
// LWW: Replace with newer value
|
|
@@ -16,7 +16,7 @@ export type { NumberAddOp };
|
|
|
16
16
|
*/
|
|
17
17
|
export function isNumberAddOp(op: Operation): op is NumberAddOp {
|
|
18
18
|
return (
|
|
19
|
-
op.
|
|
19
|
+
op.ot === 'number.add' &&
|
|
20
20
|
typeof (op as any).value === 'number'
|
|
21
21
|
);
|
|
22
22
|
}
|
|
@@ -58,7 +58,7 @@ export function coalesceNumberAdds(
|
|
|
58
58
|
if (sameTarget) {
|
|
59
59
|
// Merge operations by summing values
|
|
60
60
|
pending = {
|
|
61
|
-
|
|
61
|
+
ot: 'number.add',
|
|
62
62
|
key: pending.key,
|
|
63
63
|
path: pending.path,
|
|
64
64
|
value: pending.value + op.value,
|
|
@@ -5,12 +5,13 @@
|
|
|
5
5
|
* Allows extensible operation coalescence with a consistent interface.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type { Operation } from '../../operations/OperationTypes.js';
|
|
8
|
+
import type { Operation, TextInsertOp as GraphTextInsert, TextDeleteOp as GraphTextDelete, TextReplaceOp } from '../../operations/OperationTypes.js';
|
|
9
9
|
import { coalesceTextInserts, isTextInsertOp, type TextInsertOp } from './textInserts.js';
|
|
10
10
|
import { coalesceTextDeletes, isTextDeleteOp, type TextDeleteOp } from './textDeletes.js';
|
|
11
11
|
import { coalesceNumberAdds, isNumberAddOp } from './numberOperations.js';
|
|
12
12
|
import { coalesceVector3Adds, isVector3AddOp } from './vector3Operations.js';
|
|
13
13
|
import { coalesceLWWSets, isLWWSetOp } from './lwwOperations.js';
|
|
14
|
+
import { parseItemId } from '../../crdt/Rope.js';
|
|
14
15
|
|
|
15
16
|
export interface CoalesceOptions {
|
|
16
17
|
/** Time threshold in milliseconds (default: 1000ms = 1 second) */
|
|
@@ -47,11 +48,11 @@ const registry = new Map<string, RegistryEntry>();
|
|
|
47
48
|
* Register a coalescence handler for an operation type
|
|
48
49
|
*/
|
|
49
50
|
export function registerCoalescer<T extends Operation>(
|
|
50
|
-
|
|
51
|
+
ot: string,
|
|
51
52
|
guard: TypeGuard<T>,
|
|
52
53
|
handler: CoalesceHandler<T>
|
|
53
54
|
): void {
|
|
54
|
-
registry.set(
|
|
55
|
+
registry.set(ot, {
|
|
55
56
|
guard: guard as unknown as TypeGuard,
|
|
56
57
|
handler: handler as unknown as CoalesceHandler
|
|
57
58
|
});
|
|
@@ -60,15 +61,15 @@ export function registerCoalescer<T extends Operation>(
|
|
|
60
61
|
/**
|
|
61
62
|
* Get coalescence handler for an operation type
|
|
62
63
|
*/
|
|
63
|
-
export function getCoalescer(
|
|
64
|
-
return registry.get(
|
|
64
|
+
export function getCoalescer(ot: string): RegistryEntry | undefined {
|
|
65
|
+
return registry.get(ot);
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
/**
|
|
68
69
|
* Check if an operation type has a registered coalescer
|
|
69
70
|
*/
|
|
70
|
-
export function hasCoalescer(
|
|
71
|
-
return registry.has(
|
|
71
|
+
export function hasCoalescer(ot: string): boolean {
|
|
72
|
+
return registry.has(ot);
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
/**
|
|
@@ -89,11 +90,11 @@ export function coalesceOperations(
|
|
|
89
90
|
let currentType: string | null = null;
|
|
90
91
|
|
|
91
92
|
for (const op of ops) {
|
|
92
|
-
const entry = registry.get(op.
|
|
93
|
+
const entry = registry.get(op.ot);
|
|
93
94
|
|
|
94
95
|
if (entry && entry.guard(op)) {
|
|
95
96
|
// Operation has a coalescer
|
|
96
|
-
if (currentType === op.
|
|
97
|
+
if (currentType === op.ot) {
|
|
97
98
|
// Same type - add to pending group
|
|
98
99
|
pendingGroup.push(op);
|
|
99
100
|
} else {
|
|
@@ -105,7 +106,7 @@ export function coalesceOperations(
|
|
|
105
106
|
result.push(...coalesced);
|
|
106
107
|
}
|
|
107
108
|
}
|
|
108
|
-
currentType = op.
|
|
109
|
+
currentType = op.ot;
|
|
109
110
|
pendingGroup = [op];
|
|
110
111
|
}
|
|
111
112
|
} else {
|
|
@@ -154,6 +155,6 @@ const LWW_SET_OPERATIONS = [
|
|
|
154
155
|
'object.set',
|
|
155
156
|
] as const;
|
|
156
157
|
|
|
157
|
-
for (const
|
|
158
|
-
registerCoalescer(
|
|
158
|
+
for (const ot of LWW_SET_OPERATIONS) {
|
|
159
|
+
registerCoalescer(ot, isLWWSetOp as TypeGuard<any>, coalesceLWWSets as CoalesceHandler<any>);
|
|
159
160
|
}
|