package storage const schema = ` CREATE TABLE IF NOT EXISTS stories ( id INTEGER PRIMARY KEY AUTOINCREMENT, guid TEXT UNIQUE NOT NULL, headline TEXT NOT NULL, lede TEXT, content TEXT, content_chars INTEGER NOT NULL DEFAULT 0, image_url TEXT, article_url TEXT NOT NULL, url_canonical TEXT, headline_norm TEXT, source TEXT NOT NULL, platforms TEXT, channel TEXT, classified INTEGER NOT NULL DEFAULT 0, paywalled INTEGER NOT NULL DEFAULT 0, seen_at INTEGER NOT NULL, published_at INTEGER ); -- adventure_roster is a *snapshot*, not a log: gogobee POSTs the whole live -- board and it replaces this table wholesale. Rows are state that is currently -- true ("Josie is in holymachina"), which is the one thing the story feed can -- never be — every dispatch there is an accomplishment, and an accomplishment is -- a clipping the moment it lands. -- -- token is gogobee's per-player roster token, not a Matrix handle and not a -- story GUID. Players who ran "!news optout" are omitted from the snapshot -- upstream and so never appear here at all. CREATE TABLE IF NOT EXISTS adventure_roster ( token TEXT PRIMARY KEY, name TEXT NOT NULL, level INTEGER NOT NULL DEFAULT 0, class_race TEXT, status TEXT NOT NULL, -- "expedition" | "idle" zone TEXT, region TEXT, day INTEGER NOT NULL DEFAULT 0, -- expedition day, 0 if idle idle_hours INTEGER NOT NULL DEFAULT 0, -- hours since last player action snapshot_at INTEGER NOT NULL -- when gogobee took the snapshot ); -- The snapshot time lives outside the rows because an *empty* board is -- ambiguous: either nobody is playing, or gogobee has stopped talking to us. A -- MAX(snapshot_at) over zero rows can't tell those apart, and the page must — -- one is "quiet realm", the other is "the wire is down, trust nothing here". CREATE TABLE IF NOT EXISTS adventure_roster_meta ( id INTEGER PRIMARY KEY CHECK (id = 1), snapshot_at INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS post_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, guid TEXT NOT NULL, channel TEXT NOT NULL, event_id TEXT, url_canonical TEXT, posted_at INTEGER NOT NULL, forced INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS round_robin_state ( id INTEGER PRIMARY KEY CHECK (id = 1), last_channel TEXT, last_tick_at INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS reactions ( id INTEGER PRIMARY KEY AUTOINCREMENT, post_guid TEXT NOT NULL, channel TEXT NOT NULL, event_id TEXT NOT NULL, emoji TEXT NOT NULL, user_id TEXT NOT NULL, reacted_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS user_preferences ( user_sub TEXT PRIMARY KEY, prefs TEXT NOT NULL, username TEXT, email TEXT, updated_at INTEGER NOT NULL ); -- Per-user read + bookmark state for signed-in visitors, keyed by OIDC subject. -- One row carries both signals; a NULL timestamp means "not set". A row with -- both timestamps NULL is meaningless and is pruned, so presence of a row means -- the story is read, bookmarked, or both. CREATE TABLE IF NOT EXISTS user_story_state ( user_sub TEXT NOT NULL, story_id INTEGER NOT NULL, read_at INTEGER, bookmarked_at INTEGER, PRIMARY KEY (user_sub, story_id) ); -- Aggregate web usage. page_views holds running view counts keyed by a coarse -- path label ("home", channel slug, …) and the UTC day, so we can report both -- all-time totals and per-day breakdowns without storing any per-request rows. CREATE TABLE IF NOT EXISTS page_views ( path TEXT NOT NULL, day INTEGER NOT NULL, -- unix day (floor(unix / 86400)), UTC views INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (path, day) ); -- Per-source poll health, one row per configured feed (keyed by source name). -- Written on every poll (success and failure) so the owner-facing dashboard can -- show which feeds are healthy without keeping the poller's in-memory state. -- last_success_at / last_item_count survive failures so a broken feed still -- shows when it last worked and how much it last returned. CREATE TABLE IF NOT EXISTS source_health ( source TEXT PRIMARY KEY, last_poll_at INTEGER, -- unix, most recent poll attempt last_success_at INTEGER, -- unix, most recent successful fetch last_error TEXT, -- last failure message ('' when healthy) consecutive_failures INTEGER NOT NULL DEFAULT 0, last_item_count INTEGER NOT NULL DEFAULT 0, -- items in the last successful fetch updated_at INTEGER NOT NULL ); -- Web Push subscriptions for signed-in users, one row per browser/device -- endpoint. p256dh + auth are the client's encryption keys (RFC 8291); the -- server needs them to encrypt each push. last_notified_at is the per-endpoint -- digest watermark: the sender only counts stories seen after it. A user can -- have several endpoints (phone, desktop) — each is notified independently. CREATE TABLE IF NOT EXISTS push_subscriptions ( endpoint TEXT PRIMARY KEY, user_sub TEXT NOT NULL, p256dh TEXT NOT NULL, auth TEXT NOT NULL, created_at INTEGER NOT NULL, last_notified_at INTEGER NOT NULL ); -- Privacy-preserving daily unique estimate. visitor is a salted hash of -- IP+User-Agent; the salt rotates every UTC day and is never persisted, so the -- hashes are irreversible and cannot be linked across days. We keep only enough -- to dedup within a single day, then prune. CREATE TABLE IF NOT EXISTS daily_visitors ( day INTEGER NOT NULL, visitor TEXT NOT NULL, PRIMARY KEY (day, visitor) ); -- Per-story read counts, keyed by story id and UTC day. Incremented whenever a -- visitor opens a story in reader mode (/api/article). The day dimension lets -- us surface "popular this week" without a separate rollup; summing across all -- days gives the all-time count shown on cards. Rows age out with their story -- via the foreign-key-less prune in RunMaintenance. CREATE TABLE IF NOT EXISTS story_views ( story_id INTEGER NOT NULL, day INTEGER NOT NULL, -- unix day (floor(unix / 86400)), UTC views INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (story_id, day) ); -- --------------------------------------------------------------------------- -- games.parodia.dev -- -- The invariant the whole casino rests on: a euro is either in gogobee's -- euro_balances or in Pete's chip escrow, never both. It crosses between them -- only via a GUID-idempotent claim, and Pete never writes a euro balance — -- gogobee does, when it claims the escrow row and tells us how it went. -- --------------------------------------------------------------------------- -- A player's chips: euros that have crossed into the casino and haven't crossed -- back yet. 1:1 with euros. Keyed by Matrix user id, because that's the identity -- gogobee's ledger uses and the one an Authentik username maps onto. CREATE TABLE IF NOT EXISTS game_chips ( matrix_user TEXT PRIMARY KEY, chips INTEGER NOT NULL DEFAULT 0, -- Advisory only, and stale by design: the last euro balance gogobee told us -- about. Displayed, never trusted. The authoritative check is the debit at -- claim time, which happens on gogobee's box against gogobee's ledger. euro_balance REAL, last_played INTEGER NOT NULL DEFAULT 0, -- unix; the reaper reads this updated_at INTEGER NOT NULL DEFAULT 0 ); -- One crossing of the euro/chip border, in either direction. -- -- requested -> claimed -> funded (buy-in: gogobee debited, chips spendable) -- -> rejected (buy-in: insufficient funds, no chips) -- requested -> claimed -> settled (cash-out: chips gone, euros credited) -- -- The guid is the idempotency key end to end: it's what gogobee passes to -- DebitIdem/CreditIdem, so a claim whose ack is lost on the wire can be retried -- without the player paying twice. CREATE TABLE IF NOT EXISTS game_escrow ( guid TEXT PRIMARY KEY, matrix_user TEXT NOT NULL, kind TEXT NOT NULL, -- 'buyin' | 'cashout' amount INTEGER NOT NULL, -- euros == chips state TEXT NOT NULL, -- see the ladder above reason TEXT, -- 'insufficient_funds', when rejected balance_after REAL, -- gogobee's euro balance after the move created_at INTEGER NOT NULL, claimed_at INTEGER, -- when gogobee took it; drives the re-poll updated_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_game_escrow_state ON game_escrow(state, created_at); CREATE INDEX IF NOT EXISTS idx_game_escrow_user ON game_escrow(matrix_user, created_at DESC); -- Every hand played, for money. This is the audit trail: seeds so a disputed -- hand can be re-dealt exactly as it fell, rake so the house's take is -- accountable, and enough shape to answer "how fast is this economy actually -- moving" before the answer becomes a problem. CREATE TABLE IF NOT EXISTS game_hands ( id INTEGER PRIMARY KEY AUTOINCREMENT, matrix_user TEXT NOT NULL, game TEXT NOT NULL, -- 'blackjack' bet INTEGER NOT NULL, payout INTEGER NOT NULL, -- chips returned, net of rake rake INTEGER NOT NULL, outcome TEXT NOT NULL, seed1 INTEGER NOT NULL, -- the shoe, reproducible seed2 INTEGER NOT NULL, played_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_game_hands_user ON game_hands(matrix_user, played_at DESC); CREATE INDEX IF NOT EXISTS idx_game_hands_played ON game_hands(played_at); -- The hand a player is in the middle of. One per player: you cannot be dealt a -- second hand while chips are riding on the first. -- -- The state column is the engine's State, serialized whole — shoe included. It -- lives here rather than in memory because Pete redeploys often, and a player -- whose stake has already been taken must find their cards where they left them -- rather than a table that has forgotten them. It is also why the deck never -- goes to the browser: the authoritative shoe is this row, on the server. CREATE TABLE IF NOT EXISTS game_live_hands ( matrix_user TEXT PRIMARY KEY, game TEXT NOT NULL, -- 'blackjack' state TEXT NOT NULL, -- JSON: the engine's State seed1 INTEGER NOT NULL, -- carried to the audit log when it settles seed2 INTEGER NOT NULL, -- Set when the player is sitting at a shared table rather than playing alone. -- The engine state then lives in game_tables.state, not here, and this row is -- purely the occupancy claim: its PRIMARY KEY is what stops one player being -- in two games at once, and it is the row the cash-out check reads. Making -- game_seats a second uniqueness domain instead would be a split brain — see -- the comment on game_seats. table_id TEXT, updated_at INTEGER NOT NULL ); -- --------------------------------------------------------------------------- -- Shared tables: the casino with more than one person at it. -- --------------------------------------------------------------------------- -- A table other people can sit at. The state column is the engine's State, -- exactly as game_live_hands holds it for a solo game — one blob for the whole -- felt, because a pot is not divisible into per-player rows. -- -- version is the concurrency authority, and the mutex in the web layer is only -- an optimisation on top of it. Every state write is a conditional UPDATE -- against the version the writer read; zero rows affected means somebody moved -- first. This has to live in the database rather than in a mutex map because a -- mutex does not survive a redeploy — during a drain, two processes hold two -- different mutexes over the same row and both believe they are alone. CREATE TABLE IF NOT EXISTS game_tables ( id TEXT PRIMARY KEY, game TEXT NOT NULL, -- 'holdem' | 'uno' | 'blackjack' tier TEXT NOT NULL, -- the stake, as that game names it state TEXT NOT NULL, -- JSON: the engine's State seed1 INTEGER NOT NULL, seed2 INTEGER NOT NULL, phase TEXT NOT NULL, -- the engine's phase, lifted out so the lobby can read it hand_no INTEGER NOT NULL DEFAULT 0, -- with id, the identity of one hand: the payout key version INTEGER NOT NULL DEFAULT 0, -- Unix seconds by which the seat to act must act, or 0 for no clock. The turn -- clock scans this. It is set only when the turn lands on a human: bots resolve -- inside ApplyMove and are never waited for. deadline INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_game_tables_due ON game_tables(deadline) WHERE deadline > 0; CREATE INDEX IF NOT EXISTS idx_game_tables_lobby ON game_tables(game, updated_at DESC); -- Who is sitting where. A seat with no matrix_user is a bot. -- -- This is deliberately *not* a uniqueness domain for players: there is no unique -- index on matrix_user, and there must not be one. Occupancy is decided by -- game_live_hands' primary key, which already stops a player being in two games, -- already makes a double-clicked join a 409, and is already what the cash-out -- check reads. A second domain that could disagree with the first would silently -- switch all three off — the worst of them being a player who cashes out to zero -- while sitting at a poker table with chips in the pot. -- -- staked is what the player brought to the table and has not yet taken home. It -- is the chip-conservation anchor: the chips are off their game_chips stack and -- inside the table blob, where the idle reaper cannot see them. CREATE TABLE IF NOT EXISTS game_seats ( table_id TEXT NOT NULL, seat INTEGER NOT NULL, matrix_user TEXT, -- NULL for a bot name TEXT NOT NULL, staked INTEGER NOT NULL DEFAULT 0, -- Set once a human's clock has run out on them. An absent human is not a bot, -- but the bot loop has to be allowed past their seat or a table with three -- ghosts spends a minute an orbit folding air. They come back the moment they act. away INTEGER NOT NULL DEFAULT 0, last_seen INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (table_id, seat) ); CREATE INDEX IF NOT EXISTS idx_game_seats_user ON game_seats(matrix_user) WHERE matrix_user IS NOT NULL; -- There is no payout ledger here, and its absence is deliberate — the design -- called for one and the money model made it unnecessary. Chips cross into a -- table when a player sits down and back out when they get up; a hand ending -- moves the pot *within* the state blob and credits nobody's game_chips row. So -- there is no money write to make idempotent: a settle is a state write, -- conditional on the version, and a replayed one affects zero rows and rolls -- back. See the header of internal/storage/tables.go. -- Chat on the felt. Messages only — no typing indicators, which is the one thing -- that would have justified a socket. It does not mirror into Matrix. -- -- hand_no is kept against every line for a reason: at a table of real people, -- collusion looks like chat, and the only way to ever answer that question is to -- be able to read what was said during the hand it was said in. CREATE TABLE IF NOT EXISTS game_chat ( id INTEGER PRIMARY KEY AUTOINCREMENT, table_id TEXT NOT NULL, hand_no INTEGER NOT NULL, matrix_user TEXT, -- NULL when the house is talking name TEXT NOT NULL, body TEXT NOT NULL, said_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_game_chat_table ON game_chat(table_id, id); -- The trivia bank: questions pulled from the Open Trivia Database ahead of time, -- so that asking one is a local read. -- -- Prefetched rather than fetched per question because a trivia ladder asks a -- question every fifteen seconds with money on a clock the player is scored -- against. A live fetch would put somebody else's latency and rate limit inside -- that clock. The refill is a slow background drip (internal/opentdb); a round -- never waits on it. -- -- The question text is UNIQUE, which is the whole dedup strategy: OpenTDB hands back -- overlapping batches and the bank would otherwise fill up with the same forty -- questions. correct/incorrect are stored as the API gives them; the *shuffle* -- happens in the engine, per game, against that game's seed — so where the right -- answer sits in this table tells a player nothing. CREATE TABLE IF NOT EXISTS trivia_questions ( id INTEGER PRIMARY KEY AUTOINCREMENT, difficulty TEXT NOT NULL, -- 'easy' | 'medium' | 'hard' category TEXT NOT NULL, question TEXT NOT NULL UNIQUE, correct TEXT NOT NULL, incorrect TEXT NOT NULL, -- JSON array of the three wrong answers fetched_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_trivia_difficulty ON trivia_questions(difficulty); CREATE UNIQUE INDEX IF NOT EXISTS idx_post_log_guid_channel ON post_log(guid, channel); CREATE INDEX IF NOT EXISTS idx_post_log_event_id ON post_log(event_id); CREATE INDEX IF NOT EXISTS idx_post_log_channel_posted ON post_log(channel, posted_at); CREATE INDEX IF NOT EXISTS idx_post_log_canonical_channel ON post_log(url_canonical, channel, posted_at); CREATE INDEX IF NOT EXISTS idx_stories_classified_source ON stories(classified, source); CREATE INDEX IF NOT EXISTS idx_stories_channel_classified_seen ON stories(channel, classified, seen_at DESC); CREATE INDEX IF NOT EXISTS idx_stories_classified_seen ON stories(classified, seen_at DESC); CREATE INDEX IF NOT EXISTS idx_stories_image_url ON stories(image_url) WHERE image_url IS NOT NULL AND image_url <> ''; CREATE UNIQUE INDEX IF NOT EXISTS idx_stories_url_canonical ON stories(url_canonical) WHERE url_canonical IS NOT NULL AND url_canonical <> ''; CREATE UNIQUE INDEX IF NOT EXISTS idx_stories_source_headline_norm ON stories(source, headline_norm) WHERE headline_norm IS NOT NULL AND headline_norm <> ''; CREATE INDEX IF NOT EXISTS idx_reactions_post_guid ON reactions(post_guid); CREATE UNIQUE INDEX IF NOT EXISTS idx_reactions_event_id ON reactions(event_id); CREATE INDEX IF NOT EXISTS idx_page_views_day ON page_views(day); CREATE INDEX IF NOT EXISTS idx_daily_visitors_day ON daily_visitors(day); CREATE INDEX IF NOT EXISTS idx_story_views_day ON story_views(day); CREATE INDEX IF NOT EXISTS idx_user_state_bookmarks ON user_story_state(user_sub, bookmarked_at) WHERE bookmarked_at IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_user_state_reads ON user_story_state(user_sub, read_at) WHERE read_at IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_push_sub_user ON push_subscriptions(user_sub); ` const ftsSchema = ` CREATE VIRTUAL TABLE stories_fts USING fts5( guid UNINDEXED, headline, lede, source UNINDEXED, platforms UNINDEXED, content='stories', content_rowid='id' ); ` const ftsTriggers = ` CREATE TRIGGER stories_fts_insert AFTER INSERT ON stories BEGIN INSERT INTO stories_fts(rowid, guid, headline, lede, source, platforms) VALUES (new.id, new.guid, new.headline, new.lede, new.source, new.platforms); END; CREATE TRIGGER stories_fts_delete AFTER DELETE ON stories BEGIN INSERT INTO stories_fts(stories_fts, rowid, guid, headline, lede, source, platforms) VALUES ('delete', old.id, old.guid, old.headline, old.lede, old.source, old.platforms); END; CREATE TRIGGER stories_fts_update AFTER UPDATE ON stories BEGIN INSERT INTO stories_fts(stories_fts, rowid, guid, headline, lede, source, platforms) VALUES ('delete', old.id, old.guid, old.headline, old.lede, old.source, old.platforms); INSERT INTO stories_fts(rowid, guid, headline, lede, source, platforms) VALUES (new.id, new.guid, new.headline, new.lede, new.source, new.platforms); END; `