Skip to main content

canic_core/storage/stable/
intent.rs

1//! Stable-memory intent store primitives.
2//!
3//! Data-only storage slots for cross-canister intent tracking. The ops layer
4//! enforces mechanical invariants (uniqueness, monotonic state transitions,
5//! aggregate consistency). Policy and capacity decisions live above this layer.
6
7use crate::{
8    cdk::structures::{
9        DefaultMemoryImpl, Storable, cell::Cell, memory::VirtualMemory, storable::Bound,
10    },
11    ids::{IntentId, IntentResourceKey},
12    role_contract::allocation::memory::intent::{
13        INTENT_META_ID, INTENT_PENDING_ID, INTENT_RECORDS_ID, INTENT_TOTALS_ID,
14    },
15    storage::prelude::*,
16};
17use ic_memory::stable_structures::btreemap::BTreeMap as StableBtreeMap;
18use std::{borrow::Cow, cell::RefCell};
19
20//
21// INTENT STORE
22//
23
24pub const INTENT_STORE_SCHEMA_VERSION: u32 = 1;
25
26eager_static! {
27    static INTENT_META: RefCell<Cell<IntentStoreMetaRecord, VirtualMemory<DefaultMemoryImpl>>> =
28        RefCell::new(Cell::init(
29            crate::ic_memory_key!("canic.core.intent_meta.v1", IntentStoreMetaRecord, INTENT_META_ID),
30            IntentStoreMetaRecord::default(),
31        ));
32}
33
34eager_static! {
35    static INTENT_RECORDS: RefCell<
36        StableBtreeMap<IntentId, IntentRecord, VirtualMemory<DefaultMemoryImpl>>
37    > = RefCell::new(
38        StableBtreeMap::init(crate::ic_memory_key!("canic.core.intent_records.v1", IntentRecord, INTENT_RECORDS_ID)),
39    );
40}
41
42eager_static! {
43    static INTENT_TOTALS: RefCell<
44        StableBtreeMap<IntentResourceKey, IntentResourceTotalsRecord, VirtualMemory<DefaultMemoryImpl>>
45    > = RefCell::new(
46        StableBtreeMap::init(crate::ic_memory_key!("canic.core.intent_totals.v1", IntentResourceTotalsRecord, INTENT_TOTALS_ID)),
47    );
48}
49
50eager_static! {
51    static INTENT_PENDING: RefCell<
52        StableBtreeMap<IntentId, IntentPendingEntryRecord, VirtualMemory<DefaultMemoryImpl>>
53    > = RefCell::new(
54        StableBtreeMap::init(crate::ic_memory_key!("canic.core.intent_pending.v1", IntentPendingEntryRecord, INTENT_PENDING_ID)),
55    );
56}
57
58impl Storable for IntentId {
59    const BOUND: Bound = Bound::Bounded {
60        max_size: 8,
61        is_fixed_size: true,
62    };
63
64    fn to_bytes(&self) -> Cow<'_, [u8]> {
65        Cow::Owned(self.0.to_be_bytes().to_vec())
66    }
67
68    fn into_bytes(self) -> Vec<u8> {
69        self.0.to_be_bytes().to_vec()
70    }
71
72    fn from_bytes(bytes: Cow<[u8]>) -> Self {
73        let b = bytes.as_ref();
74
75        if b.len() != 8 {
76            return Self::default();
77        }
78
79        let mut arr = [0u8; 8];
80        arr.copy_from_slice(b);
81
82        Self(u64::from_be_bytes(arr))
83    }
84}
85
86///
87/// IntentState
88///
89
90#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
91pub enum IntentState {
92    Pending,
93    Committed,
94    Aborted,
95}
96
97///
98/// IntentRecord
99///
100
101#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
102pub struct IntentRecord {
103    pub id: IntentId,
104    pub resource_key: IntentResourceKey,
105    pub quantity: u64,
106    pub state: IntentState,
107    pub created_at: u64,
108    // TTL is enforced logically at read time; cleanup is asynchronous.
109    pub ttl_secs: Option<u64>,
110}
111
112impl IntentRecord {
113    pub const STORABLE_MAX_SIZE: u32 = 256;
114}
115
116impl_storable_bounded!(IntentRecord, IntentRecord::STORABLE_MAX_SIZE, false);
117
118///
119/// IntentStoreMetaRecord
120///
121
122#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
123pub struct IntentStoreMetaRecord {
124    pub schema_version: u32,
125    pub next_intent_id: IntentId,
126    pub pending_total: u64,
127    pub committed_total: u64,
128    pub aborted_total: u64,
129}
130
131impl IntentStoreMetaRecord {
132    pub const STORABLE_MAX_SIZE: u32 = 96;
133}
134
135impl Default for IntentStoreMetaRecord {
136    fn default() -> Self {
137        Self {
138            schema_version: INTENT_STORE_SCHEMA_VERSION,
139            next_intent_id: IntentId(1),
140            pending_total: 0,
141            committed_total: 0,
142            aborted_total: 0,
143        }
144    }
145}
146
147impl_storable_bounded!(
148    IntentStoreMetaRecord,
149    IntentStoreMetaRecord::STORABLE_MAX_SIZE,
150    false
151);
152
153///
154/// IntentResourceTotalsRecord
155///
156
157#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
158pub struct IntentResourceTotalsRecord {
159    pub reserved_qty: u64,
160    pub committed_qty: u64,
161    pub pending_count: u64,
162}
163
164impl IntentResourceTotalsRecord {
165    pub const STORABLE_MAX_SIZE: u32 = 64;
166}
167
168impl_storable_bounded!(
169    IntentResourceTotalsRecord,
170    IntentResourceTotalsRecord::STORABLE_MAX_SIZE,
171    false
172);
173
174///
175/// IntentPendingEntryRecord
176///
177
178#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
179pub struct IntentPendingEntryRecord {
180    pub resource_key: IntentResourceKey,
181    pub quantity: u64,
182    pub created_at: u64,
183    // TTL is enforced logically at read time; cleanup is asynchronous.
184    pub ttl_secs: Option<u64>,
185}
186
187impl IntentPendingEntryRecord {
188    pub const STORABLE_MAX_SIZE: u32 = 224;
189}
190
191impl_storable_bounded!(
192    IntentPendingEntryRecord,
193    IntentPendingEntryRecord::STORABLE_MAX_SIZE,
194    false
195);
196
197///
198/// IntentStore
199///
200
201pub struct IntentStore;
202
203impl IntentStore {
204    // -------------------------------------------------------------
205    // Meta
206    // -------------------------------------------------------------
207
208    #[must_use]
209    pub(crate) fn meta() -> IntentStoreMetaRecord {
210        INTENT_META.with_borrow(|cell| *cell.get())
211    }
212
213    pub(crate) fn set_meta(meta: IntentStoreMetaRecord) {
214        INTENT_META.with_borrow_mut(|cell| cell.set(meta));
215    }
216
217    // -------------------------------------------------------------
218    // Records
219    // -------------------------------------------------------------
220
221    #[must_use]
222    pub(crate) fn get_record(id: IntentId) -> Option<IntentRecord> {
223        INTENT_RECORDS.with_borrow(|map| map.get(&id))
224    }
225
226    pub(crate) fn insert_record(record: IntentRecord) -> Option<IntentRecord> {
227        INTENT_RECORDS.with_borrow_mut(|map| map.insert(record.id, record))
228    }
229
230    // -------------------------------------------------------------
231    // Totals
232    // -------------------------------------------------------------
233
234    #[must_use]
235    pub(crate) fn get_totals(key: &IntentResourceKey) -> Option<IntentResourceTotalsRecord> {
236        INTENT_TOTALS.with_borrow(|map| map.get(key))
237    }
238
239    pub(crate) fn set_totals(
240        key: IntentResourceKey,
241        totals: IntentResourceTotalsRecord,
242    ) -> Option<IntentResourceTotalsRecord> {
243        INTENT_TOTALS.with_borrow_mut(|map| map.insert(key, totals))
244    }
245
246    // -------------------------------------------------------------
247    // Pending index
248    // -------------------------------------------------------------
249
250    #[must_use]
251    pub(crate) fn get_pending(id: IntentId) -> Option<IntentPendingEntryRecord> {
252        INTENT_PENDING.with_borrow(|map| map.get(&id))
253    }
254
255    pub(crate) fn insert_pending(
256        id: IntentId,
257        entry: IntentPendingEntryRecord,
258    ) -> Option<IntentPendingEntryRecord> {
259        INTENT_PENDING.with_borrow_mut(|map| map.insert(id, entry))
260    }
261
262    pub(crate) fn remove_pending(id: IntentId) -> Option<IntentPendingEntryRecord> {
263        INTENT_PENDING.with_borrow_mut(|map| map.remove(&id))
264    }
265
266    pub(crate) fn with_pending_entries<R>(
267        f: impl FnOnce(
268            &StableBtreeMap<IntentId, IntentPendingEntryRecord, VirtualMemory<DefaultMemoryImpl>>,
269        ) -> R,
270    ) -> R {
271        INTENT_PENDING.with_borrow(|map| f(map))
272    }
273}
274
275//
276// ─────────────────────────────────────────────────────────────
277// Test helpers
278// ─────────────────────────────────────────────────────────────
279//
280
281#[cfg(test)]
282impl IntentStore {
283    pub(crate) fn reset_for_tests() {
284        INTENT_RECORDS.with_borrow_mut(StableBtreeMap::clear_new);
285        INTENT_TOTALS.with_borrow_mut(StableBtreeMap::clear_new);
286        INTENT_PENDING.with_borrow_mut(StableBtreeMap::clear_new);
287        INTENT_META.with_borrow_mut(|cell| cell.set(IntentStoreMetaRecord::default()));
288    }
289}