Skip to main content

icydb_model/node/
mod.rs

1//! Schema node graph for validated canister/entity/type definitions.
2//!
3//! This module owns the typed node descriptors used by schema validation,
4//! derive code generation, and visitor traversal.
5
6mod arg;
7mod canister;
8mod constraint;
9mod def;
10mod entity;
11mod r#enum;
12mod field;
13mod index;
14mod item;
15mod list;
16mod map;
17mod newtype;
18mod normalizer;
19mod primary_key;
20mod record;
21mod relation;
22mod schema;
23mod set;
24mod store;
25mod tuple;
26mod r#type;
27mod validator;
28mod value;
29
30use crate::{
31    prelude::*,
32    visit::{Event, Visitor},
33};
34use std::any::Any;
35use thiserror::Error as ThisError;
36
37pub use arg::*;
38pub use canister::*;
39pub use constraint::*;
40pub use def::*;
41pub use entity::*;
42pub use r#enum::*;
43pub use field::*;
44pub use index::*;
45pub use item::*;
46pub use list::*;
47pub use map::*;
48pub use newtype::*;
49pub use normalizer::*;
50pub use primary_key::*;
51pub use record::*;
52pub use relation::*;
53pub use schema::*;
54pub use set::*;
55pub use store::*;
56pub use tuple::*;
57pub use r#type::*;
58pub use validator::*;
59pub use value::*;
60
61pub const APP_MEMORY_ID_MIN: u8 = 100;
62pub const APP_MEMORY_ID_MAX: u8 = 254;
63const RESERVED_INTERNAL_MEMORY_ID: u8 = u8::MAX;
64
65///
66/// NodeError
67///
68/// Error raised when schema-node lookup or downcasting crosses an invalid
69/// boundary.
70///
71
72#[derive(Debug, ThisError)]
73pub enum NodeError {
74    #[error("{0} is an incorrect node type")]
75    IncorrectNodeType(String),
76
77    #[error("path not found: {0}")]
78    PathNotFound(String),
79}
80
81///
82/// NODE TRAITS
83///
84
85///
86/// MacroNode
87///
88/// Shared trait implemented by every concrete schema node descriptor.
89/// `as_any` keeps type erasure and downcasting local to the schema-node
90/// boundary instead of leaking it into callers.
91///
92
93pub(crate) trait MacroNode: Any {
94    fn as_any(&self) -> &dyn Any;
95}
96
97///
98/// ValidateNode
99///
100/// Trait implemented by schema nodes that validate local invariants against
101/// the surrounding schema graph.
102///
103
104pub(crate) trait ValidateNode {
105    fn validate(&self) -> Result<(), ErrorTree> {
106        Ok(())
107    }
108}
109
110///
111/// VisitableNode
112///
113/// Trait implemented by schema nodes that participate in recursive visitor
114/// traversal with canonical route-key ordering.
115///
116
117pub(crate) trait VisitableNode: ValidateNode {
118    // Route key contributes one node-local path segment to the visitor path.
119    fn route_key(&self) -> String {
120        String::new()
121    }
122
123    // Drive the enter/children/exit visitor sequence for this node.
124    fn accept<V: Visitor>(&self, visitor: &mut V) {
125        visitor.push(&self.route_key());
126        visitor.visit(self, Event::Enter);
127        self.drive(visitor);
128        visitor.visit(self, Event::Exit);
129        visitor.pop();
130    }
131
132    // Visit child nodes in canonical order.
133    fn drive<V: Visitor>(&self, _: &mut V) {}
134}
135
136// Add one source-protocol name construction failure to the authoring diagnostic tree.
137pub(crate) fn validate_source_name<K>(
138    errs: &mut ErrorTree,
139    kind: &str,
140    name: &str,
141    constructor: impl FnOnce(String) -> Result<K, icydb_schema::SchemaContractError>,
142) {
143    if let Err(error) = constructor(name.to_string()) {
144        err!(errs, "invalid {kind} name '{name}': {error}");
145    }
146}
147
148// Validate one memory id against the declared canister range.
149pub(crate) fn validate_memory_id_in_range(
150    errs: &mut ErrorTree,
151    label: &str,
152    memory_id: u8,
153    min: u8,
154    max: u8,
155) {
156    if !memory_id_is_in_range(memory_id, min, max) {
157        err!(errs, "{label} {memory_id} outside of range {min}-{max}");
158    }
159}
160
161// Reject memory id values reserved by stable-structures internals.
162pub(crate) fn validate_memory_id_not_reserved(errs: &mut ErrorTree, label: &str, memory_id: u8) {
163    if memory_id_is_reserved(memory_id) {
164        err!(
165            errs,
166            "{label} {memory_id} is reserved for stable-structures internals",
167        );
168    }
169}
170
171// Validate one application-owned memory id against IcyDB's generated-store range.
172pub(crate) fn validate_app_memory_id(errs: &mut ErrorTree, label: &str, memory_id: u8) {
173    if !app_memory_id_is_valid(memory_id) {
174        err!(
175            errs,
176            "{label} {memory_id} outside of application memory range {APP_MEMORY_ID_MIN}-{APP_MEMORY_ID_MAX}",
177        );
178    }
179}
180
181#[must_use]
182pub const fn memory_id_is_in_range(memory_id: u8, min: u8, max: u8) -> bool {
183    memory_id >= min && memory_id <= max
184}
185
186#[must_use]
187pub const fn memory_id_is_reserved(memory_id: u8) -> bool {
188    memory_id == RESERVED_INTERNAL_MEMORY_ID
189}
190
191#[must_use]
192pub const fn app_memory_id_is_valid(memory_id: u8) -> bool {
193    memory_id >= APP_MEMORY_ID_MIN && memory_id <= APP_MEMORY_ID_MAX
194}
195
196pub(crate) fn validate_stable_key_segment(errs: &mut ErrorTree, label: &str, value: &str) {
197    if !stable_key_segment_is_canonical(value) {
198        err!(
199            errs,
200            "{label} `{value}` must use lowercase ASCII letters, digits, and underscores",
201        );
202    }
203}
204
205pub(crate) fn validate_stable_key(errs: &mut ErrorTree, label: &str, value: &str) {
206    if !stable_key_is_canonical(value) {
207        err!(
208            errs,
209            "{label} `{value}` must be at most 128 bytes, must use lowercase ASCII segments beginning with a letter, must use dots as separators, must use underscores instead of hyphens, must end in .v1, and must not start with canic.",
210        );
211    }
212}
213
214#[must_use]
215pub fn stable_key_segment_is_canonical(value: &str) -> bool {
216    let mut bytes = value.bytes();
217    bytes.next().is_some_and(|byte| byte.is_ascii_lowercase())
218        && bytes.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
219}
220
221#[must_use]
222pub(crate) fn stable_key_is_canonical(value: &str) -> bool {
223    if value.len() > 128 || value.starts_with("canic.") {
224        return false;
225    }
226
227    let mut saw_segment = false;
228    let mut last_segment = "";
229    for segment in value.split('.') {
230        if !stable_key_segment_is_canonical(segment) {
231            return false;
232        }
233        saw_segment = true;
234        last_segment = segment;
235    }
236
237    saw_segment && last_segment == "v1"
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn app_memory_id_policy_accepts_only_application_range() {
246        for memory_id in APP_MEMORY_ID_MIN..=APP_MEMORY_ID_MAX {
247            let mut errors = ErrorTree::new();
248            validate_app_memory_id(&mut errors, "memory_id", memory_id);
249            validate_memory_id_not_reserved(&mut errors, "memory_id", memory_id);
250            assert!(
251                errors.is_empty(),
252                "schema should accept app memory id {memory_id}: {errors}",
253            );
254        }
255
256        for memory_id in [0, APP_MEMORY_ID_MIN - 1] {
257            let mut errors = ErrorTree::new();
258            validate_app_memory_id(&mut errors, "memory_id", memory_id);
259            assert!(
260                !errors.is_empty(),
261                "schema should reject below-range app memory id {memory_id}",
262            );
263        }
264
265        let mut errors = ErrorTree::new();
266        validate_app_memory_id(&mut errors, "memory_id", u8::MAX);
267        validate_memory_id_not_reserved(&mut errors, "memory_id", u8::MAX);
268        let rendered = errors.to_string();
269        assert!(
270            rendered.contains("outside of application memory range 100-254"),
271            "reserved id should also fail the app range check: {rendered}",
272        );
273        assert!(
274            rendered.contains("reserved for stable-structures internals"),
275            "reserved id should fail closed explicitly: {rendered}",
276        );
277    }
278
279    #[test]
280    fn stable_key_segment_policy_requires_a_lowercase_letter_prefix() {
281        for segment in ["db", "demo_rpg", "store_1", "v1"] {
282            assert!(stable_key_segment_is_canonical(segment));
283        }
284
285        for segment in [
286            "",
287            "1db",
288            "_db",
289            "Demo",
290            "demo-rpg",
291            "demo.rpg",
292            "canic.owned",
293        ] {
294            assert!(!stable_key_segment_is_canonical(segment));
295        }
296    }
297
298    #[test]
299    fn full_stable_key_policy_rejects_reserved_and_malformed_keys() {
300        assert!(stable_key_is_canonical("icydb.demo_rpg.characters.data.v1"));
301
302        for key in [
303            "canic.demo_rpg.characters.data.v1",
304            "icydb.demo_rpg.characters.data",
305            "icydb.demo-rpg.characters.data.v1",
306            "icydb.demo_rpg..data.v1",
307            "icydb.Demo.characters.data.v1",
308            "icydb.demo_rpg.characters.data.v2",
309            "icydb.1demo.characters.data.v1",
310            "icydb._demo.characters.data.v1",
311        ] {
312            assert!(!stable_key_is_canonical(key), "key should fail: {key}");
313        }
314
315        let maximum = format!("icydb.{}.data.v1", "a".repeat(114));
316        assert_eq!(maximum.len(), 128);
317        assert!(stable_key_is_canonical(&maximum));
318
319        let oversized = format!("icydb.{}.data.v1", "a".repeat(115));
320        assert_eq!(oversized.len(), 129);
321        assert!(!stable_key_is_canonical(&oversized));
322    }
323}