hephaestus/crates/heph-core/src/sqlite/mod.rs
Erich Blume d0debfceb9
Some checks failed
Build / validate (pull_request) Failing after 4s
heph-core: recurrence (roll-forward in place) + per-task logs
Slice 5 (tech-spec §4.4). Completing a recurring task rolls it forward in
place instead of marking it done — the Todoist-corner-avoiding model.

Pure recurrence module:
- next_occurrence(rrule, anchor, after): lazy RRULE expansion (rrule +
  chrono/UTC) returning the next instance strictly after `after`,
  skipping missed occurrences; None when a finite series is exhausted.
- reset_checkboxes(body): the fresh-checklist transform — unchecks every
  `- [x]`, idempotent, preserves indentation/bullet/line-endings.

Storage roll-forward (one transaction, on set_state(done) of a recurring
task): reset the canonical context doc's checklist, append the completed
occurrence to the task's log, advance do_date to the next instance after
now (skipping misses); finite series finally goes done. `skip` advances
the same way without logging. Non-recurring done is unchanged.

Per-task append-only log (`log-of` doc): log_append / log_tail — the
resumption breadcrumb + recurring-completion narrative ([[design]] §6.4).

Tests: 7 recurrence unit + 2 proptests (no checked marker survives reset;
reset idempotent for any body) + 6 end-to-end incl. five-occurrence
no-carry-forward and missed-collapse-to-one. 53 tests green. This
completes the heph-core library layer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 19:14:22 -07:00

205 lines
6.2 KiB
Rust

//! SQLite-backed [`Store`] implementation.
//!
//! `LocalStore` opens a SQLite file directly. The exclusive-lock handoff of
//! 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 log;
mod migrations;
mod nodes;
mod tasks;
pub use migrations::latest_version;
use std::path::Path;
use rusqlite::{Connection, OptionalExtension};
use ulid::Ulid;
use crate::clock::Clock;
use crate::error::Result;
use crate::model::{Attention, Link, LinkType, NewNode, NewTask, Node, Task, TaskState};
use crate::ranking::RankedTask;
use crate::store::Store;
/// A SQLite file (or in-memory database) opened directly as a backend.
pub struct LocalStore {
conn: Connection,
owner_id: String,
clock: Box<dyn Clock>,
}
impl LocalStore {
/// Open (creating if needed) a SQLite database at `path`.
pub fn open(path: impl AsRef<Path>, clock: Box<dyn Clock>) -> Result<Self> {
let conn = Connection::open(path)?;
Self::init(conn, clock)
}
/// Open a throwaway in-memory database — for tests.
pub fn open_in_memory(clock: Box<dyn Clock>) -> Result<Self> {
let conn = Connection::open_in_memory()?;
Self::init(conn, clock)
}
fn init(conn: Connection, clock: Box<dyn Clock>) -> Result<Self> {
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
migrations::migrate(&conn)?;
let owner_id = ensure_local_user(&conn, clock.as_ref())?;
Ok(LocalStore {
conn,
owner_id,
clock,
})
}
/// The id of the user whose data this store reads/writes.
///
/// For a local-only instance this is the single generated local user
/// (`oidc_sub = NULL`, tech-spec §13).
pub fn owner_id(&self) -> &str {
&self.owner_id
}
}
/// 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.
fn ensure_local_user(conn: &Connection, clock: &dyn Clock) -> Result<String> {
if let Some(id) = conn
.query_row(
"SELECT id FROM users ORDER BY created_at LIMIT 1",
[],
|r| r.get::<_, String>(0),
)
.optional()?
{
return Ok(id);
}
let id = new_id();
conn.execute(
"INSERT INTO users (id, oidc_sub, name, created_at) VALUES (?1, NULL, 'local', ?2)",
(&id, clock.now_ms()),
)?;
Ok(id)
}
impl Store for LocalStore {
fn create_node(&mut self, input: NewNode) -> Result<Node> {
let now = self.clock.now_ms();
nodes::create(&self.conn, &self.owner_id, now, input)
}
fn get_node(&self, id: &str) -> Result<Option<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(&mut self.conn, &self.owner_id, now, node_id, state)
}
fn skip_recurrence(&mut self, node_id: &str) -> Result<Task> {
let now = self.clock.now_ms();
tasks::skip(&self.conn, now, node_id)
}
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 next(&self, scope: Option<&str>, limit: usize) -> Result<Vec<RankedTask>> {
let now = self.clock.now_ms();
tasks::next(&self.conn, &self.owner_id, now, scope, limit)
}
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)
}
fn log_append(&mut self, task_id: &str, text: &str) -> Result<()> {
let now = self.clock.now_ms();
let tx = self.conn.transaction()?;
log::append(&tx, &self.owner_id, now, task_id, text)?;
tx.commit()?;
Ok(())
}
fn log_tail(&self, task_id: &str, n: usize) -> Result<Vec<String>> {
log::tail(&self.conn, task_id, n)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::clock::FixedClock;
fn store_at(now_ms: i64) -> LocalStore {
LocalStore::open_in_memory(Box::new(FixedClock(now_ms))).expect("open in-memory store")
}
#[test]
fn migrations_bring_schema_to_latest() {
let store = store_at(0);
let v: i64 = store
.conn
.query_row("PRAGMA user_version", [], |r| r.get(0))
.unwrap();
assert_eq!(v, latest_version());
}
#[test]
fn opening_twice_is_idempotent_for_the_local_user() {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch("PRAGMA foreign_keys = ON;").unwrap();
migrations::migrate(&conn).unwrap();
let a = ensure_local_user(&conn, &FixedClock(1)).unwrap();
let b = ensure_local_user(&conn, &FixedClock(2)).unwrap();
assert_eq!(a, b);
}
}