1use std::collections::{BTreeMap, HashSet};
18
19use omgbase_format::hash::{hex, sha256};
20use omgbase_format::{Block, BlockKind, BlockTree, parse_markdown, render};
21use omgbase_graph::{extract_doc_edges, project_nodes};
22use omgbase_properties::{doc_properties, frontmatter_yaml, parse_frontmatter};
23use omgbase_reconcile::json::detail_to_json;
24use omgbase_reconcile::{
25 Config, DispositionKind, FlatSource, Inserted, MatchBlock, Options, PerDocUnmatched, PoolEntry,
26 ReconcileResult, apply_cross_doc_matches, cross_doc_match, flatten, reconcile_document,
27};
28use rusqlite::{Connection, OptionalExtension, params};
29
30use crate::derived::{
31 EvictRow, fts_before_evict_rows, fts_delete_doc, fts_index_doc, rebuild_sections, sweep_pool,
32};
33use crate::effects::{CommitHead, Effect, Guard, Outcome};
34use crate::error::{Error, Result};
35use crate::graph::{
36 adopt_phantoms, maintain_edges, project_section_nodes, resolve_edges, write_doc_nodes,
37};
38use crate::materialize::{PendingKind, delete_pending, pending_at};
39use crate::mint::Mint;
40use crate::order_key::key_between;
41use crate::properties::{doc_blocks, write_doc_properties};
42use crate::read::{load_old_match_blocks, load_pool, reconstruct};
43use crate::time::pool_expiry;
44use crate::tree::canonical_attrs;
45use crate::writers::{
46 NewCommit, NewRevision, Origin, TreeInputBlock, assign_fresh_ids, assign_from_map, new_commit,
47 put_blob, write_block_tree, write_revision,
48};
49use crate::{FORMAT_MARKDOWN, Store};
50
51#[derive(Clone, Debug, PartialEq, Eq)]
54pub struct BatchItem {
55 pub path: String,
56 pub source: Option<String>,
57}
58
59impl BatchItem {
60 #[must_use]
62 pub fn observed(path: &str, source: &str) -> Self {
63 Self {
64 path: path.to_owned(),
65 source: Some(source.to_owned()),
66 }
67 }
68
69 #[must_use]
71 pub fn gone(path: &str) -> Self {
72 Self {
73 path: path.to_owned(),
74 source: None,
75 }
76 }
77}
78
79#[derive(Clone, Debug, PartialEq, Eq)]
81pub struct ObserveOutcome {
82 pub path: String,
83 pub doc_id: String,
84 pub rev: Option<String>,
86 pub commit_id: Option<String>,
88 pub converged: bool,
90 pub echo: bool,
92 pub conflicted: bool,
94 pub dispositions: BTreeMap<String, u64>,
96 pub old_hash_hex: Option<String>,
98 pub new_hash_hex: String,
100}
101
102#[derive(Clone, Debug, PartialEq, Eq)]
104pub struct DeleteOutcome {
105 pub path: String,
106 pub doc_id: Option<String>,
108 pub old_hash_hex: Option<String>,
109}
110
111impl DeleteOutcome {
112 #[must_use]
114 pub fn deleted(&self) -> bool {
115 self.doc_id.is_some()
116 }
117}
118
119#[derive(Clone, Debug, PartialEq, Eq)]
121pub enum BatchOutcome {
122 Observed(ObserveOutcome),
123 Deleted(DeleteOutcome),
124}
125
126impl BatchOutcome {
127 #[must_use]
128 pub fn path(&self) -> &str {
129 match self {
130 BatchOutcome::Observed(o) => &o.path,
131 BatchOutcome::Deleted(d) => &d.path,
132 }
133 }
134
135 #[must_use]
136 pub fn as_observed(&self) -> Option<&ObserveOutcome> {
137 match self {
138 BatchOutcome::Observed(o) => Some(o),
139 BatchOutcome::Deleted(_) => None,
140 }
141 }
142
143 #[must_use]
144 pub fn as_deleted(&self) -> Option<&DeleteOutcome> {
145 match self {
146 BatchOutcome::Deleted(d) => Some(d),
147 BatchOutcome::Observed(_) => None,
148 }
149 }
150}
151
152#[must_use]
156pub fn has_conflict_markers(source: &str) -> bool {
157 let starts = |marker: &str| {
158 source
159 .split(['\n', '\r', '\u{2028}', '\u{2029}'])
160 .any(|line| line.starts_with(marker))
161 };
162 starts("<<<<<<<") && starts(">>>>>>>")
163}
164
165pub(crate) struct Prepared {
169 pub(crate) path: String,
170 pub(crate) source: String,
171 doc_id: Option<String>,
173 tree: BlockTree,
174 old_blocks: Vec<MatchBlock>,
175 new_blocks: Vec<MatchBlock>,
176 result: ReconcileResult,
177 cross_doc_ids: Vec<String>,
179}
180
181impl Prepared {
182 pub(crate) fn plan<'a>(
186 &'a self,
187 minter: &mut Mint<'_>,
188 origin: Origin,
189 actor: Option<&'a str>,
190 reason: Option<&'a str>,
191 conflicted: Option<bool>,
192 ) -> Result<IngestPlan<'a>> {
193 let (_, rest) = split_frontmatter(&self.tree);
194 let assigned = assign_from_map(rest, &self.result.assignment, minter)?;
195 Ok(IngestPlan {
196 path: &self.path,
197 source: &self.source,
198 tree: &self.tree,
199 assigned,
200 dispositions: disposition_rows(&self.result),
201 deleted: self.result.deleted.clone(),
202 consumed_pool: self.result.consumed_pool.clone(),
203 cross_doc_ids: self.cross_doc_ids.clone(),
204 origin,
205 actor,
206 reason,
207 conflicted,
208 })
209 }
210}
211
212enum Pending {
213 Echo(ObserveOutcome),
214 Gone {
215 path: String,
216 doc_id: Option<String>,
217 old_hash_hex: Option<String>,
218 old_blocks: Vec<MatchBlock>,
219 },
220 Ingest {
221 prepared: Box<Prepared>,
222 old_hash_hex: Option<String>,
223 new_hash_hex: String,
224 clear_pending: bool,
227 },
228}
229
230fn split_frontmatter(tree: &BlockTree) -> (Option<&Block>, &[Block]) {
232 match tree.children.first() {
233 Some(b) if b.kind == BlockKind::Frontmatter => (Some(b), &tree.children[1..]),
234 _ => (None, &tree.children[..]),
235 }
236}
237
238#[allow(clippy::too_many_arguments)]
241fn prepare_reconcile(
242 conn: &Connection,
243 minter: &mut Mint<'_>,
244 repo_id: &str,
245 path: &str,
246 source: &str,
247 config: &Config,
248 pool: &[PoolEntry],
249 consumed: &mut HashSet<String>,
250) -> Result<Prepared> {
251 let doc_id: Option<String> = conn
252 .query_row(
253 "SELECT doc_id FROM docs WHERE repo_id = ?1 AND path = ?2",
254 params![repo_id, path],
255 |r| r.get(0),
256 )
257 .optional()?;
258 let tree = parse_markdown(source);
259 let old_blocks = match &doc_id {
260 Some(id) => load_old_match_blocks(conn, id)?,
261 None => Vec::new(),
262 };
263 let new_blocks = flatten(&FlatSource::from_tree(&tree, None));
264 let offered: Vec<PoolEntry> = if doc_id.is_some() {
265 pool.iter()
266 .filter(|c| !consumed.contains(&c.id))
267 .cloned()
268 .collect()
269 } else {
270 Vec::new()
271 };
272 let mut mint = minter.deferred("b");
273 let result = reconcile_document(
274 &old_blocks,
275 &new_blocks,
276 Options {
277 config,
278 pool: &offered,
279 minter: &mut mint,
280 },
281 );
282 mint.finish()?;
283 consumed.extend(result.consumed_pool.iter().cloned());
284 Ok(Prepared {
285 path: path.to_owned(),
286 source: source.to_owned(),
287 doc_id,
288 tree,
289 old_blocks,
290 new_blocks,
291 result,
292 cross_doc_ids: Vec::new(),
293 })
294}
295
296fn cross_doc_phase(pending: &mut [Pending], config: &Config) {
298 let mut docs: Vec<PerDocUnmatched> = Vec::new();
299 let mut results: BTreeMap<String, ReconcileResult> = BTreeMap::new();
300 let mut key_of: Vec<Option<String>> = vec![None; pending.len()];
301 for (i, p) in pending.iter_mut().enumerate() {
302 match p {
303 Pending::Echo(_) | Pending::Gone { doc_id: None, .. } => {}
304 Pending::Gone {
305 doc_id: Some(doc_id),
306 old_blocks,
307 ..
308 } => {
309 docs.push(PerDocUnmatched {
310 doc_id: doc_id.clone(),
311 deleted: old_blocks.clone(),
312 inserted: Vec::new(),
313 });
314 results.insert(
315 doc_id.clone(),
316 ReconcileResult {
317 deleted: old_blocks.iter().filter_map(|b| b.id.clone()).collect(),
318 ..ReconcileResult::default()
319 },
320 );
321 }
322 Pending::Ingest { prepared, .. } => {
323 let key = prepared
324 .doc_id
325 .clone()
326 .unwrap_or_else(|| format!("new:{}", prepared.path));
327 let key_of_id: BTreeMap<&str, &str> = prepared
328 .result
329 .assignment
330 .iter()
331 .map(|(k, id)| (id.as_str(), k.as_str()))
332 .collect();
333 let deleted = prepared
334 .result
335 .deleted
336 .iter()
337 .filter_map(|id| {
338 prepared
339 .old_blocks
340 .iter()
341 .find(|b| b.id.as_deref() == Some(id))
342 })
343 .cloned()
344 .collect();
345 let inserted = prepared
346 .result
347 .dispositions
348 .iter()
349 .filter(|d| d.kind == DispositionKind::Inserted)
350 .filter_map(|d| {
351 let k = key_of_id.get(d.block_id.as_str())?;
352 let block = prepared.new_blocks.iter().find(|b| &b.key == k)?;
353 Some(Inserted {
354 block: block.clone(),
355 minted_id: d.block_id.clone(),
356 })
357 })
358 .collect();
359 docs.push(PerDocUnmatched {
360 doc_id: key.clone(),
361 deleted,
362 inserted,
363 });
364 results.insert(key.clone(), std::mem::take(&mut prepared.result));
365 key_of[i] = Some(key);
366 }
367 }
368 }
369 let matches = if docs.len() < 2 {
370 Vec::new()
371 } else {
372 cross_doc_match(&docs, config)
373 };
374 if !matches.is_empty() {
375 apply_cross_doc_matches(&mut results, &matches, &config.matcher_v);
376 }
377 for (i, p) in pending.iter_mut().enumerate() {
378 if let (Pending::Ingest { prepared, .. }, Some(key)) = (p, &key_of[i]) {
379 prepared.result = results
380 .remove(key)
381 .expect("every prepared member was keyed");
382 for m in &matches {
383 if &m.to_doc == key {
384 prepared.cross_doc_ids.push(m.carried_id.clone());
385 }
386 }
387 }
388 }
389}
390
391struct BlockRow {
392 block_id: String,
393 parent_block: Option<String>,
394 order_key: String,
395 ordinal: i64,
396 depth: i64,
397 ancestor_path: String,
398 kind: String,
399 attrs: String,
400 text: String,
401 raw_hash: [u8; 32],
402 norm_hash: [u8; 32],
403 trivia_hash: Option<[u8; 32]>,
404}
405
406fn flatten_rows(
408 blocks: &[TreeInputBlock],
409 parent: Option<&str>,
410 depth: i64,
411 ancestor_path: &str,
412 out: &mut Vec<BlockRow>,
413) {
414 let mut prev_key: Option<String> = None;
415 for (ordinal, b) in blocks.iter().enumerate() {
416 let order_key = key_between(prev_key.as_deref(), None);
417 prev_key = Some(order_key.clone());
418 out.push(BlockRow {
419 block_id: b.block_id.clone(),
420 parent_block: parent.map(str::to_owned),
421 order_key,
422 ordinal: ordinal as i64,
423 depth,
424 ancestor_path: ancestor_path.to_owned(),
425 kind: b.kind.clone(),
426 attrs: canonical_attrs(&b.attrs),
427 text: b.text.clone(),
428 raw_hash: sha256(b.raw.as_bytes()),
429 norm_hash: sha256(b.text.as_bytes()),
430 trivia_hash: (!b.trivia.is_empty()).then(|| sha256(b.trivia.as_bytes())),
431 });
432 if !b.children.is_empty() {
433 flatten_rows(
434 &b.children,
435 Some(&b.block_id),
436 depth + 1,
437 &format!("{ancestor_path}{}/", b.block_id),
438 out,
439 );
440 }
441 }
442}
443
444fn evict_foreign_block_rows(conn: &Connection, doc_id: &str, ids: &[String]) -> Result<()> {
449 let mut rows = Vec::new();
450 for id in ids {
451 let row: Option<EvictRow> = conn
452 .query_row(
453 "SELECT rowid, block_id, doc_id, parent_block, text, deleted_commit IS NULL
454 FROM blocks WHERE block_id = ?1 AND doc_id != ?2",
455 params![id, doc_id],
456 |r| {
457 Ok(EvictRow {
458 rowid: r.get(0)?,
459 block_id: r.get(1)?,
460 doc_id: r.get(2)?,
461 parent_block: r.get(3)?,
462 text: r.get(4)?,
463 live: r.get(5)?,
464 })
465 },
466 )
467 .optional()?;
468 if let Some(row) = row {
469 rows.push(row);
470 }
471 }
472 fts_before_evict_rows(conn, &rows)?;
473 for r in &rows {
474 conn.execute(
475 "DELETE FROM blocks WHERE block_id = ?1 AND doc_id != ?2",
476 params![r.block_id, doc_id],
477 )?;
478 }
479 for id in ids {
480 conn.execute(
481 "DELETE FROM resurrection_pool WHERE block_id = ?1",
482 params![id],
483 )?;
484 }
485 Ok(())
486}
487
488#[derive(Clone, Debug, PartialEq, Eq)]
491pub struct Committed {
492 pub doc_id: String,
493 pub commit_id: String,
494 pub rev_id: String,
495 pub converged: bool,
496}
497
498#[derive(Clone, Debug, PartialEq)]
501pub(crate) struct DispositionRow {
502 pub block_id: String,
503 pub kind: String,
504 pub confidence: Option<f64>,
505 pub reason: Option<String>,
506 pub matcher_v: Option<String>,
507 pub detail: String,
509}
510
511pub(crate) struct IngestPlan<'a> {
514 pub path: &'a str,
515 pub source: &'a str,
516 pub tree: &'a BlockTree,
517 pub assigned: Vec<TreeInputBlock>,
518 pub dispositions: Vec<DispositionRow>,
519 pub deleted: Vec<String>,
521 pub consumed_pool: Vec<String>,
522 pub cross_doc_ids: Vec<String>,
523 pub origin: Origin,
524 pub actor: Option<&'a str>,
525 pub reason: Option<&'a str>,
526 pub conflicted: Option<bool>,
532}
533
534fn disposition_rows(result: &ReconcileResult) -> Vec<DispositionRow> {
535 result
536 .dispositions
537 .iter()
538 .map(|d| DispositionRow {
539 block_id: d.block_id.clone(),
540 kind: d.kind.as_str().to_owned(),
541 confidence: d.confidence,
542 reason: d.reason.map(|r| r.as_str().to_owned()),
543 matcher_v: Some(d.matcher_v.clone()),
544 detail: detail_to_json(&d.detail).to_string(),
545 })
546 .collect()
547}
548
549pub(crate) fn ingest_in(
556 tx: &Connection,
557 mint: &mut Mint<'_>,
558 repo_id: &str,
559 plan: &IngestPlan<'_>,
560 expires: &str,
561 commit_id: &str,
562) -> Result<Committed> {
563 let minter = mint;
564 let tree = plan.tree;
565 let source = plan.source;
566 let (fm_block, _) = split_frontmatter(tree);
567
568 let fm_blob_hex = fm_block.map(|b| put_blob(tx, &b.raw)).transpose()?;
571 let fm_trivia = fm_block.map(|b| b.trivia.as_str());
572
573 let existing: Option<(String, Option<String>)> = tx
576 .query_row(
577 "SELECT doc_id, deleted_commit FROM docs WHERE repo_id = ?1 AND path = ?2",
578 params![repo_id, plan.path],
579 |r| Ok((r.get(0)?, r.get(1)?)),
580 )
581 .optional()?;
582 let doc_id = match existing {
583 Some((id, deleted_commit)) => {
584 tx.execute(
585 "UPDATE docs SET format = ?1, leading_trivia = ?2, frontmatter_trivia = ?3, deleted_commit = NULL WHERE doc_id = ?4",
586 params![FORMAT_MARKDOWN, tree.leading_trivia, fm_trivia, id],
587 )?;
588 if deleted_commit.is_some() {
592 adopt_phantoms(tx, plan.path, &id)?;
593 }
594 id
595 }
596 None => {
597 let id = minter.mint("d")?;
598 tx.execute(
599 "INSERT INTO docs (doc_id, repo_id, path, format, leading_trivia, frontmatter_trivia) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
600 params![id, repo_id, plan.path, FORMAT_MARKDOWN, tree.leading_trivia, fm_trivia],
601 )?;
602 adopt_phantoms(tx, plan.path, &id)?;
606 id
607 }
608 };
609
610 let assigned = &plan.assigned;
612 let root_tree_hex = write_block_tree(tx, assigned)?;
613
614 let rendered_hash = sha256(source.as_bytes());
616 let (rev_id, _) = write_revision(
617 tx,
618 minter,
619 &NewRevision {
620 doc_id: &doc_id,
621 root_tree_hex: &root_tree_hex,
622 frontmatter_blob_hex: fm_blob_hex.as_deref(),
623 rendered_hash,
624 path: plan.path,
625 commit_id,
626 },
627 )?;
628
629 {
631 let mut pool = tx.prepare(
632 "INSERT OR REPLACE INTO resurrection_pool (block_id, repo_id, doc_id, raw_hash, norm_hash, type, deleted_commit, expires_ts)
633 SELECT block_id, repo_id, doc_id, raw_hash, norm_hash, type, ?1, ?2 FROM blocks WHERE block_id = ?3 AND doc_id = ?4",
634 )?;
635 for id in &plan.deleted {
636 pool.execute(params![commit_id, expires, id, doc_id])?;
637 }
638 }
639
640 let incoming: Vec<String> = plan
642 .cross_doc_ids
643 .iter()
644 .chain(plan.consumed_pool.iter())
645 .cloned()
646 .collect();
647 if !incoming.is_empty() {
648 evict_foreign_block_rows(tx, &doc_id, &incoming)?;
649 }
650
651 fts_delete_doc(tx, &doc_id)?;
653 tx.execute("DELETE FROM blocks WHERE doc_id = ?1", params![doc_id])?;
654 let mut rows = Vec::new();
655 flatten_rows(assigned, None, 0, "/", &mut rows);
656 {
657 let mut insert = tx.prepare(
658 "INSERT INTO blocks
659 (block_id, repo_id, doc_id, parent_block, order_key, ordinal, depth,
660 ancestor_path, type, attrs, text, raw_hash, norm_hash, trivia_hash, created_commit)
661 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
662 )?;
663 for r in &rows {
664 insert.execute(params![
665 r.block_id,
666 repo_id,
667 doc_id,
668 r.parent_block,
669 r.order_key,
670 r.ordinal,
671 r.depth,
672 r.ancestor_path,
673 r.kind,
674 r.attrs,
675 r.text,
676 &r.raw_hash[..],
677 &r.norm_hash[..],
678 r.trivia_hash.as_ref().map(|h| &h[..]),
679 commit_id,
680 ])?;
681 }
682 }
683 fts_index_doc(tx, &doc_id)?;
684 rebuild_sections(tx, &doc_id)?;
685
686 let body = doc_blocks(assigned);
690 let mut nodes = project_nodes(&body);
691 nodes.extend(project_section_nodes(tx, &doc_id)?);
692 write_doc_nodes(tx, repo_id, &doc_id, &nodes)?;
693
694 let property_rows = doc_properties(&doc_id, fm_block, &body);
697 write_doc_properties(tx, repo_id, &doc_id, commit_id, &property_rows)?;
698
699 let mapping = fm_block.and_then(|b| parse_frontmatter(frontmatter_yaml(&b.raw)));
705 let descriptors = extract_doc_edges(&body, mapping.as_ref());
706 let resolved = resolve_edges(tx, minter, repo_id, &doc_id, plan.path, &descriptors)?;
707 maintain_edges(tx, minter, repo_id, &doc_id, commit_id, &resolved)?;
708
709 {
711 let mut ins = tx.prepare(
712 "INSERT OR IGNORE INTO dispositions (commit_id, block_id, kind, confidence, reason, matcher_v, detail)
713 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
714 )?;
715 let mut bc = tx.prepare(
716 "INSERT OR IGNORE INTO block_changes (block_id, commit_id, kind) VALUES (?1, ?2, ?3)",
717 )?;
718 for d in &plan.dispositions {
719 ins.execute(params![
720 commit_id,
721 d.block_id,
722 d.kind,
723 d.confidence,
724 d.reason,
725 d.matcher_v,
726 d.detail,
727 ])?;
728 bc.execute(params![d.block_id, commit_id, d.kind])?;
729 }
730 }
731
732 for id in &plan.consumed_pool {
734 tx.execute(
735 "DELETE FROM resurrection_pool WHERE block_id = ?1",
736 params![id],
737 )?;
738 }
739
740 match plan.conflicted {
744 Some(conflicted) => tx.execute(
745 "UPDATE docs SET current_rev = ?1, file_hash = ?2, conflicted = ?3 WHERE doc_id = ?4",
746 params![rev_id, &rendered_hash[..], i64::from(conflicted), doc_id],
747 )?,
748 None => tx.execute(
749 "UPDATE docs SET current_rev = ?1, file_hash = ?2 WHERE doc_id = ?3",
750 params![rev_id, &rendered_hash[..], doc_id],
751 )?,
752 };
753
754 let converged = render(tree) == source && reconstruct(tx, &doc_id)?.as_deref() == Some(source);
757
758 Ok(Committed {
759 doc_id,
760 commit_id: commit_id.to_owned(),
761 rev_id,
762 converged,
763 })
764}
765
766pub(crate) fn tombstone_in(
771 tx: &Connection,
772 doc_id: &str,
773 commit_id: &str,
774 expires: Option<&str>,
775) -> Result<()> {
776 if let Some(expires) = expires {
777 tx.execute(
778 "INSERT OR REPLACE INTO resurrection_pool (block_id, repo_id, doc_id, raw_hash, norm_hash, type, deleted_commit, expires_ts)
779 SELECT block_id, repo_id, doc_id, raw_hash, norm_hash, type, ?1, ?2
780 FROM blocks WHERE doc_id = ?3 AND deleted_commit IS NULL",
781 params![commit_id, expires, doc_id],
782 )?;
783 }
784 fts_delete_doc(tx, doc_id)?;
785 tx.execute(
786 "UPDATE blocks SET deleted_commit = ?1 WHERE doc_id = ?2 AND deleted_commit IS NULL",
787 params![commit_id, doc_id],
788 )?;
789 tx.execute(
790 "UPDATE docs SET deleted_commit = ?1 WHERE doc_id = ?2",
791 params![commit_id, doc_id],
792 )?;
793 Ok(())
794}
795
796impl Store {
797 pub fn observe_batch(
801 &mut self,
802 repo_id: &str,
803 items: &[BatchItem],
804 ts: &str,
805 config: &Config,
806 ) -> Result<Vec<BatchOutcome>> {
807 let expires = pool_expiry(ts)?;
808
809 let pool = load_pool(&self.conn, repo_id, ts)?;
811 let mut consumed: HashSet<String> = HashSet::new();
812 let mut pending: Vec<Pending> = Vec::with_capacity(items.len());
813 for it in items {
814 let existing: Option<(String, Option<Vec<u8>>)> = self
815 .conn
816 .query_row(
817 "SELECT doc_id, file_hash FROM docs WHERE repo_id = ?1 AND path = ?2 AND deleted_commit IS NULL",
818 params![repo_id, it.path],
819 |r| Ok((r.get(0)?, r.get(1)?)),
820 )
821 .optional()?;
822 let old_hash_hex = existing.as_ref().and_then(|(_, h)| h.as_deref()).map(hex);
823 let intent = pending_at(&self.conn, repo_id, &it.path)?;
825 let Some(source) = &it.source else {
826 match &intent {
827 Some(row) if row.kind != PendingKind::Remove => {
830 pending.push(Pending::Gone {
831 path: it.path.clone(),
832 doc_id: None,
833 old_hash_hex: None,
834 old_blocks: Vec::new(),
835 });
836 continue;
837 }
838 Some(_) => delete_pending(&self.conn, repo_id, &it.path)?,
840 None => {}
841 }
842 let (doc_id, old_blocks) = match &existing {
843 Some((id, _)) => (Some(id.clone()), load_old_match_blocks(&self.conn, id)?),
844 None => (None, Vec::new()),
845 };
846 pending.push(Pending::Gone {
847 path: it.path.clone(),
848 doc_id,
849 old_hash_hex,
850 old_blocks,
851 });
852 continue;
853 };
854 let hash = sha256(source.as_bytes());
855 let new_hash_hex = hex(&hash);
856 let echo = |doc_id: &str| {
857 Pending::Echo(ObserveOutcome {
858 path: it.path.clone(),
859 doc_id: doc_id.to_owned(),
860 rev: None,
861 commit_id: None,
862 converged: true,
863 echo: true,
864 conflicted: false,
865 dispositions: BTreeMap::new(),
866 old_hash_hex: old_hash_hex.clone(),
867 new_hash_hex: new_hash_hex.clone(),
868 })
869 };
870 if let Some(row) = &intent {
871 if row.rendered_hash[..] == hash[..] {
872 if row.kind != PendingKind::Remove {
877 delete_pending(&self.conn, repo_id, &it.path)?;
878 }
879 let doc_id = existing
880 .as_ref()
881 .map_or(row.doc_id.as_str(), |(id, _)| id.as_str());
882 pending.push(echo(doc_id));
883 continue;
884 }
885 if let Some((doc_id, Some(stored))) = &existing {
890 if stored[..] == hash[..] {
891 delete_pending(&self.conn, repo_id, &it.path)?;
892 pending.push(echo(doc_id));
893 continue;
894 }
895 }
896 } else if let Some((doc_id, Some(stored))) = &existing {
897 if stored[..] == hash[..] {
898 pending.push(echo(doc_id));
899 continue;
900 }
901 }
902 let clear_pending = intent.is_some();
903 let prepared = prepare_reconcile(
904 &self.conn,
905 &mut self.ids.at(&self.conn),
906 repo_id,
907 &it.path,
908 source,
909 config,
910 &pool,
911 &mut consumed,
912 )?;
913 pending.push(Pending::Ingest {
914 prepared: Box::new(prepared),
915 old_hash_hex,
916 new_hash_hex,
917 clear_pending,
918 });
919 }
920
921 if pending.len() > 1 {
923 cross_doc_phase(&mut pending, config);
924 }
925
926 let mut out = Vec::with_capacity(pending.len());
928 for p in pending {
929 match p {
930 Pending::Echo(outcome) => out.push(BatchOutcome::Observed(outcome)),
931 Pending::Gone {
932 path,
933 doc_id,
934 old_hash_hex,
935 ..
936 } => {
937 if let Some(id) = &doc_id {
938 self.tombstone_observed_deletion(repo_id, id, ts, &expires)?;
939 }
940 out.push(BatchOutcome::Deleted(DeleteOutcome {
941 path,
942 doc_id,
943 old_hash_hex,
944 }));
945 }
946 Pending::Ingest {
947 prepared,
948 old_hash_hex,
949 new_hash_hex,
950 clear_pending,
951 } => {
952 let conflicted = has_conflict_markers(&prepared.source);
955 let c = self.commit_prepared(
956 repo_id,
957 &prepared,
958 ts,
959 &expires,
960 conflicted,
961 clear_pending,
962 )?;
963 let dispositions = {
964 let mut stmt = self.conn.prepare(
965 "SELECT kind, count(*) FROM dispositions WHERE commit_id = ?1 GROUP BY kind ORDER BY kind",
966 )?;
967 let rows = stmt.query_map(params![c.commit_id], |r| {
968 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)? as u64))
969 })?;
970 rows.collect::<std::result::Result<BTreeMap<_, _>, _>>()?
971 };
972 out.push(BatchOutcome::Observed(ObserveOutcome {
973 path: prepared.path,
974 doc_id: c.doc_id,
975 rev: Some(c.rev_id),
976 commit_id: Some(c.commit_id),
977 converged: c.converged,
978 echo: false,
979 conflicted,
980 dispositions,
981 old_hash_hex,
982 new_hash_hex,
983 }));
984 }
985 }
986 }
987 Ok(out)
988 }
989
990 fn commit_prepared(
995 &mut self,
996 repo_id: &str,
997 prepared: &Prepared,
998 ts: &str,
999 expires: &str,
1000 conflicted: bool,
1001 clear_pending: bool,
1002 ) -> Result<Committed> {
1003 let plan = prepared.plan(
1004 &mut self.ids.at(&self.conn),
1005 Origin::Observed,
1006 None,
1007 None,
1008 Some(conflicted),
1009 )?;
1010 self.commit_ingest(
1011 repo_id,
1012 &plan,
1013 ts,
1014 expires,
1015 clear_pending.then_some(prepared.path.as_str()),
1016 )
1017 }
1018
1019 pub(crate) fn prepare_single(
1024 &mut self,
1025 repo_id: &str,
1026 path: &str,
1027 source: &str,
1028 ts: &str,
1029 config: &Config,
1030 ) -> Result<Prepared> {
1031 let pool = load_pool(&self.conn, repo_id, ts)?;
1032 let mut consumed = HashSet::new();
1033 prepare_reconcile(
1034 &self.conn,
1035 &mut self.ids.at(&self.conn),
1036 repo_id,
1037 path,
1038 source,
1039 config,
1040 &pool,
1041 &mut consumed,
1042 )
1043 }
1044
1045 #[allow(clippy::too_many_arguments)]
1054 pub fn reconciling_ingest(
1055 &mut self,
1056 repo_id: &str,
1057 path: &str,
1058 source: &str,
1059 ts: &str,
1060 origin: Origin,
1061 actor: Option<&str>,
1062 reason: Option<&str>,
1063 config: &Config,
1064 ) -> Result<Committed> {
1065 let head = CommitHead {
1066 origin,
1067 actor,
1068 reason,
1069 clear_pending: false,
1070 ops: None,
1071 };
1072 self.reconciling_commit(repo_id, path, source, ts, &head, config, Guard::None)?
1073 .committed()?
1074 .pop()
1075 .ok_or_else(|| {
1076 Error::Other(format!("reconciling_ingest: nothing committed for {path}"))
1077 })
1078 }
1079
1080 #[allow(clippy::too_many_arguments)]
1086 pub(crate) fn reconciling_commit(
1087 &mut self,
1088 repo_id: &str,
1089 path: &str,
1090 source: &str,
1091 ts: &str,
1092 head: &CommitHead<'_>,
1093 config: &Config,
1094 guard: Guard,
1095 ) -> Result<Outcome> {
1096 let prepared = self.prepare_single(repo_id, path, source, ts, config)?;
1097 self.commit_effects(
1098 repo_id,
1099 head,
1100 &[Effect::IngestReconciled {
1101 prepared: Box::new(prepared),
1102 guard,
1103 }],
1104 ts,
1105 )
1106 }
1107
1108 pub fn fresh_ingest(
1113 &mut self,
1114 repo_id: &str,
1115 path: &str,
1116 source: &str,
1117 ts: &str,
1118 origin: Origin,
1119 ) -> Result<Committed> {
1120 let expires = pool_expiry(ts)?;
1121 let tree = parse_markdown(source);
1122 let (_, rest) = split_frontmatter(&tree);
1123 let assigned = assign_fresh_ids(rest, &mut self.ids.at(&self.conn))?;
1124 let plan = IngestPlan {
1125 path,
1126 source,
1127 tree: &tree,
1128 assigned,
1129 dispositions: Vec::new(),
1130 deleted: Vec::new(),
1131 consumed_pool: Vec::new(),
1132 cross_doc_ids: Vec::new(),
1133 origin,
1134 actor: None,
1135 reason: None,
1136 conflicted: None,
1137 };
1138 self.commit_ingest(repo_id, &plan, ts, &expires, None)
1139 }
1140
1141 pub(crate) fn commit_ingest(
1148 &mut self,
1149 repo_id: &str,
1150 plan: &IngestPlan<'_>,
1151 ts: &str,
1152 expires: &str,
1153 clear_pending_at: Option<&str>,
1154 ) -> Result<Committed> {
1155 let tx = self.conn.unchecked_transaction()?;
1156 let committed = {
1157 let mut mint = self.ids.at(&tx);
1158 let (commit_id, _) = new_commit(
1160 &tx,
1161 &mut mint,
1162 &NewCommit {
1163 repo_id,
1164 ts,
1165 origin: plan.origin,
1166 actor: plan.actor,
1167 reason: plan.reason,
1168 checkpoint_id: None,
1169 ops: None,
1170 },
1171 )?;
1172 let committed = ingest_in(&tx, &mut mint, repo_id, plan, expires, &commit_id)?;
1173 if let Some(path) = clear_pending_at {
1174 delete_pending(&tx, repo_id, path)?;
1175 }
1176 committed
1177 };
1178 tx.commit()?;
1179 Ok(committed)
1180 }
1181
1182 pub(crate) fn tombstone_observed_deletion(
1186 &mut self,
1187 repo_id: &str,
1188 doc_id: &str,
1189 ts: &str,
1190 expires: &str,
1191 ) -> Result<String> {
1192 let tx = self.conn.unchecked_transaction()?;
1193 let commit_id = {
1194 let mut mint = self.ids.at(&tx);
1195 let (commit_id, _) = new_commit(
1196 &tx,
1197 &mut mint,
1198 &NewCommit {
1199 reason: Some("observed deletion"),
1200 ..NewCommit::observed(repo_id, ts)
1201 },
1202 )?;
1203 tombstone_in(&tx, doc_id, &commit_id, Some(expires))?;
1204 commit_id
1205 };
1206 tx.commit()?;
1207 Ok(commit_id)
1208 }
1209
1210 pub fn observe_one(
1213 &mut self,
1214 repo_id: &str,
1215 path: &str,
1216 source: &str,
1217 ts: &str,
1218 config: &Config,
1219 ) -> Result<ObserveOutcome> {
1220 let mut out =
1221 self.observe_batch(repo_id, &[BatchItem::observed(path, source)], ts, config)?;
1222 match out.pop() {
1223 Some(BatchOutcome::Observed(o)) => Ok(o),
1224 _ => Err(Error::Other(format!(
1225 "observe_one: unexpected outcome for {path}"
1226 ))),
1227 }
1228 }
1229
1230 pub fn observe_delete(&mut self, repo_id: &str, path: &str, ts: &str) -> Result<DeleteOutcome> {
1236 let expires = pool_expiry(ts)?;
1237 match pending_at(&self.conn, repo_id, path)? {
1238 Some(row) if row.kind != PendingKind::Remove => {
1239 return Ok(DeleteOutcome {
1240 path: path.to_owned(),
1241 doc_id: None,
1242 old_hash_hex: None,
1243 });
1244 }
1245 Some(_) => delete_pending(&self.conn, repo_id, path)?,
1246 None => {}
1247 }
1248 let existing: Option<(String, Option<Vec<u8>>)> = self
1249 .conn
1250 .query_row(
1251 "SELECT doc_id, file_hash FROM docs WHERE repo_id = ?1 AND path = ?2 AND deleted_commit IS NULL",
1252 params![repo_id, path],
1253 |r| Ok((r.get(0)?, r.get(1)?)),
1254 )
1255 .optional()?;
1256 let Some((doc_id, file_hash)) = existing else {
1257 return Ok(DeleteOutcome {
1258 path: path.to_owned(),
1259 doc_id: None,
1260 old_hash_hex: None,
1261 });
1262 };
1263 self.tombstone_observed_deletion(repo_id, &doc_id, ts, &expires)?;
1264 sweep_pool(&self.conn, ts)?;
1265 Ok(DeleteOutcome {
1266 path: path.to_owned(),
1267 doc_id: Some(doc_id),
1268 old_hash_hex: file_hash.as_deref().map(hex),
1269 })
1270 }
1271}
1272
1273#[cfg(test)]
1274mod tests {
1275 use super::*;
1276
1277 #[test]
1278 fn conflict_markers_need_both_sides_at_line_starts() {
1279 assert!(has_conflict_markers(
1280 "<<<<<<< HEAD\na\n=======\nb\n>>>>>>> branch\n"
1281 ));
1282 assert!(!has_conflict_markers("<<<<<<< HEAD\na\n=======\nb\n"));
1283 assert!(!has_conflict_markers("a <<<<<<< b\n>>>>>>> c\n"));
1284 assert!(has_conflict_markers(">>>>>>> c\r<<<<<<< b"));
1285 assert!(has_conflict_markers("x\u{2028}<<<<<<< a\u{2029}>>>>>>> b"));
1286 assert!(!has_conflict_markers(""));
1287 }
1288
1289 #[test]
1290 fn frontmatter_is_split_only_when_first() {
1291 let tree = parse_markdown("---\na: 1\n---\n\n# H\n");
1292 let (fm, rest) = split_frontmatter(&tree);
1293 assert_eq!(fm.map(|b| b.kind), Some(BlockKind::Frontmatter));
1294 assert_eq!(rest.len(), 1);
1295 let tree = parse_markdown("# H\n");
1296 let (fm, rest) = split_frontmatter(&tree);
1297 assert!(fm.is_none());
1298 assert_eq!(rest.len(), 1);
1299 }
1300}