@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,199 @@
1
+ /**
2
+ * E2E: Text CRDT sync through the server.
3
+ *
4
+ * Verifies that text operations (init, insert) propagate correctly
5
+ * between clients through the RTCServer relay, including recovery
6
+ * from packet loss and convergence of concurrent edits.
7
+ */
8
+
9
+ import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
10
+ import { createTestServer, type TestServer } from './helpers/createTestServer.js';
11
+ import { waitForConvergence, assertGraphsEqual } from './helpers/assertions.js';
12
+ import type { GraphStore } from '@vuer-ai/vuer-rtc';
13
+
14
+ // ── Helpers ───────────────────────────────────────────────────
15
+
16
+ /** Insert a scene root + a text node so text ops have a target. */
17
+ function seedTextGraph(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: { key: 'text-doc', tag: 'Text', name: 'Doc' },
29
+ });
30
+ store.commit('seed scene');
31
+ }
32
+
33
+ /** Small delay to let async message delivery happen. */
34
+ const tick = (ms = 50) => new Promise((r) => setTimeout(r, ms));
35
+
36
+ // ── Tests ─────────────────────────────────────────────────────
37
+
38
+ describe('text-sync', () => {
39
+ let ts: TestServer;
40
+
41
+ beforeEach(() => {
42
+ ts = createTestServer();
43
+ });
44
+
45
+ afterEach(() => {
46
+ ts.close();
47
+ });
48
+
49
+ // ── 1. Text edits propagate between clients ─────────────────
50
+
51
+ it('text edits propagate between clients', async () => {
52
+ const clientA = ts.connectClient('room-1', 'session-a');
53
+ const clientB = ts.connectClient('room-1', 'session-b');
54
+
55
+ // Seed the graph from A and wait for B to catch up
56
+ seedTextGraph(clientA.store);
57
+ await waitForConvergence([clientA.store, clientB.store], 5000);
58
+
59
+ // Init text on the text-doc node
60
+ clientA.store.edit({
61
+ otype: 'text.init',
62
+ key: 'text-doc',
63
+ path: 'content',
64
+ value: '',
65
+ } as any);
66
+ clientA.store.commit('init text');
67
+ await tick();
68
+
69
+ // Insert "Hello"
70
+ clientA.store.edit({
71
+ otype: 'text.insert',
72
+ key: 'text-doc',
73
+ path: 'content',
74
+ position: 0,
75
+ value: 'Hello',
76
+ } as any);
77
+ clientA.store.commit('type hello');
78
+
79
+ // Wait for B to converge
80
+ await waitForConvergence([clientA.store, clientB.store], 5000);
81
+
82
+ const graphA = clientA.store.getState().graph;
83
+ const graphB = clientB.store.getState().graph;
84
+
85
+ assertGraphsEqual(graphA, graphB);
86
+ expect(graphA.nodes['text-doc'].content).toBe('Hello');
87
+ expect(graphB.nodes['text-doc'].content).toBe('Hello');
88
+ });
89
+
90
+ // ── 2. Text edits recover via sync after packet loss ────────
91
+
92
+ it('text edits recover via sync after packet loss', async () => {
93
+ const clientA = ts.connectClient('room-1', 'session-a');
94
+ const clientB = ts.connectClient('room-1', 'session-b', { dropRate: 1.0 });
95
+
96
+ // Seed the graph — B will miss this due to 100% drop
97
+ seedTextGraph(clientA.store);
98
+ await tick(100);
99
+
100
+ // B should have nothing
101
+ expect(Object.keys(clientB.store.getState().graph.nodes).length).toBe(0);
102
+
103
+ // Alice inits text and types while Bob has 100% drop rate
104
+ clientA.store.edit({
105
+ otype: 'text.init',
106
+ key: 'text-doc',
107
+ path: 'content',
108
+ value: '',
109
+ } as any);
110
+ clientA.store.commit('init text');
111
+ await tick();
112
+
113
+ clientA.store.edit({
114
+ otype: 'text.insert',
115
+ key: 'text-doc',
116
+ path: 'content',
117
+ position: 0,
118
+ value: 'Hello',
119
+ } as any);
120
+ clientA.store.commit('type hello');
121
+ await tick(100);
122
+
123
+ // Bob still has nothing
124
+ expect(Object.keys(clientB.store.getState().graph.nodes).length).toBe(0);
125
+
126
+ // Disable drop, retry unacked, and send sync to recover
127
+ clientB.disableDrop();
128
+ clientA.retryUnacked();
129
+ await tick(100);
130
+ clientB.sendSync();
131
+
132
+ await waitForConvergence([clientA.store, clientB.store], 5000);
133
+
134
+ const graphA = clientA.store.getState().graph;
135
+ const graphB = clientB.store.getState().graph;
136
+
137
+ assertGraphsEqual(graphA, graphB);
138
+ expect(graphB.nodes['text-doc'].content).toBe('Hello');
139
+ });
140
+
141
+ // ── 3. Concurrent text edits converge ─────────────────────
142
+
143
+ it('concurrent text edits converge', async () => {
144
+ const clientA = ts.connectClient('room-1', 'session-a');
145
+ const clientB = ts.connectClient('room-1', 'session-b');
146
+
147
+ // Seed the graph from A and wait for B to catch up
148
+ seedTextGraph(clientA.store);
149
+ await waitForConvergence([clientA.store, clientB.store], 5000);
150
+
151
+ // Both clients init text
152
+ clientA.store.edit({
153
+ otype: 'text.init',
154
+ key: 'text-doc',
155
+ path: 'content',
156
+ value: '',
157
+ } as any);
158
+ clientA.store.commit('A init text');
159
+ await waitForConvergence([clientA.store, clientB.store], 5000);
160
+
161
+ // Both clients type concurrently (before seeing each other's edits)
162
+ clientA.store.edit({
163
+ otype: 'text.insert',
164
+ key: 'text-doc',
165
+ path: 'content',
166
+ position: 0,
167
+ value: 'AAA',
168
+ } as any);
169
+
170
+ clientB.store.edit({
171
+ otype: 'text.insert',
172
+ key: 'text-doc',
173
+ path: 'content',
174
+ position: 0,
175
+ value: 'BBB',
176
+ } as any);
177
+
178
+ // Both commit before either receives the other's edit
179
+ clientA.store.commit('A types AAA');
180
+ clientB.store.commit('B types BBB');
181
+
182
+ // Wait for all messages to be delivered and graphs to converge
183
+ await waitForConvergence([clientA.store, clientB.store], 5000);
184
+
185
+ const graphA = clientA.store.getState().graph;
186
+ const graphB = clientB.store.getState().graph;
187
+
188
+ // Graphs should be identical — the text CRDT resolves the conflict
189
+ assertGraphsEqual(graphA, graphB);
190
+
191
+ // Both "AAA" and "BBB" should be present in the final text
192
+ const textA = graphA.nodes['text-doc'].content as string;
193
+ const textB = graphB.nodes['text-doc'].content as string;
194
+ expect(textA).toBe(textB);
195
+ expect(textA).toContain('AAA');
196
+ expect(textA).toContain('BBB');
197
+ expect(textA.length).toBe(6);
198
+ });
199
+ });
@@ -0,0 +1,356 @@
1
+ /**
2
+ * E2E: Operations on tombstoned nodes must still converge.
3
+ *
4
+ * Regression tests for the bug where `node._crdt?.deletedAt` guards in
5
+ * property operation handlers (number.add, vector3.set, etc.) caused
6
+ * operations to be silently dropped when a node was already tombstoned
7
+ * in one client's journal but not another's.
8
+ *
9
+ * The fix: property operations always apply regardless of `deletedAt`.
10
+ * Only `node.move` checks `deletedAt` (moving a deleted node is no-op).
11
+ */
12
+
13
+ import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
14
+ import { createTestServer, type TestServer } from './helpers/createTestServer.js';
15
+ import { waitForConvergence, assertGraphsEqual } from './helpers/assertions.js';
16
+ import type { GraphStore } from '@vuer-ai/vuer-rtc';
17
+
18
+ // ── Helpers ───────────────────────────────────────────────────
19
+
20
+ function seedGraph(store: GraphStore): void {
21
+ store.edit({
22
+ otype: 'node.insert',
23
+ key: '',
24
+ path: 'children',
25
+ value: { key: 'scene', tag: 'Scene', name: 'Scene' },
26
+ });
27
+ store.edit({
28
+ otype: 'node.insert',
29
+ key: 'scene',
30
+ path: 'children',
31
+ value: {
32
+ key: 'cube-1',
33
+ tag: 'Mesh',
34
+ name: 'Cube',
35
+ position: [0, 0, 0],
36
+ opacity: 1,
37
+ counter: 0,
38
+ },
39
+ });
40
+ store.commit('seed scene');
41
+ }
42
+
43
+ // ── Tests ─────────────────────────────────────────────────────
44
+
45
+ describe('tombstone convergence', () => {
46
+ let ts: TestServer;
47
+
48
+ beforeEach(() => {
49
+ ts = createTestServer();
50
+ });
51
+
52
+ afterEach(() => {
53
+ ts.close();
54
+ });
55
+
56
+ // ── 1. number.add after node.remove (the original bug) ──────
57
+
58
+ it('number.add on deleted node converges', async () => {
59
+ const clientA = ts.connectClient('room-1', 'Alice');
60
+ const clientB = ts.connectClient('room-1', 'Bob');
61
+
62
+ seedGraph(clientA.store);
63
+ await waitForConvergence([clientA.store, clientB.store]);
64
+
65
+ // Bob increments counter several times
66
+ for (let i = 0; i < 5; i++) {
67
+ clientB.store.edit({
68
+ otype: 'number.add',
69
+ key: 'cube-1',
70
+ path: 'counter',
71
+ value: 1,
72
+ });
73
+ clientB.store.commit(`counter +1 (${i})`);
74
+ }
75
+
76
+ // Alice deletes the node concurrently (before receiving all of Bob's adds)
77
+ clientA.store.edit({
78
+ otype: 'node.remove',
79
+ key: 'scene',
80
+ path: 'children',
81
+ value: 'cube-1',
82
+ });
83
+ clientA.store.commit('delete cube');
84
+
85
+ await waitForConvergence([clientA.store, clientB.store]);
86
+
87
+ const graphA = clientA.store.getState().graph;
88
+ const graphB = clientB.store.getState().graph;
89
+ assertGraphsEqual(graphA, graphB);
90
+
91
+ // Both should have counter = 5 on the tombstoned node
92
+ expect(graphA.nodes['cube-1'].counter).toBe(5);
93
+ expect(graphA.nodes['cube-1']._crdt?.deletedAt).toBeDefined();
94
+ });
95
+
96
+ // ── 2. vector3.set after node.remove ─────────────────────────
97
+
98
+ it('vector3.set on deleted node converges', async () => {
99
+ const clientA = ts.connectClient('room-1', 'Alice');
100
+ const clientB = ts.connectClient('room-1', 'Bob');
101
+
102
+ seedGraph(clientA.store);
103
+ await waitForConvergence([clientA.store, clientB.store]);
104
+
105
+ // Bob sets position
106
+ clientB.store.edit({
107
+ otype: 'vector3.set',
108
+ key: 'cube-1',
109
+ path: 'position',
110
+ value: [99, 99, 99],
111
+ });
112
+ clientB.store.commit('set position');
113
+
114
+ // Alice deletes concurrently
115
+ clientA.store.edit({
116
+ otype: 'node.remove',
117
+ key: 'scene',
118
+ path: 'children',
119
+ value: 'cube-1',
120
+ });
121
+ clientA.store.commit('delete cube');
122
+
123
+ await waitForConvergence([clientA.store, clientB.store]);
124
+
125
+ const graphA = clientA.store.getState().graph;
126
+ const graphB = clientB.store.getState().graph;
127
+ assertGraphsEqual(graphA, graphB);
128
+
129
+ // Position should be [99, 99, 99] on both even though node is tombstoned
130
+ expect(graphA.nodes['cube-1'].position).toEqual([99, 99, 99]);
131
+ });
132
+
133
+ // ── 3. number.add with latency (forces different journal orderings) ──
134
+
135
+ it('number.add + delete with latency converges', async () => {
136
+ const clientA = ts.connectClient('room-1', 'Alice', { latencyMs: 50 });
137
+ const clientB = ts.connectClient('room-1', 'Bob', { latencyMs: 50 });
138
+
139
+ seedGraph(clientA.store);
140
+ await waitForConvergence([clientA.store, clientB.store], 5000);
141
+
142
+ // Bob sends 5 counter increments
143
+ for (let i = 0; i < 5; i++) {
144
+ clientB.store.edit({
145
+ otype: 'number.add',
146
+ key: 'cube-1',
147
+ path: 'counter',
148
+ value: 1,
149
+ });
150
+ clientB.store.commit(`counter +1 (${i})`);
151
+ }
152
+
153
+ // Alice deletes after a short delay (overlapping with Bob's adds in flight)
154
+ await new Promise((r) => setTimeout(r, 20));
155
+ clientA.store.edit({
156
+ otype: 'node.remove',
157
+ key: 'scene',
158
+ path: 'children',
159
+ value: 'cube-1',
160
+ });
161
+ clientA.store.commit('delete cube');
162
+
163
+ await waitForConvergence([clientA.store, clientB.store], 5000);
164
+
165
+ const graphA = clientA.store.getState().graph;
166
+ const graphB = clientB.store.getState().graph;
167
+ assertGraphsEqual(graphA, graphB);
168
+
169
+ expect(graphA.nodes['cube-1'].counter).toBe(5);
170
+ });
171
+
172
+ // ── 4. Multiple property types on deleted node ──────────────
173
+
174
+ it('mixed ops on deleted node converge', async () => {
175
+ const clientA = ts.connectClient('room-1', 'Alice');
176
+ const clientB = ts.connectClient('room-1', 'Bob');
177
+
178
+ seedGraph(clientA.store);
179
+ await waitForConvergence([clientA.store, clientB.store]);
180
+
181
+ // Bob edits multiple properties
182
+ clientB.store.edit({
183
+ otype: 'number.add',
184
+ key: 'cube-1',
185
+ path: 'counter',
186
+ value: 10,
187
+ });
188
+ clientB.store.edit({
189
+ otype: 'number.set',
190
+ key: 'cube-1',
191
+ path: 'opacity',
192
+ value: 0.5,
193
+ });
194
+ clientB.store.edit({
195
+ otype: 'vector3.set',
196
+ key: 'cube-1',
197
+ path: 'position',
198
+ value: [1, 2, 3],
199
+ });
200
+ clientB.store.commit('multi-edit');
201
+
202
+ // Alice deletes concurrently
203
+ clientA.store.edit({
204
+ otype: 'node.remove',
205
+ key: 'scene',
206
+ path: 'children',
207
+ value: 'cube-1',
208
+ });
209
+ clientA.store.commit('delete cube');
210
+
211
+ await waitForConvergence([clientA.store, clientB.store]);
212
+
213
+ const graphA = clientA.store.getState().graph;
214
+ const graphB = clientB.store.getState().graph;
215
+ assertGraphsEqual(graphA, graphB);
216
+
217
+ expect(graphA.nodes['cube-1'].counter).toBe(10);
218
+ expect(graphA.nodes['cube-1'].opacity).toBe(0.5);
219
+ expect(graphA.nodes['cube-1'].position).toEqual([1, 2, 3]);
220
+ });
221
+
222
+ // ── 5. Three clients: two edit, one deletes ─────────────────
223
+
224
+ it('three clients: edits + delete converge', async () => {
225
+ const clientA = ts.connectClient('room-1', 'Alice');
226
+ const clientB = ts.connectClient('room-1', 'Bob');
227
+ const clientC = ts.connectClient('room-1', 'Charlie');
228
+
229
+ seedGraph(clientA.store);
230
+ await waitForConvergence([clientA.store, clientB.store, clientC.store]);
231
+
232
+ // Bob and Charlie both increment counter
233
+ clientB.store.edit({
234
+ otype: 'number.add',
235
+ key: 'cube-1',
236
+ path: 'counter',
237
+ value: 3,
238
+ });
239
+ clientB.store.commit('Bob +3');
240
+
241
+ clientC.store.edit({
242
+ otype: 'number.add',
243
+ key: 'cube-1',
244
+ path: 'counter',
245
+ value: 7,
246
+ });
247
+ clientC.store.commit('Charlie +7');
248
+
249
+ // Alice deletes
250
+ clientA.store.edit({
251
+ otype: 'node.remove',
252
+ key: 'scene',
253
+ path: 'children',
254
+ value: 'cube-1',
255
+ });
256
+ clientA.store.commit('delete cube');
257
+
258
+ await waitForConvergence([clientA.store, clientB.store, clientC.store]);
259
+
260
+ const graphA = clientA.store.getState().graph;
261
+ const graphB = clientB.store.getState().graph;
262
+ const graphC = clientC.store.getState().graph;
263
+ assertGraphsEqual(graphA, graphB);
264
+ assertGraphsEqual(graphA, graphC);
265
+
266
+ // counter: 0 + 3 + 7 = 10
267
+ expect(graphA.nodes['cube-1'].counter).toBe(10);
268
+ expect(graphA.nodes['cube-1']._crdt?.deletedAt).toBeDefined();
269
+ });
270
+
271
+ // ── 6. Delete then edit from same client ────────────────────
272
+
273
+ it('edit after own delete still converges', async () => {
274
+ const clientA = ts.connectClient('room-1', 'Alice');
275
+ const clientB = ts.connectClient('room-1', 'Bob');
276
+
277
+ seedGraph(clientA.store);
278
+ await waitForConvergence([clientA.store, clientB.store]);
279
+
280
+ // Alice deletes the cube
281
+ clientA.store.edit({
282
+ otype: 'node.remove',
283
+ key: 'scene',
284
+ path: 'children',
285
+ value: 'cube-1',
286
+ });
287
+ clientA.store.commit('delete cube');
288
+
289
+ await waitForConvergence([clientA.store, clientB.store]);
290
+
291
+ // Now Bob (who received the delete) still increments the counter
292
+ // This tests that operations on tombstoned nodes work even from
293
+ // clients that know the node is deleted
294
+ clientB.store.edit({
295
+ otype: 'number.add',
296
+ key: 'cube-1',
297
+ path: 'counter',
298
+ value: 42,
299
+ });
300
+ clientB.store.commit('counter +42 on deleted');
301
+
302
+ await waitForConvergence([clientA.store, clientB.store]);
303
+
304
+ const graphA = clientA.store.getState().graph;
305
+ const graphB = clientB.store.getState().graph;
306
+ assertGraphsEqual(graphA, graphB);
307
+
308
+ expect(graphA.nodes['cube-1'].counter).toBe(42);
309
+ });
310
+
311
+ // ── 7. Latency + 4 clients (reproducing the debug trace) ───
312
+
313
+ it('4-client counter + delete with latency (debug trace repro)', async () => {
314
+ const alice = ts.connectClient('room-1', 'Alice', { latencyMs: 30 });
315
+ const bob = ts.connectClient('room-1', 'Bob', { latencyMs: 30 });
316
+ const charlie = ts.connectClient('room-1', 'Charlie', { latencyMs: 30 });
317
+ const diana = ts.connectClient('room-1', 'Diana', { latencyMs: 30 });
318
+
319
+ const stores = [alice.store, bob.store, charlie.store, diana.store];
320
+
321
+ seedGraph(alice.store);
322
+ await waitForConvergence(stores, 5000);
323
+
324
+ // Bob sends 5 counter increments
325
+ for (let i = 0; i < 5; i++) {
326
+ bob.store.edit({
327
+ otype: 'number.add',
328
+ key: 'cube-1',
329
+ path: 'counter',
330
+ value: 1,
331
+ });
332
+ bob.store.commit(`Bob counter +1 (${i})`);
333
+ }
334
+
335
+ // Alice deletes the node (may interleave with Bob's adds)
336
+ await new Promise((r) => setTimeout(r, 10));
337
+ alice.store.edit({
338
+ otype: 'node.remove',
339
+ key: 'scene',
340
+ path: 'children',
341
+ value: 'cube-1',
342
+ });
343
+ alice.store.commit('Alice delete cube');
344
+
345
+ await waitForConvergence(stores, 5000);
346
+
347
+ const graphs = stores.map((s) => s.getState().graph);
348
+ assertGraphsEqual(graphs[0], graphs[1]);
349
+ assertGraphsEqual(graphs[0], graphs[2]);
350
+ assertGraphsEqual(graphs[0], graphs[3]);
351
+
352
+ // All clients must see counter = 5
353
+ expect(graphs[0].nodes['cube-1'].counter).toBe(5);
354
+ expect(graphs[0].nodes['cube-1']._crdt?.deletedAt).toBeDefined();
355
+ });
356
+ });
@@ -0,0 +1,6 @@
1
+ {"name": "array_set", "op": {"key": "node-1", "otype": "array.set", "path": "tags", "value": ["enemy", "active"]}}
2
+ {"name": "array_set_empty", "op": {"key": "node-1", "otype": "array.set", "path": "tags", "value": []}}
3
+ {"name": "array_push", "op": {"key": "node-1", "otype": "array.push", "path": "tags", "value": "new-tag"}}
4
+ {"name": "array_push_number", "op": {"key": "node-1", "otype": "array.push", "path": "scores", "value": 100}}
5
+ {"name": "array_union", "op": {"key": "node-1", "otype": "array.union", "path": "tags", "value": ["tag-a", "tag-b"]}}
6
+ {"name": "array_remove", "op": {"key": "node-1", "otype": "array.remove", "path": "tags", "value": "enemy"}}
@@ -0,0 +1,6 @@
1
+ {"name": "boolean_set_true", "op": {"key": "node-1", "otype": "boolean.set", "path": "visible", "value": true}}
2
+ {"name": "boolean_set_false", "op": {"key": "node-1", "otype": "boolean.set", "path": "visible", "value": false}}
3
+ {"name": "boolean_or_true", "op": {"key": "node-1", "otype": "boolean.or", "path": "enabled", "value": true}}
4
+ {"name": "boolean_or_false", "op": {"key": "node-1", "otype": "boolean.or", "path": "enabled", "value": false}}
5
+ {"name": "boolean_and_true", "op": {"key": "node-1", "otype": "boolean.and", "path": "active", "value": true}}
6
+ {"name": "boolean_and_false", "op": {"key": "node-1", "otype": "boolean.and", "path": "active", "value": false}}
@@ -0,0 +1,4 @@
1
+ {"name": "color_set_red", "op": {"key": "node-1", "otype": "color.set", "path": "color", "value": "#ff0000"}}
2
+ {"name": "color_set_green", "op": {"key": "node-1", "otype": "color.set", "path": "color", "value": "#00ff00"}}
3
+ {"name": "color_set_rgb", "op": {"key": "node-1", "otype": "color.set", "path": "color", "value": "#336699"}}
4
+ {"name": "color_blend", "op": {"key": "node-1", "otype": "color.blend", "path": "color", "value": "#ffffff"}}
@@ -0,0 +1,3 @@
1
+ {"name": "merge_additive_ops", "ops": [{"key": "cube-1", "otype": "vector3.add", "path": "position", "value": [1, 0, 0]}, {"key": "cube-1", "otype": "vector3.add", "path": "position", "value": [0, 1, 0]}, {"key": "cube-1", "otype": "vector3.add", "path": "position", "value": [0, 0, 1]}], "expected_merged": {"key": "cube-1", "otype": "vector3.add", "path": "position", "value": [1, 1, 1]}}
2
+ {"name": "replace_lww_ops", "ops": [{"key": "cube-1", "otype": "number.set", "path": "opacity", "value": 0.3}, {"key": "cube-1", "otype": "number.set", "path": "opacity", "value": 0.7}], "expected_merged": {"key": "cube-1", "otype": "number.set", "path": "opacity", "value": 0.7}}
3
+ {"name": "multiple_properties", "ops": [{"key": "cube-1", "otype": "vector3.add", "path": "position", "value": [1, 0, 0]}, {"key": "cube-1", "otype": "number.set", "path": "opacity", "value": 0.5}], "expected_count": 2}
@@ -0,0 +1,4 @@
1
+ {"name": "simple_edit", "msg": {"id": "msg-1", "sessionId": "session-1", "clock": {"session-1": 1}, "lamportTime": 1, "timestamp": 1700000000000, "ops": [{"key": "cube-1", "otype": "vector3.set", "path": "position", "value": [1, 2, 3]}]}}
2
+ {"name": "additive_edit", "msg": {"id": "msg-2", "sessionId": "session-1", "clock": {"session-1": 2}, "lamportTime": 2, "timestamp": 1700000001000, "ops": [{"key": "cube-1", "otype": "vector3.add", "path": "position", "value": [0.1, 0.2, 0.3]}]}}
3
+ {"name": "undo_edit", "msg": {"id": "msg-3", "sessionId": "session-1", "clock": {"session-1": 3}, "lamportTime": 3, "timestamp": 1700000002000, "ops": [{"key": "_meta", "otype": "meta.undo", "path": "_meta", "targetMsgId": "msg-1"}]}}
4
+ {"name": "redo_edit", "msg": {"id": "msg-4", "sessionId": "session-1", "clock": {"session-1": 4}, "lamportTime": 4, "timestamp": 1700000003000, "ops": [{"key": "_meta", "otype": "meta.redo", "path": "_meta", "targetMsgId": "msg-1"}]}}
@@ -0,0 +1,6 @@
1
+ {"name": "node_insert_root", "op": {"key": "", "otype": "node.insert", "path": "children", "value": {"key": "scene", "tag": "Scene", "name": "Root Scene"}}}
2
+ {"name": "node_insert_mesh", "op": {"key": "scene", "otype": "node.insert", "path": "children", "value": {"key": "cube-1", "tag": "Mesh", "name": "Cube", "position": [0, 0, 0], "scale": [1, 1, 1]}}}
3
+ {"name": "node_insert_group", "op": {"key": "scene", "otype": "node.insert", "path": "children", "value": {"key": "group-1", "tag": "Group", "name": "Group"}}}
4
+ {"name": "node_insert_nested", "op": {"key": "group-1", "otype": "node.insert", "path": "children", "value": {"key": "sphere-1", "tag": "Mesh", "name": "Sphere"}}}
5
+ {"name": "node_remove", "op": {"key": "scene", "otype": "node.remove", "path": "children", "value": "cube-1"}}
6
+ {"name": "node_remove_nested", "op": {"key": "group-1", "otype": "node.remove", "path": "children", "value": "sphere-1"}}
@@ -0,0 +1,7 @@
1
+ {"name": "number_set", "op": {"key": "node-1", "otype": "number.set", "path": "opacity", "value": 0.5}}
2
+ {"name": "number_add", "op": {"key": "node-1", "otype": "number.add", "path": "score", "value": 10}}
3
+ {"name": "number_multiply", "op": {"key": "node-1", "otype": "number.multiply", "path": "scale", "value": 2}}
4
+ {"name": "number_min", "op": {"key": "node-1", "otype": "number.min", "path": "health", "value": 50}}
5
+ {"name": "number_max", "op": {"key": "node-1", "otype": "number.max", "path": "damage", "value": 25}}
6
+ {"name": "number_add_negative", "op": {"key": "node-1", "otype": "number.add", "path": "score", "value": -5}}
7
+ {"name": "number_multiply_fraction", "op": {"key": "node-1", "otype": "number.multiply", "path": "scale", "value": 0.5}}
@@ -0,0 +1,4 @@
1
+ {"name": "object_set", "op": {"key": "node-1", "otype": "object.set", "path": "metadata", "value": {"author": "user-1", "version": 1}}}
2
+ {"name": "object_set_empty", "op": {"key": "node-1", "otype": "object.set", "path": "metadata", "value": {}}}
3
+ {"name": "object_merge", "op": {"key": "node-1", "otype": "object.merge", "path": "metadata", "value": {"tags": ["a", "b"]}}}
4
+ {"name": "object_merge_nested", "op": {"key": "node-1", "otype": "object.merge", "path": "config", "value": {"settings": {"enabled": true}}}}
@@ -0,0 +1,7 @@
1
+ {"name": "number.set", "op": {"key": "cube-1", "otype": "number.set", "path": "opacity", "value": 0.5}, "expected": {"opacity": 0.5}}
2
+ {"name": "number.add", "op": {"key": "cube-1", "otype": "number.add", "path": "score", "value": 10}, "expected": {"score": 10}}
3
+ {"name": "vector3.set", "op": {"key": "cube-1", "otype": "vector3.set", "path": "position", "value": [1, 2, 3]}, "expected": {"position": [1, 2, 3]}}
4
+ {"name": "vector3.add", "op": {"key": "cube-1", "otype": "vector3.add", "path": "position", "value": [0.1, 0.2, 0.3]}, "initial": {"position": [1, 2, 3]}, "expected": {"position": [1.1, 2.2, 3.3]}}
5
+ {"name": "boolean.set", "op": {"key": "cube-1", "otype": "boolean.set", "path": "visible", "value": false}, "expected": {"visible": false}}
6
+ {"name": "string.set", "op": {"key": "cube-1", "otype": "string.set", "path": "name", "value": "MyCube"}, "expected": {"name": "MyCube"}}
7
+ {"name": "node.insert", "op": {"key": "sphere-1", "otype": "node.insert", "path": "sphere-1", "value": {"id": "uuid-sphere", "tag": "Mesh", "name": "Sphere"}}, "expected": {"nodes": {"sphere-1": {"id": "uuid-sphere", "tag": "Mesh", "name": "Sphere"}}}}
@@ -0,0 +1,4 @@
1
+ {"name": "string_set", "op": {"key": "node-1", "otype": "string.set", "path": "name", "value": "Player"}}
2
+ {"name": "string_set_empty", "op": {"key": "node-1", "otype": "string.set", "path": "name", "value": ""}}
3
+ {"name": "string_concat", "op": {"key": "node-1", "otype": "string.concat", "path": "tags", "value": "active"}}
4
+ {"name": "string_concat_separator", "op": {"key": "node-1", "otype": "string.concat", "path": "tags", "value": "enemy", "separator": ","}}
@@ -0,0 +1,3 @@
1
+ {"name": "undo_single_edit", "steps": [{"action": "edit", "op": {"key": "cube-1", "otype": "vector3.set", "path": "position", "value": [5, 5, 5]}}, {"action": "commit"}, {"action": "undo"}], "expected": {"position": [0, 0, 0]}}
2
+ {"name": "redo_after_undo", "steps": [{"action": "edit", "op": {"key": "cube-1", "otype": "vector3.set", "path": "position", "value": [5, 5, 5]}}, {"action": "commit"}, {"action": "undo"}, {"action": "redo"}], "expected": {"position": [5, 5, 5]}}
3
+ {"name": "undo_uncommitted", "steps": [{"action": "edit", "op": {"key": "cube-1", "otype": "vector3.set", "path": "position", "value": [5, 5, 5]}}, {"action": "undo"}], "expected": {"position": [0, 0, 0]}}
@@ -0,0 +1,9 @@
1
+ {"name": "vector3_set", "op": {"key": "node-1", "otype": "vector3.set", "path": "position", "value": [1, 2, 3]}}
2
+ {"name": "vector3_set_zero", "op": {"key": "node-1", "otype": "vector3.set", "path": "position", "value": [0, 0, 0]}}
3
+ {"name": "vector3_add", "op": {"key": "node-1", "otype": "vector3.add", "path": "position", "value": [0.5, 0.5, 0.5]}}
4
+ {"name": "vector3_add_negative", "op": {"key": "node-1", "otype": "vector3.add", "path": "position", "value": [-1, -1, -1]}}
5
+ {"name": "vector3_multiply", "op": {"key": "node-1", "otype": "vector3.multiply", "path": "scale", "value": [2, 2, 2]}}
6
+ {"name": "vector3_multiply_non_uniform", "op": {"key": "node-1", "otype": "vector3.multiply", "path": "scale", "value": [1, 2, 3]}}
7
+ {"name": "quaternion_set", "op": {"key": "node-1", "otype": "quaternion.set", "path": "rotation", "value": [0, 0, 0, 1]}}
8
+ {"name": "quaternion_set_rotated", "op": {"key": "node-1", "otype": "quaternion.set", "path": "rotation", "value": [0, 0.7071, 0, 0.7071]}}
9
+ {"name": "quaternion_multiply", "op": {"key": "node-1", "otype": "quaternion.multiply", "path": "rotation", "value": [0, 0.3827, 0, 0.9239]}}