Skip to main content

rushdb_backup/
backup.rs

1//! Logical JSONL backup and restore.
2//!
3//! This is the retained half of the former `rushdb-migrate` crate: export a
4//! tenant's records and relationships as newline-delimited JSON, and restore
5//! them into a live engine preserving public identity. It is **not** a
6//! canonical storage checkpoint (policy/index metadata, outcomes, history, and
7//! journal state are not included), and it is **not** a migration tool —
8//! migrations are performed outside the engine.
9
10use std::io::BufRead;
11
12use rushdb_lmpg::Lmpg;
13
14/// Export one tenant's records and relationships as a JSONL document, returning
15/// the text plus `(records, relationships)` counts.
16///
17/// # Errors
18///
19/// Returns an error string if the underlying export fails or the produced bytes
20/// are not valid UTF-8.
21pub fn export_tenant_jsonl(
22    snap: &rushdb_storage::ReadView,
23    tenant: rushdb_types::ids::TenantId,
24) -> Result<(String, u64, u64), String> {
25    let mut bytes = Vec::new();
26    let (records, relationships) = crate::export::export_tenant_jsonl_to(snap, tenant, &mut bytes)?;
27    let jsonl = String::from_utf8(bytes).map_err(|e| e.to_string())?;
28    Ok((jsonl, records, relationships))
29}
30
31/// Restore one record line: upsert by `__id` and re-apply the user-facing
32/// `__id` property that `upsert_document` consumes as physical identity.
33fn restore_record(lmpg: &Lmpg, v: &serde_json::Value) -> Result<(), String> {
34    let label = v
35        .get("label")
36        .and_then(|x| x.as_str())
37        .ok_or("backup record missing label")?
38        .to_string();
39    let id = v
40        .get("id")
41        .and_then(|x| x.as_str())
42        .ok_or("backup record missing id")?;
43    let properties = v
44        .get("properties")
45        .cloned()
46        .unwrap_or(serde_json::json!({}));
47    let mut doc = properties.as_object().cloned().unwrap_or_default();
48    doc.insert("__id".to_string(), serde_json::json!(id));
49    doc.insert("__label".to_string(), serde_json::json!(label));
50    rushdb_lmpg::import::upsert_document(lmpg, &label, &serde_json::Value::Object(doc))
51        .map_err(|e| e.to_reason())?;
52    Ok(())
53}
54
55/// Restore one relationship line: link endpoints by decoded hex ids, writing
56/// the supplied properties onto the new edge. Idempotent on re-run: an edge
57/// with the same type between the same endpoints is left untouched.
58fn restore_relationship(lmpg: &Lmpg, v: &serde_json::Value) -> Result<(), String> {
59    let relationship_id = v
60        .get("id")
61        .and_then(|x| x.as_str())
62        .and_then(rushdb_lmpg::import::decode_simple_hex)
63        .ok_or("backup relationship missing stable id")?;
64    let rel_type = v
65        .get("type")
66        .and_then(|x| x.as_str())
67        .ok_or("backup relationship missing type")?
68        .to_string();
69    let src = v
70        .get("source")
71        .and_then(|x| x.as_str())
72        .and_then(rushdb_lmpg::import::decode_simple_hex)
73        .ok_or("backup relationship bad source id")?;
74    let tgt = v
75        .get("target")
76        .and_then(|x| x.as_str())
77        .and_then(rushdb_lmpg::import::decode_simple_hex)
78        .ok_or("backup relationship bad target id")?;
79    let fields: Vec<(String, rushdb_lmpg::plan::FieldChange)> = v
80        .get("properties")
81        .and_then(serde_json::Value::as_object)
82        .map(|o| {
83            o.iter()
84                .map(|(k, val)| {
85                    let change = if val.is_null() {
86                        rushdb_lmpg::plan::FieldChange::ExplicitNull
87                    } else {
88                        rushdb_lmpg::plan::FieldChange::Set(val.clone())
89                    };
90                    (k.clone(), change)
91                })
92                .collect()
93        })
94        .unwrap_or_default();
95
96    let snap = lmpg.store.read_view().map_err(|e| e.to_string())?;
97    let s_hdr = rushdb_lmpg::reader::try_access::record_by_id(
98        &snap,
99        lmpg.tenant,
100        &rushdb_types::ids::RecordId(src),
101    )
102    .map_err(|e| e.to_string())?
103    .ok_or_else(|| "restore: source record not found".to_string())?;
104    let t_hdr = rushdb_lmpg::reader::try_access::record_by_id(
105        &snap,
106        lmpg.tenant,
107        &rushdb_types::ids::RecordId(tgt),
108    )
109    .map_err(|e| e.to_string())?
110    .ok_or_else(|| "restore: target record not found".to_string())?;
111    // P16 resume idempotency: preserve the source relationship identity so
112    // parallel edges are distinct and retries skip exactly the same edge.
113    if rushdb_lmpg::reader::try_access::relationship_by_id(
114        &snap,
115        lmpg.tenant,
116        &rushdb_types::ids::RelationshipId(relationship_id),
117    )
118    .map_err(|e| e.to_string())?
119    .is_none()
120    {
121        lmpg.create_relationship_with_id(
122            &rel_type,
123            rushdb_lmpg::RecordRef {
124                record_id: src,
125                ordinal: s_hdr.ordinal,
126            },
127            rushdb_lmpg::RecordRef {
128                record_id: tgt,
129                ordinal: t_hdr.ordinal,
130            },
131            relationship_id,
132            fields,
133        )
134        .map_err(|e| e.to_reason())?;
135    }
136    Ok(())
137}
138
139/// Restore a JSONL backup into a live engine. Records upsert by `__id`
140/// (preserving public identity), relationships are linked by endpoint ids,
141/// relationship properties are written onto the new edges. Returns the number
142/// of records and relationships materialized.
143///
144/// # Errors
145///
146/// Returns an error string if a line is not valid JSON or has an unknown
147/// `kind`, if a record/relationship line is malformed, or if the engine
148/// operation for a line fails.
149pub fn restore_backup(lmpg: &Lmpg, jsonl: &str) -> Result<(u64, u64), String> {
150    restore_backup_reader(lmpg, std::io::Cursor::new(jsonl.as_bytes()))
151}
152
153/// Restore a JSONL backup incrementally from a buffered reader. The reader is
154/// consumed line-by-line, so callers can restore large logical exports without
155/// first materializing the complete artifact as one `String`.
156///
157/// # Errors
158/// Returns an error when a line cannot be read/decoded or an engine operation
159/// fails. A caller must discard its target if restoration stops after a prefix.
160pub fn restore_backup_reader<R: BufRead>(lmpg: &Lmpg, reader: R) -> Result<(u64, u64), String> {
161    let mut records = 0u64;
162    let mut relationships = 0u64;
163    for line in reader.lines() {
164        let line = line.map_err(|e| format!("read backup line: {e}"))?;
165        if line.trim().is_empty() {
166            continue;
167        }
168        let v: serde_json::Value = serde_json::from_str(&line).map_err(|e| e.to_string())?;
169        match v.get("kind").and_then(|x| x.as_str()).unwrap_or("") {
170            "record" => {
171                restore_record(lmpg, &v)?;
172                records += 1;
173            }
174            "relationship" => {
175                restore_relationship(lmpg, &v)?;
176                relationships += 1;
177            }
178            other => return Err(format!("unknown backup op kind `{other}`")),
179        }
180    }
181    Ok((records, relationships))
182}