hephaestus/crates/heph-core/src/sqlite/links.rs
Erich Blume df7f43788b feat(core): task.set_project — move-to-project with OR-set link semantics (§8.1)
Add `Store::set_task_project` (heph-core + RemoteStore) and the
`task.set_project` RPC: tombstone the task's existing `in-project` link(s)
and add a new one (or none, to unfile). A given project id must name a
live project-kind node, else InvalidArg/NodeNotFound.

Route `heph edit --project` through it, fixing a duplicate-link bug (the
old path added an in-project link without removing the prior one);
`--project none` now unfiles. Factor a `links::tombstone` helper out of
`sync_wiki_links`.

Tests: core move/unfile/reject + a duplicate-link regression; a socket
dispatch test. The TUI `m` gesture follows in the next commit.

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

245 lines
8.3 KiB
Rust

//! `links` table operations, plus `wiki` link materialization from bodies.
use std::collections::HashSet;
use rusqlite::{Connection, OptionalExtension, Row};
use super::{new_id, next_hlc, ops};
use crate::error::Result;
use crate::extract::extract;
use crate::model::{Link, LinkType};
use crate::oplog::op_type;
use serde_json::json;
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,
owner: &str,
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,
),
)?;
let hlc = next_hlc(conn, now)?;
ops::record(
conn,
owner,
&hlc,
op_type::LINK_ADD,
&link.id,
json!({
"src": link.src_id,
"dst": link.dst_id,
"type": link.link_type.as_str(),
"created_at": link.created_at,
}),
)?;
Ok(link)
}
/// Tombstone a single link by id, recording the OR-set `link.remove` op.
/// Monotonic: re-tombstoning an already-dead link is a harmless no-op write.
pub(super) fn tombstone(conn: &Connection, owner: &str, now: i64, link_id: &str) -> Result<()> {
conn.execute("UPDATE links SET tombstoned = 1 WHERE id = ?1", [link_id])?;
let hlc = next_hlc(conn, now)?;
ops::record(conn, owner, &hlc, op_type::LINK_REMOVE, link_id, json!({}))?;
Ok(())
}
/// The destination of the first non-tombstoned link of `link_type` out of
/// `src_id`, if any (e.g. a task's canonical-context doc or its log doc).
pub(super) fn first_dst(
conn: &Connection,
src_id: &str,
link_type: LinkType,
) -> Result<Option<String>> {
let dst = conn
.query_row(
"SELECT dst_id FROM links
WHERE src_id = ?1 AND type = ?2 AND tombstoned = 0
ORDER BY created_at, id LIMIT 1",
(src_id, link_type.as_str()),
|r| r.get(0),
)
.optional()?;
Ok(dst)
}
/// 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_id(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) {
tombstone(conn, owner, now, link_id)?;
}
}
// Add links for newly-referenced targets.
for dst in &desired {
if !existing_dsts.contains(dst.as_str()) {
add(conn, owner, 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. Shared by `wiki`
/// link materialization and the `node.resolve` surface (tech-spec §5, §6).
pub(super) fn resolve_id(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);
}
// A title may be shared by a task and its canonical-context doc (they are
// created with the same title). Wiki-links address the first-class node, so
// canonical-context docs are excluded — `[[Task Title]]` resolves to the
// task, never its internal context attachment (deterministic; tech-spec §6).
let by_title: Option<String> = conn
.query_row(
"SELECT id FROM nodes
WHERE title = ?1 AND owner_id = ?2 AND tombstoned = 0
AND id NOT IN (SELECT dst_id FROM links
WHERE type = 'canonical-context' AND tombstoned = 0)
ORDER BY created_at, id LIMIT 1",
(target, owner),
|r| r.get(0),
)
.optional()?;
Ok(by_title)
}
/// Resolve a project **name** to its node id, restricted to `project`-kind
/// nodes (so a like-named task/doc never wins). `None` if no such project.
/// Used by filter-view scope/exclude resolution (tech-spec §8.2).
pub(super) fn resolve_project_id(
conn: &Connection,
owner: &str,
name: &str,
) -> Result<Option<String>> {
Ok(conn
.query_row(
"SELECT id FROM nodes
WHERE title = ?1 AND owner_id = ?2 AND kind = 'project' AND tombstoned = 0
ORDER BY created_at, id LIMIT 1",
(name, owner),
|r| r.get(0),
)
.optional()?)
}
/// Every project node id in the subtree rooted at `root` (inclusive): `root`
/// plus every project that reaches it through `parent` links (a child holds a
/// `parent` link to its parent, src=child → dst=parent). Powers the
/// project-subtree scope of filter views (tech-spec §8.2). Cycle-safe via the
/// visited set.
pub(super) fn project_subtree(conn: &Connection, root: &str) -> Result<Vec<String>> {
let mut out = vec![root.to_string()];
let mut frontier = vec![root.to_string()];
let mut stmt = conn.prepare(
"SELECT src_id FROM links
WHERE dst_id = ?1 AND type = 'parent' AND tombstoned = 0",
)?;
while let Some(parent) = frontier.pop() {
let children: Vec<String> = stmt
.query_map([&parent], |r| r.get(0))?
.collect::<rusqlite::Result<Vec<_>>>()?;
for child in children {
if !out.contains(&child) {
out.push(child.clone());
frontier.push(child);
}
}
}
Ok(out)
}