v0.2: refactor monitor + scheduler around InvoiceStore Protocol

This commit is contained in:
Sulkta 2026-04-23 19:55:28 -07:00
parent 409005415e
commit fb1e1b73a6
6 changed files with 952 additions and 596 deletions

View file

@ -8,10 +8,17 @@ The SDK does not prescribe a database. Consumers implement
All methods are async so the same Protocol works cleanly for both
asyncpg/asyncio-sqlalchemy backends and synchronous backends wrapped
with ``asyncio.to_thread``.
This module also ships :class:`InMemoryStore` a reference implementation
used by the test suite and useful as a drop-in for local development or
ephemeral workflows that don't need durability.
"""
from __future__ import annotations
import asyncio
import copy
from dataclasses import dataclass, field
from typing import Optional, Protocol, runtime_checkable
from cardano_checkout.invoice import Invoice, InvoiceStatus
@ -67,3 +74,83 @@ class InvoiceStore(Protocol):
state.
"""
...
# ---------------------------------------------------------------------------
# Reference implementation: InMemoryStore
# ---------------------------------------------------------------------------
@dataclass
class InMemoryStore:
"""In-memory :class:`InvoiceStore` — intended for tests and local dev.
Uses an ``asyncio.Lock`` around mutating operations so concurrent callers
see a consistent view. Objects are deep-copied on read so callers can't
mutate the stored state by accident.
"""
_invoices: dict[str, Invoice] = field(default_factory=dict)
_tx_log: dict[tuple[str, str], int] = field(default_factory=dict)
_index_counters: dict[str, int] = field(default_factory=dict)
_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
async def create(self, invoice: Invoice) -> None:
async with self._lock:
if invoice.id in self._invoices:
raise ValueError(f"Invoice {invoice.id!r} already exists")
self._invoices[invoice.id] = copy.deepcopy(invoice)
# Bump the per-merchant index cursor so next_derivation_index doesn't
# hand out the same slot again.
cur = self._index_counters.get(invoice.merchant_id, -1)
if invoice.derivation_index > cur:
self._index_counters[invoice.merchant_id] = invoice.derivation_index
async def get(self, invoice_id: str) -> Optional[Invoice]:
async with self._lock:
stored = self._invoices.get(invoice_id)
return copy.deepcopy(stored) if stored else None
async def list_by_status(
self, status: InvoiceStatus, limit: int = 100
) -> list[Invoice]:
async with self._lock:
matching = [
copy.deepcopy(inv)
for inv in self._invoices.values()
if inv.status == status
]
# Newest first per contract.
matching.sort(key=lambda inv: inv.created_at, reverse=True)
return matching[:limit]
async def update(self, invoice: Invoice) -> None:
async with self._lock:
if invoice.id not in self._invoices:
raise KeyError(f"Invoice {invoice.id!r} not found")
self._invoices[invoice.id] = copy.deepcopy(invoice)
async def next_derivation_index(self, merchant_id: str) -> int:
async with self._lock:
nxt = self._index_counters.get(merchant_id, -1) + 1
self._index_counters[merchant_id] = nxt
return nxt
async def record_tx(
self, invoice_id: str, tx_hash: str, lovelace_delta: int
) -> None:
async with self._lock:
# Idempotent: (invoice_id, tx_hash) overwrites the delta rather than
# adding it a second time — see InvoiceStore.record_tx contract.
self._tx_log[(invoice_id, tx_hash)] = lovelace_delta
inv = self._invoices.get(invoice_id)
if inv and tx_hash not in inv.tx_hashes:
inv.tx_hashes.append(tx_hash)
# --- test helpers (not part of the Protocol) ---
def _all(self) -> list[Invoice]:
return [copy.deepcopy(inv) for inv in self._invoices.values()]
def _tx_records(self) -> dict[tuple[str, str], int]:
return dict(self._tx_log)