Skip to main content

kanban_persistence/
traits.rs

1use crate::PersistenceResult;
2use async_trait::async_trait;
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use std::path::{Path, PathBuf};
6use uuid::Uuid;
7
8/// Metadata for persistence operations
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct PersistenceMetadata {
11    /// ID of the instance that performed the save
12    pub instance_id: Uuid,
13    /// When this data was saved
14    pub saved_at: DateTime<Utc>,
15}
16
17impl PersistenceMetadata {
18    pub fn new(instance_id: Uuid) -> Self {
19        Self {
20            instance_id,
21            saved_at: Utc::now(),
22        }
23    }
24}
25
26/// Point-in-time snapshot of all data that needs to be persisted
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct StoreSnapshot {
29    /// Raw JSON bytes representing all boards, columns, cards, etc.
30    pub data: Vec<u8>,
31    /// Metadata about this snapshot
32    pub metadata: PersistenceMetadata,
33}
34
35/// Events that can be emitted during persistence operations
36#[derive(Debug, Clone)]
37pub enum PersistenceEvent {
38    /// Data was successfully saved
39    Saved(PersistenceMetadata),
40    /// External changes were detected
41    ExternalChangeDetected {
42        path: PathBuf,
43        saved_at: DateTime<Utc>,
44    },
45    /// A conflict occurred (our changes vs external changes)
46    ConflictDetected { reason: String },
47    /// An error occurred during persistence
48    Error(String),
49}
50
51/// Trait for abstract storage operations
52/// Implementations handle different backend storage (file, database, etc.)
53#[async_trait]
54pub trait PersistenceStore: Send + Sync {
55    /// Save a snapshot to the store
56    async fn save(&self, snapshot: StoreSnapshot) -> PersistenceResult<PersistenceMetadata>;
57
58    /// Load the current snapshot from the store
59    async fn load(&self) -> PersistenceResult<(StoreSnapshot, PersistenceMetadata)>;
60
61    /// Check if the store file exists
62    async fn exists(&self) -> bool;
63
64    /// Get the path to the store file
65    fn path(&self) -> &Path;
66
67    /// Get the unique instance ID for this store
68    fn instance_id(&self) -> uuid::Uuid;
69
70    /// Load the store synchronously (no async runtime required).
71    /// Returns `Ok(None)` when the backing file does not exist.
72    ///
73    /// The default implementation returns an error; backends that support
74    /// synchronous loading (e.g. `JsonFileStore`) override this.
75    #[allow(clippy::type_complexity)]
76    fn load_sync(&self) -> PersistenceResult<Option<(StoreSnapshot, PersistenceMetadata)>> {
77        Err(crate::PersistenceError::Unsupported(
78            "load_sync not supported by this backend".into(),
79        ))
80    }
81
82    /// Drain any open connections / file handles before the backing file is
83    /// unlinked. Required on Windows: the OS refuses to delete files that
84    /// still have live handles, and async resources (e.g. an `sqlx` pool)
85    /// outlive synchronous `Drop` because the runtime needs time to close
86    /// each connection.
87    ///
88    /// The default is a no-op; backends with long-lived handles (e.g.
89    /// `SqliteStore`) override this.
90    async fn close(&self) {}
91}
92
93/// Trait for detecting changes to the storage file
94/// Used for multi-instance coordination
95#[async_trait]
96pub trait ChangeDetector: Send + Sync {
97    /// Start watching the file for changes
98    async fn start_watching(&self, path: PathBuf) -> PersistenceResult<()>;
99
100    /// Stop watching the file
101    async fn stop_watching(&self) -> PersistenceResult<()>;
102
103    /// Subscribe to change events
104    /// Returns a broadcast receiver that yields `ChangeEvent` when the file changes
105    fn subscribe(&self) -> tokio::sync::broadcast::Receiver<ChangeEvent>;
106
107    /// Check if currently watching
108    fn is_watching(&self) -> bool;
109}
110
111/// Event indicating a change to the watched file
112#[derive(Debug, Clone)]
113pub struct ChangeEvent {
114    /// Path to the file that changed
115    pub path: PathBuf,
116    /// When the change was detected
117    pub detected_at: DateTime<Utc>,
118}
119
120/// Trait for serialization/deserialization strategies
121/// Allows swapping JSON for binary formats, databases, etc.
122pub trait Serializer<T: Send + Sync>: Send + Sync {
123    /// Serialize data to bytes
124    fn serialize(&self, data: &T) -> PersistenceResult<Vec<u8>>;
125
126    /// Deserialize data from bytes
127    fn deserialize(&self, bytes: &[u8]) -> PersistenceResult<T>;
128}
129
130/// Format versions for migration tracking
131#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
132pub enum FormatVersion {
133    V1,
134    V2,
135    V3,
136    V4,
137    V5,
138}
139
140impl FormatVersion {
141    pub fn as_u32(self) -> u32 {
142        match self {
143            Self::V1 => 1,
144            Self::V2 => 2,
145            Self::V3 => 3,
146            Self::V4 => 4,
147            Self::V5 => 5,
148        }
149    }
150
151    pub fn from_u32(v: u32) -> Option<Self> {
152        match v {
153            1 => Some(Self::V1),
154            2 => Some(Self::V2),
155            3 => Some(Self::V3),
156            4 => Some(Self::V4),
157            5 => Some(Self::V5),
158            _ => None,
159        }
160    }
161}
162
163/// Trait for migration strategies between format versions
164#[async_trait]
165pub trait MigrationStrategy: Send + Sync {
166    /// Detect the version of a file on disk
167    async fn detect_version(&self, path: &Path) -> PersistenceResult<FormatVersion>;
168
169    /// Migrate from one version to another
170    /// Returns the path to the migrated file
171    async fn migrate(
172        &self,
173        from: FormatVersion,
174        to: FormatVersion,
175        path: &Path,
176    ) -> PersistenceResult<PathBuf>;
177}
178
179/// Trait for conflict resolution between local and external changes
180pub trait ConflictResolver: Send + Sync {
181    /// Determine whether local or external change wins
182    /// Returns true if external changes should be used, false for local changes
183    fn should_use_external(
184        &self,
185        local_metadata: &PersistenceMetadata,
186        external_metadata: &PersistenceMetadata,
187    ) -> bool;
188
189    /// Get a human-readable description of the conflict resolution
190    fn explain_resolution(
191        &self,
192        local_metadata: &PersistenceMetadata,
193        external_metadata: &PersistenceMetadata,
194    ) -> String;
195}