Skip to content

Commit a494d0e

Browse files
committed
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.
1 parent a3d80c1 commit a494d0e

File tree

7 files changed

+273
-2
lines changed

7 files changed

+273
-2
lines changed

docs/implementation.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ There are two primary entry points for middleware:
3636

3737
## Persistence
3838

39+
### Brain
40+
3941
Hubot has a memory exposed as the `robot.brain` object that can be used to store and retrieve data.
4042
Furthermore, Hubot scripts exist to enable persistence across Hubot restarts.
4143
`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.
4446
Therefore, without persistence across restarts, the brain will contain the list of users encountered so far, during the current run of Hubot.
4547
On the other hand, with persistence across restarts, the brain will contain all users encountered by Hubot during all of its runs.
4648
This list of users can be accessed through `hubot.brain.users()` and other utility methods.
49+
50+
### Datastore
51+
52+
Hubot's optional datastore, exposed as the `robot.datastore` object, provides a more robust persistence model. Compared to the brain, the datastore:
53+
54+
1. Is always (instead of optionally) backed by a database
55+
2. Fetches data from the database and stores data in the database on every request, instead of periodically persisting the entire in-memory brain.
56+
57+
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.

docs/scripting.md

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -565,8 +565,10 @@ The other sections are more relevant to developers of the bot, particularly depe
565565

566566
## Persistence
567567

568-
Hubot has an in-memory key-value store exposed as `robot.brain` that can be
569-
used to store and retrieve data by scripts.
568+
Hubot has two persistence methods available that can be
569+
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`
570+
571+
### Brain
570572

571573
```coffeescript
572574
robot.respond /have a soda/i, (res) ->
@@ -600,6 +602,27 @@ module.exports = (robot) ->
600602
res.send "#{name} is user - #{user}"
601603
```
602604

605+
### Datastore
606+
607+
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:
608+
609+
```coffeescript
610+
robot.respond /have a soda/i, (res) ->
611+
# Get number of sodas had (coerced to a number).
612+
robot.datastore.get('totalSodas').then (value) ->
613+
sodasHad = value * 1 or 0
614+
615+
if sodasHad > 4
616+
res.reply "I'm too fizzy.."
617+
else
618+
res.reply 'Sure!'
619+
robot.brain.set 'totalSodas', sodasHad + 1
620+
621+
robot.respond /sleep it off/i, (res) ->
622+
robot.datastore.set('totalSodas', 0).then () ->
623+
res.reply 'zzzzz'
624+
```
625+
603626
## Script Loading
604627

605628
There are three main sources to load scripts from:

es2015.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const Adapter = require('./src/adapter')
77
const Response = require('./src/response')
88
const Listener = require('./src/listener')
99
const Message = require('./src/message')
10+
const DataStore = require('./src/datastore')
1011

1112
module.exports = {
1213
User,
@@ -22,6 +23,8 @@ module.exports = {
2223
LeaveMessage: Message.LeaveMessage,
2324
TopicMessage: Message.TopicMessage,
2425
CatchAllMessage: Message.CatchAllMessage,
26+
DataStore: DataStore.DataStore,
27+
DataStoreUnavailable: DataStore.DataStoreUnavailable,
2528

2629
loadBot (adapterPath, adapterName, enableHttpd, botName, botAlias) {
2730
return new module.exports.Robot(adapterPath, adapterName, enableHttpd, botName, botAlias)

src/datastore.js

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
'use strict'
2+
3+
class DataStore {
4+
// Represents a persistent, database-backed storage for the robot. Extend this.
5+
//
6+
// Returns a new Datastore with no storage.
7+
constructor (robot) {
8+
this.robot = robot
9+
}
10+
11+
// Public: Set value for key in the database. Overwrites existing
12+
// values if present. Returns a promise which resolves when the
13+
// write has completed.
14+
//
15+
// Value can be any JSON-serializable type.
16+
set (key, value) {
17+
return this._set(key, value, 'global')
18+
}
19+
20+
// Public: Assuming `key` represents an object in the database,
21+
// sets its `objectKey` to `value`. If `key` isn't already
22+
// present, it's instantiated as an empty object.
23+
setObject (key, objectKey, value) {
24+
return this.get(key).then((object) => {
25+
let target = object || {}
26+
target[objectKey] = value
27+
return this.set(key, target)
28+
})
29+
}
30+
31+
// Public: Adds the supplied value(s) to the end of the existing
32+
// array in the database marked by `key`. If `key` isn't already
33+
// present, it's instantiated as an empty array.
34+
setArray (key, value) {
35+
return this.get(key).then((object) => {
36+
let target = object || []
37+
// Extend the array if the value is also an array, otherwise
38+
// push the single value on the end.
39+
if (Array.isArray(value)) {
40+
return this.set(key, target.push.apply(target, value))
41+
} else {
42+
return this.set(key, target.concat(value))
43+
}
44+
})
45+
}
46+
47+
// Public: Get value by key if in the database or return `undefined`
48+
// if not found. Returns a promise which resolves to the
49+
// requested value.
50+
get (key) {
51+
return this._get(key, 'global')
52+
}
53+
54+
// Public: Digs inside the object at `key` for a key named
55+
// `objectKey`. If `key` isn't already present, or if it doesn't
56+
// contain an `objectKey`, returns `undefined`.
57+
getObject (key, objectKey) {
58+
return this.get(key).then((object) => {
59+
let target = object || {}
60+
return target[objectKey]
61+
})
62+
}
63+
64+
// Private: Implements the underlying `set` logic for the datastore.
65+
// This will be called by the public methods. This is one of two
66+
// methods that must be implemented by subclasses of this class.
67+
// `table` represents a unique namespace for this key, such as a
68+
// table in a SQL database.
69+
//
70+
// This returns a resolved promise when the `set` operation is
71+
// successful, and a rejected promise if the operation fails.
72+
_set (key, value, table) {
73+
return Promise.reject(new DataStoreUnavailable('Setter called on the abstract class.'))
74+
}
75+
76+
// Private: Implements the underlying `get` logic for the datastore.
77+
// This will be called by the public methods. This is one of two
78+
// methods that must be implemented by subclasses of this class.
79+
// `table` represents a unique namespace for this key, such as a
80+
// table in a SQL database.
81+
//
82+
// This returns a resolved promise containing the fetched value on
83+
// success, and a rejected promise if the operation fails.
84+
_get (key, table) {
85+
return Promise.reject(new DataStoreUnavailable('Getter called on the abstract class.'))
86+
}
87+
}
88+
89+
class DataStoreUnavailable extends Error {}
90+
91+
module.exports = {
92+
DataStore,
93+
DataStoreUnavailable
94+
}

src/datastores/memory.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
'use strict'
2+
3+
const DataStore = require('../datastore').DataStore
4+
5+
class InMemoryDataStore extends DataStore {
6+
constructor (robot) {
7+
super(robot)
8+
this.data = {
9+
global: {},
10+
users: {}
11+
}
12+
}
13+
14+
_get (key, table) {
15+
return Promise.resolve(this.data[table][key])
16+
}
17+
18+
_set (key, value, table) {
19+
return Promise.resolve(this.data[table][key] = value)
20+
}
21+
}
22+
23+
module.exports = InMemoryDataStore

src/robot.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ class Robot {
4040
this.brain = new Brain(this)
4141
this.alias = alias
4242
this.adapter = null
43+
this.datastore = null
4344
this.Response = Response
4445
this.commands = []
4546
this.listeners = []

test/datastore_test.js

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
'use strict'
2+
3+
/* global describe, beforeEach, it */
4+
5+
const chai = require('chai')
6+
const sinon = require('sinon')
7+
chai.use(require('sinon-chai'))
8+
9+
const expect = chai.expect
10+
11+
const Brain = require('../src/brain')
12+
const InMemoryDataStore = require('../src/datastores/memory')
13+
14+
describe('Datastore', function () {
15+
beforeEach(function () {
16+
this.clock = sinon.useFakeTimers()
17+
this.robot = {
18+
emit () {},
19+
on () {},
20+
receive: sinon.spy()
21+
}
22+
23+
// This *should* be callsArgAsync to match the 'on' API, but that makes
24+
// the tests more complicated and seems irrelevant.
25+
sinon.stub(this.robot, 'on').withArgs('running').callsArg(1)
26+
27+
this.robot.brain = new Brain(this.robot)
28+
this.robot.datastore = new InMemoryDataStore(this.robot)
29+
this.robot.brain.userForId('1', {name: 'User One'})
30+
this.robot.brain.userForId('2', {name: 'User Two'})
31+
})
32+
33+
describe('global scope', function () {
34+
it('returns undefined for values not in the datastore', function () {
35+
return this.robot.datastore.get('blah').then(function (value) {
36+
expect(value).to.be.an('undefined')
37+
})
38+
})
39+
40+
it('can store simple values', function () {
41+
return this.robot.datastore.set('key', 'value').then(() => {
42+
return this.robot.datastore.get('key').then((value) => {
43+
expect(value).to.equal('value')
44+
})
45+
})
46+
})
47+
48+
it('can store arbitrary JavaScript values', function () {
49+
let object = {
50+
'name': 'test',
51+
'data': [1, 2, 3]
52+
}
53+
return this.robot.datastore.set('key', object).then(() => {
54+
return this.robot.datastore.get('key').then((value) => {
55+
expect(value.name).to.equal('test')
56+
expect(value.data).to.deep.equal([1, 2, 3])
57+
})
58+
})
59+
})
60+
61+
it('can dig inside objects for values', function () {
62+
let object = {
63+
'a': 'one',
64+
'b': 'two'
65+
}
66+
return this.robot.datastore.set('key', object).then(() => {
67+
return this.robot.datastore.getObject('key', 'a').then((value) => {
68+
expect(value).to.equal('one')
69+
})
70+
})
71+
})
72+
73+
it('can set individual keys inside objects', function () {
74+
let object = {
75+
'a': 'one',
76+
'b': 'two'
77+
}
78+
return this.robot.datastore.set('object', object).then(() => {
79+
return this.robot.datastore.setObject('object', 'c', 'three').then(() => {
80+
return this.robot.datastore.get('object').then((value) => {
81+
expect(value.a).to.equal('one')
82+
expect(value.b).to.equal('two')
83+
expect(value.c).to.equal('three')
84+
})
85+
})
86+
})
87+
})
88+
89+
it('creates an object from scratch when none exists', function () {
90+
return this.robot.datastore.setObject('object', 'key', 'value').then(() => {
91+
return this.robot.datastore.get('object').then((value) => {
92+
let expected = {'key': 'value'}
93+
expect(value).to.deep.equal(expected)
94+
})
95+
})
96+
})
97+
98+
it('can append to an existing array', function () {
99+
return this.robot.datastore.set('array', [1, 2, 3]).then(() => {
100+
return this.robot.datastore.setArray('array', 4).then(() => {
101+
return this.robot.datastore.get('array').then((value) => {
102+
expect(value).to.deep.equal([1, 2, 3, 4])
103+
})
104+
})
105+
})
106+
})
107+
108+
it('creates an array from scratch when none exists', function () {
109+
return this.robot.datastore.setArray('array', 4).then(() => {
110+
return this.robot.datastore.get('array').then((value) => {
111+
expect(value).to.deep.equal([4])
112+
})
113+
})
114+
})
115+
})
116+
})

0 commit comments

Comments
 (0)