@playfast/reform 0.0.5 → 0.0.8
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/package.json +31 -10
- package/src/boundary/boundary.test.ts +301 -0
- package/src/calc/asyncCalc.test.ts +556 -0
- package/src/calc/asyncData.ts +9 -1
- package/src/calc/calc.test.ts +287 -0
- package/src/calc/calcFamily.test.ts +206 -0
- package/src/calc/compose.test.ts +68 -0
- package/src/channel/channel.ts +9 -5
- package/src/compose/host.ts +4 -1
- package/src/compose/props.ts +5 -1
- package/src/compose/ui.test.ts +62 -0
- package/src/compose/ui.ts +173 -44
- package/src/compose/ui.typecheck.ts +56 -0
- package/src/definition/definition.ts +2 -0
- package/src/event/event.test.ts +23 -0
- package/src/feature/feature.mount.test.ts +82 -0
- package/src/feature/feature.test.ts +60 -0
- package/src/feature/feature.typecheck.ts +108 -0
- package/src/index.ts +38 -2
- package/src/internal/capture.ts +4 -4
- package/src/internal/errors.test.ts +33 -0
- package/src/internal/errors.ts +92 -16
- package/src/internal/inspect.test.ts +28 -0
- package/src/internal/queryDriver.ts +1 -1
- package/src/internal/reuse.test.ts +116 -0
- package/src/internal/scheduler.ts +3 -1
- package/src/internal/sources.ts +2 -2
- package/src/internal/stateRegistry.ts +32 -0
- package/src/internal/store.test.ts +80 -0
- package/src/internal/track.ts +3 -1
- package/src/remote/remoteState.test.ts +695 -0
- package/src/remote/remoteState.typecheck.ts +195 -0
- package/src/runtime/bus.ts +6 -2
- package/src/runtime/hardening.test.ts +69 -0
- package/src/runtime/loop.test.ts +178 -0
- package/src/runtime/loop.ts +4 -2
- package/src/scene/seedScene.test.ts +169 -0
- package/src/state/state.ts +4 -1
- package/src/state/stateFamily.test.ts +138 -0
- package/src/state/stateFamily.ts +4 -1
- package/src/state/stateGroup.ts +31 -4
- package/src/state/token.ts +5 -4
- package/src/synced/syncedStore.ts +99 -0
- package/src/wire/tree.test.ts +81 -0
- package/src/wire/tree.ts +129 -0
- package/src/wire/triggers.test.ts +76 -0
- package/src/wire/triggers.ts +98 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// Type-level proofs enforced by `tsc --noEmit` over `src` (not a vitest file).
|
|
2
|
+
// These guard the cast-free seam: if a guarantee regresses, a `@ts-expect-error`
|
|
3
|
+
// goes unused or an assignment fails, and `tsc` breaks.
|
|
4
|
+
|
|
5
|
+
import { Context, Effect, Layer, Match, Schema as S } from 'effect'
|
|
6
|
+
import * as Boundary from '../boundary/boundary'
|
|
7
|
+
import type { AsyncData } from '../calc/asyncData'
|
|
8
|
+
import { type Channels, type Procedures } from '../channel/channel'
|
|
9
|
+
import * as Event from '../event/event'
|
|
10
|
+
import type { Store } from '../internal/store'
|
|
11
|
+
import * as Reducer from '../reducer/reducer'
|
|
12
|
+
import type { Bus } from '../runtime/bus'
|
|
13
|
+
import { type Reducers } from '../runtime/loop'
|
|
14
|
+
import * as State from '../state/state'
|
|
15
|
+
import * as StateGroup from '../state/stateGroup'
|
|
16
|
+
import { type AnySource, type Source } from '../state/token'
|
|
17
|
+
import * as RemoteState from './remoteState'
|
|
18
|
+
|
|
19
|
+
const AddIntentBase: Event.EventClass<'TAddIntent', { readonly id: string; readonly name: string }> =
|
|
20
|
+
Event.make('TAddIntent', S.Struct({ id: S.String, name: S.String }))
|
|
21
|
+
class AddIntent extends AddIntentBase {}
|
|
22
|
+
const RenameIntentBase: Event.EventClass<
|
|
23
|
+
'TRenameIntent',
|
|
24
|
+
{ readonly id: string; readonly name: string }
|
|
25
|
+
> = Event.make('TRenameIntent', S.Struct({ id: S.String, name: S.String }))
|
|
26
|
+
class RenameIntent extends RenameIntentBase {}
|
|
27
|
+
type Intent = Event.EventType<typeof AddIntent> | Event.EventType<typeof RenameIntent>
|
|
28
|
+
|
|
29
|
+
const PageBase: State.StateClass<'tcPage', number> = State.make('tcPage', S.Number)
|
|
30
|
+
class Page extends PageBase {}
|
|
31
|
+
const InputsBase: StateGroup.StateGroupClass<readonly [typeof Page]> = StateGroup.make(Page)
|
|
32
|
+
class Inputs extends InputsBase {}
|
|
33
|
+
const page: Source<'tcPage', number> = StateGroup.select(Inputs, 'tcPage')
|
|
34
|
+
|
|
35
|
+
const Row: S.Struct<{ id: typeof S.String; name: typeof S.String }> = S.Struct({
|
|
36
|
+
id: S.String,
|
|
37
|
+
name: S.String,
|
|
38
|
+
})
|
|
39
|
+
type Row = typeof Row.Type
|
|
40
|
+
|
|
41
|
+
const SendDepBase: Context.TagClass<
|
|
42
|
+
SendDep,
|
|
43
|
+
'typecheck/SendDep',
|
|
44
|
+
{ readonly go: Effect.Effect<void> }
|
|
45
|
+
> = Context.Tag('typecheck/SendDep')<SendDep, { readonly go: Effect.Effect<void> }>()
|
|
46
|
+
class SendDep extends SendDepBase {}
|
|
47
|
+
|
|
48
|
+
const ItemsBase: RemoteState.RemoteStateClass<
|
|
49
|
+
'TItems',
|
|
50
|
+
readonly [typeof page],
|
|
51
|
+
readonly [typeof AddIntent, typeof RenameIntent],
|
|
52
|
+
ReadonlyArray<Row>,
|
|
53
|
+
string,
|
|
54
|
+
true
|
|
55
|
+
> = RemoteState.make('TItems', {
|
|
56
|
+
inputs: [page],
|
|
57
|
+
output: S.Array(Row),
|
|
58
|
+
error: S.String,
|
|
59
|
+
intents: [AddIntent, RenameIntent],
|
|
60
|
+
})
|
|
61
|
+
class Items extends ItemsBase {}
|
|
62
|
+
|
|
63
|
+
// --- intent-union inference: apply/send receive exactly the declared union ---
|
|
64
|
+
|
|
65
|
+
// POSITIVE: a Match.exhaustive fold over the declared intents compiles, `send`'s
|
|
66
|
+
// R2 flows from its body, and gated `disabled` is accepted.
|
|
67
|
+
export const okLive: Layer.Layer<
|
|
68
|
+
| Store<AsyncData<ReadonlyArray<Row>, string, true>>
|
|
69
|
+
| Store<ReadonlyArray<RemoteState.PendingIntent<Intent>>>,
|
|
70
|
+
never,
|
|
71
|
+
Store<number> | SendDep | Reducers | Procedures | Channels | Bus
|
|
72
|
+
> = RemoteState.live(Items, {
|
|
73
|
+
query: ({ tcPage }) =>
|
|
74
|
+
tcPage > 0 ? Effect.succeed<ReadonlyArray<Row>>([]) : Effect.fail('empty page'),
|
|
75
|
+
send: () => Effect.flatMap(SendDep, (dep) => dep.go),
|
|
76
|
+
apply: (rows, intent) =>
|
|
77
|
+
Match.value(intent).pipe(
|
|
78
|
+
Match.tag('TAddIntent', ({ id, name }) => [...rows, { id, name }]),
|
|
79
|
+
Match.tag('TRenameIntent', ({ id, name }) =>
|
|
80
|
+
rows.map((row) => (row.id === id ? { ...row, name } : row)),
|
|
81
|
+
),
|
|
82
|
+
Match.exhaustive,
|
|
83
|
+
),
|
|
84
|
+
disabled: ({ tcPage }) => tcPage < 0,
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
// NEGATIVE: a handler typed for an undeclared tag does not fit the union.
|
|
88
|
+
export const badIntent: Layer.Layer<
|
|
89
|
+
| Store<AsyncData<ReadonlyArray<Row>, string, true>>
|
|
90
|
+
| Store<ReadonlyArray<RemoteState.PendingIntent<Intent>>>,
|
|
91
|
+
never,
|
|
92
|
+
Store<number> | Reducers | Procedures | Channels | Bus
|
|
93
|
+
> = RemoteState.live(Items, {
|
|
94
|
+
query: () => Effect.succeed<ReadonlyArray<Row>>([]),
|
|
95
|
+
send: () => Effect.void,
|
|
96
|
+
// @ts-expect-error 'TDeleteIntent' is not in the declared intent union
|
|
97
|
+
apply: (rows: ReadonlyArray<Row>, intent: { readonly _tag: 'TDeleteIntent' }) => rows,
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
// --- layer requirement honesty: RIn is complete and exact ---
|
|
101
|
+
|
|
102
|
+
// POSITIVE: the layer's RIn is exactly the input stores, the query/send context,
|
|
103
|
+
// and the engine registries — and its ROut names the overlay + pending stores.
|
|
104
|
+
export const okLayer: Layer.Layer<
|
|
105
|
+
Store<AsyncData<ReadonlyArray<Row>, string, true>> | Store<ReadonlyArray<RemoteState.PendingIntent<Intent>>>,
|
|
106
|
+
never,
|
|
107
|
+
Store<number> | SendDep | Reducers | Procedures | Channels | Bus
|
|
108
|
+
> = okLive
|
|
109
|
+
|
|
110
|
+
// NEGATIVE: dropping the send dependency (R2) from the annotation fails —
|
|
111
|
+
// requirements cannot be silently widened away.
|
|
112
|
+
// @ts-expect-error the send body's R2 must appear in the layer's requirements
|
|
113
|
+
export const badLayer: Layer.Layer<
|
|
114
|
+
Store<AsyncData<ReadonlyArray<Row>, string, true>> | Store<ReadonlyArray<RemoteState.PendingIntent<Intent>>>,
|
|
115
|
+
never,
|
|
116
|
+
Store<number> | Reducers | Procedures | Channels | Bus
|
|
117
|
+
> = okLive
|
|
118
|
+
|
|
119
|
+
// --- alwaysOn / error narrowing: the read carries exactly the possible arms ---
|
|
120
|
+
|
|
121
|
+
const AlwaysItemsBase: RemoteState.RemoteStateClass<
|
|
122
|
+
'TAlwaysItems',
|
|
123
|
+
readonly [typeof page],
|
|
124
|
+
readonly [typeof AddIntent],
|
|
125
|
+
ReadonlyArray<Row>,
|
|
126
|
+
never,
|
|
127
|
+
false
|
|
128
|
+
> = RemoteState.make('TAlwaysItems', {
|
|
129
|
+
inputs: [page],
|
|
130
|
+
output: S.Array(Row),
|
|
131
|
+
intents: [AddIntent],
|
|
132
|
+
alwaysOn: true,
|
|
133
|
+
})
|
|
134
|
+
class AlwaysItems extends AlwaysItemsBase {}
|
|
135
|
+
|
|
136
|
+
// NEGATIVE: an alwaysOn remote state rejects `disabled` (erased to never).
|
|
137
|
+
export const badDisabled: Layer.Layer<
|
|
138
|
+
| Store<AsyncData<ReadonlyArray<Row>, never, false>>
|
|
139
|
+
| Store<ReadonlyArray<RemoteState.PendingIntent<Event.EventType<typeof AddIntent>>>>,
|
|
140
|
+
never,
|
|
141
|
+
Store<number> | Reducers | Procedures | Channels | Bus
|
|
142
|
+
> = RemoteState.live(AlwaysItems, {
|
|
143
|
+
query: () => Effect.succeed<ReadonlyArray<Row>>([]),
|
|
144
|
+
send: () => Effect.void,
|
|
145
|
+
apply: (rows) => rows,
|
|
146
|
+
// @ts-expect-error an alwaysOn remote state cannot be disabled
|
|
147
|
+
disabled: () => false,
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
declare const alwaysArms: Effect.Effect.Success<typeof AlwaysItems>['_tag']
|
|
151
|
+
declare const gatedArms: Effect.Effect.Success<typeof Items>['_tag']
|
|
152
|
+
declare const idleArm: 'Idle'
|
|
153
|
+
declare const errorArm: 'Error'
|
|
154
|
+
declare const fourArms: 'Idle' | 'Loading' | 'Success' | 'Error'
|
|
155
|
+
|
|
156
|
+
// POSITIVE: alwaysOn + no `error` schema ⇒ at most Loading | Success…
|
|
157
|
+
export const alwaysExact: 'Loading' | 'Success' = alwaysArms
|
|
158
|
+
// …and gated + `error` schema ⇒ exactly all four arms (both directions).
|
|
159
|
+
export const gatedSubset: 'Idle' | 'Loading' | 'Success' | 'Error' = gatedArms
|
|
160
|
+
export const gatedAll: Effect.Effect.Success<typeof Items>['_tag'] = fourArms
|
|
161
|
+
|
|
162
|
+
// NEGATIVE: the excluded arms really are gone from the read type.
|
|
163
|
+
// @ts-expect-error an alwaysOn remote state has no Idle arm
|
|
164
|
+
export const noIdle: Effect.Effect.Success<typeof AlwaysItems>['_tag'] = idleArm
|
|
165
|
+
// @ts-expect-error no `error` schema ⇒ no Error arm
|
|
166
|
+
export const noError: Effect.Effect.Success<typeof AlwaysItems>['_tag'] = errorArm
|
|
167
|
+
|
|
168
|
+
// --- surface shape: Sources where expected, never a reducer target ---
|
|
169
|
+
|
|
170
|
+
// POSITIVE: the class itself is a `Source` (a calc input / boundary coverage),
|
|
171
|
+
// valued at the overlaid lifecycle.
|
|
172
|
+
export const classAsSource: Source<'TItems', AsyncData<ReadonlyArray<Row>, string, true>> = Items
|
|
173
|
+
export const classAsAnySource: AnySource = Items
|
|
174
|
+
|
|
175
|
+
// POSITIVE: `pending` is an ordinary read-only `Source` (badges, "saving…" chrome).
|
|
176
|
+
export const pendingAsSource: Source<'TItems/pending', ReadonlyArray<RemoteState.PendingIntent<Intent>>> =
|
|
177
|
+
Items.pending
|
|
178
|
+
|
|
179
|
+
// POSITIVE: `truth` is the un-overlaid lifecycle under the same arms.
|
|
180
|
+
export const truthAsSource: Source<'TItems/truth', AsyncData<ReadonlyArray<Row>, string, true>> =
|
|
181
|
+
Items.truth
|
|
182
|
+
|
|
183
|
+
// POSITIVE: a boundary covers the remote state like any lifecycle source.
|
|
184
|
+
export const covered: Boundary.BoundaryClass<'TItemsBoundary', readonly [typeof Items]> =
|
|
185
|
+
Boundary.make('TItemsBoundary', { over: [Items] })
|
|
186
|
+
|
|
187
|
+
// NEGATIVE: the pending queue is NOT user state — `Reducer.make` cannot target
|
|
188
|
+
// it (it is a `StateToken`, not a `StateClass`; the hidden queue reducer is the
|
|
189
|
+
// sole writer).
|
|
190
|
+
export const badReducerTarget: Reducer.StateReducerClass<State.AnyState, readonly [typeof AddIntent]> =
|
|
191
|
+
// @ts-expect-error the pending queue is not a reducible state
|
|
192
|
+
Reducer.make('TBadReducer', {
|
|
193
|
+
states: [Items.pending],
|
|
194
|
+
events: [AddIntent],
|
|
195
|
+
})
|
package/src/runtime/bus.ts
CHANGED
|
@@ -22,15 +22,19 @@ export interface Envelope {
|
|
|
22
22
|
readonly event: Tagged
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
const BusBase: Context.TagClass<Bus, 'reform/Bus', PubSub.PubSub<Envelope>> = Context.Tag(
|
|
26
|
+
'reform/Bus',
|
|
27
|
+
)<Bus, PubSub.PubSub<Envelope>>()
|
|
28
|
+
|
|
25
29
|
/** The single dispatch bus: an unbounded PubSub fanned out to the loop and procedures. */
|
|
26
|
-
export class Bus extends
|
|
30
|
+
export class Bus extends BusBase {}
|
|
27
31
|
|
|
28
32
|
// Unbounded so `publish` never suspends: UI triggers dispatch synchronously
|
|
29
33
|
// (`Runtime.runSync`, no fiber fork per event), and an event storm can't apply
|
|
30
34
|
// backpressure to the UI thread. The single drain loop empties the bus every
|
|
31
35
|
// microtask, so it does not accumulate. Swap to `PubSub.dropping(N)` if bounded
|
|
32
36
|
// memory is preferred over never dropping (publish stays synchronous either way).
|
|
33
|
-
export const busLayer = Layer.scoped(Bus, PubSub.unbounded<Envelope>())
|
|
37
|
+
export const busLayer: Layer.Layer<Bus> = Layer.scoped(Bus, PubSub.unbounded<Envelope>())
|
|
34
38
|
|
|
35
39
|
export const publish = (priority: Priority, event: Tagged): Effect.Effect<void, never, Bus> =>
|
|
36
40
|
Effect.flatMap(Bus, (bus) => PubSub.publish(bus, { priority, event }))
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { expect, it } from '@effect/vitest'
|
|
2
|
+
import { Cause, Duration, Effect, Exit, Layer, LogLevel, Logger, Schema as S } from 'effect'
|
|
3
|
+
import { Channel, Engine, Event, publish, Reducer, State } from '../index'
|
|
4
|
+
|
|
5
|
+
// Robustness guards for the drain loop and registries: a buggy reducer must not
|
|
6
|
+
// take down the bus, and a name clash that would silently drop a registration
|
|
7
|
+
// must be rejected.
|
|
8
|
+
|
|
9
|
+
it.live('a reducer that throws is isolated; the loop keeps draining', () => {
|
|
10
|
+
class Counter extends State.make('counter', S.Number) {}
|
|
11
|
+
class Good extends Event.make('Good', S.Struct({})) {}
|
|
12
|
+
class Bad extends Event.make('Bad', S.Struct({})) {}
|
|
13
|
+
class GoodReducer extends Reducer.make('GoodReducer', { states: [Counter], events: [Good] }) {}
|
|
14
|
+
class BadReducer extends Reducer.make('BadReducer', { states: [Counter], events: [Bad] }) {}
|
|
15
|
+
const GoodLive = Reducer.live(GoodReducer, (n) => n + 1)
|
|
16
|
+
const BadLive = Reducer.live(BadReducer, () => {
|
|
17
|
+
throw new Error('boom')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
const TestLayer = Layer.mergeAll(GoodLive, BadLive).pipe(
|
|
21
|
+
Layer.provideMerge(Layer.mergeAll(State.live(Counter, 0), Engine)),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
return Effect.gen(function* () {
|
|
25
|
+
const store = yield* Counter.store
|
|
26
|
+
|
|
27
|
+
// A fold that throws is caught and logged — the drain fiber survives.
|
|
28
|
+
yield* publish('Normal', Event.construct(Bad, {}))
|
|
29
|
+
yield* Effect.sleep(Duration.millis(10))
|
|
30
|
+
expect(store.get()).toBe(0)
|
|
31
|
+
|
|
32
|
+
// A later, healthy event still processes: the bus did not go deaf.
|
|
33
|
+
yield* publish('Normal', Event.construct(Good, {}))
|
|
34
|
+
yield* Effect.sleep(Duration.millis(10))
|
|
35
|
+
expect(store.get()).toBe(1)
|
|
36
|
+
}).pipe(
|
|
37
|
+
Effect.provide(TestLayer),
|
|
38
|
+
// The isolated failure logs an error; silence it so the passing test is quiet.
|
|
39
|
+
Logger.withMinimumLogLevel(LogLevel.None),
|
|
40
|
+
)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it.effect('two different channels registered under one name are rejected', () => {
|
|
44
|
+
class Merge extends Channel.make('Dup', { policy: { _tag: 'merge' } }) {}
|
|
45
|
+
class Latest extends Channel.make('Dup', { policy: { _tag: 'latest' } }) {}
|
|
46
|
+
const layer = Layer.mergeAll(Channel.live(Merge), Channel.live(Latest)).pipe(
|
|
47
|
+
Layer.provideMerge(Engine),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
return Effect.gen(function* () {
|
|
51
|
+
const exit = yield* Effect.scoped(Layer.build(layer)).pipe(Effect.exit)
|
|
52
|
+
expect(Exit.isFailure(exit)).toBe(true)
|
|
53
|
+
if (Exit.isFailure(exit)) {
|
|
54
|
+
expect(Cause.pretty(exit.cause)).toContain('duplicate Channel')
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it.effect('the same channel registered twice is idempotent (shared lane)', () => {
|
|
60
|
+
class Lane extends Channel.make('Lane', { policy: { _tag: 'exclusive' } }) {}
|
|
61
|
+
const layer = Layer.mergeAll(Channel.live(Lane), Channel.live(Lane)).pipe(
|
|
62
|
+
Layer.provideMerge(Engine),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
return Effect.gen(function* () {
|
|
66
|
+
const exit = yield* Effect.scoped(Layer.build(layer)).pipe(Effect.exit)
|
|
67
|
+
expect(Exit.isSuccess(exit)).toBe(true)
|
|
68
|
+
})
|
|
69
|
+
})
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { expect, it } from '@effect/vitest'
|
|
2
|
+
import { Duration, Effect, Layer, Schema as S, TestClock } from 'effect'
|
|
3
|
+
import { Channel, Engine, Event, Procedure, publish, Reducer, State } from '../index'
|
|
4
|
+
|
|
5
|
+
// Headless tests for the single frame-batched drain loop and channels, written
|
|
6
|
+
// with `@effect/vitest`: `it.live` for cases that lean on the real microtask
|
|
7
|
+
// queue (the store notifier), `it.effect` + `TestClock` for time-based channels.
|
|
8
|
+
|
|
9
|
+
it.live('a frame of events drains in one pass with a single notification flush', () => {
|
|
10
|
+
class Counter extends State.make('counter', S.Number) {}
|
|
11
|
+
class Bumped extends Event.make('Bumped', S.Struct({ to: S.Number })) {}
|
|
12
|
+
class BumpReducer extends Reducer.make('BumpReducer', { states: [Counter], events: [Bumped] }) {}
|
|
13
|
+
const BumpReducerLive = Reducer.live(BumpReducer, (_, event) => event.to)
|
|
14
|
+
|
|
15
|
+
const TestLayer = BumpReducerLive.pipe(
|
|
16
|
+
Layer.provideMerge(Layer.mergeAll(State.live(Counter, 0), Engine)),
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
return Effect.gen(function* () {
|
|
20
|
+
const store = yield* Counter.store
|
|
21
|
+
const notifications = { n: 0 }
|
|
22
|
+
store.subscribe(() => {
|
|
23
|
+
notifications.n += 1
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
// Three events in one tick batch into one frame, fold in order, and the
|
|
27
|
+
// coalesced store notifier wakes the subscriber exactly once.
|
|
28
|
+
yield* publish('Normal', Event.construct(Bumped, { to: 1 }))
|
|
29
|
+
yield* publish('Normal', Event.construct(Bumped, { to: 2 }))
|
|
30
|
+
yield* publish('Normal', Event.construct(Bumped, { to: 3 }))
|
|
31
|
+
yield* Effect.sleep(Duration.millis(20))
|
|
32
|
+
|
|
33
|
+
expect(store.get()).toBe(3)
|
|
34
|
+
expect(notifications.n).toBe(1)
|
|
35
|
+
}).pipe(Effect.provide(TestLayer))
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it.live('an exclusive channel serializes procedures that share it', () => {
|
|
39
|
+
class StartA extends Event.make('StartA', S.Struct({})) {}
|
|
40
|
+
class StartB extends Event.make('StartB', S.Struct({})) {}
|
|
41
|
+
class Exclusive extends Channel.make('Exclusive', { policy: { _tag: 'exclusive' } }) {}
|
|
42
|
+
|
|
43
|
+
const concurrency = { active: 0, max: 0 }
|
|
44
|
+
const guarded = function* () {
|
|
45
|
+
concurrency.active += 1
|
|
46
|
+
concurrency.max = Math.max(concurrency.max, concurrency.active)
|
|
47
|
+
yield* Effect.sleep(Duration.millis(15))
|
|
48
|
+
concurrency.active -= 1
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
class ProcA extends Procedure.make('ProcA', { events: [StartA], channel: Exclusive }) {}
|
|
52
|
+
class ProcB extends Procedure.make('ProcB', { events: [StartB], channel: Exclusive }) {}
|
|
53
|
+
const ProcALive = Procedure.live(ProcA, guarded)
|
|
54
|
+
const ProcBLive = Procedure.live(ProcB, guarded)
|
|
55
|
+
|
|
56
|
+
const TestLayer = Layer.mergeAll(Channel.live(Exclusive), ProcALive, ProcBLive).pipe(
|
|
57
|
+
Layer.provideMerge(Engine),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
return Effect.gen(function* () {
|
|
61
|
+
yield* publish('Normal', Event.construct(StartA, {}))
|
|
62
|
+
yield* publish('Normal', Event.construct(StartB, {}))
|
|
63
|
+
yield* Effect.sleep(Duration.millis(60))
|
|
64
|
+
// Without exclusivity both would run concurrently (max === 2).
|
|
65
|
+
expect(concurrency.max).toBe(1)
|
|
66
|
+
}).pipe(Effect.provide(TestLayer))
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it.live('two procedures sharing a channel each run once for one shared event', () => {
|
|
70
|
+
// Both procedures handle the SAME event on the SAME channel. The loop must
|
|
71
|
+
// offer the event to the channel once, so each procedure runs exactly once —
|
|
72
|
+
// not once per matching procedure (which would double every run).
|
|
73
|
+
class Shared extends Event.make('Shared', S.Struct({})) {}
|
|
74
|
+
class Lane extends Channel.make('Lane', { policy: { _tag: 'merge' } }) {}
|
|
75
|
+
|
|
76
|
+
const runs = { a: 0, b: 0 }
|
|
77
|
+
class ProcA extends Procedure.make('ProcA', { events: [Shared], channel: Lane }) {}
|
|
78
|
+
class ProcB extends Procedure.make('ProcB', { events: [Shared], channel: Lane }) {}
|
|
79
|
+
const ProcALive = Procedure.live(ProcA, function* () {
|
|
80
|
+
runs.a += 1
|
|
81
|
+
})
|
|
82
|
+
const ProcBLive = Procedure.live(ProcB, function* () {
|
|
83
|
+
runs.b += 1
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const TestLayer = Layer.mergeAll(Channel.live(Lane), ProcALive, ProcBLive).pipe(
|
|
87
|
+
Layer.provideMerge(Engine),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
return Effect.gen(function* () {
|
|
91
|
+
yield* publish('High', Event.construct(Shared, {}))
|
|
92
|
+
yield* Effect.sleep(Duration.millis(20))
|
|
93
|
+
expect(runs.a).toBe(1)
|
|
94
|
+
expect(runs.b).toBe(1)
|
|
95
|
+
}).pipe(Effect.provide(TestLayer))
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it.effect('a debounce channel collapses a burst into a single run', () => {
|
|
99
|
+
class Typed extends Event.make('Typed', S.Struct({})) {}
|
|
100
|
+
class Debounced extends Channel.make('Debounced', {
|
|
101
|
+
policy: { _tag: 'debounce', duration: Duration.millis(30) },
|
|
102
|
+
}) {}
|
|
103
|
+
|
|
104
|
+
const runs = { n: 0 }
|
|
105
|
+
class Search extends Procedure.make('Search', { events: [Typed], channel: Debounced }) {}
|
|
106
|
+
const SearchLive = Procedure.live(Search, function* () {
|
|
107
|
+
runs.n += 1
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
const TestLayer = Layer.mergeAll(Channel.live(Debounced), SearchLive).pipe(
|
|
111
|
+
Layer.provideMerge(Engine),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
return Effect.gen(function* () {
|
|
115
|
+
yield* publish('Normal', Event.construct(Typed, {}))
|
|
116
|
+
yield* publish('Normal', Event.construct(Typed, {}))
|
|
117
|
+
yield* publish('Normal', Event.construct(Typed, {}))
|
|
118
|
+
// Let the loop route the frame into the channel and arm the debounce window.
|
|
119
|
+
yield* Effect.yieldNow().pipe(Effect.repeatN(20))
|
|
120
|
+
// Advance the simulated clock past the window: the burst emits once.
|
|
121
|
+
yield* TestClock.adjust(Duration.millis(31))
|
|
122
|
+
yield* Effect.yieldNow().pipe(Effect.repeatN(20))
|
|
123
|
+
expect(runs.n).toBe(1)
|
|
124
|
+
}).pipe(Effect.provide(TestLayer))
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it.live('a reducer ignores events outside its declared set (no-op)', () => {
|
|
128
|
+
class Counter extends State.make('counter', S.Number) {}
|
|
129
|
+
class Bump extends Event.make('Bump', S.Struct({})) {}
|
|
130
|
+
class Unrelated extends Event.make('Unrelated', S.Struct({})) {}
|
|
131
|
+
class BumpReducer extends Reducer.make('BumpReducer', { states: [Counter], events: [Bump] }) {}
|
|
132
|
+
const BumpReducerLive = Reducer.live(BumpReducer, (n) => n + 1)
|
|
133
|
+
|
|
134
|
+
const TestLayer = BumpReducerLive.pipe(
|
|
135
|
+
Layer.provideMerge(Layer.mergeAll(State.live(Counter, 0), Engine)),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
return Effect.gen(function* () {
|
|
139
|
+
const store = yield* Counter.store
|
|
140
|
+
// An event the reducer doesn't declare never reaches its fold.
|
|
141
|
+
yield* publish('Normal', Event.construct(Unrelated, {}))
|
|
142
|
+
yield* Effect.sleep(Duration.millis(10))
|
|
143
|
+
expect(store.get()).toBe(0)
|
|
144
|
+
// A declared event does.
|
|
145
|
+
yield* publish('Normal', Event.construct(Bump, {}))
|
|
146
|
+
yield* Effect.sleep(Duration.millis(10))
|
|
147
|
+
expect(store.get()).toBe(1)
|
|
148
|
+
}).pipe(Effect.provide(TestLayer))
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it.effect('a latest channel cancels the in-flight run when a new event arrives', () => {
|
|
152
|
+
class Tick extends Event.make('Tick', S.Struct({})) {}
|
|
153
|
+
class Latest extends Channel.make('Latest', { policy: { _tag: 'latest' } }) {}
|
|
154
|
+
|
|
155
|
+
const counts = { started: 0, completed: 0 }
|
|
156
|
+
class Work extends Procedure.make('Work', { events: [Tick], channel: Latest }) {}
|
|
157
|
+
const WorkLive = Procedure.live(Work, function* () {
|
|
158
|
+
counts.started += 1
|
|
159
|
+
yield* Effect.sleep(Duration.millis(50))
|
|
160
|
+
counts.completed += 1
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
const TestLayer = Layer.mergeAll(Channel.live(Latest), WorkLive).pipe(Layer.provideMerge(Engine))
|
|
164
|
+
|
|
165
|
+
return Effect.gen(function* () {
|
|
166
|
+
// First tick starts a run that parks on its 50ms sleep.
|
|
167
|
+
yield* publish('Normal', Event.construct(Tick, {}))
|
|
168
|
+
yield* Effect.yieldNow().pipe(Effect.repeatN(20))
|
|
169
|
+
// Second tick switches the channel, interrupting the first run mid-sleep.
|
|
170
|
+
yield* publish('Normal', Event.construct(Tick, {}))
|
|
171
|
+
yield* Effect.yieldNow().pipe(Effect.repeatN(20))
|
|
172
|
+
yield* TestClock.adjust(Duration.millis(60))
|
|
173
|
+
yield* Effect.yieldNow().pipe(Effect.repeatN(20))
|
|
174
|
+
// Both runs began, but only the latest survived to completion.
|
|
175
|
+
expect(counts.started).toBe(2)
|
|
176
|
+
expect(counts.completed).toBe(1)
|
|
177
|
+
}).pipe(Effect.provide(TestLayer))
|
|
178
|
+
})
|
package/src/runtime/loop.ts
CHANGED
|
@@ -39,7 +39,9 @@ export interface ReducerRegistry {
|
|
|
39
39
|
readonly unregister: (entry: ReducerEntry) => void
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
const ReducersBase: Context.TagClass<Reducers, 'reform/Reducers', ReducerRegistry> =
|
|
43
|
+
Context.Tag('reform/Reducers')<Reducers, ReducerRegistry>()
|
|
44
|
+
export class Reducers extends ReducersBase {}
|
|
43
45
|
|
|
44
46
|
/** Drop an item from a `Map<string, Array>` bucket, pruning the key when it empties. */
|
|
45
47
|
const dropFromBuckets = <T>(map: Map<string, Array<T>>, key: string, item: T): void => {
|
|
@@ -72,7 +74,7 @@ const makeReducerRegistry = (): ReducerRegistry => {
|
|
|
72
74
|
}
|
|
73
75
|
}
|
|
74
76
|
|
|
75
|
-
export const reducersLayer = Layer.sync(Reducers, makeReducerRegistry)
|
|
77
|
+
export const reducersLayer: Layer.Layer<Reducers> = Layer.sync(Reducers, makeReducerRegistry)
|
|
76
78
|
|
|
77
79
|
/** High before Normal; within a priority class, dispatch order is preserved. */
|
|
78
80
|
const rank = (priority: Envelope['priority']): number => (priority === 'High' ? 0 : 1)
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { Effect, Layer, ManagedRuntime, Schema as S } from 'effect'
|
|
2
|
+
import { expect, test } from 'vitest'
|
|
3
|
+
import {
|
|
4
|
+
type CaptureSinkApi,
|
|
5
|
+
CaptureSink,
|
|
6
|
+
Composition,
|
|
7
|
+
Engine,
|
|
8
|
+
Event,
|
|
9
|
+
provide,
|
|
10
|
+
publish,
|
|
11
|
+
Reducer,
|
|
12
|
+
type RenderEnv,
|
|
13
|
+
type Scene,
|
|
14
|
+
scene,
|
|
15
|
+
seedScene,
|
|
16
|
+
State,
|
|
17
|
+
StateGroup,
|
|
18
|
+
Ui,
|
|
19
|
+
ui,
|
|
20
|
+
} from '../index'
|
|
21
|
+
|
|
22
|
+
// `seedScene` is the tooling seam over CLOSED scenes: the app bundle below seeds
|
|
23
|
+
// `count: 1` itself (no open store), and the overlay must reach the nested
|
|
24
|
+
// `State.live` through `Layer.locally(CurrentSeedOverrides, …)` on the scene's
|
|
25
|
+
// pre-composed layers. The harness mirrors the dev-tool preview: merge the
|
|
26
|
+
// scene's `provide`, mount it in a ManagedRuntime, and read the root
|
|
27
|
+
// composition's rendered props through the headless CaptureSink.
|
|
28
|
+
|
|
29
|
+
class CountState extends State.make('count', S.Number) {}
|
|
30
|
+
class MiniStates extends StateGroup.make(CountState) {}
|
|
31
|
+
|
|
32
|
+
class Bumped extends Event.make('Bumped', S.Struct({})) {}
|
|
33
|
+
|
|
34
|
+
class BumpReducer extends Reducer.make('BumpReducer', {
|
|
35
|
+
states: [CountState],
|
|
36
|
+
events: [Bumped],
|
|
37
|
+
}) {}
|
|
38
|
+
const BumpReducerLive = Reducer.live(BumpReducer, (n) => n + 1)
|
|
39
|
+
|
|
40
|
+
class CounterUi extends ui('Counter')<{ props: { count: number } }>() {}
|
|
41
|
+
class Counter extends Composition.make('Counter', {
|
|
42
|
+
title: 'Counter',
|
|
43
|
+
states: [MiniStates],
|
|
44
|
+
events: [Bumped],
|
|
45
|
+
ui: CounterUi,
|
|
46
|
+
}) {}
|
|
47
|
+
const CounterLive = Composition.live(Counter, function* () {
|
|
48
|
+
const count = yield* StateGroup.select(MiniStates, 'count')
|
|
49
|
+
const view = yield* CounterUi
|
|
50
|
+
return view({ count })
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// The closed app layer a scene provides: views + logic + Engine, with the state
|
|
54
|
+
// group seeding its AUTHORED value `count: 1` itself.
|
|
55
|
+
const presentations = provide(CounterUi, Ui.make(CounterUi, () => null))
|
|
56
|
+
const Views = CounterLive.pipe(Layer.provideMerge(presentations))
|
|
57
|
+
const Logic = Layer.mergeAll(BumpReducerLive).pipe(
|
|
58
|
+
Layer.provideMerge(Layer.mergeAll(Engine, StateGroup.live(MiniStates, { count: 1 }))),
|
|
59
|
+
)
|
|
60
|
+
const MiniApp = Views.pipe(Layer.provideMerge(Logic))
|
|
61
|
+
|
|
62
|
+
const CounterScene = scene(Counter, { provide: [MiniApp] })
|
|
63
|
+
|
|
64
|
+
const headlessEnv: RenderEnv = {
|
|
65
|
+
props: {},
|
|
66
|
+
tracker: { add: () => {} },
|
|
67
|
+
slots: { slot: () => () => null },
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// One ManagedRuntime per scene under test, with its own capture cell so two
|
|
71
|
+
// runtimes in the same test (the no-leakage case) cannot bleed into each other.
|
|
72
|
+
// Each `read` re-renders the root composition, repopulating the cell from the
|
|
73
|
+
// stores that runtime actually built.
|
|
74
|
+
const makeHarness = (s: Scene) => {
|
|
75
|
+
const captured: { count: number } = { count: -1 }
|
|
76
|
+
const sink: CaptureSinkApi = {
|
|
77
|
+
record: (capture) => {
|
|
78
|
+
const props = capture.props
|
|
79
|
+
if (
|
|
80
|
+
typeof props === 'object' &&
|
|
81
|
+
props !== null &&
|
|
82
|
+
'count' in props &&
|
|
83
|
+
typeof props.count === 'number'
|
|
84
|
+
) {
|
|
85
|
+
captured.count = props.count
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
const layer = s.provide
|
|
90
|
+
.reduce((a, b) => Layer.merge(a, b))
|
|
91
|
+
.pipe(Layer.provideMerge(Layer.succeed(CaptureSink, sink)))
|
|
92
|
+
const runtime = ManagedRuntime.make(layer)
|
|
93
|
+
const read = Effect.flatMap(Counter.tag, (c) =>
|
|
94
|
+
Composition.render(c, headlessEnv).pipe(Effect.map(() => captured.count)),
|
|
95
|
+
)
|
|
96
|
+
return { runtime, read }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Drive a read until a predicate holds, handing forked fibers the scheduler.
|
|
100
|
+
const waitUntil = <A, R>(
|
|
101
|
+
read: Effect.Effect<A, never, R>,
|
|
102
|
+
pred: (a: A) => boolean,
|
|
103
|
+
rounds = 1000,
|
|
104
|
+
): Effect.Effect<A, never, R> =>
|
|
105
|
+
Effect.flatMap(read, (a) =>
|
|
106
|
+
pred(a) || rounds <= 0
|
|
107
|
+
? Effect.succeed(a)
|
|
108
|
+
: Effect.flatMap(Effect.yieldNow(), () => waitUntil(read, pred, rounds - 1)),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
test('a seeded scene boots its store from the override', async () => {
|
|
112
|
+
const { runtime, read } = makeHarness(seedScene(CounterScene, { count: 5 }))
|
|
113
|
+
try {
|
|
114
|
+
expect(await runtime.runPromise(read)).toBe(5)
|
|
115
|
+
} finally {
|
|
116
|
+
await runtime.dispose()
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('the live reducer drives the SAME overridden store', async () => {
|
|
121
|
+
const { runtime, read } = makeHarness(seedScene(CounterScene, { count: 5 }))
|
|
122
|
+
try {
|
|
123
|
+
const next = await runtime.runPromise(
|
|
124
|
+
Effect.gen(function* () {
|
|
125
|
+
expect(yield* read).toBe(5)
|
|
126
|
+
yield* publish('High', Event.construct(Bumped, {}))
|
|
127
|
+
return yield* waitUntil(read, (n) => n === 6)
|
|
128
|
+
}),
|
|
129
|
+
)
|
|
130
|
+
expect(next).toBe(6)
|
|
131
|
+
} finally {
|
|
132
|
+
await runtime.dispose()
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('an absent key leaves the authored seed in place', async () => {
|
|
137
|
+
const { runtime, read } = makeHarness(seedScene(CounterScene, { other: 1 }))
|
|
138
|
+
try {
|
|
139
|
+
expect(await runtime.runPromise(read)).toBe(1)
|
|
140
|
+
} finally {
|
|
141
|
+
await runtime.dispose()
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('a schema-invalid override falls back to the authored seed', async () => {
|
|
146
|
+
const { runtime, read } = makeHarness(seedScene(CounterScene, { count: 'not-a-number' }))
|
|
147
|
+
try {
|
|
148
|
+
expect(await runtime.runPromise(read)).toBe(1)
|
|
149
|
+
} finally {
|
|
150
|
+
await runtime.dispose()
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('seeding does not leak into the original scene', async () => {
|
|
155
|
+
const seeded = makeHarness(seedScene(CounterScene, { count: 5 }))
|
|
156
|
+
try {
|
|
157
|
+
expect(await seeded.runtime.runPromise(seeded.read)).toBe(5)
|
|
158
|
+
} finally {
|
|
159
|
+
await seeded.runtime.dispose()
|
|
160
|
+
}
|
|
161
|
+
// Building the ORIGINAL scene's layers again boots from the authored seed —
|
|
162
|
+
// the overlay lives only on the seeded variant's wrapped layers.
|
|
163
|
+
const original = makeHarness(CounterScene)
|
|
164
|
+
try {
|
|
165
|
+
expect(await original.runtime.runPromise(original.read)).toBe(1)
|
|
166
|
+
} finally {
|
|
167
|
+
await original.runtime.dispose()
|
|
168
|
+
}
|
|
169
|
+
})
|
package/src/state/state.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Context, Effect, FiberRef, Layer, Option, Schema } from 'effect'
|
|
|
2
2
|
import { type Manifest, yieldableClass } from '../definition/definition'
|
|
3
3
|
import { resolveScheduler } from '../internal/scheduler'
|
|
4
4
|
import { CurrentSeedOverrides } from '../internal/seeds'
|
|
5
|
+
import { claimStateTag } from '../internal/stateRegistry'
|
|
5
6
|
import { makeStore, type Store } from '../internal/store'
|
|
6
7
|
import { readTracked } from '../internal/track'
|
|
7
8
|
|
|
@@ -40,7 +41,9 @@ export const make = <const N extends string, A>(
|
|
|
40
41
|
schema: Schema.Schema<A, any>,
|
|
41
42
|
options: StateOptions = {},
|
|
42
43
|
): StateClass<N, A> => {
|
|
43
|
-
const
|
|
44
|
+
const identifier = `reform/state/${name}`
|
|
45
|
+
claimStateTag(identifier)
|
|
46
|
+
const store = Context.GenericTag<Store<A>, Store<A>>(identifier)
|
|
44
47
|
const manifest: StateManifest<N, A> = {
|
|
45
48
|
kind: 'State',
|
|
46
49
|
name,
|