1use std::path::Path;
8
9use omgbase_format::hash::sha256;
10use omgbase_reconcile::Config;
11use omgbase_store::{Origin, Store};
12use rusqlite::params;
13use serde_json::Value;
14
15use crate::error::Result;
16use crate::fs::FileSystem;
17
18#[derive(Clone, Debug, Default, PartialEq, Eq)]
20pub struct RecoveryResult {
21 pub materialized: Vec<String>,
23 pub healed: Vec<String>,
25 pub missing: Vec<String>,
27}
28
29impl RecoveryResult {
30 #[must_use]
31 pub fn to_json(&self) -> Value {
32 serde_json::json!({ "healed": self.healed, "missing": self.missing })
33 }
34}
35
36pub fn recover_repo(
42 store: &mut Store,
43 repo_id: &str,
44 fs: &dyn FileSystem,
45 root: &Path,
46 ts: &str,
47 config: &Config,
48) -> Result<RecoveryResult> {
49 let materialized = crate::freshness::materialize_pending(store, repo_id, fs, root)?;
50 type Row = (String, Option<Vec<u8>>, Option<Vec<u8>>);
51 let docs: Vec<Row> = {
52 let mut stmt = store.conn().prepare(
53 "SELECT d.path, d.file_hash, r.rendered_hash
54 FROM docs d LEFT JOIN revisions r ON r.rev_id = d.current_rev
55 WHERE d.repo_id = ?1 AND d.deleted_commit IS NULL ORDER BY d.rowid",
56 )?;
57 let rows = stmt.query_map(params![repo_id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?;
58 rows.collect::<std::result::Result<_, _>>()?
59 };
60 let mut result = RecoveryResult {
61 materialized,
62 ..RecoveryResult::default()
63 };
64 for (path, file_hash, rendered_hash) in docs {
65 let Some(content) = fs.read(root, &path)? else {
66 result.missing.push(path);
67 continue;
68 };
69 let on_disk = sha256(content.as_bytes());
70 let recorded = rendered_hash.or(file_hash);
71 if recorded.is_none_or(|r| r[..] != on_disk[..]) {
72 store.reconciling_ingest(
73 repo_id,
74 &path,
75 &content,
76 ts,
77 Origin::Observed,
78 None,
79 None,
80 config,
81 )?;
82 result.healed.push(path);
83 }
84 }
85 Ok(result)
86}
87
88#[cfg(test)]
89mod tests {
90 use super::*;
91 use crate::fs::MemFileSystem;
92 use omgbase_store::{BatchItem, SequentialMinter};
93
94 const TS: &str = "2026-09-26T10:00:00.000Z";
95
96 #[test]
97 fn heals_divergent_files_and_reports_missing() {
98 let mut store =
99 Store::open_in_memory_with_minter(Box::new(SequentialMinter::new())).unwrap();
100 let repo = store.create_repo("fixture").unwrap();
101 let cfg = Config::default();
102 let items = [
103 BatchItem::observed("a.md", "# A\n\nOne.\n"),
104 BatchItem::observed("b.md", "# B\n"),
105 BatchItem::observed("c.md", "# C\n"),
106 ];
107 store.observe_batch(&repo, &items, TS, &cfg).unwrap();
108 let mut fs = MemFileSystem::new();
109 fs.set("a.md", "# A\n\nOne, edited.\n", 1);
110 fs.set("b.md", "# B\n", 2);
111 let root = Path::new("/r");
112 let r = recover_repo(&mut store, &repo, &fs, root, TS, &cfg).unwrap();
113 assert_eq!(r.healed, ["a.md"]);
114 assert_eq!(r.missing, ["c.md"]);
115 assert_eq!(r.to_json()["healed"], serde_json::json!(["a.md"]));
116 let commits: i64 = store
117 .conn()
118 .query_row(
119 "SELECT count(*) FROM commits WHERE repo_id = ?1",
120 params![repo],
121 |r| r.get(0),
122 )
123 .unwrap();
124 assert_eq!(commits, 4);
125 let origin: String = store
126 .conn()
127 .query_row("SELECT origin FROM commits WHERE seq = 4", [], |r| r.get(0))
128 .unwrap();
129 assert_eq!(origin, "observed");
130 let doc: String = store
131 .conn()
132 .query_row("SELECT doc_id FROM docs WHERE path = 'a.md'", [], |r| {
133 r.get(0)
134 })
135 .unwrap();
136 assert_eq!(
137 store.reconstruct(&doc).unwrap().as_deref(),
138 Some("# A\n\nOne, edited.\n")
139 );
140 let carried: i64 = store
142 .conn()
143 .query_row(
144 "SELECT count(*) FROM dispositions WHERE commit_id = 'c_3' AND kind = 'same'",
145 [],
146 |r| r.get(0),
147 )
148 .unwrap();
149 assert_eq!(carried, 1);
150 let again = recover_repo(&mut store, &repo, &fs, root, TS, &cfg).unwrap();
152 assert!(again.healed.is_empty());
153 assert_eq!(again.missing, ["c.md"]);
154 }
155
156 #[test]
160 fn recovery_materializes_pending_rows_first() {
161 struct Crashed(omgbase_store::FsDocStore);
163 impl omgbase_store::DocStore for Crashed {
164 fn exists(&self, p: &str) -> bool {
165 self.0.exists(p)
166 }
167 fn read(&self, p: &str) -> omgbase_store::Result<Option<String>> {
168 self.0.read(p)
169 }
170 fn write(&mut self, p: &str, _b: &str) -> omgbase_store::Result<()> {
171 Err(omgbase_store::Error::Other(format!("crashed before {p}")))
172 }
173 fn rename(&mut self, f: &str, _t: &str) -> omgbase_store::Result<()> {
174 Err(omgbase_store::Error::Other(format!("crashed before {f}")))
175 }
176 fn remove(&mut self, p: &str) -> omgbase_store::Result<()> {
177 Err(omgbase_store::Error::Other(format!("crashed before {p}")))
178 }
179 }
180 let dir = std::env::temp_dir().join(format!(
181 "omgbase-sync-recover-{}-{}",
182 std::process::id(),
183 std::time::SystemTime::now()
184 .duration_since(std::time::UNIX_EPOCH)
185 .map(|d| d.as_nanos())
186 .unwrap_or(0)
187 ));
188 std::fs::create_dir_all(&dir).unwrap();
189 std::fs::write(dir.join("a.md"), "# A\n").unwrap();
190 let mut store =
191 Store::open_in_memory_with_minter(Box::new(SequentialMinter::new())).unwrap();
192 let repo = store.create_repo("fixture").unwrap();
193 let cfg = Config::default();
194 let fs = crate::fs::RealFileSystem;
195 store
196 .observe_batch(&repo, &[BatchItem::observed("a.md", "# A\n")], TS, &cfg)
197 .unwrap();
198 let ctx = omgbase_store::DocOpContext {
199 repo_id: repo.clone(),
200 actor: Some("agent:test".to_owned()),
201 ts: TS.to_owned(),
202 };
203 let mut set = serde_json::Map::new();
204 set.insert("status".to_owned(), serde_json::json!("done"));
205 store
206 .docs_set_meta(
207 &ctx,
208 &mut Crashed(omgbase_store::FsDocStore::new(&dir)),
209 "a.md",
210 Some(&set),
211 &[],
212 )
213 .unwrap_err();
214 assert_eq!(store.pending_materializations(&repo).unwrap().len(), 1);
215
216 let r = recover_repo(&mut store, &repo, &fs, &dir, TS, &cfg).unwrap();
217 assert_eq!(r.materialized, ["a.md"]);
218 assert!(r.healed.is_empty(), "the file was owed, not divergent");
219 assert!(r.missing.is_empty());
220 assert_eq!(
221 std::fs::read_to_string(dir.join("a.md")).unwrap(),
222 "---\nstatus: done\n---\n\n# A\n"
223 );
224 assert!(store.pending_materializations(&repo).unwrap().is_empty());
225 assert_eq!(
226 r.to_json(),
227 serde_json::json!({ "healed": [], "missing": [] }),
228 "step 0 is not on the wire"
229 );
230 let _ = std::fs::remove_dir_all(&dir);
231 }
232}