1use super::{ReplayEntry, ReplayStore, ReplayStoreError};
48use crate::types::RunId;
49use async_trait::async_trait;
50use rusqlite::params;
51use rusqlite_isle::{AsyncIsle, AsyncIsleDriver, IsleError};
52use std::path::Path;
53
54const SCHEMA_SQL: &str = "\
55CREATE TABLE IF NOT EXISTS replay_log (\
56 seq INTEGER PRIMARY KEY AUTOINCREMENT, \
57 run_id TEXT NOT NULL, \
58 step_ref TEXT NOT NULL, \
59 input_hash TEXT NOT NULL, \
60 occurrence INTEGER NOT NULL, \
61 ctx_snapshot_json TEXT NOT NULL, \
62 step_output_json TEXT NOT NULL, \
63 created_at INTEGER NOT NULL, \
64 UNIQUE (run_id, step_ref, input_hash, occurrence)\
65);\
66CREATE INDEX IF NOT EXISTS ix_replay_run ON replay_log(run_id, seq);\
67";
68
69const CURRENT_SCHEMA_VERSION: i64 = 1;
72
73fn init_schema(conn: &mut rusqlite::Connection) -> rusqlite::Result<()> {
81 let user_version: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
83
84 match user_version {
85 0 => {
86 let table_present: i64 = conn.query_row(
91 "SELECT COUNT(*) FROM sqlite_master \
92 WHERE type = 'table' AND name = 'replay_log'",
93 [],
94 |r| r.get(0),
95 )?;
96 let has_ctx_column: i64 = if table_present > 0 {
97 conn.query_row(
98 "SELECT COUNT(*) FROM pragma_table_info('replay_log') \
99 WHERE name = 'ctx_snapshot_json'",
100 [],
101 |r| r.get(0),
102 )?
103 } else {
104 0
105 };
106 let is_legacy = table_present > 0 && has_ctx_column == 0;
107 if is_legacy {
108 conn.execute("DROP TABLE replay_log", [])?;
109 }
110 conn.execute_batch(SCHEMA_SQL)?;
111 conn.execute_batch(&format!("PRAGMA user_version = {CURRENT_SCHEMA_VERSION}"))?;
114 Ok(())
115 }
116 v if v == CURRENT_SCHEMA_VERSION => {
117 conn.execute_batch(SCHEMA_SQL)
120 }
121 v => Err(rusqlite::Error::SqliteFailure(
122 rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_ERROR),
123 Some(format!(
124 "replay_log schema version {v} is newer than supported \
125 ({CURRENT_SCHEMA_VERSION}); running an older mse binary \
126 against a store written by a newer one is not supported"
127 )),
128 )),
129 }
130}
131
132pub struct SqliteReplayStore {
140 isle: AsyncIsle,
141}
142
143impl SqliteReplayStore {
144 pub async fn open(path: impl AsRef<Path>) -> Result<(Self, AsyncIsleDriver), ReplayStoreError> {
148 let (isle, driver) = AsyncIsle::spawn(path.as_ref().to_path_buf(), init_schema)
149 .await
150 .map_err(map_isle_err)?;
151 Ok((Self { isle }, driver))
152 }
153
154 pub async fn open_in_memory() -> Result<(Self, AsyncIsleDriver), ReplayStoreError> {
159 let (isle, driver) = AsyncIsle::open_in_memory(init_schema)
160 .await
161 .map_err(map_isle_err)?;
162 Ok((Self { isle }, driver))
163 }
164}
165
166fn map_isle_err(e: IsleError) -> ReplayStoreError {
167 ReplayStoreError::Other(format!("sqlite: {e}"))
168}
169
170#[async_trait]
171impl ReplayStore for SqliteReplayStore {
172 fn name(&self) -> &str {
173 "sqlite"
174 }
175
176 async fn append(&self, entry: ReplayEntry) -> Result<(), ReplayStoreError> {
177 let ReplayEntry {
178 run_id,
179 step_ref,
180 input_hash,
181 occurrence,
182 ctx_snapshot_json,
183 step_output_json,
184 created_at,
185 } = entry;
186 let run_id_for_err = run_id.clone();
187 let step_ref_for_err = step_ref.clone();
188 let input_hash_for_err = input_hash.clone();
189 let run_id_str = run_id.to_string();
190
191 self.isle
192 .call(move |conn| {
193 conn.execute(
194 "INSERT INTO replay_log \
195 (run_id, step_ref, input_hash, occurrence, ctx_snapshot_json, \
196 step_output_json, created_at) \
197 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
198 params![
199 run_id_str,
200 step_ref,
201 input_hash,
202 occurrence as i64,
203 ctx_snapshot_json,
204 step_output_json,
205 created_at as i64,
206 ],
207 )?;
208 Ok(())
209 })
210 .await
211 .map_err(|e| match &e {
212 IsleError::Sqlite(rusqlite::Error::SqliteFailure(err, _))
213 if err.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE
214 || err.code == rusqlite::ErrorCode::ConstraintViolation =>
215 {
216 ReplayStoreError::Duplicate {
217 run_id: run_id_for_err.clone(),
218 step_ref: step_ref_for_err.clone(),
219 input_hash: input_hash_for_err.clone(),
220 occurrence,
221 }
222 }
223 _ => map_isle_err(e),
224 })
225 }
226
227 async fn list_by_run(&self, run_id: &RunId) -> Result<Vec<ReplayEntry>, ReplayStoreError> {
228 let run_id_str = run_id.to_string();
229 let run_id_owned = run_id.clone();
230 let rows: Vec<(String, String, i64, String, String, i64)> = self
231 .isle
232 .call(move |conn| {
233 let mut stmt = conn.prepare(
234 "SELECT step_ref, input_hash, occurrence, ctx_snapshot_json, \
235 step_output_json, created_at FROM replay_log \
236 WHERE run_id = ?1 ORDER BY seq ASC",
237 )?;
238 let iter = stmt.query_map(params![run_id_str], |row| {
239 Ok((
240 row.get::<_, String>(0)?,
241 row.get::<_, String>(1)?,
242 row.get::<_, i64>(2)?,
243 row.get::<_, String>(3)?,
244 row.get::<_, String>(4)?,
245 row.get::<_, i64>(5)?,
246 ))
247 })?;
248 let mut out = Vec::new();
249 for r in iter {
250 out.push(r?);
251 }
252 Ok(out)
253 })
254 .await
255 .map_err(map_isle_err)?;
256
257 Ok(rows
258 .into_iter()
259 .map(
260 |(
261 step_ref,
262 input_hash,
263 occurrence,
264 ctx_snapshot_json,
265 step_output_json,
266 created_at,
267 )| {
268 ReplayEntry {
269 run_id: run_id_owned.clone(),
270 step_ref,
271 input_hash,
272 occurrence: occurrence as u32,
273 ctx_snapshot_json,
274 step_output_json,
275 created_at: created_at as u64,
276 }
277 },
278 )
279 .collect())
280 }
281}
282
283#[cfg(test)]
288mod tests {
289 use super::*;
290 use crate::core::ctx::Ctx;
291 use crate::store::replay::ReplayCursor;
292 use crate::types::StepId;
293 use serde_json::json;
294
295 fn mk_ctx() -> Ctx {
296 let mut ctx = Ctx::new(StepId::new(), 1, "step-a");
297 ctx.meta.observer.insert("k".into(), json!("v"));
298 ctx
299 }
300
301 #[tokio::test]
302 async fn sqlite_append_and_list() {
303 let (store, driver) = SqliteReplayStore::open_in_memory().await.unwrap();
304 let run_id = RunId::new();
305 let ctx = mk_ctx();
306
307 let e0 = ReplayEntry::from_completion(
308 run_id.clone(),
309 "step-a",
310 "hash-a",
311 0,
312 &ctx,
313 &json!({ "n": 1 }),
314 )
315 .unwrap();
316 store.append(e0).await.unwrap();
317
318 let listed = store.list_by_run(&run_id).await.unwrap();
319 assert_eq!(listed.len(), 1);
320 assert_eq!(listed[0].step_ref, "step-a");
321 assert_eq!(listed[0].occurrence, 0);
322 assert_eq!(listed[0].decode_step_output().unwrap(), json!({ "n": 1 }));
323
324 let restored = listed[0].decode_ctx_snapshot().unwrap();
326 assert_eq!(restored.agent, ctx.agent);
327 assert_eq!(restored.attempt, ctx.attempt);
328 assert_eq!(restored.meta.observer.get("k"), Some(&json!("v")));
329
330 driver.shutdown().await.unwrap();
331 }
332
333 #[tokio::test]
334 async fn sqlite_unique_4_tuple_is_enforced() {
335 let (store, driver) = SqliteReplayStore::open_in_memory().await.unwrap();
336 let run_id = RunId::new();
337 let ctx = mk_ctx();
338
339 let e0 = ReplayEntry::from_completion(
340 run_id.clone(),
341 "step-a",
342 "hash-a",
343 0,
344 &ctx,
345 &json!("first"),
346 )
347 .unwrap();
348 store.append(e0.clone()).await.unwrap();
349
350 let dup_err = store.append(e0).await.unwrap_err();
352 assert!(
353 matches!(dup_err, ReplayStoreError::Duplicate { .. }),
354 "same (run_id, step_ref, input_hash, occurrence) must collide"
355 );
356
357 let e1 = ReplayEntry::from_completion(
360 run_id.clone(),
361 "step-a",
362 "hash-a",
363 1,
364 &ctx,
365 &json!("second"),
366 )
367 .unwrap();
368 store
369 .append(e1)
370 .await
371 .expect("occurrence=1 must not collide with occurrence=0");
372
373 let listed = store.list_by_run(&run_id).await.unwrap();
374 assert_eq!(listed.len(), 2);
375 let cursor = ReplayCursor::from_entries(listed);
376 assert_eq!(cursor.find("step-a", "hash-a", 0), Some(json!("first")));
377 assert_eq!(cursor.find("step-a", "hash-a", 1), Some(json!("second")));
378
379 driver.shutdown().await.unwrap();
380 }
381
382 #[tokio::test]
383 async fn sqlite_list_by_run_returns_in_seq_order() {
384 let (store, driver) = SqliteReplayStore::open_in_memory().await.unwrap();
385 let run_id = RunId::new();
386 let ctx = mk_ctx();
387
388 for (i, (step, occ)) in [("a", 0), ("b", 0), ("a", 1)].iter().enumerate() {
389 store
390 .append(
391 ReplayEntry::from_completion(
392 run_id.clone(),
393 *step,
394 "h",
395 *occ,
396 &ctx,
397 &json!({ "idx": i }),
398 )
399 .unwrap(),
400 )
401 .await
402 .unwrap();
403 }
404
405 let listed = store.list_by_run(&run_id).await.unwrap();
406 let steps: Vec<(String, u32)> = listed
407 .iter()
408 .map(|e| (e.step_ref.clone(), e.occurrence))
409 .collect();
410 assert_eq!(
411 steps,
412 vec![
413 ("a".to_string(), 0),
414 ("b".to_string(), 0),
415 ("a".to_string(), 1),
416 ]
417 );
418
419 driver.shutdown().await.unwrap();
420 }
421
422 #[tokio::test]
423 async fn sqlite_name() {
424 let (store, driver) = SqliteReplayStore::open_in_memory().await.unwrap();
425 assert_eq!(store.name(), "sqlite");
426 driver.shutdown().await.unwrap();
427 }
428
429 fn read_user_version(path: &std::path::Path) -> i64 {
436 let conn = rusqlite::Connection::open(path).unwrap();
437 conn.query_row("PRAGMA user_version", [], |r| r.get(0))
438 .unwrap()
439 }
440
441 #[tokio::test]
442 async fn sqlite_fresh_open_stamps_current_schema_version() {
443 let tmp = tempfile::TempDir::new().unwrap();
444 let path = tmp.path().join("replay.sqlite");
445
446 let (store, driver) = SqliteReplayStore::open(&path).await.unwrap();
447
448 let run_id = RunId::new();
450 let ctx = mk_ctx();
451 let entry = ReplayEntry::from_completion(
452 run_id.clone(),
453 "step-a",
454 "hash-a",
455 0,
456 &ctx,
457 &json!({ "n": 1 }),
458 )
459 .unwrap();
460 store.append(entry).await.unwrap();
461 let listed = store.list_by_run(&run_id).await.unwrap();
462 assert_eq!(listed.len(), 1);
463 assert_eq!(listed[0].decode_step_output().unwrap(), json!({ "n": 1 }));
464
465 driver.shutdown().await.unwrap();
466
467 assert_eq!(read_user_version(&path), CURRENT_SCHEMA_VERSION);
468 }
469
470 #[tokio::test]
471 async fn sqlite_legacy_schema_is_dropped_and_rebuilt() {
472 let tmp = tempfile::TempDir::new().unwrap();
473 let path = tmp.path().join("replay.sqlite");
474
475 {
479 let conn = rusqlite::Connection::open(&path).unwrap();
480 conn.execute_batch(
481 "CREATE TABLE replay_log (\
482 seq INTEGER PRIMARY KEY AUTOINCREMENT, \
483 run_id TEXT NOT NULL, \
484 step_ref TEXT NOT NULL, \
485 input_hash TEXT NOT NULL, \
486 occurrence INTEGER NOT NULL, \
487 value_json TEXT NOT NULL, \
488 created_at INTEGER NOT NULL, \
489 UNIQUE (run_id, step_ref, input_hash, occurrence)\
490 );",
491 )
492 .unwrap();
493 conn.execute(
494 "INSERT INTO replay_log \
495 (run_id, step_ref, input_hash, occurrence, value_json, created_at) \
496 VALUES ('legacy-run', 'legacy-step', 'legacy-hash', 0, 'legacy-value', 0)",
497 [],
498 )
499 .unwrap();
500 }
503
504 assert_eq!(read_user_version(&path), 0, "seed sanity: user_version=0");
505
506 let (store, driver) = SqliteReplayStore::open(&path).await.unwrap();
509
510 let run_id = RunId::new();
513 let ctx = mk_ctx();
514 let entry = ReplayEntry::from_completion(
515 run_id.clone(),
516 "step-a",
517 "hash-a",
518 0,
519 &ctx,
520 &json!({ "n": 42 }),
521 )
522 .unwrap();
523 store.append(entry).await.unwrap();
524 let listed = store.list_by_run(&run_id).await.unwrap();
525 assert_eq!(listed.len(), 1);
526
527 driver.shutdown().await.unwrap();
528
529 assert_eq!(read_user_version(&path), CURRENT_SCHEMA_VERSION);
530
531 let conn = rusqlite::Connection::open(&path).unwrap();
533 let legacy_count: i64 = conn
534 .query_row(
535 "SELECT COUNT(*) FROM replay_log WHERE run_id = 'legacy-run'",
536 [],
537 |r| r.get(0),
538 )
539 .unwrap();
540 assert_eq!(legacy_count, 0, "legacy rows must be dropped");
541
542 let ctx_col_count: i64 = conn
544 .query_row(
545 "SELECT COUNT(*) FROM pragma_table_info('replay_log') \
546 WHERE name = 'ctx_snapshot_json'",
547 [],
548 |r| r.get(0),
549 )
550 .unwrap();
551 assert_eq!(ctx_col_count, 1, "ctx_snapshot_json column must be present");
552 }
553
554 #[tokio::test]
555 async fn sqlite_current_schema_open_is_idempotent() {
556 let tmp = tempfile::TempDir::new().unwrap();
557 let path = tmp.path().join("replay.sqlite");
558
559 let run_id = {
561 let (store, driver) = SqliteReplayStore::open(&path).await.unwrap();
562 let run_id = RunId::new();
563 let ctx = mk_ctx();
564 let entry = ReplayEntry::from_completion(
565 run_id.clone(),
566 "step-a",
567 "hash-a",
568 0,
569 &ctx,
570 &json!({ "n": 7 }),
571 )
572 .unwrap();
573 store.append(entry).await.unwrap();
574 driver.shutdown().await.unwrap();
575 run_id
576 };
577
578 assert_eq!(read_user_version(&path), CURRENT_SCHEMA_VERSION);
579
580 let (store, driver) = SqliteReplayStore::open(&path).await.unwrap();
583 let listed = store.list_by_run(&run_id).await.unwrap();
584 assert_eq!(listed.len(), 1, "prior rows must survive re-open");
585 assert_eq!(listed[0].step_ref, "step-a");
586 assert_eq!(listed[0].decode_step_output().unwrap(), json!({ "n": 7 }));
587 driver.shutdown().await.unwrap();
588
589 assert_eq!(read_user_version(&path), CURRENT_SCHEMA_VERSION);
591 }
592
593 #[tokio::test]
594 async fn sqlite_future_schema_version_is_rejected() {
595 let tmp = tempfile::TempDir::new().unwrap();
596 let path = tmp.path().join("replay.sqlite");
597
598 {
601 let conn = rusqlite::Connection::open(&path).unwrap();
602 conn.execute_batch(&format!(
603 "PRAGMA user_version = {}",
604 CURRENT_SCHEMA_VERSION + 1
605 ))
606 .unwrap();
607 }
608
609 let res = SqliteReplayStore::open(&path).await;
610 let err = res
611 .err()
612 .expect("future user_version must be rejected by init_schema");
613 let msg = err.to_string();
616 assert!(
617 msg.contains("newer than supported"),
618 "unexpected error message: {msg}"
619 );
620 }
621}