fix(persist): atomic+durable .margin writes

Route save_book, write_file, write_bytes through a shared atomic_write
helper: write to a temp file, fsync, then rename into place so a crash
mid-write can never leave a truncated or empty manuscript. Rotate the
prior copy to .bak for manuscripts (not exports) so one good copy
always survives.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk
This commit is contained in:
pj committed 2026-06-22 13:12:39 -04:00
1 parent 61bd7cf6e5
commit 92941a2758
2 files changed
+31 -4

No files matched your search

+1 -1
View File
@@ -72,7 +72,7 @@ pub fn load_book(app: tauri::AppHandle, id: String) -> Result<String, String> {
#[tauri::command]
pub fn save_book(app: tauri::AppHandle, id: String, contents: String) -> Result<(), String> {
let path = library_dir(&app)?.join(format!("{id}.margin"));
fs::write(&path, contents).map_err(|e| e.to_string())
crate::project::atomic_write(&path, contents.as_bytes(), true)
}
#[tauri::command]
+30 -3
View File
@@ -1,6 +1,33 @@
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use std::fs;
use std::fs::{self, File};
use std::io::Write;
use std::path::{Path, PathBuf};
fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
let mut name = path.as_os_str().to_owned();
name.push(suffix);
PathBuf::from(name)
}
pub fn atomic_write(path: &Path, bytes: &[u8], backup: bool) -> Result<(), String> {
let tmp = with_suffix(path, ".tmp");
{
let mut f = File::create(&tmp).map_err(|e| e.to_string())?;
f.write_all(bytes).map_err(|e| e.to_string())?;
f.sync_all().map_err(|e| e.to_string())?;
}
if path.exists() {
if backup {
let bak = with_suffix(path, ".bak");
let _ = fs::remove_file(&bak);
fs::rename(path, &bak).map_err(|e| e.to_string())?;
} else {
fs::remove_file(path).map_err(|e| e.to_string())?;
}
}
fs::rename(&tmp, path).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn read_file(path: String) -> Result<String, String> {
@@ -9,11 +36,11 @@ pub fn read_file(path: String) -> Result<String, String> {
#[tauri::command]
pub fn write_file(path: String, contents: String) -> Result<(), String> {
fs::write(&path, contents).map_err(|e| e.to_string())
atomic_write(Path::new(&path), contents.as_bytes(), true)
}
#[tauri::command]
pub fn write_bytes(path: String, data: String) -> Result<(), String> {
let bytes = STANDARD.decode(data.as_bytes()).map_err(|e| e.to_string())?;
fs::write(&path, bytes).map_err(|e| e.to_string())
atomic_write(Path::new(&path), &bytes, false)
}