Skip to main content

relay_knowledge/storage/
mod.rs

1//! Storage contracts and SQLite-backed graph state.
2//!
3//! Storage owns persisted graph facts, mutation log entries, derived index
4//! metadata, and health snapshots. Domain and interface modules must not depend
5//! on SQL or concrete database types.
6
7mod canvas;
8mod code;
9mod file_index;
10mod sqlite;
11
12use std::{error::Error, fmt, future::Future, pin::Pin};
13
14use serde::{Deserialize, Serialize};
15
16use crate::domain::{
17    AuditEventRecord, AuditStatus, CodeChunkRecord, CodeGraphBatch, CodeGraphCommitReceipt,
18    CodeParseStatusCounts, CodeReferenceRecord, CodeSymbolRecord, CommitReceipt,
19    GraphMutationBatch, GraphVersion, IndexKind, IndexModality, IndexStatus,
20    ProposalConflictRecord, ProposalConflictSeverity, ProposalKind, ProposalProvenance,
21    ProposalRecord, ProposalState, RetrievalHit, RetrieverSource, ServiceOperatorState,
22    ServiceOperatorStatus, WorkerKind, WorkerStatus, WorkerTaskRecord,
23};
24
25pub use canvas::{
26    GraphCanvasSelection, GraphCanvasStorageEdge, GraphCanvasStorageNode,
27    GraphCanvasStorageRequest, GraphCanvasStorageSnapshot,
28};
29pub use code::{
30    CodeImpactChanges, CodeIndexTaskClaimRequest, CodeIndexTaskCompletion, CodeIndexTaskFailure,
31    CodeIndexTaskSeed, CodeRepositoryStore, CodeScopeRetentionRequest,
32};
33pub use file_index::{
34    FileIndexDiagnostics, FileIndexEntry, FileIndexRoot, FileIndexRootStatus, FileIndexRootUpdate,
35    FileIndexScanSummary, FileSearchHit, FileSearchRequest,
36};
37pub use sqlite::SqliteGraphStore;
38
39pub type StorageFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, StorageError>> + Send + 'a>>;
40
41/// Synthetic scope used for graph-wide index work that is not tied to evidence.
42pub const DEFAULT_INDEX_SOURCE_SCOPE: &str = "graph";
43
44/// Graph fact persistence and query contract.
45pub trait GraphStore: Send + Sync {
46    fn commit_mutation_batch(&self, batch: GraphMutationBatch) -> StorageFuture<'_, CommitReceipt>;
47
48    fn inspect_graph(&self) -> StorageFuture<'_, GraphInspection>;
49
50    fn graph_canvas(
51        &self,
52        _request: GraphCanvasStorageRequest,
53    ) -> StorageFuture<'_, GraphCanvasStorageSnapshot> {
54        Box::pin(async {
55            Err(StorageError::InvalidInput(
56                "graph canvas storage is unavailable".to_owned(),
57            ))
58        })
59    }
60
61    fn search(&self, request: GraphSearchRequest) -> StorageFuture<'_, Vec<RetrievalHit>>;
62
63    fn current_graph_version(&self) -> StorageFuture<'_, GraphVersion>;
64}
65
66/// Mutation log contract consumed by reconcilers and indexers.
67pub trait MutationLogStore: Send + Sync {
68    fn read_after(
69        &self,
70        graph_version: GraphVersion,
71        limit: usize,
72    ) -> StorageFuture<'_, Vec<MutationLogEntry>>;
73}
74
75/// Derived index metadata contract.
76pub trait IndexStore: Send + Sync {
77    fn index_statuses(&self) -> StorageFuture<'_, Vec<IndexStatus>>;
78
79    fn mark_refresh_complete(
80        &self,
81        kind: IndexKind,
82        graph_version: GraphVersion,
83    ) -> StorageFuture<'_, IndexStatus>;
84
85    fn index_cursors(&self) -> StorageFuture<'_, Vec<IndexCursor>> {
86        Box::pin(async {
87            Err(StorageError::InvalidInput(
88                "index cursor storage is unavailable".to_owned(),
89            ))
90        })
91    }
92
93    fn queue_index_refreshes(
94        &self,
95        _request: IndexRefreshQueueRequest,
96    ) -> StorageFuture<'_, IndexRefreshDiagnostics> {
97        Box::pin(async {
98            Err(StorageError::InvalidInput(
99                "index refresh task storage is unavailable".to_owned(),
100            ))
101        })
102    }
103
104    fn claim_index_refresh_task(
105        &self,
106        _request: IndexRefreshClaimRequest,
107    ) -> StorageFuture<'_, Option<IndexRefreshTask>> {
108        Box::pin(async {
109            Err(StorageError::InvalidInput(
110                "index refresh task storage is unavailable".to_owned(),
111            ))
112        })
113    }
114
115    fn complete_index_refresh_task(
116        &self,
117        _request: IndexRefreshCompletion,
118    ) -> StorageFuture<'_, IndexRefreshTask> {
119        Box::pin(async {
120            Err(StorageError::InvalidInput(
121                "index refresh task storage is unavailable".to_owned(),
122            ))
123        })
124    }
125
126    fn fail_index_refresh_task(
127        &self,
128        _request: IndexRefreshFailure,
129    ) -> StorageFuture<'_, IndexRefreshTask> {
130        Box::pin(async {
131            Err(StorageError::InvalidInput(
132                "index refresh task storage is unavailable".to_owned(),
133            ))
134        })
135    }
136
137    fn index_refresh_diagnostics(
138        &self,
139        _now_ms: u64,
140    ) -> StorageFuture<'_, IndexRefreshDiagnostics> {
141        Box::pin(async {
142            Err(StorageError::InvalidInput(
143                "index refresh diagnostics are unavailable".to_owned(),
144            ))
145        })
146    }
147
148    fn queue_worker_tasks(
149        &self,
150        _tasks: Vec<WorkerTaskSeed>,
151    ) -> StorageFuture<'_, Vec<WorkerTaskRecord>> {
152        Box::pin(async { Ok(Vec::new()) })
153    }
154
155    fn worker_statuses(&self) -> StorageFuture<'_, Vec<WorkerStatus>> {
156        Box::pin(async { Ok(Vec::new()) })
157    }
158
159    fn claim_worker_task(
160        &self,
161        _request: WorkerTaskClaimRequest,
162    ) -> StorageFuture<'_, Option<WorkerTaskRecord>> {
163        Box::pin(async { Ok(None) })
164    }
165
166    fn complete_worker_task(
167        &self,
168        _request: WorkerTaskCompletion,
169    ) -> StorageFuture<'_, WorkerTaskRecord> {
170        Box::pin(async {
171            Err(StorageError::InvalidInput(
172                "worker task storage is unavailable".to_owned(),
173            ))
174        })
175    }
176
177    fn fail_worker_task(&self, _request: WorkerTaskFailure) -> StorageFuture<'_, WorkerTaskRecord> {
178        Box::pin(async {
179            Err(StorageError::InvalidInput(
180                "worker task storage is unavailable".to_owned(),
181            ))
182        })
183    }
184
185    fn insert_proposal(&self, _proposal: NewProposal) -> StorageFuture<'_, ProposalRecord> {
186        Box::pin(async {
187            Err(StorageError::InvalidInput(
188                "proposal storage is unavailable".to_owned(),
189            ))
190        })
191    }
192
193    fn list_proposals(
194        &self,
195        _request: ProposalListRequest,
196    ) -> StorageFuture<'_, Vec<ProposalRecord>> {
197        Box::pin(async { Ok(Vec::new()) })
198    }
199
200    fn proposal_by_id(&self, _proposal_id: String) -> StorageFuture<'_, Option<ProposalRecord>> {
201        Box::pin(async { Ok(None) })
202    }
203
204    fn proposal_conflicts(
205        &self,
206        _proposal_id: String,
207    ) -> StorageFuture<'_, Vec<ProposalConflictRecord>> {
208        Box::pin(async { Ok(Vec::new()) })
209    }
210
211    fn decide_proposal(&self, _request: ProposalDecision) -> StorageFuture<'_, ProposalRecord> {
212        Box::pin(async {
213            Err(StorageError::InvalidInput(
214                "proposal storage is unavailable".to_owned(),
215            ))
216        })
217    }
218
219    fn insert_audit_event(&self, _event: NewAuditEvent) -> StorageFuture<'_, AuditEventRecord> {
220        Box::pin(async {
221            Err(StorageError::InvalidInput(
222                "audit storage is unavailable".to_owned(),
223            ))
224        })
225    }
226
227    fn query_audit_events(
228        &self,
229        _request: AuditQueryRequest,
230    ) -> StorageFuture<'_, Vec<AuditEventRecord>> {
231        Box::pin(async { Ok(Vec::new()) })
232    }
233
234    fn audit_event_count(&self) -> StorageFuture<'_, usize> {
235        Box::pin(async { Ok(0) })
236    }
237
238    fn service_operator_status(&self) -> StorageFuture<'_, ServiceOperatorStatus> {
239        Box::pin(async {
240            Ok(ServiceOperatorStatus {
241                state: ServiceOperatorState::Disabled,
242                silent_updates_enabled: false,
243                allowed_scopes: Vec::new(),
244                last_run_at_ms: None,
245                next_retry_at_ms: None,
246                last_error: None,
247                updated_at_ms: 0,
248            })
249        })
250    }
251
252    fn update_service_operator(
253        &self,
254        _request: ServiceOperatorUpdate,
255    ) -> StorageFuture<'_, ServiceOperatorStatus> {
256        Box::pin(async {
257            Err(StorageError::InvalidInput(
258                "service operator storage is unavailable".to_owned(),
259            ))
260        })
261    }
262
263    fn replace_file_index_root(
264        &self,
265        _update: FileIndexRootUpdate,
266    ) -> StorageFuture<'_, FileIndexRootStatus> {
267        unavailable_file_index_storage()
268    }
269
270    fn mark_file_index_roots_unconfigured(
271        &self,
272        _active_roots: Vec<FileIndexRoot>,
273        _now_ms: u64,
274    ) -> StorageFuture<'_, FileIndexDiagnostics> {
275        unavailable_file_index_storage()
276    }
277
278    fn search_files(&self, _request: FileSearchRequest) -> StorageFuture<'_, Vec<FileSearchHit>> {
279        unavailable_file_index_storage()
280    }
281
282    fn file_index_diagnostics(&self) -> StorageFuture<'_, FileIndexDiagnostics> {
283        unavailable_file_index_storage()
284    }
285}
286
287fn unavailable_file_index_storage<T>() -> StorageFuture<'static, T> {
288    Box::pin(async {
289        Err(StorageError::InvalidInput(
290            "file index storage is unavailable".to_owned(),
291        ))
292    })
293}
294
295/// Code graph fact persistence and query contract for tree-sitter output.
296pub trait CodeGraphStore: Send + Sync {
297    fn commit_code_graph_batch(
298        &self,
299        batch: CodeGraphBatch,
300    ) -> StorageFuture<'_, CodeGraphCommitReceipt>;
301
302    fn search_code_symbols(
303        &self,
304        request: CodeSymbolSearchRequest,
305    ) -> StorageFuture<'_, Vec<CodeSymbolRecord>>;
306
307    fn search_code_references(
308        &self,
309        request: CodeReferenceSearchRequest,
310    ) -> StorageFuture<'_, Vec<CodeReferenceRecord>>;
311
312    fn search_code_chunks(
313        &self,
314        request: CodeChunkSearchRequest,
315    ) -> StorageFuture<'_, Vec<CodeChunkRecord>>;
316}
317
318/// Combined storage facade used by the application service.
319pub trait KnowledgeStore:
320    GraphStore + MutationLogStore + IndexStore + CodeGraphStore + CodeRepositoryStore
321{
322}
323
324impl<T> KnowledgeStore for T where
325    T: GraphStore + MutationLogStore + IndexStore + CodeGraphStore + CodeRepositoryStore
326{
327}
328
329/// Bounded graph search request against an explicit graph snapshot.
330#[derive(Debug, Clone, PartialEq, Eq)]
331pub struct GraphSearchRequest {
332    pub query: String,
333    pub source_scope: Option<String>,
334    pub graph_version: GraphVersion,
335    pub limit: usize,
336    pub disabled_retriever_sources: Vec<RetrieverSource>,
337}
338
339impl GraphSearchRequest {
340    /// Returns whether storage may execute a retriever family for this request.
341    pub fn allows_retriever_source(&self, source: RetrieverSource) -> bool {
342        !self.disabled_retriever_sources.contains(&source)
343    }
344}
345
346/// Bounded code symbol search against an explicit graph snapshot.
347#[derive(Debug, Clone, PartialEq, Eq)]
348pub struct CodeSymbolSearchRequest {
349    pub source_scope: Option<String>,
350    pub path: Option<String>,
351    pub name: Option<String>,
352    pub graph_version: GraphVersion,
353    pub limit: usize,
354}
355
356/// Bounded code reference search against an explicit graph snapshot.
357#[derive(Debug, Clone, PartialEq, Eq)]
358pub struct CodeReferenceSearchRequest {
359    pub source_scope: Option<String>,
360    pub path: Option<String>,
361    pub symbol_text: Option<String>,
362    pub target_symbol_id: Option<String>,
363    pub graph_version: GraphVersion,
364    pub limit: usize,
365}
366
367/// Bounded code chunk search against an explicit graph snapshot.
368#[derive(Debug, Clone, PartialEq, Eq)]
369pub struct CodeChunkSearchRequest {
370    pub source_scope: Option<String>,
371    pub path: Option<String>,
372    pub query: Option<String>,
373    pub graph_version: GraphVersion,
374    pub limit: usize,
375}
376
377/// Worker task input inserted after graph changes or service reconciliation.
378#[derive(Debug, Clone, PartialEq, Eq)]
379pub struct WorkerTaskSeed {
380    pub kind: WorkerKind,
381    pub source_scope: String,
382    pub evidence_id: Option<String>,
383    pub target_graph_version: GraphVersion,
384    pub input_fingerprint: String,
385    pub payload_json: String,
386    pub now_ms: u64,
387}
388
389/// Worker lease acquisition request.
390#[derive(Debug, Clone, PartialEq, Eq)]
391pub struct WorkerTaskClaimRequest {
392    pub kind: Option<WorkerKind>,
393    pub lease_owner: String,
394    pub lease_duration_ms: u64,
395    pub max_attempts: u32,
396    pub now_ms: u64,
397}
398
399/// Worker completion guarded by the active lease.
400#[derive(Debug, Clone, PartialEq, Eq)]
401pub struct WorkerTaskCompletion {
402    pub task_id: String,
403    pub lease_owner: String,
404    pub attempt_count: u32,
405    pub now_ms: u64,
406}
407
408/// Worker failure report for retry and dead-letter handling.
409#[derive(Debug, Clone, PartialEq, Eq)]
410pub struct WorkerTaskFailure {
411    pub task_id: String,
412    pub lease_owner: String,
413    pub attempt_count: u32,
414    pub error_kind: String,
415    pub error_message: String,
416    pub retry_backoff_ms: u64,
417    pub max_attempts: u32,
418    pub now_ms: u64,
419}
420
421/// New proposal to persist before manual approval.
422#[derive(Debug, Clone, PartialEq, Eq)]
423pub struct NewProposal {
424    pub proposal_id: String,
425    pub source_scope: String,
426    pub kind: ProposalKind,
427    pub title: String,
428    pub summary: String,
429    pub payload_json: String,
430    pub origin: String,
431    pub provenance: ProposalProvenance,
432    pub confidence_basis_points: u16,
433    pub conflicts: Vec<NewProposalConflict>,
434    pub now_ms: u64,
435}
436
437/// New proposal conflict to persist with a proposal.
438#[derive(Debug, Clone, PartialEq, Eq)]
439pub struct NewProposalConflict {
440    pub conflict_id: String,
441    pub existing_fact_kind: String,
442    pub existing_fact_id: String,
443    pub severity: ProposalConflictSeverity,
444    pub reason: String,
445}
446
447/// Proposal list filter.
448#[derive(Debug, Clone, PartialEq, Eq)]
449pub struct ProposalListRequest {
450    pub state: Option<ProposalState>,
451    pub limit: usize,
452}
453
454/// Proposal decision request.
455#[derive(Debug, Clone, PartialEq, Eq)]
456pub struct ProposalDecision {
457    pub proposal_id: String,
458    pub next_state: ProposalState,
459    pub actor: String,
460    pub reason: Option<String>,
461    pub now_ms: u64,
462}
463
464/// New durable audit event.
465#[derive(Debug, Clone, PartialEq, Eq)]
466pub struct NewAuditEvent {
467    pub operation: String,
468    pub interface: String,
469    pub request_id: String,
470    pub trace_id: String,
471    pub status: AuditStatus,
472    pub actor: Option<String>,
473    pub source_scope: Option<String>,
474    pub graph_version: u64,
475    pub detail_json: String,
476    pub message: Option<String>,
477    pub now_ms: u64,
478}
479
480/// Audit query filter.
481#[derive(Debug, Clone, PartialEq, Eq)]
482pub struct AuditQueryRequest {
483    pub operation: Option<String>,
484    pub limit: usize,
485}
486
487/// Service operator state update.
488#[derive(Debug, Clone, PartialEq, Eq)]
489pub struct ServiceOperatorUpdate {
490    pub state: ServiceOperatorState,
491    pub silent_updates_enabled: bool,
492    pub allowed_scopes: Vec<String>,
493    pub last_error: Option<String>,
494    pub now_ms: u64,
495}
496
497/// Aggregated graph status for diagnostics.
498#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
499pub struct GraphInspection {
500    pub graph_version: GraphVersion,
501    pub entity_count: usize,
502    pub evidence_count: usize,
503    pub relation_count: usize,
504    pub claim_count: usize,
505    pub event_count: usize,
506    pub mutation_count: usize,
507    pub code_file_count: usize,
508    pub code_symbol_count: usize,
509    pub code_reference_count: usize,
510    pub code_chunk_count: usize,
511    pub code_parse_status_counts: CodeParseStatusCounts,
512}
513
514/// Mutation log entry returned for replay and index refresh planning.
515#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
516pub struct MutationLogEntry {
517    pub graph_version: GraphVersion,
518    pub evidence_count: usize,
519    pub entity_count: usize,
520    pub relation_count: usize,
521    pub claim_count: usize,
522    pub event_count: usize,
523    pub affected_scopes: Vec<String>,
524    pub affected_entity_ids: Vec<String>,
525    pub evidence_ids: Vec<String>,
526    pub source_hashes: Vec<String>,
527}
528
529/// Scoped cursor for a derived index read model.
530#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
531pub struct IndexCursor {
532    pub kind: IndexKind,
533    pub source_scope: String,
534    pub modality: IndexModality,
535    pub index_version: u64,
536    pub indexed_graph_version: GraphVersion,
537    pub state: crate::domain::IndexState,
538    pub last_error: Option<String>,
539    #[serde(skip_serializing_if = "Option::is_none")]
540    pub source_hash: Option<String>,
541    #[serde(skip_serializing_if = "Option::is_none")]
542    pub backend_cursor: Option<String>,
543    #[serde(skip_serializing_if = "Option::is_none")]
544    pub model_name: Option<String>,
545    #[serde(skip_serializing_if = "Option::is_none")]
546    pub model_dimension: Option<u32>,
547}
548
549/// Persistent index refresh task lifecycle state.
550#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
551#[serde(rename_all = "snake_case")]
552pub enum IndexRefreshTaskState {
553    Queued,
554    Running,
555    Succeeded,
556    Retrying,
557    Failed,
558    DeadLetter,
559}
560
561impl IndexRefreshTaskState {
562    /// Stable storage and API representation.
563    pub const fn as_str(self) -> &'static str {
564        match self {
565            Self::Queued => "queued",
566            Self::Running => "running",
567            Self::Succeeded => "succeeded",
568            Self::Retrying => "retrying",
569            Self::Failed => "failed",
570            Self::DeadLetter => "dead_letter",
571        }
572    }
573}
574
575/// Persistent task used by foreground refresh and startup recovery.
576#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
577pub struct IndexRefreshTask {
578    pub task_id: String,
579    pub kind: IndexKind,
580    pub source_scope: String,
581    pub modality: IndexModality,
582    pub target_graph_version: GraphVersion,
583    pub state: IndexRefreshTaskState,
584    pub lease_owner: Option<String>,
585    pub lease_expires_at_ms: Option<u64>,
586    pub attempt_count: u32,
587    pub next_retry_at_ms: u64,
588    pub input_fingerprint: String,
589    pub cursor_before: GraphVersion,
590    pub cursor_after: Option<GraphVersion>,
591    pub last_error_kind: Option<String>,
592    pub last_error_message: Option<String>,
593    pub created_at_ms: u64,
594    pub updated_at_ms: u64,
595}
596
597/// Queue request created by refresh APIs or the reconciler.
598#[derive(Debug, Clone, PartialEq, Eq)]
599pub struct IndexRefreshQueueRequest {
600    pub kinds: Vec<IndexKind>,
601    pub target_graph_version: GraphVersion,
602    pub max_queue_depth: usize,
603    pub reset_dead_letter_tasks: bool,
604    pub now_ms: u64,
605}
606
607/// Lease acquisition request for bounded foreground workers.
608#[derive(Debug, Clone, PartialEq, Eq)]
609pub struct IndexRefreshClaimRequest {
610    pub lease_owner: String,
611    pub lease_duration_ms: u64,
612    pub max_attempts: u32,
613    pub now_ms: u64,
614}
615
616/// Completion report guarded by the active task lease and attempt token.
617#[derive(Debug, Clone, PartialEq, Eq)]
618pub struct IndexRefreshCompletion {
619    pub task_id: String,
620    pub lease_owner: String,
621    pub attempt_count: u32,
622    pub indexed_graph_version: GraphVersion,
623    pub model_name: Option<String>,
624    pub model_dimension: Option<u32>,
625    pub now_ms: u64,
626}
627
628/// Failure report for retry backoff and dead-letter isolation.
629#[derive(Debug, Clone, PartialEq, Eq)]
630pub struct IndexRefreshFailure {
631    pub task_id: String,
632    pub lease_owner: String,
633    pub attempt_count: u32,
634    pub error_kind: String,
635    pub error_message: String,
636    pub retry_backoff_ms: u64,
637    pub max_attempts: u32,
638    pub now_ms: u64,
639}
640
641/// Per-kind lag included in diagnostics snapshots.
642#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
643pub struct IndexLag {
644    pub kind: IndexKind,
645    pub lag_versions: u64,
646}
647
648/// Structured reason explaining why an index family or scoped cursor is stale.
649#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
650pub struct IndexStalenessReason {
651    pub kind: IndexKind,
652    #[serde(skip_serializing_if = "Option::is_none")]
653    pub source_scope: Option<String>,
654    #[serde(skip_serializing_if = "Option::is_none")]
655    pub modality: Option<IndexModality>,
656    pub reason: String,
657    pub lag_versions: u64,
658    #[serde(skip_serializing_if = "Option::is_none")]
659    pub last_error: Option<String>,
660}
661
662/// Snapshot for queue, dead-letter, and stale-index diagnostics.
663#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
664pub struct IndexRefreshDiagnostics {
665    pub queue_depth: usize,
666    pub running_count: usize,
667    pub retrying_count: usize,
668    pub dead_letter_count: usize,
669    pub oldest_unfinished_age_ms: Option<u64>,
670    pub index_lag_by_kind: Vec<IndexLag>,
671    pub max_index_lag_versions: u64,
672    pub stale_index_count: usize,
673    pub stale_reasons: Vec<IndexStalenessReason>,
674}
675
676/// Storage health surfaced to diagnostics.
677#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
678pub struct StorageHealth {
679    pub graph_version: GraphVersion,
680    pub healthy: bool,
681    pub detail: String,
682}
683
684/// Storage boundary failure.
685#[derive(Debug)]
686pub enum StorageError {
687    Io(std::io::Error),
688    Sqlite(rusqlite::Error),
689    Join(tokio::task::JoinError),
690    LockPoisoned,
691    InvalidInput(String),
692}
693
694impl fmt::Display for StorageError {
695    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
696        match self {
697            Self::Io(error) => write!(formatter, "storage I/O failed: {error}"),
698            Self::Sqlite(error) => write!(formatter, "sqlite operation failed: {error}"),
699            Self::Join(error) => write!(formatter, "storage worker failed: {error}"),
700            Self::LockPoisoned => write!(formatter, "sqlite connection lock was poisoned"),
701            Self::InvalidInput(message) => write!(formatter, "invalid storage input: {message}"),
702        }
703    }
704}
705
706impl Error for StorageError {}
707
708impl From<std::io::Error> for StorageError {
709    fn from(error: std::io::Error) -> Self {
710        Self::Io(error)
711    }
712}
713
714impl From<rusqlite::Error> for StorageError {
715    fn from(error: rusqlite::Error) -> Self {
716        Self::Sqlite(error)
717    }
718}
719
720impl From<tokio::task::JoinError> for StorageError {
721    fn from(error: tokio::task::JoinError) -> Self {
722        Self::Join(error)
723    }
724}
725
726#[cfg(test)]
727#[path = "mod_tests.rs"]
728mod tests;