@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.
- package/CLAUDE.md +29 -0
- package/COALESCE_FIX_VERIFICATION.md +81 -0
- package/REFACTORING_NOTES.md +229 -0
- package/dist/client/EditBuffer.d.ts +13 -1
- package/dist/client/EditBuffer.d.ts.map +1 -1
- package/dist/client/EditBuffer.js +47 -3
- package/dist/client/EditBuffer.js.map +1 -1
- package/dist/client/actions.d.ts +5 -1
- package/dist/client/actions.d.ts.map +1 -1
- package/dist/client/actions.js +12 -9
- package/dist/client/actions.js.map +1 -1
- package/dist/client/coalesceGraphOps.d.ts +34 -0
- package/dist/client/coalesceGraphOps.d.ts.map +1 -0
- package/dist/client/coalesceGraphOps.js +35 -0
- package/dist/client/coalesceGraphOps.js.map +1 -0
- package/dist/client/coalesceTextOperations.d.ts +42 -0
- package/dist/client/coalesceTextOperations.d.ts.map +1 -0
- package/dist/client/coalesceTextOperations.js +119 -0
- package/dist/client/coalesceTextOperations.js.map +1 -0
- package/dist/client/coalescence/index.d.ts +9 -0
- package/dist/client/coalescence/index.d.ts.map +1 -0
- package/dist/client/coalescence/index.js +9 -0
- package/dist/client/coalescence/index.js.map +1 -0
- package/dist/client/coalescence/registry.d.ts +48 -0
- package/dist/client/coalescence/registry.d.ts.map +1 -0
- package/dist/client/coalescence/registry.js +95 -0
- package/dist/client/coalescence/registry.js.map +1 -0
- package/dist/client/coalescence/textDeletes.d.ts +38 -0
- package/dist/client/coalescence/textDeletes.d.ts.map +1 -0
- package/dist/client/coalescence/textDeletes.js +68 -0
- package/dist/client/coalescence/textDeletes.js.map +1 -0
- package/dist/client/coalescence/textInserts.d.ts +45 -0
- package/dist/client/coalescence/textInserts.d.ts.map +1 -0
- package/dist/client/coalescence/textInserts.js +96 -0
- package/dist/client/coalescence/textInserts.js.map +1 -0
- package/dist/client/createGraph.d.ts.map +1 -1
- package/dist/client/createGraph.js +9 -2
- package/dist/client/createGraph.js.map +1 -1
- package/dist/client/createTextDocument.d.ts.map +1 -1
- package/dist/client/createTextDocument.js +2 -1
- package/dist/client/createTextDocument.js.map +1 -1
- package/dist/client/index.d.ts +4 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +4 -0
- package/dist/client/index.js.map +1 -1
- package/dist/client/textActions.d.ts +1 -1
- package/dist/client/textActions.d.ts.map +1 -1
- package/dist/client/textActions.js +7 -2
- package/dist/client/textActions.js.map +1 -1
- package/dist/client/types.d.ts +3 -0
- package/dist/client/types.d.ts.map +1 -1
- package/dist/crdt/GraphTextCRDT.d.ts +0 -4
- package/dist/crdt/GraphTextCRDT.d.ts.map +1 -1
- package/dist/crdt/GraphTextCRDT.js +3 -0
- package/dist/crdt/GraphTextCRDT.js.map +1 -1
- package/dist/crdt/Rope.d.ts +27 -6
- package/dist/crdt/Rope.d.ts.map +1 -1
- package/dist/crdt/Rope.js +137 -69
- package/dist/crdt/Rope.js.map +1 -1
- package/dist/operations/OperationTypes.d.ts +10 -26
- package/dist/operations/OperationTypes.d.ts.map +1 -1
- package/dist/operations/apply/text.d.ts.map +1 -1
- package/dist/operations/apply/text.js +8 -16
- package/dist/operations/apply/text.js.map +1 -1
- package/examples/05-coalescence-usage.ts +189 -0
- package/package.json +1 -1
- package/src/client/EditBuffer.ts +51 -3
- package/src/client/actions.ts +13 -9
- package/src/client/coalesceGraphOps.ts +40 -0
- package/src/client/coalesceTextOperations.ts +134 -0
- package/src/client/coalescence/index.ts +18 -0
- package/src/client/coalescence/registry.ts +137 -0
- package/src/client/coalescence/textDeletes.ts +94 -0
- package/src/client/coalescence/textInserts.ts +128 -0
- package/src/client/createGraph.ts +11 -2
- package/src/client/createTextDocument.ts +2 -1
- package/src/client/index.ts +14 -0
- package/src/client/textActions.ts +9 -2
- package/src/client/types.ts +4 -1
- package/src/crdt/GraphTextCRDT.ts +0 -5
- package/src/crdt/Rope.ts +155 -79
- package/src/operations/OperationTypes.ts +10 -8
- package/src/operations/apply/text.ts +8 -20
- package/test-coalescence.ts +201 -0
- package/tests/client/actions.test.ts +156 -0
- package/tests/client/coalesce-text-operations.test.ts +327 -0
- package/tests/client/edit-buffer.test.ts +137 -1
- package/tests/crdt/graph-text-crdt.test.ts +29 -17
- package/tests/crdt/rope.test.ts +13 -11
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text Insert Coalescence Utility
|
|
3
|
+
*
|
|
4
|
+
* Merges consecutive text insert operations to reduce network traffic.
|
|
5
|
+
* Uses CRDT comparison logic: seq → ts → id.
|
|
6
|
+
*
|
|
7
|
+
* Merge conditions:
|
|
8
|
+
* 1. Same agent (from ID)
|
|
9
|
+
* 2. Same target (key + path)
|
|
10
|
+
* 3. Sequential IDs (ID seq numbers are consecutive)
|
|
11
|
+
* 4. Within time threshold (ts difference)
|
|
12
|
+
* 5. Compatible YATA structure (forms a chain)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Operation } from '../../operations/OperationTypes.js';
|
|
16
|
+
import { parseItemId } from '../../crdt/Rope.js';
|
|
17
|
+
|
|
18
|
+
export interface TextInsertOp {
|
|
19
|
+
otype: 'text.insert';
|
|
20
|
+
key: string;
|
|
21
|
+
path: string;
|
|
22
|
+
id: string;
|
|
23
|
+
content: string;
|
|
24
|
+
parentId: string | null;
|
|
25
|
+
seq: number;
|
|
26
|
+
ts: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface CoalesceOptions {
|
|
30
|
+
/** Time threshold in milliseconds (default: 1000ms = 1 second) */
|
|
31
|
+
thresholdMs?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Check if an operation is a text insert with CRDT metadata
|
|
36
|
+
*/
|
|
37
|
+
export function isTextInsertOp(op: Operation): op is TextInsertOp {
|
|
38
|
+
return (
|
|
39
|
+
op.otype === 'text.insert' &&
|
|
40
|
+
typeof (op as any).id === 'string' &&
|
|
41
|
+
typeof (op as any).content === 'string' &&
|
|
42
|
+
typeof (op as any).seq === 'number' &&
|
|
43
|
+
typeof (op as any).ts === 'number'
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Coalesce consecutive text insert operations.
|
|
49
|
+
*
|
|
50
|
+
* Example:
|
|
51
|
+
* Input: [insert(id:"alice:1", "h"), insert(id:"alice:2", "e"), insert(id:"alice:3", "l")]
|
|
52
|
+
* Output: [insert(id:"alice:1", "hel")]
|
|
53
|
+
*
|
|
54
|
+
* @param ops - Array of text insert operations
|
|
55
|
+
* @param options - Coalescence options
|
|
56
|
+
* @returns New array with coalesced operations
|
|
57
|
+
*/
|
|
58
|
+
export function coalesceTextInserts(
|
|
59
|
+
ops: TextInsertOp[],
|
|
60
|
+
options: CoalesceOptions = {}
|
|
61
|
+
): TextInsertOp[] {
|
|
62
|
+
const { thresholdMs = 1000 } = options;
|
|
63
|
+
|
|
64
|
+
if (ops.length === 0) return ops;
|
|
65
|
+
|
|
66
|
+
const result: TextInsertOp[] = [];
|
|
67
|
+
let pending: TextInsertOp | null = null;
|
|
68
|
+
|
|
69
|
+
for (const op of ops) {
|
|
70
|
+
if (pending === null) {
|
|
71
|
+
// Start new pending insert
|
|
72
|
+
pending = { ...op };
|
|
73
|
+
} else {
|
|
74
|
+
// Parse IDs to extract agent and local sequence numbers
|
|
75
|
+
const prevId = parseItemId(pending.id);
|
|
76
|
+
const currId = parseItemId(op.id);
|
|
77
|
+
|
|
78
|
+
// Check merge conditions
|
|
79
|
+
const sameAgent = prevId.agent === currId.agent;
|
|
80
|
+
const sameTarget = pending.key === op.key && pending.path === op.path;
|
|
81
|
+
|
|
82
|
+
// IDs must be sequential: next ID = prev ID + prev content length
|
|
83
|
+
// Example: prev="alice:5" content="hel"(3 chars) → next="alice:8"
|
|
84
|
+
const sequentialIds = currId.seq === prevId.seq + pending.content.length;
|
|
85
|
+
|
|
86
|
+
// Time threshold: operations must be close in time (ts is in seconds)
|
|
87
|
+
const timeDiffMs = (op.ts - pending.ts) * 1000;
|
|
88
|
+
const withinThreshold = timeDiffMs <= thresholdMs;
|
|
89
|
+
|
|
90
|
+
// YATA structure: check if operations form a valid chain
|
|
91
|
+
// Current op's parent should be the last character ID in the merged content
|
|
92
|
+
// (not the first ID, which is what pending.id contains after merging)
|
|
93
|
+
// OR both should have the same parent (inserting at same position)
|
|
94
|
+
const prevLastId = (pending as any)._lastCharId || pending.id;
|
|
95
|
+
const formsChain =
|
|
96
|
+
op.parentId === prevLastId ||
|
|
97
|
+
(pending.parentId === op.parentId && pending.parentId !== null);
|
|
98
|
+
|
|
99
|
+
if (sameAgent && sameTarget && sequentialIds && withinThreshold && formsChain) {
|
|
100
|
+
// Merge operations
|
|
101
|
+
const merged: TextInsertOp = {
|
|
102
|
+
otype: 'text.insert',
|
|
103
|
+
key: pending.key,
|
|
104
|
+
path: pending.path,
|
|
105
|
+
id: pending.id, // Keep first ID (anchor point)
|
|
106
|
+
content: pending.content + op.content, // Concatenate content
|
|
107
|
+
parentId: pending.parentId, // Keep first parentId
|
|
108
|
+
seq: Math.max(pending.seq, op.seq), // Use max Lamport clock for ordering
|
|
109
|
+
ts: pending.ts, // Keep first timestamp (when sequence started)
|
|
110
|
+
// Track the last character ID for chain validation in next merge
|
|
111
|
+
_lastCharId: op.id,
|
|
112
|
+
} as any;
|
|
113
|
+
pending = merged;
|
|
114
|
+
} else {
|
|
115
|
+
// Can't merge - flush pending and start new
|
|
116
|
+
result.push(pending);
|
|
117
|
+
pending = { ...op };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Flush any remaining pending insert
|
|
123
|
+
if (pending !== null) {
|
|
124
|
+
result.push(pending);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
@@ -40,6 +40,7 @@ export function createGraph(options: CreateGraphOptions): GraphStore {
|
|
|
40
40
|
let coalescingTimer: ReturnType<typeof setTimeout> | null = null;
|
|
41
41
|
let coalescingEnabled = options.coalescingEnabled ?? false;
|
|
42
42
|
let coalescingDelayMs = options.coalescingDelayMs ?? 300;
|
|
43
|
+
let coalescingThresholdMs = options.coalescingThresholdMs ?? 1000;
|
|
43
44
|
|
|
44
45
|
function dispatch(fn: (s: ClientState) => ClientState): void {
|
|
45
46
|
state = fn(state);
|
|
@@ -59,7 +60,7 @@ export function createGraph(options: CreateGraphOptions): GraphStore {
|
|
|
59
60
|
coalescingTimer = setTimeout(() => {
|
|
60
61
|
coalescingTimer = null;
|
|
61
62
|
if (state.edits.ops.length > 0) {
|
|
62
|
-
const result = commitEdits(state, 'coalesced commit');
|
|
63
|
+
const result = commitEdits(state, 'coalesced commit', coalescingThresholdMs);
|
|
63
64
|
dispatch(() => result.state);
|
|
64
65
|
if (result.msg) {
|
|
65
66
|
options.onSend?.(result.msg);
|
|
@@ -92,7 +93,9 @@ export function createGraph(options: CreateGraphOptions): GraphStore {
|
|
|
92
93
|
// Clear pending coalescing timer when explicitly committing
|
|
93
94
|
clearCoalescingTimer();
|
|
94
95
|
|
|
95
|
-
|
|
96
|
+
// Pass threshold if coalescence is enabled
|
|
97
|
+
const threshold = coalescingEnabled ? coalescingThresholdMs : undefined;
|
|
98
|
+
const result = commitEdits(state, _description, threshold);
|
|
96
99
|
dispatch(() => result.state);
|
|
97
100
|
if (result.msg) {
|
|
98
101
|
options.onSend?.(result.msg);
|
|
@@ -113,10 +116,16 @@ export function createGraph(options: CreateGraphOptions): GraphStore {
|
|
|
113
116
|
coalescingDelayMs = delayMs;
|
|
114
117
|
},
|
|
115
118
|
|
|
119
|
+
setCoalescingThreshold: (thresholdMs: number) => {
|
|
120
|
+
coalescingThresholdMs = thresholdMs;
|
|
121
|
+
},
|
|
122
|
+
|
|
116
123
|
getCoalescingEnabled: () => coalescingEnabled,
|
|
117
124
|
|
|
118
125
|
getCoalescingDelay: () => coalescingDelayMs,
|
|
119
126
|
|
|
127
|
+
getCoalescingThreshold: () => coalescingThresholdMs,
|
|
128
|
+
|
|
120
129
|
// Server communication
|
|
121
130
|
receive: (msg: CRDTMessage) => {
|
|
122
131
|
dispatch(s => onRemoteMessage(s, msg));
|
|
@@ -62,7 +62,8 @@ export function createTextDocument(options: CreateTextDocumentOptions): TextDocu
|
|
|
62
62
|
coalescingTimer = setTimeout(() => {
|
|
63
63
|
coalescingTimer = null;
|
|
64
64
|
if (state.edits.ops.length > 0) {
|
|
65
|
-
|
|
65
|
+
// Pass coalescingDelayMs as threshold for operation merging
|
|
66
|
+
const result = commitTextEdits(state, 'coalesced commit', coalescingDelayMs);
|
|
66
67
|
dispatch(() => result.state);
|
|
67
68
|
if (result.msg) {
|
|
68
69
|
options.onSend?.(result.msg);
|
package/src/client/index.ts
CHANGED
|
@@ -39,6 +39,17 @@ export {
|
|
|
39
39
|
// Edit buffer utilities
|
|
40
40
|
export { isAdditiveOp, mergeValues, EditBufferImpl, coalesceTextOps } from './EditBuffer.js';
|
|
41
41
|
|
|
42
|
+
// Coalescence utilities
|
|
43
|
+
export { coalesceGraphOps } from './coalesceGraphOps.js';
|
|
44
|
+
export {
|
|
45
|
+
coalesceOperations,
|
|
46
|
+
registerCoalescer,
|
|
47
|
+
coalesceTextInserts,
|
|
48
|
+
coalesceTextDeletes,
|
|
49
|
+
type CoalesceHandler,
|
|
50
|
+
type TypeGuard,
|
|
51
|
+
} from './coalescence/index.js';
|
|
52
|
+
|
|
42
53
|
// Sound utilities
|
|
43
54
|
export { playWhoosh, createMessageSentSound } from './sounds.js';
|
|
44
55
|
|
|
@@ -80,3 +91,6 @@ export {
|
|
|
80
91
|
redoText,
|
|
81
92
|
initTextFromServer,
|
|
82
93
|
} from './textActions.js';
|
|
94
|
+
|
|
95
|
+
export { coalesceTextOperations, compareTextOps } from './coalesceTextOperations.js';
|
|
96
|
+
export type { CoalesceOptions } from './coalesceTextOperations.js';
|
|
@@ -24,6 +24,7 @@ import type {
|
|
|
24
24
|
TextSnapshot,
|
|
25
25
|
} from './textTypes.js';
|
|
26
26
|
import { VectorClockManager, type VectorClock } from '../state/VectorClock.js';
|
|
27
|
+
import { coalesceTextOperations } from './coalesceTextOperations.js';
|
|
27
28
|
|
|
28
29
|
const clockManager = new VectorClockManager();
|
|
29
30
|
|
|
@@ -95,12 +96,18 @@ export function onTextEdit(
|
|
|
95
96
|
*/
|
|
96
97
|
export function commitTextEdits(
|
|
97
98
|
state: TextDocumentState,
|
|
98
|
-
description?: string
|
|
99
|
+
description?: string,
|
|
100
|
+
coalescingThresholdMs?: number
|
|
99
101
|
): { state: TextDocumentState; msg: TextMessage | null } {
|
|
100
102
|
if (state.edits.ops.length === 0) {
|
|
101
103
|
return { state, msg: null };
|
|
102
104
|
}
|
|
103
105
|
|
|
106
|
+
// Coalesce operations before sending (if threshold provided)
|
|
107
|
+
const operations = coalescingThresholdMs !== undefined
|
|
108
|
+
? coalesceTextOperations(state.edits.ops, { thresholdMs: coalescingThresholdMs })
|
|
109
|
+
: state.edits.ops;
|
|
110
|
+
|
|
104
111
|
const msgId = `${state.sessionId}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
105
112
|
const vectorClock = clockManager.increment(state.vectorClock, state.sessionId);
|
|
106
113
|
const lamportTime = state.lamportTime + 1;
|
|
@@ -108,7 +115,7 @@ export function commitTextEdits(
|
|
|
108
115
|
const msg: TextMessage = {
|
|
109
116
|
msgId,
|
|
110
117
|
sessionId: state.sessionId,
|
|
111
|
-
operations
|
|
118
|
+
operations,
|
|
112
119
|
vectorClock,
|
|
113
120
|
lamportTime,
|
|
114
121
|
timestamp: Date.now(),
|
package/src/client/types.ts
CHANGED
|
@@ -70,7 +70,8 @@ export interface CreateGraphOptions {
|
|
|
70
70
|
onStateChange?: (state: ClientState) => void;
|
|
71
71
|
onMessageSent?: (msg: CRDTMessage) => void; // Called after each message sent (for sound, analytics, etc)
|
|
72
72
|
coalescingEnabled?: boolean;
|
|
73
|
-
coalescingDelayMs?: number;
|
|
73
|
+
coalescingDelayMs?: number; // Time to wait before auto-committing (batching delay)
|
|
74
|
+
coalescingThresholdMs?: number; // Time window for merging operations (merge threshold)
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
/**
|
|
@@ -101,6 +102,8 @@ export interface GraphStore {
|
|
|
101
102
|
// Coalescing control
|
|
102
103
|
setCoalescingEnabled: (enabled: boolean) => void;
|
|
103
104
|
setCoalescingDelay: (delayMs: number) => void;
|
|
105
|
+
setCoalescingThreshold: (thresholdMs: number) => void;
|
|
104
106
|
getCoalescingEnabled: () => boolean;
|
|
105
107
|
getCoalescingDelay: () => number;
|
|
108
|
+
getCoalescingThreshold: () => number;
|
|
106
109
|
}
|