Merge branch 'master' of github.com:Budibase/budibase into type-portal-flags-store

This commit is contained in:
Andrew Kingston 2025-01-06 13:35:57 +00:00
commit f2eb5daf8e
No known key found for this signature in database
32 changed files with 463 additions and 495 deletions

View File

@ -10,7 +10,7 @@
},
"dependencies": {
"bulma": "^0.9.3",
"next": "14.2.15",
"next": "14.2.21",
"node-fetch": "^3.2.10",
"sass": "^1.52.3",
"react": "17.0.2",

View File

@ -46,10 +46,10 @@
resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45"
integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==
"@next/env@14.2.15":
version "14.2.15"
resolved "https://registry.yarnpkg.com/@next/env/-/env-14.2.15.tgz#06d984e37e670d93ddd6790af1844aeb935f332f"
integrity sha512-S1qaj25Wru2dUpcIZMjxeMVSwkt8BK4dmWHHiBuRstcIyOsMapqT4A4jSB6onvqeygkSSmOkyny9VVx8JIGamQ==
"@next/env@14.2.21":
version "14.2.21"
resolved "https://registry.yarnpkg.com/@next/env/-/env-14.2.21.tgz#09ff0813d29c596397e141205d4f5fd5c236bdd0"
integrity sha512-lXcwcJd5oR01tggjWJ6SrNNYFGuOOMB9c251wUNkjCpkoXOPkDeF/15c3mnVlBqrW4JJXb2kVxDFhC4GduJt2A==
"@next/eslint-plugin-next@12.1.0":
version "12.1.0"
@ -58,50 +58,50 @@
dependencies:
glob "7.1.7"
"@next/swc-darwin-arm64@14.2.15":
version "14.2.15"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.15.tgz#6386d585f39a1c490c60b72b1f76612ba4434347"
integrity sha512-Rvh7KU9hOUBnZ9TJ28n2Oa7dD9cvDBKua9IKx7cfQQ0GoYUwg9ig31O2oMwH3wm+pE3IkAQ67ZobPfEgurPZIA==
"@next/swc-darwin-arm64@14.2.21":
version "14.2.21"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.21.tgz#32a31992aace1440981df9cf7cb3af7845d94fec"
integrity sha512-HwEjcKsXtvszXz5q5Z7wCtrHeTTDSTgAbocz45PHMUjU3fBYInfvhR+ZhavDRUYLonm53aHZbB09QtJVJj8T7g==
"@next/swc-darwin-x64@14.2.15":
version "14.2.15"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.15.tgz#b7baeedc6a28f7545ad2bc55adbab25f7b45cb89"
integrity sha512-5TGyjFcf8ampZP3e+FyCax5zFVHi+Oe7sZyaKOngsqyaNEpOgkKB3sqmymkZfowy3ufGA/tUgDPPxpQx931lHg==
"@next/swc-darwin-x64@14.2.21":
version "14.2.21"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.21.tgz#5ab4b3f6685b6b52f810d0f5cf6e471480ddffdb"
integrity sha512-TSAA2ROgNzm4FhKbTbyJOBrsREOMVdDIltZ6aZiKvCi/v0UwFmwigBGeqXDA97TFMpR3LNNpw52CbVelkoQBxA==
"@next/swc-linux-arm64-gnu@14.2.15":
version "14.2.15"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.15.tgz#fa13c59d3222f70fb4cb3544ac750db2c6e34d02"
integrity sha512-3Bwv4oc08ONiQ3FiOLKT72Q+ndEMyLNsc/D3qnLMbtUYTQAmkx9E/JRu0DBpHxNddBmNT5hxz1mYBphJ3mfrrw==
"@next/swc-linux-arm64-gnu@14.2.21":
version "14.2.21"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.21.tgz#8a0e1fa887aef19ca218af2af515d0a5ee67ba3f"
integrity sha512-0Dqjn0pEUz3JG+AImpnMMW/m8hRtl1GQCNbO66V1yp6RswSTiKmnHf3pTX6xMdJYSemf3O4Q9ykiL0jymu0TuA==
"@next/swc-linux-arm64-musl@14.2.15":
version "14.2.15"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.15.tgz#30e45b71831d9a6d6d18d7ac7d611a8d646a17f9"
integrity sha512-k5xf/tg1FBv/M4CMd8S+JL3uV9BnnRmoe7F+GWC3DxkTCD9aewFRH1s5rJ1zkzDa+Do4zyN8qD0N8c84Hu96FQ==
"@next/swc-linux-arm64-musl@14.2.21":
version "14.2.21"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.21.tgz#ddad844406b42fa8965fe11250abc85c1fe0fd05"
integrity sha512-Ggfw5qnMXldscVntwnjfaQs5GbBbjioV4B4loP+bjqNEb42fzZlAaK+ldL0jm2CTJga9LynBMhekNfV8W4+HBw==
"@next/swc-linux-x64-gnu@14.2.15":
version "14.2.15"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.15.tgz#5065db17fc86f935ad117483f21f812dc1b39254"
integrity sha512-kE6q38hbrRbKEkkVn62reLXhThLRh6/TvgSP56GkFNhU22TbIrQDEMrO7j0IcQHcew2wfykq8lZyHFabz0oBrA==
"@next/swc-linux-x64-gnu@14.2.21":
version "14.2.21"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.21.tgz#db55fd666f9ba27718f65caa54b622a912cdd16b"
integrity sha512-uokj0lubN1WoSa5KKdThVPRffGyiWlm/vCc/cMkWOQHw69Qt0X1o3b2PyLLx8ANqlefILZh1EdfLRz9gVpG6tg==
"@next/swc-linux-x64-musl@14.2.15":
version "14.2.15"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.15.tgz#3c4a4568d8be7373a820f7576cf33388b5dab47e"
integrity sha512-PZ5YE9ouy/IdO7QVJeIcyLn/Rc4ml9M2G4y3kCM9MNf1YKvFY4heg3pVa/jQbMro+tP6yc4G2o9LjAz1zxD7tQ==
"@next/swc-linux-x64-musl@14.2.21":
version "14.2.21"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.21.tgz#dddb850353624efcd58c4c4e30ad8a1aab379642"
integrity sha512-iAEBPzWNbciah4+0yI4s7Pce6BIoxTQ0AGCkxn/UBuzJFkYyJt71MadYQkjPqCQCJAFQ26sYh7MOKdU+VQFgPg==
"@next/swc-win32-arm64-msvc@14.2.15":
version "14.2.15"
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.15.tgz#fb812cc4ca0042868e32a6a021da91943bb08b98"
integrity sha512-2raR16703kBvYEQD9HNLyb0/394yfqzmIeyp2nDzcPV4yPjqNUG3ohX6jX00WryXz6s1FXpVhsCo3i+g4RUX+g==
"@next/swc-win32-arm64-msvc@14.2.21":
version "14.2.21"
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.21.tgz#290012ee57b196d3d2d04853e6bf0179cae9fbaf"
integrity sha512-plykgB3vL2hB4Z32W3ktsfqyuyGAPxqwiyrAi2Mr8LlEUhNn9VgkiAl5hODSBpzIfWweX3er1f5uNpGDygfQVQ==
"@next/swc-win32-ia32-msvc@14.2.15":
version "14.2.15"
resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.15.tgz#ec26e6169354f8ced240c1427be7fd485c5df898"
integrity sha512-fyTE8cklgkyR1p03kJa5zXEaZ9El+kDNM5A+66+8evQS5e/6v0Gk28LqA0Jet8gKSOyP+OTm/tJHzMlGdQerdQ==
"@next/swc-win32-ia32-msvc@14.2.21":
version "14.2.21"
resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.21.tgz#c959135a78cab18cca588d11d1e33bcf199590d4"
integrity sha512-w5bacz4Vxqrh06BjWgua3Yf7EMDb8iMcVhNrNx8KnJXt8t+Uu0Zg4JHLDL/T7DkTCEEfKXO/Er1fcfWxn2xfPA==
"@next/swc-win32-x64-msvc@14.2.15":
version "14.2.15"
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.15.tgz#18d68697002b282006771f8d92d79ade9efd35c4"
integrity sha512-SzqGbsLsP9OwKNUG9nekShTwhj6JSB9ZLMWQ8g1gG6hdE5gQLncbnbymrwy2yVmH9nikSLYRYxYMFu78Ggp7/g==
"@next/swc-win32-x64-msvc@14.2.21":
version "14.2.21"
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.21.tgz#21ff892286555b90538a7d1b505ea21a005d6ead"
integrity sha512-sT6+llIkzpsexGYZq8cjjthRyRGe5cJVhqh12FmlbxHqna6zsDDK8UNaV7g41T6atFHCJUPeLb3uyAwrBwy0NA==
"@nodelib/fs.scandir@2.1.5":
version "2.1.5"
@ -1253,12 +1253,12 @@ natural-compare@^1.4.0:
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=
next@14.2.15:
version "14.2.15"
resolved "https://registry.yarnpkg.com/next/-/next-14.2.15.tgz#348e5603e22649775d19c785c09a89c9acb5189a"
integrity sha512-h9ctmOokpoDphRvMGnwOJAedT6zKhwqyZML9mDtspgf4Rh3Pn7UTYKqePNoDvhsWBAO5GoPNYshnAUGIazVGmw==
next@14.2.21:
version "14.2.21"
resolved "https://registry.yarnpkg.com/next/-/next-14.2.21.tgz#f6da9e2abba1a0e4ca7a5273825daf06632554ba"
integrity sha512-rZmLwucLHr3/zfDMYbJXbw0ZeoBpirxkXuvsJbk7UPorvPYZhP7vq7aHbKnU7dQNCYIimRrbB2pp3xmf+wsYUg==
dependencies:
"@next/env" "14.2.15"
"@next/env" "14.2.21"
"@swc/helpers" "0.5.5"
busboy "1.6.0"
caniuse-lite "^1.0.30001579"
@ -1266,15 +1266,15 @@ next@14.2.15:
postcss "8.4.31"
styled-jsx "5.1.1"
optionalDependencies:
"@next/swc-darwin-arm64" "14.2.15"
"@next/swc-darwin-x64" "14.2.15"
"@next/swc-linux-arm64-gnu" "14.2.15"
"@next/swc-linux-arm64-musl" "14.2.15"
"@next/swc-linux-x64-gnu" "14.2.15"
"@next/swc-linux-x64-musl" "14.2.15"
"@next/swc-win32-arm64-msvc" "14.2.15"
"@next/swc-win32-ia32-msvc" "14.2.15"
"@next/swc-win32-x64-msvc" "14.2.15"
"@next/swc-darwin-arm64" "14.2.21"
"@next/swc-darwin-x64" "14.2.21"
"@next/swc-linux-arm64-gnu" "14.2.21"
"@next/swc-linux-arm64-musl" "14.2.21"
"@next/swc-linux-x64-gnu" "14.2.21"
"@next/swc-linux-x64-musl" "14.2.21"
"@next/swc-win32-arm64-msvc" "14.2.21"
"@next/swc-win32-ia32-msvc" "14.2.21"
"@next/swc-win32-x64-msvc" "14.2.21"
node-domexception@^1.0.0:
version "1.0.0"

View File

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

View File

@ -385,17 +385,17 @@ export function getCurrentContext(): ContextMap | undefined {
}
}
export function getFeatureFlags<T extends Record<string, any>>(
export function getFeatureFlags(
key: string
): T | undefined {
): Record<string, boolean> | undefined {
const context = getCurrentContext()
if (!context) {
return undefined
}
return context.featureFlagCache?.[key] as T
return context.featureFlagCache?.[key]
}
export function setFeatureFlags(key: string, value: Record<string, any>) {
export function setFeatureFlags(key: string, value: Record<string, boolean>) {
const context = getCurrentContext()
if (!context) {
return

View File

@ -20,7 +20,7 @@ export type ContextMap = {
clients: Record<string, GoogleSpreadsheet>
}
featureFlagCache?: {
[key: string]: Record<string, any>
[key: string]: Record<string, boolean>
}
viewToTableCache?: Record<string, Table>
}

View File

@ -2,9 +2,10 @@ import env from "../environment"
import * as crypto from "crypto"
import * as context from "../context"
import { PostHog, PostHogOptions } from "posthog-node"
import { FeatureFlag } from "@budibase/types"
import tracer from "dd-trace"
import { Duration } from "../utils"
import { cloneDeep } from "lodash"
import { FeatureFlagDefaults } from "@budibase/types"
let posthog: PostHog | undefined
export function init(opts?: PostHogOptions) {
@ -30,74 +31,6 @@ export function shutdown() {
posthog?.shutdown()
}
export abstract class Flag<T> {
static boolean(defaultValue: boolean): Flag<boolean> {
return new BooleanFlag(defaultValue)
}
static string(defaultValue: string): Flag<string> {
return new StringFlag(defaultValue)
}
static number(defaultValue: number): Flag<number> {
return new NumberFlag(defaultValue)
}
protected constructor(public defaultValue: T) {}
abstract parse(value: any): T
}
type UnwrapFlag<F> = F extends Flag<infer U> ? U : never
export type FlagValues<T> = {
[K in keyof T]: UnwrapFlag<T[K]>
}
type KeysOfType<T, U> = {
[K in keyof T]: T[K] extends Flag<U> ? K : never
}[keyof T]
class BooleanFlag extends Flag<boolean> {
parse(value: any) {
if (typeof value === "string") {
return ["true", "t", "1"].includes(value.toLowerCase())
}
if (typeof value === "boolean") {
return value
}
throw new Error(`could not parse value "${value}" as boolean`)
}
}
class StringFlag extends Flag<string> {
parse(value: any) {
if (typeof value === "string") {
return value
}
throw new Error(`could not parse value "${value}" as string`)
}
}
class NumberFlag extends Flag<number> {
parse(value: any) {
if (typeof value === "number") {
return value
}
if (typeof value === "string") {
const parsed = parseFloat(value)
if (!isNaN(parsed)) {
return parsed
}
}
throw new Error(`could not parse value "${value}" as number`)
}
}
export interface EnvFlagEntry {
tenantId: string
key: string
@ -120,7 +53,7 @@ export function parseEnvFlags(flags: string): EnvFlagEntry[] {
return result
}
export class FlagSet<V extends Flag<any>, T extends { [key: string]: V }> {
export class FlagSet<T extends { [name: string]: boolean }> {
// This is used to safely cache flags sets in the current request context.
// Because multiple sets could theoretically exist, we don't want the cache of
// one to leak into another.
@ -130,34 +63,25 @@ export class FlagSet<V extends Flag<any>, T extends { [key: string]: V }> {
this.setId = crypto.randomUUID()
}
defaults(): FlagValues<T> {
return Object.keys(this.flagSchema).reduce((acc, key) => {
const typedKey = key as keyof T
acc[typedKey] = this.flagSchema[key].defaultValue
return acc
}, {} as FlagValues<T>)
defaults(): T {
return cloneDeep(this.flagSchema)
}
isFlagName(name: string | number | symbol): name is keyof T {
return this.flagSchema[name as keyof T] !== undefined
}
async get<K extends keyof T>(key: K): Promise<FlagValues<T>[K]> {
async isEnabled<K extends keyof T>(key: K): Promise<T[K]> {
const flags = await this.fetch()
return flags[key]
}
async isEnabled<K extends KeysOfType<T, boolean>>(key: K): Promise<boolean> {
const flags = await this.fetch()
return flags[key]
}
async fetch(): Promise<FlagValues<T>> {
async fetch(): Promise<T> {
return await tracer.trace("features.fetch", async span => {
const cachedFlags = context.getFeatureFlags<FlagValues<T>>(this.setId)
const cachedFlags = context.getFeatureFlags(this.setId)
if (cachedFlags) {
span?.addTags({ fromCache: true })
return cachedFlags
return cachedFlags as T
}
const tags: Record<string, any> = {}
@ -189,7 +113,7 @@ export class FlagSet<V extends Flag<any>, T extends { [key: string]: V }> {
// @ts-expect-error - TS does not like you writing into a generic type,
// but we know that it's okay in this case because it's just an object.
flagValues[key as keyof FlagValues] = value
flagValues[key as keyof T] = value
tags[`flags.${key}.source`] = "environment"
}
@ -217,11 +141,11 @@ export class FlagSet<V extends Flag<any>, T extends { [key: string]: V }> {
tags[`readFromPostHog`] = true
const personProperties: Record<string, string> = { tenantId }
const posthogFlags = await posthog.getAllFlagsAndPayloads(userId, {
const posthogFlags = await posthog.getAllFlags(userId, {
personProperties,
})
for (const [name, value] of Object.entries(posthogFlags.featureFlags)) {
for (const [name, value] of Object.entries(posthogFlags)) {
if (!this.isFlagName(name)) {
// We don't want an unexpected PostHog flag to break the app, so we
// just log it and continue.
@ -229,19 +153,20 @@ export class FlagSet<V extends Flag<any>, T extends { [key: string]: V }> {
continue
}
if (typeof value !== "boolean") {
console.warn(`Invalid value for posthog flag "${name}": ${value}`)
continue
}
if (flagValues[name] === true || specificallySetFalse.has(name)) {
// If the flag is already set to through environment variables, we
// don't want to override it back to false here.
continue
}
const payload = posthogFlags.featureFlagPayloads?.[name]
const flag = this.flagSchema[name]
try {
// @ts-expect-error - TS does not like you writing into a generic
// type, but we know that it's okay in this case because it's just
// an object.
flagValues[name] = flag.parse(payload || value)
// @ts-expect-error - TS does not like you writing into a generic type.
flagValues[name] = value
tags[`flags.${name}.source`] = "posthog"
} catch (err) {
// We don't want an invalid PostHog flag to break the app, so we just
@ -262,18 +187,12 @@ export class FlagSet<V extends Flag<any>, T extends { [key: string]: V }> {
}
}
// This is the primary source of truth for feature flags. If you want to add a
// new flag, add it here and use the `fetch` and `get` functions to access it.
// All of the machinery in this file is to make sure that flags have their
// default values set correctly and their types flow through the system.
const flagsConfig: Record<FeatureFlag, Flag<any>> = {
[FeatureFlag.DEFAULT_VALUES]: Flag.boolean(true),
[FeatureFlag.AUTOMATION_BRANCHING]: Flag.boolean(true),
[FeatureFlag.AI_CUSTOM_CONFIGS]: Flag.boolean(true),
[FeatureFlag.BUDIBASE_AI]: Flag.boolean(true),
[FeatureFlag.USE_ZOD_VALIDATOR]: Flag.boolean(env.isDev()),
}
export const flags = new FlagSet(flagsConfig)
export const flags = new FlagSet(FeatureFlagDefaults)
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T
export type FeatureFlags = UnwrapPromise<ReturnType<typeof flags.fetch>>
export async function isEnabled(flag: keyof typeof FeatureFlagDefaults) {
return await flags.isEnabled(flag)
}
export async function all() {
return await flags.fetch()
}

View File

@ -1,5 +1,5 @@
import { IdentityContext, IdentityType } from "@budibase/types"
import { Flag, FlagSet, FlagValues, init, shutdown } from "../"
import { FlagSet, init, shutdown } from "../"
import * as context from "../../context"
import environment, { withEnv } from "../../environment"
import nodeFetch from "node-fetch"
@ -7,10 +7,8 @@ import nock from "nock"
import * as crypto from "crypto"
const schema = {
TEST_BOOLEAN: Flag.boolean(false),
TEST_STRING: Flag.string("default value"),
TEST_NUMBER: Flag.number(0),
TEST_BOOLEAN_DEFAULT_TRUE: Flag.boolean(true),
TEST_BOOLEAN: false,
TEST_BOOLEAN_DEFAULT_TRUE: true,
}
const flags = new FlagSet(schema)
@ -19,7 +17,7 @@ interface TestCase {
identity?: Partial<IdentityContext>
environmentFlags?: string
posthogFlags?: PostHogFlags
expected?: Partial<FlagValues<typeof schema>>
expected?: Partial<typeof schema>
errorMessage?: string | RegExp
}
@ -83,22 +81,6 @@ describe("feature flags", () => {
},
expected: { TEST_BOOLEAN: true },
},
{
it: "should be able to read string flags from PostHog",
posthogFlags: {
featureFlags: { TEST_STRING: true },
featureFlagPayloads: { TEST_STRING: "test" },
},
expected: { TEST_STRING: "test" },
},
{
it: "should be able to read numeric flags from PostHog",
posthogFlags: {
featureFlags: { TEST_NUMBER: true },
featureFlagPayloads: { TEST_NUMBER: "123" },
},
expected: { TEST_NUMBER: 123 },
},
{
it: "should not be able to override a negative environment flag from PostHog",
environmentFlags: "default:!TEST_BOOLEAN",
@ -177,7 +159,7 @@ describe("feature flags", () => {
expect(values).toMatchObject(expected)
for (const [key, expectedValue] of Object.entries(expected)) {
const value = await flags.get(key as keyof typeof schema)
const value = await flags.isEnabled(key as keyof typeof schema)
expect(value).toBe(expectedValue)
}
} else {

View File

@ -1,5 +1,6 @@
import { FeatureFlags, parseEnvFlags } from ".."
import { FeatureFlags } from "@budibase/types"
import { setEnv } from "../../environment"
import { parseEnvFlags } from "../features"
function getCurrentFlags(): Record<string, Record<string, boolean>> {
const result: Record<string, Record<string, boolean>> = {}

View File

@ -291,8 +291,8 @@ const automationActions = (store: AutomationStore) => ({
let result: (AutomationStep | AutomationTrigger)[] = []
pathWay.forEach(path => {
const { stepIdx, branchIdx } = path
let last = result ? result[result.length - 1] : []
if (!result) {
let last = result.length ? result[result.length - 1] : []
if (!result.length) {
// Preceeding steps.
result = steps.slice(0, stepIdx + 1)
return

View File

@ -1,27 +0,0 @@
import { writable } from "svelte/store"
import { API } from "@/api"
export function createFlagsStore() {
const { subscribe, set } = writable({})
const actions = {
fetch: async () => {
const flags = await API.getFlags()
set(flags)
},
updateFlag: async (flag, value) => {
await API.updateFlag(flag, value)
await actions.fetch()
},
toggleUiFeature: async feature => {
await API.toggleUiFeature(feature)
},
}
return {
subscribe,
...actions,
}
}
export const flags = createFlagsStore()

View File

@ -0,0 +1,40 @@
import { API } from "@/api"
import { GetUserFlagsResponse } from "@budibase/types"
import { BudiStore } from "../BudiStore"
interface FlagsState {
flags: GetUserFlagsResponse
}
const INITIAL_FLAGS_STATE: FlagsState = {
flags: {},
}
export class FlagsStore extends BudiStore<FlagsState> {
constructor() {
super(INITIAL_FLAGS_STATE)
this.fetch = this.fetch.bind(this)
this.updateFlag = this.updateFlag.bind(this)
this.toggleUiFeature = this.toggleUiFeature.bind(this)
}
async fetch() {
const flags = await API.getFlags()
this.update(state => ({
...state,
flags,
}))
}
async updateFlag(flag: string, value: any) {
await API.updateFlag(flag, value)
await this.fetch()
}
async toggleUiFeature(feature: string) {
await API.toggleUiFeature(feature)
}
}
export const flags = new FlagsStore()

View File

@ -2,13 +2,19 @@ import { derived, get } from "svelte/store"
import { componentStore } from "@/stores/builder"
import { API } from "@/api"
import { BudiStore } from "../BudiStore"
import { Layout } from "@budibase/types"
export const INITIAL_LAYOUT_STATE = {
interface LayoutState {
layouts: Layout[]
selectedLayoutId: string | null
}
export const INITIAL_LAYOUT_STATE: LayoutState = {
layouts: [],
selectedLayoutId: null,
}
export class LayoutStore extends BudiStore {
export class LayoutStore extends BudiStore<LayoutState> {
constructor() {
super(INITIAL_LAYOUT_STATE)
@ -22,14 +28,14 @@ export class LayoutStore extends BudiStore {
this.store.set({ ...INITIAL_LAYOUT_STATE })
}
syncAppLayouts(pkg) {
syncAppLayouts(pkg: { layouts: Layout[] }) {
this.update(state => ({
...state,
layouts: [...pkg.layouts],
}))
}
select(layoutId) {
select(layoutId: string) {
// Check this layout exists
const state = get(this.store)
const componentState = get(componentStore)
@ -48,15 +54,15 @@ export class LayoutStore extends BudiStore {
// Select new layout
this.update(state => {
state.selectedLayoutId = layout._id
state.selectedLayoutId = layout._id!
return state
})
componentStore.select(layout.props?._id)
}
async deleteLayout(layout) {
if (!layout?._id) {
async deleteLayout(layout: Layout) {
if (!layout?._id || !layout?._rev) {
return
}
await API.deleteLayout(layout._id, layout._rev)

View File

@ -1,80 +0,0 @@
import { writable, get } from "svelte/store"
const INITIAL_PREVIEW_STATE = {
previewDevice: "desktop",
previewEventHandler: null,
showPreview: false,
selectedComponentContext: null,
}
export const createPreviewStore = () => {
const store = writable({
...INITIAL_PREVIEW_STATE,
})
const setDevice = device => {
store.update(state => {
state.previewDevice = device
return state
})
}
// Potential evt names "eject-block", "dragging-new-component"
const sendEvent = (name, payload) => {
const { previewEventHandler } = get(store)
previewEventHandler?.(name, payload)
}
const registerEventHandler = handler => {
store.update(state => {
state.previewEventHandler = handler
return state
})
}
const startDrag = component => {
sendEvent("dragging-new-component", {
dragging: true,
component,
})
}
const stopDrag = () => {
sendEvent("dragging-new-component", {
dragging: false,
})
}
//load preview?
const showPreview = isVisible => {
store.update(state => {
state.showPreview = isVisible
return state
})
}
const setSelectedComponentContext = context => {
store.update(state => {
state.selectedComponentContext = context
return state
})
}
const requestComponentContext = () => {
sendEvent("request-context")
}
return {
subscribe: store.subscribe,
setDevice,
sendEvent, //may not be required
registerEventHandler,
startDrag,
stopDrag,
showPreview,
setSelectedComponentContext,
requestComponentContext,
}
}
export const previewStore = createPreviewStore()

View File

@ -0,0 +1,90 @@
import { get } from "svelte/store"
import { BudiStore } from "../BudiStore"
type PreviewDevice = "desktop" | "tablet" | "mobile"
type PreviewEventHandler = (name: string, payload?: any) => void
type ComponentContext = Record<string, any>
interface PreviewState {
previewDevice: PreviewDevice
previewEventHandler: PreviewEventHandler | null
showPreview: boolean
selectedComponentContext: ComponentContext | null
}
const INITIAL_PREVIEW_STATE: PreviewState = {
previewDevice: "desktop",
previewEventHandler: null,
showPreview: false,
selectedComponentContext: null,
}
export class PreviewStore extends BudiStore<PreviewState> {
constructor() {
super(INITIAL_PREVIEW_STATE)
this.setDevice = this.setDevice.bind(this)
this.sendEvent = this.sendEvent.bind(this)
this.registerEventHandler = this.registerEventHandler.bind(this)
this.startDrag = this.startDrag.bind(this)
this.stopDrag = this.stopDrag.bind(this)
this.showPreview = this.showPreview.bind(this)
this.setSelectedComponentContext =
this.setSelectedComponentContext.bind(this)
this.requestComponentContext = this.requestComponentContext.bind(this)
}
setDevice(device: PreviewDevice) {
this.update(state => ({
...state,
previewDevice: device,
}))
}
// Potential evt names "eject-block", "dragging-new-component"
sendEvent(name: string, payload?: any) {
const { previewEventHandler } = get(this.store)
previewEventHandler?.(name, payload)
}
registerEventHandler(handler: PreviewEventHandler) {
this.update(state => ({
...state,
previewEventHandler: handler,
}))
}
startDrag(component: any) {
this.sendEvent("dragging-new-component", {
dragging: true,
component,
})
}
stopDrag() {
this.sendEvent("dragging-new-component", {
dragging: false,
})
}
//load preview?
showPreview(isVisible: boolean) {
this.update(state => ({
...state,
showPreview: isVisible,
}))
}
setSelectedComponentContext(context: ComponentContext) {
this.update(state => ({
...state,
selectedComponentContext: context,
}))
}
requestComponentContext() {
this.sendEvent("request-context")
}
}
export const previewStore = new PreviewStore()

View File

@ -1,62 +0,0 @@
import { writable, get, derived } from "svelte/store"
export const createUserStore = () => {
const store = writable([])
const init = users => {
store.set(users)
}
const updateUser = user => {
const $users = get(store)
if (!$users.some(x => x.sessionId === user.sessionId)) {
store.set([...$users, user])
} else {
store.update(state => {
const index = state.findIndex(x => x.sessionId === user.sessionId)
state[index] = user
return state.slice()
})
}
}
const removeUser = sessionId => {
store.update(state => {
return state.filter(x => x.sessionId !== sessionId)
})
}
const reset = () => {
store.set([])
}
return {
...store,
actions: {
init,
updateUser,
removeUser,
reset,
},
}
}
export const userStore = createUserStore()
export const userSelectedResourceMap = derived(userStore, $userStore => {
let map = {}
$userStore.forEach(user => {
const resource = user.builderMetadata?.selectedResourceId
if (resource) {
if (!map[resource]) {
map[resource] = []
}
map[resource].push(user)
}
})
return map
})
export const isOnlyUser = derived(userStore, $userStore => {
return $userStore.length < 2
})

View File

@ -0,0 +1,59 @@
import { get, derived } from "svelte/store"
import { BudiStore } from "../BudiStore"
import { UIUser } from "@budibase/types"
export class UserStore extends BudiStore<UIUser[]> {
constructor() {
super([])
}
init(users: UIUser[]) {
this.set(users)
}
updateUser(user: UIUser) {
const $users = get(this)
if (!$users.some(x => x.sessionId === user.sessionId)) {
this.set([...$users, user])
} else {
this.update(state => {
const index = state.findIndex(x => x.sessionId === user.sessionId)
state[index] = user
return state.slice()
})
}
}
removeUser(sessionId: string) {
this.update(state => {
return state.filter(x => x.sessionId !== sessionId)
})
}
reset() {
this.set([])
}
}
export const userStore = new UserStore()
export const userSelectedResourceMap = derived(
userStore,
($userStore): Record<string, UIUser[]> => {
let map: Record<string, UIUser[]> = {}
$userStore.forEach(user => {
const resource = user.builderMetadata?.selectedResourceId
if (resource) {
if (!map[resource]) {
map[resource] = []
}
map[resource].push(user)
}
})
return map
}
)
export const isOnlyUser = derived(userStore, $userStore => {
return $userStore.length < 2
})

View File

@ -1,95 +0,0 @@
import { createWebsocket } from "@budibase/frontend-core"
import {
automationStore,
userStore,
appStore,
themeStore,
navigationStore,
deploymentStore,
snippets,
datasources,
tables,
roles,
} from "@/stores/builder"
import { get } from "svelte/store"
import { auth, appsStore } from "@/stores/portal"
import { screenStore } from "./screens"
import { SocketEvent, BuilderSocketEvent, helpers } from "@budibase/shared-core"
import { notifications } from "@budibase/bbui"
export const createBuilderWebsocket = appId => {
const socket = createWebsocket("/socket/builder")
// Built-in events
socket.on("connect", () => {
socket.emit(BuilderSocketEvent.SelectApp, { appId }, ({ users }) => {
userStore.actions.init(users)
})
})
socket.on("connect_error", err => {
console.error("Failed to connect to builder websocket:", err.message)
})
socket.on("disconnect", () => {
userStore.actions.reset()
})
// User events
socket.onOther(SocketEvent.UserUpdate, ({ user }) => {
userStore.actions.updateUser(user)
})
socket.onOther(SocketEvent.UserDisconnect, ({ sessionId }) => {
userStore.actions.removeUser(sessionId)
})
socket.onOther(BuilderSocketEvent.LockTransfer, ({ userId }) => {
if (userId === get(auth)?.user?._id) {
appStore.update(state => ({
...state,
hasLock: true,
}))
}
})
// Data section events
socket.onOther(BuilderSocketEvent.TableChange, ({ id, table }) => {
tables.replaceTable(id, table)
})
socket.onOther(BuilderSocketEvent.DatasourceChange, ({ id, datasource }) => {
datasources.replaceDatasource(id, datasource)
})
// Role events
socket.onOther(BuilderSocketEvent.RoleChange, ({ id, role }) => {
roles.replace(id, role)
})
// Design section events
socket.onOther(BuilderSocketEvent.ScreenChange, ({ id, screen }) => {
screenStore.replace(id, screen)
})
// App events
socket.onOther(BuilderSocketEvent.AppMetadataChange, ({ metadata }) => {
appStore.syncMetadata(metadata)
themeStore.syncMetadata(metadata)
navigationStore.syncMetadata(metadata)
snippets.syncMetadata(metadata)
})
socket.onOther(
BuilderSocketEvent.AppPublishChange,
async ({ user, published }) => {
await appsStore.load()
if (published) {
await deploymentStore.load()
}
const verb = published ? "published" : "unpublished"
notifications.success(`${helpers.getUserLabel(user)} ${verb} this app`)
}
)
// Automation events
socket.onOther(BuilderSocketEvent.AutomationChange, ({ id, automation }) => {
automationStore.actions.replace(id, automation)
})
return socket
}

View File

@ -0,0 +1,124 @@
import { createWebsocket } from "@budibase/frontend-core"
import {
automationStore,
userStore,
appStore,
themeStore,
navigationStore,
deploymentStore,
snippets,
datasources,
tables,
roles,
} from "@/stores/builder"
import { get } from "svelte/store"
import { auth, appsStore } from "@/stores/portal"
import { screenStore } from "./screens"
import { SocketEvent, BuilderSocketEvent, helpers } from "@budibase/shared-core"
import { notifications } from "@budibase/bbui"
import { Automation, Datasource, Role, Table, UIUser } from "@budibase/types"
export const createBuilderWebsocket = (appId: string) => {
const socket = createWebsocket("/socket/builder")
// Built-in events
socket.on("connect", () => {
socket.emit(
BuilderSocketEvent.SelectApp,
{ appId },
({ users }: { users: UIUser[] }) => {
userStore.init(users)
}
)
})
socket.on("connect_error", err => {
console.error("Failed to connect to builder websocket:", err.message)
})
socket.on("disconnect", () => {
userStore.reset()
})
// User events
socket.onOther(SocketEvent.UserUpdate, ({ user }: { user: UIUser }) => {
userStore.updateUser(user)
})
socket.onOther(
SocketEvent.UserDisconnect,
({ sessionId }: { sessionId: string }) => {
userStore.removeUser(sessionId)
}
)
socket.onOther(
BuilderSocketEvent.LockTransfer,
({ userId }: { userId: string }) => {
if (userId === get(auth)?.user?._id) {
appStore.update(state => ({
...state,
hasLock: true,
}))
}
}
)
// Data section events
socket.onOther(
BuilderSocketEvent.TableChange,
({ id, table }: { id: string; table: Table }) => {
tables.replaceTable(id, table)
}
)
socket.onOther(
BuilderSocketEvent.DatasourceChange,
({ id, datasource }: { id: string; datasource: Datasource }) => {
datasources.replaceDatasource(id, datasource)
}
)
// Role events
socket.onOther(
BuilderSocketEvent.RoleChange,
({ id, role }: { id: string; role: Role }) => {
roles.replace(id, role)
}
)
// Design section events
socket.onOther(
BuilderSocketEvent.ScreenChange,
({ id, screen }: { id: string; screen: Screen }) => {
screenStore.replace(id, screen)
}
)
// App events
socket.onOther(
BuilderSocketEvent.AppMetadataChange,
({ metadata }: { metadata: any }) => {
appStore.syncMetadata(metadata)
themeStore.syncMetadata(metadata)
navigationStore.syncMetadata(metadata)
snippets.syncMetadata(metadata)
}
)
socket.onOther(
BuilderSocketEvent.AppPublishChange,
async ({ user, published }: { user: UIUser; published: boolean }) => {
await appsStore.load()
if (published) {
await deploymentStore.load()
}
const verb = published ? "published" : "unpublished"
notifications.success(`${helpers.getUserLabel(user)} ${verb} this app`)
}
)
// Automation events
socket.onOther(
BuilderSocketEvent.AutomationChange,
({ id, automation }: { id: string; automation: Automation }) => {
automationStore.actions.replace(id, automation)
}
)
return socket
}

View File

@ -1,18 +1,12 @@
import { derived, Readable } from "svelte/store"
import { auth } from "@/stores/portal"
import { FeatureFlags, FeatureFlagDefaults } from "@budibase/types"
export const INITIAL_FEATUREFLAG_STATE = {
SQS: false,
DEFAULT_VALUES: false,
BUDIBASE_AI: false,
AI_CUSTOM_CONFIGS: false,
}
export const featureFlags: Readable<Record<string, any>> = derived(
export const featureFlags: Readable<FeatureFlags> = derived(
[auth],
([$auth]) => {
return {
...INITIAL_FEATUREFLAG_STATE,
...FeatureFlagDefaults,
...($auth?.user?.flags || {}),
}
}

View File

@ -26,7 +26,7 @@
: RelationshipType.MANY_TO_MANY,
}
async function searchFunction(searchParams) {
async function searchFunction(_tableId, searchParams) {
if (
subtype !== BBReferenceFieldSubType.USER &&
subtype !== BBReferenceFieldSubType.USERS

View File

@ -1,12 +1,18 @@
import { io } from "socket.io-client"
import { io, Socket } from "socket.io-client"
import { SocketEvent, SocketSessionTTL } from "@budibase/shared-core"
import { APISessionID } from "../api"
const DefaultOptions = {
heartbeat: true,
}
export interface ExtendedSocket extends Socket {
onOther: (event: string, callback: (data: any) => void) => void
}
export const createWebsocket = (path, options = DefaultOptions) => {
export const createWebsocket = (
path: string,
options = DefaultOptions
): ExtendedSocket => {
if (!path) {
throw "A websocket path must be provided"
}
@ -32,10 +38,10 @@ export const createWebsocket = (path, options = DefaultOptions) => {
// Disable polling and rely on websocket only, as HTTP transport
// will only work with sticky sessions which we don't have
transports: ["websocket"],
})
}) as ExtendedSocket
// Set up a heartbeat that's half of the session TTL
let interval
let interval: NodeJS.Timeout | undefined
if (heartbeat) {
interval = setInterval(() => {
socket.emit(SocketEvent.Heartbeat)
@ -48,7 +54,7 @@ export const createWebsocket = (path, options = DefaultOptions) => {
// Helper utility to ignore events that were triggered due to API
// changes made by us
socket.onOther = (event, callback) => {
socket.onOther = (event: string, callback: (data: any) => void) => {
socket.on(event, data => {
if (data?.apiSessionId !== APISessionID) {
callback(data)

View File

@ -163,9 +163,9 @@ export async function finaliseRow(
contextRows: [enrichedRow],
})
const aiEnabled =
((await features.flags.isEnabled(FeatureFlag.BUDIBASE_AI)) &&
((await features.isEnabled(FeatureFlag.BUDIBASE_AI)) &&
(await pro.features.isBudibaseAIEnabled())) ||
((await features.flags.isEnabled(FeatureFlag.AI_CUSTOM_CONFIGS)) &&
((await features.isEnabled(FeatureFlag.AI_CUSTOM_CONFIGS)) &&
(await pro.features.isAICustomConfigsEnabled()))
if (aiEnabled) {
row = await processAIColumns(table, row, {

View File

@ -105,13 +105,13 @@ if (env.SELF_HOSTED) {
export async function getActionDefinitions(): Promise<
Record<keyof typeof AutomationActionStepId, AutomationStepDefinition>
> {
if (await features.flags.isEnabled(FeatureFlag.AUTOMATION_BRANCHING)) {
if (await features.isEnabled(FeatureFlag.AUTOMATION_BRANCHING)) {
BUILTIN_ACTION_DEFINITIONS["BRANCH"] = branch.definition
}
if (
env.SELF_HOSTED ||
(await features.flags.isEnabled(FeatureFlag.BUDIBASE_AI)) ||
(await features.flags.isEnabled(FeatureFlag.AI_CUSTOM_CONFIGS))
(await features.isEnabled(FeatureFlag.BUDIBASE_AI)) ||
(await features.isEnabled(FeatureFlag.AI_CUSTOM_CONFIGS))
) {
BUILTIN_ACTION_DEFINITIONS["OPENAI"] = openai.definition
}

View File

@ -100,10 +100,10 @@ export async function run({
try {
let response
const customConfigsEnabled =
(await features.flags.isEnabled(FeatureFlag.AI_CUSTOM_CONFIGS)) &&
(await features.isEnabled(FeatureFlag.AI_CUSTOM_CONFIGS)) &&
(await pro.features.isAICustomConfigsEnabled())
const budibaseAIEnabled =
(await features.flags.isEnabled(FeatureFlag.BUDIBASE_AI)) &&
(await features.isEnabled(FeatureFlag.BUDIBASE_AI)) &&
(await pro.features.isBudibaseAIEnabled())
let llmWrapper

View File

@ -7,7 +7,7 @@ import { fromZodError } from "zod-validation-error"
function validate(schema: AnyZodObject, property: "body" | "params") {
// Return a Koa middleware function
return async (ctx: Ctx, next: any) => {
if (!(await features.flags.isEnabled(FeatureFlag.USE_ZOD_VALIDATOR))) {
if (!(await features.isEnabled(FeatureFlag.USE_ZOD_VALIDATOR))) {
return next()
}

View File

@ -174,7 +174,9 @@ class QueryRunner {
}
// needs to an array for next step
if (!Array.isArray(rows)) {
if (rows === null) {
rows = []
} else if (!Array.isArray(rows)) {
rows = [rows]
}

View File

@ -1,3 +1,4 @@
import { FeatureFlags } from "@budibase/types"
import { DevInfo, User } from "../../../documents"
export interface GenerateAPIKeyRequest {
@ -8,5 +9,5 @@ export interface GenerateAPIKeyResponse extends DevInfo {}
export interface FetchAPIKeyResponse extends DevInfo {}
export interface GetGlobalSelfResponse extends User {
flags?: Record<string, any>
flags?: FeatureFlags
}

View File

@ -6,6 +6,12 @@ export enum FeatureFlag {
USE_ZOD_VALIDATOR = "USE_ZOD_VALIDATOR",
}
export interface TenantFeatureFlags {
[key: string]: FeatureFlag[]
export const FeatureFlagDefaults = {
[FeatureFlag.DEFAULT_VALUES]: true,
[FeatureFlag.AUTOMATION_BRANCHING]: true,
[FeatureFlag.AI_CUSTOM_CONFIGS]: true,
[FeatureFlag.BUDIBASE_AI]: true,
[FeatureFlag.USE_ZOD_VALIDATOR]: false,
}
export type FeatureFlags = typeof FeatureFlagDefaults

View File

@ -3,4 +3,5 @@ import { User } from "@budibase/types"
export interface UIUser extends User {
sessionId: string
gridMetadata?: { focusedCellId?: string }
builderMetadata?: { selectedResourceId?: string }
}

View File

@ -1,3 +1,4 @@
export * from "./integration"
export * from "./automations"
export * from "./grid"
export * from "./preview"

View File

@ -0,0 +1 @@
export type PreviewDevice = "desktop" | "tablet" | "mobile"

View File

@ -111,8 +111,7 @@ export async function getSelf(ctx: UserCtx<void, GetGlobalSelfResponse>) {
ctx.body = await groups.enrichUserRolesFromGroups(user)
// add the feature flags for this tenant
const flags = await features.flags.fetch()
ctx.body.flags = flags
ctx.body.flags = await features.flags.fetch()
addSessionAttributesToUser(ctx)
}