Skip to main content

reddb_types/
catalog.rs

1//! Catalog AST-leaf descriptors (ADR 0053, RQL Phase 2 S4b).
2//!
3//! These descriptor types are referenced directly by the canonical SQL AST
4//! (`CreateTableQuery.{subscriptions, analytics_config}` and the `ALTER TABLE`
5//! event/analytics operations). Re-homing them into the neutral keystone crate
6//! removes a `QueryExpr -> reddb-server` leaf edge so the AST can later move to
7//! `reddb-io-rql` with no server dependency.
8//!
9//! The server's `crate::catalog` module keeps a re-export shim so existing
10//! call-sites stay untouched (byte-faithful move, same pattern as #1061/#1062).
11
12/// The logical multi-structure model a collection presents (table, graph,
13/// vector, queue, …). Referenced as a field type by the canonical SQL AST
14/// (`CreateCollectionQuery`/`CreateTableQuery` and their builders), so it
15/// is re-homed here (ADR 0053, RQL Phase 2) to keep the AST free of a
16/// `reddb-server` leaf edge. The server's `crate::catalog` re-export shim
17/// keeps existing call-sites untouched.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum CollectionModel {
20    Table,
21    Document,
22    Graph,
23    Vector,
24    Hll,
25    Sketch,
26    Filter,
27    Kv,
28    Config,
29    Vault,
30    Mixed,
31    TimeSeries,
32    Queue,
33    Metrics,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum SubscriptionOperation {
38    Insert,
39    Update,
40    Delete,
41}
42
43impl SubscriptionOperation {
44    pub fn as_str(self) -> &'static str {
45        match self {
46            Self::Insert => "INSERT",
47            Self::Update => "UPDATE",
48            Self::Delete => "DELETE",
49        }
50    }
51
52    // `from_str` returns `Option`, not `Result` — different semantics from the
53    // `std::str::FromStr` trait method, so the trait is intentionally not used.
54    #[allow(clippy::should_implement_trait)]
55    pub fn from_str(value: &str) -> Option<Self> {
56        match value.to_ascii_uppercase().as_str() {
57            "INSERT" => Some(Self::Insert),
58            "UPDATE" => Some(Self::Update),
59            "DELETE" => Some(Self::Delete),
60            _ => None,
61        }
62    }
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct SubscriptionDescriptor {
67    /// Logical name for this subscription. Empty string for legacy unnamed subscriptions.
68    pub name: String,
69    pub source: String,
70    pub target_queue: String,
71    pub ops_filter: Vec<SubscriptionOperation>,
72    pub where_filter: Option<String>,
73    pub redact_fields: Vec<String>,
74    pub enabled: bool,
75    /// When true, events are routed to the bare `target_queue` regardless of
76    /// the current tenant — a cluster-wide subscription. When false (default),
77    /// events are namespaced as `{tenant}/{target_queue}` whenever a tenant
78    /// context is active, enforcing per-tenant isolation.
79    pub all_tenants: bool,
80}
81
82/// A graph-analytics output declared by `CREATE GRAPH ... WITH ANALYTICS (...)`.
83///
84/// Each variant maps to a family of pure graph algorithms (issues #795-#797)
85/// and resolves as a virtual `<graph>.<output>` view returning that family's
86/// native row shape. The `using` option selects the concrete algorithm inside
87/// the family (e.g. `centrality (using = pagerank)`); the remaining options are
88/// algorithm parameters carried verbatim into the executor.
89#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
90pub enum AnalyticsOutput {
91    Communities,
92    Components,
93    Centrality,
94}
95
96impl AnalyticsOutput {
97    pub fn as_str(self) -> &'static str {
98        match self {
99            Self::Communities => "communities",
100            Self::Components => "components",
101            Self::Centrality => "centrality",
102        }
103    }
104
105    // `from_str` returns `Option`, not `Result` — different semantics from the
106    // `std::str::FromStr` trait method, so the trait is intentionally not used.
107    #[allow(clippy::should_implement_trait)]
108    pub fn from_str(value: &str) -> Option<Self> {
109        match value.to_ascii_lowercase().as_str() {
110            "communities" => Some(Self::Communities),
111            "components" => Some(Self::Components),
112            "centrality" => Some(Self::Centrality),
113            _ => None,
114        }
115    }
116}
117
118/// One enabled analytics output plus its declared options. Persisted in the
119/// parent graph's `CollectionContract` (WAL-backed) and surfaced on the
120/// `CollectionDescriptor` so the resolver can recognise `<graph>.<output>`.
121///
122/// `SHOW COLLECTIONS` behaviour (issue #800 HITL decision): analytics outputs
123/// resolve as virtual `<graph>.<output>` views and are deliberately **not**
124/// registered as top-level collections. They therefore never appear in
125/// `SHOW COLLECTIONS` — not by default and not under `SHOW COLLECTIONS
126/// INCLUDING INTERNAL` — keeping the parent graph's listing clean. Only the
127/// parent graph collection is listed; its enabled outputs are introspectable
128/// through this `analytics_config` on the parent's descriptor.
129#[derive(Debug, Clone, PartialEq)]
130pub struct AnalyticsViewDescriptor {
131    pub output: AnalyticsOutput,
132    /// `using = <algorithm>` — concrete algorithm within the output family.
133    /// `None` resolves to the family default (louvain / connected-components /
134    /// pagerank).
135    pub algorithm: Option<String>,
136    /// `resolution = <f64>` — Louvain resolution (γ) for `communities`.
137    pub resolution: Option<f64>,
138    /// `max_iterations = <i64>` — iteration cap for iterative centralities.
139    pub max_iterations: Option<i64>,
140    /// `tolerance = <f64>` — convergence tolerance for iterative centralities.
141    pub tolerance: Option<f64>,
142}
143
144/// Per-collection AI policy declared via DDL `WITH (...)` (PRD #1267,
145/// issue #1271). Each modality block is optional; an absent block means
146/// the collection opts out of that modality. Persisted in the
147/// `CollectionContract` (versioned/migrated with the schema) and surfaced
148/// via introspection.
149///
150/// This slice carries the *declaration* only — no enrichment/gating
151/// behaviour. Provider/model capability validation against the matrix
152/// (#1269) happens at DDL execution time in the server, where the
153/// capability registry lives.
154#[derive(Debug, Clone, PartialEq, Default)]
155pub struct AiPolicy {
156    /// `EMBED (...)` — which fields embed into which provider/model.
157    pub embed: Option<EmbedPolicy>,
158    /// `MODERATE (...)` — content moderation gate.
159    pub moderate: Option<ModeratePolicy>,
160    /// `VISION (...)` — image-reference understanding.
161    pub vision: Option<VisionPolicy>,
162}
163
164impl AiPolicy {
165    /// True when no modality block is declared. Callers treat an empty
166    /// policy the same as an absent one (`None`).
167    pub fn is_empty(&self) -> bool {
168        self.embed.is_none() && self.moderate.is_none() && self.vision.is_none()
169    }
170}
171
172/// `EMBED (fields = (...), provider = '..', model = '..')`.
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct EmbedPolicy {
175    /// Source fields whose text is embedded.
176    pub fields: Vec<String>,
177    /// Provider token (e.g. `openai`).
178    pub provider: String,
179    /// Model name as written in the policy.
180    pub model: String,
181}
182
183/// `MODERATE (fields = (...), provider, model, sync, degraded, on_reject,
184/// hard_delete)`.
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub struct ModeratePolicy {
187    /// Source fields screened by the moderation provider.
188    pub fields: Vec<String>,
189    pub provider: String,
190    pub model: String,
191    /// When true the moderation check is a synchronous gate on the write
192    /// (`sync = true`); when false it runs out-of-band.
193    pub sync_gate: bool,
194    /// Behaviour when the moderation provider is unavailable.
195    pub degraded_mode: ModerateDegradedMode,
196    /// What happens to content that fails moderation.
197    pub reject_action: ModerateRejectAction,
198    /// When true (`hard_delete = true`), a quarantined row that
199    /// re-moderates to a reject is hard-deleted instead of being
200    /// tombstoned-and-retained for audit/appeal (the default). Opt-in
201    /// per-collection because hard-delete forfeits the audit trail.
202    pub hard_delete_on_reject: bool,
203}
204
205/// Behaviour when the moderation provider can't be reached.
206#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
207pub enum ModerateDegradedMode {
208    /// Fail open — let the write through unmoderated (default).
209    #[default]
210    Open,
211    /// Fail closed — reject the write when moderation can't run.
212    Closed,
213}
214
215impl ModerateDegradedMode {
216    pub fn as_str(self) -> &'static str {
217        match self {
218            Self::Open => "open",
219            Self::Closed => "closed",
220        }
221    }
222
223    // `from_str` returns `Option`, not `Result` — different semantics from the
224    // `std::str::FromStr` trait method, so the trait is intentionally not used.
225    #[allow(clippy::should_implement_trait)]
226    pub fn from_str(value: &str) -> Option<Self> {
227        match value.trim().to_ascii_lowercase().as_str() {
228            "open" => Some(Self::Open),
229            "closed" => Some(Self::Closed),
230            _ => None,
231        }
232    }
233}
234
235/// Disposition applied to content that fails moderation.
236#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
237pub enum ModerateRejectAction {
238    /// Reject the write outright (default).
239    #[default]
240    Reject,
241    /// Accept the write but mark it as flagged.
242    Flag,
243    /// Accept the write with the offending content redacted.
244    Redact,
245}
246
247impl ModerateRejectAction {
248    pub fn as_str(self) -> &'static str {
249        match self {
250            Self::Reject => "reject",
251            Self::Flag => "flag",
252            Self::Redact => "redact",
253        }
254    }
255
256    // `from_str` returns `Option`, not `Result` — different semantics from the
257    // `std::str::FromStr` trait method, so the trait is intentionally not used.
258    #[allow(clippy::should_implement_trait)]
259    pub fn from_str(value: &str) -> Option<Self> {
260        match value.trim().to_ascii_lowercase().as_str() {
261            "reject" => Some(Self::Reject),
262            "flag" => Some(Self::Flag),
263            "redact" => Some(Self::Redact),
264            _ => None,
265        }
266    }
267}
268
269/// `VISION (image_field = '..', outputs = (...), provider, model)`.
270#[derive(Debug, Clone, PartialEq, Eq)]
271pub struct VisionPolicy {
272    /// Field holding the image reference (URL / blob id).
273    pub image_field: String,
274    /// Output kinds requested (e.g. `caption`, `tags`, `objects`).
275    pub output_kinds: Vec<String>,
276    pub provider: String,
277    pub model: String,
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    #[test]
285    fn moderate_degraded_mode_round_trips() {
286        for (mode, name) in [
287            (ModerateDegradedMode::Open, "open"),
288            (ModerateDegradedMode::Closed, "closed"),
289        ] {
290            assert_eq!(mode.as_str(), name);
291            assert_eq!(
292                ModerateDegradedMode::from_str(&name.to_ascii_uppercase()),
293                Some(mode)
294            );
295        }
296        assert_eq!(ModerateDegradedMode::from_str("ajar"), None);
297        assert_eq!(ModerateDegradedMode::default(), ModerateDegradedMode::Open);
298    }
299
300    #[test]
301    fn moderate_reject_action_round_trips() {
302        for (action, name) in [
303            (ModerateRejectAction::Reject, "reject"),
304            (ModerateRejectAction::Flag, "flag"),
305            (ModerateRejectAction::Redact, "redact"),
306        ] {
307            assert_eq!(action.as_str(), name);
308            assert_eq!(
309                ModerateRejectAction::from_str(&name.to_ascii_uppercase()),
310                Some(action)
311            );
312        }
313        assert_eq!(ModerateRejectAction::from_str("ban"), None);
314        assert_eq!(
315            ModerateRejectAction::default(),
316            ModerateRejectAction::Reject
317        );
318    }
319
320    #[test]
321    fn ai_policy_empty_detection() {
322        assert!(AiPolicy::default().is_empty());
323        let with_embed = AiPolicy {
324            embed: Some(EmbedPolicy {
325                fields: vec!["body".to_string()],
326                provider: "openai".to_string(),
327                model: "text-embedding-3-small".to_string(),
328            }),
329            ..AiPolicy::default()
330        };
331        assert!(!with_embed.is_empty());
332    }
333
334    #[test]
335    fn subscription_operations_round_trip_canonical_names() {
336        for (op, name) in [
337            (SubscriptionOperation::Insert, "INSERT"),
338            (SubscriptionOperation::Update, "UPDATE"),
339            (SubscriptionOperation::Delete, "DELETE"),
340        ] {
341            assert_eq!(op.as_str(), name);
342            assert_eq!(
343                SubscriptionOperation::from_str(&name.to_ascii_lowercase()),
344                Some(op)
345            );
346        }
347        assert_eq!(SubscriptionOperation::from_str("UPSERT"), None);
348    }
349
350    #[test]
351    fn analytics_outputs_round_trip_lowercase_names() {
352        for (output, name) in [
353            (AnalyticsOutput::Communities, "communities"),
354            (AnalyticsOutput::Components, "components"),
355            (AnalyticsOutput::Centrality, "centrality"),
356        ] {
357            assert_eq!(output.as_str(), name);
358            assert_eq!(
359                AnalyticsOutput::from_str(&name.to_ascii_uppercase()),
360                Some(output)
361            );
362        }
363        assert_eq!(AnalyticsOutput::from_str("pagerank"), None);
364    }
365
366    #[test]
367    fn descriptors_are_plain_data_carriers() {
368        let subscription = SubscriptionDescriptor {
369            name: "audit".to_string(),
370            source: "orders".to_string(),
371            target_queue: "events".to_string(),
372            ops_filter: vec![SubscriptionOperation::Insert, SubscriptionOperation::Delete],
373            where_filter: Some("amount > 0".to_string()),
374            redact_fields: vec!["secret".to_string()],
375            enabled: true,
376            all_tenants: false,
377        };
378        assert_eq!(subscription.ops_filter[1].as_str(), "DELETE");
379
380        let view = AnalyticsViewDescriptor {
381            output: AnalyticsOutput::Centrality,
382            algorithm: Some("pagerank".to_string()),
383            resolution: Some(1.0),
384            max_iterations: Some(20),
385            tolerance: Some(0.001),
386        };
387        assert_eq!(view.output.as_str(), "centrality");
388        assert_eq!(view.algorithm.as_deref(), Some("pagerank"));
389    }
390}