forked from tsai/budibase
17 changed files with 302 additions and 233 deletions
@ -1,24 +0,0 @@ |
|||||
const userController = require("../../user") |
|
||||
|
|
||||
module.exports = async function createUser({ args, instanceId }) { |
|
||||
const ctx = { |
|
||||
params: { |
|
||||
instanceId, |
|
||||
}, |
|
||||
request: { |
|
||||
body: args.user, |
|
||||
}, |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
const response = await userController.create(ctx) |
|
||||
return { |
|
||||
user: response, |
|
||||
} |
|
||||
} catch (err) { |
|
||||
console.error(err) |
|
||||
return { |
|
||||
user: null, |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
@ -1,5 +0,0 @@ |
|||||
const wait = ms => new Promise(resolve => setTimeout(resolve, ms)) |
|
||||
|
|
||||
module.exports = async function delay({ args }) { |
|
||||
await wait(args.time) |
|
||||
} |
|
||||
@ -1,10 +0,0 @@ |
|||||
module.exports = async function filter({ args }) { |
|
||||
const { field, condition, value } = args |
|
||||
switch (condition) { |
|
||||
case "equals": |
|
||||
if (field !== value) return |
|
||||
break |
|
||||
default: |
|
||||
return |
|
||||
} |
|
||||
} |
|
||||
@ -1,29 +0,0 @@ |
|||||
const recordController = require("../../record") |
|
||||
|
|
||||
module.exports = async function saveRecord({ args, context }) { |
|
||||
const { model, ...record } = args.record |
|
||||
|
|
||||
const ctx = { |
|
||||
params: { |
|
||||
instanceId: context.instanceId, |
|
||||
modelId: model._id, |
|
||||
}, |
|
||||
request: { |
|
||||
body: record, |
|
||||
}, |
|
||||
user: { instanceId: context.instanceId }, |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
await recordController.save(ctx) |
|
||||
return { |
|
||||
record: ctx.body, |
|
||||
} |
|
||||
} catch (err) { |
|
||||
console.error(err) |
|
||||
return { |
|
||||
record: null, |
|
||||
error: err.message, |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
@ -1,26 +0,0 @@ |
|||||
const sgMail = require("@sendgrid/mail") |
|
||||
|
|
||||
sgMail.setApiKey(process.env.SENDGRID_API_KEY) |
|
||||
|
|
||||
module.exports = async function sendEmail({ args }) { |
|
||||
const msg = { |
|
||||
to: args.to, |
|
||||
from: args.from, |
|
||||
subject: args.subject, |
|
||||
text: args.text, |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
await sgMail.send(msg) |
|
||||
return { |
|
||||
success: true, |
|
||||
...args, |
|
||||
} |
|
||||
} catch (err) { |
|
||||
console.error(err) |
|
||||
return { |
|
||||
success: false, |
|
||||
error: err.message, |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
@ -1,33 +1,11 @@ |
|||||
const EventEmitter = require("events").EventEmitter |
const EventEmitter = require("events").EventEmitter |
||||
const CouchDB = require("../db") |
|
||||
const { Orchestrator, serverStrategy } = require("./workflow") |
|
||||
|
|
||||
const emitter = new EventEmitter() |
/** |
||||
|
* keeping event emitter in one central location as it might be used for things other than |
||||
async function executeRelevantWorkflows(event, eventType) { |
* workflows (what it was for originally) - having a central emitter will be useful in the |
||||
const db = new CouchDB(event.instanceId) |
* future. |
||||
const workflowsToTrigger = await db.query("database/by_workflow_trigger", { |
*/ |
||||
key: [eventType], |
|
||||
include_docs: true, |
|
||||
}) |
|
||||
|
|
||||
const workflows = workflowsToTrigger.rows.map(wf => wf.doc) |
|
||||
|
|
||||
// Create orchestrator
|
|
||||
const workflowOrchestrator = new Orchestrator() |
|
||||
workflowOrchestrator.strategy = serverStrategy |
|
||||
|
|
||||
for (let workflow of workflows) { |
const emitter = new EventEmitter() |
||||
workflowOrchestrator.execute(workflow, event) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
emitter.on("record:save", async function(event) { |
|
||||
await executeRelevantWorkflows(event, "record:save") |
|
||||
}) |
|
||||
|
|
||||
emitter.on("record:delete", async function(event) { |
|
||||
await executeRelevantWorkflows(event, "record:delete") |
|
||||
}) |
|
||||
|
|
||||
module.exports = emitter |
module.exports = emitter |
||||
|
|||||
@ -1,52 +0,0 @@ |
|||||
const mustache = require("mustache") |
|
||||
|
|
||||
/** |
|
||||
* The workflow orchestrator is a class responsible for executing workflows. |
|
||||
* It relies on the strategy pattern, which allows composable behaviour to be |
|
||||
* passed into its execute() function. This allows custom execution behaviour based |
|
||||
* on where the orchestrator is run. |
|
||||
* |
|
||||
*/ |
|
||||
exports.Orchestrator = class Orchestrator { |
|
||||
set strategy(strategy) { |
|
||||
this._strategy = strategy() |
|
||||
} |
|
||||
|
|
||||
async execute(workflow, context) { |
|
||||
if (workflow.live) { |
|
||||
this._strategy.run(workflow.definition, context) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
exports.serverStrategy = () => ({ |
|
||||
context: {}, |
|
||||
bindContextArgs: function(args) { |
|
||||
const mappedArgs = { ...args } |
|
||||
|
|
||||
// bind the workflow action args to the workflow context, if required
|
|
||||
for (let arg in args) { |
|
||||
const argValue = args[arg] |
|
||||
// We don't want to render mustache templates on non-strings
|
|
||||
if (typeof argValue !== "string") continue |
|
||||
|
|
||||
mappedArgs[arg] = mustache.render(argValue, { context: this.context }) |
|
||||
} |
|
||||
|
|
||||
return mappedArgs |
|
||||
}, |
|
||||
run: async function(workflow, context) { |
|
||||
for (let block of workflow.steps) { |
|
||||
const action = require(`../api/controllers/workflow/actions/${block.actionId}`) |
|
||||
const response = await action({ |
|
||||
args: this.bindContextArgs(block.args), |
|
||||
context, |
|
||||
}) |
|
||||
|
|
||||
this.context = { |
|
||||
...this.context, |
|
||||
[block.id]: response, |
|
||||
} |
|
||||
} |
|
||||
}, |
|
||||
}) |
|
||||
@ -0,0 +1,86 @@ |
|||||
|
const userController = require("../api/controllers/user") |
||||
|
const recordController = require("../api/controllers/record") |
||||
|
const sgMail = require("@sendgrid/mail") |
||||
|
|
||||
|
sgMail.setApiKey(process.env.SENDGRID_API_KEY) |
||||
|
|
||||
|
let BUILTIN_ACTIONS = { |
||||
|
CREATE_USER: async function({ args, instanceId }) { |
||||
|
const ctx = { |
||||
|
params: { |
||||
|
instanceId, |
||||
|
}, |
||||
|
request: { |
||||
|
body: args.user, |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
const response = await userController.create(ctx) |
||||
|
return { |
||||
|
user: response, |
||||
|
} |
||||
|
} catch (err) { |
||||
|
console.error(err) |
||||
|
return { |
||||
|
user: null, |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
SAVE_RECORD: async function({ args, context }) { |
||||
|
const { model, ...record } = args.record |
||||
|
|
||||
|
const ctx = { |
||||
|
params: { |
||||
|
instanceId: context.instanceId, |
||||
|
modelId: model._id, |
||||
|
}, |
||||
|
request: { |
||||
|
body: record, |
||||
|
}, |
||||
|
user: { instanceId: context.instanceId }, |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
await recordController.save(ctx) |
||||
|
return { |
||||
|
record: ctx.body, |
||||
|
} |
||||
|
} catch (err) { |
||||
|
console.error(err) |
||||
|
return { |
||||
|
record: null, |
||||
|
error: err.message, |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
SEND_EMAIL: async function({ args }) { |
||||
|
const msg = { |
||||
|
to: args.to, |
||||
|
from: args.from, |
||||
|
subject: args.subject, |
||||
|
text: args.text, |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
await sgMail.send(msg) |
||||
|
return { |
||||
|
success: true, |
||||
|
...args, |
||||
|
} |
||||
|
} catch (err) { |
||||
|
console.error(err) |
||||
|
return { |
||||
|
success: false, |
||||
|
error: err.message, |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
module.exports.getAction = async function(actionName) { |
||||
|
if (BUILTIN_ACTIONS[actionName] != null) { |
||||
|
return BUILTIN_ACTIONS[actionName] |
||||
|
} |
||||
|
// TODO: load async actions here
|
||||
|
} |
||||
@ -0,0 +1,67 @@ |
|||||
|
const mustache = require("mustache") |
||||
|
const actions = require("./actions") |
||||
|
const logic = require("./logic") |
||||
|
const triggers = require("./triggers") |
||||
|
|
||||
|
/** |
||||
|
* The workflow orchestrator is a class responsible for executing workflows. |
||||
|
* It relies on the strategy pattern, which allows composable behaviour to be |
||||
|
* passed into its execute() function. This allows custom execution behaviour based |
||||
|
* on where the orchestrator is run. |
||||
|
* |
||||
|
*/ |
||||
|
class Orchestrator { |
||||
|
constructor(workflow) { |
||||
|
this._context = {} |
||||
|
this._workflow = workflow |
||||
|
} |
||||
|
|
||||
|
async getStep(type, stepId) { |
||||
|
let step = null |
||||
|
if (type === "ACTION") { |
||||
|
step = await actions.getAction(stepId) |
||||
|
} else if (type === "LOGIC") { |
||||
|
step = logic.getLogic(stepId) |
||||
|
} |
||||
|
if (step == null) { |
||||
|
throw `Cannot find workflow step by name ${stepId}` |
||||
|
} |
||||
|
return step |
||||
|
} |
||||
|
|
||||
|
async execute(context) { |
||||
|
let workflow = this._workflow |
||||
|
if (!workflow.live) { |
||||
|
return |
||||
|
} |
||||
|
for (let block of workflow.steps) { |
||||
|
let step = this.getStep(block.type, block.stepId) |
||||
|
let args = { ...block.args } |
||||
|
// bind the workflow action args to the workflow context, if required
|
||||
|
for (let arg of Object.keys(args)) { |
||||
|
const argValue = args[arg] |
||||
|
// We don't want to render mustache templates on non-strings
|
||||
|
if (typeof argValue !== "string") continue |
||||
|
|
||||
|
args[arg] = mustache.render(argValue, { context: this._context }) |
||||
|
} |
||||
|
const response = await step({ |
||||
|
args, |
||||
|
context, |
||||
|
}) |
||||
|
|
||||
|
this._context = { |
||||
|
...this._context, |
||||
|
[block.id]: response, |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
module.exports.init = function() { |
||||
|
triggers.workflowQueue.process(async job => { |
||||
|
// Create orchestrator for each individual workflow (their own context)
|
||||
|
const workflowOrchestrator = new Orchestrator(job.data.workflow) |
||||
|
await workflowOrchestrator.execute(job.data.event) |
||||
|
}) |
||||
|
} |
||||
@ -0,0 +1,24 @@ |
|||||
|
const wait = ms => new Promise(resolve => setTimeout(resolve, ms)) |
||||
|
|
||||
|
let LOGIC = { |
||||
|
DELAY: async function delay({ args }) { |
||||
|
await wait(args.time) |
||||
|
}, |
||||
|
|
||||
|
FILTER: async function filter({ args }) { |
||||
|
const { field, condition, value } = args |
||||
|
switch (condition) { |
||||
|
case "equals": |
||||
|
if (field !== value) return |
||||
|
break |
||||
|
default: |
||||
|
return |
||||
|
} |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
module.exports.getLogic = function(logicName) { |
||||
|
if (LOGIC[logicName] != null) { |
||||
|
return LOGIC[logicName] |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,44 @@ |
|||||
|
let events = require("events") |
||||
|
|
||||
|
// Bull works with a Job wrapper around all messages that contains a lot more information about
|
||||
|
// the state of the message, implement this for the sake of maintaining API consistency
|
||||
|
function newJob(queue, message) { |
||||
|
return { |
||||
|
timestamp: Date.now(), |
||||
|
queue: queue, |
||||
|
data: message, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// designed to replicate Bull (https://github.com/OptimalBits/bull) in memory as a sort of mock
|
||||
|
class InMemoryQueue { |
||||
|
// opts is not used by this as there is no real use case when in memory, but is the same API as Bull
|
||||
|
constructor(name, opts) { |
||||
|
this._name = name |
||||
|
this._opts = opts |
||||
|
this._messages = [] |
||||
|
this._emitter = new events.EventEmitter() |
||||
|
} |
||||
|
|
||||
|
// same API as bull, provide a callback and it will respond when messages are available
|
||||
|
process(func) { |
||||
|
this._emitter.on("message", async () => { |
||||
|
if (this._messages.length <= 0) { |
||||
|
return |
||||
|
} |
||||
|
let msg = this._messages.shift() |
||||
|
let resp = func(msg) |
||||
|
if (resp.then != null) { |
||||
|
await resp |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
// simply puts a message to the queue and emits to the queue for processing
|
||||
|
add(msg) { |
||||
|
this._messages.push(newJob(this._name, msg)) |
||||
|
this._emitter.emit("message") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
module.exports = InMemoryQueue |
||||
@ -0,0 +1,32 @@ |
|||||
|
const CouchDB = require("../db") |
||||
|
const emitter = require("../events/index") |
||||
|
const InMemoryQueue = require("./queue/inMemoryQueue") |
||||
|
|
||||
|
let workflowQueue = new InMemoryQueue() |
||||
|
|
||||
|
async function queueRelevantWorkflows(event, eventType) { |
||||
|
const db = new CouchDB(event.instanceId) |
||||
|
const workflowsToTrigger = await db.query("database/by_workflow_trigger", { |
||||
|
key: [eventType], |
||||
|
include_docs: true, |
||||
|
}) |
||||
|
|
||||
|
const workflows = workflowsToTrigger.rows.map(wf => wf.doc) |
||||
|
for (let workflow of workflows) { |
||||
|
workflowQueue.add({ workflow, event }) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
emitter.on("record:save", async function(event) { |
||||
|
await queueRelevantWorkflows(event, "record:save") |
||||
|
}) |
||||
|
|
||||
|
emitter.on("record:delete", async function(event) { |
||||
|
await queueRelevantWorkflows(event, "record:delete") |
||||
|
}) |
||||
|
|
||||
|
module.exports.externalTrigger = async function(workflow, params) { |
||||
|
workflowQueue.add({ workflow, event: params }) |
||||
|
} |
||||
|
|
||||
|
module.exports.workflowQueue = workflowQueue |
||||
Loading…
Reference in new issue