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, IntBig, NatBig, Principal, ScalarLiteral,
58        Subaccount, Timestamp, 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        "int_big" => IntBig::from_str(value)
79            .map(ScalarLiteral::IntBig)
80            .map_err(|_| invalid()),
81        "nat_big" => NatBig::from_str(value)
82            .map(ScalarLiteral::NatBig)
83            .map_err(|_| invalid()),
84        "principal" => Principal::from_str(value)
85            .map(ScalarLiteral::Principal)
86            .map_err(|_| invalid()),
87        "subaccount" => {
88            let bytes = decode_migration_hex(value)?;
89            let bytes: [u8; 32] = bytes.try_into().map_err(|_| invalid())?;
90            Ok(ScalarLiteral::Subaccount(Subaccount::from_array(bytes)))
91        }
92        "timestamp" => Timestamp::parse_flexible(value)
93            .map(ScalarLiteral::Timestamp)
94            .map_err(|_| invalid()),
95        "u256" => U256::from_str(value)
96            .map(ScalarLiteral::U256)
97            .map_err(|_| invalid()),
98        "ulid" => Ulid::from_str(value)
99            .map(ScalarLiteral::Ulid)
100            .map_err(|_| invalid()),
101        _ => Err(invalid()),
102    }
103}
104
105fn decode_migration_hex(value: &str) -> Result<Vec<u8>, icydb_schema::SchemaContractError> {
106    if !value.len().is_multiple_of(2) {
107        return Err(icydb_schema::SchemaContractError::InvalidLiteral);
108    }
109    value
110        .as_bytes()
111        .as_chunks::<2>()
112        .0
113        .iter()
114        .map(|pair| {
115            let high = decode_hex_nibble(pair[0])?;
116            let low = decode_hex_nibble(pair[1])?;
117            Ok((high << 4) | low)
118        })
119        .collect()
120}
121
122const fn decode_hex_nibble(value: u8) -> Result<u8, icydb_schema::SchemaContractError> {
123    match value {
124        b'0'..=b'9' => Ok(value - b'0'),
125        b'a'..=b'f' => Ok(value - b'a' + 10),
126        b'A'..=b'F' => Ok(value - b'A' + 10),
127        _ => Err(icydb_schema::SchemaContractError::InvalidLiteral),
128    }
129}
130
131///
132/// Canister
133///
134
135#[derive(Clone, Debug, Serialize)]
136pub struct Canister {
137    def: Def,
138    memory_namespace: &'static str,
139    memory_profile: CanisterMemoryProfile,
140    #[serde(skip)]
141    migration_plan: Option<MigrationPlanConstructor>,
142}
143
144impl Canister {
145    #[must_use]
146    pub const fn new(
147        def: Def,
148        memory_namespace: &'static str,
149        migration_plan: Option<MigrationPlanConstructor>,
150    ) -> Self {
151        Self {
152            def,
153            memory_namespace,
154            memory_profile: CanisterMemoryProfile::General,
155            migration_plan,
156        }
157    }
158
159    #[must_use]
160    pub const fn def(&self) -> &Def {
161        &self.def
162    }
163
164    #[must_use]
165    pub const fn memory_namespace(&self) -> &'static str {
166        self.memory_namespace
167    }
168
169    /// Select the profile used when IcyDB owns memory bootstrap.
170    ///
171    /// Existing memory must match its bucket size; this does not resize it.
172    #[must_use]
173    pub const fn with_memory_profile(mut self, profile: CanisterMemoryProfile) -> Self {
174        self.memory_profile = profile;
175        self
176    }
177
178    /// Return the configured profile; newly constructed nodes use `General`.
179    #[must_use]
180    pub const fn memory_profile(&self) -> CanisterMemoryProfile {
181        self.memory_profile
182    }
183
184    /// Construct the optional source-declared migration plan.
185    ///
186    /// # Errors
187    ///
188    /// Returns the schema-contract error produced by the bounded declaration.
189    pub fn migration_plan(
190        &self,
191    ) -> Result<Option<icydb_schema::SchemaMigrationPlan>, icydb_schema::SchemaContractError> {
192        self.migration_plan
193            .map(|constructor| constructor())
194            .transpose()
195    }
196
197    #[must_use]
198    pub fn commit_stable_key(&self) -> String {
199        stable_memory_key(self.memory_namespace(), "commit", "control")
200    }
201
202    #[must_use]
203    pub fn integrity_progress_stable_key(&self) -> String {
204        stable_memory_key(self.memory_namespace(), "integrity", "progress")
205    }
206
207    #[must_use]
208    pub fn startup_stable_key(&self) -> String {
209        stable_memory_key(self.memory_namespace(), "startup", "control")
210    }
211}
212
213impl MacroNode for Canister {
214    fn as_any(&self) -> &dyn std::any::Any {
215        self
216    }
217}
218
219impl ValidateNode for Canister {
220    fn validate(&self) -> Result<(), ErrorTree> {
221        let mut errs = ErrorTree::new();
222        validate_stable_key_segment(
223            &mut errs,
224            "canister memory_namespace",
225            self.memory_namespace(),
226        );
227        for key in [
228            self.commit_stable_key(),
229            self.startup_stable_key(),
230            self.integrity_progress_stable_key(),
231        ] {
232            validate_stable_key(&mut errs, "canister control key", &key);
233        }
234
235        let canister_path = self.def().path();
236        {
237            let schema = schema_read();
238            let mut seen = BTreeMap::new();
239            for (path, store) in
240                schema.filter_nodes::<Store>(|store| store.canister() == canister_path)
241            {
242                if let Some(config) = store.journaled_memory_config()
243                    && let Some(previous) = seen.insert(config.key(), path)
244                {
245                    err!(
246                        errs,
247                        "duplicate store key `{}` in canister `{}`: {} conflicts with {}",
248                        config.key(),
249                        canister_path,
250                        previous,
251                        path
252                    );
253                }
254            }
255        }
256        errs.result()
257    }
258}
259
260impl VisitableNode for Canister {
261    fn route_key(&self) -> String {
262        self.def().path()
263    }
264}