boobst
Version:
Simple Node.js Caché driver
822 lines (779 loc) • 21.6 kB
JavaScript
/**
* Boobst module (boobst.js)
* A tiny database driver for DBMS Cache'
* @author Andrew D. Laptev <a.d.laptev@gmail.com>
* @version 0.9.1
* @license MIT
**/
var net = require('net')
, util = require('util')
, events = require('events');
const
EOL = ' ' + String.fromCharCode(0) // TODO избавиться от лишнего байта в s input=$e(input,1,$l(input)-1)
, EON = String.fromCharCode(1)
, VERSION = 9
// , VALID_CACHE_VAR_RE = /^\^?%?[\-A-Z\.a-z]+[\w\d]*(\(("[A-Za-z_\-\.\+\\/0-9]+"|\d)(,("[A-Za-z_\-\.\+\\/0-9]+"|\d))*\))?$/
, CACHE_MAX_SIZE = 32754,
/**
* @readonly
* @enum {number}
*/
BCMD = {
NOP: 0
, SET: 1
, GET: 2
, EXECUTE: 3
, SETENCODING: 4
, FLUSH: 5
, KILL: 6
, DISCONNECT: 7
, KEY: 8
, PING: 9
, ZN: 10
, HI: 11
, BLOB: 12
, ORDER: 13
, XECUTE: 14
, INPUT: 15
}
;
/**
* @this boobst.BoobstSocket
* @private
*/
function onConnection() {
// unused event handler cause on connection server first of all sends greeting BCMD.HI to client
}
/**
* Событие сокета на ошибку
* @private
* @this boobst.BoobstSocket
*/
function onError(err) {
this.emit('debug', 'error: ' + err.toString());
if (this.callback) {
if (this.command === BCMD.BLOB) { // blob errors we catch here TODO: think about properly closing MUMPS connection
this.command = BCMD.NOP;
}
this.callback.call(this, err);
}
// self.socket = null; pass this if we don't want to connect anymore
}
/**
* On close socket event
* @private
* @this BoobstSocket
*/
function onClose(transmittionErr) {
this.connected = false;
if (!this.killme) { // if we got connection troubles
if (this.out) {
this.out.end();
}
if (this.command === BCMD.BLOB && this.callback) { // if we disconnected under .blob command
this.callback.call(this, null);
} else {
this.emit('debug', 'disconnected');
if (this.data) {
this.emit('debug', this.data.toString());
if (this.callback) {
this.callback.call(this, new Error(this.data));
}
}
}
this.command = BCMD.HI;
// trying to establish connection at growing time intervals
if (!this._connectionTimeout) {
this._connectionTimeout = 0;
} else {
this._connectionTimeout += 1000;
}
setTimeout(function() {
this.connect();
}.bind(this), this._connectionTimeout);
//self.connect();
} else { // if we calls .disconnect() method
if (this.callback) {
this.callback.call(this); // disconnection callback
}
}
}
/**
* Событие сокета на получение данных
* @private
* @this BoobstSocket
*/
function onData(data) {
this.emit('debug', this.command + ' : ' + data.toString());
switch (this.command) {
case BCMD.NOP:
this.emit('debug', 'Data on NOP command: ' + data.toString());
this.command = BCMD.INPUT;
this.callback = function(data) {
this.emit('message', data);
this.command = BCMD.NOP;
this._runCommandFromQueue();
};
this.onDataCommon(data);
break;
case BCMD.SETENCODING: case BCMD.KEY: case BCMD.SET: case BCMD.KILL: case BCMD.EXECUTE: case BCMD.FLUSH: case BCMD.PING: case BCMD.GET: case BCMD.ORDER: case BCMD.XECUTE: case BCMD.INPUT:
this.onDataCommon(data);
break;
case BCMD.ZN:
this.onDataZn(data);
break;
case BCMD.BLOB:
// do nothing, this error should be caught by onError event
break;
case BCMD.HI:
this.killme = false;
this.onDataGreeting(data);
break;
default:
this.error('Lost data!');
this.log(data.toString());
}
}
/**
* Boobst Socket
* @param {Object} options
* @param {number} [options.port] port
* @param {string} [options.host] host
* @property {Function} callback
* @property {Function} emit
* @extends events.EventEmitter
* @constructor
*/
var BoobstSocket = function(options) {
/**
* Server's process id
* @type {number}
*/
this.id = 0;
options = options || {};
/**
* Protocol varsion
* @type {number}
*/
this.version = VERSION;
this.out = null;
/**
* Commands queue
* @type {Array}
*/
this.queue = [];
/**
* Port
* @type {number}
*/
this.port = options.port || 6666;
/**
* Host
* @type {string}
*/
this.host = options.host || options.server || 'localhost';
if (options.ns) {
this.ns = options.ns;
}
this.command = BCMD.HI;
this.connected = false;
this.killme = false;
/**
* Connection socket
* @type {net.Socket}
*/
this.socket = new net.Socket();
this.socket.bufferSize = 32000;
this.socket.boobst = this;
this.socket.on('connect', onConnection.bind(this));
this.socket.on('close', onClose.bind(this));
this.socket.on('data', onData.bind(this));
this.socket.on('error', onError.bind(this));
};
// events.EventEmitter inheritance
util.inherits(BoobstSocket, events.EventEmitter);
/**
* Connect to DB
* @param {function(this:boobst.BoobstSocket, (null|Error))} [callback] callback
* @return {boobst.BoobstSocket}
*/
BoobstSocket.prototype.connect = function(callback) {
try {
this.emit('debug', 'trying to connect to ' + this.host + ':' + this.port + ' > ' + this.command);
if (callback) {
/**
*
* @type {(function(this:boobst.BoobstSocket, (null|Error)))}
*/
this.onConnectionCallback = callback;
}
this.socket.connect(this.port, this.host);
delete this._connectionTimeout;
} catch(e) {
if (callback) {
callback(new Error(e));
}
}
return this;
};
//----------------------------------------------------------------------------------------------------------------------
/**
* Common event handler
* @private
* @param {Buffer} data binary data chunk
*/
BoobstSocket.prototype.onDataCommon = function(data) {
// check if this chunk is the last one
// it must have \0 character at the end
if ((data.length > 0) && (data[data.length - 1] === 0)) {
if (this.out && (this.command === BCMD.EXECUTE || this.command === BCMD.XECUTE)){ // if we're writing into stream
this.out.end(data.slice(0, data.length - 1));
delete this.out;
if (this.callback) { // if we have callback
this.callback.call(this, null); // we haven't get this.data here
}
} else {
this.data = Buffer.concat([this.data, data.slice(0, data.length - 1)]);
if (this.callback) { // if we have callback
this.callback.call(this, null, this.data);
}
}
process.nextTick(function() {
this.command = BCMD.NOP;
this._runCommandFromQueue();
}.bind(this));
} else {
if (this.out && (this.command === BCMD.EXECUTE || this.command === BCMD.XECUTE)){ // if we're writing into stream
this.out.write(data);
} else {
this.data = Buffer.concat([this.data, data]);
}
}
};
BoobstSocket.prototype.onDataInput = function(data) {
};
/**
* Connect event handler
* @private
* @param {Buffer} data greeting
*/
BoobstSocket.prototype.onDataGreeting = function(data) {
this.emit('debug', 'connected');
this.connected = true;
var dataStr = data.toString().split(';');
if (parseInt(dataStr[0], 10) !== this.version) {
var err = new Error('Mismatch of protocol versions! server: ' + dataStr[0] + ', client: ' + this.version);
if (this.onConnectionCallback) {
this.onConnectionCallback(err);
}
return;
}
// working with properly protocol version
this.id = parseInt(dataStr[1], 10);
// if we're in the different namespace when connected just switch to the right one
if (this.ns) {
if (this.ns !== dataStr[2]) {
this.queue.unshift({
cmd: BCMD.ZN
, name: this.ns
});
}
} else {
this.ns = dataStr[2];
}
if (this.onConnectionCallback) {
this.onConnectionCallback.call(this);
delete this.onConnectionCallback;
}
process.nextTick(function() {
this.command = BCMD.NOP;
this._runCommandFromQueue();
}.bind(this));
};
/**
* Namespace change event handler
* @private
* @param {Buffer} data информация о смене
*/
BoobstSocket.prototype.onDataZn = function(data) {
var str = data.toString().split('.');
if (str[0] === 'ok' && str[1] === 'zn') {
this.ns = str[2].toUpperCase();
this.emit('debug', 'IM ON ::: ' + this.ns);
if (this.callback) {
this.callback.call(this, null, true);
}
} else {
if (this.callback) {
this.callback.call(this, new Error(str));
}
}
process.nextTick(function() {
this.command = BCMD.NOP;
this._runCommandFromQueue();
}.bind(this));
};
//--------------------------------------------------------------------------
/**
* Try to execute command
* If socket is not empty, command pushed into queue
* @param {Object} commandObject
* @private
*/
BoobstSocket.prototype._tryCommand = function(commandObject) { // попытаться выполнить комманду
if (this.command !== BCMD.NOP || this.connected === false) {
this.queue.push(commandObject);
} else {
this.data = new Buffer(0);
this.command = commandObject.cmd;
this.callback = commandObject.callback;
this.emit('debug', 'command: ' + commandObject.cmd +
(commandObject.name || commandObject.uri ? ', name: ' + (commandObject.name || commandObject.uri) : '') +
(commandObject.value ? ', value:' +commandObject.value : '')
);
switch (commandObject.cmd) {
case BCMD.EXECUTE:
if (commandObject.out) {
this.out = commandObject.out;
}
this.socket.write('E ' + commandObject.name + EOL);
break;
case BCMD.XECUTE:
if (commandObject.out) {
this.out = commandObject.out;
}
this.socket.write('X ' + commandObject.name + EOL);
break;
case BCMD.GET:
this.socket.write('G ' + commandObject.forceJSON + EON + commandObject.name + EOL);
break;
case BCMD.KEY:
this.socket.write('Q ' + commandObject.name + EON + commandObject.value + EOL);
break;
case BCMD.SETENCODING:
this.socket.write('8 ' + commandObject.value + EOL);
break;
case BCMD.SET:
this.socket.write('S ' + commandObject.name + EON + commandObject.value + EOL);
break;
case BCMD.KILL:
this.socket.write('K ' + commandObject.name + EOL);
break;
case BCMD.FLUSH:
this.socket.write('F' + EOL);
break;
case BCMD.PING:
this.socket.write('P' + EOL);
break;
case BCMD.ORDER:
this.socket.write('O ' + commandObject.name + EOL);
break;
case BCMD.BLOB:
this.socket.write('B ' + commandObject.uri + EOL);
commandObject.stream.on('end', function() {
this.socket.end();
/*
if (this.callback) { // если у нас есть коллбек
this.callback.call(this, null);
}
*/
//this.command = BCMD.NOP;
//this.connect();
}.bind(this));
commandObject.stream.pipe(this.socket);
var version = process.versions.node.split('.').map(function(num) {
return parseInt(num);
});
if (version[0] === 0 && version[1] < 9) { // fix for old Streams 1 api
commandObject.stream.resume();
}
break;
case BCMD.ZN:
this.socket.write('Z ' + commandObject.name + EOL);
break;
case BCMD.DISCONNECT:
this.killme = true; // this is state to disconnect gracefully
this.command = BCMD.HI; // we are ready to the next greeting from server
this.socket.end();
break;
default:
this.error("unknown command");
this.error(commandObject);
}
}
};
/**
* Execute routine
* @param {string} name имя существующей команды
* @param {stream.Stream} [outStream] поток, в который пересылать ответ сервера
* @param {function(this:boobst.BoobstSocket, (null|Error), Object)} callback callback
*/
BoobstSocket.prototype.execute = function(name, outStream, callback) {
var cmd = {
cmd: BCMD.EXECUTE,
name: name
};
if (outStream) {
if (typeof outStream === 'function') {
cmd.callback = outStream;
} else {
cmd.out = outStream;
if (callback) {
cmd.callback = callback;
}
}
}
this._tryCommand(cmd);
return this;
};
/**
* Evaluates any code on the server. Dangerous thing disabled on server by default
* @param {string} xecute text to xecute
* @param {stream.Stream} [outStream] stream to send data
* @param {function(this:boobst.BoobstSocket, (null|Error), Object)} callback callback
*/
BoobstSocket.prototype.xecute = function(xecute, outStream, callback) {
var cmd = {
cmd: BCMD.XECUTE,
name: xecute
};
if (outStream) {
if (typeof outStream === 'function') {
cmd.callback = outStream;
} else {
cmd.out = outStream;
if (callback) {
cmd.callback = callback;
}
}
}
this._tryCommand(cmd);
return this;
};
/**
* Get value
* @param {string} name Name of variable or global node
* @param {(Array<string>|boolean|function(this:boobst.BoobstSocket, (null|Error), Object))} [subscript]
* @param {boolean|function(this:boobst.BoobstSocket, (null|Error), Object)} [forceJSON] force get JSON from node
* @param {function(this:boobst.BoobstSocket, (null|Error), Buffer)} callback Callback-function (error, data)
*/
BoobstSocket.prototype.get = function(name, subscript, forceJSON, callback) {
if (callback === undefined) {
if (forceJSON !== undefined) {
callback = forceJSON;
if (typeof subscript === 'boolean') {
forceJSON = subscript;
} else {
name = createNameFromSubscript(name, subscript);
forceJSON = false;
}
} else {
callback = subscript;
forceJSON = false;
}
} else {
name = createNameFromSubscript(name, subscript);
}
this._tryCommand({
cmd: BCMD.GET,
name: name,
forceJSON: forceJSON ? 'f' : '',
callback: callback
});
return this;
};
BoobstSocket.prototype.key = function(name, value, callback) {
isValidCacheVar(name);
this._tryCommand({
cmd: BCMD.KEY,
name: name,
value: value,
callback: callback
});
return this;
};
BoobstSocket.prototype.setEncoding = function(value, callback) {
this._tryCommand({
cmd: BCMD.SETENCODING,
value: value,
callback: callback
});
return this;
};
/**
* Set the value of variable or global
* @param {string} name variable or global name (global name starts with `^`)
* @param {string|Buffer|Array<string>} [subscripts]
* @param {string|Buffer|function|number|Date} value variable value
* @param {?function(this:boobst.BoobstSocket, Error?, string?)} [callback] callback
* @return {boobst.BoobstSocket|BoobstSocket}
*/
BoobstSocket.prototype.set = function(name, subscripts, value, callback) {
/** let this part will be filtered by server-side
if (~name.indexOf('"')) {
throw new Error("You couldn't use '\"' in variable names: " + name);
}
*/
// polymorphism
var completed
, self = this
, typeOfValue = typeof value
;
if (typeOfValue === 'function' || typeOfValue === 'undefined') { // missing subscripts attribute
callback = value;
value = subscripts;
subscripts = [];
typeOfValue = typeof value;
}
// casting
if (typeOfValue === 'undefined' || value === null) {
return this;
} else if (typeOfValue === 'number') { // number casts to string
value = value.toString();
typeOfValue = 'string';
} else if (typeOfValue === 'boolean') {
value = value ? '1true' : '0false';
typeOfValue = 'string';
} else if (value instanceof Date) { // date casts to string
value = value.toJSON();
typeOfValue = 'string';
}
if (typeOfValue === 'string' || Buffer.isBuffer(value)) {
if (typeOfValue === 'string' && Buffer.byteLength(value) > CACHE_MAX_SIZE || value.length > CACHE_MAX_SIZE) {
value = new Buffer(value);
callback = callback || function() {};
completed = 0;
for (var length = value.length, i = 0, begin = 0; begin < length; i += 1, begin += CACHE_MAX_SIZE) {
completed += 1;
this.set(name, i ? subscripts.concat(i) : subscripts, value.slice(begin, begin + CACHE_MAX_SIZE), function(err) {
if (err) {
callback(err);
callback = function() {};
} else {
completed -= 1;
if (completed === 0) {
callback.call(this, null);
}
}
});
}
return this;
} else {
name = createNameFromSubscript(name, subscripts);
isValidCacheVar(name);
this._tryCommand({
cmd: BCMD.SET,
name: name,
value: value,
callback: callback
});
return this;
}
} else if (typeOfValue === 'function') {
// do nothing TODO function stringify option
return this;
} else if (typeOfValue === 'object') { // object or array
completed = Object.keys(value).length;
Object.keys(value).forEach(function(key) {
self.set(name, subscripts.concat(key), value[key], function(err) {
if (err && callback) {
callback(err);
callback = function() {};
}
completed -= 1;
if (completed === 0 && callback) {
callback.call(this, null);
}
});
});
return this;
} else {
var err = new Error('Method `set` can accept only `string`, `object`, `Buffer`, `number`, `Date` value types. And ignores `function`. Not: ' + value);
if (callback) {
callback(err);
} else {
throw err;
}
return this;
}
};
/**
* Save javascript-оbject in Cache'
* @param {string} variable or global name (global name starts with `^`)
* @param {Array.<string>} [subscripts]
* @param {Object} object javascript-object
* @param {function(?Error)} [callback] callback
* @deprecated
*/
BoobstSocket.prototype.saveObject = BoobstSocket.prototype.set;
/**
* Returns next subscript based on current
* @param name
* @param subscript
* @param {Function} callback
*/
BoobstSocket.prototype.order = BoobstSocket.prototype.next = function(name, subscript, callback) {
name = createNameFromSubscript(name, subscript);
this._tryCommand({
cmd: BCMD.ORDER,
name: name,
callback: callback
});
return this;
};
/**
* Changes namespace
* @param {string} name existing namespace
* @param {function} [callback] callback
*/
BoobstSocket.prototype.zn = function(name, callback) {
this._tryCommand({
cmd: BCMD.ZN,
name: name,
callback: callback
});
/*
if (name.toUpperCase() !== this.ns) {
this._tryCommand({
cmd: BCMD.ZN,
name: name,
callback: callback
});
} else {
if (callback) {
callback.call(this, null, false);
}
}
*/
return this;
};
/**
* Kill a global or a local
* @param {string} name
* @param {Array<string> | function(this:boobst.BoobstSocket, (null|Error))} [subscripts]
* @param {function(this:boobst.BoobstSocket, (null|Error))} [callback] callback
*/
BoobstSocket.prototype.kill = function(name, subscripts, callback) {
if (typeof callback === 'undefined' && typeof subscripts === 'function') {
callback = subscripts;
subscripts = [];
}
name = createNameFromSubscript(name, subscripts);
this._tryCommand({
cmd: BCMD.KILL,
name: name,
callback: callback
});
return this;
};
/**
* Send binary data
* @param {string} uri uri format is: file://<file_path> or global://<global_name_w/o_^>
* @param {Stream} stream data stream
* @param {function(this:boobst.BoobstSocket, (null|Error))} [callback] callback
*/
BoobstSocket.prototype.blob = function(uri, stream, callback) {
var arr = uri.split('://');
if (arr.length !== 2) {
throw new Error('Invalid uri for blob command: "' + uri + '"');
}
if (arr[0] === 'global') {
isValidCacheVar(arr[1]);
}
this._tryCommand({
cmd: BCMD.BLOB
, stream: stream
, uri: uri
, callback: callback
});
return this;
};
/**
* Clear the local namespace and set the server variables again
* @param {function(this:boobst.BoobstSocket, (null|Error))} [callback] callback
*/
BoobstSocket.prototype.flush = function(callback) {
this._tryCommand({cmd: BCMD.FLUSH, callback: callback});
return this;
};
/**
* Check server state
* @param {function(this:boobst.BoobstSocket, (null|Error), {string})} [callback] callback
*/
BoobstSocket.prototype.ping = function(callback) {
this._tryCommand({cmd: BCMD.PING, callback: callback});
return this;
};
BoobstSocket.prototype.increment = function(name, subscripts, value, callback) {
if (callback === undefined) {
switch (typeof subscripts) {
case 'number':
callback = value;
value = subscripts;
subscripts = [];
break;
case 'function':
callback = subscripts;
value = 1;
subscripts = [];
break;
case 'object':
callback = value;
value = 1;
}
}
this.get(name, subscripts, function(err, data) {
var inc = Number(data.toString());
inc = (isNaN(inc) ? 0 : inc) + value;
// TODO global lock
this.set(name, subscripts, inc, function(err) {
if (err) {
callback(err);
} else {
callback(null, inc);
}
});
});
};
/**
* Disconnect from the db
* @param {function(this:boobst.BoobstSocket, (null|Error))} [callback] callback
*/
BoobstSocket.prototype.disconnect = function(callback) {
this._tryCommand({cmd: BCMD.DISCONNECT, callback: callback});
return this;
};
/**
* Start next command from the queue
* @private
*/
BoobstSocket.prototype._runCommandFromQueue = function() {
if (this.queue.length > 0) {
var task = this.queue.shift();
this._tryCommand(task);
}
};
//--------------------------------------------------------------------------
function isValidCacheVar(name) {
/* TODO fix it
if (!VALID_CACHE_VAR_RE.test(name)) {
throw new Error('"' + name + "\" isn't a valid Cache' variable name");
}
*/
}
function createNameFromSubscript(name, subscript) {
if (Array.isArray(subscript) && subscript.length > 0) {
return name + '(' + subscript.map(function(sub) {return '"' + sub + '"';}).join(',') + ')';
} else {
return name;
}
}
BoobstSocket.prototype.error = function(text) {
this.emit('debug', text);
};
BoobstSocket.prototype.log = function(text) {
this.emit('debug', text);
};
//--------------------------------------------------------------------------
BoobstSocket.prototype.port = 6666;
exports.BoobstSocket = BoobstSocket;