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