use crate::{
db::{
Db, EntityRuntimeHooks,
schema::{
AcceptedSchemaSnapshot, PersistedFieldSnapshot, PersistedSchemaSnapshot, SchemaStore,
compiled_schema_proposal_for_model,
},
},
error::InternalError,
model::entity::EntityModel,
traits::CanisterKind,
types::EntityTag,
};
pub(in crate::db) fn reconcile_runtime_schemas<C: CanisterKind>(
db: &Db<C>,
entity_runtime_hooks: &[EntityRuntimeHooks<C>],
) -> Result<(), InternalError> {
for hooks in entity_runtime_hooks {
reconcile_runtime_schema(db, hooks)?;
}
Ok(())
}
fn reconcile_runtime_schema<C: CanisterKind>(
db: &Db<C>,
hooks: &EntityRuntimeHooks<C>,
) -> Result<(), InternalError> {
let store = db.store_handle(hooks.store_path)?;
store.with_schema_mut(|schema_store| {
ensure_initial_schema_snapshot(
schema_store,
hooks.entity_tag,
hooks.entity_path,
hooks.model,
)
.map(|_| ())
})
}
pub(in crate::db) fn ensure_initial_schema_snapshot(
schema_store: &mut SchemaStore,
entity_tag: EntityTag,
entity_path: &str,
model: &EntityModel,
) -> Result<AcceptedSchemaSnapshot, InternalError> {
let proposal = compiled_schema_proposal_for_model(model);
let expected = proposal.initial_persisted_schema_snapshot();
if let Some(actual) = schema_store.get_persisted_snapshot(entity_tag, expected.version())? {
validate_existing_schema_snapshot(entity_path, &actual, &expected)?;
return Ok(AcceptedSchemaSnapshot::new(actual));
}
schema_store.insert_persisted_snapshot(entity_tag, &expected)?;
Ok(AcceptedSchemaSnapshot::new(expected))
}
fn validate_existing_schema_snapshot(
entity_path: &str,
actual: &crate::db::schema::PersistedSchemaSnapshot,
expected: &crate::db::schema::PersistedSchemaSnapshot,
) -> Result<(), InternalError> {
if actual == expected {
return Ok(());
}
let detail = schema_snapshot_mismatch_detail(actual, expected);
Err(InternalError::store_unsupported(format!(
"schema evolution is not yet supported for entity '{entity_path}': {detail}",
)))
}
fn schema_snapshot_mismatch_detail(
actual: &PersistedSchemaSnapshot,
expected: &PersistedSchemaSnapshot,
) -> String {
if actual.version() != expected.version() {
return format!(
"schema version changed: stored={} generated={}",
actual.version().get(),
expected.version().get(),
);
}
if actual.entity_path() != expected.entity_path() {
return format!(
"entity path changed: stored='{}' generated='{}'",
actual.entity_path(),
expected.entity_path(),
);
}
if actual.entity_name() != expected.entity_name() {
return format!(
"entity name changed: stored='{}' generated='{}'",
actual.entity_name(),
expected.entity_name(),
);
}
schema_snapshot_structural_mismatch_detail(actual, expected)
}
fn schema_snapshot_structural_mismatch_detail(
actual: &PersistedSchemaSnapshot,
expected: &PersistedSchemaSnapshot,
) -> String {
if actual.primary_key_field_id() != expected.primary_key_field_id() {
return format!(
"primary key field id changed: stored={} generated={}",
actual.primary_key_field_id().get(),
expected.primary_key_field_id().get(),
);
}
if actual.row_layout() != expected.row_layout() {
return format!(
"row layout changed: stored={:?} generated={:?}",
actual.row_layout(),
expected.row_layout(),
);
}
if actual.fields().len() != expected.fields().len() {
return format!(
"field count changed: stored={} generated={}",
actual.fields().len(),
expected.fields().len(),
);
}
for (index, (actual_field, expected_field)) in
actual.fields().iter().zip(expected.fields()).enumerate()
{
if let Some(detail) = field_snapshot_mismatch_detail(index, actual_field, expected_field) {
return detail;
}
}
"schema snapshot changed".to_string()
}
fn field_snapshot_mismatch_detail(
index: usize,
actual: &PersistedFieldSnapshot,
expected: &PersistedFieldSnapshot,
) -> Option<String> {
if actual.id() != expected.id() {
return Some(format!(
"field[{index}] id changed: stored={} generated={}",
actual.id().get(),
expected.id().get(),
));
}
if actual.name() != expected.name() {
return Some(format!(
"field[{index}] name changed: stored='{}' generated='{}'",
actual.name(),
expected.name(),
));
}
field_snapshot_contract_mismatch_detail(index, actual, expected)
}
fn field_snapshot_contract_mismatch_detail(
index: usize,
actual: &PersistedFieldSnapshot,
expected: &PersistedFieldSnapshot,
) -> Option<String> {
if actual.slot() != expected.slot() {
return Some(format!(
"field[{index}] slot changed: stored={} generated={}",
actual.slot().get(),
expected.slot().get(),
));
}
if actual.kind() != expected.kind() {
return Some(format!(
"field[{index}] kind changed: stored={:?} generated={:?}",
actual.kind(),
expected.kind(),
));
}
field_snapshot_storage_mismatch_detail(index, actual, expected)
}
fn field_snapshot_storage_mismatch_detail(
index: usize,
actual: &PersistedFieldSnapshot,
expected: &PersistedFieldSnapshot,
) -> Option<String> {
if actual.nullable() != expected.nullable() {
return Some(format!(
"field[{index}] nullability changed: stored={} generated={}",
actual.nullable(),
expected.nullable(),
));
}
if actual.default() != expected.default() {
return Some(format!(
"field[{index}] default changed: stored={:?} generated={:?}",
actual.default(),
expected.default(),
));
}
if actual.storage_decode() != expected.storage_decode() {
return Some(format!(
"field[{index}] storage decode changed: stored={:?} generated={:?}",
actual.storage_decode(),
expected.storage_decode(),
));
}
if actual.leaf_codec() != expected.leaf_codec() {
return Some(format!(
"field[{index}] leaf codec changed: stored={:?} generated={:?}",
actual.leaf_codec(),
expected.leaf_codec(),
));
}
None
}
#[cfg(test)]
mod tests {
use crate::{
db::{
Db, EntityRuntimeHooks,
data::DataStore,
index::IndexStore,
registry::StoreRegistry,
schema::{
PersistedSchemaSnapshot, SchemaStore, SchemaVersion,
compiled_schema_proposal_for_model,
},
},
error::ErrorClass,
model::field::FieldKind,
testing::test_memory,
traits::{EntityKind, EntitySchema, Path},
types::{EntityTag, Ulid},
};
use icydb_derive::{FieldProjection, PersistedRow};
use serde::Deserialize;
use std::cell::RefCell;
crate::test_canister! {
ident = SchemaReconcileTestCanister,
commit_memory_id = crate::testing::test_commit_memory_id(),
}
crate::test_store! {
ident = SchemaReconcileTestStore,
canister = SchemaReconcileTestCanister,
}
#[derive(Clone, Debug, Default, Deserialize, FieldProjection, PartialEq, PersistedRow)]
struct SchemaReconcileEntity {
id: Ulid,
name: String,
}
crate::test_entity_schema! {
ident = SchemaReconcileEntity,
id = Ulid,
id_field = id,
entity_name = "SchemaReconcileEntity",
entity_tag = EntityTag::new(0x7465_7374_7363_6865),
pk_index = 0,
fields = [
("id", FieldKind::Ulid),
("name", FieldKind::Text { max_len: None }),
],
indexes = [],
store = SchemaReconcileTestStore,
canister = SchemaReconcileTestCanister,
}
thread_local! {
static RECONCILE_DATA_STORE: RefCell<DataStore> =
RefCell::new(DataStore::init(test_memory(252)));
static RECONCILE_INDEX_STORE: RefCell<IndexStore> =
RefCell::new(IndexStore::init(test_memory(253)));
static RECONCILE_SCHEMA_STORE: RefCell<SchemaStore> =
RefCell::new(SchemaStore::init(test_memory(254)));
static RECONCILE_STORE_REGISTRY: StoreRegistry = {
let mut registry = StoreRegistry::new();
registry
.register_store(
SchemaReconcileTestStore::PATH,
&RECONCILE_DATA_STORE,
&RECONCILE_INDEX_STORE,
&RECONCILE_SCHEMA_STORE,
)
.expect("schema reconcile test store should register");
registry
};
}
static RECONCILE_RUNTIME_HOOKS: &[EntityRuntimeHooks<SchemaReconcileTestCanister>] =
&[EntityRuntimeHooks::for_entity::<SchemaReconcileEntity>()];
static RECONCILE_DB: Db<SchemaReconcileTestCanister> =
Db::new_with_hooks(&RECONCILE_STORE_REGISTRY, RECONCILE_RUNTIME_HOOKS);
fn reset_schema_store() {
RECONCILE_SCHEMA_STORE.with_borrow_mut(SchemaStore::clear);
}
#[test]
fn reconcile_runtime_schemas_writes_initial_snapshot_on_first_contact() {
reset_schema_store();
super::reconcile_runtime_schemas(&RECONCILE_DB, RECONCILE_RUNTIME_HOOKS)
.expect("initial schema reconciliation should write generated snapshot");
let snapshot = RECONCILE_SCHEMA_STORE
.with_borrow(|store| {
store.get_persisted_snapshot(
SchemaReconcileEntity::ENTITY_TAG,
SchemaVersion::initial(),
)
})
.expect("persisted schema snapshot should decode");
let snapshot = snapshot.expect("initial schema snapshot should be persisted");
assert_eq!(snapshot.entity_path(), SchemaReconcileEntity::PATH);
assert_eq!(snapshot.fields().len(), 2);
}
#[test]
fn reconcile_runtime_schemas_accepts_existing_matching_snapshot() {
reset_schema_store();
super::reconcile_runtime_schemas(&RECONCILE_DB, RECONCILE_RUNTIME_HOOKS)
.expect("initial schema reconciliation should write generated snapshot");
super::reconcile_runtime_schemas(&RECONCILE_DB, RECONCILE_RUNTIME_HOOKS)
.expect("matching persisted schema should be accepted");
assert_eq!(RECONCILE_SCHEMA_STORE.with_borrow(SchemaStore::len), 1);
}
#[test]
fn reconcile_runtime_schemas_rejects_changed_initial_snapshot() {
reset_schema_store();
let proposal = compiled_schema_proposal_for_model(SchemaReconcileEntity::MODEL);
let expected = proposal.initial_persisted_schema_snapshot();
let changed = PersistedSchemaSnapshot::new(
expected.version(),
expected.entity_path().to_string(),
"ChangedSchemaReconcileEntity".to_string(),
expected.primary_key_field_id(),
expected.row_layout().clone(),
expected.fields().to_vec(),
);
RECONCILE_SCHEMA_STORE.with_borrow_mut(|store| {
store
.insert_persisted_snapshot(SchemaReconcileEntity::ENTITY_TAG, &changed)
.expect("changed schema snapshot should encode");
});
let err = super::reconcile_runtime_schemas(&RECONCILE_DB, RECONCILE_RUNTIME_HOOKS)
.expect_err("schema reconciliation should reject changed persisted snapshot");
assert_eq!(err.class, ErrorClass::Unsupported);
assert!(
err.message
.contains("schema evolution is not yet supported"),
"schema mismatch should fail at the explicit evolution boundary"
);
assert!(
err.message
.contains("entity name changed: stored='ChangedSchemaReconcileEntity' generated='SchemaReconcileEntity'"),
"schema mismatch should include the first rejected difference"
);
}
}