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::mint::Mint;
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 Mint<'_>,
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.deferred("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 mint.finish()?;
234 consumed.extend(result.consumed_pool.iter().cloned());
235 Ok(Prepared {
236 path: path.to_owned(),
237 source: source.to_owned(),
238 doc_id,
239 tree,
240 old_blocks,
241 new_blocks,
242 result,
243 cross_doc_ids: Vec::new(),
244 })
245}
246
247fn cross_doc_phase(pending: &mut [Pending], config: &Config) {
249 let mut docs: Vec<PerDocUnmatched> = Vec::new();
250 let mut results: BTreeMap<String, ReconcileResult> = BTreeMap::new();
251 let mut key_of: Vec<Option<String>> = vec![None; pending.len()];
252 for (i, p) in pending.iter_mut().enumerate() {
253 match p {
254 Pending::Echo(_) | Pending::Gone { doc_id: None, .. } => {}
255 Pending::Gone {
256 doc_id: Some(doc_id),
257 old_blocks,
258 ..
259 } => {
260 docs.push(PerDocUnmatched {
261 doc_id: doc_id.clone(),
262 deleted: old_blocks.clone(),
263 inserted: Vec::new(),
264 });
265 results.insert(
266 doc_id.clone(),
267 ReconcileResult {
268 deleted: old_blocks.iter().filter_map(|b| b.id.clone()).collect(),
269 ..ReconcileResult::default()
270 },
271 );
272 }
273 Pending::Ingest { prepared, .. } => {
274 let key = prepared
275 .doc_id
276 .clone()
277 .unwrap_or_else(|| format!("new:{}", prepared.path));
278 let key_of_id: BTreeMap<&str, &str> = prepared
279 .result
280 .assignment
281 .iter()
282 .map(|(k, id)| (id.as_str(), k.as_str()))
283 .collect();
284 let deleted = prepared
285 .result
286 .deleted
287 .iter()
288 .filter_map(|id| {
289 prepared
290 .old_blocks
291 .iter()
292 .find(|b| b.id.as_deref() == Some(id))
293 })
294 .cloned()
295 .collect();
296 let inserted = prepared
297 .result
298 .dispositions
299 .iter()
300 .filter(|d| d.kind == DispositionKind::Inserted)
301 .filter_map(|d| {
302 let k = key_of_id.get(d.block_id.as_str())?;
303 let block = prepared.new_blocks.iter().find(|b| &b.key == k)?;
304 Some(Inserted {
305 block: block.clone(),
306 minted_id: d.block_id.clone(),
307 })
308 })
309 .collect();
310 docs.push(PerDocUnmatched {
311 doc_id: key.clone(),
312 deleted,
313 inserted,
314 });
315 results.insert(key.clone(), std::mem::take(&mut prepared.result));
316 key_of[i] = Some(key);
317 }
318 }
319 }
320 let matches = if docs.len() < 2 {
321 Vec::new()
322 } else {
323 cross_doc_match(&docs, config)
324 };
325 if !matches.is_empty() {
326 apply_cross_doc_matches(&mut results, &matches, &config.matcher_v);
327 }
328 for (i, p) in pending.iter_mut().enumerate() {
329 if let (Pending::Ingest { prepared, .. }, Some(key)) = (p, &key_of[i]) {
330 prepared.result = results
331 .remove(key)
332 .expect("every prepared member was keyed");
333 for m in &matches {
334 if &m.to_doc == key {
335 prepared.cross_doc_ids.push(m.carried_id.clone());
336 }
337 }
338 }
339 }
340}
341
342struct BlockRow {
343 block_id: String,
344 parent_block: Option<String>,
345 order_key: String,
346 ordinal: i64,
347 depth: i64,
348 ancestor_path: String,
349 kind: String,
350 attrs: String,
351 text: String,
352 raw_hash: [u8; 32],
353 norm_hash: [u8; 32],
354 trivia_hash: Option<[u8; 32]>,
355}
356
357fn flatten_rows(
359 blocks: &[TreeInputBlock],
360 parent: Option<&str>,
361 depth: i64,
362 ancestor_path: &str,
363 out: &mut Vec<BlockRow>,
364) {
365 let mut prev_key: Option<String> = None;
366 for (ordinal, b) in blocks.iter().enumerate() {
367 let order_key = key_between(prev_key.as_deref(), None);
368 prev_key = Some(order_key.clone());
369 out.push(BlockRow {
370 block_id: b.block_id.clone(),
371 parent_block: parent.map(str::to_owned),
372 order_key,
373 ordinal: ordinal as i64,
374 depth,
375 ancestor_path: ancestor_path.to_owned(),
376 kind: b.kind.clone(),
377 attrs: canonical_attrs(&b.attrs),
378 text: b.text.clone(),
379 raw_hash: sha256(b.raw.as_bytes()),
380 norm_hash: sha256(b.text.as_bytes()),
381 trivia_hash: (!b.trivia.is_empty()).then(|| sha256(b.trivia.as_bytes())),
382 });
383 if !b.children.is_empty() {
384 flatten_rows(
385 &b.children,
386 Some(&b.block_id),
387 depth + 1,
388 &format!("{ancestor_path}{}/", b.block_id),
389 out,
390 );
391 }
392 }
393}
394
395fn evict_foreign_block_rows(conn: &Connection, doc_id: &str, ids: &[String]) -> Result<()> {
400 let mut rows = Vec::new();
401 for id in ids {
402 let row: Option<EvictRow> = conn
403 .query_row(
404 "SELECT rowid, block_id, doc_id, parent_block, text, deleted_commit IS NULL
405 FROM blocks WHERE block_id = ?1 AND doc_id != ?2",
406 params![id, doc_id],
407 |r| {
408 Ok(EvictRow {
409 rowid: r.get(0)?,
410 block_id: r.get(1)?,
411 doc_id: r.get(2)?,
412 parent_block: r.get(3)?,
413 text: r.get(4)?,
414 live: r.get(5)?,
415 })
416 },
417 )
418 .optional()?;
419 if let Some(row) = row {
420 rows.push(row);
421 }
422 }
423 fts_before_evict_rows(conn, &rows)?;
424 for r in &rows {
425 conn.execute(
426 "DELETE FROM blocks WHERE block_id = ?1 AND doc_id != ?2",
427 params![r.block_id, doc_id],
428 )?;
429 }
430 for id in ids {
431 conn.execute(
432 "DELETE FROM resurrection_pool WHERE block_id = ?1",
433 params![id],
434 )?;
435 }
436 Ok(())
437}
438
439#[derive(Clone, Debug, PartialEq, Eq)]
442pub struct Committed {
443 pub doc_id: String,
444 pub commit_id: String,
445 pub rev_id: String,
446 pub converged: bool,
447}
448
449#[derive(Clone, Debug, PartialEq)]
452pub(crate) struct DispositionRow {
453 pub block_id: String,
454 pub kind: String,
455 pub confidence: Option<f64>,
456 pub reason: Option<String>,
457 pub matcher_v: Option<String>,
458 pub detail: String,
460}
461
462pub(crate) struct IngestPlan<'a> {
465 pub path: &'a str,
466 pub source: &'a str,
467 pub tree: &'a BlockTree,
468 pub assigned: Vec<TreeInputBlock>,
469 pub dispositions: Vec<DispositionRow>,
470 pub deleted: Vec<String>,
472 pub consumed_pool: Vec<String>,
473 pub cross_doc_ids: Vec<String>,
474 pub origin: Origin,
475 pub actor: Option<&'a str>,
476 pub reason: Option<&'a str>,
477}
478
479fn disposition_rows(result: &ReconcileResult) -> Vec<DispositionRow> {
480 result
481 .dispositions
482 .iter()
483 .map(|d| DispositionRow {
484 block_id: d.block_id.clone(),
485 kind: d.kind.as_str().to_owned(),
486 confidence: d.confidence,
487 reason: d.reason.map(|r| r.as_str().to_owned()),
488 matcher_v: Some(d.matcher_v.clone()),
489 detail: detail_to_json(&d.detail).to_string(),
490 })
491 .collect()
492}
493
494impl Store {
495 pub fn observe_batch(
499 &mut self,
500 repo_id: &str,
501 items: &[BatchItem],
502 ts: &str,
503 config: &Config,
504 ) -> Result<Vec<BatchOutcome>> {
505 let expires = pool_expiry(ts)?;
506
507 let pool = load_pool(&self.conn, repo_id, ts)?;
509 let mut consumed: HashSet<String> = HashSet::new();
510 let mut pending: Vec<Pending> = Vec::with_capacity(items.len());
511 for it in items {
512 let existing: Option<(String, Option<Vec<u8>>)> = self
513 .conn
514 .query_row(
515 "SELECT doc_id, file_hash FROM docs WHERE repo_id = ?1 AND path = ?2 AND deleted_commit IS NULL",
516 params![repo_id, it.path],
517 |r| Ok((r.get(0)?, r.get(1)?)),
518 )
519 .optional()?;
520 let old_hash_hex = existing.as_ref().and_then(|(_, h)| h.as_deref()).map(hex);
521 let Some(source) = &it.source else {
522 let (doc_id, old_blocks) = match &existing {
523 Some((id, _)) => (Some(id.clone()), load_old_match_blocks(&self.conn, id)?),
524 None => (None, Vec::new()),
525 };
526 pending.push(Pending::Gone {
527 path: it.path.clone(),
528 doc_id,
529 old_hash_hex,
530 old_blocks,
531 });
532 continue;
533 };
534 let hash = sha256(source.as_bytes());
535 let new_hash_hex = hex(&hash);
536 if let Some((doc_id, Some(stored))) = &existing {
537 if stored[..] == hash[..] {
538 pending.push(Pending::Echo(ObserveOutcome {
539 path: it.path.clone(),
540 doc_id: doc_id.clone(),
541 rev: None,
542 commit_id: None,
543 converged: true,
544 echo: true,
545 conflicted: false,
546 dispositions: BTreeMap::new(),
547 old_hash_hex,
548 new_hash_hex,
549 }));
550 continue;
551 }
552 }
553 let prepared = prepare_reconcile(
554 &self.conn,
555 &mut self.ids.at(&self.conn),
556 repo_id,
557 &it.path,
558 source,
559 config,
560 &pool,
561 &mut consumed,
562 )?;
563 pending.push(Pending::Ingest {
564 prepared,
565 old_hash_hex,
566 new_hash_hex,
567 });
568 }
569
570 if pending.len() > 1 {
572 cross_doc_phase(&mut pending, config);
573 }
574
575 let mut out = Vec::with_capacity(pending.len());
577 for p in pending {
578 match p {
579 Pending::Echo(outcome) => out.push(BatchOutcome::Observed(outcome)),
580 Pending::Gone {
581 path,
582 doc_id,
583 old_hash_hex,
584 ..
585 } => {
586 if let Some(id) = &doc_id {
587 self.tombstone_observed_deletion(repo_id, id, ts, &expires)?;
588 }
589 out.push(BatchOutcome::Deleted(DeleteOutcome {
590 path,
591 doc_id,
592 old_hash_hex,
593 }));
594 }
595 Pending::Ingest {
596 prepared,
597 old_hash_hex,
598 new_hash_hex,
599 } => {
600 let conflicted = has_conflict_markers(&prepared.source);
601 let c = self.commit_prepared(repo_id, &prepared, ts, &expires)?;
602 self.conn.execute(
603 "UPDATE docs SET conflicted = ?1 WHERE repo_id = ?2 AND path = ?3",
604 params![i64::from(conflicted), repo_id, prepared.path],
605 )?;
606 let dispositions = {
607 let mut stmt = self.conn.prepare(
608 "SELECT kind, count(*) FROM dispositions WHERE commit_id = ?1 GROUP BY kind ORDER BY kind",
609 )?;
610 let rows = stmt.query_map(params![c.commit_id], |r| {
611 Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)? as u64))
612 })?;
613 rows.collect::<std::result::Result<BTreeMap<_, _>, _>>()?
614 };
615 out.push(BatchOutcome::Observed(ObserveOutcome {
616 path: prepared.path,
617 doc_id: c.doc_id,
618 rev: Some(c.rev_id),
619 commit_id: Some(c.commit_id),
620 converged: c.converged,
621 echo: false,
622 conflicted,
623 dispositions,
624 old_hash_hex,
625 new_hash_hex,
626 }));
627 }
628 }
629 }
630 Ok(out)
631 }
632
633 fn commit_prepared(
636 &mut self,
637 repo_id: &str,
638 prepared: &Prepared,
639 ts: &str,
640 expires: &str,
641 ) -> Result<Committed> {
642 let (_, rest) = split_frontmatter(&prepared.tree);
643 let assigned = assign_from_map(
645 rest,
646 &prepared.result.assignment,
647 &mut self.ids.at(&self.conn),
648 )?;
649 let plan = IngestPlan {
650 path: &prepared.path,
651 source: &prepared.source,
652 tree: &prepared.tree,
653 assigned,
654 dispositions: disposition_rows(&prepared.result),
655 deleted: prepared.result.deleted.clone(),
656 consumed_pool: prepared.result.consumed_pool.clone(),
657 cross_doc_ids: prepared.cross_doc_ids.clone(),
658 origin: Origin::Observed,
659 actor: None,
660 reason: None,
661 };
662 self.commit_ingest(repo_id, &plan, ts, expires)
663 }
664
665 #[allow(clippy::too_many_arguments)]
674 pub fn reconciling_ingest(
675 &mut self,
676 repo_id: &str,
677 path: &str,
678 source: &str,
679 ts: &str,
680 origin: Origin,
681 actor: Option<&str>,
682 reason: Option<&str>,
683 config: &Config,
684 ) -> Result<Committed> {
685 let expires = pool_expiry(ts)?;
686 let pool = load_pool(&self.conn, repo_id, ts)?;
687 let mut consumed = HashSet::new();
688 let prepared = prepare_reconcile(
689 &self.conn,
690 &mut self.ids.at(&self.conn),
691 repo_id,
692 path,
693 source,
694 config,
695 &pool,
696 &mut consumed,
697 )?;
698 let (_, rest) = split_frontmatter(&prepared.tree);
699 let assigned = assign_from_map(
700 rest,
701 &prepared.result.assignment,
702 &mut self.ids.at(&self.conn),
703 )?;
704 let plan = IngestPlan {
705 path,
706 source,
707 tree: &prepared.tree,
708 assigned,
709 dispositions: disposition_rows(&prepared.result),
710 deleted: prepared.result.deleted.clone(),
711 consumed_pool: prepared.result.consumed_pool.clone(),
712 cross_doc_ids: Vec::new(),
713 origin,
714 actor,
715 reason,
716 };
717 self.commit_ingest(repo_id, &plan, ts, &expires)
718 }
719
720 pub fn fresh_ingest(
725 &mut self,
726 repo_id: &str,
727 path: &str,
728 source: &str,
729 ts: &str,
730 origin: Origin,
731 ) -> Result<Committed> {
732 let expires = pool_expiry(ts)?;
733 let tree = parse_markdown(source);
734 let (_, rest) = split_frontmatter(&tree);
735 let assigned = assign_fresh_ids(rest, &mut self.ids.at(&self.conn))?;
736 let plan = IngestPlan {
737 path,
738 source,
739 tree: &tree,
740 assigned,
741 dispositions: Vec::new(),
742 deleted: Vec::new(),
743 consumed_pool: Vec::new(),
744 cross_doc_ids: Vec::new(),
745 origin,
746 actor: None,
747 reason: None,
748 };
749 self.commit_ingest(repo_id, &plan, ts, &expires)
750 }
751
752 pub(crate) fn commit_ingest(
754 &mut self,
755 repo_id: &str,
756 plan: &IngestPlan<'_>,
757 ts: &str,
758 expires: &str,
759 ) -> Result<Committed> {
760 let tx = self.conn.unchecked_transaction()?;
761 let mut mint = self.ids.at(&tx);
762 let minter = &mut mint;
763 let tree = plan.tree;
764 let source = plan.source;
765 let (fm_block, _) = split_frontmatter(tree);
766
767 let fm_blob_hex = fm_block.map(|b| put_blob(&tx, &b.raw)).transpose()?;
770 let fm_trivia = fm_block.map(|b| b.trivia.as_str());
771
772 let existing: Option<(String, Option<String>)> = tx
775 .query_row(
776 "SELECT doc_id, deleted_commit FROM docs WHERE repo_id = ?1 AND path = ?2",
777 params![repo_id, plan.path],
778 |r| Ok((r.get(0)?, r.get(1)?)),
779 )
780 .optional()?;
781 let doc_id = match existing {
782 Some((id, deleted_commit)) => {
783 tx.execute(
784 "UPDATE docs SET format = ?1, leading_trivia = ?2, frontmatter_trivia = ?3, deleted_commit = NULL WHERE doc_id = ?4",
785 params![FORMAT_MARKDOWN, tree.leading_trivia, fm_trivia, id],
786 )?;
787 if deleted_commit.is_some() {
791 adopt_phantoms(&tx, plan.path, &id)?;
792 }
793 id
794 }
795 None => {
796 let id = minter.mint("d")?;
797 tx.execute(
798 "INSERT INTO docs (doc_id, repo_id, path, format, leading_trivia, frontmatter_trivia) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
799 params![id, repo_id, plan.path, FORMAT_MARKDOWN, tree.leading_trivia, fm_trivia],
800 )?;
801 adopt_phantoms(&tx, plan.path, &id)?;
805 id
806 }
807 };
808
809 let assigned = &plan.assigned;
811 let root_tree_hex = write_block_tree(&tx, assigned)?;
812
813 let (commit_id, _) = new_commit(
815 &tx,
816 minter,
817 &NewCommit {
818 repo_id,
819 ts,
820 origin: plan.origin,
821 actor: plan.actor,
822 reason: plan.reason,
823 checkpoint_id: None,
824 ops: None,
825 },
826 )?;
827 let rendered_hash = sha256(source.as_bytes());
828 let (rev_id, _) = write_revision(
829 &tx,
830 minter,
831 &NewRevision {
832 doc_id: &doc_id,
833 root_tree_hex: &root_tree_hex,
834 frontmatter_blob_hex: fm_blob_hex.as_deref(),
835 rendered_hash,
836 path: plan.path,
837 commit_id: &commit_id,
838 },
839 )?;
840
841 {
843 let mut pool = tx.prepare(
844 "INSERT OR REPLACE INTO resurrection_pool (block_id, repo_id, doc_id, raw_hash, norm_hash, type, deleted_commit, expires_ts)
845 SELECT block_id, repo_id, doc_id, raw_hash, norm_hash, type, ?1, ?2 FROM blocks WHERE block_id = ?3 AND doc_id = ?4",
846 )?;
847 for id in &plan.deleted {
848 pool.execute(params![commit_id, expires, id, doc_id])?;
849 }
850 }
851
852 let incoming: Vec<String> = plan
854 .cross_doc_ids
855 .iter()
856 .chain(plan.consumed_pool.iter())
857 .cloned()
858 .collect();
859 if !incoming.is_empty() {
860 evict_foreign_block_rows(&tx, &doc_id, &incoming)?;
861 }
862
863 fts_delete_doc(&tx, &doc_id)?;
865 tx.execute("DELETE FROM blocks WHERE doc_id = ?1", params![doc_id])?;
866 let mut rows = Vec::new();
867 flatten_rows(assigned, None, 0, "/", &mut rows);
868 {
869 let mut insert = tx.prepare(
870 "INSERT INTO blocks
871 (block_id, repo_id, doc_id, parent_block, order_key, ordinal, depth,
872 ancestor_path, type, attrs, text, raw_hash, norm_hash, trivia_hash, created_commit)
873 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
874 )?;
875 for r in &rows {
876 insert.execute(params![
877 r.block_id,
878 repo_id,
879 doc_id,
880 r.parent_block,
881 r.order_key,
882 r.ordinal,
883 r.depth,
884 r.ancestor_path,
885 r.kind,
886 r.attrs,
887 r.text,
888 &r.raw_hash[..],
889 &r.norm_hash[..],
890 r.trivia_hash.as_ref().map(|h| &h[..]),
891 commit_id,
892 ])?;
893 }
894 }
895 fts_index_doc(&tx, &doc_id)?;
896 rebuild_sections(&tx, &doc_id)?;
897
898 let body = doc_blocks(assigned);
902 let mut nodes = project_nodes(&body);
903 nodes.extend(project_section_nodes(&tx, &doc_id)?);
904 write_doc_nodes(&tx, repo_id, &doc_id, &nodes)?;
905
906 let property_rows = doc_properties(&doc_id, fm_block, &body);
909 write_doc_properties(&tx, repo_id, &doc_id, &commit_id, &property_rows)?;
910
911 let mapping = fm_block.and_then(|b| parse_frontmatter(frontmatter_yaml(&b.raw)));
917 let descriptors = extract_doc_edges(&body, mapping.as_ref());
918 let resolved = resolve_edges(&tx, minter, repo_id, &doc_id, plan.path, &descriptors)?;
919 maintain_edges(&tx, minter, repo_id, &doc_id, &commit_id, &resolved)?;
920
921 {
923 let mut ins = tx.prepare(
924 "INSERT OR IGNORE INTO dispositions (commit_id, block_id, kind, confidence, reason, matcher_v, detail)
925 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
926 )?;
927 let mut bc = tx.prepare(
928 "INSERT OR IGNORE INTO block_changes (block_id, commit_id, kind) VALUES (?1, ?2, ?3)",
929 )?;
930 for d in &plan.dispositions {
931 ins.execute(params![
932 commit_id,
933 d.block_id,
934 d.kind,
935 d.confidence,
936 d.reason,
937 d.matcher_v,
938 d.detail,
939 ])?;
940 bc.execute(params![d.block_id, commit_id, d.kind])?;
941 }
942 }
943
944 for id in &plan.consumed_pool {
946 tx.execute(
947 "DELETE FROM resurrection_pool WHERE block_id = ?1",
948 params![id],
949 )?;
950 }
951
952 tx.execute(
954 "UPDATE docs SET current_rev = ?1, file_hash = ?2 WHERE doc_id = ?3",
955 params![rev_id, &rendered_hash[..], doc_id],
956 )?;
957
958 let converged =
961 render(tree) == source && reconstruct(&tx, &doc_id)?.as_deref() == Some(source);
962
963 tx.commit()?;
964 Ok(Committed {
965 doc_id,
966 commit_id,
967 rev_id,
968 converged,
969 })
970 }
971
972 pub(crate) fn tombstone_observed_deletion(
976 &mut self,
977 repo_id: &str,
978 doc_id: &str,
979 ts: &str,
980 expires: &str,
981 ) -> Result<String> {
982 let tx = self.conn.unchecked_transaction()?;
983 let mut mint = self.ids.at(&tx);
984 let (commit_id, _) = new_commit(
985 &tx,
986 &mut mint,
987 &NewCommit {
988 reason: Some("observed deletion"),
989 ..NewCommit::observed(repo_id, ts)
990 },
991 )?;
992 tx.execute(
993 "INSERT OR REPLACE INTO resurrection_pool (block_id, repo_id, doc_id, raw_hash, norm_hash, type, deleted_commit, expires_ts)
994 SELECT block_id, repo_id, doc_id, raw_hash, norm_hash, type, ?1, ?2
995 FROM blocks WHERE doc_id = ?3 AND deleted_commit IS NULL",
996 params![commit_id, expires, doc_id],
997 )?;
998 fts_delete_doc(&tx, doc_id)?;
999 tx.execute(
1000 "UPDATE blocks SET deleted_commit = ?1 WHERE doc_id = ?2 AND deleted_commit IS NULL",
1001 params![commit_id, doc_id],
1002 )?;
1003 tx.execute(
1004 "UPDATE docs SET deleted_commit = ?1 WHERE doc_id = ?2",
1005 params![commit_id, doc_id],
1006 )?;
1007 tx.commit()?;
1008 Ok(commit_id)
1009 }
1010
1011 pub fn observe_one(
1014 &mut self,
1015 repo_id: &str,
1016 path: &str,
1017 source: &str,
1018 ts: &str,
1019 config: &Config,
1020 ) -> Result<ObserveOutcome> {
1021 let mut out =
1022 self.observe_batch(repo_id, &[BatchItem::observed(path, source)], ts, config)?;
1023 match out.pop() {
1024 Some(BatchOutcome::Observed(o)) => Ok(o),
1025 _ => Err(Error::Other(format!(
1026 "observe_one: unexpected outcome for {path}"
1027 ))),
1028 }
1029 }
1030
1031 pub fn observe_delete(&mut self, repo_id: &str, path: &str, ts: &str) -> Result<DeleteOutcome> {
1035 let expires = pool_expiry(ts)?;
1036 let existing: Option<(String, Option<Vec<u8>>)> = self
1037 .conn
1038 .query_row(
1039 "SELECT doc_id, file_hash FROM docs WHERE repo_id = ?1 AND path = ?2 AND deleted_commit IS NULL",
1040 params![repo_id, path],
1041 |r| Ok((r.get(0)?, r.get(1)?)),
1042 )
1043 .optional()?;
1044 let Some((doc_id, file_hash)) = existing else {
1045 return Ok(DeleteOutcome {
1046 path: path.to_owned(),
1047 doc_id: None,
1048 old_hash_hex: None,
1049 });
1050 };
1051 self.tombstone_observed_deletion(repo_id, &doc_id, ts, &expires)?;
1052 sweep_pool(&self.conn, ts)?;
1053 Ok(DeleteOutcome {
1054 path: path.to_owned(),
1055 doc_id: Some(doc_id),
1056 old_hash_hex: file_hash.as_deref().map(hex),
1057 })
1058 }
1059}
1060
1061#[cfg(test)]
1062mod tests {
1063 use super::*;
1064
1065 #[test]
1066 fn conflict_markers_need_both_sides_at_line_starts() {
1067 assert!(has_conflict_markers(
1068 "<<<<<<< HEAD\na\n=======\nb\n>>>>>>> branch\n"
1069 ));
1070 assert!(!has_conflict_markers("<<<<<<< HEAD\na\n=======\nb\n"));
1071 assert!(!has_conflict_markers("a <<<<<<< b\n>>>>>>> c\n"));
1072 assert!(has_conflict_markers(">>>>>>> c\r<<<<<<< b"));
1073 assert!(has_conflict_markers("x\u{2028}<<<<<<< a\u{2029}>>>>>>> b"));
1074 assert!(!has_conflict_markers(""));
1075 }
1076
1077 #[test]
1078 fn frontmatter_is_split_only_when_first() {
1079 let tree = parse_markdown("---\na: 1\n---\n\n# H\n");
1080 let (fm, rest) = split_frontmatter(&tree);
1081 assert_eq!(fm.map(|b| b.kind), Some(BlockKind::Frontmatter));
1082 assert_eq!(rest.len(), 1);
1083 let tree = parse_markdown("# H\n");
1084 let (fm, rest) = split_frontmatter(&tree);
1085 assert!(fm.is_none());
1086 assert_eq!(rest.len(), 1);
1087 }
1088}