Each roster name becomes /adventure/who/{token}. Anyone sees the public sheet —
stats and equipped gear, decoded from the detail_json gogobee now hangs on each
board entry — with a live JSON re-poll so an open tab tracks HP and room as they
move. The signed-in owner sees the same page enriched with their private
inventory, vault, house, and pets, unlocked by an ownership join in the new
player_self_detail table (localpart owns token) — Pete never reverses the
anonymous token to decide it. buyerLocalpart is extracted so the storefront and
the ownership check lowercase the session name the same way.
491 lines
24 KiB
Go
491 lines
24 KiB
Go
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
|
|
);
|
|
|
|
-- A signed-in buyer's own euro balance, as of the last snapshot gogobee pushed.
|
|
-- Keyed by localpart (== Authentik preferred_username == the session's Username),
|
|
-- a *separate keyspace* from the anonymous roster tokens on purpose: it is only
|
|
-- ever read for the one authenticated user asking about themselves, so the board
|
|
-- stays anonymous and no endpoint hands out anyone else's number. Advisory only —
|
|
-- the storefront greys out tiers it thinks you can't afford, but the real debit
|
|
-- happens on gogobee at claim time and a stale balance just bounces an order.
|
|
CREATE TABLE IF NOT EXISTS user_euro (
|
|
username TEXT PRIMARY KEY, -- Matrix localpart == session Username
|
|
euro REAL NOT NULL DEFAULT 0,
|
|
snapshot_at INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
|
|
-- A mischief contract a buyer placed from the web storefront, on its way to
|
|
-- gogobee. Pete never touches money and never runs the game rules — it only
|
|
-- records the *intent* and later the verdict gogobee hands back. The status
|
|
-- ladder:
|
|
--
|
|
-- pending -> placed (gogobee debited the buyer and opened a contract)
|
|
-- -> bounced_funds (buyer couldn't actually afford it)
|
|
-- -> bounced_ineligible (target no longer a valid mark: no expedition,
|
|
-- a live contract already, cooldown, cap, ...)
|
|
--
|
|
-- guid is the idempotency key end to end: gogobee passes it to DebitIdem and
|
|
-- stamps it on the contract, so a claim whose ack is lost on the wire can be
|
|
-- retried without charging the buyer twice or opening two contracts. buyer_sub
|
|
-- is the OIDC subject (stable across username changes) and keys "my orders";
|
|
-- buyer_username is what gogobee turns into @username:server. target_token is
|
|
-- the roster token of the mark — the same anonymous token the board renders, so
|
|
-- ordering a hit never needs the victim's real handle.
|
|
CREATE TABLE IF NOT EXISTS mischief_orders (
|
|
guid TEXT PRIMARY KEY,
|
|
buyer_sub TEXT NOT NULL,
|
|
buyer_username TEXT NOT NULL,
|
|
target_token TEXT NOT NULL,
|
|
target_name TEXT NOT NULL, -- display copy, frozen at order time
|
|
tier TEXT NOT NULL,
|
|
signed INTEGER NOT NULL DEFAULT 0, -- 1 = sign openly (+25%), 0 = anonymous
|
|
status TEXT NOT NULL, -- see the ladder above
|
|
detail TEXT, -- gogobee's human note on the verdict
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_mischief_orders_pending ON mischief_orders(status, created_at);
|
|
CREATE INDEX IF NOT EXISTS idx_mischief_orders_buyer ON mischief_orders(buyer_sub, created_at DESC);
|
|
|
|
-- The storefront price list. gogobee is the sole authority on prices and pushes
|
|
-- the whole catalog on the roster tick, so a fee retune reaches the storefront
|
|
-- within a snapshot and Pete never hardcodes a number that can drift. ordinal
|
|
-- preserves the grunt->boss order the push arrived in.
|
|
CREATE TABLE IF NOT EXISTS mischief_tiers (
|
|
key TEXT PRIMARY KEY,
|
|
display TEXT NOT NULL,
|
|
fee INTEGER NOT NULL,
|
|
signed_fee INTEGER NOT NULL,
|
|
blurb TEXT,
|
|
ordinal INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
|
|
-- A player's private, owner-only expansion — inventory, vault, house, pets —
|
|
-- pushed whole by gogobee on the roster tick. Keyed by localpart (== session
|
|
-- Username), a *separate keyspace* from the anonymous roster tokens on purpose:
|
|
-- like user_euro, it is only ever served back to the one authenticated user it
|
|
-- belongs to, never on the public board. token is that player's current roster
|
|
-- token, kept here so the detail page can prove owner↔page by a join without
|
|
-- ever reversing the one-way token — the association lives only in this
|
|
-- owner-private table and never reaches any public response. detail_json is the
|
|
-- {inventory, vault, house, pets} body; it is replaced wholesale each tick, so a
|
|
-- player who drops out of gogobee's push loses their stale self-view.
|
|
CREATE TABLE IF NOT EXISTS player_self_detail (
|
|
localpart TEXT PRIMARY KEY,
|
|
token TEXT NOT NULL,
|
|
detail_json TEXT NOT NULL,
|
|
snapshot_at INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_player_self_detail_token ON player_self_detail(token);
|
|
|
|
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;
|
|
`
|