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