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 ); 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) ); 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; `