196 lines
7.3 KiB
Python
196 lines
7.3 KiB
Python
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"],
|
|
)
|