Skip to content

Effect gates

Write a gate that changes something, like spending a credit, with defineEffectGate(). Covers when the change runs, what happens when a later gate refuses, and effect gates inside an or().

Say a command costs one credit. Your gate reads the balance and deducts one. A later gate then refuses. The command never ran, and the caller lost a credit anyway.

defineEffectGate splits your gate in two. check reads and refuses. commit applies the change, and seedcord holds that half back until every gate on the handler has passed.

src/gates/SpendCredit.ts
export const SpendCredit = defineEffectGate(
    'SpendCredit',
    async (ctx) => {
        if (ctx.userId === null) return;
        if ((await credits.balance(ctx.userId)) < 1) {
            throw new OutOfCredits();
        }
    },
    async (ctx) => {
        if (ctx.userId !== null) await credits.deduct(ctx.userId);
    }
);

You attach SpendCredit like any other gate.

@Gated(SpendCredit, GuildOnly())
@SlashRoute('search')
export class Search extends SlashHandler<'search'> {
    public async execute(): Promise<void> {
        await this.reply('Searching.');
    }
}

The order

seedcord runs every check before it runs any commit.

  1. Each gate's check, left to right, stopping at the first refusal.
  2. Every queued commit, in the order its gate ran.
  3. execute().

If someone runs that command in a DM, SpendCredit checks their balance first and queues its commit. GuildOnly then refuses. Step two never runs, so the caller keeps the credit.

A refusal anywhere in step one drops every queued commit. The same holds when a check throws a Fault, a Silence, or a plain error.

Warning

Step two finishes before step three starts. Your handler runs after every commit, so a throw inside it leaves the credit spent. If your users paid for that credit or can see their balance, catch the throw in your handler and refund the credit there.

Commits run one at a time. If a commit throws, the commits after it don't run, and neither does your handler. The commits before it already ran, so their changes stay.

The same happens inside a single commit. If it writes twice and throws between the two writes, the first write stays. Give each commit one write.

Inside an or

Each arm of an or can be a group of gates, like an and. If that group refuses, seedcord throws away the commits its gates queued, so they never run. The handler below passes SpendCredit to an and, and passes that and to an or.

hover for typestap for types, arrow keys walk the tokens
@Gated<readonly [Gate<GateContextBase, "GuildOnly">, Gate<GateContextBase, "(SpendCredit & RequireRole) | OwnerOnly">, EffectGate<GateContextBase, "Cooldown">]>(gates_0: Gate<GateContextBase, "GuildOnly">, gates_1: Gate<GateContextBase, "(SpendCredit & RequireRole) | OwnerOnly">, gates_2: EffectGate<GateContextBase, "Cooldown">): <TCtor>(ctor: readonly [GateFitsWith<ProvidedContext<TCtor>, KindName<...>, Gate<...>, "an agnostic">, GateFitsWith<...>, GateFitsWith<...>] extends readonly [...] ? TCtor : readonly [...]) => voidGated(
    
function GuildOnly(
    options?: GateNoticeOptions
): Gate<GateContextBase, "GuildOnly">
GuildOnly
(),
or<[Gate<GateContextBase, "SpendCredit & RequireRole">, Gate<GateContextBase, "OwnerOnly">]>(gates_0: Gate<GateContextBase, "SpendCredit & RequireRole">, gates_1: Gate<GateContextBase, "OwnerOnly">): Gate<GateContextBase, "(SpendCredit & RequireRole) | OwnerOnly"> (+1 overload)or( and<[EffectGate<GateContextBase, "SpendCredit">, Gate<GateContextBase, "RequireRole">]>(gates_0: EffectGate<GateContextBase, "SpendCredit">, gates_1: Gate<GateContextBase, "RequireRole">): Gate<GateContextBase, "SpendCredit & RequireRole">and(const SpendCredit: EffectGate<GateContextBase, "SpendCredit">SpendCredit,
function RequireRole(
    roleId: string,
    options?: RequireRoleOptions
): Gate<GateContextBase, "RequireRole">
RequireRole
('221439658686939136')),
function OwnerOnly(
    options?: GateNoticeOptions
): Gate<GateContextBase, "OwnerOnly">
OwnerOnly
()
),
function Cooldown(
    duration: number | ValidDuration,
    options?: CooldownOptions
): EffectGate<GateContextBase, "Cooldown">
Cooldown
('1m')
) @SlashRoute<"history">(...routes: "history"[]): <TCtor>(constructor: AssertSlashRoute<"history", TCtor>) => voidSlashRoute('history') export class class HistoryHistory extends
class SlashHandler<
    Route extends keyof SlashRegistry,
    Cache extends CacheType = CacheFor<Route>
>
SlashHandler
<'history'> {
public async History.execute(): Promise<void>execute(): interface Promise<T>Promise<void> { await this.RepliableHandler<ChatInputCommandInteraction<"cached">, Core, SentMessage, BufferResolvable | Stream | JSONEncodable<...> | Attachment | AttachmentBuilder | AttachmentPayload, ReplySender>.reply(response: string | ReplyResponse<BufferResolvable | Stream | JSONEncodable<APIAttachment> | Attachment | AttachmentBuilder | AttachmentPayload>, opts?: SendOpts): Promise<SentMessage>reply('Here it is.'); } }

A bot owner who lacks that role runs it. Read the checks down, then the commits.

1. every check, left to right
├─ GuildOnly            pass
├─ or
│  ├─ and
│  │  ├─ SpendCredit    pass, commit queued
│  │  └─ RequireRole    refuse
│  │
│  │  the and refuses, so this arm refuses
│  │  seedcord drops the queued commit
│  │
│  └─ OwnerOnly         pass, so the or passes
└─ Cooldown             pass, commit queued

2. every queued commit
└─ Cooldown             charges the slot

3. execute()

The owner keeps their credit and still spends a cooldown slot. Only the arm that passes keeps its commit. OwnerOnly passed without queuing a commit.

Note

Every commit runs after every check. A check never reads what another gate's commit wrote.

What the two halves share

If you annotate ctx on either half, both take that context. Your gate then fits the same handlers as a defineGate gate carrying that annotation.

Either half can return a promise. A synchronous one works too, so a commit that writes to a Map doesn't need async.

const uses = new Map<string, number>();

export const CountUse = defineEffectGate(
    'CountUse',
    () => {},
    (ctx) => {
        if (ctx.userId === null) return;
        uses.set(ctx.userId, (uses.get(ctx.userId) ?? 0) + 1);
    }
);

CountUse never refuses, so its check is empty. Its commit records the use in uses without awaiting anything.

Cooldown

Cooldown is an effect gate. It reads the window in check and charges the slot in commit, which is why a later refusal never records a use.