@playfast/reform 1.0.1 → 1.2.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.
Files changed (99) hide show
  1. package/README.md +103 -56
  2. package/package.json +17 -17
  3. package/src/boundary/boundary.test.ts +63 -49
  4. package/src/boundary/boundary.ts +144 -113
  5. package/src/calc/asyncCalc.invalidate.test.ts +518 -32
  6. package/src/calc/asyncCalc.test.ts +207 -213
  7. package/src/calc/asyncCalc.ts +131 -154
  8. package/src/calc/asyncData.ts +0 -37
  9. package/src/calc/calc.test.ts +7 -33
  10. package/src/calc/calc.ts +44 -58
  11. package/src/calc/calcFamily.test.ts +80 -34
  12. package/src/calc/calcFamily.ts +54 -65
  13. package/src/calc/compose.test.ts +1 -12
  14. package/src/calc/compose.ts +1 -36
  15. package/src/calc/queryState.ts +0 -23
  16. package/src/channel/channel.ts +126 -111
  17. package/src/compose/composition.test.ts +19 -0
  18. package/src/compose/composition.ts +351 -127
  19. package/src/compose/host.ts +0 -6
  20. package/src/compose/props.ts +13 -12
  21. package/src/compose/provide.ts +189 -62
  22. package/src/compose/slot.ts +158 -49
  23. package/src/compose/structure.test.ts +6 -9
  24. package/src/compose/structure.ts +108 -79
  25. package/src/compose/ui.test.ts +19 -7
  26. package/src/compose/ui.ts +155 -156
  27. package/src/compose/ui.typecheck.ts +40 -18
  28. package/src/definition/definition.ts +22 -41
  29. package/src/event/event.fromSource.test.ts +172 -0
  30. package/src/event/event.test.ts +10 -3
  31. package/src/event/event.ts +102 -27
  32. package/src/event/eventGroup.ts +0 -1
  33. package/src/feature/feature.mount.test.ts +122 -43
  34. package/src/feature/feature.test.ts +46 -21
  35. package/src/feature/feature.ts +1347 -256
  36. package/src/feature/feature.typecheck.ts +273 -68
  37. package/src/graph/closure.ts +403 -0
  38. package/src/index.ts +68 -126
  39. package/src/internal/bucketCache.ts +39 -0
  40. package/src/internal/capture.ts +9 -24
  41. package/src/internal/env.ts +7 -0
  42. package/src/internal/errors.test.ts +0 -6
  43. package/src/internal/errors.ts +37 -60
  44. package/src/internal/inspect.test.ts +0 -6
  45. package/src/internal/inspect.ts +0 -12
  46. package/src/internal/queryDriver.ts +105 -201
  47. package/src/internal/queryDriverStore.ts +156 -0
  48. package/src/internal/queryDriverTypes.ts +59 -0
  49. package/src/internal/queryEvents.ts +0 -12
  50. package/src/internal/queryStore.ts +0 -14
  51. package/src/internal/reuse.test.ts +10 -14
  52. package/src/internal/reuse.ts +5 -30
  53. package/src/internal/scheduler.ts +4 -44
  54. package/src/internal/seeds.ts +0 -14
  55. package/src/internal/sources.ts +26 -49
  56. package/src/internal/stateRegistry.ts +0 -14
  57. package/src/internal/store.test.ts +1 -3
  58. package/src/internal/store.ts +0 -37
  59. package/src/internal/track.ts +3 -20
  60. package/src/internal/variance.ts +5 -0
  61. package/src/internal.ts +222 -0
  62. package/src/namespace/namespace.test.ts +46 -0
  63. package/src/namespace/namespace.ts +106 -0
  64. package/src/procedure/procedure.ts +40 -35
  65. package/src/reducer/reducer.ts +78 -52
  66. package/src/remote/remoteState.test.ts +590 -397
  67. package/src/remote/remoteState.ts +425 -347
  68. package/src/remote/remoteState.typecheck.ts +47 -56
  69. package/src/runtime/appRuntime.activation.test.ts +100 -0
  70. package/src/runtime/appRuntime.test.ts +249 -0
  71. package/src/runtime/appRuntime.ts +415 -97
  72. package/src/runtime/bus.ts +2 -15
  73. package/src/runtime/eventBudget.test.ts +152 -0
  74. package/src/runtime/eventBudget.ts +120 -0
  75. package/src/runtime/hardening.test.ts +73 -13
  76. package/src/runtime/instrumentation.test.ts +55 -52
  77. package/src/runtime/instrumentation.ts +6 -60
  78. package/src/runtime/loop.test.ts +36 -17
  79. package/src/runtime/loop.ts +87 -88
  80. package/src/runtime/queries.ts +0 -17
  81. package/src/scene/featureScene.test.ts +61 -0
  82. package/src/scene/scene.ts +247 -104
  83. package/src/scene/seedScene.test.ts +42 -79
  84. package/src/state/state.nominal.typecheck.ts +68 -0
  85. package/src/state/state.test.ts +17 -0
  86. package/src/state/state.ts +80 -46
  87. package/src/state/stateFamily.test.ts +16 -11
  88. package/src/state/stateFamily.ts +55 -65
  89. package/src/state/stateGroup.ts +53 -64
  90. package/src/state/token.ts +40 -23
  91. package/src/synced/syncedStore.ts +46 -58
  92. package/src/testkit/flight.testkit.ts +75 -0
  93. package/src/ui/node.ts +0 -6
  94. package/src/ui/trigger.ts +0 -5
  95. package/src/wire/tree.test.ts +8 -7
  96. package/src/wire/tree.ts +0 -39
  97. package/src/wire/triggers.test.ts +6 -6
  98. package/src/wire/triggers.ts +14 -32
  99. package/src/internal/ctx.ts +0 -16
@@ -0,0 +1,172 @@
1
+ import { expect, it } from '@effect/vitest'
2
+ import { Duration, Effect, Exit, Layer, Option, Schema as S, Scope } from 'effect'
3
+ import { Engine, Event, Reducer, State } from '../index'
4
+
5
+ // `Event.liveFromSource` turns a Source *transition* into an ordinary event, so
6
+ // a rule can be triggered by a value moving rather than by a UI dispatch. Every
7
+ // case observes through a reducer — the framework's own writer — so what the
8
+ // log holds is exactly what the bus carried, with no spy in the middle.
9
+
10
+ const tick = Effect.sleep(Duration.millis(1))
11
+
12
+ // One string carrying both ends of a transition: `none->0` for the initial
13
+ // emission (nothing preceded it), `0->1` for a change.
14
+ const rendered = (change: Event.SourceChange<number>): string => {
15
+ const before = Option.match(change.previous, {
16
+ onNone: () => 'none',
17
+ onSome: (level) => `${level}`,
18
+ })
19
+ return `${before}->${change.current}`
20
+ }
21
+
22
+ const append = (entries: ReadonlyArray<string>, event: { readonly label: string }) => [
23
+ ...entries,
24
+ event.label,
25
+ ]
26
+
27
+ const Label = S.Struct({ label: S.String })
28
+
29
+ it.live('emits nothing for the value present at construction (changes only)', () => {
30
+ class Level extends State.make('changes-only/level', S.Number) {}
31
+ class Moved extends Event.make('ChangesOnlyMoved', Label) {}
32
+ class Log extends State.make('changes-only/log', S.Array(S.String)) {}
33
+ class LogReducer extends Reducer.make('ChangesOnlyLog', { states: [Log], events: [Moved] }) {}
34
+
35
+ // The adapter is wired DOWNSTREAM of the reducer that observes it: an
36
+ // emission is an ordinary dispatch, so — like a scene's boot events — it can
37
+ // only be folded by handlers already registered when it lands.
38
+ const TestLayer = Event.liveFromSource(Moved, Level, {
39
+ payload: (change) => Option.some({ label: rendered(change) }),
40
+ }).pipe(
41
+ Layer.provideMerge(Reducer.live(LogReducer, append)),
42
+ Layer.provideMerge(Layer.mergeAll(State.live(Level, 0), State.live(Log, []))),
43
+ Layer.provideMerge(Engine),
44
+ )
45
+
46
+ return Effect.gen(function* () {
47
+ const level = yield* Level.store
48
+ const log = yield* Log.store
49
+
50
+ yield* tick
51
+ expect(log.get()).toEqual([])
52
+
53
+ level.set(1)
54
+ yield* tick
55
+ expect(log.get()).toEqual(['0->1'])
56
+
57
+ // Store equality is the framework's, not this adapter's: re-setting the same
58
+ // value is not a change, so nothing notifies and nothing is emitted.
59
+ level.set(1)
60
+ yield* tick
61
+ expect(log.get()).toEqual(['0->1'])
62
+ }).pipe(Effect.provide(TestLayer))
63
+ })
64
+
65
+ it.live('emitInitial reports the construction value once, with no previous', () => {
66
+ class Level extends State.make('emit-initial/level', S.Number) {}
67
+ class Moved extends Event.make('EmitInitialMoved', Label) {}
68
+ class Log extends State.make('emit-initial/log', S.Array(S.String)) {}
69
+ class LogReducer extends Reducer.make('EmitInitialLog', { states: [Log], events: [Moved] }) {}
70
+
71
+ const TestLayer = Event.liveFromSource(Moved, Level, {
72
+ emitInitial: true,
73
+ payload: (change) => Option.some({ label: rendered(change) }),
74
+ }).pipe(
75
+ Layer.provideMerge(Reducer.live(LogReducer, append)),
76
+ Layer.provideMerge(Layer.mergeAll(State.live(Level, 2), State.live(Log, []))),
77
+ Layer.provideMerge(Engine),
78
+ )
79
+
80
+ return Effect.gen(function* () {
81
+ const level = yield* Level.store
82
+ const log = yield* Log.store
83
+
84
+ yield* tick
85
+ expect(log.get()).toEqual(['none->2'])
86
+
87
+ // The initial emission seeds `previous`: the next change reports the
88
+ // construction value as its left-hand side, not another `none`.
89
+ level.set(1)
90
+ yield* tick
91
+ expect(log.get()).toEqual(['none->2', '2->1'])
92
+ }).pipe(Effect.provide(TestLayer))
93
+ })
94
+
95
+ it.live('a payload of Option.none() suppresses a non-qualifying transition', () => {
96
+ class Phase extends State.make('suppressed/phase', S.Literal('idle', 'busy', 'ready')) {}
97
+ class BecameReady extends Event.make('SuppressedBecameReady', S.Struct({ from: S.String })) {}
98
+ class Log extends State.make('suppressed/log', S.Array(S.String)) {}
99
+ class LogReducer extends Reducer.make('SuppressedLog', {
100
+ states: [Log],
101
+ events: [BecameReady],
102
+ }) {}
103
+
104
+ // The exact transition predicate lives in the projection: only a move INTO
105
+ // 'ready' yields a payload — every other transition emits nothing at all.
106
+ const TestLayer = Event.liveFromSource(BecameReady, Phase, {
107
+ payload: (change) =>
108
+ change.current === 'ready'
109
+ ? Option.some({
110
+ from: Option.match(change.previous, { onNone: () => 'none', onSome: (phase) => phase }),
111
+ })
112
+ : Option.none(),
113
+ }).pipe(
114
+ Layer.provideMerge(Reducer.live(LogReducer, (entries, event) => [...entries, event.from])),
115
+ Layer.provideMerge(Layer.mergeAll(State.live(Phase, 'idle'), State.live(Log, []))),
116
+ Layer.provideMerge(Engine),
117
+ )
118
+
119
+ return Effect.gen(function* () {
120
+ const phase = yield* Phase.store
121
+ const log = yield* Log.store
122
+
123
+ phase.set('busy')
124
+ yield* tick
125
+ expect(log.get()).toEqual([])
126
+
127
+ phase.set('ready')
128
+ yield* tick
129
+ expect(log.get()).toEqual(['busy'])
130
+ }).pipe(Effect.provide(TestLayer))
131
+ })
132
+
133
+ it.live('closing the scope ends the emissions, including one already in flight', () => {
134
+ class Level extends State.make('scoped/level', S.Number) {}
135
+ class Moved extends Event.make('ScopedMoved', Label) {}
136
+ class Log extends State.make('scoped/log', S.Array(S.String)) {}
137
+ class LogReducer extends Reducer.make('ScopedLog', { states: [Log], events: [Moved] }) {}
138
+
139
+ const AdapterLive = Event.liveFromSource(Moved, Level, {
140
+ payload: (change) => Option.some({ label: rendered(change) }),
141
+ })
142
+ // Everything except the adapter outlives the closed scope, so a lost emission
143
+ // can only mean the adapter stopped — not that its observer went away.
144
+ const TestLayer = Reducer.live(LogReducer, append).pipe(
145
+ Layer.provideMerge(Layer.mergeAll(State.live(Level, 0), State.live(Log, []))),
146
+ Layer.provideMerge(Engine),
147
+ )
148
+
149
+ return Effect.gen(function* () {
150
+ const scope = yield* Scope.make()
151
+ yield* Layer.buildWithScope(AdapterLive, scope)
152
+ const level = yield* Level.store
153
+ const log = yield* Log.store
154
+
155
+ level.set(1)
156
+ yield* tick
157
+ expect(log.get()).toEqual(['0->1'])
158
+
159
+ // A write that already handed the listener to the notification scheduler,
160
+ // released before that flush runs: unsubscribing alone would not stop it.
161
+ yield* Effect.zipRight(
162
+ Effect.sync(() => level.set(2)),
163
+ Scope.close(scope, Exit.void),
164
+ )
165
+ yield* tick
166
+ expect(log.get()).toEqual(['0->1'])
167
+
168
+ level.set(0)
169
+ yield* tick
170
+ expect(log.get()).toEqual(['0->1'])
171
+ }).pipe(Effect.provide(TestLayer))
172
+ })
@@ -2,9 +2,6 @@ import { expect, test } from 'vitest'
2
2
  import { Schema as S } from 'effect'
3
3
  import { Event } from '../index'
4
4
 
5
- // An event is a pure data fact: `Event.construct` builds the tagged value the
6
- // bus carries, and the class carries the reflectable `manifest` + `tag`.
7
-
8
5
  test('Event.construct builds the tagged value `{ _tag, ...payload }`', () => {
9
6
  class Added extends Event.make('Added', S.Struct({ id: S.String })) {}
10
7
  expect(Event.construct(Added, { id: 'x' })).toEqual({ _tag: 'Added', id: 'x' })
@@ -21,3 +18,13 @@ test('empty-payload events construct to just their tag', () => {
21
18
  class Pinged extends Event.make('Pinged', S.Struct({})) {}
22
19
  expect(Event.construct(Pinged, {})).toEqual({ _tag: 'Pinged' })
23
20
  })
21
+
22
+ test('an existential event validates payloads before constructing', () => {
23
+ class Added extends Event.make('Added', S.Struct({ id: S.String })) {}
24
+ const reflected: Event.AnyEvent = Added
25
+ expect(reflected.buildUnknown({ id: 'safe' })).toEqual({
26
+ _tag: 'Added',
27
+ id: 'safe',
28
+ })
29
+ expect(() => reflected.buildUnknown({ id: 1 })).toThrow()
30
+ })
@@ -1,65 +1,140 @@
1
- import { Effect, PubSub, Runtime, type Schema } from 'effect'
1
+ import { Effect, Layer, MutableRef, Option, PubSub, Runtime, Schema } from 'effect'
2
2
  import { type Manifest, definitionClass } from '../definition/definition'
3
+ import { resolveScheduler } from '../internal/scheduler'
3
4
  import { Bus, publish, type Tagged } from '../runtime/bus'
5
+ import type { Source } from '../state/token'
4
6
  import type { Trigger } from '../ui/trigger'
5
7
 
6
- /** The dispatched value of an event: `{ _tag, ...payload }`, matched with `Match.tags`. */
7
8
  export type EventOf<N extends string, P> = Tagged & { readonly _tag: N } & P
8
9
 
9
- export interface EventManifest<N extends string, P> extends Manifest {
10
+ export interface EventSchemaReflection {
11
+ readonly ast: Schema.Schema<unknown, unknown, unknown>['ast']
12
+ }
13
+
14
+ export interface EventManifest<N extends string> extends Manifest {
10
15
  readonly kind: 'Event'
11
16
  readonly name: N
12
- readonly schema: Schema.Schema<P, any>
17
+ readonly schema: EventSchemaReflection
13
18
  }
14
19
 
15
- export interface EventClass<out N extends string, in out P> {
20
+ export interface EventReflection<out N extends string = string, out P = unknown> {
16
21
  new (): {}
17
- readonly manifest: EventManifest<N, P>
22
+ readonly manifest: EventManifest<N>
18
23
  readonly tag: N
19
- /** Carrier for `Event.construct` — builds the tagged value `{ _tag, ...payload }`. */
24
+ readonly Payload: P
25
+ }
26
+
27
+ export type EventCapture<Result> = <N extends string, P>(event: EventClass<N, P>) => Result
28
+
29
+ export interface AnyEvent extends EventReflection {
30
+ readonly buildUnknown: (payload: unknown) => Tagged
31
+ readonly capture: <Result>(visit: EventCapture<Result>) => Result
32
+ }
33
+
34
+ export interface EventClass<out N extends string, in out P> extends EventReflection<N, P> {
20
35
  readonly build: (payload: P) => EventOf<N, P>
21
- /** Carrier for `Event.trigger` — the UI callback effect (High priority). */
22
36
  readonly trigger: Effect.Effect<Trigger<P>, never, Bus>
37
+ readonly buildUnknown: (payload: unknown) => Tagged
38
+ readonly capture: <Result>(visit: EventCapture<Result>) => Result
23
39
  }
24
40
 
25
- export type AnyEvent = EventClass<string, any>
26
- export type EventType<E> = E extends EventClass<infer N, infer P> ? EventOf<N, P> : never
41
+ export type EventName<E> = E extends EventReflection<infer N, infer _P> ? N : never
42
+ export type EventPayload<E> = E extends EventReflection<string, infer P> ? P : never
43
+ export type EventType<E> = E extends EventReflection<infer N, infer P> ? EventOf<N, P> : never
27
44
 
28
- /** A pure data fact. Events depend on nothing — only reducers tie them to state. */
29
- export const make = <const N extends string, P>(
45
+ export const make = <const N extends string, P, Encoded>(
30
46
  name: N,
31
- schema: Schema.Schema<P, any>,
47
+ schema: Schema.Schema<P, Encoded, never>,
32
48
  ): EventClass<N, P> => {
33
- const build = (payload: P): EventOf<N, P> => ({ _tag: name, ...payload })
49
+ const build = (payload: P): EventOf<N, P> => ({ ...payload, _tag: name })
50
+ // Payloads at existential boundaries are already-decoded domain values, so
51
+ // check the Type side — decodeUnknownSync would reject transforming schemas
52
+ // (e.g. a real Option, whose _tag lives on the prototype).
53
+ const validatePayload = Schema.validateSync(schema)
54
+ const buildUnknown = (payload: unknown): Tagged => build(validatePayload(payload))
34
55
  const trigger = Effect.gen(function* () {
35
56
  const bus = yield* Bus
36
57
  const runtime = yield* Effect.runtime<never>()
37
- // Dispatch synchronously: on the unbounded bus `publish` only enqueues and
38
- // returns, so `runSync` never suspends and no fiber is forked per UI event.
39
- // The drain loop still folds reducers on its own fiber — publish is pure offer.
40
58
  return (payload: P) => {
41
59
  Runtime.runSync(runtime)(PubSub.publish(bus, { priority: 'High', event: build(payload) }))
42
60
  }
43
61
  })
44
- return definitionClass<EventClass<N, P>>({
62
+ const event: EventClass<N, P> = definitionClass<EventClass<N, P>>({
45
63
  manifest: { kind: 'Event' as const, name, schema },
46
64
  tag: name,
47
65
  build,
66
+ buildUnknown,
48
67
  trigger,
68
+ capture: <Result>(visit: EventCapture<Result>): Result => visit(event),
49
69
  })
70
+ return event
50
71
  }
51
72
 
52
- /** Construct the tagged value for an event (used for `boot` events). */
53
- export const construct = <N extends string, P>(event: EventClass<N, P>, payload: P): EventOf<N, P> =>
54
- event.build(payload)
73
+ export function construct<N extends string, P>(event: EventClass<N, P>, payload: P): EventOf<N, P>
74
+ export function construct<E extends AnyEvent>(event: E, payload: EventPayload<E>): EventType<E>
75
+ export function construct(event: AnyEvent, payload: unknown): Tagged {
76
+ return event.buildUnknown(payload)
77
+ }
55
78
 
56
- /** Dispatch as a follow-up (Normal priority) from a procedure/effect. */
57
- export const dispatch = <N extends string, P>(
58
- event: EventClass<N, P>,
59
- payload: P,
60
- ): Effect.Effect<void, never, Bus> => publish('Normal', event.build(payload))
79
+ export const dispatch = <E extends AnyEvent>(
80
+ event: E,
81
+ payload: EventPayload<E>,
82
+ ): Effect.Effect<void, never, Bus> => publish('Normal', event.buildUnknown(payload))
61
83
 
62
- /** A callback for the UI that dispatches at High priority via the runtime. */
63
84
  export const trigger = <N extends string, P>(
64
85
  event: EventClass<N, P>,
65
86
  ): Effect.Effect<Trigger<P>, never, Bus> => event.trigger
87
+
88
+ export interface SourceChange<A> {
89
+ readonly previous: Option.Option<A>
90
+ readonly current: A
91
+ }
92
+
93
+ export interface SourceEventConfigExternalApi<A, P> {
94
+ readonly emitInitial?: boolean
95
+ readonly payload: (change: SourceChange<A>) => Option.Option<P>
96
+ }
97
+ export type SourceEventConfig<A, P> = SourceEventConfigExternalApi<A, P>
98
+
99
+ export const liveFromSource = <N extends string, P, SourceName extends string, A, Identifier>(
100
+ event: EventClass<N, P>,
101
+ source: Source<SourceName, A, Identifier>,
102
+ config: SourceEventConfigExternalApi<A, P>,
103
+ ): Layer.Layer<never, never, Identifier | Bus> =>
104
+ Layer.scopedDiscard(
105
+ Effect.gen(function* () {
106
+ const store = yield* source.store
107
+ const scheduler = yield* resolveScheduler
108
+ const runtime = yield* Effect.runtime<Bus>()
109
+ const initial = store.getSnapshot()
110
+ // The scheduler coalesces bursts, so track the last value this adapter observed.
111
+ const seen = MutableRef.make(initial)
112
+ // A queued notification may outlive unsubscribe within the same tick.
113
+ const active = MutableRef.make(true)
114
+ const emit = (change: SourceChange<A>): void => {
115
+ if (!MutableRef.get(active)) {
116
+ return
117
+ }
118
+ const payload = config.payload(change)
119
+ if (Option.isSome(payload)) {
120
+ Runtime.runSync(runtime)(publish('Normal', event.build(payload.value)))
121
+ }
122
+ }
123
+ const unsubscribe = store.subscribe(() => {
124
+ const current = store.getSnapshot()
125
+ const previous = MutableRef.get(seen)
126
+ MutableRef.set(seen, current)
127
+ emit({ previous: Option.some(previous), current })
128
+ })
129
+ yield* Effect.addFinalizer(() =>
130
+ Effect.sync(() => {
131
+ MutableRef.set(active, false)
132
+ unsubscribe()
133
+ }),
134
+ )
135
+ if (config.emitInitial === true) {
136
+ // Defer until reducers and procedures from the same layer build have registered.
137
+ scheduler.schedule([() => emit({ previous: Option.none(), current: initial })])
138
+ }
139
+ }),
140
+ )
@@ -7,7 +7,6 @@ export interface EventGroupClass<Members extends ReadonlyArray<AnyEvent>> {
7
7
  readonly members: Members
8
8
  }
9
9
 
10
- /** Compose events into a group, the same variadic shape as `StateGroup`/`RpcGroup`. */
11
10
  export const make = <const Members extends ReadonlyArray<AnyEvent>>(
12
11
  ...members: Members
13
12
  ): EventGroupClass<Members> =>
@@ -1,7 +1,8 @@
1
- import { type Cause, Duration, Effect, Exit, Layer, ManagedRuntime, Scope } from 'effect'
1
+ import { type Cause, Context, Duration, Effect, Exit, Layer, Scope } from 'effect'
2
2
  import { Schema as S } from 'effect'
3
- import { expect, test } from 'vitest'
3
+ import { expect, it } from '@effect/vitest'
4
4
  import * as Composition from '../compose/composition'
5
+ import { mount } from '../compose/structure'
5
6
  import { ui } from '../compose/ui'
6
7
  import * as Event from '../event/event'
7
8
  import * as Reducer from '../reducer/reducer'
@@ -16,16 +17,45 @@ import { type EngineServices, featureModule, mountFeature } from './feature'
16
17
  // (Reducers registry) OPEN, so `mountFeature` builds it against the live engine.
17
18
  class Tick extends Event.make('Tick', S.Struct({})) {}
18
19
  class Count extends State.make('count', S.Number) {}
19
- class CountReducer extends Reducer.make('CountReducer', { states: [Count], events: [Tick] }) {}
20
+ class CountReducer extends Reducer.make('CountReducer', {
21
+ states: [Count],
22
+ events: [Tick],
23
+ }) {}
20
24
 
21
- const mod = featureModule(
22
- [],
23
- Reducer.live(CountReducer, (n) => n + 1).pipe(Layer.provideMerge(State.live(Count, 0))),
24
- )
25
+ class MountedCounterProbe extends Context.Tag('test/MountedCounterProbe')<
26
+ MountedCounterProbe,
27
+ { readonly value: number }
28
+ >() {}
29
+
30
+ class LazyMountedProbe extends Context.Tag('test/LazyMountedProbe')<
31
+ LazyMountedProbe,
32
+ { readonly value: string }
33
+ >() {}
34
+
35
+ class UnmountedProbe extends Context.Tag('test/UnmountedProbe')<
36
+ UnmountedProbe,
37
+ { readonly value: boolean }
38
+ >() {}
25
39
 
26
40
  // A trivial composition to hang the feature off (definitions only).
27
41
  class CtrUi extends ui('CtrUi')<{ props: {}; events: {} }>() {}
28
- class CtrComp extends Composition.make('CtrComp', { title: 'Counter', ui: CtrUi }) {}
42
+ class CtrComp extends Composition.make('CtrComp', {
43
+ title: 'Counter',
44
+ ui: CtrUi,
45
+ })<CtrComp>() {}
46
+
47
+ const CtrLive = Composition.live(CtrComp, function* () {
48
+ return mount({ props: {}, slots: {} })
49
+ })
50
+
51
+ const mod = featureModule(
52
+ [],
53
+ Layer.mergeAll(
54
+ CtrLive,
55
+ Reducer.live(CountReducer, (n) => n + 1).pipe(Layer.provideMerge(State.live(Count, 0))),
56
+ Layer.succeed(MountedCounterProbe, { value: 42 }),
57
+ ),
58
+ )
29
59
 
30
60
  class CounterFeature extends Feature.make('counter', {
31
61
  composition: CtrComp,
@@ -33,6 +63,43 @@ class CounterFeature extends Feature.make('counter', {
33
63
  boot: [Event.construct(Tick, {})],
34
64
  }) {}
35
65
 
66
+ const lazyMod = featureModule(
67
+ [],
68
+ Layer.merge(CtrLive, Layer.succeed(LazyMountedProbe, { value: 'lazy' })),
69
+ )
70
+
71
+ class LazyCounterFeature extends Feature.make('lazy-counter', {
72
+ composition: CtrComp,
73
+ loadingStrategy: 'lazy',
74
+ load: Effect.succeed(lazyMod),
75
+ placeholder: { loading: CtrComp, failed: CtrComp },
76
+ }) {}
77
+
78
+ const CounterNative = Feature.tree<typeof CounterFeature>(CounterFeature.token)
79
+ const LazyCounterNative = Feature.tree<typeof LazyCounterFeature>(LazyCounterFeature.token)
80
+
81
+ const mountCounterFeature = (engineContext: Context.Context<EngineServices>) =>
82
+ mountFeature(CounterNative.binding, engineContext)
83
+
84
+ const mountLazyCounterFeature = (engineContext: Context.Context<EngineServices>) =>
85
+ mountFeature(LazyCounterNative.binding, engineContext)
86
+
87
+ type ContextServices<C> = C extends Context.Context<infer Services> ? Services : never
88
+ type EagerMountedServices = ContextServices<
89
+ Effect.Effect.Success<ReturnType<typeof mountCounterFeature>>
90
+ >
91
+ type LazyMountedServices = ContextServices<
92
+ Effect.Effect.Success<ReturnType<typeof mountLazyCounterFeature>>
93
+ >
94
+ type Includes<Services, Candidate> = Candidate extends Services ? true : false
95
+
96
+ const mountedServiceTypeProof: readonly [
97
+ Includes<EagerMountedServices, MountedCounterProbe>,
98
+ Includes<EagerMountedServices, UnmountedProbe>,
99
+ Includes<LazyMountedServices, LazyMountedProbe>,
100
+ Includes<LazyMountedServices, UnmountedProbe>,
101
+ ] = [true, false, true, false]
102
+
36
103
  // Poll a read until it satisfies a predicate (the loop drains on a forked fiber).
37
104
  const waitUntil = <A, R>(
38
105
  read: Effect.Effect<A, never, R>,
@@ -44,39 +111,51 @@ const waitUntil = <A, R>(
44
111
  : Effect.flatMap(Effect.sleep(Duration.millis(1)), () => waitUntil(read, pred)),
45
112
  ).pipe(Effect.timeout(Duration.seconds(2)))
46
113
 
47
- test('a mounted feature registers on the shared loop, boots, and folds dispatched events', async () => {
48
- const runtime = ManagedRuntime.make(Engine)
49
- try {
50
- await runtime.runPromise(
51
- Effect.gen(function* () {
52
- const engineContext = yield* Effect.context<EngineServices>()
53
- const reducers = yield* Reducers
54
- const baseline = reducers.entries.length
55
-
56
- const scope = yield* Scope.make()
57
- const ctx = yield* mountFeature(CounterFeature.binding, engineContext).pipe(
58
- Effect.provideService(Scope.Scope, scope),
59
- )
60
- // Registered into the SHARED registry.
61
- expect(reducers.entries.length).toBe(baseline + 1)
62
-
63
- // boot dispatched Tick → Count folds to 1 on the shared loop.
64
- const booted = yield* waitUntil(Count.pipe(Effect.provide(ctx)), (n) => n === 1)
65
- expect(booted).toBe(1)
66
-
67
- // A further dispatch on the shared bus folds again → 2.
68
- yield* publish('High', Event.construct(Tick, {}))
69
- const folded = yield* waitUntil(Count.pipe(Effect.provide(ctx)), (n) => n === 2)
70
- expect(folded).toBe(2)
71
-
72
- // Unmount: closing the feature scope unregisters its reducer — the entry is
73
- // reclaimed, not left folding onto an orphaned store.
74
- yield* Scope.close(scope, Exit.succeed(undefined))
75
- expect(reducers.entries.length).toBe(baseline)
76
- expect(reducers.byTag.get('Tick')).toBeUndefined()
77
- }),
114
+ it.scopedLive(
115
+ 'a mounted feature registers on the shared loop, boots, and folds dispatched events',
116
+ () =>
117
+ Effect.gen(function* () {
118
+ const engineContext = yield* Effect.context<EngineServices>()
119
+ const reducers = yield* Reducers
120
+ const baseline = reducers.entries.length
121
+
122
+ const scope = yield* Scope.make()
123
+ const ctx = yield* mountCounterFeature(engineContext).pipe(
124
+ Effect.provideService(Scope.Scope, scope),
125
+ )
126
+ expect(mountedServiceTypeProof).toEqual([true, false, true, false])
127
+ expect(Context.get(ctx, MountedCounterProbe).value).toBe(42)
128
+ expect(Context.getOption(ctx, UnmountedProbe)._tag).toBe('None')
129
+ // Registered into the SHARED registry.
130
+ expect(reducers.entries.length).toBe(baseline + 1)
131
+
132
+ // boot dispatched Tick → Count folds to 1 on the shared loop.
133
+ const booted = yield* waitUntil(Count.pipe(Effect.provide(ctx)), (n) => n === 1)
134
+ expect(booted).toBe(1)
135
+
136
+ // A further dispatch on the shared bus folds again → 2.
137
+ yield* publish('High', Event.construct(Tick, {}))
138
+ const folded = yield* waitUntil(Count.pipe(Effect.provide(ctx)), (n) => n === 2)
139
+ expect(folded).toBe(2)
140
+
141
+ // Unmount: closing the feature scope unregisters its reducer — the entry is
142
+ // reclaimed, not left folding onto an orphaned store.
143
+ yield* Scope.close(scope, Exit.succeed(undefined))
144
+ expect(reducers.entries.length).toBe(baseline)
145
+ expect(reducers.byTag.get('Tick')).toBeUndefined()
146
+ }).pipe(Effect.provide(Engine)),
147
+ )
148
+
149
+ it.scopedLive('a lazy feature retains its mounted service type', () =>
150
+ Effect.gen(function* () {
151
+ const engineContext = yield* Effect.context<EngineServices>()
152
+ const scope = yield* Scope.make()
153
+ const ctx = yield* mountLazyCounterFeature(engineContext).pipe(
154
+ Effect.provideService(Scope.Scope, scope),
78
155
  )
79
- } finally {
80
- await runtime.dispose()
81
- }
82
- })
156
+
157
+ expect(Context.get(ctx, LazyMountedProbe).value).toBe('lazy')
158
+ expect(Context.getOption(ctx, UnmountedProbe)._tag).toBe('None')
159
+ yield* Scope.close(scope, Exit.succeed(undefined))
160
+ }).pipe(Effect.provide(Engine)),
161
+ )