Cut what the audit found: dead columns, one-time upgrades, three deps
Works through TODO.md from the 2026-09-12 over-engineering audit. Drops the entries.read/flagged/position columns (migrate() removes them from older databases), migrate_opml_children, the legacy interval_mins key, the contrib/ systemd units, test-only Db wrappers, a duplicate token generator, redundant logbuf visitors, unused page state and CSS, and the infer, dirs and tokio-stream dependencies. The icon is served once as /icon.png instead of inlined four times, taking about 94 KB off the two pages. The adoption's subscription half was not dead: it gives a fresh install's first admin the config's feeds. It stays as adopt_catalogue, now tested. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
This commit is contained in:
158
src/db.rs
158
src/db.rs
@@ -40,14 +40,10 @@ CREATE TABLE IF NOT EXISTS entries (
|
||||
published INTEGER,
|
||||
description TEXT,
|
||||
first_seen INTEGER NOT NULL,
|
||||
read INTEGER NOT NULL DEFAULT 0,
|
||||
flagged INTEGER NOT NULL DEFAULT 0,
|
||||
image TEXT,
|
||||
duration INTEGER,
|
||||
episode INTEGER,
|
||||
season INTEGER,
|
||||
-- Seconds into the audio, so playback resumes where it was left.
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (feed_id, guid)
|
||||
);
|
||||
|
||||
@@ -93,7 +89,7 @@ CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
PRIMARY KEY (user_id, feed_id)
|
||||
);
|
||||
|
||||
-- Read, starred and how far in. One row per person per item, created on first touch;
|
||||
-- Read, kept and how far in. One row per person per item, created on first touch;
|
||||
-- an item nobody has touched has no row at all, which is what unread means.
|
||||
CREATE TABLE IF NOT EXISTS entry_state (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
@@ -143,8 +139,8 @@ pub struct Managed {
|
||||
pub orphaned: bool,
|
||||
}
|
||||
|
||||
/// Adds columns that later versions introduced. CREATE TABLE IF NOT EXISTS does nothing to
|
||||
/// a table that already exists, so an installed database needs them added explicitly.
|
||||
/// Adds the columns later versions introduced and drops the ones they retired. CREATE TABLE IF
|
||||
/// NOT EXISTS does nothing to a table that already exists, so an installed database needs both.
|
||||
fn migrate(conn: &Connection) -> Result<()> {
|
||||
let wanted: &[(&str, &str, &str)] = &[
|
||||
("feeds", "image", "TEXT"),
|
||||
@@ -155,18 +151,29 @@ fn migrate(conn: &Connection) -> Result<()> {
|
||||
("entries", "duration", "INTEGER"),
|
||||
("entries", "episode", "INTEGER"),
|
||||
("entries", "season", "INTEGER"),
|
||||
("entries", "position", "INTEGER NOT NULL DEFAULT 0"),
|
||||
];
|
||||
for (table, column, ty) in wanted {
|
||||
// Read state from before accounts, long since moved to entry_state. Two bugs came from
|
||||
// queries still reading these after they stopped meaning anything, so they go.
|
||||
let retired: &[(&str, &str)] = &[("entries", "read"), ("entries", "flagged"), ("entries", "position")];
|
||||
let has = |table: &str, column: &str| -> Result<bool> {
|
||||
let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
|
||||
let existing: Vec<String> = stmt
|
||||
let names = stmt
|
||||
.query_map([], |r| r.get::<_, String>(1))?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
if !existing.iter().any(|c| c == column) {
|
||||
Ok(names.iter().any(|c| c == column))
|
||||
};
|
||||
for (table, column, ty) in wanted {
|
||||
if !has(table, column)? {
|
||||
tracing::info!(table, column, "adding column");
|
||||
conn.execute_batch(&format!("ALTER TABLE {table} ADD COLUMN {column} {ty}"))?;
|
||||
}
|
||||
}
|
||||
for (table, column) in retired {
|
||||
if has(table, column)? {
|
||||
tracing::info!(table, column, "dropping column");
|
||||
conn.execute_batch(&format!("ALTER TABLE {table} DROP COLUMN {column}"))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -345,22 +352,19 @@ impl Db {
|
||||
|
||||
/// Returns true when this entry had not been seen before.
|
||||
///
|
||||
/// A changed description or title flips `read` back to 0, which is what the original's
|
||||
/// textDiff dance was ultimately for -- minus the diff markup, which the UI can do.
|
||||
pub fn record_entry(&self, feed_id: &str, e: &crate::feed::Entry) -> Result<bool> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let inserted = conn.execute(
|
||||
"INSERT OR IGNORE INTO entries
|
||||
(feed_id, guid, title, link, published, description, first_seen, read, flagged,
|
||||
(feed_id, guid, title, link, published, description, first_seen,
|
||||
image, duration, episode, season)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 0, 0, ?8, ?9, ?10, ?11)",
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
||||
rusqlite::params![
|
||||
feed_id, e.guid, e.title, e.link, e.published, e.description, now(),
|
||||
e.image, e.duration, e.episode, e.season
|
||||
],
|
||||
)?;
|
||||
if inserted == 0 {
|
||||
// The SET expressions see the pre-update row, so this compares old vs new.
|
||||
conn.execute(
|
||||
"UPDATE entries SET
|
||||
title = coalesce(?3, title),
|
||||
@@ -368,8 +372,7 @@ impl Db {
|
||||
image = coalesce(?5, image),
|
||||
duration = coalesce(?6, duration),
|
||||
episode = coalesce(?7, episode),
|
||||
season = coalesce(?8, season),
|
||||
read = CASE WHEN description IS NOT ?4 OR title IS NOT ?3 THEN 0 ELSE read END
|
||||
season = coalesce(?8, season)
|
||||
WHERE feed_id = ?1 AND guid = ?2",
|
||||
rusqlite::params![
|
||||
feed_id, e.guid, e.title, e.description,
|
||||
@@ -687,22 +690,9 @@ pub struct EncRow {
|
||||
}
|
||||
|
||||
impl Db {
|
||||
/// One page of a feed's entries, newest first, each with its enclosures attached.
|
||||
/// `search` matches title and description, case-insensitively.
|
||||
pub fn entries(
|
||||
&self,
|
||||
user_id: i64,
|
||||
feed_id: &str,
|
||||
filter: Filter,
|
||||
search: Option<&str>,
|
||||
offset: i64,
|
||||
limit: i64,
|
||||
) -> Result<Vec<EntryRow>> {
|
||||
self.entries_in(user_id, Some(feed_id), filter, search, offset, limit, &order_sql("published", "desc"))
|
||||
}
|
||||
|
||||
/// `entries` for one feed, or across every feed the person subscribes to when `feed_id`
|
||||
/// is None: the All Subscriptions view.
|
||||
/// One page of entries, each with its enclosures attached: one feed's, or every feed the
|
||||
/// person subscribes to when `feed_id` is None (All Subscriptions). `search` matches title
|
||||
/// and description, case-insensitively.
|
||||
pub fn entries_in(
|
||||
&self,
|
||||
user_id: i64,
|
||||
@@ -793,18 +783,7 @@ impl Db {
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// How many entries match, so the UI knows whether there is another page.
|
||||
pub fn count_entries(
|
||||
&self,
|
||||
user_id: i64,
|
||||
feed_id: &str,
|
||||
filter: Filter,
|
||||
search: Option<&str>,
|
||||
) -> Result<i64> {
|
||||
self.count_in(user_id, Some(feed_id), filter, search)
|
||||
}
|
||||
|
||||
/// `count_entries` for one feed, or across every feed the person subscribes to.
|
||||
/// How many entries `entries_in` would page through, so the UI knows whether there is more.
|
||||
pub fn count_in(
|
||||
&self,
|
||||
user_id: i64,
|
||||
@@ -838,24 +817,16 @@ impl Db {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Marks every entry in a feed read, for the "mark all read" button.
|
||||
/// Moves a single-user library onto an account: everything read, starred or part-played
|
||||
/// becomes that person's, and they subscribe to every feed already in the catalogue.
|
||||
/// Runs once -- the moment there is a first account and no subscriptions yet.
|
||||
pub fn adopt_existing_library(&self, user_id: i64, catalogue: &[String]) -> Result<usize> {
|
||||
/// The first admin starts subscribed to the whole catalogue: whoever wrote config.toml meant
|
||||
/// to read those feeds, and without this a fresh install signs in to an empty sidebar. Runs
|
||||
/// only while nobody subscribes to anything, so an unsubscribe is never undone.
|
||||
pub fn adopt_catalogue(&self, user_id: i64, catalogue: &[String]) -> Result<usize> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let already: i64 =
|
||||
conn.query_row("SELECT count(*) FROM subscriptions", [], |r| r.get(0))?;
|
||||
if already > 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
let moved = conn.execute(
|
||||
"INSERT INTO entry_state (user_id, feed_id, guid, read, flagged, position)
|
||||
SELECT ?1, feed_id, guid, read, flagged, position FROM entries
|
||||
WHERE read = 1 OR flagged = 1 OR position > 0
|
||||
ON CONFLICT(user_id, feed_id, guid) DO NOTHING",
|
||||
[user_id],
|
||||
)?;
|
||||
for id in catalogue {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO subscriptions (user_id, feed_id, created) VALUES (?1, ?2, ?3)",
|
||||
@@ -868,7 +839,7 @@ impl Db {
|
||||
SELECT ?1, id, ?2 FROM feeds",
|
||||
params![user_id, now()],
|
||||
)?;
|
||||
Ok(moved)
|
||||
Ok(catalogue.len())
|
||||
}
|
||||
|
||||
// ---- subscriptions ----
|
||||
@@ -1528,8 +1499,9 @@ mod tests {
|
||||
// Starring and position are just as private.
|
||||
db.set_entry_flag(1, "f", "b", EntryFlag::Flagged, true).unwrap();
|
||||
db.set_position(2, "f", "b", 42).unwrap();
|
||||
let ray = db.entries(1, "f", Filter::All, None, 0, 50).unwrap();
|
||||
let sam = db.entries(2, "f", Filter::All, None, 0, 50).unwrap();
|
||||
let order = order_sql("published", "desc");
|
||||
let page = |user| db.entries_in(user, Some("f"), Filter::All, None, 0, 50, &order).unwrap();
|
||||
let (ray, sam) = (page(1), page(2));
|
||||
let ray_b = ray.iter().find(|e| e.guid == "b").unwrap();
|
||||
let sam_b = sam.iter().find(|e| e.guid == "b").unwrap();
|
||||
assert!(ray_b.flagged && ray_b.position == 0);
|
||||
@@ -1553,6 +1525,49 @@ mod tests {
|
||||
assert_eq!(sum.downloaded, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_old_database_loses_the_retired_read_columns() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE entries (feed_id TEXT NOT NULL, guid TEXT NOT NULL,
|
||||
first_seen INTEGER NOT NULL, read INTEGER NOT NULL DEFAULT 0,
|
||||
flagged INTEGER NOT NULL DEFAULT 0, position INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (feed_id, guid));",
|
||||
)
|
||||
.unwrap();
|
||||
// The same order as open(): the schema leaves the old table alone, migrate() fixes it.
|
||||
conn.execute_batch(SCHEMA).unwrap();
|
||||
migrate(&conn).unwrap();
|
||||
let cols: Vec<String> = conn
|
||||
.prepare("PRAGMA table_info(entries)")
|
||||
.unwrap()
|
||||
.query_map([], |r| r.get(1))
|
||||
.unwrap()
|
||||
.collect::<rusqlite::Result<_>>()
|
||||
.unwrap();
|
||||
assert!(!cols.iter().any(|c| ["read", "flagged", "position"].contains(&c.as_str())), "{cols:?}");
|
||||
assert!(cols.iter().any(|c| c == "image"), "and it still gains the newer ones");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_admin_starts_with_the_catalogue_and_only_once() {
|
||||
// Cutting this along with the dead read columns left the browser suite's admin with an
|
||||
// empty sidebar: it is how a fresh install's first account gets config.toml's feeds.
|
||||
let db = Db::memory().unwrap();
|
||||
db.exec_for_test("INSERT INTO users (id, name, is_admin, created) VALUES (1,'admin',1,0);")
|
||||
.unwrap();
|
||||
let subs = || -> i64 {
|
||||
db.conn.lock().unwrap().query_row("SELECT count(*) FROM subscriptions", [], |r| r.get(0)).unwrap()
|
||||
};
|
||||
let catalogue = ["a".to_string(), "b".to_string()];
|
||||
assert_eq!(db.adopt_catalogue(1, &catalogue).unwrap(), 2);
|
||||
assert_eq!(subs(), 2);
|
||||
// Once anyone subscribes to anything it never runs again, so an unsubscribe sticks.
|
||||
db.unsubscribe(1, "a").unwrap();
|
||||
assert_eq!(db.adopt_catalogue(1, &catalogue).unwrap(), 0);
|
||||
assert_eq!(subs(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_filter_works_with_and_without_a_search_term() {
|
||||
// Regression: the search clause used to be omitted when no term was given, while
|
||||
@@ -1576,21 +1591,22 @@ mod tests {
|
||||
|
||||
for f in [Filter::All, Filter::Unread, Filter::Downloaded, Filter::Flagged] {
|
||||
// Both paths must run without erroring, and agree with each other.
|
||||
let rows = db.entries(7, "f", f, None, 0, 50).unwrap();
|
||||
let n = db.count_entries(7, "f", f, None).unwrap();
|
||||
let order = order_sql("published", "desc");
|
||||
let rows = db.entries_in(7, Some("f"), f, None, 0, 50, &order).unwrap();
|
||||
let n = db.count_in(7, Some("f"), f, None).unwrap();
|
||||
assert_eq!(rows.len() as i64, n, "{f:?} count disagrees with the page");
|
||||
|
||||
let rows = db.entries(7, "f", f, Some("dive"), 0, 50).unwrap();
|
||||
let n = db.count_entries(7, "f", f, Some("dive")).unwrap();
|
||||
let rows = db.entries_in(7, Some("f"), f, Some("dive"), 0, 50, &order).unwrap();
|
||||
let n = db.count_in(7, Some("f"), f, Some("dive")).unwrap();
|
||||
assert_eq!(rows.len() as i64, n, "{f:?} with search disagrees");
|
||||
}
|
||||
|
||||
assert_eq!(db.count_entries(7, "f", Filter::All, None).unwrap(), 3);
|
||||
assert_eq!(db.count_entries(7, "f", Filter::Unread, None).unwrap(), 1);
|
||||
assert_eq!(db.count_entries(7, "f", Filter::Downloaded, None).unwrap(), 1);
|
||||
assert_eq!(db.count_entries(7, "f", Filter::Flagged, None).unwrap(), 1);
|
||||
assert_eq!(db.count_entries(7, "f", Filter::All, Some("dive")).unwrap(), 2);
|
||||
assert_eq!(db.count_entries(7, "f", Filter::All, Some("NOTES two")).unwrap(), 1,
|
||||
assert_eq!(db.count_in(7, Some("f"), Filter::All, None).unwrap(), 3);
|
||||
assert_eq!(db.count_in(7, Some("f"), Filter::Unread, None).unwrap(), 1);
|
||||
assert_eq!(db.count_in(7, Some("f"), Filter::Downloaded, None).unwrap(), 1);
|
||||
assert_eq!(db.count_in(7, Some("f"), Filter::Flagged, None).unwrap(), 1);
|
||||
assert_eq!(db.count_in(7, Some("f"), Filter::All, Some("dive")).unwrap(), 2);
|
||||
assert_eq!(db.count_in(7, Some("f"), Filter::All, Some("NOTES two")).unwrap(), 1,
|
||||
"search is case-insensitive and covers the description");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user