@playfast/reform 0.1.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +16 -44
- package/src/calc/calcFamily.test.ts +0 -12
- package/src/calc/calcFamily.ts +12 -49
- 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 +78 -173
- 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 -202
- 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 +48 -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 +10 -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 +131 -0
- 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 +324 -0
- package/src/runtime/instrumentation.ts +44 -0
- package/src/runtime/loop.test.ts +0 -17
- package/src/runtime/loop.ts +41 -58
- package/src/runtime/queries.ts +0 -17
- package/src/scene/featureScene.test.ts +59 -0
- package/src/scene/scene.ts +51 -44
- package/src/scene/seedScene.test.ts +96 -116
- package/src/state/state.ts +12 -28
- package/src/state/stateFamily.test.ts +2 -11
- package/src/state/stateFamily.ts +20 -54
- 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
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Context, Effect, Layer, Schema as S } from 'effect'
|
|
2
|
+
import { expect, test } from 'vitest'
|
|
3
|
+
import * as Composition from '../compose/composition'
|
|
4
|
+
import { mount } from '../compose/structure'
|
|
5
|
+
import { ui } from '../compose/ui'
|
|
6
|
+
import * as Event from '../event/event'
|
|
7
|
+
import * as Feature from '../feature/feature'
|
|
8
|
+
import { featureModule } from '../feature/feature'
|
|
9
|
+
import { makeAppRuntime } from '../runtime/appRuntime'
|
|
10
|
+
import { Engine } from '../runtime/loop'
|
|
11
|
+
import { featureScene } from './scene'
|
|
12
|
+
|
|
13
|
+
interface GreetingService {
|
|
14
|
+
readonly value: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const GreetingBase: Context.TagClass<Greeting, 'featureScene.Greeting', GreetingService> =
|
|
18
|
+
Context.Tag('featureScene.Greeting')<Greeting, GreetingService>()
|
|
19
|
+
class Greeting extends GreetingBase {}
|
|
20
|
+
|
|
21
|
+
class Started extends Event.make('featureScene.Started', S.Struct({})) {}
|
|
22
|
+
class RootUi extends ui('featureScene.RootUi')<{ props: { readonly greeting: string } }>() {}
|
|
23
|
+
class RootComp extends Composition.make('featureScene.RootComp', {
|
|
24
|
+
title: 'Feature root',
|
|
25
|
+
ui: RootUi,
|
|
26
|
+
}) {}
|
|
27
|
+
|
|
28
|
+
const RootLive = Composition.live(RootComp, function* () {
|
|
29
|
+
const greeting = yield* Greeting
|
|
30
|
+
return mount({ props: { greeting: greeting.value }, slots: {} })
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
class RootFeature extends Feature.make('featureScene.root', {
|
|
34
|
+
composition: RootComp,
|
|
35
|
+
module: featureModule([Greeting], RootLive),
|
|
36
|
+
boot: [Event.construct(Started, {})],
|
|
37
|
+
}) {}
|
|
38
|
+
|
|
39
|
+
test('featureScene closes an eager root Feature with its typed requirements and boot', () => {
|
|
40
|
+
const root = featureScene(RootFeature, {
|
|
41
|
+
provide: Layer.mergeAll(Engine, Layer.succeed(Greeting, { value: 'hello' })),
|
|
42
|
+
})
|
|
43
|
+
const app = makeAppRuntime(root)
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const rendered = Effect.runSync(
|
|
47
|
+
Composition.render(app.read(RootComp.tag), {
|
|
48
|
+
props: {},
|
|
49
|
+
tracker: { add: () => {} },
|
|
50
|
+
}),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
expect(root.composition).toBe(RootComp)
|
|
54
|
+
expect(root.boot).toEqual([Event.construct(Started, {})])
|
|
55
|
+
expect(rendered.props).toEqual({ greeting: 'hello' })
|
|
56
|
+
} finally {
|
|
57
|
+
app.dispose()
|
|
58
|
+
}
|
|
59
|
+
})
|
package/src/scene/scene.ts
CHANGED
|
@@ -2,33 +2,23 @@ import { Layer, Struct } from 'effect'
|
|
|
2
2
|
import type { CompositionClass, CompositionService } from '../compose/composition'
|
|
3
3
|
import type { UiContract } from '../compose/ui'
|
|
4
4
|
import type { EventOf } from '../event/event'
|
|
5
|
+
import type {
|
|
6
|
+
EagerFeatureClass,
|
|
7
|
+
EngineServices,
|
|
8
|
+
FeatureRequirement,
|
|
9
|
+
ProvidedBy,
|
|
10
|
+
} from '../feature/feature'
|
|
5
11
|
import { CurrentSeedOverrides } from '../internal/seeds'
|
|
6
12
|
import type { SeedsOf } from '../state/stateGroup'
|
|
7
13
|
import type { Bus } from '../runtime/bus'
|
|
14
|
+
import {
|
|
15
|
+
CurrentInstrumentation,
|
|
16
|
+
type Instrumentation,
|
|
17
|
+
noopInstrumentation,
|
|
18
|
+
} from '../runtime/instrumentation'
|
|
8
19
|
|
|
9
|
-
// A scene is a VALUE — a composition plus the closed wiring that runs it: the
|
|
10
|
-
// layers that supply its logic/views (each seeding its own live state) and the
|
|
11
|
-
// boot events to dispatch once the runtime is live. Nothing self-registers on
|
|
12
|
-
// import. A scene is the single handle every consumer takes instead of a bare
|
|
13
|
-
// composition: the react host renders it, a proof runs against it, and the dev
|
|
14
|
-
// tool previews it — all from the same closed `provide`.
|
|
15
|
-
//
|
|
16
|
-
// There is no seeded-`state` dict: variation (Default/Empty/Errored) comes from
|
|
17
|
-
// providing different closed layers, exactly as production wiring does. The
|
|
18
|
-
// composition's contract `C` is preserved (not erased to `unknown`) so a proof's
|
|
19
|
-
// facade stays fully typed from `scene.composition`.
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* The services a built reform runtime exposes to its host: every composition's
|
|
23
|
-
* logic and the event `Bus`. A scene's `provide` must close to these (a fully
|
|
24
|
-
* wired app layer provides far more; this is the subset hosts read directly).
|
|
25
|
-
*/
|
|
26
20
|
export type MountedServices = CompositionService | Bus
|
|
27
21
|
|
|
28
|
-
// A scene's boot list holds CONSTRUCTED events (`Event.construct(E, payload)`),
|
|
29
|
-
// not bare tagged objects — a lightweight nudge toward the real builder. It is
|
|
30
|
-
// not a guarantee the event is handled by the wiring (reform carries no
|
|
31
|
-
// type-level union of handled tags), only that it came from an event definition.
|
|
32
22
|
type BootEvent = EventOf<string, unknown>
|
|
33
23
|
|
|
34
24
|
export interface Scene<
|
|
@@ -36,27 +26,21 @@ export interface Scene<
|
|
|
36
26
|
S extends ReadonlyArray<unknown> = ReadonlyArray<unknown>,
|
|
37
27
|
> {
|
|
38
28
|
readonly kind: 'Scene'
|
|
39
|
-
/** The composition to run, with its contract + states preserved for typed consumers. */
|
|
40
29
|
readonly composition: CompositionClass<unknown, C, S>
|
|
41
|
-
/** Closed wiring (logic + views), each layer seeding its own live state. */
|
|
42
30
|
readonly provide: ReadonlyArray<Layer.Layer<MountedServices, never, never>>
|
|
43
|
-
/** Events dispatched once the runtime is live (e.g. `RequestedTodos`). */
|
|
44
31
|
// oxlint-disable-next-line reform-rules/no-optional-fields -- presence-optional boot read as `scene.boot ?? []` across non-batch hosts (drive/proof/remote/react/editor); Option would break them
|
|
45
32
|
readonly boot?: ReadonlyArray<BootEvent>
|
|
33
|
+
// instrumentation on the scene (not only layers): host render spans live outside layer context
|
|
34
|
+
// oxlint-disable-next-line reform-rules/no-optional-fields -- presence-optional carrier like `boot`; absent on every unprofiled scene, so Option would tax all authoring call sites
|
|
35
|
+
readonly instrumentation?: Instrumentation
|
|
46
36
|
}
|
|
47
37
|
|
|
48
|
-
/** Closed wiring + optional boot events handed to {@link scene}. */
|
|
49
38
|
export interface SceneConfig {
|
|
50
39
|
readonly provide: ReadonlyArray<Layer.Layer<MountedServices, never, never>>
|
|
51
40
|
// oxlint-disable-next-line reform-rules/no-optional-fields -- mirrors Scene.boot; presence-optional to keep the authoring call site terse and back-compatible
|
|
52
41
|
readonly boot?: ReadonlyArray<BootEvent>
|
|
53
42
|
}
|
|
54
43
|
|
|
55
|
-
/**
|
|
56
|
-
* Define a scene — `scene(TodoApp, { provide: [makeTestApp(client, seeds)] })`.
|
|
57
|
-
* The composition's contract `C` and state tuple `S` flow through, so consumers
|
|
58
|
-
* (the facade, `seedScene`) stay typed.
|
|
59
|
-
*/
|
|
60
44
|
export const scene = <C extends UiContract, S extends ReadonlyArray<unknown>>(
|
|
61
45
|
composition: CompositionClass<unknown, C, S>,
|
|
62
46
|
config: SceneConfig,
|
|
@@ -67,19 +51,31 @@ export const scene = <C extends UiContract, S extends ReadonlyArray<unknown>>(
|
|
|
67
51
|
...(config.boot !== undefined ? { boot: config.boot } : {}),
|
|
68
52
|
})
|
|
69
53
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
54
|
+
export interface FeatureSceneConfig<
|
|
55
|
+
Requires extends ReadonlyArray<FeatureRequirement>,
|
|
56
|
+
> {
|
|
57
|
+
readonly provide: Layer.Layer<EngineServices | ProvidedBy<Requires>, never, never>
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const featureScene = <
|
|
61
|
+
P,
|
|
62
|
+
C extends UiContract,
|
|
63
|
+
ROut,
|
|
64
|
+
Requires extends ReadonlyArray<FeatureRequirement>,
|
|
65
|
+
>(
|
|
66
|
+
feature: EagerFeatureClass<P, C, ROut, Requires>,
|
|
67
|
+
config: FeatureSceneConfig<Requires>,
|
|
68
|
+
): Scene<C> => {
|
|
69
|
+
const app = feature.eagerModule.layer.pipe(Layer.provideMerge(config.provide))
|
|
70
|
+
// oxlint-disable-next-line reform-rules/no-type-assertion -- close the generic root module's erased output to the host-readable Scene subset
|
|
71
|
+
const closed = app as Layer.Layer<MountedServices, never, never>
|
|
72
|
+
return scene(feature.composition, {
|
|
73
|
+
provide: [closed],
|
|
74
|
+
boot: feature.binding.boot,
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Tooling seam: wrap closed layers with seed overrides; keys are member names (not group names)
|
|
83
79
|
export const seedScene = <C extends UiContract, S extends ReadonlyArray<unknown>>(
|
|
84
80
|
base: Scene<C, S>,
|
|
85
81
|
seeds: SeedsOf<S>,
|
|
@@ -88,7 +84,18 @@ export const seedScene = <C extends UiContract, S extends ReadonlyArray<unknown>
|
|
|
88
84
|
? base
|
|
89
85
|
: { ...base, provide: base.provide.map(Layer.locally(CurrentSeedOverrides, seeds)) }
|
|
90
86
|
|
|
91
|
-
|
|
87
|
+
export const profiledScene = <C extends UiContract, S extends ReadonlyArray<unknown>>(
|
|
88
|
+
base: Scene<C, S>,
|
|
89
|
+
instrumentation: Instrumentation,
|
|
90
|
+
): Scene<C, S> => ({
|
|
91
|
+
...base,
|
|
92
|
+
instrumentation,
|
|
93
|
+
provide: base.provide.map(Layer.locally(CurrentInstrumentation, instrumentation)),
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
export const sceneInstrumentation = (candidate: Scene): Instrumentation =>
|
|
97
|
+
candidate.instrumentation ?? noopInstrumentation
|
|
98
|
+
|
|
92
99
|
export const isScene = (candidate: unknown): candidate is Scene =>
|
|
93
100
|
typeof candidate === 'object' &&
|
|
94
101
|
candidate !== null &&
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Effect, Layer,
|
|
2
|
-
import { expect,
|
|
1
|
+
import { Effect, Layer, Schema as S } from "effect";
|
|
2
|
+
import { expect, it } from "@effect/vitest";
|
|
3
3
|
import {
|
|
4
4
|
Composition,
|
|
5
5
|
Engine,
|
|
@@ -17,93 +17,84 @@ import {
|
|
|
17
17
|
StateGroup,
|
|
18
18
|
Ui,
|
|
19
19
|
ui,
|
|
20
|
-
} from
|
|
21
|
-
import { CurrentSeedOverrides } from
|
|
20
|
+
} from "../index";
|
|
21
|
+
import { CurrentSeedOverrides } from "../internal/seeds";
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
// `count: 1` itself (no open store), and the overlay must reach the nested
|
|
25
|
-
// `State.live` through `Layer.locally(CurrentSeedOverrides, …)` on the scene's
|
|
26
|
-
// pre-composed layers. The harness mirrors the dev-tool preview: merge the
|
|
27
|
-
// scene's `provide`, mount it in a ManagedRuntime, and read the root
|
|
28
|
-
// composition's rendered props through the headless Structure.
|
|
29
|
-
|
|
30
|
-
class CountState extends State.make('count', S.Number) {}
|
|
23
|
+
class CountState extends State.make("count", S.Number) {}
|
|
31
24
|
class MiniStates extends StateGroup.make(CountState) {}
|
|
32
25
|
|
|
33
|
-
class Bumped extends Event.make(
|
|
26
|
+
class Bumped extends Event.make("Bumped", S.Struct({})) {}
|
|
34
27
|
|
|
35
|
-
class BumpReducer extends Reducer.make(
|
|
28
|
+
class BumpReducer extends Reducer.make("BumpReducer", {
|
|
36
29
|
states: [CountState],
|
|
37
30
|
events: [Bumped],
|
|
38
31
|
}) {}
|
|
39
|
-
const BumpReducerLive = Reducer.live(BumpReducer, (n) => n + 1)
|
|
32
|
+
const BumpReducerLive = Reducer.live(BumpReducer, (n) => n + 1);
|
|
40
33
|
|
|
41
|
-
class CounterUi extends ui(
|
|
42
|
-
class Counter extends Composition.make(
|
|
43
|
-
title:
|
|
34
|
+
class CounterUi extends ui("Counter")<{ props: { count: number } }>() {}
|
|
35
|
+
class Counter extends Composition.make("Counter", {
|
|
36
|
+
title: "Counter",
|
|
44
37
|
states: [MiniStates],
|
|
45
38
|
events: [Bumped],
|
|
46
39
|
ui: CounterUi,
|
|
47
40
|
}) {}
|
|
48
41
|
const CounterLive = Composition.live(Counter, function* () {
|
|
49
|
-
const count = yield* StateGroup.select(MiniStates,
|
|
50
|
-
return mount({ props: { count }, slots: {} })
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
42
|
+
const count = yield* StateGroup.select(MiniStates, "count");
|
|
43
|
+
return mount({ props: { count }, slots: {} });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const presentations = provide(
|
|
47
|
+
CounterUi,
|
|
48
|
+
Ui.make(CounterUi, () => null),
|
|
49
|
+
);
|
|
50
|
+
const Views = CounterLive.pipe(Layer.provideMerge(presentations));
|
|
57
51
|
const Logic = Layer.mergeAll(BumpReducerLive).pipe(
|
|
58
|
-
Layer.provideMerge(
|
|
59
|
-
)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
52
|
+
Layer.provideMerge(
|
|
53
|
+
Layer.mergeAll(Engine, StateGroup.live(MiniStates, { count: 1 })),
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
const MiniApp = Views.pipe(Layer.provideMerge(Logic));
|
|
57
|
+
|
|
58
|
+
const CounterScene = scene(Counter, { provide: [MiniApp] });
|
|
59
|
+
|
|
60
|
+
const overlayRawSeeds = (
|
|
61
|
+
base: Scene,
|
|
62
|
+
seeds: Readonly<Record<string, unknown>>,
|
|
63
|
+
): Scene => ({
|
|
70
64
|
...base,
|
|
71
65
|
provide: base.provide.map(Layer.locally(CurrentSeedOverrides, seeds)),
|
|
72
|
-
})
|
|
66
|
+
});
|
|
73
67
|
|
|
74
68
|
const headlessEnv: RenderEnv = {
|
|
75
69
|
props: {},
|
|
76
70
|
tracker: { add: () => {} },
|
|
77
|
-
}
|
|
71
|
+
};
|
|
78
72
|
|
|
79
73
|
const isCountProps = (props: unknown): props is { readonly count: number } =>
|
|
80
|
-
typeof props ===
|
|
74
|
+
typeof props === "object" &&
|
|
81
75
|
props !== null &&
|
|
82
|
-
|
|
83
|
-
typeof props.count ===
|
|
76
|
+
"count" in props &&
|
|
77
|
+
typeof props.count === "number";
|
|
84
78
|
|
|
85
|
-
// One ManagedRuntime per scene under test. Each `read` re-renders the root
|
|
86
|
-
// composition and reads the Structure built from the stores that runtime actually
|
|
87
|
-
// constructed.
|
|
88
79
|
const makeHarness = (s: Scene) => {
|
|
89
|
-
const layer = s.provide.reduce((a, b) => Layer.merge(a, b))
|
|
90
|
-
const runtime = ManagedRuntime.make(layer)
|
|
80
|
+
const layer = s.provide.reduce((a, b) => Layer.merge(a, b));
|
|
91
81
|
const read = Effect.flatMap(Counter.tag, (c) =>
|
|
92
82
|
Composition.render(c, headlessEnv).pipe(
|
|
93
83
|
Effect.flatMap((frame) => {
|
|
94
84
|
if (!isStructure(frame)) {
|
|
95
|
-
return Effect.dieMessage(
|
|
85
|
+
return Effect.dieMessage("Counter must render a Structure");
|
|
96
86
|
}
|
|
97
87
|
return isCountProps(frame.props)
|
|
98
88
|
? Effect.succeed(frame.props.count)
|
|
99
|
-
: Effect.dieMessage(
|
|
89
|
+
: Effect.dieMessage(
|
|
90
|
+
"Counter Structure props must include numeric count",
|
|
91
|
+
);
|
|
100
92
|
}),
|
|
101
93
|
),
|
|
102
|
-
)
|
|
103
|
-
return {
|
|
104
|
-
}
|
|
94
|
+
);
|
|
95
|
+
return { layer, read };
|
|
96
|
+
};
|
|
105
97
|
|
|
106
|
-
// Drive a read until a predicate holds, handing forked fibers the scheduler.
|
|
107
98
|
const waitUntil = <A, R>(
|
|
108
99
|
read: Effect.Effect<A, never, R>,
|
|
109
100
|
pred: (a: A) => boolean,
|
|
@@ -112,65 +103,54 @@ const waitUntil = <A, R>(
|
|
|
112
103
|
Effect.flatMap(read, (a) =>
|
|
113
104
|
pred(a) || rounds <= 0
|
|
114
105
|
? Effect.succeed(a)
|
|
115
|
-
: Effect.flatMap(Effect.yieldNow(), () =>
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
125
|
-
})
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
})
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
})
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
await seeded.runtime.dispose()
|
|
167
|
-
}
|
|
168
|
-
// Building the ORIGINAL scene's layers again boots from the authored seed —
|
|
169
|
-
// the overlay lives only on the seeded variant's wrapped layers.
|
|
170
|
-
const original = makeHarness(CounterScene)
|
|
171
|
-
try {
|
|
172
|
-
expect(await original.runtime.runPromise(original.read)).toBe(1)
|
|
173
|
-
} finally {
|
|
174
|
-
await original.runtime.dispose()
|
|
175
|
-
}
|
|
176
|
-
})
|
|
106
|
+
: Effect.flatMap(Effect.yieldNow(), () =>
|
|
107
|
+
waitUntil(read, pred, rounds - 1),
|
|
108
|
+
),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
it.scopedLive("a seeded scene boots its store from the override", () => {
|
|
112
|
+
const { layer, read } = makeHarness(seedScene(CounterScene, { count: 5 }));
|
|
113
|
+
return Effect.gen(function* () {
|
|
114
|
+
expect(yield* read).toBe(5);
|
|
115
|
+
}).pipe(Effect.provide(layer));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it.scopedLive("the live reducer drives the SAME overridden store", () => {
|
|
119
|
+
const { layer, read } = makeHarness(seedScene(CounterScene, { count: 5 }));
|
|
120
|
+
return Effect.gen(function* () {
|
|
121
|
+
expect(yield* read).toBe(5);
|
|
122
|
+
yield* publish("High", Event.construct(Bumped, {}));
|
|
123
|
+
const next = yield* waitUntil(read, (n) => n === 6);
|
|
124
|
+
expect(next).toBe(6);
|
|
125
|
+
}).pipe(Effect.provide(layer));
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it.scopedLive("an absent key leaves the authored seed in place", () => {
|
|
129
|
+
const { layer, read } = makeHarness(
|
|
130
|
+
overlayRawSeeds(CounterScene, { other: 1 }),
|
|
131
|
+
);
|
|
132
|
+
return Effect.gen(function* () {
|
|
133
|
+
expect(yield* read).toBe(1);
|
|
134
|
+
}).pipe(Effect.provide(layer));
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it.scopedLive(
|
|
138
|
+
"a schema-invalid override falls back to the authored seed",
|
|
139
|
+
() => {
|
|
140
|
+
const { layer, read } = makeHarness(
|
|
141
|
+
overlayRawSeeds(CounterScene, { count: "not-a-number" }),
|
|
142
|
+
);
|
|
143
|
+
return Effect.gen(function* () {
|
|
144
|
+
expect(yield* read).toBe(1);
|
|
145
|
+
}).pipe(Effect.provide(layer));
|
|
146
|
+
},
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
it.scopedLive("seeding does not leak into the original scene", () => {
|
|
150
|
+
const seeded = makeHarness(seedScene(CounterScene, { count: 5 }));
|
|
151
|
+
const original = makeHarness(CounterScene);
|
|
152
|
+
return Effect.gen(function* () {
|
|
153
|
+
expect(yield* seeded.read.pipe(Effect.provide(seeded.layer))).toBe(5);
|
|
154
|
+
expect(yield* original.read.pipe(Effect.provide(original.layer))).toBe(1);
|
|
155
|
+
});
|
|
156
|
+
});
|
package/src/state/state.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { Context, Effect, FiberRef, Layer, Option, Schema } from 'effect'
|
|
2
|
+
import type { Effect as EffectType } from 'effect/Effect'
|
|
2
3
|
import { type Manifest, yieldableClass } from '../definition/definition'
|
|
3
4
|
import { resolveScheduler } from '../internal/scheduler'
|
|
4
5
|
import { CurrentSeedOverrides } from '../internal/seeds'
|
|
6
|
+
import { resolveInstrumentation, stateUpdateHook } from '../runtime/instrumentation'
|
|
5
7
|
import { claimStateTag } from '../internal/stateRegistry'
|
|
6
8
|
import { makeStore, type Store } from '../internal/store'
|
|
7
9
|
import { readTracked } from '../internal/track'
|
|
10
|
+
import type { AnySchema, AnyValue } from '../internal/variance'
|
|
8
11
|
|
|
9
12
|
export interface StateOptionsExternalApi {
|
|
10
13
|
readonly title?: string
|
|
@@ -15,32 +18,25 @@ export type StateOptions = StateOptionsExternalApi
|
|
|
15
18
|
export interface StateManifestExternalApi<N extends string, A> extends Manifest {
|
|
16
19
|
readonly kind: 'State'
|
|
17
20
|
readonly name: N
|
|
18
|
-
readonly schema:
|
|
21
|
+
readonly schema: AnySchema<A>
|
|
19
22
|
readonly title?: string
|
|
20
23
|
readonly description?: string
|
|
21
24
|
}
|
|
22
25
|
export type StateManifest<N extends string, A> = StateManifestExternalApi<N, A>
|
|
23
26
|
|
|
24
|
-
export interface StateClass<out N extends string, in out A> extends
|
|
27
|
+
export interface StateClass<out N extends string, in out A> extends EffectType<A, never, Store<A>> {
|
|
25
28
|
new (): {}
|
|
26
29
|
readonly manifest: StateManifest<N, A>
|
|
27
|
-
/** Internal DI tag holding the live store. The loop reads/writes through it. */
|
|
28
30
|
readonly store: Context.Tag<Store<A>, Store<A>>
|
|
29
31
|
}
|
|
30
32
|
|
|
31
|
-
export type AnyState = StateClass<string,
|
|
33
|
+
export type AnyState = StateClass<string, AnyValue>
|
|
32
34
|
export type StateValue<S> = S extends StateClass<string, infer A> ? A : never
|
|
33
|
-
export type StateName<S> = S extends StateClass<infer N,
|
|
35
|
+
export type StateName<S> = S extends StateClass<infer N, AnyValue> ? N : never
|
|
34
36
|
|
|
35
|
-
/**
|
|
36
|
-
* Atomic reactive state. `State.make` is the *definition* (a reflectable
|
|
37
|
-
* manifest + an internal store tag); it carries no starting value — the seed is
|
|
38
|
-
* an implementation detail supplied at create-time by `State.live(state, seed)`.
|
|
39
|
-
* Composed into a `StateGroup` exactly as `@effect/rpc` composes rpcs.
|
|
40
|
-
*/
|
|
41
37
|
export const make = <const N extends string, A>(
|
|
42
38
|
name: N,
|
|
43
|
-
schema:
|
|
39
|
+
schema: AnySchema<A>,
|
|
44
40
|
options: StateOptions = {},
|
|
45
41
|
): StateClass<N, A> => {
|
|
46
42
|
const identifier = `reform/state/${name}`
|
|
@@ -57,15 +53,6 @@ export const make = <const N extends string, A>(
|
|
|
57
53
|
return yieldableClass(read, { manifest, store })
|
|
58
54
|
}
|
|
59
55
|
|
|
60
|
-
/**
|
|
61
|
-
* Resolve the value a store should boot from: the ambient seed override when
|
|
62
|
-
* one is present (and valid) for this state's member name, else the authored
|
|
63
|
-
* `initial`. Reads `CurrentSeedOverrides` — empty outside the tooling seam
|
|
64
|
-
* (`Scene.seedScene`), so production wiring always boots from `initial`. An
|
|
65
|
-
* override is decoded against the state's schema; an invalid value silently
|
|
66
|
-
* falls back to `initial` — a half-typed override from the dev tool must never
|
|
67
|
-
* crash a preview.
|
|
68
|
-
*/
|
|
69
56
|
const resolveSeed = <S extends AnyState>(
|
|
70
57
|
state: S,
|
|
71
58
|
initial: StateValue<S>,
|
|
@@ -78,18 +65,15 @@ const resolveSeed = <S extends AnyState>(
|
|
|
78
65
|
return Option.getOrElse(override, () => initial)
|
|
79
66
|
})
|
|
80
67
|
|
|
81
|
-
/**
|
|
82
|
-
* Allocate a state's store, seeded with `initial` — `State.live(Feed, seed)`.
|
|
83
|
-
* The seed lives here (implementation), not on the definition, so the same
|
|
84
|
-
* definition can boot from different values in app vs. test wiring.
|
|
85
|
-
*/
|
|
86
68
|
export const live = <S extends AnyState>(
|
|
87
69
|
state: S,
|
|
88
70
|
initial: StateValue<S>,
|
|
89
71
|
): Layer.Layer<Store<StateValue<S>>> =>
|
|
90
72
|
Layer.effect(
|
|
91
73
|
state.store,
|
|
92
|
-
Effect.
|
|
93
|
-
|
|
74
|
+
Effect.all([resolveScheduler, resolveSeed(state, initial), resolveInstrumentation]).pipe(
|
|
75
|
+
Effect.map(([scheduler, seed, instrumentation]) =>
|
|
76
|
+
makeStore(seed, scheduler, stateUpdateHook(instrumentation, state.manifest.name)),
|
|
77
|
+
),
|
|
94
78
|
),
|
|
95
79
|
)
|
|
@@ -2,9 +2,6 @@ import { expect, it } from '@effect/vitest'
|
|
|
2
2
|
import { Duration, Effect, Layer, Schema as S } from 'effect'
|
|
3
3
|
import { Engine, Event, publish, Reducer, StateFamily } from '../index'
|
|
4
4
|
|
|
5
|
-
// A family is normalized keyed state: one logical slice per key, so a write to
|
|
6
|
-
// one key must wake only that key's subscribers (the point of the primitive).
|
|
7
|
-
|
|
8
5
|
const tick = Effect.sleep(Duration.millis(1))
|
|
9
6
|
|
|
10
7
|
it.live("writing one key does not notify another key's subscribers", () => {
|
|
@@ -53,7 +50,6 @@ it.live('forget(key) drops the store; re-reading the key reseeds it', () => {
|
|
|
53
50
|
expect(family.at('k').get()).toBe(42)
|
|
54
51
|
|
|
55
52
|
family.forget('k')
|
|
56
|
-
// A fresh store, reseeded to the family's initial value — not the old 42.
|
|
57
53
|
const reseeded = family.at('k')
|
|
58
54
|
expect(reseeded).not.toBe(first)
|
|
59
55
|
expect(reseeded.get()).toBe(0)
|
|
@@ -71,16 +67,13 @@ it.live('evictWhenUnused drops a key once its last subscriber leaves', () => {
|
|
|
71
67
|
const offB = store.subscribe(() => {})
|
|
72
68
|
expect(family.size()).toBe(1)
|
|
73
69
|
|
|
74
|
-
// One subscriber leaving keeps the key (another still reads it).
|
|
75
70
|
offA()
|
|
76
71
|
yield* tick
|
|
77
72
|
expect(family.size()).toBe(1)
|
|
78
73
|
|
|
79
|
-
// The last subscriber leaving evicts the key on the next microtask.
|
|
80
74
|
offB()
|
|
81
75
|
yield* tick
|
|
82
76
|
expect(family.size()).toBe(0)
|
|
83
|
-
// Re-reading reseeds a fresh store.
|
|
84
77
|
expect(family.at('k').get()).toBe(0)
|
|
85
78
|
}).pipe(Effect.provide(TestLayer))
|
|
86
79
|
})
|
|
@@ -94,10 +87,9 @@ it.live('evictWhenUnused: a same-tick re-subscribe cancels eviction', () => {
|
|
|
94
87
|
const store = family.at('k')
|
|
95
88
|
store.set(7)
|
|
96
89
|
const off = store.subscribe(() => {})
|
|
97
|
-
off()
|
|
98
|
-
store.subscribe(() => {})
|
|
90
|
+
off()
|
|
91
|
+
store.subscribe(() => {})
|
|
99
92
|
yield* tick
|
|
100
|
-
// Not evicted: the value survives and `at` returns the same live store.
|
|
101
93
|
expect(family.size()).toBe(1)
|
|
102
94
|
expect(family.at('k').get()).toBe(7)
|
|
103
95
|
}).pipe(Effect.provide(TestLayer))
|
|
@@ -128,7 +120,6 @@ it.live('a family reducer fold returning Tombstone evicts the key', () => {
|
|
|
128
120
|
const before = family.at('a')
|
|
129
121
|
expect(before.get()).toBe(1)
|
|
130
122
|
|
|
131
|
-
// The fold returns the eviction sentinel: the key's store is dropped.
|
|
132
123
|
yield* publish('High', Event.construct(Removed, { id: 'a' }))
|
|
133
124
|
yield* Effect.sleep(Duration.millis(5))
|
|
134
125
|
const after = family.at('a')
|