mongoose-transactions
Version:
Atomicity and Transactions for mongoose
455 lines (394 loc) • 15.2 kB
text/typescript
import * as mongoose from 'mongoose'
import {
TransactionModel,
Operation,
Status
} from './mongooseTransactions.collection'
/** Class representing a transaction. */
export default class Transaction {
/** Index used for retrieve the executed transaction in the run */
private rollbackIndex = 0
/** Boolean value for enable or disable saving transaction on db */
private useDb: boolean = false
/** The id of the current transaction document on database */
private transactionId = ''
/** The actions to execute on mongoose collections when transaction run is called */
private operations: Operation[] = []
/**
* Create a transaction.
* @param useDb - The boolean parameter allow to use transaction collection on db (default false)
* @param transactionId - The id of the transaction to load, load the transaction
* from db if you set useDb true (default "")
*/
constructor(useDb = false) {
this.useDb = useDb
this.transactionId = ''
}
/**
* Load transaction from transaction collection on db.
* @param transactionId - The id of the transaction to load.
* @trows Error - Throws error if the transaction is not found
*/
public async loadDbTransaction(transactionId: string) {
const loadedTransaction = await TransactionModel.findOne({
_id: transactionId
})
.lean()
.exec()
if (!loadedTransaction) return null
loadedTransaction.operations.forEach(operation => {
operation.model = mongoose.model(operation.modelName)
})
this.operations = loadedTransaction.operations
this.rollbackIndex = loadedTransaction.rollbackIndex
this.transactionId = transactionId
return loadedTransaction
}
/**
* Remove transaction from transaction collection on db,
* if the transactionId param is null, remove all documents in the collection.
* @param transactionId - Optional. The id of the transaction to remove (default null).
*/
public async removeDbTransaction(transactionId = null) {
try {
if (transactionId === null) {
await TransactionModel.deleteMany({}).exec()
} else {
await TransactionModel.deleteOne({ _id: transactionId }).exec()
}
} catch (error) {
throw new Error('Fail remove transaction[s] in removeDbTransaction')
}
}
/**
* If the instance is db true, return the actual or new transaction id.
* @throws Error - Throws error if the instance is not a db instance.
*/
public async getTransactionId() {
if (this.transactionId === '') {
await this.createTransaction()
}
return this.transactionId
}
/**
* Get transaction operations array from transaction object or collection on db.
* @param transactionId - Optional. If the transaction id is passed return the elements of the transaction id
* else return the elements of current transaction (default null).
*/
public async getOperations(transactionId = null) {
if (transactionId) {
return await TransactionModel.findOne({ _id: transactionId })
.lean()
.exec()
} else {
return this.operations
}
}
/**
* Save transaction operations array on db.
* @throws Error - Throws error if the instance is not a db instance.
* @return transactionId - The transaction id on database
*/
public async saveOperations() {
if (this.transactionId === '') {
await this.createTransaction()
}
await TransactionModel.updateOne(
{ _id: this.transactionId },
{
operations: this.operations,
rollbackIndex: this.rollbackIndex
}
)
return this.transactionId
}
/**
* Clean the operations object to begin a new transaction on the same instance.
*/
public async clean() {
this.operations = []
this.rollbackIndex = 0
this.transactionId = ''
if (this.useDb) {
await this.createTransaction()
}
}
/**
* Create the insert transaction and rollback states.
* @param modelName - The string containing the mongoose model name.
* @param data - The object containing data to insert into mongoose model.
* @returns id - The id of the object to insert.
*/
public insert(
modelName: string,
schema,
data,
options = {}
): mongoose.Types.ObjectId {
const model = mongoose.model(modelName, schema)
if (!data._id) {
data._id = new mongoose.Types.ObjectId()
}
const operation: Operation = {
data,
findId: data._id,
model,
modelName,
oldModel: null,
options,
rollbackType: 'remove',
status: Status.pending,
type: 'insert'
}
this.operations.push(operation)
return data._id
}
/**
* Create the findOneAndUpdate transaction and rollback states.
* @param modelName - The string containing the mongoose model name.
* @param findId - The id of the object to update.
* @param dataObj - The object containing data to update into mongoose model.
*/
public update(modelName, schema, findId, data, options = {}) {
const model = mongoose.model(modelName, schema)
const operation: Operation = {
data,
findId,
model,
modelName,
oldModel: null,
options,
rollbackType: 'update',
status: Status.pending,
type: 'update'
}
this.operations.push(operation)
return operation
}
/**
* Create the remove transaction and rollback states.
* @param modelName - The string containing the mongoose model name.
* @param findObj - The object containing data to find mongoose collection.
*/
public remove(modelName, schema, findId, options = {}) {
const model = mongoose.model(modelName, schema)
const operation: Operation = {
data: null,
findId,
model,
modelName,
oldModel: null,
options,
rollbackType: 'insert',
status: Status.pending,
type: 'remove'
}
this.operations.push(operation)
return operation
}
/**
* Run the operations and check errors.
* @returns Array of objects - The objects returned by operations
* Error - The error object containing:
* data - the input data of operation
* error - the error returned by the operation
* executedTransactions - the number of executed operations
* remainingTransactions - the number of the not executed operations
*/
public async run() {
if (this.useDb && this.transactionId === '') {
await this.createTransaction()
}
const final = []
return this.operations.reduce((promise, transaction, index) => {
return promise.then(async () => {
let operation
switch (transaction.type) {
case 'insert':
operation = this.insertTransaction(
transaction.model,
transaction.data
)
break
case 'update':
operation = this.findByIdTransaction(
transaction.model,
transaction.findId
).then(findRes => {
transaction.oldModel = findRes
return this.updateTransaction(
transaction.model,
transaction.findId,
transaction.data,
transaction.options
)
})
break
case 'remove':
operation = this.findByIdTransaction(
transaction.model,
transaction.findId
).then(findRes => {
transaction.oldModel = findRes
return this.removeTransaction(
transaction.model,
transaction.findId
)
})
break
}
return operation
.then(async query => {
this.rollbackIndex = index
this.updateOperationStatus(Status.success, index)
if (index === this.operations.length - 1) {
await this.updateDbTransaction(Status.success)
}
final.push(query)
return final
})
.catch(async err => {
this.updateOperationStatus(Status.error, index)
await this.updateDbTransaction(Status.error)
throw err
})
})
}, Promise.resolve([]))
}
/**
* Rollback the executed operations if any error occurred.
* @param stepNumber - (optional) the number of the operation to rollback - default to length of
* operation successfully runned
* @returns Array of objects - The objects returned by rollback operations
* Error - The error object containing:
* data - the input data of operation
* error - the error returned by the operation
* executedTransactions - the number of rollbacked operations
* remainingTransactions - the number of the not rollbacked operations
*/
public async rollback(howmany = this.rollbackIndex + 1) {
if (this.useDb && this.transactionId === '') {
await this.createTransaction()
}
let transactionsToRollback = this.operations.slice(
0,
this.rollbackIndex + 1
)
transactionsToRollback.reverse()
if (howmany !== this.rollbackIndex + 1) {
transactionsToRollback = transactionsToRollback.slice(0, howmany)
}
const final = []
return transactionsToRollback.reduce((promise, transaction, index) => {
return promise.then(() => {
let operation
switch (transaction.rollbackType) {
case 'insert':
operation = this.insertTransaction(
transaction.model,
transaction.oldModel
)
break
case 'update':
operation = this.updateTransaction(
transaction.model,
transaction.findId,
transaction.oldModel
)
break
case 'remove':
operation = this.removeTransaction(
transaction.model,
transaction.findId
)
break
}
return operation
.then(async query => {
this.rollbackIndex--
this.updateOperationStatus(Status.rollback, index)
if (index === this.operations.length - 1) {
await this.updateDbTransaction(Status.rollback)
}
final.push(query)
return final
})
.catch(async err => {
this.updateOperationStatus(Status.errorRollback, index)
await this.updateDbTransaction(Status.errorRollback)
throw err
})
})
}, Promise.resolve([]))
}
private async findByIdTransaction(model, findId) {
return await model
.findOne({ _id: findId })
.lean()
.exec()
}
private async createTransaction() {
if (!this.useDb) {
throw new Error('You must set useDB true in the constructor')
}
const transaction = await TransactionModel.create({
operations: this.operations,
rollbackIndex: this.rollbackIndex
})
this.transactionId = transaction._id ? transaction._id as string : ''
return transaction
}
private insertTransaction(model, data) {
return model.create(data).then(result => result).catch(err => this.transactionError(err, data))
}
private updateTransaction(model, id, data, options = { new: false }) {
return model.findOneAndUpdate(
{ _id: id },
data,
options)
.then(result => {
if (!result) {
this.transactionError(
new Error('Entity not found'),
{ id, data }
)
}
return result
}).catch(err => this.transactionError(err, { id, data }))
}
private removeTransaction(model, id) {
return model.findOneAndDelete({ _id: id })
.then(result => {
if (!result) {
this.transactionError(new Error('Entity not found'), id)
} else {
return result
}
}).catch(err => this.transactionError(err, id))
}
private transactionError(error, data) {
throw {
data,
error,
executedTransactions: this.rollbackIndex + 1,
remainingTransactions:
this.operations.length - (this.rollbackIndex + 1)
}
}
private updateOperationStatus(status, index) {
this.operations[index].status = status
}
private async updateDbTransaction(status) {
if (this.useDb && this.transactionId !== '') {
return await TransactionModel.findByIdAndUpdate(
this.transactionId,
{
operations: this.operations,
rollbackIndex: this.rollbackIndex,
status
},
{ new: true }
)
}
}
}