The extract pre-check is gone. It read a snapshot up to two minutes behind and still got the last word, so somebody who set out over Matrix during a lagging roster push was told they weren't on an expedition for a run gogobee would happily have ended. Same call abandon and leave already made: let it through and let rejected_not_running be the answer. The siege_join check stays, because whether a boss is camped outside town is town-wide and runs on a day-or-longer clock, but it now reads one column through SiegeIsCamped instead of loading every defender row and the whole history to look at one flag. The war-room history insert is OR REPLACE. boss_id is the primary key and it was never settled whether gogobee means the siege instance or the boss type by it, so a duplicate pair used to fail the transaction carrying the live boss and the muster too and freeze the war room on the last good snapshot. A dropped history row is the smaller failure; the open question is noted in the schema. offersToUndo's guard didn't cover the case its comment claimed. A gogobee too old to push seats sends a valid blob with no party key, which decodes to the same empty slice as a solo run, and a party member got shown the button that throws away everyone's day. That needs a new field, so whoDetail gains party_known and the flag gates the empty-list branch alone; the branch that reads the viewer's own seat is self-evidencing and keeps working against any sender. gogobee's half is written up in adventure_party_known_flag.md. And an empty offer list no longer claims "you're already out there", which Pete can't actually know from a game box too old to push offers at all.
852 lines
44 KiB
Go
852 lines
44 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
|
|
);
|
|
|
|
-- adventure_events is the structured residue of a dispatch, and it is the one
|
|
-- adventure table that is a *log* rather than a snapshot. That inversion is the
|
|
-- point. The roster answers "where is Josie now" and is replaced every tick;
|
|
-- this answers "what has Josie ever done", which no snapshot can, because each
|
|
-- push throws the last one away.
|
|
--
|
|
-- It exists because the facts were already arriving and we were burning them.
|
|
-- gogobee sends boss/opponent/zone/outcome on every fact; renderAdventure melted
|
|
-- them into a sentence and only the sentence was kept, so "how many bosses has
|
|
-- she downed" was answerable only by parsing English back out of a headline.
|
|
-- These rows are that fact, kept as fact. The story row remains the thing people
|
|
-- read; this is the thing we can count.
|
|
--
|
|
-- Keyed on guid, the same idempotency key as stories: a gogobee retry must not
|
|
-- double-count a kill. subject/opponent are *character names*, not roster tokens,
|
|
-- because that is what a fact carries and what the feed already prints in public.
|
|
-- Names are therefore the join back to a board row (roster.name -> token), which
|
|
-- is weaker than an id: a renamed or recycled character takes their history with
|
|
-- them. gogobee doesn't put a stable character id on the wire today, and inventing
|
|
-- one Pete-side would only be a guess at which two names were the same person.
|
|
CREATE TABLE IF NOT EXISTS adventure_events (
|
|
guid TEXT PRIMARY KEY, -- == stories.guid; the dispatch this fact rendered into
|
|
event_type TEXT NOT NULL,
|
|
tier TEXT, -- "priority" | "bulletin"
|
|
subject TEXT, -- character name: the one it happened to
|
|
opponent TEXT, -- character name: the other player, when there is one
|
|
boss TEXT, -- game-authored monster name, not player-controlled
|
|
zone TEXT,
|
|
region TEXT,
|
|
level INTEGER NOT NULL DEFAULT 0,
|
|
tally INTEGER NOT NULL DEFAULT 0, -- the fact's Count: defenders, days in, ...
|
|
outcome TEXT,
|
|
milestone TEXT,
|
|
stakes TEXT, -- free-text noun the fact is about: a bounty, a found treasure's name
|
|
|
|
actors TEXT, -- JSON array; the fact-guard allow-list, kept for audit
|
|
-- The expedition this dispatch is the ending of, when it is the ending of one
|
|
-- (a clear, a retreat, a death). It is the join from "how it went" to "what
|
|
-- happened", and it is the reason Pete keeps a finished run's beats for two
|
|
-- weeks while only *showing* them for six hours: the dispatch outlives the run
|
|
-- it announced, and a story that can't reach its own log is the whole point
|
|
-- of the log going missing.
|
|
run_id TEXT,
|
|
occurred_at INTEGER NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_adv_events_subject ON adventure_events(subject, occurred_at DESC);
|
|
CREATE INDEX IF NOT EXISTS idx_adv_events_opponent ON adventure_events(opponent, occurred_at DESC) WHERE opponent IS NOT NULL AND opponent <> '';
|
|
CREATE INDEX IF NOT EXISTS idx_adv_events_type ON adventure_events(event_type, occurred_at DESC);
|
|
|
|
-- The Siege. Three tables, all fed by one gogobee push and all replaced whole,
|
|
-- because the Siege is state, not history — the same contract as the roster.
|
|
--
|
|
-- The split is by lifetime, not by convenience. adventure_siege is the single
|
|
-- live boss (a CHECK-pinned one-row table, like adventure_roster_meta, so "no
|
|
-- Siege camped" is a row saying active=0 rather than an ambiguous empty table).
|
|
-- adventure_siege_defenders is the muster for that one boss and dies with it.
|
|
-- adventure_siege_history outlives both, and is the reason the current Siege
|
|
-- feels like it counts: a health bar with nothing behind it is a progress bar.
|
|
CREATE TABLE IF NOT EXISTS adventure_siege (
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
active INTEGER NOT NULL DEFAULT 0,
|
|
boss_id INTEGER NOT NULL DEFAULT 0,
|
|
boss_name TEXT NOT NULL DEFAULT '',
|
|
tier INTEGER NOT NULL DEFAULT 0,
|
|
hp_current INTEGER NOT NULL DEFAULT 0,
|
|
hp_max INTEGER NOT NULL DEFAULT 0,
|
|
starts_at INTEGER NOT NULL DEFAULT 0,
|
|
ends_at INTEGER NOT NULL DEFAULT 0,
|
|
bouts_today INTEGER NOT NULL DEFAULT 0,
|
|
snapshot_at INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
|
|
-- pos is the push order, which is gogobee's ranking (damage desc). Kept as the
|
|
-- key rather than the token because an opted-out defender carries NO token — the
|
|
-- board shows their rank and their damage as "an adventurer" and offers no link,
|
|
-- so several rows can legitimately be tokenless and they must not collide.
|
|
CREATE TABLE IF NOT EXISTS adventure_siege_defenders (
|
|
pos INTEGER PRIMARY KEY,
|
|
token TEXT NOT NULL DEFAULT '',
|
|
name TEXT NOT NULL,
|
|
level INTEGER NOT NULL DEFAULT 0,
|
|
fights INTEGER NOT NULL DEFAULT 0,
|
|
damage INTEGER NOT NULL DEFAULT 0,
|
|
fought_today INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
|
|
-- Open question, never confirmed with gogobee: whether boss_id identifies the
|
|
-- siege instance or the boss TYPE. SiegeBarForBoss matches history on boss_name
|
|
-- plus the nearest ended_at and its comment says "the same boss comes back month
|
|
-- after month", which reads like a type — in which case this key collides on the
|
|
-- second visit. ReplaceSiege inserts OR REPLACE so a collision costs one history
|
|
-- row instead of the whole push; settle the meaning before relying on the key.
|
|
CREATE TABLE IF NOT EXISTS adventure_siege_history (
|
|
boss_id INTEGER PRIMARY KEY,
|
|
boss_name TEXT NOT NULL,
|
|
tier INTEGER NOT NULL DEFAULT 0,
|
|
outcome TEXT NOT NULL, -- "defeated" | "survived"
|
|
hp_remaining INTEGER NOT NULL DEFAULT 0,
|
|
hp_max INTEGER NOT NULL DEFAULT 0,
|
|
defenders INTEGER NOT NULL DEFAULT 0,
|
|
mvp TEXT NOT NULL DEFAULT '',
|
|
mvp_fights INTEGER NOT NULL DEFAULT 0,
|
|
ended_at INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
|
|
-- The expedition liveblog. Two tables, and — unlike everything else gogobee
|
|
-- pushes — these are append-only history, not a replaceable snapshot. A beat is
|
|
-- something that HAPPENED; there is no later truth that corrects it, only more
|
|
-- of it.
|
|
--
|
|
-- adventure_run is the header, assembled from the run's beats rather than pushed
|
|
-- as its own object: the "start" beat opens it and the "end" beat closes it.
|
|
-- That means a run whose start beat never arrived still gets a row (created by
|
|
-- whatever beat did arrive) and is simply unattributed — the log survives
|
|
-- nameless instead of being dropped for want of a name.
|
|
--
|
|
-- summary is the one piece of PROSE anywhere in the liveblog. Every log line is
|
|
-- assembled by Pete out of a beat's own nouns and numbers; this is gogobee's LLM
|
|
-- reading the finished run back and saying what it was *about*, which is a
|
|
-- judgement no template can make. It arrives late — its own beat, a tick or two
|
|
-- after the run ends — and it is optional forever: with the model off, the report
|
|
-- is the log plus the numbers, which is still the report.
|
|
CREATE TABLE IF NOT EXISTS adventure_run (
|
|
run_id TEXT PRIMARY KEY,
|
|
token TEXT NOT NULL DEFAULT '', -- public board token; '' = unattributed
|
|
name TEXT NOT NULL DEFAULT '',
|
|
level INTEGER NOT NULL DEFAULT 0,
|
|
zone TEXT NOT NULL DEFAULT '',
|
|
total_rooms INTEGER NOT NULL DEFAULT 0,
|
|
started_at INTEGER NOT NULL DEFAULT 0,
|
|
updated_at INTEGER NOT NULL DEFAULT 0,
|
|
ended_at INTEGER NOT NULL DEFAULT 0, -- 0 = still walking
|
|
outcome TEXT NOT NULL DEFAULT '', -- cleared|died|retreated|abandoned
|
|
summary TEXT NOT NULL DEFAULT '' -- LLM run summary, post prose-guard
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_adv_run_token ON adventure_run(token, started_at DESC);
|
|
|
|
-- (run_id, seq) is the identity, so a re-sent batch collapses on the primary key
|
|
-- and needs no content comparison. seq is gogobee's monotonic counter, which is
|
|
-- also the render order — beats can arrive out of order across two batches and
|
|
-- still read correctly.
|
|
CREATE TABLE IF NOT EXISTS adventure_run_beat (
|
|
run_id TEXT NOT NULL,
|
|
seq INTEGER NOT NULL,
|
|
kind TEXT NOT NULL,
|
|
occurred_at INTEGER NOT NULL DEFAULT 0,
|
|
room INTEGER NOT NULL DEFAULT 0,
|
|
total_rooms INTEGER NOT NULL DEFAULT 0,
|
|
room_kind TEXT NOT NULL DEFAULT '',
|
|
target TEXT NOT NULL DEFAULT '',
|
|
outcome TEXT NOT NULL DEFAULT '',
|
|
amount INTEGER NOT NULL DEFAULT 0,
|
|
qty INTEGER NOT NULL DEFAULT 0,
|
|
hp INTEGER NOT NULL DEFAULT 0,
|
|
hp_max INTEGER NOT NULL DEFAULT 0,
|
|
crits INTEGER NOT NULL DEFAULT 0,
|
|
fumbles INTEGER NOT NULL DEFAULT 0,
|
|
region TEXT NOT NULL DEFAULT '',
|
|
-- prose is carried by exactly one beat kind ("summary") and is never rendered
|
|
-- as a log line. It lives on the beat rather than on its own endpoint so the
|
|
-- summary inherits the whole channel: idempotent on (run_id, seq), retried
|
|
-- until delivered, and impossible to attach to a run that doesn't exist.
|
|
prose TEXT NOT NULL DEFAULT '',
|
|
PRIMARY KEY (run_id, seq)
|
|
);
|
|
|
|
-- The realm: the world map, the hall of firsts, and the board. Four tables from
|
|
-- one gogobee push, all replaced whole — the same contract as the roster and the
|
|
-- Siege, and for the same reason. Every row here is a *derived* answer (how many
|
|
-- clears, who was first, who is inside right now) recomputed on the game box from
|
|
-- its own run history. Pete keeping a stale one and merging into it would let a
|
|
-- correction upstream leave a wrong number here permanently.
|
|
--
|
|
-- Unlike the Siege there is no live/history lifetime split, because none of this
|
|
-- has a lifetime: a zone does not end. What varies is only how often it changes,
|
|
-- and that is handled on the gogobee side by pushing every ten minutes instead of
|
|
-- every two.
|
|
--
|
|
-- pos is the push order throughout, kept as the key for the same reason the siege
|
|
-- muster does: an opted-out player carries NO token, so several rows can
|
|
-- legitimately be tokenless and must not collide on one.
|
|
CREATE TABLE IF NOT EXISTS adventure_realm_zone (
|
|
pos INTEGER PRIMARY KEY, -- gogobee's design-doc zone order
|
|
zone_id TEXT NOT NULL,
|
|
display TEXT NOT NULL,
|
|
tier INTEGER NOT NULL DEFAULT 0,
|
|
level_min INTEGER NOT NULL DEFAULT 0,
|
|
level_max INTEGER NOT NULL DEFAULT 0,
|
|
faction TEXT NOT NULL DEFAULT '',
|
|
atmosphere TEXT NOT NULL DEFAULT '',
|
|
postgame INTEGER NOT NULL DEFAULT 0,
|
|
first_by TEXT NOT NULL DEFAULT '',
|
|
first_token TEXT NOT NULL DEFAULT '',
|
|
first_at INTEGER NOT NULL DEFAULT 0,
|
|
clears INTEGER NOT NULL DEFAULT 0,
|
|
clearers INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
|
|
-- Who is standing in a zone right now. Its own table rather than a JSON blob on
|
|
-- the zone row so the map can be drawn with one join and "there are people in
|
|
-- there" is a count, not a parse.
|
|
CREATE TABLE IF NOT EXISTS adventure_realm_occupant (
|
|
pos INTEGER PRIMARY KEY,
|
|
zone_id TEXT NOT NULL,
|
|
token TEXT NOT NULL DEFAULT '',
|
|
name TEXT NOT NULL,
|
|
level INTEGER NOT NULL DEFAULT 0,
|
|
day INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_adv_realm_occ_zone ON adventure_realm_occupant(zone_id);
|
|
|
|
-- The hall of firsts: every thing that has happened in the realm exactly once.
|
|
-- holder is empty when the game can no longer say who did it (a treasure found
|
|
-- and later discarded leaves no owner anywhere) — an unattributed first is still
|
|
-- a first and is rendered as one.
|
|
CREATE TABLE IF NOT EXISTS adventure_realm_first (
|
|
pos INTEGER PRIMARY KEY, -- gogobee's order: oldest first
|
|
kind TEXT NOT NULL, -- "zone" | "treasure" | whatever comes next
|
|
target TEXT NOT NULL,
|
|
display TEXT NOT NULL,
|
|
tier INTEGER NOT NULL DEFAULT 0,
|
|
holder TEXT NOT NULL DEFAULT '',
|
|
token TEXT NOT NULL DEFAULT '',
|
|
at_unix INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
|
|
-- The board. pos IS the rank, and the ranking is gogobee's — the ordering is a
|
|
-- statement about what the game values, and the game gets to make it.
|
|
CREATE TABLE IF NOT EXISTS adventure_realm_standing (
|
|
pos INTEGER PRIMARY KEY,
|
|
token TEXT NOT NULL DEFAULT '',
|
|
name TEXT NOT NULL,
|
|
level INTEGER NOT NULL DEFAULT 0,
|
|
class_race TEXT NOT NULL DEFAULT '',
|
|
deepest_tier INTEGER NOT NULL DEFAULT 0,
|
|
clears INTEGER NOT NULL DEFAULT 0,
|
|
zones INTEGER NOT NULL DEFAULT 0,
|
|
firsts INTEGER NOT NULL DEFAULT 0,
|
|
siege_damage INTEGER NOT NULL DEFAULT 0,
|
|
siege_fights INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
|
|
-- One row, like adventure_siege: when the realm last arrived. Its own table
|
|
-- because "gogobee has never pushed a realm" and "gogobee pushed a realm that is
|
|
-- empty" are different states, and the page says different things about them.
|
|
CREATE TABLE IF NOT EXISTS adventure_realm_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
|
|
);
|
|
|
|
-- An equip/unequip an owner asked for from their own detail page, on its way to
|
|
-- gogobee. This is the one adventure feature that carries intent *back* to the
|
|
-- game box; it does it the mischief way, with no new network route — Pete records
|
|
-- the intent, gogobee polls and applies it against its own equipment tables, and
|
|
-- pushes back a verdict. The status ladder:
|
|
--
|
|
-- pending -> applied (worn or removed; detail says how)
|
|
-- -> rejected_not_owned (the item left the pack before gogobee got here)
|
|
-- -> rejected_not_worn (unequip of an already-empty slot)
|
|
-- -> rejected_not_equippable (the item has no slot to fill)
|
|
--
|
|
-- guid is the idempotency key end to end. Note the game action is NOT naturally
|
|
-- idempotent (equipping consumes an inventory row), so gogobee short-circuits on
|
|
-- this guid before it mutates — unlike mischief, whose action converges on its
|
|
-- own. owner_sub is the OIDC subject (stable across renames) and keys "my orders";
|
|
-- owner_localpart is the Matrix localpart gogobee turns into the MXID of the
|
|
-- character to dress. item_id is the adventure_inventory row id for an equip (the
|
|
-- table is AUTOINCREMENT, so a stale id misses cleanly rather than hitting the
|
|
-- wrong item); an unequip keys on slot alone. character_name and item_name are
|
|
-- frozen display copy gogobee ignores.
|
|
CREATE TABLE IF NOT EXISTS equip_orders (
|
|
guid TEXT PRIMARY KEY,
|
|
owner_sub TEXT NOT NULL,
|
|
owner_localpart TEXT NOT NULL,
|
|
character_name TEXT NOT NULL DEFAULT '',
|
|
item_id INTEGER NOT NULL DEFAULT 0,
|
|
item_name TEXT NOT NULL DEFAULT '',
|
|
slot TEXT NOT NULL DEFAULT '',
|
|
action TEXT NOT NULL, -- equip / unequip / upgrade / repair
|
|
tier INTEGER NOT NULL DEFAULT 0, -- upgrade target tier; unused by the other actions
|
|
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_equip_orders_pending ON equip_orders(status, created_at);
|
|
CREATE INDEX IF NOT EXISTS idx_equip_orders_owner ON equip_orders(owner_sub, created_at DESC);
|
|
|
|
-- An action an owner asked for from the web — pull out of a run, take today's
|
|
-- swing at the Siege — on its way to gogobee. Same reverse-pipe shape as
|
|
-- equip_orders and the same guid-as-idempotency-key contract, but a SEPARATE
|
|
-- table on purpose: every column of equip_orders is equip vocabulary (item, slot,
|
|
-- tier), and these verbs act on the character rather than on something it is
|
|
-- carrying. Sharing the table would have meant rows where most columns are
|
|
-- meaningless and an action set nobody could read.
|
|
--
|
|
-- The status ladder:
|
|
--
|
|
-- pending -> applied (it happened; detail says what)
|
|
-- -> rejected_not_running (extract/abandon/leave: no expedition)
|
|
-- -> rejected_not_leader (extract/abandon: a member can't call it)
|
|
-- -> rejected_is_leader (leave: the leader's row IS the expedition)
|
|
-- -> rejected_no_siege (siege_join: nothing camped outside town)
|
|
-- -> rejected_already_fought (siege_join: today's bout is already spent)
|
|
-- -> rejected_busy (already out, seated, or has a sitter)
|
|
-- -> rejected_insufficient_funds (could not cover the cost)
|
|
-- -> rejected_zone_locked (expedition_start: not open at this level)
|
|
-- -> rejected_nothing_to_resume (nothing extracted, or the window closed)
|
|
-- -> rejected_nothing_to_cancel (babysit_cancel: no sitter is engaged)
|
|
-- -> rejected_unavailable (no character, dead, or an unsold argument)
|
|
--
|
|
-- Like the equip queue, the underlying game action is NOT idempotent — an extract
|
|
-- ends an expedition and a bout spends a day — so gogobee short-circuits on the
|
|
-- guid before it mutates anything. token is the roster token the order was placed
|
|
-- from; gogobee ignores it (the localpart names the character) but it is what
|
|
-- Pete proved ownership against, and it keeps the row self-describing.
|
|
CREATE TABLE IF NOT EXISTS adventure_orders (
|
|
guid TEXT PRIMARY KEY,
|
|
owner_sub TEXT NOT NULL,
|
|
owner_localpart TEXT NOT NULL,
|
|
token TEXT NOT NULL DEFAULT '',
|
|
character_name TEXT NOT NULL DEFAULT '',
|
|
action TEXT NOT NULL, -- see the AdvAction* set
|
|
status TEXT NOT NULL, -- see the ladder above
|
|
detail TEXT, -- gogobee's human note on the verdict
|
|
params TEXT NOT NULL DEFAULT '', -- the verb's arguments as JSON; '' for the verbs that take none
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_adventure_orders_pending ON adventure_orders(status, created_at);
|
|
CREATE INDEX IF NOT EXISTS idx_adventure_orders_owner ON adventure_orders(owner_sub, created_at DESC);
|
|
|
|
-- 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);
|
|
|
|
-- Per-user visit clock for the adventure section's "while you were away" panel,
|
|
-- keyed by OIDC subject like every other per-user table.
|
|
--
|
|
-- TWO stamps, and the second one is the whole trick. window_from is where the
|
|
-- panel reads from; last_seen_at is a heartbeat written on every page load. One
|
|
-- column would make the panel a one-shot: it would show what happened, move the
|
|
-- stamp to now, and a refresh five seconds later would render an empty box over
|
|
-- the same news. So window_from advances only when a genuinely new visit begins
|
|
-- (see AdvVisitWindow), which keeps the panel stable for as long as somebody is
|
|
-- actually reading it.
|
|
CREATE TABLE IF NOT EXISTS adventure_visit (
|
|
user_sub TEXT PRIMARY KEY,
|
|
window_from INTEGER NOT NULL,
|
|
last_seen_at INTEGER NOT NULL
|
|
);
|
|
|
|
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.
|
|
--
|
|
-- user_localpart is the same identity one level down: user_sub is the OIDC
|
|
-- subject, but every adventure ownership join in this schema is keyed on the
|
|
-- Matrix localpart (see player_self_detail), and nothing else persists that
|
|
-- mapping outside a live session. It is captured at subscribe time so the
|
|
-- adventure alert sender — which runs on a ticker with no request to read a
|
|
-- session from — can answer "whose adventurer is this" at all.
|
|
--
|
|
-- last_adv_notified_at is the adventure alerts' own watermark, kept apart from
|
|
-- the digest's on purpose: the two senders run on different clocks and one
|
|
-- column would let each silently consume the other's backlog.
|
|
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
|
endpoint TEXT PRIMARY KEY,
|
|
user_sub TEXT NOT NULL,
|
|
user_localpart TEXT NOT NULL DEFAULT '',
|
|
p256dh TEXT NOT NULL,
|
|
auth TEXT NOT NULL,
|
|
created_at INTEGER NOT NULL,
|
|
last_notified_at INTEGER NOT NULL,
|
|
last_adv_notified_at INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
|
|
-- 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;
|
|
`
|