# OpenLogi Configuration Schema Versioning and Migration Paths Explained

> Understand OpenLogi configuration schema versioning and migration paths. Learn how OpenLogi automatically upgrades old config files and maintains compatibility.

- Repository: [Xuan Zhang/OpenLogi](https://github.com/AprilNEA/OpenLogi)
- Tags: migration-guide
- Published: 2026-09-12

---

**OpenLogi manages configuration compatibility by storing a `schema_version` field in [`config.toml`](https://github.com/AprilNEA/OpenLogi/blob/main/config.toml) that is compared against the `SCHEMA_VERSION` constant in the binary; when versions mismatch, idempotent migration functions automatically upgrade older files while refusing to load newer ones.**

OpenLogi (AprilNEA/OpenLogi) persists user preferences in a TOML configuration file. To ensure seamless upgrades across releases, the codebase implements a robust versioning system that detects stale schemas and transforms them at runtime. This article examines the implementation details, migration history, and code paths that handle configuration evolution from version 1 through version 7.

## How Schema Versioning Works in OpenLogi

At the core of the system lies the `schema_version` field within the TOML file and the `SCHEMA_VERSION` constant defined in [`crates/openlogi-core/src/config.rs`](https://github.com/AprilNEA/OpenLogi/blob/main/crates/openlogi-core/src/config.rs).

The current schema version is defined as a public constant:

```rust
/// The schema version the current build produces.
pub const SCHEMA_VERSION: u32 = 7;

```

When the application initializes, `Config::load_from_path` reads the persisted file and compares the embedded `schema_version` against this constant. If the stored version is older, the runtime executes a series of targeted migration methods. If the stored version exceeds the binary's capability, loading fails immediately to prevent data loss.

## The Seven Schema Versions and Breaking Changes

OpenLogi has evolved through seven distinct schema iterations, each addressing specific architectural changes:

### Version 1: Initial Format

The baseline format stored raw device configurations. No migration logic was required because this represented the starting state.

### Version 2: Unified Binding Maps

Per-device `button_bindings` and `gesture_bindings` were merged into a single `bindings` map. Migration occurs through the `RawDeviceConfig` shim, which folds legacy fields into the new structure during deserialization. The file is rewritten with the new schema on the next save operation.

### Version 3: Physical Device Identifiers

The device map key transitioned from model identifiers to physical-device identifiers. **No automatic migration exists** for this version because safely mapping model-based entries to physical devices is impossible when identical devices are present. Older entries are silently ignored.

### Version 4: Owner-Locked Gesture Removal

The global *owner-locked* gesture mode was replaced with per-button ownership. The `Config::migrate_owner_locked_gestures` function handles this transition by rewriting the old `gesture_owner` field and demoting non-owner gestures to single-click bindings.

### Version 5: Transport-Scoped to Identity Keys

Device keys changed from transport-scoped format (`direct:<vid>:<pid>:…`) to plain identity keys (`unit:<id>`), while preserving route information as metadata. `Config::migrate_transport_scoped_keys` parses legacy keys, creates new identity mappings, folds duplicate entries, and updates all references including `selected_device` and `host_switch_targets`.

### Version 6: Threshold-Based Bindings

Added support for `short` and `long` press thresholds. No explicit migration code was required because the new fields utilize sensible defaults, allowing older configurations to load unchanged.

### Version 7: Thumb-Wheel Native Direction (Current)

Thumb-wheel scroll defaults were normalized to match the device's physical direction. `Config::migrate_thumbwheel_native_direction` detects the legacy default pair (`HorizontalScrollRight/Left`) and swaps the actions to the new defaults (`HorizontalScrollLeft/Right`).

## Migration Execution Flow

Migrations execute sequentially during the loading process in `Config::load_from_path`. The logic follows this pattern:

```rust
if self.schema_version != SCHEMA_VERSION {
    // Apply migrations for each older version in order
}

```

The system guarantees **idempotency**: each migration checks the current version before executing, ensuring a configuration file upgrades at most once per release. The typical flow proceeds through:

1. **`migrate_owner_locked_gestures`** (for versions ≤ 3) – Demotes non-owner bindings and ensures default owner entries exist.
2. **`migrate_transport_scoped_keys`** (for versions ≤ 4) – Renames keys and collapses duplicates.
3. **`migrate_thumbwheel_native_direction`** (for versions ≤ 6) – Corrects scroll orientation defaults.

## Key Implementation Files

Understanding the migration architecture requires examining these specific source files:

- **[`crates/openlogi-core/src/config.rs`](https://github.com/AprilNEA/OpenLogi/blob/main/crates/openlogi-core/src/config.rs)** – Contains the `SCHEMA_VERSION` constant, the `Config` struct definition, and all migration implementations including `migrate_owner_locked_gestures`, `migrate_transport_scoped_keys`, and `migrate_thumbwheel_native_direction`.

- **[`crates/openlogi-core/src/config/tests.rs`](https://github.com/AprilNEA/OpenLogi/blob/main/crates/openlogi-core/src/config/tests.rs)** – Comprehensive test suite verifying migration paths for each version increment.

- **[`crates/openlogi-desktop/src/state/config.rs`](https://github.com/AprilNEA/OpenLogi/blob/main/crates/openlogi-desktop/src/state/config.rs)** – Desktop application wrapper that invokes the core loading logic and handles UI-specific configuration state.

- **[`docs/CONFIGURATION.md`](https://github.com/AprilNEA/OpenLogi/blob/main/docs/CONFIGURATION.md)** – User-facing documentation specifying the required `schema_version` field and current version requirements.

## Practical Code Examples

### Creating a Fresh Configuration

New configurations automatically use the current schema version:

```rust
use openlogi_core::config::Config;

let cfg = Config::default();
// cfg.schema_version == SCHEMA_VERSION (7)

```

### Loading and Automatically Migrating

The standard entry point handles version detection and migration transparently:

```rust
use openlogi_core::config::ConfigFile;
use std::path::PathBuf;

let path = dirs::config_dir()
    .unwrap()
    .join("openlogi")
    .join("config.toml");

// Loads file, checks version, runs migrations if needed
let cfg = ConfigFile::load(&path)?;
// cfg.schema_version is now 7

```

### Manual Migration for Testing

For testing specific upgrade paths, you can simulate old versions and trigger migrations individually:

```rust
use openlogi_core::config::Config;

let mut cfg = Config::default();
cfg.schema_version = 4;

cfg.migrate_owner_locked_gestures();
cfg.migrate_transport_scoped_keys();
// Continue with additional migrations as needed

```

## Summary

- OpenLogi stores a `schema_version` field in [`config.toml`](https://github.com/AprilNEA/OpenLogi/blob/main/config.toml) alongside the `SCHEMA_VERSION` constant in the binary.
- Seven schema versions exist, with versions 3, 4, 5, and 7 requiring explicit migration logic.
- Migrations run automatically during `Config::load_from_path` when the stored version differs from the binary constant.
- Each migration function is idempotent and executes only when the file version is below the target threshold.
- Configuration files with versions newer than the binary are rejected to prevent data loss.
- The core logic resides in [`crates/openlogi-core/src/config.rs`](https://github.com/AprilNEA/OpenLogi/blob/main/crates/openlogi-core/src/config.rs) with comprehensive tests in [`config/tests.rs`](https://github.com/AprilNEA/OpenLogi/blob/main/config/tests.rs).

## Frequently Asked Questions

### What happens if I open a newer configuration file with an older OpenLogi binary?

Loading fails with an error. The code explicitly checks if the stored version exceeds `SCHEMA_VERSION` and returns an error rather than attempting to downgrade or ignore unknown fields. This prevents silent data loss or corruption of newer configuration features.

### Why doesn't version 3 have an automatic migration?

Version 3 changed device identification from model-based keys to physical-device identifiers. Because multiple identical devices could exist simultaneously, the code cannot safely determine which physical instance corresponds to a legacy model-based entry. Therefore, [`crates/openlogi-core/src/config.rs`](https://github.com/AprilNEA/OpenLogi/blob/main/crates/openlogi-core/src/config.rs) intentionally omits automatic migration for this version, ignoring older entries rather than risking incorrect device mappings.

### Are the migration functions safe to run multiple times?

Yes, all migration functions are idempotent. Each function checks the current `schema_version` before executing transformations. For example, `migrate_thumbwheel_native_direction` only executes when the version is less than or equal to 6, and `migrate_transport_scoped_keys` only processes versions ≤ 4. This ensures that rerunning migrations on already-updated files has no effect.

### How does version 2 migration work without a dedicated function?

Version 2 merged separate binding maps using Rust's deserialization shims. Rather than an explicit migration function, the `RawDeviceConfig` struct handles the transformation during TOML parsing, folding `button_bindings` and `gesture_bindings` into the unified `bindings` map. The file is automatically rewritten with the new schema on the next save operation.