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