mempill_sqlite/lib.rs
1//! `mempill-sqlite` — SQLite persistence adapter for mempill.
2//!
3//! This crate provides the SQLite-backed implementation of the `PersistencePort` trait
4//! defined in `mempill-core`. It owns the database schema (DDL + indexes), the
5//! idempotent schema migration runner, and the full read + write path.
6//!
7//! # Crate organisation
8//!
9//! - [`connection`] — connection lifecycle: open file or in-memory, apply mandatory
10//! PRAGMAs (`journal_mode=WAL`, `synchronous=FULL`, `foreign_keys=ON`), run migrations.
11//! - [`migrations`] — deterministic, idempotent schema migration runner; embeds DDL via
12//! `include_str!`.
13//! - [`txn`] — `SqliteTxn`: the concrete `Txn` handle scoped to one `agent_id`.
14//! - [`store`] — [`SqlitePersistenceStore`]: `impl PersistencePort` — full read + write path.
15//! - [`DefaultEngine`] — convenience type alias + constructors for the most common setup.
16//!
17//! # PRAGMA contract (applied at connection open — before migrations or any DML)
18//!
19//! ```sql
20//! PRAGMA journal_mode = WAL; -- concurrent reads during writes
21//! PRAGMA synchronous = FULL; -- full-durability writes (mandatory; WAL+NORMAL can lose writes on power loss)
22//! PRAGMA foreign_keys = ON; -- enforce FK constraints defined in DDL
23//! ```
24//!
25//! # DefaultEngine
26//!
27//! For the common case (SQLite store, no oracle, no vector), use:
28//! ```rust,ignore
29//! let engine = mempill_sqlite::open_default_in_memory();
30//! ```
31
32pub mod connection;
33pub mod migrations;
34pub mod store;
35pub mod txn;
36
37pub use store::{SqlitePendingStore, SqlitePersistenceStore};
38
39// Re-export OraclePort bound so callers can write the `open_with_oracle` constraint
40// without adding a direct dependency on mempill-core in their Cargo.toml.
41pub use mempill_core::ports::OraclePort;
42
43// ── Crate-level error type ────────────────────────────────────────────────────
44
45/// Error type for all `mempill-sqlite` operations.
46#[derive(Debug, thiserror::Error)]
47pub enum SqliteStoreError {
48 /// A rusqlite-level database error.
49 #[error("SQLite error: {0}")]
50 Sqlite(#[from] rusqlite::Error),
51
52 /// A schema migration error.
53 #[error("Migration error: {0}")]
54 Migration(#[from] migrations::MigrationError),
55
56 /// A domain-type ↔ column mapping error (serialization / unknown enum value).
57 #[error("Mapping error: {0}")]
58 Mapping(String),
59
60 /// `begin_atomic` called while a transaction is already active on this store instance.
61 #[error("a transaction is already open on this store; commit or rollback before beginning a new one")]
62 TxnAlreadyOpen,
63}
64
65// Compile-time assertion: SqliteStoreError must be Send + Sync to satisfy
66// the `PersistencePort::Error: Send + Sync + 'static` bound.
67const _: () = {
68 fn assert_send_sync<T: Send + Sync + 'static>() {}
69 fn check() { assert_send_sync::<SqliteStoreError>(); }
70};
71
72// ── DefaultEngine — convenience type alias (E3/E4, A27) ──────────────────────
73//
74// Lives here in mempill-sqlite to preserve the dependency direction:
75// mempill-sqlite → mempill-core (allowed)
76// mempill-core → mempill-sqlite (FORBIDDEN)
77
78/// The default concrete engine type: SQLite persistence, no oracle, no vector.
79///
80/// Suitable for single-process embedded use without oracle or vector search.
81/// For production with an oracle, construct `EngineHandle` directly with your port impls.
82pub type DefaultEngine = mempill_core::EngineHandle<
83 SqlitePersistenceStore,
84 mempill_core::NoOpOracle,
85 mempill_core::NoOpVector,
86>;
87
88// ── OracleEngine — type alias for a SQLite engine with a real oracle ──────────
89
90/// An `EngineHandle` backed by SQLite persistence, a caller-supplied oracle, and no vector.
91///
92/// Use `open_with_oracle` or `open_with_oracle_in_memory` to obtain one.
93pub type OracleEngine<O> = mempill_core::EngineHandle<
94 SqlitePersistenceStore,
95 O,
96 mempill_core::NoOpVector,
97>;
98
99// ── open_with_oracle constructors ─────────────────────────────────────────────
100
101/// Open a **file-backed** SQLite engine wired with a real oracle.
102///
103/// The pending-adjudication store is constructed from the same SQLite connection,
104/// enabling full oracle resolution. `open_default` / `DefaultEngine` remain unchanged.
105///
106/// # Errors
107/// Returns `SqliteStoreError` if the connection cannot be opened or migrations fail.
108pub fn open_with_oracle<O>(
109 path: &str,
110 oracle: std::sync::Arc<O>,
111) -> Result<OracleEngine<O>, SqliteStoreError>
112where
113 O: OraclePort + Send + Sync + 'static,
114{
115 let conn = connection::open(path)?;
116 let store = std::sync::Arc::new(SqlitePersistenceStore::new(conn));
117 let pending_store: std::sync::Arc<dyn mempill_core::ErasedPendingStore> =
118 std::sync::Arc::new(mempill_core::ErasedPendingStoreAdapter::new(store.pending_store()));
119 Ok(mempill_core::EngineHandle::new_with_pending_store::<()>(
120 store,
121 Some(oracle),
122 None::<std::sync::Arc<mempill_core::NoOpVector>>,
123 pending_store,
124 mempill_core::EngineConfig::default(),
125 ))
126}
127
128/// Open an **in-memory** SQLite engine wired with a real oracle.
129///
130/// Useful for integration tests and ephemeral oracle-enabled contexts.
131///
132/// # Errors
133/// Returns `SqliteStoreError` if the connection cannot be opened or migrations fail.
134pub fn open_with_oracle_in_memory<O>(
135 oracle: std::sync::Arc<O>,
136) -> Result<OracleEngine<O>, SqliteStoreError>
137where
138 O: OraclePort + Send + Sync + 'static,
139{
140 let conn = connection::open_in_memory()?;
141 let store = std::sync::Arc::new(SqlitePersistenceStore::new(conn));
142 let pending_store: std::sync::Arc<dyn mempill_core::ErasedPendingStore> =
143 std::sync::Arc::new(mempill_core::ErasedPendingStoreAdapter::new(store.pending_store()));
144 Ok(mempill_core::EngineHandle::new_with_pending_store::<()>(
145 store,
146 Some(oracle),
147 None::<std::sync::Arc<mempill_core::NoOpVector>>,
148 pending_store,
149 mempill_core::EngineConfig::default(),
150 ))
151}
152
153// ── DefaultEngine constructors ────────────────────────────────────────────────
154
155/// Open a file-backed `DefaultEngine` at the given path.
156///
157/// The connection is fully initialised (PRAGMAs + migrations) before the handle is returned.
158///
159/// # Errors
160/// Returns `SqliteStoreError` if the connection cannot be opened or migrations fail.
161pub fn open_default(path: &str) -> Result<DefaultEngine, SqliteStoreError> {
162 let conn = connection::open(path)?;
163 let store = std::sync::Arc::new(SqlitePersistenceStore::new(conn));
164 Ok(mempill_core::EngineHandle::new(
165 store,
166 None,
167 None,
168 mempill_core::EngineConfig::default(),
169 ))
170}
171
172/// Open an **in-memory** `DefaultEngine`.
173///
174/// Useful for tests and ephemeral engine contexts.
175///
176/// # Errors
177/// Returns `SqliteStoreError` if the connection cannot be opened or migrations fail.
178pub fn open_default_in_memory() -> Result<DefaultEngine, SqliteStoreError> {
179 let conn = connection::open_in_memory()?;
180 let store = std::sync::Arc::new(SqlitePersistenceStore::new(conn));
181 Ok(mempill_core::EngineHandle::new(
182 store,
183 None,
184 None,
185 mempill_core::EngineConfig::default(),
186 ))
187}
188
189// ── End-to-end smoke tests ────────────────────────────────────────────────────
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194 use mempill_core::application::{IngestClaimRequest, QueryMemoryRequest};
195 use mempill_types::{
196 AgentId, BeliefStatus, Cardinality, Confidence, Criticality, ExternalKind, ProvenanceLabel,
197 };
198
199 /// E2E smoke test: ingest a claim then query it back.
200 ///
201 /// End-to-end: full write path (Gateway → AmplificationGuard → Reconciler → AdjudicationGate,
202 /// atomic transaction) followed by the full read path (TruthEngine fold → Projection). No mocks.
203 #[tokio::test]
204 async fn e2e_ingest_then_query_returns_belief() {
205 let engine = open_default_in_memory().expect("in-memory engine must open");
206 let agent = AgentId("e2e-agent".into());
207
208 // Ingest a claim.
209 let ingest_req = IngestClaimRequest {
210 agent_id: agent.clone(),
211 subject: "user".into(),
212 predicate: "city".into(),
213 value: serde_json::json!("Berlin"),
214 provenance: ProvenanceLabel::External(ExternalKind::UserAsserted),
215 cardinality: Cardinality::Functional,
216 valid_time: None,
217 confidence: Confidence { value_confidence: 0.95, valid_time_confidence: 0.0 },
218 criticality: Criticality::Medium,
219 derived_from: vec![],
220 };
221
222 let ingest_resp = engine.ingest_claim(ingest_req).await
223 .expect("ingest must succeed");
224 assert!(!ingest_resp.claim_ref.0.is_nil(), "claim_ref must be non-nil");
225 assert_eq!(ingest_resp.disposition, mempill_types::Disposition::CommittedCheap,
226 "first External claim must be CommittedCheap");
227
228 // Query the belief back.
229 let query_req = QueryMemoryRequest {
230 agent_id: agent.clone(),
231 subject: "user".into(),
232 predicate: "city".into(),
233 as_of_tx_time: None,
234 };
235 let query_resp = engine.query_memory(query_req).await
236 .expect("query must succeed");
237
238 // The belief must reflect the ingested claim.
239 assert!(
240 matches!(
241 query_resp.belief.status,
242 BeliefStatus::Resolved | BeliefStatus::TimingUncertain
243 ),
244 "belief status must be Resolved or TimingUncertain after ingest, got {:?}",
245 query_resp.belief.status
246 );
247 assert!(query_resp.belief.primary.is_some(), "primary belief must be present");
248 let primary = query_resp.belief.primary.unwrap();
249 assert_eq!(primary.fact.value, serde_json::json!("Berlin"),
250 "fact value must match the ingested value");
251 assert_eq!(primary.claim_ref, ingest_resp.claim_ref,
252 "queried claim_ref must match the ingested claim_ref");
253 }
254
255 /// Confirm DefaultEngine type alias is in mempill-sqlite (not mempill-core).
256 /// This verifies no mempill-core → mempill-sqlite dependency was introduced.
257 #[test]
258 fn default_engine_type_alias_exists_in_mempill_sqlite() {
259 // This test compiles only if DefaultEngine is defined in this crate.
260 fn assert_is_default_engine(_: &DefaultEngine) {}
261 let engine = open_default_in_memory().unwrap();
262 assert_is_default_engine(&engine);
263 }
264}