use std::fs;
use std::path::{Path, PathBuf};
use kmp_domain::{ContextUpdatedEvent, PortError, ProjectionMutation};
use redb::TableDefinition;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use super::format_version::{self, SUPPORTED_FORMAT_VERSION};
use super::store::{EmbeddedKernelStore, commit_error, storage_error, table_error};
pub(crate) const MIGRATIONS: TableDefinition<&str, &[u8]> =
TableDefinition::new("store_migrations");
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,
{
let source_store_file = format_version::store_file_path(source_dir);
let destination_store_file = format_version::store_file_path(destination_dir);
if same_file(&source_store_file, &destination_store_file) {
return Err(PortError::InvalidState(
"migration source and destination are the same data directory".to_string(),
));
}
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_format = format_version::read_stamped_version(source_dir)?;
if source_format > SUPPORTED_FORMAT_VERSION {
return Err(PortError::InvalidState(format!(
"migration source `{}` uses format version {source_format}, newer than this \
binary supports ({SUPPORTED_FORMAT_VERSION}); upgrade the binary",
source_dir.display()
)));
}
let source_sha256 = sha256_of(&source_store_file)?;
if destination_store_file.exists() {
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, destination_dir)?;
let destination = Self::open(destination_dir)?;
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: SUPPORTED_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,
{
if format_version::store_file_path(destination_dir).exists() {
let store = Self::open(destination_dir)?;
let receipt = store.migration_receipt().await?;
return Ok((store, receipt));
}
let (store, receipt) = Self::migrate_data_dir(source_dir, destination_dir, 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 table = match tx.open_table(MIGRATIONS) {
Ok(table) => table,
Err(redb::TableError::TableDoesNotExist(_)) => return Ok(None),
Err(error) => return Err(table_error(error)),
};
let Some(raw) = table
.get(StoreMigrationReceipt::MIGRATION_ID)
.map_err(storage_error)?
else {
return Ok(None);
};
let receipt = serde_json::from_slice(raw.value()).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 tx = store.begin_write()?;
{
let mut table = tx.open_table(MIGRATIONS).map_err(table_error)?;
table
.insert(StoreMigrationReceipt::MIGRATION_ID, encoded.as_slice())
.map_err(storage_error)?;
}
tx.commit().map_err(commit_error)
})
.await
}
}
fn read_source_events(
source_store_file: &Path,
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.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,
}
}