kmp_adapter_embedded/adapter/
migration.rs1use std::fs;
30use std::path::{Path, PathBuf};
31
32use kmp_domain::{ContextUpdatedEvent, PortError, ProjectionMutation};
33use redb::TableDefinition;
34use serde::{Deserialize, Serialize};
35use sha2::{Digest, Sha256};
36
37use super::format_version::{self, SUPPORTED_FORMAT_VERSION};
38use super::store::{EmbeddedKernelStore, commit_error, storage_error, table_error};
39
40pub(crate) const MIGRATIONS: TableDefinition<&str, &[u8]> =
42 TableDefinition::new("store_migrations");
43
44const SOURCE_COPY_FILE: &str = "migration-source.redb";
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct StoreMigrationReceipt {
51 pub source_format: u32,
52 pub source_sha256: String,
53 pub destination_format: u32,
54 pub events_migrated: u64,
55 pub mutations_applied: u64,
56 pub kernel_version: String,
57}
58
59impl StoreMigrationReceipt {
60 pub const MIGRATION_ID: &'static str = "store-format-migration";
62}
63
64impl EmbeddedKernelStore {
65 pub async fn migrate_data_dir<F>(
71 source_dir: &Path,
72 destination_dir: &Path,
73 derive: F,
74 ) -> Result<(Self, StoreMigrationReceipt), PortError>
75 where
76 F: Fn(&ContextUpdatedEvent) -> Result<Vec<ProjectionMutation>, PortError> + Send + 'static,
77 {
78 let source_store_file = format_version::store_file_path(source_dir);
79 let destination_store_file = format_version::store_file_path(destination_dir);
80
81 if same_file(&source_store_file, &destination_store_file) {
82 return Err(PortError::InvalidState(
83 "migration source and destination are the same data directory".to_string(),
84 ));
85 }
86 if !source_store_file.exists() {
87 return Err(PortError::InvalidState(format!(
88 "migration source `{}` holds no store file at `{}`",
89 source_dir.display(),
90 source_store_file.display()
91 )));
92 }
93 let source_format = format_version::read_stamped_version(source_dir)?;
94 if source_format > SUPPORTED_FORMAT_VERSION {
95 return Err(PortError::InvalidState(format!(
96 "migration source `{}` uses format version {source_format}, newer than this \
97 binary supports ({SUPPORTED_FORMAT_VERSION}); upgrade the binary",
98 source_dir.display()
99 )));
100 }
101 let source_sha256 = sha256_of(&source_store_file)?;
102
103 if destination_store_file.exists() {
104 let already = match Self::open(destination_dir) {
108 Ok(store) => store.migration_receipt().await.ok().flatten(),
109 Err(_) => None,
110 };
111 if let Some(receipt) = already
112 && receipt.source_sha256 == source_sha256
113 {
114 return Err(PortError::Conflict(format!(
115 "migration destination `{}` was already migrated from this exact \
116 source ({} events, source sha256 {}); nothing to do",
117 destination_dir.display(),
118 receipt.events_migrated,
119 receipt.source_sha256
120 )));
121 }
122 return Err(PortError::Conflict(format!(
123 "migration destination `{}` already holds a store; migrate into a new \
124 directory rather than over existing memory",
125 destination_dir.display()
126 )));
127 }
128 let events = read_source_events(&source_store_file, destination_dir)?;
129
130 let destination = Self::open(destination_dir)?;
131 let events_migrated = destination.replay_event_stream(events).await?;
132 let rebuild = destination.rebuild_projections(derive).await?;
133
134 let source_sha256_after = sha256_of(&source_store_file)?;
138 if source_sha256_after != source_sha256 {
139 return Err(PortError::InvalidState(format!(
140 "migration modified its source `{}`; refusing to report success",
141 source_store_file.display()
142 )));
143 }
144
145 let receipt = StoreMigrationReceipt {
146 source_format,
147 source_sha256,
148 destination_format: SUPPORTED_FORMAT_VERSION,
149 events_migrated,
150 mutations_applied: rebuild.mutations_applied,
151 kernel_version: env!("CARGO_PKG_VERSION").to_string(),
152 };
153 destination.write_migration_receipt(&receipt).await?;
154 Ok((destination, receipt))
155 }
156
157 pub async fn open_or_migrate_data_dir<F>(
163 source_dir: &Path,
164 destination_dir: &Path,
165 derive: F,
166 ) -> Result<(Self, Option<StoreMigrationReceipt>), PortError>
167 where
168 F: Fn(&ContextUpdatedEvent) -> Result<Vec<ProjectionMutation>, PortError> + Send + 'static,
169 {
170 if format_version::store_file_path(destination_dir).exists() {
171 let store = Self::open(destination_dir)?;
172 let receipt = store.migration_receipt().await?;
173 return Ok((store, receipt));
174 }
175 let (store, receipt) = Self::migrate_data_dir(source_dir, destination_dir, derive).await?;
176 Ok((store, Some(receipt)))
177 }
178
179 pub async fn migration_receipt(&self) -> Result<Option<StoreMigrationReceipt>, PortError> {
181 self.run(|store| {
182 let tx = store.begin_read()?;
183 let table = match tx.open_table(MIGRATIONS) {
184 Ok(table) => table,
185 Err(redb::TableError::TableDoesNotExist(_)) => return Ok(None),
187 Err(error) => return Err(table_error(error)),
188 };
189 let Some(raw) = table
190 .get(StoreMigrationReceipt::MIGRATION_ID)
191 .map_err(storage_error)?
192 else {
193 return Ok(None);
194 };
195 let receipt = serde_json::from_slice(raw.value()).map_err(|error| {
196 PortError::InvalidState(format!("migration receipt is unreadable: {error}"))
197 })?;
198 Ok(Some(receipt))
199 })
200 .await
201 }
202
203 async fn write_migration_receipt(
204 &self,
205 receipt: &StoreMigrationReceipt,
206 ) -> Result<(), PortError> {
207 let encoded = serde_json::to_vec(receipt).map_err(|error| {
208 PortError::InvalidState(format!("migration receipt is not encodable: {error}"))
209 })?;
210 self.run(move |store| {
211 let tx = store.begin_write()?;
212 {
213 let mut table = tx.open_table(MIGRATIONS).map_err(table_error)?;
214 table
215 .insert(StoreMigrationReceipt::MIGRATION_ID, encoded.as_slice())
216 .map_err(storage_error)?;
217 }
218 tx.commit().map_err(commit_error)
219 })
220 .await
221 }
222}
223
224fn read_source_events(
230 source_store_file: &Path,
231 destination_dir: &Path,
232) -> Result<Vec<ContextUpdatedEvent>, PortError> {
233 fs::create_dir_all(destination_dir).map_err(|error| {
234 PortError::Unavailable(format!(
235 "migration could not create destination `{}`: {error}",
236 destination_dir.display()
237 ))
238 })?;
239 let copy_path: PathBuf = destination_dir.join(SOURCE_COPY_FILE);
240 fs::copy(source_store_file, ©_path).map_err(|error| {
241 PortError::Unavailable(format!(
242 "migration could not copy the source store to `{}`: {error}",
243 copy_path.display()
244 ))
245 })?;
246
247 let events = {
248 let source = EmbeddedKernelStore::open_store_file(©_path)?;
249 source.read_event_log_blocking()
250 };
251
252 let _ = fs::remove_file(©_path);
255 events
256}
257
258fn sha256_of(path: &Path) -> Result<String, PortError> {
259 let bytes = fs::read(path).map_err(|error| {
260 PortError::Unavailable(format!(
261 "migration could not read `{}`: {error}",
262 path.display()
263 ))
264 })?;
265 let mut hasher = Sha256::new();
266 hasher.update(&bytes);
267 Ok(format!("{:x}", hasher.finalize()))
268}
269
270fn same_file(left: &Path, right: &Path) -> bool {
271 match (fs::canonicalize(left), fs::canonicalize(right)) {
272 (Ok(left), Ok(right)) => left == right,
273 _ => left == right,
276 }
277}