Skip to main content

khive_storage/
error.rs

1//! Storage error types shared across all backend implementations.
2
3use std::borrow::Cow;
4use std::error::Error as StdError;
5use std::fmt;
6
7use thiserror::Error;
8
9use crate::blob::ContentRef;
10use crate::capability::StorageCapability;
11
12/// What is known about one request when its single-writer execution seam
13/// terminates or is retired.
14///
15/// The state is deliberately about the request, not about why the task
16/// stopped. Callers need this distinction to decide whether a write is known
17/// not to have started, known to have had its SQLite transaction rolled back,
18/// or may already have produced side effects.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum WriterTaskRequestState {
21    /// The request's operation closure was never invoked.
22    NotStarted,
23    /// The request ran inside `BEGIN IMMEDIATE`, and that transaction was
24    /// successfully rolled back on the connection that owned it. The request
25    /// may have returned an error, failed COMMIT, or panicked; none of its
26    /// wrapped SQLite writes committed.
27    TransactionRolledBack,
28    /// The request was accepted, but its exact outcome cannot be established;
29    /// it may already have produced side effects and must not be blindly
30    /// retried.
31    SideEffectsUnknown,
32}
33
34impl fmt::Display for WriterTaskRequestState {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        f.write_str(match self {
37            Self::NotStarted => "not_started",
38            Self::TransactionRolledBack => "transaction_rolled_back",
39            Self::SideEffectsUnknown => "side_effects_unknown",
40        })
41    }
42}
43
44/// Which pre-write disk-admission step could not establish safety.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum CapacityUnavailablePhase {
47    Identity,
48    Lock,
49    Probe,
50}
51
52impl CapacityUnavailablePhase {
53    pub const fn as_str(self) -> &'static str {
54        match self {
55            Self::Identity => "identity",
56            Self::Lock => "lock",
57            Self::Probe => "probe",
58        }
59    }
60}
61
62impl fmt::Display for CapacityUnavailablePhase {
63    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64        f.write_str(self.as_str())
65    }
66}
67
68/// Unified error type for all storage operations.
69#[derive(Debug, Error)]
70pub enum StorageError {
71    #[error("{capability:?} resource not found: {resource} ({key})")]
72    NotFound {
73        capability: StorageCapability,
74        resource: &'static str,
75        key: String,
76    },
77
78    #[error("{capability:?} resource already exists: {resource} ({key})")]
79    AlreadyExists {
80        capability: StorageCapability,
81        resource: &'static str,
82        key: String,
83    },
84
85    #[error("conflict in {capability:?} during {operation}: {message}")]
86    Conflict {
87        capability: StorageCapability,
88        operation: Cow<'static, str>,
89        message: String,
90    },
91
92    #[error("invalid input for {capability:?} during {operation}: {message}")]
93    InvalidInput {
94        capability: StorageCapability,
95        operation: Cow<'static, str>,
96        message: String,
97    },
98
99    #[error("unsupported operation for {capability:?}: {operation} ({message})")]
100    Unsupported {
101        capability: StorageCapability,
102        operation: Cow<'static, str>,
103        message: String,
104    },
105
106    /// The authoritative object is larger than the caller's declared
107    /// whole-buffer limit. `observed_at_least` is either opened-object
108    /// metadata used for early refusal or the running byte count that first
109    /// crossed the limit; metadata is not treated as the actual-byte bound.
110    #[error(
111        "blob {content_ref} exceeds the {max_bytes}-byte read limit (observed at least {observed_at_least} bytes)"
112    )]
113    BlobTooLarge {
114        content_ref: ContentRef,
115        max_bytes: u64,
116        observed_at_least: u64,
117    },
118
119    /// The complete bounded body disagreed with metadata obtained from the
120    /// same opened object / GET response.
121    #[error(
122        "blob {content_ref} metadata reports {metadata_bytes} bytes but the complete body contains {actual_bytes} bytes"
123    )]
124    BlobSizeMismatch {
125        content_ref: ContentRef,
126        metadata_bytes: u64,
127        actual_bytes: u64,
128    },
129
130    /// The complete, size-consistent bounded body did not hash to the
131    /// requested content-addressed reference.
132    #[error("blob digest mismatch: expected {expected}, computed {actual}")]
133    BlobDigestMismatch {
134        expected: ContentRef,
135        actual: ContentRef,
136    },
137
138    #[error("pool failure during {operation}: {message}")]
139    Pool {
140        operation: Cow<'static, str>,
141        message: String,
142    },
143
144    #[error("timeout during {operation}")]
145    Timeout { operation: Cow<'static, str> },
146
147    /// A bounded wait for storage admission (a reader/writer handle slot or a
148    /// pooled reader checkout) elapsed before anything was acquired. The
149    /// operation never started, so retrying cannot duplicate a side effect —
150    /// distinct from [`StorageError::Timeout`], which makes no claim about
151    /// whether work was in flight when the deadline expired.
152    #[error("admission timeout during {operation} after {timeout_ms}ms{pool}", pool = match .pool_identity {
153        Some(identity) => format!(" (pool: {identity})"),
154        None => String::new(),
155    })]
156    AdmissionTimeout {
157        operation: Cow<'static, str>,
158        /// The configured admission deadline that elapsed, in milliseconds.
159        timeout_ms: u64,
160        /// Canonical database path display, or `:memory:`, when the refusing pool is known.
161        pool_identity: Option<String>,
162    },
163
164    #[error("sql transaction failure during {operation}: {message}")]
165    Transaction {
166        operation: Cow<'static, str>,
167        message: String,
168    },
169
170    /// A cached read-only handle's admitted transaction pinned a WAL
171    /// snapshot past the configured `read_tx_max_age` bound and was
172    /// proactively rolled back so the next call can open a fresh snapshot
173    /// (#1846). Distinct from the generic [`StorageError::Transaction`]
174    /// variant — which also covers failed-cleanup and write-side ambiguity
175    /// cases that are not uniformly safe to retry — so callers (and MCP
176    /// dispatch) can recognize this specific, always-safe-to-retry
177    /// condition by variant rather than by parsing rendered text.
178    #[error(
179        "cached read-only transaction exceeded the maximum read-transaction age \
180         ({max_age_secs}s) during {operation} and was rolled back; retry to open a fresh \
181         read snapshot"
182    )]
183    ReadTransactionAgeEvicted {
184        operation: Cow<'static, str>,
185        max_age_secs: u64,
186    },
187
188    /// A cached read-only handle's admitted transaction pinned a WAL
189    /// snapshot past `read_tx_max_age`, and the proactive rollback used to
190    /// end the eviction (#1846) did not restore autocommit, or the rollback
191    /// itself failed. The connection is discarded either way rather than
192    /// returned to the pool. The age check that triggered this still ran
193    /// before any read on the connection, so — exactly like
194    /// [`StorageError::ReadTransactionAgeEvicted`] — retrying the caller's
195    /// operation on a fresh connection is always safe; this variant exists
196    /// only to keep that guarantee distinguishable from a clean eviction in
197    /// the rendered message and to keep [`StorageError::Transaction`] (whose
198    /// other cases are not uniformly safe to retry) out of this path.
199    #[error(
200        "cached read-only transaction exceeded the maximum read-transaction age \
201         ({max_age_secs}s) during {operation} but could not be cleanly rolled back \
202         ({message}); the connection was discarded, retry to open a fresh read snapshot"
203    )]
204    ReadTransactionAgeEvictionCleanupFailed {
205        operation: Cow<'static, str>,
206        max_age_secs: u64,
207        message: String,
208    },
209
210    #[error("serialization failure in {capability:?}: {message}")]
211    Serialization {
212        capability: StorageCapability,
213        message: String,
214    },
215
216    #[error("index maintenance failure in {capability:?}: {message}")]
217    IndexMaintenance {
218        capability: StorageCapability,
219        message: String,
220    },
221
222    #[error("backend driver error in {capability:?} during {operation}: {source}")]
223    Driver {
224        capability: StorageCapability,
225        operation: Cow<'static, str>,
226        #[source]
227        source: Box<dyn StdError + Send + Sync>,
228    },
229
230    /// The bounded write-queue channel (ADR-067 Component A) did not free
231    /// capacity within the caller-supplied deadline. Returned only when a
232    /// caller wraps `WriterTaskHandle::send`'s `channel.send().await` in a
233    /// `tokio::time::timeout`; there is no immediate-error `try_send` path.
234    #[error("write queue full: timed out after {timeout_ms}ms waiting for writer task capacity")]
235    WriteQueueFull { timeout_ms: u64 },
236
237    /// SQLite refused the writer task's `BEGIN IMMEDIATE` with
238    /// `SQLITE_BUSY`/`SQLITE_LOCKED` until the configured busy timeout. The
239    /// queue accepted the request, but its operation closure was never
240    /// invoked, so retrying that one failed operation is safe.
241    #[error(
242        "writer task could not begin within {timeout_ms}ms because SQLite remained busy; request was not executed"
243    )]
244    WriterTaskBusy { timeout_ms: u64 },
245
246    /// One transaction-wrapped writer request returned an error, and the
247    /// writer then proved that request's SQLite transaction was rolled back.
248    /// Unlike [`StorageError::WriterTaskTerminated`], this does not retire the
249    /// writer seam. `source` preserves the operation or COMMIT error so its
250    /// capability and retry policy remain independently inspectable.
251    #[error("writer task request failed (request_state={request_state}): {source}")]
252    WriterTaskRequestFailed {
253        request_state: WriterTaskRequestState,
254        #[source]
255        source: Box<StorageError>,
256    },
257
258    /// A single-writer execution seam has terminated permanently. This is the
259    /// historical writer-task variant and display name; the fail-closed
260    /// pool-mutex fallback also uses it when transaction finalization becomes
261    /// terminal. The state identifies what is known about this request at
262    /// that boundary. Retrying on the same pool cannot recover the retired
263    /// writer seam.
264    #[error("writer task terminated (request_state={request_state})")]
265    WriterTaskTerminated {
266        request_state: WriterTaskRequestState,
267        /// Original SQLITE_FULL primary/extended codes, when the terminal cause supplied them.
268        /// Evidence only: it does not change finality, retryability or the error source chain.
269        sqlite_full_codes: Option<(i32, i32)>,
270    },
271
272    /// An internal storage failure not attributable to a specific storage
273    /// capability.
274    #[error("internal storage error: {0}")]
275    Internal(String),
276
277    /// `KHIVE_WRITE_QUEUE=1` is set but the calling thread has no Tokio
278    /// runtime context, so the writer task cannot be spawned (ADR-067
279    /// Component A). Returned instead of panicking.
280    /// See `crates/khive-storage/docs/api/error-taxonomy.md#writertasknoruntime`.
281    #[error(
282        "KHIVE_WRITE_QUEUE=1 but no Tokio runtime context is available to spawn the writer task"
283    )]
284    WriterTaskNoRuntime,
285
286    /// A filesystem-backed capability refused a write because its free-space
287    /// floor would be violated. Blob writes account for the known byte count;
288    /// SQLite admission cannot know the next transaction's size.
289    #[error(
290        "refusing write on {capability:?} at {volume}: {available_bytes} bytes available, \
291         the {floor_bytes}-byte free-space floor plus {required_headroom_bytes} bytes of \
292         operation headroom would be violated"
293    )]
294    CapacityFloor {
295        capability: StorageCapability,
296        volume: String,
297        available_bytes: u64,
298        floor_bytes: u64,
299        required_headroom_bytes: u64,
300    },
301
302    /// A new logical write could not establish disk-admission safety.
303    #[error("capacity admission unavailable for {capability:?} in {phase} phase: {message}")]
304    CapacityUnavailable {
305        capability: StorageCapability,
306        phase: CapacityUnavailablePhase,
307        message: String,
308    },
309}
310
311impl StorageError {
312    /// Construct a terminal writer outcome without native SQLite evidence.
313    ///
314    /// The request state and existing retry policy are unchanged.
315    pub fn writer_task_terminated(request_state: WriterTaskRequestState) -> Self {
316        Self::WriterTaskTerminated {
317            request_state,
318            sqlite_full_codes: None,
319        }
320    }
321
322    /// Construct a `Driver` error wrapping a backend-specific error source.
323    pub fn driver(
324        capability: StorageCapability,
325        operation: impl Into<Cow<'static, str>>,
326        source: impl StdError + Send + Sync + 'static,
327    ) -> Self {
328        Self::Driver {
329            capability,
330            operation: operation.into(),
331            source: Box::new(source),
332        }
333    }
334
335    /// Return the storage capability surface that produced this error, if any.
336    pub fn capability(&self) -> Option<StorageCapability> {
337        match self {
338            Self::NotFound { capability, .. }
339            | Self::AlreadyExists { capability, .. }
340            | Self::Conflict { capability, .. }
341            | Self::InvalidInput { capability, .. }
342            | Self::Unsupported { capability, .. }
343            | Self::Serialization { capability, .. }
344            | Self::IndexMaintenance { capability, .. }
345            | Self::Driver { capability, .. }
346            | Self::CapacityFloor { capability, .. }
347            | Self::CapacityUnavailable { capability, .. } => Some(*capability),
348            Self::BlobTooLarge { .. }
349            | Self::BlobSizeMismatch { .. }
350            | Self::BlobDigestMismatch { .. } => Some(StorageCapability::Blob),
351            Self::WriterTaskRequestFailed { source, .. } => source.capability(),
352            Self::Pool { .. }
353            | Self::Timeout { .. }
354            | Self::AdmissionTimeout { .. }
355            | Self::Transaction { .. }
356            | Self::ReadTransactionAgeEvicted { .. }
357            | Self::ReadTransactionAgeEvictionCleanupFailed { .. }
358            | Self::WriteQueueFull { .. }
359            | Self::WriterTaskBusy { .. }
360            | Self::WriterTaskTerminated { .. }
361            | Self::Internal(..)
362            | Self::WriterTaskNoRuntime => None,
363        }
364    }
365
366    /// Whether this error is transient and the operation may succeed on retry.
367    pub fn is_retryable(&self) -> bool {
368        if let Self::WriterTaskRequestFailed { source, .. } = self {
369            return source.is_retryable();
370        }
371        matches!(
372            self,
373            Self::Pool { .. }
374                | Self::Timeout { .. }
375                | Self::AdmissionTimeout { .. }
376                | Self::Transaction { .. }
377                | Self::ReadTransactionAgeEvicted { .. }
378                | Self::ReadTransactionAgeEvictionCleanupFailed { .. }
379                | Self::WriteQueueFull { .. }
380                | Self::WriterTaskBusy { .. }
381        )
382    }
383
384    /// Whether this error is an FTS5 query-parser rejection of the MATCH
385    /// expression itself, as opposed to a connection/pool/driver-level
386    /// failure of the text-search backend.
387    ///
388    /// True only for `Driver` errors from the `Text` capability at the
389    /// `fts_search` operation whose message names one of SQLite's FTS5
390    /// parser failure modes (syntax error, stack overflow, unsupported
391    /// column/phrase/NEAR query); all other errors return `false`.
392    ///
393    /// Callers that fail-open the FTS leg of a hybrid search (degrading to
394    /// vector-only results on a bad query string) MUST gate on this
395    /// predicate rather than on `StorageError` broadly — treating every
396    /// `Err` as degradable turns a real backend outage into a silently-empty
397    /// "successful" search (issue #389).
398    /// See `crates/khive-storage/docs/api/error-taxonomy.md#is_fts5_syntax_error`.
399    pub fn is_fts5_syntax_error(&self) -> bool {
400        if let Self::WriterTaskRequestFailed { source, .. } = self {
401            return source.is_fts5_syntax_error();
402        }
403        let Self::Driver {
404            capability,
405            operation,
406            source,
407        } = self
408        else {
409            return false;
410        };
411        if *capability != StorageCapability::Text || operation.as_ref() != "fts_search" {
412            return false;
413        }
414        let msg = source.to_string();
415        msg.contains("fts5: syntax error")
416            || msg.contains("fts5: parser stack overflow")
417            || msg.contains("fts5: column queries are not supported")
418            || msg.contains("fts5: phrase queries are not supported (detail")
419            || msg.contains("fts5: NEAR queries are not supported (detail")
420    }
421
422    /// Whether this error is a UNIQUE constraint violation from a raw SQL
423    /// `execute` (e.g. an `INSERT` racing an existing row under a natural
424    /// key). True only for `Driver` errors from the `Sql` capability whose
425    /// `operation` is one of `execute`, `pool_writer.execute`, or
426    /// `tx.execute`, and whose message contains `UNIQUE constraint failed`.
427    /// Batch/script operations are intentionally excluded.
428    ///
429    /// Callers that treat exact-key duplicates as a tolerated no-op
430    /// (ADR-081 §4 serve-ledger idempotency) MUST gate on this predicate
431    /// rather than swallowing every `Driver` error at `execute` — that would
432    /// also hide genuine write failures (disk full, corruption).
433    /// See `crates/khive-storage/docs/api/error-taxonomy.md#is_unique_constraint_violation`.
434    pub fn is_unique_constraint_violation(&self) -> bool {
435        if let Self::WriterTaskRequestFailed { source, .. } = self {
436            return source.is_unique_constraint_violation();
437        }
438        let Self::Driver {
439            capability,
440            operation,
441            source,
442        } = self
443        else {
444            return false;
445        };
446        if *capability != StorageCapability::Sql {
447            return false;
448        }
449        if !matches!(
450            operation.as_ref(),
451            "execute" | "pool_writer.execute" | "tx.execute"
452        ) {
453            return false;
454        }
455        source.to_string().contains("UNIQUE constraint failed")
456    }
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462    use std::fmt;
463
464    #[derive(Debug)]
465    struct FakeSource(String);
466
467    impl fmt::Display for FakeSource {
468        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
469            write!(f, "{}", self.0)
470        }
471    }
472
473    impl StdError for FakeSource {}
474
475    fn driver_err(operation: &'static str, message: &str) -> StorageError {
476        StorageError::driver(
477            StorageCapability::Text,
478            operation,
479            FakeSource(message.into()),
480        )
481    }
482
483    #[test]
484    fn writer_task_request_state_display_is_stable() {
485        assert_eq!(
486            WriterTaskRequestState::NotStarted.to_string(),
487            "not_started"
488        );
489        assert_eq!(
490            WriterTaskRequestState::TransactionRolledBack.to_string(),
491            "transaction_rolled_back"
492        );
493        assert_eq!(
494            WriterTaskRequestState::SideEffectsUnknown.to_string(),
495            "side_effects_unknown"
496        );
497    }
498
499    #[test]
500    fn writer_task_busy_is_retryable_without_claiming_queue_rejection() {
501        let error = StorageError::WriterTaskBusy { timeout_ms: 175 };
502        assert!(error.is_retryable());
503        assert_eq!(
504            error.to_string(),
505            "writer task could not begin within 175ms because SQLite remained busy; request was not executed"
506        );
507        assert_eq!(error.capability(), None);
508    }
509
510    #[test]
511    fn writer_task_request_failure_preserves_source_policy_and_rollback_state() {
512        let error = StorageError::WriterTaskRequestFailed {
513            request_state: WriterTaskRequestState::TransactionRolledBack,
514            source: Box::new(StorageError::Pool {
515                operation: "writer_task_commit".into(),
516                message: "commit refused".into(),
517            }),
518        };
519
520        assert_eq!(error.capability(), None);
521        assert!(
522            error.is_retryable(),
523            "rollback finality must not discard the source error's retry policy"
524        );
525        assert_eq!(
526            error.to_string(),
527            "writer task request failed (request_state=transaction_rolled_back): pool failure during writer_task_commit: commit refused"
528        );
529        assert_eq!(
530            StdError::source(&error).map(ToString::to_string),
531            Some("pool failure during writer_task_commit: commit refused".to_string()),
532            "the original typed storage error must remain the public source"
533        );
534    }
535
536    #[test]
537    fn writer_task_request_failure_does_not_invent_retryability() {
538        let error = StorageError::WriterTaskRequestFailed {
539            request_state: WriterTaskRequestState::TransactionRolledBack,
540            source: Box::new(StorageError::InvalidInput {
541                capability: StorageCapability::Notes,
542                operation: "append_note".into(),
543                message: "deterministic refusal".into(),
544            }),
545        };
546
547        assert!(!error.is_retryable());
548        assert_eq!(error.capability(), Some(StorageCapability::Notes));
549    }
550
551    #[test]
552    fn writer_task_terminated_is_uncapability_scoped_and_not_retryable() {
553        for request_state in [
554            WriterTaskRequestState::NotStarted,
555            WriterTaskRequestState::TransactionRolledBack,
556            WriterTaskRequestState::SideEffectsUnknown,
557        ] {
558            let error = StorageError::writer_task_terminated(request_state);
559            assert_eq!(error.capability(), None);
560            assert!(!error.is_retryable());
561            assert_eq!(
562                error.to_string(),
563                format!("writer task terminated (request_state={request_state})")
564            );
565        }
566    }
567
568    #[test]
569    fn blob_integrity_errors_are_blob_scoped_and_not_retryable() {
570        let requested = crate::blob::ContentRef::from_hex("a".repeat(64)).unwrap();
571        let actual = crate::blob::ContentRef::from_hex("b".repeat(64)).unwrap();
572        let errors = [
573            StorageError::BlobTooLarge {
574                content_ref: requested.clone(),
575                max_bytes: 8,
576                observed_at_least: 9,
577            },
578            StorageError::BlobSizeMismatch {
579                content_ref: requested.clone(),
580                metadata_bytes: 7,
581                actual_bytes: 8,
582            },
583            StorageError::BlobDigestMismatch {
584                expected: requested,
585                actual,
586            },
587        ];
588
589        for error in errors {
590            assert_eq!(error.capability(), Some(StorageCapability::Blob));
591            assert!(!error.is_retryable());
592        }
593    }
594
595    #[test]
596    fn fts5_syntax_error_at_fts_search_is_classified_as_syntax_error() {
597        let e = driver_err("fts_search", "fts5: syntax error near \"@\"");
598        assert!(e.is_fts5_syntax_error());
599    }
600
601    #[test]
602    fn fts5_parser_stack_overflow_is_classified_as_syntax_error() {
603        let e = driver_err("fts_search", "fts5: parser stack overflow");
604        assert!(e.is_fts5_syntax_error());
605    }
606
607    #[test]
608    fn fts5_unsupported_column_query_is_classified_as_syntax_error() {
609        let e = driver_err(
610            "fts_search",
611            "fts5: column queries are not supported (detail=none)",
612        );
613        assert!(e.is_fts5_syntax_error());
614    }
615
616    #[test]
617    fn timeout_is_not_classified_as_syntax_error() {
618        let e = StorageError::Timeout {
619            operation: "fts_search".into(),
620        };
621        assert!(!e.is_fts5_syntax_error());
622    }
623
624    #[test]
625    fn pool_failure_is_not_classified_as_syntax_error() {
626        let e = StorageError::Pool {
627            operation: "fts_search".into(),
628            message: "pool exhausted".into(),
629        };
630        assert!(!e.is_fts5_syntax_error());
631    }
632
633    #[test]
634    fn driver_error_at_non_search_operation_is_not_classified_as_syntax_error() {
635        let e = driver_err("open_fts_reader", "fts5: syntax error near \"@\"");
636        assert!(!e.is_fts5_syntax_error());
637    }
638
639    #[test]
640    fn driver_error_with_unrelated_message_is_not_classified_as_syntax_error() {
641        let e = driver_err("fts_search", "disk I/O error");
642        assert!(!e.is_fts5_syntax_error());
643    }
644
645    #[test]
646    fn fts5_phrase_detail_query_is_classified_as_syntax_error() {
647        let e = driver_err(
648            "fts_search",
649            "fts5: phrase queries are not supported (detail!=full)",
650        );
651        assert!(e.is_fts5_syntax_error());
652    }
653
654    #[test]
655    fn fts5_near_detail_query_is_classified_as_syntax_error() {
656        let e = driver_err(
657            "fts_search",
658            "fts5: NEAR queries are not supported (detail!=full)",
659        );
660        assert!(e.is_fts5_syntax_error());
661    }
662
663    #[test]
664    fn unprefixed_detail_message_is_not_classified_as_syntax_error() {
665        let e = driver_err(
666            "fts_search",
667            "phrase queries are not supported (detail!=full)",
668        );
669        assert!(!e.is_fts5_syntax_error());
670    }
671
672    #[test]
673    fn fts5_shadow_table_corruption_is_not_classified_as_syntax_error() {
674        let e = driver_err(
675            "fts_search",
676            "fts5: error creating shadow table notes_content: no such table",
677        );
678        assert!(!e.is_fts5_syntax_error());
679    }
680
681    #[test]
682    fn non_text_capability_is_not_classified_as_syntax_error() {
683        let e = StorageError::Driver {
684            capability: StorageCapability::Vectors,
685            operation: "fts_search".into(),
686            source: Box::new(FakeSource("fts5: syntax error near \"@\"".into())),
687        };
688        assert!(!e.is_fts5_syntax_error());
689    }
690
691    fn driver_err_sql(operation: &'static str, message: &str) -> StorageError {
692        StorageError::driver(
693            StorageCapability::Sql,
694            operation,
695            FakeSource(message.into()),
696        )
697    }
698
699    #[test]
700    fn unique_constraint_failure_at_execute_sql_capability_is_classified() {
701        let e = driver_err_sql(
702            "execute",
703            "UNIQUE constraint failed: brain_serve_ledger.namespace, \
704             brain_serve_ledger.target_id, brain_serve_ledger.query_class, \
705             brain_serve_ledger.served_at",
706        );
707        assert!(e.is_unique_constraint_violation());
708    }
709
710    #[test]
711    fn unique_constraint_failure_at_pool_writer_execute_is_classified() {
712        let e = driver_err_sql("pool_writer.execute", "UNIQUE constraint failed: t.id");
713        assert!(e.is_unique_constraint_violation());
714    }
715
716    #[test]
717    fn unique_constraint_message_at_non_execute_operation_is_not_classified() {
718        let e = driver_err_sql("query_row", "UNIQUE constraint failed: t.id");
719        assert!(!e.is_unique_constraint_violation());
720    }
721
722    #[test]
723    fn non_unique_driver_error_at_execute_is_not_classified() {
724        let e = driver_err_sql("execute", "disk I/O error");
725        assert!(!e.is_unique_constraint_violation());
726    }
727
728    #[test]
729    fn non_sql_capability_is_not_classified_as_unique_violation() {
730        let e = driver_err("execute", "UNIQUE constraint failed: t.id");
731        assert!(!e.is_unique_constraint_violation());
732    }
733
734    #[test]
735    fn timeout_is_not_classified_as_unique_violation() {
736        let e = StorageError::Timeout {
737            operation: "execute".into(),
738        };
739        assert!(!e.is_unique_constraint_violation());
740    }
741}