rmi-backend/app/db_client.py
opencode c762564d40 style(rmi-backend): complete lint cleanup — 1175→0 ruff errors
- Fix 71 invalid-syntax files (class-body newline-broken assignments)
- Add from/None chain to 307 B904 raise-without-from sites
- Add B008 ignore to ruff.toml (already in pyproject.toml)
- Noqa F401 on __init__.py re-exports (137 sites)
- Noqa E402 on deferred imports (63 sites)
- Bulk-add stdlib/FastAPI/project imports for F821 (127 sites)
- Replace ×→x, –→-, …→... in docstrings (4093 chars)
- Manual refactor of 5 SIM103/SIM116 patterns

Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py)
Co-authored-by: opencode <opencode@rugmunch.io>
2026-07-06 15:43:20 +02:00

841 lines
32 KiB
Python

#!/usr/bin/env python3
"""
RMI Supabase Database Client
============================
Persistent PostgreSQL storage via Supabase with Redis caching.
Write-through: Supabase first, then Redis.
Read-through: Redis first, Supabase on miss.
"""
import asyncio
import json
import logging
import os
from collections.abc import Callable
from datetime import UTC, datetime
from functools import wraps
from typing import Any
from dotenv import load_dotenv
# Load .env with override to ensure JWT keys win over stale Docker env vars
load_dotenv("/app/.env", override=True)
from pydantic import BaseModel, Field # noqa: E402
logger = logging.getLogger(__name__)
# ── Lazy imports to avoid import-time failures ──
try:
from supabase import Client, create_client
except ImportError as _imp_err:
create_client = None
Client = None
logger.warning(f"[DB] supabase client not installed: {_imp_err}")
try:
import redis.asyncio as redis
except ImportError:
redis = None
# ── SQL to ensure missing tables exist ──
# Run these in the Supabase SQL Editor if the tables are missing.
ENSURE_TABLES_SQL = """
-- Users / Profiles (extends auth.users for wallet-auth users)
CREATE TABLE IF NOT EXISTS profiles (
id TEXT PRIMARY KEY,
email TEXT,
wallet_address TEXT UNIQUE,
role TEXT DEFAULT 'USER',
tier TEXT DEFAULT 'FREE',
created_at TIMESTAMPTZ DEFAULT NOW(),
xp INTEGER DEFAULT 0,
level INTEGER DEFAULT 1,
display_name TEXT,
scans_remaining INTEGER DEFAULT 5,
total_scans_used INTEGER DEFAULT 0,
reputation_score INTEGER DEFAULT 0
);
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Profiles public read" ON profiles FOR SELECT USING (true);
CREATE POLICY "Profiles admin write" ON profiles FOR ALL USING (
EXISTS (SELECT 1 FROM auth.users WHERE auth.users.id = auth.uid() AND auth.users.role = 'admin')
);
-- Contract Audits
CREATE TABLE IF NOT EXISTS contract_audits (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
address TEXT NOT NULL,
chain TEXT DEFAULT 'solana',
risk_score NUMERIC DEFAULT 0,
findings JSONB DEFAULT '[]',
ai_analysis JSONB DEFAULT '{}',
audited_at TIMESTAMPTZ DEFAULT NOW()
);
ALTER TABLE contract_audits ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Audits public read" ON contract_audits FOR SELECT USING (true);
CREATE POLICY "Audits admin write" ON contract_audits FOR ALL USING (
EXISTS (SELECT 1 FROM auth.users WHERE auth.users.id = auth.uid() AND auth.users.role = 'admin')
);
-- Trenches Posts (community board)
CREATE TABLE IF NOT EXISTS trenches_posts (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
category TEXT DEFAULT 'discussion',
author_id TEXT,
upvotes INTEGER DEFAULT 0,
comments INTEGER DEFAULT 0,
tags TEXT[] DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
);
ALTER TABLE trenches_posts ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Posts public read" ON trenches_posts FOR SELECT USING (true);
CREATE POLICY "Posts owner write" ON trenches_posts FOR ALL USING (
auth.uid()::text = author_id OR
EXISTS (SELECT 1 FROM auth.users WHERE auth.users.id = auth.uid() AND auth.users.role = 'admin')
);
-- Gamification Profiles
CREATE TABLE IF NOT EXISTS gamification_profiles (
user_id TEXT PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE,
xp INTEGER DEFAULT 0,
level INTEGER DEFAULT 1,
badges TEXT[] DEFAULT '{}',
scans_count INTEGER DEFAULT 0,
posts_count INTEGER DEFAULT 0,
updated_at TIMESTAMPTZ DEFAULT NOW()
);
ALTER TABLE gamification_profiles ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Gamification public read" ON gamification_profiles FOR SELECT USING (true);
CREATE POLICY "Gamification owner write" ON gamification_profiles FOR ALL USING (
auth.uid()::text = user_id OR
EXISTS (SELECT 1 FROM auth.users WHERE auth.users.id = auth.uid() AND auth.users.role = 'admin')
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_profiles_wallet ON profiles(wallet_address);
CREATE INDEX IF NOT EXISTS idx_contract_audits_address ON contract_audits(address);
CREATE INDEX IF NOT EXISTS idx_trenches_posts_category ON trenches_posts(category);
CREATE INDEX IF NOT EXISTS idx_trenches_posts_created ON trenches_posts(created_at DESC);
"""
# ═══════════════════════════════════════════════════════════
# RETRY UTILITIES
# ═══════════════════════════════════════════════════════════
def _async_retry(max_retries: int = 3, delay: float = 0.5, backoff: float = 2.0):
"""Simple async retry decorator for Supabase operations."""
def decorator(func: Callable):
@wraps(func)
async def wrapper(*args, **kwargs):
last_exc = None
wait = delay
for attempt in range(max_retries + 1):
try:
return await func(*args, **kwargs)
except Exception as exc:
last_exc = exc
if attempt >= max_retries:
raise
logger.warning(f"[DB] Retry {attempt + 1}/{max_retries} for {func.__name__}: {exc}")
await asyncio.sleep(wait)
wait *= backoff
raise last_exc
return wrapper
return decorator
# ═══════════════════════════════════════════════════════════
# PYDANTIC MODELS
# ═══════════════════════════════════════════════════════════
class User(BaseModel):
id: str
email: str | None = None
wallet_address: str | None = None
role: str = "USER"
tier: str = "FREE"
created_at: str | None = None
xp: int = 0
level: int = 1
class InvestigationCase(BaseModel):
id: str
target: str
type: str = "wallet"
status: str = "open"
evidence: list[str] = Field(default_factory=list)
agents_assigned: list[str] = Field(default_factory=list)
created_at: str | None = None
updated_at: str | None = None
risk_score: float = 0.0
findings: dict[str, Any] = Field(default_factory=dict)
class WalletScan(BaseModel):
id: str | None = None
address: str
chain: str = "solana"
risk_score: float = 0.0
findings: dict[str, Any] = Field(default_factory=dict)
scanned_at: str | None = None
user_id: str | None = None
class ContractAudit(BaseModel):
id: str | None = None
address: str
chain: str = "solana"
risk_score: float = 0.0
findings: list[dict[str, Any]] = Field(default_factory=list)
ai_analysis: dict[str, Any] = Field(default_factory=dict)
audited_at: str | None = None
class TrenchesPost(BaseModel):
id: str | None = None
title: str
content: str
category: str = "discussion"
author_id: str | None = None
upvotes: int = 0
comments: int = 0
tags: list[str] = Field(default_factory=list)
created_at: str | None = None
class Alert(BaseModel):
id: str | None = None
token_address: str
types: list[str] = Field(default_factory=list)
webhook_url: str | None = None
active: bool = True
created_at: str | None = None
class GamificationProfile(BaseModel):
user_id: str
xp: int = 0
level: int = 1
badges: list[str] = Field(default_factory=list)
scans_count: int = 0
posts_count: int = 0
updated_at: str | None = None
# ═══════════════════════════════════════════════════════════
# SUPABASE CLIENT
# ═══════════════════════════════════════════════════════════
class SupabaseClient:
"""Wrapped Supabase client with connection pooling, retries, and helpers."""
def __init__(self, url: str | None = None, key: str | None = None):
self.url = url or os.getenv("SUPABASE_URL", "")
# Fall back through env var variants
self.key = (
key or os.getenv("SUPABASE_SERVICE_KEY") or os.getenv("SUPABASE_KEY") or os.getenv("SUPABASE_ANON_KEY", "")
)
self._client: Any | None = None
def _ensure_client(self) -> Any:
if self._client is None:
if create_client is None:
raise RuntimeError("supabase package is not installed")
if not self.url or not self.key:
raise RuntimeError("SUPABASE_URL and SUPABASE_KEY must be set")
self._client = create_client(self.url, self.key)
return self._client
@property
def client(self) -> Any:
return self._ensure_client()
# ── Auth helpers ──
@_async_retry(max_retries=3)
async def sign_up(self, email: str, password: str) -> dict[str, Any]:
"""Register a new user via Supabase Auth."""
result = self.client.auth.sign_up({"email": email, "password": password})
return result.model_dump() if hasattr(result, "model_dump") else dict(result)
@_async_retry(max_retries=3)
async def sign_in(self, email: str, password: str) -> dict[str, Any]:
"""Sign in via Supabase Auth."""
result = self.client.auth.sign_in_with_password({"email": email, "password": password})
return result.model_dump() if hasattr(result, "model_dump") else dict(result)
@_async_retry(max_retries=3)
async def get_user(self, token: str) -> dict[str, Any] | None:
"""Get user by JWT token."""
result = self.client.auth.get_user(token)
if result and result.user:
return {
"id": result.user.id,
"email": result.user.email,
}
return None
# ── Table access ──
def table(self, name: str) -> Any:
return self.client.table(name)
# ── SQL execution (requires service role or elevated privileges) ──
@_async_retry(max_retries=2)
async def execute_sql(self, sql: str) -> Any:
"""Execute raw SQL via RPC (requires a stored procedure or service role)."""
# Prefer using migrations; this is a fallback.
return self.client.rpc("exec_sql", {"query": sql}).execute()
@_async_retry(max_retries=2)
async def ensure_tables(self) -> None:
"""Best-effort table creation. Requires exec_sql RPC or run SQL manually."""
try:
await self.execute_sql(ENSURE_TABLES_SQL)
except Exception as exc:
logger.warning(f"[DB] ensure_tables failed (tables may already exist or RPC missing): {exc}")
# ═══════════════════════════════════════════════════════════
# REPOSITORIES
# ═══════════════════════════════════════════════════════════
class _BaseRepo:
def __init__(self, db: SupabaseClient):
self.db = db
def _now(self) -> str:
return datetime.now(UTC).isoformat()
def _to_dict(self, model: BaseModel, exclude_none: bool = True) -> dict[str, Any]:
d = model.model_dump()
if exclude_none:
d = {k: v for k, v in d.items() if v is not None}
return d
class UserRepository(_BaseRepo):
TABLE = "profiles"
@_async_retry(max_retries=3)
async def create(self, user: User) -> dict[str, Any]:
data = self._to_dict(user)
if not data.get("created_at"):
data["created_at"] = self._now()
result = self.db.table(self.TABLE).insert(data).execute()
return result.data[0] if result.data else {}
@_async_retry(max_retries=3)
async def get(self, user_id: str) -> dict[str, Any] | None:
result = self.db.table(self.TABLE).select("*").eq("id", user_id).execute()
return result.data[0] if result.data else None
@_async_retry(max_retries=3)
async def get_by_wallet(self, wallet_address: str) -> dict[str, Any] | None:
result = self.db.table(self.TABLE).select("*").eq("wallet_address", wallet_address.lower()).execute()
return result.data[0] if result.data else None
@_async_retry(max_retries=3)
async def list(self, limit: int = 100) -> list[dict[str, Any]]:
result = self.db.table(self.TABLE).select("*").limit(limit).execute()
return result.data or []
@_async_retry(max_retries=3)
async def update(self, user_id: str, updates: dict[str, Any]) -> dict[str, Any]:
result = self.db.table(self.TABLE).update(updates).eq("id", user_id).execute()
return result.data[0] if result.data else {}
@_async_retry(max_retries=3)
async def upsert(self, user: User) -> dict[str, Any]:
data = self._to_dict(user)
if not data.get("created_at"):
data["created_at"] = self._now()
result = self.db.table(self.TABLE).upsert(data, on_conflict="id").execute()
return result.data[0] if result.data else {}
@_async_retry(max_retries=3)
async def delete(self, user_id: str) -> bool:
self.db.table(self.TABLE).delete().eq("id", user_id).execute()
return True
class InvestigationCaseRepository(_BaseRepo):
TABLE = "cases"
@_async_retry(max_retries=3)
async def create(self, case: InvestigationCase) -> dict[str, Any]:
data = self._to_dict(case)
if not data.get("created_at"):
data["created_at"] = self._now()
if not data.get("updated_at"):
data["updated_at"] = self._now()
result = self.db.table(self.TABLE).insert(data).execute()
return result.data[0] if result.data else {}
@_async_retry(max_retries=3)
async def get(self, case_id: str) -> dict[str, Any] | None:
result = self.db.table(self.TABLE).select("*").eq("id", case_id).execute()
return result.data[0] if result.data else None
@_async_retry(max_retries=3)
async def list(self, limit: int = 100, status: str | None = None) -> list[dict[str, Any]]:
query = self.db.table(self.TABLE).select("*")
if status:
query = query.eq("status", status)
result = query.limit(limit).execute()
return result.data or []
@_async_retry(max_retries=3)
async def update(self, case_id: str, updates: dict[str, Any]) -> dict[str, Any]:
updates["updated_at"] = self._now()
result = self.db.table(self.TABLE).update(updates).eq("id", case_id).execute()
return result.data[0] if result.data else {}
@_async_retry(max_retries=3)
async def delete(self, case_id: str) -> bool:
self.db.table(self.TABLE).delete().eq("id", case_id).execute()
return True
class WalletScanRepository(_BaseRepo):
TABLE = "wallet_intel"
@_async_retry(max_retries=3)
async def create(self, scan: WalletScan) -> dict[str, Any]:
data = self._to_dict(scan)
# Map findings to transactions JSONB for schema compatibility
if "findings" in data:
data["transactions"] = data.pop("findings")
if not data.get("scanned_at"):
data["last_seen"] = self._now()
result = self.db.table(self.TABLE).insert(data).execute()
return result.data[0] if result.data else {}
@_async_retry(max_retries=3)
async def get(self, scan_id: str) -> dict[str, Any] | None:
result = self.db.table(self.TABLE).select("*").eq("id", scan_id).execute()
return result.data[0] if result.data else None
@_async_retry(max_retries=3)
async def get_by_address(self, address: str) -> dict[str, Any] | None:
result = self.db.table(self.TABLE).select("*").eq("address", address).execute()
return result.data[0] if result.data else None
@_async_retry(max_retries=3)
async def list(self, limit: int = 100, chain: str | None = None) -> list[dict[str, Any]]:
query = self.db.table(self.TABLE).select("*")
if chain:
query = query.eq("chain", chain)
result = query.limit(limit).execute()
return result.data or []
@_async_retry(max_retries=3)
async def update(self, scan_id: str, updates: dict[str, Any]) -> dict[str, Any]:
if "findings" in updates:
updates["transactions"] = updates.pop("findings")
updates["updated_at"] = self._now()
result = self.db.table(self.TABLE).update(updates).eq("id", scan_id).execute()
return result.data[0] if result.data else {}
@_async_retry(max_retries=3)
async def delete(self, scan_id: str) -> bool:
self.db.table(self.TABLE).delete().eq("id", scan_id).execute()
return True
class ContractAuditRepository(_BaseRepo):
TABLE = "contract_audits"
@_async_retry(max_retries=3)
async def create(self, audit: ContractAudit) -> dict[str, Any]:
data = self._to_dict(audit)
if not data.get("audited_at"):
data["audited_at"] = self._now()
result = self.db.table(self.TABLE).insert(data).execute()
return result.data[0] if result.data else {}
@_async_retry(max_retries=3)
async def get(self, audit_id: str) -> dict[str, Any] | None:
result = self.db.table(self.TABLE).select("*").eq("id", audit_id).execute()
return result.data[0] if result.data else None
@_async_retry(max_retries=3)
async def list(self, limit: int = 100, chain: str | None = None) -> list[dict[str, Any]]:
query = self.db.table(self.TABLE).select("*")
if chain:
query = query.eq("chain", chain)
result = query.limit(limit).execute()
return result.data or []
@_async_retry(max_retries=3)
async def update(self, audit_id: str, updates: dict[str, Any]) -> dict[str, Any]:
result = self.db.table(self.TABLE).update(updates).eq("id", audit_id).execute()
return result.data[0] if result.data else {}
@_async_retry(max_retries=3)
async def delete(self, audit_id: str) -> bool:
self.db.table(self.TABLE).delete().eq("id", audit_id).execute()
return True
class TrenchesPostRepository(_BaseRepo):
TABLE = "trenches_posts"
@_async_retry(max_retries=3)
async def create(self, post: TrenchesPost) -> dict[str, Any]:
data = self._to_dict(post)
if not data.get("created_at"):
data["created_at"] = self._now()
result = self.db.table(self.TABLE).insert(data).execute()
return result.data[0] if result.data else {}
@_async_retry(max_retries=3)
async def get(self, post_id: str) -> dict[str, Any] | None:
result = self.db.table(self.TABLE).select("*").eq("id", post_id).execute()
return result.data[0] if result.data else None
@_async_retry(max_retries=3)
async def list(self, limit: int = 50, category: str | None = None) -> list[dict[str, Any]]:
query = self.db.table(self.TABLE).select("*")
if category and category != "all":
query = query.eq("category", category)
result = query.order("created_at", desc=True).limit(limit).execute()
return result.data or []
@_async_retry(max_retries=3)
async def update(self, post_id: str, updates: dict[str, Any]) -> dict[str, Any]:
result = self.db.table(self.TABLE).update(updates).eq("id", post_id).execute()
return result.data[0] if result.data else {}
@_async_retry(max_retries=3)
async def delete(self, post_id: str) -> bool:
self.db.table(self.TABLE).delete().eq("id", post_id).execute()
return True
class AlertRepository(_BaseRepo):
TABLE = "alerts"
@_async_retry(max_retries=3)
async def create(self, alert: Alert) -> dict[str, Any]:
data = self._to_dict(alert)
# Map types -> alert_types for schema compatibility
if "types" in data:
data["alert_types"] = data.pop("types")
if not data.get("created_at"):
data["created_at"] = self._now()
result = self.db.table(self.TABLE).insert(data).execute()
return result.data[0] if result.data else {}
@_async_retry(max_retries=3)
async def get(self, alert_id: str) -> dict[str, Any] | None:
result = self.db.table(self.TABLE).select("*").eq("id", alert_id).execute()
return result.data[0] if result.data else None
@_async_retry(max_retries=3)
async def list(self, limit: int = 100, active_only: bool = True) -> list[dict[str, Any]]:
query = self.db.table(self.TABLE).select("*")
if active_only:
query = query.eq("active", True)
result = query.limit(limit).execute()
return result.data or []
@_async_retry(max_retries=3)
async def update(self, alert_id: str, updates: dict[str, Any]) -> dict[str, Any]:
if "types" in updates:
updates["alert_types"] = updates.pop("types")
result = self.db.table(self.TABLE).update(updates).eq("id", alert_id).execute()
return result.data[0] if result.data else {}
@_async_retry(max_retries=3)
async def delete(self, alert_id: str) -> bool:
self.db.table(self.TABLE).delete().eq("id", alert_id).execute()
return True
class GamificationProfileRepository(_BaseRepo):
TABLE = "gamification_profiles"
@_async_retry(max_retries=3)
async def create(self, profile: GamificationProfile) -> dict[str, Any]:
data = self._to_dict(profile)
if not data.get("updated_at"):
data["updated_at"] = self._now()
result = self.db.table(self.TABLE).insert(data).execute()
return result.data[0] if result.data else {}
@_async_retry(max_retries=3)
async def get(self, user_id: str) -> dict[str, Any] | None:
result = self.db.table(self.TABLE).select("*").eq("user_id", user_id).execute()
return result.data[0] if result.data else None
@_async_retry(max_retries=3)
async def list(self, limit: int = 100) -> list[dict[str, Any]]:
result = self.db.table(self.TABLE).select("*").limit(limit).execute()
return result.data or []
@_async_retry(max_retries=3)
async def update(self, user_id: str, updates: dict[str, Any]) -> dict[str, Any]:
updates["updated_at"] = self._now()
result = self.db.table(self.TABLE).update(updates).eq("user_id", user_id).execute()
return result.data[0] if result.data else {}
@_async_retry(max_retries=3)
async def upsert(self, profile: GamificationProfile) -> dict[str, Any]:
data = self._to_dict(profile)
if not data.get("updated_at"):
data["updated_at"] = self._now()
result = self.db.table(self.TABLE).upsert(data, on_conflict="user_id").execute()
return result.data[0] if result.data else {}
@_async_retry(max_retries=3)
async def delete(self, user_id: str) -> bool:
self.db.table(self.TABLE).delete().eq("user_id", user_id).execute()
return True
# ═══════════════════════════════════════════════════════════
# UNIFIED DB INTERFACE
# ═══════════════════════════════════════════════════════════
class RmiDatabase:
"""Unified interface: Supabase persistence + Redis cache."""
def __init__(self, supabase_url: str | None = None, supabase_key: str | None = None):
self.db = SupabaseClient(url=supabase_url, key=supabase_key)
self.users = UserRepository(self.db)
self.cases = InvestigationCaseRepository(self.db)
self.wallet_scans = WalletScanRepository(self.db)
self.contract_audits = ContractAuditRepository(self.db)
self.trenches_posts = TrenchesPostRepository(self.db)
self.alerts = AlertRepository(self.db)
self.gamification = GamificationProfileRepository(self.db)
async def ensure_tables(self) -> None:
await self.db.ensure_tables()
# ═══════════════════════════════════════════════════════════
# REDIS CACHE HELPERS
# ═══════════════════════════════════════════════════════════
_CACHE_TTL = 300 # 5 minutes default
async def _get_redis() -> Any | None:
"""Lazy Redis client (mirrors main.py pattern)."""
if redis is None:
return None
try:
import os
host = os.getenv("REDIS_HOST", "127.0.0.1")
port = int(os.getenv("REDIS_PORT", "6379"))
password = os.getenv("REDIS_PASSWORD") or None
db = int(os.getenv("REDIS_DB", "0"))
return redis.Redis(host=host, port=port, password=password, db=db, decode_responses=True)
except Exception as exc:
logger.warning(f"[DB] Redis init failed: {exc}")
return None
async def cache_set(key: str, value: Any, ttl: int = _CACHE_TTL) -> None:
r = await _get_redis()
if r is None:
return
try:
await r.set(key, json.dumps(value), ex=ttl)
except Exception as exc:
logger.warning(f"[DB] cache_set failed: {exc}")
async def cache_get(key: str) -> Any | None:
r = await _get_redis()
if r is None:
return None
try:
raw = await r.get(key)
return json.loads(raw) if raw else None
except Exception as exc:
logger.warning(f"[DB] cache_get failed: {exc}")
return None
async def cache_delete(key: str) -> None:
r = await _get_redis()
if r is None:
return
try:
await r.delete(key)
except Exception as exc:
logger.warning(f"[DB] cache_delete failed: {exc}")
async def cache_hash_set(hash_name: str, field: str, value: Any) -> None:
r = await _get_redis()
if r is None:
return
try:
await r.hset(hash_name, field, json.dumps(value))
except Exception as exc:
logger.warning(f"[DB] cache_hash_set failed: {exc}")
async def cache_hash_get_all(hash_name: str) -> dict[str, Any]:
r = await _get_redis()
if r is None:
return {}
try:
raw = await r.hgetall(hash_name)
return {k: json.loads(v) for k, v in raw.items()}
except Exception as exc:
logger.warning(f"[DB] cache_hash_get_all failed: {exc}")
return {}
# ═══════════════════════════════════════════════════════════
# MIGRATION HELPER
# ═══════════════════════════════════════════════════════════
async def sync_redis_to_supabase() -> dict[str, int]:
"""
Read all data from Redis hashes and upsert into Supabase tables.
Returns counts of synced records per table.
"""
counts = {"cases": 0, "trenches_posts": 0, "alerts": 0, "tasks": 0}
r = await _get_redis()
if r is None:
logger.warning("[DB] Redis unavailable; nothing to migrate")
return counts
db = RmiDatabase()
# ── Cases ──
try:
cases_raw = await r.hgetall("rmi:cases")
for case_json in cases_raw.values():
try:
data = json.loads(case_json)
case = InvestigationCase(
id=data.get("id", ""),
target=data.get("target", ""),
type=data.get("type", "wallet"),
status=data.get("status", "open"),
evidence=data.get("evidence") or data.get("evidence", []),
agents_assigned=data.get("agents_assigned") or data.get("agents_assigned", []),
created_at=data.get("created_at") or data.get("created"),
updated_at=data.get("updated_at") or data.get("updated") or data.get("created"),
risk_score=float(data.get("risk_score", 0)),
findings=data.get("findings") or {},
)
await db.cases.create(case)
counts["cases"] += 1
except Exception as exc:
logger.warning(f"[DB] Failed to migrate case: {exc}")
except Exception as exc:
logger.warning(f"[DB] Cases migration error: {exc}")
# ── Trenches Posts ──
try:
posts_raw = await r.hgetall("rmi:trenches:posts")
for post_json in posts_raw.values():
try:
data = json.loads(post_json)
post = TrenchesPost(
id=data.get("id"),
title=data.get("title", ""),
content=data.get("content", ""),
category=data.get("category", "discussion"),
author_id=data.get("author_id"),
upvotes=int(data.get("upvotes", 0)),
comments=int(data.get("comments", 0)),
tags=data.get("tags") or [],
created_at=data.get("created_at") or data.get("created"),
)
await db.trenches_posts.create(post)
counts["trenches_posts"] += 1
except Exception as exc:
logger.warning(f"[DB] Failed to migrate post: {exc}")
except Exception as exc:
logger.warning(f"[DB] Posts migration error: {exc}")
# ── Alerts ──
try:
alerts_raw = await r.hgetall("rmi:alerts")
for alert_json in alerts_raw.values():
try:
data = json.loads(alert_json)
alert = Alert(
id=data.get("id"),
token_address=data.get("token") or data.get("token_address", ""),
types=data.get("types") or data.get("alert_types") or [],
webhook_url=data.get("webhook") or data.get("webhook_url"),
active=data.get("active", True),
created_at=data.get("created_at") or data.get("created"),
)
await db.alerts.create(alert)
counts["alerts"] += 1
except Exception as exc:
logger.warning(f"[DB] Failed to migrate alert: {exc}")
except Exception as exc:
logger.warning(f"[DB] Alerts migration error: {exc}")
# ── Tasks (migrate as cases with type=task for traceability) ──
try:
tasks_raw = await r.hgetall("rmi:tasks")
for task_json in tasks_raw.values():
try:
data = json.loads(task_json)
case = InvestigationCase(
id=data.get("id", ""),
target=data.get("command", "task"),
type="task",
status=data.get("status", "open"),
evidence=[],
agents_assigned=[data.get("agent", "")] if data.get("agent") else [],
created_at=data.get("created") or data.get("created_at"),
updated_at=data.get("created") or data.get("created_at"),
risk_score=0.0,
findings={
"context": data.get("context", {}),
"priority": data.get("priority", "normal"),
},
)
await db.cases.create(case)
counts["tasks"] += 1
except Exception as exc:
logger.warning(f"[DB] Failed to migrate task: {exc}")
except Exception as exc:
logger.warning(f"[DB] Tasks migration error: {exc}")
logger.info(f"[DB] Migration complete: {counts}")
return counts
# ═══════════════════════════════════════════════════════════
# SINGLETON INSTANCE
# ═══════════════════════════════════════════════════════════
_rmi_db: RmiDatabase | None = None
def get_db() -> RmiDatabase:
global _rmi_db
if _rmi_db is None:
_rmi_db = RmiDatabase()
return _rmi_db