Skip to main content

truffle_core/crdt_doc/
backend.rs

1//! Persistence backends for CrdtDoc.
2
3use std::path::PathBuf;
4
5/// Backend for persisting CRDT document data across restarts.
6///
7/// Methods are synchronous — same reasoning as [`StoreBackend`](crate::synced_store::StoreBackend):
8/// file I/O for binary blobs is fast enough that async overhead isn't warranted.
9pub trait CrdtBackend: Send + Sync + 'static {
10    /// Load the latest snapshot for a document, if any.
11    fn load_snapshot(&self, doc_id: &str) -> Option<Vec<u8>>;
12
13    /// Load all incremental updates since the last snapshot, in order.
14    fn load_updates(&self, doc_id: &str) -> Vec<Vec<u8>>;
15
16    /// Append an incremental update.
17    fn save_update(&self, doc_id: &str, data: &[u8]);
18
19    /// Save a snapshot and clear all prior updates.
20    fn save_snapshot(&self, doc_id: &str, data: &[u8]);
21
22    /// Remove all persisted data for a document.
23    fn remove(&self, doc_id: &str);
24}
25
26// ---------------------------------------------------------------------------
27// MemoryCrdtBackend — no persistence (default)
28// ---------------------------------------------------------------------------
29
30/// In-memory backend (no persistence). Default.
31///
32/// All methods are no-ops. Data lives only in the `CrdtDoc`'s in-memory
33/// `LoroDoc` and is lost on process exit.
34#[derive(Debug, Default)]
35pub struct MemoryCrdtBackend;
36
37impl CrdtBackend for MemoryCrdtBackend {
38    fn load_snapshot(&self, _doc_id: &str) -> Option<Vec<u8>> {
39        None
40    }
41
42    fn load_updates(&self, _doc_id: &str) -> Vec<Vec<u8>> {
43        Vec::new()
44    }
45
46    fn save_update(&self, _doc_id: &str, _data: &[u8]) {}
47
48    fn save_snapshot(&self, _doc_id: &str, _data: &[u8]) {}
49
50    fn remove(&self, _doc_id: &str) {}
51}
52
53// ---------------------------------------------------------------------------
54// CrdtFileBackend — snapshot.bin + updates/{000001..N}.bin
55// ---------------------------------------------------------------------------
56
57/// File-backed persistence for CRDT documents.
58///
59/// Directory layout per document:
60/// ```text
61/// {base_dir}/{doc_id}/snapshot.bin
62/// {base_dir}/{doc_id}/updates/000001.bin
63/// {base_dir}/{doc_id}/updates/000002.bin
64/// ...
65/// ```
66///
67/// `save_snapshot` atomically writes the snapshot (write to `.tmp`, then
68/// rename) and then removes the `updates/` directory since the snapshot
69/// subsumes all prior updates.
70pub struct CrdtFileBackend {
71    base_dir: PathBuf,
72}
73
74impl CrdtFileBackend {
75    /// Create a new `CrdtFileBackend` rooted at `base_dir`.
76    pub fn new(base_dir: impl Into<PathBuf>) -> Self {
77        Self {
78            base_dir: base_dir.into(),
79        }
80    }
81
82    /// Document directory: `{base_dir}/{sanitized_doc_id}`.
83    fn doc_dir(&self, doc_id: &str) -> PathBuf {
84        self.base_dir.join(sanitize(doc_id))
85    }
86
87    /// Snapshot path: `{doc_dir}/snapshot.bin`.
88    fn snapshot_path(&self, doc_id: &str) -> PathBuf {
89        self.doc_dir(doc_id).join("snapshot.bin")
90    }
91
92    /// Updates directory: `{doc_dir}/updates/`.
93    fn updates_dir(&self, doc_id: &str) -> PathBuf {
94        self.doc_dir(doc_id).join("updates")
95    }
96}
97
98/// Replace characters that could cause path traversal or filesystem issues.
99fn sanitize(s: &str) -> String {
100    let replaced = s.replace('/', "_").replace('\\', "_").replace("..", "__");
101    if replaced.is_empty() || replaced == "." {
102        "_".to_string()
103    } else {
104        replaced
105    }
106}
107
108impl CrdtBackend for CrdtFileBackend {
109    fn load_snapshot(&self, doc_id: &str) -> Option<Vec<u8>> {
110        let path = self.snapshot_path(doc_id);
111        std::fs::read(&path).ok()
112    }
113
114    fn load_updates(&self, doc_id: &str) -> Vec<Vec<u8>> {
115        let dir = self.updates_dir(doc_id);
116        let mut entries: Vec<_> = match std::fs::read_dir(&dir) {
117            Ok(rd) => rd
118                .filter_map(|e| e.ok())
119                .filter(|e| {
120                    e.path()
121                        .extension()
122                        .map(|ext| ext == "bin")
123                        .unwrap_or(false)
124                })
125                .collect(),
126            Err(_) => return Vec::new(),
127        };
128
129        // Sort by filename to preserve insertion order.
130        entries.sort_by_key(|e| e.file_name());
131
132        entries
133            .into_iter()
134            .filter_map(|e| std::fs::read(e.path()).ok())
135            .collect()
136    }
137
138    fn save_update(&self, doc_id: &str, data: &[u8]) {
139        let dir = self.updates_dir(doc_id);
140        if let Err(e) = std::fs::create_dir_all(&dir) {
141            tracing::error!(
142                path = %dir.display(),
143                "crdt_file_backend: failed to create updates directory: {e}"
144            );
145            return;
146        }
147
148        // Determine next sequence number from the max existing .bin file number.
149        let next_seq = match std::fs::read_dir(&dir) {
150            Ok(rd) => {
151                rd.filter_map(|e| e.ok())
152                    .filter_map(|e| {
153                        let name = e.file_name().to_string_lossy().into_owned();
154                        if name.ends_with(".bin") {
155                            name.trim_end_matches(".bin").parse::<usize>().ok()
156                        } else {
157                            None
158                        }
159                    })
160                    .max()
161                    .unwrap_or(0)
162                    + 1
163            }
164            Err(_) => 1,
165        };
166
167        let path = dir.join(format!("{next_seq:06}.bin"));
168        if let Err(e) = std::fs::write(&path, data) {
169            tracing::error!(
170                path = %path.display(),
171                "crdt_file_backend: failed to write update: {e}"
172            );
173        }
174    }
175
176    fn save_snapshot(&self, doc_id: &str, data: &[u8]) {
177        let doc_dir = self.doc_dir(doc_id);
178        if let Err(e) = std::fs::create_dir_all(&doc_dir) {
179            tracing::error!(
180                path = %doc_dir.display(),
181                "crdt_file_backend: failed to create doc directory: {e}"
182            );
183            return;
184        }
185
186        let path = self.snapshot_path(doc_id);
187        let tmp_path = path.with_extension("bin.tmp");
188
189        // Atomic write: write to .tmp then rename.
190        if let Err(e) = std::fs::write(&tmp_path, data) {
191            tracing::error!(
192                path = %tmp_path.display(),
193                "crdt_file_backend: failed to write snapshot tmp: {e}"
194            );
195            return;
196        }
197        if let Err(e) = std::fs::rename(&tmp_path, &path) {
198            tracing::error!(
199                from = %tmp_path.display(),
200                to = %path.display(),
201                "crdt_file_backend: failed to rename snapshot tmp: {e}"
202            );
203            return;
204        }
205
206        // Clear updates directory — the snapshot subsumes them.
207        let updates_dir = self.updates_dir(doc_id);
208        if updates_dir.exists() {
209            let _ = std::fs::remove_dir_all(&updates_dir);
210        }
211    }
212
213    fn remove(&self, doc_id: &str) {
214        let doc_dir = self.doc_dir(doc_id);
215        let _ = std::fs::remove_dir_all(&doc_dir);
216    }
217}