use std::fs;
use std::path::{Path, PathBuf};
use kmp_domain::{ContextUpdatedEvent, PortError, ProjectionMutation};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use super::engine::{Key, Table};
use super::format_version::{self, StorageEngine};
use super::store::EmbeddedKernelStore;
const SOURCE_COPY_FILE: &str = "migration-source.redb";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StoreMigrationReceipt {
pub source_format: u32,
pub source_sha256: String,
pub destination_format: u32,
pub events_migrated: u64,
pub mutations_applied: u64,
pub kernel_version: String,
}
impl StoreMigrationReceipt {
pub const MIGRATION_ID: &'static str = "store-format-migration";
}
impl EmbeddedKernelStore {
pub async fn migrate_data_dir<F>(
source_dir: &Path,
destination_dir: &Path,
derive: F,
) -> Result<(Self, StoreMigrationReceipt), PortError>
where
F: Fn(&ContextUpdatedEvent) -> Result<Vec<ProjectionMutation>, PortError> + Send + 'static,
{
Self::migrate_data_dir_to(source_dir, destination_dir, StorageEngine::Redb, derive).await
}
pub async fn migrate_data_dir_to<F>(
source_dir: &Path,
destination_dir: &Path,
destination_engine: StorageEngine,
derive: F,
) -> Result<(Self, StoreMigrationReceipt), PortError>
where
F: Fn(&ContextUpdatedEvent) -> Result<Vec<ProjectionMutation>, PortError> + Send + 'static,
{
if same_file(source_dir, destination_dir) {
return Err(PortError::InvalidState(
"migration source and destination are the same data directory".to_string(),
));
}
let source_format = format_version::read_stamped_version(source_dir)?;
if source_format > StorageEngine::NEWEST_KNOWN_FORMAT_VERSION {
return Err(PortError::InvalidState(format!(
"migration source `{}` uses format version {source_format}, newer than this \
binary supports ({}); upgrade the binary",
source_dir.display(),
StorageEngine::NEWEST_KNOWN_FORMAT_VERSION
)));
}
let source_engine =
StorageEngine::from_format_version(source_format).unwrap_or(StorageEngine::Redb);
if source_engine != StorageEngine::Redb {
return Err(PortError::Unavailable(format!(
"migration from a {source_engine} store is not supported yet; the source at `{}` \
is left untouched",
source_dir.display()
)));
}
let source_store_file = format_version::store_file_path_for(source_dir, source_engine);
if !source_store_file.exists() {
return Err(PortError::InvalidState(format!(
"migration source `{}` holds no store file at `{}`",
source_dir.display(),
source_store_file.display()
)));
}
let source_sha256 = sha256_of(&source_store_file)?;
if format_version::existing_store_file(destination_dir).is_some() {
let already = match Self::open(destination_dir) {
Ok(store) => store.migration_receipt().await.ok().flatten(),
Err(_) => None,
};
if let Some(receipt) = already
&& receipt.source_sha256 == source_sha256
{
return Err(PortError::Conflict(format!(
"migration destination `{}` was already migrated from this exact \
source ({} events, source sha256 {}); nothing to do",
destination_dir.display(),
receipt.events_migrated,
receipt.source_sha256
)));
}
return Err(PortError::Conflict(format!(
"migration destination `{}` already holds a store; migrate into a new \
directory rather than over existing memory",
destination_dir.display()
)));
}
let events = read_source_events(&source_store_file, source_engine, destination_dir)?;
let destination = Self::open_with_engine(destination_dir, destination_engine)?;
let events_migrated = destination.replay_event_stream(events).await?;
let rebuild = destination.rebuild_projections(derive).await?;
let source_sha256_after = sha256_of(&source_store_file)?;
if source_sha256_after != source_sha256 {
return Err(PortError::InvalidState(format!(
"migration modified its source `{}`; refusing to report success",
source_store_file.display()
)));
}
let receipt = StoreMigrationReceipt {
source_format,
source_sha256,
destination_format: destination_engine.format_version(),
events_migrated,
mutations_applied: rebuild.mutations_applied,
kernel_version: env!("CARGO_PKG_VERSION").to_string(),
};
destination.write_migration_receipt(&receipt).await?;
Ok((destination, receipt))
}
pub async fn open_or_migrate_data_dir<F>(
source_dir: &Path,
destination_dir: &Path,
derive: F,
) -> Result<(Self, Option<StoreMigrationReceipt>), PortError>
where
F: Fn(&ContextUpdatedEvent) -> Result<Vec<ProjectionMutation>, PortError> + Send + 'static,
{
Self::open_or_migrate_data_dir_to(source_dir, destination_dir, StorageEngine::Redb, derive)
.await
}
pub async fn open_or_migrate_data_dir_to<F>(
source_dir: &Path,
destination_dir: &Path,
destination_engine: StorageEngine,
derive: F,
) -> Result<(Self, Option<StoreMigrationReceipt>), PortError>
where
F: Fn(&ContextUpdatedEvent) -> Result<Vec<ProjectionMutation>, PortError> + Send + 'static,
{
if format_version::existing_store_file(destination_dir).is_some() {
let store = Self::open(destination_dir)?;
let receipt = store.migration_receipt().await?;
return Ok((store, receipt));
}
let (store, receipt) =
Self::migrate_data_dir_to(source_dir, destination_dir, destination_engine, derive)
.await?;
Ok((store, Some(receipt)))
}
pub async fn migration_receipt(&self) -> Result<Option<StoreMigrationReceipt>, PortError> {
self.run(|store| {
let tx = store.begin_read()?;
let Some(raw) = tx.get(
Table::Migrations,
Key::Str(StoreMigrationReceipt::MIGRATION_ID),
)?
else {
return Ok(None);
};
let receipt = serde_json::from_slice(&raw).map_err(|error| {
PortError::InvalidState(format!("migration receipt is unreadable: {error}"))
})?;
Ok(Some(receipt))
})
.await
}
async fn write_migration_receipt(
&self,
receipt: &StoreMigrationReceipt,
) -> Result<(), PortError> {
let encoded = serde_json::to_vec(receipt).map_err(|error| {
PortError::InvalidState(format!("migration receipt is not encodable: {error}"))
})?;
self.run(move |store| {
let mut tx = store.begin_write()?;
tx.insert(
Table::Migrations,
Key::Str(StoreMigrationReceipt::MIGRATION_ID),
&encoded,
)?;
tx.commit()
})
.await
}
}
fn read_source_events(
source_store_file: &Path,
source_engine: StorageEngine,
destination_dir: &Path,
) -> Result<Vec<ContextUpdatedEvent>, PortError> {
fs::create_dir_all(destination_dir).map_err(|error| {
PortError::Unavailable(format!(
"migration could not create destination `{}`: {error}",
destination_dir.display()
))
})?;
let copy_path: PathBuf = destination_dir.join(SOURCE_COPY_FILE);
fs::copy(source_store_file, ©_path).map_err(|error| {
PortError::Unavailable(format!(
"migration could not copy the source store to `{}`: {error}",
copy_path.display()
))
})?;
let events = {
let source = EmbeddedKernelStore::open_store_file(©_path, source_engine)?;
source.read_event_log_blocking()
};
let _ = fs::remove_file(©_path);
events
}
fn sha256_of(path: &Path) -> Result<String, PortError> {
let bytes = fs::read(path).map_err(|error| {
PortError::Unavailable(format!(
"migration could not read `{}`: {error}",
path.display()
))
})?;
let mut hasher = Sha256::new();
hasher.update(&bytes);
Ok(format!("{:x}", hasher.finalize()))
}
fn same_file(left: &Path, right: &Path) -> bool {
match (fs::canonicalize(left), fs::canonicalize(right)) {
(Ok(left), Ok(right)) => left == right,
_ => left == right,
}
}