@vuer-ai/vuer-rtc 0.5.0 → 0.5.1
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/dist/client/coalesceTextOperations.d.ts.map +1 -1
- package/dist/client/coalesceTextOperations.js +41 -2
- package/dist/client/coalesceTextOperations.js.map +1 -1
- package/package.json +1 -1
- package/src/client/coalesceTextOperations.ts +40 -2
- package/tests/client/coalesce-graph-operations.test.ts +321 -0
- package/tests/client/coalesce-text-operations.test.ts +1 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"coalesceTextOperations.d.ts","sourceRoot":"","sources":["../../src/client/coalesceTextOperations.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AACpD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAGhD,MAAM,WAAW,eAAe;IAC9B,kEAAkE;IAClE,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,sBAAsB,CACpC,GAAG,EAAE,aAAa,EAAE,EACpB,OAAO,GAAE,eAAoB,GAC5B,aAAa,EAAE,
|
|
1
|
+
{"version":3,"file":"coalesceTextOperations.d.ts","sourceRoot":"","sources":["../../src/client/coalesceTextOperations.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AACpD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAGhD,MAAM,WAAW,eAAe;IAC9B,kEAAkE;IAClE,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,sBAAsB,CACpC,GAAG,EAAE,aAAa,EAAE,EACpB,OAAO,GAAE,eAAoB,GAC5B,aAAa,EAAE,CAuHjB;AAED;;;;;;;;;GASG;AACH,wBAAgB,cAAc,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC,EAAE,QAAQ,GAAG,MAAM,CAI/D"}
|
|
@@ -28,8 +28,14 @@ export function coalesceTextOperations(ops, options = {}) {
|
|
|
28
28
|
return ops;
|
|
29
29
|
const result = [];
|
|
30
30
|
let pendingInsert = null;
|
|
31
|
+
let pendingDelete = null;
|
|
31
32
|
for (const op of ops) {
|
|
32
33
|
if (op.type === 'insert') {
|
|
34
|
+
// Flush any pending delete before starting new insert
|
|
35
|
+
if (pendingDelete !== null) {
|
|
36
|
+
result.push(pendingDelete);
|
|
37
|
+
pendingDelete = null;
|
|
38
|
+
}
|
|
33
39
|
if (pendingInsert === null) {
|
|
34
40
|
// Start new pending insert
|
|
35
41
|
pendingInsert = { type: 'insert', op: { ...op.op } };
|
|
@@ -84,19 +90,52 @@ export function coalesceTextOperations(ops, options = {}) {
|
|
|
84
90
|
}
|
|
85
91
|
}
|
|
86
92
|
}
|
|
93
|
+
else if (op.type === 'delete') {
|
|
94
|
+
// Flush any pending insert before handling delete
|
|
95
|
+
if (pendingInsert !== null) {
|
|
96
|
+
result.push(pendingInsert);
|
|
97
|
+
pendingInsert = null;
|
|
98
|
+
}
|
|
99
|
+
if (pendingDelete === null) {
|
|
100
|
+
// Start new pending delete
|
|
101
|
+
pendingDelete = { type: 'delete', op: { ...op.op, deletions: [...op.op.deletions] } };
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
// Try to merge consecutive delete operations
|
|
105
|
+
// Deletes can be merged if they're within the time threshold
|
|
106
|
+
// We simply combine the deletions arrays
|
|
107
|
+
const canMerge = true; // For deletes, we just merge the deletions arrays
|
|
108
|
+
if (canMerge) {
|
|
109
|
+
// Merge by combining deletions
|
|
110
|
+
pendingDelete.op.deletions.push(...op.op.deletions);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
// Can't merge - flush pending and start new
|
|
114
|
+
result.push(pendingDelete);
|
|
115
|
+
pendingDelete = { type: 'delete', op: { ...op.op, deletions: [...op.op.deletions] } };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
87
119
|
else {
|
|
88
|
-
//
|
|
120
|
+
// Other operation types - flush any pending
|
|
89
121
|
if (pendingInsert !== null) {
|
|
90
122
|
result.push(pendingInsert);
|
|
91
123
|
pendingInsert = null;
|
|
92
124
|
}
|
|
125
|
+
if (pendingDelete !== null) {
|
|
126
|
+
result.push(pendingDelete);
|
|
127
|
+
pendingDelete = null;
|
|
128
|
+
}
|
|
93
129
|
result.push(op);
|
|
94
130
|
}
|
|
95
131
|
}
|
|
96
|
-
// Flush any remaining pending
|
|
132
|
+
// Flush any remaining pending operations
|
|
97
133
|
if (pendingInsert !== null) {
|
|
98
134
|
result.push(pendingInsert);
|
|
99
135
|
}
|
|
136
|
+
if (pendingDelete !== null) {
|
|
137
|
+
result.push(pendingDelete);
|
|
138
|
+
}
|
|
100
139
|
return result;
|
|
101
140
|
}
|
|
102
141
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"coalesceTextOperations.js","sourceRoot":"","sources":["../../src/client/coalesceTextOperations.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAIH,OAAO,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAO9C;;;;;;;;;;GAUG;AACH,MAAM,UAAU,sBAAsB,CACpC,GAAoB,EACpB,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,GAAoB,EAAE,CAAC;IACnC,IAAI,aAAa,GAA4C,IAAI,CAAC;
|
|
1
|
+
{"version":3,"file":"coalesceTextOperations.js","sourceRoot":"","sources":["../../src/client/coalesceTextOperations.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -41,9 +41,15 @@ export function coalesceTextOperations(
|
|
|
41
41
|
|
|
42
42
|
const result: TextOperation[] = [];
|
|
43
43
|
let pendingInsert: { type: 'insert'; op: InsertOp } | null = null;
|
|
44
|
+
let pendingDelete: { type: 'delete'; op: any } | null = null;
|
|
44
45
|
|
|
45
46
|
for (const op of ops) {
|
|
46
47
|
if (op.type === 'insert') {
|
|
48
|
+
// Flush any pending delete before starting new insert
|
|
49
|
+
if (pendingDelete !== null) {
|
|
50
|
+
result.push(pendingDelete);
|
|
51
|
+
pendingDelete = null;
|
|
52
|
+
}
|
|
47
53
|
if (pendingInsert === null) {
|
|
48
54
|
// Start new pending insert
|
|
49
55
|
pendingInsert = { type: 'insert', op: { ...op.op } };
|
|
@@ -99,20 +105,52 @@ export function coalesceTextOperations(
|
|
|
99
105
|
pendingInsert = { type: 'insert', op: { ...currOp } };
|
|
100
106
|
}
|
|
101
107
|
}
|
|
108
|
+
} else if (op.type === 'delete') {
|
|
109
|
+
// Flush any pending insert before handling delete
|
|
110
|
+
if (pendingInsert !== null) {
|
|
111
|
+
result.push(pendingInsert);
|
|
112
|
+
pendingInsert = null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (pendingDelete === null) {
|
|
116
|
+
// Start new pending delete
|
|
117
|
+
pendingDelete = { type: 'delete', op: { ...op.op, deletions: [...op.op.deletions] } };
|
|
118
|
+
} else {
|
|
119
|
+
// Try to merge consecutive delete operations
|
|
120
|
+
// Deletes can be merged if they're within the time threshold
|
|
121
|
+
// We simply combine the deletions arrays
|
|
122
|
+
const canMerge = true; // For deletes, we just merge the deletions arrays
|
|
123
|
+
|
|
124
|
+
if (canMerge) {
|
|
125
|
+
// Merge by combining deletions
|
|
126
|
+
pendingDelete.op.deletions.push(...op.op.deletions);
|
|
127
|
+
} else {
|
|
128
|
+
// Can't merge - flush pending and start new
|
|
129
|
+
result.push(pendingDelete);
|
|
130
|
+
pendingDelete = { type: 'delete', op: { ...op.op, deletions: [...op.op.deletions] } };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
102
133
|
} else {
|
|
103
|
-
//
|
|
134
|
+
// Other operation types - flush any pending
|
|
104
135
|
if (pendingInsert !== null) {
|
|
105
136
|
result.push(pendingInsert);
|
|
106
137
|
pendingInsert = null;
|
|
107
138
|
}
|
|
139
|
+
if (pendingDelete !== null) {
|
|
140
|
+
result.push(pendingDelete);
|
|
141
|
+
pendingDelete = null;
|
|
142
|
+
}
|
|
108
143
|
result.push(op);
|
|
109
144
|
}
|
|
110
145
|
}
|
|
111
146
|
|
|
112
|
-
// Flush any remaining pending
|
|
147
|
+
// Flush any remaining pending operations
|
|
113
148
|
if (pendingInsert !== null) {
|
|
114
149
|
result.push(pendingInsert);
|
|
115
150
|
}
|
|
151
|
+
if (pendingDelete !== null) {
|
|
152
|
+
result.push(pendingDelete);
|
|
153
|
+
}
|
|
116
154
|
|
|
117
155
|
return result;
|
|
118
156
|
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end tests for graph operation coalescence
|
|
3
|
+
*
|
|
4
|
+
* Verifies that text.insert and text.delete operations are properly coalesced
|
|
5
|
+
* when using createGraph with coalescingEnabled.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, jest } from '@jest/globals';
|
|
9
|
+
import { createGraph } from '../../src/client/createGraph.js';
|
|
10
|
+
import type { CRDTMessage, Operation } from '../../src/operations/OperationTypes.js';
|
|
11
|
+
|
|
12
|
+
describe('Graph Operation Coalescence (end-to-end)', () => {
|
|
13
|
+
describe('text.insert coalescence', () => {
|
|
14
|
+
it('should coalesce consecutive text.insert operations when coalescingEnabled', async () => {
|
|
15
|
+
const messages: CRDTMessage[] = [];
|
|
16
|
+
const store = createGraph({
|
|
17
|
+
sessionId: 'alice',
|
|
18
|
+
onSend: (msg) => messages.push(msg),
|
|
19
|
+
coalescingEnabled: true,
|
|
20
|
+
coalescingDelayMs: 50, // Short delay for testing
|
|
21
|
+
coalescingThresholdMs: 300, // 300ms threshold
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Create node first (insert as child of root '')
|
|
25
|
+
store.edit({ otype: 'node.insert', key: '', path: 'children', value: { key: 'doc', tag: 'Doc', name: 'Document' } });
|
|
26
|
+
|
|
27
|
+
// Initialize a text property
|
|
28
|
+
store.edit({ otype: 'text.init', key: 'doc', path: 'content', value: '' });
|
|
29
|
+
|
|
30
|
+
// Simulate typing "Hello" quickly (within 300ms)
|
|
31
|
+
store.edit({ otype: 'text.insert', key: 'doc', path: 'content', position: 0, value: 'H' });
|
|
32
|
+
store.edit({ otype: 'text.insert', key: 'doc', path: 'content', position: 1, value: 'e' });
|
|
33
|
+
store.edit({ otype: 'text.insert', key: 'doc', path: 'content', position: 2, value: 'l' });
|
|
34
|
+
store.edit({ otype: 'text.insert', key: 'doc', path: 'content', position: 3, value: 'l' });
|
|
35
|
+
store.edit({ otype: 'text.insert', key: 'doc', path: 'content', position: 4, value: 'o' });
|
|
36
|
+
|
|
37
|
+
// Wait for coalescing timer to fire
|
|
38
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
39
|
+
|
|
40
|
+
// Should have sent ONE coalesced message (init + coalesced inserts)
|
|
41
|
+
expect(messages.length).toBe(1);
|
|
42
|
+
|
|
43
|
+
// The message should contain coalesced text.insert operations
|
|
44
|
+
const msg = messages[0];
|
|
45
|
+
const textInserts = msg.ops.filter(op => op.otype === 'text.insert');
|
|
46
|
+
|
|
47
|
+
// All 5 inserts should be coalesced into 1 operation
|
|
48
|
+
expect(textInserts.length).toBeLessThan(5);
|
|
49
|
+
|
|
50
|
+
// The coalesced operation should have content "Hello"
|
|
51
|
+
const firstInsert = textInserts[0];
|
|
52
|
+
expect(firstInsert.content).toBe('Hello');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should NOT coalesce when coalescingEnabled is false', async () => {
|
|
56
|
+
const messages: CRDTMessage[] = [];
|
|
57
|
+
const store = createGraph({
|
|
58
|
+
sessionId: 'alice',
|
|
59
|
+
onSend: (msg) => messages.push(msg),
|
|
60
|
+
coalescingEnabled: false, // Disabled
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
store.edit({ otype: 'node.insert', key: '', path: 'children', value: { key: 'doc', tag: 'Doc', name: 'Document' } });
|
|
64
|
+
store.edit({ otype: 'text.init', key: 'doc', path: 'content', value: '' });
|
|
65
|
+
|
|
66
|
+
// Type "Hi" and commit immediately
|
|
67
|
+
store.edit({ otype: 'text.insert', key: 'doc', path: 'content', position: 0, value: 'H' });
|
|
68
|
+
store.edit({ otype: 'text.insert', key: 'doc', path: 'content', position: 1, value: 'i' });
|
|
69
|
+
store.commit('typing');
|
|
70
|
+
|
|
71
|
+
// Should send ONE message with init and 2 separate insert operations (not coalesced)
|
|
72
|
+
expect(messages.length).toBe(1);
|
|
73
|
+
const textInserts = messages[0].ops.filter(op => op.otype === 'text.insert');
|
|
74
|
+
|
|
75
|
+
// Should have 2 separate operations
|
|
76
|
+
expect(textInserts.length).toBe(2);
|
|
77
|
+
expect(textInserts[0].content).toBe('H');
|
|
78
|
+
expect(textInserts[1].content).toBe('i');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should respect time threshold and create separate operations for slow typing', async () => {
|
|
82
|
+
const messages: CRDTMessage[] = [];
|
|
83
|
+
const store = createGraph({
|
|
84
|
+
sessionId: 'alice',
|
|
85
|
+
onSend: (msg) => messages.push(msg),
|
|
86
|
+
coalescingEnabled: true,
|
|
87
|
+
coalescingDelayMs: 50,
|
|
88
|
+
coalescingThresholdMs: 100, // 100ms threshold
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
store.edit({ otype: 'node.insert', key: '', path: 'children', value: { key: 'doc', tag: 'Doc', name: 'Document' } });
|
|
92
|
+
store.edit({ otype: 'text.init', key: 'doc', path: 'content', value: '' });
|
|
93
|
+
|
|
94
|
+
// Type "H" and "e" quickly
|
|
95
|
+
store.edit({ otype: 'text.insert', key: 'doc', path: 'content', position: 0, value: 'H' });
|
|
96
|
+
store.edit({ otype: 'text.insert', key: 'doc', path: 'content', position: 1, value: 'e' });
|
|
97
|
+
|
|
98
|
+
// Wait for first batch to be sent
|
|
99
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
100
|
+
|
|
101
|
+
// Type "l" and "l" quickly (after threshold)
|
|
102
|
+
await new Promise(resolve => setTimeout(resolve, 150)); // Exceed threshold
|
|
103
|
+
store.edit({ otype: 'text.insert', key: 'doc', path: 'content', position: 2, value: 'l' });
|
|
104
|
+
store.edit({ otype: 'text.insert', key: 'doc', path: 'content', position: 3, value: 'l' });
|
|
105
|
+
|
|
106
|
+
// Wait for second batch
|
|
107
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
108
|
+
|
|
109
|
+
// Should have sent TWO messages: one with "He", one with "ll"
|
|
110
|
+
expect(messages.length).toBe(2);
|
|
111
|
+
|
|
112
|
+
const firstInserts = messages[0].ops.filter(op => op.otype === 'text.insert');
|
|
113
|
+
const secondInserts = messages[1].ops.filter(op => op.otype === 'text.insert');
|
|
114
|
+
|
|
115
|
+
expect(firstInserts.length).toBe(1);
|
|
116
|
+
expect(firstInserts[0].content).toBe('He');
|
|
117
|
+
|
|
118
|
+
expect(secondInserts.length).toBe(1);
|
|
119
|
+
expect(secondInserts[0].content).toBe('ll');
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe('text.delete coalescence', () => {
|
|
124
|
+
it('should coalesce consecutive text.delete operations', async () => {
|
|
125
|
+
const messages: CRDTMessage[] = [];
|
|
126
|
+
const store = createGraph({
|
|
127
|
+
sessionId: 'alice',
|
|
128
|
+
onSend: (msg) => messages.push(msg),
|
|
129
|
+
coalescingEnabled: true,
|
|
130
|
+
coalescingDelayMs: 50,
|
|
131
|
+
coalescingThresholdMs: 300,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Create node and initialize with some text
|
|
135
|
+
store.edit({ otype: 'node.insert', key: '', path: 'children', value: { key: 'doc', tag: 'Doc', name: 'Document' } });
|
|
136
|
+
store.edit({ otype: 'text.init', key: 'doc', path: 'content', value: 'Hello World' });
|
|
137
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
138
|
+
messages.length = 0; // Clear init message
|
|
139
|
+
|
|
140
|
+
// Delete "World" one character at a time (positions 6-10)
|
|
141
|
+
store.edit({ otype: 'text.delete', key: 'doc', path: 'content', position: 6, length: 1 }); // Delete 'W'
|
|
142
|
+
store.edit({ otype: 'text.delete', key: 'doc', path: 'content', position: 6, length: 1 }); // Delete 'o'
|
|
143
|
+
store.edit({ otype: 'text.delete', key: 'doc', path: 'content', position: 6, length: 1 }); // Delete 'r'
|
|
144
|
+
store.edit({ otype: 'text.delete', key: 'doc', path: 'content', position: 6, length: 1 }); // Delete 'l'
|
|
145
|
+
store.edit({ otype: 'text.delete', key: 'doc', path: 'content', position: 6, length: 1 }); // Delete 'd'
|
|
146
|
+
|
|
147
|
+
// Wait for coalescing timer
|
|
148
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
149
|
+
|
|
150
|
+
// Should have ONE coalesced delete message
|
|
151
|
+
expect(messages.length).toBe(1);
|
|
152
|
+
|
|
153
|
+
const deleteOps = messages[0].ops.filter(op => op.otype === 'text.delete');
|
|
154
|
+
|
|
155
|
+
// Should be coalesced into fewer operations
|
|
156
|
+
expect(deleteOps.length).toBeLessThan(5);
|
|
157
|
+
|
|
158
|
+
// If coalesced into 1 operation, it should have a deletions array
|
|
159
|
+
if (deleteOps.length === 1) {
|
|
160
|
+
const firstDelete = deleteOps[0] as any;
|
|
161
|
+
expect(firstDelete.deletions).toBeDefined();
|
|
162
|
+
expect(firstDelete.deletions.length).toBeGreaterThanOrEqual(1);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('should handle mixed insert and delete operations', async () => {
|
|
167
|
+
const messages: CRDTMessage[] = [];
|
|
168
|
+
const store = createGraph({
|
|
169
|
+
sessionId: 'alice',
|
|
170
|
+
onSend: (msg) => messages.push(msg),
|
|
171
|
+
coalescingEnabled: true,
|
|
172
|
+
coalescingDelayMs: 50,
|
|
173
|
+
coalescingThresholdMs: 300,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
store.edit({ otype: 'node.insert', key: '', path: 'children', value: { key: 'doc', tag: 'Doc', name: 'Document' } });
|
|
177
|
+
store.edit({ otype: 'text.init', key: 'doc', path: 'content', value: '' });
|
|
178
|
+
|
|
179
|
+
// Type "Hello", then delete "lo", then type "p"
|
|
180
|
+
store.edit({ otype: 'text.insert', key: 'doc', path: 'content', position: 0, value: 'H' });
|
|
181
|
+
store.edit({ otype: 'text.insert', key: 'doc', path: 'content', position: 1, value: 'e' });
|
|
182
|
+
store.edit({ otype: 'text.insert', key: 'doc', path: 'content', position: 2, value: 'l' });
|
|
183
|
+
store.edit({ otype: 'text.insert', key: 'doc', path: 'content', position: 3, value: 'l' });
|
|
184
|
+
store.edit({ otype: 'text.insert', key: 'doc', path: 'content', position: 4, value: 'o' });
|
|
185
|
+
store.edit({ otype: 'text.delete', key: 'doc', path: 'content', position: 3, length: 1 }); // Delete 'l'
|
|
186
|
+
store.edit({ otype: 'text.delete', key: 'doc', path: 'content', position: 3, length: 1 }); // Delete 'o'
|
|
187
|
+
store.edit({ otype: 'text.insert', key: 'doc', path: 'content', position: 3, value: 'p' });
|
|
188
|
+
|
|
189
|
+
// Wait for coalescing timer
|
|
190
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
191
|
+
|
|
192
|
+
// Should have ONE message with coalesced operations
|
|
193
|
+
expect(messages.length).toBe(1);
|
|
194
|
+
|
|
195
|
+
const msg = messages[0];
|
|
196
|
+
const inserts = msg.ops.filter(op => op.otype === 'text.insert');
|
|
197
|
+
const deletes = msg.ops.filter(op => op.otype === 'text.delete');
|
|
198
|
+
|
|
199
|
+
// Should have some coalesced inserts and deletes
|
|
200
|
+
expect(inserts.length).toBeGreaterThan(0);
|
|
201
|
+
expect(deletes.length).toBeGreaterThan(0);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe('explicit commit with coalescence', () => {
|
|
206
|
+
it('should coalesce operations when explicitly committing with coalescingEnabled', () => {
|
|
207
|
+
const messages: CRDTMessage[] = [];
|
|
208
|
+
const store = createGraph({
|
|
209
|
+
sessionId: 'alice',
|
|
210
|
+
onSend: (msg) => messages.push(msg),
|
|
211
|
+
coalescingEnabled: true,
|
|
212
|
+
coalescingThresholdMs: 300,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
store.edit({ otype: 'node.insert', key: '', path: 'children', value: { key: 'doc', tag: 'Doc', name: 'Document' } });
|
|
216
|
+
store.edit({ otype: 'text.init', key: 'doc', path: 'content', value: '' });
|
|
217
|
+
store.edit({ otype: 'text.insert', key: 'doc', path: 'content', position: 0, value: 'H' });
|
|
218
|
+
store.edit({ otype: 'text.insert', key: 'doc', path: 'content', position: 1, value: 'i' });
|
|
219
|
+
|
|
220
|
+
// Explicitly commit (should clear timer and coalesce immediately)
|
|
221
|
+
store.commit('typing');
|
|
222
|
+
|
|
223
|
+
// Should send ONE message immediately with coalesced operations
|
|
224
|
+
expect(messages.length).toBe(1);
|
|
225
|
+
|
|
226
|
+
const textInserts = messages[0].ops.filter(op => op.otype === 'text.insert');
|
|
227
|
+
expect(textInserts.length).toBe(1);
|
|
228
|
+
expect(textInserts[0].content).toBe('Hi');
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe('multi-document coalescence', () => {
|
|
233
|
+
it('should coalesce operations on different documents independently', async () => {
|
|
234
|
+
const messages: CRDTMessage[] = [];
|
|
235
|
+
const store = createGraph({
|
|
236
|
+
sessionId: 'alice',
|
|
237
|
+
onSend: (msg) => messages.push(msg),
|
|
238
|
+
coalescingEnabled: true,
|
|
239
|
+
coalescingDelayMs: 50,
|
|
240
|
+
coalescingThresholdMs: 300,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// Edit doc1
|
|
244
|
+
store.edit({ otype: 'node.insert', key: '', path: 'children', value: { key: 'doc1', tag: 'Doc', name: 'Document 1' } });
|
|
245
|
+
store.edit({ otype: 'text.init', key: 'doc1', path: 'content', value: '' });
|
|
246
|
+
store.edit({ otype: 'text.insert', key: 'doc1', path: 'content', position: 0, value: 'A' });
|
|
247
|
+
store.edit({ otype: 'text.insert', key: 'doc1', path: 'content', position: 1, value: 'B' });
|
|
248
|
+
|
|
249
|
+
// Edit doc2
|
|
250
|
+
store.edit({ otype: 'node.insert', key: '', path: 'children', value: { key: 'doc2', tag: 'Doc', name: 'Document 2' } });
|
|
251
|
+
store.edit({ otype: 'text.init', key: 'doc2', path: 'content', value: '' });
|
|
252
|
+
store.edit({ otype: 'text.insert', key: 'doc2', path: 'content', position: 0, value: 'X' });
|
|
253
|
+
store.edit({ otype: 'text.insert', key: 'doc2', path: 'content', position: 1, value: 'Y' });
|
|
254
|
+
|
|
255
|
+
// Wait for coalescing
|
|
256
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
257
|
+
|
|
258
|
+
// Should send ONE message with operations from both docs
|
|
259
|
+
expect(messages.length).toBe(1);
|
|
260
|
+
|
|
261
|
+
const msg = messages[0];
|
|
262
|
+
const doc1Inserts = msg.ops.filter(op => op.otype === 'text.insert' && op.key === 'doc1');
|
|
263
|
+
const doc2Inserts = msg.ops.filter(op => op.otype === 'text.insert' && op.key === 'doc2');
|
|
264
|
+
|
|
265
|
+
// Each document's operations should be coalesced independently
|
|
266
|
+
expect(doc1Inserts.length).toBe(1);
|
|
267
|
+
expect(doc1Inserts[0].content).toBe('AB');
|
|
268
|
+
|
|
269
|
+
expect(doc2Inserts.length).toBe(1);
|
|
270
|
+
expect(doc2Inserts[0].content).toBe('XY');
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe('edge cases', () => {
|
|
275
|
+
it('should handle rapid commits without losing operations', async () => {
|
|
276
|
+
const messages: CRDTMessage[] = [];
|
|
277
|
+
const store = createGraph({
|
|
278
|
+
sessionId: 'alice',
|
|
279
|
+
onSend: (msg) => messages.push(msg),
|
|
280
|
+
coalescingEnabled: true,
|
|
281
|
+
coalescingDelayMs: 100,
|
|
282
|
+
coalescingThresholdMs: 300,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
store.edit({ otype: 'node.insert', key: '', path: 'children', value: { key: 'doc', tag: 'Doc', name: 'Document' } });
|
|
286
|
+
store.edit({ otype: 'text.init', key: 'doc', path: 'content', value: '' });
|
|
287
|
+
store.edit({ otype: 'text.insert', key: 'doc', path: 'content', position: 0, value: 'A' });
|
|
288
|
+
store.commit('first');
|
|
289
|
+
|
|
290
|
+
store.edit({ otype: 'text.insert', key: 'doc', path: 'content', position: 1, value: 'B' });
|
|
291
|
+
store.commit('second');
|
|
292
|
+
|
|
293
|
+
store.edit({ otype: 'text.insert', key: 'doc', path: 'content', position: 2, value: 'C' });
|
|
294
|
+
store.commit('third');
|
|
295
|
+
|
|
296
|
+
// Should have 3 messages (one per commit)
|
|
297
|
+
expect(messages.length).toBe(3);
|
|
298
|
+
|
|
299
|
+
// Each message should contain the expected operation
|
|
300
|
+
expect(messages[0].ops.some(op => op.otype === 'text.insert' && op.content === 'A')).toBe(true);
|
|
301
|
+
expect(messages[1].ops.some(op => op.otype === 'text.insert' && op.content === 'B')).toBe(true);
|
|
302
|
+
expect(messages[2].ops.some(op => op.otype === 'text.insert' && op.content === 'C')).toBe(true);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('should handle empty edits gracefully', async () => {
|
|
306
|
+
const messages: CRDTMessage[] = [];
|
|
307
|
+
const store = createGraph({
|
|
308
|
+
sessionId: 'alice',
|
|
309
|
+
onSend: (msg) => messages.push(msg),
|
|
310
|
+
coalescingEnabled: true,
|
|
311
|
+
coalescingDelayMs: 50,
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// Wait for timer without any edits
|
|
315
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
316
|
+
|
|
317
|
+
// Should not send any messages
|
|
318
|
+
expect(messages.length).toBe(0);
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
});
|