Files
Pete/internal/storage/schema.go
prosolis 616055a704 Add per-story views, trending, and reader reading-experience upgrades
Surface read counts and sharpen the reader:
- story_views table + RecordStoryView on /api/article (background,
  filter-guarded); "Popular this week" home rail via TrendingStories;
  read-count badge and reading-time chip decorated onto every listing
- reader: signed-in-only read-aloud (TTS), native share/copy, and an
  Aa typography popover (size/serif/sepia) persisted per device
- real alt text on card/reader/related/search images; time-of-day Pete
  greeting on the home hero
- harden exec() to skip (not panic) on a nil DB so background writes
  can't crash on a closed handle

Tests: story_views_test.go, trending_test.go. Suite green, CSS rebuilt.
2026-07-07 22:17:23 -07:00

180 lines
7.7 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,
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;
`