insomnia/app/common/database.js
Gregory Schier 8efd8fc416 Add Sync Tests (#74)
* Started sync tests

* Simplified logic a bit and better tests

* Some more tests

* More tests yet
2017-01-09 13:59:52 -08:00

408 lines
9.5 KiB
JavaScript

import electron from 'electron';
import NeDB from 'nedb';
import fs from 'fs';
import fsPath from 'path';
import {DB_PERSIST_INTERVAL} from './constants';
import {generateId} from './misc';
import {getModel, initModel} from '../models';
import * as models from '../models/index';
import AlertModal from '../ui/components/modals/AlertModal';
import {showModal} from '../ui/components/modals/index';
import {trackEvent} from '../analytics/index';
export const CHANGE_INSERT = 'insert';
export const CHANGE_UPDATE = 'update';
export const CHANGE_REMOVE = 'remove';
let db = {};
// ~~~~~~~ //
// HELPERS //
// ~~~~~~~ //
function allTypes () {
return Object.keys(db);
}
function getDBFilePath (modelType) {
// NOTE: Do not EVER change this. EVER!
const basePath = electron.remote.app.getPath('userData');
return fsPath.join(basePath, `insomnia.${modelType}.db`);
}
/**
* Initialize the database. Note that this isn't actually async, but might be
* in the future!
*
* @param types
* @param config
* @param forceReset
* @returns {null}
*/
export function init (types, config = {}, forceReset = false) {
if (forceReset) {
changeListeners = [];
db = {};
}
// Fill in the defaults
for (const modelType of types) {
if (db[modelType]) {
console.warn(`[db] Already initialized DB.${modelType}`);
continue;
}
const filePath = getDBFilePath(modelType);
// Check to make sure the responses DB file isn't too big to parse. If it is, we
// should delete it
try {
const MBs = fs.statSync(filePath).size / 1024 / 1024;
if (modelType === models.response.type && MBs > 256) {
// NOTE: Node.js can't have a string longer than 256MB. Since the response DB can reach
// sizes that big, let's not even load it if it's bigger than that. Just start over.
console.warn(`[db] Response DB too big (${MBs}). Deleting...`);
fs.unlinkSync(filePath);
// Can't show alert until the app renders, so delay for a bit first
setTimeout(() => {
showModal(AlertModal, {
title: 'Response DB Too Large',
message: 'Your combined responses have exceeded 256MB and have been flushed. ' +
'NOTE: A better solution to this will be implemented in a future release.'
});
trackEvent('Alert', 'DB Too Large');
}, 1000);
}
} catch (err) {
// File probably did not exist probably, so no big deal
}
db[modelType] = new NeDB(Object.assign({
autoload: true,
filename: filePath,
}, config));
db[modelType].persistence.setAutocompactionInterval(DB_PERSIST_INTERVAL);
}
console.log(`[db] Initialized DB at ${getDBFilePath('$TYPE')}`);
}
// ~~~~~~~~~~~~~~~~ //
// Change Listeners //
// ~~~~~~~~~~~~~~~~ //
let bufferingChanges = false;
let changeBuffer = [];
let changeListeners = [];
export function onChange (callback) {
changeListeners.push(callback);
}
export function offChange (callback) {
changeListeners = changeListeners.filter(l => l !== callback);
}
export function bufferChanges (millis = 1000) {
bufferingChanges = true;
setTimeout(flushChanges, millis);
}
export async function flushChanges () {
bufferingChanges = false;
const changes = [...changeBuffer];
changeBuffer = [];
if (changes.length === 0) {
// No work to do
return;
}
for (const fn of changeListeners) {
await fn(changes);
}
}
async function notifyOfChange (event, doc, fromSync) {
changeBuffer.push([event, doc, fromSync]);
// Flush right away if we're not buffering
if (!bufferingChanges) {
await flushChanges();
}
}
// ~~~~~~~ //
// Helpers //
// ~~~~~~~ //
export async function getMostRecentlyModified (type, query = {}) {
const docs = await findMostRecentlyModified(type, query, 1);
return docs.length ? docs[0] : null;
}
export function findMostRecentlyModified (type, query = {}, limit = null) {
return new Promise(resolve => {
db[type].find(query).sort({modified: -1}).limit(limit).exec((err, docs) => {
resolve(docs);
})
})
}
export function find (type, query = {}) {
return new Promise((resolve, reject) => {
db[type].find(query, (err, rawDocs) => {
if (err) {
return reject(err);
}
const docs = rawDocs.map(rawDoc => {
return initModel(type, rawDoc);
});
resolve(docs);
});
});
}
export function all (type) {
return find(type);
}
export function getWhere (type, query) {
return new Promise((resolve, reject) => {
db[type].find(query, (err, rawDocs) => {
if (err) {
return reject(err);
}
if (rawDocs.length === 0) {
// Not found. Too bad!
return resolve(null);
}
resolve(initModel(type, rawDocs[0]));
})
})
}
export function get (type, id) {
return getWhere(type, {_id: id});
}
export function count (type, query = {}) {
return new Promise((resolve, reject) => {
db[type].count(query, (err, count) => {
if (err) {
return reject(err);
}
resolve(count);
});
});
}
export async function upsert (doc, fromSync = false) {
const existingDoc = await get(doc.type, doc._id);
if (existingDoc) {
return update(doc, fromSync);
} else {
return insert(doc, fromSync);
}
}
export function insert (doc, fromSync = false) {
return new Promise((resolve, reject) => {
const docWithDefaults = initModel(doc.type, doc);
db[doc.type].insert(docWithDefaults, (err, newDoc) => {
if (err) {
return reject(err);
}
notifyOfChange(CHANGE_INSERT, newDoc, fromSync);
resolve(newDoc);
});
});
}
export function update (doc, fromSync = false) {
return new Promise((resolve, reject) => {
const docWithDefaults = initModel(doc.type, doc);
db[doc.type].update({_id: docWithDefaults._id}, docWithDefaults, err => {
if (err) {
return reject(err);
}
notifyOfChange(CHANGE_UPDATE, docWithDefaults, fromSync);
resolve(docWithDefaults);
});
});
}
export async function remove (doc, fromSync = false) {
bufferChanges();
const docs = await withDescendants(doc);
const docIds = docs.map(d => d._id);
const types = [...new Set(docs.map(d => d.type))];
// Don't really need to wait for this to be over;
types.map(t => db[t].remove({_id: {$in: docIds}}, {multi: true}));
docs.map(d => notifyOfChange(CHANGE_REMOVE, d, fromSync));
flushChanges();
}
/**
* Remove a lot of documents quickly and silently
*
* @param type
* @param query
* @returns {Promise.<T>}
*/
export function removeBulkSilently (type, query) {
return new Promise(resolve => {
db[type].remove(query, {multi: true}, err => resolve());
});
}
// ~~~~~~~~~~~~~~~~~~~ //
// DEFAULT MODEL STUFF //
// ~~~~~~~~~~~~~~~~~~~ //
export function docUpdate (originalDoc, patch = {}) {
const doc = initModel(
originalDoc.type,
originalDoc,
patch,
{modified: Date.now()},
);
return update(doc);
}
export function docCreate (type, patch = {}) {
const idPrefix = getModel(type).prefix;
if (!idPrefix) {
throw new Error(`No ID prefix for ${type}`)
}
const doc = initModel(
type,
patch,
// Fields that the user can't touch
{
type: type,
modified: Date.now(),
}
);
// NOTE: This CAN'T be inside initModel() because initModel checks
// for _id existence to do migrations and stuff
if (!doc._id) {
doc._id = generateId(idPrefix);
}
return insert(doc);
}
// ~~~~~~~ //
// GENERAL //
// ~~~~~~~ //
export async function withDescendants (doc = null) {
let docsToReturn = doc ? [doc] : [];
async function next (docs) {
let foundDocs = [];
for (const d of docs) {
for (const type of allTypes()) {
// If the doc is null, we want to search for parentId === null
const parentId = d ? d._id : null;
const more = await find(type, {parentId});
foundDocs = [...foundDocs, ...more]
}
}
if (foundDocs.length === 0) {
// Didn't find anything. We're done
return docsToReturn;
}
// Continue searching for children
docsToReturn = [...docsToReturn, ...foundDocs];
return await next(foundDocs);
}
return await next([doc]);
}
export async function withAncestors (doc) {
let docsToReturn = doc ? [doc] : [];
async function next (docs) {
let foundDocs = [];
for (const d of docs) {
for (const type of allTypes()) {
// If the doc is null, we want to search for parentId === null
const more = await find(type, {_id: d.parentId});
foundDocs = [...foundDocs, ...more]
}
}
if (foundDocs.length === 0) {
// Didn't find anything. We're done
return docsToReturn;
}
// Continue searching for children
docsToReturn = [...docsToReturn, ...foundDocs];
return await next(foundDocs);
}
return await next([doc]);
}
export async function duplicate (originalDoc, patch = {}, first = true) {
bufferChanges();
// 1. Copy the doc
const newDoc = Object.assign({}, originalDoc, patch);
delete newDoc._id;
delete newDoc.created;
delete newDoc.modified;
const createdDoc = await docCreate(newDoc.type, newDoc);
// 2. Get all the children
for (const type of allTypes()) {
// Note: We never want to duplicate a response
if (type === models.response.type) {
continue;
}
const parentId = originalDoc._id;
const children = await find(type, {parentId});
for (const doc of children) {
await duplicate(doc, {parentId: createdDoc._id}, false)
}
}
if (first) {
flushChanges();
}
return createdDoc;
}