Skip to content

Commit fe1230e

Browse files
bennygenelnicolodavis
authored andcommitted
Firebase integration (#223)
* Firebase complete implementation with tests * small fix * split dbs to separate files * missing imports fixed * really fixed imports this time * fixed error caused by removed db.js and added Firebase * simplified db init and made suggested changes for tests * minor formatting fix * refactor tests * minor formatting
1 parent 7d00e63 commit fe1230e

12 files changed

Lines changed: 502 additions & 83 deletions

File tree

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@
8383
"eslint-plugin-import": "^2.7.0",
8484
"eslint-plugin-jest": "^21.1.0",
8585
"eslint-plugin-react": "7.3.0",
86+
"firebase-mock": "^2.2.4",
8687
"generate-export-aliases": "^1.0.0",
8788
"html-webpack-plugin": "^2.30.1",
8889
"husky": "^0.15.0-rc.8",
@@ -117,6 +118,7 @@
117118
},
118119
"dependencies": {
119120
"@koa/cors": "^2.2.1",
121+
"firebase": "^5.0.4",
120122
"koa": "^2.3.0",
121123
"koa-body": "^2.5.0",
122124
"koa-router": "^7.2.1",

packages/server.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,6 @@
77
*/
88

99
import { Server } from '../src/server/index.js';
10-
import { Mongo } from '../src/server/db.js';
10+
import { Mongo, Firebase } from '../src/server/db/index.js';
1111

12-
export { Server, Mongo };
12+
export { Server, Mongo, Firebase };

src/server/db/firebase.js

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
/*
2+
* Copyright 2017 The boardgame.io Authors
3+
*
4+
* Use of this source code is governed by a MIT-style
5+
* license that can be found in the LICENSE file or at
6+
* https://opensource.org/licenses/MIT.
7+
*/
8+
9+
const LRU = require('lru-cache');
10+
const firebase = require('firebase');
11+
12+
const ENGINE_FIRESTORE = 'Firestore';
13+
const ENGINE_RTDB = 'RTDB';
14+
15+
/**
16+
* Firebase RTDB/Firestore connector.
17+
*/
18+
export class Firebase {
19+
/**
20+
* Creates a new Firebase connector object.
21+
* The default engine is Firestore.
22+
* @constructor
23+
*/
24+
constructor({ config, dbname, engine, cacheSize }) {
25+
if (cacheSize === undefined) {
26+
cacheSize = 1000;
27+
}
28+
29+
if (dbname === undefined) {
30+
dbname = 'bgio';
31+
}
32+
33+
// // TODO: better handling for possible errors
34+
if (config === undefined) {
35+
config = {};
36+
}
37+
38+
this.client = firebase;
39+
this.engine = engine === ENGINE_RTDB ? engine : ENGINE_FIRESTORE;
40+
this.config = config;
41+
this.dbname = dbname;
42+
this.cache = new LRU({ max: cacheSize });
43+
}
44+
45+
/**
46+
* Connect to the instance.
47+
*/
48+
async connect() {
49+
this.client.initializeApp(this.config);
50+
this.db =
51+
this.engine === ENGINE_FIRESTORE
52+
? this.client.firestore()
53+
: this.client.database().ref();
54+
return;
55+
}
56+
57+
/**
58+
* Write the game state.
59+
* @param {string} id - The game id.
60+
* @param {object} store - A game state to persist.
61+
*/
62+
async set(id, state) {
63+
const cacheValue = this.cache.get(id);
64+
if (cacheValue && cacheValue._stateID >= state._stateID) {
65+
return;
66+
}
67+
68+
this.cache.set(id, state);
69+
70+
const col =
71+
this.engine === ENGINE_RTDB
72+
? this.db.child(id)
73+
: this.db.collection(this.dbname).doc(id);
74+
delete state._id;
75+
await col.set(state);
76+
77+
return;
78+
}
79+
80+
/**
81+
* Read the game state.
82+
* @param {string} id - The game id.
83+
* @returns {object} - A game state, or undefined
84+
* if no game is found with this id.
85+
*/
86+
async get(id) {
87+
let cacheValue = this.cache.get(id);
88+
if (cacheValue !== undefined) {
89+
return cacheValue;
90+
}
91+
92+
let col, doc, data;
93+
if (this.engine === ENGINE_RTDB) {
94+
col = this.db.child(id);
95+
data = await col.once('value');
96+
doc = data.val()
97+
? Object.assign({}, data.val(), { _id: id })
98+
: data.val();
99+
} else {
100+
col = this.db.collection(this.dbname).doc(id);
101+
data = await col.get();
102+
doc = data.data()
103+
? Object.assign({}, data.data(), { _id: id })
104+
: data.data();
105+
}
106+
107+
let oldStateID = 0;
108+
cacheValue = this.cache.get(id);
109+
/* istanbul ignore next line */
110+
if (cacheValue !== undefined) {
111+
/* istanbul ignore next line */
112+
oldStateID = cacheValue._stateID;
113+
}
114+
115+
let newStateID = -1;
116+
if (doc) {
117+
newStateID = doc._stateID;
118+
}
119+
120+
// Update the cache, but only if the read
121+
// value is newer than the value already in it.
122+
// A race condition might overwrite the
123+
// cache with an older value, so we need this.
124+
if (newStateID >= oldStateID) {
125+
this.cache.set(id, doc);
126+
}
127+
128+
return doc;
129+
}
130+
131+
/**
132+
* Check if a particular game exists.
133+
* @param {string} id - The game id.
134+
* @returns {boolean} - True if a game with this id exists.
135+
*/
136+
async has(id) {
137+
const cacheValue = this.cache.get(id);
138+
if (cacheValue !== undefined) {
139+
return true;
140+
}
141+
142+
let col, data, exists;
143+
if (this.engine === ENGINE_RTDB) {
144+
col = this.db.child(id);
145+
data = await col.once('value');
146+
exists = data.exists();
147+
} else {
148+
col = this.db.collection(this.dbname).doc(id);
149+
data = await col.get();
150+
exists = data.exists;
151+
}
152+
153+
return exists;
154+
}
155+
}

src/server/db/firebase.test.js

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
/*
2+
* Copyright 2017 The boardgame.io Authors
3+
*
4+
* Use of this source code is governed by a MIT-style
5+
* license that can be found in the LICENSE file or at
6+
* https://opensource.org/licenses/MIT.
7+
*/
8+
9+
import { Firebase } from './firebase';
10+
import firebasemock from 'firebase-mock';
11+
12+
function NewFirebase(args) {
13+
const mockDatabase = new firebasemock.MockFirebase();
14+
const mockFirestore = new firebasemock.MockFirestore();
15+
16+
const config = {
17+
apiKey: 'apikey',
18+
authDomain: 'authDomain',
19+
databaseURL: 'databaseURL',
20+
projectId: 'projectId',
21+
};
22+
23+
var mockSDK = new firebasemock.MockFirebaseSdk(
24+
// use null if your code does not use RTDB
25+
() => mockDatabase,
26+
// use null if your code does not use AUTHENTICATION
27+
() => null,
28+
// use null if your code does not use FIRESTORE
29+
() => mockFirestore,
30+
// use null if your code does not use STORAGE
31+
() => null,
32+
// use null if your code does not use MESSAGING
33+
() => null
34+
);
35+
36+
const db = new Firebase({ ...args, config });
37+
db.client = mockSDK;
38+
return db;
39+
}
40+
41+
test('construction', () => {
42+
const dbname = 'a';
43+
const db = new Firebase({ dbname });
44+
expect(db.dbname).toBe(dbname);
45+
expect(db.config).toEqual({});
46+
});
47+
48+
describe('Firestore', async () => {
49+
let db = null;
50+
51+
beforeEach(async () => {
52+
db = NewFirebase({
53+
engine: 'Firestore',
54+
});
55+
await db.connect();
56+
db.db.autoFlush();
57+
});
58+
59+
test('must return undefined when no game exists', async () => {
60+
const state = await db.get('gameID');
61+
expect(state).toEqual(null);
62+
});
63+
64+
test('cache hit', async () => {
65+
// Create game.
66+
await db.set('gameID', { a: 1 });
67+
68+
// Must return created game.
69+
const state = await db.get('gameID');
70+
expect(state).toMatchObject({ a: 1 });
71+
72+
// Must return true if game exists
73+
const has = await db.has('gameID');
74+
expect(has).toBe(true);
75+
});
76+
77+
test('cache miss', async () => {
78+
// Create game.
79+
await db.set('gameID', { a: 1 });
80+
81+
// Must return created game.
82+
db.cache.reset();
83+
const state = await db.get('gameID');
84+
expect(state).toMatchObject({ a: 1 });
85+
86+
// Must return true if game exists
87+
db.cache.reset();
88+
const has = await db.has('gameID');
89+
expect(has).toBe(true);
90+
});
91+
92+
test('cache size', async () => {
93+
const db = NewFirebase({
94+
cacheSize: 1,
95+
engine: 'Firestore',
96+
});
97+
await db.connect();
98+
db.db.autoFlush();
99+
await db.set('gameID', { a: 1 });
100+
await db.set('another', { b: 1 });
101+
const state = await db.get('gameID');
102+
// Check that it came from Firebase and not the cache.
103+
expect(state._id).toBeDefined();
104+
});
105+
106+
test('race conditions', async () => {
107+
// Out of order set()'s.
108+
await db.set('gameID', { _stateID: 1 });
109+
await db.set('gameID', { _stateID: 0 });
110+
expect(await db.get('gameID')).toEqual({ _stateID: 1 });
111+
112+
// Do not override cache on get() if it is fresher than Firebase.
113+
await db.set('gameID', { _stateID: 0 });
114+
db.cache.set('gameID', { _stateID: 1 });
115+
await db.get('gameID');
116+
expect(db.cache.get('gameID')).toEqual({ _stateID: 1 });
117+
118+
// Override if it is staler than Firebase.
119+
await db.set('gameID', { _stateID: 1 });
120+
db.cache.reset();
121+
expect(await db.get('gameID')).toMatchObject({ _stateID: 1 });
122+
expect(db.cache.get('gameID')).toMatchObject({ _stateID: 1 });
123+
});
124+
});
125+
126+
describe('RTDB', async () => {
127+
let db = null;
128+
129+
beforeEach(async () => {
130+
db = NewFirebase({
131+
engine: 'RTDB',
132+
});
133+
await db.connect();
134+
db.db.autoFlush();
135+
});
136+
137+
test('must return undefined when no game exists', async () => {
138+
const state = await db.get('gameID');
139+
expect(state).toEqual(null);
140+
});
141+
142+
test('cache hit', async () => {
143+
// Create game.
144+
await db.set('gameID', { a: 1 });
145+
146+
// Must return created game.
147+
const state = await db.get('gameID');
148+
expect(state).toMatchObject({ a: 1 });
149+
150+
// Must return true if game exists
151+
const has = await db.has('gameID');
152+
expect(has).toBe(true);
153+
});
154+
155+
test('cache miss', async () => {
156+
// Create game.
157+
await db.set('gameID', { a: 1 });
158+
159+
// Must return created game.
160+
db.cache.reset();
161+
const state = await db.get('gameID');
162+
expect(state).toMatchObject({ a: 1 });
163+
164+
// Must return true if game exists
165+
db.cache.reset();
166+
const has = await db.has('gameID');
167+
expect(has).toBe(true);
168+
});
169+
170+
test('cache size', async () => {
171+
const db = NewFirebase({
172+
cacheSize: 1,
173+
engine: 'Firestore',
174+
});
175+
await db.connect();
176+
db.db.autoFlush();
177+
await db.set('gameID', { a: 1 });
178+
await db.set('another', { b: 1 });
179+
const state = await db.get('gameID');
180+
// Check that it came from Firebase and not the cache.
181+
expect(state._id).toBeDefined();
182+
});
183+
184+
test('race conditions', async () => {
185+
// Out of order set()'s.
186+
await db.set('gameID', { _stateID: 1 });
187+
await db.set('gameID', { _stateID: 0 });
188+
expect(await db.get('gameID')).toEqual({ _stateID: 1 });
189+
190+
// Do not override cache on get() if it is fresher than Firebase.
191+
await db.set('gameID', { _stateID: 0 });
192+
db.cache.set('gameID', { _stateID: 1 });
193+
await db.get('gameID');
194+
expect(db.cache.get('gameID')).toEqual({ _stateID: 1 });
195+
196+
// Override if it is staler than Firebase.
197+
await db.set('gameID', { _stateID: 1 });
198+
db.cache.reset();
199+
expect(await db.get('gameID')).toMatchObject({ _stateID: 1 });
200+
expect(db.cache.get('gameID')).toMatchObject({ _stateID: 1 });
201+
});
202+
});

0 commit comments

Comments
 (0)