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.
Files changed (39) hide show
  1. vuer_rtc-0.0.2/PKG-INFO +292 -0
  2. vuer_rtc-0.0.2/README.md +279 -0
  3. vuer_rtc-0.0.2/pyproject.toml +27 -0
  4. vuer_rtc-0.0.2/setup.cfg +4 -0
  5. vuer_rtc-0.0.2/tests/test_dtype.py +247 -0
  6. vuer_rtc-0.0.2/tests/test_edit_buffer.py +279 -0
  7. vuer_rtc-0.0.2/tests/test_graph_store.py +907 -0
  8. vuer_rtc-0.0.2/tests/test_operation_ordering.py +340 -0
  9. vuer_rtc-0.0.2/tests/test_operations.py +937 -0
  10. vuer_rtc-0.0.2/tests/test_serdes.py +121 -0
  11. vuer_rtc-0.0.2/tests/test_text_operations.py +434 -0
  12. vuer_rtc-0.0.2/tests/test_text_snapshot.py +68 -0
  13. vuer_rtc-0.0.2/tests/test_undo_redo.py +968 -0
  14. vuer_rtc-0.0.2/tests/test_unified_schema.py +288 -0
  15. vuer_rtc-0.0.2/tests/test_vector_clock.py +161 -0
  16. vuer_rtc-0.0.2/vuer_rtc/__init__.py +46 -0
  17. vuer_rtc-0.0.2/vuer_rtc/client/__init__.py +45 -0
  18. vuer_rtc-0.0.2/vuer_rtc/client/graph_store.py +756 -0
  19. vuer_rtc-0.0.2/vuer_rtc/crdt/__init__.py +5 -0
  20. vuer_rtc-0.0.2/vuer_rtc/crdt/btree.py +155 -0
  21. vuer_rtc-0.0.2/vuer_rtc/crdt/graph_text_crdt.py +162 -0
  22. vuer_rtc-0.0.2/vuer_rtc/crdt/range_tree.py +277 -0
  23. vuer_rtc-0.0.2/vuer_rtc/crdt/rope.py +1215 -0
  24. vuer_rtc-0.0.2/vuer_rtc/datastructures/__init__.py +5 -0
  25. vuer_rtc-0.0.2/vuer_rtc/datastructures/btree.py +545 -0
  26. vuer_rtc-0.0.2/vuer_rtc/datastructures/string_rope.py +81 -0
  27. vuer_rtc-0.0.2/vuer_rtc/operations/__init__.py +31 -0
  28. vuer_rtc-0.0.2/vuer_rtc/operations/dispatcher.py +898 -0
  29. vuer_rtc-0.0.2/vuer_rtc/operations/types.py +198 -0
  30. vuer_rtc-0.0.2/vuer_rtc/serdes.py +59 -0
  31. vuer_rtc-0.0.2/vuer_rtc/state/__init__.py +17 -0
  32. vuer_rtc-0.0.2/vuer_rtc/state/conflict_resolver.py +173 -0
  33. vuer_rtc-0.0.2/vuer_rtc/state/dtype.py +287 -0
  34. vuer_rtc-0.0.2/vuer_rtc/state/vector_clock.py +86 -0
  35. vuer_rtc-0.0.2/vuer_rtc.egg-info/PKG-INFO +292 -0
  36. vuer_rtc-0.0.2/vuer_rtc.egg-info/SOURCES.txt +37 -0
  37. vuer_rtc-0.0.2/vuer_rtc.egg-info/dependency_links.txt +1 -0
  38. vuer_rtc-0.0.2/vuer_rtc.egg-info/requires.txt +5 -0
  39. vuer_rtc-0.0.2/vuer_rtc.egg-info/top_level.txt +1 -0
@@ -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.
@@ -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
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+