@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
package/src/client/EditBuffer.ts
CHANGED
|
@@ -81,7 +81,7 @@ export function isPositionTextDeleteOp(op: Operation): op is PositionTextDeleteO
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
/**
|
|
84
|
-
* Coalesce consecutive text operations
|
|
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',
|
package/src/client/actions.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
281
|
-
const coalescedOps =
|
|
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
|
+
}
|