feat: add Server database migration command
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -1,41 +1,59 @@
|
||||
# Workspace database schema baseline
|
||||
# Workspace schema migrations
|
||||
|
||||
The Workspace Server owns one control-plane SQLite database. New databases are created directly from the current canonical schema; the repository does not retain an executable chain of historical Workspace schema migrations.
|
||||
Workspace schema authority belongs to `crates/workspace-server/src/store.rs`. The canonical
|
||||
schema, ordered migrations, migration-history validation, startup upgrade path, and explicit
|
||||
`yoi-server migrate` command must remain one contract.
|
||||
|
||||
Domain components such as Ticket and Merge Request contribute their current tables to the same database, but they do not create a second Workspace authority.
|
||||
## Retained migration chain
|
||||
|
||||
## Compatibility boundary
|
||||
Released or dogfooded schema migrations are retained and composed in version order. Adding a new
|
||||
schema version does not authorize deleting the preceding migration. A migration may be removed
|
||||
only as an explicit baseline-retirement operation after the supported installations that depend on
|
||||
it have been migrated or intentionally discarded.
|
||||
|
||||
The Server accepts only the current canonical schema generation. Its `__yoi_schema_migrations` ledger must contain exactly one row naming that baseline. A database with an older, newer, or multi-generation Workspace migration history is rejected at startup.
|
||||
The current retained Workspace chain is:
|
||||
|
||||
This is intentional while Yoi has only the dogfooding deployment. Schema changes may replace the baseline rather than adding permanent compatibility code. Existing dogfooding data must be migrated manually and atomically before starting the new binary.
|
||||
1. schema 50: `workspace schema baseline`
|
||||
2. schema 51: `workspace runtime bindings`
|
||||
3. schema 52: `workspace Runtime binding revision and audit`
|
||||
4. schema 53: `durable Workspace deletion operations`
|
||||
|
||||
## Updating the dogfooding database
|
||||
A database may begin at any retained baseline. Its following history rows must be the exact prefix
|
||||
of the ordered migration chain from that baseline. This allows both a freshly created current
|
||||
database and a database upgraded across several releases while rejecting edited, reordered, or
|
||||
unknown histories.
|
||||
|
||||
1. Stop every Server and Runtime process that can write the affected SQLite or Runtime stores.
|
||||
2. Record the current binary revision and schema generation.
|
||||
3. Take a SQLite-safe backup of `server.db` and a filesystem backup of any Runtime stores whose persisted contracts change.
|
||||
4. Apply the data and schema repair explicitly. Keep Workspace SQL data and Runtime filesystem data as separate authorities; changing one does not repair the other.
|
||||
5. Replace historical migration-ledger rows with the single marker expected by the current baseline.
|
||||
6. Validate before startup:
|
||||
## Runtime behavior
|
||||
|
||||
```sql
|
||||
PRAGMA foreign_key_check;
|
||||
PRAGMA integrity_check;
|
||||
```
|
||||
`SqliteWorkspaceStore::open` computes all pending retained migrations and applies them in order.
|
||||
Each migration is transactional and restartable: if a later step fails, completed steps remain a
|
||||
valid canonical prefix and the next run resumes from that version.
|
||||
|
||||
7. Start exactly one Server generation and verify the affected API contracts.
|
||||
`yoi-server migrate` invokes the same store migration path without starting the Server:
|
||||
|
||||
There is no in-place down migration and no automatic upgrade from an old baseline. Rollback means restoring both the prior binary and the complete matching database and Runtime-store backups.
|
||||
```sh
|
||||
# Copy the DB into memory and validate the complete pending path without changing the source.
|
||||
yoi-server migrate --dry-run
|
||||
|
||||
## Creating a new baseline
|
||||
# Preflight the complete path, then apply it to the source DB.
|
||||
yoi-server migrate
|
||||
```
|
||||
|
||||
A baseline change must include:
|
||||
Use `--database <PATH>` for a non-default Server DB. Stop `yoi-server` before applying migrations
|
||||
and make an external backup before an operational upgrade.
|
||||
|
||||
- canonical DDL that creates a fresh database directly at the new generation;
|
||||
- current-schema verification for Workspace, Ticket, and Merge Request tables;
|
||||
- tests proving a fresh database records only the canonical baseline marker;
|
||||
- an explicit, separately reviewed repair procedure for the current dogfooding data;
|
||||
- removal of obsolete migration functions, fixtures, commands, and documentation.
|
||||
## Development workflow
|
||||
|
||||
Do not put temporary legacy interpretation into normal request or projection paths. If persisted Runtime data also changes identity or shape, repair that Runtime authority explicitly instead of teaching steady-state Workspace APIs to accept both contracts indefinitely.
|
||||
When changing the Workspace schema:
|
||||
|
||||
1. increment `LATEST_SCHEMA_VERSION`;
|
||||
2. append one `Migration` entry with the new version, stable name, and apply function;
|
||||
3. preserve all migrations at or above `OLDEST_SCHEMA_VERSION`;
|
||||
4. update the canonical latest-schema creator for fresh databases;
|
||||
5. add a fixture at the oldest retained version and prove migration through every retained step;
|
||||
6. prove that `--dry-run` leaves the source DB unchanged;
|
||||
7. keep DDL validation and cross-schema foreign-key checks in the shared store preparation path.
|
||||
|
||||
A deliberate baseline retirement must be a separately reviewed change. It must identify the oldest
|
||||
remaining version, provide an operational migration/discard plan for older databases, update tests
|
||||
and this document, and must not be inferred merely because a new migration was added.
|
||||
|
||||
Reference in New Issue
Block a user