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