//! 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, } impl LocalStore { /// Open (creating if needed) a SQLite database at `path`. pub fn open(path: impl AsRef, clock: Box) -> Result { let conn = Connection::open(path)?; Self::init(conn, clock) } /// Open a throwaway in-memory database — for tests. pub fn open_in_memory(clock: Box) -> Result { let conn = Connection::open_in_memory()?; Self::init(conn, clock) } fn init(conn: Connection, clock: Box) -> Result { 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 { 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 { let now = self.clock.now_ms(); nodes::create(&self.conn, &self.owner_id, now, input) } fn get_node(&self, id: &str) -> Result> { nodes::get(&self.conn, id) } fn update_node( &mut self, id: &str, title: Option, body: Option, ) -> Result { 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 { let now = self.clock.now_ms(); tasks::create(&mut self.conn, &self.owner_id, now, input) } fn get_task(&self, node_id: &str) -> Result> { tasks::get(&self.conn, node_id) } fn set_task_state(&mut self, node_id: &str, state: TaskState) -> Result { 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 { 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 { let now = self.clock.now_ms(); tasks::set_attention(&self.conn, now, node_id, attention) } fn next(&self, scope: Option<&str>, limit: usize) -> Result> { 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 { let now = self.clock.now_ms(); links::add(&self.conn, now, src_id, dst_id, link_type) } fn outgoing_links(&self, id: &str) -> Result> { links::outgoing(&self.conn, id) } fn backlinks(&self, id: &str) -> Result> { 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> { 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); } }