@yoltra/core 0.6.0 → 0.7.0

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.
package/README.es.md CHANGED
@@ -3,12 +3,12 @@
3
3
  # @yoltra/core
4
4
 
5
5
  > 👉 🇲🇽 Versión en Español  |
6
- >  [ 🇺🇸 English Version](./README.md) 
6
+ >  [ 🇺🇸 English Versión](./README.md) 
7
7
 
8
8
  ![npm downloads](https://badgen.net/npm/dm/@yoltra/core)
9
9
  ![License](https://badgen.net/npm/license/@yoltra/core)
10
10
 
11
- **Contenedor de estado orientado a eventos, agnostico de framework, con suscripciones de grano
11
+ **Contenedor de estado orientado a eventos, agnóstico de framework, con suscripciones de grano
12
12
  fino por ruta.**
13
13
 
14
14
  `@yoltra/core` es la base de [yoltra](../../README.md).
@@ -17,7 +17,7 @@ Proporciona el store, el pipeline de eventos, middleware, efectos y el sistema d
17
17
 
18
18
  ---
19
19
 
20
- ## Instalacion
20
+ ## Instalación
21
21
 
22
22
  ```bash
23
23
  npm install @yoltra/core
@@ -27,14 +27,14 @@ npm install @yoltra/core
27
27
 
28
28
  ## El Pipeline de Eventos
29
29
 
30
- Cada llamada a `emit()` fluye a traves de un pipeline determinista:
30
+ Cada llamada a `emit()` fluye a través de un pipeline determinista:
31
31
 
32
32
  ```
33
33
  emit(channel, type, payload)
34
34
  │
35
35
  ├─ 0. Dedup (opt-in) ─── Omite un duplicado solo si dedupWindowMs > 0 o se pasa un dedupKey
36
36
  │
37
- │ ══ fase de reduccion SINCRONA — corre antes de que emit() retorne ══
37
+ │ ══ fase de reduccion SINCRONA: corre antes de que emit() retorne ══
38
38
  ├─ 1. Middleware ─── Hooks pre-reducer sincronos (devolver false para rechazar → evento "no confirmado")
39
39
  ├─ 2. Reducers ─── Cada slice que aplica se prepara, y todas se confirman bajo una sola raiz
40
40
  ├─ 3. Suscriptores de eventos ─── Notificaciones de eventos confirmados/no confirmados
@@ -43,11 +43,11 @@ emit(channel, type, payload)
43
43
  └─ 5. Efectos ─── Efectos secundarios ASYNC, una tarea independiente por evento (indexados para busqueda O(1))
44
44
  ```
45
45
 
46
- La fase de reduccion (1–4) es **sincrona**, asi que `getState()` es correcto en el instante en que
47
- `emit()` retorna — incluso con middleware. Los efectos (5) corren despues como una tarea async
46
+ La fase de reducción (1–4) es **síncrona**, así que `getState()` es correcto en el instante en que
47
+ `emit()` retorna, incluso con middleware. Los efectos (5) corren después como una tarea async
48
48
  independiente; la promesa de `emit()` se resuelve cuando terminan los efectos de ese evento. Cada
49
- etapa es interceptable, y `store.instrument()` expone todo el flujo — rutas hoja cambiadas, tiempos
50
- de reduccion, fase confirmado/rechazado — a las DevTools sin ningun `as any`. Ver la
49
+ etapa es interceptable, y `store.instrument()` expone todo el flujo (rutas hoja cambiadas, tiempos
50
+ de reducción, fase confirmado/rechazado) a las DevTools sin ningún `as any`. Ver la
51
51
  [Arquitectura del Pipeline de Eventos](../../docs/es/design/event-queue-architecture.md) para el
52
52
  modelo completo.
53
53
 
@@ -58,7 +58,7 @@ modelo completo.
58
58
  ### Eventos basados en canales
59
59
 
60
60
  Los eventos son tuplas `(channel, type, payload)`. Los canales proporcionan namespacing natural
61
- que escala en bases de codigo grandes:
61
+ que escala en bases de código grandes:
62
62
 
63
63
  ```typescript
64
64
  await store.emit("auth", "login", credentials);
@@ -66,28 +66,85 @@ await store.emit("analytics", "track", { event: "page_view" });
66
66
  await store.emit("ui", "toast", { message: "Saved!" });
67
67
  ```
68
68
 
69
- ### Suscripciones de grano fino via `connect()`
69
+ ### Suscripciones de grano fino vía `connect()`
70
70
 
71
- Suscribete a rutas de estado exactas usando notacion de puntos. Soporta wildcards `*` (un
72
- segmento) y `**` (cero o mas segmentos):
71
+ Suscríbete a rutas de estado exactas usando notación de puntos. Soporta wildcards `*` (un
72
+ segmento) y `**` (cero o más segmentos):
73
73
 
74
74
  ```typescript
75
- // Ruta exacta — se dispara cuando items[0].title cambia
75
+ // Ruta exacta: se dispara cuando items[0].title cambia
76
76
  store.connect({ reducer: "todos", property: "items.0.title" }, (change) =>
77
77
  console.log("title:", change.oldValue, "→", change.newValue),
78
78
  );
79
79
 
80
- // Wildcard de un segmento — se dispara cuando el titulo de CUALQUIER item cambia
80
+ // Wildcard de un segmento: se dispara cuando el titulo de CUALQUIER item cambia
81
81
  store.connect({ reducer: "todos", property: "items.*.title" }, (change) =>
82
82
  console.log("some title changed at", change.path),
83
83
  );
84
84
 
85
- // Wildcard profundo — se dispara cuando algo bajo items cambia
85
+ // Wildcard profundo: se dispara cuando algo bajo items cambia
86
86
  store.connect({ reducer: "todos", property: "items.**" }, (change) =>
87
87
  console.log("items tree changed at", change.path),
88
88
  );
89
89
  ```
90
90
 
91
+ ### Slices que contienen un solo valor
92
+
93
+ Una slice no tiene por qué ser un objeto. Un primitivo, un `Map`, un `Set` o una `Date` es un
94
+ estado de slice válido, y se confirma igual que cualquier otro:
95
+
96
+ ```typescript
97
+ const store = createStore({
98
+ name: "session",
99
+ reducer: {
100
+ token: {
101
+ state: null as string | null,
102
+ when: { keys: [["auth", "login"]] },
103
+ reducer: (_state, event) => event.payload.token,
104
+ },
105
+ },
106
+ });
107
+
108
+ await store.emit("auth", "login", { token: "abc123" });
109
+ store.getState().token; // "abc123"
110
+ ```
111
+
112
+ Una slice así no tiene ninguna propiedad debajo, así que sus cambios se reportan en la **raíz de
113
+ la slice**, la ruta vacía. Suscríbete a ella con `property: ""`:
114
+
115
+ ```typescript
116
+ store.connect({ reducer: "token", property: "" }, (change) =>
117
+ console.log("token:", change.oldValue, " --> ", change.newValue),
118
+ );
119
+ ```
120
+
121
+ Los tipos conocen la diferencia. `property` en una slice de valor raíz acepta `""` y nada más,
122
+ porque no hay ninguna clave que direccionar, y el valor vuelve correctamente tipado:
123
+
124
+ ```typescript
125
+ const token = useAtomicProp({ reducer: "token", property: "" }); // string | null
126
+ ```
127
+
128
+ ### `""` frente a `"**"`: observar una slice completa
129
+
130
+ Dos suscripciones que suenan iguales y no lo son:
131
+
132
+ | Patrón | Se dispara cuando |
133
+ |---|---|
134
+ | `""` | el **valor completo** de la slice se reemplaza: cambia un primitivo, se reconstruye un `Map`, una slice de objeto pasa a `null` |
135
+ | `"**"` | cambia **cualquier cosa** dentro de la slice, a cualquier profundidad. También coincide con la raíz, porque `**` coincide con cero segmentos |
136
+ | `"*"` | exactamente un nivel más abajo. Nunca coincide con la raíz |
137
+
138
+ **`"**"` es la suscripción a la slice completa, y funciona para toda slice sin importar su forma.**
139
+ Recurre a `""` solo cuando te refieras al valor raíz en sí; en una slice de objeto se queda
140
+ callada, porque una slice así reporta sus cambios en las hojas.
141
+
142
+ `Map` y `Set` se comparan por referencia, no por entrada: un reducer que devuelve un `Map` nuevo
143
+ es un cambio, mutar uno en el sitio no lo es. Eso se desprende del contrato de inmutabilidad en
144
+ vez de ser un caso especial. Construye una colección nueva en lugar de mutar la almacenada. Es
145
+ también la razón de que no tengan rutas debajo: `"byId"` es suscribible, `"byId.get"` no, y los
146
+ tipos lo dicen.
147
+
91
148
  ### Inmutabilidad
92
149
 
93
150
  El estado se congela profundamente antes de confirmarse. Las mutaciones lanzan error en modo
@@ -114,7 +171,7 @@ type AppEM = {
114
171
  system: { init: void; shutdown: void };
115
172
  };
116
173
 
117
- // Coincidir con claves de evento especificas (recomendado — preserva la correlacion de tipos)
174
+ // Coincidir con claves de evento especificas (recomendado: preserva la correlacion de tipos)
118
175
  const counterReducer = {
119
176
  state: { value: 0 },
120
177
  when: {
@@ -156,7 +213,7 @@ const globalLogger = {
156
213
 
157
214
  ## Middleware
158
215
 
159
- El middleware se ejecuta **sincronamente, antes** de los reducers y puede cancelar la propagacion
216
+ El middleware se ejecuta **sincronamente, antes** de los reducers y puede cancelar la propagación
160
217
  de eventos (devolver `false` para rechazar → evento "no confirmado"). El trabajo async va en los
161
218
  efectos, no en el middleware. Soporta tanto funciones directas (legacy) como objetos
162
219
  `MiddlewareSpec` con targeting:
@@ -164,7 +221,7 @@ efectos, no en el middleware. Soporta tanto funciones directas (legacy) como obj
164
221
  ```typescript
165
222
  import type { MiddlewareSpec } from "@yoltra/core";
166
223
 
167
- // Middleware con target — solo se ejecuta para eventos del canal admin
224
+ // Middleware con target: solo se ejecuta para eventos del canal admin
168
225
  const adminGuard: MiddlewareSpec<AppState, AppEM> = {
169
226
  when: { channel: "admin" },
170
227
  middleware: (state, event) => {
@@ -174,7 +231,7 @@ const adminGuard: MiddlewareSpec<AppState, AppEM> = {
174
231
  meta: { type: "middleware", name: "adminGuard" },
175
232
  };
176
233
 
177
- // Middleware global — se ejecuta para todos los eventos (sincrono: devuelve un boolean, nunca una Promise)
234
+ // Middleware global: se ejecuta para todos los eventos (sincrono: devuelve un boolean, nunca una Promise)
178
235
  const logger = (state, event) => {
179
236
  console.log("Event:", event.channel, event.type);
180
237
  return true;
@@ -189,7 +246,7 @@ const store = createStore({
189
246
  });
190
247
  ```
191
248
 
192
- ### Middleware dinamico
249
+ ### Middleware dinámico
193
250
 
194
251
  ```typescript
195
252
  const off = store.registerMiddleware((state, event) => {
@@ -202,8 +259,8 @@ off(); // Remover despues
202
259
 
203
260
  ## Efectos
204
261
 
205
- Los efectos se ejecutan **despues** de los reducers y ven el estado final. Estan indexados por
206
- evento para busqueda O(1):
262
+ Los efectos se ejecutan **después** de los reducers y ven el estado final. Están indexados por
263
+ evento para búsqueda O(1):
207
264
 
208
265
  ```typescript
209
266
  // Via spec del store
@@ -244,16 +301,16 @@ const off2 = store.onEffect("ui", "save", async (payload, getState, emit) => {
244
301
 
245
302
  ## Suscripciones a Eventos
246
303
 
247
- Suscribete a eventos (no al estado) desde la capa de vista. Util para notificaciones,
304
+ Suscríbete a eventos (no al estado) desde la capa de vista. Útil para notificaciones,
248
305
  animaciones y reaccionar a eventos rechazados:
249
306
 
250
307
  ```typescript
251
- // Eventos confirmados (por defecto) — eventos que pasaron el middleware
308
+ // Eventos confirmados (por defecto): eventos que pasaron el middleware
252
309
  const off = store.onEvent("ui", "save", (event, getState, emit, phase) => {
253
310
  console.log("Save committed:", event.payload);
254
311
  });
255
312
 
256
- // Eventos no confirmados — eventos rechazados por el middleware
313
+ // Eventos no confirmados: eventos rechazados por el middleware
257
314
  store.onEvent(
258
315
  "ui",
259
316
  "delete",
@@ -263,7 +320,7 @@ store.onEvent(
263
320
  "uncommitted",
264
321
  );
265
322
 
266
- // Todos los eventos — tanto confirmados como no confirmados
323
+ // Todos los eventos: tanto confirmados como no confirmados
267
324
  store.onEvent(
268
325
  "ui",
269
326
  "action",
@@ -276,13 +333,13 @@ store.onEvent(
276
333
 
277
334
  ---
278
335
 
279
- ## Los commits son atomicos entre slices
336
+ ## Los commits son atómicos entre slices
280
337
 
281
- Un evento que toca varias slices las escribe todas y despues notifica. Nadie observa un evento
282
- aplicado a medias: un suscriptor de una slice que lee `getState()` ve todas las demas slices del
338
+ Un evento que toca varias slices las escribe todas y después notifica. Nadie observa un evento
339
+ aplicado a medias: un suscriptor de una slice que lee `getState()` ve todas las demás slices del
283
340
  mismo evento ya aplicadas.
284
341
 
285
- Esto importa sobre todo donde un cambio se usa como senal para volver a leer, que es lo que hacen
342
+ Esto importa sobre todo donde un cambio se usa como señal para volver a leer, que es lo que hacen
286
343
  los hooks de React.
287
344
 
288
345
  ---
@@ -290,7 +347,7 @@ los hooks de React.
290
347
  ## Rechazar una escritura
291
348
 
292
349
  Un reducer devuelve `Rejected(reason)` en lugar de estado para declinar. **Se rechaza el evento
293
- completo**: ninguna slice escribe, no se emite ninguna notificacion de cambio, y quien llamo sabe
350
+ completo**: ninguna slice escribe, no se emite ninguna notificación de cambio, y quien llamo sabe
294
351
  por que.
295
352
 
296
353
  ```typescript
@@ -313,36 +370,36 @@ const store = createStore({
313
370
 
314
371
  const result = await store.emit("plan", "patch", { steps, expectedVersion: 1 });
315
372
 
316
- result.committed; // true — el middleware lo permitio
317
- result.written; // false — pero no se escribio nada
373
+ result.committed; // true: el middleware lo permitio
374
+ result.written; // false: no se escribio nada
318
375
  result.rejected?.reason;
319
376
  ```
320
377
 
321
378
  Rechazar **no** es lo mismo que devolver el estado sin cambios, que es indistinguible de "este
322
- evento no me concierne". Tampoco es lo mismo que lanzar: un reducer que lanza tiene un bug, asi
323
- que su slice queda aislada y las demas si escriben, mientras que un reducer que rechaza ha tomado
324
- una decision a la que cede el evento entero.
379
+ evento no me concierne". Tampoco es lo mismo que lanzar: un reducer que lanza tiene un bug, así
380
+ que su slice queda aislada y las demás sí escriben, mientras que un reducer que rechaza ha tomado
381
+ una decisión a la que cede el evento entero.
325
382
 
326
383
  `emit` resuelve a un `EmitResult` cuando terminan los efectos:
327
384
 
328
385
  | | |
329
386
  |---|---|
330
- | `committed` | el middleware no lo veto |
331
- | `written` | un reducer cambio el estado de verdad |
332
- | `rejected` | presente cuando un reducer rechazo, con su `reason` |
387
+ | `committed` | el middleware no lo vetó |
388
+ | `written` | un reducer cambió el estado de verdad |
389
+ | `rejected` | presente cuando un reducer rechazó, con su `reason` |
333
390
 
334
391
  La fase `written` de `onEvent` reporta lo mismo a los suscriptores. `committed` sigue
335
392
  significando **no vetado** y no se estrecho a proposito: se dispara para todo evento que el
336
- middleware permite, incluidos todos los eventos de un store sin reducers — la forma que toma un
337
- bus de notificaciones o de analitica.
393
+ middleware permite, incluidos todos los eventos de un store sin reducers, la forma que toma un
394
+ bus de notificaciones o de analítica.
338
395
 
339
396
  ---
340
397
 
341
- ## Peticion y respuesta — `store.call()`
398
+ ## Petición y respuesta: `store.call()`
342
399
 
343
- Todo consumidor de un bus de eventos acaba escribiendo peticion/respuesta a mano: generar un id,
344
- suscribirse, emparejar, expirar, desuscribirse. Son unas ochenta lineas y siempre traen los
345
- mismos dos bugs: la suscripcion sobrevive a la llamada, y `Quien Responde` que olvida devolver el
400
+ Todo consumidor de un bus de eventos acaba escribiendo petición/respuesta a mano: generar un id,
401
+ suscribirse, emparejar, expirar, desuscribirse. Son unas ochenta líneas y siempre traen los
402
+ mismos dos bugs: la suscripción sobrevive a la llamada, y `Quien Responde` que olvida devolver el
346
403
  id produce un timeout sin nada a lo que apuntar.
347
404
 
348
405
  ```typescript
@@ -364,7 +421,7 @@ store.registerEffect({
364
421
 
365
422
  ### Una llamada resuelve al evento, no al payload
366
423
 
367
- Porque muchas veces quien llama no sabe *cual* respuesta va a recibir. `reply` nombra los tipos
424
+ Porque muchas veces quien llama no sabe *cuál* respuesta va a recibir. `reply` nombra los tipos
368
425
  **terminales**, y el evento trae el discriminante:
369
426
 
370
427
  ```typescript
@@ -388,45 +445,45 @@ for await (const step of call) await render(step.payload);
388
445
  const { payload } = await call;
389
446
  ```
390
447
 
391
- La contrapresion es real, no un buffer con limite. `emit` resuelve solo cuando terminan sus
392
- efectos, y el colector es un efecto que no retorna hasta que el consumidor tomo el elemento — asi
448
+ La contrapresión es real, no un buffer con límite. `emit` resuelve solo cuando terminan sus
449
+ efectos, y el colector es un efecto que no retorna hasta que el consumidor tomo el elemento, así
393
450
  que un `Quien Responde` que escribe `await emit("job", "tick", chunk)` **va al ritmo del lector**.
394
451
 
395
- La contrapresion entra en juego **cuando empiezas a iterar**. Una llamada que solo se espera con
396
- `await` nunca extrae nada, asi que bloquear a su productor causaria un interbloqueo de la propia
397
- llamada: el progreso que nadie lee impediria que se enviara el evento terminal. Por eso el
398
- progreso no iterado se almacena hasta `highWaterMark` y despues se cuenta en `call.dropped`.
452
+ La contrapresión entra en juego **cuando empiezas a iterar**. Una llamada que solo se espera con
453
+ `await` nunca extrae nada, así que bloquear a su productor causaría un interbloqueo de la propia
454
+ llamada: el progreso que nadie lee impediría que se enviara el evento terminal. Por eso el
455
+ progreso no iterado se almacena hasta `highWaterMark` y después se cuenta en `call.dropped`.
399
456
 
400
457
  ### Retroceso
401
458
 
402
459
  | | |
403
460
  |---|---|
404
461
  | `timeoutMs` | **Inactividad**, no total: todo evento correlacionado lo reinicia, incluido el progreso. Por defecto 30s. |
405
- | `signal` | Un `AbortSignal`, para una fecha limite real o una accion cancelada. |
462
+ | `signal` | Un `AbortSignal`, para una fecha límite real o una acción cancelada. |
406
463
  | `call.cancel(reason)` | Deja de escuchar y liquida la llamada. |
407
464
 
408
- Termine como termine, la suscripcion se elimina y se libera cualquier productor detenido por la
409
- contrapresion. Un `Quien Responde` atascado es peor que el buffer sin limite que esto reemplazo.
465
+ Termine como termine, la suscripción se elimina y se libera cualquier productor detenido por la
466
+ contrapresión. Un `Quien Responde` atascado es peor que el buffer sin límite que esto reemplazo.
410
467
 
411
468
  ---
412
469
 
413
470
  ## Leer un valor al suscribirse
414
471
 
415
- `connect` empieza en "de ahora en adelante", asi que la primera lectura habia que repetirla en
416
- otro lado — la misma ruta en dos sitios, libres de divergir:
472
+ `connect` empieza en "de ahora en adelante", así que la primera lectura había que repetirla en
473
+ otro lado: la misma ruta en dos sitios, libres de divergir:
417
474
 
418
475
  ```typescript
419
476
  store.connect({ reducer: "todos", property: "items.0.title" }, render, { immediate: true });
420
477
  ```
421
478
 
422
- El primer cambio sintetico trae `oldValue: undefined` y **sin procedencia**, porque ningun evento
423
- lo causo. React no lo necesita: `useSyncExternalStore` ya lee una instantanea al montar.
479
+ El primer cambio sintético trae `oldValue: undefined` y **sin procedencia**, porque ningún evento
480
+ lo causó. React no lo necesita: `useSyncExternalStore` ya lee una instantánea al montar.
424
481
 
425
482
  ---
426
483
 
427
- ## De donde vino un cambio
484
+ ## De dónde vino un cambio
428
485
 
429
- Un `Change` nombra el evento que lo causo, asi que un suscriptor ya no tiene que duplicar la causa
486
+ Un `Change` nombra el evento que lo causó, así que un suscriptor ya no tiene que duplicar la causa
430
487
  dentro del estado:
431
488
 
432
489
  ```typescript
@@ -438,19 +495,41 @@ store.connect({ reducer: "orders", property: "status" }, (change) => {
438
495
  });
439
496
  ```
440
497
 
441
- La procedencia esta **ausente** cuando ningun evento causo el cambio — un salto de time-travel de
442
- DevTools, o la entrega `immediate` de arriba. La ausencia es la senal, en vez de un id inventado.
498
+ La procedencia está **ausente** cuando ningún evento causó el cambio: un salto de time-travel de
499
+ DevTools, o la entrega `immediate` de arriba. La ausencia es la señal, en vez de un id inventado.
500
+
501
+ ---
502
+
503
+ ## Deduplicación de Eventos (opt-in)
504
+
505
+ La deduplicación está **desactivada por defecto**. Yoltra nunca descarta en silencio eventos
506
+ idénticos legítimos y rápidos (doble-clics, `+1` repetidos). Actívala solo cuando de verdad quieras
507
+ coalescer:
508
+
509
+ ```typescript
510
+ // Por contenido: coalescer (channel, type, payload) identicos dentro de una ventana.
511
+ const store = createStore({
512
+ name: "App",
513
+ reducer: {
514
+ /* ... */
515
+ },
516
+ dedupWindowMs: 100, // default: 0 (desactivado)
517
+ });
518
+
519
+ // Por identidad: dedup por una clave explicita, p. ej. un doble-invoke de React Strict Mode en un efecto.
520
+ await store.emit("analytics", "pageView", { page }, { dedupKey: `pageView:${page}` });
521
+ ```
443
522
 
444
523
  ---
445
524
 
446
- ## Proteccion contra cascadas (activada por defecto)
525
+ ## Protección contra cascadas (activada por defecto)
447
526
 
448
- Dos consumidores conectados entre si — un suscriptor que emite lo que su propio reducer atiende, o
449
- dos slices que atienden los eventos de la otra — producen una cadena de eventos sin final. La cola
450
- de reduccion se drena de forma **sincrona**, asi que eso no es un programa lento: es una pestana
527
+ Dos consumidores conectados entre sí, ya sea un suscriptor que emite lo que su propio reducer atiende o
528
+ dos slices que atienden los eventos de la otra, producen una cadena de eventos sin final. La cola
529
+ de reducción se drena de forma **síncrona**, así que eso no es un programa lento: es una pestana
451
530
  congelada, o un core al 100%, sin error ni stack al que apuntar.
452
531
 
453
- Por eso cada evento lleva su posicion causal, y el store se niega a extender una cadena mas alla
532
+ Por eso cada evento lleva su posición causal, y el store se niega a extender una cadena más allá
454
533
  de un tope:
455
534
 
456
535
  ```typescript
@@ -468,46 +547,24 @@ const store = createStore({
468
547
  });
469
548
  ```
470
549
 
471
- Un evento emitido mientras se atiende otro esta un nivel mas abajo que su causa, y lleva
472
- `parentId` y `depth` para que el ciclo sea legible despues. Ambos campos estan **ausentes** en un
473
- evento raiz, asi que los eventos que emite tu aplicacion siguen siendo identicos byte a byte.
550
+ Un evento emitido mientras se atiende otro está un nivel más abajo que su causa, y lleva
551
+ `parentId` y `depth` para que el ciclo sea legible después. Ambos campos están **ausentes** en un
552
+ evento raíz, así que los eventos que emite tu aplicación siguen siendo idénticos byte a byte.
474
553
 
475
554
  Superar el tope no lanza. El emit ofensor se rechaza, lo ya confirmado se mantiene, y `onCascade`
476
- (mas un error en consola) lo nombra — lanzar apareceria en el suscriptor o efecto que casualmente
555
+ (más un error en consola) lo nombra. Lanzar aparecería en el suscriptor o efecto que casualmente
477
556
  estuviera emitiendo, que es justo el fallo inatribuible que el tope existe para evitar.
478
557
 
479
558
  **Una rafaga ancha no es una cascada.** Un evento cuyo suscriptor emite quinientos hermanos es una
480
- forma legitima; la profundidad es lo que la distingue de un ciclo, y un bucle normal de
559
+ forma legítima; la profundidad es lo que la distingue de un ciclo, y un bucle normal de
481
560
  `store.emit` nunca acumula profundidad. `maxTransitionsPerDrain` acota el *ancho* y por eso viene
482
561
  desactivado.
483
562
 
484
563
  ---
485
564
 
486
- ## Deduplicacion de Eventos (opt-in)
565
+ ## Reducers Dinámicos
487
566
 
488
- La deduplicacion esta **desactivada por defecto** — yoltra nunca descarta en silencio eventos
489
- identicos legitimos y rapidos (doble-clics, `+1` repetidos). Actívala solo cuando de verdad quieras
490
- coalescer:
491
-
492
- ```typescript
493
- // Por contenido: coalescer (channel, type, payload) identicos dentro de una ventana.
494
- const store = createStore({
495
- name: "App",
496
- reducer: {
497
- /* ... */
498
- },
499
- dedupWindowMs: 100, // default: 0 (desactivado)
500
- });
501
-
502
- // Por identidad: dedup por una clave explicita — p. ej. un doble-invoke de React Strict Mode en un efecto.
503
- await store.emit("analytics", "pageView", { page }, { dedupKey: `pageView:${page}` });
504
- ```
505
-
506
- ---
507
-
508
- ## Reducers Dinamicos
509
-
510
- Agrega o elimina slices de reducer en tiempo de ejecucion:
567
+ Agrega o elimina slices de reducer en tiempo de ejecución:
511
568
 
512
569
  ```typescript
513
570
  const dispose = store.registerReducer("filters", {
@@ -550,24 +607,24 @@ if (import.meta.hot) {
550
607
 
551
608
  ---
552
609
 
553
- ## Mejores Practicas
610
+ ## Mejores Prácticas
554
611
 
555
- ### El estado es sincrono; haz `await` solo por los efectos
612
+ ### El estado es síncrono; haz `await` solo por los efectos
556
613
 
557
- La fase de reduccion es sincrona, asi que el estado refleja tu evento en el instante en que `emit()`
558
- retorna — sin `await` para leerlo. Haz `await` de `emit()` cuando ademas quieras que los efectos de
614
+ La fase de reducción es síncrona, así que el estado refleja tu evento en el instante en que `emit()`
615
+ retorna, sin `await` para leerlo. Haz `await` de `emit()` cuando además quieras que los efectos de
559
616
  _ese evento_ hayan terminado:
560
617
 
561
618
  ```typescript
562
619
  emit("todo", "add", todo);
563
- store.getState(); // Ya refleja la nueva tarea — sin await
620
+ store.getState(); // Ya refleja la nueva tarea. Sin await
564
621
 
565
622
  await emit("todo", "save", todo); // se resuelve cuando terminan los efectos de save
566
623
  ```
567
624
 
568
- ### Mantener los reducers rapidos
625
+ ### Mantener los reducers rápidos
569
626
 
570
- Los reducers son sincronos y corren en el mismo tick que `emit()`. Mueve el trabajo costoso a los
627
+ Los reducers son síncronos y corren en el mismo tick que `emit()`. Mueve el trabajo costoso a los
571
628
  efectos:
572
629
 
573
630
  ```typescript
@@ -600,31 +657,31 @@ store.registerEffect({
600
657
 
601
658
  ## Resumen de API
602
659
 
603
- ### Creacion del Store
660
+ ### Creación del Store
604
661
 
605
- | API | Descripcion |
662
+ | API | Descripción |
606
663
  | ----------------------------------------------- | ----------------------------------------------------- |
607
664
  | `createStore(spec)` | Crear un store (tipos inferidos de los reducers) |
608
- | `createStore<S, EM>(spec)` | Crear un store con tipos de estado/eventos explicitos |
665
+ | `createStore<S, EM>(spec)` | Crear un store con tipos de estado/eventos explícitos |
609
666
  | `store.emit(channel, type, payload)` | Emitir un evento (retorna una promesa) |
610
667
  | `store.getState()` | Obtener snapshot del estado actual (solo lectura) |
611
- | `store.subscribe(listener)` | Suscripcion gruesa (cualquier cambio de estado) |
612
- | `store.connect(spec, handler)` | Suscripcion de grano fino por ruta con wildcards |
613
- | `store.onEvent(channel, type, handler, phase?)` | Suscripcion a eventos (committed/uncommitted/all) |
668
+ | `store.subscribe(listener)` | Suscripción gruesa (cualquier cambio de estado) |
669
+ | `store.connect(spec, handler)` | Suscripción de grano fino por ruta con wildcards |
670
+ | `store.onEvent(channel, type, handler, phase?)` | Suscripción a eventos (committed/uncommitted/all) |
614
671
  | `store.onEffect(channel, type, handler)` | Shorthand de efecto para un solo evento |
615
672
  | `store.dispose()` | Limpiar timers y recursos |
616
673
 
617
- ### Registro Dinamico
674
+ ### Registro Dinámico
618
675
 
619
- | API | Descripcion |
676
+ | API | Descripción |
620
677
  | ----------------------------------- | ----------------------------------------- |
621
- | `store.registerReducer(name, spec)` | Agregar un slice en tiempo de ejecucion |
622
- | `store.registerMiddleware(fn)` | Agregar middleware en tiempo de ejecucion |
623
- | `store.registerEffect(spec)` | Agregar un efecto en tiempo de ejecucion |
678
+ | `store.registerReducer(name, spec)` | Agregar un slice en tiempo de ejecución |
679
+ | `store.registerMiddleware(fn)` | Agregar middleware en tiempo de ejecución |
680
+ | `store.registerEffect(spec)` | Agregar un efecto en tiempo de ejecución |
624
681
 
625
682
  ### HMR
626
683
 
627
- | API | Descripcion |
684
+ | API | Descripción |
628
685
  | --------------------------------------- | ------------------------------------------- |
629
686
  | `store.replaceReducers(reducers, opts)` | Reemplazar todos los reducers |
630
687
  | `store.replaceMiddleware(middleware)` | Reemplazar todos los middleware |
@@ -633,63 +690,185 @@ store.registerEffect({
633
690
 
634
691
  ### Helpers
635
692
 
636
- | API | Descripcion |
693
+ | API | Descripción |
637
694
  | ------------------------ | ---------------------------------------------------------------- |
638
695
  | `eventKeys<EM>()([...])` | Arrays de claves de evento con seguridad de tipos sin `as const` |
639
696
 
640
697
  ---
641
698
 
699
+ ## Guardar y restaurar estado
700
+
701
+ Dos funciones, porque las dos mitades ocurren en lados opuestos de la existencia del store.
702
+ `hydrate` produce el *estado inicial de las slices*, así que el store nace con él:
703
+
704
+ ```ts
705
+ import { createStore, createWebStorageAdapter, hydrate, persist, withHydration } from '@yoltra/core';
706
+
707
+ const adapter = createWebStorageAdapter(localStorage);
708
+ const hydration = await hydrate({ key: 'app', adapter, version: 3 });
709
+
710
+ const store = createStore({
711
+ name: 'App',
712
+ reducer: withHydration({ todos: todosSpec, ui: uiSpec }, hydration),
713
+ });
714
+
715
+ const stop = persist(store, { key: 'app', adapter, version: 3, slices: ['todos'] });
716
+ ```
717
+
718
+ Restaurar *después* de construir es la alternativa obvia y la equivocada: aplicar una
719
+ instantánea a un store vivo emite un cambio en todas las rutas, lo que en el arranque es un
720
+ parpadeo, una ráfaga de entradas de instrumentación que describen cambios que nadie hizo, y
721
+ efectos observando una transición que nunca ocurrió.
722
+
723
+ **Nada lanza en el arranque.** Un payload ausente, ilegible o no migrable recae en los valores
724
+ por defecto que declaraste y se reporta por `onError`. Un store que no arranca porque el
725
+ almacenamiento guarda JSON obsoleto es peor que uno que arranca de cero, y un disco lleno no
726
+ debería tumbar una página, así que los fallos de escritura se reportan igual en vez de lanzarse.
727
+
728
+ **Las versiones que no coinciden se rechazan, no se asumen.** Los reducers cambian, y una
729
+ instantánea escrita contra una forma anterior puede no ser estado válido para este build en
730
+ absoluto. Aporta `migrate` para actualizarla, o se descarta.
731
+
732
+ Las escrituras las dirige la instrumentación, así que un cambio confinado a una slice que no
733
+ estás persistiendo no cuesta nada, y una ráfaga se agrupa en una sola escritura. `Map`, `Set`,
734
+ `Date`, `BigInt`, `undefined` y las referencias circulares sobreviven al viaje de ida y vuelta:
735
+ `JSON.stringify` no falla con eso, los destruye en silencio.
736
+
737
+ Para un render en servidor, `dehydrate(store, { version })` produce el payload y
738
+ `hydrate({ source, version })` lo consume.
739
+
740
+ ---
741
+
742
+ ## Listas que se reordenan
743
+
744
+ La notificación por ruta es posicional para los arrays. `items.0.title` nombra un *hueco*, no
745
+ una cosa, así que `unshift`, `splice(0, 1)` y `sort` mueven casi todos los elementos a un hueco
746
+ distinto, y el diff reporta correctamente que casi todas las hojas cambiaron. Insertar una fila
747
+ al principio de mil despierta a mil suscriptores.
748
+
749
+ Eso es honesto en vez de ruidoso: con rutas posicionales el valor de casi cada índice cambió de
750
+ verdad. El remedio es la forma del estado, no un diff que se calle.
751
+
752
+ ```ts
753
+ import { createEntityAdapter } from '@yoltra/core';
754
+
755
+ const todos = createEntityAdapter<Todo>();
756
+
757
+ // state is { ids: [...], entities: { abc: {...} } }
758
+ todos.updateOne(state, { id: 'abc', changes: { done: true } });
759
+
760
+ // and the adapter hands out the paths, so they are never typed by hand
761
+ todos.pathTo('abc', 'title'); // "entities.abc.title"
762
+ todos.idsPath; // "ids"
763
+ ```
764
+
765
+ `entities.abc.title` sobrevive a insertar, eliminar y reordenar. Un contenedor de lista se
766
+ suscribe a `ids` y reordena sus hijos; las filas se suscriben a su propia entidad y siguen
767
+ dormidas durante un `sort`.
768
+
769
+ `ids` sigue siendo un array, así que un reordenamiento todavía reporta `ids.0`, `ids.1` y así
770
+ sucesivamente. Ese costo queda confinado, no eliminado. Lo que ganas es un costo proporcional a
771
+ lo que realmente cambió.
772
+
773
+ Para una lista pequeña que solo crece por el final, `items.0.title` está bien y es más simple.
774
+ El adapter es para colecciones que se reordenan, o que son lo bastante grandes como para que la
775
+ diferencia se note.
776
+
777
+ ### Lo que cuesta, medido
778
+
779
+ Con 1000 filas, hacer el diff después de una inserción al principio cuesta 1200 µs para un array
780
+ y 371 µs normalizado, y el array reporta alrededor de mil rutas cambiadas frente a dos. Ese es
781
+ el caso para el que existe el adapter.
782
+
783
+ Una actualización de un solo campo va al revés: 20 µs para el array frente a 470 µs normalizado.
784
+ `detectChangedProps` indexa un array pero enumera las claves de un objeto, construyendo dos
785
+ arrays de claves y un `Set` por comparación, así que un mapa de entidades ancho es más caro de
786
+ recorrer aunque casi nada dentro se haya movido. Los números están en `benchmarks/`, y cerrar
787
+ esa brecha es trabajo con seguimiento, no una propiedad de normalizar como tal.
788
+
789
+ Así que: normaliza las colecciones que se reordenan o que rotan mucho. Una colección grande a la
790
+ que solo se le editan campos individuales está mejor como array hoy.
791
+
792
+ ---
793
+
642
794
  ## Rendimiento
643
795
 
644
- | Metrica | Valor |
645
- | --------------------- | ------------------------------------------- |
646
- | **Tamano del bundle** | 9.2 KB para el store (minificado + gzipped) |
647
- | **Tree-shakeable** | Si (modulos ES) |
648
- | **Dependencias** | Cero |
649
- | **TypeScript** | Definiciones de tipos completas incluidas |
796
+ | Métrica | Valor |
797
+ | --------------------- | ----------------------------------------- |
798
+ | **Tamaño del bundle** | Medido en cada build, ver la tabla abajo |
799
+ | **Tree-shakeable** | Sí (módulos ES) |
800
+ | **Dependencias** | Cero |
801
+ | **TypeScript** | Definiciones de tipos completas incluidas |
802
+
803
+ El tamaño del bundle se verifica, no se afirma: `rush size` empaqueta el paquete como lo haría
804
+ un consumidor (sacudido, minificado, comprimido con gzip) y falla cuando excede el
805
+ presupuesto declarado en `package.json`. La tabla de abajo la escribe esa misma verificación,
806
+ así que no puede desviarse de lo que se midió; editarla a mano hace fallar el CI.
807
+
808
+ La cifra que importa es lo que importas, no lo que el paquete exporta:
809
+
810
+ <!-- size-table:start -->
811
+ | Import | Tamaño | Presupuesto |
812
+ | --- | --- | --- |
813
+ | `{ createStore }` | 8.3 KB | 14 KB |
814
+ | `{ createStore, hydrate, persist }` | 9.7 KB | 16 KB |
815
+ | todo | 11.2 KB | 18 KB |
816
+ <!-- size-table:end -->
817
+
818
+ Estas son cifras de **producción**: lo que públicas una vez que tu empaquetador define
819
+ `NODE_ENV=production` y las guardas exclusivas de desarrollo desaparecen. La columna de
820
+ presupuesto es el techo que `rush size` impone, y se verifica contra un build de desarrollo,
821
+ que es el mayor de los dos: el código exclusivo de desarrollo no puede crecer sin que nadie lo
822
+ note solo porque nunca llega a un usuario. Por eso el margen que se infiere aquí es
823
+ deliberadamente conservador.
824
+
825
+ La **distancia entre filas** es la afirmación de tree-shaking, y es lo que hay que vigilar: la
826
+ persistencia añade 1.5 KB a quienes la importan y nada a los demás, y el barrel completo está
827
+ 2.9 KB por encima del store. La última fila es un detector de crecimiento; `import * as all` no
828
+ es algo que nadie escriba.
650
829
 
651
830
  ---
652
831
 
653
- ## Documentacion
832
+ ## Documentación
654
833
 
655
- - **[README raiz de yoltra](../../README.md)** --
656
- Descripcion general y configuracion rapida
657
- - **[@yoltra/react](../react/README.md)** --
834
+ - **[README raíz de yoltra](../../README.md)**:
835
+ Descripción general y configuración rápida
836
+ - **[@yoltra/react](../react/README.md)**:
658
837
  Hooks de React y Suspense
659
- - **[Guia de Inicio Rapido](https://github.com/yoltra/yoltra/blob/main/docs/en/QUICK_START_GUIDE.md)**
660
- -- Cinco pasos hacia una app funcional
661
- - **[Arquitectura de Cola de Eventos](https://github.com/yoltra/yoltra/blob/main/docs/en/design/event-queue-architecture.md)**
662
- -- Inmersion tecnica profunda
663
- - **[Comparacion de Bibliotecas](https://github.com/yoltra/yoltra/blob/main/docs/en/design/state-management-library-comparison.md)**
664
- -- Comparacion arquitectonica
838
+ - **[Guia de Inicio Rápido](https://github.com/yoltra/yoltra/blob/main/docs/en/QUICK_START_GUIDE.md)**:
839
+ Cinco pasos hacia una app funcional
840
+ - **[Arquitectura de Cola de Eventos](https://github.com/yoltra/yoltra/blob/main/docs/en/design/event-queue-architecture.md)**:
841
+ Inmersión técnica profunda
842
+ - **[Comparación de Bibliotecas](https://github.com/yoltra/yoltra/blob/main/docs/en/design/state-management-library-comparison.md)**:
843
+ Comparación arquitectónica
665
844
 
666
845
  ---
667
846
 
668
847
  ## Ejemplos
669
848
 
670
- - **[App de Tareas](https://github.com/yoltra/yoltra/blob/main/examples/v0/yoltra-in-react)** --
849
+ - **[App de Tareas](https://github.com/yoltra/yoltra/blob/main/examples/v0/yoltra-in-react)**:
671
850
  CRUD completo con perfilado de rendimiento · [▶ Abrir la demo en vivo](https://yoltra.dev/es/demos/in-react)
672
- - **[Logo Cinetico](https://github.com/yoltra/yoltra/blob/main/examples/v0/yoltra-kinetic-logo)**
673
- -- 3000 círculos con simulación física. · [▶ Abrir la demo en vivo](https://yoltra.dev/es/demos/kinetic-logo)
674
- - **[Integracion con Next.js](https://github.com/yoltra/yoltra/blob/main/examples/v0/yoltra-in-nextjs)**
675
- -- Pages Router, estado de cliente + cambio de tema · [▶ Abrir la demo en vivo](https://yoltra.dev/es/demos/in-nextjs)
851
+ - **[Logo Cinético](https://github.com/yoltra/yoltra/blob/main/examples/v0/yoltra-kinetic-logo)**:
852
+ 3000 círculos con simulación física. · [▶ Abrir la demo en vivo](https://yoltra.dev/es/demos/kinetic-logo)
853
+ - **[Integración con Next.js](https://github.com/yoltra/yoltra/blob/main/examples/v0/yoltra-in-nextjs)**:
854
+ Pages Router, estado de cliente + cambio de tema · [▶ Abrir la demo en vivo](https://yoltra.dev/es/demos/in-nextjs)
676
855
 
677
856
  ---
678
857
 
679
858
  ## Contribuir
680
859
 
681
- - [Raiz del Monorepo](../../README.md)
682
- - [Guia de Contribucion](https://github.com/yoltra/yoltra/blob/main/CONTRIBUTING.md)
860
+ - [Raíz del Monorepo](../../README.md)
861
+ - [Guia de Contribución](https://github.com/yoltra/yoltra/blob/main/CONTRIBUTING.md)
683
862
 
684
863
  ---
685
864
 
686
865
  ## Estado
687
866
 
688
- **Release Candidate** -- Las APIs son estables, usadas en produccion, cambios menores posibles
867
+ **Release Candidate**. Las APIs son estables, usadas en producción, cambios menores posibles
689
868
  antes de v1.0.0.
690
869
 
691
870
  ---
692
871
 
693
872
  ## Licencia
694
873
 
695
- **MIT** -- Libre para usar en proyectos comerciales y de codigo abierto.
874
+ **MIT**. Libre para usar en proyectos comerciales y de código abierto.