SeaORM beside rusqlite: entities, schema sync, a second connection

The first step of moving to SeaORM (#18, phase 1). Nothing a user sees changes.

- src/entity.rs: the seven tables as SeaORM entities, matching the SQLite schema.
  Strings are Text, as the columns are; yes/no columns are bool, which is BOOLEAN
  on Postgres and stays INTEGER in the existing SQLite file (sync notes the
  difference and leaves it alone).
- Db holds a SeaORM connection to the same SQLite file beside the rusqlite one;
  functions move to it one at a time, and rusqlite goes with the last of them.
- db::sync creates what a database is missing from the entities (SeaORM's
  schema-sync, experimental, so sea-orm is pinned to ~2.0), plus the two indexes
  an entity cannot express. Checked against a copy of production: it added the
  lower(name) index and changed nothing else.
- Test databases are now built from the entities alone, in a temporary file
  (two connections to one ":memory:" are two databases), so every test also
  checks that the entities describe what the queries need. That caught the one
  difference: finding a user by name relied on COLLATE NOCASE, which Postgres
  lacks; it now compares lower() on both sides.
- rusqlite steps back to 0.39: 0.40's libsqlite3-sys is newer than sqlx accepts,
  and only one may link SQLite. It goes away at the end of this phase.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 18:11:28 +00:00
parent c99e17bd80
commit 484aaa1849
6 changed files with 1213 additions and 105 deletions

859
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,7 +18,8 @@ percent-encoding = "2.3.2"
quick-xml = { version = "0.42.0", features = ["escape-html"] } quick-xml = { version = "0.42.0", features = ["escape-html"] }
reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "http2", "gzip", "stream", "json", "charset", "system-proxy"] } reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "http2", "gzip", "stream", "json", "charset", "system-proxy"] }
rss = "2.1.1" rss = "2.1.1"
rusqlite = { version = "0.40.2", features = ["bundled"] } rusqlite = { version = "0.39", features = ["bundled"] }
sea-orm = { version = "~2.0.3", default-features = false, features = ["sqlx-sqlite", "sqlx-postgres", "runtime-tokio-rustls", "macros", "with-json", "schema-sync", "sqlite-use-returning-for-3_35"] }
serde = { version = "1.0.229", features = ["derive"] } serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151" serde_json = "1.0.151"
tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "fs", "io-util", "net", "sync", "time", "signal"] } tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "fs", "io-util", "net", "sync", "time", "signal"] }

191
src/db.rs
View File

@@ -7,8 +7,63 @@ use std::sync::Mutex;
/// ponytail: one global connection mutex. Writes here are tiny and rare; move to a /// ponytail: one global connection mutex. Writes here are tiny and rare; move to a
/// spawn_blocking pool if a large feed count ever makes it contend. /// spawn_blocking pool if a large feed count ever makes it contend.
///
/// Mid-way through moving to SeaORM (issue #18): `orm` is the new connection, to the same
/// SQLite file, and functions move to it one at a time; `conn` goes when the last one has.
pub struct Db { pub struct Db {
conn: Mutex<Connection>, conn: Mutex<Connection>,
orm: sea_orm::DatabaseConnection,
/// A test's database file, removed when the test is done with it.
#[cfg(test)]
tmp: Option<std::path::PathBuf>,
}
#[cfg(test)]
impl Drop for Db {
fn drop(&mut self) {
if let Some(p) = &self.tmp {
for ext in ["", "-wal", "-shm"] {
let _ = std::fs::remove_file(format!("{}{ext}", p.display()));
}
}
}
}
/// Creates whatever the database is missing from the entities in `crate::entity`: tables,
/// columns, unique keys, foreign keys. It only ever adds. What an entity cannot say -- an
/// index on an expression, or on two columns -- follows as plain SQL both databases accept.
///
/// ponytail: schema sync is experimental in SeaORM 2 and exempt from semver, hence the
/// `~2.0` pin in Cargo.toml; if it changes shape, move to sea-orm-migration files.
async fn sync(orm: &sea_orm::DatabaseConnection) -> Result<()> {
use crate::entity::*;
use sea_orm::ConnectionTrait;
orm.get_schema_builder()
.register(feeds::Entity)
.register(entries::Entity)
.register(enclosures::Entity)
.register(users::Entity)
.register(subscriptions::Entity)
.register(entry_state::Entity)
.register(sessions::Entity)
.sync(orm)
.await
.context("creating the schema")?;
for sql in [
"CREATE INDEX IF NOT EXISTS enclosures_entry ON enclosures (feed_id, guid)",
"CREATE UNIQUE INDEX IF NOT EXISTS users_name_lower ON users (lower(name))",
] {
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
}
Ok(())
}
async fn connect(path: &Path) -> Result<sea_orm::DatabaseConnection> {
let mut opts = sea_orm::ConnectOptions::new(format!("sqlite://{}?mode=rwc", path.display()));
opts.sqlx_logging(false);
sea_orm::Database::connect(opts)
.await
.with_context(|| format!("opening {}", path.display()))
} }
const SCHEMA: &str = " const SCHEMA: &str = "
@@ -243,7 +298,7 @@ pub struct FeedSummary {
} }
impl Db { impl Db {
pub fn open(path: &Path) -> Result<Self> { pub async fn open(path: &Path) -> Result<Self> {
if let Some(dir) = path.parent() { if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir) std::fs::create_dir_all(dir)
.with_context(|| format!("creating {}", dir.display()))?; .with_context(|| format!("creating {}", dir.display()))?;
@@ -255,18 +310,33 @@ impl Db {
conn.pragma_update(None, "busy_timeout", 5000)?; conn.pragma_update(None, "busy_timeout", 5000)?;
conn.execute_batch(SCHEMA).context("creating schema")?; conn.execute_batch(SCHEMA).context("creating schema")?;
migrate(&conn).context("migrating schema")?; migrate(&conn).context("migrating schema")?;
Ok(Self { conn: Mutex::new(conn) }) let orm = connect(path).await?;
sync(&orm).await?;
Ok(Self {
conn: Mutex::new(conn),
orm,
#[cfg(test)]
tmp: None,
})
} }
/// In-memory database, for tests. /// A fresh database for a test, in a file of its own: two connections to one ":memory:"
/// would be two databases. Its schema comes from the entities alone (`sync`), not SCHEMA,
/// so every test also checks that the entities describe everything the queries need.
#[cfg(test)] #[cfg(test)]
pub fn memory() -> Result<Self> { pub async fn memory() -> Result<Self> {
let conn = Connection::open_in_memory()?; static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
conn.execute_batch(SCHEMA)?; let path = std::env::temp_dir().join(format!(
// Same path as a real open, so a column added only in migrate() cannot pass the "ipx-test-{}-{}.db",
// tests while being missing in production (or the reverse). std::process::id(),
migrate(&conn)?; N.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
Ok(Self { conn: Mutex::new(conn) }) ));
let orm = connect(&path).await?;
sync(&orm).await?;
let conn = Connection::open(&path)?;
conn.pragma_update(None, "foreign_keys", "ON")?;
conn.pragma_update(None, "busy_timeout", 5000)?;
Ok(Self { conn: Mutex::new(conn), orm, tmp: Some(path) })
} }
#[cfg(test)] #[cfg(test)]
@@ -1134,8 +1204,11 @@ impl Db {
Ok(conn.last_insert_rowid()) Ok(conn.last_insert_rowid())
} }
/// Without regard to case, on either database: lower() both sides, which the unique index
/// on lower(name) serves. SQLite's COLLATE NOCASE on the column did this before, and
/// Postgres has no such thing.
pub fn user_by_name(&self, name: &str) -> Result<Option<User>> { pub fn user_by_name(&self, name: &str) -> Result<Option<User>> {
self.one_user(&format!("SELECT {USER_COLS} FROM users WHERE name = ?1"), name) self.one_user(&format!("SELECT {USER_COLS} FROM users WHERE lower(name) = lower(?1)"), name)
} }
pub fn user_by_id(&self, id: i64) -> Result<Option<User>> { pub fn user_by_id(&self, id: i64) -> Result<Option<User>> {
@@ -1594,9 +1667,9 @@ pub fn now() -> i64 {
mod tests { mod tests {
use super::*; use super::*;
#[test] #[tokio::test]
fn a_file_wordpress_listed_twice_is_folded_into_one() { async fn a_file_wordpress_listed_twice_is_folded_into_one() {
let db = Db::memory().unwrap(); let db = Db::memory().await.unwrap();
db.exec_for_test( db.exec_for_test(
"INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES "INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
(1,'f','a','https://x/a.mp3','/d/a-2.mp3','done'), (1,'f','a','https://x/a.mp3','/d/a-2.mp3','done'),
@@ -1652,9 +1725,9 @@ mod tests {
assert_eq!(row(&conn).0.as_deref(), Some("e2")); assert_eq!(row(&conn).0.as_deref(), Some("e2"));
} }
#[test] #[tokio::test]
fn every_sort_column_runs_and_orders_both_ways() { async fn every_sort_column_runs_and_orders_both_ways() {
let db = Db::memory().unwrap(); let db = Db::memory().await.unwrap();
db.exec_for_test( db.exec_for_test(
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1); "INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1);
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f'),(1,'g'); INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f'),(1,'g');
@@ -1697,9 +1770,9 @@ mod tests {
assert!(!order_sql("x'; --", "asc", false).contains("x'")); assert!(!order_sql("x'; --", "asc", false).contains("x'"));
} }
#[test] #[tokio::test]
fn deleting_a_shared_file_asks_about_everyone_else() { async fn deleting_a_shared_file_asks_about_everyone_else() {
let db = Db::memory().unwrap(); let db = Db::memory().await.unwrap();
db.exec_for_test( db.exec_for_test(
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1),(2,'sam',0),(3,'kit',0); "INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1),(2,'sam',0),(3,'kit',0);
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f'),(2,'f'),(3,'f'); INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f'),(2,'f'),(3,'f');
@@ -1723,9 +1796,9 @@ mod tests {
assert_eq!(db.others_wanting(1, 3).unwrap(), (0, 0)); assert_eq!(db.others_wanting(1, 3).unwrap(), (0, 0));
} }
#[test] #[tokio::test]
fn read_state_belongs_to_one_person() { async fn read_state_belongs_to_one_person() {
let db = Db::memory().unwrap(); let db = Db::memory().await.unwrap();
db.exec_for_test( db.exec_for_test(
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1),(2,'sam',0); "INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1),(2,'sam',0);
INSERT INTO entries (feed_id, guid, title, first_seen) VALUES INSERT INTO entries (feed_id, guid, title, first_seen) VALUES
@@ -1760,9 +1833,9 @@ mod tests {
assert_eq!(db.unread_count(1, "f").unwrap(), 1); assert_eq!(db.unread_count(1, "f").unwrap(), 1);
} }
#[test] #[tokio::test]
fn schema_is_idempotent_and_summary_handles_unknown_feeds() { async fn schema_is_idempotent_and_summary_handles_unknown_feeds() {
let db = Db::memory().unwrap(); let db = Db::memory().await.unwrap();
// Re-running the schema must not fail: open() does this on every start. // Re-running the schema must not fail: open() does this on every start.
db.conn.lock().unwrap().execute_batch(SCHEMA).unwrap(); db.conn.lock().unwrap().execute_batch(SCHEMA).unwrap();
@@ -1826,9 +1899,9 @@ mod tests {
assert_eq!(kept, 1); assert_eq!(kept, 1);
} }
#[test] #[tokio::test]
fn a_renamed_account_keeps_everything_but_its_name() { async fn a_renamed_account_keeps_everything_but_its_name() {
let db = Db::memory().unwrap(); let db = Db::memory().await.unwrap();
let ray = db.create_user("rays", None, true).unwrap(); let ray = db.create_user("rays", None, true).unwrap();
db.create_user("sam", None, false).unwrap(); db.create_user("sam", None, false).unwrap();
db.subscribe(ray, "f").unwrap(); db.subscribe(ray, "f").unwrap();
@@ -1840,9 +1913,9 @@ mod tests {
assert!(db.rename_user(ray, "sam").is_err(), "a taken name is refused"); assert!(db.rename_user(ray, "sam").is_err(), "a taken name is refused");
} }
#[test] #[tokio::test]
fn an_account_knows_when_it_was_made_and_last_signed_in() { async fn an_account_knows_when_it_was_made_and_last_signed_in() {
let db = Db::memory().unwrap(); let db = Db::memory().await.unwrap();
let id = db.create_user("ray", None, true).unwrap(); let id = db.create_user("ray", None, true).unwrap();
let get = || db.user_by_id(id).unwrap().unwrap(); let get = || db.user_by_id(id).unwrap().unwrap();
assert!(get().created.is_some_and(|t| t > 0)); assert!(get().created.is_some_and(|t| t > 0));
@@ -1858,11 +1931,11 @@ mod tests {
assert!(get().last_login.unwrap() >= first); assert!(get().last_login.unwrap() >= first);
} }
#[test] #[tokio::test]
fn the_first_admin_starts_with_the_catalogue_and_only_once() { async 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 // 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. // empty sidebar: it is how a fresh install's first account gets config.toml's feeds.
let db = Db::memory().unwrap(); let db = Db::memory().await.unwrap();
db.exec_for_test("INSERT INTO users (id, name, is_admin) VALUES (1,'admin',1);") db.exec_for_test("INSERT INTO users (id, name, is_admin) VALUES (1,'admin',1);")
.unwrap(); .unwrap();
let subs = || -> i64 { let subs = || -> i64 {
@@ -1877,12 +1950,12 @@ mod tests {
assert_eq!(subs(), 1); assert_eq!(subs(), 1);
} }
#[test] #[tokio::test]
fn every_filter_works_with_and_without_a_search_term() { async fn every_filter_works_with_and_without_a_search_term() {
// Regression: the search clause used to be omitted when no term was given, while // Regression: the search clause used to be omitted when no term was given, while
// ?2 was still bound -- rusqlite rejects a parameter the statement never mentions, // ?2 was still bound -- rusqlite rejects a parameter the statement never mentions,
// so plain filtering failed with "Wrong number of parameters passed to query". // so plain filtering failed with "Wrong number of parameters passed to query".
let db = Db::memory().unwrap(); let db = Db::memory().await.unwrap();
db.exec_for_test( db.exec_for_test(
"INSERT INTO entries (feed_id, guid, title, description, first_seen, duration) VALUES "INSERT INTO entries (feed_id, guid, title, description, first_seen, duration) VALUES
('f','a','Alpha dive','notes one', 100,NULL), ('f','a','Alpha dive','notes one', 100,NULL),
@@ -1952,10 +2025,10 @@ mod tests {
assert_eq!(listening(), ["h", "e"]); assert_eq!(listening(), ["h", "e"]);
} }
#[test] #[tokio::test]
fn pending_takes_the_latest_episodes_first() { async fn pending_takes_the_latest_episodes_first() {
// A cap of 3 must mean the three newest, not the three recorded first. // A cap of 3 must mean the three newest, not the three recorded first.
let db = Db::memory().unwrap(); let db = Db::memory().await.unwrap();
db.exec_for_test( db.exec_for_test(
"INSERT INTO entries (feed_id, guid, published, first_seen) VALUES "INSERT INTO entries (feed_id, guid, published, first_seen) VALUES
('f','old',100,100), ('f','mid',200,200), ('f','new',300,300); ('f','old',100,100), ('f','mid',200,200), ('f','new',300,300);
@@ -1969,9 +2042,9 @@ mod tests {
assert_eq!(got, vec!["u-new", "u-mid"], "newest first, oldest left for later"); assert_eq!(got, vec!["u-new", "u-mid"], "newest first, oldest left for later");
} }
#[test] #[tokio::test]
fn a_restart_requeues_interrupted_downloads() { async fn a_restart_requeues_interrupted_downloads() {
let db = Db::memory().unwrap(); let db = Db::memory().await.unwrap();
db.exec_for_test( db.exec_for_test(
"INSERT INTO enclosures (id, feed_id, guid, url, state, path) VALUES "INSERT INTO enclosures (id, feed_id, guid, url, state, path) VALUES
(1,'f','a','u1','downloading',NULL), (1,'f','a','u1','downloading',NULL),
@@ -1990,9 +2063,9 @@ mod tests {
assert_eq!(state(4), "done"); assert_eq!(state(4), "done");
} }
#[test] #[tokio::test]
fn a_show_takes_over_what_its_creator_held() { async fn a_show_takes_over_what_its_creator_held() {
let db = Db::memory().unwrap(); let db = Db::memory().await.unwrap();
db.exec_for_test( db.exec_for_test(
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1); "INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1);
INSERT INTO enclosures (id, feed_id, guid, url, state, path, last_error) VALUES INSERT INTO enclosures (id, feed_id, guid, url, state, path, last_error) VALUES
@@ -2026,9 +2099,9 @@ mod tests {
assert!(db.skipped_by_filter("other").unwrap().is_empty(), "torrents disabled is not a filter"); assert!(db.skipped_by_filter("other").unwrap().is_empty(), "torrents disabled is not a filter");
} }
#[test] #[tokio::test]
fn a_feed_in_a_group_follows_your_settings_on_the_group() { async fn a_feed_in_a_group_follows_your_settings_on_the_group() {
let db = Db::memory().unwrap(); let db = Db::memory().await.unwrap();
db.exec_for_test( db.exec_for_test(
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1),(2,'sam',0); "INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1),(2,'sam',0);
INSERT INTO subscriptions (user_id, feed_id, allow_explicit) VALUES INSERT INTO subscriptions (user_id, feed_id, allow_explicit) VALUES
@@ -2045,9 +2118,9 @@ mod tests {
assert_eq!(explicit(None), [None, Some(false)], "outside a group nothing is inherited"); assert_eq!(explicit(None), [None, Some(false)], "outside a group nothing is inherited");
} }
#[test] #[tokio::test]
fn error_since_marks_the_start_of_a_run_of_failures_and_clears_on_success() { async fn error_since_marks_the_start_of_a_run_of_failures_and_clears_on_success() {
let db = Db::memory().unwrap(); let db = Db::memory().await.unwrap();
db.set_feed_error("f", "http://x", "HTTP 404").unwrap(); db.set_feed_error("f", "http://x", "HTTP 404").unwrap();
// Backdate it, as if this feed had already been failing a while, so a second // Backdate it, as if this feed had already been failing a while, so a second
// failure landing "now" is distinguishable from the first. // failure landing "now" is distinguishable from the first.
@@ -2065,9 +2138,9 @@ mod tests {
assert_eq!(after.error_since, None, "a clean check ends the run of failures"); assert_eq!(after.error_since, None, "a clean check ends the run of failures");
} }
#[test] #[tokio::test]
fn a_pin_survives_saving_the_feeds_settings_and_needs_a_subscription() { async fn a_pin_survives_saving_the_feeds_settings_and_needs_a_subscription() {
let db = Db::memory().unwrap(); let db = Db::memory().await.unwrap();
let me = db.create_user("pat", None, false).unwrap(); let me = db.create_user("pat", None, false).unwrap();
assert!(!db.set_pinned(me, "f", true).unwrap(), "not subscribed: nothing to pin"); assert!(!db.set_pinned(me, "f", true).unwrap(), "not subscribed: nothing to pin");
db.subscribe(me, "f").unwrap(); db.subscribe(me, "f").unwrap();
@@ -2080,9 +2153,9 @@ mod tests {
assert!(db.pinned_feeds(me).unwrap().is_empty()); assert!(db.pinned_feeds(me).unwrap().is_empty());
} }
#[test] #[tokio::test]
fn enclosure_url_is_the_dedupe_key() { async fn enclosure_url_is_the_dedupe_key() {
let db = Db::memory().unwrap(); let db = Db::memory().await.unwrap();
let conn = db.conn.lock().unwrap(); let conn = db.conn.lock().unwrap();
let insert = "INSERT INTO enclosures (feed_id, guid, url, state) VALUES ('f', 'g', 'http://x/a.mp3', 'pending')"; let insert = "INSERT INTO enclosures (feed_id, guid, url, state) VALUES ('f', 'g', 'http://x/a.mp3', 'pending')";
conn.execute(insert, []).unwrap(); conn.execute(insert, []).unwrap();

224
src/entity.rs Normal file
View File

@@ -0,0 +1,224 @@
//! The database's tables as SeaORM entities: the one description of the schema, from which
//! `Db::open` creates what a database is missing, on SQLite or Postgres alike (see `db::sync`).
//! What each column means is in the comments on `db::SCHEMA`, the SQLite schema these match.
pub mod feeds {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "feeds")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub id: String,
#[sea_orm(column_type = "Text")]
pub url: String,
#[sea_orm(column_type = "Text", nullable)]
pub title: Option<String>,
#[sea_orm(column_type = "Text", nullable)]
pub image: Option<String>,
#[sea_orm(column_type = "Text", nullable)]
pub category: Option<String>,
#[sea_orm(column_type = "Text", nullable)]
pub etag: Option<String>,
#[sea_orm(column_type = "Text", nullable)]
pub last_modified: Option<String>,
pub last_checked: Option<i64>,
pub ttl_mins: Option<i64>,
#[sea_orm(column_type = "Text", nullable)]
pub last_error: Option<String>,
pub error_since: Option<i64>,
#[sea_orm(default_value = false)]
pub orphaned: bool,
#[sea_orm(column_type = "Text", nullable)]
pub group_id: Option<String>,
#[sea_orm(default_value = false)]
pub managed: bool,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}
pub mod entries {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "entries")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub feed_id: String,
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub guid: String,
#[sea_orm(column_type = "Text", nullable)]
pub title: Option<String>,
#[sea_orm(column_type = "Text", nullable)]
pub link: Option<String>,
pub published: Option<i64>,
#[sea_orm(column_type = "Text", nullable)]
pub description: Option<String>,
pub first_seen: i64,
#[sea_orm(column_type = "Text", nullable)]
pub image: Option<String>,
pub duration: Option<i64>,
pub episode: Option<i64>,
pub season: Option<i64>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}
pub mod enclosures {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "enclosures")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
#[sea_orm(column_type = "Text")]
pub feed_id: String,
#[sea_orm(column_type = "Text")]
pub guid: String,
/// The dedupe key: one file serves every subscriber.
#[sea_orm(unique, column_type = "Text")]
pub url: String,
#[sea_orm(column_type = "Text", nullable)]
pub mime: Option<String>,
pub length: Option<i64>,
#[sea_orm(column_type = "Text", nullable)]
pub path: Option<String>,
#[sea_orm(column_type = "Text")]
pub state: String,
#[sea_orm(default_value = 0)]
pub bytes_done: i64,
pub downloaded_at: Option<i64>,
#[sea_orm(column_type = "Text", nullable)]
pub last_error: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}
pub mod users {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "users")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
/// Unique without regard to case: `db::sync` adds the index on lower(name), which
/// works the same on both databases where SQLite's COLLATE NOCASE does not.
#[sea_orm(column_type = "Text")]
pub name: String,
#[sea_orm(column_type = "Text", nullable)]
pub pass_hash: Option<String>,
#[sea_orm(default_value = false)]
pub is_admin: bool,
pub created: Option<i64>,
pub last_login: Option<i64>,
#[sea_orm(column_type = "Text", nullable)]
pub theme: Option<String>,
#[sea_orm(column_type = "Text", nullable)]
pub theme_mode: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}
/// A table of one person's rows, gone when they are.
macro_rules! owned_by_user {
() => {
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::users::Entity",
from = "Column::UserId",
to = "super::users::Column::Id",
on_delete = "Cascade"
)]
User,
}
impl Related<super::users::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
impl ActiveModelBehavior for ActiveModel {}
};
}
pub mod subscriptions {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "subscriptions")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub user_id: i64,
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub feed_id: String,
/// JSON array of strings; NULL follows the feed.
#[sea_orm(column_type = "Text", nullable)]
pub keywords: Option<String>,
pub auto_download: Option<bool>,
pub allow_explicit: Option<bool>,
pub max_new_per_check: Option<i64>,
#[sea_orm(default_value = false)]
pub pinned: bool,
}
owned_by_user!();
}
pub mod entry_state {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "entry_state")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub user_id: i64,
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub feed_id: String,
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub guid: String,
#[sea_orm(default_value = false)]
pub read: bool,
#[sea_orm(default_value = false)]
pub flagged: bool,
#[sea_orm(default_value = 0)]
pub position: i64,
pub duration: Option<i64>,
}
owned_by_user!();
}
pub mod sessions {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "sessions")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub token: String,
pub user_id: i64,
pub seen: i64,
}
owned_by_user!();
}

View File

@@ -1,6 +1,7 @@
mod auth; mod auth;
mod config; mod config;
mod db; mod db;
mod entity;
mod download; mod download;
mod feed; mod feed;
mod ipc; mod ipc;
@@ -179,7 +180,7 @@ async fn main() -> Result<()> {
let config_path = cli.config.clone().unwrap_or_else(config::config_path); let config_path = cli.config.clone().unwrap_or_else(config::config_path);
let cfg = config::Config::load(&config_path)?; let cfg = config::Config::load(&config_path)?;
let db = db::Db::open(&config::data_dir().join("state.db"))?; let db = db::Db::open(&config::data_dir().join("state.db")).await?;
// A daemon owns the state; don't have two processes downloading the same thing. // A daemon owns the state; don't have two processes downloading the same thing.
let wire_cmd = match &cli.command { let wire_cmd = match &cli.command {
@@ -1690,8 +1691,8 @@ mod tests {
} }
} }
#[test] #[tokio::test]
fn a_shared_feed_is_fetched_for_whoever_wants_the_most() { async fn a_shared_feed_is_fetched_for_whoever_wants_the_most() {
// Nobody subscribed: the feed's own settings stand, as in a single-user install. // Nobody subscribed: the feed's own settings stand, as in a single-user install.
let p = merge_policy(&[], &feed(), 3); let p = merge_policy(&[], &feed(), 3);
assert!(p.auto_download); assert!(p.auto_download);
@@ -1721,10 +1722,10 @@ mod tests {
assert!(p.auto_download); assert!(p.auto_download);
} }
fn test_ctx(cfg: config::Config) -> Ctx { async fn test_ctx(cfg: config::Config) -> Ctx {
Ctx { Ctx {
cfg: std::sync::RwLock::new(Arc::new(cfg)), cfg: std::sync::RwLock::new(Arc::new(cfg)),
db: db::Db::memory().unwrap(), db: db::Db::memory().await.unwrap(),
client: reqwest::Client::new(), client: reqwest::Client::new(),
out: Emitter::terminal(), out: Emitter::terminal(),
torrents: tokio::sync::OnceCell::new(), torrents: tokio::sync::OnceCell::new(),
@@ -1734,11 +1735,11 @@ mod tests {
} }
} }
#[test] #[tokio::test]
fn a_derived_feed_is_not_scanned_once_its_opml_leaves_config() { async fn a_derived_feed_is_not_scanned_once_its_opml_leaves_config() {
// davewiner: the OPML subscription left config.toml, but its 922 derived rows // davewiner: the OPML subscription left config.toml, but its 922 derived rows
// stayed in the database and kept being scanned under the no-parent fallback. // stayed in the database and kept being scanned under the no-parent fallback.
let ctx = test_ctx(config::Config::default()); let ctx = test_ctx(config::Config::default()).await;
ctx.db.upsert_managed("child", "http://x/child.xml", "Child", "gone-opml").unwrap(); ctx.db.upsert_managed("child", "http://x/child.xml", "Child", "gone-opml").unwrap();
assert!( assert!(
subscriptions(&ctx).unwrap().iter().all(|s| s.id != "child"), subscriptions(&ctx).unwrap().iter().all(|s| s.id != "child"),
@@ -1746,9 +1747,9 @@ mod tests {
); );
} }
#[test] #[tokio::test]
fn retiring_a_group_drops_what_was_never_downloaded_and_orphans_the_rest() { async fn retiring_a_group_drops_what_was_never_downloaded_and_orphans_the_rest() {
let ctx = test_ctx(config::Config::default()); let ctx = test_ctx(config::Config::default()).await;
ctx.db.upsert_managed("empty", "http://x/empty.xml", "Empty", "parent").unwrap(); ctx.db.upsert_managed("empty", "http://x/empty.xml", "Empty", "parent").unwrap();
ctx.db.upsert_managed("has-file", "http://x/has-file.xml", "Has File", "parent").unwrap(); ctx.db.upsert_managed("has-file", "http://x/has-file.xml", "Has File", "parent").unwrap();
let enc = feed::Enclosure { url: "http://x/ep.mp3".into(), mime: None, length: None }; let enc = feed::Enclosure { url: "http://x/ep.mp3".into(), mime: None, length: None };
@@ -1763,14 +1764,14 @@ mod tests {
assert!(ctx.db.feed_summary("has-file").unwrap().orphaned, "and flagged as orphaned"); assert!(ctx.db.feed_summary("has-file").unwrap().orphaned, "and flagged as orphaned");
} }
#[test] #[tokio::test]
fn a_stranded_group_is_retired_but_a_promoted_feed_keeps_its_entries() { async fn a_stranded_group_is_retired_but_a_promoted_feed_keeps_its_entries() {
// davewiner: the OPML left config before retire_group existed, and 11 of its feeds // davewiner: the OPML left config before retire_group existed, and 11 of its feeds
// promoted to config since still said managed = 1. // promoted to config since still said managed = 1.
let mut cfg = config::Config::default(); let mut cfg = config::Config::default();
cfg.feeds.insert("promoted".into(), feed()); cfg.feeds.insert("promoted".into(), feed());
cfg.feeds.insert("live-opml".into(), feed()); cfg.feeds.insert("live-opml".into(), feed());
let ctx = test_ctx(cfg); let ctx = test_ctx(cfg).await;
ctx.db.upsert_managed("promoted", "http://x/p.xml", "Promoted", "gone-opml").unwrap(); ctx.db.upsert_managed("promoted", "http://x/p.xml", "Promoted", "gone-opml").unwrap();
ctx.db.upsert_managed("empty", "http://x/e.xml", "Empty", "gone-opml").unwrap(); ctx.db.upsert_managed("empty", "http://x/e.xml", "Empty", "gone-opml").unwrap();
ctx.db.upsert_managed("listed", "http://x/l.xml", "Listed", "live-opml").unwrap(); ctx.db.upsert_managed("listed", "http://x/l.xml", "Listed", "live-opml").unwrap();

View File

@@ -149,10 +149,10 @@ mod tests {
// age_key 0 means "never recorded" -- not the same as "infinitely old". // age_key 0 means "never recorded" -- not the same as "infinitely old".
} }
#[test] #[tokio::test]
fn query_never_offers_a_file_anyone_starred_and_prefers_ones_everyone_read() { async fn query_never_offers_a_file_anyone_starred_and_prefers_ones_everyone_read() {
// One file serves both subscribers, so it takes both of them to release it. // One file serves both subscribers, so it takes both of them to release it.
let db = Db::memory().unwrap(); let db = Db::memory().await.unwrap();
db.exec_for_test( db.exec_for_test(
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1),(2,'sam',0); "INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1),(2,'sam',0);
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f'),(2,'f'); INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f'),(2,'f');
@@ -185,9 +185,9 @@ mod tests {
); );
} }
#[test] #[tokio::test]
fn prune_keeps_entries_that_still_have_a_file() { async fn prune_keeps_entries_that_still_have_a_file() {
let db = Db::memory().unwrap(); let db = Db::memory().await.unwrap();
db.exec_for_test( db.exec_for_test(
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1); "INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1);
INSERT INTO entry_state (user_id, feed_id, guid, flagged) VALUES (1,'f','flagged',1); INSERT INTO entry_state (user_id, feed_id, guid, flagged) VALUES (1,'f','flagged',1);