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 toTransactionHost#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 transactionoptions
object (which contains the transaction-specific options merged with the default ones) and thesetTx
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 thetx
property onTransactionHost
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 thetx
property onTransactionHost
.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 asKnex
itself, because it's the type of theknex
instance that we'll use to start the transaction.TTx
- While the type of thetx
instance passed toknex.transaction
is typed asKnex.Transaction
, it also exposes methods that are specific to the transactional context. Therefore, we'll use the baseKnex
type here as well, because issuing queries is all that is really needed.TOptions
- Knex provides an existing type calledKnex.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.
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
.