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). The body of a member's commit,
5//! [`ingest_in`], runs §5.4 steps 1–4 and 6–13 against a transaction and a
6//! commit id it is handed, so the `api` path ([`crate::effects`], store 13.7)
7//! can run N of them under one commit row.
8//!
9//! **The gate rule** (store 14.0, §5.7). A path with a `pending_materializations`
10//! row — an `api` commit whose file has not landed yet — is observed against
11//! that intent before the echo gate: bytes hashing to the row's
12//! `rendered_hash` are the materialized file (echo; the row goes); a member
13//! gone under a `write`/`rename` row is **not** a deletion (the materializer
14//! still owes the file; the row stays); foreign bytes win (ADR-004) — the
15//! normal reconciling ingest, the row deleted in that member's transaction.
16
17use std::collections::{BTreeMap, HashSet};
18
19use omgbase_format::hash::{hex, sha256};
20use omgbase_format::{
21    Block, BlockKind, BlockTree, FORMAT_MARKDOWN, format_for_path, parse_path, render,
22};
23use omgbase_graph::{extract_doc_edges, project_nodes};
24use omgbase_properties::{
25    document_mapping, document_properties, frontmatter_yaml, parse_frontmatter,
26};
27use omgbase_reconcile::json::detail_to_json;
28use omgbase_reconcile::{
29    Config, DispositionKind, FlatSource, Inserted, MatchBlock, Options, PerDocUnmatched, PoolEntry,
30    ReconcileResult, apply_cross_doc_matches, cross_doc_match, flatten, reconcile_document,
31};
32use rusqlite::{Connection, OptionalExtension, params};
33
34use crate::Store;
35use crate::delta::TreeResolver;
36use crate::derived::{
37    EvictRow, fts_before_evict_rows, fts_delete_doc, fts_index_doc, rebuild_sections, sweep_pool,
38};
39use crate::effects::{CommitHead, Effect, Guard, Outcome};
40use crate::error::{Error, Result};
41use crate::graph::{
42    adopt_phantoms, maintain_edges, project_section_nodes, resolve_edges, write_doc_nodes,
43};
44use crate::materialize::{PendingKind, delete_pending, pending_at};
45use crate::mint::Mint;
46use crate::order_key::key_between;
47use crate::properties::{doc_blocks, write_doc_properties};
48use crate::read::{load_old_match_blocks, load_pool, reconstruct};
49use crate::time::pool_expiry;
50use crate::tree::canonical_attrs;
51use crate::writers::{
52    NewCommit, NewRevision, Origin, TreeInputBlock, TreeWriteContext, assign_fresh_ids,
53    assign_from_map, is_checkpoint_revision, new_commit, previous_revision, write_block_tree_in,
54    write_frontmatter_blob, write_revision,
55};
56
57/// One member of a batch: the bytes now at `path`, or `None` when the path
58/// is gone.
59#[derive(Clone, Debug, PartialEq, Eq)]
60pub struct BatchItem {
61    pub path: String,
62    pub source: Option<String>,
63}
64
65impl BatchItem {
66    /// The bytes at `path` are now `source`.
67    #[must_use]
68    pub fn observed(path: &str, source: &str) -> Self {
69        Self {
70            path: path.to_owned(),
71            source: Some(source.to_owned()),
72        }
73    }
74
75    /// `path` is gone.
76    #[must_use]
77    pub fn gone(path: &str) -> Self {
78        Self {
79            path: path.to_owned(),
80            source: None,
81        }
82    }
83}
84
85/// The outcome of observing bytes at a path (§5.4).
86#[derive(Clone, Debug, PartialEq, Eq)]
87pub struct ObserveOutcome {
88    pub path: String,
89    pub doc_id: String,
90    /// `None` on an echo.
91    pub rev: Option<String>,
92    /// `None` on an echo.
93    pub commit_id: Option<String>,
94    /// §5.4 step 13; `true` on an echo.
95    pub converged: bool,
96    /// The bytes already matched the stored revision: no commit.
97    pub echo: bool,
98    /// The bytes carry git conflict markers.
99    pub conflicted: bool,
100    /// Disposition kind → count over this commit (empty on an echo).
101    pub dispositions: BTreeMap<String, u64>,
102    /// The prior `file_hash` (hex), or `None` when the doc was new or gone.
103    pub old_hash_hex: Option<String>,
104    /// `sha256(source)` (hex).
105    pub new_hash_hex: String,
106}
107
108/// The outcome of observing that a path is gone (§5.6).
109#[derive(Clone, Debug, PartialEq, Eq)]
110pub struct DeleteOutcome {
111    pub path: String,
112    /// The tombstoned doc, or `None` when nothing was live (a no-op).
113    pub doc_id: Option<String>,
114    pub old_hash_hex: Option<String>,
115}
116
117impl DeleteOutcome {
118    /// Whether a live doc was tombstoned.
119    #[must_use]
120    pub fn deleted(&self) -> bool {
121        self.doc_id.is_some()
122    }
123}
124
125/// The outcome of one batch member.
126#[derive(Clone, Debug, PartialEq, Eq)]
127pub enum BatchOutcome {
128    Observed(ObserveOutcome),
129    Deleted(DeleteOutcome),
130}
131
132impl BatchOutcome {
133    #[must_use]
134    pub fn path(&self) -> &str {
135        match self {
136            BatchOutcome::Observed(o) => &o.path,
137            BatchOutcome::Deleted(d) => &d.path,
138        }
139    }
140
141    #[must_use]
142    pub fn as_observed(&self) -> Option<&ObserveOutcome> {
143        match self {
144            BatchOutcome::Observed(o) => Some(o),
145            BatchOutcome::Deleted(_) => None,
146        }
147    }
148
149    #[must_use]
150    pub fn as_deleted(&self) -> Option<&DeleteOutcome> {
151        match self {
152            BatchOutcome::Deleted(d) => Some(d),
153            BatchOutcome::Observed(_) => None,
154        }
155    }
156}
157
158/// §5.4 step 14: a line starting with `<<<<<<<` **and** a line starting with
159/// `>>>>>>>` (the reference's `/^<{7}/m && /^>{7}/m`; JavaScript's `^` in
160/// multiline mode matches after `\n`, `\r`, U+2028 and U+2029).
161#[must_use]
162pub fn has_conflict_markers(source: &str) -> bool {
163    let starts = |marker: &str| {
164        source
165            .split(['\n', '\r', '\u{2028}', '\u{2029}'])
166            .any(|line| line.starts_with(marker))
167    };
168    starts("<<<<<<<") && starts(">>>>>>>")
169}
170
171/// A member parsed and reconciled but not committed (pass 1) — what an
172/// [`Effect::IngestReconciled`](crate::effects::Effect) carries into the
173/// transaction.
174pub(crate) struct Prepared {
175    pub(crate) path: String,
176    pub(crate) source: String,
177    /// The doc row at `path`, live or tombstoned, or `None` when the path is new.
178    doc_id: Option<String>,
179    tree: BlockTree,
180    old_blocks: Vec<MatchBlock>,
181    new_blocks: Vec<MatchBlock>,
182    result: ReconcileResult,
183    /// Ids carried in by the cross-document phase.
184    cross_doc_ids: Vec<String>,
185}
186
187impl Prepared {
188    /// §5.4 step 3 for this member — assign the matcher's ids onto the body
189    /// (nothing mints: every key is assigned) — and the [`IngestPlan`] that
190    /// commits it with the given provenance.
191    pub(crate) fn plan<'a>(
192        &'a self,
193        minter: &mut Mint<'_>,
194        origin: Origin,
195        actor: Option<&'a str>,
196        reason: Option<&'a str>,
197        conflicted: Option<bool>,
198    ) -> Result<IngestPlan<'a>> {
199        let (_, rest) = split_frontmatter(&self.tree);
200        let assigned = assign_from_map(rest, &self.result.assignment, minter)?;
201        Ok(IngestPlan {
202            path: &self.path,
203            source: &self.source,
204            tree: &self.tree,
205            assigned,
206            dispositions: disposition_rows(&self.result),
207            deleted: self.result.deleted.clone(),
208            consumed_pool: self.result.consumed_pool.clone(),
209            cross_doc_ids: self.cross_doc_ids.clone(),
210            origin,
211            actor,
212            reason,
213            conflicted,
214        })
215    }
216}
217
218enum Pending {
219    Echo(ObserveOutcome),
220    Gone {
221        path: String,
222        doc_id: Option<String>,
223        old_hash_hex: Option<String>,
224        old_blocks: Vec<MatchBlock>,
225    },
226    Ingest {
227        prepared: Box<Prepared>,
228        old_hash_hex: Option<String>,
229        new_hash_hex: String,
230        /// The path had a pending row the file now supersedes: delete it in
231        /// the member's transaction (§5.7).
232        clear_pending: bool,
233    },
234}
235
236/// The frontmatter block (when the first block is one) and the body.
237fn split_frontmatter(tree: &BlockTree) -> (Option<&Block>, &[Block]) {
238    match tree.children.first() {
239        Some(b) if b.kind == BlockKind::Frontmatter => (Some(b), &tree.children[1..]),
240        _ => (None, &tree.children[..]),
241    }
242}
243
244/// §5.1 step 4: parse, load the old tree (regardless of tombstone), offer the
245/// pool minus `consumed` when a doc row exists, reconcile, mint `b` ids.
246#[allow(clippy::too_many_arguments)]
247fn prepare_reconcile(
248    conn: &Connection,
249    minter: &mut Mint<'_>,
250    repo_id: &str,
251    path: &str,
252    source: &str,
253    config: &Config,
254    pool: &[PoolEntry],
255    consumed: &mut HashSet<String>,
256) -> Result<Prepared> {
257    let doc_id: Option<String> = conn
258        .query_row(
259            "SELECT doc_id FROM docs WHERE repo_id = ?1 AND path = ?2",
260            params![repo_id, path],
261            |r| r.get(0),
262        )
263        .optional()?;
264    // The format the path selects decides the parser (`spec/properties` §1;
265    // the reference's `adapterForPath`): `.yaml`/`.yml` and `.json` files
266    // are their own block trees, everything else is Markdown.
267    let tree = parse_path(path, source);
268    let old_blocks = match &doc_id {
269        Some(id) => load_old_match_blocks(conn, id)?,
270        None => Vec::new(),
271    };
272    let new_blocks = flatten(&FlatSource::from_tree(&tree, None));
273    let offered: Vec<PoolEntry> = if doc_id.is_some() {
274        pool.iter()
275            .filter(|c| !consumed.contains(&c.id))
276            .cloned()
277            .collect()
278    } else {
279        Vec::new()
280    };
281    let mut mint = minter.deferred("b");
282    let result = reconcile_document(
283        &old_blocks,
284        &new_blocks,
285        Options {
286            config,
287            pool: &offered,
288            minter: &mut mint,
289        },
290    );
291    mint.finish()?;
292    consumed.extend(result.consumed_pool.iter().cloned());
293    Ok(Prepared {
294        path: path.to_owned(),
295        source: source.to_owned(),
296        doc_id,
297        tree,
298        old_blocks,
299        new_blocks,
300        result,
301        cross_doc_ids: Vec::new(),
302    })
303}
304
305/// §5.3: pool the leftovers, match across documents, apply.
306fn cross_doc_phase(pending: &mut [Pending], config: &Config) {
307    let mut docs: Vec<PerDocUnmatched> = Vec::new();
308    let mut results: BTreeMap<String, ReconcileResult> = BTreeMap::new();
309    let mut key_of: Vec<Option<String>> = vec![None; pending.len()];
310    for (i, p) in pending.iter_mut().enumerate() {
311        match p {
312            Pending::Echo(_) | Pending::Gone { doc_id: None, .. } => {}
313            Pending::Gone {
314                doc_id: Some(doc_id),
315                old_blocks,
316                ..
317            } => {
318                docs.push(PerDocUnmatched {
319                    doc_id: doc_id.clone(),
320                    deleted: old_blocks.clone(),
321                    inserted: Vec::new(),
322                });
323                results.insert(
324                    doc_id.clone(),
325                    ReconcileResult {
326                        deleted: old_blocks.iter().filter_map(|b| b.id.clone()).collect(),
327                        ..ReconcileResult::default()
328                    },
329                );
330            }
331            Pending::Ingest { prepared, .. } => {
332                let key = prepared
333                    .doc_id
334                    .clone()
335                    .unwrap_or_else(|| format!("new:{}", prepared.path));
336                let key_of_id: BTreeMap<&str, &str> = prepared
337                    .result
338                    .assignment
339                    .iter()
340                    .map(|(k, id)| (id.as_str(), k.as_str()))
341                    .collect();
342                let deleted = prepared
343                    .result
344                    .deleted
345                    .iter()
346                    .filter_map(|id| {
347                        prepared
348                            .old_blocks
349                            .iter()
350                            .find(|b| b.id.as_deref() == Some(id))
351                    })
352                    .cloned()
353                    .collect();
354                let inserted = prepared
355                    .result
356                    .dispositions
357                    .iter()
358                    .filter(|d| d.kind == DispositionKind::Inserted)
359                    .filter_map(|d| {
360                        let k = key_of_id.get(d.block_id.as_str())?;
361                        let block = prepared.new_blocks.iter().find(|b| &b.key == k)?;
362                        Some(Inserted {
363                            block: block.clone(),
364                            minted_id: d.block_id.clone(),
365                        })
366                    })
367                    .collect();
368                docs.push(PerDocUnmatched {
369                    doc_id: key.clone(),
370                    deleted,
371                    inserted,
372                });
373                results.insert(key.clone(), std::mem::take(&mut prepared.result));
374                key_of[i] = Some(key);
375            }
376        }
377    }
378    let matches = if docs.len() < 2 {
379        Vec::new()
380    } else {
381        cross_doc_match(&docs, config)
382    };
383    if !matches.is_empty() {
384        apply_cross_doc_matches(&mut results, &matches, &config.matcher_v);
385    }
386    for (i, p) in pending.iter_mut().enumerate() {
387        if let (Pending::Ingest { prepared, .. }, Some(key)) = (p, &key_of[i]) {
388            prepared.result = results
389                .remove(key)
390                .expect("every prepared member was keyed");
391            for m in &matches {
392                if &m.to_doc == key {
393                    prepared.cross_doc_ids.push(m.carried_id.clone());
394                }
395            }
396        }
397    }
398}
399
400struct BlockRow {
401    block_id: String,
402    parent_block: Option<String>,
403    order_key: String,
404    ordinal: i64,
405    depth: i64,
406    ancestor_path: String,
407    kind: String,
408    attrs: String,
409    text: String,
410    raw_hash: [u8; 32],
411    norm_hash: [u8; 32],
412    trivia_hash: Option<[u8; 32]>,
413}
414
415/// The `blocks` rows of a body tree in pre-order (§4.3, §5.4 step 9).
416fn flatten_rows(
417    blocks: &[TreeInputBlock],
418    parent: Option<&str>,
419    depth: i64,
420    ancestor_path: &str,
421    out: &mut Vec<BlockRow>,
422) {
423    let mut prev_key: Option<String> = None;
424    for (ordinal, b) in blocks.iter().enumerate() {
425        let order_key = key_between(prev_key.as_deref(), None);
426        prev_key = Some(order_key.clone());
427        out.push(BlockRow {
428            block_id: b.block_id.clone(),
429            parent_block: parent.map(str::to_owned),
430            order_key,
431            ordinal: ordinal as i64,
432            depth,
433            ancestor_path: ancestor_path.to_owned(),
434            kind: b.kind.clone(),
435            attrs: canonical_attrs(&b.attrs),
436            text: b.text.clone(),
437            raw_hash: sha256(b.raw.as_bytes()),
438            norm_hash: sha256(b.text.as_bytes()),
439            trivia_hash: (!b.trivia.is_empty()).then(|| sha256(b.trivia.as_bytes())),
440        });
441        if !b.children.is_empty() {
442            flatten_rows(
443                &b.children,
444                Some(&b.block_id),
445                depth + 1,
446                &format!("{ancestor_path}{}/", b.block_id),
447                out,
448            );
449        }
450    }
451}
452
453/// §5.4 step 8: delete another document's `blocks` row for each id (its FTS
454/// entry first when it is a live leaf, `spec/search` §1.1 — leaf-ness decided
455/// over the table before any row goes, a parent left childless re-indexed)
456/// and the id's pool row.
457fn evict_foreign_block_rows(conn: &Connection, doc_id: &str, ids: &[String]) -> Result<()> {
458    let mut rows = Vec::new();
459    for id in ids {
460        let row: Option<EvictRow> = conn
461            .query_row(
462                "SELECT rowid, block_id, doc_id, parent_block, text, deleted_commit IS NULL
463                 FROM blocks WHERE block_id = ?1 AND doc_id != ?2",
464                params![id, doc_id],
465                |r| {
466                    Ok(EvictRow {
467                        rowid: r.get(0)?,
468                        block_id: r.get(1)?,
469                        doc_id: r.get(2)?,
470                        parent_block: r.get(3)?,
471                        text: r.get(4)?,
472                        live: r.get(5)?,
473                    })
474                },
475            )
476            .optional()?;
477        if let Some(row) = row {
478            rows.push(row);
479        }
480    }
481    fts_before_evict_rows(conn, &rows)?;
482    for r in &rows {
483        conn.execute(
484            "DELETE FROM blocks WHERE block_id = ?1 AND doc_id != ?2",
485            params![r.block_id, doc_id],
486        )?;
487    }
488    for id in ids {
489        conn.execute(
490            "DELETE FROM resurrection_pool WHERE block_id = ?1",
491            params![id],
492        )?;
493    }
494    Ok(())
495}
496
497/// What §5.4 steps 1–13 produce: the committed document, commit and
498/// revision, and whether the bytes converged (step 13).
499#[derive(Clone, Debug, PartialEq, Eq)]
500pub struct Committed {
501    pub doc_id: String,
502    pub commit_id: String,
503    pub rev_id: String,
504    pub converged: bool,
505}
506
507/// A disposition to persist (§5.4 step 10): the matcher's, or the `api`
508/// intent rows of `spec/mutate` §4 (`matcher_v` null, `detail` `{}`).
509#[derive(Clone, Debug, PartialEq)]
510pub(crate) struct DispositionRow {
511    pub block_id: String,
512    pub kind: String,
513    pub confidence: Option<f64>,
514    pub reason: Option<String>,
515    pub matcher_v: Option<String>,
516    /// JSON text.
517    pub detail: String,
518}
519
520/// Everything §5.4 needs to commit one document: the parsed tree and the
521/// id-assigned body, the identity decisions, and the commit row's provenance.
522pub(crate) struct IngestPlan<'a> {
523    pub path: &'a str,
524    pub source: &'a str,
525    pub tree: &'a BlockTree,
526    pub assigned: Vec<TreeInputBlock>,
527    pub dispositions: Vec<DispositionRow>,
528    /// Pooled (step 7).
529    pub deleted: Vec<String>,
530    pub consumed_pool: Vec<String>,
531    pub cross_doc_ids: Vec<String>,
532    pub origin: Origin,
533    pub actor: Option<&'a str>,
534    pub reason: Option<&'a str>,
535    /// `docs.conflicted` for this commit: `Some` on the observed path (§5.4
536    /// step 12 writes it with the pointers, in the same transaction — the
537    /// reference's `observeFile` sets it from the conflict-marker scan),
538    /// `None` leaves the column as it is (`api` ingests, the reconciling and
539    /// fresh ingests of the sync layer).
540    pub conflicted: Option<bool>,
541}
542
543/// §5.4 step 10 (14.2): which verdicts the store persists — every kind but
544/// `same`.
545#[must_use]
546pub fn persists_disposition(kind: &str) -> bool {
547    kind != "same"
548}
549
550/// §5.4 (14.2): kind → count over every verdict of a reconcile result, `same`
551/// included — what the observe outcome reports. A `BTreeMap` orders the kinds
552/// bytewise, as `ORDER BY kind` did through 14.1.
553fn count_kinds(result: &ReconcileResult) -> BTreeMap<String, u64> {
554    let mut counts = BTreeMap::new();
555    for d in &result.dispositions {
556        *counts.entry(d.kind.as_str().to_owned()).or_insert(0) += 1;
557    }
558    counts
559}
560
561fn disposition_rows(result: &ReconcileResult) -> Vec<DispositionRow> {
562    result
563        .dispositions
564        .iter()
565        .map(|d| DispositionRow {
566            block_id: d.block_id.clone(),
567            kind: d.kind.as_str().to_owned(),
568            confidence: d.confidence,
569            reason: d.reason.map(|r| r.as_str().to_owned()),
570            matcher_v: Some(d.matcher_v.clone()),
571            detail: detail_to_json(&d.detail).to_string(),
572        })
573        .collect()
574}
575
576/// §5.4 steps 0–4 and 6–13 for one document against an open transaction
577/// `tx` and the commit row `commit_id` already written in it (step 5 is the
578/// caller's: [`Store::commit_ingest`] writes one row per document, the `api`
579/// path of [`crate::effects`] one row per request — store 13.7). Mints `d`
580/// for a new path, `r`, then `x`/`e` through `mint`, which must be the
581/// store's minter over `tx`. Nothing here opens a transaction of its own.
582pub(crate) fn ingest_in(
583    tx: &Connection,
584    mint: &mut Mint<'_>,
585    repo_id: &str,
586    plan: &IngestPlan<'_>,
587    expires: &str,
588    commit_id: &str,
589) -> Result<Committed> {
590    let minter = mint;
591    let tree = plan.tree;
592    let source = plan.source;
593    let (fm_block, _) = split_frontmatter(tree);
594    // `docs.format`: decided by the path's extension (`spec/properties` §1,
595    // `spec/store` `docs.format`) — `markdown`, `yaml` or `json`.
596    let format = format_for_path(plan.path);
597    let markdown = format == FORMAT_MARKDOWN;
598
599    let fm_trivia = fm_block.map(|b| b.trivia.as_str());
600
601    // 2. Doc row (the path's row regardless of tombstone; a re-created
602    //    path is revived — §5.6 "Re-creation"). Steps 0 and 1 follow: they
603    //    need the doc id to find the previous revision.
604    let existing: Option<(String, Option<String>)> = tx
605        .query_row(
606            "SELECT doc_id, deleted_commit FROM docs WHERE repo_id = ?1 AND path = ?2",
607            params![repo_id, plan.path],
608            |r| Ok((r.get(0)?, r.get(1)?)),
609        )
610        .optional()?;
611    let doc_id = match existing {
612        Some((id, deleted_commit)) => {
613            tx.execute(
614                "UPDATE docs SET format = ?1, leading_trivia = ?2, frontmatter_trivia = ?3, deleted_commit = NULL WHERE doc_id = ?4",
615                params![format, tree.leading_trivia, fm_trivia, id],
616            )?;
617            // spec/graph §3.5: a revived row becomes live again, so the
618            // phantom edges that accrued at its path while it was
619            // tombstoned re-point to it.
620            if deleted_commit.is_some() {
621                adopt_phantoms(tx, plan.path, &id)?;
622            }
623            id
624        }
625        None => {
626            let id = minter.mint("d")?;
627            tx.execute(
628                "INSERT INTO docs (doc_id, repo_id, path, format, leading_trivia, frontmatter_trivia) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
629                params![id, repo_id, plan.path, format, tree.leading_trivia, fm_trivia],
630            )?;
631            // spec/graph §3.5: a new row at this path adopts the open
632            // phantom edges that pointed at it (the reference runs this
633            // right after the INSERT; nothing is minted).
634            adopt_phantoms(tx, plan.path, &id)?;
635            id
636        }
637    };
638
639    // 0. The revision's place (§4.4, 16.0): its seq decides whether it is a
640    //    checkpoint; a non-checkpoint revision writes its tree and frontmatter
641    //    as deltas against the previous revision's RESOLVED tree and raw where
642    //    that is smaller. The previous revision is the document's greatest seq
643    //    — on every path the current revision, never archived (retention rule
644    //    1) — but the rule is stated for the general case: no live predecessor
645    //    ⇒ a checkpoint.
646    let previous = previous_revision(tx, &doc_id)?;
647    let seq = previous.as_ref().map_or(1, |p| p.seq + 1);
648    let checkpoint = is_checkpoint_revision(seq, previous.as_ref());
649    let mut resolver = TreeResolver::new(tx);
650    let base = match &previous {
651        Some(p) if !checkpoint => match &p.root_tree {
652            Some(root) => Some(resolver.tree(&doc_id, p.seq, &hex(root))?),
653            None => None,
654        },
655        _ => None,
656    };
657    let tree_ctx = TreeWriteContext { base, checkpoint };
658
659    // 1. Frontmatter blob: the raw at a checkpoint, else the smaller of a line
660    //    delta over the previous revision's frontmatter and the raw (§5.4 step 1).
661    let fm_blob_hex = match fm_block {
662        Some(b) => {
663            let base_raw = match &previous {
664                Some(p) if !checkpoint => match &p.frontmatter_blob {
665                    Some(fm) => Some(resolver.frontmatter(&doc_id, p.seq, &hex(fm))?),
666                    None => None,
667                },
668                _ => None,
669            };
670            Some(write_frontmatter_blob(
671                tx,
672                &b.raw,
673                base_raw.as_deref(),
674                checkpoint,
675            )?)
676        }
677        None => None,
678    };
679
680    // 4. Write the tree.
681    let assigned = &plan.assigned;
682    let root_tree_hex = write_block_tree_in(tx, assigned, &tree_ctx)?;
683
684    // 6. The revision row (the commit row, step 5, is the caller's).
685    let rendered_hash = sha256(source.as_bytes());
686    let (rev_id, _) = write_revision(
687        tx,
688        minter,
689        &NewRevision {
690            doc_id: &doc_id,
691            root_tree_hex: &root_tree_hex,
692            frontmatter_blob_hex: fm_blob_hex.as_deref(),
693            rendered_hash,
694            path: plan.path,
695            commit_id,
696            seq: Some(seq),
697        },
698    )?;
699
700    // 7. Pool the deleted.
701    {
702        let mut pool = tx.prepare(
703            "INSERT OR REPLACE INTO resurrection_pool (block_id, repo_id, doc_id, raw_hash, norm_hash, type, deleted_commit, expires_ts)
704             SELECT block_id, repo_id, doc_id, raw_hash, norm_hash, type, ?1, ?2 FROM blocks WHERE block_id = ?3 AND doc_id = ?4",
705        )?;
706        for id in &plan.deleted {
707            pool.execute(params![commit_id, expires, id, doc_id])?;
708        }
709    }
710
711    // 8. Evict foreign rows.
712    let incoming: Vec<String> = plan
713        .cross_doc_ids
714        .iter()
715        .chain(plan.consumed_pool.iter())
716        .cloned()
717        .collect();
718    if !incoming.is_empty() {
719        evict_foreign_block_rows(tx, &doc_id, &incoming)?;
720    }
721
722    // 9. Refresh the blocks, sections and FTS.
723    fts_delete_doc(tx, &doc_id)?;
724    tx.execute("DELETE FROM blocks WHERE doc_id = ?1", params![doc_id])?;
725    let mut rows = Vec::new();
726    flatten_rows(assigned, None, 0, "/", &mut rows);
727    {
728        let mut insert = tx.prepare(
729            "INSERT INTO blocks
730               (block_id, repo_id, doc_id, parent_block, order_key, ordinal, depth,
731                ancestor_path, type, attrs, text, raw_hash, norm_hash, trivia_hash, created_commit)
732             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
733        )?;
734        for r in &rows {
735            insert.execute(params![
736                r.block_id,
737                repo_id,
738                doc_id,
739                r.parent_block,
740                r.order_key,
741                r.ordinal,
742                r.depth,
743                r.ancestor_path,
744                r.kind,
745                r.attrs,
746                r.text,
747                &r.raw_hash[..],
748                &r.norm_hash[..],
749                r.trivia_hash.as_ref().map(|h| &h[..]),
750                commit_id,
751            ])?;
752        }
753    }
754    fts_index_doc(tx, &doc_id)?;
755    rebuild_sections(tx, &doc_id)?;
756
757    // 9a. Nodes (spec/graph §2): the Markdown adapter's projections over the
758    //     assigned body, then the `md:section` nodes from the sections just
759    //     rebuilt; deleted (FTS first) and reinserted. A `yaml`/`json`
760    //     document's blocks are not Markdown — the `md:*` projections do not
761    //     apply (the reference's adapters project kinds of their own that no
762    //     spec pins yet) — and it has no headings, so no sections.
763    let body = doc_blocks(assigned);
764    let mut nodes = if markdown {
765        project_nodes(&body)
766    } else {
767        Vec::new()
768    };
769    nodes.extend(project_section_nodes(tx, &doc_id)?);
770    write_doc_nodes(tx, repo_id, &doc_id, &nodes)?;
771
772    // 9b. Properties (spec/properties §6): the document's rows — from the
773    //     frontmatter block and the assigned body for Markdown; from the
774    //     whole-document mapping alone for `yaml`/`json` (§3.4, 1.2) —
775    //     deleted then written.
776    let property_rows = document_properties(&doc_id, format, source, fm_block, &body);
777    write_doc_properties(tx, repo_id, &doc_id, commit_id, &property_rows)?;
778
779    // 9c. Edges (spec/graph §3): descriptors from the blocks in pre-order
780    //     then the frontmatter mapping (the same parse the properties step
781    //     uses; none when it fails), resolved in order (**mints `x`** per
782    //     new external URI), then the intervals (**mints `e`** per new
783    //     edge) and the rollup. For a `yaml`/`json` document the mapping is
784    //     the whole document's (§3.4) and its blocks, not being Markdown, are
785    //     not scanned for links: the frontmatter relations are its edges.
786    let mapping = if markdown {
787        fm_block.and_then(|b| parse_frontmatter(frontmatter_yaml(&b.raw)))
788    } else {
789        document_mapping(format, source)
790    };
791    let descriptors = if markdown {
792        extract_doc_edges(&body, mapping.as_ref())
793    } else {
794        extract_doc_edges(&[], mapping.as_ref())
795    };
796    let resolved = resolve_edges(tx, minter, repo_id, &doc_id, plan.path, &descriptors)?;
797    maintain_edges(tx, minter, repo_id, &doc_id, commit_id, &resolved)?;
798
799    // 10. Dispositions and block_changes — every verdict but `same` (14.2).
800    //     The matcher (and the known-id resolver) still produce one per
801    //     untouched block, and the outcome counts them, but the two tables
802    //     record what CHANGED: a block's rows are its own history, so
803    //     `MAX(commits.ts)` over its `block_changes` and `blocks_history`
804    //     are the block's changes, not its siblings'. Every other kind — the
805    //     first revision's `inserted` rows the mint oracle (§2.1) depends on
806    //     included — is written as before.
807    {
808        let mut ins = tx.prepare(
809            "INSERT OR IGNORE INTO dispositions (commit_id, block_id, kind, confidence, reason, matcher_v, detail)
810             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
811        )?;
812        let mut bc = tx.prepare(
813            "INSERT OR IGNORE INTO block_changes (block_id, commit_id, kind) VALUES (?1, ?2, ?3)",
814        )?;
815        for d in plan
816            .dispositions
817            .iter()
818            .filter(|d| persists_disposition(&d.kind))
819        {
820            ins.execute(params![
821                commit_id,
822                d.block_id,
823                d.kind,
824                d.confidence,
825                d.reason,
826                d.matcher_v,
827                d.detail,
828            ])?;
829            bc.execute(params![d.block_id, commit_id, d.kind])?;
830        }
831    }
832
833    // 11. Consume the pool.
834    for id in &plan.consumed_pool {
835        tx.execute(
836            "DELETE FROM resurrection_pool WHERE block_id = ?1",
837            params![id],
838        )?;
839    }
840
841    // 12. Pointers — and, on the observed path, the conflict flag, so
842    //     the row the commit leaves behind is consistent as a whole (an
843    //     `api` plan leaves `conflicted` as it is).
844    match plan.conflicted {
845        Some(conflicted) => tx.execute(
846            "UPDATE docs SET current_rev = ?1, file_hash = ?2, conflicted = ?3 WHERE doc_id = ?4",
847            params![rev_id, &rendered_hash[..], i64::from(conflicted), doc_id],
848        )?,
849        None => tx.execute(
850            "UPDATE docs SET current_rev = ?1, file_hash = ?2 WHERE doc_id = ?3",
851            params![rev_id, &rendered_hash[..], doc_id],
852        )?,
853    };
854
855    // 13. Converged: file_hash == rendered_hash (trivially), render(tree) ==
856    //     source, reconstruct(doc) == source.
857    let converged = render(tree) == source && reconstruct(tx, &doc_id)?.as_deref() == Some(source);
858
859    Ok(Committed {
860        doc_id,
861        commit_id: commit_id.to_owned(),
862        rev_id,
863        converged,
864    })
865}
866
867/// §5.6's tombstone against an open transaction and its commit row: with
868/// `expires`, every live block is pooled first (the observed deletion);
869/// without, nothing is (`docs_delete`, `spec/mutate` §6). Then the FTS rows
870/// go, the live blocks and the doc row take `deleted_commit`.
871pub(crate) fn tombstone_in(
872    tx: &Connection,
873    doc_id: &str,
874    commit_id: &str,
875    expires: Option<&str>,
876) -> Result<()> {
877    if let Some(expires) = expires {
878        tx.execute(
879            "INSERT OR REPLACE INTO resurrection_pool (block_id, repo_id, doc_id, raw_hash, norm_hash, type, deleted_commit, expires_ts)
880             SELECT block_id, repo_id, doc_id, raw_hash, norm_hash, type, ?1, ?2
881               FROM blocks WHERE doc_id = ?3 AND deleted_commit IS NULL",
882            params![commit_id, expires, doc_id],
883        )?;
884    }
885    fts_delete_doc(tx, doc_id)?;
886    tx.execute(
887        "UPDATE blocks SET deleted_commit = ?1 WHERE doc_id = ?2 AND deleted_commit IS NULL",
888        params![commit_id, doc_id],
889    )?;
890    tx.execute(
891        "UPDATE docs SET deleted_commit = ?1 WHERE doc_id = ?2",
892        params![commit_id, doc_id],
893    )?;
894    Ok(())
895}
896
897impl Store {
898    /// §5: observe a batch of members at `ts` (RFC 3339 UTC, §2.4) with the
899    /// `spec/reconcile` thresholds in `config`. Does **not** sweep the pool
900    /// (§5.5: the caller does, once per batch).
901    pub fn observe_batch(
902        &mut self,
903        repo_id: &str,
904        items: &[BatchItem],
905        ts: &str,
906        config: &Config,
907    ) -> Result<Vec<BatchOutcome>> {
908        let expires = pool_expiry(ts)?;
909
910        // ---- pass 1: echo gate + reconcile, no writes ------------------------------
911        let pool = load_pool(&self.conn, repo_id, ts)?;
912        let mut consumed: HashSet<String> = HashSet::new();
913        let mut pending: Vec<Pending> = Vec::with_capacity(items.len());
914        for it in items {
915            let existing: Option<(String, Option<Vec<u8>>)> = self
916                .conn
917                .query_row(
918                    "SELECT doc_id, file_hash FROM docs WHERE repo_id = ?1 AND path = ?2 AND deleted_commit IS NULL",
919                    params![repo_id, it.path],
920                    |r| Ok((r.get(0)?, r.get(1)?)),
921                )
922                .optional()?;
923            let old_hash_hex = existing.as_ref().and_then(|(_, h)| h.as_deref()).map(hex);
924            // §5.7: the intent an `api` commit still owes this path, if any.
925            let intent = pending_at(&self.conn, repo_id, &it.path)?;
926            let Some(source) = &it.source else {
927                match &intent {
928                    // The file the materializer owes is not here yet: not a
929                    // deletion; the row stays for it.
930                    Some(row) if row.kind != PendingKind::Remove => {
931                        pending.push(Pending::Gone {
932                            path: it.path.clone(),
933                            doc_id: None,
934                            old_hash_hex: None,
935                            old_blocks: Vec::new(),
936                        });
937                        continue;
938                    }
939                    // A `remove` whose file is gone: fulfilled.
940                    Some(_) => delete_pending(&self.conn, repo_id, &it.path)?,
941                    None => {}
942                }
943                let (doc_id, old_blocks) = match &existing {
944                    Some((id, _)) => (Some(id.clone()), load_old_match_blocks(&self.conn, id)?),
945                    None => (None, Vec::new()),
946                };
947                pending.push(Pending::Gone {
948                    path: it.path.clone(),
949                    doc_id,
950                    old_hash_hex,
951                    old_blocks,
952                });
953                continue;
954            };
955            let hash = sha256(source.as_bytes());
956            let new_hash_hex = hex(&hash);
957            let echo = |doc_id: &str| {
958                Pending::Echo(ObserveOutcome {
959                    path: it.path.clone(),
960                    doc_id: doc_id.to_owned(),
961                    rev: None,
962                    commit_id: None,
963                    converged: true,
964                    echo: true,
965                    conflicted: false,
966                    dispositions: BTreeMap::new(),
967                    old_hash_hex: old_hash_hex.clone(),
968                    new_hash_hex: new_hash_hex.clone(),
969                })
970            };
971            if let Some(row) = &intent {
972                if row.rendered_hash[..] == hash[..] {
973                    // The bytes the commit meant: the observation confirms
974                    // the write (the row goes) — or, for a `remove`, finds
975                    // the very file the materializer will unlink (the row
976                    // stays; nothing to ingest).
977                    if row.kind != PendingKind::Remove {
978                        delete_pending(&self.conn, repo_id, &it.path)?;
979                    }
980                    let doc_id = existing
981                        .as_ref()
982                        .map_or(row.doc_id.as_str(), |(id, _)| id.as_str());
983                    pending.push(echo(doc_id));
984                    continue;
985                }
986                // Foreign bytes: the file wins; the normal ingest below
987                // clears the row in its transaction — unless the database
988                // already agrees with the file (a row naming other bytes is
989                // stale either way; the observation settles it).
990                if let Some((doc_id, Some(stored))) = &existing {
991                    if stored[..] == hash[..] {
992                        delete_pending(&self.conn, repo_id, &it.path)?;
993                        pending.push(echo(doc_id));
994                        continue;
995                    }
996                }
997            } else if let Some((doc_id, Some(stored))) = &existing {
998                if stored[..] == hash[..] {
999                    pending.push(echo(doc_id));
1000                    continue;
1001                }
1002            }
1003            let clear_pending = intent.is_some();
1004            let prepared = prepare_reconcile(
1005                &self.conn,
1006                &mut self.ids.at(&self.conn),
1007                repo_id,
1008                &it.path,
1009                source,
1010                config,
1011                &pool,
1012                &mut consumed,
1013            )?;
1014            pending.push(Pending::Ingest {
1015                prepared: Box::new(prepared),
1016                old_hash_hex,
1017                new_hash_hex,
1018                clear_pending,
1019            });
1020        }
1021
1022        // ---- cross-document phase (spec/reconcile §7) --------------------------------
1023        if pending.len() > 1 {
1024            cross_doc_phase(&mut pending, config);
1025        }
1026
1027        // ---- pass 2: commit, in batch order -------------------------------------------
1028        let mut out = Vec::with_capacity(pending.len());
1029        for p in pending {
1030            match p {
1031                Pending::Echo(outcome) => out.push(BatchOutcome::Observed(outcome)),
1032                Pending::Gone {
1033                    path,
1034                    doc_id,
1035                    old_hash_hex,
1036                    ..
1037                } => {
1038                    if let Some(id) = &doc_id {
1039                        self.tombstone_observed_deletion(repo_id, id, ts, &expires)?;
1040                    }
1041                    out.push(BatchOutcome::Deleted(DeleteOutcome {
1042                        path,
1043                        doc_id,
1044                        old_hash_hex,
1045                    }));
1046                }
1047                Pending::Ingest {
1048                    prepared,
1049                    old_hash_hex,
1050                    new_hash_hex,
1051                    clear_pending,
1052                } => {
1053                    // The flag rides the commit's transaction (step 12), not
1054                    // an autocommit after it; so does the pending row's end.
1055                    let conflicted = has_conflict_markers(&prepared.source);
1056                    let c = self.commit_prepared(
1057                        repo_id,
1058                        &prepared,
1059                        ts,
1060                        &expires,
1061                        conflicted,
1062                        clear_pending,
1063                    )?;
1064                    // §5.4 (14.2): the outcome counts every verdict the
1065                    // matcher produced, `same` included — it describes what
1066                    // the matcher saw; the table describes what changed
1067                    // (step 10 drops `same`) — so the counts come from the
1068                    // in-memory result, never the rows. Kinds in bytewise
1069                    // order, as the table query listed them through 14.1.
1070                    let dispositions = count_kinds(&prepared.result);
1071                    out.push(BatchOutcome::Observed(ObserveOutcome {
1072                        path: prepared.path,
1073                        doc_id: c.doc_id,
1074                        rev: Some(c.rev_id),
1075                        commit_id: Some(c.commit_id),
1076                        converged: c.converged,
1077                        echo: false,
1078                        conflicted,
1079                        dispositions,
1080                        old_hash_hex,
1081                        new_hash_hex,
1082                    }));
1083                }
1084            }
1085        }
1086        Ok(out)
1087    }
1088
1089    /// §5.4 steps 1–13 for a prepared (reconciled) member: an `observed`
1090    /// commit with the matcher's dispositions, `docs.conflicted` set to
1091    /// `conflicted` in the same transaction — and, with `clear_pending`,
1092    /// the path's pending row deleted in it too (§5.7: the file won).
1093    fn commit_prepared(
1094        &mut self,
1095        repo_id: &str,
1096        prepared: &Prepared,
1097        ts: &str,
1098        expires: &str,
1099        conflicted: bool,
1100        clear_pending: bool,
1101    ) -> Result<Committed> {
1102        let plan = prepared.plan(
1103            &mut self.ids.at(&self.conn),
1104            Origin::Observed,
1105            None,
1106            None,
1107            Some(conflicted),
1108        )?;
1109        self.commit_ingest(
1110            repo_id,
1111            &plan,
1112            ts,
1113            expires,
1114            clear_pending.then_some(prepared.path.as_str()),
1115        )
1116    }
1117
1118    /// §5.1 step 4 for one path outside a batch: the repo's pool at `ts`
1119    /// offered, the member parsed and reconciled, `b` ids minted — nothing
1120    /// written. What [`Store::reconciling_ingest`] commits, and what a
1121    /// document operation hands to [`Store::commit_effects`].
1122    pub(crate) fn prepare_single(
1123        &mut self,
1124        repo_id: &str,
1125        path: &str,
1126        source: &str,
1127        ts: &str,
1128        config: &Config,
1129    ) -> Result<Prepared> {
1130        let pool = load_pool(&self.conn, repo_id, ts)?;
1131        let mut consumed = HashSet::new();
1132        prepare_reconcile(
1133            &self.conn,
1134            &mut self.ids.at(&self.conn),
1135            repo_id,
1136            path,
1137            source,
1138            config,
1139            &pool,
1140            &mut consumed,
1141        )
1142    }
1143
1144    /// Parse and reconcile `source` at `path` against the stored tree (the
1145    /// pool offered when a doc row exists, §5.1 step 4) and commit it with
1146    /// the given provenance — the reference's `ingestFile` with
1147    /// `makeReconcilingResolver`: `docs_create`/`docs_set_meta` (`api`) and
1148    /// the file-CAS conflict ingest of `spec/mutate` §4 (`observed`). No echo
1149    /// gate, no `conflicted` update, no sweep. The sync layer's recovery and
1150    /// `attach_source` (`spec/sync` §4.3, §6) ingest through this too, with
1151    /// `Origin::Observed`.
1152    #[allow(clippy::too_many_arguments)]
1153    pub fn reconciling_ingest(
1154        &mut self,
1155        repo_id: &str,
1156        path: &str,
1157        source: &str,
1158        ts: &str,
1159        origin: Origin,
1160        actor: Option<&str>,
1161        reason: Option<&str>,
1162        config: &Config,
1163    ) -> Result<Committed> {
1164        let head = CommitHead {
1165            origin,
1166            actor,
1167            reason,
1168            clear_pending: false,
1169            ops: None,
1170        };
1171        self.reconciling_commit(repo_id, path, source, ts, &head, config, Guard::None)?
1172            .committed()?
1173            .pop()
1174            .ok_or_else(|| {
1175                Error::Other(format!("reconciling_ingest: nothing committed for {path}"))
1176            })
1177    }
1178
1179    /// [`Store::reconciling_ingest`] with a precondition the transaction
1180    /// re-validates (`spec/mutate` §6, 2.2): `docs_create` guards "no live
1181    /// document at the path", `docs_set_meta` the document's triple and the
1182    /// bytes it patched. Returns the commit's [`Outcome`] — a disk conflict
1183    /// is the caller's to ingest as `observed`.
1184    #[allow(clippy::too_many_arguments)]
1185    pub(crate) fn reconciling_commit(
1186        &mut self,
1187        repo_id: &str,
1188        path: &str,
1189        source: &str,
1190        ts: &str,
1191        head: &CommitHead<'_>,
1192        config: &Config,
1193        guard: Guard,
1194    ) -> Result<Outcome> {
1195        let prepared = self.prepare_single(repo_id, path, source, ts, config)?;
1196        self.commit_effects(
1197            repo_id,
1198            head,
1199            &[Effect::IngestReconciled {
1200                prepared: Box::new(prepared),
1201                guard,
1202            }],
1203            ts,
1204        )
1205    }
1206
1207    /// Parse `source` at `path` and commit it with a **fresh `b` id for every
1208    /// block** — the reference's `ingestFile` without a resolver (no
1209    /// dispositions, nothing pooled): the re-mint path `spec/sync` §6 pins for
1210    /// a `borne` source's `attach_source` (§9). No echo gate, no sweep.
1211    pub fn fresh_ingest(
1212        &mut self,
1213        repo_id: &str,
1214        path: &str,
1215        source: &str,
1216        ts: &str,
1217        origin: Origin,
1218    ) -> Result<Committed> {
1219        let expires = pool_expiry(ts)?;
1220        let tree = parse_path(path, source);
1221        let (_, rest) = split_frontmatter(&tree);
1222        let assigned = assign_fresh_ids(rest, &mut self.ids.at(&self.conn))?;
1223        let plan = IngestPlan {
1224            path,
1225            source,
1226            tree: &tree,
1227            assigned,
1228            dispositions: Vec::new(),
1229            deleted: Vec::new(),
1230            consumed_pool: Vec::new(),
1231            cross_doc_ids: Vec::new(),
1232            origin,
1233            actor: None,
1234            reason: None,
1235            conflicted: None,
1236        };
1237        self.commit_ingest(repo_id, &plan, ts, &expires, None)
1238    }
1239
1240    /// §5.4 steps 1–13 in one transaction of their own, under a commit row
1241    /// of their own (the reference's `ingestFile`): BEGIN, step 5, then
1242    /// [`ingest_in`], the pending row at `clear_pending_at` deleted (§5.7),
1243    /// COMMIT. The observed path and the fresh ingest commit through this;
1244    /// the `api` path runs N [`ingest_in`]s under one row via
1245    /// [`Store::commit_effects`](crate::effects).
1246    pub(crate) fn commit_ingest(
1247        &mut self,
1248        repo_id: &str,
1249        plan: &IngestPlan<'_>,
1250        ts: &str,
1251        expires: &str,
1252        clear_pending_at: Option<&str>,
1253    ) -> Result<Committed> {
1254        let tx = self.conn.unchecked_transaction()?;
1255        let committed = {
1256            let mut mint = self.ids.at(&tx);
1257            // 5. The commit row.
1258            let (commit_id, _) = new_commit(
1259                &tx,
1260                &mut mint,
1261                &NewCommit {
1262                    repo_id,
1263                    ts,
1264                    origin: plan.origin,
1265                    actor: plan.actor,
1266                    reason: plan.reason,
1267                    checkpoint_id: None,
1268                    ops: None,
1269                },
1270            )?;
1271            let committed = ingest_in(&tx, &mut mint, repo_id, plan, expires, &commit_id)?;
1272            if let Some(path) = clear_pending_at {
1273                delete_pending(&tx, repo_id, path)?;
1274            }
1275            committed
1276        };
1277        tx.commit()?;
1278        Ok(committed)
1279    }
1280
1281    /// §5.6: tombstone a live doc whose path is gone, in one transaction of
1282    /// its own: an `observed deletion` commit (mints `c`), then
1283    /// [`tombstone_in`] with the pool. Returns the commit id.
1284    pub(crate) fn tombstone_observed_deletion(
1285        &mut self,
1286        repo_id: &str,
1287        doc_id: &str,
1288        ts: &str,
1289        expires: &str,
1290    ) -> Result<String> {
1291        let tx = self.conn.unchecked_transaction()?;
1292        let commit_id = {
1293            let mut mint = self.ids.at(&tx);
1294            let (commit_id, _) = new_commit(
1295                &tx,
1296                &mut mint,
1297                &NewCommit {
1298                    reason: Some("observed deletion"),
1299                    ..NewCommit::observed(repo_id, ts)
1300                },
1301            )?;
1302            tombstone_in(&tx, doc_id, &commit_id, Some(expires))?;
1303            commit_id
1304        };
1305        tx.commit()?;
1306        Ok(commit_id)
1307    }
1308
1309    /// A batch of one (§5: "the same procedure without the middle"). Does not
1310    /// sweep the pool.
1311    pub fn observe_one(
1312        &mut self,
1313        repo_id: &str,
1314        path: &str,
1315        source: &str,
1316        ts: &str,
1317        config: &Config,
1318    ) -> Result<ObserveOutcome> {
1319        let mut out =
1320            self.observe_batch(repo_id, &[BatchItem::observed(path, source)], ts, config)?;
1321        match out.pop() {
1322            Some(BatchOutcome::Observed(o)) => Ok(o),
1323            _ => Err(Error::Other(format!(
1324                "observe_one: unexpected outcome for {path}"
1325            ))),
1326        }
1327    }
1328
1329    /// Observe that `path` is gone (§5.6) and sweep the pool at `ts` (§5.5),
1330    /// as the reference's `observeDelete` does. A path with no live doc is a
1331    /// no-op (`doc_id: None`); so is a path whose file an `api` commit still
1332    /// owes (§5.7: a `write`/`rename` row — the materializer, not a
1333    /// tombstone, answers for it).
1334    pub fn observe_delete(&mut self, repo_id: &str, path: &str, ts: &str) -> Result<DeleteOutcome> {
1335        let expires = pool_expiry(ts)?;
1336        match pending_at(&self.conn, repo_id, path)? {
1337            Some(row) if row.kind != PendingKind::Remove => {
1338                return Ok(DeleteOutcome {
1339                    path: path.to_owned(),
1340                    doc_id: None,
1341                    old_hash_hex: None,
1342                });
1343            }
1344            Some(_) => delete_pending(&self.conn, repo_id, path)?,
1345            None => {}
1346        }
1347        let existing: Option<(String, Option<Vec<u8>>)> = self
1348            .conn
1349            .query_row(
1350                "SELECT doc_id, file_hash FROM docs WHERE repo_id = ?1 AND path = ?2 AND deleted_commit IS NULL",
1351                params![repo_id, path],
1352                |r| Ok((r.get(0)?, r.get(1)?)),
1353            )
1354            .optional()?;
1355        let Some((doc_id, file_hash)) = existing else {
1356            return Ok(DeleteOutcome {
1357                path: path.to_owned(),
1358                doc_id: None,
1359                old_hash_hex: None,
1360            });
1361        };
1362        self.tombstone_observed_deletion(repo_id, &doc_id, ts, &expires)?;
1363        sweep_pool(&self.conn, ts)?;
1364        Ok(DeleteOutcome {
1365            path: path.to_owned(),
1366            doc_id: Some(doc_id),
1367            old_hash_hex: file_hash.as_deref().map(hex),
1368        })
1369    }
1370}
1371
1372#[cfg(test)]
1373mod tests {
1374    use super::*;
1375    use omgbase_format::parse_markdown;
1376
1377    #[test]
1378    fn conflict_markers_need_both_sides_at_line_starts() {
1379        assert!(has_conflict_markers(
1380            "<<<<<<< HEAD\na\n=======\nb\n>>>>>>> branch\n"
1381        ));
1382        assert!(!has_conflict_markers("<<<<<<< HEAD\na\n=======\nb\n"));
1383        assert!(!has_conflict_markers("a <<<<<<< b\n>>>>>>> c\n"));
1384        assert!(has_conflict_markers(">>>>>>> c\r<<<<<<< b"));
1385        assert!(has_conflict_markers("x\u{2028}<<<<<<< a\u{2029}>>>>>>> b"));
1386        assert!(!has_conflict_markers(""));
1387    }
1388
1389    #[test]
1390    fn frontmatter_is_split_only_when_first() {
1391        let tree = parse_markdown("---\na: 1\n---\n\n# H\n");
1392        let (fm, rest) = split_frontmatter(&tree);
1393        assert_eq!(fm.map(|b| b.kind), Some(BlockKind::Frontmatter));
1394        assert_eq!(rest.len(), 1);
1395        let tree = parse_markdown("# H\n");
1396        let (fm, rest) = split_frontmatter(&tree);
1397        assert!(fm.is_none());
1398        assert_eq!(rest.len(), 1);
1399    }
1400}