Skip to main content

omgbase_sync/
admin.rs

1//! Status (`spec/sync/README.md` §4.4): `repos_status` and `sync_status`.
2//! Disk agreement is only ever reported from a read-only scan the caller
3//! asked for; without one `checked` is false and nothing is ever green.
4
5use std::path::Path;
6
7use omgbase_store::Store;
8use rusqlite::{OptionalExtension, params};
9use serde_json::Value;
10
11use crate::error::Result;
12use crate::freshness::{DiskDrift, detect_disk_drift};
13use crate::fs::FileSystem;
14
15/// The drift counts plus whether a scan ran.
16#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
17pub struct DiskStatus {
18    pub drift: DiskDrift,
19    /// `true` iff a root was supplied and the read-only scan ran.
20    pub checked: bool,
21}
22
23impl DiskStatus {
24    #[must_use]
25    pub fn to_json(&self) -> Value {
26        serde_json::json!({
27            "changed": self.drift.changed,
28            "deleted": self.drift.deleted,
29            "untracked": self.drift.untracked,
30            "checked": self.checked,
31        })
32    }
33}
34
35/// `repos_status`.
36#[derive(Clone, Debug, PartialEq, Eq)]
37pub struct RepoStatus {
38    pub repo_id: String,
39    pub slug: String,
40    /// §9: the caller's argument or `""`, never the derived root.
41    pub root_path: String,
42    pub docs: i64,
43    pub blocks: i64,
44    pub commits: i64,
45    pub open_edges: i64,
46    /// Live docs whose `file_hash` differs from their current revision's
47    /// `rendered_hash`.
48    pub unconverged: i64,
49    pub disk: DiskStatus,
50}
51
52impl RepoStatus {
53    /// The MCP shape (`camelCase`; `_id` is the root row's id — `spec/surface`
54    /// §1.2, 5.0 — the rest of the keys are the status's own).
55    #[must_use]
56    pub fn to_json(&self) -> Value {
57        serde_json::json!({
58            "_id": self.repo_id,
59            "slug": self.slug,
60            "rootPath": self.root_path,
61            "docs": self.docs,
62            "blocks": self.blocks,
63            "commits": self.commits,
64            "openEdges": self.open_edges,
65            "unconverged": self.unconverged,
66            "disk": self.disk.to_json(),
67        })
68    }
69}
70
71/// §4.4 `repos_status`: counts, `unconverged`, and the drift with `checked:
72/// true` when a root (and filesystem) was supplied.
73pub fn repos_status(
74    store: &Store,
75    repo_id: &str,
76    disk: Option<(&dyn FileSystem, &Path)>,
77) -> Result<RepoStatus> {
78    let conn = store.conn();
79    let slug: Option<String> = conn
80        .query_row(
81            "SELECT slug FROM repos WHERE repo_id = ?1",
82            params![repo_id],
83            |r| r.get(0),
84        )
85        .optional()?;
86    let count =
87        |sql: &str| -> Result<i64> { Ok(conn.query_row(sql, params![repo_id], |r| r.get(0))?) };
88    let unconverged = count(
89        "SELECT count(*) FROM docs d JOIN revisions r ON r.rev_id = d.current_rev
90         WHERE d.repo_id = ?1 AND d.deleted_commit IS NULL AND d.file_hash IS NOT r.rendered_hash",
91    )?;
92    let (root_path, disk) = match disk {
93        Some((fs, root)) => (
94            root.to_string_lossy().into_owned(),
95            DiskStatus {
96                drift: detect_disk_drift(store, repo_id, fs, root)?,
97                checked: true,
98            },
99        ),
100        None => (String::new(), DiskStatus::default()),
101    };
102    Ok(RepoStatus {
103        repo_id: repo_id.to_owned(),
104        slug: slug.unwrap_or_default(),
105        root_path,
106        docs: count("SELECT count(*) FROM docs WHERE repo_id = ?1 AND deleted_commit IS NULL")?,
107        blocks: count("SELECT count(*) FROM blocks WHERE repo_id = ?1 AND deleted_commit IS NULL")?,
108        commits: count("SELECT count(*) FROM commits WHERE repo_id = ?1")?,
109        open_edges: count("SELECT count(*) FROM edges WHERE repo_id = ?1 AND to_commit IS NULL")?,
110        unconverged,
111        disk,
112    })
113}
114
115/// `sync_status`.
116#[derive(Clone, Debug, PartialEq, Eq)]
117pub struct SyncStatus {
118    pub last_commit_seq: i64,
119    /// The latest checkpoint by `ts`.
120    pub last_checkpoint: Option<String>,
121    /// `unconverged == 0 && checked && no drift`.
122    pub convergent: bool,
123    pub disk: DiskStatus,
124}
125
126impl SyncStatus {
127    #[must_use]
128    pub fn to_json(&self) -> Value {
129        serde_json::json!({
130            "lastCommitSeq": self.last_commit_seq,
131            "lastCheckpoint": self.last_checkpoint,
132            "convergent": self.convergent,
133            "diskChecked": self.disk.checked,
134            "disk": self.disk.to_json(),
135        })
136    }
137}
138
139/// §4.4 `sync_status`.
140pub fn sync_status(
141    store: &Store,
142    repo_id: &str,
143    disk: Option<(&dyn FileSystem, &Path)>,
144) -> Result<SyncStatus> {
145    let conn = store.conn();
146    let last_commit_seq: i64 = conn.query_row(
147        "SELECT COALESCE(MAX(seq), 0) FROM commits WHERE repo_id = ?1",
148        params![repo_id],
149        |r| r.get(0),
150    )?;
151    let last_checkpoint: Option<String> = conn
152        .query_row(
153            "SELECT id FROM checkpoints WHERE repo_id = ?1 ORDER BY ts DESC LIMIT 1",
154            params![repo_id],
155            |r| r.get(0),
156        )
157        .optional()?;
158    let status = repos_status(store, repo_id, disk)?;
159    Ok(SyncStatus {
160        last_commit_seq,
161        last_checkpoint,
162        convergent: status.unconverged == 0 && status.disk.checked && status.disk.drift.is_clean(),
163        disk: status.disk,
164    })
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use crate::checkpoint::process_checkpoint;
171    use crate::fs::MemFileSystem;
172    use omgbase_reconcile::Config;
173    use omgbase_store::SequentialMinter;
174
175    const TS: &str = "2026-09-26T10:00:00.000Z";
176
177    #[test]
178    fn status_counts_and_never_green_unverified() {
179        let mut store =
180            Store::open_in_memory_with_minter(Box::new(SequentialMinter::new())).unwrap();
181        let repo = store.create_repo("fixture").unwrap();
182        let s = sync_status(&store, &repo, None).unwrap();
183        assert_eq!(s.last_commit_seq, 0);
184        assert_eq!(s.last_checkpoint, None);
185        assert!(!s.convergent, "an unverified disk is never convergent");
186        let rs = repos_status(&store, &repo, None).unwrap();
187        assert_eq!(
188            (
189                rs.docs,
190                rs.blocks,
191                rs.commits,
192                rs.open_edges,
193                rs.unconverged
194            ),
195            (0, 0, 0, 0, 0)
196        );
197        assert_eq!(rs.slug, "fixture");
198        assert_eq!(rs.root_path, "");
199        assert!(!rs.disk.checked);
200
201        let mut fs = MemFileSystem::new();
202        fs.set("a.md", "# A\n\nSee [b](b.md).\n", 1);
203        let root = Path::new("/r");
204        process_checkpoint(
205            &mut store,
206            &repo,
207            &fs,
208            root,
209            &["a.md".to_owned()],
210            TS,
211            None,
212            &Config::default(),
213        )
214        .unwrap();
215        let rs = repos_status(&store, &repo, Some((&fs, root))).unwrap();
216        assert_eq!(
217            (
218                rs.docs,
219                rs.blocks,
220                rs.commits,
221                rs.open_edges,
222                rs.unconverged
223            ),
224            (1, 2, 1, 1, 0)
225        );
226        assert_eq!(rs.root_path, "/r");
227        assert!(rs.disk.checked);
228        // The checkpoint did not record file_stats, so a.md is a candidate
229        // but its bytes match the doc: no drift.
230        assert!(rs.disk.drift.is_clean());
231        let s = sync_status(&store, &repo, Some((&fs, root))).unwrap();
232        assert_eq!(s.last_commit_seq, 1);
233        assert_eq!(s.last_checkpoint.as_deref(), Some("cp_0"));
234        assert!(s.convergent);
235        assert_eq!(s.to_json()["diskChecked"], true);
236        assert_eq!(rs.to_json()["openEdges"], 1);
237
238        fs.set("a.md", "# A\n\nEdited.\n", 2);
239        let s = sync_status(&store, &repo, Some((&fs, root))).unwrap();
240        assert!(!s.convergent);
241        assert_eq!(s.disk.drift.changed, 1);
242
243        // unconverged: a doc whose file_hash no longer matches its revision.
244        store
245            .conn()
246            .execute("UPDATE docs SET file_hash = x'00'", [])
247            .unwrap();
248        assert_eq!(repos_status(&store, &repo, None).unwrap().unconverged, 1);
249        assert_eq!(repos_status(&store, "rp_nope", None).unwrap().slug, "");
250    }
251}