@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 +462 -76
- package/README.md +317 -63
- package/dist/types/index.d.ts +6 -1
- package/dist/types/persistence/persist.d.ts +3 -6
- package/dist/types/reducer/Reducer.d.ts +3 -2
- package/dist/types/store/Store.d.ts +239 -56
- package/dist/types/store/call.d.ts +149 -0
- package/dist/types/store/callQueue.d.ts +79 -0
- package/dist/types/store/matching.d.ts +49 -0
- package/dist/types/store/paths.d.ts +39 -0
- package/dist/types/store/performCall.d.ts +15 -0
- package/dist/types/store/rejection.d.ts +58 -0
- package/dist/types/types.d.ts +246 -13
- package/dist/yoltra.cjs +3 -8
- package/dist/yoltra.cjs.map +1 -1
- package/dist/yoltra.mjs +1509 -1038
- package/dist/yoltra.mjs.map +1 -1
- package/dist/yoltra.umd.js +3 -8
- package/dist/yoltra.umd.js.map +1 -1
- package/package.json +12 -11
package/README.es.md
CHANGED
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
# @yoltra/core
|
|
4
4
|
|
|
5
5
|
> 👉 🇲🇽 Versión en Español |
|
|
6
|
-
> [ 🇺🇸 English
|
|
6
|
+
> [ 🇺🇸 English Versión](./README.md)
|
|
7
7
|
|
|
8
8
|

|
|
9
9
|

|
|
10
10
|
|
|
11
|
-
**Contenedor de estado orientado a eventos,
|
|
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
|
-
##
|
|
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
|
|
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
|
|
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 ───
|
|
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
|
|
47
|
-
`emit()` retorna
|
|
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
|
|
50
|
-
de
|
|
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
|
|
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
|
|
69
|
+
### Suscripciones de grano fino vía `connect()`
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
segmento) y `**` (cero o
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 **
|
|
206
|
-
evento para
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|
|
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
|
-
##
|
|
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
|
-
|
|
282
|
-
|
|
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
|
|
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
|
-
##
|
|
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
|
|
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
|
|
610
|
+
## Mejores Prácticas
|
|
347
611
|
|
|
348
|
-
### El estado es
|
|
612
|
+
### El estado es síncrono; haz `await` solo por los efectos
|
|
349
613
|
|
|
350
|
-
La fase de
|
|
351
|
-
retorna
|
|
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
|
|
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
|
|
625
|
+
### Mantener los reducers rápidos
|
|
362
626
|
|
|
363
|
-
Los reducers son
|
|
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
|
-
###
|
|
660
|
+
### Creación del Store
|
|
397
661
|
|
|
398
|
-
| API |
|
|
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
|
|
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)` |
|
|
405
|
-
| `store.connect(spec, handler)` |
|
|
406
|
-
| `store.onEvent(channel, type, handler, phase?)` |
|
|
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
|
|
674
|
+
### Registro Dinámico
|
|
411
675
|
|
|
412
|
-
| API |
|
|
676
|
+
| API | Descripción |
|
|
413
677
|
| ----------------------------------- | ----------------------------------------- |
|
|
414
|
-
| `store.registerReducer(name, spec)` | Agregar un slice en tiempo de
|
|
415
|
-
| `store.registerMiddleware(fn)` | Agregar middleware en tiempo de
|
|
416
|
-
| `store.registerEffect(spec)` | Agregar un efecto en tiempo de
|
|
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 |
|
|
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 |
|
|
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
|
-
|
|
|
796
|
+
| Métrica | Valor |
|
|
438
797
|
| --------------------- | ----------------------------------------- |
|
|
439
|
-
| **
|
|
440
|
-
| **Tree-shakeable** |
|
|
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
|
-
##
|
|
832
|
+
## Documentación
|
|
447
833
|
|
|
448
|
-
- **[README
|
|
449
|
-
|
|
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
|
|
453
|
-
|
|
454
|
-
- **[Arquitectura de Cola de Eventos](https://github.com/yoltra/yoltra/blob/main/docs/en/design/event-queue-architecture.md)
|
|
455
|
-
|
|
456
|
-
- **[
|
|
457
|
-
|
|
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
|
|
466
|
-
|
|
467
|
-
- **[
|
|
468
|
-
|
|
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
|
-
- [
|
|
475
|
-
- [Guia de
|
|
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
|
|
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
|
|
874
|
+
**MIT**. Libre para usar en proyectos comerciales y de código abierto.
|