Database Integration

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 processAt is in the future, the hook is stored as pending until that time.
  • If processAt is in the past (or omitted), the hook is eligible immediately.
  • Retries still follow the retry policy; processAt only 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