from __future__ import annotations import json import sqlite3 from datetime import datetime, timezone from pathlib import Path from typing import Any, Iterable from .models import LedgerEntry, canonical_json, sha256_text GENESIS_HASH = "0" * 64 class LedgerError(RuntimeError): pass class ChainDivergenceError(LedgerError): pass class SQLiteLedger: def __init__(self, path: str | Path = ":memory:") -> None: self.path = str(path) self._conn = sqlite3.connect(self.path) self._conn.row_factory = sqlite3.Row self._init_schema() def close(self) -> None: self._conn.close() def __enter__(self) -> "SQLiteLedger": return self def __exit__(self, exc_type, exc, tb) -> None: self.close() def _init_schema(self) -> None: self._conn.execute( """ CREATE TABLE IF NOT EXISTS entries ( sequence INTEGER PRIMARY KEY, timestamp TEXT NOT NULL, kind TEXT NOT NULL, payload TEXT NOT NULL, previous_hash TEXT NOT NULL, entry_hash TEXT NOT NULL UNIQUE, anchor_ref TEXT ) """ ) self._conn.execute( """ CREATE TABLE IF NOT EXISTS anchors ( id INTEGER PRIMARY KEY AUTOINCREMENT, method TEXT NOT NULL, reference TEXT NOT NULL, anchored_hash TEXT NOT NULL, created_at TEXT NOT NULL, metadata TEXT NOT NULL ) """ ) self._conn.commit() @property def head(self) -> LedgerEntry | None: row = self._conn.execute("SELECT * FROM entries ORDER BY sequence DESC LIMIT 1").fetchone() return self._row_to_entry(row) if row else None def append(self, kind: str, payload: dict[str, Any], anchor_ref: str | None = None) -> LedgerEntry: last = self.head sequence = 0 if last is None else last.sequence + 1 previous_hash = GENESIS_HASH if last is None else last.entry_hash entry = LedgerEntry.create(sequence=sequence, kind=kind, payload=payload, previous_hash=previous_hash, anchor_ref=anchor_ref) self._conn.execute( "INSERT INTO entries(sequence, timestamp, kind, payload, previous_hash, entry_hash, anchor_ref) VALUES (?, ?, ?, ?, ?, ?, ?)", ( entry.sequence, entry.timestamp.astimezone(timezone.utc).isoformat().replace("+00:00", "Z"), entry.kind, canonical_json(entry.payload), entry.previous_hash, entry.entry_hash, entry.anchor_ref, ), ) self._conn.commit() return entry def all_entries(self) -> list[LedgerEntry]: rows = self._conn.execute("SELECT * FROM entries ORDER BY sequence ASC").fetchall() return [self._row_to_entry(row) for row in rows] def entry_at(self, sequence: int) -> LedgerEntry | None: row = self._conn.execute("SELECT * FROM entries WHERE sequence = ?", (sequence,)).fetchone() return self._row_to_entry(row) if row else None def entries_after(self, sequence: int) -> list[LedgerEntry]: rows = self._conn.execute("SELECT * FROM entries WHERE sequence > ? ORDER BY sequence ASC", (sequence,)).fetchall() return [self._row_to_entry(row) for row in rows] def verify_chain(self) -> tuple[bool, list[str]]: issues: list[str] = [] previous_hash = GENESIS_HASH for expected_sequence, entry in enumerate(self.all_entries()): if entry.sequence != expected_sequence: issues.append(f"sequence gap at {entry.sequence}") if entry.previous_hash != previous_hash: issues.append(f"previous hash mismatch at {entry.sequence}") recomputed = LedgerEntry.compute_hash( sequence=entry.sequence, timestamp=entry.timestamp, kind=entry.kind, payload=entry.payload, previous_hash=entry.previous_hash, ) if recomputed != entry.entry_hash: issues.append(f"entry hash mismatch at {entry.sequence}") previous_hash = entry.entry_hash return (not issues, issues) def anchor_head(self, method: str, reference: str, metadata: dict[str, str] | None = None) -> str: head = self.head if head is None: raise LedgerError("cannot anchor an empty ledger") metadata_json = json.dumps(metadata or {}, sort_keys=True, separators=(",", ":"), ensure_ascii=True) self._conn.execute( "INSERT INTO anchors(method, reference, anchored_hash, created_at, metadata) VALUES (?, ?, ?, ?, ?)", ( method, reference, head.entry_hash, datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), metadata_json, ), ) self._conn.commit() return head.entry_hash def anchors(self) -> list[dict[str, Any]]: rows = self._conn.execute("SELECT * FROM anchors ORDER BY id ASC").fetchall() return [dict(row) for row in rows] def export_json(self) -> str: return canonical_json([entry.model_dump(mode="python") for entry in self.all_entries()]) def import_entries(self, entries: Iterable[LedgerEntry]) -> None: for entry in entries: self._insert_entry(entry) self._conn.commit() def _insert_entry(self, entry: LedgerEntry) -> None: last = self.head expected_sequence = 0 if last is None else last.sequence + 1 expected_previous_hash = GENESIS_HASH if last is None else last.entry_hash if entry.sequence != expected_sequence or entry.previous_hash != expected_previous_hash: raise ChainDivergenceError("incoming entry does not extend the current chain") recomputed = LedgerEntry.compute_hash( sequence=entry.sequence, timestamp=entry.timestamp, kind=entry.kind, payload=entry.payload, previous_hash=entry.previous_hash, ) if recomputed != entry.entry_hash: raise LedgerError("incoming entry hash is invalid") self._conn.execute( "INSERT INTO entries(sequence, timestamp, kind, payload, previous_hash, entry_hash, anchor_ref) VALUES (?, ?, ?, ?, ?, ?, ?)", ( entry.sequence, entry.timestamp.astimezone(timezone.utc).isoformat().replace("+00:00", "Z"), entry.kind, canonical_json(entry.payload), entry.previous_hash, entry.entry_hash, entry.anchor_ref, ), ) def _row_to_entry(self, row: sqlite3.Row | None) -> LedgerEntry: if row is None: raise LedgerError("entry not found") payload = json.loads(row["payload"]) timestamp = datetime.fromisoformat(row["timestamp"].replace("Z", "+00:00")) return LedgerEntry( sequence=row["sequence"], timestamp=timestamp, kind=row["kind"], payload=payload, previous_hash=row["previous_hash"], entry_hash=row["entry_hash"], anchor_ref=row["anchor_ref"], )