Files
the-daily-epub/src/db.rs
T
thalladaandClaude Fable 5.1 57efbb49b4 Curation v2 step 2: Claude Opus 5 editor, the Brief, per-provider budgets
- AnthropicBackend (Messages API, cached system block, output_config.effort,
  server-side fallbacks, refusal surfaced as an error); Llms { bulk, editor }
  with editor_or_bulk(); PriceTable-based UsageMeter per provider.
- [anthropic], [editorial], deepseek.max_concurrent_requests and
  curation.max_article_count config; startup logs resolved providers.
- Budget day is the UTC date of started_at, preloaded from
  runs.provider_costs_json; finish_run writes provider_costs_json and
  config_json. Stage A batches run concurrently with per-batch budget checks.
- Editor prompt with one-line "why" per pick; no minimum lineup size;
  --max-articles is a ceiling; top-up branch deleted; why stored on picks and
  issue_articles.why and rendered in chapters and In this issue.
- Summaries on the editor client (3k-token input, concurrency 4, bulk then
  excerpt fallback); "The Brief" replaces From the Editor; section intros gone.
- Colophon carries per-provider costs and models.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1rCLQeKBgnBo3oTgHuTMe
2026-09-02 03:56:14 +00:00

1315 lines
49 KiB
Rust

//! SQLite access layer (spec §3.13).
//!
//! Runtime queries only — no `sqlx::query!` macros (implementation notes §1).
//! Timestamps are stored as RFC3339 UTC strings and dates as `YYYY-MM-DD`
//! (implementation notes §2). Pipeline writes are idempotent upserts so that
//! `generate --date X` can be re-run safely; feedback events are append-only.
use std::collections::BTreeMap;
use std::path::Path;
use std::str::FromStr;
use std::time::Duration;
use jiff::Timestamp;
use jiff::civil::Date;
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous};
use sqlx::{Row, SqlitePool};
use crate::types::{
Article, ArticleId, Entry, EntryId, Facets, LlmScore, Pick, RatedArticle, RatingEvent,
SocialRef, SocialSource, SourceRef,
};
/// Embedded migrations from `./migrations` (implementation notes §1).
pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");
/// `kv` key holding the ingest watermark (§3.1).
pub const KV_WATERMARK: &str = "ingest_watermark";
/// `kv` key holding the current taste profile document (§3.6).
pub const KV_TASTE_PROFILE: &str = "taste_profile";
/// `kv` key holding the taste profile version/build time (§3.6).
pub const KV_PROFILE_VERSION: &str = "profile_version";
#[derive(Debug, thiserror::Error)]
pub enum DbError {
#[error("sqlite error: {0}")]
Sqlx(#[from] sqlx::Error),
#[error("migration error: {0}")]
Migrate(#[from] sqlx::migrate::MigrateError),
#[error("could not create database directory {path}: {source}")]
CreateDir {
path: String,
#[source]
source: std::io::Error,
},
#[error("malformed value in column `{column}`: {value}")]
Decode { column: &'static str, value: String },
#[error("malformed JSON in column `{column}`: {source}")]
Json {
column: &'static str,
#[source]
source: serde_json::Error,
},
}
type Result<T> = std::result::Result<T, DbError>;
/// Handle to the shared SQLite pool.
#[derive(Debug, Clone)]
pub struct Db {
pool: SqlitePool,
}
impl Db {
/// Open (creating if needed) the database at `path` with WAL + foreign keys on,
/// creating parent directories first. Does **not** run migrations.
pub async fn open(path: &Path) -> Result<Self> {
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
std::fs::create_dir_all(parent).map_err(|source| DbError::CreateDir {
path: parent.display().to_string(),
source,
})?;
}
let opts = SqliteConnectOptions::new()
.filename(path)
.create_if_missing(true)
.foreign_keys(true)
.journal_mode(SqliteJournalMode::Wal)
.synchronous(SqliteSynchronous::Normal)
.busy_timeout(Duration::from_secs(30));
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect_with(opts)
.await?;
Ok(Self { pool })
}
/// Open and run all pending migrations — used by every subcommand (§2).
pub async fn open_and_migrate(path: &Path) -> Result<Self> {
let db = Self::open(path).await?;
db.migrate().await?;
Ok(db)
}
/// Run the embedded migrations (`daily-epub db migrate`).
pub async fn migrate(&self) -> Result<()> {
MIGRATOR.run(&self.pool).await?;
Ok(())
}
/// Escape hatch for modules that need bespoke queries.
pub fn pool(&self) -> &SqlitePool {
&self.pool
}
pub async fn close(&self) {
self.pool.close().await;
}
// -----------------------------------------------------------------
// kv (§3.1 watermark, §3.6 taste profile)
// -----------------------------------------------------------------
pub async fn kv_get(&self, key: &str) -> Result<Option<String>> {
let row = sqlx::query("SELECT value FROM kv WHERE key = ?")
.bind(key)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(|r| r.get::<String, _>("value")))
}
pub async fn kv_set(&self, key: &str, value: &str) -> Result<()> {
sqlx::query(
"INSERT INTO kv (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
)
.bind(key)
.bind(value)
.execute(&self.pool)
.await?;
Ok(())
}
/// Latest successful ingest watermark (§3.1). The lookback window overlaps it,
/// so a missing watermark simply means "use the full lookback window".
pub async fn get_watermark(&self) -> Result<Option<Timestamp>> {
match self.kv_get(KV_WATERMARK).await? {
Some(s) => Ok(Some(parse_ts("watermark", &s)?)),
None => Ok(None),
}
}
pub async fn set_watermark(&self, ts: Timestamp) -> Result<()> {
self.kv_set(KV_WATERMARK, &fmt_ts(ts)).await
}
// -----------------------------------------------------------------
// entries (§3.1)
// -----------------------------------------------------------------
/// Upsert one raw Miniflux entry, keyed by the Miniflux entry id (§3.1).
pub async fn upsert_entry(&self, entry: &Entry) -> Result<()> {
sqlx::query(
"INSERT INTO entries (id, feed_id, feed_title, category, title, url, canonical_url,
author, published_at, comments_url, raw_content, fetched_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
feed_id = excluded.feed_id,
feed_title = excluded.feed_title,
category = excluded.category,
title = excluded.title,
url = excluded.url,
canonical_url = excluded.canonical_url,
author = excluded.author,
published_at = excluded.published_at,
comments_url = excluded.comments_url,
raw_content = excluded.raw_content,
fetched_at = excluded.fetched_at",
)
.bind(entry.id)
.bind(entry.feed_id)
.bind(entry.feed_title.as_deref())
.bind(entry.category.as_deref())
.bind(&entry.title)
.bind(&entry.url)
.bind(entry.canonical_url.as_deref())
.bind(entry.author.as_deref())
.bind(entry.published_at.map(fmt_ts))
.bind(entry.comments_url.as_deref())
.bind(&entry.raw_content)
.bind(fmt_ts(entry.fetched_at))
.execute(&self.pool)
.await?;
Ok(())
}
/// Upsert a batch inside one transaction. Returns the number of rows written.
pub async fn upsert_entries(&self, entries: &[Entry]) -> Result<usize> {
let mut tx = self.pool.begin().await?;
for entry in entries {
sqlx::query(
"INSERT INTO entries (id, feed_id, feed_title, category, title, url, canonical_url,
author, published_at, comments_url, raw_content, fetched_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
feed_id = excluded.feed_id,
feed_title = excluded.feed_title,
category = excluded.category,
title = excluded.title,
url = excluded.url,
canonical_url = excluded.canonical_url,
author = excluded.author,
published_at = excluded.published_at,
comments_url = excluded.comments_url,
raw_content = excluded.raw_content,
fetched_at = excluded.fetched_at",
)
.bind(entry.id)
.bind(entry.feed_id)
.bind(entry.feed_title.as_deref())
.bind(entry.category.as_deref())
.bind(&entry.title)
.bind(&entry.url)
.bind(entry.canonical_url.as_deref())
.bind(entry.author.as_deref())
.bind(entry.published_at.map(fmt_ts))
.bind(entry.comments_url.as_deref())
.bind(&entry.raw_content)
.bind(fmt_ts(entry.fetched_at))
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(entries.len())
}
/// Entries published within `[since, until]`, newest first (§3.2 input).
pub async fn entries_in_window(
&self,
since: Timestamp,
until: Timestamp,
) -> Result<Vec<Entry>> {
let rows = sqlx::query(
"SELECT id, feed_id, feed_title, category, title, url, canonical_url, author,
published_at, comments_url, raw_content, fetched_at
FROM entries
WHERE published_at IS NOT NULL AND published_at >= ? AND published_at <= ?
ORDER BY published_at DESC",
)
.bind(fmt_ts(since))
.bind(fmt_ts(until))
.fetch_all(&self.pool)
.await?;
rows.iter().map(entry_from_row).collect()
}
pub async fn get_entry(&self, id: EntryId) -> Result<Option<Entry>> {
let row = sqlx::query(
"SELECT id, feed_id, feed_title, category, title, url, canonical_url, author,
published_at, comments_url, raw_content, fetched_at
FROM entries WHERE id = ?",
)
.bind(id)
.fetch_optional(&self.pool)
.await?;
row.as_ref().map(entry_from_row).transpose()
}
pub async fn count_entries(&self) -> Result<i64> {
let row = sqlx::query("SELECT COUNT(*) AS n FROM entries")
.fetch_one(&self.pool)
.await?;
Ok(row.get::<i64, _>("n"))
}
// -----------------------------------------------------------------
// articles (§3.2, §3.3)
// -----------------------------------------------------------------
/// Upsert a deduped cluster by canonical URL; returns its `articles.id`.
///
/// The denormalized fields on [`Article`] are not stored here — they come
/// from the joined `entries` row when loading.
pub async fn upsert_article(&self, article: &Article) -> Result<ArticleId> {
let sources = serde_json::to_string(&article.sources).unwrap_or_else(|_| "[]".into());
let row = sqlx::query(
"INSERT INTO articles (canonical_url, title, best_entry_id, content_html, word_count,
excerpt_only, image_count, sources_json, first_seen)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(canonical_url) DO UPDATE SET
title = excluded.title,
best_entry_id = excluded.best_entry_id,
content_html = excluded.content_html,
word_count = excluded.word_count,
excerpt_only = excluded.excerpt_only,
image_count = excluded.image_count,
sources_json = excluded.sources_json
RETURNING id",
)
.bind(&article.canonical_url)
.bind(&article.title)
.bind(article.best_entry_id)
.bind(&article.content_html)
.bind(article.word_count)
.bind(article.excerpt_only)
.bind(article.image_count)
.bind(sources)
.bind(fmt_ts(article.first_seen))
.fetch_one(&self.pool)
.await?;
Ok(row.get::<i64, _>("id"))
}
/// Load one article with its social refs and entry-derived fields.
pub async fn get_article(&self, id: ArticleId) -> Result<Option<Article>> {
let row = sqlx::query(ARTICLE_SELECT_BY_ID)
.bind(id)
.fetch_optional(&self.pool)
.await?;
let Some(row) = row else { return Ok(None) };
let mut article = article_from_row(&row)?;
article.social = self.social_for_article(article.id).await?;
Ok(Some(article))
}
pub async fn article_id_for_url(&self, canonical_url: &str) -> Result<Option<ArticleId>> {
let row = sqlx::query("SELECT id FROM articles WHERE canonical_url = ?")
.bind(canonical_url)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(|r| r.get::<i64, _>("id")))
}
/// Article ids already published in some issue — never repeat them (§3.5).
pub async fn previously_published_ids(&self) -> Result<Vec<ArticleId>> {
let rows = sqlx::query("SELECT DISTINCT article_id FROM issue_articles")
.fetch_all(&self.pool)
.await?;
Ok(rows.iter().map(|r| r.get::<i64, _>("article_id")).collect())
}
/// Articles the LLM scored below `threshold` within the last `days` (§3.5).
pub async fn recently_low_scored_ids(
&self,
threshold: f64,
since: Date,
) -> Result<Vec<ArticleId>> {
let rows = sqlx::query(
"SELECT DISTINCT article_id FROM scores
WHERE llm_score IS NOT NULL AND llm_score < ? AND run_date >= ?",
)
.bind(threshold)
.bind(since.to_string())
.fetch_all(&self.pool)
.await?;
Ok(rows.iter().map(|r| r.get::<i64, _>("article_id")).collect())
}
// -----------------------------------------------------------------
// social (§3.4)
// -----------------------------------------------------------------
pub async fn upsert_social(&self, r: &SocialRef) -> Result<()> {
sqlx::query(
"INSERT INTO social (article_id, source, item_id, score, num_comments, item_url, fetched_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(article_id, source) DO UPDATE SET
item_id = excluded.item_id,
score = excluded.score,
num_comments = excluded.num_comments,
item_url = excluded.item_url,
fetched_at = excluded.fetched_at",
)
.bind(r.article_id)
.bind(r.source.as_str())
.bind(r.item_id.as_deref())
.bind(r.score)
.bind(r.num_comments)
.bind(r.item_url.as_deref())
.bind(fmt_ts(r.fetched_at))
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn social_for_article(&self, article_id: ArticleId) -> Result<Vec<SocialRef>> {
let rows = sqlx::query(
"SELECT article_id, source, item_id, score, num_comments, item_url, fetched_at
FROM social WHERE article_id = ? ORDER BY source",
)
.bind(article_id)
.fetch_all(&self.pool)
.await?;
rows.iter().map(social_from_row).collect()
}
// -----------------------------------------------------------------
// scores (§3.5, §3.6)
// -----------------------------------------------------------------
pub async fn upsert_score(
&self,
article_id: ArticleId,
run_date: Date,
prefilter_score: Option<f64>,
llm: Option<&LlmScore>,
) -> Result<()> {
sqlx::query(
"INSERT INTO scores (article_id, run_date, prefilter_score, llm_score, llm_category, rationale)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(article_id, run_date) DO UPDATE SET
prefilter_score = COALESCE(excluded.prefilter_score, scores.prefilter_score),
llm_score = COALESCE(excluded.llm_score, scores.llm_score),
llm_category = COALESCE(excluded.llm_category, scores.llm_category),
rationale = COALESCE(excluded.rationale, scores.rationale)",
)
.bind(article_id)
.bind(run_date.to_string())
.bind(prefilter_score)
.bind(llm.map(|l| l.score))
.bind(llm.map(|l| l.category.as_str()))
.bind(llm.map(|l| l.rationale.as_str()))
.execute(&self.pool)
.await?;
Ok(())
}
// -----------------------------------------------------------------
// issues + lineup (§3.10)
// -----------------------------------------------------------------
/// Issue number = days since the first issue, 1-based (§3.10).
pub async fn next_issue_number(&self, date: Date) -> Result<i64> {
if let Some(row) = sqlx::query("SELECT issue_number FROM issues WHERE date = ?")
.bind(date.to_string())
.fetch_optional(&self.pool)
.await?
{
return Ok(row.get::<i64, _>("issue_number"));
}
let row = sqlx::query("SELECT MIN(date) AS first_date FROM issues")
.fetch_one(&self.pool)
.await?;
let first: Option<String> = row.get("first_date");
match first {
Some(s) => {
let first = parse_date("issues.date", &s)?;
Ok((date - first).get_days() as i64 + 1)
}
None => Ok(1),
}
}
#[allow(clippy::too_many_arguments)]
pub async fn upsert_issue(
&self,
date: Date,
issue_number: i64,
generated_at: Timestamp,
epub_path: Option<&str>,
x4_path: Option<&str>,
xtc_path: Option<&str>,
front_page_html: Option<&str>,
report_json: Option<&str>,
) -> Result<()> {
sqlx::query(
"INSERT INTO issues (date, issue_number, generated_at, epub_path, x4_path, xtc_path,
front_page_html, report_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(date) DO UPDATE SET
issue_number = excluded.issue_number,
generated_at = excluded.generated_at,
epub_path = COALESCE(excluded.epub_path, issues.epub_path),
x4_path = COALESCE(excluded.x4_path, issues.x4_path),
xtc_path = COALESCE(excluded.xtc_path, issues.xtc_path),
front_page_html = COALESCE(excluded.front_page_html, issues.front_page_html),
report_json = COALESCE(excluded.report_json, issues.report_json)",
)
.bind(date.to_string())
.bind(issue_number)
.bind(fmt_ts(generated_at))
.bind(epub_path)
.bind(x4_path)
.bind(xtc_path)
.bind(front_page_html)
.bind(report_json)
.execute(&self.pool)
.await?;
Ok(())
}
/// Replace the lineup for a date (regeneration is idempotent, notes §12).
pub async fn replace_issue_articles(&self, date: Date, picks: &[Pick]) -> Result<()> {
let mut tx = self.pool.begin().await?;
sqlx::query("DELETE FROM issue_articles WHERE issue_date = ?")
.bind(date.to_string())
.execute(&mut *tx)
.await?;
for pick in picks {
sqlx::query(
"INSERT INTO issue_articles (issue_date, article_id, section, position, is_lead, summary, why)
VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(date.to_string())
.bind(pick.article.id)
.bind(&pick.section)
.bind(pick.position)
.bind(pick.is_lead)
.bind(pick.summary.as_deref())
.bind(pick.why.as_deref())
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}
/// Recent issue dates + stored report JSON, newest first (`GET /issues.json`, §3.12).
pub async fn recent_reports(&self, limit: i64) -> Result<Vec<(Date, Option<String>)>> {
let rows = sqlx::query("SELECT date, report_json FROM issues ORDER BY date DESC LIMIT ?")
.bind(limit)
.fetch_all(&self.pool)
.await?;
rows.iter()
.map(|r| {
let d = parse_date("issues.date", &r.get::<String, _>("date"))?;
Ok((d, r.get::<Option<String>, _>("report_json")))
})
.collect()
}
/// Issues older than `cutoff`, used by retention pruning (§3.11).
pub async fn issues_before(
&self,
cutoff: Date,
) -> Result<Vec<(Date, Option<String>, Option<String>, Option<String>)>> {
let rows = sqlx::query(
"SELECT date, epub_path, x4_path, xtc_path FROM issues WHERE date < ? ORDER BY date",
)
.bind(cutoff.to_string())
.fetch_all(&self.pool)
.await?;
rows.iter()
.map(|r| {
let d = parse_date("issues.date", &r.get::<String, _>("date"))?;
Ok((
d,
r.get::<Option<String>, _>("epub_path"),
r.get::<Option<String>, _>("x4_path"),
r.get::<Option<String>, _>("xtc_path"),
))
})
.collect()
}
// -----------------------------------------------------------------
// append-only rating events (§6.2)
// -----------------------------------------------------------------
/// Append one feedback event and return its database id.
pub async fn append_rating_event(&self, event: &RatingEvent) -> Result<i64> {
let row = sqlx::query(
"INSERT INTO rating_events
(article_id, issue_date, kind, source, label, value, note, event_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id",
)
.bind(event.article_id)
.bind(event.issue_date.map(|date| date.to_string()))
.bind(&event.kind)
.bind(&event.source)
.bind(&event.label)
.bind(event.value)
.bind(event.note.as_deref())
.bind(fmt_ts(event.event_at))
.fetch_one(&self.pool)
.await?;
Ok(row.get("id"))
}
/// Latest issue containing an article, used to attach CLI feedback when possible.
pub async fn latest_issue_date_for_article(
&self,
article_id: ArticleId,
) -> Result<Option<Date>> {
let row = sqlx::query(
"SELECT issue_date FROM issue_articles
WHERE article_id = ? ORDER BY issue_date DESC LIMIT 1",
)
.bind(article_id)
.fetch_optional(&self.pool)
.await?;
row.map(|row| {
parse_date(
"issue_articles.issue_date",
&row.get::<String, _>("issue_date"),
)
})
.transpose()
}
/// Current explicit verdicts, newest first. A latest `cleared` event removes
/// its article from this learned set (§6.2).
pub async fn current_ratings(&self, lookback_days: i64) -> Result<Vec<RatedArticle>> {
self.latest_explicit_ratings(lookback_days, false).await
}
/// Current explicit events including `cleared`, for the ratings CLI.
pub async fn current_ratings_including_cleared(
&self,
lookback_days: i64,
) -> Result<Vec<RatedArticle>> {
self.latest_explicit_ratings(lookback_days, true).await
}
async fn latest_explicit_ratings(
&self,
lookback_days: i64,
include_cleared: bool,
) -> Result<Vec<RatedArticle>> {
let since = Timestamp::now()
.checked_sub(jiff::Span::new().hours(lookback_days.max(0).saturating_mul(24)))
.unwrap_or(Timestamp::UNIX_EPOCH);
let rows = sqlx::query(
"WITH ranked AS (
SELECT re.*,
ROW_NUMBER() OVER (
PARTITION BY re.article_id
ORDER BY re.event_at DESC, re.id DESC
) AS event_rank
FROM rating_events re
WHERE re.kind = 'explicit' AND re.event_at >= ?
)
SELECT r.article_id, r.issue_date, r.label, r.value, r.note, r.event_at,
COALESCE(a.title, '') AS title,
COALESCE(e.feed_title, '') AS feed_title,
(SELECT ia.summary FROM issue_articles ia
WHERE ia.article_id = r.article_id
ORDER BY ia.issue_date DESC LIMIT 1) AS summary,
aa.facets_json AS facets_json
FROM ranked r
JOIN articles a ON a.id = r.article_id
LEFT JOIN entries e ON e.id = a.best_entry_id
LEFT JOIN article_assessments aa
ON aa.article_id = r.article_id AND aa.stage = 'deep'
WHERE r.event_rank = 1 AND (? OR r.label != 'cleared')
ORDER BY r.event_at DESC, r.id DESC",
)
.bind(fmt_ts(since))
.bind(include_cleared)
.fetch_all(&self.pool)
.await?;
rows.iter().map(rated_article_from_row).collect()
}
// -----------------------------------------------------------------
// runs (§3.6 cost guardrail, §3.13)
// -----------------------------------------------------------------
/// Insert a `running` row at the top of `generate`; returns `runs.id`.
pub async fn start_run(&self, date: Date, started_at: Timestamp) -> Result<i64> {
let row = sqlx::query(
"INSERT INTO runs (date, started_at, status) VALUES (?, ?, 'running') RETURNING id",
)
.bind(date.to_string())
.bind(fmt_ts(started_at))
.fetch_one(&self.pool)
.await?;
Ok(row.get::<i64, _>("id"))
}
/// Write the final counters/status for a run (§3.13).
pub async fn finish_run(&self, id: i64, report: &crate::report::RunReport) -> Result<()> {
sqlx::query(
"UPDATE runs SET finished_at = ?, entries_fetched = ?, candidates = ?, selected = ?,
input_tokens = ?, cached_tokens = ?, output_tokens = ?, cost_usd = ?,
status = ?, error = ?, provider_costs_json = ?, config_json = ?
WHERE id = ?",
)
.bind(report.finished_at.map(fmt_ts))
.bind(report.counts.entries_fetched)
.bind(report.counts.candidates)
.bind(report.counts.selected)
.bind(report.usage.input_tokens)
.bind(report.usage.cached_tokens)
.bind(report.usage.output_tokens)
.bind(report.cost_usd)
.bind(report.status.as_str())
.bind(report.error.as_deref())
.bind(
serde_json::to_string(&report.provider_costs).map_err(|source| DbError::Json {
column: "runs.provider_costs_json",
source,
})?,
)
.bind(
serde_json::to_string(&report.config_json).map_err(|source| DbError::Json {
column: "runs.config_json",
source,
})?,
)
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
/// Earlier provider spend on the UTC date containing this run's start (§5).
pub async fn provider_spend_for_utc_day(
&self,
started_at: Timestamp,
) -> Result<BTreeMap<String, f64>> {
let utc_date = started_at
.to_zoned(jiff::tz::TimeZone::UTC)
.date()
.to_string();
let rows = sqlx::query(
"SELECT provider_costs_json FROM runs
WHERE substr(started_at, 1, 10) = ? AND started_at < ?
AND provider_costs_json IS NOT NULL",
)
.bind(utc_date)
.bind(fmt_ts(started_at))
.fetch_all(&self.pool)
.await?;
let mut totals = BTreeMap::new();
for row in rows {
let raw = row.get::<String, _>("provider_costs_json");
let providers: BTreeMap<String, crate::report::ProviderUsage> =
serde_json::from_str(&raw).map_err(|source| DbError::Json {
column: "runs.provider_costs_json",
source,
})?;
for (provider, usage) in providers {
*totals.entry(provider).or_insert(0.0) += usage.cost_usd;
}
}
Ok(totals)
}
}
/// Joined projection used by [`Db::get_article`]; keep in sync with [`article_from_row`].
const ARTICLE_SELECT_BY_ID: &str = "\
SELECT a.id AS id, a.canonical_url AS canonical_url, a.title AS title,
a.best_entry_id AS best_entry_id, a.content_html AS content_html,
a.word_count AS word_count, a.excerpt_only AS excerpt_only,
a.image_count AS image_count, a.sources_json AS sources_json,
a.first_seen AS first_seen,
e.url AS entry_url, e.author AS author, e.feed_id AS feed_id,
e.feed_title AS feed_title, e.category AS category,
e.published_at AS published_at, e.comments_url AS comments_url
FROM articles a LEFT JOIN entries e ON e.id = a.best_entry_id
WHERE a.id = ?";
// ---------------------------------------------------------------------------
// Row mapping helpers (implementation notes §1: manual mapping, no macros)
// ---------------------------------------------------------------------------
/// RFC3339 UTC, the on-disk timestamp format (notes §2).
pub fn fmt_ts(ts: Timestamp) -> String {
ts.to_string()
}
pub fn parse_ts(column: &'static str, value: &str) -> Result<Timestamp> {
Timestamp::from_str(value).map_err(|_| DbError::Decode {
column,
value: value.to_string(),
})
}
pub fn parse_date(column: &'static str, value: &str) -> Result<Date> {
Date::from_str(value).map_err(|_| DbError::Decode {
column,
value: value.to_string(),
})
}
fn entry_from_row(row: &sqlx::sqlite::SqliteRow) -> Result<Entry> {
Ok(Entry {
id: row.get("id"),
feed_id: row.get("feed_id"),
feed_title: row.get("feed_title"),
category: row.get("category"),
title: row.get("title"),
url: row.get("url"),
canonical_url: row.get("canonical_url"),
author: row.get("author"),
published_at: row
.get::<Option<String>, _>("published_at")
.map(|s| parse_ts("entries.published_at", &s))
.transpose()?,
comments_url: row.get("comments_url"),
raw_content: row.get("raw_content"),
fetched_at: parse_ts("entries.fetched_at", &row.get::<String, _>("fetched_at"))?,
})
}
fn article_from_row(row: &sqlx::sqlite::SqliteRow) -> Result<Article> {
let sources: Vec<SourceRef> =
serde_json::from_str(&row.get::<String, _>("sources_json")).unwrap_or_default();
Ok(Article {
id: row.get("id"),
canonical_url: row.get("canonical_url"),
title: row.get::<Option<String>, _>("title").unwrap_or_default(),
best_entry_id: row.get::<Option<i64>, _>("best_entry_id").unwrap_or(0),
content_html: row
.get::<Option<String>, _>("content_html")
.unwrap_or_default(),
word_count: row.get("word_count"),
excerpt_only: row.get("excerpt_only"),
image_count: row.get("image_count"),
sources,
first_seen: parse_ts("articles.first_seen", &row.get::<String, _>("first_seen"))?,
url: row
.get::<Option<String>, _>("entry_url")
.unwrap_or_else(|| row.get("canonical_url")),
author: row.get("author"),
feed_id: row.get::<Option<i64>, _>("feed_id").unwrap_or(0),
feed_title: row
.get::<Option<String>, _>("feed_title")
.unwrap_or_default(),
category: row.get("category"),
published_at: row
.get::<Option<String>, _>("published_at")
.map(|s| parse_ts("entries.published_at", &s))
.transpose()?,
comments_url: row.get("comments_url"),
image_urls: Vec::new(),
social: Vec::new(),
extract_method: crate::types::ExtractMethod::Miniflux,
})
}
fn rated_article_from_row(row: &sqlx::sqlite::SqliteRow) -> Result<RatedArticle> {
let issue_date = row
.get::<Option<String>, _>("issue_date")
.map(|raw| parse_date("rating_events.issue_date", &raw))
.transpose()?;
let facets = row.get::<Option<String>, _>("facets_json").and_then(|raw| {
match serde_json::from_str::<Facets>(&raw) {
Ok(facets) => Some(facets),
Err(error) => {
tracing::warn!(%error, "ignoring malformed assessment facets");
None
}
}
});
Ok(RatedArticle {
article_id: row.get("article_id"),
issue_date,
title: row.get("title"),
feed_title: row.get("feed_title"),
summary: row.get("summary"),
facets,
note: row.get("note"),
value: row.get("value"),
label: row.get("label"),
event_at: parse_ts("rating_events.event_at", &row.get::<String, _>("event_at"))?,
})
}
fn social_from_row(row: &sqlx::sqlite::SqliteRow) -> Result<SocialRef> {
let raw: String = row.get("source");
let source = SocialSource::parse(&raw).ok_or(DbError::Decode {
column: "social.source",
value: raw,
})?;
Ok(SocialRef {
article_id: row.get("article_id"),
source,
item_id: row.get("item_id"),
score: row.get("score"),
num_comments: row.get("num_comments"),
item_url: row.get("item_url"),
fetched_at: parse_ts("social.fetched_at", &row.get::<String, _>("fetched_at"))?,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::ExtractMethod;
fn ts(s: &str) -> Timestamp {
s.parse().unwrap()
}
fn sample_entry(id: EntryId) -> Entry {
Entry {
id,
feed_id: 7,
feed_title: Some("Hacker News".into()),
category: Some("Tech".into()),
title: format!("Story {id}"),
url: format!("https://example.com/{id}"),
canonical_url: Some(format!("https://example.com/{id}")),
author: Some("someone".into()),
published_at: Some(ts("2026-08-15T04:00:00Z")),
comments_url: Some("https://news.ycombinator.com/item?id=1".into()),
raw_content: "<p>hello</p>".into(),
fetched_at: ts("2026-08-15T05:30:00Z"),
}
}
async fn temp_db() -> (tempfile::TempDir, Db) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nested").join("daily-epub.db");
let db = Db::open_and_migrate(&path).await.unwrap();
(dir, db)
}
#[tokio::test]
async fn open_creates_dirs_and_migrates() {
let (dir, db) = temp_db().await;
assert!(dir.path().join("nested").join("daily-epub.db").exists());
// Migrations are idempotent.
db.migrate().await.unwrap();
assert_eq!(db.count_entries().await.unwrap(), 0);
let row = sqlx::query("PRAGMA journal_mode")
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(row.get::<String, _>(0).to_lowercase(), "wal");
let row = sqlx::query("PRAGMA foreign_keys")
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(row.get::<i64, _>(0), 1);
}
#[tokio::test]
async fn entry_upsert_is_idempotent() {
let (_dir, db) = temp_db().await;
db.upsert_entries(&[sample_entry(1), sample_entry(2)])
.await
.unwrap();
db.upsert_entries(&[sample_entry(1)]).await.unwrap();
assert_eq!(db.count_entries().await.unwrap(), 2);
let mut changed = sample_entry(1);
changed.title = "Renamed".into();
db.upsert_entry(&changed).await.unwrap();
let got = db.get_entry(1).await.unwrap().unwrap();
assert_eq!(got.title, "Renamed");
assert_eq!(got, changed);
let window = db
.entries_in_window(ts("2026-08-14T00:00:00Z"), ts("2026-08-16T00:00:00Z"))
.await
.unwrap();
assert_eq!(window.len(), 2);
}
#[tokio::test]
async fn kv_watermark_round_trip() {
let (_dir, db) = temp_db().await;
assert!(db.get_watermark().await.unwrap().is_none());
let t = ts("2026-08-15T05:30:00Z");
db.set_watermark(t).await.unwrap();
assert_eq!(db.get_watermark().await.unwrap(), Some(t));
db.kv_set("taste_profile", "hello").await.unwrap();
assert_eq!(
db.kv_get("taste_profile").await.unwrap().as_deref(),
Some("hello")
);
}
#[tokio::test]
async fn article_and_social_round_trip() {
let (_dir, db) = temp_db().await;
db.upsert_entry(&sample_entry(1)).await.unwrap();
let article = Article {
id: 0,
canonical_url: "https://example.com/1".into(),
title: "Story 1".into(),
best_entry_id: 1,
content_html: "<p>body</p>".into(),
word_count: 900,
excerpt_only: false,
image_count: 2,
sources: vec![SourceRef {
entry_id: 1,
feed_id: 7,
feed_title: "Hacker News".into(),
category: Some("Tech".into()),
kind: crate::types::SourceKind::HnFrontpage,
}],
first_seen: ts("2026-08-15T05:30:00Z"),
url: "https://example.com/1".into(),
author: None,
feed_id: 7,
feed_title: "Hacker News".into(),
category: None,
published_at: None,
comments_url: None,
image_urls: vec![],
social: vec![],
extract_method: ExtractMethod::Miniflux,
};
let id = db.upsert_article(&article).await.unwrap();
assert_eq!(db.upsert_article(&article).await.unwrap(), id);
db.upsert_social(&SocialRef {
article_id: id,
source: SocialSource::Hn,
item_id: Some("42".into()),
score: 342,
num_comments: 210,
item_url: Some("https://news.ycombinator.com/item?id=42".into()),
fetched_at: ts("2026-08-15T05:31:00Z"),
})
.await
.unwrap();
let loaded = db.get_article(id).await.unwrap().unwrap();
assert_eq!(loaded.word_count, 900);
assert_eq!(loaded.sources.len(), 1);
assert_eq!(loaded.social.len(), 1);
assert_eq!(loaded.social[0].score, 342);
assert_eq!(loaded.feed_title, "Hacker News");
assert_eq!(
db.article_id_for_url("https://example.com/1")
.await
.unwrap(),
Some(id)
);
}
#[tokio::test]
async fn runs_and_issue_numbers() {
let (_dir, db) = temp_db().await;
let date: Date = "2026-08-15".parse().unwrap();
assert_eq!(db.next_issue_number(date).await.unwrap(), 1);
let run_id = db
.start_run(date, ts("2026-08-15T05:30:00Z"))
.await
.unwrap();
let mut report = crate::report::RunReport::new(date, ts("2026-08-15T05:30:00Z"));
report.counts.entries_fetched = 412;
report.counts.candidates = 120;
report.counts.selected = 20;
report.finish(ts("2026-08-15T05:36:00Z"));
db.finish_run(run_id, &report).await.unwrap();
db.upsert_issue(
date,
1,
ts("2026-08-15T05:36:00Z"),
None,
None,
None,
None,
Some("{}"),
)
.await
.unwrap();
let next: Date = "2026-08-16".parse().unwrap();
assert_eq!(db.next_issue_number(next).await.unwrap(), 2);
assert_eq!(db.recent_reports(5).await.unwrap().len(), 1);
// No provider spend was recorded, so the budget-day preload is empty.
let spend = db
.provider_spend_for_utc_day(ts("2026-08-15T23:00:00Z"))
.await
.unwrap();
assert!(spend.values().all(|usd| *usd == 0.0));
}
async fn record_run(db: &Db, date: Date, started: &str, deepseek: f64, anthropic: f64) {
use crate::report::{ProviderUsage, RunReport};
let started_at = ts(started);
let run_id = db.start_run(date, started_at).await.unwrap();
let mut report = RunReport::new(date, started_at);
report.provider_costs.insert(
"deepseek".into(),
ProviderUsage {
usage: crate::types::TokenUsage {
input_tokens: 10,
..Default::default()
},
cost_usd: deepseek,
},
);
report.provider_costs.insert(
"anthropic".into(),
ProviderUsage {
usage: crate::types::TokenUsage::default(),
cost_usd: anthropic,
},
);
report.config_json = serde_json::json!({"models": {"editor": "claude-opus-5"}});
report.finish(started_at);
db.finish_run(run_id, &report).await.unwrap();
}
#[tokio::test]
async fn provider_spend_is_summed_by_the_utc_day_of_started_at() {
let (_dir, db) = temp_db().await;
let date: Date = "2026-08-15".parse().unwrap();
record_run(&db, date, "2026-08-15T03:00:00Z", 0.10, 0.50).await;
record_run(&db, date, "2026-08-15T23:30:00Z", 0.05, 0.0).await;
// Nominal issue date 08-15 in New York, but already 08-16 in UTC: a
// different budget day (§5).
record_run(&db, date, "2026-08-16T01:00:00Z", 1.0, 1.0).await;
let spend = db
.provider_spend_for_utc_day(ts("2026-08-15T23:45:00Z"))
.await
.unwrap();
assert!((spend["deepseek"] - 0.15).abs() < 1e-9);
assert!((spend["anthropic"] - 0.5).abs() < 1e-9);
// Only runs that started earlier than this one count.
let spend = db
.provider_spend_for_utc_day(ts("2026-08-15T12:00:00Z"))
.await
.unwrap();
assert!((spend["deepseek"] - 0.10).abs() < 1e-9);
let spend = db
.provider_spend_for_utc_day(ts("2026-08-17T12:00:00Z"))
.await
.unwrap();
assert!(spend.is_empty());
// `provider_costs_json` and `config_json` were written and round-trip.
let row =
sqlx::query("SELECT provider_costs_json, config_json FROM runs ORDER BY id LIMIT 1")
.fetch_one(&db.pool)
.await
.unwrap();
let costs: BTreeMap<String, crate::report::ProviderUsage> =
serde_json::from_str(&row.get::<String, _>("provider_costs_json")).unwrap();
assert_eq!(costs["deepseek"].usage.input_tokens, 10);
assert!((costs["anthropic"].cost_usd - 0.5).abs() < 1e-9);
let config: serde_json::Value =
serde_json::from_str(&row.get::<String, _>("config_json")).unwrap();
assert_eq!(config["models"]["editor"], "claude-opus-5");
}
#[tokio::test]
async fn latest_explicit_rating_wins_and_clear_removes_it() {
let (_dir, db) = temp_db().await;
db.upsert_entry(&sample_entry(1)).await.unwrap();
let article = Article {
id: 0,
canonical_url: "https://example.com/1".into(),
title: "Story 1".into(),
best_entry_id: 1,
content_html: "<p>body</p>".into(),
word_count: 900,
excerpt_only: false,
image_count: 0,
sources: vec![],
first_seen: ts("2026-08-15T05:30:00Z"),
url: "https://example.com/1".into(),
author: None,
feed_id: 7,
feed_title: "Hacker News".into(),
category: None,
published_at: None,
comments_url: None,
image_urls: vec![],
social: vec![],
extract_method: ExtractMethod::Miniflux,
};
let article_id = db.upsert_article(&article).await.unwrap();
for (date, number, summary) in [
("2026-08-14", 1, "Older summary"),
("2026-08-15", 2, "Newest summary"),
] {
db.upsert_issue(
date.parse().unwrap(),
number,
ts("2026-08-15T05:30:00Z"),
None,
None,
None,
None,
None,
)
.await
.unwrap();
sqlx::query(
"INSERT INTO issue_articles
(issue_date, article_id, section, position, is_lead, summary)
VALUES (?, ?, 'Top Stories', 1, 0, ?)",
)
.bind(date)
.bind(article_id)
.bind(summary)
.execute(db.pool())
.await
.unwrap();
}
sqlx::query(
"INSERT INTO article_assessments
(article_id, stage, model, prompt_version, facets_json, assessed_at)
VALUES (?, 'deep', 'mock', 1, ?, '2026-08-15T11:00:00Z')",
)
.bind(article_id)
.bind(r#"{"format":"analysis_essay","depth":"deep","evidence":null,"commerciality":null,"topic_group":"software_engineering","technicality":"advanced","locality":null,"specific_topics":null}"#)
.execute(db.pool())
.await
.unwrap();
let event = |label: &str, value: f64, at: &str| RatingEvent {
id: 0,
article_id,
issue_date: Some("2026-08-15".parse().unwrap()),
kind: "explicit".into(),
source: "cli".into(),
label: label.into(),
value,
note: None,
event_at: ts(at),
};
db.append_rating_event(&event("loved", 1.0, "2026-08-15T12:00:00Z"))
.await
.unwrap();
db.append_rating_event(&event("good", 0.35, "2026-08-15T13:00:00Z"))
.await
.unwrap();
let mut implicit = event("read_fully", 0.5, "2026-08-15T13:30:00Z");
implicit.kind = "implicit".into();
implicit.source = "bookorbit".into();
db.append_rating_event(&implicit).await.unwrap();
let ratings = db.current_ratings(36500).await.unwrap();
assert_eq!(ratings.len(), 1);
assert_eq!(ratings[0].label, "good");
assert_eq!(ratings[0].feed_title, "Hacker News");
assert_eq!(ratings[0].summary.as_deref(), Some("Newest summary"));
assert_eq!(
ratings[0]
.facets
.as_ref()
.and_then(|facets| facets.format.as_deref()),
Some("analysis_essay")
);
db.append_rating_event(&event("cleared", 0.0, "2026-08-15T14:00:00Z"))
.await
.unwrap();
assert!(db.current_ratings(36500).await.unwrap().is_empty());
let events = db.current_ratings_including_cleared(36500).await.unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].label, "cleared");
let sources: Vec<String> =
sqlx::query_scalar("SELECT source FROM rating_events ORDER BY id")
.fetch_all(db.pool())
.await
.unwrap();
assert_eq!(sources, ["cli", "cli", "bookorbit", "cli"]);
}
#[tokio::test]
async fn curation_v2_migration_copies_ratings_and_drops_old_tables() {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
sqlx::raw_sql(include_str!("../migrations/0001_init.sql"))
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO articles (id, canonical_url, title, first_seen) VALUES
(1, 'https://example.com/loved', 'Loved', '2026-08-15T00:00:00Z'),
(2, 'https://example.com/down', 'Down', '2026-08-15T00:00:00Z')",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO ratings (issue_date, article_id, vote, rated_at) VALUES
('2026-08-15', 1, 1, '2026-08-15T12:00:00Z'),
('2026-08-15', 2, -1, '2026-08-15T13:00:00Z')",
)
.execute(&pool)
.await
.unwrap();
sqlx::raw_sql(include_str!("../migrations/0002_curation_v2.sql"))
.execute(&pool)
.await
.unwrap();
let rows = sqlx::query(
"SELECT article_id, issue_date, kind, source, label, value, event_at
FROM rating_events ORDER BY article_id",
)
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].get::<String, _>("label"), "loved");
assert_eq!(rows[0].get::<f64, _>("value"), 1.0);
assert_eq!(rows[1].get::<String, _>("label"), "not_for_me");
assert_eq!(rows[1].get::<f64, _>("value"), -1.0);
for row in &rows {
assert_eq!(row.get::<String, _>("kind"), "explicit");
assert_eq!(row.get::<String, _>("source"), "migration");
assert_eq!(row.get::<String, _>("issue_date"), "2026-08-15");
}
assert_eq!(rows[0].get::<String, _>("event_at"), "2026-08-15T12:00:00Z");
let tables: Vec<String> =
sqlx::query_scalar("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")
.fetch_all(&pool)
.await
.unwrap();
assert!(!tables.iter().any(|table| table == "ratings"));
assert!(!tables.iter().any(|table| table == "feed_priors"));
assert!(tables.iter().any(|table| table == "scores"));
for expected in [
"rating_events",
"article_embeddings",
"interest_embeddings",
"article_assessments",
"candidate_runs",
] {
assert!(tables.iter().any(|table| table == expected), "{expected}");
}
}
}