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};
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                // Derived and staging Store-state records are disposable.
112                store_state_namespaces: HashMap::new(),
113                verification_status: serializable.verification_status,
114                instance_metadata: serializable.instance_metadata,
115                instance_secrets: serializable.instance_secrets,
116                tips: serializable.tips,
117            }),
118            store_state_point_reads: std::sync::atomic::AtomicUsize::new(0),
119            store_state_scan_reads: std::sync::atomic::AtomicUsize::new(0),
120            #[cfg(feature = "testing")]
121            store_history_reads: std::sync::atomic::AtomicUsize::new(0),
122        })
123    }
124}
125
126/// Saves the entire database state (all entries) to a specified file as JSON.
127///
128/// **Atomicity:** the write goes to `<path>.tmp` first, then renames into
129/// place. On POSIX the final rename is atomic — a process crash mid-write
130/// leaves the previous snapshot intact and any stale `.tmp` is overwritten
131/// on the next save. On Windows the rename is not atomic when the
132/// destination already exists, so a crash during the rename can leave a
133/// stale `.tmp` and an out-of-date snapshot.
134///
135/// # Arguments
136/// * `backend` - The InMemory database to save
137/// * `path` - The path to the file where the state should be saved.
138///
139/// # Returns
140/// A `Result` indicating success or an I/O or serialization error.
141pub(crate) fn save_to_file<P: AsRef<Path>>(backend: &InMemory, path: P) -> Result<()> {
142    // Clone data under locks, then release before file I/O. Cache
143    // deliberately not persisted; see SerializableDatabase docs.
144    let serializable = {
145        let inner = backend.inner.read().unwrap();
146        SerializableDatabase {
147            version: PERSISTENCE_VERSION,
148            entries: inner.entries.clone(),
149            verification_status: inner.verification_status.clone(),
150            instance_metadata: inner.instance_metadata.clone(),
151            instance_secrets: inner.instance_secrets.clone(),
152            cache: None,
153            tips: inner.tips.clone(),
154        }
155    };
156
157    let json = serde_json::to_string_pretty(&serializable)
158        .map_err(|e| -> Error { BackendError::SerializationFailed { source: e }.into() })?;
159
160    // Write to a sibling tempfile, then atomic rename. `<path>.tmp` is the
161    // standard convention; a stale tempfile from a crashed previous run is
162    // overwritten on the next save.
163    let path = path.as_ref();
164    let mut tmp = path.as_os_str().to_owned();
165    tmp.push(".tmp");
166    let tmp_path = std::path::PathBuf::from(tmp);
167
168    std::fs::write(&tmp_path, json.as_bytes())
169        .map_err(|e| -> Error { BackendError::FileIo { source: e }.into() })?;
170    std::fs::rename(&tmp_path, path).map_err(|e| -> Error {
171        // Best-effort cleanup of the tempfile if rename failed; ignore
172        // any cleanup error (the original failure is what the caller
173        // needs to see).
174        let _ = std::fs::remove_file(&tmp_path);
175        BackendError::FileIo { source: e }.into()
176    })
177}
178
179/// Attempts to load the database state from a specified JSON file.
180///
181/// Returns `Ok(None)` when the file does not exist; the caller decides
182/// whether that's a fresh-start signal or an error (strict load vs.
183/// bootstrap). Other I/O errors and deserialisation errors surface
184/// directly.
185///
186/// Reading the bytes and parsing happen in a single call so there's no
187/// TOCTOU window between an external "does this snapshot exist?" check
188/// and the actual read.
189pub(crate) fn try_load_from_file<P: AsRef<Path>>(path: P) -> Result<Option<InMemory>> {
190    match std::fs::read_to_string(path) {
191        Ok(json) => {
192            let database: InMemory = serde_json::from_str(&json).map_err(|e| -> Error {
193                BackendError::DeserializationFailed { source: e }.into()
194            })?;
195            Ok(Some(database))
196        }
197        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
198        Err(e) => Err(BackendError::FileIo { source: e }.into()),
199    }
200}
201
202/// Loads the database state from a specified JSON file.
203///
204/// If the file does not exist, a new, empty `InMemory` database is returned.
205/// Callers that need to distinguish "missing" from "loaded empty" should
206/// use [`try_load_from_file`] instead.
207///
208/// # Arguments
209/// * `path` - The path to the file from which to load the state.
210///
211/// # Returns
212/// A `Result` containing the loaded `InMemory` database or an I/O or deserialization error.
213pub(crate) fn load_from_file<P: AsRef<Path>>(path: P) -> Result<InMemory> {
214    Ok(try_load_from_file(path)?.unwrap_or_else(InMemory::new))
215}