@vuer-ai/vuer-rtc-server 0.2.3 → 0.4.1
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 -1
- package/README.md +56 -0
- package/dist/archive/ArchivalService.js +1 -1
- package/dist/archive/ArchivalService.js.map +1 -1
- package/dist/broker/InMemoryBroker.d.ts +2 -2
- package/dist/broker/InMemoryBroker.d.ts.map +1 -1
- package/dist/broker/InMemoryBroker.js +4 -4
- package/dist/broker/InMemoryBroker.js.map +1 -1
- package/dist/broker/types.d.ts +3 -3
- package/dist/broker/types.d.ts.map +1 -1
- package/dist/journal/CoalescingService.d.ts.map +1 -1
- package/dist/journal/CoalescingService.js +18 -208
- package/dist/journal/CoalescingService.js.map +1 -1
- package/dist/journal/GraphJournalService.d.ts +127 -0
- package/dist/journal/GraphJournalService.d.ts.map +1 -0
- package/dist/journal/GraphJournalService.js +491 -0
- package/dist/journal/GraphJournalService.js.map +1 -0
- package/dist/journal/JournalRLE.d.ts +2 -2
- package/dist/journal/JournalRLE.js +14 -14
- package/dist/journal/JournalRLE.js.map +1 -1
- package/dist/journal/JournalRepository.js +7 -7
- package/dist/journal/JournalRepository.js.map +1 -1
- package/dist/journal/JournalService.d.ts.map +1 -1
- package/dist/journal/JournalService.js +6 -40
- package/dist/journal/JournalService.js.map +1 -1
- package/dist/journal/RLECompression.d.ts +9 -9
- package/dist/journal/RLECompression.d.ts.map +1 -1
- package/dist/journal/RLECompression.js +22 -22
- package/dist/journal/RLECompression.js.map +1 -1
- package/dist/journal/TextJournalService.d.ts +98 -0
- package/dist/journal/TextJournalService.d.ts.map +1 -0
- package/dist/journal/TextJournalService.js +401 -0
- package/dist/journal/TextJournalService.js.map +1 -0
- package/dist/journal/index.d.ts +3 -1
- package/dist/journal/index.d.ts.map +1 -1
- package/dist/journal/index.js +4 -1
- package/dist/journal/index.js.map +1 -1
- package/dist/journal/rle-demo.js +11 -11
- package/dist/journal/rle-demo.js.map +1 -1
- package/dist/serve.d.ts +29 -11
- package/dist/serve.d.ts.map +1 -1
- package/dist/serve.js +558 -93
- package/dist/serve.js.map +1 -1
- package/dist/transport/RTCServer.d.ts +2 -2
- package/dist/transport/RTCServer.d.ts.map +1 -1
- package/dist/transport/RTCServer.js +22 -22
- package/dist/transport/RTCServer.js.map +1 -1
- package/docs/API.md +642 -0
- package/examples/compression-example.ts +3 -3
- package/package.json +2 -2
- package/prisma/schema.prisma +124 -6
- package/src/archive/ArchivalService.ts +1 -1
- package/src/broker/InMemoryBroker.ts +4 -4
- package/src/broker/types.ts +3 -3
- package/src/journal/CoalescingService.ts +18 -235
- package/src/journal/{JournalService.ts → GraphJournalService.ts} +34 -74
- package/src/journal/JournalRLE.ts +15 -15
- package/src/journal/JournalRepository.ts +7 -7
- package/src/journal/RLECompression.ts +24 -24
- package/src/journal/TextJournalService.ts +483 -0
- package/src/journal/index.ts +10 -2
- package/src/journal/rle-demo.ts +11 -11
- package/src/serve.ts +598 -94
- package/src/transport/RTCServer.ts +23 -23
- package/tests/benchmark/journal-optimization-benchmark.test.ts +14 -14
- package/tests/compression/compression.test.ts +8 -8
- package/tests/demo.ts +88 -88
- package/tests/e2e/convergence.test.ts +9 -9
- package/tests/e2e/helpers/assertions.ts +22 -0
- package/tests/e2e/helpers/createTestServer.ts +4 -4
- package/tests/e2e/latency.test.ts +47 -41
- package/tests/e2e/packet-loss.test.ts +6 -6
- package/tests/e2e/relay.test.ts +9 -9
- package/tests/e2e/sync-perf.test.ts +5 -5
- package/tests/e2e/sync-reconciliation.test.ts +6 -6
- package/tests/e2e/text-sync.test.ts +14 -14
- package/tests/e2e/tombstone-convergence.test.ts +22 -22
- package/tests/fixtures/array-ops.jsonl +6 -6
- package/tests/fixtures/boolean-ops.jsonl +6 -6
- package/tests/fixtures/color-ops.jsonl +4 -4
- package/tests/fixtures/edit-buffer.jsonl +3 -3
- package/tests/fixtures/messages.jsonl +4 -4
- package/tests/fixtures/node-ops.jsonl +6 -6
- package/tests/fixtures/number-ops.jsonl +7 -7
- package/tests/fixtures/object-ops.jsonl +4 -4
- package/tests/fixtures/operations.jsonl +7 -7
- package/tests/fixtures/string-ops.jsonl +4 -4
- package/tests/fixtures/undo-redo.jsonl +3 -3
- package/tests/fixtures/vector-ops.jsonl +9 -9
- package/tests/integration/repositories.test.ts +8 -9
- package/tests/journal/compaction-load-bug.test.ts +31 -31
- package/tests/journal/compaction.test.ts +26 -26
- package/tests/journal/journal-rle.test.ts +38 -38
- package/tests/journal/journal-service.test.ts +13 -13
- package/tests/journal/lww-ordering-bug.test.ts +39 -39
- package/tests/journal/rle-compression.test.ts +71 -71
- package/tests/journal/text-coalescing.test.ts +34 -34
- package/tests/test-data/datatypes.ts +85 -85
- package/tests/test-data/operations-example.ts +62 -62
- package/tests/test-data/scene-example.ts +11 -11
- package/tests/unit/operations.test.ts +7 -7
- package/tests/unit/s3-compression.test.ts +5 -3
- package/tests/unit/vectorClock.test.ts +2 -2
- package/tests/journal/multi-session-coalescing.test.ts +0 -871
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Journal Service - Business logic for journal operations
|
|
2
|
+
* Graph Journal Service - Business logic for graph document journal operations
|
|
3
3
|
*
|
|
4
4
|
* Handles:
|
|
5
|
-
* - Receiving and validating CRDTMessages
|
|
5
|
+
* - Receiving and validating CRDTMessages for graph documents
|
|
6
6
|
* - Storing messages in journal
|
|
7
7
|
* - Processing meta.undo/meta.redo operations
|
|
8
8
|
* - Computing current graph state
|
|
@@ -21,8 +21,6 @@ import {
|
|
|
21
21
|
OperationValidator,
|
|
22
22
|
TextRope,
|
|
23
23
|
compactRope,
|
|
24
|
-
toRaw,
|
|
25
|
-
fromRaw,
|
|
26
24
|
} from '@vuer-ai/vuer-rtc';
|
|
27
25
|
|
|
28
26
|
import { JournalRepository } from './JournalRepository.js';
|
|
@@ -31,7 +29,7 @@ import { DocumentRepository } from '../persistence/DocumentRepository.js';
|
|
|
31
29
|
/**
|
|
32
30
|
* Safely serialize an object, handling circular references by removing them.
|
|
33
31
|
* Also strips 'parent' references which cause cycles in tree structures.
|
|
34
|
-
*
|
|
32
|
+
* TextRope instances are automatically serialized to strings via toJSON().
|
|
35
33
|
*/
|
|
36
34
|
function safeSerialize(obj: unknown): unknown {
|
|
37
35
|
const seen = new WeakSet();
|
|
@@ -39,14 +37,6 @@ function safeSerialize(obj: unknown): unknown {
|
|
|
39
37
|
// Skip parent references which cause cycles
|
|
40
38
|
if (key === 'parent') return undefined;
|
|
41
39
|
|
|
42
|
-
// Properly serialize TextRope instances using toRaw()
|
|
43
|
-
if (value instanceof TextRope) {
|
|
44
|
-
return {
|
|
45
|
-
_textRope: true,
|
|
46
|
-
raw: toRaw(value),
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
|
-
|
|
50
40
|
if (typeof value === 'object' && value !== null) {
|
|
51
41
|
if (seen.has(value)) return undefined; // Circular reference
|
|
52
42
|
seen.add(value);
|
|
@@ -71,49 +61,21 @@ export interface DocumentState {
|
|
|
71
61
|
journal: JournalEntry[];
|
|
72
62
|
}
|
|
73
63
|
|
|
74
|
-
/**
|
|
75
|
-
* Recursively walk an object and restore TextRope instances from their raw form.
|
|
76
|
-
*/
|
|
77
|
-
function restoreTextRopes(obj: any): any {
|
|
78
|
-
if (obj === null || obj === undefined) return obj;
|
|
79
|
-
|
|
80
|
-
// Check if this is a serialized TextRope
|
|
81
|
-
if (typeof obj === 'object' && obj._textRope === true && obj.raw) {
|
|
82
|
-
return fromRaw(obj.raw);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// Recursively process arrays
|
|
86
|
-
if (Array.isArray(obj)) {
|
|
87
|
-
return obj.map(restoreTextRopes);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// Recursively process objects
|
|
91
|
-
if (typeof obj === 'object') {
|
|
92
|
-
const result: any = {};
|
|
93
|
-
for (const [key, value] of Object.entries(obj)) {
|
|
94
|
-
result[key] = restoreTextRopes(value);
|
|
95
|
-
}
|
|
96
|
-
return result;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
return obj;
|
|
100
|
-
}
|
|
101
64
|
|
|
102
65
|
/**
|
|
103
66
|
* Safely parse a Document.currentState (Json) into a Snapshot,
|
|
104
67
|
* providing defaults for any missing fields.
|
|
105
|
-
*
|
|
68
|
+
* Text properties are stored as plain strings and lazy-upgraded to TextRope on edit.
|
|
106
69
|
*/
|
|
107
70
|
function parseSnapshot(currentState: unknown): Snapshot {
|
|
108
71
|
const raw = (currentState ?? {}) as Record<string, unknown>;
|
|
109
72
|
|
|
110
|
-
|
|
111
|
-
const graph = restoreTextRopes(raw.graph as SceneGraph) || createEmptyGraph();
|
|
73
|
+
const graph = (raw.graph as SceneGraph) || createEmptyGraph();
|
|
112
74
|
|
|
113
75
|
return {
|
|
114
76
|
graph,
|
|
115
77
|
vectorClock: (raw.vectorClock as Record<string, number>) || {},
|
|
116
|
-
|
|
78
|
+
lt: (typeof raw.lt === 'number' ? raw.lt : 0),
|
|
117
79
|
journalIndex: (typeof raw.journalIndex === 'number' ? raw.journalIndex : 0),
|
|
118
80
|
};
|
|
119
81
|
}
|
|
@@ -159,7 +121,7 @@ function compactTextRopes(graph: SceneGraph): SceneGraph {
|
|
|
159
121
|
return { ...graph, nodes };
|
|
160
122
|
}
|
|
161
123
|
|
|
162
|
-
export class
|
|
124
|
+
export class GraphJournalService {
|
|
163
125
|
private journalRepo: JournalRepository;
|
|
164
126
|
private documentRepo: DocumentRepository;
|
|
165
127
|
private validator: OperationValidator;
|
|
@@ -231,10 +193,10 @@ export class JournalService {
|
|
|
231
193
|
for (let i = 1; i < memberClocks.length; i++) {
|
|
232
194
|
const clock = memberClocks[i];
|
|
233
195
|
// For each session in result, take min with this clock
|
|
234
|
-
for (const
|
|
235
|
-
result[
|
|
236
|
-
result[
|
|
237
|
-
clock[
|
|
196
|
+
for (const client of Object.keys(result)) {
|
|
197
|
+
result[client] = Math.min(
|
|
198
|
+
result[client],
|
|
199
|
+
clock[client] ?? 0,
|
|
238
200
|
);
|
|
239
201
|
}
|
|
240
202
|
// Sessions not in result but in clock are implicitly 0 in result,
|
|
@@ -249,8 +211,8 @@ export class JournalService {
|
|
|
249
211
|
* watermark[S] >= clock[S].
|
|
250
212
|
*/
|
|
251
213
|
isDominatedByWatermark(msgClock: VectorClock, watermark: VectorClock): boolean {
|
|
252
|
-
for (const [
|
|
253
|
-
if ((watermark[
|
|
214
|
+
for (const [client, time] of Object.entries(msgClock)) {
|
|
215
|
+
if ((watermark[client] ?? 0) < time) {
|
|
254
216
|
return false;
|
|
255
217
|
}
|
|
256
218
|
}
|
|
@@ -308,7 +270,7 @@ export class JournalService {
|
|
|
308
270
|
// getSince() now returns PersistedJournalEntry with deletedAt from DB.
|
|
309
271
|
const persistedEntries = await this.journalRepo.getSince(
|
|
310
272
|
documentId,
|
|
311
|
-
snapshot.
|
|
273
|
+
snapshot.lt,
|
|
312
274
|
);
|
|
313
275
|
|
|
314
276
|
const journal: JournalEntry[] = persistedEntries.map((entry) => ({
|
|
@@ -350,19 +312,19 @@ export class JournalService {
|
|
|
350
312
|
|
|
351
313
|
// Process meta operations (undo/redo)
|
|
352
314
|
for (const op of msg.ops) {
|
|
353
|
-
if (op.
|
|
315
|
+
if (op.ot === 'meta.undo') {
|
|
354
316
|
const targetId = (op as any).targetMsgId;
|
|
355
317
|
const target = state.journal.find((e) => e.msg.id === targetId);
|
|
356
318
|
if (target) {
|
|
357
|
-
target.deletedAt = msg.
|
|
358
|
-
// Persist deletedAt to DB (
|
|
319
|
+
target.deletedAt = msg.ts;
|
|
320
|
+
// Persist deletedAt to DB (ts is seconds, DB stores as Date in ms)
|
|
359
321
|
await this.journalRepo.updateDeletedAt(
|
|
360
322
|
documentId,
|
|
361
323
|
targetId,
|
|
362
|
-
new Date(msg.
|
|
324
|
+
new Date(msg.ts * 1000),
|
|
363
325
|
);
|
|
364
326
|
}
|
|
365
|
-
} else if (op.
|
|
327
|
+
} else if (op.ot === 'meta.redo') {
|
|
366
328
|
const targetId = (op as any).targetMsgId;
|
|
367
329
|
const target = state.journal.find((e) => e.msg.id === targetId);
|
|
368
330
|
if (target) {
|
|
@@ -393,7 +355,7 @@ export class JournalService {
|
|
|
393
355
|
|
|
394
356
|
// Filter out meta ops for graph computation
|
|
395
357
|
const realOps = entry.msg.ops.filter(
|
|
396
|
-
(op) => !op.
|
|
358
|
+
(op) => !op.ot.startsWith('meta.')
|
|
397
359
|
);
|
|
398
360
|
if (realOps.length > 0) {
|
|
399
361
|
graph = applyMessage(graph, { ...entry.msg, ops: realOps });
|
|
@@ -425,9 +387,9 @@ export class JournalService {
|
|
|
425
387
|
// to handle out-of-order messages correctly after compaction
|
|
426
388
|
const postSnapshotJournal = state.journal
|
|
427
389
|
.filter((e) => {
|
|
428
|
-
// Include message if ANY
|
|
429
|
-
for (const [
|
|
430
|
-
if (time > (state.snapshot.vectorClock[
|
|
390
|
+
// Include message if ANY client in its clock is ahead of snapshot
|
|
391
|
+
for (const [client, time] of Object.entries(e.msg.clock)) {
|
|
392
|
+
if (time > (state.snapshot.vectorClock[client] ?? 0)) {
|
|
431
393
|
return true;
|
|
432
394
|
}
|
|
433
395
|
}
|
|
@@ -435,10 +397,8 @@ export class JournalService {
|
|
|
435
397
|
})
|
|
436
398
|
.map((e) => e.msg);
|
|
437
399
|
|
|
438
|
-
// Serialize
|
|
439
|
-
//
|
|
440
|
-
// but MessagePack can't serialize class instances properly. Convert them to
|
|
441
|
-
// {_textRope: true, raw: [...]} format so the client can hydrate them.
|
|
400
|
+
// Serialize snapshot before sending to client. TextRope instances (if any)
|
|
401
|
+
// are automatically converted to plain strings via toJSON().
|
|
442
402
|
const serializedSnapshot = safeSerialize(state.snapshot) as Snapshot;
|
|
443
403
|
|
|
444
404
|
return {
|
|
@@ -485,27 +445,27 @@ export class JournalService {
|
|
|
485
445
|
// Build new snapshot from entries [0..compactUpToIndex]
|
|
486
446
|
let newGraph = state.snapshot.graph;
|
|
487
447
|
let mergedClock = { ...state.snapshot.vectorClock };
|
|
488
|
-
let maxLamport = state.snapshot.
|
|
448
|
+
let maxLamport = state.snapshot.lt;
|
|
489
449
|
|
|
490
450
|
for (let i = 0; i <= compactUpToIndex; i++) {
|
|
491
451
|
const entry = state.journal[i];
|
|
492
452
|
if (!entry.deletedAt) {
|
|
493
453
|
// Filter out meta ops for graph computation
|
|
494
454
|
const realOps = entry.msg.ops.filter(
|
|
495
|
-
(op) => !op.
|
|
455
|
+
(op) => !op.ot.startsWith('meta.')
|
|
496
456
|
);
|
|
497
457
|
if (realOps.length > 0) {
|
|
498
458
|
newGraph = applyMessage(newGraph, { ...entry.msg, ops: realOps });
|
|
499
459
|
}
|
|
500
460
|
}
|
|
501
461
|
// Merge clock and lamport regardless of deletedAt
|
|
502
|
-
for (const [
|
|
503
|
-
mergedClock[
|
|
504
|
-
mergedClock[
|
|
462
|
+
for (const [client, time] of Object.entries(entry.msg.clock)) {
|
|
463
|
+
mergedClock[client] = Math.max(
|
|
464
|
+
mergedClock[client] || 0,
|
|
505
465
|
time,
|
|
506
466
|
);
|
|
507
467
|
}
|
|
508
|
-
maxLamport = Math.max(maxLamport, entry.msg.
|
|
468
|
+
maxLamport = Math.max(maxLamport, entry.msg.lt);
|
|
509
469
|
}
|
|
510
470
|
|
|
511
471
|
// Collect batchIds of compacted entries before mutating state
|
|
@@ -526,7 +486,7 @@ export class JournalService {
|
|
|
526
486
|
state.snapshot = {
|
|
527
487
|
graph: newGraph,
|
|
528
488
|
vectorClock: mergedClock,
|
|
529
|
-
|
|
489
|
+
lt: maxLamport,
|
|
530
490
|
journalIndex: maxLamport,
|
|
531
491
|
};
|
|
532
492
|
|
|
@@ -585,7 +545,7 @@ export class JournalService {
|
|
|
585
545
|
snapshot: {
|
|
586
546
|
graph: createEmptyGraph(),
|
|
587
547
|
vectorClock: {},
|
|
588
|
-
|
|
548
|
+
lt: 0,
|
|
589
549
|
journalIndex: 0,
|
|
590
550
|
},
|
|
591
551
|
journal: [],
|
|
@@ -611,7 +571,7 @@ export class JournalService {
|
|
|
611
571
|
snapshot: {
|
|
612
572
|
graph: createEmptyGraph(),
|
|
613
573
|
vectorClock: {},
|
|
614
|
-
|
|
574
|
+
lt: 0,
|
|
615
575
|
journalIndex: 0,
|
|
616
576
|
},
|
|
617
577
|
journal: [],
|
|
@@ -18,7 +18,7 @@ import type { CRDTMessage } from '@vuer-ai/vuer-rtc';
|
|
|
18
18
|
* Compressed operation segment - groups consecutive ops from same agent
|
|
19
19
|
*/
|
|
20
20
|
export interface RLESegment {
|
|
21
|
-
agentId: string; //
|
|
21
|
+
agentId: string; // client from first message in run
|
|
22
22
|
count: number; // Number of messages in this run
|
|
23
23
|
messages: CRDTMessage[]; // The actual messages (compressed payload)
|
|
24
24
|
}
|
|
@@ -40,8 +40,8 @@ export interface RLEEncodedJournal {
|
|
|
40
40
|
/**
|
|
41
41
|
* Encode a sequence of CRDTMessages using RLE
|
|
42
42
|
*
|
|
43
|
-
* Groups consecutive messages from the same
|
|
44
|
-
* Each segment stores the
|
|
43
|
+
* Groups consecutive messages from the same client into segments.
|
|
44
|
+
* Each segment stores the client once and all messages in that run.
|
|
45
45
|
*/
|
|
46
46
|
export function encodeJournalRLE(messages: CRDTMessage[]): RLEEncodedJournal {
|
|
47
47
|
if (messages.length === 0) {
|
|
@@ -61,7 +61,7 @@ export function encodeJournalRLE(messages: CRDTMessage[]): RLEEncodedJournal {
|
|
|
61
61
|
let currentSegment: RLESegment | null = null;
|
|
62
62
|
|
|
63
63
|
for (const msg of messages) {
|
|
64
|
-
const agentId = msg.
|
|
64
|
+
const agentId = msg.client;
|
|
65
65
|
|
|
66
66
|
// Start a new segment if agent changes
|
|
67
67
|
if (!currentSegment || currentSegment.agentId !== agentId) {
|
|
@@ -113,12 +113,12 @@ export function decodeJournalRLE(encoded: RLEEncodedJournal): CRDTMessage[] {
|
|
|
113
113
|
const messages: CRDTMessage[] = [];
|
|
114
114
|
|
|
115
115
|
for (const segment of encoded.segments) {
|
|
116
|
-
// Verify all messages in segment have correct
|
|
116
|
+
// Verify all messages in segment have correct client
|
|
117
117
|
for (const msg of segment.messages) {
|
|
118
|
-
if (msg.
|
|
118
|
+
if (msg.client !== segment.agentId) {
|
|
119
119
|
throw new Error(
|
|
120
|
-
`RLE decode error:
|
|
121
|
-
`Expected ${segment.agentId}, got ${msg.
|
|
120
|
+
`RLE decode error: client mismatch in segment. ` +
|
|
121
|
+
`Expected ${segment.agentId}, got ${msg.client}`
|
|
122
122
|
);
|
|
123
123
|
}
|
|
124
124
|
messages.push(msg);
|
|
@@ -184,9 +184,9 @@ export function verifyRLEIntegrity(
|
|
|
184
184
|
errors.push(`Message ${i}: id mismatch (${orig.id} vs ${dec.id})`);
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
-
// Check
|
|
188
|
-
if (orig.
|
|
189
|
-
errors.push(`Message ${i}:
|
|
187
|
+
// Check client
|
|
188
|
+
if (orig.client !== dec.client) {
|
|
189
|
+
errors.push(`Message ${i}: client mismatch`);
|
|
190
190
|
}
|
|
191
191
|
|
|
192
192
|
// Check vector clock (deep equality)
|
|
@@ -204,13 +204,13 @@ export function verifyRLEIntegrity(
|
|
|
204
204
|
}
|
|
205
205
|
|
|
206
206
|
// Check lamport time
|
|
207
|
-
if (orig.
|
|
208
|
-
errors.push(`Message ${i}:
|
|
207
|
+
if (orig.lt !== dec.lt) {
|
|
208
|
+
errors.push(`Message ${i}: lt mismatch`);
|
|
209
209
|
}
|
|
210
210
|
|
|
211
211
|
// Check timestamp
|
|
212
|
-
if (orig.
|
|
213
|
-
errors.push(`Message ${i}:
|
|
212
|
+
if (orig.ts !== dec.ts) {
|
|
213
|
+
errors.push(`Message ${i}: ts mismatch`);
|
|
214
214
|
}
|
|
215
215
|
|
|
216
216
|
// Check operations (deep equality)
|
|
@@ -32,13 +32,13 @@ export class JournalRepository {
|
|
|
32
32
|
return this.prisma.journalBatch.create({
|
|
33
33
|
data: {
|
|
34
34
|
documentId,
|
|
35
|
-
|
|
35
|
+
client: msg.client,
|
|
36
36
|
batchId: msg.id,
|
|
37
37
|
operations: msg.ops as any[],
|
|
38
38
|
vectorClock: msg.clock as any,
|
|
39
|
-
lamportTime: msg.
|
|
40
|
-
startTime: new Date(msg.
|
|
41
|
-
endTime: new Date(msg.
|
|
39
|
+
lamportTime: msg.lt,
|
|
40
|
+
startTime: new Date(msg.ts * 1000),
|
|
41
|
+
endTime: new Date(msg.ts * 1000),
|
|
42
42
|
},
|
|
43
43
|
});
|
|
44
44
|
}
|
|
@@ -67,10 +67,10 @@ export class JournalRepository {
|
|
|
67
67
|
return batches.map((batch) => ({
|
|
68
68
|
msg: {
|
|
69
69
|
id: batch.batchId,
|
|
70
|
-
|
|
70
|
+
client: batch.client,
|
|
71
71
|
clock: (batch.vectorClock as Record<string, number>) ?? {},
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
lt: batch.lamportTime ?? 0,
|
|
73
|
+
ts: batch.startTime.getTime() / 1000,
|
|
74
74
|
ops: batch.operations as any[],
|
|
75
75
|
},
|
|
76
76
|
deletedAt: batch.deletedAt
|
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Optimizes journal storage by:
|
|
5
5
|
* 1. Run-length encoding consecutive operations (e.g., 10 sequential edits from same agent)
|
|
6
|
-
* 2. Only storing agent/
|
|
6
|
+
* 2. Only storing agent/client when it changes
|
|
7
7
|
* 3. Preserving CRDT semantics (no operations are combined or lost)
|
|
8
8
|
*
|
|
9
|
-
* Format: {
|
|
10
|
-
* -
|
|
9
|
+
* Format: { client, count: N, ops: [op1, op2, ...] }
|
|
10
|
+
* - client is stored with first op of each run
|
|
11
11
|
* - count tracks consecutive ops from same agent
|
|
12
12
|
* - ops are stored as-is (no merging/combining)
|
|
13
13
|
*/
|
|
@@ -18,8 +18,8 @@ import type { CRDTMessage } from '@vuer-ai/vuer-rtc';
|
|
|
18
18
|
* A run-length encoded journal entry
|
|
19
19
|
*/
|
|
20
20
|
export interface RLEJournalEntry {
|
|
21
|
-
|
|
22
|
-
count: number; // Number of consecutive operations from this
|
|
21
|
+
client: string; // Agent/client that performed this run
|
|
22
|
+
count: number; // Number of consecutive operations from this client
|
|
23
23
|
lamportTime: number; // Start lamport time of this run
|
|
24
24
|
endLamportTime: number; // End lamport time (start + count - 1)
|
|
25
25
|
ops: any[]; // Operations in this run (one per index)
|
|
@@ -27,14 +27,14 @@ export interface RLEJournalEntry {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
|
-
* Encode consecutive operations from the same
|
|
31
|
-
* Returns a list of RLE entries where consecutive ops from same
|
|
30
|
+
* Encode consecutive operations from the same client using RLE.
|
|
31
|
+
* Returns a list of RLE entries where consecutive ops from same client are grouped.
|
|
32
32
|
*
|
|
33
33
|
* Example:
|
|
34
|
-
* Input: [msg1(
|
|
34
|
+
* Input: [msg1(cid=A), msg2(cid=A), msg3(cid=B), msg4(cid=B), msg5(cid=B)]
|
|
35
35
|
* Output: [
|
|
36
|
-
* {
|
|
37
|
-
* {
|
|
36
|
+
* { client: A, count: 2, ops: [msg1.ops[0], msg2.ops[0]], ... },
|
|
37
|
+
* { client: B, count: 3, ops: [msg3.ops[0], msg4.ops[0], msg5.ops[0]], ... }
|
|
38
38
|
* ]
|
|
39
39
|
*/
|
|
40
40
|
export function encodeRLE(messages: CRDTMessage[]): RLEJournalEntry[] {
|
|
@@ -44,23 +44,23 @@ export function encodeRLE(messages: CRDTMessage[]): RLEJournalEntry[] {
|
|
|
44
44
|
let currentRun: RLEJournalEntry | null = null;
|
|
45
45
|
|
|
46
46
|
for (const msg of messages) {
|
|
47
|
-
if (!currentRun || currentRun.
|
|
47
|
+
if (!currentRun || currentRun.client !== msg.client) {
|
|
48
48
|
// Start new run
|
|
49
49
|
if (currentRun) {
|
|
50
50
|
encoded.push(currentRun);
|
|
51
51
|
}
|
|
52
52
|
currentRun = {
|
|
53
|
-
|
|
53
|
+
client: msg.client,
|
|
54
54
|
count: 1,
|
|
55
|
-
lamportTime: msg.
|
|
56
|
-
endLamportTime: msg.
|
|
55
|
+
lamportTime: msg.lt,
|
|
56
|
+
endLamportTime: msg.lt,
|
|
57
57
|
ops: [...msg.ops],
|
|
58
|
-
timestamp: msg.
|
|
58
|
+
timestamp: msg.ts,
|
|
59
59
|
};
|
|
60
60
|
} else {
|
|
61
61
|
// Extend current run
|
|
62
62
|
currentRun.count++;
|
|
63
|
-
currentRun.endLamportTime = msg.
|
|
63
|
+
currentRun.endLamportTime = msg.lt;
|
|
64
64
|
currentRun.ops.push(...msg.ops);
|
|
65
65
|
}
|
|
66
66
|
}
|
|
@@ -104,10 +104,10 @@ export function decodeRLE(
|
|
|
104
104
|
// Reconstruct message
|
|
105
105
|
messages.push({
|
|
106
106
|
id: `msg-${lamportTime}`, // Note: Original IDs are lost; this is a limitation
|
|
107
|
-
|
|
107
|
+
client: entry.client,
|
|
108
108
|
clock: {}, // Vector clock info is lost in current RLE format
|
|
109
|
-
lamportTime,
|
|
110
|
-
|
|
109
|
+
lt: lamportTime,
|
|
110
|
+
ts: entry.timestamp,
|
|
111
111
|
ops: opsForMsg,
|
|
112
112
|
});
|
|
113
113
|
|
|
@@ -143,8 +143,8 @@ export function encodeRLEWithMetadata(
|
|
|
143
143
|
const vectorClocks: Record<number, Record<string, number>> = {};
|
|
144
144
|
|
|
145
145
|
for (const msg of messages) {
|
|
146
|
-
messageIds[msg.
|
|
147
|
-
vectorClocks[msg.
|
|
146
|
+
messageIds[msg.lt] = msg.id;
|
|
147
|
+
vectorClocks[msg.lt] = msg.clock;
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
return {
|
|
@@ -176,10 +176,10 @@ export function decodeRLEWithMetadata(
|
|
|
176
176
|
|
|
177
177
|
messages.push({
|
|
178
178
|
id: encoded.messageIds[lamportTime] || `msg-${lamportTime}`,
|
|
179
|
-
|
|
179
|
+
client: entry.client,
|
|
180
180
|
clock: encoded.vectorClocks[lamportTime] || {},
|
|
181
|
-
lamportTime,
|
|
182
|
-
|
|
181
|
+
lt: lamportTime,
|
|
182
|
+
ts: entry.timestamp,
|
|
183
183
|
ops: opsForMsg,
|
|
184
184
|
});
|
|
185
185
|
|