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    pub fn encode(&self) -> ArrayResult<Vec<u8>> {
44        zerompk::to_msgpack_vec(self).map_err(|e| ArrayError::SegmentCorruption {
45            detail: format!("encode CellPayload: {e}"),
46        })
47    }
48
49    pub fn decode(bytes: &[u8]) -> ArrayResult<Self> {
50        zerompk::from_msgpack(bytes).map_err(|e| ArrayError::SegmentCorruption {
51            detail: format!("decode CellPayload: {e}"),
52        })
53    }
54}
55
56/// Returns true if `bytes` is any non-payload sentinel byte sequence.
57pub fn is_cell_sentinel(bytes: &[u8]) -> bool {
58    is_cell_tombstone(bytes) || is_cell_gdpr_erasure(bytes)
59}
60
61/// Returns true if `bytes` is the soft-delete tombstone sentinel.
62pub fn is_cell_tombstone(bytes: &[u8]) -> bool {
63    bytes == CELL_TOMBSTONE_SENTINEL
64}
65
66/// Returns true if `bytes` is the GDPR erasure sentinel.
67pub fn is_cell_gdpr_erasure(bytes: &[u8]) -> bool {
68    bytes == CELL_GDPR_ERASURE_SENTINEL
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74    use nodedb_types::Surrogate;
75
76    fn sample_payload() -> CellPayload {
77        CellPayload {
78            valid_from_ms: 1_000,
79            valid_until_ms: OPEN_UPPER,
80            attrs: vec![CellValue::Int64(42)],
81            surrogate: Surrogate::ZERO,
82        }
83    }
84
85    #[test]
86    fn payload_msgpack_roundtrip() {
87        let p = sample_payload();
88        let bytes = p.encode().unwrap();
89        let decoded = CellPayload::decode(&bytes).unwrap();
90        assert_eq!(decoded.valid_from_ms, 1_000);
91        assert_eq!(decoded.valid_until_ms, OPEN_UPPER);
92        assert_eq!(decoded.attrs, vec![CellValue::Int64(42)]);
93        assert_eq!(decoded.surrogate, Surrogate::ZERO);
94    }
95
96    #[test]
97    fn encoded_payload_first_byte_is_fixarray() {
98        let bytes = sample_payload().encode().unwrap();
99        // zerompk encodes a 4-field struct as fixarray 0x94.
100        assert_eq!(bytes[0], 0x94);
101    }
102
103    #[test]
104    fn sentinels_are_disjoint_from_payload() {
105        let bytes = sample_payload().encode().unwrap();
106        assert!(is_cell_sentinel(&[0xFF]));
107        assert!(is_cell_sentinel(&[0xFE]));
108        assert!(is_cell_tombstone(&[0xFF]));
109        assert!(is_cell_gdpr_erasure(&[0xFE]));
110        assert!(!is_cell_sentinel(&bytes[..1]));
111    }
112
113    #[test]
114    fn tombstone_and_erasure_are_distinct() {
115        assert!(!is_cell_tombstone(CELL_GDPR_ERASURE_SENTINEL));
116        assert!(!is_cell_gdpr_erasure(CELL_TOMBSTONE_SENTINEL));
117    }
118}