Skip to main content

nodedb_array/tile/
cell_payload.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Bitemporal cell payload for the array engine.
4//!
5//! `CellPayload` wraps the cell's attribute values with valid-time bounds and
6//! a Control-Plane-allocated surrogate. The sentinel constants mirror the
7//! graph engine's key sentinels so storage layer code can apply the same
8//! tombstone / GDPR-erasure distinction to array cells.
9
10pub use nodedb_types::OPEN_UPPER;
11use nodedb_types::Surrogate;
12use serde::{Deserialize, Serialize};
13
14use crate::error::{ArrayError, ArrayResult};
15use crate::types::cell_value::value::CellValue;
16
17/// Soft-delete marker for a cell version.
18pub const CELL_TOMBSTONE_SENTINEL: &[u8] = &[0xFF];
19
20/// GDPR erasure marker — preserves coordinate existence, removes content.
21pub const CELL_GDPR_ERASURE_SENTINEL: &[u8] = &[0xFE];
22
23/// Bitemporal cell value payload.
24///
25/// `valid_until_ms` uses [`OPEN_UPPER`] (`i64::MAX`) as the open-upper sentinel.
26#[derive(
27    Debug,
28    Clone,
29    PartialEq,
30    Serialize,
31    Deserialize,
32    zerompk::ToMessagePack,
33    zerompk::FromMessagePack,
34)]
35pub struct CellPayload {
36    pub valid_from_ms: i64,
37    pub valid_until_ms: i64,
38    pub attrs: Vec<CellValue>,
39    pub surrogate: Surrogate,
40}
41
42impl CellPayload {
43    /// Encode a live cell. A live cell always holds a bound surrogate, so a
44    /// payload that carries `Surrogate::ZERO` is refused.
45    pub fn encode(&self) -> ArrayResult<Vec<u8>> {
46        self.check_bound()?;
47        zerompk::to_msgpack_vec(self).map_err(|e| ArrayError::SegmentCorruption {
48            detail: format!("encode CellPayload: {e}"),
49        })
50    }
51
52    /// Decode a live cell, refusing one that carries `Surrogate::ZERO`.
53    pub fn decode(bytes: &[u8]) -> ArrayResult<Self> {
54        let payload: Self =
55            zerompk::from_msgpack(bytes).map_err(|e| ArrayError::SegmentCorruption {
56                detail: format!("decode CellPayload: {e}"),
57            })?;
58        payload.check_bound()?;
59        Ok(payload)
60    }
61
62    fn check_bound(&self) -> ArrayResult<()> {
63        if self.surrogate == Surrogate::ZERO {
64            return Err(ArrayError::SegmentCorruption {
65                detail: "live cell payload carries Surrogate::ZERO, which names no row".into(),
66            });
67        }
68        Ok(())
69    }
70}
71
72/// Returns true if `bytes` is any non-payload sentinel byte sequence.
73pub fn is_cell_sentinel(bytes: &[u8]) -> bool {
74    is_cell_tombstone(bytes) || is_cell_gdpr_erasure(bytes)
75}
76
77/// Returns true if `bytes` is the soft-delete tombstone sentinel.
78pub fn is_cell_tombstone(bytes: &[u8]) -> bool {
79    bytes == CELL_TOMBSTONE_SENTINEL
80}
81
82/// Returns true if `bytes` is the GDPR erasure sentinel.
83pub fn is_cell_gdpr_erasure(bytes: &[u8]) -> bool {
84    bytes == CELL_GDPR_ERASURE_SENTINEL
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use nodedb_types::Surrogate;
91
92    fn sample_payload() -> CellPayload {
93        CellPayload {
94            valid_from_ms: 1_000,
95            valid_until_ms: OPEN_UPPER,
96            attrs: vec![CellValue::Int64(42)],
97            surrogate: Surrogate::new(7),
98        }
99    }
100
101    #[test]
102    fn a_payload_under_zero_is_refused() {
103        let mut p = sample_payload();
104        p.surrogate = Surrogate::ZERO;
105        assert!(p.encode().is_err());
106        let raw = zerompk::to_msgpack_vec(&p).unwrap();
107        assert!(CellPayload::decode(&raw).is_err());
108    }
109
110    #[test]
111    fn payload_msgpack_roundtrip() {
112        let p = sample_payload();
113        let bytes = p.encode().unwrap();
114        let decoded = CellPayload::decode(&bytes).unwrap();
115        assert_eq!(decoded.valid_from_ms, 1_000);
116        assert_eq!(decoded.valid_until_ms, OPEN_UPPER);
117        assert_eq!(decoded.attrs, vec![CellValue::Int64(42)]);
118        assert_eq!(decoded.surrogate, Surrogate::new(7));
119    }
120
121    #[test]
122    fn encoded_payload_first_byte_is_fixarray() {
123        let bytes = sample_payload().encode().unwrap();
124        // zerompk encodes a 4-field struct as fixarray 0x94.
125        assert_eq!(bytes[0], 0x94);
126    }
127
128    #[test]
129    fn sentinels_are_disjoint_from_payload() {
130        let bytes = sample_payload().encode().unwrap();
131        assert!(is_cell_sentinel(&[0xFF]));
132        assert!(is_cell_sentinel(&[0xFE]));
133        assert!(is_cell_tombstone(&[0xFF]));
134        assert!(is_cell_gdpr_erasure(&[0xFE]));
135        assert!(!is_cell_sentinel(&bytes[..1]));
136    }
137
138    #[test]
139    fn tombstone_and_erasure_are_distinct() {
140        assert!(!is_cell_tombstone(CELL_GDPR_ERASURE_SENTINEL));
141        assert!(!is_cell_gdpr_erasure(CELL_TOMBSTONE_SENTINEL));
142    }
143}