Skip to main content

icydb_model/node/
canister.rs

1//! Module: node::canister
2//!
3//! Responsibility: canister-level schema node metadata and memory allocation validation.
4//! Does not own: ICP lifecycle management or runtime stable-memory implementation.
5//! Boundary: validates permanent namespaces and store keys before runtime use.
6
7#[cfg(test)]
8mod tests;
9
10use crate::node::{stable_memory_key, validate_stable_key, validate_stable_key_segment};
11use crate::prelude::*;
12use std::collections::BTreeMap;
13use std::str::FromStr;
14
15/// Build-time constructor for one source-declared coordinated migration plan.
16pub type MigrationPlanConstructor =
17    fn() -> Result<icydb_schema::SchemaMigrationPlan, icydb_schema::SchemaContractError>;
18
19///
20/// CanisterMemoryProfile
21///
22/// Build-time bucket sizing for IcyDB-owned shared memory bootstrap. This is
23/// physical configuration, not accepted schema authority or allocation access.
24/// A host that bootstraps first owns the effective setting instead.
25///
26
27#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
28#[serde(rename_all = "snake_case")]
29pub enum CanisterMemoryProfile {
30    /// 256 KiB buckets; 8 GiB shared manager capacity before backing limits.
31    Compact,
32    /// 1 MiB buckets; 32 GiB shared manager capacity before backing limits.
33    General,
34    /// 8 MiB buckets; 256 GiB shared manager capacity before backing limits.
35    HighHeadroom,
36}
37
38impl CanisterMemoryProfile {
39    /// Return the immutable bucket size in 64 KiB Wasm pages.
40    #[must_use]
41    pub const fn bucket_size_pages(self) -> u16 {
42        match self {
43            Self::Compact => 4,
44            Self::General => 16,
45            Self::HighHeadroom => 128,
46        }
47    }
48}
49
50/// Parse one macro-validated textual migration literal into its exact public atom.
51#[doc(hidden)]
52pub fn migration_literal_from_text(
53    kind: &str,
54    value: &str,
55) -> Result<icydb_schema::ScalarLiteral, icydb_schema::SchemaContractError> {
56    use icydb_schema::{
57        Account, Blob, Date, Decimal, Duration, Principal, ScalarLiteral, Subaccount, Timestamp,
58        U256, Ulid,
59    };
60
61    let invalid = || icydb_schema::SchemaContractError::InvalidLiteral;
62    match kind {
63        "account" => Account::from_str(value)
64            .map(ScalarLiteral::Account)
65            .map_err(|_| invalid()),
66        "blob" => decode_migration_hex(value)
67            .map(Blob::from)
68            .map(ScalarLiteral::Blob),
69        "date" => Date::parse(value)
70            .map(ScalarLiteral::Date)
71            .ok_or_else(invalid),
72        "decimal" => Decimal::from_str(value)
73            .map(ScalarLiteral::Decimal)
74            .map_err(|_| invalid()),
75        "duration" => Duration::parse_flexible(value)
76            .map(ScalarLiteral::Duration)
77            .map_err(|_| invalid()),
78        "principal" => Principal::from_str(value)
79            .map(ScalarLiteral::Principal)
80            .map_err(|_| invalid()),
81        "subaccount" => {
82            let bytes = decode_migration_hex(value)?;
83            let bytes: [u8; 32] = bytes.try_into().map_err(|_| invalid())?;
84            Ok(ScalarLiteral::Subaccount(Subaccount::from_array(bytes)))
85        }
86        "timestamp" => Timestamp::parse_flexible(value)
87            .map(ScalarLiteral::Timestamp)
88            .map_err(|_| invalid()),
89        "u256" => U256::from_str(value)
90            .map(ScalarLiteral::U256)
91            .map_err(|_| invalid()),
92        "ulid" => Ulid::from_str(value)
93            .map(ScalarLiteral::Ulid)
94            .map_err(|_| invalid()),
95        _ => Err(invalid()),
96    }
97}
98
99fn decode_migration_hex(value: &str) -> Result<Vec<u8>, icydb_schema::SchemaContractError> {
100    if !value.len().is_multiple_of(2) {
101        return Err(icydb_schema::SchemaContractError::InvalidLiteral);
102    }
103    value
104        .as_bytes()
105        .as_chunks::<2>()
106        .0
107        .iter()
108        .map(|pair| {
109            let high = decode_hex_nibble(pair[0])?;
110            let low = decode_hex_nibble(pair[1])?;
111            Ok((high << 4) | low)
112        })
113        .collect()
114}
115
116const fn decode_hex_nibble(value: u8) -> Result<u8, icydb_schema::SchemaContractError> {
117    match value {
118        b'0'..=b'9' => Ok(value - b'0'),
119        b'a'..=b'f' => Ok(value - b'a' + 10),
120        b'A'..=b'F' => Ok(value - b'A' + 10),
121        _ => Err(icydb_schema::SchemaContractError::InvalidLiteral),
122    }
123}
124
125///
126/// Canister
127///
128
129#[derive(Clone, Debug, Serialize)]
130pub struct Canister {
131    def: Def,
132    memory_namespace: &'static str,
133    memory_profile: CanisterMemoryProfile,
134    #[serde(skip)]
135    migration_plan: Option<MigrationPlanConstructor>,
136}
137
138impl Canister {
139    #[must_use]
140    pub const fn new(
141        def: Def,
142        memory_namespace: &'static str,
143        migration_plan: Option<MigrationPlanConstructor>,
144    ) -> Self {
145        Self {
146            def,
147            memory_namespace,
148            memory_profile: CanisterMemoryProfile::General,
149            migration_plan,
150        }
151    }
152
153    #[must_use]
154    pub const fn def(&self) -> &Def {
155        &self.def
156    }
157
158    #[must_use]
159    pub const fn memory_namespace(&self) -> &'static str {
160        self.memory_namespace
161    }
162
163    /// Select the profile used when IcyDB owns memory bootstrap.
164    ///
165    /// Existing memory must match its bucket size; this does not resize it.
166    #[must_use]
167    pub const fn with_memory_profile(mut self, profile: CanisterMemoryProfile) -> Self {
168        self.memory_profile = profile;
169        self
170    }
171
172    /// Return the configured profile; newly constructed nodes use `General`.
173    #[must_use]
174    pub const fn memory_profile(&self) -> CanisterMemoryProfile {
175        self.memory_profile
176    }
177
178    /// Construct the optional source-declared migration plan.
179    ///
180    /// # Errors
181    ///
182    /// Returns the schema-contract error produced by the bounded declaration.
183    pub fn migration_plan(
184        &self,
185    ) -> Result<Option<icydb_schema::SchemaMigrationPlan>, icydb_schema::SchemaContractError> {
186        self.migration_plan
187            .map(|constructor| constructor())
188            .transpose()
189    }
190
191    #[must_use]
192    pub fn commit_stable_key(&self) -> String {
193        stable_memory_key(self.memory_namespace(), "commit", "control")
194    }
195
196    #[must_use]
197    pub fn integrity_progress_stable_key(&self) -> String {
198        stable_memory_key(self.memory_namespace(), "integrity", "progress")
199    }
200
201    #[must_use]
202    pub fn startup_stable_key(&self) -> String {
203        stable_memory_key(self.memory_namespace(), "startup", "control")
204    }
205}
206
207impl MacroNode for Canister {
208    fn as_any(&self) -> &dyn std::any::Any {
209        self
210    }
211}
212
213impl ValidateNode for Canister {
214    fn validate(&self) -> Result<(), ErrorTree> {
215        let mut errs = ErrorTree::new();
216        validate_stable_key_segment(
217            &mut errs,
218            "canister memory_namespace",
219            self.memory_namespace(),
220        );
221        for key in [
222            self.commit_stable_key(),
223            self.startup_stable_key(),
224            self.integrity_progress_stable_key(),
225        ] {
226            validate_stable_key(&mut errs, "canister control key", &key);
227        }
228
229        let canister_path = self.def().path();
230        {
231            let schema = schema_read();
232            let mut seen = BTreeMap::new();
233            for (path, store) in
234                schema.filter_nodes::<Store>(|store| store.canister() == canister_path)
235            {
236                if let Some(config) = store.journaled_memory_config()
237                    && let Some(previous) = seen.insert(config.key(), path)
238                {
239                    err!(
240                        errs,
241                        "duplicate store key `{}` in canister `{}`: {} conflicts with {}",
242                        config.key(),
243                        canister_path,
244                        previous,
245                        path
246                    );
247                }
248            }
249        }
250        errs.result()
251    }
252}
253
254impl VisitableNode for Canister {
255    fn route_key(&self) -> String {
256        self.def().path()
257    }
258}