Postgres: pick the database by URL, copy-db, tests on both

- IPX_DATABASE_URL (postgres://...) picks the database; unset, it is the SQLite
  file as before. Passwords are taken out of anything logged.
- `ipx copy-db <state.db>` copies every table into the empty database the URL
  names, in one transaction, and moves the id counters past the copied ids. A
  copy of production went across in 14s with every count and column
  fingerprint identical.
- With IPX_TEST_DATABASE_URL set, each test gets a Postgres schema of its own;
  all 79 pass on both databases. Fixtures write booleans as true/false.
- Sorts say where an item with no value goes (NULLS FIRST going up, LAST going
  down): SQLite counts NULL as smallest, Postgres as largest, so "largest first"
  on Postgres led with every item that has no file. Tested on both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 20:28:50 +00:00
parent bd8f6ab855
commit bc53e0f730
3 changed files with 193 additions and 48 deletions

View File

@@ -37,6 +37,12 @@ struct Cli {
enum Command {
/// Show configured feeds and their state
List,
/// Copy everything from a SQLite state.db into the database IPX_DATABASE_URL names, which
/// must be empty: the one-off move to Postgres
CopyDb {
/// The SQLite file to copy from
from: PathBuf,
},
/// Scan feeds for new entries
Fetch {
/// Only this feed id
@@ -180,7 +186,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")).await?;
let db = db::Db::open(&db::location()).await?;
// A daemon owns the state; don't have two processes downloading the same thing.
let wire_cmd = match &cli.command {
@@ -195,7 +201,8 @@ async fn main() -> Result<()> {
| Command::Add { .. }
| Command::Rm { .. }
| Command::Import { .. }
| Command::Export { .. } => None,
| Command::Export { .. }
| Command::CopyDb { .. } => None,
};
if let Some(cmd) = &wire_cmd
&& !cli.local
@@ -229,6 +236,7 @@ async fn main() -> Result<()> {
Command::User { cmd } => user_cmd(&ctx, cmd).await,
Command::Import { file } => import(&ctx, &config_path, &file).await,
Command::Export { file } => export(&ctx, &file).await,
Command::CopyDb { from } => copy_db(&ctx, &from).await,
_ => run(&ctx, wire_cmd.expect("only List and Daemon have no wire form")).await,
}
}
@@ -770,6 +778,15 @@ pub fn collect_outlines(outlines: &[opml::Outline], out: &mut Vec<(String, Strin
}
}
async fn copy_db(ctx: &Ctx, from: &std::path::Path) -> Result<()> {
anyhow::ensure!(from.exists(), "{} does not exist", from.display());
let source = db::Db::open(&from.display().to_string()).await?;
for (table, n) in ctx.db.copy_from(&source).await? {
println!("{table:14} {n}");
}
Ok(())
}
async fn export(ctx: &Ctx, file: &std::path::Path) -> Result<()> {
let mut doc = opml::OPML::default();
doc.head = Some(opml::Head {