Session Management
Five session mechanisms across the stack: auth cookies, chat sessions, Redis caches, per-session locks, and summary regeneration.
Session Management
Session management is the system's most cross-cutting concern. There are five distinct session mechanisms operating at different layers and with different lifetimes.
Auth Session (Supabase SSR Cookie)
The auth session is the user's identity proof. It flows through every component via the cookie header.
Cookie refresh: The SSR client transparently refreshes the session if the access token is expired. On refresh, the middleware sets a new cookie on the response via set-cookie header. This means the cookie is always valid for the duration of the user's session or until they log out.
Blocked-user lifecycle: When an admin blocks a user, the profiles.status changes to blocked. The middleware checks this on every request, so the block takes effect on next navigation (not immediately on the current page). The server helper (auth()) also returns null for blocked users, preventing API calls from succeeding even if the user somehow bypasses middleware.
Chat Session (Frontend-Owned DB Row)
The chat session is the user's conversation state. The frontend owns its lifecycle; the backend reads and writes messages but never creates or deletes sessions.
Ownership chain: Purchase or upload → user_library row → session creation (chat_sessions) → message writes (messages). Every link in the chain is validated before the next link is used. A user cannot create a session for a book they do not own, and the backend validates the session-user relationship before streaming.
Session schema (Supabase chat_sessions table):
| Column | Type | Notes |
|---|---|---|
id | UUID | Primary key, generated by frontend |
user_id | UUID | FK to auth.users |
character_id | UUID | FK to characters |
book_id | UUID | FK to books |
title | Text | Readable label for the conversation list |
message_count | Integer | Incremented by backend after each stream |
preview | Text | First ~100 chars of last assistant response |
is_archived | Boolean | Soft-delete flag, default false |
created_at | Timestamp | Set on insert |
updated_at | Timestamp | Updated by backend after each stream |
Redis Cache Sessions (Backend Read-Through)
The backend caches three data sources in Redis to avoid querying Supabase on every chat request. These are read-through caches with a 3600-second TTL and no explicit invalidation.
| Cache key | Value | TTL | Populated by |
|---|---|---|---|
chat:sp:{character_id} | System prompt only | 3600s | First chat request for that character |
chat:char:{character_id} | Full character row (includes system prompt + profile) | 3600s | First chat request for that character |
chat:sess:{session_id} | Session row + last 10 messages + summary | 3600s | First chat request for that session, refreshed after each stream |
chat:book:{book_id} | Title, author, description, category | 3600s | First chat request for that book |
chat:qr:{character_id}:{hash} | Narrative + keyword query | 3600s | First unique phrasing per character |
Caches are loaded sequentially, one key at a time via individual GET calls (no Redis pipelining or MGET). On any miss, the data is fetched from Supabase and written with SETEX.
The query rewrite cache uses the first 16 hex characters of the SHA-256 digest of user_message + last_4_messages as the key. The same phrasings in the same conversational context always hit the cache, making the rewriter a no-op for repeated questions.
Per-Session Lock (Redis)
The per-session write lock prevents two concurrent chat requests for the same session from interleaving their writes to the messages table. It lives entirely in Redis and is invisible to the frontend except through the 429 error.
Lock parameters:
| Parameter | Value | Why |
|---|---|---|
| Key | chat:lock:{session_id} | One lock per session, not per user |
| Value | Random hex token | Stored locally by the worker for CAS release |
| NX | Set only if key does not exist | Mutex semantics |
| EX 30 | 30-second TTL | Bounds the lifetime of a stuck lock |
Stale lock recovery: If a worker crashes mid-stream, the lock key persists in Redis for at most 30 seconds before it expires automatically. The next request for that session will succeed after the TTL elapses. The Lua CAS release prevents a slow worker that gave up and released its token from accidentally deleting a newer worker's lock.
Session Summary Regeneration
After every 10 messages (message_count % 10 == 0), the backend spawns a background task that calls the LLM to regenerate a concise summary of the conversation. The summary is stored in the Redis session cache and used as the third section of the system prompt (after the character persona and relevant passages).
The summary is not persisted to Supabase. If the Redis cache is evicted (3600s TTL), the summary is regenerated from the last 10 messages fetched from Supabase on the next request, without the summary context. The summary regeneration task runs after the stream completes and the lock is released, so it does not block the response to the user.