Coverage for src/lexigram/admin/realtime/subject_hub.py: 0%
28 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:18 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:18 +0800
1"""Subject-backed admin event hub.
3The hub the legacy SSE event stream used (``AdminEventHub`` in
4``realtime/sse.py``) was retired in the live-widgets plan; this hub
5backs both the inbox bridge and the ``/admin/_sse/widgets`` stream via
6:class:`lexigram.reactive.Subject`, giving subscribers bounded
7backpressure and the full operator toolbox.
8"""
10from __future__ import annotations
12from collections.abc import AsyncGenerator
13from dataclasses import dataclass
14from typing import Any
16from lexigram.admin.realtime.sse import AdminEvent, AdminEventType
17from lexigram.reactive import Subject, ops
20@dataclass(frozen=True)
21class _TargetedEvent:
22 """Internal envelope pairing an event with its delivery scope.
24 ``target_users is None`` means broadcast to every subscriber;
25 otherwise delivery is restricted to the listed user ids. This is
26 what makes ``target_users``/``user_id`` filtering real instead of
27 the no-op it would be if ``Subject`` only ever carried bare
28 ``AdminEvent`` values.
29 """
31 event: AdminEvent
32 target_users: tuple[Any, ...] | None
35class SubjectAdminEventHub:
36 """Fan-out hub for admin events over a reactive Subject.
38 ``on_overflow="drop_latest"`` is deliberate: the default
39 ``"block"`` mode would suspend ``publish()`` itself — and thus every
40 caller awaiting it, including a write-action HTTP response — on one
41 slow subscriber. A dropped live delta is recoverable by the next
42 reconcile-on-load snapshot; a blocked publisher is not.
44 Example:
45 ```python
46 hub = SubjectAdminEventHub()
48 async for event in hub.subscribe(resources=["users"]):
49 publish_sse(event)
51 await hub.publish(AdminEvent(event_type=AdminEventType.RESOURCE_UPDATED, data={}, resource_type="users", resource_id=1))
52 ```
53 """
55 def __init__(self, subject: Subject[_TargetedEvent] | None = None) -> None:
56 """Initialize the hub.
58 Args:
59 subject: Optional shared Subject; defaults to a private one
60 with ``on_overflow="drop_latest"``.
61 """
62 self._subject = subject or Subject[_TargetedEvent](on_overflow="drop_latest")
64 async def subscribe(
65 self,
66 user_id: Any | None = None,
67 resources: list[str] | None = None,
68 event_types: list[AdminEventType] | None = None,
69 tenant_id: str | None = None,
70 ) -> AsyncGenerator[AdminEvent, None]:
71 """Subscribe to filtered admin events.
73 Args:
74 user_id: Restricts delivery to broadcast events
75 (``target_users is None``) plus events explicitly
76 targeted at this user. ``None`` (the default) sees only
77 broadcast events — matches the legacy hub's targeting
78 semantics, which ``action_executor.py`` relies on to keep
79 a caller's own action-result notification private to
80 that caller.
81 resources: Optional resource-type filter. Caller is
82 responsible for authorizing which resources the
83 subscriber may request — this hub applies the filter,
84 it does not authorize it (see the SSE route handler).
85 event_types: Optional event-type filter.
86 tenant_id: Restricts delivery to events with no ``tenant_id``
87 (untenanted / framework-level) plus events whose
88 ``tenant_id`` matches. ``None`` sees only untenanted
89 events.
91 Yields:
92 Matching AdminEvent objects as they are published.
93 """
94 stream = self._subject.pipe(
95 ops.filter(lambda te: te.target_users is None or user_id in te.target_users)
96 )
97 stream = stream.pipe(
98 ops.filter(
99 lambda te: te.event.tenant_id is None or te.event.tenant_id == tenant_id
100 )
101 )
102 if resources:
103 stream = stream.pipe(
104 ops.filter(lambda te: te.event.resource_type in resources)
105 )
106 if event_types:
107 stream = stream.pipe(
108 ops.filter(lambda te: te.event.event_type in event_types)
109 )
110 async for targeted in stream:
111 yield targeted.event
113 async def publish(
114 self,
115 event: AdminEvent,
116 target_users: list[Any] | None = None,
117 ) -> int:
118 """Publish an event to active subscribers.
120 Args:
121 event: Admin event to publish.
122 target_users: Restrict delivery to these user ids; ``None``
123 (the default) broadcasts to every subscriber.
125 Returns:
126 Number of active subscriber channels (approximate).
127 """
128 await self._subject.publish(
129 _TargetedEvent(
130 event=event,
131 target_users=(
132 tuple(target_users) if target_users is not None else None
133 ),
134 )
135 )
136 return 1
138 async def publish_notification(
139 self,
140 title: str,
141 message: str,
142 level: str = "info",
143 target_users: list[Any] | None = None,
144 ) -> int:
145 """Publish a notification event.
147 Produces an ``AdminEventType.NOTIFICATION`` broadcast to the
148 given users (or everyone when ``target_users`` is ``None``),
149 mirroring the retired legacy hub's call signature so
150 callers (the inbox bridge, action-result notifications) can move
151 to this hub with no call-site changes beyond the import.
152 """
153 event = AdminEvent(
154 event_type=AdminEventType.NOTIFICATION,
155 data={"title": title, "message": message, "level": level},
156 )
157 return await self.publish(event, target_users=target_users)