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}