@vuer-ai/vuer-rtc-server 0.1.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 -0
- package/PHASE1_SUMMARY.md +94 -0
- package/README.md +423 -0
- package/dist/broker/InMemoryBroker.d.ts +24 -0
- package/dist/broker/InMemoryBroker.d.ts.map +1 -0
- package/dist/broker/InMemoryBroker.js +65 -0
- package/dist/broker/InMemoryBroker.js.map +1 -0
- package/dist/broker/index.d.ts +3 -0
- package/dist/broker/index.d.ts.map +1 -0
- package/dist/broker/index.js +2 -0
- package/dist/broker/index.js.map +1 -0
- package/dist/broker/types.d.ts +47 -0
- package/dist/broker/types.d.ts.map +1 -0
- package/dist/broker/types.js +9 -0
- package/dist/broker/types.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/journal/JournalRepository.d.ts +39 -0
- package/dist/journal/JournalRepository.d.ts.map +1 -0
- package/dist/journal/JournalRepository.js +102 -0
- package/dist/journal/JournalRepository.js.map +1 -0
- package/dist/journal/JournalService.d.ts +69 -0
- package/dist/journal/JournalService.d.ts.map +1 -0
- package/dist/journal/JournalService.js +224 -0
- package/dist/journal/JournalService.js.map +1 -0
- package/dist/journal/index.d.ts +6 -0
- package/dist/journal/index.d.ts.map +1 -0
- package/dist/journal/index.js +6 -0
- package/dist/journal/index.js.map +1 -0
- package/dist/persistence/DocumentRepository.d.ts +22 -0
- package/dist/persistence/DocumentRepository.d.ts.map +1 -0
- package/dist/persistence/DocumentRepository.js +66 -0
- package/dist/persistence/DocumentRepository.js.map +1 -0
- package/dist/persistence/PrismaClient.d.ts +8 -0
- package/dist/persistence/PrismaClient.d.ts.map +1 -0
- package/dist/persistence/PrismaClient.js +21 -0
- package/dist/persistence/PrismaClient.js.map +1 -0
- package/dist/persistence/SessionRepository.d.ts +22 -0
- package/dist/persistence/SessionRepository.d.ts.map +1 -0
- package/dist/persistence/SessionRepository.js +103 -0
- package/dist/persistence/SessionRepository.js.map +1 -0
- package/dist/persistence/index.d.ts +7 -0
- package/dist/persistence/index.d.ts.map +1 -0
- package/dist/persistence/index.js +7 -0
- package/dist/persistence/index.js.map +1 -0
- package/dist/serve.d.ts +18 -0
- package/dist/serve.d.ts.map +1 -0
- package/dist/serve.js +211 -0
- package/dist/serve.js.map +1 -0
- package/dist/transport/RTCServer.d.ts +92 -0
- package/dist/transport/RTCServer.d.ts.map +1 -0
- package/dist/transport/RTCServer.js +273 -0
- package/dist/transport/RTCServer.js.map +1 -0
- package/dist/transport/index.d.ts +2 -0
- package/dist/transport/index.d.ts.map +1 -0
- package/dist/transport/index.js +2 -0
- package/dist/transport/index.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +2 -0
- package/dist/version.js.map +1 -0
- package/jest.config.js +36 -0
- package/package.json +56 -0
- package/prisma/schema.prisma +121 -0
- package/src/broker/InMemoryBroker.ts +81 -0
- package/src/broker/index.ts +2 -0
- package/src/broker/types.ts +60 -0
- package/src/index.ts +23 -0
- package/src/journal/JournalRepository.ts +119 -0
- package/src/journal/JournalService.ts +291 -0
- package/src/journal/index.ts +10 -0
- package/src/persistence/DocumentRepository.ts +76 -0
- package/src/persistence/PrismaClient.ts +24 -0
- package/src/persistence/SessionRepository.ts +114 -0
- package/src/persistence/index.ts +7 -0
- package/src/serve.ts +240 -0
- package/src/transport/RTCServer.ts +327 -0
- package/src/transport/index.ts +1 -0
- package/src/version.ts +1 -0
- package/tests/README.md +112 -0
- package/tests/demo.ts +555 -0
- package/tests/e2e/convergence.test.ts +221 -0
- package/tests/e2e/helpers/assertions.ts +158 -0
- package/tests/e2e/helpers/createTestServer.ts +220 -0
- package/tests/e2e/latency.test.ts +512 -0
- package/tests/e2e/packet-loss.test.ts +229 -0
- package/tests/e2e/relay.test.ts +255 -0
- package/tests/e2e/sync-perf.test.ts +365 -0
- package/tests/e2e/sync-reconciliation.test.ts +237 -0
- package/tests/e2e/text-sync.test.ts +199 -0
- package/tests/e2e/tombstone-convergence.test.ts +356 -0
- package/tests/fixtures/array-ops.jsonl +6 -0
- package/tests/fixtures/boolean-ops.jsonl +6 -0
- package/tests/fixtures/color-ops.jsonl +4 -0
- package/tests/fixtures/edit-buffer.jsonl +3 -0
- package/tests/fixtures/messages.jsonl +4 -0
- package/tests/fixtures/node-ops.jsonl +6 -0
- package/tests/fixtures/number-ops.jsonl +7 -0
- package/tests/fixtures/object-ops.jsonl +4 -0
- package/tests/fixtures/operations.jsonl +7 -0
- package/tests/fixtures/string-ops.jsonl +4 -0
- package/tests/fixtures/undo-redo.jsonl +3 -0
- package/tests/fixtures/vector-ops.jsonl +9 -0
- package/tests/integration/repositories.test.ts +320 -0
- package/tests/journal/journal-service.test.ts +185 -0
- package/tests/test-data/datatypes.ts +677 -0
- package/tests/test-data/operations-example.ts +306 -0
- package/tests/test-data/scene-example.ts +247 -0
- package/tests/unit/operations.test.ts +310 -0
- package/tests/unit/vectorClock.test.ts +281 -0
- package/tsconfig.json +19 -0
- package/tsconfig.test.json +8 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2E: CRDT convergence with simulated packet loss and retries.
|
|
3
|
+
*
|
|
4
|
+
* Verifies that clients can recover from dropped messages by
|
|
5
|
+
* re-sending unacknowledged operations.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
|
|
9
|
+
import { createTestServer, type TestServer } from './helpers/createTestServer.js';
|
|
10
|
+
import { waitFor, waitForConvergence, assertGraphsEqual } from './helpers/assertions.js';
|
|
11
|
+
import type { GraphStore } from '@vuer-ai/vuer-rtc';
|
|
12
|
+
|
|
13
|
+
// ── Helpers ───────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
function seedGraph(store: GraphStore): void {
|
|
16
|
+
store.edit({
|
|
17
|
+
otype: 'node.insert',
|
|
18
|
+
key: '',
|
|
19
|
+
path: 'children',
|
|
20
|
+
value: { key: 'scene', tag: 'Scene', name: 'Scene' },
|
|
21
|
+
});
|
|
22
|
+
store.edit({
|
|
23
|
+
otype: 'node.insert',
|
|
24
|
+
key: 'scene',
|
|
25
|
+
path: 'children',
|
|
26
|
+
value: {
|
|
27
|
+
key: 'cube-1',
|
|
28
|
+
tag: 'Mesh',
|
|
29
|
+
name: 'Cube',
|
|
30
|
+
position: [0, 0, 0],
|
|
31
|
+
opacity: 1,
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
store.commit('seed scene');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── Tests ─────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
describe('packet-loss', () => {
|
|
40
|
+
let ts: TestServer;
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
ts = createTestServer();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
ts.close();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// ── 1. Retry recovers dropped message ───────────────────────
|
|
51
|
+
|
|
52
|
+
it('retry recovers a dropped message', async () => {
|
|
53
|
+
// Client A has 100% drop rate — all initial sends are lost
|
|
54
|
+
const clientA = ts.connectClient('room-1', 'session-a', { dropRate: 1.0 });
|
|
55
|
+
const clientB = ts.connectClient('room-1', 'session-b');
|
|
56
|
+
|
|
57
|
+
seedGraph(clientA.store);
|
|
58
|
+
|
|
59
|
+
// Give some time — B should NOT receive the seed
|
|
60
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
61
|
+
expect(Object.keys(clientB.store.getState().graph.nodes).length).toBe(0);
|
|
62
|
+
|
|
63
|
+
// Disable drop and retry
|
|
64
|
+
clientA.disableDrop();
|
|
65
|
+
clientA.retryUnacked();
|
|
66
|
+
|
|
67
|
+
await waitForConvergence([clientA.store, clientB.store], 3000);
|
|
68
|
+
|
|
69
|
+
const graphA = clientA.store.getState().graph;
|
|
70
|
+
const graphB = clientB.store.getState().graph;
|
|
71
|
+
assertGraphsEqual(graphA, graphB);
|
|
72
|
+
expect(Object.keys(graphB.nodes).length).toBeGreaterThan(0);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// ── 2. Partial loss with retry ──────────────────────────────
|
|
76
|
+
|
|
77
|
+
it('recovers from partial packet loss with retry', async () => {
|
|
78
|
+
// 50% drop rate — some messages get through, some don't
|
|
79
|
+
const clientA = ts.connectClient('room-1', 'session-a', { dropRate: 0.5 });
|
|
80
|
+
const clientB = ts.connectClient('room-1', 'session-b', { dropRate: 0.5 });
|
|
81
|
+
|
|
82
|
+
// Seed with no drop so both clients have the base graph
|
|
83
|
+
clientA.disableDrop();
|
|
84
|
+
clientB.disableDrop();
|
|
85
|
+
seedGraph(clientA.store);
|
|
86
|
+
await waitForConvergence([clientA.store, clientB.store], 3000);
|
|
87
|
+
|
|
88
|
+
// Re-enable drop and make edits
|
|
89
|
+
clientA.disableDrop(); // A sends reliably
|
|
90
|
+
// B has 50% drop — acks from server may be lost
|
|
91
|
+
|
|
92
|
+
clientA.store.edit({
|
|
93
|
+
otype: 'vector3.set',
|
|
94
|
+
key: 'cube-1',
|
|
95
|
+
path: 'position',
|
|
96
|
+
value: [10, 20, 30],
|
|
97
|
+
});
|
|
98
|
+
clientA.store.commit('move cube');
|
|
99
|
+
|
|
100
|
+
clientB.store.edit({
|
|
101
|
+
otype: 'number.set',
|
|
102
|
+
key: 'cube-1',
|
|
103
|
+
path: 'opacity',
|
|
104
|
+
value: 0.7,
|
|
105
|
+
});
|
|
106
|
+
clientB.store.commit('set opacity');
|
|
107
|
+
|
|
108
|
+
// Wait a bit for any messages that got through
|
|
109
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
110
|
+
|
|
111
|
+
// Disable drop and retry all unacked
|
|
112
|
+
clientA.disableDrop();
|
|
113
|
+
clientB.disableDrop();
|
|
114
|
+
clientA.retryUnacked();
|
|
115
|
+
clientB.retryUnacked();
|
|
116
|
+
|
|
117
|
+
await waitForConvergence([clientA.store, clientB.store], 3000);
|
|
118
|
+
|
|
119
|
+
const graphA = clientA.store.getState().graph;
|
|
120
|
+
const graphB = clientB.store.getState().graph;
|
|
121
|
+
assertGraphsEqual(graphA, graphB);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// ── 3. Multiple retries needed ──────────────────────────────
|
|
125
|
+
|
|
126
|
+
it('converges after multiple retry rounds', async () => {
|
|
127
|
+
const clientA = ts.connectClient('room-1', 'session-a', { dropRate: 0.8 });
|
|
128
|
+
const clientB = ts.connectClient('room-1', 'session-b');
|
|
129
|
+
|
|
130
|
+
seedGraph(clientA.store);
|
|
131
|
+
|
|
132
|
+
// Retry up to 5 times — with 80% drop, ~99.97% chance at least one gets through
|
|
133
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
134
|
+
clientA.retryUnacked();
|
|
135
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Disable drop for final retry
|
|
139
|
+
clientA.disableDrop();
|
|
140
|
+
clientA.retryUnacked();
|
|
141
|
+
|
|
142
|
+
await waitForConvergence([clientA.store, clientB.store], 3000);
|
|
143
|
+
|
|
144
|
+
const graphB = clientB.store.getState().graph;
|
|
145
|
+
expect(Object.keys(graphB.nodes).length).toBeGreaterThan(0);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ── 4. Lost ack — message delivered but ack dropped ─────────
|
|
149
|
+
|
|
150
|
+
it('handles lost ack gracefully', async () => {
|
|
151
|
+
const clientA = ts.connectClient('room-1', 'session-a');
|
|
152
|
+
const clientB = ts.connectClient('room-1', 'session-b');
|
|
153
|
+
|
|
154
|
+
seedGraph(clientA.store);
|
|
155
|
+
await waitForConvergence([clientA.store, clientB.store], 3000);
|
|
156
|
+
|
|
157
|
+
// Make an edit and commit
|
|
158
|
+
clientA.store.edit({
|
|
159
|
+
otype: 'vector3.set',
|
|
160
|
+
key: 'cube-1',
|
|
161
|
+
path: 'position',
|
|
162
|
+
value: [99, 99, 99],
|
|
163
|
+
});
|
|
164
|
+
const msg = clientA.store.commit('move cube');
|
|
165
|
+
expect(msg).not.toBeNull();
|
|
166
|
+
|
|
167
|
+
// Wait for B to receive the broadcast
|
|
168
|
+
await waitFor(() => {
|
|
169
|
+
const pos = clientB.store.getState().graph.nodes['cube-1']?.position;
|
|
170
|
+
return Array.isArray(pos) && pos[0] === 99;
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Simulate: the message was delivered but A never got the ack.
|
|
174
|
+
// A's journal entry is still unacked. Re-sending should be idempotent.
|
|
175
|
+
clientA.retryUnacked();
|
|
176
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
177
|
+
|
|
178
|
+
// Both should still be converged — the duplicate was handled
|
|
179
|
+
const graphA = clientA.store.getState().graph;
|
|
180
|
+
const graphB = clientB.store.getState().graph;
|
|
181
|
+
assertGraphsEqual(graphA, graphB);
|
|
182
|
+
expect(graphA.nodes['cube-1'].position).toEqual([99, 99, 99]);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// ── 5. Packet loss + latency combined ──────────────────────
|
|
186
|
+
|
|
187
|
+
it('converges with combined latency and packet loss', async () => {
|
|
188
|
+
const clientA = ts.connectClient('room-1', 'session-a', { latencyMs: 50, dropRate: 0.3 });
|
|
189
|
+
const clientB = ts.connectClient('room-1', 'session-b', { latencyMs: 50, dropRate: 0.3 });
|
|
190
|
+
|
|
191
|
+
// Seed reliably first
|
|
192
|
+
clientA.disableDrop();
|
|
193
|
+
clientB.disableDrop();
|
|
194
|
+
seedGraph(clientA.store);
|
|
195
|
+
await waitForConvergence([clientA.store, clientB.store], 5000);
|
|
196
|
+
|
|
197
|
+
// Re-enable conditions
|
|
198
|
+
// Use a fixed seed approach: set dropRate back
|
|
199
|
+
// For deterministic behavior, we just retry until converged
|
|
200
|
+
|
|
201
|
+
clientA.store.edit({
|
|
202
|
+
otype: 'number.add',
|
|
203
|
+
key: 'cube-1',
|
|
204
|
+
path: 'opacity',
|
|
205
|
+
value: 5,
|
|
206
|
+
});
|
|
207
|
+
clientA.store.commit('add opacity');
|
|
208
|
+
|
|
209
|
+
// Retry loop until convergence or timeout
|
|
210
|
+
const start = Date.now();
|
|
211
|
+
let converged = false;
|
|
212
|
+
while (!converged && Date.now() - start < 5000) {
|
|
213
|
+
clientA.retryUnacked();
|
|
214
|
+
clientB.retryUnacked();
|
|
215
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
216
|
+
try {
|
|
217
|
+
const gA = clientA.store.getState().graph;
|
|
218
|
+
const gB = clientB.store.getState().graph;
|
|
219
|
+
assertGraphsEqual(gA, gB);
|
|
220
|
+
converged = true;
|
|
221
|
+
} catch {
|
|
222
|
+
// Not yet converged, retry
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
expect(converged).toBe(true);
|
|
227
|
+
expect(clientA.store.getState().graph.nodes['cube-1'].opacity).toBe(6);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2E: Basic message relay through the server.
|
|
3
|
+
*
|
|
4
|
+
* Verifies that the RTCServer correctly relays CRDTMessages
|
|
5
|
+
* between clients, assigns sequence numbers, deduplicates
|
|
6
|
+
* retries, and fans out to all room members.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
|
|
10
|
+
import { createTestServer, type TestServer } from './helpers/createTestServer.js';
|
|
11
|
+
import { waitFor, waitForConvergence, assertGraphsEqual } from './helpers/assertions.js';
|
|
12
|
+
import type { GraphStore, CRDTMessage } from '@vuer-ai/vuer-rtc';
|
|
13
|
+
|
|
14
|
+
// ── Helpers ───────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
/** Insert a scene root + a mesh node so property ops have a target. */
|
|
17
|
+
function seedGraph(store: GraphStore): void {
|
|
18
|
+
store.edit({
|
|
19
|
+
otype: 'node.insert',
|
|
20
|
+
key: '',
|
|
21
|
+
path: 'children',
|
|
22
|
+
value: { key: 'scene', tag: 'Scene', name: 'Scene' },
|
|
23
|
+
});
|
|
24
|
+
store.edit({
|
|
25
|
+
otype: 'node.insert',
|
|
26
|
+
key: 'scene',
|
|
27
|
+
path: 'children',
|
|
28
|
+
value: {
|
|
29
|
+
key: 'cube-1',
|
|
30
|
+
tag: 'Mesh',
|
|
31
|
+
name: 'Cube',
|
|
32
|
+
position: [0, 0, 0],
|
|
33
|
+
opacity: 1,
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
store.commit('seed scene');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── Tests ─────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
describe('relay', () => {
|
|
42
|
+
let ts: TestServer;
|
|
43
|
+
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
ts = createTestServer();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
ts.close();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// ── 1. Sequential edits ────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
it('two clients: sequential edits', async () => {
|
|
55
|
+
const clientA = ts.connectClient('room-1', 'session-a');
|
|
56
|
+
const clientB = ts.connectClient('room-1', 'session-b');
|
|
57
|
+
|
|
58
|
+
// Client A seeds the graph
|
|
59
|
+
seedGraph(clientA.store);
|
|
60
|
+
|
|
61
|
+
// Wait for Client B to receive the seed
|
|
62
|
+
await waitForConvergence([clientA.store, clientB.store]);
|
|
63
|
+
|
|
64
|
+
// Client A edits position
|
|
65
|
+
clientA.store.edit({
|
|
66
|
+
otype: 'vector3.set',
|
|
67
|
+
key: 'cube-1',
|
|
68
|
+
path: 'position',
|
|
69
|
+
value: [1, 2, 3],
|
|
70
|
+
});
|
|
71
|
+
clientA.store.commit('move cube');
|
|
72
|
+
|
|
73
|
+
// Wait for convergence
|
|
74
|
+
await waitForConvergence([clientA.store, clientB.store]);
|
|
75
|
+
|
|
76
|
+
// Both graphs should be identical
|
|
77
|
+
const graphA = clientA.store.getState().graph;
|
|
78
|
+
const graphB = clientB.store.getState().graph;
|
|
79
|
+
assertGraphsEqual(graphA, graphB);
|
|
80
|
+
|
|
81
|
+
// Verify the position value is correct
|
|
82
|
+
expect(graphB.nodes['cube-1'].position).toEqual([1, 2, 3]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ── 2. Ack after commit ────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
it('client receives ack after commit', async () => {
|
|
88
|
+
const clientA = ts.connectClient('room-1', 'session-a');
|
|
89
|
+
|
|
90
|
+
// Seed the graph
|
|
91
|
+
seedGraph(clientA.store);
|
|
92
|
+
|
|
93
|
+
// Wait for the seed commit to be acked
|
|
94
|
+
await waitFor(() => {
|
|
95
|
+
const journal = clientA.store.getState().journal;
|
|
96
|
+
return journal.length > 0 && journal.every((e) => e.ack);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Now make an edit and commit
|
|
100
|
+
clientA.store.edit({
|
|
101
|
+
otype: 'vector3.set',
|
|
102
|
+
key: 'cube-1',
|
|
103
|
+
path: 'position',
|
|
104
|
+
value: [5, 5, 5],
|
|
105
|
+
});
|
|
106
|
+
const msg = clientA.store.commit('set position');
|
|
107
|
+
expect(msg).not.toBeNull();
|
|
108
|
+
|
|
109
|
+
// Wait for the ack to arrive
|
|
110
|
+
await waitFor(() => {
|
|
111
|
+
const journal = clientA.store.getState().journal;
|
|
112
|
+
const entry = journal.find((e) => e.msg.id === msg!.id);
|
|
113
|
+
return entry !== undefined && entry.ack === true;
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// The entry should now be acked
|
|
117
|
+
const journal = clientA.store.getState().journal;
|
|
118
|
+
const entry = journal.find((e) => e.msg.id === msg!.id);
|
|
119
|
+
expect(entry).toBeDefined();
|
|
120
|
+
expect(entry!.ack).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ── 3. Deduplication ───────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
it('server deduplicates messages', async () => {
|
|
126
|
+
const clientA = ts.connectClient('room-1', 'session-a');
|
|
127
|
+
const clientB = ts.connectClient('room-1', 'session-b');
|
|
128
|
+
|
|
129
|
+
// Seed the graph from A
|
|
130
|
+
seedGraph(clientA.store);
|
|
131
|
+
await waitForConvergence([clientA.store, clientB.store]);
|
|
132
|
+
|
|
133
|
+
// Client A edits and commits
|
|
134
|
+
clientA.store.edit({
|
|
135
|
+
otype: 'vector3.set',
|
|
136
|
+
key: 'cube-1',
|
|
137
|
+
path: 'position',
|
|
138
|
+
value: [10, 20, 30],
|
|
139
|
+
});
|
|
140
|
+
const msg = clientA.store.commit('move cube');
|
|
141
|
+
expect(msg).not.toBeNull();
|
|
142
|
+
|
|
143
|
+
// Wait for Client B to receive the original broadcast
|
|
144
|
+
await waitFor(() => {
|
|
145
|
+
const pos = clientB.store.getState().graph.nodes['cube-1']?.position;
|
|
146
|
+
return Array.isArray(pos) && pos[0] === 10;
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Snapshot B's journal length after receiving the first copy
|
|
150
|
+
const journalLenBefore = clientB.store.getState().journal.length;
|
|
151
|
+
|
|
152
|
+
// Simulate a duplicate: rebroadcast the same message via the broker.
|
|
153
|
+
// The client-side CRDT layer should ignore it (journal dedup in onRemoteMessage).
|
|
154
|
+
await ts.broker.publish('room-1', { ...msg!, serverSeq: 999 });
|
|
155
|
+
|
|
156
|
+
// Give time for any duplicate delivery to settle
|
|
157
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
158
|
+
|
|
159
|
+
// Client B's journal should not have grown — the duplicate was dropped
|
|
160
|
+
const journalLenAfter = clientB.store.getState().journal.length;
|
|
161
|
+
expect(journalLenAfter).toBe(journalLenBefore);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// ── 4. Monotonic server sequence numbers ───────────────────
|
|
165
|
+
|
|
166
|
+
it('messages have monotonic server sequence numbers', async () => {
|
|
167
|
+
const clientA = ts.connectClient('room-1', 'session-a');
|
|
168
|
+
const clientB = ts.connectClient('room-1', 'session-b');
|
|
169
|
+
|
|
170
|
+
// Seed the graph
|
|
171
|
+
seedGraph(clientA.store);
|
|
172
|
+
await waitForConvergence([clientA.store, clientB.store]);
|
|
173
|
+
|
|
174
|
+
// Collect serverSeq values from messages that Client B receives
|
|
175
|
+
const serverSeqs: number[] = [];
|
|
176
|
+
const originalReceive = clientB.store.receive.bind(clientB.store);
|
|
177
|
+
clientB.store.receive = (msg: CRDTMessage) => {
|
|
178
|
+
// The SequencedMessage carries serverSeq
|
|
179
|
+
if ((msg as any).serverSeq !== undefined) {
|
|
180
|
+
serverSeqs.push((msg as any).serverSeq);
|
|
181
|
+
}
|
|
182
|
+
originalReceive(msg);
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
// Client A sends multiple commits
|
|
186
|
+
clientA.store.edit({
|
|
187
|
+
otype: 'vector3.set',
|
|
188
|
+
key: 'cube-1',
|
|
189
|
+
path: 'position',
|
|
190
|
+
value: [1, 0, 0],
|
|
191
|
+
});
|
|
192
|
+
clientA.store.commit('move 1');
|
|
193
|
+
|
|
194
|
+
clientA.store.edit({
|
|
195
|
+
otype: 'vector3.set',
|
|
196
|
+
key: 'cube-1',
|
|
197
|
+
path: 'position',
|
|
198
|
+
value: [2, 0, 0],
|
|
199
|
+
});
|
|
200
|
+
clientA.store.commit('move 2');
|
|
201
|
+
|
|
202
|
+
clientA.store.edit({
|
|
203
|
+
otype: 'vector3.set',
|
|
204
|
+
key: 'cube-1',
|
|
205
|
+
path: 'position',
|
|
206
|
+
value: [3, 0, 0],
|
|
207
|
+
});
|
|
208
|
+
clientA.store.commit('move 3');
|
|
209
|
+
|
|
210
|
+
// Wait for all three to arrive at B
|
|
211
|
+
await waitFor(() => serverSeqs.length >= 3);
|
|
212
|
+
|
|
213
|
+
// Verify monotonically increasing
|
|
214
|
+
for (let i = 1; i < serverSeqs.length; i++) {
|
|
215
|
+
expect(serverSeqs[i]).toBeGreaterThan(serverSeqs[i - 1]);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// ── 5. Three clients in the same room ──────────────────────
|
|
220
|
+
|
|
221
|
+
it('three clients in the same room', async () => {
|
|
222
|
+
const clientA = ts.connectClient('room-1', 'session-a');
|
|
223
|
+
const clientB = ts.connectClient('room-1', 'session-b');
|
|
224
|
+
const clientC = ts.connectClient('room-1', 'session-c');
|
|
225
|
+
|
|
226
|
+
// Client A seeds the graph
|
|
227
|
+
seedGraph(clientA.store);
|
|
228
|
+
|
|
229
|
+
// Wait for all three to converge on the seed
|
|
230
|
+
await waitForConvergence([clientA.store, clientB.store, clientC.store]);
|
|
231
|
+
|
|
232
|
+
// Client A edits position
|
|
233
|
+
clientA.store.edit({
|
|
234
|
+
otype: 'vector3.set',
|
|
235
|
+
key: 'cube-1',
|
|
236
|
+
path: 'position',
|
|
237
|
+
value: [7, 8, 9],
|
|
238
|
+
});
|
|
239
|
+
clientA.store.commit('move cube');
|
|
240
|
+
|
|
241
|
+
// Wait for all three to converge
|
|
242
|
+
await waitForConvergence([clientA.store, clientB.store, clientC.store]);
|
|
243
|
+
|
|
244
|
+
// All three should have the same graph
|
|
245
|
+
const graphA = clientA.store.getState().graph;
|
|
246
|
+
const graphB = clientB.store.getState().graph;
|
|
247
|
+
const graphC = clientC.store.getState().graph;
|
|
248
|
+
assertGraphsEqual(graphA, graphB);
|
|
249
|
+
assertGraphsEqual(graphA, graphC);
|
|
250
|
+
|
|
251
|
+
// Verify the value
|
|
252
|
+
expect(graphB.nodes['cube-1'].position).toEqual([7, 8, 9]);
|
|
253
|
+
expect(graphC.nodes['cube-1'].position).toEqual([7, 8, 9]);
|
|
254
|
+
});
|
|
255
|
+
});
|