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}