Skip to main content

Creating a custom adapter

If you can't find an adapter for a database library you want to use with @nestjs-cls/transactional, you can create a custom adapter. See below for a step-by-step guide.

Adapter interface

A transactional adapter is an instance of an object implementing the following interface:

interface TransactionalAdapter<TConnection, TTx, TOptions> {
connectionToken: any;
defaultTyOptions?: Partial<TOptions>;
optionsFactory: TransactionalOptionsAdapterFactory<
TConnection,
TTx,
TOptions
>;
}

The connectionToken is an injection token under which the underlying database connection object is provided.

The defaultTxOptions object is the default transaction options that are used when no options are passed to the withTransaction call.

An optionFactory is a function that takes the injected connection object and returns the adapter options object of interface:

interface TransactionalAdapterOptions<TTx, TOptions> {
wrapWithTransaction: (
options: TOptions,
fn: (...args: any[]) => Promise<any>,
setTx: (client: TTx) => void,
) => Promise<any>;
getFallbackInstance: () => TTx;
}

This object contains two methods:

  • wrapWithTransaction - a function that takes the method decorated with @Transactional (or a callback passed to TransactionHost#withTransaction) and wraps it with transaction handling logic. It should return a promise that resolves to the result of the decorated method. The other parameter is the adapter-specific transaction options object (which contains the transaction-specific options merged with the default ones) and the setTx function which should be called with the transaction instance to make it available in the CLS context.

  • getFallbackInstance - when a transactional context is not available, this method is used to return a "fallback" instance of the transaction object. This is needed for cases when the tx property on TransactionHost is accessed outside of a transactional context.

Typing

The most important (and tricky) part of creating a custom adapter is to define the typing for the transaction instance.

It is important to note that the tx property of TransactionHost must work both inside and outside of a transactional context. Therefore it should not have any methods that are specific to a transactional context, because they would be unavailable outside of it (and cause runtime errors).

For an adapter, we're going to need to define the following types:

  • TConnection - a type of the "connection" object. This can be anything that lets us create an instance of the transaction.
  • TTx - a type of the transaction instance. This is the type of the tx property on TransactionHost.
  • TOptions - a type for the options object that is passed to the underlying library's transaction handling method.

Step-by-step Guide

In this guide, we'll show step-by-step how to create a custom adapter for the knex library.

How Knex handles transactions

First, let's take a look at how knex handles transactions and queries, so we can understand what we need to do to create a custom adapter for it.

import { Knex } from 'knex';

const knex = Knex({
// [...] knex init settings
});

async function main() {
// Knex uses the transaction method on the `knex` instance to start a new transaction.
await knex.transaction(
// The first parameter to the method is a callback that receives a `tx` object.
async (tx) => {
// Within the callback, the `tx` object refers to the same transaction instance.
// This is what we'll need to store in the CLS context.
await tx('users').insert({ name: 'John' });
await tx('users').insert({ name: 'Jane' });
},
// And the second parameter is the transaction options.
{ isolationLevel: 'serializable' },
);

// The `knex` instance itself can be used to issue queries outside of
// the transactional context. This is what we'll provide as the fallback.
const users = await knex('users')
.where({ name: 'John' })
.orWhere({ name: 'Jane' });
}

Deciding the typing for the Knex adapter

As seen above, we'll need to define the following types:

  • TConnection - This can be typed as Knex itself, because it's the type of the knex instance that we'll use to start the transaction.
  • TTx - While the type of the tx instance passed to knex.transaction is typed as Knex.Transaction, it also exposes methods that are specific to the transactional context. Therefore, we'll use the base Knex type here as well, because issuing queries is all that is really needed.
  • TOptions - Knex provides an existing type called Knex.TransactionConfig for the transaction options, so we'll just use that.

Putting it all together

While the adapter itself can be any object that implements the TransactionalAdapter interface, we'll create a class that implements it.

export class MyTransactionalAdapterKnex
implements TransactionalAdapter<Knex, Knex, Knex.TransactionConfig>
{
// implement the property for the connection token
connectionToken: any;

// implement default options feature
defaultTxOptions?: Partial<Knex.TransactionConfig>;

// We can decide on a custom API for the transactional adapter.
// In this example, we just pass individual parameters, but
// a custom interface is usually preferred.
constructor(
myKnexInstanceToken: any,
defaultTxOptions: Partial<Knex.TransactionConfig>,
) {
this.connectionToken = myKnexInstanceToken;
this.defaultTxOptions = defaultTxOptions;
}

//
optionsFactory(knexInstance: Knex) {
return {
wrapWithTransaction: (
// the options object is the transaction-specific options merged with the default ones
options: Knex.TransactionConfig,
fn: (...args: any[]) => Promise<any>,
setTx: (client: Knex) => void,
) => {
// We'll use the `knex.transaction` method to start a new transaction.
return knexInstance.transaction(
(tx) => {
// We'll call the `setTx` function with the `tx` instance
// to store it in the CLS context.
setTx(tx);
// And then we'll call the original method.
return fn();
},
// Don't forget to pass the options object, too
options,
);
},
// The fallback is the `knex` instance itself.
getFallbackInstance: () => knexInstance,
};
}
}

Using the custom adapter

Like any other adapter, you just pass an instance of it to the adapter option of ClsPluginTransactional:

ClsModule.forRoot({
plugins: [
new ClsPluginTransactional({
// Don't forget to import the module which provides the knex instance
imports: [KnexModule],
adapter: new MyTransactionalAdapterKnex(KNEX_TOKEN, { isolationLevel: 'serializable' }),
}),
],
}),

When injecting the TransactionHost, type it as TransactionHost<MyTransactionalAdapterKnex> to get the correct typing of the tx property.

In a similar manner, use @Transactional<MyTransactionalAdapterKnex>() to get typing for the options object.

note

The TransactionalAdapter can also implement all Lifecycle hooks if there's any setup or teardown logic required.

However, being created manually outside of Nest's control, it can not inject any dependencies except for the pre-defined database connection instance via the connectionToken.