Skip to main content

fraiseql_core/runtime/subscription/
manager.rs

1use std::sync::{
2    Arc,
3    atomic::{AtomicU64, Ordering},
4};
5
6use dashmap::DashMap;
7use tokio::sync::broadcast;
8
9#[allow(clippy::wildcard_imports)]
10// Reason: types::* re-exports the subscription type vocabulary used throughout this module
11use super::{SubscriptionError, types::*};
12use crate::schema::CompiledSchema;
13
14// =============================================================================
15// Subscription Manager
16// =============================================================================
17
18/// Maximum number of active subscriptions a single connection may hold.
19///
20/// Prevents a single authenticated connection from exhausting server memory by
21/// calling `subscribe()` in a loop.
22const MAX_SUBSCRIPTIONS_PER_CONNECTION: usize = 100;
23
24/// Manages active subscriptions and event routing.
25///
26/// The `SubscriptionManager` is the central hub for:
27/// - Tracking active subscriptions per connection
28/// - Receiving events from database listeners
29/// - Matching events to subscriptions
30/// - Broadcasting to transport adapters
31pub struct SubscriptionManager {
32    /// Compiled schema for subscription definitions.
33    schema: Arc<CompiledSchema>,
34
35    /// Active subscriptions indexed by ID.
36    subscriptions: DashMap<SubscriptionId, ActiveSubscription>,
37
38    /// Subscriptions indexed by connection ID (for cleanup on disconnect).
39    subscriptions_by_connection: DashMap<String, Vec<SubscriptionId>>,
40
41    /// Broadcast channel for delivering events to transports.
42    event_sender: broadcast::Sender<SubscriptionPayload>,
43
44    /// Monotonic sequence counter for event ordering.
45    sequence_counter: AtomicU64,
46
47    /// Whether the deployment is multi-tenant (`security.multi_tenant`).
48    ///
49    /// In multi-tenant mode the tenant gate fails **closed**: an event is only
50    /// delivered when both the subscription and the event carry the *same*
51    /// tenant — a missing tenant on either side never matches (M-tenant-ws-failopen).
52    /// In single-tenant mode the gate stays permissive so existing deployments,
53    /// where tenant ids are typically absent, keep working.
54    multi_tenant: bool,
55}
56
57impl SubscriptionManager {
58    /// Create a new subscription manager.
59    ///
60    /// # Arguments
61    ///
62    /// * `schema` - Compiled schema containing subscription definitions
63    /// * `channel_capacity` - Broadcast channel capacity (default: 1024)
64    #[must_use]
65    pub fn new(schema: Arc<CompiledSchema>) -> Self {
66        Self::with_capacity(schema, 1024)
67    }
68
69    /// Create a new subscription manager with custom channel capacity.
70    #[must_use]
71    pub fn with_capacity(schema: Arc<CompiledSchema>, channel_capacity: usize) -> Self {
72        let (event_sender, _) = broadcast::channel(channel_capacity);
73        // Derive the tenancy mode from the compiled schema the manager already
74        // holds — no separate flag has to be threaded through every call site.
75        let multi_tenant = schema.is_multi_tenant();
76
77        Self {
78            schema,
79            subscriptions: DashMap::new(),
80            subscriptions_by_connection: DashMap::new(),
81            event_sender,
82            sequence_counter: AtomicU64::new(1),
83            multi_tenant,
84        }
85    }
86
87    /// Get a receiver for subscription payloads.
88    ///
89    /// Transport adapters use this to receive events for delivery.
90    #[must_use]
91    pub fn receiver(&self) -> broadcast::Receiver<SubscriptionPayload> {
92        self.event_sender.subscribe()
93    }
94
95    /// Subscribe to a subscription type.
96    ///
97    /// # Arguments
98    ///
99    /// * `subscription_name` - Name of the subscription type
100    /// * `user_context` - User authentication/authorization context
101    /// * `variables` - Runtime variables from client
102    /// * `connection_id` - Client connection identifier
103    ///
104    /// # Errors
105    ///
106    /// Returns error if subscription not found or user not authorized.
107    pub fn subscribe(
108        &self,
109        subscription_name: &str,
110        user_context: serde_json::Value,
111        variables: serde_json::Value,
112        connection_id: &str,
113    ) -> Result<SubscriptionId, SubscriptionError> {
114        self.subscribe_with_rls(
115            subscription_name,
116            user_context,
117            variables,
118            connection_id,
119            Vec::new(),
120        )
121    }
122
123    /// Subscribe with pre-evaluated RLS conditions for event-level filtering.
124    ///
125    /// The caller should evaluate the RLS policy at subscribe time and pass
126    /// the resulting conditions (via [`extract_rls_conditions`](super::extract_rls_conditions)).
127    /// During event delivery, each condition is checked against the event data:
128    /// the event is only delivered if **every** condition matches.
129    ///
130    /// # Errors
131    ///
132    /// Returns error if subscription not found or connection limit exceeded.
133    pub fn subscribe_with_rls(
134        &self,
135        subscription_name: &str,
136        user_context: serde_json::Value,
137        variables: serde_json::Value,
138        connection_id: &str,
139        rls_conditions: Vec<(String, serde_json::Value)>,
140    ) -> Result<SubscriptionId, SubscriptionError> {
141        self.register(
142            subscription_name,
143            user_context,
144            variables,
145            connection_id,
146            rls_conditions,
147            None,
148        )
149    }
150
151    /// Subscribe a subscriber whose events are served through `plan` (ruling AA 4): the
152    /// executor planned it at subscribe time from the client's selection and principal
153    /// ([`Executor::plan_subscription`](crate::runtime::Executor::plan_subscription)). An event
154    /// the plan suppresses is not delivered, and nothing tells the subscriber it existed.
155    ///
156    /// # Errors
157    ///
158    /// As [`subscribe_with_rls`](Self::subscribe_with_rls).
159    pub fn subscribe_planned(
160        &self,
161        plan: Arc<crate::runtime::SubscriptionPlan>,
162        user_context: serde_json::Value,
163        variables: serde_json::Value,
164        connection_id: &str,
165        rls_conditions: Vec<(String, serde_json::Value)>,
166    ) -> Result<SubscriptionId, SubscriptionError> {
167        let name = plan.subscription_name().to_string();
168        self.register(&name, user_context, variables, connection_id, rls_conditions, Some(plan))
169    }
170
171    fn register(
172        &self,
173        subscription_name: &str,
174        user_context: serde_json::Value,
175        variables: serde_json::Value,
176        connection_id: &str,
177        rls_conditions: Vec<(String, serde_json::Value)>,
178        plan: Option<Arc<crate::runtime::SubscriptionPlan>>,
179    ) -> Result<SubscriptionId, SubscriptionError> {
180        // Find subscription definition
181        let mut definition = self
182            .schema
183            .find_subscription(subscription_name)
184            .ok_or_else(|| SubscriptionError::SubscriptionNotFound(subscription_name.to_string()))?
185            .clone();
186
187        // Expand filter_fields into argument_paths on the filter.
188        // Each filter_field name becomes an argument_path entry mapping
189        // the field name to a JSON pointer path (e.g., "user_id" → "/user_id").
190        if !definition.filter_fields.is_empty() {
191            let filter =
192                definition.filter.get_or_insert_with(|| crate::schema::SubscriptionFilter {
193                    argument_paths: std::collections::HashMap::new(),
194                    static_filters: Vec::new(),
195                });
196            for field in &definition.filter_fields {
197                filter
198                    .argument_paths
199                    .entry(field.clone())
200                    .or_insert_with(|| format!("/{field}"));
201            }
202        }
203
204        // Every filter key must name a declared argument (#1262). Checked here, after the
205        // expansion above, so a `filter_fields` entry is covered by the same rule as an
206        // authored condition. `fraiseql compile` refuses this shape, so reaching it means
207        // the compiled schema was edited by hand.
208        Self::check_filter_arguments(&definition)?;
209
210        // Ruling AC 6: a type with a read gate is delivered only through a plan, which the
211        // executor builds from the subscriber's selection and principal. Without one, the
212        // manager would serve every field of every after-image.
213        if plan.is_none() {
214            if let Some(gate) = self.read_gate(&definition.return_type) {
215                return Err(SubscriptionError::Forbidden(format!(
216                    "Subscription '{subscription_name}' delivers '{}', which {gate}: it is \
217                     delivered only through a plan built from the subscriber's selection \
218                     and principal (`Executor::plan_subscription`)",
219                    definition.return_type
220                )));
221            }
222        }
223
224        // Create active subscription with RLS conditions
225        let mut active = ActiveSubscription::new(
226            subscription_name,
227            definition,
228            user_context,
229            variables,
230            connection_id,
231        )
232        .with_rls_conditions(rls_conditions);
233        if let Some(plan) = plan {
234            active = active.with_plan(plan);
235        }
236
237        let id = active.id;
238
239        // Enforce per-connection subscription cap before inserting.
240        {
241            let mut conn_subs =
242                self.subscriptions_by_connection.entry(connection_id.to_string()).or_default();
243            if conn_subs.len() >= MAX_SUBSCRIPTIONS_PER_CONNECTION {
244                return Err(SubscriptionError::Internal(format!(
245                    "Connection '{connection_id}' has reached the maximum of \
246                     {MAX_SUBSCRIPTIONS_PER_CONNECTION} concurrent subscriptions"
247                )));
248            }
249            conn_subs.push(id);
250        }
251
252        // Store subscription
253        self.subscriptions.insert(id, active);
254
255        tracing::info!(
256            subscription_id = %id,
257            subscription_name = subscription_name,
258            connection_id = connection_id,
259            "Subscription created"
260        );
261
262        Ok(id)
263    }
264
265    /// Refuse a subscription whose filter names an argument it does not declare (#1262).
266    ///
267    /// Call sites pass a definition whose `filter_fields` have already been expanded into
268    /// `argument_paths`, so this sees the same key set the delivery loop will look up.
269    ///
270    /// # Errors
271    ///
272    /// Returns [`SubscriptionError::UnresolvableFilter`] naming every unresolvable
273    /// reference and the arguments the subscription does declare.
274    /// The first read gate a document of `type_name` can meet, at any level: a type's
275    /// `requires_role`, a `requires_scope` field, an `authorize` field.
276    fn read_gate(&self, type_name: &str) -> Option<String> {
277        let mut seen = std::collections::HashSet::new();
278        let mut pending = vec![type_name.to_string()];
279        while let Some(current) = pending.pop() {
280            if !seen.insert(current.clone()) {
281                continue;
282            }
283            let Some(type_def) = self.schema.find_type(&current) else {
284                continue;
285            };
286            if type_def.requires_role.is_some() {
287                return Some(format!("reaches '{current}', whose read requires a role"));
288            }
289            for field in &type_def.fields {
290                if field.requires_scope.is_some() || field.authorize {
291                    return Some(format!("reaches the gated field '{current}.{}'", field.name));
292                }
293                if let Some(child) =
294                    field.field_type.inner_type().unwrap_or(&field.field_type).type_name()
295                {
296                    pending.push(child.to_string());
297                }
298            }
299        }
300        None
301    }
302
303    fn check_filter_arguments(
304        definition: &crate::schema::SubscriptionDefinition,
305    ) -> Result<(), SubscriptionError> {
306        definition
307            .filter_violation()
308            .map_or(Ok(()), |violation| Err(SubscriptionError::UnresolvableFilter(violation)))
309    }
310
311    /// The arguments `document` gives its root field, resolved against `variables`, for
312    /// this manager's schema: what a subscription registered here filters with (#1158).
313    ///
314    /// # Errors
315    ///
316    /// As [`subscription_arguments`](super::subscription_arguments): a document with no
317    /// single root field, an unknown subscription or an undeclared argument.
318    pub fn subscription_arguments(
319        &self,
320        document: &crate::graphql::ParsedQuery,
321        variables: Option<&serde_json::Value>,
322    ) -> crate::error::Result<serde_json::Value> {
323        let max_depth = self
324            .schema
325            .validation_config
326            .as_ref()
327            .and_then(|v| v.max_query_depth)
328            .unwrap_or(crate::schema::DEFAULT_MAX_QUERY_DEPTH);
329        super::subscription_arguments(&self.schema, document, variables, max_depth)
330            .map(serde_json::Value::Object)
331    }
332
333    /// Unsubscribe from a subscription.
334    ///
335    /// # Errors
336    ///
337    /// Returns error if subscription not found.
338    pub fn unsubscribe(&self, id: SubscriptionId) -> Result<(), SubscriptionError> {
339        let removed = self
340            .subscriptions
341            .remove(&id)
342            .ok_or_else(|| SubscriptionError::NotActive(id.to_string()))?;
343
344        // Remove from connection index
345        if let Some(mut subs) = self.subscriptions_by_connection.get_mut(&removed.1.connection_id) {
346            subs.retain(|s| *s != id);
347        }
348
349        tracing::info!(
350            subscription_id = %id,
351            subscription_name = removed.1.subscription_name,
352            "Subscription removed"
353        );
354
355        Ok(())
356    }
357
358    /// Unsubscribe all subscriptions for a connection.
359    ///
360    /// Called when a client disconnects.
361    ///
362    /// # Concurrency note
363    ///
364    /// A concurrent `subscribe` call that runs between the `DashMap` entry removal and the
365    /// per-subscription cleanup loop would create a new connection entry that is not cleaned
366    /// up by this call. A second-pass removal after the first loop closes this window for
367    /// all but the most extreme concurrent races. Any subscription that slips through is
368    /// benign: it will receive events until the broadcast receiver is dropped (which happens
369    /// on disconnect), and will be removed on the next disconnect or subscription-not-found
370    /// event for that ID.
371    pub fn unsubscribe_connection(&self, connection_id: &str) {
372        // First pass: remove the connection index atomically and clean up known subscriptions.
373        let first_pass_count = if let Some((_, subscription_ids)) =
374            self.subscriptions_by_connection.remove(connection_id)
375        {
376            let count = subscription_ids.len();
377            for id in subscription_ids {
378                self.subscriptions.remove(&id);
379            }
380            count
381        } else {
382            0
383        };
384
385        // Second pass: clean up any subscriptions added by a concurrent `subscribe` call that
386        // ran between the `remove()` above and the loop.  A concurrent `subscribe` that saw
387        // the connection entry absent would have inserted a *new* entry; removing it here
388        // closes the TOCTOU window to a negligible two-CAS race.
389        let second_pass_count = if let Some((_, subscription_ids)) =
390            self.subscriptions_by_connection.remove(connection_id)
391        {
392            let count = subscription_ids.len();
393            for id in subscription_ids {
394                self.subscriptions.remove(&id);
395                tracing::warn!(
396                    subscription_id = %id,
397                    connection_id = connection_id,
398                    "Cleaned up subscription added concurrently during disconnect"
399                );
400            }
401            count
402        } else {
403            0
404        };
405
406        tracing::info!(
407            connection_id = connection_id,
408            subscriptions_removed = first_pass_count + second_pass_count,
409            "All subscriptions removed for connection"
410        );
411    }
412
413    /// Get an active subscription by ID.
414    #[must_use]
415    pub fn get_subscription(&self, id: SubscriptionId) -> Option<ActiveSubscription> {
416        self.subscriptions.get(&id).map(|r| r.clone())
417    }
418
419    /// Replace the plan every later event of subscription `id` is served through — a policy
420    /// reload re-plans a live subscription (ruling AC 7).
421    ///
422    /// # Errors
423    ///
424    /// [`SubscriptionError::NotActive`] when the subscription is gone.
425    pub fn replace_plan(
426        &self,
427        id: SubscriptionId,
428        plan: Arc<crate::runtime::SubscriptionPlan>,
429    ) -> Result<(), SubscriptionError> {
430        let mut subscription = self
431            .subscriptions
432            .get_mut(&id)
433            .ok_or_else(|| SubscriptionError::NotActive(id.to_string()))?;
434        subscription.plan = Some(plan);
435        Ok(())
436    }
437
438    /// Replace the server-owned RLS conditions of a live subscription (#611).
439    ///
440    /// Used when a schema hot-reload changes the row-visibility policy behind an
441    /// already-connected subscription: the transport re-derives the conditions
442    /// from the new policy and swaps them in, so the tightened boundary applies
443    /// to the next delivered event without waiting for a reconnect. Delivery
444    /// matching reads the conditions per event, so the swap takes effect
445    /// immediately and atomically.
446    ///
447    /// # Errors
448    ///
449    /// Returns [`SubscriptionError::NotActive`] when the subscription no longer
450    /// exists (e.g. it raced a disconnect) — callers treat that as already gone.
451    pub fn update_rls_conditions(
452        &self,
453        id: SubscriptionId,
454        rls_conditions: Vec<(String, serde_json::Value)>,
455    ) -> Result<(), SubscriptionError> {
456        let mut subscription = self
457            .subscriptions
458            .get_mut(&id)
459            .ok_or_else(|| SubscriptionError::NotActive(id.to_string()))?;
460        subscription.rls_conditions = rls_conditions;
461        Ok(())
462    }
463
464    /// Get all active subscriptions for a connection.
465    #[must_use]
466    pub fn get_connection_subscriptions(&self, connection_id: &str) -> Vec<ActiveSubscription> {
467        self.subscriptions_by_connection
468            .get(connection_id)
469            .map(|ids| {
470                ids.iter()
471                    .filter_map(|id| self.subscriptions.get(id).map(|r| r.clone()))
472                    .collect()
473            })
474            .unwrap_or_default()
475    }
476
477    /// Get total number of active subscriptions.
478    #[must_use]
479    pub fn subscription_count(&self) -> usize {
480        self.subscriptions.len()
481    }
482
483    /// Get number of active connections with subscriptions.
484    #[must_use]
485    pub fn connection_count(&self) -> usize {
486        self.subscriptions_by_connection.len()
487    }
488
489    /// Publish an event to matching subscriptions.
490    ///
491    /// This is called by the database listener when an event is received.
492    /// The event is matched against all active subscriptions and delivered
493    /// to matching ones.
494    ///
495    /// # Arguments
496    ///
497    /// * `event` - The database event to publish
498    ///
499    /// # Returns
500    ///
501    /// Number of subscriptions that matched the event.
502    pub fn publish_event(&self, mut event: SubscriptionEvent) -> usize {
503        // Assign sequence number
504        event.sequence_number = self.sequence_counter.fetch_add(1, Ordering::SeqCst);
505
506        let mut matched = 0;
507
508        // Find matching subscriptions
509        for subscription in &self.subscriptions {
510            if self.matches_subscription(&event, &subscription) {
511                // A planned subscription is served through its plan; an event the plan
512                // suppresses is not delivered, and counts as no match.
513                let data = match &subscription.plan {
514                    Some(plan) => match plan.deliver(&event.data) {
515                        Some(served) => served,
516                        None => continue,
517                    },
518                    None => self.project_event_data(&event, &subscription),
519                };
520                matched += 1;
521
522                // Every seam reads this payload — `/ws` its `data`, the Kafka mirror and the
523                // webhook adapter its `event` (ruling AC 7). For a planned subscription the
524                // event carries the served document and no before-image, which no plan
525                // covers: nothing downstream of the plan can see the raw after-image.
526                let mut delivered = event.clone();
527                if subscription.plan.is_some() {
528                    delivered.data = data.clone();
529                    delivered.old_data = None;
530                }
531                // Ruling AA 5: the subscriber sees its own delivery position, gap-free —
532                // never the manager's global counter or the Change-Spine `seq`, which count
533                // every change, those withheld from this subscriber included.
534                delivered.sequence_number =
535                    subscription.delivered.fetch_add(1, Ordering::SeqCst) + 1;
536                // Ruling AI: the envelope names no principal but the subscriber and carries
537                // nothing rows it may not read shape (`seq` included).
538                let own_user = subscription.plan.as_ref().and_then(|plan| plan.subscriber_uuid());
539                delivered.change_spine = delivered.change_spine.as_ref().and_then(|envelope| {
540                    envelope.for_subscriber(own_user, subscription.tenant_id.as_deref())
541                });
542                let payload = SubscriptionPayload {
543                    subscription_id: subscription.id,
544                    subscription_name: subscription.subscription_name.clone(),
545                    event: delivered,
546                    data,
547                };
548
549                // Send to broadcast channel (may fail if no receivers, that's ok)
550                let _ = self.event_sender.send(payload);
551            }
552        }
553
554        if matched > 0 {
555            tracing::debug!(
556                event_id = event.event_id,
557                entity_type = event.entity_type,
558                operation = %event.operation,
559                matched = matched,
560                "Event matched subscriptions"
561            );
562        }
563
564        matched
565    }
566
567    /// Check if an event matches a subscription's filters and RLS conditions.
568    #[allow(clippy::cognitive_complexity)] // Reason: multi-criteria subscription matching (entity type, operation, filters, RLS conditions)
569    fn matches_subscription(
570        &self,
571        event: &SubscriptionEvent,
572        subscription: &ActiveSubscription,
573    ) -> bool {
574        // Tenant isolation (M-tenant-ws-failopen).
575        //
576        // Multi-tenant deployments fail **closed**: an event is delivered only
577        // when the subscription and the event carry the *same* tenant. A missing
578        // tenant on either side never matches — in particular a tenant-less
579        // subscriber must not receive any tenant's events (the fail-open hole),
580        // and a tenant-scoped subscriber must not receive untagged events.
581        //
582        // Single-tenant deployments stay permissive: tenant ids are typically
583        // absent, so the gate only filters out events that carry a *different*
584        // explicit tenant than a tenant-scoped subscription.
585        let tenant_ok = match (subscription.tenant_id.as_deref(), event.tenant_id.as_deref()) {
586            (Some(sub_tenant), Some(event_tenant)) => sub_tenant == event_tenant,
587            // One or both sides lack a tenant: only acceptable in single-tenant mode.
588            _ => !self.multi_tenant,
589        };
590        if !tenant_ok {
591            tracing::trace!(
592                subscription_id = %subscription.id,
593                sub_tenant = ?subscription.tenant_id,
594                event_tenant = ?event.tenant_id,
595                multi_tenant = self.multi_tenant,
596                "Tenant gate — event filtered"
597            );
598            return false;
599        }
600
601        // Check entity type matches (subscription return_type maps to entity)
602        if subscription.definition.return_type != event.entity_type {
603            return false;
604        }
605
606        // Check operation matches topic (if specified)
607        if let Some(ref topic) = subscription.definition.topic {
608            let expected_op = match topic.to_lowercase().as_str() {
609                t if t.contains("created") || t.contains("insert") => {
610                    Some(SubscriptionOperation::Create)
611                },
612                t if t.contains("updated") || t.contains("update") => {
613                    Some(SubscriptionOperation::Update)
614                },
615                t if t.contains("deleted") || t.contains("delete") => {
616                    Some(SubscriptionOperation::Delete)
617                },
618                _ => None,
619            };
620
621            if let Some(expected) = expected_op {
622                if event.operation != expected {
623                    return false;
624                }
625            }
626        }
627
628        // Check row-level security conditions (evaluated at subscribe time).
629        // Every condition must match (AND semantics) — RLS always wins.
630        for (field, expected_value) in &subscription.rls_conditions {
631            let actual = get_json_pointer_value(&event.data, field);
632            if actual != Some(expected_value) {
633                tracing::trace!(
634                    subscription_id = %subscription.id,
635                    field = field,
636                    expected = ?expected_value,
637                    actual = ?actual,
638                    "RLS condition mismatch — event filtered"
639                );
640                return false;
641            }
642        }
643
644        // Evaluate compiled WHERE filters against event.data and subscription variables
645        if let Some(ref filter) = subscription.definition.filter {
646            // Check argument-based filters (variable values must match event data)
647            for (arg_name, path) in &filter.argument_paths {
648                // Get the variable value provided by the client
649                if let Some(expected_value) = subscription.variables.get(arg_name) {
650                    // Get the actual value from event data using JSON pointer
651                    let actual_value = get_json_pointer_value(&event.data, path);
652
653                    // Compare values
654                    if actual_value != Some(expected_value) {
655                        tracing::trace!(
656                            subscription_id = %subscription.id,
657                            arg_name = arg_name,
658                            expected = ?expected_value,
659                            actual = ?actual_value,
660                            "Filter mismatch on argument"
661                        );
662                        return false;
663                    }
664                }
665            }
666
667            // Check static filter conditions
668            for condition in &filter.static_filters {
669                let actual_value = get_json_pointer_value(&event.data, &condition.path);
670
671                if !evaluate_filter_condition(actual_value, condition.operator, &condition.value) {
672                    tracing::trace!(
673                        subscription_id = %subscription.id,
674                        path = condition.path,
675                        operator = ?condition.operator,
676                        expected = ?condition.value,
677                        actual = ?actual_value,
678                        "Filter mismatch on static condition"
679                    );
680                    return false;
681                }
682            }
683        }
684
685        true
686    }
687
688    /// Project event data to subscription's field selection.
689    fn project_event_data(
690        &self,
691        event: &SubscriptionEvent,
692        subscription: &ActiveSubscription,
693    ) -> serde_json::Value {
694        // If no fields specified, return full event data
695        if subscription.definition.fields.is_empty() {
696            return event.data.clone();
697        }
698
699        // Project only requested fields
700        let mut projected = serde_json::Map::new();
701
702        for field in &subscription.definition.fields {
703            // Support both simple field names and JSON pointer paths
704            let value = if field.starts_with('/') {
705                get_json_pointer_value(&event.data, field).cloned()
706            } else {
707                event.data.get(field).cloned()
708            };
709
710            if let Some(v) = value {
711                // Use the field name (without leading slash) as the key
712                let key = field.trim_start_matches('/').to_string();
713                projected.insert(key, v);
714            }
715        }
716
717        serde_json::Value::Object(projected)
718    }
719}
720
721/// Retrieve a value from JSON data using a JSON pointer path.
722///
723/// # Lifetime Parameter
724///
725/// The lifetime `'a` is tied to the input `data` reference. The returned reference
726/// is guaranteed to live as long as the input data reference, enabling zero-copy
727/// access to nested JSON values without allocation.
728///
729/// # Arguments
730///
731/// * `data` - The JSON data object to query
732/// * `path` - The path to the value, either in JSON pointer format (/a/b/c) or dot notation (a.b.c)
733///
734/// # Returns
735///
736/// A reference to the JSON value if found, or `None` if the path doesn't exist.
737/// The returned reference has the same lifetime as the input data.
738///
739/// # Examples
740///
741/// ```rust
742/// # use serde_json::json;
743/// # fn get_json_pointer_value<'a>(data: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
744/// #     let normalized = if path.starts_with('/') { path.to_string() } else { format!("/{}", path.replace('.', "/")) };
745/// #     data.pointer(&normalized)
746/// # }
747/// let data = json!({"user": {"id": 123, "name": "Alice"}});
748/// let id = get_json_pointer_value(&data, "user/id");  // Some(&123)
749/// let alt = get_json_pointer_value(&data, "user.id"); // Some(&123)
750/// let missing = get_json_pointer_value(&data, "admin/id"); // None
751/// ```
752pub fn get_json_pointer_value<'a>(
753    data: &'a serde_json::Value,
754    path: &str,
755) -> Option<&'a serde_json::Value> {
756    // Normalize path to JSON pointer format
757    let normalized = if path.starts_with('/') {
758        path.to_string()
759    } else {
760        format!("/{}", path.replace('.', "/"))
761    };
762
763    data.pointer(&normalized)
764}
765
766/// Evaluate a filter condition against an actual value.
767pub fn evaluate_filter_condition(
768    actual: Option<&serde_json::Value>,
769    operator: crate::schema::FilterOperator,
770    expected: &serde_json::Value,
771) -> bool {
772    use crate::schema::FilterOperator;
773
774    match actual {
775        None => {
776            // Null/missing values only match specific conditions
777            matches!(operator, FilterOperator::Eq) && expected.is_null()
778        },
779        Some(actual_value) => match operator {
780            FilterOperator::Eq => actual_value == expected,
781            FilterOperator::Ne => actual_value != expected,
782            FilterOperator::Gt => {
783                compare_values(actual_value, expected) == Some(std::cmp::Ordering::Greater)
784            },
785            FilterOperator::Gte => {
786                matches!(
787                    compare_values(actual_value, expected),
788                    Some(std::cmp::Ordering::Greater | std::cmp::Ordering::Equal)
789                )
790            },
791            FilterOperator::Lt => {
792                compare_values(actual_value, expected) == Some(std::cmp::Ordering::Less)
793            },
794            FilterOperator::Lte => {
795                matches!(
796                    compare_values(actual_value, expected),
797                    Some(std::cmp::Ordering::Less | std::cmp::Ordering::Equal)
798                )
799            },
800            FilterOperator::Contains => {
801                match (actual_value, expected) {
802                    // Array contains value
803                    (serde_json::Value::Array(arr), val) => arr.contains(val),
804                    // String contains substring
805                    (serde_json::Value::String(s), serde_json::Value::String(sub)) => {
806                        s.contains(sub.as_str())
807                    },
808                    _ => false,
809                }
810            },
811            FilterOperator::StartsWith => match (actual_value, expected) {
812                (serde_json::Value::String(s), serde_json::Value::String(prefix)) => {
813                    s.starts_with(prefix.as_str())
814                },
815                _ => false,
816            },
817            FilterOperator::EndsWith => match (actual_value, expected) {
818                (serde_json::Value::String(s), serde_json::Value::String(suffix)) => {
819                    s.ends_with(suffix.as_str())
820                },
821                _ => false,
822            },
823        },
824    }
825}
826
827/// Compare two JSON values for ordering (numeric and string comparisons).
828fn compare_values(a: &serde_json::Value, b: &serde_json::Value) -> Option<std::cmp::Ordering> {
829    match (a, b) {
830        // Numeric comparisons
831        (serde_json::Value::Number(a), serde_json::Value::Number(b)) => {
832            let a_f64 = a.as_f64()?;
833            let b_f64 = b.as_f64()?;
834            a_f64.partial_cmp(&b_f64)
835        },
836        // String comparisons
837        (serde_json::Value::String(a), serde_json::Value::String(b)) => Some(a.cmp(b)),
838        // Bool comparisons (false < true)
839        (serde_json::Value::Bool(a), serde_json::Value::Bool(b)) => Some(a.cmp(b)),
840        // Null comparisons
841        (serde_json::Value::Null, serde_json::Value::Null) => Some(std::cmp::Ordering::Equal),
842        // Incompatible types
843        _ => None,
844    }
845}
846
847impl std::fmt::Debug for SubscriptionManager {
848    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
849        f.debug_struct("SubscriptionManager")
850            .field("subscription_count", &self.subscriptions.len())
851            .field("connection_count", &self.subscriptions_by_connection.len())
852            .finish_non_exhaustive()
853    }
854}