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:
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 }),
},
})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:
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)InviteExpiredError
adaWhat 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
CallerNotAllowedErrororActorNotCreatedError, 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.