hephaestus/crates/heph-core/src/store.rs
Erich Blume dd5ef7dc63
Some checks failed
Build / validate (pull_request) Failing after 11s
fix: deleting a project unfiles its tasks to the Inbox (§8.1/§8.2)
Project delete previously tombstoned only the project node, leaving its
tasks with a live in-project link to a dead project — orphaned (not in the
Inbox, unbrowsable, blank project) rather than unfiled as intended. New
atomic Store::delete_project tombstones every in-project link to the project
(tasks fall to the Inbox), then tombstones the project node; tasks are never
deleted. Exposed as the project.delete RPC (LocalStore + RemoteStore); the
heph-tui sidebar `D` now routes through it. Core test asserts the task
survives and becomes unfiled; the project node is tombstoned.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 19:15:54 -07:00

227 lines
11 KiB
Rust

//! The storage abstraction (tech-spec §3.1).
//!
//! A runtime points at *something that stores nodes*; whether that is a local
//! SQLite file ([`crate::sqlite::LocalStore`]) or a remote server (a future
//! `RemoteStore`) is configuration. This trait is the seam.
use crate::error::Result;
use crate::filter::ListFilter;
use crate::model::{
Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, SchedulePatch,
SyncCursors, Task, TaskState,
};
use crate::oplog::Op;
use crate::ranking::RankedTask;
/// 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>;
/// Fetch a node by id. Returns `None` if it does not exist.
///
/// 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>;
/// Tombstone (soft-delete) a node. No hard deletes (tech-spec §4.3).
fn tombstone_node(&mut self, id: &str) -> Result<()>;
/// List non-tombstoned nodes (owner-scoped), optionally filtered by `kind`,
/// ordered by title. The enumeration surfaces (projects, tags) build on this
/// (tech-spec §6 `node.list`).
fn list_nodes(&self, kind: Option<NodeKind>) -> Result<Vec<Node>>;
/// First-class wiki-link targets (tech-spec §8.4): non-tombstoned nodes
/// minus the internal noise — `tag` nodes and the `doc`s that are a task's
/// canonical-context or log attachment. Backs the `[[` picker.
fn list_linkable_nodes(&self) -> Result<Vec<Node>>;
/// Resolve a wiki-link target (`[[title]]`) to a node, **exactly** — an
/// alias match first, then an exact, owner-scoped, non-tombstoned title
/// match; `None` if nothing matches (an unresolved link is allowed, §5).
///
/// This is the same mapping the store uses to materialize `wiki` links, so
/// a surface's "follow link under cursor" jumps to the *same* node the
/// stored link points at — unlike fuzzy `search` (tech-spec §6, §8).
fn resolve_node(&self, title: &str) -> Result<Option<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. Completing a **recurring** task rolls it
/// forward in place — fresh checklist, logged occurrence, advanced do-date
/// (tech-spec §4.4) — rather than marking it done.
fn set_task_state(&mut self, node_id: &str, state: TaskState) -> Result<Task>;
/// Skip the current occurrence of a recurring task: advance its do-date
/// without logging a completion (tech-spec §4.4). Errors on a non-recurring
/// task.
fn skip_recurrence(&mut self, node_id: &str) -> Result<Task>;
/// Set a task's attention-state.
fn set_task_attention(&mut self, node_id: &str, attention: Attention) -> Result<Task>;
/// Apply a partial update to a task's schedule scalars — do-date, late-on,
/// recurrence (tech-spec §6 `task.set_schedule`). Each [`SchedulePatch`]
/// field is a double option: absent = unchanged, `null` = clear, value =
/// set. This is the "reschedule" path (the scalars with no dedicated setter).
fn set_task_schedule(&mut self, node_id: &str, patch: SchedulePatch) -> Result<Task>;
/// Re-file a task under a project (or unfile it when `project_id` is
/// `None`) — the move-to-project path (tech-spec §8.1). OR-set link
/// semantics: the old `in-project` link is tombstoned and a new one added.
/// A given `project_id` must name a live `project`-kind node.
fn set_task_project(&mut self, node_id: &str, project_id: Option<&str>) -> Result<Task>;
/// Delete a project: unfile its tasks (they fall to the Inbox) and tombstone
/// the project node. Tasks are preserved, never deleted.
fn delete_project(&mut self, project_id: &str) -> Result<()>;
/// Promote a `- [ ]` context-item line in `container_id`'s body into a
/// committed task, rewriting that source line into a `[[link]]` to the new
/// task (Fork A, tech-spec §4.3, §6). `item_ref` is the 1-based index of the
/// item among the container's context items in document order.
fn promote(
&mut self,
container_id: &str,
item_ref: usize,
attention: Option<Attention>,
project_id: Option<String>,
) -> Result<Task>;
/// The Tactical "what is next?" ranking (tech-spec §7), using the store's
/// injected clock as `now`. `scope`, when `Some`, restricts to a project
/// node id; `red` items always appear regardless of `limit`.
fn next(&self, scope: Option<&str>, limit: usize) -> Result<Vec<RankedTask>>;
/// Enumerate outstanding committed tasks matching a [`ListFilter`] — the
/// data-expressed predicate behind filter views (tech-spec §8.2). Returns
/// **titled** [`RankedTask`] rows (the same shape `next` returns, so the
/// survey view needs no N+1 `get_node`). An empty filter is the whole
/// outstanding set (the Organizational survey, tech-spec §6).
fn list(&self, filter: &ListFilter) -> Result<Vec<RankedTask>>;
/// Run a built-in named filter view (`tom|ondeck|chores|work|tasks`,
/// tech-spec §8.2): resolve its project names to ids (subtree-expanded),
/// build the [`ListFilter`], and return the matching rows. Errors on an
/// unknown view name.
fn view(&self, name: &str) -> Result<Vec<RankedTask>>;
/// Working-set health — orange/active/on-deck/conflict counts (tech-spec §7).
fn health(&self) -> Result<Health>;
/// Full-text search over title + body (FTS5), owner-scoped, best-match
/// first, tombstones excluded (tech-spec §6). `query` is FTS5 MATCH syntax.
fn search(&self, query: &str) -> Result<Vec<Node>>;
/// Open (creating if absent) the journal node for an ISO `date`. The id is
/// deterministic in `(owner, date)` so offline replicas converge (§3.1).
fn journal_open_or_create(&mut self, date: &str) -> Result<Node>;
// --- 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>>;
// --- tags (tech-spec §4, §8.3) ---
/// Tag `node_id` with `tag` (trimmed), creating the canonical `tag`-kind
/// node on first use — its id is deterministic in `(owner, name)`, so a
/// name is one shared tag and replicas converge. OR-set `tagged` link;
/// idempotent. Returns the tag node. Errors on a missing node or blank name.
fn add_tag(&mut self, node_id: &str, tag: &str) -> Result<Node>;
/// Remove `tag` from `node_id` (tombstone the `tagged` link); a no-op if it
/// isn't tagged. The tag node itself persists.
fn remove_tag(&mut self, node_id: &str, tag: &str) -> Result<()>;
/// The tag names on `node_id`, sorted. (Enumerate *all* tags via
/// [`Store::list_nodes`] with [`NodeKind::Tag`].)
fn tags_of(&self, node_id: &str) -> Result<Vec<String>>;
/// One-time migration (tech-spec §8.4): rewrite legacy name-addressed body
/// links `[[Name]]` to the canonical `[[NODEID]]`, resolving each name and
/// re-materializing the `wiki` links by id. Idempotent (already-id links are
/// left alone). Returns the number of nodes whose body changed.
fn migrate_wikilinks_to_ids(&mut self) -> Result<usize>;
// --- per-task log ([[design]] §6.4) ---
/// Append a line to a task's append-only log (creating the log on first
/// use). The log is the resumption breadcrumb store.
fn log_append(&mut self, task_id: &str, text: &str) -> Result<()>;
/// The task's latest `n` log entries (oldest→newest); empty if it has none.
fn log_tail(&self, task_id: &str, n: usize) -> Result<Vec<String>>;
/// Export every non-tombstoned node to a `.md` directory tree under `dir`,
/// returning the count written (tech-spec §5). One-way; no import.
fn export(&self, dir: &std::path::Path) -> Result<usize>;
// --- sync (op-log) ---
/// Ops for this owner with HLC strictly greater than `after` (None ⇒ all),
/// in causal order — the push cursor for sync (tech-spec §12).
fn ops_since(&self, after: Option<&str>) -> Result<Vec<Op>>;
/// Apply a foreign op with the merge rules (LWW / OR-set / tombstone),
/// idempotently. Returns `true` if newly applied. Ops should be applied in
/// HLC order (tech-spec §12).
fn apply_op(&mut self, op: &Op) -> Result<bool>;
/// Rewrite this replica's `owner_id` to a canonical user id — the one-time,
/// pre-first-sync adoption of tech-spec §13. After this all data is owned by
/// `canonical`; replicas that adopt the same id can sync. (Full adoption,
/// incl. owner-embedded deterministic ids, is refined with auth.)
fn adopt_owner(&mut self, canonical: &str) -> Result<()>;
/// The push/pull HLC cursors for a sync `peer` (the hub url). Defaults to
/// empty cursors when this replica has never synced with `peer` (§12).
fn sync_state(&self, peer: &str) -> Result<SyncCursors>;
/// Record progress with a sync `peer`: advance the `pushed`/`pulled` HLC
/// cursors (each `None` leaves that direction unchanged). Upserts the
/// `sync_state` row (§12).
fn record_sync(&mut self, peer: &str, pushed: Option<&str>, pulled: Option<&str>)
-> Result<()>;
/// Single-tenant authentication gate (tech-spec §13). Map an OIDC `sub` to
/// this store's owner: on first sight, **claim** the owner by binding its
/// `oidc_sub`; thereafter authorize only that same `sub`. Returns `true` if
/// the `sub` owns this store, `false` if a different identity presented a
/// token. A hub calls this before serving any op exchange.
fn authorize_owner_sub(&mut self, sub: &str) -> Result<bool>;
/// Open merge conflicts surfaced for the user (`heph conflicts`).
fn conflicts_list(&self) -> Result<Vec<Conflict>>;
/// Settle a conflict by the user's choice (`"local"`/`"remote"`).
fn conflicts_resolve(&mut self, id: &str, choice: &str) -> Result<()>;
}