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.

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);
  },
});

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