@yoltra/core 0.5.0 → 0.6.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 +214 -7
- package/README.md +258 -13
- 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 +247 -17
- package/dist/types/store/call.d.ts +149 -0
- package/dist/types/store/callQueue.d.ts +79 -0
- package/dist/types/store/rejection.d.ts +58 -0
- package/dist/types/types.d.ts +244 -11
- package/dist/yoltra.cjs +2 -2
- package/dist/yoltra.cjs.map +1 -1
- package/dist/yoltra.mjs +1261 -750
- package/dist/yoltra.mjs.map +1 -1
- package/dist/yoltra.umd.js +2 -2
- package/dist/yoltra.umd.js.map +1 -1
- package/package.json +9 -9
package/README.es.md
CHANGED
|
@@ -36,7 +36,7 @@ emit(channel, type, payload)
|
|
|
36
36
|
│
|
|
37
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
|
│
|
|
@@ -276,6 +276,213 @@ store.onEvent(
|
|
|
276
276
|
|
|
277
277
|
---
|
|
278
278
|
|
|
279
|
+
## Los commits son atomicos entre slices
|
|
280
|
+
|
|
281
|
+
Un evento que toca varias slices las escribe todas y despues notifica. Nadie observa un evento
|
|
282
|
+
aplicado a medias: un suscriptor de una slice que lee `getState()` ve todas las demas slices del
|
|
283
|
+
mismo evento ya aplicadas.
|
|
284
|
+
|
|
285
|
+
Esto importa sobre todo donde un cambio se usa como senal para volver a leer, que es lo que hacen
|
|
286
|
+
los hooks de React.
|
|
287
|
+
|
|
288
|
+
---
|
|
289
|
+
|
|
290
|
+
## Rechazar una escritura
|
|
291
|
+
|
|
292
|
+
Un reducer devuelve `Rejected(reason)` en lugar de estado para declinar. **Se rechaza el evento
|
|
293
|
+
completo**: ninguna slice escribe, no se emite ninguna notificacion de cambio, y quien llamo sabe
|
|
294
|
+
por que.
|
|
295
|
+
|
|
296
|
+
```typescript
|
|
297
|
+
import { createStore, Rejected } from "@yoltra/core";
|
|
298
|
+
|
|
299
|
+
const store = createStore({
|
|
300
|
+
name: "plan",
|
|
301
|
+
reducer: {
|
|
302
|
+
plan: {
|
|
303
|
+
state: { steps: [], version: 1 },
|
|
304
|
+
when: { keys: [["plan", "patch"]] },
|
|
305
|
+
reducer: (state, event) =>
|
|
306
|
+
event.payload.expectedVersion === state.version
|
|
307
|
+
? { ...state, steps: event.payload.steps, version: state.version + 1 }
|
|
308
|
+
: Rejected(`escritura obsoleta: esperaba v${event.payload.expectedVersion}`),
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
onRejected: (rejection, event, slice) => metrics.increment("write.refused", { slice }),
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const result = await store.emit("plan", "patch", { steps, expectedVersion: 1 });
|
|
315
|
+
|
|
316
|
+
result.committed; // true — el middleware lo permitio
|
|
317
|
+
result.written; // false — pero no se escribio nada
|
|
318
|
+
result.rejected?.reason;
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
Rechazar **no** es lo mismo que devolver el estado sin cambios, que es indistinguible de "este
|
|
322
|
+
evento no me concierne". Tampoco es lo mismo que lanzar: un reducer que lanza tiene un bug, asi
|
|
323
|
+
que su slice queda aislada y las demas si escriben, mientras que un reducer que rechaza ha tomado
|
|
324
|
+
una decision a la que cede el evento entero.
|
|
325
|
+
|
|
326
|
+
`emit` resuelve a un `EmitResult` cuando terminan los efectos:
|
|
327
|
+
|
|
328
|
+
| | |
|
|
329
|
+
|---|---|
|
|
330
|
+
| `committed` | el middleware no lo veto |
|
|
331
|
+
| `written` | un reducer cambio el estado de verdad |
|
|
332
|
+
| `rejected` | presente cuando un reducer rechazo, con su `reason` |
|
|
333
|
+
|
|
334
|
+
La fase `written` de `onEvent` reporta lo mismo a los suscriptores. `committed` sigue
|
|
335
|
+
significando **no vetado** y no se estrecho a proposito: se dispara para todo evento que el
|
|
336
|
+
middleware permite, incluidos todos los eventos de un store sin reducers — la forma que toma un
|
|
337
|
+
bus de notificaciones o de analitica.
|
|
338
|
+
|
|
339
|
+
---
|
|
340
|
+
|
|
341
|
+
## Peticion y respuesta — `store.call()`
|
|
342
|
+
|
|
343
|
+
Todo consumidor de un bus de eventos acaba escribiendo peticion/respuesta a mano: generar un id,
|
|
344
|
+
suscribirse, emparejar, expirar, desuscribirse. Son unas ochenta lineas y siempre traen los
|
|
345
|
+
mismos dos bugs: la suscripcion sobrevive a la llamada, y `Quien Responde` que olvida devolver el
|
|
346
|
+
id produce un timeout sin nada a lo que apuntar.
|
|
347
|
+
|
|
348
|
+
```typescript
|
|
349
|
+
const res = await store.call("rpc", "ask", { q: "quien?" }, { reply: ["rpc", "answer"] });
|
|
350
|
+
res.payload.text;
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
`Quien Responde` no hace nada especial. Responde con el `emit` que recibio, y la marca causal del
|
|
354
|
+
store correlaciona ambos: **no hay id que generar, devolver ni olvidar**.
|
|
355
|
+
|
|
356
|
+
```typescript
|
|
357
|
+
store.registerEffect({
|
|
358
|
+
when: { keys: [["rpc", "ask"]] },
|
|
359
|
+
effect: async (event, _get, emit) => {
|
|
360
|
+
await emit("rpc", "answer", await lookup(event.payload.q));
|
|
361
|
+
},
|
|
362
|
+
});
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
### Una llamada resuelve al evento, no al payload
|
|
366
|
+
|
|
367
|
+
Porque muchas veces quien llama no sabe *cual* respuesta va a recibir. `reply` nombra los tipos
|
|
368
|
+
**terminales**, y el evento trae el discriminante:
|
|
369
|
+
|
|
370
|
+
```typescript
|
|
371
|
+
const res = await store.call("rpc", "ask", { q }, { reply: ["rpc", ["answer", "error"]] });
|
|
372
|
+
|
|
373
|
+
switch (res.type) {
|
|
374
|
+
case "answer": return res.payload.text;
|
|
375
|
+
case "error": throw new Error(res.payload.reason);
|
|
376
|
+
}
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
### El progreso se transmite, y el productor espera
|
|
380
|
+
|
|
381
|
+
Cualquier evento correlacionado que **no** sea terminal es progreso. Itera la llamada para
|
|
382
|
+
consumirlo:
|
|
383
|
+
|
|
384
|
+
```typescript
|
|
385
|
+
const call = store.call("job", "start", { id }, { reply: ["job", "done"], highWaterMark: 4 });
|
|
386
|
+
|
|
387
|
+
for await (const step of call) await render(step.payload);
|
|
388
|
+
const { payload } = await call;
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
La contrapresion es real, no un buffer con limite. `emit` resuelve solo cuando terminan sus
|
|
392
|
+
efectos, y el colector es un efecto que no retorna hasta que el consumidor tomo el elemento — asi
|
|
393
|
+
que un `Quien Responde` que escribe `await emit("job", "tick", chunk)` **va al ritmo del lector**.
|
|
394
|
+
|
|
395
|
+
La contrapresion entra en juego **cuando empiezas a iterar**. Una llamada que solo se espera con
|
|
396
|
+
`await` nunca extrae nada, asi que bloquear a su productor causaria un interbloqueo de la propia
|
|
397
|
+
llamada: el progreso que nadie lee impediria que se enviara el evento terminal. Por eso el
|
|
398
|
+
progreso no iterado se almacena hasta `highWaterMark` y despues se cuenta en `call.dropped`.
|
|
399
|
+
|
|
400
|
+
### Retroceso
|
|
401
|
+
|
|
402
|
+
| | |
|
|
403
|
+
|---|---|
|
|
404
|
+
| `timeoutMs` | **Inactividad**, no total: todo evento correlacionado lo reinicia, incluido el progreso. Por defecto 30s. |
|
|
405
|
+
| `signal` | Un `AbortSignal`, para una fecha limite real o una accion cancelada. |
|
|
406
|
+
| `call.cancel(reason)` | Deja de escuchar y liquida la llamada. |
|
|
407
|
+
|
|
408
|
+
Termine como termine, la suscripcion se elimina y se libera cualquier productor detenido por la
|
|
409
|
+
contrapresion. Un `Quien Responde` atascado es peor que el buffer sin limite que esto reemplazo.
|
|
410
|
+
|
|
411
|
+
---
|
|
412
|
+
|
|
413
|
+
## Leer un valor al suscribirse
|
|
414
|
+
|
|
415
|
+
`connect` empieza en "de ahora en adelante", asi que la primera lectura habia que repetirla en
|
|
416
|
+
otro lado — la misma ruta en dos sitios, libres de divergir:
|
|
417
|
+
|
|
418
|
+
```typescript
|
|
419
|
+
store.connect({ reducer: "todos", property: "items.0.title" }, render, { immediate: true });
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
El primer cambio sintetico trae `oldValue: undefined` y **sin procedencia**, porque ningun evento
|
|
423
|
+
lo causo. React no lo necesita: `useSyncExternalStore` ya lee una instantanea al montar.
|
|
424
|
+
|
|
425
|
+
---
|
|
426
|
+
|
|
427
|
+
## De donde vino un cambio
|
|
428
|
+
|
|
429
|
+
Un `Change` nombra el evento que lo causo, asi que un suscriptor ya no tiene que duplicar la causa
|
|
430
|
+
dentro del estado:
|
|
431
|
+
|
|
432
|
+
```typescript
|
|
433
|
+
store.connect({ reducer: "orders", property: "status" }, (change) => {
|
|
434
|
+
audit.record(change.path, change.newValue, {
|
|
435
|
+
causedBy: change.eventId,
|
|
436
|
+
via: `${change.channel}/${change.type}`,
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
La procedencia esta **ausente** cuando ningun evento causo el cambio — un salto de time-travel de
|
|
442
|
+
DevTools, o la entrega `immediate` de arriba. La ausencia es la senal, en vez de un id inventado.
|
|
443
|
+
|
|
444
|
+
---
|
|
445
|
+
|
|
446
|
+
## Proteccion contra cascadas (activada por defecto)
|
|
447
|
+
|
|
448
|
+
Dos consumidores conectados entre si — un suscriptor que emite lo que su propio reducer atiende, o
|
|
449
|
+
dos slices que atienden los eventos de la otra — producen una cadena de eventos sin final. La cola
|
|
450
|
+
de reduccion se drena de forma **sincrona**, asi que eso no es un programa lento: es una pestana
|
|
451
|
+
congelada, o un core al 100%, sin error ni stack al que apuntar.
|
|
452
|
+
|
|
453
|
+
Por eso cada evento lleva su posicion causal, y el store se niega a extender una cadena mas alla
|
|
454
|
+
de un tope:
|
|
455
|
+
|
|
456
|
+
```typescript
|
|
457
|
+
const store = createStore({
|
|
458
|
+
name: "app",
|
|
459
|
+
reducer: { ... },
|
|
460
|
+
|
|
461
|
+
// Por defecto 64. Acotado configures o no: un fallo tan grave no deberia exigir
|
|
462
|
+
// configuracion para evitarse. Usa Infinity para renunciar a el conscientemente.
|
|
463
|
+
maxReduceDepth: 64,
|
|
464
|
+
|
|
465
|
+
onCascade: ({ event, depth, chain }) => {
|
|
466
|
+
report(`cascada en ${event.channel}/${event.type}, profundidad ${depth}`, chain);
|
|
467
|
+
},
|
|
468
|
+
});
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
Un evento emitido mientras se atiende otro esta un nivel mas abajo que su causa, y lleva
|
|
472
|
+
`parentId` y `depth` para que el ciclo sea legible despues. Ambos campos estan **ausentes** en un
|
|
473
|
+
evento raiz, asi que los eventos que emite tu aplicacion siguen siendo identicos byte a byte.
|
|
474
|
+
|
|
475
|
+
Superar el tope no lanza. El emit ofensor se rechaza, lo ya confirmado se mantiene, y `onCascade`
|
|
476
|
+
(mas un error en consola) lo nombra — lanzar apareceria en el suscriptor o efecto que casualmente
|
|
477
|
+
estuviera emitiendo, que es justo el fallo inatribuible que el tope existe para evitar.
|
|
478
|
+
|
|
479
|
+
**Una rafaga ancha no es una cascada.** Un evento cuyo suscriptor emite quinientos hermanos es una
|
|
480
|
+
forma legitima; la profundidad es lo que la distingue de un ciclo, y un bucle normal de
|
|
481
|
+
`store.emit` nunca acumula profundidad. `maxTransitionsPerDrain` acota el *ancho* y por eso viene
|
|
482
|
+
desactivado.
|
|
483
|
+
|
|
484
|
+
---
|
|
485
|
+
|
|
279
486
|
## Deduplicacion de Eventos (opt-in)
|
|
280
487
|
|
|
281
488
|
La deduplicacion esta **desactivada por defecto** — yoltra nunca descarta en silencio eventos
|
|
@@ -434,12 +641,12 @@ store.registerEffect({
|
|
|
434
641
|
|
|
435
642
|
## Rendimiento
|
|
436
643
|
|
|
437
|
-
| Metrica | Valor
|
|
438
|
-
| --------------------- |
|
|
439
|
-
| **Tamano del bundle** |
|
|
440
|
-
| **Tree-shakeable** | Si (modulos ES)
|
|
441
|
-
| **Dependencias** | Cero
|
|
442
|
-
| **TypeScript** | Definiciones de tipos completas incluidas
|
|
644
|
+
| Metrica | Valor |
|
|
645
|
+
| --------------------- | ------------------------------------------- |
|
|
646
|
+
| **Tamano del bundle** | 9.2 KB para el store (minificado + gzipped) |
|
|
647
|
+
| **Tree-shakeable** | Si (modulos ES) |
|
|
648
|
+
| **Dependencias** | Cero |
|
|
649
|
+
| **TypeScript** | Definiciones de tipos completas incluidas |
|
|
443
650
|
|
|
444
651
|
---
|
|
445
652
|
|
package/README.md
CHANGED
|
@@ -36,7 +36,7 @@ emit(channel, type, payload)
|
|
|
36
36
|
│
|
|
37
37
|
│ ══ SYNCHRONOUS reduce phase — runs before emit() returns ══
|
|
38
38
|
├─ 1. Middleware ─── Synchronous pre-reducer hooks (return false to reject → "uncommitted" event)
|
|
39
|
-
├─ 2. Reducers ───
|
|
39
|
+
├─ 2. Reducers ─── Every matching slice staged, then all committed under one root
|
|
40
40
|
├─ 3. Event subscribers ─── Committed/uncommitted event notifications
|
|
41
41
|
├─ 4. Coarse subscribers ─── External store listeners (useSyncExternalStore, etc.), if state changed
|
|
42
42
|
│
|
|
@@ -316,7 +316,17 @@ store.onEvent(
|
|
|
316
316
|
"uncommitted",
|
|
317
317
|
);
|
|
318
318
|
|
|
319
|
-
//
|
|
319
|
+
// Written events — state actually changed. Fires after the commit, so getState() is current.
|
|
320
|
+
store.onEvent(
|
|
321
|
+
"plan",
|
|
322
|
+
"patch",
|
|
323
|
+
(event, getState) => {
|
|
324
|
+
console.log("applied:", getState().plan);
|
|
325
|
+
},
|
|
326
|
+
"written",
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
// All events — both committed and uncommitted (not written; see below)
|
|
320
330
|
store.onEvent(
|
|
321
331
|
"ui",
|
|
322
332
|
"action",
|
|
@@ -327,6 +337,185 @@ store.onEvent(
|
|
|
327
337
|
);
|
|
328
338
|
```
|
|
329
339
|
|
|
340
|
+
`committed` means **not vetoed**, and always has: it fires for every event middleware let through,
|
|
341
|
+
whether or not a reducer wrote anything — including every event in a store with no reducers at
|
|
342
|
+
all. `written` is the stricter fact, added rather than substituted, so toasts and analytics keep
|
|
343
|
+
working unchanged. `all` stays `committed | uncommitted`; folding `written` in would hand existing
|
|
344
|
+
subscribers a second notification per event.
|
|
345
|
+
|
|
346
|
+
---
|
|
347
|
+
|
|
348
|
+
## Commits are atomic across slices
|
|
349
|
+
|
|
350
|
+
An event that touches several slices writes all of them, then notifies. Nothing observes a
|
|
351
|
+
half-applied event — a subscriber to one slice reading `getState()` sees every other slice of the
|
|
352
|
+
same event already applied.
|
|
353
|
+
|
|
354
|
+
That matters most where a change is used as a signal to re-read, which is what the React hooks do.
|
|
355
|
+
|
|
356
|
+
---
|
|
357
|
+
|
|
358
|
+
## Refusing a write
|
|
359
|
+
|
|
360
|
+
A reducer returns `Rejected(reason)` instead of state to decline. **The whole event is rejected**:
|
|
361
|
+
no slice writes, no change notification fires, and the caller is told why.
|
|
362
|
+
|
|
363
|
+
```typescript
|
|
364
|
+
import { createStore, Rejected } from "@yoltra/core";
|
|
365
|
+
|
|
366
|
+
const store = createStore({
|
|
367
|
+
name: "plan",
|
|
368
|
+
reducer: {
|
|
369
|
+
plan: {
|
|
370
|
+
state: { steps: [], version: 1 },
|
|
371
|
+
when: { keys: [["plan", "patch"]] },
|
|
372
|
+
reducer: (state, event) =>
|
|
373
|
+
event.payload.expectedVersion === state.version
|
|
374
|
+
? { ...state, steps: event.payload.steps, version: state.version + 1 }
|
|
375
|
+
: Rejected(`stale write: expected v${event.payload.expectedVersion}, have v${state.version}`),
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
onRejected: (rejection, event, slice) => metrics.increment("write.refused", { slice }),
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
const result = await store.emit("plan", "patch", { steps, expectedVersion: 1 });
|
|
382
|
+
|
|
383
|
+
result.committed; // true — middleware allowed it
|
|
384
|
+
result.written; // false — but nothing was written
|
|
385
|
+
result.rejected?.reason; // "stale write: expected v1, have v3"
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
Refusing is **not** the same as returning the state unchanged, which is indistinguishable from
|
|
389
|
+
"this event did not concern me". It is also not the same as throwing: a reducer that throws has a
|
|
390
|
+
bug, so its slice is isolated and every other slice still commits, while a reducer that refuses
|
|
391
|
+
has made a decision and the whole event yields to it.
|
|
392
|
+
|
|
393
|
+
`emit` resolves to an `EmitResult` once effects have run:
|
|
394
|
+
|
|
395
|
+
| | |
|
|
396
|
+
|---|---|
|
|
397
|
+
| `committed` | middleware did not veto |
|
|
398
|
+
| `written` | a reducer actually changed state |
|
|
399
|
+
| `rejected` | present when a reducer refused, carrying `reason` |
|
|
400
|
+
|
|
401
|
+
---
|
|
402
|
+
|
|
403
|
+
## Request and reply — `store.call()`
|
|
404
|
+
|
|
405
|
+
Every event-bus consumer eventually writes request/reply by hand: mint an id, subscribe, match,
|
|
406
|
+
time out, unsubscribe. It is about eighty lines and it has the same two bugs every time — the
|
|
407
|
+
subscription outlives the call, and a responder that forgets to echo the id produces a timeout
|
|
408
|
+
with nothing to point at.
|
|
409
|
+
|
|
410
|
+
```typescript
|
|
411
|
+
const res = await store.call("rpc", "ask", { q: "who?" }, { reply: ["rpc", "answer"] });
|
|
412
|
+
res.payload.text;
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
The responder does nothing special. It replies through the `emit` it was handed, and the store's
|
|
416
|
+
causal stamp correlates the two — **there is no id to mint, echo, or forget**:
|
|
417
|
+
|
|
418
|
+
```typescript
|
|
419
|
+
store.registerEffect({
|
|
420
|
+
when: { keys: [["rpc", "ask"]] },
|
|
421
|
+
effect: async (event, _get, emit) => {
|
|
422
|
+
await emit("rpc", "answer", await lookup(event.payload.q));
|
|
423
|
+
},
|
|
424
|
+
});
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
### A call resolves to the event, not the payload
|
|
428
|
+
|
|
429
|
+
Because a caller often cannot know *which* reply it will get. `reply` names the **terminal**
|
|
430
|
+
types, and the event carries the discriminant:
|
|
431
|
+
|
|
432
|
+
```typescript
|
|
433
|
+
const res = await store.call("rpc", "ask", { q }, { reply: ["rpc", ["answer", "error"]] });
|
|
434
|
+
|
|
435
|
+
switch (res.type) {
|
|
436
|
+
case "answer": return res.payload.text;
|
|
437
|
+
case "error": throw new Error(res.payload.reason);
|
|
438
|
+
}
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
### Progress streams, and the producer waits
|
|
442
|
+
|
|
443
|
+
Any correlated event that is **not** terminal is progress. Iterate the call to consume it:
|
|
444
|
+
|
|
445
|
+
```typescript
|
|
446
|
+
const call = store.call("job", "start", { id }, {
|
|
447
|
+
reply: ["job", "done"],
|
|
448
|
+
highWaterMark: 4,
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
for await (const step of call) await render(step.payload);
|
|
452
|
+
const { payload } = await call;
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
The backpressure is real, not a buffer with a limit. `emit` resolves only once its effects have
|
|
456
|
+
run, and the collector is an effect that does not return until the consumer has taken the item —
|
|
457
|
+
so a responder writing `await emit("job", "tick", chunk)` is **paced by the reader**:
|
|
458
|
+
|
|
459
|
+
```typescript
|
|
460
|
+
effect: async (_event, _get, emit) => {
|
|
461
|
+
for (const chunk of chunks) {
|
|
462
|
+
await emit("job", "tick", chunk); // waits here while the consumer is behind
|
|
463
|
+
}
|
|
464
|
+
await emit("job", "done", { ok: true });
|
|
465
|
+
}
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
Backpressure engages **once you begin iterating**. A call that is only awaited never pulls, so
|
|
469
|
+
blocking its producer would deadlock the call itself — progress nobody reads would stop the
|
|
470
|
+
terminal event from ever being sent. Un-iterated progress therefore buffers to `highWaterMark`
|
|
471
|
+
and is then counted on `call.dropped` rather than blocking.
|
|
472
|
+
|
|
473
|
+
### Giving up
|
|
474
|
+
|
|
475
|
+
| | |
|
|
476
|
+
|---|---|
|
|
477
|
+
| `timeoutMs` | **Idle**, not total — every correlated event resets it, progress included. A job that streams for two minutes will not fail a thirty-second call. Default 30s. |
|
|
478
|
+
| `signal` | An `AbortSignal`, for a real deadline or a cancelled action. |
|
|
479
|
+
| `call.cancel(reason)` | Stops listening and settles. Safe to call twice. |
|
|
480
|
+
|
|
481
|
+
However a call ends — resolved, timed out, aborted — the subscription is removed and any producer
|
|
482
|
+
parked on backpressure is released. A wedged responder is worse than the unbounded buffer this
|
|
483
|
+
replaced.
|
|
484
|
+
|
|
485
|
+
## Reading a value as you subscribe
|
|
486
|
+
|
|
487
|
+
`connect` starts at "from now on", so a subscriber's first read had to repeat the path elsewhere —
|
|
488
|
+
the same path in two places, free to drift:
|
|
489
|
+
|
|
490
|
+
```typescript
|
|
491
|
+
store.connect({ reducer: "todos", property: "items.0.title" }, render, { immediate: true });
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
The synthetic first change has `oldValue: undefined` and **no provenance**, because no event
|
|
495
|
+
caused it. For a wildcard pattern, which has no single current value, the slice root is delivered
|
|
496
|
+
with `path: ""`.
|
|
497
|
+
|
|
498
|
+
React does not need this: `useSyncExternalStore` already reads a snapshot on mount.
|
|
499
|
+
|
|
500
|
+
---
|
|
501
|
+
|
|
502
|
+
## Where a change came from
|
|
503
|
+
|
|
504
|
+
A `Change` names the event that caused it, so a subscriber no longer has to mirror the cause into
|
|
505
|
+
state and keep it in two places:
|
|
506
|
+
|
|
507
|
+
```typescript
|
|
508
|
+
store.connect({ reducer: "orders", property: "status" }, (change) => {
|
|
509
|
+
audit.record(change.path, change.newValue, {
|
|
510
|
+
causedBy: change.eventId,
|
|
511
|
+
via: `${change.channel}/${change.type}`,
|
|
512
|
+
});
|
|
513
|
+
});
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
Provenance is **absent** when no event caused the change — a DevTools time-travel jump, or the
|
|
517
|
+
`immediate` delivery above. Absence is the signal, rather than a fabricated id.
|
|
518
|
+
|
|
330
519
|
---
|
|
331
520
|
|
|
332
521
|
## Event Deduplication (opt-in)
|
|
@@ -350,6 +539,57 @@ await store.emit("analytics", "pageView", { page }, { dedupKey: `pageView:${page
|
|
|
350
539
|
|
|
351
540
|
---
|
|
352
541
|
|
|
542
|
+
## Cascade protection (on by default)
|
|
543
|
+
|
|
544
|
+
Two consumers wired into each other — a subscriber that emits what its own reducer answers, or
|
|
545
|
+
two slices that answer each other's events — produce an event chain with no end. The reduce queue
|
|
546
|
+
drains **synchronously**, so that is not a slow program: it is a frozen tab, or a pinned core,
|
|
547
|
+
with no error and no stack to point at.
|
|
548
|
+
|
|
549
|
+
Every event therefore carries its causal position, and the store refuses to extend a chain past a
|
|
550
|
+
ceiling:
|
|
551
|
+
|
|
552
|
+
```typescript
|
|
553
|
+
const store = createStore({
|
|
554
|
+
name: "app",
|
|
555
|
+
reducer: { ... },
|
|
556
|
+
|
|
557
|
+
// Defaults to 64. Bounded whether or not you configure it — a failure mode this bad
|
|
558
|
+
// should not require configuration to avoid. Set Infinity to opt out and own it.
|
|
559
|
+
maxReduceDepth: 64,
|
|
560
|
+
|
|
561
|
+
onCascade: ({ event, depth, chain }) => {
|
|
562
|
+
report(`cascade at ${event.channel}/${event.type}, depth ${depth}`, chain);
|
|
563
|
+
},
|
|
564
|
+
});
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
An event emitted while another is being handled is one deeper than its cause, and carries
|
|
568
|
+
`parentId` and `depth` so the cycle is legible after the fact:
|
|
569
|
+
|
|
570
|
+
```typescript
|
|
571
|
+
store.onEvent("plan", "patch", (event) => {
|
|
572
|
+
event.depth; // 0 for an event emitted by application code
|
|
573
|
+
event.parentId; // undefined at depth 0; the causing event's id below it
|
|
574
|
+
});
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
Both fields are **absent** on a root event rather than present as `0`/`undefined`, so events your
|
|
578
|
+
application emits stay byte-identical to before this existed.
|
|
579
|
+
|
|
580
|
+
Breaching does not throw. The offending emit is refused, everything already committed stands, and
|
|
581
|
+
`onCascade` (plus a console error) names it — a throw would surface in whichever subscriber or
|
|
582
|
+
effect happened to be emitting, which is the same unattributable failure the ceiling exists to
|
|
583
|
+
prevent.
|
|
584
|
+
|
|
585
|
+
**A wide burst is not a cascade.** One event whose subscriber fans out to five hundred siblings
|
|
586
|
+
is a legitimate shape; depth is what separates it from a cycle, and a plain loop of `store.emit`
|
|
587
|
+
never accumulates depth at all — each call drains to completion before the next, so every one is
|
|
588
|
+
a root. `maxTransitionsPerDrain` bounds burst *width* and is off by default for that reason; the
|
|
589
|
+
event that starts a drain is never refused by it.
|
|
590
|
+
|
|
591
|
+
---
|
|
592
|
+
|
|
353
593
|
## Dynamic Reducers
|
|
354
594
|
|
|
355
595
|
Add or remove reducer slices at runtime:
|
|
@@ -575,7 +815,7 @@ individual fields edited is better off as an array today.
|
|
|
575
815
|
|
|
576
816
|
| Metric | Value |
|
|
577
817
|
| ------------------ | ----------------------------------------- |
|
|
578
|
-
| **Bundle size** |
|
|
818
|
+
| **Bundle size** | 9.2 KB for the store (minified + gzipped) |
|
|
579
819
|
| **Tree-shakeable** | Yes (ES modules) |
|
|
580
820
|
| **Dependencies** | Zero |
|
|
581
821
|
| **TypeScript** | Full type definitions included |
|
|
@@ -586,16 +826,21 @@ would — tree-shaken, minified, gzipped — and fails when it exceeds the budge
|
|
|
586
826
|
|
|
587
827
|
The number that matters is what you import, not what the package exports:
|
|
588
828
|
|
|
589
|
-
| Import | Size
|
|
590
|
-
| ----------------------------------- | ------ |
|
|
591
|
-
| `{ createStore }` |
|
|
592
|
-
| `{ createStore, hydrate, persist }` |
|
|
593
|
-
| everything |
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
829
|
+
| Import | Size | Budget |
|
|
830
|
+
| ----------------------------------- | ------- | ------ |
|
|
831
|
+
| `{ createStore }` | 9.2 KB | 14 KB |
|
|
832
|
+
| `{ createStore, hydrate, persist }` | 10.7 KB | 16 KB |
|
|
833
|
+
| everything | 12.1 KB | 18 KB |
|
|
834
|
+
|
|
835
|
+
The **gap between rows** is the tree-shaking claim, and it is what to watch: persistence adds
|
|
836
|
+
1.5 KB to the people who import it and nothing to anyone else, and the whole barrel is 2.9 KB
|
|
837
|
+
past the store. The last row is a growth tripwire; `import * as all` is not something anybody
|
|
838
|
+
writes.
|
|
839
|
+
|
|
840
|
+
The first row moves only when the store itself grows, and it has: bounding cascades, staging
|
|
841
|
+
commits so they apply atomically, and `store.call()` are all store machinery rather than
|
|
842
|
+
opt-in modules, so they are paid by everyone. That is the honest trade for a default that
|
|
843
|
+
stops a runaway from hanging the tab.
|
|
599
844
|
|
|
600
845
|
---
|
|
601
846
|
|
package/dist/types/index.d.ts
CHANGED
|
@@ -9,10 +9,15 @@ export { EventBus } from './eventBus/EventBus.js';
|
|
|
9
9
|
export { LooseEventBus } from './eventBus/LooseEventBus.js';
|
|
10
10
|
export { Reducer } from './reducer/Reducer.js';
|
|
11
11
|
export { Store, createStore, typedEvents } from './store/Store.js';
|
|
12
|
+
export { Rejected, isRejected } from './store/rejection.js';
|
|
13
|
+
export { CallAbortedError, CallTimeoutError } from './store/call.js';
|
|
14
|
+
export type { CallHandle, CallOptions, ReplySpec } from './store/call.js';
|
|
15
|
+
export type { Rejection } from './store/rejection.js';
|
|
12
16
|
export { detectChangedProps } from './utils/detectChangedProps.js';
|
|
13
17
|
export { freezeState } from './utils/immutability.js';
|
|
18
|
+
export type { AliasWatch } from './utils/immutability.js';
|
|
14
19
|
export { eventKeys } from './types.js';
|
|
15
|
-
export type { EventMapBase, EventKey, Event, EventUnion, Change, Emit, EmitOptions, EventMeta, InstrumentedEvent, InstrumentationObserver, Unsubscribe, StoreSpec, StoreInstance, ReducerSpec, ReducerFunction, ReducersMapAny, StateFromReducers, EMFromReducersStrict, EffectSpec, EffectFunction, MiddlewareFunction, MiddlewareSpec, MiddlewareInput, DeepReadonly, DeepRO, Primitive, RootValue, Path, PathValue, WithGlob, Dotted, EventPhase, EventSubscriptionHandler, NarrowedEventHandler, When, EventFromWhen, EventConsumerType, EventConsumerMeta, } from './types.js';
|
|
20
|
+
export type { EventMapBase, EventKey, Event, EventUnion, Change, Emit, EmitOptions, EmitResult, ConnectOptions, EventMeta, InstrumentedEvent, CascadeInfo, InstrumentationObserver, Unsubscribe, StoreSpec, StoreInstance, ReducerSpec, ReducerFunction, ReducersMapAny, StateFromReducers, EMFromReducersStrict, EffectSpec, EffectFunction, MiddlewareFunction, MiddlewareSpec, MiddlewareInput, DeepReadonly, DeepRO, Primitive, RootValue, Path, PathValue, WithGlob, Dotted, EventPhase, NotifiedPhase, EventSubscriptionHandler, NarrowedEventHandler, When, EventFromWhen, EventConsumerType, EventConsumerMeta, } from './types.js';
|
|
16
21
|
export { createEntityAdapter } from './entity/entityAdapter.js';
|
|
17
22
|
export type { EntityAdapter, EntityAdapterOptions, EntityId, EntityState, EntityUpdate, } from './entity/entityAdapter.js';
|
|
18
23
|
export { decodeState, encodeState, encodeStateBounded } from './serialize/codec.js';
|
|
@@ -84,10 +84,6 @@ export interface Hydration {
|
|
|
84
84
|
export declare function hydrate(options: PersistOptions & {
|
|
85
85
|
readonly source?: string;
|
|
86
86
|
}): Promise<Hydration>;
|
|
87
|
-
/** A reducer spec, as far as hydration cares: something carrying an initial `state`. */
|
|
88
|
-
interface HasState {
|
|
89
|
-
state: unknown;
|
|
90
|
-
}
|
|
91
87
|
/**
|
|
92
88
|
* Replaces each reducer's initial state with what was restored for it.
|
|
93
89
|
*
|
|
@@ -97,7 +93,9 @@ interface HasState {
|
|
|
97
93
|
*
|
|
98
94
|
* @public
|
|
99
95
|
*/
|
|
100
|
-
export declare function withHydration<R extends Record<string,
|
|
96
|
+
export declare function withHydration<R extends Record<string, {
|
|
97
|
+
state: unknown;
|
|
98
|
+
}>>(reducers: R, hydration: Hydration): R;
|
|
101
99
|
/** The store surface persistence needs, which is two methods wide. */
|
|
102
100
|
export interface PersistableStore {
|
|
103
101
|
getState(): unknown;
|
|
@@ -123,4 +121,3 @@ export declare function persist(store: PersistableStore, options: PersistOptions
|
|
|
123
121
|
* @public
|
|
124
122
|
*/
|
|
125
123
|
export declare function dehydrate(store: Pick<PersistableStore, "getState">, options: Pick<PersistOptions, "version" | "slices">): string;
|
|
126
|
-
export {};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { EventMapBase, EventUnion, ReducerFunction } from '../types.js';
|
|
2
|
+
import { Rejection } from '../store/rejection.js';
|
|
2
3
|
/**
|
|
3
4
|
* Thin wrapper around a pure reducer function (stateful event consumer):
|
|
4
5
|
* given a state `S` and an event (from {@link EventUnion | `EventUnion<EM>`}),
|
|
@@ -68,7 +69,7 @@ export declare class Reducer<S, EM extends EventMapBase = EventMapBase> {
|
|
|
68
69
|
*
|
|
69
70
|
* @param state - Current state.
|
|
70
71
|
* @param event - An event drawn from {@link EventUnion | `EventUnion<EM>`}.
|
|
71
|
-
* @returns The next state
|
|
72
|
+
* @returns The next state, or a {@link Rejection} if the reducer refused the write.
|
|
72
73
|
*
|
|
73
74
|
* @example
|
|
74
75
|
* ```ts
|
|
@@ -77,5 +78,5 @@ export declare class Reducer<S, EM extends EventMapBase = EventMapBase> {
|
|
|
77
78
|
*
|
|
78
79
|
* @public
|
|
79
80
|
*/
|
|
80
|
-
reduce(state: S, event: EventUnion<EM>): S;
|
|
81
|
+
reduce(state: S, event: EventUnion<EM>): S | Rejection;
|
|
81
82
|
}
|