use crate::db::schema::{PersistedFieldSnapshot, PersistedSchemaSnapshot};
#[derive(Debug, Eq, PartialEq)]
pub(in crate::db::schema) enum SchemaTransitionDecision {
ExactMatch,
Rejected(SchemaTransitionRejection),
}
#[derive(Debug, Eq, PartialEq)]
pub(in crate::db::schema) struct SchemaTransitionRejection {
detail: String,
}
impl SchemaTransitionRejection {
const fn new(detail: String) -> Self {
Self { detail }
}
pub(in crate::db::schema) const fn detail(&self) -> &str {
self.detail.as_str()
}
}
pub(in crate::db::schema) fn decide_schema_transition(
actual: &PersistedSchemaSnapshot,
expected: &PersistedSchemaSnapshot,
) -> SchemaTransitionDecision {
if actual == expected {
return SchemaTransitionDecision::ExactMatch;
}
SchemaTransitionDecision::Rejected(SchemaTransitionRejection::new(
schema_snapshot_mismatch_detail(actual, expected),
))
}
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(),
));
}
if actual.nested_leaves() != expected.nested_leaves() {
return Some(format!(
"field[{index}] nested leaf metadata changed: stored={} generated={}",
actual.nested_leaves().len(),
expected.nested_leaves().len(),
));
}
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::schema::{
FieldId, PersistedFieldKind, PersistedFieldSnapshot, PersistedSchemaSnapshot,
SchemaFieldDefault, SchemaFieldSlot, SchemaRowLayout, SchemaTransitionDecision,
SchemaVersion, decide_schema_transition,
},
model::field::{FieldStorageDecode, LeafCodec, ScalarCodec},
};
fn expected_snapshot() -> PersistedSchemaSnapshot {
PersistedSchemaSnapshot::new(
SchemaVersion::initial(),
"test::SchemaReconcileEntity".to_string(),
"SchemaReconcileEntity".to_string(),
FieldId::new(1),
SchemaRowLayout::new(
SchemaVersion::initial(),
vec![
(FieldId::new(1), SchemaFieldSlot::new(0)),
(FieldId::new(2), SchemaFieldSlot::new(1)),
],
),
vec![
PersistedFieldSnapshot::new(
FieldId::new(1),
"id".to_string(),
SchemaFieldSlot::new(0),
PersistedFieldKind::Ulid,
Vec::new(),
false,
SchemaFieldDefault::None,
FieldStorageDecode::ByKind,
LeafCodec::Scalar(ScalarCodec::Ulid),
),
PersistedFieldSnapshot::new(
FieldId::new(2),
"name".to_string(),
SchemaFieldSlot::new(1),
PersistedFieldKind::Text { max_len: None },
Vec::new(),
false,
SchemaFieldDefault::None,
FieldStorageDecode::ByKind,
LeafCodec::Scalar(ScalarCodec::Text),
),
],
)
}
fn changed_entity_name_snapshot(expected: &PersistedSchemaSnapshot) -> PersistedSchemaSnapshot {
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(),
)
}
#[test]
fn schema_transition_policy_accepts_only_exact_snapshot_match() {
let expected = expected_snapshot();
assert_eq!(
decide_schema_transition(&expected, &expected),
SchemaTransitionDecision::ExactMatch,
);
let changed = changed_entity_name_snapshot(&expected);
let SchemaTransitionDecision::Rejected(rejection) =
decide_schema_transition(&changed, &expected)
else {
panic!("changed schema snapshot should be rejected");
};
assert!(
rejection
.detail()
.contains("entity name changed: stored='ChangedSchemaReconcileEntity' generated='SchemaReconcileEntity'"),
"transition rejection should retain the first schema mismatch detail",
);
}
#[test]
fn schema_transition_policy_reports_row_layout_mismatch_after_entity_identity() {
let expected = expected_snapshot();
let changed = PersistedSchemaSnapshot::new(
expected.version(),
expected.entity_path().to_string(),
expected.entity_name().to_string(),
expected.primary_key_field_id(),
SchemaRowLayout::new(
SchemaVersion::initial(),
vec![
(FieldId::new(1), SchemaFieldSlot::new(1)),
(FieldId::new(2), SchemaFieldSlot::new(0)),
],
),
expected.fields().to_vec(),
);
let SchemaTransitionDecision::Rejected(rejection) =
decide_schema_transition(&changed, &expected)
else {
panic!("changed row layout should be rejected");
};
assert!(
rejection.detail().contains("row layout changed"),
"row-layout drift should be reported before field metadata drift",
);
}
}