Перейти к содержимому

Ручные миграции

Кодогенерируемые миграции покрывают обычный случай: переименовать поля, поменять типы, заполнить значения по умолчанию. Когда этого мало — чтения из другой структуры, дозаполнение данных, чистка ключей внутри 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.

Внутри замыкания шага 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 place
ctx.transform::<u16, u16>("sample_rate", |r| Ok(r.saturating_mul(10)))?;
// combine first_name + last_name into display_name, remove originals
ctx.merge::<String, String, String>(
("first_name", "last_name"),
"display_name",
|f, l| Ok(format!("{f} {l}")),
)?;
// split "host:port" into two separate keys
ctx.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] может принять 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),
..
}) = refused
else {
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.

Упавший шаг не оставляет на диске ничего: его проход откатывается раньше, чем что-то записано. Больше сказать нужно только про текстовые движки: данные и метаданные у них лежат в двух файлах, и заменяются эти файлы по одному. Поэтому сначала пишутся метаданные с пометкой о том, что запись начата, потом данные, потом сами метаданные. Процесс, остановившийся посередине, оставляет пометку, и следующее открытие её прочтёт. Если данные — те, что писались, метаданные дописываются. Если данные — те, что остановленное открытие нашло, пометка отбрасывается, и миграция проходит заново. Иначе открытие отказывает: нельзя сказать, какие данные описывают метаданные.