graphrefly 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- graphrefly/__init__.py +160 -0
- graphrefly/compat/__init__.py +18 -0
- graphrefly/compat/async_utils.py +228 -0
- graphrefly/compat/asyncio_runner.py +89 -0
- graphrefly/compat/trio_runner.py +81 -0
- graphrefly/core/__init__.py +142 -0
- graphrefly/core/clock.py +20 -0
- graphrefly/core/dynamic_node.py +749 -0
- graphrefly/core/guard.py +277 -0
- graphrefly/core/meta.py +149 -0
- graphrefly/core/node.py +963 -0
- graphrefly/core/protocol.py +460 -0
- graphrefly/core/runner.py +107 -0
- graphrefly/core/subgraph_locks.py +296 -0
- graphrefly/core/sugar.py +138 -0
- graphrefly/core/versioning.py +193 -0
- graphrefly/extra/__init__.py +313 -0
- graphrefly/extra/adapters.py +2149 -0
- graphrefly/extra/backoff.py +287 -0
- graphrefly/extra/backpressure.py +113 -0
- graphrefly/extra/checkpoint.py +307 -0
- graphrefly/extra/composite.py +303 -0
- graphrefly/extra/cron.py +133 -0
- graphrefly/extra/data_structures.py +707 -0
- graphrefly/extra/resilience.py +727 -0
- graphrefly/extra/sources.py +766 -0
- graphrefly/extra/tier1.py +1067 -0
- graphrefly/extra/tier2.py +1802 -0
- graphrefly/graph/__init__.py +31 -0
- graphrefly/graph/graph.py +2249 -0
- graphrefly/integrations/__init__.py +1 -0
- graphrefly/integrations/fastapi.py +767 -0
- graphrefly/patterns/__init__.py +5 -0
- graphrefly/patterns/ai.py +2132 -0
- graphrefly/patterns/cqrs.py +515 -0
- graphrefly/patterns/memory.py +639 -0
- graphrefly/patterns/messaging.py +553 -0
- graphrefly/patterns/orchestration.py +536 -0
- graphrefly/patterns/reactive_layout/__init__.py +81 -0
- graphrefly/patterns/reactive_layout/measurement_adapters.py +276 -0
- graphrefly/patterns/reactive_layout/reactive_block_layout.py +434 -0
- graphrefly/patterns/reactive_layout/reactive_layout.py +943 -0
- graphrefly/py.typed +1 -0
- graphrefly-0.1.0.dist-info/METADATA +253 -0
- graphrefly-0.1.0.dist-info/RECORD +47 -0
- graphrefly-0.1.0.dist-info/WHEEL +4 -0
- graphrefly-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
"""CQRS patterns (roadmap §4.5).
|
|
2
|
+
|
|
3
|
+
Composition layer over reactive_log (3.2), pipeline/sagas (4.1), event bus (4.2),
|
|
4
|
+
projections (4.3). Guards (1.5) enforce command/query boundary.
|
|
5
|
+
|
|
6
|
+
- ``cqrs(name, opts?)`` → ``CqrsGraph`` — top-level factory
|
|
7
|
+
- ``CqrsGraph.command(name, handler)`` — write-only node; guard rejects ``observe``
|
|
8
|
+
- ``CqrsGraph.event(name)`` — backed by ``reactive_log``; append-only
|
|
9
|
+
- ``CqrsGraph.projection(name, events, reducer, initial)`` — read-only derived;
|
|
10
|
+
guard rejects ``write``
|
|
11
|
+
- ``CqrsGraph.saga(name, events, handler)`` — event-driven side effects
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import TYPE_CHECKING, Any
|
|
18
|
+
|
|
19
|
+
from graphrefly.core.clock import wall_clock_ns
|
|
20
|
+
from graphrefly.core.guard import policy
|
|
21
|
+
from graphrefly.core.node import node
|
|
22
|
+
from graphrefly.core.protocol import MessageType, batch
|
|
23
|
+
from graphrefly.core.sugar import derived, state
|
|
24
|
+
from graphrefly.extra.data_structures import Versioned, reactive_log
|
|
25
|
+
from graphrefly.graph.graph import Graph
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from collections.abc import Callable, Sequence
|
|
29
|
+
|
|
30
|
+
from graphrefly.core.guard import GuardFn
|
|
31
|
+
from graphrefly.core.node import Node
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# Guards
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
COMMAND_GUARD: GuardFn = policy(
|
|
38
|
+
lambda allow, deny: [
|
|
39
|
+
allow("write"),
|
|
40
|
+
allow("signal"),
|
|
41
|
+
deny("observe"),
|
|
42
|
+
]
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
PROJECTION_GUARD: GuardFn = policy(
|
|
46
|
+
lambda allow, deny: [
|
|
47
|
+
allow("observe"),
|
|
48
|
+
allow("signal"),
|
|
49
|
+
deny("write"),
|
|
50
|
+
]
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
EVENT_GUARD: GuardFn = policy(
|
|
54
|
+
lambda allow, deny: [
|
|
55
|
+
allow("observe"),
|
|
56
|
+
allow("signal"),
|
|
57
|
+
deny("write"),
|
|
58
|
+
]
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
# Helpers
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _cqrs_meta(kind: str, extra: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
67
|
+
out: dict[str, Any] = {"cqrs": True, "cqrs_type": kind}
|
|
68
|
+
if extra:
|
|
69
|
+
out.update(extra)
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _keepalive(n: Any) -> Callable[[], None]:
|
|
74
|
+
"""Keep dep wiring alive; returns unsubscribe handle for cleanup."""
|
|
75
|
+
return n.subscribe(lambda _msgs: None)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _tuple_snapshot(raw: Any) -> tuple[Any, ...]:
|
|
79
|
+
if isinstance(raw, tuple):
|
|
80
|
+
return raw
|
|
81
|
+
if isinstance(raw, list):
|
|
82
|
+
return tuple(raw)
|
|
83
|
+
return ()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
# Event envelope
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True, slots=True)
|
|
92
|
+
class CqrsEvent:
|
|
93
|
+
"""Immutable envelope for events emitted by command handlers.
|
|
94
|
+
|
|
95
|
+
Attributes:
|
|
96
|
+
type: Event name.
|
|
97
|
+
payload: Arbitrary event data.
|
|
98
|
+
timestamp_ns: Wall-clock nanoseconds (via ``wall_clock_ns()``).
|
|
99
|
+
seq: Monotonic sequence within this ``CqrsGraph`` instance.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
type: str
|
|
103
|
+
payload: Any
|
|
104
|
+
timestamp_ns: int
|
|
105
|
+
seq: int
|
|
106
|
+
v0: dict[str, Any] | None = None
|
|
107
|
+
"""V0 identity of the event log node at append time (§6.0b)."""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
# Event store adapter
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
type EventStoreCursor = dict[str, Any]
|
|
115
|
+
"""Opaque cursor for resumable event loading."""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True, slots=True)
|
|
119
|
+
class LoadEventsResult:
|
|
120
|
+
"""Result of :meth:`EventStoreAdapter.load_events`."""
|
|
121
|
+
|
|
122
|
+
events: list[CqrsEvent] = field(default_factory=list)
|
|
123
|
+
cursor: EventStoreCursor | None = None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class EventStoreAdapter:
|
|
127
|
+
"""Interface for pluggable event persistence.
|
|
128
|
+
|
|
129
|
+
:meth:`persist` is **synchronous** — called inline during ``dispatch``.
|
|
130
|
+
:meth:`load_events` is async and backs :meth:`CqrsGraph.rebuild_projection`.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
def persist(self, event: CqrsEvent) -> None:
|
|
134
|
+
raise NotImplementedError
|
|
135
|
+
|
|
136
|
+
async def load_events(
|
|
137
|
+
self, event_type: str, cursor: EventStoreCursor | None = None
|
|
138
|
+
) -> LoadEventsResult:
|
|
139
|
+
"""Load persisted events; if *cursor* is set, resume from that position."""
|
|
140
|
+
raise NotImplementedError
|
|
141
|
+
|
|
142
|
+
async def flush(self) -> None:
|
|
143
|
+
"""Optional: flush buffered writes. Default is a no-op."""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class MemoryEventStore(EventStoreAdapter):
|
|
147
|
+
"""In-memory event store (default)."""
|
|
148
|
+
|
|
149
|
+
def __init__(self) -> None:
|
|
150
|
+
self._store: dict[str, list[CqrsEvent]] = {}
|
|
151
|
+
|
|
152
|
+
def persist(self, event: CqrsEvent) -> None:
|
|
153
|
+
self._store.setdefault(event.type, []).append(event)
|
|
154
|
+
|
|
155
|
+
async def load_events(
|
|
156
|
+
self, event_type: str, cursor: EventStoreCursor | None = None
|
|
157
|
+
) -> LoadEventsResult:
|
|
158
|
+
events = list(self._store.get(event_type, []))
|
|
159
|
+
since_ts: int | None = cursor.get("timestamp_ns") if cursor is not None else None
|
|
160
|
+
since_seq: int | None = cursor.get("seq") if cursor is not None else None
|
|
161
|
+
if since_ts is not None:
|
|
162
|
+
events = [
|
|
163
|
+
e
|
|
164
|
+
for e in events
|
|
165
|
+
if e.timestamp_ns > since_ts
|
|
166
|
+
or (
|
|
167
|
+
e.timestamp_ns == since_ts
|
|
168
|
+
and e.seq > (since_seq if since_seq is not None else -1)
|
|
169
|
+
)
|
|
170
|
+
]
|
|
171
|
+
new_cursor: EventStoreCursor | None = (
|
|
172
|
+
{"timestamp_ns": events[-1].timestamp_ns, "seq": events[-1].seq} if events else cursor
|
|
173
|
+
)
|
|
174
|
+
return LoadEventsResult(events=events, cursor=new_cursor)
|
|
175
|
+
|
|
176
|
+
def clear(self) -> None:
|
|
177
|
+
self._store.clear()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
# Handler types
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@dataclass(frozen=True, slots=True)
|
|
186
|
+
class CommandActions:
|
|
187
|
+
"""Actions available inside a command handler."""
|
|
188
|
+
|
|
189
|
+
emit: Callable[[str, Any], None]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
type CommandHandler = Callable[[Any, CommandActions], None]
|
|
193
|
+
|
|
194
|
+
type ProjectionReducer = Callable[[Any, list[CqrsEvent]], Any]
|
|
195
|
+
"""Projection reducer folds events into a read model.
|
|
196
|
+
|
|
197
|
+
**Purity contract:** Reducers MUST be pure — return a new state value
|
|
198
|
+
without mutating ``state`` or any event. The ``state`` parameter is the
|
|
199
|
+
original ``initial`` on every invocation (full event-sourcing replay).
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
type SagaHandler = Callable[[CqrsEvent], None]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ---------------------------------------------------------------------------
|
|
206
|
+
# CqrsGraph
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class CqrsGraph(Graph):
|
|
211
|
+
"""CQRS graph container — composes events, commands, projections, and sagas."""
|
|
212
|
+
|
|
213
|
+
__slots__ = (
|
|
214
|
+
"_command_handlers",
|
|
215
|
+
"_event_logs",
|
|
216
|
+
"_event_store",
|
|
217
|
+
"_keepalive_disposers",
|
|
218
|
+
"_projections",
|
|
219
|
+
"_sagas",
|
|
220
|
+
"_seq",
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
def __init__(self, name: str, opts: dict[str, Any] | None = None) -> None:
|
|
224
|
+
super().__init__(name, opts)
|
|
225
|
+
self._event_logs: dict[str, dict[str, Any]] = {}
|
|
226
|
+
self._command_handlers: dict[str, CommandHandler] = {}
|
|
227
|
+
self._projections: set[str] = set()
|
|
228
|
+
self._sagas: set[str] = set()
|
|
229
|
+
self._keepalive_disposers: list[Callable[[], None]] = []
|
|
230
|
+
self._event_store: EventStoreAdapter | None = None
|
|
231
|
+
self._seq = 0
|
|
232
|
+
|
|
233
|
+
def destroy(self) -> None:
|
|
234
|
+
for dispose in self._keepalive_disposers:
|
|
235
|
+
dispose()
|
|
236
|
+
self._keepalive_disposers.clear()
|
|
237
|
+
super().destroy()
|
|
238
|
+
|
|
239
|
+
# -- Events ---------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
def event(self, name: str) -> Node[Any]:
|
|
242
|
+
"""Register a named event stream backed by ``reactive_log``.
|
|
243
|
+
|
|
244
|
+
Guard denies external ``write`` — only commands append internally.
|
|
245
|
+
"""
|
|
246
|
+
existing = self._event_logs.get(name)
|
|
247
|
+
if existing is not None:
|
|
248
|
+
return existing["node"]
|
|
249
|
+
|
|
250
|
+
log = reactive_log(name=name)
|
|
251
|
+
entries = log.entries
|
|
252
|
+
|
|
253
|
+
def pass_through(deps: list[Any], _actions: Any) -> Any:
|
|
254
|
+
return deps[0]
|
|
255
|
+
|
|
256
|
+
guarded = node(
|
|
257
|
+
[entries],
|
|
258
|
+
pass_through,
|
|
259
|
+
name=name,
|
|
260
|
+
describe_kind="state",
|
|
261
|
+
meta=_cqrs_meta("event", {"event_name": name}),
|
|
262
|
+
guard=EVENT_GUARD,
|
|
263
|
+
initial=entries.get(),
|
|
264
|
+
)
|
|
265
|
+
self.add(name, guarded)
|
|
266
|
+
self._keepalive_disposers.append(_keepalive(guarded))
|
|
267
|
+
self._event_logs[name] = {"log": log, "node": guarded}
|
|
268
|
+
return guarded
|
|
269
|
+
|
|
270
|
+
def _append_event(self, event_name: str, payload: Any) -> None:
|
|
271
|
+
"""Internal: append to an event log, auto-registering if needed."""
|
|
272
|
+
entry = self._event_logs.get(event_name)
|
|
273
|
+
if entry is None:
|
|
274
|
+
self.event(event_name)
|
|
275
|
+
entry = self._event_logs[event_name]
|
|
276
|
+
# Terminal guard: reject dispatch to completed/errored streams.
|
|
277
|
+
status = entry["node"].status
|
|
278
|
+
if status in ("completed", "errored"):
|
|
279
|
+
msg = f'Cannot dispatch to terminated event stream "{event_name}" (status: {status}).'
|
|
280
|
+
raise RuntimeError(msg)
|
|
281
|
+
self._seq += 1
|
|
282
|
+
nv = entry["log"].entries.v
|
|
283
|
+
evt = CqrsEvent(
|
|
284
|
+
type=event_name,
|
|
285
|
+
payload=payload,
|
|
286
|
+
timestamp_ns=wall_clock_ns(),
|
|
287
|
+
seq=self._seq,
|
|
288
|
+
v0={"id": nv.id, "version": nv.version} if nv is not None else None,
|
|
289
|
+
)
|
|
290
|
+
entry["log"].append(evt)
|
|
291
|
+
if self._event_store is not None:
|
|
292
|
+
self._event_store.persist(evt)
|
|
293
|
+
|
|
294
|
+
# -- Commands -------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
def command(self, name: str, handler: CommandHandler) -> Node[Any]:
|
|
297
|
+
"""Register a command with its handler. Guard denies ``observe`` (write-only).
|
|
298
|
+
|
|
299
|
+
The command node carries dynamic ``meta.error`` — a reactive companion
|
|
300
|
+
that holds the last handler error (or ``None`` on success).
|
|
301
|
+
|
|
302
|
+
Use ``dispatch(name, payload)`` to execute.
|
|
303
|
+
"""
|
|
304
|
+
cmd_node = state(
|
|
305
|
+
None,
|
|
306
|
+
name=name,
|
|
307
|
+
describe_kind="state",
|
|
308
|
+
meta={
|
|
309
|
+
**_cqrs_meta("command", {"command_name": name}),
|
|
310
|
+
"error": None,
|
|
311
|
+
},
|
|
312
|
+
guard=COMMAND_GUARD,
|
|
313
|
+
)
|
|
314
|
+
self.add(name, cmd_node)
|
|
315
|
+
self._command_handlers[name] = handler
|
|
316
|
+
return cmd_node
|
|
317
|
+
|
|
318
|
+
def dispatch(self, command_name: str, payload: Any) -> None:
|
|
319
|
+
"""Execute a registered command.
|
|
320
|
+
|
|
321
|
+
Wraps the entire dispatch in ``batch()`` so the command node DATA and
|
|
322
|
+
all emitted events settle atomically.
|
|
323
|
+
|
|
324
|
+
If the handler throws, ``meta.error`` on the command node is set to
|
|
325
|
+
the error and the exception is re-raised.
|
|
326
|
+
"""
|
|
327
|
+
handler = self._command_handlers.get(command_name)
|
|
328
|
+
if handler is None:
|
|
329
|
+
msg = f'Unknown command: "{command_name}". Register with .command() first.'
|
|
330
|
+
raise ValueError(msg)
|
|
331
|
+
cmd_node = self.resolve(command_name)
|
|
332
|
+
|
|
333
|
+
def emit(event_name: str, event_payload: Any) -> None:
|
|
334
|
+
self._append_event(event_name, event_payload)
|
|
335
|
+
|
|
336
|
+
with batch():
|
|
337
|
+
cmd_node.down([(MessageType.DATA, payload)], internal=True)
|
|
338
|
+
try:
|
|
339
|
+
handler(payload, CommandActions(emit=emit))
|
|
340
|
+
cmd_node.meta["error"].down([(MessageType.DATA, None)], internal=True)
|
|
341
|
+
except Exception as exc:
|
|
342
|
+
cmd_node.meta["error"].down([(MessageType.DATA, exc)], internal=True)
|
|
343
|
+
raise
|
|
344
|
+
|
|
345
|
+
# -- Projections ----------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
def projection(
|
|
348
|
+
self,
|
|
349
|
+
name: str,
|
|
350
|
+
event_names: Sequence[str],
|
|
351
|
+
reducer: ProjectionReducer,
|
|
352
|
+
initial: Any,
|
|
353
|
+
) -> Node[Any]:
|
|
354
|
+
"""Register a read-only projection derived from event streams.
|
|
355
|
+
|
|
356
|
+
Guard denies ``write`` — value is computed from events only.
|
|
357
|
+
|
|
358
|
+
**Purity contract:** The ``reducer`` must be a pure function — it
|
|
359
|
+
receives the original ``initial`` on every invocation. Never mutate
|
|
360
|
+
``initial``; always return a new value.
|
|
361
|
+
"""
|
|
362
|
+
event_nodes = []
|
|
363
|
+
for ename in event_names:
|
|
364
|
+
if ename not in self._event_logs:
|
|
365
|
+
self.event(ename)
|
|
366
|
+
event_nodes.append(self._event_logs[ename]["node"])
|
|
367
|
+
|
|
368
|
+
captured_initial = initial
|
|
369
|
+
|
|
370
|
+
def compute(deps: list[Any], _actions: Any) -> Any:
|
|
371
|
+
all_events: list[CqrsEvent] = []
|
|
372
|
+
for dep in deps:
|
|
373
|
+
snap = dep
|
|
374
|
+
entries = _tuple_snapshot(snap.value if isinstance(snap, Versioned) else ())
|
|
375
|
+
all_events.extend(entries)
|
|
376
|
+
all_events.sort(key=lambda e: (e.timestamp_ns, e.seq))
|
|
377
|
+
return reducer(captured_initial, all_events)
|
|
378
|
+
|
|
379
|
+
proj_node = derived(
|
|
380
|
+
event_nodes,
|
|
381
|
+
compute,
|
|
382
|
+
name=name,
|
|
383
|
+
meta=_cqrs_meta(
|
|
384
|
+
"projection",
|
|
385
|
+
{
|
|
386
|
+
"projection_name": name,
|
|
387
|
+
"source_events": list(event_names),
|
|
388
|
+
},
|
|
389
|
+
),
|
|
390
|
+
guard=PROJECTION_GUARD,
|
|
391
|
+
initial=initial,
|
|
392
|
+
)
|
|
393
|
+
self.add(name, proj_node)
|
|
394
|
+
for ename in event_names:
|
|
395
|
+
self.connect(ename, name)
|
|
396
|
+
self._keepalive_disposers.append(_keepalive(proj_node))
|
|
397
|
+
self._projections.add(name)
|
|
398
|
+
return proj_node
|
|
399
|
+
|
|
400
|
+
# -- Sagas ----------------------------------------------------------------
|
|
401
|
+
|
|
402
|
+
def saga(
|
|
403
|
+
self,
|
|
404
|
+
name: str,
|
|
405
|
+
event_names: Sequence[str],
|
|
406
|
+
handler: SagaHandler,
|
|
407
|
+
) -> Node[Any]:
|
|
408
|
+
"""Register an event-driven side effect.
|
|
409
|
+
|
|
410
|
+
Runs handler for each **new** event from the specified streams (tracks
|
|
411
|
+
last-processed entry count per stream).
|
|
412
|
+
|
|
413
|
+
The saga node carries dynamic ``meta.error`` — a reactive companion
|
|
414
|
+
that holds the last handler error (or ``None`` on success). Handler
|
|
415
|
+
errors do not propagate out of the saga run (the event cursor still
|
|
416
|
+
advances so the same entry is not delivered twice).
|
|
417
|
+
"""
|
|
418
|
+
event_nodes = []
|
|
419
|
+
for ename in event_names:
|
|
420
|
+
if ename not in self._event_logs:
|
|
421
|
+
self.event(ename)
|
|
422
|
+
event_nodes.append(self._event_logs[ename]["node"])
|
|
423
|
+
|
|
424
|
+
# Track last-processed entry count per event to only process new entries
|
|
425
|
+
last_counts: dict[str, int] = {}
|
|
426
|
+
|
|
427
|
+
saga_ref: dict[str, Any] = {"node": None}
|
|
428
|
+
|
|
429
|
+
def run_saga(deps: list[Any], _actions: Any) -> None:
|
|
430
|
+
saga_n = saga_ref["node"]
|
|
431
|
+
err_node = saga_n.meta["error"]
|
|
432
|
+
for i, dep in enumerate(deps):
|
|
433
|
+
snap = dep
|
|
434
|
+
ename = event_names[i]
|
|
435
|
+
entries = _tuple_snapshot(snap.value if isinstance(snap, Versioned) else ())
|
|
436
|
+
last_count = last_counts.get(ename, 0)
|
|
437
|
+
if len(entries) > last_count:
|
|
438
|
+
new_entries = entries[last_count:]
|
|
439
|
+
for entry in new_entries:
|
|
440
|
+
try:
|
|
441
|
+
handler(entry)
|
|
442
|
+
err_node.down([(MessageType.DATA, None)], internal=True)
|
|
443
|
+
except Exception as exc:
|
|
444
|
+
err_node.down([(MessageType.DATA, exc)], internal=True)
|
|
445
|
+
last_counts[ename] = len(entries)
|
|
446
|
+
|
|
447
|
+
saga_node = node(
|
|
448
|
+
event_nodes,
|
|
449
|
+
run_saga,
|
|
450
|
+
name=name,
|
|
451
|
+
describe_kind="effect",
|
|
452
|
+
meta={
|
|
453
|
+
**_cqrs_meta(
|
|
454
|
+
"saga",
|
|
455
|
+
{
|
|
456
|
+
"saga_name": name,
|
|
457
|
+
"source_events": list(event_names),
|
|
458
|
+
},
|
|
459
|
+
),
|
|
460
|
+
"error": None,
|
|
461
|
+
},
|
|
462
|
+
)
|
|
463
|
+
saga_ref["node"] = saga_node
|
|
464
|
+
self.add(name, saga_node)
|
|
465
|
+
for ename in event_names:
|
|
466
|
+
self.connect(ename, name)
|
|
467
|
+
self._keepalive_disposers.append(_keepalive(saga_node))
|
|
468
|
+
self._sagas.add(name)
|
|
469
|
+
return saga_node
|
|
470
|
+
|
|
471
|
+
# -- Event store ----------------------------------------------------------
|
|
472
|
+
|
|
473
|
+
def use_event_store(self, adapter: EventStoreAdapter) -> None:
|
|
474
|
+
"""Wire a pluggable event store adapter."""
|
|
475
|
+
self._event_store = adapter
|
|
476
|
+
|
|
477
|
+
async def rebuild_projection(
|
|
478
|
+
self,
|
|
479
|
+
event_names: Sequence[str],
|
|
480
|
+
reducer: ProjectionReducer,
|
|
481
|
+
initial: Any,
|
|
482
|
+
) -> Any:
|
|
483
|
+
"""Replay persisted events through a reducer to rebuild a read model.
|
|
484
|
+
|
|
485
|
+
Requires an event store adapter wired via ``use_event_store()``.
|
|
486
|
+
"""
|
|
487
|
+
if self._event_store is None:
|
|
488
|
+
msg = "No event store wired. Call use_event_store() first."
|
|
489
|
+
raise RuntimeError(msg)
|
|
490
|
+
all_events: list[CqrsEvent] = []
|
|
491
|
+
for ename in event_names:
|
|
492
|
+
result = await self._event_store.load_events(ename)
|
|
493
|
+
all_events.extend(result.events)
|
|
494
|
+
all_events.sort(key=lambda e: (e.timestamp_ns, e.seq))
|
|
495
|
+
return reducer(initial, all_events)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
# ---------------------------------------------------------------------------
|
|
499
|
+
# Factory
|
|
500
|
+
# ---------------------------------------------------------------------------
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def cqrs(name: str, opts: dict[str, Any] | None = None) -> CqrsGraph:
|
|
504
|
+
"""Create a CQRS graph container.
|
|
505
|
+
|
|
506
|
+
Example:
|
|
507
|
+
```python
|
|
508
|
+
app = cqrs("orders")
|
|
509
|
+
app.event("order_placed")
|
|
510
|
+
app.command("place_order", lambda payload, actions: actions.emit("order_placed", payload))
|
|
511
|
+
app.projection("order_count", ["order_placed"], lambda _s, events: len(events), 0)
|
|
512
|
+
app.dispatch("place_order", {"id": "1"})
|
|
513
|
+
```
|
|
514
|
+
"""
|
|
515
|
+
return CqrsGraph(name, opts)
|