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:
191
src/db.rs
191
src/db.rs
@@ -7,8 +7,63 @@ use std::sync::Mutex;
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// 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 {
|
||||
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 = "
|
||||
@@ -243,7 +298,7 @@ pub struct FeedSummary {
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub fn open(path: &Path) -> Result<Self> {
|
||||
pub async fn open(path: &Path) -> Result<Self> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)
|
||||
.with_context(|| format!("creating {}", dir.display()))?;
|
||||
@@ -255,18 +310,33 @@ impl Db {
|
||||
conn.pragma_update(None, "busy_timeout", 5000)?;
|
||||
conn.execute_batch(SCHEMA).context("creating 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)]
|
||||
pub fn memory() -> Result<Self> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
conn.execute_batch(SCHEMA)?;
|
||||
// Same path as a real open, so a column added only in migrate() cannot pass the
|
||||
// tests while being missing in production (or the reverse).
|
||||
migrate(&conn)?;
|
||||
Ok(Self { conn: Mutex::new(conn) })
|
||||
pub async fn memory() -> Result<Self> {
|
||||
static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"ipx-test-{}-{}.db",
|
||||
std::process::id(),
|
||||
N.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
));
|
||||
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)]
|
||||
@@ -1134,8 +1204,11 @@ impl Db {
|
||||
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>> {
|
||||
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>> {
|
||||
@@ -1594,9 +1667,9 @@ pub fn now() -> i64 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_file_wordpress_listed_twice_is_folded_into_one() {
|
||||
let db = Db::memory().unwrap();
|
||||
#[tokio::test]
|
||||
async fn a_file_wordpress_listed_twice_is_folded_into_one() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
db.exec_for_test(
|
||||
"INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
|
||||
(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"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_sort_column_runs_and_orders_both_ways() {
|
||||
let db = Db::memory().unwrap();
|
||||
#[tokio::test]
|
||||
async fn every_sort_column_runs_and_orders_both_ways() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
db.exec_for_test(
|
||||
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1);
|
||||
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'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_shared_file_asks_about_everyone_else() {
|
||||
let db = Db::memory().unwrap();
|
||||
#[tokio::test]
|
||||
async fn deleting_a_shared_file_asks_about_everyone_else() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
db.exec_for_test(
|
||||
"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');
|
||||
@@ -1723,9 +1796,9 @@ mod tests {
|
||||
assert_eq!(db.others_wanting(1, 3).unwrap(), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_state_belongs_to_one_person() {
|
||||
let db = Db::memory().unwrap();
|
||||
#[tokio::test]
|
||||
async fn read_state_belongs_to_one_person() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
db.exec_for_test(
|
||||
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1),(2,'sam',0);
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_is_idempotent_and_summary_handles_unknown_feeds() {
|
||||
let db = Db::memory().unwrap();
|
||||
#[tokio::test]
|
||||
async fn schema_is_idempotent_and_summary_handles_unknown_feeds() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
// Re-running the schema must not fail: open() does this on every start.
|
||||
db.conn.lock().unwrap().execute_batch(SCHEMA).unwrap();
|
||||
|
||||
@@ -1826,9 +1899,9 @@ mod tests {
|
||||
assert_eq!(kept, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_renamed_account_keeps_everything_but_its_name() {
|
||||
let db = Db::memory().unwrap();
|
||||
#[tokio::test]
|
||||
async fn a_renamed_account_keeps_everything_but_its_name() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
let ray = db.create_user("rays", None, true).unwrap();
|
||||
db.create_user("sam", None, false).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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_account_knows_when_it_was_made_and_last_signed_in() {
|
||||
let db = Db::memory().unwrap();
|
||||
#[tokio::test]
|
||||
async fn an_account_knows_when_it_was_made_and_last_signed_in() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
let id = db.create_user("ray", None, true).unwrap();
|
||||
let get = || db.user_by_id(id).unwrap().unwrap();
|
||||
assert!(get().created.is_some_and(|t| t > 0));
|
||||
@@ -1858,11 +1931,11 @@ mod tests {
|
||||
assert!(get().last_login.unwrap() >= first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_admin_starts_with_the_catalogue_and_only_once() {
|
||||
#[tokio::test]
|
||||
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
|
||||
// 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);")
|
||||
.unwrap();
|
||||
let subs = || -> i64 {
|
||||
@@ -1877,12 +1950,12 @@ mod tests {
|
||||
assert_eq!(subs(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_filter_works_with_and_without_a_search_term() {
|
||||
#[tokio::test]
|
||||
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
|
||||
// ?2 was still bound -- rusqlite rejects a parameter the statement never mentions,
|
||||
// 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(
|
||||
"INSERT INTO entries (feed_id, guid, title, description, first_seen, duration) VALUES
|
||||
('f','a','Alpha dive','notes one', 100,NULL),
|
||||
@@ -1952,10 +2025,10 @@ mod tests {
|
||||
assert_eq!(listening(), ["h", "e"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_takes_the_latest_episodes_first() {
|
||||
#[tokio::test]
|
||||
async fn pending_takes_the_latest_episodes_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(
|
||||
"INSERT INTO entries (feed_id, guid, published, first_seen) VALUES
|
||||
('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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_restart_requeues_interrupted_downloads() {
|
||||
let db = Db::memory().unwrap();
|
||||
#[tokio::test]
|
||||
async fn a_restart_requeues_interrupted_downloads() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
db.exec_for_test(
|
||||
"INSERT INTO enclosures (id, feed_id, guid, url, state, path) VALUES
|
||||
(1,'f','a','u1','downloading',NULL),
|
||||
@@ -1990,9 +2063,9 @@ mod tests {
|
||||
assert_eq!(state(4), "done");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_show_takes_over_what_its_creator_held() {
|
||||
let db = Db::memory().unwrap();
|
||||
#[tokio::test]
|
||||
async fn a_show_takes_over_what_its_creator_held() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
db.exec_for_test(
|
||||
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1);
|
||||
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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_feed_in_a_group_follows_your_settings_on_the_group() {
|
||||
let db = Db::memory().unwrap();
|
||||
#[tokio::test]
|
||||
async fn a_feed_in_a_group_follows_your_settings_on_the_group() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
db.exec_for_test(
|
||||
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1),(2,'sam',0);
|
||||
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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_since_marks_the_start_of_a_run_of_failures_and_clears_on_success() {
|
||||
let db = Db::memory().unwrap();
|
||||
#[tokio::test]
|
||||
async fn error_since_marks_the_start_of_a_run_of_failures_and_clears_on_success() {
|
||||
let db = Db::memory().await.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
|
||||
// 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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pin_survives_saving_the_feeds_settings_and_needs_a_subscription() {
|
||||
let db = Db::memory().unwrap();
|
||||
#[tokio::test]
|
||||
async fn a_pin_survives_saving_the_feeds_settings_and_needs_a_subscription() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
let me = db.create_user("pat", None, false).unwrap();
|
||||
assert!(!db.set_pinned(me, "f", true).unwrap(), "not subscribed: nothing to pin");
|
||||
db.subscribe(me, "f").unwrap();
|
||||
@@ -2080,9 +2153,9 @@ mod tests {
|
||||
assert!(db.pinned_feeds(me).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enclosure_url_is_the_dedupe_key() {
|
||||
let db = Db::memory().unwrap();
|
||||
#[tokio::test]
|
||||
async fn enclosure_url_is_the_dedupe_key() {
|
||||
let db = Db::memory().await.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')";
|
||||
conn.execute(insert, []).unwrap();
|
||||
|
||||
224
src/entity.rs
Normal file
224
src/entity.rs
Normal 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!();
|
||||
}
|
||||
29
src/main.rs
29
src/main.rs
@@ -1,6 +1,7 @@
|
||||
mod auth;
|
||||
mod config;
|
||||
mod db;
|
||||
mod entity;
|
||||
mod download;
|
||||
mod feed;
|
||||
mod ipc;
|
||||
@@ -179,7 +180,7 @@ async fn main() -> Result<()> {
|
||||
|
||||
let config_path = cli.config.clone().unwrap_or_else(config::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.
|
||||
let wire_cmd = match &cli.command {
|
||||
@@ -1690,8 +1691,8 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shared_feed_is_fetched_for_whoever_wants_the_most() {
|
||||
#[tokio::test]
|
||||
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.
|
||||
let p = merge_policy(&[], &feed(), 3);
|
||||
assert!(p.auto_download);
|
||||
@@ -1721,10 +1722,10 @@ mod tests {
|
||||
assert!(p.auto_download);
|
||||
}
|
||||
|
||||
fn test_ctx(cfg: config::Config) -> Ctx {
|
||||
async fn test_ctx(cfg: config::Config) -> Ctx {
|
||||
Ctx {
|
||||
cfg: std::sync::RwLock::new(Arc::new(cfg)),
|
||||
db: db::Db::memory().unwrap(),
|
||||
db: db::Db::memory().await.unwrap(),
|
||||
client: reqwest::Client::new(),
|
||||
out: Emitter::terminal(),
|
||||
torrents: tokio::sync::OnceCell::new(),
|
||||
@@ -1734,11 +1735,11 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_derived_feed_is_not_scanned_once_its_opml_leaves_config() {
|
||||
#[tokio::test]
|
||||
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
|
||||
// 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();
|
||||
assert!(
|
||||
subscriptions(&ctx).unwrap().iter().all(|s| s.id != "child"),
|
||||
@@ -1746,9 +1747,9 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retiring_a_group_drops_what_was_never_downloaded_and_orphans_the_rest() {
|
||||
let ctx = test_ctx(config::Config::default());
|
||||
#[tokio::test]
|
||||
async fn retiring_a_group_drops_what_was_never_downloaded_and_orphans_the_rest() {
|
||||
let ctx = test_ctx(config::Config::default()).await;
|
||||
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();
|
||||
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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stranded_group_is_retired_but_a_promoted_feed_keeps_its_entries() {
|
||||
#[tokio::test]
|
||||
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
|
||||
// promoted to config since still said managed = 1.
|
||||
let mut cfg = config::Config::default();
|
||||
cfg.feeds.insert("promoted".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("empty", "http://x/e.xml", "Empty", "gone-opml").unwrap();
|
||||
ctx.db.upsert_managed("listed", "http://x/l.xml", "Listed", "live-opml").unwrap();
|
||||
|
||||
@@ -149,10 +149,10 @@ mod tests {
|
||||
// age_key 0 means "never recorded" -- not the same as "infinitely old".
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_never_offers_a_file_anyone_starred_and_prefers_ones_everyone_read() {
|
||||
#[tokio::test]
|
||||
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.
|
||||
let db = Db::memory().unwrap();
|
||||
let db = Db::memory().await.unwrap();
|
||||
db.exec_for_test(
|
||||
"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');
|
||||
@@ -185,9 +185,9 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_keeps_entries_that_still_have_a_file() {
|
||||
let db = Db::memory().unwrap();
|
||||
#[tokio::test]
|
||||
async fn prune_keeps_entries_that_still_have_a_file() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
db.exec_for_test(
|
||||
"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);
|
||||
|
||||
Reference in New Issue
Block a user