1use std::sync::mpsc::Receiver;
8
9use serde_json::Value;
10
11use crate::engine::{EngineClient, FileBytes};
12use crate::error::Result;
13use crate::source::SyncSource;
14
15#[derive(Clone, Debug, Default, PartialEq, Eq)]
17pub struct SyncInSummary {
18 pub ingested: Vec<String>,
19 pub suppressed: Vec<String>,
21 pub conflicted: Vec<String>,
23 pub deleted: Vec<String>,
25}
26
27impl SyncInSummary {
28 #[must_use]
29 pub fn to_json(&self) -> Value {
30 serde_json::json!({
31 "ingested": self.ingested,
32 "suppressed": self.suppressed,
33 "conflicted": self.conflicted,
34 "deleted": self.deleted,
35 })
36 }
37}
38
39#[derive(Clone, Debug, Default, PartialEq, Eq)]
41pub struct SyncOutSummary {
42 pub cursor: i64,
44 pub written: Vec<String>,
45 pub removed: Vec<String>,
46}
47
48impl SyncOutSummary {
49 #[must_use]
50 pub fn to_json(&self) -> Value {
51 serde_json::json!({
52 "cursor": self.cursor,
53 "written": self.written,
54 "removed": self.removed,
55 })
56 }
57}
58
59pub struct Coordinator<'a> {
61 engine: &'a mut dyn EngineClient,
62 source: &'a mut dyn SyncSource,
63}
64
65impl<'a> Coordinator<'a> {
66 pub fn new(engine: &'a mut dyn EngineClient, source: &'a mut dyn SyncSource) -> Self {
67 Self { engine, source }
68 }
69
70 pub fn sync_in(&mut self) -> Result<SyncInSummary> {
72 let paths: Vec<String> = self
73 .source
74 .enumerate()?
75 .into_iter()
76 .map(|e| e.path)
77 .collect();
78 self.reconcile(&paths)
79 }
80
81 pub fn reconcile(&mut self, paths: &[String]) -> Result<SyncInSummary> {
84 let mut files: Vec<FileBytes> = Vec::new();
85 let mut gone: Vec<String> = Vec::new();
86 for path in paths {
87 match self.source.fetch(path)? {
88 Some(item) => files.push(FileBytes {
89 path: path.clone(),
90 content: item.content,
91 }),
92 None => gone.push(path.clone()),
93 }
94 }
95 let mut summary = SyncInSummary::default();
96 if !files.is_empty() {
97 for r in self.engine.observe_many(&files)? {
98 if r.echo {
99 summary.suppressed.push(r.path);
100 } else if r.conflicted {
101 summary.conflicted.push(r.path);
102 } else {
103 summary.ingested.push(r.path);
104 }
105 }
106 }
107 for path in gone {
108 if self.engine.observe_delete(&path)?.deleted() {
109 summary.deleted.push(path);
110 }
111 }
112 Ok(summary)
113 }
114
115 pub fn sync_out(&mut self, cursor: i64) -> Result<SyncOutSummary> {
121 let mut summary = SyncOutSummary {
122 cursor,
123 ..SyncOutSummary::default()
124 };
125 if !self.source.capabilities().write_through {
126 return Ok(summary);
127 }
128 let mut cur = cursor;
129 loop {
130 let page = self.engine.changes_since(cur, None, None)?;
131 for digest in &page.digests {
132 if digest.origin == "observed" {
133 continue;
134 }
135 for rev in &digest.revisions {
136 match self.engine.read_doc(&rev.path)? {
137 Some(doc) => {
138 self.source.write(&rev.path, &doc.content)?;
139 summary.written.push(rev.path.clone());
140 }
141 None => {
142 self.source.remove(&rev.path)?;
143 summary.removed.push(rev.path.clone());
144 }
145 }
146 }
147 }
148 cur = page.cursor;
149 if !page.truncated {
150 break;
151 }
152 }
153 summary.cursor = cur;
154 Ok(summary)
155 }
156
157 pub fn watch_in(&mut self) -> Result<Option<Receiver<Vec<String>>>> {
161 if !self.source.capabilities().watch {
162 return Ok(None);
163 }
164 Ok(Some(self.source.watch()?))
165 }
166
167 pub fn handle_batches(
170 &mut self,
171 batches: &Receiver<Vec<String>>,
172 mut on_summary: impl FnMut(SyncInSummary),
173 mut on_error: impl FnMut(crate::error::Error),
174 ) {
175 for paths in batches.iter() {
176 match self.reconcile(&paths) {
177 Ok(s) => on_summary(s),
178 Err(e) => on_error(e),
179 }
180 }
181 }
182
183 pub fn stop_watch(&mut self) -> Result<()> {
185 self.source.unwatch()
186 }
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192 use crate::engine::InProcessEngineClient;
193 use crate::source::{MemSource, SourceCapabilities};
194 use omgbase_store::{NullDocStore, SequentialMinter, Store};
195
196 const TS: &str = "2026-09-26T10:00:00.000Z";
197
198 #[test]
199 fn sync_in_reconcile_and_sync_out_round_trip() {
200 let mut store =
201 Store::open_in_memory_with_minter(Box::new(SequentialMinter::new())).unwrap();
202 let repo = store.create_repo("fixture").unwrap();
203 let mut source = MemSource::with_files(&[("a.md", "# A\n\nOne.\n"), ("b.md", "# B\n")]);
204 {
205 let mut engine = InProcessEngineClient::new(&mut store, &repo).at(TS);
206 let mut co = Coordinator::new(&mut engine, &mut source);
207 let s = co.sync_in().unwrap();
208 assert_eq!(s.ingested, ["a.md", "b.md"]);
209 assert!(s.suppressed.is_empty() && s.deleted.is_empty() && s.conflicted.is_empty());
210 let s = co.sync_in().unwrap();
212 assert_eq!(s.suppressed, ["a.md", "b.md"]);
213 assert_eq!(
214 s.to_json()["suppressed"],
215 serde_json::json!(["a.md", "b.md"])
216 );
217 let out = co.sync_out(0).unwrap();
219 assert_eq!(out.cursor, 2);
220 assert!(out.written.is_empty() && out.removed.is_empty());
221 }
222 let ctx = omgbase_store::DocOpContext {
224 repo_id: repo.clone(),
225 actor: Some("agent:test".into()),
226 ts: TS.into(),
227 };
228 store
229 .docs_create(&ctx, &mut NullDocStore, "authored.md", "# Authored\n", None)
230 .unwrap();
231 source.files.remove("b.md");
232 source.set("c.md", "<<<<<<< a\nx\n=======\ny\n>>>>>>> b\n");
233 let out = {
234 let mut engine = InProcessEngineClient::new(&mut store, &repo).at(TS);
235 let mut co = Coordinator::new(&mut engine, &mut source);
236 let s = co
237 .reconcile(&["b.md".to_owned(), "c.md".to_owned(), "zzz.md".to_owned()])
238 .unwrap();
239 assert_eq!(s.deleted, ["b.md"]);
240 assert_eq!(s.conflicted, ["c.md"]);
241 co.sync_out(2).unwrap()
242 };
243 assert_eq!(out.written, ["authored.md"]);
244 assert!(out.removed.is_empty());
245 assert_eq!(out.cursor, 5);
246 assert_eq!(source.files["authored.md"], "# Authored\n");
247 assert_eq!(source.log.len(), 1);
248 assert_eq!(out.to_json()["cursor"], 5);
249 {
251 let mut engine = InProcessEngineClient::new(&mut store, &repo).at(TS);
252 let mut co = Coordinator::new(&mut engine, &mut source);
253 let s = co.reconcile(&["authored.md".to_owned()]).unwrap();
254 assert_eq!(s.suppressed, ["authored.md"]);
255 assert_eq!(co.sync_out(5).unwrap().cursor, 5);
256 }
257 }
258
259 #[test]
260 fn sync_out_removes_tombstoned_docs_and_pages() {
261 let mut store =
262 Store::open_in_memory_with_minter(Box::new(SequentialMinter::new())).unwrap();
263 let repo = store.create_repo("fixture").unwrap();
264 let mut source = MemSource::with_files(&[]);
265 let ctx = omgbase_store::DocOpContext {
267 repo_id: repo.clone(),
268 actor: Some("t".into()),
269 ts: TS.into(),
270 };
271 for i in 0..60 {
272 store
273 .docs_create(
274 &ctx,
275 &mut NullDocStore,
276 &format!("n{i:02}.md"),
277 "# N\n",
278 None,
279 )
280 .unwrap();
281 }
282 store
283 .docs_delete(&ctx, &mut NullDocStore, "n00.md")
284 .unwrap();
285 let out = {
286 let mut engine = InProcessEngineClient::new(&mut store, &repo).at(TS);
287 let mut co = Coordinator::new(&mut engine, &mut source);
288 co.sync_out(0).unwrap()
289 };
290 assert_eq!(out.cursor, 61);
291 assert_eq!(out.written.len(), 59);
292 assert_eq!(out.removed, ["n00.md"]);
293 assert_eq!(source.files.len(), 59);
294 let mut ro = MemSource::new(SourceCapabilities::default());
296 let mut engine = InProcessEngineClient::new(&mut store, &repo).at(TS);
297 let mut co = Coordinator::new(&mut engine, &mut ro);
298 assert_eq!(
299 co.sync_out(3).unwrap(),
300 SyncOutSummary {
301 cursor: 3,
302 ..SyncOutSummary::default()
303 }
304 );
305 assert!(co.watch_in().unwrap().is_none());
306 }
307
308 #[test]
309 fn watch_in_reconciles_batches() {
310 let mut store = Store::open_in_memory().unwrap();
311 let repo = store.create_repo("fixture").unwrap();
312 let mut source = MemSource::with_files(&[("a.md", "# A\n")]);
313 let rx = {
314 let mut engine = InProcessEngineClient::new(&mut store, &repo);
315 let mut co = Coordinator::new(&mut engine, &mut source);
316 co.watch_in().unwrap().expect("watching source")
317 };
318 source.emit(&["a.md"]);
319 source.emit(&["gone.md"]);
320 source.unwatch().unwrap();
322 let mut engine = InProcessEngineClient::new(&mut store, &repo);
323 let mut co = Coordinator::new(&mut engine, &mut source);
324 let mut summaries = Vec::new();
325 co.handle_batches(&rx, |s| summaries.push(s), |e| panic!("{e}"));
326 assert_eq!(summaries.len(), 2);
327 assert_eq!(summaries[0].ingested, ["a.md"]);
328 assert!(
329 summaries[1].deleted.is_empty(),
330 "nothing was live at gone.md"
331 );
332 co.stop_watch().unwrap();
333 }
334}