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
19mod devices;
20
21pub use devices::{
22 plain_name, Created, Decision, Inserted, NewAuthkey, NewDevice, NewEnrollment, Poll, Waiting,
23};
24
25const SCHEMA: &str = "
27 CREATE TABLE IF NOT EXISTS memory_files (
28 project_key TEXT NOT NULL,
29 file_path TEXT NOT NULL,
30 content TEXT NOT NULL,
31 source_env TEXT,
32 updated_at TEXT NOT NULL,
33 deleted INTEGER NOT NULL DEFAULT 0,
34 PRIMARY KEY (project_key, file_path)
35 );
36";
37
38#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct Existing {
42 pub content: String,
45 pub deleted: bool,
47}
48
49pub struct Store {
51 conn: Mutex<Connection>,
55}
56
57impl Store {
58 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
60 let path = path.as_ref();
61 if let Some(dir) = path.parent() {
62 if !dir.as_os_str().is_empty() {
63 fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
64 }
65 }
66 let conn = Connection::open(path).with_context(|| format!("opening {}", path.display()))?;
67 let store = Self {
68 conn: Mutex::new(conn),
69 };
70 store.migrate()?;
71 Ok(store)
72 }
73
74 pub fn open_in_memory() -> Result<Self> {
76 let store = Self {
77 conn: Mutex::new(Connection::open_in_memory()?),
78 };
79 store.migrate()?;
80 Ok(store)
81 }
82
83 fn lock(&self) -> MutexGuard<'_, Connection> {
87 self.conn.lock().unwrap_or_else(PoisonError::into_inner)
88 }
89
90 fn migrate(&self) -> Result<()> {
91 let conn = self.lock();
92 conn.execute_batch(SCHEMA)?;
93
94 let has_deleted = {
97 let mut stmt = conn.prepare("PRAGMA table_info(memory_files)")?;
98 let mut rows = stmt.query([])?;
99 let mut found = false;
100 while let Some(row) = rows.next()? {
101 if row.get::<_, String>(1)? == "deleted" {
102 found = true;
103 }
104 }
105 found
106 };
107 if !has_deleted {
108 conn.execute(
109 "ALTER TABLE memory_files ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0",
110 [],
111 )?;
112 }
113
114 conn.execute_batch(devices::SCHEMA)?;
119 Ok(())
120 }
121
122 pub fn get(&self, project_key: &str, file_path: &str) -> Result<Option<Existing>> {
124 let conn = self.lock();
125 let row = conn
126 .query_row(
127 "SELECT content, deleted FROM memory_files WHERE project_key = ?1 AND file_path = ?2",
128 (project_key, file_path),
129 |r| {
130 Ok(Existing {
131 content: r.get(0)?,
132 deleted: r.get::<_, i64>(1)? != 0,
133 })
134 },
135 )
136 .optional()?;
137 Ok(row)
138 }
139
140 pub fn upsert(
142 &self,
143 project_key: &str,
144 file_path: &str,
145 content: &str,
146 source_env: &str,
147 updated_at: &str,
148 ) -> Result<()> {
149 let conn = self.lock();
150 conn.execute(
151 "INSERT INTO memory_files (project_key, file_path, content, source_env, updated_at, deleted)
152 VALUES (?1, ?2, ?3, ?4, ?5, 0)
153 ON CONFLICT(project_key, file_path) DO UPDATE SET
154 content = excluded.content,
155 source_env = excluded.source_env,
156 updated_at = excluded.updated_at,
157 deleted = 0",
158 (project_key, file_path, content, nullable(source_env), updated_at),
159 )?;
160 Ok(())
161 }
162
163 pub fn tombstone(
168 &self,
169 project_key: &str,
170 file_path: &str,
171 source_env: &str,
172 updated_at: &str,
173 ) -> Result<()> {
174 let conn = self.lock();
175 conn.execute(
176 "INSERT INTO memory_files (project_key, file_path, content, source_env, updated_at, deleted)
177 VALUES (?1, ?2, '', ?3, ?4, 1)
178 ON CONFLICT(project_key, file_path) DO UPDATE SET
179 source_env = excluded.source_env,
180 updated_at = excluded.updated_at,
181 deleted = 1",
182 (project_key, file_path, nullable(source_env), updated_at),
183 )?;
184 Ok(())
185 }
186
187 pub fn list(&self, project_key: &str) -> Result<Vec<File>> {
191 let conn = self.lock();
192 let mut stmt = conn.prepare(
193 "SELECT file_path, content, COALESCE(source_env, ''), updated_at, deleted
194 FROM memory_files WHERE project_key = ?1 ORDER BY file_path",
195 )?;
196 let rows = stmt.query_map((project_key,), |r| {
197 let content: String = r.get(1)?;
198 let deleted = r.get::<_, i64>(4)? != 0;
199 Ok(File {
200 file_path: r.get(0)?,
201 content: if deleted { None } else { Some(content) },
202 source_env: r.get(2)?,
203 updated_at: r.get(3)?,
204 deleted,
205 })
206 })?;
207 let mut files = Vec::new();
208 for row in rows {
209 files.push(row?);
210 }
211 Ok(files)
212 }
213
214 pub fn last_sync_at(&self) -> Result<String> {
217 let conn = self.lock();
218 let v: Option<String> =
219 conn.query_row("SELECT MAX(updated_at) FROM memory_files", [], |r| r.get(0))?;
220 Ok(v.unwrap_or_default())
221 }
222
223 pub fn admin_stats(&self) -> Result<(Vec<ProjectStats>, AdminTotals)> {
225 let conn = self.lock();
226
227 let mut projects = Vec::new();
228 let mut totals = AdminTotals::default();
229 {
230 let mut stmt = conn.prepare(
231 "SELECT project_key,
232 SUM(CASE WHEN deleted = 0 THEN 1 ELSE 0 END),
233 SUM(CASE WHEN deleted = 1 THEN 1 ELSE 0 END),
234 MAX(updated_at)
235 FROM memory_files GROUP BY project_key ORDER BY MAX(updated_at) DESC",
236 )?;
237 let rows = stmt.query_map([], |r| {
238 Ok(ProjectStats {
239 project_key: r.get(0)?,
240 file_count: r.get(1)?,
241 deleted_count: r.get(2)?,
242 sources: Vec::new(),
243 last_updated_at: r.get::<_, Option<String>>(3)?.unwrap_or_default(),
244 })
245 })?;
246 for row in rows {
247 let p = row?;
248 totals.file_count += p.file_count;
249 totals.deleted_count += p.deleted_count;
250 projects.push(p);
251 }
252 }
253 totals.project_count = projects.len() as i64;
254
255 {
260 let mut stmt = conn.prepare(
261 "SELECT DISTINCT project_key, source_env FROM memory_files WHERE source_env IS NOT NULL",
262 )?;
263 let rows =
264 stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?;
265 for row in rows {
266 let (key, src) = row?;
267 if src.is_empty() {
268 continue;
269 }
270 if let Some(p) = projects.iter_mut().find(|p| p.project_key == key) {
271 p.sources.push(src);
272 }
273 }
274 }
275 for p in &mut projects {
276 p.sources.sort();
277 }
278 Ok((projects, totals))
279 }
280
281 pub fn backup(&self, dir: impl AsRef<Path>, keep: usize) -> Result<PathBuf> {
285 let dir = dir.as_ref();
286 fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
287
288 let stamp = now().replace([':', '.'], "-");
295 let dest = dir.join(format!("recall-{stamp}.db"));
296 let dest_str = dest
297 .to_str()
298 .context("backup path is not valid UTF-8")?
299 .to_owned();
300
301 let existed = dest.exists();
304 let vacuumed = {
305 let conn = self.lock();
306 conn.execute("VACUUM INTO ?1", (&dest_str,))
307 .with_context(|| format!("VACUUM INTO {dest_str}"))
308 };
309 if let Err(err) = vacuumed {
310 if !existed {
316 let _ = fs::remove_file(&dest);
317 }
318 return Err(err);
319 }
320
321 let mut snapshots: Vec<PathBuf> = fs::read_dir(dir)?
322 .filter_map(|e| e.ok())
323 .map(|e| e.path())
324 .filter(|p| {
325 p.file_name()
326 .and_then(|n| n.to_str())
327 .is_some_and(|n| n.starts_with("recall-") && n.ends_with(".db"))
328 })
329 .collect();
330 snapshots.sort();
331 for stale in snapshots.iter().take(snapshots.len().saturating_sub(keep)) {
332 let _ = fs::remove_file(stale);
333 }
334 Ok(dest)
335 }
336}
337
338fn nullable(s: &str) -> Option<&str> {
341 if s.is_empty() {
342 None
343 } else {
344 Some(s)
345 }
346}
347
348pub(crate) mod admin;
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357
358 fn store() -> Store {
359 Store::open_in_memory().unwrap()
360 }
361
362 #[test]
363 fn upsert_get_and_list_round_trip() {
364 let st = store();
365 st.upsert(
366 "acme/app",
367 "MEMORY.md",
368 "hello",
369 "laptop",
370 "2026-01-01T00:00:00.000Z",
371 )
372 .unwrap();
373
374 let got = st.get("acme/app", "MEMORY.md").unwrap().unwrap();
375 assert_eq!(got.content, "hello");
376 assert!(!got.deleted);
377
378 let files = st.list("acme/app").unwrap();
379 assert_eq!(files.len(), 1);
380 assert_eq!(files[0].content.as_deref(), Some("hello"));
381 assert_eq!(files[0].source_env, "laptop");
382 assert!(st.get("acme/app", "missing.md").unwrap().is_none());
383 }
384
385 #[test]
388 fn tombstone_preserves_content_but_list_withholds_it() {
389 let st = store();
390 st.upsert(
391 "acme/app",
392 "gone.md",
393 "secret",
394 "laptop",
395 "2026-01-01T00:00:00.000Z",
396 )
397 .unwrap();
398 st.tombstone("acme/app", "gone.md", "laptop", "2026-01-01T00:00:01.000Z")
399 .unwrap();
400
401 let row = st.get("acme/app", "gone.md").unwrap().unwrap();
402 assert_eq!(row.content, "secret", "content must stay recoverable");
403 assert!(row.deleted);
404
405 let files = st.list("acme/app").unwrap();
406 assert_eq!(
407 files.len(),
408 1,
409 "tombstones are listed so clients can delete locally"
410 );
411 assert!(files[0].deleted);
412 assert_eq!(files[0].content, None, "a pull must not resurrect it");
413 }
414
415 #[test]
417 fn upsert_clears_a_tombstone() {
418 let st = store();
419 st.tombstone("acme/app", "f.md", "laptop", "2026-01-01T00:00:00.000Z")
420 .unwrap();
421 st.upsert(
422 "acme/app",
423 "f.md",
424 "back",
425 "laptop",
426 "2026-01-01T00:00:01.000Z",
427 )
428 .unwrap();
429 let row = st.get("acme/app", "f.md").unwrap().unwrap();
430 assert!(!row.deleted);
431 assert_eq!(row.content, "back");
432 }
433
434 #[test]
435 fn last_sync_at_is_empty_on_a_fresh_database() {
436 assert_eq!(store().last_sync_at().unwrap(), "");
437 }
438
439 #[test]
442 fn admin_stats_keeps_commas_inside_a_source_env() {
443 let st = store();
444 st.upsert(
445 "acme/app",
446 "a.md",
447 "x",
448 "laptop,evil",
449 "2026-01-01T00:00:00.000Z",
450 )
451 .unwrap();
452 let (projects, _) = st.admin_stats().unwrap();
453 assert_eq!(projects[0].sources, vec!["laptop,evil".to_string()]);
454 }
455
456 #[test]
459 fn migrates_a_database_that_predates_tombstones() {
460 let dir = tempfile::tempdir().unwrap();
461 let path = dir.path().join("old.db");
462 {
463 let conn = Connection::open(&path).unwrap();
464 conn.execute_batch(
465 "CREATE TABLE memory_files (
466 project_key TEXT NOT NULL,
467 file_path TEXT NOT NULL,
468 content TEXT NOT NULL,
469 source_env TEXT,
470 updated_at TEXT NOT NULL,
471 PRIMARY KEY (project_key, file_path)
472 );
473 INSERT INTO memory_files VALUES ('acme/app','old.md','kept','node-era','2026-09-03T21:49:55.191Z');",
474 )
475 .unwrap();
476 }
477 let st = Store::open(&path).unwrap();
478 let files = st.list("acme/app").unwrap();
479 assert_eq!(files.len(), 1);
480 assert_eq!(files[0].content.as_deref(), Some("kept"));
481 assert!(!files[0].deleted);
482 }
483
484 #[test]
485 fn backup_names_carry_milliseconds() {
486 let dir = tempfile::tempdir().unwrap();
487 let st = store();
488 let dest = st.backup(dir.path(), 7).unwrap();
489 let name = dest.file_name().unwrap().to_str().unwrap();
490 assert!(
492 name.starts_with("recall-") && name.ends_with("Z.db"),
493 "got {name}"
494 );
495 let stamp = &name["recall-".len()..name.len() - ".db".len()];
496 assert_eq!(stamp.len(), 24, "got {stamp}");
497 assert!(
500 stamp[20..23].chars().all(|c| c.is_ascii_digit()),
501 "no millisecond field in {stamp}"
502 );
503 }
504}