Skip to main content

nodedb_types/sync/
shape.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Shape definition schema: parameterized boundaries for sync subscriptions.
4//!
5//! A "shape" defines what subset of the database a Lite client sees.
6//! Three shape types:
7//!
8//! - **Document shape**: `SELECT * FROM collection WHERE predicate`
9//! - **Graph shape**: N-hop subgraph from a root node
10//! - **Vector shape**: collection + optional namespace filter
11
12use serde::{Deserialize, Serialize};
13
14/// A shape definition: describes which data falls within the subscription.
15#[derive(
16    Debug,
17    Clone,
18    Serialize,
19    Deserialize,
20    rkyv::Archive,
21    rkyv::Serialize,
22    rkyv::Deserialize,
23    zerompk::ToMessagePack,
24    zerompk::FromMessagePack,
25)]
26pub struct ShapeDefinition {
27    /// Unique shape ID.
28    pub shape_id: String,
29    /// Tenant scope.
30    pub tenant_id: u32,
31    /// Shape type with parameters.
32    pub shape_type: ShapeType,
33    /// Human-readable description (for debugging).
34    pub description: String,
35    /// Optional field filter: only sync these fields (empty = all fields).
36    /// Enables selective field-level sync instead of full document sync.
37    #[serde(default)]
38    pub field_filter: Vec<String>,
39}
40
41/// Coordinate range filter for array shapes.
42///
43/// Tile-aligned: subscriptions cover whole tiles; sub-tile filtering
44/// happens at receive time.
45/// Both `start` and `end` are inclusive, with `end` of `None` meaning
46/// "all coords from `start` onwards".
47///
48/// Defined here in `nodedb-types` (not in `nodedb-array`) to avoid
49/// adding a cross-crate dependency from `nodedb-types` on `nodedb-array`.
50/// Callers that hold a `nodedb_array::sync::snapshot::CoordRange` convert
51/// to this type before constructing a `ShapeType::Array`.
52#[derive(
53    Debug,
54    Clone,
55    PartialEq,
56    Eq,
57    Serialize,
58    Deserialize,
59    rkyv::Archive,
60    rkyv::Serialize,
61    rkyv::Deserialize,
62    zerompk::ToMessagePack,
63    zerompk::FromMessagePack,
64)]
65pub struct ArrayCoordRange {
66    /// Inclusive lower bound (one element per dimension).
67    pub start: Vec<u64>,
68    /// Inclusive upper bound (one element per dimension).
69    /// `None` = unbounded (all coords from `start` onwards).
70    pub end: Option<Vec<u64>>,
71}
72
73impl ArrayCoordRange {
74    /// Return `true` if `coord` falls within this range.
75    ///
76    /// Comparison is per-dimension lexicographic. A missing dimension in
77    /// `coord` compared to `start`/`end` causes the check to return `false`.
78    pub fn contains(&self, coord: &[u64]) -> bool {
79        if coord.len() != self.start.len() {
80            return false;
81        }
82        if coord < self.start.as_slice() {
83            return false;
84        }
85        if let Some(end) = &self.end
86            && (coord.len() != end.len() || coord > end.as_slice())
87        {
88            return false;
89        }
90        true
91    }
92}
93
94/// Shape type: determines how mutations are evaluated for inclusion.
95#[derive(
96    Debug,
97    Clone,
98    Serialize,
99    Deserialize,
100    rkyv::Archive,
101    rkyv::Serialize,
102    rkyv::Deserialize,
103    zerompk::ToMessagePack,
104    zerompk::FromMessagePack,
105)]
106#[serde(rename_all = "snake_case")]
107#[non_exhaustive]
108pub enum ShapeType {
109    /// Document shape: all documents in a collection matching a predicate.
110    ///
111    /// Predicate is serialized filter bytes (MessagePack). Empty = all documents.
112    #[serde(rename = "document")]
113    Document {
114        collection: String,
115        /// Serialized predicate bytes. Empty = no filter (all documents).
116        predicate: Vec<u8>,
117    },
118
119    /// Graph shape: N-hop subgraph from root nodes.
120    ///
121    /// Includes all nodes and edges reachable within `max_depth` hops
122    /// from the root nodes. Edge label filter is optional.
123    #[serde(rename = "graph")]
124    Graph {
125        root_nodes: Vec<String>,
126        max_depth: usize,
127        edge_label: Option<String>,
128    },
129
130    /// Vector shape: all vectors in a collection/namespace.
131    ///
132    /// Optionally filtered by field_name (named vector fields).
133    #[serde(rename = "vector")]
134    Vector {
135        collection: String,
136        field_name: Option<String>,
137    },
138
139    /// Array shape: all ops on a named ND-array within an optional
140    /// coordinate range.
141    ///
142    /// `coord_range = None` means "subscribe to all ops on this array".
143    /// When set, only ops whose coord falls within the range are delivered.
144    #[serde(rename = "array")]
145    Array {
146        array_name: String,
147        coord_range: Option<ArrayCoordRange>,
148    },
149}
150
151impl ShapeDefinition {
152    /// Check if a mutation on a specific collection/document might match this shape.
153    ///
154    /// This is a fast pre-check — returns true if the mutation COULD match.
155    /// Actual predicate evaluation happens separately for document shapes.
156    /// Returns `false` for `ShapeType::Array` (use `matches_array_op` instead).
157    pub fn could_match(&self, collection: &str, _doc_id: &str) -> bool {
158        match &self.shape_type {
159            ShapeType::Document {
160                collection: shape_coll,
161                ..
162            } => shape_coll == collection,
163            ShapeType::Graph { root_nodes, .. } => {
164                // Conservative: any mutation could affect graph nodes.
165                !root_nodes.is_empty()
166            }
167            ShapeType::Vector {
168                collection: shape_coll,
169                ..
170            } => shape_coll == collection,
171            ShapeType::Array { .. } => false,
172        }
173    }
174
175    /// Check if an array op on `array` at `coord` matches this shape.
176    ///
177    /// Returns `false` for non-Array shape types — use `could_match` for those.
178    /// `coord` is the raw dimension values cast to `u64` (unsigned index space).
179    pub fn matches_array_op(&self, array: &str, coord: &[u64]) -> bool {
180        match &self.shape_type {
181            ShapeType::Array {
182                array_name,
183                coord_range,
184            } => {
185                if array_name != array {
186                    return false;
187                }
188                match coord_range {
189                    None => true,
190                    Some(range) => range.contains(coord),
191                }
192            }
193            _ => false,
194        }
195    }
196
197    /// Get the primary collection for this shape (if applicable).
198    pub fn collection(&self) -> Option<&str> {
199        match &self.shape_type {
200            ShapeType::Document { collection, .. } => Some(collection),
201            ShapeType::Vector { collection, .. } => Some(collection),
202            ShapeType::Graph { .. } => None,
203            ShapeType::Array { .. } => None,
204        }
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn document_shape_matches_collection() {
214        let shape = ShapeDefinition {
215            shape_id: "s1".into(),
216            tenant_id: 1,
217            shape_type: ShapeType::Document {
218                collection: "orders".into(),
219                predicate: Vec::new(),
220            },
221            description: "all orders".into(),
222            field_filter: vec![],
223        };
224
225        assert!(shape.could_match("orders", "o1"));
226        assert!(!shape.could_match("users", "u1"));
227        assert_eq!(shape.collection(), Some("orders"));
228    }
229
230    #[test]
231    fn graph_shape() {
232        let shape = ShapeDefinition {
233            shape_id: "g1".into(),
234            tenant_id: 1,
235            shape_type: ShapeType::Graph {
236                root_nodes: vec!["alice".into()],
237                max_depth: 2,
238                edge_label: Some("KNOWS".into()),
239            },
240            description: "alice's network".into(),
241            field_filter: vec![],
242        };
243
244        assert!(shape.could_match("any_collection", "any_doc"));
245        assert_eq!(shape.collection(), None);
246    }
247
248    #[test]
249    fn vector_shape() {
250        let shape = ShapeDefinition {
251            shape_id: "v1".into(),
252            tenant_id: 1,
253            shape_type: ShapeType::Vector {
254                collection: "embeddings".into(),
255                field_name: Some("title".into()),
256            },
257            description: "title embeddings".into(),
258            field_filter: vec![],
259        };
260
261        assert!(shape.could_match("embeddings", "e1"));
262        assert!(!shape.could_match("other", "e1"));
263    }
264
265    #[test]
266    fn msgpack_roundtrip() {
267        let shape = ShapeDefinition {
268            shape_id: "test".into(),
269            tenant_id: 5,
270            shape_type: ShapeType::Document {
271                collection: "users".into(),
272                predicate: vec![1, 2, 3],
273            },
274            description: "test shape".into(),
275            field_filter: vec![],
276        };
277        let bytes = zerompk::to_msgpack_vec(&shape).unwrap();
278        let decoded: ShapeDefinition = zerompk::from_msgpack(&bytes).unwrap();
279        assert_eq!(decoded.shape_id, "test");
280        assert_eq!(decoded.tenant_id, 5);
281        assert!(matches!(decoded.shape_type, ShapeType::Document { .. }));
282    }
283
284    #[test]
285    fn array_shape_matches_array_op_no_range() {
286        let shape = ShapeDefinition {
287            shape_id: "a1".into(),
288            tenant_id: 1,
289            shape_type: ShapeType::Array {
290                array_name: "prices".into(),
291                coord_range: None,
292            },
293            description: "all prices".into(),
294            field_filter: vec![],
295        };
296        assert!(shape.matches_array_op("prices", &[0, 0]));
297        assert!(shape.matches_array_op("prices", &[999, 999]));
298        assert!(!shape.matches_array_op("other", &[0, 0]));
299        // could_match returns false for Array shapes.
300        assert!(!shape.could_match("prices", "x"));
301        assert_eq!(shape.collection(), None);
302    }
303
304    #[test]
305    fn array_shape_matches_array_op_with_range() {
306        let range = ArrayCoordRange {
307            start: vec![10, 10],
308            end: Some(vec![20, 20]),
309        };
310        let shape = ShapeDefinition {
311            shape_id: "a2".into(),
312            tenant_id: 1,
313            shape_type: ShapeType::Array {
314                array_name: "temps".into(),
315                coord_range: Some(range),
316            },
317            description: "temps sub-range".into(),
318            field_filter: vec![],
319        };
320        assert!(shape.matches_array_op("temps", &[10, 10]));
321        assert!(shape.matches_array_op("temps", &[15, 15]));
322        assert!(shape.matches_array_op("temps", &[20, 20]));
323        assert!(!shape.matches_array_op("temps", &[9, 9]));
324        assert!(!shape.matches_array_op("temps", &[21, 21]));
325        assert!(!shape.matches_array_op("temps", &[10])); // wrong ndim
326    }
327
328    #[test]
329    fn array_coord_range_msgpack_roundtrip() {
330        let range = ArrayCoordRange {
331            start: vec![1, 2, 3],
332            end: Some(vec![4, 5, 6]),
333        };
334        let bytes = zerompk::to_msgpack_vec(&range).unwrap();
335        let decoded: ArrayCoordRange = zerompk::from_msgpack(&bytes).unwrap();
336        assert_eq!(decoded.start, vec![1, 2, 3]);
337        assert_eq!(decoded.end, Some(vec![4, 5, 6]));
338    }
339
340    #[test]
341    fn array_shape_msgpack_roundtrip() {
342        let shape = ShapeDefinition {
343            shape_id: "a3".into(),
344            tenant_id: 7,
345            shape_type: ShapeType::Array {
346                array_name: "sensor_matrix".into(),
347                coord_range: Some(ArrayCoordRange {
348                    start: vec![0, 0],
349                    end: None,
350                }),
351            },
352            description: "half-open range".into(),
353            field_filter: vec![],
354        };
355        let bytes = zerompk::to_msgpack_vec(&shape).unwrap();
356        let decoded: ShapeDefinition = zerompk::from_msgpack(&bytes).unwrap();
357        assert_eq!(decoded.shape_id, "a3");
358        assert!(matches!(
359            decoded.shape_type,
360            ShapeType::Array { ref array_name, .. } if array_name == "sensor_matrix"
361        ));
362    }
363}