Documentación
Guía para desarrolladores
Esta guía cubre el modelo completo de Yoltra y cada función. Si aún no lo has hecho, revisa primero el inicio rápido.
#Modelo mental
Yoltra es una única fuente de verdad con un solo ciclo:
emit(channel, type, payload)
│ (síncrono, bajo un lock reentrante)
▼
middleware ──► reducers ──► comparación estructural ──► suscriptores atómicos
│ │
│ └─ rutas hoja con puntos + ancestros
▼ confirmado
agenda efectos (async, por evento) ──► EmitHandle- Las escrituras son eventos. Nunca mutas el estado directamente; emites.
- Los reducers pliegan los eventos en slices del estado (puros
(prev, event) → next). - Una comparación estructural de cada slice cambiado produce rutas hoja con puntos.
- Los suscriptores atómicos (
connect) se disparan exactamente en las rutas que cambiaron. - Los efectos corren de forma asíncrona después de que el evento se confirma.
No hay magia reactiva — sin proxies, sin grafo de dependencias. La reactividad es
diff(prev, next) → rutas → suscriptores, lo que la hace barata y portable.
#Estado y slices
El estado es un dict superficial de nombre de slice → valor del slice. Cada
reducer es dueño de un slice. Los valores de slice son dict/list/primitivos
comunes (la vía rápida), o dataclasses/modelos de Pydantic donde quieras
validación.
from yoltra import create_store
store = create_store("App")
@store.reducer(name="counter", state={"n": 0}, when={"keys": [("math", "inc")]})
def counter(prev, event):
return {"n": prev["n"] + event.payload}
store.emit("math", "inc", 5)
store.get_state()["counter"] # {'n': 5}get_state() retorna la instantánea inmutable actual. En desarrollo es un proxy
de solo lectura (ver Inmutabilidad); léela una vez y reúsala
dentro de un cálculo, porque la referencia cambia en la siguiente confirmación.
#Reducers
Un reducer es un callable simple (prev_slice, event) → next_slice. Retorna
prev sin cambios (el mismo objeto) para indicar que no hubo cambio — el
store verifica la identidad, así que un slice intacto no hace trabajo alguno
aguas abajo.
Los reducers se direccionan con un matcher When:
| Matcher | Significado |
|---|---|
{"keys": [("ui", "add"), ("ui", "rename")]} | estos pares (channel, type) exactos |
{"channel": "ui"} | todo evento en el canal ui |
{"channels": ["ui", "data"]} | todo evento en cualquiera de los canales listados |
{"any": True} | todo evento |
Los reducers basados en claves se enrutan en O(1); los demás se evalúan por evento.
@store.reducer(name="todos", state={"items": []},
when={"keys": [("ui", "add"), ("ui", "rename")]})
def todos(prev, event):
if event.type == "add":
return {"items": [*prev["items"], {"title": event.payload, "done": False}]}
if event.type == "rename":
i, title = event.payload
items = list(prev["items"])
items[i] = {**items[i], "title": title}
return {"items": items}
return prev#El objeto evento
Los handlers reciben un Event con .channel, .type, .payload y un .id
generado por el store.
#Suscripciones atómicas (por ruta)
connect se suscribe a una ruta con puntos dentro de un slice y recibe un
Change (.old_value, .new_value, .path) cuando esa ruta cambia.
off = store.connect(
reducer="todos",
property="items.*.title",
handler=lambda c: print(c.path, c.old_value, "→", c.new_value),
)
# ... más tarde
off() # cada registro retorna una función para cancelar la suscripciónPatrones glob sobre los segmentos de la ruta:
*coincide con exactamente un segmento —items.*.title**coincide con cero o más segmentos —items.**,**.title
Expansión de ancestros: cuando cambia una hoja profunda, el store también
notifica a los suscriptores de cada ruta ancestro. Un suscriptor en items se
dispara cuando cambia items.0.title.
#El detalle de la longitud de listas
La comparación estructural trata un cambio de longitud como un cambio en la ruta completa de la lista, y un cambio de elemento en el lugar como un cambio en la hoja:
store.connect(reducer="todos", property="items.0.title",
handler=lambda c: print("leaf:", c.new_value))
store.connect(reducer="todos", property="items",
handler=lambda c: print("list changed"))
store.emit("ui", "add", "A") # longitud 0→1 → "list changed"
store.emit("ui", "rename", (0, "B")) # en el lugar → "leaf: B"Si necesitas reacciones por item cuando se agregan items, suscríbete a la lista
(o a un glob como items.**) en lugar de a un índice fijo.
#Suscripciones gruesas
subscribe(fn) registra un listener grueso que se llama una vez por cada
confirmación que realmente cambió el estado — ideal para integraciones de
"volver a renderizar todo".
off = store.subscribe(lambda: print("state changed"))#Middleware
El middleware corre antes de los reducers y puede vetar un evento retornando
False. Retornar None o True lo deja pasar.
@store.middleware()
def guard(state, event, emit):
if event.type == "delete" and not state["auth"]["is_admin"]:
return False # veto
return TrueUn evento vetado notifica a los suscriptores de eventos uncommitted (abajo) y
nunca llega a los reducers. Las excepciones lanzadas en el middleware se capturan
y se tratan como un veto (a prueba de fallos, "fail-closed").
#Suscripciones a eventos
on_event(channel, type, handler, phase="committed") observa eventos sin afectar
el pipeline (dispara y olvida). El handler es (event, get_state, emit, phase).
store.on_event("ui", "add",
lambda event, get_state, emit, phase: log(event.payload, phase))
# fases: "committed" (por defecto), "uncommitted" (vetado), "all"
store.on_event("ui", "delete", audit, phase="all")#Efectos y el modelo asíncrono
Los efectos corren después de que un evento se confirma. Regístralos por clave o por patrón:
@store.effect(when={"keys": [("ui", "add")]})
async def persist(event, get_state, emit):
await db.save(get_state()["todos"]["items"])
# una sola clave, forma imperativa:
store.on_effect("ui", "add", persist)Los efectos pueden ser síncronos o asíncronos. Los errores en los efectos se
capturan y se registran — emit nunca lanza por causa de un efecto (pasa
on_effect_error a create_store para un callback).
#EmitHandle: puente entre síncrono y asíncrono
emit() retorna de inmediato (el estado ya se confirmó) con un EmitHandle que
se resuelve cuando terminan los efectos de ese evento:
handle = store.emit("ui", "add", "milk")
assert store.get_state()["todos"]["items"] # ya confirmado
handle.wait() # síncrono: bloquea hasta que terminen los efectos
handle.effects_done # bool
# en código async:
await store.emit("ui", "add", "milk") # espera los efectos
await store.aemit("ui", "add", "milk") # alias más legible#effects_mode
create_store(..., effects_mode=...) controla dónde corren los efectos:
| Modo | Comportamiento |
|---|---|
"auto" (por defecto) | Si hay un event loop corriendo, agenda los efectos en él; si no, los corre en un hilo demonio propio del store. Funciona igual en scripts, notebooks y servidores. |
"inline" | Corre efectos síncronos de forma síncrona dentro de emit(). Sin hilos, totalmente determinista — ideal para pruebas. Los efectos async se rechazan al registrarse. |
"asyncio" | Requiere un loop corriendo y siempre agenda en él. |
Los emits reentrantes (un efecto que emite otro evento) nunca causan interbloqueo — los efectos de cada evento son su propia tarea.
#Inmutabilidad
El estado es inmutable por contrato. En producción
(YOLTRA_ENV=production) esto es un paso directo sin costo. En desarrollo (por
defecto), get_state() y el prev que se pasa a los reducers se envuelven en un
proxy recursivo de solo lectura, así que una mutación accidental en el lugar
lanza ImmutableStateError:
store.get_state()["todos"]["items"].append(x) # ImmutableStateError (dev)Siempre construye valores nuevos en los reducers ({**prev, ...}, [*items, x]);
nunca mutes prev. Alterna programáticamente con yoltra.set_dev(True/False)
(útil en notebooks).
#Deduplicación (opcional)
La deduplicación está desactivada por defecto. Actívala con una ventana para dedup por contenido, o usa una clave de identidad por emit:
store = create_store("App", dedup_window_ms=100) # dedup por contenido
store.emit("ui", "add", "x")
store.emit("ui", "add", "x") # idéntico dentro de 100 ms → se omite
store.emit("ui", "save", data, dedup_key="save-1") # dedup por identidad#Concurrencia
emit()es seguro entre hilos (protegido por un lock reentrante); los emits concurrentes se serializan, así que no se pierden actualizaciones y se preserva el orden FIFO.get_state()no usa locks — una lectura atómica de una instantánea inmutable.- Los reducers, middleware y suscriptores corren en el hilo que llama — mantenlos puros y rápidos.
- Los efectos pueden correr en otro hilo (el loop demonio en
auto); haz que el código de tus efectos sea seguro entre hilos/async respecto a recursos externos.
#Instrumentación
instrument(observer) recibe un InstrumentedEvent después de cada evento —
changed_paths, prev_values/next_values, reduce_time_ms y committed. Es
el punto de extensión para devtools y métricas, y no cuesta nada cuando no hay
ningún observer registrado.
store.instrument(lambda info: print(info.changed_paths, f"{info.reduce_time_ms:.3f}ms"))#Viaje en el tiempo (protegido)
Reproducir eventos y aplicar instantáneas externas es potente para depurar
corridas de agentes no deterministas, por eso está protegido detrás de
allow_replay:
store = create_store("App", allow_replay=True)
store._apply_external_state({"counter": {"n": 42}}) # restaura una instantánea
store._replay_events(snapshot, events) # reproduce a través de los reducersSon internos (con prefijo de guion bajo) y están desactivados salvo que optes por activarlos.
#Ciclo de vida
dispose() libera las suscripciones y desmonta el worker de efectos; Store
también es un context manager:
with create_store("App") as store:
... # store.dispose() corre al salir#Ver también
- Migrar desde
@yoltra/core(TS) - Pruebas
- Pizarra reactiva para agentes
- Referencia de la API:
docs/api/