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