@playfast/reform 0.1.0 → 1.1.1

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.
Files changed (97) hide show
  1. package/README.md +18 -1
  2. package/package.json +1 -1
  3. package/src/boundary/boundary.test.ts +1 -33
  4. package/src/boundary/boundary.ts +4 -82
  5. package/src/calc/asyncCalc.invalidate.test.ts +2 -14
  6. package/src/calc/asyncCalc.test.ts +0 -38
  7. package/src/calc/asyncCalc.ts +11 -163
  8. package/src/calc/asyncCalcDefinitions.ts +48 -0
  9. package/src/calc/asyncData.ts +0 -37
  10. package/src/calc/calc.test.ts +4 -28
  11. package/src/calc/calc.ts +16 -44
  12. package/src/calc/calcFamily.test.ts +0 -12
  13. package/src/calc/calcFamily.ts +12 -49
  14. package/src/calc/compose.test.ts +1 -12
  15. package/src/calc/compose.ts +0 -22
  16. package/src/calc/queryState.ts +0 -23
  17. package/src/channel/channel.ts +78 -173
  18. package/src/channel/procedureRegistry.ts +90 -0
  19. package/src/compose/composition.ts +7 -67
  20. package/src/compose/host.ts +0 -6
  21. package/src/compose/props.ts +3 -9
  22. package/src/compose/provide.ts +19 -33
  23. package/src/compose/slot.ts +2 -30
  24. package/src/compose/structure.test.ts +0 -6
  25. package/src/compose/structure.ts +3 -67
  26. package/src/compose/ui.test.ts +0 -4
  27. package/src/compose/ui.ts +17 -111
  28. package/src/compose/ui.typecheck.ts +0 -14
  29. package/src/definition/definition.ts +0 -32
  30. package/src/event/event.test.ts +0 -3
  31. package/src/event/event.ts +5 -14
  32. package/src/event/eventGroup.ts +0 -1
  33. package/src/feature/feature.mount.test.ts +66 -54
  34. package/src/feature/feature.test.ts +67 -46
  35. package/src/feature/feature.ts +185 -214
  36. package/src/feature/feature.typecheck.ts +29 -16
  37. package/src/feature/featureBinding.ts +48 -0
  38. package/src/index.ts +245 -202
  39. package/src/internal/capture.ts +0 -20
  40. package/src/internal/ctx.ts +2 -9
  41. package/src/internal/env.ts +9 -0
  42. package/src/internal/errors.test.ts +0 -6
  43. package/src/internal/errors.ts +14 -48
  44. package/src/internal/inspect.test.ts +0 -6
  45. package/src/internal/inspect.ts +0 -12
  46. package/src/internal/queryDriver.ts +48 -243
  47. package/src/internal/queryDriverStore.ts +153 -0
  48. package/src/internal/queryDriverTypes.ts +57 -0
  49. package/src/internal/queryEvents.ts +0 -12
  50. package/src/internal/queryStore.ts +0 -14
  51. package/src/internal/reuse.test.ts +0 -11
  52. package/src/internal/reuse.ts +0 -28
  53. package/src/internal/scheduler.ts +0 -43
  54. package/src/internal/seeds.ts +0 -14
  55. package/src/internal/sources.ts +4 -41
  56. package/src/internal/stateRegistry.ts +0 -14
  57. package/src/internal/store.test.ts +1 -3
  58. package/src/internal/store.ts +10 -37
  59. package/src/internal/track.ts +0 -18
  60. package/src/internal/variance.ts +5 -0
  61. package/src/namespace/namespace.test.ts +46 -0
  62. package/src/namespace/namespace.ts +85 -0
  63. package/src/procedure/procedure.ts +2 -24
  64. package/src/reducer/reducer.ts +3 -21
  65. package/src/remote/remoteState.test.ts +92 -75
  66. package/src/remote/remoteState.ts +99 -486
  67. package/src/remote/remoteState.typecheck.ts +0 -33
  68. package/src/remote/remoteStateDefinition.ts +135 -0
  69. package/src/remote/remoteStateLayers.ts +118 -0
  70. package/src/remote/remoteStateLiveTypes.ts +59 -0
  71. package/src/remote/remoteStateSend.ts +64 -0
  72. package/src/runtime/appRuntime.test.ts +197 -0
  73. package/src/runtime/appRuntime.ts +131 -0
  74. package/src/runtime/bus.ts +2 -15
  75. package/src/runtime/eventBudget.test.ts +155 -0
  76. package/src/runtime/eventBudget.ts +119 -0
  77. package/src/runtime/hardening.test.ts +74 -9
  78. package/src/runtime/instrumentation.test.ts +324 -0
  79. package/src/runtime/instrumentation.ts +44 -0
  80. package/src/runtime/loop.test.ts +0 -17
  81. package/src/runtime/loop.ts +41 -58
  82. package/src/runtime/queries.ts +0 -17
  83. package/src/scene/featureScene.test.ts +59 -0
  84. package/src/scene/scene.ts +51 -44
  85. package/src/scene/seedScene.test.ts +96 -116
  86. package/src/state/state.ts +12 -28
  87. package/src/state/stateFamily.test.ts +2 -11
  88. package/src/state/stateFamily.ts +20 -54
  89. package/src/state/stateGroup.ts +2 -49
  90. package/src/state/token.ts +4 -17
  91. package/src/synced/syncedStore.ts +7 -42
  92. package/src/ui/node.ts +0 -6
  93. package/src/ui/trigger.ts +0 -5
  94. package/src/wire/tree.test.ts +0 -3
  95. package/src/wire/tree.ts +0 -39
  96. package/src/wire/triggers.test.ts +0 -4
  97. package/src/wire/triggers.ts +5 -27
@@ -2,26 +2,6 @@ import { Equal, MutableRef } from 'effect'
2
2
  import { type Inspectable, inspectable } from './inspect'
3
3
  import { defaultScheduler, type Scheduler } from './scheduler'
4
4
 
5
- /**
6
- * A reactive cell living outside Effect. The single source of truth for one
7
- * slice of state. `set` is internal: only the reduce loop ever holds a Store
8
- * (reducers return values, the loop writes them), which is how "state changes
9
- * only in the reduce loop" is enforced structurally — there is no public writer.
10
- *
11
- * This is the synchronous, React-facing analog of Effect's `SubscriptionRef`
12
- * (a `Ref` + `PubSub` + a `changes` `Stream`). We don't use `SubscriptionRef`
13
- * directly because `useSyncExternalStore` requires a *synchronous* `getSnapshot`
14
- * and a *synchronous* `subscribe` returning an unsubscribe thunk, whereas
15
- * `SubscriptionRef.get` is an `Effect` and its `changes` is a `Stream` (a fiber
16
- * per subscription). Mutable state is held in a `MutableRef` (Effect's mutable
17
- * cell), so there is no reassigned binding.
18
- *
19
- * `getSnapshot` + `subscribe` are the stable pair the React bridge binds to;
20
- * `getSnapshot` returns the same reference until the value actually changes.
21
- * `getVersion` returns a monotonic counter bumped on every real change — the
22
- * host sums it across a render's tracked stores for a `useSyncExternalStore`
23
- * snapshot, which closes the tear/dropped-update window a value snapshot can't.
24
- */
25
5
  export interface Store<in out A> extends Inspectable {
26
6
  get(): A
27
7
  set(value: A): void
@@ -30,9 +10,13 @@ export interface Store<in out A> extends Inspectable {
30
10
  subscribe(listener: () => void): () => void
31
11
  }
32
12
 
33
- export const makeStore = <A>(initial: A, scheduler: Scheduler = defaultScheduler): Store<A> => {
34
- // The store is a mutable reactive slot by design, so its value lives in a
35
- // `MutableRef` we update in place (no reassigned binding).
13
+ const noChange = (): void => {}
14
+
15
+ export const makeStore = <A>(
16
+ initial: A,
17
+ scheduler: Scheduler = defaultScheduler,
18
+ onChange: () => void = noChange,
19
+ ): Store<A> => {
36
20
  const current = MutableRef.make(initial)
37
21
  const version = MutableRef.make(0)
38
22
  const listeners = new Set<() => void>()
@@ -46,6 +30,7 @@ export const makeStore = <A>(initial: A, scheduler: Scheduler = defaultScheduler
46
30
  }
47
31
  MutableRef.set(current, next)
48
32
  MutableRef.update(version, (count) => count + 1)
33
+ onChange()
49
34
  scheduler.schedule(listeners)
50
35
  },
51
36
  subscribe: (listener) => {
@@ -58,27 +43,14 @@ export const makeStore = <A>(initial: A, scheduler: Scheduler = defaultScheduler
58
43
  }
59
44
  }
60
45
 
61
- /**
62
- * A read-only `Store` whose value is computed from upstream sources.
63
- *
64
- * Reads are pull-fresh: `get`/`getSnapshot` call `compute` directly, so a
65
- * headless snapshot always reflects the current source state without waiting for
66
- * a notification (`compute` must memoize so an unchanged read returns a stable
67
- * reference — `useSyncExternalStore` requires it). *Notifications*, by contrast,
68
- * run through the same coalescing scheduler as `makeStore`: an upstream change
69
- * recomputes once and wakes subscribers only when the output actually moves (by
70
- * `Equal.equals`), so every store kind shares one notification timing. `set` is
71
- * a no-op — the only writer is the upstream subscription.
72
- */
73
46
  export const makeDerivedStore = <A>(
74
47
  compute: () => A,
75
48
  subscribeUpstream: (onChange: () => void) => () => void,
76
49
  scheduler: Scheduler = defaultScheduler,
50
+ onOutputChange: () => void = noChange,
77
51
  ): { readonly store: Store<A>; readonly unsubscribe: () => void } => {
78
52
  const listeners = new Set<() => void>()
79
53
  const version = MutableRef.make(0)
80
- // The last output we notified against — tracked only here, never touched by
81
- // reads, so a `get` racing ahead of `onChange` can't suppress a notification.
82
54
  const seen = MutableRef.make<{ readonly value: A } | undefined>({ value: compute() })
83
55
  const onChange = () => {
84
56
  const previous = MutableRef.get(seen)
@@ -86,6 +58,7 @@ export const makeDerivedStore = <A>(
86
58
  MutableRef.set(seen, { value: next })
87
59
  if (previous === undefined || !Equal.equals(previous.value, next)) {
88
60
  MutableRef.update(version, (count) => count + 1)
61
+ onOutputChange()
89
62
  scheduler.schedule(listeners)
90
63
  }
91
64
  }
@@ -1,37 +1,19 @@
1
1
  import { Context, Effect, Option } from 'effect'
2
2
  import type { Store } from './store'
3
3
 
4
- /**
5
- * Anything a render can depend on: a source it can subscribe to for changes and
6
- * snapshot a monotonic version from. `Store` (and the calc/family stores)
7
- * structurally satisfy this. The host sums `getVersion()` across a render's
8
- * tracked sources for its `useSyncExternalStore` snapshot — a value that moves
9
- * whenever any dependency changes, which is what closes the tear/dropped-update
10
- * window a per-store value snapshot cannot.
11
- */
12
4
  export interface Subscribable {
13
5
  subscribe(listener: () => void): () => void
14
6
  getVersion(): number
15
7
  }
16
8
 
17
- /** A per-render dependency sink. The renderer provides one; reads record into it. */
18
9
  export interface Tracker {
19
10
  add(source: Subscribable): void
20
11
  }
21
12
 
22
- /**
23
- * The active render's dependency tracker (D5). Present only while a composition
24
- * is rendering under `@reform/react`; absent in procedures and headless reads.
25
- */
26
13
  const CurrentTrackerBase: Context.TagClass<CurrentTracker, 'reform/Tracker', Tracker> =
27
14
  Context.Tag('reform/Tracker')<CurrentTracker, Tracker>()
28
15
  export class CurrentTracker extends CurrentTrackerBase {}
29
16
 
30
- /**
31
- * Read a store's current snapshot and, if a tracker is active, record the store
32
- * as a dependency of the current render. One read API, two behaviors by context
33
- * (D5): inside a render it subscribes the slice; elsewhere it just snapshots.
34
- */
35
17
  export const readTracked = <A>(store: Store<A>): Effect.Effect<A> =>
36
18
  Effect.map(Effect.serviceOption(CurrentTracker), (tracker) => {
37
19
  if (Option.isSome(tracker)) {
@@ -0,0 +1,5 @@
1
+ import type { Schema } from 'effect'
2
+
3
+ export type AnyValue = Schema.Schema.Type<Schema.Schema.AnyNoContext>
4
+
5
+ export type AnySchema<A> = Schema.Schema<A, Schema.Schema.Encoded<Schema.Schema.AnyNoContext>>
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { Match, Schema as S } from 'effect'
3
+ import * as Event from '../event/event'
4
+ import * as Namespace from './namespace'
5
+
6
+ describe('Namespace.make', () => {
7
+ const ns = Namespace.make('demoFeature')
8
+
9
+ it('prefixes every definition kind', () => {
10
+ class Saved extends ns.Event('Saved', S.Struct({})) {}
11
+ class Lane extends ns.Channel('Lane', { policy: { _tag: 'latest' } }) {}
12
+ class Count extends ns.State('count', S.Number) {}
13
+ class Doubled extends ns.Calc('Doubled', { inputs: [Count], output: S.Number }) {}
14
+ class Feed extends ns.AsyncCalc('Feed', { inputs: [Count], output: S.Number }) {}
15
+ class Rows extends ns.RemoteState('Rows', {
16
+ inputs: [Count],
17
+ output: S.Number,
18
+ intents: [],
19
+ }) {}
20
+
21
+ expect(Saved.tag).toBe('demoFeature.Saved')
22
+ expect(Lane.manifest.name).toBe('demoFeature.Lane')
23
+ expect(Count.manifest.name).toBe('demoFeature.count')
24
+ expect(Doubled.manifest.name).toBe('demoFeature.Doubled')
25
+ expect(Feed.manifest.name).toBe('demoFeature.Feed')
26
+ expect(Rows.manifest.name).toBe('demoFeature.Rows')
27
+ })
28
+
29
+ it('keeps the tag literal type for Match.tags folds', () => {
30
+ class Opened extends ns.Event('Opened', S.Struct({})) {}
31
+ class Closed extends ns.Event('Closed', S.Struct({ reason: S.String })) {}
32
+ type Folded = Event.EventType<typeof Opened> | Event.EventType<typeof Closed>
33
+
34
+ const fold = (event: Folded): string =>
35
+ Match.value(event).pipe(
36
+ Match.tags({
37
+ 'demoFeature.Opened': () => 'open',
38
+ 'demoFeature.Closed': ({ reason }) => reason,
39
+ }),
40
+ Match.exhaustive,
41
+ )
42
+
43
+ expect(fold(Event.construct(Opened, {}))).toBe('open')
44
+ expect(fold(Event.construct(Closed, { reason: 'done' }))).toBe('done')
45
+ })
46
+ })
@@ -0,0 +1,85 @@
1
+ import * as AsyncCalc from '../calc/asyncCalc'
2
+ import * as Calc from '../calc/calc'
3
+ import * as CalcFamily from '../calc/calcFamily'
4
+ import * as Channel from '../channel/channel'
5
+ import * as Event from '../event/event'
6
+ import type { GatedOf } from '../internal/queryDriver'
7
+ import * as RemoteState from '../remote/remoteState'
8
+ import * as State from '../state/state'
9
+ import * as StateFamily from '../state/stateFamily'
10
+ import type { AnySource } from '../state/token'
11
+ import type { AnySchema } from '../internal/variance'
12
+
13
+ export interface FeatureNamespace<Prefix extends string> {
14
+ readonly prefix: Prefix
15
+ readonly Event: <const N extends string, P>(
16
+ name: N,
17
+ schema: AnySchema<P>,
18
+ ) => Event.EventClass<`${Prefix}.${N}`, P>
19
+ readonly Channel: (name: string, config: Channel.ChannelConfig) => Channel.ChannelClass
20
+ readonly State: <const N extends string, A>(
21
+ name: N,
22
+ schema: AnySchema<A>,
23
+ options?: State.StateOptions,
24
+ ) => State.StateClass<`${Prefix}.${N}`, A>
25
+ readonly StateFamily: <const N extends string, K, V>(
26
+ name: N,
27
+ key: AnySchema<K>,
28
+ valueSchema: AnySchema<V>,
29
+ options?: StateFamily.StateFamilyMakeOptionsExternalApi,
30
+ ) => StateFamily.StateFamilyClass<`${Prefix}.${N}`, K, V>
31
+ readonly Calc: <const N extends string, const Inputs extends ReadonlyArray<AnySource>, Out>(
32
+ name: N,
33
+ config: Calc.CalcConfig<Inputs, Out>,
34
+ ) => Calc.CalcClass<`${Prefix}.${N}`, Inputs, Out>
35
+ readonly CalcFamily: <
36
+ const N extends string,
37
+ const Inputs extends ReadonlyArray<AnySource>,
38
+ K,
39
+ Out,
40
+ >(
41
+ name: N,
42
+ config: CalcFamily.CalcFamilyConfig<Inputs, K, Out>,
43
+ ) => CalcFamily.CalcFamilyClass<`${Prefix}.${N}`, Inputs, K, Out>
44
+ readonly AsyncCalc: <
45
+ const N extends string,
46
+ const Inputs extends ReadonlyArray<AnySource>,
47
+ A,
48
+ E = never,
49
+ const AlwaysOn extends boolean = false,
50
+ >(
51
+ name: N,
52
+ config: AsyncCalc.AsyncCalcConfig<Inputs, A, E, AlwaysOn>,
53
+ ) => AsyncCalc.AsyncCalcClass<`${Prefix}.${N}`, Inputs, A, E, GatedOf<AlwaysOn>>
54
+ readonly RemoteState: <
55
+ const N extends string,
56
+ const Inputs extends ReadonlyArray<AnySource>,
57
+ const Intents extends ReadonlyArray<Event.AnyEvent>,
58
+ A,
59
+ E = never,
60
+ const AlwaysOn extends boolean = false,
61
+ >(
62
+ name: N,
63
+ config: RemoteState.RemoteStateConfigExternalApi<Inputs, Intents, A, E, AlwaysOn>,
64
+ ) => RemoteState.RemoteStateClass<
65
+ `${Prefix}.${N}`,
66
+ Inputs,
67
+ Intents,
68
+ A,
69
+ E,
70
+ GatedOf<AlwaysOn>
71
+ >
72
+ }
73
+
74
+ export const make = <const Prefix extends string>(prefix: Prefix): FeatureNamespace<Prefix> => ({
75
+ prefix,
76
+ Event: (name, schema) => Event.make(`${prefix}.${name}`, schema),
77
+ Channel: (name, config) => Channel.make(`${prefix}.${name}`, config),
78
+ State: (name, schema, options) => State.make(`${prefix}.${name}`, schema, options),
79
+ StateFamily: (name, key, valueSchema, options) =>
80
+ StateFamily.make(`${prefix}.${name}`, key, valueSchema, options),
81
+ Calc: (name, config) => Calc.make(`${prefix}.${name}`, config),
82
+ CalcFamily: (name, config) => CalcFamily.make(`${prefix}.${name}`, config),
83
+ AsyncCalc: (name, config) => AsyncCalc.make(`${prefix}.${name}`, config),
84
+ RemoteState: (name, config) => RemoteState.make(`${prefix}.${name}`, config),
85
+ })
@@ -5,8 +5,8 @@ import { type Manifest, definitionClass } from '../definition/definition'
5
5
  import { type AnyEvent, type EventType } from '../event/event'
6
6
  import { type Ctx } from '../internal/ctx'
7
7
  import { Bus, narrowHandled } from '../runtime/bus'
8
+ import type { AnyValue } from '../internal/variance'
8
9
 
9
- /** The reflectable descriptor a procedure carries. */
10
10
  export interface ProcedureManifest extends Manifest {
11
11
  readonly kind: 'Procedure'
12
12
  }
@@ -14,26 +14,17 @@ export interface ProcedureManifest extends Manifest {
14
14
  export interface ProcedureClass<Events extends ReadonlyArray<AnyEvent>> {
15
15
  new (): {}
16
16
  readonly manifest: ProcedureManifest
17
- /** The trigger events (marker data; read by `Procedure.live`). */
18
17
  readonly events: Events
19
- /** The channel this procedure's runs are scheduled on (marker data). */
20
18
  readonly channel: ChannelClass
21
19
  }
22
20
 
23
21
  export interface ProcedureConfig<Events extends ReadonlyArray<AnyEvent>> {
24
22
  readonly events: Events
25
- /** The scheduling lane. Replaces the old `concurrency` option. */
26
23
  readonly channel: ChannelClass
27
24
  }
28
25
 
29
26
  export type AnyProcedure = ProcedureClass<ReadonlyArray<AnyEvent>>
30
27
 
31
- /**
32
- * A cross-event-loop flow. The *definition* declares its trigger events and the
33
- * channel it runs on; the *implementation* is the effectful body. Procedures no
34
- * longer subscribe to the bus themselves — the single drain loop routes events
35
- * to the channel, which runs the registered bodies under its policy.
36
- */
37
28
  export const make = <const Events extends ReadonlyArray<AnyEvent>>(
38
29
  name: string,
39
30
  config: ProcedureConfig<Events>,
@@ -44,15 +35,9 @@ export const make = <const Events extends ReadonlyArray<AnyEvent>>(
44
35
  channel: config.channel,
45
36
  })
46
37
 
47
- /**
48
- * Register the flow body into its channel. The body may read state and dispatch
49
- * events, but never writes state (the core invariant); its context requirements
50
- * (RPC clients, etc.) are captured here so the channel fiber can run it with no
51
- * outstanding R.
52
- */
53
38
  export const live = <
54
39
  Events extends ReadonlyArray<AnyEvent>,
55
- Eff extends YieldWrap<Effect.Effect<any, any, any>>,
40
+ Eff extends YieldWrap<Effect.Effect<AnyValue, AnyValue, AnyValue>>,
56
41
  Done,
57
42
  >(
58
43
  procedure: ProcedureClass<Events>,
@@ -61,23 +46,16 @@ export const live = <
61
46
  const name = procedure.manifest.name
62
47
  const channelName = procedure.channel.manifest.name
63
48
  const handles = new Set(procedure.events.map((event) => event.tag))
64
- // Scoped registration (see `Reducer.live`): an eager procedure's scope is the
65
- // root runtime's, so behavior is unchanged; a lazy feature's procedure registers
66
- // on mount and unregisters on unmount, so it stops running and is reclaimed.
67
49
  return Layer.scopedDiscard(
68
50
  Effect.gen(function* () {
69
51
  const procedures = yield* Procedures
70
52
  if (procedures.entries.some((entry) => entry.name === name)) {
71
53
  yield* Effect.logWarning(`reform: duplicate procedure name '${name}' registered`)
72
54
  }
73
- // Snapshot the body's full context (Bus + RPC clients) so `run` is total.
74
55
  const runtime = yield* Effect.runtime<Bus | Ctx<Eff>>()
75
56
  const entry: ProcedureEntry = {
76
57
  name,
77
58
  channelName,
78
- // The channel only routes events whose tag is in `handles`, so at runtime
79
- // `event` is always one of `Events`; `narrowHandled` restores the body's
80
- // declared event union from the erased `Tagged` envelope.
81
59
  handles,
82
60
  run: (event) => Effect.provide(Effect.gen(() => body(narrowHandled(event))), runtime),
83
61
  }
@@ -13,8 +13,8 @@ import {
13
13
  } from '../state/stateFamily'
14
14
  import { narrowHandled, type Tagged } from '../runtime/bus'
15
15
  import { type ReducerEntry, Reducers } from '../runtime/loop'
16
+ import type { AnyValue } from '../internal/variance'
16
17
 
17
- /** The reflectable descriptor a reducer carries, like every other primitive's `*Manifest`. */
18
18
  export interface ReducerManifest extends Manifest {
19
19
  readonly kind: 'Reducer'
20
20
  }
@@ -33,7 +33,6 @@ export interface FamilyReducerConfig<F extends AnyFamily, Events extends Readonl
33
33
  export interface StateReducerClass<S extends AnyState, Events extends ReadonlyArray<AnyEvent>> {
34
34
  new (): {}
35
35
  readonly manifest: ReducerManifest
36
- /** Marker data read by `Reducer.live`. */
37
36
  readonly config: StateReducerConfig<S, Events>
38
37
  readonly handles: ReadonlySet<string>
39
38
  }
@@ -45,12 +44,6 @@ export interface FamilyReducerClass<F extends AnyFamily, Events extends Readonly
45
44
  readonly handles: ReadonlySet<string>
46
45
  }
47
46
 
48
- /**
49
- * Reducers are the only writers of state. The *definition* (`Reducer.make`)
50
- * declares which state and events it touches; the *implementation*
51
- * (`Reducer.live`) is the pure fold. An unhandled event is a no-op because
52
- * it is never in `handles`.
53
- */
54
47
  export function make<S extends AnyState, const Events extends ReadonlyArray<AnyEvent>>(
55
48
  name: string,
56
49
  config: StateReducerConfig<S, Events>,
@@ -72,11 +65,9 @@ export function make(
72
65
  })
73
66
  }
74
67
 
75
- /** A thenable check that excludes null/undefined without an assertion. */
76
68
  const isThenable = (candidate: unknown): candidate is PromiseLike<unknown> =>
77
69
  Predicate.isRecord(candidate) && typeof candidate['then'] === 'function'
78
70
 
79
- /** Reject an accidentally-async fold up front: a reducer must be a pure sync write. */
80
71
  const sync = <A>(candidate: A): A => {
81
72
  if (isThenable(candidate)) {
82
73
  // oxlint-disable-next-line reform-rules/no-throw -- sync reducer-write boundary can't yield an Effect; an async fold is a programmer defect
@@ -98,14 +89,10 @@ export function live(
98
89
  | StateReducerClass<AnyState, ReadonlyArray<AnyEvent>>
99
90
  | FamilyReducerClass<AnyFamily, ReadonlyArray<AnyEvent>>,
100
91
  // oxlint-disable-next-line reform-rules/no-explicit-any-value -- overload-impl signature: `unknown` params fail contravariance against the State/Family folds (TS2394); only `any` subsumes both
101
- fold: (acc: any, event: any) => any,
102
- ): Layer.Layer<never, never, any> {
92
+ fold: (acc: AnyValue, event: AnyValue) => AnyValue,
93
+ ): Layer.Layer<never, never, AnyValue> {
103
94
  const { config, handles } = reducer
104
95
  const name = reducer.manifest.name
105
- // Scoped registration: register on build, unregister on scope close. For an eager
106
- // app the scope is the root runtime's (closes only at dispose), so behavior is
107
- // unchanged; for a lazy feature the scope is the feature's, so unmount removes the
108
- // entry — it stops folding and is reclaimed instead of leaking onto a dead store.
109
96
  return Layer.scopedDiscard(
110
97
  Effect.gen(function* () {
111
98
  const reducers = yield* Reducers
@@ -119,13 +106,8 @@ export function live(
119
106
  name,
120
107
  handles,
121
108
  apply: (event: Tagged) => {
122
- // The loop only invokes `apply` for events in `handles`, so `event`
123
- // is one of this reducer's declared events; `narrowHandled` restores
124
- // that typed view from the erased `Tagged` envelope `keyOf` is fed.
125
109
  const key = config.keyOf(narrowHandled(event))
126
110
  const next = fold(family.at(key).get(), event)
127
- // A fold may return the eviction sentinel to drop the key's store
128
- // (bounding an otherwise-unbounded family) instead of a new value.
129
111
  if (next === Tombstone) {
130
112
  family.forget(key)
131
113
  } else {