Versioned JSON config for C++ — load, migrate, log, save. Without the boilerplate.
Header-only. Single dependency: nlohmann-json. Targets C++23.
- What you get
- Quick start
- What happens on
load() - Concepts
- Core API
- Writing migrations
- Advanced — schema validation
- Advanced — key ordering
- Advanced — pre-save hook (
onBeforeSave) - Diagnostics —
onAfterLoad - Logging — masking and dump helpers
- Operational — backups
- Operational — formatting
LoadOptionsreference- Why one class, not two
- Patterns & recipes
- Error handling
- Thread safety
- Integration
- FAQ
- Roadmap
- Status
- Versioned schema with hand-written migrations.
- Typed section access via nlohmann's
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT. - Data-safe writes: versioned
.baksnapshots, atomic rename, skip-if-identical.
#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 ...
}- Reads the file from disk.
- Inserts a
Versionfield if the file has none (controlled byassumedLegacyVersion). - Runs registered migrations forward to
targetVersion. - If anything changed and
autoRewriteOnChange = true, takes a.vN.baksnapshot of the old file and atomically rewrites the current one. - Returns a
LoadResult; typed access goes throughcfg.section<T>("path").
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.
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.
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.
The file is saved back to disk when:
- a migration ran, or
Versionwas missing and had to be inserted, or- a
setSectionOrderrule changed the layout, or - a
setSectionSchema(... Drop)rule removed an unknown key.
Controlled by LoadOptions::autoRewriteOnChange (defaults to true).
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, ...
The five members below are the entire core surface. Anything not listed here is opt-in advanced behaviour, covered in later sections.
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().
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.
[[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;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.
[[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.
- Pure. No I/O, no globals, no time-dependent behaviour.
- 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.
- Self-documenting. The
descriptionfield goes into the log on every migration run. - 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.
- Never throw on "missing field." If the source field isn't there, the migration is a no-op or installs a safe default.
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");
}
});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");
}
});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");
}
}
});cfg.addMigration(4, 5, "Introduce metrics section",
[](Json& j) {
if (!j.contains("metrics")) {
j["metrics"] = {
{ "enabled", false },
{ "intervalSec", 10 },
};
}
});- Calling
j.at("X")withoutcontains— 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.
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.
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); // rootTwo ways to declare:
- From a C++ struct (
<T>) — serialises a default-constructedTvia nlohmann'sto_json(provided byNLOHMANN_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.
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). |
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.
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.
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 DTOListed 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.
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.
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.
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().
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."
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.
Three layers, highest-level first.
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 }));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" }));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()));- Case-insensitive key match (
Password,PASSWORD,passwordall hit). - Recurses into nested objects and arrays.
- Preserves
nullasnull(does not replace nulls with"***"). - Returns
std::string(UTF-8, fromnlohmann::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.
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. Nis 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>0keeps theNnewest, deletes older. - Skip-if-identical in
save()(see Core API §save) means steady-state configs do not accumulate.bakfiles even withautoRewriteOnChange = trueandbackupOnSave = 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)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.
One struct — evolvejson::LoadOptions — split visually below by concern.
| 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. |
| 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. |
| 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. |
ConfigFile deliberately bundles loading, migration, schema policing,
hooks, and dump helpers behind a single facade. Splitting into a Loader
Strictpair 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.
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.
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) ...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.
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> |
ConfigFileis not thread-safe. Use it from a single thread (typically your startup sequence).- After
load(), the document returned inLoadResult.jsonis a snapshot — safe to share read-only. section<T>()anddocument()are safe to call concurrently on aconst ConfigFile, but you must not callload()concurrently with either.
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.
Add nlohmann-json to your vcpkg.json:
{ "dependencies": ["nlohmann-json"] }find_package(nlohmann_json REQUIRED)
target_include_directories(yourtarget PRIVATE path/to/evolvejson/include)
target_link_libraries(yourtarget PRIVATE nlohmann_json::nlohmann_json)evolvejson ships its own port inside the repository under
ports/evolvejson/. To consume it from another project that uses vcpkg
manifest mode:
-
Clone
evolvejsonsomewhere reachable from the downstream project. -
Add a
vcpkg-configuration.jsonnext to the downstreamvcpkg.json: -
Add the dep to the downstream
vcpkg.json:{ "dependencies": ["evolvejson"] } -
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 (
registriesentry pointing at the GitHub repo) is not supported in0.1.0. It requiresversions/and a baseline, which are not shipped yet.
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.
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-runsload()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.
Standalone repository: https://github.com/VitaliBabkin/evolvejson.
Test suite at tests/TestEvolvejson.cpp — 83 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.
{ "overlay-ports": [ "../evolvejson/ports" ] }