Merge master.

This commit is contained in:
Sam Rose 2024-10-08 15:06:57 +01:00
commit f1b04d1252
No known key found for this signature in database
24 changed files with 546 additions and 454 deletions

View File

@ -0,0 +1,300 @@
import env from "../environment"
import * as context from "../context"
import { PostHog, PostHogOptions } from "posthog-node"
import { FeatureFlag, IdentityType, UserCtx } from "@budibase/types"
import tracer from "dd-trace"
import { Duration } from "../utils"
let posthog: PostHog | undefined
export function init(opts?: PostHogOptions) {
if (
env.POSTHOG_TOKEN &&
env.POSTHOG_API_HOST &&
!env.SELF_HOSTED &&
env.POSTHOG_FEATURE_FLAGS_ENABLED
) {
console.log("initializing posthog client...")
posthog = new PostHog(env.POSTHOG_TOKEN, {
host: env.POSTHOG_API_HOST,
personalApiKey: env.POSTHOG_PERSONAL_TOKEN,
featureFlagsPollingInterval: Duration.fromMinutes(3).toMs(),
...opts,
})
} else {
console.log("posthog disabled")
}
}
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
value: boolean
}
export function parseEnvFlags(flags: string): EnvFlagEntry[] {
const split = flags.split(",").map(x => x.split(":"))
const result: EnvFlagEntry[] = []
for (const [tenantId, ...features] of split) {
for (let feature of features) {
let value = true
if (feature.startsWith("!")) {
feature = feature.slice(1)
value = false
}
result.push({ tenantId, key: feature, value })
}
}
return result
}
export class FlagSet<V extends Flag<any>, T extends { [key: string]: V }> {
// 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.
private readonly setId: string
constructor(private readonly flagSchema: T) {
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>)
}
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,
ctx?: UserCtx
): Promise<FlagValues<T>[K]> {
const flags = await this.fetch(ctx)
return flags[key]
}
async isEnabled<K extends KeysOfType<T, boolean>>(
key: K,
ctx?: UserCtx
): Promise<boolean> {
const flags = await this.fetch(ctx)
return flags[key]
}
async fetch(ctx?: UserCtx): Promise<FlagValues<T>> {
return await tracer.trace("features.fetch", async span => {
const cachedFlags = context.getFeatureFlags<FlagValues<T>>(this.setId)
if (cachedFlags) {
span?.addTags({ fromCache: true })
return cachedFlags
}
const tags: Record<string, any> = {}
const flagValues = this.defaults()
const currentTenantId = context.getTenantId()
const specificallySetFalse = new Set<string>()
for (const { tenantId, key, value } of parseEnvFlags(
env.TENANT_FEATURE_FLAGS || ""
)) {
if (!tenantId || (tenantId !== "*" && tenantId !== currentTenantId)) {
continue
}
tags[`readFromEnvironmentVars`] = true
if (value === false) {
specificallySetFalse.add(key)
}
// ignore unknown flags
if (!this.isFlagName(key)) {
continue
}
if (typeof flagValues[key] !== "boolean") {
throw new Error(`Feature: ${key} is not a boolean`)
}
// @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
tags[`flags.${key}.source`] = "environment"
}
const license = ctx?.user?.license
if (license) {
tags[`readFromLicense`] = true
for (const feature of license.features) {
if (!this.isFlagName(feature)) {
continue
}
if (
flagValues[feature] === true ||
specificallySetFalse.has(feature)
) {
// If the flag is already set to through environment variables, we
// don't want to override it back to false here.
continue
}
// @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[feature] = true
tags[`flags.${feature}.source`] = "license"
}
}
const identity = context.getIdentity()
tags[`identity.type`] = identity?.type
tags[`identity.tenantId`] = identity?.tenantId
tags[`identity._id`] = identity?._id
if (posthog && identity?.type === IdentityType.USER) {
tags[`readFromPostHog`] = true
const personProperties: Record<string, string> = {}
if (identity.tenantId) {
personProperties.tenantId = identity.tenantId
}
const posthogFlags = await posthog.getAllFlagsAndPayloads(
identity._id,
{
personProperties,
}
)
for (const [name, value] of Object.entries(posthogFlags.featureFlags)) {
if (!this.isFlagName(name)) {
// We don't want an unexpected PostHog flag to break the app, so we
// just log it and continue.
console.warn(`Unexpected 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)
tags[`flags.${name}.source`] = "posthog"
} catch (err) {
// We don't want an invalid PostHog flag to break the app, so we just
// log it and continue.
console.warn(`Error parsing posthog flag "${name}": ${value}`, err)
}
}
}
context.setFeatureFlags(this.setId, flagValues)
for (const [key, value] of Object.entries(flagValues)) {
tags[`flags.${key}.value`] = value
}
span?.addTags(tags)
return flagValues
})
}
}
// 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.
export const flags = new FlagSet({
DEFAULT_VALUES: Flag.boolean(env.isDev()),
AUTOMATION_BRANCHING: Flag.boolean(env.isDev()),
SQS: Flag.boolean(env.isDev()),
[FeatureFlag.AI_CUSTOM_CONFIGS]: Flag.boolean(env.isDev()),
[FeatureFlag.ENRICHED_RELATIONSHIPS]: Flag.boolean(env.isDev()),
})
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T
export type FeatureFlags = UnwrapPromise<ReturnType<typeof flags.fetch>>

View File

@ -1,281 +1,2 @@
import env from "../environment" export * from "./features"
import * as context from "../context" export * as testutils from "./tests/utils"
import { PostHog, PostHogOptions } from "posthog-node"
import { FeatureFlag, IdentityType, UserCtx } from "@budibase/types"
import tracer from "dd-trace"
import { Duration } from "../utils"
let posthog: PostHog | undefined
export function init(opts?: PostHogOptions) {
if (
env.POSTHOG_TOKEN &&
env.POSTHOG_API_HOST &&
!env.SELF_HOSTED &&
env.POSTHOG_FEATURE_FLAGS_ENABLED
) {
console.log("initializing posthog client...")
posthog = new PostHog(env.POSTHOG_TOKEN, {
host: env.POSTHOG_API_HOST,
personalApiKey: env.POSTHOG_PERSONAL_TOKEN,
featureFlagsPollingInterval: Duration.fromMinutes(3).toMs(),
...opts,
})
} else {
console.log("posthog disabled")
}
}
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 class FlagSet<V extends Flag<any>, T extends { [key: string]: V }> {
// 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.
private readonly setId: string
constructor(private readonly flagSchema: T) {
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>)
}
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,
ctx?: UserCtx
): Promise<FlagValues<T>[K]> {
const flags = await this.fetch(ctx)
return flags[key]
}
async isEnabled<K extends KeysOfType<T, boolean>>(
key: K,
ctx?: UserCtx
): Promise<boolean> {
const flags = await this.fetch(ctx)
return flags[key]
}
async fetch(ctx?: UserCtx): Promise<FlagValues<T>> {
return await tracer.trace("features.fetch", async span => {
const cachedFlags = context.getFeatureFlags<FlagValues<T>>(this.setId)
if (cachedFlags) {
span?.addTags({ fromCache: true })
return cachedFlags
}
const tags: Record<string, any> = {}
const flagValues = this.defaults()
const currentTenantId = context.getTenantId()
const specificallySetFalse = new Set<string>()
const split = (env.TENANT_FEATURE_FLAGS || "")
.split(",")
.map(x => x.split(":"))
for (const [tenantId, ...features] of split) {
if (!tenantId || (tenantId !== "*" && tenantId !== currentTenantId)) {
continue
}
tags[`readFromEnvironmentVars`] = true
for (let feature of features) {
let value = true
if (feature.startsWith("!")) {
feature = feature.slice(1)
value = false
specificallySetFalse.add(feature)
}
// ignore unknown flags
if (!this.isFlagName(feature)) {
continue
}
if (typeof flagValues[feature] !== "boolean") {
throw new Error(`Feature: ${feature} is not a boolean`)
}
// @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[feature as keyof FlagValues] = value
tags[`flags.${feature}.source`] = "environment"
}
}
const license = ctx?.user?.license
if (license) {
tags[`readFromLicense`] = true
for (const feature of license.features) {
if (!this.isFlagName(feature)) {
continue
}
if (
flagValues[feature] === true ||
specificallySetFalse.has(feature)
) {
// If the flag is already set to through environment variables, we
// don't want to override it back to false here.
continue
}
// @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[feature] = true
tags[`flags.${feature}.source`] = "license"
}
}
const identity = context.getIdentity()
tags[`identity.type`] = identity?.type
tags[`identity.tenantId`] = identity?.tenantId
tags[`identity._id`] = identity?._id
if (posthog && identity?.type === IdentityType.USER) {
tags[`readFromPostHog`] = true
const personProperties: Record<string, string> = {}
if (identity.tenantId) {
personProperties.tenantId = identity.tenantId
}
const posthogFlags = await posthog.getAllFlagsAndPayloads(
identity._id,
{
personProperties,
}
)
for (const [name, value] of Object.entries(posthogFlags.featureFlags)) {
if (!this.isFlagName(name)) {
// We don't want an unexpected PostHog flag to break the app, so we
// just log it and continue.
console.warn(`Unexpected 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)
tags[`flags.${name}.source`] = "posthog"
} catch (err) {
// We don't want an invalid PostHog flag to break the app, so we just
// log it and continue.
console.warn(`Error parsing posthog flag "${name}": ${value}`, err)
}
}
}
context.setFeatureFlags(this.setId, flagValues)
for (const [key, value] of Object.entries(flagValues)) {
tags[`flags.${key}.value`] = value
}
span?.addTags(tags)
return flagValues
})
}
}
// 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.
export const flags = new FlagSet({
DEFAULT_VALUES: Flag.boolean(env.isDev()),
AUTOMATION_BRANCHING: Flag.boolean(env.isDev()),
SQS: Flag.boolean(env.isDev()),
[FeatureFlag.AI_CUSTOM_CONFIGS]: Flag.boolean(env.isDev()),
[FeatureFlag.ENRICHED_RELATIONSHIPS]: Flag.boolean(env.isDev()),
})

View File

@ -0,0 +1,64 @@
import { FeatureFlags, parseEnvFlags } from ".."
import { setEnv } from "../../environment"
function getCurrentFlags(): Record<string, Record<string, boolean>> {
const result: Record<string, Record<string, boolean>> = {}
for (const { tenantId, key, value } of parseEnvFlags(
process.env.TENANT_FEATURE_FLAGS || ""
)) {
const tenantFlags = result[tenantId] || {}
// Don't allow overwriting specifically false flags, to match the beheaviour
// of FlagSet.
if (tenantFlags[key] === false) {
continue
}
tenantFlags[key] = value
result[tenantId] = tenantFlags
}
return result
}
function buildFlagString(
flags: Record<string, Record<string, boolean>>
): string {
const parts: string[] = []
for (const [tenantId, tenantFlags] of Object.entries(flags)) {
for (const [key, value] of Object.entries(tenantFlags)) {
if (value === false) {
parts.push(`${tenantId}:!${key}`)
} else {
parts.push(`${tenantId}:${key}`)
}
}
}
return parts.join(",")
}
export function setFeatureFlags(
tenantId: string,
flags: Partial<FeatureFlags>
): () => void {
const current = getCurrentFlags()
for (const [key, value] of Object.entries(flags)) {
const tenantFlags = current[tenantId] || {}
tenantFlags[key] = value
current[tenantId] = tenantFlags
}
const flagString = buildFlagString(current)
return setEnv({ TENANT_FEATURE_FLAGS: flagString })
}
export function withFeatureFlags<T>(
tenantId: string,
flags: Partial<FeatureFlags>,
f: () => T
) {
const cleanup = setFeatureFlags(tenantId, flags)
const result = f()
if (result instanceof Promise) {
return result.finally(cleanup)
} else {
cleanup()
return result
}
}

View File

@ -2,7 +2,6 @@ import { generateGlobalUserID } from "../../../db"
import { authError } from "../utils" import { authError } from "../utils"
import * as users from "../../../users" import * as users from "../../../users"
import * as context from "../../../context" import * as context from "../../../context"
import fetch from "node-fetch"
import { import {
SaveSSOUserFunction, SaveSSOUserFunction,
SSOAuthDetails, SSOAuthDetails,
@ -97,28 +96,13 @@ export async function authenticate(
return done(null, ssoUser) return done(null, ssoUser)
} }
async function getProfilePictureUrl(user: User, details: SSOAuthDetails) {
const pictureUrl = details.profile?._json.picture
if (pictureUrl) {
const response = await fetch(pictureUrl)
if (response.status === 200) {
const type = response.headers.get("content-type") as string
if (type.startsWith("image/")) {
return pictureUrl
}
}
}
}
/** /**
* @returns a user that has been sync'd with third party information * @returns a user that has been sync'd with third party information
*/ */
async function syncUser(user: User, details: SSOAuthDetails): Promise<SSOUser> { async function syncUser(user: User, details: SSOAuthDetails): Promise<SSOUser> {
let firstName let firstName
let lastName let lastName
let pictureUrl
let oauth2 let oauth2
let thirdPartyProfile
if (details.profile) { if (details.profile) {
const profile = details.profile const profile = details.profile
@ -134,12 +118,6 @@ async function syncUser(user: User, details: SSOAuthDetails): Promise<SSOUser> {
lastName = name.familyName lastName = name.familyName
} }
} }
pictureUrl = await getProfilePictureUrl(user, details)
thirdPartyProfile = {
...profile._json,
}
} }
// oauth tokens for future use // oauth tokens for future use
@ -155,8 +133,6 @@ async function syncUser(user: User, details: SSOAuthDetails): Promise<SSOUser> {
providerType: details.providerType, providerType: details.providerType,
firstName, firstName,
lastName, lastName,
thirdPartyProfile,
pictureUrl,
oauth2, oauth2,
} }
} }

View File

@ -59,10 +59,8 @@ export function ssoAccount(account: Account = cloudAccount()): SSOAccount {
accessToken: generator.string(), accessToken: generator.string(),
refreshToken: generator.string(), refreshToken: generator.string(),
}, },
pictureUrl: generator.url(),
provider: provider(), provider: provider(),
providerType: providerType(), providerType: providerType(),
thirdPartyProfile: {},
} }
} }
@ -76,9 +74,7 @@ export function verifiableSsoAccount(
accessToken: generator.string(), accessToken: generator.string(),
refreshToken: generator.string(), refreshToken: generator.string(),
}, },
pictureUrl: generator.url(),
provider: AccountSSOProvider.MICROSOFT, provider: AccountSSOProvider.MICROSOFT,
providerType: AccountSSOProviderType.MICROSOFT, providerType: AccountSSOProviderType.MICROSOFT,
thirdPartyProfile: { id: "abc123" },
} }
} }

View File

@ -25,7 +25,6 @@ export const user = (userProps?: Partial<Omit<User, "userId">>): User => {
roles: { app_test: "admin" }, roles: { app_test: "admin" },
firstName: generator.first(), firstName: generator.first(),
lastName: generator.last(), lastName: generator.last(),
pictureUrl: "http://example.com",
tenantId: tenant.id(), tenantId: tenant.id(),
...userProps, ...userProps,
} }
@ -86,9 +85,5 @@ export function ssoUser(
oauth2: opts.details?.oauth2, oauth2: opts.details?.oauth2,
provider: opts.details?.provider!, provider: opts.details?.provider!,
providerType: opts.details?.providerType!, providerType: opts.details?.providerType!,
thirdPartyProfile: {
email: base.email,
picture: base.pictureUrl,
},
} }
} }

View File

@ -127,13 +127,13 @@ export async function create(ctx: Ctx<CreateViewRequest, ViewResponse>) {
const parsedView: Omit<RequiredKeys<ViewV2>, "id" | "version"> = { const parsedView: Omit<RequiredKeys<ViewV2>, "id" | "version"> = {
name: view.name, name: view.name,
type: view.type,
tableId: view.tableId, tableId: view.tableId,
query: view.query, query: view.query,
queryUI: view.queryUI, queryUI: view.queryUI,
sort: view.sort, sort: view.sort,
schema, schema,
primaryDisplay: view.primaryDisplay, primaryDisplay: view.primaryDisplay,
uiMetadata: view.uiMetadata,
} }
const result = await sdk.views.create(tableId, parsedView) const result = await sdk.views.create(tableId, parsedView)
ctx.status = 201 ctx.status = 201
@ -163,6 +163,7 @@ export async function update(ctx: Ctx<UpdateViewRequest, ViewResponse>) {
const parsedView: RequiredKeys<ViewV2> = { const parsedView: RequiredKeys<ViewV2> = {
id: view.id, id: view.id,
name: view.name, name: view.name,
type: view.type,
version: view.version, version: view.version,
tableId: view.tableId, tableId: view.tableId,
query: view.query, query: view.query,
@ -170,7 +171,6 @@ export async function update(ctx: Ctx<UpdateViewRequest, ViewResponse>) {
sort: view.sort, sort: view.sort,
schema, schema,
primaryDisplay: view.primaryDisplay, primaryDisplay: view.primaryDisplay,
uiMetadata: view.uiMetadata,
} }
const result = await sdk.views.update(tableId, parsedView) const result = await sdk.views.update(tableId, parsedView)

View File

@ -14,12 +14,7 @@ jest.mock("../../../utilities/redis", () => ({
import { checkBuilderEndpoint } from "./utilities/TestFunctions" import { checkBuilderEndpoint } from "./utilities/TestFunctions"
import * as setup from "./utilities" import * as setup from "./utilities"
import { AppStatus } from "../../../db/utils" import { AppStatus } from "../../../db/utils"
import { import { events, utils, context, features } from "@budibase/backend-core"
events,
utils,
context,
withEnv as withCoreEnv,
} from "@budibase/backend-core"
import env from "../../../environment" import env from "../../../environment"
import { type App } from "@budibase/types" import { type App } from "@budibase/types"
import tk from "timekeeper" import tk from "timekeeper"
@ -358,9 +353,13 @@ describe("/applications", () => {
.delete(`/api/global/roles/${prodAppId}`) .delete(`/api/global/roles/${prodAppId}`)
.reply(200, {}) .reply(200, {})
await withCoreEnv({ TENANT_FEATURE_FLAGS: "*:SQS" }, async () => { await features.testutils.withFeatureFlags(
"*",
{ SQS: true },
async () => {
await config.api.application.delete(app.appId) await config.api.application.delete(app.appId)
}) }
)
}) })
}) })

View File

@ -13,8 +13,7 @@ import {
context, context,
InternalTable, InternalTable,
tenancy, tenancy,
withEnv as withCoreEnv, features,
setEnv as setCoreEnv,
} from "@budibase/backend-core" } from "@budibase/backend-core"
import { quotas } from "@budibase/pro" import { quotas } from "@budibase/pro"
import { import {
@ -40,7 +39,6 @@ import {
TableSchema, TableSchema,
JsonFieldSubType, JsonFieldSubType,
RowExportFormat, RowExportFormat,
FeatureFlag,
RelationSchemaField, RelationSchemaField,
} from "@budibase/types" } from "@budibase/types"
import { generator, mocks } from "@budibase/backend-core/tests" import { generator, mocks } from "@budibase/backend-core/tests"
@ -98,12 +96,12 @@ describe.each([
let envCleanup: (() => void) | undefined let envCleanup: (() => void) | undefined
beforeAll(async () => { beforeAll(async () => {
await withCoreEnv({ TENANT_FEATURE_FLAGS: "*:SQS" }, () => config.init()) await features.testutils.withFeatureFlags("*", { SQS: true }, () =>
if (isLucene) { config.init()
envCleanup = setCoreEnv({ TENANT_FEATURE_FLAGS: "*:!SQS" }) )
} else if (isSqs) { envCleanup = features.testutils.setFeatureFlags("*", {
envCleanup = setCoreEnv({ TENANT_FEATURE_FLAGS: "*:SQS" }) SQS: isSqs,
} })
if (dsProvider) { if (dsProvider) {
const rawDatasource = await dsProvider const rawDatasource = await dsProvider
@ -2517,15 +2515,9 @@ describe.each([
let flagCleanup: (() => void) | undefined let flagCleanup: (() => void) | undefined
beforeAll(async () => { beforeAll(async () => {
const env = { flagCleanup = features.testutils.setFeatureFlags("*", {
TENANT_FEATURE_FLAGS: `*:${FeatureFlag.ENRICHED_RELATIONSHIPS}`, ENRICHED_RELATIONSHIPS: true,
} })
if (isSqs) {
env.TENANT_FEATURE_FLAGS = `${env.TENANT_FEATURE_FLAGS},*:SQS`
} else {
env.TENANT_FEATURE_FLAGS = `${env.TENANT_FEATURE_FLAGS},*:!SQS`
}
flagCleanup = setCoreEnv(env)
const aux2Table = await config.api.table.save(saveTableRequest()) const aux2Table = await config.api.table.save(saveTableRequest())
const aux2Data = await config.api.row.save(aux2Table._id!, {}) const aux2Data = await config.api.row.save(aux2Table._id!, {})
@ -2752,9 +2744,10 @@ describe.each([
it.each(testScenarios)( it.each(testScenarios)(
"does not enrich relationships when not enabled (via %s)", "does not enrich relationships when not enabled (via %s)",
async (__, retrieveDelegate) => { async (__, retrieveDelegate) => {
await withCoreEnv( await features.testutils.withFeatureFlags(
"*",
{ {
TENANT_FEATURE_FLAGS: `*:!${FeatureFlag.ENRICHED_RELATIONSHIPS}`, ENRICHED_RELATIONSHIPS: false,
}, },
async () => { async () => {
const otherRows = _.sampleSize(auxData, 5) const otherRows = _.sampleSize(auxData, 5)

View File

@ -7,9 +7,9 @@ import {
import { import {
context, context,
db as dbCore, db as dbCore,
features,
MAX_VALID_DATE, MAX_VALID_DATE,
MIN_VALID_DATE, MIN_VALID_DATE,
setEnv as setCoreEnv,
SQLITE_DESIGN_DOC_ID, SQLITE_DESIGN_DOC_ID,
utils, utils,
withEnv as withCoreEnv, withEnv as withCoreEnv,
@ -94,16 +94,12 @@ describe.each([
} }
beforeAll(async () => { beforeAll(async () => {
await withCoreEnv({ TENANT_FEATURE_FLAGS: "*:SQS" }, () => config.init()) await features.testutils.withFeatureFlags("*", { SQS: true }, () =>
if (isLucene) { config.init()
envCleanup = setCoreEnv({ )
TENANT_FEATURE_FLAGS: "*:!SQS", envCleanup = features.testutils.setFeatureFlags("*", {
SQS: isSqs,
}) })
} else if (isSqs) {
envCleanup = setCoreEnv({
TENANT_FEATURE_FLAGS: "*:SQS",
})
}
if (config.app?.appId) { if (config.app?.appId) {
config.app = await config.api.application.update(config.app?.appId, { config.app = await config.api.application.update(config.app?.appId, {

View File

@ -2,7 +2,7 @@ import * as setup from "./utilities"
import path from "path" import path from "path"
import nock from "nock" import nock from "nock"
import { generator } from "@budibase/backend-core/tests" import { generator } from "@budibase/backend-core/tests"
import { withEnv as withCoreEnv, env as coreEnv } from "@budibase/backend-core" import { features } from "@budibase/backend-core"
interface App { interface App {
background: string background: string
@ -85,11 +85,10 @@ describe("/templates", () => {
it.each(["sqs", "lucene"])( it.each(["sqs", "lucene"])(
`should be able to create an app from a template (%s)`, `should be able to create an app from a template (%s)`,
async source => { async source => {
const env: Partial<typeof coreEnv> = { await features.testutils.withFeatureFlags(
TENANT_FEATURE_FLAGS: source === "sqs" ? "*:SQS" : "", "*",
} { SQS: source === "sqs" },
async () => {
await withCoreEnv(env, async () => {
const name = generator.guid().replaceAll("-", "") const name = generator.guid().replaceAll("-", "")
const url = `/${name}` const url = `/${name}`
@ -112,14 +111,18 @@ describe("/templates", () => {
expect(agencyProjects.name).toBe("Agency Projects") expect(agencyProjects.name).toBe("Agency Projects")
expect(users.name).toBe("Users") expect(users.name).toBe("Users")
const { rows } = await config.api.row.search(agencyProjects._id!, { const { rows } = await config.api.row.search(
agencyProjects._id!,
{
tableId: agencyProjects._id!, tableId: agencyProjects._id!,
query: {}, query: {},
}) }
)
expect(rows).toHaveLength(3) expect(rows).toHaveLength(3)
}) })
}) }
)
} }
) )
}) })

View File

@ -22,7 +22,6 @@ import {
RelationshipType, RelationshipType,
TableSchema, TableSchema,
RenameColumn, RenameColumn,
FeatureFlag,
BBReferenceFieldSubType, BBReferenceFieldSubType,
NumericCalculationFieldMetadata, NumericCalculationFieldMetadata,
ViewV2Schema, ViewV2Schema,
@ -33,13 +32,7 @@ import { generator, mocks } from "@budibase/backend-core/tests"
import { DatabaseName, getDatasource } from "../../../integrations/tests/utils" import { DatabaseName, getDatasource } from "../../../integrations/tests/utils"
import merge from "lodash/merge" import merge from "lodash/merge"
import { quotas } from "@budibase/pro" import { quotas } from "@budibase/pro"
import { import { db, roles, features } from "@budibase/backend-core"
db,
roles,
withEnv as withCoreEnv,
setEnv as setCoreEnv,
env,
} from "@budibase/backend-core"
describe.each([ describe.each([
["lucene", undefined], ["lucene", undefined],
@ -104,18 +97,13 @@ describe.each([
} }
beforeAll(async () => { beforeAll(async () => {
await withCoreEnv({ TENANT_FEATURE_FLAGS: isSqs ? "*:SQS" : "" }, () => await features.testutils.withFeatureFlags("*", { SQS: isSqs }, () =>
config.init() config.init()
) )
if (isLucene) {
envCleanup = setCoreEnv({ envCleanup = features.testutils.setFeatureFlags("*", {
TENANT_FEATURE_FLAGS: "*:!SQS", SQS: isSqs,
}) })
} else if (isSqs) {
envCleanup = setCoreEnv({
TENANT_FEATURE_FLAGS: "*:SQS",
})
}
if (dsProvider) { if (dsProvider) {
datasource = await config.createDatasource({ datasource = await config.createDatasource({
@ -157,7 +145,7 @@ describe.each([
}) })
it("can persist views with all fields", async () => { it("can persist views with all fields", async () => {
const newView: Required<Omit<CreateViewRequest, "queryUI">> = { const newView: Required<Omit<CreateViewRequest, "queryUI" | "type">> = {
name: generator.name(), name: generator.name(),
tableId: table._id!, tableId: table._id!,
primaryDisplay: "id", primaryDisplay: "id",
@ -179,9 +167,6 @@ describe.each([
visible: true, visible: true,
}, },
}, },
uiMetadata: {
foo: "bar",
},
} }
const res = await config.api.viewV2.create(newView) const res = await config.api.viewV2.create(newView)
@ -551,6 +536,7 @@ describe.each([
let view = await config.api.viewV2.create({ let view = await config.api.viewV2.create({
tableId: table._id!, tableId: table._id!,
name: generator.guid(), name: generator.guid(),
type: ViewV2Type.CALCULATION,
schema: { schema: {
sum: { sum: {
visible: true, visible: true,
@ -574,11 +560,35 @@ describe.each([
expect(sum.field).toEqual("Price") expect(sum.field).toEqual("Price")
}) })
it("cannot create a view with calculation fields unless it has the right type", async () => {
await config.api.viewV2.create(
{
tableId: table._id!,
name: generator.guid(),
schema: {
sum: {
visible: true,
calculationType: CalculationType.SUM,
field: "Price",
},
},
},
{
status: 400,
body: {
message:
"Calculation fields are not allowed in non-calculation views",
},
}
)
})
it("cannot create a calculation view with more than 5 aggregations", async () => { it("cannot create a calculation view with more than 5 aggregations", async () => {
await config.api.viewV2.create( await config.api.viewV2.create(
{ {
tableId: table._id!, tableId: table._id!,
name: generator.guid(), name: generator.guid(),
type: ViewV2Type.CALCULATION,
schema: { schema: {
sum: { sum: {
visible: true, visible: true,
@ -626,6 +636,7 @@ describe.each([
{ {
tableId: table._id!, tableId: table._id!,
name: generator.guid(), name: generator.guid(),
type: ViewV2Type.CALCULATION,
schema: { schema: {
sum: { sum: {
visible: true, visible: true,
@ -654,6 +665,7 @@ describe.each([
{ {
tableId: table._id!, tableId: table._id!,
name: generator.guid(), name: generator.guid(),
type: ViewV2Type.CALCULATION,
schema: { schema: {
count: { count: {
visible: true, visible: true,
@ -680,6 +692,7 @@ describe.each([
{ {
tableId: table._id!, tableId: table._id!,
name: generator.guid(), name: generator.guid(),
type: ViewV2Type.CALCULATION,
schema: { schema: {
count: { count: {
visible: true, visible: true,
@ -709,6 +722,7 @@ describe.each([
await config.api.viewV2.create({ await config.api.viewV2.create({
tableId: table._id!, tableId: table._id!,
name: generator.guid(), name: generator.guid(),
type: ViewV2Type.CALCULATION,
schema: { schema: {
count: { count: {
visible: true, visible: true,
@ -795,7 +809,9 @@ describe.each([
it("can update all fields", async () => { it("can update all fields", async () => {
const tableId = table._id! const tableId = table._id!
const updatedData: Required<Omit<UpdateViewRequest, "queryUI">> = { const updatedData: Required<
Omit<UpdateViewRequest, "queryUI" | "type">
> = {
version: view.version, version: view.version,
id: view.id, id: view.id,
tableId, tableId,
@ -823,9 +839,6 @@ describe.each([
readonly: true, readonly: true,
}, },
}, },
uiMetadata: {
foo: "bar",
},
} }
await config.api.viewV2.update(updatedData) await config.api.viewV2.update(updatedData)
@ -1010,6 +1023,32 @@ describe.each([
) )
}) })
it("cannot update view type after creation", async () => {
const view = await config.api.viewV2.create({
tableId: table._id!,
name: generator.guid(),
schema: {
id: { visible: true },
Price: {
visible: true,
},
},
})
await config.api.viewV2.update(
{
...view,
type: ViewV2Type.CALCULATION,
},
{
status: 400,
body: {
message: "Cannot update view type after creation",
},
}
)
})
isInternal && isInternal &&
it("updating schema will only validate modified field", async () => { it("updating schema will only validate modified field", async () => {
let view = await config.api.viewV2.create({ let view = await config.api.viewV2.create({
@ -1082,6 +1121,7 @@ describe.each([
view = await config.api.viewV2.create({ view = await config.api.viewV2.create({
tableId: table._id!, tableId: table._id!,
name: generator.guid(), name: generator.guid(),
type: ViewV2Type.CALCULATION,
schema: { schema: {
country: { country: {
visible: true, visible: true,
@ -2643,12 +2683,8 @@ describe.each([
describe("foreign relationship columns", () => { describe("foreign relationship columns", () => {
let envCleanup: () => void let envCleanup: () => void
beforeAll(() => { beforeAll(() => {
const flags = [`*:${FeatureFlag.ENRICHED_RELATIONSHIPS}`] envCleanup = features.testutils.setFeatureFlags("*", {
if (env.TENANT_FEATURE_FLAGS) { ENRICHED_RELATIONSHIPS: true,
flags.push(...env.TENANT_FEATURE_FLAGS.split(","))
}
envCleanup = setCoreEnv({
TENANT_FEATURE_FLAGS: flags.join(","),
}) })
}) })
@ -2839,6 +2875,7 @@ describe.each([
it("should be able to search by calculations", async () => { it("should be able to search by calculations", async () => {
const view = await config.api.viewV2.create({ const view = await config.api.viewV2.create({
tableId: table._id!, tableId: table._id!,
type: ViewV2Type.CALCULATION,
name: generator.guid(), name: generator.guid(),
schema: { schema: {
"Quantity Sum": { "Quantity Sum": {
@ -2873,6 +2910,7 @@ describe.each([
const view = await config.api.viewV2.create({ const view = await config.api.viewV2.create({
tableId: table._id!, tableId: table._id!,
name: generator.guid(), name: generator.guid(),
type: ViewV2Type.CALCULATION,
schema: { schema: {
quantity: { quantity: {
visible: true, visible: true,
@ -2911,6 +2949,7 @@ describe.each([
const view = await config.api.viewV2.create({ const view = await config.api.viewV2.create({
tableId: table._id!, tableId: table._id!,
name: generator.guid(), name: generator.guid(),
type: ViewV2Type.CALCULATION,
schema: { schema: {
aggregate: { aggregate: {
visible: true, visible: true,
@ -2971,6 +3010,7 @@ describe.each([
const view = await config.api.viewV2.create({ const view = await config.api.viewV2.create({
tableId: table._id!, tableId: table._id!,
name: generator.guid(), name: generator.guid(),
type: ViewV2Type.CALCULATION,
schema: { schema: {
count: { count: {
visible: true, visible: true,
@ -3005,6 +3045,7 @@ describe.each([
{ {
tableId: table._id!, tableId: table._id!,
name: generator.guid(), name: generator.guid(),
type: ViewV2Type.CALCULATION,
schema: { schema: {
count: { count: {
visible: true, visible: true,
@ -3053,6 +3094,7 @@ describe.each([
const view = await config.api.viewV2.create({ const view = await config.api.viewV2.create({
tableId: table._id!, tableId: table._id!,
name: generator.guid(), name: generator.guid(),
type: ViewV2Type.CALCULATION,
schema: { schema: {
sum: { sum: {
visible: true, visible: true,

View File

@ -2,8 +2,8 @@ import * as setup from "../../../api/routes/tests/utilities"
import { basicTable } from "../../../tests/utilities/structures" import { basicTable } from "../../../tests/utilities/structures"
import { import {
db as dbCore, db as dbCore,
features,
SQLITE_DESIGN_DOC_ID, SQLITE_DESIGN_DOC_ID,
withEnv as withCoreEnv,
} from "@budibase/backend-core" } from "@budibase/backend-core"
import { import {
LinkDocument, LinkDocument,
@ -71,11 +71,11 @@ function oldLinkDocument(): Omit<LinkDocument, "tableId"> {
} }
async function sqsDisabled(cb: () => Promise<void>) { async function sqsDisabled(cb: () => Promise<void>) {
await withCoreEnv({ TENANT_FEATURE_FLAGS: "*:!SQS" }, cb) await features.testutils.withFeatureFlags("*", { SQS: false }, cb)
} }
async function sqsEnabled(cb: () => Promise<void>) { async function sqsEnabled(cb: () => Promise<void>) {
await withCoreEnv({ TENANT_FEATURE_FLAGS: "*:SQS" }, cb) await features.testutils.withFeatureFlags("*", { SQS: true }, cb)
} }
describe("SQS migration", () => { describe("SQS migration", () => {

View File

@ -10,10 +10,7 @@ import {
import TestConfiguration from "../../../../../tests/utilities/TestConfiguration" import TestConfiguration from "../../../../../tests/utilities/TestConfiguration"
import { search } from "../../../../../sdk/app/rows/search" import { search } from "../../../../../sdk/app/rows/search"
import { generator } from "@budibase/backend-core/tests" import { generator } from "@budibase/backend-core/tests"
import { import { features } from "@budibase/backend-core"
withEnv as withCoreEnv,
setEnv as setCoreEnv,
} from "@budibase/backend-core"
import { import {
DatabaseName, DatabaseName,
getDatasource, getDatasource,
@ -41,19 +38,13 @@ describe.each([
let table: Table let table: Table
beforeAll(async () => { beforeAll(async () => {
await withCoreEnv({ TENANT_FEATURE_FLAGS: isSqs ? "*:SQS" : "" }, () => await features.testutils.withFeatureFlags("*", { SQS: isSqs }, () =>
config.init() config.init()
) )
if (isLucene) { envCleanup = features.testutils.setFeatureFlags("*", {
envCleanup = setCoreEnv({ SQS: isSqs,
TENANT_FEATURE_FLAGS: "*:!SQS",
}) })
} else if (isSqs) {
envCleanup = setCoreEnv({
TENANT_FEATURE_FLAGS: "*:SQS",
})
}
if (dsProvider) { if (dsProvider) {
datasource = await config.createDatasource({ datasource = await config.createDatasource({

View File

@ -70,6 +70,9 @@ export async function update(tableId: string, view: ViewV2): Promise<ViewV2> {
if (!existingView || !existingView.name) { if (!existingView || !existingView.name) {
throw new HTTPError(`View ${view.id} not found in table ${tableId}`, 404) throw new HTTPError(`View ${view.id} not found in table ${tableId}`, 404)
} }
if (isV2(existingView) && existingView.type !== view.type) {
throw new HTTPError(`Cannot update view type after creation`, 400)
}
delete views[existingView.name] delete views[existingView.name]
views[view.name] = view views[view.name] = view

View File

@ -132,6 +132,13 @@ async function guardViewSchema(
if (helpers.views.isCalculationView(view)) { if (helpers.views.isCalculationView(view)) {
await guardCalculationViewSchema(table, view) await guardCalculationViewSchema(table, view)
} else {
if (helpers.views.hasCalculationFields(view)) {
throw new HTTPError(
"Calculation fields are not allowed in non-calculation views",
400
)
}
} }
await checkReadonlyFields(table, view) await checkReadonlyFields(table, view)

View File

@ -59,6 +59,10 @@ export async function update(tableId: string, view: ViewV2): Promise<ViewV2> {
throw new HTTPError(`View ${view.id} not found in table ${tableId}`, 404) throw new HTTPError(`View ${view.id} not found in table ${tableId}`, 404)
} }
if (isV2(existingView) && existingView.type !== view.type) {
throw new HTTPError(`Cannot update view type after creation`, 400)
}
delete table.views[existingView.name] delete table.views[existingView.name]
table.views[view.name] = view table.views[view.name] = view
await db.put(table) await db.put(table)

View File

@ -8,7 +8,7 @@ import {
} from "@budibase/types" } from "@budibase/types"
import { outputProcessing } from ".." import { outputProcessing } from ".."
import { generator, structures } from "@budibase/backend-core/tests" import { generator, structures } from "@budibase/backend-core/tests"
import { setEnv as setCoreEnv } from "@budibase/backend-core" import { features } from "@budibase/backend-core"
import * as bbReferenceProcessor from "../bbReferenceProcessor" import * as bbReferenceProcessor from "../bbReferenceProcessor"
import TestConfiguration from "../../../tests/utilities/TestConfiguration" import TestConfiguration from "../../../tests/utilities/TestConfiguration"
@ -21,7 +21,7 @@ jest.mock("../bbReferenceProcessor", (): typeof bbReferenceProcessor => ({
describe("rowProcessor - outputProcessing", () => { describe("rowProcessor - outputProcessing", () => {
const config = new TestConfiguration() const config = new TestConfiguration()
let cleanupEnv: () => void = () => {} let cleanupFlags: () => void = () => {}
beforeAll(async () => { beforeAll(async () => {
await config.init() await config.init()
@ -33,11 +33,11 @@ describe("rowProcessor - outputProcessing", () => {
beforeEach(() => { beforeEach(() => {
jest.resetAllMocks() jest.resetAllMocks()
cleanupEnv = setCoreEnv({ TENANT_FEATURE_FLAGS: "*SQS" }) cleanupFlags = features.testutils.setFeatureFlags("*", { SQS: true })
}) })
afterEach(() => { afterEach(() => {
cleanupEnv() cleanupFlags()
}) })
const processOutputBBReferenceMock = const processOutputBBReferenceMock =

View File

@ -3,6 +3,7 @@ import {
ViewCalculationFieldMetadata, ViewCalculationFieldMetadata,
ViewFieldMetadata, ViewFieldMetadata,
ViewV2, ViewV2,
ViewV2Type,
} from "@budibase/types" } from "@budibase/types"
import { pickBy } from "lodash" import { pickBy } from "lodash"
@ -21,6 +22,10 @@ export function isBasicViewField(
type UnsavedViewV2 = Omit<ViewV2, "id" | "version"> type UnsavedViewV2 = Omit<ViewV2, "id" | "version">
export function isCalculationView(view: UnsavedViewV2) { export function isCalculationView(view: UnsavedViewV2) {
return view.type === ViewV2Type.CALCULATION
}
export function hasCalculationFields(view: UnsavedViewV2) {
return Object.values(view.schema || {}).some(isCalculationField) return Object.values(view.schema || {}).some(isCalculationField)
} }

View File

@ -12,7 +12,6 @@ export interface CreateAccountRequest {
name?: string name?: string
password: string password: string
provider?: AccountSSOProvider provider?: AccountSSOProvider
thirdPartyProfile: object
} }
export interface SearchAccountsRequest { export interface SearchAccountsRequest {

View File

@ -98,8 +98,6 @@ export interface AccountSSO {
provider: AccountSSOProvider provider: AccountSSOProvider
providerType: AccountSSOProviderType providerType: AccountSSOProviderType
oauth2?: OAuthTokens oauth2?: OAuthTokens
pictureUrl?: string
thirdPartyProfile: any // TODO: define what the google profile looks like
} }
export type SSOAccount = (Account | CloudAccount) & AccountSSO export type SSOAccount = (Account | CloudAccount) & AccountSSO

View File

@ -79,10 +79,15 @@ export enum CalculationType {
MAX = "max", MAX = "max",
} }
export enum ViewV2Type {
CALCULATION = "calculation",
}
export interface ViewV2 { export interface ViewV2 {
version: 2 version: 2
id: string id: string
name: string name: string
type?: ViewV2Type
primaryDisplay?: string primaryDisplay?: string
tableId: string tableId: string
query?: LegacyFilter[] | SearchFilters query?: LegacyFilter[] | SearchFilters
@ -94,7 +99,6 @@ export interface ViewV2 {
type?: SortType type?: SortType
} }
schema?: ViewV2Schema schema?: ViewV2Schema
uiMetadata?: Record<string, any>
} }
export type ViewV2Schema = Record<string, ViewFieldMetadata> export type ViewV2Schema = Record<string, ViewFieldMetadata>

View File

@ -21,7 +21,6 @@ export interface UserSSO {
provider: string // the individual provider e.g. Okta, Auth0, Google provider: string // the individual provider e.g. Okta, Auth0, Google
providerType: SSOProviderType providerType: SSOProviderType
oauth2?: OAuth2 oauth2?: OAuth2
thirdPartyProfile?: SSOProfileJson
profile?: { profile?: {
displayName?: string displayName?: string
name?: { name?: {
@ -45,7 +44,6 @@ export interface User extends Document {
userId?: string userId?: string
firstName?: string firstName?: string
lastName?: string lastName?: string
pictureUrl?: string
forceResetPassword?: boolean forceResetPassword?: boolean
roles: UserRoles roles: UserRoles
builder?: { builder?: {

View File

@ -1,5 +1,5 @@
import { mocks, structures } from "@budibase/backend-core/tests" import { mocks, structures } from "@budibase/backend-core/tests"
import { context, events, setEnv as setCoreEnv } from "@budibase/backend-core" import { context, events, features } from "@budibase/backend-core"
import { Event, IdentityType } from "@budibase/types" import { Event, IdentityType } from "@budibase/types"
import { TestConfiguration } from "../../../../tests" import { TestConfiguration } from "../../../../tests"
@ -17,11 +17,9 @@ describe.each(["lucene", "sql"])("/api/global/auditlogs (%s)", method => {
let envCleanup: (() => void) | undefined let envCleanup: (() => void) | undefined
beforeAll(async () => { beforeAll(async () => {
if (method === "lucene") { envCleanup = features.testutils.setFeatureFlags("*", {
envCleanup = setCoreEnv({ TENANT_FEATURE_FLAGS: "*:!SQS" }) SQS: method === "sql",
} else if (method === "sql") { })
envCleanup = setCoreEnv({ TENANT_FEATURE_FLAGS: "*:SQS" })
}
await config.beforeAll() await config.beforeAll()
}) })