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}