Skip to main content
@durable-effect/task ships with in-memory implementations of the Storage and Alarm services so you can test task logic without deploying to Cloudflare.

Test stack overview

ComponentReturnsPurpose
makeInMemoryStorage(){ layer: Layer<Storage>, handle: InMemoryStorageHandle }In-memory key-value storage with an inspection handle
makeInMemoryAlarm(){ layer: Layer<Alarm>, handle: InMemoryAlarmHandle }In-memory alarm with an inspection handle
buildRegistryLayer(config)Layer<TaskRegistry>Builds a registry from a name-to-task map
registerTask(definition)RegisteredTaskRegisters a definition whose R is already never
registerTaskWithLayer(def, layer)RegisteredTaskRegisters a definition that requires a service layer
TaskRunnerLiveLayer<TaskRunner, never, TaskRegistry | Storage | Alarm>Wires TaskRunner to the registry, storage, and alarm

Building a test stack

Call makeInMemoryStorage() and makeInMemoryAlarm() to get layers and handles, then assemble them with buildRegistryLayer and TaskRunnerLive:
import {
  TaskRunnerLive,
  makeInMemoryStorage,
  makeInMemoryAlarm,
  buildRegistryLayer,
  registerTask,
} from "@durable-effect/task"
import { Layer } from "effect"

function makeTestStack() {
  const { layer: storageLayer, handle: storageHandle } = makeInMemoryStorage()
  const { layer: alarmLayer, handle: alarmHandle } = makeInMemoryAlarm()
  const registryLayer = buildRegistryLayer({
    counter: registerTask(counterTask),
  })

  const fullLayer = Layer.provide(
    TaskRunnerLive,
    Layer.mergeAll(registryLayer, storageLayer, alarmLayer),
  )

  return { fullLayer, storageHandle, alarmHandle }
}

Running the TaskRunner

Provide the full layer to your test program and use TaskRunner to dispatch events or trigger alarms:
import { Effect } from "effect"
import { TaskRunner } from "@durable-effect/task"

const program = Effect.gen(function* () {
  const runner = yield* TaskRunner
  yield* runner.handleEvent("counter", "c-1", { _tag: "Increment", amount: 5 })
  yield* runner.handleEvent("counter", "c-1", { _tag: "Increment", amount: 3 })
})

await Effect.runPromise(Effect.provide(program, fullLayer))

TaskRunner methods

MethodSignatureDescription
handleEvent(name, id, event)(name: string, id: string, event: unknown) => Effect<void, TaskNotFoundError | TaskValidationError | TaskExecutionError>Dispatch an event to a named task instance.
handleAlarm(name, id)(name: string, id: string) => Effect<void, TaskNotFoundError | TaskExecutionError>Trigger the alarm handler for a named task instance.

Inspecting state and alarms

After running your program, use the handles to assert on stored state and alarm scheduling.

InMemoryStorageHandle

MethodReturnsDescription
getData()Map<string, unknown>Returns the full in-memory storage map. State is stored under the key "t:state".
has(key)booleanCheck whether a key exists in storage.
keys()ReadonlyArray<string>List all stored keys.
clear()voidClear all stored data.

InMemoryAlarmHandle

MethodReturnsDescription
isScheduled()booleanWhether an alarm is currently scheduled.
getScheduledTime()number | nullThe scheduled alarm timestamp in milliseconds, or null if none.
clear()voidClear the scheduled alarm.

Complete example

import { Effect, Layer, Schema } from "effect"
import {
  Task,
  TaskRunner,
  registerTask,
  buildRegistryLayer,
  TaskRunnerLive,
  makeInMemoryStorage,
  makeInMemoryAlarm,
} from "@durable-effect/task"

// -- Define the task --

const CounterState = Schema.Struct({ count: Schema.Number })
const IncrementEvent = Schema.Struct({
  _tag: Schema.Literal("Increment"),
  amount: Schema.Number,
})

const counterTask = Task.define({
  state: CounterState,
  event: IncrementEvent,
  onEvent: (ctx, event) =>
    Effect.gen(function* () {
      const current = yield* ctx.recall()
      const count = current ? current.count : 0
      yield* ctx.save({ count: count + event.amount })
    }),
  onAlarm: () => Effect.void,
})

// -- Build the test stack --

function makeTestStack() {
  const { layer: storageLayer, handle: storageHandle } = makeInMemoryStorage()
  const { layer: alarmLayer, handle: alarmHandle } = makeInMemoryAlarm()
  const registryLayer = buildRegistryLayer({
    counter: registerTask(counterTask),
  })

  const fullLayer = Layer.provide(
    TaskRunnerLive,
    Layer.mergeAll(registryLayer, storageLayer, alarmLayer),
  )

  return { fullLayer, storageHandle, alarmHandle }
}

// -- Run the test --

const { fullLayer, storageHandle, alarmHandle } = makeTestStack()

const program = Effect.gen(function* () {
  const runner = yield* TaskRunner
  yield* runner.handleEvent("counter", "c-1", { _tag: "Increment", amount: 5 })
  yield* runner.handleEvent("counter", "c-1", { _tag: "Increment", amount: 3 })
})

await Effect.runPromise(Effect.provide(program, fullLayer))

// Assert on stored state
storageHandle.getData().get("t:state") // => { count: 8 }

// Assert on alarm state
alarmHandle.isScheduled()      // => false (no alarm was set in this task)
alarmHandle.getScheduledTime() // => null
registerTask(definition) requires that the definition’s R type parameter is already never. If your task depends on services, use registerTaskWithLayer(definition, layer) or call withServices(definition, layer) before registering.

Tasks with services in tests

For tasks that use Effect services, provide the layer at registration time using registerTaskWithLayer:
import { registerTaskWithLayer, buildRegistryLayer } from "@durable-effect/task"

const registryLayer = buildRegistryLayer({
  myTask: registerTaskWithLayer(myTask, MyServiceLive),
})

Build docs developers (and LLMs) love