@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
package/prisma/schema.prisma
CHANGED
|
@@ -10,7 +10,7 @@ datasource db {
|
|
|
10
10
|
url = env("DATABASE_URL")
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
// Document represents a collaborative 3D scene
|
|
13
|
+
// Document represents a collaborative 3D scene graph
|
|
14
14
|
model Document {
|
|
15
15
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
|
16
16
|
name String
|
|
@@ -19,7 +19,7 @@ model Document {
|
|
|
19
19
|
updatedAt DateTime @updatedAt
|
|
20
20
|
|
|
21
21
|
// Current state snapshot (for fast loading without replaying all operations)
|
|
22
|
-
currentState Json // Serialized
|
|
22
|
+
currentState Json // Serialized SceneGraph
|
|
23
23
|
version Int @default(0) // Monotonically increasing version number
|
|
24
24
|
|
|
25
25
|
// Relations
|
|
@@ -31,6 +31,27 @@ model Document {
|
|
|
31
31
|
@@index([updatedAt])
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// TextDocument represents a collaborative text document (standalone CRDT)
|
|
35
|
+
model TextDocument {
|
|
36
|
+
id String @id @default(auto()) @map("_id") @db.ObjectId
|
|
37
|
+
name String
|
|
38
|
+
ownerId String
|
|
39
|
+
createdAt DateTime @default(now())
|
|
40
|
+
updatedAt DateTime @updatedAt
|
|
41
|
+
|
|
42
|
+
// Current text content (plain string from TextRope.toJSON())
|
|
43
|
+
currentText Json // Plain string or serialized TextRope
|
|
44
|
+
version Int @default(0) // Monotonically increasing version number
|
|
45
|
+
|
|
46
|
+
// Relations
|
|
47
|
+
textOperations TextOperation[]
|
|
48
|
+
textSessions TextSession[]
|
|
49
|
+
textJournalBatches TextJournalBatch[]
|
|
50
|
+
|
|
51
|
+
@@index([ownerId])
|
|
52
|
+
@@index([updatedAt])
|
|
53
|
+
}
|
|
54
|
+
|
|
34
55
|
// Operation represents a single edit operation (event sourcing)
|
|
35
56
|
model Operation {
|
|
36
57
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
|
@@ -68,7 +89,7 @@ model Operation {
|
|
|
68
89
|
model JournalBatch {
|
|
69
90
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
|
70
91
|
documentId String @db.ObjectId
|
|
71
|
-
|
|
92
|
+
client String // Client that created these operations
|
|
72
93
|
|
|
73
94
|
// Batch identification
|
|
74
95
|
batchId String // Client-generated batch UUID
|
|
@@ -77,7 +98,7 @@ model JournalBatch {
|
|
|
77
98
|
operations Json[] // Array of serialized operations
|
|
78
99
|
|
|
79
100
|
// CRDT ordering metadata (optional for backward compat with existing rows)
|
|
80
|
-
vectorClock Json? // Map of
|
|
101
|
+
vectorClock Json? // Map of client -> counter (causal ordering)
|
|
81
102
|
lamportTime Int? @default(0) // Lamport timestamp for deterministic tie-breaking
|
|
82
103
|
deletedAt DateTime? // Soft-delete timestamp (set by meta.undo)
|
|
83
104
|
|
|
@@ -92,10 +113,10 @@ model JournalBatch {
|
|
|
92
113
|
@@unique([documentId, batchId])
|
|
93
114
|
@@index([documentId, persistedAt])
|
|
94
115
|
@@index([documentId, lamportTime])
|
|
95
|
-
@@index([
|
|
116
|
+
@@index([client])
|
|
96
117
|
}
|
|
97
118
|
|
|
98
|
-
// Session represents a client connection
|
|
119
|
+
// Session represents a client connection to a graph document
|
|
99
120
|
model Session {
|
|
100
121
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
|
101
122
|
documentId String @db.ObjectId
|
|
@@ -125,3 +146,100 @@ model Session {
|
|
|
125
146
|
@@index([clientId])
|
|
126
147
|
@@index([documentId, connected])
|
|
127
148
|
}
|
|
149
|
+
|
|
150
|
+
// ====================================
|
|
151
|
+
// Text Document Models
|
|
152
|
+
// ====================================
|
|
153
|
+
|
|
154
|
+
// TextOperation represents a single text edit operation
|
|
155
|
+
model TextOperation {
|
|
156
|
+
id String @id @default(auto()) @map("_id") @db.ObjectId
|
|
157
|
+
textDocumentId String @db.ObjectId
|
|
158
|
+
textSessionId String @db.ObjectId
|
|
159
|
+
|
|
160
|
+
// Operation identification
|
|
161
|
+
opId String // Client-generated UUID for idempotency
|
|
162
|
+
type String // "text.insert" | "text.delete" | "text.replace"
|
|
163
|
+
|
|
164
|
+
// Operation payload
|
|
165
|
+
data Json // Type-specific operation data
|
|
166
|
+
|
|
167
|
+
// CRDT ordering metadata
|
|
168
|
+
vectorClock Json // Map of sessionId -> counter (causal ordering)
|
|
169
|
+
lamportTime Int // Lamport timestamp for deterministic tie-breaking
|
|
170
|
+
|
|
171
|
+
// Timestamps
|
|
172
|
+
createdAt DateTime @default(now())
|
|
173
|
+
appliedAt DateTime @default(now())
|
|
174
|
+
|
|
175
|
+
// Relations
|
|
176
|
+
textDocument TextDocument @relation(fields: [textDocumentId], references: [id], onDelete: Cascade)
|
|
177
|
+
textSession TextSession @relation(fields: [textSessionId], references: [id], onDelete: Cascade)
|
|
178
|
+
|
|
179
|
+
@@unique([textDocumentId, opId])
|
|
180
|
+
@@index([textDocumentId, lamportTime])
|
|
181
|
+
@@index([textSessionId])
|
|
182
|
+
@@index([textDocumentId, createdAt])
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// TextJournalBatch represents a batched write-ahead log entry for text operations
|
|
186
|
+
model TextJournalBatch {
|
|
187
|
+
id String @id @default(auto()) @map("_id") @db.ObjectId
|
|
188
|
+
textDocumentId String @db.ObjectId
|
|
189
|
+
client String // Client that created these operations
|
|
190
|
+
|
|
191
|
+
// Batch identification
|
|
192
|
+
batchId String // Client-generated batch UUID
|
|
193
|
+
|
|
194
|
+
// Batched operations (array of operation data)
|
|
195
|
+
operations Json[] // Array of serialized operations
|
|
196
|
+
|
|
197
|
+
// CRDT ordering metadata
|
|
198
|
+
vectorClock Json? // Map of client -> counter (causal ordering)
|
|
199
|
+
lamportTime Int? @default(0) // Lamport timestamp for deterministic tie-breaking
|
|
200
|
+
deletedAt DateTime? // Soft-delete timestamp (set by meta.undo)
|
|
201
|
+
|
|
202
|
+
// Timing metadata
|
|
203
|
+
startTime DateTime // Timestamp of first operation in batch
|
|
204
|
+
endTime DateTime // Timestamp of last operation in batch
|
|
205
|
+
persistedAt DateTime @default(now()) // When this batch was saved to DB
|
|
206
|
+
|
|
207
|
+
// Relations
|
|
208
|
+
textDocument TextDocument @relation(fields: [textDocumentId], references: [id], onDelete: Cascade)
|
|
209
|
+
|
|
210
|
+
@@unique([textDocumentId, batchId])
|
|
211
|
+
@@index([textDocumentId, persistedAt])
|
|
212
|
+
@@index([textDocumentId, lamportTime])
|
|
213
|
+
@@index([client])
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// TextSession represents a client connection to a text document
|
|
217
|
+
model TextSession {
|
|
218
|
+
id String @id @default(auto()) @map("_id") @db.ObjectId
|
|
219
|
+
textDocumentId String @db.ObjectId
|
|
220
|
+
|
|
221
|
+
// User and client identification
|
|
222
|
+
userId String
|
|
223
|
+
clientId String // Unique client identifier (for reconnection)
|
|
224
|
+
|
|
225
|
+
// Vector clock state for this session
|
|
226
|
+
clockValue Int @default(0) // This session's clock counter
|
|
227
|
+
|
|
228
|
+
// Connection state
|
|
229
|
+
connected Boolean @default(true)
|
|
230
|
+
connectedAt DateTime @default(now())
|
|
231
|
+
disconnectedAt DateTime?
|
|
232
|
+
lastSeenAt DateTime @default(now())
|
|
233
|
+
|
|
234
|
+
// Presence data (cursor position, selection, etc.)
|
|
235
|
+
presence Json? // PresenceData structure
|
|
236
|
+
|
|
237
|
+
// Relations
|
|
238
|
+
textDocument TextDocument @relation(fields: [textDocumentId], references: [id], onDelete: Cascade)
|
|
239
|
+
textOperations TextOperation[]
|
|
240
|
+
|
|
241
|
+
@@index([textDocumentId])
|
|
242
|
+
@@index([userId])
|
|
243
|
+
@@index([clientId])
|
|
244
|
+
@@index([textDocumentId, connected])
|
|
245
|
+
}
|
|
@@ -62,7 +62,7 @@ export class ArchivalService {
|
|
|
62
62
|
const metadata: ArchivalMetadata = {
|
|
63
63
|
documentId,
|
|
64
64
|
snapshotVersion: snapshot.journalIndex || 0,
|
|
65
|
-
lamportTime: snapshot.
|
|
65
|
+
lamportTime: snapshot.lt || 0,
|
|
66
66
|
originalSize: compressionResult.originalSize,
|
|
67
67
|
compressedSize: compressionResult.compressedSize,
|
|
68
68
|
compressionRatio: compressionResult.ratio,
|
|
@@ -49,17 +49,17 @@ export class InMemoryBroker implements RoomBroker {
|
|
|
49
49
|
|
|
50
50
|
async setMember(
|
|
51
51
|
roomId: string,
|
|
52
|
-
|
|
52
|
+
client: string,
|
|
53
53
|
state: MemberState,
|
|
54
54
|
): Promise<void> {
|
|
55
55
|
if (!this.members.has(roomId)) {
|
|
56
56
|
this.members.set(roomId, new Map());
|
|
57
57
|
}
|
|
58
|
-
this.members.get(roomId)!.set(
|
|
58
|
+
this.members.get(roomId)!.set(client, state);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
async removeMember(roomId: string,
|
|
62
|
-
this.members.get(roomId)?.delete(
|
|
61
|
+
async removeMember(roomId: string, client: string): Promise<void> {
|
|
62
|
+
this.members.get(roomId)?.delete(client);
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
async getMembers(roomId: string): Promise<Map<string, MemberState>> {
|
package/src/broker/types.ts
CHANGED
|
@@ -19,7 +19,7 @@ export interface SequencedMessage extends CRDTMessage {
|
|
|
19
19
|
* Per-member state tracked by the broker.
|
|
20
20
|
*/
|
|
21
21
|
export interface MemberState {
|
|
22
|
-
|
|
22
|
+
client: string;
|
|
23
23
|
vectorClock: Record<string, number>;
|
|
24
24
|
lastSeen: number;
|
|
25
25
|
connected: boolean;
|
|
@@ -44,10 +44,10 @@ export interface RoomBroker {
|
|
|
44
44
|
nextSeq(roomId: string): Promise<number>;
|
|
45
45
|
|
|
46
46
|
/** Register or update a member in a room. */
|
|
47
|
-
setMember(roomId: string,
|
|
47
|
+
setMember(roomId: string, client: string, state: MemberState): Promise<void>;
|
|
48
48
|
|
|
49
49
|
/** Remove a member from a room. */
|
|
50
|
-
removeMember(roomId: string,
|
|
50
|
+
removeMember(roomId: string, client: string): Promise<void>;
|
|
51
51
|
|
|
52
52
|
/** Get all members in a room. */
|
|
53
53
|
getMembers(roomId: string): Promise<Map<string, MemberState>>;
|
|
@@ -58,28 +58,6 @@ export interface CoalescingResult {
|
|
|
58
58
|
reduction: { journalBatches: number; operations: number };
|
|
59
59
|
}
|
|
60
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
61
|
/**
|
|
84
62
|
* Operation with embedded timestamp for coalescing
|
|
85
63
|
*/
|
|
@@ -88,29 +66,11 @@ interface TimestampedOp {
|
|
|
88
66
|
timestamp: number; // ms
|
|
89
67
|
}
|
|
90
68
|
|
|
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
69
|
/**
|
|
110
70
|
* Check if an operation is a set operation
|
|
111
71
|
*/
|
|
112
72
|
function isSetOp(op: Operation): boolean {
|
|
113
|
-
return op.
|
|
73
|
+
return op.ot.endsWith('.set');
|
|
114
74
|
}
|
|
115
75
|
|
|
116
76
|
/**
|
|
@@ -119,184 +79,10 @@ function isSetOp(op: Operation): boolean {
|
|
|
119
79
|
* factors together gives a different result than applying them sequentially
|
|
120
80
|
*/
|
|
121
81
|
function isAdditiveOp(op: Operation): boolean {
|
|
122
|
-
return op.
|
|
123
|
-
op.
|
|
82
|
+
return op.ot === 'vector3.add' ||
|
|
83
|
+
op.ot === 'number.add';
|
|
124
84
|
}
|
|
125
85
|
|
|
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
86
|
|
|
301
87
|
/**
|
|
302
88
|
* Coalesce set operations with configurable strategy
|
|
@@ -427,17 +213,17 @@ function coalesceAdditiveOps(tOps: TimestampedOp[]): TimestampedOp[] {
|
|
|
427
213
|
for (const tOp of tOps) {
|
|
428
214
|
const op = tOp.op;
|
|
429
215
|
if (isAdditiveOp(op)) {
|
|
430
|
-
const key = `${(op as any).key}:${(op as any).path}:${op.
|
|
216
|
+
const key = `${(op as any).key}:${(op as any).path}:${op.ot}`;
|
|
431
217
|
const existing = pendingByKey.get(key);
|
|
432
218
|
|
|
433
|
-
if (existing && existing.op.
|
|
219
|
+
if (existing && existing.op.ot === op.ot) {
|
|
434
220
|
// Merge values
|
|
435
|
-
if (op.
|
|
221
|
+
if (op.ot === 'vector3.add') {
|
|
436
222
|
const va = (existing.op as any).value as [number, number, number];
|
|
437
223
|
const vb = (op as any).value as [number, number, number];
|
|
438
224
|
(existing.op as any).value = [va[0] + vb[0], va[1] + vb[1], va[2] + vb[2]];
|
|
439
225
|
existing.timestamp = tOp.timestamp; // update to latest timestamp
|
|
440
|
-
} else if (op.
|
|
226
|
+
} else if (op.ot === 'number.add') {
|
|
441
227
|
(existing.op as any).value = (existing.op as any).value + (op as any).value;
|
|
442
228
|
existing.timestamp = tOp.timestamp;
|
|
443
229
|
}
|
|
@@ -488,16 +274,16 @@ function coalesceOperations(
|
|
|
488
274
|
// Group messages into consecutive runs from the same session
|
|
489
275
|
const runs: CRDTMessage[][] = [];
|
|
490
276
|
let currentRun: CRDTMessage[] = [];
|
|
491
|
-
let
|
|
277
|
+
let lastClient: string | null = null;
|
|
492
278
|
|
|
493
279
|
for (const msg of messages) {
|
|
494
|
-
if (msg.
|
|
495
|
-
//
|
|
280
|
+
if (msg.client !== lastClient && currentRun.length > 0) {
|
|
281
|
+
// Client switch - emit current run and start new one
|
|
496
282
|
runs.push(currentRun);
|
|
497
283
|
currentRun = [];
|
|
498
284
|
}
|
|
499
285
|
currentRun.push(msg);
|
|
500
|
-
|
|
286
|
+
lastClient = msg.client;
|
|
501
287
|
}
|
|
502
288
|
if (currentRun.length > 0) {
|
|
503
289
|
runs.push(currentRun);
|
|
@@ -510,16 +296,13 @@ function coalesceOperations(
|
|
|
510
296
|
// Flatten all ops from this run with embedded timestamps
|
|
511
297
|
let tOps: TimestampedOp[] = [];
|
|
512
298
|
for (const msg of run) {
|
|
513
|
-
const timestamp = msg.
|
|
299
|
+
const timestamp = msg.ts * 1000; // convert to ms
|
|
514
300
|
for (const op of msg.ops) {
|
|
515
301
|
tOps.push({ op, timestamp });
|
|
516
302
|
}
|
|
517
303
|
}
|
|
518
304
|
|
|
519
305
|
// Apply coalescing in order
|
|
520
|
-
if (config.enableTextCoalesce !== false) {
|
|
521
|
-
tOps = coalesceTextOps(tOps);
|
|
522
|
-
}
|
|
523
306
|
if (config.enableSetCoalesce !== false) {
|
|
524
307
|
tOps = coalesceSetOps(tOps, config);
|
|
525
308
|
}
|
|
@@ -564,10 +347,10 @@ export class CoalescingService {
|
|
|
564
347
|
// Convert batches to messages
|
|
565
348
|
const messages: CRDTMessage[] = batches.map(b => ({
|
|
566
349
|
id: b.id,
|
|
567
|
-
|
|
350
|
+
client: b.client ?? 'unknown',
|
|
568
351
|
clock: (b.vectorClock as Record<string, number>) ?? {},
|
|
569
|
-
|
|
570
|
-
|
|
352
|
+
lt: b.lamportTime ?? 0,
|
|
353
|
+
ts: b.persistedAt.getTime() / 1000,
|
|
571
354
|
ops: (Array.isArray(b.operations) ? b.operations : []) as unknown as Operation[],
|
|
572
355
|
}));
|
|
573
356
|
|
|
@@ -600,9 +383,9 @@ export class CoalescingService {
|
|
|
600
383
|
await tx.journalBatch.create({
|
|
601
384
|
data: {
|
|
602
385
|
documentId: docId,
|
|
603
|
-
batchId: `coalesced-${msg.
|
|
604
|
-
lamportTime: msg.
|
|
605
|
-
|
|
386
|
+
batchId: `coalesced-${msg.client}-${Date.now()}-${i}`,
|
|
387
|
+
lamportTime: msg.lt,
|
|
388
|
+
client: msg.client,
|
|
606
389
|
vectorClock: msg.clock,
|
|
607
390
|
operations: msg.ops as unknown as any[],
|
|
608
391
|
startTime: now,
|