Skip to main content

omgbase_store/
observe.rs

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