From dd5ef7dc637e078eb9d283feea47fbf73e8fe133 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 3 Jun 2026 19:15:54 -0700 Subject: [PATCH] =?UTF-8?q?fix:=20deleting=20a=20project=20unfiles=20its?= =?UTF-8?q?=20tasks=20to=20the=20Inbox=20(=C2=A78.1/=C2=A78.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Project delete previously tombstoned only the project node, leaving its tasks with a live in-project link to a dead project — orphaned (not in the Inbox, unbrowsable, blank project) rather than unfiled as intended. New atomic Store::delete_project tombstones every in-project link to the project (tasks fall to the Inbox), then tombstones the project node; tasks are never deleted. Exposed as the project.delete RPC (LocalStore + RemoteStore); the heph-tui sidebar `D` now routes through it. Core test asserts the task survives and becomes unfiled; the project node is tombstoned. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/heph-core/src/sqlite/mod.rs | 61 ++++++++++++++++++++++++++++ crates/heph-core/src/sqlite/tasks.rs | 28 +++++++++++++ crates/heph-core/src/store.rs | 4 ++ crates/heph-tui/src/app.rs | 4 +- crates/heph-tui/src/backend.rs | 7 ++++ crates/heph-tui/tests/navigation.rs | 4 ++ crates/hephd/src/remote.rs | 5 +++ crates/hephd/src/rpc.rs | 5 +++ 8 files changed, 116 insertions(+), 2 deletions(-) diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs index c4ec1b1..d4b93f1 100644 --- a/crates/heph-core/src/sqlite/mod.rs +++ b/crates/heph-core/src/sqlite/mod.rs @@ -243,6 +243,11 @@ impl Store for LocalStore { tasks::set_project(&mut self.conn, &self.owner_id, now, node_id, project_id) } + fn delete_project(&mut self, project_id: &str) -> Result<()> { + let now = self.clock.now_ms(); + tasks::delete_project(&mut self.conn, &self.owner_id, now, project_id) + } + fn promote( &mut self, container_id: &str, @@ -465,6 +470,62 @@ mod tests { assert_eq!(v, latest_version()); } + #[test] + fn delete_project_unfiles_its_tasks_then_tombstones_it() { + use crate::filter::ListFilter; + use crate::model::{NewNode, NewTask, NodeKind}; + let mut store = store_at(1); + + let proj = store + .create_node(NewNode { + kind: NodeKind::Project, + title: "Garden".into(), + body: None, + }) + .unwrap(); + let task = store + .create_task(NewTask { + title: "Weed the beds".into(), + attention: None, + do_date: None, + late_on: None, + recurrence: None, + project_id: None, + }) + .unwrap(); + store + .set_task_project(&task.node_id, Some(&proj.id)) + .unwrap(); + + // Filed under the project before deletion. + let filed = store + .list(&ListFilter { + scope: vec![proj.id.clone()], + ..Default::default() + }) + .unwrap(); + assert_eq!(filed.len(), 1); + + store.delete_project(&proj.id).unwrap(); + + // The project node is tombstoned… + let ts: i64 = store + .conn + .query_row("SELECT tombstoned FROM nodes WHERE id=?1", [&proj.id], |r| { + r.get(0) + }) + .unwrap(); + assert_eq!(ts, 1); + // …and the task survives, now unfiled (it shows in the Inbox), not orphaned. + let inbox = store + .list(&ListFilter { + unfiled: true, + ..Default::default() + }) + .unwrap(); + assert!(inbox.iter().any(|t| t.node_id == task.node_id)); + } + #[test] fn resolve_node_matches_exact_title_not_fuzzy() { use crate::model::NewNode; diff --git a/crates/heph-core/src/sqlite/tasks.rs b/crates/heph-core/src/sqlite/tasks.rs index 22bfdaa..1d5e9c7 100644 --- a/crates/heph-core/src/sqlite/tasks.rs +++ b/crates/heph-core/src/sqlite/tasks.rs @@ -570,6 +570,34 @@ pub(super) fn set_project( require(conn, node_id) } +/// Delete a project: **unfile every task currently filed under it** (tombstone +/// the `in-project` links, so those tasks fall to the Inbox), then tombstone the +/// project node itself — atomically. Tasks are preserved, never deleted. +pub(super) fn delete_project( + conn: &mut Connection, + owner: &str, + now: i64, + project_id: &str, +) -> Result<()> { + let project = + nodes::get(conn, project_id)?.ok_or_else(|| Error::NodeNotFound(project_id.into()))?; + if project.kind != NodeKind::Project { + return Err(Error::InvalidArg(format!( + "{project_id} is not a project node" + ))); + } + + let tx = conn.transaction()?; + for link in links::backlinks(&tx, project_id)? { + if link.link_type == LinkType::InProject { + links::tombstone(&tx, owner, now, &link.id)?; + } + } + nodes::tombstone(&tx, owner, now, project_id)?; + tx.commit()?; + Ok(()) +} + /// Apply a partial schedule update (do-date / late-on / recurrence) — the /// "reschedule" path (tech-spec §6). Reads the current row, overlays the /// present `patch` fields (a double-option per field: absent = leave, `null` = diff --git a/crates/heph-core/src/store.rs b/crates/heph-core/src/store.rs index 1ef715c..a582abd 100644 --- a/crates/heph-core/src/store.rs +++ b/crates/heph-core/src/store.rs @@ -94,6 +94,10 @@ pub trait Store { /// A given `project_id` must name a live `project`-kind node. fn set_task_project(&mut self, node_id: &str, project_id: Option<&str>) -> Result; + /// Delete a project: unfile its tasks (they fall to the Inbox) and tombstone + /// the project node. Tasks are preserved, never deleted. + fn delete_project(&mut self, project_id: &str) -> Result<()>; + /// Promote a `- [ ]` context-item line in `container_id`'s body into a /// committed task, rewriting that source line into a `[[link]]` to the new /// task (Fork A, tech-spec §4.3, §6). `item_ref` is the 1-based index of the diff --git a/crates/heph-tui/src/app.rs b/crates/heph-tui/src/app.rs index 2325278..38bd38d 100644 --- a/crates/heph-tui/src/app.rs +++ b/crates/heph-tui/src/app.rs @@ -752,8 +752,8 @@ impl App { self.mutate(format!("deleted: {title}"), |b| b.tombstone(&task_id)); } Some(PendingDelete::Project { project_id, title }) => { - self.mutate(format!("deleted project: {title}"), |b| { - b.tombstone(&project_id) + self.mutate(format!("deleted project: {title} (tasks → Inbox)"), |b| { + b.delete_project(&project_id) }); self.rebuild_projects(); self.reload(); diff --git a/crates/heph-tui/src/backend.rs b/crates/heph-tui/src/backend.rs index 974fa12..784c520 100644 --- a/crates/heph-tui/src/backend.rs +++ b/crates/heph-tui/src/backend.rs @@ -67,6 +67,8 @@ pub trait Backend { ) -> Result; /// Create a new project node; returns its node id. fn create_project(&mut self, name: &str) -> Result; + /// Delete a project: unfile its tasks (→ Inbox), then tombstone the project. + fn delete_project(&mut self, project_id: &str) -> Result<()>; } /// The real backend: a thin client of the `hephd` unix socket. @@ -217,4 +219,9 @@ impl Backend for ClientBackend { let node: heph_core::Node = serde_json::from_value(v)?; Ok(node.id) } + + fn delete_project(&mut self, project_id: &str) -> Result<()> { + self.call("project.delete", json!({ "id": project_id }))?; + Ok(()) + } } diff --git a/crates/heph-tui/tests/navigation.rs b/crates/heph-tui/tests/navigation.rs index a6f3842..6b79fd2 100644 --- a/crates/heph-tui/tests/navigation.rs +++ b/crates/heph-tui/tests/navigation.rs @@ -134,6 +134,10 @@ impl Backend for Fake { self.rec.borrow_mut().created_projects.push(name.into()); Ok(format!("proj:{name}")) } + fn delete_project(&mut self, project_id: &str) -> Result<()> { + self.rec.borrow_mut().tombstoned.push(project_id.into()); + Ok(()) + } } fn fixture() -> Fake { diff --git a/crates/hephd/src/remote.rs b/crates/hephd/src/remote.rs index be74c2a..55b12b8 100644 --- a/crates/hephd/src/remote.rs +++ b/crates/hephd/src/remote.rs @@ -133,6 +133,11 @@ impl Store for RemoteStore { self.call("node.tombstone", json!({ "id": id })).map(|_| ()) } + fn delete_project(&mut self, project_id: &str) -> Result<()> { + self.call("project.delete", json!({ "id": project_id })) + .map(|_| ()) + } + fn resolve_node(&self, title: &str) -> Result> { self.call_as("node.resolve", json!({ "title": title })) } diff --git a/crates/hephd/src/rpc.rs b/crates/hephd/src/rpc.rs index 7a91008..1b7a947 100644 --- a/crates/hephd/src/rpc.rs +++ b/crates/hephd/src/rpc.rs @@ -369,6 +369,11 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result { + let p: IdParam = parse(params)?; + store.delete_project(&p.id)?; + Value::Null + } "task.skip" => { let p: IdParam = parse(params)?; json!(store.skip_recurrence(&p.id)?)