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
534#[must_use]
537pub fn persists_disposition(kind: &str) -> bool {
538 kind != "same"
539}
540
541fn count_kinds(result: &ReconcileResult) -> BTreeMap<String, u64> {
545 let mut counts = BTreeMap::new();
546 for d in &result.dispositions {
547 *counts.entry(d.kind.as_str().to_owned()).or_insert(0) += 1;
548 }
549 counts
550}
551
552fn disposition_rows(result: &ReconcileResult) -> Vec<DispositionRow> {
553 result
554 .dispositions
555 .iter()
556 .map(|d| DispositionRow {
557 block_id: d.block_id.clone(),
558 kind: d.kind.as_str().to_owned(),
559 confidence: d.confidence,
560 reason: d.reason.map(|r| r.as_str().to_owned()),
561 matcher_v: Some(d.matcher_v.clone()),
562 detail: detail_to_json(&d.detail).to_string(),
563 })
564 .collect()
565}
566
567pub(crate) fn ingest_in(
574 tx: &Connection,
575 mint: &mut Mint<'_>,
576 repo_id: &str,
577 plan: &IngestPlan<'_>,
578 expires: &str,
579 commit_id: &str,
580) -> Result<Committed> {
581 let minter = mint;
582 let tree = plan.tree;
583 let source = plan.source;
584 let (fm_block, _) = split_frontmatter(tree);
585
586 let fm_blob_hex = fm_block.map(|b| put_blob(tx, &b.raw)).transpose()?;
589 let fm_trivia = fm_block.map(|b| b.trivia.as_str());
590
591 let existing: Option<(String, Option<String>)> = tx
594 .query_row(
595 "SELECT doc_id, deleted_commit FROM docs WHERE repo_id = ?1 AND path = ?2",
596 params![repo_id, plan.path],
597 |r| Ok((r.get(0)?, r.get(1)?)),
598 )
599 .optional()?;
600 let doc_id = match existing {
601 Some((id, deleted_commit)) => {
602 tx.execute(
603 "UPDATE docs SET format = ?1, leading_trivia = ?2, frontmatter_trivia = ?3, deleted_commit = NULL WHERE doc_id = ?4",
604 params![FORMAT_MARKDOWN, tree.leading_trivia, fm_trivia, id],
605 )?;
606 if deleted_commit.is_some() {
610 adopt_phantoms(tx, plan.path, &id)?;
611 }
612 id
613 }
614 None => {
615 let id = minter.mint("d")?;
616 tx.execute(
617 "INSERT INTO docs (doc_id, repo_id, path, format, leading_trivia, frontmatter_trivia) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
618 params![id, repo_id, plan.path, FORMAT_MARKDOWN, tree.leading_trivia, fm_trivia],
619 )?;
620 adopt_phantoms(tx, plan.path, &id)?;
624 id
625 }
626 };
627
628 let assigned = &plan.assigned;
630 let root_tree_hex = write_block_tree(tx, assigned)?;
631
632 let rendered_hash = sha256(source.as_bytes());
634 let (rev_id, _) = write_revision(
635 tx,
636 minter,
637 &NewRevision {
638 doc_id: &doc_id,
639 root_tree_hex: &root_tree_hex,
640 frontmatter_blob_hex: fm_blob_hex.as_deref(),
641 rendered_hash,
642 path: plan.path,
643 commit_id,
644 },
645 )?;
646
647 {
649 let mut pool = tx.prepare(
650 "INSERT OR REPLACE INTO resurrection_pool (block_id, repo_id, doc_id, raw_hash, norm_hash, type, deleted_commit, expires_ts)
651 SELECT block_id, repo_id, doc_id, raw_hash, norm_hash, type, ?1, ?2 FROM blocks WHERE block_id = ?3 AND doc_id = ?4",
652 )?;
653 for id in &plan.deleted {
654 pool.execute(params![commit_id, expires, id, doc_id])?;
655 }
656 }
657
658 let incoming: Vec<String> = plan
660 .cross_doc_ids
661 .iter()
662 .chain(plan.consumed_pool.iter())
663 .cloned()
664 .collect();
665 if !incoming.is_empty() {
666 evict_foreign_block_rows(tx, &doc_id, &incoming)?;
667 }
668
669 fts_delete_doc(tx, &doc_id)?;
671 tx.execute("DELETE FROM blocks WHERE doc_id = ?1", params![doc_id])?;
672 let mut rows = Vec::new();
673 flatten_rows(assigned, None, 0, "/", &mut rows);
674 {
675 let mut insert = tx.prepare(
676 "INSERT INTO blocks
677 (block_id, repo_id, doc_id, parent_block, order_key, ordinal, depth,
678 ancestor_path, type, attrs, text, raw_hash, norm_hash, trivia_hash, created_commit)
679 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
680 )?;
681 for r in &rows {
682 insert.execute(params![
683 r.block_id,
684 repo_id,
685 doc_id,
686 r.parent_block,
687 r.order_key,
688 r.ordinal,
689 r.depth,
690 r.ancestor_path,
691 r.kind,
692 r.attrs,
693 r.text,
694 &r.raw_hash[..],
695 &r.norm_hash[..],
696 r.trivia_hash.as_ref().map(|h| &h[..]),
697 commit_id,
698 ])?;
699 }
700 }
701 fts_index_doc(tx, &doc_id)?;
702 rebuild_sections(tx, &doc_id)?;
703
704 let body = doc_blocks(assigned);
708 let mut nodes = project_nodes(&body);
709 nodes.extend(project_section_nodes(tx, &doc_id)?);
710 write_doc_nodes(tx, repo_id, &doc_id, &nodes)?;
711
712 let property_rows = doc_properties(&doc_id, fm_block, &body);
715 write_doc_properties(tx, repo_id, &doc_id, commit_id, &property_rows)?;
716
717 let mapping = fm_block.and_then(|b| parse_frontmatter(frontmatter_yaml(&b.raw)));
723 let descriptors = extract_doc_edges(&body, mapping.as_ref());
724 let resolved = resolve_edges(tx, minter, repo_id, &doc_id, plan.path, &descriptors)?;
725 maintain_edges(tx, minter, repo_id, &doc_id, commit_id, &resolved)?;
726
727 {
736 let mut ins = tx.prepare(
737 "INSERT OR IGNORE INTO dispositions (commit_id, block_id, kind, confidence, reason, matcher_v, detail)
738 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
739 )?;
740 let mut bc = tx.prepare(
741 "INSERT OR IGNORE INTO block_changes (block_id, commit_id, kind) VALUES (?1, ?2, ?3)",
742 )?;
743 for d in plan
744 .dispositions
745 .iter()
746 .filter(|d| persists_disposition(&d.kind))
747 {
748 ins.execute(params![
749 commit_id,
750 d.block_id,
751 d.kind,
752 d.confidence,
753 d.reason,
754 d.matcher_v,
755 d.detail,
756 ])?;
757 bc.execute(params![d.block_id, commit_id, d.kind])?;
758 }
759 }
760
761 for id in &plan.consumed_pool {
763 tx.execute(
764 "DELETE FROM resurrection_pool WHERE block_id = ?1",
765 params![id],
766 )?;
767 }
768
769 match plan.conflicted {
773 Some(conflicted) => tx.execute(
774 "UPDATE docs SET current_rev = ?1, file_hash = ?2, conflicted = ?3 WHERE doc_id = ?4",
775 params![rev_id, &rendered_hash[..], i64::from(conflicted), doc_id],
776 )?,
777 None => tx.execute(
778 "UPDATE docs SET current_rev = ?1, file_hash = ?2 WHERE doc_id = ?3",
779 params![rev_id, &rendered_hash[..], doc_id],
780 )?,
781 };
782
783 let converged = render(tree) == source && reconstruct(tx, &doc_id)?.as_deref() == Some(source);
786
787 Ok(Committed {
788 doc_id,
789 commit_id: commit_id.to_owned(),
790 rev_id,
791 converged,
792 })
793}
794
795pub(crate) fn tombstone_in(
800 tx: &Connection,
801 doc_id: &str,
802 commit_id: &str,
803 expires: Option<&str>,
804) -> Result<()> {
805 if let Some(expires) = expires {
806 tx.execute(
807 "INSERT OR REPLACE INTO resurrection_pool (block_id, repo_id, doc_id, raw_hash, norm_hash, type, deleted_commit, expires_ts)
808 SELECT block_id, repo_id, doc_id, raw_hash, norm_hash, type, ?1, ?2
809 FROM blocks WHERE doc_id = ?3 AND deleted_commit IS NULL",
810 params![commit_id, expires, doc_id],
811 )?;
812 }
813 fts_delete_doc(tx, doc_id)?;
814 tx.execute(
815 "UPDATE blocks SET deleted_commit = ?1 WHERE doc_id = ?2 AND deleted_commit IS NULL",
816 params![commit_id, doc_id],
817 )?;
818 tx.execute(
819 "UPDATE docs SET deleted_commit = ?1 WHERE doc_id = ?2",
820 params![commit_id, doc_id],
821 )?;
822 Ok(())
823}
824
825impl Store {
826 pub fn observe_batch(
830 &mut self,
831 repo_id: &str,
832 items: &[BatchItem],
833 ts: &str,
834 config: &Config,
835 ) -> Result<Vec<BatchOutcome>> {
836 let expires = pool_expiry(ts)?;
837
838 let pool = load_pool(&self.conn, repo_id, ts)?;
840 let mut consumed: HashSet<String> = HashSet::new();
841 let mut pending: Vec<Pending> = Vec::with_capacity(items.len());
842 for it in items {
843 let existing: Option<(String, Option<Vec<u8>>)> = self
844 .conn
845 .query_row(
846 "SELECT doc_id, file_hash FROM docs WHERE repo_id = ?1 AND path = ?2 AND deleted_commit IS NULL",
847 params![repo_id, it.path],
848 |r| Ok((r.get(0)?, r.get(1)?)),
849 )
850 .optional()?;
851 let old_hash_hex = existing.as_ref().and_then(|(_, h)| h.as_deref()).map(hex);
852 let intent = pending_at(&self.conn, repo_id, &it.path)?;
854 let Some(source) = &it.source else {
855 match &intent {
856 Some(row) if row.kind != PendingKind::Remove => {
859 pending.push(Pending::Gone {
860 path: it.path.clone(),
861 doc_id: None,
862 old_hash_hex: None,
863 old_blocks: Vec::new(),
864 });
865 continue;
866 }
867 Some(_) => delete_pending(&self.conn, repo_id, &it.path)?,
869 None => {}
870 }
871 let (doc_id, old_blocks) = match &existing {
872 Some((id, _)) => (Some(id.clone()), load_old_match_blocks(&self.conn, id)?),
873 None => (None, Vec::new()),
874 };
875 pending.push(Pending::Gone {
876 path: it.path.clone(),
877 doc_id,
878 old_hash_hex,
879 old_blocks,
880 });
881 continue;
882 };
883 let hash = sha256(source.as_bytes());
884 let new_hash_hex = hex(&hash);
885 let echo = |doc_id: &str| {
886 Pending::Echo(ObserveOutcome {
887 path: it.path.clone(),
888 doc_id: doc_id.to_owned(),
889 rev: None,
890 commit_id: None,
891 converged: true,
892 echo: true,
893 conflicted: false,
894 dispositions: BTreeMap::new(),
895 old_hash_hex: old_hash_hex.clone(),
896 new_hash_hex: new_hash_hex.clone(),
897 })
898 };
899 if let Some(row) = &intent {
900 if row.rendered_hash[..] == hash[..] {
901 if row.kind != PendingKind::Remove {
906 delete_pending(&self.conn, repo_id, &it.path)?;
907 }
908 let doc_id = existing
909 .as_ref()
910 .map_or(row.doc_id.as_str(), |(id, _)| id.as_str());
911 pending.push(echo(doc_id));
912 continue;
913 }
914 if let Some((doc_id, Some(stored))) = &existing {
919 if stored[..] == hash[..] {
920 delete_pending(&self.conn, repo_id, &it.path)?;
921 pending.push(echo(doc_id));
922 continue;
923 }
924 }
925 } else if let Some((doc_id, Some(stored))) = &existing {
926 if stored[..] == hash[..] {
927 pending.push(echo(doc_id));
928 continue;
929 }
930 }
931 let clear_pending = intent.is_some();
932 let prepared = prepare_reconcile(
933 &self.conn,
934 &mut self.ids.at(&self.conn),
935 repo_id,
936 &it.path,
937 source,
938 config,
939 &pool,
940 &mut consumed,
941 )?;
942 pending.push(Pending::Ingest {
943 prepared: Box::new(prepared),
944 old_hash_hex,
945 new_hash_hex,
946 clear_pending,
947 });
948 }
949
950 if pending.len() > 1 {
952 cross_doc_phase(&mut pending, config);
953 }
954
955 let mut out = Vec::with_capacity(pending.len());
957 for p in pending {
958 match p {
959 Pending::Echo(outcome) => out.push(BatchOutcome::Observed(outcome)),
960 Pending::Gone {
961 path,
962 doc_id,
963 old_hash_hex,
964 ..
965 } => {
966 if let Some(id) = &doc_id {
967 self.tombstone_observed_deletion(repo_id, id, ts, &expires)?;
968 }
969 out.push(BatchOutcome::Deleted(DeleteOutcome {
970 path,
971 doc_id,
972 old_hash_hex,
973 }));
974 }
975 Pending::Ingest {
976 prepared,
977 old_hash_hex,
978 new_hash_hex,
979 clear_pending,
980 } => {
981 let conflicted = has_conflict_markers(&prepared.source);
984 let c = self.commit_prepared(
985 repo_id,
986 &prepared,
987 ts,
988 &expires,
989 conflicted,
990 clear_pending,
991 )?;
992 let dispositions = count_kinds(&prepared.result);
999 out.push(BatchOutcome::Observed(ObserveOutcome {
1000 path: prepared.path,
1001 doc_id: c.doc_id,
1002 rev: Some(c.rev_id),
1003 commit_id: Some(c.commit_id),
1004 converged: c.converged,
1005 echo: false,
1006 conflicted,
1007 dispositions,
1008 old_hash_hex,
1009 new_hash_hex,
1010 }));
1011 }
1012 }
1013 }
1014 Ok(out)
1015 }
1016
1017 fn commit_prepared(
1022 &mut self,
1023 repo_id: &str,
1024 prepared: &Prepared,
1025 ts: &str,
1026 expires: &str,
1027 conflicted: bool,
1028 clear_pending: bool,
1029 ) -> Result<Committed> {
1030 let plan = prepared.plan(
1031 &mut self.ids.at(&self.conn),
1032 Origin::Observed,
1033 None,
1034 None,
1035 Some(conflicted),
1036 )?;
1037 self.commit_ingest(
1038 repo_id,
1039 &plan,
1040 ts,
1041 expires,
1042 clear_pending.then_some(prepared.path.as_str()),
1043 )
1044 }
1045
1046 pub(crate) fn prepare_single(
1051 &mut self,
1052 repo_id: &str,
1053 path: &str,
1054 source: &str,
1055 ts: &str,
1056 config: &Config,
1057 ) -> Result<Prepared> {
1058 let pool = load_pool(&self.conn, repo_id, ts)?;
1059 let mut consumed = HashSet::new();
1060 prepare_reconcile(
1061 &self.conn,
1062 &mut self.ids.at(&self.conn),
1063 repo_id,
1064 path,
1065 source,
1066 config,
1067 &pool,
1068 &mut consumed,
1069 )
1070 }
1071
1072 #[allow(clippy::too_many_arguments)]
1081 pub fn reconciling_ingest(
1082 &mut self,
1083 repo_id: &str,
1084 path: &str,
1085 source: &str,
1086 ts: &str,
1087 origin: Origin,
1088 actor: Option<&str>,
1089 reason: Option<&str>,
1090 config: &Config,
1091 ) -> Result<Committed> {
1092 let head = CommitHead {
1093 origin,
1094 actor,
1095 reason,
1096 clear_pending: false,
1097 ops: None,
1098 };
1099 self.reconciling_commit(repo_id, path, source, ts, &head, config, Guard::None)?
1100 .committed()?
1101 .pop()
1102 .ok_or_else(|| {
1103 Error::Other(format!("reconciling_ingest: nothing committed for {path}"))
1104 })
1105 }
1106
1107 #[allow(clippy::too_many_arguments)]
1113 pub(crate) fn reconciling_commit(
1114 &mut self,
1115 repo_id: &str,
1116 path: &str,
1117 source: &str,
1118 ts: &str,
1119 head: &CommitHead<'_>,
1120 config: &Config,
1121 guard: Guard,
1122 ) -> Result<Outcome> {
1123 let prepared = self.prepare_single(repo_id, path, source, ts, config)?;
1124 self.commit_effects(
1125 repo_id,
1126 head,
1127 &[Effect::IngestReconciled {
1128 prepared: Box::new(prepared),
1129 guard,
1130 }],
1131 ts,
1132 )
1133 }
1134
1135 pub fn fresh_ingest(
1140 &mut self,
1141 repo_id: &str,
1142 path: &str,
1143 source: &str,
1144 ts: &str,
1145 origin: Origin,
1146 ) -> Result<Committed> {
1147 let expires = pool_expiry(ts)?;
1148 let tree = parse_markdown(source);
1149 let (_, rest) = split_frontmatter(&tree);
1150 let assigned = assign_fresh_ids(rest, &mut self.ids.at(&self.conn))?;
1151 let plan = IngestPlan {
1152 path,
1153 source,
1154 tree: &tree,
1155 assigned,
1156 dispositions: Vec::new(),
1157 deleted: Vec::new(),
1158 consumed_pool: Vec::new(),
1159 cross_doc_ids: Vec::new(),
1160 origin,
1161 actor: None,
1162 reason: None,
1163 conflicted: None,
1164 };
1165 self.commit_ingest(repo_id, &plan, ts, &expires, None)
1166 }
1167
1168 pub(crate) fn commit_ingest(
1175 &mut self,
1176 repo_id: &str,
1177 plan: &IngestPlan<'_>,
1178 ts: &str,
1179 expires: &str,
1180 clear_pending_at: Option<&str>,
1181 ) -> Result<Committed> {
1182 let tx = self.conn.unchecked_transaction()?;
1183 let committed = {
1184 let mut mint = self.ids.at(&tx);
1185 let (commit_id, _) = new_commit(
1187 &tx,
1188 &mut mint,
1189 &NewCommit {
1190 repo_id,
1191 ts,
1192 origin: plan.origin,
1193 actor: plan.actor,
1194 reason: plan.reason,
1195 checkpoint_id: None,
1196 ops: None,
1197 },
1198 )?;
1199 let committed = ingest_in(&tx, &mut mint, repo_id, plan, expires, &commit_id)?;
1200 if let Some(path) = clear_pending_at {
1201 delete_pending(&tx, repo_id, path)?;
1202 }
1203 committed
1204 };
1205 tx.commit()?;
1206 Ok(committed)
1207 }
1208
1209 pub(crate) fn tombstone_observed_deletion(
1213 &mut self,
1214 repo_id: &str,
1215 doc_id: &str,
1216 ts: &str,
1217 expires: &str,
1218 ) -> Result<String> {
1219 let tx = self.conn.unchecked_transaction()?;
1220 let commit_id = {
1221 let mut mint = self.ids.at(&tx);
1222 let (commit_id, _) = new_commit(
1223 &tx,
1224 &mut mint,
1225 &NewCommit {
1226 reason: Some("observed deletion"),
1227 ..NewCommit::observed(repo_id, ts)
1228 },
1229 )?;
1230 tombstone_in(&tx, doc_id, &commit_id, Some(expires))?;
1231 commit_id
1232 };
1233 tx.commit()?;
1234 Ok(commit_id)
1235 }
1236
1237 pub fn observe_one(
1240 &mut self,
1241 repo_id: &str,
1242 path: &str,
1243 source: &str,
1244 ts: &str,
1245 config: &Config,
1246 ) -> Result<ObserveOutcome> {
1247 let mut out =
1248 self.observe_batch(repo_id, &[BatchItem::observed(path, source)], ts, config)?;
1249 match out.pop() {
1250 Some(BatchOutcome::Observed(o)) => Ok(o),
1251 _ => Err(Error::Other(format!(
1252 "observe_one: unexpected outcome for {path}"
1253 ))),
1254 }
1255 }
1256
1257 pub fn observe_delete(&mut self, repo_id: &str, path: &str, ts: &str) -> Result<DeleteOutcome> {
1263 let expires = pool_expiry(ts)?;
1264 match pending_at(&self.conn, repo_id, path)? {
1265 Some(row) if row.kind != PendingKind::Remove => {
1266 return Ok(DeleteOutcome {
1267 path: path.to_owned(),
1268 doc_id: None,
1269 old_hash_hex: None,
1270 });
1271 }
1272 Some(_) => delete_pending(&self.conn, repo_id, path)?,
1273 None => {}
1274 }
1275 let existing: Option<(String, Option<Vec<u8>>)> = self
1276 .conn
1277 .query_row(
1278 "SELECT doc_id, file_hash FROM docs WHERE repo_id = ?1 AND path = ?2 AND deleted_commit IS NULL",
1279 params![repo_id, path],
1280 |r| Ok((r.get(0)?, r.get(1)?)),
1281 )
1282 .optional()?;
1283 let Some((doc_id, file_hash)) = existing else {
1284 return Ok(DeleteOutcome {
1285 path: path.to_owned(),
1286 doc_id: None,
1287 old_hash_hex: None,
1288 });
1289 };
1290 self.tombstone_observed_deletion(repo_id, &doc_id, ts, &expires)?;
1291 sweep_pool(&self.conn, ts)?;
1292 Ok(DeleteOutcome {
1293 path: path.to_owned(),
1294 doc_id: Some(doc_id),
1295 old_hash_hex: file_hash.as_deref().map(hex),
1296 })
1297 }
1298}
1299
1300#[cfg(test)]
1301mod tests {
1302 use super::*;
1303
1304 #[test]
1305 fn conflict_markers_need_both_sides_at_line_starts() {
1306 assert!(has_conflict_markers(
1307 "<<<<<<< HEAD\na\n=======\nb\n>>>>>>> branch\n"
1308 ));
1309 assert!(!has_conflict_markers("<<<<<<< HEAD\na\n=======\nb\n"));
1310 assert!(!has_conflict_markers("a <<<<<<< b\n>>>>>>> c\n"));
1311 assert!(has_conflict_markers(">>>>>>> c\r<<<<<<< b"));
1312 assert!(has_conflict_markers("x\u{2028}<<<<<<< a\u{2029}>>>>>>> b"));
1313 assert!(!has_conflict_markers(""));
1314 }
1315
1316 #[test]
1317 fn frontmatter_is_split_only_when_first() {
1318 let tree = parse_markdown("---\na: 1\n---\n\n# H\n");
1319 let (fm, rest) = split_frontmatter(&tree);
1320 assert_eq!(fm.map(|b| b.kind), Some(BlockKind::Frontmatter));
1321 assert_eq!(rest.len(), 1);
1322 let tree = parse_markdown("# H\n");
1323 let (fm, rest) = split_frontmatter(&tree);
1324 assert!(fm.is_none());
1325 assert_eq!(rest.len(), 1);
1326 }
1327}