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