Skip to main content

omgbase_store/
observe.rs

1//! Observation (`spec/store/README.md` §5): bytes at a path become a commit.
2//! Two passes — echo gate + reconcile without writes, the cross-document
3//! phase of `spec/reconcile` §7, then one transaction per member — plus the
4//! observed-deletion tombstone (§5.6).
5
6use 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/// One member of a batch: the bytes now at `path`, or `None` when the path
39/// is gone.
40#[derive(Clone, Debug, PartialEq, Eq)]
41pub struct BatchItem {
42    pub path: String,
43    pub source: Option<String>,
44}
45
46impl BatchItem {
47    /// The bytes at `path` are now `source`.
48    #[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    /// `path` is gone.
57    #[must_use]
58    pub fn gone(path: &str) -> Self {
59        Self {
60            path: path.to_owned(),
61            source: None,
62        }
63    }
64}
65
66/// The outcome of observing bytes at a path (§5.4).
67#[derive(Clone, Debug, PartialEq, Eq)]
68pub struct ObserveOutcome {
69    pub path: String,
70    pub doc_id: String,
71    /// `None` on an echo.
72    pub rev: Option<String>,
73    /// `None` on an echo.
74    pub commit_id: Option<String>,
75    /// §5.4 step 13; `true` on an echo.
76    pub converged: bool,
77    /// The bytes already matched the stored revision: no commit.
78    pub echo: bool,
79    /// The bytes carry git conflict markers.
80    pub conflicted: bool,
81    /// Disposition kind → count over this commit (empty on an echo).
82    pub dispositions: BTreeMap<String, u64>,
83    /// The prior `file_hash` (hex), or `None` when the doc was new or gone.
84    pub old_hash_hex: Option<String>,
85    /// `sha256(source)` (hex).
86    pub new_hash_hex: String,
87}
88
89/// The outcome of observing that a path is gone (§5.6).
90#[derive(Clone, Debug, PartialEq, Eq)]
91pub struct DeleteOutcome {
92    pub path: String,
93    /// The tombstoned doc, or `None` when nothing was live (a no-op).
94    pub doc_id: Option<String>,
95    pub old_hash_hex: Option<String>,
96}
97
98impl DeleteOutcome {
99    /// Whether a live doc was tombstoned.
100    #[must_use]
101    pub fn deleted(&self) -> bool {
102        self.doc_id.is_some()
103    }
104}
105
106/// The outcome of one batch member.
107#[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/// §5.4 step 14: a line starting with `<<<<<<<` **and** a line starting with
140/// `>>>>>>>` (the reference's `/^<{7}/m && /^>{7}/m`; JavaScript's `^` in
141/// multiline mode matches after `\n`, `\r`, U+2028 and U+2029).
142#[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
152/// A member parsed and reconciled but not committed (pass 1).
153struct Prepared {
154    path: String,
155    source: String,
156    /// The doc row at `path`, live or tombstoned, or `None` when the path is new.
157    doc_id: Option<String>,
158    tree: BlockTree,
159    old_blocks: Vec<MatchBlock>,
160    new_blocks: Vec<MatchBlock>,
161    result: ReconcileResult,
162    /// Ids carried in by the cross-document phase.
163    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
181/// The frontmatter block (when the first block is one) and the body.
182fn 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/// §5.1 step 4: parse, load the old tree (regardless of tombstone), offer the
190/// pool minus `consumed` when a doc row exists, reconcile, mint `b` ids.
191#[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
247/// §5.3: pool the leftovers, match across documents, apply.
248fn 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
357/// The `blocks` rows of a body tree in pre-order (§4.3, §5.4 step 9).
358fn 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
395/// §5.4 step 8: delete another document's `blocks` row for each id (its FTS
396/// entry first when it is a live leaf, `spec/search` §1.1 — leaf-ness decided
397/// over the table before any row goes, a parent left childless re-indexed)
398/// and the id's pool row.
399fn 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/// What §5.4 steps 1–13 produce: the committed document, commit and
440/// revision, and whether the bytes converged (step 13).
441#[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/// A disposition to persist (§5.4 step 10): the matcher's, or the `api`
450/// intent rows of `spec/mutate` §4 (`matcher_v` null, `detail` `{}`).
451#[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    /// JSON text.
459    pub detail: String,
460}
461
462/// Everything §5.4 needs to commit one document: the parsed tree and the
463/// id-assigned body, the identity decisions, and the commit row's provenance.
464pub(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    /// Pooled (step 7).
471    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    /// §5: observe a batch of members at `ts` (RFC 3339 UTC, §2.4) with the
496    /// `spec/reconcile` thresholds in `config`. Does **not** sweep the pool
497    /// (§5.5: the caller does, once per batch).
498    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        // ---- pass 1: echo gate + reconcile, no writes ------------------------------
508        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        // ---- cross-document phase (spec/reconcile §7) --------------------------------
571        if pending.len() > 1 {
572            cross_doc_phase(&mut pending, config);
573        }
574
575        // ---- pass 2: commit, in batch order -------------------------------------------
576        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    /// §5.4 steps 1–13 for a prepared (reconciled) member: an `observed`
634    /// commit with the matcher's dispositions.
635    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        // 3. Assign ids (nothing mints: the matcher assigned every key).
644        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    /// Parse and reconcile `source` at `path` against the stored tree (the
666    /// pool offered when a doc row exists, §5.1 step 4) and commit it with
667    /// the given provenance — the reference's `ingestFile` with
668    /// `makeReconcilingResolver`: `docs_create`/`docs_set_meta` (`api`) and
669    /// the file-CAS conflict ingest of `spec/mutate` §4 (`observed`). No echo
670    /// gate, no `conflicted` update, no sweep. The sync layer's recovery and
671    /// `attach_source` (`spec/sync` §4.3, §6) ingest through this too, with
672    /// `Origin::Observed`.
673    #[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    /// Parse `source` at `path` and commit it with a **fresh `b` id for every
721    /// block** — the reference's `ingestFile` without a resolver (no
722    /// dispositions, nothing pooled): the re-mint path `spec/sync` §6 pins for
723    /// a `borne` source's `attach_source` (§9). No echo gate, no sweep.
724    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    /// §5.4 steps 1–13 in one transaction (the reference's `ingestFile`).
753    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        // 2. Frontmatter blob (the reference puts it before the doc row; blobs
768        //    are content-addressed, so the order is unobservable).
769        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        // 1. Doc row (the path's row regardless of tombstone; a re-created
773        //    path is revived — §5.6 "Re-creation").
774        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                // spec/graph §3.5: a revived row becomes live again, so the
788                // phantom edges that accrued at its path while it was
789                // tombstoned re-point to it.
790                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                // spec/graph §3.5: a new row at this path adopts the open
802                // phantom edges that pointed at it (the reference runs this
803                // right after the INSERT; nothing is minted).
804                adopt_phantoms(&tx, plan.path, &id)?;
805                id
806            }
807        };
808
809        // 4. Write the tree.
810        let assigned = &plan.assigned;
811        let root_tree_hex = write_block_tree(&tx, assigned)?;
812
813        // 5–6. Commit and revision rows.
814        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        // 7. Pool the deleted.
842        {
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        // 8. Evict foreign rows.
853        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        // 9. Refresh the blocks, sections and FTS.
864        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        // 9a. Nodes (spec/graph §2): the adapter's projections over the
899        //     assigned body, then the `md:section` nodes from the sections just
900        //     rebuilt; deleted (FTS first) and reinserted.
901        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        // 9b. Properties (spec/properties §6): the document's rows from the
907        //     frontmatter block and the assigned body, deleted then written.
908        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        // 9c. Edges (spec/graph §3): descriptors from the blocks in pre-order
912        //     then the frontmatter mapping (the same parse the properties step
913        //     uses; none when it fails), resolved in order (**mints `x`** per
914        //     new external URI), then the intervals (**mints `e`** per new
915        //     edge) and the rollup.
916        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        // 10. Dispositions and block_changes.
922        {
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        // 11. Consume the pool.
945        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        // 12. Pointers.
953        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        // 13. Converged: file_hash == rendered_hash (trivially), render(tree) ==
959        //     source, reconstruct(doc) == source.
960        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    /// §5.6: tombstone a live doc whose path is gone. Mints `c`; pools every
973    /// live block; drops the FTS rows; tombstones the blocks and the doc row.
974    /// Returns the commit id.
975    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    /// A batch of one (§5: "the same procedure without the middle"). Does not
1012    /// sweep the pool.
1013    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    /// Observe that `path` is gone (§5.6) and sweep the pool at `ts` (§5.5),
1032    /// as the reference's `observeDelete` does. A path with no live doc is a
1033    /// no-op (`doc_id: None`).
1034    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}