hopper_core/migrate/mod.rs
1//! Migration helpers -- safe version upgrades for on-chain accounts.
2//!
3//! Hopper's migration system supports three patterns:
4//!
5//! 1. **Append-safe**: New fields appended to the end, realloc to larger size
6//! 2. **Segment-safe**: New segments added to the segment table
7//! 3. **Full migration**: Data reshuffled between versions
8//!
9//! ## Safety
10//!
11//! - Migration always validates the source layout_id before touching data
12//! - The destination version must be strictly greater than the source
13//! - Realloc is rent-safe (payer provides lamports for the delta)
14
15use crate::account::{read_layout_id, read_version, write_header, FixedLayout};
16use crate::check::{check_owner, check_writable};
17use hopper_runtime::{error::ProgramError, AccountView, Address, ProgramResult};
18
19/// Migrate an account in-place by appending new fields.
20///
21/// This is the cheapest migration: no data movement, just realloc + header update.
22///
23/// ## Preconditions
24///
25/// - Account must be owned by `program_id`
26/// - Account must be writable
27/// - Account layout_id must match `old_layout_id`
28/// - `new_size > old_size` (append-only growth)
29///
30/// ## What it does
31///
32/// 1. Validates ownership, writable, and old layout_id
33/// 2. Reallocs account data to `new_size`
34/// 3. Updates header: new version, new layout_id
35/// 4. Zeroes the newly appended region
36///
37/// New fields are left zero-initialized. The caller should fill them after.
38#[inline]
39#[allow(clippy::too_many_arguments)]
40pub fn migrate_append(
41 account: &AccountView,
42 payer: &AccountView,
43 program_id: &Address,
44 old_layout_id: &[u8; 8],
45 new_version: u8,
46 new_layout_id: &[u8; 8],
47 new_disc: u8,
48 new_size: usize,
49) -> ProgramResult {
50 check_owner(account, program_id)?;
51 check_writable(account)?;
52
53 let data = account.try_borrow()?;
54 let current_layout = read_layout_id(&data)?;
55 if ¤t_layout != old_layout_id {
56 return Err(ProgramError::InvalidAccountData);
57 }
58 let current_version = read_version(&data)?;
59 if new_version <= current_version {
60 return Err(ProgramError::InvalidAccountData);
61 }
62
63 let old_size = data.len();
64 if new_size <= old_size {
65 return Err(ProgramError::InvalidArgument);
66 }
67
68 // Realloc
69 crate::account::safe_realloc(account, new_size, payer)?;
70
71 // Write updated header
72 let mut data = account.try_borrow_mut()?;
73 write_header(&mut data, new_disc, new_version, new_layout_id)?;
74
75 // Zero the appended region
76 for byte in &mut data[old_size..new_size] {
77 *byte = 0;
78 }
79
80 Ok(())
81}
82
83/// Check if a migration from OldLayout to NewLayout would be append-compatible.
84///
85/// Append-compatible means:
86/// - New layout is strictly larger
87/// - The first `old_size` bytes can stay as-is
88/// - Only new fields were added at the end
89///
90/// This is a compile-time check helper -- use in tests and CI.
91pub const fn is_append_compatible<Old: FixedLayout, New: FixedLayout>() -> bool {
92 New::SIZE > Old::SIZE
93}
94
95/// Migration descriptor for schema export.
96#[derive(Clone, Copy)]
97pub struct MigrationDescriptor {
98 /// Source layout name.
99 pub from_name: &'static str,
100 /// Source version.
101 pub from_version: u8,
102 /// Source layout_id.
103 pub from_layout_id: [u8; 8],
104 /// Target layout name.
105 pub to_name: &'static str,
106 /// Target version.
107 pub to_version: u8,
108 /// Target layout_id.
109 pub to_layout_id: [u8; 8],
110 /// Migration kind.
111 pub kind: MigrationKind,
112}
113
114/// The kind of migration.
115#[derive(Clone, Copy, PartialEq, Eq)]
116pub enum MigrationKind {
117 /// Fields appended to the end -- realloc only, no data movement.
118 Append,
119 /// Segments added to segment table -- realloc + table update.
120 SegmentAppend,
121 /// Full data migration -- copy with transformation.
122 Full,
123}