1pub mod replay;
36pub mod storage;
37
38pub use storage::*;
39
40#[cfg(test)]
41mod tests {
42 use super::*;
43 use crate::db;
44 use rusqlite::{Connection, params};
45
46 fn test_db() -> Connection {
47 db::open(std::path::Path::new(":memory:")).unwrap()
48 }
49
50 fn chat_corpus() -> String {
54 let mut s = String::new();
55 let header = "[system] You are an assistant operating on the alphaonedev/ai-memory-mcp codebase. Always cite tool ids in your replies. Always cite tool ids in your replies.\n";
56 let user = "[user] What did we decide about the v0.7 attested-cortex epic last sprint? Please include the full transcript of the relevant meeting.\n";
57 let assistant = "[assistant] Per the v0.7 epic doc, the attested-cortex track adds a memory_transcripts table backed by zstd-3 compressed BLOBs. The decision was logged in the meeting transcript on 2026-04-12 at 14:33 UTC.\n";
58 let tool_call = "[tool_call name=\"memory_recall\" args={\"query\":\"v0.7 attested cortex\",\"limit\":10,\"namespace\":\"team/eng/memory\"}]\n";
59 let tool_result = "[tool_result name=\"memory_recall\" ok=true count=10 latency_ms=42]\n";
60 for _ in 0..16 {
61 s.push_str(header);
62 s.push_str(user);
63 s.push_str(assistant);
64 s.push_str(tool_call);
65 s.push_str(tool_result);
66 }
67 debug_assert!(s.len() >= 5_000, "corpus too small: {}", s.len());
68 s
69 }
70
71 #[test]
72 fn migration_is_idempotent() {
73 let p = tempfile::NamedTempFile::new().unwrap();
76 let path = p.path().to_path_buf();
77 let _ = db::open(&path).unwrap();
78 let conn = db::open(&path).unwrap();
79 let cnt: i64 = conn
81 .query_row("SELECT count(*) FROM memory_transcripts", [], |r| r.get(0))
82 .unwrap();
83 assert_eq!(cnt, 0);
84 }
85
86 #[test]
87 fn round_trip_returns_original_content() {
88 let conn = test_db();
89 let body = chat_corpus();
90 let handle = store(&conn, "team/eng", &body, None).unwrap();
91 let got = fetch(&conn, &handle.id).unwrap();
92 assert_eq!(got.as_deref(), Some(body.as_str()));
93 }
94
95 #[test]
96 fn fetch_missing_id_returns_none() {
97 let conn = test_db();
98 let got = fetch(&conn, "not-a-real-uuid").unwrap();
99 assert!(got.is_none());
100 }
101
102 #[test]
103 fn compression_ratio_at_least_5x_on_chat_corpus() {
104 let conn = test_db();
105 let body = chat_corpus();
106 let handle = store(&conn, "team/eng", &body, None).unwrap();
107 let ratio = handle.original_size as f64 / handle.compressed_size as f64;
108 assert!(
109 ratio >= 5.0,
110 "expected >=5x zstd-3 ratio on chat-shaped text, got {ratio:.2}x \
111 (orig={} compressed={})",
112 handle.original_size,
113 handle.compressed_size,
114 );
115 assert_eq!(handle.original_size, body.len() as i64);
117 }
118
119 #[test]
120 fn namespace_created_index_exists() {
121 let conn = test_db();
122 let mut stmt = conn
123 .prepare("PRAGMA index_list('memory_transcripts')")
124 .unwrap();
125 let names: Vec<String> = stmt
126 .query_map([], |r| r.get::<_, String>(1))
127 .unwrap()
128 .map(std::result::Result::unwrap)
129 .collect();
130 assert!(
131 names
132 .iter()
133 .any(|n| n == "idx_memory_transcripts_namespace_created"),
134 "expected idx_memory_transcripts_namespace_created in {names:?}"
135 );
136 }
137
138 fn insert_test_memory(conn: &Connection, id: &str) {
143 let now = chrono::Utc::now().to_rfc3339();
144 conn.execute(
151 "INSERT INTO memories (
152 id, tier, namespace, title, content, created_at, updated_at
153 ) VALUES (?1, 'short', 'team/eng', ?2, 'body', ?3, ?3)",
154 params![id, format!("title-{id}"), now],
155 )
156 .unwrap();
157 }
158
159 #[test]
162 fn i2_link_then_transcripts_for_memory_round_trip() {
163 let conn = test_db();
164 insert_test_memory(&conn, "mem-1");
165 let t = store(&conn, "team/eng", "abcdefghij", None).unwrap();
166
167 link_transcript(&conn, "mem-1", &t.id, Some(2), Some(7)).unwrap();
168
169 let got = transcripts_for_memory(&conn, "mem-1").unwrap();
170 assert_eq!(
171 got,
172 vec![TranscriptLink {
173 memory_id: "mem-1".into(),
174 transcript_id: t.id.clone(),
175 span_start: Some(2),
176 span_end: Some(7),
177 }],
178 );
179 }
180
181 #[test]
185 fn i2_memories_for_transcript_returns_all_linked_memories() {
186 let conn = test_db();
187 insert_test_memory(&conn, "mem-a");
188 insert_test_memory(&conn, "mem-b");
189 insert_test_memory(&conn, "mem-c");
190 let t = store(&conn, "team/eng", "shared transcript body", None).unwrap();
191
192 link_transcript(&conn, "mem-a", &t.id, None, None).unwrap();
193 link_transcript(&conn, "mem-b", &t.id, Some(0), Some(10)).unwrap();
194 link_transcript(&conn, "mem-c", &t.id, Some(11), Some(22)).unwrap();
195
196 let got = memories_for_transcript(&conn, &t.id).unwrap();
197 let ids: Vec<&str> = got.iter().map(|l| l.memory_id.as_str()).collect();
198 assert_eq!(ids, vec!["mem-a", "mem-b", "mem-c"]);
199 }
200
201 #[test]
205 fn i2_null_spans_round_trip_as_none() {
206 let conn = test_db();
207 insert_test_memory(&conn, "mem-null");
208 let t = store(&conn, "team/eng", "body", None).unwrap();
209
210 link_transcript(&conn, "mem-null", &t.id, None, None).unwrap();
211
212 let got = transcripts_for_memory(&conn, "mem-null").unwrap();
213 assert_eq!(got.len(), 1);
214 assert_eq!(got[0].span_start, None);
215 assert_eq!(got[0].span_end, None);
216 }
217
218 #[test]
222 fn i2_delete_memory_cascades_to_links() {
223 let conn = test_db();
224 insert_test_memory(&conn, "mem-doomed");
225 insert_test_memory(&conn, "mem-survives");
226 let t = store(&conn, "team/eng", "body", None).unwrap();
227
228 link_transcript(&conn, "mem-doomed", &t.id, None, None).unwrap();
229 link_transcript(&conn, "mem-survives", &t.id, None, None).unwrap();
230 assert_eq!(memories_for_transcript(&conn, &t.id).unwrap().len(), 2);
231
232 conn.execute("DELETE FROM memories WHERE id = ?1", params!["mem-doomed"])
233 .unwrap();
234
235 let remaining = memories_for_transcript(&conn, &t.id).unwrap();
236 assert_eq!(remaining.len(), 1);
237 assert_eq!(remaining[0].memory_id, "mem-survives");
238 }
239
240 #[test]
245 fn i2_delete_transcript_cascades_to_links() {
246 let conn = test_db();
247 insert_test_memory(&conn, "mem-x");
248 let t = store(&conn, "team/eng", "ephemeral", None).unwrap();
249
250 link_transcript(&conn, "mem-x", &t.id, None, None).unwrap();
251 assert_eq!(transcripts_for_memory(&conn, "mem-x").unwrap().len(), 1);
252
253 conn.execute(
254 "DELETE FROM memory_transcripts WHERE id = ?1",
255 params![t.id],
256 )
257 .unwrap();
258
259 assert!(transcripts_for_memory(&conn, "mem-x").unwrap().is_empty());
260 }
261
262 #[test]
266 fn i2_migration_is_idempotent() {
267 let p = tempfile::NamedTempFile::new().unwrap();
268 let path = p.path().to_path_buf();
269 let _ = db::open(&path).unwrap();
270 let conn = db::open(&path).unwrap();
271 let cnt: i64 = conn
272 .query_row("SELECT count(*) FROM memory_transcript_links", [], |r| {
273 r.get(0)
274 })
275 .unwrap();
276 assert_eq!(cnt, 0);
277 }
278
279 #[test]
283 fn i2_join_table_indexes_exist() {
284 let conn = test_db();
285 let mut stmt = conn
286 .prepare("PRAGMA index_list('memory_transcript_links')")
287 .unwrap();
288 let names: Vec<String> = stmt
289 .query_map([], |r| r.get::<_, String>(1))
290 .unwrap()
291 .map(std::result::Result::unwrap)
292 .collect();
293 for expected in ["idx_mtl_transcript", "idx_mtl_memory"] {
294 assert!(
295 names.iter().any(|n| n == expected),
296 "expected {expected} in {names:?}"
297 );
298 }
299 }
300
301 #[test]
302 fn purge_expired_only_removes_past_due_rows() {
303 let conn = test_db();
304 let expired = store(
306 &conn,
307 "team/eng",
308 "expired body",
309 Some(chrono::Duration::seconds(-crate::SECS_PER_HOUR)),
310 )
311 .unwrap();
312 let live = store(
314 &conn,
315 "team/eng",
316 "live body",
317 Some(chrono::Duration::seconds(crate::SECS_PER_HOUR)),
318 )
319 .unwrap();
320 let immortal = store(&conn, "team/eng", "immortal body", None).unwrap();
322
323 let n = purge_expired(&conn).unwrap();
324 assert_eq!(n, 1, "exactly the past-due row should be deleted");
325
326 assert!(fetch(&conn, &expired.id).unwrap().is_none());
327 assert_eq!(
328 fetch(&conn, &live.id).unwrap().as_deref(),
329 Some("live body"),
330 );
331 assert_eq!(
332 fetch(&conn, &immortal.id).unwrap().as_deref(),
333 Some("immortal body"),
334 );
335 }
336
337 use crate::config::{TranscriptNamespaceConfig, TranscriptsConfig};
342 use std::collections::HashMap;
343
344 fn backdate_created(conn: &Connection, id: &str, secs: i64) -> String {
349 let stamp = (chrono::Utc::now() - chrono::Duration::seconds(secs)).to_rfc3339();
350 conn.execute(
351 "UPDATE memory_transcripts SET created_at = ?1 WHERE id = ?2",
352 params![stamp, id],
353 )
354 .unwrap();
355 stamp
356 }
357
358 fn backdate_archived(conn: &Connection, id: &str, secs: i64) -> String {
361 let stamp = (chrono::Utc::now() - chrono::Duration::seconds(secs)).to_rfc3339();
362 conn.execute(
363 "UPDATE memory_transcripts SET archived_at = ?1 WHERE id = ?2",
364 params![stamp, id],
365 )
366 .unwrap();
367 stamp
368 }
369
370 fn fast_cfg() -> TranscriptsConfig {
375 TranscriptsConfig {
376 default_ttl_secs: Some(crate::SECS_PER_HOUR),
377 archive_grace_secs: Some(crate::SECS_PER_HOUR),
378 namespaces: None,
379 max_decompressed_bytes: None,
380 }
381 }
382
383 fn archived_at(conn: &Connection, id: &str) -> Option<String> {
386 conn.query_row(
387 "SELECT archived_at FROM memory_transcripts WHERE id = ?1",
388 params![id],
389 |r| r.get::<_, Option<String>>(0),
390 )
391 .unwrap()
392 }
393
394 fn row_exists(conn: &Connection, id: &str) -> bool {
397 let n: i64 = conn
398 .query_row(
399 "SELECT COUNT(*) FROM memory_transcripts WHERE id = ?1",
400 params![id],
401 |r| r.get(0),
402 )
403 .unwrap();
404 n > 0
405 }
406
407 #[test]
410 fn i3_unlinked_aged_transcript_is_archived() {
411 let conn = test_db();
412 let cfg = fast_cfg();
413
414 let t = store(&conn, "team/eng", "old body", None).unwrap();
415 backdate_created(&conn, &t.id, 7200); let report = sweep_transcript_lifecycle(&conn, &cfg).unwrap();
418 assert_eq!(report.archived, 1, "phase 1 must archive the aged row");
419 assert_eq!(
420 report.pruned, 0,
421 "phase 2 must not fire on a freshly archived row"
422 );
423 assert!(
424 archived_at(&conn, &t.id).is_some(),
425 "archived_at must be set after phase 1",
426 );
427 }
428
429 #[test]
432 fn i3_archived_past_grace_is_pruned_with_cascade() {
433 let conn = test_db();
434 let cfg = fast_cfg();
435
436 insert_test_memory(&conn, "mem-cascade");
437 let t = store(&conn, "team/eng", "to be pruned", None).unwrap();
438 link_transcript(&conn, "mem-cascade", &t.id, None, None).unwrap();
439
440 backdate_archived(&conn, &t.id, 7200);
442
443 let report = sweep_transcript_lifecycle(&conn, &cfg).unwrap();
444 assert_eq!(report.pruned, 1, "phase 2 must hard-DELETE the row");
445 assert!(!row_exists(&conn, &t.id), "transcript row gone");
446
447 assert!(
449 transcripts_for_memory(&conn, "mem-cascade")
450 .unwrap()
451 .is_empty(),
452 "ON DELETE CASCADE must clear the I2 join row",
453 );
454 }
455
456 #[test]
461 fn i3_live_linked_memory_keeps_transcript_alive() {
462 let conn = test_db();
463 let cfg = fast_cfg();
464
465 insert_test_memory(&conn, "mem-immortal");
467
468 let t = store(&conn, "team/eng", "still wanted", None).unwrap();
469 link_transcript(&conn, "mem-immortal", &t.id, None, None).unwrap();
470 backdate_created(&conn, &t.id, 7200); let report = sweep_transcript_lifecycle(&conn, &cfg).unwrap();
473 assert_eq!(report.archived, 0);
474 assert!(
475 archived_at(&conn, &t.id).is_none(),
476 "live linked memory must keep archived_at NULL",
477 );
478 }
479
480 #[test]
485 fn i3_all_linked_memories_expired_then_transcript_is_archived() {
486 let conn = test_db();
487 let cfg = fast_cfg();
488
489 insert_test_memory(&conn, "mem-expired");
491 let past = (chrono::Utc::now() - chrono::Duration::seconds(60)).to_rfc3339();
492 conn.execute(
493 "UPDATE memories SET expires_at = ?1 WHERE id = 'mem-expired'",
494 params![past],
495 )
496 .unwrap();
497
498 let t = store(&conn, "team/eng", "no longer needed", None).unwrap();
499 link_transcript(&conn, "mem-expired", &t.id, None, None).unwrap();
500 backdate_created(&conn, &t.id, 7200);
501
502 let report = sweep_transcript_lifecycle(&conn, &cfg).unwrap();
503 assert_eq!(report.archived, 1);
504 assert!(archived_at(&conn, &t.id).is_some());
505 }
506
507 #[test]
511 fn i3_per_namespace_override_extends_ttl_beyond_global_default() {
512 let conn = test_db();
513
514 let mut ns_table = HashMap::new();
516 ns_table.insert(
517 "team/audit".to_string(),
518 TranscriptNamespaceConfig {
519 default_ttl_secs: Some(crate::SECS_PER_DAY),
520 archive_grace_secs: None,
521 auto_extract: None,
522 },
523 );
524 let cfg = TranscriptsConfig {
525 default_ttl_secs: Some(crate::SECS_PER_HOUR),
526 archive_grace_secs: Some(crate::SECS_PER_HOUR),
527 namespaces: Some(ns_table),
528 max_decompressed_bytes: None,
529 };
530
531 let eng = store(&conn, "team/eng", "eng body", None).unwrap();
533 backdate_created(&conn, &eng.id, 7200);
534 let audit = store(&conn, "team/audit", "audit body", None).unwrap();
535 backdate_created(&conn, &audit.id, 7200);
536
537 let report = sweep_transcript_lifecycle(&conn, &cfg).unwrap();
538 assert_eq!(report.archived, 1, "only team/eng is past the resolved TTL");
539 assert!(archived_at(&conn, &eng.id).is_some());
540 assert!(
541 archived_at(&conn, &audit.id).is_none(),
542 "team/audit override (1d) keeps the audit row live",
543 );
544 }
545
546 #[test]
550 fn i3_prefix_pattern_override_matches_child_namespaces() {
551 let conn = test_db();
552
553 let mut ns_table = HashMap::new();
554 ns_table.insert(
555 "ephemeral/*".to_string(),
556 TranscriptNamespaceConfig {
557 default_ttl_secs: Some(60),
558 archive_grace_secs: Some(60),
559 auto_extract: None,
560 },
561 );
562 let cfg = TranscriptsConfig {
563 default_ttl_secs: Some(crate::SECS_PER_DAY * 30), archive_grace_secs: Some(crate::SECS_PER_WEEK),
565 namespaces: Some(ns_table),
566 max_decompressed_bytes: None,
567 };
568
569 let t = store(&conn, "ephemeral/scratch", "scratch", None).unwrap();
572 backdate_created(&conn, &t.id, 300);
573
574 let report = sweep_transcript_lifecycle(&conn, &cfg).unwrap();
575 assert_eq!(
576 report.archived, 1,
577 "prefix pattern must apply to ephemeral/scratch"
578 );
579 }
580
581 #[test]
585 fn i3_archived_within_grace_is_not_pruned() {
586 let conn = test_db();
587 let cfg = fast_cfg();
588
589 let t = store(&conn, "team/eng", "still in grace", None).unwrap();
590 backdate_archived(&conn, &t.id, 1800);
592
593 let report = sweep_transcript_lifecycle(&conn, &cfg).unwrap();
594 assert_eq!(report.pruned, 0);
595 assert!(row_exists(&conn, &t.id));
596 }
597
598 #[test]
602 fn i3_archive_then_prune_in_two_sweeps() {
603 let conn = test_db();
604 let cfg = fast_cfg();
605
606 let t = store(&conn, "team/eng", "lifecycle e2e", None).unwrap();
607 backdate_created(&conn, &t.id, 7200);
608
609 let r1 = sweep_transcript_lifecycle(&conn, &cfg).unwrap();
611 assert_eq!(r1.archived, 1);
612 assert_eq!(r1.pruned, 0);
613
614 backdate_archived(&conn, &t.id, 7200);
616
617 let r2 = sweep_transcript_lifecycle(&conn, &cfg).unwrap();
619 assert_eq!(r2.archived, 0);
620 assert_eq!(r2.pruned, 1);
621 assert!(!row_exists(&conn, &t.id));
622 }
623
624 #[test]
629 fn i3_idempotent_phase1_does_not_restamp_archived_rows() {
630 let conn = test_db();
631 let cfg = fast_cfg();
632
633 let t = store(&conn, "team/eng", "already archived", None).unwrap();
634 backdate_created(&conn, &t.id, 7200);
635 let _ = sweep_transcript_lifecycle(&conn, &cfg).unwrap();
636 let first_stamp = archived_at(&conn, &t.id).unwrap();
637
638 std::thread::sleep(std::time::Duration::from_millis(20));
640 let r2 = sweep_transcript_lifecycle(&conn, &cfg).unwrap();
641 assert_eq!(r2.archived, 0, "no row should be re-archived");
642
643 let second_stamp = archived_at(&conn, &t.id).unwrap();
644 assert_eq!(
645 first_stamp, second_stamp,
646 "archived_at must be preserved across sweeps",
647 );
648 }
649}