Skip to main content

graphrefly_storage/
codec.rs

1//! Codec abstraction for tier serialization (Phase 14.6 — DS-14-storage Q4
2//! lock, M4.B 2026-05-10).
3//!
4//! Tiers parameterize over a [`Codec<T>`] to encode values before
5//! `backend.write` and decode bytes after `backend.read`. [`JsonCodec`] is the
6//! default (zero-sized, parity-encoded to match TS `jsonCodec`). `DagCbor` /
7//! `zstd` codecs land in later sub-slices when content-addressing scenarios
8//! surface.
9//!
10//! # Parity with TS `jsonCodec`
11//!
12//! TS `jsonCodec.encode` runs values through `stableJsonString` — recursive
13//! key-sort + `JSON.stringify(_, undefined, 0)`. [`JsonCodec`] mirrors via
14//! [`serde_json::to_value`] (BTreeMap-backed `Map`, sorted iteration) →
15//! [`serde_json::to_vec`]. Snapshot files written by the Rust impl are
16//! byte-identical to TS for the value schemas Graph emits (ASCII keys,
17//! integer numerics, no floats).
18
19use serde::{de::DeserializeOwned, Serialize};
20use thiserror::Error;
21
22/// Codec encode / decode failures. Stringified internally — the underlying
23/// `serde_json::Error` carries position info in its `Display` impl.
24#[derive(Debug, Error)]
25pub enum CodecError {
26    #[error("codec encode failed: {0}")]
27    Encode(String),
28
29    #[error("codec decode failed: {0}")]
30    Decode(String),
31}
32
33/// Codec for tier serialization. Tiers call `encode(value)` before
34/// `backend.write` and `decode(bytes)` after `backend.read`. `name` +
35/// `version` surface at the tier level for `format_version` migration (Q4).
36pub trait Codec<T>: Send + Sync {
37    /// Codec identifier (e.g. `"json"`, `"dag-cbor"`, `"dag-cbor-zstd"`).
38    fn name(&self) -> &str;
39    /// Codec version. Bumped when the on-wire format changes incompatibly.
40    fn version(&self) -> u32;
41    fn encode(&self, value: &T) -> Result<Vec<u8>, CodecError>;
42    fn decode(&self, bytes: &[u8]) -> Result<T, CodecError>;
43}
44
45/// Zero-sized JSON codec — UTF-8 text, canonical (sorted-key) JSON. Matches
46/// TS `jsonCodec` byte-for-byte on the value schemas Graph emits.
47#[derive(Debug, Default, Clone, Copy)]
48pub struct JsonCodec;
49
50impl<T> Codec<T> for JsonCodec
51where
52    T: Serialize + DeserializeOwned + Send + Sync,
53{
54    fn name(&self) -> &'static str {
55        "json"
56    }
57    fn version(&self) -> u32 {
58        1
59    }
60    fn encode(&self, value: &T) -> Result<Vec<u8>, CodecError> {
61        // Canonical (sorted-key) JSON via the BTreeMap-backed `Value` route —
62        // matches TS `stableJsonString`.
63        let v = serde_json::to_value(value).map_err(|e| CodecError::Encode(e.to_string()))?;
64        serde_json::to_vec(&v).map_err(|e| CodecError::Encode(e.to_string()))
65    }
66    fn decode(&self, bytes: &[u8]) -> Result<T, CodecError> {
67        serde_json::from_slice(bytes).map_err(|e| CodecError::Decode(e.to_string()))
68    }
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74    use serde::Deserialize;
75
76    #[derive(Serialize, Deserialize, Debug, PartialEq)]
77    struct Counter {
78        zebra: u32,
79        apple: u32,
80        monkey: u32,
81    }
82
83    #[test]
84    fn json_codec_round_trip() {
85        let codec = JsonCodec;
86        let v = Counter {
87            zebra: 1,
88            apple: 2,
89            monkey: 3,
90        };
91        let bytes = <JsonCodec as Codec<Counter>>::encode(&codec, &v).unwrap();
92        let back: Counter = <JsonCodec as Codec<Counter>>::decode(&codec, &bytes).unwrap();
93        assert_eq!(v, back);
94    }
95
96    #[test]
97    fn json_codec_canonical_sorts_keys() {
98        let codec = JsonCodec;
99        let v = Counter {
100            zebra: 1,
101            apple: 2,
102            monkey: 3,
103        };
104        let bytes = <JsonCodec as Codec<Counter>>::encode(&codec, &v).unwrap();
105        let s = std::str::from_utf8(&bytes).unwrap();
106        // Keys must appear in alphabetical order regardless of struct
107        // declaration order.
108        assert_eq!(s, r#"{"apple":2,"monkey":3,"zebra":1}"#);
109    }
110
111    #[test]
112    fn json_codec_name_and_version() {
113        let codec = JsonCodec;
114        assert_eq!(<JsonCodec as Codec<Counter>>::name(&codec), "json");
115        assert_eq!(<JsonCodec as Codec<Counter>>::version(&codec), 1);
116    }
117
118    #[test]
119    fn json_codec_decode_rejects_invalid_bytes() {
120        let codec = JsonCodec;
121        let result: Result<Counter, _> = <JsonCodec as Codec<Counter>>::decode(&codec, b"not json");
122        assert!(matches!(result, Err(CodecError::Decode(_))));
123    }
124}