use anyhow::{Context, Result};
use exocortex_storage::bounded_io::{
atomic_write_private, read_bounded, serialize_json_pretty_bounded,
};
use crate::wal::{Wal, WalEntry};
pub const FORMAT: &str = "exocortex-backup";
pub const VERSION: u32 = 1;
pub const MAX_BACKUP_BYTES: u64 = 640 * 1024 * 1024;
const BACKUP_NOUN: &str = "backup";
#[derive(serde::Serialize, serde::Deserialize)]
pub struct Backup {
pub format: String,
pub version: u32,
pub created_at: String,
pub ontology_fingerprint: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ontology_summary: Option<exocortex_kernel::OntologySummary>,
pub entries: Vec<WalEntry>,
}
pub fn export(
wal: &Wal,
fingerprint: &str,
summary: &exocortex_kernel::OntologySummary,
path: &std::path::Path,
) -> Result<usize> {
let entries = wal.entries().context("read WAL for backup")?;
let doc = Backup {
format: FORMAT.into(),
version: VERSION,
created_at: chrono::Utc::now().to_rfc3339(),
ontology_fingerprint: fingerprint.into(),
ontology_summary: Some(summary.clone()),
entries,
};
let n = doc.entries.len();
let json = serialize_json_pretty_bounded(&doc, MAX_BACKUP_BYTES, BACKUP_NOUN)
.context("serialize backup")?;
atomic_write_private(path, &json, BACKUP_NOUN)
.with_context(|| format!("write backup {}", path.display()))?;
Ok(n)
}
#[derive(Debug, serde::Serialize)]
pub struct ImportReport {
pub imported: usize,
pub first_local_lsn: u64,
}
pub fn import(
wal: &Wal,
ontology: &exocortex_kernel::Ontology,
path: &std::path::Path,
) -> Result<ImportReport> {
let raw = read_bounded(path, MAX_BACKUP_BYTES, BACKUP_NOUN)?;
let doc: Backup = serde_json::from_slice(&raw).context("parse backup")?;
anyhow::ensure!(
doc.format == FORMAT,
"not an exocortex backup (format `{}`)",
doc.format
);
anyhow::ensure!(
doc.version == VERSION,
"backup version {} unsupported (this build reads {VERSION})",
doc.version
);
let verdict = match &doc.ontology_summary {
Some(summary) => exocortex_kernel::admit_backup(
exocortex_kernel::BackupOntology::Summarized { summary },
ontology,
),
None => exocortex_kernel::admit_backup(
exocortex_kernel::BackupOntology::Legacy {
fingerprint_hex: &doc.ontology_fingerprint,
},
ontology,
),
};
if let Err(error) = verdict {
anyhow::bail!(
"ontology mismatch: {error} — the backup was written against a different pack set"
);
}
for (i, e) in doc.entries.iter().enumerate() {
anyhow::ensure!(
e.memories.len() == e.memory_ids.len(),
"entry {i}: memories/memory_ids length mismatch"
);
for (j, d) in e.memories.iter().enumerate() {
exocortex_kernel::validator::validate_draft(
ontology,
d,
exocortex_kernel::validator::SourceCeiling {
source: "backup-import",
ceiling: exocortex_kernel::Visibility::Org,
},
)
.with_context(|| format!("entry {i} draft {j} fails current-ontology validation"))?;
}
}
let imported = doc.entries.len();
let first = wal
.append_imported_batch(doc.entries)
.context("atomically append imported backup")?;
Ok(ImportReport {
imported,
first_local_lsn: first,
})
}
#[cfg(test)]
mod tests {
use super::BACKUP_NOUN;
use exocortex_storage::bounded_io::{ensure_size, read_bounded, serialize_json_pretty_bounded};
#[test]
fn backup_size_boundary_is_inclusive_and_file_reads_are_bounded() {
assert!(ensure_size(7, 7, BACKUP_NOUN).is_ok());
assert!(ensure_size(8, 7, BACKUP_NOUN).is_err());
let dir =
std::env::temp_dir().join(format!("exocortex-bounded-backup-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("backup.json");
std::fs::write(&path, b"1234567").unwrap();
assert_eq!(read_bounded(&path, 7, BACKUP_NOUN).unwrap(), b"1234567");
assert!(read_bounded(&path, 6, BACKUP_NOUN).is_err());
assert!(serialize_json_pretty_bounded(&"x", 3, BACKUP_NOUN).is_ok());
assert!(serialize_json_pretty_bounded(&"x", 2, BACKUP_NOUN).is_err());
std::fs::remove_dir_all(dir).unwrap();
}
}