Skip to main content

recall_server/
store.rs

1//! SQLite persistence.
2//!
3//! The schema here is deliberately identical to the one the Node server
4//! created, because this server opens the *existing production database
5//! file* rather than migrating to a new one. That is what makes the cutover
6//! reversible: roll back by starting the old container against the same
7//! untouched file.
8
9use 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
19/// Frozen: an already-deployed database was created with exactly this.
20const 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/// The stored state of one file, used to decide whether a push needs
33/// merging.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct Existing {
36    /// The stored bytes. Present even for a tombstone — the server keeps the
37    /// last known content, it just refuses to hand it back over the wire.
38    pub content: String,
39    /// Whether the row is a tombstone.
40    pub deleted: bool,
41}
42
43/// The SQLite database, and every query the server makes against it.
44pub struct Store {
45    // A single connection behind a mutex. This is a single-owner server
46    // against a local file; a pool would buy nothing and SQLite would
47    // serialize the writes anyway.
48    conn: Mutex<Connection>,
49}
50
51impl Store {
52    /// Opens (creating if needed) the database at `path`.
53    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    /// An in-memory database, for tests.
69    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    // A panic in one request must not render the whole store unusable, and
78    // nothing here leaves the database in a half-written state, so a
79    // poisoned mutex is recovered rather than propagated.
80    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        // Databases created before tombstones existed have no `deleted`
89        // column. Adding it is safe and idempotent when guarded like this.
90        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    /// Reads one row.
111    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    /// Writes content, clearing any tombstone.
129    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    /// Marks a file deleted while deliberately leaving its content in
152    /// place: a mistaken delete stays recoverable at the database level,
153    /// even though nothing in the app surfaces an undo yet. [`Store::list`]
154    /// withholds the content so a pull can't resurrect it.
155    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    /// Every file for a project, tombstones included so a pulling client
176    /// knows what to remove locally — but with the deleted content
177    /// withheld.
178    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    /// The most recent write across all projects, for `/health`. Empty when
203    /// nothing has ever been synced.
204    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    /// Aggregates per project for the admin page.
212    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        // Sources are collected separately rather than with GROUP_CONCAT:
244        // SQLite won't take a custom separator together with DISTINCT, and
245        // source_env is client-supplied, so a value containing a comma
246        // would silently split into bogus entries.
247        {
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    /// Writes a consistent snapshot via `VACUUM INTO`, which is safe
270    /// against a live database — unlike copying the file — then prunes the
271    /// oldest snapshots beyond `keep`.
272    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        // Mirrors the Node server's naming, which was
277        // toISOString().replace(/[:.]/g, "-"). Millisecond precision
278        // matters twice over: without it two snapshots in the same second
279        // collide (and VACUUM INTO refuses to overwrite an existing file),
280        // and the prune below sorts these names lexicographically alongside
281        // any snapshots the Node server already wrote into the directory.
282        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
312/// An absent `source_env` is stored as NULL, not `''` — `admin_stats`
313/// distinguishes the two.
314fn 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    /// Both halves of the tombstone contract in one place: the row keeps
354    /// its content, the listing does not hand it back.
355    #[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    /// A push after a delete revives the row and clears the tombstone.
384    #[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    /// A `source_env` containing a comma must survive as one value — the
408    /// reason sources aren't gathered with GROUP_CONCAT.
409    #[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    /// The `deleted` column is added to databases that predate tombstones,
425    /// without touching their rows.
426    #[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        // recall-2026-09-03T21-49-55-191Z.db
459        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        // The three characters before the Z are the milliseconds, which
466        // keep two snapshots in the same second from colliding.
467        assert!(
468            stamp[20..23].chars().all(|c| c.is_ascii_digit()),
469            "no millisecond field in {stamp}"
470        );
471    }
472}