Skip to content

LegacyDataUpgrade

Basti edited this page Sep 27, 2026 · 1 revision

LegacyDataUpgrade runs a one-time clean upgrade of a plugin's data folder after a major release. It moves the old files into a backup folder, writes fresh bundled files, and carries over only the values the admin had actually changed. Use it when an in-place migration (ConfigKeyMigrator + ConfigDefaultsInserter) would leave too much old text behind: outdated comments, old defaults the admin never touched, missing new list entries.

Why Use It? 🎯

  • The new version renamed keys, changed indentation, and changed defaults all at once.
  • Admins should get the new file with its current comments, not their old file with renamed keys.
  • Their own settings must survive, and they should learn what couldn't be carried over.

Highlights ✨

  • Runs only once: it starts when the old file still has one of the old version's keys (the marker keys). The fresh file no longer has them; run() makes sure of that and throws an IllegalStateException before touching anything if a marker key is in the new bundled marker file.
  • Never deletes anything. The whole data folder moves into a backup folder, e.g. backup-1.x; if that exists, backup-1.x-2 and so on are used.
  • Compares the admin's old file with the old default file bundled in the jar, so values the admin never touched get the new defaults.
  • Changed values go into the fresh file as text: only the value is replaced, comments and quoting stay. A changed list replaces the list block and reports new default entries it lacks (e.g. POPLAR_SAPLING missing from a custom sapling list).
  • Keys are renamed with the same rules as ConfigKeyMigrator, by key name (carryOverValues) or by exact path (carryOverValuesByPath), so the plugin's existing migration map can be reused.
  • Works with old files saved as UTF-8 or ANSI; the result is UTF-8 with the fresh file's line endings.
  • Only technical errors are logged (English WARNING). Everything else comes back in a Report for the plugin to show in its own language.

Limitations ⚠️

  • Everything moves: every entry of the data folder goes into the backup, also databases, caches or leftovers you didn't plan for; only keepFiles come back. There's no dry run, and nothing is restored on a downgrade.
  • No "done" record: the upgrade starts whenever the marker file holds a marker key. On purpose: after a downgrade to the old version, which adds the old keys back, the next start upgrades again (backup-1.x-2, ...). An admin who adds a marker key back by hand gets the same. A marker key in your new bundled marker file is caught (see Misconfiguration throws).
  • A locked file stops it: if an entry can't be moved (e.g. a file another program holds open), the moved entries go back and the upgrade is tried again on the next start.
  • Marker detection: markers are found on key lines, not inside list items or in text values; the marker file must be UTF-8 or Windows-1252.
  • Misconfiguration throws: run() throws an IllegalStateException without markerKeys, if backupDirName isn't a plain folder name (letters, digits, ., _, -, starting with a letter or digit, no ..), or if the new bundled marker file contains a marker key (found the same way as in the data folder). It checks this on every call, before any file is touched. carryOverValues and carryOverValuesByPath throw an IllegalArgumentException right away if an open section is listed twice or the renames are rejected (see Renames). A carryOverValues file must exist under legacyDefaultsRoot, or the upgrade stops with an error.
  • Kept files are copied: each file is read into memory and written anew, without its permissions, owner or timestamps; empty folders and symlinked folders aren't copied.
  • Values it can carry: single values, including dates (2024-01-01), and flat lists of single values. Lists of sections, nested lists and serialized objects are reported as UNSUPPORTED_VALUE. If the old file isn't valid YAML, nothing is carried, and the error is listed in errors().
  • Renames: the same rules as ConfigKeyMigrator. carryOverValues renames by key name in every section and rejects conflicting or chained names and entries that change the nesting depth. carryOverValuesByPath renames exact paths, a renamed section takes everything below it, and it rejects entries whose result depends on their order.
  • Open sections: only their direct children are added or removed; keys the admin nested deeper are dropped as UNKNOWN_OLD_KEY. Deleted defaults are only mirrored inside open sections; anywhere else a deleted default comes back.
  • Loose comparison: numbers compare by value, dates by their date text, everything else as text, so "true" equals true and "5" equals 5.
  • Formatting of carried values: a replaced value keeps the fresh file's quoting (a plain default stays plain unless the text needs quotes), a key added to an open section is written in double quotes, dates as plain 2024-01-01 (or a UTC time like 2024-01-01T10:00:00Z), decimals in Java notation (1.23456789E7, still read as a number), and comments between list items move below the new items.
  • Requirements on your bundled files: each key a value is carried into must be on its own line in block style; an anchor or tag on that line stays. Multi-line text into a single-quoted default gives WRITE_FAILED.
  • Inline open sections: an open section your bundled file writes inline on one line (soils: {DIRT: true}) is rewritten to one key per line, with all shipped entries, when an admin key goes into it or a deleted default comes out. One spread over several lines or holding sections or nested lists can't be rewritten: an admin key gives WRITE_FAILED, and deleted defaults stay.

Quick Start 🚀

  1. Bundle the old version's default files in the jar under one folder, e.g. legacy/1.x/config.yml, legacy/1.x/lang/en_US.yml. Keep them byte-identical to the old release (exclude the folder from formatters).
  2. Call the builder at the very start of onEnable(), before saveDefaultConfig() or anything else reads the data folder.
  3. Keep the Report and log it once your messages are loaded.

References 📚

Builder method Purpose
LegacyDataUpgrade.builder(Plugin) Uses the plugin's data folder, getResource and logger. builder(File, Function<String, InputStream>, Logger) does the same without a plugin (tests).
markerKeys(String...) Dotted paths only the old version has. Any of them in the marker file starts the upgrade; checked on the raw text, so it also works when the old file isn't valid YAML. Required.
markerFile(String) File checked for the marker keys. Default config.yml.
backupDirName(String) Backup folder inside the data folder, e.g. backup-1.x. Required. Earlier backups with this name (plus -2, -3, ...) are not moved again.
legacyDefaultsRoot(String) Jar folder with the old defaults: the old default of data file X is the resource <root>/X.
olderDefaultsRoot(String) Jar folder with an older release's defaults, laid out like legacyDefaultsRoot; call it once per release. A file there may hold only the keys whose defaults differed. With at least one, a compared file counts as unchanged if every value it has is that key's default in one known release; keys it lacks don't count. Only affects customizedFiles() and filesWithoutBaseline(), not carryOverValues.
freshResources(String...) / freshResources(Collection<String>) Bundled files written fresh; the jar path equals the data-folder path.
keepFiles(String...) Files or folders copied back unchanged from the backup, e.g. player data. Copied after the fresh files.
carryOverValues(String file, Map<String, String> keyMigrations, String... openSections) Carries changed values of file into its fresh copy (the file is written fresh automatically), renamed by key name. openSections are new paths of sections the admin fills freely, e.g. soils.
carryOverValuesByPath(String file, Map<String, String> pathMigrations, String... openSections) Same, but renamed by exact path like ConfigKeyMigrator.rewriteKeyPathsInFile, so values can move to another section or depth.
ignoreOldKeys(String file, String... oldPaths) Old keys of file that are neither carried over nor reported, e.g. leftovers of an even older version that the old version no longer read. A path matches that one value; section.* matches everything below the section.
run() Runs the upgrade and returns the Report.
Report field Contents
performed() true if the old files were moved into the backup. notNeeded() is true when there was nothing to do and no error.
backupDir() Path of the backup folder (relative if the data folder is), null if nothing was moved.
carried() CarriedValue(file, oldPath, newPath, value) for each value now in the fresh file.
dropped() DroppedValue(file, oldPath, value, reason) for each changed value left out; the backup still has it.
removed() RemovedEntry(file, oldPath, newPath) for default keys the admin had deleted from an open section, removed here too.
missingListEntries() MissingListEntries(file, path, entries): entries new in the shipped default list that the carried-over list lacks.
customizedFiles() Backup files whose content differs from their old default. With olderDefaultsRoot, only files with a value no known release had. The admin's version now lives only in the backup.
filesWithoutBaseline() Backup files that have no old default to compare against and weren't copied back (e.g. a custom language file).
errors() Technical problems. performed() == false with errors means the upgrade stopped before touching any file.

Values are strings, lists read [A, B]; file paths use /. SkipReason is one of:

  • UNKNOWN_OLD_KEY – the key isn't in the old defaults, and the new file has no such key either.
  • NO_NEW_KEY – the key was a default in the old version but has no counterpart in the new file.
  • TYPE_MISMATCH – list vs. single value vs. section don't match between old and new.
  • UNSUPPORTED_VALUE – e.g. a list of sections, which can't be written back as text.
  • WRITE_FAILED – writing the value would have broken the file, so it was left out.

LegacyDataUpgrade.hasAnyKey(File yamlFile, Collection<String> paths) (also with String...) tells whether a file has one of the dotted key paths, found on its key lines the same way as the marker keys, so it also works for a file that isn't valid YAML. It returns false for a missing or unreadable file. Use it to decide, before building an upgrade, which old version a folder comes from, e.g. whether ignoreOldKeys applies.

How Values Are Carried Over 🔁

  • Only leaf values that differ from the old default (or that the old default doesn't have) are considered. Numbers compare by value, lists entry by entry.
  • With carryOverValues the old path is renamed segment by segment like ConfigKeyMigrator (a species-limits.oak.max-horizontal-radius entry also renames the birch section's key). With carryOverValuesByPath only the listed paths and everything below them are renamed.
  • A value that already equals the new default is skipped silently.
  • A value the old file holds under both its old and its new name (e.g. after the old version ran again in a folder the new one had used) comes from the new name: it's carried if it differs from the new default and reported alone, while the old name is neither carried nor reported. Keys listed in ignoreOldKeys don't count as the new name.
  • Single values: only the value in the new key's line changes. The fresh file's comments, inline comment and quoting style ("...", '...', plain) stay.
  • Lists: the list block is replaced; comments inside it stay, below the new items. New default entries the admin's list lacks are reported, not added.
  • Open sections: keys the admin added are inserted with the indentation of their siblings; old default keys the admin had deleted are removed; keys new in this version stay.
  • Pass the same sections to ConfigDefaultsInserter.insertMissingKeys(..., openSections) on later reloads, so the entries removed here aren't added back. The upgrade moves its .seen-defaults.yml into the backup too, so the record starts fresh.
  • After every change the file must still be valid YAML and hold the admin's value, otherwise the change is undone and reported as WRITE_FAILED.
  • Other files (language files, extra config files) are always fresh. They are compared by parsed content, so line endings, indentation or quoting alone don't count as a customization.
  • Older releases may have shipped other defaults, and 1.x-style plugins only added missing keys on update, so one folder can mix generations. Pass each older release to olderDefaultsRoot: an untouched file from an early release, or one a later release completed with its new keys, then isn't reported. A value set back to exactly an older release's default looks unchanged too; a changed value, a deleted or added list entry or an extra key is still reported.
  • With olderDefaultsRoot, the new version's bundled file counts as a known release too, so a folder that a downgrade to the old version had mixed with new files isn't reported either.
  • TurtleLib's own state file .seen-defaults.yml is moved into the backup but never listed in customizedFiles() or filesWithoutBaseline().

Failure Behavior 🛡️

  • Everything that can fail without touching the data folder runs first: checking the markers against the bundled marker file, detecting the marker, reading all fresh and old default files from the jar. A missing resource stops the upgrade with an error and nothing changes.
  • If moving into the backup fails halfway, the moved entries are moved back.
  • Problems after the move (writing a fresh file, copying a kept file) are logged and listed in errors(). The backup is complete in any case, and the plugin continues with fresh files.

Examples 🧪

import net.kroet.turtlelib.helper.LegacyDataUpgrade;

public final class ExamplePlugin extends JavaPlugin {
    private LegacyDataUpgrade.Report upgradeReport;

    @Override
    public void onEnable() {
        upgradeReport = LegacyDataUpgrade.builder(this)
            .markerKeys("update-check", "chat-prefix-label")
            .backupDirName("backup-1.x")
            .legacyDefaultsRoot("legacy/1.x/")
            .freshResources("leaf_mappings.yml")
            .keepFiles("toggles.yml")
            .carryOverValues("config.yml", LEGACY_CONFIG_KEYS, "soils", "saplings")
            .run();

        saveDefaultConfig();
        // ... load messages, then log upgradeReport in the server's language
    }
}

Tips & Notes 💡

  • Show the backup path relative to the server folder, e.g. getServer().getWorldContainer().toPath().toAbsolutePath().normalize().relativize(report.backupDir().toAbsolutePath().normalize()); backupDir() is relative on a default server start.
  • Pick marker keys that exist only in the old version and are unlikely to be removed by admins, e.g. top-level sections. run() refuses marker keys that are in the new bundled marker file, so any test that runs the upgrade with the real resources catches a wrong choice.
  • Language files don't need to be in freshResources if your MessageService writes them anyway; they are still moved to the backup and compared with their old defaults.
  • Two old versions with different formats: run one builder per version, newest first, with the same backupDirName, and start the next only if the previous one's notNeeded() is true. A version whose values can't be mapped can get a builder without carryOverValues (fresh files only); give it that version's defaults with olderDefaultsRoot, so its untouched files aren't listed in filesWithoutBaseline(). If a later version left the older keys in its files, list them in ignoreOldKeys: a leftover that shares its name with a new key would otherwise override the value the later version actually used.
  • For small releases without a major format change, prefer ConfigKeyMigrator + ConfigDefaultsInserter: they keep the admin's file and only change what's needed.

Relationships to Other Helpers 🔗

Clone this wiki locally