@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
package/.env ADDED
@@ -0,0 +1 @@
1
+ DATABASE_URL="mongodb+srv://ge-dev-vuer-rtc:zitfi7-vebwEb-wocmob@development.bckeeee.mongodb.net/vuer-rtc?appName=development"
@@ -0,0 +1,94 @@
1
+ # Phase 1: Foundation - Completed ✅
2
+
3
+ ## Summary
4
+ Successfully implemented the foundation for Vuer RTC with comprehensive test coverage using Test-Driven Development (TDD).
5
+
6
+ ## What Was Built
7
+
8
+ ### 1. Testing Infrastructure
9
+ - **Jest** with TypeScript and ESM support
10
+ - Parallel test execution (50% of available CPUs)
11
+ - Async test support for integration tests
12
+ - Test directory structure: `unit/`, `integration/`, `e2e/`
13
+ - **Test Coverage**: 45 passing unit tests
14
+
15
+ ### 2. Vector Clock Implementation (CRDT)
16
+ - `VectorClockManager` class with full CRDT support
17
+ - Operations: create, increment, merge, compare
18
+ - Causal ordering detection
19
+ - Concurrent operation detection
20
+ - **23 tests** covering all edge cases
21
+
22
+ ### 3. Operation Types & Validation
23
+ - **4 operation types**: CREATE, UPDATE, DELETE, TRANSFORM
24
+ - Complete TypeScript type definitions
25
+ - `OperationValidator` with schema validation
26
+ - Transform3D with quaternion rotation support
27
+ - **20 tests** for type guards and validation
28
+
29
+ ### 4. Prisma Schema (MongoDB)
30
+ - **5 data models**: Document, SceneObject, Operation, JournalBatch, Session
31
+ - CRDT metadata for conflict resolution
32
+ - Soft deletes with tombstone pattern
33
+ - Optimized indexes for queries
34
+ - Write-ahead log structure (33ms batching)
35
+
36
+ ### 5. Database Repositories
37
+ - `PrismaClient` singleton for connection management
38
+ - `DocumentRepository` with CRUD operations
39
+ - `SessionRepository` with presence tracking
40
+ - Version incrementing and clock value management
41
+ - Integration tests ready (require MongoDB)
42
+
43
+ ### 6. Code Quality
44
+ - Modular architecture with clean exports
45
+ - High-level abstractions
46
+ - Comprehensive documentation
47
+ - Type-safe implementations
48
+
49
+ ## Git Commits
50
+
51
+ 1. `19da4ca` - Initialize Jest testing framework
52
+ 2. `4cfcd90` - Implement vector clock with tests (23 tests)
53
+ 3. `6ab7c9e` - Add operation types and validation (20 tests)
54
+ 4. `ce90de3` - Add Prisma schema for all models
55
+ 5. `35920c6` - Add database repositories
56
+ 6. `b2a81a8` - Refactor with modular exports
57
+
58
+ ## Metrics
59
+
60
+ - **Total Tests**: 45 passing
61
+ - **Code Coverage**: Ready for >80% threshold
62
+ - **TypeScript**: 100% type-safe
63
+ - **MongoDB Models**: 5 complete schemas
64
+ - **Repository Methods**: 15+ database operations
65
+
66
+ ## Ready for Next Phase
67
+
68
+ Phase 1 provides the complete foundation for:
69
+ - Phase 2: State Management (SceneState, StateManager, ConflictResolver)
70
+ - Phase 3: WebSocket Server (real-time communication)
71
+ - Phase 4: Write-Ahead Log (journal with batching)
72
+ - Phase 5: History/Undo system
73
+ - Phase 6: Client Library
74
+ - Phase 7: Documentation site
75
+
76
+ ## To Create PR
77
+
78
+ ```bash
79
+ # Add GitHub remote (if not already done)
80
+ git remote add origin https://github.com/USERNAME/vuer-rtc.git
81
+
82
+ # Push to GitHub
83
+ git push -u origin main
84
+
85
+ # Create PR using GitHub CLI
86
+ gh pr create \
87
+ --title "Phase 1: Foundation - Jest, Vector Clocks, Operations, Prisma" \
88
+ --body "$(cat PHASE1_SUMMARY.md)" \
89
+ --reviewer marvinluo1
90
+ ```
91
+
92
+ ## Next Steps
93
+
94
+ Continue with Phase 2: State Management implementation.
package/README.md ADDED
@@ -0,0 +1,423 @@
1
+ # @vuer-ai/vuer-rtc-server
2
+
3
+ Real-time collaborative library for vuer.ai. Server is built with MongoDB persistence and Fastify.
4
+
5
+ The CRDT logic is in `@vuer-ai/vuer-rtc`. This package provides server-specific functionality.
6
+
7
+ ## How CRDT Works
8
+
9
+ ### The Core Concept
10
+
11
+ **CRDT (Conflict-free Replicated Data Types)** allows multiple users to edit the same data concurrently without conflicts. The key insight: instead of locking or resolving conflicts, design operations that **always merge correctly**.
12
+
13
+ ### Architecture
14
+
15
+ ```
16
+ ┌─────────────┐ CRDTMessage ┌─────────────┐
17
+ │ Client A │ ──────────────────▶ │ Server │
18
+ │ (Alice) │ │ │
19
+ └─────────────┘ │ 1. Append │
20
+ │ to │
21
+ ┌─────────────┐ CRDTMessage │ Journal │
22
+ │ Client B │ ──────────────────▶ │ │
23
+ │ (Bob) │ │ 2. Apply │
24
+ └─────────────┘ │ to │
25
+ │ Graph │
26
+ └─────────────┘
27
+
28
+
29
+ Broadcast to all
30
+ ```
31
+
32
+ ### State Model
33
+
34
+ The server maintains two data structures:
35
+
36
+ ```typescript
37
+ interface ServerState {
38
+ graph: SceneGraph; // Materialized view (fast reads)
39
+ journal: CRDTMessage[]; // Append-only log (source of truth)
40
+ snapshot?: Snapshot; // Periodic checkpoint (optional)
41
+ }
42
+ ```
43
+
44
+ | Component | Purpose | Persistence |
45
+ |-----------|---------|-------------|
46
+ | `graph` | Current state for queries | In-memory (rebuilt from journal) |
47
+ | `journal` | Complete history of all messages | MongoDB / disk |
48
+ | `snapshot` | Checkpoint to speed up recovery | MongoDB / disk |
49
+
50
+ **Why both?**
51
+ - `graph` = fast reads, but lost on restart
52
+ - `journal` = durable, enables replay, sync, and debugging
53
+
54
+ ### Key Components
55
+
56
+ 1. **CRDTMessage (Envelope)** - Contains metadata for ordering:
57
+ - `lamportTime` - Logical clock for total ordering (LWW)
58
+ - `clock` - Vector clock for causal ordering
59
+ - `sessionId` - Who sent this message
60
+
61
+ 2. **Operations** - Explicit `otype` determines merge behavior:
62
+ - `vector3.set` → Last-Write-Wins (absolute)
63
+ - `vector3.add` → Sum values (relative/additive)
64
+ - `number.add` → Sum values (counters)
65
+
66
+ ### Why Explicit `otype` Matters
67
+
68
+ **Scenario: Two users drag the same object simultaneously**
69
+
70
+ ❌ **Without explicit otype** (ambiguous):
71
+ ```
72
+ Alice: position = [5, 0, 0] // Did she SET or ADD?
73
+ Bob: position = [0, 3, 0] // Did he SET or ADD?
74
+ // Result: ??? (unpredictable)
75
+ ```
76
+
77
+ ✅ **With explicit otype** (unambiguous):
78
+ ```
79
+ Alice: { otype: 'vector3.add', value: [5, 0, 0] } // += [5,0,0]
80
+ Bob: { otype: 'vector3.add', value: [0, 3, 0] } // += [0,3,0]
81
+ // Result: position += [5, 3, 0] ✅ (both movements apply)
82
+ ```
83
+
84
+ ### Merge Rules by otype
85
+
86
+ | otype | Merge | Use Case |
87
+ |-------|-------|----------|
88
+ | `*.set` | Last-Write-Wins (higher lamport wins) | Absolute values |
89
+ | `*.add` | Sum all values | Counters, drag deltas |
90
+ | `*.multiply` | Product | Scale gestures |
91
+ | `array.push` | Append all | Adding children |
92
+ | `array.remove` | Remove from all | Removing children |
93
+
94
+ ## Journal Lifecycle
95
+
96
+ The journal is the **source of truth**. The graph is just a materialized view.
97
+
98
+ ### Message Flow
99
+
100
+ ```
101
+ Client sends CRDTMessage
102
+
103
+
104
+ ┌─────────────────────────┐
105
+ │ 1. Deduplicate │ ← Skip if msg.id already in journal
106
+ │ 2. Append to journal │ ← Persist to MongoDB
107
+ │ 3. Apply to graph │ ← Update in-memory state
108
+ │ 4. Broadcast │ ← Send to other clients
109
+ └─────────────────────────┘
110
+ ```
111
+
112
+ ### Idempotency
113
+
114
+ **Critical**: Not all operations are idempotent on replay!
115
+
116
+ | Operation Type | Idempotent? | Why |
117
+ |---------------|-------------|-----|
118
+ | `*.set` (LWW) | ✅ Yes | Compares lamportTime, same result on replay |
119
+ | `*.add` | ❌ No | Blindly adds value, doubles on replay |
120
+ | `*.multiply` | ❌ No | Blindly multiplies, compounds on replay |
121
+ | `node.insert` | ✅ Yes | Checks if node exists |
122
+
123
+ **Solution**: Track applied message IDs per node to skip duplicates:
124
+
125
+ ```typescript
126
+ interface SceneNode {
127
+ // ... existing fields
128
+ appliedMsgIds: Set<string>; // Track which messages contributed
129
+ }
130
+
131
+ function applyOperation(node, op, meta) {
132
+ if (node.appliedMsgIds.has(meta.messageId)) {
133
+ return; // Already applied, skip
134
+ }
135
+ node.appliedMsgIds.add(meta.messageId);
136
+ // ... apply operation
137
+ }
138
+ ```
139
+
140
+ ### Recovery Flow
141
+
142
+ When server restarts:
143
+
144
+ ```
145
+ 1. Load snapshot from DB (if exists)
146
+ └─ snapshot = { graph, journalIndex }
147
+
148
+ 2. Load journal entries after snapshot
149
+ └─ journal.slice(snapshot.journalIndex)
150
+
151
+ 3. Replay journal onto snapshot.graph
152
+ └─ for (msg of journal) graph = applyMessage(graph, msg)
153
+
154
+ 4. Server ready
155
+ ```
156
+
157
+ ### Client Sync Flow
158
+
159
+ When a new client connects:
160
+
161
+ ```
162
+ ┌────────────┐ ┌────────────┐
163
+ │ New Client │ │ Server │
164
+ └─────┬──────┘ └─────┬──────┘
165
+ │ │
166
+ │ 1. Connect │
167
+ │──────────────────────────────────▶│
168
+ │ │
169
+ │ 2. Send current graph │
170
+ │◀──────────────────────────────────│
171
+ │ (or snapshot + journal tail) │
172
+ │ │
173
+ │ 3. Client applies, catches up │
174
+ │ │
175
+ │ 4. Subscribe to live updates │
176
+ │◀─────────────────────────────────▶│
177
+ │ │
178
+ ```
179
+
180
+ **Option A**: Send full `graph` (simple, but large)
181
+ **Option B**: Send `snapshot` + `journalTail` (smaller, incremental)
182
+
183
+ ### Compaction
184
+
185
+ The journal grows unbounded. Periodically compact:
186
+
187
+ ```
188
+ Before compaction:
189
+ journal: [msg1, msg2, msg3, ..., msg1000]
190
+ snapshot: null
191
+
192
+ After compaction:
193
+ journal: [msg901, msg902, ..., msg1000] ← Keep recent
194
+ snapshot: { graph: <state at msg900>, journalIndex: 900 }
195
+ ```
196
+
197
+ **When to compact**:
198
+ - Journal exceeds N messages (e.g., 1000)
199
+ - Periodic timer (e.g., every hour)
200
+ - On graceful shutdown
201
+
202
+ ## Usage
203
+
204
+ ```typescript
205
+ import { createEmptyGraph, applyMessage } from '@vuer-rtc/server/operations';
206
+ import type { CRDTMessage } from '@vuer-rtc/server/operations';
207
+
208
+ // Create empty scene
209
+ let graph = createEmptyGraph();
210
+
211
+ // Apply a message with operations
212
+ const msg: CRDTMessage = {
213
+ id: 'msg-001',
214
+ sessionId: 'alice',
215
+ clock: { alice: 1 },
216
+ lamportTime: 1,
217
+ timestamp: Date.now(),
218
+ ops: [
219
+ // Create scene root
220
+ {
221
+ key: 'scene',
222
+ otype: 'node.insert',
223
+ path: 'scene',
224
+ value: { key: 'uuid-scene', tag: 'Scene', name: 'My Scene' },
225
+ },
226
+ // Create cube with parent (automatically adds to parent's children)
227
+ {
228
+ key: 'cube-1',
229
+ otype: 'node.insert',
230
+ path: 'cube-1',
231
+ parent: 'scene', // Automatically adds to scene's children
232
+ value: {
233
+ key: 'uuid-001',
234
+ tag: 'Mesh',
235
+ name: 'Red Cube',
236
+ color: '#ff0000',
237
+ 'transform.position': [0, 0, 0],
238
+ },
239
+ },
240
+ ],
241
+ };
242
+
243
+ graph = applyMessage(graph, msg);
244
+ ```
245
+
246
+ ## CRDTMessage Structure
247
+
248
+ ```typescript
249
+ interface CRDTMessage {
250
+ id: string; // Message ID
251
+ sessionId: string; // Who sent this
252
+ clock: VectorClock; // For causal ordering
253
+ lamportTime: number; // For total ordering (LWW)
254
+ timestamp: number; // Wall-clock time
255
+ ops: Operation[]; // Batch of operations
256
+ }
257
+ ```
258
+
259
+ ## Operation Types
260
+
261
+ ### Node Operations
262
+ | otype | Description |
263
+ |-------|-------------|
264
+ | `node.insert` | Create new node (idempotent). Use `parent` field to auto-add to parent's children. |
265
+ | `node.remove` | Delete node (tombstone) |
266
+
267
+ ### Number Operations
268
+ | otype | Merge | Example |
269
+ |-------|-------|---------|
270
+ | `number.set` | LWW | `opacity = 0.5` |
271
+ | `number.add` | Sum | `score += 10` |
272
+ | `number.multiply` | Product | `scale *= 2` |
273
+ | `number.min` | Minimum | `min(current, new)` |
274
+ | `number.max` | Maximum | `max(current, new)` |
275
+
276
+ ### Vector3 Operations
277
+ | otype | Merge | Example |
278
+ |-------|-------|---------|
279
+ | `vector3.set` | LWW | `position = [0, 5, 0]` |
280
+ | `vector3.add` | Component sum | `position += [5, 0, 0]` |
281
+ | `vector3.multiply` | Component product | `scale *= [2, 2, 2]` |
282
+
283
+ ### Array Operations
284
+ | otype | Merge | Example |
285
+ |-------|-------|---------|
286
+ | `array.set` | LWW | `children = ['a', 'b']` |
287
+ | `array.push` | Append | `children.push('c')` |
288
+ | `array.remove` | Remove | `children.remove('a')` |
289
+ | `array.union` | Union | Merge sets |
290
+
291
+ ### Other Operations
292
+ | otype | Merge | Example |
293
+ |-------|-------|---------|
294
+ | `color.set` | LWW | `color = '#ff0000'` |
295
+ | `string.set` | LWW | `name = 'Cube'` |
296
+ | `boolean.set` | LWW | `visible = true` |
297
+ | `quaternion.set` | LWW | `rotation = [0, 0, 0, 1]` |
298
+
299
+ ## Additive vs LWW
300
+
301
+ ### Additive Operations (`*.add`)
302
+ Order doesn't matter, values accumulate:
303
+ ```
304
+ Alice: position += [5, 0, 0]
305
+ Bob: position += [0, 3, 0]
306
+ Result: position += [5, 3, 0] ✅
307
+ ```
308
+
309
+ ### LWW Operations (`*.set`)
310
+ Higher lamportTime wins:
311
+ ```
312
+ Alice: color = red (lamport: 10)
313
+ Bob: color = blue (lamport: 11)
314
+ Result: color = blue ✅ (Bob's lamport is higher)
315
+ ```
316
+
317
+ ### Client Reconciliation
318
+
319
+ When a client receives a message from the server, it applies it using the **same `applyMessage` function**:
320
+
321
+ ```
322
+ 1. Client makes local edit → applies locally (optimistic)
323
+ 2. Client sends to server → server applies & broadcasts
324
+ 3. Client receives broadcast → applies with deduplication
325
+ ```
326
+
327
+ **Important**: Clients must track applied message IDs to avoid double-applying their own messages:
328
+
329
+ ```typescript
330
+ // Client-side state
331
+ const appliedMsgIds = new Set<string>();
332
+
333
+ function onServerMessage(msg: CRDTMessage) {
334
+ if (appliedMsgIds.has(msg.id)) {
335
+ return; // Already applied locally, skip
336
+ }
337
+ appliedMsgIds.add(msg.id);
338
+ graph = applyMessage(graph, msg);
339
+ }
340
+ ```
341
+
342
+ **For LWW operations**: If the server's lamport time is higher, the server value wins:
343
+ ```typescript
344
+ // Client has: color = '#ff0000' (lamport: 5)
345
+ // Server sends: color = '#0000ff' (lamport: 8)
346
+ graph = applyMessage(graph, serverMsg); // color becomes '#0000ff'
347
+ ```
348
+
349
+ **For additive operations**: Each unique message applies once:
350
+ ```typescript
351
+ // Client applied locally: position += [5, 0, 0] (msg-alice-1)
352
+ // Server broadcasts same message back
353
+ // Client skips (already in appliedMsgIds)
354
+
355
+ // Server sends Bob's edit: position += [0, 3, 0] (msg-bob-1)
356
+ // Client applies (new message ID)
357
+ graph = applyMessage(graph, serverMsg); // position += [0, 3, 0]
358
+ ```
359
+
360
+ **Key insight**: Convergence requires both CRDT merge rules AND message deduplication. Without deduplication, additive operations would double-apply.
361
+
362
+ ### Conflict Resolution Example
363
+
364
+ ```typescript
365
+ import { createEmptyGraph, applyMessage } from '@vuer-rtc/server/operations';
366
+
367
+ // Setup: cube at position [0, 0, 0]
368
+ let graph = applyMessage(createEmptyGraph(), {
369
+ id: 'setup', sessionId: 'server', clock: { server: 1 }, lamportTime: 0, timestamp: Date.now(),
370
+ ops: [{ key: 'cube', otype: 'node.insert', path: 'cube', value: { key: 'uuid', tag: 'Mesh', name: 'Cube', 'position': [0, 0, 0], color: '#fff' }}],
371
+ });
372
+
373
+ // Alice drags right, Bob drags up (concurrent - both use additive)
374
+ graph = applyMessage(graph, {
375
+ id: 'alice', sessionId: 'alice', clock: { alice: 1 }, lamportTime: 1, timestamp: Date.now(),
376
+ ops: [{ key: 'cube', otype: 'vector3.add', path: 'position', value: [5, 0, 0] }],
377
+ });
378
+ graph = applyMessage(graph, {
379
+ id: 'bob', sessionId: 'bob', clock: { bob: 1 }, lamportTime: 2, timestamp: Date.now(),
380
+ ops: [{ key: 'cube', otype: 'vector3.add', path: 'position', value: [0, 3, 0] }],
381
+ });
382
+
383
+ console.log(graph.nodes['cube'].position); // [5, 3, 0] - both movements applied!
384
+
385
+ // Bob sets blue (lamport 11), then Alice's earlier edit arrives (lamport 10)
386
+ graph = applyMessage(graph, {
387
+ id: 'bob-color', sessionId: 'bob', clock: { bob: 2 }, lamportTime: 11, timestamp: Date.now(),
388
+ ops: [{ key: 'cube', otype: 'color.set', path: 'color', value: '#0000ff' }],
389
+ });
390
+ graph = applyMessage(graph, {
391
+ id: 'alice-color', sessionId: 'alice', clock: { alice: 2 }, lamportTime: 10, timestamp: Date.now(),
392
+ ops: [{ key: 'cube', otype: 'color.set', path: 'color', value: '#ff0000' }],
393
+ });
394
+
395
+ console.log(graph.nodes['cube'].color); // '#0000ff' - Bob still wins (lamport 11 > 10)
396
+ ```
397
+
398
+ ## Examples
399
+
400
+ See the `examples/` folder for runnable examples:
401
+
402
+ ```bash
403
+ npx tsx examples/01-basic-usage.ts
404
+ npx tsx examples/02-concurrent-edits.ts
405
+ npx tsx examples/03-scene-building.ts
406
+ npx tsx examples/04-conflict-resolution.ts
407
+ ```
408
+
409
+ ## Project Structure
410
+
411
+ ```
412
+ src/operations/
413
+ ├── OperationTypes.ts # Type definitions
414
+ ├── dispatcher.ts # applyMessage(), applyMessages()
415
+ ├── apply/
416
+ │ ├── index.ts # Registry exports
417
+ │ ├── types.ts # OpMeta, ApplyFn
418
+ │ ├── number.ts # NumberSet, NumberAdd, ...
419
+ │ ├── vector3.ts # Vector3Set, Vector3Add, ...
420
+ │ ├── array.ts # ArraySet, ArrayPush, ...
421
+ │ ├── node.ts # NodeInsert, NodeRemove
422
+ │ └── ...
423
+ ```
@@ -0,0 +1,24 @@
1
+ /**
2
+ * InMemoryBroker — Single-process RoomBroker implementation.
3
+ *
4
+ * Zero external dependencies. Suitable for:
5
+ * - Development and testing
6
+ * - Single-server production (< ~100 concurrent users)
7
+ *
8
+ * Swap for RedisBroker when scaling to multiple server instances.
9
+ */
10
+ import type { RoomBroker, SequencedMessage, MemberState } from './types.js';
11
+ export declare class InMemoryBroker implements RoomBroker {
12
+ private seqs;
13
+ private subs;
14
+ private members;
15
+ publish(roomId: string, msg: SequencedMessage): Promise<void>;
16
+ subscribe(roomId: string, handler: (msg: SequencedMessage) => void): () => void;
17
+ nextSeq(roomId: string): Promise<number>;
18
+ setMember(roomId: string, sessionId: string, state: MemberState): Promise<void>;
19
+ removeMember(roomId: string, sessionId: string): Promise<void>;
20
+ getMembers(roomId: string): Promise<Map<string, MemberState>>;
21
+ deleteRoom(roomId: string): Promise<void>;
22
+ clearRoom(roomId: string): Promise<void>;
23
+ }
24
+ //# sourceMappingURL=InMemoryBroker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"InMemoryBroker.d.ts","sourceRoot":"","sources":["../../src/broker/InMemoryBroker.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,gBAAgB,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE5E,qBAAa,cAAe,YAAW,UAAU;IAC/C,OAAO,CAAC,IAAI,CAA6B;IACzC,OAAO,CAAC,IAAI,CAA2D;IACvE,OAAO,CAAC,OAAO,CAA+C;IAExD,OAAO,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC;IAQnE,SAAS,CACP,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,CAAC,GAAG,EAAE,gBAAgB,KAAK,IAAI,GACvC,MAAM,IAAI;IAcP,OAAO,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAOxC,SAAS,CACb,MAAM,EAAE,MAAM,EACd,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,WAAW,GACjB,OAAO,CAAC,IAAI,CAAC;IAOV,YAAY,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAI9D,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;IAI7D,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAMzC,SAAS,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAM/C"}
@@ -0,0 +1,65 @@
1
+ /**
2
+ * InMemoryBroker — Single-process RoomBroker implementation.
3
+ *
4
+ * Zero external dependencies. Suitable for:
5
+ * - Development and testing
6
+ * - Single-server production (< ~100 concurrent users)
7
+ *
8
+ * Swap for RedisBroker when scaling to multiple server instances.
9
+ */
10
+ export class InMemoryBroker {
11
+ seqs = new Map();
12
+ subs = new Map();
13
+ members = new Map();
14
+ async publish(roomId, msg) {
15
+ const handlers = this.subs.get(roomId);
16
+ if (!handlers)
17
+ return;
18
+ for (const handler of handlers) {
19
+ handler(msg);
20
+ }
21
+ }
22
+ subscribe(roomId, handler) {
23
+ if (!this.subs.has(roomId)) {
24
+ this.subs.set(roomId, new Set());
25
+ }
26
+ const handlers = this.subs.get(roomId);
27
+ handlers.add(handler);
28
+ return () => {
29
+ handlers.delete(handler);
30
+ if (handlers.size === 0) {
31
+ this.subs.delete(roomId);
32
+ }
33
+ };
34
+ }
35
+ async nextSeq(roomId) {
36
+ const current = this.seqs.get(roomId) ?? 0;
37
+ const next = current + 1;
38
+ this.seqs.set(roomId, next);
39
+ return next;
40
+ }
41
+ async setMember(roomId, sessionId, state) {
42
+ if (!this.members.has(roomId)) {
43
+ this.members.set(roomId, new Map());
44
+ }
45
+ this.members.get(roomId).set(sessionId, state);
46
+ }
47
+ async removeMember(roomId, sessionId) {
48
+ this.members.get(roomId)?.delete(sessionId);
49
+ }
50
+ async getMembers(roomId) {
51
+ return this.members.get(roomId) ?? new Map();
52
+ }
53
+ async deleteRoom(roomId) {
54
+ this.seqs.delete(roomId);
55
+ this.subs.delete(roomId);
56
+ this.members.delete(roomId);
57
+ }
58
+ async clearRoom(roomId) {
59
+ this.seqs.delete(roomId);
60
+ this.members.delete(roomId);
61
+ // Subscriptions are intentionally kept alive so existing
62
+ // WebSocket connections continue to receive broadcasts.
63
+ }
64
+ }
65
+ //# sourceMappingURL=InMemoryBroker.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"InMemoryBroker.js","sourceRoot":"","sources":["../../src/broker/InMemoryBroker.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,MAAM,OAAO,cAAc;IACjB,IAAI,GAAG,IAAI,GAAG,EAAkB,CAAC;IACjC,IAAI,GAAG,IAAI,GAAG,EAAgD,CAAC;IAC/D,OAAO,GAAG,IAAI,GAAG,EAAoC,CAAC;IAE9D,KAAK,CAAC,OAAO,CAAC,MAAc,EAAE,GAAqB;QACjD,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;QACvC,IAAI,CAAC,QAAQ;YAAE,OAAO;QACtB,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;YAC/B,OAAO,CAAC,GAAG,CAAC,CAAC;QACf,CAAC;IACH,CAAC;IAED,SAAS,CACP,MAAc,EACd,OAAwC;QAExC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC;YAC3B,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM,EAAE,IAAI,GAAG,EAAE,CAAC,CAAC;QACnC,CAAC;QACD,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM,CAAE,CAAC;QACxC,QAAQ,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;QACtB,OAAO,GAAG,EAAE;YACV,QAAQ,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;YACzB,IAAI,QAAQ,CAAC,IAAI,KAAK,CAAC,EAAE,CAAC;gBACxB,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;YAC3B,CAAC;QACH,CAAC,CAAC;IACJ,CAAC;IAED,KAAK,CAAC,OAAO,CAAC,MAAc;QAC1B,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;QAC3C,MAAM,IAAI,GAAG,OAAO,GAAG,CAAC,CAAC;QACzB,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;QAC5B,OAAO,IAAI,CAAC;IACd,CAAC;IAED,KAAK,CAAC,SAAS,CACb,MAAc,EACd,SAAiB,EACjB,KAAkB;QAElB,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC;YAC9B,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,IAAI,GAAG,EAAE,CAAC,CAAC;QACtC,CAAC;QACD,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAE,CAAC,GAAG,CAAC,SAAS,EAAE,KAAK,CAAC,CAAC;IAClD,CAAC;IAED,KAAK,CAAC,YAAY,CAAC,MAAc,EAAE,SAAiB;QAClD,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,SAAS,CAAC,CAAC;IAC9C,CAAC;IAED,KAAK,CAAC,UAAU,CAAC,MAAc;QAC7B,OAAO,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,IAAI,GAAG,EAAE,CAAC;IAC/C,CAAC;IAED,KAAK,CAAC,UAAU,CAAC,MAAc;QAC7B,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;QACzB,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;QACzB,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IAC9B,CAAC;IAED,KAAK,CAAC,SAAS,CAAC,MAAc;QAC5B,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;QACzB,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;QAC5B,yDAAyD;QACzD,wDAAwD;IAC1D,CAAC;CACF"}
@@ -0,0 +1,3 @@
1
+ export type { RoomBroker, SequencedMessage, MemberState } from './types.js';
2
+ export { InMemoryBroker } from './InMemoryBroker.js';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/broker/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,UAAU,EAAE,gBAAgB,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAC5E,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC"}
@@ -0,0 +1,2 @@
1
+ export { InMemoryBroker } from './InMemoryBroker.js';
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/broker/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC"}
@@ -0,0 +1,47 @@
1
+ /**
2
+ * RoomBroker — Room-scoped pub/sub, sequencing, and membership.
3
+ *
4
+ * The sync layer is data-type agnostic. It operates on CRDTMessage
5
+ * envelopes without inspecting ops[]. Swap InMemoryBroker for
6
+ * RedisBroker when scaling to multiple server instances.
7
+ */
8
+ import type { CRDTMessage } from '@vuer-ai/vuer-rtc';
9
+ /**
10
+ * A message enriched with the server-assigned sequence number.
11
+ */
12
+ export interface SequencedMessage extends CRDTMessage {
13
+ serverSeq: number;
14
+ }
15
+ /**
16
+ * Per-member state tracked by the broker.
17
+ */
18
+ export interface MemberState {
19
+ sessionId: string;
20
+ vectorClock: Record<string, number>;
21
+ lastSeen: number;
22
+ connected: boolean;
23
+ }
24
+ /**
25
+ * Room-scoped broker interface.
26
+ *
27
+ * All methods are async to support both in-memory and Redis backends.
28
+ */
29
+ export interface RoomBroker {
30
+ /** Publish a sequenced message to all subscribers in a room. */
31
+ publish(roomId: string, msg: SequencedMessage): Promise<void>;
32
+ /** Subscribe to messages in a room. Returns an unsubscribe function. */
33
+ subscribe(roomId: string, handler: (msg: SequencedMessage) => void): () => void;
34
+ /** Get the next monotonic sequence number for a room. */
35
+ nextSeq(roomId: string): Promise<number>;
36
+ /** Register or update a member in a room. */
37
+ setMember(roomId: string, sessionId: string, state: MemberState): Promise<void>;
38
+ /** Remove a member from a room. */
39
+ removeMember(roomId: string, sessionId: string): Promise<void>;
40
+ /** Get all members in a room. */
41
+ getMembers(roomId: string): Promise<Map<string, MemberState>>;
42
+ /** Clean up all state for a room (used in tests). */
43
+ deleteRoom(roomId: string): Promise<void>;
44
+ /** Reset data (seqs, members) but keep subscriptions alive. */
45
+ clearRoom(roomId: string): Promise<void>;
46
+ }
47
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/broker/types.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAErD;;GAEG;AACH,MAAM,WAAW,gBAAiB,SAAQ,WAAW;IACnD,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;GAEG;AACH,MAAM,WAAW,WAAW;IAC1B,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACpC,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,OAAO,CAAC;CACpB;AAED;;;;GAIG;AACH,MAAM,WAAW,UAAU;IACzB,gEAAgE;IAChE,OAAO,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAE9D,wEAAwE;IACxE,SAAS,CACP,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,CAAC,GAAG,EAAE,gBAAgB,KAAK,IAAI,GACvC,MAAM,IAAI,CAAC;IAEd,yDAAyD;IACzD,OAAO,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAEzC,6CAA6C;IAC7C,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAEhF,mCAAmC;IACnC,YAAY,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAE/D,iCAAiC;IACjC,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC,CAAC;IAE9D,qDAAqD;IACrD,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAE1C,+DAA+D;IAC/D,SAAS,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1C"}