npm package: @lafken/state-machine
Description: Build Step Functions workflows with TypeScript decorators - declarative orchestration with Lafken
Author: Aníbal Jorquera
Latest version: 0.10.6

# @lafken/state-machine

Define AWS Step Functions state machines using TypeScript decorators. `@lafken/state-machine` lets you declare states, transitions, service integrations, and nested workflows directly within your classes — no raw JSON or ASL required.

> [!NOTE]
> This library exclusively supports **JSONata** for data transformation and querying. **JSONPath is not supported**.

## Installation

```bash
npm install @lafken/state-machine
```

## Getting Started

Register the `StateMachineResolver` in your application and define your first workflow:

```typescript
import { createApp, createModule } from '@lafken/main';
import { StateMachineResolver } from '@lafken/state-machine/resolver';
import { StateMachine, State, Event } from '@lafken/state-machine/main';

// 1. Define the state machine
@StateMachine({
  startAt: 'processOrder',
})
class OrderWorkflow {
  @State({ next: 'notifyCustomer' })
  processOrder(@Event('{% $states.input %}') order: any) {
    return { orderId: order.id, status: 'processed' };
  }

  @State({ end: true })
  notifyCustomer(@Event('{% $states.input %}') data: any) {
    console.log(`Order ${data.orderId} completed`);
  }
}

// 2. Register it in a module
const orderModule = createModule({
  name: 'orders',
  resources: [OrderWorkflow],
});

// 3. Add the resolver to your app
createApp({
  name: 'my-app',
  resolvers: [new StateMachineResolver()],
  modules: [orderModule],
});
```

## Features

### Defining a State Machine

Use the `@StateMachine` decorator to mark a class as a state machine resource. The `startAt` property defines the entry point of the workflow and must reference either a method name or an inline state definition.

```typescript
import { StateMachine, State } from '@lafken/state-machine/main';

@StateMachine({
  startAt: 'validate',
})
export class PaymentWorkflow {
  @State({ next: 'charge' })
  validate(@Event('{% $states.input %}') input: any) {
    return { amount: input.amount, valid: true };
  }

  @State({ end: true })
  charge(@Event('{% $states.input %}') input: any) {
    return { charged: input.amount };
  }
}
```

You can also start with a declarative state instead of a Lambda task:

```typescript
@StateMachine({
  startAt: {
    type: 'wait',
    seconds: 5,
    next: { type: 'succeed' },
  },
})
export class DelayedWorkflow {}
```

### Lambda Tasks

The `@State` decorator turns a method into a Lambda-backed task within the state machine. Step Functions will invoke the Lambda automatically during execution.

Use `next` to chain to the following state, or `end: true` to mark it as a terminal state:

```typescript
@StateMachine({ startAt: 'enrich' })
export class DataPipeline {
  @State({ next: 'store' })
  enrich(@Event('{% $states.input %}') record: any) {
    return { ...record, enrichedAt: new Date().toISOString() };
  }

  @State({ end: true })
  store(@Event('{% $states.input %}') record: any) {
    console.log('Storing record:', record);
  }
}
```

#### Output Transformation

Use the `output` option to transform a state's result before passing it to the next state. It accepts a JSONata expression or a plain object:

```typescript
@State({
  next: 'process',
  output: '{% $states.result.data %}',
})
fetchData() {
  return { data: { items: [1, 2, 3] }, metadata: {} };
}
```

#### State Assignment

Use `assign` to add or update values in the state machine context. Assigned values persist across subsequent states:

```typescript
@State({
  next: 'sendEmail',
  assign: { attemptCount: '{% $states.input.attemptCount + 1 %}' },
})
retry(@Event('{% $states.input %}') input: any) {
  return input;
}
```

### AWS Service Integrations

Instead of invoking a Lambda function, a state can directly call an AWS service API. Use the `integrationResource` property to specify the service ARN, and the `@IntegrationOptions` decorator to access resource references.

This pattern eliminates the need for intermediate Lambda functions when you just need to call an AWS service (DynamoDB, SQS, SNS, etc.).

```typescript
import {
  StateMachine,
  State,
  Event,
  type IntegrationOptionsParams,
} from '@lafken/state-machine/main';
import { IntegrationOptions } from '@lafken/api/main';

@StateMachine({
  startAt: 'saveItem',
  services: ['dynamodb'],
})
export class InventoryWorkflow {
  @State({
    integrationResource: 'arn:aws:states:::dynamodb:putItem',
    next: 'confirm',
  })
  saveItem(@IntegrationOptions() { getResourceValue }: IntegrationOptionsParams) {
    return {
      TableName: getResourceValue('dynamo::inventory', 'id'),
      Item: {
        sku: { S: '{% $states.input.sku %}' },
        quantity: { N: '{% $string($states.input.quantity) %}' },
      },
    };
  }

  @State({ end: true })
  confirm(@Event('{% $states.input %}') e: any) {
    console.log('Item saved successfully');
  }
}
```

A read example using `getItem`:

```typescript
@State({
  integrationResource: 'arn:aws:states:::dynamodb:getItem',
  next: 'processResult',
  output: '{% $states.result.Item %}',
})
lookupUser(@IntegrationOptions() { getResourceValue }: IntegrationOptionsParams) {
  return {
    TableName: getResourceValue('dynamo::users', 'id'),
    Key: {
      email: { S: '{% $states.input.email %}' },
    },
  };
}
```

### Nested State Machines

Complex workflows often require sub-workflows for parallel execution or iteration over collections. Use `@NestedStateMachine` to define these embedded workflows.

#### Map State

A Map state iterates over a collection and executes a set of states for each item:

```typescript
import { StateMachine, NestedStateMachine, State, Event } from '@lafken/state-machine/main';

@NestedStateMachine({
  startAt: 'processItem',
})
class ItemProcessor {
  @State({ end: true })
  processItem(@Event('{% $states.input %}') item: any) {
    return { id: item.id, processed: true };
  }
}

@StateMachine({
  startAt: {
    type: 'map',
    mode: 'inline',
    items: '{% $states.input.items %}',
    states: ItemProcessor,
    end: true,
  },
})
export class BatchWorkflow {}
```

#### Parallel State

A Parallel state runs multiple branches concurrently. Each branch is a class decorated with `@NestedStateMachine`:

```typescript
@NestedStateMachine({ startAt: 'sendEmail' })
class EmailBranch {
  @State({ end: true })
  sendEmail(@Event('{% $states.input %}') e: any) {
    console.log('Sending email...');
  }
}

@NestedStateMachine({ startAt: 'sendSms' })
class SmsBranch {
  @State({ end: true })
  sendSms(@Event('{% $states.input %}') e: any) {
    console.log('Sending SMS...');
  }
}

@StateMachine({
  startAt: {
    type: 'parallel',
    branches: [EmailBranch, SmsBranch],
    end: true,
  },
})
export class NotificationWorkflow {}
```

### Choice State

Use the `choice` type in `startAt` or `next` to define conditional branching based on JSONata expressions:

```typescript
@StateMachine({
  startAt: {
    type: 'choice',
    choices: [
      {
        condition: '{% $states.input.priority = "high" %}',
        next: 'expedite',
      },
      {
        condition: '{% $states.input.priority = "low" %}',
        next: 'enqueue',
      },
    ],
    default: { type: 'fail', error: 'UnknownPriority', cause: 'Priority not recognized' },
  },
})
export class RoutingWorkflow {
  @State({ end: true })
  expedite(@Event('{% $states.input %}') e: any) {
    return { routed: 'express' };
  }

  @State({ end: true })
  enqueue(@Event('{% $states.input %}') e: any) {
    return { routed: 'standard' };
  }
}
```

### Error Handling

States support `retry` and `catch` configurations for resilient workflows:

```typescript
@State({
  next: 'done',
  retry: [
    {
      errorEquals: ['States.TaskFailed'],
      intervalSeconds: 2,
      maxAttempt: 3,
      backoffRate: 2,
    },
  ],
  catch: [
    {
      errorEquals: ['States.ALL'],
      next: { type: 'fail', error: 'ProcessingError', cause: 'Task failed after retries' },
    },
  ],
})
riskyOperation(@Event('{% $states.input %}') input: any) {
  // This operation will be retried up to 3 times on failure
  return { result: 'done' };
}
```

### State Events & Payloads

Each `@State` method can receive input data through the `@Event` decorator. You can pass either a JSONata expression or a typed `@Payload` class.

#### JSONata Expression

Extract or transform the incoming event inline:

```typescript
@State({ end: true })
handle(@Event('{% $states.input.user %}') user: { name: string; role: string }) {
  console.log(`User: ${user.name}, Role: ${user.role}`);
}
```

#### Typed Payload

Define a structured payload class with `@Payload` and `@Param` decorators for clear, declarative input mapping:

```typescript
import { Payload, Param } from '@lafken/state-machine/main';

@Payload()
export class InvoiceInput {
  @Param({ context: 'input', source: 'invoiceId' })
  invoiceId: string;

  @Param({ context: 'input', source: 'lineItems', type: [Number] })
  lineItems: number[];

  @Param({ context: 'execution', source: 'id' })
  executionId: string;
}

@StateMachine({ startAt: 'generateInvoice' })
export class InvoiceWorkflow {
  @State({ next: 'send' })
  generateInvoice(@Event(InvoiceInput) input: InvoiceInput) {
    return { id: input.invoiceId, total: input.lineItems.reduce((a, b) => a + b, 0) };
  }

  @State({ end: true })
  send(@Event('{% $states.input %}') invoice: any) {
    console.log('Sending invoice:', invoice.id);
  }
}
```

Available `@Param` contexts:

| Context          | Description                                 | Example sources                        |
| ---------------- | ------------------------------------------- | -------------------------------------- |
| `input`          | Values from the state input                 | Any field name (e.g. `'orderId'`)      |
| `execution`      | Execution metadata                          | `'id'`, `'name'`, `'start_time'`       |
| `state`          | Current state metadata                      | `'entered_time'`, `'name'`             |
| `state_machine`  | State machine metadata                      | `'id'`, `'name'`                       |
| `task`           | Task metadata                               | `'token'`                              |
| `custom`         | A hardcoded value                           | Use `value` instead of `source`        |
| `jsonata`        | A dynamic JSONata expression                | Use `value` with a JSONata string      |