├── testDB ├── LOCK ├── CURRENT ├── LOG ├── 000003.log └── MANIFEST-000002 ├── .babelrc ├── .DS_Store ├── .gitignore ├── .npmignore ├── src ├── index.js ├── utils │ ├── metaDBUtils.js │ ├── fetchUtils.js │ ├── configUtils.js │ ├── 64to8.js │ ├── urls.js │ ├── cushionWorkerUtils.js │ └── swUtils.js ├── cushion.js ├── cushion_worker │ ├── cushionWorker.js │ └── cushionWorkerIndex.js ├── metaDB.js ├── store.js ├── databaseAuth.js └── account.js ├── index.html ├── .defaultCushionConfig.json ├── sw.js ├── tests ├── account.test.js └── store.test.js ├── package.json ├── webpack.config.js └── README.md /testDB/LOCK: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testDB/CURRENT: -------------------------------------------------------------------------------- 1 | MANIFEST-000002 2 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /testDB/LOG: -------------------------------------------------------------------------------- 1 | 2019/08/15-09:23:59.123026 7fd26d7fa700 Delete type=3 #1 2 | -------------------------------------------------------------------------------- /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CushionDB/CushionClient/HEAD/.DS_Store -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | cushionDB*/ 4 | cushionMeta/ 5 | testDB/ -------------------------------------------------------------------------------- /testDB/000003.log: -------------------------------------------------------------------------------- 1 | TNÿmeta-storeÿ_local_uuid&"b031f368-de96-40ef-aeff-fcfc624d3a7d" -------------------------------------------------------------------------------- /testDB/MANIFEST-000002: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CushionDB/CushionClient/HEAD/testDB/MANIFEST-000002 -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | cushionDB/ 3 | cushionMeta/ 4 | webpack.config.js 5 | tests/ 6 | testDB/ 7 | .gitignore 8 | .babelrc 9 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Cushion from './cushion'; 2 | 3 | export default Cushion; 4 | 5 | // global.cushion = new Cushion(); 6 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/utils/metaDBUtils.js: -------------------------------------------------------------------------------- 1 | export const getDefaultMetaDBDoc = (localDBName, remoteDBAddress, username, subscribedToPush) => { 2 | return { 3 | _id: 'cushionMeta', 4 | localDBName, 5 | remoteDBAddress, 6 | username, 7 | subscribedToPush 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /.defaultCushionConfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "couchBaseURL": "http://localhost:5984", 3 | "cushionServerBaseURL": "http://localhost:3001", 4 | "publicVapid": "BP_QrGDbKkoFatPv0iiMEfl5XINAZSknNz171ID_uNXmmA5_bRnmGgt7zqfyDOgzwptrG9w0lqEU94ru34teLQU", 5 | "appname": "CushionDB", 6 | "appPushIcons": false 7 | } -------------------------------------------------------------------------------- /src/utils/fetchUtils.js: -------------------------------------------------------------------------------- 1 | export const getFetchOpts = ({ method, data }) => { 2 | let fetchOpts = { 3 | method, 4 | headers: { 5 | 'Content-Type': 'application/json', 6 | Accept: 'application/json', 7 | } 8 | } 9 | 10 | return data ? { ...fetchOpts, body: JSON.stringify(data) } : fetchOpts; 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/configUtils.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const rootDir = path.dirname(require.main.filename); 3 | let configObj = require('../../.defaultCushionConfig.json'); 4 | 5 | export const getConfigObj = () => { 6 | let userConfig; 7 | 8 | try { 9 | userConfig = require (rootDir + 'cushionConfig.json'); 10 | } catch { 11 | userConfig = undefined; 12 | } 13 | 14 | return userConfig ? {...configObj, ...userConfig} : configObj; 15 | } 16 | -------------------------------------------------------------------------------- /src/utils/64to8.js: -------------------------------------------------------------------------------- 1 | const urlB64ToUint8Array = (base64String) => { 2 | const padding = '='.repeat((4 - base64String.length % 4) % 4); 3 | const base64 = (base64String + padding) 4 | .replace(/\-/g, '+') 5 | .replace(/_/g, '/'); 6 | 7 | const rawData = window.atob(base64); 8 | const outputArray = new Uint8Array(rawData.length); 9 | 10 | for (let i = 0; i < rawData.length; ++i) { 11 | outputArray[i] = rawData.charCodeAt(i); 12 | } 13 | return outputArray; 14 | } 15 | 16 | export default urlB64ToUint8Array; 17 | -------------------------------------------------------------------------------- /sw.js: -------------------------------------------------------------------------------- 1 | try { 2 | importScripts('node_modules/cushiondb-client/dist/cushionWorker.js'); 3 | } catch { 4 | importScripts('assets/cushionWorker.js'); 5 | } 6 | 7 | self.addEventListener('sync', evt => { 8 | evt.waitUntil(cushionWorker.syncEventTriggered(evt)); 9 | }); 10 | 11 | self.addEventListener('message', evt => { 12 | evt.waitUntil(cushionWorker.messageEventTriggered(evt)); 13 | }); 14 | 15 | self.addEventListener('push', evt => { 16 | console.log('pusheventTriggered'); 17 | evt.waitUntil(cushionWorker.pushEventTriggered(evt)); 18 | }); 19 | -------------------------------------------------------------------------------- /src/utils/urls.js: -------------------------------------------------------------------------------- 1 | import { getConfigObj } from './configUtils'; 2 | 3 | const configObj = getConfigObj(); 4 | 5 | export const subscribeDeviceToPush = () => `${configObj.cushionServerBaseURL}/subscribe_device_to_notifications`; 6 | export const signup = () => `${configObj.cushionServerBaseURL}/signup`; 7 | export const isSubscribedToPush = (username) => `${configObj.cushionServerBaseURL}/is_subscribed_to_push/${username}`; 8 | export const changePassword = () => `${configObj.cushionServerBaseURL}/updatePassword`; 9 | export const triggerUpdateDevices = () => `${configObj.cushionServerBaseURL}/trigger_update_user_devices`; 10 | -------------------------------------------------------------------------------- /src/cushion.js: -------------------------------------------------------------------------------- 1 | import Store from './store'; 2 | import Account from './account'; 3 | import DatabaseAuth from './databaseAuth'; 4 | 5 | import { registerServiceWorker } from './utils/swUtils'; 6 | import { getConfigObj } from './utils/configUtils'; 7 | 8 | const TESTING = process.env.NODE_ENV === 'testing'; 9 | const CONFIG = getConfigObj(); 10 | 11 | class Cushion { 12 | constructor() { 13 | if (!TESTING) registerServiceWorker(); 14 | 15 | const dbAuth = new DatabaseAuth(CONFIG.couchBaseURL); 16 | 17 | this.ready = dbAuth.ready; 18 | 19 | dbAuth.ready.then(() => { 20 | this.store = new Store(dbAuth); 21 | this.account = new Account(dbAuth); 22 | }); 23 | } 24 | }; 25 | 26 | export default Cushion; 27 | -------------------------------------------------------------------------------- /src/utils/cushionWorkerUtils.js: -------------------------------------------------------------------------------- 1 | import PouchDB from 'pouchdb'; 2 | 3 | export const pouchSync = (fromDB, toDB) => { 4 | return new PouchDB(fromDB).replicate.to(toDB); 5 | } 6 | 7 | export const addEventToArr = (arr, id, evt) => { 8 | if (arr.some(e => e.id === id)) throw new Error('Event ID taken'); 9 | 10 | return [ ...arr, { id, evt } ]; 11 | } 12 | 13 | export const removeEventFromArr = (arr, id) => { 14 | const len = arr.length; 15 | const filteredArr = arr.filter(evt => evt.id !== id); 16 | 17 | if (len === filteredArr.length) throw new Error('Event ID not found'); 18 | 19 | return filteredArr; 20 | } 21 | 22 | export const triggerEvents = (arr, id, evt) => { 23 | return new Promise((res, rej) => { 24 | const event = arr.find(e => e.id === id).evt; 25 | 26 | event(evt); 27 | 28 | res(true); 29 | rej('Event with this ID not found'); 30 | }); 31 | } 32 | 33 | export const triggerUpdateUsersDevices = () => { 34 | 35 | } 36 | -------------------------------------------------------------------------------- /tests/account.test.js: -------------------------------------------------------------------------------- 1 | import Cushion from '../src/cushion'; 2 | import PouchDB from 'pouchdb'; 3 | 4 | const account = new Cushion().account; 5 | const user = { username: 'foo', password: 'secret' }; 6 | 7 | describe('Test sign in', () => { 8 | 9 | test('Sign in foo user with secret password', () => { 10 | expect.assertions(1); 11 | return account.signIn(user).then( res => 12 | expect(res.name).toBe('foo') 13 | ) 14 | }); 15 | }); 16 | 17 | describe('Get session information', () => { 18 | test('Get user foo session information' , () => { 19 | expect.assertions(1); 20 | return account.getSession().then( res => { 21 | expect(res.ok).toBe(true); 22 | } ); 23 | }); 24 | }); 25 | 26 | describe('Change foo password', () => { 27 | test('Change user foo password from secret to secret2', () => { 28 | expect.assertions(1); 29 | return account.changePassword(user.username, 'secret2') 30 | .then( res => expect(res.ok).toBe(true) ); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cushiondb-client", 3 | "version": "0.1.2", 4 | "description": "CushionDB's database on the client side", 5 | "main": "./dist/main.js", 6 | "scripts": { 7 | "prod": "webpack --mode production", 8 | "dev": "webpack-dev-server --mode development --open", 9 | "build": "webpack --config ./webpack.config.js --mode=production", 10 | "test": "jest" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/CushionDB/CushionClient.git" 15 | }, 16 | "keywords": [], 17 | "author": "", 18 | "license": "ISC", 19 | "bugs": { 20 | "url": "https://github.com/CushionDB/issues" 21 | }, 22 | "homepage": "https://github.com/drote/cushion#readme", 23 | "devDependencies": { 24 | "@babel/core": "^7.5.5", 25 | "@babel/preset-env": "^7.5.5", 26 | "babel-loader": "^8.0.6", 27 | "jes": "^0.6.1", 28 | "jest": "^24.8.0", 29 | "webpack": "^4.36.1", 30 | "webpack-cli": "^3.3.6", 31 | "webpack-dev-server": "^3.7.2" 32 | }, 33 | "dependencies": { 34 | "fs": "0.0.1-security", 35 | "pouchdb": "^7.1.1", 36 | "pouchdb-authentication": "^1.1.3", 37 | "pouchdb-find": "^7.1.1", 38 | "utf-8": "^2.0.0" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = [{ 4 | output: { 5 | path: path.resolve(__dirname, 'dist'), 6 | filename: '[name].js', 7 | publicPath: '/assets', 8 | library: 'cushiondb-client', 9 | libraryExport: 'default', 10 | libraryTarget: 'umd' 11 | }, 12 | entry: { 13 | main: './src/index.js', 14 | }, 15 | module: { 16 | rules: [ 17 | { 18 | test: /\.js$/, 19 | exclude: /node_modules/, 20 | use: { 21 | loader: "babel-loader" 22 | } 23 | } 24 | ] 25 | }, 26 | node: { 27 | fs: 'empty' 28 | } 29 | }, 30 | { 31 | output: { 32 | path: path.resolve(__dirname, 'dist'), 33 | filename: '[name].js', 34 | publicPath: '/assets' 35 | }, 36 | entry: { 37 | cushionWorker: './src/cushion_worker/cushionWorkerIndex.js' 38 | }, 39 | module: { 40 | rules: [ 41 | { 42 | test: /\.js$/, 43 | exclude: /node_modules/, 44 | use: { 45 | loader: "babel-loader" 46 | } 47 | } 48 | ] 49 | }, 50 | node: { 51 | fs: 'empty' 52 | } 53 | } 54 | ]; 55 | -------------------------------------------------------------------------------- /src/cushion_worker/cushionWorker.js: -------------------------------------------------------------------------------- 1 | import PouchDB from 'pouchdb'; 2 | import path from 'path'; 3 | import * as utils from '../utils/cushionWorkerUtils'; 4 | 5 | class CushionWorker { 6 | constructor() { 7 | this.pushEvents = []; 8 | this.messageEvents = []; 9 | this.syncEvents = []; 10 | } 11 | 12 | getMetaDB() { 13 | const cushionMeta = new PouchDB('cushionMeta'); 14 | return cushionMeta.get('cushionMeta'); 15 | } 16 | 17 | pushEventTriggered(evt) { 18 | const eventData = JSON.parse(evt.data.text()); 19 | 20 | return utils.triggerEvents(this.pushEvents, eventData.action, evt); 21 | } 22 | 23 | messageEventTriggered(evt) { 24 | const eventData = evt.data; 25 | 26 | return utils.triggerEvents(this.messageEvents, eventData.id, evt); 27 | } 28 | 29 | syncEventTriggered(evt) { 30 | return utils.triggerEvents(this.syncEvents, evt.tag, evt); 31 | } 32 | 33 | addPushEvent(id, evt) { 34 | this.pushEvents = utils.addEventToArr(this.pushEvents, id, evt); 35 | } 36 | 37 | addMessageEvent(id, evt) { 38 | this.messageEvents = utils.addEventToArr(this.messageEvents, id, evt); 39 | } 40 | 41 | addSyncEvent(id, evt) { 42 | this.syncEvents = utils.addEventToArr(this.syncEvents, id, evt); 43 | } 44 | 45 | removePushEvent(id) { 46 | this.pushEvents = utils.removeEventFromArr(this.pushEvents, id); 47 | } 48 | 49 | removeSyncEvent(id) { 50 | this.syncEvents = utils.removeEventFromArr(this.syncEvents, id); 51 | } 52 | 53 | removeMessageEvent(id) { 54 | this.messageEvents = utils.removeEventFromArr(this.messageEvents, id); 55 | } 56 | } 57 | 58 | export default CushionWorker; 59 | -------------------------------------------------------------------------------- /src/utils/swUtils.js: -------------------------------------------------------------------------------- 1 | import urlB64ToUint8Array from './64to8'; 2 | import { getConfigObj } from './configUtils'; 3 | 4 | const configObj = getConfigObj(); 5 | 6 | const getServiceWorker = () => { 7 | if (navigator.serviceWorker.controller) { 8 | return Promise.resolve(navigator.serviceWorker); 9 | } 10 | 11 | return new Promise((resolve) => { 12 | function onControllerChange() { 13 | navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange); 14 | resolve(navigator.serviceWorker); 15 | } 16 | 17 | navigator.serviceWorker.addEventListener('controllerchange', onControllerChange); 18 | }); 19 | } 20 | 21 | const postMessage = (id, payload, sw) => { 22 | return new Promise((res, rej) => { 23 | const msgChannel = new MessageChannel(); 24 | msgChannel.port1.onmessage = (evt) => { 25 | if (evt.data.error) { 26 | rej(evt.data.error); 27 | } else { 28 | res(evt.data); 29 | } 30 | } 31 | 32 | sw.controller.postMessage({ id, payload }, [msgChannel.port2]); 33 | }); 34 | } 35 | 36 | export const subscribeDeviceToNotifications = () => { 37 | return getServiceWorker() 38 | 39 | .then(sw => sw.ready) 40 | .then(reg => { 41 | return reg.pushManager.subscribe({ 42 | userVisibleOnly: true, 43 | applicationServerKey: urlB64ToUint8Array(configObj.publicVapid), 44 | }); 45 | }) 46 | 47 | .catch(err => Promise.reject(err)); 48 | } 49 | 50 | export const scheduleSyncPush = () => { 51 | getServiceWorker().then(sw => { 52 | postMessage('SCHEDULE_PUSH', {}, sw); 53 | }); 54 | } 55 | 56 | export const scheduleSyncPull = () => { 57 | getServiceWorker().then(sw => { 58 | postMessage('SCHEDULE_PULL', {}, sw); 59 | }); 60 | } 61 | 62 | export const registerServiceWorker = () => { 63 | if ('serviceWorker' in navigator) { 64 | navigator.serviceWorker.register('../../sw.js'); 65 | } 66 | } -------------------------------------------------------------------------------- /src/metaDB.js: -------------------------------------------------------------------------------- 1 | import PouchDB from 'pouchdb'; 2 | import { getFetchOpts } from './utils/fetchUtils'; 3 | import * as utils from './utils/metaDBUtils'; 4 | import * as urls from './utils/urls'; 5 | 6 | let metaDB; 7 | 8 | class MetaDB { 9 | constructor() { 10 | this.localName = 'cushionDB'; 11 | this.remoteAddress; 12 | this.pushSubscribed; 13 | this.username; 14 | this.ready = this.assignMetaVars(); 15 | } 16 | 17 | localDBName() { 18 | return this.localName; 19 | } 20 | 21 | remoteDBName() { 22 | return this.remoteAddress; 23 | } 24 | 25 | subscribedToPush() { 26 | return this.pushSubscribed; 27 | } 28 | 29 | getUsername() { 30 | return this.username; 31 | } 32 | 33 | getMetaDB() { 34 | const cushionMeta = new PouchDB('cushionMeta'); 35 | return cushionMeta.get('cushionMeta'); 36 | } 37 | 38 | assignMetaVars() { 39 | return this.getMetaDB() 40 | 41 | .then(dbDoc => { 42 | this.remoteAddress = dbDoc.remoteDBAddress; 43 | this.pushSubscribed = dbDoc.subscribedToPush; 44 | this.username = dbDoc.username; 45 | return Promise.resolve(); 46 | }) 47 | 48 | .catch(_ => { 49 | this.remoteAddress = null; 50 | this.pushSubscribed = null; 51 | this.username = null; 52 | return Promise.resolve(); 53 | }); 54 | } 55 | 56 | start(remoteDBAddress, username) { 57 | return fetch( 58 | urls.isSubscribedToPush(username), 59 | getFetchOpts({ 60 | method: 'GET' 61 | }) 62 | ) 63 | 64 | .then(res => res.json()) 65 | .then(json => { 66 | 67 | const cushionDBDoc = utils.getDefaultMetaDBDoc( 68 | this.localDBName(), 69 | remoteDBAddress, 70 | username, 71 | json.subscribed 72 | ) 73 | 74 | metaDB = new PouchDB('cushionMeta'); 75 | 76 | return metaDB.put(cushionDBDoc); 77 | }) 78 | 79 | .catch(err => Promise.reject(err)); 80 | } 81 | 82 | subscribeToNotifications() { 83 | return this.getMetaDB() 84 | 85 | .then(userDoc => { 86 | const metaDB = new PouchDB('cushionMeta'); 87 | const newDoc = { 88 | ...userDoc, 89 | subscribedToPush: true, 90 | } 91 | 92 | return metaDB.put(newDoc); 93 | }); 94 | } 95 | 96 | destroy() { 97 | return new PouchDB('cushionMeta').destroy(); 98 | } 99 | } 100 | 101 | export default MetaDB; 102 | -------------------------------------------------------------------------------- /src/cushion_worker/cushionWorkerIndex.js: -------------------------------------------------------------------------------- 1 | import CushionWorker from './cushionWorker'; 2 | import path from 'path'; 3 | import { getFetchOpts } from '../utils/fetchUtils'; 4 | import { getConfigObj } from '../utils/configUtils'; 5 | import * as urls from '../utils/urls'; 6 | import MetaDB from '../metaDB'; 7 | import * as utils from '../utils/cushionWorkerUtils'; 8 | 9 | const configObj = getConfigObj(); 10 | const useIcons = configObj.appPushIcons; 11 | const ROOT_DIR = path.dirname(require.main.filename); 12 | 13 | global.cushionWorker = new CushionWorker(); 14 | 15 | cushionWorker.addPushEvent('SYNC', (event) => { 16 | const metaDB = new MetaDB(); 17 | const title = configObj.appname; 18 | const options = { 19 | body: "is updating in the background.", 20 | silent: true, 21 | renotify: false 22 | }; 23 | 24 | if (useIcons) { 25 | options = { 26 | ...options, 27 | icon: '.\icons\logo-icon.png', 28 | badge: '.\icons\logo-badge.png' 29 | } 30 | } 31 | 32 | return metaDB.ready.then(_ => { 33 | return Promise.all([ 34 | utils.pouchSync(metaDB.remoteDBName(), metaDB.localDBName()), 35 | self.registration.showNotification(title, options) 36 | ]); 37 | }); 38 | }); 39 | 40 | cushionWorker.addMessageEvent('SCHEDULE_PUSH', () => { 41 | self.registration.sync.register('REPLICATE_TO_SERVER'); 42 | return Promise.resolve(); 43 | }); 44 | 45 | cushionWorker.addMessageEvent('SCHEDULE_PULL', () => { 46 | self.registration.sync.register('REPLICATE_FROM_SERVER'); 47 | return Promise.resolve(); 48 | }); 49 | 50 | cushionWorker.addSyncEvent('REPLICATE_TO_SERVER', () => { 51 | const metaDB = new MetaDB(); 52 | let userDoc; 53 | 54 | return metaDB.ready.then(_ => { 55 | return utils.pouchSync(metaDB.localDBName(), metaDB.remoteDBName()); 56 | }) 57 | 58 | .then(() => { 59 | if (metaDB.subscribedToPush()) { 60 | 61 | const data = { 62 | username: metaDB.username, 63 | device: navigator.platform 64 | }; 65 | 66 | return fetch( 67 | urls.triggerUpdateDevices(), 68 | getFetchOpts({ 69 | method: 'POST', 70 | data 71 | }) 72 | ); 73 | } 74 | }) 75 | 76 | .catch(err => Promise.reject(err)); 77 | }); 78 | 79 | cushionWorker.addSyncEvent('REPLICATE_FROM_SERVER', () => { 80 | const metaDB = new MetaDB(); 81 | 82 | return metaDB.ready.then(_ => { 83 | if (!metaDB.remoteDBName()) return; 84 | 85 | return utils.pouchSync(metaDB.remoteDBName(), metaDB.localDBName()); 86 | }) 87 | 88 | .catch(err => Promise.reject(err)); 89 | }); 90 | -------------------------------------------------------------------------------- /tests/store.test.js: -------------------------------------------------------------------------------- 1 | import Cushion from '../src/cushion'; 2 | import PouchDB from 'pouchdb'; 3 | 4 | const store = new Cushion().store; 5 | store.localDB = new PouchDB('testDB'); 6 | store.deleteAll(); 7 | 8 | describe('starting an unregistered new cushion DB instance', () => { 9 | test('starts with default pouchDB if name not provided', () => { 10 | // expect(store.localDB.name).toBe('testDB'); 11 | }); 12 | 13 | test('starts with an empty listeners list', () => { 14 | expect(store.listeners.length).toEqual(0); 15 | }); 16 | }); 17 | 18 | describe('Store functionality', () => { 19 | const doc = { todo: 'Task 1', due: 'today', completed: false }; 20 | let docID; 21 | 22 | test('Get All docs - no document exist', () => { 23 | expect.assertions(1); 24 | return store.getAll().then( docs => { 25 | expect(docs.length).toEqual(0); 26 | }); 27 | }); 28 | 29 | test('Add document to cushionDB', () => { 30 | expect.assertions(1); 31 | return store.set(doc).then( id => { 32 | docID = id ; 33 | expect(id).toEqual(expect.anything()) 34 | }); 35 | }); 36 | 37 | test('Retrieve document from cushionDB', () => { 38 | expect.assertions(1); 39 | return store.get(docID).then( doc => { 40 | expect(doc).toHaveProperty('_id', docID ); 41 | }); 42 | }); 43 | 44 | test('Update document', () => { 45 | const attributes = { length: 3 }; 46 | expect.assertions(1); 47 | return store.update(docID, attributes).then( id => { 48 | expect(id).toEqual(docID) 49 | }); 50 | }); 51 | 52 | test('Update document - check new field added', () => { 53 | expect.assertions(1); 54 | return store.get(docID).then ( doc => { 55 | expect(doc).toHaveProperty('length', 3); 56 | }); 57 | }); 58 | 59 | test('Add second document to cushionDB', () => { 60 | const doc2 = { todo: 'Task 2', due: 'tomorrow', completed: false }; 61 | expect.assertions(1); 62 | return store.set(doc2).then( id => { 63 | return store.get(id).then(doc => 64 | expect(doc).toHaveProperty('todo', 'Task 2') 65 | ) 66 | }); 67 | }); 68 | 69 | test('Find a document by attribute', () => { 70 | // creates a index document within the local database 71 | expect.assertions(1); 72 | return store.find('todo', 'Task 2').then( docs => 73 | expect(docs[0]).toHaveProperty('todo', 'Task 2') 74 | ) 75 | }); 76 | 77 | test('Get All docs', () => { 78 | expect.assertions(1); 79 | return store.getAll().then( docs => { 80 | // 2 docs plus index document 81 | expect(docs.length).toEqual(3); 82 | }); 83 | }); 84 | 85 | test('Delete a document', () => { 86 | expect.assertions(1); 87 | return store.delete(docID).then(id => { 88 | expect(id).toEqual(docID) 89 | }); 90 | }); 91 | 92 | test('Destroy DB ', () => { 93 | expect.assertions(1) ; 94 | return store.destroy().then( res => { 95 | expect(res).toHaveProperty('ok', true); 96 | }); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /src/store.js: -------------------------------------------------------------------------------- 1 | import PouchDB from 'pouchdb'; 2 | import PouchDBFind from 'pouchdb-find'; 3 | PouchDB.plugin(PouchDBFind); 4 | 5 | import { scheduleSyncPush, scheduleSyncPull } from './utils/swUtils'; 6 | import urlB64ToUint8Array from './utils/64to8.js'; 7 | 8 | let listeners = []; 9 | let dbAuth; 10 | 11 | const notifyListeners = () => { 12 | listeners.forEach(l => l()); 13 | } 14 | 15 | class Store { 16 | constructor(dataAuth) { 17 | dbAuth = dataAuth; 18 | dbAuth.bindToLocalDBChange(notifyListeners); 19 | } 20 | 21 | subscribe(listener) { 22 | listeners = [ 23 | ...listeners, 24 | listener 25 | ]; 26 | 27 | return () => { 28 | listeners = listeners.filter(l => l !== listener); 29 | } 30 | } 31 | 32 | set(document) { 33 | return dbAuth.localDB.post(document) 34 | .then(doc => doc.id) 35 | .catch(err => Promise.reject(err)); 36 | } 37 | 38 | get(id) { 39 | return dbAuth.localDB.get(id) 40 | .then(doc => { 41 | const { _rev, ...docWithoutRev } = doc; 42 | 43 | return docWithoutRev; 44 | }) 45 | 46 | .catch(err => Promise.reject(err)); 47 | } 48 | 49 | getAll() { 50 | return dbAuth.localDB.allDocs({ 51 | 'include_docs': true, 52 | }) 53 | 54 | .then(docs => { 55 | return docs.rows.map(doc => { 56 | const { _rev, ...rest } = doc.doc; 57 | return rest; 58 | }); 59 | }) 60 | 61 | .catch(err => Promise.reject(err)); 62 | } 63 | 64 | update(id, attrs) { 65 | return dbAuth.localDB.get(id) 66 | .then(doc => { 67 | return dbAuth.localDB.put({ 68 | ...doc, 69 | ...attrs 70 | }); 71 | }) 72 | 73 | .then(doc => doc.id) 74 | .catch(err => Promise.reject(err)); 75 | } 76 | 77 | delete(id) { 78 | return dbAuth.localDB.get(id) 79 | .then(doc => { 80 | doc._deleted = true; 81 | 82 | return dbAuth.localDB.put(doc) 83 | }) 84 | 85 | .then(doc => doc.id) 86 | .catch(err => Promise.reject(err)); 87 | } 88 | 89 | deleteAll() { 90 | return dbAuth.localDB.allDocs({ 91 | include_docs: true 92 | }) 93 | 94 | .then(docs => { 95 | const deletedDocs = docs.rows.map(row => { 96 | return { 97 | _id: row.doc._id, 98 | _rev: row.doc._rev, 99 | _deleted: true 100 | } 101 | }); 102 | 103 | return dbAuth.localDB.bulkDocs(deletedDocs); 104 | }) 105 | 106 | .catch(err => Promise.reject(err)); 107 | } 108 | 109 | find(attribute, value) { 110 | return dbAuth.localDB.createIndex({ 111 | index: { 112 | fields: [attribute] 113 | } 114 | }) 115 | 116 | .then(res => { 117 | return dbAuth.localDB.find({ 118 | selector: { 119 | [attribute]: value 120 | } 121 | }); 122 | }) 123 | 124 | .then(r => r.docs) 125 | .catch(err => Promise.reject(err)); 126 | } 127 | 128 | destroy() { 129 | return dbAuth.destroyLocal() 130 | .then(_ => { 131 | return { status: 'success' }; 132 | }) 133 | 134 | .catch(err => Promise.reject(err)); 135 | } 136 | } 137 | 138 | export default Store; 139 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | # Overview 4 | 5 | CushionDB is an open source, easy-to-use data management framework for building small, offline-first, PWA compliant applications. It simplifies the process of managing and persisting single-user data without writing any server-side database code and provides user authentication capabilities that are crucial for efficient client-side data management. 6 | 7 | CushionDB expands on current offline-first data models by employing different PWA tools that ensure data integrity regardless of network connectivity, and abstracts many of the complexities around utilizing these tools for native-like client side data management. 8 | 9 | CushionClient is designed to work with [CushionSever](https://github.com/CushionDB/CushionServer) and a pre-configured CouchDB we call [CushionCouch](https://github.com/CushionDB/CushionCouchDocker). Both CushionServer and CushionCouch are setup as Docker images and can easily be built, configured and run using a simple script provided by CushionDB. 10 | 11 | ## CushionDB Architecture 12 | 13 |

14 | 15 | # Getting Started 16 | 17 | ## Prerequisites 18 | 19 | * Node.js >= 10.16.3 - *CushionClient* 20 | * Docker - *CushionServer and CushionCouch* 21 | 22 | ## Install 23 | 24 | ``` 25 | npm i cushiondb-client 26 | mv node_modules/cushiondb-client/sw.js . 27 | ``` 28 | 29 | ## Setup 30 | 31 | CushionClient is an npm package. It can either be added to the projects node modules by running `npm i cushion-client` from within your project's root directory, or `cushiondb-client` can simply be added as a dependency inside your project's `package.json` file. 32 | 33 | Once CushionClient has been added as a dependency, changes can be made to the `.couchConfig.json` file within the `node_modules/cushiondb-client` directory. This is only necessary if the default server configurations are changed while setting up the CushionDB backend. 34 | 35 |

36 | 37 | The URLs in this config file will be used for networking with the two backend containers and the Public VAPID key is needed for PWA Push Notifications to work. 38 | 39 | Finally, a Service Worker file (`sw.js`) is needed in the project's root directory. This file comes packaged with the `cushion-client` node module and can be moved to your current project's root directory by running `mv node_modules/cushion-client/sw.js .` from the command line. 40 | 41 |

42 | 43 | ## Sample Usage 44 | 45 | ```js 46 | import Cushion from 'cushiondb-client'; 47 | // or 48 | const Cushion = require('cushiondb-client'); 49 | ``` 50 | 51 | ```js 52 | // instantiate a new CushionDB object 53 | const cushion = new Cushion(); 54 | 55 | // sign up a new user 56 | cushion.account.signUp({ 57 | username: 'jDoe', 58 | password: 'secret' 59 | }); 60 | 61 | // add a document to the database 62 | cushion.store.set({ 63 | title: 'todo', 64 | completed: false 65 | }).then(id => { 66 | // do something 67 | }).catch(err => { 68 | // handle error 69 | }); 70 | ``` 71 | 72 | # The Team 73 | 74 | [Avshar Kirksall](https://avshrk.github.io/ 75 | ) *Software Engineer* Brooklyn, NY 76 | 77 | [Jaron Truman](https://jtruman88.github.io/ 78 | ) *Software Engineer* Las Vegas, NV 79 | 80 | [Daniel Rote](https://drote.github.io) *Software Engineer* Seattle, WA 81 | -------------------------------------------------------------------------------- /src/databaseAuth.js: -------------------------------------------------------------------------------- 1 | import PouchDB from 'pouchdb'; 2 | import PouchAuth from 'pouchdb-authentication'; 3 | PouchDB.plugin(PouchAuth); 4 | 5 | import MetaDB from './metaDB'; 6 | import { scheduleSyncPush, scheduleSyncPull } from './utils/swUtils'; 7 | 8 | class DatabaseAuth { 9 | constructor(couchBaseURL) { 10 | this.metaDB = new MetaDB(); 11 | this.couchBaseURL = couchBaseURL; 12 | this.ready = this.metaDB.ready; 13 | 14 | this.metaDB.ready 15 | 16 | .then(() => { 17 | this.localDB = new PouchDB(this.metaDB.localDBName()); 18 | 19 | if (this.metaDB.remoteDBName()) { 20 | this.remoteDB = new PouchDB(this.metaDB.remoteDBName()); 21 | scheduleSyncPull(); 22 | } 23 | 24 | this.bindToLocalDBChange(() => { 25 | this.isSignedIn().then(res => { 26 | if (res) { 27 | scheduleSyncPush(); 28 | } 29 | }) 30 | }); 31 | }) 32 | 33 | .catch(err => Promise.reject(err)); 34 | } 35 | 36 | bindToLocalDBChange(callback) { 37 | this.localDB.changes({ 38 | live: true, 39 | since: 'now' 40 | }).on('change', callback); 41 | } 42 | 43 | isSignedIn() { 44 | if (!this.remoteDB) return Promise.resolve(false); 45 | 46 | return this.getSession(this.remoteDB) 47 | 48 | .then(res => !!res) 49 | .catch(err => Promise.reject(err)); 50 | } 51 | 52 | getSession(remoteDB) { 53 | return this.remoteDB.getSession() 54 | 55 | .then(res => { 56 | if (!res.userCtx.name) return null; 57 | 58 | return res; 59 | }) 60 | } 61 | 62 | getPassword(remoteDB) { 63 | if (!this.remoteDB) return undefined; 64 | 65 | return this.remoteDB.__opts.auth.password; 66 | } 67 | 68 | signIn(username, password) { 69 | const couchUserDBName = DatabaseAuth.createCouchUserDBName(this.couchBaseURL, username); 70 | const fakeRemoteDB = this.createRemoteCouchDBHandle(couchUserDBName, username, password); 71 | 72 | return fakeRemoteDB.logIn(username, password) 73 | 74 | .then(res => this.metaDB.start(couchUserDBName, username)) 75 | .then(res => { 76 | this.remoteDB = fakeRemoteDB; 77 | scheduleSyncPull(); 78 | scheduleSyncPush(); 79 | return true; 80 | }) 81 | 82 | .catch(err => { 83 | if (err.name === 'unauthorized' || err.name === 'forbidden') { 84 | return Promise.reject('Username or password incorrect'); 85 | } 86 | 87 | return Promise.reject(err); 88 | }); 89 | } 90 | 91 | signOut(options = {}) { 92 | if (!this.remoteDB) return Promise.reject('No remote database to sign out with'); 93 | 94 | return this.remoteDB.logOut() 95 | 96 | .then(res => { 97 | if (!res.ok) return Promise.reject('Sign out failed'); 98 | 99 | if (options.destroyLocal) { 100 | return this.destroyLocal() 101 | .then(() => { 102 | this.localDB = new PouchDB(this.metaDB.localDBName()); 103 | this.remoteDB = null; 104 | return this.metaDB.destroy(); 105 | }); 106 | } 107 | 108 | this.remoteDB = null; 109 | return this.metaDB.destroy(); 110 | }) 111 | 112 | .catch(err => Promise.reject(err)); 113 | } 114 | 115 | destroyLocal() { 116 | return this.localDB.destroy() 117 | .then(res => res) 118 | .catch(err => Promise.reject(err)); 119 | } 120 | 121 | createRemoteCouchDBHandle(remoteName, username, password) { 122 | return new PouchDB( 123 | remoteName, 124 | { 125 | skip_setup: true, 126 | auth: { 127 | username, 128 | password 129 | } 130 | } 131 | ); 132 | } 133 | 134 | changePassword(username, newPassword) { 135 | return this.signOut() 136 | 137 | .then(_ => this.signIn(username, newPassword)); 138 | } 139 | 140 | destroyUser(username) { 141 | return this.remoteDB.deleteUser(username) 142 | 143 | .then(res => { 144 | this.remoteDB = null; 145 | return this.localDB.destroy(); 146 | }) 147 | 148 | .then(res => { 149 | return this.metaDB.destroy(); 150 | }) 151 | 152 | .catch(err => Promise.reject(err)); 153 | } 154 | 155 | getUsername() { 156 | if (!this.remoteDB) return undefined; 157 | 158 | return this.metaDB.getUsername(); 159 | } 160 | 161 | subscribeToNotifications() { 162 | this.metaDB.subscribeToNotifications(); 163 | } 164 | 165 | getUserDoc(username) { 166 | return this.remoteDB.getUser(username); 167 | } 168 | 169 | static createCouchUserDBName(couchBaseURL, username) { 170 | const hexUsername = Buffer.from(username, 'utf8').toString('hex'); 171 | 172 | return `${couchBaseURL}/cushion_${hexUsername}`; 173 | } 174 | } 175 | 176 | export default DatabaseAuth; 177 | -------------------------------------------------------------------------------- /src/account.js: -------------------------------------------------------------------------------- 1 | import PouchDB from 'pouchdb'; 2 | import PouchAuth from 'pouchdb-authentication'; 3 | PouchDB.plugin(PouchAuth); 4 | 5 | import * as urls from './utils/urls'; 6 | import * as fetchUtils from './utils/fetchUtils'; 7 | import * as swUtils from './utils/swUtils'; 8 | 9 | let dbAuth; 10 | 11 | class Account { 12 | constructor(dataAuth) { 13 | dbAuth = dataAuth; 14 | } 15 | 16 | isSignedIn() { 17 | return dbAuth.isSignedIn(); 18 | } 19 | 20 | signUp({ username, password }) { 21 | if (!username || !password) { 22 | throw new Error('username and password are required.'); 23 | } 24 | 25 | let errMessage; 26 | 27 | return fetch( 28 | urls.signup(), 29 | fetchUtils.getFetchOpts({ 30 | method: 'POST', 31 | data: { 32 | username, 33 | password 34 | } 35 | }) 36 | ) 37 | 38 | .then(response => { 39 | if (response.ok) { 40 | return { status: 'success' }; 41 | } 42 | 43 | switch (response.status) { 44 | case 401: 45 | errMessage = 'CouchDB admin or password incorrect'; 46 | break; 47 | case 409: 48 | errMessage = 'Username is taken'; 49 | break; 50 | default: 51 | errMessage = 'Something went wrong'; 52 | } 53 | 54 | return Promise.reject(errMessage) 55 | }) 56 | 57 | .catch(err => Promise.reject(err)); 58 | } 59 | 60 | signIn({ username, password }) { 61 | if (!username || !password) { 62 | return Promise.reject('username and password are required.'); 63 | } 64 | 65 | return dbAuth.signIn(username, password) 66 | 67 | .then(res => { 68 | return { status: 'success' } 69 | }) 70 | 71 | .catch(err => Promise.reject(err)); 72 | } 73 | 74 | signOut(options) { 75 | return dbAuth.signOut(options) 76 | .then(res => { 77 | return { status: 'success' } 78 | }) 79 | .catch(err => Promise.reject(err)); 80 | } 81 | 82 | getUserDoc(username) { 83 | return this.isSignedIn() 84 | 85 | .then(res => { 86 | if (!res) return Promise.reject('User is not signed in'); 87 | 88 | return dbAuth.remoteDB.getUserDoc(username) 89 | }) 90 | 91 | .catch(err => Promise.reject(err)); 92 | } 93 | 94 | changePassword(username, newPassword) { 95 | return fetch( 96 | urls.changePassword(), 97 | fetchUtils.getFetchOpts({ 98 | method: 'POST', 99 | data: { 100 | name: username, 101 | roles: [], 102 | type: 'user', 103 | password: newPassword 104 | } 105 | }) 106 | ) 107 | 108 | .then(res => res.json()) 109 | .then(json => { 110 | if (json.ok) { 111 | dbAuth.changePassword(username, newPassword); 112 | } 113 | }) 114 | 115 | .catch(err => Promise.reject(err)); 116 | } 117 | 118 | destroy(username) { 119 | return this.isSignedIn() 120 | 121 | .then(res => { 122 | if (!res) return Promise.reject('User is not signed in'); 123 | 124 | return dbAuth.destroyUser(username); 125 | }) 126 | 127 | .then(res => { 128 | if (res.ok) { 129 | return { status: 'success' }; 130 | } 131 | }) 132 | 133 | .catch(err => Promise.reject(err)); 134 | } 135 | 136 | // changeUsername({curUsername, password, newUsername}){ 137 | // let url = `${TEMP_CONFIG.remoteBaseURL}updateUser`; 138 | // let options = { 139 | // method: 'PUT', 140 | // data: { 141 | // name: curUsername, 142 | // password: password, 143 | // newName: newUsername 144 | // }, 145 | // headers: { 146 | // "Content-Type": "application/json", 147 | // "Accept": "application/json" 148 | // }, 149 | // }; 150 | // request(url, options); 151 | // } 152 | 153 | subscribeToNotifications() { 154 | let subscription; 155 | 156 | return this.isSignedIn() 157 | 158 | .then(res => { 159 | if (!res) return Promise.reject('User is not signed in'); 160 | 161 | return swUtils.subscribeDeviceToNotifications(); 162 | }) 163 | 164 | .then(sub => { 165 | subscription = sub; 166 | return dbAuth.getUsername(); 167 | }) 168 | 169 | .then(username => { 170 | return fetch( 171 | urls.subscribeDeviceToPush(), 172 | fetchUtils.getFetchOpts({ 173 | method: 'POST', 174 | data: { 175 | username, 176 | subscription, 177 | device: navigator.platform 178 | } 179 | }) 180 | ); 181 | }) 182 | 183 | .then(_ => { 184 | dbAuth.subscribeToNotifications(); 185 | return { status: 'success' } 186 | }) 187 | 188 | .catch(err => Promise.reject(err)); 189 | } 190 | } 191 | 192 | export default Account; 193 | --------------------------------------------------------------------------------