mod admission;
mod compatibility;
use crate::db::schema::{
FieldId, MutationPlan, MutationPublicationPreflight, MutationPublicationStatus,
PersistedFieldSnapshot, PersistedNestedLeafSnapshot, PersistedSchemaSnapshot, SchemaFieldSlot,
SchemaMutationExecutionPlan, SchemaMutationRequest, SchemaMutationRunnerContract,
SchemaMutationSupportedExecutionPath, SchemaMutationSupportedPathRejection,
schema_mutation_request_for_snapshots,
};
pub(in crate::db::schema) use admission::{
SchemaAdmissionIdentityComparison, SchemaAdmissionRejectionClassification,
SchemaAdmissionRejectionReason, schema_admission_rejection,
};
use compatibility::{
accepted_snapshot_extends_generated_indexes,
accepted_snapshot_extends_generated_with_ddl_fields,
field_has_supported_missing_absence_policy, generated_index_names_only_changed,
};
#[cfg(test)]
use admission::classify_schema_admission_rejection;
#[derive(Debug, Eq, PartialEq)]
pub(in crate::db::schema) enum SchemaTransitionDecision {
Accepted(SchemaTransitionPlan),
Rejected(SchemaTransitionRejection),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(in crate::db::schema) enum SchemaTransitionPlanKind {
AddExpressionIndex,
AddFieldPathIndex,
AppendOnlyNullableFields,
ExactMatch,
MetadataOnlyIndexRename,
}
#[derive(Debug, Eq, PartialEq)]
pub(in crate::db::schema) struct SchemaTransitionPlan {
kind: SchemaTransitionPlanKind,
mutation_plan: MutationPlan,
}
impl SchemaTransitionPlan {
fn from_mutation_request(
kind: SchemaTransitionPlanKind,
request: SchemaMutationRequest<'_>,
) -> Self {
Self {
kind,
mutation_plan: request.lower_to_plan(),
}
}
pub(in crate::db::schema) const fn kind(&self) -> SchemaTransitionPlanKind {
self.kind
}
#[expect(
dead_code,
reason = "0.152 keeps the raw publication status available for diagnostics while reconciliation uses preflight"
)]
pub(in crate::db::schema) fn publication_status(&self) -> MutationPublicationStatus {
self.mutation_plan.publication_status()
}
pub(in crate::db::schema) fn publication_preflight(
&self,
runner: &SchemaMutationRunnerContract,
) -> MutationPublicationPreflight {
self.mutation_plan.publication_preflight(runner)
}
#[expect(
dead_code,
reason = "0.152 stages mutation audit identity before diagnostics expose it"
)]
pub(in crate::db::schema) fn mutation_fingerprint(&self) -> [u8; 16] {
self.mutation_plan.fingerprint()
}
pub(in crate::db::schema) fn supported_developer_physical_path(
&self,
) -> Result<SchemaMutationSupportedExecutionPath, SchemaMutationSupportedPathRejection> {
self.mutation_plan.supported_developer_physical_path()
}
pub(in crate::db::schema) fn execution_plan(&self) -> SchemaMutationExecutionPlan {
self.mutation_plan.execution_plan()
}
#[cfg(test)]
pub(in crate::db::schema) const fn mutation_plan(&self) -> &MutationPlan {
&self.mutation_plan
}
#[cfg(test)]
pub(in crate::db::schema) fn added_field_count(&self) -> usize {
self.mutation_plan.added_field_count()
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(in crate::db::schema) enum SchemaTransitionRejectionKind {
EntityIdentity,
FieldContract,
FieldSlot,
RowLayout,
SchemaVersion,
Snapshot,
}
#[derive(Debug, Eq, PartialEq)]
pub(in crate::db::schema) struct SchemaTransitionRejection {
kind: SchemaTransitionRejectionKind,
detail: String,
admission: Option<SchemaAdmissionRejectionClassification>,
}
impl SchemaTransitionRejection {
pub(super) const fn new(
kind: SchemaTransitionRejectionKind,
detail: String,
admission: Option<SchemaAdmissionRejectionClassification>,
) -> Self {
Self {
kind,
detail,
admission,
}
}
pub(in crate::db::schema) const fn kind(&self) -> SchemaTransitionRejectionKind {
self.kind
}
pub(in crate::db::schema) const fn detail(&self) -> &str {
self.detail.as_str()
}
pub(in crate::db::schema) const fn admission(
&self,
) -> Option<SchemaAdmissionRejectionClassification> {
self.admission
}
}
pub(in crate::db::schema) fn decide_schema_transition(
actual: &PersistedSchemaSnapshot,
expected: &PersistedSchemaSnapshot,
) -> SchemaTransitionDecision {
if generated_index_names_only_changed(actual, expected) {
return SchemaTransitionDecision::Accepted(SchemaTransitionPlan::from_mutation_request(
SchemaTransitionPlanKind::MetadataOnlyIndexRename,
SchemaMutationRequest::ExactMatch,
));
}
if accepted_snapshot_extends_generated_indexes(actual, expected) {
return SchemaTransitionDecision::Accepted(SchemaTransitionPlan::from_mutation_request(
SchemaTransitionPlanKind::ExactMatch,
SchemaMutationRequest::ExactMatch,
));
}
if accepted_snapshot_extends_generated_with_ddl_fields(actual, expected) {
return SchemaTransitionDecision::Accepted(SchemaTransitionPlan::from_mutation_request(
SchemaTransitionPlanKind::ExactMatch,
SchemaMutationRequest::ExactMatch,
));
}
match schema_mutation_request_for_snapshots(actual, expected) {
SchemaMutationRequest::ExactMatch => {
return SchemaTransitionDecision::Accepted(
SchemaTransitionPlan::from_mutation_request(
SchemaTransitionPlanKind::ExactMatch,
SchemaMutationRequest::ExactMatch,
),
);
}
SchemaMutationRequest::AppendOnlyFields(added_fields)
if added_fields
.iter()
.all(field_has_supported_missing_absence_policy) =>
{
return SchemaTransitionDecision::Accepted(
SchemaTransitionPlan::from_mutation_request(
SchemaTransitionPlanKind::AppendOnlyNullableFields,
SchemaMutationRequest::AppendOnlyFields(added_fields),
),
);
}
SchemaMutationRequest::AddFieldPathIndex { target } => {
return SchemaTransitionDecision::Accepted(
SchemaTransitionPlan::from_mutation_request(
SchemaTransitionPlanKind::AddFieldPathIndex,
SchemaMutationRequest::AddFieldPathIndex { target },
),
);
}
SchemaMutationRequest::AddExpressionIndex { target } => {
return SchemaTransitionDecision::Accepted(
SchemaTransitionPlan::from_mutation_request(
SchemaTransitionPlanKind::AddExpressionIndex,
SchemaMutationRequest::AddExpressionIndex { target },
),
);
}
SchemaMutationRequest::AppendOnlyFields(_)
| SchemaMutationRequest::DropNonRequiredSecondaryIndex { .. }
| SchemaMutationRequest::AlterNullability { .. }
| SchemaMutationRequest::Incompatible => {}
}
let (kind, detail) = schema_snapshot_mismatch_detail(actual, expected);
SchemaTransitionDecision::Rejected(SchemaTransitionRejection::new(kind, detail, None))
}
fn schema_snapshot_mismatch_detail(
actual: &PersistedSchemaSnapshot,
expected: &PersistedSchemaSnapshot,
) -> (SchemaTransitionRejectionKind, String) {
if actual.entity_path() != expected.entity_path() {
return (
SchemaTransitionRejectionKind::EntityIdentity,
format!(
"entity path changed: stored='{}' generated='{}'",
actual.entity_path(),
expected.entity_path(),
),
);
}
if actual.entity_name() != expected.entity_name() {
return (
SchemaTransitionRejectionKind::EntityIdentity,
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,
) -> (SchemaTransitionRejectionKind, String) {
if actual.primary_key_field_ids() != expected.primary_key_field_ids() {
return (
SchemaTransitionRejectionKind::EntityIdentity,
format!(
"primary key field ids changed: stored={:?} generated={:?}",
actual
.primary_key_field_ids()
.iter()
.map(|field_id| field_id.get())
.collect::<Vec<_>>(),
expected
.primary_key_field_ids()
.iter()
.map(|field_id| field_id.get())
.collect::<Vec<_>>(),
),
);
}
if let Some(detail) = unsupported_generated_additive_field_detail(actual, expected) {
return (SchemaTransitionRejectionKind::FieldContract, detail);
}
if let Some(detail) = unsupported_generated_removed_field_detail(actual, expected) {
return (SchemaTransitionRejectionKind::FieldContract, detail);
}
if actual.row_layout() != expected.row_layout() {
return (
SchemaTransitionRejectionKind::RowLayout,
row_layout_mismatch_detail(actual, expected),
);
}
if actual.fields().len() != expected.fields().len() {
return (
SchemaTransitionRejectionKind::FieldContract,
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(mismatch) = field_snapshot_mismatch_detail(index, actual_field, expected_field)
{
return mismatch;
}
}
(
SchemaTransitionRejectionKind::Snapshot,
"schema snapshot changed".to_string(),
)
}
fn unsupported_generated_additive_field_detail(
actual: &PersistedSchemaSnapshot,
expected: &PersistedSchemaSnapshot,
) -> Option<String> {
let SchemaMutationRequest::AppendOnlyFields(added_fields) =
schema_mutation_request_for_snapshots(actual, expected)
else {
return None;
};
let index = actual.fields().len();
let field = &added_fields[0];
Some(format!(
"unsupported additive field transition: generated field[{index}] id={} slot={} name='{}' kind={:?} nullable={} default={:?}; field must be nullable without a default or carry a valid explicit persisted default payload",
field.id().get(),
field.slot().get(),
field.name(),
field.kind(),
field.nullable(),
field.default(),
))
}
fn unsupported_generated_removed_field_detail(
actual: &PersistedSchemaSnapshot,
expected: &PersistedSchemaSnapshot,
) -> Option<String> {
if actual.fields().len() <= expected.fields().len()
|| actual.row_layout().field_to_slot().len() <= expected.row_layout().field_to_slot().len()
{
return None;
}
if !actual
.fields()
.iter()
.zip(expected.fields())
.all(|(actual_field, expected_field)| actual_field == expected_field)
{
return None;
}
if !actual
.row_layout()
.field_to_slot()
.iter()
.zip(expected.row_layout().field_to_slot())
.all(|(actual_pair, expected_pair)| actual_pair == expected_pair)
{
return None;
}
let index = expected.fields().len();
let field = &actual.fields()[index];
Some(format!(
"unsupported removed field transition: stored field[{index}] id={} slot={} name='{}' kind={:?}; retained-slot support is not enabled yet",
field.id().get(),
field.slot().get(),
field.name(),
field.kind(),
))
}
fn row_layout_mismatch_detail(
actual: &PersistedSchemaSnapshot,
expected: &PersistedSchemaSnapshot,
) -> String {
let stored_count = actual.row_layout().field_to_slot().len();
let generated_count = expected.row_layout().field_to_slot().len();
let prefix = format!(
"row layout changed: stored_version={} generated_version={} stored_fields={} generated_fields={}",
actual.row_layout().version().get(),
expected.row_layout().version().get(),
stored_count,
generated_count,
);
if actual.row_layout().version() != expected.row_layout().version() {
return prefix;
}
if let Some(detail) = row_layout_first_pair_mismatch_detail(actual, expected) {
return format!("{prefix}; {detail}");
}
prefix
}
fn row_layout_first_pair_mismatch_detail(
actual: &PersistedSchemaSnapshot,
expected: &PersistedSchemaSnapshot,
) -> Option<String> {
for (index, (actual_pair, expected_pair)) in actual
.row_layout()
.field_to_slot()
.iter()
.zip(expected.row_layout().field_to_slot())
.enumerate()
{
if actual_pair != expected_pair {
return Some(format!(
"first_difference=row_layout[{index}] {}; {}",
row_layout_field_detail("stored", actual_pair.0, actual_pair.1, actual.fields()),
row_layout_field_detail(
"generated",
expected_pair.0,
expected_pair.1,
expected.fields(),
),
));
}
}
if actual.row_layout().field_to_slot().len() > expected.row_layout().field_to_slot().len() {
let index = expected.row_layout().field_to_slot().len();
let (field_id, slot) = actual.row_layout().field_to_slot()[index];
return Some(format!(
"first_difference=stored_extra row_layout[{index}] {}; generated_has_no_layout_entry",
row_layout_field_detail("stored", field_id, slot, actual.fields()),
));
}
if expected.row_layout().field_to_slot().len() > actual.row_layout().field_to_slot().len() {
let index = actual.row_layout().field_to_slot().len();
let (field_id, slot) = expected.row_layout().field_to_slot()[index];
return Some(format!(
"first_difference=generated_extra row_layout[{index}] stored_has_no_layout_entry; {}",
row_layout_field_detail("generated", field_id, slot, expected.fields()),
));
}
None
}
fn row_layout_field_detail(
label: &str,
field_id: FieldId,
slot: SchemaFieldSlot,
fields: &[PersistedFieldSnapshot],
) -> String {
let Some(field) = fields.iter().find(|field| field.id() == field_id) else {
return format!(
"{label}_field_id={} {label}_slot={} {label}_field_metadata=missing",
field_id.get(),
slot.get(),
);
};
format!(
"{label}_field_id={} {label}_slot={} {label}_name='{}' {label}_kind={:?}",
field_id.get(),
slot.get(),
field.name(),
field.kind(),
)
}
fn field_snapshot_mismatch_detail(
index: usize,
actual: &PersistedFieldSnapshot,
expected: &PersistedFieldSnapshot,
) -> Option<(SchemaTransitionRejectionKind, String)> {
if actual.id() != expected.id() {
return Some((
SchemaTransitionRejectionKind::FieldContract,
format!(
"field[{index}] id changed: stored={} generated={}",
actual.id().get(),
expected.id().get(),
),
));
}
if actual.name() != expected.name() {
return Some((
SchemaTransitionRejectionKind::FieldContract,
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<(SchemaTransitionRejectionKind, String)> {
if actual.slot() != expected.slot() {
return Some((
SchemaTransitionRejectionKind::FieldSlot,
format!(
"field[{index}] slot changed: stored={} generated={}",
actual.slot().get(),
expected.slot().get(),
),
));
}
if actual.kind() != expected.kind() {
return Some((
SchemaTransitionRejectionKind::FieldContract,
format!(
"field[{index}] kind changed: stored={:?} generated={:?}",
actual.kind(),
expected.kind(),
),
));
}
if actual.nested_leaves() != expected.nested_leaves() {
return Some((
SchemaTransitionRejectionKind::FieldContract,
nested_leaf_mismatch_detail(index, actual.nested_leaves(), expected.nested_leaves()),
));
}
field_snapshot_storage_mismatch_detail(index, actual, expected)
}
fn nested_leaf_mismatch_detail(
field_index: usize,
actual: &[PersistedNestedLeafSnapshot],
expected: &[PersistedNestedLeafSnapshot],
) -> String {
let prefix = format!(
"field[{field_index}] nested leaf metadata changed: stored={} generated={}",
actual.len(),
expected.len(),
);
if let Some(detail) = nested_leaf_first_mismatch_detail(actual, expected) {
return format!("{prefix}; {detail}");
}
prefix
}
fn nested_leaf_first_mismatch_detail(
actual: &[PersistedNestedLeafSnapshot],
expected: &[PersistedNestedLeafSnapshot],
) -> Option<String> {
for (index, (actual_leaf, expected_leaf)) in actual.iter().zip(expected).enumerate() {
if actual_leaf != expected_leaf {
return Some(format!(
"first_difference=nested_leaf[{index}] {}; {}",
nested_leaf_detail("stored", actual_leaf),
nested_leaf_detail("generated", expected_leaf),
));
}
}
if actual.len() > expected.len() {
let index = expected.len();
return Some(format!(
"first_difference=stored_extra nested_leaf[{index}] {}; generated_has_no_nested_leaf",
nested_leaf_detail("stored", &actual[index]),
));
}
if expected.len() > actual.len() {
let index = actual.len();
return Some(format!(
"first_difference=generated_extra nested_leaf[{index}] stored_has_no_nested_leaf; {}",
nested_leaf_detail("generated", &expected[index]),
));
}
None
}
fn nested_leaf_detail(label: &str, leaf: &PersistedNestedLeafSnapshot) -> String {
let path = if leaf.path().is_empty() {
"<root>".to_string()
} else {
leaf.path().join(".")
};
format!(
"{label}_path='{path}' {label}_kind={:?} {label}_nullable={} {label}_storage_decode={:?} {label}_leaf_codec={:?}",
leaf.kind(),
leaf.nullable(),
leaf.storage_decode(),
leaf.leaf_codec(),
)
}
fn field_snapshot_storage_mismatch_detail(
index: usize,
actual: &PersistedFieldSnapshot,
expected: &PersistedFieldSnapshot,
) -> Option<(SchemaTransitionRejectionKind, String)> {
if actual.nullable() != expected.nullable() {
return Some((
SchemaTransitionRejectionKind::FieldContract,
format!(
"field[{index}] nullability changed: stored={} generated={}",
actual.nullable(),
expected.nullable(),
),
));
}
if actual.default() != expected.default() {
return Some((
SchemaTransitionRejectionKind::FieldContract,
format!(
"field[{index}] default changed: stored={:?} generated={:?}",
actual.default(),
expected.default(),
),
));
}
if actual.storage_decode() != expected.storage_decode() {
return Some((
SchemaTransitionRejectionKind::FieldContract,
format!(
"field[{index}] storage decode changed: stored={:?} generated={:?}",
actual.storage_decode(),
expected.storage_decode(),
),
));
}
if actual.leaf_codec() != expected.leaf_codec() {
return Some((
SchemaTransitionRejectionKind::FieldContract,
format!(
"field[{index}] leaf codec changed: stored={:?} generated={:?}",
actual.leaf_codec(),
expected.leaf_codec(),
),
));
}
None
}
#[cfg(test)]
mod tests;