Skip to main content

omgbase_sync/
source.rs

1//! The sync source contract (`spec/sync/README.md` §5): the in-engine view
2//! of an adapter — capabilities, `enumerate`/`fetch`, `write`/`remove` when
3//! it writes through, a `watch` stream of [`WatchEvent`]s when it can watch
4//! (one `Ready` once the feed is primed, then `Batch`es — §5 "Readiness").
5//! Every call crosses a pipe in production
6//! ([`crate::external::ExternalSource`]); an in-memory source serves tests.
7
8use std::collections::BTreeMap;
9use std::sync::mpsc::{Receiver, RecvTimeoutError};
10use std::time::{Duration, Instant};
11
12use serde_json::Value;
13
14use crate::error::{Error, Result};
15
16/// How a source's identity relates to omgbase block identity.
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub enum SourceIdentity {
19    /// Anonymous bytes; the reconciler infers continuity.
20    Inferred,
21    /// Stable per-member ids upstream (no adapter exists; §9).
22    Borne,
23}
24
25impl SourceIdentity {
26    #[must_use]
27    pub const fn as_str(&self) -> &'static str {
28        match self {
29            SourceIdentity::Inferred => "inferred",
30            SourceIdentity::Borne => "borne",
31        }
32    }
33}
34
35/// The handshake's capabilities.
36#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37pub struct SourceCapabilities {
38    pub identity: SourceIdentity,
39    pub write_through: bool,
40    pub watch: bool,
41}
42
43impl Default for SourceCapabilities {
44    fn default() -> Self {
45        Self {
46            identity: SourceIdentity::Inferred,
47            write_through: false,
48            watch: false,
49        }
50    }
51}
52
53/// JavaScript truthiness (the reference's `Boolean(c.writeThrough)`).
54fn js_truthy(v: Option<&Value>) -> bool {
55    match v {
56        None | Some(Value::Null) => false,
57        Some(Value::Bool(b)) => *b,
58        Some(Value::Number(n)) => n.as_f64().is_some_and(|f| f != 0.0 && !f.is_nan()),
59        Some(Value::String(s)) => !s.is_empty(),
60        Some(Value::Array(_) | Value::Object(_)) => true,
61    }
62}
63
64impl SourceCapabilities {
65    /// From the handshake's `capabilities` object: `identity` is `borne`
66    /// only when it says so; the booleans by JavaScript truthiness.
67    #[must_use]
68    pub fn from_json(v: Option<&Value>) -> Self {
69        let obj = v.and_then(Value::as_object);
70        Self {
71            identity: match obj.and_then(|o| o.get("identity")).and_then(Value::as_str) {
72                Some("borne") => SourceIdentity::Borne,
73                _ => SourceIdentity::Inferred,
74            },
75            write_through: js_truthy(obj.and_then(|o| o.get("writeThrough"))),
76            watch: js_truthy(obj.and_then(|o| o.get("watch"))),
77        }
78    }
79
80    #[must_use]
81    pub fn to_json(&self) -> Value {
82        serde_json::json!({
83            "identity": self.identity.as_str(),
84            "writeThrough": self.write_through,
85            "watch": self.watch,
86        })
87    }
88}
89
90/// A member of the scope: its storage key and cheap change token.
91#[derive(Clone, Debug, PartialEq, Eq)]
92pub struct SourceEntry {
93    /// The repo-relative canonical path (`docs.path`).
94    pub path: String,
95    /// The source's change token (the fs adapter: `"<mtime_ns>:<size>"`).
96    pub revision: String,
97    /// The source locator when it differs from `path`.
98    pub source_id: Option<String>,
99}
100
101impl SourceEntry {
102    /// From a protocol `entries[]` element; `None` when `path` is missing.
103    #[must_use]
104    pub fn from_json(v: &Value) -> Option<Self> {
105        Some(Self {
106            path: v.get("path")?.as_str()?.to_owned(),
107            revision: match v.get("revision") {
108                Some(Value::String(s)) => s.clone(),
109                Some(other) if !other.is_null() => other.to_string(),
110                _ => String::new(),
111            },
112            source_id: v.get("sourceId").and_then(Value::as_str).map(str::to_owned),
113        })
114    }
115}
116
117impl SourceEntry {
118    /// `{ path, revision, sourceId? }` as the protocol carries it.
119    #[must_use]
120    pub fn to_json(&self) -> Value {
121        let mut v = serde_json::json!({ "path": self.path, "revision": self.revision });
122        if let Some(id) = &self.source_id {
123            v["sourceId"] = Value::String(id.clone());
124        }
125        v
126    }
127}
128
129/// A hydrated member: the entry plus its bytes.
130#[derive(Clone, Debug, PartialEq, Eq)]
131pub struct SourceItem {
132    pub entry: SourceEntry,
133    /// The engine hashes this itself (§5).
134    pub content: String,
135}
136
137impl SourceItem {
138    /// From a protocol `item`; `None` for `null`/missing or no `path`.
139    #[must_use]
140    pub fn from_json(v: Option<&Value>) -> Option<Self> {
141        let v = v?;
142        if v.is_null() {
143            return None;
144        }
145        let entry = SourceEntry::from_json(v)?;
146        let content = match v.get("content") {
147            Some(Value::String(s)) => s.clone(),
148            _ => String::new(),
149        };
150        Some(Self { entry, content })
151    }
152
153    /// `{ path, revision, sourceId?, content }`.
154    #[must_use]
155    pub fn to_json(&self) -> Value {
156        let mut v = self.entry.to_json();
157        v["content"] = Value::String(self.content.clone());
158        v
159    }
160}
161
162/// One unsolicited line of a live watch, as the wire carries it (§5):
163/// `{"event":"ready"}` once the feed is primed, then `{"event":"batch",
164/// "paths":[…]}` per debounced batch of changed paths.
165#[derive(Clone, Debug, PartialEq, Eq)]
166pub enum WatchEvent {
167    /// Every change from now on will be reported (sent once, after the
168    /// `watch` response). An in-process source is ready at once.
169    Ready,
170    /// A batch of changed repo-relative paths.
171    Batch(Vec<String>),
172}
173
174impl WatchEvent {
175    /// The event as the protocol spells it (what the fixtures record).
176    #[must_use]
177    pub fn to_json(&self) -> Value {
178        match self {
179            WatchEvent::Ready => serde_json::json!({ "event": "ready" }),
180            WatchEvent::Batch(paths) => {
181                serde_json::json!({ "event": "batch", "paths": paths })
182            }
183        }
184    }
185}
186
187/// How [`wait_ready`] ended.
188#[derive(Clone, Copy, Debug, PartialEq, Eq)]
189pub enum Readiness {
190    /// `Ready` arrived.
191    Ready,
192    /// Patience ran out first (an adapter built before sync 1.2 never says
193    /// `ready`); a host proceeds as if ready, with a warning (§5).
194    TimedOut,
195    /// The stream closed first (the adapter exited or was unwatched).
196    Ended,
197}
198
199/// Wait up to `patience` for the stream's `Ready`. Any `Batch` that arrives
200/// first is returned alongside, in order, so nothing the adapter reported
201/// before readiness is lost to the caller.
202#[must_use]
203pub fn wait_ready(rx: &Receiver<WatchEvent>, patience: Duration) -> (Readiness, Vec<Vec<String>>) {
204    let deadline = Instant::now() + patience;
205    let mut early = Vec::new();
206    loop {
207        let now = Instant::now();
208        if now >= deadline {
209            return (Readiness::TimedOut, early);
210        }
211        match rx.recv_timeout(deadline - now) {
212            Ok(WatchEvent::Ready) => return (Readiness::Ready, early),
213            Ok(WatchEvent::Batch(paths)) => early.push(paths),
214            Err(RecvTimeoutError::Timeout) => return (Readiness::TimedOut, early),
215            Err(RecvTimeoutError::Disconnected) => return (Readiness::Ended, early),
216        }
217    }
218}
219
220/// The in-engine handle to a source.
221pub trait SyncSource {
222    fn capabilities(&self) -> SourceCapabilities;
223
224    /// The full current scope.
225    fn enumerate(&mut self) -> Result<Vec<SourceEntry>>;
226
227    /// One member's current state, or `None` when it left the scope.
228    fn fetch(&mut self, path: &str) -> Result<Option<SourceItem>>;
229
230    /// Persist engine-authored bytes (write-through sources only).
231    fn write(&mut self, _path: &str, _content: &str) -> Result<()> {
232        Err(Error::Unsupported("write".to_owned()))
233    }
234
235    /// Remove a member (write-through sources only).
236    fn remove(&mut self, _path: &str) -> Result<()> {
237        Err(Error::Unsupported("remove".to_owned()))
238    }
239
240    /// Subscribe to the watch stream (watching sources only): one
241    /// [`WatchEvent::Ready`] once the feed is primed, then one
242    /// [`WatchEvent::Batch`] per batch of changed paths.
243    fn watch(&mut self) -> Result<Receiver<WatchEvent>> {
244        Err(Error::Unsupported("watch".to_owned()))
245    }
246
247    /// Stop the batches.
248    fn unwatch(&mut self) -> Result<()> {
249        Ok(())
250    }
251
252    /// Release the process / resources.
253    fn close(&mut self) -> Result<()> {
254        Ok(())
255    }
256}
257
258/// An in-memory source for tests: a `path → content` map with a configurable
259/// capability set; writes and removes are applied to the map and logged.
260#[derive(Debug, Default)]
261pub struct MemSource {
262    pub caps: SourceCapabilities,
263    pub files: BTreeMap<String, String>,
264    /// `("write", path, content)` / `("remove", path, "")` in call order.
265    pub log: Vec<(&'static str, String, String)>,
266    /// Revision counter per path (bumped on every write).
267    revisions: BTreeMap<String, u64>,
268    events: Option<std::sync::mpsc::Sender<WatchEvent>>,
269}
270
271impl MemSource {
272    #[must_use]
273    pub fn new(caps: SourceCapabilities) -> Self {
274        Self {
275            caps,
276            ..Self::default()
277        }
278    }
279
280    /// A source with `path → content` files and every capability.
281    #[must_use]
282    pub fn with_files(files: &[(&str, &str)]) -> Self {
283        let mut s = Self::new(SourceCapabilities {
284            identity: SourceIdentity::Inferred,
285            write_through: true,
286            watch: true,
287        });
288        for (p, c) in files {
289            s.files.insert((*p).to_owned(), (*c).to_owned());
290        }
291        s
292    }
293
294    /// Set a file from "outside" (as if the human edited it).
295    pub fn set(&mut self, path: &str, content: &str) {
296        self.files.insert(path.to_owned(), content.to_owned());
297        *self.revisions.entry(path.to_owned()).or_insert(0) += 1;
298    }
299
300    /// Emit a watch batch (a no-op when nothing watches).
301    pub fn emit(&self, paths: &[&str]) {
302        if let Some(tx) = &self.events {
303            let _ = tx.send(WatchEvent::Batch(
304                paths.iter().map(|p| (*p).to_owned()).collect(),
305            ));
306        }
307    }
308
309    fn entry(&self, path: &str) -> SourceEntry {
310        SourceEntry {
311            path: path.to_owned(),
312            revision: self.revisions.get(path).copied().unwrap_or(0).to_string(),
313            source_id: None,
314        }
315    }
316}
317
318impl SyncSource for MemSource {
319    fn capabilities(&self) -> SourceCapabilities {
320        self.caps
321    }
322
323    fn enumerate(&mut self) -> Result<Vec<SourceEntry>> {
324        Ok(self.files.keys().map(|p| self.entry(p)).collect())
325    }
326
327    fn fetch(&mut self, path: &str) -> Result<Option<SourceItem>> {
328        Ok(self.files.get(path).map(|content| SourceItem {
329            entry: self.entry(path),
330            content: content.clone(),
331        }))
332    }
333
334    fn write(&mut self, path: &str, content: &str) -> Result<()> {
335        if !self.caps.write_through {
336            return Err(Error::Unsupported("write".to_owned()));
337        }
338        self.set(path, content);
339        self.log
340            .push(("write", path.to_owned(), content.to_owned()));
341        Ok(())
342    }
343
344    fn remove(&mut self, path: &str) -> Result<()> {
345        if !self.caps.write_through {
346            return Err(Error::Unsupported("remove".to_owned()));
347        }
348        self.files.remove(path);
349        self.log.push(("remove", path.to_owned(), String::new()));
350        Ok(())
351    }
352
353    /// An in-process source is ready at once: `Ready` is queued before
354    /// `watch` returns.
355    fn watch(&mut self) -> Result<Receiver<WatchEvent>> {
356        if !self.caps.watch {
357            return Err(Error::Unsupported("watch".to_owned()));
358        }
359        let (tx, rx) = std::sync::mpsc::channel();
360        let _ = tx.send(WatchEvent::Ready);
361        self.events = Some(tx);
362        Ok(rx)
363    }
364
365    fn unwatch(&mut self) -> Result<()> {
366        self.events = None;
367        Ok(())
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374    use serde_json::json;
375
376    #[test]
377    fn capabilities_parse_with_js_truthiness() {
378        let c = SourceCapabilities::from_json(Some(
379            &json!({"identity": "borne", "writeThrough": "yes", "watch": 0}),
380        ));
381        assert_eq!(c.identity, SourceIdentity::Borne);
382        assert!(c.write_through);
383        assert!(!c.watch);
384        let c = SourceCapabilities::from_json(Some(&json!({"identity": "weird", "watch": true})));
385        assert_eq!(c.identity, SourceIdentity::Inferred);
386        assert!(c.watch && !c.write_through);
387        assert_eq!(
388            SourceCapabilities::from_json(None),
389            SourceCapabilities::default()
390        );
391        assert_eq!(
392            SourceCapabilities::from_json(Some(&json!(null))),
393            SourceCapabilities::default()
394        );
395        assert_eq!(
396            c.to_json(),
397            json!({"identity": "inferred", "writeThrough": false, "watch": true})
398        );
399    }
400
401    #[test]
402    fn entries_and_items_parse() {
403        let e =
404            SourceEntry::from_json(&json!({"path": "a.md", "revision": "1:2", "sourceId": "x"}))
405                .unwrap();
406        assert_eq!(
407            (e.path.as_str(), e.revision.as_str(), e.source_id.as_deref()),
408            ("a.md", "1:2", Some("x"))
409        );
410        assert_eq!(
411            SourceEntry::from_json(&json!({"path": "a.md", "revision": 7}))
412                .unwrap()
413                .revision,
414            "7"
415        );
416        assert_eq!(SourceEntry::from_json(&json!({"revision": "1"})), None);
417        let it = SourceItem::from_json(Some(
418            &json!({"path": "a.md", "revision": "r", "content": "# A\n"}),
419        ))
420        .unwrap();
421        assert_eq!(it.content, "# A\n");
422        assert_eq!(
423            it.to_json(),
424            json!({"path": "a.md", "revision": "r", "content": "# A\n"})
425        );
426        assert_eq!(
427            e.to_json(),
428            json!({"path": "a.md", "revision": "1:2", "sourceId": "x"})
429        );
430        assert_eq!(SourceItem::from_json(Some(&json!(null))), None);
431        assert_eq!(SourceItem::from_json(None), None);
432    }
433
434    #[test]
435    fn mem_source_behaves() {
436        let mut s = MemSource::with_files(&[("a.md", "A")]);
437        assert_eq!(s.enumerate().unwrap()[0].path, "a.md");
438        assert_eq!(s.fetch("a.md").unwrap().unwrap().content, "A");
439        assert!(s.fetch("b.md").unwrap().is_none());
440        s.write("b.md", "B").unwrap();
441        s.remove("a.md").unwrap();
442        assert_eq!(s.log.len(), 2);
443        let rx = s.watch().unwrap();
444        s.emit(&["b.md"]);
445        assert_eq!(rx.recv().unwrap(), WatchEvent::Ready, "ready at once");
446        assert_eq!(
447            rx.recv().unwrap(),
448            WatchEvent::Batch(vec!["b.md".to_owned()])
449        );
450        s.unwatch().unwrap();
451        assert!(rx.recv().is_err(), "unwatch closes the stream");
452        s.close().unwrap();
453        let mut ro = MemSource::new(SourceCapabilities::default());
454        assert!(matches!(ro.write("x", "y"), Err(Error::Unsupported(_))));
455        assert!(matches!(ro.remove("x"), Err(Error::Unsupported(_))));
456        assert!(matches!(ro.watch(), Err(Error::Unsupported(_))));
457    }
458
459    #[test]
460    fn watch_events_spell_the_wire() {
461        assert_eq!(WatchEvent::Ready.to_json(), json!({"event": "ready"}));
462        assert_eq!(
463            WatchEvent::Batch(vec!["a.md".into(), "sub/b.md".into()]).to_json(),
464            json!({"event": "batch", "paths": ["a.md", "sub/b.md"]})
465        );
466    }
467
468    #[test]
469    fn wait_ready_keeps_early_batches_and_bounds_the_wait() {
470        let patience = Duration::from_millis(200);
471        // Ready after an early batch: both are reported.
472        let (tx, rx) = std::sync::mpsc::channel();
473        tx.send(WatchEvent::Batch(vec!["early.md".into()])).unwrap();
474        tx.send(WatchEvent::Ready).unwrap();
475        tx.send(WatchEvent::Batch(vec!["later.md".into()])).unwrap();
476        assert_eq!(
477            wait_ready(&rx, patience),
478            (Readiness::Ready, vec![vec!["early.md".to_owned()]])
479        );
480        assert_eq!(
481            rx.try_recv().unwrap(),
482            WatchEvent::Batch(vec!["later.md".into()]),
483            "what follows ready stays in the stream"
484        );
485        // No ready within patience: a timeout, with what did arrive.
486        let (tx, rx) = std::sync::mpsc::channel::<WatchEvent>();
487        tx.send(WatchEvent::Batch(vec!["a.md".into()])).unwrap();
488        let started = Instant::now();
489        assert_eq!(
490            wait_ready(&rx, patience),
491            (Readiness::TimedOut, vec![vec!["a.md".to_owned()]])
492        );
493        assert!(started.elapsed() >= patience);
494        // The stream closes first.
495        let (tx, rx) = std::sync::mpsc::channel::<WatchEvent>();
496        drop(tx);
497        assert_eq!(wait_ready(&rx, patience), (Readiness::Ended, vec![]));
498        // In-process sources are ready at once.
499        let mut s = MemSource::with_files(&[]);
500        let rx = s.watch().unwrap();
501        assert_eq!(wait_ready(&rx, patience), (Readiness::Ready, vec![]));
502    }
503}