From a494d0e5273dec1b83c14fc1f8950611d4d9373c Mon Sep 17 00:00:00 2001 From: Misty De Meo Date: Fri, 11 Jan 2019 09:27:20 +1100 Subject: [PATCH] feat: Add new Datastore class The Datastore class provides a persistent, database-backed key-value data storage class. This complements the existing Brain class in a few ways: 1. Each get/set operation is directly backed by the backing database, allowing multiple Hubot instances to share cooperative access to data simultaneously; 2. get/set operations are asynchronous, mapping well to the async access methods used by many database adapters. --- docs/implementation.md | 11 ++++ docs/scripting.md | 27 ++++++++- es2015.js | 3 + src/datastore.js | 94 +++++++++++++++++++++++++++++++ src/datastores/memory.js | 23 ++++++++ src/robot.js | 1 + test/datastore_test.js | 116 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 273 insertions(+), 2 deletions(-) create mode 100644 src/datastore.js create mode 100644 src/datastores/memory.js create mode 100644 test/datastore_test.js diff --git a/docs/implementation.md b/docs/implementation.md index fa7bdeac7..5f2bc9875 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -36,6 +36,8 @@ There are two primary entry points for middleware: ## Persistence +### Brain + Hubot has a memory exposed as the `robot.brain` object that can be used to store and retrieve data. Furthermore, Hubot scripts exist to enable persistence across Hubot restarts. `hubot-redis-brain` is such a script and uses a backend Redis server. @@ -44,3 +46,12 @@ By default, the brain contains a list of all users seen by Hubot. Therefore, without persistence across restarts, the brain will contain the list of users encountered so far, during the current run of Hubot. On the other hand, with persistence across restarts, the brain will contain all users encountered by Hubot during all of its runs. This list of users can be accessed through `hubot.brain.users()` and other utility methods. + +### Datastore + +Hubot's optional datastore, exposed as the `robot.datastore` object, provides a more robust persistence model. Compared to the brain, the datastore: + +1. Is always (instead of optionally) backed by a database +2. Fetches data from the database and stores data in the database on every request, instead of periodically persisting the entire in-memory brain. + +The datastore is useful in cases where there's a need for greater reassurances of data integrity or in cases where multiple Hubot instances need to access the same database. diff --git a/docs/scripting.md b/docs/scripting.md index e5e13afb6..b9432264c 100644 --- a/docs/scripting.md +++ b/docs/scripting.md @@ -565,8 +565,10 @@ The other sections are more relevant to developers of the bot, particularly depe ## Persistence -Hubot has an in-memory key-value store exposed as `robot.brain` that can be -used to store and retrieve data by scripts. +Hubot has two persistence methods available that can be +used to store and retrieve data by scripts: an in-memory key-value store exposed as `robot.brain`, and an optional persistent database-backed key-value store expsoed as `robot.datastore` + +### Brain ```coffeescript robot.respond /have a soda/i, (res) -> @@ -600,6 +602,27 @@ module.exports = (robot) -> res.send "#{name} is user - #{user}" ``` +### Datastore + +Unlike the brain, the datastore's getter and setter methods are asynchronous and don't resolve until the call to the underlying database has resolved. This requires a slightly different approach to accessing data: + +```coffeescript +robot.respond /have a soda/i, (res) -> + # Get number of sodas had (coerced to a number). + robot.datastore.get('totalSodas').then (value) -> + sodasHad = value * 1 or 0 + + if sodasHad > 4 + res.reply "I'm too fizzy.." + else + res.reply 'Sure!' + robot.brain.set 'totalSodas', sodasHad + 1 + +robot.respond /sleep it off/i, (res) -> + robot.datastore.set('totalSodas', 0).then () -> + res.reply 'zzzzz' +``` + ## Script Loading There are three main sources to load scripts from: diff --git a/es2015.js b/es2015.js index af706962c..7f523b366 100644 --- a/es2015.js +++ b/es2015.js @@ -7,6 +7,7 @@ const Adapter = require('./src/adapter') const Response = require('./src/response') const Listener = require('./src/listener') const Message = require('./src/message') +const DataStore = require('./src/datastore') module.exports = { User, @@ -22,6 +23,8 @@ module.exports = { LeaveMessage: Message.LeaveMessage, TopicMessage: Message.TopicMessage, CatchAllMessage: Message.CatchAllMessage, + DataStore: DataStore.DataStore, + DataStoreUnavailable: DataStore.DataStoreUnavailable, loadBot (adapterPath, adapterName, enableHttpd, botName, botAlias) { return new module.exports.Robot(adapterPath, adapterName, enableHttpd, botName, botAlias) diff --git a/src/datastore.js b/src/datastore.js new file mode 100644 index 000000000..7f211c213 --- /dev/null +++ b/src/datastore.js @@ -0,0 +1,94 @@ +'use strict' + +class DataStore { + // Represents a persistent, database-backed storage for the robot. Extend this. + // + // Returns a new Datastore with no storage. + constructor (robot) { + this.robot = robot + } + + // Public: Set value for key in the database. Overwrites existing + // values if present. Returns a promise which resolves when the + // write has completed. + // + // Value can be any JSON-serializable type. + set (key, value) { + return this._set(key, value, 'global') + } + + // Public: Assuming `key` represents an object in the database, + // sets its `objectKey` to `value`. If `key` isn't already + // present, it's instantiated as an empty object. + setObject (key, objectKey, value) { + return this.get(key).then((object) => { + let target = object || {} + target[objectKey] = value + return this.set(key, target) + }) + } + + // Public: Adds the supplied value(s) to the end of the existing + // array in the database marked by `key`. If `key` isn't already + // present, it's instantiated as an empty array. + setArray (key, value) { + return this.get(key).then((object) => { + let target = object || [] + // Extend the array if the value is also an array, otherwise + // push the single value on the end. + if (Array.isArray(value)) { + return this.set(key, target.push.apply(target, value)) + } else { + return this.set(key, target.concat(value)) + } + }) + } + + // Public: Get value by key if in the database or return `undefined` + // if not found. Returns a promise which resolves to the + // requested value. + get (key) { + return this._get(key, 'global') + } + + // Public: Digs inside the object at `key` for a key named + // `objectKey`. If `key` isn't already present, or if it doesn't + // contain an `objectKey`, returns `undefined`. + getObject (key, objectKey) { + return this.get(key).then((object) => { + let target = object || {} + return target[objectKey] + }) + } + + // Private: Implements the underlying `set` logic for the datastore. + // This will be called by the public methods. This is one of two + // methods that must be implemented by subclasses of this class. + // `table` represents a unique namespace for this key, such as a + // table in a SQL database. + // + // This returns a resolved promise when the `set` operation is + // successful, and a rejected promise if the operation fails. + _set (key, value, table) { + return Promise.reject(new DataStoreUnavailable('Setter called on the abstract class.')) + } + + // Private: Implements the underlying `get` logic for the datastore. + // This will be called by the public methods. This is one of two + // methods that must be implemented by subclasses of this class. + // `table` represents a unique namespace for this key, such as a + // table in a SQL database. + // + // This returns a resolved promise containing the fetched value on + // success, and a rejected promise if the operation fails. + _get (key, table) { + return Promise.reject(new DataStoreUnavailable('Getter called on the abstract class.')) + } +} + +class DataStoreUnavailable extends Error {} + +module.exports = { + DataStore, + DataStoreUnavailable +} diff --git a/src/datastores/memory.js b/src/datastores/memory.js new file mode 100644 index 000000000..506989911 --- /dev/null +++ b/src/datastores/memory.js @@ -0,0 +1,23 @@ +'use strict' + +const DataStore = require('../datastore').DataStore + +class InMemoryDataStore extends DataStore { + constructor (robot) { + super(robot) + this.data = { + global: {}, + users: {} + } + } + + _get (key, table) { + return Promise.resolve(this.data[table][key]) + } + + _set (key, value, table) { + return Promise.resolve(this.data[table][key] = value) + } +} + +module.exports = InMemoryDataStore diff --git a/src/robot.js b/src/robot.js index ae5029020..74046d4ca 100644 --- a/src/robot.js +++ b/src/robot.js @@ -40,6 +40,7 @@ class Robot { this.brain = new Brain(this) this.alias = alias this.adapter = null + this.datastore = null this.Response = Response this.commands = [] this.listeners = [] diff --git a/test/datastore_test.js b/test/datastore_test.js new file mode 100644 index 000000000..2cb1ae5b8 --- /dev/null +++ b/test/datastore_test.js @@ -0,0 +1,116 @@ +'use strict' + +/* global describe, beforeEach, it */ + +const chai = require('chai') +const sinon = require('sinon') +chai.use(require('sinon-chai')) + +const expect = chai.expect + +const Brain = require('../src/brain') +const InMemoryDataStore = require('../src/datastores/memory') + +describe('Datastore', function () { + beforeEach(function () { + this.clock = sinon.useFakeTimers() + this.robot = { + emit () {}, + on () {}, + receive: sinon.spy() + } + + // This *should* be callsArgAsync to match the 'on' API, but that makes + // the tests more complicated and seems irrelevant. + sinon.stub(this.robot, 'on').withArgs('running').callsArg(1) + + this.robot.brain = new Brain(this.robot) + this.robot.datastore = new InMemoryDataStore(this.robot) + this.robot.brain.userForId('1', {name: 'User One'}) + this.robot.brain.userForId('2', {name: 'User Two'}) + }) + + describe('global scope', function () { + it('returns undefined for values not in the datastore', function () { + return this.robot.datastore.get('blah').then(function (value) { + expect(value).to.be.an('undefined') + }) + }) + + it('can store simple values', function () { + return this.robot.datastore.set('key', 'value').then(() => { + return this.robot.datastore.get('key').then((value) => { + expect(value).to.equal('value') + }) + }) + }) + + it('can store arbitrary JavaScript values', function () { + let object = { + 'name': 'test', + 'data': [1, 2, 3] + } + return this.robot.datastore.set('key', object).then(() => { + return this.robot.datastore.get('key').then((value) => { + expect(value.name).to.equal('test') + expect(value.data).to.deep.equal([1, 2, 3]) + }) + }) + }) + + it('can dig inside objects for values', function () { + let object = { + 'a': 'one', + 'b': 'two' + } + return this.robot.datastore.set('key', object).then(() => { + return this.robot.datastore.getObject('key', 'a').then((value) => { + expect(value).to.equal('one') + }) + }) + }) + + it('can set individual keys inside objects', function () { + let object = { + 'a': 'one', + 'b': 'two' + } + return this.robot.datastore.set('object', object).then(() => { + return this.robot.datastore.setObject('object', 'c', 'three').then(() => { + return this.robot.datastore.get('object').then((value) => { + expect(value.a).to.equal('one') + expect(value.b).to.equal('two') + expect(value.c).to.equal('three') + }) + }) + }) + }) + + it('creates an object from scratch when none exists', function () { + return this.robot.datastore.setObject('object', 'key', 'value').then(() => { + return this.robot.datastore.get('object').then((value) => { + let expected = {'key': 'value'} + expect(value).to.deep.equal(expected) + }) + }) + }) + + it('can append to an existing array', function () { + return this.robot.datastore.set('array', [1, 2, 3]).then(() => { + return this.robot.datastore.setArray('array', 4).then(() => { + return this.robot.datastore.get('array').then((value) => { + expect(value).to.deep.equal([1, 2, 3, 4]) + }) + }) + }) + }) + + it('creates an array from scratch when none exists', function () { + return this.robot.datastore.setArray('array', 4).then(() => { + return this.robot.datastore.get('array').then((value) => { + expect(value).to.deep.equal([4]) + }) + }) + }) + }) +})