express-slonik
Version:
Slonik transaction middleware
206 lines • 8.61 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.RequestTransactionContext = exports.IsolationLevels = exports.sql = void 0;
const crypto_1 = require("crypto");
const events_1 = __importDefault(require("events"));
const slonik_1 = require("slonik");
const zod_1 = require("zod");
const errors_1 = require("./errors");
exports.sql = (0, slonik_1.createSqlTag)({
typeAliases: {
void: zod_1.z.object({}).strict(),
},
});
/**
* PostgreSQL transaction isolation levels.
* @see {@link https://www.postgresql.org/docs/current/transaction-iso.html | PostgreSQL Documentation} for detailed
* information.
*/
exports.IsolationLevels = {
/**
* Default isolation level in PostgreSQL. Guarantees dirty reads never happen.
*
* @see {@link https://www.postgresql.org/docs/current/transaction-iso.html#XACT-READ-COMMITTED | READ COMMITTED}
*/
READ_COMMITTED: exports.sql.fragment `READ COMMITTED`,
/**
* Higher isolation level than READ COMMITTED. Guarantees repeatable reads _in addition to_
* never allowing dirty reads.
*
* @see {@link https://www.postgresql.org/docs/current/transaction-iso.html#XACT-REPEATABLE-READ | REPEATABLE READ}
*/
REPEATABLE_READ: exports.sql.fragment `REPEATABLE READ`,
/**
* Highest isolation level. Guarantees phantom reads and serialization anomalies never happen
* _in addition to_ never allowing non-repeatable reads and dirty reads.
*
* @see {@link https://www.postgresql.org/docs/current/transaction-iso.html#XACT-SERIALIZABLE | SERIALIZABLE}
*/
SERIALIZABLE: exports.sql.fragment `SERIALIZABLE`,
};
/**
* Transaction context that wraps the Slonik transaction as an EventEmitter.
*
* @param transaction DatabaseTransactionConnection instance
*/
class TransactionContext extends events_1.default {
constructor(transactionId, transaction, options) {
super(options);
this.transactionId = transactionId;
this.transaction = transaction;
this.options = options;
}
commit() {
this.emit("commit");
}
rollback(error) {
this.error = error;
this.emit("rollback", error);
}
}
class RequestTransactionContext {
constructor(pool) {
this.pool = pool;
this.transactionContext = {};
}
static getOrCreateContext(pool) {
if (!RequestTransactionContext.instance) {
RequestTransactionContext.instance = new RequestTransactionContext(pool);
}
return RequestTransactionContext.instance;
}
/**
* Middleware function for starting a transaction context. While the transaction context is open,
* Slonik DatabaseTransactionConnection object is available under req.transaction.
*
* @param isolationLevel - PostgreSQL [transaction isolation level](https://www.postgresql.org/docs/current/transaction-iso.html). Defaults to read committed isolation level.
* @param retryLimit - Number of times to retry transaction. Defaults to `5`.
*/
begin(isolationLevel = exports.IsolationLevels.READ_COMMITTED, retryLimit = 5) {
return async (req, res, next) => {
if (!this.pool) {
return next(new errors_1.UndefinedPoolError("Pool is not instantiated"));
}
const transactionId = (0, crypto_1.randomUUID)();
try {
await this.pool.transaction(async (transaction) => {
await transaction.query(exports.sql.typeAlias("void") `SET TRANSACTION ISOLATION LEVEL ${isolationLevel};`);
this.transactionContext[transactionId] = new TransactionContext(transactionId, transaction);
const transactionContext = this.transactionContext[transactionId];
req.transactionId = transactionId;
req.transaction = transaction;
const autoCommit = transactionContext.commit.bind(transactionContext);
const autoRollback = transactionContext.commit.bind(transactionContext);
// Allow transaction to be automatically committed or rolled back when response is sent.
// These event handlers must be removed when the promise below is either resolved or
// rejected.
res.on("finish", autoCommit).on("error", autoRollback);
// Hold the transaction open until committed or on error.
await new Promise((resolve, reject) => {
// We use .once (as opposed to .on) because we want to commit or rollback at most
// once.
transactionContext
.once("commit", () => {
// Prevent the response events from being registered multiple times if
// transaction.begin() is called again down the middleware chain.
res.off("finish", autoCommit).off("error", autoRollback);
resolve();
})
.once("rollback", (error) => {
// Prevent the response events from being registered multiple times if
// transaction.begin() is called again down the middleware chain.
res.off("finish", autoCommit).off("error", autoRollback);
reject(error);
});
// While the transaction is held open, hand off control to next middleware.
next();
});
}, retryLimit);
// Hand control over to the next middleware in the pipeline when transaction is completed.
next();
}
catch (error) {
next(error);
}
finally {
// Outside of transaction context, the req.transaction is undefined.
delete req.transaction;
delete req.transactionId;
delete this.transactionContext[transactionId];
}
};
}
/**
* Commit the current transaction.
*/
commit() {
return (req, res, next) => {
if (!req.transactionId) {
return next(new errors_1.TransactionOutOfBoundsError("Cannot commit outside of transaction"));
}
// No need to call next() here since this.begin handler will call next()
// when the promise resolves.
this.transactionContext[req.transactionId].commit();
};
}
/**
* Catch any errors in the request handler stack to rollback transaction.
*/
catchError() {
return (error, req, res, next) => {
if (req.transactionId) {
// No need to call next(error) here since this.begin handler will call next(err) when
// promise rejects.
this.transactionContext[req.transactionId].rollback(error);
}
else {
// Hand off control to next error handler if outside of transaction context.
next(error);
}
};
}
/**
* Ends the transaction. If there are no errors in the request handler chain, the transaction is
* automatically committed. Otherwise, it is rolled back.
*/
end() {
return [this.commit(), this.catchError()];
}
}
exports.RequestTransactionContext = RequestTransactionContext;
function isDatabasePool(poolLike) {
const keys = [
"any",
"anyFirst",
"configuration",
"connect",
"end",
"exists",
"getPoolState",
"many",
"manyFirst",
"maybeOne",
"maybeOneFirst",
"one",
"oneFirst",
"query",
"stream",
"transaction",
];
return keys.every(Object.prototype.hasOwnProperty.bind(poolLike));
}
/**
* Request handler wrapped in express-slonik context.
* @param pool - Slonik {@link DatabasePool} instance
*/
function createMiddleware(pool) {
if (!isDatabasePool(pool)) {
throw new TypeError("Argument must be an instance of Slonik pool instance");
}
return RequestTransactionContext.getOrCreateContext(pool);
}
exports.default = createMiddleware;
//# sourceMappingURL=middleware.js.map