use crate::PersistenceResult;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersistenceMetadata {
pub instance_id: Uuid,
pub saved_at: DateTime<Utc>,
#[serde(default)]
pub writer_version: Option<String>,
#[serde(default)]
pub writer_commit: Option<String>,
#[serde(default, skip_serializing)]
pub format_version: Option<u32>,
}
impl PersistenceMetadata {
pub fn new(instance_id: Uuid) -> Self {
Self {
instance_id,
saved_at: Utc::now(),
writer_version: None,
writer_commit: None,
format_version: None,
}
}
pub fn with_writer_stamp(
mut self,
version: impl Into<String>,
commit: impl Into<String>,
) -> Self {
self.writer_version = Some(version.into());
self.writer_commit = Some(commit.into());
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoreSnapshot {
pub data: Vec<u8>,
pub metadata: PersistenceMetadata,
}
#[derive(Debug, Clone)]
pub enum PersistenceEvent {
Saved(PersistenceMetadata),
ExternalChangeDetected {
path: PathBuf,
saved_at: DateTime<Utc>,
},
ConflictDetected { reason: String },
Error(String),
}
#[async_trait]
pub trait PersistenceStore: Send + Sync {
async fn save(&self, snapshot: StoreSnapshot) -> PersistenceResult<PersistenceMetadata>;
async fn load(&self) -> PersistenceResult<(StoreSnapshot, PersistenceMetadata)>;
async fn exists(&self) -> bool;
fn path(&self) -> &Path;
fn instance_id(&self) -> uuid::Uuid;
#[allow(clippy::type_complexity)]
fn load_sync(&self) -> PersistenceResult<Option<(StoreSnapshot, PersistenceMetadata)>> {
Err(crate::PersistenceError::Unsupported(
"load_sync not supported by this backend".into(),
))
}
async fn close(&self) {}
}
#[async_trait]
pub trait ChangeDetector: Send + Sync {
async fn start_watching(&self, path: PathBuf) -> PersistenceResult<()>;
async fn stop_watching(&self) -> PersistenceResult<()>;
fn subscribe(&self) -> tokio::sync::broadcast::Receiver<ChangeEvent>;
fn is_watching(&self) -> bool;
}
#[derive(Debug, Clone)]
pub struct ChangeEvent {
pub path: PathBuf,
pub detected_at: DateTime<Utc>,
}
pub trait Serializer<T: Send + Sync>: Send + Sync {
fn serialize(&self, data: &T) -> PersistenceResult<Vec<u8>>;
fn deserialize(&self, bytes: &[u8]) -> PersistenceResult<T>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum FormatVersion {
V1,
V2,
V3,
V4,
V5,
V6,
V7,
}
impl FormatVersion {
pub const MAX: Self = Self::V7;
pub fn as_u32(self) -> u32 {
match self {
Self::V1 => 1,
Self::V2 => 2,
Self::V3 => 3,
Self::V4 => 4,
Self::V5 => 5,
Self::V6 => 6,
Self::V7 => 7,
}
}
pub fn from_u32(v: u32) -> Option<Self> {
match v {
1 => Some(Self::V1),
2 => Some(Self::V2),
3 => Some(Self::V3),
4 => Some(Self::V4),
5 => Some(Self::V5),
6 => Some(Self::V6),
7 => Some(Self::V7),
_ => None,
}
}
}
#[async_trait]
pub trait MigrationStrategy: Send + Sync {
async fn detect_version(&self, path: &Path) -> PersistenceResult<FormatVersion>;
async fn migrate(
&self,
from: FormatVersion,
to: FormatVersion,
path: &Path,
) -> PersistenceResult<PathBuf>;
}
pub trait ConflictResolver: Send + Sync {
fn should_use_external(
&self,
local_metadata: &PersistenceMetadata,
external_metadata: &PersistenceMetadata,
) -> bool;
fn explain_resolution(
&self,
local_metadata: &PersistenceMetadata,
external_metadata: &PersistenceMetadata,
) -> String;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_version_max_equals_v7() {
assert_eq!(FormatVersion::MAX, FormatVersion::V7);
}
#[test]
fn test_format_version_max_as_u32_matches_largest_variant() {
assert_eq!(FormatVersion::MAX.as_u32(), 7);
}
#[test]
fn test_default_metadata_has_no_writer_stamp() {
let md = PersistenceMetadata::new(Uuid::nil());
assert!(md.writer_version.is_none());
assert!(md.writer_commit.is_none());
}
#[test]
fn test_with_writer_stamp_populates_both_fields() {
let md = PersistenceMetadata::new(Uuid::nil()).with_writer_stamp("0.6.0", "abc1234");
assert_eq!(md.writer_version.as_deref(), Some("0.6.0"));
assert_eq!(md.writer_commit.as_deref(), Some("abc1234"));
}
#[test]
fn test_metadata_serde_round_trips_with_writer_stamp() {
let md = PersistenceMetadata::new(Uuid::nil()).with_writer_stamp("0.6.0", "abc1234");
let json = serde_json::to_string(&md).unwrap();
let parsed: PersistenceMetadata = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.writer_version.as_deref(), Some("0.6.0"));
assert_eq!(parsed.writer_commit.as_deref(), Some("abc1234"));
}
#[test]
fn test_metadata_deserialize_legacy_envelope_missing_stamp_fields() {
let legacy = serde_json::json!({
"instance_id": "550e8400-e29b-41d4-a716-446655440000",
"saved_at": "2024-01-01T00:00:00Z"
});
let parsed: PersistenceMetadata = serde_json::from_value(legacy).unwrap();
assert!(parsed.writer_version.is_none());
assert!(parsed.writer_commit.is_none());
}
}