Skip to main content

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}