fix: deleting a project unfiles its tasks to the Inbox (§8.1/§8.2)
Some checks failed
Build / validate (pull_request) Failing after 11s

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) <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-06-03 19:15:54 -07:00
commit dd5ef7dc63
8 changed files with 116 additions and 2 deletions

View file

@ -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;

View file

@ -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` =

View file

@ -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<Task>;
/// 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

View file

@ -752,8 +752,8 @@ impl<B: Backend> App<B> {
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();

View file

@ -67,6 +67,8 @@ pub trait Backend {
) -> Result<String>;
/// Create a new project node; returns its node id.
fn create_project(&mut self, name: &str) -> Result<String>;
/// 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(())
}
}

View file

@ -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 {

View file

@ -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<Option<Node>> {
self.call_as("node.resolve", json!({ "title": title }))
}

View file

@ -369,6 +369,11 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result<Va
let p: SetProjectParams = parse(params)?;
json!(store.set_task_project(&p.id, p.project_id.as_deref())?)
}
"project.delete" => {
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)?)