@yoltra/core 0.5.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,27 +27,27 @@ 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
- ├─ 2. Reducers ─── Actualizaciones de estado sincronas, deteccion de cambios de grano fino por ruta
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
41
41
  ├─ 4. Suscriptores gruesos ─── Listeners externos del store (useSyncExternalStore, etc.), si el estado cambio
42
42
  │
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,10 +333,177 @@ store.onEvent(
276
333
 
277
334
  ---
278
335
 
279
- ## Deduplicacion de Eventos (opt-in)
336
+ ## Los commits son atómicos entre slices
337
+
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
340
+ mismo evento ya aplicadas.
341
+
342
+ Esto importa sobre todo donde un cambio se usa como señal para volver a leer, que es lo que hacen
343
+ los hooks de React.
344
+
345
+ ---
346
+
347
+ ## Rechazar una escritura
348
+
349
+ Un reducer devuelve `Rejected(reason)` en lugar de estado para declinar. **Se rechaza el evento
350
+ completo**: ninguna slice escribe, no se emite ninguna notificación de cambio, y quien llamo sabe
351
+ por que.
352
+
353
+ ```typescript
354
+ import { createStore, Rejected } from "@yoltra/core";
355
+
356
+ const store = createStore({
357
+ name: "plan",
358
+ reducer: {
359
+ plan: {
360
+ state: { steps: [], version: 1 },
361
+ when: { keys: [["plan", "patch"]] },
362
+ reducer: (state, event) =>
363
+ event.payload.expectedVersion === state.version
364
+ ? { ...state, steps: event.payload.steps, version: state.version + 1 }
365
+ : Rejected(`escritura obsoleta: esperaba v${event.payload.expectedVersion}`),
366
+ },
367
+ },
368
+ onRejected: (rejection, event, slice) => metrics.increment("write.refused", { slice }),
369
+ });
370
+
371
+ const result = await store.emit("plan", "patch", { steps, expectedVersion: 1 });
372
+
373
+ result.committed; // true: el middleware lo permitio
374
+ result.written; // false: no se escribio nada
375
+ result.rejected?.reason;
376
+ ```
377
+
378
+ Rechazar **no** es lo mismo que devolver el estado sin cambios, que es indistinguible de "este
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.
382
+
383
+ `emit` resuelve a un `EmitResult` cuando terminan los efectos:
384
+
385
+ | | |
386
+ |---|---|
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` |
390
+
391
+ La fase `written` de `onEvent` reporta lo mismo a los suscriptores. `committed` sigue
392
+ significando **no vetado** y no se estrecho a proposito: se dispara para todo evento que el
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.
395
+
396
+ ---
397
+
398
+ ## Petición y respuesta: `store.call()`
399
+
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
403
+ id produce un timeout sin nada a lo que apuntar.
404
+
405
+ ```typescript
406
+ const res = await store.call("rpc", "ask", { q: "quien?" }, { reply: ["rpc", "answer"] });
407
+ res.payload.text;
408
+ ```
409
+
410
+ `Quien Responde` no hace nada especial. Responde con el `emit` que recibio, y la marca causal del
411
+ store correlaciona ambos: **no hay id que generar, devolver ni olvidar**.
412
+
413
+ ```typescript
414
+ store.registerEffect({
415
+ when: { keys: [["rpc", "ask"]] },
416
+ effect: async (event, _get, emit) => {
417
+ await emit("rpc", "answer", await lookup(event.payload.q));
418
+ },
419
+ });
420
+ ```
421
+
422
+ ### Una llamada resuelve al evento, no al payload
423
+
424
+ Porque muchas veces quien llama no sabe *cuál* respuesta va a recibir. `reply` nombra los tipos
425
+ **terminales**, y el evento trae el discriminante:
426
+
427
+ ```typescript
428
+ const res = await store.call("rpc", "ask", { q }, { reply: ["rpc", ["answer", "error"]] });
429
+
430
+ switch (res.type) {
431
+ case "answer": return res.payload.text;
432
+ case "error": throw new Error(res.payload.reason);
433
+ }
434
+ ```
435
+
436
+ ### El progreso se transmite, y el productor espera
437
+
438
+ Cualquier evento correlacionado que **no** sea terminal es progreso. Itera la llamada para
439
+ consumirlo:
440
+
441
+ ```typescript
442
+ const call = store.call("job", "start", { id }, { reply: ["job", "done"], highWaterMark: 4 });
443
+
444
+ for await (const step of call) await render(step.payload);
445
+ const { payload } = await call;
446
+ ```
447
+
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í
450
+ que un `Quien Responde` que escribe `await emit("job", "tick", chunk)` **va al ritmo del lector**.
451
+
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`.
280
456
 
281
- La deduplicacion esta **desactivada por defecto** — yoltra nunca descarta en silencio eventos
282
- identicos legitimos y rapidos (doble-clics, `+1` repetidos). Actívala solo cuando de verdad quieras
457
+ ### Retroceso
458
+
459
+ | | |
460
+ |---|---|
461
+ | `timeoutMs` | **Inactividad**, no total: todo evento correlacionado lo reinicia, incluido el progreso. Por defecto 30s. |
462
+ | `signal` | Un `AbortSignal`, para una fecha límite real o una acción cancelada. |
463
+ | `call.cancel(reason)` | Deja de escuchar y liquida la llamada. |
464
+
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.
467
+
468
+ ---
469
+
470
+ ## Leer un valor al suscribirse
471
+
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:
474
+
475
+ ```typescript
476
+ store.connect({ reducer: "todos", property: "items.0.title" }, render, { immediate: true });
477
+ ```
478
+
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.
481
+
482
+ ---
483
+
484
+ ## De dónde vino un cambio
485
+
486
+ Un `Change` nombra el evento que lo causó, así que un suscriptor ya no tiene que duplicar la causa
487
+ dentro del estado:
488
+
489
+ ```typescript
490
+ store.connect({ reducer: "orders", property: "status" }, (change) => {
491
+ audit.record(change.path, change.newValue, {
492
+ causedBy: change.eventId,
493
+ via: `${change.channel}/${change.type}`,
494
+ });
495
+ });
496
+ ```
497
+
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
283
507
  coalescer:
284
508
 
285
509
  ```typescript
@@ -292,15 +516,55 @@ const store = createStore({
292
516
  dedupWindowMs: 100, // default: 0 (desactivado)
293
517
  });
294
518
 
295
- // Por identidad: dedup por una clave explicita — p. ej. un doble-invoke de React Strict Mode en un efecto.
519
+ // Por identidad: dedup por una clave explicita, p. ej. un doble-invoke de React Strict Mode en un efecto.
296
520
  await store.emit("analytics", "pageView", { page }, { dedupKey: `pageView:${page}` });
297
521
  ```
298
522
 
299
523
  ---
300
524
 
301
- ## Reducers Dinamicos
525
+ ## Protección contra cascadas (activada por defecto)
526
+
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
530
+ congelada, o un core al 100%, sin error ni stack al que apuntar.
531
+
532
+ Por eso cada evento lleva su posición causal, y el store se niega a extender una cadena más allá
533
+ de un tope:
534
+
535
+ ```typescript
536
+ const store = createStore({
537
+ name: "app",
538
+ reducer: { ... },
539
+
540
+ // Por defecto 64. Acotado configures o no: un fallo tan grave no deberia exigir
541
+ // configuracion para evitarse. Usa Infinity para renunciar a el conscientemente.
542
+ maxReduceDepth: 64,
543
+
544
+ onCascade: ({ event, depth, chain }) => {
545
+ report(`cascada en ${event.channel}/${event.type}, profundidad ${depth}`, chain);
546
+ },
547
+ });
548
+ ```
549
+
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.
553
+
554
+ Superar el tope no lanza. El emit ofensor se rechaza, lo ya confirmado se mantiene, y `onCascade`
555
+ (más un error en consola) lo nombra. Lanzar aparecería en el suscriptor o efecto que casualmente
556
+ estuviera emitiendo, que es justo el fallo inatribuible que el tope existe para evitar.
557
+
558
+ **Una rafaga ancha no es una cascada.** Un evento cuyo suscriptor emite quinientos hermanos es una
559
+ forma legítima; la profundidad es lo que la distingue de un ciclo, y un bucle normal de
560
+ `store.emit` nunca acumula profundidad. `maxTransitionsPerDrain` acota el *ancho* y por eso viene
561
+ desactivado.
562
+
563
+ ---
564
+
565
+ ## Reducers Dinámicos
302
566
 
303
- Agrega o elimina slices de reducer en tiempo de ejecucion:
567
+ Agrega o elimina slices de reducer en tiempo de ejecución:
304
568
 
305
569
  ```typescript
306
570
  const dispose = store.registerReducer("filters", {
@@ -343,24 +607,24 @@ if (import.meta.hot) {
343
607
 
344
608
  ---
345
609
 
346
- ## Mejores Practicas
610
+ ## Mejores Prácticas
347
611
 
348
- ### El estado es sincrono; haz `await` solo por los efectos
612
+ ### El estado es síncrono; haz `await` solo por los efectos
349
613
 
350
- La fase de reduccion es sincrona, asi que el estado refleja tu evento en el instante en que `emit()`
351
- 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
352
616
  _ese evento_ hayan terminado:
353
617
 
354
618
  ```typescript
355
619
  emit("todo", "add", todo);
356
- store.getState(); // Ya refleja la nueva tarea — sin await
620
+ store.getState(); // Ya refleja la nueva tarea. Sin await
357
621
 
358
622
  await emit("todo", "save", todo); // se resuelve cuando terminan los efectos de save
359
623
  ```
360
624
 
361
- ### Mantener los reducers rapidos
625
+ ### Mantener los reducers rápidos
362
626
 
363
- 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
364
628
  efectos:
365
629
 
366
630
  ```typescript
@@ -393,31 +657,31 @@ store.registerEffect({
393
657
 
394
658
  ## Resumen de API
395
659
 
396
- ### Creacion del Store
660
+ ### Creación del Store
397
661
 
398
- | API | Descripcion |
662
+ | API | Descripción |
399
663
  | ----------------------------------------------- | ----------------------------------------------------- |
400
664
  | `createStore(spec)` | Crear un store (tipos inferidos de los reducers) |
401
- | `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 |
402
666
  | `store.emit(channel, type, payload)` | Emitir un evento (retorna una promesa) |
403
667
  | `store.getState()` | Obtener snapshot del estado actual (solo lectura) |
404
- | `store.subscribe(listener)` | Suscripcion gruesa (cualquier cambio de estado) |
405
- | `store.connect(spec, handler)` | Suscripcion de grano fino por ruta con wildcards |
406
- | `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) |
407
671
  | `store.onEffect(channel, type, handler)` | Shorthand de efecto para un solo evento |
408
672
  | `store.dispose()` | Limpiar timers y recursos |
409
673
 
410
- ### Registro Dinamico
674
+ ### Registro Dinámico
411
675
 
412
- | API | Descripcion |
676
+ | API | Descripción |
413
677
  | ----------------------------------- | ----------------------------------------- |
414
- | `store.registerReducer(name, spec)` | Agregar un slice en tiempo de ejecucion |
415
- | `store.registerMiddleware(fn)` | Agregar middleware en tiempo de ejecucion |
416
- | `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 |
417
681
 
418
682
  ### HMR
419
683
 
420
- | API | Descripcion |
684
+ | API | Descripción |
421
685
  | --------------------------------------- | ------------------------------------------- |
422
686
  | `store.replaceReducers(reducers, opts)` | Reemplazar todos los reducers |
423
687
  | `store.replaceMiddleware(middleware)` | Reemplazar todos los middleware |
@@ -426,63 +690,185 @@ store.registerEffect({
426
690
 
427
691
  ### Helpers
428
692
 
429
- | API | Descripcion |
693
+ | API | Descripción |
430
694
  | ------------------------ | ---------------------------------------------------------------- |
431
695
  | `eventKeys<EM>()([...])` | Arrays de claves de evento con seguridad de tipos sin `as const` |
432
696
 
433
697
  ---
434
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
+
435
794
  ## Rendimiento
436
795
 
437
- | Metrica | Valor |
796
+ | Métrica | Valor |
438
797
  | --------------------- | ----------------------------------------- |
439
- | **Tamano del bundle** | ~8KB (minificado + gzipped) |
440
- | **Tree-shakeable** | Si (modulos ES) |
798
+ | **Tamaño del bundle** | Medido en cada build, ver la tabla abajo |
799
+ | **Tree-shakeable** | Sí (módulos ES) |
441
800
  | **Dependencias** | Cero |
442
801
  | **TypeScript** | Definiciones de tipos completas incluidas |
443
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.
829
+
444
830
  ---
445
831
 
446
- ## Documentacion
832
+ ## Documentación
447
833
 
448
- - **[README raiz de yoltra](../../README.md)** --
449
- Descripcion general y configuracion rapida
450
- - **[@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)**:
451
837
  Hooks de React y Suspense
452
- - **[Guia de Inicio Rapido](https://github.com/yoltra/yoltra/blob/main/docs/en/QUICK_START_GUIDE.md)**
453
- -- Cinco pasos hacia una app funcional
454
- - **[Arquitectura de Cola de Eventos](https://github.com/yoltra/yoltra/blob/main/docs/en/design/event-queue-architecture.md)**
455
- -- Inmersion tecnica profunda
456
- - **[Comparacion de Bibliotecas](https://github.com/yoltra/yoltra/blob/main/docs/en/design/state-management-library-comparison.md)**
457
- -- 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
458
844
 
459
845
  ---
460
846
 
461
847
  ## Ejemplos
462
848
 
463
- - **[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)**:
464
850
  CRUD completo con perfilado de rendimiento · [▶ Abrir la demo en vivo](https://yoltra.dev/es/demos/in-react)
465
- - **[Logo Cinetico](https://github.com/yoltra/yoltra/blob/main/examples/v0/yoltra-kinetic-logo)**
466
- -- 3000 círculos con simulación física. · [▶ Abrir la demo en vivo](https://yoltra.dev/es/demos/kinetic-logo)
467
- - **[Integracion con Next.js](https://github.com/yoltra/yoltra/blob/main/examples/v0/yoltra-in-nextjs)**
468
- -- 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)
469
855
 
470
856
  ---
471
857
 
472
858
  ## Contribuir
473
859
 
474
- - [Raiz del Monorepo](../../README.md)
475
- - [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)
476
862
 
477
863
  ---
478
864
 
479
865
  ## Estado
480
866
 
481
- **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
482
868
  antes de v1.0.0.
483
869
 
484
870
  ---
485
871
 
486
872
  ## Licencia
487
873
 
488
- **MIT** -- Libre para usar en proyectos comerciales y de codigo abierto.
874
+ **MIT**. Libre para usar en proyectos comerciales y de código abierto.