use serde::{Deserialize, Serialize};
use crate::migrate::OnlineSafetyClassification;
use crate::types::HeerId;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum StepKind {
ExpandSchema,
BeginCompatibilityWindow,
BackfillChunked,
ValidateBackfill,
CutoverReads,
CutoverWrites,
FinalizeConstraints,
CleanupLegacyState,
RunReversibleSchemaOp,
}
const STEP_KIND_LABELS: &[(StepKind, &str)] = &[
(StepKind::ExpandSchema, "expand_schema"),
(
StepKind::BeginCompatibilityWindow,
"begin_compatibility_window",
),
(StepKind::BackfillChunked, "backfill_chunked"),
(StepKind::ValidateBackfill, "validate_backfill"),
(StepKind::CutoverReads, "cutover_reads"),
(StepKind::CutoverWrites, "cutover_writes"),
(StepKind::FinalizeConstraints, "finalize_constraints"),
(StepKind::CleanupLegacyState, "cleanup_legacy_state"),
(StepKind::RunReversibleSchemaOp, "run_reversible_schema_op"),
];
const _STEP_KIND_DRIFT_GUARD: () = {
const fn check(k: StepKind) -> u8 {
match k {
StepKind::ExpandSchema => 0,
StepKind::BeginCompatibilityWindow => 1,
StepKind::BackfillChunked => 2,
StepKind::ValidateBackfill => 3,
StepKind::CutoverReads => 4,
StepKind::CutoverWrites => 5,
StepKind::FinalizeConstraints => 6,
StepKind::CleanupLegacyState => 7,
StepKind::RunReversibleSchemaOp => 8,
}
}
let _ = check(StepKind::ExpandSchema);
};
impl StepKind {
pub fn as_db_str(self) -> &'static str {
STEP_KIND_LABELS
.iter()
.find_map(|(v, label)| (*v == self).then_some(*label))
.expect("invariant: StepKind variant missing from STEP_KIND_LABELS")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum PlanClassification {
OnlineSafe,
ExpandContract,
OfflineOnly,
}
impl From<OnlineSafetyClassification> for Option<PlanClassification> {
fn from(value: OnlineSafetyClassification) -> Self {
match value {
OnlineSafetyClassification::OnlineSafe => Some(PlanClassification::OnlineSafe),
OnlineSafetyClassification::ExpandContract => Some(PlanClassification::ExpandContract),
OnlineSafetyClassification::OfflineOnly => Some(PlanClassification::OfflineOnly),
OnlineSafetyClassification::FastLockDestructiveGuarded => None,
}
}
}
const PLAN_CLASSIFICATION_LABELS: &[(PlanClassification, &str)] = &[
(PlanClassification::OnlineSafe, "online_safe"),
(PlanClassification::ExpandContract, "expand_contract"),
(PlanClassification::OfflineOnly, "offline_only"),
];
const _PLAN_CLASSIFICATION_DRIFT_GUARD: () = {
const fn check(c: PlanClassification) -> u8 {
match c {
PlanClassification::OnlineSafe => 0,
PlanClassification::ExpandContract => 1,
PlanClassification::OfflineOnly => 2,
}
}
let _ = check(PlanClassification::OnlineSafe);
};
impl PlanClassification {
pub fn as_db_str(self) -> &'static str {
PLAN_CLASSIFICATION_LABELS
.iter()
.find_map(|(v, label)| (*v == self).then_some(*label))
.expect("invariant: PlanClassification variant missing from PLAN_CLASSIFICATION_LABELS")
}
pub fn from_db_str(s: &str) -> Option<Self> {
PLAN_CLASSIFICATION_LABELS
.iter()
.find_map(|(variant, label)| (*label == s).then_some(*variant))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[non_exhaustive]
pub enum StepParameters {
ExpandSchema { sql_segments: Vec<String> },
BeginCompatibilityWindow { hooks: Vec<String> },
BackfillChunked {
table: String,
predicate_template: String,
chunk_size: u32,
},
ValidateBackfill { gate_query: String },
CutoverReads { description: String },
CutoverWrites { description: String },
FinalizeConstraints { sql_segments: Vec<String> },
CleanupLegacyState { sql_segments: Vec<String> },
RunReversibleSchemaOp { up_sql: String, down_sql: String },
}
impl StepParameters {
pub const fn kind(&self) -> StepKind {
match self {
StepParameters::ExpandSchema { .. } => StepKind::ExpandSchema,
StepParameters::BeginCompatibilityWindow { .. } => StepKind::BeginCompatibilityWindow,
StepParameters::BackfillChunked { .. } => StepKind::BackfillChunked,
StepParameters::ValidateBackfill { .. } => StepKind::ValidateBackfill,
StepParameters::CutoverReads { .. } => StepKind::CutoverReads,
StepParameters::CutoverWrites { .. } => StepKind::CutoverWrites,
StepParameters::FinalizeConstraints { .. } => StepKind::FinalizeConstraints,
StepParameters::CleanupLegacyState { .. } => StepKind::CleanupLegacyState,
StepParameters::RunReversibleSchemaOp { .. } => StepKind::RunReversibleSchemaOp,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Step {
pub kind: StepKind,
pub ordinal: u32,
pub parameters: StepParameters,
}
impl Step {
pub fn is_consistent(&self) -> bool {
self.kind == self.parameters.kind()
}
pub fn emits_destructive_sql(&self) -> bool {
match (&self.kind, &self.parameters) {
(StepKind::CleanupLegacyState, _) => true,
(
StepKind::RunReversibleSchemaOp,
StepParameters::RunReversibleSchemaOp { up_sql, .. },
) => sql_contains_destructive_token(up_sql),
_ => false,
}
}
}
fn sql_contains_destructive_token(sql: &str) -> bool {
const DROP_KIND_KEYWORDS: &[&[u8]] = &[
b"TABLE",
b"COLUMN",
b"INDEX",
b"CONSTRAINT",
b"SCHEMA",
b"TYPE",
b"VIEW",
b"FUNCTION",
b"TRIGGER",
];
let bytes = sql.as_bytes();
let n = bytes.len();
let is_ident_byte = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
let is_ws_byte = |b: u8| matches!(b, b' ' | b'\t' | b'\n' | b'\r');
let mut i = 0usize;
while i < n {
let prev_ok = i == 0 || !is_ident_byte(bytes[i - 1]);
if prev_ok {
if bytes_match_kw(&bytes[i..], b"TRUNCATE") {
let after = i + b"TRUNCATE".len();
let next_ok = after == n || !is_ident_byte(bytes[after]);
if next_ok {
return true;
}
}
if bytes_match_kw(&bytes[i..], b"DROP") {
let after_drop = i + b"DROP".len();
if after_drop < n && !is_ident_byte(bytes[after_drop]) {
let mut j = after_drop;
while j < n && is_ws_byte(bytes[j]) {
j += 1;
}
if j < n {
for kw in DROP_KIND_KEYWORDS {
if bytes_match_kw(&bytes[j..], kw) {
let after_kw = j + kw.len();
let next_ok = after_kw == n || !is_ident_byte(bytes[after_kw]);
if next_ok {
return true;
}
}
}
}
}
}
}
i += 1;
}
false
}
fn bytes_match_kw(input: &[u8], kw: &[u8]) -> bool {
if input.len() < kw.len() {
return false;
}
for (a, b) in input.iter().zip(kw.iter()) {
if !a.eq_ignore_ascii_case(b) {
return false;
}
}
true
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlanHeader {
pub plan_id: HeerId,
pub slug: String,
pub classification: PlanClassification,
pub originating_migration: String,
pub target_database: String,
pub app_label: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LivePlan {
pub header: PlanHeader,
pub steps: Vec<Step>,
}
impl LivePlan {
pub fn has_destructive_steps(&self) -> bool {
self.steps.iter().any(Step::emits_destructive_sql)
}
pub fn validate(&self) -> Result<(), PlanValidationError> {
if self.steps.is_empty() {
return Err(PlanValidationError::EmptySteps);
}
for (idx, step) in self.steps.iter().enumerate() {
if !step.is_consistent() {
return Err(PlanValidationError::KindMismatch {
ordinal: step.ordinal,
declared: step.kind,
parameters_kind: step.parameters.kind(),
});
}
let expected = u32::try_from(idx).map_err(|_| PlanValidationError::TooManySteps)?;
if step.ordinal != expected {
return Err(PlanValidationError::OrdinalGap {
position: idx,
expected,
observed: step.ordinal,
});
}
}
validate_slug_bytes(&self.header.slug)?;
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum PlanValidationError {
#[error("live plan has no steps")]
EmptySteps,
#[error("live plan has more than u32::MAX steps")]
TooManySteps,
#[error(
"step ordinal {ordinal}: kind {declared:?} disagrees with parameters tag {parameters_kind:?}"
)]
KindMismatch {
ordinal: u32,
declared: StepKind,
parameters_kind: StepKind,
},
#[error("step at position {position}: expected ordinal {expected}, observed {observed}")]
OrdinalGap {
position: usize,
expected: u32,
observed: u32,
},
#[error("slug contains non-portable byte 0x{byte:02x} at offset {offset}")]
SlugByte { offset: usize, byte: u8 },
#[error("slug is empty")]
EmptySlug,
}
fn validate_slug_bytes(slug: &str) -> Result<(), PlanValidationError> {
if slug.is_empty() {
return Err(PlanValidationError::EmptySlug);
}
for (offset, &byte) in slug.as_bytes().iter().enumerate() {
let portable = byte.is_ascii_alphanumeric() || byte == b'_';
if !portable {
return Err(PlanValidationError::SlugByte { offset, byte });
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_step(kind: StepKind, ordinal: u32) -> Step {
let parameters = match kind {
StepKind::ExpandSchema => StepParameters::ExpandSchema {
sql_segments: vec!["ALTER TABLE foo ADD COLUMN bar INT".to_string()],
},
StepKind::BeginCompatibilityWindow => StepParameters::BeginCompatibilityWindow {
hooks: vec!["dual_read_foo_bar".to_string()],
},
StepKind::BackfillChunked => StepParameters::BackfillChunked {
table: "foo".to_string(),
predicate_template: "WHERE bar IS NULL AND id BETWEEN $1 AND $2".to_string(),
chunk_size: 10_000,
},
StepKind::ValidateBackfill => StepParameters::ValidateBackfill {
gate_query: "SELECT COUNT(*) FROM foo WHERE bar IS NULL".to_string(),
},
StepKind::CutoverReads => StepParameters::CutoverReads {
description: "flip read path to use bar".to_string(),
},
StepKind::CutoverWrites => StepParameters::CutoverWrites {
description: "flip write path to use bar".to_string(),
},
StepKind::FinalizeConstraints => StepParameters::FinalizeConstraints {
sql_segments: vec!["ALTER TABLE foo ALTER COLUMN bar SET NOT NULL".to_string()],
},
StepKind::CleanupLegacyState => StepParameters::CleanupLegacyState {
sql_segments: vec!["ALTER TABLE foo DROP COLUMN baz".to_string()],
},
StepKind::RunReversibleSchemaOp => StepParameters::RunReversibleSchemaOp {
up_sql: "CREATE INDEX idx_foo ON foo(bar)".to_string(),
down_sql: "DROP INDEX idx_foo".to_string(),
},
};
Step {
kind,
ordinal,
parameters,
}
}
fn sample_plan() -> LivePlan {
LivePlan {
header: PlanHeader {
plan_id: HeerId::ZERO,
slug: "demo_slug".to_string(),
classification: PlanClassification::ExpandContract,
originating_migration: "V20260428010203__demo".to_string(),
target_database: "main".to_string(),
app_label: "".to_string(),
},
steps: vec![
sample_step(StepKind::ExpandSchema, 0),
sample_step(StepKind::BackfillChunked, 1),
sample_step(StepKind::CutoverReads, 2),
],
}
}
#[test]
fn step_kind_round_trips_through_serde_json() {
let all = [
StepKind::ExpandSchema,
StepKind::BeginCompatibilityWindow,
StepKind::BackfillChunked,
StepKind::ValidateBackfill,
StepKind::CutoverReads,
StepKind::CutoverWrites,
StepKind::FinalizeConstraints,
StepKind::CleanupLegacyState,
StepKind::RunReversibleSchemaOp,
];
for kind in all {
let s = serde_json::to_string(&kind).unwrap();
let back: StepKind = serde_json::from_str(&s).unwrap();
assert_eq!(back, kind);
}
}
#[test]
fn step_kind_serializes_in_snake_case() {
let s = serde_json::to_string(&StepKind::BeginCompatibilityWindow).unwrap();
assert_eq!(s, "\"begin_compatibility_window\"");
let s = serde_json::to_string(&StepKind::RunReversibleSchemaOp).unwrap();
assert_eq!(s, "\"run_reversible_schema_op\"");
}
#[test]
fn step_kind_label_slice_covers_every_variant_with_canonical_form() {
let exhaustive_list = [
StepKind::ExpandSchema,
StepKind::BeginCompatibilityWindow,
StepKind::BackfillChunked,
StepKind::ValidateBackfill,
StepKind::CutoverReads,
StepKind::CutoverWrites,
StepKind::FinalizeConstraints,
StepKind::CleanupLegacyState,
StepKind::RunReversibleSchemaOp,
];
assert_eq!(
STEP_KIND_LABELS.len(),
exhaustive_list.len(),
"STEP_KIND_LABELS must contain exactly one row per StepKind variant"
);
for variant in exhaustive_list {
let row = STEP_KIND_LABELS
.iter()
.find(|(v, _)| *v == variant)
.unwrap_or_else(|| panic!("STEP_KIND_LABELS missing row for {variant:?}"));
let serialized = serde_json::to_string(&variant).unwrap();
let expected = format!("\"{}\"", row.1);
assert_eq!(
serialized, expected,
"serde label for {variant:?} drifted from STEP_KIND_LABELS",
);
}
}
#[test]
fn plan_classification_label_slice_covers_every_variant() {
let exhaustive_list = [
PlanClassification::OnlineSafe,
PlanClassification::ExpandContract,
PlanClassification::OfflineOnly,
];
assert_eq!(
PLAN_CLASSIFICATION_LABELS.len(),
exhaustive_list.len(),
"PLAN_CLASSIFICATION_LABELS must contain exactly one row per variant"
);
for variant in exhaustive_list {
let label = variant.as_db_str();
assert_eq!(
PlanClassification::from_db_str(label),
Some(variant),
"round-trip failed for {variant:?}",
);
}
}
#[test]
fn plan_classification_round_trips_through_serde_json() {
for c in [
PlanClassification::OnlineSafe,
PlanClassification::ExpandContract,
PlanClassification::OfflineOnly,
] {
let s = serde_json::to_string(&c).unwrap();
let back: PlanClassification = serde_json::from_str(&s).unwrap();
assert_eq!(back, c);
}
}
#[test]
fn plan_classification_db_str_round_trip() {
for c in [
PlanClassification::OnlineSafe,
PlanClassification::ExpandContract,
PlanClassification::OfflineOnly,
] {
assert_eq!(PlanClassification::from_db_str(c.as_db_str()), Some(c));
}
assert_eq!(PlanClassification::from_db_str("nope"), None);
}
#[test]
fn online_safety_classification_to_plan_classification() {
assert_eq!(
<Option<PlanClassification>>::from(OnlineSafetyClassification::OnlineSafe),
Some(PlanClassification::OnlineSafe)
);
assert_eq!(
<Option<PlanClassification>>::from(OnlineSafetyClassification::ExpandContract),
Some(PlanClassification::ExpandContract)
);
assert_eq!(
<Option<PlanClassification>>::from(OnlineSafetyClassification::OfflineOnly),
Some(PlanClassification::OfflineOnly)
);
assert_eq!(
<Option<PlanClassification>>::from(
OnlineSafetyClassification::FastLockDestructiveGuarded
),
None
);
}
#[test]
fn step_parameters_kind_returns_matching_tag() {
for kind in [
StepKind::ExpandSchema,
StepKind::BeginCompatibilityWindow,
StepKind::BackfillChunked,
StepKind::ValidateBackfill,
StepKind::CutoverReads,
StepKind::CutoverWrites,
StepKind::FinalizeConstraints,
StepKind::CleanupLegacyState,
StepKind::RunReversibleSchemaOp,
] {
let step = sample_step(kind, 0);
assert_eq!(step.parameters.kind(), kind);
assert!(step.is_consistent());
}
}
#[test]
fn step_inconsistent_when_kind_disagrees_with_parameters() {
let mut step = sample_step(StepKind::ExpandSchema, 0);
step.kind = StepKind::CleanupLegacyState;
assert!(!step.is_consistent());
}
#[test]
fn live_plan_round_trips_through_serde_json() {
let plan = sample_plan();
let s = serde_json::to_string_pretty(&plan).unwrap();
let back: LivePlan = serde_json::from_str(&s).unwrap();
assert_eq!(back, plan);
}
#[test]
fn live_plan_plan_id_serializes_as_string() {
let plan = sample_plan();
let s = serde_json::to_string(&plan).unwrap();
assert!(
s.contains("\"plan_id\":\"0\""),
"plan_id must serialize as a JSON string; got: {s}",
);
}
#[test]
fn live_plan_validate_accepts_well_formed_plan() {
let plan = sample_plan();
assert!(plan.validate().is_ok());
}
#[test]
fn live_plan_validate_rejects_kind_mismatch() {
let mut plan = sample_plan();
plan.steps[0].kind = StepKind::CleanupLegacyState;
let err = plan.validate().unwrap_err();
assert!(matches!(
err,
PlanValidationError::KindMismatch {
ordinal: 0,
declared: StepKind::CleanupLegacyState,
parameters_kind: StepKind::ExpandSchema,
}
));
}
#[test]
fn live_plan_validate_rejects_ordinal_gap() {
let mut plan = sample_plan();
plan.steps[1].ordinal = 5;
let err = plan.validate().unwrap_err();
assert!(matches!(
err,
PlanValidationError::OrdinalGap {
position: 1,
expected: 1,
observed: 5
}
));
}
#[test]
fn live_plan_validate_rejects_empty_steps() {
let mut plan = sample_plan();
plan.steps.clear();
assert!(matches!(
plan.validate().unwrap_err(),
PlanValidationError::EmptySteps
));
}
#[test]
fn live_plan_validate_rejects_non_portable_slug_byte() {
let mut plan = sample_plan();
plan.header.slug = "bad slug".to_string();
let err = plan.validate().unwrap_err();
assert!(matches!(
err,
PlanValidationError::SlugByte {
offset: 3,
byte: b' '
}
));
}
#[test]
fn live_plan_validate_rejects_empty_slug() {
let mut plan = sample_plan();
plan.header.slug = "".to_string();
assert!(matches!(
plan.validate().unwrap_err(),
PlanValidationError::EmptySlug
));
}
#[test]
fn step_ordinal_sort_is_stable() {
let mut steps = [
sample_step(StepKind::CutoverReads, 2),
sample_step(StepKind::ExpandSchema, 0),
sample_step(StepKind::BackfillChunked, 1),
];
steps.sort_by_key(|s| s.ordinal);
assert_eq!(steps[0].kind, StepKind::ExpandSchema);
assert_eq!(steps[1].kind, StepKind::BackfillChunked);
assert_eq!(steps[2].kind, StepKind::CutoverReads);
}
#[test]
fn cleanup_legacy_state_step_is_always_destructive() {
let step = sample_step(StepKind::CleanupLegacyState, 0);
assert!(step.emits_destructive_sql());
}
#[test]
fn expand_and_backfill_steps_are_not_destructive() {
for kind in [
StepKind::ExpandSchema,
StepKind::BeginCompatibilityWindow,
StepKind::BackfillChunked,
StepKind::ValidateBackfill,
StepKind::CutoverReads,
StepKind::CutoverWrites,
StepKind::FinalizeConstraints,
] {
let step = sample_step(kind, 0);
assert!(
!step.emits_destructive_sql(),
"{kind:?} must not be classified destructive",
);
}
}
#[test]
fn run_reversible_op_destructive_when_up_sql_drops_state() {
for sql in [
"DROP TABLE foo",
"alter table foo drop column bar",
"DROP INDEX CONCURRENTLY idx_x",
"TRUNCATE foo",
"DROP\nTABLE foo",
"ALTER TABLE foo DROP CONSTRAINT bar_fk",
] {
let step = Step {
kind: StepKind::RunReversibleSchemaOp,
ordinal: 0,
parameters: StepParameters::RunReversibleSchemaOp {
up_sql: sql.to_string(),
down_sql: "".to_string(),
},
};
assert!(
step.emits_destructive_sql(),
"expected `{sql}` to be classified destructive",
);
}
}
#[test]
fn run_reversible_op_not_destructive_when_up_sql_only_creates() {
for sql in [
"CREATE INDEX idx_foo ON foo(bar)",
"ALTER TABLE foo ADD COLUMN bar INT",
"CREATE TABLE bar (id BIGINT PRIMARY KEY)",
"-- DROP TABLE in a comment shouldn't trip but the helper is conservative",
] {
let step = Step {
kind: StepKind::RunReversibleSchemaOp,
ordinal: 0,
parameters: StepParameters::RunReversibleSchemaOp {
up_sql: sql.to_string(),
down_sql: "".to_string(),
},
};
let observed = step.emits_destructive_sql();
if sql.contains("DROP TABLE") || sql.contains("TRUNCATE") {
assert!(observed, "conservative: comment with DROP trips: {sql}");
} else {
assert!(!observed, "no destructive token in: {sql}");
}
}
}
#[test]
fn destructive_token_does_not_match_inside_identifiers() {
for sql in [
"INSERT INTO do_not_drop_table VALUES (1)",
"UPDATE dropped_records SET x = 1",
"SELECT truncate_log_id FROM foo",
] {
assert!(
!sql_contains_destructive_token(sql),
"must not match inside identifier: {sql}",
);
}
}
#[test]
fn live_plan_has_destructive_steps_walks_all_steps() {
let plan = sample_plan();
assert!(!plan.has_destructive_steps());
let mut plan2 = sample_plan();
plan2
.steps
.push(sample_step(StepKind::CleanupLegacyState, 3));
assert!(plan2.has_destructive_steps());
}
}