Skip to main content

nodedb_types/sync/
shape.rs

1//! Shape definition schema: parameterized boundaries for sync subscriptions.
2//!
3//! A "shape" defines what subset of the database a Lite client sees.
4//! Three shape types:
5//!
6//! - **Document shape**: `SELECT * FROM collection WHERE predicate`
7//! - **Graph shape**: N-hop subgraph from a root node
8//! - **Vector shape**: collection + optional namespace filter
9
10use serde::{Deserialize, Serialize};
11
12/// A shape definition: describes which data falls within the subscription.
13#[derive(
14    Debug,
15    Clone,
16    Serialize,
17    Deserialize,
18    rkyv::Archive,
19    rkyv::Serialize,
20    rkyv::Deserialize,
21    zerompk::ToMessagePack,
22    zerompk::FromMessagePack,
23)]
24pub struct ShapeDefinition {
25    /// Unique shape ID.
26    pub shape_id: String,
27    /// Tenant scope.
28    pub tenant_id: u32,
29    /// Shape type with parameters.
30    pub shape_type: ShapeType,
31    /// Human-readable description (for debugging).
32    pub description: String,
33    /// Optional field filter: only sync these fields (empty = all fields).
34    /// Enables selective field-level sync instead of full document sync.
35    #[serde(default)]
36    pub field_filter: Vec<String>,
37}
38
39/// Shape type: determines how mutations are evaluated for inclusion.
40#[derive(
41    Debug,
42    Clone,
43    Serialize,
44    Deserialize,
45    rkyv::Archive,
46    rkyv::Serialize,
47    rkyv::Deserialize,
48    zerompk::ToMessagePack,
49    zerompk::FromMessagePack,
50)]
51pub enum ShapeType {
52    /// Document shape: all documents in a collection matching a predicate.
53    ///
54    /// Predicate is serialized filter bytes (MessagePack). Empty = all documents.
55    Document {
56        collection: String,
57        /// Serialized predicate bytes. Empty = no filter (all documents).
58        predicate: Vec<u8>,
59    },
60
61    /// Graph shape: N-hop subgraph from root nodes.
62    ///
63    /// Includes all nodes and edges reachable within `max_depth` hops
64    /// from the root nodes. Edge label filter is optional.
65    Graph {
66        root_nodes: Vec<String>,
67        max_depth: usize,
68        edge_label: Option<String>,
69    },
70
71    /// Vector shape: all vectors in a collection/namespace.
72    ///
73    /// Optionally filtered by field_name (named vector fields).
74    Vector {
75        collection: String,
76        field_name: Option<String>,
77    },
78}
79
80impl ShapeDefinition {
81    /// Check if a mutation on a specific collection/document might match this shape.
82    ///
83    /// This is a fast pre-check — returns true if the mutation COULD match.
84    /// Actual predicate evaluation happens separately for document shapes.
85    pub fn could_match(&self, collection: &str, _doc_id: &str) -> bool {
86        match &self.shape_type {
87            ShapeType::Document {
88                collection: shape_coll,
89                ..
90            } => shape_coll == collection,
91            ShapeType::Graph { root_nodes, .. } => {
92                // Conservative: any mutation could affect graph nodes.
93                !root_nodes.is_empty()
94            }
95            ShapeType::Vector {
96                collection: shape_coll,
97                ..
98            } => shape_coll == collection,
99        }
100    }
101
102    /// Get the primary collection for this shape (if applicable).
103    pub fn collection(&self) -> Option<&str> {
104        match &self.shape_type {
105            ShapeType::Document { collection, .. } => Some(collection),
106            ShapeType::Vector { collection, .. } => Some(collection),
107            ShapeType::Graph { .. } => None,
108        }
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    #[test]
117    fn document_shape_matches_collection() {
118        let shape = ShapeDefinition {
119            shape_id: "s1".into(),
120            tenant_id: 1,
121            shape_type: ShapeType::Document {
122                collection: "orders".into(),
123                predicate: Vec::new(),
124            },
125            description: "all orders".into(),
126            field_filter: vec![],
127        };
128
129        assert!(shape.could_match("orders", "o1"));
130        assert!(!shape.could_match("users", "u1"));
131        assert_eq!(shape.collection(), Some("orders"));
132    }
133
134    #[test]
135    fn graph_shape() {
136        let shape = ShapeDefinition {
137            shape_id: "g1".into(),
138            tenant_id: 1,
139            shape_type: ShapeType::Graph {
140                root_nodes: vec!["alice".into()],
141                max_depth: 2,
142                edge_label: Some("KNOWS".into()),
143            },
144            description: "alice's network".into(),
145            field_filter: vec![],
146        };
147
148        assert!(shape.could_match("any_collection", "any_doc"));
149        assert_eq!(shape.collection(), None);
150    }
151
152    #[test]
153    fn vector_shape() {
154        let shape = ShapeDefinition {
155            shape_id: "v1".into(),
156            tenant_id: 1,
157            shape_type: ShapeType::Vector {
158                collection: "embeddings".into(),
159                field_name: Some("title".into()),
160            },
161            description: "title embeddings".into(),
162            field_filter: vec![],
163        };
164
165        assert!(shape.could_match("embeddings", "e1"));
166        assert!(!shape.could_match("other", "e1"));
167    }
168
169    #[test]
170    fn msgpack_roundtrip() {
171        let shape = ShapeDefinition {
172            shape_id: "test".into(),
173            tenant_id: 5,
174            shape_type: ShapeType::Document {
175                collection: "users".into(),
176                predicate: vec![1, 2, 3],
177            },
178            description: "test shape".into(),
179            field_filter: vec![],
180        };
181        let bytes = zerompk::to_msgpack_vec(&shape).unwrap();
182        let decoded: ShapeDefinition = zerompk::from_msgpack(&bytes).unwrap();
183        assert_eq!(decoded.shape_id, "test");
184        assert_eq!(decoded.tenant_id, 5);
185        assert!(matches!(decoded.shape_type, ShapeType::Document { .. }));
186    }
187}