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.
Files changed (47) hide show
  1. graphrefly/__init__.py +160 -0
  2. graphrefly/compat/__init__.py +18 -0
  3. graphrefly/compat/async_utils.py +228 -0
  4. graphrefly/compat/asyncio_runner.py +89 -0
  5. graphrefly/compat/trio_runner.py +81 -0
  6. graphrefly/core/__init__.py +142 -0
  7. graphrefly/core/clock.py +20 -0
  8. graphrefly/core/dynamic_node.py +749 -0
  9. graphrefly/core/guard.py +277 -0
  10. graphrefly/core/meta.py +149 -0
  11. graphrefly/core/node.py +963 -0
  12. graphrefly/core/protocol.py +460 -0
  13. graphrefly/core/runner.py +107 -0
  14. graphrefly/core/subgraph_locks.py +296 -0
  15. graphrefly/core/sugar.py +138 -0
  16. graphrefly/core/versioning.py +193 -0
  17. graphrefly/extra/__init__.py +313 -0
  18. graphrefly/extra/adapters.py +2149 -0
  19. graphrefly/extra/backoff.py +287 -0
  20. graphrefly/extra/backpressure.py +113 -0
  21. graphrefly/extra/checkpoint.py +307 -0
  22. graphrefly/extra/composite.py +303 -0
  23. graphrefly/extra/cron.py +133 -0
  24. graphrefly/extra/data_structures.py +707 -0
  25. graphrefly/extra/resilience.py +727 -0
  26. graphrefly/extra/sources.py +766 -0
  27. graphrefly/extra/tier1.py +1067 -0
  28. graphrefly/extra/tier2.py +1802 -0
  29. graphrefly/graph/__init__.py +31 -0
  30. graphrefly/graph/graph.py +2249 -0
  31. graphrefly/integrations/__init__.py +1 -0
  32. graphrefly/integrations/fastapi.py +767 -0
  33. graphrefly/patterns/__init__.py +5 -0
  34. graphrefly/patterns/ai.py +2132 -0
  35. graphrefly/patterns/cqrs.py +515 -0
  36. graphrefly/patterns/memory.py +639 -0
  37. graphrefly/patterns/messaging.py +553 -0
  38. graphrefly/patterns/orchestration.py +536 -0
  39. graphrefly/patterns/reactive_layout/__init__.py +81 -0
  40. graphrefly/patterns/reactive_layout/measurement_adapters.py +276 -0
  41. graphrefly/patterns/reactive_layout/reactive_block_layout.py +434 -0
  42. graphrefly/patterns/reactive_layout/reactive_layout.py +943 -0
  43. graphrefly/py.typed +1 -0
  44. graphrefly-0.1.0.dist-info/METADATA +253 -0
  45. graphrefly-0.1.0.dist-info/RECORD +47 -0
  46. graphrefly-0.1.0.dist-info/WHEEL +4 -0
  47. 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)