1use anyhow::{Context, Result, anyhow};
10use chrono::{DateTime, Duration, Utc};
11use rusqlite::{Connection, OptionalExtension, params};
12use std::io::{Read, Write};
13
14use crate::config::{ResolvedTranscriptLifecycle, TranscriptsConfig};
15
16const LIFECYCLE_TRACE_TARGET: &str = "transcripts.lifecycle";
19
20const ZSTD_LEVEL: i32 = 3;
23
24pub const MAX_DECOMPRESSED_BYTES: usize = 16 * 1024 * 1024;
34
35#[derive(Debug, Clone)]
41pub struct Transcript {
42 pub id: String,
43 pub namespace: String,
44 pub created_at: String,
45 pub expires_at: Option<String>,
46 pub compressed_size: i64,
47 pub original_size: i64,
48}
49
50pub fn store(
64 conn: &Connection,
65 namespace: &str,
66 content: &str,
67 ttl: Option<Duration>,
68) -> Result<Transcript> {
69 let id = uuid::Uuid::new_v4().to_string();
70 let now = Utc::now();
71 let created_at = now.to_rfc3339();
72 let expires_at = ttl.map(|d| (now + d).to_rfc3339());
73
74 let original_size =
75 i64::try_from(content.len()).context("transcript content length overflows i64")?;
76 let blob = zstd_compress(content.as_bytes())
77 .context("zstd compression failed for transcript content")?;
78 let compressed_size =
79 i64::try_from(blob.len()).context("compressed transcript length overflows i64")?;
80
81 conn.execute(
82 "INSERT INTO memory_transcripts (
83 id, namespace, created_at, expires_at,
84 compressed_size, original_size, zstd_level, content_blob
85 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
86 params![
87 id,
88 namespace,
89 created_at,
90 expires_at,
91 compressed_size,
92 original_size,
93 ZSTD_LEVEL,
94 blob,
95 ],
96 )
97 .context("INSERT into memory_transcripts failed")?;
98
99 Ok(Transcript {
100 id,
101 namespace: namespace.to_string(),
102 created_at,
103 expires_at,
104 compressed_size,
105 original_size,
106 })
107}
108
109pub fn fetch(conn: &Connection, id: &str) -> Result<Option<String>> {
119 let row: Option<Vec<u8>> = conn
120 .query_row(
121 "SELECT content_blob FROM memory_transcripts WHERE id = ?1",
122 params![id],
123 |r| r.get::<_, Vec<u8>>(0),
124 )
125 .optional()
126 .context("SELECT memory_transcripts failed")?;
127
128 let Some(blob) = row else {
129 return Ok(None);
130 };
131
132 let bytes = zstd_decompress(&blob).context("zstd decompression failed")?;
133 let text = String::from_utf8(bytes).context("transcript blob did not decode to valid UTF-8")?;
134 Ok(Some(text))
135}
136
137pub fn fetch_metadata(conn: &Connection, id: &str) -> Result<Option<Transcript>> {
148 let row = conn
149 .query_row(
150 "SELECT id, namespace, created_at, expires_at,
151 compressed_size, original_size
152 FROM memory_transcripts WHERE id = ?1",
153 params![id],
154 |r| {
155 Ok(Transcript {
156 id: r.get(0)?,
157 namespace: r.get(1)?,
158 created_at: r.get(2)?,
159 expires_at: r.get(3)?,
160 compressed_size: r.get(4)?,
161 original_size: r.get(5)?,
162 })
163 },
164 )
165 .optional()
166 .context("SELECT memory_transcripts metadata failed")?;
167 Ok(row)
168}
169
170pub fn purge_expired(conn: &Connection) -> Result<usize> {
181 let now = Utc::now().to_rfc3339();
182 let n = conn
183 .execute(
184 "DELETE FROM memory_transcripts
185 WHERE expires_at IS NOT NULL AND expires_at <= ?1",
186 params![now],
187 )
188 .context("DELETE expired memory_transcripts failed")?;
189 Ok(n)
190}
191
192#[derive(Debug, Clone, PartialEq, Eq)]
207pub struct TranscriptLink {
208 pub memory_id: String,
209 pub transcript_id: String,
210 pub span_start: Option<i64>,
211 pub span_end: Option<i64>,
212}
213
214pub fn link_transcript(
229 conn: &Connection,
230 memory_id: &str,
231 transcript_id: &str,
232 span_start: Option<i64>,
233 span_end: Option<i64>,
234) -> Result<()> {
235 conn.execute(
236 "INSERT OR REPLACE INTO memory_transcript_links (
237 memory_id, transcript_id, span_start, span_end
238 ) VALUES (?1, ?2, ?3, ?4)",
239 params![memory_id, transcript_id, span_start, span_end],
240 )
241 .context("INSERT into memory_transcript_links failed")?;
242 Ok(())
243}
244
245pub fn transcripts_for_memory(conn: &Connection, memory_id: &str) -> Result<Vec<TranscriptLink>> {
254 let mut stmt = conn
255 .prepare(
256 "SELECT memory_id, transcript_id, span_start, span_end
257 FROM memory_transcript_links
258 WHERE memory_id = ?1
259 ORDER BY transcript_id",
260 )
261 .context("PREPARE transcripts_for_memory failed")?;
262 let rows = stmt
263 .query_map(params![memory_id], row_to_link)
264 .context("QUERY transcripts_for_memory failed")?;
265 let mut out = Vec::new();
266 for r in rows {
267 out.push(r.context("decode transcripts_for_memory row")?);
268 }
269 Ok(out)
270}
271
272pub fn memories_for_transcript(
281 conn: &Connection,
282 transcript_id: &str,
283) -> Result<Vec<TranscriptLink>> {
284 let mut stmt = conn
285 .prepare(
286 "SELECT memory_id, transcript_id, span_start, span_end
287 FROM memory_transcript_links
288 WHERE transcript_id = ?1
289 ORDER BY memory_id",
290 )
291 .context("PREPARE memories_for_transcript failed")?;
292 let rows = stmt
293 .query_map(params![transcript_id], row_to_link)
294 .context("QUERY memories_for_transcript failed")?;
295 let mut out = Vec::new();
296 for r in rows {
297 out.push(r.context("decode memories_for_transcript row")?);
298 }
299 Ok(out)
300}
301
302fn row_to_link(row: &rusqlite::Row<'_>) -> rusqlite::Result<TranscriptLink> {
303 Ok(TranscriptLink {
304 memory_id: row.get(0)?,
305 transcript_id: row.get(1)?,
306 span_start: row.get(2)?,
307 span_end: row.get(3)?,
308 })
309}
310
311#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
321pub struct SweepReport {
322 pub archived: usize,
324 pub pruned: usize,
326 pub errors: usize,
331}
332
333pub fn sweep_transcript_lifecycle(
364 conn: &Connection,
365 cfg: &TranscriptsConfig,
366) -> Result<SweepReport> {
367 let now = Utc::now();
368 let mut report = SweepReport::default();
369
370 archive_phase(conn, cfg, now, &mut report)?;
372
373 prune_phase(conn, cfg, now, &mut report)?;
376
377 Ok(report)
378}
379
380fn archive_phase(
383 conn: &Connection,
384 cfg: &TranscriptsConfig,
385 now: DateTime<Utc>,
386 report: &mut SweepReport,
387) -> Result<()> {
388 let live_candidates: Vec<(String, String, String)> = {
393 let mut stmt = conn
394 .prepare(
395 "SELECT id, namespace, created_at
396 FROM memory_transcripts
397 WHERE archived_at IS NULL",
398 )
399 .context("PREPARE archive_phase scan failed")?;
400 let rows = stmt
401 .query_map([], |r| {
402 Ok((
403 r.get::<_, String>(0)?,
404 r.get::<_, String>(1)?,
405 r.get::<_, String>(2)?,
406 ))
407 })
408 .context("QUERY archive_phase scan failed")?;
409 rows.collect::<rusqlite::Result<Vec<_>>>()
410 .context("decode archive_phase rows")?
411 };
412
413 for (id, namespace, created_at) in live_candidates {
414 let resolved = cfg.resolve(&namespace);
415 match should_archive(conn, &id, &created_at, now, resolved) {
416 Ok(true) => {
417 let stamp = now.to_rfc3339();
418 if let Err(e) = conn.execute(
419 "UPDATE memory_transcripts
420 SET archived_at = ?1
421 WHERE id = ?2 AND archived_at IS NULL",
422 params![stamp, id],
423 ) {
424 tracing::warn!(
425 target: LIFECYCLE_TRACE_TARGET,
426 "archive UPDATE failed for transcript {id}: {e}"
427 );
428 report.errors += 1;
429 } else {
430 report.archived += 1;
431 }
432 }
433 Ok(false) => {}
434 Err(e) => {
435 tracing::warn!(
436 target: LIFECYCLE_TRACE_TARGET,
437 "archive eligibility check failed for transcript {id}: {e}"
438 );
439 report.errors += 1;
440 }
441 }
442 }
443 Ok(())
444}
445
446fn prune_phase(
449 conn: &Connection,
450 cfg: &TranscriptsConfig,
451 now: DateTime<Utc>,
452 report: &mut SweepReport,
453) -> Result<()> {
454 let candidates: Vec<(String, String, String)> = {
455 let mut stmt = conn
456 .prepare(
457 "SELECT id, namespace, archived_at
458 FROM memory_transcripts
459 WHERE archived_at IS NOT NULL",
460 )
461 .context("PREPARE prune_phase scan failed")?;
462 let rows = stmt
463 .query_map([], |r| {
464 Ok((
465 r.get::<_, String>(0)?,
466 r.get::<_, String>(1)?,
467 r.get::<_, String>(2)?,
468 ))
469 })
470 .context("QUERY prune_phase scan failed")?;
471 rows.collect::<rusqlite::Result<Vec<_>>>()
472 .context("decode prune_phase rows")?
473 };
474
475 for (id, namespace, archived_at) in candidates {
476 let resolved = cfg.resolve(&namespace);
477 let archived_at = match DateTime::parse_from_rfc3339(&archived_at) {
478 Ok(t) => t.with_timezone(&Utc),
479 Err(e) => {
480 tracing::warn!(
481 target: LIFECYCLE_TRACE_TARGET,
482 "transcript {id} has unparseable archived_at {archived_at:?}: {e}"
483 );
484 report.errors += 1;
485 continue;
486 }
487 };
488 let prune_at = archived_at + Duration::seconds(resolved.archive_grace_secs);
489 if prune_at >= now {
490 continue;
491 }
492 match conn.execute("DELETE FROM memory_transcripts WHERE id = ?1", params![id]) {
493 Ok(n) => report.pruned += n,
494 Err(e) => {
495 tracing::warn!(
496 target: LIFECYCLE_TRACE_TARGET,
497 "prune DELETE failed for transcript {id}: {e}"
498 );
499 report.errors += 1;
500 }
501 }
502 }
503 Ok(())
504}
505
506fn should_archive(
519 conn: &Connection,
520 transcript_id: &str,
521 created_at: &str,
522 now: DateTime<Utc>,
523 resolved: ResolvedTranscriptLifecycle,
524) -> Result<bool> {
525 let created = DateTime::parse_from_rfc3339(created_at)
527 .with_context(|| format!("transcript {transcript_id} has unparseable created_at"))?
528 .with_timezone(&Utc);
529 let archive_at = created + Duration::seconds(resolved.default_ttl_secs);
530 if archive_at >= now {
531 return Ok(false);
532 }
533
534 let now_str = now.to_rfc3339();
538 let alive: i64 = conn
539 .query_row(
540 "SELECT COUNT(*)
541 FROM memory_transcript_links l
542 JOIN memories m ON m.id = l.memory_id
543 WHERE l.transcript_id = ?1
544 AND (m.expires_at IS NULL OR m.expires_at > ?2)",
545 params![transcript_id, now_str],
546 |r| r.get(0),
547 )
548 .with_context(|| format!("alive-memory count failed for transcript {transcript_id}"))?;
549 Ok(alive == 0)
550}
551
552fn zstd_compress(input: &[u8]) -> Result<Vec<u8>> {
553 let mut out = Vec::with_capacity(input.len() / 4 + 64);
554 {
555 let mut encoder = zstd::stream::write::Encoder::new(&mut out, ZSTD_LEVEL)?;
556 encoder.write_all(input)?;
557 encoder.finish()?;
558 }
559 Ok(out)
560}
561
562fn zstd_decompress(input: &[u8]) -> Result<Vec<u8>> {
576 let init_cap = std::cmp::min(input.len() * 4, MAX_DECOMPRESSED_BYTES);
580 let mut out = Vec::with_capacity(init_cap);
581 let mut decoder = zstd::stream::read::Decoder::new(input)?;
582 let mut buf = [0u8; 64 * 1024];
586 loop {
587 let n = decoder.read(&mut buf)?;
588 if n == 0 {
589 break;
590 }
591 if out.len().saturating_add(n) > MAX_DECOMPRESSED_BYTES {
592 tracing::warn!(
593 target: "transcripts.bomb",
594 cap_bytes = MAX_DECOMPRESSED_BYTES,
595 so_far = out.len(),
596 "rejecting transcript: decompressed size would exceed cap"
597 );
598 return Err(anyhow!(
599 "transcript decompression exceeded {} byte cap (decompression bomb defence)",
600 MAX_DECOMPRESSED_BYTES
601 ));
602 }
603 out.extend_from_slice(&buf[..n]);
604 }
605 Ok(out)
606}
607
608#[cfg(test)]
614mod tests {
615 use super::*;
616 use rusqlite::Connection;
617
618 fn fresh_db() -> Connection {
619 crate::db::open(std::path::Path::new(":memory:")).expect("open in-memory db")
620 }
621
622 fn insert_memory(conn: &Connection, id: &str, expires_at: Option<&str>) {
623 let now = Utc::now().to_rfc3339();
624 conn.execute(
629 "INSERT INTO memories (
630 id, tier, namespace, title, content, expires_at, created_at, updated_at
631 ) VALUES (?1, 'short', 'ns', ?2, 'body', ?3, ?4, ?4)",
632 rusqlite::params![id, format!("title-{id}"), expires_at, now],
633 )
634 .expect("insert test memory");
635 }
636
637 #[test]
638 fn store_and_fetch_round_trips_content() {
639 let conn = fresh_db();
640 let body = "hello transcripts";
641 let t = store(&conn, "ns-x", body, None).expect("store ok");
642 assert_eq!(t.namespace, "ns-x");
643 assert!(t.compressed_size > 0);
644 assert_eq!(t.original_size, body.len() as i64);
645 let back = fetch(&conn, &t.id).expect("fetch ok").expect("present");
646 assert_eq!(back, body);
647 }
648
649 #[test]
650 fn store_with_ttl_sets_expires_at() {
651 let conn = fresh_db();
652 let t = store(&conn, "ns-x", "body", Some(Duration::seconds(120))).expect("store ok");
653 assert!(t.expires_at.is_some());
654 }
655
656 #[test]
657 fn fetch_missing_id_returns_none() {
658 let conn = fresh_db();
659 let r = fetch(&conn, "no-such-id").expect("query ok");
660 assert!(r.is_none());
661 }
662
663 #[test]
664 fn fetch_metadata_returns_handle_without_blob() {
665 let conn = fresh_db();
666 let t = store(&conn, "ns-x", "body", None).expect("store ok");
667 let meta = fetch_metadata(&conn, &t.id)
668 .expect("query ok")
669 .expect("present");
670 assert_eq!(meta.id, t.id);
671 assert_eq!(meta.namespace, "ns-x");
672 assert_eq!(meta.original_size, t.original_size);
673 }
674
675 #[test]
676 fn fetch_metadata_missing_returns_none() {
677 let conn = fresh_db();
678 let r = fetch_metadata(&conn, "no-such-id").expect("query ok");
679 assert!(r.is_none());
680 }
681
682 #[test]
683 fn purge_expired_removes_only_past_due_rows() {
684 let conn = fresh_db();
685 let _live = store(&conn, "ns-x", "live", None).expect("store live");
687 let past = store(&conn, "ns-x", "past", None).expect("store past");
689 conn.execute(
690 "UPDATE memory_transcripts SET expires_at = '2000-01-01T00:00:00+00:00' WHERE id = ?1",
691 rusqlite::params![past.id],
692 )
693 .unwrap();
694 let n = purge_expired(&conn).expect("purge ok");
695 assert_eq!(n, 1, "exactly one past-expiry row");
696 assert!(fetch(&conn, &past.id).unwrap().is_none());
697 }
698
699 #[test]
700 fn link_and_transcripts_for_memory_round_trip() {
701 let conn = fresh_db();
702 insert_memory(&conn, "m1", None);
703 let t = store(&conn, "ns-x", "body", None).expect("store ok");
704 link_transcript(&conn, "m1", &t.id, Some(0), Some(4)).expect("link ok");
705 let links = transcripts_for_memory(&conn, "m1").expect("query ok");
706 assert_eq!(links.len(), 1);
707 assert_eq!(links[0].memory_id, "m1");
708 assert_eq!(links[0].transcript_id, t.id);
709 assert_eq!(links[0].span_start, Some(0));
710 assert_eq!(links[0].span_end, Some(4));
711 }
712
713 #[test]
714 fn memories_for_transcript_round_trip() {
715 let conn = fresh_db();
716 insert_memory(&conn, "m1", None);
717 insert_memory(&conn, "m2", None);
718 let t = store(&conn, "ns-x", "body", None).expect("store ok");
719 link_transcript(&conn, "m1", &t.id, None, None).expect("link ok");
720 link_transcript(&conn, "m2", &t.id, None, None).expect("link ok");
721 let mems = memories_for_transcript(&conn, &t.id).expect("query ok");
722 assert_eq!(mems.len(), 2);
723 assert_eq!(mems[0].memory_id, "m1");
725 assert_eq!(mems[1].memory_id, "m2");
726 }
727
728 #[test]
729 fn link_transcript_replaces_on_duplicate_pair() {
730 let conn = fresh_db();
731 insert_memory(&conn, "m1", None);
732 let t = store(&conn, "ns-x", "body", None).expect("store ok");
733 link_transcript(&conn, "m1", &t.id, Some(0), Some(4)).expect("link ok");
734 link_transcript(&conn, "m1", &t.id, Some(2), Some(10)).expect("relink ok");
736 let links = transcripts_for_memory(&conn, "m1").expect("query ok");
737 assert_eq!(links.len(), 1);
738 assert_eq!(links[0].span_start, Some(2));
739 assert_eq!(links[0].span_end, Some(10));
740 }
741
742 #[test]
743 fn sweep_archives_aged_rows_with_no_links() {
744 let conn = fresh_db();
745 let t = store(&conn, "ns-x", "old", None).expect("store ok");
746 conn.execute(
748 "UPDATE memory_transcripts SET created_at = '2000-01-01T00:00:00+00:00' WHERE id = ?1",
749 rusqlite::params![t.id],
750 )
751 .unwrap();
752 let cfg = TranscriptsConfig::default();
753 let report = sweep_transcript_lifecycle(&conn, &cfg).expect("sweep ok");
754 assert!(report.archived >= 1, "expected archive: {report:?}");
755 }
756
757 #[test]
758 fn sweep_prunes_archived_rows_past_grace() {
759 let conn = fresh_db();
760 let t = store(&conn, "ns-x", "old", None).expect("store ok");
761 conn.execute(
763 "UPDATE memory_transcripts SET archived_at = '2000-01-01T00:00:00+00:00' WHERE id = ?1",
764 rusqlite::params![t.id],
765 )
766 .unwrap();
767 let cfg = TranscriptsConfig::default();
768 let report = sweep_transcript_lifecycle(&conn, &cfg).expect("sweep ok");
769 assert_eq!(report.pruned, 1, "expected prune: {report:?}");
770 assert!(fetch_metadata(&conn, &t.id).unwrap().is_none());
771 }
772
773 #[test]
774 fn sweep_skips_live_rows() {
775 let conn = fresh_db();
776 let t = store(&conn, "ns-x", "fresh body", None).expect("store ok");
777 let cfg = TranscriptsConfig::default();
778 let report = sweep_transcript_lifecycle(&conn, &cfg).expect("sweep ok");
779 assert_eq!(report.archived, 0);
781 assert_eq!(report.pruned, 0);
782 assert!(fetch_metadata(&conn, &t.id).unwrap().is_some());
783 }
784
785 #[test]
786 fn sweep_skips_archive_when_memory_still_alive() {
787 let conn = fresh_db();
790 insert_memory(&conn, "m1", None); let t = store(&conn, "ns-x", "body", None).expect("store ok");
792 link_transcript(&conn, "m1", &t.id, None, None).expect("link ok");
793 conn.execute(
795 "UPDATE memory_transcripts SET created_at = '2000-01-01T00:00:00+00:00' WHERE id = ?1",
796 rusqlite::params![t.id],
797 )
798 .unwrap();
799 let cfg = TranscriptsConfig::default();
800 let report = sweep_transcript_lifecycle(&conn, &cfg).expect("sweep ok");
801 assert_eq!(
803 report.archived, 0,
804 "live memory keeps transcript: {report:?}"
805 );
806 }
807
808 #[test]
809 fn sweep_handles_unparseable_archived_at() {
810 let conn = fresh_db();
813 let t = store(&conn, "ns-x", "body", None).expect("store ok");
814 conn.execute(
815 "UPDATE memory_transcripts SET archived_at = 'not-a-date' WHERE id = ?1",
816 rusqlite::params![t.id],
817 )
818 .unwrap();
819 let cfg = TranscriptsConfig::default();
820 let report = sweep_transcript_lifecycle(&conn, &cfg).expect("sweep ok");
821 assert!(report.errors >= 1, "expected error tally: {report:?}");
822 assert_eq!(report.pruned, 0, "unparseable row must not be pruned");
823 }
824
825 #[test]
826 fn should_archive_returns_false_when_within_ttl() {
827 let conn = fresh_db();
828 let t = store(&conn, "ns-x", "fresh", None).expect("store ok");
829 let cfg = TranscriptsConfig::default();
832 let resolved = cfg.resolve("ns-x");
833 let res = super::should_archive(&conn, &t.id, &t.created_at, Utc::now(), resolved)
834 .expect("should_archive ok");
835 assert!(!res, "fresh row must not be archive-eligible");
836 }
837
838 #[test]
839 fn sweep_archive_phase_tallies_should_archive_failure() {
840 let conn = fresh_db();
844 let t = store(&conn, "ns-x", "body", None).expect("store ok");
845 conn.execute(
846 "UPDATE memory_transcripts SET created_at = 'not-a-date' WHERE id = ?1",
847 rusqlite::params![t.id],
848 )
849 .unwrap();
850 let cfg = TranscriptsConfig::default();
851 let report = sweep_transcript_lifecycle(&conn, &cfg).expect("sweep ok");
852 assert!(report.errors >= 1, "expected error tally: {report:?}");
853 assert_eq!(report.archived, 0);
854 }
855
856 #[test]
857 fn should_archive_propagates_unparseable_created_at() {
858 let conn = fresh_db();
859 let cfg = TranscriptsConfig::default();
860 let resolved = cfg.resolve("ns-x");
861 let err =
862 super::should_archive(&conn, "id", "not-a-date", Utc::now(), resolved).unwrap_err();
863 let msg = format!("{err:#}");
864 assert!(msg.contains("unparseable created_at"), "got: {msg}");
865 }
866
867 #[test]
868 fn zstd_round_trip_decodes_to_original() {
869 let original = b"some non-trivial bytes \x00\x01\x02 with binary";
870 let blob = super::zstd_compress(original).expect("compress");
871 let back = super::zstd_decompress(&blob).expect("decompress");
872 assert_eq!(back, original);
873 }
874
875 #[test]
876 fn zstd_decompress_rejects_oversized_blob() {
877 let big = vec![0u8; super::MAX_DECOMPRESSED_BYTES + 1024];
881 let blob = super::zstd_compress(&big).expect("compress");
882 let err = super::zstd_decompress(&blob).unwrap_err();
883 let msg = format!("{err}");
884 assert!(msg.contains("decompression bomb"), "got: {msg}");
885 }
886
887 #[test]
888 fn fetch_invalid_utf8_blob_returns_error() {
889 let conn = fresh_db();
893 let t = store(&conn, "ns-x", "placeholder", None).expect("store");
894 let bad_blob = super::zstd_compress(&[0xFF, 0xFE, 0xFD]).expect("compress bad utf8");
895 conn.execute(
896 "UPDATE memory_transcripts SET content_blob = ?1 WHERE id = ?2",
897 rusqlite::params![bad_blob, t.id],
898 )
899 .unwrap();
900 let err = fetch(&conn, &t.id).unwrap_err();
901 let msg = format!("{err:#}");
902 assert!(msg.contains("UTF-8") || msg.contains("utf"), "got: {msg}");
903 }
904}