Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 46 additions & 26 deletions backend/db_catalogue.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,31 +62,13 @@ class of errors the read helpers degrade gracefully on (fresh volume,
return "no such table" in msg or "no such column" in msg


class BaseCatalogue:
"""Read (and build) access to one eq2db SQLite file."""

#: Enable ``PRAGMA foreign_keys`` per connection — set True when the
#: schema relies on ON DELETE CASCADE (aas, zones, raids).
FOREIGN_KEYS: ClassVar[bool] = False

#: Create the shared ``_meta`` provenance table in init_db. Every
#: module uses it except classes.db (committed pre-populated, no
#: download provenance to track).
CREATE_META: ClassVar[bool] = True
class PathBound:
"""The path-bound identity + dunder surface shared by every SQLite data
interface: sync catalogues/stores (:class:`BaseCatalogue`) and the async
users.db domain stores (:class:`AsyncStoreBase`)."""

def __init__(self, path: Path) -> None:
self.path = Path(path)
# Connections opened via the context-manager protocol; a stack so
# nested ``with cat as conn:`` blocks close their own connection.
self._ctx_conns: list[sqlite3.Connection] = []

def __init_subclass__(cls, **kwargs) -> None:
"""Fail at class-definition time when a subclass forgets
``_create_schema`` — earlier and clearer than the first
``init_db()`` call raising NotImplementedError at runtime."""
super().__init_subclass__(**kwargs)
if cls._create_schema is BaseCatalogue._create_schema:
raise TypeError(f"{cls.__name__} must implement _create_schema(conn)")

# ── Introspection / logging ──────────────────────────────────────────────

Expand Down Expand Up @@ -114,7 +96,7 @@ def __bool__(self) -> bool:
def __eq__(self, other: object) -> bool:
"""Two catalogues are equal when they are the same class over the
same path — handy in tests (``assert cat == RaidCatalogue(p)``)."""
if not isinstance(other, BaseCatalogue):
if not isinstance(other, PathBound):
return NotImplemented
return type(self) is type(other) and self.path == other.path

Expand All @@ -131,6 +113,38 @@ def __fspath__(self) -> str:
``os.path.getsize(cat)`` and ``sqlite3.connect(cat)`` all work."""
return str(self.path)

def clear_caches(self) -> None:
"""Reset per-instance caches — used by tests and build scripts.

Default: no caches. Subclasses holding caches override."""


class BaseCatalogue(PathBound):
"""Read (and build) access to one SQLite file via synchronous sqlite3."""

#: Enable ``PRAGMA foreign_keys`` per connection — set True when the
#: schema relies on ON DELETE CASCADE (aas, zones, raids).
FOREIGN_KEYS: ClassVar[bool] = False

#: Create the shared ``_meta`` provenance table in init_db. Every
#: module uses it except classes.db (committed pre-populated, no
#: download provenance to track).
CREATE_META: ClassVar[bool] = True

def __init__(self, path: Path) -> None:
super().__init__(path)
# Connections opened via the context-manager protocol; a stack so
# nested ``with cat as conn:`` blocks close their own connection.
self._ctx_conns: list[sqlite3.Connection] = []

def __init_subclass__(cls, **kwargs) -> None:
"""Fail at class-definition time when a subclass forgets
``_create_schema`` — earlier and clearer than the first
``init_db()`` call raising NotImplementedError at runtime."""
super().__init_subclass__(**kwargs)
if cls._create_schema is BaseCatalogue._create_schema:
raise TypeError(f"{cls.__name__} must implement _create_schema(conn)")

# ── Connection lifecycle ─────────────────────────────────────────────────

def __enter__(self) -> sqlite3.Connection:
Expand Down Expand Up @@ -243,7 +257,13 @@ def _create_schema(self, conn: sqlite3.Connection) -> None:
def _post_init(self, conn: sqlite3.Connection) -> None:
"""Optional post-commit startup work (data backfills). Default: none."""

def clear_caches(self) -> None:
"""Reset per-instance caches — used by tests and build scripts.

Default: no caches. Subclasses holding caches override."""
class AsyncStoreBase(PathBound):
"""Base for the aiosqlite-backed users.db domain stores.

Unlike :class:`BaseCatalogue`, these stores do NOT own their schema:
the whole users.db family shares one file whose tables/migrations are
orchestrated by ``backend.server.db.init_db()`` at startup. Each domain
method opens its own aiosqlite connection against ``self.path`` —
exactly the per-call transaction shape the old free functions had,
minus the ``path: Path = DB_PATH`` threading."""
4 changes: 2 additions & 2 deletions backend/server/api/character/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -415,10 +415,10 @@ async def prewarm_character_cache() -> None:
pre-warms at boot, not just the default server.
"""
from backend.server.core.executor import run_sync
from backend.server.db.servers import list_servers_sync
from backend.server.db.servers import store as _servers_db

try:
servers = await run_sync(list_servers_sync)
servers = await run_sync(_servers_db.list_servers_sync)
except Exception as exc:
_log.warning("[startup] Could not load server registry for pre-warm: %s", exc)
return
Expand Down
2 changes: 1 addition & 1 deletion backend/server/api/favorites.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@
from backend.server.core.cache_keys import char_cache_key
from backend.server.core.executor import run_sync
from backend.server.core.validation import validate_character_name
from backend.server.db import favorites as favorites_db
from backend.server.db import get_active_claims
from backend.server.db.favorites import store as favorites_db
from backend.server.limiter import limiter
from backend.server.server_context import current_world

Expand Down
8 changes: 4 additions & 4 deletions backend/server/api/raid_schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from backend.server.core.audit_log import audit_log
from backend.server.core.text_moderation import contains_blocked_term, sanitize_text
from backend.server.core.twitch import is_blocked, parse_twitch_login
from backend.server.db.raid_schedule import get_schedule, replace_schedule
from backend.server.db.raid_schedule import store as raid_schedule_db
from backend.server.server_context import current_world

_log = logging.getLogger(__name__)
Expand Down Expand Up @@ -165,7 +165,7 @@ def _fmt_schedule(teams: list[dict]) -> RaidScheduleResponse:
async def get_raid_schedule(guild_name: str) -> RaidScheduleResponse:
"""Public — anyone may view a guild's raid schedule."""
_validate_guild_name(guild_name)
teams = await get_schedule(current_world(), guild_name)
teams = await raid_schedule_db.get_schedule(current_world(), guild_name)
return _fmt_schedule(teams)


Expand Down Expand Up @@ -232,6 +232,6 @@ async def put_raid_schedule(guild_name: str, body: RaidScheduleInput, request: R
)

world = current_world()
await replace_schedule(world, guild_name, db_teams, updated_by=user["id"])
await raid_schedule_db.replace_schedule(world, guild_name, db_teams, updated_by=user["id"])
audit_log("raid_schedule_updated", actor=user["id"], guild=guild_name, teams=len(db_teams))
return _fmt_schedule(await get_schedule(world, guild_name))
return _fmt_schedule(await raid_schedule_db.get_schedule(world, guild_name))
133 changes: 77 additions & 56 deletions backend/server/db/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,62 +48,83 @@ def init_db(path: Path = DB_PATH) -> None:


# ---------------------------------------------------------------------------
# Re-export the per-domain helpers so the existing API shape is preserved.
# Order matters: each domain only imports from web.db (this module) at the
# helper level — no inter-domain imports.
# Facade: re-export each domain store's bound methods so the existing
# `users_db.get_active_claims(...)` API shape is preserved. The domains are
# XStore(AsyncStoreBase) classes now (backend/db_catalogue.py) — the bound
# methods read the shared instance's `path` dynamically, so conftest
# re-points one attribute per store and every alias follows.
# ---------------------------------------------------------------------------

from backend.server.db.claims import ( # noqa: E402,F401
delete_claim,
delete_claims_for_user,
get_active_claims,
get_claim_by_id,
list_claims,
review_claim,
set_primary,
submit_claim,
withdraw_claim,
)
from backend.server.db.item_watch import ( # noqa: E402,F401
add_item_watch,
list_item_watches,
remove_item_watch,
update_item_watch_check,
)
from backend.server.db.servers import ( # noqa: E402,F401
get_server_by_subdomain_sync,
get_server_by_world_sync,
list_servers_sync,
set_default_server_sync,
upsert_server_settings_sync,
)
from backend.server.db.tokens import ( # noqa: E402,F401
generate_token,
hash_token,
list_api_tokens,
lookup_api_token,
mint_api_token,
revoke_api_token,
)
from backend.server.db.users import ( # noqa: E402,F401
approve_all_pending,
create_role_request,
get_display_names_for_discord_ids,
get_role_request,
get_user_access_status,
grant_role,
has_role,
list_all_users,
list_pending_users,
list_role_assignments,
list_role_requests,
list_roles_for_user,
review_and_grant_role,
review_role_request,
revoke_role,
role_has_capability,
set_user_access,
upsert_user,
user_has_capability_via_db,
withdraw_role_request,
from backend.server.db.claims import store as claims_store # noqa: E402
from backend.server.db.favorites import store as favorites_store # noqa: E402
from backend.server.db.item_watch import store as item_watch_store # noqa: E402
from backend.server.db.raid_schedule import store as raid_schedule_store # noqa: E402
from backend.server.db.servers import store as servers_store # noqa: E402
from backend.server.db.tokens import store as tokens_store # noqa: E402
from backend.server.db.users import store as users_store # noqa: E402

# claims
delete_claim = claims_store.delete_claim
delete_claims_for_user = claims_store.delete_claims_for_user
get_active_claims = claims_store.get_active_claims
get_claim_by_id = claims_store.get_claim_by_id
list_claims = claims_store.list_claims
review_claim = claims_store.review_claim
set_primary = claims_store.set_primary
submit_claim = claims_store.submit_claim
withdraw_claim = claims_store.withdraw_claim

# item watch
add_item_watch = item_watch_store.add_item_watch
list_item_watches = item_watch_store.list_item_watches
remove_item_watch = item_watch_store.remove_item_watch
update_item_watch_check = item_watch_store.update_item_watch_check

# servers registry
get_server_by_subdomain_sync = servers_store.get_server_by_subdomain_sync
get_server_by_world_sync = servers_store.get_server_by_world_sync
list_servers_sync = servers_store.list_servers_sync
set_default_server_sync = servers_store.set_default_server_sync
upsert_server_settings_sync = servers_store.upsert_server_settings_sync

# api tokens
generate_token = tokens_store.generate_token
hash_token = tokens_store.hash_token
list_api_tokens = tokens_store.list_api_tokens
lookup_api_token = tokens_store.lookup_api_token
mint_api_token = tokens_store.mint_api_token
revoke_api_token = tokens_store.revoke_api_token

# users + roles
approve_all_pending = users_store.approve_all_pending
create_role_request = users_store.create_role_request
get_display_names_for_discord_ids = users_store.get_display_names_for_discord_ids
get_role_request = users_store.get_role_request
get_user_access_status = users_store.get_user_access_status
grant_role = users_store.grant_role
has_role = users_store.has_role
list_all_users = users_store.list_all_users
list_pending_users = users_store.list_pending_users
list_role_assignments = users_store.list_role_assignments
list_role_requests = users_store.list_role_requests
list_roles_for_user = users_store.list_roles_for_user
review_and_grant_role = users_store.review_and_grant_role
review_role_request = users_store.review_role_request
revoke_role = users_store.revoke_role
role_has_capability = users_store.role_has_capability
set_user_access = users_store.set_user_access
upsert_user = users_store.upsert_user
user_has_capability_via_db = users_store.user_has_capability_via_db
withdraw_role_request = users_store.withdraw_role_request

#: Every domain store over users.db — conftest re-points `store.path` on
#: each after re-resolving DB_PATH from the env.
ALL_STORES = (
claims_store,
favorites_store,
item_watch_store,
raid_schedule_store,
servers_store,
tokens_store,
users_store,
)
Loading
Loading