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,707 @@
1
+ """Reactive data structures — roadmap §3.2 (KV map, log, index, list, pub/sub).
2
+
3
+ All mutation methods use two-phase protocol: ``DIRTY`` then ``DATA`` inside
4
+ :func:`~graphrefly.core.protocol.batch`, matching the TypeScript implementations.
5
+
6
+ Snapshot equality uses a monotonic *version* counter so downstream nodes can
7
+ emit ``RESOLVED`` instead of ``DATA`` when the visible collection is unchanged
8
+ (e.g. prune that evicts nothing, LRU reorder with no visible change).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import threading
14
+ from bisect import bisect_left
15
+ from dataclasses import dataclass
16
+ from types import MappingProxyType
17
+ from typing import TYPE_CHECKING, Any, NamedTuple
18
+
19
+ from graphrefly.core.clock import monotonic_ns
20
+ from graphrefly.core.protocol import MessageType, batch
21
+ from graphrefly.core.sugar import derived, state
22
+
23
+ if TYPE_CHECKING:
24
+ from collections.abc import Sequence
25
+
26
+ from graphrefly.core.node import Node
27
+
28
+ # --- Versioned snapshot -------------------------------------------------------
29
+
30
+
31
+ class Versioned(NamedTuple):
32
+ """Immutable snapshot paired with a monotonic version for ``equals``.
33
+
34
+ When the backing node has V0 versioning (GRAPHREFLY-SPEC §7), ``v0``
35
+ carries the node's identity (``id``) and version counter for
36
+ diff-friendly observation and cross-snapshot dedup (roadmap §6.0b).
37
+ """
38
+
39
+ version: int
40
+ value: Any
41
+ v0: dict[str, Any] | None = None
42
+
43
+
44
+ def _versioned_equals(a: Any, b: Any) -> bool:
45
+ """``NodeOptions.equals`` comparing only the ``version`` field."""
46
+ if not isinstance(a, Versioned) or not isinstance(b, Versioned):
47
+ return a is b
48
+ return a.version == b.version
49
+
50
+
51
+ def _v0_from_node(n: Any) -> dict[str, Any] | None:
52
+ """Extract V0 identity from a node's versioning info, if available."""
53
+ v = getattr(n, "v", None)
54
+ if v is None:
55
+ return None
56
+ return {"id": v.id, "version": v.version}
57
+
58
+
59
+ def _bump(
60
+ current: Versioned | None, next_value: Any, v0: dict[str, Any] | None = None
61
+ ) -> Versioned:
62
+ """Increment version. ``v0`` is captured before the backing node's DATA
63
+ emission, so ``v0.version`` is one behind the node's post-emission value.
64
+ This is intentional — ``v0`` records the node's version at snapshot
65
+ construction time.
66
+ """
67
+ v = current.version if isinstance(current, Versioned) else 0
68
+ return Versioned(version=v + 1, value=next_value, v0=v0)
69
+
70
+
71
+ # --- helpers ------------------------------------------------------------------
72
+
73
+
74
+ def _keepalive_derived(n: Any) -> None:
75
+ """Subscribe so derived nodes stay wired to deps (``get()`` updates without a user sink)."""
76
+ n.subscribe(lambda _msgs: None)
77
+
78
+
79
+ def _mono_now() -> int:
80
+ return monotonic_ns()
81
+
82
+
83
+ def _push_two_phase(node: Any, snapshot: Any) -> None:
84
+ """Emit DIRTY then DATA inside a batch — two-phase protocol."""
85
+ with batch():
86
+ node.down([(MessageType.DIRTY,)])
87
+ node.down([(MessageType.DATA, snapshot)])
88
+
89
+
90
+ # --- reactive_map (KV + TTL + LRU eviction) -----------------------------------
91
+
92
+
93
+ @dataclass(frozen=True, slots=True)
94
+ class _MapState:
95
+ """Internal snapshot: values with optional monotonic expiry; LRU order (oldest first)."""
96
+
97
+ entries: dict[Any, tuple[Any, int | None]]
98
+ lru: tuple[Any, ...]
99
+
100
+ @staticmethod
101
+ def empty() -> _MapState:
102
+ return _MapState(entries={}, lru=())
103
+
104
+
105
+ def _visible_map(ms: _MapState) -> MappingProxyType[Any, Any]:
106
+ now = _mono_now()
107
+ out: dict[Any, Any] = {}
108
+ for k, (v, exp) in ms.entries.items():
109
+ if exp is not None and exp <= now:
110
+ continue
111
+ out[k] = v
112
+ return MappingProxyType(out)
113
+
114
+
115
+ def _purge_stale(ms: _MapState) -> _MapState:
116
+ now = _mono_now()
117
+ if not ms.entries:
118
+ return ms
119
+ alive: dict[Any, tuple[Any, int | None]] = {}
120
+ for k, (v, exp) in ms.entries.items():
121
+ if exp is not None and exp <= now:
122
+ continue
123
+ alive[k] = (v, exp)
124
+ lru = tuple(k for k in ms.lru if k in alive)
125
+ return _MapState(entries=alive, lru=lru)
126
+
127
+
128
+ def _touch_lru(order: tuple[Any, ...], key: Any) -> tuple[Any, ...]:
129
+ without = tuple(k for k in order if k != key)
130
+ return (*without, key)
131
+
132
+
133
+ def _evict_lru(
134
+ entries: dict[Any, tuple[Any, int | None]],
135
+ lru: tuple[Any, ...],
136
+ max_size: int,
137
+ ) -> tuple[dict[Any, tuple[Any, int | None]], tuple[Any, ...]]:
138
+ while len(entries) > max_size and lru:
139
+ victim = lru[0]
140
+ lru = tuple(lru[1:])
141
+ entries.pop(victim, None)
142
+ return entries, lru
143
+
144
+
145
+ @dataclass(frozen=True, slots=True)
146
+ class ReactiveMapBundle:
147
+ """Key–value store with optional per-key TTL and LRU eviction at capacity.
148
+
149
+ Attributes:
150
+ data: A :func:`~graphrefly.core.sugar.state` node whose value is a
151
+ :class:`~types.MappingProxyType` (immutable) of non-expired keys.
152
+ Uses versioned snapshots for efficient ``RESOLVED`` deduplication.
153
+
154
+ Notes:
155
+ TTL deadlines use :func:`~graphrefly.core.clock.monotonic_ns`
156
+ (nanoseconds). Expired keys remain in the internal store until the
157
+ next mutation or :meth:`prune`. LRU order is updated on
158
+ ``set``, not on dict reads from ``data``.
159
+
160
+ Example:
161
+ ```python
162
+ from graphrefly.extra import reactive_map
163
+ m = reactive_map(max_size=10)
164
+ m.set("a", 1)
165
+ assert m.data.get().value["a"] == 1
166
+ ```
167
+ """
168
+
169
+ _state: Node[_MapState]
170
+ data: Node[Versioned]
171
+ _default_ttl: float | None
172
+ _max_size: int | None
173
+ _version: list[int]
174
+
175
+ def _sync_data(self) -> None:
176
+ raw = self._state.get()
177
+ snap = _visible_map(raw if raw is not None else _MapState.empty())
178
+ cur = self.data.get()
179
+ next_snap = _bump(
180
+ cur if isinstance(cur, Versioned) else Versioned(0, snap),
181
+ snap,
182
+ _v0_from_node(self.data),
183
+ )
184
+ self._version[0] = next_snap.version
185
+ _push_two_phase(self.data, next_snap)
186
+
187
+ def set(self, key: Any, value: Any, *, ttl: float | None = None) -> None:
188
+ eff = self._default_ttl if ttl is None else ttl
189
+ exp: int | None = None
190
+ if eff is not None:
191
+ if eff <= 0:
192
+ msg = "ttl must be > 0"
193
+ raise ValueError(msg)
194
+ exp = _mono_now() + int(eff * 1_000_000_000)
195
+
196
+ raw = self._state.get()
197
+ cur = _purge_stale(raw if raw is not None else _MapState.empty())
198
+ ent = dict(cur.entries)
199
+ lru = _touch_lru(cur.lru, key)
200
+ ent[key] = (value, exp)
201
+ if self._max_size is not None:
202
+ ent, lru = _evict_lru(ent, lru, self._max_size)
203
+ _push_two_phase(self._state, _MapState(entries=ent, lru=lru))
204
+ self._sync_data()
205
+
206
+ def delete(self, key: Any) -> None:
207
+ raw = self._state.get()
208
+ cur = _purge_stale(raw if raw is not None else _MapState.empty())
209
+ if key not in cur.entries:
210
+ return
211
+ ent = dict(cur.entries)
212
+ del ent[key]
213
+ lru = tuple(k for k in cur.lru if k != key)
214
+ _push_two_phase(self._state, _MapState(entries=ent, lru=lru))
215
+ self._sync_data()
216
+
217
+ def clear(self) -> None:
218
+ _push_two_phase(self._state, _MapState.empty())
219
+ self._sync_data()
220
+
221
+ def prune(self) -> None:
222
+ """Drop expired keys (monotonic clock) and emit."""
223
+ raw = self._state.get()
224
+ _push_two_phase(
225
+ self._state,
226
+ _purge_stale(raw if raw is not None else _MapState.empty()),
227
+ )
228
+ self._sync_data()
229
+
230
+
231
+ def reactive_map(
232
+ *,
233
+ default_ttl: float | None = None,
234
+ max_size: int | None = None,
235
+ name: str | None = None,
236
+ ) -> ReactiveMapBundle:
237
+ """Creates a reactive key–value map with optional TTL and LRU eviction.
238
+
239
+ Args:
240
+ default_ttl: If set, seconds until expiry when :meth:`ReactiveMapBundle.set` omits
241
+ ``ttl`` (``None`` = no default expiry).
242
+ max_size: If set, maximum number of entries; evicts LRU when exceeded (must be >= 1).
243
+ name: Optional registry name for ``describe()`` / debugging.
244
+
245
+ Returns:
246
+ A :class:`ReactiveMapBundle` with imperative ``set`` / ``delete`` / ``clear`` /
247
+ ``prune`` and a ``data`` node exposing the live snapshot.
248
+
249
+ Example:
250
+ ```python
251
+ from graphrefly.extra import reactive_map
252
+ m = reactive_map(default_ttl=60.0, max_size=100)
253
+ m.set("x", 1)
254
+ assert m.data.get().value["x"] == 1
255
+ ```
256
+ """
257
+
258
+ if max_size is not None and max_size < 1:
259
+ msg = "max_size must be >= 1 when set"
260
+ raise ValueError(msg)
261
+
262
+ inner = state(_MapState.empty(), describe_kind="state", name=name)
263
+ init_snap = Versioned(version=0, value=MappingProxyType({}))
264
+ data = state(init_snap, describe_kind="state", equals=_versioned_equals)
265
+ bundle = ReactiveMapBundle(
266
+ _state=inner,
267
+ data=data,
268
+ _default_ttl=default_ttl,
269
+ _max_size=max_size,
270
+ _version=[0],
271
+ )
272
+ return bundle
273
+
274
+
275
+ # --- reactive_log (append-only + tail view) -----------------------------------
276
+
277
+
278
+ @dataclass(frozen=True, slots=True)
279
+ class ReactiveLogBundle:
280
+ """Append-only log of values stored as an immutable versioned tuple.
281
+
282
+ Attributes:
283
+ entries: Node whose value is a :class:`Versioned` wrapping a ``tuple``
284
+ of all log entries.
285
+
286
+ Example:
287
+ ```python
288
+ from graphrefly.extra import reactive_log
289
+ lg = reactive_log()
290
+ lg.append("event1")
291
+ assert lg.entries.get().value == ("event1",)
292
+ ```
293
+ """
294
+
295
+ _state: Node[Versioned]
296
+ entries: Node[Versioned]
297
+ _max_size: int | None
298
+
299
+ def _trim(self, t: tuple[Any, ...]) -> tuple[Any, ...]:
300
+ """Trim from head if bounded and over capacity."""
301
+ if self._max_size is not None and len(t) > self._max_size:
302
+ return t[len(t) - self._max_size :]
303
+ return t
304
+
305
+ def _v0(self) -> dict[str, Any] | None:
306
+ return _v0_from_node(self._state)
307
+
308
+ def append(self, value: Any) -> None:
309
+ cur = self._state.get()
310
+ t: tuple[Any, ...] = cur.value if isinstance(cur, Versioned) else (cur or ())
311
+ t = self._trim((*t, value))
312
+ _push_two_phase(self._state, _bump(cur, t, self._v0()))
313
+
314
+ def append_many(self, values: Sequence[Any]) -> None:
315
+ """Extend log with all *values*, trim once, emit one snapshot."""
316
+ cur = self._state.get()
317
+ t: tuple[Any, ...] = cur.value if isinstance(cur, Versioned) else (cur or ())
318
+ t = self._trim((*t, *values))
319
+ _push_two_phase(self._state, _bump(cur, t, self._v0()))
320
+
321
+ def trim_head(self, n: int) -> None:
322
+ """Remove first *n* entries from the log and emit a snapshot.
323
+
324
+ Args:
325
+ n: Number of entries to remove from the head (must be >= 0).
326
+ """
327
+ if n < 0:
328
+ msg = "n must be >= 0"
329
+ raise ValueError(msg)
330
+ cur = self._state.get()
331
+ t: tuple[Any, ...] = cur.value if isinstance(cur, Versioned) else (cur or ())
332
+ v0 = self._v0()
333
+ if n >= len(t):
334
+ _push_two_phase(self._state, _bump(cur, (), v0))
335
+ else:
336
+ _push_two_phase(self._state, _bump(cur, t[n:], v0))
337
+
338
+ def clear(self) -> None:
339
+ cur = self._state.get()
340
+ _push_two_phase(self._state, _bump(cur, (), self._v0()))
341
+
342
+ def tail(self, n: int) -> Node[tuple[Any, ...]]:
343
+ """Last ``n`` entries (or fewer if shorter); updates when the log changes."""
344
+
345
+ if n < 0:
346
+ msg = "n must be >= 0"
347
+ raise ValueError(msg)
348
+
349
+ def _tail(deps: list[Any], _a: Any) -> tuple[Any, ...]:
350
+ raw = deps[0]
351
+ t: tuple[Any, ...] = raw.value if isinstance(raw, Versioned) else (raw or ())
352
+ return t[-n:] if n else ()
353
+
354
+ raw = self._state.get()
355
+ t0: tuple[Any, ...] = raw.value if isinstance(raw, Versioned) else (raw or ())
356
+ init_tail = t0[-n:] if n else ()
357
+ out = derived([self._state], _tail, initial=init_tail)
358
+ _keepalive_derived(out)
359
+ return out
360
+
361
+
362
+ def reactive_log(
363
+ initial: Sequence[Any] | None = None,
364
+ *,
365
+ max_size: int | None = None,
366
+ name: str | None = None,
367
+ ) -> ReactiveLogBundle:
368
+ """Creates an append-only reactive log (tuple snapshot).
369
+
370
+ Args:
371
+ initial: Optional seed sequence; copied to a tuple.
372
+ max_size: If set, maximum number of entries; oldest entries are trimmed
373
+ from the head when the buffer exceeds this size (must be >= 1).
374
+ name: Optional registry name for ``describe()`` / debugging.
375
+
376
+ Returns:
377
+ A :class:`ReactiveLogBundle` with ``append`` / ``append_many`` /
378
+ ``trim_head`` / ``clear`` and :meth:`~ReactiveLogBundle.tail`.
379
+
380
+ Example:
381
+ ```python
382
+ from graphrefly.extra import reactive_log
383
+ lg = reactive_log([1, 2])
384
+ lg.append(3)
385
+ assert lg.entries.get().value == (1, 2, 3)
386
+ ```
387
+ """
388
+ if max_size is not None and max_size < 1:
389
+ msg = "max_size must be >= 1"
390
+ raise ValueError(msg)
391
+
392
+ init = tuple(initial) if initial is not None else ()
393
+ # Trim initial if it exceeds max_size
394
+ if max_size is not None and len(init) > max_size:
395
+ init = init[len(init) - max_size :]
396
+ inner = state(
397
+ Versioned(version=0, value=init),
398
+ describe_kind="state",
399
+ equals=_versioned_equals,
400
+ name=name,
401
+ )
402
+ return ReactiveLogBundle(_state=inner, entries=inner, _max_size=max_size)
403
+
404
+
405
+ # --- reactive_index (primary key + secondary sort key) ----------------------
406
+
407
+
408
+ @dataclass(frozen=True, slots=True)
409
+ class _IndexRow[K]:
410
+ primary: K
411
+ secondary: Any
412
+ value: Any
413
+
414
+
415
+ def _row_key(row: _IndexRow[Any]) -> tuple[Any, Any]:
416
+ return (row.secondary, row.primary)
417
+
418
+
419
+ @dataclass(frozen=True, slots=True)
420
+ class ReactiveIndexBundle[K]:
421
+ """Dual-key index: unique primary key with rows sorted by ``(secondary, primary)``.
422
+
423
+ Attributes:
424
+ by_primary: Derived node mapping ``primary -> value``.
425
+ ordered: Derived node with all rows as a sorted tuple.
426
+
427
+ Example:
428
+ ```python
429
+ from graphrefly.extra import reactive_index
430
+ idx = reactive_index()
431
+ idx.upsert("alice", score=90, value={"name": "Alice"})
432
+ assert "alice" in idx.by_primary.get()
433
+ ```
434
+ """
435
+
436
+ _state: Node[Versioned]
437
+ by_primary: Node[dict[K, Any]]
438
+ ordered: Node[Versioned]
439
+
440
+ def _v0(self) -> dict[str, Any] | None:
441
+ return _v0_from_node(self._state)
442
+
443
+ def upsert(self, primary: K, secondary: Any, value: Any) -> None:
444
+ cur = self._state.get()
445
+ prev: tuple[_IndexRow[K], ...] = cur.value if isinstance(cur, Versioned) else (cur or ())
446
+ rows = [r for r in prev if r.primary != primary]
447
+ row = _IndexRow(primary=primary, secondary=secondary, value=value)
448
+ keys = [_row_key(r) for r in rows]
449
+ pos = bisect_left(keys, _row_key(row))
450
+ rows.insert(pos, row)
451
+ _push_two_phase(self._state, _bump(cur, tuple(rows), self._v0()))
452
+
453
+ def delete(self, primary: K) -> None:
454
+ cur = self._state.get()
455
+ prev: tuple[_IndexRow[K], ...] = cur.value if isinstance(cur, Versioned) else (cur or ())
456
+ rows = [r for r in prev if r.primary != primary]
457
+ if len(rows) == len(prev):
458
+ return
459
+ _push_two_phase(self._state, _bump(cur, tuple(rows), self._v0()))
460
+
461
+ def clear(self) -> None:
462
+ cur = self._state.get()
463
+ _push_two_phase(self._state, _bump(cur, (), self._v0()))
464
+
465
+
466
+ def reactive_index(*, name: str | None = None) -> ReactiveIndexBundle[Any]:
467
+ """Creates a dual-key index: unique primary key, rows sorted by ``(secondary, primary)``.
468
+
469
+ Args:
470
+ name: Optional registry name for ``describe()`` / debugging.
471
+
472
+ Returns:
473
+ A :class:`ReactiveIndexBundle` with ``upsert`` / ``delete`` / ``clear`` and
474
+ ``by_primary`` / ``ordered`` derived nodes.
475
+ """
476
+ empty: tuple[_IndexRow[Any], ...] = ()
477
+ inner = state(
478
+ Versioned(version=0, value=empty),
479
+ describe_kind="state",
480
+ equals=_versioned_equals,
481
+ name=name,
482
+ )
483
+
484
+ def _ordered(deps: list[Any], _a: Any) -> tuple[_IndexRow[Any], ...]:
485
+ raw = deps[0]
486
+ return raw.value if isinstance(raw, Versioned) else (raw or ())
487
+
488
+ def _by_p(deps: list[Any], _a: Any) -> Any:
489
+ raw = deps[0]
490
+ rows = raw.value if isinstance(raw, Versioned) else (raw or ())
491
+ return MappingProxyType({r.primary: r.value for r in rows})
492
+
493
+ by_p = derived([inner], _by_p, initial=MappingProxyType({}))
494
+ ordered_node = derived([inner], _ordered, initial=())
495
+ _keepalive_derived(by_p)
496
+ _keepalive_derived(ordered_node)
497
+ return ReactiveIndexBundle(_state=inner, by_primary=by_p, ordered=ordered_node)
498
+
499
+
500
+ # --- reactive_list -------------------------------------------------------------
501
+
502
+
503
+ @dataclass(frozen=True, slots=True)
504
+ class ReactiveListBundle:
505
+ """Positional list backed by an immutable versioned tuple snapshot.
506
+
507
+ Attributes:
508
+ items: Node whose value is a :class:`Versioned` wrapping the current
509
+ item tuple.
510
+
511
+ Example:
512
+ ```python
513
+ from graphrefly.extra import reactive_list
514
+ lst = reactive_list([1, 2])
515
+ lst.append(3)
516
+ assert lst.items.get().value == (1, 2, 3)
517
+ ```
518
+ """
519
+
520
+ _state: Node[Versioned]
521
+ items: Node[Versioned]
522
+
523
+ def _cur_tuple(self) -> tuple[Any, ...]:
524
+ raw = self._state.get()
525
+ return raw.value if isinstance(raw, Versioned) else (raw or ())
526
+
527
+ def _v0(self) -> dict[str, Any] | None:
528
+ return _v0_from_node(self._state)
529
+
530
+ def append(self, value: Any) -> None:
531
+ cur = self._state.get()
532
+ t = self._cur_tuple()
533
+ _push_two_phase(self._state, _bump(cur, (*t, value), self._v0()))
534
+
535
+ def insert(self, index: int, value: Any) -> None:
536
+ cur = self._state.get()
537
+ t = self._cur_tuple()
538
+ if index < 0 or index > len(t):
539
+ msg = "index out of range"
540
+ raise IndexError(msg)
541
+ _push_two_phase(self._state, _bump(cur, (*t[:index], value, *t[index:]), self._v0()))
542
+
543
+ def pop(self, index: int = -1) -> Any:
544
+ cur = self._state.get()
545
+ t = self._cur_tuple()
546
+ if not t:
547
+ msg = "pop from empty list"
548
+ raise IndexError(msg)
549
+ i = index if index >= 0 else len(t) + index
550
+ if i < 0 or i >= len(t):
551
+ msg = "index out of range"
552
+ raise IndexError(msg)
553
+ v = t[i]
554
+ _push_two_phase(self._state, _bump(cur, (*t[:i], *t[i + 1 :]), self._v0()))
555
+ return v
556
+
557
+ def clear(self) -> None:
558
+ cur = self._state.get()
559
+ _push_two_phase(self._state, _bump(cur, (), self._v0()))
560
+
561
+
562
+ def reactive_list(
563
+ initial: Sequence[Any] | None = None,
564
+ *,
565
+ name: str | None = None,
566
+ ) -> ReactiveListBundle:
567
+ """Creates a reactive list backed by an immutable tuple snapshot (versioned).
568
+
569
+ Args:
570
+ initial: Optional initial sequence.
571
+ name: Optional registry name for ``describe()`` / debugging.
572
+
573
+ Returns:
574
+ A :class:`ReactiveListBundle` with ``append`` / ``insert`` / ``pop`` / ``clear``.
575
+ """
576
+ init = tuple(initial) if initial is not None else ()
577
+ inner = state(
578
+ Versioned(version=0, value=init),
579
+ describe_kind="state",
580
+ equals=_versioned_equals,
581
+ name=name,
582
+ )
583
+ return ReactiveListBundle(_state=inner, items=inner)
584
+
585
+
586
+ # --- pubsub (lazy topic nodes) ------------------------------------------------
587
+
588
+
589
+ class PubSubHub:
590
+ """Lazy per-topic source node registry for pub/sub messaging.
591
+
592
+ Topics are created on first access as independent manual source nodes.
593
+ Use :meth:`publish` to push values via the two-phase ``DIRTY`` then ``DATA``
594
+ protocol. Thread-safe.
595
+
596
+ Example:
597
+ ```python
598
+ from graphrefly.extra import pubsub
599
+ from graphrefly.extra.sources import for_each
600
+ hub = pubsub()
601
+ log = []
602
+ unsub = for_each(hub.topic("events"), log.append)
603
+ hub.publish("events", "hello")
604
+ unsub()
605
+ assert log == ["hello"]
606
+ ```
607
+ """
608
+
609
+ __slots__ = ("_lock", "_topics")
610
+
611
+ def __init__(self) -> None:
612
+ self._lock = threading.Lock()
613
+ self._topics: dict[str, Node[Any]] = {}
614
+
615
+ def topic(self, name: str) -> Node[Any]:
616
+ with self._lock:
617
+ if name not in self._topics:
618
+ self._topics[name] = state(None, describe_kind="state")
619
+ return self._topics[name]
620
+
621
+ def publish(self, name: str, value: Any) -> None:
622
+ t = self.topic(name)
623
+ _push_two_phase(t, value)
624
+
625
+ def remove_topic(self, name: str) -> bool:
626
+ """Tear down and remove a topic node by name.
627
+
628
+ Returns ``True`` if the topic existed and was removed, ``False`` otherwise.
629
+ """
630
+ with self._lock:
631
+ n = self._topics.get(name)
632
+ if n is None:
633
+ return False
634
+ n.down([(MessageType.TEARDOWN,)])
635
+ del self._topics[name]
636
+ return True
637
+
638
+
639
+ def pubsub() -> PubSubHub:
640
+ """Create a new :class:`PubSubHub` with an empty topic registry.
641
+
642
+ Returns:
643
+ A fresh :class:`PubSubHub` instance.
644
+
645
+ Example:
646
+ ```python
647
+ from graphrefly.extra import pubsub
648
+ hub = pubsub()
649
+ hub.publish("topic", 1)
650
+ assert hub.topic("topic").get() == 1
651
+ ```
652
+ """
653
+ return PubSubHub()
654
+
655
+
656
+ # --- scan / slice helpers on reactive_log (optional graph nodes) -------------
657
+
658
+
659
+ def log_slice(
660
+ log: ReactiveLogBundle,
661
+ start: int,
662
+ stop: int | None = None,
663
+ ) -> Node[tuple[Any, ...]]:
664
+ """Derived view of a slice of the log, same semantics as ``tuple[start:stop]`` (stop exclusive).
665
+
666
+ Args:
667
+ log: A :class:`ReactiveLogBundle`.
668
+ start: Start index (must be >= 0).
669
+ stop: End index (exclusive); if ``None``, slice to the end.
670
+
671
+ Returns:
672
+ A derived node emitting the sliced tuple; stays updated while the log changes.
673
+ """
674
+
675
+ if start < 0:
676
+ msg = "start must be >= 0"
677
+ raise ValueError(msg)
678
+
679
+ def _slice(deps: list[Any], _a: Any) -> tuple[Any, ...]:
680
+ raw = deps[0]
681
+ t: tuple[Any, ...] = raw.value if isinstance(raw, Versioned) else (raw or ())
682
+ if stop is None:
683
+ return t[start:]
684
+ return t[start:stop]
685
+
686
+ raw = log._state.get()
687
+ t0: tuple[Any, ...] = raw.value if isinstance(raw, Versioned) else (raw or ())
688
+ init = t0[start:stop] if stop is not None else t0[start:]
689
+ out = derived([log._state], _slice, initial=init)
690
+ _keepalive_derived(out)
691
+ return out
692
+
693
+
694
+ __all__ = [
695
+ "PubSubHub",
696
+ "ReactiveIndexBundle",
697
+ "ReactiveListBundle",
698
+ "ReactiveLogBundle",
699
+ "ReactiveMapBundle",
700
+ "Versioned",
701
+ "log_slice",
702
+ "pubsub",
703
+ "reactive_index",
704
+ "reactive_list",
705
+ "reactive_log",
706
+ "reactive_map",
707
+ ]