use std::collections::{BTreeMap, HashSet};
use omgbase_format::hash::{hex, sha256};
use omgbase_format::{Block, BlockKind, BlockTree, parse_markdown, render};
use omgbase_graph::{extract_doc_edges, project_nodes};
use omgbase_properties::{doc_properties, frontmatter_yaml, parse_frontmatter};
use omgbase_reconcile::json::detail_to_json;
use omgbase_reconcile::{
Config, DispositionKind, FlatSource, Inserted, MatchBlock, Options, PerDocUnmatched, PoolEntry,
ReconcileResult, apply_cross_doc_matches, cross_doc_match, flatten, reconcile_document,
};
use rusqlite::{Connection, OptionalExtension, params};
use crate::derived::{fts_delete_doc, fts_index_doc, rebuild_sections, sweep_pool};
use crate::error::{Error, Result};
use crate::graph::{
adopt_phantoms, maintain_edges, project_section_nodes, resolve_edges, write_doc_nodes,
};
use crate::ids::IdMinter;
use crate::order_key::key_between;
use crate::properties::{doc_blocks, write_doc_properties};
use crate::read::{load_old_match_blocks, load_pool, reconstruct};
use crate::time::pool_expiry;
use crate::tree::canonical_attrs;
use crate::writers::{
NewCommit, NewRevision, TreeInputBlock, assign_from_map, new_commit, put_blob,
write_block_tree, write_revision,
};
use crate::{FORMAT_MARKDOWN, Store};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BatchItem {
pub path: String,
pub source: Option<String>,
}
impl BatchItem {
#[must_use]
pub fn observed(path: &str, source: &str) -> Self {
Self {
path: path.to_owned(),
source: Some(source.to_owned()),
}
}
#[must_use]
pub fn gone(path: &str) -> Self {
Self {
path: path.to_owned(),
source: None,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ObserveOutcome {
pub path: String,
pub doc_id: String,
pub rev: Option<String>,
pub commit_id: Option<String>,
pub converged: bool,
pub echo: bool,
pub conflicted: bool,
pub dispositions: BTreeMap<String, u64>,
pub old_hash_hex: Option<String>,
pub new_hash_hex: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DeleteOutcome {
pub path: String,
pub doc_id: Option<String>,
pub old_hash_hex: Option<String>,
}
impl DeleteOutcome {
#[must_use]
pub fn deleted(&self) -> bool {
self.doc_id.is_some()
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BatchOutcome {
Observed(ObserveOutcome),
Deleted(DeleteOutcome),
}
impl BatchOutcome {
#[must_use]
pub fn path(&self) -> &str {
match self {
BatchOutcome::Observed(o) => &o.path,
BatchOutcome::Deleted(d) => &d.path,
}
}
#[must_use]
pub fn as_observed(&self) -> Option<&ObserveOutcome> {
match self {
BatchOutcome::Observed(o) => Some(o),
BatchOutcome::Deleted(_) => None,
}
}
#[must_use]
pub fn as_deleted(&self) -> Option<&DeleteOutcome> {
match self {
BatchOutcome::Deleted(d) => Some(d),
BatchOutcome::Observed(_) => None,
}
}
}
#[must_use]
pub fn has_conflict_markers(source: &str) -> bool {
let starts = |marker: &str| {
source
.split(['\n', '\r', '\u{2028}', '\u{2029}'])
.any(|line| line.starts_with(marker))
};
starts("<<<<<<<") && starts(">>>>>>>")
}
struct Prepared {
path: String,
source: String,
doc_id: Option<String>,
tree: BlockTree,
old_blocks: Vec<MatchBlock>,
new_blocks: Vec<MatchBlock>,
result: ReconcileResult,
cross_doc_ids: Vec<String>,
}
enum Pending {
Echo(ObserveOutcome),
Gone {
path: String,
doc_id: Option<String>,
old_hash_hex: Option<String>,
old_blocks: Vec<MatchBlock>,
},
Ingest {
prepared: Prepared,
old_hash_hex: Option<String>,
new_hash_hex: String,
},
}
fn split_frontmatter(tree: &BlockTree) -> (Option<&Block>, &[Block]) {
match tree.children.first() {
Some(b) if b.kind == BlockKind::Frontmatter => (Some(b), &tree.children[1..]),
_ => (None, &tree.children[..]),
}
}
#[allow(clippy::too_many_arguments)]
fn prepare_reconcile(
conn: &Connection,
minter: &mut dyn IdMinter,
repo_id: &str,
path: &str,
source: &str,
config: &Config,
pool: &[PoolEntry],
consumed: &mut HashSet<String>,
) -> Result<Prepared> {
let doc_id: Option<String> = conn
.query_row(
"SELECT doc_id FROM docs WHERE repo_id = ?1 AND path = ?2",
params![repo_id, path],
|r| r.get(0),
)
.optional()?;
let tree = parse_markdown(source);
let old_blocks = match &doc_id {
Some(id) => load_old_match_blocks(conn, id)?,
None => Vec::new(),
};
let new_blocks = flatten(&FlatSource::from_tree(&tree, None));
let offered: Vec<PoolEntry> = if doc_id.is_some() {
pool.iter()
.filter(|c| !consumed.contains(&c.id))
.cloned()
.collect()
} else {
Vec::new()
};
let mut mint = || minter.mint("b");
let result = reconcile_document(
&old_blocks,
&new_blocks,
Options {
config,
pool: &offered,
minter: &mut mint,
},
);
consumed.extend(result.consumed_pool.iter().cloned());
Ok(Prepared {
path: path.to_owned(),
source: source.to_owned(),
doc_id,
tree,
old_blocks,
new_blocks,
result,
cross_doc_ids: Vec::new(),
})
}
fn cross_doc_phase(pending: &mut [Pending], config: &Config) {
let mut docs: Vec<PerDocUnmatched> = Vec::new();
let mut results: BTreeMap<String, ReconcileResult> = BTreeMap::new();
let mut key_of: Vec<Option<String>> = vec![None; pending.len()];
for (i, p) in pending.iter_mut().enumerate() {
match p {
Pending::Echo(_) | Pending::Gone { doc_id: None, .. } => {}
Pending::Gone {
doc_id: Some(doc_id),
old_blocks,
..
} => {
docs.push(PerDocUnmatched {
doc_id: doc_id.clone(),
deleted: old_blocks.clone(),
inserted: Vec::new(),
});
results.insert(
doc_id.clone(),
ReconcileResult {
deleted: old_blocks.iter().filter_map(|b| b.id.clone()).collect(),
..ReconcileResult::default()
},
);
}
Pending::Ingest { prepared, .. } => {
let key = prepared
.doc_id
.clone()
.unwrap_or_else(|| format!("new:{}", prepared.path));
let key_of_id: BTreeMap<&str, &str> = prepared
.result
.assignment
.iter()
.map(|(k, id)| (id.as_str(), k.as_str()))
.collect();
let deleted = prepared
.result
.deleted
.iter()
.filter_map(|id| {
prepared
.old_blocks
.iter()
.find(|b| b.id.as_deref() == Some(id))
})
.cloned()
.collect();
let inserted = prepared
.result
.dispositions
.iter()
.filter(|d| d.kind == DispositionKind::Inserted)
.filter_map(|d| {
let k = key_of_id.get(d.block_id.as_str())?;
let block = prepared.new_blocks.iter().find(|b| &b.key == k)?;
Some(Inserted {
block: block.clone(),
minted_id: d.block_id.clone(),
})
})
.collect();
docs.push(PerDocUnmatched {
doc_id: key.clone(),
deleted,
inserted,
});
results.insert(key.clone(), std::mem::take(&mut prepared.result));
key_of[i] = Some(key);
}
}
}
let matches = if docs.len() < 2 {
Vec::new()
} else {
cross_doc_match(&docs, config)
};
if !matches.is_empty() {
apply_cross_doc_matches(&mut results, &matches, &config.matcher_v);
}
for (i, p) in pending.iter_mut().enumerate() {
if let (Pending::Ingest { prepared, .. }, Some(key)) = (p, &key_of[i]) {
prepared.result = results
.remove(key)
.expect("every prepared member was keyed");
for m in &matches {
if &m.to_doc == key {
prepared.cross_doc_ids.push(m.carried_id.clone());
}
}
}
}
}
struct BlockRow {
block_id: String,
parent_block: Option<String>,
order_key: String,
ordinal: i64,
depth: i64,
ancestor_path: String,
kind: String,
attrs: String,
text: String,
raw_hash: [u8; 32],
norm_hash: [u8; 32],
trivia_hash: Option<[u8; 32]>,
}
fn flatten_rows(
blocks: &[TreeInputBlock],
parent: Option<&str>,
depth: i64,
ancestor_path: &str,
out: &mut Vec<BlockRow>,
) {
let mut prev_key: Option<String> = None;
for (ordinal, b) in blocks.iter().enumerate() {
let order_key = key_between(prev_key.as_deref(), None);
prev_key = Some(order_key.clone());
out.push(BlockRow {
block_id: b.block_id.clone(),
parent_block: parent.map(str::to_owned),
order_key,
ordinal: ordinal as i64,
depth,
ancestor_path: ancestor_path.to_owned(),
kind: b.kind.clone(),
attrs: canonical_attrs(&b.attrs),
text: b.text.clone(),
raw_hash: sha256(b.raw.as_bytes()),
norm_hash: sha256(b.text.as_bytes()),
trivia_hash: (!b.trivia.is_empty()).then(|| sha256(b.trivia.as_bytes())),
});
if !b.children.is_empty() {
flatten_rows(
&b.children,
Some(&b.block_id),
depth + 1,
&format!("{ancestor_path}{}/", b.block_id),
out,
);
}
}
}
fn evict_foreign_block_rows(conn: &Connection, doc_id: &str, ids: &[String]) -> Result<()> {
for id in ids {
let row: Option<(i64, String, Option<String>)> = conn
.query_row(
"SELECT rowid, text, deleted_commit FROM blocks WHERE block_id = ?1 AND doc_id != ?2",
params![id, doc_id],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.optional()?;
if let Some((rowid, text, deleted_commit)) = row {
if deleted_commit.is_none() {
conn.execute(
"INSERT INTO blocks_fts(blocks_fts, rowid, text) VALUES('delete', ?1, ?2)",
params![rowid, text],
)?;
}
conn.execute(
"DELETE FROM blocks WHERE block_id = ?1 AND doc_id != ?2",
params![id, doc_id],
)?;
}
conn.execute(
"DELETE FROM resurrection_pool WHERE block_id = ?1",
params![id],
)?;
}
Ok(())
}
struct Committed {
doc_id: String,
commit_id: String,
rev_id: String,
converged: bool,
}
impl Store {
pub fn observe_batch(
&mut self,
repo_id: &str,
items: &[BatchItem],
ts: &str,
config: &Config,
) -> Result<Vec<BatchOutcome>> {
let expires = pool_expiry(ts)?;
let pool = load_pool(&self.conn, repo_id, ts)?;
let mut consumed: HashSet<String> = HashSet::new();
let mut pending: Vec<Pending> = Vec::with_capacity(items.len());
for it in items {
let existing: Option<(String, Option<Vec<u8>>)> = self
.conn
.query_row(
"SELECT doc_id, file_hash FROM docs WHERE repo_id = ?1 AND path = ?2 AND deleted_commit IS NULL",
params![repo_id, it.path],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()?;
let old_hash_hex = existing.as_ref().and_then(|(_, h)| h.as_deref()).map(hex);
let Some(source) = &it.source else {
let (doc_id, old_blocks) = match &existing {
Some((id, _)) => (Some(id.clone()), load_old_match_blocks(&self.conn, id)?),
None => (None, Vec::new()),
};
pending.push(Pending::Gone {
path: it.path.clone(),
doc_id,
old_hash_hex,
old_blocks,
});
continue;
};
let hash = sha256(source.as_bytes());
let new_hash_hex = hex(&hash);
if let Some((doc_id, Some(stored))) = &existing {
if stored[..] == hash[..] {
pending.push(Pending::Echo(ObserveOutcome {
path: it.path.clone(),
doc_id: doc_id.clone(),
rev: None,
commit_id: None,
converged: true,
echo: true,
conflicted: false,
dispositions: BTreeMap::new(),
old_hash_hex,
new_hash_hex,
}));
continue;
}
}
let prepared = prepare_reconcile(
&self.conn,
&mut *self.minter,
repo_id,
&it.path,
source,
config,
&pool,
&mut consumed,
)?;
pending.push(Pending::Ingest {
prepared,
old_hash_hex,
new_hash_hex,
});
}
if pending.len() > 1 {
cross_doc_phase(&mut pending, config);
}
let mut out = Vec::with_capacity(pending.len());
for p in pending {
match p {
Pending::Echo(outcome) => out.push(BatchOutcome::Observed(outcome)),
Pending::Gone {
path,
doc_id,
old_hash_hex,
..
} => {
if let Some(id) = &doc_id {
self.tombstone_observed_deletion(repo_id, id, ts, &expires)?;
}
out.push(BatchOutcome::Deleted(DeleteOutcome {
path,
doc_id,
old_hash_hex,
}));
}
Pending::Ingest {
prepared,
old_hash_hex,
new_hash_hex,
} => {
let conflicted = has_conflict_markers(&prepared.source);
let c = self.commit_prepared(repo_id, &prepared, ts, &expires)?;
self.conn.execute(
"UPDATE docs SET conflicted = ?1 WHERE repo_id = ?2 AND path = ?3",
params![i64::from(conflicted), repo_id, prepared.path],
)?;
let dispositions = {
let mut stmt = self.conn.prepare(
"SELECT kind, count(*) FROM dispositions WHERE commit_id = ?1 GROUP BY kind ORDER BY kind",
)?;
let rows = stmt.query_map(params![c.commit_id], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)? as u64))
})?;
rows.collect::<std::result::Result<BTreeMap<_, _>, _>>()?
};
out.push(BatchOutcome::Observed(ObserveOutcome {
path: prepared.path,
doc_id: c.doc_id,
rev: Some(c.rev_id),
commit_id: Some(c.commit_id),
converged: c.converged,
echo: false,
conflicted,
dispositions,
old_hash_hex,
new_hash_hex,
}));
}
}
}
Ok(out)
}
fn commit_prepared(
&mut self,
repo_id: &str,
prepared: &Prepared,
ts: &str,
expires: &str,
) -> Result<Committed> {
let tx = self.conn.unchecked_transaction()?;
let minter: &mut dyn IdMinter = &mut *self.minter;
let tree = &prepared.tree;
let source = prepared.source.as_str();
let (fm_block, rest) = split_frontmatter(tree);
let fm_blob_hex = fm_block.map(|b| put_blob(&tx, &b.raw)).transpose()?;
let fm_trivia = fm_block.map(|b| b.trivia.as_str());
let existing: Option<(String, Option<String>)> = tx
.query_row(
"SELECT doc_id, deleted_commit FROM docs WHERE repo_id = ?1 AND path = ?2",
params![repo_id, prepared.path],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()?;
let doc_id = match existing {
Some((id, deleted_commit)) => {
tx.execute(
"UPDATE docs SET format = ?1, leading_trivia = ?2, frontmatter_trivia = ?3, deleted_commit = NULL WHERE doc_id = ?4",
params![FORMAT_MARKDOWN, tree.leading_trivia, fm_trivia, id],
)?;
if deleted_commit.is_some() {
adopt_phantoms(&tx, &prepared.path, &id)?;
}
id
}
None => {
let id = minter.mint("d");
tx.execute(
"INSERT INTO docs (doc_id, repo_id, path, format, leading_trivia, frontmatter_trivia) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![id, repo_id, prepared.path, FORMAT_MARKDOWN, tree.leading_trivia, fm_trivia],
)?;
adopt_phantoms(&tx, &prepared.path, &id)?;
id
}
};
let assigned = assign_from_map(rest, &prepared.result.assignment, minter);
let root_tree_hex = write_block_tree(&tx, &assigned)?;
let (commit_id, _) = new_commit(&tx, minter, &NewCommit::observed(repo_id, ts))?;
let rendered_hash = sha256(source.as_bytes());
let (rev_id, _) = write_revision(
&tx,
minter,
&NewRevision {
doc_id: &doc_id,
root_tree_hex: &root_tree_hex,
frontmatter_blob_hex: fm_blob_hex.as_deref(),
rendered_hash,
path: &prepared.path,
commit_id: &commit_id,
},
)?;
{
let mut pool = tx.prepare(
"INSERT OR REPLACE INTO resurrection_pool (block_id, repo_id, doc_id, raw_hash, norm_hash, type, deleted_commit, expires_ts)
SELECT block_id, repo_id, doc_id, raw_hash, norm_hash, type, ?1, ?2 FROM blocks WHERE block_id = ?3 AND doc_id = ?4",
)?;
for id in &prepared.result.deleted {
pool.execute(params![commit_id, expires, id, doc_id])?;
}
}
let incoming: Vec<String> = prepared
.cross_doc_ids
.iter()
.chain(prepared.result.consumed_pool.iter())
.cloned()
.collect();
if !incoming.is_empty() {
evict_foreign_block_rows(&tx, &doc_id, &incoming)?;
}
fts_delete_doc(&tx, &doc_id)?;
tx.execute("DELETE FROM blocks WHERE doc_id = ?1", params![doc_id])?;
let mut rows = Vec::new();
flatten_rows(&assigned, None, 0, "/", &mut rows);
{
let mut insert = tx.prepare(
"INSERT INTO blocks
(block_id, repo_id, doc_id, parent_block, order_key, ordinal, depth,
ancestor_path, type, attrs, text, raw_hash, norm_hash, trivia_hash, created_commit)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
)?;
for r in &rows {
insert.execute(params![
r.block_id,
repo_id,
doc_id,
r.parent_block,
r.order_key,
r.ordinal,
r.depth,
r.ancestor_path,
r.kind,
r.attrs,
r.text,
&r.raw_hash[..],
&r.norm_hash[..],
r.trivia_hash.as_ref().map(|h| &h[..]),
commit_id,
])?;
}
}
fts_index_doc(&tx, &doc_id)?;
rebuild_sections(&tx, &doc_id)?;
let body = doc_blocks(&assigned);
let mut nodes = project_nodes(&body);
nodes.extend(project_section_nodes(&tx, &doc_id)?);
write_doc_nodes(&tx, repo_id, &doc_id, &nodes)?;
let property_rows = doc_properties(&doc_id, fm_block, &body);
write_doc_properties(&tx, repo_id, &doc_id, &commit_id, &property_rows)?;
let mapping = fm_block.and_then(|b| parse_frontmatter(frontmatter_yaml(&b.raw)));
let descriptors = extract_doc_edges(&body, mapping.as_ref());
let resolved = resolve_edges(&tx, minter, repo_id, &doc_id, &prepared.path, &descriptors)?;
maintain_edges(&tx, minter, repo_id, &doc_id, &commit_id, &resolved)?;
{
let mut ins = tx.prepare(
"INSERT OR IGNORE INTO dispositions (commit_id, block_id, kind, confidence, reason, matcher_v, detail)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
)?;
let mut bc = tx.prepare(
"INSERT OR IGNORE INTO block_changes (block_id, commit_id, kind) VALUES (?1, ?2, ?3)",
)?;
for d in &prepared.result.dispositions {
ins.execute(params![
commit_id,
d.block_id,
d.kind.as_str(),
d.confidence,
d.reason.map(|r| r.as_str()),
d.matcher_v,
detail_to_json(&d.detail).to_string(),
])?;
bc.execute(params![d.block_id, commit_id, d.kind.as_str()])?;
}
}
for id in &prepared.result.consumed_pool {
tx.execute(
"DELETE FROM resurrection_pool WHERE block_id = ?1",
params![id],
)?;
}
tx.execute(
"UPDATE docs SET current_rev = ?1, file_hash = ?2 WHERE doc_id = ?3",
params![rev_id, &rendered_hash[..], doc_id],
)?;
let converged =
render(tree) == source && reconstruct(&tx, &doc_id)?.as_deref() == Some(source);
tx.commit()?;
Ok(Committed {
doc_id,
commit_id,
rev_id,
converged,
})
}
pub(crate) fn tombstone_observed_deletion(
&mut self,
repo_id: &str,
doc_id: &str,
ts: &str,
expires: &str,
) -> Result<String> {
let tx = self.conn.unchecked_transaction()?;
let minter: &mut dyn IdMinter = &mut *self.minter;
let (commit_id, _) = new_commit(
&tx,
minter,
&NewCommit {
reason: Some("observed deletion"),
..NewCommit::observed(repo_id, ts)
},
)?;
tx.execute(
"INSERT OR REPLACE INTO resurrection_pool (block_id, repo_id, doc_id, raw_hash, norm_hash, type, deleted_commit, expires_ts)
SELECT block_id, repo_id, doc_id, raw_hash, norm_hash, type, ?1, ?2
FROM blocks WHERE doc_id = ?3 AND deleted_commit IS NULL",
params![commit_id, expires, doc_id],
)?;
fts_delete_doc(&tx, doc_id)?;
tx.execute(
"UPDATE blocks SET deleted_commit = ?1 WHERE doc_id = ?2 AND deleted_commit IS NULL",
params![commit_id, doc_id],
)?;
tx.execute(
"UPDATE docs SET deleted_commit = ?1 WHERE doc_id = ?2",
params![commit_id, doc_id],
)?;
tx.commit()?;
Ok(commit_id)
}
pub fn observe_one(
&mut self,
repo_id: &str,
path: &str,
source: &str,
ts: &str,
config: &Config,
) -> Result<ObserveOutcome> {
let mut out =
self.observe_batch(repo_id, &[BatchItem::observed(path, source)], ts, config)?;
match out.pop() {
Some(BatchOutcome::Observed(o)) => Ok(o),
_ => Err(Error::Other(format!(
"observe_one: unexpected outcome for {path}"
))),
}
}
pub fn observe_delete(&mut self, repo_id: &str, path: &str, ts: &str) -> Result<DeleteOutcome> {
let expires = pool_expiry(ts)?;
let existing: Option<(String, Option<Vec<u8>>)> = self
.conn
.query_row(
"SELECT doc_id, file_hash FROM docs WHERE repo_id = ?1 AND path = ?2 AND deleted_commit IS NULL",
params![repo_id, path],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()?;
let Some((doc_id, file_hash)) = existing else {
return Ok(DeleteOutcome {
path: path.to_owned(),
doc_id: None,
old_hash_hex: None,
});
};
self.tombstone_observed_deletion(repo_id, &doc_id, ts, &expires)?;
sweep_pool(&self.conn, ts)?;
Ok(DeleteOutcome {
path: path.to_owned(),
doc_id: Some(doc_id),
old_hash_hex: file_hash.as_deref().map(hex),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn conflict_markers_need_both_sides_at_line_starts() {
assert!(has_conflict_markers(
"<<<<<<< HEAD\na\n=======\nb\n>>>>>>> branch\n"
));
assert!(!has_conflict_markers("<<<<<<< HEAD\na\n=======\nb\n"));
assert!(!has_conflict_markers("a <<<<<<< b\n>>>>>>> c\n"));
assert!(has_conflict_markers(">>>>>>> c\r<<<<<<< b"));
assert!(has_conflict_markers("x\u{2028}<<<<<<< a\u{2029}>>>>>>> b"));
assert!(!has_conflict_markers(""));
}
#[test]
fn frontmatter_is_split_only_when_first() {
let tree = parse_markdown("---\na: 1\n---\n\n# H\n");
let (fm, rest) = split_frontmatter(&tree);
assert_eq!(fm.map(|b| b.kind), Some(BlockKind::Frontmatter));
assert_eq!(rest.len(), 1);
let tree = parse_markdown("# H\n");
let (fm, rest) = split_frontmatter(&tree);
assert!(fm.is_none());
assert_eq!(rest.len(), 1);
}
}