--- # nbd-q2d1 title: nbd migrate command status: completed type: feature priority: critical created_at: 2026-03-10T23:30:29Z updated_at: 2026-03-10T23:30:31Z blocked_by: - nbd-o3k8 --- Add `nbd migrate` to bring all ticket files on disk into conformance with the current schema. This is the standard mechanism for handling any schema change — field removals, field additions, renames, or type changes. ## Motivation Schema changes (like removing `id` from the JSON body, adding new fields with defaults, or renaming a field) leave existing ticket files in an old format. `nbd migrate` re-serialises every ticket through the current serde schema, which: - **Removes** fields that no longer exist in `Ticket` (they are ignored on deserialise, then absent on re-serialise). - **Adds** new fields with their `#[serde(default)]` values. - **Normalises** any formatting differences (e.g. key order, whitespace). The current immediate use case is scrubbing the `"id"` key from all existing `.json` files after the id-from-filename schema change. ## Design principles - **Idempotent.** Running `nbd migrate` on an already-current store is a no-op (files are re-written identically). - **Non-destructive.** A failure on one ticket does not abort the rest; errors are collected and reported at the end. - **Source of truth unchanged.** If a ticket cannot be parsed, it is left on disk as-is and reported as an error. - **Dry-run available.** `--dry-run` prints what would change without writing. ## Approach ### main.rs Add `Migrate` variant to `Commands`: ``` Migrate { /// Print changes without writing them. #[arg(long)] dry_run: bool, } ``` Implement `cmd_migrate(dry_run: bool) -> store::Result<()>`: 1. `find_nbd_root()` 2. Call `store::migrate_tickets(&root, dry_run).await` 3. Print a summary: `Migrated N tickets (M errors)`. ### store.rs Add `migrate_tickets(root: &Path, dry_run: bool) -> Result`: 1. `fs::read_dir(tickets_dir(root))` 2. For each `*.json`, `*.md`, `*.toml`, `*.jsonb` file (all supported formats — the ones that exist now and future ones added by the multi-format feature): a. Read the raw bytes. b. Attempt to deserialise into current `Ticket`, injecting `id` from filename. c. Re-serialise to the current schema (same format as the original file's extension). d. Compare raw bytes. If unchanged, skip (count as already-current). e. If changed and `dry_run`: print `would update {filename}`, do not write. f. If changed and not `dry_run`: write the new bytes to the same path. g. If deserialise fails: record the error, leave the file untouched. 3. Return `MigrateReport { updated: usize, already_current: usize, errors: Vec<(String, String)> }`. ```rust pub struct MigrateReport { pub updated: usize, pub already_current: usize, pub errors: Vec<(String, String)>, // (filename, error message) } ``` ### display.rs Add `print_migrate_report(report: &MigrateReport)`: ``` Migrated 3 tickets. Current 5 tickets (already up to date). Errors 1 ticket could not be migrated: bad_ticket.json: trailing comma at line 4 ``` When `--json`, serialise `MigrateReport` directly (derive `Serialize`). ## How schema changes use this For **field removal** (e.g. removing `id` from JSON): - Old files have `"id": "..."` → on deserialise, serde ignores it (unknown field). - Re-serialise → `id` is absent (since `#[serde(skip)]`). - File bytes differ → `migrate` rewrites. For **field addition** (e.g. adding `tags: Vec` later): - New field in `Ticket` gets `#[serde(default)]`. - Old files lack `tags` → deserialise gives `vec![]`. - Re-serialise → `"tags": []` is written. - File bytes differ → `migrate` rewrites. ## Tests Unit tests (`src/tests.rs`): - `migrate_tickets` on a store with old-format files (containing `"id"`) rewrites them without `id`. - `migrate_tickets` on an already-current store returns `updated: 0`, `already_current: N`. - `migrate_tickets --dry-run` does not modify files on disk. - A file with invalid JSON is counted in `errors` and left unchanged. Integration tests (`tests/integration.rs`): - Create tickets with old code (inject `id` manually into JSON), run `nbd migrate`, verify `id` is gone from files. - `nbd migrate --dry-run` reports changes but does not modify files. - `nbd migrate` exits zero even when some tickets error (but prints error summary). - `nbd migrate --json` outputs a valid JSON object with `updated`, `already_current`, `errors` fields. ## Files touched - `src/main.rs` — `Migrate` command, `cmd_migrate` - `src/store.rs` — `migrate_tickets`, `MigrateReport` - `src/display.rs` — `print_migrate_report` - `src/tests.rs` — unit tests - `tests/integration.rs` — integration tests - `README.md` — document `nbd migrate`