Skip to main content

opys_engine/store/
model.rs

1//! The reactive model pass: after user `--write` DML mutates the raw tables,
2//! react to the resulting *state delta* and materialize lifecycle cascades
3//! before the corpus is validated and flushed. The pass reads STATE, never the
4//! SQL text — so it is independent of how an edit was expressed (parsing SQL
5//! shapes is unreliable; a document either exists afterwards or it doesn't).
6//!
7//! Today the one cascade is **removal**: a `DELETE` that drops a `docs` row is
8//! treated as a lifecycle close — the id is reserved against reuse and every
9//! inbound reference to it is struck into a `~~title~~` tombstone, so no live
10//! reference is left dangling. The model pass and the validator (the `verify`
11//! gate in `commands/query.rs`) are deliberately separate seams.
12
13use crate::error::Result;
14use crate::project_config::ProjectConfig;
15use crate::refs;
16
17use super::{g_i64, g_str, IntoParam, Store};
18
19/// A load-time snapshot of `id -> (dkey, title)` for every live document, taken
20/// before a `--write` runs so the model pass can diff against it afterwards.
21pub type Baseline = Vec<(String, i64, String)>;
22
23impl Store {
24    /// Snapshot `id -> (dkey, title)` for every live document. Captured before
25    /// user DML so [`Store::cascade_removals`] can find what the edit removed
26    /// (and recover a removed doc's last-known title for its tombstone).
27    pub fn baseline(&mut self) -> Result<Baseline> {
28        let (_, rows) = self.select("SELECT dkey, id, title FROM docs", vec![])?;
29        Ok(rows
30            .iter()
31            .filter_map(|r| {
32                let dkey = g_i64(&r[0])?;
33                let id = g_str(&r[1])?;
34                let title = g_str(&r[2]).unwrap_or_default();
35                Some((id, dkey, title))
36            })
37            .collect())
38    }
39
40    /// React to a `--write` that removed documents: for each id present in
41    /// `baseline` but no longer in `docs`, purge any child rows a raw `DELETE
42    /// FROM docs` left behind, reserve the id against reuse, and strike every
43    /// inbound reference to it into a tombstone. Runs before the sync pass, the
44    /// verify gate, and flush.
45    pub fn cascade_removals(&mut self, pcfg: &ProjectConfig, baseline: &Baseline) -> Result<()> {
46        let present = self.doc_ids()?;
47        let removed: Vec<&(String, i64, String)> = baseline
48            .iter()
49            .filter(|(id, _, _)| !present.contains(id))
50            .collect();
51        if removed.is_empty() {
52            return Ok(());
53        }
54
55        // 1. Purge orphaned child rows: a raw `DELETE FROM docs WHERE …` only
56        //    removes the `docs` row, leaving this doc's tags/relations/fm_fields
57        //    behind. Clear them first so a removed doc's own outbound relations
58        //    can't masquerade as a live referrer in the inbound query below.
59        for (_, dkey, _) in &removed {
60            self.delete_doc(*dkey)?;
61        }
62
63        // 2. Reserve the id and strike inbound references to a tombstone.
64        for (id, _, title) in &removed {
65            self.reserve_id(id, title)?;
66            self.strike_inbound(pcfg, id, &refs::strike(title))?;
67        }
68        Ok(())
69    }
70
71    /// Reserve `id` (with its last-known `title`) in the used-id ledger so its
72    /// number is never reused, unless it is already reserved.
73    fn reserve_id(&mut self, id: &str, title: &str) -> Result<()> {
74        let already = self
75            .scalar(
76                "SELECT 1 FROM retired WHERE id = $1 LIMIT 1",
77                vec![id.into_param()],
78            )?
79            .is_some();
80        if already {
81            return Ok(());
82        }
83        self.retire_id(id, title)
84    }
85
86    /// Strike every live document's reference to `id` (in any relation map) into
87    /// the `struck` tombstone value. Only existing entries are struck — a doc
88    /// that never referenced `id` has nothing dangling and is left untouched.
89    fn strike_inbound(&mut self, pcfg: &ProjectConfig, id: &str, struck: &str) -> Result<()> {
90        let (_, rows) = self.select(
91            "SELECT DISTINCT dkey FROM relations WHERE ref_id = $1",
92            vec![id.into_param()],
93        )?;
94        let dkeys: Vec<i64> = rows.iter().filter_map(|r| g_i64(&r[0])).collect();
95        for k in dkeys {
96            let mut doc = self.doc(k)?;
97            let mut changed = false;
98            for field in refs::RELATION_FIELDS {
99                let mut entries = refs::parse_in(&doc.frontmatter, field);
100                if let Some(e) = entries.iter_mut().find(|(i, _)| i == id) {
101                    if e.1 != struck {
102                        e.1 = struck.to_string();
103                        refs::set_in(&mut doc.frontmatter, field, &entries);
104                        changed = true;
105                    }
106                }
107            }
108            if changed {
109                self.put_doc(pcfg, Some(k), &doc)?;
110            }
111        }
112        Ok(())
113    }
114}