@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,310 @@
1
+ /**
2
+ * Tests for operation application on server side
3
+ * Tests all operation types using shared fixtures
4
+ */
5
+
6
+ import { describe, it, expect, beforeEach } from '@jest/globals';
7
+ import { readFileSync } from 'fs';
8
+ import { join, dirname } from 'path';
9
+ import { fileURLToPath } from 'url';
10
+ import type { SceneGraph, CRDTMessage, Operation } from '@vuer-ai/vuer-rtc';
11
+
12
+ const __dirname = dirname(fileURLToPath(import.meta.url));
13
+
14
+ interface OpFixture {
15
+ name: string;
16
+ op: Operation;
17
+ }
18
+
19
+ function loadOps(filename: string): OpFixture[] {
20
+ const filepath = join(__dirname, '../fixtures', filename);
21
+ const content = readFileSync(filepath, 'utf-8');
22
+ return content
23
+ .trim()
24
+ .split('\n')
25
+ .map((line) => JSON.parse(line));
26
+ }
27
+
28
+ function createMsg(op: Operation, lamport = 1): CRDTMessage {
29
+ return {
30
+ id: `msg-${Math.random().toString(36).slice(2)}`,
31
+ sessionId: 'test-session',
32
+ clock: { 'test-session': lamport },
33
+ lamportTime: lamport,
34
+ timestamp: Date.now() / 1000,
35
+ ops: [op],
36
+ };
37
+ }
38
+
39
+ async function createNodeWithProps(
40
+ graph: SceneGraph,
41
+ key: string,
42
+ props: Record<string, unknown>
43
+ ): Promise<SceneGraph> {
44
+ const { applyMessage } = await import('@vuer-ai/vuer-rtc');
45
+ const nodeOp: Operation = {
46
+ key: '',
47
+ otype: 'node.insert',
48
+ path: 'children',
49
+ value: {
50
+ key,
51
+ id: `uuid-${key}`,
52
+ tag: 'Mesh',
53
+ name: key,
54
+ ...props,
55
+ },
56
+ };
57
+ return applyMessage(graph, createMsg(nodeOp, 0));
58
+ }
59
+
60
+ describe('Server Operation Tests', () => {
61
+ describe('Number Operations', () => {
62
+ let numberOps: OpFixture[];
63
+
64
+ beforeEach(() => {
65
+ numberOps = loadOps('number-ops.jsonl');
66
+ });
67
+
68
+ it('should apply number.set operation', async () => {
69
+ const { createEmptyGraph, applyMessage } = await import('@vuer-ai/vuer-rtc');
70
+ let graph = createEmptyGraph();
71
+ graph = await createNodeWithProps(graph, 'node-1', { opacity: 1 });
72
+
73
+ const op = numberOps.find((f) => f.name === 'number_set')!.op;
74
+ const result = applyMessage(graph, createMsg(op));
75
+
76
+ expect(result.nodes['node-1'].opacity).toBe(0.5);
77
+ });
78
+
79
+ it('should apply number.add operation', async () => {
80
+ const { createEmptyGraph, applyMessage } = await import('@vuer-ai/vuer-rtc');
81
+ let graph = createEmptyGraph();
82
+ graph = await createNodeWithProps(graph, 'node-1', { score: 50 });
83
+
84
+ const op = numberOps.find((f) => f.name === 'number_add')!.op;
85
+ const result = applyMessage(graph, createMsg(op));
86
+
87
+ expect(result.nodes['node-1'].score).toBe(60);
88
+ });
89
+
90
+ it('should apply number.multiply operation', async () => {
91
+ const { createEmptyGraph, applyMessage } = await import('@vuer-ai/vuer-rtc');
92
+ let graph = createEmptyGraph();
93
+ graph = await createNodeWithProps(graph, 'node-1', { scale: 3 });
94
+
95
+ const op = numberOps.find((f) => f.name === 'number_multiply')!.op;
96
+ const result = applyMessage(graph, createMsg(op));
97
+
98
+ expect(result.nodes['node-1'].scale).toBe(6);
99
+ });
100
+
101
+ it('should apply number.min operation', async () => {
102
+ const { createEmptyGraph, applyMessage } = await import('@vuer-ai/vuer-rtc');
103
+ let graph = createEmptyGraph();
104
+ graph = await createNodeWithProps(graph, 'node-1', { health: 100 });
105
+
106
+ const op = numberOps.find((f) => f.name === 'number_min')!.op;
107
+ const result = applyMessage(graph, createMsg(op));
108
+
109
+ expect(result.nodes['node-1'].health).toBe(50);
110
+ });
111
+
112
+ it('should apply number.max operation', async () => {
113
+ const { createEmptyGraph, applyMessage } = await import('@vuer-ai/vuer-rtc');
114
+ let graph = createEmptyGraph();
115
+ graph = await createNodeWithProps(graph, 'node-1', { damage: 10 });
116
+
117
+ const op = numberOps.find((f) => f.name === 'number_max')!.op;
118
+ const result = applyMessage(graph, createMsg(op));
119
+
120
+ expect(result.nodes['node-1'].damage).toBe(25);
121
+ });
122
+ });
123
+
124
+ describe('Vector3 Operations', () => {
125
+ let vectorOps: OpFixture[];
126
+
127
+ beforeEach(() => {
128
+ vectorOps = loadOps('vector-ops.jsonl');
129
+ });
130
+
131
+ it('should apply vector3.set operation', async () => {
132
+ const { createEmptyGraph, applyMessage } = await import('@vuer-ai/vuer-rtc');
133
+ let graph = createEmptyGraph();
134
+ graph = await createNodeWithProps(graph, 'node-1', { position: [0, 0, 0] });
135
+
136
+ const op = vectorOps.find((f) => f.name === 'vector3_set')!.op;
137
+ const result = applyMessage(graph, createMsg(op));
138
+
139
+ expect(result.nodes['node-1'].position).toEqual([1, 2, 3]);
140
+ });
141
+
142
+ it('should apply vector3.add operation', async () => {
143
+ const { createEmptyGraph, applyMessage } = await import('@vuer-ai/vuer-rtc');
144
+ let graph = createEmptyGraph();
145
+ graph = await createNodeWithProps(graph, 'node-1', { position: [1, 1, 1] });
146
+
147
+ const op = vectorOps.find((f) => f.name === 'vector3_add')!.op;
148
+ const result = applyMessage(graph, createMsg(op));
149
+
150
+ expect(result.nodes['node-1'].position).toEqual([1.5, 1.5, 1.5]);
151
+ });
152
+
153
+ it('should apply vector3.multiply operation', async () => {
154
+ const { createEmptyGraph, applyMessage } = await import('@vuer-ai/vuer-rtc');
155
+ let graph = createEmptyGraph();
156
+ graph = await createNodeWithProps(graph, 'node-1', { scale: [2, 2, 2] });
157
+
158
+ const op = vectorOps.find((f) => f.name === 'vector3_multiply')!.op;
159
+ const result = applyMessage(graph, createMsg(op));
160
+
161
+ expect(result.nodes['node-1'].scale).toEqual([4, 4, 4]);
162
+ });
163
+ });
164
+
165
+ describe('Boolean Operations', () => {
166
+ let boolOps: OpFixture[];
167
+
168
+ beforeEach(() => {
169
+ boolOps = loadOps('boolean-ops.jsonl');
170
+ });
171
+
172
+ it('should apply boolean.set true', async () => {
173
+ const { createEmptyGraph, applyMessage } = await import('@vuer-ai/vuer-rtc');
174
+ let graph = createEmptyGraph();
175
+ graph = await createNodeWithProps(graph, 'node-1', { visible: false });
176
+
177
+ const op = boolOps.find((f) => f.name === 'boolean_set_true')!.op;
178
+ const result = applyMessage(graph, createMsg(op));
179
+
180
+ expect(result.nodes['node-1'].visible).toBe(true);
181
+ });
182
+
183
+ it('should apply boolean.or operation', async () => {
184
+ const { createEmptyGraph, applyMessage } = await import('@vuer-ai/vuer-rtc');
185
+ let graph = createEmptyGraph();
186
+ graph = await createNodeWithProps(graph, 'node-1', { enabled: false });
187
+
188
+ const op = boolOps.find((f) => f.name === 'boolean_or_true')!.op;
189
+ const result = applyMessage(graph, createMsg(op));
190
+
191
+ expect(result.nodes['node-1'].enabled).toBe(true);
192
+ });
193
+
194
+ it('should apply boolean.and operation', async () => {
195
+ const { createEmptyGraph, applyMessage } = await import('@vuer-ai/vuer-rtc');
196
+ let graph = createEmptyGraph();
197
+ graph = await createNodeWithProps(graph, 'node-1', { active: true });
198
+
199
+ const op = boolOps.find((f) => f.name === 'boolean_and_false')!.op;
200
+ const result = applyMessage(graph, createMsg(op));
201
+
202
+ expect(result.nodes['node-1'].active).toBe(false);
203
+ });
204
+ });
205
+
206
+ describe('Array Operations', () => {
207
+ let arrayOps: OpFixture[];
208
+
209
+ beforeEach(() => {
210
+ arrayOps = loadOps('array-ops.jsonl');
211
+ });
212
+
213
+ it('should apply array.set operation', async () => {
214
+ const { createEmptyGraph, applyMessage } = await import('@vuer-ai/vuer-rtc');
215
+ let graph = createEmptyGraph();
216
+ graph = await createNodeWithProps(graph, 'node-1', { tags: [] });
217
+
218
+ const op = arrayOps.find((f) => f.name === 'array_set')!.op;
219
+ const result = applyMessage(graph, createMsg(op));
220
+
221
+ expect(result.nodes['node-1'].tags).toEqual(['enemy', 'active']);
222
+ });
223
+
224
+ it('should apply array.push operation', async () => {
225
+ const { createEmptyGraph, applyMessage } = await import('@vuer-ai/vuer-rtc');
226
+ let graph = createEmptyGraph();
227
+ graph = await createNodeWithProps(graph, 'node-1', { tags: ['initial'] });
228
+
229
+ const op = arrayOps.find((f) => f.name === 'array_push')!.op;
230
+ const result = applyMessage(graph, createMsg(op));
231
+
232
+ expect(result.nodes['node-1'].tags).toEqual(['initial', 'new-tag']);
233
+ });
234
+
235
+ it('should apply array.union operation', async () => {
236
+ const { createEmptyGraph, applyMessage } = await import('@vuer-ai/vuer-rtc');
237
+ let graph = createEmptyGraph();
238
+ graph = await createNodeWithProps(graph, 'node-1', { tags: ['initial'] });
239
+
240
+ const op = arrayOps.find((f) => f.name === 'array_union')!.op;
241
+ const result = applyMessage(graph, createMsg(op));
242
+
243
+ expect(result.nodes['node-1'].tags).toContain('initial');
244
+ expect(result.nodes['node-1'].tags).toContain('tag-a');
245
+ expect(result.nodes['node-1'].tags).toContain('tag-b');
246
+ });
247
+
248
+ it('should apply array.remove operation', async () => {
249
+ const { createEmptyGraph, applyMessage } = await import('@vuer-ai/vuer-rtc');
250
+ let graph = createEmptyGraph();
251
+ graph = await createNodeWithProps(graph, 'node-1', { tags: ['friend', 'enemy'] });
252
+
253
+ const op = arrayOps.find((f) => f.name === 'array_remove')!.op;
254
+ const result = applyMessage(graph, createMsg(op));
255
+
256
+ expect(result.nodes['node-1'].tags).toEqual(['friend']);
257
+ });
258
+ });
259
+
260
+ describe('Node Operations', () => {
261
+ let nodeOps: OpFixture[];
262
+
263
+ beforeEach(() => {
264
+ nodeOps = loadOps('node-ops.jsonl');
265
+ });
266
+
267
+ it('should insert root node', async () => {
268
+ const { createEmptyGraph, applyMessage } = await import('@vuer-ai/vuer-rtc');
269
+ const graph = createEmptyGraph();
270
+
271
+ const op = nodeOps.find((f) => f.name === 'node_insert_root')!.op;
272
+ const result = applyMessage(graph, createMsg(op));
273
+
274
+ expect(result.rootKey).toBe('scene');
275
+ expect(result.nodes['scene']).toBeDefined();
276
+ expect(result.nodes['scene'].tag).toBe('Scene');
277
+ });
278
+
279
+ it('should insert child under parent', async () => {
280
+ const { createEmptyGraph, applyMessage } = await import('@vuer-ai/vuer-rtc');
281
+ let graph = createEmptyGraph();
282
+
283
+ const rootOp = nodeOps.find((f) => f.name === 'node_insert_root')!.op;
284
+ graph = applyMessage(graph, createMsg(rootOp, 1));
285
+
286
+ const meshOp = nodeOps.find((f) => f.name === 'node_insert_mesh')!.op;
287
+ const result = applyMessage(graph, createMsg(meshOp, 2));
288
+
289
+ expect(result.nodes['cube-1']).toBeDefined();
290
+ expect(result.nodes['scene'].children).toContain('cube-1');
291
+ });
292
+
293
+ it('should remove node (tombstone)', async () => {
294
+ const { createEmptyGraph, applyMessage } = await import('@vuer-ai/vuer-rtc');
295
+ let graph = createEmptyGraph();
296
+
297
+ const rootOp = nodeOps.find((f) => f.name === 'node_insert_root')!.op;
298
+ graph = applyMessage(graph, createMsg(rootOp, 1));
299
+
300
+ const meshOp = nodeOps.find((f) => f.name === 'node_insert_mesh')!.op;
301
+ graph = applyMessage(graph, createMsg(meshOp, 2));
302
+
303
+ const removeOp = nodeOps.find((f) => f.name === 'node_remove')!.op;
304
+ const result = applyMessage(graph, createMsg(removeOp, 3));
305
+
306
+ expect(result.nodes['scene'].children).not.toContain('cube-1');
307
+ expect(result.nodes['cube-1']._crdt?.deletedAt).toBeDefined();
308
+ });
309
+ });
310
+ });
@@ -0,0 +1,281 @@
1
+ import { describe, it, expect, beforeEach } from '@jest/globals';
2
+
3
+ // Import types that we'll implement
4
+ type VectorClock = Record<string, number>;
5
+
6
+ interface IVectorClockManager {
7
+ create(sessionId: string): VectorClock;
8
+ increment(clock: VectorClock, sessionId: string): VectorClock;
9
+ merge(clock1: VectorClock, clock2: VectorClock): VectorClock;
10
+ compare(clock1: VectorClock, clock2: VectorClock): number;
11
+ }
12
+
13
+ // We'll implement this later
14
+ let VectorClockManager: new () => IVectorClockManager;
15
+
16
+ try {
17
+ const module = await import('@vuer-ai/vuer-rtc');
18
+ VectorClockManager = module.VectorClockManager;
19
+ } catch {
20
+ // Implementation doesn't exist yet - tests will fail until we implement
21
+ VectorClockManager = class {
22
+ create() { throw new Error('Not implemented'); }
23
+ increment() { throw new Error('Not implemented'); }
24
+ merge() { throw new Error('Not implemented'); }
25
+ compare() { throw new Error('Not implemented'); }
26
+ } as any;
27
+ }
28
+
29
+ describe('VectorClockManager', () => {
30
+ let manager: IVectorClockManager;
31
+
32
+ beforeEach(() => {
33
+ manager = new VectorClockManager();
34
+ });
35
+
36
+ describe('create', () => {
37
+ it('should create a new vector clock for a session', () => {
38
+ const clock = manager.create('session-1');
39
+
40
+ expect(clock).toBeDefined();
41
+ expect(clock['session-1']).toBe(0);
42
+ });
43
+
44
+ it('should create an empty clock for new session', () => {
45
+ const clock = manager.create('session-abc');
46
+
47
+ expect(Object.keys(clock)).toHaveLength(1);
48
+ expect(clock['session-abc']).toBe(0);
49
+ });
50
+ });
51
+
52
+ describe('increment', () => {
53
+ it('should increment the counter for a session', () => {
54
+ const clock = { 'session-1': 5 };
55
+ const newClock = manager.increment(clock, 'session-1');
56
+
57
+ expect(newClock['session-1']).toBe(6);
58
+ });
59
+
60
+ it('should not mutate the original clock', () => {
61
+ const clock = { 'session-1': 5 };
62
+ const newClock = manager.increment(clock, 'session-1');
63
+
64
+ expect(clock['session-1']).toBe(5); // Original unchanged
65
+ expect(newClock['session-1']).toBe(6);
66
+ });
67
+
68
+ it('should initialize counter to 1 if session not in clock', () => {
69
+ const clock = { 'session-1': 5 };
70
+ const newClock = manager.increment(clock, 'session-2');
71
+
72
+ expect(newClock['session-1']).toBe(5);
73
+ expect(newClock['session-2']).toBe(1);
74
+ });
75
+
76
+ it('should handle incrementing from 0', () => {
77
+ const clock = { 'session-1': 0 };
78
+ const newClock = manager.increment(clock, 'session-1');
79
+
80
+ expect(newClock['session-1']).toBe(1);
81
+ });
82
+ });
83
+
84
+ describe('merge', () => {
85
+ it('should take the maximum value for each session', () => {
86
+ const clock1 = { 'session-1': 5, 'session-2': 3 };
87
+ const clock2 = { 'session-1': 3, 'session-2': 7 };
88
+
89
+ const merged = manager.merge(clock1, clock2);
90
+
91
+ expect(merged['session-1']).toBe(5); // max(5, 3)
92
+ expect(merged['session-2']).toBe(7); // max(3, 7)
93
+ });
94
+
95
+ it('should include sessions only in first clock', () => {
96
+ const clock1 = { 'session-1': 5, 'session-2': 3 };
97
+ const clock2 = { 'session-1': 3 };
98
+
99
+ const merged = manager.merge(clock1, clock2);
100
+
101
+ expect(merged['session-1']).toBe(5);
102
+ expect(merged['session-2']).toBe(3);
103
+ });
104
+
105
+ it('should include sessions only in second clock', () => {
106
+ const clock1 = { 'session-1': 5 };
107
+ const clock2 = { 'session-1': 3, 'session-2': 7 };
108
+
109
+ const merged = manager.merge(clock1, clock2);
110
+
111
+ expect(merged['session-1']).toBe(5);
112
+ expect(merged['session-2']).toBe(7);
113
+ });
114
+
115
+ it('should not mutate either input clock', () => {
116
+ const clock1 = { 'session-1': 5 };
117
+ const clock2 = { 'session-1': 3 };
118
+
119
+ manager.merge(clock1, clock2);
120
+
121
+ expect(clock1['session-1']).toBe(5); // Unchanged
122
+ expect(clock2['session-1']).toBe(3); // Unchanged
123
+ });
124
+
125
+ it('should handle empty clocks', () => {
126
+ const clock1 = {};
127
+ const clock2 = { 'session-1': 5 };
128
+
129
+ const merged = manager.merge(clock1, clock2);
130
+
131
+ expect(merged['session-1']).toBe(5);
132
+ });
133
+
134
+ it('should handle both empty clocks', () => {
135
+ const clock1 = {};
136
+ const clock2 = {};
137
+
138
+ const merged = manager.merge(clock1, clock2);
139
+
140
+ expect(Object.keys(merged)).toHaveLength(0);
141
+ });
142
+ });
143
+
144
+ describe('compare', () => {
145
+ it('should return 1 when clock1 > clock2 (causally after)', () => {
146
+ const clock1 = { 'session-1': 5, 'session-2': 3 };
147
+ const clock2 = { 'session-1': 3, 'session-2': 2 };
148
+
149
+ const result = manager.compare(clock1, clock2);
150
+
151
+ expect(result).toBe(1);
152
+ });
153
+
154
+ it('should return -1 when clock1 < clock2 (causally before)', () => {
155
+ const clock1 = { 'session-1': 3, 'session-2': 2 };
156
+ const clock2 = { 'session-1': 5, 'session-2': 3 };
157
+
158
+ const result = manager.compare(clock1, clock2);
159
+
160
+ expect(result).toBe(-1);
161
+ });
162
+
163
+ it('should return 0 when clocks are concurrent', () => {
164
+ const clock1 = { 'session-1': 5, 'session-2': 2 };
165
+ const clock2 = { 'session-1': 3, 'session-2': 4 };
166
+
167
+ const result = manager.compare(clock1, clock2);
168
+
169
+ expect(result).toBe(0); // Concurrent
170
+ });
171
+
172
+ it('should return 0 for identical clocks', () => {
173
+ const clock1 = { 'session-1': 5, 'session-2': 3 };
174
+ const clock2 = { 'session-1': 5, 'session-2': 3 };
175
+
176
+ const result = manager.compare(clock1, clock2);
177
+
178
+ expect(result).toBe(0);
179
+ });
180
+
181
+ it('should handle missing sessions in clock1', () => {
182
+ const clock1 = { 'session-1': 5 };
183
+ const clock2 = { 'session-1': 3, 'session-2': 2 };
184
+
185
+ const result = manager.compare(clock1, clock2);
186
+
187
+ expect(result).toBe(0); // Concurrent (5>3 but 0<2)
188
+ });
189
+
190
+ it('should handle missing sessions in clock2', () => {
191
+ const clock1 = { 'session-1': 3, 'session-2': 2 };
192
+ const clock2 = { 'session-1': 5 };
193
+
194
+ const result = manager.compare(clock1, clock2);
195
+
196
+ expect(result).toBe(0); // Concurrent (3<5 but 2>0)
197
+ });
198
+
199
+ it('should treat missing entries as 0', () => {
200
+ const clock1 = { 'session-1': 5 };
201
+ const clock2 = { 'session-2': 3 };
202
+
203
+ const result = manager.compare(clock1, clock2);
204
+
205
+ expect(result).toBe(0); // Concurrent
206
+ });
207
+ });
208
+
209
+ describe('concurrency detection', () => {
210
+ it('should detect concurrent operations', () => {
211
+ // Session 1 does operation A: {s1: 1, s2: 0}
212
+ const clockA = { 'session-1': 1, 'session-2': 0 };
213
+
214
+ // Session 2 does operation B: {s1: 0, s2: 1}
215
+ const clockB = { 'session-1': 0, 'session-2': 1 };
216
+
217
+ // Neither causally precedes the other
218
+ expect(manager.compare(clockA, clockB)).toBe(0);
219
+ expect(manager.compare(clockB, clockA)).toBe(0);
220
+ });
221
+
222
+ it('should detect causal ordering', () => {
223
+ // Session 1 does operation A: {s1: 1}
224
+ const clockA = { 'session-1': 1 };
225
+
226
+ // Session 1 does operation B after A: {s1: 2}
227
+ const clockB = { 'session-1': 2 };
228
+
229
+ // B causally follows A
230
+ expect(manager.compare(clockA, clockB)).toBe(-1);
231
+ expect(manager.compare(clockB, clockA)).toBe(1);
232
+ });
233
+
234
+ it('should detect concurrent edits to same object', () => {
235
+ // Both sessions have seen initial state
236
+ const baseClock = { 'session-1': 5, 'session-2': 3 };
237
+
238
+ // Session 1 makes an edit
239
+ const clock1 = manager.increment(baseClock, 'session-1');
240
+
241
+ // Session 2 makes concurrent edit (hasn't seen session 1's edit)
242
+ const clock2 = manager.increment(baseClock, 'session-2');
243
+
244
+ // These are concurrent
245
+ expect(manager.compare(clock1, clock2)).toBe(0);
246
+ });
247
+ });
248
+
249
+ describe('real-world scenarios', () => {
250
+ it('should handle multi-session collaboration', async () => {
251
+ // Three sessions editing concurrently
252
+ const sessions = ['s1', 's2', 's3'];
253
+ const clocks: Record<string, VectorClock> = {};
254
+
255
+ // Each session starts
256
+ sessions.forEach(sid => {
257
+ clocks[sid] = manager.create(sid);
258
+ });
259
+
260
+ // s1 makes first edit
261
+ clocks.s1 = manager.increment(clocks.s1, 's1');
262
+ expect(clocks.s1).toEqual({ 's1': 1 });
263
+
264
+ // s2 receives s1's edit and makes own edit
265
+ clocks.s2 = manager.merge(clocks.s2, clocks.s1);
266
+ clocks.s2 = manager.increment(clocks.s2, 's2');
267
+ expect(clocks.s2).toEqual({ 's1': 1, 's2': 1 });
268
+
269
+ // s2's edit causally follows s1's
270
+ expect(manager.compare(clocks.s1, clocks.s2)).toBe(-1);
271
+
272
+ // s3 makes concurrent edit (hasn't seen s1 or s2)
273
+ clocks.s3 = manager.increment(clocks.s3, 's3');
274
+ expect(clocks.s3).toEqual({ 's3': 1 });
275
+
276
+ // s3's edit is concurrent with both s1 and s2
277
+ expect(manager.compare(clocks.s1, clocks.s3)).toBe(0);
278
+ expect(manager.compare(clocks.s2, clocks.s3)).toBe(0);
279
+ });
280
+ });
281
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "lib": ["ES2022"],
7
+ "declaration": true,
8
+ "declarationMap": true,
9
+ "sourceMap": true,
10
+ "outDir": "dist",
11
+ "rootDir": "src",
12
+ "strict": true,
13
+ "esModuleInterop": true,
14
+ "skipLibCheck": true,
15
+ "forceConsistentCasingInFileNames": true
16
+ },
17
+ "include": ["src"],
18
+ "exclude": ["node_modules", "dist"]
19
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "rootDir": ".",
5
+ "noEmit": true
6
+ },
7
+ "include": ["src", "tests"]
8
+ }