@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.
Files changed (76) hide show
  1. package/.env +1 -0
  2. package/S3_COMPRESSION_GUIDE.md +233 -0
  3. package/dist/archive/ArchivalService.d.ts +117 -0
  4. package/dist/archive/ArchivalService.d.ts.map +1 -0
  5. package/dist/archive/ArchivalService.js +181 -0
  6. package/dist/archive/ArchivalService.js.map +1 -0
  7. package/dist/broker/InMemoryBroker.d.ts +2 -0
  8. package/dist/broker/InMemoryBroker.d.ts.map +1 -1
  9. package/dist/broker/InMemoryBroker.js +4 -0
  10. package/dist/broker/InMemoryBroker.js.map +1 -1
  11. package/dist/compression/CompressionUtils.d.ts +57 -0
  12. package/dist/compression/CompressionUtils.d.ts.map +1 -0
  13. package/dist/compression/CompressionUtils.js +90 -0
  14. package/dist/compression/CompressionUtils.js.map +1 -0
  15. package/dist/compression/index.d.ts +7 -0
  16. package/dist/compression/index.d.ts.map +1 -0
  17. package/dist/compression/index.js +7 -0
  18. package/dist/compression/index.js.map +1 -0
  19. package/dist/journal/CoalescingService.d.ts +63 -0
  20. package/dist/journal/CoalescingService.d.ts.map +1 -0
  21. package/dist/journal/CoalescingService.js +507 -0
  22. package/dist/journal/CoalescingService.js.map +1 -0
  23. package/dist/journal/JournalRLE.d.ts +81 -0
  24. package/dist/journal/JournalRLE.d.ts.map +1 -0
  25. package/dist/journal/JournalRLE.js +199 -0
  26. package/dist/journal/JournalRLE.js.map +1 -0
  27. package/dist/journal/JournalService.d.ts +7 -3
  28. package/dist/journal/JournalService.d.ts.map +1 -1
  29. package/dist/journal/JournalService.js +152 -12
  30. package/dist/journal/JournalService.js.map +1 -1
  31. package/dist/journal/RLECompression.d.ts +73 -0
  32. package/dist/journal/RLECompression.d.ts.map +1 -0
  33. package/dist/journal/RLECompression.js +152 -0
  34. package/dist/journal/RLECompression.js.map +1 -0
  35. package/dist/journal/rle-demo.d.ts +8 -0
  36. package/dist/journal/rle-demo.d.ts.map +1 -0
  37. package/dist/journal/rle-demo.js +159 -0
  38. package/dist/journal/rle-demo.js.map +1 -0
  39. package/dist/persistence/S3ColdStorage.d.ts +62 -0
  40. package/dist/persistence/S3ColdStorage.d.ts.map +1 -0
  41. package/dist/persistence/S3ColdStorage.js +88 -0
  42. package/dist/persistence/S3ColdStorage.js.map +1 -0
  43. package/dist/persistence/S3ColdStorageIntegration.d.ts +78 -0
  44. package/dist/persistence/S3ColdStorageIntegration.d.ts.map +1 -0
  45. package/dist/persistence/S3ColdStorageIntegration.js +93 -0
  46. package/dist/persistence/S3ColdStorageIntegration.js.map +1 -0
  47. package/dist/serve.d.ts +2 -0
  48. package/dist/serve.d.ts.map +1 -1
  49. package/dist/serve.js +623 -15
  50. package/dist/serve.js.map +1 -1
  51. package/docs/RLE_COMPRESSION.md +397 -0
  52. package/examples/compression-example.ts +259 -0
  53. package/package.json +14 -14
  54. package/src/archive/ArchivalService.ts +250 -0
  55. package/src/broker/InMemoryBroker.ts +5 -0
  56. package/src/compression/CompressionUtils.ts +113 -0
  57. package/src/compression/index.ts +14 -0
  58. package/src/journal/COALESCING.md +267 -0
  59. package/src/journal/CoalescingService.ts +626 -0
  60. package/src/journal/JournalRLE.ts +265 -0
  61. package/src/journal/JournalService.ts +163 -11
  62. package/src/journal/RLECompression.ts +210 -0
  63. package/src/journal/rle-demo.ts +193 -0
  64. package/src/serve.ts +702 -15
  65. package/tests/benchmark/journal-optimization-benchmark.test.ts +482 -0
  66. package/tests/compression/compression.test.ts +343 -0
  67. package/tests/integration/repositories.test.ts +89 -0
  68. package/tests/journal/compaction-load-bug.test.ts +409 -0
  69. package/tests/journal/compaction.test.ts +42 -2
  70. package/tests/journal/journal-rle.test.ts +511 -0
  71. package/tests/journal/lww-ordering-bug.test.ts +248 -0
  72. package/tests/journal/multi-session-coalescing.test.ts +871 -0
  73. package/tests/journal/rle-compression.test.ts +526 -0
  74. package/tests/journal/text-coalescing.test.ts +210 -0
  75. package/tests/unit/s3-compression.test.ts +257 -0
  76. 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
+ }