@yoltra/core 0.6.0 → 0.8.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 +443 -148
- package/README.md +207 -84
- package/dist/types/index.d.ts +2 -1
- package/dist/types/persistence/persist.d.ts +2 -2
- package/dist/types/store/Store.d.ts +373 -66
- package/dist/types/store/fingerprint.d.ts +62 -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 +2 -2
- package/dist/types/types.d.ts +528 -18
- package/dist/yoltra.cjs +3 -8
- package/dist/yoltra.cjs.map +1 -1
- package/dist/yoltra.mjs +2133 -1385
- 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 +6 -4
package/README.es.md
CHANGED
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
# @yoltra/core
|
|
4
4
|
|
|
5
5
|
> 👉 🇲🇽 Versión en Español |
|
|
6
|
-
> [ 🇺🇸 English
|
|
6
|
+
> [ 🇺🇸 English Versión](./README.md)
|
|
7
7
|
|
|
8
|
-
](https://www.npmjs.com/package/@yoltra/core)
|
|
9
|
+
[](https://www.npmjs.com/package/@yoltra/core)
|
|
10
|
+
[](https://www.npmjs.com/package/@yoltra/core)
|
|
11
|
+
[](https://github.com/yoltra/yoltra/blob/main/LICENSE)
|
|
10
12
|
|
|
11
|
-
**Contenedor de estado orientado a eventos,
|
|
13
|
+
**Contenedor de estado orientado a eventos, agnóstico de framework, con suscripciones de grano
|
|
12
14
|
fino por ruta.**
|
|
13
15
|
|
|
14
16
|
`@yoltra/core` es la base de [yoltra](../../README.md).
|
|
@@ -17,7 +19,7 @@ Proporciona el store, el pipeline de eventos, middleware, efectos y el sistema d
|
|
|
17
19
|
|
|
18
20
|
---
|
|
19
21
|
|
|
20
|
-
##
|
|
22
|
+
## Instalación
|
|
21
23
|
|
|
22
24
|
```bash
|
|
23
25
|
npm install @yoltra/core
|
|
@@ -27,14 +29,14 @@ npm install @yoltra/core
|
|
|
27
29
|
|
|
28
30
|
## El Pipeline de Eventos
|
|
29
31
|
|
|
30
|
-
Cada llamada a `emit()` fluye a
|
|
32
|
+
Cada llamada a `emit()` fluye a través de un pipeline determinista:
|
|
31
33
|
|
|
32
34
|
```
|
|
33
35
|
emit(channel, type, payload)
|
|
34
36
|
│
|
|
35
37
|
├─ 0. Dedup (opt-in) ─── Omite un duplicado solo si dedupWindowMs > 0 o se pasa un dedupKey
|
|
36
38
|
│
|
|
37
|
-
│ ══ fase de reduccion SINCRONA
|
|
39
|
+
│ ══ fase de reduccion SINCRONA: corre antes de que emit() retorne ══
|
|
38
40
|
├─ 1. Middleware ─── Hooks pre-reducer sincronos (devolver false para rechazar → evento "no confirmado")
|
|
39
41
|
├─ 2. Reducers ─── Cada slice que aplica se prepara, y todas se confirman bajo una sola raiz
|
|
40
42
|
├─ 3. Suscriptores de eventos ─── Notificaciones de eventos confirmados/no confirmados
|
|
@@ -43,11 +45,11 @@ emit(channel, type, payload)
|
|
|
43
45
|
└─ 5. Efectos ─── Efectos secundarios ASYNC, una tarea independiente por evento (indexados para busqueda O(1))
|
|
44
46
|
```
|
|
45
47
|
|
|
46
|
-
La fase de
|
|
47
|
-
`emit()` retorna
|
|
48
|
+
La fase de reducción (1–4) es **síncrona**, así que `getState()` es correcto en el instante en que
|
|
49
|
+
`emit()` retorna, incluso con middleware. Los efectos (5) corren después como una tarea async
|
|
48
50
|
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
|
|
51
|
+
etapa es interceptable, y `store.instrument()` expone todo el flujo (rutas hoja cambiadas, tiempos
|
|
52
|
+
de reducción, fase confirmado/rechazado) a las DevTools sin ningún `as any`. Ver la
|
|
51
53
|
[Arquitectura del Pipeline de Eventos](../../docs/es/design/event-queue-architecture.md) para el
|
|
52
54
|
modelo completo.
|
|
53
55
|
|
|
@@ -58,7 +60,7 @@ modelo completo.
|
|
|
58
60
|
### Eventos basados en canales
|
|
59
61
|
|
|
60
62
|
Los eventos son tuplas `(channel, type, payload)`. Los canales proporcionan namespacing natural
|
|
61
|
-
que escala en bases de
|
|
63
|
+
que escala en bases de código grandes:
|
|
62
64
|
|
|
63
65
|
```typescript
|
|
64
66
|
await store.emit("auth", "login", credentials);
|
|
@@ -66,28 +68,85 @@ await store.emit("analytics", "track", { event: "page_view" });
|
|
|
66
68
|
await store.emit("ui", "toast", { message: "Saved!" });
|
|
67
69
|
```
|
|
68
70
|
|
|
69
|
-
### Suscripciones de grano fino
|
|
71
|
+
### Suscripciones de grano fino vía `connect()`
|
|
70
72
|
|
|
71
|
-
|
|
72
|
-
segmento) y `**` (cero o
|
|
73
|
+
Suscríbete a rutas de estado exactas usando notación de puntos. Soporta wildcards `*` (un
|
|
74
|
+
segmento) y `**` (cero o más segmentos):
|
|
73
75
|
|
|
74
76
|
```typescript
|
|
75
|
-
// Ruta exacta
|
|
77
|
+
// Ruta exacta: se dispara cuando items[0].title cambia
|
|
76
78
|
store.connect({ reducer: "todos", property: "items.0.title" }, (change) =>
|
|
77
79
|
console.log("title:", change.oldValue, "→", change.newValue),
|
|
78
80
|
);
|
|
79
81
|
|
|
80
|
-
// Wildcard de un segmento
|
|
82
|
+
// Wildcard de un segmento: se dispara cuando el titulo de CUALQUIER item cambia
|
|
81
83
|
store.connect({ reducer: "todos", property: "items.*.title" }, (change) =>
|
|
82
84
|
console.log("some title changed at", change.path),
|
|
83
85
|
);
|
|
84
86
|
|
|
85
|
-
// Wildcard profundo
|
|
87
|
+
// Wildcard profundo: se dispara cuando algo bajo items cambia
|
|
86
88
|
store.connect({ reducer: "todos", property: "items.**" }, (change) =>
|
|
87
89
|
console.log("items tree changed at", change.path),
|
|
88
90
|
);
|
|
89
91
|
```
|
|
90
92
|
|
|
93
|
+
### Slices que contienen un solo valor
|
|
94
|
+
|
|
95
|
+
Una slice no tiene por qué ser un objeto. Un primitivo, un `Map`, un `Set` o una `Date` es un
|
|
96
|
+
estado de slice válido, y se confirma igual que cualquier otro:
|
|
97
|
+
|
|
98
|
+
```typescript
|
|
99
|
+
const store = createStore({
|
|
100
|
+
name: "session",
|
|
101
|
+
reducer: {
|
|
102
|
+
token: {
|
|
103
|
+
state: null as string | null,
|
|
104
|
+
when: { keys: [["auth", "login"]] },
|
|
105
|
+
reducer: (_state, event) => event.payload.token,
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await store.emit("auth", "login", { token: "abc123" });
|
|
111
|
+
store.getState().token; // "abc123"
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Una slice así no tiene ninguna propiedad debajo, así que sus cambios se reportan en la **raíz de
|
|
115
|
+
la slice**, la ruta vacía. Suscríbete a ella con `property: ""`:
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
store.connect({ reducer: "token", property: "" }, (change) =>
|
|
119
|
+
console.log("token:", change.oldValue, " --> ", change.newValue),
|
|
120
|
+
);
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Los tipos conocen la diferencia. `property` en una slice de valor raíz acepta `""` y nada más,
|
|
124
|
+
porque no hay ninguna clave que direccionar, y el valor vuelve correctamente tipado:
|
|
125
|
+
|
|
126
|
+
```typescript
|
|
127
|
+
const token = useAtomicProp({ reducer: "token", property: "" }); // string | null
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### `""` frente a `"**"`: observar una slice completa
|
|
131
|
+
|
|
132
|
+
Dos suscripciones que suenan iguales y no lo son:
|
|
133
|
+
|
|
134
|
+
| Patrón | Se dispara cuando |
|
|
135
|
+
|---|---|
|
|
136
|
+
| `""` | el **valor completo** de la slice se reemplaza: cambia un primitivo, se reconstruye un `Map`, una slice de objeto pasa a `null` |
|
|
137
|
+
| `"**"` | cambia **cualquier cosa** dentro de la slice, a cualquier profundidad. También coincide con la raíz, porque `**` coincide con cero segmentos |
|
|
138
|
+
| `"*"` | exactamente un nivel más abajo. Nunca coincide con la raíz |
|
|
139
|
+
|
|
140
|
+
**`"**"` es la suscripción a la slice completa, y funciona para toda slice sin importar su forma.**
|
|
141
|
+
Recurre a `""` solo cuando te refieras al valor raíz en sí; en una slice de objeto se queda
|
|
142
|
+
callada, porque una slice así reporta sus cambios en las hojas.
|
|
143
|
+
|
|
144
|
+
`Map` y `Set` se comparan por referencia, no por entrada: un reducer que devuelve un `Map` nuevo
|
|
145
|
+
es un cambio, mutar uno en el sitio no lo es. Eso se desprende del contrato de inmutabilidad en
|
|
146
|
+
vez de ser un caso especial. Construye una colección nueva en lugar de mutar la almacenada. Es
|
|
147
|
+
también la razón de que no tengan rutas debajo: `"byId"` es suscribible, `"byId.get"` no, y los
|
|
148
|
+
tipos lo dicen.
|
|
149
|
+
|
|
91
150
|
### Inmutabilidad
|
|
92
151
|
|
|
93
152
|
El estado se congela profundamente antes de confirmarse. Las mutaciones lanzan error en modo
|
|
@@ -114,7 +173,7 @@ type AppEM = {
|
|
|
114
173
|
system: { init: void; shutdown: void };
|
|
115
174
|
};
|
|
116
175
|
|
|
117
|
-
// Coincidir con claves de evento especificas (recomendado
|
|
176
|
+
// Coincidir con claves de evento especificas (recomendado: preserva la correlacion de tipos)
|
|
118
177
|
const counterReducer = {
|
|
119
178
|
state: { value: 0 },
|
|
120
179
|
when: {
|
|
@@ -156,7 +215,7 @@ const globalLogger = {
|
|
|
156
215
|
|
|
157
216
|
## Middleware
|
|
158
217
|
|
|
159
|
-
El middleware se ejecuta **sincronamente, antes** de los reducers y puede cancelar la
|
|
218
|
+
El middleware se ejecuta **sincronamente, antes** de los reducers y puede cancelar la propagación
|
|
160
219
|
de eventos (devolver `false` para rechazar → evento "no confirmado"). El trabajo async va en los
|
|
161
220
|
efectos, no en el middleware. Soporta tanto funciones directas (legacy) como objetos
|
|
162
221
|
`MiddlewareSpec` con targeting:
|
|
@@ -164,7 +223,7 @@ efectos, no en el middleware. Soporta tanto funciones directas (legacy) como obj
|
|
|
164
223
|
```typescript
|
|
165
224
|
import type { MiddlewareSpec } from "@yoltra/core";
|
|
166
225
|
|
|
167
|
-
// Middleware con target
|
|
226
|
+
// Middleware con target: solo se ejecuta para eventos del canal admin
|
|
168
227
|
const adminGuard: MiddlewareSpec<AppState, AppEM> = {
|
|
169
228
|
when: { channel: "admin" },
|
|
170
229
|
middleware: (state, event) => {
|
|
@@ -174,7 +233,8 @@ const adminGuard: MiddlewareSpec<AppState, AppEM> = {
|
|
|
174
233
|
meta: { type: "middleware", name: "adminGuard" },
|
|
175
234
|
};
|
|
176
235
|
|
|
177
|
-
// Middleware global
|
|
236
|
+
// Middleware global: se ejecuta para todos los eventos. Sincrono, nunca una Promise: solo un
|
|
237
|
+
// `false` explicito veta, asi que un middleware que solo observa puede no devolver nada.
|
|
178
238
|
const logger = (state, event) => {
|
|
179
239
|
console.log("Event:", event.channel, event.type);
|
|
180
240
|
return true;
|
|
@@ -189,7 +249,7 @@ const store = createStore({
|
|
|
189
249
|
});
|
|
190
250
|
```
|
|
191
251
|
|
|
192
|
-
### Middleware
|
|
252
|
+
### Middleware dinámico
|
|
193
253
|
|
|
194
254
|
```typescript
|
|
195
255
|
const off = store.registerMiddleware((state, event) => {
|
|
@@ -202,8 +262,8 @@ off(); // Remover despues
|
|
|
202
262
|
|
|
203
263
|
## Efectos
|
|
204
264
|
|
|
205
|
-
Los efectos se ejecutan **
|
|
206
|
-
evento para
|
|
265
|
+
Los efectos se ejecutan **después** de los reducers y ven el estado final. Están indexados por
|
|
266
|
+
evento para búsqueda O(1):
|
|
207
267
|
|
|
208
268
|
```typescript
|
|
209
269
|
// Via spec del store
|
|
@@ -244,16 +304,16 @@ const off2 = store.onEffect("ui", "save", async (payload, getState, emit) => {
|
|
|
244
304
|
|
|
245
305
|
## Suscripciones a Eventos
|
|
246
306
|
|
|
247
|
-
|
|
307
|
+
Suscríbete a eventos (no al estado) desde la capa de vista. Útil para notificaciones,
|
|
248
308
|
animaciones y reaccionar a eventos rechazados:
|
|
249
309
|
|
|
250
310
|
```typescript
|
|
251
|
-
// Eventos confirmados (por defecto)
|
|
311
|
+
// Eventos confirmados (por defecto): eventos que pasaron el middleware
|
|
252
312
|
const off = store.onEvent("ui", "save", (event, getState, emit, phase) => {
|
|
253
313
|
console.log("Save committed:", event.payload);
|
|
254
314
|
});
|
|
255
315
|
|
|
256
|
-
// Eventos no confirmados
|
|
316
|
+
// Eventos no confirmados: eventos rechazados por el middleware
|
|
257
317
|
store.onEvent(
|
|
258
318
|
"ui",
|
|
259
319
|
"delete",
|
|
@@ -263,7 +323,7 @@ store.onEvent(
|
|
|
263
323
|
"uncommitted",
|
|
264
324
|
);
|
|
265
325
|
|
|
266
|
-
// Todos los eventos
|
|
326
|
+
// Todos los eventos: tanto confirmados como no confirmados
|
|
267
327
|
store.onEvent(
|
|
268
328
|
"ui",
|
|
269
329
|
"action",
|
|
@@ -274,15 +334,34 @@ store.onEvent(
|
|
|
274
334
|
);
|
|
275
335
|
```
|
|
276
336
|
|
|
337
|
+
### Suscriptores de eventos y viaje en el tiempo
|
|
338
|
+
|
|
339
|
+
**El replay no llama a tus handlers.** Recorrer una línea de tiempo de DevTools vuelve a reducir
|
|
340
|
+
los eventos, así que el estado sigue el recorrido, pero los handlers de `onEvent` permanecen en
|
|
341
|
+
silencio. Antes se ejecutaban igual que con un evento real, así que arrastrar la línea de tiempo
|
|
342
|
+
volvía a publicar a los pares, a escribir en sockets y a disparar analítica por eventos que no
|
|
343
|
+
estaban ocurriendo de nuevo, sin nada dentro del handler que permitiera notar la diferencia.
|
|
344
|
+
|
|
345
|
+
Un handler que deriva estado de vista puramente del flujo de eventos, y que no hace E/S, puede
|
|
346
|
+
activarlo:
|
|
347
|
+
|
|
348
|
+
```ts
|
|
349
|
+
store.onEvent("ui", "save", handler, "committed", { duringReplay: true });
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
`store.isReplaying` existe para lo que deba ramificar en lugar de simplemente omitirse. Los
|
|
353
|
+
suscriptores gruesos de `subscribe` y las suscripciones de `connect` siguen disparándose, porque
|
|
354
|
+
el estado sí cambió y la interfaz tiene que seguir el recorrido.
|
|
355
|
+
|
|
277
356
|
---
|
|
278
357
|
|
|
279
|
-
## Los commits son
|
|
358
|
+
## Los commits son atómicos entre slices
|
|
280
359
|
|
|
281
|
-
Un evento que toca varias slices las escribe todas y
|
|
282
|
-
aplicado a medias: un suscriptor de una slice que lee `getState()` ve todas las
|
|
360
|
+
Un evento que toca varias slices las escribe todas y después notifica. Nadie observa un evento
|
|
361
|
+
aplicado a medias: un suscriptor de una slice que lee `getState()` ve todas las demás slices del
|
|
283
362
|
mismo evento ya aplicadas.
|
|
284
363
|
|
|
285
|
-
Esto importa sobre todo donde un cambio se usa como
|
|
364
|
+
Esto importa sobre todo donde un cambio se usa como señal para volver a leer, que es lo que hacen
|
|
286
365
|
los hooks de React.
|
|
287
366
|
|
|
288
367
|
---
|
|
@@ -290,7 +369,7 @@ los hooks de React.
|
|
|
290
369
|
## Rechazar una escritura
|
|
291
370
|
|
|
292
371
|
Un reducer devuelve `Rejected(reason)` en lugar de estado para declinar. **Se rechaza el evento
|
|
293
|
-
completo**: ninguna slice escribe, no se emite ninguna
|
|
372
|
+
completo**: ninguna slice escribe, no se emite ninguna notificación de cambio, y quien llamo sabe
|
|
294
373
|
por que.
|
|
295
374
|
|
|
296
375
|
```typescript
|
|
@@ -313,36 +392,36 @@ const store = createStore({
|
|
|
313
392
|
|
|
314
393
|
const result = await store.emit("plan", "patch", { steps, expectedVersion: 1 });
|
|
315
394
|
|
|
316
|
-
result.committed; // true
|
|
317
|
-
result.written; // false
|
|
395
|
+
result.committed; // true: el middleware lo permitio
|
|
396
|
+
result.written; // false: no se escribio nada
|
|
318
397
|
result.rejected?.reason;
|
|
319
398
|
```
|
|
320
399
|
|
|
321
400
|
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,
|
|
323
|
-
que su slice queda aislada y las
|
|
324
|
-
una
|
|
401
|
+
evento no me concierne". Tampoco es lo mismo que lanzar: un reducer que lanza tiene un bug, así
|
|
402
|
+
que su slice queda aislada y las demás sí escriben, mientras que un reducer que rechaza ha tomado
|
|
403
|
+
una decisión a la que cede el evento entero.
|
|
325
404
|
|
|
326
405
|
`emit` resuelve a un `EmitResult` cuando terminan los efectos:
|
|
327
406
|
|
|
328
407
|
| | |
|
|
329
408
|
|---|---|
|
|
330
|
-
| `committed` | el middleware no lo
|
|
331
|
-
| `written` | un reducer
|
|
332
|
-
| `rejected` | presente cuando un reducer
|
|
409
|
+
| `committed` | el middleware no lo vetó |
|
|
410
|
+
| `written` | un reducer cambió el estado de verdad |
|
|
411
|
+
| `rejected` | presente cuando un reducer rechazó, con su `reason` |
|
|
333
412
|
|
|
334
413
|
La fase `written` de `onEvent` reporta lo mismo a los suscriptores. `committed` sigue
|
|
335
414
|
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
|
|
337
|
-
bus de notificaciones o de
|
|
415
|
+
middleware permite, incluidos todos los eventos de un store sin reducers, la forma que toma un
|
|
416
|
+
bus de notificaciones o de analítica.
|
|
338
417
|
|
|
339
418
|
---
|
|
340
419
|
|
|
341
|
-
##
|
|
420
|
+
## Petición y respuesta: `store.call()`
|
|
342
421
|
|
|
343
|
-
Todo consumidor de un bus de eventos acaba escribiendo
|
|
344
|
-
suscribirse, emparejar, expirar, desuscribirse. Son unas ochenta
|
|
345
|
-
mismos dos bugs: la
|
|
422
|
+
Todo consumidor de un bus de eventos acaba escribiendo petición/respuesta a mano: generar un id,
|
|
423
|
+
suscribirse, emparejar, expirar, desuscribirse. Son unas ochenta líneas y siempre traen los
|
|
424
|
+
mismos dos bugs: la suscripción sobrevive a la llamada, y `Quien Responde` que olvida devolver el
|
|
346
425
|
id produce un timeout sin nada a lo que apuntar.
|
|
347
426
|
|
|
348
427
|
```typescript
|
|
@@ -364,7 +443,7 @@ store.registerEffect({
|
|
|
364
443
|
|
|
365
444
|
### Una llamada resuelve al evento, no al payload
|
|
366
445
|
|
|
367
|
-
Porque muchas veces quien llama no sabe *
|
|
446
|
+
Porque muchas veces quien llama no sabe *cuál* respuesta va a recibir. `reply` nombra los tipos
|
|
368
447
|
**terminales**, y el evento trae el discriminante:
|
|
369
448
|
|
|
370
449
|
```typescript
|
|
@@ -388,45 +467,45 @@ for await (const step of call) await render(step.payload);
|
|
|
388
467
|
const { payload } = await call;
|
|
389
468
|
```
|
|
390
469
|
|
|
391
|
-
La
|
|
392
|
-
efectos, y el colector es un efecto que no retorna hasta que el consumidor tomo el elemento
|
|
470
|
+
La contrapresión es real, no un buffer con límite. `emit` resuelve solo cuando terminan sus
|
|
471
|
+
efectos, y el colector es un efecto que no retorna hasta que el consumidor tomo el elemento, así
|
|
393
472
|
que un `Quien Responde` que escribe `await emit("job", "tick", chunk)` **va al ritmo del lector**.
|
|
394
473
|
|
|
395
|
-
La
|
|
396
|
-
`await` nunca extrae nada,
|
|
397
|
-
llamada: el progreso que nadie lee
|
|
398
|
-
progreso no iterado se almacena hasta `highWaterMark` y
|
|
474
|
+
La contrapresión entra en juego **cuando empiezas a iterar**. Una llamada que solo se espera con
|
|
475
|
+
`await` nunca extrae nada, así que bloquear a su productor causaría un interbloqueo de la propia
|
|
476
|
+
llamada: el progreso que nadie lee impediría que se enviara el evento terminal. Por eso el
|
|
477
|
+
progreso no iterado se almacena hasta `highWaterMark` y después se cuenta en `call.dropped`.
|
|
399
478
|
|
|
400
479
|
### Retroceso
|
|
401
480
|
|
|
402
481
|
| | |
|
|
403
482
|
|---|---|
|
|
404
483
|
| `timeoutMs` | **Inactividad**, no total: todo evento correlacionado lo reinicia, incluido el progreso. Por defecto 30s. |
|
|
405
|
-
| `signal` | Un `AbortSignal`, para una fecha
|
|
484
|
+
| `signal` | Un `AbortSignal`, para una fecha límite real o una acción cancelada. |
|
|
406
485
|
| `call.cancel(reason)` | Deja de escuchar y liquida la llamada. |
|
|
407
486
|
|
|
408
|
-
Termine como termine, la
|
|
409
|
-
|
|
487
|
+
Termine como termine, la suscripción se elimina y se libera cualquier productor detenido por la
|
|
488
|
+
contrapresión. Un `Quien Responde` atascado es peor que el buffer sin límite que esto reemplazo.
|
|
410
489
|
|
|
411
490
|
---
|
|
412
491
|
|
|
413
492
|
## Leer un valor al suscribirse
|
|
414
493
|
|
|
415
|
-
`connect` empieza en "de ahora en adelante",
|
|
416
|
-
otro lado
|
|
494
|
+
`connect` empieza en "de ahora en adelante", así que la primera lectura había que repetirla en
|
|
495
|
+
otro lado: la misma ruta en dos sitios, libres de divergir:
|
|
417
496
|
|
|
418
497
|
```typescript
|
|
419
498
|
store.connect({ reducer: "todos", property: "items.0.title" }, render, { immediate: true });
|
|
420
499
|
```
|
|
421
500
|
|
|
422
|
-
El primer cambio
|
|
423
|
-
lo
|
|
501
|
+
El primer cambio sintético trae `oldValue: undefined` y **sin procedencia**, porque ningún evento
|
|
502
|
+
lo causó. React no lo necesita: `useSyncExternalStore` ya lee una instantánea al montar.
|
|
424
503
|
|
|
425
504
|
---
|
|
426
505
|
|
|
427
|
-
## De
|
|
506
|
+
## De dónde vino un cambio
|
|
428
507
|
|
|
429
|
-
Un `Change` nombra el evento que lo
|
|
508
|
+
Un `Change` nombra el evento que lo causó, así que un suscriptor ya no tiene que duplicar la causa
|
|
430
509
|
dentro del estado:
|
|
431
510
|
|
|
432
511
|
```typescript
|
|
@@ -438,19 +517,41 @@ store.connect({ reducer: "orders", property: "status" }, (change) => {
|
|
|
438
517
|
});
|
|
439
518
|
```
|
|
440
519
|
|
|
441
|
-
La procedencia
|
|
442
|
-
DevTools, o la entrega `immediate` de arriba. La ausencia es la
|
|
520
|
+
La procedencia está **ausente** cuando ningún evento causó el cambio: un salto de time-travel de
|
|
521
|
+
DevTools, o la entrega `immediate` de arriba. La ausencia es la señal, en vez de un id inventado.
|
|
443
522
|
|
|
444
523
|
---
|
|
445
524
|
|
|
446
|
-
##
|
|
525
|
+
## Deduplicación de Eventos (opt-in)
|
|
526
|
+
|
|
527
|
+
La deduplicación está **desactivada por defecto**. Yoltra nunca descarta en silencio eventos
|
|
528
|
+
idénticos legítimos y rápidos (doble-clics, `+1` repetidos). Actívala solo cuando de verdad quieras
|
|
529
|
+
coalescer:
|
|
530
|
+
|
|
531
|
+
```typescript
|
|
532
|
+
// Por contenido: coalescer (channel, type, payload) identicos dentro de una ventana.
|
|
533
|
+
const store = createStore({
|
|
534
|
+
name: "App",
|
|
535
|
+
reducer: {
|
|
536
|
+
/* ... */
|
|
537
|
+
},
|
|
538
|
+
dedupWindowMs: 100, // default: 0 (desactivado)
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
// Por identidad: dedup por una clave explicita, p. ej. un doble-invoke de React Strict Mode en un efecto.
|
|
542
|
+
await store.emit("analytics", "pageView", { page }, { dedupKey: `pageView:${page}` });
|
|
543
|
+
```
|
|
447
544
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
545
|
+
---
|
|
546
|
+
|
|
547
|
+
## Protección contra cascadas (activada por defecto)
|
|
548
|
+
|
|
549
|
+
Dos consumidores conectados entre sí, ya sea un suscriptor que emite lo que su propio reducer atiende o
|
|
550
|
+
dos slices que atienden los eventos de la otra, producen una cadena de eventos sin final. La cola
|
|
551
|
+
de reducción se drena de forma **síncrona**, así que eso no es un programa lento: es una pestana
|
|
451
552
|
congelada, o un core al 100%, sin error ni stack al que apuntar.
|
|
452
553
|
|
|
453
|
-
Por eso cada evento lleva su
|
|
554
|
+
Por eso cada evento lleva su posición causal, y el store se niega a extender una cadena más allá
|
|
454
555
|
de un tope:
|
|
455
556
|
|
|
456
557
|
```typescript
|
|
@@ -468,58 +569,95 @@ const store = createStore({
|
|
|
468
569
|
});
|
|
469
570
|
```
|
|
470
571
|
|
|
471
|
-
Un evento emitido mientras se atiende otro
|
|
472
|
-
`parentId` y `depth` para que el ciclo sea legible
|
|
473
|
-
evento
|
|
572
|
+
Un evento emitido mientras se atiende otro está un nivel más abajo que su causa, y lleva
|
|
573
|
+
`parentId` y `depth` para que el ciclo sea legible después. Ambos campos están **ausentes** en un
|
|
574
|
+
evento raíz, así que los eventos que emite tu aplicación siguen siendo idénticos byte a byte.
|
|
474
575
|
|
|
475
576
|
Superar el tope no lanza. El emit ofensor se rechaza, lo ya confirmado se mantiene, y `onCascade`
|
|
476
|
-
(
|
|
577
|
+
(más un error en consola) lo nombra. Lanzar aparecería en el suscriptor o efecto que casualmente
|
|
477
578
|
estuviera emitiendo, que es justo el fallo inatribuible que el tope existe para evitar.
|
|
478
579
|
|
|
479
580
|
**Una rafaga ancha no es una cascada.** Un evento cuyo suscriptor emite quinientos hermanos es una
|
|
480
|
-
forma
|
|
581
|
+
forma legítima; la profundidad es lo que la distingue de un ciclo, y un bucle normal de
|
|
481
582
|
`store.emit` nunca acumula profundidad. `maxTransitionsPerDrain` acota el *ancho* y por eso viene
|
|
482
583
|
desactivado.
|
|
483
584
|
|
|
484
585
|
---
|
|
485
586
|
|
|
486
|
-
##
|
|
587
|
+
## Reducers Dinámicos
|
|
487
588
|
|
|
488
|
-
|
|
489
|
-
identicos legitimos y rapidos (doble-clics, `+1` repetidos). Actívala solo cuando de verdad quieras
|
|
490
|
-
coalescer:
|
|
589
|
+
Agrega o elimina slices de reducer en tiempo de ejecución:
|
|
491
590
|
|
|
492
591
|
```typescript
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
reducer: {
|
|
497
|
-
/* ... */
|
|
498
|
-
},
|
|
499
|
-
dedupWindowMs: 100, // default: 0 (desactivado)
|
|
592
|
+
const dispose = store.registerReducer("filters", {
|
|
593
|
+
state: { q: "" },
|
|
594
|
+
when: { keys: eventKeys<AppEM>()([["ui", "setQuery"]]) },
|
|
595
|
+
reducer: (state, event) => (event.type === "setQuery" ? { q: event.payload } : state),
|
|
500
596
|
});
|
|
501
597
|
|
|
502
|
-
//
|
|
503
|
-
|
|
598
|
+
// Despues: remover el slice y su estado
|
|
599
|
+
dispose();
|
|
504
600
|
```
|
|
505
601
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
## Reducers Dinamicos
|
|
602
|
+
### Decorar un store, con sus tipos
|
|
509
603
|
|
|
510
|
-
|
|
604
|
+
Una slice agregada en runtime era invisible para el sistema de tipos: `registerReducer`
|
|
605
|
+
recibía un `string` y devolvía un disposer, así que nada aguas abajo sabía que la slice
|
|
606
|
+
existía ni qué forma tenía. `withSlice` devuelve **el mismo store, re-tipado**:
|
|
511
607
|
|
|
512
608
|
```typescript
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
609
|
+
type TransferEM = { transfer: { granted: { id: string } } };
|
|
610
|
+
|
|
611
|
+
const transfers = defineSlice<TransferEM>()({
|
|
612
|
+
state: { granted: [] as string[] },
|
|
613
|
+
when: { keys: [["transfer", "granted"]] },
|
|
614
|
+
reducer: (s, e) => (e.type === "granted" ? { granted: [...s.granted, e.payload.id] } : s),
|
|
517
615
|
});
|
|
518
616
|
|
|
519
|
-
|
|
520
|
-
|
|
617
|
+
const app = store.withSlice("transfers", transfers, { owner: "@scope/transfers" });
|
|
618
|
+
|
|
619
|
+
app.getState().transfers.granted; // string[]
|
|
620
|
+
app.emit("transfer", "granted", { id: "a1" }); // el canal nuevo ya es emitible
|
|
521
621
|
```
|
|
522
622
|
|
|
623
|
+
`withMiddleware` y `withEffect` hacen lo mismo para el mapa de eventos. Las llamadas se
|
|
624
|
+
encadenan, y una librería publica un decorador tomando un store y devolviendo otro:
|
|
625
|
+
|
|
626
|
+
```typescript
|
|
627
|
+
export function withTransfers<R extends string, S extends Record<R, any>, EM extends EventMapBase>(
|
|
628
|
+
store: StoreInstance<R, S, EM>,
|
|
629
|
+
config: TransfersConfig,
|
|
630
|
+
) {
|
|
631
|
+
return store.withSlice("transfers", transfers, { owner: "@scope/transfers" });
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Los decoradores se anidan, en cualquier orden.
|
|
635
|
+
const decorated = withTransfers(withDevtools(store, dtConfig), config);
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
**Por qué los builders.** El `when` de un spec lleva cadenas de canal y tipo, no tipos de
|
|
639
|
+
payload, así que el mapa de eventos que aporta una decoración no puede inferirse de ahí, y
|
|
640
|
+
TypeScript no tiene inferencia parcial de argumentos de tipo. `defineSlice<EM>()` lo coloca en
|
|
641
|
+
posición de valor, donde la inferencia sí funciona, así que ningún sitio de registro necesita
|
|
642
|
+
un argumento de tipo ni un cast. Una consecuencia que conviene conocer: **una función de
|
|
643
|
+
middleware sin spec nunca puede ampliar el mapa de eventos**, porque el parámetro de evento de
|
|
644
|
+
`MiddlewareFunction` es un tipo mapeado del que no se puede inferir nada de vuelta. Solo la
|
|
645
|
+
forma de spec de `defineMiddleware` puede.
|
|
646
|
+
|
|
647
|
+
**Es el mismo objeto.** Nada se vuelve a suscribir, ningún estado se mueve, y una llamada
|
|
648
|
+
`store.call()` en vuelo no se ve afectada. Solo cambia el tipo.
|
|
649
|
+
|
|
650
|
+
**Orden.** Decora en el ámbito del módulo, una vez, antes del primer render. Entre
|
|
651
|
+
`createStore` y la decoración la slice realmente no existe, y un componente que la lea verá
|
|
652
|
+
`undefined` hasta que exista.
|
|
653
|
+
|
|
654
|
+
**Disposición.** `withSlice` no devuelve disposer a propósito: después de ejecutarlo, el tipo
|
|
655
|
+
ampliado sigue prometiendo una slice que ya no está, y ningún sistema de tipos puede expresar
|
|
656
|
+
"válido hasta esa llamada". Usa `registerSlice` cuando la slice sea tuya y necesites
|
|
657
|
+
desmontarla, y mantén ese disposer privado a la librería. Leer una slice desmontada lanza un
|
|
658
|
+
error con nombre en desarrollo, en lugar de devolver `undefined` desde un tipo que prometía un
|
|
659
|
+
valor.
|
|
660
|
+
|
|
523
661
|
---
|
|
524
662
|
|
|
525
663
|
## Hot Module Replacement
|
|
@@ -548,26 +686,52 @@ if (import.meta.hot) {
|
|
|
548
686
|
}
|
|
549
687
|
```
|
|
550
688
|
|
|
689
|
+
### `replace*` reemplaza lo que tú escribiste, no lo que agregó una librería
|
|
690
|
+
|
|
691
|
+
Un reducer, middleware o efecto registrado **después** de la construcción, con
|
|
692
|
+
`registerReducer`, `registerMiddleware` o `registerEffect`, sobrevive a una llamada a
|
|
693
|
+
`replace*`. Esos registros nunca formaron parte del conjunto que estás reemplazando: nadie que
|
|
694
|
+
escribe `replaceReducers(myReducers)` quiere decir "y ademas borra la slice que montó devtools,
|
|
695
|
+
junto con su estado".
|
|
696
|
+
|
|
697
|
+
Antes ocurría lo contrario, lo que hacía que la línea de HMR de arriba borrara la slice de una
|
|
698
|
+
librería y su estado al primer guardado de archivo, sin error y sin advertencia. Es también la
|
|
699
|
+
razón por la que una llamada `store.call()` en vuelo ya no muere a mitad de recarga: su
|
|
700
|
+
listener de respuesta pertenece al propio store.
|
|
701
|
+
|
|
702
|
+
Pasa `{ scope: "all" }` para el comportamiento anterior, que un arnés de pruebas que reinicia un
|
|
703
|
+
store entre casos sí puede querer:
|
|
704
|
+
|
|
705
|
+
```typescript
|
|
706
|
+
store.replaceReducers(nextReducers, { scope: "all" });
|
|
707
|
+
store.hotReplace({ reducer: nextReducers, scope: "all" }); // se reenvía a los tres
|
|
708
|
+
```
|
|
709
|
+
|
|
710
|
+
Una aplicación que declara una slice que una librería ya montó recibe un error que nombra la
|
|
711
|
+
slice, en lugar de una apropiación silenciosa que deja a la librería con un disposer de algo que
|
|
712
|
+
ya no es suyo. En desarrollo, `replace*` registra en nivel debug cuando preservó algo, así que
|
|
713
|
+
"por qué sigue disparándose ese efecto tras la recarga" tiene respuesta.
|
|
714
|
+
|
|
551
715
|
---
|
|
552
716
|
|
|
553
|
-
## Mejores
|
|
717
|
+
## Mejores Prácticas
|
|
554
718
|
|
|
555
|
-
### El estado es
|
|
719
|
+
### El estado es síncrono; haz `await` solo por los efectos
|
|
556
720
|
|
|
557
|
-
La fase de
|
|
558
|
-
retorna
|
|
721
|
+
La fase de reducción es síncrona, así que el estado refleja tu evento en el instante en que `emit()`
|
|
722
|
+
retorna, sin `await` para leerlo. Haz `await` de `emit()` cuando además quieras que los efectos de
|
|
559
723
|
_ese evento_ hayan terminado:
|
|
560
724
|
|
|
561
725
|
```typescript
|
|
562
726
|
emit("todo", "add", todo);
|
|
563
|
-
store.getState(); // Ya refleja la nueva tarea
|
|
727
|
+
store.getState(); // Ya refleja la nueva tarea. Sin await
|
|
564
728
|
|
|
565
729
|
await emit("todo", "save", todo); // se resuelve cuando terminan los efectos de save
|
|
566
730
|
```
|
|
567
731
|
|
|
568
|
-
### Mantener los reducers
|
|
732
|
+
### Mantener los reducers rápidos
|
|
569
733
|
|
|
570
|
-
Los reducers son
|
|
734
|
+
Los reducers son síncronos y corren en el mismo tick que `emit()`. Mueve el trabajo costoso a los
|
|
571
735
|
efectos:
|
|
572
736
|
|
|
573
737
|
```typescript
|
|
@@ -600,96 +764,227 @@ store.registerEffect({
|
|
|
600
764
|
|
|
601
765
|
## Resumen de API
|
|
602
766
|
|
|
603
|
-
###
|
|
767
|
+
### Creación del Store
|
|
604
768
|
|
|
605
|
-
| API |
|
|
769
|
+
| API | Descripción |
|
|
606
770
|
| ----------------------------------------------- | ----------------------------------------------------- |
|
|
607
771
|
| `createStore(spec)` | Crear un store (tipos inferidos de los reducers) |
|
|
608
|
-
| `createStore<S, EM>(spec)` | Crear un store con tipos de estado/eventos
|
|
772
|
+
| `createStore<S, EM>(spec)` | Crear un store con tipos de estado/eventos explícitos |
|
|
609
773
|
| `store.emit(channel, type, payload)` | Emitir un evento (retorna una promesa) |
|
|
610
774
|
| `store.getState()` | Obtener snapshot del estado actual (solo lectura) |
|
|
611
|
-
| `store.subscribe(listener)` |
|
|
612
|
-
| `store.connect(spec, handler)` |
|
|
613
|
-
| `store.onEvent(channel, type, handler, phase?)` |
|
|
775
|
+
| `store.subscribe(listener)` | Suscripción gruesa (cualquier cambio de estado) |
|
|
776
|
+
| `store.connect(spec, handler)` | Suscripción de grano fino por ruta con wildcards |
|
|
777
|
+
| `store.onEvent(channel, type, handler, phase?, options?)` | Suscripción a eventos (committed/uncommitted/written/all). Silenciosa durante el replay salvo `{ duringReplay: true }` |
|
|
778
|
+
| `store.onRegistrationChange(observer, opts?)` | Avisa cuando el store gana o pierde un reducer, middleware o efecto |
|
|
614
779
|
| `store.onEffect(channel, type, handler)` | Shorthand de efecto para un solo evento |
|
|
615
780
|
| `store.dispose()` | Limpiar timers y recursos |
|
|
616
781
|
|
|
617
|
-
### Registro
|
|
782
|
+
### Registro Dinámico
|
|
618
783
|
|
|
619
|
-
| API |
|
|
784
|
+
| API | Descripción |
|
|
620
785
|
| ----------------------------------- | ----------------------------------------- |
|
|
621
|
-
| `store.
|
|
622
|
-
| `store.
|
|
623
|
-
| `store.
|
|
786
|
+
| `store.registerSlice(name, spec, opts?)` | Agrega un slice en runtime; devuelve el store re-tipado y un disposer |
|
|
787
|
+
| `store.withSlice(name, spec, opts?)` | Igual, devolviendo el store re-tipado para encadenar |
|
|
788
|
+
| `store.withMiddleware(mw)`, `store.withEffect(spec)` | Registra y amplía el mapa de eventos |
|
|
789
|
+
| `defineSlice<EM>()`, `defineMiddleware<EM>()`, `defineEffect<EM>()` | Declara el mapa de eventos que aporta un spec |
|
|
790
|
+
| `store.registerReducer(name, spec)` | Agregar un slice en tiempo de ejecución |
|
|
791
|
+
| `store.registerMiddleware(fn)` | Agregar middleware en tiempo de ejecución |
|
|
792
|
+
| `store.registerEffect(spec)` | Agregar un efecto en tiempo de ejecución |
|
|
624
793
|
|
|
625
794
|
### HMR
|
|
626
795
|
|
|
627
|
-
| API |
|
|
796
|
+
| API | Descripción |
|
|
628
797
|
| --------------------------------------- | ------------------------------------------- |
|
|
629
|
-
| `store.replaceReducers(reducers, opts)`
|
|
630
|
-
| `store.replaceMiddleware(middleware)`
|
|
631
|
-
| `store.replaceEffects(effects)`
|
|
632
|
-
| `store.hotReplace(partial)`
|
|
798
|
+
| `store.replaceReducers(reducers, opts)` | Reemplaza los reducers del spec; los de runtime sobreviven salvo `{ scope: "all" }` |
|
|
799
|
+
| `store.replaceMiddleware(middleware, opts)` | Reemplaza el middleware del spec; misma regla |
|
|
800
|
+
| `store.replaceEffects(effects, opts)` | Reemplaza los efectos del spec; misma regla |
|
|
801
|
+
| `store.hotReplace(partial)` | Reemplaza cualquier subconjunto; reenvía `scope` |
|
|
633
802
|
|
|
634
803
|
### Helpers
|
|
635
804
|
|
|
636
|
-
| API |
|
|
805
|
+
| API | Descripción |
|
|
637
806
|
| ------------------------ | ---------------------------------------------------------------- |
|
|
638
807
|
| `eventKeys<EM>()([...])` | Arrays de claves de evento con seguridad de tipos sin `as const` |
|
|
639
808
|
|
|
640
809
|
---
|
|
641
810
|
|
|
811
|
+
## Guardar y restaurar estado
|
|
812
|
+
|
|
813
|
+
Dos funciones, porque las dos mitades ocurren en lados opuestos de la existencia del store.
|
|
814
|
+
`hydrate` produce el *estado inicial de las slices*, así que el store nace con él:
|
|
815
|
+
|
|
816
|
+
```ts
|
|
817
|
+
import { createStore, createWebStorageAdapter, hydrate, persist, withHydration } from '@yoltra/core';
|
|
818
|
+
|
|
819
|
+
const adapter = createWebStorageAdapter(localStorage);
|
|
820
|
+
const hydration = await hydrate({ key: 'app', adapter, version: 3 });
|
|
821
|
+
|
|
822
|
+
const store = createStore({
|
|
823
|
+
name: 'App',
|
|
824
|
+
reducer: withHydration({ todos: todosSpec, ui: uiSpec }, hydration),
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
const stop = persist(store, { key: 'app', adapter, version: 3, slices: ['todos'] });
|
|
828
|
+
```
|
|
829
|
+
|
|
830
|
+
Restaurar *después* de construir es la alternativa obvia y la equivocada: aplicar una
|
|
831
|
+
instantánea a un store vivo emite un cambio en todas las rutas, lo que en el arranque es un
|
|
832
|
+
parpadeo, una ráfaga de entradas de instrumentación que describen cambios que nadie hizo, y
|
|
833
|
+
efectos observando una transición que nunca ocurrió.
|
|
834
|
+
|
|
835
|
+
**Nada lanza en el arranque.** Un payload ausente, ilegible o no migrable recae en los valores
|
|
836
|
+
por defecto que declaraste y se reporta por `onError`. Un store que no arranca porque el
|
|
837
|
+
almacenamiento guarda JSON obsoleto es peor que uno que arranca de cero, y un disco lleno no
|
|
838
|
+
debería tumbar una página, así que los fallos de escritura se reportan igual en vez de lanzarse.
|
|
839
|
+
|
|
840
|
+
**Las versiones que no coinciden se rechazan, no se asumen.** Los reducers cambian, y una
|
|
841
|
+
instantánea escrita contra una forma anterior puede no ser estado válido para este build en
|
|
842
|
+
absoluto. Aporta `migrate` para actualizarla, o se descarta.
|
|
843
|
+
|
|
844
|
+
Las escrituras las dirige la instrumentación, así que un cambio confinado a una slice que no
|
|
845
|
+
estás persistiendo no cuesta nada, y una ráfaga se agrupa en una sola escritura. `Map`, `Set`,
|
|
846
|
+
`Date`, `BigInt`, `undefined` y las referencias circulares sobreviven al viaje de ida y vuelta:
|
|
847
|
+
`JSON.stringify` no falla con eso, los destruye en silencio.
|
|
848
|
+
|
|
849
|
+
Para un render en servidor, `dehydrate(store, { version })` produce el payload y
|
|
850
|
+
`hydrate({ source, version })` lo consume.
|
|
851
|
+
|
|
852
|
+
---
|
|
853
|
+
|
|
854
|
+
## Listas que se reordenan
|
|
855
|
+
|
|
856
|
+
La notificación por ruta es posicional para los arrays. `items.0.title` nombra un *hueco*, no
|
|
857
|
+
una cosa, así que `unshift`, `splice(0, 1)` y `sort` mueven casi todos los elementos a un hueco
|
|
858
|
+
distinto, y el diff reporta correctamente que casi todas las hojas cambiaron. Insertar una fila
|
|
859
|
+
al principio de mil despierta a mil suscriptores.
|
|
860
|
+
|
|
861
|
+
Eso es honesto en vez de ruidoso: con rutas posicionales el valor de casi cada índice cambió de
|
|
862
|
+
verdad. El remedio es la forma del estado, no un diff que se calle.
|
|
863
|
+
|
|
864
|
+
```ts
|
|
865
|
+
import { createEntityAdapter } from '@yoltra/core';
|
|
866
|
+
|
|
867
|
+
const todos = createEntityAdapter<Todo>();
|
|
868
|
+
|
|
869
|
+
// state is { ids: [...], entities: { abc: {...} } }
|
|
870
|
+
todos.updateOne(state, { id: 'abc', changes: { done: true } });
|
|
871
|
+
|
|
872
|
+
// and the adapter hands out the paths, so they are never typed by hand
|
|
873
|
+
todos.pathTo('abc', 'title'); // "entities.abc.title"
|
|
874
|
+
todos.idsPath; // "ids"
|
|
875
|
+
```
|
|
876
|
+
|
|
877
|
+
`entities.abc.title` sobrevive a insertar, eliminar y reordenar. Un contenedor de lista se
|
|
878
|
+
suscribe a `ids` y reordena sus hijos; las filas se suscriben a su propia entidad y siguen
|
|
879
|
+
dormidas durante un `sort`.
|
|
880
|
+
|
|
881
|
+
`ids` sigue siendo un array, así que un reordenamiento todavía reporta `ids.0`, `ids.1` y así
|
|
882
|
+
sucesivamente. Ese costo queda confinado, no eliminado. Lo que ganas es un costo proporcional a
|
|
883
|
+
lo que realmente cambió.
|
|
884
|
+
|
|
885
|
+
Para una lista pequeña que solo crece por el final, `items.0.title` está bien y es más simple.
|
|
886
|
+
El adapter es para colecciones que se reordenan, o que son lo bastante grandes como para que la
|
|
887
|
+
diferencia se note.
|
|
888
|
+
|
|
889
|
+
### Lo que cuesta, medido
|
|
890
|
+
|
|
891
|
+
Con 1000 filas, hacer el diff después de una inserción al principio cuesta 1200 µs para un array
|
|
892
|
+
y 371 µs normalizado, y el array reporta alrededor de mil rutas cambiadas frente a dos. Ese es
|
|
893
|
+
el caso para el que existe el adapter.
|
|
894
|
+
|
|
895
|
+
Una actualización de un solo campo va al revés: 20 µs para el array frente a 470 µs normalizado.
|
|
896
|
+
`detectChangedProps` indexa un array pero enumera las claves de un objeto, construyendo dos
|
|
897
|
+
arrays de claves y un `Set` por comparación, así que un mapa de entidades ancho es más caro de
|
|
898
|
+
recorrer aunque casi nada dentro se haya movido. Los números están en `benchmarks/`, y cerrar
|
|
899
|
+
esa brecha es trabajo con seguimiento, no una propiedad de normalizar como tal.
|
|
900
|
+
|
|
901
|
+
Así que: normaliza las colecciones que se reordenan o que rotan mucho. Una colección grande a la
|
|
902
|
+
que solo se le editan campos individuales está mejor como array hoy.
|
|
903
|
+
|
|
904
|
+
---
|
|
905
|
+
|
|
642
906
|
## Rendimiento
|
|
643
907
|
|
|
644
|
-
|
|
|
645
|
-
| --------------------- |
|
|
646
|
-
| **
|
|
647
|
-
| **Tree-shakeable** |
|
|
648
|
-
| **Dependencias** | Cero
|
|
649
|
-
| **TypeScript** | Definiciones de tipos completas incluidas
|
|
908
|
+
| Métrica | Valor |
|
|
909
|
+
| --------------------- | ----------------------------------------- |
|
|
910
|
+
| **Tamaño del bundle** | Medido en cada build, ver la tabla abajo |
|
|
911
|
+
| **Tree-shakeable** | Sí (módulos ES) |
|
|
912
|
+
| **Dependencias** | Cero |
|
|
913
|
+
| **TypeScript** | Definiciones de tipos completas incluidas |
|
|
914
|
+
|
|
915
|
+
El tamaño del bundle se verifica, no se afirma: `rush size` empaqueta el paquete como lo haría
|
|
916
|
+
un consumidor (sacudido, minificado, comprimido con gzip) y falla cuando excede el
|
|
917
|
+
presupuesto declarado en `package.json`. La tabla de abajo la escribe esa misma verificación,
|
|
918
|
+
así que no puede desviarse de lo que se midió; editarla a mano hace fallar el CI.
|
|
919
|
+
|
|
920
|
+
La cifra que importa es lo que importas, no lo que el paquete exporta:
|
|
921
|
+
|
|
922
|
+
<!-- size-table:start -->
|
|
923
|
+
| Import | Tamaño | Presupuesto |
|
|
924
|
+
| --- | --- | --- |
|
|
925
|
+
| `{ createStore }` | 11.5 KB | 14 KB |
|
|
926
|
+
| `{ createStore, hydrate, persist }` | 12.8 KB | 16 KB |
|
|
927
|
+
| todo | 14.2 KB | 18 KB |
|
|
928
|
+
<!-- size-table:end -->
|
|
929
|
+
|
|
930
|
+
Estas son cifras de **producción**: lo que públicas una vez que tu empaquetador define
|
|
931
|
+
`NODE_ENV=production` y las guardas exclusivas de desarrollo desaparecen. La columna de
|
|
932
|
+
presupuesto es el techo que `rush size` impone, y se verifica contra un build de desarrollo,
|
|
933
|
+
que es el mayor de los dos: el código exclusivo de desarrollo no puede crecer sin que nadie lo
|
|
934
|
+
note solo porque nunca llega a un usuario. Por eso el margen que se infiere aquí es
|
|
935
|
+
deliberadamente conservador.
|
|
936
|
+
|
|
937
|
+
La **distancia entre filas** es la afirmación de tree-shaking, y es lo que hay que vigilar: la
|
|
938
|
+
persistencia añade 1.5 KB a quienes la importan y nada a los demás, y el barrel completo está
|
|
939
|
+
2.9 KB por encima del store. La última fila es un detector de crecimiento; `import * as all` no
|
|
940
|
+
es algo que nadie escriba.
|
|
650
941
|
|
|
651
942
|
---
|
|
652
943
|
|
|
653
|
-
##
|
|
944
|
+
## Documentación
|
|
654
945
|
|
|
655
|
-
- **[README
|
|
656
|
-
|
|
657
|
-
- **[@yoltra/react](../react/README.md)
|
|
946
|
+
- **[README raíz de yoltra](../../README.md)**:
|
|
947
|
+
Descripción general y configuración rápida
|
|
948
|
+
- **[@yoltra/react](../react/README.md)**:
|
|
658
949
|
Hooks de React y Suspense
|
|
659
|
-
- **[Guia de Inicio
|
|
660
|
-
|
|
661
|
-
- **[
|
|
662
|
-
|
|
663
|
-
- **[
|
|
664
|
-
|
|
950
|
+
- **[Guia de Inicio Rápido](https://github.com/yoltra/yoltra/blob/main/docs/en/QUICK_START_GUIDE.md)**:
|
|
951
|
+
Cinco pasos hacia una app funcional
|
|
952
|
+
- **[Actualizar a 0.8.0](https://github.com/yoltra/yoltra/blob/main/docs/es/UPGRADE_0.8.md)**:
|
|
953
|
+
Cinco cambios de comportamiento, y un riesgo si haces rollback
|
|
954
|
+
- **[Guía de Decoración](https://github.com/yoltra/yoltra/blob/main/docs/es/DECORATION_GUIDE.md)**:
|
|
955
|
+
Agregar una slice, middleware o efecto al store de alguien más, con los tipos
|
|
956
|
+
- **[Arquitectura de Cola de Eventos](https://github.com/yoltra/yoltra/blob/main/docs/en/design/event-queue-architecture.md)**:
|
|
957
|
+
Inmersión técnica profunda
|
|
958
|
+
- **[Comparación de Bibliotecas](https://github.com/yoltra/yoltra/blob/main/docs/en/design/state-management-library-comparison.md)**:
|
|
959
|
+
Comparación arquitectónica
|
|
665
960
|
|
|
666
961
|
---
|
|
667
962
|
|
|
668
963
|
## Ejemplos
|
|
669
964
|
|
|
670
|
-
- **[App de Tareas](https://github.com/yoltra/yoltra/blob/main/examples/v0/yoltra-in-react)
|
|
965
|
+
- **[App de Tareas](https://github.com/yoltra/yoltra/blob/main/examples/v0/yoltra-in-react)**:
|
|
671
966
|
CRUD completo con perfilado de rendimiento · [▶ Abrir la demo en vivo](https://yoltra.dev/es/demos/in-react)
|
|
672
|
-
- **[Logo
|
|
673
|
-
|
|
674
|
-
- **[
|
|
675
|
-
|
|
967
|
+
- **[Logo Cinético](https://github.com/yoltra/yoltra/blob/main/examples/v0/yoltra-kinetic-logo)**:
|
|
968
|
+
3000 círculos con simulación física. · [▶ Abrir la demo en vivo](https://yoltra.dev/es/demos/kinetic-logo)
|
|
969
|
+
- **[Integración con Next.js](https://github.com/yoltra/yoltra/blob/main/examples/v0/yoltra-in-nextjs)**:
|
|
970
|
+
Pages Router, estado de cliente + cambio de tema · [▶ Abrir la demo en vivo](https://yoltra.dev/es/demos/in-nextjs)
|
|
676
971
|
|
|
677
972
|
---
|
|
678
973
|
|
|
679
974
|
## Contribuir
|
|
680
975
|
|
|
681
|
-
- [
|
|
682
|
-
- [Guia de
|
|
976
|
+
- [Raíz del Monorepo](../../README.md)
|
|
977
|
+
- [Guia de Contribución](https://github.com/yoltra/yoltra/blob/main/CONTRIBUTING.md)
|
|
683
978
|
|
|
684
979
|
---
|
|
685
980
|
|
|
686
981
|
## Estado
|
|
687
982
|
|
|
688
|
-
**Release Candidate
|
|
983
|
+
**Release Candidate**. Las APIs son estables, usadas en producción, cambios menores posibles
|
|
689
984
|
antes de v1.0.0.
|
|
690
985
|
|
|
691
986
|
---
|
|
692
987
|
|
|
693
988
|
## Licencia
|
|
694
989
|
|
|
695
|
-
**MIT
|
|
990
|
+
**MIT**. Libre para usar en proyectos comerciales y de código abierto.
|