Skip to main content

omgbase_store/
plan.rs

1//! The whole-document update planner (`spec/mutate/README.md` §7):
2//! reconcile a proposed complete document against the stored tree (no pool),
3//! lower to kernel ops, verify by a dry-run apply, fall back to a full
4//! replace; and `apply_opset` with its preconditions.
5
6use omgbase_format::hash::{hex, sha256};
7use omgbase_format::{Block, BlockKind, parse_markdown};
8use omgbase_mutate::{
9    ErrorCode, LowerResult, MutationError, Op, Opset, OpsetPrecondition, lower_replace,
10    lower_top_level, render, summarize,
11};
12use omgbase_reconcile::{Config, FlatSource, Options, flatten, reconcile_document};
13use rusqlite::{OptionalExtension, params};
14use serde_json::json;
15
16use crate::Store;
17use crate::doc_store::{DocStore, NullDocStore};
18use crate::error::{Error, Result};
19use crate::mutate::{
20    ApplyOrigin, ApplyRequest, ApplyResult, SetFrontmatter, find_doc_by_ref, load_mut_doc,
21};
22use crate::read::load_old_match_blocks;
23
24enum Verified {
25    Ok,
26    Diverged { actual: String },
27    Threw(String),
28}
29
30/// §7 step 4: name the first differing byte and the proposed block covering it.
31fn describe_divergence(v: &Verified, proposed: &[Block], expected: &str) -> String {
32    match v {
33        Verified::Ok => "converged".to_owned(),
34        Verified::Threw(e) => format!("simulation threw {e}"),
35        Verified::Diverged { actual } => {
36            let a = actual.as_bytes();
37            let e = expected.as_bytes();
38            let n = a.len().min(e.len());
39            let mut off = 0;
40            while off < n && a[off] == e[off] {
41                off += 1;
42            }
43            let idx = proposed
44                .iter()
45                .position(|b| off >= b.span.start && off < b.span.end + b.trivia.len());
46            let where_ = match idx {
47                Some(i) => format!(
48                    "proposed block #{i} ({}, bytes {}-{})",
49                    proposed[i].kind.as_str(),
50                    proposed[i].span.start,
51                    proposed[i].span.end
52                ),
53                None if off >= expected.len() => "past the end of the proposed content".to_owned(),
54                None => "leading trivia".to_owned(),
55            };
56            let snippet = |s: &str| -> String {
57                let mut start = off.min(s.len());
58                while !s.is_char_boundary(start) {
59                    start -= 1;
60                }
61                let piece: String = s[start..].chars().take(24).collect();
62                serde_json::to_string(&piece).unwrap_or_default()
63            };
64            format!(
65                "first divergence at byte {off} in {where_}: expected {}, rendered {}",
66                snippet(expected),
67                snippet(actual)
68            )
69        }
70    }
71}
72
73impl Store {
74    /// §7 `plan_update`: the opset for replacing `doc_ref`'s content with
75    /// `content`. Never writes; mints (the reconcile's new ids, the dry-run
76    /// verification's) as the reference does.
77    pub fn plan_update(
78        &mut self,
79        repo_id: &str,
80        doc_ref: &str,
81        content: &str,
82        config: &Config,
83    ) -> Result<Opset> {
84        let info = find_doc_by_ref(&self.conn, repo_id, doc_ref)?.ok_or_else(|| {
85            Error::from(MutationError::with_data(
86                ErrorCode::DocMissing,
87                format!("doc {doc_ref} not found"),
88                json!({ "doc": doc_ref }),
89            ))
90        })?;
91        let old_doc = load_mut_doc(&self.conn, &info.doc_id)?.ok_or_else(|| {
92            Error::from(MutationError::with_data(
93                ErrorCode::DocMissing,
94                format!("doc {doc_ref} not found"),
95                json!({ "doc": doc_ref }),
96            ))
97        })?;
98        let current_content = render(&old_doc);
99        let base_content_hash = hex(&sha256(current_content.as_bytes()));
100
101        let tree = parse_markdown(content);
102        let (fm_block, rest): (Option<&Block>, &[Block]) = match tree.children.first() {
103            Some(b) if b.kind == BlockKind::Frontmatter => (Some(b), &tree.children[1..]),
104            _ => (None, &tree.children[..]),
105        };
106        let old_match = load_old_match_blocks(&self.conn, &info.doc_id)?;
107        let new_match = flatten(&FlatSource::from_blocks(rest, None));
108        let mut minter = self.ids.at(&self.conn);
109        let mut mint = minter.deferred("b");
110        let result = reconcile_document(
111            &old_match,
112            &new_match,
113            Options {
114                config,
115                pool: &[],
116                minter: &mut mint,
117            },
118        );
119        mint.finish()?;
120
121        let target_fm: Option<String> = fm_block.map(|b| format!("{}{}", b.raw, b.trivia));
122        let fm_changed = target_fm != old_doc.frontmatter_raw;
123        let set_frontmatter: Vec<SetFrontmatter> = if fm_changed {
124            vec![SetFrontmatter {
125                doc: info.doc_id.clone(),
126                raw: target_fm.clone(),
127            }]
128        } else {
129            Vec::new()
130        };
131
132        let mut diagnostics: Vec<String> = Vec::new();
133        let t2 = lower_top_level(&old_doc, rest, &result.assignment, &result.dispositions);
134        let v2 = self.verify(
135            repo_id,
136            &info.path,
137            &t2,
138            &set_frontmatter,
139            content,
140            &current_content,
141        );
142        let (chosen, converges) = if matches!(v2, Verified::Ok) {
143            (t2, true)
144        } else {
145            diagnostics.push(format!(
146                "top-level lowering did not reproduce the proposed content byte-for-byte ({}); falling back to full replace",
147                describe_divergence(&v2, &tree.children, content)
148            ));
149            let t3 = lower_replace(&old_doc, content);
150            let v3 = self.verify(
151                repo_id,
152                &info.path,
153                &t3,
154                &set_frontmatter,
155                content,
156                &current_content,
157            );
158            if matches!(v3, Verified::Ok) {
159                diagnostics
160                    .push("full-replace plan converges (block identity not preserved)".to_owned());
161                (t3, true)
162            } else {
163                diagnostics.push(format!(
164                    "no lowering reproduced the proposed content exactly (full replace: {}); plan will not apply — inspect and re-plan",
165                    describe_divergence(&v3, &tree.children, content)
166                ));
167                (t2, false)
168            }
169        };
170        let summary = summarize(&chosen.ops, chosen.preserved, chosen.ambiguous);
171        Ok(Opset {
172            target_doc: info.doc_id.clone(),
173            target_path: info.path.clone(),
174            precondition: OpsetPrecondition {
175                doc: info.doc_id,
176                path: info.path,
177                base_revision: info.current_rev,
178                base_content_hash,
179            },
180            matcher_v: config.matcher_v.clone(),
181            ops: chosen.ops,
182            frontmatter: fm_changed.then_some(target_fm),
183            summary,
184            converges,
185            diagnostics,
186        })
187    }
188
189    /// Simulate a lowering by a dry-run apply; the rendered result must equal
190    /// the proposed content exactly.
191    fn verify(
192        &mut self,
193        repo_id: &str,
194        path: &str,
195        lowering: &LowerResult,
196        set_frontmatter: &[SetFrontmatter],
197        expected: &str,
198        current_content: &str,
199    ) -> Verified {
200        let req = ApplyRequest {
201            repo_id: repo_id.to_owned(),
202            ops: lowering
203                .ops
204                .iter()
205                .map(|p| p.op.clone())
206                .collect::<Vec<Op>>(),
207            origin: ApplyOrigin::new("plan:verify", None),
208            dry_run: true,
209            set_frontmatter: set_frontmatter.to_vec(),
210        };
211        let mut null = NullDocStore;
212        match self.apply(&req, &mut null, "1970-01-01T00:00:00.000Z") {
213            Ok(res) => {
214                let after = res
215                    .diffs
216                    .as_ref()
217                    .and_then(|d| d.iter().find(|(p, _)| p == path))
218                    .map_or(current_content, |(_, diff)| diff.after.as_str());
219                if after == expected {
220                    Verified::Ok
221                } else {
222                    Verified::Diverged {
223                        actual: after.to_owned(),
224                    }
225                }
226            }
227            Err(Error::Mutation(e)) => Verified::Threw(format!("{}: {}", e.code, e.message)),
228            Err(e) => Verified::Threw(e.to_string()),
229        }
230    }
231
232    /// §7.2 `apply_opset`: refuse a non-convergent plan, check the
233    /// preconditions (`stale_plan` with the current revision and hash), then
234    /// apply the plan's kernel ops with its frontmatter override.
235    pub fn apply_opset(
236        &mut self,
237        repo_id: &str,
238        opset: &Opset,
239        origin: &ApplyOrigin,
240        dry_run: bool,
241        doc_store: &mut dyn DocStore,
242        ts: &str,
243    ) -> Result<ApplyResult> {
244        if !opset.converges {
245            let why = opset
246                .diagnostics
247                .last()
248                .map_or(String::new(), |d| format!(" — {d}"));
249            return Err(MutationError::with_data(
250                ErrorCode::PlanNotConvergent,
251                format!("opset does not reproduce the proposed content; re-plan{why}"),
252                json!({ "diagnostics": opset.diagnostics }),
253            )
254            .into());
255        }
256        let row: Option<(Option<String>, Option<Vec<u8>>)> = self
257            .conn
258            .query_row(
259                "SELECT current_rev, file_hash FROM docs WHERE doc_id = ?1 AND deleted_commit IS NULL",
260                params![opset.precondition.doc],
261                |r| Ok((r.get(0)?, r.get(1)?)),
262            )
263            .optional()?;
264        let Some((current_rev, file_hash)) = row else {
265            return Err(MutationError::with_data(
266                ErrorCode::DocMissing,
267                format!("doc {} not found", opset.precondition.doc),
268                json!({ "doc": opset.precondition.doc }),
269            )
270            .into());
271        };
272        let fh = file_hash.map(|h| hex(&h));
273        let stale = (opset.precondition.base_revision.is_some()
274            && current_rev != opset.precondition.base_revision)
275            || fh
276                .as_deref()
277                .is_some_and(|h| h != opset.precondition.base_content_hash);
278        if stale {
279            return Err(MutationError::with_data(
280                ErrorCode::StalePlan,
281                "document changed since the plan was computed; re-plan",
282                json!({
283                    "doc": opset.precondition.doc,
284                    "expected_revision": opset.precondition.base_revision,
285                    "current": { "revision": current_rev, "content_hash": fh },
286                    "retriable": true,
287                }),
288            )
289            .into());
290        }
291        let req = ApplyRequest {
292            repo_id: repo_id.to_owned(),
293            ops: opset.kernel_ops(),
294            origin: origin.clone(),
295            dry_run,
296            set_frontmatter: match &opset.frontmatter {
297                Some(raw) => vec![SetFrontmatter {
298                    doc: opset.precondition.doc.clone(),
299                    raw: raw.clone(),
300                }],
301                None => Vec::new(),
302            },
303        };
304        self.apply(&req, doc_store, ts)
305    }
306
307    /// `docs_update`: plan then apply (`None` result on a dry run).
308    #[allow(clippy::too_many_arguments)]
309    pub fn docs_update(
310        &mut self,
311        repo_id: &str,
312        doc_ref: &str,
313        content: &str,
314        config: &Config,
315        origin: &ApplyOrigin,
316        dry_run: bool,
317        doc_store: &mut dyn DocStore,
318        ts: &str,
319    ) -> Result<(Opset, Option<ApplyResult>)> {
320        let opset = self.plan_update(repo_id, doc_ref, content, config)?;
321        if dry_run {
322            return Ok((opset, None));
323        }
324        let result = self.apply_opset(repo_id, &opset, origin, false, doc_store, ts)?;
325        Ok((opset, Some(result)))
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn divergence_descriptions() {
335        let tree = parse_markdown("# Title\n\nAlpha.\n");
336        let v = Verified::Diverged {
337            actual: "# Title\n\nAlphb.\n".to_owned(),
338        };
339        assert_eq!(
340            describe_divergence(&v, &tree.children, "# Title\n\nAlpha.\n"),
341            "first divergence at byte 13 in proposed block #1 (paragraph, bytes 9-15): expected \"a.\\n\", rendered \"b.\\n\""
342        );
343        let lead = parse_markdown("\n\n# Title\n");
344        let v = Verified::Diverged {
345            actual: "# Title\n".to_owned(),
346        };
347        assert_eq!(
348            describe_divergence(&v, &lead.children, "\n\n# Title\n"),
349            "first divergence at byte 0 in leading trivia: expected \"\\n\\n# Title\\n\", rendered \"# Title\\n\""
350        );
351        let v = Verified::Diverged {
352            actual: "# Title\n\nAlpha.\n\nmore".to_owned(),
353        };
354        assert!(
355            describe_divergence(&v, &tree.children, "# Title\n\nAlpha.\n").contains("past the end")
356        );
357        assert_eq!(
358            describe_divergence(
359                &Verified::Threw("doc_missing: doc d_0 not found".into()),
360                &[],
361                ""
362            ),
363            "simulation threw doc_missing: doc d_0 not found"
364        );
365        assert_eq!(describe_divergence(&Verified::Ok, &[], ""), "converged");
366    }
367}