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}