@vuer-ai/vuer-rtc-server 0.2.0 → 0.2.2
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/.env +1 -0
- package/S3_COMPRESSION_GUIDE.md +233 -0
- package/dist/archive/ArchivalService.d.ts +117 -0
- package/dist/archive/ArchivalService.d.ts.map +1 -0
- package/dist/archive/ArchivalService.js +181 -0
- package/dist/archive/ArchivalService.js.map +1 -0
- package/dist/broker/InMemoryBroker.d.ts +2 -0
- package/dist/broker/InMemoryBroker.d.ts.map +1 -1
- package/dist/broker/InMemoryBroker.js +4 -0
- package/dist/broker/InMemoryBroker.js.map +1 -1
- package/dist/compression/CompressionUtils.d.ts +57 -0
- package/dist/compression/CompressionUtils.d.ts.map +1 -0
- package/dist/compression/CompressionUtils.js +90 -0
- package/dist/compression/CompressionUtils.js.map +1 -0
- package/dist/compression/index.d.ts +7 -0
- package/dist/compression/index.d.ts.map +1 -0
- package/dist/compression/index.js +7 -0
- package/dist/compression/index.js.map +1 -0
- package/dist/journal/CoalescingService.d.ts +63 -0
- package/dist/journal/CoalescingService.d.ts.map +1 -0
- package/dist/journal/CoalescingService.js +507 -0
- package/dist/journal/CoalescingService.js.map +1 -0
- package/dist/journal/JournalRLE.d.ts +81 -0
- package/dist/journal/JournalRLE.d.ts.map +1 -0
- package/dist/journal/JournalRLE.js +199 -0
- package/dist/journal/JournalRLE.js.map +1 -0
- package/dist/journal/JournalService.d.ts +7 -3
- package/dist/journal/JournalService.d.ts.map +1 -1
- package/dist/journal/JournalService.js +152 -12
- package/dist/journal/JournalService.js.map +1 -1
- package/dist/journal/RLECompression.d.ts +73 -0
- package/dist/journal/RLECompression.d.ts.map +1 -0
- package/dist/journal/RLECompression.js +152 -0
- package/dist/journal/RLECompression.js.map +1 -0
- package/dist/journal/rle-demo.d.ts +8 -0
- package/dist/journal/rle-demo.d.ts.map +1 -0
- package/dist/journal/rle-demo.js +159 -0
- package/dist/journal/rle-demo.js.map +1 -0
- package/dist/persistence/S3ColdStorage.d.ts +62 -0
- package/dist/persistence/S3ColdStorage.d.ts.map +1 -0
- package/dist/persistence/S3ColdStorage.js +88 -0
- package/dist/persistence/S3ColdStorage.js.map +1 -0
- package/dist/persistence/S3ColdStorageIntegration.d.ts +78 -0
- package/dist/persistence/S3ColdStorageIntegration.d.ts.map +1 -0
- package/dist/persistence/S3ColdStorageIntegration.js +93 -0
- package/dist/persistence/S3ColdStorageIntegration.js.map +1 -0
- package/dist/serve.d.ts +2 -0
- package/dist/serve.d.ts.map +1 -1
- package/dist/serve.js +623 -15
- package/dist/serve.js.map +1 -1
- package/docs/RLE_COMPRESSION.md +397 -0
- package/examples/compression-example.ts +259 -0
- package/package.json +14 -14
- package/src/archive/ArchivalService.ts +250 -0
- package/src/broker/InMemoryBroker.ts +5 -0
- package/src/compression/CompressionUtils.ts +113 -0
- package/src/compression/index.ts +14 -0
- package/src/journal/COALESCING.md +267 -0
- package/src/journal/CoalescingService.ts +626 -0
- package/src/journal/JournalRLE.ts +265 -0
- package/src/journal/JournalService.ts +163 -11
- package/src/journal/RLECompression.ts +210 -0
- package/src/journal/rle-demo.ts +193 -0
- package/src/serve.ts +702 -15
- package/tests/benchmark/journal-optimization-benchmark.test.ts +482 -0
- package/tests/compression/compression.test.ts +343 -0
- package/tests/integration/repositories.test.ts +89 -0
- package/tests/journal/compaction-load-bug.test.ts +409 -0
- package/tests/journal/compaction.test.ts +42 -2
- package/tests/journal/journal-rle.test.ts +511 -0
- package/tests/journal/lww-ordering-bug.test.ts +248 -0
- package/tests/journal/multi-session-coalescing.test.ts +871 -0
- package/tests/journal/rle-compression.test.ts +526 -0
- package/tests/journal/text-coalescing.test.ts +210 -0
- package/tests/unit/s3-compression.test.ts +257 -0
- package/PHASE1_SUMMARY.md +0 -94
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CoalescingService - Merges compatible operations in journal batches
|
|
3
|
+
*
|
|
4
|
+
* Reduces journal size by:
|
|
5
|
+
* - Merging consecutive text.insert operations into multi-char inserts
|
|
6
|
+
* - Merging set operations on same (key, path) within time threshold
|
|
7
|
+
* - Merging additive operations (vector3.add, number.add)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { PrismaClient } from '@prisma/client';
|
|
11
|
+
import type { CRDTMessage, Operation } from '@vuer-ai/vuer-rtc';
|
|
12
|
+
|
|
13
|
+
// Default time threshold for set operation coalescing (ms)
|
|
14
|
+
const DEFAULT_SET_THRESHOLD_MS = 5000;
|
|
15
|
+
const DEFAULT_SAMPLING_INTERVAL_MS = 100;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Coalescing strategy for set operations
|
|
19
|
+
*/
|
|
20
|
+
export type SetCoalescingStrategy =
|
|
21
|
+
| 'last-write' // Keep only the last write within threshold (current behavior)
|
|
22
|
+
| 'throttle'; // Sample at regular intervals
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Per-property coalescing configuration
|
|
26
|
+
*/
|
|
27
|
+
export interface PropertyCoalescingConfig {
|
|
28
|
+
/** Time window for coalescing (ms) */
|
|
29
|
+
thresholdMs?: number;
|
|
30
|
+
/** Coalescing strategy */
|
|
31
|
+
strategy?: SetCoalescingStrategy;
|
|
32
|
+
/** Sampling interval for throttle strategy (ms) */
|
|
33
|
+
samplingIntervalMs?: number;
|
|
34
|
+
/** Delta threshold - only coalesce if value change is below this (for numeric values) */
|
|
35
|
+
deltaThreshold?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CoalescingConfig {
|
|
39
|
+
/** Default time threshold for set operations (ms) */
|
|
40
|
+
setThresholdMs?: number;
|
|
41
|
+
/** Default coalescing strategy for set operations */
|
|
42
|
+
setStrategy?: SetCoalescingStrategy;
|
|
43
|
+
/** Default sampling interval for throttle strategy (ms) */
|
|
44
|
+
samplingIntervalMs?: number;
|
|
45
|
+
/** Per-property overrides (key format: "key.path" or "*.path" for all keys) */
|
|
46
|
+
propertyConfig?: Record<string, PropertyCoalescingConfig>;
|
|
47
|
+
|
|
48
|
+
// Feature toggles
|
|
49
|
+
enableTextCoalesce?: boolean;
|
|
50
|
+
enableSetCoalesce?: boolean;
|
|
51
|
+
enableVectorCoalesce?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface CoalescingResult {
|
|
55
|
+
ok: boolean;
|
|
56
|
+
before: { journalBatches: number; operations: number };
|
|
57
|
+
after: { journalBatches: number; operations: number };
|
|
58
|
+
reduction: { journalBatches: number; operations: number };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Type for position-based text insert
|
|
63
|
+
*/
|
|
64
|
+
interface PositionTextInsertOp {
|
|
65
|
+
otype: 'text.insert';
|
|
66
|
+
key: string;
|
|
67
|
+
path: string;
|
|
68
|
+
position: number;
|
|
69
|
+
value: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Type for position-based text delete
|
|
74
|
+
*/
|
|
75
|
+
interface PositionTextDeleteOp {
|
|
76
|
+
otype: 'text.delete';
|
|
77
|
+
key: string;
|
|
78
|
+
path: string;
|
|
79
|
+
position: number;
|
|
80
|
+
length: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Operation with embedded timestamp for coalescing
|
|
85
|
+
*/
|
|
86
|
+
interface TimestampedOp {
|
|
87
|
+
op: Operation;
|
|
88
|
+
timestamp: number; // ms
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Check if an operation is a position-based text insert
|
|
93
|
+
*/
|
|
94
|
+
function isPositionTextInsertOp(op: Operation): op is PositionTextInsertOp {
|
|
95
|
+
return op.otype === 'text.insert' &&
|
|
96
|
+
typeof (op as any).position === 'number' &&
|
|
97
|
+
typeof (op as any).value === 'string';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Check if an operation is a position-based text delete
|
|
102
|
+
*/
|
|
103
|
+
function isPositionTextDeleteOp(op: Operation): op is PositionTextDeleteOp {
|
|
104
|
+
return op.otype === 'text.delete' &&
|
|
105
|
+
typeof (op as any).position === 'number' &&
|
|
106
|
+
typeof (op as any).length === 'number';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Check if an operation is a set operation
|
|
111
|
+
*/
|
|
112
|
+
function isSetOp(op: Operation): boolean {
|
|
113
|
+
return op.otype.endsWith('.set');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Check if an operation is an additive operation
|
|
118
|
+
* Note: number.multiply is NOT included because multiplying multiple
|
|
119
|
+
* factors together gives a different result than applying them sequentially
|
|
120
|
+
*/
|
|
121
|
+
function isAdditiveOp(op: Operation): boolean {
|
|
122
|
+
return op.otype === 'vector3.add' ||
|
|
123
|
+
op.otype === 'number.add';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Coalesce consecutive text inserts and deletes (works with timestamped ops)
|
|
128
|
+
*
|
|
129
|
+
* Insert coalescing: Consecutive inserts at adjacent positions
|
|
130
|
+
* insert(10, "a"), insert(11, "b"), insert(12, "c") → insert(10, "abc")
|
|
131
|
+
*
|
|
132
|
+
* Delete coalescing:
|
|
133
|
+
* - Forward (Delete key): Same position
|
|
134
|
+
* del(10, 1), del(10, 1), del(10, 1) → del(10, 3)
|
|
135
|
+
* - Backward (Backspace): Position moves left by length
|
|
136
|
+
* del(10, 1), del(9, 1), del(8, 1) → del(8, 3)
|
|
137
|
+
*/
|
|
138
|
+
function coalesceTextOps(tOps: TimestampedOp[]): TimestampedOp[] {
|
|
139
|
+
if (tOps.length === 0) return tOps;
|
|
140
|
+
|
|
141
|
+
const result: TimestampedOp[] = [];
|
|
142
|
+
let pendingInsert: { op: PositionTextInsertOp; timestamp: number } | null = null;
|
|
143
|
+
let pendingDelete: { op: PositionTextDeleteOp; timestamp: number } | null = null;
|
|
144
|
+
|
|
145
|
+
for (const tOp of tOps) {
|
|
146
|
+
const op = tOp.op;
|
|
147
|
+
|
|
148
|
+
if (isPositionTextInsertOp(op)) {
|
|
149
|
+
// Flush any pending delete
|
|
150
|
+
if (pendingDelete !== null) {
|
|
151
|
+
result.push({ op: pendingDelete.op as unknown as Operation, timestamp: pendingDelete.timestamp });
|
|
152
|
+
pendingDelete = null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Coalesce inserts
|
|
156
|
+
if (pendingInsert === null) {
|
|
157
|
+
pendingInsert = {
|
|
158
|
+
op: {
|
|
159
|
+
otype: 'text.insert',
|
|
160
|
+
key: op.key,
|
|
161
|
+
path: op.path,
|
|
162
|
+
position: op.position,
|
|
163
|
+
value: op.value,
|
|
164
|
+
},
|
|
165
|
+
timestamp: tOp.timestamp,
|
|
166
|
+
};
|
|
167
|
+
} else if (
|
|
168
|
+
pendingInsert.op.key === op.key &&
|
|
169
|
+
pendingInsert.op.path === op.path &&
|
|
170
|
+
pendingInsert.op.position + pendingInsert.op.value.length === op.position
|
|
171
|
+
) {
|
|
172
|
+
// Consecutive insert - merge
|
|
173
|
+
pendingInsert = {
|
|
174
|
+
op: {
|
|
175
|
+
otype: 'text.insert',
|
|
176
|
+
key: pendingInsert.op.key,
|
|
177
|
+
path: pendingInsert.op.path,
|
|
178
|
+
position: pendingInsert.op.position,
|
|
179
|
+
value: pendingInsert.op.value + op.value,
|
|
180
|
+
},
|
|
181
|
+
timestamp: tOp.timestamp,
|
|
182
|
+
};
|
|
183
|
+
} else {
|
|
184
|
+
// Non-consecutive - flush and start new
|
|
185
|
+
result.push({ op: pendingInsert.op as unknown as Operation, timestamp: pendingInsert.timestamp });
|
|
186
|
+
pendingInsert = {
|
|
187
|
+
op: {
|
|
188
|
+
otype: 'text.insert',
|
|
189
|
+
key: op.key,
|
|
190
|
+
path: op.path,
|
|
191
|
+
position: op.position,
|
|
192
|
+
value: op.value,
|
|
193
|
+
},
|
|
194
|
+
timestamp: tOp.timestamp,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
} else if (isPositionTextDeleteOp(op)) {
|
|
198
|
+
// Flush any pending insert
|
|
199
|
+
if (pendingInsert !== null) {
|
|
200
|
+
result.push({ op: pendingInsert.op as unknown as Operation, timestamp: pendingInsert.timestamp });
|
|
201
|
+
pendingInsert = null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Coalesce deletes
|
|
205
|
+
if (pendingDelete === null) {
|
|
206
|
+
pendingDelete = {
|
|
207
|
+
op: {
|
|
208
|
+
otype: 'text.delete',
|
|
209
|
+
key: op.key,
|
|
210
|
+
path: op.path,
|
|
211
|
+
position: op.position,
|
|
212
|
+
length: op.length,
|
|
213
|
+
},
|
|
214
|
+
timestamp: tOp.timestamp,
|
|
215
|
+
};
|
|
216
|
+
} else if (
|
|
217
|
+
pendingDelete.op.key === op.key &&
|
|
218
|
+
pendingDelete.op.path === op.path
|
|
219
|
+
) {
|
|
220
|
+
// Check if this is forward deletion (same position) or backward deletion (position moves left)
|
|
221
|
+
const isForwardDelete = pendingDelete.op.position === op.position;
|
|
222
|
+
const isBackwardDelete = op.position + op.length === pendingDelete.op.position;
|
|
223
|
+
|
|
224
|
+
if (isForwardDelete) {
|
|
225
|
+
// Forward delete: accumulate lengths at same position
|
|
226
|
+
pendingDelete = {
|
|
227
|
+
op: {
|
|
228
|
+
otype: 'text.delete',
|
|
229
|
+
key: pendingDelete.op.key,
|
|
230
|
+
path: pendingDelete.op.path,
|
|
231
|
+
position: pendingDelete.op.position,
|
|
232
|
+
length: pendingDelete.op.length + op.length,
|
|
233
|
+
},
|
|
234
|
+
timestamp: tOp.timestamp,
|
|
235
|
+
};
|
|
236
|
+
} else if (isBackwardDelete) {
|
|
237
|
+
// Backward delete: position moves left, accumulate lengths
|
|
238
|
+
pendingDelete = {
|
|
239
|
+
op: {
|
|
240
|
+
otype: 'text.delete',
|
|
241
|
+
key: pendingDelete.op.key,
|
|
242
|
+
path: pendingDelete.op.path,
|
|
243
|
+
position: op.position, // use new (leftmost) position
|
|
244
|
+
length: pendingDelete.op.length + op.length,
|
|
245
|
+
},
|
|
246
|
+
timestamp: tOp.timestamp,
|
|
247
|
+
};
|
|
248
|
+
} else {
|
|
249
|
+
// Non-consecutive - flush and start new
|
|
250
|
+
result.push({ op: pendingDelete.op as unknown as Operation, timestamp: pendingDelete.timestamp });
|
|
251
|
+
pendingDelete = {
|
|
252
|
+
op: {
|
|
253
|
+
otype: 'text.delete',
|
|
254
|
+
key: op.key,
|
|
255
|
+
path: op.path,
|
|
256
|
+
position: op.position,
|
|
257
|
+
length: op.length,
|
|
258
|
+
},
|
|
259
|
+
timestamp: tOp.timestamp,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
} else {
|
|
263
|
+
// Different key/path - flush and start new
|
|
264
|
+
result.push({ op: pendingDelete.op as unknown as Operation, timestamp: pendingDelete.timestamp });
|
|
265
|
+
pendingDelete = {
|
|
266
|
+
op: {
|
|
267
|
+
otype: 'text.delete',
|
|
268
|
+
key: op.key,
|
|
269
|
+
path: op.path,
|
|
270
|
+
position: op.position,
|
|
271
|
+
length: op.length,
|
|
272
|
+
},
|
|
273
|
+
timestamp: tOp.timestamp,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
} else {
|
|
277
|
+
// Other operation type - flush pending
|
|
278
|
+
if (pendingInsert !== null) {
|
|
279
|
+
result.push({ op: pendingInsert.op as unknown as Operation, timestamp: pendingInsert.timestamp });
|
|
280
|
+
pendingInsert = null;
|
|
281
|
+
}
|
|
282
|
+
if (pendingDelete !== null) {
|
|
283
|
+
result.push({ op: pendingDelete.op as unknown as Operation, timestamp: pendingDelete.timestamp });
|
|
284
|
+
pendingDelete = null;
|
|
285
|
+
}
|
|
286
|
+
result.push(tOp);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Flush any remaining pending ops
|
|
291
|
+
if (pendingInsert !== null) {
|
|
292
|
+
result.push({ op: pendingInsert.op as unknown as Operation, timestamp: pendingInsert.timestamp });
|
|
293
|
+
}
|
|
294
|
+
if (pendingDelete !== null) {
|
|
295
|
+
result.push({ op: pendingDelete.op as unknown as Operation, timestamp: pendingDelete.timestamp });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return result;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Coalesce set operations with configurable strategy
|
|
303
|
+
* Uses embedded timestamps in TimestampedOp for correct tracking
|
|
304
|
+
*
|
|
305
|
+
* Strategies:
|
|
306
|
+
* - 'last-write': Keep only the last write within threshold (default)
|
|
307
|
+
* - 'throttle': Sample at regular intervals (samplingIntervalMs)
|
|
308
|
+
*/
|
|
309
|
+
function coalesceSetOps(
|
|
310
|
+
tOps: TimestampedOp[],
|
|
311
|
+
config: CoalescingConfig
|
|
312
|
+
): TimestampedOp[] {
|
|
313
|
+
if (tOps.length === 0) return tOps;
|
|
314
|
+
|
|
315
|
+
const thresholdMs = config.setThresholdMs ?? DEFAULT_SET_THRESHOLD_MS;
|
|
316
|
+
const strategy = config.setStrategy ?? 'last-write';
|
|
317
|
+
const samplingIntervalMs = config.samplingIntervalMs ?? DEFAULT_SAMPLING_INTERVAL_MS;
|
|
318
|
+
|
|
319
|
+
if (strategy === 'last-write') {
|
|
320
|
+
return coalesceSetOpsLastWrite(tOps, thresholdMs);
|
|
321
|
+
} else if (strategy === 'throttle') {
|
|
322
|
+
return coalesceSetOpsThrottle(tOps, thresholdMs, samplingIntervalMs);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return tOps;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Last-write strategy: Keep only the last write within threshold
|
|
330
|
+
*/
|
|
331
|
+
function coalesceSetOpsLastWrite(tOps: TimestampedOp[], thresholdMs: number): TimestampedOp[] {
|
|
332
|
+
const result: TimestampedOp[] = [];
|
|
333
|
+
const lastSetByKey = new Map<string, { tOp: TimestampedOp; resultIndex: number }>();
|
|
334
|
+
|
|
335
|
+
for (const tOp of tOps) {
|
|
336
|
+
const op = tOp.op;
|
|
337
|
+
const timestamp = tOp.timestamp;
|
|
338
|
+
|
|
339
|
+
if (isSetOp(op)) {
|
|
340
|
+
const key = `${(op as any).key}:${(op as any).path}`;
|
|
341
|
+
const existing = lastSetByKey.get(key);
|
|
342
|
+
|
|
343
|
+
if (existing && (timestamp - existing.tOp.timestamp) <= thresholdMs) {
|
|
344
|
+
// Within threshold - replace existing with new
|
|
345
|
+
result[existing.resultIndex] = tOp;
|
|
346
|
+
lastSetByKey.set(key, { tOp, resultIndex: existing.resultIndex });
|
|
347
|
+
} else {
|
|
348
|
+
// New set or outside threshold
|
|
349
|
+
const resultIndex = result.length;
|
|
350
|
+
result.push(tOp);
|
|
351
|
+
lastSetByKey.set(key, { tOp, resultIndex });
|
|
352
|
+
}
|
|
353
|
+
} else {
|
|
354
|
+
result.push(tOp);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return result;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Throttle strategy: Sample at regular intervals
|
|
363
|
+
* Keep one sample per interval, dropping intermediate updates
|
|
364
|
+
*/
|
|
365
|
+
function coalesceSetOpsThrottle(
|
|
366
|
+
tOps: TimestampedOp[],
|
|
367
|
+
thresholdMs: number,
|
|
368
|
+
samplingIntervalMs: number
|
|
369
|
+
): TimestampedOp[] {
|
|
370
|
+
const result: TimestampedOp[] = [];
|
|
371
|
+
const lastSampleByKey = new Map<string, { tOp: TimestampedOp; resultIndex: number; intervalStart: number }>();
|
|
372
|
+
|
|
373
|
+
for (const tOp of tOps) {
|
|
374
|
+
const op = tOp.op;
|
|
375
|
+
const timestamp = tOp.timestamp;
|
|
376
|
+
|
|
377
|
+
if (isSetOp(op)) {
|
|
378
|
+
const key = `${(op as any).key}:${(op as any).path}`;
|
|
379
|
+
const existing = lastSampleByKey.get(key);
|
|
380
|
+
|
|
381
|
+
if (!existing) {
|
|
382
|
+
// First sample for this key
|
|
383
|
+
const resultIndex = result.length;
|
|
384
|
+
result.push(tOp);
|
|
385
|
+
lastSampleByKey.set(key, {
|
|
386
|
+
tOp,
|
|
387
|
+
resultIndex,
|
|
388
|
+
intervalStart: Math.floor(timestamp / samplingIntervalMs) * samplingIntervalMs,
|
|
389
|
+
});
|
|
390
|
+
} else {
|
|
391
|
+
const currentInterval = Math.floor(timestamp / samplingIntervalMs) * samplingIntervalMs;
|
|
392
|
+
const timeSinceFirstSample = timestamp - existing.intervalStart;
|
|
393
|
+
|
|
394
|
+
if (currentInterval === existing.intervalStart) {
|
|
395
|
+
// Same interval - replace with latest
|
|
396
|
+
result[existing.resultIndex] = tOp;
|
|
397
|
+
lastSampleByKey.set(key, { ...existing, tOp });
|
|
398
|
+
} else if (timeSinceFirstSample <= thresholdMs) {
|
|
399
|
+
// New interval, still within threshold - add new sample
|
|
400
|
+
const resultIndex = result.length;
|
|
401
|
+
result.push(tOp);
|
|
402
|
+
lastSampleByKey.set(key, { tOp, resultIndex, intervalStart: currentInterval });
|
|
403
|
+
} else {
|
|
404
|
+
// Outside threshold - reset
|
|
405
|
+
const resultIndex = result.length;
|
|
406
|
+
result.push(tOp);
|
|
407
|
+
lastSampleByKey.set(key, { tOp, resultIndex, intervalStart: currentInterval });
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
} else {
|
|
411
|
+
result.push(tOp);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return result;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Coalesce additive operations (works with timestamped ops)
|
|
420
|
+
*/
|
|
421
|
+
function coalesceAdditiveOps(tOps: TimestampedOp[]): TimestampedOp[] {
|
|
422
|
+
if (tOps.length === 0) return tOps;
|
|
423
|
+
|
|
424
|
+
const result: TimestampedOp[] = [];
|
|
425
|
+
const pendingByKey = new Map<string, TimestampedOp>();
|
|
426
|
+
|
|
427
|
+
for (const tOp of tOps) {
|
|
428
|
+
const op = tOp.op;
|
|
429
|
+
if (isAdditiveOp(op)) {
|
|
430
|
+
const key = `${(op as any).key}:${(op as any).path}:${op.otype}`;
|
|
431
|
+
const existing = pendingByKey.get(key);
|
|
432
|
+
|
|
433
|
+
if (existing && existing.op.otype === op.otype) {
|
|
434
|
+
// Merge values
|
|
435
|
+
if (op.otype === 'vector3.add') {
|
|
436
|
+
const va = (existing.op as any).value as [number, number, number];
|
|
437
|
+
const vb = (op as any).value as [number, number, number];
|
|
438
|
+
(existing.op as any).value = [va[0] + vb[0], va[1] + vb[1], va[2] + vb[2]];
|
|
439
|
+
existing.timestamp = tOp.timestamp; // update to latest timestamp
|
|
440
|
+
} else if (op.otype === 'number.add') {
|
|
441
|
+
(existing.op as any).value = (existing.op as any).value + (op as any).value;
|
|
442
|
+
existing.timestamp = tOp.timestamp;
|
|
443
|
+
}
|
|
444
|
+
} else {
|
|
445
|
+
if (existing) {
|
|
446
|
+
result.push(existing);
|
|
447
|
+
}
|
|
448
|
+
pendingByKey.set(key, { op: { ...op }, timestamp: tOp.timestamp });
|
|
449
|
+
}
|
|
450
|
+
} else {
|
|
451
|
+
// Flush all pending additive ops
|
|
452
|
+
for (const pending of pendingByKey.values()) {
|
|
453
|
+
result.push(pending);
|
|
454
|
+
}
|
|
455
|
+
pendingByKey.clear();
|
|
456
|
+
result.push(tOp);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Flush remaining
|
|
461
|
+
for (const pending of pendingByKey.values()) {
|
|
462
|
+
result.push(pending);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return result;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Coalesce operations in a message array
|
|
470
|
+
*
|
|
471
|
+
* IMPORTANT: Preserves interleaved order to maintain causality.
|
|
472
|
+
* Groups messages into consecutive runs from the same session, then coalesces
|
|
473
|
+
* each run separately. This ensures Lamport's "happened-before" relation is preserved.
|
|
474
|
+
*
|
|
475
|
+
* Example:
|
|
476
|
+
* Input: [alice-msg1, alice-msg2, bob-msg3, alice-msg4]
|
|
477
|
+
* Output: [alice-run1, bob-run2, alice-run3]
|
|
478
|
+
*
|
|
479
|
+
* This prevents the bug where all operations from a session get the last message's
|
|
480
|
+
* lamportTime, which breaks LWW conflict resolution.
|
|
481
|
+
*/
|
|
482
|
+
function coalesceOperations(
|
|
483
|
+
messages: CRDTMessage[],
|
|
484
|
+
config: CoalescingConfig
|
|
485
|
+
): CRDTMessage[] {
|
|
486
|
+
if (messages.length === 0) return [];
|
|
487
|
+
|
|
488
|
+
// Group messages into consecutive runs from the same session
|
|
489
|
+
const runs: CRDTMessage[][] = [];
|
|
490
|
+
let currentRun: CRDTMessage[] = [];
|
|
491
|
+
let lastSessionId: string | null = null;
|
|
492
|
+
|
|
493
|
+
for (const msg of messages) {
|
|
494
|
+
if (msg.sessionId !== lastSessionId && currentRun.length > 0) {
|
|
495
|
+
// Session switch - emit current run and start new one
|
|
496
|
+
runs.push(currentRun);
|
|
497
|
+
currentRun = [];
|
|
498
|
+
}
|
|
499
|
+
currentRun.push(msg);
|
|
500
|
+
lastSessionId = msg.sessionId;
|
|
501
|
+
}
|
|
502
|
+
if (currentRun.length > 0) {
|
|
503
|
+
runs.push(currentRun);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Coalesce each run separately
|
|
507
|
+
const coalescedMessages: CRDTMessage[] = [];
|
|
508
|
+
|
|
509
|
+
for (const run of runs) {
|
|
510
|
+
// Flatten all ops from this run with embedded timestamps
|
|
511
|
+
let tOps: TimestampedOp[] = [];
|
|
512
|
+
for (const msg of run) {
|
|
513
|
+
const timestamp = msg.timestamp * 1000; // convert to ms
|
|
514
|
+
for (const op of msg.ops) {
|
|
515
|
+
tOps.push({ op, timestamp });
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Apply coalescing in order
|
|
520
|
+
if (config.enableTextCoalesce !== false) {
|
|
521
|
+
tOps = coalesceTextOps(tOps);
|
|
522
|
+
}
|
|
523
|
+
if (config.enableSetCoalesce !== false) {
|
|
524
|
+
tOps = coalesceSetOps(tOps, config);
|
|
525
|
+
}
|
|
526
|
+
if (config.enableVectorCoalesce !== false) {
|
|
527
|
+
tOps = coalesceAdditiveOps(tOps);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Extract final ops
|
|
531
|
+
const allOps = tOps.map(t => t.op);
|
|
532
|
+
|
|
533
|
+
if (allOps.length === 0) continue;
|
|
534
|
+
|
|
535
|
+
// Rebuild message for this run - use the last message's metadata
|
|
536
|
+
const lastMsg = run[run.length - 1];
|
|
537
|
+
coalescedMessages.push({
|
|
538
|
+
...lastMsg,
|
|
539
|
+
ops: allOps,
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return coalescedMessages;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
export class CoalescingService {
|
|
547
|
+
constructor(private prisma: PrismaClient) {}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Coalesce operations in a document's journal
|
|
551
|
+
*/
|
|
552
|
+
async coalesce(docId: string, config: CoalescingConfig = {}): Promise<CoalescingResult> {
|
|
553
|
+
// Get all journal batches
|
|
554
|
+
const batches = await this.prisma.journalBatch.findMany({
|
|
555
|
+
where: { documentId: docId },
|
|
556
|
+
orderBy: { lamportTime: 'asc' },
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
const beforeOpsCount = batches.reduce((sum, b) => {
|
|
560
|
+
const ops = Array.isArray(b.operations) ? b.operations : [];
|
|
561
|
+
return sum + ops.length;
|
|
562
|
+
}, 0);
|
|
563
|
+
|
|
564
|
+
// Convert batches to messages
|
|
565
|
+
const messages: CRDTMessage[] = batches.map(b => ({
|
|
566
|
+
id: b.id,
|
|
567
|
+
sessionId: b.sessionId ?? 'unknown',
|
|
568
|
+
clock: (b.vectorClock as Record<string, number>) ?? {},
|
|
569
|
+
lamportTime: b.lamportTime ?? 0,
|
|
570
|
+
timestamp: b.persistedAt.getTime() / 1000,
|
|
571
|
+
ops: (Array.isArray(b.operations) ? b.operations : []) as unknown as Operation[],
|
|
572
|
+
}));
|
|
573
|
+
|
|
574
|
+
// Coalesce
|
|
575
|
+
const coalesced = coalesceOperations(messages, config);
|
|
576
|
+
|
|
577
|
+
// Count ops after
|
|
578
|
+
const afterOpsCount = coalesced.reduce((sum, m) => sum + m.ops.length, 0);
|
|
579
|
+
|
|
580
|
+
// If no reduction, skip update
|
|
581
|
+
if (afterOpsCount >= beforeOpsCount) {
|
|
582
|
+
return {
|
|
583
|
+
ok: true,
|
|
584
|
+
before: { journalBatches: batches.length, operations: beforeOpsCount },
|
|
585
|
+
after: { journalBatches: batches.length, operations: beforeOpsCount },
|
|
586
|
+
reduction: { journalBatches: 0, operations: 0 },
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Delete old batches and create new ones (one per session)
|
|
591
|
+
const now = new Date();
|
|
592
|
+
await this.prisma.$transaction(async (tx) => {
|
|
593
|
+
// Delete old batches
|
|
594
|
+
await tx.journalBatch.deleteMany({ where: { documentId: docId } });
|
|
595
|
+
|
|
596
|
+
// Create coalesced batches - one per session
|
|
597
|
+
for (let i = 0; i < coalesced.length; i++) {
|
|
598
|
+
const msg = coalesced[i];
|
|
599
|
+
if (msg.ops.length > 0) {
|
|
600
|
+
await tx.journalBatch.create({
|
|
601
|
+
data: {
|
|
602
|
+
documentId: docId,
|
|
603
|
+
batchId: `coalesced-${msg.sessionId}-${Date.now()}-${i}`,
|
|
604
|
+
lamportTime: msg.lamportTime,
|
|
605
|
+
sessionId: msg.sessionId,
|
|
606
|
+
vectorClock: msg.clock,
|
|
607
|
+
operations: msg.ops as unknown as any[],
|
|
608
|
+
startTime: now,
|
|
609
|
+
endTime: now,
|
|
610
|
+
},
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
return {
|
|
617
|
+
ok: true,
|
|
618
|
+
before: { journalBatches: batches.length, operations: beforeOpsCount },
|
|
619
|
+
after: { journalBatches: coalesced.length, operations: afterOpsCount },
|
|
620
|
+
reduction: {
|
|
621
|
+
journalBatches: batches.length - coalesced.length,
|
|
622
|
+
operations: beforeOpsCount - afterOpsCount,
|
|
623
|
+
},
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
}
|