"""Accounts, chat history, and usage analytics in Supabase. Supabase exposes every table through PostgREST, which is plain HTTPS + JSON, so this module needs only the standard library (urllib). No public method raises: each returns (result, error) and the app shows the error instead of crashing. Writes that nothing waits for (chat rows, clicks, heartbeats) run on a background thread so a slow database never delays the tutor. Tables (created by supabase_schema.sql): anatomy_users one row per account: user_id + email anatomy_messages every chat message, in order, per user anatomy_sessions one row per sign-in with first and last activity times anatomy_events one row per button click """ from __future__ import annotations import json import logging import os import queue import re import threading import time import urllib.error import urllib.parse import urllib.request from datetime import datetime, timezone log = logging.getLogger("anatomy.supabase") USERS_TABLE = "anatomy_users" MESSAGES_TABLE = "anatomy_messages" SESSIONS_TABLE = "anatomy_sessions" EVENTS_TABLE = "anatomy_events" DEFAULT_TIMEOUT = 15.0 RETRIES = 2 # attempts for network failures and 5xx replies QUEUE_SIZE = 10_000 # background writes waiting for Supabase; newer ones are dropped (and logged) beyond this EXISTS = "__user_id_exists__" # error returned by create_user when the ID is taken # Lower-case; letters, digits, dot, underscore, hyphen; 2 to 64 characters. USER_ID_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{1,63}$") EMAIL_RE = re.compile( r"^[a-z0-9!#$%&'*+/=?^_`{|}~.-]+@" r"[a-z0-9](?:[a-z0-9-]*[a-z0-9])?" r"(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*" r"\.[a-z]{2,}$" ) def normalize_user_id(value: str | None) -> str: return (value or "").strip().lower() def is_valid_user_id(value: str | None) -> bool: return USER_ID_RE.match(normalize_user_id(value)) is not None def normalize_email(value: str | None) -> str: return (value or "").strip().lower() def is_valid_email(value: str | None) -> bool: email = normalize_email(value) if not email or len(email) > 254 or ".." in email: return False local = email.split("@")[0] if local.startswith(".") or local.endswith("."): return False return EMAIL_RE.match(email) is not None def now_iso() -> str: return datetime.now(timezone.utc).isoformat() def _env(*names: str) -> str: for name in names: value = os.environ.get(name, "") if value and value.strip(): return value.strip() return "" def _is_conflict(error: str | None) -> bool: text = error or "" return "HTTP 409" in text or "23505" in text class SupabaseStore: """Thin PostgREST client scoped to this app's four tables.""" def __init__(self, url: str | None = None, key: str | None = None, timeout: float | None = None) -> None: raw_url = (url if url is not None else _env("SUPABASE_URL", "SUPABASE_PROJECT_URL")).strip().rstrip("/") if raw_url and not raw_url.startswith(("http://", "https://")): raw_url = "https://" + raw_url if raw_url.endswith("/rest/v1"): # tolerate a pasted REST endpoint raw_url = raw_url[: -len("/rest/v1")] self.url = raw_url self.key = key if key is not None else _env("SUPABASE_KEY", "SUPABASE_SERVICE_ROLE_KEY", "SUPABASE_SERVICE_KEY") try: self.timeout = float(timeout if timeout is not None else (_env("SUPABASE_TIMEOUT") or DEFAULT_TIMEOUT)) except (TypeError, ValueError): self.timeout = DEFAULT_TIMEOUT self._queue: queue.Queue = queue.Queue(maxsize=QUEUE_SIZE) self._worker: threading.Thread | None = None self._worker_lock = threading.Lock() # ---- configuration ------------------------------------------------------- @property def enabled(self) -> bool: return bool(self.url and self.key) def config_hint(self) -> str: """Why persistence is off (empty when it is on).""" missing = [name for name, value in (("SUPABASE_URL", self.url), ("SUPABASE_KEY", self.key)) if not value] return "Missing Space secret(s): " + ", ".join(missing) if missing else "" # ---- HTTP ---------------------------------------------------------------- def _request(self, method: str, table: str, params: dict | None = None, body=None, prefer: str | None = None): """Return (ok, data, error). Never raises.""" if not self.enabled: return False, None, self.config_hint() url = f"{self.url}/rest/v1/{table}" if params: url += "?" + urllib.parse.urlencode(params) headers = {"apikey": self.key, "Authorization": "Bearer " + self.key, "Accept": "application/json"} data = None if body is not None: data = json.dumps(body).encode("utf-8") headers["Content-Type"] = "application/json" if prefer: headers["Prefer"] = prefer error = None for attempt in range(RETRIES): request = urllib.request.Request(url, data=data, headers=headers, method=method) try: with urllib.request.urlopen(request, timeout=self.timeout) as response: raw = response.read() if not raw: return True, [], None try: return True, json.loads(raw.decode("utf-8")), None except ValueError: return True, [], None except urllib.error.HTTPError as err: detail = "" try: detail = err.read().decode("utf-8", "ignore")[:400] except Exception: pass error = f"Supabase {method} {table}: HTTP {err.code} {detail or err.reason}" if err.code < 500: return False, None, error except Exception as err: # URLError, timeout, DNS, TLS error = f"Supabase {method} {table}: {type(err).__name__}: {err}" if method == "POST": # an insert is never repeated: the first attempt may have landed return False, None, error if attempt < RETRIES - 1: time.sleep(0.5 * (attempt + 1)) return False, None, error def ping(self): """Connectivity and schema check. Returns (ok, error).""" ok, _, error = self._request("GET", USERS_TABLE, params={"select": "user_id", "limit": "1"}) return ok, error # ---- accounts ------------------------------------------------------------ def get_user(self, user_id: str): """Return (row_or_None, error).""" ok, data, error = self._request( "GET", USERS_TABLE, params={"select": "user_id,email,created_at,last_login_at", "user_id": "eq." + normalize_user_id(user_id), "limit": "1"}, ) if not ok: return None, error return (data[0] if isinstance(data, list) and data else None), None def create_user(self, user_id: str, email: str): """Insert a new account. Returns (row, error); error is EXISTS when the ID is taken.""" now = now_iso() body = {"user_id": normalize_user_id(user_id), "email": normalize_email(email), "created_at": now, "last_login_at": now} ok, data, error = self._request("POST", USERS_TABLE, body=body, prefer="return=representation") if not ok: return None, (EXISTS if _is_conflict(error) else error) return (data[0] if isinstance(data, list) and data else body), None def record_login(self, user_id: str): ok, _, error = self._request( "PATCH", USERS_TABLE, params={"user_id": "eq." + normalize_user_id(user_id)}, body={"last_login_at": now_iso()}, prefer="return=minimal", ) return ok, error def delete_user(self, user_id: str): """Remove an account and everything stored for it (used by verify_supabase.py). Returns (ok, error).""" params = {"user_id": "eq." + normalize_user_id(user_id)} for table in (EVENTS_TABLE, SESSIONS_TABLE, MESSAGES_TABLE, USERS_TABLE): ok, _, error = self._request("DELETE", table, params=params, prefer="return=minimal") if not ok: return False, error return True, None # ---- chat history -------------------------------------------------------- def load_messages(self, user_id: str, limit: int = 200): """The most recent `limit` messages of a user, oldest first. Returns (rows, error).""" ok, data, error = self._request( "GET", MESSAGES_TABLE, params={ "select": "role,content,mode,topic,source,created_at", "user_id": "eq." + normalize_user_id(user_id), "order": "id.desc", "limit": str(int(limit)), }, ) if not ok: return [], error rows = data if isinstance(data, list) else [] rows.reverse() return rows, None def append_messages(self, user_id: str, session_id: str, messages: list[dict]): """messages: [{'role', 'content', 'mode', 'topic', 'source'}]. Returns (ok, error).""" now = now_iso() rows = [] for message in messages: if message.get("role") not in ("user", "assistant") or not isinstance(message.get("content"), str): continue # Every row carries the same keys: PostgREST rejects a batch whose objects differ. rows.append({ "user_id": normalize_user_id(user_id), "session_id": session_id, "role": message["role"], "content": message["content"], "mode": message.get("mode"), "topic": message.get("topic"), "source": message.get("source"), "created_at": now, }) if not rows: return True, None ok, _, error = self._request("POST", MESSAGES_TABLE, body=rows, prefer="return=minimal") return ok, error # ---- time on site -------------------------------------------------------- def start_session(self, session_id: str, user_id: str): now = now_iso() ok, _, error = self._request( "POST", SESSIONS_TABLE, body={"session_id": session_id, "user_id": normalize_user_id(user_id), "started_at": now, "last_seen_at": now}, prefer="return=minimal", ) return ok, error def heartbeat(self, session_id: str): ok, _, error = self._request( "PATCH", SESSIONS_TABLE, params={"session_id": "eq." + session_id}, body={"last_seen_at": now_iso()}, prefer="return=minimal", ) return ok, error def end_session(self, session_id: str): now = now_iso() ok, _, error = self._request( "PATCH", SESSIONS_TABLE, params={"session_id": "eq." + session_id}, body={"last_seen_at": now, "ended_at": now}, prefer="return=minimal", ) return ok, error # ---- clicks -------------------------------------------------------------- def log_event(self, user_id: str, session_id: str, event: str, detail: dict | None = None): ok, _, error = self._request( "POST", EVENTS_TABLE, body={"user_id": normalize_user_id(user_id), "session_id": session_id, "event": event, "detail": detail or {}, "created_at": now_iso()}, prefer="return=minimal", ) return ok, error # ---- background writes --------------------------------------------------- def enqueue(self, method: str, *args) -> None: """Run a store method on the background thread, in order; failures are logged.""" if not self.enabled: return try: self._queue.put_nowait((method, args)) except queue.Full: log.warning("Supabase write queue is full; dropping %s", method) return with self._worker_lock: if self._worker is None or not self._worker.is_alive(): self._worker = threading.Thread(target=self._run, name="supabase-writer", daemon=True) self._worker.start() def _run(self) -> None: while True: method, args = self._queue.get() try: ok, error = getattr(self, method)(*args) if not ok: log.warning("%s failed: %s", method, error) except Exception: log.exception("%s failed", method) finally: self._queue.task_done() def flush(self, timeout: float = 3.0) -> bool: """Wait up to `timeout` seconds for queued writes to finish. Returns True when all of them have.""" deadline = time.monotonic() + timeout with self._queue.all_tasks_done: # Queue.join() with a timeout while self._queue.unfinished_tasks: remaining = deadline - time.monotonic() if remaining <= 0: return False self._queue.all_tasks_done.wait(remaining) return True