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