Skip to main content

core_api/
schema.rs

1//! Declarative schema-as-code for mushroomdb.
2//!
3//! [`Schema`] is a serde-JSON-round-trippable description of the fulltext
4//! indexes, materialized views, and linking rules that should exist in a
5//! database.  [`GraphDb::apply_schema`] applies the schema idempotently:
6//! items already matching the live database are left untouched (no WAL write),
7//! items that differ are replaced (delete + create), and items absent from the
8//! schema are left in place (no pruning — destructive removal is out of scope
9//! for this plan).
10//!
11//! # Application order
12//!
13//! 1. **Fulltext** indexes are applied first: rules may later benefit from
14//!    freshly-enabled fulltext state during backfill, even though the current
15//!    rule predicates do not require it.
16//! 2. **Views** are applied second: they are cheaper to backfill than rules
17//!    and logically independent of rules.
18//! 3. **Rules** are applied last.  Creating a rule triggers a full backfill
19//!    of derived edges, which can be expensive for large graphs — document the
20//!    cost at the call site.
21//!
22//! # Update semantics
23//!
24//! When a schema item (rule or view) exists in the database but its definition
25//! differs from the one in the schema, it is replaced via `delete_X` +
26//! `create_X`.  For rules, the `create_rule` call triggers a full backfill of
27//! derived edges — this can be expensive for large graphs.  The re-backfill
28//! cost is inherent to any definition change (the old edge set may not be
29//! valid under the new predicate), so there is no cheaper path in v1.
30//!
31//! # No pruning
32//!
33//! Items that live in the database but are absent from the schema are left
34//! untouched.  Destructive removal ("prune items not in the schema") waits for
35//! explicit demand — YAGNI for this plan.
36
37use crate::roles::RoleDef;
38use crate::{GraphDb, Result, RuleDef, ViewDef};
39use core_storage::{fs::Fs, GraphError};
40use serde::{Deserialize, Serialize};
41
42// ---------------------------------------------------------------------------
43// Public types
44// ---------------------------------------------------------------------------
45
46/// A declarative description of the schema that should exist in a database.
47///
48/// All lists default to empty when absent from JSON, so a partial schema
49/// that names only rules (for example) is valid.
50#[derive(Serialize, Deserialize, Clone, Default, Debug)]
51pub struct Schema {
52    /// Fulltext index declarations as `(label, field)` pairs.
53    #[serde(default)]
54    pub fulltext: Vec<(String, String)>,
55    /// Linking rule definitions.
56    #[serde(default)]
57    pub rules: Vec<RuleDef>,
58    /// Materialized view definitions.
59    #[serde(default)]
60    pub views: Vec<ViewDef>,
61    /// RBAC role definitions. Persisted as `roles.json` sidecar on apply.
62    #[serde(default)]
63    pub roles: Vec<RoleDef>,
64}
65
66/// The outcome of a single [`GraphDb::apply_schema`] call.
67///
68/// Entry names are namespaced: `"rule:NAME"`, `"view:NAME"`,
69/// `"fulltext:LABEL.FIELD"`.
70#[derive(Debug, PartialEq)]
71pub struct SchemaDiff {
72    /// Items that did not exist and were created.
73    pub created: Vec<String>,
74    /// Items that existed but whose definition differed; replaced via
75    /// delete + create.
76    pub updated: Vec<String>,
77    /// Items that already matched the live database; no WAL writes made.
78    pub unchanged: Vec<String>,
79}
80
81// ---------------------------------------------------------------------------
82// apply_schema implementation
83// ---------------------------------------------------------------------------
84
85impl<F: Fs> GraphDb<F> {
86    /// Apply `schema` to the database idempotently.
87    ///
88    /// Returns a [`SchemaDiff`] describing what was created, updated, or left
89    /// unchanged.  The diff is in application order: fulltext, then views,
90    /// then rules.
91    ///
92    /// Items absent from `schema` but present in the database are left
93    /// untouched (no pruning).
94    ///
95    /// # Atomicity of validation
96    ///
97    /// All rules and views that would be created or updated are validated
98    /// **before** any mutation is made.  If any definition is invalid, the
99    /// function returns `Err` without touching the database.  This prevents
100    /// the partial-application hazard where the old item is already deleted
101    /// before the invalid replacement fails.
102    ///
103    /// # Update cost
104    ///
105    /// Updating a rule (definition differs) triggers `delete_rule` +
106    /// `create_rule`.  The `create_rule` call runs a full backfill of derived
107    /// edges.  This can be expensive for large graphs; prefer stable rule
108    /// definitions in production.
109    pub fn apply_schema(&mut self, schema: &Schema) -> Result<SchemaDiff> {
110        // Pre-validation pass: validate every rule, view, and role that would be
111        // created or updated, before touching the database.  Unchanged items
112        // are already valid (they passed validation when first created).
113        let live_views = self.views();
114        let live_rules = self.rules();
115
116        // Reject duplicate names within the submitted schema before any mutation.
117        {
118            let mut seen_rules = std::collections::HashSet::new();
119            for rule_def in &schema.rules {
120                if !seen_rules.insert(rule_def.name.as_str()) {
121                    return Err(GraphError::RuleInvalid {
122                        detail: format!("duplicate rule name in schema: {}", rule_def.name),
123                    });
124                }
125            }
126            let mut seen_views = std::collections::HashSet::new();
127            for view_def in &schema.views {
128                if !seen_views.insert(view_def.name.as_str()) {
129                    return Err(GraphError::RuleInvalid {
130                        detail: format!("duplicate view name in schema: {}", view_def.name),
131                    });
132                }
133            }
134        }
135
136        for view_def in &schema.views {
137            let would_mutate = live_views
138                .iter()
139                .find(|v| v.name == view_def.name)
140                .is_none_or(|live| live != view_def);
141            if would_mutate {
142                view_def
143                    .validate()
144                    .map_err(|e| GraphError::RuleInvalid { detail: e })?;
145            }
146        }
147
148        for rule_def in &schema.rules {
149            let would_mutate = live_rules
150                .iter()
151                .find(|r| r.name == rule_def.name)
152                .is_none_or(|live| live != rule_def);
153            if would_mutate {
154                rule_def
155                    .validate()
156                    .map_err(|e| GraphError::RuleInvalid { detail: e })?;
157            }
158        }
159
160        // Validate roles: non-empty names, unique names within the schema.
161        {
162            let mut seen = std::collections::HashSet::new();
163            for role_def in &schema.roles {
164                if role_def.name.is_empty() {
165                    return Err(GraphError::RuleInvalid {
166                        detail: "role name must not be empty".into(),
167                    });
168                }
169                if !seen.insert(role_def.name.as_str()) {
170                    return Err(GraphError::RuleInvalid {
171                        detail: format!("duplicate role name: {}", role_def.name),
172                    });
173                }
174            }
175        }
176
177        // Mutation pass — all definitions are known-valid from here.
178        let mut created = Vec::new();
179        let mut updated = Vec::new();
180        let mut unchanged = Vec::new();
181
182        // 1. Fulltext indexes.
183        for (label, field) in &schema.fulltext {
184            let key = format!("fulltext:{label}.{field}");
185            if self.is_fulltext_enabled(label, field) {
186                unchanged.push(key);
187            } else {
188                self.enable_fulltext(label, field)?;
189                created.push(key);
190            }
191        }
192
193        // 2. Views.
194        for view_def in &schema.views {
195            let key = format!("view:{}", view_def.name);
196            if let Some(live) = live_views.iter().find(|v| v.name == view_def.name) {
197                if live == view_def {
198                    unchanged.push(key);
199                } else {
200                    // Delete + create to pick up the new definition.
201                    self.delete_view(&view_def.name)?;
202                    self.create_view(view_def.clone())?;
203                    updated.push(key);
204                }
205            } else {
206                self.create_view(view_def.clone())?;
207                created.push(key);
208            }
209        }
210
211        // 3. Rules — creating a rule triggers a full backfill (see module doc).
212        for rule_def in &schema.rules {
213            let key = format!("rule:{}", rule_def.name);
214            if let Some(live) = live_rules.iter().find(|r| r.name == rule_def.name) {
215                if live == rule_def {
216                    unchanged.push(key);
217                } else {
218                    // Delete + create; create_rule backfills all derived edges.
219                    self.delete_rule(&rule_def.name)?;
220                    self.create_rule(rule_def.clone())?;
221                    updated.push(key);
222                }
223            } else {
224                self.create_rule(rule_def.clone())?;
225                created.push(key);
226            }
227        }
228
229        // 4. Roles — sidecar only (no WAL records). Written atomically when
230        // any role is new or changed; unchanged roles leave the file untouched
231        // (byte-identical idempotency guarantee).
232        {
233            let live_roles = self.roles();
234            let mut new_roles: Vec<RoleDef> = live_roles.clone();
235            let mut roles_changed = false;
236
237            for role_def in &schema.roles {
238                let key = format!("role:{}", role_def.name);
239                if let Some(live) = live_roles.iter().find(|r| r.name == role_def.name) {
240                    if live == role_def {
241                        unchanged.push(key);
242                    } else {
243                        // Update the entry in new_roles.
244                        if let Some(slot) = new_roles.iter_mut().find(|r| r.name == role_def.name) {
245                            *slot = role_def.clone();
246                        }
247                        roles_changed = true;
248                        updated.push(key);
249                    }
250                } else {
251                    new_roles.push(role_def.clone());
252                    roles_changed = true;
253                    created.push(key);
254                }
255            }
256
257            if roles_changed {
258                self.commit_roles(new_roles)?;
259            }
260        }
261
262        Ok(SchemaDiff {
263            created,
264            updated,
265            unchanged,
266        })
267    }
268}