UNPKG

transactions-mongoose

Version:
832 lines (754 loc) โ€ข 30.9 kB
/* * ========================================================= * ๐Ÿ‡บ๐Ÿ‡ฆ๐Ÿ‡บ๐Ÿ‡ฆ๐Ÿ‡บ๐Ÿ‡ฆ๐Ÿ‡บ๐Ÿ‡ฆ๐Ÿ‡บ๐Ÿ‡ฆ๐Ÿ‡บ๐Ÿ‡ฆ๐Ÿ‡บ๐Ÿ‡ฆ Mongoose Transactions ๐Ÿ‡บ๐Ÿ‡ฆ๐Ÿ‡บ๐Ÿ‡ฆ๐Ÿ‡บ๐Ÿ‡ฆ๐Ÿ‡บ๐Ÿ‡ฆ๐Ÿ‡บ๐Ÿ‡ฆ๐Ÿ‡บ๐Ÿ‡ฆ๐Ÿ‡บ๐Ÿ‡ฆ * ========================================================= * Copyright (c) 2019-2023 * @Author: ๐Ÿ‡บ๐Ÿ‡ฆRosbitskyy Ruslan * @email: rosbitskyy@gmail.com */ /** * @type {{hasValidator: boolean, FieldValidator: {validator: function, message?: string}, * type?: object, required?: boolean, unique?: boolean, index?: boolean, min: number, max: number, * validate?:{validator: function, message?: string}}} */ const SchemaConstructor = {} String.prototype.capitalize = function () { return this.charAt(0).toUpperCase() + this.slice(1); } const Namespace = require('mongoose'); /** @typedef {import('../types/').Document} Document */ class Document { _ABSTRACT = 'Abstract'; _doc = {} isNew = false; #modified = {_id: false, __v: false}; /** * @param {object: Namespace.Model} model * @param {object} object */ constructor(model, object) { this._doc = object; this.model = model; this._modelName = this._ABSTRACT + this.model.prototype.collection.collectionName.capitalize(); this.init() } get schema() { return this.model.schema.obj } init() { const t = this; if (!this._doc._id) throw new Error('Document _id is required for update Document') for (let path of Object.keys(this.schema).concat(['_id'])) { if (!this.hasOwnProperty(path)) Object.defineProperties(this, { [path]: { get() { return t._doc[path]; }, set(v) { t._doc[path] = v; t.markModified(path, true) } } }) this.markModified(path, false) } for (let path of Object.keys(this._doc)) if (path !== '_id') this.markModified(path, true) } markModified(path, v = true) { this.#modified[path] = v; } /** * @param {string|null} path * @return {boolean} */ isModified(path = null) { if (!path) return true; else return !!this.#modified[path]; } /** * @param {object|Array<string>|null} v * @return {Error.ValidationError} */ validateSync(v = null) { const doc = this.AbstractModel(); for (let path of Object.keys(this.#modified)) { if (this.isModified(path)) { const error = doc.validateSync([path]) if (error) { this.clear() return error; } } } return null; } /** * @param {string[]} paths * @return {Promise<undefined|*>} */ async validate(paths) { const doc = this.AbstractModel(); try { const er = await doc.validate(paths) if (er) throw er } catch (e) { this.clear() return e; } return undefined } AbstractModel() { if (Namespace.modelNames().includes(this._modelName)) return new (Namespace.model(this._modelName))(this._doc); return new (Namespace.model(this._modelName, new Namespace.Schema(this.schema, { _id: false, autoCreate: false })))(this._doc); } async save() { throw new Error('Invalid save method call for this type of document. Did you mean update?') } async clear() { if (Namespace.modelNames().includes(this._modelName)) Namespace.deleteModel(this._modelName) } } /** @typedef {import('../types/').NamespaceParser} NamespaceParser */ class NamespaceParser { /** * @param {object|HydratedDocument} v * @return {boolean} */ isModel = (v) => !!v && !!v.modelName && !!v.find; /** * @param {object|HydratedDocument} v * @return {boolean} */ isDocument = (v) => !!v && v.constructor && v.constructor.name === 'model' && !!Namespace.modelNames().includes(v.constructor.modelName) && !!v._doc; /** * @param {object|HydratedDocument} v * @return {HydratedDocument|null} */ documentModel = (v) => this.isDocument(v) ? Namespace.model(v.constructor.modelName) : null; /** * @param {object} v * @return {boolean} */ isCleanDocument = (v) => !!v && v.constructor === {}.constructor && !this.isDocument(v) && typeof v !== 'function'; /** * @param {object: {Model,any}|HydratedDocument} v * @return {*|(function(string): Model<InferSchemaType<any>, ObtainSchemaGeneric<any, "TQueryHelpers">, ObtainSchemaGeneric<any, "TInstanceMethods">, ObtainSchemaGeneric<any, "TVirtuals">, HydratedDocument<InferSchemaType<any>, ObtainSchemaGeneric<any, "TVirtuals"> & ObtainSchemaGeneric<any, "TInstanceMethods">, ObtainSchemaGeneric<any, "TQueryHelpers">>, any>)|string|Model<any>|null|(Model<InferSchemaType<any>, ObtainSchemaGeneric<any, "TQueryHelpers">, ObtainSchemaGeneric<any, "TInstanceMethods">, ObtainSchemaGeneric<any, "TVirtuals">, HydratedDocument<InferSchemaType<any>, ObtainSchemaGeneric<any, "TVirtuals"> & ObtainSchemaGeneric<any, "TInstanceMethods">, ObtainSchemaGeneric<any, "TQueryHelpers">>, any> & ObtainSchemaGeneric<any, "TStaticMethods">)} */ getCleanDocModel = (v) => { if (!!v && this.isCleanDocument(v)) { if (v.Model) { if (this.isModel(v.Model)) return v.Model; else if (v.Model.constructor === ''.constructor && Namespace.modelNames().includes(v.Model)) return Namespace.model(v.Model) } else if (v.constructor === {}.constructor) for (let k of Object.keys(v)) if (this.isModel(v[k])) return v[k]; } return null; } /** * @param {object: {Model,any}|HydratedDocument} v * @return {(function(string): Model<InferSchemaType<any>, ObtainSchemaGeneric<any, "TQueryHelpers">, ObtainSchemaGeneric<any, "TInstanceMethods">, ObtainSchemaGeneric<any, "TVirtuals">, HydratedDocument<InferSchemaType<any>, ObtainSchemaGeneric<any, "TVirtuals"> & ObtainSchemaGeneric<any, "TInstanceMethods">, ObtainSchemaGeneric<any, "TQueryHelpers">>, any>)|string|Model<any>|*|Model<InferSchemaType<any>, ObtainSchemaGeneric<any, "TQueryHelpers">, ObtainSchemaGeneric<any, "TInstanceMethods">, ObtainSchemaGeneric<any, "TVirtuals">, HydratedDocument<InferSchemaType<any>, ObtainSchemaGeneric<any, "TVirtuals"> & ObtainSchemaGeneric<any, "TInstanceMethods">, ObtainSchemaGeneric<any, "TQueryHelpers">>, any>|null} */ parseModel(v) { let m = this.documentModel(v); if (this.isModel(m)) return m; else return this.getCleanDocModel(v); } /** * @param {object} v * @return {boolean} */ isExecutor = (v) => !!v && typeof v === 'function'; /** * @param {object: Namespace.Model|Namespace.Document|null} model * @param {object|function|null} object * @return {boolean} */ isValidArguments(model, object) { if (object) delete object.id const isExecutor = (!this.isModel(model) && this.isExecutor(object)); const isDoc = (this.isModel(model) && (this.isDocument(object) || this.isCleanDocument(object))); const exclude = ['__v', '_id', 'id']; if (isDoc) { if (this.isCleanDocument(object) && !object._id) exclude.map(it => delete object[it]); else if (this.isDocument(object) && !object._doc._id) exclude.map(it => delete object._doc[it]); } return isExecutor || isDoc; } } /** @typedef {import('../types/').TransactionData} TransactionData */ class TransactionData { MODEL = 'model' OBJECT = 'Object' FUNCTION = 'Function' DOCUMENT = 'Document' #excludeUpdateFields = ['_id', 'id', 'tableData']; /** * @type {[string]} */ #uniqueFields = []; /** * @type {Transaction} */ #transaction = null; /** * @param {object: Namespace.Model} model * @param {Namespace.Model|object|function} data * @param {Transaction} transaction */ constructor(model, data, transaction) { this.#transaction = transaction; this.id = TransactionData.rid; this.model = model; this.data = data; this.document = this.#getDocument(); this.checkAsyncValidators = false; this.checkValidateSchema = false; this.checkValidateUniques = false; this.result = null this.function = null this.#defineFunction(); this.#setDebuggerInfo(); this.#collectUniques(); } static get rid() { return Math.random().toString(16).substring(2) } get isModel() { return !!this.document; } /** * @return {boolean} */ get isExecutor() { return !!this.function; } /** * @return {string} */ get type() { return this.data.constructor.name.replace('Async', '') } get transaction() { return this.#transaction } get uniqueFields() { return this.#uniqueFields } /** * @return {boolean} */ get isVerified() { return this.checkAsyncValidators && this.checkValidateUniques && this.checkValidateSchema } /** * @return {object} */ get schema() { return this.model.schema.obj } get schemaFields() { return Object.keys(this.schema) } /** * @return {object} */ get modifiedData() { const doc = this.document, obj = {}; if (this.isModel) for (let key of this.schemaFields.concat('_id')) if (doc.isModified(key)) obj[key] = doc[key]; return obj; } #setDebuggerInfo() { this.debugger = TransactionError.debugger(); } #defineFunction() { this.function = (this.type === this.FUNCTION) ? this.data : null; if (this.function && !this.function.name) Object.defineProperty(this.function, "name", {value: (this.function.withSession ? 'session' : 'execute')}); } /** * @return {Document|null} */ #getDocument() { if (this.type === this.OBJECT) { if (this.data._id && Namespace.Types.ObjectId.isValid(this.data._id)) return new Document(this.model, this.data); else return new this.model(this.data); } else if (this.type === this.MODEL) return this.data; return null; } /** * @param {object} data */ update(data) { if (data.constructor.name === this.OBJECT) { this.#excludeUpdateFields.map(it => delete data[it]); for (let k of Object.keys(data)) this.document[k] = data[k]; this.#collectUniques() } return this; } #collectUniques() { if (!this.document) return; const t = this; for (let field of this.schemaFields) { const schema = this.schemaConstructor(field); if (schema.unique === true && !this.#uniqueFields.includes(field) && this.document.isModified(field)) this.#uniqueFields.push(field) if (!schema.hasOwnProperty('hasValidator')) Object.defineProperties(schema, { hasValidator: { get() { return schema && schema.validate && schema.validate.validator && schema.validate.validator.constructor.name === t.FUNCTION } }, FieldValidator: { get() { return schema && schema.validate } } }) } } /** * @param {string} field * @return {SchemaConstructor} */ schemaConstructor(field) { return this.schema[field] } } /** @typedef {import('../types/').TransactionError} TransactionError */ class TransactionError extends Error { /** * @param {TransactionData} _T * @param {string} message * @param {stack?: string|null} stack */ constructor(_T, message, stack = null) { super(message) this.#stack(_T, stack) const {id, model, checkAsyncValidators, checkValidateSchema, checkValidateUniques} = _T; this.time = Date.now(); const name = (_T.document || {})._modelName; const funcType = (_T.isExecutor && _T.function.constructor.name) || _T.type; const hasObject = Object.keys(_T.modifiedData || {}) .some(it => !!_T.modifiedData[it] && typeof _T.modifiedData[it] === 'object' && !_T.modifiedData[it].getTime) const modifiedData = _T.isModel ? (hasObject ? JSON.stringify(_T.modifiedData) : _T.modifiedData) : funcType + (_T.function.withSession ? ' with Session' : ''); this.transaction = { id, ...(name && {document: name}), model: (model || {}).modelName || funcType, checkAsyncValidators, checkValidateSchema, checkValidateUniques, modifiedData, } } static debugger() { const lookup = ['process', 'Transaction']; let e = new Error(); const stack = e.stack.split("\n").slice(1).reverse(); let frame = stack.filter(it => lookup.every(s => !it.includes('/' + s))).slice(0).join('\n'); let line = stack[1].substring(frame.indexOf('/')); return {frame, line} } /** * @param {TransactionData} transaction * @param {stack?: string|null} stack */ #stack(transaction, stack = null) { try { const name = (stack ? stack.split("\n")[1] : ''); this.stack = null; //๐Ÿ‡บ๐Ÿ‡ฆะฝะฐะผ ะฝั–ั„ั–ะณะฐ ั†ะต ะฝะต ั†ั–ะบะฐะฒะพ this.info = (name + '\n' + transaction.debugger.frame).trim() } catch (e) { } } } /** @typedef {import('../types/').Transaction} Transaction */ class Transaction extends NamespaceParser { #REPLICA_SET = 'replicaSet'; /** * @type {{updated:Document[], created:TransactionData[]}} */ #documents = { updated: [], created: [] }; #sendbox = false; /** * @type {Array<TransactionData>} */ #data = []; /** * @type {Array<TransactionData>} */ #commits = [] #strict = true; constructor() { super(); } get useStrict() { return this.#strict; } get transactions() { return this.#data; } get commits() { return this.#commits } get isVerified() { return this.transactions.every(it => it.isVerified) } /** * @param {string} name * @return {Model<InferSchemaType<any>, ObtainSchemaGeneric<any, "TQueryHelpers">, ObtainSchemaGeneric<any, "TInstanceMethods">, ObtainSchemaGeneric<any, "TVirtuals">, HydratedDocument<InferSchemaType<any>, ObtainSchemaGeneric<any, "TVirtuals"> & ObtainSchemaGeneric<any, "TInstanceMethods">, ObtainSchemaGeneric<any, "TQueryHelpers">>, any> & ObtainSchemaGeneric<any, "TStaticMethods">} * @constructor */ static Model(name) { return Namespace.model(name) } setSendbox(v) { this.#sendbox = !!v; return this } /** * @param {function} fn * @return {TransactionData} */ execute(fn) { return this.add(null, fn); } /** * @param {function(session: Namespace.ClientSession)} fn * @return {TransactionData} */ session(fn) { Object.defineProperty(fn, "withSession", {value: true}); const transactionData = this.execute(fn); this.#verifySessionCallback(transactionData) return transactionData } setStrict(v) { this.#strict = !!v; return this; } /** * options: MongoOptions: node_modules/mongodb/src/connection_string.ts:259 * @return {{connectionString:string,options:{userSpecifiedReplicaSet:boolean,replicaSet:string,hosts:[]}}} */ get client() { const v = Namespace.default || {}; v.connectionString = (v.connection || {})._connectionString; v.options = ((v.connection || {}).client || {}).options || {}; return v; } /** * @return {boolean} */ get isReplicaSet() { return this.client.options.userSpecifiedReplicaSet && !!this.client.options.replicaSet } /** * @param {TransactionData} transaction */ #verifySessionCallback(transaction) { if (transaction && transaction.isExecutor && !this.isReplicaSet && (this.useStrict || this.transactions.filter(it => it.isModel).length > 0)) { const pattern = /[^\s\S]*?new \w.*\([\s\S|.]*?{[\s\S|.]*?}[\s\S|.]*?\)/gm; let fnStr = transaction.function.toString(); let results = Array.from(fnStr.matchAll(pattern)); if (results && results.length) { let list = []; for (let result of results) { const line = result[0]; const rs = Array.from(line.matchAll(/(new \w*)/gm)); const newObj = rs[0][0]; const model = newObj.replace('new ', '').trim() if (Namespace.modelNames().includes(model)) { let lines = (fnStr.substring(0, fnStr.indexOf(line)).split('\n')) const definition = lines[lines.length - 1].trim(); if (!definition.startsWith('//')) { const position = lines.length - 1; const replacers = ['const', 'let', 'var', '=']; let variable = definition; for (let v of replacers) variable = variable.replace(v, '').trim(); let saving = (fnStr.split('\n').find(it => it.includes(`${variable}.save`)) || '').trim(); saving = saving.startsWith('//') ? null : saving; let debugLine = transaction.debugger.line.split(':'); debugLine[1] = Number(debugLine[1]) + position; debugLine[2] = Number(lines[lines.length - 1].length + 1); debugLine = String(debugLine.join(':') + ' '); // console.log(debugLine.join(':')) list.push({ model, line, position, definition, variable, saving, debugLine, usage: line.replace(newObj + "(", "transaction.add(" + model + ", ") }) } } } if (list.length) { let message = 'Attempting to create a new object (document) using a session without a replica set.' + '\n\tUse strick: ' + this.#strict + '\n'; for (let it of list) { message += '\tPosition: ' + it.debugLine + '\n\tLine: ' + it.line + '\n\tReplace with: ' + it.usage + (it.saving ? '\n\tRemove: ' + it.saving + ' or comment it // ' + it.saving : '') + '\n'; if (!!it.variable) { fnStr = fnStr.replaceAll(it.line, it.usage); const definition = it.definition.replace('=', '').trim(); fnStr = fnStr.replaceAll(it.definition, definition + '_td ='); if (it.saving) { fnStr = fnStr.replaceAll(it.saving, it.definition + " " + it.variable + '_td.result;'); } } } if (list.every(it => !!it.variable)) { const rows = fnStr.split('\n'); fnStr = rows.slice(1, rows.length - 1).join('\n'); transaction.function = this.AsyncFunctionConstructor(transaction, 'session', fnStr); this.#sendbox && console.log(this.constructor.name, transaction.function.toString()); } throw new TransactionError(transaction, message); } } } } /** * @param {TransactionData} transaction * @param {...string} args * @return {function} * @constructor */ AsyncFunctionConstructor(transaction, ...args) { const AsyncFunction = transaction.function.constructor; const fn = new AsyncFunction(...args); if (transaction.function.withSession) Object.defineProperty(fn, "withSession", {value: true}); return fn; } /** * Requires MongoDB >= 3.6.0 * @param {TransactionData} transaction * @return {Promise<*>} */ async #execute(transaction) { if (this.#sendbox && transaction.isExecutor) { const fnstr = transaction.function.toString(); const rows = fnstr.split('\n') console.log(this.constructor.name, 'execute', (transaction.function.withSession ? 'withSession ' : '') + (rows.slice(0, Math.min(4, rows.length)).join('\n'))) } let rv, session; if (transaction.isExecutor && transaction.type === transaction.FUNCTION) { if (transaction.function.withSession) { await this.client.startSession().then(async (_session) => { (session = _session).startTransaction(); rv = await transaction.function(session); await session.commitTransaction(); return rv; }).then((doc) => session.endSession()).catch(async (e) => { await session.abortTransaction() await session.endSession() throw e }) } else { rv = await transaction.function() } } return rv; } /** * @param {object: Namespace.Model|Namespace.Document|null} model * @param {object|Namespace.Document|function|null} object * @return {TransactionData} */ add(model, object = null) { if (model && !object) { object = model; model = this.parseModel(model); } if (!this.isValidArguments(model, object)) throw new Error('No Model is specified, it is not possible to convert the object into a model') const td = new TransactionData(model, object, this) this.#setTimestamps(td).#clearPerviousCommits(); this.transactions.push(td) return td; } /** * @param {TransactionData} transaction */ #setTimestamps(transaction) { try { if (transaction.isModel) { const paths = [ {path: 'updatedAt', condition: transaction.document.isModified()}, {path: 'createdAt', condition: transaction.document.isNew} ] for (let v of paths) if (v.condition && transaction.schema[v.path] !== undefined) transaction.document[v.path] = Date.now(); } } catch (e) { console.error(e) } return this; } #clearPerviousCommits() { this.#commits = []; return this; } /** * Executes registered validation rules with asynchronous validators for this document. * @param {TransactionData} transaction * @return {Promise<void>} */ async validateAsyncValidators(transaction) { if (transaction.document) { for (let field of transaction.schemaFields) { const schemaConstructor = transaction.schemaConstructor(field) if (schemaConstructor.hasValidator && transaction.document.isModified(field)) { try { let error = await transaction.document.validate([field]); if (error) this.validationError(transaction, error) } catch (e) { this.validationError(transaction, e) } } } } transaction.checkAsyncValidators = true } /** * @param {TransactionData} transaction * @param {object:{message: string}|Error} error */ validationError(transaction, error) { this.clear(); throw new TransactionError(transaction, error.message) } /** * Executes registered validation rules (skipping asynchronous validators) for this document. * @param {TransactionData} transaction * @return {Promise<void>} */ async validateSchema(transaction) { if (transaction.isModel) { /** * @type {Error.ValidationError} */ let error = transaction.document.validateSync(); if (error) this.validationError(transaction, error) error = await transaction.document.validate(Object.keys(transaction.modifiedData)); if (error) this.validationError(transaction, error) if (!transaction.document.isNew && transaction.document.isModified('_id')) { this.validationError(transaction, {message: 'An attempt to change the Document identifier was detected. Field: _id'}) } } transaction.checkValidateSchema = true; } clear() { this.#clearAbstractDocuments(); this.#data = []; return this } async validate() { for (let transaction of this.transactions) { if (transaction.isModel) { await this.validateAsyncValidators(transaction); await this.validateSchema(transaction); await this.validateUnique(transaction); } else if (transaction.type === transaction.FUNCTION) { transaction.checkAsyncValidators = transaction.checkValidateSchema = transaction.checkValidateUniques = true } } } #clearAbstractDocuments() { // for (let transaction of this.transactions) { // if (transaction.isModel && transaction.document.constructor.name === transaction.DOCUMENT) // transaction.document.clear && transaction.document.clear(); // } } /** * Search unique keys if exists * @param {TransactionData} transaction * @return {Promise<void>} */ async validateUnique(transaction) { if (transaction.isModel) { const isNew = transaction.document.isNew; const isModified = transaction.document.isModified(); if (!isNew) { const prevTransaction = this.transactions.find(it => it.document && it.id !== transaction.id && it.document._id === transaction.document._id && it.document.isNew) if (!prevTransaction) { const v = await transaction.model.exists({_id: transaction.document._id}); if (!v) { this.clear() this.documentNotExists(transaction) } } } if (transaction.uniqueFields.length) { let query = {$or: []}; for (let key of transaction.uniqueFields) query.$or.push({[key]: transaction.document[key]}) if (!isNew) query = {_id: {$ne: transaction.document._id}, $or: query.$or} if (isNew || isModified) { const count = await transaction.model.countDocuments(query); if (count) this.validationError(transaction, {message: 'Duplicate key found. Check one of: ' + JSON.stringify(query.$or)}) } } } transaction.checkValidateUniques = true } async #rollback() { console.info(this.constructor.name, `: Rollback ${this.commits.length} of ${this.transactions.length} transactions`) for (let doc of this.#documents.updated) { doc.markModified('__v'); await doc.save(); } for (let td of this.#documents.created) { await td.model.deleteOne({_id: td.document._id}) } this.clear(); this.#commits = []; this.#documents.updated = []; this.#documents.created = []; } async commit() { await this.validate(); if (this.isVerified) for (let transaction of this.transactions) { if (!this.isVerified) await this.validate() // if new transaction added in executor try { if (transaction.isModel) { if (transaction.document.isNew) { this.#documents.created.push(transaction); transaction.result = await transaction.document.save(); this.#sendbox && console.log(this.constructor.name, 'save', transaction.model, transaction.document) } else if (transaction.document.isModified()) { const _id = transaction.document._id; const doc = await transaction.model.findById(_id); if (doc) { this.#documents.updated.push(doc) transaction.result = await transaction.model.updateOne({_id}, transaction.modifiedData) this.#sendbox && console.log(this.constructor.name, 'update', transaction.model, transaction.modifiedData) } else { this.documentNotExists(transaction) } } } else if (transaction.isExecutor) transaction.result = await this.#execute(transaction) } catch (e) { await this.#rollback() throw new TransactionError(transaction, this.constructor.name + ': ' + e.message, e.stack) } this.commits.push(transaction) } this.#sendbox && console.log(this.constructor.name, 'committed', this.commits.length, this.commits.map(it => it.isModel ? it.model : it.function)) this.clear(); } documentNotExists(transaction) { throw new TransactionError(transaction, 'Document ObjectId(`' + transaction.document._id + '`) not exists in collection ' + transaction.model.prototype.collection.collectionName.capitalize()) } } module.exports = {Transaction, TransactionData, TransactionError};