graphddb-runtime 0.2.4__tar.gz → 0.2.5__tar.gz
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.
- {graphddb_runtime-0.2.4 → graphddb_runtime-0.2.5}/PKG-INFO +1 -1
- {graphddb_runtime-0.2.4 → graphddb_runtime-0.2.5}/graphddb_runtime/__init__.py +19 -0
- {graphddb_runtime-0.2.4 → graphddb_runtime-0.2.5}/graphddb_runtime/async_runtime.py +14 -1
- graphddb_runtime-0.2.5/graphddb_runtime/middleware.py +485 -0
- {graphddb_runtime-0.2.4 → graphddb_runtime-0.2.5}/graphddb_runtime/runtime.py +682 -82
- {graphddb_runtime-0.2.4 → graphddb_runtime-0.2.5}/graphddb_runtime.egg-info/PKG-INFO +1 -1
- {graphddb_runtime-0.2.4 → graphddb_runtime-0.2.5}/graphddb_runtime.egg-info/SOURCES.txt +3 -0
- {graphddb_runtime-0.2.4 → graphddb_runtime-0.2.5}/pyproject.toml +1 -1
- graphddb_runtime-0.2.5/tests/test_integration_middleware.py +312 -0
- graphddb_runtime-0.2.5/tests/test_middleware.py +1014 -0
- {graphddb_runtime-0.2.4 → graphddb_runtime-0.2.5}/README.md +0 -0
- {graphddb_runtime-0.2.4 → graphddb_runtime-0.2.5}/graphddb_runtime/batch.py +0 -0
- {graphddb_runtime-0.2.4 → graphddb_runtime-0.2.5}/graphddb_runtime/concurrency.py +0 -0
- {graphddb_runtime-0.2.4 → graphddb_runtime-0.2.5}/graphddb_runtime/cursor.py +0 -0
- {graphddb_runtime-0.2.4 → graphddb_runtime-0.2.5}/graphddb_runtime/errors.py +0 -0
- {graphddb_runtime-0.2.4 → graphddb_runtime-0.2.5}/graphddb_runtime/filters.py +0 -0
- {graphddb_runtime-0.2.4 → graphddb_runtime-0.2.5}/graphddb_runtime/hydration.py +0 -0
- {graphddb_runtime-0.2.4 → graphddb_runtime-0.2.5}/graphddb_runtime/limits.py +0 -0
- {graphddb_runtime-0.2.4 → graphddb_runtime-0.2.5}/graphddb_runtime/per_key_cursor.py +0 -0
- {graphddb_runtime-0.2.4 → graphddb_runtime-0.2.5}/graphddb_runtime/relations.py +0 -0
- {graphddb_runtime-0.2.4 → graphddb_runtime-0.2.5}/graphddb_runtime/templates.py +0 -0
- {graphddb_runtime-0.2.4 → graphddb_runtime-0.2.5}/graphddb_runtime/transactions.py +0 -0
- {graphddb_runtime-0.2.4 → graphddb_runtime-0.2.5}/graphddb_runtime.egg-info/dependency_links.txt +0 -0
- {graphddb_runtime-0.2.4 → graphddb_runtime-0.2.5}/graphddb_runtime.egg-info/requires.txt +0 -0
- {graphddb_runtime-0.2.4 → graphddb_runtime-0.2.5}/graphddb_runtime.egg-info/top_level.txt +0 -0
- {graphddb_runtime-0.2.4 → graphddb_runtime-0.2.5}/setup.cfg +0 -0
- {graphddb_runtime-0.2.4 → graphddb_runtime-0.2.5}/tests/test_concurrency.py +0 -0
- {graphddb_runtime-0.2.4 → graphddb_runtime-0.2.5}/tests/test_contract_runtime.py +0 -0
- {graphddb_runtime-0.2.4 → graphddb_runtime-0.2.5}/tests/test_integration.py +0 -0
- {graphddb_runtime-0.2.4 → graphddb_runtime-0.2.5}/tests/test_integration_command.py +0 -0
- {graphddb_runtime-0.2.4 → graphddb_runtime-0.2.5}/tests/test_integration_compose.py +0 -0
- {graphddb_runtime-0.2.4 → graphddb_runtime-0.2.5}/tests/test_integration_contract.py +0 -0
- {graphddb_runtime-0.2.4 → graphddb_runtime-0.2.5}/tests/test_integration_edge_derive.py +0 -0
- {graphddb_runtime-0.2.4 → graphddb_runtime-0.2.5}/tests/test_integration_edge_write.py +0 -0
- {graphddb_runtime-0.2.4 → graphddb_runtime-0.2.5}/tests/test_integration_events.py +0 -0
- {graphddb_runtime-0.2.4 → graphddb_runtime-0.2.5}/tests/test_integration_referential.py +0 -0
- {graphddb_runtime-0.2.4 → graphddb_runtime-0.2.5}/tests/test_integration_relations.py +0 -0
- {graphddb_runtime-0.2.4 → graphddb_runtime-0.2.5}/tests/test_integration_unique.py +0 -0
- {graphddb_runtime-0.2.4 → graphddb_runtime-0.2.5}/tests/test_relations.py +0 -0
- {graphddb_runtime-0.2.4 → graphddb_runtime-0.2.5}/tests/test_unit.py +0 -0
|
@@ -31,12 +31,31 @@ from .per_key_cursor import (
|
|
|
31
31
|
encode_per_key_cursor,
|
|
32
32
|
serialize_contract_key,
|
|
33
33
|
)
|
|
34
|
+
from .middleware import (
|
|
35
|
+
MiddlewareRegistry,
|
|
36
|
+
MiddlewareRuntime,
|
|
37
|
+
PersistCtx,
|
|
38
|
+
ReadOpCtx,
|
|
39
|
+
ReadRequestCtx,
|
|
40
|
+
RECOVER_NONE,
|
|
41
|
+
WriteCtx,
|
|
42
|
+
WriteRuntime,
|
|
43
|
+
)
|
|
34
44
|
from .runtime import GraphDDBRuntime
|
|
35
45
|
|
|
36
46
|
__all__ = [
|
|
37
47
|
"GraphDDBRuntime",
|
|
38
48
|
"AsyncGraphDDBRuntime",
|
|
39
49
|
"RuntimeLimits",
|
|
50
|
+
# middleware / hooks (issue #50 / #140 — Python parity)
|
|
51
|
+
"MiddlewareRegistry",
|
|
52
|
+
"MiddlewareRuntime",
|
|
53
|
+
"WriteRuntime",
|
|
54
|
+
"ReadRequestCtx",
|
|
55
|
+
"ReadOpCtx",
|
|
56
|
+
"WriteCtx",
|
|
57
|
+
"PersistCtx",
|
|
58
|
+
"RECOVER_NONE",
|
|
40
59
|
"RELATION_TRAVERSAL_CONCURRENCY",
|
|
41
60
|
"map_with_concurrency",
|
|
42
61
|
"GraphDDBError",
|
|
@@ -43,6 +43,18 @@ class AsyncGraphDDBRuntime:
|
|
|
43
43
|
def __init__(self, runtime: GraphDDBRuntime) -> None:
|
|
44
44
|
self.sync = runtime
|
|
45
45
|
|
|
46
|
+
def use(self, mw: Any) -> None:
|
|
47
|
+
"""Register a host-side middleware on the wrapped runtime (issue #140).
|
|
48
|
+
|
|
49
|
+
Middleware is host-only config (never serialized); registering it on the
|
|
50
|
+
shared sync runtime means an ``async`` caller and a ``sync`` caller of the
|
|
51
|
+
same runtime see the identical hook chain."""
|
|
52
|
+
self.sync.use(mw)
|
|
53
|
+
|
|
54
|
+
def clear_middleware(self) -> None:
|
|
55
|
+
"""Remove every registered middleware on the wrapped runtime."""
|
|
56
|
+
self.sync.clear_middleware()
|
|
57
|
+
|
|
46
58
|
async def execute_query(
|
|
47
59
|
self,
|
|
48
60
|
query_id: str,
|
|
@@ -99,9 +111,10 @@ class AsyncGraphDDBRuntime:
|
|
|
99
111
|
self,
|
|
100
112
|
transaction_id: str,
|
|
101
113
|
params: Mapping[str, Any],
|
|
114
|
+
options: Optional[Mapping[str, Any]] = None,
|
|
102
115
|
) -> None:
|
|
103
116
|
await asyncio.to_thread(
|
|
104
|
-
self.sync.execute_transaction, transaction_id, params
|
|
117
|
+
self.sync.execute_transaction, transaction_id, params, options
|
|
105
118
|
)
|
|
106
119
|
|
|
107
120
|
async def explain(self, query_id: str, params: Mapping[str, Any]) -> dict:
|
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
"""Host-side middleware / hooks for the Python runtime (issue #50, #140).
|
|
2
|
+
|
|
3
|
+
The Python runtime parity counterpart of the TypeScript ``src/middleware/``. It
|
|
4
|
+
implements the SAME hook points as the TS reference — read R1–R5 and write
|
|
5
|
+
W1–W5 — with the SAME interface (a ``Middleware`` carrying optional ``read`` /
|
|
6
|
+
``write`` hook groups), the SAME ``context`` injection per call, the SAME
|
|
7
|
+
ordering (``before*`` first-registered-first / FIFO; ``after*`` and ``on_error``
|
|
8
|
+
last-registered-first / LIFO), and the SAME atomicity (a transaction
|
|
9
|
+
``before``-throw aborts the whole transaction; the persist hooks fire once per
|
|
10
|
+
atomic batch; ``on_error`` may recover by returning a value).
|
|
11
|
+
|
|
12
|
+
This is a **parallel, host-only** implementation. Like the TS hooks, a
|
|
13
|
+
``Middleware`` is NEVER serialized: it lives only on the runtime's host-side
|
|
14
|
+
:class:`MiddlewareRegistry` (the Python equivalent of the TS ``ClientManager``)
|
|
15
|
+
and never touches ``operations.json`` / the planner / the spec generator, so the
|
|
16
|
+
TS↔Python bridge (#48) is unaffected.
|
|
17
|
+
|
|
18
|
+
Idiomatic Python differences from the TS reference (behavior is identical):
|
|
19
|
+
|
|
20
|
+
- The runtime core is synchronous (boto3 is sync), so hooks are plain callables
|
|
21
|
+
invoked synchronously rather than ``await``ed. The :class:`AsyncGraphDDBRuntime`
|
|
22
|
+
wrapper runs the whole sync call (hooks included) in a worker thread, so an
|
|
23
|
+
``async`` caller sees the identical behavior without a second hook path.
|
|
24
|
+
- Hook groups are expressed as nested ``dict`` / mapping (``{"read": {"op":
|
|
25
|
+
{...}}}``) OR a small object with the matching attributes — a ``Middleware``
|
|
26
|
+
is anything :func:`_hook` can read a callable off. This mirrors the TS object
|
|
27
|
+
literal a host registers, and keeps registration as light as ``runtime.use({...})``.
|
|
28
|
+
- Context objects are mutable dataclasses; the mutable fields the proposal marks
|
|
29
|
+
(``params`` in R1, ``operation`` in R2, ``kind`` / ``input`` in W1, ``items``
|
|
30
|
+
in W3) are plain attributes a hook reassigns or mutates in place.
|
|
31
|
+
|
|
32
|
+
A read or write issued with no ``context`` sees an empty ``{}`` — exactly as the
|
|
33
|
+
TS runtime threads ``context ?? {}``.
|
|
34
|
+
|
|
35
|
+
@see docs/proposals/read-middleware.md
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
from dataclasses import dataclass, field
|
|
41
|
+
from typing import (
|
|
42
|
+
Any,
|
|
43
|
+
Callable,
|
|
44
|
+
Dict,
|
|
45
|
+
List,
|
|
46
|
+
Mapping,
|
|
47
|
+
Optional,
|
|
48
|
+
Sequence,
|
|
49
|
+
Tuple,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# A registered middleware is any object/mapping carrying the hook groups. The
|
|
53
|
+
# library never validates its shape beyond reading callables off the known
|
|
54
|
+
# paths, mirroring the TS "hooks are unrestricted" stance (proposal appendix A).
|
|
55
|
+
Middleware = Any
|
|
56
|
+
RequestContext = Dict[str, Any]
|
|
57
|
+
Item = Dict[str, Any]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ── hook lookup ─────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _branch(node: Any, key: str) -> Any:
|
|
64
|
+
"""Read a sub-group (``read`` / ``write`` / ``op`` / ``persist``) off a node.
|
|
65
|
+
|
|
66
|
+
A node is either a mapping (the common ``{...}`` registration literal) or an
|
|
67
|
+
object with attributes; either form is supported so a host may register a
|
|
68
|
+
dict OR a small class instance — both are idiomatic Python.
|
|
69
|
+
"""
|
|
70
|
+
if node is None:
|
|
71
|
+
return None
|
|
72
|
+
if isinstance(node, Mapping):
|
|
73
|
+
return node.get(key)
|
|
74
|
+
return getattr(node, key, None)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _hook(mw: Middleware, *path: str) -> Optional[Callable[..., Any]]:
|
|
78
|
+
"""Resolve the callable at ``path`` (e.g. ``"read", "op", "before"``), or None.
|
|
79
|
+
|
|
80
|
+
Returns ``None`` when any segment is absent or the leaf is not callable, so an
|
|
81
|
+
unset hook is simply skipped (matching the TS ``if (hook) ...`` guards).
|
|
82
|
+
"""
|
|
83
|
+
node: Any = mw
|
|
84
|
+
for segment in path:
|
|
85
|
+
node = _branch(node, segment)
|
|
86
|
+
if node is None:
|
|
87
|
+
return None
|
|
88
|
+
return node if callable(node) else None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ── registry ────────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class MiddlewareRegistry:
|
|
95
|
+
"""A process/runtime-scoped, registration-ordered list of middleware.
|
|
96
|
+
|
|
97
|
+
Mirrors the TS ``MiddlewareRegistry`` on ``ClientManager``: append-only via
|
|
98
|
+
:meth:`use`, cleared via :meth:`clear`, snapshotted per call via
|
|
99
|
+
:meth:`snapshot` so a call's hook set is stable even if middleware is
|
|
100
|
+
registered / cleared mid-flight.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(self) -> None:
|
|
104
|
+
self._list: List[Middleware] = []
|
|
105
|
+
|
|
106
|
+
def use(self, mw: Middleware) -> None:
|
|
107
|
+
"""Register a middleware (appended last → runs last in FIFO ``before*``)."""
|
|
108
|
+
self._list.append(mw)
|
|
109
|
+
|
|
110
|
+
def clear(self) -> None:
|
|
111
|
+
"""Remove all registered middleware (teardown / tests)."""
|
|
112
|
+
self._list = []
|
|
113
|
+
|
|
114
|
+
def snapshot(self) -> Tuple[Middleware, ...]:
|
|
115
|
+
"""A point-in-time, registration-ordered snapshot."""
|
|
116
|
+
return tuple(self._list)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ── context objects ───────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass
|
|
123
|
+
class ReadRequestCtx:
|
|
124
|
+
"""R1 / R4 / R5 (request-level) context.
|
|
125
|
+
|
|
126
|
+
``before`` (R1) may mutate :attr:`params` and may raise to cancel; ``after``
|
|
127
|
+
(R4) receives the final assembled result and may return a replacement;
|
|
128
|
+
``on_error`` (R5) sees a request-level failure and may recover by returning a
|
|
129
|
+
value.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
kind: str # 'query'
|
|
133
|
+
model: Any
|
|
134
|
+
context: RequestContext
|
|
135
|
+
params: Dict[str, Any]
|
|
136
|
+
state: Dict[str, Any] = field(default_factory=dict)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass
|
|
140
|
+
class ReadOpCtx:
|
|
141
|
+
"""R2 / R3 / R5 (op-level) context — fires for the root read AND every relation
|
|
142
|
+
fan-out fetch (incl. nested recursion).
|
|
143
|
+
|
|
144
|
+
``before`` (R2) may mutate :attr:`operation` before it is sent and may raise
|
|
145
|
+
to cancel; ``after`` (R3) receives the op's raw items and may return a
|
|
146
|
+
replacement list; ``on_error`` (R5) sees the op-level failure and may recover
|
|
147
|
+
by returning an item list.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
kind: str # 'GetItem' | 'Query' | 'BatchGetItem'
|
|
151
|
+
model: Any
|
|
152
|
+
context: RequestContext
|
|
153
|
+
operation: Dict[str, Any]
|
|
154
|
+
relation_path: Tuple[str, ...]
|
|
155
|
+
state: Dict[str, Any] = field(default_factory=dict)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass
|
|
159
|
+
class WriteCtx:
|
|
160
|
+
"""W1 / W2 / W5 (logical-write-level) context.
|
|
161
|
+
|
|
162
|
+
``before`` (W1) may mutate :attr:`kind` and :attr:`input` (incl. the
|
|
163
|
+
delete→update soft-delete rewrite) and may raise to cancel (in a transaction,
|
|
164
|
+
aborting ALL ops); ``after`` (W2) observes the committed change; ``on_error``
|
|
165
|
+
(W5) sees the logical-level failure and may recover by returning a value.
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
kind: str # 'put' | 'update' | 'delete'
|
|
169
|
+
model: Any
|
|
170
|
+
context: RequestContext
|
|
171
|
+
input: Dict[str, Any]
|
|
172
|
+
state: Dict[str, Any] = field(default_factory=dict)
|
|
173
|
+
transaction: Optional[Dict[str, Any]] = None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@dataclass
|
|
177
|
+
class PersistCtx:
|
|
178
|
+
"""W3 / W4 / W5 (physical-persist-level) context — fires AFTER derivation, on
|
|
179
|
+
the REAL composed batch.
|
|
180
|
+
|
|
181
|
+
``before`` (W3) may mutate :attr:`items` (the fully composed Put / Update /
|
|
182
|
+
Delete / ConditionCheck set) and may raise to abort the batch; ``after`` (W4)
|
|
183
|
+
observes the executor results; ``on_error`` (W5) sees the persist-level
|
|
184
|
+
failure and may recover by returning a value.
|
|
185
|
+
|
|
186
|
+
In a transaction the persist hooks fire ONCE for the whole atomic batch; for a
|
|
187
|
+
single-op write :attr:`items` is a one-element list.
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
items: List[Dict[str, Any]]
|
|
191
|
+
origins: Tuple[Dict[str, Any], ...]
|
|
192
|
+
context: RequestContext
|
|
193
|
+
state: Dict[str, Any] = field(default_factory=dict)
|
|
194
|
+
transaction: Optional[Dict[str, Any]] = None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# A sentinel distinguishing "an on_error hook declined" (returned None) from "an
|
|
198
|
+
# on_error hook recovered with the value None". TS uses `!== undefined`; Python's
|
|
199
|
+
# only spare in-band value is a private sentinel, so a hook that genuinely wants
|
|
200
|
+
# to recover with `None` returns this. A bare `None` (the common "decline")
|
|
201
|
+
# means "did not recover".
|
|
202
|
+
class _Decline:
|
|
203
|
+
__slots__ = ()
|
|
204
|
+
|
|
205
|
+
def __repr__(self) -> str: # pragma: no cover - debug aid
|
|
206
|
+
return "<decline>"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# Recover-with-None marker the host returns to recover a read/write to the value
|
|
210
|
+
# ``None`` (e.g. a ``query`` recovering to "no row"). Returning a bare ``None``
|
|
211
|
+
# means decline (the original error rethrows).
|
|
212
|
+
RECOVER_NONE = object()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _recovered(value: Any) -> Tuple[bool, Any]:
|
|
216
|
+
"""Interpret an ``on_error`` hook return as (did_recover, value).
|
|
217
|
+
|
|
218
|
+
- a bare ``None`` → declined (did NOT recover);
|
|
219
|
+
- :data:`RECOVER_NONE` → recovered with the value ``None``;
|
|
220
|
+
- any other value → recovered with that value.
|
|
221
|
+
"""
|
|
222
|
+
if value is None:
|
|
223
|
+
return False, None
|
|
224
|
+
if value is RECOVER_NONE:
|
|
225
|
+
return True, None
|
|
226
|
+
return True, value
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ── read runtime ───────────────────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class MiddlewareRuntime:
|
|
233
|
+
"""The per-read middleware runtime — the read-path counterpart of the TS
|
|
234
|
+
``MiddlewareRuntime``.
|
|
235
|
+
|
|
236
|
+
Immutable wrt the middleware list and the request ``context``; built ONCE at
|
|
237
|
+
the request entry (R1) and threaded by reference into the root read and every
|
|
238
|
+
relation fan-out op so a read's hook set is stable for its whole life.
|
|
239
|
+
"""
|
|
240
|
+
|
|
241
|
+
def __init__(self, chain: Sequence[Middleware], context: RequestContext) -> None:
|
|
242
|
+
self._chain: Tuple[Middleware, ...] = tuple(chain)
|
|
243
|
+
self.context = context
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def active(self) -> bool:
|
|
247
|
+
"""``True`` when at least one middleware is registered for this read."""
|
|
248
|
+
return len(self._chain) > 0
|
|
249
|
+
|
|
250
|
+
def request_ctx(
|
|
251
|
+
self, kind: str, model: Any, params: Dict[str, Any]
|
|
252
|
+
) -> ReadRequestCtx:
|
|
253
|
+
"""Build the request-level context (R1 / R4 / R5 share it). Pure."""
|
|
254
|
+
return ReadRequestCtx(kind=kind, model=model, context=self.context, params=params)
|
|
255
|
+
|
|
256
|
+
def run_request_before(self, ctx: ReadRequestCtx) -> None:
|
|
257
|
+
"""R1 — request entry, before key-resolution / plan. Runs every
|
|
258
|
+
``read.before`` FIFO; a hook may mutate ``ctx.params`` and may raise to
|
|
259
|
+
cancel the read."""
|
|
260
|
+
for mw in self._chain:
|
|
261
|
+
hook = _hook(mw, "read", "before")
|
|
262
|
+
if hook is not None:
|
|
263
|
+
hook(ctx)
|
|
264
|
+
|
|
265
|
+
def run_request_after(self, ctx: ReadRequestCtx, result: Any) -> Any:
|
|
266
|
+
"""R4 — final assembled, relation-merged result. Runs every
|
|
267
|
+
``read.afterFetch`` LIFO, threading each hook's return into the next
|
|
268
|
+
(onion), and returns the final (possibly replaced) result."""
|
|
269
|
+
current = result
|
|
270
|
+
for mw in reversed(self._chain):
|
|
271
|
+
hook = _hook(mw, "read", "afterFetch")
|
|
272
|
+
if hook is not None:
|
|
273
|
+
current = hook(ctx, current)
|
|
274
|
+
return current
|
|
275
|
+
|
|
276
|
+
def run_request_error(self, ctx: ReadRequestCtx, err: BaseException) -> Any:
|
|
277
|
+
"""R5 (request-level) — runs every ``read.onError`` LIFO. A hook may
|
|
278
|
+
recover by RETURNING a value (the first such hook wins and short-circuits);
|
|
279
|
+
otherwise the original error rethrows."""
|
|
280
|
+
for mw in reversed(self._chain):
|
|
281
|
+
hook = _hook(mw, "read", "onError")
|
|
282
|
+
if hook is None:
|
|
283
|
+
continue
|
|
284
|
+
did, value = _recovered(hook(ctx, err))
|
|
285
|
+
if did:
|
|
286
|
+
return value
|
|
287
|
+
raise err
|
|
288
|
+
|
|
289
|
+
def run_op(
|
|
290
|
+
self,
|
|
291
|
+
operation: Dict[str, Any],
|
|
292
|
+
relation_path: Tuple[str, ...],
|
|
293
|
+
model: Any,
|
|
294
|
+
send: Callable[[Dict[str, Any]], List[Item]],
|
|
295
|
+
) -> List[Item]:
|
|
296
|
+
"""Drive ONE physical op (root read or any fan-out fetch) through R2 → send
|
|
297
|
+
→ R3, with R5 (op-level) on failure.
|
|
298
|
+
|
|
299
|
+
1. R2 (``read.op.before``, FIFO) — may mutate ``ctx.operation``, may raise;
|
|
300
|
+
2. the caller's ``send`` runs against the (possibly mutated) operation;
|
|
301
|
+
3. R3 (``read.op.afterFetch``, LIFO) — transforms the raw items (onion);
|
|
302
|
+
4. on any raise, R5 (``read.op.onError``, LIFO) runs: a hook may recover by
|
|
303
|
+
RETURNING an item list (those become this op's items); else rethrow.
|
|
304
|
+
|
|
305
|
+
``send`` receives ``ctx.operation`` so a caller that sends what this passes
|
|
306
|
+
through observes an R2 mutation on the actual send.
|
|
307
|
+
"""
|
|
308
|
+
ctx = ReadOpCtx(
|
|
309
|
+
kind=_op_kind(operation),
|
|
310
|
+
model=model,
|
|
311
|
+
context=self.context,
|
|
312
|
+
operation=operation,
|
|
313
|
+
relation_path=relation_path,
|
|
314
|
+
)
|
|
315
|
+
try:
|
|
316
|
+
for mw in self._chain:
|
|
317
|
+
hook = _hook(mw, "read", "op", "before")
|
|
318
|
+
if hook is not None:
|
|
319
|
+
hook(ctx)
|
|
320
|
+
items = send(ctx.operation)
|
|
321
|
+
for mw in reversed(self._chain):
|
|
322
|
+
hook = _hook(mw, "read", "op", "afterFetch")
|
|
323
|
+
if hook is not None:
|
|
324
|
+
items = hook(ctx, items)
|
|
325
|
+
return items
|
|
326
|
+
except BaseException as err: # noqa: BLE001 - mirrors TS catch-all
|
|
327
|
+
for mw in reversed(self._chain):
|
|
328
|
+
hook = _hook(mw, "read", "op", "onError")
|
|
329
|
+
if hook is None:
|
|
330
|
+
continue
|
|
331
|
+
did, value = _recovered(hook(ctx, err))
|
|
332
|
+
if did:
|
|
333
|
+
return value if value is not None else []
|
|
334
|
+
raise
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _op_kind(operation: Mapping[str, Any]) -> str:
|
|
338
|
+
"""Map a physical operation to the R2/R3 op kind (its ``type``)."""
|
|
339
|
+
return operation.get("type", "")
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# ── write runtime ──────────────────────────────────────────────────────────────
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
class WriteRuntime:
|
|
346
|
+
"""The per-write middleware runtime — the write-path counterpart of the TS
|
|
347
|
+
``WriteRuntime``.
|
|
348
|
+
|
|
349
|
+
Built ONCE per logical write call (single-op) or per transaction (the persist
|
|
350
|
+
hooks fire once for the whole atomic batch). Drives W1–W5 with the SAME
|
|
351
|
+
ordering: ``before*`` FIFO; ``after`` / ``on_error`` LIFO.
|
|
352
|
+
"""
|
|
353
|
+
|
|
354
|
+
def __init__(self, chain: Sequence[Middleware], context: RequestContext) -> None:
|
|
355
|
+
self._chain: Tuple[Middleware, ...] = tuple(chain)
|
|
356
|
+
self.context = context
|
|
357
|
+
|
|
358
|
+
@property
|
|
359
|
+
def active(self) -> bool:
|
|
360
|
+
"""``True`` when at least one middleware is registered for this write."""
|
|
361
|
+
return len(self._chain) > 0
|
|
362
|
+
|
|
363
|
+
@property
|
|
364
|
+
def has_write_after(self) -> bool:
|
|
365
|
+
"""``True`` when at least one registered middleware sets a ``write.after``
|
|
366
|
+
(W2) hook.
|
|
367
|
+
|
|
368
|
+
The single-op write path uses this to decide whether to request the
|
|
369
|
+
pre-write image (``ReturnValues: ALL_OLD``) so W2 receives a real
|
|
370
|
+
``{ oldImage, newImage }`` rather than ``{}`` — the cost is paid only when
|
|
371
|
+
a W2 hook actually exists (parity with the TS ``WriteRuntime.hasWriteAfter``).
|
|
372
|
+
"""
|
|
373
|
+
return any(_hook(mw, "write", "after") is not None for mw in self._chain)
|
|
374
|
+
|
|
375
|
+
def write_ctx(
|
|
376
|
+
self,
|
|
377
|
+
kind: str,
|
|
378
|
+
model: Any,
|
|
379
|
+
write_input: Dict[str, Any],
|
|
380
|
+
transaction: Optional[Dict[str, Any]] = None,
|
|
381
|
+
) -> WriteCtx:
|
|
382
|
+
"""Build a W1/W2/W5 logical-write context (pure). ``transaction`` is set
|
|
383
|
+
when the op is part of an atomic batch."""
|
|
384
|
+
return WriteCtx(
|
|
385
|
+
kind=kind,
|
|
386
|
+
model=model,
|
|
387
|
+
context=self.context,
|
|
388
|
+
input=write_input,
|
|
389
|
+
transaction=transaction,
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
def run_write_before(self, ctx: WriteCtx) -> None:
|
|
393
|
+
"""W1 — logical write, before effect derivation. Runs every
|
|
394
|
+
``write.before`` FIFO; a hook may mutate ``ctx.kind`` / ``ctx.input``
|
|
395
|
+
(incl. the delete→update rewrite) and may raise to cancel (in a tx, the
|
|
396
|
+
raise propagates and aborts the whole batch)."""
|
|
397
|
+
for mw in self._chain:
|
|
398
|
+
hook = _hook(mw, "write", "before")
|
|
399
|
+
if hook is not None:
|
|
400
|
+
hook(ctx)
|
|
401
|
+
|
|
402
|
+
def run_write_after(self, ctx: WriteCtx, change: Mapping[str, Any]) -> None:
|
|
403
|
+
"""W2 — logical write, after commit. Runs every ``write.after`` LIFO with
|
|
404
|
+
the committed change. Observe-only."""
|
|
405
|
+
for mw in reversed(self._chain):
|
|
406
|
+
hook = _hook(mw, "write", "after")
|
|
407
|
+
if hook is not None:
|
|
408
|
+
hook(ctx, change)
|
|
409
|
+
|
|
410
|
+
def run_write_error(self, ctx: WriteCtx, err: BaseException) -> Tuple[bool, Any]:
|
|
411
|
+
"""W5 (logical-level) — runs every ``write.onError`` LIFO. Returns
|
|
412
|
+
``(did_recover, value)``; the first hook that recovers wins. If none
|
|
413
|
+
recovers, the original error rethrows (symmetric with the read
|
|
414
|
+
``onError``)."""
|
|
415
|
+
for mw in reversed(self._chain):
|
|
416
|
+
hook = _hook(mw, "write", "onError")
|
|
417
|
+
if hook is None:
|
|
418
|
+
continue
|
|
419
|
+
did, value = _recovered(hook(ctx, err))
|
|
420
|
+
if did:
|
|
421
|
+
return True, value
|
|
422
|
+
raise err
|
|
423
|
+
|
|
424
|
+
def persist_ctx(
|
|
425
|
+
self,
|
|
426
|
+
items: List[Dict[str, Any]],
|
|
427
|
+
origins: Sequence[Mapping[str, Any]],
|
|
428
|
+
transaction: Optional[Dict[str, Any]] = None,
|
|
429
|
+
) -> PersistCtx:
|
|
430
|
+
"""Build a W3/W4/W5 persist context (pure)."""
|
|
431
|
+
return PersistCtx(
|
|
432
|
+
items=items,
|
|
433
|
+
origins=tuple(origins),
|
|
434
|
+
context=self.context,
|
|
435
|
+
transaction=transaction,
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
def run_persist(
|
|
439
|
+
self,
|
|
440
|
+
ctx: PersistCtx,
|
|
441
|
+
send: Callable[[List[Dict[str, Any]]], Any],
|
|
442
|
+
) -> Any:
|
|
443
|
+
"""Drive the PHYSICAL persist of one composed batch through W3 → send → W4,
|
|
444
|
+
with W5 (persist-level) on failure.
|
|
445
|
+
|
|
446
|
+
1. W3 (``write.persist.before``, FIFO) — may mutate ``ctx.items``, may raise;
|
|
447
|
+
2. the caller's ``send`` runs against the (possibly mutated) ``ctx.items``;
|
|
448
|
+
3. W4 (``write.persist.after``, LIFO) — observes the executor results;
|
|
449
|
+
4. on any raise, W5 (``write.persist.onError``, LIFO) runs: a hook may
|
|
450
|
+
recover by returning a value (treated as the send's result; W4 still
|
|
451
|
+
runs with it). If none recovers, the original error rethrows.
|
|
452
|
+
"""
|
|
453
|
+
try:
|
|
454
|
+
for mw in self._chain:
|
|
455
|
+
hook = _hook(mw, "write", "persist", "before")
|
|
456
|
+
if hook is not None:
|
|
457
|
+
hook(ctx)
|
|
458
|
+
results = send(ctx.items)
|
|
459
|
+
except BaseException as err: # noqa: BLE001 - mirrors TS catch-all
|
|
460
|
+
recovered_value: Any = None
|
|
461
|
+
did_recover = False
|
|
462
|
+
for mw in reversed(self._chain):
|
|
463
|
+
hook = _hook(mw, "write", "persist", "onError")
|
|
464
|
+
if hook is None:
|
|
465
|
+
continue
|
|
466
|
+
did, value = _recovered(hook(ctx, err))
|
|
467
|
+
if did:
|
|
468
|
+
recovered_value = value
|
|
469
|
+
did_recover = True
|
|
470
|
+
break
|
|
471
|
+
if not did_recover:
|
|
472
|
+
raise
|
|
473
|
+
results = recovered_value
|
|
474
|
+
for mw in reversed(self._chain):
|
|
475
|
+
hook = _hook(mw, "write", "persist", "after")
|
|
476
|
+
if hook is not None:
|
|
477
|
+
hook(ctx, results)
|
|
478
|
+
return results
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
# The shared EMPTY runtimes — no middleware, ``active`` is ``False``. Used as the
|
|
482
|
+
# default when a call carries no registered middleware so callers never need a
|
|
483
|
+
# null check; ``run_op`` / ``run_persist`` simply invoke ``send``.
|
|
484
|
+
NO_MIDDLEWARE = MiddlewareRuntime((), {})
|
|
485
|
+
NO_WRITE_MIDDLEWARE = WriteRuntime((), {})
|