Guides
Shipping an update
An app update changes the core's types while the device still holds data the old version wrote. Undra reads it by name, converts what it can, and refuses the rest out loud.
What outlives a build#
Three things an app writes can be read by the next version of the app: a snapshot of its stores (crash recovery on the web, undra dev's reload, an app that saves one), the cached results of persist queries, and the offline queue of idempotent mutations. Each records the fingerprint of its types: a hash of exactly the records, enums and fields it holds. A change elsewhere in the core (a new method, a new query, a doc comment) does not move it, so the data is used as it is, with no work at all.
When a fingerprint did change, Undra reads the old bytes with the description that was stored next to them and converts them to the new types by name. It never guesses: data that cannot be converted is refused with a typed outcome you can show, never decoded into the wrong values.
What migrates for free#
These changes need no code. They are the ones most updates are made of:
| Change | What the old data becomes |
|---|---|
| Fields or signals reordered | matched by name; order is irrelevant |
A field added as an Option | None |
A field added with #[undra(default)] | its zero value: false, 0, "", empty, the nil UUID, the epoch |
A store signal added with #[undra(default)] | T::default() |
| A field, variant or signal removed | dropped |
| Enum variants added or renumbered | matched by name |
An integer widened (i32 to i64, u8 to u32, u16 to i32), f32 to f64 | the same value |
T to Option<T>, Bytes to Vec<u8> or back | the same value |
Any of these inside a Vec, an Option or a map | applied to every item |
Anything else is not structural: a narrowing (i64 to i32), a type change (i32 to String), a renamed field, variant or signal, a variant the new type no longer has, or a new field with no default. Those need a hook.
Writing a hook#
A hook is a plain function marked #[undra::migrate]. It receives the old value as a DynValue (fields and variants by name) and returns the new one, or a MigrateError to refuse it. There are three kinds:
use undra::prelude::*;
// Any persisted `Todo` whose structure changed, wherever it is.
#[undra::migrate(ty = "Todo")]
fn todo_from_name(old: &DynValue) -> Result<Todo, MigrateError> {
let title = old.field("name").and_then(DynValue::as_str)
.ok_or_else(|| MigrateError::new("a todo without a name"))?;
Ok(Todo { title: title.to_owned(), done: false })
}
// One signal of one store in a snapshot (`None`: the snapshot has no such signal).
#[undra::migrate(store = "Profile", signal = "age")]
fn age(old: Option<&DynValue>) -> Result<f32, MigrateError> {
match old.and_then(DynValue::as_i64) {
Some(age) if age >= 0 => Ok(age as f32),
Some(age) => Err(MigrateError::new(format!("{age} is not an age"))),
None => Ok(18.0),
}
}
// The queued input of one mutation, by parameter name.
#[undra::migrate(mutation = "add_todo")]
fn add_todo(old: &DynRecord) -> Result<DynRecord, MigrateError> {
let mut new = old.clone();
new.rename("name", "title");
Ok(new)
}Structural migration is tried first, then the hook. Hooks run in the core, under the panic guard: a hook that panics is a failed migration, logged with its name. from = "0x…" restricts a hook to one old fingerprint, when you need to tell two older versions apart. A target that does not exist, or a function of the wrong shape, is E0066.
When nothing converts#
Each kind of data has its own refusal, and none of them loses data silently:
| Data | Outcome |
|---|---|
| A snapshot | the restore fails as a whole with RestoreError::Incompatible { store, signal, reason } (restore code 7 on the platforms: UndraRestoreError / UndraRestoreException), the core is unchanged, and the reason is logged. One exception: a store type the new build removed is left out (its handles go stale) and reported. |
| A cached query result | deleted, because it can be fetched again; a WARN names the query and the reason |
| A queued mutation | never deleted: it moves to the dead letters, and the rest of the queue replays |
The dead letters are yours to show, export or settle:
let query = ctx.query();
for letter in query.dead_letters() {
log::warn!("{} could not be sent: {}", letter.mutation, letter.reason);
// letter.params: the input by parameter name; letter.raw: the bytes as stored
}
query.retry_dead_letter(key)?; // after an update that added a hook
query.discard_dead_letter(key); // for goodctx.query().persist_stats() counts what was migrated, dropped and dead-lettered, and the runtime's statistics carry the same numbers under query.persist.
Testing an update#
Build the old and the new core, write the data with one and read it with the other. The playground does exactly this in its contract scenarios: UNDRA_PLAYGROUND_V2=1 undra build builds a second core whose stores, records and mutations changed the way an update does, and every platform checks that a snapshot and a queue written by the first are read by name by the second, and that an incompatible change is refused or dead-lettered. In Rust, a TestRuntime restores a snapshot you build from an edited Schema (its stores_closure is the description an older build would have written).