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(¤t) 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}