@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.
Files changed (104) hide show
  1. package/.env +1 -1
  2. package/README.md +56 -0
  3. package/dist/archive/ArchivalService.js +1 -1
  4. package/dist/archive/ArchivalService.js.map +1 -1
  5. package/dist/broker/InMemoryBroker.d.ts +2 -2
  6. package/dist/broker/InMemoryBroker.d.ts.map +1 -1
  7. package/dist/broker/InMemoryBroker.js +4 -4
  8. package/dist/broker/InMemoryBroker.js.map +1 -1
  9. package/dist/broker/types.d.ts +3 -3
  10. package/dist/broker/types.d.ts.map +1 -1
  11. package/dist/journal/CoalescingService.d.ts.map +1 -1
  12. package/dist/journal/CoalescingService.js +18 -208
  13. package/dist/journal/CoalescingService.js.map +1 -1
  14. package/dist/journal/GraphJournalService.d.ts +127 -0
  15. package/dist/journal/GraphJournalService.d.ts.map +1 -0
  16. package/dist/journal/GraphJournalService.js +491 -0
  17. package/dist/journal/GraphJournalService.js.map +1 -0
  18. package/dist/journal/JournalRLE.d.ts +2 -2
  19. package/dist/journal/JournalRLE.js +14 -14
  20. package/dist/journal/JournalRLE.js.map +1 -1
  21. package/dist/journal/JournalRepository.js +7 -7
  22. package/dist/journal/JournalRepository.js.map +1 -1
  23. package/dist/journal/JournalService.d.ts.map +1 -1
  24. package/dist/journal/JournalService.js +6 -40
  25. package/dist/journal/JournalService.js.map +1 -1
  26. package/dist/journal/RLECompression.d.ts +9 -9
  27. package/dist/journal/RLECompression.d.ts.map +1 -1
  28. package/dist/journal/RLECompression.js +22 -22
  29. package/dist/journal/RLECompression.js.map +1 -1
  30. package/dist/journal/TextJournalService.d.ts +98 -0
  31. package/dist/journal/TextJournalService.d.ts.map +1 -0
  32. package/dist/journal/TextJournalService.js +401 -0
  33. package/dist/journal/TextJournalService.js.map +1 -0
  34. package/dist/journal/index.d.ts +3 -1
  35. package/dist/journal/index.d.ts.map +1 -1
  36. package/dist/journal/index.js +4 -1
  37. package/dist/journal/index.js.map +1 -1
  38. package/dist/journal/rle-demo.js +11 -11
  39. package/dist/journal/rle-demo.js.map +1 -1
  40. package/dist/serve.d.ts +29 -11
  41. package/dist/serve.d.ts.map +1 -1
  42. package/dist/serve.js +558 -93
  43. package/dist/serve.js.map +1 -1
  44. package/dist/transport/RTCServer.d.ts +2 -2
  45. package/dist/transport/RTCServer.d.ts.map +1 -1
  46. package/dist/transport/RTCServer.js +22 -22
  47. package/dist/transport/RTCServer.js.map +1 -1
  48. package/docs/API.md +642 -0
  49. package/examples/compression-example.ts +3 -3
  50. package/package.json +2 -2
  51. package/prisma/schema.prisma +124 -6
  52. package/src/archive/ArchivalService.ts +1 -1
  53. package/src/broker/InMemoryBroker.ts +4 -4
  54. package/src/broker/types.ts +3 -3
  55. package/src/journal/CoalescingService.ts +18 -235
  56. package/src/journal/{JournalService.ts → GraphJournalService.ts} +34 -74
  57. package/src/journal/JournalRLE.ts +15 -15
  58. package/src/journal/JournalRepository.ts +7 -7
  59. package/src/journal/RLECompression.ts +24 -24
  60. package/src/journal/TextJournalService.ts +483 -0
  61. package/src/journal/index.ts +10 -2
  62. package/src/journal/rle-demo.ts +11 -11
  63. package/src/serve.ts +598 -94
  64. package/src/transport/RTCServer.ts +23 -23
  65. package/tests/benchmark/journal-optimization-benchmark.test.ts +14 -14
  66. package/tests/compression/compression.test.ts +8 -8
  67. package/tests/demo.ts +88 -88
  68. package/tests/e2e/convergence.test.ts +9 -9
  69. package/tests/e2e/helpers/assertions.ts +22 -0
  70. package/tests/e2e/helpers/createTestServer.ts +4 -4
  71. package/tests/e2e/latency.test.ts +47 -41
  72. package/tests/e2e/packet-loss.test.ts +6 -6
  73. package/tests/e2e/relay.test.ts +9 -9
  74. package/tests/e2e/sync-perf.test.ts +5 -5
  75. package/tests/e2e/sync-reconciliation.test.ts +6 -6
  76. package/tests/e2e/text-sync.test.ts +14 -14
  77. package/tests/e2e/tombstone-convergence.test.ts +22 -22
  78. package/tests/fixtures/array-ops.jsonl +6 -6
  79. package/tests/fixtures/boolean-ops.jsonl +6 -6
  80. package/tests/fixtures/color-ops.jsonl +4 -4
  81. package/tests/fixtures/edit-buffer.jsonl +3 -3
  82. package/tests/fixtures/messages.jsonl +4 -4
  83. package/tests/fixtures/node-ops.jsonl +6 -6
  84. package/tests/fixtures/number-ops.jsonl +7 -7
  85. package/tests/fixtures/object-ops.jsonl +4 -4
  86. package/tests/fixtures/operations.jsonl +7 -7
  87. package/tests/fixtures/string-ops.jsonl +4 -4
  88. package/tests/fixtures/undo-redo.jsonl +3 -3
  89. package/tests/fixtures/vector-ops.jsonl +9 -9
  90. package/tests/integration/repositories.test.ts +8 -9
  91. package/tests/journal/compaction-load-bug.test.ts +31 -31
  92. package/tests/journal/compaction.test.ts +26 -26
  93. package/tests/journal/journal-rle.test.ts +38 -38
  94. package/tests/journal/journal-service.test.ts +13 -13
  95. package/tests/journal/lww-ordering-bug.test.ts +39 -39
  96. package/tests/journal/rle-compression.test.ts +71 -71
  97. package/tests/journal/text-coalescing.test.ts +34 -34
  98. package/tests/test-data/datatypes.ts +85 -85
  99. package/tests/test-data/operations-example.ts +62 -62
  100. package/tests/test-data/scene-example.ts +11 -11
  101. package/tests/unit/operations.test.ts +7 -7
  102. package/tests/unit/s3-compression.test.ts +5 -3
  103. package/tests/unit/vectorClock.test.ts +2 -2
  104. package/tests/journal/multi-session-coalescing.test.ts +0 -871
@@ -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 SceneState
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
- sessionId String // Session that created these operations
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 sessionId -> counter (causal ordering)
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([sessionId])
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.lamportTime || 0,
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
- sessionId: string,
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(sessionId, state);
58
+ this.members.get(roomId)!.set(client, state);
59
59
  }
60
60
 
61
- async removeMember(roomId: string, sessionId: string): Promise<void> {
62
- this.members.get(roomId)?.delete(sessionId);
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>> {
@@ -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
- sessionId: string;
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, sessionId: string, state: MemberState): Promise<void>;
47
+ setMember(roomId: string, client: string, state: MemberState): Promise<void>;
48
48
 
49
49
  /** Remove a member from a room. */
50
- removeMember(roomId: string, sessionId: string): Promise<void>;
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.otype.endsWith('.set');
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.otype === 'vector3.add' ||
123
- op.otype === 'number.add';
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.otype}`;
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.otype === op.otype) {
219
+ if (existing && existing.op.ot === op.ot) {
434
220
  // Merge values
435
- if (op.otype === 'vector3.add') {
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.otype === 'number.add') {
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 lastSessionId: string | null = null;
277
+ let lastClient: string | null = null;
492
278
 
493
279
  for (const msg of messages) {
494
- if (msg.sessionId !== lastSessionId && currentRun.length > 0) {
495
- // Session switch - emit current run and start new one
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
- lastSessionId = msg.sessionId;
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.timestamp * 1000; // convert to ms
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
- sessionId: b.sessionId ?? 'unknown',
350
+ client: b.client ?? 'unknown',
568
351
  clock: (b.vectorClock as Record<string, number>) ?? {},
569
- lamportTime: b.lamportTime ?? 0,
570
- timestamp: b.persistedAt.getTime() / 1000,
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.sessionId}-${Date.now()}-${i}`,
604
- lamportTime: msg.lamportTime,
605
- sessionId: msg.sessionId,
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,