Skip to main content

grit_core/
lib.rs

1//! # grit-core
2//!
3//! An embedded, bi-temporal property graph for agent memory: one SQLite file,
4//! in-process, deterministic. See `AGENTS.md` at the repo root for the full
5//! design contract; the short version:
6//!
7//! - All writes are [`GraphOp`]s appended to an op-log; graph tables are
8//!   derived state applied in the same transaction (sync-ready by design).
9//! - Facts are never mutated in place. Edges carry two timelines — event time
10//!   (`valid_at`/`invalid_at`) and system time (`created_at`/`expired_at`) —
11//!   so "what did I believe in March?" is a query, not archaeology.
12//! - No LLM, no network, no embedding computation. Time is injected via
13//!   [`Clock`]; embeddings are stored, never created.
14//!
15//! # Example
16//! ```
17//! use grit_core::{Budget, GraphOp, Grit, Options, Query, Traversal};
18//! # fn main() -> Result<(), grit_core::Error> {
19//! # let dir = std::env::temp_dir().join(format!("grit-doc-{}", std::process::id()));
20//! # std::fs::create_dir_all(&dir)?;
21//! # let path = dir.join("memory.db");
22//! # let _ = std::fs::remove_file(&path);
23//! let g = Grit::open(&path, Options::new("device-a"))?;
24//!
25//! let yoneda = g.new_id();
26//! let functor = g.new_id();
27//! g.apply(GraphOp::AddNode {
28//!     id: yoneda,
29//!     kind: "concept".into(),
30//!     name: "Yoneda lemma".into(),
31//!     summary: "Objects are determined by their relationships".into(),
32//!     attrs: serde_json::json!({}),
33//!     group_id: "category-theory".into(),
34//! })?;
35//! g.apply(GraphOp::AddNode {
36//!     id: functor,
37//!     kind: "concept".into(),
38//!     name: "Functor".into(),
39//!     summary: "Structure-preserving map between categories".into(),
40//!     attrs: serde_json::json!({}),
41//!     group_id: "category-theory".into(),
42//! })?;
43//! g.apply(GraphOp::AddEdge {
44//!     id: g.new_id(),
45//!     src: yoneda,
46//!     dst: functor,
47//!     rel: "STATED_IN_TERMS_OF".into(),
48//!     fact: "The Yoneda lemma is stated in terms of hom-functors".into(),
49//!     attrs: serde_json::json!({}),
50//!     group_id: "category-theory".into(),
51//!     valid_at: None,
52//!     invalid_at: None,
53//! })?;
54//!
55//! let hits = g.search(
56//!     Query::text("yoneda").group("category-theory").budget(Budget::items(10)),
57//! )?;
58//! assert!(!hits.is_empty());
59//!
60//! let ctx = g.traverse(&[yoneda], &Traversal::default())?;
61//! assert_eq!(ctx.nodes.len(), 2);
62//! # Ok(()) }
63//! ```
64#![deny(unsafe_code)]
65#![warn(missing_docs)]
66
67mod clock;
68mod error;
69mod export;
70mod hlc;
71mod migrate;
72mod model;
73mod ops;
74mod query;
75mod search;
76mod vecext;
77mod writer;
78
79use std::path::Path;
80use std::sync::mpsc::{Receiver, Sender, channel};
81use std::sync::{Arc, Mutex};
82use std::thread::JoinHandle;
83
84use rusqlite::{Connection, OpenFlags};
85use uuid::Uuid;
86
87pub use crate::clock::{Clock, ManualClock, SystemClock, TimestampMs};
88pub use crate::error::{Error, Result};
89pub use crate::export::{ImportStats, import_jsonl};
90pub use crate::hlc::{Hlc, HlcGenerator};
91pub use crate::migrate::SCHEMA_VERSION;
92pub use crate::model::{Edge, Episode, Node, Subgraph};
93pub use crate::ops::{GraphOp, OplogEntry};
94#[doc(hidden)]
95pub use crate::query::{EXPAND_SQL_AS_AT, EXPAND_SQL_CURRENT};
96pub use crate::query::{MergeCandidate, NodeHistory, Stats, Traversal};
97pub use crate::search::{Budget, Query, SearchHit, SearchKind, SearchTarget};
98
99use crate::writer::{VecTable, WriteMsg};
100
101/// Open-time configuration for [`Grit`].
102pub struct Options {
103    /// Stable identifier for this device/installation; stamped on every op
104    /// and used as the HLC tie-breaker.
105    pub device_id: String,
106    /// Time source. Defaults to [`SystemClock`]; tests inject [`ManualClock`].
107    pub clock: Arc<dyn Clock>,
108}
109
110impl Options {
111    /// Options with the given device id and the real system clock.
112    pub fn new(device_id: impl Into<String>) -> Self {
113        Self {
114            device_id: device_id.into(),
115            clock: Arc::new(SystemClock),
116        }
117    }
118
119    /// Replace the time source (Design Invariant 3: time is injected).
120    pub fn clock(mut self, clock: Arc<dyn Clock>) -> Self {
121        self.clock = clock;
122        self
123    }
124}
125
126/// Handle to one grit database: a writer actor plus a read connection.
127///
128/// All methods take `&self`; `Grit` is `Send + Sync`. Writes are serialized
129/// through the single writer thread (SQLite has one writer — embrace it);
130/// reads run on a separate WAL connection and never block behind writes.
131pub struct Grit {
132    write_tx: Sender<WriteMsg>,
133    writer: Option<JoinHandle<()>>,
134    pub(crate) read_conn: Mutex<Connection>,
135    clock: Arc<dyn Clock>,
136    hlc: HlcGenerator,
137    device_id: String,
138}
139
140impl Grit {
141    /// Open (creating or migrating as needed) the database at `path`.
142    ///
143    /// The file plus its WAL sidecars is the entire truth (Design Invariant 2).
144    /// In-memory databases are not supported: grit uses two connections
145    /// (writer + reader) that must see the same file.
146    ///
147    /// # Example
148    /// ```no_run
149    /// use grit_core::{Grit, Options};
150    /// let g = Grit::open("memory.db", Options::new("laptop"))?;
151    /// # Ok::<(), grit_core::Error>(())
152    /// ```
153    pub fn open(path: impl AsRef<Path>, opts: Options) -> Result<Self> {
154        let path = path.as_ref();
155        // Two connections must see the same on-disk file: reject every SQLite
156        // spelling of "not a real file" (bare :memory:, memory-mode URIs, and
157        // the empty path, which opens a distinct temp db per connection).
158        if let Some(s) = path.to_str()
159            && (s.is_empty()
160                || s == ":memory:"
161                || (s.starts_with("file:")
162                    && (s.contains(":memory:") || s.contains("mode=memory"))))
163        {
164            return Err(Error::InvalidOp(
165                "in-memory or empty database paths are not supported".into(),
166            ));
167        }
168        vecext::register_sqlite_vec();
169
170        let mut write_conn = Connection::open(path)?;
171        configure(&write_conn)?;
172        migrate::migrate(&mut write_conn)?;
173
174        let read_conn = Connection::open_with_flags(
175            path,
176            OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
177        )?;
178        read_conn.busy_timeout(std::time::Duration::from_secs(5))?;
179        configure_cache(&read_conn)?;
180
181        let (write_tx, write_rx): (Sender<WriteMsg>, Receiver<WriteMsg>) = channel();
182        let writer = writer::spawn(write_conn, Arc::clone(&opts.clock), write_rx);
183
184        Ok(Self {
185            write_tx,
186            writer: Some(writer),
187            read_conn: Mutex::new(read_conn),
188            hlc: HlcGenerator::new(opts.device_id.clone()),
189            clock: opts.clock,
190            device_id: opts.device_id,
191        })
192    }
193
194    /// Mint a UUIDv7 from the injected clock. Use for the `id` fields of
195    /// [`GraphOp`]s.
196    pub fn new_id(&self) -> Uuid {
197        let ms = self.clock.now_ms().max(0) as u64;
198        let ts = uuid::Timestamp::from_unix(
199            uuid::NoContext,
200            ms / 1000,
201            ((ms % 1000) * 1_000_000) as u32,
202        );
203        Uuid::new_v7(ts)
204    }
205
206    /// Apply a local write: mint an [`OplogEntry`] (op id + HLC), append it to
207    /// the oplog and update the graph tables in one transaction. Returns the
208    /// entry — the unit a sync mechanism would ship to other devices.
209    ///
210    /// # Example
211    /// ```no_run
212    /// # use grit_core::{GraphOp, Grit, Options};
213    /// # let g = Grit::open("memory.db", Options::new("laptop"))?;
214    /// let entry = g.apply(GraphOp::AddNode {
215    ///     id: g.new_id(),
216    ///     kind: "person".into(),
217    ///     name: "Ada Lovelace".into(),
218    ///     summary: String::new(),
219    ///     attrs: serde_json::json!({}),
220    ///     group_id: String::new(),
221    /// })?;
222    /// assert_eq!(entry.device_id, "laptop");
223    /// # Ok::<(), grit_core::Error>(())
224    /// ```
225    pub fn apply(&self, op: GraphOp) -> Result<OplogEntry> {
226        validate(&op)?;
227        let entry = OplogEntry {
228            id: self.new_id(),
229            hlc: self.hlc.next(self.clock.as_ref()),
230            device_id: self.device_id.clone(),
231            op,
232        };
233        writer::call(&self.write_tx, |reply| WriteMsg::Apply {
234            entry: entry.clone(),
235            reply,
236        })?;
237        Ok(entry)
238    }
239
240    /// Apply an op that originated on another device (future sync ingest, and
241    /// the test seam for the oplog merge laws). Idempotent: returns `false`
242    /// if this op id was already applied. Folds the remote HLC into the local
243    /// generator so subsequent local ops sort after everything observed.
244    ///
245    /// Unlike [`Grit::apply`], structurally invalid ops (e.g. a self-merge)
246    /// are recorded and applied as no-ops rather than erroring — one
247    /// malformed entry from a buggy device must not wedge a sync loop. The
248    /// one exception is a negative HLC wall time, which is rejected: it
249    /// cannot be ordered, so nothing downstream of it can converge.
250    pub fn apply_remote(&self, entry: OplogEntry) -> Result<bool> {
251        if entry.hlc.wall_ms < 0 {
252            return Err(Error::InvalidHlc(entry.hlc.encode()));
253        }
254        self.hlc.observe(&entry.hlc);
255        writer::call(&self.write_tx, |reply| WriteMsg::Apply { entry, reply })
256    }
257
258    /// Read the oplog from local sequence `after_seq` (exclusive; 0 = start).
259    /// Returns `(seq, entry)` pairs in local apply order.
260    pub fn oplog_since(&self, after_seq: i64) -> Result<Vec<(i64, OplogEntry)>> {
261        let conn = self.read();
262        let mut stmt = conn.prepare_cached(
263            "SELECT seq, id, hlc, device_id, op FROM oplog WHERE seq > ?1 ORDER BY seq",
264        )?;
265        let rows = stmt.query_map([after_seq], |row| {
266            Ok((
267                row.get::<_, i64>(0)?,
268                row.get::<_, String>(1)?,
269                row.get::<_, String>(2)?,
270                row.get::<_, String>(3)?,
271                row.get::<_, String>(4)?,
272            ))
273        })?;
274        let mut out = Vec::new();
275        for row in rows {
276            let (seq, id, hlc, device_id, op) = row?;
277            out.push((
278                seq,
279                OplogEntry {
280                    id: Uuid::parse_str(&id)
281                        .map_err(|_| Error::Corrupt(format!("bad uuid in oplog: {id}")))?,
282                    hlc: hlc.parse()?,
283                    device_id,
284                    op: serde_json::from_str(&op)?,
285                },
286            ));
287        }
288        Ok(out)
289    }
290
291    /// Register the embedding model whose vectors this file stores. Creates
292    /// the `vec_nodes`/`vec_edges` tables sized to `dim` if they are missing
293    /// (also after an import — vectors are never exported, the tables are
294    /// rebuilt empty and rows re-embed). Vectors are partitioned by their
295    /// row's `group_id`, so vector search filters by group inside the KNN
296    /// scan. Embeddings are recomputable local state — not part of the oplog
297    /// (Design Invariant 5).
298    pub fn register_embedding_model(
299        &self,
300        model_id: impl Into<String>,
301        dim: usize,
302        model_version: impl Into<String>,
303    ) -> Result<()> {
304        writer::call(&self.write_tx, |reply| WriteMsg::RegisterModel {
305            model_id: model_id.into(),
306            dim,
307            version: model_version.into(),
308            reply,
309        })
310    }
311
312    /// Store (or replace) the embedding for a node. The caller computed it;
313    /// grit only stores it, in the node's group partition. The node row must
314    /// already exist ([`Error::NotFound`] otherwise) — apply the
315    /// [`GraphOp::AddNode`] first, then embed. Embedding a purged id is a
316    /// silent no-op (right to forget).
317    pub fn set_node_embedding(&self, node_id: Uuid, vector: Vec<f32>) -> Result<()> {
318        writer::call(&self.write_tx, |reply| WriteMsg::SetEmbedding {
319            table: VecTable::Nodes,
320            id: node_id,
321            vector,
322            reply,
323        })
324    }
325
326    /// Store (or replace) the embedding for an edge (its fact sentence), in
327    /// the edge's group partition. The edge row must already exist
328    /// ([`Error::NotFound`] otherwise); embedding a purged id is a no-op.
329    pub fn set_edge_embedding(&self, edge_id: Uuid, vector: Vec<f32>) -> Result<()> {
330        writer::call(&self.write_tx, |reply| WriteMsg::SetEmbedding {
331            table: VecTable::Edges,
332            id: edge_id,
333            vector,
334            reply,
335        })
336    }
337
338    pub(crate) fn now_ms(&self) -> TimestampMs {
339        self.clock.now_ms()
340    }
341
342    /// Lock the read connection, recovering from poisoning: a panic on
343    /// another thread mid-read leaves the connection itself sound (rusqlite
344    /// resets statements), and a storage library must not convert one panic
345    /// into a permanent panic on every subsequent call.
346    pub(crate) fn read(&self) -> std::sync::MutexGuard<'_, Connection> {
347        self.read_conn
348            .lock()
349            .unwrap_or_else(std::sync::PoisonError::into_inner)
350    }
351}
352
353// Compile-time proof of the documented `Send + Sync` claim on `Grit`.
354const _: () = {
355    const fn assert_send_sync<T: Send + Sync>() {}
356    assert_send_sync::<Grit>();
357};
358
359impl Drop for Grit {
360    fn drop(&mut self) {
361        let _ = self.write_tx.send(WriteMsg::Shutdown);
362        if let Some(handle) = self.writer.take() {
363            let _ = handle.join();
364        }
365    }
366}
367
368fn validate(op: &GraphOp) -> Result<()> {
369    match op {
370        GraphOp::MergeNodes { from, into } if from == into => {
371            Err(Error::InvalidOp("cannot merge a node into itself".into()))
372        }
373        GraphOp::Purge { ids } if ids.is_empty() => {
374            Err(Error::InvalidOp("purge with no ids".into()))
375        }
376        GraphOp::UpdateNode {
377            name: None,
378            summary: None,
379            kind: None,
380            attrs: None,
381            ..
382        } => Err(Error::InvalidOp("update with no fields".into())),
383        _ => Ok(()),
384    }
385}
386
387fn configure(conn: &Connection) -> Result<()> {
388    conn.busy_timeout(std::time::Duration::from_secs(5))?;
389    conn.pragma_update(None, "journal_mode", "WAL")?;
390    conn.pragma_update(None, "synchronous", "NORMAL")?;
391    configure_cache(conn)?;
392    Ok(())
393}
394
395/// Read-performance pragmas, applied to writer and reader connections alike.
396/// SQLite's defaults (2 MB page cache, no mmap) assume a shared server box;
397/// grit is a personal memory store where the traversal latency budget
398/// (AGENTS.md invariant 6) is worth 32 MB of cache and a read-only mmap.
399fn configure_cache(conn: &Connection) -> Result<()> {
400    conn.pragma_update(None, "cache_size", -32_768)?; // KiB units when negative: 32 MB
401    conn.pragma_update(None, "mmap_size", 268_435_456)?; // 256 MB
402    Ok(())
403}