@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/tests/demo.ts
CHANGED
|
@@ -15,22 +15,22 @@ type VectorClock = Record<string, number>;
|
|
|
15
15
|
|
|
16
16
|
interface CRDTMessage {
|
|
17
17
|
id: string;
|
|
18
|
-
|
|
18
|
+
client: string;
|
|
19
19
|
clock: VectorClock;
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
lt: number;
|
|
21
|
+
ts: number;
|
|
22
22
|
ops: Operation[];
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
interface BaseOp {
|
|
26
26
|
key: string;
|
|
27
|
-
|
|
27
|
+
ot: string;
|
|
28
28
|
path: string;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
interface NodeInsertOp extends BaseOp {
|
|
32
32
|
key: string;
|
|
33
|
-
|
|
33
|
+
ot: 'node.insert';
|
|
34
34
|
path: string;
|
|
35
35
|
value: {
|
|
36
36
|
key: string;
|
|
@@ -42,62 +42,62 @@ interface NodeInsertOp extends BaseOp {
|
|
|
42
42
|
|
|
43
43
|
interface NodeRemoveOp extends BaseOp {
|
|
44
44
|
key: string;
|
|
45
|
-
|
|
45
|
+
ot: 'node.remove';
|
|
46
46
|
path: string;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
interface NumberSetOp extends BaseOp {
|
|
50
50
|
key: string;
|
|
51
|
-
|
|
51
|
+
ot: 'number.set';
|
|
52
52
|
path: string;
|
|
53
53
|
value: number;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
interface NumberAddOp extends BaseOp {
|
|
57
57
|
key: string;
|
|
58
|
-
|
|
58
|
+
ot: 'number.add';
|
|
59
59
|
path: string;
|
|
60
60
|
value: number;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
interface Vector3SetOp extends BaseOp {
|
|
64
64
|
key: string;
|
|
65
|
-
|
|
65
|
+
ot: 'vector3.set';
|
|
66
66
|
path: string;
|
|
67
67
|
value: [number, number, number];
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
interface Vector3AddOp extends BaseOp {
|
|
71
71
|
key: string;
|
|
72
|
-
|
|
72
|
+
ot: 'vector3.add';
|
|
73
73
|
path: string;
|
|
74
74
|
value: [number, number, number];
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
interface ColorSetOp extends BaseOp {
|
|
78
78
|
key: string;
|
|
79
|
-
|
|
79
|
+
ot: 'color.set';
|
|
80
80
|
path: string;
|
|
81
81
|
value: string;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
interface ArraySetOp extends BaseOp {
|
|
85
85
|
key: string;
|
|
86
|
-
|
|
86
|
+
ot: 'array.set';
|
|
87
87
|
path: string;
|
|
88
88
|
value: any[];
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
interface ArrayPushOp extends BaseOp {
|
|
92
92
|
key: string;
|
|
93
|
-
|
|
93
|
+
ot: 'array.push';
|
|
94
94
|
path: string;
|
|
95
95
|
value: any;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
interface ArrayRemoveOp extends BaseOp {
|
|
99
99
|
key: string;
|
|
100
|
-
|
|
100
|
+
ot: 'array.remove';
|
|
101
101
|
path: string;
|
|
102
102
|
value: any;
|
|
103
103
|
}
|
|
@@ -125,16 +125,16 @@ console.log('\n📦 Step 1: Create Scene\n');
|
|
|
125
125
|
const msg1: CRDTMessage = {
|
|
126
126
|
// === CRDT Wrapper (envelope) ===
|
|
127
127
|
id: 'msg-001',
|
|
128
|
-
|
|
128
|
+
client: 'session-server',
|
|
129
129
|
clock: { 'session-server': 1 },
|
|
130
|
-
|
|
131
|
-
|
|
130
|
+
lt: 1,
|
|
131
|
+
ts: Date.now() / 1000,
|
|
132
132
|
|
|
133
133
|
// === Operations (batch) ===
|
|
134
134
|
ops: [
|
|
135
135
|
{
|
|
136
136
|
key: 'scene',
|
|
137
|
-
|
|
137
|
+
ot: 'node.insert',
|
|
138
138
|
path: 'scene',
|
|
139
139
|
value: {
|
|
140
140
|
key: 'uuid-scene-001',
|
|
@@ -150,11 +150,11 @@ const msg1: CRDTMessage = {
|
|
|
150
150
|
};
|
|
151
151
|
|
|
152
152
|
console.log('Message 1:');
|
|
153
|
-
console.log(' Envelope: id=%s, session=%s, lamport=%d', msg1.id, msg1.
|
|
153
|
+
console.log(' Envelope: id=%s, session=%s, lamport=%d', msg1.id, msg1.client, msg1.lt);
|
|
154
154
|
console.log(' Operations: %d ops', msg1.ops.length);
|
|
155
155
|
const op1 = msg1.ops[0];
|
|
156
|
-
if (op1.
|
|
157
|
-
console.log(' ✓ %s: %s (tag=%s)', op1.
|
|
156
|
+
if (op1.ot === 'node.insert') {
|
|
157
|
+
console.log(' ✓ %s: %s (tag=%s)', op1.ot, op1.key, op1.value.tag);
|
|
158
158
|
}
|
|
159
159
|
|
|
160
160
|
// ========================================
|
|
@@ -164,15 +164,15 @@ console.log('\n📦 Step 2: Batch Insert - Multiple Nodes\n');
|
|
|
164
164
|
|
|
165
165
|
const msg2: CRDTMessage = {
|
|
166
166
|
id: 'msg-002',
|
|
167
|
-
|
|
167
|
+
client: 'session-alice',
|
|
168
168
|
clock: { 'session-alice': 1 },
|
|
169
|
-
|
|
170
|
-
|
|
169
|
+
lt: 2,
|
|
170
|
+
ts: Date.now() / 1000,
|
|
171
171
|
ops: [
|
|
172
172
|
// Insert cube
|
|
173
173
|
{
|
|
174
174
|
key: 'cube-1',
|
|
175
|
-
|
|
175
|
+
ot: 'node.insert',
|
|
176
176
|
path: 'cube-1',
|
|
177
177
|
value: {
|
|
178
178
|
key: 'uuid-cube-001',
|
|
@@ -187,7 +187,7 @@ const msg2: CRDTMessage = {
|
|
|
187
187
|
// Insert sphere
|
|
188
188
|
{
|
|
189
189
|
key: 'sphere-1',
|
|
190
|
-
|
|
190
|
+
ot: 'node.insert',
|
|
191
191
|
path: 'sphere-1',
|
|
192
192
|
value: {
|
|
193
193
|
key: 'uuid-sphere-001',
|
|
@@ -202,7 +202,7 @@ const msg2: CRDTMessage = {
|
|
|
202
202
|
// Add both to scene's children
|
|
203
203
|
{
|
|
204
204
|
key: 'scene',
|
|
205
|
-
|
|
205
|
+
ot: 'array.set',
|
|
206
206
|
path: 'children',
|
|
207
207
|
value: ['cube-1', 'sphere-1'],
|
|
208
208
|
},
|
|
@@ -210,13 +210,13 @@ const msg2: CRDTMessage = {
|
|
|
210
210
|
};
|
|
211
211
|
|
|
212
212
|
console.log('Message 2 (BATCH):');
|
|
213
|
-
console.log(' Envelope: id=%s, session=%s, lamport=%d', msg2.id, msg2.
|
|
213
|
+
console.log(' Envelope: id=%s, session=%s, lamport=%d', msg2.id, msg2.client, msg2.lt);
|
|
214
214
|
console.log(' Operations: %d ops (BATCHED!)', msg2.ops.length);
|
|
215
215
|
msg2.ops.forEach((op) => {
|
|
216
|
-
if (op.
|
|
217
|
-
console.log(' ✓ %s: %s (tag=%s)', op.
|
|
216
|
+
if (op.ot === 'node.insert') {
|
|
217
|
+
console.log(' ✓ %s: %s (tag=%s)', op.ot, op.key, op.value.tag);
|
|
218
218
|
} else {
|
|
219
|
-
console.log(' ✓ %s: %s.%s', op.
|
|
219
|
+
console.log(' ✓ %s: %s.%s', op.ot, op.key, op.path);
|
|
220
220
|
}
|
|
221
221
|
});
|
|
222
222
|
|
|
@@ -227,14 +227,14 @@ console.log('\n🎯 Step 3: Additive Transform (Drag)\n');
|
|
|
227
227
|
|
|
228
228
|
const msg3: CRDTMessage = {
|
|
229
229
|
id: 'msg-003',
|
|
230
|
-
|
|
230
|
+
client: 'session-alice',
|
|
231
231
|
clock: { 'session-alice': 2 },
|
|
232
|
-
|
|
233
|
-
|
|
232
|
+
lt: 3,
|
|
233
|
+
ts: Date.now() / 1000,
|
|
234
234
|
ops: [
|
|
235
235
|
{
|
|
236
236
|
key: 'cube-1',
|
|
237
|
-
|
|
237
|
+
ot: 'vector3.add', // Additive!
|
|
238
238
|
path: 'transform.position',
|
|
239
239
|
value: [5, 0, 0], // Drag by +5 on X
|
|
240
240
|
},
|
|
@@ -243,7 +243,7 @@ const msg3: CRDTMessage = {
|
|
|
243
243
|
|
|
244
244
|
console.log('Message 3 (Additive):');
|
|
245
245
|
const op3 = msg3.ops[0] as Vector3AddOp;
|
|
246
|
-
console.log(' ✓ %s: %s.%s += %s', op3.
|
|
246
|
+
console.log(' ✓ %s: %s.%s += %s', op3.ot, op3.key, op3.path, JSON.stringify(op3.value));
|
|
247
247
|
console.log(' → Relative movement (position.x += 5)');
|
|
248
248
|
|
|
249
249
|
// ========================================
|
|
@@ -253,14 +253,14 @@ console.log('\n🎯 Step 4: Absolute Transform (Set)\n');
|
|
|
253
253
|
|
|
254
254
|
const msg4: CRDTMessage = {
|
|
255
255
|
id: 'msg-004',
|
|
256
|
-
|
|
256
|
+
client: 'session-bob',
|
|
257
257
|
clock: { 'session-bob': 1 },
|
|
258
|
-
|
|
259
|
-
|
|
258
|
+
lt: 4,
|
|
259
|
+
ts: Date.now() / 1000,
|
|
260
260
|
ops: [
|
|
261
261
|
{
|
|
262
262
|
key: 'sphere-1',
|
|
263
|
-
|
|
263
|
+
ot: 'vector3.set', // Absolute!
|
|
264
264
|
path: 'transform.position',
|
|
265
265
|
value: [0, 5, 0], // Set to the exact position
|
|
266
266
|
},
|
|
@@ -269,7 +269,7 @@ const msg4: CRDTMessage = {
|
|
|
269
269
|
|
|
270
270
|
console.log('Message 4 (Absolute):');
|
|
271
271
|
const op4 = msg4.ops[0] as Vector3SetOp;
|
|
272
|
-
console.log(' ✓ %s: %s.%s = %s', op4.
|
|
272
|
+
console.log(' ✓ %s: %s.%s = %s', op4.ot, op4.key, op4.path, JSON.stringify(op4.value));
|
|
273
273
|
console.log(' → Absolute position (position = [0, 5, 0])');
|
|
274
274
|
|
|
275
275
|
// ========================================
|
|
@@ -279,26 +279,26 @@ console.log('\n🔄 Step 5: Batch Update - Same Node\n');
|
|
|
279
279
|
|
|
280
280
|
const msg5: CRDTMessage = {
|
|
281
281
|
id: 'msg-005',
|
|
282
|
-
|
|
282
|
+
client: 'session-alice',
|
|
283
283
|
clock: { 'session-alice': 3 },
|
|
284
|
-
|
|
285
|
-
|
|
284
|
+
lt: 5,
|
|
285
|
+
ts: Date.now() / 1000,
|
|
286
286
|
ops: [
|
|
287
287
|
{
|
|
288
288
|
key: 'cube-1',
|
|
289
|
-
|
|
289
|
+
ot: 'color.set',
|
|
290
290
|
path: 'color',
|
|
291
291
|
value: '#00ff00',
|
|
292
292
|
},
|
|
293
293
|
{
|
|
294
294
|
key: 'cube-1',
|
|
295
|
-
|
|
295
|
+
ot: 'number.set',
|
|
296
296
|
path: 'opacity',
|
|
297
297
|
value: 0.5,
|
|
298
298
|
},
|
|
299
299
|
{
|
|
300
300
|
key: 'cube-1',
|
|
301
|
-
|
|
301
|
+
ot: 'vector3.add',
|
|
302
302
|
path: 'transform.position',
|
|
303
303
|
value: [0, 2, 0], // Move up by 2
|
|
304
304
|
},
|
|
@@ -306,11 +306,11 @@ const msg5: CRDTMessage = {
|
|
|
306
306
|
};
|
|
307
307
|
|
|
308
308
|
console.log('Message 5 (Batch - Same Node):');
|
|
309
|
-
console.log(' Envelope: lamport=%d', msg5.
|
|
309
|
+
console.log(' Envelope: lamport=%d', msg5.lt);
|
|
310
310
|
console.log(' Operations on "%s": %d ops', msg5.ops[0].key, msg5.ops.length);
|
|
311
311
|
msg5.ops.forEach((op) => {
|
|
312
312
|
if ('value' in op) {
|
|
313
|
-
console.log(' ✓ %s: %s = %s', op.
|
|
313
|
+
console.log(' ✓ %s: %s = %s', op.ot, op.path, JSON.stringify(op.value));
|
|
314
314
|
}
|
|
315
315
|
});
|
|
316
316
|
|
|
@@ -321,22 +321,22 @@ console.log('\n🔀 Step 6: Compound Update - Multiple Nodes\n');
|
|
|
321
321
|
|
|
322
322
|
const msg6: CRDTMessage = {
|
|
323
323
|
id: 'msg-006',
|
|
324
|
-
|
|
324
|
+
client: 'session-bob',
|
|
325
325
|
clock: { 'session-bob': 2 },
|
|
326
|
-
|
|
327
|
-
|
|
326
|
+
lt: 6,
|
|
327
|
+
ts: Date.now() / 1000,
|
|
328
328
|
ops: [
|
|
329
329
|
// Update cube
|
|
330
330
|
{
|
|
331
331
|
key: 'cube-1',
|
|
332
|
-
|
|
332
|
+
ot: 'number.set',
|
|
333
333
|
path: 'metalness',
|
|
334
334
|
value: 0.8,
|
|
335
335
|
},
|
|
336
336
|
// Update sphere
|
|
337
337
|
{
|
|
338
338
|
key: 'sphere-1',
|
|
339
|
-
|
|
339
|
+
ot: 'color.set',
|
|
340
340
|
path: 'color',
|
|
341
341
|
value: '#ffff00',
|
|
342
342
|
},
|
|
@@ -344,11 +344,11 @@ const msg6: CRDTMessage = {
|
|
|
344
344
|
};
|
|
345
345
|
|
|
346
346
|
console.log('Message 6 (Compound - Different Nodes):');
|
|
347
|
-
console.log(' Envelope: lamport=%d', msg6.
|
|
347
|
+
console.log(' Envelope: lamport=%d', msg6.lt);
|
|
348
348
|
console.log(' Operations: %d nodes updated', new Set(msg6.ops.map((op) => op.key)).size);
|
|
349
349
|
msg6.ops.forEach((op) => {
|
|
350
350
|
if ('value' in op) {
|
|
351
|
-
console.log(' ✓ %s: %s.%s = %s', op.
|
|
351
|
+
console.log(' ✓ %s: %s.%s = %s', op.ot, op.key, op.path, JSON.stringify(op.value));
|
|
352
352
|
}
|
|
353
353
|
});
|
|
354
354
|
|
|
@@ -359,20 +359,20 @@ console.log('\n🎯 Step 7: Multi-Select Drag\n');
|
|
|
359
359
|
|
|
360
360
|
const msg7: CRDTMessage = {
|
|
361
361
|
id: 'msg-007',
|
|
362
|
-
|
|
362
|
+
client: 'session-alice',
|
|
363
363
|
clock: { 'session-alice': 4 },
|
|
364
|
-
|
|
365
|
-
|
|
364
|
+
lt: 7,
|
|
365
|
+
ts: Date.now() / 1000,
|
|
366
366
|
ops: [
|
|
367
367
|
{
|
|
368
368
|
key: 'cube-1',
|
|
369
|
-
|
|
369
|
+
ot: 'vector3.add',
|
|
370
370
|
path: 'transform.position',
|
|
371
371
|
value: [3, 0, 0],
|
|
372
372
|
},
|
|
373
373
|
{
|
|
374
374
|
key: 'sphere-1',
|
|
375
|
-
|
|
375
|
+
ot: 'vector3.add',
|
|
376
376
|
path: 'transform.position',
|
|
377
377
|
value: [3, 0, 0],
|
|
378
378
|
},
|
|
@@ -395,14 +395,14 @@ console.log('\n➕ Step 8: Additive Score (Concurrent)\n');
|
|
|
395
395
|
|
|
396
396
|
const msg8a: CRDTMessage = {
|
|
397
397
|
id: 'msg-008a',
|
|
398
|
-
|
|
398
|
+
client: 'session-alice',
|
|
399
399
|
clock: { 'session-alice': 5 },
|
|
400
|
-
|
|
401
|
-
|
|
400
|
+
lt: 8,
|
|
401
|
+
ts: Date.now() / 1000,
|
|
402
402
|
ops: [
|
|
403
403
|
{
|
|
404
404
|
key: 'cube-1',
|
|
405
|
-
|
|
405
|
+
ot: 'number.add',
|
|
406
406
|
path: 'score',
|
|
407
407
|
value: 10,
|
|
408
408
|
},
|
|
@@ -411,14 +411,14 @@ const msg8a: CRDTMessage = {
|
|
|
411
411
|
|
|
412
412
|
const msg8b: CRDTMessage = {
|
|
413
413
|
id: 'msg-008b',
|
|
414
|
-
|
|
414
|
+
client: 'session-bob',
|
|
415
415
|
clock: { 'session-bob': 3 },
|
|
416
|
-
|
|
417
|
-
|
|
416
|
+
lt: 9,
|
|
417
|
+
ts: Date.now() / 1000,
|
|
418
418
|
ops: [
|
|
419
419
|
{
|
|
420
420
|
key: 'cube-1',
|
|
421
|
-
|
|
421
|
+
ot: 'number.add',
|
|
422
422
|
path: 'score',
|
|
423
423
|
value: 5,
|
|
424
424
|
},
|
|
@@ -426,9 +426,9 @@ const msg8b: CRDTMessage = {
|
|
|
426
426
|
};
|
|
427
427
|
|
|
428
428
|
console.log('Message 8a (Alice):');
|
|
429
|
-
console.log(' ✓ %s: cube-1.score += 10', msg8a.ops[0].
|
|
429
|
+
console.log(' ✓ %s: cube-1.score += 10', msg8a.ops[0].ot);
|
|
430
430
|
console.log('Message 8b (Bob - concurrent):');
|
|
431
|
-
console.log(' ✓ %s: cube-1.score += 5', msg8b.ops[0].
|
|
431
|
+
console.log(' ✓ %s: cube-1.score += 5', msg8b.ops[0].ot);
|
|
432
432
|
console.log(' → Final score: 15 (both additions applied!)');
|
|
433
433
|
|
|
434
434
|
// ========================================
|
|
@@ -438,15 +438,15 @@ console.log('\n🔄 Step 9: Reparent Node\n');
|
|
|
438
438
|
|
|
439
439
|
const msg9: CRDTMessage = {
|
|
440
440
|
id: 'msg-009',
|
|
441
|
-
|
|
441
|
+
client: 'session-alice',
|
|
442
442
|
clock: { 'session-alice': 6 },
|
|
443
|
-
|
|
444
|
-
|
|
443
|
+
lt: 10,
|
|
444
|
+
ts: Date.now() / 1000,
|
|
445
445
|
ops: [
|
|
446
446
|
// Create new parent
|
|
447
447
|
{
|
|
448
448
|
key: 'group-1',
|
|
449
|
-
|
|
449
|
+
ot: 'node.insert',
|
|
450
450
|
path: 'group-1',
|
|
451
451
|
value: {
|
|
452
452
|
key: 'uuid-group-001',
|
|
@@ -460,14 +460,14 @@ const msg9: CRDTMessage = {
|
|
|
460
460
|
// Remove from old parent
|
|
461
461
|
{
|
|
462
462
|
key: 'scene',
|
|
463
|
-
|
|
463
|
+
ot: 'array.remove',
|
|
464
464
|
path: 'children',
|
|
465
465
|
value: 'cube-1',
|
|
466
466
|
},
|
|
467
467
|
// Add to new parent
|
|
468
468
|
{
|
|
469
469
|
key: 'group-1',
|
|
470
|
-
|
|
470
|
+
ot: 'array.push',
|
|
471
471
|
path: 'children',
|
|
472
472
|
value: 'cube-1',
|
|
473
473
|
},
|
|
@@ -477,11 +477,11 @@ const msg9: CRDTMessage = {
|
|
|
477
477
|
console.log('Message 9 (Reparent - Compound):');
|
|
478
478
|
console.log(' Operations: %d ops (atomic)', msg9.ops.length);
|
|
479
479
|
msg9.ops.forEach((op, i) => {
|
|
480
|
-
if (op.
|
|
480
|
+
if (op.ot === 'node.insert') {
|
|
481
481
|
console.log(' %d. Create group: %s', i + 1, op.key);
|
|
482
|
-
} else if (op.
|
|
482
|
+
} else if (op.ot === 'array.remove') {
|
|
483
483
|
console.log(' %d. Remove from %s.children: "%s"', i + 1, op.key, op.value);
|
|
484
|
-
} else if (op.
|
|
484
|
+
} else if (op.ot === 'array.push') {
|
|
485
485
|
console.log(' %d. Add to %s.children: "%s"', i + 1, op.key, op.value);
|
|
486
486
|
}
|
|
487
487
|
});
|
|
@@ -494,22 +494,22 @@ console.log('\n🗑️ Step 10: Delete Node\n');
|
|
|
494
494
|
|
|
495
495
|
const msg10: CRDTMessage = {
|
|
496
496
|
id: 'msg-010',
|
|
497
|
-
|
|
497
|
+
client: 'session-bob',
|
|
498
498
|
clock: { 'session-bob': 4 },
|
|
499
|
-
|
|
500
|
-
|
|
499
|
+
lt: 11,
|
|
500
|
+
ts: Date.now() / 1000,
|
|
501
501
|
ops: [
|
|
502
502
|
// Remove from parent
|
|
503
503
|
{
|
|
504
504
|
key: 'scene',
|
|
505
|
-
|
|
505
|
+
ot: 'array.remove',
|
|
506
506
|
path: 'children',
|
|
507
507
|
value: 'sphere-1',
|
|
508
508
|
},
|
|
509
509
|
// Delete node (tombstone)
|
|
510
510
|
{
|
|
511
511
|
key: 'sphere-1',
|
|
512
|
-
|
|
512
|
+
ot: 'node.remove',
|
|
513
513
|
path: 'sphere-1',
|
|
514
514
|
},
|
|
515
515
|
],
|
|
@@ -517,9 +517,9 @@ const msg10: CRDTMessage = {
|
|
|
517
517
|
|
|
518
518
|
console.log('Message 10 (Delete):');
|
|
519
519
|
msg10.ops.forEach((op, i) => {
|
|
520
|
-
if (op.
|
|
520
|
+
if (op.ot === 'array.remove') {
|
|
521
521
|
console.log(' %d. Remove from parent: %s', i + 1, op.value);
|
|
522
|
-
} else if (op.
|
|
522
|
+
} else if (op.ot === 'node.remove') {
|
|
523
523
|
console.log(' %d. Delete node: %s (tombstone)', i + 1, op.key);
|
|
524
524
|
}
|
|
525
525
|
});
|
|
@@ -549,7 +549,7 @@ console.log('\n' + '='.repeat(60));
|
|
|
549
549
|
console.log('\n💡 Key Design Points:\n');
|
|
550
550
|
console.log(' • CRDTMessage = envelope (metadata) + ops (batch)');
|
|
551
551
|
console.log(' • Operations use `key` (human-friendly) not UUID');
|
|
552
|
-
console.log(' •
|
|
552
|
+
console.log(' • ot is explicit: "vector3.add" vs "vector3.set"');
|
|
553
553
|
console.log(' • True batching: one message, many nodes, atomic');
|
|
554
554
|
console.log(' • No nested "properties" - clean operation structure');
|
|
555
555
|
console.log('\n' + '='.repeat(60));
|
|
@@ -16,13 +16,13 @@ import type { GraphStore } from '@vuer-ai/vuer-rtc';
|
|
|
16
16
|
/** Insert a scene root + a mesh node so property ops have a target. */
|
|
17
17
|
function seedGraph(store: GraphStore): void {
|
|
18
18
|
store.edit({
|
|
19
|
-
|
|
19
|
+
ot: 'node.insert',
|
|
20
20
|
key: '',
|
|
21
21
|
path: 'children',
|
|
22
22
|
value: { key: 'scene', tag: 'Scene', name: 'Scene' },
|
|
23
23
|
});
|
|
24
24
|
store.edit({
|
|
25
|
-
|
|
25
|
+
ot: 'node.insert',
|
|
26
26
|
key: 'scene',
|
|
27
27
|
path: 'children',
|
|
28
28
|
value: {
|
|
@@ -62,14 +62,14 @@ describe('convergence', () => {
|
|
|
62
62
|
// Both clients edit the same node's position concurrently
|
|
63
63
|
// (before seeing each other's edits)
|
|
64
64
|
clientA.store.edit({
|
|
65
|
-
|
|
65
|
+
ot: 'vector3.set',
|
|
66
66
|
key: 'cube-1',
|
|
67
67
|
path: 'position',
|
|
68
68
|
value: [10, 0, 0],
|
|
69
69
|
});
|
|
70
70
|
|
|
71
71
|
clientB.store.edit({
|
|
72
|
-
|
|
72
|
+
ot: 'vector3.set',
|
|
73
73
|
key: 'cube-1',
|
|
74
74
|
path: 'position',
|
|
75
75
|
value: [0, 20, 0],
|
|
@@ -101,7 +101,7 @@ describe('convergence', () => {
|
|
|
101
101
|
|
|
102
102
|
// Client A inserts a new node
|
|
103
103
|
clientA.store.edit({
|
|
104
|
-
|
|
104
|
+
ot: 'node.insert',
|
|
105
105
|
key: 'scene',
|
|
106
106
|
path: 'children',
|
|
107
107
|
value: {
|
|
@@ -114,7 +114,7 @@ describe('convergence', () => {
|
|
|
114
114
|
|
|
115
115
|
// Client B edits a property on the existing node
|
|
116
116
|
clientB.store.edit({
|
|
117
|
-
|
|
117
|
+
ot: 'vector3.set',
|
|
118
118
|
key: 'cube-1',
|
|
119
119
|
path: 'position',
|
|
120
120
|
value: [99, 99, 99],
|
|
@@ -149,7 +149,7 @@ describe('convergence', () => {
|
|
|
149
149
|
|
|
150
150
|
// Client A edits and commits
|
|
151
151
|
clientA.store.edit({
|
|
152
|
-
|
|
152
|
+
ot: 'vector3.set',
|
|
153
153
|
key: 'cube-1',
|
|
154
154
|
path: 'position',
|
|
155
155
|
value: [42, 42, 42],
|
|
@@ -187,7 +187,7 @@ describe('convergence', () => {
|
|
|
187
187
|
|
|
188
188
|
// Client A sets position (vector3.set)
|
|
189
189
|
clientA.store.edit({
|
|
190
|
-
|
|
190
|
+
ot: 'vector3.set',
|
|
191
191
|
key: 'cube-1',
|
|
192
192
|
path: 'position',
|
|
193
193
|
value: [1, 2, 3],
|
|
@@ -195,7 +195,7 @@ describe('convergence', () => {
|
|
|
195
195
|
|
|
196
196
|
// Client B sets opacity (number.set) on the same node
|
|
197
197
|
clientB.store.edit({
|
|
198
|
-
|
|
198
|
+
ot: 'number.set',
|
|
199
199
|
key: 'cube-1',
|
|
200
200
|
path: 'opacity',
|
|
201
201
|
value: 0.5,
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { GraphStore, SceneGraph, SceneNode } from '@vuer-ai/vuer-rtc';
|
|
9
|
+
import { getText } from '@vuer-ai/vuer-rtc';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Wait until `condition` returns true, polling every `interval` ms.
|
|
@@ -133,8 +134,29 @@ function nodeDataKeys(node: SceneNode): string[] {
|
|
|
133
134
|
.sort();
|
|
134
135
|
}
|
|
135
136
|
|
|
137
|
+
/** Check if a value is a TextRope instance */
|
|
138
|
+
function isTextRope(v: unknown): boolean {
|
|
139
|
+
return (
|
|
140
|
+
v != null &&
|
|
141
|
+
typeof v === 'object' &&
|
|
142
|
+
typeof (v as any).toString === 'function' &&
|
|
143
|
+
typeof (v as any).toJSON === 'function' &&
|
|
144
|
+
(v as any).agentId !== undefined &&
|
|
145
|
+
(v as any).clock !== undefined &&
|
|
146
|
+
(v as any)._tree !== undefined
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
136
150
|
/** Simple recursive deep equality for JSON-safe values. */
|
|
137
151
|
function deepEqual(a: unknown, b: unknown): boolean {
|
|
152
|
+
// Handle TextRope instances by converting to string
|
|
153
|
+
if (isTextRope(a)) {
|
|
154
|
+
a = getText(a as any);
|
|
155
|
+
}
|
|
156
|
+
if (isTextRope(b)) {
|
|
157
|
+
b = getText(b as any);
|
|
158
|
+
}
|
|
159
|
+
|
|
138
160
|
if (a === b) return true;
|
|
139
161
|
if (a == null || b == null) return a === b;
|
|
140
162
|
if (typeof a !== typeof b) return false;
|
|
@@ -46,7 +46,7 @@ export interface TestServer {
|
|
|
46
46
|
broker: InMemoryBroker;
|
|
47
47
|
server: RTCServer;
|
|
48
48
|
/** Connect a new GraphStore client to a room. */
|
|
49
|
-
connectClient(roomId: string,
|
|
49
|
+
connectClient(roomId: string, client: string, opts?: ConnectClientOptions): TestClient;
|
|
50
50
|
/** Tear down all connections and state. */
|
|
51
51
|
close(): void;
|
|
52
52
|
}
|
|
@@ -131,7 +131,7 @@ export function createTestServer(): TestServer {
|
|
|
131
131
|
|
|
132
132
|
const clients: Array<{ cleanup: () => void }> = [];
|
|
133
133
|
|
|
134
|
-
function connectClient(roomId: string,
|
|
134
|
+
function connectClient(roomId: string, client: string, opts?: ConnectClientOptions): TestClient {
|
|
135
135
|
const { client: clientWs, server: serverWs } = createFakeWsPair();
|
|
136
136
|
const latency = opts?.latencyMs ?? 0;
|
|
137
137
|
const dropRate = opts?.dropRate ?? 0;
|
|
@@ -142,7 +142,7 @@ export function createTestServer(): TestServer {
|
|
|
142
142
|
|
|
143
143
|
// Create a GraphStore that sends CRDTMessages over the fake WS (msgpack)
|
|
144
144
|
const store = createGraph({
|
|
145
|
-
|
|
145
|
+
client,
|
|
146
146
|
onSend: (msg: CRDTMessage) => {
|
|
147
147
|
clientWs.send(serialize({ mtype: 'crdt', msg }));
|
|
148
148
|
},
|
|
@@ -170,7 +170,7 @@ export function createTestServer(): TestServer {
|
|
|
170
170
|
});
|
|
171
171
|
|
|
172
172
|
// Register the server-side WS with the RTCServer
|
|
173
|
-
rtcServer.handleConnection(serverWs, roomId,
|
|
173
|
+
rtcServer.handleConnection(serverWs, roomId, client);
|
|
174
174
|
|
|
175
175
|
const retryUnacked = () => {
|
|
176
176
|
const unacked = getUnackedMessages(store.getState());
|