Skip to content

Repository files navigation

evolvejson

Versioned JSON config for C++ — load, migrate, log, save. Without the boilerplate.

Header-only. Single dependency: nlohmann-json. Targets C++23.


Table of contents


What you get

  • Versioned schema with hand-written migrations.
  • Typed section access via nlohmann's NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT.
  • Data-safe writes: versioned .bak snapshots, atomic rename, skip-if-identical.

Quick start

#include <evolvejson/evolvejson.hpp>
#include <iostream>

struct DatabaseConfig {
    std::string host;
    int         port{ 5432 };
    bool        readOnly{ false };
};
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(
    DatabaseConfig, host, port, readOnly)

int main() {
    evolvejson::ConfigFile cfg{ "config.json", /*targetVersion=*/2 };

    cfg.addMigration(1, 2,
        "Move readOnly: appCfg -> database",
        [](evolvejson::Json& j) {
            if (j["appCfg"].contains("readOnly")) {
                j["database"]["readOnly"] = j["appCfg"]["readOnly"];
                j["appCfg"].erase("readOnly");
            }
        });

    auto loaded = cfg.load();
    if (!loaded) { std::cerr << loaded.error() << '\n'; return 1; }

    auto db = cfg.section<DatabaseConfig>("database");
    if (!db) { std::cerr << db.error() << '\n'; return 1; }

    // db->host, db->port, db->readOnly ...
}

What happens on load()

  1. Reads the file from disk.
  2. Inserts a Version field if the file has none (controlled by assumedLegacyVersion).
  3. Runs registered migrations forward to targetVersion.
  4. If anything changed and autoRewriteOnChange = true, takes a .vN.bak snapshot of the old file and atomically rewrites the current one.
  5. Returns a LoadResult; typed access goes through cfg.section<T>("path").

Concepts

Schema version

A top-level JSON integer field (default name "Version"). Your code declares a targetVersion — evolvejson migrates the loaded document forward to that number on load.

Migration

A pure function on raw JSON:

struct Migration {
    uint32_t fromVersion;
    uint32_t toVersion;
    std::string description;
    std::function<void(Json&)> apply;
};

Migrations are applied in order current → target, picked by matching fromVersion to the document's current version. After each migration, the document's Version is bumped to toVersion.

Default-filling

load() does not parse typed structs — it loads, migrates, and saves the raw JSON. Typed structs come into play via section<T>(): nlohmann's NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT makes missing fields silently take the struct's C++ default value.

Rewrite triggers

The file is saved back to disk when:

  • a migration ran, or
  • Version was missing and had to be inserted, or
  • a setSectionOrder rule changed the layout, or
  • a setSectionSchema(... Drop) rule removed an unknown key.

Controlled by LoadOptions::autoRewriteOnChange (defaults to true).

Versioned backup

Before any rewrite, a .vN.bak snapshot of the old file is taken, where N is the schema version of the current (pre-migration) file. If a backup for that version already exists, a numeric suffix is appended: config.v1.bak.1.json, config.v1.bak.2.json, ...


Core API

The five members below are the entire core surface. Anything not listed here is opt-in advanced behaviour, covered in later sections.

ConfigFile(path, targetVersion, opts = {})

ConfigFile(std::filesystem::path path,
           uint32_t targetVersion,
           LoadOptions opts = {});

Identifies the file on disk and the schema version your code expects. Construction is cheap — no I/O happens until load().

addMigration(from, to, description, fn)

ConfigFile& addMigration(uint32_t from, uint32_t to,
                         std::string description,
                         std::function<void(Json&)> apply);
ConfigFile& addMigration(Migration m);

Register as many migrations as you need; registration order doesn't matter (they're selected by fromVersion). Chainable.

load() — two forms

[[nodiscard]] std::expected<LoadResult, std::string> load();

bool load(LoadResult& out,
          const ErrorCallback& onError = {}) noexcept;

The expected form is the primary API; the bool form is a convenience for callers that prefer an error callback over branching on a result type. Both do the same work and never throw.

auto r = cfg.load();
if (!r) { std::cerr << r.error() << '\n'; return 1; }
// r->json, r->loadedVersion, r->finalVersion, r->wasMigrated, r->wasRewritten

evolvejson::LoadResult res;
if (!cfg.load(res, [](auto e){ std::cerr << e << '\n'; })) return 1;

section<T>(path), sectionOr<T>(path, fallback), document()

Valid only after load() succeeds.

template <typename T>
std::expected<T, std::string> section(std::string_view path) const;

template <typename T>
T sectionOr(std::string_view path, T fallback) const noexcept;

const Json* document() const noexcept;

Path format: JSON Pointer with / separator. Leading / is optional.

cfg.section<DatabaseConfig>("database");
cfg.section<MigrationCfg>("users/migration/sql");
cfg.section<std::vector<Filter>>("symbols/priceFilters");

T must be nlohmann::json-deserialisable. Use NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT for struct types — it gives you free default-filling for missing fields.

save(j)

[[nodiscard]] std::expected<void, std::string> save(const Json& j) const;

Most callers never call this — load() does it for you. Reach for save() when autoRewriteOnChange = false, or to flush an in-memory edit you made (e.g. a password decrypted at startup that you want re-encrypted on disk).

save() takes a .bak of the existing file (if backupOnSave = true), then writes atomically: the document is serialised into <path>.tmp, flushed, closed, and rename()d onto the target. Power loss, disk-full, quota-exceeded, or a throwing onBeforeSave therefore never corrupt the existing file; any half-written .tmp is cleaned up on the error path.

Skip-if-identical: before doing any disk work, save() compares the would-be bytes (post-hook, post-format) against the current on-disk content. Identical bytes → complete no-op, no .bak, no .tmp, no rename. Steady-state configs do not accumulate backups.


Writing migrations

Golden rules

  1. Pure. No I/O, no globals, no time-dependent behaviour.
  2. Idempotent where possible. If a key you want to add already exists, leave it alone. Defensive migrations are cheap insurance against re-runs in test harnesses.
  3. Self-documenting. The description field goes into the log on every migration run.
  4. Never depend on C++ struct layouts. A migration written today must still work five years from now, even after the relevant struct is deleted or restructured.
  5. Never throw on "missing field." If the source field isn't there, the migration is a no-op or installs a safe default.

Typical shapes

Rename a field

cfg.addMigration(1, 2, "Rename server.Enabled -> server.enabled",
    [](Json& j) {
        if (j["server"].contains("Enabled")) {
            j["server"]["enabled"] = j["server"]["Enabled"];
            j["server"].erase("Enabled");
        }
    });

Move a field between sections

cfg.addMigration(2, 3, "Move readOnly: appCfg -> database",
    [](Json& j) {
        if (j["appCfg"].contains("readOnly")) {
            j["database"]["readOnly"] = j["appCfg"]["readOnly"];
            j["appCfg"].erase("readOnly");
        }
    });

Split or convert a value

cfg.addMigration(3, 4, "Split address into host+port",
    [](Json& j) {
        if (j["address"].is_string()) {
            const auto s = j["address"].get<std::string>();
            const auto colon = s.find(':');
            if (colon != std::string::npos) {
                j["host"] = s.substr(0, colon);
                j["port"] = std::stoi(s.substr(colon + 1));
                j.erase("address");
            }
        }
    });

Add a section with defaults

cfg.addMigration(4, 5, "Introduce metrics section",
    [](Json& j) {
        if (!j.contains("metrics")) {
            j["metrics"] = {
                { "enabled", false },
                { "intervalSec", 10 },
            };
        }
    });

Anti-patterns to avoid

  • Calling j.at("X") without contains — throws when the field is missing, breaks the whole load.
  • Assuming types — always check is_boolean(), is_number_integer(), is_string() before .get<>().
  • Reading from filesystem or environment inside the lambda.
  • Throwing for "the field I wanted is missing" — that's just a no-op.
  • Using C++ structs from your current project. What if someone renames a field? The migration written today must still do what it promised.

Advanced — schema validation

Order is cosmetic. Schema is what tells evolvejson which keys are expected to exist and what to do with the rest. Self-contained, opt-in. Skip this section if you don't need typo detection.

Declaring a schema

template <typename T> ConfigFile& setSectionSchema(std::string path);
ConfigFile& setSectionSchema(std::string path, std::vector<std::string> keys);
ConfigFile& setSectionSchema(std::vector<std::string> keys);  // root

Two ways to declare:

  • From a C++ struct (<T>) — serialises a default-constructed T via nlohmann's to_json (provided by NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT) and uses the resulting top-level keys as the allowed set. Single source of truth: when the struct grows a field, the schema follows.
  • Explicit list — pass the allowed keys directly. Useful when the section's shape is not modelled by a single C++ DTO.

Wildcards in path work the same as in setSectionOrder (see next section). Schema rules run after order rules during load(), so a Drop removal affects the final layout. Objects without a registered schema rule are never subject to any policy — opt in path by path.

UnknownKeyPolicy

Set on LoadOptions::unknownKeys (default: Warn):

Policy Behaviour
Keep Unknown keys are preserved untouched. No diagnostic.
Warn (default) Unknown keys are preserved; onUnknownKey(path, key) fires if a callback is registered. Otherwise a silent no-op. Fires on every load() that sees the key — "tell me every time you see a typo."
Drop Unknown keys are removed and the document is flagged for rewrite. onUnknownKey(path, key) fires after removal — "tell me when you drop something" (audit-log model).

onUnknownKey — the diagnostic side of schema rules

ConfigFile& onUnknownKey(std::function<void(std::string_view path,
                                            std::string_view key)> cb);

Invoked once per key found in the loaded document that is NOT in a setSectionSchema rule's known-keys set, under Warn or Drop. path is the JSON Pointer-style path of the parent object (empty for root). Throws are swallowed — the hook is for diagnostics, not control flow.

LoadOptions opts;
opts.unknownKeys = evolvejson::UnknownKeyPolicy::Drop;
ConfigFile cfg{ path, 1, opts };

cfg.setSectionSchema({ "Version", "appCfg", "server" });   // root
cfg.setSectionSchema<ServerSection>("server");

cfg.onUnknownKey([](std::string_view p, std::string_view k) {
    log_warn("Config: dropped unknown key '{}' at '{}'",
             k, p.empty() ? "<root>" : p);
});

A typo like server.maxThredas (not in ServerSection) is removed on load and reported. A legitimate unknown at a path with no schema rule survives untouched.

onUnknownKey is meaningless without a schema rule and lives only here. It is not a general-purpose diagnostic alongside onAfterLoad.


Advanced — key ordering

The shape of your config.json on disk matters for code review and diff readability. setSectionOrder declares a canonical layout; evolvejson reorders the document so it matches. Purely cosmetic — for validation see the previous section.

Declaring an order

ConfigFile& setSectionOrder(std::vector<std::string> keys);                     // root
ConfigFile& setSectionOrder(std::string path, std::vector<std::string> keys);
template <typename T> ConfigFile& setSectionOrder(std::string path);
cfg.setSectionOrder({                        // top-level
    "Version", "appCfg", "logging", "database", "server",
});

cfg.setSectionOrder("server", {              // nested
    "host", "port", "tls", "maxConnections", "idleTimeoutSec",
});

cfg.setSectionOrder<ServerSection>("server"); // from a DTO

Listed keys are moved to the front in the given order. Other keys are kept in their original relative order, appended after the listed ones. If the document already matches, wasRewritten stays false. Rules with no matching path are silently skipped.

Wildcards: *

A single * segment expands to "every element of an array" (or "every value of an object") at that point. The rule is then applied to each child.

cfg.setSectionOrder("items/*", { "id", "name", "value" });
cfg.setSectionOrder("groups/*/members/*", { "id", "role" });
cfg.setSectionOrder<ConnectorSection>("DataConnections/*");

When a wildcard targets a missing path or a non-array/non-object node, the rule is silently skipped — same as plain paths.

Free-function reorderKeys

For ad-hoc reordering outside of ConfigFile (test code, one-off scripts):

#include <evolvejson/reorder.hpp>

evolvejson::reorderKeys(node, std::vector<std::string_view>{ "a", "b" });

Same semantics: listed keys come first in declared order, anything else trails. Returns true iff the layout changed. Inside a ConfigFile, prefer the wildcard form of setSectionOrder.


Advanced — pre-save hook (onBeforeSave)

ConfigFile& onBeforeSave(std::function<void(Json&)> cb);

Called inside every save(), just before the document is serialised — whether the save was triggered by load()'s auto-rewrite or by a direct save() call. The callback receives a private copy of the document, mutates it, and that copy is what lands on disk. The caller's in-memory value (and the document cached by document()) is never touched.

This is a control-flow feature, not a diagnostic: a throwing onBeforeSave turns the save() into an error and the file is not overwritten. The existing file and any .bak taken at the start of save() remain as-is. Callers get the thrown message in the std::expected<void, std::string> from save().

Encrypt-on-save / decrypt-on-load recipe

Canonical pattern for secret fields stored encrypted at rest, used as plaintext at runtime:

// 1. On startup, after load(), decrypt fields in-memory.
//    evolvejson has no onAfterLoad mutation hook on purpose: decryption
//    is usually interleaved with typed parsing.
account.password = (pw.length() >= 32)
    ? decrypt(pw, user, iv)
    : pw;  // still plaintext; will be encrypted on next save

// 2. On save, encrypt fields back. The hook runs against a private copy.
cfg.onBeforeSave([&iv](evolvejson::Json& j) {
    for (auto& account : j["accounts"]) {
        auto pw = account.value("password", std::string{});
        if (!pw.empty() && pw.length() < 32) {  // <32 ⇒ still plaintext
            account["password"] = encrypt(
                pw, account["user"].get<std::string>(), iv);
        }
    }
});

The length < 32 gate keeps the hook idempotent: the same document may be saved multiple times across the lifetime of a process. If you need a rewrite for some other reason (e.g. a missing Version was inserted), the hook rides along for free. If the only thing that changed is "plaintext password arrived at startup", call cfg.save(*cfg.document()) explicitly — there is no auto-trigger for "the hook would have done something."


Diagnostics — onAfterLoad

ConfigFile& onAfterLoad(std::function<void(const Json&)> cb);

Called after migrations + rewrite, before load() returns. Use for startup logging — single hook, diagnostic only. The callback sees the same document that will be stored in LoadResult.json. Throws are swallowed (diagnostic, not control flow).

cfg.onAfterLoad([&](const evolvejson::Json& j) {
    log_info("Config loaded:\n{}", evolvejson::dump(j));
});

For masking, the ConfigFile::dumpDocument() / dumpSection() helpers in the next section are usually a better fit than calling dump() directly.


Logging — masking and dump helpers

Three layers, highest-level first.

ConfigFile::dumpSection / dumpDocument — covers 90% of cases

std::string ConfigFile::dumpDocument() const;
std::string ConfigFile::dumpDocument(const FormatOptions& fmt) const;
std::string ConfigFile::dumpSection(std::string_view path) const;
std::string ConfigFile::dumpSection(std::string_view path,
                                    const FormatOptions& fmt) const;

Whole document or subtree, masked with LoadOptions::secretKeys, formatted with LoadOptions::format. Returns "" if load() has not succeeded or the path is missing.

log_info("database: {}",      cfg.dumpSection("database"));
log_info("Full config: {}",   cfg.dumpDocument());
log_debug("Compact dump: {}", cfg.dumpDocument({ .indent = -1 }));

Free dumpMasked — for Json values not from a ConfigFile

std::string dumpMasked(const Json& j,
                       std::span<const std::string_view> secretKeys,
                       const FormatOptions& fmt = {});
std::string dumpMasked(const Json& j,
                       std::initializer_list<std::string_view> secretKeys,
                       const FormatOptions& fmt = {});

Use when the value did not come from a ConfigFile — manual construction, a subtree of an external document, etc. Pass an explicit key list.

log_info("connection: {}",
         evolvejson::dumpMasked(externalJson, { "password", "token" }));

Free dump — escape hatch, no masking

std::string dump(const Json& j, const FormatOptions& fmt = {});

Reserve for content known to be safe to log verbatim.

log_debug("Raw: {}", evolvejson::dump(*cfg.document()));

Matching rules

  • Case-insensitive key match (Password, PASSWORD, password all hit).
  • Recurses into nested objects and arrays.
  • Preserves null as null (does not replace nulls with "***").
  • Returns std::string (UTF-8, from nlohmann::json::dump).

Mask by JSON Pointer or regex: do a copy + walk yourself. The built-in masking is deliberately limited to key-name matching to keep the API trivial.


Operational — backups

The defaults are what you want; tune only if you have a reason.

config.json                  ← current
config.v1.bak.json           ← snapshot taken before 1→2 migration
config.v2.bak.json           ← snapshot taken before 2→3 migration
config.v2.bak.1.json         ← second 2→3 migration (unusual, but possible)
  • Naming: <stem>.v<N>.bak<ext>, with .bak.<i><ext> suffix on collision.
  • N is the schema version of the file about to be overwritten — i.e., the pre-migration version. Easy to find "the file as it was in v1" in disaster recovery.
  • Retention: configured via LoadOptions::keepBackups. 0 (default) keeps forever; N>0 keeps the N newest, deletes older.
  • Skip-if-identical in save() (see Core API §save) means steady-state configs do not accumulate .bak files even with autoRewriteOnChange = true and backupOnSave = true.

For custom snapshot logic outside ConfigFile:

#include <evolvejson/backup.hpp>

evolvejson::Backup b{ "/var/backups/myapp", /*keepLast=*/10 };
auto path = b.snapshot("/etc/app/config.json", /*version=*/3);
// path == "/var/backups/myapp/config.v3.bak.json" (or "" on no-op/error)

Operational — formatting

Rarely needed. The default is indent = 4, space-indented, UTF-8 preserved — what most editors and code reviewers expect.

struct FormatOptions {
    int  indent{ 4 };          // -1 = compact single-line output
    char indentChar{ ' ' };    // ' ' or '\t'
    bool ensureAscii{ false }; // if true, escape non-ASCII as \uXXXX
};

Set it via LoadOptions::format for save behaviour, or pass it directly to dumpDocument / dumpSection / free dump / free dumpMasked for a one-off override. Maps 1:1 to nlohmann's dump(indent, indentChar, ensureAscii).

Common knobs: indent = -1 for compact wire formats, indentChar = '\t' with indent = 1 for tab-indented, ensureAscii = true for ancient editors or ASCII-only transports.


LoadOptions reference

One struct — evolvejson::LoadOptions — split visually below by concern.

Secrets

Field Default Meaning
secretKeys { password, token, apikey, api_key, secret } Case-insensitive key names masked by ConfigFile::dumpDocument / dumpSection. Free dumpMasked is unaffected — it takes its own list.

Behavioural

Field Default Meaning
versionKey "Version" Top-level integer key holding the schema version.
assumedLegacyVersion 0 Inserted when versionKey is missing on disk. After insertion, normal migrations run from this version.
autoRewriteOnChange true If false, load() does not auto-save raw-document changes. Caller flushes via explicit save().
unknownKeys Warn Policy for keys outside a setSectionSchema rule. Keep / Warn / Drop. Order rules never trigger this — they are purely cosmetic.

Operational

Field Default Meaning
backupDir {} (next to file) Directory for .bak snapshots.
keepBackups 0 (keep all) Retention count. N>0 keeps N newest, deletes older.
backupBeforeMigration true Snapshot before migrating.
backupOnSave true Snapshot the existing file on every save(). Skip-if-identical applies first.
format { indent=4, ' ', ensureAscii=false } Pretty-print on write; also default for dumpDocument / dumpSection.

Why one class, not two

ConfigFile deliberately bundles loading, migration, schema policing, hooks, and dump helpers behind a single facade. Splitting into a Loader

  • Strict pair was considered and rejected: the cost (every consumer wires two objects, every example gets longer) outweighs the benefit (slightly cleaner mental map in docs we control anyway). The advanced features are opt-in — a Quick Start consumer never touches them and pays nothing for their existence. The code paths are flat, the binary stays small, and the surface that matters fits on one screen.

Patterns & recipes

Registering migrations in a separate file

Keep ConfigMigrations.h in your project, export one registration function:

// ConfigMigrations.h
inline void RegisterConfigMigrations(evolvejson::ConfigFile& cfg) {
    cfg.addMigration(1, 2, "...", [](auto& j) { /* ... */ });
    cfg.addMigration(2, 3, "...", [](auto& j) { /* ... */ });
}

This is the one place that grows as the schema evolves.

Blending with a legacy parser

If you have an existing Config::Load with lots of per-field if contains logic, drop evolvejson in as a pre-step and leave the legacy parser alone:

evolvejson::ConfigFile cfg{ path, kConfigCurrentVersion };
RegisterConfigMigrations(cfg);

evolvejson::LoadResult res;
if (!cfg.load(res, [](auto e){ log_error(e); })) return false;

m_json = std::move(res.json);
// ... continue with legacy ParseXxxConfig(m_json) ...

Pairing order and schema for a DTO

When the section is fully described by one DTO and you want both the on-disk layout and the typo-detection wired up, register both rules explicitly:

cfg.setSectionOrder<ServerSection> ("server");
cfg.setSectionSchema<ServerSection>("server");

Two lines; the two concerns are independent on purpose. Use only one when order and schema diverge — for example, when a section has additional valid keys handled outside the DTO (auxiliary fields, optional sub-objects), in which case order from <T> is fine but schema needs an explicit list.


Error handling

Every fallible operation returns std::expected<T, std::string>. The bool form of load() delivers errors via an optional callback. No exceptions propagate out of load() or save().

Situation Error message (example)
File not found Config file not found: /etc/app/config.json
Invalid JSON JSON parse error in /etc/app/config.json: [json.exception.parse_error.101] parse error at line 3, column 5: ...
No migration from current version No migration path from version 3 towards 5
Duplicate migration fromVersion Duplicate migration fromVersion=1: 'Rename Foo' and 'Move Bar' both claim to apply starting from that version
Non-progressing migration Invalid migration 'Self-loop': toVersion (2) must be strictly greater than fromVersion (2)
Malformed Version field Invalid 'Version' field in /etc/app/config.json: expected unsigned integer, got string
Migration threw Migration 2->3 ('Rename Foo') failed: std::out_of_range
Atomic replace failed Atomic replace failed: /etc/app/config.json (Permission denied)
Write failed Cannot write config file: /etc/app/config.json
onBeforeSave threw onBeforeSave threw: <message>

Thread safety

  • ConfigFile is not thread-safe. Use it from a single thread (typically your startup sequence).
  • After load(), the document returned in LoadResult.json is a snapshot — safe to share read-only.
  • section<T>() and document() are safe to call concurrently on a const ConfigFile, but you must not call load() concurrently with either.

Integration

Headers

One umbrella include — recommended:

#include <evolvejson/evolvejson.hpp>

If you really want to cherry-pick:

Header What it gives you
<evolvejson/config.hpp> ConfigFile, LoadOptions, LoadResult, UnknownKeyPolicy
<evolvejson/masking.hpp> dumpMasked, plain dump
<evolvejson/reorder.hpp> free-function reorderKeys
<evolvejson/backup.hpp> standalone Backup class
<evolvejson/format.hpp> FormatOptions struct
<evolvejson/migration.hpp> Migration struct

No linking required — header-only.

vcpkg

Add nlohmann-json to your vcpkg.json:

{ "dependencies": ["nlohmann-json"] }

CMake

find_package(nlohmann_json REQUIRED)
target_include_directories(yourtarget PRIVATE path/to/evolvejson/include)
target_link_libraries(yourtarget PRIVATE nlohmann_json::nlohmann_json)

vcpkg overlay-port (recommended for downstream projects)

evolvejson ships its own port inside the repository under ports/evolvejson/. To consume it from another project that uses vcpkg manifest mode:

  1. Clone evolvejson somewhere reachable from the downstream project.

  2. Add a vcpkg-configuration.json next to the downstream vcpkg.json:

    {
      "overlay-ports": [ "../evolvejson/ports" ]
    }
  3. Add the dep to the downstream vcpkg.json:

    { "dependencies": ["evolvejson"] }
  4. In the downstream CMakeLists.txt:

    find_package(evolvejson CONFIG REQUIRED)
    target_link_libraries(yourtarget PRIVATE evolvejson::evolvejson)

nlohmann-json is brought in transitively through the exported INTERFACE target; downstream projects do not need to find it manually.

A custom-registry path (registries entry pointing at the GitHub repo) is not supported in 0.1.0. It requires versions/ and a baseline, which are not shipped yet.


FAQ

Q: What happens if my migration throws? The whole load() returns an error. The file on disk is unchanged.

Q: Can a migration skip versions (e.g. 1 → 5 in one go)? Yes — register a single Migration{1, 5, ..., fn}. But you'll often want separate 1→2, 2→3, ... entries for testability and to share partial paths with future files still on older versions.

Q: What if my config file has no Version field at all? Set LoadOptions::assumedLegacyVersion = N. On load, Version = N is inserted and the file is rewritten. Migrations run from N onwards.

Q: Can I use a different key name for the version? Yes — LoadOptions::versionKey = "schemaVersion", etc. Must be a top-level integer field.

Q: How do I test migrations? Write a small input JSON fixture, load it through evolvejson::ConfigFile with your migrations registered, assert against the expected output JSON. See tests/TestEvolvejson.cpp for working examples.

Q: Is ordering of migrations important? No — addMigration order doesn't matter. The library picks migrations by matching fromVersion to the current document version, iteratively, until it reaches targetVersion.

Q: What if two migrations have the same fromVersion? load() refuses to run and returns an error naming both offenders. The check happens before the file is opened, so no data is touched and no backup is taken.


Roadmap

Short-term:

  • Optional field-level crypto helper built on top of onBeforeSave — a one-liner that hides the walk-and-encrypt boilerplate for the typical "encrypt these keys" case.

Medium-term (quality-of-life):

  • JSON Schema validation hook (via nlohmann/json_schema) as an optional add-on header. Keep core dependency-free.
  • Migration introspection — cfg.describeMigrationPath(fromVersion) returning the chain of descriptions, for dry-run reports.
  • Fuzzy-key masking (regex or JSON Pointer) as an opt-in option on LoadOptions::secretKeys.
  • cfg.watch() — optional filesystem watcher that re-runs load() when the file changes on disk. Platform-specific implementation; deferred until someone needs it.

Long-term:

  • CLI tool: evolvejson migrate --from v1 --to v2 config.json — useful for devops and config pipelines that don't want to link the library.
  • Cross-platform CI on MSVC / GCC / Clang. The current code is already portable — just needs CI coverage to prove it.

Non-goals (rejected so far):

  • YAML / TOML support. Different domain. A sibling library (evolvetoml, etc.) makes more sense than one tool for all.
  • Schema diffing / automatic migration generation. Magic that leads to surprises. Migrations should be written by hand and reviewed.
  • Runtime schema registry / network-loaded migrations. Out of scope — this is a config library, not a data-store.

Status

Standalone repository: https://github.com/VitaliBabkin/evolvejson. Test suite at tests/TestEvolvejson.cpp83 tests, all green. License: MIT. Namespace: evolvejson::. Dependencies: only nlohmann-json. Target OS: portable (Windows-first for now).

Used in production by multiple internal projects.

About

Header-only library for versioned JSON config files: schema migrations, atomic writes, versioned backups, key masking. Single dependency: nlohmann-json

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages