Durable Hooks
Persist side effects in the same transaction and run them after commit with retries
Durable hooks solve a common problem:
What if your database transaction commits, but your side effect (email, webhook, API call) fails?
With durable hooks, you register a hook trigger during the transaction. The trigger is persisted in the database as part of the same commit, and then the hook is executed after the commit. If execution fails, it's retried with an exponential backoff policy.
Defining hooks
Hooks are defined on the fragment definition:
Use defineHook() + function syntax
Hook implementations must be created via defineHook(...) and written with function (...) { ... }
(or async function (...) { ... }) so the hook this context is available and typed. Arrow
functions (() => {}) don't have their own this, so you won't be able to access this.idempotencyKey.
export const fragmentDef = defineFragment<Config>("my-fragment")
.extend(withDatabase(mySchema))
.provideHooks(({ defineHook, config }) => ({
onSubscribe: defineHook(async function (payload: { email: string }) {
// Hook functions run outside the transaction, after commit.
// `this.idempotencyKey` is a unique idempotency key for this transaction.
await config.onSubscribe?.({ idempotencyKey: this.idempotencyKey, email: payload.email });
}),
}))
.build();Hook context (this)
Hook functions receive a this context that includes:
idempotencyKey: a unique idempotency key for the originating transaction (use for idempotency)
Triggering hooks from services
Within a service method, trigger a hook using uow.triggerHook() inside the mutate callback:
subscribe: function (email: string) {
return this.serviceTx(mySchema)
.mutate(({ uow }) => {
const id = uow.create("subscriber", { email, subscribedAt: new Date() });
// Register side effect to run after commit (and retry on failure)
uow.triggerHook("onSubscribe", { email });
return { id, email };
})
.build();
}The key point is that the hook trigger is part of your transaction: if the transaction rolls back, the hook trigger is not recorded, so the side effect won't run.
Scheduling hooks (processAt)
You can schedule the first hook attempt for a specific time using processAt:
uow.triggerHook("onSubscribe", { email }, { processAt: new Date(Date.now() + 60_000) });- If
processAtis in the future, the hook is stored as pending until that time. - If
processAtis in the past (or omitted), the hook is eligible immediately. - Retries still follow the retry policy;
processAtonly affects the first attempt.
Running the transaction in a route handler
Hooks are recorded when the handler executes the transaction:
defineRoute({
method: "POST",
path: "/subscribe",
handler: async function ({ input }, { json }) {
const { email } = await input.valid();
const result = await this.handlerTx()
.withServiceCalls(() => [services.subscribe(email)] as const)
.transform(({ serviceResult: [result] }) => result)
.execute();
return json(result);
},
});Dispatching hooks outside requests
Durable hooks are processed after mutations, but you should also run a background dispatcher so retries and scheduled hooks fire even when no new requests arrive.
Node (polling)
import { createDurableHooksProcessor } from "@fragno-dev/db/dispatchers/node";
const dispatcher = createDurableHooksProcessor([fragment], {
pollIntervalMs: 2000,
});
if (dispatcher) {
dispatcher.startPolling();
}Cloudflare Durable Objects (alarms)
import { createDurableHooksProcessor } from "@fragno-dev/db/dispatchers/cloudflare-do";
export class DurableHooksDispatcher {
handler: ReturnType<ReturnType<NonNullable<typeof createDurableHooksProcessor>>>;
constructor(state: DurableObjectState, env: Env) {
const dispatcher = createDurableHooksProcessor([env.fragment]);
this.handler = dispatcher(state, env);
}
alarm() {
return this.handler.alarm?.();
}
}Recovering stuck hooks
If a worker crashes after marking a hook as processing, that hook can remain stuck forever. Fragno
automatically re-queues hooks that have been in processing for too long (default: 10 minutes).
You can configure or disable this behavior when instantiating the fragment:
const fragment = instantiate(fragmentDef)
.withConfig(config)
.withOptions({
databaseAdapter,
durableHooks: {
// Minutes a hook may stay in `processing` before it is re-queued.
// Use `false` to disable stuck-processing recovery entirely.
stuckProcessingTimeoutMinutes: 10,
onStuckProcessingHooks: ({ namespace, timeoutMinutes, events }) => {
console.warn(
`Re-queued ${events.length} stuck hooks in ${namespace} after ${timeoutMinutes} minutes`,
events,
);
},
},
})
.build();When hooks are re-queued they may run again, so hook implementations must remain idempotent. If
stuckProcessingTimeoutMinutes is set to false, no stuck-processing checks run and the callback
will not fire.
Retry behavior
If a hook execution fails, it will be retried with an exponential backoff policy. This makes hooks safe for "at least once" delivery, as long as your hook implementation is idempotent.
Use the idempotencyKey to make idempotency easy:
- store processed idempotency keys in your external system
- or pass the idempotencyKey as an idempotency key to third-party APIs that support it