OpenLogi Configuration Schema Versioning and Migration Paths Explained
OpenLogi manages configuration compatibility by storing a schema_version field in 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.
The current schema version is defined as a public constant:
/// 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:
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:
migrate_owner_locked_gestures(for versions ≤ 3) – Demotes non-owner bindings and ensures default owner entries exist.migrate_transport_scoped_keys(for versions ≤ 4) – Renames keys and collapses duplicates.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– Contains theSCHEMA_VERSIONconstant, theConfigstruct definition, and all migration implementations includingmigrate_owner_locked_gestures,migrate_transport_scoped_keys, andmigrate_thumbwheel_native_direction. -
crates/openlogi-core/src/config/tests.rs– Comprehensive test suite verifying migration paths for each version increment. -
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– User-facing documentation specifying the requiredschema_versionfield and current version requirements.
Practical Code Examples
Creating a Fresh Configuration
New configurations automatically use the current schema version:
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:
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:
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_versionfield inconfig.tomlalongside theSCHEMA_VERSIONconstant 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_pathwhen 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.rswith comprehensive tests inconfig/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 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.
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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →