Coverage for src/lumen/core/write_events.py: 89%
36 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-08-31 18:46 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-08-31 18:46 +0000
1"""
2Transport-neutral collection of write events.
4The repository records what it wrote; *who* reacts to it is decided elsewhere.
5This exists because write-time extensions (see ``lumen.cortex``) must run
6**after** the writing transaction has committed — a transaction bundle commits
7long after the individual ``create``/``update`` calls inside it returned.
9The repository therefore does not dispatch anything. It appends to whatever
10collector is active on the current context, and the layer that owns the
11transaction boundary (``ResourceService``, the bundle route) drains the
12collector once the data is durable.
14Nothing is recorded unless a collector is active, so the normal path pays for
15one ``ContextVar.get()`` per write.
16"""
18from __future__ import annotations
20import contextlib
21from collections.abc import Iterator
22from contextvars import ContextVar
23from dataclasses import dataclass
24from datetime import datetime
25from typing import Any, Literal
26from uuid import UUID
28WriteOperation = Literal["create", "update", "delete"]
31@dataclass(frozen=True)
32class WriteEvent:
33 """One durable change to one resource."""
35 tenant_id: UUID
36 operation: WriteOperation
37 resource_type: str
38 resource_id: str
39 version_id: int
40 last_updated: datetime | None
41 #: The resource as stored. ``None`` for deletes.
42 resource: dict[str, Any] | None
45_collector: ContextVar[list[WriteEvent] | None] = ContextVar("lumen_write_events", default=None)
48def record_write_event(event: WriteEvent) -> None:
49 """Append ``event`` to the active collector, if any."""
50 sink = _collector.get()
51 if sink is not None:
52 sink.append(event)
55@contextlib.contextmanager
56def collect_write_events() -> Iterator[list[WriteEvent]]:
57 """Collect the write events produced inside the block.
59 The list is filled as writes happen and is complete when the block exits.
60 Nested collectors are independent: the inner block captures its own writes
61 and the outer one does not see them, which keeps a plugin's own writes out
62 of the batch that triggered it.
63 """
64 events: list[WriteEvent] = []
65 token = _collector.set(events)
66 try:
67 yield events
68 finally:
69 _collector.reset(token)
72@contextlib.contextmanager
73def suspend_write_events() -> Iterator[None]:
74 """Run a block with no active collector."""
75 token = _collector.set(None)
76 try:
77 yield
78 finally:
79 _collector.reset(token)