@yoltra/core 0.4.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 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 ─── Actualizaciones de estado sincronas, deteccion de cambios de grano fino por ruta
39
+ ├─ 2. Reducers ─── Cada slice que aplica se prepara, y todas se confirman bajo una sola raiz
40
40
  ├─ 3. Suscriptores de eventos ─── Notificaciones de eventos confirmados/no confirmados
41
41
  ├─ 4. Suscriptores gruesos ─── Listeners externos del store (useSyncExternalStore, etc.), si el estado cambio
42
42
  │
@@ -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** | ~8KB (minificado + gzipped) |
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 ─── Synchronous state updates, fine-grained path change detection
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
  │
@@ -87,6 +87,63 @@ store.connect({ reducer: "todos", property: "items.**" }, (change) =>
87
87
  );
88
88
  ```
89
89
 
90
+ ### Slices that hold a single value
91
+
92
+ A slice does not have to be an object. A primitive, a `Map`, a `Set` or a `Date` is a valid
93
+ slice state, and it commits like any other:
94
+
95
+ ```typescript
96
+ const store = createStore({
97
+ name: "session",
98
+ reducer: {
99
+ token: {
100
+ state: null as string | null,
101
+ when: { keys: [["auth", "login"]] },
102
+ reducer: (_state, event) => event.payload.token,
103
+ },
104
+ },
105
+ });
106
+
107
+ await store.emit("auth", "login", { token: "abc123" });
108
+ store.getState().token; // "abc123"
109
+ ```
110
+
111
+ Such a slice has no property beneath it, so its changes are reported at the **slice root** —
112
+ the empty path. Subscribe to it with `property: ""`:
113
+
114
+ ```typescript
115
+ store.connect({ reducer: "token", property: "" }, (change) =>
116
+ console.log("token:", change.oldValue, " --> ", change.newValue),
117
+ );
118
+ ```
119
+
120
+ The types know the difference. `property` on a root-value slice accepts `""` and nothing else —
121
+ there is no key to address — and the value comes back correctly typed:
122
+
123
+ ```typescript
124
+ const token = useAtomicProp({ reducer: "token", property: "" }); // string | null
125
+ ```
126
+
127
+ ### `""` versus `"**"` — watching a whole slice
128
+
129
+ Two subscriptions sound alike and are not:
130
+
131
+ | Pattern | Fires when |
132
+ |---|---|
133
+ | `""` | the slice's **whole value** is replaced — a primitive changes, a `Map` is rebuilt, an object slice becomes `null` |
134
+ | `"**"` | **anything** in the slice changes, at any depth. Matches the root too, since `**` matches zero segments |
135
+ | `"*"` | one level down, exactly. Never matches the root |
136
+
137
+ **`"**"` is the whole-slice subscription, and it works for every slice regardless of shape.**
138
+ Reach for `""` only when you mean the root value itself; on an object slice it stays quiet,
139
+ because such a slice reports its changes at their leaves.
140
+
141
+ `Map` and `Set` are compared by reference, not by entry: a reducer returning a new `Map` is a
142
+ change, mutating one in place is not. That follows from the immutability contract rather than
143
+ being a special case — build a new collection instead of mutating the stored one. It is also why
144
+ they have no paths beneath them: `"byId"` is subscribable, `"byId.get"` is not, and the types
145
+ say so.
146
+
90
147
  ### Immutability
91
148
 
92
149
  State is deep-frozen before committing. Mutations throw in strict mode:
@@ -259,7 +316,17 @@ store.onEvent(
259
316
  "uncommitted",
260
317
  );
261
318
 
262
- // All events — both committed and uncommitted
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)
263
330
  store.onEvent(
264
331
  "ui",
265
332
  "action",
@@ -270,6 +337,185 @@ store.onEvent(
270
337
  );
271
338
  ```
272
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
+
273
519
  ---
274
520
 
275
521
  ## Event Deduplication (opt-in)
@@ -293,6 +539,57 @@ await store.emit("analytics", "pageView", { page }, { dedupKey: `pageView:${page
293
539
 
294
540
  ---
295
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
+
296
593
  ## Dynamic Reducers
297
594
 
298
595
  Add or remove reducer slices at runtime:
@@ -518,7 +815,7 @@ individual fields edited is better off as an array today.
518
815
 
519
816
  | Metric | Value |
520
817
  | ------------------ | ----------------------------------------- |
521
- | **Bundle size** | 6.7 KB for the store (minified + gzipped) |
818
+ | **Bundle size** | 9.2 KB for the store (minified + gzipped) |
522
819
  | **Tree-shakeable** | Yes (ES modules) |
523
820
  | **Dependencies** | Zero |
524
821
  | **TypeScript** | Full type definitions included |
@@ -529,16 +826,21 @@ would — tree-shaken, minified, gzipped — and fails when it exceeds the budge
529
826
 
530
827
  The number that matters is what you import, not what the package exports:
531
828
 
532
- | Import | Size |
533
- | ----------------------------------- | ------ |
534
- | `{ createStore }` | 6.7 KB |
535
- | `{ createStore, hydrate, persist }` | 8.2 KB |
536
- | everything | 9.5 KB |
537
-
538
- Persistence and the entity adapter cost nothing to anyone who does not import them — the
539
- first row has not moved as either was added, which is the tree-shaking claim being checked
540
- rather than repeated. The last row is a growth tripwire; `import * as all` is not something
541
- anybody writes.
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.
542
844
 
543
845
  ---
544
846
 
@@ -1,4 +1,4 @@
1
- import { Event, EventMapBase } from '../types';
1
+ import { Event, EventMapBase } from '../types.js';
2
2
  /**
3
3
  * Minimal, synchronous pub/sub event bus keyed by **channel** and **type**.
4
4
  *
@@ -1,2 +1,2 @@
1
- export * from './EventBus';
2
- export * from './LooseEventBus';
1
+ export * from './EventBus.js';
2
+ export * from './LooseEventBus.js';
@@ -5,19 +5,24 @@
5
5
  *
6
6
  * @packageDocumentation
7
7
  */
8
- export { EventBus } from './eventBus/EventBus';
9
- export { LooseEventBus } from './eventBus/LooseEventBus';
10
- export { Reducer } from './reducer/Reducer';
11
- export { Store, createStore, typedEvents } from './store/Store';
12
- export { detectChangedProps } from './utils/detectChangedProps';
13
- export { freezeState } from './utils/immutability';
14
- export { eventKeys } from './types';
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, Path, PathValue, WithGlob, Dotted, EventPhase, EventSubscriptionHandler, NarrowedEventHandler, When, EventFromWhen, EventConsumerType, EventConsumerMeta, } from './types';
16
- export { createEntityAdapter } from './entity/entityAdapter';
17
- export type { EntityAdapter, EntityAdapterOptions, EntityId, EntityState, EntityUpdate, } from './entity/entityAdapter';
18
- export { decodeState, encodeState, encodeStateBounded } from './serialize/codec';
19
- export type { BoundedEncodeResult, EncodeOptions, EncodeReport, EncodeResult, } from './serialize/codec';
20
- export { dehydrate, hydrate, persist, withHydration } from './persistence/persist';
21
- export type { Hydration, PersistableStore, PersistenceAdapter, PersistencePhase, PersistOptions, } from './persistence/persist';
22
- export { createMemoryAdapter, createWebStorageAdapter } from './persistence/adapters';
23
- export type { WebStorageLike } from './persistence/adapters';
8
+ export { EventBus } from './eventBus/EventBus.js';
9
+ export { LooseEventBus } from './eventBus/LooseEventBus.js';
10
+ export { Reducer } from './reducer/Reducer.js';
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';
16
+ export { detectChangedProps } from './utils/detectChangedProps.js';
17
+ export { freezeState } from './utils/immutability.js';
18
+ export type { AliasWatch } from './utils/immutability.js';
19
+ export { eventKeys } 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';
21
+ export { createEntityAdapter } from './entity/entityAdapter.js';
22
+ export type { EntityAdapter, EntityAdapterOptions, EntityId, EntityState, EntityUpdate, } from './entity/entityAdapter.js';
23
+ export { decodeState, encodeState, encodeStateBounded } from './serialize/codec.js';
24
+ export type { BoundedEncodeResult, EncodeOptions, EncodeReport, EncodeResult, } from './serialize/codec.js';
25
+ export { dehydrate, hydrate, persist, withHydration } from './persistence/persist.js';
26
+ export type { Hydration, PersistableStore, PersistenceAdapter, PersistencePhase, PersistOptions, } from './persistence/persist.js';
27
+ export { createMemoryAdapter, createWebStorageAdapter } from './persistence/adapters.js';
28
+ export type { WebStorageLike } from './persistence/adapters.js';
@@ -1,4 +1,4 @@
1
- import { PersistenceAdapter } from './persist';
1
+ import { PersistenceAdapter } from './persist.js';
2
2
  /** The slice of the Web Storage API used here. */
3
3
  export interface WebStorageLike {
4
4
  getItem(key: string): string | null;