Skip to main content

omgbase_sync/
freshness.rs

1//! The freshness sweep (`spec/sync/README.md` §4.3): the `file_stats` cache
2//! against a filesystem snapshot — as a pure plan ([`sweep_plan`]) and as the
3//! I/O around it; read-only disk drift; the cache rebuild.
4
5use std::collections::{HashMap, HashSet};
6use std::path::Path;
7
8use omgbase_format::hash::{hex, sha256};
9use omgbase_reconcile::Config;
10use omgbase_store::Store;
11use rusqlite::params;
12use serde_json::Value;
13
14use crate::checkpoint::{CheckpointResult, process_checkpoint};
15use crate::error::Result;
16use crate::fs::{FileStat, FileSystem};
17
18/// A `file_stats` row.
19#[derive(Clone, Debug, PartialEq, Eq)]
20pub struct CacheRow {
21    pub path: String,
22    pub mtime_ns: i64,
23    pub size: i64,
24    pub hash: [u8; 32],
25}
26
27/// One walked file with its stat.
28#[derive(Clone, Debug, PartialEq, Eq)]
29pub struct DiskEntry {
30    pub path: String,
31    pub stat: FileStat,
32}
33
34/// The §4.3 decisions over a snapshot.
35#[derive(Clone, Debug, Default, PartialEq, Eq)]
36pub struct SweepPlan {
37    /// Paths not in the cache or whose `(mtime_ns, size)` differs, in walk order.
38    pub candidates: Vec<String>,
39    /// Candidates whose bytes' hash differs from the cache (or have no row).
40    pub changed: Vec<String>,
41    /// Cached paths not on disk, in cache order.
42    pub deletions: Vec<String>,
43    /// Candidates whose hash matched: only the stat is refreshed.
44    pub refreshed: Vec<String>,
45    /// The hash of every candidate (for the cache refresh).
46    pub hashes: HashMap<String, [u8; 32]>,
47}
48
49impl SweepPlan {
50    /// `{ candidates, changed, deletions, refreshed }` (the `pure.json` shape).
51    #[must_use]
52    pub fn to_json(&self) -> Value {
53        serde_json::json!({
54            "candidates": self.candidates,
55            "changed": self.changed,
56            "deletions": self.deletions,
57            "refreshed": self.refreshed,
58        })
59    }
60
61    /// The paths a checkpoint processes: `changed ++ deletions`.
62    #[must_use]
63    pub fn to_ingest(&self) -> Vec<String> {
64        self.changed
65            .iter()
66            .chain(self.deletions.iter())
67            .cloned()
68            .collect()
69    }
70}
71
72/// §4.3 steps 1–2 as a pure function: `hash_of(path)` is called once per
73/// candidate, in candidate order.
74pub fn sweep_plan(
75    cache: &[CacheRow],
76    disk: &[DiskEntry],
77    hash_of: &mut dyn FnMut(&str) -> Result<[u8; 32]>,
78) -> Result<SweepPlan> {
79    let cached: HashMap<&str, &CacheRow> = cache.iter().map(|r| (r.path.as_str(), r)).collect();
80    let seen: HashSet<&str> = disk.iter().map(|d| d.path.as_str()).collect();
81    let mut plan = SweepPlan::default();
82    for d in disk {
83        let differs = cached
84            .get(d.path.as_str())
85            .is_none_or(|c| c.mtime_ns != d.stat.mtime_ns || c.size != d.stat.size);
86        if differs {
87            plan.candidates.push(d.path.clone());
88        }
89    }
90    for c in cache {
91        if !seen.contains(c.path.as_str()) {
92            plan.deletions.push(c.path.clone());
93        }
94    }
95    for path in &plan.candidates {
96        let hash = hash_of(path)?;
97        plan.hashes.insert(path.clone(), hash);
98        if cached.get(path.as_str()).is_none_or(|c| c.hash != hash) {
99            plan.changed.push(path.clone());
100        } else {
101            plan.refreshed.push(path.clone());
102        }
103    }
104    Ok(plan)
105}
106
107/// The repo's `file_stats` rows in row order.
108pub fn load_cache(store: &Store, repo_id: &str) -> Result<Vec<CacheRow>> {
109    let mut stmt = store.conn().prepare(
110        "SELECT path, mtime_ns, size, hash FROM file_stats WHERE repo_id = ?1 ORDER BY rowid",
111    )?;
112    let rows = stmt.query_map(params![repo_id], |r| {
113        let hash: Vec<u8> = r.get(3)?;
114        Ok(CacheRow {
115            path: r.get(0)?,
116            mtime_ns: r.get(1)?,
117            size: r.get(2)?,
118            hash: hash.try_into().unwrap_or([0; 32]),
119        })
120    })?;
121    Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
122}
123
124/// The walk with each file's stat (a file that vanished between the walk and
125/// the stat is skipped).
126pub fn snapshot(fs: &dyn FileSystem, root: &Path) -> Result<Vec<DiskEntry>> {
127    let mut out = Vec::new();
128    for path in fs.walk_markdown(root)? {
129        if let Some(stat) = fs.stat(root, &path)? {
130            out.push(DiskEntry { path, stat });
131        }
132    }
133    Ok(out)
134}
135
136/// Hash the file at `path` (`sha256` of its bytes); `None` when absent.
137fn hash_file(fs: &dyn FileSystem, root: &Path, path: &str) -> Result<Option<[u8; 32]>> {
138    Ok(fs.read(root, path)?.map(|s| sha256(s.as_bytes())))
139}
140
141/// §4.3 step 4 `record_file_stat`: upsert the fresh stat and `hash`; a path
142/// that vanished meanwhile deletes its row.
143pub fn record_file_stat(
144    store: &Store,
145    repo_id: &str,
146    fs: &dyn FileSystem,
147    root: &Path,
148    path: &str,
149    hash: &[u8; 32],
150) -> Result<()> {
151    match fs.stat(root, path)? {
152        None => {
153            store.conn().execute(
154                "DELETE FROM file_stats WHERE repo_id = ?1 AND path = ?2",
155                params![repo_id, path],
156            )?;
157        }
158        Some(st) => {
159            store.conn().execute(
160                "INSERT INTO file_stats (repo_id, path, mtime_ns, size, hash) VALUES (?1, ?2, ?3, ?4, ?5)
161                 ON CONFLICT(repo_id, path) DO UPDATE SET mtime_ns = excluded.mtime_ns, size = excluded.size, hash = excluded.hash",
162                params![repo_id, path, st.mtime_ns, st.size, &hash[..]],
163            )?;
164        }
165    }
166    Ok(())
167}
168
169/// The checkpoint result plus the scan counters.
170#[derive(Clone, Debug, PartialEq, Eq)]
171pub struct SweepResult {
172    /// Step 0 (store 14.0): the paths whose pending materialization this
173    /// sweep landed before walking (not on the wire).
174    pub materialized: Vec<String>,
175    pub checkpoint: CheckpointResult,
176    /// Files walked.
177    pub scanned: usize,
178    /// Stat-mismatched (or new) files hashed.
179    pub candidates: usize,
180    /// `ingested`, `deleted` or `conflicted` non-empty.
181    pub changed: bool,
182}
183
184impl SweepResult {
185    /// The checkpoint fields plus `scanned`, `candidates`, `changed`.
186    #[must_use]
187    pub fn to_json(&self) -> Value {
188        let mut v = self.checkpoint.to_json();
189        v["scanned"] = Value::from(self.scanned);
190        v["candidates"] = Value::from(self.candidates);
191        v["changed"] = Value::from(self.changed);
192        v
193    }
194}
195
196/// §4.3: step 0 — replay the repo's pending materializations (store 14.0:
197/// the files an `api` commit still owes, left by a crash before they
198/// landed) through the filesystem's [`DocStore`](omgbase_store::DocStore),
199/// so the walk below sees them as echoes rather than stale files; then plan
200/// over the cache and the snapshot, refresh touched-not-edited stats,
201/// checkpoint `changed ++ deletions` at `ts` (with `git_head` on the row),
202/// refresh the cache for the changed paths and drop the deletions' rows.
203pub fn freshness_sweep(
204    store: &mut Store,
205    repo_id: &str,
206    fs: &dyn FileSystem,
207    root: &Path,
208    ts: &str,
209    git_head: Option<&str>,
210    config: &Config,
211) -> Result<SweepResult> {
212    let materialized = materialize_pending(store, repo_id, fs, root)?;
213    let cache = load_cache(store, repo_id)?;
214    let disk = snapshot(fs, root)?;
215    let plan = {
216        let mut hash_of = |path: &str| -> Result<[u8; 32]> {
217            Ok(hash_file(fs, root, path)?.unwrap_or_else(|| sha256(b"")))
218        };
219        sweep_plan(&cache, &disk, &mut hash_of)?
220    };
221    let fresh: HashMap<&str, FileStat> = disk.iter().map(|d| (d.path.as_str(), d.stat)).collect();
222    for path in &plan.refreshed {
223        if let Some(st) = fresh.get(path.as_str()) {
224            store.conn().execute(
225                "UPDATE file_stats SET mtime_ns = ?1, size = ?2 WHERE repo_id = ?3 AND path = ?4",
226                params![st.mtime_ns, st.size, repo_id, path],
227            )?;
228        }
229    }
230    let checkpoint = process_checkpoint(
231        store,
232        repo_id,
233        fs,
234        root,
235        &plan.to_ingest(),
236        ts,
237        git_head,
238        config,
239    )?;
240    for path in &plan.changed {
241        let hash = plan.hashes.get(path).copied().unwrap_or([0; 32]);
242        record_file_stat(store, repo_id, fs, root, path, &hash)?;
243    }
244    for path in &plan.deletions {
245        store.conn().execute(
246            "DELETE FROM file_stats WHERE repo_id = ?1 AND path = ?2",
247            params![repo_id, path],
248        )?;
249    }
250    let changed = !checkpoint.ingested.is_empty()
251        || !checkpoint.deleted.is_empty()
252        || !checkpoint.conflicted.is_empty();
253    Ok(SweepResult {
254        materialized,
255        checkpoint,
256        scanned: disk.len(),
257        candidates: plan.candidates.len(),
258        changed,
259    })
260}
261
262/// §4.3 step 0 (store 14.0): [`Store::materialize_pending`] through the
263/// filesystem's write seam; nothing when the filesystem has none.
264pub fn materialize_pending(
265    store: &mut Store,
266    repo_id: &str,
267    fs: &dyn FileSystem,
268    root: &Path,
269) -> Result<Vec<String>> {
270    match fs.doc_store(root) {
271        Some(mut ds) => Ok(store.materialize_pending(repo_id, ds.as_mut())?),
272        None => Ok(Vec::new()),
273    }
274}
275
276/// How the database disagrees with the disk (§4.3 "Disk drift").
277#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
278pub struct DiskDrift {
279    /// Candidates whose live doc's `file_hash` differs from the bytes' hash.
280    pub changed: usize,
281    /// Live docs whose path is not on disk.
282    pub deleted: usize,
283    /// Candidates with no live doc at that path.
284    pub untracked: usize,
285}
286
287impl DiskDrift {
288    #[must_use]
289    pub fn is_clean(&self) -> bool {
290        self.changed == 0 && self.deleted == 0 && self.untracked == 0
291    }
292}
293
294/// Read-only: the same cache and snapshot, counted, touching nothing.
295pub fn detect_disk_drift(
296    store: &Store,
297    repo_id: &str,
298    fs: &dyn FileSystem,
299    root: &Path,
300) -> Result<DiskDrift> {
301    let cache = load_cache(store, repo_id)?;
302    let cached: HashMap<&str, &CacheRow> = cache.iter().map(|r| (r.path.as_str(), r)).collect();
303    let docs: HashMap<String, Option<Vec<u8>>> = {
304        let mut stmt = store.conn().prepare(
305            "SELECT path, file_hash FROM docs WHERE repo_id = ?1 AND deleted_commit IS NULL",
306        )?;
307        let rows = stmt.query_map(params![repo_id], |r| Ok((r.get(0)?, r.get(1)?)))?;
308        rows.collect::<std::result::Result<_, _>>()?
309    };
310    let disk = snapshot(fs, root)?;
311    let seen: HashSet<&str> = disk.iter().map(|d| d.path.as_str()).collect();
312    let mut drift = DiskDrift::default();
313    for d in &disk {
314        let differs = cached
315            .get(d.path.as_str())
316            .is_none_or(|c| c.mtime_ns != d.stat.mtime_ns || c.size != d.stat.size);
317        if !differs {
318            continue;
319        }
320        match docs.get(&d.path) {
321            None => drift.untracked += 1,
322            Some(file_hash) => {
323                let on_disk = hash_file(fs, root, &d.path)?;
324                let same = matches!((file_hash, on_disk), (Some(h), Some(od)) if h[..] == od[..]);
325                if !same {
326                    drift.changed += 1;
327                }
328            }
329        }
330    }
331    for path in docs.keys() {
332        if !seen.contains(path.as_str()) {
333            drift.deleted += 1;
334        }
335    }
336    Ok(drift)
337}
338
339/// §4.3 (since 1.1): delete the repo's rows, then walk the tree and record a
340/// row only for a file whose bytes' hash equals its live doc's `file_hash` —
341/// the cache may say "known" only about bytes the store already holds. A file
342/// with no live doc, or whose bytes differ from what was ingested, gets no row,
343/// so the next sweep still sees it as a candidate and drift still reports it.
344/// Returns the number of files walked, recorded or not.
345pub fn rebuild_file_stats(
346    store: &Store,
347    repo_id: &str,
348    fs: &dyn FileSystem,
349    root: &Path,
350) -> Result<usize> {
351    store.conn().execute(
352        "DELETE FROM file_stats WHERE repo_id = ?1",
353        params![repo_id],
354    )?;
355    let live: HashMap<String, Vec<u8>> = {
356        let mut stmt = store.conn().prepare(
357            "SELECT path, file_hash FROM docs
358             WHERE repo_id = ?1 AND deleted_commit IS NULL AND file_hash IS NOT NULL",
359        )?;
360        let rows = stmt.query_map(params![repo_id], |r| Ok((r.get(0)?, r.get(1)?)))?;
361        rows.collect::<std::result::Result<_, _>>()?
362    };
363    let paths = fs.walk_markdown(root)?;
364    for path in &paths {
365        let Some(want) = live.get(path) else {
366            continue; // no live doc: nothing the cache may vouch for
367        };
368        if let Some(hash) = hash_file(fs, root, path)? {
369            if hash[..] == want[..] {
370                record_file_stat(store, repo_id, fs, root, path, &hash)?;
371            }
372        }
373    }
374    Ok(paths.len())
375}
376
377/// The cache as the fixtures project it: `(path, mtime_ns, size, hash hex)`
378/// by `path`.
379pub fn file_stats_rows(store: &Store, repo_id: &str) -> Result<Vec<(String, i64, i64, String)>> {
380    let mut rows: Vec<(String, i64, i64, String)> = load_cache(store, repo_id)?
381        .into_iter()
382        .map(|c| (c.path, c.mtime_ns, c.size, hex(&c.hash)))
383        .collect();
384    rows.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
385    Ok(rows)
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391    use crate::fs::MemFileSystem;
392    use omgbase_store::SequentialMinter;
393
394    const TS: &str = "2026-09-26T10:00:00.000Z";
395    const ROOT: &str = "/r";
396
397    fn row(path: &str, mtime_ns: i64, content: &str) -> CacheRow {
398        CacheRow {
399            path: path.to_owned(),
400            mtime_ns,
401            size: content.len() as i64,
402            hash: sha256(content.as_bytes()),
403        }
404    }
405
406    fn entry(path: &str, mtime_ns: i64, content: &str) -> DiskEntry {
407        DiskEntry {
408            path: path.to_owned(),
409            stat: FileStat {
410                mtime_ns,
411                size: content.len() as i64,
412            },
413        }
414    }
415
416    #[test]
417    fn plan_decisions() {
418        let cache = [
419            row("a.md", 1, "A"),
420            row("b.md", 2, "B"),
421            row("gone.md", 3, "G"),
422        ];
423        let disk = [
424            entry("new.md", 9, "N"),
425            entry("a.md", 1, "A"), // unchanged stat: not a candidate
426            entry("b.md", 5, "B"), // touched, same bytes: refreshed
427        ];
428        let mut hashed = Vec::new();
429        let plan = sweep_plan(&cache, &disk, &mut |p: &str| {
430            hashed.push(p.to_owned());
431            Ok(sha256(match p {
432                "new.md" => b"N",
433                "b.md" => b"B",
434                _ => b"?",
435            }))
436        })
437        .unwrap();
438        assert_eq!(plan.candidates, ["new.md", "b.md"]);
439        assert_eq!(plan.changed, ["new.md"]);
440        assert_eq!(plan.refreshed, ["b.md"]);
441        assert_eq!(plan.deletions, ["gone.md"]);
442        assert_eq!(
443            hashed,
444            ["new.md", "b.md"],
445            "only candidates are hashed, in order"
446        );
447        assert_eq!(plan.to_ingest(), ["new.md", "gone.md"]);
448        assert_eq!(
449            plan.to_json(),
450            serde_json::json!({"candidates": ["new.md", "b.md"], "changed": ["new.md"], "deletions": ["gone.md"], "refreshed": ["b.md"]})
451        );
452        // Size change alone is a candidate; a differing hash is changed.
453        let plan = sweep_plan(
454            &[row("a.md", 1, "A")],
455            &[entry("a.md", 1, "AB")],
456            &mut |_| Ok(sha256(b"AB")),
457        )
458        .unwrap();
459        assert_eq!(plan.changed, ["a.md"]);
460        let empty = sweep_plan(&[], &[], &mut |_| unreachable!()).unwrap();
461        assert_eq!(empty, SweepPlan::default());
462    }
463
464    #[test]
465    fn sweep_drift_and_rebuild_over_a_mem_fs() {
466        let mut store =
467            Store::open_in_memory_with_minter(Box::new(SequentialMinter::new())).unwrap();
468        let repo = store.create_repo("fixture").unwrap();
469        let mut fs = MemFileSystem::new();
470        let root = Path::new(ROOT);
471        fs.set("a.md", "# A\n", 1);
472        fs.set("d/b.md", "# B\n", 2);
473        let cfg = Config::default();
474
475        let drift = detect_disk_drift(&store, &repo, &fs, root).unwrap();
476        assert_eq!(
477            drift,
478            DiskDrift {
479                changed: 0,
480                deleted: 0,
481                untracked: 2
482            }
483        );
484
485        let r = freshness_sweep(&mut store, &repo, &fs, root, TS, None, &cfg).unwrap();
486        assert_eq!((r.scanned, r.candidates, r.changed), (2, 2, true));
487        assert_eq!(r.checkpoint.ingested, ["a.md", "d/b.md"]);
488        assert_eq!(r.to_json()["scanned"], 2);
489        let stats = file_stats_rows(&store, &repo).unwrap();
490        assert_eq!(stats.len(), 2);
491        assert_eq!(stats[0].0, "a.md");
492        assert_eq!((stats[0].1, stats[0].2), (1, 4));
493        assert_eq!(stats[0].3, hex(&sha256(b"# A\n")));
494        assert!(
495            detect_disk_drift(&store, &repo, &fs, root)
496                .unwrap()
497                .is_clean()
498        );
499
500        // Quiet sweep: nothing hashed, nothing changed.
501        let r = freshness_sweep(&mut store, &repo, &fs, root, TS, None, &cfg).unwrap();
502        assert_eq!((r.scanned, r.candidates, r.changed), (2, 0, false));
503        assert!(r.checkpoint.ingested.is_empty());
504
505        // A touch without an edit: a candidate, refreshed, no ingest.
506        fs.set("a.md", "# A\n", 10);
507        let r = freshness_sweep(&mut store, &repo, &fs, root, TS, None, &cfg).unwrap();
508        assert_eq!((r.scanned, r.candidates, r.changed), (2, 1, false));
509        assert!(
510            r.checkpoint.suppressed.is_empty(),
511            "a refreshed path is not even observed"
512        );
513        assert_eq!(file_stats_rows(&store, &repo).unwrap()[0].1, 10);
514
515        // An edit and a deletion.
516        fs.set("a.md", "# A2\n", 11);
517        fs.remove("d/b.md");
518        assert_eq!(
519            detect_disk_drift(&store, &repo, &fs, root).unwrap(),
520            DiskDrift {
521                changed: 1,
522                deleted: 1,
523                untracked: 0
524            }
525        );
526        let r = freshness_sweep(&mut store, &repo, &fs, root, TS, None, &cfg).unwrap();
527        assert_eq!(r.checkpoint.ingested, ["a.md"]);
528        assert_eq!(r.checkpoint.deleted, ["d/b.md"]);
529        assert!(r.changed);
530        let stats = file_stats_rows(&store, &repo).unwrap();
531        assert_eq!(stats.len(), 1);
532        assert_eq!(stats[0].3, hex(&sha256(b"# A2\n")));
533
534        // Rebuild from scratch (1.1): a.md's bytes match its live doc and get a
535        // row; d/b.md is back on disk but its doc is tombstoned, so it gets no
536        // row and stays visible as untracked. The count is the walk, not the rows.
537        fs.set("d/b.md", "# B\n", 3);
538        assert_eq!(rebuild_file_stats(&store, &repo, &fs, root).unwrap(), 2);
539        let stats = file_stats_rows(&store, &repo).unwrap();
540        assert_eq!(stats.len(), 1);
541        assert_eq!(stats[0].0, "a.md");
542        assert_eq!(
543            detect_disk_drift(&store, &repo, &fs, root).unwrap(),
544            DiskDrift {
545                changed: 0,
546                deleted: 0,
547                untracked: 1
548            },
549            "a rebuild does not hide an untracked file"
550        );
551        let r = freshness_sweep(&mut store, &repo, &fs, root, TS, None, &cfg).unwrap();
552        assert_eq!(r.checkpoint.ingested, ["d/b.md"]);
553        assert_eq!(file_stats_rows(&store, &repo).unwrap().len(), 2);
554
555        // Rebuild over a pending edit: the edited file gets no row, drift and
556        // the sweep still see it; the rebuild's count still walks it.
557        fs.set("a.md", "# A3\n", 12);
558        assert_eq!(rebuild_file_stats(&store, &repo, &fs, root).unwrap(), 2);
559        let stats = file_stats_rows(&store, &repo).unwrap();
560        assert_eq!(stats.len(), 1);
561        assert_eq!(stats[0].0, "d/b.md");
562        assert_eq!(
563            detect_disk_drift(&store, &repo, &fs, root).unwrap(),
564            DiskDrift {
565                changed: 1,
566                deleted: 0,
567                untracked: 0
568            },
569            "a rebuild keeps a pending edit visible"
570        );
571        let r = freshness_sweep(&mut store, &repo, &fs, root, TS, None, &cfg).unwrap();
572        assert_eq!(r.checkpoint.ingested, ["a.md"]);
573        assert_eq!(
574            file_stats_rows(&store, &repo).unwrap()[0].3,
575            hex(&sha256(b"# A3\n"))
576        );
577
578        // record_file_stat deletes the row of a vanished file.
579        fs.remove("d/b.md");
580        record_file_stat(&store, &repo, &fs, root, "d/b.md", &[0; 32]).unwrap();
581        assert_eq!(file_stats_rows(&store, &repo).unwrap().len(), 1);
582    }
583
584    /// A doc store whose writes die: the api request commits, the file half
585    /// never runs (store 14.0, `spec/store` §5.7).
586    struct Crashed;
587
588    impl omgbase_store::DocStore for Crashed {
589        fn exists(&self, _path: &str) -> bool {
590            false
591        }
592        fn read(&self, _path: &str) -> omgbase_store::Result<Option<String>> {
593            Ok(None)
594        }
595        fn write(&mut self, path: &str, _bytes: &str) -> omgbase_store::Result<()> {
596            Err(omgbase_store::Error::Other(format!(
597                "crashed before {path}"
598            )))
599        }
600        fn rename(&mut self, from: &str, _to: &str) -> omgbase_store::Result<()> {
601            Err(omgbase_store::Error::Other(format!(
602                "crashed before {from}"
603            )))
604        }
605        fn remove(&mut self, path: &str) -> omgbase_store::Result<()> {
606            Err(omgbase_store::Error::Other(format!(
607                "crashed before {path}"
608            )))
609        }
610    }
611
612    fn append_tail(repo: &str, doc: &str) -> omgbase_store::ApplyRequest {
613        omgbase_store::ApplyRequest {
614            repo_id: repo.to_owned(),
615            ops: vec![omgbase_store::Op::Insert {
616                doc: Some(doc.to_owned()),
617                to: omgbase_store::To {
618                    parent: omgbase_store::Parent::Doc,
619                    at: omgbase_store::At::End,
620                },
621                markdown: "A tail.".to_owned(),
622                expect: None,
623            }],
624            origin: omgbase_store::ApplyOrigin::new("agent:test", Some("fixture")),
625            dry_run: false,
626        }
627    }
628
629    /// §4.3 step 0 (2.2): over the real filesystem the sweep replays the
630    /// pending rows a crashed request left before it walks, so the file the
631    /// api commit owed is brought forward and then seen as an echo — never
632    /// read back as a human edit.
633    #[test]
634    fn sweep_step_zero_materializes_pending_rows_over_the_real_fs() {
635        let dir = std::env::temp_dir().join(format!(
636            "omgbase-sync-sweep-{}-{}",
637            std::process::id(),
638            std::time::SystemTime::now()
639                .duration_since(std::time::UNIX_EPOCH)
640                .map(|d| d.as_nanos())
641                .unwrap_or(0)
642        ));
643        std::fs::create_dir_all(&dir).unwrap();
644        std::fs::write(dir.join("a.md"), "# A\n\nAlpha.\n").unwrap();
645        let mut store =
646            Store::open_in_memory_with_minter(Box::new(SequentialMinter::new())).unwrap();
647        let repo = store.create_repo("fixture").unwrap();
648        let fs = crate::fs::RealFileSystem;
649        let cfg = Config::default();
650        let r = freshness_sweep(&mut store, &repo, &fs, &dir, TS, None, &cfg).unwrap();
651        assert_eq!(r.checkpoint.ingested, ["a.md"]);
652        assert!(r.materialized.is_empty());
653
654        // The request commits; its file half dies.
655        let err = store
656            .apply(&append_tail(&repo, "a.md"), &mut Crashed, TS)
657            .unwrap_err();
658        assert!(err.to_string().starts_with("crashed before"), "{err}");
659        assert_eq!(store.pending_materializations(&repo).unwrap().len(), 1);
660        assert_eq!(
661            std::fs::read_to_string(dir.join("a.md")).unwrap(),
662            "# A\n\nAlpha.\n"
663        );
664
665        let r = freshness_sweep(&mut store, &repo, &fs, &dir, TS, None, &cfg).unwrap();
666        assert_eq!(r.materialized, ["a.md"], "step 0 landed the file");
667        assert_eq!(
668            std::fs::read_to_string(dir.join("a.md")).unwrap(),
669            "# A\n\nAlpha.\n\nA tail.\n"
670        );
671        assert_eq!(
672            r.checkpoint.suppressed,
673            ["a.md"],
674            "… which the walk then sees as an echo"
675        );
676        assert!(r.checkpoint.ingested.is_empty());
677        assert!(store.pending_materializations(&repo).unwrap().is_empty());
678        assert!(
679            detect_disk_drift(&store, &repo, &fs, &dir)
680                .unwrap()
681                .is_clean()
682        );
683        let observed: i64 = store
684            .conn()
685            .query_row(
686                "SELECT count(*) FROM commits WHERE origin = 'observed'",
687                [],
688                |r| r.get(0),
689            )
690            .unwrap();
691        assert_eq!(observed, 1, "the api intent was never re-ingested");
692        let _ = std::fs::remove_dir_all(&dir);
693    }
694
695    /// The gate rule through a sweep over the fixture filesystem (which has
696    /// no write seam, so step 0 is skipped): a human's bytes at a path with a
697    /// pending row are ingested — the file wins — and the row is cleared.
698    #[test]
699    fn sweep_over_a_mem_fs_lets_a_foreign_file_win_and_clears_the_row() {
700        let mut store =
701            Store::open_in_memory_with_minter(Box::new(SequentialMinter::new())).unwrap();
702        let repo = store.create_repo("fixture").unwrap();
703        let mut fs = MemFileSystem::new();
704        let root = Path::new(ROOT);
705        fs.set("a.md", "# A\n\nAlpha.\n", 1);
706        let cfg = Config::default();
707        freshness_sweep(&mut store, &repo, &fs, root, TS, None, &cfg).unwrap();
708        store
709            .apply(&append_tail(&repo, "a.md"), &mut Crashed, TS)
710            .unwrap_err();
711        assert_eq!(store.pending_materializations(&repo).unwrap().len(), 1);
712        fs.set("a.md", "# A\n\nAlpha, by a human.\n", 2);
713        let r = freshness_sweep(&mut store, &repo, &fs, root, TS, None, &cfg).unwrap();
714        assert!(r.materialized.is_empty(), "no write seam, no step 0");
715        assert_eq!(r.checkpoint.ingested, ["a.md"]);
716        assert!(store.pending_materializations(&repo).unwrap().is_empty());
717        let doc: String = store
718            .conn()
719            .query_row("SELECT doc_id FROM docs WHERE path = 'a.md'", [], |r| {
720                r.get(0)
721            })
722            .unwrap();
723        assert_eq!(
724            store.reconstruct(&doc).unwrap().as_deref(),
725            Some("# A\n\nAlpha, by a human.\n")
726        );
727    }
728}