Transactions
Whenever you are performing multiple writes, usually in a controller, or wherever your business logic is, you will likely want to wrap those writes in a transaction.
Let's say, for example, you have a createManuscript
controller, which inserts a
manuscript, as well as a manuscript version, and then inserts two new teams in
the database. If the second team insertion fails, you will end up with a "corrupted"
state in your data, where you cannot be sure that that team has been created. It
is preferrable to roll back all changes, and throw an error.
Wrapping logic in a transaction will ensure that if any of the writes fail, all of them will be rolled back, even if some of them have successfully completed already.
useTransaction
To start using transactions, simply import useTransaction
from @coko/server
.
useTransaction
wraps a function. The first argument of that function will be the transaction
object. This object needs to be passed into all query
s, otherwise they'll be
performed outside the transaction.
In its simplest form, it will look like this:
const { useTransaction } = require('@coko/server')
const someBusinessLogic = () => {
try {
useTransaction(async trx =>
// pass trx object to `query`
await MyModel.query(trx).insert({
some: 'data',
})
await MyOtherModel.query(trx).insert({
and: 'how',
})
)
// here you can assume that all's gone well
} catch (e) {
throw new Error(e)
}
}
Make sure your transactions are wrapped in a try/catch
block.
Queries mixed in with writes inside a transaction
It is quite likely that your business logic will do some data fetching, as well as some data writing. If this is the case, it is advisable to pass the transaction object to the fetching queries as well, even though there's nothing to roll back.
const { useTransaction } = require('@coko/server')
const someBusinessLogicWithFetches = () => {
try {
useTransaction(async trx =>
await MyModel.query(trx).insert({
some: 'data',
})
// not a write - pass the `trx` object to `query` anyway
await MyModel.query(trx).findOne({
theData: true,
})
await MyOtherModel.query(trx).insert({
and: 'how',
})
)
} catch (e) {
throw new Error(e)
}
}
Nested Transactions
Let's say you have a function called createNewManuscript
. This function does two things,
so we want to wrap them in a transaction.
const createNewManuscript = async () => {
try {
return useTransaction(async (trx) => {
const manuscript = await Manuscript.query(trx).insert({
title: "My title",
});
await Version.query(trx).insert({
version: "1",
manuscriptId: manuscript.id,
});
return manuscript;
});
} catch (e) {
throw new Error(e);
}
};
If the version insert fails, the manuscript insert will be rolled back. So far so good.
Let's now say we have another piece of business logic that uses createNewManuscript
.
It also does multiple things, so we want to use a transaction there as well.
const someOtherBusinessLogic = () => {
try {
useTransaction(async (trx) => {
// ???
const manuscript = await createNewManuscript();
await MyOtherModel.query(trx).insert({
my: "data",
});
});
} catch (e) {
throw new Error(e);
}
};
We have an issue here. createNewManuscript
uses a transaction, but it is a
different transaction object than the one MyOtherModel
is using. This means
that in the code above, if the last insert fails, the manuscript creation logic
will not be rolled back!
We get around that by passing the transaction object and using useTransaction
's options.
const someOtherBusinessLogic = () => {
try {
useTransaction(async (trx) => {
// Pass the transaction object down to the other function
const manuscript = await createNewManuscript({ trx: trx });
await MyOtherModel.query(trx).insert({
my: "data",
});
});
} catch (e) {
throw new Error(e);
}
};
// Function now has an options argument that is empty by default
const createNewManuscript = async (options = {}) => {
try {
// Grab the transaction object from options
const { trx } = options;
return useTransaction(
async (trx) => {
const manuscript = await Manuscript.query(trx).insert({
title: "My title",
});
await Version.query(trx).insert({
version: "1",
manuscriptId: manuscript.id,
});
return manuscript;
},
// `useTransaction` accepts an object with options as a second argument.
// If trx is undefined, it will create an internal transaction object.
// If it is defined, it will use that.
{ trx: trx }
);
} catch (e) {
throw new Error(e);
}
};
What we have achieved here is that createManuscript
will use its own transaction
if options.trx
is undefined, but will use the caller function's transcation if
it is defined.
Only use a transaction if caller function says so
In functions where you have a single write, there is no need to use transactions
as there is nothing to roll back. If the single write fails, you catch
it and
you're done.
But you might want to be able to run this function as part of a larger transaction.
We can make this happen with the passedTrxOnly
option.
const someBusinessLogic = async () => {
try {
return useTransaction(async (trx) => {
// Pass the transaction object
const team = await createTeam({ trx: trx });
await MyModel.query(trx).insert({
all: "the data",
});
return manuscript;
});
} catch (e) {
throw new Error(e);
}
};
const createTeam = async (options = {}) => {
try {
return useTransaction(
async (trx) => {
// Team does a single write
return Team.query(trx).insert({
role: "someRole",
});
},
// If trx is defined, it will use that.
// If it is not defined, it will not use a transaction at all!
{
trx: trx,
passedTrxOnly: true,
}
);
} catch (e) {
throw new Error(e);
}
};
What we have achieved here is that createTeam
will run as part of a transaction
only if options.trx
is defined.
Summary
useTransaction(fn)
will create a transaction object that you can use infn
with your database queriesuseTransaction(fn, { trx: trx })
allows you to override a transaction object and use the caller function's transaction object insteaduseTransaction(fn, { trx: trx, passedTrxOnly: true })
allows you to run a function without a transaction, but still use the caller function's transaction object if it exists
When to use transactions
- You have multiple database writes within a single function that should succeed or fail as a block
- You have a single database write in a function, but it is called by another function that uses a transaction
API
Import
const { useTransaction } = require("@coko/server");
Arguments
callback
: (function -- required) The function that will run the transaction logic
options
: (object -- default {}
) The options object
useTransaction(callback, options);
Return value
useTransaction
returns whatever its callback function returns
Callback arguments
trx
: The transaction object that will be used.
This will be undefined
if options.trx
is undefined
and options.passedTrxOnly
is true
.
You can still use it with query(trx)
in this case. It will simply not do anything
(it is equivalent to running query()
).
useTransaction(async trx => /* your code */)
Options
trx
: (transaction object -- default undefined
) Explicitly declare the transaction object to use.
passedTrxOnly
: (boolean -- default false
) Only use a transaction if explicitly defined
useTransaction(async trx => /* your code */, {
trx: myExplicitTransaction,
passedTrxOnly: true,
})