1use std::collections::{BTreeMap, HashSet};
18
19use omgbase_format::hash::{hex, sha256};
20use omgbase_format::{
21 Block, BlockKind, BlockTree, FORMAT_MARKDOWN, format_for_path, parse_path, render,
22};
23use omgbase_graph::{extract_doc_edges, project_nodes};
24use omgbase_properties::{
25 document_mapping, document_properties, frontmatter_yaml, parse_frontmatter,
26};
27use omgbase_reconcile::json::detail_to_json;
28use omgbase_reconcile::{
29 Config, DispositionKind, FlatSource, Inserted, MatchBlock, Options, PerDocUnmatched, PoolEntry,
30 ReconcileResult, apply_cross_doc_matches, cross_doc_match, flatten, reconcile_document,
31};
32use rusqlite::{Connection, OptionalExtension, params};
33
34use crate::Store;
35use crate::delta::TreeResolver;
36use crate::derived::{
37 EvictRow, fts_before_evict_rows, fts_delete_doc, fts_index_doc, rebuild_sections, sweep_pool,
38};
39use crate::effects::{CommitHead, Effect, Guard, Outcome};
40use crate::error::{Error, Result};
41use crate::graph::{
42 adopt_phantoms, maintain_edges, project_section_nodes, resolve_edges, write_doc_nodes,
43};
44use crate::materialize::{PendingKind, delete_pending, pending_at};
45use crate::mint::Mint;
46use crate::order_key::key_between;
47use crate::properties::{doc_blocks, write_doc_properties};
48use crate::read::{load_old_match_blocks, load_pool, reconstruct};
49use crate::time::pool_expiry;
50use crate::tree::canonical_attrs;
51use crate::writers::{
52 NewCommit, NewRevision, Origin, TreeInputBlock, TreeWriteContext, assign_fresh_ids,
53 assign_from_map, is_checkpoint_revision, new_commit, previous_revision, write_block_tree_in,
54 write_frontmatter_blob, write_revision,
55};
56
57#[derive(Clone, Debug, PartialEq, Eq)]
60pub struct BatchItem {
61 pub path: String,
62 pub source: Option<String>,
63}
64
65impl BatchItem {
66 #[must_use]
68 pub fn observed(path: &str, source: &str) -> Self {
69 Self {
70 path: path.to_owned(),
71 source: Some(source.to_owned()),
72 }
73 }
74
75 #[must_use]
77 pub fn gone(path: &str) -> Self {
78 Self {
79 path: path.to_owned(),
80 source: None,
81 }
82 }
83}
84
85#[derive(Clone, Debug, PartialEq, Eq)]
87pub struct ObserveOutcome {
88 pub path: String,
89 pub doc_id: String,
90 pub rev: Option<String>,
92 pub commit_id: Option<String>,
94 pub converged: bool,
96 pub echo: bool,
98 pub conflicted: bool,
100 pub dispositions: BTreeMap<String, u64>,
102 pub old_hash_hex: Option<String>,
104 pub new_hash_hex: String,
106}
107
108#[derive(Clone, Debug, PartialEq, Eq)]
110pub struct DeleteOutcome {
111 pub path: String,
112 pub doc_id: Option<String>,
114 pub old_hash_hex: Option<String>,
115}
116
117impl DeleteOutcome {
118 #[must_use]
120 pub fn deleted(&self) -> bool {
121 self.doc_id.is_some()
122 }
123}
124
125#[derive(Clone, Debug, PartialEq, Eq)]
127pub enum BatchOutcome {
128 Observed(ObserveOutcome),
129 Deleted(DeleteOutcome),
130}
131
132impl BatchOutcome {
133 #[must_use]
134 pub fn path(&self) -> &str {
135 match self {
136 BatchOutcome::Observed(o) => &o.path,
137 BatchOutcome::Deleted(d) => &d.path,
138 }
139 }
140
141 #[must_use]
142 pub fn as_observed(&self) -> Option<&ObserveOutcome> {
143 match self {
144 BatchOutcome::Observed(o) => Some(o),
145 BatchOutcome::Deleted(_) => None,
146 }
147 }
148
149 #[must_use]
150 pub fn as_deleted(&self) -> Option<&DeleteOutcome> {
151 match self {
152 BatchOutcome::Deleted(d) => Some(d),
153 BatchOutcome::Observed(_) => None,
154 }
155 }
156}
157
158#[must_use]
162pub fn has_conflict_markers(source: &str) -> bool {
163 let starts = |marker: &str| {
164 source
165 .split(['\n', '\r', '\u{2028}', '\u{2029}'])
166 .any(|line| line.starts_with(marker))
167 };
168 starts("<<<<<<<") && starts(">>>>>>>")
169}
170
171pub(crate) struct Prepared {
175 pub(crate) path: String,
176 pub(crate) source: String,
177 doc_id: Option<String>,
179 tree: BlockTree,
180 old_blocks: Vec<MatchBlock>,
181 new_blocks: Vec<MatchBlock>,
182 result: ReconcileResult,
183 cross_doc_ids: Vec<String>,
185}
186
187impl Prepared {
188 pub(crate) fn plan<'a>(
192 &'a self,
193 minter: &mut Mint<'_>,
194 origin: Origin,
195 actor: Option<&'a str>,
196 reason: Option<&'a str>,
197 conflicted: Option<bool>,
198 ) -> Result<IngestPlan<'a>> {
199 let (_, rest) = split_frontmatter(&self.tree);
200 let assigned = assign_from_map(rest, &self.result.assignment, minter)?;
201 Ok(IngestPlan {
202 path: &self.path,
203 source: &self.source,
204 tree: &self.tree,
205 assigned,
206 dispositions: disposition_rows(&self.result),
207 deleted: self.result.deleted.clone(),
208 consumed_pool: self.result.consumed_pool.clone(),
209 cross_doc_ids: self.cross_doc_ids.clone(),
210 origin,
211 actor,
212 reason,
213 conflicted,
214 })
215 }
216}
217
218enum Pending {
219 Echo(ObserveOutcome),
220 Gone {
221 path: String,
222 doc_id: Option<String>,
223 old_hash_hex: Option<String>,
224 old_blocks: Vec<MatchBlock>,
225 },
226 Ingest {
227 prepared: Box<Prepared>,
228 old_hash_hex: Option<String>,
229 new_hash_hex: String,
230 clear_pending: bool,
233 },
234}
235
236fn split_frontmatter(tree: &BlockTree) -> (Option<&Block>, &[Block]) {
238 match tree.children.first() {
239 Some(b) if b.kind == BlockKind::Frontmatter => (Some(b), &tree.children[1..]),
240 _ => (None, &tree.children[..]),
241 }
242}
243
244#[allow(clippy::too_many_arguments)]
247fn prepare_reconcile(
248 conn: &Connection,
249 minter: &mut Mint<'_>,
250 repo_id: &str,
251 path: &str,
252 source: &str,
253 config: &Config,
254 pool: &[PoolEntry],
255 consumed: &mut HashSet<String>,
256) -> Result<Prepared> {
257 let doc_id: Option<String> = conn
258 .query_row(
259 "SELECT doc_id FROM docs WHERE repo_id = ?1 AND path = ?2",
260 params![repo_id, path],
261 |r| r.get(0),
262 )
263 .optional()?;
264 let tree = parse_path(path, source);
268 let old_blocks = match &doc_id {
269 Some(id) => load_old_match_blocks(conn, id)?,
270 None => Vec::new(),
271 };
272 let new_blocks = flatten(&FlatSource::from_tree(&tree, None));
273 let offered: Vec<PoolEntry> = if doc_id.is_some() {
274 pool.iter()
275 .filter(|c| !consumed.contains(&c.id))
276 .cloned()
277 .collect()
278 } else {
279 Vec::new()
280 };
281 let mut mint = minter.deferred("b");
282 let result = reconcile_document(
283 &old_blocks,
284 &new_blocks,
285 Options {
286 config,
287 pool: &offered,
288 minter: &mut mint,
289 },
290 );
291 mint.finish()?;
292 consumed.extend(result.consumed_pool.iter().cloned());
293 Ok(Prepared {
294 path: path.to_owned(),
295 source: source.to_owned(),
296 doc_id,
297 tree,
298 old_blocks,
299 new_blocks,
300 result,
301 cross_doc_ids: Vec::new(),
302 })
303}
304
305fn cross_doc_phase(pending: &mut [Pending], config: &Config) {
307 let mut docs: Vec<PerDocUnmatched> = Vec::new();
308 let mut results: BTreeMap<String, ReconcileResult> = BTreeMap::new();
309 let mut key_of: Vec<Option<String>> = vec![None; pending.len()];
310 for (i, p) in pending.iter_mut().enumerate() {
311 match p {
312 Pending::Echo(_) | Pending::Gone { doc_id: None, .. } => {}
313 Pending::Gone {
314 doc_id: Some(doc_id),
315 old_blocks,
316 ..
317 } => {
318 docs.push(PerDocUnmatched {
319 doc_id: doc_id.clone(),
320 deleted: old_blocks.clone(),
321 inserted: Vec::new(),
322 });
323 results.insert(
324 doc_id.clone(),
325 ReconcileResult {
326 deleted: old_blocks.iter().filter_map(|b| b.id.clone()).collect(),
327 ..ReconcileResult::default()
328 },
329 );
330 }
331 Pending::Ingest { prepared, .. } => {
332 let key = prepared
333 .doc_id
334 .clone()
335 .unwrap_or_else(|| format!("new:{}", prepared.path));
336 let key_of_id: BTreeMap<&str, &str> = prepared
337 .result
338 .assignment
339 .iter()
340 .map(|(k, id)| (id.as_str(), k.as_str()))
341 .collect();
342 let deleted = prepared
343 .result
344 .deleted
345 .iter()
346 .filter_map(|id| {
347 prepared
348 .old_blocks
349 .iter()
350 .find(|b| b.id.as_deref() == Some(id))
351 })
352 .cloned()
353 .collect();
354 let inserted = prepared
355 .result
356 .dispositions
357 .iter()
358 .filter(|d| d.kind == DispositionKind::Inserted)
359 .filter_map(|d| {
360 let k = key_of_id.get(d.block_id.as_str())?;
361 let block = prepared.new_blocks.iter().find(|b| &b.key == k)?;
362 Some(Inserted {
363 block: block.clone(),
364 minted_id: d.block_id.clone(),
365 })
366 })
367 .collect();
368 docs.push(PerDocUnmatched {
369 doc_id: key.clone(),
370 deleted,
371 inserted,
372 });
373 results.insert(key.clone(), std::mem::take(&mut prepared.result));
374 key_of[i] = Some(key);
375 }
376 }
377 }
378 let matches = if docs.len() < 2 {
379 Vec::new()
380 } else {
381 cross_doc_match(&docs, config)
382 };
383 if !matches.is_empty() {
384 apply_cross_doc_matches(&mut results, &matches, &config.matcher_v);
385 }
386 for (i, p) in pending.iter_mut().enumerate() {
387 if let (Pending::Ingest { prepared, .. }, Some(key)) = (p, &key_of[i]) {
388 prepared.result = results
389 .remove(key)
390 .expect("every prepared member was keyed");
391 for m in &matches {
392 if &m.to_doc == key {
393 prepared.cross_doc_ids.push(m.carried_id.clone());
394 }
395 }
396 }
397 }
398}
399
400struct BlockRow {
401 block_id: String,
402 parent_block: Option<String>,
403 order_key: String,
404 ordinal: i64,
405 depth: i64,
406 ancestor_path: String,
407 kind: String,
408 attrs: String,
409 text: String,
410 raw_hash: [u8; 32],
411 norm_hash: [u8; 32],
412 trivia_hash: Option<[u8; 32]>,
413}
414
415fn flatten_rows(
417 blocks: &[TreeInputBlock],
418 parent: Option<&str>,
419 depth: i64,
420 ancestor_path: &str,
421 out: &mut Vec<BlockRow>,
422) {
423 let mut prev_key: Option<String> = None;
424 for (ordinal, b) in blocks.iter().enumerate() {
425 let order_key = key_between(prev_key.as_deref(), None);
426 prev_key = Some(order_key.clone());
427 out.push(BlockRow {
428 block_id: b.block_id.clone(),
429 parent_block: parent.map(str::to_owned),
430 order_key,
431 ordinal: ordinal as i64,
432 depth,
433 ancestor_path: ancestor_path.to_owned(),
434 kind: b.kind.clone(),
435 attrs: canonical_attrs(&b.attrs),
436 text: b.text.clone(),
437 raw_hash: sha256(b.raw.as_bytes()),
438 norm_hash: sha256(b.text.as_bytes()),
439 trivia_hash: (!b.trivia.is_empty()).then(|| sha256(b.trivia.as_bytes())),
440 });
441 if !b.children.is_empty() {
442 flatten_rows(
443 &b.children,
444 Some(&b.block_id),
445 depth + 1,
446 &format!("{ancestor_path}{}/", b.block_id),
447 out,
448 );
449 }
450 }
451}
452
453fn evict_foreign_block_rows(conn: &Connection, doc_id: &str, ids: &[String]) -> Result<()> {
458 let mut rows = Vec::new();
459 for id in ids {
460 let row: Option<EvictRow> = conn
461 .query_row(
462 "SELECT rowid, block_id, doc_id, parent_block, text, deleted_commit IS NULL
463 FROM blocks WHERE block_id = ?1 AND doc_id != ?2",
464 params![id, doc_id],
465 |r| {
466 Ok(EvictRow {
467 rowid: r.get(0)?,
468 block_id: r.get(1)?,
469 doc_id: r.get(2)?,
470 parent_block: r.get(3)?,
471 text: r.get(4)?,
472 live: r.get(5)?,
473 })
474 },
475 )
476 .optional()?;
477 if let Some(row) = row {
478 rows.push(row);
479 }
480 }
481 fts_before_evict_rows(conn, &rows)?;
482 for r in &rows {
483 conn.execute(
484 "DELETE FROM blocks WHERE block_id = ?1 AND doc_id != ?2",
485 params![r.block_id, doc_id],
486 )?;
487 }
488 for id in ids {
489 conn.execute(
490 "DELETE FROM resurrection_pool WHERE block_id = ?1",
491 params![id],
492 )?;
493 }
494 Ok(())
495}
496
497#[derive(Clone, Debug, PartialEq, Eq)]
500pub struct Committed {
501 pub doc_id: String,
502 pub commit_id: String,
503 pub rev_id: String,
504 pub converged: bool,
505}
506
507#[derive(Clone, Debug, PartialEq)]
510pub(crate) struct DispositionRow {
511 pub block_id: String,
512 pub kind: String,
513 pub confidence: Option<f64>,
514 pub reason: Option<String>,
515 pub matcher_v: Option<String>,
516 pub detail: String,
518}
519
520pub(crate) struct IngestPlan<'a> {
523 pub path: &'a str,
524 pub source: &'a str,
525 pub tree: &'a BlockTree,
526 pub assigned: Vec<TreeInputBlock>,
527 pub dispositions: Vec<DispositionRow>,
528 pub deleted: Vec<String>,
530 pub consumed_pool: Vec<String>,
531 pub cross_doc_ids: Vec<String>,
532 pub origin: Origin,
533 pub actor: Option<&'a str>,
534 pub reason: Option<&'a str>,
535 pub conflicted: Option<bool>,
541}
542
543#[must_use]
546pub fn persists_disposition(kind: &str) -> bool {
547 kind != "same"
548}
549
550fn count_kinds(result: &ReconcileResult) -> BTreeMap<String, u64> {
554 let mut counts = BTreeMap::new();
555 for d in &result.dispositions {
556 *counts.entry(d.kind.as_str().to_owned()).or_insert(0) += 1;
557 }
558 counts
559}
560
561fn disposition_rows(result: &ReconcileResult) -> Vec<DispositionRow> {
562 result
563 .dispositions
564 .iter()
565 .map(|d| DispositionRow {
566 block_id: d.block_id.clone(),
567 kind: d.kind.as_str().to_owned(),
568 confidence: d.confidence,
569 reason: d.reason.map(|r| r.as_str().to_owned()),
570 matcher_v: Some(d.matcher_v.clone()),
571 detail: detail_to_json(&d.detail).to_string(),
572 })
573 .collect()
574}
575
576pub(crate) fn ingest_in(
583 tx: &Connection,
584 mint: &mut Mint<'_>,
585 repo_id: &str,
586 plan: &IngestPlan<'_>,
587 expires: &str,
588 commit_id: &str,
589) -> Result<Committed> {
590 let minter = mint;
591 let tree = plan.tree;
592 let source = plan.source;
593 let (fm_block, _) = split_frontmatter(tree);
594 let format = format_for_path(plan.path);
597 let markdown = format == FORMAT_MARKDOWN;
598
599 let fm_trivia = fm_block.map(|b| b.trivia.as_str());
600
601 let existing: Option<(String, Option<String>)> = tx
605 .query_row(
606 "SELECT doc_id, deleted_commit FROM docs WHERE repo_id = ?1 AND path = ?2",
607 params![repo_id, plan.path],
608 |r| Ok((r.get(0)?, r.get(1)?)),
609 )
610 .optional()?;
611 let doc_id = match existing {
612 Some((id, deleted_commit)) => {
613 tx.execute(
614 "UPDATE docs SET format = ?1, leading_trivia = ?2, frontmatter_trivia = ?3, deleted_commit = NULL WHERE doc_id = ?4",
615 params![format, tree.leading_trivia, fm_trivia, id],
616 )?;
617 if deleted_commit.is_some() {
621 adopt_phantoms(tx, plan.path, &id)?;
622 }
623 id
624 }
625 None => {
626 let id = minter.mint("d")?;
627 tx.execute(
628 "INSERT INTO docs (doc_id, repo_id, path, format, leading_trivia, frontmatter_trivia) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
629 params![id, repo_id, plan.path, format, tree.leading_trivia, fm_trivia],
630 )?;
631 adopt_phantoms(tx, plan.path, &id)?;
635 id
636 }
637 };
638
639 let previous = previous_revision(tx, &doc_id)?;
647 let seq = previous.as_ref().map_or(1, |p| p.seq + 1);
648 let checkpoint = is_checkpoint_revision(seq, previous.as_ref());
649 let mut resolver = TreeResolver::new(tx);
650 let base = match &previous {
651 Some(p) if !checkpoint => match &p.root_tree {
652 Some(root) => Some(resolver.tree(&doc_id, p.seq, &hex(root))?),
653 None => None,
654 },
655 _ => None,
656 };
657 let tree_ctx = TreeWriteContext { base, checkpoint };
658
659 let fm_blob_hex = match fm_block {
662 Some(b) => {
663 let base_raw = match &previous {
664 Some(p) if !checkpoint => match &p.frontmatter_blob {
665 Some(fm) => Some(resolver.frontmatter(&doc_id, p.seq, &hex(fm))?),
666 None => None,
667 },
668 _ => None,
669 };
670 Some(write_frontmatter_blob(
671 tx,
672 &b.raw,
673 base_raw.as_deref(),
674 checkpoint,
675 )?)
676 }
677 None => None,
678 };
679
680 let assigned = &plan.assigned;
682 let root_tree_hex = write_block_tree_in(tx, assigned, &tree_ctx)?;
683
684 let rendered_hash = sha256(source.as_bytes());
686 let (rev_id, _) = write_revision(
687 tx,
688 minter,
689 &NewRevision {
690 doc_id: &doc_id,
691 root_tree_hex: &root_tree_hex,
692 frontmatter_blob_hex: fm_blob_hex.as_deref(),
693 rendered_hash,
694 path: plan.path,
695 commit_id,
696 seq: Some(seq),
697 },
698 )?;
699
700 {
702 let mut pool = tx.prepare(
703 "INSERT OR REPLACE INTO resurrection_pool (block_id, repo_id, doc_id, raw_hash, norm_hash, type, deleted_commit, expires_ts)
704 SELECT block_id, repo_id, doc_id, raw_hash, norm_hash, type, ?1, ?2 FROM blocks WHERE block_id = ?3 AND doc_id = ?4",
705 )?;
706 for id in &plan.deleted {
707 pool.execute(params![commit_id, expires, id, doc_id])?;
708 }
709 }
710
711 let incoming: Vec<String> = plan
713 .cross_doc_ids
714 .iter()
715 .chain(plan.consumed_pool.iter())
716 .cloned()
717 .collect();
718 if !incoming.is_empty() {
719 evict_foreign_block_rows(tx, &doc_id, &incoming)?;
720 }
721
722 fts_delete_doc(tx, &doc_id)?;
724 tx.execute("DELETE FROM blocks WHERE doc_id = ?1", params![doc_id])?;
725 let mut rows = Vec::new();
726 flatten_rows(assigned, None, 0, "/", &mut rows);
727 {
728 let mut insert = tx.prepare(
729 "INSERT INTO blocks
730 (block_id, repo_id, doc_id, parent_block, order_key, ordinal, depth,
731 ancestor_path, type, attrs, text, raw_hash, norm_hash, trivia_hash, created_commit)
732 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
733 )?;
734 for r in &rows {
735 insert.execute(params![
736 r.block_id,
737 repo_id,
738 doc_id,
739 r.parent_block,
740 r.order_key,
741 r.ordinal,
742 r.depth,
743 r.ancestor_path,
744 r.kind,
745 r.attrs,
746 r.text,
747 &r.raw_hash[..],
748 &r.norm_hash[..],
749 r.trivia_hash.as_ref().map(|h| &h[..]),
750 commit_id,
751 ])?;
752 }
753 }
754 fts_index_doc(tx, &doc_id)?;
755 rebuild_sections(tx, &doc_id)?;
756
757 let body = doc_blocks(assigned);
764 let mut nodes = if markdown {
765 project_nodes(&body)
766 } else {
767 Vec::new()
768 };
769 nodes.extend(project_section_nodes(tx, &doc_id)?);
770 write_doc_nodes(tx, repo_id, &doc_id, &nodes)?;
771
772 let property_rows = document_properties(&doc_id, format, source, fm_block, &body);
777 write_doc_properties(tx, repo_id, &doc_id, commit_id, &property_rows)?;
778
779 let mapping = if markdown {
787 fm_block.and_then(|b| parse_frontmatter(frontmatter_yaml(&b.raw)))
788 } else {
789 document_mapping(format, source)
790 };
791 let descriptors = if markdown {
792 extract_doc_edges(&body, mapping.as_ref())
793 } else {
794 extract_doc_edges(&[], mapping.as_ref())
795 };
796 let resolved = resolve_edges(tx, minter, repo_id, &doc_id, plan.path, &descriptors)?;
797 maintain_edges(tx, minter, repo_id, &doc_id, commit_id, &resolved)?;
798
799 {
808 let mut ins = tx.prepare(
809 "INSERT OR IGNORE INTO dispositions (commit_id, block_id, kind, confidence, reason, matcher_v, detail)
810 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
811 )?;
812 let mut bc = tx.prepare(
813 "INSERT OR IGNORE INTO block_changes (block_id, commit_id, kind) VALUES (?1, ?2, ?3)",
814 )?;
815 for d in plan
816 .dispositions
817 .iter()
818 .filter(|d| persists_disposition(&d.kind))
819 {
820 ins.execute(params![
821 commit_id,
822 d.block_id,
823 d.kind,
824 d.confidence,
825 d.reason,
826 d.matcher_v,
827 d.detail,
828 ])?;
829 bc.execute(params![d.block_id, commit_id, d.kind])?;
830 }
831 }
832
833 for id in &plan.consumed_pool {
835 tx.execute(
836 "DELETE FROM resurrection_pool WHERE block_id = ?1",
837 params![id],
838 )?;
839 }
840
841 match plan.conflicted {
845 Some(conflicted) => tx.execute(
846 "UPDATE docs SET current_rev = ?1, file_hash = ?2, conflicted = ?3 WHERE doc_id = ?4",
847 params![rev_id, &rendered_hash[..], i64::from(conflicted), doc_id],
848 )?,
849 None => tx.execute(
850 "UPDATE docs SET current_rev = ?1, file_hash = ?2 WHERE doc_id = ?3",
851 params![rev_id, &rendered_hash[..], doc_id],
852 )?,
853 };
854
855 let converged = render(tree) == source && reconstruct(tx, &doc_id)?.as_deref() == Some(source);
858
859 Ok(Committed {
860 doc_id,
861 commit_id: commit_id.to_owned(),
862 rev_id,
863 converged,
864 })
865}
866
867pub(crate) fn tombstone_in(
872 tx: &Connection,
873 doc_id: &str,
874 commit_id: &str,
875 expires: Option<&str>,
876) -> Result<()> {
877 if let Some(expires) = expires {
878 tx.execute(
879 "INSERT OR REPLACE INTO resurrection_pool (block_id, repo_id, doc_id, raw_hash, norm_hash, type, deleted_commit, expires_ts)
880 SELECT block_id, repo_id, doc_id, raw_hash, norm_hash, type, ?1, ?2
881 FROM blocks WHERE doc_id = ?3 AND deleted_commit IS NULL",
882 params![commit_id, expires, doc_id],
883 )?;
884 }
885 fts_delete_doc(tx, doc_id)?;
886 tx.execute(
887 "UPDATE blocks SET deleted_commit = ?1 WHERE doc_id = ?2 AND deleted_commit IS NULL",
888 params![commit_id, doc_id],
889 )?;
890 tx.execute(
891 "UPDATE docs SET deleted_commit = ?1 WHERE doc_id = ?2",
892 params![commit_id, doc_id],
893 )?;
894 Ok(())
895}
896
897impl Store {
898 pub fn observe_batch(
902 &mut self,
903 repo_id: &str,
904 items: &[BatchItem],
905 ts: &str,
906 config: &Config,
907 ) -> Result<Vec<BatchOutcome>> {
908 let expires = pool_expiry(ts)?;
909
910 let pool = load_pool(&self.conn, repo_id, ts)?;
912 let mut consumed: HashSet<String> = HashSet::new();
913 let mut pending: Vec<Pending> = Vec::with_capacity(items.len());
914 for it in items {
915 let existing: Option<(String, Option<Vec<u8>>)> = self
916 .conn
917 .query_row(
918 "SELECT doc_id, file_hash FROM docs WHERE repo_id = ?1 AND path = ?2 AND deleted_commit IS NULL",
919 params![repo_id, it.path],
920 |r| Ok((r.get(0)?, r.get(1)?)),
921 )
922 .optional()?;
923 let old_hash_hex = existing.as_ref().and_then(|(_, h)| h.as_deref()).map(hex);
924 let intent = pending_at(&self.conn, repo_id, &it.path)?;
926 let Some(source) = &it.source else {
927 match &intent {
928 Some(row) if row.kind != PendingKind::Remove => {
931 pending.push(Pending::Gone {
932 path: it.path.clone(),
933 doc_id: None,
934 old_hash_hex: None,
935 old_blocks: Vec::new(),
936 });
937 continue;
938 }
939 Some(_) => delete_pending(&self.conn, repo_id, &it.path)?,
941 None => {}
942 }
943 let (doc_id, old_blocks) = match &existing {
944 Some((id, _)) => (Some(id.clone()), load_old_match_blocks(&self.conn, id)?),
945 None => (None, Vec::new()),
946 };
947 pending.push(Pending::Gone {
948 path: it.path.clone(),
949 doc_id,
950 old_hash_hex,
951 old_blocks,
952 });
953 continue;
954 };
955 let hash = sha256(source.as_bytes());
956 let new_hash_hex = hex(&hash);
957 let echo = |doc_id: &str| {
958 Pending::Echo(ObserveOutcome {
959 path: it.path.clone(),
960 doc_id: doc_id.to_owned(),
961 rev: None,
962 commit_id: None,
963 converged: true,
964 echo: true,
965 conflicted: false,
966 dispositions: BTreeMap::new(),
967 old_hash_hex: old_hash_hex.clone(),
968 new_hash_hex: new_hash_hex.clone(),
969 })
970 };
971 if let Some(row) = &intent {
972 if row.rendered_hash[..] == hash[..] {
973 if row.kind != PendingKind::Remove {
978 delete_pending(&self.conn, repo_id, &it.path)?;
979 }
980 let doc_id = existing
981 .as_ref()
982 .map_or(row.doc_id.as_str(), |(id, _)| id.as_str());
983 pending.push(echo(doc_id));
984 continue;
985 }
986 if let Some((doc_id, Some(stored))) = &existing {
991 if stored[..] == hash[..] {
992 delete_pending(&self.conn, repo_id, &it.path)?;
993 pending.push(echo(doc_id));
994 continue;
995 }
996 }
997 } else if let Some((doc_id, Some(stored))) = &existing {
998 if stored[..] == hash[..] {
999 pending.push(echo(doc_id));
1000 continue;
1001 }
1002 }
1003 let clear_pending = intent.is_some();
1004 let prepared = prepare_reconcile(
1005 &self.conn,
1006 &mut self.ids.at(&self.conn),
1007 repo_id,
1008 &it.path,
1009 source,
1010 config,
1011 &pool,
1012 &mut consumed,
1013 )?;
1014 pending.push(Pending::Ingest {
1015 prepared: Box::new(prepared),
1016 old_hash_hex,
1017 new_hash_hex,
1018 clear_pending,
1019 });
1020 }
1021
1022 if pending.len() > 1 {
1024 cross_doc_phase(&mut pending, config);
1025 }
1026
1027 let mut out = Vec::with_capacity(pending.len());
1029 for p in pending {
1030 match p {
1031 Pending::Echo(outcome) => out.push(BatchOutcome::Observed(outcome)),
1032 Pending::Gone {
1033 path,
1034 doc_id,
1035 old_hash_hex,
1036 ..
1037 } => {
1038 if let Some(id) = &doc_id {
1039 self.tombstone_observed_deletion(repo_id, id, ts, &expires)?;
1040 }
1041 out.push(BatchOutcome::Deleted(DeleteOutcome {
1042 path,
1043 doc_id,
1044 old_hash_hex,
1045 }));
1046 }
1047 Pending::Ingest {
1048 prepared,
1049 old_hash_hex,
1050 new_hash_hex,
1051 clear_pending,
1052 } => {
1053 let conflicted = has_conflict_markers(&prepared.source);
1056 let c = self.commit_prepared(
1057 repo_id,
1058 &prepared,
1059 ts,
1060 &expires,
1061 conflicted,
1062 clear_pending,
1063 )?;
1064 let dispositions = count_kinds(&prepared.result);
1071 out.push(BatchOutcome::Observed(ObserveOutcome {
1072 path: prepared.path,
1073 doc_id: c.doc_id,
1074 rev: Some(c.rev_id),
1075 commit_id: Some(c.commit_id),
1076 converged: c.converged,
1077 echo: false,
1078 conflicted,
1079 dispositions,
1080 old_hash_hex,
1081 new_hash_hex,
1082 }));
1083 }
1084 }
1085 }
1086 Ok(out)
1087 }
1088
1089 fn commit_prepared(
1094 &mut self,
1095 repo_id: &str,
1096 prepared: &Prepared,
1097 ts: &str,
1098 expires: &str,
1099 conflicted: bool,
1100 clear_pending: bool,
1101 ) -> Result<Committed> {
1102 let plan = prepared.plan(
1103 &mut self.ids.at(&self.conn),
1104 Origin::Observed,
1105 None,
1106 None,
1107 Some(conflicted),
1108 )?;
1109 self.commit_ingest(
1110 repo_id,
1111 &plan,
1112 ts,
1113 expires,
1114 clear_pending.then_some(prepared.path.as_str()),
1115 )
1116 }
1117
1118 pub(crate) fn prepare_single(
1123 &mut self,
1124 repo_id: &str,
1125 path: &str,
1126 source: &str,
1127 ts: &str,
1128 config: &Config,
1129 ) -> Result<Prepared> {
1130 let pool = load_pool(&self.conn, repo_id, ts)?;
1131 let mut consumed = HashSet::new();
1132 prepare_reconcile(
1133 &self.conn,
1134 &mut self.ids.at(&self.conn),
1135 repo_id,
1136 path,
1137 source,
1138 config,
1139 &pool,
1140 &mut consumed,
1141 )
1142 }
1143
1144 #[allow(clippy::too_many_arguments)]
1153 pub fn reconciling_ingest(
1154 &mut self,
1155 repo_id: &str,
1156 path: &str,
1157 source: &str,
1158 ts: &str,
1159 origin: Origin,
1160 actor: Option<&str>,
1161 reason: Option<&str>,
1162 config: &Config,
1163 ) -> Result<Committed> {
1164 let head = CommitHead {
1165 origin,
1166 actor,
1167 reason,
1168 clear_pending: false,
1169 ops: None,
1170 };
1171 self.reconciling_commit(repo_id, path, source, ts, &head, config, Guard::None)?
1172 .committed()?
1173 .pop()
1174 .ok_or_else(|| {
1175 Error::Other(format!("reconciling_ingest: nothing committed for {path}"))
1176 })
1177 }
1178
1179 #[allow(clippy::too_many_arguments)]
1185 pub(crate) fn reconciling_commit(
1186 &mut self,
1187 repo_id: &str,
1188 path: &str,
1189 source: &str,
1190 ts: &str,
1191 head: &CommitHead<'_>,
1192 config: &Config,
1193 guard: Guard,
1194 ) -> Result<Outcome> {
1195 let prepared = self.prepare_single(repo_id, path, source, ts, config)?;
1196 self.commit_effects(
1197 repo_id,
1198 head,
1199 &[Effect::IngestReconciled {
1200 prepared: Box::new(prepared),
1201 guard,
1202 }],
1203 ts,
1204 )
1205 }
1206
1207 pub fn fresh_ingest(
1212 &mut self,
1213 repo_id: &str,
1214 path: &str,
1215 source: &str,
1216 ts: &str,
1217 origin: Origin,
1218 ) -> Result<Committed> {
1219 let expires = pool_expiry(ts)?;
1220 let tree = parse_path(path, source);
1221 let (_, rest) = split_frontmatter(&tree);
1222 let assigned = assign_fresh_ids(rest, &mut self.ids.at(&self.conn))?;
1223 let plan = IngestPlan {
1224 path,
1225 source,
1226 tree: &tree,
1227 assigned,
1228 dispositions: Vec::new(),
1229 deleted: Vec::new(),
1230 consumed_pool: Vec::new(),
1231 cross_doc_ids: Vec::new(),
1232 origin,
1233 actor: None,
1234 reason: None,
1235 conflicted: None,
1236 };
1237 self.commit_ingest(repo_id, &plan, ts, &expires, None)
1238 }
1239
1240 pub(crate) fn commit_ingest(
1247 &mut self,
1248 repo_id: &str,
1249 plan: &IngestPlan<'_>,
1250 ts: &str,
1251 expires: &str,
1252 clear_pending_at: Option<&str>,
1253 ) -> Result<Committed> {
1254 let tx = self.conn.unchecked_transaction()?;
1255 let committed = {
1256 let mut mint = self.ids.at(&tx);
1257 let (commit_id, _) = new_commit(
1259 &tx,
1260 &mut mint,
1261 &NewCommit {
1262 repo_id,
1263 ts,
1264 origin: plan.origin,
1265 actor: plan.actor,
1266 reason: plan.reason,
1267 checkpoint_id: None,
1268 ops: None,
1269 },
1270 )?;
1271 let committed = ingest_in(&tx, &mut mint, repo_id, plan, expires, &commit_id)?;
1272 if let Some(path) = clear_pending_at {
1273 delete_pending(&tx, repo_id, path)?;
1274 }
1275 committed
1276 };
1277 tx.commit()?;
1278 Ok(committed)
1279 }
1280
1281 pub(crate) fn tombstone_observed_deletion(
1285 &mut self,
1286 repo_id: &str,
1287 doc_id: &str,
1288 ts: &str,
1289 expires: &str,
1290 ) -> Result<String> {
1291 let tx = self.conn.unchecked_transaction()?;
1292 let commit_id = {
1293 let mut mint = self.ids.at(&tx);
1294 let (commit_id, _) = new_commit(
1295 &tx,
1296 &mut mint,
1297 &NewCommit {
1298 reason: Some("observed deletion"),
1299 ..NewCommit::observed(repo_id, ts)
1300 },
1301 )?;
1302 tombstone_in(&tx, doc_id, &commit_id, Some(expires))?;
1303 commit_id
1304 };
1305 tx.commit()?;
1306 Ok(commit_id)
1307 }
1308
1309 pub fn observe_one(
1312 &mut self,
1313 repo_id: &str,
1314 path: &str,
1315 source: &str,
1316 ts: &str,
1317 config: &Config,
1318 ) -> Result<ObserveOutcome> {
1319 let mut out =
1320 self.observe_batch(repo_id, &[BatchItem::observed(path, source)], ts, config)?;
1321 match out.pop() {
1322 Some(BatchOutcome::Observed(o)) => Ok(o),
1323 _ => Err(Error::Other(format!(
1324 "observe_one: unexpected outcome for {path}"
1325 ))),
1326 }
1327 }
1328
1329 pub fn observe_delete(&mut self, repo_id: &str, path: &str, ts: &str) -> Result<DeleteOutcome> {
1335 let expires = pool_expiry(ts)?;
1336 match pending_at(&self.conn, repo_id, path)? {
1337 Some(row) if row.kind != PendingKind::Remove => {
1338 return Ok(DeleteOutcome {
1339 path: path.to_owned(),
1340 doc_id: None,
1341 old_hash_hex: None,
1342 });
1343 }
1344 Some(_) => delete_pending(&self.conn, repo_id, path)?,
1345 None => {}
1346 }
1347 let existing: Option<(String, Option<Vec<u8>>)> = self
1348 .conn
1349 .query_row(
1350 "SELECT doc_id, file_hash FROM docs WHERE repo_id = ?1 AND path = ?2 AND deleted_commit IS NULL",
1351 params![repo_id, path],
1352 |r| Ok((r.get(0)?, r.get(1)?)),
1353 )
1354 .optional()?;
1355 let Some((doc_id, file_hash)) = existing else {
1356 return Ok(DeleteOutcome {
1357 path: path.to_owned(),
1358 doc_id: None,
1359 old_hash_hex: None,
1360 });
1361 };
1362 self.tombstone_observed_deletion(repo_id, &doc_id, ts, &expires)?;
1363 sweep_pool(&self.conn, ts)?;
1364 Ok(DeleteOutcome {
1365 path: path.to_owned(),
1366 doc_id: Some(doc_id),
1367 old_hash_hex: file_hash.as_deref().map(hex),
1368 })
1369 }
1370}
1371
1372#[cfg(test)]
1373mod tests {
1374 use super::*;
1375 use omgbase_format::parse_markdown;
1376
1377 #[test]
1378 fn conflict_markers_need_both_sides_at_line_starts() {
1379 assert!(has_conflict_markers(
1380 "<<<<<<< HEAD\na\n=======\nb\n>>>>>>> branch\n"
1381 ));
1382 assert!(!has_conflict_markers("<<<<<<< HEAD\na\n=======\nb\n"));
1383 assert!(!has_conflict_markers("a <<<<<<< b\n>>>>>>> c\n"));
1384 assert!(has_conflict_markers(">>>>>>> c\r<<<<<<< b"));
1385 assert!(has_conflict_markers("x\u{2028}<<<<<<< a\u{2029}>>>>>>> b"));
1386 assert!(!has_conflict_markers(""));
1387 }
1388
1389 #[test]
1390 fn frontmatter_is_split_only_when_first() {
1391 let tree = parse_markdown("---\na: 1\n---\n\n# H\n");
1392 let (fm, rest) = split_frontmatter(&tree);
1393 assert_eq!(fm.map(|b| b.kind), Some(BlockKind::Frontmatter));
1394 assert_eq!(rest.len(), 1);
1395 let tree = parse_markdown("# H\n");
1396 let (fm, rest) = split_frontmatter(&tree);
1397 assert!(fm.is_none());
1398 assert_eq!(rest.len(), 1);
1399 }
1400}