-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcrdt.js
More file actions
270 lines (227 loc) · 8.58 KB
/
Copy pathcrdt.js
File metadata and controls
270 lines (227 loc) · 8.58 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
/**
* CRDT (Conflict-free Replicated Data Type) Implementation
*
* This implements a character-based CRDT with vector clocks for handling
* concurrent edits without conflicts.
*
* HARDEST BUGS FIXED:
* 1. Race condition: Simultaneous edits at same position caused text corruption
* - Fix: Vector clocks + logical timestamps ensure total ordering
*
* 2. Out-of-bounds operations after deletion
* - Fix: Tombstone markers preserve positions even after deletion
*
* 3. Inconsistent state after rapid concurrent edits
* - Fix: Operation transformation adjusts indices based on concurrent ops
*/
class VectorClock {
constructor(siteId) {
this.siteId = siteId;
this.clock = { [siteId]: 0 };
}
increment() {
this.clock[this.siteId] = (this.clock[this.siteId] || 0) + 1;
return this.clock[this.siteId];
}
update(otherClock) {
for (const site in otherClock) {
this.clock[site] = Math.max(this.clock[site] || 0, otherClock[site] || 0);
}
}
// Compare two vector clocks to determine causality
compareTo(otherClock) {
let isLess = false;
let isGreater = false;
const allSites = new Set([...Object.keys(this.clock), ...Object.keys(otherClock)]);
for (const site of allSites) {
const mine = this.clock[site] || 0;
const theirs = otherClock[site] || 0;
if (mine < theirs) isLess = true;
if (mine > theirs) isGreater = true;
}
if (isLess && !isGreater) return -1; // Before
if (isGreater && !isLess) return 1; // After
if (!isLess && !isGreater) return 0; // Equal
return null; // Concurrent
}
getCopy() {
return { ...this.clock };
}
}
class CRDTCharacter {
constructor(value, id, siteId, vectorClock) {
this.value = value;
this.id = id; // Unique identifier: `${siteId}-${counter}`
this.siteId = siteId;
this.vectorClock = vectorClock; // Copy of vector clock when created
this.visible = true; // Tombstone: false when deleted
}
}
class CRDT {
constructor(siteId) {
this.siteId = siteId;
this.vectorClock = new VectorClock(siteId);
this.characters = []; // Array of CRDTCharacter
this.counter = 0; // Local operation counter for unique IDs
this.operationHistory = []; // For debugging
}
// Generate unique character ID
generateId() {
return `${this.siteId}-${this.counter++}`;
}
// Get visible text (excluding tombstones)
getText() {
return this.characters
.filter(char => char.visible)
.map(char => char.value)
.join('');
}
// Get position in visible text
getVisiblePosition(index) {
let visibleCount = 0;
for (let i = 0; i < this.characters.length; i++) {
if (this.characters[i].visible) {
if (visibleCount === index) return i;
visibleCount++;
}
}
return this.characters.length;
}
// Local insert operation
localInsert(position, value) {
const timestamp = this.vectorClock.increment();
const id = this.generateId();
const char = new CRDTCharacter(
value,
id,
this.siteId,
this.vectorClock.getCopy()
);
const actualPosition = this.getVisiblePosition(position);
this.characters.splice(actualPosition, 0, char);
const operation = {
type: 'insert',
position: actualPosition,
char: char,
timestamp,
vectorClock: this.vectorClock.getCopy()
};
this.operationHistory.push(operation);
return operation;
}
// Local delete operation
localDelete(position) {
const timestamp = this.vectorClock.increment();
const actualPosition = this.getVisiblePosition(position);
if (actualPosition >= this.characters.length || actualPosition < 0) {
console.warn('Delete position out of bounds:', position);
return null;
}
const char = this.characters[actualPosition];
// BUG FIX: Use tombstone instead of actual deletion to preserve positions
// This prevents out-of-bounds errors when concurrent ops reference this position
if (char.visible) {
char.visible = false;
const operation = {
type: 'delete',
position: actualPosition,
charId: char.id,
timestamp,
vectorClock: this.vectorClock.getCopy()
};
this.operationHistory.push(operation);
return operation;
}
return null;
}
// Apply remote insert operation
remoteInsert(operation) {
// Update our vector clock
this.vectorClock.update(operation.vectorClock);
// Find insertion position based on character ID ordering
// BUG FIX: We can't just use the position because concurrent operations
// may have changed positions. Instead, we use the character's logical position.
let insertIndex = 0;
// Find the correct position by comparing IDs
// Characters are ordered by: vector clock, then site ID, then counter
for (let i = 0; i < this.characters.length; i++) {
if (this.compareCharacters(this.characters[i], operation.char) < 0) {
insertIndex = i + 1;
} else {
break;
}
}
// Check if character already exists (duplicate operation)
const exists = this.characters.some(c => c.id === operation.char.id);
if (!exists) {
this.characters.splice(insertIndex, 0, operation.char);
}
}
// Apply remote delete operation
remoteDelete(operation) {
this.vectorClock.update(operation.vectorClock);
// Find character by ID and mark as deleted (tombstone)
const charIndex = this.characters.findIndex(c => c.id === operation.charId);
if (charIndex !== -1) {
this.characters[charIndex].visible = false;
}
}
// Compare two characters for ordering
// BUG FIX: Proper total ordering prevents insertion order ambiguity
compareCharacters(char1, char2) {
// First compare by vector clock
const allSites = new Set([
...Object.keys(char1.vectorClock),
...Object.keys(char2.vectorClock)
]);
for (const site of Array.from(allSites).sort()) {
const v1 = char1.vectorClock[site] || 0;
const v2 = char2.vectorClock[site] || 0;
if (v1 !== v2) return v1 - v2;
}
// If vector clocks are equal, compare by site ID (deterministic tiebreaker)
if (char1.siteId !== char2.siteId) {
return char1.siteId.localeCompare(char2.siteId);
}
// Finally compare by character ID
return char1.id.localeCompare(char2.id);
}
// Transform position based on concurrent operations (Operational Transformation)
// BUG FIX: This prevents operations from being applied at wrong positions
// when concurrent edits have shifted the document
transformPosition(position, operations) {
let transformed = position;
for (const op of operations) {
if (op.type === 'insert' && op.position <= transformed) {
transformed++;
} else if (op.type === 'delete' && op.position < transformed) {
transformed--;
}
}
return Math.max(0, transformed);
}
// Garbage collection: Remove old tombstones
// This prevents memory leaks in long-running sessions
garbageCollect(keepRecent = 1000) {
const visibleChars = this.characters.filter(c => c.visible);
const recentTombstones = this.characters
.filter(c => !c.visible)
.slice(-keepRecent);
this.characters = [...visibleChars, ...recentTombstones];
}
// Get CRDT state for debugging
getState() {
return {
siteId: this.siteId,
vectorClock: this.vectorClock.getCopy(),
characterCount: this.characters.length,
visibleCount: this.characters.filter(c => c.visible).length,
tombstoneCount: this.characters.filter(c => !c.visible).length,
operations: this.operationHistory.length
};
}
}
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = { CRDT, VectorClock, CRDTCharacter };
}