feat: add Server database migration command

This commit is contained in:
2026-09-06 16:17:48 +09:00
parent c5a834bfd2
commit 31f7d39647
2 changed files with 156 additions and 28 deletions
+111 -1
View File
@@ -20,6 +20,7 @@ enum Command {
Serve(ServeOptions),
Identity(Vec<String>),
TrustRuntime(Vec<String>),
Migrate(MigrateOptions),
Skills(SkillsCommand),
Help,
}
@@ -30,6 +31,13 @@ struct ServeOptions {
config: Option<PathBuf>,
}
#[derive(Debug)]
struct MigrateOptions {
database: Option<PathBuf>,
dry_run: bool,
help: bool,
}
#[derive(Debug)]
struct SkillWorkspaceOptions {
workspace_id: String,
@@ -70,6 +78,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
Command::Serve(options) => run_serve(options).await,
Command::Identity(args) => run_identity_command(args),
Command::TrustRuntime(args) => run_trust_runtime_command(args),
Command::Migrate(options) => run_migrate(options),
Command::Skills(command) => run_skills(command),
Command::Help => Ok(()),
}
@@ -84,6 +93,7 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
match command.as_str() {
"identity" => Ok(Command::Identity(rest.to_vec())),
"trust-runtime" => Ok(Command::TrustRuntime(rest.to_vec())),
"migrate" => parse_migrate_options(rest).map(Command::Migrate),
"skills" => parse_skills_command(rest),
"serve" => {
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
@@ -509,6 +519,40 @@ fn load_skill_workspace_config(
})
}
fn run_migrate(options: MigrateOptions) -> Result<(), Box<dyn std::error::Error>> {
if options.help {
print_migrate_help();
return Ok(());
}
let database_path = options
.database
.unwrap_or_else(ServerConfig::default_server_database_path);
let plan = if options.dry_run {
SqliteWorkspaceStore::migration_plan(&database_path)?
} else {
SqliteWorkspaceStore::migrate_database(&database_path)?
};
println!("server_db={}", database_path.display());
println!("current_schema_version={}", plan.current_schema_version);
println!("target_schema_version={}", plan.target_schema_version);
println!("migration_required={}", plan.migration_required());
for migration in &plan.migrations {
println!("migration={} {}", migration.version, migration.name);
}
println!(
"result={}",
if options.dry_run {
"dry-run-validated"
} else if plan.migration_required() {
"migrated"
} else {
"unchanged"
}
);
Ok(())
}
async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Error>> {
let database_path = ServerConfig::default_server_database_path();
if let Some(parent) = database_path.parent() {
@@ -683,6 +727,44 @@ fn parse_skill_workspace_options(args: &[String]) -> Result<SkillWorkspaceOption
Ok(SkillWorkspaceOptions { workspace_id })
}
fn parse_migrate_options(args: &[String]) -> Result<MigrateOptions, CliError> {
let mut database = None;
let mut dry_run = false;
let mut help = false;
let mut index = 0;
while index < args.len() {
let arg = &args[index];
match arg.as_str() {
"--database" => {
index += 1;
let value = args
.get(index)
.ok_or_else(|| CliError("--database requires a path".to_string()))?;
database = Some(PathBuf::from(value));
}
_ if arg.starts_with("--database=") => {
database = Some(PathBuf::from(value_after_equals(arg, "--database")?));
}
"--dry-run" => dry_run = true,
"--help" | "-h" => help = true,
_ if arg.starts_with('-') => {
return Err(CliError(format!("unknown migrate option `{arg}`")));
}
_ => {
return Err(CliError(format!(
"unexpected positional argument `{arg}`; use --database <PATH>"
)));
}
}
index += 1;
}
Ok(MigrateOptions {
database,
dry_run,
help,
})
}
fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
let mut listen = None;
let mut config = None;
@@ -745,7 +827,13 @@ fn parse_listen(value: &str) -> Result<SocketAddr, CliError> {
fn print_help() {
println!(
"yoi-server\n\nUsage:\n yoi-server identity init --server-id <SERVER_ID> [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id <RUNTIME_ID> --workspace-id <WORKSPACE_ID> --base-url <URL> --public-key <KEY> [--display-name <NAME>] [--replace]\n yoi-server trust-runtime list --workspace-id <WORKSPACE_ID> [--json] [--include-revoked]\n yoi-server trust-runtime revoke --workspace-id <WORKSPACE_ID> --runtime-id <RUNTIME_ID>\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
"yoi-server\n\nUsage:\n yoi-server identity init --server-id <SERVER_ID> [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id <RUNTIME_ID> --workspace-id <WORKSPACE_ID> --base-url <URL> --public-key <KEY> [--display-name <NAME>] [--replace]\n yoi-server trust-runtime list --workspace-id <WORKSPACE_ID> [--json] [--include-revoked]\n yoi-server trust-runtime revoke --workspace-id <WORKSPACE_ID> --runtime-id <RUNTIME_ID>\n yoi-server migrate [--dry-run] [--database <PATH>]\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
);
}
fn print_migrate_help() {
println!(
"yoi-server migrate\n\nUsage:\n yoi-server migrate [OPTIONS]\n\nDescription:\n Validates and applies every retained Server DB schema migration in order. --dry-run copies the database into memory and runs the same migration path without changing the source database. Stop yoi-server before applying migrations.\n\nOptions:\n --database <PATH> Server DB path (default: canonical Yoi server DB)\n --dry-run Validate the complete migration without changing the source DB\n -h, --help Print help"
);
}
@@ -776,6 +864,28 @@ mod tests {
}
}
#[test]
fn parse_migrate_uses_the_shared_schema_path() {
let command = parse_command(&[
"migrate".to_string(),
"--dry-run".to_string(),
"--database=/tmp/server.db".to_string(),
])
.unwrap();
let Command::Migrate(options) = command else {
panic!("expected migrate command");
};
assert!(options.dry_run);
assert!(!options.help);
assert_eq!(options.database, Some(PathBuf::from("/tmp/server.db")));
}
#[test]
fn parse_migrate_rejects_unknown_options() {
let error = parse_command(&["migrate".to_string(), "--apply-all".to_string()]).unwrap_err();
assert_eq!(error.to_string(), "unknown migrate option `--apply-all`");
}
#[test]
fn parse_skills_requires_server_workspace_id() {
let error = parse_skills_command(&["list".to_string()]).unwrap_err();