diff --git a/apps/server/src/common/db/apps.js b/apps/server/src/common/db/apps.js deleted file mode 100644 index 913f2853d..000000000 --- a/apps/server/src/common/db/apps.js +++ /dev/null @@ -1,9 +0,0 @@ -import { config } from '../../config/app-config'; -import { Database } from '../database.service'; -import { logger } from '../logging'; - -logger.info(`Starting apps DB at ${config.apps.dbPath}`); -const db = new Database({ filename: config.apps.dbPath, autoload: true }, [ - { fieldName: 'name', unique: true }, -]); -export { db as apps }; diff --git a/apps/server/src/common/db/collections-registry.ts b/apps/server/src/common/db/collections-registry.ts new file mode 100644 index 000000000..3b5a6cb09 --- /dev/null +++ b/apps/server/src/common/db/collections-registry.ts @@ -0,0 +1,6 @@ +import { Collection } from 'lokijs'; +import { App } from '@flogo-web/core'; + +export const collections: { + apps?: Collection; +} = {}; diff --git a/apps/server/src/common/db/db.spec.ts b/apps/server/src/common/db/db.spec.ts new file mode 100644 index 000000000..a0892727d --- /dev/null +++ b/apps/server/src/common/db/db.spec.ts @@ -0,0 +1,48 @@ +import { initDb, persistedDb } from './db'; + +let collection: Collection; + +beforeEach(async () => { + await initDb({ persist: false }); + collection = persistedDb.addCollection('test'); +}); + +describe('db library integration', () => { + it('should save object with dots in its keys', function() { + const inserted = collection.insert({ + 'my.object': { + 'is.nested': { + foo: [ + { + 'bar.baz': true, + }, + ], + }, + }, + }); + + expect(inserted).toMatchObject({ + 'my.object': { + 'is.nested': { + foo: [ + { + 'bar.baz': true, + }, + ], + }, + }, + }); + + expect(collection.get(inserted.$loki)).toMatchObject({ + 'my.object': { + 'is.nested': { + foo: [ + { + 'bar.baz': true, + }, + ], + }, + }, + }); + }); +}); diff --git a/apps/server/src/common/db/db.ts b/apps/server/src/common/db/db.ts new file mode 100644 index 000000000..da2fc8060 --- /dev/null +++ b/apps/server/src/common/db/db.ts @@ -0,0 +1,57 @@ +import Loki from 'lokijs'; + +import { App } from '@flogo-web/core'; +import { config } from '../../config'; +import { collections } from './collections-registry'; + +const dbPath = config.apps.dbPath; + +export let persistedDb: Loki; +// todo: use by non-persistent collections like contributions +const memoryDb = new Loki('mem.db', { adapter: new Loki.LokiMemoryAdapter() }); + +export function initDb({ persist = true, autosave = true } = {}) { + return new Promise(resolve => { + persistedDb = new Loki(dbPath, { + adapter: persist ? new Loki.LokiFsAdapter() : new Loki.LokiMemoryAdapter(), + autoload: true, + autosave, + autoloadCallback: afterInitDb(resolve), + autosaveInterval: 4000, + }); + }); +} + +export function flushAndCloseDb() { + if (persistedDb) { + return new Promise((resolve, reject) => { + persistedDb.save(err => { + if (err) { + return reject(err); + } + + persistedDb.close(err2 => { + if (err2) { + return reject(err2); + } + resolve(); + }); + }); + }); + } +} + +function afterInitDb(signalReadyFn: Function) { + return () => { + let apps = persistedDb.getCollection('apps'); + if (apps == null) { + apps = persistedDb.addCollection('apps', { + unique: ['id'], + indices: ['name'], + clone: true, + }); + } + collections.apps = apps; + signalReadyFn(); + }; +} diff --git a/apps/server/src/common/db/index.ts b/apps/server/src/common/db/index.ts index 9e7debdf5..a6974024f 100644 --- a/apps/server/src/common/db/index.ts +++ b/apps/server/src/common/db/index.ts @@ -1,3 +1,4 @@ -export { apps } from './apps'; -export { indexer } from './indexer'; export { contributionsDBService } from './contributions'; + +export { initDb, flushAndCloseDb } from './db'; +export { collections } from './collections-registry'; diff --git a/apps/server/src/config/app-config.js b/apps/server/src/config/app-config.js index 2b9a54b98..edba365fc 100644 --- a/apps/server/src/config/app-config.js +++ b/apps/server/src/config/app-config.js @@ -21,8 +21,6 @@ const FLOW_TESTER_PORT = process.env.FLOGO_FLOW_TESTER_PORT || '8080'; const DB_DIR = process.env.FLOGO_WEB_DBDIR || path.resolve(LOCAL_DIR, 'db'); -console.log(`localDir=${LOCAL_DIR},publicDir=${PUBLIC_DIR}`); - const logLevel = process.env.FLOGO_WEB_LOGLEVEL || 'debug'; const appPort = process.env.PORT || 3303; @@ -69,7 +67,7 @@ const config = { /* apps module config */ // TODO: consolidate and cleanup apps: { - dbPath: path.resolve(DB_DIR, 'apps.db'), + dbPath: path.resolve(DB_DIR, 'flogo.db'), }, indexer: { dbPath: path.resolve(DB_DIR, 'indexer.db'), diff --git a/apps/server/src/configure-engines.ts b/apps/server/src/configure-engines.ts index fbe68a41d..e0b3f0a39 100644 --- a/apps/server/src/configure-engines.ts +++ b/apps/server/src/configure-engines.ts @@ -1,22 +1,18 @@ import { rootContainer, installDefaults } from './init'; -import { config } from './config/app-config'; -import { TOKENS } from './core'; -import { Database } from './common/database.service'; +import { config } from './config'; import { getInitializedEngine } from './modules/engine'; import { syncTasks } from './modules/contrib-install-controller/sync-tasks'; import { AppsService } from './modules/apps'; +import { initDb, flushAndCloseDb } from './common/db'; -getInitializedEngine(config.defaultEngine.path, { forceCreate: false }) +initDb() + .then(() => getInitializedEngine(config.defaultEngine.path, { forceCreate: false })) .then(engine => syncTasks(engine)) .then(() => { console.log('[log] init test engine done'); return installDefaults(rootContainer.resolve(AppsService)); }) - .then(() => { - const apps = rootContainer.get(TOKENS.AppsDb); - const indexer = rootContainer.get(TOKENS.ResourceIndexerDb); - return Promise.all([apps.compact(), indexer.compact()]); - }) + .then(() => flushAndCloseDb()) .catch(error => { console.error(error); console.error(error.stack); diff --git a/apps/server/src/core/models/app.ts b/apps/server/src/core/models/app.ts index 38e2402d0..a6c38d3a3 100644 --- a/apps/server/src/core/models/app.ts +++ b/apps/server/src/core/models/app.ts @@ -5,7 +5,7 @@ export function constructApp(inputData, generateId?: () => string): App { const now = new Date().toISOString(); return { ...inputData, - _id: inputData._id || generateId(), + id: inputData.id || generateId(), name: inputData.name.trim(), createdAt: now, updatedAt: null, diff --git a/apps/server/src/injector/persistence/module.ts b/apps/server/src/injector/persistence/module.ts index 75711867d..586964c43 100644 --- a/apps/server/src/injector/persistence/module.ts +++ b/apps/server/src/injector/persistence/module.ts @@ -1,8 +1,9 @@ import { ContainerModule, interfaces } from 'inversify'; -import { apps, indexer } from '../../common/db'; +import { collections } from '../../common/db'; +import { indexer } from '../../common/db/indexer'; import { TOKENS } from '../../core'; export const PersistenceModule = new ContainerModule((bind: interfaces.Bind) => { - bind(TOKENS.AppsDb).toConstantValue(apps); + bind(TOKENS.AppsDb).toDynamicValue(() => collections.apps); bind(TOKENS.ResourceIndexerDb).toConstantValue(indexer); }); diff --git a/apps/server/src/modules/apps/app-importer.ts b/apps/server/src/modules/apps/app-importer.ts index be41b0191..c6d835ace 100644 --- a/apps/server/src/modules/apps/app-importer.ts +++ b/apps/server/src/modules/apps/app-importer.ts @@ -31,6 +31,6 @@ export class AppImporter { shortid.generate, contributions ); - return { _id: id, ...newApp }; + return { id, ...newApp }; } } diff --git a/apps/server/src/modules/apps/apps-service.ts b/apps/server/src/modules/apps/apps-service.ts index 3c9f6465a..75a85dc0b 100644 --- a/apps/server/src/modules/apps/apps-service.ts +++ b/apps/server/src/modules/apps/apps-service.ts @@ -1,5 +1,6 @@ import { inject, injectable } from 'inversify'; -import { escapeRegExp, fromPairs, isEqual, pick } from 'lodash'; +import { escapeRegExp, pick } from 'lodash'; +import { Collection } from 'lokijs'; import shortid from 'shortid'; import { App, CONTRIB_REFS } from '@flogo-web/core'; @@ -10,7 +11,6 @@ import { normalizeName } from '../transfer/export/utils/normalize-name'; import { AppImporter } from './app-importer'; import { AppExporter, ExportAppOptions } from './app-exporter'; -import { Database } from '../../common/database.service'; import { ErrorManager, ERROR_TYPES as GENERAL_ERROR_TYPES } from '../../common/errors'; import { Logger } from '../../common/logging'; import { CONSTRAINTS } from '../../common/validation'; @@ -41,7 +41,7 @@ const PUBLISH_FIELDS: Array = [ @injectable() export class AppsService { constructor( - @inject(TOKENS.AppsDb) private appsDb: Database, + @inject(TOKENS.AppsDb) private appsDb: Collection, @inject(TOKENS.Logger) private logger: Logger, private resourceService: ResourceService, private triggersService: AppTriggersService, @@ -66,7 +66,7 @@ export class AppsService { async importApp(app) { const appToSave = await this.appImporter.import(app); const savedApp = await saveNew(appToSave, this.appsDb); - return this.findOne(savedApp._id); + return this.findOne(savedApp.id); } /** @@ -74,50 +74,28 @@ export class AppsService { * @param appId if not provided will try to use app.id * @param newData {object} */ - update(appId, newData) { + async update(appId, newData) { const inputData = cleanInput(newData); - return ( - this.appsDb - // fetch editable fields only - .findOne( - { _id: appId }, - fromPairs(EDITABLE_FIELDS.concat('_id').map(field => [field, 1])) - ) - .then(app => { - if (!app) { - throw ErrorManager.makeError('App not found', { - type: GENERAL_ERROR_TYPES.COMMON.NOT_FOUND, - }); - } + const storedApp = this.appsDb.by('id', appId); - const mergedData = Object.assign({}, app, inputData); - if (isEqual(mergedData, app)) { - // no changes, we don't need to update anything - return false; - } + if (!storedApp) { + throw ErrorManager.makeError('App not found', { + type: GENERAL_ERROR_TYPES.COMMON.NOT_FOUND, + }); + } + + const appToValidate = { ...storedApp, ...inputData }; + const errors = Validator.validateSimpleApp(appToValidate); + if (errors) { + throw ErrorManager.createValidationError('Validation error', errors); + } + if (inputData.name) { + appToValidate.name = appToValidate.name.trim(); + throwIfNameNotUnique(appToValidate.name, appId, this.appsDb); + } + this.appsDb.update({ ...storedApp, ...appToValidate }); - const errors = Validator.validateSimpleApp(mergedData); - if (errors) { - throw ErrorManager.createValidationError('Validation error', errors); - } - if (inputData.name) { - inputData.name = inputData.name.trim(); - return validateUniqueName(inputData.name, appId, this.appsDb); - } - return true; - }) - .then(shouldUpdate => { - if (shouldUpdate) { - delete inputData._id; - return this.appsDb.update( - { _id: appId }, - { $set: Object.assign({ updatedAt: nowISO() }, inputData) } - ); - } - return null; - }) - .then(() => this.findOne(appId, { withFlows: true })) - ); + return this.findOne(appId, { withFlows: true }); } /** @@ -126,12 +104,12 @@ export class AppsService { * @param appId {string} appId */ async remove(appId) { - const numRemoved = await this.appsDb.remove({ _id: appId }); - const wasRemoved = numRemoved > 0; - if (wasRemoved) { + const appToRemove = await this.appsDb.by('id', appId); + if (appToRemove) { + this.appsDb.remove(appToRemove); await this.resourceService.removeFromRecentByAppId(appId); } - return wasRemoved; + return !!appToRemove; } /** @@ -152,13 +130,18 @@ export class AppsService { * @param options * @param options.withFlows: retrieveFlows */ - find(terms: { name?: string | RegExp } = {}, { withFlows } = { withFlows: false }) { + async find( + terms: { name?: string | RegExp } = {}, + { withFlows } = { withFlows: false } + ) { + const queryOptions: { [prop: string]: any } = {}; if (terms.name) { const name = getAppNameForSearch(terms.name); - terms.name = new RegExp(`^${name}$`, 'i'); + queryOptions.name = { $regex: [`^${name}$`, 'i'] }; } - return this.appsDb.find(terms).then(apps => apps.map(cleanForOutput)); + const result = this.appsDb.find(queryOptions); + return result.map(cleanForOutput); } /** @@ -174,10 +157,12 @@ export class AppsService { * @param options * @param options.withFlows retrieve flows */ - findOne(appId, { withFlows }: { withFlows: string | boolean } = { withFlows: false }) { - return this.appsDb - .findOne({ _id: appId }) - .then(app => (app ? cleanForOutput(app) : null)); + async findOne( + appId, + { withFlows }: { withFlows: string | boolean } = { withFlows: false } + ) { + const app = this.appsDb.by('id', appId); + return app ? cleanForOutput(app) : null; } /** @@ -234,7 +219,7 @@ export class AppsService { appId, { format, flowIds }: { appModel?: string; format?: string; flowIds?: string[] } = {} ) { - const app = await this.appsDb.findOne({ _id: appId }); + const app = this.appsDb.by('id', appId); if (!app) { throw ErrorManager.makeError('Application not found', { type: GENERAL_ERROR_TYPES.COMMON.NOT_FOUND, @@ -279,33 +264,31 @@ function cleanInput(app) { } function cleanForOutput(app) { - const cleanedApp = Object.assign({ id: app._id }, app); - return pick(cleanedApp, PUBLISH_FIELDS); -} - -function nowISO() { - return new Date().toISOString(); + return pick(app, PUBLISH_FIELDS); } -function validateUniqueName(inputName, appId: string, appsDb: Database) { +function throwIfNameNotUnique(inputName, appId: string, appsDb: Collection) { const name = getAppNameForSearch(inputName); - return appsDb - .findOne( - { _id: { $ne: appId }, name: new RegExp(`^${name}$`, 'i') }, - { _id: 1, name: 1 } + const [existing] = appsDb + .chain() + .find( + { + id: { $ne: appId }, + name: { $regex: [`^${name}$`, 'i'] }, + }, + true ) - .then(nameExists => { - if (nameExists) { - throw ErrorManager.createValidationError('Validation error', [ - { - property: 'name', - title: 'Name already exists', - detail: "There's another app with that name", - value: inputName, - type: CONSTRAINTS.UNIQUE, - }, - ]); - } - return true; - }); + .data(); + + if (existing) { + throw ErrorManager.createValidationError('Validation error', [ + { + property: 'name', + title: 'Name already exists', + detail: "There's another app with that name", + value: inputName, + type: CONSTRAINTS.UNIQUE, + }, + ]); + } } diff --git a/apps/server/src/modules/apps/common.ts b/apps/server/src/modules/apps/common.ts index ed240a2fe..895d929e6 100644 --- a/apps/server/src/modules/apps/common.ts +++ b/apps/server/src/modules/apps/common.ts @@ -1,17 +1,18 @@ import { escapeRegExp } from 'lodash'; -import { Database } from '../../common/database.service'; +import { Collection } from 'lokijs'; import { findGreatestNameIndex } from '../../common/utils/collection'; -export async function saveNew(app, appsDb: Database) { +export async function saveNew(app, appsDb: Collection) { const uniqueName = await ensureUniqueName(app.name, appsDb); app.name = uniqueName; return appsDb.insert(app); } -function ensureUniqueName(forName, appsDb: Database) { +function ensureUniqueName(forName, appsDb: Collection) { const normalizedName = escapeRegExp(forName.trim().toLowerCase()); - return appsDb.find({ name: new RegExp(`^${normalizedName}`, 'i') }).then(apps => { - const greatestIndex = findGreatestNameIndex(forName, apps); - return greatestIndex < 0 ? forName : `${forName} (${greatestIndex + 1})`; + const results = appsDb.find({ + name: { $regex: [`^${normalizedName}`, 'i'] }, }); + const greatestIndex = findGreatestNameIndex(forName, results); + return greatestIndex < 0 ? forName : `${forName} (${greatestIndex + 1})`; } diff --git a/apps/server/src/modules/apps/handlers-service.ts b/apps/server/src/modules/apps/handlers-service.ts index cef0a93d9..0368910c6 100644 --- a/apps/server/src/modules/apps/handlers-service.ts +++ b/apps/server/src/modules/apps/handlers-service.ts @@ -1,7 +1,8 @@ -import { defaults, isEmpty, pick } from 'lodash'; +import { defaults, pick } from 'lodash'; import { injectable, inject } from 'inversify'; +import { Collection } from 'lokijs'; +import { App, Trigger, Handler } from '@flogo-web/core'; import { TOKENS } from '../../core'; -import { Database } from '../../common/database.service'; import { ErrorManager, ERROR_TYPES } from '../../common/errors'; import { Validator } from './validator'; import { ISONow } from '../../common/utils'; @@ -10,138 +11,155 @@ const EDITABLE_FIELDS = ['settings', 'outputs', 'actionMappings']; @injectable() export class HandlersService { - constructor(@inject(TOKENS.AppsDb) private appsDb: Database) {} + constructor(@inject(TOKENS.AppsDb) private appsDb: Collection) {} - save(triggerId, resourceId, handlerData) { + async save(triggerId, resourceId, handlerData) { if (!triggerId || !resourceId) { throw new TypeError('Params triggerId and resourceId are required'); } - const findQuery = { 'triggers.id': triggerId, 'resources.id': resourceId }; - return this.appsDb.findOne(findQuery, { resources: 1, triggers: 1 }).then(app => { - if (!app) { - throw ErrorManager.makeError('App not found', { - type: ERROR_TYPES.COMMON.NOT_FOUND, - }); - } - - const errors = Validator.validateHandler(handlerData); - if (errors) { - throw ErrorManager.createValidationError('Validation error', errors); - } - let handler = cleanInput(handlerData, EDITABLE_FIELDS); - - const triggerIndex = app.triggers.findIndex(t => t.id === triggerId); - const trigger = app.triggers[triggerIndex]; - - let updateQuery = {}; - const now = ISONow(); - - const existingHandlerIndex = trigger.handlers.findIndex( - h => h.resourceId === resourceId - ); - if (existingHandlerIndex >= 0) { - const existingHandler = trigger.handlers[existingHandlerIndex]; - handler = defaults(handler, existingHandler); - handler.updatedAt = now; - updateQuery = { - $set: { - [`triggers.${triggerIndex}.handlers.${existingHandlerIndex}`]: handler, - }, - }; - } else { - handler = defaults(handler, { - resourceId, - createdAt: now, - updatedAt: null, - settings: {}, - outputs: {}, - actionMappings: { - input: {}, - output: {}, - }, - }); - updateQuery = { - $push: { [`triggers.${triggerIndex}.handlers`]: handler }, - }; - } - - return this.appsDb - .update(findQuery, updateQuery, {}) - .then(modifiedCount => this.findOne(triggerId, resourceId)); - }); - } + const [app] = this.appsDb + .chain() + .find( + >{ 'triggers.id': triggerId, 'resources.id': resourceId }, + true + ) + .data(); + + if (!app) { + throw ErrorManager.makeError('App not found', { + type: ERROR_TYPES.COMMON.NOT_FOUND, + }); + } + + const errors = Validator.validateHandler(handlerData); + if (errors) { + throw ErrorManager.createValidationError('Validation error', errors); + } + let handler = cleanInput(handlerData, EDITABLE_FIELDS); - findOne(triggerId, resourceId) { - return this.appsDb - .findOne({ 'triggers.id': triggerId }, { triggers: 1 }) - .then(app => { - if (!app) { - return null; - } - const trigger = app.triggers.find(t => t.id === triggerId); - const handler = trigger.handlers.find(h => h.resourceId === resourceId); - if (handler) { - handler.appId = app._id; - handler.triggerId = trigger.id; - } - return handler; + const triggerIndex = app.triggers.findIndex(t => t.id === triggerId); + const trigger = app.triggers[triggerIndex]; + + const now = ISONow(); + + const existingHandlerIndex = trigger.handlers.findIndex( + h => h.resourceId === resourceId + ); + if (existingHandlerIndex >= 0) { + const existingHandler = trigger.handlers[existingHandlerIndex]; + defaults(handler, existingHandler); + handler.updatedAt = now; + trigger.handlers[existingHandlerIndex] = handler as Handler; + } else { + handler = defaults(handler, { + resourceId, + createdAt: now, + updatedAt: null, + settings: {}, + outputs: {}, + actionMappings: { + input: {}, + output: {}, + }, }); + trigger.handlers.push(handler as Handler); + } + + this.appsDb.update(app); + return this.findOne(triggerId, resourceId); } - list(triggerId) { - return this.appsDb - .findOne({ 'triggers.id': triggerId }) - .then(app => (app.triggers.handlers ? app.triggers.handlers : [])); + async findOne(triggerId, resourceId) { + const [app] = this.appsDb + .chain() + .find(>{ 'triggers.id': triggerId }, true) + .data(); + if (!app) { + return null; + } + + const trigger = app.triggers.find(t => t.id === triggerId); + const storedHandler = trigger.handlers.find(h => h.resourceId === resourceId); + let result = null; + if (storedHandler) { + result = { + ...storedHandler, + appId: app.id, + triggerId: trigger.id, + }; + } + return result; } - remove(triggerId, resourceId) { + async list(triggerId) { + const [trigger] = this.appsDb + .chain() + .find(>{ 'triggers.id': triggerId }, true) + .map(app => app.triggers.find(t => t.id === triggerId)) + .data(); + return trigger && trigger.handlers ? trigger.handlers : []; + } + + async remove(triggerId, resourceId) { if (!triggerId || !resourceId) { throw new TypeError('Params triggerId and resourceId are required'); } - return this.appsDb - .findOne( - { 'triggers.id': triggerId, 'resources.id': resourceId }, - { triggers: 1, resources: 1 } + const [app] = this.appsDb + .chain() + .find( + >{ + 'triggers.id': triggerId, + 'resources.id': resourceId, + }, + true ) - .then(app => { - const triggerIndex = app.triggers.findIndex(t => t.id === triggerId); - return this.appsDb.update( - { 'triggers.id': triggerId, 'resources.id': resourceId }, - { $pull: { [`triggers.${triggerIndex}.handlers`]: { resourceId } } }, - {} - ); - }) - .then(numRemoved => numRemoved > 0); + .data(); + if (!app) { + return false; + } + + const trigger = app.triggers.find(t => t.id === triggerId); + trigger.handlers = trigger.handlers.filter(h => h.resourceId !== resourceId); + this.appsDb.update(app); + return true; } - removeByResourceId(resourceId) { - return this.appsDb - .findOne({ 'triggers.handlers.resourceId': resourceId }, { triggers: 1 }) - .then(app => { - if (!app) { - return null; - } - const pull = app.triggers.reduce((result, trigger, index) => { - const existingHandlers = trigger.handlers.findIndex( - handler => handler.resourceId === resourceId - ); - if (existingHandlers >= 0) { - result['triggers.' + index + '.handlers'] = { resourceId }; - } - return result; - }, {}); - if (!isEmpty(pull)) { - return this.appsDb.update( - { 'triggers.handlers.resourceId': resourceId }, - { $pull: pull }, - {} - ); - } - return null; - }) - .then(numRemoved => numRemoved > 0); + async removeByResourceId(resourceId) { + let [app] = this.appsDb + .chain() + .find( + >{ + 'triggers.handlers.resourceId': resourceId, + }, + true + ) + .data(); + + if (!app) { + return false; + } + + app = removeHandlerWhereResourceId(app, resourceId); + this.appsDb.update(app); + + return true; + } +} + +function removeHandlerWhereResourceId( + app: App & LokiObj, + resourceIdx: string +): App & LokiObj { + const isHandlerToRemove = (handler: Handler) => handler.resourceId === resourceIdx; + for (const trigger of app.triggers) { + const handlerToRemoveIndex = trigger.handlers.findIndex(isHandlerToRemove); + if (handlerToRemoveIndex > -1) { + trigger.handlers.splice(handlerToRemoveIndex, 1); + break; + } } + return app; } function cleanInput(trigger, fields) { diff --git a/apps/server/src/modules/apps/tests/apps-service.spec.ts b/apps/server/src/modules/apps/tests/apps-service.spec.ts new file mode 100644 index 000000000..abc096ca7 --- /dev/null +++ b/apps/server/src/modules/apps/tests/apps-service.spec.ts @@ -0,0 +1,70 @@ +import { initDb, collections } from '../../../common/db'; +import { ResourceService } from '../../resources'; + +import { AppsService } from '../apps-service'; +import { mockDate, restoreDate } from './utils'; + +const NOW_ISO = '2019-06-21T21:34:16.051Z'; +let appsService: AppsService; + +beforeEach(async () => { + await initDb({ persist: false }); + mockDate(NOW_ISO); + appsService = new AppsService( + collections.apps, + null, + ({ + removeFromRecentByAppId: appId => Promise.resolve(), + } as Partial) as any, + null, + null, + null + ); +}); + +afterEach(() => { + restoreDate(); +}); + +it('saves an app', async () => { + const app = await appsService.create({ name: 'myApp' }); + expect(app).toMatchObject({ + id: expect.any(String), + name: 'myApp', + updatedAt: null, + createdAt: NOW_ISO, + }); +}); + +it('updates an app', async () => { + const app = await appsService.create({ name: 'appToUpdate' }); + expect(app).toMatchObject({ + name: 'appToUpdate', + }); + await appsService.update(app.id, { name: 'new name' }); + expect(await appsService.findOne(app.id)).toMatchObject({ name: 'new name' }); +}); + +it('removes an app', async () => { + const app = await appsService.create({ name: 'app to remove' }); + expect(app).toMatchObject({ + name: 'app to remove', + }); + await appsService.remove(app.id); + expect(await appsService.findOne(app.id)).toBeFalsy(); + expect(await appsService.find()).toHaveLength(0); +}); + +it('lists apps', async () => { + await appsService.create({ name: 'some app 1' }); + await appsService.create({ name: 'some app 2' }); + await appsService.create({ name: 'some app 3' }); + const apps = await appsService.find(); + expect(apps).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'some app 1' }), + expect.objectContaining({ name: 'some app 2' }), + expect.objectContaining({ name: 'some app 3' }), + ]) + ); +}); diff --git a/apps/server/src/modules/apps/tests/handlers-service.spec.ts b/apps/server/src/modules/apps/tests/handlers-service.spec.ts new file mode 100644 index 000000000..68d7b3a08 --- /dev/null +++ b/apps/server/src/modules/apps/tests/handlers-service.spec.ts @@ -0,0 +1,113 @@ +import { App } from '@flogo-web/core'; +import { initDb, collections } from '../../../common/db'; +import { mockDate, restoreDate } from './utils'; +import { HandlersService } from '../handlers-service'; + +const APP_ID = 'my-app-id'; +const TRIGGER_ID = 'trigger-1'; +const LINKED_RESOURCE_ID = 'linked-resource'; +const UNLINKED_RESOURCE_ID = 'unlinked-resource'; + +let handlersService: HandlersService; +let appsCollection: Collection; + +beforeEach(async () => { + await initDb({ persist: false }); + appsCollection = collections.apps; + appsCollection.insert(getSeedApp()); + handlersService = new HandlersService(appsCollection); +}); + +afterEach(() => { + restoreDate(); +}); + +it('creates handlers', async () => { + expect(await handlersService.findOne(TRIGGER_ID, UNLINKED_RESOURCE_ID)).toBeFalsy(); + await handlersService.save(TRIGGER_ID, UNLINKED_RESOURCE_ID, {}); + expect(await handlersService.findOne(TRIGGER_ID, UNLINKED_RESOURCE_ID)).toBeTruthy(); +}); + +it('updates handlers', async () => { + expect(await handlersService.findOne(TRIGGER_ID, LINKED_RESOURCE_ID)).toEqual( + expect.objectContaining({ + settings: { + mySetting: 'foo', + }, + }) + ); + await handlersService.save(TRIGGER_ID, LINKED_RESOURCE_ID, { + settings: { + somethingElse: 'bar', + }, + }); + expect(await handlersService.findOne(TRIGGER_ID, LINKED_RESOURCE_ID)).toEqual( + expect.objectContaining({ + settings: { + somethingElse: 'bar', + }, + }) + ); +}); + +it('removes handlers', async () => { + expect(await handlersService.findOne(TRIGGER_ID, LINKED_RESOURCE_ID)).toBeTruthy(); + await handlersService.remove(TRIGGER_ID, LINKED_RESOURCE_ID); + expect(await handlersService.findOne(TRIGGER_ID, LINKED_RESOURCE_ID)).toBeFalsy(); +}); + +it('should remove handlers by resource id', async () => { + expect(await handlersService.findOne(TRIGGER_ID, LINKED_RESOURCE_ID)).toBeTruthy(); + await handlersService.removeByResourceId(LINKED_RESOURCE_ID); + expect(await handlersService.findOne(TRIGGER_ID, LINKED_RESOURCE_ID)).toBeFalsy(); +}); + +it('lists handlers', async () => { + await handlersService.save(TRIGGER_ID, UNLINKED_RESOURCE_ID, { + settings: { + somethingElse: 'bar', + }, + }); + expect(await handlersService.list(TRIGGER_ID)).toHaveLength(2); +}); + +function getSeedApp(): App { + return { + id: APP_ID, + type: 'apptype', + name: 'My app', + resources: [ + { + id: LINKED_RESOURCE_ID, + name: 'my resource 1', + type: 'resourcetype', + data: {}, + }, + { + id: UNLINKED_RESOURCE_ID, + name: 'my resource 2', + type: 'resourcetype', + data: {}, + }, + ], + triggers: [ + { + id: TRIGGER_ID, + name: 'trigger 1', + ref: 'github.com/some/ref', + createdAt: null, + updatedAt: null, + settings: {}, + handlers: [ + { + resourceId: LINKED_RESOURCE_ID, + settings: { + mySetting: 'foo', + }, + outputs: {}, + }, + ], + }, + ], + }; +} diff --git a/apps/server/src/modules/apps/tests/triggers-service.spec.ts b/apps/server/src/modules/apps/tests/triggers-service.spec.ts new file mode 100644 index 000000000..618d31375 --- /dev/null +++ b/apps/server/src/modules/apps/tests/triggers-service.spec.ts @@ -0,0 +1,100 @@ +import { App } from '@flogo-web/core'; + +import { initDb, collections } from '../../../common/db'; +import { AppTriggersService } from '../triggers'; +import { mockDate, restoreDate } from './utils'; +import { ContributionsService } from '../../contribs'; + +const NOW_ISO = '2019-06-21T21:34:16.051Z'; +const VALID_REF = 'github.com/project-flogo/contrib/cool-trigger'; + +let triggerService: AppTriggersService; + +const APP_ID = 'some-app-id'; +let appsCollection: Collection; + +beforeEach(async () => { + mockDate(NOW_ISO); + + await initDb({ persist: false }); + appsCollection = collections.apps; + appsCollection.insert(getSeedApp()); + triggerService = new AppTriggersService(appsCollection, ({ + async findByRef(ref: string) { + if (ref === VALID_REF) { + return { ref: VALID_REF, name: 'cool trigger schema' }; + } + }, + } as Partial) as any); +}); + +afterEach(() => { + restoreDate(); +}); + +it('creates a trigger', async () => { + const trigger = await triggerService.create(APP_ID, { + name: 'my trigger', + ref: VALID_REF, + }); + expect(trigger).toMatchObject({ + id: expect.any(String), + name: 'my trigger', + ref: VALID_REF, + createdAt: NOW_ISO, + }); +}); + +it('updates a trigger', async () => { + const trigger = await triggerService.create(APP_ID, { + name: 'my trigger to update', + description: 'some description', + ref: VALID_REF, + }); + await triggerService.update(trigger.id, { name: 'new trigger name' }); + expect(await triggerService.findOne(trigger.id)).toMatchObject({ + id: trigger.id, + name: 'new trigger name', + description: 'some description', + ref: VALID_REF, + }); +}); + +it('removes a trigger', async () => { + const trigger = await triggerService.create(APP_ID, { + ref: VALID_REF, + name: 'trigger to remove', + }); + expect(trigger).toMatchObject({ + ref: VALID_REF, + name: 'trigger to remove', + }); + const wasRemoved = await triggerService.remove(trigger.id); + expect(wasRemoved).toBe(true); + expect(await triggerService.findOne(trigger.id)).toBeFalsy(); + expect(await triggerService.list(APP_ID)).toHaveLength(0); +}); + +it('lists triggers', async () => { + await triggerService.create(APP_ID, { ref: VALID_REF, name: 'My Trigger 1' }); + await triggerService.create(APP_ID, { ref: VALID_REF, name: 'My Trigger 2' }); + await triggerService.create(APP_ID, { ref: VALID_REF, name: 'My Trigger 3' }); + const triggers = await triggerService.list(APP_ID); + expect(triggers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'My Trigger 1' }), + expect.objectContaining({ name: 'My Trigger 2' }), + expect.objectContaining({ name: 'My Trigger 3' }), + ]) + ); +}); + +function getSeedApp(): App { + return { + id: APP_ID, + type: 'apptype', + name: 'My app', + resources: [], + triggers: [], + }; +} diff --git a/apps/server/src/modules/apps/tests/utils.ts b/apps/server/src/modules/apps/tests/utils.ts new file mode 100644 index 000000000..f2fe1ea41 --- /dev/null +++ b/apps/server/src/modules/apps/tests/utils.ts @@ -0,0 +1,20 @@ +const RealDate = Date; + +export function mockDate(isoDate) { + // @ts-ignore + global.Date = class extends RealDate { + // @ts-ignore + constructor(...args) { + // @ts-ignore + if (args.length) { + // @ts-ignore + return new RealDate(...args); + } + return new RealDate(isoDate); + } + }; +} + +export function restoreDate() { + global.Date = RealDate; +} diff --git a/apps/server/src/modules/apps/triggers.ts b/apps/server/src/modules/apps/triggers.ts index 06fd22c42..a2f8bbb5f 100644 --- a/apps/server/src/modules/apps/triggers.ts +++ b/apps/server/src/modules/apps/triggers.ts @@ -1,19 +1,18 @@ import pick from 'lodash/pick'; -import mapKeys from 'lodash/mapKeys'; import { injectable, inject } from 'inversify'; +import { Collection } from 'lokijs'; + +import { App, Trigger } from '@flogo-web/core'; import { TOKENS } from '../../core'; -import { ContributionManager } from '../contributions'; -import { Database } from '../../common/database.service'; +import { ContributionsService } from '../contribs'; import { ErrorManager, ERROR_TYPES } from '../../common/errors'; import { CONSTRAINTS } from '../../common/validation'; import { generateShortId, ISONow } from '../../common/utils'; import { findGreatestNameIndex } from '../../common/utils/collection'; - import { Validator } from './validator'; const EDITABLE_FIELDS_CREATION = ['name', 'ref', 'description', 'settings']; - const EDITABLE_FIELDS_UPDATE = ['name', 'description', 'settings']; const getComparableTriggerName = fromName => fromName.trim().toLowerCase(); @@ -78,9 +77,13 @@ const nameExists = (triggerId, name, triggers) => { */ @injectable() export class AppTriggersService { - constructor(@inject(TOKENS.AppsDb) private appsDb: Database) {} + constructor( + @inject(TOKENS.AppsDb) private appsDb: Collection, + @inject(TOKENS.ContributionsManager) + private contributionsService: ContributionsService + ) {} - create(appId, triggerData) { + async create(appId, newTrigger): Promise { if (!appId) { return Promise.reject( ErrorManager.makeError('App not found', { @@ -89,156 +92,142 @@ export class AppTriggersService { ); } - return this.appsDb - .findOne({ _id: appId }, { triggers: 1 }) - .then(app => { - if (!app) { - throw ErrorManager.makeError('App not found', { - type: ERROR_TYPES.COMMON.NOT_FOUND, - }); - } + const app = this.appsDb.findOne({ id: appId }); - const errors = Validator.validateTriggerCreate(triggerData); - if (errors) { - throw ErrorManager.createValidationError('Validation error', errors); - } + if (!app) { + throw ErrorManager.makeError('App not found', { + type: ERROR_TYPES.COMMON.NOT_FOUND, + }); + } - return ContributionManager.findByRef(triggerData.ref).then(contribTrigger => { - if (!contribTrigger) { - throw ErrorManager.createValidationError('Validation error', [ - { - property: 'ref', - title: 'Trigger not installed', - detail: 'The specified ref for contrib is not installed', - value: triggerData.ref, - type: CONSTRAINTS.NOT_INSTALLED_TRIGGER, - }, - ]); - } - triggerData.name = ensureUniqueName(app.triggers, triggerData.name); - return triggerData; - }); - }) - .then(newTrigger => { - newTrigger = cleanInput(triggerData, EDITABLE_FIELDS_CREATION); - newTrigger.id = generateShortId(); - newTrigger.name = newTrigger.name.trim(); - newTrigger.createdAt = ISONow(); - newTrigger.updatedAt = null; - newTrigger.handlers = []; + const errors = Validator.validateTriggerCreate(newTrigger); + if (errors) { + throw ErrorManager.createValidationError('Validation error', errors); + } - return this.appsDb - .update({ _id: appId }, { $push: { triggers: newTrigger } }) - .then(() => { - newTrigger.appId = appId; - return newTrigger; - }); - }); + const contribTrigger = await this.contributionsService.findByRef(newTrigger.ref); + if (!contribTrigger) { + throw ErrorManager.createValidationError('Validation error', [ + { + property: 'ref', + title: 'Trigger not installed', + detail: 'The specified ref for contrib is not installed', + value: newTrigger.ref, + type: CONSTRAINTS.NOT_INSTALLED_TRIGGER, + }, + ]); + } + + newTrigger = cleanInput(newTrigger, EDITABLE_FIELDS_CREATION); + newTrigger.id = generateShortId(); + newTrigger.name = ensureUniqueName(app.triggers, newTrigger.name.trim()); + newTrigger.createdAt = ISONow(); + newTrigger.updatedAt = null; + newTrigger.handlers = []; + + app.triggers.push(newTrigger); + this.appsDb.update(app); + + return { ...newTrigger, appId }; } - update(triggerId, triggerData) { - const appsDb = this.appsDb; + async update(triggerId, triggerData) { const appNotFound = ErrorManager.makeError('App not found', { type: ERROR_TYPES.COMMON.NOT_FOUND, }); - return this.findOne(triggerId).then(trigger => { - if (!trigger) { - throw appNotFound; - } - const errors = Validator.validateTriggerUpdate(triggerData); - if (errors) { - throw ErrorManager.createValidationError('Validation error', errors); - } + const trigger = await this.findOne(triggerId); + if (!trigger) { + throw appNotFound; + } + const errors = Validator.validateTriggerUpdate(triggerData); + if (errors) { + throw ErrorManager.createValidationError('Validation error', errors); + } - triggerData = cleanInput(triggerData, EDITABLE_FIELDS_UPDATE); + triggerData = cleanInput(triggerData, EDITABLE_FIELDS_UPDATE); - return _atomicUpdate(triggerData, trigger.appId).then(updatedCount => - updatedCount > 0 ? this.findOne(triggerId) : null - ); - }); + const app = this.appsDb.findOne({ id: trigger.appId }); + if (!app) { + throw appNotFound; + } - function _atomicUpdate(triggerFields, appId) { - return new Promise((resolve, reject) => { - const appQuery = { _id: appId }; - const updateQuery: any = {}; + if (triggerData.name) { + validateNameUnique(triggerData.name, app, triggerId); + } - appsDb - .findOne(appQuery, { triggers: 1 }) - .then(app => { - if (!app) { - return reject(appNotFound); - } + const triggerToUpdate = app.triggers.find(t => t.id === triggerId); + Object.assign(triggerToUpdate, triggerData); + triggerToUpdate.updatedAt = ISONow(); - if (triggerFields.name) { - if (nameExists(triggerId, triggerFields.name, app.triggers)) { - // do nothing - return reject( - ErrorManager.createValidationError('Validation error', [ - { - property: 'name', - title: 'Name already exists', - detail: "There's another trigger in the app with this name", - value: { - triggerId, - appId: app.id, - name: triggerFields.name, - }, - type: CONSTRAINTS.UNIQUE, - }, - ]) - ); - } - } + this.appsDb.update(app); - const triggerIndex = app.triggers.findIndex(t => t.id === triggerId); - const modifierPrefix = `triggers.${triggerIndex}`; - triggerFields.updatedAt = ISONow(); - // makes { $set: { 'triggers.1.name': 'my trigger' } }; - updateQuery.$set = mapKeys( - triggerFields, - (v, fieldName) => `${modifierPrefix}.${fieldName}` - ); - return appsDb.update(appQuery, updateQuery); - }) - .then(count => resolve(count)) - .catch(e => reject(e)); - }); - } + return this.findOne(triggerId); } - findOne(triggerId) { - return this.appsDb - .findOne({ 'triggers.id': triggerId }, { triggers: 1 }) - .then(app => { + async findOne(triggerId): Promise { + const [trigger] = this.appsDb + .chain() + .find(>{ 'triggers.id': triggerId }, true) + .map(app => { if (!app) { return null; } - const trigger = app.triggers.find(t => t.id === triggerId); - trigger.appId = app._id; - return trigger; - }); + const foundTrigger = app.triggers.find(t => t.id === triggerId); + return { ...foundTrigger, appId: app.id }; + }) + .data({ removeMeta: true }); + + return trigger || null; } - list(appId, { name }: { name?: string }) { - return this.appsDb - .findOne({ _id: appId }) - .then(app => (app && app.triggers ? app.triggers : [])) - .then(triggers => { - if (name) { - const findName = getComparableTriggerName(name); - return triggers.filter( - trigger => findName === getComparableTriggerName(trigger.name) - ); - } - return triggers; - }); + async list(appId, { name }: { name?: string } = {}) { + const app = this.appsDb.findOne({ id: appId }); + let triggers = app && app.triggers ? app.triggers : []; + if (name) { + const findName = getComparableTriggerName(name); + triggers = triggers.filter( + trigger => findName === getComparableTriggerName(trigger.name) + ); + } + return triggers; } - remove(triggerId) { - return this.appsDb - .update({ 'triggers.id': triggerId }, { $pull: { triggers: { id: triggerId } } }) - .then(numRemoved => numRemoved > 0); + async remove(triggerId) { + const [app] = this.appsDb + .chain() + .find(>{ 'triggers.id': triggerId }, true) + .data(); + if (!app) { + return false; + } + + const triggerIndex = app.triggers.findIndex(trigger => trigger.id === triggerId); + if (triggerIndex >= 0) { + app.triggers.splice(triggerIndex, 1); + this.appsDb.update(app); + return true; + } + return false; + } +} + +function validateNameUnique(name: string, app: App, triggerId: string) { + if (nameExists(triggerId, name, app.triggers)) { + // do nothing + throw ErrorManager.createValidationError('Validation error', [ + { + property: 'name', + title: 'Name already exists', + detail: "There's another trigger in the app with this name", + value: { + triggerId, + appId: app.id, + name: name, + }, + type: CONSTRAINTS.UNIQUE, + }, + ]); } } diff --git a/apps/server/src/modules/resources/prepare-update-query.spec.ts b/apps/server/src/modules/resources/prepare-update-query.spec.ts deleted file mode 100644 index 6ae9243c0..000000000 --- a/apps/server/src/modules/resources/prepare-update-query.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { pick } from 'lodash'; -import { prepareUpdateQuery } from './prepare-update-query'; - -describe('resources: prepareUpdateQuery', () => { - const MockOldResourceData = { - name: 'sample', - metadata: {}, - data: { sample: true }, - }; - const MockNewResourceData = { - name: 'sample new', - metadata: {}, - data: { sample: true }, - }; - - it('should create proper query for an resource to update the db', () => { - const query = prepareUpdateQuery(MockNewResourceData, MockOldResourceData, 1); - expect(query).toHaveProperty('$set'); - expect(query).not.toHaveProperty('$unset'); - expect(query.$set).toHaveProperty(['resources.1.name'], 'sample new'); - expect(Object.keys(query.$set)).toEqual( - expect.arrayContaining(['resources.1.metadata', 'resources.1.data']) - ); - }); -}); diff --git a/apps/server/src/modules/resources/prepare-update-query.ts b/apps/server/src/modules/resources/prepare-update-query.ts deleted file mode 100644 index 2ea4211fa..000000000 --- a/apps/server/src/modules/resources/prepare-update-query.ts +++ /dev/null @@ -1,17 +0,0 @@ -const mapKeys = require('lodash/mapKeys'); - -interface UpdateQuery { - $set?: any; - $unset?: any; -} - -export function prepareUpdateQuery(newAction, oldResource, indexOfResource) { - const updateQuery: UpdateQuery = {}; - const modifierPrefix = `resources.${indexOfResource}`; - // makes { $set: { 'resources.1.name': 'my action' } }; - updateQuery.$set = mapKeys( - newAction, - (v, fieldName) => `${modifierPrefix}.${fieldName}` - ); - return updateQuery; -} diff --git a/apps/server/src/modules/resources/resource-service.ts b/apps/server/src/modules/resources/resource-service.ts index 51d8a7e4a..ab5cc69ac 100644 --- a/apps/server/src/modules/resources/resource-service.ts +++ b/apps/server/src/modules/resources/resource-service.ts @@ -119,10 +119,10 @@ export class ResourceService { const context = this.createHookContext(foundResource); await this.resourceHooks.wrapAndRun('list', context, hookContext => { const resource = hookContext.resource as any; - resource.appId = app._id; - app.id = app._id; + resource.appId = app.id; + app.id = app.id; const triggers = app.triggers.filter(isTriggerForResource(resource.id)); - app = omit(app, ['triggers', 'resources', '_id']); + app = omit(app, ['triggers', 'resources']); hookContext.resource = { ...resource, app, triggers }; }); return context.resource as ExtendedResource; diff --git a/apps/server/src/modules/resources/resource.repository.spec.ts b/apps/server/src/modules/resources/resource.repository.spec.ts new file mode 100644 index 000000000..700a6671f --- /dev/null +++ b/apps/server/src/modules/resources/resource.repository.spec.ts @@ -0,0 +1,114 @@ +import { App } from '@flogo-web/core'; +import { initDb, collections } from '../../common/db'; +import { Database } from '../../common/database.service'; +import { constructApp } from '../../core/models/app'; +import { ResourceRepository } from './resource.repository'; + +const APP_ID = 'my-app-id'; + +describe('Resource repository', () => { + let resourceRepository: ResourceRepository; + let appsCollection: Collection; + + beforeAll(() => initDb({ persist: false })); + + beforeEach(async () => { + collections.apps.clear({ removeIndices: true }); + appsCollection = collections.apps; + appsCollection.insert( + constructApp( + { + name: 'my app', + type: 'ui-app', + }, + () => APP_ID + ) + ); + + resourceRepository = new ResourceRepository(appsCollection, new Database(undefined), { + error: () => {}, + } as any); + await resourceRepository.create(APP_ID, { + id: 'stored-resource', + name: 'stored-resource', + type: 'resource', + data: {}, + }); + }); + + it('should save a new resource', async () => { + await resourceRepository.create(APP_ID, { + id: 'new-resource', + name: 'new resource', + type: 'resource', + data: {}, + }); + const app = await resourceRepository.getApp(APP_ID); + expect(app.resources).toHaveLength(2); + expect(app.resources).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'new-resource', + name: 'new resource', + type: 'resource', + }), + ]) + ); + }); + + it('finds an app by resource id', async () => { + await resourceRepository.create(APP_ID, { + id: 'cool-resource', + name: 'my resource', + type: 'resource', + data: {}, + }); + const app = await resourceRepository.findAppByResourceId('cool-resource'); + expect(app).toMatchObject({ + id: APP_ID, + name: 'my app', + type: 'ui-app', + }); + }); + + it('should update an existing resource', async () => { + await resourceRepository.update(APP_ID, { + id: 'stored-resource', + name: 'updated resource', + data: { + it: 'works', + }, + }); + const app = await resourceRepository.getApp(APP_ID); + expect(app.resources).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'stored-resource', + name: 'updated resource', + }), + ]) + ); + }); + + it('should remove a resource', async () => { + await resourceRepository.create(APP_ID, { + id: 'resource-to-remove', + name: 'my resource', + type: 'resource', + data: {}, + }); + let app = await resourceRepository.getApp(APP_ID); + expect(app.resources).toHaveLength(2); + + const removeResult = await resourceRepository.remove('resource-to-remove'); + expect(removeResult).toBe(true); + app = await resourceRepository.getApp(APP_ID); + + expect(app.resources).toHaveLength(1); + expect(app.resources).not.toEqual([ + expect.objectContaining({ + id: 'resource-to-remove', + }), + ]); + }); +}); diff --git a/apps/server/src/modules/resources/resource.repository.ts b/apps/server/src/modules/resources/resource.repository.ts index 668bb7e02..702b2e70b 100644 --- a/apps/server/src/modules/resources/resource.repository.ts +++ b/apps/server/src/modules/resources/resource.repository.ts @@ -1,4 +1,5 @@ import { inject, injectable } from 'inversify'; +import { Collection } from 'lokijs'; import { App } from '@flogo-web/core'; import { Resource } from '@flogo-web/lib-server/core'; import { TOKENS } from '../../core'; @@ -7,7 +8,6 @@ import { Database } from '../../common/database.service'; import { Logger } from '../../common/logging'; import { CONSTRAINTS } from '../../common/validation'; import { ErrorManager, ERROR_TYPES } from '../../common/errors'; -import { prepareUpdateQuery } from './prepare-update-query'; const RECENT_RESOURCES_ID = 'resources:recent'; const MAX_RECENT = 10; @@ -15,69 +15,97 @@ const MAX_RECENT = 10; @injectable() export class ResourceRepository { constructor( - @inject(TOKENS.AppsDb) private appsDb: Database, + @inject(TOKENS.AppsDb) private appsCollection: Collection, @inject(TOKENS.ResourceIndexerDb) private indexerDb: Database, @inject(TOKENS.Logger) private logger: Logger ) {} - getApp(appId): Promise { - return this.appsDb.findOne({ _id: appId }, { resources: 1 }); + async getApp(appId): Promise { + return this.appsCollection.findOne({ id: appId }); } - async create(appId: string, resource: Resource): Promise { + async create(appId: string, resource: Resource): Promise { resource.createdAt = ISONow(); resource.updatedAt = null; - return this.appsDb.update( - { _id: appId }, - { - $push: { - resources: { - ...resource, - createdAt: ISONow(), - updatedAt: null, - }, - }, - } - ); + + this.appsCollection.findAndUpdate({ id: appId }, app => { + app.resources.push({ + ...resource, + createdAt: ISONow(), + updatedAt: null, + }); + }); } async update( appId: string, resource: Partial & { id: string } ): Promise { - const updateCount = await atomicUpdate(this.appsDb, { - resource, - appId, - }); - if (updateCount <= 0) { - return Promise.reject(new Error('Error while saving flow')); + const app = this.appsCollection.findOne({ id: appId }); + if (!app) { + ErrorManager.makeError('App not found', { + type: ERROR_TYPES.COMMON.NOT_FOUND, + }); + } + + if (resource.name) { + this.throwIfNameNotUnique(app, resource); } + + const resourceIndex = app.resources.findIndex(t => t.id === resource.id); + if (resourceIndex < 0) { + throw new Error('Error while saving flow'); + } + + const newResource = Object.assign({}, app.resources[resourceIndex], resource, { + updatedAt: ISONow(), + }); + + app.resources[resourceIndex] = newResource; + this.appsCollection.update(app); storeAsRecent(this.indexerDb, { id: resource.id, appId }).catch(e => this.logger.error(e) ); return true; } - findAppByResourceId(resourceId: string) { - return this.appsDb.findOne({ 'resources.id': resourceId }); + async findAppByResourceId(resourceId: string) { + const [app] = this.appsCollection + .chain() + .find(>{ 'resources.id': resourceId }, true) + .data(); + return app; } - listRecent() { + async listRecent() { return this.indexerDb .findOne({ _id: RECENT_RESOURCES_ID }) .then(all => (all && all.resources ? all.resources : [])); } async remove(resourceId: string) { - const removedCount = await this.appsDb.update( - { 'resources.id': resourceId }, - { $pull: { resources: { id: resourceId } } } - ); - const wasDeleted = removedCount > 0; - if (wasDeleted) { - this.logIfFailed(removeFromRecent(this.indexerDb, 'id', resourceId)); + const [app] = this.appsCollection + .chain() + .find( + >{ + 'resources.id': resourceId, + }, + true + ) + .data(); + if (!app) { + return false; + } + + const resourceIndex = app.resources.findIndex(r => r.id === resourceId); + if (resourceIndex < 0) { + return false; } - return wasDeleted; + + app.resources.splice(resourceIndex, 1); + this.appsCollection.update(app); + this.logIfFailed(removeFromRecent(this.indexerDb, 'id', resourceId)); + return true; } removeFromRecent(compareField: string, fieldVal: any) { @@ -87,68 +115,29 @@ export class ResourceRepository { private logIfFailed(promise: Promise) { promise.catch(e => this.logger.error(e)); } -} -function atomicUpdate(appsDb: Database, { resource, appId }) { - return new Promise((resolve, reject) => { - const appQuery = { _id: appId }; - const updateQuery = {}; - - const createUpdateQuery = (err, app) => { - if (err) { - return reject(err); - } else if (!app) { - return reject( - ErrorManager.makeError('App not found', { - type: ERROR_TYPES.COMMON.NOT_FOUND, - }) - ); - } - - if (resource.name) { - const isNameUnique = !(app.resources || []).find( - resourceNameComparator(resource) - ); - if (!isNameUnique) { - // do nothing - return reject( - ErrorManager.createValidationError('Validation error', [ - { - property: 'name', - title: 'Name already exists', - detail: "There's another resource in the app with this name", - value: { - resourceId: resource.id, - appId: app.id, - name: resource.name, - }, - type: CONSTRAINTS.UNIQUE, - }, - ]) - ); - } - } - - const resourceIndex = app.resources.findIndex(t => t.id === resource.id); - resource.updatedAt = ISONow(); - Object.assign( - updateQuery, - prepareUpdateQuery(resource, app.resources[resourceIndex], resourceIndex) - ); - return null; - }; - - // queue find and update operation to nedb to make sure they execute one after the other - // and no other operation is mixed between them - appsDb.collection.findOne(appQuery, { resources: 1 }, createUpdateQuery); - appsDb.collection.update(appQuery, updateQuery, {}, (err, updatedCount) => - err ? reject(err) : resolve(updatedCount) - ); - }); + private throwIfNameNotUnique(app, resource: Partial & { id: string }) { + const isNameUnique = !(app.resources || []).find(resourceNameComparator(resource)); + if (!isNameUnique) { + ErrorManager.createValidationError('Validation error', [ + { + property: 'name', + title: 'Name already exists', + detail: "There's another resource in the app with this name", + value: { + resourceId: resource.id, + appId: app.id, + name: resource.name, + }, + type: CONSTRAINTS.UNIQUE, + }, + ]); + } + } } const comparableName = name => name.trim().toLowerCase(); -function resourceNameComparator(resource: Resource) { +function resourceNameComparator(resource: Partial) { const resourceName = comparableName(resource.name); return (r: Resource) => comparableName(r.name) === resourceName && r.id !== resource.id; } diff --git a/apps/server/src/modules/transfer/import/import-app.ts b/apps/server/src/modules/transfer/import/import-app.ts index 0e974f14c..f753ba8e1 100644 --- a/apps/server/src/modules/transfer/import/import-app.ts +++ b/apps/server/src/modules/transfer/import/import-app.ts @@ -153,7 +153,7 @@ function cleanAndValidateApp( validator.validate(rawApp); rawApp = rawApp as FlogoAppModel.App; return constructApp({ - _id: getNextId(), + id: getNextId(), name: rawApp.name, type: rawApp.type, description: rawApp.description, diff --git a/apps/server/src/modules/transfer/tests/samples/imported-app.ts b/apps/server/src/modules/transfer/tests/samples/imported-app.ts index 52c7f2655..ac4b8c0e3 100644 --- a/apps/server/src/modules/transfer/tests/samples/imported-app.ts +++ b/apps/server/src/modules/transfer/tests/samples/imported-app.ts @@ -1,5 +1,5 @@ const expectedApp = { - _id: expect.any(String), + id: expect.any(String), name: 'RestApp', type: 'flogo:app', version: '0.0.1', diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 9ace29a72..c9430fb95 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1,12 +1,14 @@ import { rootContainer, createApp as createServerApp } from './init'; +import { initDb } from './common/db'; import { logger } from './common/logging'; import { config } from './config/app-config'; import { init as initWebsocketApi } from './api/ws'; import { getInitializedEngine } from './modules/engine'; import { syncTasks } from './modules/contrib-install-controller/sync-tasks'; -initEngine(config.defaultEngine.path) +initDb({ autosave: false }) + .then(() => initEngine(config.defaultEngine.path)) .then(() => createServerApp({ port: config.app.port as string, diff --git a/package.json b/package.json index 0037d4450..bbf407f45 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "koa-send": "^5.0.0", "koa-static": "^5.0.0", "lodash": "^4.14.104", + "lokijs": "^1.5.6", "nedb": "1.8.0", "npm-run-all": "^4.1.5", "performance-now": "2.1.0", @@ -111,6 +112,7 @@ "@types/koa": "^2.0.46", "@types/koa-router": "^7.0.32", "@types/lodash": "~4.14.104", + "@types/lokijs": "^1.5.2", "@types/mocha": "^5.0.0", "@types/node": "10.12.10", "@types/socket.io-client": "~1.4.29", diff --git a/yarn.lock b/yarn.lock index a1642d31f..0a10492c6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -882,6 +882,11 @@ version "4.14.118" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.118.tgz#247bab39bfcc6d910d4927c6e06cbc70ec376f27" +"@types/lokijs@^1.5.2": + version "1.5.2" + resolved "https://registry.yarnpkg.com/@types/lokijs/-/lokijs-1.5.2.tgz#ed228f080033ce1fb16eff4acde65cb9ae0f1bf2" + integrity sha512-ZF14v1P1Bjbw8VJRu+p4WS9V926CAOjWF4yq23QmSBWRPe0/GXlUKzSxjP1fi/xi8nrq6zr9ECo8Z/8KsRqroQ== + "@types/mime@*": version "2.0.0" resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.0.tgz#5a7306e367c539b9f6543499de8dd519fac37a8b" @@ -6250,6 +6255,11 @@ loglevel@^1.4.1: version "1.6.1" resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.6.1.tgz#e0fc95133b6ef276cdc8887cdaf24aa6f156f8fa" +lokijs@^1.5.6: + version "1.5.6" + resolved "https://registry.yarnpkg.com/lokijs/-/lokijs-1.5.6.tgz#6de6b8c3ff7a972fd0104169f81e7ddc244c029f" + integrity sha512-xJoDXy8TASTjmXMKr4F8vvNUCu4dqlwY5gmn0g5BajGt1GM3goDCafNiGAh/sfrWgkfWu1J4OfsxWm8yrWweJA== + lolex@^2.2.0, lolex@^2.3.2: version "2.7.5" resolved "https://registry.yarnpkg.com/lolex/-/lolex-2.7.5.tgz#113001d56bfc7e02d56e36291cc5c413d1aa0733"