1use omgbase_reconcile::Config;
6use omgbase_store::{BatchItem, Origin, Store};
7use serde_json::Value;
8
9use crate::checkpoint::{CheckpointResult, finish_checkpoint};
10use crate::error::Result;
11use crate::registry::ensure_repo;
12use crate::source::{SourceIdentity, SyncSource};
13
14pub fn reconcile_changes(
17 store: &mut Store,
18 repo_id: &str,
19 source: &mut dyn SyncSource,
20 paths: &[String],
21 ts: &str,
22 git_head: Option<&str>,
23 config: &Config,
24) -> Result<CheckpointResult> {
25 let mut items = Vec::with_capacity(paths.len());
26 for path in paths {
27 items.push(BatchItem {
28 path: path.clone(),
29 source: source.fetch(path)?.map(|it| it.content),
30 });
31 }
32 let outcomes = store.observe_batch(repo_id, &items, ts, config)?;
33 finish_checkpoint(store, repo_id, &outcomes, ts, git_head)
34}
35
36#[derive(Clone, Debug, PartialEq, Eq)]
38pub struct AttachResult {
39 pub repo_id: String,
40 pub file_count: usize,
41 pub all_converged: bool,
42}
43
44impl AttachResult {
45 #[must_use]
46 pub fn to_json(&self) -> Value {
47 serde_json::json!({
48 "repo_id": self.repo_id,
49 "file_count": self.file_count,
50 "all_converged": self.all_converged,
51 })
52 }
53}
54
55pub fn attach_source(
60 store: &mut Store,
61 slug: &str,
62 root_path: Option<&str>,
63 source: &mut dyn SyncSource,
64 ts: &str,
65 config: &Config,
66) -> Result<AttachResult> {
67 let repo_id = ensure_repo(store, slug, root_path)?;
68 let inferred = source.capabilities().identity == SourceIdentity::Inferred;
69 let mut file_count = 0;
70 let mut all_converged = true;
71 for entry in source.enumerate()? {
72 let Some(item) = source.fetch(&entry.path)? else {
73 continue;
74 };
75 let committed = if inferred {
76 store.reconciling_ingest(
77 &repo_id,
78 &entry.path,
79 &item.content,
80 ts,
81 Origin::Observed,
82 None,
83 None,
84 config,
85 )?
86 } else {
87 store.fresh_ingest(&repo_id, &entry.path, &item.content, ts, Origin::Observed)?
88 };
89 file_count += 1;
90 if !committed.converged {
91 all_converged = false;
92 }
93 }
94 Ok(AttachResult {
95 repo_id,
96 file_count,
97 all_converged,
98 })
99}
100
101#[cfg(test)]
102mod tests {
103 use super::*;
104 use crate::source::{MemSource, SourceCapabilities};
105 use omgbase_store::SequentialMinter;
106 use rusqlite::params;
107
108 const TS: &str = "2026-09-26T10:00:00.000Z";
109
110 #[test]
111 fn attach_then_reconcile_changes() {
112 let mut store =
113 Store::open_in_memory_with_minter(Box::new(SequentialMinter::new())).unwrap();
114 let cfg = Config::default();
115 let mut source = MemSource::with_files(&[("a.md", "# A\n\nOne.\n"), ("b.md", "# B\n")]);
116 let r = attach_source(
117 &mut store,
118 "vault",
119 Some("/data/vault"),
120 &mut source,
121 TS,
122 &cfg,
123 )
124 .unwrap();
125 assert_eq!(
126 r,
127 AttachResult {
128 repo_id: "rp_0".into(),
129 file_count: 2,
130 all_converged: true
131 }
132 );
133 assert_eq!(r.to_json()["file_count"], 2);
134 let fs_source: String = store
135 .conn()
136 .query_row("SELECT name FROM sources WHERE adapter = 'fs'", [], |r| {
137 r.get(0)
138 })
139 .unwrap();
140 assert_eq!(fs_source, "vault-fs");
141 let dispositions: i64 = store
142 .conn()
143 .query_row("SELECT count(*) FROM dispositions", [], |r| r.get(0))
144 .unwrap();
145 assert_eq!(
146 dispositions, 3,
147 "inferred identity: the reconciling resolver records dispositions"
148 );
149
150 let again = attach_source(&mut store, "vault", None, &mut source, TS, &cfg).unwrap();
152 assert_eq!(again.repo_id, "rp_0");
153 let commits: i64 = store
154 .conn()
155 .query_row("SELECT count(*) FROM commits", [], |r| r.get(0))
156 .unwrap();
157 assert_eq!(commits, 4);
158
159 source.set("a.md", "# A\n\nOne, edited.\n");
160 source.files.remove("b.md");
161 let paths: Vec<String> = ["a.md", "b.md", "nope.md"]
162 .iter()
163 .map(|s| (*s).to_owned())
164 .collect();
165 let cp = reconcile_changes(
166 &mut store,
167 "rp_0",
168 &mut source,
169 &paths,
170 TS,
171 Some("deadbeef"),
172 &cfg,
173 )
174 .unwrap();
175 assert_eq!(cp.ingested, ["a.md"]);
176 assert_eq!(cp.deleted, ["b.md"]);
177 assert_eq!(cp.checkpoint_id, "cp_0");
178 let head: Option<String> = store
179 .conn()
180 .query_row("SELECT git_head FROM checkpoints", [], |r| r.get(0))
181 .unwrap();
182 assert_eq!(head.as_deref(), Some("deadbeef"));
183 let cp = reconcile_changes(
184 &mut store,
185 "rp_0",
186 &mut source,
187 &["a.md".to_owned()],
188 TS,
189 None,
190 &cfg,
191 )
192 .unwrap();
193 assert_eq!(cp.suppressed, ["a.md"]);
194 }
195
196 #[test]
197 fn borne_sources_re_mint() {
198 let mut store =
199 Store::open_in_memory_with_minter(Box::new(SequentialMinter::new())).unwrap();
200 let mut source = MemSource::new(SourceCapabilities {
201 identity: SourceIdentity::Borne,
202 write_through: false,
203 watch: false,
204 });
205 source.set("a.md", "# A\n\nOne.\n");
206 let r = attach_source(&mut store, "b", None, &mut source, TS, &Config::default()).unwrap();
207 assert_eq!(r.file_count, 1);
208 assert!(r.all_converged);
209 let dispositions: i64 = store
210 .conn()
211 .query_row("SELECT count(*) FROM dispositions", [], |r| r.get(0))
212 .unwrap();
213 assert_eq!(dispositions, 0, "the re-mint path records no dispositions");
214 let blocks: i64 = store
215 .conn()
216 .query_row(
217 "SELECT count(*) FROM blocks WHERE repo_id = ?1",
218 params![r.repo_id],
219 |r| r.get(0),
220 )
221 .unwrap();
222 assert_eq!(blocks, 2);
223 assert!(
224 store
225 .conn()
226 .query_row("SELECT 1 FROM sources", [], |_| Ok(()))
227 .is_err(),
228 "no root: no fs source"
229 );
230 }
231}