1use std::path::Path;
6
7use omgbase_reconcile::Config;
8use omgbase_store::{BatchItem, BatchOutcome, Store};
9use rusqlite::params;
10use serde_json::Value;
11
12use crate::error::Result;
13use crate::fs::FileSystem;
14
15#[derive(Clone, Debug, Default, PartialEq, Eq)]
17pub struct CheckpointResult {
18 pub checkpoint_id: String,
19 pub ingested: Vec<String>,
21 pub suppressed: Vec<String>,
23 pub deleted: Vec<String>,
25 pub conflicted: Vec<String>,
27}
28
29impl CheckpointResult {
30 #[must_use]
32 pub fn to_json(&self) -> Value {
33 serde_json::json!({
34 "checkpoint_id": self.checkpoint_id,
35 "ingested": self.ingested,
36 "suppressed": self.suppressed,
37 "deleted": self.deleted,
38 "conflicted": self.conflicted,
39 })
40 }
41}
42
43#[must_use]
46pub fn files_json(outcomes: &[BatchOutcome]) -> Value {
47 Value::Array(
48 outcomes
49 .iter()
50 .map(|o| match o {
51 BatchOutcome::Deleted(d) => {
52 serde_json::json!([d.path, d.old_hash_hex, Value::Null])
53 }
54 BatchOutcome::Observed(ob) => {
55 serde_json::json!([ob.path, ob.old_hash_hex, ob.new_hash_hex])
56 }
57 })
58 .collect(),
59 )
60}
61
62pub fn finish_checkpoint(
65 store: &mut Store,
66 repo_id: &str,
67 outcomes: &[BatchOutcome],
68 ts: &str,
69 git_head: Option<&str>,
70) -> Result<CheckpointResult> {
71 let checkpoint_id = store.mint("cp");
72 let mut result = CheckpointResult {
73 checkpoint_id: checkpoint_id.clone(),
74 ..CheckpointResult::default()
75 };
76 for o in outcomes {
77 match o {
78 BatchOutcome::Deleted(d) => {
79 if d.doc_id.is_some() {
80 result.deleted.push(d.path.clone());
81 }
82 }
83 BatchOutcome::Observed(ob) => {
84 if ob.echo {
85 result.suppressed.push(ob.path.clone());
86 } else if ob.conflicted {
87 result.conflicted.push(ob.path.clone());
88 } else {
89 result.ingested.push(ob.path.clone());
90 }
91 }
92 }
93 }
94 store.conn().execute(
95 "INSERT INTO checkpoints (id, repo_id, ts, files, git_head) VALUES (?1, ?2, ?3, ?4, ?5)",
96 params![
97 checkpoint_id,
98 repo_id,
99 ts,
100 files_json(outcomes).to_string(),
101 git_head
102 ],
103 )?;
104 store.sweep_pool(ts)?;
105 Ok(result)
106}
107
108#[allow(clippy::too_many_arguments)]
111pub fn process_checkpoint(
112 store: &mut Store,
113 repo_id: &str,
114 fs: &dyn FileSystem,
115 root: &Path,
116 paths: &[String],
117 ts: &str,
118 git_head: Option<&str>,
119 config: &Config,
120) -> Result<CheckpointResult> {
121 let mut items = Vec::with_capacity(paths.len());
122 for path in paths {
123 items.push(BatchItem {
124 path: path.clone(),
125 source: fs.read(root, path)?,
126 });
127 }
128 let outcomes = store.observe_batch(repo_id, &items, ts, config)?;
129 finish_checkpoint(store, repo_id, &outcomes, ts, git_head)
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135 use crate::fs::MemFileSystem;
136 use omgbase_store::SequentialMinter;
137
138 const TS: &str = "2026-09-26T10:00:00.000Z";
139
140 #[test]
141 fn checkpoint_buckets_and_files_column() {
142 let mut store =
143 Store::open_in_memory_with_minter(Box::new(SequentialMinter::new())).unwrap();
144 let repo = store.create_repo("fixture").unwrap();
145 let mut fs = MemFileSystem::new();
146 fs.set("a.md", "# A\n\nOne.\n", 1);
147 fs.set("c.md", "<<<<<<< HEAD\nx\n=======\ny\n>>>>>>> b\n", 2);
148 let root = Path::new("/r");
149 let paths: Vec<String> = ["a.md", "c.md", "gone.md"]
150 .iter()
151 .map(|s| (*s).to_owned())
152 .collect();
153 let r = process_checkpoint(
154 &mut store,
155 &repo,
156 &fs,
157 root,
158 &paths,
159 TS,
160 None,
161 &Config::default(),
162 )
163 .unwrap();
164 assert_eq!(r.checkpoint_id, "cp_0");
165 assert_eq!(r.ingested, ["a.md"]);
166 assert_eq!(r.conflicted, ["c.md"]);
167 assert!(r.deleted.is_empty(), "nothing was live at gone.md");
168 assert!(r.suppressed.is_empty());
169
170 fs.remove("c.md");
172 let r2 = process_checkpoint(
173 &mut store,
174 &repo,
175 &fs,
176 root,
177 &paths,
178 TS,
179 Some("abc123"),
180 &Config::default(),
181 )
182 .unwrap();
183 assert_eq!(r2.checkpoint_id, "cp_1");
184 assert_eq!(r2.suppressed, ["a.md"]);
185 assert_eq!(r2.deleted, ["c.md"]);
186 assert_eq!(r2.to_json()["deleted"], serde_json::json!(["c.md"]));
187
188 let rows: Vec<(String, String, Option<String>)> = {
189 let mut stmt = store
190 .conn()
191 .prepare(
192 "SELECT id, files, git_head FROM checkpoints WHERE repo_id = ?1 ORDER BY rowid",
193 )
194 .unwrap();
195 stmt.query_map(params![repo], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))
196 .unwrap()
197 .map(|r| r.unwrap())
198 .collect()
199 };
200 assert_eq!(rows.len(), 2);
201 let files: Value = serde_json::from_str(&rows[0].1).unwrap();
202 assert_eq!(files.as_array().unwrap().len(), 3);
203 assert_eq!(files[0][0], "a.md");
204 assert_eq!(files[0][1], Value::Null);
205 assert_eq!(files[0][2].as_str().unwrap().len(), 64);
206 assert_eq!(files[2], serde_json::json!(["gone.md", null, null]));
207 assert_eq!(rows[1].2.as_deref(), Some("abc123"));
208 let files2: Value = serde_json::from_str(&rows[1].1).unwrap();
209 assert_eq!(files2[0][1], files2[0][2], "an echo repeats the hash");
210 assert_eq!(files2[1][2], Value::Null);
211 assert!(files2[1][1].is_string(), "the gone member's prior hash");
212 assert!(!rows[0].1.contains(' '), "compact JSON like JSON.stringify");
213 }
214}