heph-core: tasks, links, canonical-context doc
Some checks failed
Build / validate (pull_request) Failing after 3s

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) <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-05-31 19:02:35 -07:00
commit b9d2072f75
9 changed files with 922 additions and 99 deletions

View file

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

View file

@ -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<Attention> {
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<TaskState> {
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<LinkType> {
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<Attention>,
/// Earliest-actionable date, epoch ms (candidacy gate only, §7).
pub do_date: Option<i64>,
/// When lateness becomes a problem, epoch ms (the sole urgency signal, §7).
pub late_on: Option<i64>,
/// Lifecycle state.
pub state: TaskState,
/// RFC-5545 RRULE; present ⇒ a recurring definition (§4.4).
pub recurrence: Option<String>,
}
/// 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<Attention>,
/// Earliest-actionable date, epoch ms.
pub do_date: Option<i64>,
/// Lateness-problem marker, epoch ms.
pub late_on: Option<i64>,
/// RRULE for a recurring definition.
pub recurrence: Option<String>,
/// Optional project node to link via `in-project`.
pub project_id: Option<String>,
}
/// Input for creating a node.
#[derive(Debug, Clone)]
pub struct NewNode {

View file

@ -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<Link> {
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<Link> {
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<Vec<Link>> {
query(conn, "src_id", id)
}
/// All non-tombstoned links pointing at `id`.
pub(super) fn backlinks(conn: &Connection, id: &str) -> Result<Vec<Link>> {
query(conn, "dst_id", id)
}
fn query(conn: &Connection, column: &str, id: &str) -> Result<Vec<Link>> {
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::<rusqlite::Result<Vec<_>>>()?)
}
/// 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<String> = Vec::new();
let mut desired_set: HashSet<String> = 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::<rusqlite::Result<Vec<_>>>()?
};
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<Option<String>> {
let by_alias: Option<String> = 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<String> = 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)
}

View file

@ -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<String> {
{
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<String> {
Ok(id)
}
/// Map a `nodes` row to a [`Node`]. Column order must match the SELECT.
fn row_to_node(row: &Row) -> rusqlite::Result<Node> {
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<Node> {
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<Option<Node>> {
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<String>,
body: Option<String>,
) -> Result<Node> {
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<Task> {
let now = self.clock.now_ms();
tasks::create(&mut self.conn, &self.owner_id, now, input)
}
fn get_task(&self, node_id: &str) -> Result<Option<Task>> {
tasks::get(&self.conn, node_id)
}
fn set_task_state(&mut self, node_id: &str, state: TaskState) -> Result<Task> {
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<Task> {
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<Link> {
let now = self.clock.now_ms();
links::add(&self.conn, now, src_id, dst_id, link_type)
}
fn outgoing_links(&self, id: &str) -> Result<Vec<Link>> {
links::outgoing(&self.conn, id)
}
fn backlinks(&self, id: &str) -> Result<Vec<Link>> {
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);
}
}

View file

@ -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<String>,
) -> 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<Node> {
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<Node> {
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<Option<Node>> {
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<String>,
body: Option<String>,
) -> Result<Node> {
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(())
}

View file

@ -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<Task> {
let attention = match row.get::<_, Option<String>>("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<Task> {
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<Option<Task>> {
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<Task> {
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<Task> {
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<Task> {
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)
}

View file

@ -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<Node>;
@ -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<Option<Node>>;
/// 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<String>,
body: Option<String>,
) -> Result<Node>;
// --- 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<Task>;
/// Fetch a task by its node id.
fn get_task(&self, node_id: &str) -> Result<Option<Task>>;
/// 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<Task>;
/// Set a task's attention-state.
fn set_task_attention(&mut self, node_id: &str, attention: Attention) -> Result<Task>;
// --- links ---
/// Add a typed link between two nodes.
fn add_link(&mut self, src_id: &str, dst_id: &str, link_type: LinkType) -> Result<Link>;
/// All non-tombstoned links originating at `id`.
fn outgoing_links(&self, id: &str) -> Result<Vec<Link>>;
/// All non-tombstoned links pointing at `id` (backlinks).
fn backlinks(&self, id: &str) -> Result<Vec<Link>>;
}

View file

@ -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<String> = 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()
}

View file

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