Skip to main content

kernoid_sync/
state.rs

1// SPDX-License-Identifier: MIT
2//! The local state of a folder kept in step, in SQLite: the store's tree as last read with the journal state it
3//! belongs to, and per path what both sides were when they last agreed (`plan::Base`). One database per folder; it
4//! lives outside the folder, where the app keeps its data.
5
6use std::{collections::BTreeMap, path::Path};
7
8use rusqlite::{Connection, OptionalExtension, params};
9
10use crate::{
11    Error, Result,
12    plan::{Base, Local, RelPath, Remote},
13};
14
15/// A node of the store as read from the server.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct Node {
18    pub id: String,
19    pub parent: Option<String>,
20    pub name: String,
21    pub directory: bool,
22    pub blob: Option<String>,
23    pub size: u64,
24    pub role: Option<String>,
25}
26
27#[derive(Debug)]
28pub struct State {
29    db: Connection,
30}
31
32impl From<rusqlite::Error> for Error {
33    fn from(error: rusqlite::Error) -> Self {
34        Error::State(error.to_string())
35    }
36}
37
38impl State {
39    pub fn open(path: &Path) -> Result<State> {
40        let db = Connection::open(path)?;
41        db.execute_batch(
42            "PRAGMA journal_mode = WAL;
43             CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
44             CREATE TABLE IF NOT EXISTS remote (
45                 id TEXT PRIMARY KEY, parent TEXT, name TEXT NOT NULL, directory INTEGER NOT NULL, blob TEXT,
46                 size INTEGER NOT NULL, role TEXT);
47             CREATE TABLE IF NOT EXISTS base (
48                 path TEXT PRIMARY KEY, id TEXT NOT NULL, directory INTEGER NOT NULL, blob TEXT, size INTEGER NOT NULL,
49                 local_directory INTEGER NOT NULL, local_size INTEGER NOT NULL, local_modified TEXT NOT NULL);",
50        )?;
51        Ok(State { db })
52    }
53
54    pub fn meta(&self, key: &str) -> Result<Option<String>> {
55        Ok(self.db.query_row("SELECT value FROM meta WHERE key = ?1", [key], |row| row.get(0)).optional()?)
56    }
57
58    pub fn set_meta(&self, key: &str, value: &str) -> Result<()> {
59        self.db.execute("INSERT OR REPLACE INTO meta (key, value) VALUES (?1, ?2)", [key, value])?;
60        Ok(())
61    }
62
63    pub fn remote(&self) -> Result<BTreeMap<String, Node>> {
64        let mut query = self.db.prepare("SELECT id, parent, name, directory, blob, size, role FROM remote")?;
65        let rows = query.query_map([], |row| {
66            Ok(Node {
67                id: row.get(0)?,
68                parent: row.get(1)?,
69                name: row.get(2)?,
70                directory: row.get(3)?,
71                blob: row.get(4)?,
72                size: row.get::<_, i64>(5)? as u64,
73                role: row.get(6)?,
74            })
75        })?;
76        let mut out = BTreeMap::new();
77        for node in rows {
78            let node = node?;
79            out.insert(node.id.clone(), node);
80        }
81        Ok(out)
82    }
83
84    /// Replaces the stored tree and its state at once.
85    pub fn set_remote(&mut self, nodes: &BTreeMap<String, Node>, state: &str) -> Result<()> {
86        let tx = self.db.transaction()?;
87        tx.execute("DELETE FROM remote", [])?;
88        {
89            let mut insert = tx.prepare(
90                "INSERT INTO remote (id, parent, name, directory, blob, size, role) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
91            )?;
92            for node in nodes.values() {
93                insert.execute(params![
94                    node.id,
95                    node.parent,
96                    node.name,
97                    node.directory,
98                    node.blob,
99                    node.size as i64,
100                    node.role
101                ])?;
102            }
103        }
104        tx.execute("INSERT OR REPLACE INTO meta (key, value) VALUES ('state', ?1)", [state])?;
105        tx.commit()?;
106        Ok(())
107    }
108
109    pub fn base(&self) -> Result<BTreeMap<RelPath, Base>> {
110        let mut query = self
111            .db
112            .prepare("SELECT path, id, directory, blob, size, local_directory, local_size, local_modified FROM base")?;
113        let rows = query.query_map([], |row| {
114            let modified: String = row.get(7)?;
115            Ok((
116                row.get::<_, String>(0)?,
117                Base {
118                    remote: Remote {
119                        id: row.get(1)?,
120                        directory: row.get(2)?,
121                        blob: row.get(3)?,
122                        size: row.get::<_, i64>(4)? as u64,
123                    },
124                    local: Local {
125                        directory: row.get(5)?,
126                        size: row.get::<_, i64>(6)? as u64,
127                        modified: modified.parse().unwrap_or_default(),
128                    },
129                },
130            ))
131        })?;
132        let mut out = BTreeMap::new();
133        for row in rows {
134            let (path, base) = row?;
135            out.insert(path, base);
136        }
137        Ok(out)
138    }
139
140    /// Replaces what both sides agreed on.
141    pub fn set_base(&mut self, base: &BTreeMap<RelPath, Base>) -> Result<()> {
142        let tx = self.db.transaction()?;
143        tx.execute("DELETE FROM base", [])?;
144        {
145            let mut insert = tx.prepare(
146                "INSERT INTO base (path, id, directory, blob, size, local_directory, local_size, local_modified)
147                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
148            )?;
149            for (path, base) in base {
150                insert.execute(params![
151                    path,
152                    base.remote.id,
153                    base.remote.directory,
154                    base.remote.blob,
155                    base.remote.size as i64,
156                    base.local.directory,
157                    base.local.size as i64,
158                    base.local.modified.to_string()
159                ])?;
160            }
161        }
162        tx.commit()?;
163        Ok(())
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn what_is_stored_comes_back() {
173        let dir = std::env::temp_dir().join(format!("kernoid-sync-state-{}", std::process::id()));
174        std::fs::create_dir_all(&dir).unwrap();
175        let path = dir.join("state.db");
176        let mut state = State::open(&path).unwrap();
177        let node = Node {
178            id: "n1".into(),
179            parent: None,
180            name: "a.txt".into(),
181            directory: false,
182            blob: Some("b1".into()),
183            size: 3,
184            role: None,
185        };
186        state.set_remote(&BTreeMap::from([("n1".to_owned(), node.clone())]), "7").unwrap();
187        let base = Base {
188            remote: Remote { id: "n1".into(), directory: false, blob: Some("b1".into()), size: 3 },
189            local: Local { directory: false, size: 3, modified: 1_761_000_000_123_456_789 },
190        };
191        state.set_base(&BTreeMap::from([("a.txt".to_owned(), base.clone())])).unwrap();
192        drop(state);
193        let state = State::open(&path).unwrap();
194        assert_eq!(state.meta("state").unwrap().as_deref(), Some("7"));
195        assert_eq!(state.remote().unwrap()["n1"], node);
196        assert_eq!(state.base().unwrap()["a.txt"], base);
197        std::fs::remove_dir_all(&dir).unwrap();
198    }
199}