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}