sqlite3-trans
Version:
Proper transaction support for node-sqlite3
265 lines (215 loc) • 7.49 kB
JavaScript
/* jslint node: true */
'use strict';
// deps
const EventEmitter = require('events').EventEmitter;
const { functionsIn, isFunction, bind } = require('lodash');
const NON_PROXIED_METHOD_NAMES = [
'emit',
'addListener',
'setMaxListeners',
'on',
'once',
'removeListener',
'removeAllListeners',
'listeners',
'prepare',
];
const LOCKING_METHODS = ['exec', 'run', 'get', 'all', 'each', 'map', 'finalize', 'reset'];
module.exports = class TransDatabase extends EventEmitter {
constructor(db) {
super();
this.db = db;
this.queue = [];
this.lockCount = 0;
this.db.serialize();
this._exec = bind(this.db.exec, this.db);
this._wrapDbObject(this, this, this.db);
this.db.on('error', () => {
if (this.currentTransaction) {
this.currentTransaction.rollback(() => {});
}
});
//
// wrap prepare which is handled without locking logic
// directly, but instead with inner methods
//
const self = this;
this.prepare = function () {
const oldStatement = self.db.prepare.apply(self.db, arguments);
const newStatement = new EventEmitter();
self._wrapDbObject(self, newStatement, oldStatement);
return newStatement;
};
}
static wrap(db) {
return new TransDatabase(db);
}
beginTransaction(cb) {
if (this.currentTransaction) {
return this.queue.push({
type: 'transaction',
object: this,
method: 'beginTransaction',
args: arguments,
});
}
const trans = this.db;
let finished = false;
this.currentTransaction = trans;
const self = this;
function finishTransaction(err, callback) {
finished = true;
self.currentTransaction = null;
self._flushQueue();
return callback(err);
}
trans.commit = function (callback) {
if (finished) {
return callback(new Error('Transaction already finished'));
}
self._wait(() => {
self._exec('COMMIT;', err => {
return finishTransaction(err, callback);
});
});
};
trans.rollback = function (callback) {
if (finished) {
return callback(new Error('Transaction already finished'));
}
self._wait(() => {
self._exec('ROLLBACK;', err => {
return finishTransaction(err, callback);
});
});
};
// OK, now begin
this._wait(err => {
if (err) {
finishTransaction(err, cb);
}
self._exec('BEGIN;', err => {
if (err) {
return cb(err);
}
return cb(null, trans);
});
});
}
_wait(cb) {
const self = this;
function check() {
if (0 === self.lockCount) {
return cb();
} else {
setImmediate(check);
}
}
return check();
}
_flushQueue() {
while (this.queue.length > 0) {
const queuedItem = this.queue.shift();
if ('lock' === queuedItem.type) {
++this.lockCount;
}
// perform queued call
queuedItem.object[queuedItem.method].apply(
queuedItem.object,
queuedItem.args
);
if ('transaction' === queuedItem.type) {
break;
}
}
}
_getSourceMethods(source) {
let properties = new Set();
let currentObj = source;
do {
Object.getOwnPropertyNames(currentObj).forEach(item => properties.add(item));
} while ((currentObj = Object.getPrototypeOf(currentObj)));
return [...properties.keys()].filter(item => typeof source[item] === 'function');
}
_wrapDbObject(transDb, target, source) {
this._getSourceMethods(source)
.filter(methodName => this._isProxyMethod(methodName))
.forEach(methodName => {
target[methodName] = this._wrapDbMethod(transDb, source, methodName);
});
this._interceptEmittedEvents(target, source, bind(target.emit, target));
}
_isProxyMethod(methodName) {
return !NON_PROXIED_METHOD_NAMES.includes(methodName);
}
static _isLockedMethod(methodName) {
return LOCKING_METHODS.includes(methodName);
}
_wrapDbMethod(transDb, object, methodName) {
return function () {
const args = arguments;
const lockedMethod = TransDatabase._isLockedMethod(methodName);
if (lockedMethod) {
function missingCallback(err) {
if (err) {
transDb.db.emit('error', err);
}
}
// ensure each rolls back on error to decrement |lockCount|
if ('each' === methodName) {
if (
args.length < 2 ||
(!isFunction(args[args.length - 1]) &&
!isFunction(args[args.length - 2]))
) {
args[args.length] = args[args.length + 1] = missingCallback;
args.length += 2;
} else if (
isFunction(args[args.length - 1]) &&
!isFunction(args[args.length - 2])
) {
args[args.length] = missingCallback;
args.length += 1;
}
}
let originalCallback;
const newCallback = function () {
if (transDb.lockCount < 1) {
throw new Error('Locks are not balanced!');
}
--transDb.lockCount;
originalCallback.apply(this, arguments);
};
if (args.length > 0 && isFunction(args[args.length - 1])) {
originalCallback = args[args.length - 1];
args[args.length - 1] = newCallback;
} else {
originalCallback = missingCallback;
args[args.length] = newCallback;
args.length += 1;
}
}
if (!this.currentTransaction) {
if (lockedMethod) {
transDb.lockCount++;
}
object[methodName].apply(object, args); // call inner
} else {
// already in transaction; defer
transDb.queue.push({
type: lockedMethod ? 'lock' : 'simple',
object,
method: methodName,
args,
});
}
};
}
_interceptEmittedEvents(target, emitter, handler) {
const oldEmit = emitter.emit;
emitter.emit = function () {
handler.apply(target, arguments);
oldEmit.apply(emitter, arguments);
};
}
};