@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.
Files changed (114) hide show
  1. package/.env +1 -0
  2. package/PHASE1_SUMMARY.md +94 -0
  3. package/README.md +423 -0
  4. package/dist/broker/InMemoryBroker.d.ts +24 -0
  5. package/dist/broker/InMemoryBroker.d.ts.map +1 -0
  6. package/dist/broker/InMemoryBroker.js +65 -0
  7. package/dist/broker/InMemoryBroker.js.map +1 -0
  8. package/dist/broker/index.d.ts +3 -0
  9. package/dist/broker/index.d.ts.map +1 -0
  10. package/dist/broker/index.js +2 -0
  11. package/dist/broker/index.js.map +1 -0
  12. package/dist/broker/types.d.ts +47 -0
  13. package/dist/broker/types.d.ts.map +1 -0
  14. package/dist/broker/types.js +9 -0
  15. package/dist/broker/types.js.map +1 -0
  16. package/dist/index.d.ts +13 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +18 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/journal/JournalRepository.d.ts +39 -0
  21. package/dist/journal/JournalRepository.d.ts.map +1 -0
  22. package/dist/journal/JournalRepository.js +102 -0
  23. package/dist/journal/JournalRepository.js.map +1 -0
  24. package/dist/journal/JournalService.d.ts +69 -0
  25. package/dist/journal/JournalService.d.ts.map +1 -0
  26. package/dist/journal/JournalService.js +224 -0
  27. package/dist/journal/JournalService.js.map +1 -0
  28. package/dist/journal/index.d.ts +6 -0
  29. package/dist/journal/index.d.ts.map +1 -0
  30. package/dist/journal/index.js +6 -0
  31. package/dist/journal/index.js.map +1 -0
  32. package/dist/persistence/DocumentRepository.d.ts +22 -0
  33. package/dist/persistence/DocumentRepository.d.ts.map +1 -0
  34. package/dist/persistence/DocumentRepository.js +66 -0
  35. package/dist/persistence/DocumentRepository.js.map +1 -0
  36. package/dist/persistence/PrismaClient.d.ts +8 -0
  37. package/dist/persistence/PrismaClient.d.ts.map +1 -0
  38. package/dist/persistence/PrismaClient.js +21 -0
  39. package/dist/persistence/PrismaClient.js.map +1 -0
  40. package/dist/persistence/SessionRepository.d.ts +22 -0
  41. package/dist/persistence/SessionRepository.d.ts.map +1 -0
  42. package/dist/persistence/SessionRepository.js +103 -0
  43. package/dist/persistence/SessionRepository.js.map +1 -0
  44. package/dist/persistence/index.d.ts +7 -0
  45. package/dist/persistence/index.d.ts.map +1 -0
  46. package/dist/persistence/index.js +7 -0
  47. package/dist/persistence/index.js.map +1 -0
  48. package/dist/serve.d.ts +18 -0
  49. package/dist/serve.d.ts.map +1 -0
  50. package/dist/serve.js +211 -0
  51. package/dist/serve.js.map +1 -0
  52. package/dist/transport/RTCServer.d.ts +92 -0
  53. package/dist/transport/RTCServer.d.ts.map +1 -0
  54. package/dist/transport/RTCServer.js +273 -0
  55. package/dist/transport/RTCServer.js.map +1 -0
  56. package/dist/transport/index.d.ts +2 -0
  57. package/dist/transport/index.d.ts.map +1 -0
  58. package/dist/transport/index.js +2 -0
  59. package/dist/transport/index.js.map +1 -0
  60. package/dist/version.d.ts +2 -0
  61. package/dist/version.d.ts.map +1 -0
  62. package/dist/version.js +2 -0
  63. package/dist/version.js.map +1 -0
  64. package/jest.config.js +36 -0
  65. package/package.json +56 -0
  66. package/prisma/schema.prisma +121 -0
  67. package/src/broker/InMemoryBroker.ts +81 -0
  68. package/src/broker/index.ts +2 -0
  69. package/src/broker/types.ts +60 -0
  70. package/src/index.ts +23 -0
  71. package/src/journal/JournalRepository.ts +119 -0
  72. package/src/journal/JournalService.ts +291 -0
  73. package/src/journal/index.ts +10 -0
  74. package/src/persistence/DocumentRepository.ts +76 -0
  75. package/src/persistence/PrismaClient.ts +24 -0
  76. package/src/persistence/SessionRepository.ts +114 -0
  77. package/src/persistence/index.ts +7 -0
  78. package/src/serve.ts +240 -0
  79. package/src/transport/RTCServer.ts +327 -0
  80. package/src/transport/index.ts +1 -0
  81. package/src/version.ts +1 -0
  82. package/tests/README.md +112 -0
  83. package/tests/demo.ts +555 -0
  84. package/tests/e2e/convergence.test.ts +221 -0
  85. package/tests/e2e/helpers/assertions.ts +158 -0
  86. package/tests/e2e/helpers/createTestServer.ts +220 -0
  87. package/tests/e2e/latency.test.ts +512 -0
  88. package/tests/e2e/packet-loss.test.ts +229 -0
  89. package/tests/e2e/relay.test.ts +255 -0
  90. package/tests/e2e/sync-perf.test.ts +365 -0
  91. package/tests/e2e/sync-reconciliation.test.ts +237 -0
  92. package/tests/e2e/text-sync.test.ts +199 -0
  93. package/tests/e2e/tombstone-convergence.test.ts +356 -0
  94. package/tests/fixtures/array-ops.jsonl +6 -0
  95. package/tests/fixtures/boolean-ops.jsonl +6 -0
  96. package/tests/fixtures/color-ops.jsonl +4 -0
  97. package/tests/fixtures/edit-buffer.jsonl +3 -0
  98. package/tests/fixtures/messages.jsonl +4 -0
  99. package/tests/fixtures/node-ops.jsonl +6 -0
  100. package/tests/fixtures/number-ops.jsonl +7 -0
  101. package/tests/fixtures/object-ops.jsonl +4 -0
  102. package/tests/fixtures/operations.jsonl +7 -0
  103. package/tests/fixtures/string-ops.jsonl +4 -0
  104. package/tests/fixtures/undo-redo.jsonl +3 -0
  105. package/tests/fixtures/vector-ops.jsonl +9 -0
  106. package/tests/integration/repositories.test.ts +320 -0
  107. package/tests/journal/journal-service.test.ts +185 -0
  108. package/tests/test-data/datatypes.ts +677 -0
  109. package/tests/test-data/operations-example.ts +306 -0
  110. package/tests/test-data/scene-example.ts +247 -0
  111. package/tests/unit/operations.test.ts +310 -0
  112. package/tests/unit/vectorClock.test.ts +281 -0
  113. package/tsconfig.json +19 -0
  114. package/tsconfig.test.json +8 -0
@@ -0,0 +1,365 @@
1
+ /**
2
+ * Sync Performance Tests
3
+ *
4
+ * Stress tests for bloom filter sync reconciliation under heavy load.
5
+ * Measures: digest build time, convergence time with packet loss,
6
+ * compaction impact, and scaling with multiple clients.
7
+ */
8
+
9
+ import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
10
+ import { buildSyncDigest } from '@vuer-ai/vuer-rtc';
11
+ import type { GraphStore } from '@vuer-ai/vuer-rtc';
12
+ import { createTestServer, type TestServer } from './helpers/createTestServer.js';
13
+ import { waitFor, waitForConvergence, assertGraphsEqual } from './helpers/assertions.js';
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
+ /** Generate N position edits on cube-1, one commit each. */
38
+ function generateEdits(store: GraphStore, n: number): void {
39
+ for (let i = 0; i < n; i++) {
40
+ store.edit({
41
+ otype: 'vector3.set',
42
+ key: 'cube-1',
43
+ path: 'position',
44
+ value: [i, i * 2, i * 3],
45
+ });
46
+ store.commit(`edit ${i}`);
47
+ }
48
+ }
49
+
50
+ function hrMs(start: [number, number]): number {
51
+ const diff = process.hrtime(start);
52
+ return diff[0] * 1000 + diff[1] / 1e6;
53
+ }
54
+
55
+ // ── Tests ─────────────────────────────────────────────────────
56
+
57
+ describe('sync-perf', () => {
58
+ let ts: TestServer;
59
+
60
+ beforeEach(() => {
61
+ ts = createTestServer();
62
+ });
63
+
64
+ afterEach(() => {
65
+ ts.close();
66
+ });
67
+
68
+ // ── 1. Digest build time at scale ───────────────────────────
69
+
70
+ it('digest build time scales linearly (1K, 5K, 10K messages)', async () => {
71
+ const clientA = ts.connectClient('room-1', 'session-a');
72
+ seedGraph(clientA.store);
73
+
74
+ const sizes = [1000, 5000, 10000];
75
+ const results: Array<{ n: number; buildMs: number; filterBytes: number }> = [];
76
+
77
+ for (const n of sizes) {
78
+ const currentJournal = clientA.store.getState().journal.length;
79
+ const needed = n - currentJournal + 1;
80
+ if (needed > 0) generateEdits(clientA.store, needed);
81
+
82
+ // Measure digest build time (average of 10 runs)
83
+ const runs = 10;
84
+ const t0 = process.hrtime();
85
+ for (let i = 0; i < runs; i++) {
86
+ buildSyncDigest(clientA.store.getState());
87
+ }
88
+ const elapsed = hrMs(t0);
89
+ const avgMs = elapsed / runs;
90
+
91
+ const digest = buildSyncDigest(clientA.store.getState());
92
+ const filterBytes = digest.filter.length;
93
+
94
+ results.push({ n: digest.count, buildMs: avgMs, filterBytes });
95
+ }
96
+
97
+ console.log('\n Digest build time:');
98
+ console.log(' ┌───────────┬───────────────┬──────────────┬────────────────┐');
99
+ console.log(' │ Messages │ Build (ms) │ Filter (KB) │ msgs/ms │');
100
+ console.log(' ├───────────┼───────────────┼──────────────┼────────────────┤');
101
+ for (const r of results) {
102
+ const kb = (r.filterBytes / 1024).toFixed(1);
103
+ const throughput = (r.n / r.buildMs).toFixed(0);
104
+ console.log(
105
+ ` │ ${String(r.n).padStart(7)} │ ${r.buildMs.toFixed(3).padStart(11)} │ ${kb.padStart(10)} │ ${throughput.padStart(12)} │`,
106
+ );
107
+ }
108
+ console.log(' └───────────┴───────────────┴──────────────┴────────────────┘');
109
+
110
+ // Sanity: 10K messages should build in < 50ms
111
+ const last = results[results.length - 1];
112
+ expect(last.buildMs).toBeLessThan(50);
113
+ expect(last.filterBytes).toBeLessThan(last.n * 3);
114
+ });
115
+
116
+ // ── 2. Compaction shrinks digest dramatically ───────────────
117
+
118
+ it('compaction reduces digest size after ack', async () => {
119
+ const clientA = ts.connectClient('room-1', 'session-a');
120
+ const clientB = ts.connectClient('room-1', 'session-b');
121
+ seedGraph(clientA.store);
122
+ await waitForConvergence([clientA.store, clientB.store], 3000);
123
+
124
+ // B makes 2000 edits
125
+ generateEdits(clientB.store, 2000);
126
+ await waitForConvergence([clientA.store, clientB.store], 5000);
127
+
128
+ const beforeDigest = buildSyncDigest(clientA.store.getState());
129
+ const beforeCount = beforeDigest.count;
130
+ const beforeFilterKB = beforeDigest.filter.length / 1024;
131
+
132
+ clientA.store.compact();
133
+
134
+ const afterState = clientA.store.getState();
135
+ const afterDigest = buildSyncDigest(afterState);
136
+ const afterCount = afterDigest.count;
137
+ const afterFilterKB = afterDigest.filter.length / 1024;
138
+
139
+ console.log('\n Compaction impact (client A, 2000 remote edits):');
140
+ console.log(` Before: journal=${beforeCount}, filter=${beforeFilterKB.toFixed(1)} KB`);
141
+ console.log(` After: journal=${afterCount}, filter=${afterFilterKB.toFixed(1)} KB`);
142
+ console.log(` Reduction: ${((1 - afterCount / beforeCount) * 100).toFixed(0)}%`);
143
+
144
+ expect(afterCount).toBeLessThan(beforeCount);
145
+ expect(Object.keys(afterState.snapshot.vectorClock).length).toBeGreaterThan(0);
146
+ });
147
+
148
+ // ── 3. Heavy load convergence with 50% drops + sync ────────
149
+
150
+ it('converges 1000 edits with 50% drop + sync recovery', async () => {
151
+ const clientA = ts.connectClient('room-1', 'session-a');
152
+ const clientB = ts.connectClient('room-1', 'session-b', { dropRate: 0.5 });
153
+ seedGraph(clientA.store);
154
+
155
+ for (const entry of clientA.store.getState().journal) {
156
+ clientB.store.receive(entry.msg);
157
+ }
158
+
159
+ const editStart = process.hrtime();
160
+ generateEdits(clientA.store, 1000);
161
+ const editMs = hrMs(editStart);
162
+
163
+ await new Promise((r) => setTimeout(r, 500));
164
+
165
+ const bJournal = clientB.store.getState().journal.length;
166
+ const aJournal = clientA.store.getState().journal.length;
167
+ const missing = aJournal - bJournal;
168
+
169
+ clientB.disableDrop();
170
+ const syncStart = process.hrtime();
171
+
172
+ for (let round = 0; round < 5; round++) {
173
+ clientB.retryUnacked();
174
+ clientB.sendSync();
175
+ await new Promise((r) => setTimeout(r, 200));
176
+ }
177
+
178
+ await waitForConvergence([clientA.store, clientB.store], 10000);
179
+ const syncMs = hrMs(syncStart);
180
+
181
+ assertGraphsEqual(
182
+ clientA.store.getState().graph,
183
+ clientB.store.getState().graph,
184
+ );
185
+
186
+ console.log('\n Heavy load convergence (1000 edits, 50% drop):');
187
+ console.log(` Edit generation: ${editMs.toFixed(1)} ms`);
188
+ console.log(` Messages dropped: ~${missing} of ${aJournal}`);
189
+ console.log(` Sync recovery: ${syncMs.toFixed(1)} ms`);
190
+ console.log(` Final position: ${JSON.stringify(clientB.store.getState().graph.nodes['cube-1'].position)}`);
191
+
192
+ expect(clientB.store.getState().graph.nodes['cube-1'].position).toEqual([999, 1998, 2997]);
193
+ });
194
+
195
+ // ── 4. Multi-client scaling: 5 clients, 100 edits each ─────
196
+
197
+ it('5 clients × 100 edits converge via sync', async () => {
198
+ const N_CLIENTS = 5;
199
+ const EDITS_PER_CLIENT = 100;
200
+ const clients = [];
201
+
202
+ for (let i = 0; i < N_CLIENTS; i++) {
203
+ const c = ts.connectClient('room-1', `session-${i}`, { dropRate: 0.3 });
204
+ clients.push(c);
205
+ }
206
+
207
+ // First client seeds reliably
208
+ clients[0].disableDrop();
209
+ seedGraph(clients[0].store);
210
+ await new Promise((r) => setTimeout(r, 100));
211
+
212
+ // Replay seed to all clients
213
+ const seed = clients[0].store.getState().journal;
214
+ for (let i = 1; i < N_CLIENTS; i++) {
215
+ for (const entry of seed) {
216
+ clients[i].store.receive(entry.msg);
217
+ }
218
+ }
219
+
220
+ // All clients fire edits
221
+ const editStart = process.hrtime();
222
+ for (let i = 0; i < N_CLIENTS; i++) {
223
+ for (let j = 0; j < EDITS_PER_CLIENT; j++) {
224
+ clients[i].store.edit({
225
+ otype: 'number.set',
226
+ key: 'cube-1',
227
+ path: `prop_${i}_${j}`,
228
+ value: i * 1000 + j,
229
+ });
230
+ clients[i].store.commit(`client-${i} edit ${j}`);
231
+ }
232
+ }
233
+ const editMs = hrMs(editStart);
234
+
235
+ // Let some broadcasts arrive
236
+ await new Promise((r) => setTimeout(r, 300));
237
+
238
+ // Disable drops and sync repeatedly
239
+ const syncStart = process.hrtime();
240
+ for (const c of clients) c.disableDrop();
241
+
242
+ for (let round = 0; round < 15; round++) {
243
+ for (const c of clients) {
244
+ c.retryUnacked();
245
+ c.sendSync();
246
+ }
247
+ await new Promise((r) => setTimeout(r, 200));
248
+ }
249
+
250
+ await waitForConvergence(
251
+ clients.map((c) => c.store),
252
+ 20000,
253
+ );
254
+ const syncMs = hrMs(syncStart);
255
+
256
+ for (let i = 1; i < N_CLIENTS; i++) {
257
+ assertGraphsEqual(
258
+ clients[0].store.getState().graph,
259
+ clients[i].store.getState().graph,
260
+ );
261
+ }
262
+
263
+ const totalEdits = N_CLIENTS * EDITS_PER_CLIENT;
264
+ const journalSizes = clients.map((c) => c.store.getState().journal.length);
265
+
266
+ console.log(`\n Multi-client convergence (${N_CLIENTS} clients × ${EDITS_PER_CLIENT} edits):`);
267
+ console.log(` Total edits: ${totalEdits}`);
268
+ console.log(` Edit generation: ${editMs.toFixed(1)} ms (${(totalEdits / editMs * 1000).toFixed(0)} edits/sec)`);
269
+ console.log(` Sync recovery: ${syncMs.toFixed(1)} ms`);
270
+ console.log(` Journal sizes: [${journalSizes.join(', ')}]`);
271
+
272
+ const node = clients[0].store.getState().graph.nodes['cube-1'];
273
+ const propCount = Object.keys(node).filter((k) => k.startsWith('prop_')).length;
274
+ expect(propCount).toBe(totalEdits);
275
+ }, 60_000);
276
+
277
+ // ── 5. Long session: compaction keeps journal bounded ───────
278
+
279
+ it('long session: 2000 edits with periodic compaction', async () => {
280
+ // Two clients — A edits, B receives. A compacts periodically.
281
+ // Key insight: compaction works on ACKED entries, so we need to
282
+ // let acks arrive (async) before calling compact.
283
+ const clientA = ts.connectClient('room-1', 'session-a');
284
+ const clientB = ts.connectClient('room-1', 'session-b', { dropRate: 0.2 });
285
+ seedGraph(clientA.store);
286
+
287
+ for (const entry of clientA.store.getState().journal) {
288
+ clientB.store.receive(entry.msg);
289
+ }
290
+
291
+ const TOTAL = 2000;
292
+ const BATCH_SIZE = 500;
293
+ const compactionStats: Array<{ at: number; journalBefore: number; journalAfter: number }> = [];
294
+
295
+ const start = process.hrtime();
296
+
297
+ for (let batch = 0; batch < TOTAL / BATCH_SIZE; batch++) {
298
+ // Fire a batch of edits
299
+ for (let i = 0; i < BATCH_SIZE; i++) {
300
+ const idx = batch * BATCH_SIZE + i;
301
+ clientA.store.edit({
302
+ otype: 'number.set',
303
+ key: 'cube-1',
304
+ path: `val_${idx % 100}`,
305
+ value: idx,
306
+ });
307
+ clientA.store.commit(`edit ${idx}`);
308
+ }
309
+
310
+ // Wait for acks to arrive (they come back via setTimeout(0))
311
+ await waitFor(
312
+ () => clientA.store.getState().journal.every((e) => e.ack),
313
+ 3000,
314
+ );
315
+
316
+ // Now compact — all entries are acked
317
+ const jBefore = clientA.store.getState().journal.length;
318
+ clientA.store.compact();
319
+ const jAfter = clientA.store.getState().journal.length;
320
+ compactionStats.push({
321
+ at: (batch + 1) * BATCH_SIZE,
322
+ journalBefore: jBefore,
323
+ journalAfter: jAfter,
324
+ });
325
+ }
326
+
327
+ const editMs = hrMs(start);
328
+
329
+ // Final sync for B
330
+ clientB.disableDrop();
331
+ for (let round = 0; round < 10; round++) {
332
+ clientB.retryUnacked();
333
+ clientB.sendSync();
334
+ await new Promise((r) => setTimeout(r, 200));
335
+ }
336
+
337
+ await waitForConvergence([clientA.store, clientB.store], 15000);
338
+ const totalMs = hrMs(start);
339
+
340
+ assertGraphsEqual(
341
+ clientA.store.getState().graph,
342
+ clientB.store.getState().graph,
343
+ );
344
+
345
+ const digestA = buildSyncDigest(clientA.store.getState());
346
+ const digestB = buildSyncDigest(clientB.store.getState());
347
+ const snapshotClock = clientA.store.getState().snapshot.vectorClock;
348
+
349
+ console.log(`\n Long session (${TOTAL} edits, compact every ${BATCH_SIZE}):`);
350
+ console.log(` Edit + compact: ${editMs.toFixed(1)} ms (${(TOTAL / editMs * 1000).toFixed(0)} edits/sec)`);
351
+ console.log(` Total time: ${totalMs.toFixed(1)} ms`);
352
+ console.log(` Final journal: A=${digestA.count}, B=${digestB.count}`);
353
+ console.log(` Final filter: A=${(digestA.filter.length / 1024).toFixed(1)} KB, B=${(digestB.filter.length / 1024).toFixed(1)} KB`);
354
+ console.log(` Snapshot vector clock: ${JSON.stringify(snapshotClock)}`);
355
+ console.log(' Compaction rounds:');
356
+ for (const s of compactionStats) {
357
+ console.log(` @${s.at}: journal ${s.journalBefore} → ${s.journalAfter}`);
358
+ }
359
+
360
+ // After periodic compaction, A's journal should be near-empty
361
+ expect(digestA.count).toBeLessThan(BATCH_SIZE);
362
+ // Vector clock should cover A's compacted entries
363
+ expect(snapshotClock['session-a']).toBeGreaterThan(0);
364
+ }, 60_000);
365
+ });
@@ -0,0 +1,237 @@
1
+ /**
2
+ * E2E: Bloom filter-based sync reconciliation.
3
+ *
4
+ * Verifies that clients can recover from dropped server→client broadcasts
5
+ * by sending a bloom filter digest and receiving missing messages.
6
+ */
7
+
8
+ import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
9
+ import { createTestServer, type TestServer } from './helpers/createTestServer.js';
10
+ import { 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('sync-reconciliation', () => {
40
+ let ts: TestServer;
41
+
42
+ beforeEach(() => {
43
+ ts = createTestServer();
44
+ });
45
+
46
+ afterEach(() => {
47
+ ts.close();
48
+ });
49
+
50
+ // ── 1. Full recovery via sync after 100% drop ─────────────
51
+
52
+ it('recovers all messages via sync after 100% broadcast drop', async () => {
53
+ // A sends reliably to the server, but B drops all incoming (100% drop)
54
+ const clientA = ts.connectClient('room-1', 'session-a');
55
+ const clientB = ts.connectClient('room-1', 'session-b', { dropRate: 1.0 });
56
+
57
+ seedGraph(clientA.store);
58
+
59
+ // Wait for A's messages to be processed by the server
60
+ await new Promise((r) => setTimeout(r, 100));
61
+
62
+ // B should have nothing since all broadcasts were dropped
63
+ expect(Object.keys(clientB.store.getState().graph.nodes).length).toBe(0);
64
+
65
+ // Disable drop and send sync — B tells server what it has (nothing),
66
+ // server retransmits everything
67
+ clientB.disableDrop();
68
+ clientB.sendSync();
69
+
70
+ await waitForConvergence([clientA.store, clientB.store], 3000);
71
+
72
+ const graphA = clientA.store.getState().graph;
73
+ const graphB = clientB.store.getState().graph;
74
+ assertGraphsEqual(graphA, graphB);
75
+ expect(Object.keys(graphB.nodes).length).toBeGreaterThan(0);
76
+ });
77
+
78
+ // ── 2. Partial recovery via sync after 50% drop ───────────
79
+
80
+ it('recovers from partial drop via sync', async () => {
81
+ // Seed reliably first so both clients have the base graph
82
+ const clientA = ts.connectClient('room-1', 'session-a');
83
+ const clientB = ts.connectClient('room-1', 'session-b');
84
+
85
+ seedGraph(clientA.store);
86
+ await waitForConvergence([clientA.store, clientB.store], 3000);
87
+
88
+ // Connect a third client with 50% drops — it starts behind
89
+ const clientC = ts.connectClient('room-1', 'session-c', { dropRate: 0.5 });
90
+
91
+ // Replay journal so C has the base graph
92
+ for (const entry of clientA.store.getState().journal) {
93
+ clientC.store.receive(entry.msg);
94
+ }
95
+
96
+ // A makes property edits (commutative, safe for out-of-order replay)
97
+ clientA.store.edit({
98
+ otype: 'vector3.set',
99
+ key: 'cube-1',
100
+ path: 'position',
101
+ value: [10, 20, 30],
102
+ });
103
+ clientA.store.commit('move cube');
104
+
105
+ clientA.store.edit({
106
+ otype: 'number.set',
107
+ key: 'cube-1',
108
+ path: 'opacity',
109
+ value: 0.5,
110
+ });
111
+ clientA.store.commit('set opacity');
112
+
113
+ // Wait for processing — C may have received some broadcasts, missed others
114
+ await new Promise((r) => setTimeout(r, 200));
115
+
116
+ // Disable drop and sync to recover missing messages
117
+ clientC.disableDrop();
118
+ clientC.sendSync();
119
+
120
+ await waitForConvergence([clientA.store, clientC.store], 3000);
121
+
122
+ const graphA = clientA.store.getState().graph;
123
+ const graphC = clientC.store.getState().graph;
124
+ assertGraphsEqual(graphA, graphC);
125
+ });
126
+
127
+ // ── 3. Sync is idempotent ─────────────────────────────────
128
+
129
+ it('sync is idempotent when nothing is missing', async () => {
130
+ const clientA = ts.connectClient('room-1', 'session-a');
131
+ const clientB = ts.connectClient('room-1', 'session-b');
132
+
133
+ seedGraph(clientA.store);
134
+ await waitForConvergence([clientA.store, clientB.store], 3000);
135
+
136
+ // Both converged, now send sync — should be a no-op
137
+ const graphBefore = JSON.stringify(clientB.store.getState().graph);
138
+ clientB.sendSync();
139
+
140
+ await new Promise((r) => setTimeout(r, 200));
141
+
142
+ const graphAfter = JSON.stringify(clientB.store.getState().graph);
143
+ expect(graphAfter).toBe(graphBefore);
144
+
145
+ assertGraphsEqual(clientA.store.getState().graph, clientB.store.getState().graph);
146
+ });
147
+
148
+ // ── 4. Bidirectional sync recovers both sides ─────────────
149
+
150
+ it('bidirectional sync recovers both clients', async () => {
151
+ // Both clients drop each other's broadcasts
152
+ const clientA = ts.connectClient('room-1', 'session-a', { dropRate: 1.0 });
153
+ const clientB = ts.connectClient('room-1', 'session-b', { dropRate: 1.0 });
154
+
155
+ // Seed from A — server gets it, but broadcast to B is dropped
156
+ seedGraph(clientA.store);
157
+
158
+ // B makes its own edit — server gets it, but broadcast to A is dropped
159
+ await new Promise((r) => setTimeout(r, 100));
160
+
161
+ // Disable drop for sync
162
+ clientA.disableDrop();
163
+ clientB.disableDrop();
164
+
165
+ // B also needs to retry its unacked messages (since A→server messages were also
166
+ // dropped when clientA had 100% dropRate)
167
+ clientA.retryUnacked();
168
+ clientB.retryUnacked();
169
+
170
+ await new Promise((r) => setTimeout(r, 200));
171
+
172
+ // Now both send sync
173
+ clientA.sendSync();
174
+ clientB.sendSync();
175
+
176
+ await waitForConvergence([clientA.store, clientB.store], 3000);
177
+
178
+ const graphA = clientA.store.getState().graph;
179
+ const graphB = clientB.store.getState().graph;
180
+ assertGraphsEqual(graphA, graphB);
181
+ });
182
+
183
+ // ── 5. Sync + retry together ──────────────────────────────
184
+
185
+ it('sync + retry together achieve full convergence', async () => {
186
+ const clientA = ts.connectClient('room-1', 'session-a', { dropRate: 0.5 });
187
+ const clientB = ts.connectClient('room-1', 'session-b', { dropRate: 0.5 });
188
+
189
+ // Seed reliably
190
+ clientA.disableDrop();
191
+ clientB.disableDrop();
192
+ seedGraph(clientA.store);
193
+ await waitForConvergence([clientA.store, clientB.store], 3000);
194
+
195
+ // Re-enable drops for edits
196
+ clientA.setLatency(0);
197
+ clientB.setLatency(0);
198
+
199
+ // Both make edits (some may be dropped)
200
+ clientA.store.edit({
201
+ otype: 'vector3.set',
202
+ key: 'cube-1',
203
+ path: 'position',
204
+ value: [99, 88, 77],
205
+ });
206
+ clientA.store.commit('move');
207
+
208
+ clientB.store.edit({
209
+ otype: 'number.set',
210
+ key: 'cube-1',
211
+ path: 'opacity',
212
+ value: 0.3,
213
+ });
214
+ clientB.store.commit('opacity');
215
+
216
+ await new Promise((r) => setTimeout(r, 200));
217
+
218
+ // Full recovery: disable drop, retry unacked, sync
219
+ clientA.disableDrop();
220
+ clientB.disableDrop();
221
+ clientA.retryUnacked();
222
+ clientB.retryUnacked();
223
+
224
+ await new Promise((r) => setTimeout(r, 200));
225
+
226
+ clientA.sendSync();
227
+ clientB.sendSync();
228
+
229
+ await waitForConvergence([clientA.store, clientB.store], 3000);
230
+
231
+ const graphA = clientA.store.getState().graph;
232
+ const graphB = clientB.store.getState().graph;
233
+ assertGraphsEqual(graphA, graphB);
234
+ expect(graphB.nodes['cube-1'].position).toEqual([99, 88, 77]);
235
+ expect(graphB.nodes['cube-1'].opacity).toBe(0.3);
236
+ });
237
+ });