Skip to main content

notedthat_write/
error.rs

1//! Error types for the shared write path.
2
3use notedthat_core::{Error as CoreError, StorageError};
4
5/// What a write had already done to storage when a later step failed.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum WriteEffect {
8    /// The bytes are stored under the key.
9    Stored,
10    /// The key is gone.
11    Deleted,
12}
13
14impl std::fmt::Display for WriteEffect {
15    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16        f.write_str(match self {
17            Self::Stored => "stored",
18            Self::Deleted => "deleted",
19        })
20    }
21}
22
23/// Errors returned by shared write operations.
24#[derive(Debug, thiserror::Error)]
25pub enum WriteError {
26    /// Storage-layer failure.
27    #[error(transparent)]
28    Storage(StorageError),
29    /// Upload size exceeded the configured limit.
30    #[error("payload too large: {size} bytes (limit {limit})")]
31    TooLarge {
32        /// Actual byte size.
33        size: u64,
34        /// Maximum allowed byte size.
35        limit: u64,
36    },
37    /// Path/domain validation failure.
38    #[error(transparent)]
39    Path(CoreError),
40    /// Indexer queue was full while enqueueing an upsert.
41    #[error("indexer queue full during upsert")]
42    IndexerBackpressureUpsert,
43    /// Indexer queue was full while enqueueing a tombstone.
44    #[error("indexer queue full during tombstone")]
45    IndexerBackpressureTombstone,
46    /// The event log refused the change after storage had already taken it.
47    ///
48    /// Mirrors the indexer backpressure variants: the caller answers 503 with
49    /// `Retry-After` and a retried write publishes the event (D38, D55).
50    #[error("change event not published after the object was {after}")]
51    EventPublishFailed {
52        /// What storage had already done by the time publishing failed.
53        after: WriteEffect,
54    },
55    /// Object body exceeds the `NOTEDTHAT_MAX_PATCHABLE_SIZE` limit before or after splice.
56    #[error("patch payload too large: {size} bytes (limit {limit})")]
57    PatchTooLarge {
58        /// Actual byte size.
59        size: u64,
60        /// Maximum allowed byte size.
61        limit: u64,
62    },
63    /// Requested line range is beyond the end of the object at server-side splice time.
64    #[error("line range {first}..{last} out of range (total {total_lines} lines)")]
65    PatchLineOutOfRange {
66        /// Requested first line.
67        first: u64,
68        /// Requested last line.
69        last: u64,
70        /// Total line count in the object.
71        total_lines: u64,
72        /// Total byte count in the object (for X-Content-Range-Bytes).
73        total_bytes: u64,
74    },
75    /// Invalid range, mode contradiction, or missing If-Match for PATCH.
76    #[error("invalid patch request: {message}")]
77    PatchInvalidRange {
78        /// Human-readable reason.
79        message: String,
80    },
81    /// A body written to `.notedthat/manifest.json` is not the manifest startup
82    /// would accept: not a manifest document, naming another knowledge base, or
83    /// outside the limits `KbManifest::validate` enforces. The message is the
84    /// one the refused boot would have printed.
85    #[error("invalid manifest: {message}")]
86    InvalidManifest {
87        /// Human-readable reason, as startup would report it.
88        message: String,
89    },
90    /// Replace operation found no occurrence of the requested old string.
91    #[error("replace: no match found for old_string")]
92    ReplaceNoMatch,
93    /// Replace operation found multiple occurrences, making single replace ambiguous.
94    #[error("replace: found {count} matches; use replace_all to replace them all")]
95    ReplaceAmbiguous {
96        /// Number of matches found in the object body.
97        count: u64,
98    },
99}
100
101impl From<StorageError> for WriteError {
102    fn from(err: StorageError) -> Self {
103        Self::Storage(err)
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::WriteError;
110
111    #[test]
112    fn patch_too_large_displays_size_limit_when_constructed() {
113        let err = WriteError::PatchTooLarge {
114            size: 200 * 1024 * 1024,
115            limit: 100 * 1024 * 1024,
116        };
117
118        assert!(err.to_string().contains("too large"));
119    }
120
121    #[test]
122    fn patch_line_out_of_range_constructs_with_line_and_byte_totals() {
123        let err = WriteError::PatchLineOutOfRange {
124            first: 999,
125            last: 1000,
126            total_lines: 20,
127            total_bytes: 100,
128        };
129
130        assert!(err.to_string().contains("out of range"));
131    }
132
133    #[test]
134    fn patch_invalid_range_displays_reason_when_constructed() {
135        let err = WriteError::PatchInvalidRange {
136            message: "test".into(),
137        };
138
139        assert!(err.to_string().contains("invalid patch"));
140    }
141
142    #[test]
143    fn replace_ambiguous_displays_match_count_when_constructed() {
144        let err = WriteError::ReplaceAmbiguous { count: 3 };
145
146        assert_eq!(
147            err.to_string(),
148            "replace: found 3 matches; use replace_all to replace them all"
149        );
150    }
151}