.ndjson
+//! thumbnails/ (files.thumbnail_dir, when bundled)
+//! uploads/ (files.uploads_dir, when bundled)
+//! plugins/ (files.plugins_dir, when bundled)
+//! cache/ (pdf.cache_dir, only with --include-cache)
+//! ```
+
+use std::path::{Path, PathBuf};
+
+use anyhow::{Context, Result};
+use sea_orm::{ConnectionTrait, DatabaseConnection};
+
+use crate::manifest::{
+ ARCHIVE_FORMAT_VERSION, ArtifactEntry, ArtifactGroup, Manifest, TableCount, backend_name,
+ schema_version,
+};
+use crate::reroot::{self, RerootStats};
+use crate::{TransferReport, registry};
+
+/// An artifact tree to bundle on export: which group, and the source
+/// instance's directory that currently holds those files.
+#[derive(Clone, Debug)]
+pub struct ArtifactSource {
+ pub group: ArtifactGroup,
+ pub source_dir: PathBuf,
+}
+
+/// Where an artifact group's files should be written on import, for the target
+/// instance.
+#[derive(Clone, Debug)]
+pub struct ArtifactTarget {
+ pub group: ArtifactGroup,
+ pub target_dir: PathBuf,
+}
+
+/// Outcome of [`import_archive`].
+#[derive(Debug)]
+pub struct ImportOutcome {
+ pub manifest: Manifest,
+ pub report: TransferReport,
+ pub reroot: RerootStats,
+}
+
+/// Export `conn` and the given artifact trees into a `.tar.gz` at `out_path`.
+/// Returns the manifest that was written. Artifact sources whose directory does
+/// not exist are silently skipped (and omitted from the manifest).
+pub async fn export_archive(
+ conn: &DatabaseConnection,
+ out_path: &Path,
+ artifacts: &[ArtifactSource],
+) -> Result {
+ let staging = tempfile::tempdir().context("failed to create export staging dir")?;
+ let db_dir = staging.path().join("db");
+ std::fs::create_dir_all(&db_dir)?;
+
+ let counts = registry::dump_all_to_dir(conn, &db_dir)
+ .await
+ .context("failed to dump database tables")?;
+ let total_rows = counts.iter().map(|c| c.rows).sum();
+
+ let present: Vec<&ArtifactSource> =
+ artifacts.iter().filter(|a| a.source_dir.exists()).collect();
+
+ let manifest = Manifest {
+ format_version: ARCHIVE_FORMAT_VERSION,
+ source_backend: backend_name(conn.get_database_backend()).to_string(),
+ schema_version: schema_version(conn).await?,
+ tables: counts
+ .into_iter()
+ .map(|c| TableCount {
+ table: c.table,
+ rows: c.rows,
+ })
+ .collect(),
+ total_rows,
+ artifacts: present
+ .iter()
+ .map(|a| ArtifactEntry {
+ group: a.group,
+ source_base_dir: a.source_dir.to_string_lossy().into_owned(),
+ })
+ .collect(),
+ created_at: chrono::Utc::now().to_rfc3339(),
+ };
+
+ std::fs::write(
+ staging.path().join("manifest.json"),
+ serde_json::to_vec_pretty(&manifest)?,
+ )
+ .context("failed to write manifest.json")?;
+
+ write_archive(out_path, staging.path(), &present)
+ .with_context(|| format!("failed to write archive {}", out_path.display()))?;
+
+ Ok(manifest)
+}
+
+/// Import a `.tar.gz` produced by [`export_archive`] into `conn`, unpacking
+/// bundled artifacts to the given targets and re-rooting DB paths accordingly.
+///
+/// This performs the load and re-root only. The schema-version and
+/// fresh-target safety checks belong to the CLI layer, which should validate
+/// the returned/[`extract`]ed manifest before committing to an import.
+pub async fn import_archive(
+ conn: &DatabaseConnection,
+ in_path: &Path,
+ targets: &[ArtifactTarget],
+) -> Result {
+ let staging = tempfile::tempdir().context("failed to create import staging dir")?;
+ let manifest = extract(in_path, staging.path())?;
+
+ let report = crate::load_from_dir(conn, &staging.path().join("db"))
+ .await
+ .context("failed to load database from archive")?;
+
+ // Unpack each bundled artifact group to its target dir and record the
+ // base-dir remapping for path re-rooting.
+ let mut thumbnail_remap: Option<(String, String)> = None;
+ let mut uploads_remap: Option<(String, String)> = None;
+
+ for entry in &manifest.artifacts {
+ let Some(target) = targets.iter().find(|t| t.group == entry.group) else {
+ continue;
+ };
+ let src = staging.path().join(entry.group.archive_dir());
+ if src.exists() {
+ copy_dir_all(&src, &target.target_dir).with_context(|| {
+ format!(
+ "failed to unpack {} into {}",
+ entry.group.archive_dir(),
+ target.target_dir.display()
+ )
+ })?;
+ }
+ let to = target.target_dir.to_string_lossy().into_owned();
+ match entry.group {
+ ArtifactGroup::Thumbnails => {
+ thumbnail_remap = Some((entry.source_base_dir.clone(), to))
+ }
+ ArtifactGroup::Uploads => uploads_remap = Some((entry.source_base_dir.clone(), to)),
+ ArtifactGroup::Plugins | ArtifactGroup::Cache => {}
+ }
+ }
+
+ let reroot = reroot::reroot_all(
+ conn,
+ thumbnail_remap
+ .as_ref()
+ .map(|(f, t)| (f.as_str(), t.as_str())),
+ uploads_remap
+ .as_ref()
+ .map(|(f, t)| (f.as_str(), t.as_str())),
+ )
+ .await
+ .context("failed to re-root artifact paths")?;
+
+ Ok(ImportOutcome {
+ manifest,
+ report,
+ reroot,
+ })
+}
+
+/// Extract an archive into `dest` and return its (validated-format) manifest.
+/// Exposed so the CLI can inspect the manifest before importing.
+pub fn extract(in_path: &Path, dest: &Path) -> Result {
+ let file = std::fs::File::open(in_path)
+ .with_context(|| format!("failed to open archive {}", in_path.display()))?;
+ let decoder = flate2::read::GzDecoder::new(file);
+ let mut archive = tar::Archive::new(decoder);
+ archive
+ .unpack(dest)
+ .context("failed to extract archive (corrupt or not a codex export?)")?;
+
+ let manifest_file = std::fs::File::open(dest.join("manifest.json"))
+ .context("archive is missing manifest.json")?;
+ let manifest: Manifest = serde_json::from_reader(std::io::BufReader::new(manifest_file))
+ .context("failed to parse manifest.json")?;
+
+ check_format_version(&manifest)?;
+ Ok(manifest)
+}
+
+/// Read only the `manifest.json` from an archive without unpacking it, for
+/// pre-flight checks (schema version, artifact list) before a heavy import.
+/// `manifest.json` is written first, so this stops after the first entry.
+pub fn read_manifest(in_path: &Path) -> Result {
+ let file = std::fs::File::open(in_path)
+ .with_context(|| format!("failed to open archive {}", in_path.display()))?;
+ let decoder = flate2::read::GzDecoder::new(file);
+ let mut archive = tar::Archive::new(decoder);
+
+ for entry in archive.entries().context("archive is not a valid tar")? {
+ let mut entry = entry?;
+ if entry.path()?.as_ref() == Path::new("manifest.json") {
+ let manifest: Manifest =
+ serde_json::from_reader(&mut entry).context("failed to parse manifest.json")?;
+ check_format_version(&manifest)?;
+ return Ok(manifest);
+ }
+ }
+ anyhow::bail!("archive is missing manifest.json");
+}
+
+fn check_format_version(manifest: &Manifest) -> Result<()> {
+ if manifest.format_version != ARCHIVE_FORMAT_VERSION {
+ anyhow::bail!(
+ "unsupported archive format version {} (this build reads version {})",
+ manifest.format_version,
+ ARCHIVE_FORMAT_VERSION
+ );
+ }
+ Ok(())
+}
+
+/// Pack the staged manifest + `db/` tree and the artifact source dirs into a
+/// gzip tar. Artifacts are streamed directly from their source locations (no
+/// intermediate copy).
+fn write_archive(out: &Path, staging: &Path, artifacts: &[&ArtifactSource]) -> Result<()> {
+ if let Some(parent) = out.parent()
+ && !parent.as_os_str().is_empty()
+ {
+ std::fs::create_dir_all(parent).ok();
+ }
+ let file = std::fs::File::create(out)?;
+ let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default());
+ let mut builder = tar::Builder::new(encoder);
+
+ builder.append_path_with_name(staging.join("manifest.json"), "manifest.json")?;
+ builder.append_dir_all("db", staging.join("db"))?;
+ for a in artifacts {
+ builder.append_dir_all(a.group.archive_dir(), &a.source_dir)?;
+ }
+
+ builder.into_inner()?.finish()?;
+ Ok(())
+}
+
+/// Recursively copy `src` into `dst`, creating `dst` and intermediate dirs.
+fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
+ std::fs::create_dir_all(dst)?;
+ for entry in std::fs::read_dir(src)? {
+ let entry = entry?;
+ let target = dst.join(entry.file_name());
+ if entry.file_type()?.is_dir() {
+ copy_dir_all(&entry.path(), &target)?;
+ } else {
+ std::fs::copy(entry.path(), &target)?;
+ }
+ }
+ Ok(())
+}
diff --git a/crates/codex-migrate/src/conn.rs b/crates/codex-migrate/src/conn.rs
new file mode 100644
index 00000000..d383c064
--- /dev/null
+++ b/crates/codex-migrate/src/conn.rs
@@ -0,0 +1,106 @@
+//! Build a [`DatabaseConfig`] from a connection URL, for the `copy` command's
+//! `--from` / `--to` flags and the `CODEX_SOURCE_/TARGET_DATABASE_URL` env vars.
+//!
+//! Supported forms:
+//! - `sqlite://` (also `sqlite:`) — e.g. `sqlite:///abs/codex.db`
+//! or `sqlite://relative/codex.db`
+//! - `postgres://user:pass@host:port/dbname` (also `postgresql://`)
+//!
+//! Passwords with characters that need URL-escaping are fiddly to pass on a
+//! command line; prefer `--from-config` / `--to-config` (or env) for those.
+
+use anyhow::{Context, Result, bail};
+use codex_config::{DatabaseConfig, DatabaseType, PostgresConfig, SQLiteConfig};
+
+/// Parse a database connection URL into a [`DatabaseConfig`]. Pool settings and
+/// other fields use their defaults.
+pub fn database_config_from_url(raw: &str) -> Result {
+ if let Some(rest) = raw
+ .strip_prefix("sqlite://")
+ .or_else(|| raw.strip_prefix("sqlite:"))
+ {
+ let path = rest.split('?').next().unwrap_or(rest);
+ if path.is_empty() {
+ bail!("sqlite URL is missing a file path: {raw}");
+ }
+ return Ok(DatabaseConfig {
+ db_type: DatabaseType::SQLite,
+ postgres: None,
+ sqlite: Some(SQLiteConfig {
+ path: path.to_string(),
+ ..SQLiteConfig::default()
+ }),
+ });
+ }
+
+ if raw.starts_with("postgres://") || raw.starts_with("postgresql://") {
+ let parsed =
+ url::Url::parse(raw).with_context(|| format!("invalid postgres URL: {raw}"))?;
+ let host = parsed.host_str().unwrap_or("localhost").to_string();
+ let port = parsed.port().unwrap_or(5432);
+ let username = parsed.username().to_string();
+ let password = parsed.password().unwrap_or("").to_string();
+ let database_name = parsed.path().trim_start_matches('/').to_string();
+ if database_name.is_empty() {
+ bail!("postgres URL is missing a database name: {raw}");
+ }
+ return Ok(DatabaseConfig {
+ db_type: DatabaseType::Postgres,
+ postgres: Some(PostgresConfig {
+ host,
+ port,
+ username,
+ password,
+ database_name,
+ ..PostgresConfig::default()
+ }),
+ sqlite: None,
+ });
+ }
+
+ bail!("unsupported database URL scheme (expected sqlite:// or postgres://): {raw}");
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_absolute_sqlite() {
+ let cfg = database_config_from_url("sqlite:///var/lib/codex/codex.db").unwrap();
+ assert_eq!(cfg.db_type, DatabaseType::SQLite);
+ assert_eq!(cfg.sqlite.unwrap().path, "/var/lib/codex/codex.db");
+ }
+
+ #[test]
+ fn parses_relative_sqlite_and_strips_query() {
+ let cfg = database_config_from_url("sqlite://data/codex.db?mode=rwc").unwrap();
+ assert_eq!(cfg.sqlite.unwrap().path, "data/codex.db");
+ }
+
+ #[test]
+ fn parses_postgres() {
+ let cfg =
+ database_config_from_url("postgres://codex:secret@db.internal:5433/codexdb").unwrap();
+ assert_eq!(cfg.db_type, DatabaseType::Postgres);
+ let pg = cfg.postgres.unwrap();
+ assert_eq!(pg.host, "db.internal");
+ assert_eq!(pg.port, 5433);
+ assert_eq!(pg.username, "codex");
+ assert_eq!(pg.password, "secret");
+ assert_eq!(pg.database_name, "codexdb");
+ }
+
+ #[test]
+ fn postgres_defaults_port_when_absent() {
+ let cfg = database_config_from_url("postgresql://u@localhost/db").unwrap();
+ assert_eq!(cfg.postgres.unwrap().port, 5432);
+ }
+
+ #[test]
+ fn rejects_unknown_scheme() {
+ assert!(database_config_from_url("mysql://x/y").is_err());
+ assert!(database_config_from_url("postgres://u@h/").is_err());
+ assert!(database_config_from_url("sqlite://").is_err());
+ }
+}
diff --git a/crates/codex-migrate/src/engine.rs b/crates/codex-migrate/src/engine.rs
new file mode 100644
index 00000000..3b495e95
--- /dev/null
+++ b/crates/codex-migrate/src/engine.rs
@@ -0,0 +1,127 @@
+//! Generic, entity-driven transfer primitives.
+//!
+//! Every function here is generic over a SeaORM [`EntityTrait`]. Because
+//! SeaORM performs the engine-specific type mapping (UUID blob ↔ native
+//! `uuid`, text JSON ↔ JSONB, 0/1 ↔ `bool`, timestamps) when it materializes a
+//! typed `Model`, moving rows as `Model` values is correct by construction
+//! across SQLite and PostgreSQL — no hand-written cast rules, no raw bytes.
+
+use std::io::{BufRead, Write};
+
+use anyhow::Result;
+use futures::TryStreamExt;
+use sea_orm::{ConnectionTrait, EntityTrait, IntoActiveModel, PaginatorTrait, StreamTrait};
+use serde::Serialize;
+use serde::de::DeserializeOwned;
+
+/// Default insert batch size. Bounds memory and round-trips while staying well
+/// under parameter limits for the widest tables.
+pub const DEFAULT_BATCH_SIZE: usize = 1000;
+
+/// Stream every row of `E` and write it as one NDJSON line to `out`.
+/// Returns the number of rows written.
+pub async fn dump_table(conn: &C, out: &mut W) -> Result
+where
+ E: EntityTrait,
+ E::Model: Serialize,
+ C: ConnectionTrait + StreamTrait,
+ W: Write,
+{
+ let mut stream = E::find().stream(conn).await?;
+ let mut count = 0u64;
+ while let Some(model) = stream.try_next().await? {
+ serde_json::to_writer(&mut *out, &model)?;
+ out.write_all(b"\n")?;
+ count += 1;
+ }
+ Ok(count)
+}
+
+/// Read NDJSON lines from `reader`, deserialize each into `E::Model`, and
+/// insert them into `conn` in batches of `batch_size`. Returns rows inserted.
+pub async fn load_table(conn: &C, reader: R, batch_size: usize) -> Result
+where
+ E: EntityTrait,
+ E::Model: DeserializeOwned + IntoActiveModel,
+ C: ConnectionTrait,
+ R: BufRead,
+{
+ let mut batch: Vec = Vec::with_capacity(batch_size);
+ let mut count = 0u64;
+ for line in reader.lines() {
+ let line = line?;
+ if line.trim().is_empty() {
+ continue;
+ }
+ let model: E::Model = serde_json::from_str(&line)?;
+ batch.push(model.into_active_model());
+ if batch.len() >= batch_size {
+ count += insert_batch::(conn, std::mem::take(&mut batch)).await?;
+ }
+ }
+ if !batch.is_empty() {
+ count += insert_batch::(conn, batch).await?;
+ }
+ Ok(count)
+}
+
+/// Stream every row of `E` directly from `src` into `dst` in batches, without
+/// an intermediate serialized form. This is the path used by the direct
+/// database-to-database `copy`.
+pub async fn copy_table(src: &S, dst: &D, batch_size: usize) -> Result
+where
+ E: EntityTrait,
+ E::Model: IntoActiveModel,
+ S: ConnectionTrait + StreamTrait,
+ D: ConnectionTrait,
+{
+ let mut stream = E::find().stream(src).await?;
+ let mut batch: Vec = Vec::with_capacity(batch_size);
+ let mut count = 0u64;
+ while let Some(model) = stream.try_next().await? {
+ batch.push(model.into_active_model());
+ if batch.len() >= batch_size {
+ count += insert_batch::(dst, std::mem::take(&mut batch)).await?;
+ }
+ }
+ if !batch.is_empty() {
+ count += insert_batch::(dst, batch).await?;
+ }
+ Ok(count)
+}
+
+/// Count rows in `E`.
+pub async fn count_table(conn: &C) -> Result
+where
+ E: EntityTrait,
+ E::Model: Send + Sync,
+ C: ConnectionTrait,
+{
+ Ok(E::find().count(conn).await?)
+}
+
+/// Delete every row of `E`. Used by `--replace` before a load. Callers must
+/// have foreign-key enforcement disabled (see [`crate::fk`]).
+pub async fn truncate_table(conn: &C) -> Result
+where
+ E: EntityTrait,
+ C: ConnectionTrait,
+{
+ Ok(E::delete_many().exec(conn).await?.rows_affected)
+}
+
+/// Insert one batch, using `exec_without_returning` to avoid a per-row
+/// RETURNING clause on bulk loads. No-op (and no DB round-trip) when empty.
+async fn insert_batch(conn: &C, batch: Vec) -> Result
+where
+ E: EntityTrait,
+ E::Model: IntoActiveModel,
+ C: ConnectionTrait,
+{
+ let n = batch.len() as u64;
+ if n == 0 {
+ return Ok(0);
+ }
+ E::insert_many(batch).exec_without_returning(conn).await?;
+ Ok(n)
+}
diff --git a/crates/codex-migrate/src/fk.rs b/crates/codex-migrate/src/fk.rs
new file mode 100644
index 00000000..cb5fb8cc
--- /dev/null
+++ b/crates/codex-migrate/src/fk.rs
@@ -0,0 +1,35 @@
+//! Foreign-key enforcement control for bulk loads.
+//!
+//! A 1:1 load inserts tables in an arbitrary order, so per-row FK enforcement
+//! would reject rows whose parents are inserted later. Rather than maintain a
+//! drift-prone topological insert order across ~60 tables (some
+//! self-referential), we disable enforcement for the duration of the load and
+//! rely on the source data already being internally consistent.
+//!
+//! These calls are transaction-scoped and must run on the same connection that
+//! performs the load — pass a [`sea_orm::DatabaseTransaction`].
+//!
+//! - **SQLite:** `PRAGMA defer_foreign_keys = ON` defers all FK checks until
+//! COMMIT (and auto-resets there), so a complete, consistent dataset is still
+//! validated as a whole when the transaction commits.
+//! - **PostgreSQL:** `SET LOCAL session_replication_role = replica` suppresses
+//! FK (and other) triggers for the transaction. This requires the connection
+//! role to be a superuser or the table owner. There is no commit-time
+//! re-check, so integrity rests on source consistency plus the row-count
+//! verification performed after the load.
+
+use anyhow::{Context, Result};
+use sea_orm::{ConnectionTrait, DatabaseBackend};
+
+/// Disable foreign-key enforcement for the current transaction.
+pub async fn disable(conn: &C) -> Result<()> {
+ let sql = match conn.get_database_backend() {
+ DatabaseBackend::Sqlite => "PRAGMA defer_foreign_keys = ON",
+ DatabaseBackend::Postgres => "SET LOCAL session_replication_role = replica",
+ DatabaseBackend::MySql => "SET FOREIGN_KEY_CHECKS = 0",
+ };
+ conn.execute_unprepared(sql)
+ .await
+ .with_context(|| format!("failed to disable foreign-key enforcement ({sql})"))?;
+ Ok(())
+}
diff --git a/crates/codex-migrate/src/guard.rs b/crates/codex-migrate/src/guard.rs
new file mode 100644
index 00000000..d164a352
--- /dev/null
+++ b/crates/codex-migrate/src/guard.rs
@@ -0,0 +1,26 @@
+//! Safety gate for destructive loads.
+//!
+//! `import` and `copy` always truncate the target (a faithful mirror must
+//! overwrite migration-seeded rows), so before doing that we refuse a target
+//! that already holds *user* data unless the caller passed `--replace`.
+//!
+//! "User data" is deliberately a small, high-signal set of top-level tables.
+//! Migration-seeded tables (e.g. `settings`) are non-empty on any freshly
+//! migrated database and must NOT be treated as user data, or a fresh target
+//! would always look occupied.
+
+use anyhow::Result;
+use codex_db::entities::{books, libraries, series, users};
+use sea_orm::ConnectionTrait;
+
+use crate::engine::count_table;
+
+/// Returns `true` if the target already contains user-owned content
+/// (libraries, series, books, or users). A freshly migrated, unused database
+/// returns `false`.
+pub async fn has_user_data(conn: &C) -> Result {
+ Ok(count_table::(conn).await? > 0
+ || count_table::(conn).await? > 0
+ || count_table::(conn).await? > 0
+ || count_table::(conn).await? > 0)
+}
diff --git a/crates/codex-migrate/src/lib.rs b/crates/codex-migrate/src/lib.rs
new file mode 100644
index 00000000..9a00ff60
--- /dev/null
+++ b/crates/codex-migrate/src/lib.rs
@@ -0,0 +1,106 @@
+//! Portable, engine-agnostic, entity-driven data transfer for Codex.
+//!
+//! Moves every row of every table between two SeaORM backends by materializing
+//! typed `Model` values, so SQLite ↔ PostgreSQL type differences (UUID blob vs
+//! native `uuid`, text JSON vs JSONB, 0/1 vs `bool`) are handled by SeaORM
+//! rather than by hand-written casts. This crate is the shared engine behind
+//! the `export`, `import`, and `copy` subcommands.
+//!
+//! Loads run inside a single destination transaction with foreign-key
+//! enforcement disabled ([`fk`]), then commit as a unit. On SQLite the commit
+//! re-validates the whole dataset (deferred FK check); on PostgreSQL integrity
+//! rests on source consistency plus the row-count verification in [`verify`].
+
+pub mod archive;
+pub mod conn;
+pub mod engine;
+pub mod fk;
+pub mod guard;
+pub mod manifest;
+pub mod registry;
+pub mod reroot;
+pub mod verify;
+
+pub use conn::database_config_from_url;
+
+use std::path::Path;
+
+use anyhow::{Context, Result};
+use sea_orm::{DatabaseConnection, TransactionTrait};
+
+pub use manifest::Manifest;
+pub use registry::{TableRows, table_names};
+
+/// Outcome of a [`transfer`], carrying per-table and total row counts.
+#[derive(Debug, Clone)]
+pub struct TransferReport {
+ pub tables: Vec,
+ pub total_rows: u64,
+}
+
+/// Copy all data from `src` into `dst`, producing a faithful 1:1 mirror.
+///
+/// The destination must already have the schema applied (run migrations
+/// first). Because migrations seed rows (e.g. default `settings`), a freshly
+/// migrated database is *not* empty; every table is therefore truncated before
+/// the load so the source's own rows are authoritative. The entire operation
+/// (disable FK → truncate → load) runs in one destination transaction and
+/// commits atomically — on SQLite the commit re-validates all foreign keys.
+///
+/// This is the low-level primitive. The destructive-overwrite safety gate
+/// (refuse a target that already holds user data unless `--replace`) lives in
+/// the CLI layer, not here.
+pub async fn transfer(
+ src: &DatabaseConnection,
+ dst: &DatabaseConnection,
+) -> Result {
+ let txn = dst
+ .begin()
+ .await
+ .context("failed to open destination transaction")?;
+
+ fk::disable(&txn).await?;
+
+ registry::truncate_all(&txn)
+ .await
+ .context("failed to clear destination before load")?;
+
+ let tables = registry::copy_all(src, &txn, engine::DEFAULT_BATCH_SIZE)
+ .await
+ .context("failed while copying tables")?;
+
+ txn.commit()
+ .await
+ .context("failed to commit destination transaction (foreign-key check may have failed)")?;
+
+ let total_rows = tables.iter().map(|t| t.rows).sum();
+ Ok(TransferReport { tables, total_rows })
+}
+
+/// Load a directory of `
.ndjson` files into `dst`, producing a faithful
+/// 1:1 mirror. Same transaction semantics as [`transfer`] (disable FK →
+/// truncate → load → commit); this is the archive-backed load used by
+/// `import`.
+pub async fn load_from_dir(dst: &DatabaseConnection, db_dir: &Path) -> Result {
+ let txn = dst
+ .begin()
+ .await
+ .context("failed to open destination transaction")?;
+
+ fk::disable(&txn).await?;
+
+ registry::truncate_all(&txn)
+ .await
+ .context("failed to clear destination before load")?;
+
+ let tables = registry::load_all_from_dir(&txn, db_dir, engine::DEFAULT_BATCH_SIZE)
+ .await
+ .context("failed while loading tables from archive")?;
+
+ txn.commit()
+ .await
+ .context("failed to commit destination transaction (foreign-key check may have failed)")?;
+
+ let total_rows = tables.iter().map(|t| t.rows).sum();
+ Ok(TransferReport { tables, total_rows })
+}
diff --git a/crates/codex-migrate/src/manifest.rs b/crates/codex-migrate/src/manifest.rs
new file mode 100644
index 00000000..3709fb95
--- /dev/null
+++ b/crates/codex-migrate/src/manifest.rs
@@ -0,0 +1,92 @@
+//! Archive manifest: the `manifest.json` at the root of an export bundle.
+//!
+//! Records enough to validate an import (format + schema version), to verify
+//! it (per-table row counts), and to re-root on-disk artifact paths (which
+//! artifact groups are present and the source base directory each came from).
+
+use anyhow::{Context, Result};
+use migration::{Migrator, MigratorTrait};
+use sea_orm::{ConnectionTrait, DatabaseBackend};
+use serde::{Deserialize, Serialize};
+
+/// Bumped when the on-disk archive layout or manifest shape changes
+/// incompatibly. Import refuses a mismatched major format.
+pub const ARCHIVE_FORMAT_VERSION: u32 = 1;
+
+/// A bundleable tree of on-disk files that the database only references by
+/// path. Each maps to a top-level directory inside the archive.
+#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+pub enum ArtifactGroup {
+ /// Generated book/series thumbnails (`files.thumbnail_dir`).
+ Thumbnails,
+ /// Uploaded / extracted / plugin covers (`files.uploads_dir`).
+ Uploads,
+ /// Plugin data and credentials on disk (`files.plugins_dir`).
+ Plugins,
+ /// Rendered PDF page cache (`pdf.cache_dir`) — reproducible, opt-in.
+ Cache,
+}
+
+impl ArtifactGroup {
+ /// The archive-relative top-level directory for this group.
+ pub fn archive_dir(self) -> &'static str {
+ match self {
+ ArtifactGroup::Thumbnails => "thumbnails",
+ ArtifactGroup::Uploads => "uploads",
+ ArtifactGroup::Plugins => "plugins",
+ ArtifactGroup::Cache => "cache",
+ }
+ }
+}
+
+/// Which artifact group is present in the archive and the source instance's
+/// base directory it was captured from (needed to re-root DB paths on import).
+#[derive(Serialize, Deserialize, Clone, Debug)]
+pub struct ArtifactEntry {
+ pub group: ArtifactGroup,
+ /// The source instance's configured base dir (e.g. `data/thumbnails`).
+ pub source_base_dir: String,
+}
+
+/// Row count for one table at export time.
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
+pub struct TableCount {
+ pub table: String,
+ pub rows: u64,
+}
+
+/// The archive's `manifest.json`.
+#[derive(Serialize, Deserialize, Clone, Debug)]
+pub struct Manifest {
+ pub format_version: u32,
+ /// `"sqlite"` or `"postgres"` — the backend the data was exported from.
+ pub source_backend: String,
+ /// Name of the last applied migration at export time, or `None` if the
+ /// source had no migration bookkeeping.
+ pub schema_version: Option,
+ pub tables: Vec,
+ pub total_rows: u64,
+ pub artifacts: Vec,
+ /// RFC 3339 timestamp of the export.
+ pub created_at: String,
+}
+
+/// Render a backend as its manifest string.
+pub fn backend_name(backend: DatabaseBackend) -> &'static str {
+ match backend {
+ DatabaseBackend::Sqlite => "sqlite",
+ DatabaseBackend::Postgres => "postgres",
+ DatabaseBackend::MySql => "mysql",
+ }
+}
+
+/// The current schema version of `conn`: the lexicographically-last applied
+/// migration name (migration names are timestamp-prefixed, so this is the most
+/// recent). Returns `None` if no migrations are applied.
+pub async fn schema_version(conn: &C) -> Result