core/tools: serialize domain objects on PersistentDataView commit

This commit is contained in:
2026-07-24 15:31:06 +08:00
parent 96518974be
commit e82a226f91
2 changed files with 89 additions and 43 deletions
+55 -26
View File
@@ -94,23 +94,6 @@ class PersistentDataView[T](AbstractAsyncContextManager["PersistentDataView[T]"]
return None return None
return current return current
def _ensure_parent(self, root: object) -> object:
"""Ensure dict parents along path exist; return parent container for last key."""
if not self._path:
return root
current: object = root
for key in self._path[:-1]:
if not isinstance(current, dict):
msg = f"cannot navigate non-dict parent at {key!r}"
raise TypeError(msg)
mapping = cast("dict[object, object]", current)
child: object | None = mapping.get(key)
if child is None:
child = cast("object", {})
mapping[key] = child
current = child
return current
def __getitem__(self, key: str | int) -> PersistentDataView[Any]: def __getitem__(self, key: str | int) -> PersistentDataView[Any]:
child_path = (*self._path, key) child_path = (*self._path, key)
cached = self._child_cache.get(child_path) cached = self._child_cache.get(child_path)
@@ -142,11 +125,16 @@ class PersistentDataView[T](AbstractAsyncContextManager["PersistentDataView[T]"]
def store(self, value: T) -> None: def store(self, value: T) -> None:
txn = self._require_txn() txn = self._require_txn()
self._domain[self._path] = value self._domain[self._path] = value
if not self._path: self._write_at_path(txn, self._path, value)
txn.dirty = True
def _write_at_path(self, txn: _TxnState, path: tuple[str | int, ...], value: object) -> None:
"""Write *value* into the txn buffer at *path* (may be a live domain object)."""
if not path:
txn.root = value txn.root = value
else: return
parent = self._ensure_parent(txn.root) parent = self._ensure_parent_for(txn.root, path)
last = self._path[-1] last = path[-1]
if isinstance(parent, dict): if isinstance(parent, dict):
cast("dict[object, object]", parent)[last] = value cast("dict[object, object]", parent)[last] = value
elif isinstance(parent, list) and isinstance(last, int): elif isinstance(parent, list) and isinstance(last, int):
@@ -155,11 +143,52 @@ class PersistentDataView[T](AbstractAsyncContextManager["PersistentDataView[T]"]
lst.append(None) lst.append(None)
lst[last] = value lst[last] = value
else: else:
msg = f"cannot store at path {self._path!r}" msg = f"cannot store at path {path!r}"
raise TypeError(msg) raise TypeError(msg)
txn.dirty = True
def _ensure_parent_for(self, root: object, path: tuple[str | int, ...]) -> object:
"""Ensure dict parents along *path* exist; return parent container for last key."""
if not path:
return root
current: object = root
for key in path[:-1]:
if not isinstance(current, dict):
msg = f"cannot navigate non-dict parent at {key!r}"
raise TypeError(msg)
mapping = cast("dict[object, object]", current)
child: object | None = mapping.get(key)
if child is None:
child = cast("object", {})
mapping[key] = child
current = child
return current
@staticmethod
def _serialize_value(value: object) -> object:
"""Prefer domain ``to_raw()`` so stores receive JSON-ish trees, not live objects."""
to_raw = getattr(value, "to_raw", None)
if callable(to_raw):
return to_raw()
return value
def _flush_domain(self, txn: _TxnState) -> None:
"""Rewrite domain objects into the buffer in serializable form before store."""
for path, value in self._domain.items():
self._write_at_path(txn, path, self._serialize_value(value))
def _materialize_domain(self, typ: type[Any], current: object) -> object: def _materialize_domain(self, typ: type[Any], current: object) -> object:
if current is not None and isinstance(current, typ):
self._domain[self._path] = current
return current
# Hybrid domain objects (e.g. TodoStack): reconstruct from durable raw.
from_raw = getattr(typ, "from_raw", None)
if current is not None and callable(from_raw):
converted: object = from_raw(current)
self._domain[self._path] = converted
self.store(cast("T", converted))
return converted
ctor = cast("Any", typ) ctor = cast("Any", typ)
if current is None: if current is None:
try: try:
@@ -170,15 +199,13 @@ class PersistentDataView[T](AbstractAsyncContextManager["PersistentDataView[T]"]
if created is not None: if created is not None:
self.store(cast("T", created)) self.store(cast("T", created))
return created return created
if not isinstance(current, typ):
try: try:
converted: object = ctor(current) converted = ctor(current)
except TypeError: except TypeError:
converted = current converted = current
self._domain[self._path] = converted self._domain[self._path] = converted
self.store(cast("T", converted)) self.store(cast("T", converted))
return converted return converted
return current
@overload @overload
def typed(self, typ: None = None) -> T: ... def typed(self, typ: None = None) -> T: ...
@@ -211,6 +238,7 @@ class PersistentDataView[T](AbstractAsyncContextManager["PersistentDataView[T]"]
"""Flush the root buffer to the store without closing the txn.""" """Flush the root buffer to the store without closing the txn."""
txn = self._require_txn() txn = self._require_txn()
if txn.dirty: if txn.dirty:
self._flush_domain(txn)
await self._store.store(txn.root) await self._store.store(txn.root)
txn.dirty = False txn.dirty = False
@@ -249,6 +277,7 @@ class PersistentDataView[T](AbstractAsyncContextManager["PersistentDataView[T]"]
# Root exit # Root exit
try: try:
if exc_type is None and txn.dirty: if exc_type is None and txn.dirty:
self._flush_domain(txn)
await self._store.store(txn.root) await self._store.store(txn.root)
finally: finally:
self._txn = None self._txn = None
+19 -2
View File
@@ -29,7 +29,7 @@ async def test_view_rollback_on_error() -> None:
assert await store.load() == {"keep": 1} assert await store.load() == {"keep": 1}
async def test_typed_todo_stack() -> None: async def test_typed_todo_stack_commits_raw() -> None:
store = MemoryViewStore({}) store = MemoryViewStore({})
root: PersistentDataView[dict[str, object]] = PersistentDataView(store, bound_type=dict) root: PersistentDataView[dict[str, object]] = PersistentDataView(store, bound_type=dict)
async with root as data: async with root as data:
@@ -38,7 +38,24 @@ async def test_typed_todo_stack() -> None:
assert todo.depth == 1 assert todo.depth == 1
loaded = await store.load() loaded = await store.load()
assert isinstance(loaded, dict) assert isinstance(loaded, dict)
assert isinstance(loaded.get("todo"), TodoStack) raw_todo = loaded.get("todo")
assert isinstance(raw_todo, dict)
assert "groups" in raw_todo
restored = TodoStack.from_raw(raw_todo)
assert restored.depth == 1
assert [i.title for i in restored.groups[0].items] == ["A", "B"]
async def test_typed_todo_stack_roundtrip_from_raw() -> None:
store = MemoryViewStore({"todo": {"groups": [], "next_id": 1}})
root: PersistentDataView[dict[str, object]] = PersistentDataView(store, bound_type=dict)
async with root as data:
todo = data["todo"].typed(TodoStack)
_ = todo.push_group(["only"])
loaded = await store.load()
assert isinstance(loaded, dict)
again = TodoStack.from_raw(loaded["todo"])
assert [i.title for i in again.all_items()] == ["only"]
async def test_mutate_outside_txn_errors() -> None: async def test_mutate_outside_txn_errors() -> None: