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.
57/// `ChecksumError::NonCanonicalContent` (B1, 2026-05-22) is a writer-side
58/// rejection of cross-impl-divergent content (non-ASCII keys, subnormal
59/// floats); also routed through `Codec::Encode` since it's an encode-time
60/// failure surfaced from the same checksum codepath.
61impl From<ChecksumError> for StorageError {
62    fn from(e: ChecksumError) -> Self {
63        match e {
64            ChecksumError::CanonicalJsonFailed(err) => {
65                StorageError::Codec(CodecError::Encode(err.to_string()))
66            }
67            ChecksumError::NonCanonicalContent { reason } => {
68                StorageError::Codec(CodecError::Encode(reason))
69            }
70        }
71    }
72}
73
74/// Replay-time failures from `Graph::restore_snapshot({ mode: "diff" })`.
75#[derive(Debug, Error)]
76pub enum RestoreError {
77    /// A phase's `batch()` rejected. `lifecycle` and `frame_seq` identify the
78    /// boundary; `message` carries the underlying cause. Earlier phases stay
79    /// committed (Q2 partial-restore semantics).
80    #[error("restore phase {lifecycle:?} failed at frame_seq={frame_seq}: {message}")]
81    PhaseFailed {
82        lifecycle: Lifecycle,
83        frame_seq: u64,
84        message: String,
85    },
86
87    /// A mid-stream frame's checksum mismatched and the
88    /// `on_torn_write` policy resolved to "abort". The Q3 default is
89    /// "abort" for mid-stream (vs "skip" for WAL tail).
90    #[error("torn write mid-stream at frame_seq={frame_seq}: {reason}")]
91    TornWriteMidStream { frame_seq: u64, reason: String },
92
93    /// `restore_snapshot({ mode: "diff" })` ran with no `mode:"full"` baseline
94    /// in the snapshot tier — replay needs a starting point.
95    #[error("no mode=full baseline in snapshot tier; replay requires a baseline")]
96    BaselineMissing,
97
98    /// A frame's `format_version` doesn't match the tier's configured codec.
99    /// Distinct from [`StorageError::CodecMismatch`] which is tier-level;
100    /// this one is replay-time.
101    #[error("codec mismatch at frame_seq={frame_seq}: expected {expected:?}, found {found:?}")]
102    CodecMismatch {
103        frame_seq: u64,
104        expected: String,
105        found: String,
106    },
107
108    /// Caller passed a `source: { tier }` shape without a `wal_tier` when the
109    /// snapshot tier itself doesn't carry WAL frames. Distinct from
110    /// [`StorageError::BackendNoListSupport`] because the diagnostic is
111    /// "you forgot to wire the WAL tier", not "the backend can't enumerate".
112    #[error("restore requires a wal_tier when the snapshot tier does not carry WAL frames")]
113    WalTierRequired,
114}
115
116/// Per-lifecycle phase telemetry within a [`RestoreResult`].
117#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
118pub struct PhaseStat {
119    pub lifecycle: Lifecycle,
120    pub frames: u64,
121}
122
123/// Telemetry returned by a successful `restore_snapshot({ mode: "diff" })`
124/// call. Every field is observable so tests + dry-run audit paths can pin
125/// replay invariants (CLAUDE.md dry-run equivalence rule).
126#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
127pub struct RestoreResult {
128    /// Total frames applied across all phases.
129    pub replayed_frames: u64,
130    /// Frames dropped due to a tail torn-write under `on_torn_write: "skip"`.
131    pub skipped_frames: u64,
132    /// Highest `frame_seq` applied (zero if no frames replayed).
133    pub final_seq: u64,
134    /// Per-lifecycle phase breakdown in cross-scope replay order.
135    pub phases: Vec<PhaseStat>,
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn restore_result_discriminants_present() {
144        let r = RestoreResult {
145            replayed_frames: 3,
146            skipped_frames: 0,
147            final_seq: 42,
148            phases: vec![
149                PhaseStat {
150                    lifecycle: Lifecycle::Spec,
151                    frames: 1,
152                },
153                PhaseStat {
154                    lifecycle: Lifecycle::Data,
155                    frames: 2,
156                },
157            ],
158        };
159        assert_eq!(r.replayed_frames, 3);
160        assert_eq!(r.phases.len(), 2);
161        assert_eq!(r.phases[0].lifecycle, Lifecycle::Spec);
162    }
163
164    #[test]
165    fn storage_error_display_carries_tier_name() {
166        let e = StorageError::BackendNoListSupport {
167            tier: "memory".into(),
168        };
169        assert!(e.to_string().contains("memory"));
170    }
171
172    #[test]
173    fn restore_error_phase_failed_includes_seq() {
174        let e = RestoreError::PhaseFailed {
175            lifecycle: Lifecycle::Data,
176            frame_seq: 17,
177            message: "downstream invariant broken".into(),
178        };
179        let s = e.to_string();
180        assert!(s.contains("17"), "frame_seq missing from display: {s}");
181        assert!(s.contains("Data"), "lifecycle missing from display: {s}");
182    }
183}