Skip to main content

omgbase_sync/
coordinator.rs

1//! The coordinator (`spec/sync/README.md` §6): drives a source against an
2//! engine client with no reconciliation logic of its own — whole-file bytes
3//! in, the change feed out. Loop safety: the engine's echo gate makes a
4//! written file that comes back an echo, and observed commits are never
5//! exported.
6
7use 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/// What a `sync_in`/`reconcile` did, by path.
16#[derive(Clone, Debug, Default, PartialEq, Eq)]
17pub struct SyncInSummary {
18    pub ingested: Vec<String>,
19    /// Echoes: the bytes already matched, no commit.
20    pub suppressed: Vec<String>,
21    /// Ingested but carrying git conflict markers.
22    pub conflicted: Vec<String>,
23    /// Gone paths whose live doc was tombstoned.
24    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/// What a `sync_out` did.
40#[derive(Clone, Debug, Default, PartialEq, Eq)]
41pub struct SyncOutSummary {
42    /// The final feed cursor.
43    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
59/// A source paired with an engine.
60pub 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    /// source → engine: the source's full current scope.
71    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    /// source → engine for a set of paths: fetch each; present items go to
82    /// `observe_many` in one call, gone paths to `observe_delete` one by one.
83    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    /// engine → source: page `changes_since(cursor)`; for every digest whose
116    /// origin is not `observed`, every revision's doc is re-read by path and
117    /// written, or removed when it no longer reads; follows `truncated`
118    /// pages; returns the final cursor. A source without write-through
119    /// exports nothing and returns `cursor` unchanged.
120    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    /// Live source → engine: subscribe when the source can watch; `None`
158    /// otherwise. Drive the stream with [`Coordinator::handle_batches`] or
159    /// call [`Coordinator::reconcile`] per received batch.
160    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    /// Reconcile every batch the stream yields until it closes (the adapter
168    /// exited or `unwatch` ran), reporting each summary or error.
169    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    /// Stop the watch stream.
184    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            // Again: everything echoes.
211            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            // Observed commits are never exported.
218            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        // An engine-authored (api) commit exports; a gone path deletes.
223        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        // The written file comes back as an echo: the loop terminates.
250        {
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        // 60 api commits via docs_create so paging (limit 50) is exercised.
266        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        // Read-only source: nothing exported, cursor unchanged.
295        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        // Stopping the watch drops the sender, so the stream ends.
321        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}