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, WatchEvent};
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. The stream yields [`WatchEvent::Ready`] once the feed is
159    /// primed (a host waits for it — [`crate::wait_ready`] — before its
160    /// priming sweep, §5), then batches. Drive it with
161    /// [`Coordinator::handle_batches`] or call [`Coordinator::reconcile`] per
162    /// received batch.
163    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    /// Reconcile every batch the stream yields until it closes (the adapter
171    /// exited or `unwatch` ran), reporting each summary or error. `Ready` is
172    /// not a batch and is skipped.
173    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    /// Stop the watch stream.
191    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            // Again: everything echoes.
218            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            // Observed commits are never exported.
225            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        // An engine-authored (api) commit exports; a gone path deletes.
230        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        // The written file comes back as an echo: the loop terminates.
257        {
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        // 60 api commits via docs_create so paging (limit 50) is exercised.
273        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        // Read-only source: nothing exported, cursor unchanged.
302        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        // Stopping the watch drops the sender, so the stream ends.
328        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}