integrity: one write, one transaction, and nothing left behind

Node update, delete and move each ran as separate statements, with the
slug chosen outside the write that took it. They are now one transaction
each, opened with BEGIN IMMEDIATE so a read-then-write cannot fail with
SQLITE_BUSY halfway. Creating a node, a space or a file does the same.

A delete is one function, delete_subtree, used by the API and the SFTP
mount alike: SFTP deletes used to skip the media cleanup and orphan the
files a document embedded. Rows go in the transaction, bytes after the
commit. Deleting a media row that backs a file in the tree is refused;
the media browser marks those files and stops counting them as
documents that embed them.

A space can no longer be moved into a folder. The diary is found by a
role column (migration 0013) instead of its slug, so renaming it no
longer breaks the reminder or the agenda, and it cannot be deleted. The
same migration adds the unique index root slugs never had.

A card is done exactly when it is in the last list: a done patch now
moves the card, a card made in the done list is stamped, and adding or
deleting lists settles the board. Seeding a fresh board is atomic, and
a card links only to a document.

Uploads are fsynced before the rename, and zip entries are written aside
and renamed like any other upload. A sweep at start and once a day
removes leftover .part files, files no row knows, and uploads nothing
has embedded for a week.

Before a new build migrates the database it writes a VACUUM INTO copy,
diary.db.pre-<version>, since migrations only go forward.
This commit is contained in:
nvrl committed 2026-10-20 01:26:47 +02:00
1 parent 082f2c8602
commit e1d696701f
16 files changed
+812 -182

No files matched your search

+51 -1
View File
@@ -2,6 +2,7 @@ mod auth;
mod backup;
mod cli;
mod config;
mod db;
mod error;
mod mail;
mod notify;
@@ -9,6 +10,7 @@ mod routes;
mod sftp;
mod state;
mod static_files;
mod sweep;
mod throttle;
mod vault;
@@ -110,6 +112,8 @@ async fn main() -> Result<()> {
let notify = notify::Notifier::new(db.clone(), Arc::clone(&config))?;
notify::spawn(Arc::clone(&notify));
sweep::spawn(db.clone(), config.uploads_dir());
let state = AppState {
vault: vault::Vault::new(db.clone(), config.vault_idle),
db,
@@ -157,7 +161,9 @@ async fn open_db(config: &Config) -> Result<sqlx::SqlitePool> {
.await
.context("could not open the workspace database")?;
sqlx::migrate!("./migrations")
let migrator = sqlx::migrate!("./migrations");
snapshot_before_migrating(&db, &migrator, config).await?;
migrator
.run(&db)
.await
.context("could not apply database migrations")?;
@@ -165,6 +171,50 @@ async fn open_db(config: &Config) -> Result<sqlx::SqlitePool> {
Ok(db)
}
/// A copy of the database as it was, taken before a migration changes it.
/// Migrations only go forward, and some of them reshape or drop what was
/// there; the copy is the way back if one goes wrong on real data. A fresh
/// database, or one already up to date, gets none.
async fn snapshot_before_migrating(
db: &sqlx::SqlitePool,
migrator: &sqlx::migrate::Migrator,
config: &Config,
) -> Result<()> {
let applied: Option<i64> = match sqlx::query_scalar(
"SELECT MAX(version) FROM _sqlx_migrations WHERE success",
)
.fetch_one(db)
.await
{
Ok(version) => version,
// No bookkeeping table: nothing has ever been migrated, so there is
// nothing to keep.
Err(_) => return Ok(()),
};
let Some(applied) = applied else {
return Ok(());
};
let latest = migrator.iter().map(|m| m.version).max().unwrap_or(0);
if latest <= applied {
return Ok(());
}
let path = config.data_dir.join(format!("diary.db.pre-{latest:04}"));
if path.exists() {
return Ok(());
}
tracing::info!(
"database is at migration {applied}, the binary at {latest}: keeping a copy at {}",
path.display()
);
sqlx::query("VACUUM INTO ?1")
.bind(path.to_string_lossy().into_owned())
.execute(db)
.await
.with_context(|| format!("could not snapshot the database to {}", path.display()))?;
Ok(())
}
async fn serve(bind: std::net::SocketAddr, max_upload: usize, state: AppState) -> Result<()> {
let app = Router::new()
.nest("/api", routes::api_router(max_upload))