generated from eblume/project-template
heph-core: tasks, links, canonical-context doc
Some checks failed
Build / validate (pull_request) Failing after 3s
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:
parent
1995e0e3cf
commit
b9d2072f75
9 changed files with 922 additions and 99 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
148
crates/heph-core/src/sqlite/links.rs
Normal file
148
crates/heph-core/src/sqlite/links.rs
Normal 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)
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
147
crates/heph-core/src/sqlite/nodes.rs
Normal file
147
crates/heph-core/src/sqlite/nodes.rs
Normal 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(())
|
||||
}
|
||||
136
crates/heph-core/src/sqlite/tasks.rs
Normal file
136
crates/heph-core/src/sqlite/tasks.rs
Normal 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)
|
||||
}
|
||||
|
|
@ -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>>;
|
||||
}
|
||||
|
|
|
|||
212
crates/heph-core/tests/tasks_and_links.rs
Normal file
212
crates/heph-core/tests/tasks_and_links.rs
Normal 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()
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue