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
15 changes: 15 additions & 0 deletions .changeset/curly-effects-invoke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@typeonce/effect-machine": minor
---

Require every `Machine.invoke` `effect` source to be a factory evaluated when its owning state is entered. This gives lifecycle callbacks immediate output and failure inference while making Effect construction timing explicit.

Wrap previously direct Effects in a zero-argument function:

```ts
Machine.invoke({
id: "load",
effect: () => load,
onDone: ({ output, target }) => target.none()
})
```
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -376,7 +376,7 @@ State-scoped work starts on entry and is interrupted on exit:
Loading: {
invoke: Machine.invoke({
id: "save-document",
effect: saveDocument,
effect: () => saveDocument,
onDone: ({ output, target }) => target.full.Saved({ id: output.id }),
onFailure: ({ error, target }) => target.full.Failed({ message: String(error) })
})
Expand Down Expand Up @@ -443,8 +443,10 @@ lookup.
`onDone` is required for a non-`never` output, and `onFailure` is required for a
non-`never` typed error; each handler is omitted when its channel is `never`.
Defects, interruption, and source-construction failures terminate the owning
runtime. `effect: Effect.sleep(...)` is valid, but `after` keeps timers explicit
and makes static durations visible through activity inspection.
runtime. Effect sources are always factories evaluated when their state is
entered. Use `effect: () => Effect.sleep(...)` for a generic Effect, while
`after` keeps timers explicit and makes static durations visible through
activity inspection.

## Reactivity

Expand Down
15 changes: 8 additions & 7 deletions docs/agent-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -731,7 +731,7 @@ receive the typed Effect channels and can transition directly:
```ts
invoke: Machine.invoke({
id: "save",
effect: SaveService.save(draft),
effect: () => SaveService.save(draft),
onDone: ({ output, target }) => target.full.Saved({ entry: output }),
onFailure: ({ error, target }) =>
target.full.SaveFailed({ message: error.message })
Expand Down Expand Up @@ -803,12 +803,13 @@ invoke: Machine.invoke({
```

The timer starts on state entry and is interrupted on exit. Its `onDone` is
always required. `effect: Effect.sleep(...)` has the same scoped cancellation
behavior, but `after` records timer intent and exposes a static duration through
`Machine.activityDefinitions`. For reusable process logic, provide `logic`, a
state-local lifecycle `id`, and a typed `address`. TypeScript checks the address
protocol against the logic event protocol. Lifecycle ids and addresses serve
different purposes and must both be explicit.
always required. `effect: () => Effect.sleep(...)` has the same scoped
cancellation behavior, but `after` records timer intent and exposes a static
duration through `Machine.activityDefinitions`. Effect sources are always
factories evaluated when their state is entered. For reusable process logic,
provide `logic`, a state-local lifecycle `id`, and a typed `address`. TypeScript
checks the address protocol against the logic event protocol. Lifecycle ids and
addresses serve different purposes and must both be explicit.

## Invoked child statecharts

Expand Down
6 changes: 3 additions & 3 deletions examples/playground/src/examples/media-player/machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export const MediaPlayerMachine = MediaPlayerDefinition.handle({
Paused: {
invoke: Machine.invoke({
id: "pause-audio",
effect: pauseAudio,
effect: () => pauseAudio,
onDone: ({ target }) => target.none(),
onFailure: ({ error, target }, enqueue) => {
enqueue.raise(MediaPlayerInternalEvents.OperationFailed({ message: error.message }))
Expand All @@ -75,7 +75,7 @@ export const MediaPlayerMachine = MediaPlayerDefinition.handle({
invoke: [
Machine.invoke({
id: "play-audio",
effect: playAudio,
effect: () => playAudio,
onDone: ({ target }) => target.none(),
onFailure: ({ error, target }, enqueue) => {
enqueue.raise(MediaPlayerInternalEvents.OperationFailed({ message: error.message }))
Expand Down Expand Up @@ -145,7 +145,7 @@ export const MediaPlayerMachine = MediaPlayerDefinition.handle({
Restarting: {
invoke: Machine.invoke({
id: "restart-audio",
effect: restartAudio,
effect: () => restartAudio,
onDone: ({ target }, enqueue) => {
enqueue.raise(MediaPlayerInternalEvents.RestartSucceeded())
return target.none()
Expand Down
9 changes: 5 additions & 4 deletions examples/pokemon/src/machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,11 @@ const machine = Machine.make({
Loading: {
invoke: Machine.invoke({
id: "load-team",
effect: Effect.gen(function*() {
const service = yield* PokemonService
return yield* service.getRandomTeam()
}),
effect: () =>
Effect.gen(function*() {
const service = yield* PokemonService
return yield* service.getRandomTeam()
}),
onDone: ({ output, target }) => target.full.ActiveTeam.from({ team: output }),
onFailure: ({ target }) => target.full.Failed.from()
})
Expand Down
2 changes: 1 addition & 1 deletion examples/pokemon/src/machines/replace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export const ReplaceMachine = Machine.make({
Replacing: {
invoke: Machine.invoke({
id: "replaceWithRandom",
effect: replaceWithRandom,
effect: () => replaceWithRandom,
onDone: ({ output, target }, enqueue) => {
enqueue.raise(ReplaceInternalEvents.Replaced({ pokemon: output.pokemon }))
return target.none()
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
"test": "vitest run",
"test:types": "tstyche",
"check:architecture": "node --test scripts/check-architecture.test.mjs && node scripts/check-architecture.mjs",
"check:ci": "node --test scripts/ci-changes.test.mjs scripts/runtime-performance-compatibility.test.mjs scripts/runtime-performance-regression.test.mjs",
"check:ci": "node --test scripts/ci-changes.test.mjs scripts/invoke-autocomplete.test.mjs scripts/runtime-performance-compatibility.test.mjs scripts/runtime-performance-regression.test.mjs",
"typecheck": "tsc -p tsconfig.json --noEmit",
"perf:types": "pnpm build && node scripts/type-performance.mjs",
"perf:runtime": "pnpm build && node --expose-gc scripts/runtime-performance.mjs",
Expand Down
2 changes: 1 addition & 1 deletion scripts/fixtures/consumer/consumer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ const cluster = ClusterMachine.make("ConsumerEntity", machine, {
})
const invoked = Machine.invoke({
id: "fixture-load",
effect: Effect.succeed("ready"),
effect: () => Effect.succeed("ready"),
onDone: ({ target }) => target.none()
})
const delayed = Machine.invoke({
Expand Down
2 changes: 1 addition & 1 deletion scripts/fixtures/consumer/deep-bound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ const machine = definition.handle({
Idle: {
invoke: definition.invoke({
id: "deep-inline-invoke",
effect: Effect.asVoid(ExternalService),
effect: () => Effect.asVoid(ExternalService),
onDone: ({ target }) => target.none()
}),
on: {
Expand Down
92 changes: 92 additions & 0 deletions scripts/invoke-autocomplete.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { strict as assert } from "node:assert"
import fs from "node:fs"
import path from "node:path"
import { test } from "node:test"
import ts from "typescript"

const projectRoot = path.resolve(import.meta.dirname, "..")
const virtualFile = path.join(projectRoot, "invoke-autocomplete.fixture.ts")
const source = `
import { Effect } from "effect"
import { Machine } from "./src/index.js"

const States = Machine.defineStates({ Loading: {}, Done: {}, Failed: {} })
const definition = Machine.make({
states: States.states,
events: Machine.events(),
initial: () => States.initial.Loading.from()
})

definition.handle({
Loading: {
invoke: Machine.invoke({
id: "load",
effect: () => Effect.fail("offline").pipe(Effect.as(1)),
onDone: ({ /*done-context*/ }) => States.initial.Done.from(),
onFailure: ({ /*failure-context*/ }) => States.initial.Failed.from()
})
},
Done: {},
Failed: {}
})

definition.handle({
Loading: {
invoke: Machine.invoke({
id: "incomplete",
effect: () => Effect.fail("offline").pipe(Effect.as(1)),
/*invoke-properties*/
})
},
Done: {},
Failed: {}
})
`

const config = ts.readConfigFile(path.join(projectRoot, "tsconfig.json"), ts.sys.readFile)
const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, projectRoot)
const host = {
directoryExists: ts.sys.directoryExists,
fileExists: ts.sys.fileExists,
getCompilationSettings: () => parsed.options,
getCurrentDirectory: () => projectRoot,
getDefaultLibFileName: (options) => ts.getDefaultLibFilePath(options),
getDirectories: ts.sys.getDirectories,
getNewLine: () => ts.sys.newLine,
getScriptFileNames: () => [...parsed.fileNames, virtualFile],
getScriptSnapshot: (file) =>
file === virtualFile
? ts.ScriptSnapshot.fromString(source)
: fs.existsSync(file)
? ts.ScriptSnapshot.fromString(fs.readFileSync(file, "utf8"))
: undefined,
getScriptVersion: () => "0",
readDirectory: ts.sys.readDirectory,
readFile: ts.sys.readFile,
realpath: ts.sys.realpath,
useCaseSensitiveFileNames: () => ts.sys.useCaseSensitiveFileNames
}

const service = ts.createLanguageService(host)

const completions = (marker) => {
const position = source.indexOf(`/*${marker}*/`)
assert.notEqual(position, -1)
return new Set(service.getCompletionsAtPosition(virtualFile, position, {})?.entries.map((entry) => entry.name))
}

test("contextually completes Effect invocation factories while authoring", () => {
const done = completions("done-context")
assert.equal(done.has("output"), true)
assert.equal(done.has("state"), true)
assert.equal(done.has("target"), true)

const failure = completions("failure-context")
assert.equal(failure.has("error"), true)
assert.equal(failure.has("state"), true)
assert.equal(failure.has("target"), true)

const properties = completions("invoke-properties")
assert.equal(properties.has("onDone"), true)
assert.equal(properties.has("onFailure"), true)
})
Loading