use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug)]
pub struct MigrationError {
pub from_version: u32,
pub to_version: u32,
pub reason: String,
}
impl std::fmt::Display for MigrationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"schema migration {} → {} failed: {}",
self.from_version, self.to_version, self.reason
)
}
}
impl std::error::Error for MigrationError {}
#[allow(dead_code)] pub type MigrationFn = fn(&mut serde_json::Value) -> Result<(), MigrationError>;
#[allow(dead_code)] pub trait SchemaMigration {
const CURRENT_VERSION: u32;
const DOMAIN: &'static str;
const MIGRATIONS: &'static [MigrationFn];
fn migrate(value: &mut serde_json::Value, version: u32) -> Result<u32, MigrationError> {
if version > Self::CURRENT_VERSION {
return Err(MigrationError {
from_version: version,
to_version: Self::CURRENT_VERSION,
reason: format!(
"{} record at v{version} is newer than current v{}",
Self::DOMAIN,
Self::CURRENT_VERSION
),
});
}
let mut current = version;
for (idx, step) in Self::MIGRATIONS.iter().enumerate() {
let step_from = (idx as u32) + 1;
if current > step_from {
continue;
}
if current < step_from {
return Err(MigrationError {
from_version: current,
to_version: step_from + 1,
reason: format!(
"{} migration list is non-contiguous at index {idx}",
Self::DOMAIN
),
});
}
step(value)?;
current = step_from + 1;
value["schema_version"] = serde_json::json!(current);
}
if current != Self::CURRENT_VERSION {
return Err(MigrationError {
from_version: version,
to_version: Self::CURRENT_VERSION,
reason: format!(
"{} migrated to v{current} but expected v{}",
Self::DOMAIN,
Self::CURRENT_VERSION
),
});
}
Ok(current)
}
}
#[allow(dead_code)] pub fn backup_before_migrate(path: &Path, domain: &str) -> PathBuf {
let bak = path.with_extension(
path.extension()
.map(|ext| format!("{}.bak", ext.to_string_lossy()))
.unwrap_or_else(|| "bak".to_string()),
);
match fs::copy(path, &bak) {
Ok(_) => tracing::info!(
domain,
from = %path.display(),
to = %bak.display(),
"schema backup created"
),
Err(e) => tracing::warn!(
domain,
from = %path.display(),
error = %e,
"schema backup failed (continuing — migration is crash-safe)"
),
}
bak
}
pub mod registry {
use super::{MigrationFn, SchemaMigration};
pub struct SessionMigration;
impl SchemaMigration for SessionMigration {
const CURRENT_VERSION: u32 = 1;
const DOMAIN: &'static str = "session";
const MIGRATIONS: &'static [MigrationFn] = &[];
}
pub struct OfflineQueueMigration;
impl SchemaMigration for OfflineQueueMigration {
const CURRENT_VERSION: u32 = 1;
const DOMAIN: &'static str = "offline_queue";
const MIGRATIONS: &'static [MigrationFn] = &[];
}
pub struct RuntimeMigration;
impl SchemaMigration for RuntimeMigration {
const CURRENT_VERSION: u32 = 2;
const DOMAIN: &'static str = "runtime";
const MIGRATIONS: &'static [MigrationFn] = &[];
}
pub struct TaskMigration;
impl SchemaMigration for TaskMigration {
const CURRENT_VERSION: u32 = 2;
const DOMAIN: &'static str = "task";
const MIGRATIONS: &'static [MigrationFn] = &[];
}
pub struct AutomationMigration;
impl SchemaMigration for AutomationMigration {
const CURRENT_VERSION: u32 = 1;
const DOMAIN: &'static str = "automation";
const MIGRATIONS: &'static [MigrationFn] = &[];
}
pub struct AutomationRunMigration;
impl SchemaMigration for AutomationRunMigration {
const CURRENT_VERSION: u32 = 1;
const DOMAIN: &'static str = "automation_run";
const MIGRATIONS: &'static [MigrationFn] = &[];
}
}
#[cfg(test)]
mod tests {
use super::*;
struct TestThreadMigration;
fn add_archived_field(value: &mut serde_json::Value) -> Result<(), MigrationError> {
if value.get("archived").is_none() {
value["archived"] = serde_json::json!(false);
}
Ok(())
}
fn add_kind_field(value: &mut serde_json::Value) -> Result<(), MigrationError> {
if value.get("kind").is_none() {
value["kind"] = serde_json::json!("standard");
}
Ok(())
}
impl SchemaMigration for TestThreadMigration {
const CURRENT_VERSION: u32 = 3;
const DOMAIN: &'static str = "test_thread";
const MIGRATIONS: &'static [MigrationFn] = &[add_archived_field, add_kind_field];
}
#[test]
fn migrate_no_op_when_already_current() {
let mut value = serde_json::json!({
"schema_version": 3,
"id": "abc",
"archived": true,
"kind": "feature_branch"
});
let final_version = TestThreadMigration::migrate(&mut value, 3).expect("ok");
assert_eq!(final_version, 3);
assert_eq!(value["archived"], serde_json::json!(true));
assert_eq!(value["kind"], serde_json::json!("feature_branch"));
}
#[test]
fn migrate_runs_all_steps_from_v1() {
let mut value = serde_json::json!({
"schema_version": 1,
"id": "abc"
});
let final_version = TestThreadMigration::migrate(&mut value, 1).expect("ok");
assert_eq!(final_version, 3);
assert_eq!(value["schema_version"], serde_json::json!(3));
assert_eq!(value["archived"], serde_json::json!(false));
assert_eq!(value["kind"], serde_json::json!("standard"));
}
#[test]
fn migrate_runs_only_remaining_steps_from_v2() {
let mut value = serde_json::json!({
"schema_version": 2,
"id": "abc",
"archived": true
});
let final_version = TestThreadMigration::migrate(&mut value, 2).expect("ok");
assert_eq!(final_version, 3);
assert_eq!(value["archived"], serde_json::json!(true));
assert_eq!(value["kind"], serde_json::json!("standard"));
}
#[test]
fn migrate_rejects_newer_than_current() {
let mut value = serde_json::json!({
"schema_version": 99
});
let err = TestThreadMigration::migrate(&mut value, 99).expect_err("must reject");
assert_eq!(err.from_version, 99);
assert_eq!(err.to_version, 3);
assert!(err.reason.contains("newer than current"));
}
#[test]
fn backup_creates_bak_file_alongside_original() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("session_abc.json");
std::fs::write(&path, r#"{"id":"abc"}"#).expect("write");
let bak = backup_before_migrate(&path, "test_session");
assert!(bak.exists(), "bak file must exist at {}", bak.display());
assert_eq!(
std::fs::read_to_string(&bak).expect("read bak"),
r#"{"id":"abc"}"#
);
assert!(
bak.to_string_lossy().ends_with(".json.bak"),
"bak suffix must be `.json.bak`; got {}",
bak.display()
);
}
#[test]
fn backup_failure_does_not_panic_or_propagate() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("does_not_exist.json");
let bak = backup_before_migrate(&path, "test_session");
assert!(bak.to_string_lossy().ends_with(".json.bak"));
}
}