Deadlines
A saga that waits needs a deadline. A large transfer waits for a support agent’s approval, and expires if nobody approves it within 24 hours. Schedule a command for the deadline, and let the aggregate decide whichever comes first.
Declare a command that reports the deadline
Section titled “Declare a command that reports the deadline”A scheduled command fires when nobody is waiting for its answer, so it never rejects. It reports a fact, and records nothing when the fact no longer applies.
import * as Domain from "@alchemy.run/domain";import { TransferFailed } from "./FailTransfer.command.ts";import { TransferId } from "./Transfer.id.ts";
/** * The approval window closed. Scheduled by `TransferApprovalDeadline`; it * reports a fact, so it never rejects, and it records nothing if the transfer * was approved in time. */export class ExpireTransfer extends Domain.Command("ExpireTransfer", { target: TransferId, events: [TransferFailed],}) {}Schedule it
Section titled “Schedule it”A policy schedules the command when the waiting starts.
import * as Domain from "@alchemy.run/domain";import * as Effect from "effect/Effect";import { ExpireTransfer } from "./ExpireTransfer.command.ts";import { TransferRequested } from "./RequestTransfer.command.ts";import { Transfer } from "./Transfer.aggregate.ts";
/** How long a support agent has to approve a large transfer. */export const APPROVAL_WINDOW = "24 hours";
/** * Whenever a transfer needs approval, expire it if nobody approves it in time. * The Transfer decides the race: approval and expiry are commands on the same * instance, so exactly one of them wins. */export class TransferApprovalDeadline extends Domain.Policy("TransferApprovalDeadline", { from: Transfer, on: [TransferRequested],}) {}
export const TransferApprovalDeadlineLive = TransferApprovalDeadline.toLayer( Effect.gen(function* () { const transfers = yield* Transfer; return Effect.fn(function* ({ source, event }) { if (!event.needsApproval) return; yield* transfers.schedule(source, new ExpireTransfer(), { after: APPROVAL_WINDOW }); }); }),);The schedule is durable: it fires even if everything restarted in between. Scheduling again with the same command id replaces the earlier one.
A policy can run twice for the same event. Its schedule then arms the same expiry again, which changes nothing: it stays due when it was, and if it already ran, it doesn’t run again.
Let the aggregate decide the race
Section titled “Let the aggregate decide the race”Approval and expiry are both commands to the same transfer. One may come from a support agent at any moment, the other from the schedule after 24 hours.
The transfer handles one command at a time, so whichever arrives first changes its state, and its decide turns the other away. Approval is only accepted while the transfer is awaiting approval:
ApproveTransfer: Effect.fn(function* (s, _, { id }) { const approvedBy = yield* CurrentActor; if (approvedBy._tag !== "Agent") return yield* new NotPermitted(); if (s._tag !== "AwaitingApproval") return yield* new NotAwaitingApproval();Expiry fails the transfer only if it’s still waiting, and records nothing otherwise:
ExpireTransfer: (s, _, { id }) => s._tag === "AwaitingApproval" ? [new TransferFailed({ transferId: id, reason: "not approved in time" })] : [],So an approved transfer ignores its expiry, and an expired transfer rejects a late approval with NotAwaitingApproval.
Cancel it
Section titled “Cancel it”When the deadline no longer applies, cancel it. Schedule the command with a command id you can name again, and unschedule it by that id:
// when the waiting startsyield* transfers.schedule(source, new ExpireTransfer(), { after: APPROVAL_WINDOW, commandId: `expire:${source.id}`,});
// when the transfer is approvedyield* transfers.unschedule(source, `expire:${source.id}`);schedule returns a ScheduleReceipt with the command id and when the command is due (at), so code that schedules without its own id can still cancel by receipt.commandId.
Unscheduling is durable, like scheduling. Once it succeeds, the command never fires, even when a story’s clock passes its time. When there was nothing to cancel, it fails and says why: AlreadyExecuted when the command already ran, AlreadyCancelled when it was cancelled before, and NotScheduled when nothing was scheduled with that id.
A policy cancels after the approval is recorded, so the expiry can still arrive in between. Keep the guard in decide for that case. A policy can also run again for the same event, and then finds the expiry already cancelled:
yield* transfers.unschedule(source, `expire:${source.id}`).pipe( Effect.catchTag(["AlreadyExecuted", "AlreadyCancelled"], () => Effect.void),);Arm it again
Section titled “Arm it again”A deadline can need to start over. A ticket stalls an hour after it opens or resumes, unless it’s paused first. Arm the stall timer under one id per ticket, from both events:
yield* tickets.schedule(source, new MarkStalled(), { after: "1 hour", commandId: `stall:${source.id}`,});A resume before the hour replaces the pending timer, so the ticket never stalls early. A ticket paused through the hour gets the timer as a no-op, and MarkStalled records nothing. When it resumes, scheduling stall:<id> again arms a new timer, which fires an hour later.
Test it with the clock
Section titled “Test it with the clock”A story has its own clock. Story.clock moves it, and any scheduled command that’s now due fires.
// A minute before the deadline, nothing has happened.Story.clock("2026-01-02T08:59:00Z"),Story.then(transfer),// At the deadline, the transfer expires.Story.clock("2026-01-02T09:00:00Z"),Story.then(transfer, new TransferFailed({ transferId: transfer.id, reason: "not approved in time" })),// Approval now comes too late.Story.provide(CurrentActor, agent("support-7")),Story.when(transfer, new ApproveTransfer()),Story.rejected(new NotAwaitingApproval()),Story.then(transfer) with no events checks that nothing new was recorded.