diff --git a/.eslintrc.json b/.eslintrc.json index 57479ba..28d1eac 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -13,6 +13,7 @@ "rules": { "@typescript-eslint/explicit-function-return-type": "warn", "@typescript-eslint/strict-boolean-expressions": "warn", + "@typescript-eslint/no-non-null-assertion": "off", "simple-import-sort/sort": "error" } -} +} \ No newline at end of file diff --git a/package.json b/package.json index 7059d34..b32b5a5 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "cleaners": "^0.3.12", "compression": "^1.7.4", "cors": "^2.8.5", - "edge-server-tools": "^0.2.11", + "edge-server-tools": "^0.2.13", "express": "^4.17.1", "firebase-admin": "^8.12.1", "morgan": "^1.10.0", diff --git a/src/NotificationManager.ts b/src/NotificationManager.ts new file mode 100644 index 0000000..39e8c88 --- /dev/null +++ b/src/NotificationManager.ts @@ -0,0 +1,64 @@ +import io from '@pm2/io' +import admin from 'firebase-admin' + +import { ApiKey } from './models' + +import BatchResponse = admin.messaging.BatchResponse + +const successCounter = io.counter({ + id: 'notifications:success:total', + name: 'Total Successful Notifications' +}) +const failureCounter = io.counter({ + id: 'notifications:failure:total', + name: 'Total Failed Notifications' +}) + +export const createNotificationManager = async ( + apiKey: ApiKey | string +): Promise => { + if (typeof apiKey === 'string') apiKey = await ApiKey.fetch(apiKey) + + const name = `app:${apiKey.appId}` + let app: admin.app.App + try { + app = admin.app(name) + } catch (err) { + app = admin.initializeApp( + { + credential: admin.credential.cert(apiKey.adminsdk) + }, + name + ) + } + return app +} + +export const sendNotification = async ( + app: admin.app.App, + title: string, + body: string, + tokens: string[], + data = {} +): Promise => { + const message: admin.messaging.MulticastMessage = { + notification: { + title, + body + }, + data, + tokens + } + + try { + const response = await app.messaging().sendMulticast(message) + + successCounter.inc(response.successCount) + failureCounter.inc(response.failureCount) + + return response + } catch (err) { + console.error(JSON.stringify(err, null, 2)) + throw err + } +} diff --git a/src/api/index.ts b/src/api/index.ts deleted file mode 100644 index 6fb9cfc..0000000 --- a/src/api/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { makeExpressRoute } from 'serverlet/express' - -import { config } from '../config' -import { pushNotificationRouterV2 } from './router' -import { createServer } from './server' -// Create server -const server = createServer(makeExpressRoute(pushNotificationRouterV2), config) - -// Start Server -server.listen(server.get('httpPort'), server.get('httpHost'), () => { - console.log( - `Express server listening on port ${JSON.stringify(server.get('httpPort'))}` - ) -}) diff --git a/src/api/router.ts b/src/api/router.ts index 701c311..73dac4d 100644 --- a/src/api/router.ts +++ b/src/api/router.ts @@ -1,4 +1,4 @@ -import { asArray, asObject, asString, asUnknown } from 'cleaners' +import { asArray, asObject, asString } from 'cleaners' import { type HttpRequest, type HttpResponse, @@ -10,10 +10,13 @@ import { jsonResponse, statusCodes, statusResponse -} from '../types/response-types' +} from '../types/http/response-types' +import { asAction } from '../types/task/Action' +import { asActionEffect } from '../types/task/ActionEffect' import { - DbDoc, + asTaskDoc, logger, + TaskDoc, wrappedDeleteFromDb, wrappedGetFromDb, wrappedSaveToDb @@ -51,31 +54,34 @@ const getTaskRoute = async (request: HttpRequest): Promise => { } // Construct a body and returns it as an HttpResponse. -// The body should have triggers, action, and taskId. +// The body should have actionEffects, action, userId, _id and taskId. const createTaskRoute = async (request: HttpRequest): Promise => { try { const asBody = asObject({ taskId: asString, - triggers: asArray(asUnknown), - action: asUnknown + actionEffects: asArray(asActionEffect), + action: asAction }) const queryObject = getQueryParamObject( - ['taskId', 'triggers', 'action'], + ['taskId', 'actionEffects', 'action'], request.path ) - const triggersAsString = queryObject.triggers - const triggersAsArray = convertStringToArray(triggersAsString) - queryObject.triggers = triggersAsArray ?? [] - const { taskId, triggers, action } = asBody(queryObject) + const actionEffectsAsString = queryObject.actionEffects + const actionEffectsAsArray = convertStringToArray(actionEffectsAsString) + queryObject.actionEffects = actionEffectsAsArray ?? [] + const { taskId, actionEffects, action } = asBody(queryObject) + const cleanedAction = asAction(action) - const doc: DbDoc = { - taskId, + const doc: TaskDoc = asTaskDoc({ + taskId: taskId, userId: request.headers.userId, - triggers, - action, + actionEffects: actionEffects.map(actionEffect => + asActionEffect(actionEffect) + ), + cleanedAction, _id: `${request.headers.userId}:${taskId}` // To help with partitioning - } + }) await wrappedSaveToDb([doc]) return statusResponse(statusCodes.SUCCESS, 'Successfully created the task') @@ -85,6 +91,8 @@ const createTaskRoute = async (request: HttpRequest): Promise => { } } +// Remove tasks from the database. If the taskIds array is empty, it +// will delete all tasks under the userId. const deleteTaskRoute = async (request: HttpRequest): Promise => { try { const asQuery = asObject({ diff --git a/src/config.ts b/src/config.ts deleted file mode 100644 index aa5a5ce..0000000 --- a/src/config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { makeConfig } from 'cleaner-config' -import { asNumber, asObject, asOptional, asString } from 'cleaners' - -const { COUCH_HOSTNAME = 'localhost', COUCH_PASSWORD = 'password' } = - process.env - -export const asServerConfig = asObject({ - couchUri: asOptional( - asString, - `http://username:${COUCH_PASSWORD}@${COUCH_HOSTNAME}:5984` - ), - // for running the local server - httpPort: asOptional(asNumber, 8008), - httpHost: asOptional(asString, '127.0.0.1') -}) - -export const config = makeConfig(asServerConfig, 'pushServerConfig.json') diff --git a/src/couchSetup.ts b/src/couchSetup.ts index 0e38581..dd3b802 100644 --- a/src/couchSetup.ts +++ b/src/couchSetup.ts @@ -8,6 +8,7 @@ import { } from 'edge-server-tools' import { ServerScope } from 'nano' +import { tasksListening, tasksPublishing } from './database/views/couch-tasks' import { serverConfig } from './serverConfig' // --------------------------------------------------------------------------- @@ -48,21 +49,18 @@ export const settingsSetup: DatabaseSetup = { const apiKeysSetup: DatabaseSetup = { name: 'db_api_keys' } -const thresholdsSetup: DatabaseSetup = { name: 'db_currency_thresholds' } - -const devicesSetup: DatabaseSetup = { name: 'db_devices' } - -const usersSetup: DatabaseSetup = { - name: 'db_user_settings' - // documents: { - // '_design/filter': makeJsDesign('by-currency', ?), - // '_design/map': makeJsDesign('currency-codes', ?) - // } -} - -const defaultsSetup: DatabaseSetup = { - name: 'defaults' - // syncedDocuments: ['thresholds'] +const tasksSetup: DatabaseSetup = { + name: 'db_tasks', + // Turn on partition by userId for performance and security reasons. + // https://docs.couchdb.org/en/3.2.2/partitioned-dbs/index.html + options: { + partitioned: true + }, + // Set up the views + documents: { + '_design/tasks_listening': tasksListening, + '_design/tasks_publishing': tasksPublishing + } } // --------------------------------------------------------------------------- @@ -79,13 +77,12 @@ export async function setupDatabases( replicatorSetup: syncedReplicators, disableWatching } - + // @ts-expect-error await setupDatabase(connection, settingsSetup, options) await Promise.all([ + // @ts-expect-error setupDatabase(connection, apiKeysSetup, options), - setupDatabase(connection, thresholdsSetup, options), - setupDatabase(connection, devicesSetup, options), - setupDatabase(connection, usersSetup, options), - setupDatabase(connection, defaultsSetup, options) + // @ts-expect-error + setupDatabase(connection, tasksSetup, options) ]) } diff --git a/src/database/views/couch-tasks.ts b/src/database/views/couch-tasks.ts new file mode 100644 index 0000000..9cbc62c --- /dev/null +++ b/src/database/views/couch-tasks.ts @@ -0,0 +1,81 @@ +/** + * Configures couchDB views that are used to model message queues. + * Associated helper functions are also provided. + * + * Publishers listen to these views to perform actions on update. One + * way of doing this is to use the {@link viewToStream} function from + * `edge-server-tools`. + * + * A key advantage of using views is that documents are programmatically + * indexed and serverd to views based on certain conditions, thereby + * elimitating the need to build seqarate listeners that subscribe to db + * documents and perform actions on update. + * + * Views can be named as a string, just like a normal database. They can + * be called by using `db.view(name, params)` method. The response will + * be of type `nano.DocumentViewResponse` where `T` is the shape of the + * documents defined elsewhere. This type has a `rows` property that is + * consistent with many other getter methods in nano. + */ + +// Certain import lines have lintings disabled because they are +// referenced only by documentation comments. +import { + JsDesignDocument, + makeJsDesign, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + viewToStream +} from 'edge-server-tools' + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { ActionEffect } from '../../types/task/ActionEffect' +import { Task } from '../../types/task/Task' +import { dbTasks, logger, packChange, TaskDoc } from '../../utils/dbUtils' + +/** + * A view that indexes to all tasks that contain at least one incomplete + * {@link ActionEffect}. + * + * @remarks + * This view is not intended to be subscribed by any publishers. Think + * of this as a staging area for ongoing tasks. + */ +export const tasksListening: JsDesignDocument = makeJsDesign( + 'tasks_listening', + () => ({ + filter: function (taskDoc: TaskDoc) { + return taskDoc.doc.actionEffects.some(e => e.completed === false) + } + }) +) + +/** + * A view that indexes to all tasks with all {@link ActionEffect} + * completed. + */ +export const tasksPublishing: JsDesignDocument = makeJsDesign( + 'tasks_publishing', + () => ({ + filter: function (taskDoc: TaskDoc) { + return taskDoc.doc.actionEffects.every(Boolean) + } + }) +) + +/** + * Updates the a task document in the `db_tasks` database. The function + * receives a {@link Task} object and updates the relavent document based on + * the content of this task. + * @param {Task} updatedTask - The task that has its `action.inProgress` + * flag updated. + */ +export const updateInProgress = async ( + updatedTask: Task, + id: string +): Promise => { + try { + await dbTasks.insert(packChange(updatedTask, id)) + } catch (e) { + logger(`Failed to make ${updatedTask.taskId}'s action as inprogress: `, e) + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..fdfbf5f --- /dev/null +++ b/src/index.ts @@ -0,0 +1,33 @@ +import nano from 'nano' +import { makeExpressRoute } from 'serverlet/express' + +import { pushNotificationRouterV2 } from './api/router' +import { setupDatabases } from './couchSetup' +import { createServer } from './server' +import { serverConfig } from './serverConfig' + +async function main(): Promise { + // Set up databases: + const connection = nano(serverConfig.couchUri) + await setupDatabases(connection) + + // Create server + const server = createServer( + makeExpressRoute(pushNotificationRouterV2), + serverConfig + ) + + // Start Server + server.listen(server.get('httpPort'), server.get('httpHost'), () => { + console.log( + `Express server listening on port ${JSON.stringify( + server.get('httpPort') + )}` + ) + }) +} + +main().catch(error => { + console.error(error) + process.exit(1) +}) diff --git a/src/models/CurrencyThreshold.ts b/src/models/CurrencyThreshold.ts index 3a3300d..16340e5 100644 --- a/src/models/CurrencyThreshold.ts +++ b/src/models/CurrencyThreshold.ts @@ -60,12 +60,14 @@ export class CurrencyThreshold extends Base implements ICurrencyThreshold { price: number ): Promise { const threshold = this.thresholds[hours] ?? { + custom: undefined, lastUpdated: 0, price: 0 } threshold.lastUpdated = timestamp threshold.price = price this.thresholds[hours] = threshold + return (await this.save()) as CurrencyThreshold } } diff --git a/src/models/User.ts b/src/models/User.ts index 6b9c387..363ebc7 100644 --- a/src/models/User.ts +++ b/src/models/User.ts @@ -40,14 +40,12 @@ export class User extends Base implements ReturnType { public devices: ReturnType public notifications: ReturnType - // @ts-expect-error constructor(...args) { super(...args) - // @ts-expect-error - if (!this.devices) this.devices = {} + if (this.devices == null) this.devices = {} // @ts-expect-error - if (!this.notifications) { + if (this.notifications == null) { this.notifications = { enabled: true, currencyCodes: {} diff --git a/src/models/User/views.ts b/src/models/User/views.ts index 9a8e2ed..c663c0f 100644 --- a/src/models/User/views.ts +++ b/src/models/User/views.ts @@ -2,7 +2,6 @@ declare function emit(...args: any[]): void export const views = { filter: { - // @ts-expect-error byCurrency(doc) { var notifs = doc.notifications if (notifs && notifs.enabled && notifs.currencyCodes) { diff --git a/src/models/base.ts b/src/models/base.ts index f5ec01e..4b5aeb7 100644 --- a/src/models/base.ts +++ b/src/models/base.ts @@ -26,21 +26,19 @@ export class Base implements ReturnType { return new Proxy(this, { set(target: Base, key: PropertyKey, value: any): any { - // @ts-expect-error return key in target ? (target[key] = value) : target.set(key, value) }, get(target: Base, key: PropertyKey): any { - // @ts-expect-error return key in target ? target[key] : target.get(key) } }) } - public validate() { + public validate(): void { ;(this.constructor as typeof Base).asType(this.dataValues) } - public processAPIResponse(response: Nano.DocumentInsertResponse) { + public processAPIResponse(response: Nano.DocumentInsertResponse): void { if (response.ok === true) { this._id = response.id this._rev = response.rev @@ -106,7 +104,6 @@ export class Base implements ReturnType { } public get(key: PropertyKey): any { - // @ts-expect-error return this.dataValues[key] } @@ -115,12 +112,10 @@ export class Base implements ReturnType { for (const prop in key) { // eslint-disable-next-line no-prototype-builtins if (key.hasOwnProperty(prop)) { - // @ts-expect-error this.dataValues[prop] = key[prop] } } } else { - // @ts-expect-error this.dataValues[key] = value } diff --git a/src/publishers/push.ts b/src/publishers/push.ts new file mode 100644 index 0000000..585f8dd --- /dev/null +++ b/src/publishers/push.ts @@ -0,0 +1,169 @@ +/** + * A push publisher subscribes to a view that contains all tasks whose + * arrays of {@link ActionEffect}s are all marked as completed. The + * publisher's job is to push notifications to devices based on the + * completed tasks. + */ + +import { viewToStream } from 'edge-server-tools' + +import { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + tasksPublishing, + updateInProgress +} from '../database/views/couch-tasks' +import { + createNotificationManager, + sendNotification +} from '../NotificationManager' +import { asPushActionData } from '../types/task/ActionData' +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { ActionEffect } from '../types/task/ActionEffect' +import { Task } from '../types/task/Task' +import { + asTaskDoc, + dbTasks, + logger, + TaskDoc, + wrappedDeleteFromDb +} from '../utils/dbUtils' + +/** Used in the retry mechnism for the publisher */ +const RETRY_LIMIT = 5 + +/** + * Begins listening to the 'tasks_publishing' view defined in + * {@link tasksPublishing}. For every new task document received, the + * publisher checks if the action is in progress. If it is, skip the + * processing. If it is not, the publisher will pick up the task by + * executing the push notification action. + * + * If the action is marked as repeatable, the publisher will then mark + * all {@link ActionEffect}s as completed so that 'task_listening' + * view can pick the task up again for processing. + * + * @returns {Promise} 0 if the connection is closed. + */ +export const runPushPublisher = async (): Promise => { + for await (const doc of viewToStream(async params => + Promise.resolve( + dbTasks.view('tasks_publishing', 'tasks_publishing', params) + ) + )) { + const clean: TaskDoc = asTaskDoc(doc) + const currentTask = clean.doc + if (!canExecute(currentTask)) continue + + // Set the action of the task as in progress + // If this process fails, we stop processing the current task + await signalActionStarted(currentTask) + if (currentTask.action.inProgress === false) continue + + // Send notification to the devices + await handlePushNotification(currentTask) + + // Perform chores after the notification has been sent + await handleActionAfterPushingNotification(currentTask) + + // Set the action of the task as not in progress + await finishCurrentTaskAction(currentTask) + } + return 0 +} + +// ----------------------------------------------------------------------------- +// Helper functions +// ----------------------------------------------------------------------------- + +/** + * Determines if the current task from the view is eliglbe for pushing + * notfications to devices. + * @returns Whether the task is eligible for pushing notifications. + */ +const canExecute = (task: Task): boolean => { + return ( + task.action.inProgress != null && + task.action.type === 'push' && + task.action.repeat != null && + task.action.inProgress === false + ) +} + +/** + * By setting the action as in progress, and update that change in the + * database, other publishers will not pick up the task. + * + * If the update fails, we stop processing the current task by setting + * its inProgress flag to false. The {@link execute} function will + * skip this task. + */ +const signalActionStarted = async (task: Task): Promise => { + task.action.inProgress = true + await updateInProgress(task, `${task.userId}:${task.taskId}`).catch(_ => { + task.action.inProgress = false + }) +} + +/** + * Prepares and sends a push notification to the devices identified by tokenIds. + */ +const handlePushNotification = async (task: Task): Promise => { + const { apiKey, title, body, tokenIds } = asPushActionData(task.action.data) + const notificationManager = await createNotificationManager(apiKey) + await sendNotification( + notificationManager, + title, + body, + tokenIds, + task.action.data.additionalData ?? {} + ) +} + +/** + * Some actions are repeatable. If the action is repeatable, we mark all + * {@link ActionEffect}s as completed so that 'task_listening' view + * can pick the task up again for processing. + * + * Otherwise, we delete the task from the database. + */ +const handleActionAfterPushingNotification = async ( + task: Task +): Promise => { + if (task.action.repeat === true) { + // Reset all action effects as incomplete + task.actionEffects.forEach(actionEffect => { + actionEffect.completed = false + }) + } else { + await wrappedDeleteFromDb([task.taskId], task.userId) + } +} + +/** + * Setting the action as not in progress, and update that change in the + * database. A retry mechinism is used to minimize the chance for + * leaving a task whose inProgress flag is always true, which prevents + * it from being ever picked up by publishers. + */ +const finishCurrentTaskAction = async (task: Task): Promise => { + // If not a repeatable action, that means the task has been deleted + // from the db. Do nothing. + if (task.action.repeat === false) return + + var currentRetry = 0 + + // Use a while loop to implement a retry mechanism + while (true) { + try { + task.action.inProgress = false + await updateInProgress(task, `${task.userId}:${task.taskId}`) + break + } catch (e) { + if (currentRetry++ > RETRY_LIMIT) { + logger(`Failed to update inProgress flag after ${RETRY_LIMIT} retries`) + break + } + logger(e) + } + } +} diff --git a/src/api/server.ts b/src/server.ts similarity index 89% rename from src/api/server.ts rename to src/server.ts index 8b48ca3..e52e7fc 100644 --- a/src/api/server.ts +++ b/src/server.ts @@ -1,10 +1,10 @@ import bodyParser from 'body-parser' import compression from 'compression' import cors from 'cors' -import express, { type RequestHandler } from 'express' +import express from 'express' import morgan from 'morgan' -import { asServerConfig } from '../config' +import { asServerConfig } from './serverConfig' const BodyParseError = { message: 'error parsing body data', @@ -33,8 +33,8 @@ export const createServer = ( app.use(compressionHandler) // Create throttled slack poster // Set local app params - app.set('httpPort', config.httpPort) - app.set('httpHost', config.httpHost) + app.set('httpPort', config.listenPort) + app.set('httpHost', config.listenHost) // Morgan Logging app.use(morgan(MorganTemplate)) // configure app to use bodyParser() and return 400 error if body is not json diff --git a/src/serverConfig.ts b/src/serverConfig.ts index 23d5ebd..14aae8b 100644 --- a/src/serverConfig.ts +++ b/src/serverConfig.ts @@ -5,7 +5,7 @@ import { asNumber, asObject, asOptional, asString } from 'cleaners' * Configures the server process as a whole, * such as where to listen and how to talk to the database. */ -const asServerConfig = asObject({ +export const asServerConfig = asObject({ // HTTP server options: listenHost: asOptional(asString, '127.0.0.1'), listenPort: asOptional(asNumber, 8008), diff --git a/src/types/request-types.ts b/src/types/http/request-types.ts similarity index 86% rename from src/types/request-types.ts rename to src/types/http/request-types.ts index c44a33b..0889b88 100644 --- a/src/types/request-types.ts +++ b/src/types/http/request-types.ts @@ -1,6 +1,6 @@ import { ExpressRequest } from 'serverlet/express' -import { ApiKey } from '../models' +import { ApiKey } from '../../models' export interface ExtendedRequest extends ExpressRequest { readonly body: any diff --git a/src/types/response-types.ts b/src/types/http/response-types.ts similarity index 100% rename from src/types/response-types.ts rename to src/types/http/response-types.ts diff --git a/src/types/task/Action.ts b/src/types/task/Action.ts new file mode 100644 index 0000000..8d0241a --- /dev/null +++ b/src/types/task/Action.ts @@ -0,0 +1,54 @@ +// ------------------------------------------------------------------- +// Type definitions +// ------------------------------------------------------------------- + +import { asBoolean, asObject, asOptional, asValue, Cleaner } from 'cleaners' + +import { ActionData, asActionData } from './ActionData' + +/** + * Describes types of action to be done by some service. Some properties + * are optional because certain types of actions do not require the + * optional properties. + */ +export interface Action { + /** + * The type of the action. + * - 'push': An action for pushing a notification to a device. + * - 'broadcast-tx': An action for broadcasting transactions to a + * network provider such as Blockbook. + * - 'client': // TODO: Add description + */ + type: 'push' | 'broadcast-tx' | 'client' + + /** + * If true, the task will be reused, otherwise, the task will be + * deleted after the action is completed. + */ + repeat?: boolean + + /** + * Mutex implementation to prevent race conditions. + */ + inProgress?: boolean + + /** + * Additional payload for consumption. For 'push' action type, data + * must contain apiKey, body, message, and tokenIds to send + * notifications. + * @see {@link ApiKey} + * @see {@link NotificationManager.init} + * @see {@link NotificationManager.send} + */ + data: ActionData +} + +// ------------------------------------------------------------------- +// Cleaners definitions +// ------------------------------------------------------------------- +export const asAction: Cleaner = asObject({ + type: asValue('push', 'broadcast-tx', 'client'), + repeat: asOptional(asBoolean), + inProgress: asOptional(asBoolean), + data: asActionData +}) diff --git a/src/types/task/ActionData.ts b/src/types/task/ActionData.ts new file mode 100644 index 0000000..ea5d4d4 --- /dev/null +++ b/src/types/task/ActionData.ts @@ -0,0 +1,69 @@ +// ------------------------------------------------------------------- +// Type definitions +// ------------------------------------------------------------------- + +import { + asArray, + asEither, + asObject, + asOptional, + asString, + Cleaner +} from 'cleaners' + +import { ApiKey } from '../../models' + +export interface GeneralActionData { + additionalData?: Object +} + +export interface PushActionData extends GeneralActionData { + apiKey: ApiKey | string + title: string + body: string + tokenIds: string[] +} + +export interface BroadcastTxActionData extends GeneralActionData { + SOMETHING: string +} + +export interface ClientActionData extends GeneralActionData { + SOMETHING: string +} + +export type ActionData = + | PushActionData + | BroadcastTxActionData + | ClientActionData + +// ------------------------------------------------------------------- +// Cleaners definitions +// ------------------------------------------------------------------- + +export const asGeneralActionData: Cleaner = asObject({ + additionalData: asOptional(asObject) +}) + +export const asPushActionData: Cleaner = asObject({ + apiKey: asString, + title: asString, + body: asString, + tokenIds: asArray(asString) +}) + +export const asBroadcastTxActionData: Cleaner = asObject( + { + SOMETHING: asString + } +) + +export const asClientActionData: Cleaner = asObject({ + SOMETHING: asString +}) + +export const asActionData: Cleaner = asEither( + asPushActionData, + asBroadcastTxActionData, + asClientActionData +) diff --git a/src/types/task/ActionEffect.ts b/src/types/task/ActionEffect.ts new file mode 100644 index 0000000..b774f0f --- /dev/null +++ b/src/types/task/ActionEffect.ts @@ -0,0 +1,82 @@ +import { + asBoolean, + asEither, + asNumber, + asObject, + asOptional, + asString, + asValue, + Cleaner +} from 'cleaners' + +// ------------------------------------------------------------------- +// Type definitions +// ------------------------------------------------------------------- +export type ActionEffect = + | { + type: 'balance' + completed: boolean + params: { + address: string + aboveAmount?: string + belowAmount?: string + contractAddress?: string + network: string + } + } + | { + type: 'tx-confs' + completed: boolean + params: { + confirmations: number + txId: string + network: string + } + } + | { + type: 'price' + completed: boolean + params: { + aboveRate?: string + belowRate?: string + currencyPair: string + network: string + } + } + +// ------------------------------------------------------------------- +// Cleaners definitions +// ------------------------------------------------------------------- + +export const asActionEffect: Cleaner = asEither( + asObject({ + type: asValue('balance'), + completed: asBoolean, + params: asObject({ + address: asString, + aboveAmount: asOptional(asString), + belowAmount: asOptional(asString), + contractAddress: asOptional(asString), + network: asString + }) + }), + asObject({ + type: asValue('tx-confs'), + completed: asBoolean, + params: asObject({ + confirmations: asNumber, + txId: asString, + network: asString + }) + }), + asObject({ + type: asValue('price'), + completed: asBoolean, + params: asObject({ + aboveRate: asOptional(asString), + belowRate: asOptional(asString), + currencyPair: asString, + network: asString + }) + }) +) diff --git a/src/types/task/Task.ts b/src/types/task/Task.ts new file mode 100644 index 0000000..7f843a5 --- /dev/null +++ b/src/types/task/Task.ts @@ -0,0 +1,33 @@ +import { asArray, asObject, asString, Cleaner } from 'cleaners' + +import { Action, asAction } from './Action' +import { ActionEffect, asActionEffect } from './ActionEffect' + +// ------------------------------------------------------------------- +// Type definitions +// ------------------------------------------------------------------- + +/** + * Describes a task that can be stored in the `db_tasks` database. + * + * `taskId` and `userId` are required to construct the `_id` of the + * couchDB document. The `_id` is used to partition the documents by + * user for performance and security reasons. + */ +export interface Task { + taskId: string + userId: string + actionEffects: ActionEffect[] + action: Action +} + +// ------------------------------------------------------------------- +// Cleaners definitions +// ------------------------------------------------------------------- + +export const asTask: Cleaner = asObject({ + taskId: asString, + userId: asString, + actionEffects: asArray(asActionEffect), + action: asAction +}) diff --git a/src/utils/dbUtils.ts b/src/utils/dbUtils.ts index f995c5e..a5235b1 100644 --- a/src/utils/dbUtils.ts +++ b/src/utils/dbUtils.ts @@ -1,55 +1,37 @@ -import { asArray, asMaybe, asObject, asString, asUnknown } from 'cleaners' +import { Cleaner } from 'cleaners' +import { asCouchDoc, CouchDoc } from 'edge-server-tools' import nano from 'nano' -import { config } from './../config' - -export interface DbDoc - extends nano.IdentifiedDocument, - nano.MaybeRevisionedDocument { - taskId: string - userId: string - triggers: any[] - action: any -} - -export const asDbDoc = (raw: any): DbDoc => { - return { - ...asObject({ - taskId: asString, - userId: asString, - triggers: asArray(asUnknown), - action: asUnknown, - _id: asString - })(raw), - ...asObject(asMaybe(asString))(raw) - } -} - -const { couchUri } = config +import { asTask, Task } from '../types/task/Task' +import { serverConfig } from './../serverConfig' +const { couchUri } = serverConfig const nanoDb = nano(couchUri) -const dbTasks: nano.DocumentScope = nanoDb.db.use('db_tasks') // ------------------------------------------------------------------------------ -// Public API +// Public APIs for the 'db_tasks' database // ------------------------------------------------------------------------------ +export type TaskDoc = CouchDoc +export const asTaskDoc: Cleaner> = asCouchDoc(asTask) +export const dbTasks: nano.DocumentScope = nanoDb.db.use('db_tasks') -export const wrappedSaveToDb = (docs: DbDoc[]): void => saveToDb(dbTasks, docs) +export const wrappedSaveToDb = (docs: TaskDoc[]): void => + saveToDb(dbTasks, docs) export const wrappedGetFromDb = async ( keys: string[], userId: string -): Promise => getFromDb(dbTasks, keys, userId) +): Promise => getFromDb(dbTasks, keys, userId, asTaskDoc) export const wrappedDeleteFromDb = async ( keys: string[], userId: string ): Promise => deleteFromDb(dbTasks, keys, userId) // ------------------------------------------------------------------------------ -// Public Helpers +// Public Helpers - Agnostic of the database // ------------------------------------------------------------------------------ -export const saveToDb = ( - db: nano.DocumentScope, - docs: DbDoc[] +export const saveToDb = ( + db: nano.DocumentScope>, + docs: Array> ): void => { if (docs.length === 0) return db.bulk({ docs }) @@ -59,16 +41,18 @@ export const saveToDb = ( .catch(logger) } -export const deleteFromDb = async ( - db: nano.DocumentScope, +export const deleteFromDb = async ( + db: nano.DocumentScope>, keys: string[], userId: string ): Promise => { - const docs = await getFromDb(db, keys, userId) + // TODO: NOT SURE HOW TO HANDLE THE TYPE ERROR. SOMEONE HELP. + // @ts-ignore + const docs = await getFromDb(db, keys, userId, asTaskDoc) const docsToDelete: any[] = [] docs.forEach(element => { - docsToDelete.push({ _id: element._id, _deleted: true, _rev: element._rev }) + docsToDelete.push({ _id: element.id, _deleted: true, _rev: element.rev }) }) db.bulk({ docs: docsToDelete }) @@ -78,21 +62,22 @@ export const deleteFromDb = async ( .catch(logger) } -export const getFromDb = async ( - db: nano.DocumentScope, +export const getFromDb = async ( + db: nano.DocumentScope>, keys: string[], - userId: string -): Promise => { + userId: string, + cleaner: Cleaner> +): Promise>> => { // Grab existing db data for requested dates const response = await db.partitionedList(userId).catch(logger) - if (response == null) return [] + if (response == null || !(response instanceof Object)) return [] return response.rows .filter(element => !('error' in element) && element.doc != null) .filter( element => keys.length === 0 || keys.includes(element.id.split(':')[1]) ) .map(({ doc }) => doc) - .map(asDbDoc) + .map(cleaner) } export const logger = (...args: any): void => { @@ -106,6 +91,19 @@ export const logger = (...args: any): void => { console.log(result) } +/** + * Convert a {@link Task} object into a {@link TaskDoc} object that + * implements {@link CouchDoc}. + * @param doc - A {@link Task} object. + * @returns {TaskDoc} - A {@link TaskDoc} object wrapping `doc`. + */ +export const packChange = (doc: T, id: string): CouchDoc => { + return { + id: id, + doc: doc + } +} + // ------------------------------------------------------------------------------ // Private Helpers // ------------------------------------------------------------------------------ diff --git a/tsconfig.json b/tsconfig.json index a10b6ac..fa71ccc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,8 +7,7 @@ "moduleResolution": "node", "resolveJsonModule": true, "noImplicitAny": false, - "noEmit": true, "strict": true } -} +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 45382d8..46d3b97 100644 --- a/yarn.lock +++ b/yarn.lock @@ -327,11 +327,9 @@ "@types/node" "*" "@types/cors@^2.8.7": - version "2.8.7" - resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.7.tgz#ab2f47f1cba93bce27dfd3639b006cc0e5600889" - integrity sha512-sOdDRU3oRS7LBNTIqwDkPJyq0lpHYcbMTt0TrjzsXbk/e37hcLTH6eZX7CdbDeN0yJJvzw9hFBZkbtCSbk/jAQ== - dependencies: - "@types/express" "*" + version "2.8.12" + resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.12.tgz#6b2c510a7ad7039e98e7b8d3d6598f4359e5c080" + integrity sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw== "@types/eslint-visitor-keys@^1.0.0": version "1.0.0" @@ -347,13 +345,22 @@ "@types/qs" "*" "@types/range-parser" "*" -"@types/express@*", "@types/express@^4.17.8": - version "4.17.8" - resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.8.tgz#3df4293293317e61c60137d273a2e96cd8d5f27a" - integrity sha512-wLhcKh3PMlyA2cNAB9sjM1BntnhPMiM0JOBwPBqttjHev2428MLEB4AYVN+d8s2iyCVZac+o41Pflm/ZH5vLXQ== +"@types/express-serve-static-core@^4.17.18": + version "4.17.29" + resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.29.tgz#2a1795ea8e9e9c91b4a4bbe475034b20c1ec711c" + integrity sha512-uMd++6dMKS32EOuw1Uli3e3BPgdLIXmezcfHv7N4c1s3gkhikBplORPpMq3fuWkxncZN1reb16d5n8yhQ80x7Q== + dependencies: + "@types/node" "*" + "@types/qs" "*" + "@types/range-parser" "*" + +"@types/express@^4.17.8": + version "4.17.13" + resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.13.tgz#a76e2995728999bab51a33fabce1d705a3709034" + integrity sha512-6bSZTPaTIACxn48l50SR+axgrqm6qXFIxrdAKaG6PaJk3+zuUr35hBlgT7vOmJcum+OEaIBLtHV/qloEAFITeA== dependencies: "@types/body-parser" "*" - "@types/express-serve-static-core" "*" + "@types/express-serve-static-core" "^4.17.18" "@types/qs" "*" "@types/serve-static" "*" @@ -385,9 +392,9 @@ integrity sha512-Jus9s4CDbqwocc5pOAnh8ShfrnMcPHuJYzVcSUU7lrh8Ni5HuIqX3oilL86p3dlTrk0LzHRCgA/GQ7uNCw6l2Q== "@types/node-schedule@^1.3.0": - version "1.3.0" - resolved "https://registry.yarnpkg.com/@types/node-schedule/-/node-schedule-1.3.0.tgz#100f69078e74d736d59433fc4634ff49d0a9142d" - integrity sha512-gjKmC9wFxn8laKYKwVP2iZvxeiA1DWUb6CXAYXtoYBcDbiyMqxLn2sQq+8aaNc7Xr0p93Hf0O0VizdaEPUO0vA== + version "1.3.2" + resolved "https://registry.yarnpkg.com/@types/node-schedule/-/node-schedule-1.3.2.tgz#cc7e32c6795cbadc8de03d0e1f86311727375423" + integrity sha512-Y0CqdAr+lCpArT8CJJjJq4U2v8Bb5e7ru2nV/NhDdaptCMCRdOL3Y7tAhen39HluQMaIKWvPbDuiFBUQpg7Srw== dependencies: "@types/node" "*" @@ -402,9 +409,9 @@ integrity sha512-FX7mIFKfnGCfq10DGWNhfCNxhACEeqH5uulT6wRRA1KEt7zgLe0HdrAd9/QQkObDqp2Z0KEV3OAmNgs0lTx5tQ== "@types/node@^14.0.5": - version "14.11.1" - resolved "https://registry.yarnpkg.com/@types/node/-/node-14.11.1.tgz#56af902ad157e763f9ba63d671c39cda3193c835" - integrity sha512-oTQgnd0hblfLsJ6BvJzzSL+Inogp3lq9fGgqRkMB/ziKMgEUaFl801OncOzUmalfzt14N0oPHMK47ipl+wbTIw== + version "14.18.22" + resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.22.tgz#fd2a15dca290fc9ad565b672fde746191cd0c6e6" + integrity sha512-qzaYbXVzin6EPjghf/hTdIbnVW1ErMx8rPzwRNJhlbyJhu2SyqlvjGOY/tbUt6VFyzg56lROcOeSQRInpt63Yw== "@types/node@^8.10.59": version "8.10.61" @@ -600,11 +607,6 @@ any-promise@^1.0.0: resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f" integrity sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A== -arg@^4.1.0: - version "4.1.3" - resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" - integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== - argparse@^1.0.7: version "1.0.10" resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" @@ -695,7 +697,7 @@ axios-cookiejar-support@^1.0.1: is-redirect "^1.0.0" pify "^5.0.0" -axios@^0.21.2: +axios@^0.21.1, axios@^0.21.2: version "0.21.4" resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.4.tgz#c67b90dc0568e5c1cf2b0b858c43ba28e2eda575" integrity sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg== @@ -731,7 +733,7 @@ bignumber.js@^7.0.0: resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-7.2.1.tgz#80c048759d826800807c4bfd521e50edbba57a5f" integrity sha512-S4XzBk5sMB+Rcb/LNcpzXr57VRTxgAvaAEDAl1AwRx27j00hT84O6OkteE7u8UB3NuaaygCRrEpqox4uDOrbdQ== -body-parser@1.19.0, body-parser@^1.19.0: +body-parser@1.19.0: version "1.19.0" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a" integrity sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw== @@ -747,6 +749,24 @@ body-parser@1.19.0, body-parser@^1.19.0: raw-body "2.4.0" type-is "~1.6.17" +body-parser@^1.19.0: + version "1.20.0" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.0.tgz#3de69bd89011c11573d7bfee6a64f11b6bd27cc5" + integrity sha512-DfJ+q6EPcGKZD1QWUjSpqp+Q7bDQTsQIF4zfUAtZ6qk+H/3/QRhg9CEp39ss+/T2vw0+HaidC0ecJj/DRLIaKg== + dependencies: + bytes "3.1.2" + content-type "~1.0.4" + debug "2.6.9" + depd "2.0.0" + destroy "1.2.0" + http-errors "2.0.0" + iconv-lite "0.4.24" + on-finished "2.4.1" + qs "6.10.3" + raw-body "2.5.1" + type-is "~1.6.18" + unpipe "1.0.0" + brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" @@ -782,6 +802,11 @@ bytes@3.1.0: resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6" integrity sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg== +bytes@3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" + integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== + call-bind@^1.0.0, call-bind@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c" @@ -826,7 +851,7 @@ cleaner-config@^0.1.8: minimist "^1.2.5" sucrase "^3.17.1" -cleaners@^0.3.12, cleaners@^0.3.8: +cleaners@^0.3.11, cleaners@^0.3.12, cleaners@^0.3.8: version "0.3.12" resolved "https://registry.yarnpkg.com/cleaners/-/cleaners-0.3.12.tgz#0e99ef2460a59ed87550a60bdfc441b1696ff9cb" integrity sha512-bK7IvvYyhfy30S3VKmWi/YVWp0MUH1pEYfdtbjCpQiIzy8gmP9GCXv6AVZENDHbpcRHqMnZONs3ieyJMh3zvVw== @@ -991,6 +1016,14 @@ cosmiconfig@^7.0.0: path-type "^4.0.0" yaml "^1.10.0" +cron-parser@^2.18.0: + version "2.18.0" + resolved "https://registry.yarnpkg.com/cron-parser/-/cron-parser-2.18.0.tgz#de1bb0ad528c815548371993f81a54e5a089edcf" + integrity sha512-s4odpheTyydAbTBQepsqd2rNWGa2iV3cyo8g7zbI2QQYGLVsfbhmwukayS1XHppe02Oy1fg7mg6xoaraVJeEcg== + dependencies: + is-nan "^1.3.0" + moment-timezone "^0.5.31" + cross-spawn@^6.0.5: version "6.0.5" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" @@ -1127,15 +1160,20 @@ degenerator@^1.0.4: escodegen "1.x.x" esprima "3.x.x" +depd@2.0.0, depd@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" + integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== + depd@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak= -depd@~2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" - integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== +destroy@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" + integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== destroy@~1.0.4: version "1.0.4" @@ -1197,10 +1235,10 @@ ecdsa-sig-formatter@1.0.11, ecdsa-sig-formatter@^1.0.11: dependencies: safe-buffer "^5.0.1" -edge-server-tools@^0.2.11: - version "0.2.11" - resolved "https://registry.yarnpkg.com/edge-server-tools/-/edge-server-tools-0.2.11.tgz#802310ba12d09dc45d359338026dd68861e28ba6" - integrity sha512-dLvyD0TSZM30VsXkAV8nH3Xq7hA3NOUFnhYehCsVLe4BBt0WHjgwyB460xglxrWHx7HzYBKcED4o98rFiQ0ihg== +edge-server-tools@^0.2.13: + version "0.2.13" + resolved "https://registry.yarnpkg.com/edge-server-tools/-/edge-server-tools-0.2.13.tgz#d7686488e5a4915203c0220949758efa110b681a" + integrity sha512-FXLdAWVT/XGEM3PehENVVyXuC0vdS5e4KdchW3fpZFp4Wb8eAr3xmud7VhT4pyXIXsywS+nWiW5MhGQXpl6kdw== dependencies: cleaners "^0.3.11" nano "^9.0.4" @@ -2061,6 +2099,17 @@ http-errors@1.7.3, http-errors@~1.7.2: statuses ">= 1.5.0 < 2" toidentifier "1.0.0" +http-errors@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" + integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== + dependencies: + depd "2.0.0" + inherits "2.0.4" + setprototypeof "1.2.0" + statuses "2.0.1" + toidentifier "1.0.1" + http-parser-js@>=0.5.1: version "0.5.2" resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.5.2.tgz#da2e31d237b393aae72ace43882dd7e270a8ff77" @@ -2260,6 +2309,14 @@ is-map@^2.0.1: resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.1.tgz#520dafc4307bb8ebc33b813de5ce7c9400d644a1" integrity sha512-T/S49scO8plUiAOA2DBTBG3JHpn1yiw0kRp6dgiZ0v2/6twi5eiB0rHtHFH9ZIrvlWc6+4O+m4zg5+Z833aXgw== +is-nan@^1.3.0: + version "1.3.2" + resolved "https://registry.yarnpkg.com/is-nan/-/is-nan-1.3.2.tgz#043a54adea31748b55b6cd4e09aadafa69bd9e1d" + integrity sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w== + dependencies: + call-bind "^1.0.0" + define-properties "^1.1.3" + is-negative-zero@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.2.tgz#7bf6f03a28003b8b3965de3ac26f664d765f3150" @@ -2691,6 +2748,11 @@ log-update@^4.0.0: slice-ansi "^4.0.0" wrap-ansi "^6.2.0" +long-timeout@0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/long-timeout/-/long-timeout-0.1.1.tgz#9721d788b47e0bcb5a24c2e2bee1a0da55dab514" + integrity sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w== + long@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/long/-/long-4.0.0.tgz#9a7b71cfb7d361a194ea555241c92f7468d5bf28" @@ -2864,6 +2926,17 @@ nano@10.0.0: qs "^6.10.3" tough-cookie "^4.0.0" +nano@^9.0.4: + version "9.0.5" + resolved "https://registry.yarnpkg.com/nano/-/nano-9.0.5.tgz#2b767819f612907a3ac09b21f2929d4097407262" + integrity sha512-fEAhwAdXh4hDDnC8cYJtW6D8ivOmpvFAqT90+zEuQREpRkzA/mJPcI4EKv15JUdajaqiLTXNoKK6PaRF+/06DQ== + dependencies: + "@types/tough-cookie" "^4.0.0" + axios "^0.21.1" + axios-cookiejar-support "^1.0.1" + qs "^6.9.4" + tough-cookie "^4.0.0" + natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" @@ -2906,6 +2979,15 @@ node-forge@^0.9.0: resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.9.1.tgz#775368e6846558ab6676858a4d8c6e8d16c677b5" integrity sha512-G6RlQt5Sb4GMBzXvhfkeFmbqR6MzhtnT7VTHuLadjkii3rdYHNdw0m8zA4BTxVIh68FicCQ2NSUANpsqkr9jvQ== +node-schedule@^1.3.2: + version "1.3.3" + resolved "https://registry.yarnpkg.com/node-schedule/-/node-schedule-1.3.3.tgz#f8e01c5fb9597f09ecf9c4c25d6938e5e7a06f48" + integrity sha512-uF9Ubn6luOPrcAYKfsXWimcJ1tPFtQ8I85wb4T3NgJQrXazEzojcFZVk46ZlLHby3eEJChgkV/0T689IsXh2Gw== + dependencies: + cron-parser "^2.18.0" + long-timeout "0.1.1" + sorted-array-functions "^1.3.0" + normalize-package-data@^2.3.2: version "2.5.0" resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8" @@ -3000,6 +3082,13 @@ object.values@^1.1.5: define-properties "^1.1.3" es-abstract "^1.19.1" +on-finished@2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" + integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== + dependencies: + ee-first "1.1.1" + on-finished@~2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" @@ -3327,12 +3416,19 @@ punycode@^2.1.0, punycode@^2.1.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== +qs@6.10.3: + version "6.10.3" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.10.3.tgz#d6cde1b2ffca87b5aa57889816c5f81535e22e8e" + integrity sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ== + dependencies: + side-channel "^1.0.4" + qs@6.7.0: version "6.7.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc" integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ== -qs@^6.10.3: +qs@^6.10.3, qs@^6.9.4: version "6.11.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a" integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q== @@ -3354,6 +3450,16 @@ raw-body@2.4.0: iconv-lite "0.4.24" unpipe "1.0.0" +raw-body@2.5.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.1.tgz#fe1b1628b181b700215e5fd42389f98b71392857" + integrity sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig== + dependencies: + bytes "3.1.2" + http-errors "2.0.0" + iconv-lite "0.4.24" + unpipe "1.0.0" + raw-body@^2.2.0: version "2.4.1" resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.1.tgz#30ac82f98bb5ae8c152e67149dac8d55153b168c" @@ -3573,6 +3679,11 @@ setprototypeof@1.1.1: resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.1.tgz#7e95acb24aa92f5885e0abef5ba131330d4ae683" integrity sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw== +setprototypeof@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" + integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== + shebang-command@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" @@ -3673,6 +3784,11 @@ socks@~2.3.2: ip "1.1.5" smart-buffer "^4.1.0" +sorted-array-functions@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/sorted-array-functions/-/sorted-array-functions-1.3.0.tgz#8605695563294dffb2c9796d602bd8459f7a0dd5" + integrity sha512-2sqgzeFlid6N4Z2fUQ1cvFmTOLRi/sEDzSQ0OKYchqgoPmQBVyM3959qYx3fpS6Esef80KjmpgPeEr028dP3OA== + source-map@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" @@ -3709,6 +3825,11 @@ sprintf-js@~1.0.2: resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= +statuses@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" + integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== + "statuses@>= 1.5.0 < 2", statuses@~1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" @@ -3861,7 +3982,7 @@ stubs@^3.0.0: resolved "https://registry.yarnpkg.com/stubs/-/stubs-3.0.0.tgz#e8d2ba1fa9c90570303c030b6900f7d5f89abe5b" integrity sha1-6NK6H6nJBXAwPAMLaQD31fiavls= -sucrase@^3.17.1: +sucrase@^3.17.1, sucrase@^3.21.0: version "3.23.0" resolved "https://registry.yarnpkg.com/sucrase/-/sucrase-3.23.0.tgz#2a7fa80a04f055fb2e95d2aead03fec1dba52838" integrity sha512-xgC1xboStzGhCnRywlBf/DLmkC+SkdAKqrNCDsxGrzM0phR5oUxoFKiQNrsc2D8wDdAm03iLbSZqjHDddo3IzQ== @@ -3970,6 +4091,11 @@ toidentifier@1.0.0: resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553" integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw== +toidentifier@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" + integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== + tough-cookie@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.0.0.tgz#d822234eeca882f991f0f908824ad2622ddbece4" @@ -3989,18 +4115,6 @@ ts-interface-checker@^0.1.9: resolved "https://registry.yarnpkg.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz#784fd3d679722bc103b1b4b8030bcddb5db2a699" integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA== -ts-node@^9.0.0: - version "9.1.1" - resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-9.1.1.tgz#51a9a450a3e959401bda5f004a72d54b936d376d" - integrity sha512-hPlt7ZACERQGf03M253ytLY3dHbGNGrAq9qIHWUY9XHYl1z7wYngSr3OQ5xmui8o2AaxsONxIzjafLUiWBo1Fg== - dependencies: - arg "^4.1.0" - create-require "^1.1.0" - diff "^4.0.1" - make-error "^1.1.1" - source-map-support "^0.5.17" - yn "3.1.1" - tsconfig-paths@^3.14.1: version "3.14.1" resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz#ba0734599e8ea36c862798e920bcf163277b137a"