From 795903142761a31e4d290530544900f7684dd0c8 Mon Sep 17 00:00:00 2001 From: Edvin Norling Date: Wed, 22 Apr 2026 09:16:31 +0200 Subject: [PATCH] server: define async Store protocol (#306) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 step (3) of the RFC #275 PostgreSQL-backend effort (epic #257). Pure interface definition: new `cq_server.store._protocol.Store` with `async def` signatures covering every public method on today's `RemoteStore`. No implementations, no runtime callers touched. Because a subpackage and a module can't share a name, `store.py` moves to `store/__init__.py` (rename preserves history; only the two same-package relative imports change, `.scoring` -> `..scoring` and `.tables` -> `..tables`). The RemoteStore class is otherwise untouched. `@runtime_checkable` plus an `issubclass(RemoteStore, Store)` assertion covers the structural acceptance criterion — Python's runtime Protocol check inspects attribute names only, so sync RemoteStore satisfies the async protocol today and future async `SqliteStore` (#308) will keep satisfying it. Deliberately out of scope: `db_path` (SQLite-specific property) and `__enter__`/`__exit__` (sync dunders; async `__aenter__`/`__aexit__` land with the real async `SqliteStore` in #308). Closes #306. --- .../cq_server/{store.py => store/__init__.py} | 7 +- .../backend/src/cq_server/store/_protocol.py | 157 ++++++++++++++++++ server/backend/tests/test_store_protocol.py | 14 ++ 3 files changed, 176 insertions(+), 2 deletions(-) rename server/backend/src/cq_server/{store.py => store/__init__.py} (99%) create mode 100644 server/backend/src/cq_server/store/_protocol.py create mode 100644 server/backend/tests/test_store_protocol.py diff --git a/server/backend/src/cq_server/store.py b/server/backend/src/cq_server/store/__init__.py similarity index 99% rename from server/backend/src/cq_server/store.py rename to server/backend/src/cq_server/store/__init__.py index e574f4c..f57e192 100644 --- a/server/backend/src/cq_server/store.py +++ b/server/backend/src/cq_server/store/__init__.py @@ -16,8 +16,11 @@ from cq.models import KnowledgeUnit -from .scoring import calculate_relevance -from .tables import ensure_api_keys_table, ensure_review_columns, ensure_users_table +from ..scoring import calculate_relevance +from ..tables import ensure_api_keys_table, ensure_review_columns, ensure_users_table +from ._protocol import Store + +__all__ = ["DEFAULT_DB_PATH", "RemoteStore", "Store", "normalize_domains"] _logger = logging.getLogger(__name__) diff --git a/server/backend/src/cq_server/store/_protocol.py b/server/backend/src/cq_server/store/_protocol.py new file mode 100644 index 0000000..a7a5b30 --- /dev/null +++ b/server/backend/src/cq_server/store/_protocol.py @@ -0,0 +1,157 @@ +"""Async `Store` protocol for the cq server. + +Pure interface definition — no implementations live here. Concrete +backends (`SqliteStore`, later `PostgresStore`) will conform to this +protocol so callers can depend on the surface without caring about +dialect. Early implementations may shim native sync drivers via a +threadpool; the protocol itself stays async. +""" + +from typing import Any, Protocol, runtime_checkable + +from cq.models import KnowledgeUnit + + +@runtime_checkable +class Store(Protocol): + """Async storage protocol for the cq server. + + Implementations are expected to be one-per-dialect (`SqliteStore`, + `PostgresStore`). Method names and argument shapes match the current + `RemoteStore` exactly so callers migrate without rewriting call sites. + """ + + async def close(self) -> None: + """Release underlying connections/resources; idempotent.""" + ... + + async def insert(self, unit: KnowledgeUnit) -> None: + """Insert a knowledge unit; raises on id conflict or empty domains.""" + ... + + async def get(self, unit_id: str) -> KnowledgeUnit | None: + """Return an approved KU by id, or None if missing or not approved.""" + ... + + async def get_any(self, unit_id: str) -> KnowledgeUnit | None: + """Return a KU by id regardless of review status, or None if missing.""" + ... + + async def get_review_status(self, unit_id: str) -> dict[str, str | None] | None: + """Return review metadata (status, reviewed_by, reviewed_at) or None.""" + ... + + async def set_review_status(self, unit_id: str, status: str, reviewed_by: str) -> None: + """Update a KU's review status; raises KeyError if the id is unknown.""" + ... + + async def update(self, unit: KnowledgeUnit) -> None: + """Replace an existing KU; raises KeyError if the id is unknown.""" + ... + + async def query( + self, + domains: list[str], + *, + languages: list[str] | None = None, + frameworks: list[str] | None = None, + pattern: str = "", + limit: int = 5, + ) -> list[KnowledgeUnit]: + """Return approved KUs matching any of the domains, ranked by relevance.""" + ... + + async def count(self) -> int: + """Return the total number of KUs in the store.""" + ... + + async def domain_counts(self) -> dict[str, int]: + """Return approved KU counts keyed by domain tag.""" + ... + + async def pending_queue(self, *, limit: int = 20, offset: int = 0) -> list[dict[str, Any]]: + """Return pending KUs with review metadata, oldest first.""" + ... + + async def pending_count(self) -> int: + """Return the number of pending KUs.""" + ... + + async def counts_by_status(self) -> dict[str, int]: + """Return KU counts keyed by review status.""" + ... + + async def counts_by_tier(self) -> dict[str, int]: + """Return approved KU counts keyed by tier.""" + ... + + async def list_units( + self, + *, + domain: str | None = None, + confidence_min: float | None = None, + confidence_max: float | None = None, + status: str | None = None, + limit: int = 100, + ) -> list[dict[str, Any]]: + """Return KUs with review metadata, filtered by domain/confidence/status.""" + ... + + async def create_user(self, username: str, password_hash: str) -> None: + """Insert a new user; raises on duplicate username.""" + ... + + async def get_user(self, username: str) -> dict[str, Any] | None: + """Return user row by username, or None if missing.""" + ... + + async def count_active_api_keys_for_user(self, user_id: int) -> int: + """Return the number of non-revoked, non-expired API keys for a user.""" + ... + + async def create_api_key( + self, + *, + key_id: str, + user_id: int, + name: str, + labels: list[str], + key_prefix: str, + key_hash: str, + ttl: str, + expires_at: str, + ) -> dict[str, Any]: + """Insert a new API key row and return the inserted row.""" + ... + + async def get_api_key_for_user(self, *, user_id: int, key_id: str) -> dict[str, Any] | None: + """Return the key row if it exists and belongs to the user, else None.""" + ... + + async def get_active_api_key_by_id(self, key_id: str) -> dict[str, Any] | None: + """Return the active key row (including the owner's username) by id, or None if missing, revoked, or expired.""" + ... + + async def list_api_keys_for_user(self, user_id: int) -> list[dict[str, Any]]: + """Return all API keys owned by the user, newest first.""" + ... + + async def revoke_api_key(self, *, user_id: int, key_id: str) -> bool: + """Mark the key revoked; return True if a row was updated.""" + ... + + async def touch_api_key_last_used(self, key_id: str) -> None: + """Best-effort update of ``last_used_at``; errors are swallowed.""" + ... + + async def confidence_distribution(self) -> dict[str, int]: + """Return confidence-bucket counts for approved KUs.""" + ... + + async def recent_activity(self, limit: int = 20) -> list[dict[str, Any]]: + """Return recent activity events (one per KU), newest first.""" + ... + + async def daily_counts(self, *, days: int = 30) -> list[dict[str, Any]]: + """Return per-day proposed/approved/rejected counts; raises on days <= 0.""" + ... diff --git a/server/backend/tests/test_store_protocol.py b/server/backend/tests/test_store_protocol.py new file mode 100644 index 0000000..a853487 --- /dev/null +++ b/server/backend/tests/test_store_protocol.py @@ -0,0 +1,14 @@ +"""Structural test: `RemoteStore` satisfies the async `Store` protocol. + +`@runtime_checkable` protocols check attribute names only, not signatures +or sync-vs-async — so today's sync `RemoteStore` qualifies as long as +every method name the protocol declares exists on the class. Once +`SqliteStore` (issue #308) replaces `RemoteStore` with an actually-async +implementation, this same test stays valid. +""" + +from cq_server.store import RemoteStore, Store + + +def test_remote_store_satisfies_store_protocol() -> None: + assert issubclass(RemoteStore, Store)