@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
@@ -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';
@@ -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: state.edits.ops,
118
+ operations,
112
119
  vectorClock,
113
120
  lamportTime,
114
121
  timestamp: Date.now(),
@@ -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
  }
@@ -36,11 +36,6 @@ import {
36
36
  // Types
37
37
  // ============================================
38
38
 
39
- export interface ItemId {
40
- agent: string;
41
- seq: number;
42
- }
43
-
44
39
  /**
45
40
  * Graph-level CRDT coordinator
46
41
  *