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#[derive(Debug, Error)]
89#[non_exhaustive]
90pub enum SqliteStoreError {
91 #[error("sqlite operation failed: {0}")]
93 Database(#[from] rusqlite::Error),
94 #[error("sqlite JSON decoding failed: {0}")]
96 Json(#[from] serde_json::Error),
97}
98
99#[derive(Clone)]
105pub struct SqliteStore {
106 connection: Arc<Mutex<Connection>>,
107}
108
109impl SqliteStore {
110 pub fn open(path: impl AsRef<Path>) -> Result<Self, SqliteStoreError> {
117 Self::from_connection(Connection::open(path)?)
118 }
119
120 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 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 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}