khora_data/scene/migrations.rs
1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8
9//! Scene format migrations.
10//!
11//! `SceneHeader.format_version` is bumped whenever the on-disk layout
12//! changes. Migrations are registered through `inventory::submit!` and
13//! applied in chain at load time before the strategy decodes the payload.
14//!
15//! Today no migrations are registered (every supported scene already
16//! reads as `format_version = 1`). The framework is kept thin so a
17//! future schema change can ship the migration alongside the format
18//! bump without touching `SerializationService`.
19//!
20//! Migration ordering: the runner sorts entries by `from_version` and
21//! applies each whose `from_version` matches the current payload, then
22//! bumps the working version to `to_version` and repeats. Two migrations
23//! covering the same `from_version` is a configuration error — the
24//! runner picks the first registered match and logs a warning.
25
26use std::fmt;
27
28/// One step of a scene-format migration. Receives the raw payload bytes
29/// emitted by the previous version's strategy and returns the bytes the
30/// next version's strategy expects.
31pub trait SceneMigration: Send + Sync {
32 /// Source format version (matches `SceneHeader.format_version`).
33 #[allow(clippy::wrong_self_convention)]
34 fn from_version(&self) -> u32;
35 /// Target format version produced by this migration.
36 fn to_version(&self) -> u32;
37 /// Apply the migration. Implementations should be pure (no side
38 /// effects) and self-contained — they run before the world is
39 /// touched.
40 fn migrate(&self, payload: &[u8]) -> Result<Vec<u8>, MigrationError>;
41}
42
43/// Migration step failure. Wraps strategy-specific errors as text so the
44/// runner can keep its surface narrow.
45#[derive(Debug)]
46pub enum MigrationError {
47 /// The payload could not be decoded with the source-version reader.
48 DecodeFailed(String),
49 /// The payload could not be re-encoded with the target-version writer.
50 EncodeFailed(String),
51 /// A migration matched but is missing for one of the steps in the chain.
52 StepMissing {
53 /// Source version that was reached.
54 from: u32,
55 /// Target version that has no registered migration.
56 to: u32,
57 },
58}
59
60impl fmt::Display for MigrationError {
61 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62 match self {
63 Self::DecodeFailed(msg) => write!(f, "migration decode failed: {}", msg),
64 Self::EncodeFailed(msg) => write!(f, "migration encode failed: {}", msg),
65 Self::StepMissing { from, to } => {
66 write!(f, "no migration registered from v{} to v{}", from, to)
67 }
68 }
69 }
70}
71
72/// Inventory entry for plugin-registered migrations.
73pub struct SceneMigrationRegistration {
74 /// The migration step (typically a zero-sized type with the trait
75 /// impl). Stored as a static reference so `inventory::collect!`
76 /// works without needing a `Box`.
77 pub migration: &'static dyn SceneMigration,
78}
79
80inventory::collect!(SceneMigrationRegistration);
81
82/// Apply registered migrations to bring `payload` from `from_version`
83/// up to `to_version`. Returns the (possibly unchanged) payload bytes.
84pub fn migrate_payload(
85 mut payload: Vec<u8>,
86 from_version: u32,
87 to_version: u32,
88) -> Result<Vec<u8>, MigrationError> {
89 if from_version == to_version {
90 return Ok(payload);
91 }
92 let mut current = from_version;
93 while current < to_version {
94 let next_step = inventory::iter::<SceneMigrationRegistration>
95 .into_iter()
96 .map(|r| r.migration)
97 .find(|m| m.from_version() == current);
98 let Some(step) = next_step else {
99 return Err(MigrationError::StepMissing {
100 from: current,
101 to: current + 1,
102 });
103 };
104 payload = step.migrate(&payload)?;
105 current = step.to_version();
106 }
107 Ok(payload)
108}