@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
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text Journal Service - Business logic for text document journal operations
|
|
3
|
+
*
|
|
4
|
+
* Handles:
|
|
5
|
+
* - Receiving and validating CRDTMessages for text documents
|
|
6
|
+
* - Storing messages in journal
|
|
7
|
+
* - Processing meta.undo/meta.redo operations
|
|
8
|
+
* - Computing current text state (TextRope)
|
|
9
|
+
* - Providing snapshot + journal for new clients
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { PrismaClient } from '@prisma/client';
|
|
13
|
+
import {
|
|
14
|
+
type CRDTMessage,
|
|
15
|
+
type VectorClock,
|
|
16
|
+
type RawTextRope,
|
|
17
|
+
TextRope,
|
|
18
|
+
create,
|
|
19
|
+
insert,
|
|
20
|
+
remove as deleteText,
|
|
21
|
+
compactRope,
|
|
22
|
+
fromRaw,
|
|
23
|
+
} from '@vuer-ai/vuer-rtc';
|
|
24
|
+
|
|
25
|
+
/** How often the compaction loop runs (ms). */
|
|
26
|
+
const COMPACTION_INTERVAL_MS = 30_000;
|
|
27
|
+
|
|
28
|
+
/** Number of journal entries that triggers compaction for a document. */
|
|
29
|
+
const COMPACTION_THRESHOLD_ENTRIES = 500;
|
|
30
|
+
|
|
31
|
+
export interface TextJournalEntry {
|
|
32
|
+
msg: CRDTMessage;
|
|
33
|
+
deletedAt?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface TextDocumentState {
|
|
37
|
+
snapshot: {
|
|
38
|
+
text: TextRope;
|
|
39
|
+
vectorClock: VectorClock;
|
|
40
|
+
lt: number;
|
|
41
|
+
};
|
|
42
|
+
journal: TextJournalEntry[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Safely parse a TextDocument.currentText (Json) into a TextRope.
|
|
47
|
+
* Text is stored as plain strings and lazy-upgraded to TextRope on edit.
|
|
48
|
+
*/
|
|
49
|
+
function parseTextSnapshot(currentText: unknown, agentId: string): TextRope {
|
|
50
|
+
if (typeof currentText === 'string') {
|
|
51
|
+
const rope = create(agentId);
|
|
52
|
+
if (currentText.length > 0) {
|
|
53
|
+
// Insert initial text
|
|
54
|
+
insert(rope, 0, currentText);
|
|
55
|
+
}
|
|
56
|
+
return rope;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// If it's already a TextRope object, reconstruct it
|
|
60
|
+
if (currentText && typeof currentText === 'object') {
|
|
61
|
+
try {
|
|
62
|
+
// Attempt to hydrate from serialized format
|
|
63
|
+
const hydrated = fromRaw(currentText as RawTextRope);
|
|
64
|
+
hydrated.agentId = agentId;
|
|
65
|
+
return hydrated;
|
|
66
|
+
} catch {
|
|
67
|
+
// Fall back to empty rope
|
|
68
|
+
return create(agentId);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return create(agentId);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export class TextJournalService {
|
|
76
|
+
private prisma: PrismaClient;
|
|
77
|
+
|
|
78
|
+
// In-memory state per document (for fast access)
|
|
79
|
+
private documentStates: Map<string, TextDocumentState> = new Map();
|
|
80
|
+
|
|
81
|
+
/** Handle returned by setInterval for the compaction loop. */
|
|
82
|
+
private compactionTimer: ReturnType<typeof setInterval> | null = null;
|
|
83
|
+
|
|
84
|
+
/** Mutex set to prevent concurrent compact + processMessage races. */
|
|
85
|
+
private compactionLocks = new Set<string>();
|
|
86
|
+
|
|
87
|
+
/** Callback for fetching connected member vector clocks per document. */
|
|
88
|
+
private memberClockProvider: ((docId: string) => Promise<VectorClock[]>) | null = null;
|
|
89
|
+
|
|
90
|
+
constructor(prisma: PrismaClient) {
|
|
91
|
+
this.prisma = prisma;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Compaction loop ──────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Start the periodic compaction loop.
|
|
98
|
+
* Should be called once after the service is created.
|
|
99
|
+
*/
|
|
100
|
+
startCompactionLoop(): void {
|
|
101
|
+
if (this.compactionTimer) return; // already running
|
|
102
|
+
this.compactionTimer = setInterval(() => {
|
|
103
|
+
this.compactAllLoadedDocuments().catch((err) =>
|
|
104
|
+
console.error('[text-compaction-loop]', err)
|
|
105
|
+
);
|
|
106
|
+
}, COMPACTION_INTERVAL_MS);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Stop the periodic compaction loop (for graceful shutdown).
|
|
111
|
+
*/
|
|
112
|
+
stopCompactionLoop(): void {
|
|
113
|
+
if (this.compactionTimer) {
|
|
114
|
+
clearInterval(this.compactionTimer);
|
|
115
|
+
this.compactionTimer = null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Store a callback for fetching connected member vector clocks per document.
|
|
121
|
+
* Used by the compaction loop to compute safe watermarks.
|
|
122
|
+
*/
|
|
123
|
+
setMemberClockProvider(fn: (docId: string) => Promise<VectorClock[]>): void {
|
|
124
|
+
this.memberClockProvider = fn;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Compute safe compaction point: the component-wise minimum of all
|
|
129
|
+
* connected clients' vector clocks.
|
|
130
|
+
*/
|
|
131
|
+
computeCompactionWatermark(memberClocks: VectorClock[]): VectorClock {
|
|
132
|
+
if (memberClocks.length === 0) return {};
|
|
133
|
+
|
|
134
|
+
const result: VectorClock = { ...memberClocks[0] };
|
|
135
|
+
for (let i = 1; i < memberClocks.length; i++) {
|
|
136
|
+
const clock = memberClocks[i];
|
|
137
|
+
for (const client of Object.keys(result)) {
|
|
138
|
+
result[client] = Math.min(
|
|
139
|
+
result[client],
|
|
140
|
+
clock[client] ?? 0,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Check if a message's vector clock is dominated by the watermark.
|
|
149
|
+
*/
|
|
150
|
+
isDominatedByWatermark(msgClock: VectorClock, watermark: VectorClock): boolean {
|
|
151
|
+
for (const [client, time] of Object.entries(msgClock)) {
|
|
152
|
+
if ((watermark[client] ?? 0) < time) {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Iterate all in-memory document states and compact those whose
|
|
161
|
+
* journal exceeds COMPACTION_THRESHOLD_ENTRIES.
|
|
162
|
+
*/
|
|
163
|
+
private async compactAllLoadedDocuments(): Promise<void> {
|
|
164
|
+
for (const [documentId, state] of this.documentStates) {
|
|
165
|
+
if (state.journal.length >= COMPACTION_THRESHOLD_ENTRIES) {
|
|
166
|
+
try {
|
|
167
|
+
let watermark: VectorClock | undefined;
|
|
168
|
+
if (this.memberClockProvider) {
|
|
169
|
+
const clocks = await this.memberClockProvider(documentId);
|
|
170
|
+
if (clocks.length > 0) {
|
|
171
|
+
watermark = this.computeCompactionWatermark(clocks);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
await this.compact(documentId, watermark);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
console.error(`[text-compaction] failed for ${documentId}:`, err);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── Document loading ─────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Load text document state from database.
|
|
186
|
+
*/
|
|
187
|
+
async loadDocument(documentId: string): Promise<TextDocumentState | null> {
|
|
188
|
+
// Check in-memory cache first
|
|
189
|
+
if (this.documentStates.has(documentId)) {
|
|
190
|
+
return this.documentStates.get(documentId)!;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Load from database
|
|
194
|
+
const doc = await this.prisma.textDocument.findUnique({ where: { id: documentId } });
|
|
195
|
+
if (!doc) return null;
|
|
196
|
+
|
|
197
|
+
// Parse text rope
|
|
198
|
+
const textRope = parseTextSnapshot(doc.currentText, 'system');
|
|
199
|
+
|
|
200
|
+
// Load journal entries after the snapshot
|
|
201
|
+
const batches = await this.prisma.textJournalBatch.findMany({
|
|
202
|
+
where: { textDocumentId: documentId },
|
|
203
|
+
orderBy: { lamportTime: 'asc' },
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const journal: TextJournalEntry[] = batches.flatMap((batch) => {
|
|
207
|
+
const ops = Array.isArray(batch.operations) ? batch.operations : [];
|
|
208
|
+
return ops.map((op: any) => ({
|
|
209
|
+
msg: {
|
|
210
|
+
id: batch.batchId,
|
|
211
|
+
client: batch.client,
|
|
212
|
+
ops: [op],
|
|
213
|
+
clock: (batch.vectorClock as VectorClock) || {},
|
|
214
|
+
lt: batch.lamportTime || 0,
|
|
215
|
+
ts: new Date(batch.persistedAt).getTime() / 1000,
|
|
216
|
+
},
|
|
217
|
+
deletedAt: batch.deletedAt ? new Date(batch.deletedAt).getTime() / 1000 : undefined,
|
|
218
|
+
}));
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const state: TextDocumentState = {
|
|
222
|
+
snapshot: {
|
|
223
|
+
text: textRope,
|
|
224
|
+
vectorClock: {},
|
|
225
|
+
lt: 0,
|
|
226
|
+
},
|
|
227
|
+
journal,
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
this.documentStates.set(documentId, state);
|
|
231
|
+
return state;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Process incoming message from client
|
|
236
|
+
*/
|
|
237
|
+
async processMessage(
|
|
238
|
+
documentId: string,
|
|
239
|
+
msg: CRDTMessage
|
|
240
|
+
): Promise<{ success: boolean; error?: string }> {
|
|
241
|
+
// Load state
|
|
242
|
+
const state = await this.loadDocument(documentId);
|
|
243
|
+
if (!state) {
|
|
244
|
+
return { success: false, error: 'Text document not found' };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Check for duplicate
|
|
248
|
+
const exists = await this.prisma.textJournalBatch.findFirst({
|
|
249
|
+
where: { textDocumentId: documentId, batchId: msg.id },
|
|
250
|
+
});
|
|
251
|
+
if (exists) {
|
|
252
|
+
return { success: true }; // Already processed
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Process meta operations (undo/redo)
|
|
256
|
+
for (const op of msg.ops) {
|
|
257
|
+
if (op.ot === 'meta.undo') {
|
|
258
|
+
const targetId = (op as any).targetMsgId;
|
|
259
|
+
const target = state.journal.find((e) => e.msg.id === targetId);
|
|
260
|
+
if (target) {
|
|
261
|
+
target.deletedAt = msg.ts;
|
|
262
|
+
// Update in DB
|
|
263
|
+
await this.prisma.textJournalBatch.updateMany({
|
|
264
|
+
where: { textDocumentId: documentId, batchId: targetId },
|
|
265
|
+
data: { deletedAt: new Date(msg.ts * 1000) },
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
} else if (op.ot === 'meta.redo') {
|
|
269
|
+
const targetId = (op as any).targetMsgId;
|
|
270
|
+
const target = state.journal.find((e) => e.msg.id === targetId);
|
|
271
|
+
if (target) {
|
|
272
|
+
delete target.deletedAt;
|
|
273
|
+
// Clear in DB
|
|
274
|
+
await this.prisma.textJournalBatch.updateMany({
|
|
275
|
+
where: { textDocumentId: documentId, batchId: targetId },
|
|
276
|
+
data: { deletedAt: null },
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Add to journal
|
|
283
|
+
state.journal.push({ msg });
|
|
284
|
+
|
|
285
|
+
// Persist to database
|
|
286
|
+
await this.prisma.textJournalBatch.create({
|
|
287
|
+
data: {
|
|
288
|
+
textDocumentId: documentId,
|
|
289
|
+
client: msg.client,
|
|
290
|
+
batchId: msg.id,
|
|
291
|
+
operations: msg.ops as any,
|
|
292
|
+
vectorClock: msg.clock as any,
|
|
293
|
+
lamportTime: msg.lt,
|
|
294
|
+
startTime: new Date(msg.ts * 1000),
|
|
295
|
+
endTime: new Date(msg.ts * 1000),
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
return { success: true };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Get state for new client (snapshot + journal).
|
|
304
|
+
*/
|
|
305
|
+
async getStateForClient(documentId: string): Promise<{
|
|
306
|
+
snapshot: { text: string; vectorClock: VectorClock; lt: number };
|
|
307
|
+
journal: CRDTMessage[];
|
|
308
|
+
} | null> {
|
|
309
|
+
const state = await this.loadDocument(documentId);
|
|
310
|
+
if (!state) return null;
|
|
311
|
+
|
|
312
|
+
// Filter journal entries using vector clock comparison
|
|
313
|
+
const postSnapshotJournal = state.journal
|
|
314
|
+
.filter((e) => {
|
|
315
|
+
for (const [client, time] of Object.entries(e.msg.clock)) {
|
|
316
|
+
if (time > (state.snapshot.vectorClock[client] ?? 0)) {
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return false;
|
|
321
|
+
})
|
|
322
|
+
.map((e) => e.msg);
|
|
323
|
+
|
|
324
|
+
// Serialize TextRope to plain string via toJSON()
|
|
325
|
+
const serializedText = state.snapshot.text.toString();
|
|
326
|
+
|
|
327
|
+
return {
|
|
328
|
+
snapshot: {
|
|
329
|
+
text: serializedText,
|
|
330
|
+
vectorClock: state.snapshot.vectorClock,
|
|
331
|
+
lt: state.snapshot.lt,
|
|
332
|
+
},
|
|
333
|
+
journal: postSnapshotJournal,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Compact journal (create new snapshot from acknowledged entries).
|
|
339
|
+
*/
|
|
340
|
+
async compact(documentId: string, watermark?: VectorClock): Promise<void> {
|
|
341
|
+
// Acquire compaction lock
|
|
342
|
+
if (this.compactionLocks.has(documentId)) return;
|
|
343
|
+
this.compactionLocks.add(documentId);
|
|
344
|
+
|
|
345
|
+
try {
|
|
346
|
+
const state = await this.loadDocument(documentId);
|
|
347
|
+
if (!state || state.journal.length === 0) return;
|
|
348
|
+
|
|
349
|
+
// Determine compaction boundary
|
|
350
|
+
let compactUpToIndex = -1;
|
|
351
|
+
if (watermark) {
|
|
352
|
+
for (let i = 0; i < state.journal.length; i++) {
|
|
353
|
+
if (this.isDominatedByWatermark(state.journal[i].msg.clock, watermark)) {
|
|
354
|
+
compactUpToIndex = i;
|
|
355
|
+
} else {
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
} else {
|
|
360
|
+
compactUpToIndex = state.journal.length - 1;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (compactUpToIndex < 0) return;
|
|
364
|
+
|
|
365
|
+
// Build new snapshot
|
|
366
|
+
let newText = state.snapshot.text;
|
|
367
|
+
let mergedClock = { ...state.snapshot.vectorClock };
|
|
368
|
+
let maxLamport = state.snapshot.lt;
|
|
369
|
+
|
|
370
|
+
for (let i = 0; i <= compactUpToIndex; i++) {
|
|
371
|
+
const entry = state.journal[i];
|
|
372
|
+
if (!entry.deletedAt) {
|
|
373
|
+
// Apply text operations to rope
|
|
374
|
+
for (const op of entry.msg.ops) {
|
|
375
|
+
if (op.ot.startsWith('text.')) {
|
|
376
|
+
// Apply text operation
|
|
377
|
+
if (op.ot === 'text.insert') {
|
|
378
|
+
insert(newText, (op as any).position, (op as any).value);
|
|
379
|
+
} else if (op.ot === 'text.delete') {
|
|
380
|
+
deleteText(newText, (op as any).position, (op as any).length || 1);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
// Merge clock and lamport
|
|
386
|
+
for (const [client, time] of Object.entries(entry.msg.clock)) {
|
|
387
|
+
mergedClock[client] = Math.max(
|
|
388
|
+
mergedClock[client] || 0,
|
|
389
|
+
time,
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
maxLamport = Math.max(maxLamport, entry.msg.lt);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Compact TextRope (merge spans, remove tombstones)
|
|
396
|
+
newText = compactRope(newText);
|
|
397
|
+
|
|
398
|
+
// Collect batchIds of compacted entries
|
|
399
|
+
const compactedBatchIds = state.journal
|
|
400
|
+
.slice(0, compactUpToIndex + 1)
|
|
401
|
+
.map((e) => e.msg.id);
|
|
402
|
+
|
|
403
|
+
// Update snapshot
|
|
404
|
+
state.snapshot = {
|
|
405
|
+
text: newText,
|
|
406
|
+
vectorClock: mergedClock,
|
|
407
|
+
lt: maxLamport,
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
// Remove compacted entries from in-memory journal
|
|
411
|
+
state.journal = state.journal.slice(compactUpToIndex + 1);
|
|
412
|
+
|
|
413
|
+
// Persist snapshot (TextRope.toJSON() returns plain string)
|
|
414
|
+
const serializedText = newText.toJSON();
|
|
415
|
+
await this.prisma.textDocument.update({
|
|
416
|
+
where: { id: documentId },
|
|
417
|
+
data: { currentText: serializedText as any },
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
// Delete compacted batches from DB
|
|
421
|
+
if (compactedBatchIds.length > 0) {
|
|
422
|
+
const deletedCount = await this.prisma.textJournalBatch.deleteMany({
|
|
423
|
+
where: {
|
|
424
|
+
textDocumentId: documentId,
|
|
425
|
+
batchId: { in: compactedBatchIds },
|
|
426
|
+
},
|
|
427
|
+
});
|
|
428
|
+
console.log(`[text-compact] Deleted ${deletedCount.count} journal batches for doc ${documentId}`);
|
|
429
|
+
}
|
|
430
|
+
} finally {
|
|
431
|
+
this.compactionLocks.delete(documentId);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Create a new text document
|
|
437
|
+
*/
|
|
438
|
+
async createDocument(
|
|
439
|
+
name: string,
|
|
440
|
+
ownerId: string
|
|
441
|
+
): Promise<string> {
|
|
442
|
+
const doc = await this.prisma.textDocument.create({
|
|
443
|
+
data: {
|
|
444
|
+
name,
|
|
445
|
+
ownerId,
|
|
446
|
+
currentText: '',
|
|
447
|
+
version: 0,
|
|
448
|
+
},
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
// Initialize in-memory state
|
|
452
|
+
this.documentStates.set(doc.id, {
|
|
453
|
+
snapshot: {
|
|
454
|
+
text: create('system'),
|
|
455
|
+
vectorClock: {},
|
|
456
|
+
lt: 0,
|
|
457
|
+
},
|
|
458
|
+
journal: [],
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
return doc.id;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Clear all journal entries for a document (keep document record).
|
|
466
|
+
*/
|
|
467
|
+
async clearDocument(documentId: string): Promise<void> {
|
|
468
|
+
await this.prisma.textJournalBatch.deleteMany({
|
|
469
|
+
where: { textDocumentId: documentId },
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
// Reset in-memory state
|
|
473
|
+
const state = this.documentStates.get(documentId);
|
|
474
|
+
if (state) {
|
|
475
|
+
state.journal = [];
|
|
476
|
+
state.snapshot = {
|
|
477
|
+
text: create('system'),
|
|
478
|
+
vectorClock: {},
|
|
479
|
+
lt: 0,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
package/src/journal/index.ts
CHANGED
|
@@ -4,7 +4,15 @@
|
|
|
4
4
|
|
|
5
5
|
export { JournalRepository } from './JournalRepository.js';
|
|
6
6
|
export {
|
|
7
|
-
|
|
7
|
+
GraphJournalService,
|
|
8
8
|
type JournalEntry as ServerJournalEntry,
|
|
9
9
|
type DocumentState,
|
|
10
|
-
} from './
|
|
10
|
+
} from './GraphJournalService.js';
|
|
11
|
+
export {
|
|
12
|
+
TextJournalService,
|
|
13
|
+
type TextJournalEntry as ServerTextJournalEntry,
|
|
14
|
+
type TextDocumentState as ServerTextDocumentState,
|
|
15
|
+
} from './TextJournalService.js';
|
|
16
|
+
|
|
17
|
+
// Backward compatibility export
|
|
18
|
+
export { GraphJournalService as JournalService } from './GraphJournalService.js';
|
package/src/journal/rle-demo.ts
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
*/
|
|
18
18
|
function createMessage(
|
|
19
19
|
id: string,
|
|
20
|
-
|
|
20
|
+
client: string,
|
|
21
21
|
lamportTime: number,
|
|
22
22
|
timestamp: number,
|
|
23
23
|
key: string = 'cube-1',
|
|
@@ -25,14 +25,14 @@ function createMessage(
|
|
|
25
25
|
): CRDTMessage {
|
|
26
26
|
return {
|
|
27
27
|
id,
|
|
28
|
-
|
|
29
|
-
clock: { [
|
|
30
|
-
lamportTime,
|
|
31
|
-
timestamp,
|
|
28
|
+
client,
|
|
29
|
+
clock: { [client]: lamportTime },
|
|
30
|
+
lt: lamportTime,
|
|
31
|
+
ts: timestamp,
|
|
32
32
|
ops: [
|
|
33
33
|
{
|
|
34
34
|
key,
|
|
35
|
-
|
|
35
|
+
ot: 'vector3.set',
|
|
36
36
|
path: 'position',
|
|
37
37
|
value,
|
|
38
38
|
},
|
|
@@ -130,14 +130,14 @@ function main() {
|
|
|
130
130
|
const user = i < 15 ? 'user-1' : 'user-2';
|
|
131
131
|
const msg: CRDTMessage = {
|
|
132
132
|
id: `msg-${i}`,
|
|
133
|
-
|
|
133
|
+
client: user,
|
|
134
134
|
clock: { [user]: i },
|
|
135
|
-
|
|
136
|
-
|
|
135
|
+
lt: i,
|
|
136
|
+
ts: 1000 + i,
|
|
137
137
|
ops: [
|
|
138
138
|
{
|
|
139
139
|
key: `node-${i % 5}`,
|
|
140
|
-
|
|
140
|
+
ot: 'node.insert',
|
|
141
141
|
path: 'children',
|
|
142
142
|
value: {
|
|
143
143
|
key: `child-${i}`,
|
|
@@ -150,7 +150,7 @@ function main() {
|
|
|
150
150
|
},
|
|
151
151
|
{
|
|
152
152
|
key: `node-${i % 5}`,
|
|
153
|
-
|
|
153
|
+
ot: 'vector3.set',
|
|
154
154
|
path: 'color',
|
|
155
155
|
value: [Math.random(), Math.random(), Math.random()],
|
|
156
156
|
},
|