Ручные миграции
Кодогенерируемые миграции покрывают обычный случай: переименовать поля, поменять типы, заполнить значения по умолчанию. Когда этого мало — чтения из другой структуры, дозаполнение данных, чистка ключей внутри ReactiveMap, — шаги миграции можно написать руками.
Точка входа
Заголовок раздела «Точка входа»Ручные шаги регистрируют методом билдера .migrations():
let (store, report) = StoreBuilder::new(app) .migrations(|m| { m.for_prefix("net") .step(1, "move off the privileged port", |ctx| { ctx.set("port", &8080u16) }); }) .migrate()?;migrate выполняет эти шаги и все, что объявлены через #[migrate], и отдаёт отчёт о том, что сделал. build не выполняет ни одного, поэтому после .migrations() остаётся билдер, у которого build нет вовсе: шаги, которые передали и тут же молча выбросили, — ошибка, о которой никто бы не сказал. Всё остальное про store задают до этого вызова.
Шаги одной линии идут по порядку версий. Порядок префиксов заранее никто не считает: шаг, который читает чужой префикс, сначала приводит в порядок его, — см. Чтение из чужого префикса.
Объявление шага
Заголовок раздела «Объявление шага»m.for_node::<Profile>() .step(2, "split full name", |ctx| { let full_name = ctx.get::<String>("full_name")?.unwrap_or_default(); let mut parts = full_name.splitn(2, ' '); ctx.set("first_name", &parts.next().unwrap_or_default().to_string())?; ctx.set("last_name", &parts.next().unwrap_or_default().to_string())?; ctx.delete("full_name")?; Ok(()) });for_node::<T>() находит структуру по её префиксу и id. Вручную for_prefix("net") называет безымянную линию префикса, а for_named("ui", "panels") — линию, объявленную с этим id.
К одной версии ведёт один шаг. Если написать руками шаг к версии, к которой уже ведёт шаг #[migrate], ни один шаг этого префикса не выполнится: MigrationError::StepTwice назовёт оба. А шаг #[migrate] оставляет структуру там, где она стоит: шаг между двумя префиксами или двумя id не скомпилируется, потому что прочитал бы новое место, ничего там не нашёл и записал бы значения по умолчанию. Перенос сохранённого — это отдельный шаг, через global_get и global_set. .step(version, description, closure) регистрирует преобразование, которое переводит её из version - 1 в version.
API контекста
Заголовок раздела «API контекста»Внутри замыкания шага ctx даёт низкоуровневый доступ к сохранённым ключам линии. Все аргументы-ключи отсчитываются от её префикса, если не сказано иначе.
Базовые операции
Заголовок раздела «Базовые операции»| Метод | Описание |
|---|---|
ctx.get::<T>(key) |
Читает ключ. Возвращает Result<Option<T>>. |
ctx.set(key, value) |
Пишет ключ. |
ctx.delete(key) |
Удаляет ключ. |
ctx.rename(from, to) |
Копирует ключ под новое имя и удаляет старое. Ничего не делает, если исходного ключа нет. |
Составные операции
Заголовок раздела «Составные операции»Они объединяют чтение, преобразование и запись в один вызов:
| Метод | Описание |
|---|---|
ctx.transform::<TOld, TNew>(key, f) |
Читает ключ, применяет f, пишет результат обратно под тем же ключом. Ничего не делает, если ключа нет. |
ctx.merge::<T1, T2, TNew>((k1, k2), into, f) |
Читает два ключа, соединяет их через f в третий ключ, удаляет исходные. Ничего не делает, если нет хотя бы одного из источников. |
ctx.split::<TOld, T1, T2>(from, (k1, k2), f) |
Читает один ключ, разделяет его на два через f, удаляет исходный. Ничего не делает, если источника нет. |
Примеры:
// multiply a value in placectx.transform::<u16, u16>("sample_rate", |r| Ok(r.saturating_mul(10)))?;
// combine first_name + last_name into display_name, remove originalsctx.merge::<String, String, String>( ("first_name", "last_name"), "display_name", |f, l| Ok(format!("{f} {l}")),)?;
// split "host:port" into two separate keysctx.split::<String, String, u16>( "address", ("host", "port"), |s| { let (h, p) = s.split_once(':').unwrap(); Ok((h.to_string(), p.parse()?)) },)?;Коллекции
Заголовок раздела «Коллекции»| Метод | Описание |
|---|---|
ctx.scan_map::<K, V>(key) |
Обходит все записи под prefix.key.* и возвращает их как IndexMap<K, V> — в том порядке, в каком по ним идёт сама карта. |
Полезно, когда поле ReactiveMap мигрируют, не проходя через AmeData: записи читают и пишут туда, где им теперь место.
let old_routes = ctx.scan_map::<String, String>("routes")?;ctx.delete_prefix("routes")?;
for (name, url) in &old_routes { ctx.set(&format!("endpoints.{name}"), url)?;}Старый уровень снимают одним вызовом, а не циклом: карта владеет всем, что под ней, и delete_prefix забирает это целиком.
Вернуться должна каждая запись. Шаг читает карту, меняет её и пишет обратно, так что запись, пропущенная при чтении, стала бы записью удалённой. Запись, которая не читается как K или V, — ошибка, и транзакция шага откатывается.
Глобальный доступ
Заголовок раздела «Глобальный доступ»| Метод | Описание |
|---|---|
ctx.global_get::<T>(path) |
Читает любой ключ из store по его полному пути. |
ctx.global_set(path, value) |
Пишет любой ключ в store по его полному пути. |
global_get и global_set полностью обходят префикс линии. Полезно, когда шагу нужно прочитать из структуры, которая уже мигрировала:
let plan = ctx.global_get::<String>("identity.plan")?.unwrap();Ограничение области
Заголовок раздела «Ограничение области»ctx.scoped(sub_prefix) возвращает новый MigrationContext с корнем в {current_prefix}.{sub_prefix}. Используется внутри ctx.nested() и напрямую нужен редко.
Значения от приложения
Заголовок раздела «Значения от приложения»Шаг из #[migrate] — голая fn и ничего не захватывает, а замыкание, которое регистрируют здесь, обязано жить 'static. Поэтому то, что шагу нужно от приложения, — таблицу соответствий, настройки, от которых он уходит, — передают билдеру, по одному значению на тип, а шаг просит его по типу:
struct LegacyDefaults { port: u16,}
let (store, report) = StoreBuilder::new(app) .provide(LegacyDefaults { port: 8080 }) .migrations(|m| { m.for_prefix("net") .step(1, "fill in the port the old build assumed", |ctx| { let port = ctx.require::<LegacyDefaults>()?.port; ctx.set("port", &port) }); }) .migrate()?;ctx.provided::<T>() отвечает None, если значения такого типа не дали. ctx.require::<T>() превращает это в отказ RunStep::NothingProvided, который называет запрошенный тип и то, что дали вместо него.
Чтение из чужого префикса
Заголовок раздела «Чтение из чужого префикса»Шагу, который читает другой префикс через ctx.global_get, нужно, чтобы тот уже мигрировал. Объявлять для этого ничего не надо, и порядок никто заранее не считает: чтение само приводит чужой префикс в порядок — тут же, в той же транзакции.
m.for_node::<Profile>() .step(2, "snapshot plan from identity", |ctx| { let plan = ctx .global_get::<String>("complex_identity.plan")? .expect("reading it is what migrates it"); ctx.set("plan_snapshot", &plan)?; Ok(()) });Поэтому чтение видит уже мигрировавшее значение, а порядок задаётся самими обращениями, а не списком, который кто-то должен держать в согласии с кодом. Цепочка — Workspace читает Profile, Profile читает Identity — мигрирует все три именно в таком порядке, каким бы ни был порядок регистрации.
Префикс, который тянется обратно в тот, что ещё не доделан, — это цикл: ни один не может пойти первым, и называют его целиком, от начала до конца, а не по тому единственному звену, которое его замкнуло.
Как достать контекст из #[migrate]
Заголовок раздела «Как достать контекст из #[migrate]»Тот же контекст доступен и сгенерированному шагу: функция #[migrate] может принять MigrationContext вторым аргументом, и всё, что на этой странице, к нему относится. Зачем он нужен и что шаг вычищает сам, без просьбы, — на странице Объявление шагов.
Падение и откат
Заголовок раздела «Падение и откат»migrate идёт по store проходами. Проход начинается с одного префикса, у которого есть шаги к выполнению, и держит его и все префиксы, до которых дотянулись его шаги. Проход — это одна транзакция, а в отчёте он зовётся компонентом. Упавший шаг откатывает свой проход и больше ничего: проходы до него и после фиксируются как обычно.
Открыть store отказываются, и OpenStore::Migrating несёт весь отчёт: данные под этим префиксом не то, что теперь объявляет код, и store, открытый поверх них, отдало бы новому коду старые данные.
let refused = StoreBuilder::new(app) .migrations(|m| { m.for_prefix("net").step(1, "turns the data down", |_| { Err(MigrationError::Custom("this data is not ours".into()).into()) }); }) .migrate();
let Err(OpenStore::Migrating { report: Some(report), ..}) = refusedelse { panic!("a failed step let the store open");};Из отчёта видно, какие префиксы упали и почему; они остаются на своей версии, а все остальные проходы смигрированы. Приложение, которому лучше работать на данных как есть, открывает их через build, который шагов не выполняет, — или ставит or_in_memory() перед migrate и работает на пустом store в памяти.
for component in &report.components { if let ComponentOutcome::Failed { error, .. } = &component.outcome { eprintln!("{:?} was left as it was: {error:?}", component.prefixes); }}
let store = StoreBuilder::new(app).build()?;Упавший компонент называет префиксы, которые держал его проход, а ошибка упавшего шага несёт префикс, версию, к которой шаг вёл данные, и файл store.
Что остаётся на диске после остановки
Заголовок раздела «Что остаётся на диске после остановки»Упавший шаг не оставляет на диске ничего: его проход откатывается раньше, чем что-то записано. Больше сказать нужно только про текстовые движки: данные и метаданные у них лежат в двух файлах, и заменяются эти файлы по одному. Поэтому сначала пишутся метаданные с пометкой о том, что запись начата, потом данные, потом сами метаданные. Процесс, остановившийся посередине, оставляет пометку, и следующее открытие её прочтёт. Если данные — те, что писались, метаданные дописываются. Если данные — те, что остановленное открытие нашло, пометка отбрасывается, и миграция проходит заново. Иначе открытие отказывает: нельзя сказать, какие данные описывают метаданные.