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:

ts
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.

python
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:

MatcherSignificado
{"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.

python
@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.

python
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ón

Patrones 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:

python
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".

python
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.

python
@store.middleware()
def guard(state, event, emit):
    if event.type == "delete" and not state["auth"]["is_admin"]:
        return False     # veto
    return True

Un 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).

python
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:

python
@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:

python
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:

ModoComportamiento
"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:

python
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:

python
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.

python
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:

python
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 reducers

Son 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:

python
with create_store("App") as store:
    ...          # store.dispose() corre al salir

#Ver también

Editar esta página en GitHub