@playfast/reform 1.0.1 → 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.
- package/README.md +18 -1
- package/package.json +1 -1
- package/src/boundary/boundary.test.ts +1 -33
- package/src/boundary/boundary.ts +4 -82
- package/src/calc/asyncCalc.invalidate.test.ts +2 -14
- package/src/calc/asyncCalc.test.ts +0 -38
- package/src/calc/asyncCalc.ts +11 -163
- package/src/calc/asyncCalcDefinitions.ts +48 -0
- package/src/calc/asyncData.ts +0 -37
- package/src/calc/calc.test.ts +4 -28
- package/src/calc/calc.ts +6 -46
- package/src/calc/calcFamily.test.ts +0 -12
- package/src/calc/calcFamily.ts +6 -48
- package/src/calc/compose.test.ts +1 -12
- package/src/calc/compose.ts +0 -22
- package/src/calc/queryState.ts +0 -23
- package/src/channel/channel.ts +72 -174
- package/src/channel/procedureRegistry.ts +90 -0
- package/src/compose/composition.ts +7 -67
- package/src/compose/host.ts +0 -6
- package/src/compose/props.ts +3 -9
- package/src/compose/provide.ts +19 -33
- package/src/compose/slot.ts +2 -30
- package/src/compose/structure.test.ts +0 -6
- package/src/compose/structure.ts +3 -67
- package/src/compose/ui.test.ts +0 -4
- package/src/compose/ui.ts +17 -111
- package/src/compose/ui.typecheck.ts +0 -14
- package/src/definition/definition.ts +0 -32
- package/src/event/event.test.ts +0 -3
- package/src/event/event.ts +5 -14
- package/src/event/eventGroup.ts +0 -1
- package/src/feature/feature.mount.test.ts +66 -54
- package/src/feature/feature.test.ts +67 -46
- package/src/feature/feature.ts +185 -214
- package/src/feature/feature.typecheck.ts +29 -16
- package/src/feature/featureBinding.ts +48 -0
- package/src/index.ts +245 -229
- package/src/internal/capture.ts +0 -20
- package/src/internal/ctx.ts +2 -9
- package/src/internal/env.ts +9 -0
- package/src/internal/errors.test.ts +0 -6
- package/src/internal/errors.ts +14 -48
- package/src/internal/inspect.test.ts +0 -6
- package/src/internal/inspect.ts +0 -12
- package/src/internal/queryDriver.ts +42 -243
- package/src/internal/queryDriverStore.ts +153 -0
- package/src/internal/queryDriverTypes.ts +57 -0
- package/src/internal/queryEvents.ts +0 -12
- package/src/internal/queryStore.ts +0 -14
- package/src/internal/reuse.test.ts +0 -11
- package/src/internal/reuse.ts +0 -28
- package/src/internal/scheduler.ts +0 -43
- package/src/internal/seeds.ts +0 -14
- package/src/internal/sources.ts +4 -41
- 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 +0 -18
- package/src/internal/variance.ts +5 -0
- package/src/namespace/namespace.test.ts +46 -0
- package/src/namespace/namespace.ts +85 -0
- package/src/procedure/procedure.ts +2 -24
- package/src/reducer/reducer.ts +3 -21
- package/src/remote/remoteState.test.ts +92 -75
- package/src/remote/remoteState.ts +99 -486
- package/src/remote/remoteState.typecheck.ts +0 -33
- package/src/remote/remoteStateDefinition.ts +135 -0
- package/src/remote/remoteStateLayers.ts +118 -0
- package/src/remote/remoteStateLiveTypes.ts +59 -0
- package/src/remote/remoteStateSend.ts +64 -0
- package/src/runtime/appRuntime.test.ts +197 -0
- package/src/runtime/appRuntime.ts +40 -83
- package/src/runtime/bus.ts +2 -15
- package/src/runtime/eventBudget.test.ts +155 -0
- package/src/runtime/eventBudget.ts +119 -0
- package/src/runtime/hardening.test.ts +74 -9
- package/src/runtime/instrumentation.test.ts +213 -192
- package/src/runtime/instrumentation.ts +0 -49
- package/src/runtime/loop.test.ts +0 -17
- package/src/runtime/loop.ts +25 -55
- package/src/runtime/queries.ts +0 -17
- package/src/scene/featureScene.test.ts +59 -0
- package/src/scene/scene.ts +32 -59
- package/src/scene/seedScene.test.ts +96 -116
- package/src/state/state.ts +7 -26
- package/src/state/stateFamily.test.ts +2 -11
- package/src/state/stateFamily.ts +9 -52
- package/src/state/stateGroup.ts +2 -49
- package/src/state/token.ts +4 -17
- package/src/synced/syncedStore.ts +7 -42
- package/src/ui/node.ts +0 -6
- package/src/ui/trigger.ts +0 -5
- package/src/wire/tree.test.ts +0 -3
- package/src/wire/tree.ts +0 -39
- package/src/wire/triggers.test.ts +0 -4
- package/src/wire/triggers.ts +5 -27
package/src/internal/store.ts
CHANGED
|
@@ -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,7 +10,6 @@ export interface Store<in out A> extends Inspectable {
|
|
|
30
10
|
subscribe(listener: () => void): () => void
|
|
31
11
|
}
|
|
32
12
|
|
|
33
|
-
/** Default change hook — a shared no-op so unprofiled stores pay one skipped call. */
|
|
34
13
|
const noChange = (): void => {}
|
|
35
14
|
|
|
36
15
|
export const makeStore = <A>(
|
|
@@ -38,8 +17,6 @@ export const makeStore = <A>(
|
|
|
38
17
|
scheduler: Scheduler = defaultScheduler,
|
|
39
18
|
onChange: () => void = noChange,
|
|
40
19
|
): Store<A> => {
|
|
41
|
-
// The store is a mutable reactive slot by design, so its value lives in a
|
|
42
|
-
// `MutableRef` we update in place (no reassigned binding).
|
|
43
20
|
const current = MutableRef.make(initial)
|
|
44
21
|
const version = MutableRef.make(0)
|
|
45
22
|
const listeners = new Set<() => void>()
|
|
@@ -66,18 +43,6 @@ export const makeStore = <A>(
|
|
|
66
43
|
}
|
|
67
44
|
}
|
|
68
45
|
|
|
69
|
-
/**
|
|
70
|
-
* A read-only `Store` whose value is computed from upstream sources.
|
|
71
|
-
*
|
|
72
|
-
* Reads are pull-fresh: `get`/`getSnapshot` call `compute` directly, so a
|
|
73
|
-
* headless snapshot always reflects the current source state without waiting for
|
|
74
|
-
* a notification (`compute` must memoize so an unchanged read returns a stable
|
|
75
|
-
* reference — `useSyncExternalStore` requires it). *Notifications*, by contrast,
|
|
76
|
-
* run through the same coalescing scheduler as `makeStore`: an upstream change
|
|
77
|
-
* recomputes once and wakes subscribers only when the output actually moves (by
|
|
78
|
-
* `Equal.equals`), so every store kind shares one notification timing. `set` is
|
|
79
|
-
* a no-op — the only writer is the upstream subscription.
|
|
80
|
-
*/
|
|
81
46
|
export const makeDerivedStore = <A>(
|
|
82
47
|
compute: () => A,
|
|
83
48
|
subscribeUpstream: (onChange: () => void) => () => void,
|
|
@@ -86,8 +51,6 @@ export const makeDerivedStore = <A>(
|
|
|
86
51
|
): { readonly store: Store<A>; readonly unsubscribe: () => void } => {
|
|
87
52
|
const listeners = new Set<() => void>()
|
|
88
53
|
const version = MutableRef.make(0)
|
|
89
|
-
// The last output we notified against — tracked only here, never touched by
|
|
90
|
-
// reads, so a `get` racing ahead of `onChange` can't suppress a notification.
|
|
91
54
|
const seen = MutableRef.make<{ readonly value: A } | undefined>({ value: compute() })
|
|
92
55
|
const onChange = () => {
|
|
93
56
|
const previous = MutableRef.get(seen)
|
package/src/internal/track.ts
CHANGED
|
@@ -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,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<
|
|
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
|
}
|
package/src/reducer/reducer.ts
CHANGED
|
@@ -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:
|
|
102
|
-
): Layer.Layer<never, never,
|
|
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 {
|