use crate::live_migrate::plan::Step;
use crate::migrate::SchemaOperation;
pub mod backfill_then_tighten;
pub mod codec_transition;
pub mod generated_column_refusal;
pub mod index_dependent;
pub mod multi_fk_staging;
pub mod nullable_not_null;
pub mod replacement_column;
pub mod three_step_default;
pub mod two_phase_validate;
pub mod unique_via_index;
#[derive(Debug, Clone)]
pub struct PatternContext {
pub estimated_rows: Option<u64>,
pub validation_threshold_rows: u64,
pub multi_fk_threshold: u32,
pub backfill_chunk_size: u32,
}
impl PatternContext {
pub fn with_defaults() -> Self {
Self {
estimated_rows: None,
validation_threshold_rows: 100_000,
multi_fk_threshold: 4,
backfill_chunk_size: 10_000,
}
}
}
pub trait Pattern {
const ID: &'static str;
const IDEMPOTENT_PREDICATE: bool;
fn emit(op: &SchemaOperation, ctx: &PatternContext) -> Result<Vec<Step>, PatternError>;
}
pub fn dispatch_pattern(
op: &SchemaOperation,
ctx: &PatternContext,
) -> Result<Vec<Step>, PatternError> {
use crate::migrate::diff::ColumnChange;
use crate::migrate::schema::IndexKindSchema;
match op {
SchemaOperation::AlterColumn { change, .. } => match change {
ColumnChange::ChangeType { using: Some(_), .. } => Err(PatternError::CannotEmit {
pattern: "dispatch_pattern",
reason: "ColumnChange::ChangeType with adopter-supplied `using` (\
#[field(type_change_using = \"...\")]) is offline-only — \
the live-plan shadow-column pattern can only emit a default \
SQL cast in its backfill and cannot replicate a custom \
USING expression. Route through the offline-apply path \
(see `live_migrate::classify` OfflineOnly verdict)"
.to_string(),
}),
ColumnChange::ChangeType { .. } => replacement_column::ReplacementColumn::emit(op, ctx),
ColumnChange::CodecChange { .. } => Err(PatternError::CannotEmit {
pattern: "dispatch_pattern",
reason: "an at-rest codec change (a column gained, swapped, or dropped a \
`#[field(protected(codec = ...))]` codec) requires re-encoding every \
row and is never an online SQL-cast backfill. The classifier routes \
this OfflineOnly (add / drop) or ExpandContract (codec → codec); apply \
via the offline compose path. Online codec rotation is deferred to a \
post-v1 SchemaOperation (issue #371)."
.to_string(),
}),
ColumnChange::SetNullable(false) => nullable_not_null::NullableNotNull::emit(op, ctx),
_ => Err(PatternError::CannotEmit {
pattern: "dispatch_pattern",
reason: format!(
"no live-plan pattern covers AlterColumn change variant {change:?}"
),
}),
},
SchemaOperation::AddForeignKey { .. } => {
backfill_then_tighten::BackfillThenTighten::emit(op, ctx)
}
SchemaOperation::AddIndex(index) => match index.kind {
IndexKindSchema::UniqueConstraint | IndexKindSchema::UniqueIndex => {
unique_via_index::UniqueViaIndex::emit(op, ctx)
}
IndexKindSchema::NonUnique => index_dependent::IndexDependent::emit(op, ctx),
},
SchemaOperation::AddTable(_) => multi_fk_staging::MultiFkStaging::emit(op, ctx),
SchemaOperation::AddColumn { .. } => three_step_default::ThreeStepDefault::emit(op, ctx),
SchemaOperation::DropTable(_) => Err(PatternError::CannotEmit {
pattern: "dispatch_pattern",
reason: "DropTable live-plan staging is deferred to a later phase (this build \
dispatches AlterColumn / AddFK / AddIndex / AddTable / AddColumn)"
.to_string(),
}),
_ => Err(PatternError::CannotEmit {
pattern: "dispatch_pattern",
reason: format!(
"operation variant {} should not have been classified ExpandContract",
operation_variant_name(op),
),
}),
}
}
fn operation_variant_name(op: &SchemaOperation) -> &'static str {
match op {
SchemaOperation::AddTable(_) => "AddTable",
SchemaOperation::DropTable(_) => "DropTable",
SchemaOperation::RenameTable { .. } => "RenameTable",
SchemaOperation::AddColumn { .. } => "AddColumn",
SchemaOperation::DropColumn { .. } => "DropColumn",
SchemaOperation::RenameColumn { .. } => "RenameColumn",
SchemaOperation::AlterColumn { .. } => "AlterColumn",
SchemaOperation::AddForeignKey { .. } => "AddForeignKey",
SchemaOperation::DropForeignKey { .. } => "DropForeignKey",
SchemaOperation::AddIndex(_) => "AddIndex",
SchemaOperation::DropIndex(_) => "DropIndex",
SchemaOperation::AddExclusionConstraint { .. } => "AddExclusionConstraint",
SchemaOperation::DropExclusionConstraint { .. } => "DropExclusionConstraint",
SchemaOperation::SetTableComment { .. } => "SetTableComment",
SchemaOperation::SetStorageParams { .. } => "SetStorageParams",
SchemaOperation::SetTablespace { .. } => "SetTablespace",
SchemaOperation::AddEnum(_) => "AddEnum",
SchemaOperation::DropEnum(_) => "DropEnum",
SchemaOperation::AddEnumVariant { .. } => "AddEnumVariant",
SchemaOperation::PkTypeFlip { .. } => "PkTypeFlip",
SchemaOperation::PkTypeFlipGroup(_) => "PkTypeFlipGroup",
SchemaOperation::PkTypeFlipMultiGroup(_) => "PkTypeFlipMultiGroup",
SchemaOperation::RenameApp { .. } => "RenameApp",
SchemaOperation::MoveModelBetweenApps { .. } => "MoveModelBetweenApps",
SchemaOperation::Unsupported { .. } => "Unsupported",
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum PatternError {
#[error("operation does not match pattern {pattern}: {reason}")]
WrongOperation {
pattern: &'static str,
reason: String,
},
#[error("pattern {pattern} cannot handle this operation: {reason}")]
CannotEmit {
pattern: &'static str,
reason: String,
},
#[error("invariant violation in pattern {pattern}: {detail}")]
Invariant {
pattern: &'static str,
detail: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
use crate::live_migrate::plan::{StepKind, StepParameters};
use crate::migrate::diff::ColumnChange;
#[test]
fn pattern_context_defaults_match_v3_plan() {
let ctx = PatternContext::with_defaults();
assert!(ctx.estimated_rows.is_none());
assert_eq!(ctx.validation_threshold_rows, 100_000);
assert_eq!(ctx.multi_fk_threshold, 4);
assert_eq!(ctx.backfill_chunk_size, 10_000);
}
#[test]
fn pattern_error_wrong_operation_displays_pattern_id() {
let err = PatternError::WrongOperation {
pattern: "demo",
reason: "expected AlterColumn".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("demo"));
assert!(msg.contains("expected AlterColumn"));
}
#[test]
fn pattern_error_cannot_emit_displays_pattern_id() {
let err = PatternError::CannotEmit {
pattern: "demo",
reason: "missing FK target".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("demo"));
assert!(msg.contains("missing FK target"));
}
#[test]
fn pattern_error_invariant_displays_pattern_id() {
let err = PatternError::Invariant {
pattern: "demo",
detail: "ordinal sequence broken".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("demo"));
assert!(msg.contains("ordinal sequence broken"));
}
#[test]
fn dispatch_witnesses_pattern_id_uniqueness() {
let ids = [
nullable_not_null::NullableNotNull::ID,
replacement_column::ReplacementColumn::ID,
codec_transition::CodecTransition::ID,
backfill_then_tighten::BackfillThenTighten::ID,
index_dependent::IndexDependent::ID,
two_phase_validate::TwoPhaseValidate::ID,
unique_via_index::UniqueViaIndex::ID,
three_step_default::ThreeStepDefault::ID,
multi_fk_staging::MultiFkStaging::ID,
];
let mut seen: Vec<&'static str> = ids.to_vec();
seen.sort_unstable();
seen.dedup();
assert_eq!(
seen.len(),
ids.len(),
"pattern IDs must be unique across the catalogue: {ids:?}",
);
}
#[test]
fn dispatch_witness_idempotent_predicate_flag_matches_emitted_shape() {
let ctx = PatternContext::with_defaults();
let op = SchemaOperation::AlterColumn {
table: "ledger_entry".to_string(),
column: "amount".to_string(),
change: ColumnChange::ChangeType {
from: "INTEGER".to_string(),
to: "BIGINT".to_string(),
using: None,
},
};
let steps = replacement_column::ReplacementColumn::emit(&op, &ctx).unwrap();
const { assert!(replacement_column::ReplacementColumn::IDEMPOTENT_PREDICATE) };
assert!(
steps
.iter()
.any(|s| matches!(s.parameters, StepParameters::BackfillChunked { .. })),
"claim mismatched: pattern advertises chunked backfill but emitted no BackfillChunked",
);
const { assert!(!index_dependent::IndexDependent::IDEMPOTENT_PREDICATE) };
const { assert!(!nullable_not_null::NullableNotNull::IDEMPOTENT_PREDICATE) };
let nn_op = SchemaOperation::AlterColumn {
table: "demo_t".to_string(),
column: "demo_col".to_string(),
change: ColumnChange::SetNullable(false),
};
let nn_steps = nullable_not_null::NullableNotNull::emit(&nn_op, &ctx).unwrap();
assert!(
nn_steps
.iter()
.all(|s| !matches!(s.parameters, StepParameters::BackfillChunked { .. })),
"nullable_not_null must NOT emit BackfillChunked",
);
let op = SchemaOperation::AddIndex(crate::migrate::schema::IndexSchema {
extension_dependency: None,
include: Vec::new(),
index_type: crate::migrate::schema::IndexTypeSchema::BTree,
kind: crate::migrate::schema::IndexKindSchema::NonUnique,
name: "demo_idx".to_string(),
nulls_not_distinct: false,
predicate: None,
requires_out_of_transaction: false,
table: "demo_t".to_string(),
target: crate::migrate::schema::IndexTargetSchema::Columns(vec![
crate::migrate::schema::IndexColumnSchema {
name: "demo_col".to_string(),
nulls: crate::migrate::schema::IndexNullsOrderSchema::Default,
opclass: None,
order: crate::migrate::schema::IndexOrderSchema::Asc,
},
]),
});
let steps = index_dependent::IndexDependent::emit(&op, &ctx).unwrap();
assert!(
steps
.iter()
.all(|s| !matches!(s.parameters, StepParameters::BackfillChunked { .. })),
"index_dependent must not emit BackfillChunked",
);
for step in &steps {
assert_eq!(step.kind, step.parameters.kind());
}
}
#[test]
fn dispatch_witness_steps_have_sequential_ordinals() {
let ctx = PatternContext::with_defaults();
let op = SchemaOperation::AlterColumn {
table: "demo_t".to_string(),
column: "demo_col".to_string(),
change: ColumnChange::SetNullable(false),
};
let steps = nullable_not_null::NullableNotNull::emit(&op, &ctx).unwrap();
for (idx, step) in steps.iter().enumerate() {
assert_eq!(step.ordinal as usize, idx);
assert_eq!(step.kind, step.parameters.kind());
}
}
#[test]
fn dispatch_pattern_routes_alter_column_change_type_to_replacement_column() {
let ctx = PatternContext::with_defaults();
let op = SchemaOperation::AlterColumn {
table: "ledger_entry".to_string(),
column: "amount".to_string(),
change: ColumnChange::ChangeType {
from: "INTEGER".to_string(),
to: "BIGINT".to_string(),
using: None,
},
};
let steps = dispatch_pattern(&op, &ctx).unwrap();
assert!(
steps
.iter()
.any(|s| matches!(s.parameters, StepParameters::BackfillChunked { .. })),
"ChangeType must route to a backfill-emitting pattern",
);
}
#[test]
fn dispatch_pattern_routes_alter_column_set_not_null_to_nullable_not_null() {
let ctx = PatternContext::with_defaults();
let op = SchemaOperation::AlterColumn {
table: "demo_t".to_string(),
column: "demo_col".to_string(),
change: ColumnChange::SetNullable(false),
};
let steps = dispatch_pattern(&op, &ctx).unwrap();
assert!(
steps
.iter()
.any(|s| s.kind == StepKind::FinalizeConstraints)
);
}
#[test]
fn dispatch_pattern_routes_add_index_unique_to_unique_via_index() {
use crate::migrate::schema::{
IndexColumnSchema, IndexKindSchema, IndexNullsOrderSchema, IndexOrderSchema,
IndexSchema, IndexTargetSchema, IndexTypeSchema,
};
let ctx = PatternContext::with_defaults();
let op = SchemaOperation::AddIndex(IndexSchema {
extension_dependency: None,
include: Vec::new(),
index_type: IndexTypeSchema::BTree,
kind: IndexKindSchema::UniqueIndex,
name: "uq_vehicle_vin".to_string(),
nulls_not_distinct: false,
predicate: None,
requires_out_of_transaction: false,
table: "vehicle".to_string(),
target: IndexTargetSchema::Columns(vec![IndexColumnSchema {
name: "vin".to_string(),
nulls: IndexNullsOrderSchema::Default,
opclass: None,
order: IndexOrderSchema::Asc,
}]),
});
let steps = dispatch_pattern(&op, &ctx).unwrap();
assert!(!steps.is_empty(), "unique index dispatch must emit steps");
}
#[test]
fn dispatch_pattern_rejects_unsupported_variant() {
let ctx = PatternContext::with_defaults();
let op = SchemaOperation::Unsupported {
reason: "test fixture".to_string(),
};
let err = dispatch_pattern(&op, &ctx).expect_err("Unsupported must refuse");
assert!(matches!(err, PatternError::CannotEmit { .. }));
}
#[test]
fn dispatch_pattern_rejects_drop_table_with_actionable_message() {
let ctx = PatternContext::with_defaults();
let op = SchemaOperation::DropTable("legacy_t".to_string());
let err = dispatch_pattern(&op, &ctx).expect_err("DropTable refused for now");
match err {
PatternError::CannotEmit { reason, .. } => {
assert!(reason.contains("DropTable") || reason.contains("deferred"));
}
other => panic!("expected CannotEmit, got {other:?}"),
}
}
#[test]
fn dispatch_pattern_refuses_change_type_with_adopter_using() {
let ctx = PatternContext::with_defaults();
let op = SchemaOperation::AlterColumn {
table: "items".to_string(),
column: "kind".to_string(),
change: ColumnChange::ChangeType {
from: "TEXT".to_string(),
to: "UUID".to_string(),
using: Some("(\"kind\"::text)::uuid".to_string()),
},
};
let err = dispatch_pattern(&op, &ctx).expect_err("dispatch must refuse using.is_some()");
match err {
PatternError::CannotEmit { reason, .. } => {
assert!(
reason.contains("type_change_using")
|| reason.contains("USING")
|| reason.contains("offline-only"),
"refusal reason should name the adopter USING / offline path: {reason}",
);
}
other => panic!("expected CannotEmit, got {other:?}"),
}
}
}