use crate::DjogiError;
use crate::context::DjogiContext;
use crate::live_migrate::plan_file::PlanFileError;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ComposeError {
#[error("schema diff failed: {0}")]
Diff(String),
#[error("offline-only operation cannot be composed as live plan")]
OfflineOnly,
#[error("active plan already exists in bucket")]
ActivePlanExists,
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Serialize(PlanFileError),
#[error(transparent)]
Db(DjogiError),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExtractResult {
Params {
table: String,
filter: String,
columns: Vec<String>,
batch_size: i64,
},
NotBackfillChunked,
Malformed(String),
}
#[derive(Debug, Clone)]
pub struct ComposeMeta {
pub workspace_root: std::path::PathBuf,
pub originating_migration: String,
pub target_database: String,
pub app_label: String,
}
#[derive(Debug, Default)]
pub struct ComposeReport {
pub plans_composed: usize,
pub plan_file_paths: Vec<std::path::PathBuf>,
pub plan_ids: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StepResult {
Completed,
Partial {
rows_done: i64,
rows_total: i64,
},
Paused,
}
pub async fn compose_live_plans(
ctx: &mut DjogiContext,
descriptors: Vec<crate::descriptor::ModelDescriptor>,
snapshot_path: std::path::PathBuf,
meta: ComposeMeta,
) -> Result<ComposeReport, ComposeError> {
use std::collections::BTreeMap;
let snapshot = crate::migrate::snapshot::load_snapshot(&snapshot_path)
.map_err(|e| ComposeError::Diff(e.to_string()))?;
let after_map = crate::migrate::projection::project_from_iters(
descriptors.iter(),
std::iter::empty::<&crate::descriptor::EnumDescriptor>(),
std::iter::empty::<&crate::apps::AppDescriptor>(),
meta.originating_migration.clone(),
)
.map_err(|e| ComposeError::Diff(e.to_string()))?;
let bucket_key = crate::migrate::projection::BucketKey {
database: meta.target_database.clone(),
app: meta.app_label.clone(),
};
let mut before_map = BTreeMap::new();
before_map.insert(bucket_key, snapshot);
let deltas = crate::migrate::diff::diff_bucket_maps(&before_map, &after_map)
.map_err(|e| ComposeError::Diff(e.to_string()))?;
use crate::migrate::schema::OnlineSafetyClassification;
let inbound_fk_counts: std::collections::BTreeMap<String, u32> =
std::collections::BTreeMap::new();
let volatility_overrides: std::collections::BTreeMap<
(String, String),
crate::descriptor::DefaultVolatility,
> = std::collections::BTreeMap::new();
let classify_ctx = crate::live_migrate::classify::ClassifyContext::application_default(
&inbound_fk_counts,
&volatility_overrides,
);
let mut report = ComposeReport::default();
let bucket = format!("{}:{}", meta.target_database, meta.app_label);
check_no_active_plan(ctx, &bucket).await?;
for delta in deltas {
let classified =
crate::live_migrate::classify::classify_delta(&delta.operations, &classify_ctx);
let has_expand_contract = classified.iter().any(|(_, classification)| {
matches!(classification, OnlineSafetyClassification::ExpandContract)
});
if !has_expand_contract {
continue;
}
let plan_id = crate::types::HeerId::new(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64,
0, 0, )
.unwrap_or(crate::types::HeerId::ZERO);
let slug = format!("live_{}", meta.app_label);
let plan = build_skeleton_plan(
plan_id.to_string(),
slug.clone(),
OnlineSafetyClassification::ExpandContract,
Vec::new(), );
let migrations_root = meta.workspace_root.join("migrations");
let path = crate::live_migrate::plan_file::write_plan(&migrations_root, &plan)
.map_err(ComposeError::Serialize)?;
report.plans_composed += 1;
report.plan_file_paths.push(path);
report.plan_ids.push(plan_id.to_string());
}
Ok(report)
}
pub fn extract_backfill_params(steps: &[crate::live_migrate::plan::Step]) -> ExtractResult {
use crate::live_migrate::plan::{StepKind, StepParameters};
for step in steps {
if step.kind == StepKind::BackfillChunked {
match &step.parameters {
StepParameters::BackfillChunked {
table,
predicate_template,
chunk_size,
} => {
return ExtractResult::Params {
table: table.clone(),
filter: predicate_template.clone(),
columns: Vec::new(),
batch_size: *chunk_size as i64,
};
}
_ => {
return ExtractResult::Malformed(format!(
"step kind is BackfillChunked but parameters are {:?}",
step.parameters.kind()
));
}
}
}
}
ExtractResult::NotBackfillChunked
}
pub async fn check_no_active_plan(
ctx: &mut DjogiContext,
bucket: &str,
) -> Result<(), ComposeError> {
let (target_db, app_label) = match bucket.split_once(':') {
Some((db, label)) => (db.to_string(), label.to_string()),
None => ("main".to_string(), bucket.to_string()),
};
let sql = "SELECT 1 FROM djogi_live_plans \
WHERE target_database = $1 AND app_label = $2 \
AND status IN ('pending', 'running', 'paused')";
let exists = ctx
.query_opt(sql, &[&target_db, &app_label])
.await
.map_err(ComposeError::Db)?
.is_some();
if exists {
return Err(ComposeError::ActivePlanExists);
}
Ok(())
}
pub fn build_skeleton_plan(
plan_id: String,
slug: String,
classification: crate::migrate::OnlineSafetyClassification,
steps: Vec<crate::live_migrate::plan::Step>,
) -> crate::live_migrate::plan::LivePlan {
use crate::live_migrate::plan::PlanClassification;
let plan_id_val: crate::types::HeerId = plan_id.parse().unwrap_or(crate::types::HeerId::ZERO);
let plan_classification: PlanClassification =
Option::<PlanClassification>::from(classification)
.expect("FastLockDestructiveGuarded does not route through live-plan pipeline");
crate::live_migrate::plan::LivePlan {
header: crate::live_migrate::plan::PlanHeader {
plan_id: plan_id_val,
slug,
classification: plan_classification,
originating_migration: String::new(),
target_database: String::new(),
app_label: String::new(),
},
steps,
}
}
pub fn sanitize_app_label(label: &str) -> String {
if label.is_empty() {
return String::new();
}
let replaced: String = label
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();
let collapsed: String =
replaced
.chars()
.fold(String::with_capacity(replaced.len()), |mut acc, c| {
if c == '_' && acc.ends_with('_') {
acc
} else {
acc.push(c);
acc
}
});
let trimmed = collapsed.trim_matches(|c: char| c == '_' || c == '-');
let lowercased = trimmed.to_lowercase();
if lowercased.len() > 63 {
let truncated = &lowercased[..63];
truncated.to_string()
} else {
lowercased
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::live_migrate::plan::{Step, StepKind, StepParameters};
#[test]
fn sanitize_normal_input_preserved() {
assert_eq!(sanitize_app_label("my_app"), "my_app");
}
#[test]
fn sanitize_special_chars_replaced_with_underscore() {
assert_eq!(sanitize_app_label("my@app#name"), "my_app_name");
}
#[test]
fn sanitize_empty_input_returns_empty() {
assert_eq!(sanitize_app_label(""), "");
}
#[test]
fn sanitize_max_length_truncates_to_63_bytes() {
let long = "a".repeat(100);
assert_eq!(sanitize_app_label(&long), "a".repeat(63));
}
#[test]
fn sanitize_leading_separators_stripped() {
assert_eq!(sanitize_app_label("__-my_app"), "my_app");
}
#[test]
fn sanitize_trailing_separators_stripped() {
assert_eq!(sanitize_app_label("my_app_-__"), "my_app");
}
#[test]
fn sanitize_multiple_underscores_collapsed() {
assert_eq!(sanitize_app_label("a___b"), "a_b");
}
#[test]
fn sanitize_lowercase_applied() {
assert_eq!(sanitize_app_label("MyApp"), "myapp");
}
#[test]
fn sanitize_hyphens_preserved_in_middle() {
assert_eq!(sanitize_app_label("my-app_name"), "my-app_name");
}
#[test]
fn sanitize_only_special_chars_returns_empty() {
assert_eq!(sanitize_app_label("@#$%^&*()"), "");
}
fn dummy_step(ordinal: u32) -> Step {
Step {
kind: StepKind::ExpandSchema,
ordinal,
parameters: StepParameters::ExpandSchema {
sql_segments: vec!["ALTER TABLE foo ADD COLUMN bar INT".to_string()],
},
}
}
#[test]
fn build_skeleton_plan_constructs_valid_plan() {
let plan = build_skeleton_plan(
"123".to_string(),
"add_bar_column".to_string(),
crate::migrate::OnlineSafetyClassification::ExpandContract,
vec![dummy_step(0), dummy_step(1), dummy_step(2)],
);
assert_eq!(plan.header.slug, "add_bar_column");
assert_eq!(plan.steps.len(), 3);
}
#[test]
fn build_skeleton_plan_parses_plan_id_from_string() {
let plan = build_skeleton_plan(
"42".to_string(),
"test_slug".to_string(),
crate::migrate::OnlineSafetyClassification::ExpandContract,
vec![dummy_step(0)],
);
assert_eq!(plan.header.plan_id.as_i64(), 42);
}
fn backfill_step(ordinal: u32) -> Step {
Step {
kind: StepKind::BackfillChunked,
ordinal,
parameters: StepParameters::BackfillChunked {
table: "vehicles".to_string(),
predicate_template: "old_status IS NULL".to_string(),
chunk_size: 500,
},
}
}
#[test]
fn extract_backfill_params_empty_steps_returns_not_backfill() {
let steps: Vec<Step> = vec![];
assert_eq!(
extract_backfill_params(&steps),
ExtractResult::NotBackfillChunked,
);
}
#[test]
fn extract_backfill_params_no_backfill_step_returns_not_backfill() {
let steps = vec![dummy_step(0), dummy_step(1)];
assert_eq!(
extract_backfill_params(&steps),
ExtractResult::NotBackfillChunked,
);
}
#[test]
fn extract_backfill_params_extracts_correct_params() {
let steps = vec![dummy_step(0), backfill_step(1), dummy_step(2)];
let result = extract_backfill_params(&steps);
assert_eq!(
result,
ExtractResult::Params {
table: "vehicles".to_string(),
filter: "old_status IS NULL".to_string(),
columns: Vec::<String>::new(),
batch_size: 500,
},
);
}
#[test]
fn extract_backfill_params_batch_size_cast_from_u32_to_i64() {
let step = Step {
kind: StepKind::BackfillChunked,
ordinal: 0,
parameters: StepParameters::BackfillChunked {
table: "t".to_string(),
predicate_template: "1=1".to_string(),
chunk_size: u32::MAX,
},
};
let result = extract_backfill_params(&[step]);
assert_eq!(
result,
ExtractResult::Params {
table: "t".to_string(),
filter: "1=1".to_string(),
columns: Vec::<String>::new(),
batch_size: i64::from(u32::MAX),
},
);
}
#[test]
fn extract_backfill_params_kind_mismatch_returns_malformed() {
let step = Step {
kind: StepKind::BackfillChunked,
ordinal: 0,
parameters: StepParameters::ExpandSchema {
sql_segments: vec!["ALTER TABLE foo ADD COLUMN bar INT".to_string()],
},
};
match extract_backfill_params(&[step]) {
ExtractResult::Malformed(diag) => {
assert!(
diag.contains("BackfillChunked"),
"diagnostic should mention BackfillChunked: {diag}",
);
}
other => panic!("expected Malformed, got {other:?}"),
}
}
#[test]
fn extract_backfill_params_first_step_is_backfill() {
let steps = vec![backfill_step(0), dummy_step(1)];
assert!(matches!(
extract_backfill_params(&steps),
ExtractResult::Params { .. }
));
}
#[test]
fn extract_backfill_params_last_step_is_backfill() {
let steps = vec![dummy_step(0), backfill_step(1)];
assert!(matches!(
extract_backfill_params(&steps),
ExtractResult::Params { .. }
));
}
}