Development Documentation (main branch) - For stable release docs, see docs.rs/eidetica
Skip to main content

eidetica/backend/database/in_memory/
persistence.rs

1//! Persistence operations for InMemory database
2//!
3//! This module handles serialization and file I/O for saving/loading
4//! the in-memory database state to/from JSON files.
5
6use std::{collections::HashMap, path::Path, sync::RwLock};
7
8use serde::{Deserialize, Deserializer, Serialize, Serializer};
9
10use super::{InMemory, InMemoryInner, TreeTipsCache, cache::InMemoryCrdtCache};
11use crate::{
12    Error, Result,
13    backend::{InstanceMetadata, InstanceSecrets, VerificationStatus, errors::BackendError},
14    entry::{Entry, ID},
15};
16
17/// The current persistence file format version.
18/// v0 indicates this is an unstable format subject to breaking changes.
19const PERSISTENCE_VERSION: u8 = 0;
20
21/// Helper to check if version is default (0) for serde skip_serializing_if
22fn is_v0(v: &u8) -> bool {
23    *v == 0
24}
25
26/// Validates the persistence version during deserialization.
27fn validate_persistence_version<'de, D>(deserializer: D) -> std::result::Result<u8, D::Error>
28where
29    D: Deserializer<'de>,
30{
31    use serde::Deserialize;
32    let version = u8::deserialize(deserializer)?;
33    if version != PERSISTENCE_VERSION {
34        return Err(serde::de::Error::custom(format!(
35            "unsupported persistence version {version}; only version {PERSISTENCE_VERSION} is supported"
36        )));
37    }
38    Ok(version)
39}
40
41/// Serializable version of InMemory database for persistence
42#[derive(Serialize, Deserialize)]
43struct SerializableDatabase {
44    /// File format version for compatibility checking
45    #[serde(
46        rename = "_v",
47        default,
48        skip_serializing_if = "is_v0",
49        deserialize_with = "validate_persistence_version"
50    )]
51    version: u8,
52    entries: HashMap<ID, Entry>,
53    #[serde(default)]
54    verification_status: HashMap<ID, VerificationStatus>,
55    /// Instance metadata containing device public key and system database IDs
56    #[serde(default)]
57    instance_metadata: Option<InstanceMetadata>,
58    /// Instance secrets containing the device signing key
59    #[serde(default)]
60    instance_secrets: Option<InstanceSecrets>,
61    /// CRDT state cache *was* serialized here pre-unification. The cache is
62    /// now scope-keyed (Shared vs User) and bounded by an LRU; rather than
63    /// serializing an opaque LRU snapshot, we treat the cache as ephemeral
64    /// performance state and rebuild lazily on load. Field retained as
65    /// `#[serde(default, skip_serializing)]` so old snapshots still
66    /// deserialize cleanly; the bytes are discarded.
67    #[serde(default, skip_serializing)]
68    #[allow(dead_code)]
69    cache: Option<serde_json::Value>,
70    /// Cached tips grouped by tree
71    #[serde(default)]
72    tips: HashMap<ID, TreeTipsCache>,
73}
74
75impl Serialize for InMemory {
76    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
77    where
78        S: Serializer,
79    {
80        // Clone data under locks, then release before serializing.
81        // The CRDT cache is deliberately not persisted; see the
82        // SerializableDatabase docs.
83        let serializable = {
84            let inner = self.inner.read().unwrap();
85            SerializableDatabase {
86                version: PERSISTENCE_VERSION,
87                entries: inner.entries.clone(),
88                verification_status: inner.verification_status.clone(),
89                instance_metadata: inner.instance_metadata.clone(),
90                instance_secrets: inner.instance_secrets.clone(),
91                cache: None,
92                tips: inner.tips.clone(),
93            }
94        };
95
96        serializable.serialize(serializer)
97    }
98}
99
100impl<'de> Deserialize<'de> for InMemory {
101    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
102    where
103        D: Deserializer<'de>,
104    {
105        // Version validation happens via deserialize_with on SerializableDatabase._v
106        let serializable = SerializableDatabase::deserialize(deserializer)?;
107
108        Ok(InMemory {
109            inner: RwLock::new(InMemoryInner {
110                entries: serializable.entries,
111                verification_status: serializable.verification_status,
112                instance_metadata: serializable.instance_metadata,
113                instance_secrets: serializable.instance_secrets,
114                tips: serializable.tips,
115            }),
116            // Cache rebuilds lazily as reads materialize state — see the
117            // `cache` field's doc on SerializableDatabase.
118            crdt_cache: std::sync::Mutex::new(InMemoryCrdtCache::new()),
119        })
120    }
121}
122
123/// Saves the entire database state (all entries) to a specified file as JSON.
124///
125/// **Atomicity:** the write goes to `<path>.tmp` first, then renames into
126/// place. On POSIX the final rename is atomic — a process crash mid-write
127/// leaves the previous snapshot intact and any stale `.tmp` is overwritten
128/// on the next save. On Windows the rename is not atomic when the
129/// destination already exists, so a crash during the rename can leave a
130/// stale `.tmp` and an out-of-date snapshot.
131///
132/// # Arguments
133/// * `backend` - The InMemory database to save
134/// * `path` - The path to the file where the state should be saved.
135///
136/// # Returns
137/// A `Result` indicating success or an I/O or serialization error.
138pub(crate) fn save_to_file<P: AsRef<Path>>(backend: &InMemory, path: P) -> Result<()> {
139    // Clone data under locks, then release before file I/O. Cache
140    // deliberately not persisted; see SerializableDatabase docs.
141    let serializable = {
142        let inner = backend.inner.read().unwrap();
143        SerializableDatabase {
144            version: PERSISTENCE_VERSION,
145            entries: inner.entries.clone(),
146            verification_status: inner.verification_status.clone(),
147            instance_metadata: inner.instance_metadata.clone(),
148            instance_secrets: inner.instance_secrets.clone(),
149            cache: None,
150            tips: inner.tips.clone(),
151        }
152    };
153
154    let json = serde_json::to_string_pretty(&serializable)
155        .map_err(|e| -> Error { BackendError::SerializationFailed { source: e }.into() })?;
156
157    // Write to a sibling tempfile, then atomic rename. `<path>.tmp` is the
158    // standard convention; a stale tempfile from a crashed previous run is
159    // overwritten on the next save.
160    let path = path.as_ref();
161    let mut tmp = path.as_os_str().to_owned();
162    tmp.push(".tmp");
163    let tmp_path = std::path::PathBuf::from(tmp);
164
165    std::fs::write(&tmp_path, json.as_bytes())
166        .map_err(|e| -> Error { BackendError::FileIo { source: e }.into() })?;
167    std::fs::rename(&tmp_path, path).map_err(|e| -> Error {
168        // Best-effort cleanup of the tempfile if rename failed; ignore
169        // any cleanup error (the original failure is what the caller
170        // needs to see).
171        let _ = std::fs::remove_file(&tmp_path);
172        BackendError::FileIo { source: e }.into()
173    })
174}
175
176/// Attempts to load the database state from a specified JSON file.
177///
178/// Returns `Ok(None)` when the file does not exist; the caller decides
179/// whether that's a fresh-start signal or an error (strict load vs.
180/// bootstrap). Other I/O errors and deserialisation errors surface
181/// directly.
182///
183/// Reading the bytes and parsing happen in a single call so there's no
184/// TOCTOU window between an external "does this snapshot exist?" check
185/// and the actual read.
186pub(crate) fn try_load_from_file<P: AsRef<Path>>(path: P) -> Result<Option<InMemory>> {
187    match std::fs::read_to_string(path) {
188        Ok(json) => {
189            let database: InMemory = serde_json::from_str(&json).map_err(|e| -> Error {
190                BackendError::DeserializationFailed { source: e }.into()
191            })?;
192            Ok(Some(database))
193        }
194        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
195        Err(e) => Err(BackendError::FileIo { source: e }.into()),
196    }
197}
198
199/// Loads the database state from a specified JSON file.
200///
201/// If the file does not exist, a new, empty `InMemory` database is returned.
202/// Callers that need to distinguish "missing" from "loaded empty" should
203/// use [`try_load_from_file`] instead.
204///
205/// # Arguments
206/// * `path` - The path to the file from which to load the state.
207///
208/// # Returns
209/// A `Result` containing the loaded `InMemory` database or an I/O or deserialization error.
210pub(crate) fn load_from_file<P: AsRef<Path>>(path: P) -> Result<InMemory> {
211    Ok(try_load_from_file(path)?.unwrap_or_else(InMemory::new))
212}