emit_event now swallows and reports any failure, including crashes in the built-in default_emit formatting itself; log_change routes through it. logfmt chain callbacks that raise are logged and treated as fall-through, and logmigr callbacks get on_error reporting like fatal_error handlers, so a broken logging callback can no longer abort a transaction or open. Demo: raw user ids in v0 logs, logfmt-resolved names in v1 logs.
123 lines
3.4 KiB
Python
123 lines
3.4 KiB
Python
#!/usr/bin/env -S uv run
|
|
import asyncio
|
|
import sys
|
|
from datetime import UTC, datetime, timedelta
|
|
from pathlib import Path
|
|
|
|
import msgspec
|
|
|
|
from kanta import Kanta
|
|
from kanta.callbacks import DictPost, DictPre
|
|
from kanta.logging import configure_logging
|
|
|
|
|
|
filename = Path(__file__).with_name("demo.kantadb")
|
|
|
|
# For demonstration purposes, we use "original v0" and "modified v1" in this same script
|
|
# Normally your app would only have the latest supported data model
|
|
|
|
|
|
class Data(msgspec.Struct): # type: ignore - intentionally redefined later
|
|
users: dict[str, dict] = {}
|
|
counter: int = 0
|
|
|
|
|
|
kanta_v0 = Kanta(filename, Data())
|
|
|
|
|
|
@kanta_v0.bootstrap
|
|
def bootstrap(data: Data) -> None:
|
|
"""Create the initial admin user."""
|
|
data.users["userid001"] = {"name": "Alice", "role": "admin"}
|
|
|
|
|
|
# Redefinition to simulate new version
|
|
class Data(msgspec.Struct):
|
|
users: dict[str, dict] = {}
|
|
total: int = 0 # Replaces old counter field
|
|
lang: str = "en" # New field
|
|
|
|
|
|
def migrate_v1(d: dict) -> None:
|
|
"""Rename counter to total"""
|
|
d["total"] = d["counter"]
|
|
|
|
|
|
kanta_v1 = Kanta(filename, Data(), migrations=sys.modules[__name__])
|
|
|
|
|
|
@kanta_v1.logfmt
|
|
def resolve_user(
|
|
value: str, path: str, previous: DictPre, current: DictPost
|
|
) -> str | None:
|
|
"""Resolve user ids to names from the database state itself."""
|
|
if path != "$user" and not path.startswith("users."):
|
|
return None
|
|
# Post-change state first, then pre-change (deleted users are only there).
|
|
for state in (current, previous):
|
|
name = state.get("users", {}).get(value, {}).get("name")
|
|
if name:
|
|
return name
|
|
return None
|
|
|
|
|
|
async def main() -> None:
|
|
filename.unlink(missing_ok=True)
|
|
|
|
print("# Database creation with v0 schema and basic ops, pretty logs")
|
|
# Open and close automatically; you can also `await kanta.open()` instead
|
|
async with kanta_v0 as kanta:
|
|
with kanta.transaction(action="create", user="userid001") as data:
|
|
data.users["userid002"] = {"name": "Bob", "role": "user"}
|
|
|
|
with kanta.transaction(action="update", user="userid001") as data:
|
|
data.users["userid002"]["role"] = "editor"
|
|
data.counter = 1
|
|
|
|
# Display-only extra string, appended after the action.
|
|
with kanta.transaction(
|
|
action="export", user="userid002", extra="extra info"
|
|
) as data:
|
|
data.counter = 2
|
|
|
|
try:
|
|
with kanta.transaction(action="reset") as data:
|
|
data.counter = 99
|
|
raise ValueError("simulated failure")
|
|
except ValueError:
|
|
print(f"# Reading does not need transaction: {data.counter=}", flush=True)
|
|
|
|
print(
|
|
"\n# A later version of our application with new data model, migrations and logfmt"
|
|
)
|
|
async with kanta_v1 as kanta:
|
|
with kanta.transaction(
|
|
action="import", user="userid001", logdiff=False
|
|
) as data:
|
|
data.total = 3
|
|
|
|
with kanta.transaction(action="delete", user="userid002") as data:
|
|
del data.users["userid001"]
|
|
|
|
with kanta.transaction(
|
|
action="update", user="userid002", extra=filename.name
|
|
) as data:
|
|
data.total = 4
|
|
|
|
|
|
# Fake clock for deterministic timestamps
|
|
_now = datetime(2027, 1, 1, tzinfo=UTC)
|
|
|
|
|
|
@kanta_v0.clock
|
|
@kanta_v1.clock
|
|
def fake_clock() -> datetime:
|
|
global _now
|
|
_now += timedelta(hours=1)
|
|
return _now
|
|
|
|
|
|
if __name__ == "__main__":
|
|
configure_logging()
|
|
asyncio.run(main())
|