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,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
|
+
]
|