Skip to content

Rubric Migration

Oliver Yasuna edited this page Jul 1, 2026 · 1 revision

Rubric — Migration

Every @Config has a version. When a loaded file declares a lower version, Rubric runs registered migration steps in order before decoding. This lets you evolve the schema (rename fields, restructure sections, transform values) without invalidating existing files.

The reserved version key

Rubric stores the schema version at the reserved root key rubric_config_version. When reading a file:

  1. Migrator.readVersion(tree, assumed = 1) reads the key. If absent, assume version 1.
  2. Migrator.migrate(tree, fileVersion, schemaVersion, registry) runs registered steps from fileVersion + 1 up to schemaVersion, in order.
  3. After a successful load, save() calls Migrator.stampVersion(tree, schemaVersion) so the file writes the new version back.

Writing a step

import com.oliveryasuna.mc.rubric.migration.MigrationRegistry;
import com.oliveryasuna.mc.rubric.migration.MigrationStep;
import com.oliveryasuna.mc.rubric.migration.MigrationOps;

final MigrationRegistry migrations = new MigrationRegistry()
        .register(MigrationStep.builder(2)  // target version
                .op(MigrationOps.renameKey("legacyName", "displayName"))
                .op(MigrationOps.move("audio.oldVolume", "audio.master"))
                .op(MigrationOps.transform("tickInterval", node -> {
                    // v1 stored ticks; v2 stores milliseconds.
                    if(node instanceof Scalar s && s.value() instanceof Number ticks) {
                        return Scalar.of(ticks.intValue() * 50);
                    }
                    return node;
                }))
                .build());

// Hand the registry to the manager at registration time.
Rubric.register(MyModConfig.class, migrations);

Ops

MigrationOps provides three built-in factories. Every op takes dotted paths ("display.opacity") and is a no-op if the referenced key is absent — so re-running a step on an already-migrated tree is safe.

  • renameKey(oldPath, newPath) — moves the value at oldPath to newPath. If newPath already has a value, the old one wins.
  • move(fromPath, toPath) — same as renameKey in practice; the alias exists for readability when you're moving across sections.
  • transform(path, Function<ValueNode, ValueNode>) — runs an arbitrary transform on the node at path. The function may return the same node type, a different node type, or null to delete.

Custom ops: implement MigrationOp. Each op receives the root Section and mutates it in place. Look at MigrationOps for examples.

Multi-step migrations

Register one step per target version. Rubric picks the shortest path from fileVersion + 1 to schemaVersion — so if a user comes in on version 1 and your schema is version 4, steps for 2, 3, and 4 all run in order.

migrations
    .register(MigrationStep.builder(2).op(...).build())
    .register(MigrationStep.builder(3).op(...).build())
    .register(MigrationStep.builder(4).op(...).build());

Downgrades

If the file's version is higher than the schema's — the user launched an older version of your mod after using a newer one — Rubric returns a MigrationReport with downgrade = true and skips the load (the tree is not mutated, defaults are seeded, no file rewrite happens). This is intentional: downgrading almost always destroys data. If your mod knows how to handle a specific downgrade path, register that step and set sync.strict = false — but plan carefully.

The report

LoadResult.migration() returns a MigrationReport:

  • migration.migratedtrue if any step ran.
  • migration.applied — the list of target versions the migrator walked through.
  • migration.downgradetrue if the file was newer than the schema.
  • migration.fromVersion / migration.toVersion — the endpoints.

Use this to log or surface a message on first launch after a version bump.

Testing your migration

Rubric ships headless test doubles (HeadlessPlatform, InMemoryConfigIO) in rubric-core that make unit-testing a migration straightforward. Seed the in-memory IO with the "old" file contents, register the migration, run manager.load(), assert on the resulting POJO.

What next

  • ConceptsMigrator, MigrationReport, LoadResult.
  • Formats — reminder that the reserved version key is written into whatever format your @Config picked.

Clone this wiki locally