Skip to main content

fraiseql_core/runtime/subscription/
mod.rs

1//! Subscription runtime for event-driven GraphQL subscriptions.
2//!
3//! FraiseQL subscriptions are **compiled projections of database events**, not
4//! traditional resolver-based subscriptions. Events originate from database
5//! transactions (via LISTEN/NOTIFY or CDC) and are delivered through transport
6//! adapters.
7//!
8//! # Architecture
9//!
10//! ```text
11//! Database Transaction (INSERT/UPDATE/DELETE)
12//!     ↓ (commits)
13//! LISTEN/NOTIFY (PostgreSQL)
14//!     ↓
15//! SubscriptionManager (event routing)
16//!     ↓
17//! SubscriptionMatcher (filter evaluation)
18//!     ↓ (parallel delivery)
19//! ├─ graphql-ws Adapter (WebSocket)
20//! ├─ Webhook Adapter (HTTP POST)
21//! └─ Kafka Adapter (event streaming)
22//! ```
23//!
24//! # Example
25//!
26//! ```text
27//! // Illustrative — subscription infrastructure requires a live schema + transport.
28//! // Use SubscriptionManager::new(Arc::new(schema)) for the full API.
29//!
30//! // Create subscription manager
31//! let manager = SubscriptionManager::new(Arc::new(schema));
32//!
33//! // Subscribe to events (synchronous, not async)
34//! let subscription_id = manager.subscribe(
35//!     "OrderCreated",
36//!     user_context_json,
37//!     variables_json,
38//!     "connection-id",
39//! )?;
40//!
41//! // Receive events via broadcast channel
42//! let mut receiver = manager.receiver();
43//! while let Ok(payload) = receiver.recv().await {
44//!     if payload.subscription_id == subscription_id {
45//!         // Deliver to client
46//!     }
47//! }
48//!
49//! // Unsubscribe (synchronous)
50//! manager.unsubscribe(subscription_id)?;
51//! ```
52
53use thiserror::Error;
54
55mod kafka;
56mod manager;
57pub mod protocol;
58#[cfg(test)]
59mod tests;
60mod transport;
61mod types;
62mod webhook;
63
64pub use kafka::{KafkaAdapter, KafkaConfig, KafkaMessage};
65pub use manager::SubscriptionManager;
66pub use transport::{BoxDynTransportAdapter, DeliveryResult, TransportAdapter, TransportManager};
67pub use types::{
68    ActiveSubscription, ChangeSpineEnvelope, SubscriptionEvent, SubscriptionId,
69    SubscriptionOperation, SubscriptionPayload,
70};
71pub use webhook::{WebhookAdapter, WebhookPayload, WebhookTransportConfig};
72/// Backward-compatible type alias — use [`WebhookTransportConfig`] in new code.
73pub type WebhookConfig = WebhookTransportConfig;
74
75/// Extract `(field, value)` equality conditions from an RLS `WhereClause`,
76/// **fail-closed**.
77///
78/// Walks the clause tree and collects all `Field { op: Eq }` nodes; `And` nodes are
79/// recursively flattened. Any shape that **cannot** be represented as a simple
80/// `field == value` equality — a non-`Eq` operator, `Or`, `Not`, or a `NativeField`
81/// column condition — is a hard **error**, not a silent skip.
82///
83/// This is a security property (#596). Subscription event delivery enforces each
84/// extracted condition against the event data (AND semantics); a condition that is
85/// *dropped* rather than *enforced* silently widens visibility — the subscriber
86/// receives rows the RLS clause was meant to hide. When these conditions derive from a
87/// row-visibility policy or an RLS clause, "deliver more, never fewer" is exactly the
88/// wrong default. So the caller evaluates the policy at subscribe time, passes the
89/// result here, and **refuses the subscription** (rather than delivering unfiltered)
90/// if any part cannot be enforced.
91///
92/// # Errors
93///
94/// Returns a description of the first unenforceable clause shape encountered.
95pub fn extract_rls_conditions(
96    clause: &crate::db::WhereClause,
97) -> Result<Vec<(String, serde_json::Value)>, String> {
98    let mut conditions = Vec::new();
99    collect_eq_conditions(clause, &mut conditions)?;
100    Ok(conditions)
101}
102
103fn collect_eq_conditions(
104    clause: &crate::db::WhereClause,
105    out: &mut Vec<(String, serde_json::Value)>,
106) -> Result<(), String> {
107    use crate::db::{WhereClause, WhereOperator};
108    match clause {
109        WhereClause::Field {
110            path,
111            operator: WhereOperator::Eq,
112            value,
113        } => {
114            // Use the last path component as the field name (e.g., ["tenant_id"] → "tenant_id")
115            if let Some(field) = path.last() {
116                out.push((field.clone(), value.clone()));
117            }
118            Ok(())
119        },
120        WhereClause::And(clauses) => {
121            for c in clauses {
122                collect_eq_conditions(c, out)?;
123            }
124            Ok(())
125        },
126        WhereClause::Field { path, operator, .. } => Err(format!(
127            "row-visibility condition on `{}` uses operator `{operator:?}`; only `Eq` can be \
128             enforced on the pushed event stream — refusing the subscription rather than \
129             delivering unfiltered rows (#596)",
130            path.last().map_or("<field>", String::as_str),
131        )),
132        // `Or`, `Not`, and `NativeField` (a native-column condition that does not map to
133        // a JSONB event-data key) cannot be enforced as event-stream equality — refuse
134        // rather than silently widen.
135        _ => Err("row-visibility clause uses an `Or`/`Not`/native-column shape that cannot be \
136                  enforced as event-stream equality — refusing the subscription rather than \
137                  delivering unfiltered rows (#596)"
138            .to_string()),
139    }
140}
141
142// =============================================================================
143// Error Types
144// =============================================================================
145
146/// Errors that can occur during subscription operations.
147#[non_exhaustive]
148#[derive(Debug, Error)]
149pub enum SubscriptionError {
150    /// Subscription type not found in schema.
151    #[error("Subscription not found: {0}")]
152    SubscriptionNotFound(String),
153
154    /// Authentication required for subscription.
155    #[error("Authentication required for subscription: {0}")]
156    AuthenticationRequired(String),
157
158    /// User not authorized for subscription.
159    #[error("Not authorized for subscription: {0}")]
160    Forbidden(String),
161
162    /// Invalid subscription variables.
163    #[error("Invalid subscription variables: {0}")]
164    InvalidVariables(String),
165
166    /// Subscription already exists.
167    #[error("Subscription already exists: {0}")]
168    AlreadyExists(String),
169
170    /// Subscription not active.
171    #[error("Subscription not active: {0}")]
172    NotActive(String),
173
174    /// Internal subscription error.
175    #[error("Subscription error: {0}")]
176    Internal(String),
177
178    /// Channel send error.
179    #[error("Failed to send event: {0}")]
180    SendError(String),
181
182    /// Database connection error.
183    #[error("Database connection error: {0}")]
184    DatabaseConnection(String),
185
186    /// Listener already running.
187    #[error("Listener already running")]
188    ListenerAlreadyRunning,
189
190    /// Listener not running.
191    #[error("Listener not running")]
192    ListenerNotRunning,
193
194    /// Failed to parse notification payload.
195    #[error("Failed to parse notification: {0}")]
196    InvalidNotification(String),
197
198    /// Failed to deliver event to transport.
199    #[error("Failed to deliver to {transport}: {reason}")]
200    DeliveryFailed {
201        /// Transport that failed.
202        transport: String,
203        /// Reason for failure.
204        reason:    String,
205    },
206}