1use std::sync::mpsc::Receiver;
8
9use serde_json::Value;
10
11use crate::engine::{EngineClient, FileBytes};
12use crate::error::Result;
13use crate::source::{SyncSource, WatchEvent};
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<WatchEvent>>> {
164 if !self.source.capabilities().watch {
165 return Ok(None);
166 }
167 Ok(Some(self.source.watch()?))
168 }
169
170 pub fn handle_batches(
174 &mut self,
175 events: &Receiver<WatchEvent>,
176 mut on_summary: impl FnMut(SyncInSummary),
177 mut on_error: impl FnMut(crate::error::Error),
178 ) {
179 for event in events.iter() {
180 let WatchEvent::Batch(paths) = event else {
181 continue;
182 };
183 match self.reconcile(&paths) {
184 Ok(s) => on_summary(s),
185 Err(e) => on_error(e),
186 }
187 }
188 }
189
190 pub fn stop_watch(&mut self) -> Result<()> {
192 self.source.unwatch()
193 }
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199 use crate::engine::InProcessEngineClient;
200 use crate::source::{MemSource, SourceCapabilities};
201 use omgbase_store::{NullDocStore, SequentialMinter, Store};
202
203 const TS: &str = "2026-09-26T10:00:00.000Z";
204
205 #[test]
206 fn sync_in_reconcile_and_sync_out_round_trip() {
207 let mut store =
208 Store::open_in_memory_with_minter(Box::new(SequentialMinter::new())).unwrap();
209 let repo = store.create_repo("fixture").unwrap();
210 let mut source = MemSource::with_files(&[("a.md", "# A\n\nOne.\n"), ("b.md", "# B\n")]);
211 {
212 let mut engine = InProcessEngineClient::new(&mut store, &repo).at(TS);
213 let mut co = Coordinator::new(&mut engine, &mut source);
214 let s = co.sync_in().unwrap();
215 assert_eq!(s.ingested, ["a.md", "b.md"]);
216 assert!(s.suppressed.is_empty() && s.deleted.is_empty() && s.conflicted.is_empty());
217 let s = co.sync_in().unwrap();
219 assert_eq!(s.suppressed, ["a.md", "b.md"]);
220 assert_eq!(
221 s.to_json()["suppressed"],
222 serde_json::json!(["a.md", "b.md"])
223 );
224 let out = co.sync_out(0).unwrap();
226 assert_eq!(out.cursor, 2);
227 assert!(out.written.is_empty() && out.removed.is_empty());
228 }
229 let ctx = omgbase_store::DocOpContext {
231 repo_id: repo.clone(),
232 actor: Some("agent:test".into()),
233 ts: TS.into(),
234 };
235 store
236 .docs_create(&ctx, &mut NullDocStore, "authored.md", "# Authored\n", None)
237 .unwrap();
238 source.files.remove("b.md");
239 source.set("c.md", "<<<<<<< a\nx\n=======\ny\n>>>>>>> b\n");
240 let out = {
241 let mut engine = InProcessEngineClient::new(&mut store, &repo).at(TS);
242 let mut co = Coordinator::new(&mut engine, &mut source);
243 let s = co
244 .reconcile(&["b.md".to_owned(), "c.md".to_owned(), "zzz.md".to_owned()])
245 .unwrap();
246 assert_eq!(s.deleted, ["b.md"]);
247 assert_eq!(s.conflicted, ["c.md"]);
248 co.sync_out(2).unwrap()
249 };
250 assert_eq!(out.written, ["authored.md"]);
251 assert!(out.removed.is_empty());
252 assert_eq!(out.cursor, 5);
253 assert_eq!(source.files["authored.md"], "# Authored\n");
254 assert_eq!(source.log.len(), 1);
255 assert_eq!(out.to_json()["cursor"], 5);
256 {
258 let mut engine = InProcessEngineClient::new(&mut store, &repo).at(TS);
259 let mut co = Coordinator::new(&mut engine, &mut source);
260 let s = co.reconcile(&["authored.md".to_owned()]).unwrap();
261 assert_eq!(s.suppressed, ["authored.md"]);
262 assert_eq!(co.sync_out(5).unwrap().cursor, 5);
263 }
264 }
265
266 #[test]
267 fn sync_out_removes_tombstoned_docs_and_pages() {
268 let mut store =
269 Store::open_in_memory_with_minter(Box::new(SequentialMinter::new())).unwrap();
270 let repo = store.create_repo("fixture").unwrap();
271 let mut source = MemSource::with_files(&[]);
272 let ctx = omgbase_store::DocOpContext {
274 repo_id: repo.clone(),
275 actor: Some("t".into()),
276 ts: TS.into(),
277 };
278 for i in 0..60 {
279 store
280 .docs_create(
281 &ctx,
282 &mut NullDocStore,
283 &format!("n{i:02}.md"),
284 "# N\n",
285 None,
286 )
287 .unwrap();
288 }
289 store
290 .docs_delete(&ctx, &mut NullDocStore, "n00.md")
291 .unwrap();
292 let out = {
293 let mut engine = InProcessEngineClient::new(&mut store, &repo).at(TS);
294 let mut co = Coordinator::new(&mut engine, &mut source);
295 co.sync_out(0).unwrap()
296 };
297 assert_eq!(out.cursor, 61);
298 assert_eq!(out.written.len(), 59);
299 assert_eq!(out.removed, ["n00.md"]);
300 assert_eq!(source.files.len(), 59);
301 let mut ro = MemSource::new(SourceCapabilities::default());
303 let mut engine = InProcessEngineClient::new(&mut store, &repo).at(TS);
304 let mut co = Coordinator::new(&mut engine, &mut ro);
305 assert_eq!(
306 co.sync_out(3).unwrap(),
307 SyncOutSummary {
308 cursor: 3,
309 ..SyncOutSummary::default()
310 }
311 );
312 assert!(co.watch_in().unwrap().is_none());
313 }
314
315 #[test]
316 fn watch_in_reconciles_batches() {
317 let mut store = Store::open_in_memory().unwrap();
318 let repo = store.create_repo("fixture").unwrap();
319 let mut source = MemSource::with_files(&[("a.md", "# A\n")]);
320 let rx = {
321 let mut engine = InProcessEngineClient::new(&mut store, &repo);
322 let mut co = Coordinator::new(&mut engine, &mut source);
323 co.watch_in().unwrap().expect("watching source")
324 };
325 source.emit(&["a.md"]);
326 source.emit(&["gone.md"]);
327 source.unwatch().unwrap();
329 let mut engine = InProcessEngineClient::new(&mut store, &repo);
330 let mut co = Coordinator::new(&mut engine, &mut source);
331 let mut summaries = Vec::new();
332 co.handle_batches(&rx, |s| summaries.push(s), |e| panic!("{e}"));
333 assert_eq!(summaries.len(), 2, "the leading `Ready` is not a batch");
334 assert_eq!(summaries[0].ingested, ["a.md"]);
335 assert!(
336 summaries[1].deleted.is_empty(),
337 "nothing was live at gone.md"
338 );
339 co.stop_watch().unwrap();
340 }
341}