diff --git a/.changeset/honest-task-entity-types.md b/.changeset/honest-task-entity-types.md new file mode 100644 index 0000000..2f454cf --- /dev/null +++ b/.changeset/honest-task-entity-types.md @@ -0,0 +1,5 @@ +--- +"@humanlayer/effect-machine": patch +--- + +Preserve exact machine schema types on `MachineEntity` and require `.task()` shorthand to return a valid machine event without using `any` or diagnostic suppressions. diff --git a/src/cluster/entity-actor-ref.ts b/src/cluster/entity-actor-ref.ts index 4707c50..168c50b 100644 --- a/src/cluster/entity-actor-ref.ts +++ b/src/cluster/entity-actor-ref.ts @@ -15,6 +15,7 @@ import { Effect, Option, Schema, Stream } from "effect"; import type { ExtractReply, ReplyTypeBrand } from "../internal/brands.js"; import { ActorStoppedError, NoReplyError } from "../errors.js"; +import type { MachineSchemaDefinition } from "../schema.js"; import type { EntityRpcs, MachineEntity } from "./to-entity.js"; /** @@ -75,8 +76,10 @@ export const makeEntityActorRef = < R, EntityType extends string, ClientError, + StateDefinition extends Record, + EventDefinition extends MachineSchemaDefinition, >( - entity: MachineEntity, + entity: MachineEntity, client: RpcClient.RpcClient< EntityRpcs, Schema.Codec>[number], ClientError diff --git a/src/cluster/entity-machine.ts b/src/cluster/entity-machine.ts index d59cce6..007ebe5 100644 --- a/src/cluster/entity-machine.ts +++ b/src/cluster/entity-machine.ts @@ -29,6 +29,7 @@ import { } from "effect"; import { type Machine, replay } from "../machine.js"; +import type { MachineSchemaDefinition } from "../schema.js"; import type { ActorSystemService } from "../actor.js"; import { ActorSystem as ActorSystemTag, makeSystem } from "../actor.js"; import type { ProcessEventHooks } from "../internal/transition.js"; @@ -139,8 +140,10 @@ function layer< E extends { readonly _tag: string }, R, EntityType extends string, + StateDefinition extends Record, + EventDefinition extends MachineSchemaDefinition, >( - entity: MachineEntity, + entity: MachineEntity, options?: EntityOptionsWithoutPersistence, ): EntityLayer; function layer< @@ -148,8 +151,10 @@ function layer< E extends { readonly _tag: string }, R, EntityType extends string, + StateDefinition extends Record, + EventDefinition extends MachineSchemaDefinition, >( - entity: MachineEntity, + entity: MachineEntity, options: EntityOptionsWithPersistence, ): EntityLayer; function layer< @@ -157,8 +162,10 @@ function layer< E extends { readonly _tag: string }, R, EntityType extends string, + StateDefinition extends Record, + EventDefinition extends MachineSchemaDefinition, >( - entity: MachineEntity, + entity: MachineEntity, options: EntityMachineOptions, ): EntityLayer; function layer< @@ -166,7 +173,12 @@ function layer< E extends { readonly _tag: string }, R, EntityType extends string, ->(entity: MachineEntity, options?: EntityMachineOptions) { + StateDefinition extends Record, + EventDefinition extends MachineSchemaDefinition, +>( + entity: MachineEntity, + options?: EntityMachineOptions, +) { type Rpcs = EntityRpcs, Schema.Codec>[number]; const machine = entity.machine; const persistence = options?.persistence; diff --git a/src/cluster/to-entity.ts b/src/cluster/to-entity.ts index 8677f8c..a32aacf 100644 --- a/src/cluster/to-entity.ts +++ b/src/cluster/to-entity.ts @@ -8,6 +8,7 @@ import { Rpc } from "effect/unstable/rpc"; import { Schema } from "effect"; import type { Machine } from "../machine.js"; +import type { MachineSchemaDefinition } from "../schema.js"; import { MissingSchemaError } from "../errors.js"; /** @@ -60,9 +61,13 @@ export interface MachineEntity< Event extends { readonly _tag: string }, R, EntityType extends string, + StateDefinition extends Record = Record< + string, + Schema.Struct.Fields + >, + EventDefinition extends MachineSchemaDefinition = MachineSchemaDefinition, > extends Entity.Entity, Schema.Codec>[number]> { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- schema-definition parameters are carried opaquely by MachineEntity - readonly machine: Machine; + readonly machine: Machine; readonly stateSchema: Schema.Codec; readonly eventSchema: Schema.Codec; readonly rpcs: EntityRpcs, Schema.Codec>; @@ -104,11 +109,12 @@ export const toEntity = < E extends { readonly _tag: string }, R, const EntityType extends string, + StateDefinition extends Record, + EventDefinition extends MachineSchemaDefinition, >( - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Schema fields need wide acceptance - machine: Machine, + machine: Machine, options: ToEntityOptions, -): MachineEntity => { +): MachineEntity => { const stateSchema = machine.stateSchema; const eventSchema = machine.eventSchema; diff --git a/src/machine.ts b/src/machine.ts index c9e93ef..dd7ac49 100644 --- a/src/machine.ts +++ b/src/machine.ts @@ -34,8 +34,8 @@ * * @module */ -import type { Duration, Schema } from "effect"; -import { Cause, Effect, Exit, Option, Random, Scope } from "effect"; +import type { Duration } from "effect"; +import { Cause, Effect, Exit, Option, Random, Schema, Scope } from "effect"; import type { DeferReplyResult, ReplyResult, TransitionResult } from "./internal/utils.js"; import { getTag, makeReply, makeDeferReply } from "./internal/utils.js"; @@ -178,12 +178,18 @@ export interface BackgroundEffect { // Options types // ============================================================================ -export interface TaskOptions { - readonly onSuccess?: (value: A, ctx: StateHandlerContext) => ES; +interface TaskCommonOptions { readonly onFailure?: (cause: Cause.Cause, ctx: StateHandlerContext) => EF; readonly name?: string; } +/** Options for a mapped task, or for shorthand when the task already returns an event. */ +export type TaskOptions = TaskCommonOptions & + ( + | { readonly onSuccess: (value: A, ctx: StateHandlerContext) => ES } + | ([A] extends [Event] ? { readonly onSuccess?: undefined } : never) + ); + // ============================================================================ // Recovery / Durability // ============================================================================ @@ -633,14 +639,12 @@ export class Machine< A, E1, R1, - ES extends VariantsUnion<_ED> & BrandedEvent, - EF extends VariantsUnion<_ED> & BrandedEvent, + ES extends Event & VariantsUnion<_ED> & BrandedEvent, + EF extends Event & VariantsUnion<_ED> & BrandedEvent, >( state: TaggedOrConstructor, - run: ( - ctx: StateHandlerContext & BrandedEvent>, - ) => Effect.Effect, - options: TaskOptions & BrandedEvent, A, E1, ES, EF>, + run: (ctx: StateHandlerContext) => Effect.Effect, + options: TaskOptions, ): Machine; /** Multiple states — handler receives the selected state union. */ task< @@ -648,35 +652,38 @@ export class Machine< A, E1, R1, - ES extends VariantsUnion<_ED> & BrandedEvent, - EF extends VariantsUnion<_ED> & BrandedEvent, + ES extends Event & VariantsUnion<_ED> & BrandedEvent, + EF extends Event & VariantsUnion<_ED> & BrandedEvent, >( states: NS, run: ( - ctx: StateHandlerContext< - NS[number] extends TaggedOrConstructor ? S : never, - VariantsUnion<_ED> & BrandedEvent - >, + ctx: StateHandlerContext ? S : never, Event>, ) => Effect.Effect, options: TaskOptions< NS[number] extends TaggedOrConstructor ? S : never, - VariantsUnion<_ED> & BrandedEvent, + Event, A, E1, ES, EF >, ): Machine; - /* eslint-disable @typescript-eslint/no-explicit-any -- public overloads preserve selection/task correlation at this implementation boundary */ - task( - stateOrStates: any, - run: (ctx: StateHandlerContext) => Effect.Effect, - options: any, - ): Machine { - const handler: StateEffectHandler = Effect.fn("effect-machine.task")(function* ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- implementation is checked by public overloads - ctx: StateHandlerContext, - ) { + task< + NS extends VariantsUnion<_SD> & BrandedState, + A, + E1, + R1, + ES extends Event & VariantsUnion<_ED> & BrandedEvent, + EF extends Event & VariantsUnion<_ED> & BrandedEvent, + >( + stateOrStates: TaggedOrConstructor | ReadonlyArray>, + run: (ctx: StateHandlerContext) => Effect.Effect, + options: TaskOptions, + ): Machine { + const isEvent = this.eventSchema !== undefined ? Schema.is(this.eventSchema) : undefined; + const handler: StateEffectHandler = Effect.fn( + "effect-machine.task", + )(function* (ctx: StateHandlerContext) { yield* emitTaskInspection({ actorId: ctx.actorId, state: ctx.state, @@ -684,7 +691,6 @@ export class Machine< phase: "start", }); - // @effect-diagnostics anyUnknownInErrorContext:off -- the public task overloads preserve concrete error and requirement channels at this implementation boundary const exit = yield* Effect.exit(run(ctx)); if (Exit.isSuccess(exit)) { @@ -695,7 +701,11 @@ export class Machine< phase: "success", }); const successEvent = - options.onSuccess !== undefined ? options.onSuccess(exit.value, ctx) : exit.value; + options.onSuccess !== undefined + ? options.onSuccess(exit.value, ctx) + : isEvent !== undefined && isEvent(exit.value) + ? exit.value + : yield* Effect.die("Task shorthand produced an invalid machine event"); yield* ctx.self.send(successEvent); yield* Effect.yieldNow; return; @@ -723,13 +733,11 @@ export class Machine< yield* Effect.yieldNow; return; } - // @effect-diagnostics anyUnknownInErrorContext:off return yield* Effect.failCause(cause).pipe(Effect.orDie); }); return this.registerStateEffect(stateOrStates, handler); } - /* eslint-enable @typescript-eslint/no-explicit-any */ // ---- timeout ---- @@ -756,7 +764,7 @@ export class Machine< */ timeout & BrandedState>( state: TaggedOrConstructor, - config: TimeoutConfig & BrandedEvent>, + config: TimeoutConfig & BrandedEvent>, ): Machine { const stateTag = getTag(state); const duration = config.duration; diff --git a/test/cluster-type-constraints.test.ts b/test/cluster-type-constraints.test.ts index 53cddc6..9c1c17e 100644 --- a/test/cluster-type-constraints.test.ts +++ b/test/cluster-type-constraints.test.ts @@ -32,6 +32,14 @@ const clusterMachine = Machine.make({ .background(() => ClusterService.pipe(Effect.andThen((service) => service.run))); const ClusterEntity = toEntity(clusterMachine, { type: "TypeConstraints" }); + +const ForeignState = State({ Foreign: {} }); +const ForeignEvent = Event({ Foreign: {} }); +// @ts-expect-error - a machine-owned entity retains its machine's exact state/event definitions +ClusterEntity.machine.on(ForeignState.Foreign, ForeignEvent.Foreign, () => + ClusterState.Active({ count: 0 }), +); + const withoutPersistence = EntityMachine.layer(ClusterEntity); const withPersistence = EntityMachine.layer(ClusterEntity, { persistence: { strategy: "journal" }, diff --git a/test/type-constraints.test.ts b/test/type-constraints.test.ts index f91fb9b..55e3979 100644 --- a/test/type-constraints.test.ts +++ b/test/type-constraints.test.ts @@ -104,6 +104,14 @@ const _test6 = Machine.make({ onSuccess: () => MyEvent.Complete, }); +// Task shorthand is only valid when the task itself returns a machine event. +const _invalidTaskShorthand = Machine.make({ + state: MyState, + event: MyEvent, + initial: MyState.Loading({ url: "/" }), + // @ts-expect-error - non-event task results require an onSuccess mapper +}).task(MyState.Loading, () => Effect.succeed(123), {}); + const _test6Spawn = Machine.spawn(_test6); type _Test6RequiresService = Assert< MyService extends EffectRequirements ? true : false