Skip to main content

runifold_store_sqlite/
store.rs

1use std::{
2    path::Path,
3    sync::{Arc, Mutex, MutexGuard},
4    time::Duration,
5};
6
7use runifold_core::{
8    CapabilityId, Checkpoint, CheckpointError, CheckpointErrorKind, CheckpointId, CheckpointStore,
9    EffectId, Journal, JournalError, RunEvent, RunId,
10};
11use runifold_effect::{EffectExecutorError, EffectExecutorErrorKind, EffectRecord, EffectStore};
12use rusqlite::{Connection, OptionalExtension, Transaction, TransactionBehavior, params};
13use thiserror::Error;
14
15mod conversation;
16
17const SCHEMA: &str = "
18CREATE TABLE IF NOT EXISTS runifold_checkpoints (
19    checkpoint_id TEXT PRIMARY KEY NOT NULL,
20    revision      INTEGER NOT NULL CHECK (revision >= 0),
21    record_json   TEXT NOT NULL
22);
23
24CREATE TABLE IF NOT EXISTS runifold_effects (
25    effect_id       TEXT PRIMARY KEY NOT NULL,
26    capability_id   TEXT NOT NULL,
27    idempotency_key TEXT,
28    revision        INTEGER NOT NULL CHECK (revision >= 0),
29    record_json     TEXT NOT NULL,
30    UNIQUE (capability_id, idempotency_key)
31);
32
33CREATE INDEX IF NOT EXISTS runifold_effects_capability_key
34    ON runifold_effects (capability_id, idempotency_key);
35
36CREATE TABLE IF NOT EXISTS runifold_events (
37    event_id   TEXT PRIMARY KEY NOT NULL,
38    run_id     TEXT NOT NULL,
39    sequence   INTEGER NOT NULL CHECK (sequence >= 0),
40    event_json TEXT NOT NULL,
41    UNIQUE (run_id, sequence)
42);
43
44CREATE INDEX IF NOT EXISTS runifold_events_run_sequence
45    ON runifold_events (run_id, sequence);
46
47CREATE TABLE IF NOT EXISTS runifold_conversation_state (
48    singleton_id   INTEGER PRIMARY KEY NOT NULL CHECK (singleton_id = 1),
49    format_version INTEGER NOT NULL,
50    state_blob     BLOB NOT NULL,
51    updated_at_ms  INTEGER NOT NULL
52);
53
54PRAGMA user_version = 1;
55";
56
57/// Failure while opening, initializing, or directly querying a `SQLite` store.
58#[derive(Debug, Error)]
59#[non_exhaustive]
60pub enum SqliteStoreError {
61    /// `SQLite` rejected an operation.
62    #[error("sqlite operation failed: {0}")]
63    Database(#[from] rusqlite::Error),
64    /// Persisted JSON did not satisfy its canonical Rust representation.
65    #[error("sqlite JSON decoding failed: {0}")]
66    Json(#[from] serde_json::Error),
67}
68
69/// Cloneable SQLite-backed effect, checkpoint, and journal store.
70///
71/// Clones share one connection and serialize operations at the connection
72/// boundary. `SQLite` transactions still provide persistence and CAS guarantees
73/// across separately opened store instances and processes.
74#[derive(Clone)]
75pub struct SqliteStore {
76    connection: Arc<Mutex<Connection>>,
77}
78
79impl SqliteStore {
80    /// Opens or creates a file-backed store and initializes its schema.
81    ///
82    /// # Errors
83    ///
84    /// Returns [`SqliteStoreError`] when the database cannot be opened or
85    /// initialized.
86    pub fn open(path: impl AsRef<Path>) -> Result<Self, SqliteStoreError> {
87        Self::from_connection(Connection::open(path)?)
88    }
89
90    /// Creates a process-local `SQLite` database, primarily for tests.
91    ///
92    /// # Errors
93    ///
94    /// Returns [`SqliteStoreError`] when `SQLite` initialization fails.
95    pub fn open_in_memory() -> Result<Self, SqliteStoreError> {
96        Self::from_connection(Connection::open_in_memory()?)
97    }
98
99    fn from_connection(connection: Connection) -> Result<Self, SqliteStoreError> {
100        connection.busy_timeout(Duration::from_secs(5))?;
101        connection.execute_batch(SCHEMA)?;
102        Ok(Self {
103            connection: Arc::new(Mutex::new(connection)),
104        })
105    }
106
107    /// Loads all journal events for one Run in sequence order.
108    ///
109    /// # Errors
110    ///
111    /// Returns [`SqliteStoreError`] when querying or decoding fails.
112    pub fn events(&self, run_id: RunId) -> Result<Vec<RunEvent>, SqliteStoreError> {
113        let connection = self.lock();
114        let mut statement = connection.prepare(
115            "SELECT event_json
116             FROM runifold_events
117             WHERE run_id = ?1
118             ORDER BY sequence ASC",
119        )?;
120        let rows = statement.query_map([run_id.to_string()], |row| row.get::<_, String>(0))?;
121        decode_rows(rows)
122    }
123
124    fn lock(&self) -> MutexGuard<'_, Connection> {
125        self.connection
126            .lock()
127            .unwrap_or_else(std::sync::PoisonError::into_inner)
128    }
129}
130
131impl std::fmt::Debug for SqliteStore {
132    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133        formatter
134            .debug_struct("SqliteStore")
135            .finish_non_exhaustive()
136    }
137}
138
139impl CheckpointStore for SqliteStore {
140    fn load(&self, id: CheckpointId) -> Result<Checkpoint, CheckpointError> {
141        let connection = self.lock();
142        let record = connection
143            .query_row(
144                "SELECT record_json
145                 FROM runifold_checkpoints
146                 WHERE checkpoint_id = ?1",
147                [id.to_string()],
148                |row| row.get::<_, String>(0),
149            )
150            .optional()
151            .map_err(|error| checkpoint_storage(&error))?;
152        let record = record.ok_or_else(|| {
153            CheckpointError::new(
154                CheckpointErrorKind::NotFound,
155                format!("checkpoint `{id}` does not exist"),
156            )
157        })?;
158        serde_json::from_str(&record).map_err(|error| {
159            CheckpointError::new(CheckpointErrorKind::InvalidPayload, error.to_string())
160        })
161    }
162
163    fn compare_and_swap(
164        &self,
165        checkpoint: &Checkpoint,
166        expected_revision: Option<u64>,
167    ) -> Result<(), CheckpointError> {
168        let revision = sqlite_revision(checkpoint.revision).map_err(checkpoint_invalid)?;
169        let expected = expected_revision
170            .map(sqlite_revision)
171            .transpose()
172            .map_err(checkpoint_invalid)?;
173        let record = serde_json::to_string(checkpoint).map_err(|error| {
174            CheckpointError::new(CheckpointErrorKind::InvalidPayload, error.to_string())
175        })?;
176        let mut connection = self.lock();
177        let transaction = connection
178            .transaction_with_behavior(TransactionBehavior::Immediate)
179            .map_err(|error| checkpoint_storage(&error))?;
180        let current = current_revision(
181            &transaction,
182            "runifold_checkpoints",
183            "checkpoint_id",
184            &checkpoint.id.to_string(),
185        )
186        .map_err(|error| checkpoint_storage(&error))?;
187
188        match (current, expected) {
189            (None, None) if revision == 0 => {
190                transaction
191                    .execute(
192                        "INSERT INTO runifold_checkpoints
193                         (checkpoint_id, revision, record_json)
194                         VALUES (?1, ?2, ?3)",
195                        params![checkpoint.id.to_string(), revision, record],
196                    )
197                    .map_err(|error| checkpoint_storage(&error))?;
198            }
199            (Some(current), Some(expected))
200                if current == expected
201                    && expected.checked_add(1).is_some_and(|next| revision == next) =>
202            {
203                let changed = transaction
204                    .execute(
205                        "UPDATE runifold_checkpoints
206                         SET revision = ?1, record_json = ?2
207                         WHERE checkpoint_id = ?3 AND revision = ?4",
208                        params![revision, record, checkpoint.id.to_string(), expected],
209                    )
210                    .map_err(|error| checkpoint_storage(&error))?;
211                if changed != 1 {
212                    return Err(checkpoint_conflict(checkpoint.id));
213                }
214            }
215            (None, Some(_)) => {
216                return Err(CheckpointError::new(
217                    CheckpointErrorKind::NotFound,
218                    format!("checkpoint `{}` does not exist", checkpoint.id),
219                ));
220            }
221            _ => return Err(checkpoint_conflict(checkpoint.id)),
222        }
223        transaction
224            .commit()
225            .map_err(|error| checkpoint_storage(&error))
226    }
227}
228
229impl EffectStore for SqliteStore {
230    fn load(&self, id: EffectId) -> Result<Option<EffectRecord>, EffectExecutorError> {
231        let connection = self.lock();
232        let record = connection
233            .query_row(
234                "SELECT record_json FROM runifold_effects WHERE effect_id = ?1",
235                [id.to_string()],
236                |row| row.get::<_, String>(0),
237            )
238            .optional()
239            .map_err(|error| effect_storage(&error))?;
240        record
241            .map(|record| serde_json::from_str(&record).map_err(|error| effect_protocol(&error)))
242            .transpose()
243    }
244
245    fn find_by_idempotency(
246        &self,
247        capability_id: CapabilityId,
248        key: &str,
249    ) -> Result<Option<EffectRecord>, EffectExecutorError> {
250        let connection = self.lock();
251        let record = connection
252            .query_row(
253                "SELECT record_json
254                 FROM runifold_effects
255                 WHERE capability_id = ?1 AND idempotency_key = ?2",
256                params![capability_id.to_string(), key],
257                |row| row.get::<_, String>(0),
258            )
259            .optional()
260            .map_err(|error| effect_storage(&error))?;
261        record
262            .map(|record| serde_json::from_str(&record).map_err(|error| effect_protocol(&error)))
263            .transpose()
264    }
265
266    fn compare_and_swap(
267        &self,
268        record: &EffectRecord,
269        expected_revision: Option<u64>,
270    ) -> Result<(), EffectExecutorError> {
271        let revision = sqlite_revision(record.revision).map_err(effect_store_message)?;
272        let expected = expected_revision
273            .map(sqlite_revision)
274            .transpose()
275            .map_err(effect_store_message)?;
276        let json = serde_json::to_string(record).map_err(|error| effect_protocol(&error))?;
277        let effect_id = record.request.effect_id.to_string();
278        let capability_id = record.request.capability_id.to_string();
279        let idempotency_key = record.request.idempotency_key.as_deref();
280        let mut connection = self.lock();
281        let transaction = connection
282            .transaction_with_behavior(TransactionBehavior::Immediate)
283            .map_err(|error| effect_storage(&error))?;
284        let current = current_revision(&transaction, "runifold_effects", "effect_id", &effect_id)
285            .map_err(|error| effect_storage(&error))?;
286
287        if let Some(key) = idempotency_key {
288            let owner = transaction
289                .query_row(
290                    "SELECT effect_id
291                     FROM runifold_effects
292                     WHERE capability_id = ?1 AND idempotency_key = ?2",
293                    params![capability_id, key],
294                    |row| row.get::<_, String>(0),
295                )
296                .optional()
297                .map_err(|error| effect_storage(&error))?;
298            if owner.is_some_and(|owner| owner != effect_id) {
299                return Err(EffectExecutorError::new(
300                    EffectExecutorErrorKind::IdempotencyConflict,
301                    "idempotency key already belongs to another effect",
302                ));
303            }
304        }
305
306        match (current, expected) {
307            (None, None) if revision == 0 => {
308                transaction
309                    .execute(
310                        "INSERT INTO runifold_effects
311                         (effect_id, capability_id, idempotency_key, revision, record_json)
312                         VALUES (?1, ?2, ?3, ?4, ?5)",
313                        params![effect_id, capability_id, idempotency_key, revision, json],
314                    )
315                    .map_err(|error| effect_storage(&error))?;
316            }
317            (Some(current), Some(expected))
318                if current == expected
319                    && expected.checked_add(1).is_some_and(|next| revision == next) =>
320            {
321                let changed = transaction
322                    .execute(
323                        "UPDATE runifold_effects
324                         SET capability_id = ?1, idempotency_key = ?2,
325                             revision = ?3, record_json = ?4
326                         WHERE effect_id = ?5 AND revision = ?6",
327                        params![
328                            capability_id,
329                            idempotency_key,
330                            revision,
331                            json,
332                            effect_id,
333                            expected
334                        ],
335                    )
336                    .map_err(|error| effect_storage(&error))?;
337                if changed != 1 {
338                    return Err(effect_conflict());
339                }
340            }
341            _ => return Err(effect_conflict()),
342        }
343        transaction.commit().map_err(|error| effect_storage(&error))
344    }
345}
346
347impl Journal for SqliteStore {
348    fn record(&self, event: &RunEvent) -> Result<(), JournalError> {
349        let sequence =
350            sqlite_revision(event.meta.sequence).map_err(|error| journal_message(&error))?;
351        let json = serde_json::to_string(event).map_err(|error| journal_message(&error))?;
352        self.lock()
353            .execute(
354                "INSERT INTO runifold_events
355                 (event_id, run_id, sequence, event_json)
356                 VALUES (?1, ?2, ?3, ?4)",
357                params![
358                    event.meta.event_id.to_string(),
359                    event.meta.run_id.to_string(),
360                    sequence,
361                    json
362                ],
363            )
364            .map_err(|error| journal_message(&error))?;
365        Ok(())
366    }
367}
368
369fn current_revision(
370    transaction: &Transaction<'_>,
371    table: &str,
372    id_column: &str,
373    id: &str,
374) -> rusqlite::Result<Option<i64>> {
375    let sql = format!("SELECT revision FROM {table} WHERE {id_column} = ?1");
376    transaction
377        .query_row(&sql, [id], |row| row.get(0))
378        .optional()
379}
380
381fn sqlite_revision(value: u64) -> Result<i64, String> {
382    i64::try_from(value).map_err(|_| "revision exceeds SQLite integer range".into())
383}
384
385fn checkpoint_invalid(message: String) -> CheckpointError {
386    CheckpointError::new(CheckpointErrorKind::InvalidPayload, message)
387}
388
389fn checkpoint_storage(error: &rusqlite::Error) -> CheckpointError {
390    CheckpointError::new(CheckpointErrorKind::Storage, error.to_string())
391}
392
393fn checkpoint_conflict(id: CheckpointId) -> CheckpointError {
394    CheckpointError::new(
395        CheckpointErrorKind::Conflict,
396        format!("checkpoint `{id}` revision precondition failed"),
397    )
398}
399
400fn effect_storage(error: &rusqlite::Error) -> EffectExecutorError {
401    effect_store_message(error.to_string())
402}
403
404fn effect_store_message(message: String) -> EffectExecutorError {
405    EffectExecutorError::new(EffectExecutorErrorKind::Store, message)
406}
407
408fn effect_protocol(error: &serde_json::Error) -> EffectExecutorError {
409    EffectExecutorError::new(EffectExecutorErrorKind::Protocol, error.to_string())
410}
411
412fn effect_conflict() -> EffectExecutorError {
413    EffectExecutorError::new(
414        EffectExecutorErrorKind::Store,
415        "effect record revision precondition failed",
416    )
417}
418
419fn journal_message(error: &impl ToString) -> JournalError {
420    JournalError {
421        message: error.to_string(),
422    }
423}
424
425fn decode_rows(
426    rows: impl Iterator<Item = rusqlite::Result<String>>,
427) -> Result<Vec<RunEvent>, SqliteStoreError> {
428    rows.map(|row| {
429        let json = row?;
430        Ok(serde_json::from_str(&json)?)
431    })
432    .collect()
433}
434
435#[cfg(test)]
436mod tests {
437    use std::fs;
438
439    use runifold_core::{
440        CapabilityId, Checkpoint, CheckpointErrorKind, CheckpointId, CheckpointStore, EffectClass,
441        EffectId, EffectKind, EffectRequest, EventFactory, InvocationId, Journal, LifecycleEvent,
442        RunEvent, RunEventKind, RunId,
443    };
444    use runifold_effect::{EffectExecutorErrorKind, EffectRecord, EffectStatus, EffectStore};
445    use serde_json::json;
446    use uuid::Uuid;
447
448    use super::{SqliteStore, SqliteStoreError};
449
450    #[test]
451    fn checkpoint_survives_reopen_and_rejects_stale_revision() {
452        let path = temporary_database_path();
453        let checkpoint = Checkpoint::initial(
454            CheckpointId::new(),
455            RunId::new(),
456            "test",
457            1,
458            json!({"step": 1}),
459        );
460        {
461            let store = SqliteStore::open(&path).unwrap();
462            CheckpointStore::compare_and_swap(&store, &checkpoint, None).unwrap();
463        }
464        let store = SqliteStore::open(&path).unwrap();
465        assert_eq!(
466            CheckpointStore::load(&store, checkpoint.id).unwrap(),
467            checkpoint
468        );
469
470        let next = checkpoint.next(json!({"step": 2})).unwrap();
471        CheckpointStore::compare_and_swap(&store, &next, Some(0)).unwrap();
472        let stale = checkpoint.next(json!({"step": 3})).unwrap();
473        let error = CheckpointStore::compare_and_swap(&store, &stale, Some(0)).unwrap_err();
474        assert_eq!(error.kind, CheckpointErrorKind::Conflict);
475        fs::remove_file(path).unwrap();
476    }
477
478    #[test]
479    fn effect_survives_reopen_and_preserves_idempotency_index() {
480        let path = temporary_database_path();
481        let capability_id = CapabilityId::new();
482        let request = effect_request(capability_id, "stable-key");
483        let completed = EffectRecord {
484            revision: 2,
485            request: request.clone(),
486            status: EffectStatus::Completed {
487                output: json!({"ok": true}),
488            },
489        };
490        {
491            let store = SqliteStore::open(&path).unwrap();
492            EffectStore::compare_and_swap(&store, &EffectRecord::prepared(request.clone()), None)
493                .unwrap();
494            let started = EffectRecord {
495                revision: 1,
496                request: request.clone(),
497                status: EffectStatus::Started,
498            };
499            EffectStore::compare_and_swap(&store, &started, Some(0)).unwrap();
500            EffectStore::compare_and_swap(&store, &completed, Some(1)).unwrap();
501        }
502
503        let store = SqliteStore::open(&path).unwrap();
504        assert_eq!(
505            store
506                .find_by_idempotency(capability_id, "stable-key")
507                .unwrap(),
508            Some(completed)
509        );
510
511        let conflicting = EffectRecord::prepared(effect_request(capability_id, "stable-key"));
512        let error = EffectStore::compare_and_swap(&store, &conflicting, None).unwrap_err();
513        assert_eq!(error.kind, EffectExecutorErrorKind::IdempotencyConflict);
514        fs::remove_file(path).unwrap();
515    }
516
517    #[test]
518    fn journal_round_trips_events_in_run_sequence_order() {
519        let store = SqliteStore::open_in_memory().unwrap();
520        let run_id = RunId::new();
521        let factory = EventFactory::new(run_id, None);
522        let first = factory.emit(RunEventKind::Lifecycle(LifecycleEvent::Started), None);
523        let second = factory.emit(
524            RunEventKind::Lifecycle(LifecycleEvent::Completed {
525                output: json!("done"),
526            }),
527            Some(first.meta.event_id),
528        );
529
530        store.record(&first).unwrap();
531        store.record(&second).unwrap();
532
533        assert_eq!(store.events(run_id).unwrap(), vec![first, second]);
534    }
535
536    #[test]
537    fn direct_store_error_preserves_json_source() {
538        use std::error::Error as _;
539
540        let error: SqliteStoreError = serde_json::from_str::<RunEvent>("{").unwrap_err().into();
541
542        assert!(matches!(error, SqliteStoreError::Json(_)));
543        assert!(error.source().is_some());
544    }
545
546    fn effect_request(capability_id: CapabilityId, key: &str) -> EffectRequest {
547        EffectRequest {
548            effect_id: EffectId::new(),
549            invocation_id: InvocationId::new(),
550            kind: EffectKind::Tool,
551            capability_id,
552            input: json!({"value": 1}),
553            effect_class: EffectClass::IdempotentWrite,
554            idempotency_key: Some(key.into()),
555        }
556    }
557
558    fn temporary_database_path() -> std::path::PathBuf {
559        std::env::temp_dir().join(format!("runifold-{}.sqlite3", Uuid::now_v7()))
560    }
561}