@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.
- package/README.md +103 -56
- package/package.json +17 -17
- package/src/boundary/boundary.test.ts +63 -49
- package/src/boundary/boundary.ts +144 -113
- package/src/calc/asyncCalc.invalidate.test.ts +518 -32
- package/src/calc/asyncCalc.test.ts +207 -213
- package/src/calc/asyncCalc.ts +131 -154
- package/src/calc/asyncData.ts +0 -37
- package/src/calc/calc.test.ts +7 -33
- package/src/calc/calc.ts +44 -58
- package/src/calc/calcFamily.test.ts +80 -34
- package/src/calc/calcFamily.ts +54 -65
- package/src/calc/compose.test.ts +1 -12
- package/src/calc/compose.ts +1 -36
- package/src/calc/queryState.ts +0 -23
- package/src/channel/channel.ts +126 -111
- package/src/compose/composition.test.ts +19 -0
- package/src/compose/composition.ts +351 -127
- package/src/compose/host.ts +0 -6
- package/src/compose/props.ts +13 -12
- package/src/compose/provide.ts +189 -62
- package/src/compose/slot.ts +158 -49
- package/src/compose/structure.test.ts +6 -9
- package/src/compose/structure.ts +108 -79
- package/src/compose/ui.test.ts +19 -7
- package/src/compose/ui.ts +155 -156
- package/src/compose/ui.typecheck.ts +40 -18
- package/src/definition/definition.ts +22 -41
- package/src/event/event.fromSource.test.ts +172 -0
- package/src/event/event.test.ts +10 -3
- package/src/event/event.ts +102 -27
- package/src/event/eventGroup.ts +0 -1
- package/src/feature/feature.mount.test.ts +122 -43
- package/src/feature/feature.test.ts +46 -21
- package/src/feature/feature.ts +1347 -256
- package/src/feature/feature.typecheck.ts +273 -68
- package/src/graph/closure.ts +403 -0
- package/src/index.ts +68 -126
- package/src/internal/bucketCache.ts +39 -0
- package/src/internal/capture.ts +9 -24
- package/src/internal/env.ts +7 -0
- package/src/internal/errors.test.ts +0 -6
- package/src/internal/errors.ts +37 -60
- package/src/internal/inspect.test.ts +0 -6
- package/src/internal/inspect.ts +0 -12
- package/src/internal/queryDriver.ts +105 -201
- package/src/internal/queryDriverStore.ts +156 -0
- package/src/internal/queryDriverTypes.ts +59 -0
- package/src/internal/queryEvents.ts +0 -12
- package/src/internal/queryStore.ts +0 -14
- package/src/internal/reuse.test.ts +10 -14
- package/src/internal/reuse.ts +5 -30
- package/src/internal/scheduler.ts +4 -44
- package/src/internal/seeds.ts +0 -14
- package/src/internal/sources.ts +26 -49
- package/src/internal/stateRegistry.ts +0 -14
- package/src/internal/store.test.ts +1 -3
- package/src/internal/store.ts +0 -37
- package/src/internal/track.ts +3 -20
- package/src/internal/variance.ts +5 -0
- package/src/internal.ts +222 -0
- package/src/namespace/namespace.test.ts +46 -0
- package/src/namespace/namespace.ts +106 -0
- package/src/procedure/procedure.ts +40 -35
- package/src/reducer/reducer.ts +78 -52
- package/src/remote/remoteState.test.ts +590 -397
- package/src/remote/remoteState.ts +425 -347
- package/src/remote/remoteState.typecheck.ts +47 -56
- package/src/runtime/appRuntime.activation.test.ts +100 -0
- package/src/runtime/appRuntime.test.ts +249 -0
- package/src/runtime/appRuntime.ts +415 -97
- package/src/runtime/bus.ts +2 -15
- package/src/runtime/eventBudget.test.ts +152 -0
- package/src/runtime/eventBudget.ts +120 -0
- package/src/runtime/hardening.test.ts +73 -13
- package/src/runtime/instrumentation.test.ts +55 -52
- package/src/runtime/instrumentation.ts +6 -60
- package/src/runtime/loop.test.ts +36 -17
- package/src/runtime/loop.ts +87 -88
- package/src/runtime/queries.ts +0 -17
- package/src/scene/featureScene.test.ts +61 -0
- package/src/scene/scene.ts +247 -104
- package/src/scene/seedScene.test.ts +42 -79
- package/src/state/state.nominal.typecheck.ts +68 -0
- package/src/state/state.test.ts +17 -0
- package/src/state/state.ts +80 -46
- package/src/state/stateFamily.test.ts +16 -11
- package/src/state/stateFamily.ts +55 -65
- package/src/state/stateGroup.ts +53 -64
- package/src/state/token.ts +40 -23
- package/src/synced/syncedStore.ts +46 -58
- package/src/testkit/flight.testkit.ts +75 -0
- package/src/ui/node.ts +0 -6
- package/src/ui/trigger.ts +0 -5
- package/src/wire/tree.test.ts +8 -7
- package/src/wire/tree.ts +0 -39
- package/src/wire/triggers.test.ts +6 -6
- package/src/wire/triggers.ts +14 -32
- 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
|
+
})
|
package/src/event/event.test.ts
CHANGED
|
@@ -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
|
+
})
|
package/src/event/event.ts
CHANGED
|
@@ -1,65 +1,140 @@
|
|
|
1
|
-
import { Effect, PubSub, Runtime,
|
|
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
|
|
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:
|
|
17
|
+
readonly schema: EventSchemaReflection
|
|
13
18
|
}
|
|
14
19
|
|
|
15
|
-
export interface
|
|
20
|
+
export interface EventReflection<out N extends string = string, out P = unknown> {
|
|
16
21
|
new (): {}
|
|
17
|
-
readonly manifest: EventManifest<N
|
|
22
|
+
readonly manifest: EventManifest<N>
|
|
18
23
|
readonly tag: N
|
|
19
|
-
|
|
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
|
|
26
|
-
export type
|
|
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
|
-
|
|
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,
|
|
47
|
+
schema: Schema.Schema<P, Encoded, never>,
|
|
32
48
|
): EventClass<N, P> => {
|
|
33
|
-
const build = (payload: P): EventOf<N, P> => ({ _tag: name
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
export
|
|
54
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
+
)
|
package/src/event/eventGroup.ts
CHANGED
|
@@ -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,
|
|
1
|
+
import { type Cause, Context, Duration, Effect, Exit, Layer, Scope } from 'effect'
|
|
2
2
|
import { Schema as S } from 'effect'
|
|
3
|
-
import { expect,
|
|
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', {
|
|
20
|
+
class CountReducer extends Reducer.make('CountReducer', {
|
|
21
|
+
states: [Count],
|
|
22
|
+
events: [Tick],
|
|
23
|
+
}) {}
|
|
20
24
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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', {
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
Effect.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
+
)
|