Akter

Timers and messages

A command can't call another actor or wait, because it holds the actor's transaction. Instead it writes rows that the engine acts on after the command commits: a timer runs one of the actor's own commands later, and a message runs a command on another actor.

Schedule a command⁠

Akter.schedule(delay, command, payload) runs one of the actor's own commands after delay, and returns the timer's id. Akter.cancel(id) removes a timer that hasn't fired.

Send a message⁠

Akter.send(command, key, payload) runs a command on another actor. The receiving member must list the actor caller, and its contract declares that caller's principal as Akter.ActorPrincipal, which names the sender's actor and key.

Try it⁠

An invite expires a week after it opens. Accepting it adds the person to a team:

team.contract.ts
import { Schema } from "effect"
import { Akter } from "@akterjs/effect/contract"

export class InviteExpiredError extends Schema.TaggedError<InviteExpiredError>()("InviteExpiredError", {}) {}

export const Team = Akter.make("Team", {
  key: Schema.String,
  callers: { user: Akter.Principal, actor: Akter.ActorPrincipal },
  state: Schema.Struct({ members: Schema.Array(Schema.String) }),
  initial: () => ({ members: [] }),
  commands: {
    addMember: Akter.command({ payload: { member: Schema.String }, success: Schema.Void, callers: ["actor"] }),
  },
  queries: {
    members: Akter.query({ success: Schema.Array(Schema.String) }),
  },
})

export const Invite = Akter.make("Invite", {
  key: Schema.String,
  state: Schema.Struct({ status: Schema.Literals(["pending", "accepted", "expired"]) }),
  initial: () => ({ status: "pending" as const }),
  commands: {
    open: Akter.command({ payload: {}, success: Schema.Void }),
    accept: Akter.command({ payload: {}, success: Schema.Void, error: InviteExpiredError }),
    expire: Akter.command({ payload: {}, success: Schema.Void }),
  },
})
team.actor.ts
import { Effect, Layer } from "effect"
import { Akter } from "@akterjs/effect/contract"
import { Invite, InviteExpiredError, Team } from "./team.contract.ts"

const TeamLive = Team.toLayer({
  commands: {
    addMember: (team, { member }) => Team.update({ members: [...team.members, member] }),
  },
  queries: {
    members: (team) => Effect.succeed(team.members),
  },
})

const InviteLive = Invite.toLayer({
  commands: {
    open: () => Effect.asVoid(Akter.schedule("7 days", Invite.commands.expire, {})),
    accept: (invite) =>
      Effect.gen(function* () {
        if (invite.status === "expired") {
          return yield* new InviteExpiredError()
        }
        const person = yield* Akter.actorKey
        yield* Invite.update({ status: "accepted" })
        yield* Akter.send(Team.commands.addMember, "core", { member: person })
      }),
    expire: (invite) => (invite.status === "pending" ? Invite.update({ status: "expired" }) : Effect.void),
  },
})

export const TeamApp = Layer.mergeAll(TeamLive, InviteLive)

story.advance moves Effect's TestClock forward and fires the timers that come due. Story.run uses the TestClock that @effect/vitest gives each test, and provides its own anywhere else, so this script runs as it is:

team.story.ts
import { Effect } from "effect"
import { Story } from "@akterjs/effect/testing"
import { Invite, Team } from "./team.contract.ts"
import { TeamApp } from "./team.actor.ts"

const program = Story.run(TeamApp, function* (story) {
  const ada = yield* story.actor(Invite, "ada")
  const bob = yield* story.actor(Invite, "bob")
  const team = yield* story.actor(Team, "core")

  yield* ada.open({})
  yield* bob.open({})
  yield* story.advance("1 day")
  yield* ada.accept({})

  yield* story.advance("7 days")
  const late = yield* Effect.flip(bob.accept({}))
  console.log(late._tag)
  console.log((yield* team.members()).join(", "))
})

Effect.runPromise(program)
Text
InviteExpiredError
ada

What you can rely on⁠

  • Timers and messages are rows written in the command's transaction. A command that fails leaves neither behind.
  • A timer fires once, at or after its time. Claiming the timer and running its command happen in one transaction.
  • A message is delivered after the sending command commits, and retried until the receiver answers. Each message carries its own command id, so a retry returns the first result instead of running the command twice.
  • A message the receiver refuses, for example with CallerNotAllowedError or ActorNotCreatedError, isn't retried. The sender's outbox records the refusal and logs a warning.
  • A message doesn't return the receiver's answer to the sender. To call another actor and use the reply, use a job.