Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/actor-host-data.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"effect-machine": minor
---

Allow ActorHost factories to receive typed data from the hosting parent as a second argument. Call host(request, hostInput) to bind parent input to a generation while consumers continue to call acquire(request). Existing one-argument factories keep their current behavior.
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ Use `ActorHost.make({ identity, spawn })` when consumers must request a child wi
- Construct the host in the service layer. `spawn` captures those services.
- Run `host.host(input)` in the parent state's `.spawn` handler. It registers a generation and waits for a consumer.
- Consumers call `host.acquire(input)`. Identity values match with `Object.is`. The first matching consumer supplies the factory input.
- For parent-owned startup data, type the second `spawn(request, hostInput)` argument and call `host(input, hostInput)`. Consumers still call `acquire(input)`. Host data belongs to that generation; pass immutable values. One-argument factories need no host data.
- Concurrent consumers share startup and its result. Cancelling one consumer does not cancel startup. ActorHost starts actors from either `Machine.spawn` or `system.spawn` before it returns them.
- The factory receives the host generation's Scope and ActorScope. State exit closes the child. Closing the host service also closes the current generation and fails pending consumers.
- A second active host fails with `ActorHostOccupiedError`. A closed generation fails pending acquisition with `ActorHostClosedError`.
Expand Down
2 changes: 2 additions & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -370,4 +370,6 @@ Input machines require `input: (entityId) => Input`. Use `initializeState` only

Create `ActorHost.make({ identity, spawn })` in a scoped service layer. Use `host.host(input)` in a state-scoped `.spawn` handler. Use `host.acquire(input)` from consumers. Identity uses `Object.is`. The first matching consumer supplies the spawn input. Concurrent consumers share startup. Consumer cancellation does not stop startup or the actor. The parent state scope owns the actor, and host service shutdown closes any active generation. Keep session validation in the application.

Parents can pass typed data with `host.host(input, hostInput)`. Type the second `spawn` argument to receive it. This data belongs to the host generation; consumers still call `acquire(input)`. Pass immutable values.

ActorHost starts direct `Machine.spawn` results too. A registered `host` wait is interrupted on generation close; consumers receive `ActorHostClosedError` before actor cleanup. A consumer attached to an old generation must acquire again after reentry. Handle expected factory errors in the parent's spawn handler. Use `Effect.orDie` only for invariant failures, because it defects the parent. Both host errors are exported from the package root.
22 changes: 22 additions & 0 deletions docs/actors.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,28 @@ const menu = yield * menuHost.acquire({ sessionId });

`host` registers one generation in the current state scope. The first matching `acquire` supplies the factory input. Identity values match with `Object.is`. Concurrent consumers share startup and its result. Consumer cancellation does not cancel startup or stop the actor. ActorHost starts the factory result, so both `Machine.spawn` and `system.spawn` work.

When the parent owns startup data, give `spawn` a typed second argument. Pass that data to `host`; consumers still provide only the request input.

```ts
const screenHost =
yield *
ActorHost.make({
identity: (request: { sessionId: string }) => request.sessionId,
spawn: (request, screen: { destination: string }) =>
Machine.spawn(screenMachine, { input: { ...request, ...screen } }),
});

parent.spawn(State.Screen, ({ state }) =>
screenHost
.host({ sessionId: state.sessionId }, { destination: state.destination })
.pipe(Effect.asVoid, Effect.orDie),
);

const screen = yield * screenHost.acquire({ sessionId });
```

The second argument belongs to the registered generation. A consumer cannot replace it. Reentry supplies new host data. Host data is passed by reference, so use immutable values. Existing one-argument factories need no host data.

The factory uses the services captured when the host was made. Its Scope and ActorScope belong to the hosting generation. State exit closes the actor. Closing the host service also closes the current generation and ends consumers waiting for a future generation.

Consumers receive `ActorHostClosedError` before actor cleanup starts. An acquisition belongs to the generation it observed. Acquire again after reentry to get the new actor. The registered `host` wait is interrupted when its generation closes. Calls made after the host service closes fail with `ActorHostClosedError`. An overlapping host fails with `ActorHostOccupiedError`, including while the previous actor is still being cleaned up.
Expand Down
32 changes: 24 additions & 8 deletions src/actor-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,18 @@ import { ActorHostClosedError, ActorHostOccupiedError } from "./errors.js";

export { ActorHostClosedError, ActorHostOccupiedError } from "./errors.js";

export interface ActorHost<Input, S extends { readonly _tag: string }, E, Output, Failure> {
export interface ActorHost<
Input,
S extends { readonly _tag: string },
E,
Output,
Failure,
HostInput = void,
> {
/** Register in the current scope. Wait for acquisition; interrupt if this generation closes. */
readonly host: (
input: Input,
hostInput: HostInput,
) => Effect.Effect<
ActorRef<S, E, Output>,
Failure | ActorHostClosedError | ActorHostOccupiedError,
Expand All @@ -27,7 +35,9 @@ export interface ActorHost<Input, S extends { readonly _tag: string }, E, Output
*
* Call `host` from a machine's state-scoped spawn handler. Call `acquire` from
* consumers. The first matching consumer supplies the spawn input. Identity
* values match with Object.is. The factory captures the services at make time;
* values match with Object.is. Host data is a separate factory argument owned by
* the registered generation; consumers cannot replace it.
* The factory captures the services at make time;
* its Scope and ActorScope always belong to the hosting generation. The actor
* starts before publication, including factories that use Machine.spawn.
*/
Expand All @@ -38,9 +48,13 @@ export const make = Effect.fn("effect-machine.actorHost.make")(function* <
Output,
Failure,
R,
HostInput = void,
>(options: {
readonly identity: (input: Input) => unknown;
readonly spawn: (input: Input) => Effect.Effect<ActorRef<S, E, Output>, Failure, R>;
readonly spawn: (
input: Input,
hostInput: HostInput,
) => Effect.Effect<ActorRef<S, E, Output>, Failure, R>;
}) {
type Actor = ActorRef<S, E, Output>;
interface Entry {
Expand Down Expand Up @@ -77,9 +91,9 @@ export const make = Effect.fn("effect-machine.actorHost.make")(function* <
),
);

const host: ActorHost<Input, S, E, Output, Failure>["host"] = Effect.fn(
const host: ActorHost<Input, S, E, Output, Failure, HostInput>["host"] = Effect.fn(
"effect-machine.actorHost.host",
)((input) =>
)((input, hostInput) =>
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
if (yield* Deferred.isDone(closed)) return yield* failClosed;
Expand Down Expand Up @@ -127,7 +141,9 @@ export const make = Effect.fn("effect-machine.actorHost.make")(function* <
entry.actor,
Deferred.await(entry.requested).pipe(
Effect.flatMap((requested) =>
Machine.scoped(options.spawn(requested).pipe(Effect.tap((actor) => actor.start))),
Machine.scoped(
options.spawn(requested, hostInput).pipe(Effect.tap((actor) => actor.start)),

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Parent data stays with the generation.
 parent state
-  host(request)
+  host(request, hostInput)
     acquire(request)
       Deferred.await(entry.requested)
-        spawn(requested)
+        spawn(requested, hostInput)

The host closure supplies the second argument. A consumer cannot replace it. Existing request matching and scope cleanup stay intact. This call trace comes from source; calldiff is unavailable in this environment.

),
),
Scope.provide(scope),
Effect.provideContext(services),
Expand All @@ -150,7 +166,7 @@ export const make = Effect.fn("effect-machine.actorHost.make")(function* <
}),
),
);
const acquire: ActorHost<Input, S, E, Output, Failure>["acquire"] = Effect.fn(
const acquire: ActorHost<Input, S, E, Output, Failure, HostInput>["acquire"] = Effect.fn(
"effect-machine.actorHost.acquire",
)((input) =>
Effect.raceFirst(
Expand All @@ -169,5 +185,5 @@ export const make = Effect.fn("effect-machine.actorHost.make")(function* <
Deferred.await(closed).pipe(Effect.andThen(failClosed)),
),
);
return { host, acquire } satisfies ActorHost<Input, S, E, Output, Failure>;
return { host, acquire } satisfies ActorHost<Input, S, E, Output, Failure, HostInput>;
});
31 changes: 31 additions & 0 deletions test/actor-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,37 @@ describe("ActorHost", () => {
}).pipe(Effect.provide(ActorSystemDefault)),
);

it.scoped("keeps typed host data with its generation across consumers and reentry", () =>

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Check generation and type boundaries.

The actual machine receives rewards:early from an early consumer and its first owner. A later consumer gets the same actor. After the owner closes, reentry yields a new actor with history:next. The type-constraints test also rejects missing host data, invalid host data, and attempts to pass host data through acquire.

Full gate: 396 tests, 909 assertions, types, lint, format, build, and example gates passed.

Effect.gen(function* () {
const host = yield* ActorHost.make({
identity: (input: { session: object; request: string }) => input.session,
spawn: (input, owner: { destination: string }) =>
Machine.spawn(child, { input: { value: `${owner.destination}:${input.request}` } }),
});
const session = {};
const waiting = yield* host.acquire({ session, request: "early" }).pipe(Effect.forkScoped);
yield* yieldFibers;
const firstOwner = yield* ownerScope;
const firstHosted = yield* host
.host({ session, request: "registration" }, { destination: "rewards" })
.pipe(Scope.provide(firstOwner), Effect.forkScoped);
const first = yield* Fiber.join(waiting);
expect(yield* first.snapshot).toEqual(ChildState.Ready({ value: "rewards:early" }));
expect(yield* Fiber.join(firstHosted)).toBe(first);
expect(yield* host.acquire({ session, request: "later" })).toBe(first);
yield* Scope.close(firstOwner, Exit.void);
expect((yield* first.call(ChildEvent.Finish)).transitioned).toBe(false);
const nextOwner = yield* ownerScope;
const nextHosted = yield* host
.host({ session, request: "registration" }, { destination: "history" })
.pipe(Scope.provide(nextOwner), Effect.forkScoped);
const next = yield* host.acquire({ session, request: "next" });
expect(next).not.toBe(first);
expect(yield* Fiber.join(nextHosted)).toBe(next);
expect(yield* next.snapshot).toEqual(ChildState.Ready({ value: "history:next" }));
}),
);

it.scoped("keeps actual recovery running when an acquiring consumer cancels", () =>
Effect.gen(function* () {
const system = yield* ActorSystemService;
Expand Down
17 changes: 17 additions & 0 deletions test/type-constraints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
*/
import { Effect, Schema, Context } from "effect";
import {
ActorHost,
ActorSystemDefault,
ActorSystemService,
Machine,
Expand Down Expand Up @@ -329,3 +330,19 @@ const _test9b = PayloadReplyEvent.GetById(_test9bPayload);
const _test9bId: string = _test9b.id;

// This file should compile with all @ts-expect-error comments being valid

const _hostInputTypes = Effect.gen(function* () {
const host = yield* ActorHost.make({
identity: (request: { session: string }) => request.session,
spawn: (_request, owner: { url: string }) =>
Machine.spawn(_test1, { hydrate: MyState.Loading(owner) }),
});
const _call1 = host.host({ session: "session" }, { url: "/menu" });
const _call2 = host.acquire({ session: "session" });
// @ts-expect-error - the factory requires host data
const _call3 = host.host({ session: "session" });
// @ts-expect-error - host data must match the factory's exact type
const _call4 = host.host({ session: "session" }, { url: 123 });
// @ts-expect-error - consumers cannot supply host data
const _call5 = host.acquire({ session: "session" }, { url: "/other" });
});
Loading