Skip to main content

icydb_core/db/schema/
store.rs

1//! Module: db::schema::store
2//! Responsibility: stable BTreeMap-backed schema metadata persistence.
3//! Does not own: reconciliation policy, typed snapshot encoding, or generated proposal construction.
4//! Boundary: provides the third per-store stable memory alongside row and index stores.
5
6use crate::{
7    db::schema::{
8        PersistedSchemaSnapshot, SchemaVersion, decode_persisted_schema_snapshot,
9        encode_persisted_schema_snapshot,
10    },
11    error::InternalError,
12    traits::Storable,
13    types::EntityTag,
14};
15use canic_cdk::structures::{BTreeMap, DefaultMemoryImpl, memory::VirtualMemory, storable::Bound};
16use std::borrow::Cow;
17
18const SCHEMA_KEY_BYTES_USIZE: usize = 12;
19const SCHEMA_KEY_BYTES: u32 = 12;
20const MAX_SCHEMA_SNAPSHOT_BYTES: u32 = 512 * 1024;
21
22///
23/// RawSchemaKey
24///
25/// Stable key for one persisted schema snapshot entry.
26/// It combines the entity tag and schema version so reconciliation can load
27/// concrete versions without depending on generated entity names.
28///
29
30#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
31struct RawSchemaKey([u8; SCHEMA_KEY_BYTES_USIZE]);
32
33#[allow(
34    dead_code,
35    reason = "raw schema keys are populated by upcoming startup reconciliation"
36)]
37impl RawSchemaKey {
38    /// Build the raw persisted key for one entity schema version.
39    #[must_use]
40    fn from_entity_version(entity: EntityTag, version: SchemaVersion) -> Self {
41        let mut out = [0u8; SCHEMA_KEY_BYTES_USIZE];
42        out[..size_of::<u64>()].copy_from_slice(&entity.value().to_be_bytes());
43        out[size_of::<u64>()..].copy_from_slice(&version.get().to_be_bytes());
44
45        Self(out)
46    }
47
48    /// Return the entity tag encoded in this schema key.
49    #[must_use]
50    fn entity_tag(self) -> EntityTag {
51        let mut bytes = [0u8; size_of::<u64>()];
52        bytes.copy_from_slice(&self.0[..size_of::<u64>()]);
53
54        EntityTag::new(u64::from_be_bytes(bytes))
55    }
56
57    /// Return the schema version encoded in this schema key.
58    #[must_use]
59    fn version(self) -> u32 {
60        let mut bytes = [0u8; size_of::<u32>()];
61        bytes.copy_from_slice(&self.0[size_of::<u64>()..]);
62
63        u32::from_be_bytes(bytes)
64    }
65}
66
67impl Storable for RawSchemaKey {
68    fn to_bytes(&self) -> Cow<'_, [u8]> {
69        Cow::Borrowed(&self.0)
70    }
71
72    fn from_bytes(bytes: Cow<'_, [u8]>) -> Self {
73        debug_assert_eq!(
74            bytes.len(),
75            SCHEMA_KEY_BYTES_USIZE,
76            "RawSchemaKey::from_bytes received unexpected byte length",
77        );
78
79        if bytes.len() != SCHEMA_KEY_BYTES_USIZE {
80            return Self([0u8; SCHEMA_KEY_BYTES_USIZE]);
81        }
82
83        let mut out = [0u8; SCHEMA_KEY_BYTES_USIZE];
84        out.copy_from_slice(bytes.as_ref());
85        Self(out)
86    }
87
88    fn into_bytes(self) -> Vec<u8> {
89        self.0.to_vec()
90    }
91
92    const BOUND: Bound = Bound::Bounded {
93        max_size: SCHEMA_KEY_BYTES,
94        is_fixed_size: true,
95    };
96}
97
98///
99/// RawSchemaSnapshot
100///
101/// Raw persisted schema snapshot payload.
102/// This wrapper stores the encoded `PersistedSchemaSnapshot` payload while
103/// keeping the stable-memory value boundary independent from the typed schema
104/// DTOs used by reconciliation.
105///
106
107#[derive(Clone, Debug, Eq, PartialEq)]
108struct RawSchemaSnapshot(Vec<u8>);
109
110impl RawSchemaSnapshot {
111    /// Encode one typed persisted-schema snapshot into a raw store payload.
112    fn from_persisted_snapshot(snapshot: &PersistedSchemaSnapshot) -> Result<Self, InternalError> {
113        encode_persisted_schema_snapshot(snapshot).map(Self)
114    }
115
116    /// Build one raw schema snapshot from already-encoded bytes.
117    #[must_use]
118    #[cfg(test)]
119    const fn from_bytes(bytes: Vec<u8>) -> Self {
120        Self(bytes)
121    }
122
123    /// Borrow the encoded schema snapshot payload.
124    #[must_use]
125    const fn as_bytes(&self) -> &[u8] {
126        self.0.as_slice()
127    }
128
129    /// Consume the snapshot into its encoded payload bytes.
130    #[must_use]
131    #[cfg(test)]
132    fn into_bytes(self) -> Vec<u8> {
133        self.0
134    }
135
136    /// Decode this raw store payload into a typed persisted-schema snapshot.
137    fn decode_persisted_snapshot(&self) -> Result<PersistedSchemaSnapshot, InternalError> {
138        decode_persisted_schema_snapshot(self.as_bytes())
139    }
140}
141
142impl Storable for RawSchemaSnapshot {
143    fn to_bytes(&self) -> Cow<'_, [u8]> {
144        Cow::Borrowed(self.as_bytes())
145    }
146
147    fn from_bytes(bytes: Cow<'_, [u8]>) -> Self {
148        Self(bytes.into_owned())
149    }
150
151    fn into_bytes(self) -> Vec<u8> {
152        self.0
153    }
154
155    const BOUND: Bound = Bound::Bounded {
156        max_size: MAX_SCHEMA_SNAPSHOT_BYTES,
157        is_fixed_size: false,
158    };
159}
160
161///
162/// SchemaStore
163///
164/// Thin persistence wrapper over one stable schema metadata BTreeMap.
165/// Startup reconciliation writes and validates encoded schema snapshots here
166/// before row/index operations proceed.
167///
168
169pub struct SchemaStore {
170    map: BTreeMap<RawSchemaKey, RawSchemaSnapshot, VirtualMemory<DefaultMemoryImpl>>,
171}
172
173impl SchemaStore {
174    /// Initialize the schema store with the provided backing memory.
175    #[must_use]
176    pub fn init(memory: VirtualMemory<DefaultMemoryImpl>) -> Self {
177        Self {
178            map: BTreeMap::init(memory),
179        }
180    }
181
182    /// Insert or replace one typed persisted schema snapshot.
183    pub(in crate::db) fn insert_persisted_snapshot(
184        &mut self,
185        entity: EntityTag,
186        snapshot: &PersistedSchemaSnapshot,
187    ) -> Result<(), InternalError> {
188        let key = RawSchemaKey::from_entity_version(entity, snapshot.version());
189        let raw_snapshot = RawSchemaSnapshot::from_persisted_snapshot(snapshot)?;
190        let _ = self.insert_raw_snapshot(key, raw_snapshot);
191
192        Ok(())
193    }
194
195    /// Load and decode one typed persisted schema snapshot.
196    pub(in crate::db) fn get_persisted_snapshot(
197        &self,
198        entity: EntityTag,
199        version: SchemaVersion,
200    ) -> Result<Option<PersistedSchemaSnapshot>, InternalError> {
201        let key = RawSchemaKey::from_entity_version(entity, version);
202        self.get_raw_snapshot(&key)
203            .map(|snapshot| snapshot.decode_persisted_snapshot())
204            .transpose()
205    }
206
207    /// Insert or replace one raw schema snapshot.
208    fn insert_raw_snapshot(
209        &mut self,
210        key: RawSchemaKey,
211        snapshot: RawSchemaSnapshot,
212    ) -> Option<RawSchemaSnapshot> {
213        self.map.insert(key, snapshot)
214    }
215
216    /// Load one raw schema snapshot by key.
217    #[must_use]
218    fn get_raw_snapshot(&self, key: &RawSchemaKey) -> Option<RawSchemaSnapshot> {
219        self.map.get(key)
220    }
221
222    /// Return whether one schema snapshot key is present.
223    #[must_use]
224    #[cfg(test)]
225    fn contains_raw_snapshot(&self, key: &RawSchemaKey) -> bool {
226        self.map.contains_key(key)
227    }
228
229    /// Return the number of schema snapshot entries in this store.
230    #[must_use]
231    #[cfg(test)]
232    pub(in crate::db) fn len(&self) -> u64 {
233        self.map.len()
234    }
235
236    /// Return whether this schema store currently has no persisted snapshots.
237    #[must_use]
238    #[cfg(test)]
239    pub(in crate::db) fn is_empty(&self) -> bool {
240        self.map.is_empty()
241    }
242
243    /// Clear all schema metadata entries from the store.
244    #[cfg(test)]
245    pub(in crate::db) fn clear(&mut self) {
246        self.map.clear();
247    }
248}
249
250///
251/// TESTS
252///
253
254#[cfg(test)]
255mod tests {
256    use super::{RawSchemaKey, RawSchemaSnapshot, SchemaStore};
257    use crate::{
258        db::schema::{
259            FieldId, PersistedFieldKind, PersistedFieldSnapshot, PersistedSchemaSnapshot,
260            SchemaFieldDefault, SchemaFieldSlot, SchemaRowLayout, SchemaVersion,
261        },
262        model::field::{FieldStorageDecode, LeafCodec, ScalarCodec},
263        testing::test_memory,
264        traits::Storable,
265        types::EntityTag,
266    };
267    use std::borrow::Cow;
268
269    #[test]
270    fn raw_schema_key_round_trips_entity_and_version() {
271        let key = RawSchemaKey::from_entity_version(EntityTag::new(0x0102_0304_0506_0708), {
272            SchemaVersion::initial()
273        });
274        let encoded = key.to_bytes().into_owned();
275        let decoded = RawSchemaKey::from_bytes(Cow::Owned(encoded));
276
277        assert_eq!(decoded.entity_tag(), EntityTag::new(0x0102_0304_0506_0708));
278        assert_eq!(decoded.version(), SchemaVersion::initial().get());
279    }
280
281    #[test]
282    fn raw_schema_snapshot_round_trips_payload_bytes() {
283        let snapshot = RawSchemaSnapshot::from_bytes(vec![1, 2, 3, 5, 8]);
284        let encoded = snapshot.to_bytes().into_owned();
285        let decoded = <RawSchemaSnapshot as Storable>::from_bytes(Cow::Owned(encoded));
286
287        assert_eq!(decoded.as_bytes(), &[1, 2, 3, 5, 8]);
288        assert_eq!(decoded.into_bytes(), vec![1, 2, 3, 5, 8]);
289    }
290
291    #[test]
292    fn schema_store_persists_raw_snapshots_by_entity_version_key() {
293        let mut store = SchemaStore::init(test_memory(251));
294        let key = RawSchemaKey::from_entity_version(EntityTag::new(17), SchemaVersion::initial());
295
296        assert!(store.is_empty());
297        assert!(!store.contains_raw_snapshot(&key));
298
299        store.insert_raw_snapshot(key, RawSchemaSnapshot::from_bytes(vec![9, 4, 6]));
300
301        assert_eq!(store.len(), 1);
302        assert!(store.contains_raw_snapshot(&key));
303        assert_eq!(
304            store
305                .get_raw_snapshot(&key)
306                .expect("schema snapshot should be present")
307                .as_bytes(),
308            &[9, 4, 6],
309        );
310
311        store.clear();
312        assert!(store.is_empty());
313    }
314
315    #[test]
316    fn raw_schema_snapshot_encodes_and_decodes_typed_snapshot() {
317        let snapshot = PersistedSchemaSnapshot::new(
318            SchemaVersion::initial(),
319            "entities::Encoded".to_string(),
320            "Encoded".to_string(),
321            FieldId::new(1),
322            SchemaRowLayout::new(
323                SchemaVersion::initial(),
324                vec![
325                    (FieldId::new(1), SchemaFieldSlot::new(0)),
326                    (FieldId::new(2), SchemaFieldSlot::new(1)),
327                ],
328            ),
329            vec![
330                PersistedFieldSnapshot::new(
331                    FieldId::new(1),
332                    "id".to_string(),
333                    SchemaFieldSlot::new(0),
334                    PersistedFieldKind::Ulid,
335                    false,
336                    SchemaFieldDefault::None,
337                    FieldStorageDecode::ByKind,
338                    LeafCodec::Scalar(ScalarCodec::Ulid),
339                ),
340                PersistedFieldSnapshot::new(
341                    FieldId::new(2),
342                    "payload".to_string(),
343                    SchemaFieldSlot::new(1),
344                    PersistedFieldKind::Map {
345                        key: Box::new(PersistedFieldKind::Text { max_len: None }),
346                        value: Box::new(PersistedFieldKind::List(Box::new(
347                            PersistedFieldKind::Uint,
348                        ))),
349                    },
350                    false,
351                    SchemaFieldDefault::None,
352                    FieldStorageDecode::ByKind,
353                    LeafCodec::StructuralFallback,
354                ),
355            ],
356        );
357
358        let raw = RawSchemaSnapshot::from_persisted_snapshot(&snapshot)
359            .expect("schema snapshot should encode");
360        let decoded = raw
361            .decode_persisted_snapshot()
362            .expect("schema snapshot should decode");
363
364        assert_eq!(decoded, snapshot);
365    }
366}