Skip to main content

nodedb_lite/engine/document/history/
value.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Value encoding for versioned document history.
4//!
5//! Layout: `[tag:u8][valid_from_ms:i64 LE][valid_until_ms:i64 LE][body_msgpack...]`
6//!
7//! The 17-byte header encodes the tag and both temporal bounds. The remaining
8//! bytes are the raw MessagePack document body (empty for tombstones and
9//! GDPR-erased entries).
10
11use crate::error::LiteError;
12
13/// Minimum encoded value length: 1 (tag) + 8 (valid_from_ms) + 8 (valid_until_ms).
14const HEADER_LEN: usize = 17;
15
16/// Tag byte values for versioned document records.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18#[repr(u8)]
19pub enum VersionTag {
20    /// Document exists and is readable.
21    Live = 0x00,
22    /// Document was deleted; body is empty.
23    Tombstone = 0xFF,
24    /// Document was GDPR-erased; body is empty.
25    GdprErased = 0xFE,
26}
27
28impl VersionTag {
29    /// Parse a raw tag byte.
30    ///
31    /// Returns `None` for unrecognised tag values (forward-compatibility guard).
32    pub fn from_u8(v: u8) -> Option<Self> {
33        match v {
34            0x00 => Some(Self::Live),
35            0xFF => Some(Self::Tombstone),
36            0xFE => Some(Self::GdprErased),
37            _ => None,
38        }
39    }
40}
41
42/// Decoded view of a versioned document record.
43#[derive(Debug, Clone)]
44pub struct DecodedVersion {
45    /// Status tag for this version.
46    pub tag: VersionTag,
47    /// Valid-time lower bound (ms since epoch, inclusive).
48    pub valid_from_ms: i64,
49    /// Valid-time upper bound (ms since epoch, exclusive). `i64::MAX` = open.
50    pub valid_until_ms: i64,
51    /// Raw MessagePack body. Empty for tombstone / GDPR-erased entries.
52    pub body: Vec<u8>,
53}
54
55impl DecodedVersion {
56    /// Whether this version is a live (readable) document.
57    pub fn is_live(&self) -> bool {
58        self.tag == VersionTag::Live
59    }
60}
61
62/// Encode a versioned value payload.
63///
64/// The tag byte and both temporal bounds are written as a fixed 17-byte
65/// header followed by the raw `body` bytes.
66pub fn encode_value(
67    tag: VersionTag,
68    valid_from_ms: i64,
69    valid_until_ms: i64,
70    body: &[u8],
71) -> Vec<u8> {
72    let mut buf = Vec::with_capacity(HEADER_LEN + body.len());
73    buf.push(tag as u8);
74    buf.extend_from_slice(&valid_from_ms.to_le_bytes());
75    buf.extend_from_slice(&valid_until_ms.to_le_bytes());
76    buf.extend_from_slice(body);
77    buf
78}
79
80/// Decode a versioned value payload.
81///
82/// Returns an error if `bytes` is shorter than the 17-byte header or if the
83/// tag byte is not a recognised `VersionTag`.
84pub fn decode_value(bytes: &[u8]) -> Result<DecodedVersion, LiteError> {
85    if bytes.len() < HEADER_LEN {
86        return Err(LiteError::Serialization {
87            detail: format!(
88                "versioned document value too short: {} bytes (need at least {HEADER_LEN})",
89                bytes.len()
90            ),
91        });
92    }
93    let tag_byte = bytes[0];
94    let tag = VersionTag::from_u8(tag_byte).ok_or_else(|| LiteError::Serialization {
95        detail: format!("unknown version tag byte: 0x{tag_byte:02X}"),
96    })?;
97    let valid_from_ms = i64::from_le_bytes(
98        bytes[1..9]
99            .try_into()
100            .expect("length checked above — 8 bytes"),
101    );
102    let valid_until_ms = i64::from_le_bytes(
103        bytes[9..17]
104            .try_into()
105            .expect("length checked above — 8 bytes"),
106    );
107    Ok(DecodedVersion {
108        tag,
109        valid_from_ms,
110        valid_until_ms,
111        body: bytes[HEADER_LEN..].to_vec(),
112    })
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn encode_decode_live_roundtrip() {
121        let body = b"msgpack_body_here";
122        let encoded = encode_value(VersionTag::Live, 1_000, 2_000, body);
123        let decoded = decode_value(&encoded).unwrap();
124        assert_eq!(decoded.tag, VersionTag::Live);
125        assert_eq!(decoded.valid_from_ms, 1_000);
126        assert_eq!(decoded.valid_until_ms, 2_000);
127        assert_eq!(decoded.body, body);
128    }
129
130    #[test]
131    fn encode_decode_tombstone() {
132        let encoded = encode_value(VersionTag::Tombstone, 500, i64::MAX, &[]);
133        let decoded = decode_value(&encoded).unwrap();
134        assert_eq!(decoded.tag, VersionTag::Tombstone);
135        assert!(decoded.body.is_empty());
136    }
137
138    #[test]
139    fn decode_too_short_returns_error() {
140        assert!(decode_value(&[0x00; 16]).is_err());
141    }
142
143    #[test]
144    fn decode_unknown_tag_returns_error() {
145        let mut buf = vec![0u8; 17];
146        buf[0] = 0xAB; // unrecognised
147        assert!(decode_value(&buf).is_err());
148    }
149
150    #[test]
151    fn is_live_false_for_tombstone() {
152        let encoded = encode_value(VersionTag::Tombstone, 0, 0, &[]);
153        let decoded = decode_value(&encoded).unwrap();
154        assert!(!decoded.is_live());
155    }
156}