@vuer-ai/vuer-rtc 0.4.1 → 0.5.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/CLAUDE.md +29 -0
- package/COALESCE_FIX_VERIFICATION.md +81 -0
- package/REFACTORING_NOTES.md +229 -0
- package/dist/client/EditBuffer.d.ts +13 -1
- package/dist/client/EditBuffer.d.ts.map +1 -1
- package/dist/client/EditBuffer.js +47 -3
- package/dist/client/EditBuffer.js.map +1 -1
- package/dist/client/actions.d.ts +5 -1
- package/dist/client/actions.d.ts.map +1 -1
- package/dist/client/actions.js +12 -9
- package/dist/client/actions.js.map +1 -1
- package/dist/client/coalesceGraphOps.d.ts +34 -0
- package/dist/client/coalesceGraphOps.d.ts.map +1 -0
- package/dist/client/coalesceGraphOps.js +35 -0
- package/dist/client/coalesceGraphOps.js.map +1 -0
- package/dist/client/coalesceTextOperations.d.ts +42 -0
- package/dist/client/coalesceTextOperations.d.ts.map +1 -0
- package/dist/client/coalesceTextOperations.js +119 -0
- package/dist/client/coalesceTextOperations.js.map +1 -0
- package/dist/client/coalescence/index.d.ts +9 -0
- package/dist/client/coalescence/index.d.ts.map +1 -0
- package/dist/client/coalescence/index.js +9 -0
- package/dist/client/coalescence/index.js.map +1 -0
- package/dist/client/coalescence/registry.d.ts +48 -0
- package/dist/client/coalescence/registry.d.ts.map +1 -0
- package/dist/client/coalescence/registry.js +95 -0
- package/dist/client/coalescence/registry.js.map +1 -0
- package/dist/client/coalescence/textDeletes.d.ts +38 -0
- package/dist/client/coalescence/textDeletes.d.ts.map +1 -0
- package/dist/client/coalescence/textDeletes.js +68 -0
- package/dist/client/coalescence/textDeletes.js.map +1 -0
- package/dist/client/coalescence/textInserts.d.ts +45 -0
- package/dist/client/coalescence/textInserts.d.ts.map +1 -0
- package/dist/client/coalescence/textInserts.js +96 -0
- package/dist/client/coalescence/textInserts.js.map +1 -0
- package/dist/client/createGraph.d.ts.map +1 -1
- package/dist/client/createGraph.js +9 -2
- package/dist/client/createGraph.js.map +1 -1
- package/dist/client/createTextDocument.d.ts.map +1 -1
- package/dist/client/createTextDocument.js +2 -1
- package/dist/client/createTextDocument.js.map +1 -1
- package/dist/client/index.d.ts +4 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +4 -0
- package/dist/client/index.js.map +1 -1
- package/dist/client/textActions.d.ts +1 -1
- package/dist/client/textActions.d.ts.map +1 -1
- package/dist/client/textActions.js +7 -2
- package/dist/client/textActions.js.map +1 -1
- package/dist/client/types.d.ts +3 -0
- package/dist/client/types.d.ts.map +1 -1
- package/dist/crdt/GraphTextCRDT.d.ts +0 -4
- package/dist/crdt/GraphTextCRDT.d.ts.map +1 -1
- package/dist/crdt/GraphTextCRDT.js +3 -0
- package/dist/crdt/GraphTextCRDT.js.map +1 -1
- package/dist/crdt/Rope.d.ts +27 -6
- package/dist/crdt/Rope.d.ts.map +1 -1
- package/dist/crdt/Rope.js +137 -69
- package/dist/crdt/Rope.js.map +1 -1
- package/dist/operations/OperationTypes.d.ts +10 -26
- package/dist/operations/OperationTypes.d.ts.map +1 -1
- package/dist/operations/apply/text.d.ts.map +1 -1
- package/dist/operations/apply/text.js +4 -0
- package/dist/operations/apply/text.js.map +1 -1
- package/examples/05-coalescence-usage.ts +189 -0
- package/package.json +1 -1
- package/src/client/EditBuffer.ts +51 -3
- package/src/client/actions.ts +13 -9
- package/src/client/coalesceGraphOps.ts +40 -0
- package/src/client/coalesceTextOperations.ts +134 -0
- package/src/client/coalescence/index.ts +18 -0
- package/src/client/coalescence/registry.ts +137 -0
- package/src/client/coalescence/textDeletes.ts +94 -0
- package/src/client/coalescence/textInserts.ts +128 -0
- package/src/client/createGraph.ts +11 -2
- package/src/client/createTextDocument.ts +2 -1
- package/src/client/index.ts +14 -0
- package/src/client/textActions.ts +9 -2
- package/src/client/types.ts +4 -1
- package/src/crdt/GraphTextCRDT.ts +0 -5
- package/src/crdt/Rope.ts +155 -79
- package/src/operations/OperationTypes.ts +10 -8
- package/src/operations/apply/text.ts +4 -0
- package/test-coalescence.ts +201 -0
- package/tests/client/actions.test.ts +156 -0
- package/tests/client/coalesce-text-operations.test.ts +327 -0
- package/tests/client/edit-buffer.test.ts +137 -1
- package/tests/crdt/graph-text-crdt.test.ts +29 -17
- package/tests/crdt/rope.test.ts +13 -11
package/src/client/actions.ts
CHANGED
|
@@ -13,8 +13,9 @@ import type { CRDTMessage, Operation, SceneGraph, SceneNode } from '../operation
|
|
|
13
13
|
import type { ClientState, JournalEntry, Snapshot } from './types.js';
|
|
14
14
|
import { applyMessage, applyOperation, createEmptyGraph } from '../operations/dispatcher.js';
|
|
15
15
|
import { VectorClockManager, type VectorClock } from '../state/VectorClock.js';
|
|
16
|
-
import { isAdditiveOp, mergeValues, opDedupKey
|
|
16
|
+
import { isAdditiveOp, mergeValues, opDedupKey } from './EditBuffer.js';
|
|
17
17
|
import { TextRope, snapshot as cloneRope, compact as compactRope } from '../crdt/Rope.js';
|
|
18
|
+
import { coalesceGraphOps } from './coalesceGraphOps.js';
|
|
18
19
|
|
|
19
20
|
const clockManager = new VectorClockManager();
|
|
20
21
|
|
|
@@ -237,15 +238,11 @@ export function onEdit(state: ClientState, op: Operation): ClientState {
|
|
|
237
238
|
|
|
238
239
|
for (const k of new Set(keysToClone)) {
|
|
239
240
|
const orig = graph.nodes[k];
|
|
241
|
+
// Shallow clone preserves TextRope references (they're mutated in place)
|
|
240
242
|
const cloned: SceneNode = {
|
|
241
243
|
...orig,
|
|
242
244
|
children: orig.children ? [...orig.children] : [],
|
|
243
245
|
};
|
|
244
|
-
for (const prop of Object.keys(cloned)) {
|
|
245
|
-
if (cloned[prop] instanceof TextRope) {
|
|
246
|
-
cloned[prop] = cloneRope(cloned[prop] as TextRope);
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
246
|
graph.nodes[k] = cloned;
|
|
250
247
|
}
|
|
251
248
|
|
|
@@ -265,10 +262,15 @@ export function onEdit(state: ClientState, op: Operation): ClientState {
|
|
|
265
262
|
|
|
266
263
|
/**
|
|
267
264
|
* Action: Commit edits
|
|
265
|
+
*
|
|
266
|
+
* @param state - Current client state
|
|
267
|
+
* @param description - Optional message description
|
|
268
|
+
* @param coalescingThresholdMs - Time threshold for operation coalescence (default: no coalescence)
|
|
268
269
|
*/
|
|
269
270
|
export function commitEdits(
|
|
270
271
|
state: ClientState,
|
|
271
|
-
|
|
272
|
+
description?: string,
|
|
273
|
+
coalescingThresholdMs?: number
|
|
272
274
|
): { state: ClientState; msg: CRDTMessage | null } {
|
|
273
275
|
if (state.edits.ops.length === 0) {
|
|
274
276
|
return { state, msg: null };
|
|
@@ -277,8 +279,10 @@ export function commitEdits(
|
|
|
277
279
|
const newClock = clockManager.increment(state.vectorClock, state.sessionId);
|
|
278
280
|
const newLamport = state.lamportTime + 1;
|
|
279
281
|
|
|
280
|
-
// Coalesce
|
|
281
|
-
const coalescedOps =
|
|
282
|
+
// Coalesce CRDT operations before sending (if threshold specified)
|
|
283
|
+
const coalescedOps = coalescingThresholdMs !== undefined
|
|
284
|
+
? coalesceGraphOps(state.edits.ops, { thresholdMs: coalescingThresholdMs })
|
|
285
|
+
: state.edits.ops;
|
|
282
286
|
|
|
283
287
|
const msg: CRDTMessage = {
|
|
284
288
|
id: `${state.sessionId}:${newLamport}`,
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Graph Operation Coalescence
|
|
3
|
+
*
|
|
4
|
+
* Coalesces graph CRDT operations using the coalescence registry.
|
|
5
|
+
* This is the main API for coalescing operations in the graph store.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Operation } from '../operations/OperationTypes.js';
|
|
9
|
+
import { coalesceOperations, type CoalesceOptions } from './coalescence/index.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Coalesce graph operations for network transmission or journal storage.
|
|
13
|
+
*
|
|
14
|
+
* Uses the coalescence registry to merge consecutive operations:
|
|
15
|
+
* - text.insert: Merges consecutive inserts from same agent
|
|
16
|
+
* - text.delete: Merges consecutive deletes on same target
|
|
17
|
+
* - Future: vector3.add, number.add, etc.
|
|
18
|
+
*
|
|
19
|
+
* Example:
|
|
20
|
+
* ```typescript
|
|
21
|
+
* const ops = [
|
|
22
|
+
* { otype: 'text.insert', id: 'alice:1', content: 'h', ... },
|
|
23
|
+
* { otype: 'text.insert', id: 'alice:2', content: 'e', ... },
|
|
24
|
+
* { otype: 'text.insert', id: 'alice:3', content: 'l', ... },
|
|
25
|
+
* ];
|
|
26
|
+
*
|
|
27
|
+
* const coalesced = coalesceGraphOps(ops, { thresholdMs: 300 });
|
|
28
|
+
* // Result: [{ otype: 'text.insert', id: 'alice:1', content: 'hel', ... }]
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* @param ops - Array of graph operations
|
|
32
|
+
* @param options - Coalescence options
|
|
33
|
+
* @returns New array with coalesced operations
|
|
34
|
+
*/
|
|
35
|
+
export function coalesceGraphOps(
|
|
36
|
+
ops: Operation[],
|
|
37
|
+
options: CoalesceOptions = {}
|
|
38
|
+
): Operation[] {
|
|
39
|
+
return coalesceOperations(ops, options);
|
|
40
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coalesce Text Operations
|
|
3
|
+
*
|
|
4
|
+
* Merges consecutive CRDT insert operations to reduce network traffic and storage.
|
|
5
|
+
* Uses the same comparison logic as the graph CRDT: seq → ts → id.
|
|
6
|
+
*
|
|
7
|
+
* Merge conditions:
|
|
8
|
+
* 1. Same agent
|
|
9
|
+
* 2. Sequential IDs (ID seq numbers are consecutive)
|
|
10
|
+
* 3. Within time threshold (ts difference)
|
|
11
|
+
* 4. Compatible YATA structure (forms a chain)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { TextOperation } from './textTypes.js';
|
|
15
|
+
import type { InsertOp } from '../crdt/Rope.js';
|
|
16
|
+
import { parseItemId } from '../crdt/Rope.js';
|
|
17
|
+
|
|
18
|
+
export interface CoalesceOptions {
|
|
19
|
+
/** Time threshold in milliseconds (default: 1000ms = 1 second) */
|
|
20
|
+
thresholdMs?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Coalesce consecutive text insert operations.
|
|
25
|
+
*
|
|
26
|
+
* Example:
|
|
27
|
+
* Input: [insert(id:"alice:1", "h"), insert(id:"alice:2", "e"), insert(id:"alice:3", "l")]
|
|
28
|
+
* Output: [insert(id:"alice:1", "hel")]
|
|
29
|
+
*
|
|
30
|
+
* @param ops - Array of text operations
|
|
31
|
+
* @param options - Coalescence options
|
|
32
|
+
* @returns New array with coalesced operations
|
|
33
|
+
*/
|
|
34
|
+
export function coalesceTextOperations(
|
|
35
|
+
ops: TextOperation[],
|
|
36
|
+
options: CoalesceOptions = {}
|
|
37
|
+
): TextOperation[] {
|
|
38
|
+
const { thresholdMs = 1000 } = options;
|
|
39
|
+
|
|
40
|
+
if (ops.length === 0) return ops;
|
|
41
|
+
|
|
42
|
+
const result: TextOperation[] = [];
|
|
43
|
+
let pendingInsert: { type: 'insert'; op: InsertOp } | null = null;
|
|
44
|
+
|
|
45
|
+
for (const op of ops) {
|
|
46
|
+
if (op.type === 'insert') {
|
|
47
|
+
if (pendingInsert === null) {
|
|
48
|
+
// Start new pending insert
|
|
49
|
+
pendingInsert = { type: 'insert', op: { ...op.op } };
|
|
50
|
+
} else {
|
|
51
|
+
const prevOp = pendingInsert.op;
|
|
52
|
+
const currOp = op.op;
|
|
53
|
+
|
|
54
|
+
// Parse IDs to extract agent and local sequence numbers
|
|
55
|
+
const prevId = parseItemId(prevOp.id);
|
|
56
|
+
const currId = parseItemId(currOp.id);
|
|
57
|
+
|
|
58
|
+
// Check merge conditions
|
|
59
|
+
const sameAgent = prevId.agent === currId.agent;
|
|
60
|
+
|
|
61
|
+
// IDs must be sequential: next ID = prev ID + prev content length
|
|
62
|
+
// Example: prev="alice:5" content="hel"(3 chars) → next="alice:8"
|
|
63
|
+
const sequentialIds = currId.seq === prevId.seq + prevOp.content.length;
|
|
64
|
+
|
|
65
|
+
// Time threshold: operations must be close in time (ts is in seconds)
|
|
66
|
+
const timeDiffMs = (currOp.ts - prevOp.ts) * 1000;
|
|
67
|
+
const withinThreshold = timeDiffMs <= thresholdMs;
|
|
68
|
+
|
|
69
|
+
// YATA structure: check if operations form a valid chain
|
|
70
|
+
// Current op's parent should be the last character ID in the merged content
|
|
71
|
+
// (not the first ID, which is what prevOp.id contains after merging)
|
|
72
|
+
// OR both should have the same parent (inserting at same position)
|
|
73
|
+
const prevLastId = (prevOp as any)._lastCharId || prevOp.id;
|
|
74
|
+
const formsChain =
|
|
75
|
+
currOp.parentId === prevLastId ||
|
|
76
|
+
(prevOp.parentId === currOp.parentId && prevOp.parentId !== null);
|
|
77
|
+
|
|
78
|
+
if (sameAgent && sequentialIds && withinThreshold && formsChain) {
|
|
79
|
+
// Merge operations
|
|
80
|
+
const mergedOp: InsertOp = {
|
|
81
|
+
id: prevOp.id, // Keep first ID (anchor point)
|
|
82
|
+
content: prevOp.content + currOp.content, // Concatenate content
|
|
83
|
+
parentId: prevOp.parentId, // Keep first parentId
|
|
84
|
+
seq: Math.max(prevOp.seq, currOp.seq), // Use max Lamport clock for ordering
|
|
85
|
+
ts: prevOp.ts, // Keep first timestamp (when sequence started)
|
|
86
|
+
// Track the last character ID for chain validation in next merge
|
|
87
|
+
_lastCharId: currOp.id,
|
|
88
|
+
} as any;
|
|
89
|
+
pendingInsert = { type: 'insert', op: mergedOp };
|
|
90
|
+
} else {
|
|
91
|
+
// Debug: Log why merge failed
|
|
92
|
+
if (!sameAgent) console.log('[coalesce] Different agents:', prevId.agent, 'vs', currId.agent);
|
|
93
|
+
if (!sequentialIds) console.log('[coalesce] Non-sequential IDs:', currId.seq, '!==', prevId.seq + prevOp.content.length);
|
|
94
|
+
if (!withinThreshold) console.log('[coalesce] Time threshold exceeded:', timeDiffMs, 'ms >', thresholdMs, 'ms');
|
|
95
|
+
if (!formsChain) console.log('[coalesce] Not a YATA chain:', { currParent: currOp.parentId, prevId: prevOp.id, prevParent: prevOp.parentId, currOp: currOp.id });
|
|
96
|
+
|
|
97
|
+
// Can't merge - flush pending and start new
|
|
98
|
+
result.push(pendingInsert);
|
|
99
|
+
pendingInsert = { type: 'insert', op: { ...currOp } };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
// Delete operation - flush any pending insert
|
|
104
|
+
if (pendingInsert !== null) {
|
|
105
|
+
result.push(pendingInsert);
|
|
106
|
+
pendingInsert = null;
|
|
107
|
+
}
|
|
108
|
+
result.push(op);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Flush any remaining pending insert
|
|
113
|
+
if (pendingInsert !== null) {
|
|
114
|
+
result.push(pendingInsert);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Comparison logic for text operations (matches graph CRDT):
|
|
122
|
+
* 1. Compare by Lamport clock (seq)
|
|
123
|
+
* 2. If equal, compare by wall-clock time (ts)
|
|
124
|
+
* 3. If equal, compare by ID (lexicographic)
|
|
125
|
+
*
|
|
126
|
+
* @param a - First operation
|
|
127
|
+
* @param b - Second operation
|
|
128
|
+
* @returns Negative if a < b, positive if a > b, 0 if equal
|
|
129
|
+
*/
|
|
130
|
+
export function compareTextOps(a: InsertOp, b: InsertOp): number {
|
|
131
|
+
if (a.seq !== b.seq) return a.seq - b.seq; // Lamport clock
|
|
132
|
+
if (a.ts !== b.ts) return a.ts - b.ts; // Wall-clock time
|
|
133
|
+
return a.id.localeCompare(b.id); // Final fallback
|
|
134
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coalescence Utilities
|
|
3
|
+
*
|
|
4
|
+
* Extensible operation coalescence system with registration pattern.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
coalesceOperations,
|
|
9
|
+
registerCoalescer,
|
|
10
|
+
getCoalescer,
|
|
11
|
+
hasCoalescer,
|
|
12
|
+
type CoalesceOptions,
|
|
13
|
+
type CoalesceHandler,
|
|
14
|
+
type TypeGuard,
|
|
15
|
+
} from './registry.js';
|
|
16
|
+
|
|
17
|
+
export { coalesceTextInserts, isTextInsertOp, type TextInsertOp } from './textInserts.js';
|
|
18
|
+
export { coalesceTextDeletes, isTextDeleteOp, type TextDeleteOp } from './textDeletes.js';
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coalescence Registry
|
|
3
|
+
*
|
|
4
|
+
* Maps operation types to their coalescence handlers.
|
|
5
|
+
* Allows extensible operation coalescence with a consistent interface.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Operation } from '../../operations/OperationTypes.js';
|
|
9
|
+
import { coalesceTextInserts, isTextInsertOp, type TextInsertOp } from './textInserts.js';
|
|
10
|
+
import { coalesceTextDeletes, isTextDeleteOp, type TextDeleteOp } from './textDeletes.js';
|
|
11
|
+
|
|
12
|
+
export interface CoalesceOptions {
|
|
13
|
+
/** Time threshold in milliseconds (default: 1000ms = 1 second) */
|
|
14
|
+
thresholdMs?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Coalescence handler function type
|
|
19
|
+
*/
|
|
20
|
+
export type CoalesceHandler<T extends Operation = Operation> = (
|
|
21
|
+
ops: T[],
|
|
22
|
+
options: CoalesceOptions
|
|
23
|
+
) => T[];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Type guard function type
|
|
27
|
+
*/
|
|
28
|
+
export type TypeGuard<T extends Operation = Operation> = (op: Operation) => op is T;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Registry entry combining type guard and handler
|
|
32
|
+
*/
|
|
33
|
+
interface RegistryEntry<T extends Operation = Operation> {
|
|
34
|
+
guard: TypeGuard<T>;
|
|
35
|
+
handler: CoalesceHandler<T>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Global coalescence registry
|
|
40
|
+
*/
|
|
41
|
+
const registry = new Map<string, RegistryEntry>();
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Register a coalescence handler for an operation type
|
|
45
|
+
*/
|
|
46
|
+
export function registerCoalescer<T extends Operation>(
|
|
47
|
+
otype: string,
|
|
48
|
+
guard: TypeGuard<T>,
|
|
49
|
+
handler: CoalesceHandler<T>
|
|
50
|
+
): void {
|
|
51
|
+
registry.set(otype, {
|
|
52
|
+
guard: guard as unknown as TypeGuard,
|
|
53
|
+
handler: handler as unknown as CoalesceHandler
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get coalescence handler for an operation type
|
|
59
|
+
*/
|
|
60
|
+
export function getCoalescer(otype: string): RegistryEntry | undefined {
|
|
61
|
+
return registry.get(otype);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Check if an operation type has a registered coalescer
|
|
66
|
+
*/
|
|
67
|
+
export function hasCoalescer(otype: string): boolean {
|
|
68
|
+
return registry.has(otype);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Coalesce operations by grouping by type and applying registered handlers
|
|
73
|
+
*
|
|
74
|
+
* @param ops - Array of operations
|
|
75
|
+
* @param options - Coalescence options
|
|
76
|
+
* @returns New array with coalesced operations
|
|
77
|
+
*/
|
|
78
|
+
export function coalesceOperations(
|
|
79
|
+
ops: Operation[],
|
|
80
|
+
options: CoalesceOptions = {}
|
|
81
|
+
): Operation[] {
|
|
82
|
+
if (ops.length === 0) return ops;
|
|
83
|
+
|
|
84
|
+
const result: Operation[] = [];
|
|
85
|
+
let pendingGroup: Operation[] = [];
|
|
86
|
+
let currentType: string | null = null;
|
|
87
|
+
|
|
88
|
+
for (const op of ops) {
|
|
89
|
+
const entry = registry.get(op.otype);
|
|
90
|
+
|
|
91
|
+
if (entry && entry.guard(op)) {
|
|
92
|
+
// Operation has a coalescer
|
|
93
|
+
if (currentType === op.otype) {
|
|
94
|
+
// Same type - add to pending group
|
|
95
|
+
pendingGroup.push(op);
|
|
96
|
+
} else {
|
|
97
|
+
// Different type - flush pending and start new group
|
|
98
|
+
if (currentType !== null && pendingGroup.length > 0) {
|
|
99
|
+
const prevEntry = registry.get(currentType);
|
|
100
|
+
if (prevEntry) {
|
|
101
|
+
const coalesced = prevEntry.handler(pendingGroup as any, options);
|
|
102
|
+
result.push(...coalesced);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
currentType = op.otype;
|
|
106
|
+
pendingGroup = [op];
|
|
107
|
+
}
|
|
108
|
+
} else {
|
|
109
|
+
// No coalescer - flush pending and pass through
|
|
110
|
+
if (currentType !== null && pendingGroup.length > 0) {
|
|
111
|
+
const prevEntry = registry.get(currentType);
|
|
112
|
+
if (prevEntry) {
|
|
113
|
+
const coalesced = prevEntry.handler(pendingGroup as any, options);
|
|
114
|
+
result.push(...coalesced);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
currentType = null;
|
|
118
|
+
pendingGroup = [];
|
|
119
|
+
result.push(op);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Flush any remaining pending group
|
|
124
|
+
if (currentType !== null && pendingGroup.length > 0) {
|
|
125
|
+
const entry = registry.get(currentType);
|
|
126
|
+
if (entry) {
|
|
127
|
+
const coalesced = entry.handler(pendingGroup as any, options);
|
|
128
|
+
result.push(...coalesced);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Register built-in coalescers
|
|
136
|
+
registerCoalescer('text.insert', isTextInsertOp as TypeGuard<any>, coalesceTextInserts as CoalesceHandler<any>);
|
|
137
|
+
registerCoalescer('text.delete', isTextDeleteOp as TypeGuard<any>, coalesceTextDeletes as CoalesceHandler<any>);
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text Delete Coalescence Utility
|
|
3
|
+
*
|
|
4
|
+
* Merges consecutive text delete operations.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Operation } from '../../operations/OperationTypes.js';
|
|
8
|
+
|
|
9
|
+
export interface TextDeleteOp {
|
|
10
|
+
otype: 'text.delete';
|
|
11
|
+
key: string;
|
|
12
|
+
path: string;
|
|
13
|
+
deletions: Array<{ id: string; length: number }>; // Array of deletion ranges
|
|
14
|
+
seq: number;
|
|
15
|
+
ts: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CoalesceOptions {
|
|
19
|
+
/** Time threshold in milliseconds (default: 1000ms = 1 second) */
|
|
20
|
+
thresholdMs?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Check if an operation is a text delete with CRDT metadata
|
|
25
|
+
*/
|
|
26
|
+
export function isTextDeleteOp(op: Operation): op is TextDeleteOp {
|
|
27
|
+
return (
|
|
28
|
+
op.otype === 'text.delete' &&
|
|
29
|
+
Array.isArray((op as any).deletions) &&
|
|
30
|
+
typeof (op as any).seq === 'number' &&
|
|
31
|
+
typeof (op as any).ts === 'number'
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Coalesce consecutive text delete operations.
|
|
37
|
+
*
|
|
38
|
+
* Example:
|
|
39
|
+
* Input: [delete(["alice:5"]), delete(["alice:6", "alice:7"])]
|
|
40
|
+
* Output: [delete(["alice:5", "alice:6", "alice:7"])]
|
|
41
|
+
*
|
|
42
|
+
* @param ops - Array of text delete operations
|
|
43
|
+
* @param options - Coalescence options
|
|
44
|
+
* @returns New array with coalesced operations
|
|
45
|
+
*/
|
|
46
|
+
export function coalesceTextDeletes(
|
|
47
|
+
ops: TextDeleteOp[],
|
|
48
|
+
options: CoalesceOptions = {}
|
|
49
|
+
): TextDeleteOp[] {
|
|
50
|
+
const { thresholdMs = 1000 } = options;
|
|
51
|
+
|
|
52
|
+
if (ops.length === 0) return ops;
|
|
53
|
+
|
|
54
|
+
const result: TextDeleteOp[] = [];
|
|
55
|
+
let pending: TextDeleteOp | null = null;
|
|
56
|
+
|
|
57
|
+
for (const op of ops) {
|
|
58
|
+
if (pending === null) {
|
|
59
|
+
// Start new pending delete
|
|
60
|
+
pending = { ...op, deletions: [...op.deletions] };
|
|
61
|
+
} else {
|
|
62
|
+
// Check merge conditions
|
|
63
|
+
const sameTarget = pending.key === op.key && pending.path === op.path;
|
|
64
|
+
|
|
65
|
+
// Time threshold: operations must be close in time (ts is in seconds)
|
|
66
|
+
const timeDiffMs = (op.ts - pending.ts) * 1000;
|
|
67
|
+
const withinThreshold = timeDiffMs <= thresholdMs;
|
|
68
|
+
|
|
69
|
+
if (sameTarget && withinThreshold) {
|
|
70
|
+
// Merge operations - combine deletion lists
|
|
71
|
+
const merged: TextDeleteOp = {
|
|
72
|
+
otype: 'text.delete',
|
|
73
|
+
key: pending.key,
|
|
74
|
+
path: pending.path,
|
|
75
|
+
deletions: [...pending.deletions, ...op.deletions],
|
|
76
|
+
seq: Math.max(pending.seq, op.seq), // Use max Lamport clock for ordering
|
|
77
|
+
ts: pending.ts, // Keep first timestamp (when sequence started)
|
|
78
|
+
};
|
|
79
|
+
pending = merged;
|
|
80
|
+
} else {
|
|
81
|
+
// Can't merge - flush pending and start new
|
|
82
|
+
result.push(pending);
|
|
83
|
+
pending = { ...op, deletions: [...op.deletions] };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Flush any remaining pending delete
|
|
89
|
+
if (pending !== null) {
|
|
90
|
+
result.push(pending);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text Insert Coalescence Utility
|
|
3
|
+
*
|
|
4
|
+
* Merges consecutive text insert operations to reduce network traffic.
|
|
5
|
+
* Uses CRDT comparison logic: seq → ts → id.
|
|
6
|
+
*
|
|
7
|
+
* Merge conditions:
|
|
8
|
+
* 1. Same agent (from ID)
|
|
9
|
+
* 2. Same target (key + path)
|
|
10
|
+
* 3. Sequential IDs (ID seq numbers are consecutive)
|
|
11
|
+
* 4. Within time threshold (ts difference)
|
|
12
|
+
* 5. Compatible YATA structure (forms a chain)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Operation } from '../../operations/OperationTypes.js';
|
|
16
|
+
import { parseItemId } from '../../crdt/Rope.js';
|
|
17
|
+
|
|
18
|
+
export interface TextInsertOp {
|
|
19
|
+
otype: 'text.insert';
|
|
20
|
+
key: string;
|
|
21
|
+
path: string;
|
|
22
|
+
id: string;
|
|
23
|
+
content: string;
|
|
24
|
+
parentId: string | null;
|
|
25
|
+
seq: number;
|
|
26
|
+
ts: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface CoalesceOptions {
|
|
30
|
+
/** Time threshold in milliseconds (default: 1000ms = 1 second) */
|
|
31
|
+
thresholdMs?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Check if an operation is a text insert with CRDT metadata
|
|
36
|
+
*/
|
|
37
|
+
export function isTextInsertOp(op: Operation): op is TextInsertOp {
|
|
38
|
+
return (
|
|
39
|
+
op.otype === 'text.insert' &&
|
|
40
|
+
typeof (op as any).id === 'string' &&
|
|
41
|
+
typeof (op as any).content === 'string' &&
|
|
42
|
+
typeof (op as any).seq === 'number' &&
|
|
43
|
+
typeof (op as any).ts === 'number'
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Coalesce consecutive text insert operations.
|
|
49
|
+
*
|
|
50
|
+
* Example:
|
|
51
|
+
* Input: [insert(id:"alice:1", "h"), insert(id:"alice:2", "e"), insert(id:"alice:3", "l")]
|
|
52
|
+
* Output: [insert(id:"alice:1", "hel")]
|
|
53
|
+
*
|
|
54
|
+
* @param ops - Array of text insert operations
|
|
55
|
+
* @param options - Coalescence options
|
|
56
|
+
* @returns New array with coalesced operations
|
|
57
|
+
*/
|
|
58
|
+
export function coalesceTextInserts(
|
|
59
|
+
ops: TextInsertOp[],
|
|
60
|
+
options: CoalesceOptions = {}
|
|
61
|
+
): TextInsertOp[] {
|
|
62
|
+
const { thresholdMs = 1000 } = options;
|
|
63
|
+
|
|
64
|
+
if (ops.length === 0) return ops;
|
|
65
|
+
|
|
66
|
+
const result: TextInsertOp[] = [];
|
|
67
|
+
let pending: TextInsertOp | null = null;
|
|
68
|
+
|
|
69
|
+
for (const op of ops) {
|
|
70
|
+
if (pending === null) {
|
|
71
|
+
// Start new pending insert
|
|
72
|
+
pending = { ...op };
|
|
73
|
+
} else {
|
|
74
|
+
// Parse IDs to extract agent and local sequence numbers
|
|
75
|
+
const prevId = parseItemId(pending.id);
|
|
76
|
+
const currId = parseItemId(op.id);
|
|
77
|
+
|
|
78
|
+
// Check merge conditions
|
|
79
|
+
const sameAgent = prevId.agent === currId.agent;
|
|
80
|
+
const sameTarget = pending.key === op.key && pending.path === op.path;
|
|
81
|
+
|
|
82
|
+
// IDs must be sequential: next ID = prev ID + prev content length
|
|
83
|
+
// Example: prev="alice:5" content="hel"(3 chars) → next="alice:8"
|
|
84
|
+
const sequentialIds = currId.seq === prevId.seq + pending.content.length;
|
|
85
|
+
|
|
86
|
+
// Time threshold: operations must be close in time (ts is in seconds)
|
|
87
|
+
const timeDiffMs = (op.ts - pending.ts) * 1000;
|
|
88
|
+
const withinThreshold = timeDiffMs <= thresholdMs;
|
|
89
|
+
|
|
90
|
+
// YATA structure: check if operations form a valid chain
|
|
91
|
+
// Current op's parent should be the last character ID in the merged content
|
|
92
|
+
// (not the first ID, which is what pending.id contains after merging)
|
|
93
|
+
// OR both should have the same parent (inserting at same position)
|
|
94
|
+
const prevLastId = (pending as any)._lastCharId || pending.id;
|
|
95
|
+
const formsChain =
|
|
96
|
+
op.parentId === prevLastId ||
|
|
97
|
+
(pending.parentId === op.parentId && pending.parentId !== null);
|
|
98
|
+
|
|
99
|
+
if (sameAgent && sameTarget && sequentialIds && withinThreshold && formsChain) {
|
|
100
|
+
// Merge operations
|
|
101
|
+
const merged: TextInsertOp = {
|
|
102
|
+
otype: 'text.insert',
|
|
103
|
+
key: pending.key,
|
|
104
|
+
path: pending.path,
|
|
105
|
+
id: pending.id, // Keep first ID (anchor point)
|
|
106
|
+
content: pending.content + op.content, // Concatenate content
|
|
107
|
+
parentId: pending.parentId, // Keep first parentId
|
|
108
|
+
seq: Math.max(pending.seq, op.seq), // Use max Lamport clock for ordering
|
|
109
|
+
ts: pending.ts, // Keep first timestamp (when sequence started)
|
|
110
|
+
// Track the last character ID for chain validation in next merge
|
|
111
|
+
_lastCharId: op.id,
|
|
112
|
+
} as any;
|
|
113
|
+
pending = merged;
|
|
114
|
+
} else {
|
|
115
|
+
// Can't merge - flush pending and start new
|
|
116
|
+
result.push(pending);
|
|
117
|
+
pending = { ...op };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Flush any remaining pending insert
|
|
123
|
+
if (pending !== null) {
|
|
124
|
+
result.push(pending);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
@@ -40,6 +40,7 @@ export function createGraph(options: CreateGraphOptions): GraphStore {
|
|
|
40
40
|
let coalescingTimer: ReturnType<typeof setTimeout> | null = null;
|
|
41
41
|
let coalescingEnabled = options.coalescingEnabled ?? false;
|
|
42
42
|
let coalescingDelayMs = options.coalescingDelayMs ?? 300;
|
|
43
|
+
let coalescingThresholdMs = options.coalescingThresholdMs ?? 1000;
|
|
43
44
|
|
|
44
45
|
function dispatch(fn: (s: ClientState) => ClientState): void {
|
|
45
46
|
state = fn(state);
|
|
@@ -59,7 +60,7 @@ export function createGraph(options: CreateGraphOptions): GraphStore {
|
|
|
59
60
|
coalescingTimer = setTimeout(() => {
|
|
60
61
|
coalescingTimer = null;
|
|
61
62
|
if (state.edits.ops.length > 0) {
|
|
62
|
-
const result = commitEdits(state, 'coalesced commit');
|
|
63
|
+
const result = commitEdits(state, 'coalesced commit', coalescingThresholdMs);
|
|
63
64
|
dispatch(() => result.state);
|
|
64
65
|
if (result.msg) {
|
|
65
66
|
options.onSend?.(result.msg);
|
|
@@ -92,7 +93,9 @@ export function createGraph(options: CreateGraphOptions): GraphStore {
|
|
|
92
93
|
// Clear pending coalescing timer when explicitly committing
|
|
93
94
|
clearCoalescingTimer();
|
|
94
95
|
|
|
95
|
-
|
|
96
|
+
// Pass threshold if coalescence is enabled
|
|
97
|
+
const threshold = coalescingEnabled ? coalescingThresholdMs : undefined;
|
|
98
|
+
const result = commitEdits(state, _description, threshold);
|
|
96
99
|
dispatch(() => result.state);
|
|
97
100
|
if (result.msg) {
|
|
98
101
|
options.onSend?.(result.msg);
|
|
@@ -113,10 +116,16 @@ export function createGraph(options: CreateGraphOptions): GraphStore {
|
|
|
113
116
|
coalescingDelayMs = delayMs;
|
|
114
117
|
},
|
|
115
118
|
|
|
119
|
+
setCoalescingThreshold: (thresholdMs: number) => {
|
|
120
|
+
coalescingThresholdMs = thresholdMs;
|
|
121
|
+
},
|
|
122
|
+
|
|
116
123
|
getCoalescingEnabled: () => coalescingEnabled,
|
|
117
124
|
|
|
118
125
|
getCoalescingDelay: () => coalescingDelayMs,
|
|
119
126
|
|
|
127
|
+
getCoalescingThreshold: () => coalescingThresholdMs,
|
|
128
|
+
|
|
120
129
|
// Server communication
|
|
121
130
|
receive: (msg: CRDTMessage) => {
|
|
122
131
|
dispatch(s => onRemoteMessage(s, msg));
|
|
@@ -62,7 +62,8 @@ export function createTextDocument(options: CreateTextDocumentOptions): TextDocu
|
|
|
62
62
|
coalescingTimer = setTimeout(() => {
|
|
63
63
|
coalescingTimer = null;
|
|
64
64
|
if (state.edits.ops.length > 0) {
|
|
65
|
-
|
|
65
|
+
// Pass coalescingDelayMs as threshold for operation merging
|
|
66
|
+
const result = commitTextEdits(state, 'coalesced commit', coalescingDelayMs);
|
|
66
67
|
dispatch(() => result.state);
|
|
67
68
|
if (result.msg) {
|
|
68
69
|
options.onSend?.(result.msg);
|
package/src/client/index.ts
CHANGED
|
@@ -39,6 +39,17 @@ export {
|
|
|
39
39
|
// Edit buffer utilities
|
|
40
40
|
export { isAdditiveOp, mergeValues, EditBufferImpl, coalesceTextOps } from './EditBuffer.js';
|
|
41
41
|
|
|
42
|
+
// Coalescence utilities
|
|
43
|
+
export { coalesceGraphOps } from './coalesceGraphOps.js';
|
|
44
|
+
export {
|
|
45
|
+
coalesceOperations,
|
|
46
|
+
registerCoalescer,
|
|
47
|
+
coalesceTextInserts,
|
|
48
|
+
coalesceTextDeletes,
|
|
49
|
+
type CoalesceHandler,
|
|
50
|
+
type TypeGuard,
|
|
51
|
+
} from './coalescence/index.js';
|
|
52
|
+
|
|
42
53
|
// Sound utilities
|
|
43
54
|
export { playWhoosh, createMessageSentSound } from './sounds.js';
|
|
44
55
|
|
|
@@ -80,3 +91,6 @@ export {
|
|
|
80
91
|
redoText,
|
|
81
92
|
initTextFromServer,
|
|
82
93
|
} from './textActions.js';
|
|
94
|
+
|
|
95
|
+
export { coalesceTextOperations, compareTextOps } from './coalesceTextOperations.js';
|
|
96
|
+
export type { CoalesceOptions } from './coalesceTextOperations.js';
|