From b9d2072f755dfe5517b3d3fd6b3330e3132a99c9 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sun, 31 May 2026 19:02:35 -0700 Subject: [PATCH] heph-core: tasks, links, canonical-context doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 3 (tech-spec §4.2–§4.3, §6). Refactor the SQLite layer into focused submodules (nodes/tasks/links) behind a thin delegating Store impl so a transaction can span several. - Model: Attention (white/orange/red/blue), TaskState (outstanding/done/dropped), LinkType, Link, Task, NewTask. - create_task: in one transaction mints the task node + tasks row, the canonical context doc, the canonical-context link, and an optional in-project link. get_task / set_task_state / set_task_attention. - Links CRUD: add_link, outgoing_links, backlinks (non-tombstoned). - update_node: a body change re-runs extraction and reconciles this node's wiki links — diff-based and idempotent, resolved via alias then exact title (owner-scoped); unresolved targets link on a later edit once the target exists; dropped targets are tombstoned. 20 tests green (12 unit + 8 integration). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/heph-core/src/lib.rs | 2 +- crates/heph-core/src/model.rs | 173 ++++++++++++++++++ crates/heph-core/src/sqlite/links.rs | 148 +++++++++++++++ crates/heph-core/src/sqlite/mod.rs | 160 +++++++--------- crates/heph-core/src/sqlite/nodes.rs | 147 +++++++++++++++ crates/heph-core/src/sqlite/tasks.rs | 136 ++++++++++++++ crates/heph-core/src/store.rs | 42 ++++- crates/heph-core/tests/tasks_and_links.rs | 212 ++++++++++++++++++++++ docs/changelog.d/v1-prototype.feature.md | 1 + 9 files changed, 922 insertions(+), 99 deletions(-) create mode 100644 crates/heph-core/src/sqlite/links.rs create mode 100644 crates/heph-core/src/sqlite/nodes.rs create mode 100644 crates/heph-core/src/sqlite/tasks.rs create mode 100644 crates/heph-core/tests/tasks_and_links.rs diff --git a/crates/heph-core/src/lib.rs b/crates/heph-core/src/lib.rs index 41eea60..6b3eeef 100644 --- a/crates/heph-core/src/lib.rs +++ b/crates/heph-core/src/lib.rs @@ -18,6 +18,6 @@ pub mod store; pub use clock::{Clock, FixedClock}; pub use error::{Error, Result}; pub use extract::{extract, ContextItem, Extraction}; -pub use model::{NewNode, Node, NodeKind}; +pub use model::{Attention, Link, LinkType, NewNode, NewTask, Node, NodeKind, Task, TaskState}; pub use sqlite::LocalStore; pub use store::Store; diff --git a/crates/heph-core/src/model.rs b/crates/heph-core/src/model.rs index 2278999..1525396 100644 --- a/crates/heph-core/src/model.rs +++ b/crates/heph-core/src/model.rs @@ -68,6 +68,179 @@ pub struct Node { pub tombstoned: bool, } +/// A task's attention-state — the lived colour discipline ([[design]] §6.2). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Attention { + /// Default — actionable once the do-date arrives. + White, + /// Top of mind (keep ≤ 6). + Orange, + /// Top of mind **+ a consequence exists if late** (consequence, not severity). + Red, + /// On-deck / backlog, deliberately cooling off (hidden from `next`). + Blue, +} + +impl Attention { + /// The storage string. + pub fn as_str(self) -> &'static str { + match self { + Attention::White => "white", + Attention::Orange => "orange", + Attention::Red => "red", + Attention::Blue => "blue", + } + } + + /// Parse a storage string. + pub fn parse(s: &str) -> Result { + Ok(match s { + "white" => Attention::White, + "orange" => Attention::Orange, + "red" => Attention::Red, + "blue" => Attention::Blue, + other => return Err(Error::Integrity(format!("unknown attention: {other}"))), + }) + } +} + +/// A committed task's lifecycle state (tech-spec §4.3). `done` and `dropped` +/// are both "not outstanding"; the distinction is retained for honesty/history. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TaskState { + /// Still to be done. + Outstanding, + /// Accomplished. + Done, + /// Let go / dismissed (e.g. during a Blue review). + Dropped, +} + +impl TaskState { + /// The storage string. + pub fn as_str(self) -> &'static str { + match self { + TaskState::Outstanding => "outstanding", + TaskState::Done => "done", + TaskState::Dropped => "dropped", + } + } + + /// Parse a storage string. + pub fn parse(s: &str) -> Result { + Ok(match s { + "outstanding" => TaskState::Outstanding, + "done" => TaskState::Done, + "dropped" => TaskState::Dropped, + other => return Err(Error::Integrity(format!("unknown task state: {other}"))), + }) + } +} + +/// A typed, directional edge between two nodes (tech-spec §4.2). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LinkType { + /// Materialized from a `[[link]]` in a body. + Wiki, + /// Task → its auto-created context doc. + CanonicalContext, + /// Doc ↔ task context association. + ContextOf, + /// Task → its append-only log node. + LogOf, + /// Blocking dependency. + Blocks, + /// Hierarchy. + Parent, + /// Node → tag. + Tagged, + /// Task → project. + InProject, +} + +impl LinkType { + /// The storage string. + pub fn as_str(self) -> &'static str { + match self { + LinkType::Wiki => "wiki", + LinkType::CanonicalContext => "canonical-context", + LinkType::ContextOf => "context-of", + LinkType::LogOf => "log-of", + LinkType::Blocks => "blocks", + LinkType::Parent => "parent", + LinkType::Tagged => "tagged", + LinkType::InProject => "in-project", + } + } + + /// Parse a storage string. + pub fn parse(s: &str) -> Result { + Ok(match s { + "wiki" => LinkType::Wiki, + "canonical-context" => LinkType::CanonicalContext, + "context-of" => LinkType::ContextOf, + "log-of" => LinkType::LogOf, + "blocks" => LinkType::Blocks, + "parent" => LinkType::Parent, + "tagged" => LinkType::Tagged, + "in-project" => LinkType::InProject, + other => return Err(Error::Integrity(format!("unknown link type: {other}"))), + }) + } +} + +/// A persisted link (a row of the `links` table). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Link { + /// ULID id. + pub id: String, + /// Source node id. + pub src_id: String, + /// Destination node id. + pub dst_id: String, + /// The edge type. + pub link_type: LinkType, + /// Creation time, epoch ms. + pub created_at: i64, + /// Whether tombstoned. + pub tombstoned: bool, +} + +/// A persisted committed task (a `tasks` row joined to its node id). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Task { + /// The id of the backing `task` node. + pub node_id: String, + /// Attention-state (the colour discipline). + pub attention: Option, + /// Earliest-actionable date, epoch ms (candidacy gate only, §7). + pub do_date: Option, + /// When lateness becomes a problem, epoch ms (the sole urgency signal, §7). + pub late_on: Option, + /// Lifecycle state. + pub state: TaskState, + /// RFC-5545 RRULE; present ⇒ a recurring definition (§4.4). + pub recurrence: Option, +} + +/// Input for creating a committed task. The canonical context `doc` and the +/// `canonical-context` link are created automatically (tech-spec §6). +#[derive(Debug, Clone, Default)] +pub struct NewTask { + /// Title (shared by the task node and its canonical context doc). + pub title: String, + /// Attention-state. + pub attention: Option, + /// Earliest-actionable date, epoch ms. + pub do_date: Option, + /// Lateness-problem marker, epoch ms. + pub late_on: Option, + /// RRULE for a recurring definition. + pub recurrence: Option, + /// Optional project node to link via `in-project`. + pub project_id: Option, +} + /// Input for creating a node. #[derive(Debug, Clone)] pub struct NewNode { diff --git a/crates/heph-core/src/sqlite/links.rs b/crates/heph-core/src/sqlite/links.rs new file mode 100644 index 0000000..87bb5f9 --- /dev/null +++ b/crates/heph-core/src/sqlite/links.rs @@ -0,0 +1,148 @@ +//! `links` table operations, plus `wiki` link materialization from bodies. + +use std::collections::HashSet; + +use rusqlite::{Connection, OptionalExtension, Row}; + +use super::new_id; +use crate::error::Result; +use crate::extract::extract; +use crate::model::{Link, LinkType}; + +const COLUMNS: &str = "id, src_id, dst_id, type, created_at, tombstoned"; + +fn from_row(row: &Row) -> rusqlite::Result { + Ok(Link { + id: row.get("id")?, + src_id: row.get("src_id")?, + dst_id: row.get("dst_id")?, + link_type: LinkType::parse(&row.get::<_, String>("type")?) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?, + created_at: row.get("created_at")?, + tombstoned: row.get::<_, i64>("tombstoned")? != 0, + }) +} + +/// Add a typed link. +pub(super) fn add( + conn: &Connection, + now: i64, + src_id: &str, + dst_id: &str, + link_type: LinkType, +) -> Result { + let link = Link { + id: new_id(), + src_id: src_id.to_string(), + dst_id: dst_id.to_string(), + link_type, + created_at: now, + tombstoned: false, + }; + conn.execute( + "INSERT INTO links (id, src_id, dst_id, type, created_at, tombstoned) + VALUES (?1, ?2, ?3, ?4, ?5, 0)", + ( + &link.id, + &link.src_id, + &link.dst_id, + link.link_type.as_str(), + link.created_at, + ), + )?; + Ok(link) +} + +/// All non-tombstoned links originating at `id`. +pub(super) fn outgoing(conn: &Connection, id: &str) -> Result> { + query(conn, "src_id", id) +} + +/// All non-tombstoned links pointing at `id`. +pub(super) fn backlinks(conn: &Connection, id: &str) -> Result> { + query(conn, "dst_id", id) +} + +fn query(conn: &Connection, column: &str, id: &str) -> Result> { + let sql = format!( + "SELECT {COLUMNS} FROM links WHERE {column} = ?1 AND tombstoned = 0 ORDER BY created_at, id" + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map([id], from_row)?; + Ok(rows.collect::>>()?) +} + +/// Reconcile the `wiki` links out of `src_id` to match the resolvable +/// `[[wiki-links]]` in `body`. Diff-based and idempotent: unchanged bodies +/// produce no writes. Targets that don't resolve to a node are left for a later +/// re-sync once the target exists (tech-spec §5). +pub(super) fn sync_wiki_links( + conn: &Connection, + owner: &str, + src_id: &str, + body: &str, + now: i64, +) -> Result<()> { + // Desired set: resolved destination node ids, de-duplicated, order-stable. + let mut desired: Vec = Vec::new(); + let mut desired_set: HashSet = HashSet::new(); + for target in extract(body).wiki_links { + if let Some(dst) = resolve(conn, owner, &target)? { + if dst != src_id && desired_set.insert(dst.clone()) { + desired.push(dst); + } + } + } + + // Existing wiki links from this source. + let existing: Vec<(String, String)> = { + let mut stmt = conn.prepare( + "SELECT id, dst_id FROM links + WHERE src_id = ?1 AND type = 'wiki' AND tombstoned = 0", + )?; + let rows = stmt.query_map([src_id], |r| Ok((r.get(0)?, r.get(1)?)))?; + rows.collect::>>()? + }; + let existing_dsts: HashSet<&str> = existing.iter().map(|(_, d)| d.as_str()).collect(); + + // Tombstone links whose target is no longer referenced. + for (link_id, dst) in &existing { + if !desired_set.contains(dst) { + conn.execute("UPDATE links SET tombstoned = 1 WHERE id = ?1", [link_id])?; + } + } + // Add links for newly-referenced targets. + for dst in &desired { + if !existing_dsts.contains(dst.as_str()) { + add(conn, now, src_id, dst, LinkType::Wiki)?; + } + } + Ok(()) +} + +/// Resolve a wiki-link target to a node id for this owner, matching an alias +/// first, then an exact title. `None` if nothing matches. +fn resolve(conn: &Connection, owner: &str, target: &str) -> Result> { + let by_alias: Option = conn + .query_row( + "SELECT n.id FROM aliases a JOIN nodes n ON n.id = a.node_id + WHERE a.alias = ?1 AND n.owner_id = ?2 AND n.tombstoned = 0 + ORDER BY n.created_at, n.id LIMIT 1", + (target, owner), + |r| r.get(0), + ) + .optional()?; + if by_alias.is_some() { + return Ok(by_alias); + } + let by_title: Option = conn + .query_row( + "SELECT id FROM nodes + WHERE title = ?1 AND owner_id = ?2 AND tombstoned = 0 + ORDER BY created_at, id LIMIT 1", + (target, owner), + |r| r.get(0), + ) + .optional()?; + Ok(by_title) +} diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs index 6db3734..1979b33 100644 --- a/crates/heph-core/src/sqlite/mod.rs +++ b/crates/heph-core/src/sqlite/mod.rs @@ -4,19 +4,26 @@ //! tech-spec §3.1 is layered on by `hephd` when it owns the file; the store //! itself stays a thin, synchronous SQLite wrapper so it is trivially testable //! against an in-memory database. +//! +//! The query logic lives in focused submodules ([`nodes`], [`tasks`], [`links`]) +//! as free functions over a `&Connection`; the [`Store`] impl here is a thin +//! delegating layer so a transaction can span several of them. +mod links; mod migrations; +mod nodes; +mod tasks; pub use migrations::latest_version; use std::path::Path; -use rusqlite::{Connection, OptionalExtension, Row}; +use rusqlite::{Connection, OptionalExtension}; use ulid::Ulid; use crate::clock::Clock; use crate::error::Result; -use crate::model::{NewNode, Node, NodeKind}; +use crate::model::{Attention, Link, LinkType, NewNode, NewTask, Node, Task, TaskState}; use crate::store::Store; /// A SQLite file (or in-memory database) opened directly as a backend. @@ -57,13 +64,17 @@ impl LocalStore { pub fn owner_id(&self) -> &str { &self.owner_id } +} - /// Placeholder HLC string until the real hybrid logical clock lands (§12). - /// - /// Zero-padded epoch ms keeps it lexically sortable in the meantime. - fn next_hlc(&self, now_ms: i64) -> String { - format!("{now_ms:016}") - } +/// A fresh ULID, as a string id. +pub(crate) fn new_id() -> String { + Ulid::new().to_string() +} + +/// Placeholder HLC string until the real hybrid logical clock lands (§12). +/// Zero-padded epoch ms keeps it lexically sortable in the meantime. +pub(crate) fn hlc_for(now_ms: i64) -> String { + format!("{now_ms:016}") } /// Ensure a single local user exists, returning its id. @@ -78,7 +89,7 @@ fn ensure_local_user(conn: &Connection, clock: &dyn Clock) -> Result { { return Ok(id); } - let id = Ulid::new().to_string(); + let id = new_id(); conn.execute( "INSERT INTO users (id, oidc_sub, name, created_at) VALUES (?1, NULL, 'local', ?2)", (&id, clock.now_ms()), @@ -86,66 +97,56 @@ fn ensure_local_user(conn: &Connection, clock: &dyn Clock) -> Result { Ok(id) } -/// Map a `nodes` row to a [`Node`]. Column order must match the SELECT. -fn row_to_node(row: &Row) -> rusqlite::Result { - Ok(Node { - id: row.get("id")?, - owner_id: row.get("owner_id")?, - kind: NodeKind::parse(&row.get::<_, String>("kind")?) - .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?, - title: row.get("title")?, - body: row.get("body")?, - created_at: row.get("created_at")?, - modified_at: row.get("modified_at")?, - hlc: row.get("hlc")?, - tombstoned: row.get::<_, i64>("tombstoned")? != 0, - }) -} - -const NODE_COLUMNS: &str = - "id, owner_id, kind, title, body, created_at, modified_at, hlc, tombstoned"; - impl Store for LocalStore { fn create_node(&mut self, input: NewNode) -> Result { let now = self.clock.now_ms(); - let node = Node { - id: Ulid::new().to_string(), - owner_id: self.owner_id.clone(), - kind: input.kind, - title: input.title, - body: input.body, - created_at: now, - modified_at: now, - hlc: self.next_hlc(now), - tombstoned: false, - }; - self.conn.execute( - "INSERT INTO nodes (id, owner_id, kind, title, body, created_at, modified_at, hlc, tombstoned) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 0)", - ( - &node.id, - &node.owner_id, - node.kind.as_str(), - &node.title, - &node.body, - node.created_at, - node.modified_at, - &node.hlc, - ), - )?; - Ok(node) + nodes::create(&self.conn, &self.owner_id, now, input) } fn get_node(&self, id: &str) -> Result> { - let node = self - .conn - .query_row( - &format!("SELECT {NODE_COLUMNS} FROM nodes WHERE id = ?1"), - [id], - row_to_node, - ) - .optional()?; - Ok(node) + nodes::get(&self.conn, id) + } + + fn update_node( + &mut self, + id: &str, + title: Option, + body: Option, + ) -> Result { + let now = self.clock.now_ms(); + nodes::update(&mut self.conn, &self.owner_id, now, id, title, body) + } + + fn create_task(&mut self, input: NewTask) -> Result { + let now = self.clock.now_ms(); + tasks::create(&mut self.conn, &self.owner_id, now, input) + } + + fn get_task(&self, node_id: &str) -> Result> { + tasks::get(&self.conn, node_id) + } + + fn set_task_state(&mut self, node_id: &str, state: TaskState) -> Result { + let now = self.clock.now_ms(); + tasks::set_state(&self.conn, now, node_id, state) + } + + fn set_task_attention(&mut self, node_id: &str, attention: Attention) -> Result { + let now = self.clock.now_ms(); + tasks::set_attention(&self.conn, now, node_id, attention) + } + + fn add_link(&mut self, src_id: &str, dst_id: &str, link_type: LinkType) -> Result { + let now = self.clock.now_ms(); + links::add(&self.conn, now, src_id, dst_id, link_type) + } + + fn outgoing_links(&self, id: &str) -> Result> { + links::outgoing(&self.conn, id) + } + + fn backlinks(&self, id: &str) -> Result> { + links::backlinks(&self.conn, id) } } @@ -170,8 +171,6 @@ mod tests { #[test] fn opening_twice_is_idempotent_for_the_local_user() { - // Re-running init against the same connection-equivalent must not create - // a second local user; ensure_local_user is the guard. let conn = Connection::open_in_memory().unwrap(); conn.execute_batch("PRAGMA foreign_keys = ON;").unwrap(); migrations::migrate(&conn).unwrap(); @@ -179,35 +178,4 @@ mod tests { let b = ensure_local_user(&conn, &FixedClock(2)).unwrap(); assert_eq!(a, b); } - - #[test] - fn create_then_get_round_trips() { - let mut store = store_at(1_700_000_000_000); - let created = store - .create_node(NewNode::doc( - "Roof leak log", - "# Roof\n\nCalled contractor.", - )) - .unwrap(); - - assert_eq!(created.kind, NodeKind::Doc); - assert_eq!(created.title, "Roof leak log"); - assert_eq!( - created.body.as_deref(), - Some("# Roof\n\nCalled contractor.") - ); - assert_eq!(created.created_at, 1_700_000_000_000); - assert_eq!(created.modified_at, 1_700_000_000_000); - assert!(!created.tombstoned); - assert_eq!(created.owner_id, store.owner_id()); - - let fetched = store.get_node(&created.id).unwrap(); - assert_eq!(fetched.as_ref(), Some(&created)); - } - - #[test] - fn get_missing_node_is_none() { - let store = store_at(0); - assert_eq!(store.get_node("01ARYZ6S41NONEXISTENT00000").unwrap(), None); - } } diff --git a/crates/heph-core/src/sqlite/nodes.rs b/crates/heph-core/src/sqlite/nodes.rs new file mode 100644 index 0000000..a955749 --- /dev/null +++ b/crates/heph-core/src/sqlite/nodes.rs @@ -0,0 +1,147 @@ +//! `nodes` table operations. + +use rusqlite::{Connection, OptionalExtension, Row}; + +use super::{hlc_for, links, new_id}; +use crate::error::{Error, Result}; +use crate::model::{NewNode, Node, NodeKind}; + +/// The `nodes` columns in a fixed order, shared by every SELECT here. +pub(super) const COLUMNS: &str = + "id, owner_id, kind, title, body, created_at, modified_at, hlc, tombstoned"; + +/// Build an in-memory [`Node`] (not yet persisted). +pub(super) fn build( + owner: &str, + now: i64, + kind: NodeKind, + title: String, + body: Option, +) -> Node { + Node { + id: new_id(), + owner_id: owner.to_string(), + kind, + title, + body, + created_at: now, + modified_at: now, + hlc: hlc_for(now), + tombstoned: false, + } +} + +/// Insert a fully-formed [`Node`] row. +pub(super) fn insert(conn: &Connection, node: &Node) -> Result<()> { + conn.execute( + "INSERT INTO nodes (id, owner_id, kind, title, body, created_at, modified_at, hlc, tombstoned) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + ( + &node.id, + &node.owner_id, + node.kind.as_str(), + &node.title, + &node.body, + node.created_at, + node.modified_at, + &node.hlc, + node.tombstoned as i64, + ), + )?; + Ok(()) +} + +/// Map a `nodes` row (selected with [`COLUMNS`]) to a [`Node`]. +pub(super) fn from_row(row: &Row) -> rusqlite::Result { + Ok(Node { + id: row.get("id")?, + owner_id: row.get("owner_id")?, + kind: NodeKind::parse(&row.get::<_, String>("kind")?) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?, + title: row.get("title")?, + body: row.get("body")?, + created_at: row.get("created_at")?, + modified_at: row.get("modified_at")?, + hlc: row.get("hlc")?, + tombstoned: row.get::<_, i64>("tombstoned")? != 0, + }) +} + +/// Create and persist a node. +pub(super) fn create(conn: &Connection, owner: &str, now: i64, input: NewNode) -> Result { + let node = build(owner, now, input.kind, input.title, input.body); + insert(conn, &node)?; + Ok(node) +} + +/// Fetch a node by id (tombstoned rows included). +pub(super) fn get(conn: &Connection, id: &str) -> Result> { + let node = conn + .query_row( + &format!("SELECT {COLUMNS} FROM nodes WHERE id = ?1"), + [id], + from_row, + ) + .optional()?; + Ok(node) +} + +/// Update a node's title and/or body. A body change re-runs extraction and +/// reconciles this node's `wiki` links (tech-spec §5). +pub(super) fn update( + conn: &mut Connection, + owner: &str, + now: i64, + id: &str, + title: Option, + body: Option, +) -> Result { + let mut node = get(conn, id)?.ok_or_else(|| Error::NodeNotFound(id.to_string()))?; + + if let Some(t) = title { + node.title = t; + } + let body_changed = match body { + Some(b) => { + let changed = node.body.as_deref() != Some(b.as_str()); + node.body = Some(b); + changed + } + None => false, + }; + node.modified_at = now; + node.hlc = hlc_for(now); + + let tx = conn.transaction()?; + tx.execute( + "UPDATE nodes SET title = ?1, body = ?2, modified_at = ?3, hlc = ?4 WHERE id = ?5", + ( + &node.title, + &node.body, + node.modified_at, + &node.hlc, + &node.id, + ), + )?; + if body_changed { + links::sync_wiki_links( + &tx, + owner, + &node.id, + node.body.as_deref().unwrap_or(""), + now, + )?; + } + tx.commit()?; + Ok(node) +} + +/// Bump `modified_at`/`hlc` on a node (used when a task scalar field changes so +/// the node's modified time reflects the mutation for sync ordering). +pub(super) fn touch(conn: &Connection, now: i64, id: &str) -> Result<()> { + conn.execute( + "UPDATE nodes SET modified_at = ?1, hlc = ?2 WHERE id = ?3", + (now, hlc_for(now), id), + )?; + Ok(()) +} diff --git a/crates/heph-core/src/sqlite/tasks.rs b/crates/heph-core/src/sqlite/tasks.rs new file mode 100644 index 0000000..7f61915 --- /dev/null +++ b/crates/heph-core/src/sqlite/tasks.rs @@ -0,0 +1,136 @@ +//! `tasks` table operations. +//! +//! A committed task is a `task` node plus a `tasks` row. On creation it also +//! gets a canonical context `doc` and a `canonical-context` link (tech-spec §6). + +use rusqlite::{Connection, OptionalExtension, Row}; + +use super::{links, nodes}; +use crate::error::{Error, Result}; +use crate::model::{Attention, LinkType, NewTask, NodeKind, Task, TaskState}; + +fn from_row(row: &Row) -> rusqlite::Result { + let attention = match row.get::<_, Option>("attention")? { + Some(s) => Some( + Attention::parse(&s) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?, + ), + None => None, + }; + Ok(Task { + node_id: row.get("node_id")?, + attention, + do_date: row.get("do_date")?, + late_on: row.get("late_on")?, + state: TaskState::parse(&row.get::<_, String>("state")?) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?, + recurrence: row.get("recurrence")?, + }) +} + +const COLUMNS: &str = "node_id, attention, do_date, late_on, state, recurrence"; + +/// Create a committed task: its task node, the `tasks` row, the canonical +/// context doc, the `canonical-context` link, and (if given) an `in-project` +/// link — all in one transaction. +pub(super) fn create(conn: &mut Connection, owner: &str, now: i64, input: NewTask) -> Result { + let task = Task { + node_id: String::new(), // filled below + attention: input.attention, + do_date: input.do_date, + late_on: input.late_on, + state: TaskState::Outstanding, + recurrence: input.recurrence, + }; + + let tx = conn.transaction()?; + + let task_node = nodes::build(owner, now, NodeKind::Task, input.title.clone(), None); + nodes::insert(&tx, &task_node)?; + tx.execute( + "INSERT INTO tasks (node_id, attention, do_date, late_on, state, recurrence) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + ( + &task_node.id, + task.attention.map(|a| a.as_str()), + task.do_date, + task.late_on, + task.state.as_str(), + &task.recurrence, + ), + )?; + + // The canonical context doc (the task's jumping-off point / checklist body). + let doc = nodes::build( + owner, + now, + NodeKind::Doc, + input.title.clone(), + Some(String::new()), + ); + nodes::insert(&tx, &doc)?; + links::add(&tx, now, &task_node.id, &doc.id, LinkType::CanonicalContext)?; + + if let Some(project_id) = &input.project_id { + links::add(&tx, now, &task_node.id, project_id, LinkType::InProject)?; + } + + tx.commit()?; + + Ok(Task { + node_id: task_node.id, + ..task + }) +} + +/// Fetch a task by node id. +pub(super) fn get(conn: &Connection, node_id: &str) -> Result> { + let task = conn + .query_row( + &format!("SELECT {COLUMNS} FROM tasks WHERE node_id = ?1"), + [node_id], + from_row, + ) + .optional()?; + Ok(task) +} + +fn require(conn: &Connection, node_id: &str) -> Result { + get(conn, node_id)?.ok_or_else(|| Error::NodeNotFound(node_id.to_string())) +} + +/// Set a task's lifecycle state. +pub(super) fn set_state( + conn: &Connection, + now: i64, + node_id: &str, + state: TaskState, +) -> Result { + let updated = conn.execute( + "UPDATE tasks SET state = ?1 WHERE node_id = ?2", + (state.as_str(), node_id), + )?; + if updated == 0 { + return Err(Error::NodeNotFound(node_id.to_string())); + } + nodes::touch(conn, now, node_id)?; + require(conn, node_id) +} + +/// Set a task's attention-state. +pub(super) fn set_attention( + conn: &Connection, + now: i64, + node_id: &str, + attention: Attention, +) -> Result { + let updated = conn.execute( + "UPDATE tasks SET attention = ?1 WHERE node_id = ?2", + (attention.as_str(), node_id), + )?; + if updated == 0 { + return Err(Error::NodeNotFound(node_id.to_string())); + } + nodes::touch(conn, now, node_id)?; + require(conn, node_id) +} diff --git a/crates/heph-core/src/store.rs b/crates/heph-core/src/store.rs index 6c7a8ed..7465646 100644 --- a/crates/heph-core/src/store.rs +++ b/crates/heph-core/src/store.rs @@ -5,13 +5,15 @@ //! `RemoteStore`) is configuration. This trait is the seam. use crate::error::Result; -use crate::model::{NewNode, Node}; +use crate::model::{Attention, Link, LinkType, NewNode, NewTask, Node, Task, TaskState}; -/// A backend that can store and retrieve nodes. +/// A backend that can store and retrieve nodes, tasks, and links. /// /// Methods that mutate take `&mut self`: a `LocalStore` holds an exclusive lock /// on its file, so single-writer semantics are honest at the type level. pub trait Store { + // --- nodes --- + /// Create a node, assigning it an id and timestamps. Returns the stored row. fn create_node(&mut self, input: NewNode) -> Result; @@ -20,4 +22,40 @@ pub trait Store { /// Tombstoned nodes are still returned here (callers that must exclude them /// — `next`, `list`, `search`, `export` — filter explicitly). fn get_node(&self, id: &str) -> Result>; + + /// Update a node's title and/or body. A body update re-runs markdown + /// extraction and reconciles this node's `wiki` links (tech-spec §5, §6). + fn update_node( + &mut self, + id: &str, + title: Option, + body: Option, + ) -> Result; + + // --- tasks --- + + /// Create a committed task, auto-creating its canonical context `doc` and + /// the `canonical-context` link (tech-spec §6). + fn create_task(&mut self, input: NewTask) -> Result; + + /// Fetch a task by its node id. + fn get_task(&self, node_id: &str) -> Result>; + + /// Set a task's lifecycle state. (Recurrence roll-forward is layered on in + /// a later slice — tech-spec §4.4.) + fn set_task_state(&mut self, node_id: &str, state: TaskState) -> Result; + + /// Set a task's attention-state. + fn set_task_attention(&mut self, node_id: &str, attention: Attention) -> Result; + + // --- links --- + + /// Add a typed link between two nodes. + fn add_link(&mut self, src_id: &str, dst_id: &str, link_type: LinkType) -> Result; + + /// All non-tombstoned links originating at `id`. + fn outgoing_links(&self, id: &str) -> Result>; + + /// All non-tombstoned links pointing at `id` (backlinks). + fn backlinks(&self, id: &str) -> Result>; } diff --git a/crates/heph-core/tests/tasks_and_links.rs b/crates/heph-core/tests/tasks_and_links.rs new file mode 100644 index 0000000..ee17402 --- /dev/null +++ b/crates/heph-core/tests/tasks_and_links.rs @@ -0,0 +1,212 @@ +//! Public-API tests for tasks, the canonical context doc, links, and +//! wiki-link materialization (tech-spec §4–§6, slice 3). + +use heph_core::{ + Attention, FixedClock, LinkType, LocalStore, NewNode, NewTask, NodeKind, Store, TaskState, +}; + +fn store() -> LocalStore { + LocalStore::open_in_memory(Box::new(FixedClock(1_700_000_000_000))).unwrap() +} + +#[test] +fn create_task_makes_a_canonical_context_doc_and_link() { + let mut s = store(); + let task = s + .create_task(NewTask { + title: "Fix the roof leak".into(), + attention: Some(Attention::Orange), + ..Default::default() + }) + .unwrap(); + + // The task node exists and is a task. + let node = s.get_node(&task.node_id).unwrap().unwrap(); + assert_eq!(node.kind, NodeKind::Task); + assert_eq!(node.title, "Fix the roof leak"); + assert_eq!(node.body, None); + + // It has exactly one canonical-context link to a doc node. + let out = s.outgoing_links(&task.node_id).unwrap(); + let ctx: Vec<_> = out + .iter() + .filter(|l| l.link_type == LinkType::CanonicalContext) + .collect(); + assert_eq!(ctx.len(), 1); + + let doc = s.get_node(&ctx[0].dst_id).unwrap().unwrap(); + assert_eq!(doc.kind, NodeKind::Doc); + assert_eq!(doc.title, "Fix the roof leak"); + assert_eq!(doc.body.as_deref(), Some("")); +} + +#[test] +fn task_scalar_fields_round_trip_and_default_to_outstanding() { + let mut s = store(); + let task = s + .create_task(NewTask { + title: "Renew passport".into(), + attention: Some(Attention::Red), + do_date: Some(1_700_000_100_000), + late_on: Some(1_700_000_200_000), + ..Default::default() + }) + .unwrap(); + + let got = s.get_task(&task.node_id).unwrap().unwrap(); + assert_eq!(got.attention, Some(Attention::Red)); + assert_eq!(got.do_date, Some(1_700_000_100_000)); + assert_eq!(got.late_on, Some(1_700_000_200_000)); + assert_eq!(got.state, TaskState::Outstanding); + assert_eq!(got.recurrence, None); +} + +#[test] +fn set_state_and_attention_persist() { + let mut s = store(); + let task = s + .create_task(NewTask { + title: "Pay invoice".into(), + ..Default::default() + }) + .unwrap(); + + s.set_task_attention(&task.node_id, Attention::Blue) + .unwrap(); + let t = s.set_task_state(&task.node_id, TaskState::Done).unwrap(); + assert_eq!(t.state, TaskState::Done); + assert_eq!(t.attention, Some(Attention::Blue)); + + let reread = s.get_task(&task.node_id).unwrap().unwrap(); + assert_eq!(reread.state, TaskState::Done); + assert_eq!(reread.attention, Some(Attention::Blue)); +} + +#[test] +fn project_link_is_created_when_given() { + let mut s = store(); + let project = s + .create_node(NewNode { + kind: NodeKind::Project, + title: "Chores".into(), + body: None, + }) + .unwrap(); + let task = s + .create_task(NewTask { + title: "Take out trash".into(), + project_id: Some(project.id.clone()), + ..Default::default() + }) + .unwrap(); + + let has_project = s + .outgoing_links(&task.node_id) + .unwrap() + .iter() + .any(|l| l.link_type == LinkType::InProject && l.dst_id == project.id); + assert!(has_project); +} + +#[test] +fn updating_a_body_materializes_resolved_wiki_links() { + let mut s = store(); + let target = s + .create_node(NewNode { + kind: NodeKind::Doc, + title: "Contractor calls".into(), + body: Some(String::new()), + }) + .unwrap(); + let source = s.create_node(NewNode::doc("Roof log", "")).unwrap(); + + s.update_node(&source.id, None, Some("See [[Contractor calls]].".into())) + .unwrap(); + + let wiki: Vec<_> = s + .outgoing_links(&source.id) + .unwrap() + .into_iter() + .filter(|l| l.link_type == LinkType::Wiki) + .collect(); + assert_eq!(wiki.len(), 1); + assert_eq!(wiki[0].dst_id, target.id); + + // And the target sees it as a backlink. + let back = s.backlinks(&target.id).unwrap(); + assert!(back.iter().any(|l| l.src_id == source.id)); +} + +#[test] +fn unresolved_wiki_link_links_once_target_is_created() { + let mut s = store(); + let source = s.create_node(NewNode::doc("Notes", "")).unwrap(); + + // Target doesn't exist yet → no wiki link. + s.update_node(&source.id, None, Some("plan [[Nursery]] build".into())) + .unwrap(); + assert!(s + .outgoing_links(&source.id) + .unwrap() + .iter() + .all(|l| l.link_type != LinkType::Wiki)); + + // Create the target, then edit the body again → the link now resolves. + let nursery = s.create_node(NewNode::doc("Nursery", "")).unwrap(); + s.update_node(&source.id, None, Some("plan [[Nursery]] build now".into())) + .unwrap(); + + let wiki: Vec<_> = s + .outgoing_links(&source.id) + .unwrap() + .into_iter() + .filter(|l| l.link_type == LinkType::Wiki) + .collect(); + assert_eq!(wiki.len(), 1); + assert_eq!(wiki[0].dst_id, nursery.id); +} + +#[test] +fn wiki_links_reconcile_on_edit_add_and_remove() { + let mut s = store(); + let a = s.create_node(NewNode::doc("A", "")).unwrap(); + let b = s.create_node(NewNode::doc("B", "")).unwrap(); + let src = s.create_node(NewNode::doc("Src", "")).unwrap(); + + s.update_node(&src.id, None, Some("[[A]] and [[B]]".into())) + .unwrap(); + assert_eq!(active_wiki(&s, &src.id), 2); + + // Drop B from the body → its wiki link is tombstoned. + s.update_node(&src.id, None, Some("only [[A]] now".into())) + .unwrap(); + let dsts: Vec = s + .outgoing_links(&src.id) + .unwrap() + .into_iter() + .filter(|l| l.link_type == LinkType::Wiki) + .map(|l| l.dst_id) + .collect(); + assert_eq!(dsts, vec![a.id.clone()]); + assert!(!dsts.contains(&b.id)); +} + +#[test] +fn re_saving_identical_body_does_not_duplicate_wiki_links() { + let mut s = store(); + let _a = s.create_node(NewNode::doc("A", "")).unwrap(); + let src = s.create_node(NewNode::doc("Src", "")).unwrap(); + + s.update_node(&src.id, None, Some("[[A]]".into())).unwrap(); + s.update_node(&src.id, None, Some("[[A]]".into())).unwrap(); + s.update_node(&src.id, None, Some("[[A]]".into())).unwrap(); + assert_eq!(active_wiki(&s, &src.id), 1); +} + +fn active_wiki(s: &LocalStore, id: &str) -> usize { + s.outgoing_links(id) + .unwrap() + .iter() + .filter(|l| l.link_type == LinkType::Wiki) + .count() +} diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index 8c80911..1ff6bfc 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -2,4 +2,5 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - Cargo workspace + `heph-core` crate; migration-run SQLite schema (§4.5); clock-injected `Store` trait + `LocalStore` node create/get; single local-user bootstrap. - Markdown extraction (§5): `[[wiki-links]]` and GFM `- [ ]` checkbox context-items derived purely and idempotently from a body, skipping code blocks. +- Committed tasks (§4.3, §6): `task.create` auto-creates the canonical context `doc` + `canonical-context` link; attention/do-date/late-on/state/recurrence columns; set-state/set-attention. Links CRUD (outgoing/backlinks). A body update reconciles `wiki` links (diff-based, resolved by alias/title, idempotent). - CI runs the Rust suite (fmt/clippy/test) via the project build hook.