polyvers
Single-macro schema versioning for Rust.
Declare a struct family once, with #[add] / #[edit] / #[delete] mutations across versions. The macro emits one fully-typed module per version so serde deserializes each version in a single pass — no serde(flatten), which is known to slow deserialization by 50%+ (serde-rs/serde#2186, #2363).
Example
versioned!
Generated API
For family the_config, the macro emits:
| Item | Description |
|---|---|
the_config::v0_1::Main etc. |
One module per version, with all family structs. |
the_config::Latest |
Type alias for the last version's first struct. |
the_config::AnyVersion |
Enum with one variant per version wrapping that version's main. |
the_config::VERSIONS |
&[&str] of declared version strings. |
the_config::LATEST_VERSION |
The last version string. |
the_config::parse_at_version(v, json) |
Runtime version dispatch into AnyVersion from a JSON string. |
AnyVersion exposes .version(), .into_v0_X(), .as_v0_X() per version.
Binary codecs (rkyv, bincode, postcard)
JSON is the default runtime format. For binary formats, add a codec clause and enable the matching cargo feature on polyvers. The macro then emits a parse_at_version_<codec>(version, bytes) free function and a to_<codec>_bytes() method on AnyVersion. The JSON dispatcher coexists.
versioned!
// Serialize/parse via each enabled codec:
let any = V0_1;
let b = any.to_bincode_bytes?;
let p = any.to_postcard_bytes?;
let r = any.to_rkyv_bytes?;
let same = parse_at_version_rkyv?;
Cargo features:
| Feature | Pulls in the user's … dependency |
Generated fns |
|---|---|---|
rkyv-08 |
rkyv = "0.8" |
parse_at_version_rkyv / to_rkyv_bytes |
rkyv-07 |
rkyv = "0.7" (legacy API) |
parse_at_version_rkyv / to_rkyv_bytes |
bincode-2 |
bincode = "2" (serde feature) |
parse_at_version_bincode / to_bincode_bytes |
postcard-1 |
postcard = "1" (alloc feature) |
parse_at_version_postcard / to_postcard_bytes |
Enable rkyv-08 xor rkyv-07 — not both. Using codec rkyv; without either feature produces a clear compile-time error pointing at the missing feature.
Because the JSON dispatcher is always emitted, structs in a family that declares any codec must also derive serde::Serialize + serde::Deserialize. The future plan is to make the JSON dispatcher opt-out via codec json; for binary-only families.
Per-version metadata
Each version can carry its own metadata (release date, author, breaking-change notes, anything you like) without putting those fields on the data struct. Declare a meta TypeName; at the top, then provide a meta { … } initializer in every version block. The macro emits pub fn meta() -> TypeName per version module, plus meta_at_version(&str) and AnyVersion::meta() at the family level.
versioned!
let info = meta; // from the version module
let info = meta_at_version // by version string at runtime
.expect;
let any = parse_at_version?;
let info = any.meta; // from a parsed AnyVersion
The metadata type is yours — any struct, any fields, any derives, including types like chrono::DateTime<Utc> that aren't const-constructible (the generated meta() is a regular function, so non-const expressions work).
If you declare meta TypeName; at the top, every version must provide a meta { … } block; if you don't, no version may. Both are caught at compile time.
Mutation grammar
Inside a non-base version block, every field line must use one of:
#[add] name: Type— introduce a new field. Errors if the field already exists in the parent.#[edit] name: NewType— change an existing field's type. Errors if the field doesn't exist.#[delete] name— remove a field (bare identifier, no type). Errors if the field doesn't exist.
Field-level attributes that aren't #[add]/#[edit]/#[delete] (e.g., #[serde(rename = "...")]) are forwarded onto generated fields and inherited across versions.
Version-id mangling
Version literals become module/method names by replacing . with _ and prefixing v:
| Literal | Module | Variant |
|---|---|---|
"0.1" |
v0_1 |
V0_1 |
"0.1.0" |
v0_1_0 |
V0_1_0 |
"1.0-rc1" |
v1_0__rc1 |
V1_0__rc1 |
Two literals that mangle to the same identifier (e.g., "0.1" and "0_1") are rejected at compile time.
Limitations (v0.1.1)
- JSON is always emitted; binary codecs are additive via the
codecclause + cargo feature. TOML/YAML may come later. - No automatic migrations between versions. (
From<v0_1::Main> for v0_2::Mainis on the v0.2.0 roadmap with#[add(default = ...)]and#[edit(from = path)]hints.) - No generic type parameters on family structs.
- No visibility modifiers on fields — all generated fields are
pub. - Family struct types must be referenced by bare name inside the macro. Path-qualified references (
v0_1::JustAStruct) are not rewritten across versions. - Do not declare
typealiases inside the macro body.
Examples
Runnable examples live in examples/. Run any of them with cargo run --example <name>:
basic— the canonical 2-version family with both compile-time and runtime parsing.three_versions— three versions showing every mutation kind, including a sub-struct that evolves alongside.manual_migration— implementingFrom<v0_1::Main> for v0_2::Mainby hand (auto-derived migrations land in v0.2.0).runtime_dispatch— reading the version out of a JSON file and dispatching acrossAnyVersion.version_meta— attaching per-version metadata (release date, author, notes) via themetaclause.codec_rkyv— round-tripping versioned data through rkyv. Run withcargo run --features rkyv-08 --example codec_rkyv.
License
Licensed under the MIT License.