graphddb-runtime 0.2.4__tar.gz → 0.3.0__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.
Files changed (42) hide show
  1. {graphddb_runtime-0.2.4 → graphddb_runtime-0.3.0}/PKG-INFO +1 -1
  2. {graphddb_runtime-0.2.4 → graphddb_runtime-0.3.0}/graphddb_runtime/__init__.py +19 -0
  3. {graphddb_runtime-0.2.4 → graphddb_runtime-0.3.0}/graphddb_runtime/async_runtime.py +14 -1
  4. graphddb_runtime-0.3.0/graphddb_runtime/middleware.py +485 -0
  5. {graphddb_runtime-0.2.4 → graphddb_runtime-0.3.0}/graphddb_runtime/runtime.py +682 -82
  6. {graphddb_runtime-0.2.4 → graphddb_runtime-0.3.0}/graphddb_runtime/transactions.py +133 -0
  7. {graphddb_runtime-0.2.4 → graphddb_runtime-0.3.0}/graphddb_runtime.egg-info/PKG-INFO +1 -1
  8. {graphddb_runtime-0.2.4 → graphddb_runtime-0.3.0}/graphddb_runtime.egg-info/SOURCES.txt +5 -0
  9. {graphddb_runtime-0.2.4 → graphddb_runtime-0.3.0}/pyproject.toml +1 -1
  10. graphddb_runtime-0.3.0/tests/test_integration_maintain.py +337 -0
  11. graphddb_runtime-0.3.0/tests/test_integration_middleware.py +312 -0
  12. graphddb_runtime-0.3.0/tests/test_maintain.py +280 -0
  13. graphddb_runtime-0.3.0/tests/test_middleware.py +1014 -0
  14. {graphddb_runtime-0.2.4 → graphddb_runtime-0.3.0}/README.md +0 -0
  15. {graphddb_runtime-0.2.4 → graphddb_runtime-0.3.0}/graphddb_runtime/batch.py +0 -0
  16. {graphddb_runtime-0.2.4 → graphddb_runtime-0.3.0}/graphddb_runtime/concurrency.py +0 -0
  17. {graphddb_runtime-0.2.4 → graphddb_runtime-0.3.0}/graphddb_runtime/cursor.py +0 -0
  18. {graphddb_runtime-0.2.4 → graphddb_runtime-0.3.0}/graphddb_runtime/errors.py +0 -0
  19. {graphddb_runtime-0.2.4 → graphddb_runtime-0.3.0}/graphddb_runtime/filters.py +0 -0
  20. {graphddb_runtime-0.2.4 → graphddb_runtime-0.3.0}/graphddb_runtime/hydration.py +0 -0
  21. {graphddb_runtime-0.2.4 → graphddb_runtime-0.3.0}/graphddb_runtime/limits.py +0 -0
  22. {graphddb_runtime-0.2.4 → graphddb_runtime-0.3.0}/graphddb_runtime/per_key_cursor.py +0 -0
  23. {graphddb_runtime-0.2.4 → graphddb_runtime-0.3.0}/graphddb_runtime/relations.py +0 -0
  24. {graphddb_runtime-0.2.4 → graphddb_runtime-0.3.0}/graphddb_runtime/templates.py +0 -0
  25. {graphddb_runtime-0.2.4 → graphddb_runtime-0.3.0}/graphddb_runtime.egg-info/dependency_links.txt +0 -0
  26. {graphddb_runtime-0.2.4 → graphddb_runtime-0.3.0}/graphddb_runtime.egg-info/requires.txt +0 -0
  27. {graphddb_runtime-0.2.4 → graphddb_runtime-0.3.0}/graphddb_runtime.egg-info/top_level.txt +0 -0
  28. {graphddb_runtime-0.2.4 → graphddb_runtime-0.3.0}/setup.cfg +0 -0
  29. {graphddb_runtime-0.2.4 → graphddb_runtime-0.3.0}/tests/test_concurrency.py +0 -0
  30. {graphddb_runtime-0.2.4 → graphddb_runtime-0.3.0}/tests/test_contract_runtime.py +0 -0
  31. {graphddb_runtime-0.2.4 → graphddb_runtime-0.3.0}/tests/test_integration.py +0 -0
  32. {graphddb_runtime-0.2.4 → graphddb_runtime-0.3.0}/tests/test_integration_command.py +0 -0
  33. {graphddb_runtime-0.2.4 → graphddb_runtime-0.3.0}/tests/test_integration_compose.py +0 -0
  34. {graphddb_runtime-0.2.4 → graphddb_runtime-0.3.0}/tests/test_integration_contract.py +0 -0
  35. {graphddb_runtime-0.2.4 → graphddb_runtime-0.3.0}/tests/test_integration_edge_derive.py +0 -0
  36. {graphddb_runtime-0.2.4 → graphddb_runtime-0.3.0}/tests/test_integration_edge_write.py +0 -0
  37. {graphddb_runtime-0.2.4 → graphddb_runtime-0.3.0}/tests/test_integration_events.py +0 -0
  38. {graphddb_runtime-0.2.4 → graphddb_runtime-0.3.0}/tests/test_integration_referential.py +0 -0
  39. {graphddb_runtime-0.2.4 → graphddb_runtime-0.3.0}/tests/test_integration_relations.py +0 -0
  40. {graphddb_runtime-0.2.4 → graphddb_runtime-0.3.0}/tests/test_integration_unique.py +0 -0
  41. {graphddb_runtime-0.2.4 → graphddb_runtime-0.3.0}/tests/test_relations.py +0 -0
  42. {graphddb_runtime-0.2.4 → graphddb_runtime-0.3.0}/tests/test_unit.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphddb-runtime
3
- Version: 0.2.4
3
+ Version: 0.3.0
4
4
  Summary: Thin DynamoDB executor for GraphDDB-generated Python repositories (single-operation core, issue #44).
5
5
  License: MIT
6
6
  Requires-Python: >=3.9
@@ -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 docs/middleware.md 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/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 (see docs/middleware.md).
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((), {})