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 arguments;
56mod kafka;
57mod manager;
58pub mod protocol;
59#[cfg(test)]
60mod tests;
61mod transport;
62mod types;
63mod webhook;
64
65pub use arguments::subscription_arguments;
66pub use kafka::{KafkaAdapter, KafkaConfig, KafkaMessage};
67pub use manager::SubscriptionManager;
68pub use transport::{BoxDynTransportAdapter, DeliveryResult, TransportAdapter, TransportManager};
69pub use types::{
70    ActiveSubscription, ChangeSpineEnvelope, SubscriptionEvent, SubscriptionId,
71    SubscriptionOperation, SubscriptionPayload,
72};
73pub use webhook::{WebhookAdapter, WebhookPayload, WebhookTransportConfig};
74/// Backward-compatible type alias — use [`WebhookTransportConfig`] in new code.
75pub type WebhookConfig = WebhookTransportConfig;
76
77/// Extract `(field, value)` equality conditions from an RLS `WhereClause`,
78/// **fail-closed**.
79///
80/// Walks the clause tree and collects all `Field { op: Eq }` nodes; `And` nodes are
81/// recursively flattened. Any shape that **cannot** be represented as a simple
82/// `field == value` equality — a non-`Eq` operator, `Or`, `Not`, or a `NativeField`
83/// column condition — is a hard **error**, not a silent skip.
84///
85/// This is a security property (#596). Subscription event delivery enforces each
86/// extracted condition against the event data (AND semantics); a condition that is
87/// *dropped* rather than *enforced* silently widens visibility — the subscriber
88/// receives rows the RLS clause was meant to hide. When these conditions derive from a
89/// row-visibility policy or an RLS clause, "deliver more, never fewer" is exactly the
90/// wrong default. So the caller evaluates the policy at subscribe time, passes the
91/// result here, and **refuses the subscription** (rather than delivering unfiltered)
92/// if any part cannot be enforced.
93///
94/// # Errors
95///
96/// Returns a description of the first unenforceable clause shape encountered.
97pub fn extract_rls_conditions(
98    clause: &crate::backend::WhereClause,
99) -> Result<Vec<(String, serde_json::Value)>, String> {
100    let mut conditions = Vec::new();
101    collect_eq_conditions(clause, &mut conditions)?;
102    Ok(conditions)
103}
104
105fn collect_eq_conditions(
106    clause: &crate::backend::WhereClause,
107    out: &mut Vec<(String, serde_json::Value)>,
108) -> Result<(), String> {
109    use crate::backend::{WhereClause, WhereOperator};
110    match clause {
111        WhereClause::Field {
112            path,
113            operator: WhereOperator::Eq,
114            value,
115        } => {
116            // Use the last path component as the field name (e.g., ["tenant_id"] → "tenant_id")
117            if let Some(field) = path.last() {
118                out.push((field.clone(), value.clone()));
119            }
120            Ok(())
121        },
122        WhereClause::And(clauses) => {
123            for c in clauses {
124                collect_eq_conditions(c, out)?;
125            }
126            Ok(())
127        },
128        // The declared field types only steer SQL cast selection; an event
129        // stream compares JSON values directly, so the annotation is
130        // transparent here. Falling into the catch-all below would refuse
131        // every subscription that carries a user filter.
132        WhereClause::Typed { inner, .. } => collect_eq_conditions(inner, out),
133        WhereClause::Field { path, operator, .. } => Err(format!(
134            "row-visibility condition on `{}` uses operator `{operator:?}`; only `Eq` can be \
135             enforced on the pushed event stream — refusing the subscription rather than \
136             delivering unfiltered rows (#596)",
137            path.last().map_or("<field>", String::as_str),
138        )),
139        // `Or`, `Not`, and `NativeField` (a native-column condition that does not map to
140        // a JSONB event-data key) cannot be enforced as event-stream equality — refuse
141        // rather than silently widen.
142        _ => Err("row-visibility clause uses an `Or`/`Not`/native-column shape that cannot be \
143                  enforced as event-stream equality — refusing the subscription rather than \
144                  delivering unfiltered rows (#596)"
145            .to_string()),
146    }
147}
148
149// =============================================================================
150// Error Types
151// =============================================================================
152
153/// Errors that can occur during subscription operations.
154#[non_exhaustive]
155#[derive(Debug, Error)]
156pub enum SubscriptionError {
157    /// Subscription type not found in schema.
158    #[error("Subscription not found: {0}")]
159    SubscriptionNotFound(String),
160
161    /// Authentication required for subscription.
162    #[error("Authentication required for subscription: {0}")]
163    AuthenticationRequired(String),
164
165    /// User not authorized for subscription.
166    #[error("Not authorized for subscription: {0}")]
167    Forbidden(String),
168
169    /// Invalid subscription variables.
170    #[error("Invalid subscription variables: {0}")]
171    InvalidVariables(String),
172
173    /// A filter references an argument the subscription does not declare (#1262).
174    ///
175    /// The client sends its variables under the declared GraphQL argument names, so
176    /// such a condition can never be matched — and a condition whose variable is
177    /// absent is skipped *by design*, because that is how an unsupplied optional
178    /// argument behaves. Left alone the two are indistinguishable and the
179    /// subscription fails **open**, delivering every event on its topic. Refused at
180    /// subscribe instead. `fraiseql compile` rejects the same shape, so this is
181    /// reachable only through a hand-edited compiled schema.
182    #[error("Subscription filter cannot be applied: {0}")]
183    UnresolvableFilter(String),
184
185    /// Subscription already exists.
186    #[error("Subscription already exists: {0}")]
187    AlreadyExists(String),
188
189    /// Subscription not active.
190    #[error("Subscription not active: {0}")]
191    NotActive(String),
192
193    /// Internal subscription error.
194    #[error("Subscription error: {0}")]
195    Internal(String),
196
197    /// Channel send error.
198    #[error("Failed to send event: {0}")]
199    SendError(String),
200
201    /// Database connection error.
202    #[error("Database connection error: {0}")]
203    DatabaseConnection(String),
204
205    /// Listener already running.
206    #[error("Listener already running")]
207    ListenerAlreadyRunning,
208
209    /// Listener not running.
210    #[error("Listener not running")]
211    ListenerNotRunning,
212
213    /// Failed to parse notification payload.
214    #[error("Failed to parse notification: {0}")]
215    InvalidNotification(String),
216
217    /// Failed to deliver event to transport.
218    #[error("Failed to deliver to {transport}: {reason}")]
219    DeliveryFailed {
220        /// Transport that failed.
221        transport: String,
222        /// Reason for failure.
223        reason:    String,
224    },
225}