Merge master.

This commit is contained in:
Sam Rose 2025-03-31 09:51:21 +01:00
commit 867afe7806
No known key found for this signature in database
41 changed files with 555 additions and 143 deletions

View File

@ -1,6 +1,6 @@
{ {
"$schema": "node_modules/lerna/schemas/lerna-schema.json", "$schema": "node_modules/lerna/schemas/lerna-schema.json",
"version": "3.7.0", "version": "3.7.4",
"npmClient": "yarn", "npmClient": "yarn",
"concurrency": 20, "concurrency": 20,
"command": { "command": {

View File

@ -56,6 +56,7 @@
"koa-pino-logger": "4.0.0", "koa-pino-logger": "4.0.0",
"lodash": "4.17.21", "lodash": "4.17.21",
"node-fetch": "2.6.7", "node-fetch": "2.6.7",
"object-sizeof": "2.6.1",
"passport-google-oauth": "2.0.0", "passport-google-oauth": "2.0.0",
"passport-local": "1.0.0", "passport-local": "1.0.0",
"passport-oauth2-refresh": "^2.1.0", "passport-oauth2-refresh": "^2.1.0",

View File

@ -1,6 +1,6 @@
import { AnyDocument, Database, Document } from "@budibase/types" import { AnyDocument, Database, Document, DocumentType } from "@budibase/types"
import { JobQueue, Queue, createQueue } from "../queue" import { BudibaseQueue, JobQueue } from "../queue"
import * as dbUtils from "../db" import * as dbUtils from "../db"
interface ProcessDocMessage { interface ProcessDocMessage {
@ -13,11 +13,11 @@ const PERSIST_MAX_ATTEMPTS = 100
let processor: DocWritethroughProcessor | undefined let processor: DocWritethroughProcessor | undefined
export class DocWritethroughProcessor { export class DocWritethroughProcessor {
private static _queue: Queue private static _queue: BudibaseQueue<ProcessDocMessage>
public static get queue() { public static get queue() {
if (!DocWritethroughProcessor._queue) { if (!DocWritethroughProcessor._queue) {
DocWritethroughProcessor._queue = createQueue<ProcessDocMessage>( DocWritethroughProcessor._queue = new BudibaseQueue<ProcessDocMessage>(
JobQueue.DOC_WRITETHROUGH_QUEUE, JobQueue.DOC_WRITETHROUGH_QUEUE,
{ {
jobOptions: { jobOptions: {
@ -57,6 +57,10 @@ export class DocWritethroughProcessor {
docId: string docId: string
data: Record<string, any> data: Record<string, any>
}) { }) {
// HACK - for now drop SCIM events
if (docId.startsWith(DocumentType.SCIM_LOG)) {
return
}
const db = dbUtils.getDB(dbName) const db = dbUtils.getDB(dbName)
let doc: AnyDocument | undefined let doc: AnyDocument | undefined
try { try {

View File

@ -18,7 +18,9 @@ import {
const initialTime = Date.now() const initialTime = Date.now()
async function waitForQueueCompletion() { async function waitForQueueCompletion() {
await utils.queue.processMessages(DocWritethroughProcessor.queue) await utils.queue.processMessages(
DocWritethroughProcessor.queue.getBullQueue()
)
} }
beforeAll(() => utils.queue.useRealQueues()) beforeAll(() => utils.queue.useRealQueues())

View File

@ -1,5 +1,4 @@
import BullQueue from "bull" import { BudibaseQueue, JobQueue } from "../../queue"
import { createQueue, JobQueue } from "../../queue"
import { Event, Identity } from "@budibase/types" import { Event, Identity } from "@budibase/types"
export interface EventPayload { export interface EventPayload {
@ -9,10 +8,19 @@ export interface EventPayload {
timestamp?: string | number timestamp?: string | number
} }
export let asyncEventQueue: BullQueue.Queue export let asyncEventQueue: BudibaseQueue<EventPayload>
export function init() { export function init() {
asyncEventQueue = createQueue<EventPayload>(JobQueue.SYSTEM_EVENT_QUEUE) asyncEventQueue = new BudibaseQueue<EventPayload>(
JobQueue.SYSTEM_EVENT_QUEUE,
{
jobTags: (event: EventPayload) => {
return {
"event.name": event.event,
}
},
}
)
} }
export async function shutdown() { export async function shutdown() {

View File

@ -8,24 +8,30 @@ import {
} from "@budibase/types" } from "@budibase/types"
import { EventProcessor } from "./types" import { EventProcessor } from "./types"
import { getAppId, doInTenant, getTenantId } from "../../context" import { getAppId, doInTenant, getTenantId } from "../../context"
import BullQueue from "bull" import { BudibaseQueue, JobQueue } from "../../queue"
import { createQueue, JobQueue } from "../../queue"
import { isAudited } from "../../utils" import { isAudited } from "../../utils"
import env from "../../environment" import env from "../../environment"
export default class AuditLogsProcessor implements EventProcessor { export default class AuditLogsProcessor implements EventProcessor {
static auditLogsEnabled = false static auditLogsEnabled = false
static auditLogQueue: BullQueue.Queue<AuditLogQueueEvent> static auditLogQueue: BudibaseQueue<AuditLogQueueEvent>
// can't use constructor as need to return promise // can't use constructor as need to return promise
static init(fn: AuditLogFn) { static init(fn: AuditLogFn) {
AuditLogsProcessor.auditLogsEnabled = true AuditLogsProcessor.auditLogsEnabled = true
const writeAuditLogs = fn const writeAuditLogs = fn
AuditLogsProcessor.auditLogQueue = createQueue<AuditLogQueueEvent>( AuditLogsProcessor.auditLogQueue = new BudibaseQueue<AuditLogQueueEvent>(
JobQueue.AUDIT_LOG JobQueue.AUDIT_LOG,
{
jobTags: (event: AuditLogQueueEvent) => {
return {
"event.name": event.event,
}
},
}
) )
return AuditLogsProcessor.auditLogQueue.process(async job => { return AuditLogsProcessor.auditLogQueue.process(async job => {
return doInTenant(job.data.tenantId, async () => { await doInTenant(job.data.tenantId, async () => {
let properties = job.data.properties let properties = job.data.properties
if (properties.audited) { if (properties.audited) {
properties = { properties = {

View File

@ -96,12 +96,17 @@ export class InMemoryQueue<T = any> implements Partial<Queue<T>> {
let resp = func(message) let resp = func(message)
async function retryFunc(fnc: any) { async function retryFunc(fnc: any, attempt = 0) {
try { try {
await fnc await fnc
} catch (e: any) { } catch (e: any) {
await helpers.wait(50) attempt++
await retryFunc(func(message)) if (attempt < 3) {
await helpers.wait(100 * attempt)
await retryFunc(func(message), attempt)
} else {
throw e
}
} }
} }

View File

@ -2,10 +2,12 @@ import env from "../environment"
import { getRedisOptions } from "../redis/utils" import { getRedisOptions } from "../redis/utils"
import { JobQueue } from "./constants" import { JobQueue } from "./constants"
import InMemoryQueue from "./inMemoryQueue" import InMemoryQueue from "./inMemoryQueue"
import BullQueue, { QueueOptions, JobOptions } from "bull" import BullQueue, { Queue, QueueOptions, JobOptions, Job } from "bull"
import { addListeners, StalledFn } from "./listeners" import { addListeners, StalledFn } from "./listeners"
import { Duration } from "../utils" import { Duration } from "../utils"
import * as timers from "../timers" import * as timers from "../timers"
import tracer from "dd-trace"
import sizeof from "object-sizeof"
export type { QueueOptions, Queue, JobOptions } from "bull" export type { QueueOptions, Queue, JobOptions } from "bull"
@ -15,7 +17,7 @@ const QUEUE_LOCK_MS = Duration.fromMinutes(5).toMs()
const QUEUE_LOCK_RENEW_INTERNAL_MS = Duration.fromSeconds(30).toMs() const QUEUE_LOCK_RENEW_INTERNAL_MS = Duration.fromSeconds(30).toMs()
// cleanup the queue every 60 seconds // cleanup the queue every 60 seconds
const CLEANUP_PERIOD_MS = Duration.fromSeconds(60).toMs() const CLEANUP_PERIOD_MS = Duration.fromSeconds(60).toMs()
let QUEUES: BullQueue.Queue[] = [] let QUEUES: Queue[] = []
let cleanupInterval: NodeJS.Timeout let cleanupInterval: NodeJS.Timeout
async function cleanup() { async function cleanup() {
@ -25,49 +27,205 @@ async function cleanup() {
} }
} }
export function createQueue<T>( async function withMetrics<T>(
jobQueue: JobQueue, name: string,
opts: { cb: () => Promise<T>,
tags?: Record<string, string | number>
): Promise<T> {
const start = performance.now()
try {
const result = await cb()
tracer.dogstatsd.increment(`${name}.success`, 1, tags)
return result
} catch (err) {
tracer.dogstatsd.increment(`${name}.error`, 1, tags)
throw err
} finally {
const durationMs = performance.now() - start
tracer.dogstatsd.distribution(`${name}.duration.ms`, durationMs, tags)
tracer.dogstatsd.increment(name, 1, tags)
}
}
function jobOptsTags(opts: JobOptions) {
return {
"job.opts.attempts": opts.attempts,
"job.opts.backoff": opts.backoff,
"job.opts.delay": opts.delay,
"job.opts.jobId": opts.jobId,
"job.opts.lifo": opts.lifo,
"job.opts.preventParsingData": opts.preventParsingData,
"job.opts.priority": opts.priority,
"job.opts.removeOnComplete": opts.removeOnComplete,
"job.opts.removeOnFail": opts.removeOnFail,
"job.opts.repeat": opts.repeat,
"job.opts.stackTraceLimit": opts.stackTraceLimit,
"job.opts.timeout": opts.timeout,
}
}
function jobTags(job: Job) {
return {
"job.id": job.id,
"job.attemptsMade": job.attemptsMade,
"job.timestamp": job.timestamp,
"job.data.sizeBytes": sizeof(job.data),
...jobOptsTags(job.opts || {}),
}
}
export interface BudibaseQueueOpts<T> {
removeStalledCb?: StalledFn removeStalledCb?: StalledFn
maxStalledCount?: number maxStalledCount?: number
jobOptions?: JobOptions jobOptions?: JobOptions
} = {} jobTags?: (job: T) => Record<string, any>
): BullQueue.Queue<T> { }
export class BudibaseQueue<T> {
private queue: Queue<T>
private opts: BudibaseQueueOpts<T>
private jobQueue: JobQueue
constructor(jobQueue: JobQueue, opts: BudibaseQueueOpts<T> = {}) {
this.opts = opts
this.jobQueue = jobQueue
this.queue = this.initQueue()
}
private initQueue() {
const redisOpts = getRedisOptions() const redisOpts = getRedisOptions()
const queueConfig: QueueOptions = { const queueConfig: QueueOptions = {
redis: redisOpts, redis: redisOpts,
settings: { settings: {
maxStalledCount: opts.maxStalledCount ? opts.maxStalledCount : 0, maxStalledCount: this.opts.maxStalledCount
? this.opts.maxStalledCount
: 0,
lockDuration: QUEUE_LOCK_MS, lockDuration: QUEUE_LOCK_MS,
lockRenewTime: QUEUE_LOCK_RENEW_INTERNAL_MS, lockRenewTime: QUEUE_LOCK_RENEW_INTERNAL_MS,
}, },
} }
if (opts.jobOptions) { if (this.opts.jobOptions) {
queueConfig.defaultJobOptions = opts.jobOptions queueConfig.defaultJobOptions = this.opts.jobOptions
} }
let queue: BullQueue.Queue<T> let queue: Queue<T>
if (!env.isTest()) { if (!env.isTest()) {
queue = new BullQueue(jobQueue, queueConfig) queue = new BullQueue(this.jobQueue, queueConfig)
} else if ( } else if (
process.env.BULL_TEST_REDIS_PORT && process.env.BULL_TEST_REDIS_PORT &&
!isNaN(+process.env.BULL_TEST_REDIS_PORT) !isNaN(+process.env.BULL_TEST_REDIS_PORT)
) { ) {
queue = new BullQueue(jobQueue, { queue = new BullQueue(this.jobQueue, {
redis: { host: "localhost", port: +process.env.BULL_TEST_REDIS_PORT }, redis: { host: "localhost", port: +process.env.BULL_TEST_REDIS_PORT },
}) })
} else { } else {
queue = new InMemoryQueue(jobQueue, queueConfig) as any queue = new InMemoryQueue(this.jobQueue, queueConfig) as any
} }
addListeners(queue, jobQueue, opts?.removeStalledCb)
addListeners(queue, this.jobQueue, this.opts.removeStalledCb)
QUEUES.push(queue) QUEUES.push(queue)
if (!cleanupInterval && !env.isTest()) { if (!cleanupInterval && !env.isTest()) {
cleanupInterval = timers.set(cleanup, CLEANUP_PERIOD_MS) cleanupInterval = timers.set(cleanup, CLEANUP_PERIOD_MS)
// fire off an initial cleanup // fire off an initial cleanup
cleanup().catch(err => { cleanup().catch(err => {
console.error(`Unable to cleanup ${jobQueue} initially - ${err}`) console.error(`Unable to cleanup ${this.jobQueue} initially - ${err}`)
}) })
} }
return queue return queue
}
getBullQueue() {
return this.queue
}
process(
concurrency: number,
cb: (job: Job<T>) => Promise<void>
): Promise<void>
process(cb: (job: Job<T>) => Promise<void>): Promise<void>
process(...args: any[]) {
let concurrency: number | undefined = undefined
let cb: (job: Job<T>) => Promise<void>
if (args.length === 2) {
concurrency = args[0]
cb = args[1]
} else {
cb = args[0]
}
const wrappedCb = async (job: Job<T>) => {
await tracer.trace("queue.process", async span => {
// @ts-expect-error monkey patching the parent span id
if (job.data._parentSpanContext) {
// @ts-expect-error monkey patching the parent span id
const parentContext = job.data._parentSpanContext
const parent = {
traceId: parentContext.traceId,
spanId: parentContext.spanId,
toTraceId: () => parentContext.traceId,
toSpanId: () => parentContext.spanId,
toTraceparent: () => "",
}
span.addLink(parent)
}
span.addTags({ "queue.name": this.jobQueue, ...jobTags(job) })
if (this.opts.jobTags) {
span.addTags(this.opts.jobTags(job.data))
}
tracer.dogstatsd.distribution(
"queue.process.sizeBytes",
sizeof(job.data),
this.metricTags()
)
await this.withMetrics("queue.process", () => cb(job))
})
}
if (concurrency) {
return this.queue.process(concurrency, wrappedCb)
} else {
return this.queue.process(wrappedCb)
}
}
async add(data: T, opts?: JobOptions): Promise<Job<T>> {
return await tracer.trace("queue.add", async span => {
span.addTags({
"queue.name": this.jobQueue,
"job.data.sizeBytes": sizeof(data),
...jobOptsTags(opts || {}),
})
if (this.opts.jobTags) {
span.addTags(this.opts.jobTags(data))
}
// @ts-expect-error monkey patching the parent span id
data._parentSpanContext = {
traceId: span.context().toTraceId(),
spanId: span.context().toSpanId(),
}
tracer.dogstatsd.distribution(
"queue.add.sizeBytes",
sizeof(data),
this.metricTags()
)
return await this.withMetrics("queue.add", () =>
this.queue.add(data, opts)
)
})
}
private withMetrics<T>(name: string, cb: () => Promise<T>) {
return withMetrics(name, cb, this.metricTags())
}
private metricTags() {
return { queueName: this.jobQueue }
}
close() {
return this.queue.close()
}
} }
export async function shutdown() { export async function shutdown() {

View File

@ -6,6 +6,7 @@ import {
isInvalidISODateString, isInvalidISODateString,
isValidFilter, isValidFilter,
isValidISODateString, isValidISODateString,
isValidTime,
sqlLog, sqlLog,
validateManyToMany, validateManyToMany,
} from "./utils" } from "./utils"
@ -417,6 +418,11 @@ class InternalBuilder {
} }
if (typeof input === "string" && schema.type === FieldType.DATETIME) { if (typeof input === "string" && schema.type === FieldType.DATETIME) {
if (schema.timeOnly) {
if (!isValidTime(input)) {
return null
}
} else {
if (isInvalidISODateString(input)) { if (isInvalidISODateString(input)) {
return null return null
} }
@ -424,6 +430,7 @@ class InternalBuilder {
return new Date(input.trim()) return new Date(input.trim())
} }
} }
}
return input return input
} }

View File

@ -0,0 +1,35 @@
import { isValidISODateString, isInvalidISODateString } from "../utils"
describe("ISO date string validity checks", () => {
it("accepts a valid ISO date string without a time", () => {
const str = "2013-02-01"
const valid = isValidISODateString(str)
const invalid = isInvalidISODateString(str)
expect(valid).toEqual(true)
expect(invalid).toEqual(false)
})
it("accepts a valid ISO date string with a time", () => {
const str = "2013-02-01T01:23:45Z"
const valid = isValidISODateString(str)
const invalid = isInvalidISODateString(str)
expect(valid).toEqual(true)
expect(invalid).toEqual(false)
})
it("accepts a valid ISO date string with a time and millis", () => {
const str = "2013-02-01T01:23:45.678Z"
const valid = isValidISODateString(str)
const invalid = isInvalidISODateString(str)
expect(valid).toEqual(true)
expect(invalid).toEqual(false)
})
it("rejects an invalid ISO date string", () => {
const str = "2013-523-814T444:22:11Z"
const valid = isValidISODateString(str)
const invalid = isInvalidISODateString(str)
expect(valid).toEqual(false)
expect(invalid).toEqual(true)
})
})

View File

@ -14,7 +14,7 @@ import environment from "../environment"
const DOUBLE_SEPARATOR = `${SEPARATOR}${SEPARATOR}` const DOUBLE_SEPARATOR = `${SEPARATOR}${SEPARATOR}`
const ROW_ID_REGEX = /^\[.*]$/g const ROW_ID_REGEX = /^\[.*]$/g
const ENCODED_SPACE = encodeURIComponent(" ") const ENCODED_SPACE = encodeURIComponent(" ")
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}.\d{3}Z)?$/ const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:.\d{3})?Z)?$/
const TIME_REGEX = /^(?:\d{2}:)?(?:\d{2}:)(?:\d{2})$/ const TIME_REGEX = /^(?:\d{2}:)?(?:\d{2}:)(?:\d{2})$/
export function isExternalTableID(tableId: string) { export function isExternalTableID(tableId: string) {
@ -139,17 +139,17 @@ export function breakRowIdField(_id: string | { _id: string }): any[] {
} }
} }
export function isInvalidISODateString(str: string) { export function isValidISODateString(str: string) {
const trimmedValue = str.trim() const trimmedValue = str.trim()
if (!ISO_DATE_REGEX.test(trimmedValue)) { if (!ISO_DATE_REGEX.test(trimmedValue)) {
return false return false
} }
let d = new Date(trimmedValue) const d = new Date(trimmedValue)
return isNaN(d.getTime()) return !isNaN(d.getTime())
} }
export function isValidISODateString(str: string) { export function isInvalidISODateString(str: string) {
return ISO_DATE_REGEX.test(str.trim()) return !isValidISODateString(str)
} }
export function isValidFilter(value: any) { export function isValidFilter(value: any) {

View File

@ -75,6 +75,7 @@
class:is-disabled={disabled} class:is-disabled={disabled}
class:is-focused={isFocused} class:is-focused={isFocused}
> >
<!-- We need to ignore prettier here as we want no whitespace -->
<!-- prettier-ignore --> <!-- prettier-ignore -->
<textarea <textarea
bind:this={textarea} bind:this={textarea}
@ -90,6 +91,7 @@
on:blur on:blur
on:keypress on:keypress
>{value || ""}</textarea> >{value || ""}</textarea>
<slot />
</div> </div>
<style> <style>

View File

@ -114,6 +114,7 @@
inputmode={getInputMode(type)} inputmode={getInputMode(type)}
autocomplete={autocompleteValue} autocomplete={autocompleteValue}
/> />
<slot />
</div> </div>
<style> <style>

View File

@ -41,5 +41,7 @@
on:blur on:blur
on:focus on:focus
on:keyup on:keyup
/> >
<slot />
</TextField>
</Field> </Field>

View File

@ -7,11 +7,13 @@
export let label: string | undefined = undefined export let label: string | undefined = undefined
export let labelPosition = "above" export let labelPosition = "above"
export let placeholder: string | undefined = undefined export let placeholder: string | undefined = undefined
export let disabled = false export let readonly: boolean = false
export let disabled: boolean = false
export let error: string | undefined = undefined export let error: string | undefined = undefined
export let height: number | undefined = undefined export let height: number | undefined = undefined
export let minHeight: number | undefined = undefined export let minHeight: number | undefined = undefined
export let helpText: string | undefined = undefined export let helpText: string | undefined = undefined
export let updateOnChange: boolean = false
let textarea: TextArea let textarea: TextArea
export function focus() { export function focus() {
@ -33,11 +35,16 @@
<TextArea <TextArea
bind:this={textarea} bind:this={textarea}
{disabled} {disabled}
{readonly}
{value} {value}
{placeholder} {placeholder}
{height} {height}
{minHeight} {minHeight}
{updateOnChange}
on:change={onChange} on:change={onChange}
on:keypress on:keypress
/> on:scrollable
>
<slot />
</TextArea>
</Field> </Field>

View File

@ -45,7 +45,7 @@
}, },
{ {
label: "Multi-select", label: "Multi-select",
value: FieldType.ARRAY.type, value: FieldType.ARRAY,
}, },
{ {
label: "Barcode/QR", label: "Barcode/QR",

View File

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Icon, Input, Drawer, Button, CoreTextArea } from "@budibase/bbui" import { Icon, Input, Drawer, Button, TextArea } from "@budibase/bbui"
import { import {
readableToRuntimeBinding, readableToRuntimeBinding,
runtimeToReadableBinding, runtimeToReadableBinding,
@ -67,7 +67,7 @@
<!-- svelte-ignore a11y-no-static-element-interactions --> <!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="control" class:multiline class:disabled class:scrollable> <div class="control" class:multiline class:disabled class:scrollable>
<svelte:component <svelte:component
this={multiline ? CoreTextArea : Input} this={multiline ? TextArea : Input}
{label} {label}
{disabled} {disabled}
readonly={isJS} readonly={isJS}
@ -78,7 +78,7 @@
{placeholder} {placeholder}
{updateOnChange} {updateOnChange}
{autocomplete} {autocomplete}
/> >
{#if !disabled && !disableBindings} {#if !disabled && !disableBindings}
<div <div
class="icon" class="icon"
@ -90,6 +90,7 @@
<Icon size="S" name="FlashOn" /> <Icon size="S" name="FlashOn" />
</div> </div>
{/if} {/if}
</svelte:component>
</div> </div>
<Drawer <Drawer
on:drawerHide={onDrawerHide} on:drawerHide={onDrawerHide}

View File

@ -2,7 +2,7 @@
import { getContext } from "svelte" import { getContext } from "svelte"
import { MarkdownViewer } from "@budibase/bbui" import { MarkdownViewer } from "@budibase/bbui"
export let text: string = "" export let text: any = ""
export let color: string | undefined = undefined export let color: string | undefined = undefined
export let align: "left" | "center" | "right" | "justify" = "left" export let align: "left" | "center" | "right" | "justify" = "left"
@ -12,6 +12,9 @@
// Add in certain settings to styles // Add in certain settings to styles
$: styles = enrichStyles($component.styles, color, align) $: styles = enrichStyles($component.styles, color, align)
// Ensure we're always passing in a string value to the markdown editor
$: safeText = stringify(text)
const enrichStyles = ( const enrichStyles = (
styles: any, styles: any,
colorStyle: typeof color, colorStyle: typeof color,
@ -31,10 +34,24 @@
}, },
} }
} }
const stringify = (text: any): string => {
if (text == null) {
return ""
}
if (typeof text !== "string") {
try {
return JSON.stringify(text)
} catch (e) {
return ""
}
}
return text
}
</script> </script>
<div use:styleable={styles}> <div use:styleable={styles}>
<MarkdownViewer value={text} /> <MarkdownViewer value={safeText} />
</div> </div>
<style> <style>

View File

@ -82,8 +82,9 @@ export const deriveStores = (context: StoreContext): ConfigDerivedStore => {
config.canEditColumns = false config.canEditColumns = false
} }
// Determine if we can select rows // Determine if we can select rows. Always true in the meantime as you can
config.canSelectRows = !!config.canDeleteRows || !!config.canAddRows // use the selected rows binding regardless of readonly state.
config.canSelectRows = true
return config return config
} }

View File

@ -0,0 +1,12 @@
import { join } from "../../utilities/centralPath"
import { TOP_LEVEL_PATH, DEV_ASSET_PATH } from "../../utilities/fileSystem"
import { Ctx } from "@budibase/types"
import env from "../../environment"
import send from "koa-send"
// this is a public endpoint with no middlewares
export const serveBuilderAssets = async function (ctx: Ctx<void, void>) {
let topLevelPath = env.isDev() ? DEV_ASSET_PATH : TOP_LEVEL_PATH
const builderPath = join(topLevelPath, "builder")
await send(ctx, ctx.file, { root: builderPath })
}

View File

@ -76,11 +76,6 @@ export const toggleBetaUiFeature = async function (
} }
} }
export const serveBuilder = async function (ctx: Ctx<void, void>) {
const builderPath = join(TOP_LEVEL_PATH, "builder")
await send(ctx, ctx.file, { root: builderPath })
}
export const uploadFile = async function ( export const uploadFile = async function (
ctx: Ctx<void, ProcessAttachmentResponse> ctx: Ctx<void, ProcessAttachmentResponse>
) { ) {

View File

@ -1,4 +1,9 @@
import { AIOperationEnum, AutoFieldSubType, FieldType } from "@budibase/types" import {
AIOperationEnum,
AutoFieldSubType,
FieldType,
JsonFieldSubType,
} from "@budibase/types"
import TestConfiguration from "../../../../tests/utilities/TestConfiguration" import TestConfiguration from "../../../../tests/utilities/TestConfiguration"
import { importToRows } from "../utils" import { importToRows } from "../utils"
@ -152,5 +157,33 @@ describe("utils", () => {
]) ])
}) })
}) })
it("coerces strings into arrays for array fields", async () => {
await config.doInContext(config.appId, async () => {
const table = await config.createTable({
name: "table",
type: "table",
schema: {
colours: {
name: "colours",
type: FieldType.ARRAY,
constraints: {
type: JsonFieldSubType.ARRAY,
inclusion: ["red"],
},
},
},
})
const data = [{ colours: "red" }]
const result = await importToRows(data, table, config.user?._id)
expect(result).toEqual([
expect.objectContaining({
colours: ["red"],
}),
])
})
})
}) })
}) })

View File

@ -155,13 +155,19 @@ export async function importToRows(
schema.type === FieldType.ARRAY) && schema.type === FieldType.ARRAY) &&
row[fieldName] row[fieldName]
) { ) {
const rowVal = Array.isArray(row[fieldName]) const isArray = Array.isArray(row[fieldName])
? row[fieldName]
: [row[fieldName]] // Add option to inclusion constraints
const rowVal = isArray ? row[fieldName] : [row[fieldName]]
let merged = [...schema.constraints!.inclusion!, ...rowVal] let merged = [...schema.constraints!.inclusion!, ...rowVal]
let superSet = new Set(merged) let superSet = new Set(merged)
schema.constraints!.inclusion = Array.from(superSet) schema.constraints!.inclusion = Array.from(superSet)
schema.constraints!.inclusion.sort() schema.constraints!.inclusion.sort()
// If array type, ensure we import the value as an array
if (!isArray && schema.type === FieldType.ARRAY) {
row[fieldName] = rowVal
}
} }
} }

View File

@ -8,6 +8,7 @@ import { middleware as pro } from "@budibase/pro"
import { apiEnabled, automationsEnabled } from "../features" import { apiEnabled, automationsEnabled } from "../features"
import migrations from "../middleware/appMigrations" import migrations from "../middleware/appMigrations"
import { automationQueue } from "../automations" import { automationQueue } from "../automations"
import assetRouter from "./routes/assets"
export { shutdown } from "./routes/public" export { shutdown } from "./routes/public"
const compress = require("koa-compress") const compress = require("koa-compress")
@ -16,7 +17,7 @@ export const router: Router = new Router()
router.get("/health", async ctx => { router.get("/health", async ctx => {
if (automationsEnabled()) { if (automationsEnabled()) {
if (!(await automationQueue.isReady())) { if (!(await automationQueue.getBullQueue().isReady())) {
ctx.status = 503 ctx.status = 503
return return
} }
@ -44,6 +45,12 @@ if (apiEnabled()) {
) )
// re-direct before any middlewares occur // re-direct before any middlewares occur
.redirect("/", "/builder") .redirect("/", "/builder")
// send assets before middleware
router.use(assetRouter.routes())
router.use(assetRouter.allowedMethods())
router
.use( .use(
auth.buildAuthMiddleware([], { auth.buildAuthMiddleware([], {
publicAllowed: true, publicAllowed: true,

View File

@ -0,0 +1,11 @@
import { addFileManagement } from "../utils"
import { serveBuilderAssets } from "../controllers/assets"
import Router from "@koa/router"
const router: Router = new Router()
addFileManagement(router)
router.get("/builder/:file*", serveBuilderAssets)
export default router

View File

@ -1,35 +1,17 @@
import Router from "@koa/router" import Router from "@koa/router"
import * as controller from "../controllers/static" import * as controller from "../controllers/static"
import { budibaseTempDir } from "../../utilities/budibaseDir"
import authorized from "../../middleware/authorized" import authorized from "../../middleware/authorized"
import { permissions } from "@budibase/backend-core" import { permissions } from "@budibase/backend-core"
import env from "../../environment" import { addFileManagement } from "../utils"
import { paramResource } from "../../middleware/resourceId" import { paramResource } from "../../middleware/resourceId"
import { devClientLibPath } from "../../utilities/fileSystem"
const { BUILDER, PermissionType, PermissionLevel } = permissions const { BUILDER, PermissionType, PermissionLevel } = permissions
const router: Router = new Router() const router: Router = new Router()
/* istanbul ignore next */ addFileManagement(router)
router.param("file", async (file: any, ctx: any, next: any) => {
ctx.file = file && file.includes(".") ? file : "index.html"
if (!ctx.file.startsWith("budibase-client")) {
return next()
}
// test serves from require
if (env.isTest()) {
const path = devClientLibPath()
ctx.devPath = path.split(ctx.file)[0]
} else if (env.isDev()) {
// Serving the client library from your local dir in dev
ctx.devPath = budibaseTempDir()
}
return next()
})
router router
.get("/builder/:file*", controller.serveBuilder)
.get("/api/assets/client", controller.serveClientLibrary) .get("/api/assets/client", controller.serveClientLibrary)
.post("/api/attachments/process", authorized(BUILDER), controller.uploadFile) .post("/api/attachments/process", authorized(BUILDER), controller.uploadFile)
.post("/api/beta/:feature", controller.toggleBetaUiFeature) .post("/api/beta/:feature", controller.toggleBetaUiFeature)

View File

@ -0,0 +1,34 @@
import fs from "fs"
import { join } from "path"
import { DEV_ASSET_PATH } from "../../../utilities/fileSystem"
import * as setup from "./utilities"
const path = join(DEV_ASSET_PATH, "builder")
let addedPath = false
const config = setup.getConfig()
beforeAll(async () => {
if (!fs.existsSync(path)) {
addedPath = true
fs.mkdirSync(path)
}
const indexPath = join(path, "index.html")
if (!fs.existsSync(indexPath)) {
fs.writeFileSync(indexPath, "<html></html>", "utf8")
addedPath = true
}
await config.init()
})
afterAll(() => {
if (addedPath) {
fs.rmSync(path, { recursive: true })
}
})
describe("/builder/:file*", () => {
it("should be able to retrieve the builder file", async () => {
const res = await config.api.assets.get("index.html")
expect(res.text).toContain("<html")
})
})

View File

@ -2276,6 +2276,25 @@ if (descriptions.length) {
expect(updated.attachment.key).toBe(newAttachment.key) expect(updated.attachment.key).toBe(newAttachment.key)
}) })
it("should allow updating signature row", async () => {
const { table, row } = await coreAttachmentEnrichment(
{
signature: {
type: FieldType.SIGNATURE_SINGLE,
name: "signature",
constraints: { presence: false },
},
},
"signature",
`${uuid.v4()}.png`
)
const newSignature = generateAttachment(`${uuid.v4()}.png`)
row["signature"] = newSignature
const updated = await config.api.row.save(table._id!, row)
expect(updated.signature.key).toBe(newSignature.key)
})
it("should allow enriching attachment list rows", async () => { it("should allow enriching attachment list rows", async () => {
await coreAttachmentEnrichment( await coreAttachmentEnrichment(
{ {

View File

@ -0,0 +1,23 @@
import env from "../environment"
import { devClientLibPath } from "../utilities/fileSystem"
import { budibaseTempDir } from "../utilities/budibaseDir"
import Router from "@koa/router"
export function addFileManagement(router: Router) {
/* istanbul ignore next */
router.param("file", async (file: any, ctx: any, next: any) => {
ctx.file = file && file.includes(".") ? file : "index.html"
if (!ctx.file.startsWith("budibase-client")) {
return next()
}
// test serves from require
if (env.isTest()) {
const path = devClientLibPath()
ctx.devPath = path.split(ctx.file)[0]
} else if (env.isDev()) {
// Serving the client library from your local dir in dev
ctx.devPath = budibaseTempDir()
}
return next()
})
}

View File

@ -13,7 +13,7 @@ export type AppMigrationJob = {
// always create app migration queue - so that events can be pushed and read from it // always create app migration queue - so that events can be pushed and read from it
// across the different api and automation services // across the different api and automation services
const appMigrationQueue = queue.createQueue<AppMigrationJob>( const appMigrationQueue = new queue.BudibaseQueue<AppMigrationJob>(
queue.JobQueue.APP_MIGRATION, queue.JobQueue.APP_MIGRATION,
{ {
jobOptions: { jobOptions: {

View File

@ -7,25 +7,36 @@ import { getAppMigrationQueue } from "../appMigrations/queue"
import { createBullBoard } from "@bull-board/api" import { createBullBoard } from "@bull-board/api"
import { AutomationData } from "@budibase/types" import { AutomationData } from "@budibase/types"
export const automationQueue = queue.createQueue<AutomationData>( export const automationQueue = new queue.BudibaseQueue<AutomationData>(
queue.JobQueue.AUTOMATION, queue.JobQueue.AUTOMATION,
{ removeStalledCb: automation.removeStalled } {
removeStalledCb: automation.removeStalled,
jobTags: (job: AutomationData) => {
return {
"automation.id": job.automation._id,
"automation.name": job.automation.name,
"automation.appId": job.automation.appId,
"automation.createdAt": job.automation.createdAt,
"automation.trigger": job.automation.definition.trigger.stepId,
}
},
}
) )
const PATH_PREFIX = "/bulladmin" 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 queues = [new BullAdapter(automationQueue)] const queues = [new BullAdapter(automationQueue.getBullQueue())]
const backupQueue = backups.getBackupQueue() const backupQueue = backups.getBackupQueue()
if (backupQueue) { if (backupQueue) {
queues.push(new BullAdapter(backupQueue)) queues.push(new BullAdapter(backupQueue.getBullQueue()))
} }
const appMigrationQueue = getAppMigrationQueue() const appMigrationQueue = getAppMigrationQueue()
if (appMigrationQueue) { if (appMigrationQueue) {
queues.push(new BullAdapter(appMigrationQueue)) queues.push(new BullAdapter(appMigrationQueue.getBullQueue()))
} }
const serverAdapter = new KoaAdapter() const serverAdapter = new KoaAdapter()

View File

@ -22,7 +22,7 @@ export function afterAll() {
} }
export function getTestQueue(): queue.InMemoryQueue<AutomationData> { export function getTestQueue(): queue.InMemoryQueue<AutomationData> {
return getQueue() as unknown as queue.InMemoryQueue<AutomationData> return getQueue().getBullQueue() as unknown as queue.InMemoryQueue<AutomationData>
} }
export function triggerCron(message: Job<AutomationData>) { export function triggerCron(message: Job<AutomationData>) {
@ -48,7 +48,7 @@ export async function runInProd(fn: any) {
export async function captureAllAutomationRemovals(f: () => Promise<unknown>) { export async function captureAllAutomationRemovals(f: () => Promise<unknown>) {
const messages: Job<AutomationData>[] = [] const messages: Job<AutomationData>[] = []
const queue = getQueue() const queue = getQueue().getBullQueue()
const messageListener = async (message: Job<AutomationData>) => { const messageListener = async (message: Job<AutomationData>) => {
messages.push(message) messages.push(message)
@ -82,7 +82,7 @@ export async function captureAutomationRemovals(
export async function captureAllAutomationMessages(f: () => Promise<unknown>) { export async function captureAllAutomationMessages(f: () => Promise<unknown>) {
const messages: Job<AutomationData>[] = [] const messages: Job<AutomationData>[] = []
const queue = getQueue() const queue = getQueue().getBullQueue()
const messageListener = async (message: Job<AutomationData>) => { const messageListener = async (message: Job<AutomationData>) => {
messages.push(message) messages.push(message)
@ -122,7 +122,7 @@ export async function captureAllAutomationResults(
f: () => Promise<unknown> f: () => Promise<unknown>
): Promise<queue.TestQueueMessage<AutomationData>[]> { ): Promise<queue.TestQueueMessage<AutomationData>[]> {
const runs: queue.TestQueueMessage<AutomationData>[] = [] const runs: queue.TestQueueMessage<AutomationData>[] = []
const queue = getQueue() const queue = getQueue().getBullQueue()
let messagesOutstanding = 0 let messagesOutstanding = 0
const completedListener = async ( const completedListener = async (

View File

@ -107,12 +107,14 @@ export async function updateTestHistory(
// end the repetition and the job itself // end the repetition and the job itself
export async function disableAllCrons(appId: any) { export async function disableAllCrons(appId: any) {
const promises = [] const promises = []
const jobs = await automationQueue.getRepeatableJobs() const jobs = await automationQueue.getBullQueue().getRepeatableJobs()
for (let job of jobs) { for (let job of jobs) {
if (job.key.includes(`${appId}_cron`)) { if (job.key.includes(`${appId}_cron`)) {
promises.push(automationQueue.removeRepeatableByKey(job.key)) promises.push(
automationQueue.getBullQueue().removeRepeatableByKey(job.key)
)
if (job.id) { if (job.id) {
promises.push(automationQueue.removeJobs(job.id)) promises.push(automationQueue.getBullQueue().removeJobs(job.id))
} }
} }
} }
@ -121,10 +123,10 @@ export async function disableAllCrons(appId: any) {
} }
export async function disableCronById(jobId: JobId) { export async function disableCronById(jobId: JobId) {
const jobs = await automationQueue.getRepeatableJobs() const jobs = await automationQueue.getBullQueue().getRepeatableJobs()
for (const job of jobs) { for (const job of jobs) {
if (job.id === jobId) { if (job.id === jobId) {
await automationQueue.removeRepeatableByKey(job.key) await automationQueue.getBullQueue().removeRepeatableByKey(job.key)
} }
} }
console.log(`jobId=${jobId} disabled`) console.log(`jobId=${jobId} disabled`)

View File

@ -371,8 +371,7 @@ class SqlServerIntegration extends Sql implements DatasourcePlus {
? `${query.sql}; SELECT SCOPE_IDENTITY() AS id;` ? `${query.sql}; SELECT SCOPE_IDENTITY() AS id;`
: query.sql : query.sql
this.log(sql, query.bindings) this.log(sql, query.bindings)
const resp = await request.query(sql) return await request.query(sql)
return resp
} catch (err: any) { } catch (err: any) {
let readableMessage = getReadableErrorMessage( let readableMessage = getReadableErrorMessage(
SourceName.SQL_SERVER, SourceName.SQL_SERVER,

View File

@ -190,7 +190,7 @@ describe("oauth2 utils", () => {
await config.doInContext(config.appId, () => getToken(oauthConfig._id)) await config.doInContext(config.appId, () => getToken(oauthConfig._id))
await testUtils.queue.processMessages( await testUtils.queue.processMessages(
cache.docWritethrough.DocWritethroughProcessor.queue cache.docWritethrough.DocWritethroughProcessor.queue.getBullQueue()
) )
const usageLog = await config.doInContext(config.appId, () => const usageLog = await config.doInContext(config.appId, () =>
@ -216,7 +216,7 @@ describe("oauth2 utils", () => {
config.doInContext(config.appId, () => getToken(oauthConfig._id)) config.doInContext(config.appId, () => getToken(oauthConfig._id))
).rejects.toThrow() ).rejects.toThrow()
await testUtils.queue.processMessages( await testUtils.queue.processMessages(
cache.docWritethrough.DocWritethroughProcessor.queue cache.docWritethrough.DocWritethroughProcessor.queue.getBullQueue()
) )
const usageLog = await config.doInContext(config.appId, () => const usageLog = await config.doInContext(config.appId, () =>
@ -247,7 +247,7 @@ describe("oauth2 utils", () => {
getToken(oauthConfig._id) getToken(oauthConfig._id)
) )
await testUtils.queue.processMessages( await testUtils.queue.processMessages(
cache.docWritethrough.DocWritethroughProcessor.queue cache.docWritethrough.DocWritethroughProcessor.queue.getBullQueue()
) )
for (const appId of [config.appId, config.prodAppId]) { for (const appId of [config.appId, config.prodAppId]) {

View File

@ -0,0 +1,8 @@
import { TestAPI } from "./base"
export class AssetsAPI extends TestAPI {
get = async (path: string) => {
// has to be raw, body isn't JSON
return await this._requestRaw("get", `/builder/${path}`)
}
}

View File

@ -21,6 +21,7 @@ import { EnvironmentAPI } from "./environment"
import { UserPublicAPI } from "./public/user" import { UserPublicAPI } from "./public/user"
import { MiscAPI } from "./misc" import { MiscAPI } from "./misc"
import { OAuth2API } from "./oauth2" import { OAuth2API } from "./oauth2"
import { AssetsAPI } from "./assets"
export default class API { export default class API {
application: ApplicationAPI application: ApplicationAPI
@ -44,6 +45,7 @@ export default class API {
user: UserAPI user: UserAPI
viewV2: ViewV2API viewV2: ViewV2API
webhook: WebhookAPI webhook: WebhookAPI
assets: AssetsAPI
public: { public: {
user: UserPublicAPI user: UserPublicAPI
@ -71,6 +73,7 @@ export default class API {
this.user = new UserAPI(config) this.user = new UserAPI(config)
this.viewV2 = new ViewV2API(config) this.viewV2 = new ViewV2API(config)
this.webhook = new WebhookAPI(config) this.webhook = new WebhookAPI(config)
this.assets = new AssetsAPI(config)
this.public = { this.public = {
user: new UserPublicAPI(config), user: new UserPublicAPI(config),
} }

View File

@ -8,6 +8,7 @@ import { v4 as uuid } from "uuid"
export const TOP_LEVEL_PATH = export const TOP_LEVEL_PATH =
env.TOP_LEVEL_PATH || resolve(join(__dirname, "..", "..", "..")) env.TOP_LEVEL_PATH || resolve(join(__dirname, "..", "..", ".."))
export const DEV_ASSET_PATH = join(TOP_LEVEL_PATH, "packages", "server")
/** /**
* Upon first startup of instance there may not be everything we need in tmp directory, set it up. * Upon first startup of instance there may not be everything we need in tmp directory, set it up.

View File

@ -106,7 +106,7 @@ export function validate(
} else if ( } else if (
// If provided must be a valid date // If provided must be a valid date
columnType === FieldType.DATETIME && columnType === FieldType.DATETIME &&
isNaN(new Date(columnData).getTime()) sql.utils.isInvalidISODateString(columnData)
) { ) {
results.schemaValidation[columnName] = false results.schemaValidation[columnName] = false
} else if ( } else if (

View File

@ -30,4 +30,12 @@ export const SWITCHABLE_TYPES: SwitchableTypes = {
FieldType.LONGFORM, FieldType.LONGFORM,
], ],
[FieldType.NUMBER]: [FieldType.NUMBER, FieldType.BOOLEAN], [FieldType.NUMBER]: [FieldType.NUMBER, FieldType.BOOLEAN],
[FieldType.JSON]: [
FieldType.JSON,
FieldType.ARRAY,
FieldType.ATTACHMENTS,
FieldType.ATTACHMENT_SINGLE,
FieldType.BB_REFERENCE,
FieldType.SIGNATURE_SINGLE,
],
} }

View File

@ -128,6 +128,7 @@ export enum FieldType {
export const JsonTypes = [ export const JsonTypes = [
FieldType.ATTACHMENT_SINGLE, FieldType.ATTACHMENT_SINGLE,
FieldType.ATTACHMENTS, FieldType.ATTACHMENTS,
FieldType.SIGNATURE_SINGLE,
// only BB_REFERENCE is JSON, it's an array, BB_REFERENCE_SINGLE is a string type // only BB_REFERENCE is JSON, it's an array, BB_REFERENCE_SINGLE is a string type
FieldType.BB_REFERENCE, FieldType.BB_REFERENCE,
FieldType.JSON, FieldType.JSON,