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
19mod devices;
20
21pub use devices::{
22    plain_name, Created, Decision, Inserted, NewAuthkey, NewDevice, NewEnrollment, Poll, Waiting,
23};
24
25/// Frozen: an already-deployed database was created with exactly this.
26const 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/// The stored state of one file, used to decide whether a push needs
39/// merging.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct Existing {
42    /// The stored bytes. Present even for a tombstone — the server keeps the
43    /// last known content, it just refuses to hand it back over the wire.
44    pub content: String,
45    /// Whether the row is a tombstone.
46    pub deleted: bool,
47}
48
49/// The SQLite database, and every query the server makes against it.
50pub struct Store {
51    // A single connection behind a mutex. This is a single-owner server
52    // against a local file; a pool would buy nothing and SQLite would
53    // serialize the writes anyway.
54    conn: Mutex<Connection>,
55}
56
57impl Store {
58    /// Opens (creating if needed) the database at `path`.
59    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    /// An in-memory database, for tests.
75    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    // A panic in one request must not render the whole store unusable, and
84    // nothing here leaves the database in a half-written state, so a
85    // poisoned mutex is recovered rather than propagated.
86    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        // Databases created before tombstones existed have no `deleted`
95        // column. Adding it is safe and idempotent when guarded like this.
96        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        // Tables of their own, beside memory_files rather than in it, so a
115        // server from before devices existed still opens this file and
116        // simply never looks at them: rolling back stays a matter of
117        // starting the older image.
118        conn.execute_batch(devices::SCHEMA)?;
119        Ok(())
120    }
121
122    /// Reads one row.
123    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    /// Writes content, clearing any tombstone.
141    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    /// Marks a file deleted while deliberately leaving its content in
164    /// place: a mistaken delete stays recoverable at the database level,
165    /// even though nothing in the app surfaces an undo yet. [`Store::list`]
166    /// withholds the content so a pull can't resurrect it.
167    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    /// Every file for a project, tombstones included so a pulling client
188    /// knows what to remove locally — but with the deleted content
189    /// withheld.
190    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    /// The most recent write across all projects, for `/health`. Empty when
215    /// nothing has ever been synced.
216    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    /// Aggregates per project for the admin page.
224    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        // Sources are collected separately rather than with GROUP_CONCAT:
256        // SQLite won't take a custom separator together with DISTINCT, and
257        // source_env is client-supplied, so a value containing a comma
258        // would silently split into bogus entries.
259        {
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    /// Writes a consistent snapshot via `VACUUM INTO`, which is safe
282    /// against a live database — unlike copying the file — then prunes the
283    /// oldest snapshots beyond `keep`.
284    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        // Mirrors the Node server's naming, which was
289        // toISOString().replace(/[:.]/g, "-"). Millisecond precision
290        // matters twice over: without it two snapshots in the same second
291        // collide (and VACUUM INTO refuses to overwrite an existing file),
292        // and the prune below sorts these names lexicographically alongside
293        // any snapshots the Node server already wrote into the directory.
294        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        // VACUUM INTO refuses a file that is already there, so one that is
302        // there now is not this call's to delete if the vacuum fails.
303        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            // A vacuum that fails part way (a full disk, a file size limit)
311            // leaves the part it wrote. Left there, it is named like every
312            // good snapshot, sorts among them, and counts toward `keep`,
313            // so the prune would delete a good one to make room for it,
314            // and anyone restoring would find a file that does not open.
315            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
338/// An absent `source_env` is stored as NULL, not `''` — `admin_stats`
339/// distinguishes the two.
340fn nullable(s: &str) -> Option<&str> {
341    if s.is_empty() {
342        None
343    } else {
344        Some(s)
345    }
346}
347
348// What `recall-server admin` does to a database. A child module so it can
349// share the one connection type and `Store::backup` without widening what
350// `Store` exposes; crate-private, because nothing but that subcommand, and
351// nothing reachable from the HTTP router, may call it.
352pub(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    /// Both halves of the tombstone contract in one place: the row keeps
386    /// its content, the listing does not hand it back.
387    #[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    /// A push after a delete revives the row and clears the tombstone.
416    #[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    /// A `source_env` containing a comma must survive as one value — the
440    /// reason sources aren't gathered with GROUP_CONCAT.
441    #[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    /// The `deleted` column is added to databases that predate tombstones,
457    /// without touching their rows.
458    #[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        // recall-2026-09-03T21-49-55-191Z.db
491        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        // The three characters before the Z are the milliseconds, which
498        // keep two snapshots in the same second from colliding.
499        assert!(
500            stamp[20..23].chars().all(|c| c.is_ascii_digit()),
501            "no millisecond field in {stamp}"
502        );
503    }
504}