PostgreSQL is the durable store for all data that must survive a server restart or Redis flush: player accounts, profiles, completed game history, password-reset tokens, friendships, and player blocks. The schema is managed through five plain SQL migration files inDocumentation Index
Fetch the complete documentation index at: https://mintlify.com/Antonelli-Tech-Solutions/spades/llms.txt
Use this file to discover all available pages before exploring further.
db/migrations/, applied in order before the server starts. There is no migration framework — each file is idempotent (uses IF NOT EXISTS guards) and can be safely re-run against an already-migrated database.
Running migrations
Apply the migrations in order usingpsql. Set DATABASE_URL to your connection string before running:
Schema reference
001 — Players and email verification tokens
This migration creates the two tables required for account creation and email verification. Theplayers table is the root identity record — every other table references it. The email_verification_tokens table holds one-time tokens emailed to new registrants; the token is deleted once the address is verified.
players.is_verified defaults to false; the POST /api/auth/login endpoint rejects login with 403 until the flag is set to true by GET /api/auth/verify-email. The email_verification_tokens index on player_id speeds up the resend-verification lookup, which queries by player rather than by token.
002 — Player profiles and game history
This migration creates three tables that together provide the public profile view returned byGET /api/profile/:playerId. player_profiles stores cosmetic settings (avatar icon, felt colour, card back). games records each completed game with final team scores. game_players is the join table linking a player to a specific game, capturing their seat, team, and win/loss outcome.
game_players index on player_id is critical for the profile endpoint, which fetches recent games for a given player across potentially many game rows. The team column holds NS or EW; won is derived from the final scores at the time the game is persisted.
003 — Password reset tokens
This migration creates the table used by the forgot-password / reset-password flow. A token is generated when a player submitsPOST /api/auth/forgot-password and emailed as a link. The token is validated and deleted when the player submits POST /api/auth/reset-password. Tokens expire after one hour (expires_at is checked server-side before the password is updated).
player_id allows the server to quickly check whether a player already has a pending reset token (to avoid accumulating stale rows) and to clean up old tokens by player rather than by scanning the whole table.
004 — Friendships
This migration creates thefriendships table, which tracks both pending friend requests and accepted friendships in a single row per directed pair. The status column holds 'pending' until the addressee accepts; it is updated to 'accepted' by POST /api/friends/accept. The migration includes upgrade logic to handle existing installations from an earlier schema that used different column names.
friendships_pair_unique_bidi) is important: the directional UNIQUE(requester_id, addressee_id) constraint only prevents Alice from sending Bob a second request, but does not prevent a race where Alice sends Bob a request at the same time Bob sends Alice a request. The LEAST/GREATEST index collapses both directions into the same key.
005 — Player blocks
This migration creates theplayer_blocks table used by the blocking system. A block record prevents the blocked player from sending friend requests and is checked before any social action. Blocking is directional — blocker_id blocked blocked_id — but the isBlockedEitherDirection helper checks both directions so the effect is mutual.
UNIQUE(blocker_id, blocked_id) constraint makes blocking idempotent — attempting to block an already-blocked player returns 201 silently without creating a duplicate row.
Redis ephemeral data
The following data is stored exclusively in Redis and is not persisted to PostgreSQL. It is intentionally ephemeral: it is reconstructed from in-progress game state or simply expires.| Key pattern | Contents |
|---|---|
session:{sessionId} | Session record: playerId, email, username |
table:{tableId} | Full table object: seats, observers, status, visibility, join policy, host |
game:{tableId} | Full game state: hands, bids, tricks, scores, phase |
presence:{playerId} | Player presence: { status: 'online' | 'playing', tableId } |
lobby:tables | Hash of public waiting tables for the lobby browser |
lobby:all | Hash of all active tables (used for presence reconciliation and player-to-table lookup) |
joinlink:{token} | Single-use join link: { tableId, createdAt } |
spectatorlink:{token} | Multi-use spectator link: { tableId } |
invite:{tableId}:{playerId} | Active invite duplicate-check key |
invite:id:{inviteId} | Full invite record (used by the decline endpoint) |
invited:{tableId} | Set of player IDs invited to a table (bypasses join policy) |
ratelimit:* | Per-IP rate-limit counters (auth and social endpoints) |
All Redis keys related to a table are cleaned up when the table is terminated — either by the host, because all players left, or on expiry. Sessions expire with a TTL set at creation time. Presence keys carry a 1-hour safety-net TTL so stale keys cannot accumulate if a server crashes before the cleanup on disconnect fires.