The last nineteen functions move to SeaORM: recording feeds, items and enclosures, managed OPML feeds, folding WordPress's repeated files, and handing a Patreon creator's files to its shows. Two SQLite-only forms go: GLOB becomes a LIKE with the underscore escaped (broader, harmlessly: the fold still keys on `_=` and digits), and UPDATE OR IGNORE becomes an UPDATE ... WHERE NOT EXISTS. The two transactions are SeaORM transactions. With nothing left on it, rusqlite goes, with the SQL schema and migrate(). The entities are the schema: create_missing makes whatever tables and indexes a database lacks, from them, with CREATE ... IF NOT EXISTS. Production's schema already has every column migrate() added and none it dropped. Not SeaORM's schema sync, used until now: despite its docs it drops a unique index the entities do not describe, so it dropped users_name_lower on every open. Every `ipx` command then took a write lock, and against a daemon busy writing, `ipx status` -- the healthcheck -- failed 7 times in 15 where the old code failed none. Now 15 in 15, as before. On Postgres it would not have started. WAL is set only when a file is not already in it: setting it takes a lock that cannot wait out a busy daemon. Checked on copies of production: a forced scan of all 162 feeds against the real feeds with no database errors; the feed list, filters, sorts, search and the reaper's candidates against the old code on the same data, earlier in the branch. The column comments from the SQL schema move to the entities. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
245 lines
8.8 KiB
Rust
245 lines
8.8 KiB
Rust
//! 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::create_missing`). Times are Unix seconds.
|
|
|
|
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>,
|
|
/// The channel's first <itunes:category>, for the Directory.
|
|
#[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>,
|
|
/// When the current run of failures began; NULL while the feed is healthy. Kept through
|
|
/// repeated failures so the UI can tell a blip (macmanx: failed once, fine an hour
|
|
/// later) from a feed that has been down for a day.
|
|
pub error_since: Option<i64>,
|
|
/// Came from a subscribed OPML that no longer lists it, but has downloads, so kept.
|
|
#[sea_orm(default_value = false)]
|
|
pub orphaned: bool,
|
|
/// The OPML subscription this feed came from.
|
|
#[sea_orm(column_type = "Text", nullable)]
|
|
pub group_id: Option<String>,
|
|
/// Derived from an OPML and not written to config.toml. Writing 80-odd generated entries
|
|
/// into a hand-edited file made it unreadable; the OPML is the source of truth, so they
|
|
/// are re-derived instead. Customising one promotes it to config.
|
|
#[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, and the reason one file serves every subscriber. A reaped file keeps
|
|
/// its row with path NULL and state 'reaped', so a purged episode is never fetched again.
|
|
#[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::create_missing` 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,
|
|
/// NULL for someone who only ever arrives through the proxy: there is no password to
|
|
/// check, and leaving it empty is not the same as leaving it unset.
|
|
#[sea_orm(column_type = "Text", nullable)]
|
|
pub pass_hash: Option<String>,
|
|
#[sea_orm(default_value = false)]
|
|
pub is_admin: bool,
|
|
/// For whoever maintains the server. NULL where it is not known.
|
|
pub created: Option<i64>,
|
|
pub last_login: Option<i64>,
|
|
/// The theme chosen in Settings, and light, dark or auto. NULL until one is chosen.
|
|
#[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 {}
|
|
};
|
|
}
|
|
|
|
/// What one person wants from a feed. The feed, its items and its files are shared; this is the
|
|
/// part that is not. NULL in a column means: follow the feed's own setting.
|
|
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>,
|
|
/// Pinned to the top of this person's feed list, a feed inside a folder included.
|
|
#[sea_orm(default_value = false)]
|
|
pub pinned: bool,
|
|
}
|
|
|
|
owned_by_user!();
|
|
}
|
|
|
|
/// 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.
|
|
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,
|
|
/// The length this person's player measured, beside the position it is measured against.
|
|
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!();
|
|
}
|