@yoltra/core 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.es.md +322 -143
- package/README.md +85 -76
- package/dist/types/store/Store.d.ts +5 -52
- 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 +4 -4
- package/dist/yoltra.cjs +3 -8
- package/dist/yoltra.cjs.map +1 -1
- package/dist/yoltra.mjs +898 -938
- 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 +4 -3
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,14 +27,14 @@ 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
39
|
├─ 2. Reducers ─── Cada slice que aplica se prepara, y todas se confirman bajo una sola raiz
|
|
40
40
|
├─ 3. Suscriptores de eventos ─── Notificaciones de eventos confirmados/no confirmados
|
|
@@ -43,11 +43,11 @@ emit(channel, type, payload)
|
|
|
43
43
|
└─ 5. Efectos ─── Efectos secundarios ASYNC, una tarea independiente por evento (indexados para busqueda O(1))
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
La fase de
|
|
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,13 +333,13 @@ store.onEvent(
|
|
|
276
333
|
|
|
277
334
|
---
|
|
278
335
|
|
|
279
|
-
## Los commits son
|
|
336
|
+
## Los commits son atómicos entre slices
|
|
280
337
|
|
|
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
|
|
338
|
+
Un evento que toca varias slices las escribe todas y después notifica. Nadie observa un evento
|
|
339
|
+
aplicado a medias: un suscriptor de una slice que lee `getState()` ve todas las demás slices del
|
|
283
340
|
mismo evento ya aplicadas.
|
|
284
341
|
|
|
285
|
-
Esto importa sobre todo donde un cambio se usa como
|
|
342
|
+
Esto importa sobre todo donde un cambio se usa como señal para volver a leer, que es lo que hacen
|
|
286
343
|
los hooks de React.
|
|
287
344
|
|
|
288
345
|
---
|
|
@@ -290,7 +347,7 @@ los hooks de React.
|
|
|
290
347
|
## Rechazar una escritura
|
|
291
348
|
|
|
292
349
|
Un reducer devuelve `Rejected(reason)` en lugar de estado para declinar. **Se rechaza el evento
|
|
293
|
-
completo**: ninguna slice escribe, no se emite ninguna
|
|
350
|
+
completo**: ninguna slice escribe, no se emite ninguna notificación de cambio, y quien llamo sabe
|
|
294
351
|
por que.
|
|
295
352
|
|
|
296
353
|
```typescript
|
|
@@ -313,36 +370,36 @@ const store = createStore({
|
|
|
313
370
|
|
|
314
371
|
const result = await store.emit("plan", "patch", { steps, expectedVersion: 1 });
|
|
315
372
|
|
|
316
|
-
result.committed; // true
|
|
317
|
-
result.written; // false
|
|
373
|
+
result.committed; // true: el middleware lo permitio
|
|
374
|
+
result.written; // false: no se escribio nada
|
|
318
375
|
result.rejected?.reason;
|
|
319
376
|
```
|
|
320
377
|
|
|
321
378
|
Rechazar **no** es lo mismo que devolver el estado sin cambios, que es indistinguible de "este
|
|
322
|
-
evento no me concierne". Tampoco es lo mismo que lanzar: un reducer que lanza tiene un bug,
|
|
323
|
-
que su slice queda aislada y las
|
|
324
|
-
una
|
|
379
|
+
evento no me concierne". Tampoco es lo mismo que lanzar: un reducer que lanza tiene un bug, así
|
|
380
|
+
que su slice queda aislada y las demás sí escriben, mientras que un reducer que rechaza ha tomado
|
|
381
|
+
una decisión a la que cede el evento entero.
|
|
325
382
|
|
|
326
383
|
`emit` resuelve a un `EmitResult` cuando terminan los efectos:
|
|
327
384
|
|
|
328
385
|
| | |
|
|
329
386
|
|---|---|
|
|
330
|
-
| `committed` | el middleware no lo
|
|
331
|
-
| `written` | un reducer
|
|
332
|
-
| `rejected` | presente cuando un reducer
|
|
387
|
+
| `committed` | el middleware no lo vetó |
|
|
388
|
+
| `written` | un reducer cambió el estado de verdad |
|
|
389
|
+
| `rejected` | presente cuando un reducer rechazó, con su `reason` |
|
|
333
390
|
|
|
334
391
|
La fase `written` de `onEvent` reporta lo mismo a los suscriptores. `committed` sigue
|
|
335
392
|
significando **no vetado** y no se estrecho a proposito: se dispara para todo evento que el
|
|
336
|
-
middleware permite, incluidos todos los eventos de un store sin reducers
|
|
337
|
-
bus de notificaciones o de
|
|
393
|
+
middleware permite, incluidos todos los eventos de un store sin reducers, la forma que toma un
|
|
394
|
+
bus de notificaciones o de analítica.
|
|
338
395
|
|
|
339
396
|
---
|
|
340
397
|
|
|
341
|
-
##
|
|
398
|
+
## Petición y respuesta: `store.call()`
|
|
342
399
|
|
|
343
|
-
Todo consumidor de un bus de eventos acaba escribiendo
|
|
344
|
-
suscribirse, emparejar, expirar, desuscribirse. Son unas ochenta
|
|
345
|
-
mismos dos bugs: la
|
|
400
|
+
Todo consumidor de un bus de eventos acaba escribiendo petición/respuesta a mano: generar un id,
|
|
401
|
+
suscribirse, emparejar, expirar, desuscribirse. Son unas ochenta líneas y siempre traen los
|
|
402
|
+
mismos dos bugs: la suscripción sobrevive a la llamada, y `Quien Responde` que olvida devolver el
|
|
346
403
|
id produce un timeout sin nada a lo que apuntar.
|
|
347
404
|
|
|
348
405
|
```typescript
|
|
@@ -364,7 +421,7 @@ store.registerEffect({
|
|
|
364
421
|
|
|
365
422
|
### Una llamada resuelve al evento, no al payload
|
|
366
423
|
|
|
367
|
-
Porque muchas veces quien llama no sabe *
|
|
424
|
+
Porque muchas veces quien llama no sabe *cuál* respuesta va a recibir. `reply` nombra los tipos
|
|
368
425
|
**terminales**, y el evento trae el discriminante:
|
|
369
426
|
|
|
370
427
|
```typescript
|
|
@@ -388,45 +445,45 @@ for await (const step of call) await render(step.payload);
|
|
|
388
445
|
const { payload } = await call;
|
|
389
446
|
```
|
|
390
447
|
|
|
391
|
-
La
|
|
392
|
-
efectos, y el colector es un efecto que no retorna hasta que el consumidor tomo el elemento
|
|
448
|
+
La contrapresión es real, no un buffer con límite. `emit` resuelve solo cuando terminan sus
|
|
449
|
+
efectos, y el colector es un efecto que no retorna hasta que el consumidor tomo el elemento, así
|
|
393
450
|
que un `Quien Responde` que escribe `await emit("job", "tick", chunk)` **va al ritmo del lector**.
|
|
394
451
|
|
|
395
|
-
La
|
|
396
|
-
`await` nunca extrae nada,
|
|
397
|
-
llamada: el progreso que nadie lee
|
|
398
|
-
progreso no iterado se almacena hasta `highWaterMark` y
|
|
452
|
+
La contrapresión entra en juego **cuando empiezas a iterar**. Una llamada que solo se espera con
|
|
453
|
+
`await` nunca extrae nada, así que bloquear a su productor causaría un interbloqueo de la propia
|
|
454
|
+
llamada: el progreso que nadie lee impediría que se enviara el evento terminal. Por eso el
|
|
455
|
+
progreso no iterado se almacena hasta `highWaterMark` y después se cuenta en `call.dropped`.
|
|
399
456
|
|
|
400
457
|
### Retroceso
|
|
401
458
|
|
|
402
459
|
| | |
|
|
403
460
|
|---|---|
|
|
404
461
|
| `timeoutMs` | **Inactividad**, no total: todo evento correlacionado lo reinicia, incluido el progreso. Por defecto 30s. |
|
|
405
|
-
| `signal` | Un `AbortSignal`, para una fecha
|
|
462
|
+
| `signal` | Un `AbortSignal`, para una fecha límite real o una acción cancelada. |
|
|
406
463
|
| `call.cancel(reason)` | Deja de escuchar y liquida la llamada. |
|
|
407
464
|
|
|
408
|
-
Termine como termine, la
|
|
409
|
-
|
|
465
|
+
Termine como termine, la suscripción se elimina y se libera cualquier productor detenido por la
|
|
466
|
+
contrapresión. Un `Quien Responde` atascado es peor que el buffer sin límite que esto reemplazo.
|
|
410
467
|
|
|
411
468
|
---
|
|
412
469
|
|
|
413
470
|
## Leer un valor al suscribirse
|
|
414
471
|
|
|
415
|
-
`connect` empieza en "de ahora en adelante",
|
|
416
|
-
otro lado
|
|
472
|
+
`connect` empieza en "de ahora en adelante", así que la primera lectura había que repetirla en
|
|
473
|
+
otro lado: la misma ruta en dos sitios, libres de divergir:
|
|
417
474
|
|
|
418
475
|
```typescript
|
|
419
476
|
store.connect({ reducer: "todos", property: "items.0.title" }, render, { immediate: true });
|
|
420
477
|
```
|
|
421
478
|
|
|
422
|
-
El primer cambio
|
|
423
|
-
lo
|
|
479
|
+
El primer cambio sintético trae `oldValue: undefined` y **sin procedencia**, porque ningún evento
|
|
480
|
+
lo causó. React no lo necesita: `useSyncExternalStore` ya lee una instantánea al montar.
|
|
424
481
|
|
|
425
482
|
---
|
|
426
483
|
|
|
427
|
-
## De
|
|
484
|
+
## De dónde vino un cambio
|
|
428
485
|
|
|
429
|
-
Un `Change` nombra el evento que lo
|
|
486
|
+
Un `Change` nombra el evento que lo causó, así que un suscriptor ya no tiene que duplicar la causa
|
|
430
487
|
dentro del estado:
|
|
431
488
|
|
|
432
489
|
```typescript
|
|
@@ -438,19 +495,41 @@ store.connect({ reducer: "orders", property: "status" }, (change) => {
|
|
|
438
495
|
});
|
|
439
496
|
```
|
|
440
497
|
|
|
441
|
-
La procedencia
|
|
442
|
-
DevTools, o la entrega `immediate` de arriba. La ausencia es la
|
|
498
|
+
La procedencia está **ausente** cuando ningún evento causó el cambio: un salto de time-travel de
|
|
499
|
+
DevTools, o la entrega `immediate` de arriba. La ausencia es la señal, en vez de un id inventado.
|
|
500
|
+
|
|
501
|
+
---
|
|
502
|
+
|
|
503
|
+
## Deduplicación de Eventos (opt-in)
|
|
504
|
+
|
|
505
|
+
La deduplicación está **desactivada por defecto**. Yoltra nunca descarta en silencio eventos
|
|
506
|
+
idénticos legítimos y rápidos (doble-clics, `+1` repetidos). Actívala solo cuando de verdad quieras
|
|
507
|
+
coalescer:
|
|
508
|
+
|
|
509
|
+
```typescript
|
|
510
|
+
// Por contenido: coalescer (channel, type, payload) identicos dentro de una ventana.
|
|
511
|
+
const store = createStore({
|
|
512
|
+
name: "App",
|
|
513
|
+
reducer: {
|
|
514
|
+
/* ... */
|
|
515
|
+
},
|
|
516
|
+
dedupWindowMs: 100, // default: 0 (desactivado)
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
// Por identidad: dedup por una clave explicita, p. ej. un doble-invoke de React Strict Mode en un efecto.
|
|
520
|
+
await store.emit("analytics", "pageView", { page }, { dedupKey: `pageView:${page}` });
|
|
521
|
+
```
|
|
443
522
|
|
|
444
523
|
---
|
|
445
524
|
|
|
446
|
-
##
|
|
525
|
+
## Protección contra cascadas (activada por defecto)
|
|
447
526
|
|
|
448
|
-
Dos consumidores conectados entre
|
|
449
|
-
dos slices que atienden los eventos de la otra
|
|
450
|
-
de
|
|
527
|
+
Dos consumidores conectados entre sí, ya sea un suscriptor que emite lo que su propio reducer atiende o
|
|
528
|
+
dos slices que atienden los eventos de la otra, producen una cadena de eventos sin final. La cola
|
|
529
|
+
de reducción se drena de forma **síncrona**, así que eso no es un programa lento: es una pestana
|
|
451
530
|
congelada, o un core al 100%, sin error ni stack al que apuntar.
|
|
452
531
|
|
|
453
|
-
Por eso cada evento lleva su
|
|
532
|
+
Por eso cada evento lleva su posición causal, y el store se niega a extender una cadena más allá
|
|
454
533
|
de un tope:
|
|
455
534
|
|
|
456
535
|
```typescript
|
|
@@ -468,46 +547,24 @@ const store = createStore({
|
|
|
468
547
|
});
|
|
469
548
|
```
|
|
470
549
|
|
|
471
|
-
Un evento emitido mientras se atiende otro
|
|
472
|
-
`parentId` y `depth` para que el ciclo sea legible
|
|
473
|
-
evento
|
|
550
|
+
Un evento emitido mientras se atiende otro está un nivel más abajo que su causa, y lleva
|
|
551
|
+
`parentId` y `depth` para que el ciclo sea legible después. Ambos campos están **ausentes** en un
|
|
552
|
+
evento raíz, así que los eventos que emite tu aplicación siguen siendo idénticos byte a byte.
|
|
474
553
|
|
|
475
554
|
Superar el tope no lanza. El emit ofensor se rechaza, lo ya confirmado se mantiene, y `onCascade`
|
|
476
|
-
(
|
|
555
|
+
(más un error en consola) lo nombra. Lanzar aparecería en el suscriptor o efecto que casualmente
|
|
477
556
|
estuviera emitiendo, que es justo el fallo inatribuible que el tope existe para evitar.
|
|
478
557
|
|
|
479
558
|
**Una rafaga ancha no es una cascada.** Un evento cuyo suscriptor emite quinientos hermanos es una
|
|
480
|
-
forma
|
|
559
|
+
forma legítima; la profundidad es lo que la distingue de un ciclo, y un bucle normal de
|
|
481
560
|
`store.emit` nunca acumula profundidad. `maxTransitionsPerDrain` acota el *ancho* y por eso viene
|
|
482
561
|
desactivado.
|
|
483
562
|
|
|
484
563
|
---
|
|
485
564
|
|
|
486
|
-
##
|
|
565
|
+
## Reducers Dinámicos
|
|
487
566
|
|
|
488
|
-
|
|
489
|
-
identicos legitimos y rapidos (doble-clics, `+1` repetidos). Actívala solo cuando de verdad quieras
|
|
490
|
-
coalescer:
|
|
491
|
-
|
|
492
|
-
```typescript
|
|
493
|
-
// Por contenido: coalescer (channel, type, payload) identicos dentro de una ventana.
|
|
494
|
-
const store = createStore({
|
|
495
|
-
name: "App",
|
|
496
|
-
reducer: {
|
|
497
|
-
/* ... */
|
|
498
|
-
},
|
|
499
|
-
dedupWindowMs: 100, // default: 0 (desactivado)
|
|
500
|
-
});
|
|
501
|
-
|
|
502
|
-
// Por identidad: dedup por una clave explicita — p. ej. un doble-invoke de React Strict Mode en un efecto.
|
|
503
|
-
await store.emit("analytics", "pageView", { page }, { dedupKey: `pageView:${page}` });
|
|
504
|
-
```
|
|
505
|
-
|
|
506
|
-
---
|
|
507
|
-
|
|
508
|
-
## Reducers Dinamicos
|
|
509
|
-
|
|
510
|
-
Agrega o elimina slices de reducer en tiempo de ejecucion:
|
|
567
|
+
Agrega o elimina slices de reducer en tiempo de ejecución:
|
|
511
568
|
|
|
512
569
|
```typescript
|
|
513
570
|
const dispose = store.registerReducer("filters", {
|
|
@@ -550,24 +607,24 @@ if (import.meta.hot) {
|
|
|
550
607
|
|
|
551
608
|
---
|
|
552
609
|
|
|
553
|
-
## Mejores
|
|
610
|
+
## Mejores Prácticas
|
|
554
611
|
|
|
555
|
-
### El estado es
|
|
612
|
+
### El estado es síncrono; haz `await` solo por los efectos
|
|
556
613
|
|
|
557
|
-
La fase de
|
|
558
|
-
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
|
|
559
616
|
_ese evento_ hayan terminado:
|
|
560
617
|
|
|
561
618
|
```typescript
|
|
562
619
|
emit("todo", "add", todo);
|
|
563
|
-
store.getState(); // Ya refleja la nueva tarea
|
|
620
|
+
store.getState(); // Ya refleja la nueva tarea. Sin await
|
|
564
621
|
|
|
565
622
|
await emit("todo", "save", todo); // se resuelve cuando terminan los efectos de save
|
|
566
623
|
```
|
|
567
624
|
|
|
568
|
-
### Mantener los reducers
|
|
625
|
+
### Mantener los reducers rápidos
|
|
569
626
|
|
|
570
|
-
Los reducers son
|
|
627
|
+
Los reducers son síncronos y corren en el mismo tick que `emit()`. Mueve el trabajo costoso a los
|
|
571
628
|
efectos:
|
|
572
629
|
|
|
573
630
|
```typescript
|
|
@@ -600,31 +657,31 @@ store.registerEffect({
|
|
|
600
657
|
|
|
601
658
|
## Resumen de API
|
|
602
659
|
|
|
603
|
-
###
|
|
660
|
+
### Creación del Store
|
|
604
661
|
|
|
605
|
-
| API |
|
|
662
|
+
| API | Descripción |
|
|
606
663
|
| ----------------------------------------------- | ----------------------------------------------------- |
|
|
607
664
|
| `createStore(spec)` | Crear un store (tipos inferidos de los reducers) |
|
|
608
|
-
| `createStore<S, EM>(spec)` | Crear un store con tipos de estado/eventos
|
|
665
|
+
| `createStore<S, EM>(spec)` | Crear un store con tipos de estado/eventos explícitos |
|
|
609
666
|
| `store.emit(channel, type, payload)` | Emitir un evento (retorna una promesa) |
|
|
610
667
|
| `store.getState()` | Obtener snapshot del estado actual (solo lectura) |
|
|
611
|
-
| `store.subscribe(listener)` |
|
|
612
|
-
| `store.connect(spec, handler)` |
|
|
613
|
-
| `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) |
|
|
614
671
|
| `store.onEffect(channel, type, handler)` | Shorthand de efecto para un solo evento |
|
|
615
672
|
| `store.dispose()` | Limpiar timers y recursos |
|
|
616
673
|
|
|
617
|
-
### Registro
|
|
674
|
+
### Registro Dinámico
|
|
618
675
|
|
|
619
|
-
| API |
|
|
676
|
+
| API | Descripción |
|
|
620
677
|
| ----------------------------------- | ----------------------------------------- |
|
|
621
|
-
| `store.registerReducer(name, spec)` | Agregar un slice en tiempo de
|
|
622
|
-
| `store.registerMiddleware(fn)` | Agregar middleware en tiempo de
|
|
623
|
-
| `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 |
|
|
624
681
|
|
|
625
682
|
### HMR
|
|
626
683
|
|
|
627
|
-
| API |
|
|
684
|
+
| API | Descripción |
|
|
628
685
|
| --------------------------------------- | ------------------------------------------- |
|
|
629
686
|
| `store.replaceReducers(reducers, opts)` | Reemplazar todos los reducers |
|
|
630
687
|
| `store.replaceMiddleware(middleware)` | Reemplazar todos los middleware |
|
|
@@ -633,63 +690,185 @@ store.registerEffect({
|
|
|
633
690
|
|
|
634
691
|
### Helpers
|
|
635
692
|
|
|
636
|
-
| API |
|
|
693
|
+
| API | Descripción |
|
|
637
694
|
| ------------------------ | ---------------------------------------------------------------- |
|
|
638
695
|
| `eventKeys<EM>()([...])` | Arrays de claves de evento con seguridad de tipos sin `as const` |
|
|
639
696
|
|
|
640
697
|
---
|
|
641
698
|
|
|
699
|
+
## Guardar y restaurar estado
|
|
700
|
+
|
|
701
|
+
Dos funciones, porque las dos mitades ocurren en lados opuestos de la existencia del store.
|
|
702
|
+
`hydrate` produce el *estado inicial de las slices*, así que el store nace con él:
|
|
703
|
+
|
|
704
|
+
```ts
|
|
705
|
+
import { createStore, createWebStorageAdapter, hydrate, persist, withHydration } from '@yoltra/core';
|
|
706
|
+
|
|
707
|
+
const adapter = createWebStorageAdapter(localStorage);
|
|
708
|
+
const hydration = await hydrate({ key: 'app', adapter, version: 3 });
|
|
709
|
+
|
|
710
|
+
const store = createStore({
|
|
711
|
+
name: 'App',
|
|
712
|
+
reducer: withHydration({ todos: todosSpec, ui: uiSpec }, hydration),
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
const stop = persist(store, { key: 'app', adapter, version: 3, slices: ['todos'] });
|
|
716
|
+
```
|
|
717
|
+
|
|
718
|
+
Restaurar *después* de construir es la alternativa obvia y la equivocada: aplicar una
|
|
719
|
+
instantánea a un store vivo emite un cambio en todas las rutas, lo que en el arranque es un
|
|
720
|
+
parpadeo, una ráfaga de entradas de instrumentación que describen cambios que nadie hizo, y
|
|
721
|
+
efectos observando una transición que nunca ocurrió.
|
|
722
|
+
|
|
723
|
+
**Nada lanza en el arranque.** Un payload ausente, ilegible o no migrable recae en los valores
|
|
724
|
+
por defecto que declaraste y se reporta por `onError`. Un store que no arranca porque el
|
|
725
|
+
almacenamiento guarda JSON obsoleto es peor que uno que arranca de cero, y un disco lleno no
|
|
726
|
+
debería tumbar una página, así que los fallos de escritura se reportan igual en vez de lanzarse.
|
|
727
|
+
|
|
728
|
+
**Las versiones que no coinciden se rechazan, no se asumen.** Los reducers cambian, y una
|
|
729
|
+
instantánea escrita contra una forma anterior puede no ser estado válido para este build en
|
|
730
|
+
absoluto. Aporta `migrate` para actualizarla, o se descarta.
|
|
731
|
+
|
|
732
|
+
Las escrituras las dirige la instrumentación, así que un cambio confinado a una slice que no
|
|
733
|
+
estás persistiendo no cuesta nada, y una ráfaga se agrupa en una sola escritura. `Map`, `Set`,
|
|
734
|
+
`Date`, `BigInt`, `undefined` y las referencias circulares sobreviven al viaje de ida y vuelta:
|
|
735
|
+
`JSON.stringify` no falla con eso, los destruye en silencio.
|
|
736
|
+
|
|
737
|
+
Para un render en servidor, `dehydrate(store, { version })` produce el payload y
|
|
738
|
+
`hydrate({ source, version })` lo consume.
|
|
739
|
+
|
|
740
|
+
---
|
|
741
|
+
|
|
742
|
+
## Listas que se reordenan
|
|
743
|
+
|
|
744
|
+
La notificación por ruta es posicional para los arrays. `items.0.title` nombra un *hueco*, no
|
|
745
|
+
una cosa, así que `unshift`, `splice(0, 1)` y `sort` mueven casi todos los elementos a un hueco
|
|
746
|
+
distinto, y el diff reporta correctamente que casi todas las hojas cambiaron. Insertar una fila
|
|
747
|
+
al principio de mil despierta a mil suscriptores.
|
|
748
|
+
|
|
749
|
+
Eso es honesto en vez de ruidoso: con rutas posicionales el valor de casi cada índice cambió de
|
|
750
|
+
verdad. El remedio es la forma del estado, no un diff que se calle.
|
|
751
|
+
|
|
752
|
+
```ts
|
|
753
|
+
import { createEntityAdapter } from '@yoltra/core';
|
|
754
|
+
|
|
755
|
+
const todos = createEntityAdapter<Todo>();
|
|
756
|
+
|
|
757
|
+
// state is { ids: [...], entities: { abc: {...} } }
|
|
758
|
+
todos.updateOne(state, { id: 'abc', changes: { done: true } });
|
|
759
|
+
|
|
760
|
+
// and the adapter hands out the paths, so they are never typed by hand
|
|
761
|
+
todos.pathTo('abc', 'title'); // "entities.abc.title"
|
|
762
|
+
todos.idsPath; // "ids"
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
`entities.abc.title` sobrevive a insertar, eliminar y reordenar. Un contenedor de lista se
|
|
766
|
+
suscribe a `ids` y reordena sus hijos; las filas se suscriben a su propia entidad y siguen
|
|
767
|
+
dormidas durante un `sort`.
|
|
768
|
+
|
|
769
|
+
`ids` sigue siendo un array, así que un reordenamiento todavía reporta `ids.0`, `ids.1` y así
|
|
770
|
+
sucesivamente. Ese costo queda confinado, no eliminado. Lo que ganas es un costo proporcional a
|
|
771
|
+
lo que realmente cambió.
|
|
772
|
+
|
|
773
|
+
Para una lista pequeña que solo crece por el final, `items.0.title` está bien y es más simple.
|
|
774
|
+
El adapter es para colecciones que se reordenan, o que son lo bastante grandes como para que la
|
|
775
|
+
diferencia se note.
|
|
776
|
+
|
|
777
|
+
### Lo que cuesta, medido
|
|
778
|
+
|
|
779
|
+
Con 1000 filas, hacer el diff después de una inserción al principio cuesta 1200 µs para un array
|
|
780
|
+
y 371 µs normalizado, y el array reporta alrededor de mil rutas cambiadas frente a dos. Ese es
|
|
781
|
+
el caso para el que existe el adapter.
|
|
782
|
+
|
|
783
|
+
Una actualización de un solo campo va al revés: 20 µs para el array frente a 470 µs normalizado.
|
|
784
|
+
`detectChangedProps` indexa un array pero enumera las claves de un objeto, construyendo dos
|
|
785
|
+
arrays de claves y un `Set` por comparación, así que un mapa de entidades ancho es más caro de
|
|
786
|
+
recorrer aunque casi nada dentro se haya movido. Los números están en `benchmarks/`, y cerrar
|
|
787
|
+
esa brecha es trabajo con seguimiento, no una propiedad de normalizar como tal.
|
|
788
|
+
|
|
789
|
+
Así que: normaliza las colecciones que se reordenan o que rotan mucho. Una colección grande a la
|
|
790
|
+
que solo se le editan campos individuales está mejor como array hoy.
|
|
791
|
+
|
|
792
|
+
---
|
|
793
|
+
|
|
642
794
|
## Rendimiento
|
|
643
795
|
|
|
644
|
-
|
|
|
645
|
-
| --------------------- |
|
|
646
|
-
| **
|
|
647
|
-
| **Tree-shakeable** |
|
|
648
|
-
| **Dependencias** | Cero
|
|
649
|
-
| **TypeScript** | Definiciones de tipos completas incluidas
|
|
796
|
+
| Métrica | Valor |
|
|
797
|
+
| --------------------- | ----------------------------------------- |
|
|
798
|
+
| **Tamaño del bundle** | Medido en cada build, ver la tabla abajo |
|
|
799
|
+
| **Tree-shakeable** | Sí (módulos ES) |
|
|
800
|
+
| **Dependencias** | Cero |
|
|
801
|
+
| **TypeScript** | Definiciones de tipos completas incluidas |
|
|
802
|
+
|
|
803
|
+
El tamaño del bundle se verifica, no se afirma: `rush size` empaqueta el paquete como lo haría
|
|
804
|
+
un consumidor (sacudido, minificado, comprimido con gzip) y falla cuando excede el
|
|
805
|
+
presupuesto declarado en `package.json`. La tabla de abajo la escribe esa misma verificación,
|
|
806
|
+
así que no puede desviarse de lo que se midió; editarla a mano hace fallar el CI.
|
|
807
|
+
|
|
808
|
+
La cifra que importa es lo que importas, no lo que el paquete exporta:
|
|
809
|
+
|
|
810
|
+
<!-- size-table:start -->
|
|
811
|
+
| Import | Tamaño | Presupuesto |
|
|
812
|
+
| --- | --- | --- |
|
|
813
|
+
| `{ createStore }` | 8.3 KB | 14 KB |
|
|
814
|
+
| `{ createStore, hydrate, persist }` | 9.7 KB | 16 KB |
|
|
815
|
+
| todo | 11.2 KB | 18 KB |
|
|
816
|
+
<!-- size-table:end -->
|
|
817
|
+
|
|
818
|
+
Estas son cifras de **producción**: lo que públicas una vez que tu empaquetador define
|
|
819
|
+
`NODE_ENV=production` y las guardas exclusivas de desarrollo desaparecen. La columna de
|
|
820
|
+
presupuesto es el techo que `rush size` impone, y se verifica contra un build de desarrollo,
|
|
821
|
+
que es el mayor de los dos: el código exclusivo de desarrollo no puede crecer sin que nadie lo
|
|
822
|
+
note solo porque nunca llega a un usuario. Por eso el margen que se infiere aquí es
|
|
823
|
+
deliberadamente conservador.
|
|
824
|
+
|
|
825
|
+
La **distancia entre filas** es la afirmación de tree-shaking, y es lo que hay que vigilar: la
|
|
826
|
+
persistencia añade 1.5 KB a quienes la importan y nada a los demás, y el barrel completo está
|
|
827
|
+
2.9 KB por encima del store. La última fila es un detector de crecimiento; `import * as all` no
|
|
828
|
+
es algo que nadie escriba.
|
|
650
829
|
|
|
651
830
|
---
|
|
652
831
|
|
|
653
|
-
##
|
|
832
|
+
## Documentación
|
|
654
833
|
|
|
655
|
-
- **[README
|
|
656
|
-
|
|
657
|
-
- **[@yoltra/react](../react/README.md)
|
|
834
|
+
- **[README raíz de yoltra](../../README.md)**:
|
|
835
|
+
Descripción general y configuración rápida
|
|
836
|
+
- **[@yoltra/react](../react/README.md)**:
|
|
658
837
|
Hooks de React y Suspense
|
|
659
|
-
- **[Guia de Inicio
|
|
660
|
-
|
|
661
|
-
- **[Arquitectura de Cola de Eventos](https://github.com/yoltra/yoltra/blob/main/docs/en/design/event-queue-architecture.md)
|
|
662
|
-
|
|
663
|
-
- **[
|
|
664
|
-
|
|
838
|
+
- **[Guia de Inicio Rápido](https://github.com/yoltra/yoltra/blob/main/docs/en/QUICK_START_GUIDE.md)**:
|
|
839
|
+
Cinco pasos hacia una app funcional
|
|
840
|
+
- **[Arquitectura de Cola de Eventos](https://github.com/yoltra/yoltra/blob/main/docs/en/design/event-queue-architecture.md)**:
|
|
841
|
+
Inmersión técnica profunda
|
|
842
|
+
- **[Comparación de Bibliotecas](https://github.com/yoltra/yoltra/blob/main/docs/en/design/state-management-library-comparison.md)**:
|
|
843
|
+
Comparación arquitectónica
|
|
665
844
|
|
|
666
845
|
---
|
|
667
846
|
|
|
668
847
|
## Ejemplos
|
|
669
848
|
|
|
670
|
-
- **[App de Tareas](https://github.com/yoltra/yoltra/blob/main/examples/v0/yoltra-in-react)
|
|
849
|
+
- **[App de Tareas](https://github.com/yoltra/yoltra/blob/main/examples/v0/yoltra-in-react)**:
|
|
671
850
|
CRUD completo con perfilado de rendimiento · [▶ Abrir la demo en vivo](https://yoltra.dev/es/demos/in-react)
|
|
672
|
-
- **[Logo
|
|
673
|
-
|
|
674
|
-
- **[
|
|
675
|
-
|
|
851
|
+
- **[Logo Cinético](https://github.com/yoltra/yoltra/blob/main/examples/v0/yoltra-kinetic-logo)**:
|
|
852
|
+
3000 círculos con simulación física. · [▶ Abrir la demo en vivo](https://yoltra.dev/es/demos/kinetic-logo)
|
|
853
|
+
- **[Integración con Next.js](https://github.com/yoltra/yoltra/blob/main/examples/v0/yoltra-in-nextjs)**:
|
|
854
|
+
Pages Router, estado de cliente + cambio de tema · [▶ Abrir la demo en vivo](https://yoltra.dev/es/demos/in-nextjs)
|
|
676
855
|
|
|
677
856
|
---
|
|
678
857
|
|
|
679
858
|
## Contribuir
|
|
680
859
|
|
|
681
|
-
- [
|
|
682
|
-
- [Guia de
|
|
860
|
+
- [Raíz del Monorepo](../../README.md)
|
|
861
|
+
- [Guia de Contribución](https://github.com/yoltra/yoltra/blob/main/CONTRIBUTING.md)
|
|
683
862
|
|
|
684
863
|
---
|
|
685
864
|
|
|
686
865
|
## Estado
|
|
687
866
|
|
|
688
|
-
**Release Candidate
|
|
867
|
+
**Release Candidate**. Las APIs son estables, usadas en producción, cambios menores posibles
|
|
689
868
|
antes de v1.0.0.
|
|
690
869
|
|
|
691
870
|
---
|
|
692
871
|
|
|
693
872
|
## Licencia
|
|
694
873
|
|
|
695
|
-
**MIT
|
|
874
|
+
**MIT**. Libre para usar en proyectos comerciales y de código abierto.
|