use std::path::PathBuf;
pub trait CrdtBackend: Send + Sync + 'static {
fn load_snapshot(&self, doc_id: &str) -> Option<Vec<u8>>;
fn load_updates(&self, doc_id: &str) -> Vec<Vec<u8>>;
fn save_update(&self, doc_id: &str, data: &[u8]);
fn save_snapshot(&self, doc_id: &str, data: &[u8]);
fn remove(&self, doc_id: &str);
}
#[derive(Debug, Default)]
pub struct MemoryCrdtBackend;
impl CrdtBackend for MemoryCrdtBackend {
fn load_snapshot(&self, _doc_id: &str) -> Option<Vec<u8>> {
None
}
fn load_updates(&self, _doc_id: &str) -> Vec<Vec<u8>> {
Vec::new()
}
fn save_update(&self, _doc_id: &str, _data: &[u8]) {}
fn save_snapshot(&self, _doc_id: &str, _data: &[u8]) {}
fn remove(&self, _doc_id: &str) {}
}
pub struct CrdtFileBackend {
base_dir: PathBuf,
}
impl CrdtFileBackend {
pub fn new(base_dir: impl Into<PathBuf>) -> Self {
Self {
base_dir: base_dir.into(),
}
}
fn doc_dir(&self, doc_id: &str) -> PathBuf {
self.base_dir.join(sanitize(doc_id))
}
fn snapshot_path(&self, doc_id: &str) -> PathBuf {
self.doc_dir(doc_id).join("snapshot.bin")
}
fn updates_dir(&self, doc_id: &str) -> PathBuf {
self.doc_dir(doc_id).join("updates")
}
}
fn sanitize(s: &str) -> String {
let replaced = s.replace('/', "_").replace('\\', "_").replace("..", "__");
if replaced.is_empty() || replaced == "." {
"_".to_string()
} else {
replaced
}
}
impl CrdtBackend for CrdtFileBackend {
fn load_snapshot(&self, doc_id: &str) -> Option<Vec<u8>> {
let path = self.snapshot_path(doc_id);
std::fs::read(&path).ok()
}
fn load_updates(&self, doc_id: &str) -> Vec<Vec<u8>> {
let dir = self.updates_dir(doc_id);
let mut entries: Vec<_> = match std::fs::read_dir(&dir) {
Ok(rd) => rd
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.extension()
.map(|ext| ext == "bin")
.unwrap_or(false)
})
.collect(),
Err(_) => return Vec::new(),
};
entries.sort_by_key(|e| e.file_name());
entries
.into_iter()
.filter_map(|e| std::fs::read(e.path()).ok())
.collect()
}
fn save_update(&self, doc_id: &str, data: &[u8]) {
let dir = self.updates_dir(doc_id);
if let Err(e) = std::fs::create_dir_all(&dir) {
tracing::error!(
path = %dir.display(),
"crdt_file_backend: failed to create updates directory: {e}"
);
return;
}
let next_seq = match std::fs::read_dir(&dir) {
Ok(rd) => {
rd.filter_map(|e| e.ok())
.filter_map(|e| {
let name = e.file_name().to_string_lossy().into_owned();
if name.ends_with(".bin") {
name.trim_end_matches(".bin").parse::<usize>().ok()
} else {
None
}
})
.max()
.unwrap_or(0)
+ 1
}
Err(_) => 1,
};
let path = dir.join(format!("{next_seq:06}.bin"));
if let Err(e) = std::fs::write(&path, data) {
tracing::error!(
path = %path.display(),
"crdt_file_backend: failed to write update: {e}"
);
}
}
fn save_snapshot(&self, doc_id: &str, data: &[u8]) {
let doc_dir = self.doc_dir(doc_id);
if let Err(e) = std::fs::create_dir_all(&doc_dir) {
tracing::error!(
path = %doc_dir.display(),
"crdt_file_backend: failed to create doc directory: {e}"
);
return;
}
let path = self.snapshot_path(doc_id);
let tmp_path = path.with_extension("bin.tmp");
if let Err(e) = std::fs::write(&tmp_path, data) {
tracing::error!(
path = %tmp_path.display(),
"crdt_file_backend: failed to write snapshot tmp: {e}"
);
return;
}
if let Err(e) = std::fs::rename(&tmp_path, &path) {
tracing::error!(
from = %tmp_path.display(),
to = %path.display(),
"crdt_file_backend: failed to rename snapshot tmp: {e}"
);
return;
}
let updates_dir = self.updates_dir(doc_id);
if updates_dir.exists() {
let _ = std::fs::remove_dir_all(&updates_dir);
}
}
fn remove(&self, doc_id: &str) {
let doc_dir = self.doc_dir(doc_id);
let _ = std::fs::remove_dir_all(&doc_dir);
}
}