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.
180 lines
7.7 KiB
Go
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;
|
|
`
|