How the Migration System in Clash Nyanpasu Handles Data Upgrades Between Application Versions

The migration system in Clash Nyanpasu uses a persisted JSON state file and a trait-based runner to automatically execute version-specific data transformations when the application starts after an update.

Clash Nyanpasu ships with a robust migration framework that ensures user data—profiles, settings, and configuration files—remains compatible across application updates. When a user launches a newer version, the migration system in Clash Nyanpasu detects pending upgrades, executes them in sequence, and persists the results to prevent duplicate runs.

Core Architecture of the Migration System

The framework is built around three core abstractions: the Migration trait that defines individual upgrade steps, a persistent JSON store that tracks execution state, and a Runner that orchestrates the process.

The Migration Trait

Each data upgrade implements the Migration trait defined in backend/tauri/src/core/migration/mod.rs. This interface requires the target version, a unique name, the transformation logic, and an optional rollback mechanism.

pub trait Migration<'a>: DynClone {
    fn version(&self) -> &'a Version;                 // target version, e.g. 2.0.0
    fn name(&self) -> Cow<'a, str>;                  // unique identifier
    fn migrate(&self) -> std::io::Result<()> { … }   // actual transformation
    fn discard(&self) -> std::io::Result<()> { Ok(()) } // rollback on failure
}

Migration State Persistence

The system records progress in a JSON file named migration.json located in the application data directory (e.g., ~/.local/share/clash-nyanpasu/migration.json on Linux). The schema, defined in backend/tauri/src/core/migration/db.rs, stores the last successfully migrated version and a map of migration names to their execution states.

{
  "version": "2.0.0",
  "states": {
    "profile_script_newtype": "Completed",
    "some_other_migration": "Failed"
  }
}

The Runner Orchestrator

The Runner struct, also in backend/tauri/src/core/migration/mod.rs, serves as the high-level orchestrator. It loads the persisted state, determines which migrations are pending, and executes them in order. The runner provides methods like run_upcoming_units() to process all pending migrations for the current application version.

How Migrations Are Defined and Batched

Migrations are organized into logical groups to simplify version management and ensure atomicity where required.

Migration Units and Version Grouping

The Unit enum wraps either a single migration or a batch of migrations that belong to the same semantic version. This allows the system to treat all migrations for version 2.0.0 as a single logical unit, ensuring they run together or not at all.

The Static UNITS Registry

All available migration units are registered in a compile-time static array located in backend/tauri/src/core/migration/units/mod.rs. This UNITS list maps semantic versions to their corresponding Unit implementations, ordered chronologically.

pub static UNITS: &[(Version, Unit<'static, dyn Migration<'static>>)] = &[
    // ... earlier units ...
    (
        Version::new(2, 0, 0),
        Unit::Single(&ProfileScriptNewtype {}),
    ),
    // ... later units ...
];

Execution Flow and Error Handling

The migration system employs a deterministic execution flow with clear decision logic and robust error handling.

Decision Logic for Pending Migrations

The Runner uses the advice_migration method to determine whether a specific migration should run. This logic compares the migration's target version against the stored version and checks the previous execution state.

pub fn advice_migration<'a, T>(&self, migration: &T) -> MigrationAdvice
where
    T: Clone + Migration<'a> + Send + Sync,
{
    let migration_ver = migration.version();
    let store = self.store.borrow();

    // Run only if migration version ≥ stored version
    if migration_ver >= &store.version {
        // Look up previous state for this named migration
        match store.states.get(&migration.name()) {
            Some(MigrationState::Completed) => MigrationAdvice::Done,
            Some(MigrationState::Failed)    => MigrationAdvice::Pending,
            _                               => MigrationAdvice::Ignored,
        }
    } else {
        MigrationAdvice::Ignored
    }
}

Running Migrations and Rollback Mechanism

When run_upcoming_units is called, the runner iterates through the UNITS array starting from the stored version. For each pending migration, it executes the migrate method. If successful, the state is updated to Completed. If an error occurs, the state is marked as Failed and the optional discard method is invoked to roll back any partial changes.

A DropGuard ensures that the state file is written back to disk even if the application crashes immediately after a migration completes, preventing data inconsistency.

Real-World Example: Profile Script Migration (v2.0.0)

The migration that rewrites the old "profile script" format into a new strongly-typed representation demonstrates the practical implementation. Located in backend/tauri/src/core/migration/units/unit_200/profile_script_newtype.rs, this migration implements the Migration trait for version 2.0.0.

// backend/tauri/src/core/migration/units/unit_200/profile_script_newtype.rs
impl Migration<'_> for ProfileScriptNewtype {
    fn version(&self) -> &Version { &Version::new(2, 0, 0) }
    fn name(&self) -> Cow<'_, str> { Cow::Borrowed("profile_script_newtype") }

    fn migrate(&self) -> std::io::Result<()> {
        eprintln!("Trying to migrate profiles files...");
        let profiles = migrate_profile_data(profiles);
        // … write back the transformed YAML …
        Ok(())
    }

    fn discard(&self) -> std::io::Result<()> {
        // If migration fails, we can revert the file to its original backup.
        Ok(())
    }
}

When a user upgrades from any version prior to 2.0.0, the Runner detects this pending migration, executes the data transformation, and updates the stored version to prevent re-execution on subsequent launches.

Summary

  • Persistence Layer – The MigrationFile struct in backend/tauri/src/core/migration/db.rs maintains a JSON record of the last successful version and individual migration states.
  • Trait-Based Abstraction – Each upgrade step implements the Migration trait, defining version targets, transformation logic via migrate(), and rollback capability via discard().
  • Version Grouping – The Unit enum batches migrations by semantic version, registered in the static UNITS array in backend/tauri/src/core/migration/units/mod.rs.
  • Orchestration Logic – The Runner struct provides advice_migration() to determine pending work and run_upcoming_units() to execute migrations sequentially.
  • Error Handling – Failed migrations trigger discard() for rollback and are marked as Failed in the state file for retry on next startup.
  • Atomic Persistence – A DropGuard ensures the migration state is written to disk even if the application crashes post-migration.

Frequently Asked Questions

How does Clash Nyanpasu know which migrations to run after an update?

The migration system in Clash Nyanpasu compares the version stored in migration.json against the target versions defined in the static UNITS registry. The Runner::advice_migration() method checks if a migration's version is greater than or equal to the stored version and whether its state is not already Completed, returning Pending only for migrations that need execution.

What happens if a migration fails during the upgrade process?

If a migration fails, the Runner catches the error and marks the migration state as Failed in the persistent store. It then invokes the optional discard() method on the migration implementation to roll back any partial changes. On the next application startup, the migration will be retried since its state remains Failed rather than Completed.

Where is the migration state stored on disk?

The migration state is persisted in a JSON file named migration.json located in the application data directory, typically ~/.local/share/clash-nyanpasu/migration.json on Linux or equivalent paths on other platforms. This file stores the last successfully migrated version and a map of individual migration names to their execution states (NotStarted, InProgress, Completed, or Failed).

Can developers test migrations before releasing a new version?

Yes, developers can test migrations by invoking the Runner with skip_advice set to true, which forces the execution of migrations regardless of the stored version state. This is particularly useful in nightly builds or development environments to verify that new migrations in backend/tauri/src/core/migration/units/ correctly transform data without waiting for an actual version upgrade scenario.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →