From c1d01d3b1c9426f76a75c07444422946880e868b Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Mon, 15 Jun 2026 00:27:49 +0000 Subject: [PATCH] Fix creation of new database, ensuring that a bootstrap record of the initial state is always written. --- README.md | 14 +++++++----- docs/database.md | 8 ++++++- kanta/filelock.py | 4 +++- kanta/kanta.py | 2 +- kanta/kantaimpl.py | 13 ++++++----- kanta/persistence.py | 7 ++++-- tests/support.py | 12 ++++++++++ tests/test_kanta_integration.py | 39 +++++++++++++++++++++++++++++++++ tests/test_mtime.py | 5 +++-- 9 files changed, 87 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index d69892c..a086f4d 100644 --- a/README.md +++ b/README.md @@ -53,10 +53,13 @@ asyncio.run(main()) ## Bootstrap and Open Modes -Kanta supports open-time bootstrap callbacks for initializing a brand-new -database before `open()` returns. +When `open()` creates a brand-new database, it always writes a single bootstrap +change record from the initial data object you passed to `Kanta(...)`. The +simplest bootstrap is therefore the object itself — no extra code is required. -Register bootstrap handlers with a decorator: +Bootstrap handlers are optional. Use them only when you need to modify the +initial state at creation time, for example to seed defaults or perform +expensive/external setup that should happen exactly once: ```python kanta = Kanta("data.kantadb", Data()) @@ -76,9 +79,10 @@ async def bootstrap_async(data) -> None: data.counter = 1 ``` -When multiple bootstrap handlers are registered: +Whether or not handlers are registered, exactly one bootstrap change record is +written when a new database is created. The record contains the initial object, +or the state after all bootstrap handlers have run. When handlers are present: - they run in registration order, -- exactly one bootstrap change record is queued, - bootstrap metadata (`action`, `user`, `mtime`) is taken from the last registration. diff --git a/docs/database.md b/docs/database.md index daf8edc..6c5765b 100644 --- a/docs/database.md +++ b/docs/database.md @@ -133,7 +133,11 @@ when they have a default value. #### Bootstrap Callbacks -- Bootstrap callbacks run during `open()` when the database is empty. +- When `open()` creates a new database, it always writes a single bootstrap + `ChangeRecord`. +- The simplest bootstrap is the initial data object passed to `Kanta(...)`; + bootstrap callbacks are optional and only needed when you want to modify or + enrich that object at creation time. - Register callbacks via: - `@kanta.bootstrap` - `@kanta.bootstrap(action=..., user=..., mtime=...)` @@ -146,6 +150,8 @@ when they have a default value. - exactly one bootstrap `ChangeRecord` is queued, - bootstrap metadata (`action`, `user`, `mtime`) is taken from the last callback registration. +- If no bootstrap callbacks are registered, the bootstrap record still uses + `action="bootstrap"` and contains the initial data object. - If any bootstrap callback raises, Kanta closes and removes the database file, then re-raises the exception. diff --git a/kanta/filelock.py b/kanta/filelock.py index d719c19..369eea7 100644 --- a/kanta/filelock.py +++ b/kanta/filelock.py @@ -129,7 +129,9 @@ class LockedFile: else: self._open_unix(path, create, readonly) - def open_and_read(self, path: Path, create: bool = False, readonly: bool = False) -> bytes: + def open_and_read( + self, path: Path, create: bool = False, readonly: bool = False + ) -> bytes: """Open *path* and read all content. Combined operation for efficient use with asyncio.to_thread(). diff --git a/kanta/kanta.py b/kanta/kanta.py index 64c1b76..1e3bac6 100644 --- a/kanta/kanta.py +++ b/kanta/kanta.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import datetime from pathlib import Path from types import ModuleType, SimpleNamespace -from typing import Any, Generic, TypeVar +from typing import Generic, TypeVar from kanta.kantaimpl import KantaImpl from kanta.serialization import JsonSerializer, Serializer diff --git a/kanta/kantaimpl.py b/kanta/kantaimpl.py index 1471238..57c56f0 100644 --- a/kanta/kantaimpl.py +++ b/kanta/kantaimpl.py @@ -172,19 +172,22 @@ class KantaImpl(PersistenceMixin, Generic[T]): db_path=self.filename, action="open", ) - elif self.callback_registry.has("bootstrap"): + else: try: - await self.callback_registry.invoke( - "bootstrap", - InjectionContext(data=self.data, kanta=self._kanta), - ) + if self.callback_registry.has("bootstrap"): + await self.callback_registry.invoke( + "bootstrap", + InjectionContext(data=self.data, kanta=self._kanta), + ) + self.statedict = {} current = struct_to_dict(self.data, serializer=self.serializer) self.queue_change( self.bootstrap_action, current, user=self.bootstrap_user, mtime=self.bootstrap_mtime, + force=True, ) except Exception: self.file.close() diff --git a/kanta/persistence.py b/kanta/persistence.py index 1feed89..10413e9 100644 --- a/kanta/persistence.py +++ b/kanta/persistence.py @@ -109,6 +109,7 @@ class PersistenceMixin: *, user: str | None = None, mtime: bool | datetime = True, + force: bool = False, ) -> ChangeRecord | None: """Queue a change record internally (thread-safe). @@ -121,9 +122,11 @@ class PersistenceMixin: previous modification time remains in effect; this is used for system operations that are not considered modifications. A :class:`~datetime.datetime` value sets ``m`` to that explicit time. + force: If ``True``, queue the record even when the diff is empty. Returns: - The queued :class:`ChangeRecord`, or ``None`` if the diff was empty. + The queued :class:`ChangeRecord`, or ``None`` if the diff was empty + and *force* is ``False``. """ now = datetime.now(UTC) @@ -137,7 +140,7 @@ class PersistenceMixin: raise TypeError("mtime must be True, False, or a datetime") diff = compute_diff(self.statedict, current) - if not diff: + if not diff and not force: return None record = ChangeRecord( diff --git a/tests/support.py b/tests/support.py index cfa7855..e087008 100644 --- a/tests/support.py +++ b/tests/support.py @@ -70,6 +70,18 @@ def change_actions(path: Path, format_config) -> list[str]: return actions +def read_changes(path: Path, format_config) -> list[ChangeRecord]: + _, serializer_cls = format_config + serializer = serializer_cls() + framer = serializer.framer_cls() + records: list[ChangeRecord] = [] + for is_snapshot, payload, _, _ in framer.iter_records(path.read_bytes(), 0): + if is_snapshot: + continue + records.append(serializer.decode(payload, type=ChangeRecord)) + return records + + def make_migrations_module(name: str, fn_name: str, fn): mod = ModuleType(name) mod.__dict__[fn_name] = fn diff --git a/tests/test_kanta_integration.py b/tests/test_kanta_integration.py index 613eb7b..bcd7a16 100644 --- a/tests/test_kanta_integration.py +++ b/tests/test_kanta_integration.py @@ -17,6 +17,7 @@ from .support import ( change_actions, fixed_change, make_kanta, + read_changes, seed_single_change, ) @@ -30,6 +31,44 @@ async def test_load_empty(tmp_path, format_config): await kanta.close() +@pytest.mark.asyncio +async def test_new_file_writes_bootstrap_record_without_handlers( + tmp_path, format_config +): + path = tmp_path / "test.db" + kanta = make_kanta(path, Data, format_config) + await kanta.open() + await kanta.close() + + records = read_changes(path, format_config) + assert len(records) == 1 + assert records[0].a == "bootstrap" + assert records[0].diff == {"$replace": {"users": {}, "counter": 0}} + + +@pytest.mark.asyncio +async def test_new_file_persists_initial_state_for_roundtrip(tmp_path, format_config): + path = tmp_path / "test.db" + kanta = make_kanta( + path, Data(counter=5, users={"alice": User(name="Alice")}), format_config + ) + await kanta.open() + await kanta.close() + + records = read_changes(path, format_config) + assert len(records) == 1 + assert records[0].a == "bootstrap" + assert records[0].diff == { + "$replace": {"users": {"alice": {"name": "Alice", "age": 0}}, "counter": 5} + } + + kanta2 = make_kanta(path, Data, format_config) + await kanta2.open() + assert kanta2.data.counter == 5 + assert kanta2.data.users["alice"].name == "Alice" + await kanta2.close() + + @pytest.mark.asyncio async def test_open_overwrites_caller_owned_root_data(tmp_path, format_config): path = tmp_path / "test.db" diff --git a/tests/test_mtime.py b/tests/test_mtime.py index 620482c..cb498fb 100644 --- a/tests/test_mtime.py +++ b/tests/test_mtime.py @@ -82,8 +82,9 @@ async def test_transaction_mtime_false_preserves_mtime(tmp_path, format_config): continue records.append(serializer.decode(payload, type=ChangeRecord)) - assert records[0].m == first_m - assert records[1].m is None + assert records[0].a == "bootstrap" + assert records[1].m == first_m + assert records[2].m is None assert kanta.mtime == first_m