Skip to main content

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}