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#[derive(Debug, Error)]
84#[non_exhaustive]
85pub enum SqliteStoreError {
86 #[error("sqlite operation failed: {0}")]
88 Database(#[from] rusqlite::Error),
89 #[error("sqlite JSON decoding failed: {0}")]
91 Json(#[from] serde_json::Error),
92}
93
94#[derive(Clone)]
100pub struct SqliteStore {
101 connection: Arc<Mutex<Connection>>,
102}
103
104impl SqliteStore {
105 pub fn open(path: impl AsRef<Path>) -> Result<Self, SqliteStoreError> {
112 Self::from_connection(Connection::open(path)?)
113 }
114
115 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 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}