@vuer-ai/vuer-rtc 0.4.2 → 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 +8 -16
  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 +8 -20
  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
@@ -81,7 +81,7 @@ export function isPositionTextDeleteOp(op: Operation): op is PositionTextDeleteO
81
81
  }
82
82
 
83
83
  /**
84
- * Coalesce consecutive text operations in an operation array.
84
+ * Coalesce consecutive text operations (position-based format only).
85
85
  * Merges sequential text.insert and text.delete operations.
86
86
  *
87
87
  * Insert: Consecutive inserts at adjacent positions
@@ -93,6 +93,18 @@ export function isPositionTextDeleteOp(op: Operation): op is PositionTextDeleteO
93
93
  * - Backward (Backspace): Position moves left by length
94
94
  * del(10, 1), del(9, 1) → del(9, 2)
95
95
  *
96
+ * IMPORTANT: This function is for POSITION-BASED coalescence only.
97
+ * Operations with CRDT metadata (id, parentId, seq) are skipped and passed through unchanged.
98
+ * For CRDT-level coalescence, use coalesceGraphOps() instead.
99
+ *
100
+ * Why the distinction?
101
+ * - Position-based: Coalesce before CRDT conversion (edit buffer stage)
102
+ * - CRDT-level: Coalesce after CRDT conversion (journal/network stage)
103
+ *
104
+ * Currently, the graph store flow applies CRDT metadata during onEdit(),
105
+ * so this function is not used for graph text operations.
106
+ * See coalesceGraphOps() for CRDT-level coalescence.
107
+ *
96
108
  * Returns a new array with coalesced operations.
97
109
  */
98
110
  export function coalesceTextOps(ops: Operation[]): Operation[] {
@@ -104,13 +116,31 @@ export function coalesceTextOps(ops: Operation[]): Operation[] {
104
116
 
105
117
  for (const op of ops) {
106
118
  if (isPositionTextInsertOp(op)) {
119
+ // Check if operation has CRDT metadata - if so, don't coalesce it
120
+ const hasCRDTMetadata = (op as any).id !== undefined;
121
+
122
+ if (hasCRDTMetadata) {
123
+ // Flush any pending operations
124
+ if (pendingInsert !== null) {
125
+ result.push(pendingInsert as unknown as Operation);
126
+ pendingInsert = null;
127
+ }
128
+ if (pendingDelete !== null) {
129
+ result.push(pendingDelete as unknown as Operation);
130
+ pendingDelete = null;
131
+ }
132
+ // Pass through operations with CRDT metadata unchanged
133
+ result.push(op as unknown as Operation);
134
+ continue;
135
+ }
136
+
107
137
  // Flush any pending delete
108
138
  if (pendingDelete !== null) {
109
139
  result.push(pendingDelete as unknown as Operation);
110
140
  pendingDelete = null;
111
141
  }
112
142
 
113
- // Coalesce inserts
143
+ // Coalesce inserts (only for position-based operations without CRDT metadata)
114
144
  if (pendingInsert === null) {
115
145
  // Start new pending insert
116
146
  pendingInsert = {
@@ -145,13 +175,31 @@ export function coalesceTextOps(ops: Operation[]): Operation[] {
145
175
  };
146
176
  }
147
177
  } else if (isPositionTextDeleteOp(op)) {
178
+ // Check if operation has CRDT metadata - if so, don't coalesce it
179
+ const hasCRDTMetadata = (op as any).deletions !== undefined;
180
+
181
+ if (hasCRDTMetadata) {
182
+ // Flush any pending operations
183
+ if (pendingInsert !== null) {
184
+ result.push(pendingInsert as unknown as Operation);
185
+ pendingInsert = null;
186
+ }
187
+ if (pendingDelete !== null) {
188
+ result.push(pendingDelete as unknown as Operation);
189
+ pendingDelete = null;
190
+ }
191
+ // Pass through operations with CRDT metadata unchanged
192
+ result.push(op as unknown as Operation);
193
+ continue;
194
+ }
195
+
148
196
  // Flush any pending insert
149
197
  if (pendingInsert !== null) {
150
198
  result.push(pendingInsert as unknown as Operation);
151
199
  pendingInsert = null;
152
200
  }
153
201
 
154
- // Coalesce deletes
202
+ // Coalesce deletes (only for position-based operations without CRDT metadata)
155
203
  if (pendingDelete === null) {
156
204
  pendingDelete = {
157
205
  otype: 'text.delete',
@@ -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
+ }