1use std::fs;
10use std::path::{Path, PathBuf};
11use std::sync::{Mutex, MutexGuard, PoisonError};
12
13use anyhow::{Context, Result};
14use recall_wire::{AdminTotals, File, ProjectStats};
15use rusqlite::{Connection, OptionalExtension};
16
17use crate::now;
18
19const SCHEMA: &str = "
21 CREATE TABLE IF NOT EXISTS memory_files (
22 project_key TEXT NOT NULL,
23 file_path TEXT NOT NULL,
24 content TEXT NOT NULL,
25 source_env TEXT,
26 updated_at TEXT NOT NULL,
27 deleted INTEGER NOT NULL DEFAULT 0,
28 PRIMARY KEY (project_key, file_path)
29 );
30";
31
32#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct Existing {
36 pub content: String,
39 pub deleted: bool,
41}
42
43pub struct Store {
45 conn: Mutex<Connection>,
49}
50
51impl Store {
52 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
54 let path = path.as_ref();
55 if let Some(dir) = path.parent() {
56 if !dir.as_os_str().is_empty() {
57 fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
58 }
59 }
60 let conn = Connection::open(path).with_context(|| format!("opening {}", path.display()))?;
61 let store = Self {
62 conn: Mutex::new(conn),
63 };
64 store.migrate()?;
65 Ok(store)
66 }
67
68 pub fn open_in_memory() -> Result<Self> {
70 let store = Self {
71 conn: Mutex::new(Connection::open_in_memory()?),
72 };
73 store.migrate()?;
74 Ok(store)
75 }
76
77 fn lock(&self) -> MutexGuard<'_, Connection> {
81 self.conn.lock().unwrap_or_else(PoisonError::into_inner)
82 }
83
84 fn migrate(&self) -> Result<()> {
85 let conn = self.lock();
86 conn.execute_batch(SCHEMA)?;
87
88 let has_deleted = {
91 let mut stmt = conn.prepare("PRAGMA table_info(memory_files)")?;
92 let mut rows = stmt.query([])?;
93 let mut found = false;
94 while let Some(row) = rows.next()? {
95 if row.get::<_, String>(1)? == "deleted" {
96 found = true;
97 }
98 }
99 found
100 };
101 if !has_deleted {
102 conn.execute(
103 "ALTER TABLE memory_files ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0",
104 [],
105 )?;
106 }
107 Ok(())
108 }
109
110 pub fn get(&self, project_key: &str, file_path: &str) -> Result<Option<Existing>> {
112 let conn = self.lock();
113 let row = conn
114 .query_row(
115 "SELECT content, deleted FROM memory_files WHERE project_key = ?1 AND file_path = ?2",
116 (project_key, file_path),
117 |r| {
118 Ok(Existing {
119 content: r.get(0)?,
120 deleted: r.get::<_, i64>(1)? != 0,
121 })
122 },
123 )
124 .optional()?;
125 Ok(row)
126 }
127
128 pub fn upsert(
130 &self,
131 project_key: &str,
132 file_path: &str,
133 content: &str,
134 source_env: &str,
135 updated_at: &str,
136 ) -> Result<()> {
137 let conn = self.lock();
138 conn.execute(
139 "INSERT INTO memory_files (project_key, file_path, content, source_env, updated_at, deleted)
140 VALUES (?1, ?2, ?3, ?4, ?5, 0)
141 ON CONFLICT(project_key, file_path) DO UPDATE SET
142 content = excluded.content,
143 source_env = excluded.source_env,
144 updated_at = excluded.updated_at,
145 deleted = 0",
146 (project_key, file_path, content, nullable(source_env), updated_at),
147 )?;
148 Ok(())
149 }
150
151 pub fn tombstone(
156 &self,
157 project_key: &str,
158 file_path: &str,
159 source_env: &str,
160 updated_at: &str,
161 ) -> Result<()> {
162 let conn = self.lock();
163 conn.execute(
164 "INSERT INTO memory_files (project_key, file_path, content, source_env, updated_at, deleted)
165 VALUES (?1, ?2, '', ?3, ?4, 1)
166 ON CONFLICT(project_key, file_path) DO UPDATE SET
167 source_env = excluded.source_env,
168 updated_at = excluded.updated_at,
169 deleted = 1",
170 (project_key, file_path, nullable(source_env), updated_at),
171 )?;
172 Ok(())
173 }
174
175 pub fn list(&self, project_key: &str) -> Result<Vec<File>> {
179 let conn = self.lock();
180 let mut stmt = conn.prepare(
181 "SELECT file_path, content, COALESCE(source_env, ''), updated_at, deleted
182 FROM memory_files WHERE project_key = ?1 ORDER BY file_path",
183 )?;
184 let rows = stmt.query_map((project_key,), |r| {
185 let content: String = r.get(1)?;
186 let deleted = r.get::<_, i64>(4)? != 0;
187 Ok(File {
188 file_path: r.get(0)?,
189 content: if deleted { None } else { Some(content) },
190 source_env: r.get(2)?,
191 updated_at: r.get(3)?,
192 deleted,
193 })
194 })?;
195 let mut files = Vec::new();
196 for row in rows {
197 files.push(row?);
198 }
199 Ok(files)
200 }
201
202 pub fn last_sync_at(&self) -> Result<String> {
205 let conn = self.lock();
206 let v: Option<String> =
207 conn.query_row("SELECT MAX(updated_at) FROM memory_files", [], |r| r.get(0))?;
208 Ok(v.unwrap_or_default())
209 }
210
211 pub fn admin_stats(&self) -> Result<(Vec<ProjectStats>, AdminTotals)> {
213 let conn = self.lock();
214
215 let mut projects = Vec::new();
216 let mut totals = AdminTotals::default();
217 {
218 let mut stmt = conn.prepare(
219 "SELECT project_key,
220 SUM(CASE WHEN deleted = 0 THEN 1 ELSE 0 END),
221 SUM(CASE WHEN deleted = 1 THEN 1 ELSE 0 END),
222 MAX(updated_at)
223 FROM memory_files GROUP BY project_key ORDER BY MAX(updated_at) DESC",
224 )?;
225 let rows = stmt.query_map([], |r| {
226 Ok(ProjectStats {
227 project_key: r.get(0)?,
228 file_count: r.get(1)?,
229 deleted_count: r.get(2)?,
230 sources: Vec::new(),
231 last_updated_at: r.get::<_, Option<String>>(3)?.unwrap_or_default(),
232 })
233 })?;
234 for row in rows {
235 let p = row?;
236 totals.file_count += p.file_count;
237 totals.deleted_count += p.deleted_count;
238 projects.push(p);
239 }
240 }
241 totals.project_count = projects.len() as i64;
242
243 {
248 let mut stmt = conn.prepare(
249 "SELECT DISTINCT project_key, source_env FROM memory_files WHERE source_env IS NOT NULL",
250 )?;
251 let rows =
252 stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?;
253 for row in rows {
254 let (key, src) = row?;
255 if src.is_empty() {
256 continue;
257 }
258 if let Some(p) = projects.iter_mut().find(|p| p.project_key == key) {
259 p.sources.push(src);
260 }
261 }
262 }
263 for p in &mut projects {
264 p.sources.sort();
265 }
266 Ok((projects, totals))
267 }
268
269 pub fn backup(&self, dir: impl AsRef<Path>, keep: usize) -> Result<PathBuf> {
273 let dir = dir.as_ref();
274 fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
275
276 let stamp = now().replace([':', '.'], "-");
283 let dest = dir.join(format!("recall-{stamp}.db"));
284 let dest_str = dest
285 .to_str()
286 .context("backup path is not valid UTF-8")?
287 .to_owned();
288
289 {
290 let conn = self.lock();
291 conn.execute("VACUUM INTO ?1", (&dest_str,))
292 .with_context(|| format!("VACUUM INTO {dest_str}"))?;
293 }
294
295 let mut snapshots: Vec<PathBuf> = fs::read_dir(dir)?
296 .filter_map(|e| e.ok())
297 .map(|e| e.path())
298 .filter(|p| {
299 p.file_name()
300 .and_then(|n| n.to_str())
301 .is_some_and(|n| n.starts_with("recall-") && n.ends_with(".db"))
302 })
303 .collect();
304 snapshots.sort();
305 for stale in snapshots.iter().take(snapshots.len().saturating_sub(keep)) {
306 let _ = fs::remove_file(stale);
307 }
308 Ok(dest)
309 }
310}
311
312fn nullable(s: &str) -> Option<&str> {
315 if s.is_empty() {
316 None
317 } else {
318 Some(s)
319 }
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325
326 fn store() -> Store {
327 Store::open_in_memory().unwrap()
328 }
329
330 #[test]
331 fn upsert_get_and_list_round_trip() {
332 let st = store();
333 st.upsert(
334 "acme/app",
335 "MEMORY.md",
336 "hello",
337 "laptop",
338 "2026-01-01T00:00:00.000Z",
339 )
340 .unwrap();
341
342 let got = st.get("acme/app", "MEMORY.md").unwrap().unwrap();
343 assert_eq!(got.content, "hello");
344 assert!(!got.deleted);
345
346 let files = st.list("acme/app").unwrap();
347 assert_eq!(files.len(), 1);
348 assert_eq!(files[0].content.as_deref(), Some("hello"));
349 assert_eq!(files[0].source_env, "laptop");
350 assert!(st.get("acme/app", "missing.md").unwrap().is_none());
351 }
352
353 #[test]
356 fn tombstone_preserves_content_but_list_withholds_it() {
357 let st = store();
358 st.upsert(
359 "acme/app",
360 "gone.md",
361 "secret",
362 "laptop",
363 "2026-01-01T00:00:00.000Z",
364 )
365 .unwrap();
366 st.tombstone("acme/app", "gone.md", "laptop", "2026-01-01T00:00:01.000Z")
367 .unwrap();
368
369 let row = st.get("acme/app", "gone.md").unwrap().unwrap();
370 assert_eq!(row.content, "secret", "content must stay recoverable");
371 assert!(row.deleted);
372
373 let files = st.list("acme/app").unwrap();
374 assert_eq!(
375 files.len(),
376 1,
377 "tombstones are listed so clients can delete locally"
378 );
379 assert!(files[0].deleted);
380 assert_eq!(files[0].content, None, "a pull must not resurrect it");
381 }
382
383 #[test]
385 fn upsert_clears_a_tombstone() {
386 let st = store();
387 st.tombstone("acme/app", "f.md", "laptop", "2026-01-01T00:00:00.000Z")
388 .unwrap();
389 st.upsert(
390 "acme/app",
391 "f.md",
392 "back",
393 "laptop",
394 "2026-01-01T00:00:01.000Z",
395 )
396 .unwrap();
397 let row = st.get("acme/app", "f.md").unwrap().unwrap();
398 assert!(!row.deleted);
399 assert_eq!(row.content, "back");
400 }
401
402 #[test]
403 fn last_sync_at_is_empty_on_a_fresh_database() {
404 assert_eq!(store().last_sync_at().unwrap(), "");
405 }
406
407 #[test]
410 fn admin_stats_keeps_commas_inside_a_source_env() {
411 let st = store();
412 st.upsert(
413 "acme/app",
414 "a.md",
415 "x",
416 "laptop,evil",
417 "2026-01-01T00:00:00.000Z",
418 )
419 .unwrap();
420 let (projects, _) = st.admin_stats().unwrap();
421 assert_eq!(projects[0].sources, vec!["laptop,evil".to_string()]);
422 }
423
424 #[test]
427 fn migrates_a_database_that_predates_tombstones() {
428 let dir = tempfile::tempdir().unwrap();
429 let path = dir.path().join("old.db");
430 {
431 let conn = Connection::open(&path).unwrap();
432 conn.execute_batch(
433 "CREATE TABLE memory_files (
434 project_key TEXT NOT NULL,
435 file_path TEXT NOT NULL,
436 content TEXT NOT NULL,
437 source_env TEXT,
438 updated_at TEXT NOT NULL,
439 PRIMARY KEY (project_key, file_path)
440 );
441 INSERT INTO memory_files VALUES ('acme/app','old.md','kept','node-era','2026-09-03T21:49:55.191Z');",
442 )
443 .unwrap();
444 }
445 let st = Store::open(&path).unwrap();
446 let files = st.list("acme/app").unwrap();
447 assert_eq!(files.len(), 1);
448 assert_eq!(files[0].content.as_deref(), Some("kept"));
449 assert!(!files[0].deleted);
450 }
451
452 #[test]
453 fn backup_names_carry_milliseconds() {
454 let dir = tempfile::tempdir().unwrap();
455 let st = store();
456 let dest = st.backup(dir.path(), 7).unwrap();
457 let name = dest.file_name().unwrap().to_str().unwrap();
458 assert!(
460 name.starts_with("recall-") && name.ends_with("Z.db"),
461 "got {name}"
462 );
463 let stamp = &name["recall-".len()..name.len() - ".db".len()];
464 assert_eq!(stamp.len(), 24, "got {stamp}");
465 assert!(
468 stamp[20..23].chars().all(|c| c.is_ascii_digit()),
469 "no millisecond field in {stamp}"
470 );
471 }
472}