vuer-rtc 0.0.2__tar.gz
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.
- vuer_rtc-0.0.2/PKG-INFO +292 -0
- vuer_rtc-0.0.2/README.md +279 -0
- vuer_rtc-0.0.2/pyproject.toml +27 -0
- vuer_rtc-0.0.2/setup.cfg +4 -0
- vuer_rtc-0.0.2/tests/test_dtype.py +247 -0
- vuer_rtc-0.0.2/tests/test_edit_buffer.py +279 -0
- vuer_rtc-0.0.2/tests/test_graph_store.py +907 -0
- vuer_rtc-0.0.2/tests/test_operation_ordering.py +340 -0
- vuer_rtc-0.0.2/tests/test_operations.py +937 -0
- vuer_rtc-0.0.2/tests/test_serdes.py +121 -0
- vuer_rtc-0.0.2/tests/test_text_operations.py +434 -0
- vuer_rtc-0.0.2/tests/test_text_snapshot.py +68 -0
- vuer_rtc-0.0.2/tests/test_undo_redo.py +968 -0
- vuer_rtc-0.0.2/tests/test_unified_schema.py +288 -0
- vuer_rtc-0.0.2/tests/test_vector_clock.py +161 -0
- vuer_rtc-0.0.2/vuer_rtc/__init__.py +46 -0
- vuer_rtc-0.0.2/vuer_rtc/client/__init__.py +45 -0
- vuer_rtc-0.0.2/vuer_rtc/client/graph_store.py +756 -0
- vuer_rtc-0.0.2/vuer_rtc/crdt/__init__.py +5 -0
- vuer_rtc-0.0.2/vuer_rtc/crdt/btree.py +155 -0
- vuer_rtc-0.0.2/vuer_rtc/crdt/graph_text_crdt.py +162 -0
- vuer_rtc-0.0.2/vuer_rtc/crdt/range_tree.py +277 -0
- vuer_rtc-0.0.2/vuer_rtc/crdt/rope.py +1215 -0
- vuer_rtc-0.0.2/vuer_rtc/datastructures/__init__.py +5 -0
- vuer_rtc-0.0.2/vuer_rtc/datastructures/btree.py +545 -0
- vuer_rtc-0.0.2/vuer_rtc/datastructures/string_rope.py +81 -0
- vuer_rtc-0.0.2/vuer_rtc/operations/__init__.py +31 -0
- vuer_rtc-0.0.2/vuer_rtc/operations/dispatcher.py +898 -0
- vuer_rtc-0.0.2/vuer_rtc/operations/types.py +198 -0
- vuer_rtc-0.0.2/vuer_rtc/serdes.py +59 -0
- vuer_rtc-0.0.2/vuer_rtc/state/__init__.py +17 -0
- vuer_rtc-0.0.2/vuer_rtc/state/conflict_resolver.py +173 -0
- vuer_rtc-0.0.2/vuer_rtc/state/dtype.py +287 -0
- vuer_rtc-0.0.2/vuer_rtc/state/vector_clock.py +86 -0
- vuer_rtc-0.0.2/vuer_rtc.egg-info/PKG-INFO +292 -0
- vuer_rtc-0.0.2/vuer_rtc.egg-info/SOURCES.txt +37 -0
- vuer_rtc-0.0.2/vuer_rtc.egg-info/dependency_links.txt +1 -0
- vuer_rtc-0.0.2/vuer_rtc.egg-info/requires.txt +5 -0
- vuer_rtc-0.0.2/vuer_rtc.egg-info/top_level.txt +1 -0
vuer_rtc-0.0.2/PKG-INFO
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vuer-rtc
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: CRDT-based real-time collaborative data structures for Python
|
|
5
|
+
Author-email: Ge Yang <ge.ike.yang@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: msgpack>=1.0
|
|
10
|
+
Provides-Extra: test
|
|
11
|
+
Requires-Dist: pytest>=7.0; extra == "test"
|
|
12
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "test"
|
|
13
|
+
|
|
14
|
+
# vuer-rtc
|
|
15
|
+
|
|
16
|
+
CRDT-based real-time collaborative data structures for Python.
|
|
17
|
+
|
|
18
|
+
A Python port of the TypeScript [`@vuer-ai/vuer-rtc`](../vuer-rtc) library. Multiple clients can concurrently edit a shared scene graph and all changes converge automatically — no manual conflict resolution required.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install vuer-rtc
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Or install from source with test dependencies:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install -e ".[test]"
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Quick start
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from vuer_rtc import create_graph
|
|
36
|
+
|
|
37
|
+
# Create a client store
|
|
38
|
+
store = create_graph("client-1", on_send=lambda msg: send_to_server(msg))
|
|
39
|
+
|
|
40
|
+
# Build a scene
|
|
41
|
+
store.edit({
|
|
42
|
+
"ot": "node.insert",
|
|
43
|
+
"key": "", # parent key ("" = root)
|
|
44
|
+
"path": "children",
|
|
45
|
+
"value": {"key": "scene", "tag": "Scene", "name": "My Scene"},
|
|
46
|
+
})
|
|
47
|
+
store.edit({
|
|
48
|
+
"ot": "node.insert",
|
|
49
|
+
"key": "scene",
|
|
50
|
+
"path": "children",
|
|
51
|
+
"value": {"key": "cube", "tag": "Mesh", "position": [0, 1, 0], "opacity": 1.0},
|
|
52
|
+
})
|
|
53
|
+
store.commit("create scene")
|
|
54
|
+
|
|
55
|
+
# Edit properties
|
|
56
|
+
store.edit({"ot": "vector3.set", "key": "cube", "path": "position", "value": [3, 2, 1]})
|
|
57
|
+
store.edit({"ot": "number.set", "key": "cube", "path": "opacity", "value": 0.5})
|
|
58
|
+
store.commit("move and fade cube")
|
|
59
|
+
|
|
60
|
+
# Read state
|
|
61
|
+
graph = store.get_state().graph
|
|
62
|
+
print(graph.nodes["cube"].get_property("position")) # [3, 2, 1]
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Two-client example
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from vuer_rtc import create_graph
|
|
69
|
+
|
|
70
|
+
messages_a, messages_b = [], []
|
|
71
|
+
|
|
72
|
+
store_a = create_graph("alice", on_send=lambda msg: messages_a.append(msg))
|
|
73
|
+
store_b = create_graph("bob", on_send=lambda msg: messages_b.append(msg))
|
|
74
|
+
|
|
75
|
+
# Alice creates a node
|
|
76
|
+
store_a.edit({
|
|
77
|
+
"ot": "node.insert",
|
|
78
|
+
"key": "",
|
|
79
|
+
"path": "children",
|
|
80
|
+
"value": {"key": "obj", "tag": "Mesh", "health": 100},
|
|
81
|
+
})
|
|
82
|
+
msg = store_a.commit("create obj")
|
|
83
|
+
|
|
84
|
+
# Bob receives Alice's message
|
|
85
|
+
store_b.receive(msg)
|
|
86
|
+
|
|
87
|
+
# Both edit concurrently
|
|
88
|
+
store_a.edit({"ot": "number.add", "key": "obj", "path": "health", "value": -25})
|
|
89
|
+
msg_a = store_a.commit("damage")
|
|
90
|
+
|
|
91
|
+
store_b.edit({"ot": "number.add", "key": "obj", "path": "health", "value": -10})
|
|
92
|
+
msg_b = store_b.commit("poison")
|
|
93
|
+
|
|
94
|
+
# Exchange messages
|
|
95
|
+
store_a.receive(msg_b)
|
|
96
|
+
store_b.receive(msg_a)
|
|
97
|
+
|
|
98
|
+
# Both converge: 100 + (-25) + (-10) = 65
|
|
99
|
+
assert store_a.get_state().graph.nodes["obj"].get_property("health") == 65
|
|
100
|
+
assert store_b.get_state().graph.nodes["obj"].get_property("health") == 65
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Operations
|
|
104
|
+
|
|
105
|
+
Every operation is a dict with `ot`, `key`, `path`, and typically `value`.
|
|
106
|
+
|
|
107
|
+
### Number
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
{"ot": "number.set", "key": "n", "path": "score", "value": 42} # LWW
|
|
111
|
+
{"ot": "number.add", "key": "n", "path": "counter", "value": 1} # additive
|
|
112
|
+
{"ot": "number.multiply", "key": "n", "path": "scale", "value": 2} # multiplicative
|
|
113
|
+
{"ot": "number.min", "key": "n", "path": "cooldown", "value": 5} # min
|
|
114
|
+
{"ot": "number.max", "key": "n", "path": "health", "value": 0} # max
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Vector3 / Quaternion / Euler
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
{"ot": "vector3.set", "key": "n", "path": "position", "value": [1, 2, 3]}
|
|
121
|
+
{"ot": "vector3.add", "key": "n", "path": "velocity", "value": [0, 1, 0]}
|
|
122
|
+
|
|
123
|
+
{"ot": "quaternion.set", "key": "n", "path": "rotation", "value": [0, 0, 0, 1]}
|
|
124
|
+
{"ot": "quaternion.multiply", "key": "n", "path": "rotation", "value": [0, 0.707, 0, 0.707]}
|
|
125
|
+
|
|
126
|
+
{"ot": "euler.set", "key": "n", "path": "rotation", "value": [0, 1.57, 0]}
|
|
127
|
+
{"ot": "euler.add", "key": "n", "path": "rotation", "value": [0, 0.1, 0]}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### String / Boolean / Color
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
{"ot": "string.set", "key": "n", "path": "name", "value": "Cube"}
|
|
134
|
+
{"ot": "string.concat","key": "n", "path": "log", "value": " line2", "separator": "\n"}
|
|
135
|
+
|
|
136
|
+
{"ot": "boolean.set", "key": "n", "path": "visible", "value": True}
|
|
137
|
+
{"ot": "boolean.or", "key": "n", "path": "dirty", "value": True} # True wins
|
|
138
|
+
{"ot": "boolean.and", "key": "n", "path": "locked", "value": False} # False wins
|
|
139
|
+
|
|
140
|
+
{"ot": "color.set", "key": "n", "path": "color", "value": "#ff0000"}
|
|
141
|
+
{"ot": "color.blend", "key": "n", "path": "color", "value": "#0000ff"} # averages
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### Array / Object
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
{"ot": "array.set", "key": "n", "path": "items", "value": [1, 2, 3]}
|
|
148
|
+
{"ot": "array.push", "key": "n", "path": "items", "value": 4}
|
|
149
|
+
{"ot": "array.remove", "key": "n", "path": "items", "value": 2}
|
|
150
|
+
{"ot": "array.union", "key": "n", "path": "tags", "value": ["a", "b"]}
|
|
151
|
+
|
|
152
|
+
{"ot": "object.set", "key": "n", "path": "config", "value": {"debug": True}}
|
|
153
|
+
{"ot": "object.merge", "key": "n", "path": "config", "value": {"verbose": True}} # deep merge
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### Node (scene graph structure)
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
# Insert a child node
|
|
160
|
+
{"ot": "node.insert", "key": "parent", "path": "children",
|
|
161
|
+
"value": {"key": "child", "tag": "Mesh", "name": "Child"}}
|
|
162
|
+
|
|
163
|
+
# Remove (soft-delete / tombstone)
|
|
164
|
+
{"ot": "node.remove", "key": "parent", "path": "children", "value": "child"}
|
|
165
|
+
|
|
166
|
+
# Move a node to a new parent
|
|
167
|
+
{"ot": "node.move", "key": "old-parent", "path": "children",
|
|
168
|
+
"value": {"nodeKey": "child", "newParent": "new-parent"}}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### Text (collaborative CRDT text)
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
# Initialize a text property
|
|
175
|
+
{"ot": "text.init", "key": "doc", "path": "content"}
|
|
176
|
+
|
|
177
|
+
# Insert text at a position. `value` is an [anchor, content] tuple; use None
|
|
178
|
+
# for the anchor on a position-based local insert (the CRDT computes it).
|
|
179
|
+
{"ot": "text.insert", "key": "doc", "path": "content", "position": 0, "value": [None, "Hello"]}
|
|
180
|
+
|
|
181
|
+
# Delete a range
|
|
182
|
+
{"ot": "text.delete", "key": "doc", "path": "content", "position": 0, "length": 5}
|
|
183
|
+
|
|
184
|
+
# Atomic delete + insert (for select-and-type)
|
|
185
|
+
{"ot": "text.replace", "key": "doc", "path": "content", "position": 0, "length": 5, "value": [None, "Hi"]}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
**Important:** Use `text.replace` instead of separate `text.delete` + `text.insert` when replacing a selection. The edit buffer deduplicates by `key:path`, so a delete followed by an insert on the same key and path will lose the delete.
|
|
189
|
+
|
|
190
|
+
## Undo / redo
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
store.edit({"ot": "number.set", "key": "n", "path": "x", "value": 10})
|
|
194
|
+
store.commit("set x")
|
|
195
|
+
|
|
196
|
+
store.undo() # x reverts to previous value
|
|
197
|
+
store.redo() # x = 10 again
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Undo/redo is journal-based. Each `undo()` marks a journal entry as deleted and replays the remaining entries. This means undo works correctly across concurrent edits from multiple clients.
|
|
201
|
+
|
|
202
|
+
## Edit buffer
|
|
203
|
+
|
|
204
|
+
Edits are buffered until `commit()`. Additive operations on the same `key:path` are merged automatically:
|
|
205
|
+
|
|
206
|
+
```python
|
|
207
|
+
store.edit({"ot": "vector3.add", "key": "n", "path": "pos", "value": [1, 0, 0]})
|
|
208
|
+
store.edit({"ot": "vector3.add", "key": "n", "path": "pos", "value": [0, 1, 0]})
|
|
209
|
+
store.edit({"ot": "vector3.add", "key": "n", "path": "pos", "value": [0, 0, 1]})
|
|
210
|
+
|
|
211
|
+
# Only one op in the buffer: value = [1, 1, 1]
|
|
212
|
+
assert len(store.get_state().edits.ops) == 1
|
|
213
|
+
|
|
214
|
+
store.commit("combined move")
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Use `store.cancel()` to discard uncommitted edits and revert to the pre-edit graph.
|
|
218
|
+
|
|
219
|
+
## Receiving remote messages
|
|
220
|
+
|
|
221
|
+
```python
|
|
222
|
+
store.receive(msg) # apply a CRDTMessage from the server
|
|
223
|
+
store.ack(msg_id) # mark one of our messages as server-acknowledged
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Duplicate messages are automatically ignored (idempotent).
|
|
227
|
+
|
|
228
|
+
## Retry & compaction
|
|
229
|
+
|
|
230
|
+
```python
|
|
231
|
+
from vuer_rtc import get_unacked_messages
|
|
232
|
+
|
|
233
|
+
# Retry unacknowledged messages (e.g. after reconnect)
|
|
234
|
+
for msg in get_unacked_messages(store.get_state()):
|
|
235
|
+
send_to_server(msg)
|
|
236
|
+
|
|
237
|
+
# Compact acknowledged journal entries into a snapshot
|
|
238
|
+
store.compact()
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
## Conflict resolution
|
|
242
|
+
|
|
243
|
+
| Merge strategy | Operation types |
|
|
244
|
+
|---|---|
|
|
245
|
+
| **Last-Write-Wins** (LWW) | `*.set` — highest Lamport timestamp wins |
|
|
246
|
+
| **Additive** | `number.add`, `vector3.add`, `quaternion.multiply` — values accumulate |
|
|
247
|
+
| **Commutative** | `boolean.or` / `boolean.and`, `number.min` / `number.max`, `array.union` |
|
|
248
|
+
| **Deep merge** | `object.merge` — recursive per-key merge |
|
|
249
|
+
| **CRDT text** | `text.insert` / `text.delete` — RGA/YATA algorithm, order-independent |
|
|
250
|
+
|
|
251
|
+
All strategies are deterministic: given the same set of operations (in any order), every client converges to the same state.
|
|
252
|
+
|
|
253
|
+
## Architecture
|
|
254
|
+
|
|
255
|
+
```
|
|
256
|
+
GraphStore
|
|
257
|
+
├── ClientState
|
|
258
|
+
│ ├── graph: SceneGraph # current computed scene
|
|
259
|
+
│ ├── journal: [JournalEntry] # committed ops (with ack status)
|
|
260
|
+
│ ├── edits: EditBuffer # uncommitted ops
|
|
261
|
+
│ ├── snapshot: Snapshot # compacted checkpoint
|
|
262
|
+
│ └── vector_clock / lamport_time
|
|
263
|
+
│
|
|
264
|
+
├── edit(op) → optimistic apply + buffer
|
|
265
|
+
├── commit() → journal entry + CRDTMessage out
|
|
266
|
+
├── receive(msg) → journal entry + rebuild graph
|
|
267
|
+
├── undo() / redo() → meta ops + rebuild
|
|
268
|
+
└── compact() → snapshot from acked entries
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
State transitions are pure functions (`on_edit`, `commit_edits`, `on_remote_message`, etc.) wrapped by the `GraphStore` class for convenience. You can use either the class or the bare functions depending on your architecture.
|
|
272
|
+
|
|
273
|
+
## Running tests
|
|
274
|
+
|
|
275
|
+
```bash
|
|
276
|
+
pip install -e ".[test]"
|
|
277
|
+
pytest tests/ -x -q
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
To skip slow benchmarks:
|
|
281
|
+
|
|
282
|
+
```bash
|
|
283
|
+
pytest tests/ -x -q -m "not slow"
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
## Lossless text checkpoints (0.0.2)
|
|
287
|
+
|
|
288
|
+
When constructing `Snapshot` from a TypeScript server response, retain its `textRopes` field along with the graph, vector clock, Lamport time, and journal index. `createGraph(initial_snapshot=...)` and `GraphStore.fromServer(...)` automatically hydrate this metadata; `hydrateTextSnapshot(snapshot)` supports custom consumers. Python uses its existing `_textCrdt.<path>` rope plus visible string representation. The metadata preserves TS character IDs and deleted anchors; msgpack `serialize`/`deserialize` forwards it unchanged.
|
|
289
|
+
|
|
290
|
+
Do not reconstruct a live CRDT checkpoint from plain strings or remove `textRopes` in a bridge. Legacy checkpoints remain readable but cannot recover discarded identities. New hydration accepts both Python snake_case and TypeScript camelCase rope fields. The first local insertion after loading a peer's checkpoint switches to the local agent's character IDs.
|
|
291
|
+
|
|
292
|
+
The server's additive `sync-check` / `sync-status` messages can be encoded as ordinary wire dictionaries. Compare the SHA-256 body checksum only at matching committed clocks, after pending edits are acknowledged. Slow checks or different clocks indicate catch-up, not proven divergence. Python does not automatically run the application-level checksum monitor or manage held drafts.
|
vuer_rtc-0.0.2/README.md
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
# vuer-rtc
|
|
2
|
+
|
|
3
|
+
CRDT-based real-time collaborative data structures for Python.
|
|
4
|
+
|
|
5
|
+
A Python port of the TypeScript [`@vuer-ai/vuer-rtc`](../vuer-rtc) library. Multiple clients can concurrently edit a shared scene graph and all changes converge automatically — no manual conflict resolution required.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install vuer-rtc
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or install from source with test dependencies:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install -e ".[test]"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from vuer_rtc import create_graph
|
|
23
|
+
|
|
24
|
+
# Create a client store
|
|
25
|
+
store = create_graph("client-1", on_send=lambda msg: send_to_server(msg))
|
|
26
|
+
|
|
27
|
+
# Build a scene
|
|
28
|
+
store.edit({
|
|
29
|
+
"ot": "node.insert",
|
|
30
|
+
"key": "", # parent key ("" = root)
|
|
31
|
+
"path": "children",
|
|
32
|
+
"value": {"key": "scene", "tag": "Scene", "name": "My Scene"},
|
|
33
|
+
})
|
|
34
|
+
store.edit({
|
|
35
|
+
"ot": "node.insert",
|
|
36
|
+
"key": "scene",
|
|
37
|
+
"path": "children",
|
|
38
|
+
"value": {"key": "cube", "tag": "Mesh", "position": [0, 1, 0], "opacity": 1.0},
|
|
39
|
+
})
|
|
40
|
+
store.commit("create scene")
|
|
41
|
+
|
|
42
|
+
# Edit properties
|
|
43
|
+
store.edit({"ot": "vector3.set", "key": "cube", "path": "position", "value": [3, 2, 1]})
|
|
44
|
+
store.edit({"ot": "number.set", "key": "cube", "path": "opacity", "value": 0.5})
|
|
45
|
+
store.commit("move and fade cube")
|
|
46
|
+
|
|
47
|
+
# Read state
|
|
48
|
+
graph = store.get_state().graph
|
|
49
|
+
print(graph.nodes["cube"].get_property("position")) # [3, 2, 1]
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Two-client example
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from vuer_rtc import create_graph
|
|
56
|
+
|
|
57
|
+
messages_a, messages_b = [], []
|
|
58
|
+
|
|
59
|
+
store_a = create_graph("alice", on_send=lambda msg: messages_a.append(msg))
|
|
60
|
+
store_b = create_graph("bob", on_send=lambda msg: messages_b.append(msg))
|
|
61
|
+
|
|
62
|
+
# Alice creates a node
|
|
63
|
+
store_a.edit({
|
|
64
|
+
"ot": "node.insert",
|
|
65
|
+
"key": "",
|
|
66
|
+
"path": "children",
|
|
67
|
+
"value": {"key": "obj", "tag": "Mesh", "health": 100},
|
|
68
|
+
})
|
|
69
|
+
msg = store_a.commit("create obj")
|
|
70
|
+
|
|
71
|
+
# Bob receives Alice's message
|
|
72
|
+
store_b.receive(msg)
|
|
73
|
+
|
|
74
|
+
# Both edit concurrently
|
|
75
|
+
store_a.edit({"ot": "number.add", "key": "obj", "path": "health", "value": -25})
|
|
76
|
+
msg_a = store_a.commit("damage")
|
|
77
|
+
|
|
78
|
+
store_b.edit({"ot": "number.add", "key": "obj", "path": "health", "value": -10})
|
|
79
|
+
msg_b = store_b.commit("poison")
|
|
80
|
+
|
|
81
|
+
# Exchange messages
|
|
82
|
+
store_a.receive(msg_b)
|
|
83
|
+
store_b.receive(msg_a)
|
|
84
|
+
|
|
85
|
+
# Both converge: 100 + (-25) + (-10) = 65
|
|
86
|
+
assert store_a.get_state().graph.nodes["obj"].get_property("health") == 65
|
|
87
|
+
assert store_b.get_state().graph.nodes["obj"].get_property("health") == 65
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Operations
|
|
91
|
+
|
|
92
|
+
Every operation is a dict with `ot`, `key`, `path`, and typically `value`.
|
|
93
|
+
|
|
94
|
+
### Number
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
{"ot": "number.set", "key": "n", "path": "score", "value": 42} # LWW
|
|
98
|
+
{"ot": "number.add", "key": "n", "path": "counter", "value": 1} # additive
|
|
99
|
+
{"ot": "number.multiply", "key": "n", "path": "scale", "value": 2} # multiplicative
|
|
100
|
+
{"ot": "number.min", "key": "n", "path": "cooldown", "value": 5} # min
|
|
101
|
+
{"ot": "number.max", "key": "n", "path": "health", "value": 0} # max
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Vector3 / Quaternion / Euler
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
{"ot": "vector3.set", "key": "n", "path": "position", "value": [1, 2, 3]}
|
|
108
|
+
{"ot": "vector3.add", "key": "n", "path": "velocity", "value": [0, 1, 0]}
|
|
109
|
+
|
|
110
|
+
{"ot": "quaternion.set", "key": "n", "path": "rotation", "value": [0, 0, 0, 1]}
|
|
111
|
+
{"ot": "quaternion.multiply", "key": "n", "path": "rotation", "value": [0, 0.707, 0, 0.707]}
|
|
112
|
+
|
|
113
|
+
{"ot": "euler.set", "key": "n", "path": "rotation", "value": [0, 1.57, 0]}
|
|
114
|
+
{"ot": "euler.add", "key": "n", "path": "rotation", "value": [0, 0.1, 0]}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### String / Boolean / Color
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
{"ot": "string.set", "key": "n", "path": "name", "value": "Cube"}
|
|
121
|
+
{"ot": "string.concat","key": "n", "path": "log", "value": " line2", "separator": "\n"}
|
|
122
|
+
|
|
123
|
+
{"ot": "boolean.set", "key": "n", "path": "visible", "value": True}
|
|
124
|
+
{"ot": "boolean.or", "key": "n", "path": "dirty", "value": True} # True wins
|
|
125
|
+
{"ot": "boolean.and", "key": "n", "path": "locked", "value": False} # False wins
|
|
126
|
+
|
|
127
|
+
{"ot": "color.set", "key": "n", "path": "color", "value": "#ff0000"}
|
|
128
|
+
{"ot": "color.blend", "key": "n", "path": "color", "value": "#0000ff"} # averages
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Array / Object
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
{"ot": "array.set", "key": "n", "path": "items", "value": [1, 2, 3]}
|
|
135
|
+
{"ot": "array.push", "key": "n", "path": "items", "value": 4}
|
|
136
|
+
{"ot": "array.remove", "key": "n", "path": "items", "value": 2}
|
|
137
|
+
{"ot": "array.union", "key": "n", "path": "tags", "value": ["a", "b"]}
|
|
138
|
+
|
|
139
|
+
{"ot": "object.set", "key": "n", "path": "config", "value": {"debug": True}}
|
|
140
|
+
{"ot": "object.merge", "key": "n", "path": "config", "value": {"verbose": True}} # deep merge
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Node (scene graph structure)
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
# Insert a child node
|
|
147
|
+
{"ot": "node.insert", "key": "parent", "path": "children",
|
|
148
|
+
"value": {"key": "child", "tag": "Mesh", "name": "Child"}}
|
|
149
|
+
|
|
150
|
+
# Remove (soft-delete / tombstone)
|
|
151
|
+
{"ot": "node.remove", "key": "parent", "path": "children", "value": "child"}
|
|
152
|
+
|
|
153
|
+
# Move a node to a new parent
|
|
154
|
+
{"ot": "node.move", "key": "old-parent", "path": "children",
|
|
155
|
+
"value": {"nodeKey": "child", "newParent": "new-parent"}}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### Text (collaborative CRDT text)
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
# Initialize a text property
|
|
162
|
+
{"ot": "text.init", "key": "doc", "path": "content"}
|
|
163
|
+
|
|
164
|
+
# Insert text at a position. `value` is an [anchor, content] tuple; use None
|
|
165
|
+
# for the anchor on a position-based local insert (the CRDT computes it).
|
|
166
|
+
{"ot": "text.insert", "key": "doc", "path": "content", "position": 0, "value": [None, "Hello"]}
|
|
167
|
+
|
|
168
|
+
# Delete a range
|
|
169
|
+
{"ot": "text.delete", "key": "doc", "path": "content", "position": 0, "length": 5}
|
|
170
|
+
|
|
171
|
+
# Atomic delete + insert (for select-and-type)
|
|
172
|
+
{"ot": "text.replace", "key": "doc", "path": "content", "position": 0, "length": 5, "value": [None, "Hi"]}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
**Important:** Use `text.replace` instead of separate `text.delete` + `text.insert` when replacing a selection. The edit buffer deduplicates by `key:path`, so a delete followed by an insert on the same key and path will lose the delete.
|
|
176
|
+
|
|
177
|
+
## Undo / redo
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
store.edit({"ot": "number.set", "key": "n", "path": "x", "value": 10})
|
|
181
|
+
store.commit("set x")
|
|
182
|
+
|
|
183
|
+
store.undo() # x reverts to previous value
|
|
184
|
+
store.redo() # x = 10 again
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Undo/redo is journal-based. Each `undo()` marks a journal entry as deleted and replays the remaining entries. This means undo works correctly across concurrent edits from multiple clients.
|
|
188
|
+
|
|
189
|
+
## Edit buffer
|
|
190
|
+
|
|
191
|
+
Edits are buffered until `commit()`. Additive operations on the same `key:path` are merged automatically:
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
store.edit({"ot": "vector3.add", "key": "n", "path": "pos", "value": [1, 0, 0]})
|
|
195
|
+
store.edit({"ot": "vector3.add", "key": "n", "path": "pos", "value": [0, 1, 0]})
|
|
196
|
+
store.edit({"ot": "vector3.add", "key": "n", "path": "pos", "value": [0, 0, 1]})
|
|
197
|
+
|
|
198
|
+
# Only one op in the buffer: value = [1, 1, 1]
|
|
199
|
+
assert len(store.get_state().edits.ops) == 1
|
|
200
|
+
|
|
201
|
+
store.commit("combined move")
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Use `store.cancel()` to discard uncommitted edits and revert to the pre-edit graph.
|
|
205
|
+
|
|
206
|
+
## Receiving remote messages
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
store.receive(msg) # apply a CRDTMessage from the server
|
|
210
|
+
store.ack(msg_id) # mark one of our messages as server-acknowledged
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Duplicate messages are automatically ignored (idempotent).
|
|
214
|
+
|
|
215
|
+
## Retry & compaction
|
|
216
|
+
|
|
217
|
+
```python
|
|
218
|
+
from vuer_rtc import get_unacked_messages
|
|
219
|
+
|
|
220
|
+
# Retry unacknowledged messages (e.g. after reconnect)
|
|
221
|
+
for msg in get_unacked_messages(store.get_state()):
|
|
222
|
+
send_to_server(msg)
|
|
223
|
+
|
|
224
|
+
# Compact acknowledged journal entries into a snapshot
|
|
225
|
+
store.compact()
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## Conflict resolution
|
|
229
|
+
|
|
230
|
+
| Merge strategy | Operation types |
|
|
231
|
+
|---|---|
|
|
232
|
+
| **Last-Write-Wins** (LWW) | `*.set` — highest Lamport timestamp wins |
|
|
233
|
+
| **Additive** | `number.add`, `vector3.add`, `quaternion.multiply` — values accumulate |
|
|
234
|
+
| **Commutative** | `boolean.or` / `boolean.and`, `number.min` / `number.max`, `array.union` |
|
|
235
|
+
| **Deep merge** | `object.merge` — recursive per-key merge |
|
|
236
|
+
| **CRDT text** | `text.insert` / `text.delete` — RGA/YATA algorithm, order-independent |
|
|
237
|
+
|
|
238
|
+
All strategies are deterministic: given the same set of operations (in any order), every client converges to the same state.
|
|
239
|
+
|
|
240
|
+
## Architecture
|
|
241
|
+
|
|
242
|
+
```
|
|
243
|
+
GraphStore
|
|
244
|
+
├── ClientState
|
|
245
|
+
│ ├── graph: SceneGraph # current computed scene
|
|
246
|
+
│ ├── journal: [JournalEntry] # committed ops (with ack status)
|
|
247
|
+
│ ├── edits: EditBuffer # uncommitted ops
|
|
248
|
+
│ ├── snapshot: Snapshot # compacted checkpoint
|
|
249
|
+
│ └── vector_clock / lamport_time
|
|
250
|
+
│
|
|
251
|
+
├── edit(op) → optimistic apply + buffer
|
|
252
|
+
├── commit() → journal entry + CRDTMessage out
|
|
253
|
+
├── receive(msg) → journal entry + rebuild graph
|
|
254
|
+
├── undo() / redo() → meta ops + rebuild
|
|
255
|
+
└── compact() → snapshot from acked entries
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
State transitions are pure functions (`on_edit`, `commit_edits`, `on_remote_message`, etc.) wrapped by the `GraphStore` class for convenience. You can use either the class or the bare functions depending on your architecture.
|
|
259
|
+
|
|
260
|
+
## Running tests
|
|
261
|
+
|
|
262
|
+
```bash
|
|
263
|
+
pip install -e ".[test]"
|
|
264
|
+
pytest tests/ -x -q
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
To skip slow benchmarks:
|
|
268
|
+
|
|
269
|
+
```bash
|
|
270
|
+
pytest tests/ -x -q -m "not slow"
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
## Lossless text checkpoints (0.0.2)
|
|
274
|
+
|
|
275
|
+
When constructing `Snapshot` from a TypeScript server response, retain its `textRopes` field along with the graph, vector clock, Lamport time, and journal index. `createGraph(initial_snapshot=...)` and `GraphStore.fromServer(...)` automatically hydrate this metadata; `hydrateTextSnapshot(snapshot)` supports custom consumers. Python uses its existing `_textCrdt.<path>` rope plus visible string representation. The metadata preserves TS character IDs and deleted anchors; msgpack `serialize`/`deserialize` forwards it unchanged.
|
|
276
|
+
|
|
277
|
+
Do not reconstruct a live CRDT checkpoint from plain strings or remove `textRopes` in a bridge. Legacy checkpoints remain readable but cannot recover discarded identities. New hydration accepts both Python snake_case and TypeScript camelCase rope fields. The first local insertion after loading a peer's checkpoint switches to the local agent's character IDs.
|
|
278
|
+
|
|
279
|
+
The server's additive `sync-check` / `sync-status` messages can be encoded as ordinary wire dictionaries. Compare the SHA-256 body checksum only at matching committed clocks, after pending edits are acknowledged. Slow checks or different clocks indicate catch-up, not proven divergence. Python does not automatically run the application-level checksum monitor or manage held drafts.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "vuer-rtc"
|
|
7
|
+
version = "0.0.2"
|
|
8
|
+
description = "CRDT-based real-time collaborative data structures for Python"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{name = "Ge Yang", email = "ge.ike.yang@gmail.com"}]
|
|
13
|
+
dependencies = ["msgpack>=1.0"]
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
test = ["pytest>=7.0", "pytest-asyncio>=0.21"]
|
|
17
|
+
|
|
18
|
+
[tool.setuptools.packages.find]
|
|
19
|
+
where = ["."]
|
|
20
|
+
include = ["vuer_rtc*"]
|
|
21
|
+
|
|
22
|
+
[tool.pytest.ini_options]
|
|
23
|
+
testpaths = ["tests"]
|
|
24
|
+
asyncio_mode = "auto"
|
|
25
|
+
markers = [
|
|
26
|
+
"slow: marks tests as slow (>60s), deselect with '-m \"not slow\"'",
|
|
27
|
+
]
|
vuer_rtc-0.0.2/setup.cfg
ADDED