Data model evolution
One of desert’s main goals is allowing stored or transmitted data to survive controlled changes to Rust data types.
Evolution support is generated by #[derive(BinaryCodec)] for structs and
enums. The derive macro writes a compact version header and enough metadata for
older and newer versions to skip, default, or reinterpret fields where that is
safe.
Compatibility rules in short
Compatible changes:
- Convert a multi-field tuple into a struct with the same field order.
- Wrap a value in a
#[desert(transparent)]single-field struct. - Replace one collection type with another collection type using the same item representation.
- Add a struct field with
FieldAdded. - Make a field optional with
FieldMadeOptional. - Remove a field with
FieldRemoved, with the limits described below. - Make a field transient with
FieldMadeTransientplus#[transient(default)]. - Add an enum variant at the end of the enum.
- Insert or remove
#[transient]enum variants.
Breaking or risky changes:
- Rename a field without preserving the serialized field name. The current Rust derive macro does not have a field rename attribute.
- Reorder enum variants without
#[desert(sorted_constructors)]. - Remove an enum variant that has already been serialized.
- Change a field’s type unless the old and new types intentionally share a binary representation.
- Use
DeduplicatedStringin evolvable fields unless all readers and writers agree on the exact stream shape.
Tuples and structs
Version-0 structs are compatible with tuples of the same arity and field order:
use desert_rust::{deserialize, serialize_to_byte_vec, BinaryCodec, Result};
#[derive(Debug, PartialEq, BinaryCodec)]
struct Point {
x: i32,
y: i32,
}
fn main() -> Result<()> {
let bytes = serialize_to_byte_vec(&(10, 20))?;
let point: Point = deserialize(&bytes)?;
assert_eq!(point, Point { x: 10, y: 20 });
Ok(())
}
This is useful for moving from positional values to named records.
Transparent wrappers
Transparent single-field structs are encoded exactly like the inner field:
use desert_rust::{deserialize, serialize_to_byte_vec, BinaryCodec, Result};
#[derive(Debug, PartialEq, BinaryCodec)]
#[desert(transparent)]
struct UserId(u64);
fn main() -> Result<()> {
let bytes = serialize_to_byte_vec(&42u64)?;
let id: UserId = deserialize(&bytes)?;
assert_eq!(id, UserId(42));
Ok(())
}
This lets a model evolve from primitive values to domain newtypes without changing stored bytes.
Collections
Generic collection codecs use a shared iterable format, so many collection changes are compatible:
use desert_rust::{deserialize, serialize_to_byte_vec, Result};
use std::collections::LinkedList;
fn main() -> Result<()> {
let bytes = serialize_to_byte_vec(&vec![1i32, 2, 3])?;
let values: LinkedList<i32> = deserialize(&bytes)?;
assert_eq!(values.into_iter().collect::<Vec<_>>(), vec![1, 2, 3]);
Ok(())
}
Set and map compatibility depends on the target type’s Eq, Hash, or Ord
requirements and on whether duplicate values make sense.
Adding a field
When a struct receives a new field, record it with FieldAdded and provide the
default expression used when reading old data:
use desert_rust::BinaryCodec;
#[derive(Debug, Clone, PartialEq, BinaryCodec)]
struct ProductV1 {
name: String,
price: i32,
}
#[derive(Debug, Clone, PartialEq, BinaryCodec)]
#[desert(evolution(FieldAdded("in_stock", true)))]
struct ProductV2 {
name: String,
in_stock: bool,
price: i32,
}
With this change:
ProductV2can readProductV1data and usestrueforin_stock.ProductV1can readProductV2data by skipping the added field.
The added field does not have to be the last field in the Rust struct. The evolution metadata records which generation introduced it.
Making a field optional
A field can be changed from T to Option<T>:
use desert_rust::BinaryCodec;
#[derive(Debug, Clone, PartialEq, BinaryCodec)]
#[desert(evolution(
FieldAdded("in_stock", true),
FieldMadeOptional("price")
))]
struct ProductV3 {
name: String,
in_stock: bool,
price: Option<i32>,
}
With this change:
- New code reads old data as
Some(old_value). - Old code can read new data if the option is
Some(value). - Old code cannot read new data if the option is
None, because it expected a non-optional field value.
This can be used as an intermediate migration before removing a field.
Removing a field
A removed field must be recorded:
use desert_rust::BinaryCodec;
#[derive(Debug, Clone, PartialEq, BinaryCodec)]
#[desert(evolution(
FieldAdded("in_stock", true),
FieldMadeOptional("price"),
FieldRemoved("price")
))]
struct ProductV4 {
name: String,
in_stock: bool,
}
With this change:
- New code can read old data by skipping
price. - Old code can read new data only if the removed field had already become
optional, in which case it is read as
None. - Old code that expects a non-optional removed field cannot read new data.
Transient fields
Adding a new transient field does not change the binary representation:
use desert_rust::BinaryCodec;
#[derive(Debug, Clone, PartialEq, BinaryCodec)]
struct WithCache {
value: String,
#[transient(None::<usize>)]
cached_len: Option<usize>,
}
If an existing serialized field becomes transient, record it with
FieldMadeTransient and keep a transient default expression:
use desert_rust::BinaryCodec;
#[derive(Debug, Clone, PartialEq, BinaryCodec)]
#[desert(evolution(
FieldAdded("in_stock", true),
FieldMadeOptional("price"),
FieldRemoved("price"),
FieldMadeTransient("name")
))]
struct ProductV5 {
#[transient("unknown".to_string())]
name: String,
in_stock: bool,
}
FieldMadeTransient behaves like FieldRemoved for the wire format. New code
can read old data and uses the transient default. Older code cannot read the new
data if it requires the missing field.
Evolving enums
Enums are encoded by constructor id. Constructor ids follow source order unless
#[desert(sorted_constructors)] is used.
Adding a new variant at the end is compatible for old values:
use desert_rust::BinaryCodec;
#[derive(Debug, Clone, PartialEq, BinaryCodec)]
enum EventV1 {
Started,
Message(String),
}
#[derive(Debug, Clone, PartialEq, BinaryCodec)]
enum EventV2 {
Started,
Message(String),
Stopped,
}
With this change:
EventV2can read oldEventV1values.EventV1can readEventV2data only when the stored constructor id also existed inEventV1.EventV1cannot readEventV2::Stopped.
Enum variants with fields can have their own evolution steps:
use desert_rust::BinaryCodec;
#[derive(Debug, Clone, PartialEq, BinaryCodec)]
enum Event {
#[desert(evolution(FieldAdded("source", "api".to_string())))]
Message { text: String, source: String },
}
Transient variants are not assigned constructor ids:
use desert_rust::BinaryCodec;
#[derive(Debug, Clone, PartialEq, BinaryCodec)]
enum RuntimeState {
Stored,
#[transient]
InMemoryOnly,
}
Serializing RuntimeState::InMemoryOnly fails. The benefit is that such variants
can be inserted or removed without shifting persistent constructor ids.
Evolution encoding
For derived structs and non-transparent enum variant payloads, desert writes:
- a version byte
- for non-zero versions, compact evolution metadata describing chunks and removed or optional fields
- the field data split into generation chunks
Version 0 data has no extra evolution metadata beyond the version byte. This
keeps initial records compact and is why version-0 structs and tuples can share
the same format.
PointV1
#[derive(Debug, Clone, PartialEq, desert_rust::BinaryCodec)]
#[desert(evolution())]
struct PointV1 {
x: i32,
y: i32,
}
let bytes = desert_rust::serialize_to_byte_vec(&PointV1 { x: 10, y: 20 })?;
[0x00, 0x00, 0x00, 0x00, 0x0A, 0x00, 0x00, 0x00, 0x14]
00 | 00 | 00 | 00 | 0A | 00 | 00 | 00 | 14 |
| versionversion 0 | xfirst field | ysecond field | ||||||
When a field is added, the new generation is written as a later chunk. Older
readers can skip chunks they do not know about. Newer readers can detect missing
chunks and use defaults from FieldAdded.
PointV2 adds label
#[derive(Debug, Clone, PartialEq, desert_rust::BinaryCodec)]
#[desert(evolution(FieldAdded("label", "origin".to_string())))]
struct PointV2 {
x: i32,
label: String,
y: i32,
}
let value = PointV2 { x: 10, label: "origin".to_string(), y: 20 };
let bytes = desert_rust::serialize_to_byte_vec(&value)?;
[0x01, 0x10, 0x0E, 0x00, 0x00, 0x00, 0x0A, 0x00, 0x00, 0x00, 0x14, 0x0C, 0x6F, 0x72, 0x69, 0x67, 0x69, 0x6E]
01 | 10 | 0E | 00 | 00 | 00 | 0A | 00 | 00 | 00 | 14 |
| versionversion 1 | v0 sizebyte length of original-field chunk | v1 sizebyte length of added-field chunk | xversion-0 field | yversion-0 field | ||||||
0C | 6F | 72 | 69 | 67 | 69 | 6E | ||||
| label lengthadded string length | label UTF-8added field data | |||||||||
If a later version makes the field optional, the metadata records the field
position. Older readers can still read Some(value) as the original field type;
None is only understood by readers that know the optional step.
PointV3 makes label optional
#[derive(Debug, Clone, PartialEq, desert_rust::BinaryCodec)]
#[desert(evolution(
FieldAdded("label", "origin".to_string()),
FieldMadeOptional("label")
))]
struct PointV3 {
x: i32,
label: Option<String>,
y: i32,
}
let value = PointV3 { x: 10, label: None, y: 20 };
let bytes = desert_rust::serialize_to_byte_vec(&value)?;
[0x02, 0x10, 0x02, 0x01, 0x01, 0x00, 0x00, 0x00, 0x0A, 0x00, 0x00, 0x00, 0x14, 0x00]
02 | 10 | 02 | 01 | 01 | 00 | 00 | 00 | 0A |
| versionversion 2 | v0 sizebyte length of original-field chunk | v1 sizebyte length of optional-field chunk | optionalFieldMadeOptional marker plus field position | xversion-0 field | ||||
00 | 00 | 00 | 14 | 00 | ||||
| yversion-0 field | labelOption::None marker | |||||||
When a field is removed, the metadata records the removed field name so readers
that still know the field can either treat it as None if it is optional, or
fail clearly if it is required.
PointV4 removes label
#[derive(Debug, Clone, PartialEq, desert_rust::BinaryCodec)]
#[desert(evolution(
FieldAdded("label", "origin".to_string()),
FieldMadeOptional("label"),
FieldRemoved("label")
))]
struct PointV4 {
x: i32,
y: i32,
}
let bytes = desert_rust::serialize_to_byte_vec(&PointV4 { x: 10, y: 20 })?;
[0x03, 0x10, 0x00, 0x03, 0x0A, 0x6C, 0x61, 0x62, 0x65, 0x6C, 0x03, 0x01, 0x00, 0x00, 0x00, 0x0A, 0x00, 0x00, 0x00, 0x14]
03 | 10 | 00 | 03 | 0A | 6C | 61 | 62 | 65 | 6C |
| versionversion 3 | v0 sizebyte length of original-field chunk | v1 sizeremoved field leaves no data in the added chunk | removedFieldRemoved marker for optional step | name lengthremoved field name length | name UTF-8removed field name | ||||
03 | 01 | 00 | 00 | 00 | 0A | 00 | 00 | 00 | 14 |
| removedFieldRemoved marker | name refdeduplicated reference to field name | xversion-0 field | yversion-0 field | ||||||
Keeping evolution safe
Use these practices for long-lived formats:
- Append evolution steps. Do not rewrite old evolution history.
- Keep field names stable.
- Prefer
Option<T>as a compatibility bridge before removing a field. - Keep enum constructor order stable, or opt into sorted constructors from the first released version.
- Add roundtrip and cross-version compatibility tests for every model that is persisted or sent across process boundaries.