Skip to main content

graphrefly_storage/
error.rs

1//! Storage tier + WAL replay error types (Phase 14.6 — DS-14-storage Q3/Q9).
2//!
3//! - [`StorageError`] — tier-level preconditions (backend lacks capability,
4//!   codec mismatch, backend I/O failure). Surfaced by `BaseStorageTier`
5//!   operations (M4.B+).
6//! - [`RestoreError`] — replay-time failures from
7//!   `Graph::restore_snapshot({ mode: "diff" })` (M4.E). Distinct from
8//!   `StorageError` so callers can disambiguate "tier broken" from "replay
9//!   couldn't complete".
10//! - [`RestoreResult`] — telemetry returned on successful replay
11//!   (inspection-as-test-harness shape per CLAUDE.md dry-run rule).
12//!
13//! Both error enums mirror the TS impl at
14//! `packages/pure-ts/src/extra/storage/wal.ts:194-251`; variant names align
15//! with the TS string discriminants for parity-test diffability.
16
17use serde::Serialize;
18use thiserror::Error;
19
20use graphrefly_structures::Lifecycle;
21
22use crate::codec::CodecError;
23use crate::wal::ChecksumError;
24
25/// Tier-level preconditions surfaced by `BaseStorageTier` operations.
26#[derive(Debug, Error)]
27pub enum StorageError {
28    /// Caller invoked `list_by_prefix` on a backend that has no enumeration
29    /// support. Lazy-thrown on first stream-yield, not at attach.
30    #[error("storage tier {tier:?} does not support list_by_prefix; WAL replay requires it")]
31    BackendNoListSupport { tier: String },
32
33    /// Mixed codecs detected within a single WAL — replay refuses to proceed
34    /// because frame deserialization would silently corrupt downstream state.
35    #[error("codec mismatch: expected {expected:?}, found {found:?}")]
36    CodecMismatch { expected: String, found: String },
37
38    /// Codec encode / decode failed (typed values that don't round-trip
39    /// through `serde_json`, version-mismatch decode, etc.).
40    #[error("codec error: {0}")]
41    Codec(#[from] CodecError),
42
43    /// Wraps an underlying backend I/O failure (file system, redb transaction,
44    /// network round-trip). The `source` chain preserves the original error.
45    #[error("backend error: {message}")]
46    BackendError {
47        message: String,
48        #[source]
49        source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
50    },
51}
52
53/// Convenience: `wal_frame_checksum` / `verify_wal_frame_checksum` failures
54/// bubble through `?` at the tier-flush boundary without explicit mapping.
55/// `ChecksumError::CanonicalJsonFailed` was a `serde_json::Error` at root,
56/// which is a codec-encode failure — funnel through the `Codec` variant.
57impl From<ChecksumError> for StorageError {
58    fn from(e: ChecksumError) -> Self {
59        match e {
60            ChecksumError::CanonicalJsonFailed(err) => {
61                StorageError::Codec(CodecError::Encode(err.to_string()))
62            }
63        }
64    }
65}
66
67/// Replay-time failures from `Graph::restore_snapshot({ mode: "diff" })`.
68#[derive(Debug, Error)]
69pub enum RestoreError {
70    /// A phase's `batch()` rejected. `lifecycle` and `frame_seq` identify the
71    /// boundary; `message` carries the underlying cause. Earlier phases stay
72    /// committed (Q2 partial-restore semantics).
73    #[error("restore phase {lifecycle:?} failed at frame_seq={frame_seq}: {message}")]
74    PhaseFailed {
75        lifecycle: Lifecycle,
76        frame_seq: u64,
77        message: String,
78    },
79
80    /// A mid-stream frame's checksum mismatched and the
81    /// `on_torn_write` policy resolved to "abort". The Q3 default is
82    /// "abort" for mid-stream (vs "skip" for WAL tail).
83    #[error("torn write mid-stream at frame_seq={frame_seq}: {reason}")]
84    TornWriteMidStream { frame_seq: u64, reason: String },
85
86    /// `restore_snapshot({ mode: "diff" })` ran with no `mode:"full"` baseline
87    /// in the snapshot tier — replay needs a starting point.
88    #[error("no mode=full baseline in snapshot tier; replay requires a baseline")]
89    BaselineMissing,
90
91    /// A frame's `format_version` doesn't match the tier's configured codec.
92    /// Distinct from [`StorageError::CodecMismatch`] which is tier-level;
93    /// this one is replay-time.
94    #[error("codec mismatch at frame_seq={frame_seq}: expected {expected:?}, found {found:?}")]
95    CodecMismatch {
96        frame_seq: u64,
97        expected: String,
98        found: String,
99    },
100
101    /// Caller passed a `source: { tier }` shape without a `wal_tier` when the
102    /// snapshot tier itself doesn't carry WAL frames. Distinct from
103    /// [`StorageError::BackendNoListSupport`] because the diagnostic is
104    /// "you forgot to wire the WAL tier", not "the backend can't enumerate".
105    #[error("restore requires a wal_tier when the snapshot tier does not carry WAL frames")]
106    WalTierRequired,
107}
108
109/// Per-lifecycle phase telemetry within a [`RestoreResult`].
110#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
111pub struct PhaseStat {
112    pub lifecycle: Lifecycle,
113    pub frames: u64,
114}
115
116/// Telemetry returned by a successful `restore_snapshot({ mode: "diff" })`
117/// call. Every field is observable so tests + dry-run audit paths can pin
118/// replay invariants (CLAUDE.md dry-run equivalence rule).
119#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
120pub struct RestoreResult {
121    /// Total frames applied across all phases.
122    pub replayed_frames: u64,
123    /// Frames dropped due to a tail torn-write under `on_torn_write: "skip"`.
124    pub skipped_frames: u64,
125    /// Highest `frame_seq` applied (zero if no frames replayed).
126    pub final_seq: u64,
127    /// Per-lifecycle phase breakdown in cross-scope replay order.
128    pub phases: Vec<PhaseStat>,
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn restore_result_discriminants_present() {
137        let r = RestoreResult {
138            replayed_frames: 3,
139            skipped_frames: 0,
140            final_seq: 42,
141            phases: vec![
142                PhaseStat {
143                    lifecycle: Lifecycle::Spec,
144                    frames: 1,
145                },
146                PhaseStat {
147                    lifecycle: Lifecycle::Data,
148                    frames: 2,
149                },
150            ],
151        };
152        assert_eq!(r.replayed_frames, 3);
153        assert_eq!(r.phases.len(), 2);
154        assert_eq!(r.phases[0].lifecycle, Lifecycle::Spec);
155    }
156
157    #[test]
158    fn storage_error_display_carries_tier_name() {
159        let e = StorageError::BackendNoListSupport {
160            tier: "memory".into(),
161        };
162        assert!(e.to_string().contains("memory"));
163    }
164
165    #[test]
166    fn restore_error_phase_failed_includes_seq() {
167        let e = RestoreError::PhaseFailed {
168            lifecycle: Lifecycle::Data,
169            frame_seq: 17,
170            message: "downstream invariant broken".into(),
171        };
172        let s = e.to_string();
173        assert!(s.contains("17"), "frame_seq missing from display: {s}");
174        assert!(s.contains("Data"), "lifecycle missing from display: {s}");
175    }
176}