@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.
Files changed (89) hide show
  1. package/CLAUDE.md +29 -0
  2. package/COALESCE_FIX_VERIFICATION.md +81 -0
  3. package/REFACTORING_NOTES.md +229 -0
  4. package/dist/client/EditBuffer.d.ts +13 -1
  5. package/dist/client/EditBuffer.d.ts.map +1 -1
  6. package/dist/client/EditBuffer.js +47 -3
  7. package/dist/client/EditBuffer.js.map +1 -1
  8. package/dist/client/actions.d.ts +5 -1
  9. package/dist/client/actions.d.ts.map +1 -1
  10. package/dist/client/actions.js +12 -9
  11. package/dist/client/actions.js.map +1 -1
  12. package/dist/client/coalesceGraphOps.d.ts +34 -0
  13. package/dist/client/coalesceGraphOps.d.ts.map +1 -0
  14. package/dist/client/coalesceGraphOps.js +35 -0
  15. package/dist/client/coalesceGraphOps.js.map +1 -0
  16. package/dist/client/coalesceTextOperations.d.ts +42 -0
  17. package/dist/client/coalesceTextOperations.d.ts.map +1 -0
  18. package/dist/client/coalesceTextOperations.js +119 -0
  19. package/dist/client/coalesceTextOperations.js.map +1 -0
  20. package/dist/client/coalescence/index.d.ts +9 -0
  21. package/dist/client/coalescence/index.d.ts.map +1 -0
  22. package/dist/client/coalescence/index.js +9 -0
  23. package/dist/client/coalescence/index.js.map +1 -0
  24. package/dist/client/coalescence/registry.d.ts +48 -0
  25. package/dist/client/coalescence/registry.d.ts.map +1 -0
  26. package/dist/client/coalescence/registry.js +95 -0
  27. package/dist/client/coalescence/registry.js.map +1 -0
  28. package/dist/client/coalescence/textDeletes.d.ts +38 -0
  29. package/dist/client/coalescence/textDeletes.d.ts.map +1 -0
  30. package/dist/client/coalescence/textDeletes.js +68 -0
  31. package/dist/client/coalescence/textDeletes.js.map +1 -0
  32. package/dist/client/coalescence/textInserts.d.ts +45 -0
  33. package/dist/client/coalescence/textInserts.d.ts.map +1 -0
  34. package/dist/client/coalescence/textInserts.js +96 -0
  35. package/dist/client/coalescence/textInserts.js.map +1 -0
  36. package/dist/client/createGraph.d.ts.map +1 -1
  37. package/dist/client/createGraph.js +9 -2
  38. package/dist/client/createGraph.js.map +1 -1
  39. package/dist/client/createTextDocument.d.ts.map +1 -1
  40. package/dist/client/createTextDocument.js +2 -1
  41. package/dist/client/createTextDocument.js.map +1 -1
  42. package/dist/client/index.d.ts +4 -0
  43. package/dist/client/index.d.ts.map +1 -1
  44. package/dist/client/index.js +4 -0
  45. package/dist/client/index.js.map +1 -1
  46. package/dist/client/textActions.d.ts +1 -1
  47. package/dist/client/textActions.d.ts.map +1 -1
  48. package/dist/client/textActions.js +7 -2
  49. package/dist/client/textActions.js.map +1 -1
  50. package/dist/client/types.d.ts +3 -0
  51. package/dist/client/types.d.ts.map +1 -1
  52. package/dist/crdt/GraphTextCRDT.d.ts +0 -4
  53. package/dist/crdt/GraphTextCRDT.d.ts.map +1 -1
  54. package/dist/crdt/GraphTextCRDT.js +3 -0
  55. package/dist/crdt/GraphTextCRDT.js.map +1 -1
  56. package/dist/crdt/Rope.d.ts +27 -6
  57. package/dist/crdt/Rope.d.ts.map +1 -1
  58. package/dist/crdt/Rope.js +137 -69
  59. package/dist/crdt/Rope.js.map +1 -1
  60. package/dist/operations/OperationTypes.d.ts +10 -26
  61. package/dist/operations/OperationTypes.d.ts.map +1 -1
  62. package/dist/operations/apply/text.d.ts.map +1 -1
  63. package/dist/operations/apply/text.js +4 -0
  64. package/dist/operations/apply/text.js.map +1 -1
  65. package/examples/05-coalescence-usage.ts +189 -0
  66. package/package.json +1 -1
  67. package/src/client/EditBuffer.ts +51 -3
  68. package/src/client/actions.ts +13 -9
  69. package/src/client/coalesceGraphOps.ts +40 -0
  70. package/src/client/coalesceTextOperations.ts +134 -0
  71. package/src/client/coalescence/index.ts +18 -0
  72. package/src/client/coalescence/registry.ts +137 -0
  73. package/src/client/coalescence/textDeletes.ts +94 -0
  74. package/src/client/coalescence/textInserts.ts +128 -0
  75. package/src/client/createGraph.ts +11 -2
  76. package/src/client/createTextDocument.ts +2 -1
  77. package/src/client/index.ts +14 -0
  78. package/src/client/textActions.ts +9 -2
  79. package/src/client/types.ts +4 -1
  80. package/src/crdt/GraphTextCRDT.ts +0 -5
  81. package/src/crdt/Rope.ts +155 -79
  82. package/src/operations/OperationTypes.ts +10 -8
  83. package/src/operations/apply/text.ts +4 -0
  84. package/test-coalescence.ts +201 -0
  85. package/tests/client/actions.test.ts +156 -0
  86. package/tests/client/coalesce-text-operations.test.ts +327 -0
  87. package/tests/client/edit-buffer.test.ts +137 -1
  88. package/tests/crdt/graph-text-crdt.test.ts +29 -17
  89. package/tests/crdt/rope.test.ts +13 -11
@@ -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, coalesceTextOps } from './EditBuffer.js';
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
- _description?: string
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 consecutive text operations before sending
281
- const coalescedOps = coalesceTextOps(state.edits.ops);
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
- const result = commitEdits(state, _description);
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
- const result = commitTextEdits(state, 'coalesced commit');
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);
@@ -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';