@vuer-ai/vuer-rtc 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/EditBuffer.d.ts +43 -0
- package/dist/client/EditBuffer.d.ts.map +1 -0
- package/dist/client/EditBuffer.js +96 -0
- package/dist/client/EditBuffer.js.map +1 -0
- package/dist/client/actions.d.ts +66 -0
- package/dist/client/actions.d.ts.map +1 -0
- package/dist/client/actions.js +345 -0
- package/dist/client/actions.js.map +1 -0
- package/dist/client/createGraph.d.ts +30 -0
- package/dist/client/createGraph.d.ts.map +1 -0
- package/dist/client/createGraph.js +91 -0
- package/dist/client/createGraph.js.map +1 -0
- package/dist/client/hooks.d.ts +81 -0
- package/dist/client/hooks.d.ts.map +1 -0
- package/dist/client/hooks.js +161 -0
- package/dist/client/hooks.js.map +1 -0
- package/dist/client/index.d.ts +8 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +10 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/types.d.ts +74 -0
- package/dist/client/types.d.ts.map +1 -0
- package/dist/client/types.js +11 -0
- package/dist/client/types.js.map +1 -0
- package/dist/hooks.d.ts +8 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +7 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/operations/OperationTypes.d.ts +239 -0
- package/dist/operations/OperationTypes.d.ts.map +1 -0
- package/dist/operations/OperationTypes.js +10 -0
- package/dist/operations/OperationTypes.js.map +1 -0
- package/dist/operations/OperationValidator.d.ts +32 -0
- package/dist/operations/OperationValidator.d.ts.map +1 -0
- package/dist/operations/OperationValidator.js +208 -0
- package/dist/operations/OperationValidator.js.map +1 -0
- package/dist/operations/apply/array.d.ts +22 -0
- package/dist/operations/apply/array.d.ts.map +1 -0
- package/dist/operations/apply/array.js +64 -0
- package/dist/operations/apply/array.js.map +1 -0
- package/dist/operations/apply/boolean.d.ts +18 -0
- package/dist/operations/apply/boolean.d.ts.map +1 -0
- package/dist/operations/apply/boolean.js +34 -0
- package/dist/operations/apply/boolean.js.map +1 -0
- package/dist/operations/apply/color.d.ts +14 -0
- package/dist/operations/apply/color.d.ts.map +1 -0
- package/dist/operations/apply/color.js +46 -0
- package/dist/operations/apply/color.js.map +1 -0
- package/dist/operations/apply/index.d.ts +18 -0
- package/dist/operations/apply/index.d.ts.map +1 -0
- package/dist/operations/apply/index.js +26 -0
- package/dist/operations/apply/index.js.map +1 -0
- package/dist/operations/apply/node.d.ts +24 -0
- package/dist/operations/apply/node.d.ts.map +1 -0
- package/dist/operations/apply/node.js +77 -0
- package/dist/operations/apply/node.js.map +1 -0
- package/dist/operations/apply/number.d.ts +26 -0
- package/dist/operations/apply/number.d.ts.map +1 -0
- package/dist/operations/apply/number.js +54 -0
- package/dist/operations/apply/number.js.map +1 -0
- package/dist/operations/apply/object.d.ts +14 -0
- package/dist/operations/apply/object.d.ts.map +1 -0
- package/dist/operations/apply/object.js +47 -0
- package/dist/operations/apply/object.js.map +1 -0
- package/dist/operations/apply/quaternion.d.ts +15 -0
- package/dist/operations/apply/quaternion.d.ts.map +1 -0
- package/dist/operations/apply/quaternion.js +33 -0
- package/dist/operations/apply/quaternion.js.map +1 -0
- package/dist/operations/apply/string.d.ts +14 -0
- package/dist/operations/apply/string.d.ts.map +1 -0
- package/dist/operations/apply/string.js +26 -0
- package/dist/operations/apply/string.js.map +1 -0
- package/dist/operations/apply/types.d.ts +34 -0
- package/dist/operations/apply/types.d.ts.map +1 -0
- package/dist/operations/apply/types.js +32 -0
- package/dist/operations/apply/types.js.map +1 -0
- package/dist/operations/apply/vector3.d.ts +18 -0
- package/dist/operations/apply/vector3.d.ts.map +1 -0
- package/dist/operations/apply/vector3.js +44 -0
- package/dist/operations/apply/vector3.js.map +1 -0
- package/dist/operations/dispatcher.d.ts +35 -0
- package/dist/operations/dispatcher.d.ts.map +1 -0
- package/dist/operations/dispatcher.js +107 -0
- package/dist/operations/dispatcher.js.map +1 -0
- package/dist/operations/index.d.ts +10 -0
- package/dist/operations/index.d.ts.map +1 -0
- package/dist/operations/index.js +17 -0
- package/dist/operations/index.js.map +1 -0
- package/dist/state/ConflictResolver.d.ts +36 -0
- package/dist/state/ConflictResolver.d.ts.map +1 -0
- package/dist/state/ConflictResolver.js +167 -0
- package/dist/state/ConflictResolver.js.map +1 -0
- package/dist/state/DType.d.ts +160 -0
- package/dist/state/DType.d.ts.map +1 -0
- package/dist/state/DType.js +282 -0
- package/dist/state/DType.js.map +1 -0
- package/dist/state/Schema.d.ts +32 -0
- package/dist/state/Schema.d.ts.map +1 -0
- package/dist/state/Schema.js +175 -0
- package/dist/state/Schema.js.map +1 -0
- package/dist/state/VectorClock.d.ts +42 -0
- package/dist/state/VectorClock.d.ts.map +1 -0
- package/dist/state/VectorClock.js +84 -0
- package/dist/state/VectorClock.js.map +1 -0
- package/dist/state/index.d.ts +11 -0
- package/dist/state/index.d.ts.map +1 -0
- package/dist/state/index.js +13 -0
- package/dist/state/index.js.map +1 -0
- package/docs/OPERATION_HINTS.md +222 -0
- package/docs/SCENE_GRAPH.md +373 -0
- package/docs/TYPE_BEHAVIORS.md +348 -0
- package/examples/01-basic-usage.ts +139 -0
- package/examples/02-concurrent-edits.ts +208 -0
- package/examples/03-scene-building.ts +258 -0
- package/examples/04-conflict-resolution.ts +339 -0
- package/examples/README.md +86 -0
- package/jest.config.js +19 -0
- package/package.json +57 -0
- package/src/client/EditBuffer.ts +105 -0
- package/src/client/actions.ts +397 -0
- package/src/client/createGraph.ts +132 -0
- package/src/client/hooks.tsx +249 -0
- package/src/client/index.ts +35 -0
- package/src/client/types.ts +94 -0
- package/src/hooks.ts +20 -0
- package/src/index.ts +14 -0
- package/src/operations/OperationTypes.ts +340 -0
- package/src/operations/OperationValidator.ts +260 -0
- package/src/operations/apply/array.ts +84 -0
- package/src/operations/apply/boolean.ts +48 -0
- package/src/operations/apply/color.ts +65 -0
- package/src/operations/apply/index.ts +37 -0
- package/src/operations/apply/node.ts +98 -0
- package/src/operations/apply/number.ts +76 -0
- package/src/operations/apply/object.ts +63 -0
- package/src/operations/apply/quaternion.ts +47 -0
- package/src/operations/apply/string.ts +36 -0
- package/src/operations/apply/types.ts +66 -0
- package/src/operations/apply/vector3.ts +60 -0
- package/src/operations/dispatcher.ts +127 -0
- package/src/operations/index.ts +80 -0
- package/src/state/ConflictResolver.ts +205 -0
- package/src/state/DType.ts +333 -0
- package/src/state/Schema.ts +236 -0
- package/src/state/VectorClock.ts +98 -0
- package/src/state/index.ts +14 -0
- package/tests/client/actions.test.ts +371 -0
- package/tests/client/edit-buffer.test.ts +117 -0
- package/tests/fixtures/array-ops.jsonl +6 -0
- package/tests/fixtures/boolean-ops.jsonl +6 -0
- package/tests/fixtures/color-ops.jsonl +4 -0
- package/tests/fixtures/edit-buffer.jsonl +3 -0
- package/tests/fixtures/node-ops.jsonl +6 -0
- package/tests/fixtures/number-ops.jsonl +7 -0
- package/tests/fixtures/object-ops.jsonl +4 -0
- package/tests/fixtures/operations.jsonl +7 -0
- package/tests/fixtures/string-ops.jsonl +4 -0
- package/tests/fixtures/undo-redo.jsonl +3 -0
- package/tests/fixtures/vector-ops.jsonl +9 -0
- package/tests/operations/collections.test.ts +193 -0
- package/tests/operations/nodes.test.ts +228 -0
- package/tests/operations/primitives.test.ts +222 -0
- package/tests/operations/vectors.test.ts +150 -0
- package/tsconfig.json +21 -0
- package/tsconfig.test.json +9 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Example 03: Scene Building
|
|
3
|
+
*
|
|
4
|
+
* Demonstrates building a complete scene with batched operations,
|
|
5
|
+
* reparenting, and node deletion.
|
|
6
|
+
* Run with: npx tsx examples/03-scene-building.ts
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createEmptyGraph, applyMessage, applyMessages } from '../src/operations/index.js';
|
|
10
|
+
import type { CRDTMessage } from '../src/operations/index.js';
|
|
11
|
+
|
|
12
|
+
console.log('š¬ Example 03: Scene Building\n');
|
|
13
|
+
|
|
14
|
+
// ============================================
|
|
15
|
+
// Step 1: Create initial scene structure
|
|
16
|
+
// ============================================
|
|
17
|
+
|
|
18
|
+
console.log('--- Step 1: Create Scene with Multiple Objects ---\n');
|
|
19
|
+
|
|
20
|
+
const initScene: CRDTMessage = {
|
|
21
|
+
id: 'init',
|
|
22
|
+
sessionId: 'server',
|
|
23
|
+
clock: { server: 1 },
|
|
24
|
+
lamportTime: 1,
|
|
25
|
+
timestamp: Date.now(),
|
|
26
|
+
ops: [
|
|
27
|
+
// Scene root (no parent - it's the root)
|
|
28
|
+
{
|
|
29
|
+
key: 'scene',
|
|
30
|
+
otype: 'node.insert',
|
|
31
|
+
path: 'scene',
|
|
32
|
+
value: {
|
|
33
|
+
id: 'uuid-scene',
|
|
34
|
+
tag: 'Scene',
|
|
35
|
+
name: 'Game Level',
|
|
36
|
+
background: '#1a1a2e',
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
// Player (parent: scene)
|
|
40
|
+
{
|
|
41
|
+
key: 'player',
|
|
42
|
+
otype: 'node.insert',
|
|
43
|
+
path: 'player',
|
|
44
|
+
parent: 'scene',
|
|
45
|
+
value: {
|
|
46
|
+
id: 'uuid-player',
|
|
47
|
+
tag: 'Mesh',
|
|
48
|
+
name: 'Player',
|
|
49
|
+
color: '#00ff00',
|
|
50
|
+
health: 100,
|
|
51
|
+
'transform.position': [0, 1, 0],
|
|
52
|
+
'transform.scale': [1, 2, 1],
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
// Enemy 1 (parent: scene)
|
|
56
|
+
{
|
|
57
|
+
key: 'enemy-1',
|
|
58
|
+
otype: 'node.insert',
|
|
59
|
+
path: 'enemy-1',
|
|
60
|
+
parent: 'scene',
|
|
61
|
+
value: {
|
|
62
|
+
id: 'uuid-enemy-1',
|
|
63
|
+
tag: 'Mesh',
|
|
64
|
+
name: 'Goblin',
|
|
65
|
+
color: '#ff0000',
|
|
66
|
+
health: 50,
|
|
67
|
+
'transform.position': [10, 1, 5],
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
// Enemy 2 (parent: scene)
|
|
71
|
+
{
|
|
72
|
+
key: 'enemy-2',
|
|
73
|
+
otype: 'node.insert',
|
|
74
|
+
path: 'enemy-2',
|
|
75
|
+
parent: 'scene',
|
|
76
|
+
value: {
|
|
77
|
+
id: 'uuid-enemy-2',
|
|
78
|
+
tag: 'Mesh',
|
|
79
|
+
name: 'Orc',
|
|
80
|
+
color: '#880000',
|
|
81
|
+
health: 100,
|
|
82
|
+
'transform.position': [-10, 1, 5],
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
let graph = applyMessage(createEmptyGraph(), initScene);
|
|
89
|
+
|
|
90
|
+
console.log('Created nodes:', Object.keys(graph.nodes));
|
|
91
|
+
console.log('Scene children:', graph.nodes['scene'].children);
|
|
92
|
+
|
|
93
|
+
// ============================================
|
|
94
|
+
// Step 2: Create a group and reparent enemies
|
|
95
|
+
// ============================================
|
|
96
|
+
|
|
97
|
+
console.log('\n--- Step 2: Reparent Enemies into Group ---\n');
|
|
98
|
+
|
|
99
|
+
const reparent: CRDTMessage = {
|
|
100
|
+
id: 'reparent',
|
|
101
|
+
sessionId: 'alice',
|
|
102
|
+
clock: { alice: 1 },
|
|
103
|
+
lamportTime: 2,
|
|
104
|
+
timestamp: Date.now(),
|
|
105
|
+
ops: [
|
|
106
|
+
// Create enemies group (parent: scene)
|
|
107
|
+
{
|
|
108
|
+
key: 'enemies',
|
|
109
|
+
otype: 'node.insert',
|
|
110
|
+
path: 'enemies',
|
|
111
|
+
parent: 'scene',
|
|
112
|
+
value: {
|
|
113
|
+
id: 'uuid-enemies',
|
|
114
|
+
tag: 'Group',
|
|
115
|
+
name: 'Enemies',
|
|
116
|
+
'transform.position': [0, 0, 0],
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
// Remove enemies from scene and add to group
|
|
120
|
+
{
|
|
121
|
+
key: 'scene',
|
|
122
|
+
otype: 'array.remove',
|
|
123
|
+
path: 'children',
|
|
124
|
+
value: 'enemy-1',
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
key: 'scene',
|
|
128
|
+
otype: 'array.remove',
|
|
129
|
+
path: 'children',
|
|
130
|
+
value: 'enemy-2',
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
key: 'enemies',
|
|
134
|
+
otype: 'array.set',
|
|
135
|
+
path: 'children',
|
|
136
|
+
value: ['enemy-1', 'enemy-2'],
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
graph = applyMessage(graph, reparent);
|
|
142
|
+
|
|
143
|
+
console.log('Scene children:', graph.nodes['scene'].children);
|
|
144
|
+
console.log('Enemies group children:', graph.nodes['enemies'].children);
|
|
145
|
+
|
|
146
|
+
// ============================================
|
|
147
|
+
// Step 3: Batch update - damage all enemies
|
|
148
|
+
// ============================================
|
|
149
|
+
|
|
150
|
+
console.log('\n--- Step 3: Batch Update - Damage All Enemies ---\n');
|
|
151
|
+
|
|
152
|
+
const damageAll: CRDTMessage = {
|
|
153
|
+
id: 'damage',
|
|
154
|
+
sessionId: 'player',
|
|
155
|
+
clock: { player: 1 },
|
|
156
|
+
lamportTime: 3,
|
|
157
|
+
timestamp: Date.now(),
|
|
158
|
+
ops: [
|
|
159
|
+
{
|
|
160
|
+
key: 'enemy-1',
|
|
161
|
+
otype: 'number.add',
|
|
162
|
+
path: 'health',
|
|
163
|
+
value: -30, // Take 30 damage
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
key: 'enemy-2',
|
|
167
|
+
otype: 'number.add',
|
|
168
|
+
path: 'health',
|
|
169
|
+
value: -30, // Take 30 damage
|
|
170
|
+
},
|
|
171
|
+
],
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
graph = applyMessage(graph, damageAll);
|
|
175
|
+
|
|
176
|
+
console.log('Enemy 1 health:', graph.nodes['enemy-1'].health);
|
|
177
|
+
console.log('Enemy 2 health:', graph.nodes['enemy-2'].health);
|
|
178
|
+
|
|
179
|
+
// ============================================
|
|
180
|
+
// Step 4: Delete dead enemy
|
|
181
|
+
// ============================================
|
|
182
|
+
|
|
183
|
+
console.log('\n--- Step 4: Kill Enemy 1 ---\n');
|
|
184
|
+
|
|
185
|
+
// More damage to enemy-1
|
|
186
|
+
const killEnemy1: CRDTMessage = {
|
|
187
|
+
id: 'kill',
|
|
188
|
+
sessionId: 'player',
|
|
189
|
+
clock: { player: 2 },
|
|
190
|
+
lamportTime: 4,
|
|
191
|
+
timestamp: Date.now(),
|
|
192
|
+
ops: [
|
|
193
|
+
{
|
|
194
|
+
key: 'enemy-1',
|
|
195
|
+
otype: 'number.add',
|
|
196
|
+
path: 'health',
|
|
197
|
+
value: -20, // Now at 0
|
|
198
|
+
},
|
|
199
|
+
],
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
graph = applyMessage(graph, killEnemy1);
|
|
203
|
+
console.log('Enemy 1 health after damage:', graph.nodes['enemy-1'].health);
|
|
204
|
+
|
|
205
|
+
// Remove dead enemy
|
|
206
|
+
const removeEnemy1: CRDTMessage = {
|
|
207
|
+
id: 'remove-enemy',
|
|
208
|
+
sessionId: 'server',
|
|
209
|
+
clock: { server: 2 },
|
|
210
|
+
lamportTime: 5,
|
|
211
|
+
timestamp: Date.now(),
|
|
212
|
+
ops: [
|
|
213
|
+
{
|
|
214
|
+
key: 'enemies',
|
|
215
|
+
otype: 'array.remove',
|
|
216
|
+
path: 'children',
|
|
217
|
+
value: 'enemy-1',
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
key: 'enemy-1',
|
|
221
|
+
otype: 'node.remove',
|
|
222
|
+
path: 'enemy-1',
|
|
223
|
+
},
|
|
224
|
+
],
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
graph = applyMessage(graph, removeEnemy1);
|
|
228
|
+
|
|
229
|
+
console.log('Enemy 1 deleted:', graph.nodes['enemy-1'].deletedAt !== undefined);
|
|
230
|
+
console.log('Enemies group children:', graph.nodes['enemies'].children);
|
|
231
|
+
|
|
232
|
+
// ============================================
|
|
233
|
+
// Final Scene Tree
|
|
234
|
+
// ============================================
|
|
235
|
+
|
|
236
|
+
console.log('\nš Final Scene Tree:\n');
|
|
237
|
+
|
|
238
|
+
function printTree(key: string, indent = 0) {
|
|
239
|
+
const node = graph.nodes[key];
|
|
240
|
+
if (!node || node.deletedAt) return;
|
|
241
|
+
|
|
242
|
+
const prefix = ' '.repeat(indent);
|
|
243
|
+
const health = node.health !== undefined ? ` (health: ${node.health})` : '';
|
|
244
|
+
console.log(`${prefix}āā ${node.name} [${node.tag}]${health}`);
|
|
245
|
+
|
|
246
|
+
for (const childKey of node.children || []) {
|
|
247
|
+
printTree(childKey, indent + 1);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
console.log('scene');
|
|
252
|
+
printTree('scene');
|
|
253
|
+
|
|
254
|
+
console.log('\nš All nodes (including tombstones):');
|
|
255
|
+
for (const [key, node] of Object.entries(graph.nodes)) {
|
|
256
|
+
const status = node.deletedAt ? 'ā DELETED' : 'ā
ACTIVE';
|
|
257
|
+
console.log(` ${key}: ${status}`);
|
|
258
|
+
}
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Example 04: Conflict Resolution
|
|
3
|
+
*
|
|
4
|
+
* Demonstrates how CRDT resolves conflicts when messages arrive out-of-order.
|
|
5
|
+
* Run with: npx tsx examples/04-conflict-resolution.ts
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createEmptyGraph, applyMessage } from '../src/operations/index.js';
|
|
9
|
+
import type { CRDTMessage } from '../src/operations/index.js';
|
|
10
|
+
|
|
11
|
+
console.log('š¬ Example 04: Conflict Resolution\n');
|
|
12
|
+
|
|
13
|
+
// ============================================
|
|
14
|
+
// Setup: Create a cube
|
|
15
|
+
// ============================================
|
|
16
|
+
|
|
17
|
+
let graph = applyMessage(createEmptyGraph(), {
|
|
18
|
+
id: 'setup',
|
|
19
|
+
sessionId: 'server',
|
|
20
|
+
clock: { server: 1 },
|
|
21
|
+
lamportTime: 0,
|
|
22
|
+
timestamp: Date.now(),
|
|
23
|
+
ops: [{
|
|
24
|
+
key: 'cube',
|
|
25
|
+
otype: 'node.insert',
|
|
26
|
+
path: 'cube',
|
|
27
|
+
value: {
|
|
28
|
+
id: 'uuid-cube',
|
|
29
|
+
tag: 'Mesh',
|
|
30
|
+
name: 'Cube',
|
|
31
|
+
color: '#ffffff',
|
|
32
|
+
'transform.position': [0, 0, 0],
|
|
33
|
+
},
|
|
34
|
+
}],
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
console.log('Initial state:');
|
|
38
|
+
console.log(' color:', graph.nodes['cube'].color);
|
|
39
|
+
console.log(' position:', graph.nodes['cube']['transform.position']);
|
|
40
|
+
|
|
41
|
+
// ============================================
|
|
42
|
+
// Scenario 1: LWW - Out-of-order arrival
|
|
43
|
+
// ============================================
|
|
44
|
+
|
|
45
|
+
console.log('\n--- Scenario 1: LWW with Out-of-Order Arrival ---\n');
|
|
46
|
+
console.log('Bob sets color to blue (lamport: 11)');
|
|
47
|
+
console.log('Alice sets color to red (lamport: 10) - arrives AFTER Bob\n');
|
|
48
|
+
|
|
49
|
+
// Bob's message arrives first (higher lamport)
|
|
50
|
+
graph = applyMessage(graph, {
|
|
51
|
+
id: 'bob-color',
|
|
52
|
+
sessionId: 'bob',
|
|
53
|
+
clock: { bob: 1 },
|
|
54
|
+
lamportTime: 11,
|
|
55
|
+
timestamp: Date.now(),
|
|
56
|
+
ops: [{ key: 'cube', otype: 'color.set', path: 'color', value: '#0000ff' }],
|
|
57
|
+
});
|
|
58
|
+
console.log('After Bob (lamport 11):', graph.nodes['cube'].color);
|
|
59
|
+
|
|
60
|
+
// Alice's message arrives later (lower lamport - should be ignored)
|
|
61
|
+
graph = applyMessage(graph, {
|
|
62
|
+
id: 'alice-color',
|
|
63
|
+
sessionId: 'alice',
|
|
64
|
+
clock: { alice: 1 },
|
|
65
|
+
lamportTime: 10,
|
|
66
|
+
timestamp: Date.now(),
|
|
67
|
+
ops: [{ key: 'cube', otype: 'color.set', path: 'color', value: '#ff0000' }],
|
|
68
|
+
});
|
|
69
|
+
console.log('After Alice (lamport 10):', graph.nodes['cube'].color);
|
|
70
|
+
|
|
71
|
+
console.log('\nā
Result: Bob wins! (lamport 11 > 10)');
|
|
72
|
+
console.log(' Alice\'s late message was correctly ignored.');
|
|
73
|
+
|
|
74
|
+
// ============================================
|
|
75
|
+
// Scenario 2: Additive - Order doesn't matter
|
|
76
|
+
// ============================================
|
|
77
|
+
|
|
78
|
+
console.log('\n--- Scenario 2: Additive Operations ---\n');
|
|
79
|
+
console.log('Messages can arrive in any order, result is the same.\n');
|
|
80
|
+
|
|
81
|
+
// Reset position
|
|
82
|
+
graph = applyMessage(graph, {
|
|
83
|
+
id: 'reset',
|
|
84
|
+
sessionId: 'server',
|
|
85
|
+
clock: { server: 2 },
|
|
86
|
+
lamportTime: 20,
|
|
87
|
+
timestamp: Date.now(),
|
|
88
|
+
ops: [{ key: 'cube', otype: 'vector3.set', path: 'transform.position', value: [0, 0, 0] }],
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
console.log('Position reset to [0, 0, 0]');
|
|
92
|
+
|
|
93
|
+
// Apply in order: Alice, Bob, Charlie
|
|
94
|
+
let graph1 = applyMessage(graph, {
|
|
95
|
+
id: 'alice-move', sessionId: 'alice', clock: { alice: 2 }, lamportTime: 21, timestamp: Date.now(),
|
|
96
|
+
ops: [{ key: 'cube', otype: 'vector3.add', path: 'transform.position', value: [1, 0, 0] }],
|
|
97
|
+
});
|
|
98
|
+
graph1 = applyMessage(graph1, {
|
|
99
|
+
id: 'bob-move', sessionId: 'bob', clock: { bob: 2 }, lamportTime: 22, timestamp: Date.now(),
|
|
100
|
+
ops: [{ key: 'cube', otype: 'vector3.add', path: 'transform.position', value: [0, 2, 0] }],
|
|
101
|
+
});
|
|
102
|
+
graph1 = applyMessage(graph1, {
|
|
103
|
+
id: 'charlie-move', sessionId: 'charlie', clock: { charlie: 1 }, lamportTime: 23, timestamp: Date.now(),
|
|
104
|
+
ops: [{ key: 'cube', otype: 'vector3.add', path: 'transform.position', value: [0, 0, 3] }],
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Apply in reverse order: Charlie, Bob, Alice
|
|
108
|
+
let graph2 = applyMessage(graph, {
|
|
109
|
+
id: 'charlie-move', sessionId: 'charlie', clock: { charlie: 1 }, lamportTime: 23, timestamp: Date.now(),
|
|
110
|
+
ops: [{ key: 'cube', otype: 'vector3.add', path: 'transform.position', value: [0, 0, 3] }],
|
|
111
|
+
});
|
|
112
|
+
graph2 = applyMessage(graph2, {
|
|
113
|
+
id: 'bob-move', sessionId: 'bob', clock: { bob: 2 }, lamportTime: 22, timestamp: Date.now(),
|
|
114
|
+
ops: [{ key: 'cube', otype: 'vector3.add', path: 'transform.position', value: [0, 2, 0] }],
|
|
115
|
+
});
|
|
116
|
+
graph2 = applyMessage(graph2, {
|
|
117
|
+
id: 'alice-move', sessionId: 'alice', clock: { alice: 2 }, lamportTime: 21, timestamp: Date.now(),
|
|
118
|
+
ops: [{ key: 'cube', otype: 'vector3.add', path: 'transform.position', value: [1, 0, 0] }],
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
console.log('Order 1 (Alice ā Bob ā Charlie):', graph1.nodes['cube']['transform.position']);
|
|
122
|
+
console.log('Order 2 (Charlie ā Bob ā Alice):', graph2.nodes['cube']['transform.position']);
|
|
123
|
+
|
|
124
|
+
const pos1 = JSON.stringify(graph1.nodes['cube']['transform.position']);
|
|
125
|
+
const pos2 = JSON.stringify(graph2.nodes['cube']['transform.position']);
|
|
126
|
+
console.log('\nā
Result:', pos1 === pos2 ? 'Same! Order doesn\'t matter for additive ops.' : 'ā Different!');
|
|
127
|
+
|
|
128
|
+
// ============================================
|
|
129
|
+
// Scenario 3: Mixed operations
|
|
130
|
+
// ============================================
|
|
131
|
+
|
|
132
|
+
console.log('\n--- Scenario 3: Mixed Operations in Real-time ---\n');
|
|
133
|
+
console.log('Simulating two users editing simultaneously...\n');
|
|
134
|
+
|
|
135
|
+
// Start fresh
|
|
136
|
+
graph = applyMessage(createEmptyGraph(), {
|
|
137
|
+
id: 'init',
|
|
138
|
+
sessionId: 'server',
|
|
139
|
+
clock: { server: 1 },
|
|
140
|
+
lamportTime: 0,
|
|
141
|
+
timestamp: Date.now(),
|
|
142
|
+
ops: [{
|
|
143
|
+
key: 'box',
|
|
144
|
+
otype: 'node.insert',
|
|
145
|
+
path: 'box',
|
|
146
|
+
value: {
|
|
147
|
+
id: 'uuid-box',
|
|
148
|
+
tag: 'Mesh',
|
|
149
|
+
name: 'Box',
|
|
150
|
+
color: '#888888',
|
|
151
|
+
'transform.position': [0, 0, 0],
|
|
152
|
+
},
|
|
153
|
+
}],
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
console.log('Initial: color=#888888, position=[0,0,0]');
|
|
157
|
+
|
|
158
|
+
// Alice drags and changes color
|
|
159
|
+
const aliceMsg: CRDTMessage = {
|
|
160
|
+
id: 'alice-edit',
|
|
161
|
+
sessionId: 'alice',
|
|
162
|
+
clock: { alice: 1 },
|
|
163
|
+
lamportTime: 5,
|
|
164
|
+
timestamp: Date.now(),
|
|
165
|
+
ops: [
|
|
166
|
+
{ key: 'box', otype: 'vector3.add', path: 'transform.position', value: [10, 0, 0] },
|
|
167
|
+
{ key: 'box', otype: 'color.set', path: 'color', value: '#ff0000' },
|
|
168
|
+
],
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// Bob drags and changes color (higher lamport)
|
|
172
|
+
const bobMsg: CRDTMessage = {
|
|
173
|
+
id: 'bob-edit',
|
|
174
|
+
sessionId: 'bob',
|
|
175
|
+
clock: { bob: 1 },
|
|
176
|
+
lamportTime: 6,
|
|
177
|
+
timestamp: Date.now(),
|
|
178
|
+
ops: [
|
|
179
|
+
{ key: 'box', otype: 'vector3.add', path: 'transform.position', value: [0, 5, 0] },
|
|
180
|
+
{ key: 'box', otype: 'color.set', path: 'color', value: '#00ff00' },
|
|
181
|
+
],
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
// Apply Bob first, then Alice (out of order)
|
|
185
|
+
graph = applyMessage(graph, bobMsg);
|
|
186
|
+
console.log('After Bob: color=%s, position=%s', graph.nodes['box'].color, JSON.stringify(graph.nodes['box']['transform.position']));
|
|
187
|
+
|
|
188
|
+
graph = applyMessage(graph, aliceMsg);
|
|
189
|
+
console.log('After Alice: color=%s, position=%s', graph.nodes['box'].color, JSON.stringify(graph.nodes['box']['transform.position']));
|
|
190
|
+
|
|
191
|
+
console.log('\nā
Final result:');
|
|
192
|
+
console.log(' Position: [10, 5, 0] - Both drags applied (additive)');
|
|
193
|
+
console.log(' Color: #00ff00 - Bob wins (LWW, lamport 6 > 5)');
|
|
194
|
+
|
|
195
|
+
// ============================================
|
|
196
|
+
// Scenario 4: Complex interleaved out-of-order
|
|
197
|
+
// ============================================
|
|
198
|
+
|
|
199
|
+
console.log('\n--- Scenario 4: Complex Interleaved Out-of-Order ---\n');
|
|
200
|
+
console.log('This scenario breaks naive approaches:\n');
|
|
201
|
+
console.log(' ā Simple set: Always overwrites ā last arrival wins (wrong)');
|
|
202
|
+
console.log(' ā 1-step lookahead: Compare with previous op only (wrong)');
|
|
203
|
+
console.log(' ā
Full LWW: Compare with node\'s max lamport (correct)\n');
|
|
204
|
+
|
|
205
|
+
// Timeline of when operations were CREATED:
|
|
206
|
+
// t=1: Alice sets color to red (lamport 10)
|
|
207
|
+
// t=2: Bob sets color to blue (lamport 20)
|
|
208
|
+
// t=3: Alice sets color to green (lamport 30)
|
|
209
|
+
// t=4: Charlie sets color to yellow (lamport 40)
|
|
210
|
+
// t=5: Bob sets color to purple (lamport 25) - Bob was offline, clock behind
|
|
211
|
+
|
|
212
|
+
// But network delivers them in scrambled order:
|
|
213
|
+
const messages = [
|
|
214
|
+
{ name: 'Bob (purple)', lamport: 25, color: '#800080' },
|
|
215
|
+
{ name: 'Alice (green)', lamport: 30, color: '#00ff00' },
|
|
216
|
+
{ name: 'Charlie (yellow)', lamport: 40, color: '#ffff00' },
|
|
217
|
+
{ name: 'Alice (red)', lamport: 10, color: '#ff0000' },
|
|
218
|
+
{ name: 'Bob (blue)', lamport: 20, color: '#0000ff' },
|
|
219
|
+
];
|
|
220
|
+
|
|
221
|
+
console.log('Messages arrive in scrambled order:');
|
|
222
|
+
messages.forEach((m, i) => console.log(` ${i + 1}. ${m.name} (lamport: ${m.lamport})`));
|
|
223
|
+
|
|
224
|
+
// ========================================
|
|
225
|
+
// Architecture: State = { graph, journal }
|
|
226
|
+
// ========================================
|
|
227
|
+
// - graph: Current materialized state
|
|
228
|
+
// - journal: Log of all messages (for replay/debugging)
|
|
229
|
+
//
|
|
230
|
+
// On each message:
|
|
231
|
+
// 1. state.journal.push(msg)
|
|
232
|
+
// 2. state.graph = applyMessage(state.graph, msg)
|
|
233
|
+
|
|
234
|
+
interface State {
|
|
235
|
+
graph: ReturnType<typeof createEmptyGraph>;
|
|
236
|
+
journal: CRDTMessage[];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const state: State = {
|
|
240
|
+
graph: createEmptyGraph(),
|
|
241
|
+
journal: [],
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
// Helper: process a message (append + apply)
|
|
245
|
+
function processMessage(s: State, msg: CRDTMessage): State {
|
|
246
|
+
return {
|
|
247
|
+
journal: [...s.journal, msg],
|
|
248
|
+
graph: applyMessage(s.graph, msg),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Init
|
|
253
|
+
state.graph = applyMessage(state.graph, {
|
|
254
|
+
id: 'init', sessionId: 'server', clock: { server: 1 }, lamportTime: 0, timestamp: Date.now(),
|
|
255
|
+
ops: [{ key: 'obj', otype: 'node.insert', path: 'obj', value: { key: 'uuid', tag: 'Mesh', name: 'Object', color: '#ffffff' }}],
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
console.log('\nš State = { graph, journal }');
|
|
259
|
+
console.log('ā'.repeat(65));
|
|
260
|
+
console.log(' # Message Lamport MaxLamport State Applied?');
|
|
261
|
+
console.log('ā'.repeat(65));
|
|
262
|
+
|
|
263
|
+
for (let i = 0; i < messages.length; i++) {
|
|
264
|
+
const m = messages[i];
|
|
265
|
+
const prevColor = state.graph.nodes['obj'].color;
|
|
266
|
+
|
|
267
|
+
const msg: CRDTMessage = {
|
|
268
|
+
id: `msg-${m.lamport}`,
|
|
269
|
+
sessionId: m.name,
|
|
270
|
+
clock: { [m.name]: 1 },
|
|
271
|
+
lamportTime: m.lamport,
|
|
272
|
+
timestamp: Date.now(),
|
|
273
|
+
ops: [{ key: 'obj', otype: 'color.set', path: 'color', value: m.color }],
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
// Process: append to journal + apply to graph
|
|
277
|
+
Object.assign(state, processMessage(state, msg));
|
|
278
|
+
|
|
279
|
+
const applied = state.graph.nodes['obj'].color !== prevColor;
|
|
280
|
+
const maxLamport = state.graph.nodes['obj'].lamportTime;
|
|
281
|
+
const appliedStr = applied ? 'ā applied' : 'ā ignored';
|
|
282
|
+
|
|
283
|
+
console.log(` ${i + 1} ${m.name.padEnd(16)} ${String(m.lamport).padStart(3)} ${String(maxLamport).padStart(3)} ${state.graph.nodes['obj'].color} ${appliedStr}`);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
console.log('ā'.repeat(65));
|
|
287
|
+
console.log(`\n state.journal: ${state.journal.length} messages`);
|
|
288
|
+
console.log(` state.graph: color = ${state.graph.nodes['obj'].color} (maxLamport = ${state.graph.nodes['obj'].lamportTime})`);
|
|
289
|
+
|
|
290
|
+
const isCorrect = state.graph.nodes['obj'].color === '#ffff00';
|
|
291
|
+
console.log(`\n${isCorrect ? 'ā
' : 'ā'} Result: ${isCorrect ? 'CORRECT' : 'WRONG'}`);
|
|
292
|
+
|
|
293
|
+
// Verify convergence - apply same messages in different order
|
|
294
|
+
console.log('\n--- Verifying convergence with different arrival orders ---\n');
|
|
295
|
+
|
|
296
|
+
const orders = [
|
|
297
|
+
[0, 1, 2, 3, 4], // Original scrambled
|
|
298
|
+
[4, 3, 2, 1, 0], // Reverse
|
|
299
|
+
[2, 0, 4, 1, 3], // Random shuffle 1
|
|
300
|
+
[3, 1, 4, 0, 2], // Random shuffle 2
|
|
301
|
+
];
|
|
302
|
+
|
|
303
|
+
const results: string[] = [];
|
|
304
|
+
|
|
305
|
+
for (let i = 0; i < orders.length; i++) {
|
|
306
|
+
let g = applyMessage(createEmptyGraph(), {
|
|
307
|
+
id: 'init', sessionId: 'server', clock: { server: 1 }, lamportTime: 0, timestamp: Date.now(),
|
|
308
|
+
ops: [{ key: 'obj', otype: 'node.insert', path: 'obj', value: { key: 'uuid', tag: 'Mesh', name: 'Object', color: '#ffffff' }}],
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
for (const idx of orders[i]) {
|
|
312
|
+
const msg = messages[idx];
|
|
313
|
+
g = applyMessage(g, {
|
|
314
|
+
id: `msg-${msg.lamport}`,
|
|
315
|
+
sessionId: msg.name,
|
|
316
|
+
clock: { [msg.name]: 1 },
|
|
317
|
+
lamportTime: msg.lamport,
|
|
318
|
+
timestamp: Date.now(),
|
|
319
|
+
ops: [{ key: 'obj', otype: 'color.set', path: 'color', value: msg.color }],
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
results.push(g.nodes['obj'].color);
|
|
324
|
+
console.log(`Order ${i + 1}: [${orders[i].join(', ')}] ā ${g.nodes['obj'].color}`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const allSame = results.every(r => r === results[0]);
|
|
328
|
+
console.log(`\n${allSame ? 'ā
' : 'ā'} All orders converge to same result: ${allSame ? 'YES' : 'NO'}`);
|
|
329
|
+
|
|
330
|
+
// ============================================
|
|
331
|
+
// Summary
|
|
332
|
+
// ============================================
|
|
333
|
+
|
|
334
|
+
console.log('\nš Summary:');
|
|
335
|
+
console.log('ā'.repeat(50));
|
|
336
|
+
console.log('LWW (*.set): Higher lamport wins, regardless of arrival order');
|
|
337
|
+
console.log('Additive (*.add): All values accumulate, order doesn\'t matter');
|
|
338
|
+
console.log('ā'.repeat(50));
|
|
339
|
+
console.log('\nClients converge to the same state without coordination!');
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# CRDT Examples
|
|
2
|
+
|
|
3
|
+
This folder contains examples demonstrating how the CRDT system works.
|
|
4
|
+
|
|
5
|
+
## Running Examples
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
cd packages/server
|
|
9
|
+
npx tsx examples/01-basic-usage.ts
|
|
10
|
+
npx tsx examples/02-concurrent-edits.ts
|
|
11
|
+
npx tsx examples/03-scene-building.ts
|
|
12
|
+
npx tsx examples/04-conflict-resolution.ts
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Examples
|
|
16
|
+
|
|
17
|
+
### 01-basic-usage.ts
|
|
18
|
+
Basic usage of the CRDT system:
|
|
19
|
+
- Creating an empty scene graph
|
|
20
|
+
- Inserting nodes
|
|
21
|
+
- Applying operations
|
|
22
|
+
- Moving objects (additive)
|
|
23
|
+
- Changing properties
|
|
24
|
+
|
|
25
|
+
### 02-concurrent-edits.ts
|
|
26
|
+
Demonstrates concurrent edit resolution:
|
|
27
|
+
- **Additive operations** (`vector3.add`, `number.add`): Values are summed
|
|
28
|
+
- **LWW operations** (`color.set`, `vector3.set`): Last write wins (by lamport time)
|
|
29
|
+
|
|
30
|
+
### 03-scene-building.ts
|
|
31
|
+
Building a complete scene:
|
|
32
|
+
- Batch operations (multiple ops in one message)
|
|
33
|
+
- Reparenting nodes
|
|
34
|
+
- Node deletion (tombstones)
|
|
35
|
+
|
|
36
|
+
### 04-conflict-resolution.ts
|
|
37
|
+
Demonstrates conflict resolution:
|
|
38
|
+
- LWW with out-of-order message arrival
|
|
39
|
+
- Additive operations are order-independent
|
|
40
|
+
- Mixed operations in real-time editing
|
|
41
|
+
|
|
42
|
+
## Core Concepts
|
|
43
|
+
|
|
44
|
+
### CRDTMessage Structure
|
|
45
|
+
|
|
46
|
+
```typescript
|
|
47
|
+
interface CRDTMessage {
|
|
48
|
+
id: string; // Message ID
|
|
49
|
+
sessionId: string; // Who sent this
|
|
50
|
+
clock: VectorClock; // For causal ordering
|
|
51
|
+
lamportTime: number; // For total ordering (LWW)
|
|
52
|
+
timestamp: number; // Wall-clock time
|
|
53
|
+
ops: Operation[]; // Batch of operations
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Operation Types
|
|
58
|
+
|
|
59
|
+
| otype | Merge Behavior | Use Case |
|
|
60
|
+
|-------|---------------|----------|
|
|
61
|
+
| `node.insert` | Idempotent | Create node (use `parent` field to auto-add to parent's children) |
|
|
62
|
+
| `node.remove` | Tombstone | Delete node |
|
|
63
|
+
| `number.set` | LWW | Absolute number |
|
|
64
|
+
| `number.add` | Sum | Counter, delta |
|
|
65
|
+
| `vector3.set` | LWW | Absolute position |
|
|
66
|
+
| `vector3.add` | Component sum | Drag movement |
|
|
67
|
+
| `color.set` | LWW | Material color |
|
|
68
|
+
| `array.set` | LWW | Replace array |
|
|
69
|
+
| `array.push` | Append | Add child |
|
|
70
|
+
| `array.remove` | Remove | Remove child |
|
|
71
|
+
|
|
72
|
+
### Additive vs LWW
|
|
73
|
+
|
|
74
|
+
**Additive** (`*.add`): Order doesn't matter, values accumulate
|
|
75
|
+
```
|
|
76
|
+
Alice: position += [5, 0, 0]
|
|
77
|
+
Bob: position += [0, 3, 0]
|
|
78
|
+
Result: position += [5, 3, 0] ā
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
**LWW** (`*.set`): Higher lamportTime wins
|
|
82
|
+
```
|
|
83
|
+
Alice: color = red (lamport: 10)
|
|
84
|
+
Bob: color = blue (lamport: 11)
|
|
85
|
+
Result: color = blue ā
(Bob's lamport is higher)
|
|
86
|
+
```
|
package/jest.config.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** @type {import('jest').Config} */
|
|
2
|
+
export default {
|
|
3
|
+
testEnvironment: 'node',
|
|
4
|
+
extensionsToTreatAsEsm: ['.ts', '.tsx'],
|
|
5
|
+
moduleNameMapper: {
|
|
6
|
+
'^(\\.{1,2}/.*)\\.js$': '$1',
|
|
7
|
+
},
|
|
8
|
+
transform: {
|
|
9
|
+
'^.+\\.tsx?$': [
|
|
10
|
+
'ts-jest',
|
|
11
|
+
{
|
|
12
|
+
useESM: true,
|
|
13
|
+
tsconfig: 'tsconfig.test.json',
|
|
14
|
+
},
|
|
15
|
+
],
|
|
16
|
+
},
|
|
17
|
+
testMatch: ['**/tests/**/*.test.ts'],
|
|
18
|
+
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
|
|
19
|
+
};
|