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