use std::path::Path;
use omgbase_reconcile::Config;
use omgbase_store::{BatchItem, BatchOutcome, Store};
use rusqlite::params;
use serde_json::Value;
use crate::error::Result;
use crate::fs::FileSystem;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct CheckpointResult {
pub checkpoint_id: String,
pub ingested: Vec<String>,
pub suppressed: Vec<String>,
pub deleted: Vec<String>,
pub conflicted: Vec<String>,
}
impl CheckpointResult {
#[must_use]
pub fn to_json(&self) -> Value {
serde_json::json!({
"checkpoint_id": self.checkpoint_id,
"ingested": self.ingested,
"suppressed": self.suppressed,
"deleted": self.deleted,
"conflicted": self.conflicted,
})
}
}
#[must_use]
pub fn files_json(outcomes: &[BatchOutcome]) -> Value {
Value::Array(
outcomes
.iter()
.map(|o| match o {
BatchOutcome::Deleted(d) => {
serde_json::json!([d.path, d.old_hash_hex, Value::Null])
}
BatchOutcome::Observed(ob) => {
serde_json::json!([ob.path, ob.old_hash_hex, ob.new_hash_hex])
}
})
.collect(),
)
}
pub fn finish_checkpoint(
store: &mut Store,
repo_id: &str,
outcomes: &[BatchOutcome],
ts: &str,
git_head: Option<&str>,
) -> Result<CheckpointResult> {
let checkpoint_id = store.mint("cp")?;
let mut result = CheckpointResult {
checkpoint_id: checkpoint_id.clone(),
..CheckpointResult::default()
};
for o in outcomes {
match o {
BatchOutcome::Deleted(d) => {
if d.doc_id.is_some() {
result.deleted.push(d.path.clone());
}
}
BatchOutcome::Observed(ob) => {
if ob.echo {
result.suppressed.push(ob.path.clone());
} else if ob.conflicted {
result.conflicted.push(ob.path.clone());
} else {
result.ingested.push(ob.path.clone());
}
}
}
}
let tx = store.transaction()?;
tx.execute(
"INSERT INTO checkpoints (id, repo_id, ts, files, git_head) VALUES (?1, ?2, ?3, ?4, ?5)",
params![
checkpoint_id,
repo_id,
ts,
files_json(outcomes).to_string(),
git_head
],
)?;
omgbase_store::derived::sweep_pool(&tx, ts)?;
tx.commit()?;
Ok(result)
}
#[allow(clippy::too_many_arguments)]
pub fn process_checkpoint(
store: &mut Store,
repo_id: &str,
fs: &dyn FileSystem,
root: &Path,
paths: &[String],
ts: &str,
git_head: Option<&str>,
config: &Config,
) -> Result<CheckpointResult> {
let mut items = Vec::with_capacity(paths.len());
for path in paths {
items.push(BatchItem {
path: path.clone(),
source: fs.read(root, path)?,
});
}
let outcomes = store.observe_batch(repo_id, &items, ts, config)?;
finish_checkpoint(store, repo_id, &outcomes, ts, git_head)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fs::MemFileSystem;
use omgbase_store::SequentialMinter;
use rusqlite::Connection;
const TS: &str = "2026-09-26T10:00:00.000Z";
#[test]
fn checkpoint_buckets_and_files_column() {
let mut store =
Store::open_in_memory_with_minter(Box::new(SequentialMinter::new())).unwrap();
let repo = store.create_repo("fixture").unwrap();
let mut fs = MemFileSystem::new();
fs.set("a.md", "# A\n\nOne.\n", 1);
fs.set("c.md", "<<<<<<< HEAD\nx\n=======\ny\n>>>>>>> b\n", 2);
let root = Path::new("/r");
let paths: Vec<String> = ["a.md", "c.md", "gone.md"]
.iter()
.map(|s| (*s).to_owned())
.collect();
let r = process_checkpoint(
&mut store,
&repo,
&fs,
root,
&paths,
TS,
None,
&Config::default(),
)
.unwrap();
assert_eq!(r.checkpoint_id, "cp_0");
assert_eq!(r.ingested, ["a.md"]);
assert_eq!(r.conflicted, ["c.md"]);
assert!(r.deleted.is_empty(), "nothing was live at gone.md");
assert!(r.suppressed.is_empty());
fs.remove("c.md");
let r2 = process_checkpoint(
&mut store,
&repo,
&fs,
root,
&paths,
TS,
Some("abc123"),
&Config::default(),
)
.unwrap();
assert_eq!(r2.checkpoint_id, "cp_1");
assert_eq!(r2.suppressed, ["a.md"]);
assert_eq!(r2.deleted, ["c.md"]);
assert_eq!(r2.to_json()["deleted"], serde_json::json!(["c.md"]));
let rows: Vec<(String, String, Option<String>)> = {
let mut stmt = store
.conn()
.prepare(
"SELECT id, files, git_head FROM checkpoints WHERE repo_id = ?1 ORDER BY rowid",
)
.unwrap();
stmt.query_map(params![repo], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))
.unwrap()
.map(|r| r.unwrap())
.collect()
};
assert_eq!(rows.len(), 2);
let files: Value = serde_json::from_str(&rows[0].1).unwrap();
assert_eq!(files.as_array().unwrap().len(), 3);
assert_eq!(files[0][0], "a.md");
assert_eq!(files[0][1], Value::Null);
assert_eq!(files[0][2].as_str().unwrap().len(), 64);
assert_eq!(files[2], serde_json::json!(["gone.md", null, null]));
assert_eq!(rows[1].2.as_deref(), Some("abc123"));
let files2: Value = serde_json::from_str(&rows[1].1).unwrap();
assert_eq!(files2[0][1], files2[0][2], "an echo repeats the hash");
assert_eq!(files2[1][2], Value::Null);
assert!(files2[1][1].is_string(), "the gone member's prior hash");
assert!(!rows[0].1.contains(' '), "compact JSON like JSON.stringify");
}
thread_local! {
static TRACE: std::cell::RefCell<Vec<String>> = const { std::cell::RefCell::new(Vec::new()) };
}
fn record_stmt(e: rusqlite::trace::TraceEvent<'_>) {
if let rusqlite::trace::TraceEvent::Stmt(_, sql) = e {
TRACE.with(|t| t.borrow_mut().push(sql.trim().to_owned()));
}
}
fn start_trace(conn: &Connection) {
TRACE.with(|t| t.borrow_mut().clear());
conn.trace_v2(
rusqlite::trace::TraceEventCodes::SQLITE_TRACE_STMT,
Some(record_stmt),
);
}
fn traced() -> Vec<String> {
TRACE.with(|t| t.borrow().clone())
}
fn tx_of(log: &[String], needle: &str) -> Option<usize> {
let i = log
.iter()
.position(|s| s.contains(needle))
.unwrap_or_else(|| panic!("no statement matching {needle:?} in {log:#?}"));
let begin = log[..i].iter().rposition(|s| s.starts_with("BEGIN"))?;
if log[begin..i]
.iter()
.any(|s| s.starts_with("COMMIT") || s.starts_with("ROLLBACK"))
{
return None;
}
let end = log[i..]
.iter()
.position(|s| {
s.starts_with("COMMIT") || s.starts_with("ROLLBACK") || s.starts_with("BEGIN")
})
.map(|k| i + k)?;
log[end].starts_with("COMMIT").then_some(begin)
}
fn checkpoint_count(store: &Store) -> i64 {
store
.conn()
.query_row("SELECT count(*) FROM checkpoints", [], |r| r.get(0))
.unwrap()
}
#[test]
fn checkpoint_row_and_sweep_share_one_transaction() {
let mut store =
Store::open_in_memory_with_minter(Box::new(SequentialMinter::new())).unwrap();
let repo = store.create_repo("fixture").unwrap();
start_trace(store.conn());
let r = finish_checkpoint(&mut store, &repo, &[], TS, None).unwrap();
assert_eq!(r.checkpoint_id, "cp_0");
let log = traced();
let row = tx_of(&log, "INSERT INTO checkpoints");
assert!(row.is_some(), "the row rides a transaction: {log:#?}");
assert_eq!(
row,
tx_of(&log, "DELETE FROM resurrection_pool WHERE expires_ts"),
"… the same one as the sweep: {log:#?}"
);
assert_eq!(checkpoint_count(&store), 1);
assert!(store.conn().is_autocommit(), "nothing left open");
let mut fs = MemFileSystem::new();
fs.set("a.md", "# A\n\nOne.\n", 1);
start_trace(store.conn());
let paths = vec!["a.md".to_owned()];
process_checkpoint(
&mut store,
&repo,
&fs,
Path::new("/r"),
&paths,
TS,
None,
&Config::default(),
)
.unwrap();
let log = traced();
let member = tx_of(&log, "INSERT INTO commits");
let row = tx_of(&log, "INSERT INTO checkpoints");
assert!(member.is_some() && row.is_some(), "{log:#?}");
assert_ne!(member, row, "the member's commit is its own transaction");
assert_eq!(
row,
tx_of(&log, "DELETE FROM resurrection_pool WHERE expires_ts")
);
assert_eq!(checkpoint_count(&store), 2);
}
#[test]
fn a_failing_sweep_leaves_no_checkpoint_row() {
let mut store =
Store::open_in_memory_with_minter(Box::new(SequentialMinter::new())).unwrap();
let repo = store.create_repo("fixture").unwrap();
store
.conn()
.execute_batch("DROP TABLE resurrection_pool")
.unwrap();
start_trace(store.conn());
let err = finish_checkpoint(&mut store, &repo, &[], TS, None).unwrap_err();
assert!(
err.to_string().contains("resurrection_pool"),
"the sweep failed: {err}"
);
assert_eq!(checkpoint_count(&store), 0, "the row was rolled back");
assert!(store.conn().is_autocommit(), "the transaction is closed");
let log = traced();
assert!(
log.iter().any(|s| s.starts_with("INSERT INTO checkpoints")),
"the row was attempted: {log:#?}"
);
assert!(
log.iter().any(|s| s.starts_with("ROLLBACK")),
"… and rolled back: {log:#?}"
);
assert!(!log.iter().any(|s| s.starts_with("COMMIT")));
}
}