1use std::collections::BTreeMap;
4
5use anyhow::{Context, Result};
6use rusqlite::Connection;
7use serde::Serialize;
8use sha2::{Digest, Sha256};
9
10use super::visibility::classify_memory;
11
12pub const MEMORY_VISIBILITY_INVENTORY_SCHEMA_VERSION: u32 = 1;
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
15pub struct MemoryVisibilityInventory {
16 pub schema_version: u32,
17 pub runtime_version: String,
18 pub database_schema_version: i64,
19 pub sqlite_user_version: i64,
20 pub as_of_epoch: i64,
21 pub snapshot_memory_count: u64,
22 pub snapshot_max_memory_id: Option<i64>,
23 pub snapshot_max_updated_at_epoch: Option<i64>,
24 pub classification_counts: BTreeMap<String, u64>,
25 pub reason_counts: BTreeMap<String, u64>,
26 pub inventory_sha256: String,
27}
28
29pub fn build_memory_visibility_inventory(
30 conn: &Connection,
31 as_of_epoch: i64,
32) -> Result<MemoryVisibilityInventory> {
33 let snapshot = conn.unchecked_transaction()?;
34 let report = build_memory_visibility_inventory_in_snapshot(&snapshot, as_of_epoch)?;
35 snapshot.commit()?;
36 Ok(report)
37}
38
39fn build_memory_visibility_inventory_in_snapshot(
40 conn: &Connection,
41 as_of_epoch: i64,
42) -> Result<MemoryVisibilityInventory> {
43 let sqlite_user_version = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?;
44 let database_schema_version = logical_schema_version(conn, sqlite_user_version)?;
45 let (snapshot_memory_count, snapshot_max_memory_id, snapshot_max_updated_at_epoch) = conn
46 .query_row(
47 "SELECT COUNT(*), MAX(id), MAX(updated_at_epoch) FROM memories",
48 [],
49 |row| Ok((row.get::<_, u64>(0)?, row.get(1)?, row.get(2)?)),
50 )
51 .context("bind memory visibility inventory snapshot")?;
52 let mut statement = conn.prepare("SELECT id FROM memories ORDER BY id")?;
53 let ids = statement
54 .query_map([], |row| row.get::<_, i64>(0))?
55 .collect::<std::result::Result<Vec<_>, _>>()?;
56 let mut classification_counts = BTreeMap::new();
57 let mut reason_counts = BTreeMap::new();
58 for id in ids {
59 let visibility = classify_memory(conn, id, as_of_epoch)?;
60 *classification_counts
61 .entry(visibility.classification.as_str().to_string())
62 .or_insert(0) += 1;
63 *reason_counts
64 .entry(visibility.reason.as_str().to_string())
65 .or_insert(0) += 1;
66 }
67 let mut report = MemoryVisibilityInventory {
68 schema_version: MEMORY_VISIBILITY_INVENTORY_SCHEMA_VERSION,
69 runtime_version: env!("CARGO_PKG_VERSION").to_string(),
70 database_schema_version,
71 sqlite_user_version,
72 as_of_epoch,
73 snapshot_memory_count,
74 snapshot_max_memory_id,
75 snapshot_max_updated_at_epoch,
76 classification_counts,
77 reason_counts,
78 inventory_sha256: String::new(),
79 };
80 report.inventory_sha256 = format!("{:x}", Sha256::digest(serde_json::to_vec(&report)?));
81 Ok(report)
82}
83
84fn logical_schema_version(conn: &Connection, sqlite_user_version: i64) -> Result<i64> {
85 let has_ledger: bool = conn.query_row(
86 "SELECT EXISTS(SELECT 1 FROM sqlite_schema
87 WHERE type='table' AND name='_schema_migrations')",
88 [],
89 |row| row.get(0),
90 )?;
91 if !has_ledger {
92 return Ok(sqlite_user_version);
93 }
94 conn.query_row(
95 "SELECT COALESCE(MAX(version), 0) FROM _schema_migrations",
96 [],
97 |row| row.get(0),
98 )
99 .context("read logical schema version")
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105 use std::time::{SystemTime, UNIX_EPOCH};
106
107 #[test]
108 fn inventory_is_deterministic_content_free_and_snapshot_bound() -> Result<()> {
109 let conn = Connection::open_in_memory()?;
110 crate::memory::tests_helper::setup_memory_schema(&conn);
111 conn.execute(
112 "INSERT INTO memories
113 (id, project, title, content, memory_type, created_at_epoch,
114 updated_at_epoch, status, source_trust_class)
115 VALUES (1, '/repo', 'SECRET_TITLE', 'SECRET_BODY', 'bugfix',
116 10, 11, 'active', 'local_tool_output')",
117 [],
118 )?;
119 conn.execute(
120 "INSERT INTO memories
121 (id, project, title, content, memory_type, created_at_epoch,
122 updated_at_epoch, status, source_trust_class)
123 VALUES (2, '/repo', 'manual', 'safe', 'bugfix',
124 12, 13, 'active', 'user_prompt')",
125 [],
126 )?;
127 let first = build_memory_visibility_inventory(&conn, 100)?;
128 let second = build_memory_visibility_inventory(&conn, 100)?;
129 assert_eq!(first, second);
130 assert_eq!(first.snapshot_memory_count, 2);
131 assert_eq!(first.classification_counts["legacy_unverified"], 1);
132 assert_eq!(first.classification_counts["current"], 1);
133 let json = serde_json::to_string(&first)?;
134 assert!(!json.contains("SECRET_TITLE"));
135 assert!(!json.contains("SECRET_BODY"));
136
137 conn.execute("UPDATE memories SET updated_at_epoch = 14 WHERE id = 2", [])?;
138 let changed = build_memory_visibility_inventory(&conn, 100)?;
139 assert_ne!(first.inventory_sha256, changed.inventory_sha256);
140 Ok(())
141 }
142
143 #[test]
144 fn inventory_snapshot_stays_consistent_after_wal_writer_commit() -> Result<()> {
145 let path = std::env::temp_dir().join(format!(
146 "remem-g2-inventory-{}-{}.db",
147 std::process::id(),
148 SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos()
149 ));
150 let result = (|| -> Result<()> {
151 let reader = Connection::open(&path)?;
152 reader.execute_batch("PRAGMA journal_mode = WAL;")?;
153 crate::memory::tests_helper::setup_memory_schema(&reader);
154 reader.execute(
155 "INSERT INTO memories
156 (id, project, title, content, memory_type, created_at_epoch,
157 updated_at_epoch, status, source_trust_class)
158 VALUES (1, '/repo', 'before', 'body', 'bugfix', 10, 10,
159 'active', 'local_tool_output')",
160 [],
161 )?;
162
163 let writer = Connection::open(&path)?;
164 writer.execute_batch("PRAGMA journal_mode = WAL;")?;
165 let snapshot = reader.unchecked_transaction()?;
166 let _: i64 =
167 snapshot.query_row("SELECT COUNT(*) FROM memories", [], |row| row.get(0))?;
168 writer.execute(
169 "INSERT INTO memories
170 (id, project, title, content, memory_type, created_at_epoch,
171 updated_at_epoch, status, source_trust_class)
172 VALUES (2, '/repo', 'after', 'body', 'bugfix', 20, 20,
173 'active', 'local_tool_output')",
174 [],
175 )?;
176
177 let report = build_memory_visibility_inventory_in_snapshot(&snapshot, 100)?;
178 assert_eq!(report.snapshot_memory_count, 1);
179 assert_eq!(report.snapshot_max_memory_id, Some(1));
180 assert_eq!(report.classification_counts["legacy_unverified"], 1);
181 snapshot.commit()?;
182 Ok(())
183 })();
184 let _ = std::fs::remove_file(&path);
185 result
186 }
187}