diff --git a/demo/.gitignore b/demo/.gitignore index 3f68360..74df5b7 100644 --- a/demo/.gitignore +++ b/demo/.gitignore @@ -1 +1 @@ -demo.db +demo.kantadb diff --git a/demo/main.py b/demo/main.py index 05b0491..ba3efe6 100644 --- a/demo/main.py +++ b/demo/main.py @@ -3,12 +3,14 @@ Run from the project root: python demo/main.py Demonstrates bootstrap, colored transaction diffs, logfmt value formatting, -custom log headers, logging toggles, rollback, and migrations. The database -is recreated on every run; everything else lives in this file. +custom log headers, logging toggles, rollback, migrations, and a custom clock. +The database is recreated with fixed timestamps on every run; everything else +lives in this file. """ import asyncio import logging +from datetime import UTC, datetime, timedelta from pathlib import Path from types import ModuleType @@ -18,12 +20,25 @@ from kanta import Kanta from kanta.logging import _ACTION, _ACTOR, _RESET, _SESSION, _TARGET from kanta.logging import configure_logging -DB = Path(__file__).with_name("demo.db") +DB = Path(__file__).with_name("demo.kantadb") # Fake directory: user id -> display name, resolved by the logfmt callbacks. USERS = {"u1": "Alice", "u2": "Bob", "u3": "Carol"} +class Clock: + """Deterministic clock: manually advanced, so every run is identical.""" + + def __init__(self) -> None: + self.current = datetime(2026, 8, 6, 12, 0, tzinfo=UTC) + + def advance(self, **kwargs) -> None: + self.current += timedelta(**kwargs) + + +clock = Clock() + + class DataV1(msgspec.Struct): """Original schema (version 0).""" @@ -48,6 +63,14 @@ migrations = ModuleType("demo_migrations") migrations.migrate_v1 = migrate_v1 +def add_clock(kanta: Kanta) -> None: + """Use the shared deterministic clock for all record timestamps.""" + + @kanta.clock + def fake_now() -> datetime: + return clock.current + + def add_logfmts(kanta: Kanta) -> None: """Resolve user ids to display names in headers and diff paths.""" @@ -73,12 +96,17 @@ def add_header(kanta: Kanta) -> None: return f"{actor} {session} {_ACTION}{action}{_RESET} {target}" +def section(title: str) -> None: + print(f"\n# {title}", flush=True) + + async def main() -> None: DB.unlink(missing_ok=True) - print("=== Standard logging: bootstrap, diffs, toggles, rollback ===", flush=True) + section("Standard logging: bootstrap, diffs, toggles, rollback") kanta = Kanta(DB, DataV1()) + add_clock(kanta) add_logfmts(kanta) @kanta.bootstrap @@ -87,23 +115,29 @@ async def main() -> None: await kanta.open() + clock.advance(minutes=2) with kanta.transaction(action="create", user="u2") as data: data.users["u2"] = {"name": "Bob", "role": "user"} + clock.advance(minutes=5) with kanta.transaction(action="update", user="u1") as data: data.users["u2"]["role"] = "editor" data.counter = 1 + clock.advance(seconds=30) with kanta.transaction(action="delete", user="u1") as data: del data.users["u2"] # Display-only extra string, appended after the action. + clock.advance(hours=1) with kanta.transaction(action="export", user="u1", extra=DB.name) as data: data.counter = 2 - # Compact logging: diff only (no header) ... + # Compact logging, diff only: a system fix stamped by the clock, but the + # modification time (m) is not updated. + clock.advance(minutes=10) with kanta.transaction( - action="repair", log={"header": False, "diff": True} + action="repair", mtime=False, log={"header": False, "diff": True} ) as data: data.users["u3"] = {"name": "Carol", "role": "user"} @@ -115,7 +149,8 @@ async def main() -> None: except ValueError: pass - # ... and header only (no diff). + # Compact logging, header only. + clock.advance(minutes=5) with kanta.transaction( action="import", user="u1", log={"header": True, "diff": False} ) as data: @@ -123,17 +158,21 @@ async def main() -> None: await kanta.close() - print("=== Reopen with migrations and a custom log header ===", flush=True) + section("Reopen with migrations and a custom log header") + clock.advance(days=1) kanta = Kanta(DB, Data(), migrations=migrations) + add_clock(kanta) add_logfmts(kanta) add_header(kanta) await kanta.open() # No target given: defaults to the database filename. + clock.advance(minutes=3) with kanta.transaction(action="update", user="u1", extra={"session_id": 3}) as data: data.settings["theme"] = "light" + clock.advance(minutes=1) with kanta.transaction( action="update", user="u2", @@ -143,7 +182,9 @@ async def main() -> None: await kanta.close() - print(f"=== Database written to {DB} ===", flush=True) + # The pretty names only exist in the logs; the database stores raw ids. + section("Raw database records (user ids and timestamps, not pretty names)") + print(DB.read_text(), end="", flush=True) if __name__ == "__main__": diff --git a/docs/database.md b/docs/database.md index f7f09eb..475650e 100644 --- a/docs/database.md +++ b/docs/database.md @@ -163,6 +163,15 @@ when they have a default value. - Multiple handlers are supported and invoked in registration order. A failing handler is logged and does not prevent subsequent handlers from running. +#### Clock + +- `@kanta.clock` registers a callback `() -> datetime` that replaces the + default UTC clock. Its value is used for all record timestamps (`ts`, and + `m` when `mtime` is `True`) and for snapshot timestamps. +- Register before `open()` so that bootstrap and migration records use the + custom clock as well. This is mainly useful for tests and reproducible + demos. + #### Transaction Log Formatting - Logfmt callbacks prettify identifiers in the change log and are registered with