use crate::{error::CliError, ui, Result};
use colored::Colorize;
use forgedb_migrations::{HopBodyClass, MigrationGenerator, MigrationTracker, SchemaChange};
use std::path::{Path, PathBuf};
fn map_err(e: String) -> CliError {
CliError::Migration(e)
}
pub struct MigrateCreateOptions {
pub description: String,
pub auto: bool,
pub schema: Option<PathBuf>,
}
pub struct MigrateStatusOptions;
pub struct MigrateBuildOptions {
pub from: u32,
pub to: u32,
pub output: Option<PathBuf>,
}
pub struct MigrateRunOptions {
pub src: PathBuf,
pub dest: PathBuf,
pub bin_dir: Option<PathBuf>,
}
pub fn create(opts: MigrateCreateOptions) -> Result<()> {
let migrations_dir = PathBuf::from("migrations");
if opts.auto {
let schema_path = opts.schema.clone().unwrap_or_else(|| PathBuf::from("schema.forge"));
ui::info(&format!(
"Auto-detecting schema changes ({})...",
schema_path.display()
));
let new_src = std::fs::read_to_string(&schema_path).map_err(|e| {
CliError::Migration(format!(
"Failed to read schema '{}': {}",
schema_path.display(),
e
))
})?;
let changes = detect_schema_changes(&migrations_dir, &new_src)?;
if changes.is_empty() {
save_schema_snapshot(&migrations_dir, &new_src)?;
let lineage = forgedb_migrations::MigrationLineage::load(&migrations_dir)
.map_err(map_err)?;
forgedb_migrations::save_versioned_schema(
&migrations_dir,
lineage.current_format_version(),
&new_src,
)
.map_err(map_err)?;
ui::success("No schema changes detected (snapshot up to date)");
return Ok(());
}
let authored: Vec<_> = changes
.iter()
.filter(|c| c.hop_body_class() == HopBodyClass::Authored)
.collect();
let has_breaking = changes.iter().any(|c| c.is_breaking());
let authored_count = authored.len();
ui::info(&format!("Detected {} change(s)", changes.len()));
for change in &changes {
let marker = if change.hop_body_class() == HopBodyClass::Authored {
" [authored]".yellow()
} else {
"".normal()
};
println!(" • {}{}", change.description(), marker);
}
let lineage = forgedb_migrations::MigrationLineage::load(&migrations_dir)
.map_err(map_err)?;
let (from_version, to_version) = lineage.next_version_span();
let migration = MigrationGenerator::generate_versioned(
&migrations_dir,
opts.description,
changes.clone(),
from_version,
to_version,
)
.map_err(map_err)?;
save_schema_snapshot(&migrations_dir, &new_src)?;
forgedb_migrations::save_versioned_schema(&migrations_dir, to_version, &new_src)
.map_err(map_err)?;
let scaffold = forgedb_migrations::scaffold_authored_body(&migrations_dir, &migration)
.map_err(map_err)?;
ui::success(&format!(
"Created migration: {} (format v{} → v{})",
migration.filename(),
from_version,
to_version
));
println!("\n{}", MigrationGenerator::generate_report(&migration));
let needs_transform = has_breaking || authored_count > 0;
if !needs_transform {
ui::info(
"Additive change: run `forgedb generate` and restart your app — existing \
rows are backfilled with defaults on reopen (no data step needed).",
);
} else {
if let Some((path, created)) = &scaffold {
if *created {
ui::warning(&format!(
"Authored transform scaffolded at {} — fill in every TODO before building.",
path.display()
));
} else {
ui::info(&format!(
"Authored transform already present at {} (left unchanged).",
path.display()
));
}
}
print_migration_next_steps(from_version, to_version, authored_count > 0);
}
} else {
ui::warning("Manual migration mode - you'll need to edit the migration file manually");
let migration = MigrationGenerator::generate(
migrations_dir,
opts.description,
vec![], )
.map_err(map_err)?;
ui::success(&format!(
"Created migration template: {}",
migration.filename()
));
ui::info("Edit the migration file to add your changes");
}
Ok(())
}
pub fn status(_opts: MigrateStatusOptions) -> Result<()> {
let migrations_dir = PathBuf::from("migrations");
let all_migrations =
MigrationGenerator::load_all_migrations(&migrations_dir).map_err(map_err)?;
if all_migrations.is_empty() {
ui::info("No migrations found");
return Ok(());
}
let tracker = MigrationTracker::new(&migrations_dir).map_err(map_err)?;
println!("{}", "Migration Status".bold());
println!("{}", "=".repeat(60));
println!("{}\n", tracker.status_summary(all_migrations.len()));
for migration in &all_migrations {
let is_applied = tracker.is_applied(&migration.id);
let status = if is_applied {
"✓".green()
} else {
"○".yellow()
};
let breaking_marker = if migration.has_breaking_changes() {
" ⚠️ ".red()
} else {
"".normal()
};
println!(
"{} {} - {}{} ({} changes)",
status,
migration.id.cyan(),
migration.description,
breaking_marker,
migration.changes.len()
);
if is_applied {
if let Some(record) = tracker
.applied_migrations()
.iter()
.find(|r| r.migration_id == migration.id)
{
println!(
" Applied at: {}",
record
.applied_at
.format("%Y-%m-%d %H:%M:%S")
.to_string()
.dimmed()
);
}
}
}
Ok(())
}
pub fn build(opts: MigrateBuildOptions) -> Result<()> {
let migrations_dir = PathBuf::from("migrations");
let output = opts
.output
.unwrap_or_else(|| migrations_dir.join("transform"));
let bin = compile_transformer(opts.from, opts.to, &output)?;
ui::success(&format!("Built transformer: {}", bin.display()));
ui::info(&format!(
"Run it with the app STOPPED: `forgedb migrate up --from {} --to {} --src <data> --dest <migrated>` \
(or directly: `{} <src> <dest>`)",
opts.from,
opts.to,
bin.display()
));
Ok(())
}
fn compile_transformer(from: u32, to: u32, output: &Path) -> Result<PathBuf> {
ui::info(&format!(
"Generating transformer for format v{from} → v{to} into {}",
output.display()
));
emit_transform(from, to, output, true)?;
ui::info("Compiling the transformer (cargo build --release)...");
let status = std::process::Command::new("cargo")
.args(["build", "--release"])
.current_dir(output)
.status()
.map_err(|e| CliError::Migration(format!("failed to run cargo: {e}")))?;
if !status.success() {
return Err(CliError::Migration(
"transformer build failed (see cargo output above)".to_string(),
));
}
Ok(output.join("target/release/forgedb-transform"))
}
fn run_transformer(bin: &Path, src: &Path, dest: &Path) -> Result<()> {
let status = std::process::Command::new(bin)
.arg(src)
.arg(dest)
.status()
.map_err(|e| CliError::Migration(format!("failed to run transformer: {e}")))?;
if !status.success() {
return Err(CliError::Migration(
"transformer exited non-zero (see output above); the source dir is unchanged"
.to_string(),
));
}
Ok(())
}
pub fn run(opts: MigrateRunOptions) -> Result<()> {
let bin_dir = opts
.bin_dir
.unwrap_or_else(|| PathBuf::from("migrations/transform"));
let bin = bin_dir.join("target/release/forgedb-transform");
if !bin.exists() {
return Err(CliError::Migration(format!(
"transformer bin not found at {} — run `forgedb migrate build --from <F> --to <T>` first",
bin.display()
)));
}
ui::info(&format!(
"Migrating {} → {} via {}",
opts.src.display(),
opts.dest.display(),
bin.display()
));
run_transformer(&bin, &opts.src, &opts.dest)?;
ui::success("Migration complete — point the regenerated app at the destination dir");
Ok(())
}
pub struct MigrateUpOptions {
pub from: Option<u32>,
pub to: Option<u32>,
pub src: Option<PathBuf>,
pub dest: Option<PathBuf>,
pub output: Option<PathBuf>,
pub tenant_root: Option<PathBuf>,
pub dest_suffix: Option<String>,
}
pub fn up(opts: MigrateUpOptions) -> Result<()> {
let migrations_dir = PathBuf::from("migrations");
let lineage = forgedb_migrations::MigrationLineage::load(&migrations_dir).map_err(map_err)?;
let to = opts.to.unwrap_or_else(|| lineage.current_format_version());
let output = opts
.output
.clone()
.unwrap_or_else(|| migrations_dir.join("transform"));
let jobs: Vec<(PathBuf, PathBuf)> = if let Some(root) = &opts.tenant_root {
collect_tenant_jobs(root, to, opts.dest_suffix.as_deref())?
} else {
let src = opts.src.clone().ok_or_else(|| {
CliError::Migration(
"`migrate up` needs --src and --dest (or --tenant-root for a per-tenant sweep)"
.to_string(),
)
})?;
let dest = opts
.dest
.clone()
.ok_or_else(|| CliError::Migration("`migrate up` needs --dest".to_string()))?;
vec![(src, dest)]
};
let from = match opts.from {
Some(f) => f,
None => {
let first = &jobs[0].0;
detect_src_format_version(first)?.ok_or_else(|| {
CliError::Migration(format!(
"could not detect the source format version of {} — pass --from explicitly",
first.display()
))
})?
}
};
if from == to {
ui::success(&format!(
"Data is already at format v{to} — nothing to migrate."
));
return Ok(());
}
if to < from {
return Err(CliError::Migration(format!(
"refusing to migrate backwards: source is at v{from}, target is v{to} \
(the transformer only replays forward)"
)));
}
ui::info(&format!(
"Migrating {} data dir(s): format v{from} → v{to}",
jobs.len()
));
let bin = compile_transformer(from, to, &output)?;
let mut failures = Vec::new();
for (src, dest) in &jobs {
ui::info(&format!("→ {} ⇒ {}", src.display(), dest.display()));
match run_transformer(&bin, src, dest) {
Ok(()) => ui::success(&format!(" migrated {}", dest.display())),
Err(e) => {
ui::error(&format!(" FAILED {}: {}", src.display(), e));
failures.push(src.clone());
}
}
}
if !failures.is_empty() {
return Err(CliError::Migration(format!(
"{} of {} data dir(s) failed to migrate (their originals are unchanged): {}",
failures.len(),
jobs.len(),
failures
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ")
)));
}
ui::success(&format!(
"Migration complete ({} dir(s)) — regenerate your app and point it at the destination(s).",
jobs.len()
));
Ok(())
}
fn collect_tenant_jobs(
root: &Path,
to: u32,
dest_suffix: Option<&str>,
) -> Result<Vec<(PathBuf, PathBuf)>> {
let suffix = dest_suffix
.map(str::to_string)
.unwrap_or_else(|| format!("-migrated-v{to}"));
let mut jobs = Vec::new();
let entries = std::fs::read_dir(root).map_err(|e| {
CliError::Migration(format!(
"failed to read tenant root {}: {}",
root.display(),
e
))
})?;
for entry in entries {
let path = entry.map_err(|e| CliError::Migration(e.to_string()))?.path();
if !path.is_dir() {
continue;
}
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default()
.to_string();
if name.ends_with(&suffix) {
continue;
}
if detect_src_format_version(&path)?.is_none() {
continue;
}
jobs.push((path, root.join(format!("{name}{suffix}"))));
}
jobs.sort();
if jobs.is_empty() {
return Err(CliError::Migration(format!(
"no tenant data dirs found under {} (a data dir has a <model>/manifest.json)",
root.display()
)));
}
Ok(jobs)
}
fn detect_src_format_version(data_dir: &Path) -> Result<Option<u32>> {
if !data_dir.is_dir() {
return Ok(None);
}
let entries = std::fs::read_dir(data_dir)
.map_err(|e| CliError::Migration(format!("failed to read {}: {}", data_dir.display(), e)))?;
for entry in entries {
let manifest = entry
.map_err(|e| CliError::Migration(e.to_string()))?
.path()
.join("manifest.json");
if manifest.is_file() {
let txt = std::fs::read_to_string(&manifest).map_err(|e| {
CliError::Migration(format!("failed to read {}: {}", manifest.display(), e))
})?;
if let Some(fv) = serde_json::from_str::<serde_json::Value>(&txt)
.ok()
.and_then(|v| v.get("format_version").and_then(|x| x.as_u64()))
{
return Ok(Some(fv as u32));
}
}
}
Ok(None)
}
pub fn emit_transform(from: u32, to: u32, output: &Path, force: bool) -> Result<()> {
use forgedb_codegen::{HopPlan, TransformGenerator, TransformPlan, VersionSchema};
use forgedb_migrations::MigrationLineage;
let migrations_dir = PathBuf::from("migrations");
let lineage = MigrationLineage::load(&migrations_dir).map_err(map_err)?;
let hop_migrations = lineage.expand_range(from, to).map_err(map_err)?;
if hop_migrations.is_empty() {
return Err(CliError::Migration(format!(
"empty migration range (v{from} → v{to}): nothing to transform"
)));
}
let mut parsed: Vec<(u32, forgedb_parser::Schema)> = Vec::new();
for v in from..=to {
let src = forgedb_migrations::load_versioned_schema(&migrations_dir, v).map_err(map_err)?;
let schema = forgedb_parser::Parser::new(&src)
.and_then(|mut p| p.parse())
.map_err(|e| {
CliError::Migration(format!("failed to parse committed schema v{v}: {e}"))
})?;
parsed.push((v, schema));
}
let mut hops = Vec::new();
for m in &hop_migrations {
let dest_schema = &parsed
.iter()
.find(|(v, _)| *v == m.to_version)
.expect("range parsed every version")
.1;
let model_ops = build_model_ops(&m.changes, dest_schema);
let authored_src = if m.authored_changes().is_empty() {
None
} else {
let p = forgedb_migrations::authored_body_path(&migrations_dir, &m.id);
Some(std::fs::read_to_string(&p).map_err(|e| {
CliError::Migration(format!(
"migration {} has authored residue but its transform {:?} is missing: {}. \
Author the body (see the scaffold `forgedb migrate create` wrote), then rebuild.",
m.id, p, e
))
})?)
};
hops.push(HopPlan {
from_version: m.from_version,
to_version: m.to_version,
migration_id: m.id.clone(),
model_ops,
authored_src,
});
}
let versions: Vec<VersionSchema> = parsed
.iter()
.map(|(v, s)| VersionSchema {
version: *v,
schema: s,
})
.collect();
let plan = TransformPlan { versions, hops };
let crate_out = TransformGenerator::generate(&plan, "forgedb-transform")
.map_err(|e| CliError::CodeGeneration(e.to_string()))?;
std::fs::create_dir_all(output)?;
let cargo_path = output.join("Cargo.toml");
if !cargo_path.exists() {
std::fs::write(&cargo_path, &crate_out.cargo_toml)?;
}
for (rel, content) in &crate_out.sources {
let path = output.join(rel);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
if path.exists() && !force {
return Err(CliError::Other(format!(
"File exists: {}. Use --force to overwrite",
path.display()
)));
}
std::fs::write(&path, content)?;
}
ui::success(&format!(
"Generated transformer crate ({} source files) at {}",
crate_out.sources.len(),
output.display()
));
Ok(())
}
fn build_model_ops(
changes: &[SchemaChange],
dest_schema: &forgedb_parser::Schema,
) -> Vec<forgedb_codegen::ModelOp> {
use std::collections::BTreeMap;
#[derive(Default)]
struct Acc {
source_model: Option<String>,
renames: Vec<(String, String)>,
removes: Vec<String>,
adds: Vec<(String, String)>,
}
let mut acc: BTreeMap<String, Acc> = BTreeMap::new();
for change in changes {
match change {
SchemaChange::RenameModel { old_name, new_name } => {
acc.entry(new_name.clone()).or_default().source_model = Some(old_name.clone());
}
SchemaChange::RenameField {
model_name,
old_name,
new_name,
} => {
acc.entry(model_name.clone())
.or_default()
.renames
.push((old_name.clone(), new_name.clone()));
}
SchemaChange::RemoveField {
model_name,
field_name,
} => {
acc.entry(model_name.clone())
.or_default()
.removes
.push(field_name.clone());
}
SchemaChange::AddField {
model_name,
field_name,
nullable,
default_value,
..
} => {
let json = add_field_default_json(
dest_schema,
model_name,
field_name,
*nullable,
default_value.as_deref(),
);
acc.entry(model_name.clone())
.or_default()
.adds
.push((field_name.clone(), json));
}
_ => {}
}
}
acc.into_iter()
.map(|(model, a)| forgedb_codegen::ModelOp {
source_model: a.source_model.unwrap_or_else(|| model.clone()),
model,
field_renames: a.renames,
field_removes: a.removes,
field_adds: a.adds,
})
.collect()
}
fn add_field_default_json(
dest_schema: &forgedb_parser::Schema,
model_name: &str,
field_name: &str,
nullable: bool,
default_value: Option<&str>,
) -> String {
use forgedb_parser::FieldType;
fn base(ft: &FieldType) -> &FieldType {
match ft {
FieldType::Nullable(inner) => base(inner),
other => other,
}
}
let ftype = dest_schema
.models
.iter()
.find(|m| m.name == model_name)
.and_then(|m| m.fields.iter().find(|f| f.name == field_name))
.map(|f| base(&f.field_type));
let quote_str = |s: &str| serde_json::to_string(s).unwrap_or_else(|_| "\"\"".to_string());
match default_value {
None => {
if nullable {
return "null".to_string();
}
match ftype {
Some(FieldType::Bool) => "false".to_string(),
Some(FieldType::F64) => "0.0".to_string(),
Some(
FieldType::U32 | FieldType::U64 | FieldType::I32 | FieldType::I64
| FieldType::Timestamp,
) => "0".to_string(),
Some(FieldType::String | FieldType::Char(_)) => "\"\"".to_string(),
_ => "null".to_string(),
}
}
Some(d) => match ftype {
Some(FieldType::Bool) => {
if d == "true" || d == "false" {
d.to_string()
} else {
"false".to_string()
}
}
Some(FieldType::F64) => {
if d.parse::<f64>().is_ok() {
d.to_string()
} else {
"0.0".to_string()
}
}
Some(
FieldType::U32 | FieldType::U64 | FieldType::I32 | FieldType::I64
| FieldType::Timestamp,
) => {
if d.parse::<i64>().is_ok() {
d.to_string()
} else {
"0".to_string()
}
}
Some(FieldType::Json) => {
if serde_json::from_str::<serde_json::Value>(d).is_ok() {
d.to_string()
} else {
quote_str(d)
}
}
_ => quote_str(d),
},
}
}
fn snapshot_path(migrations_dir: &std::path::Path) -> PathBuf {
migrations_dir.join(".schema-snapshot.forge")
}
fn save_schema_snapshot(migrations_dir: &std::path::Path, schema_src: &str) -> Result<()> {
std::fs::create_dir_all(migrations_dir).map_err(|e| {
CliError::Migration(format!("Failed to create migrations dir: {}", e))
})?;
std::fs::write(snapshot_path(migrations_dir), schema_src).map_err(|e| {
CliError::Migration(format!("Failed to write schema snapshot: {}", e))
})?;
Ok(())
}
fn to_simple_schema(schema: &forgedb_parser::Schema) -> forgedb_migrations::SimpleSchema {
use forgedb_parser::{FieldType, RelationType};
let models = schema
.models
.iter()
.map(|m| forgedb_migrations::SimpleModel {
name: m.name.clone(),
fields: m
.fields
.iter()
.filter(|f| {
!matches!(
&f.field_type,
FieldType::Relation(RelationType::OneToMany(_))
| FieldType::Relation(RelationType::ManyToMany(_))
)
})
.map(|f| forgedb_migrations::SimpleField {
name: f.name.clone(),
field_type: format!("{:?}", f.field_type),
nullable: f.is_nullable(),
unique: f.unique,
indexed: f.indexed,
index_type: format!("{:?}", f.index_type),
constraints: f
.constraints
.iter()
.map(|c| forgedb_migrations::SimpleConstraint {
name: c.name.clone(),
params: c.params.iter().map(|p| format!("{:?}", p)).collect(),
})
.collect(),
})
.collect(),
composite_indexes: m
.composite_indexes
.iter()
.map(|ci| ci.fields.clone())
.collect(),
})
.collect();
forgedb_migrations::SimpleSchema { models }
}
fn print_migration_next_steps(from: u32, to: u32, has_authored: bool) {
println!("\n{}", "Next steps (offline data migration):".bold());
let mut step = 1;
if has_authored {
println!(
" {step}. Author the transform body in migrations/<id>/transform.rs \
(fill in each TODO)."
);
step += 1;
}
println!(" {step}. Regenerate your app: forgedb generate");
step += 1;
println!(
" {step}. Migrate the data with the app STOPPED:\n \
forgedb migrate up --from {from} --to {to} --src <data-dir> --dest <migrated-dir>"
);
step += 1;
println!(" {step}. Point the regenerated app at <migrated-dir>.");
println!(" See docs/MIGRATIONS.md for the full lifecycle.");
}
fn detect_schema_changes(
migrations_dir: &std::path::Path,
new_src: &str,
) -> Result<Vec<SchemaChange>> {
let new_schema = forgedb_parser::Parser::new(new_src)
.and_then(|mut p| p.parse())
.map_err(|e| CliError::Migration(format!("Failed to parse schema: {}", e)))?;
let new_simple = to_simple_schema(&new_schema);
let snap = snapshot_path(migrations_dir);
let Ok(old_src) = std::fs::read_to_string(&snap) else {
return Ok(Vec::new());
};
let old_schema = forgedb_parser::Parser::new(&old_src)
.and_then(|mut p| p.parse())
.map_err(|e| {
CliError::Migration(format!("Failed to parse recorded schema snapshot: {}", e))
})?;
let old_simple = to_simple_schema(&old_schema);
Ok(forgedb_migrations::SchemaDiffer::diff(&old_simple, &new_simple))
}