Files
Pete/internal/storage/schema.go
prosolis 74aa578a2d Precompute content_chars to drop per-render body scans
The N-min-read chip derived reading time via LENGTH(content) over the
full article-body TEXT column on every listing render. LENGTH can't use
an index, so SQLite read each row's whole body per request on the hottest
path. Cache the character count in a content_chars column filled at insert
time (backfilled for existing rows), and point StoryContentLengths at it.
2026-07-07 22:41:41 -07:00

181 lines
7.8 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
);
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;
`