Merge branch 'master' into fix-formula-crash

This commit is contained in:
Andrew Kingston 2024-02-19 09:53:30 +00:00 committed by GitHub
commit 19e1b642c1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
65 changed files with 1203 additions and 1753 deletions

View File

@ -98,7 +98,6 @@ services:
couchdb-service: couchdb-service:
restart: unless-stopped restart: unless-stopped
image: budibase/couchdb image: budibase/couchdb
pull_policy: always
environment: environment:
- COUCHDB_PASSWORD=${COUCH_DB_PASSWORD} - COUCHDB_PASSWORD=${COUCH_DB_PASSWORD}
- COUCHDB_USER=${COUCH_DB_USER} - COUCHDB_USER=${COUCH_DB_USER}

View File

@ -1,5 +1,5 @@
{ {
"version": "2.19.3", "version": "2.19.6",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*", "packages/*",

View File

@ -58,7 +58,7 @@
"lint": "yarn run lint:eslint && yarn run lint:prettier", "lint": "yarn run lint:eslint && yarn run lint:prettier",
"lint:fix:eslint": "eslint --fix --max-warnings=0 packages qa-core", "lint:fix:eslint": "eslint --fix --max-warnings=0 packages qa-core",
"lint:fix:prettier": "prettier --write \"packages/**/*.{js,ts,svelte}\" && prettier --write \"examples/**/*.{js,ts,svelte}\" && prettier --write \"qa-core/**/*.{js,ts,svelte}\"", "lint:fix:prettier": "prettier --write \"packages/**/*.{js,ts,svelte}\" && prettier --write \"examples/**/*.{js,ts,svelte}\" && prettier --write \"qa-core/**/*.{js,ts,svelte}\"",
"lint:fix": "yarn run lint:fix:prettier && yarn run lint:fix:eslint", "lint:fix": "yarn run lint:fix:eslint && yarn run lint:fix:prettier",
"build:specs": "lerna run --stream specs", "build:specs": "lerna run --stream specs",
"build:docker:airgap": "node hosting/scripts/airgapped/airgappedDockerBuild", "build:docker:airgap": "node hosting/scripts/airgapped/airgappedDockerBuild",
"build:docker:airgap:single": "SINGLE_IMAGE=1 node hosting/scripts/airgapped/airgappedDockerBuild", "build:docker:airgap:single": "SINGLE_IMAGE=1 node hosting/scripts/airgapped/airgappedDockerBuild",

View File

@ -3,6 +3,7 @@ import {
Event, Event,
Datasource, Datasource,
Query, Query,
QueryPreview,
QueryCreatedEvent, QueryCreatedEvent,
QueryUpdatedEvent, QueryUpdatedEvent,
QueryDeletedEvent, QueryDeletedEvent,
@ -68,9 +69,9 @@ const run = async (count: number, timestamp?: string | number) => {
await publishEvent(Event.QUERIES_RUN, properties, timestamp) await publishEvent(Event.QUERIES_RUN, properties, timestamp)
} }
const previewed = async (datasource: Datasource, query: Query) => { const previewed = async (datasource: Datasource, query: QueryPreview) => {
const properties: QueryPreviewedEvent = { const properties: QueryPreviewedEvent = {
queryId: query._id, queryId: query.queryId,
datasourceId: datasource._id as string, datasourceId: datasource._id as string,
source: datasource.source, source: datasource.source,
queryVerb: query.queryVerb, queryVerb: query.queryVerb,

View File

@ -6,6 +6,7 @@ import * as context from "./context"
import semver from "semver" import semver from "semver"
import { bustCache, withCache, TTL, CacheKey } from "./cache/generic" import { bustCache, withCache, TTL, CacheKey } from "./cache/generic"
import environment from "./environment" import environment from "./environment"
import { logAlert } from "./logging"
export const getInstall = async (): Promise<Installation> => { export const getInstall = async (): Promise<Installation> => {
return withCache(CacheKey.INSTALLATION, TTL.ONE_DAY, getInstallFromDB, { return withCache(CacheKey.INSTALLATION, TTL.ONE_DAY, getInstallFromDB, {
@ -80,27 +81,35 @@ export const checkInstallVersion = async (): Promise<void> => {
const currentVersion = install.version const currentVersion = install.version
const newVersion = environment.VERSION const newVersion = environment.VERSION
if (currentVersion !== newVersion) { try {
const isUpgrade = semver.gt(newVersion, currentVersion) if (currentVersion !== newVersion) {
const isDowngrade = semver.lt(newVersion, currentVersion) const isUpgrade = semver.gt(newVersion, currentVersion)
const isDowngrade = semver.lt(newVersion, currentVersion)
const success = await updateVersion(newVersion) const success = await updateVersion(newVersion)
if (success) { if (success) {
await context.doInIdentityContext( await context.doInIdentityContext(
{ {
_id: install.installId, _id: install.installId,
type: IdentityType.INSTALLATION, type: IdentityType.INSTALLATION,
}, },
async () => { async () => {
if (isUpgrade) { if (isUpgrade) {
await events.installation.upgraded(currentVersion, newVersion) await events.installation.upgraded(currentVersion, newVersion)
} else if (isDowngrade) { } else if (isDowngrade) {
await events.installation.downgraded(currentVersion, newVersion) await events.installation.downgraded(currentVersion, newVersion)
}
} }
} )
) await events.identification.identifyInstallationGroup(install.installId)
await events.identification.identifyInstallationGroup(install.installId) }
}
} catch (err: any) {
if (err?.message?.includes("Invalid Version")) {
logAlert(`Invalid version "${newVersion}" - is it semver?`)
} else {
logAlert("Failed to retrieve version", err)
} }
} }
} }

View File

@ -2,11 +2,12 @@ import { Header } from "../../constants"
const correlator = require("correlation-id") const correlator = require("correlation-id")
export const setHeader = (headers: any) => { export const setHeader = (headers: Record<string, string>) => {
const correlationId = correlator.getId() const correlationId = correlator.getId()
if (correlationId) { if (!correlationId) {
headers[Header.CORRELATION_ID] = correlationId return
} }
headers[Header.CORRELATION_ID] = correlationId
} }
export function getId() { export function getId() {

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because it is too large Load Diff

View File

@ -128,10 +128,10 @@
> >
<div class="item-body"> <div class="item-body">
<img <img
width="20" width={20}
height="20" height={20}
src={externalActions[action.stepId].icon} src={externalActions[action.stepId].icon}
alt="zapier" alt={externalActions[action.stepId].name}
/> />
<span class="icon-spacing"> <span class="icon-spacing">
<Body size="XS"> <Body size="XS">

View File

@ -1,5 +1,6 @@
import DiscordLogo from "assets/discord.svg" import DiscordLogo from "assets/discord.svg"
import ZapierLogo from "assets/zapier.png" import ZapierLogo from "assets/zapier.png"
import n8nLogo from "assets/n8n_square.png"
import MakeLogo from "assets/make.svg" import MakeLogo from "assets/make.svg"
import SlackLogo from "assets/slack.svg" import SlackLogo from "assets/slack.svg"
@ -8,4 +9,5 @@ export const externalActions = {
discord: { name: "discord", icon: DiscordLogo }, discord: { name: "discord", icon: DiscordLogo },
slack: { name: "slack", icon: SlackLogo }, slack: { name: "slack", icon: SlackLogo },
integromat: { name: "integromat", icon: MakeLogo }, integromat: { name: "integromat", icon: MakeLogo },
n8n: { name: "n8n", icon: n8nLogo },
} }

View File

@ -79,6 +79,7 @@
disableWrapping: true, disableWrapping: true,
}) })
$: editingJs = codeMode === EditorModes.JS $: editingJs = codeMode === EditorModes.JS
$: requiredProperties = block.schema.inputs.required || []
$: stepCompletions = $: stepCompletions =
codeMode === EditorModes.Handlebars codeMode === EditorModes.Handlebars
@ -359,6 +360,11 @@
) )
} }
function getFieldLabel(key, value) {
const requiredSuffix = requiredProperties.includes(key) ? "*" : ""
return `${value.title || (key === "row" ? "Table" : key)} ${requiredSuffix}`
}
onMount(async () => { onMount(async () => {
try { try {
await environment.loadVariables() await environment.loadVariables()
@ -376,7 +382,7 @@
<Label <Label
tooltip={value.title === "Binding / Value" tooltip={value.title === "Binding / Value"
? "If using the String input type, please use a comma or newline separated string" ? "If using the String input type, please use a comma or newline separated string"
: null}>{value.title || (key === "row" ? "Table" : key)}</Label : null}>{getFieldLabel(key, value)}</Label
> >
{/if} {/if}
<div class:field-width={shouldRenderField(value)}> <div class:field-width={shouldRenderField(value)}>

View File

@ -127,10 +127,14 @@
} }
}) })
$: jsonArrays = bindings $: jsonArrays = bindings
.filter(x => x.fieldSchema?.type === "jsonarray") .filter(
x =>
x.fieldSchema?.type === "jsonarray" ||
(x.fieldSchema?.type === "json" && x.fieldSchema?.subtype === "array")
)
.map(binding => { .map(binding => {
const { providerId, readableBinding, runtimeBinding, tableId } = binding const { providerId, readableBinding, runtimeBinding, tableId } = binding
const { name, type, prefixKeys } = binding.fieldSchema const { name, type, prefixKeys, subtype } = binding.fieldSchema
return { return {
providerId, providerId,
label: readableBinding, label: readableBinding,
@ -138,7 +142,8 @@
fieldType: type, fieldType: type,
tableId, tableId,
prefixKeys, prefixKeys,
type: "jsonarray", type: type === "jsonarray" ? "jsonarray" : "queryarray",
subtype,
value: `{{ literal ${runtimeBinding} }}`, value: `{{ literal ${runtimeBinding} }}`,
} }
}) })

View File

@ -85,6 +85,16 @@
activity = newActivity activity = newActivity
dispatch("change", fields) dispatch("change", fields)
} }
function isJsonArray(value) {
if (!value || typeof value === "string") {
return false
}
if (value.type === "array") {
return true
}
return value.type === "json" && value.subtype === "array"
}
</script> </script>
<!-- Builds Objects with Key Value Pairs. Useful for building things like Request Headers. --> <!-- Builds Objects with Key Value Pairs. Useful for building things like Request Headers. -->
@ -112,7 +122,9 @@
bind:value={field.name} bind:value={field.name}
on:blur={changed} on:blur={changed}
/> />
{#if options} {#if isJsonArray(field.value)}
<Select readonly={true} value="Array" options={["Array"]} />
{:else if options}
<Select <Select
bind:value={field.value} bind:value={field.value}
{compare} {compare}

View File

@ -40,6 +40,7 @@
let schemaType let schemaType
let autoSchema = {} let autoSchema = {}
let nestedSchemaFields = {}
let rows = [] let rows = []
let keys = {} let keys = {}
@ -83,13 +84,14 @@
return return
} }
nestedSchemaFields = response.nestedSchemaFields
if (Object.keys(newQuery.schema).length === 0) { if (Object.keys(newQuery.schema).length === 0) {
// Assign this to a variable instead of directly to the newQuery.schema so that a user // Assign this to a variable instead of directly to the newQuery.schema so that a user
// can change the table they're querying and have the schema update until they first // can change the table they're querying and have the schema update until they first
// edit it // edit it
autoSchema = response.schema autoSchema = response.schema
} }
rows = response.rows rows = response.rows
notifications.success("Query executed successfully") notifications.success("Query executed successfully")
@ -120,6 +122,7 @@
Object.keys(newQuery.schema).length === 0 Object.keys(newQuery.schema).length === 0
? autoSchema ? autoSchema
: newQuery.schema, : newQuery.schema,
nestedSchemaFields,
}) })
notifications.success("Query saved successfully") notifications.success("Query saved successfully")

View File

@ -159,7 +159,7 @@
newQuery.fields.queryString = queryString newQuery.fields.queryString = queryString
newQuery.fields.authConfigId = authConfigId newQuery.fields.authConfigId = authConfigId
newQuery.fields.disabledHeaders = restUtils.flipHeaderState(enabledHeaders) newQuery.fields.disabledHeaders = restUtils.flipHeaderState(enabledHeaders)
newQuery.schema = schema newQuery.schema = schema || {}
return newQuery return newQuery
} }

View File

@ -5,7 +5,6 @@
Label, Label,
Input, Input,
Select, Select,
Divider,
Layout, Layout,
Icon, Icon,
Button, Button,
@ -124,7 +123,6 @@
{#each query.fields.steps ?? [] as step, index} {#each query.fields.steps ?? [] as step, index}
<div class="block"> <div class="block">
<div class="subblock"> <div class="subblock">
<Divider noMargin />
<div class="blockSection"> <div class="blockSection">
<div class="block-options"> <div class="block-options">
Stage {index + 1} Stage {index + 1}

View File

@ -27,6 +27,7 @@ export const ActionStepID = {
slack: "slack", slack: "slack",
zapier: "zapier", zapier: "zapier",
integromat: "integromat", integromat: "integromat",
n8n: "n8n",
} }
export const Features = { export const Features = {

View File

@ -310,6 +310,7 @@ export const BannedSearchTypes = [
"formula", "formula",
"json", "json",
"jsonarray", "jsonarray",
"queryarray",
] ]
export const DatasourceTypes = { export const DatasourceTypes = {

View File

@ -425,7 +425,7 @@ const generateComponentContextBindings = (asset, componentContext) => {
table = info.table table = info.table
// Determine what to prefix bindings with // Determine what to prefix bindings with
if (datasource.type === "jsonarray") { if (datasource.type === "jsonarray" || datasource.type === "queryarray") {
// For JSON arrays, use the array name as the readable prefix // For JSON arrays, use the array name as the readable prefix
const split = datasource.label.split(".") const split = datasource.label.split(".")
readablePrefix = split[split.length - 1] readablePrefix = split[split.length - 1]
@ -904,6 +904,19 @@ export const getSchemaForDatasource = (asset, datasource, options) => {
schema = JSONUtils.getJSONArrayDatasourceSchema(tableSchema, datasource) schema = JSONUtils.getJSONArrayDatasourceSchema(tableSchema, datasource)
} }
// "queryarray" datasources are arrays inside JSON responses
else if (type === "queryarray") {
const queries = get(queriesStores).list
table = queries.find(query => query._id === datasource.tableId)
let tableSchema = table?.schema
let nestedSchemaFields = table?.nestedSchemaFields
schema = JSONUtils.generateQueryArraySchemas(
tableSchema,
nestedSchemaFields
)
schema = JSONUtils.getJSONArrayDatasourceSchema(schema, datasource)
}
// Otherwise we assume we're targeting an internal table or a plus // Otherwise we assume we're targeting an internal table or a plus
// datasource, and we can treat it as a table with a schema // datasource, and we can treat it as a table with a schema
else { else {

View File

@ -3,6 +3,7 @@
import { ActionMenu, MenuItem, Icon } from "@budibase/bbui" import { ActionMenu, MenuItem, Icon } from "@budibase/bbui"
export let component export let component
export let opened
$: definition = componentStore.getDefinition(component?._component) $: definition = componentStore.getDefinition(component?._component)
$: noPaste = !$componentStore.componentToPaste $: noPaste = !$componentStore.componentToPaste
@ -85,6 +86,39 @@
> >
Paste Paste
</MenuItem> </MenuItem>
{#if component?._children?.length}
<MenuItem
icon="TreeExpand"
keyBind="!ArrowRight"
on:click={() => keyboardEvent("ArrowRight", false)}
disabled={opened}
>
Expand
</MenuItem>
<MenuItem
icon="TreeCollapse"
keyBind="!ArrowLeft"
on:click={() => keyboardEvent("ArrowLeft", false)}
disabled={!opened}
>
Collapse
</MenuItem>
<MenuItem
icon="TreeExpandAll"
keyBind="Ctrl+!ArrowRight"
on:click={() => keyboardEvent("ArrowRight", true)}
>
Expand All
</MenuItem>
<MenuItem
icon="TreeCollapseAll"
keyBind="Ctrl+!ArrowLeft"
on:click={() => keyboardEvent("ArrowLeft", true)}
>
Collapse All
</MenuItem>
{/if}
</ActionMenu> </ActionMenu>
<style> <style>

View File

@ -9,6 +9,7 @@
import { goto, isActive } from "@roxi/routify" import { goto, isActive } from "@roxi/routify"
import { notifications } from "@budibase/bbui" import { notifications } from "@budibase/bbui"
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import componentTreeNodesStore from "stores/portal/componentTreeNodesStore"
let confirmDeleteDialog let confirmDeleteDialog
let confirmEjectDialog let confirmEjectDialog
@ -61,6 +62,40 @@
["ArrowDown"]: () => { ["ArrowDown"]: () => {
componentStore.selectNext() componentStore.selectNext()
}, },
["ArrowRight"]: component => {
componentTreeNodesStore.expandNode(component._id)
},
["ArrowLeft"]: component => {
componentTreeNodesStore.collapseNode(component._id)
},
["Ctrl+ArrowRight"]: component => {
componentTreeNodesStore.expandNode(component._id)
const expandChildren = component => {
const children = component._children ?? []
children.forEach(child => {
componentTreeNodesStore.expandNode(child._id)
expandChildren(child)
})
}
expandChildren(component)
},
["Ctrl+ArrowLeft"]: component => {
componentTreeNodesStore.collapseNode(component._id)
const collapseChildren = component => {
const children = component._children ?? []
children.forEach(child => {
componentTreeNodesStore.collapseNode(child._id)
collapseChildren(child)
})
}
collapseChildren(component)
},
["Escape"]: () => { ["Escape"]: () => {
if ($isActive(`./:componentId/new`)) { if ($isActive(`./:componentId/new`)) {
$goto(`./${$componentStore.selectedComponentId}`) $goto(`./${$componentStore.selectedComponentId}`)

View File

@ -17,11 +17,12 @@
} from "helpers/components" } from "helpers/components"
import { get } from "svelte/store" import { get } from "svelte/store"
import { dndStore } from "./dndStore" import { dndStore } from "./dndStore"
import componentTreeNodesStore from "stores/portal/componentTreeNodesStore"
export let components = [] export let components = []
export let level = 0 export let level = 0
let closedNodes = {} $: openNodes = $componentTreeNodesStore
$: filteredComponents = components?.filter(component => { $: filteredComponents = components?.filter(component => {
return ( return (
@ -54,15 +55,6 @@
return componentSupportsChildren(component) && component._children?.length return componentSupportsChildren(component) && component._children?.length
} }
function toggleNodeOpen(componentId) {
if (closedNodes[componentId]) {
delete closedNodes[componentId]
} else {
closedNodes[componentId] = true
}
closedNodes = closedNodes
}
const onDrop = async e => { const onDrop = async e => {
e.stopPropagation() e.stopPropagation()
try { try {
@ -72,14 +64,14 @@
} }
} }
const isOpen = (component, selectedComponentPath, closedNodes) => { const isOpen = (component, selectedComponentPath, openNodes) => {
if (!component?._children?.length) { if (!component?._children?.length) {
return false return false
} }
if (selectedComponentPath.includes(component._id)) { if (selectedComponentPath.slice(0, -1).includes(component._id)) {
return true return true
} }
return !closedNodes[component._id] return openNodes[`nodeOpen-${component._id}`]
} }
const isChildOfSelectedComponent = component => { const isChildOfSelectedComponent = component => {
@ -96,7 +88,7 @@
<ul> <ul>
{#each filteredComponents || [] as component, index (component._id)} {#each filteredComponents || [] as component, index (component._id)}
{@const opened = isOpen(component, $selectedComponentPath, closedNodes)} {@const opened = isOpen(component, $selectedComponentPath, openNodes)}
<li <li
on:click|stopPropagation={() => { on:click|stopPropagation={() => {
componentStore.select(component._id) componentStore.select(component._id)
@ -110,7 +102,7 @@
on:dragend={dndStore.actions.reset} on:dragend={dndStore.actions.reset}
on:dragstart={() => dndStore.actions.dragstart(component)} on:dragstart={() => dndStore.actions.dragstart(component)}
on:dragover={dragover(component, index)} on:dragover={dragover(component, index)}
on:iconClick={() => toggleNodeOpen(component._id)} on:iconClick={() => componentTreeNodesStore.toggleNode(component._id)}
on:drop={onDrop} on:drop={onDrop}
hovering={$hoverStore.componentId === component._id} hovering={$hoverStore.componentId === component._id}
on:mouseenter={() => hover(component._id)} on:mouseenter={() => hover(component._id)}
@ -125,8 +117,9 @@
highlighted={isChildOfSelectedComponent(component)} highlighted={isChildOfSelectedComponent(component)}
selectedBy={$userSelectedResourceMap[component._id]} selectedBy={$userSelectedResourceMap[component._id]}
> >
<ComponentDropdownMenu {component} /> <ComponentDropdownMenu {opened} {component} />
</NavItem> </NavItem>
{#if opened} {#if opened}
<svelte:self <svelte:self
components={component._children} components={component._children}
@ -144,13 +137,6 @@
padding-left: 0; padding-left: 0;
margin: 0; margin: 0;
} }
ul :global(.icon.arrow) {
transition: opacity 130ms ease-out;
opacity: 0;
}
ul:hover :global(.icon.arrow) {
opacity: 1;
}
ul, ul,
li { li {
min-width: max-content; min-width: max-content;

View File

@ -29,6 +29,7 @@ import {
} from "constants/backend" } from "constants/backend"
import BudiStore from "./BudiStore" import BudiStore from "./BudiStore"
import { Utils } from "@budibase/frontend-core" import { Utils } from "@budibase/frontend-core"
import componentTreeNodesStore from "stores/portal/componentTreeNodesStore"
export const INITIAL_COMPONENTS_STATE = { export const INITIAL_COMPONENTS_STATE = {
components: {}, components: {},
@ -662,6 +663,7 @@ export class ComponentStore extends BudiStore {
const screen = get(selectedScreen) const screen = get(selectedScreen)
const parent = findComponentParent(screen.props, componentId) const parent = findComponentParent(screen.props, componentId)
const index = parent?._children.findIndex(x => x._id === componentId) const index = parent?._children.findIndex(x => x._id === componentId)
const componentTreeNodes = get(componentTreeNodesStore)
// Check for screen and navigation component edge cases // Check for screen and navigation component edge cases
const screenComponentId = `${screen._id}-screen` const screenComponentId = `${screen._id}-screen`
@ -680,9 +682,15 @@ export class ComponentStore extends BudiStore {
if (index > 0) { if (index > 0) {
// If sibling before us accepts children, select a descendant // If sibling before us accepts children, select a descendant
const previousSibling = parent._children[index - 1] const previousSibling = parent._children[index - 1]
if (previousSibling._children?.length) { if (
previousSibling._children?.length &&
componentTreeNodes[`nodeOpen-${previousSibling._id}`]
) {
let target = previousSibling let target = previousSibling
while (target._children?.length) { while (
target._children?.length &&
componentTreeNodes[`nodeOpen-${target._id}`]
) {
target = target._children[target._children.length - 1] target = target._children[target._children.length - 1]
} }
return target._id return target._id
@ -703,6 +711,7 @@ export class ComponentStore extends BudiStore {
const screen = get(selectedScreen) const screen = get(selectedScreen)
const parent = findComponentParent(screen.props, componentId) const parent = findComponentParent(screen.props, componentId)
const index = parent?._children.findIndex(x => x._id === componentId) const index = parent?._children.findIndex(x => x._id === componentId)
const componentTreeNodes = get(componentTreeNodesStore)
// Check for screen and navigation component edge cases // Check for screen and navigation component edge cases
const screenComponentId = `${screen._id}-screen` const screenComponentId = `${screen._id}-screen`
@ -712,7 +721,11 @@ export class ComponentStore extends BudiStore {
} }
// If we have children, select first child // If we have children, select first child
if (component._children?.length) { if (
component._children?.length &&
(state.selectedComponentId === navComponentId ||
componentTreeNodes[`nodeOpen-${component._id}`])
) {
return component._children[0]._id return component._children[0]._id
} else if (!parent) { } else if (!parent) {
return null return null

View File

@ -92,12 +92,13 @@ const resetBuilderHistory = () => {
export const initialise = async pkg => { export const initialise = async pkg => {
const { application } = pkg const { application } = pkg
// must be first operation to make sure subsequent requests have correct app ID
appStore.syncAppPackage(pkg)
await Promise.all([ await Promise.all([
appStore.syncAppRoutes(), appStore.syncAppRoutes(),
componentStore.refreshDefinitions(application?.appId), componentStore.refreshDefinitions(application?.appId),
]) ])
builderStore.init(application) builderStore.init(application)
appStore.syncAppPackage(pkg)
navigationStore.syncAppNavigation(application?.navigation) navigationStore.syncAppNavigation(application?.navigation)
themeStore.syncAppTheme(application) themeStore.syncAppTheme(application)
screenStore.syncAppScreens(pkg) screenStore.syncAppScreens(pkg)

View File

@ -0,0 +1,36 @@
import { createSessionStorageStore } from "@budibase/frontend-core"
const baseStore = createSessionStorageStore("openNodes", {})
const toggleNode = componentId => {
baseStore.update(openNodes => {
openNodes[`nodeOpen-${componentId}`] = !openNodes[`nodeOpen-${componentId}`]
return openNodes
})
}
const expandNode = componentId => {
baseStore.update(openNodes => {
openNodes[`nodeOpen-${componentId}`] = true
return openNodes
})
}
const collapseNode = componentId => {
baseStore.update(openNodes => {
openNodes[`nodeOpen-${componentId}`] = false
return openNodes
})
}
const store = {
subscribe: baseStore.subscribe,
toggleNode,
expandNode,
collapseNode,
}
export default store

View File

@ -13,7 +13,12 @@ import { COMPOSE_PATH } from "./makeFiles"
import { info, success } from "../utils" import { info, success } from "../utils"
import { start } from "./start" import { start } from "./start"
const BB_COMPOSE_SERVICES = ["app-service", "worker-service", "proxy-service"] const BB_COMPOSE_SERVICES = [
"app-service",
"worker-service",
"proxy-service",
"couchdb-service",
]
const BB_SINGLE_SERVICE = ["budibase"] const BB_SINGLE_SERVICE = ["budibase"]
export async function update() { export async function update() {

View File

@ -270,6 +270,7 @@
{ {
"type": "buttonConfiguration", "type": "buttonConfiguration",
"key": "buttons", "key": "buttons",
"nested": true,
"defaultValue": [ "defaultValue": [
{ {
"type": "cta", "type": "cta",

View File

@ -84,7 +84,7 @@
// Fetches the form schema from this form's dataSource // Fetches the form schema from this form's dataSource
const fetchSchema = async dataSource => { const fetchSchema = async dataSource => {
if (dataSource?.tableId && dataSource?.type !== "query") { if (dataSource?.tableId && !dataSource?.type?.startsWith("query")) {
try { try {
table = await API.fetchTableDefinition(dataSource.tableId) table = await API.fetchTableDefinition(dataSource.tableId)
} catch (error) { } catch (error) {

View File

@ -7,6 +7,7 @@ import NestedProviderFetch from "@budibase/frontend-core/src/fetch/NestedProvide
import FieldFetch from "@budibase/frontend-core/src/fetch/FieldFetch.js" import FieldFetch from "@budibase/frontend-core/src/fetch/FieldFetch.js"
import JSONArrayFetch from "@budibase/frontend-core/src/fetch/JSONArrayFetch.js" import JSONArrayFetch from "@budibase/frontend-core/src/fetch/JSONArrayFetch.js"
import ViewV2Fetch from "@budibase/frontend-core/src/fetch/ViewV2Fetch.js" import ViewV2Fetch from "@budibase/frontend-core/src/fetch/ViewV2Fetch.js"
import QueryArrayFetch from "@budibase/frontend-core/src/fetch/QueryArrayFetch"
/** /**
* Fetches the schema of any kind of datasource. * Fetches the schema of any kind of datasource.
@ -28,6 +29,7 @@ export const fetchDatasourceSchema = async (
provider: NestedProviderFetch, provider: NestedProviderFetch,
field: FieldFetch, field: FieldFetch,
jsonarray: JSONArrayFetch, jsonarray: JSONArrayFetch,
queryarray: QueryArrayFetch,
}[datasource?.type] }[datasource?.type]
if (!handler) { if (!handler) {
return null return null

View File

@ -0,0 +1,25 @@
import FieldFetch from "./FieldFetch.js"
import {
getJSONArrayDatasourceSchema,
generateQueryArraySchemas,
} from "../utils/json"
export default class QueryArrayFetch extends FieldFetch {
async getDefinition(datasource) {
if (!datasource?.tableId) {
return null
}
// JSON arrays need their table definitions fetched.
// We can then extract their schema as a subset of the table schema.
try {
const table = await this.API.fetchQueryDefinition(datasource.tableId)
const schema = generateQueryArraySchemas(
table?.schema,
table?.nestedSchemaFields
)
return { schema: getJSONArrayDatasourceSchema(schema, datasource) }
} catch (error) {
return null
}
}
}

View File

@ -9,6 +9,7 @@ import JSONArrayFetch from "./JSONArrayFetch.js"
import UserFetch from "./UserFetch.js" import UserFetch from "./UserFetch.js"
import GroupUserFetch from "./GroupUserFetch.js" import GroupUserFetch from "./GroupUserFetch.js"
import CustomFetch from "./CustomFetch.js" import CustomFetch from "./CustomFetch.js"
import QueryArrayFetch from "./QueryArrayFetch.js"
const DataFetchMap = { const DataFetchMap = {
table: TableFetch, table: TableFetch,
@ -24,6 +25,7 @@ const DataFetchMap = {
provider: NestedProviderFetch, provider: NestedProviderFetch,
field: FieldFetch, field: FieldFetch,
jsonarray: JSONArrayFetch, jsonarray: JSONArrayFetch,
queryarray: QueryArrayFetch,
} }
// Constructs a new fetch model for a certain datasource // Constructs a new fetch model for a certain datasource

View File

@ -1 +1,2 @@
export { createLocalStorageStore } from "./localStorage" export { createLocalStorageStore } from "./localStorage"
export { createSessionStorageStore } from "./sessionStorage"

View File

@ -0,0 +1,46 @@
import { get, writable } from "svelte/store"
export const createSessionStorageStore = (sessionStorageKey, initialValue) => {
const store = writable(initialValue, () => {
// Hydrate from session storage when we get a new subscriber
hydrate()
// Listen for session storage changes and keep store in sync
const storageListener = ({ key }) => {
return key === sessionStorageKey && hydrate()
}
window.addEventListener("storage", storageListener)
return () => window.removeEventListener("storage", storageListener)
})
// New store setter which updates the store and sessionstorage
const set = value => {
store.set(value)
sessionStorage.setItem(sessionStorageKey, JSON.stringify(value))
}
// New store updater which updates the store and sessionstorage
const update = updaterFn => set(updaterFn(get(store)))
// Hydrates the store from sessionstorage
const hydrate = () => {
const sessionValue = sessionStorage.getItem(sessionStorageKey)
if (sessionValue == null) {
set(initialValue)
} else {
try {
store.set(JSON.parse(sessionValue))
} catch {
set(initialValue)
}
}
}
// Patch the default svelte store functions with our overrides
return {
...store,
set,
update,
}
}

View File

@ -1,3 +1,5 @@
import { utils } from "@budibase/shared-core"
/** /**
* Gets the schema for a datasource which is targeting a JSON array, including * Gets the schema for a datasource which is targeting a JSON array, including
* nested JSON arrays. The returned schema is a squashed, table-like schema * nested JSON arrays. The returned schema is a squashed, table-like schema
@ -119,3 +121,33 @@ const extractJSONSchemaKeys = (jsonSchema, squashObjects = false) => {
}) })
return keys return keys
} }
export const generateQueryArraySchemas = (schema, nestedSchemaFields) => {
for (let key in schema) {
if (
schema[key]?.type === "json" &&
schema[key]?.subtype === "array" &&
utils.hasSchema(nestedSchemaFields[key])
) {
schema[key] = {
schema: {
schema: Object.entries(nestedSchemaFields[key] || {}).reduce(
(acc, [nestedKey, fieldSchema]) => {
acc[nestedKey] = {
name: nestedKey,
type: fieldSchema.type,
subtype: fieldSchema.subtype,
}
return acc
},
{}
),
type: "json",
},
type: "json",
subtype: "array",
}
}
}
return schema
}

View File

@ -66,6 +66,7 @@ COPY packages/server/dist/ dist/
COPY packages/server/docker_run.sh . COPY packages/server/docker_run.sh .
COPY packages/server/builder/ builder/ COPY packages/server/builder/ builder/
COPY packages/server/client/ client/ COPY packages/server/client/ client/
COPY packages/server/pm2.config.js .
ARG BUDIBASE_VERSION ARG BUDIBASE_VERSION
ARG GIT_COMMIT_SHA ARG GIT_COMMIT_SHA

View File

@ -1,4 +1,3 @@
import * as actions from "../../automations/actions"
import * as triggers from "../../automations/triggers" import * as triggers from "../../automations/triggers"
import { import {
getAutomationParams, getAutomationParams,
@ -20,11 +19,12 @@ import {
Automation, Automation,
AutomationActionStepId, AutomationActionStepId,
AutomationResults, AutomationResults,
BBContext, UserCtx,
} from "@budibase/types" } from "@budibase/types"
import { getActionDefinitions as actionDefs } from "../../automations/actions" import { getActionDefinitions as actionDefs } from "../../automations/actions"
import sdk from "../../sdk" import sdk from "../../sdk"
import { builderSocket } from "../../websockets" import { builderSocket } from "../../websockets"
import env from "../../environment"
async function getActionDefinitions() { async function getActionDefinitions() {
return removeDeprecated(await actionDefs()) return removeDeprecated(await actionDefs())
@ -72,7 +72,7 @@ function cleanAutomationInputs(automation: Automation) {
return automation return automation
} }
export async function create(ctx: BBContext) { export async function create(ctx: UserCtx) {
const db = context.getAppDB() const db = context.getAppDB()
let automation = ctx.request.body let automation = ctx.request.body
automation.appId = ctx.appId automation.appId = ctx.appId
@ -141,7 +141,7 @@ export async function handleStepEvents(
} }
} }
export async function update(ctx: BBContext) { export async function update(ctx: UserCtx) {
const db = context.getAppDB() const db = context.getAppDB()
let automation = ctx.request.body let automation = ctx.request.body
automation.appId = ctx.appId automation.appId = ctx.appId
@ -192,7 +192,7 @@ export async function update(ctx: BBContext) {
builderSocket?.emitAutomationUpdate(ctx, automation) builderSocket?.emitAutomationUpdate(ctx, automation)
} }
export async function fetch(ctx: BBContext) { export async function fetch(ctx: UserCtx) {
const db = context.getAppDB() const db = context.getAppDB()
const response = await db.allDocs( const response = await db.allDocs(
getAutomationParams(null, { getAutomationParams(null, {
@ -202,12 +202,12 @@ export async function fetch(ctx: BBContext) {
ctx.body = response.rows.map(row => row.doc) ctx.body = response.rows.map(row => row.doc)
} }
export async function find(ctx: BBContext) { export async function find(ctx: UserCtx) {
const db = context.getAppDB() const db = context.getAppDB()
ctx.body = await db.get(ctx.params.id) ctx.body = await db.get(ctx.params.id)
} }
export async function destroy(ctx: BBContext) { export async function destroy(ctx: UserCtx) {
const db = context.getAppDB() const db = context.getAppDB()
const automationId = ctx.params.id const automationId = ctx.params.id
const oldAutomation = await db.get<Automation>(automationId) const oldAutomation = await db.get<Automation>(automationId)
@ -221,11 +221,11 @@ export async function destroy(ctx: BBContext) {
builderSocket?.emitAutomationDeletion(ctx, automationId) builderSocket?.emitAutomationDeletion(ctx, automationId)
} }
export async function logSearch(ctx: BBContext) { export async function logSearch(ctx: UserCtx) {
ctx.body = await automations.logs.logSearch(ctx.request.body) ctx.body = await automations.logs.logSearch(ctx.request.body)
} }
export async function clearLogError(ctx: BBContext) { export async function clearLogError(ctx: UserCtx) {
const { automationId, appId } = ctx.request.body const { automationId, appId } = ctx.request.body
await context.doInAppContext(appId, async () => { await context.doInAppContext(appId, async () => {
const db = context.getProdAppDB() const db = context.getProdAppDB()
@ -244,15 +244,15 @@ export async function clearLogError(ctx: BBContext) {
}) })
} }
export async function getActionList(ctx: BBContext) { export async function getActionList(ctx: UserCtx) {
ctx.body = await getActionDefinitions() ctx.body = await getActionDefinitions()
} }
export async function getTriggerList(ctx: BBContext) { export async function getTriggerList(ctx: UserCtx) {
ctx.body = getTriggerDefinitions() ctx.body = getTriggerDefinitions()
} }
export async function getDefinitionList(ctx: BBContext) { export async function getDefinitionList(ctx: UserCtx) {
ctx.body = { ctx.body = {
trigger: getTriggerDefinitions(), trigger: getTriggerDefinitions(),
action: await getActionDefinitions(), action: await getActionDefinitions(),
@ -265,7 +265,7 @@ export async function getDefinitionList(ctx: BBContext) {
* * * *
*********************/ *********************/
export async function trigger(ctx: BBContext) { export async function trigger(ctx: UserCtx) {
const db = context.getAppDB() const db = context.getAppDB()
let automation = await db.get<Automation>(ctx.params.id) let automation = await db.get<Automation>(ctx.params.id)
@ -275,7 +275,9 @@ export async function trigger(ctx: BBContext) {
automation, automation,
{ {
fields: ctx.request.body.fields, fields: ctx.request.body.fields,
timeout: ctx.request.body.timeout * 1000 || 120000, timeout:
ctx.request.body.timeout * 1000 ||
env.getDefaults().AUTOMATION_SYNC_TIMEOUT,
}, },
{ getResponses: true } { getResponses: true }
) )
@ -310,7 +312,7 @@ function prepareTestInput(input: any) {
return input return input
} }
export async function test(ctx: BBContext) { export async function test(ctx: UserCtx) {
const db = context.getAppDB() const db = context.getAppDB()
let automation = await db.get<Automation>(ctx.params.id) let automation = await db.get<Automation>(ctx.params.id)
await setTestFlag(automation._id!) await setTestFlag(automation._id!)

View File

@ -1,7 +1,7 @@
import fetch from "node-fetch" import fetch from "node-fetch"
import env from "../../environment" import env from "../../environment"
import { checkSlashesInUrl } from "../../utilities" import { checkSlashesInUrl } from "../../utilities"
import { request } from "../../utilities/workerRequests" import { createRequest } from "../../utilities/workerRequests"
import { clearLock as redisClearLock } from "../../utilities/redis" import { clearLock as redisClearLock } from "../../utilities/redis"
import { DocumentType } from "../../db/utils" import { DocumentType } from "../../db/utils"
import { import {
@ -13,14 +13,19 @@ import {
} from "@budibase/backend-core" } from "@budibase/backend-core"
import { App } from "@budibase/types" import { App } from "@budibase/types"
async function redirect(ctx: any, method: string, path: string = "global") { async function redirect(
ctx: any,
method: "GET" | "POST" | "DELETE",
path: string = "global"
) {
const { devPath } = ctx.params const { devPath } = ctx.params
const queryString = ctx.originalUrl.split("?")[1] || "" const queryString = ctx.originalUrl.split("?")[1] || ""
const response = await fetch( const response = await fetch(
checkSlashesInUrl( checkSlashesInUrl(
`${env.WORKER_URL}/api/${path}/${devPath}?${queryString}` `${env.WORKER_URL}/api/${path}/${devPath}?${queryString}`
), ),
request(ctx, { createRequest({
ctx,
method, method,
body: ctx.request.body, body: ctx.request.body,
}) })

View File

@ -1,5 +1,4 @@
import { generateQueryID } from "../../../db/utils" import { generateQueryID } from "../../../db/utils"
import { BaseQueryVerbs } from "../../../constants"
import { Thread, ThreadType } from "../../../threads" import { Thread, ThreadType } from "../../../threads"
import { save as saveDatasource } from "../datasource" import { save as saveDatasource } from "../datasource"
import { RestImporter } from "./import" import { RestImporter } from "./import"
@ -7,36 +6,27 @@ import { invalidateDynamicVariables } from "../../../threads/utils"
import env from "../../../environment" import env from "../../../environment"
import { events, context, utils, constants } from "@budibase/backend-core" import { events, context, utils, constants } from "@budibase/backend-core"
import sdk from "../../../sdk" import sdk from "../../../sdk"
import { QueryEvent, QueryResponse } from "../../../threads/definitions" import { QueryEvent } from "../../../threads/definitions"
import { import {
ConfigType, ConfigType,
Query, Query,
UserCtx, UserCtx,
SessionCookie, SessionCookie,
JsonFieldSubType,
QueryResponse,
QueryPreview,
QuerySchema, QuerySchema,
FieldType, FieldType,
type ExecuteQueryRequest, type ExecuteQueryRequest,
type ExecuteQueryResponse, type ExecuteQueryResponse,
type Row, type Row,
} from "@budibase/types" } from "@budibase/types"
import { ValidQueryNameRegex } from "@budibase/shared-core" import { ValidQueryNameRegex, utils as JsonUtils } from "@budibase/shared-core"
const Runner = new Thread(ThreadType.QUERY, { const Runner = new Thread(ThreadType.QUERY, {
timeoutMs: env.QUERY_THREAD_TIMEOUT || 10000, timeoutMs: env.QUERY_THREAD_TIMEOUT,
}) })
// simple function to append "readable" to all read queries
function enrichQueries(input: any) {
const wasArray = Array.isArray(input)
const queries = wasArray ? input : [input]
for (let query of queries) {
if (query.queryVerb === BaseQueryVerbs.READ) {
query.readable = true
}
}
return wasArray ? queries : queries[0]
}
export async function fetch(ctx: UserCtx) { export async function fetch(ctx: UserCtx) {
ctx.body = await sdk.queries.fetch() ctx.body = await sdk.queries.fetch()
} }
@ -84,7 +74,7 @@ export { _import as import }
export async function save(ctx: UserCtx) { export async function save(ctx: UserCtx) {
const db = context.getAppDB() const db = context.getAppDB()
const query = ctx.request.body const query: Query = ctx.request.body
// Validate query name // Validate query name
if (!query?.name.match(ValidQueryNameRegex)) { if (!query?.name.match(ValidQueryNameRegex)) {
@ -100,7 +90,6 @@ export async function save(ctx: UserCtx) {
} else { } else {
eventFn = () => events.query.updated(datasource, query) eventFn = () => events.query.updated(datasource, query)
} }
const response = await db.put(query) const response = await db.put(query)
await eventFn() await eventFn()
query._rev = response.rev query._rev = response.rev
@ -133,7 +122,7 @@ export async function preview(ctx: UserCtx) {
const { datasource, envVars } = await sdk.datasources.getWithEnvVars( const { datasource, envVars } = await sdk.datasources.getWithEnvVars(
ctx.request.body.datasourceId ctx.request.body.datasourceId
) )
const query = ctx.request.body const query: QueryPreview = ctx.request.body
// preview may not have a queryId as it hasn't been saved, but if it does // preview may not have a queryId as it hasn't been saved, but if it does
// this stops dynamic variables from calling the same query // this stops dynamic variables from calling the same query
const { fields, parameters, queryVerb, transformer, queryId, schema } = query const { fields, parameters, queryVerb, transformer, queryId, schema } = query
@ -153,6 +142,69 @@ export async function preview(ctx: UserCtx) {
const authConfigCtx: any = getAuthConfig(ctx) const authConfigCtx: any = getAuthConfig(ctx)
function getSchemaFields(
rows: any[],
keys: string[]
): {
previewSchema: Record<string, string | QuerySchema>
nestedSchemaFields: {
[key: string]: Record<string, string | QuerySchema>
}
} {
const previewSchema: Record<string, string | QuerySchema> = {}
const nestedSchemaFields: {
[key: string]: Record<string, string | QuerySchema>
} = {}
const makeQuerySchema = (
type: FieldType,
name: string,
subtype?: string
): QuerySchema => ({
type,
name,
subtype,
})
if (rows?.length > 0) {
for (let key of [...new Set(keys)] as string[]) {
const field = rows[0][key]
let type = typeof field,
fieldMetadata = makeQuerySchema(FieldType.STRING, key)
if (field)
switch (type) {
case "boolean":
fieldMetadata = makeQuerySchema(FieldType.BOOLEAN, key)
break
case "object":
if (field instanceof Date) {
fieldMetadata = makeQuerySchema(FieldType.DATETIME, key)
} else if (Array.isArray(field)) {
if (JsonUtils.hasSchema(field[0])) {
fieldMetadata = makeQuerySchema(
FieldType.JSON,
key,
JsonFieldSubType.ARRAY
)
} else {
fieldMetadata = makeQuerySchema(FieldType.ARRAY, key)
}
nestedSchemaFields[key] = getSchemaFields(
field,
Object.keys(field[0])
).previewSchema
} else {
fieldMetadata = makeQuerySchema(FieldType.JSON, key)
}
break
case "number":
fieldMetadata = makeQuerySchema(FieldType.NUMBER, key)
break
}
previewSchema[key] = fieldMetadata
}
}
return { previewSchema, nestedSchemaFields }
}
try { try {
const inputs: QueryEvent = { const inputs: QueryEvent = {
appId: ctx.appId, appId: ctx.appId,
@ -171,38 +223,11 @@ export async function preview(ctx: UserCtx) {
}, },
} }
const { rows, keys, info, extra } = await Runner.run<QueryResponse>(inputs) const { rows, keys, info, extra } = (await Runner.run(
const previewSchema: Record<string, QuerySchema> = {} inputs
const makeQuerySchema = (type: FieldType, name: string): QuerySchema => ({ )) as QueryResponse
type, const { previewSchema, nestedSchemaFields } = getSchemaFields(rows, keys)
name,
})
if (rows?.length > 0) {
for (let key of [...new Set(keys)] as string[]) {
const field = rows[0][key]
let type = typeof field,
fieldMetadata = makeQuerySchema(FieldType.STRING, key)
if (field)
switch (type) {
case "boolean":
fieldMetadata = makeQuerySchema(FieldType.BOOLEAN, key)
break
case "object":
if (field instanceof Date) {
fieldMetadata = makeQuerySchema(FieldType.DATETIME, key)
} else if (Array.isArray(field)) {
fieldMetadata = makeQuerySchema(FieldType.ARRAY, key)
} else {
fieldMetadata = makeQuerySchema(FieldType.JSON, key)
}
break
case "number":
fieldMetadata = makeQuerySchema(FieldType.NUMBER, key)
break
}
previewSchema[key] = fieldMetadata
}
}
// if existing schema, update to include any previous schema keys // if existing schema, update to include any previous schema keys
if (existingSchema) { if (existingSchema) {
for (let key of Object.keys(previewSchema)) { for (let key of Object.keys(previewSchema)) {
@ -216,6 +241,7 @@ export async function preview(ctx: UserCtx) {
await events.query.previewed(datasource, query) await events.query.previewed(datasource, query)
ctx.body = { ctx.body = {
rows, rows,
nestedSchemaFields,
schema: previewSchema, schema: previewSchema,
info, info,
extra, extra,

View File

@ -0,0 +1,239 @@
import { Datasource, Query } from "@budibase/types"
import * as setup from "../utilities"
import { databaseTestProviders } from "../../../../integrations/tests/utils"
import mysql from "mysql2/promise"
jest.unmock("mysql2")
jest.unmock("mysql2/promise")
const createTableSQL = `
CREATE TABLE test_table (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL
)
`
const insertSQL = `
INSERT INTO test_table (name) VALUES ('one'), ('two'), ('three'), ('four'), ('five')
`
const dropTableSQL = `
DROP TABLE test_table
`
describe("/queries", () => {
let config = setup.getConfig()
let datasource: Datasource
async function createQuery(query: Partial<Query>): Promise<Query> {
const defaultQuery: Query = {
datasourceId: datasource._id!,
name: "New Query",
parameters: [],
fields: {},
schema: {},
queryVerb: "read",
transformer: "return data",
readable: true,
}
return await config.api.query.create({ ...defaultQuery, ...query })
}
async function withConnection(
callback: (client: mysql.Connection) => Promise<void>
): Promise<void> {
const ds = await databaseTestProviders.mysql.datasource()
const con = await mysql.createConnection(ds.config!)
try {
await callback(con)
} finally {
con.end()
}
}
afterAll(async () => {
await databaseTestProviders.mysql.stop()
setup.afterAll()
})
beforeAll(async () => {
await config.init()
datasource = await config.api.datasource.create(
await databaseTestProviders.mysql.datasource()
)
})
beforeEach(async () => {
await withConnection(async connection => {
const resp = await connection.query(createTableSQL)
await connection.query(insertSQL)
})
})
afterEach(async () => {
await withConnection(async connection => {
await connection.query(dropTableSQL)
})
})
it("should execute a query", async () => {
const query = await createQuery({
fields: {
sql: "SELECT * FROM test_table ORDER BY id",
},
})
const result = await config.api.query.execute(query._id!)
expect(result.data).toEqual([
{
id: 1,
name: "one",
},
{
id: 2,
name: "two",
},
{
id: 3,
name: "three",
},
{
id: 4,
name: "four",
},
{
id: 5,
name: "five",
},
])
})
it("should be able to transform a query", async () => {
const query = await createQuery({
fields: {
sql: "SELECT * FROM test_table WHERE id = 1",
},
transformer: `
data[0].id = data[0].id + 1;
return data;
`,
})
const result = await config.api.query.execute(query._id!)
expect(result.data).toEqual([
{
id: 2,
name: "one",
},
])
})
it("should be able to insert with bindings", async () => {
const query = await createQuery({
fields: {
sql: "INSERT INTO test_table (name) VALUES ({{ foo }})",
},
parameters: [
{
name: "foo",
default: "bar",
},
],
queryVerb: "create",
})
const result = await config.api.query.execute(query._id!, {
parameters: {
foo: "baz",
},
})
expect(result.data).toEqual([
{
created: true,
},
])
await withConnection(async connection => {
const [rows] = await connection.query(
"SELECT * FROM test_table WHERE name = 'baz'"
)
expect(rows).toHaveLength(1)
})
})
it("should be able to update rows", async () => {
const query = await createQuery({
fields: {
sql: "UPDATE test_table SET name = {{ name }} WHERE id = {{ id }}",
},
parameters: [
{
name: "id",
default: "",
},
{
name: "name",
default: "updated",
},
],
queryVerb: "update",
})
const result = await config.api.query.execute(query._id!, {
parameters: {
id: "1",
name: "foo",
},
})
expect(result.data).toEqual([
{
updated: true,
},
])
await withConnection(async connection => {
const [rows] = await connection.query(
"SELECT * FROM test_table WHERE id = 1"
)
expect(rows).toEqual([{ id: 1, name: "foo" }])
})
})
it("should be able to delete rows", async () => {
const query = await createQuery({
fields: {
sql: "DELETE FROM test_table WHERE id = {{ id }}",
},
parameters: [
{
name: "id",
default: "",
},
],
queryVerb: "delete",
})
const result = await config.api.query.execute(query._id!, {
parameters: {
id: "1",
},
})
expect(result.data).toEqual([
{
deleted: true,
},
])
await withConnection(async connection => {
const [rows] = await connection.query(
"SELECT * FROM test_table WHERE id = 1"
)
expect(rows).toHaveLength(0)
})
})
})

View File

@ -167,4 +167,77 @@ describe("/queries", () => {
expect(rows).toHaveLength(1) expect(rows).toHaveLength(1)
}) })
}) })
it("should be able to update rows", async () => {
const query = await createQuery({
fields: {
sql: "UPDATE test_table SET name = {{ name }} WHERE id = {{ id }}",
},
parameters: [
{
name: "id",
default: "",
},
{
name: "name",
default: "updated",
},
],
queryVerb: "update",
})
const result = await config.api.query.execute(query._id!, {
parameters: {
id: "1",
name: "foo",
},
})
expect(result.data).toEqual([
{
updated: true,
},
])
await withClient(async client => {
const { rows } = await client.query(
"SELECT * FROM test_table WHERE id = 1"
)
expect(rows).toEqual([{ id: 1, name: "foo" }])
})
})
it("should be able to delete rows", async () => {
const query = await createQuery({
fields: {
sql: "DELETE FROM test_table WHERE id = {{ id }}",
},
parameters: [
{
name: "id",
default: "",
},
],
queryVerb: "delete",
})
const result = await config.api.query.execute(query._id!, {
parameters: {
id: "1",
},
})
expect(result.data).toEqual([
{
deleted: true,
},
])
await withClient(async client => {
const { rows } = await client.query(
"SELECT * FROM test_table WHERE id = 1"
)
expect(rows).toHaveLength(0)
})
})
}) })

View File

@ -9,6 +9,7 @@ import * as serverLog from "./steps/serverLog"
import * as discord from "./steps/discord" import * as discord from "./steps/discord"
import * as slack from "./steps/slack" import * as slack from "./steps/slack"
import * as zapier from "./steps/zapier" import * as zapier from "./steps/zapier"
import * as n8n from "./steps/n8n"
import * as make from "./steps/make" import * as make from "./steps/make"
import * as filter from "./steps/filter" import * as filter from "./steps/filter"
import * as delay from "./steps/delay" import * as delay from "./steps/delay"
@ -48,6 +49,7 @@ const ACTION_IMPLS: Record<
slack: slack.run, slack: slack.run,
zapier: zapier.run, zapier: zapier.run,
integromat: make.run, integromat: make.run,
n8n: n8n.run,
} }
export const BUILTIN_ACTION_DEFINITIONS: Record<string, AutomationStepSchema> = export const BUILTIN_ACTION_DEFINITIONS: Record<string, AutomationStepSchema> =
{ {
@ -70,6 +72,7 @@ export const BUILTIN_ACTION_DEFINITIONS: Record<string, AutomationStepSchema> =
slack: slack.definition, slack: slack.definition,
zapier: zapier.definition, zapier: zapier.definition,
integromat: make.definition, integromat: make.definition,
n8n: n8n.definition,
} }
// don't add the bash script/definitions unless in self host // don't add the bash script/definitions unless in self host

View File

@ -15,7 +15,7 @@ const PATH_PREFIX = "/bulladmin"
export async function init() { export async function init() {
// Set up queues for bull board admin // Set up queues for bull board admin
const backupQueue = await backups.getBackupQueue() const backupQueue = backups.getBackupQueue()
const queues = [automationQueue] const queues = [automationQueue]
if (backupQueue) { if (backupQueue) {
queues.push(backupQueue) queues.push(backupQueue)

View File

@ -65,7 +65,7 @@ export async function run({ inputs, context }: AutomationStepInput) {
success = true success = true
try { try {
stdout = execSync(command, { stdout = execSync(command, {
timeout: environment.QUERY_THREAD_TIMEOUT || 500, timeout: environment.QUERY_THREAD_TIMEOUT,
}).toString() }).toString()
} catch (err: any) { } catch (err: any) {
stdout = err.message stdout = err.message

View File

@ -10,6 +10,8 @@ import {
AutomationStepSchema, AutomationStepSchema,
AutomationStepType, AutomationStepType,
} from "@budibase/types" } from "@budibase/types"
import { utils } from "@budibase/backend-core"
import env from "../../environment"
export const definition: AutomationStepSchema = { export const definition: AutomationStepSchema = {
name: "External Data Connector", name: "External Data Connector",

View File

@ -34,28 +34,8 @@ export const definition: AutomationStepSchema = {
type: AutomationIOType.JSON, type: AutomationIOType.JSON,
title: "Payload", title: "Payload",
}, },
value1: {
type: AutomationIOType.STRING,
title: "Input Value 1",
},
value2: {
type: AutomationIOType.STRING,
title: "Input Value 2",
},
value3: {
type: AutomationIOType.STRING,
title: "Input Value 3",
},
value4: {
type: AutomationIOType.STRING,
title: "Input Value 4",
},
value5: {
type: AutomationIOType.STRING,
title: "Input Value 5",
},
}, },
required: ["url", "value1", "value2", "value3", "value4", "value5"], required: ["url", "body"],
}, },
outputs: { outputs: {
properties: { properties: {

View File

@ -0,0 +1,125 @@
import fetch, { HeadersInit } from "node-fetch"
import { getFetchResponse } from "./utils"
import {
AutomationActionStepId,
AutomationStepSchema,
AutomationStepInput,
AutomationStepType,
AutomationIOType,
AutomationFeature,
HttpMethod,
} from "@budibase/types"
export const definition: AutomationStepSchema = {
name: "n8n Integration",
stepTitle: "n8n",
tagline: "Trigger an n8n workflow",
description:
"Performs a webhook call to n8n and gets the response (if configured)",
icon: "ri-shut-down-line",
stepId: AutomationActionStepId.n8n,
type: AutomationStepType.ACTION,
internal: false,
features: {
[AutomationFeature.LOOPING]: true,
},
inputs: {},
schema: {
inputs: {
properties: {
url: {
type: AutomationIOType.STRING,
title: "Webhook URL",
},
method: {
type: AutomationIOType.STRING,
title: "Method",
enum: Object.values(HttpMethod),
},
authorization: {
type: AutomationIOType.STRING,
title: "Authorization",
},
body: {
type: AutomationIOType.JSON,
title: "Payload",
},
},
required: ["url", "method"],
},
outputs: {
properties: {
success: {
type: AutomationIOType.BOOLEAN,
description: "Whether call was successful",
},
httpStatus: {
type: AutomationIOType.NUMBER,
description: "The HTTP status code returned",
},
response: {
type: AutomationIOType.OBJECT,
description: "The webhook response - this can have properties",
},
},
required: ["success", "response"],
},
},
}
export async function run({ inputs }: AutomationStepInput) {
const { url, body, method, authorization } = inputs
let payload = {}
try {
payload = body?.value ? JSON.parse(body?.value) : {}
} catch (err) {
return {
httpStatus: 400,
response: "Invalid payload JSON",
success: false,
}
}
if (!url?.trim()?.length) {
return {
httpStatus: 400,
response: "Missing Webhook URL",
success: false,
}
}
let response
let request: {
method: string
headers: HeadersInit
body?: string
} = {
method: method || HttpMethod.GET,
headers: {
"Content-Type": "application/json",
Authorization: authorization,
},
}
if (!["GET", "HEAD"].includes(request.method)) {
request.body = JSON.stringify({
...payload,
})
}
try {
response = await fetch(url, request)
} catch (err: any) {
return {
httpStatus: 400,
response: err.message,
success: false,
}
}
const { status, message } = await getFetchResponse(response)
return {
httpStatus: status,
success: status === 200,
response: message,
}
}

View File

@ -9,8 +9,9 @@ import {
AutomationCustomIOType, AutomationCustomIOType,
} from "@budibase/types" } from "@budibase/types"
import * as triggers from "../triggers" import * as triggers from "../triggers"
import { db as dbCore, context } from "@budibase/backend-core" import { context } from "@budibase/backend-core"
import { features } from "@budibase/pro" import { features } from "@budibase/pro"
import env from "../../environment"
export const definition: AutomationStepSchema = { export const definition: AutomationStepSchema = {
name: "Trigger an automation", name: "Trigger an automation",
@ -76,7 +77,8 @@ export async function run({ inputs }: AutomationStepInput) {
automation, automation,
{ {
fields: { ...fieldParams }, fields: { ...fieldParams },
timeout: inputs.timeout * 1000 || 120000, timeout:
inputs.timeout * 1000 || env.getDefaults().AUTOMATION_SYNC_TIMEOUT,
}, },
{ getResponses: true } { getResponses: true }
) )

View File

@ -32,26 +32,6 @@ export const definition: AutomationStepSchema = {
type: AutomationIOType.JSON, type: AutomationIOType.JSON,
title: "Payload", title: "Payload",
}, },
value1: {
type: AutomationIOType.STRING,
title: "Payload Value 1",
},
value2: {
type: AutomationIOType.STRING,
title: "Payload Value 2",
},
value3: {
type: AutomationIOType.STRING,
title: "Payload Value 3",
},
value4: {
type: AutomationIOType.STRING,
title: "Payload Value 4",
},
value5: {
type: AutomationIOType.STRING,
title: "Payload Value 5",
},
}, },
required: ["url"], required: ["url"],
}, },

View File

@ -0,0 +1,68 @@
import { getConfig, afterAll, runStep, actions } from "./utilities"
describe("test the outgoing webhook action", () => {
let config = getConfig()
beforeAll(async () => {
await config.init()
})
afterAll()
it("should be able to run the action and default to 'get'", async () => {
const res = await runStep(actions.n8n.stepId, {
url: "http://www.example.com",
body: {
test: "IGNORE_ME",
},
})
expect(res.response.url).toEqual("http://www.example.com")
expect(res.response.method).toEqual("GET")
expect(res.response.body).toBeUndefined()
expect(res.success).toEqual(true)
})
it("should add the payload props when a JSON string is provided", async () => {
const payload = `{ "name": "Adam", "age": 9 }`
const res = await runStep(actions.n8n.stepId, {
body: {
value: payload,
},
method: "POST",
url: "http://www.example.com",
})
expect(res.response.url).toEqual("http://www.example.com")
expect(res.response.method).toEqual("POST")
expect(res.response.body).toEqual(`{"name":"Adam","age":9}`)
expect(res.success).toEqual(true)
})
it("should return a 400 if the JSON payload string is malformed", async () => {
const payload = `{ value1 1 }`
const res = await runStep(actions.n8n.stepId, {
value1: "ONE",
body: {
value: payload,
},
method: "POST",
url: "http://www.example.com",
})
expect(res.httpStatus).toEqual(400)
expect(res.response).toEqual("Invalid payload JSON")
expect(res.success).toEqual(false)
})
it("should not append the body if the method is HEAD", async () => {
const res = await runStep(actions.n8n.stepId, {
url: "http://www.example.com",
method: "HEAD",
body: {
test: "IGNORE_ME",
},
})
expect(res.response.url).toEqual("http://www.example.com")
expect(res.response.method).toEqual("HEAD")
expect(res.response.body).toBeUndefined()
expect(res.success).toEqual(true)
})
})

View File

@ -3,6 +3,7 @@ jest.spyOn(global.console, "error")
import * as setup from "./utilities" import * as setup from "./utilities"
import * as automation from "../index" import * as automation from "../index"
import { serverLogAutomation } from "../../tests/utilities/structures" import { serverLogAutomation } from "../../tests/utilities/structures"
import env from "../../environment"
describe("Test triggering an automation from another automation", () => { describe("Test triggering an automation from another automation", () => {
let config = setup.getConfig() let config = setup.getConfig()
@ -22,7 +23,10 @@ describe("Test triggering an automation from another automation", () => {
let newAutomation = await config.createAutomation(automation) let newAutomation = await config.createAutomation(automation)
const inputs: any = { const inputs: any = {
automation: { automationId: newAutomation._id, timeout: 12000 }, automation: {
automationId: newAutomation._id,
timeout: env.getDefaults().AUTOMATION_THREAD_TIMEOUT,
},
} }
const res = await setup.runStep( const res = await setup.runStep(
setup.actions.TRIGGER_AUTOMATION_RUN.stepId, setup.actions.TRIGGER_AUTOMATION_RUN.stepId,
@ -33,7 +37,12 @@ describe("Test triggering an automation from another automation", () => {
}) })
it("should fail gracefully if the automation id is incorrect", async () => { it("should fail gracefully if the automation id is incorrect", async () => {
const inputs: any = { automation: { automationId: null, timeout: 12000 } } const inputs: any = {
automation: {
automationId: null,
timeout: env.getDefaults().AUTOMATION_THREAD_TIMEOUT,
},
}
const res = await setup.runStep( const res = await setup.runStep(
setup.actions.TRIGGER_AUTOMATION_RUN.stepId, setup.actions.TRIGGER_AUTOMATION_RUN.stepId,
inputs inputs

View File

@ -18,6 +18,21 @@ function parseIntSafe(number?: string) {
} }
} }
const DEFAULTS = {
QUERY_THREAD_TIMEOUT: 10000,
AUTOMATION_THREAD_TIMEOUT: 12000,
AUTOMATION_SYNC_TIMEOUT: 120000,
AUTOMATION_MAX_ITERATIONS: 200,
JS_PER_EXECUTION_TIME_LIMIT_MS: 1000,
TEMPLATE_REPOSITORY: "app",
PLUGINS_DIR: "/plugins",
FORKED_PROCESS_NAME: "main",
JS_RUNNER_MEMORY_LIMIT: 64,
}
const QUERY_THREAD_TIMEOUT =
parseIntSafe(process.env.QUERY_THREAD_TIMEOUT) ||
DEFAULTS.QUERY_THREAD_TIMEOUT
const environment = { const environment = {
// features // features
APP_FEATURES: process.env.APP_FEATURES, APP_FEATURES: process.env.APP_FEATURES,
@ -42,7 +57,8 @@ const environment = {
JEST_WORKER_ID: process.env.JEST_WORKER_ID, JEST_WORKER_ID: process.env.JEST_WORKER_ID,
BUDIBASE_ENVIRONMENT: process.env.BUDIBASE_ENVIRONMENT, BUDIBASE_ENVIRONMENT: process.env.BUDIBASE_ENVIRONMENT,
DISABLE_ACCOUNT_PORTAL: process.env.DISABLE_ACCOUNT_PORTAL, DISABLE_ACCOUNT_PORTAL: process.env.DISABLE_ACCOUNT_PORTAL,
TEMPLATE_REPOSITORY: process.env.TEMPLATE_REPOSITORY || "app", TEMPLATE_REPOSITORY:
process.env.TEMPLATE_REPOSITORY || DEFAULTS.TEMPLATE_REPOSITORY,
DISABLE_AUTO_PROD_APP_SYNC: process.env.DISABLE_AUTO_PROD_APP_SYNC, DISABLE_AUTO_PROD_APP_SYNC: process.env.DISABLE_AUTO_PROD_APP_SYNC,
SESSION_UPDATE_PERIOD: process.env.SESSION_UPDATE_PERIOD, SESSION_UPDATE_PERIOD: process.env.SESSION_UPDATE_PERIOD,
// minor // minor
@ -50,14 +66,20 @@ const environment = {
LOGGER: process.env.LOGGER, LOGGER: process.env.LOGGER,
ACCOUNT_PORTAL_URL: process.env.ACCOUNT_PORTAL_URL, ACCOUNT_PORTAL_URL: process.env.ACCOUNT_PORTAL_URL,
AUTOMATION_MAX_ITERATIONS: AUTOMATION_MAX_ITERATIONS:
parseIntSafe(process.env.AUTOMATION_MAX_ITERATIONS) || 200, parseIntSafe(process.env.AUTOMATION_MAX_ITERATIONS) ||
DEFAULTS.AUTOMATION_MAX_ITERATIONS,
SENDGRID_API_KEY: process.env.SENDGRID_API_KEY, SENDGRID_API_KEY: process.env.SENDGRID_API_KEY,
DYNAMO_ENDPOINT: process.env.DYNAMO_ENDPOINT, DYNAMO_ENDPOINT: process.env.DYNAMO_ENDPOINT,
QUERY_THREAD_TIMEOUT: parseIntSafe(process.env.QUERY_THREAD_TIMEOUT), QUERY_THREAD_TIMEOUT: QUERY_THREAD_TIMEOUT,
AUTOMATION_THREAD_TIMEOUT:
parseIntSafe(process.env.AUTOMATION_THREAD_TIMEOUT) ||
DEFAULTS.AUTOMATION_THREAD_TIMEOUT > QUERY_THREAD_TIMEOUT
? DEFAULTS.AUTOMATION_THREAD_TIMEOUT
: QUERY_THREAD_TIMEOUT,
SQL_MAX_ROWS: process.env.SQL_MAX_ROWS, SQL_MAX_ROWS: process.env.SQL_MAX_ROWS,
BB_ADMIN_USER_EMAIL: process.env.BB_ADMIN_USER_EMAIL, BB_ADMIN_USER_EMAIL: process.env.BB_ADMIN_USER_EMAIL,
BB_ADMIN_USER_PASSWORD: process.env.BB_ADMIN_USER_PASSWORD, BB_ADMIN_USER_PASSWORD: process.env.BB_ADMIN_USER_PASSWORD,
PLUGINS_DIR: process.env.PLUGINS_DIR || "/plugins", PLUGINS_DIR: process.env.PLUGINS_DIR || DEFAULTS.PLUGINS_DIR,
OPENAI_API_KEY: process.env.OPENAI_API_KEY, OPENAI_API_KEY: process.env.OPENAI_API_KEY,
MAX_IMPORT_SIZE_MB: process.env.MAX_IMPORT_SIZE_MB, MAX_IMPORT_SIZE_MB: process.env.MAX_IMPORT_SIZE_MB,
SESSION_EXPIRY_SECONDS: process.env.SESSION_EXPIRY_SECONDS, SESSION_EXPIRY_SECONDS: process.env.SESSION_EXPIRY_SECONDS,
@ -70,12 +92,21 @@ const environment = {
ENABLE_ANALYTICS: process.env.ENABLE_ANALYTICS, ENABLE_ANALYTICS: process.env.ENABLE_ANALYTICS,
SELF_HOSTED: process.env.SELF_HOSTED, SELF_HOSTED: process.env.SELF_HOSTED,
HTTP_MB_LIMIT: process.env.HTTP_MB_LIMIT, HTTP_MB_LIMIT: process.env.HTTP_MB_LIMIT,
FORKED_PROCESS_NAME: process.env.FORKED_PROCESS_NAME || "main", FORKED_PROCESS_NAME:
process.env.FORKED_PROCESS_NAME || DEFAULTS.FORKED_PROCESS_NAME,
JS_PER_INVOCATION_TIMEOUT_MS: JS_PER_INVOCATION_TIMEOUT_MS:
parseIntSafe(process.env.JS_PER_EXECUTION_TIME_LIMIT_MS) || 1000, parseIntSafe(process.env.JS_PER_EXECUTION_TIME_LIMIT_MS) ||
DEFAULTS.JS_PER_EXECUTION_TIME_LIMIT_MS,
JS_PER_REQUEST_TIMEOUT_MS: parseIntSafe( JS_PER_REQUEST_TIMEOUT_MS: parseIntSafe(
process.env.JS_PER_REQUEST_TIME_LIMIT_MS process.env.JS_PER_REQUEST_TIME_LIMIT_MS
), ),
TOP_LEVEL_PATH:
process.env.TOP_LEVEL_PATH || process.env.SERVER_TOP_LEVEL_PATH,
APP_MIGRATION_TIMEOUT: parseIntSafe(process.env.APP_MIGRATION_TIMEOUT),
JS_RUNNER_MEMORY_LIMIT:
parseIntSafe(process.env.JS_RUNNER_MEMORY_LIMIT) ||
DEFAULTS.JS_RUNNER_MEMORY_LIMIT,
LOG_JS_ERRORS: process.env.LOG_JS_ERRORS,
// old // old
CLIENT_ID: process.env.CLIENT_ID, CLIENT_ID: process.env.CLIENT_ID,
_set(key: string, value: any) { _set(key: string, value: any) {
@ -92,12 +123,9 @@ const environment = {
isInThread: () => { isInThread: () => {
return process.env.FORKED_PROCESS return process.env.FORKED_PROCESS
}, },
TOP_LEVEL_PATH: getDefaults: () => {
process.env.TOP_LEVEL_PATH || process.env.SERVER_TOP_LEVEL_PATH, return DEFAULTS
APP_MIGRATION_TIMEOUT: parseIntSafe(process.env.APP_MIGRATION_TIMEOUT), },
JS_RUNNER_MEMORY_LIMIT:
parseIntSafe(process.env.JS_RUNNER_MEMORY_LIMIT) || 64,
LOG_JS_ERRORS: process.env.LOG_JS_ERRORS,
} }
// clean up any environment variable edge cases // clean up any environment variable edge cases

View File

@ -10,6 +10,7 @@ import {
RestAuthType, RestAuthType,
RestBasicAuthConfig, RestBasicAuthConfig,
RestBearerAuthConfig, RestBearerAuthConfig,
HttpMethod,
} from "@budibase/types" } from "@budibase/types"
import get from "lodash/get" import get from "lodash/get"
import * as https from "https" import * as https from "https"
@ -86,30 +87,30 @@ const SCHEMA: Integration = {
query: { query: {
create: { create: {
readable: true, readable: true,
displayName: "POST", displayName: HttpMethod.POST,
type: QueryType.FIELDS, type: QueryType.FIELDS,
fields: coreFields, fields: coreFields,
}, },
read: { read: {
displayName: "GET", displayName: HttpMethod.GET,
readable: true, readable: true,
type: QueryType.FIELDS, type: QueryType.FIELDS,
fields: coreFields, fields: coreFields,
}, },
update: { update: {
displayName: "PUT", displayName: HttpMethod.PUT,
readable: true, readable: true,
type: QueryType.FIELDS, type: QueryType.FIELDS,
fields: coreFields, fields: coreFields,
}, },
patch: { patch: {
displayName: "PATCH", displayName: HttpMethod.PATCH,
readable: true, readable: true,
type: QueryType.FIELDS, type: QueryType.FIELDS,
fields: coreFields, fields: coreFields,
}, },
delete: { delete: {
displayName: "DELETE", displayName: HttpMethod.DELETE,
type: QueryType.FIELDS, type: QueryType.FIELDS,
fields: coreFields, fields: coreFields,
}, },
@ -358,7 +359,7 @@ class RestIntegration implements IntegrationBase {
path = "", path = "",
queryString = "", queryString = "",
headers = {}, headers = {},
method = "GET", method = HttpMethod.GET,
disabledHeaders, disabledHeaders,
bodyType, bodyType,
requestBody, requestBody,
@ -413,23 +414,23 @@ class RestIntegration implements IntegrationBase {
} }
async create(opts: RestQuery) { async create(opts: RestQuery) {
return this._req({ ...opts, method: "POST" }) return this._req({ ...opts, method: HttpMethod.POST })
} }
async read(opts: RestQuery) { async read(opts: RestQuery) {
return this._req({ ...opts, method: "GET" }) return this._req({ ...opts, method: HttpMethod.GET })
} }
async update(opts: RestQuery) { async update(opts: RestQuery) {
return this._req({ ...opts, method: "PUT" }) return this._req({ ...opts, method: HttpMethod.PUT })
} }
async patch(opts: RestQuery) { async patch(opts: RestQuery) {
return this._req({ ...opts, method: "PATCH" }) return this._req({ ...opts, method: HttpMethod.PATCH })
} }
async delete(opts: RestQuery) { async delete(opts: RestQuery) {
return this._req({ ...opts, method: "DELETE" }) return this._req({ ...opts, method: HttpMethod.DELETE })
} }
} }

View File

@ -3,6 +3,7 @@ jest.unmock("pg")
import { Datasource } from "@budibase/types" import { Datasource } from "@budibase/types"
import * as postgres from "./postgres" import * as postgres from "./postgres"
import * as mongodb from "./mongodb" import * as mongodb from "./mongodb"
import * as mysql from "./mysql"
import { StartedTestContainer } from "testcontainers" import { StartedTestContainer } from "testcontainers"
jest.setTimeout(30000) jest.setTimeout(30000)
@ -13,4 +14,4 @@ export interface DatabaseProvider {
datasource(): Promise<Datasource> datasource(): Promise<Datasource>
} }
export const databaseTestProviders = { postgres, mongodb } export const databaseTestProviders = { postgres, mongodb, mysql }

View File

@ -11,7 +11,9 @@ export async function start(): Promise<StartedTestContainer> {
MONGO_INITDB_ROOT_PASSWORD: "password", MONGO_INITDB_ROOT_PASSWORD: "password",
}) })
.withWaitStrategy( .withWaitStrategy(
Wait.forSuccessfulCommand(`mongosh --eval "db.version()"`) Wait.forSuccessfulCommand(
`mongosh --eval "db.version()"`
).withStartupTimeout(10000)
) )
.start() .start()
} }

View File

@ -0,0 +1,61 @@
import { Datasource, SourceName } from "@budibase/types"
import { GenericContainer, Wait, StartedTestContainer } from "testcontainers"
import { AbstractWaitStrategy } from "testcontainers/build/wait-strategies/wait-strategy"
let container: StartedTestContainer | undefined
class MySQLWaitStrategy extends AbstractWaitStrategy {
async waitUntilReady(container: any, boundPorts: any, startTime?: Date) {
// Because MySQL first starts itself up, runs an init script, then restarts,
// it's possible for the mysqladmin ping to succeed early and then tests to
// run against a MySQL that's mid-restart and fail. To get around this, we
// wait for logs and then do a ping check.
const logs = Wait.forLogMessage(
"/usr/sbin/mysqld: ready for connections",
2
)
await logs.waitUntilReady(container, boundPorts, startTime)
const command = Wait.forSuccessfulCommand(
`mysqladmin ping -h localhost -P 3306 -u root -ppassword`
)
await command.waitUntilReady(container)
}
}
export async function start(): Promise<StartedTestContainer> {
return await new GenericContainer("mysql:8.3")
.withExposedPorts(3306)
.withEnvironment({ MYSQL_ROOT_PASSWORD: "password" })
.withWaitStrategy(new MySQLWaitStrategy().withStartupTimeout(10000))
.start()
}
export async function datasource(): Promise<Datasource> {
if (!container) {
container = await start()
}
const host = container.getHost()
const port = container.getMappedPort(3306)
return {
type: "datasource_plus",
source: SourceName.MYSQL,
plus: true,
config: {
host,
port,
user: "root",
password: "password",
database: "mysql",
},
}
}
export async function stop() {
if (container) {
await container.stop()
container = undefined
}
}

View File

@ -303,7 +303,7 @@ class Orchestrator {
if (timeout) { if (timeout) {
setTimeout(() => { setTimeout(() => {
timeoutFlag = true timeoutFlag = true
}, timeout || 12000) }, timeout || env.AUTOMATION_THREAD_TIMEOUT)
} }
stepCount++ stepCount++
@ -621,7 +621,7 @@ export async function executeInThread(job: Job<AutomationData>) {
const timeoutPromise = new Promise((resolve, reject) => { const timeoutPromise = new Promise((resolve, reject) => {
setTimeout(() => { setTimeout(() => {
reject(new Error("Timeout exceeded")) reject(new Error("Timeout exceeded"))
}, job.data.event.timeout || 12000) }, job.data.event.timeout || env.AUTOMATION_THREAD_TIMEOUT)
}) })
return await context.doInAppContext(appId, async () => { return await context.doInAppContext(appId, async () => {

View File

@ -1,4 +1,10 @@
import { Response, default as fetch } from "node-fetch" import {
Response,
default as fetch,
type RequestInit,
Headers,
HeadersInit,
} from "node-fetch"
import env from "../environment" import env from "../environment"
import { checkSlashesInUrl } from "./index" import { checkSlashesInUrl } from "./index"
import { import {
@ -7,36 +13,62 @@ import {
tenancy, tenancy,
logging, logging,
env as coreEnv, env as coreEnv,
utils,
} from "@budibase/backend-core" } from "@budibase/backend-core"
import { Ctx, User, EmailInvite } from "@budibase/types" import { Ctx, User, EmailInvite } from "@budibase/types"
export function request(ctx?: Ctx, request?: any) { interface Request {
if (!request.headers) { ctx?: Ctx
request.headers = {} method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH"
headers?: { [key: string]: string }
body?: { [key: string]: any }
}
export function createRequest(request: Request): RequestInit {
const headers: Record<string, string> = {}
const requestInit: RequestInit = {
method: request.method,
} }
if (!ctx) {
request.headers[constants.Header.API_KEY] = coreEnv.INTERNAL_API_KEY const ctx = request.ctx
if (tenancy.isTenantIdSet()) {
request.headers[constants.Header.TENANT_ID] = tenancy.getTenantId() if (!ctx && coreEnv.INTERNAL_API_KEY) {
headers[constants.Header.API_KEY] = coreEnv.INTERNAL_API_KEY
} else if (ctx && ctx.headers) {
// copy all Budibase utilised headers over - copying everything can have
// side effects like requests being rejected due to odd content types etc
for (let header of Object.values(constants.Header)) {
const value = ctx.headers[header]
if (value === undefined) {
continue
}
headers[header] = Array.isArray(value) ? value[0] : value
}
// be specific about auth headers
const cookie = ctx.headers[constants.Header.COOKIE],
apiKey = ctx.headers[constants.Header.API_KEY]
if (cookie) {
headers[constants.Header.COOKIE] = cookie
} else if (apiKey) {
headers[constants.Header.API_KEY] = Array.isArray(apiKey)
? apiKey[0]
: apiKey
} }
} }
// apply tenancy if its available
if (tenancy.isTenantIdSet()) {
headers[constants.Header.TENANT_ID] = tenancy.getTenantId()
}
if (request.body && Object.keys(request.body).length > 0) { if (request.body && Object.keys(request.body).length > 0) {
request.headers["Content-Type"] = "application/json" headers["Content-Type"] = "application/json"
request.body = requestInit.body = JSON.stringify(request.body)
typeof request.body === "object"
? JSON.stringify(request.body)
: request.body
} else {
delete request.body
}
if (ctx && ctx.headers) {
request.headers = ctx.headers
} }
// add x-budibase-correlation-id header logging.correlation.setHeader(headers)
logging.correlation.setHeader(request.headers) requestInit.headers = headers
return requestInit
return request
} }
async function checkResponse( async function checkResponse(
@ -54,7 +86,7 @@ async function checkResponse(
} }
const msg = `Unable to ${errorMsg} - ${responseErrorMessage}` const msg = `Unable to ${errorMsg} - ${responseErrorMessage}`
if (ctx) { if (ctx) {
ctx.throw(msg, response.status) ctx.throw(response.status || 500, msg)
} else { } else {
throw msg throw msg
} }
@ -85,7 +117,7 @@ export async function sendSmtpEmail({
// tenant ID will be set in header // tenant ID will be set in header
const response = await fetch( const response = await fetch(
checkSlashesInUrl(env.WORKER_URL + `/api/global/email/send`), checkSlashesInUrl(env.WORKER_URL + `/api/global/email/send`),
request(undefined, { createRequest({
method: "POST", method: "POST",
body: { body: {
email: to, email: to,
@ -107,7 +139,8 @@ export async function removeAppFromUserRoles(ctx: Ctx, appId: string) {
const prodAppId = dbCore.getProdAppID(appId) const prodAppId = dbCore.getProdAppID(appId)
const response = await fetch( const response = await fetch(
checkSlashesInUrl(env.WORKER_URL + `/api/global/roles/${prodAppId}`), checkSlashesInUrl(env.WORKER_URL + `/api/global/roles/${prodAppId}`),
request(ctx, { createRequest({
ctx,
method: "DELETE", method: "DELETE",
}) })
) )
@ -118,7 +151,7 @@ export async function allGlobalUsers(ctx: Ctx) {
const response = await fetch( const response = await fetch(
checkSlashesInUrl(env.WORKER_URL + "/api/global/users"), checkSlashesInUrl(env.WORKER_URL + "/api/global/users"),
// we don't want to use API key when getting self // we don't want to use API key when getting self
request(ctx, { method: "GET" }) createRequest({ ctx, method: "GET" })
) )
return checkResponse(response, "get users", { ctx }) return checkResponse(response, "get users", { ctx })
} }
@ -127,7 +160,7 @@ export async function saveGlobalUser(ctx: Ctx) {
const response = await fetch( const response = await fetch(
checkSlashesInUrl(env.WORKER_URL + "/api/global/users"), checkSlashesInUrl(env.WORKER_URL + "/api/global/users"),
// we don't want to use API key when getting self // we don't want to use API key when getting self
request(ctx, { method: "POST", body: ctx.request.body }) createRequest({ ctx, method: "POST", body: ctx.request.body })
) )
return checkResponse(response, "save user", { ctx }) return checkResponse(response, "save user", { ctx })
} }
@ -138,7 +171,7 @@ export async function deleteGlobalUser(ctx: Ctx) {
env.WORKER_URL + `/api/global/users/${ctx.params.userId}` env.WORKER_URL + `/api/global/users/${ctx.params.userId}`
), ),
// we don't want to use API key when getting self // we don't want to use API key when getting self
request(ctx, { method: "DELETE" }) createRequest({ ctx, method: "DELETE" })
) )
return checkResponse(response, "delete user", { ctx }) return checkResponse(response, "delete user", { ctx })
} }
@ -149,7 +182,7 @@ export async function readGlobalUser(ctx: Ctx): Promise<User> {
env.WORKER_URL + `/api/global/users/${ctx.params.userId}` env.WORKER_URL + `/api/global/users/${ctx.params.userId}`
), ),
// we don't want to use API key when getting self // we don't want to use API key when getting self
request(ctx, { method: "GET" }) createRequest({ ctx, method: "GET" })
) )
return checkResponse(response, "get user", { ctx }) return checkResponse(response, "get user", { ctx })
} }
@ -159,7 +192,7 @@ export async function getChecklist(): Promise<{
}> { }> {
const response = await fetch( const response = await fetch(
checkSlashesInUrl(env.WORKER_URL + "/api/global/configs/checklist"), checkSlashesInUrl(env.WORKER_URL + "/api/global/configs/checklist"),
request(undefined, { method: "GET" }) createRequest({ method: "GET" })
) )
return checkResponse(response, "get checklist") return checkResponse(response, "get checklist")
} }
@ -167,7 +200,7 @@ export async function getChecklist(): Promise<{
export async function generateApiKey(userId: string) { export async function generateApiKey(userId: string) {
const response = await fetch( const response = await fetch(
checkSlashesInUrl(env.WORKER_URL + "/api/global/self/api_key"), checkSlashesInUrl(env.WORKER_URL + "/api/global/self/api_key"),
request(undefined, { method: "POST", body: { userId } }) createRequest({ method: "POST", body: { userId } })
) )
return checkResponse(response, "generate API key") return checkResponse(response, "generate API key")
} }

View File

@ -16,4 +16,5 @@ export enum Header {
CORRELATION_ID = "x-budibase-correlation-id", CORRELATION_ID = "x-budibase-correlation-id",
AUTHORIZATION = "authorization", AUTHORIZATION = "authorization",
MIGRATING_APP = "x-budibase-migrating-app", MIGRATING_APP = "x-budibase-migrating-app",
COOKIE = "cookie",
} }

View File

@ -57,3 +57,13 @@ export function filterValueToLabel() {
{} {}
) )
} }
export function hasSchema(test: any) {
return (
typeof test === "object" &&
!Array.isArray(test) &&
test !== null &&
!(test instanceof Date) &&
Object.keys(test).length > 0
)
}

View File

@ -69,6 +69,7 @@ export enum AutomationActionStepId {
slack = "slack", slack = "slack",
zapier = "zapier", zapier = "zapier",
integromat = "integromat", integromat = "integromat",
n8n = "n8n",
} }
export interface EmailInvite { export interface EmailInvite {

View File

@ -4,6 +4,7 @@ import type { Row } from "./row"
export interface QuerySchema { export interface QuerySchema {
name?: string name?: string
type: string type: string
subtype?: string
} }
export interface Query extends Document { export interface Query extends Document {
@ -17,11 +18,23 @@ export interface Query extends Document {
queryVerb: string queryVerb: string
} }
export interface QueryPreview extends Omit<Query, "_id"> {
queryId: string
}
export interface QueryParameter { export interface QueryParameter {
name: string name: string
default: string default: string
} }
export interface QueryResponse {
rows: any[]
keys: string[]
info: any
extra: any
pagination: any
}
export interface RestQueryFields { export interface RestQueryFields {
path: string path: string
queryString?: string queryString?: string
@ -64,3 +77,12 @@ export interface ExecuteQueryRequest {
export interface ExecuteQueryResponse { export interface ExecuteQueryResponse {
data: Row[] data: Row[]
} }
export enum HttpMethod {
GET = "GET",
POST = "POST",
PATCH = "PATCH",
PUT = "PUT",
HEAD = "HEAD",
DELETE = "DELETE",
}

View File

@ -16,6 +16,10 @@ export enum AutoFieldSubType {
AUTO_ID = "autoID", AUTO_ID = "autoID",
} }
export enum JsonFieldSubType {
ARRAY = "array",
}
export enum FormulaType { export enum FormulaType {
STATIC = "static", STATIC = "static",
DYNAMIC = "dynamic", DYNAMIC = "dynamic",

View File

@ -5,6 +5,7 @@ import {
AutoFieldSubType, AutoFieldSubType,
AutoReason, AutoReason,
FormulaType, FormulaType,
JsonFieldSubType,
RelationshipType, RelationshipType,
} from "./constants" } from "./constants"
@ -81,6 +82,11 @@ export interface NumberFieldMetadata extends Omit<BaseFieldSchema, "subtype"> {
} }
} }
export interface JsonFieldMetadata extends Omit<BaseFieldSchema, "subtype"> {
type: FieldType.JSON
subtype?: JsonFieldSubType.ARRAY
}
export interface DateFieldMetadata extends Omit<BaseFieldSchema, "subtype"> { export interface DateFieldMetadata extends Omit<BaseFieldSchema, "subtype"> {
type: FieldType.DATETIME type: FieldType.DATETIME
ignoreTimezones?: boolean ignoreTimezones?: boolean
@ -162,6 +168,7 @@ export type FieldSchema =
| NumberFieldMetadata | NumberFieldMetadata
| LongFormFieldMetadata | LongFormFieldMetadata
| BBReferenceFieldMetadata | BBReferenceFieldMetadata
| JsonFieldMetadata
export interface TableSchema { export interface TableSchema {
[key: string]: FieldSchema [key: string]: FieldSchema

View File

@ -37,6 +37,7 @@ RUN apk del .gyp \
COPY packages/worker/dist/ dist/ COPY packages/worker/dist/ dist/
COPY packages/worker/docker_run.sh . COPY packages/worker/docker_run.sh .
COPY packages/server/pm2.config.js .
EXPOSE 4001 EXPOSE 4001

View File

@ -5572,9 +5572,9 @@
integrity sha512-7GgtHCs/QZrBrDzgIJnQtuSvhFSwhyYSI2uafSwZoNt1iOGhEN5fwNrQMjtONyHm9+/LoA4453jH0CMYcr06Pg== integrity sha512-7GgtHCs/QZrBrDzgIJnQtuSvhFSwhyYSI2uafSwZoNt1iOGhEN5fwNrQMjtONyHm9+/LoA4453jH0CMYcr06Pg==
"@types/node@^18.11.18": "@types/node@^18.11.18":
version "18.19.10" version "18.19.13"
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.19.10.tgz#4de314ab66faf6bc8ba691021a091ddcdf13a158" resolved "https://registry.yarnpkg.com/@types/node/-/node-18.19.13.tgz#c3e989ca967b862a1f6c8c4148fe31865eedaf1a"
integrity sha512-IZD8kAM02AW1HRDTPOlz3npFava678pr8Ie9Vp8uRhBROXAv8MXT2pCnGZZAKYdromsNQLHQcfWQ6EOatVLtqA== integrity sha512-kgnbRDj8ioDyGxoiaXsiu1Ybm/K14ajCgMOkwiqpHrnF7d7QiYRoRqHIpglMMs3DwXinlK4qJ8TZGlj4hfleJg==
dependencies: dependencies:
undici-types "~5.26.4" undici-types "~5.26.4"
@ -10763,7 +10763,7 @@ fetch-cookie@0.11.0:
dependencies: dependencies:
tough-cookie "^2.3.3 || ^3.0.1 || ^4.0.0" tough-cookie "^2.3.3 || ^3.0.1 || ^4.0.0"
fflate@^0.4.1: fflate@^0.4.1, fflate@^0.4.8:
version "0.4.8" version "0.4.8"
resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.4.8.tgz#f90b82aefbd8ac174213abb338bd7ef848f0f5ae" resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.4.8.tgz#f90b82aefbd8ac174213abb338bd7ef848f0f5ae"
integrity sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA== integrity sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==