Files
AetherTable/database/postgres/03_campaign_persistence.sql
John SmithandClaude 6aef9e73b8 feat(wave12): Postgres persistence & campaign save/load
- storage.py: dual-mode store — PostgresStore (asyncpg pool) when
  DATABASE_URL is reachable, MemoryStore fallback otherwise; idempotent
  _ensure_tables() DDL at startup; PBKDF2 credential helpers centralized
- 03_campaign_persistence.sql: narrative_state.users, campaigns (anchors
  the previously free-floating campaign_id), campaign_saves (JSONB
  snapshots) + owner/updated index — also auto-run by compose init
- auth routes now persist users across restarts (verified live: seeded GM
  account survives process restarts via Postgres)
- new token-guarded API: POST /api/v1/campaign/save (upsert by owner+name),
  GET /campaign/saves, GET /campaign/save/{id}, DELETE /campaign/save/{id}
- client: CampaignSaveModal (save/list/load/delete with round + timestamp),
  Campaign Saves entry in Table Tools dropdown, snapshot build/apply in App
- fix compose postgres crash-loop on SELinux systems (:ro,z relabel flag)
- tests: memory-mode API CRUD always runs; PG end-to-end incl. cross-owner
  denial and durability-across-reinit skips without DATABASE_URL
  (58 pytest pass with Postgres up, cargo clean, MCR 100%)

Co-Authored-By: Claude <[email protected]>
2026-08-23 10:53:40 -04:00

41 lines
1.7 KiB
SQL

-- Wave 12: Campaign Persistence
-- Idempotent DDL for identity and campaign save/load. The orchestrator also
-- executes this at startup (storage.py::_ensure_tables) because the docker
-- compose init directory only runs on first volume creation.
CREATE SCHEMA IF NOT EXISTS narrative_state;
CREATE TABLE IF NOT EXISTS narrative_state.users (
user_id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
username TEXT NOT NULL,
display_name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'player'
CHECK (role IN ('gm', 'player', 'spectator', 'admin')),
password_hash TEXT NOT NULL,
salt_hex TEXT NOT NULL,
assigned_token_ids JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS narrative_state.campaigns (
campaign_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_user_id TEXT NOT NULL REFERENCES narrative_state.users(user_id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS narrative_state.campaign_saves (
save_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
campaign_id UUID REFERENCES narrative_state.campaigns(campaign_id) ON DELETE CASCADE,
owner_user_id TEXT NOT NULL REFERENCES narrative_state.users(user_id) ON DELETE CASCADE,
save_name TEXT NOT NULL,
snapshot JSONB NOT NULL,
round_number INT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_campaign_saves_owner_updated
ON narrative_state.campaign_saves (owner_user_id, updated_at DESC);