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 /// Equality-index declarations as `(label, field)` pairs. Each enables an
56 /// exact-match index so `WHERE n.field = value` becomes an indexed lookup.
57 #[serde(default)]
58 pub indexes: Vec<(String, String)>,
59 /// Linking rule definitions.
60 #[serde(default)]
61 pub rules: Vec<RuleDef>,
62 /// Materialized view definitions.
63 #[serde(default)]
64 pub views: Vec<ViewDef>,
65 /// RBAC role definitions. Persisted as `roles.json` sidecar on apply.
66 #[serde(default)]
67 pub roles: Vec<RoleDef>,
68}
69
70/// The outcome of a single [`GraphDb::apply_schema`] call.
71///
72/// Entry names are namespaced: `"rule:NAME"`, `"view:NAME"`,
73/// `"fulltext:LABEL.FIELD"`.
74#[derive(Debug, PartialEq)]
75pub struct SchemaDiff {
76 /// Items that did not exist and were created.
77 pub created: Vec<String>,
78 /// Items that existed but whose definition differed; replaced via
79 /// delete + create.
80 pub updated: Vec<String>,
81 /// Items that already matched the live database; no WAL writes made.
82 pub unchanged: Vec<String>,
83}
84
85// ---------------------------------------------------------------------------
86// apply_schema implementation
87// ---------------------------------------------------------------------------
88
89impl<F: Fs> GraphDb<F> {
90 /// Apply `schema` to the database idempotently.
91 ///
92 /// Returns a [`SchemaDiff`] describing what was created, updated, or left
93 /// unchanged. The diff is in application order: fulltext, then views,
94 /// then rules.
95 ///
96 /// Items absent from `schema` but present in the database are left
97 /// untouched (no pruning).
98 ///
99 /// # Atomicity of validation
100 ///
101 /// All rules and views that would be created or updated are validated
102 /// **before** any mutation is made. If any definition is invalid, the
103 /// function returns `Err` without touching the database. This prevents
104 /// the partial-application hazard where the old item is already deleted
105 /// before the invalid replacement fails.
106 ///
107 /// # Update cost
108 ///
109 /// Updating a rule (definition differs) triggers `delete_rule` +
110 /// `create_rule`. The `create_rule` call runs a full backfill of derived
111 /// edges. This can be expensive for large graphs; prefer stable rule
112 /// definitions in production.
113 pub fn apply_schema(&mut self, schema: &Schema) -> Result<SchemaDiff> {
114 // Pre-validation pass: validate every rule, view, and role that would be
115 // created or updated, before touching the database. Unchanged items
116 // are already valid (they passed validation when first created).
117 let live_views = self.views();
118 let live_rules = self.rules();
119
120 // Reject duplicate names within the submitted schema before any mutation.
121 {
122 let mut seen_rules = std::collections::HashSet::new();
123 for rule_def in &schema.rules {
124 if !seen_rules.insert(rule_def.name.as_str()) {
125 return Err(GraphError::RuleInvalid {
126 detail: format!("duplicate rule name in schema: {}", rule_def.name),
127 });
128 }
129 }
130 let mut seen_views = std::collections::HashSet::new();
131 for view_def in &schema.views {
132 if !seen_views.insert(view_def.name.as_str()) {
133 return Err(GraphError::RuleInvalid {
134 detail: format!("duplicate view name in schema: {}", view_def.name),
135 });
136 }
137 }
138 }
139
140 for view_def in &schema.views {
141 let would_mutate = live_views
142 .iter()
143 .find(|v| v.name == view_def.name)
144 .is_none_or(|live| live != view_def);
145 if would_mutate {
146 view_def
147 .validate()
148 .map_err(|e| GraphError::RuleInvalid { detail: e })?;
149 }
150 }
151
152 for rule_def in &schema.rules {
153 let would_mutate = live_rules
154 .iter()
155 .find(|r| r.name == rule_def.name)
156 .is_none_or(|live| live != rule_def);
157 if would_mutate {
158 rule_def
159 .validate()
160 .map_err(|e| GraphError::RuleInvalid { detail: e })?;
161 }
162 }
163
164 // Validate roles: non-empty names, unique names within the schema,
165 // and write-scope subset rule (§7.1 ruling: create/update/delete_labels
166 // must each be a subset of the role's read labels).
167 {
168 let mut seen = std::collections::HashSet::new();
169 for role_def in &schema.roles {
170 if role_def.name.is_empty() {
171 return Err(GraphError::RuleInvalid {
172 detail: "role name must not be empty".into(),
173 });
174 }
175 if !seen.insert(role_def.name.as_str()) {
176 return Err(GraphError::RuleInvalid {
177 detail: format!("duplicate role name: {}", role_def.name),
178 });
179 }
180 if let Some(pred) = &role_def.visible_where {
181 pred.validate().map_err(|e| GraphError::RuleInvalid {
182 detail: format!("role '{}': {e}", role_def.name),
183 })?;
184 // A predicate narrows the label leg. With no labels there is
185 // nothing to narrow, so the role would quietly be its `keys`
186 // alone under a name that reads like a restriction.
187 if role_def.labels.is_empty() {
188 return Err(GraphError::RuleInvalid {
189 detail: format!(
190 "role '{}': visible_where narrows the labels leg and the role \
191 declares no labels",
192 role_def.name
193 ),
194 });
195 }
196 }
197 if let Some(write) = &role_def.write {
198 let read_labels: std::collections::HashSet<&str> =
199 role_def.labels.iter().map(String::as_str).collect();
200 // Check create_labels, update_labels, delete_labels — each must
201 // be a subset of the role's read labels (§7.1 subset ruling).
202 // create_edge_types and delete_edge_types are exempt (no read-scope
203 // analog for edge types).
204 for (field, labels) in [
205 ("create_labels", &write.create_labels),
206 ("update_labels", &write.update_labels),
207 ("delete_labels", &write.delete_labels),
208 ] {
209 for label in labels {
210 if !read_labels.contains(label.as_str()) {
211 return Err(GraphError::RuleInvalid {
212 detail: format!(
213 "role '{}': write scope {field} contains label '{}' \
214 that is not in the role's read labels (subset rule)",
215 role_def.name, label
216 ),
217 });
218 }
219 }
220 }
221 }
222 }
223 }
224
225 // Mutation pass — all definitions are known-valid from here.
226 let mut created = Vec::new();
227 let mut updated = Vec::new();
228 let mut unchanged = Vec::new();
229
230 // 1. Fulltext indexes.
231 for (label, field) in &schema.fulltext {
232 let key = format!("fulltext:{label}.{field}");
233 if self.is_fulltext_enabled(label, field) {
234 unchanged.push(key);
235 } else {
236 self.enable_fulltext(label, field)?;
237 created.push(key);
238 }
239 }
240
241 // 1b. Equality (property) indexes.
242 for (label, field) in &schema.indexes {
243 let key = format!("index:{label}.{field}");
244 if self.is_index_enabled(label, field) {
245 unchanged.push(key);
246 } else {
247 self.enable_index(label, field)?;
248 created.push(key);
249 }
250 }
251
252 // 2. Views.
253 for view_def in &schema.views {
254 let key = format!("view:{}", view_def.name);
255 if let Some(live) = live_views.iter().find(|v| v.name == view_def.name) {
256 if live == view_def {
257 unchanged.push(key);
258 } else {
259 // Delete + create to pick up the new definition.
260 self.delete_view(&view_def.name)?;
261 self.create_view(view_def.clone())?;
262 updated.push(key);
263 }
264 } else {
265 self.create_view(view_def.clone())?;
266 created.push(key);
267 }
268 }
269
270 // 3. Rules — creating a rule triggers a full backfill (see module doc).
271 for rule_def in &schema.rules {
272 let key = format!("rule:{}", rule_def.name);
273 if let Some(live) = live_rules.iter().find(|r| r.name == rule_def.name) {
274 if live == rule_def {
275 unchanged.push(key);
276 } else {
277 // Delete + create; create_rule backfills all derived edges.
278 self.delete_rule(&rule_def.name)?;
279 self.create_rule(rule_def.clone())?;
280 updated.push(key);
281 }
282 } else {
283 self.create_rule(rule_def.clone())?;
284 created.push(key);
285 }
286 }
287
288 // 4. Roles — sidecar only (no WAL records). Written atomically when
289 // any role is new or changed; unchanged roles leave the file untouched
290 // (byte-identical idempotency guarantee).
291 {
292 let live_roles = self.roles();
293 let mut new_roles: Vec<RoleDef> = live_roles.clone();
294 let mut roles_changed = false;
295
296 for role_def in &schema.roles {
297 let key = format!("role:{}", role_def.name);
298 if let Some(live) = live_roles.iter().find(|r| r.name == role_def.name) {
299 if live == role_def {
300 unchanged.push(key);
301 } else {
302 // Update the entry in new_roles.
303 if let Some(slot) = new_roles.iter_mut().find(|r| r.name == role_def.name) {
304 *slot = role_def.clone();
305 }
306 roles_changed = true;
307 updated.push(key);
308 }
309 } else {
310 new_roles.push(role_def.clone());
311 roles_changed = true;
312 created.push(key);
313 }
314 }
315
316 if roles_changed {
317 self.commit_roles(new_roles)?;
318 }
319 }
320
321 Ok(SchemaDiff {
322 created,
323 updated,
324 unchanged,
325 })
326 }
327}