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}