@xeplr/workflow-engine 0.0.0-stage → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +182 -2
  3. package/db/xcfgSetup.js +8 -0
  4. package/index.js +290 -0
  5. package/lib/actionCatalog.js +197 -0
  6. package/lib/actions/jobChainSteps.js +79 -0
  7. package/lib/actions/jobRun.js +171 -0
  8. package/lib/actions/screenShow.js +71 -0
  9. package/lib/auth.js +19 -0
  10. package/lib/db.js +82 -0
  11. package/lib/envExposed.js +87 -0
  12. package/lib/flows.js +916 -0
  13. package/lib/jobChains.js +426 -0
  14. package/lib/workflowRunner.js +652 -0
  15. package/migrations/0003_workflows.sql +35 -0
  16. package/migrations/0004_workflow_steps.sql +79 -0
  17. package/migrations/0005_workflow_runs.sql +49 -0
  18. package/migrations/0006_workflow_step_runs.sql +42 -0
  19. package/migrations/0007_workflow_resume_keys.sql +42 -0
  20. package/migrations/0008_workflow_run_edges.sql +43 -0
  21. package/migrations/0009_workflow_steps_layout.sql +11 -0
  22. package/migrations/0010_workflow_steps_sample_output.sql +17 -0
  23. package/migrations/0011_workflow_steps_params.sql +27 -0
  24. package/migrations/0012_workflows_kind.sql +27 -0
  25. package/migrations/0013_workflows_key.sql +48 -0
  26. package/migrations/0014_workflows_trigger.sql +30 -0
  27. package/migrations-tenants/0001_companies.sql +23 -0
  28. package/migrations-tenants/0002_workspaces.sql +22 -0
  29. package/models/Company.js +82 -0
  30. package/models/Workflow.js +67 -0
  31. package/models/WorkflowResumeKey.js +32 -0
  32. package/models/WorkflowRun.js +64 -0
  33. package/models/WorkflowRunEdge.js +49 -0
  34. package/models/WorkflowStep.js +66 -0
  35. package/models/WorkflowStepRun.js +49 -0
  36. package/models/Workspace.js +75 -0
  37. package/models/index.js +25 -0
  38. package/package.json +47 -4
@@ -0,0 +1,197 @@
1
+ // WHAT THIS PRODUCT CAN BIND TOGETHER.
2
+ //
3
+ // @xeplr/actions ships the actions; this file decides which of them THIS app
4
+ // offers, and registers them once at startup (see bin/www). Whatever a workflow
5
+ // step turns out to be, it will name one of these — so the registry is
6
+ // populated before the first request rather than lazily.
7
+ //
8
+ // A deliberate WHITELIST rather than "register everything the package exports",
9
+ // for two reasons. Some built-ins are more dangerous than others —
10
+ // spawnProgram runs arbitrary executables — and offering one should be a
11
+ // decision somebody made, visible in a diff, not a side effect of upgrading a
12
+ // dependency. And several entries in that package are PLACEHOLDERS: modules
13
+ // with a comment saying the implementation is pending and nothing else in them.
14
+ // Registering one would put a name in the catalog, draw it in the step picker,
15
+ // and fail at the moment somebody relied on it.
16
+ //
17
+ // So the list below is names this app WANTS, and readiness is checked against
18
+ // the package rather than assumed. A name that is not ready yet stays in the
19
+ // list, out of the catalog, and lands the day the package implements it.
20
+ var actions = require('@xeplr/actions');
21
+ var xcfgSetup = require('../db/xcfgSetup');
22
+
23
+ // The keys on @xeplr/actions' `builtins`, not the action names — those are
24
+ // kebab-case and assigned by the modules themselves (`dbFetch` registers as
25
+ // `db-fetch`). Naming the export is what makes this list checkable.
26
+ var WANTED = [
27
+ 'dbFetch',
28
+ 'dbPush',
29
+ 'dbQuery',
30
+ // Calling a procedure is its own step now — it used to be reachable only by
31
+ // running a movement, which meant a procedure that returns nothing could not
32
+ // be run at all.
33
+ 'dbProcedure',
34
+ 'dbListTables',
35
+ 'dbListViews',
36
+ 'dbListProcedures',
37
+ 'dbListColumns',
38
+ 'fileMove',
39
+
40
+ // Email. The five inbound ones are what a workflow monitors a mailbox with;
41
+ // emailSend is also what a `wait` step uses to deliver its own resume link
42
+ // (see workflowRunner's mintResumeKey — the key is available to the step's
43
+ // own action as {resumeKey}).
44
+ //
45
+ // emailDelete is offered but worth knowing about: it is a permanent expunge,
46
+ // not a move to Trash. emailMove to a trash folder is the reversible one.
47
+ 'emailRead',
48
+ 'emailMove',
49
+ 'emailDelete',
50
+ 'emailDownloadEmail',
51
+ 'emailDownloadAttachments',
52
+ 'sendEmail'
53
+ ];
54
+
55
+ // THIS APP'S OWN ACTIONS — not from @xeplr/actions, and not placeholders.
56
+ //
57
+ // Glue between two products, which is exactly why it is not in the shared
58
+ // action library: @xeplr/actions must not learn what a job is. Registered the
59
+ // same way and appearing in the same catalog, so the step editor needs no
60
+ // notion that some actions come from here and some from there.
61
+ var LOCAL = [
62
+ require('./actions/jobRun')
63
+ ];
64
+
65
+ // REGISTERED BUT NOT OFFERED.
66
+ //
67
+ // An action a step can legitimately name, that no author should ever pick off
68
+ // a palette. The registry must have it — the engine resolves a step's
69
+ // actionName through the registry, so a step naming an unregistered action
70
+ // cannot run at all — but catalog() leaves it out, because the only thing that
71
+ // knows how to build one of these steps is the facade that owns them.
72
+ //
73
+ // `screen-show` is the case: a screens flow's steps are generated by
74
+ // lib/flows.js from a design held in another app, and one dropped by hand onto
75
+ // the ordinary workflow canvas would park its run forever — nothing outside
76
+ // that facade knows how to show the screen or how to resume it. Hiding it is
77
+ // not secrecy; it is not offering half of something.
78
+ var HIDDEN = [
79
+ require('./actions/screenShow'),
80
+ // A job chain's two steps, generated by lib/jobChains.js from a job's
81
+ // "After this job" rules — same reasoning as screen-show.
82
+ require('./actions/jobChainSteps').decide,
83
+ require('./actions/jobChainSteps').next
84
+ ];
85
+
86
+ var HIDDEN_NAMES = HIDDEN.map(function(def) { return def.name; });
87
+
88
+ // Needs the control-plane connection to record what moved where, so it is
89
+ // registered separately once xcfgSetup has resolved.
90
+ var WANTED_WITH_META = ['fileUpload'];
91
+
92
+ // DELIBERATELY ABSENT: spawnProgram. It runs arbitrary executables, which is a
93
+ // different kind of permission from "call an HTTP endpoint" — named here so
94
+ // that leaving it out reads as a decision rather than an oversight.
95
+
96
+ /** A module the registry will actually accept, as opposed to a placeholder. */
97
+ function isReady(def) {
98
+ return Boolean(def && typeof def.execute === 'function' && def.name);
99
+ }
100
+
101
+ function offer(key, def, registered, skipped) {
102
+ if (!isReady(def)) { skipped.push(key); return; }
103
+ actions.register(def);
104
+ registered.push(def.name);
105
+ }
106
+
107
+ /**
108
+ * Registers this app's actions and returns their names.
109
+ *
110
+ * Idempotent — @xeplr/actions' register() overwrites by name, so calling this
111
+ * twice leaves the registry in the same state rather than doubling it.
112
+ */
113
+ function registerAll() {
114
+ var registered = [];
115
+ var skipped = [];
116
+
117
+ WANTED.forEach(function(key) {
118
+ offer(key, actions.builtins[key], registered, skipped);
119
+ });
120
+
121
+ LOCAL.forEach(function(def) {
122
+ offer(def.name, def, registered, skipped);
123
+ });
124
+
125
+ // Same registration, no catalog entry — see HIDDEN.
126
+ HIDDEN.forEach(function(def) {
127
+ offer(def.name, def, registered, skipped);
128
+ });
129
+
130
+ WANTED_WITH_META.forEach(function(key) {
131
+ var entry = actions.builtins[key];
132
+ // Factory-shaped in some versions, a plain module in others. Both are
133
+ // handled rather than asserted, because which one it is is the package's
134
+ // business and not worth breaking a boot over.
135
+ //
136
+ // Guarded because reading xcfgSetup.metaStore THROWS when the
137
+ // control-plane connection was never established — which is a real state
138
+ // for an embedded host (see registerWorkflow, where xeplr_configs is
139
+ // best-effort). Losing one action to that is correct; losing the whole
140
+ // catalog, and with it every other action, is not.
141
+ try {
142
+ var def = typeof entry === 'function' ? entry({ metaKnex: xcfgSetup.metaStore }) : entry;
143
+ offer(key, def, registered, skipped);
144
+ } catch (err) {
145
+ console.warn('[actions] ' + key + ' could not be registered: ' + err.message);
146
+ skipped.push(key);
147
+ }
148
+ });
149
+
150
+ // Said out loud, once, at startup. A name silently missing from the picker is
151
+ // a bug somebody spends an afternoon on; a line in the boot log is not.
152
+ if (skipped.length) {
153
+ console.warn('[actions] not yet implemented in @xeplr/actions, so not offered: ' + skipped.join(', '));
154
+ }
155
+
156
+ return registered;
157
+ }
158
+
159
+ /**
160
+ * The catalog, as the UI needs it: name, description and the INPUT SCHEMA that
161
+ * describes what each one takes.
162
+ *
163
+ * The schema is the point. A step editor told separately what an action accepts
164
+ * is a second description of it, and the day the two disagree the form collects
165
+ * a field the action ignores. Sending the registry's own schema means the form
166
+ * is generated from the thing it is a form for.
167
+ *
168
+ * The EXECUTOR is not sent, and could not usefully be — it is a function, it
169
+ * would not survive JSON, and an endpoint that hands out executors is an
170
+ * endpoint that explains how to bypass itself.
171
+ */
172
+ function catalog() {
173
+ return actions.list().filter(function(a) {
174
+ // HIDDEN is registered, so the engine can run a step that names it, and
175
+ // absent here, so no palette ever draws it. Filtered at the point the list
176
+ // leaves the process rather than by not registering it — the two questions
177
+ // ("can this run" and "should anyone pick it") have different answers.
178
+ return HIDDEN_NAMES.indexOf(a.name) === -1;
179
+ }).map(function(a) {
180
+ return {
181
+ name: a.name,
182
+ description: a.description,
183
+ inputSchema: a.inputSchema,
184
+ outputSchema: a.outputSchema
185
+ };
186
+ });
187
+ }
188
+
189
+ module.exports = {
190
+ registerAll: registerAll,
191
+ catalog: catalog,
192
+ isReady: isReady,
193
+ WANTED: WANTED,
194
+ WANTED_WITH_META: WANTED_WITH_META,
195
+ LOCAL: LOCAL,
196
+ HIDDEN: HIDDEN
197
+ };
@@ -0,0 +1,79 @@
1
+ // THE TWO STEPS OF A JOB CHAIN — see lib/jobChains.js.
2
+ //
3
+ // Registered but HIDDEN from GET /actions, like screen-show: only the facade
4
+ // that generates a chain's workflow knows how to build these steps, and one
5
+ // dropped onto the canvas by hand would have no rules and no finished job to
6
+ // read.
7
+
8
+ var db = require('../db');
9
+ var jobChains = require('../jobChains');
10
+
11
+ var NOT_CONNECTED =
12
+ 'This step starts a job, but this install has not connected jobs to workflow. ' +
13
+ 'The host must pass startJob to startEngine()/registerWorkflow() — with @xeplr/jobs ' +
14
+ 'in the same process that is (jobId, body) => jobs.trigger(jobId, body).';
15
+
16
+ // `decide` does nothing. Its transitions are the job's rules, and the engine
17
+ // evaluates them against the run's params once it returns.
18
+ var decide = {
19
+ name: jobChains.DECIDE_ACTION,
20
+ description: 'Choose which job runs next from how the last one ended. Its transitions are the rules.',
21
+ requires: [],
22
+ inputSchema: [{ name: 'rules', type: 'array', order: 1, description: 'The rules as written on the job.' }],
23
+ outputSchema: null,
24
+ execute: async function() { return {}; }
25
+ };
26
+
27
+ // `run_n` starts one job and parks until it reports back. The rule's inputs and
28
+ // carry are resolved HERE against the run's own params (typed — see
29
+ // resolveTyped), not by the engine's text interpolation, so a window or a
30
+ // number passes as itself.
31
+ var next = {
32
+ name: jobChains.NEXT_ACTION,
33
+ description: 'Start the next job of a chain with the period and values carried so far, and wait for it.',
34
+ requires: [],
35
+ inputSchema: [
36
+ { name: 'jobId', type: 'string', required: true, order: 1 },
37
+ { name: 'inputs', type: 'object', order: 2 },
38
+ { name: 'carry', type: 'object', order: 3 },
39
+ { name: 'callbackUrl', type: 'string', order: 4 }
40
+ ],
41
+ outputSchema: null,
42
+
43
+ execute: async function(ctx) {
44
+ var input = ctx.input || {};
45
+ var startJob = jobChains.jobStarter();
46
+ if (!startJob) throw new Error(NOT_CONNECTED);
47
+
48
+ // The STEP's values as saved, not the interpolated input: `inputs` and
49
+ // `carry` hold `{output.x}` bindings that only resolveTyped keeps typed.
50
+ var step = await db.model('WorkflowStep').query().findById(ctx.system.stepId);
51
+ var run = await db.model('WorkflowRun').query().findById(ctx.system.runId);
52
+ var call = jobChains.nextJobCall(run && run.params, {
53
+ jobId: input.jobId,
54
+ inputs: step && step.values ? step.values.inputs : input.inputs,
55
+ carry: step && step.values ? step.values.carry : input.carry
56
+ });
57
+
58
+ // The resume key travels in the chain, so a host can resume this step
59
+ // in-process when the job ends (jobChains.jobFinished); the callback URL
60
+ // is the same key over HTTP, for a jobs service in another process. Null
61
+ // when WORKFLOW_PUBLIC_URL is unset, which is fine in-process.
62
+ call.chain.resumeKey = (ctx.system && ctx.system.resumeKey) || null;
63
+ var started = await startJob(input.jobId, {
64
+ inputs: call.inputs,
65
+ chain: call.chain,
66
+ callbackUrl: input.callbackUrl || null
67
+ });
68
+
69
+ return {
70
+ jobId: input.jobId,
71
+ occurrenceId: (started && started.occurrenceId) || null,
72
+ status: 'running',
73
+ window: call.inputs.window || null,
74
+ carry: call.chain.carry
75
+ };
76
+ }
77
+ };
78
+
79
+ module.exports = { decide: decide, next: next };
@@ -0,0 +1,171 @@
1
+ // job-run — START A JOB AND PARK UNTIL IT REPORTS BACK.
2
+ //
3
+ // The step behind an arrow on the "connect jobs" canvas. Everything it does is
4
+ // two HTTP calls' worth of glue between two products that do not import each
5
+ // other:
6
+ //
7
+ // 1. POST <jobsUrl>/jobs/<jobId>/trigger { inputs, callbackUrl }
8
+ // 2. return — the STEP does not finish here. It is a `wait` step, so the run
9
+ // parks, and @xeplr/jobs POSTs the outcome to callbackUrl when the
10
+ // occurrence reaches a terminal state, which resumes it.
11
+ //
12
+ // ── why it does not wait for the job ─────────────────────────────────────
13
+ //
14
+ // A movement runs for twenty minutes. Holding an await open across that would
15
+ // tie a run's progress to one process staying alive, and a deploy in the
16
+ // middle would lose it with nothing recording why. The resume key is a row in
17
+ // a table: it survives a restart, and it is the same mechanism an emailed
18
+ // approval link has always used.
19
+ //
20
+ // ── THIS IS TEMPORARY, AND SHOULD BE DELETED ─────────────────────────────
21
+ //
22
+ // Nothing here is about jobs except the URL shape and the field named
23
+ // `callbackUrl`. The general form is @xeplr/actions' `http-request` (today a
24
+ // placeholder) called from a wait step with `{resumeUrl}` bound into its body,
25
+ // which would serve jobs, saved API calls and anything else that can honour
26
+ // the callback contract. This exists so the jobs canvas can be finished
27
+ // first; when http-request lands, a job step becomes a configuration of it and
28
+ // this file goes away.
29
+ //
30
+ // It lives in xeplr-workflow rather than in @xeplr/actions deliberately: it is
31
+ // glue between two products, and putting it in the shared action library would
32
+ // teach that library what a job is.
33
+
34
+ var CALLBACK_UNREACHABLE =
35
+ 'This step starts a job and waits for it to report back, but WORKFLOW_PUBLIC_URL ' +
36
+ 'is not set, so there is no address to give the job to call. Set it to the base ' +
37
+ 'URL this service is reachable on (including any mount path — e.g. ' +
38
+ 'https://bi.example.com/workflow) and run this again.';
39
+
40
+ module.exports = {
41
+ name: 'job-run',
42
+ description: 'Run a saved job and wait for it to finish. The job reports its outcome back, ' +
43
+ 'so the next step runs only once it has actually completed.',
44
+ requires: [],
45
+
46
+ inputSchema: [
47
+ { name: 'jobsUrl', type: 'string', order: 1,
48
+ description: 'Base URL of the jobs API — e.g. https://bi.example.com/api. Not needed when ' +
49
+ 'the host connected jobs in-process (startEngine\'s startJob).' },
50
+ { name: 'jobId', type: 'string', required: true, order: 2,
51
+ description: 'The job to run.' },
52
+ { name: 'callbackUrl', type: 'string', order: 3,
53
+ description: 'Where the job reports its outcome. Bind this to {resumeUrl}; it is the ' +
54
+ 'address of this step\'s own resume key. Needed only when the job is started ' +
55
+ 'over HTTP — an in-process start reports back through the same key directly.' },
56
+ { name: 'inputs', type: 'object', order: 4, group: 'Advanced',
57
+ description: 'Values to override the job\'s saved inputs, for this run only. The job row ' +
58
+ 'is not changed. Commonly a window: { window: { from, to } }.' },
59
+ { name: 'timeoutMs', type: 'number', default: 30000, order: 5, group: 'Advanced',
60
+ description: 'How long to wait for the TRIGGER call to be accepted. Nothing to do with ' +
61
+ 'how long the job itself may run — that is the job\'s own timeout.' }
62
+ ],
63
+
64
+ execute: async function(ctx) {
65
+ var input = ctx.input || {};
66
+
67
+ // THE HOST'S OWN WAY OF STARTING A JOB, when it gave one (startEngine's
68
+ // startJob — @xeplr/jobs' trigger() in the same process). No HTTP, no
69
+ // jobsUrl and no credentials: the call runs inside this run's tenant
70
+ // context, which is the job's. Without it this step falls back to the
71
+ // jobs API over HTTP below.
72
+ var startJob = require('../jobChains').jobStarter();
73
+ if (startJob) {
74
+ var started = await startJob(input.jobId, {
75
+ inputs: input.inputs || null,
76
+ callbackUrl: input.callbackUrl || null,
77
+ chain: { resumeKey: (ctx.system && ctx.system.resumeKey) || null }
78
+ });
79
+ return {
80
+ jobId: input.jobId,
81
+ occurrenceId: (started && started.occurrenceId) || null,
82
+ startedAt: new Date().toISOString(),
83
+ status: 'running'
84
+ };
85
+ }
86
+
87
+ // The binding resolved to nothing, which means WORKFLOW_PUBLIC_URL is
88
+ // unset (see workflowRunner's publicResumeUrl). Caught HERE, before the
89
+ // job is started, because the alternative is a job that runs perfectly for
90
+ // twenty minutes and then has nowhere to report to — leaving the step
91
+ // waiting forever on work that actually succeeded.
92
+ if (!input.callbackUrl) throw new Error(CALLBACK_UNREACHABLE);
93
+
94
+ // THE SAME CHECK FOR THE OTHER BINDING, and it needs its own message.
95
+ //
96
+ // The canvas fills jobsUrl with `{env.JOBS_API_URL}`, and the engine
97
+ // resolves an unknown binding to the EMPTY STRING rather than failing —
98
+ // so an unset (or unexposed) variable arrives here as '' and satisfies
99
+ // `required`, which only rejects null and undefined. Left alone that
100
+ // becomes fetch('/jobs/j1/trigger') and a "Failed to parse URL" nobody can
101
+ // trace back to an environment variable.
102
+ //
103
+ // Both halves are named because either one alone is the cause: the
104
+ // variable can be set and simply not listed in WORKFLOW_ENV_EXPOSED, which
105
+ // is exactly as invisible.
106
+ var jobsUrl = String(input.jobsUrl || '').trim();
107
+ if (!/^https?:\/\//i.test(jobsUrl)) {
108
+ throw new Error('This step needs the address of the jobs API and got ' +
109
+ (jobsUrl ? '"' + jobsUrl + '"' : 'nothing') + '. It is normally bound to ' +
110
+ '{env.JOBS_API_URL}, which resolves to an empty string unless JOBS_API_URL is set ' +
111
+ 'AND named in WORKFLOW_ENV_EXPOSED — check both. It must be absolute, e.g. ' +
112
+ 'https://bi.example.com/api');
113
+ }
114
+
115
+ var base = jobsUrl.replace(/\/+$/, '');
116
+ var url = base + '/jobs/' + encodeURIComponent(input.jobId) + '/trigger';
117
+
118
+ var controller = new AbortController();
119
+ var timer = setTimeout(function() { controller.abort(); }, input.timeoutMs || 30000);
120
+ if (timer.unref) timer.unref();
121
+
122
+ var res;
123
+ try {
124
+ res = await fetch(url, {
125
+ method: 'POST',
126
+ headers: { 'Content-Type': 'application/json' },
127
+ body: JSON.stringify({
128
+ inputs: input.inputs || undefined,
129
+ callbackUrl: input.callbackUrl
130
+ }),
131
+ signal: controller.signal
132
+ });
133
+ } finally {
134
+ clearTimeout(timer);
135
+ }
136
+
137
+ // BUSY IS A FAILURE, NOT A WAIT.
138
+ //
139
+ // A job holds one lock and runs one at a time. Treating 409 as "try again
140
+ // shortly" would mean a chain that quietly continues past a movement that
141
+ // never ran, and the step after it aggregating a period nothing loaded.
142
+ // Failing here stops the run, which is what the step's own onError already
143
+ // defaults to.
144
+ if (res.status === 409) {
145
+ throw new Error('Job ' + input.jobId + ' is already running, so this step cannot start it. ' +
146
+ 'Wait for the current run to finish, or schedule these so they do not overlap.');
147
+ }
148
+
149
+ if (!res.ok) {
150
+ var detail = await res.text().catch(function() { return ''; });
151
+ throw new Error('Could not start job ' + input.jobId + ' — the jobs API answered ' +
152
+ res.status + (detail ? ': ' + detail.slice(0, 300) : '') + '.');
153
+ }
154
+
155
+ var body = await res.json().catch(function() { return {}; });
156
+ var row = (body.dataArray && body.dataArray[0]) || {};
157
+
158
+ // WHAT THIS STEP KNOWS SO FAR — the handle, not the result.
159
+ //
160
+ // The occurrence's outcome arrives later, through the callback, and
161
+ // resumeByKey MERGES it over this. So a downstream binding of
162
+ // {steps.<key>.output.occurrenceId} works from the moment the job starts,
163
+ // and {steps.<key>.output.output.totalRows} works once it has finished.
164
+ return {
165
+ jobId: input.jobId,
166
+ occurrenceId: row.occurrenceId || null,
167
+ startedAt: new Date().toISOString(),
168
+ status: 'running'
169
+ };
170
+ }
171
+ };
@@ -0,0 +1,71 @@
1
+ // screen-show — PARK THE RUN AND LET A PERSON FILL IN A SCREEN.
2
+ //
3
+ // The step behind every node of a 'screens' flow (see lib/flows.js). It is a
4
+ // `wait` step whose action does nothing at all, and the nothing is the whole
5
+ // design:
6
+ //
7
+ // 1. the engine mints the resume key BEFORE the action runs (see
8
+ // workflowRunner's executeFrom) and parks the run at status 'waiting';
9
+ // 2. this returns immediately, so there is nothing to undo and nothing that
10
+ // can fail between "the person opened the screen" and "the run is
11
+ // waiting for them";
12
+ // 3. the person submits; POST /flows/runs/:runId/submit resumes the step
13
+ // with what they typed, and resumeByKey MERGES that onto this output.
14
+ //
15
+ // ── why it returns {} and not { screen } ─────────────────────────────────
16
+ //
17
+ // The step's output IS the submitted values, and nothing else. Transitions on
18
+ // a screens flow read `output.<field>` where <field> is a field of the screen,
19
+ // so anything this action put in the output would occupy a name a screen might
20
+ // legitimately use — a screen with a field called `screen` would then branch on
21
+ // this action's own bookkeeping until the moment it was submitted.
22
+ //
23
+ // Which screen was shown is not lost by returning nothing: the resolved input
24
+ // is persisted on the step run (workflow_step_runs.input), so history records
25
+ // it, and the facade reads the screen off the STEP row (values.screen) rather
26
+ // than off a run, because that is where the flow's design lives.
27
+ //
28
+ // ── why it is not in the action palette ──────────────────────────────────
29
+ //
30
+ // It is registered (the engine resolves a step's actionName through the
31
+ // registry, so a step could not run without it) but hidden from GET /actions —
32
+ // see lib/actionCatalog.js's HIDDEN. Dropping one onto the ordinary workflow
33
+ // canvas would produce a step that parks forever: nothing outside the flows
34
+ // facade knows how to show a screen or how to resume it.
35
+
36
+ module.exports = {
37
+ name: 'screen-show',
38
+ description: 'Show a screen to a person and wait for them to submit it. The values they ' +
39
+ 'submit become this step\'s output, so a transition can branch on them.',
40
+ requires: [],
41
+
42
+ inputSchema: [
43
+ { name: 'screen', type: 'string', required: true, order: 1,
44
+ description: 'Key of the screen to show. The app that owns the screens resolves it; ' +
45
+ 'this engine only carries it.' },
46
+
47
+ // WHAT THE SCREEN OPENS WITH — an ordinary step input, resolved by
48
+ // interpolateAll before this runs, like an email's `to`.
49
+ //
50
+ // A screen can declare that a field is filled in by whoever opens the form
51
+ // (@xeplr/ui-factory's `filledBy: 'caller'`): a containment's complaint is
52
+ // known by the flow and is not a question for the person. This is where
53
+ // the flow answers it — { complaintId: '{steps.d1.output.recordId}' } —
54
+ // and the values arrive already substituted, so the facade hands the app a
55
+ // plain object and nothing downstream knows what a token is.
56
+ //
57
+ // Not called "prefill": it is the step's input, and a screen step is an
58
+ // ordinary step whose action happens to park the run.
59
+ { name: 'values', type: 'object', order: 2,
60
+ description: 'What the screen starts with — the fields it says are filled in by ' +
61
+ 'whoever opens it. A person is not asked for these.' }
62
+ ],
63
+
64
+ // Whatever the person submitted, which is not knowable from here — declared
65
+ // as nothing rather than as a lie. See the note above.
66
+ outputSchema: null,
67
+
68
+ execute: async function() {
69
+ return {};
70
+ }
71
+ };
package/lib/auth.js ADDED
@@ -0,0 +1,19 @@
1
+ // THE AUTH DATABASE, for the hooks that grant a creator access to the company
2
+ // or workspace they just made (models/Company.js, models/Workspace.js) — the
3
+ // standalone server's own tenant screens. An embedded host has its own
4
+ // tenants and never inserts these rows.
5
+ //
6
+ // One connection per process, opened on first use: @xeplr/auth's attach()
7
+ // opens a new one every time it is called, and the standalone server reads
8
+ // the same database (orchestration/standalone.js uses this too).
9
+ var _auth = null;
10
+
11
+ function get() {
12
+ if (!_auth) _auth = require('@xeplr/auth').attach();
13
+ return _auth;
14
+ }
15
+
16
+ module.exports = {
17
+ ready: function() { return get().ready(); },
18
+ model: function(name) { return get().model(name); }
19
+ };
package/lib/db.js ADDED
@@ -0,0 +1,82 @@
1
+ var { getConnection, registerMTs } = require('@xeplr/db');
2
+ var models = require('../models');
3
+
4
+ var _registered = false;
5
+ var _conn = null;
6
+
7
+ /**
8
+ * Bind workflow's own models to a connection. Config-driven — never reads
9
+ * process.env; that is orchestration/standalone.js's job when nothing else
10
+ * supplies one.
11
+ *
12
+ * @param {object} config
13
+ * @param {string} config.name - database name (workflow's OWN domain data —
14
+ * this is always workflow's responsibility, embedded or standalone; that
15
+ * data belongs to workflow, not whatever it's mounted into)
16
+ * @param {string} config.connection - connection string/encrypted blob, same
17
+ * shape @xeplr/db's getConnection already expects
18
+ * @param {object} [config.mts] - { l1: {name, header}, ... }. registerMTs()
19
+ * is PROCESS-GLOBAL — only pass this when nothing else in this process has
20
+ * already called it. A host app embedding workflow has already registered
21
+ * its own levels; passing config.mts in that case would silently overwrite
22
+ * them. Standalone mode passes it; registerWorkflow's embedded branch does
23
+ * not.
24
+ */
25
+ async function connectDb(config) {
26
+ config = config || {};
27
+ if (!config.name || !config.connection) {
28
+ throw new Error('connectDb: { name, connection } are required');
29
+ }
30
+ if (config.mts && !_registered) {
31
+ registerMTs(config.mts);
32
+ _registered = true;
33
+ }
34
+
35
+ // bind:false — A SECONDARY CONNECTION, never the process's global one.
36
+ //
37
+ // @xeplr/db's bindModels does `Model.knex(instance)` on OBJECTION'S BASE
38
+ // CLASS, so it is global: the last getConnection with bind:true wins for
39
+ // every model in the process, whoever declared it. Standalone that is
40
+ // harmless, because workflow is the only thing here. Embedded it is not —
41
+ // connecting to workflow's own database re-pointed the HOST's models at it
42
+ // too, and the host's own routes started failing on tables that were never
43
+ // in this database.
44
+ //
45
+ // So workflow binds its OWN models to its OWN connection and leaves the
46
+ // global binding alone. Exactly what @xeplr/auth's attach() does to reach
47
+ // the auth database from the api process, and what @xeplr/actions'
48
+ // attachConfig() does for xeplr_configs — this is the third instance of the
49
+ // same shape, not a new idea.
50
+ _conn = await getConnection(config.name, config.connection, {
51
+ bind: false,
52
+ connectionName: config.connectionName || 'workflow'
53
+ });
54
+ return _conn;
55
+ }
56
+
57
+ function conn() {
58
+ if (!_conn) throw new Error('workflow: connection not ready — await connectDb() first');
59
+ return _conn;
60
+ }
61
+
62
+ /**
63
+ * A workflow model BOUND to workflow's own connection.
64
+ *
65
+ * Every query in this package goes through here rather than through the class
66
+ * exported by models/index.js. An unbound class falls back to the global
67
+ * binding, which belongs to whatever else is in this process — the host app,
68
+ * embedded — so it would read the wrong database and mostly report that
69
+ * workflow's tables do not exist.
70
+ *
71
+ * Objection caches per (Model, knex) pair, so calling this per request costs a
72
+ * map lookup rather than a new class.
73
+ */
74
+ function model(name) {
75
+ var M = models[name];
76
+ if (!M) throw new Error('workflow: unknown model "' + name + '"');
77
+ return M.bindKnex(conn());
78
+ }
79
+
80
+ module.exports = connectDb;
81
+ module.exports.conn = conn;
82
+ module.exports.model = model;