1use std::collections::{BTreeMap, HashSet};
7
8use omgbase_format::hash::{hex, sha256};
9use omgbase_format::{Block, BlockKind, BlockTree, parse_markdown, render};
10use omgbase_graph::{extract_doc_edges, project_nodes};
11use omgbase_properties::{doc_properties, frontmatter_yaml, parse_frontmatter};
12use omgbase_reconcile::json::detail_to_json;
13use omgbase_reconcile::{
14 Config, DispositionKind, FlatSource, Inserted, MatchBlock, Options, PerDocUnmatched, PoolEntry,
15 ReconcileResult, apply_cross_doc_matches, cross_doc_match, flatten, reconcile_document,
16};
17use rusqlite::{Connection, OptionalExtension, params};
18
19use crate::derived::{
20 EvictRow, fts_before_evict_rows, fts_delete_doc, fts_index_doc, rebuild_sections, sweep_pool,
21};
22use crate::error::{Error, Result};
23use crate::graph::{
24 adopt_phantoms, maintain_edges, project_section_nodes, resolve_edges, write_doc_nodes,
25};
26use crate::ids::IdMinter;
27use crate::order_key::key_between;
28use crate::properties::{doc_blocks, write_doc_properties};
29use crate::read::{load_old_match_blocks, load_pool, reconstruct};
30use crate::time::pool_expiry;
31use crate::tree::canonical_attrs;
32use crate::writers::{
33 NewCommit, NewRevision, Origin, TreeInputBlock, assign_fresh_ids, assign_from_map, new_commit,
34 put_blob, write_block_tree, write_revision,
35};
36use crate::{FORMAT_MARKDOWN, Store};
37
38#[derive(Clone, Debug, PartialEq, Eq)]
41pub struct BatchItem {
42 pub path: String,
43 pub source: Option<String>,
44}
45
46impl BatchItem {
47 #[must_use]
49 pub fn observed(path: &str, source: &str) -> Self {
50 Self {
51 path: path.to_owned(),
52 source: Some(source.to_owned()),
53 }
54 }
55
56 #[must_use]
58 pub fn gone(path: &str) -> Self {
59 Self {
60 path: path.to_owned(),
61 source: None,
62 }
63 }
64}
65
66#[derive(Clone, Debug, PartialEq, Eq)]
68pub struct ObserveOutcome {
69 pub path: String,
70 pub doc_id: String,
71 pub rev: Option<String>,
73 pub commit_id: Option<String>,
75 pub converged: bool,
77 pub echo: bool,
79 pub conflicted: bool,
81 pub dispositions: BTreeMap<String, u64>,
83 pub old_hash_hex: Option<String>,
85 pub new_hash_hex: String,
87}
88
89#[derive(Clone, Debug, PartialEq, Eq)]
91pub struct DeleteOutcome {
92 pub path: String,
93 pub doc_id: Option<String>,
95 pub old_hash_hex: Option<String>,
96}
97
98impl DeleteOutcome {
99 #[must_use]
101 pub fn deleted(&self) -> bool {
102 self.doc_id.is_some()
103 }
104}
105
106#[derive(Clone, Debug, PartialEq, Eq)]
108pub enum BatchOutcome {
109 Observed(ObserveOutcome),
110 Deleted(DeleteOutcome),
111}
112
113impl BatchOutcome {
114 #[must_use]
115 pub fn path(&self) -> &str {
116 match self {
117 BatchOutcome::Observed(o) => &o.path,
118 BatchOutcome::Deleted(d) => &d.path,
119 }
120 }
121
122 #[must_use]
123 pub fn as_observed(&self) -> Option<&ObserveOutcome> {
124 match self {
125 BatchOutcome::Observed(o) => Some(o),
126 BatchOutcome::Deleted(_) => None,
127 }
128 }
129
130 #[must_use]
131 pub fn as_deleted(&self) -> Option<&DeleteOutcome> {
132 match self {
133 BatchOutcome::Deleted(d) => Some(d),
134 BatchOutcome::Observed(_) => None,
135 }
136 }
137}
138
139#[must_use]
143pub fn has_conflict_markers(source: &str) -> bool {
144 let starts = |marker: &str| {
145 source
146 .split(['\n', '\r', '\u{2028}', '\u{2029}'])
147 .any(|line| line.starts_with(marker))
148 };
149 starts("<<<<<<<") && starts(">>>>>>>")
150}
151
152struct Prepared {
154 path: String,
155 source: String,
156 doc_id: Option<String>,
158 tree: BlockTree,
159 old_blocks: Vec<MatchBlock>,
160 new_blocks: Vec<MatchBlock>,
161 result: ReconcileResult,
162 cross_doc_ids: Vec<String>,
164}
165
166enum Pending {
167 Echo(ObserveOutcome),
168 Gone {
169 path: String,
170 doc_id: Option<String>,
171 old_hash_hex: Option<String>,
172 old_blocks: Vec<MatchBlock>,
173 },
174 Ingest {
175 prepared: Prepared,
176 old_hash_hex: Option<String>,
177 new_hash_hex: String,
178 },
179}
180
181fn split_frontmatter(tree: &BlockTree) -> (Option<&Block>, &[Block]) {
183 match tree.children.first() {
184 Some(b) if b.kind == BlockKind::Frontmatter => (Some(b), &tree.children[1..]),
185 _ => (None, &tree.children[..]),
186 }
187}
188
189#[allow(clippy::too_many_arguments)]
192fn prepare_reconcile(
193 conn: &Connection,
194 minter: &mut dyn IdMinter,
195 repo_id: &str,
196 path: &str,
197 source: &str,
198 config: &Config,
199 pool: &[PoolEntry],
200 consumed: &mut HashSet<String>,
201) -> Result<Prepared> {
202 let doc_id: Option<String> = conn
203 .query_row(
204 "SELECT doc_id FROM docs WHERE repo_id = ?1 AND path = ?2",
205 params![repo_id, path],
206 |r| r.get(0),
207 )
208 .optional()?;
209 let tree = parse_markdown(source);
210 let old_blocks = match &doc_id {
211 Some(id) => load_old_match_blocks(conn, id)?,
212 None => Vec::new(),
213 };
214 let new_blocks = flatten(&FlatSource::from_tree(&tree, None));
215 let offered: Vec<PoolEntry> = if doc_id.is_some() {
216 pool.iter()
217 .filter(|c| !consumed.contains(&c.id))
218 .cloned()
219 .collect()
220 } else {
221 Vec::new()
222 };
223 let mut mint = || minter.mint("b");
224 let result = reconcile_document(
225 &old_blocks,
226 &new_blocks,
227 Options {
228 config,
229 pool: &offered,
230 minter: &mut mint,
231 },
232 );
233 consumed.extend(result.consumed_pool.iter().cloned());
234 Ok(Prepared {
235 path: path.to_owned(),
236 source: source.to_owned(),
237 doc_id,
238 tree,
239 old_blocks,
240 new_blocks,
241 result,
242 cross_doc_ids: Vec::new(),
243 })
244}
245
246fn cross_doc_phase(pending: &mut [Pending], config: &Config) {
248 let mut docs: Vec<PerDocUnmatched> = Vec::new();
249 let mut results: BTreeMap<String, ReconcileResult> = BTreeMap::new();
250 let mut key_of: Vec<Option<String>> = vec![None; pending.len()];
251 for (i, p) in pending.iter_mut().enumerate() {
252 match p {
253 Pending::Echo(_) | Pending::Gone { doc_id: None, .. } => {}
254 Pending::Gone {
255 doc_id: Some(doc_id),
256 old_blocks,
257 ..
258 } => {
259 docs.push(PerDocUnmatched {
260 doc_id: doc_id.clone(),
261 deleted: old_blocks.clone(),
262 inserted: Vec::new(),
263 });
264 results.insert(
265 doc_id.clone(),
266 ReconcileResult {
267 deleted: old_blocks.iter().filter_map(|b| b.id.clone()).collect(),
268 ..ReconcileResult::default()
269 },
270 );
271 }
272 Pending::Ingest { prepared, .. } => {
273 let key = prepared
274 .doc_id
275 .clone()
276 .unwrap_or_else(|| format!("new:{}", prepared.path));
277 let key_of_id: BTreeMap<&str, &str> = prepared
278 .result
279 .assignment
280 .iter()
281 .map(|(k, id)| (id.as_str(), k.as_str()))
282 .collect();
283 let deleted = prepared
284 .result
285 .deleted
286 .iter()
287 .filter_map(|id| {
288 prepared
289 .old_blocks
290 .iter()
291 .find(|b| b.id.as_deref() == Some(id))
292 })
293 .cloned()
294 .collect();
295 let inserted = prepared
296 .result
297 .dispositions
298 .iter()
299 .filter(|d| d.kind == DispositionKind::Inserted)
300 .filter_map(|d| {
301 let k = key_of_id.get(d.block_id.as_str())?;
302 let block = prepared.new_blocks.iter().find(|b| &b.key == k)?;
303 Some(Inserted {
304 block: block.clone(),
305 minted_id: d.block_id.clone(),
306 })
307 })
308 .collect();
309 docs.push(PerDocUnmatched {
310 doc_id: key.clone(),
311 deleted,
312 inserted,
313 });
314 results.insert(key.clone(), std::mem::take(&mut prepared.result));
315 key_of[i] = Some(key);
316 }
317 }
318 }
319 let matches = if docs.len() < 2 {
320 Vec::new()
321 } else {
322 cross_doc_match(&docs, config)
323 };
324 if !matches.is_empty() {
325 apply_cross_doc_matches(&mut results, &matches, &config.matcher_v);
326 }
327 for (i, p) in pending.iter_mut().enumerate() {
328 if let (Pending::Ingest { prepared, .. }, Some(key)) = (p, &key_of[i]) {
329 prepared.result = results
330 .remove(key)
331 .expect("every prepared member was keyed");
332 for m in &matches {
333 if &m.to_doc == key {
334 prepared.cross_doc_ids.push(m.carried_id.clone());
335 }
336 }
337 }
338 }
339}
340
341struct BlockRow {
342 block_id: String,
343 parent_block: Option<String>,
344 order_key: String,
345 ordinal: i64,
346 depth: i64,
347 ancestor_path: String,
348 kind: String,
349 attrs: String,
350 text: String,
351 raw_hash: [u8; 32],
352 norm_hash: [u8; 32],
353 trivia_hash: Option<[u8; 32]>,
354}
355
356fn flatten_rows(
358 blocks: &[TreeInputBlock],
359 parent: Option<&str>,
360 depth: i64,
361 ancestor_path: &str,
362 out: &mut Vec<BlockRow>,
363) {
364 let mut prev_key: Option<String> = None;
365 for (ordinal, b) in blocks.iter().enumerate() {
366 let order_key = key_between(prev_key.as_deref(), None);
367 prev_key = Some(order_key.clone());
368 out.push(BlockRow {
369 block_id: b.block_id.clone(),
370 parent_block: parent.map(str::to_owned),
371 order_key,
372 ordinal: ordinal as i64,
373 depth,
374 ancestor_path: ancestor_path.to_owned(),
375 kind: b.kind.clone(),
376 attrs: canonical_attrs(&b.attrs),
377 text: b.text.clone(),
378 raw_hash: sha256(b.raw.as_bytes()),
379 norm_hash: sha256(b.text.as_bytes()),
380 trivia_hash: (!b.trivia.is_empty()).then(|| sha256(b.trivia.as_bytes())),
381 });
382 if !b.children.is_empty() {
383 flatten_rows(
384 &b.children,
385 Some(&b.block_id),
386 depth + 1,
387 &format!("{ancestor_path}{}/", b.block_id),
388 out,
389 );
390 }
391 }
392}
393
394fn evict_foreign_block_rows(conn: &Connection, doc_id: &str, ids: &[String]) -> Result<()> {
399 let mut rows = Vec::new();
400 for id in ids {
401 let row: Option<EvictRow> = conn
402 .query_row(
403 "SELECT rowid, block_id, doc_id, parent_block, text, deleted_commit IS NULL
404 FROM blocks WHERE block_id = ?1 AND doc_id != ?2",
405 params![id, doc_id],
406 |r| {
407 Ok(EvictRow {
408 rowid: r.get(0)?,
409 block_id: r.get(1)?,
410 doc_id: r.get(2)?,
411 parent_block: r.get(3)?,
412 text: r.get(4)?,
413 live: r.get(5)?,
414 })
415 },
416 )
417 .optional()?;
418 if let Some(row) = row {
419 rows.push(row);
420 }
421 }
422 fts_before_evict_rows(conn, &rows)?;
423 for r in &rows {
424 conn.execute(
425 "DELETE FROM blocks WHERE block_id = ?1 AND doc_id != ?2",
426 params![r.block_id, doc_id],
427 )?;
428 }
429 for id in ids {
430 conn.execute(
431 "DELETE FROM resurrection_pool WHERE block_id = ?1",
432 params![id],
433 )?;
434 }
435 Ok(())
436}
437
438#[derive(Clone, Debug, PartialEq, Eq)]
441pub struct Committed {
442 pub doc_id: String,
443 pub commit_id: String,
444 pub rev_id: String,
445 pub converged: bool,
446}
447
448#[derive(Clone, Debug, PartialEq)]
451pub(crate) struct DispositionRow {
452 pub block_id: String,
453 pub kind: String,
454 pub confidence: Option<f64>,
455 pub reason: Option<String>,
456 pub matcher_v: Option<String>,
457 pub detail: String,
459}
460
461pub(crate) struct IngestPlan<'a> {
464 pub path: &'a str,
465 pub source: &'a str,
466 pub tree: &'a BlockTree,
467 pub assigned: Vec<TreeInputBlock>,
468 pub dispositions: Vec<DispositionRow>,
469 pub deleted: Vec<String>,
471 pub consumed_pool: Vec<String>,
472 pub cross_doc_ids: Vec<String>,
473 pub origin: Origin,
474 pub actor: Option<&'a str>,
475 pub reason: Option<&'a str>,
476}
477
478fn disposition_rows(result: &ReconcileResult) -> Vec<DispositionRow> {
479 result
480 .dispositions
481 .iter()
482 .map(|d| DispositionRow {
483 block_id: d.block_id.clone(),
484 kind: d.kind.as_str().to_owned(),
485 confidence: d.confidence,
486 reason: d.reason.map(|r| r.as_str().to_owned()),
487 matcher_v: Some(d.matcher_v.clone()),
488 detail: detail_to_json(&d.detail).to_string(),
489 })
490 .collect()
491}
492
493impl Store {
494 pub fn observe_batch(
498 &mut self,
499 repo_id: &str,
500 items: &[BatchItem],
501 ts: &str,
502 config: &Config,
503 ) -> Result<Vec<BatchOutcome>> {
504 let expires = pool_expiry(ts)?;
505
506 let pool = load_pool(&self.conn, repo_id, ts)?;
508 let mut consumed: HashSet<String> = HashSet::new();
509 let mut pending: Vec<Pending> = Vec::with_capacity(items.len());
510 for it in items {
511 let existing: Option<(String, Option<Vec<u8>>)> = self
512 .conn
513 .query_row(
514 "SELECT doc_id, file_hash FROM docs WHERE repo_id = ?1 AND path = ?2 AND deleted_commit IS NULL",
515 params![repo_id, it.path],
516 |r| Ok((r.get(0)?, r.get(1)?)),
517 )
518 .optional()?;
519 let old_hash_hex = existing.as_ref().and_then(|(_, h)| h.as_deref()).map(hex);
520 let Some(source) = &it.source else {
521 let (doc_id, old_blocks) = match &existing {
522 Some((id, _)) => (Some(id.clone()), load_old_match_blocks(&self.conn, id)?),
523 None => (None, Vec::new()),
524 };
525 pending.push(Pending::Gone {
526 path: it.path.clone(),
527 doc_id,
528 old_hash_hex,
529 old_blocks,
530 });
531 continue;
532 };
533 let hash = sha256(source.as_bytes());
534 let new_hash_hex = hex(&hash);
535 if let Some((doc_id, Some(stored))) = &existing {
536 if stored[..] == hash[..] {
537 pending.push(Pending::Echo(ObserveOutcome {
538 path: it.path.clone(),
539 doc_id: doc_id.clone(),
540 rev: None,
541 commit_id: None,
542 converged: true,
543 echo: true,
544 conflicted: false,
545 dispositions: BTreeMap::new(),
546 old_hash_hex,
547 new_hash_hex,
548 }));
549 continue;
550 }
551 }
552 let prepared = prepare_reconcile(
553 &self.conn,
554 &mut *self.minter,
555 repo_id,
556 &it.path,
557 source,
558 config,
559 &pool,
560 &mut consumed,
561 )?;
562 pending.push(Pending::Ingest {
563 prepared,
564 old_hash_hex,
565 new_hash_hex,
566 });
567 }
568
569 if pending.len() > 1 {
571 cross_doc_phase(&mut pending, config);
572 }
573
574 let mut out = Vec::with_capacity(pending.len());
576 for p in pending {
577 match p {
578 Pending::Echo(outcome) => out.push(BatchOutcome::Observed(outcome)),
579 Pending::Gone {
580 path,
581 doc_id,
582 old_hash_hex,
583 ..
584 } => {
585 if let Some(id) = &doc_id {
586 self.tombstone_observed_deletion(repo_id, id, ts, &expires)?;
587 }
588 out.push(BatchOutcome::Deleted(DeleteOutcome {
589 path,
590 doc_id,
591 old_hash_hex,
592 }));
593 }
594 Pending::Ingest {
595 prepared,
596 old_hash_hex,
597 new_hash_hex,
598 } => {
599 let conflicted = has_conflict_markers(&prepared.source);
600 let c = self.commit_prepared(repo_id, &prepared, ts, &expires)?;
601 self.conn.execute(
602 "UPDATE docs SET conflicted = ?1 WHERE repo_id = ?2 AND path = ?3",
603 params![i64::from(conflicted), repo_id, prepared.path],
604 )?;
605 let dispositions = {
606 let mut stmt = self.conn.prepare(
607 "SELECT kind, count(*) FROM dispositions WHERE commit_id = ?1 GROUP BY kind ORDER BY kind",
608 )?;
609 let rows = stmt.query_map(params![c.commit_id], |r| {
610 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)? as u64))
611 })?;
612 rows.collect::<std::result::Result<BTreeMap<_, _>, _>>()?
613 };
614 out.push(BatchOutcome::Observed(ObserveOutcome {
615 path: prepared.path,
616 doc_id: c.doc_id,
617 rev: Some(c.rev_id),
618 commit_id: Some(c.commit_id),
619 converged: c.converged,
620 echo: false,
621 conflicted,
622 dispositions,
623 old_hash_hex,
624 new_hash_hex,
625 }));
626 }
627 }
628 }
629 Ok(out)
630 }
631
632 fn commit_prepared(
635 &mut self,
636 repo_id: &str,
637 prepared: &Prepared,
638 ts: &str,
639 expires: &str,
640 ) -> Result<Committed> {
641 let (_, rest) = split_frontmatter(&prepared.tree);
642 let assigned = assign_from_map(rest, &prepared.result.assignment, &mut *self.minter);
644 let plan = IngestPlan {
645 path: &prepared.path,
646 source: &prepared.source,
647 tree: &prepared.tree,
648 assigned,
649 dispositions: disposition_rows(&prepared.result),
650 deleted: prepared.result.deleted.clone(),
651 consumed_pool: prepared.result.consumed_pool.clone(),
652 cross_doc_ids: prepared.cross_doc_ids.clone(),
653 origin: Origin::Observed,
654 actor: None,
655 reason: None,
656 };
657 self.commit_ingest(repo_id, &plan, ts, expires)
658 }
659
660 #[allow(clippy::too_many_arguments)]
669 pub fn reconciling_ingest(
670 &mut self,
671 repo_id: &str,
672 path: &str,
673 source: &str,
674 ts: &str,
675 origin: Origin,
676 actor: Option<&str>,
677 reason: Option<&str>,
678 config: &Config,
679 ) -> Result<Committed> {
680 let expires = pool_expiry(ts)?;
681 let pool = load_pool(&self.conn, repo_id, ts)?;
682 let mut consumed = HashSet::new();
683 let prepared = prepare_reconcile(
684 &self.conn,
685 &mut *self.minter,
686 repo_id,
687 path,
688 source,
689 config,
690 &pool,
691 &mut consumed,
692 )?;
693 let (_, rest) = split_frontmatter(&prepared.tree);
694 let assigned = assign_from_map(rest, &prepared.result.assignment, &mut *self.minter);
695 let plan = IngestPlan {
696 path,
697 source,
698 tree: &prepared.tree,
699 assigned,
700 dispositions: disposition_rows(&prepared.result),
701 deleted: prepared.result.deleted.clone(),
702 consumed_pool: prepared.result.consumed_pool.clone(),
703 cross_doc_ids: Vec::new(),
704 origin,
705 actor,
706 reason,
707 };
708 self.commit_ingest(repo_id, &plan, ts, &expires)
709 }
710
711 pub fn fresh_ingest(
716 &mut self,
717 repo_id: &str,
718 path: &str,
719 source: &str,
720 ts: &str,
721 origin: Origin,
722 ) -> Result<Committed> {
723 let expires = pool_expiry(ts)?;
724 let tree = parse_markdown(source);
725 let (_, rest) = split_frontmatter(&tree);
726 let assigned = assign_fresh_ids(rest, &mut *self.minter);
727 let plan = IngestPlan {
728 path,
729 source,
730 tree: &tree,
731 assigned,
732 dispositions: Vec::new(),
733 deleted: Vec::new(),
734 consumed_pool: Vec::new(),
735 cross_doc_ids: Vec::new(),
736 origin,
737 actor: None,
738 reason: None,
739 };
740 self.commit_ingest(repo_id, &plan, ts, &expires)
741 }
742
743 pub(crate) fn commit_ingest(
745 &mut self,
746 repo_id: &str,
747 plan: &IngestPlan<'_>,
748 ts: &str,
749 expires: &str,
750 ) -> Result<Committed> {
751 let tx = self.conn.unchecked_transaction()?;
752 let minter: &mut dyn IdMinter = &mut *self.minter;
753 let tree = plan.tree;
754 let source = plan.source;
755 let (fm_block, _) = split_frontmatter(tree);
756
757 let fm_blob_hex = fm_block.map(|b| put_blob(&tx, &b.raw)).transpose()?;
760 let fm_trivia = fm_block.map(|b| b.trivia.as_str());
761
762 let existing: Option<(String, Option<String>)> = tx
765 .query_row(
766 "SELECT doc_id, deleted_commit FROM docs WHERE repo_id = ?1 AND path = ?2",
767 params![repo_id, plan.path],
768 |r| Ok((r.get(0)?, r.get(1)?)),
769 )
770 .optional()?;
771 let doc_id = match existing {
772 Some((id, deleted_commit)) => {
773 tx.execute(
774 "UPDATE docs SET format = ?1, leading_trivia = ?2, frontmatter_trivia = ?3, deleted_commit = NULL WHERE doc_id = ?4",
775 params![FORMAT_MARKDOWN, tree.leading_trivia, fm_trivia, id],
776 )?;
777 if deleted_commit.is_some() {
781 adopt_phantoms(&tx, plan.path, &id)?;
782 }
783 id
784 }
785 None => {
786 let id = minter.mint("d");
787 tx.execute(
788 "INSERT INTO docs (doc_id, repo_id, path, format, leading_trivia, frontmatter_trivia) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
789 params![id, repo_id, plan.path, FORMAT_MARKDOWN, tree.leading_trivia, fm_trivia],
790 )?;
791 adopt_phantoms(&tx, plan.path, &id)?;
795 id
796 }
797 };
798
799 let assigned = &plan.assigned;
801 let root_tree_hex = write_block_tree(&tx, assigned)?;
802
803 let (commit_id, _) = new_commit(
805 &tx,
806 minter,
807 &NewCommit {
808 repo_id,
809 ts,
810 origin: plan.origin,
811 actor: plan.actor,
812 reason: plan.reason,
813 checkpoint_id: None,
814 ops: None,
815 },
816 )?;
817 let rendered_hash = sha256(source.as_bytes());
818 let (rev_id, _) = write_revision(
819 &tx,
820 minter,
821 &NewRevision {
822 doc_id: &doc_id,
823 root_tree_hex: &root_tree_hex,
824 frontmatter_blob_hex: fm_blob_hex.as_deref(),
825 rendered_hash,
826 path: plan.path,
827 commit_id: &commit_id,
828 },
829 )?;
830
831 {
833 let mut pool = tx.prepare(
834 "INSERT OR REPLACE INTO resurrection_pool (block_id, repo_id, doc_id, raw_hash, norm_hash, type, deleted_commit, expires_ts)
835 SELECT block_id, repo_id, doc_id, raw_hash, norm_hash, type, ?1, ?2 FROM blocks WHERE block_id = ?3 AND doc_id = ?4",
836 )?;
837 for id in &plan.deleted {
838 pool.execute(params![commit_id, expires, id, doc_id])?;
839 }
840 }
841
842 let incoming: Vec<String> = plan
844 .cross_doc_ids
845 .iter()
846 .chain(plan.consumed_pool.iter())
847 .cloned()
848 .collect();
849 if !incoming.is_empty() {
850 evict_foreign_block_rows(&tx, &doc_id, &incoming)?;
851 }
852
853 fts_delete_doc(&tx, &doc_id)?;
855 tx.execute("DELETE FROM blocks WHERE doc_id = ?1", params![doc_id])?;
856 let mut rows = Vec::new();
857 flatten_rows(assigned, None, 0, "/", &mut rows);
858 {
859 let mut insert = tx.prepare(
860 "INSERT INTO blocks
861 (block_id, repo_id, doc_id, parent_block, order_key, ordinal, depth,
862 ancestor_path, type, attrs, text, raw_hash, norm_hash, trivia_hash, created_commit)
863 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
864 )?;
865 for r in &rows {
866 insert.execute(params![
867 r.block_id,
868 repo_id,
869 doc_id,
870 r.parent_block,
871 r.order_key,
872 r.ordinal,
873 r.depth,
874 r.ancestor_path,
875 r.kind,
876 r.attrs,
877 r.text,
878 &r.raw_hash[..],
879 &r.norm_hash[..],
880 r.trivia_hash.as_ref().map(|h| &h[..]),
881 commit_id,
882 ])?;
883 }
884 }
885 fts_index_doc(&tx, &doc_id)?;
886 rebuild_sections(&tx, &doc_id)?;
887
888 let body = doc_blocks(assigned);
892 let mut nodes = project_nodes(&body);
893 nodes.extend(project_section_nodes(&tx, &doc_id)?);
894 write_doc_nodes(&tx, repo_id, &doc_id, &nodes)?;
895
896 let property_rows = doc_properties(&doc_id, fm_block, &body);
899 write_doc_properties(&tx, repo_id, &doc_id, &commit_id, &property_rows)?;
900
901 let mapping = fm_block.and_then(|b| parse_frontmatter(frontmatter_yaml(&b.raw)));
907 let descriptors = extract_doc_edges(&body, mapping.as_ref());
908 let resolved = resolve_edges(&tx, minter, repo_id, &doc_id, plan.path, &descriptors)?;
909 maintain_edges(&tx, minter, repo_id, &doc_id, &commit_id, &resolved)?;
910
911 {
913 let mut ins = tx.prepare(
914 "INSERT OR IGNORE INTO dispositions (commit_id, block_id, kind, confidence, reason, matcher_v, detail)
915 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
916 )?;
917 let mut bc = tx.prepare(
918 "INSERT OR IGNORE INTO block_changes (block_id, commit_id, kind) VALUES (?1, ?2, ?3)",
919 )?;
920 for d in &plan.dispositions {
921 ins.execute(params![
922 commit_id,
923 d.block_id,
924 d.kind,
925 d.confidence,
926 d.reason,
927 d.matcher_v,
928 d.detail,
929 ])?;
930 bc.execute(params![d.block_id, commit_id, d.kind])?;
931 }
932 }
933
934 for id in &plan.consumed_pool {
936 tx.execute(
937 "DELETE FROM resurrection_pool WHERE block_id = ?1",
938 params![id],
939 )?;
940 }
941
942 tx.execute(
944 "UPDATE docs SET current_rev = ?1, file_hash = ?2 WHERE doc_id = ?3",
945 params![rev_id, &rendered_hash[..], doc_id],
946 )?;
947
948 let converged =
951 render(tree) == source && reconstruct(&tx, &doc_id)?.as_deref() == Some(source);
952
953 tx.commit()?;
954 Ok(Committed {
955 doc_id,
956 commit_id,
957 rev_id,
958 converged,
959 })
960 }
961
962 pub(crate) fn tombstone_observed_deletion(
966 &mut self,
967 repo_id: &str,
968 doc_id: &str,
969 ts: &str,
970 expires: &str,
971 ) -> Result<String> {
972 let tx = self.conn.unchecked_transaction()?;
973 let minter: &mut dyn IdMinter = &mut *self.minter;
974 let (commit_id, _) = new_commit(
975 &tx,
976 minter,
977 &NewCommit {
978 reason: Some("observed deletion"),
979 ..NewCommit::observed(repo_id, ts)
980 },
981 )?;
982 tx.execute(
983 "INSERT OR REPLACE INTO resurrection_pool (block_id, repo_id, doc_id, raw_hash, norm_hash, type, deleted_commit, expires_ts)
984 SELECT block_id, repo_id, doc_id, raw_hash, norm_hash, type, ?1, ?2
985 FROM blocks WHERE doc_id = ?3 AND deleted_commit IS NULL",
986 params![commit_id, expires, doc_id],
987 )?;
988 fts_delete_doc(&tx, doc_id)?;
989 tx.execute(
990 "UPDATE blocks SET deleted_commit = ?1 WHERE doc_id = ?2 AND deleted_commit IS NULL",
991 params![commit_id, doc_id],
992 )?;
993 tx.execute(
994 "UPDATE docs SET deleted_commit = ?1 WHERE doc_id = ?2",
995 params![commit_id, doc_id],
996 )?;
997 tx.commit()?;
998 Ok(commit_id)
999 }
1000
1001 pub fn observe_one(
1004 &mut self,
1005 repo_id: &str,
1006 path: &str,
1007 source: &str,
1008 ts: &str,
1009 config: &Config,
1010 ) -> Result<ObserveOutcome> {
1011 let mut out =
1012 self.observe_batch(repo_id, &[BatchItem::observed(path, source)], ts, config)?;
1013 match out.pop() {
1014 Some(BatchOutcome::Observed(o)) => Ok(o),
1015 _ => Err(Error::Other(format!(
1016 "observe_one: unexpected outcome for {path}"
1017 ))),
1018 }
1019 }
1020
1021 pub fn observe_delete(&mut self, repo_id: &str, path: &str, ts: &str) -> Result<DeleteOutcome> {
1025 let expires = pool_expiry(ts)?;
1026 let existing: Option<(String, Option<Vec<u8>>)> = self
1027 .conn
1028 .query_row(
1029 "SELECT doc_id, file_hash FROM docs WHERE repo_id = ?1 AND path = ?2 AND deleted_commit IS NULL",
1030 params![repo_id, path],
1031 |r| Ok((r.get(0)?, r.get(1)?)),
1032 )
1033 .optional()?;
1034 let Some((doc_id, file_hash)) = existing else {
1035 return Ok(DeleteOutcome {
1036 path: path.to_owned(),
1037 doc_id: None,
1038 old_hash_hex: None,
1039 });
1040 };
1041 self.tombstone_observed_deletion(repo_id, &doc_id, ts, &expires)?;
1042 sweep_pool(&self.conn, ts)?;
1043 Ok(DeleteOutcome {
1044 path: path.to_owned(),
1045 doc_id: Some(doc_id),
1046 old_hash_hex: file_hash.as_deref().map(hex),
1047 })
1048 }
1049}
1050
1051#[cfg(test)]
1052mod tests {
1053 use super::*;
1054
1055 #[test]
1056 fn conflict_markers_need_both_sides_at_line_starts() {
1057 assert!(has_conflict_markers(
1058 "<<<<<<< HEAD\na\n=======\nb\n>>>>>>> branch\n"
1059 ));
1060 assert!(!has_conflict_markers("<<<<<<< HEAD\na\n=======\nb\n"));
1061 assert!(!has_conflict_markers("a <<<<<<< b\n>>>>>>> c\n"));
1062 assert!(has_conflict_markers(">>>>>>> c\r<<<<<<< b"));
1063 assert!(has_conflict_markers("x\u{2028}<<<<<<< a\u{2029}>>>>>>> b"));
1064 assert!(!has_conflict_markers(""));
1065 }
1066
1067 #[test]
1068 fn frontmatter_is_split_only_when_first() {
1069 let tree = parse_markdown("---\na: 1\n---\n\n# H\n");
1070 let (fm, rest) = split_frontmatter(&tree);
1071 assert_eq!(fm.map(|b| b.kind), Some(BlockKind::Frontmatter));
1072 assert_eq!(rest.len(), 1);
1073 let tree = parse_markdown("# H\n");
1074 let (fm, rest) = split_frontmatter(&tree);
1075 assert!(fm.is_none());
1076 assert_eq!(rest.len(), 1);
1077 }
1078}