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 declared memory ranges and stable keys before runtime use.
6
7#[cfg(test)]
8mod tests;
9
10use crate::node::{
11    stable_memory_key, validate_app_memory_id, validate_memory_id_in_range,
12    validate_memory_id_not_reserved, validate_stable_key, validate_stable_key_segment,
13};
14use crate::prelude::*;
15use std::collections::BTreeMap;
16use std::str::FromStr;
17
18/// Build-time constructor for one source-declared coordinated migration plan.
19pub type MigrationPlanConstructor =
20    fn() -> Result<icydb_schema::SchemaMigrationPlan, icydb_schema::SchemaContractError>;
21
22///
23/// CanisterMemoryProfile
24///
25/// Build-time bucket sizing for IcyDB-owned shared memory bootstrap. This is
26/// physical configuration, not accepted schema authority or allocation access.
27/// A host that bootstraps first owns the effective setting instead.
28///
29
30#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
31#[serde(rename_all = "snake_case")]
32pub enum CanisterMemoryProfile {
33    /// 256 KiB buckets; 8 GiB shared manager capacity before backing limits.
34    Compact,
35    /// 1 MiB buckets; 32 GiB shared manager capacity before backing limits.
36    General,
37    /// 8 MiB buckets; 256 GiB shared manager capacity before backing limits.
38    HighHeadroom,
39}
40
41impl CanisterMemoryProfile {
42    /// Return the immutable bucket size in 64 KiB Wasm pages.
43    #[must_use]
44    pub const fn bucket_size_pages(self) -> u16 {
45        match self {
46            Self::Compact => 4,
47            Self::General => 16,
48            Self::HighHeadroom => 128,
49        }
50    }
51}
52
53/// Parse one macro-validated textual migration literal into its exact public atom.
54#[doc(hidden)]
55pub fn migration_literal_from_text(
56    kind: &str,
57    value: &str,
58) -> Result<icydb_schema::ScalarLiteral, icydb_schema::SchemaContractError> {
59    use icydb_schema::{
60        Account, Blob, Date, Decimal, Duration, IntBig, NatBig, Principal, ScalarLiteral,
61        Subaccount, Timestamp, U256, Ulid,
62    };
63
64    let invalid = || icydb_schema::SchemaContractError::InvalidLiteral;
65    match kind {
66        "account" => Account::from_str(value)
67            .map(ScalarLiteral::Account)
68            .map_err(|_| invalid()),
69        "blob" => decode_migration_hex(value)
70            .map(Blob::from)
71            .map(ScalarLiteral::Blob),
72        "date" => Date::parse(value)
73            .map(ScalarLiteral::Date)
74            .ok_or_else(invalid),
75        "decimal" => Decimal::from_str(value)
76            .map(ScalarLiteral::Decimal)
77            .map_err(|_| invalid()),
78        "duration" => Duration::parse_flexible(value)
79            .map(ScalarLiteral::Duration)
80            .map_err(|_| invalid()),
81        "int_big" => IntBig::from_str(value)
82            .map(ScalarLiteral::IntBig)
83            .map_err(|_| invalid()),
84        "nat_big" => NatBig::from_str(value)
85            .map(ScalarLiteral::NatBig)
86            .map_err(|_| invalid()),
87        "principal" => Principal::from_str(value)
88            .map(ScalarLiteral::Principal)
89            .map_err(|_| invalid()),
90        "subaccount" => {
91            let bytes = decode_migration_hex(value)?;
92            let bytes: [u8; 32] = bytes.try_into().map_err(|_| invalid())?;
93            Ok(ScalarLiteral::Subaccount(Subaccount::from_array(bytes)))
94        }
95        "timestamp" => Timestamp::parse_flexible(value)
96            .map(ScalarLiteral::Timestamp)
97            .map_err(|_| invalid()),
98        "u256" => U256::from_str(value)
99            .map(ScalarLiteral::U256)
100            .map_err(|_| invalid()),
101        "ulid" => Ulid::from_str(value)
102            .map(ScalarLiteral::Ulid)
103            .map_err(|_| invalid()),
104        _ => Err(invalid()),
105    }
106}
107
108fn decode_migration_hex(value: &str) -> Result<Vec<u8>, icydb_schema::SchemaContractError> {
109    if !value.len().is_multiple_of(2) {
110        return Err(icydb_schema::SchemaContractError::InvalidLiteral);
111    }
112    value
113        .as_bytes()
114        .as_chunks::<2>()
115        .0
116        .iter()
117        .map(|pair| {
118            let high = decode_hex_nibble(pair[0])?;
119            let low = decode_hex_nibble(pair[1])?;
120            Ok((high << 4) | low)
121        })
122        .collect()
123}
124
125const fn decode_hex_nibble(value: u8) -> Result<u8, icydb_schema::SchemaContractError> {
126    match value {
127        b'0'..=b'9' => Ok(value - b'0'),
128        b'a'..=b'f' => Ok(value - b'a' + 10),
129        b'A'..=b'F' => Ok(value - b'A' + 10),
130        _ => Err(icydb_schema::SchemaContractError::InvalidLiteral),
131    }
132}
133
134///
135/// Canister
136///
137
138#[derive(Clone, Debug, Serialize)]
139pub struct Canister {
140    def: Def,
141    memory_namespace: &'static str,
142    memory_profile: CanisterMemoryProfile,
143    memory_min: u8,
144    memory_max: u8,
145    commit_memory_id: u8,
146    startup_memory_id: u8,
147    integrity_progress_memory_id: u8,
148    #[serde(skip)]
149    migration_plan: Option<MigrationPlanConstructor>,
150}
151
152impl Canister {
153    #[must_use]
154    #[expect(
155        clippy::too_many_arguments,
156        reason = "the generated canister node keeps its closed memory identities explicit"
157    )]
158    pub const fn new(
159        def: Def,
160        memory_namespace: &'static str,
161        memory_min: u8,
162        memory_max: u8,
163        commit_memory_id: u8,
164        startup_memory_id: u8,
165        integrity_progress_memory_id: u8,
166        migration_plan: Option<MigrationPlanConstructor>,
167    ) -> Self {
168        Self {
169            def,
170            memory_namespace,
171            memory_profile: CanisterMemoryProfile::General,
172            memory_min,
173            memory_max,
174            commit_memory_id,
175            startup_memory_id,
176            integrity_progress_memory_id,
177            migration_plan,
178        }
179    }
180
181    #[must_use]
182    pub const fn def(&self) -> &Def {
183        &self.def
184    }
185
186    #[must_use]
187    pub const fn memory_namespace(&self) -> &'static str {
188        self.memory_namespace
189    }
190
191    /// Select the profile used when IcyDB owns memory bootstrap.
192    ///
193    /// Existing memory must match its bucket size; this does not resize it.
194    #[must_use]
195    pub const fn with_memory_profile(mut self, profile: CanisterMemoryProfile) -> Self {
196        self.memory_profile = profile;
197        self
198    }
199
200    /// Return the configured profile; newly constructed nodes use `General`.
201    #[must_use]
202    pub const fn memory_profile(&self) -> CanisterMemoryProfile {
203        self.memory_profile
204    }
205
206    #[must_use]
207    pub const fn memory_min(&self) -> u8 {
208        self.memory_min
209    }
210
211    #[must_use]
212    pub const fn memory_max(&self) -> u8 {
213        self.memory_max
214    }
215
216    #[must_use]
217    pub const fn commit_memory_id(&self) -> u8 {
218        self.commit_memory_id
219    }
220
221    #[must_use]
222    pub const fn startup_memory_id(&self) -> u8 {
223        self.startup_memory_id
224    }
225
226    #[must_use]
227    pub const fn integrity_progress_memory_id(&self) -> u8 {
228        self.integrity_progress_memory_id
229    }
230
231    /// Construct the optional source-declared migration plan.
232    ///
233    /// # Errors
234    ///
235    /// Returns the schema-contract error produced by the bounded declaration.
236    pub fn migration_plan(
237        &self,
238    ) -> Result<Option<icydb_schema::SchemaMigrationPlan>, icydb_schema::SchemaContractError> {
239        self.migration_plan
240            .map(|constructor| constructor())
241            .transpose()
242    }
243
244    #[must_use]
245    pub fn commit_stable_key(&self) -> String {
246        stable_memory_key(self.memory_namespace(), "commit", "control")
247    }
248
249    #[must_use]
250    pub fn integrity_progress_stable_key(&self) -> String {
251        stable_memory_key(self.memory_namespace(), "integrity", "progress")
252    }
253
254    #[must_use]
255    pub fn startup_stable_key(&self) -> String {
256        stable_memory_key(self.memory_namespace(), "startup", "control")
257    }
258
259    fn validate_declared_memory_contract(&self, errs: &mut ErrorTree) {
260        validate_stable_key_segment(errs, "canister memory_namespace", self.memory_namespace());
261        validate_memory_id_in_range(
262            errs,
263            "commit_memory_id",
264            self.commit_memory_id(),
265            self.memory_min(),
266            self.memory_max(),
267        );
268        validate_app_memory_id(errs, "commit_memory_id", self.commit_memory_id());
269        validate_memory_id_not_reserved(errs, "commit_memory_id", self.commit_memory_id());
270        validate_stable_key(errs, "commit stable key", &self.commit_stable_key());
271        validate_memory_id_in_range(
272            errs,
273            "startup_memory_id",
274            self.startup_memory_id(),
275            self.memory_min(),
276            self.memory_max(),
277        );
278        validate_app_memory_id(errs, "startup_memory_id", self.startup_memory_id());
279        validate_memory_id_not_reserved(errs, "startup_memory_id", self.startup_memory_id());
280        validate_stable_key(errs, "startup stable key", &self.startup_stable_key());
281        validate_memory_id_in_range(
282            errs,
283            "integrity_progress_memory_id",
284            self.integrity_progress_memory_id(),
285            self.memory_min(),
286            self.memory_max(),
287        );
288        validate_app_memory_id(
289            errs,
290            "integrity_progress_memory_id",
291            self.integrity_progress_memory_id(),
292        );
293        validate_memory_id_not_reserved(
294            errs,
295            "integrity_progress_memory_id",
296            self.integrity_progress_memory_id(),
297        );
298        validate_stable_key(
299            errs,
300            "integrity progress stable key",
301            &self.integrity_progress_stable_key(),
302        );
303    }
304
305    fn register_store_allocations(
306        &self,
307        canister_path: &str,
308        seen_ids: &mut BTreeMap<u8, (String, String)>,
309        seen_keys: &mut BTreeMap<String, (u8, String)>,
310        errs: &mut ErrorTree,
311    ) {
312        let schema = schema_read();
313        for (path, store) in schema.filter_nodes::<Store>(|node| node.canister() == canister_path) {
314            if !matches!(store.storage(), StoreStorage::Journaled(_)) {
315                continue;
316            }
317            for (allocation, role) in [
318                (
319                    store.stable_data_allocation(self.memory_namespace()),
320                    "data",
321                ),
322                (
323                    store.stable_index_allocation(self.memory_namespace()),
324                    "index",
325                ),
326                (
327                    store.stable_schema_allocation(self.memory_namespace()),
328                    "schema",
329                ),
330            ] {
331                assert_unique_memory_allocation(
332                    allocation.memory_id(),
333                    allocation.stable_key().to_string(),
334                    format!("Store `{path}`.{role}_memory"),
335                    canister_path,
336                    seen_ids,
337                    seen_keys,
338                    errs,
339                );
340            }
341            if store.is_journaled_storage() {
342                let allocation = store.journal_allocation(self.memory_namespace());
343                assert_unique_memory_allocation(
344                    allocation.memory_id(),
345                    allocation.stable_key().to_string(),
346                    format!("Store `{path}`.journal_memory"),
347                    canister_path,
348                    seen_ids,
349                    seen_keys,
350                    errs,
351                );
352            }
353        }
354    }
355}
356
357impl MacroNode for Canister {
358    fn as_any(&self) -> &dyn std::any::Any {
359        self
360    }
361}
362
363impl ValidateNode for Canister {
364    fn validate(&self) -> Result<(), ErrorTree> {
365        let mut errs = ErrorTree::new();
366
367        let canister_path = self.def().path();
368        let mut seen_ids = BTreeMap::<u8, (String, String)>::new();
369        let mut seen_keys = BTreeMap::<String, (u8, String)>::new();
370
371        self.validate_declared_memory_contract(&mut errs);
372
373        assert_unique_memory_allocation(
374            self.commit_memory_id(),
375            self.commit_stable_key(),
376            format!("Canister `{}`.commit_memory", self.def().path()),
377            &canister_path,
378            &mut seen_ids,
379            &mut seen_keys,
380            &mut errs,
381        );
382        assert_unique_memory_allocation(
383            self.startup_memory_id(),
384            self.startup_stable_key(),
385            format!("Canister `{}`.startup_memory", self.def().path()),
386            &canister_path,
387            &mut seen_ids,
388            &mut seen_keys,
389            &mut errs,
390        );
391        assert_unique_memory_allocation(
392            self.integrity_progress_memory_id(),
393            self.integrity_progress_stable_key(),
394            format!("Canister `{}`.integrity_progress_memory", self.def().path()),
395            &canister_path,
396            &mut seen_ids,
397            &mut seen_keys,
398            &mut errs,
399        );
400        self.register_store_allocations(&canister_path, &mut seen_ids, &mut seen_keys, &mut errs);
401
402        errs.result()
403    }
404}
405
406fn assert_unique_memory_allocation(
407    memory_id: u8,
408    stable_key: String,
409    slot: String,
410    canister_path: &str,
411    seen_ids: &mut BTreeMap<u8, (String, String)>,
412    seen_keys: &mut BTreeMap<String, (u8, String)>,
413    errs: &mut ErrorTree,
414) {
415    if let Some((existing_key, existing_slot)) = seen_ids.get(&memory_id) {
416        err!(
417            errs,
418            "duplicate memory_id `{}` used in canister `{}`: {} ({}) conflicts with {} ({})",
419            memory_id,
420            canister_path,
421            existing_slot,
422            existing_key,
423            slot,
424            stable_key,
425        );
426    } else {
427        seen_ids.insert(memory_id, (stable_key.clone(), slot.clone()));
428    }
429
430    if let Some((existing_id, existing_slot)) = seen_keys.get(&stable_key) {
431        err!(
432            errs,
433            "duplicate stable_key `{}` used in canister `{}`: {} ({}) conflicts with {} ({})",
434            stable_key,
435            canister_path,
436            existing_slot,
437            existing_id,
438            slot,
439            memory_id,
440        );
441    } else {
442        seen_keys.insert(stable_key, (memory_id, slot));
443    }
444}
445
446impl VisitableNode for Canister {
447    fn route_key(&self) -> String {
448        self.def().path()
449    }
450}