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}