Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Evolving persisted data

thisversion logo

thisversion

Keep schema history out of your application model.

crates.io MSRV 1.88+ no_std supported unsafe forbidden

API reference · Examples · Changelog

Introduction

This guide explains how to use thisversion, with a particular focus on advanced workflows and the decisions involved in evolving persisted data.

For the what, the available derives, traits, types and functions, see the API reference.

The chapters below focus on decisions that arise as your data evolves. They can be read independently:

Adding the next schema version

Imagine an application that saves a worker’s settings between runs. Its first release stores the worker’s name and always uses a 30-second timeout. Later, users need to choose their own timeout. The application must still understand the settings it saved before that option existed.

This is a reason to add a schema version. V1 continues to describe records that contain only a name. V2 stores the chosen timeout as well. A migration gives V1 records the behavior they had when they were written.

Extend the history

Keep V1’s serialized representation and meaning intact so its stored records remain readable. Append V2 to the enum deriving VersionFamily. The order of the variants tells the library which migration runs next.

The application model now needs a timeout too. Move VersionBoundary and its model attribute from V1 to V2, while V1 keeps its Serde derives. Finally, implement the conversion from V1 to V2. Together, the types look like this:

#![allow(unused)]
fn main() {
extern crate serde;
extern crate thisversion;
use serde::{Deserialize, Serialize};
use thisversion::{VersionBoundary, VersionFamily};

#[derive(Deserialize, Serialize, VersionFamily)]
#[serde(tag = "version", content = "data")]
enum SettingsVersions {
    V1(SettingsV1),
    V2(SettingsV2),
}

#[derive(Deserialize, Serialize)]
struct SettingsV1 {
    name: String,
}

#[derive(Deserialize, Serialize, VersionBoundary)]
#[thisversion(model = Settings)]
struct SettingsV2 {
    name: String,
    timeout_secs: u32,
}

struct Settings {
    name: String,
    timeout_secs: u32,
}

impl From<SettingsV1> for SettingsV2 {
    fn from(previous: SettingsV1) -> Self {
        Self {
            name: previous.name,
            timeout_secs: 30,
        }
    }
}
}

The 30 belongs in the migration because it describes historical behavior. For example, a stored V1 record containing {"name":"worker"} becomes a Settings model with timeout_secs = 30. A V2 record has its own timeout_secs value; if that field is missing, the V2 record is invalid.

When you call into_model() on a V1 family value, the library first runs the V1-to-V2 migration, then constructs Settings. Future versions extend this chain with another adjacent migration.

Saving a model through SettingsVersions with into_schema() now produces V2. If older applications must read newly written data, update those readers to recognize V2 before enabling V2 writes.

A new version can also be necessary when field types stay the same but their meaning changes. Each migration should preserve the interpretation of its source version. If the new representation cannot accept every old value, see Handling failures and model differences.

Test the persisted contract

Keep serialized fixtures from supported releases. Verify that decoding and migration produce the intended current values. Constructing an old Rust struct tests the migration, but not whether bytes written by an old release still deserialize correctly.

For this change, verify that:

  • V1 settings load with timeout_secs = 30.
  • V2 settings with a custom timeout are preserved.
  • Newly written settings use the V2 tag.

Keep existing serialized tags stable as well. Renaming a Rust variant can change its serialized identifier unless the codec mapping preserves it explicitly.

Adopting existing persisted data

An existing application may already have persisted data without an explicit schema version. Over time, its reader may have accumulated aliases, defaults, and custom decoding rules to keep that data working.

Preserve that behavior as a legacy schema. Here we will call the struct V0. V0 becomes the compatibility boundary for existing data: its job is to interpret what was already in storage so it can enter the versioned history.

Preserve what the old reader knows

V0 does not need to reconstruct a history that was never recorded. Treat it as a compatibility reader for the legacy formats you can actually identify.

If two old records have the same representation but different meanings, no schema definition can determine which interpretation was intended. The application must provide some other information to distinguish them.

Select the legacy reader explicitly

A family wrapper cannot discover a version tag that was never written. Before decoding, identify legacy data using information you already have, such as its source, file location, database metadata, or an unambiguous format marker.

Once V0 has been selected, it can enter the ordinary migration path:

#![allow(unused)]
fn main() {
extern crate serde_json;
extern crate serde;
extern crate thisversion;
use thisversion::{VersionBoundary, VersionFamily};

struct Settings {
    name: String,
}

#[derive(VersionBoundary)]
#[thisversion(model = Settings)]
#[derive(serde::Deserialize)]
struct V0 {
    name: String,
}

#[derive(VersionFamily)]
enum Versions {
    V0(V0),
}
use thisversion::traits::IntoModel;

fn read_legacy(input: &str) -> Result<Settings, serde_json::Error> {
    let legacy: V0 = serde_json::from_str(input)?;
    Ok(Versions::V0(legacy).into_model())
}
}

If legacy and versioned data share an entry point, determine the format first and then decode it with the appropriate reader.

Establish the new write format

Reading legacy data and writing new data are separate decisions. Once the application has a current model, retain the version identifier, one way to do this is to serialize it through the family so newly written data carries an explicit schema identity:

#![allow(unused)]
fn main() {
extern crate serde_json;
extern crate serde;
extern crate thisversion;
use thisversion::{VersionBoundary, VersionFamily};

struct Settings {
    name: String,
}

#[derive(VersionBoundary)]
#[thisversion(model = Settings)]
#[derive(serde::Serialize)]
struct V1 {
    name: String,
}

#[derive(serde::Serialize, VersionFamily)]
enum Versions {
    V1(V1),
}
use thisversion::traits::IntoSchema;

fn write_current(settings: Settings) -> Result<String, serde_json::Error> {
    let schema: Versions = settings.into_schema();
    serde_json::to_string(&schema)
}
}

From this point on, new records identify their schema explicitly and can follow the normal versioned migration path.

If you serialize a bare schema instead, store its version separately—for example, in a database column. The important part is that a future reader can determine which schema should decode the record.

Decide when to rewrite legacy data

Loading a legacy record does not require immediately rewriting it. You can migrate records gradually as they are loaded and saved, or rewrite existing storage in a separate migration operation.

Whichever strategy you choose, keep the legacy reader for as long as supported inputs may still arrive in that format. That may include old records that have not yet been touched, as well as imports, restored backups, or data produced by older deployments.

Before new writers begin emitting the versioned format, make sure any readers that must continue working can understand it.

Choosing how nested schemas evolve

Imagine a document editor that originally stored a title and body as plain text. Later, content becomes a separate part of the application: it contains paragraphs and can be marked as reviewed.

Document metadata and content can now evolve independently. There are two ways to represent that relationship in a persisted schema:

  • Store the child family
    Use this when the child should carry its own version and evolve independently of the parent.

  • Pin a child schema
    Use this when the parent’s stored format intentionally contains one particular representation of the child.

The difference determines whether the parent automatically follows new child versions or remains tied to a specific child format.

Give the child its own history

Start by giving content an independent version history. Content V1 stores paragraphs; V2 adds review state.

A version family names those stored forms, while VersionBoundary connects the newest schema to the application’s Content model. Migrating old content makes it unreviewed:

#![allow(unused)]
fn main() {
extern crate thisversion;
use thisversion::{VersionBoundary, VersionFamily};
use thisversion::traits::{IntoModel, IntoSchema};

#[derive(VersionFamily)]
enum ContentVersions {
    V1(ContentV1),
    V2(ContentV2),
}

struct ContentV1 {
    paragraphs: Vec<String>,
}

#[derive(VersionBoundary)]
#[thisversion(model = Content)]
struct ContentV2 {
    paragraphs: Vec<String>,
    reviewed: bool,
}

struct Content {
    paragraphs: Vec<String>,
    reviewed: bool,
}

impl From<ContentV1> for ContentV2 {
    fn from(old: ContentV1) -> Self {
        Self {
            paragraphs: old.paragraphs,
            reviewed: false,
        }
    }
}
}

Content now has a history that can advance independently of any document that contains it.

Store the child family in the parent

The original document schema has a plain body. Its next version replaces that field with ContentVersions:

#![allow(unused)]
fn main() {
extern crate thisversion;
#[derive(VersionFamily)]
enum ContentVersions {
    V1(ContentV1),
    V2(ContentV2),
}

struct ContentV1 {
    paragraphs: Vec<String>,
}

#[derive(VersionBoundary)]
#[thisversion(model = Content)]
struct ContentV2 {
    paragraphs: Vec<String>,
    reviewed: bool,
}

struct Content {
    paragraphs: Vec<String>,
    reviewed: bool,
}

impl From<ContentV1> for ContentV2 {
    fn from(old: ContentV1) -> Self {
        Self {
            paragraphs: old.paragraphs,
            reviewed: false,
        }
    }
}
use thisversion::{VersionBoundary, VersionFamily};
use thisversion::traits::{IntoModel, IntoSchema};

#[derive(VersionFamily)]
enum DocumentVersions {
    V0(DocumentV0),
    V1(DocumentV1),
}

struct DocumentV0 {
    title: String,
    body: String,
}

#[derive(VersionBoundary)]
#[thisversion(model = Document)]
struct DocumentV1 {
    title: String,
    #[thisversion(nested)]
    content: ContentVersions,
}

struct Document {
    title: String,
    content: Content,
}

impl From<DocumentV0> for DocumentV1 {
    fn from(old: DocumentV0) -> Self {
        Self {
            title: old.title,
            content: ContentVersions::V1(ContentV1 {
                paragraphs: vec![old.body],
            }),
        }
    }
}
}

The migration explicitly creates ContentVersions::V1. Naming the variant is important: the old document body corresponds to Content V1, and that historical fact should not change when Content V2 or V3 is added later.

The nested marker tells VersionBoundary to convert the field between ContentVersions and the application’s Content model. Converting the outer family therefore migrates both histories:

#![allow(unused)]
fn main() {
extern crate thisversion;
#[derive(VersionFamily)]
enum ContentVersions {
    V1(ContentV1),
    V2(ContentV2),
}

struct ContentV1 {
    paragraphs: Vec<String>,
}

#[derive(VersionBoundary)]
#[thisversion(model = Content)]
struct ContentV2 {
    paragraphs: Vec<String>,
    reviewed: bool,
}

impl From<ContentV1> for ContentV2 {
    fn from(old: ContentV1) -> Self {
        Self {
            paragraphs: old.paragraphs,
            reviewed: false,
        }
    }
}

struct Content {
    paragraphs: Vec<String>,
    reviewed: bool,
}

#[derive(VersionFamily)]
enum DocumentVersions {
    V0(DocumentV0),
    V1(DocumentV1),
}

struct DocumentV0 {
    title: String,
    body: String,
}

#[derive(VersionBoundary)]
#[thisversion(model = Document)]
struct DocumentV1 {
    title: String,
    #[thisversion(nested)]
    content: ContentVersions,
}

struct Document {
    title: String,
    content: Content,
}

impl From<DocumentV0> for DocumentV1 {
    fn from(old: DocumentV0) -> Self {
        Self {
            title: old.title,
            content: ContentVersions::V1(ContentV1 {
                paragraphs: vec![old.body],
            }),
        }
    }
}
use thisversion::traits::{IntoModel, IntoSchema};
use thisversion::{VersionBoundary, VersionFamily};

let old = DocumentVersions::V0(DocumentV0 {
    title: "Draft".into(),
    body: "First paragraph".into(),
});

let document = old.into_model();

assert_eq!(document.content.paragraphs, ["First paragraph"]);
assert!(!document.content.reviewed);

let stored: DocumentVersions = document.into_schema();

assert!(matches!(
    stored,
    DocumentVersions::V1(DocumentV1 {
        content: ContentVersions::V2(_),
        ..
    })
));
}

Document V1 may also contain Content V1 directly. Loading it still migrates the child to the current Content model, even though the document itself is already at its latest version.

Saving takes the opposite path and writes the latest variant of each family. If content later gains V3, Document V1 can remain the latest document schema; new writes will simply contain Content V3.

That independence also affects compatibility. A reader must understand the new child version before it can read documents that contain it, even if the parent version itself has not changed.

Pin the parent to a child schema

Sometimes the parent should not follow the child’s latest stored representation. Its persisted format may deliberately contain the fields of a particular child schema without storing a child version tag.

In that design, store the child schema directly and name its family in the nested attribute:

#![allow(unused)]
fn main() {
extern crate thisversion;
#[derive(VersionFamily)]
enum ContentVersions {
    V1(ContentV1),
    V2(ContentV2),
}

struct ContentV1 {
    paragraphs: Vec<String>,
}

#[derive(VersionBoundary)]
#[thisversion(model = Content)]
struct ContentV2 {
    paragraphs: Vec<String>,
    reviewed: bool,
}

struct Content {
    paragraphs: Vec<String>,
    reviewed: bool,
}

impl From<ContentV1> for ContentV2 {
    fn from(old: ContentV1) -> Self {
        Self {
            paragraphs: old.paragraphs,
            reviewed: false,
        }
    }
}
use thisversion::{VersionBoundary, VersionFamily};

#[derive(VersionBoundary)]
#[thisversion(model = PinnedDocument)]
struct PinnedDocumentSchema {
    title: String,
    #[thisversion(nested = ContentVersions)]
    content: ContentV1,
}

struct PinnedDocument {
    title: String,
    content: Content,
}

// NOTE: this is a down migration. V2 -> v1
impl From<ContentV2> for ContentV1 {
    fn from(
        ContentV2 {
            paragraphs,
            reviewed: _, // This is lossy!
        }: ContentV2,
    ) -> Self {
        Self { paragraphs }
    }
}
}

Here, nested = ContentVersions tells the derive that ContentV1 belongs to the family that knows how to migrate it into the current Content model.

Loading still follows the child history, so Content V1 becomes a current Content with reviewed = false.

Saving is different. The parent remains pinned to Content V1, so the current Content V2 representation must be converted back to V1. In this example, that conversion cannot preserve reviewed:

#![allow(unused)]
fn main() {
extern crate thisversion;
struct ContentV1 {
    paragraphs: Vec<String>,
}

#[derive(VersionBoundary)]
#[thisversion(model = Content)]
struct ContentV2 {
    paragraphs: Vec<String>,
    reviewed: bool,
}

struct Content {
    paragraphs: Vec<String>,
    reviewed: bool,
}

impl From<ContentV1> for ContentV2 {
    fn from(old: ContentV1) -> Self {
        Self {
            paragraphs: old.paragraphs,
            reviewed: false,
        }
    }
}

#[derive(VersionFamily)]
enum ContentVersions {
    V1(ContentV1),
    V2(ContentV2),
}

impl From<ContentV2> for ContentV1 {
    fn from(latest: ContentV2) -> Self {
        Self {
            paragraphs: latest.paragraphs,
        }
    }
}

#[derive(VersionBoundary)]
#[thisversion(model = PinnedDocument)]
struct PinnedDocumentSchema {
    title: String,
    #[thisversion(nested = ContentVersions)]
    content: ContentV1,
}

struct PinnedDocument {
    title: String,
    content: Content,
}
use thisversion::traits::{IntoModel, IntoSchema};
use thisversion::{VersionBoundary, VersionFamily};

let document = PinnedDocument {
    title: "Reviewed draft".into(),
    content: Content {
        paragraphs: vec!["First paragraph".into()],
        reviewed: true,
    },
};

let stored: PinnedDocumentSchema = document.into_schema();
let reloaded = stored.into_model();

assert!(!reloaded.content.reviewed);
}

The round trip deliberately loses review state because the pinned format has nowhere to store it.

If that information must survive, evolve the parent to store Content V2 or the child family instead. IntoSchema is infallible. Here, From<ContentV2> for ContentV1 lets the model save through the pinned parent, deliberately losing review state. Without that reverse conversion, the derive still supports loading, but saving to PinnedDocumentSchema does not compile. If an export to the pinned format must reject reviewed content, provide a separate fallible conversion that returns Result<PinnedDocumentSchema, Error>.

Choose between the two designs

The choice comes down to what the parent’s persisted contract should mean:

  • Store ContentVersions
    When content owns its version history and the parent should accept new child versions without introducing a new parent version.

  • Store ContentV1 with nested = ContentVersions
    When the parent intentionally promises that particular child representation, even as the child family continues to evolve elsewhere.

For either design, test old child versions nested inside the latest parent. For a pinned child, also test the full model → schema → model round trip so any information lost by the older representation is explicit.

Fallible conversions

Use TryFrom when a migration can reject old values. Keep both derives: mark the family fallible, give the latest schema an error type, and load with try_into_model().

Here, a retry count narrows from u32 to u8. The standard conversion supplies TryFromIntError when the old value exceeds 255:

extern crate serde;
extern crate serde_json;
extern crate thisversion;
use std::num::TryFromIntError;
use serde::{Deserialize, Serialize};
use thisversion::{VersionBoundary, VersionFamily};
use thisversion::traits::{IntoSchema, TryIntoModel};

#[derive(Deserialize, Serialize, VersionFamily)]
#[serde(tag = "version", content = "data")]
#[thisversion(fallible)]
enum SettingsVersions {
    V1(SettingsV1),
    V2(SettingsV2),
}

#[derive(Deserialize, Serialize)]
struct SettingsV1 {
    retries: u32,
}

#[derive(Deserialize, Serialize, VersionBoundary)]
#[thisversion(model = Settings, error = TryFromIntError)]
struct SettingsV2 {
    retries: u8,
}

struct Settings {
    retries: u8,
}

impl TryFrom<SettingsV1> for SettingsV2 {
    type Error = TryFromIntError;

    fn try_from(old: SettingsV1) -> Result<Self, Self::Error> {
        Ok(Self { retries: old.retries.try_into()? })
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let input = r#"{"version":"V1","data":{"retries":3}}"#;
    let stored: SettingsVersions = serde_json::from_str(input)?;
    let settings = stored.try_into_model()?;
    let stored: SettingsVersions = settings.into_schema();
    assert!(matches!(stored, SettingsVersions::V2(_)));
    Ok(())
}

Nested errors

The latest schema chooses the family’s final error type. Errors from migration steps and nested conversions must convert into it through From.

A parent can reuse the child’s error. Add these types to the example above:

#![allow(unused)]
fn main() {
extern crate serde;
extern crate serde_json;
extern crate thisversion;
use serde::Deserialize;
struct Settings {
    retries: u8,
}

#[derive(Deserialize)]
struct SettingsV1 {
    retries: u32,
}

#[derive(VersionBoundary, Deserialize)]
#[thisversion(model = Settings, error = TryFromIntError)]
struct SettingsV2 {
    retries: u8,
}

impl TryFrom<SettingsV1> for SettingsV2 {
    type Error = TryFromIntError;

    fn try_from(old: SettingsV1) -> Result<Self, Self::Error> {
        Ok(Self { retries: old.retries.try_into()? })
    }
}

#[derive(Deserialize, VersionFamily)]
#[serde(tag = "version", content = "data")]
#[thisversion(fallible)]
enum SettingsVersions {
    V1(SettingsV1),
    V2(SettingsV2),
}
use std::num::TryFromIntError;
use thisversion::{VersionBoundary, VersionFamily};
use thisversion::traits::{IntoSchema, TryIntoModel};

struct Job {
    settings: Settings,
}

#[derive(VersionBoundary)]
#[thisversion(model = Job, error = TryFromIntError)]
struct JobV1 {
    #[thisversion(nested)]
    settings: SettingsVersions,
}
}

JobV1::try_into_model() propagates a failed settings migration. If the parent has several error sources, choose a parent error enum and implement From for each source. Mark a containing version family fallible too. Infallible From migrations can remain in a fallible family; their Infallible error must also convert into the final error type.

Validation and error handling

Latest inputs skip historical migrations. Here, V2’s u8 already prevents out-of-range retry counts. Other invariants may need checks during decoding or custom model construction. error = E enables error propagation; it does not add validation to ordinary fields. Check invalid historical and latest inputs, including nested values.

The Serde DeserializeIntoModel adapter combines decoding and migration, turning conversion errors into Serde errors using Display. To retain domain errors for matching, decode first, as above. To return decoding and migration errors through one application error, add:

#![allow(unused)]
fn main() {
extern crate serde;
extern crate serde_json;
extern crate thisversion;
use serde::Deserialize;
struct Settings {
    retries: u8,
}

#[derive(Deserialize)]
struct SettingsV1 {
    retries: u32,
}

#[derive(VersionBoundary, Deserialize)]
#[thisversion(model = Settings, error = TryFromIntError)]
struct SettingsV2 {
    retries: u8,
}

impl TryFrom<SettingsV1> for SettingsV2 {
    type Error = TryFromIntError;

    fn try_from(old: SettingsV1) -> Result<Self, Self::Error> {
        Ok(Self { retries: old.retries.try_into()? })
    }
}

#[derive(VersionFamily, Deserialize)]
#[serde(tag = "version", content = "data")]
#[thisversion(fallible)]
enum SettingsVersions {
    V1(SettingsV1),
    V2(SettingsV2),
}
use std::num::TryFromIntError;

use thisversion::{VersionBoundary, VersionFamily};
use thisversion::traits::{IntoSchema, TryIntoModel};

#[derive(Debug)]
enum LoadError {
    Decode(serde_json::Error),
    Migration(TryFromIntError),
}

fn load(input: &str) -> Result<Settings, LoadError> {
    let stored: SettingsVersions =
        serde_json::from_str(input).map_err(LoadError::Decode)?;
    stored.try_into_model().map_err(LoadError::Migration)
}
}

Manual conversions

Use VersionBoundary when matching schema and model fields can move directly or convert through nested. For custom mapping or model validation, implement IntoModel or TryIntoModel on the latest schema yourself. VersionFamily still connects the history. Ordinary From and TryFrom migrations remain part of the derive workflow described earlier.

Custom model mapping

Here the schema’s display_name becomes the model’s name, which must be nonempty when loaded. The manual conversion replaces VersionBoundary:

#![allow(unused)]
fn main() {
extern crate thisversion;
use thisversion::VersionFamily;
use thisversion::traits::{IntoModel, IntoSchema, TryIntoModel};

#[derive(VersionFamily)]
#[thisversion(fallible)]
enum ContactVersions {
    V1(ContactV1),
}

struct ContactV1 {
    display_name: String,
}

struct Contact {
    name: String,
}

#[derive(Debug)]
struct EmptyName;

impl TryIntoModel for ContactV1 {
    type Model = Contact;
    type Error = EmptyName;

    fn try_into_model(self) -> Result<Contact, EmptyName> {
        if self.display_name.trim().is_empty() {
            return Err(EmptyName);
        }
        Ok(Contact { name: self.display_name })
    }
}

impl IntoSchema<ContactVersions> for Contact {
    fn into_schema(self) -> ContactVersions {
        ContactVersions::V1(ContactV1 { display_name: self.name })
    }
}
}

The validation runs for every version that reaches this latest schema, including V1 itself. Saving is implemented separately because replacing the derive also removes its generated output conversion. For an infallible mapping, implement IntoModel instead and use an infallible family. A manual schema needs its own TryIntoModel implementation if used directly with the Serde adapter, which requires that trait.

Several sources, one model

Another family can feed the same Contact. Add these definitions:

#![allow(unused)]
fn main() {
extern crate thisversion;
struct Contact {
    name: String,
}
use thisversion::traits::IntoModel;
use thisversion::VersionFamily;

#[derive(VersionFamily)]
enum DirectoryVersions {
    V1(DirectoryV1),
}

struct DirectoryV1 {
    first_name: String,
    last_name: String,
}

impl IntoModel for DirectoryV1 {
    type Model = Contact;

    fn into_model(self) -> Contact {
        Contact { name: format!("{} {}", self.first_name, self.last_name) }
    }
}
}

Assuming directory names are already valid, each family now follows its own history into Contact. The directory mapping discards the original name parts, so it deliberately has no reverse conversion. If export must preserve them, retain them in the model or elsewhere in the application. Supporting an input format does not require supporting it as an output format.