1use 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#[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#[derive(Clone, Debug, PartialEq, Eq)]
29pub struct DiskEntry {
30 pub path: String,
31 pub stat: FileStat,
32}
33
34#[derive(Clone, Debug, Default, PartialEq, Eq)]
36pub struct SweepPlan {
37 pub candidates: Vec<String>,
39 pub changed: Vec<String>,
41 pub deletions: Vec<String>,
43 pub refreshed: Vec<String>,
45 pub hashes: HashMap<String, [u8; 32]>,
47}
48
49impl SweepPlan {
50 #[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 #[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
72pub 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
107pub 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
124pub 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
136fn 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
141pub 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#[derive(Clone, Debug, PartialEq, Eq)]
171pub struct SweepResult {
172 pub materialized: Vec<String>,
175 pub checkpoint: CheckpointResult,
176 pub scanned: usize,
178 pub candidates: usize,
180 pub changed: bool,
182}
183
184impl SweepResult {
185 #[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
196pub 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
262pub 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
278pub struct DiskDrift {
279 pub changed: usize,
281 pub deleted: usize,
283 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
294pub 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
339pub 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; };
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
377pub 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"), entry("b.md", 5, "B"), ];
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 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 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 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 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 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 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 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 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 #[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 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 #[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}