icydb-core 0.94.0

IcyDB — A schema-first typed query engine and persistence runtime for Internet Computer canisters
Documentation
//! Module: db::registry
//! Responsibility: thread-local store registry lifecycle and lookup boundary.
//! Does not own: store encode/decode semantics or query/executor planning behavior.
//! Boundary: manages registry state for named data/index stores and typed registry errors.

use crate::{
    db::{
        cursor::IndexScanContinuationInput,
        data::DataStore,
        data::{DataKey, RawRow, StorageKey},
        direction::Direction,
        index::{
            IndexState, IndexStore, RawIndexEntry, RawIndexKey, SealedStructuralIndexEntryReader,
            SealedStructuralPrimaryRowReader, StructuralIndexEntryReader,
            StructuralPrimaryRowReader,
        },
    },
    error::{ErrorClass, ErrorOrigin, InternalError},
    model::index::IndexModel,
    types::EntityTag,
};
use std::{cell::RefCell, ops::Bound, thread::LocalKey};
use thiserror::Error as ThisError;

///
/// StoreRegistryError
///

#[derive(Debug, ThisError)]
#[expect(clippy::enum_variant_names)]
pub enum StoreRegistryError {
    #[error("store '{0}' not found")]
    StoreNotFound(String),

    #[error("store '{0}' already registered")]
    StoreAlreadyRegistered(String),

    #[error(
        "store '{name}' reuses the same row/index store pair already registered as '{existing_name}'"
    )]
    StoreHandlePairAlreadyRegistered { name: String, existing_name: String },
}

impl StoreRegistryError {
    pub(crate) const fn class(&self) -> ErrorClass {
        match self {
            Self::StoreNotFound(_) => ErrorClass::Internal,
            Self::StoreAlreadyRegistered(_) | Self::StoreHandlePairAlreadyRegistered { .. } => {
                ErrorClass::InvariantViolation
            }
        }
    }
}

impl From<StoreRegistryError> for InternalError {
    fn from(err: StoreRegistryError) -> Self {
        Self::classified(err.class(), ErrorOrigin::Store, err.to_string())
    }
}

///
/// StoreHandle
/// Bound pair of row and index stores for one schema `Store` path.
///

#[derive(Clone, Copy, Debug)]
pub struct StoreHandle {
    data: &'static LocalKey<RefCell<DataStore>>,
    index: &'static LocalKey<RefCell<IndexStore>>,
}

impl StoreHandle {
    /// Build a store handle from thread-local row/index stores.
    #[must_use]
    pub const fn new(
        data: &'static LocalKey<RefCell<DataStore>>,
        index: &'static LocalKey<RefCell<IndexStore>>,
    ) -> Self {
        Self { data, index }
    }

    /// Borrow the row store immutably.
    pub fn with_data<R>(&self, f: impl FnOnce(&DataStore) -> R) -> R {
        #[cfg(feature = "diagnostics")]
        {
            crate::db::physical_access::measure_physical_access_operation(|| {
                self.data.with_borrow(f)
            })
        }

        #[cfg(not(feature = "diagnostics"))]
        {
            self.data.with_borrow(f)
        }
    }

    /// Borrow the row store mutably.
    pub fn with_data_mut<R>(&self, f: impl FnOnce(&mut DataStore) -> R) -> R {
        self.data.with_borrow_mut(f)
    }

    /// Borrow the index store immutably.
    pub fn with_index<R>(&self, f: impl FnOnce(&IndexStore) -> R) -> R {
        #[cfg(feature = "diagnostics")]
        {
            crate::db::physical_access::measure_physical_access_operation(|| {
                self.index.with_borrow(f)
            })
        }

        #[cfg(not(feature = "diagnostics"))]
        {
            self.index.with_borrow(f)
        }
    }

    /// Borrow the index store mutably.
    pub fn with_index_mut<R>(&self, f: impl FnOnce(&mut IndexStore) -> R) -> R {
        self.index.with_borrow_mut(f)
    }

    /// Return the explicit lifecycle state of the bound index store.
    #[must_use]
    pub(in crate::db) fn index_state(&self) -> IndexState {
        self.with_index(IndexStore::state)
    }

    /// Mark the bound index store as Building.
    pub(in crate::db) fn mark_index_building(&self) {
        self.with_index_mut(IndexStore::mark_building);
    }

    /// Mark the bound index store as Ready.
    pub(in crate::db) fn mark_index_ready(&self) {
        self.with_index_mut(IndexStore::mark_ready);
    }

    /// Return the raw row-store accessor.
    #[must_use]
    pub const fn data_store(&self) -> &'static LocalKey<RefCell<DataStore>> {
        self.data
    }

    /// Return the raw index-store accessor.
    #[must_use]
    pub const fn index_store(&self) -> &'static LocalKey<RefCell<IndexStore>> {
        self.index
    }
}

impl StructuralPrimaryRowReader for StoreHandle {
    fn read_primary_row_structural(&self, key: &DataKey) -> Result<Option<RawRow>, InternalError> {
        let raw_key = key.to_raw()?;

        Ok(self.with_data(|store| store.get(&raw_key)))
    }
}

impl SealedStructuralPrimaryRowReader for StoreHandle {}

impl StructuralIndexEntryReader for StoreHandle {
    fn read_index_entry_structural(
        &self,
        store: &'static LocalKey<RefCell<IndexStore>>,
        key: &RawIndexKey,
    ) -> Result<Option<RawIndexEntry>, InternalError> {
        Ok(store.with_borrow(|index_store| index_store.get(key)))
    }

    fn read_index_keys_in_raw_range_structural(
        &self,
        _entity_path: &'static str,
        entity_tag: EntityTag,
        store: &'static LocalKey<RefCell<IndexStore>>,
        index: &IndexModel,
        bounds: (&Bound<RawIndexKey>, &Bound<RawIndexKey>),
        limit: usize,
    ) -> Result<Vec<StorageKey>, InternalError> {
        let data_keys = store.with_borrow(|index_store| {
            index_store.resolve_data_values_in_raw_range_limited(
                entity_tag,
                index,
                bounds,
                IndexScanContinuationInput::new(None, Direction::Asc),
                limit,
                None,
            )
        })?;

        let mut out = Vec::with_capacity(data_keys.len());
        for data_key in data_keys {
            out.push(data_key.storage_key());
        }

        Ok(out)
    }
}

impl SealedStructuralIndexEntryReader for StoreHandle {}

///
/// StoreRegistry
/// Thread-local registry for both row and index stores.
///

#[derive(Default)]
pub struct StoreRegistry {
    stores: Vec<(&'static str, StoreHandle)>,
}

impl StoreRegistry {
    /// Create an empty store registry.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Iterate registered stores.
    ///
    /// Iteration order follows registration order. Semantic result ordering
    /// must still not depend on this iteration order; callers that need
    /// deterministic ordering must sort by store path.
    pub fn iter(&self) -> impl Iterator<Item = (&'static str, StoreHandle)> {
        self.stores.iter().copied()
    }

    /// Register a `Store` path to its row/index store pair.
    pub fn register_store(
        &mut self,
        name: &'static str,
        data: &'static LocalKey<RefCell<DataStore>>,
        index: &'static LocalKey<RefCell<IndexStore>>,
    ) -> Result<(), InternalError> {
        if self
            .stores
            .iter()
            .any(|(existing_name, _)| *existing_name == name)
        {
            return Err(StoreRegistryError::StoreAlreadyRegistered(name.to_string()).into());
        }

        // Keep one canonical logical store name per physical row/index store pair.
        if let Some(existing_name) =
            self.stores
                .iter()
                .find_map(|(existing_name, existing_handle)| {
                    (std::ptr::eq(existing_handle.data_store(), data)
                        && std::ptr::eq(existing_handle.index_store(), index))
                    .then_some(*existing_name)
                })
        {
            return Err(StoreRegistryError::StoreHandlePairAlreadyRegistered {
                name: name.to_string(),
                existing_name: existing_name.to_string(),
            }
            .into());
        }

        self.stores.push((name, StoreHandle::new(data, index)));

        Ok(())
    }

    /// Look up a store handle by path.
    pub fn try_get_store(&self, path: &str) -> Result<StoreHandle, InternalError> {
        self.stores
            .iter()
            .find_map(|(existing_path, handle)| (*existing_path == path).then_some(*handle))
            .ok_or_else(|| StoreRegistryError::StoreNotFound(path.to_string()).into())
    }
}

///
/// TESTS
///

#[cfg(test)]
mod tests {
    use crate::{
        db::{data::DataStore, index::IndexStore, registry::StoreRegistry},
        error::{ErrorClass, ErrorOrigin},
        testing::test_memory,
    };
    use std::{cell::RefCell, ptr};

    const STORE_PATH: &str = "store_registry_tests::Store";
    const ALIAS_STORE_PATH: &str = "store_registry_tests::StoreAlias";

    thread_local! {
        static TEST_DATA_STORE: RefCell<DataStore> = RefCell::new(DataStore::init(test_memory(151)));
        static TEST_INDEX_STORE: RefCell<IndexStore> =
            RefCell::new(IndexStore::init(test_memory(152)));
    }

    fn test_registry() -> StoreRegistry {
        let mut registry = StoreRegistry::new();
        registry
            .register_store(STORE_PATH, &TEST_DATA_STORE, &TEST_INDEX_STORE)
            .expect("test store registration should succeed");
        registry
    }

    #[test]
    fn register_store_binds_data_and_index_handles() {
        let registry = test_registry();
        let handle = registry
            .try_get_store(STORE_PATH)
            .expect("registered store path should resolve");

        assert!(
            ptr::eq(handle.data_store(), &TEST_DATA_STORE),
            "store handle should expose the registered data store accessor"
        );
        assert!(
            ptr::eq(handle.index_store(), &TEST_INDEX_STORE),
            "store handle should expose the registered index store accessor"
        );

        let data_rows = handle.with_data(|store| store.len());
        let index_rows = handle.with_index(IndexStore::len);
        assert_eq!(data_rows, 0, "fresh test data store should be empty");
        assert_eq!(index_rows, 0, "fresh test index store should be empty");
    }

    #[test]
    fn missing_store_path_rejected_before_access() {
        let registry = StoreRegistry::new();
        let err = registry
            .try_get_store("store_registry_tests::Missing")
            .expect_err("missing path should fail lookup");

        assert_eq!(err.class, ErrorClass::Internal);
        assert_eq!(err.origin, ErrorOrigin::Store);
        assert!(
            err.message
                .contains("store 'store_registry_tests::Missing' not found"),
            "missing store lookup should include the missing path"
        );
    }

    #[test]
    fn duplicate_store_registration_is_rejected() {
        let mut registry = StoreRegistry::new();
        registry
            .register_store(STORE_PATH, &TEST_DATA_STORE, &TEST_INDEX_STORE)
            .expect("initial store registration should succeed");

        let err = registry
            .register_store(STORE_PATH, &TEST_DATA_STORE, &TEST_INDEX_STORE)
            .expect_err("duplicate registration should fail");
        assert_eq!(err.class, ErrorClass::InvariantViolation);
        assert_eq!(err.origin, ErrorOrigin::Store);
        assert!(
            err.message
                .contains("store 'store_registry_tests::Store' already registered"),
            "duplicate registration should include the conflicting path"
        );
    }

    #[test]
    fn alias_store_registration_reusing_same_store_pair_is_rejected() {
        let mut registry = StoreRegistry::new();
        registry
            .register_store(STORE_PATH, &TEST_DATA_STORE, &TEST_INDEX_STORE)
            .expect("initial store registration should succeed");

        let err = registry
            .register_store(ALIAS_STORE_PATH, &TEST_DATA_STORE, &TEST_INDEX_STORE)
            .expect_err("alias registration reusing the same store pair should fail");
        assert_eq!(err.class, ErrorClass::InvariantViolation);
        assert_eq!(err.origin, ErrorOrigin::Store);
        assert!(
            err.message.contains(
                "store 'store_registry_tests::StoreAlias' reuses the same row/index store pair"
            ),
            "alias registration should include conflicting alias path"
        );
        assert!(
            err.message
                .contains("registered as 'store_registry_tests::Store'"),
            "alias registration should include original path"
        );
    }
}