feat(core,fuse,cli,gui): discard a queued change and undo it here

A change Drive will not take could be exported or retried, never let go
of. The Sync page's Discard button (with a confirmation) and
`pdfs sync discard <id>` drop it: a node made here goes, as if deleted
before it was sent; a node Drive has goes back to what Drive has, under
the same inode, with every change queued on it, while what is queued
below it stays. A change on the wire or an open file is refused.
This commit is contained in:
nvrl committed 2026-10-05 00:57:02 +02:00
1 parent da013c29d6
commit 9dfe4c5450
22 files changed
+1530 -710

No files matched your search

+35
View File
@@ -908,6 +908,41 @@ impl Db {
Ok(blobs)
}
/// Drop the ops queued on the node `uid` names, and nothing else, returning
/// the staged blobs they held. `None`, dropping nothing, while one of them
/// is on the wire: it may land after the drop.
///
/// For a change the user discards on a node Drive has. Unlike
/// [`Db::delete_ops_for_uid`], what is queued below the node stays: the
/// files made in a folder whose rename is discarded still go in it.
pub fn discard_ops_on_node(&self, uid: &str) -> Result<Option<Vec<String>>> {
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
let lid = node_lid(&tx, uid)?;
let on = on_node(2);
let sending: bool = tx.query_row(
&format!("SELECT EXISTS (SELECT 1 FROM pending_op WHERE {on} AND claimed_at <> 0)"),
params![uid, lid],
|r| r.get(0),
)?;
if sending {
return Ok(None);
}
let blobs = {
let mut stmt = tx.prepare(&format!(
"SELECT blob_path FROM pending_op WHERE {on} AND blob_path IS NOT NULL"
))?;
let rows = stmt.query_map(params![uid, lid], |r| r.get::<_, String>(0))?;
rows.collect::<std::result::Result<Vec<_>, _>>()?
};
tx.execute(
&format!("DELETE FROM pending_op WHERE {on}"),
params![uid, lid],
)?;
tx.commit()?;
Ok(Some(blobs))
}
/// What [`Db::finish_create`] does to the node rows and pins when a create
/// lands, without the op ([`adopt_placeholder_row_tx`]).
#[cfg(test)]
+52
View File
@@ -2633,6 +2633,58 @@ fn failed_atomic_trash_insert_keeps_prior_revision_and_blob_ownership() {
);
}
/// Discarding a change to a node Drive has drops that node's ops and nothing
/// queued below it, and nothing at all while one of them is on the wire.
#[test]
fn discarding_a_nodes_change_keeps_what_is_queued_inside_it() {
let db = Db::open_in_memory().unwrap();
db.upsert_nodes(&[
folder("root", None, "My Files"),
folder("dir", Some("root"), "dir"),
file("inner", "dir", "inner.bin", 1),
])
.unwrap();
let op = |kind: &str, link: &str, blob: Option<&str>| PendingOp {
id: 0,
kind: kind.to_string(),
uid: uid(link).to_string(),
parent_uid: None,
name: Some("renamed".to_string()),
blob_path: blob.map(str::to_string),
meta_json: Some("{}".to_string()),
created_at: 1,
attempts: 0,
last_error: None,
next_attempt_at: 0,
};
db.enqueue_op(&op(OP_RENAME, "dir", None)).unwrap();
db.enqueue_op(&op(OP_REVISION, "inner", Some("/staging/inner")))
.unwrap();
let dir = uid("dir").to_string();
assert_eq!(db.discard_ops_on_node(&dir).unwrap(), Some(vec![]));
let left: Vec<String> = db
.pending_ops()
.unwrap()
.into_iter()
.map(|op| op.uid)
.collect();
assert_eq!(left, vec![uid("inner").to_string()]);
let inner = uid("inner").to_string();
let claimed = db.claim_next_due_op(2).unwrap().unwrap();
assert_eq!(claimed.uid, inner);
assert_eq!(db.discard_ops_on_node(&inner).unwrap(), None);
assert_eq!(db.pending_ops().unwrap().len(), 1);
db.record_op_failure(claimed.id, "no answer", 0).unwrap();
db.release_op_claim(claimed.id).unwrap();
assert_eq!(
db.discard_ops_on_node(&inner).unwrap(),
Some(vec!["/staging/inner".to_string()])
);
assert!(db.pending_ops().unwrap().is_empty());
}
/// A revision op names no parent, so the queue alone cannot see that its file
/// sits inside a folder being removed. The node tree can: trashing the folder
/// has to take the uploads of the files below it along, or they fail forever