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