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