@vuer-ai/vuer-rtc 0.5.2 → 0.5.3

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.
@@ -12,8 +12,6 @@ export interface TextDeleteOp {
12
12
  id: string;
13
13
  length: number;
14
14
  }>;
15
- seq: number;
16
- ts: number;
17
15
  }
18
16
  export interface CoalesceOptions {
19
17
  /** Time threshold in milliseconds (default: 1000ms = 1 second) */
@@ -1 +1 @@
1
- {"version":3,"file":"textDeletes.d.ts","sourceRoot":"","sources":["../../../src/client/coalescence/textDeletes.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,oCAAoC,CAAC;AAGpE,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,aAAa,CAAC;IACrB,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACjD,GAAG,EAAE,MAAM,CAAC;IACZ,EAAE,EAAE,MAAM,CAAC;CACZ;AAED,MAAM,WAAW,eAAe;IAC9B,kEAAkE;IAClE,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;GAEG;AACH,wBAAgB,cAAc,CAAC,EAAE,EAAE,SAAS,GAAG,EAAE,IAAI,YAAY,CAOhE;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,mBAAmB,CACjC,GAAG,EAAE,YAAY,EAAE,EACnB,OAAO,GAAE,eAAoB,GAC5B,YAAY,EAAE,CAoDhB"}
1
+ {"version":3,"file":"textDeletes.d.ts","sourceRoot":"","sources":["../../../src/client/coalescence/textDeletes.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,oCAAoC,CAAC;AAGpE,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,aAAa,CAAC;IACrB,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAGlD;AAED,MAAM,WAAW,eAAe;IAC9B,kEAAkE;IAClE,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;GAEG;AACH,wBAAgB,cAAc,CAAC,EAAE,EAAE,SAAS,GAAG,EAAE,IAAI,YAAY,CAMhE;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,mBAAmB,CACjC,GAAG,EAAE,YAAY,EAAE,EACnB,OAAO,GAAE,eAAoB,GAC5B,YAAY,EAAE,CAgDhB"}
@@ -10,8 +10,7 @@ import { optimizeDeletions } from './utils.js';
10
10
  export function isTextDeleteOp(op) {
11
11
  return (op.otype === 'text.delete' &&
12
12
  Array.isArray(op.deletions) &&
13
- typeof op.seq === 'number' &&
14
- typeof op.ts === 'number');
13
+ op.deletions.length > 0);
15
14
  }
16
15
  /**
17
16
  * Coalesce consecutive text delete operations.
@@ -38,18 +37,15 @@ export function coalesceTextDeletes(ops, options = {}) {
38
37
  else {
39
38
  // Check merge conditions
40
39
  const sameTarget = pending.key === op.key && pending.path === op.path;
41
- // Time threshold: operations must be close in time (ts is in seconds)
42
- const timeDiffMs = (op.ts - pending.ts) * 1000;
43
- const withinThreshold = timeDiffMs <= thresholdMs;
44
- if (sameTarget && withinThreshold) {
40
+ // NOTE: Time threshold is not available for TextDeleteOp (no ts field)
41
+ // We merge based on target (key + path) only
42
+ if (sameTarget) {
45
43
  // Merge operations - combine deletion lists
46
44
  const merged = {
47
45
  otype: 'text.delete',
48
46
  key: pending.key,
49
47
  path: pending.path,
50
48
  deletions: [...pending.deletions, ...op.deletions],
51
- seq: Math.max(pending.seq, op.seq), // Use max Lamport clock for ordering
52
- ts: pending.ts, // Keep first timestamp (when sequence started)
53
49
  };
54
50
  pending = merged;
55
51
  }
@@ -1 +1 @@
1
- {"version":3,"file":"textDeletes.js","sourceRoot":"","sources":["../../../src/client/coalescence/textDeletes.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,OAAO,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAgB/C;;GAEG;AACH,MAAM,UAAU,cAAc,CAAC,EAAa;IAC1C,OAAO,CACL,EAAE,CAAC,KAAK,KAAK,aAAa;QAC1B,KAAK,CAAC,OAAO,CAAE,EAAU,CAAC,SAAS,CAAC;QACpC,OAAQ,EAAU,CAAC,GAAG,KAAK,QAAQ;QACnC,OAAQ,EAAU,CAAC,EAAE,KAAK,QAAQ,CACnC,CAAC;AACJ,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,UAAU,mBAAmB,CACjC,GAAmB,EACnB,UAA2B,EAAE;IAE7B,MAAM,EAAE,WAAW,GAAG,IAAI,EAAE,GAAG,OAAO,CAAC;IAEvC,IAAI,GAAG,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,GAAG,CAAC;IAEjC,MAAM,MAAM,GAAmB,EAAE,CAAC;IAClC,IAAI,OAAO,GAAwB,IAAI,CAAC;IAExC,KAAK,MAAM,EAAE,IAAI,GAAG,EAAE,CAAC;QACrB,IAAI,OAAO,KAAK,IAAI,EAAE,CAAC;YACrB,2BAA2B;YAC3B,OAAO,GAAG,EAAE,GAAG,EAAE,EAAE,SAAS,EAAE,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,EAAE,CAAC;QACpD,CAAC;aAAM,CAAC;YACN,yBAAyB;YACzB,MAAM,UAAU,GAAG,OAAO,CAAC,GAAG,KAAK,EAAE,CAAC,GAAG,IAAI,OAAO,CAAC,IAAI,KAAK,EAAE,CAAC,IAAI,CAAC;YAEtE,sEAAsE;YACtE,MAAM,UAAU,GAAG,CAAC,EAAE,CAAC,EAAE,GAAG,OAAO,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC;YAC/C,MAAM,eAAe,GAAG,UAAU,IAAI,WAAW,CAAC;YAElD,IAAI,UAAU,IAAI,eAAe,EAAE,CAAC;gBAClC,4CAA4C;gBAC5C,MAAM,MAAM,GAAiB;oBAC3B,KAAK,EAAE,aAAa;oBACpB,GAAG,EAAE,OAAO,CAAC,GAAG;oBAChB,IAAI,EAAE,OAAO,CAAC,IAAI;oBAClB,SAAS,EAAE,CAAC,GAAG,OAAO,CAAC,SAAS,EAAE,GAAG,EAAE,CAAC,SAAS,CAAC;oBAClD,GAAG,EAAE,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,EAAE,qCAAqC;oBACzE,EAAE,EAAE,OAAO,CAAC,EAAE,EAAE,+CAA+C;iBAChE,CAAC;gBACF,OAAO,GAAG,MAAM,CAAC;YACnB,CAAC;iBAAM,CAAC;gBACN,4CAA4C;gBAC5C,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;gBACrB,OAAO,GAAG,EAAE,GAAG,EAAE,EAAE,SAAS,EAAE,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,EAAE,CAAC;YACpD,CAAC;QACH,CAAC;IACH,CAAC;IAED,qCAAqC;IACrC,IAAI,OAAO,KAAK,IAAI,EAAE,CAAC;QACrB,0CAA0C;QAC1C,OAAO,CAAC,SAAS,GAAG,iBAAiB,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;QACzD,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IACvB,CAAC;IAED,8DAA8D;IAC9D,KAAK,MAAM,EAAE,IAAI,MAAM,EAAE,CAAC;QACxB,EAAE,CAAC,SAAS,GAAG,iBAAiB,CAAC,EAAE,CAAC,SAAS,CAAC,CAAC;IACjD,CAAC;IAED,OAAO,MAAM,CAAC;AAChB,CAAC"}
1
+ {"version":3,"file":"textDeletes.js","sourceRoot":"","sources":["../../../src/client/coalescence/textDeletes.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,OAAO,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAgB/C;;GAEG;AACH,MAAM,UAAU,cAAc,CAAC,EAAa;IAC1C,OAAO,CACL,EAAE,CAAC,KAAK,KAAK,aAAa;QAC1B,KAAK,CAAC,OAAO,CAAE,EAAU,CAAC,SAAS,CAAC;QACnC,EAAU,CAAC,SAAS,CAAC,MAAM,GAAG,CAAC,CACjC,CAAC;AACJ,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,UAAU,mBAAmB,CACjC,GAAmB,EACnB,UAA2B,EAAE;IAE7B,MAAM,EAAE,WAAW,GAAG,IAAI,EAAE,GAAG,OAAO,CAAC;IAEvC,IAAI,GAAG,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,GAAG,CAAC;IAEjC,MAAM,MAAM,GAAmB,EAAE,CAAC;IAClC,IAAI,OAAO,GAAwB,IAAI,CAAC;IAExC,KAAK,MAAM,EAAE,IAAI,GAAG,EAAE,CAAC;QACrB,IAAI,OAAO,KAAK,IAAI,EAAE,CAAC;YACrB,2BAA2B;YAC3B,OAAO,GAAG,EAAE,GAAG,EAAE,EAAE,SAAS,EAAE,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,EAAE,CAAC;QACpD,CAAC;aAAM,CAAC;YACN,yBAAyB;YACzB,MAAM,UAAU,GAAG,OAAO,CAAC,GAAG,KAAK,EAAE,CAAC,GAAG,IAAI,OAAO,CAAC,IAAI,KAAK,EAAE,CAAC,IAAI,CAAC;YAEtE,uEAAuE;YACvE,6CAA6C;YAC7C,IAAI,UAAU,EAAE,CAAC;gBACf,4CAA4C;gBAC5C,MAAM,MAAM,GAAiB;oBAC3B,KAAK,EAAE,aAAa;oBACpB,GAAG,EAAE,OAAO,CAAC,GAAG;oBAChB,IAAI,EAAE,OAAO,CAAC,IAAI;oBAClB,SAAS,EAAE,CAAC,GAAG,OAAO,CAAC,SAAS,EAAE,GAAG,EAAE,CAAC,SAAS,CAAC;iBACnD,CAAC;gBACF,OAAO,GAAG,MAAM,CAAC;YACnB,CAAC;iBAAM,CAAC;gBACN,4CAA4C;gBAC5C,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;gBACrB,OAAO,GAAG,EAAE,GAAG,EAAE,EAAE,SAAS,EAAE,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,EAAE,CAAC;YACpD,CAAC;QACH,CAAC;IACH,CAAC;IAED,qCAAqC;IACrC,IAAI,OAAO,KAAK,IAAI,EAAE,CAAC;QACrB,0CAA0C;QAC1C,OAAO,CAAC,SAAS,GAAG,iBAAiB,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;QACzD,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IACvB,CAAC;IAED,8DAA8D;IAC9D,KAAK,MAAM,EAAE,IAAI,MAAM,EAAE,CAAC;QACxB,EAAE,CAAC,SAAS,GAAG,iBAAiB,CAAC,EAAE,CAAC,SAAS,CAAC,CAAC;IACjD,CAAC;IAED,OAAO,MAAM,CAAC;AAChB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vuer-ai/vuer-rtc",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "CRDT-based real-time collaborative data structures",
@@ -12,8 +12,8 @@ export interface TextDeleteOp {
12
12
  key: string;
13
13
  path: string;
14
14
  deletions: Array<{ id: string; length: number }>; // Array of deletion ranges
15
- seq: number;
16
- ts: number;
15
+ // NOTE: TextDeleteOp does NOT have seq/ts fields (those are only on TextInsertOp)
16
+ // Time-based coalescence is not available for delete operations
17
17
  }
18
18
 
19
19
  export interface CoalesceOptions {
@@ -28,8 +28,7 @@ export function isTextDeleteOp(op: Operation): op is TextDeleteOp {
28
28
  return (
29
29
  op.otype === 'text.delete' &&
30
30
  Array.isArray((op as any).deletions) &&
31
- typeof (op as any).seq === 'number' &&
32
- typeof (op as any).ts === 'number'
31
+ (op as any).deletions.length > 0
33
32
  );
34
33
  }
35
34
 
@@ -63,19 +62,15 @@ export function coalesceTextDeletes(
63
62
  // Check merge conditions
64
63
  const sameTarget = pending.key === op.key && pending.path === op.path;
65
64
 
66
- // Time threshold: operations must be close in time (ts is in seconds)
67
- const timeDiffMs = (op.ts - pending.ts) * 1000;
68
- const withinThreshold = timeDiffMs <= thresholdMs;
69
-
70
- if (sameTarget && withinThreshold) {
65
+ // NOTE: Time threshold is not available for TextDeleteOp (no ts field)
66
+ // We merge based on target (key + path) only
67
+ if (sameTarget) {
71
68
  // Merge operations - combine deletion lists
72
69
  const merged: TextDeleteOp = {
73
70
  otype: 'text.delete',
74
71
  key: pending.key,
75
72
  path: pending.path,
76
73
  deletions: [...pending.deletions, ...op.deletions],
77
- seq: Math.max(pending.seq, op.seq), // Use max Lamport clock for ordering
78
- ts: pending.ts, // Keep first timestamp (when sequence started)
79
74
  };
80
75
  pending = merged;
81
76
  } else {
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Integration Tests for Graph Operation Coalescence
3
+ *
4
+ * These tests verify that operation coalescence works end-to-end through
5
+ * the GraphStore commit pipeline, not just at the Rope level.
6
+ *
7
+ * CRITICAL: These tests catch bugs that unit tests miss - like the isTextDeleteOp
8
+ * type guard checking for fields that don't exist!
9
+ */
10
+
11
+ import { describe, it, expect } from '@jest/globals';
12
+ import { createGraph } from '../../src/client/createGraph.js';
13
+ import type { CRDTMessage } from '../../src/operations/OperationTypes.js';
14
+
15
+ describe('Graph Operation Coalescence (Integration)', () => {
16
+ describe('text.delete coalescence', () => {
17
+ it('should coalesce consecutive text.delete operations', () => {
18
+ const messages: CRDTMessage[] = [];
19
+
20
+ const store = createGraph({
21
+ sessionId: 'alice',
22
+ coalescingEnabled: true,
23
+ coalescingDelayMs: 0,
24
+ coalescingThresholdMs: 1000,
25
+ onSend: (msg) => messages.push(msg),
26
+ });
27
+
28
+ // Initialize scene and text node
29
+ store.edit({ otype: 'node.init', key: 'scene', path: '', value: { tag: 'Scene' } });
30
+ store.commit('init scene');
31
+
32
+ store.edit({ otype: 'node.insert', key: 'scene', path: 'children', value: { key: 'text-doc', tag: 'Text' } });
33
+ store.commit('add text node');
34
+
35
+ store.edit({ otype: 'text.init', key: 'text-doc', path: 'content', value: '' } as any);
36
+ store.commit('init text');
37
+
38
+ // Type "Hello" (5 operations)
39
+ store.edit({ otype: 'text.insert', key: 'text-doc', path: 'content', position: 0, value: 'H' } as any);
40
+ store.edit({ otype: 'text.insert', key: 'text-doc', path: 'content', position: 1, value: 'e' } as any);
41
+ store.edit({ otype: 'text.insert', key: 'text-doc', path: 'content', position: 2, value: 'l' } as any);
42
+ store.edit({ otype: 'text.insert', key: 'text-doc', path: 'content', position: 3, value: 'l' } as any);
43
+ store.edit({ otype: 'text.insert', key: 'text-doc', path: 'content', position: 4, value: 'o' } as any);
44
+ store.commit('type Hello');
45
+
46
+ messages.length = 0; // Clear previous messages
47
+
48
+ // Delete all 5 characters (1 operation)
49
+ store.edit({ otype: 'text.delete', key: 'text-doc', path: 'content', position: 0, length: 5 } as any);
50
+ store.commit('delete all');
51
+
52
+ // Verify we sent exactly 1 message
53
+ expect(messages).toHaveLength(1);
54
+
55
+ const msg = messages[0];
56
+ expect(msg.ops).toHaveLength(1);
57
+ expect(msg.ops[0].otype).toBe('text.delete');
58
+
59
+ const deleteOp = msg.ops[0] as any;
60
+ expect(deleteOp.deletions).toBeDefined();
61
+
62
+ // CRITICAL: This verifies the coalescence optimization works!
63
+ // Without the fix, this would have many single-char deletions
64
+ expect(deleteOp.deletions).toHaveLength(1);
65
+ expect(deleteOp.deletions[0].length).toBe(5);
66
+ });
67
+
68
+ it('should coalesce multiple consecutive delete operations', () => {
69
+ const messages: CRDTMessage[] = [];
70
+
71
+ const store = createGraph({
72
+ sessionId: 'alice',
73
+ coalescingEnabled: true,
74
+ coalescingDelayMs: 0,
75
+ coalescingThresholdMs: 1000,
76
+ onSend: (msg) => messages.push(msg),
77
+ });
78
+
79
+ // Initialize
80
+ store.edit({ otype: 'node.init', key: 'scene', path: '', value: { tag: 'Scene' } });
81
+ store.commit('init');
82
+
83
+ store.edit({ otype: 'node.insert', key: 'scene', path: 'children', value: { key: 'text-doc', tag: 'Text' } });
84
+ store.commit('add text');
85
+
86
+ store.edit({ otype: 'text.init', key: 'text-doc', path: 'content', value: 'Hello' } as any);
87
+ store.commit('init text with Hello');
88
+
89
+ messages.length = 0;
90
+
91
+ // Simulate rapid backspace (5 separate delete operations)
92
+ store.edit({ otype: 'text.delete', key: 'text-doc', path: 'content', position: 4, length: 1 } as any); // 'o'
93
+ store.edit({ otype: 'text.delete', key: 'text-doc', path: 'content', position: 3, length: 1 } as any); // 'l'
94
+ store.edit({ otype: 'text.delete', key: 'text-doc', path: 'content', position: 2, length: 1 } as any); // 'l'
95
+ store.edit({ otype: 'text.delete', key: 'text-doc', path: 'content', position: 1, length: 1 } as any); // 'e'
96
+ store.edit({ otype: 'text.delete', key: 'text-doc', path: 'content', position: 0, length: 1 } as any); // 'H'
97
+ store.commit('rapid backspace');
98
+
99
+ // Verify coalescence worked
100
+ expect(messages).toHaveLength(1);
101
+
102
+ const msg = messages[0];
103
+
104
+ // Find the delete operation(s)
105
+ const deleteOps = msg.ops.filter(op => op.otype === 'text.delete');
106
+ expect(deleteOps.length).toBeGreaterThan(0);
107
+
108
+ // CRITICAL TEST: Verify each operation has optimized deletions
109
+ // The key fix is that isTextDeleteOp now accepts operations with deletions arrays
110
+ for (const op of deleteOps) {
111
+ const deleteOp = op as any;
112
+ expect(deleteOp.deletions).toBeDefined();
113
+ expect(Array.isArray(deleteOp.deletions)).toBe(true);
114
+ expect(deleteOp.deletions.length).toBeGreaterThan(0);
115
+
116
+ // Each deletion should be valid
117
+ for (const del of deleteOp.deletions) {
118
+ expect(del.id).toBeDefined();
119
+ expect(typeof del.length).toBe('number');
120
+ expect(del.length).toBeGreaterThan(0);
121
+ }
122
+ }
123
+
124
+ // Verify we're not sending more operations than we created
125
+ // (coalescence may merge some, but shouldn't create more)
126
+ expect(msg.ops.length).toBeLessThanOrEqual(5);
127
+ });
128
+
129
+ it('should verify type guard accepts delete operations with deletions', () => {
130
+ const messages: CRDTMessage[] = [];
131
+
132
+ const store = createGraph({
133
+ sessionId: 'alice',
134
+ coalescingEnabled: true,
135
+ coalescingDelayMs: 0,
136
+ coalescingThresholdMs: 1000,
137
+ onSend: (msg) => messages.push(msg),
138
+ });
139
+
140
+ // Initialize
141
+ store.edit({ otype: 'node.init', key: 'scene', path: '', value: { tag: 'Scene' } });
142
+ store.commit('init');
143
+
144
+ store.edit({ otype: 'node.insert', key: 'scene', path: 'children', value: { key: 'text-doc', tag: 'Text' } });
145
+ store.commit('add text');
146
+
147
+ store.edit({ otype: 'text.init', key: 'text-doc', path: 'content', value: 'Test' } as any);
148
+ store.commit('init text');
149
+
150
+ messages.length = 0;
151
+
152
+ // Single delete operation
153
+ store.edit({ otype: 'text.delete', key: 'text-doc', path: 'content', position: 0, length: 4 } as any);
154
+ store.commit('delete');
155
+
156
+ expect(messages).toHaveLength(1);
157
+
158
+ const deleteOp = messages[0].ops[0] as any;
159
+
160
+ // CRITICAL: Verify the operation has the deletions field
161
+ // This is what isTextDeleteOp checks for
162
+ expect(deleteOp.otype).toBe('text.delete');
163
+ expect(Array.isArray(deleteOp.deletions)).toBe(true);
164
+ expect(deleteOp.deletions.length).toBeGreaterThan(0);
165
+
166
+ // Verify it does NOT have seq/ts (those are on TextInsertOp only!)
167
+ expect(deleteOp.seq).toBeUndefined();
168
+ expect(deleteOp.ts).toBeUndefined();
169
+ });
170
+ });
171
+
172
+ describe('text.insert coalescence', () => {
173
+ it('should coalesce consecutive text.insert operations', () => {
174
+ const messages: CRDTMessage[] = [];
175
+
176
+ const store = createGraph({
177
+ sessionId: 'alice',
178
+ coalescingEnabled: true,
179
+ coalescingDelayMs: 0,
180
+ coalescingThresholdMs: 1000,
181
+ onSend: (msg) => messages.push(msg),
182
+ });
183
+
184
+ // Initialize
185
+ store.edit({ otype: 'node.init', key: 'scene', path: '', value: { tag: 'Scene' } });
186
+ store.commit('init');
187
+
188
+ store.edit({ otype: 'node.insert', key: 'scene', path: 'children', value: { key: 'text-doc', tag: 'Text' } });
189
+ store.commit('add text');
190
+
191
+ store.edit({ otype: 'text.init', key: 'text-doc', path: 'content', value: '' } as any);
192
+ store.commit('init text');
193
+
194
+ messages.length = 0;
195
+
196
+ // Type "Hello" (5 operations)
197
+ store.edit({ otype: 'text.insert', key: 'text-doc', path: 'content', position: 0, value: 'H' } as any);
198
+ store.edit({ otype: 'text.insert', key: 'text-doc', path: 'content', position: 1, value: 'e' } as any);
199
+ store.edit({ otype: 'text.insert', key: 'text-doc', path: 'content', position: 2, value: 'l' } as any);
200
+ store.edit({ otype: 'text.insert', key: 'text-doc', path: 'content', position: 3, value: 'l' } as any);
201
+ store.edit({ otype: 'text.insert', key: 'text-doc', path: 'content', position: 4, value: 'o' } as any);
202
+ store.commit('type Hello');
203
+
204
+ expect(messages).toHaveLength(1);
205
+
206
+ const msg = messages[0];
207
+
208
+ // Verify inserts were coalesced
209
+ expect(msg.ops).toHaveLength(1);
210
+ expect(msg.ops[0].otype).toBe('text.insert');
211
+
212
+ const insertOp = msg.ops[0] as any;
213
+ expect(insertOp.content).toBe('Hello');
214
+ expect(insertOp.id).toBeDefined();
215
+ expect(insertOp.seq).toBeDefined();
216
+ expect(insertOp.ts).toBeDefined();
217
+ });
218
+ });
219
+ });