Merge remote-tracking branch 'origin/master' into automation-branching-ux-updates

This commit is contained in:
Dean 2024-11-06 09:33:39 +00:00
commit 5f31166bf3
21 changed files with 86 additions and 92 deletions

View File

@ -13,7 +13,6 @@ on:
options: options:
- patch - patch
- minor - minor
- major
required: true required: true
jobs: jobs:

View File

@ -1,6 +1,6 @@
{ {
"$schema": "node_modules/lerna/schemas/lerna-schema.json", "$schema": "node_modules/lerna/schemas/lerna-schema.json",
"version": "3.0.3", "version": "3.1.0",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*", "packages/*",

View File

@ -272,7 +272,6 @@ export const flags = new FlagSet({
[FeatureFlag.SQS]: Flag.boolean(true), [FeatureFlag.SQS]: Flag.boolean(true),
[FeatureFlag.AI_CUSTOM_CONFIGS]: Flag.boolean(env.isDev()), [FeatureFlag.AI_CUSTOM_CONFIGS]: Flag.boolean(env.isDev()),
[FeatureFlag.ENRICHED_RELATIONSHIPS]: Flag.boolean(env.isDev()), [FeatureFlag.ENRICHED_RELATIONSHIPS]: Flag.boolean(env.isDev()),
[FeatureFlag.TABLES_DEFAULT_ADMIN]: Flag.boolean(env.isDev()),
[FeatureFlag.BUDIBASE_AI]: Flag.boolean(env.isDev()), [FeatureFlag.BUDIBASE_AI]: Flag.boolean(env.isDev()),
}) })

View File

@ -21,7 +21,7 @@
let editing = false let editing = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
$: blockRefs = $selectedAutomation.blockRefs || {} $: blockRefs = $selectedAutomation?.blockRefs || {}
$: stepNames = automation?.definition.stepNames $: stepNames = automation?.definition.stepNames
$: allSteps = automation?.definition.steps || [] $: allSteps = automation?.definition.steps || []
$: automationName = itemName || stepNames?.[block.id] || block?.name || "" $: automationName = itemName || stepNames?.[block.id] || block?.name || ""

View File

@ -645,7 +645,7 @@
<!-- Custom Layouts --> <!-- Custom Layouts -->
{#if stepLayouts[block.stepId]} {#if stepLayouts[block.stepId]}
{#each Object.keys(stepLayouts[block.stepId] || {}) as key} {#each Object.keys(stepLayouts[block.stepId] || {}) as key}
{#if canShowField(key, stepLayouts[block.stepId].schema)} {#if canShowField(stepLayouts[block.stepId].schema)}
{#each stepLayouts[block.stepId][key].content as config} {#each stepLayouts[block.stepId][key].content as config}
{#if config.title} {#if config.title}
<PropField label={config.title} labelTooltip={config.tooltip}> <PropField label={config.title} labelTooltip={config.tooltip}>
@ -670,7 +670,7 @@
{:else} {:else}
<!-- Default Schema Property Layout --> <!-- Default Schema Property Layout -->
{#each schemaProperties as [key, value]} {#each schemaProperties as [key, value]}
{#if canShowField(key, value)} {#if canShowField(value)}
{@const label = getFieldLabel(key, value)} {@const label = getFieldLabel(key, value)}
<div class:block-field={shouldRenderField(value)}> <div class:block-field={shouldRenderField(value)}>
{#if key !== "fields" && value.type !== "boolean" && shouldRenderField(value)} {#if key !== "fields" && value.type !== "boolean" && shouldRenderField(value)}
@ -693,7 +693,7 @@
</div> </div>
{/if} {/if}
<div> <div>
{#if value.type === "string" && value.enum && canShowField(key, value)} {#if value.type === "string" && value.enum && canShowField(value)}
<Select <Select
on:change={e => onChange({ [key]: e.detail })} on:change={e => onChange({ [key]: e.detail })}
value={inputData[key]} value={inputData[key]}

View File

@ -65,7 +65,7 @@
let tableOptions let tableOptions
let errorChecker = new RelationshipErrorChecker( let errorChecker = new RelationshipErrorChecker(
invalidThroughTable, invalidThroughTable,
relationshipExists manyToManyRelationshipExistsFn
) )
let errors = {} let errors = {}
let fromPrimary, fromForeign, fromColumn, toColumn let fromPrimary, fromForeign, fromColumn, toColumn
@ -125,7 +125,7 @@
} }
return false return false
} }
function relationshipExists() { function manyToManyRelationshipExistsFn() {
if ( if (
originalFromTable && originalFromTable &&
originalToTable && originalToTable &&
@ -141,16 +141,14 @@
datasource.entities[getTable(toId).name].schema datasource.entities[getTable(toId).name].schema
).filter(value => value.through) ).filter(value => value.through)
const matchAgainstUserInput = (fromTableId, toTableId) => const matchAgainstUserInput = link =>
(fromTableId === fromId && toTableId === toId) || (link.throughTo === throughToKey &&
(fromTableId === toId && toTableId === fromId) link.throughFrom === throughFromKey) ||
(link.throughTo === throughFromKey && link.throughFrom === throughToKey)
return !!fromThroughLinks.find(from => const allLinks = [...fromThroughLinks, ...toThroughLinks]
toThroughLinks.find( return !!allLinks.find(
to => link => link.through === throughId && matchAgainstUserInput(link)
from.through === to.through &&
matchAgainstUserInput(from.tableId, to.tableId)
)
) )
} }
@ -181,16 +179,15 @@
relationshipType: errorChecker.relationshipTypeSet(relationshipType), relationshipType: errorChecker.relationshipTypeSet(relationshipType),
fromTable: fromTable:
errorChecker.tableSet(fromTable) || errorChecker.tableSet(fromTable) ||
errorChecker.doesRelationshipExists() ||
errorChecker.differentTables(fromId, toId, throughId), errorChecker.differentTables(fromId, toId, throughId),
toTable: toTable:
errorChecker.tableSet(toTable) || errorChecker.tableSet(toTable) ||
errorChecker.doesRelationshipExists() ||
errorChecker.differentTables(toId, fromId, throughId), errorChecker.differentTables(toId, fromId, throughId),
throughTable: throughTable:
errorChecker.throughTableSet(throughTable) || errorChecker.throughTableSet(throughTable) ||
errorChecker.throughIsNullable() || errorChecker.throughIsNullable() ||
errorChecker.differentTables(throughId, fromId, toId), errorChecker.differentTables(throughId, fromId, toId) ||
errorChecker.doesRelationshipExists(),
throughFromKey: throughFromKey:
errorChecker.manyForeignKeySet(throughFromKey) || errorChecker.manyForeignKeySet(throughFromKey) ||
errorChecker.manyTypeMismatch( errorChecker.manyTypeMismatch(
@ -198,7 +195,8 @@
throughTable, throughTable,
fromTable.primary[0], fromTable.primary[0],
throughToKey throughToKey
), ) ||
errorChecker.differentColumns(throughFromKey, throughToKey),
throughToKey: throughToKey:
errorChecker.manyForeignKeySet(throughToKey) || errorChecker.manyForeignKeySet(throughToKey) ||
errorChecker.manyTypeMismatch( errorChecker.manyTypeMismatch(
@ -372,6 +370,16 @@
fromColumn = selectedFromTable.name fromColumn = selectedFromTable.name
fromPrimary = selectedFromTable?.primary[0] || null fromPrimary = selectedFromTable?.primary[0] || null
} }
if (relationshipType === RelationshipType.MANY_TO_MANY) {
relationshipPart1 = PrettyRelationshipDefinitions.MANY
relationshipPart2 = PrettyRelationshipDefinitions.MANY
} else if (relationshipType === RelationshipType.MANY_TO_ONE) {
relationshipPart1 = PrettyRelationshipDefinitions.ONE
relationshipPart2 = PrettyRelationshipDefinitions.MANY
} else {
relationshipPart1 = PrettyRelationshipDefinitions.MANY
relationshipPart2 = PrettyRelationshipDefinitions.ONE
}
}) })
</script> </script>

View File

@ -3,6 +3,7 @@ import { RelationshipType } from "@budibase/types"
const typeMismatch = "Column type of the foreign key must match the primary key" const typeMismatch = "Column type of the foreign key must match the primary key"
const columnBeingUsed = "Column name cannot be an existing column" const columnBeingUsed = "Column name cannot be an existing column"
const mustBeDifferentTables = "From/to/through tables must be different" const mustBeDifferentTables = "From/to/through tables must be different"
const mustBeDifferentColumns = "Foreign keys must be different"
const primaryKeyNotSet = "Please pick the primary key" const primaryKeyNotSet = "Please pick the primary key"
const throughNotNullable = const throughNotNullable =
"Ensure non-key columns are nullable or auto-generated" "Ensure non-key columns are nullable or auto-generated"
@ -30,9 +31,9 @@ function typeMismatchCheck(fromTable, toTable, primary, foreign) {
} }
export class RelationshipErrorChecker { export class RelationshipErrorChecker {
constructor(invalidThroughTableFn, relationshipExistsFn) { constructor(invalidThroughTableFn, manyToManyRelationshipExistsFn) {
this.invalidThroughTable = invalidThroughTableFn this.invalidThroughTable = invalidThroughTableFn
this.relationshipExists = relationshipExistsFn this.manyToManyRelationshipExists = manyToManyRelationshipExistsFn
} }
setType(type) { setType(type) {
@ -72,7 +73,7 @@ export class RelationshipErrorChecker {
} }
doesRelationshipExists() { doesRelationshipExists() {
return this.isMany() && this.relationshipExists() return this.isMany() && this.manyToManyRelationshipExists()
? relationshipAlreadyExists ? relationshipAlreadyExists
: null : null
} }
@ -83,6 +84,11 @@ export class RelationshipErrorChecker {
return error ? mustBeDifferentTables : null return error ? mustBeDifferentTables : null
} }
differentColumns(columnA, columnB) {
const error = columnA && columnB && columnA === columnB
return error ? mustBeDifferentColumns : null
}
columnBeingUsed(table, column, ogName) { columnBeingUsed(table, column, ogName) {
return isColumnNameBeingUsed(table, column, ogName) ? columnBeingUsed : null return isColumnNameBeingUsed(table, column, ogName) ? columnBeingUsed : null
} }

View File

@ -1,8 +1,8 @@
<script> <script>
import { redirect } from "@roxi/routify" import { redirect } from "@roxi/routify"
import { licensing, featureFlags } from "stores/portal" import { featureFlags } from "stores/portal"
if ($featureFlags.AI_CUSTOM_CONFIGS && $licensing.customAIConfigsEnabled) { if ($featureFlags.AI_CUSTOM_CONFIGS) {
$redirect("./ai") $redirect("./ai")
} else { } else {
$redirect("./auth") $redirect("./auth")

View File

@ -402,7 +402,6 @@ const automationActions = store => ({
traverse: (blockRefs, automation) => { traverse: (blockRefs, automation) => {
let blocks = [] let blocks = []
if (!automation || !blockRefs) { if (!automation || !blockRefs) {
console.error("Need a valid automation")
return return
} }
if (automation.definition?.trigger) { if (automation.definition?.trigger) {

@ -1 +1 @@
Subproject commit 2ab8536b6005576684810d774f1ac22239218546 Subproject commit 04bee88597edb1edb88ed299d0597b587f0362ec

View File

@ -56,6 +56,7 @@ jest.mock("@budibase/pro", () => ({
ai: { ai: {
LargeLanguageModel: { LargeLanguageModel: {
forCurrentTenant: async () => ({ forCurrentTenant: async () => ({
initialised: true,
run: jest.fn(() => `Mock LLM Response`), run: jest.fn(() => `Mock LLM Response`),
buildPromptFromAIOperation: jest.fn(), buildPromptFromAIOperation: jest.fn(),
}), }),

View File

@ -54,6 +54,7 @@ jest.mock("@budibase/pro", () => ({
ai: { ai: {
LargeLanguageModel: { LargeLanguageModel: {
forCurrentTenant: async () => ({ forCurrentTenant: async () => ({
initialised: true,
run: jest.fn(() => `Mock LLM Response`), run: jest.fn(() => `Mock LLM Response`),
buildPromptFromAIOperation: jest.fn(), buildPromptFromAIOperation: jest.fn(),
}), }),

View File

@ -106,21 +106,15 @@ export async function run({
(await features.flags.isEnabled(FeatureFlag.BUDIBASE_AI)) && (await features.flags.isEnabled(FeatureFlag.BUDIBASE_AI)) &&
(await pro.features.isBudibaseAIEnabled()) (await pro.features.isBudibaseAIEnabled())
let llm
if (budibaseAIEnabled || customConfigsEnabled) { if (budibaseAIEnabled || customConfigsEnabled) {
const llm = await pro.ai.LargeLanguageModel.forCurrentTenant(inputs.model) llm = await pro.ai.LargeLanguageModel.forCurrentTenant(inputs.model)
response = await llm.run(inputs.prompt)
} else {
// fallback to the default that uses the environment variable for backwards compat
if (!env.OPENAI_API_KEY) {
return {
success: false,
response:
"OpenAI API Key not configured - please add the OPENAI_API_KEY environment variable.",
}
}
response = await legacyOpenAIPrompt(inputs)
} }
response = llm?.initialised
? await llm.run(inputs.prompt)
: await legacyOpenAIPrompt(inputs)
return { return {
response, response,
success: true, success: true,

View File

@ -1,9 +1,6 @@
import { getConfig, runStep, afterAll as _afterAll } from "./utilities" import { getConfig, runStep, afterAll as _afterAll } from "./utilities"
import { OpenAI } from "openai" import { OpenAI } from "openai"
import { import { setEnv as setCoreEnv } from "@budibase/backend-core"
withEnv as withCoreEnv,
setEnv as setCoreEnv,
} from "@budibase/backend-core"
import * as pro from "@budibase/pro" import * as pro from "@budibase/pro"
jest.mock("openai", () => ({ jest.mock("openai", () => ({
@ -28,6 +25,7 @@ jest.mock("@budibase/pro", () => ({
ai: { ai: {
LargeLanguageModel: { LargeLanguageModel: {
forCurrentTenant: jest.fn().mockImplementation(() => ({ forCurrentTenant: jest.fn().mockImplementation(() => ({
initialised: true,
init: jest.fn(), init: jest.fn(),
run: jest.fn(), run: jest.fn(),
})), })),
@ -63,16 +61,6 @@ describe("test the openai action", () => {
afterAll(_afterAll) afterAll(_afterAll)
it("should present the correct error message when the OPENAI_API_KEY variable isn't set", async () => {
await withCoreEnv({ OPENAI_API_KEY: "" }, async () => {
let res = await runStep("OPENAI", { prompt: OPENAI_PROMPT })
expect(res.response).toEqual(
"OpenAI API Key not configured - please add the OPENAI_API_KEY environment variable."
)
expect(res.success).toBeFalsy()
})
})
it("should be able to receive a response from ChatGPT given a prompt", async () => { it("should be able to receive a response from ChatGPT given a prompt", async () => {
const res = await runStep("OPENAI", { prompt: OPENAI_PROMPT }) const res = await runStep("OPENAI", { prompt: OPENAI_PROMPT })
expect(res.response).toEqual("This is a test") expect(res.response).toEqual("This is a test")

View File

@ -1,10 +1,10 @@
import { FeatureFlag, Row, Table } from "@budibase/types" import { Row, Table } from "@budibase/types"
import * as external from "./external" import * as external from "./external"
import * as internal from "./internal" import * as internal from "./internal"
import { isExternal } from "./utils" import { isExternal } from "./utils"
import { setPermissions } from "../permissions" import { setPermissions } from "../permissions"
import { features, roles } from "@budibase/backend-core" import { roles } from "@budibase/backend-core"
export async function create( export async function create(
table: Omit<Table, "_id" | "_rev">, table: Omit<Table, "_id" | "_rev">,
@ -18,16 +18,10 @@ export async function create(
createdTable = await internal.create(table, rows, userId) createdTable = await internal.create(table, rows, userId)
} }
const setExplicitPermission = await features.flags.isEnabled(
FeatureFlag.TABLES_DEFAULT_ADMIN
)
if (setExplicitPermission) {
await setPermissions(createdTable._id!, { await setPermissions(createdTable._id!, {
writeRole: roles.BUILTIN_ROLE_IDS.ADMIN, writeRole: roles.BUILTIN_ROLE_IDS.ADMIN,
readRole: roles.BUILTIN_ROLE_IDS.ADMIN, readRole: roles.BUILTIN_ROLE_IDS.ADMIN,
}) })
}
return createdTable return createdTable
} }

View File

@ -2,7 +2,6 @@ import {
BBReferenceFieldSubType, BBReferenceFieldSubType,
CalculationType, CalculationType,
canGroupBy, canGroupBy,
FeatureFlag,
FieldType, FieldType,
isNumeric, isNumeric,
PermissionLevel, PermissionLevel,
@ -16,7 +15,7 @@ import {
ViewV2ColumnEnriched, ViewV2ColumnEnriched,
ViewV2Enriched, ViewV2Enriched,
} from "@budibase/types" } from "@budibase/types"
import { context, docIds, features, HTTPError } from "@budibase/backend-core" import { context, docIds, HTTPError } from "@budibase/backend-core"
import { import {
helpers, helpers,
PROTECTED_EXTERNAL_COLUMNS, PROTECTED_EXTERNAL_COLUMNS,
@ -287,17 +286,12 @@ export async function create(
await guardViewSchema(tableId, viewRequest) await guardViewSchema(tableId, viewRequest)
const view = await pickApi(tableId).create(tableId, viewRequest) const view = await pickApi(tableId).create(tableId, viewRequest)
const setExplicitPermission = await features.flags.isEnabled(
FeatureFlag.TABLES_DEFAULT_ADMIN
)
if (setExplicitPermission) {
// Set permissions to be the same as the table // Set permissions to be the same as the table
const tablePerms = await sdk.permissions.getResourcePerms(tableId) const tablePerms = await sdk.permissions.getResourcePerms(tableId)
await sdk.permissions.setPermissions(view.id, { await sdk.permissions.setPermissions(view.id, {
writeRole: tablePerms[PermissionLevel.WRITE].role, writeRole: tablePerms[PermissionLevel.WRITE].role,
readRole: tablePerms[PermissionLevel.READ].role, readRole: tablePerms[PermissionLevel.READ].role,
}) })
}
return view return view
} }

View File

@ -400,7 +400,7 @@ class Orchestrator {
) )
} catch (err) { } catch (err) {
this.updateContextAndOutput( this.updateContextAndOutput(
pathStepIdx, pathStepIdx + 1,
steps[stepToLoopIndex], steps[stepToLoopIndex],
{}, {},
{ {
@ -420,7 +420,7 @@ class Orchestrator {
(loopStep.inputs.iterations && loopStepIndex === maxIterations) (loopStep.inputs.iterations && loopStepIndex === maxIterations)
) { ) {
this.updateContextAndOutput( this.updateContextAndOutput(
pathStepIdx, pathStepIdx + 1,
steps[stepToLoopIndex], steps[stepToLoopIndex],
{ {
items: this.loopStepOutputs, items: this.loopStepOutputs,
@ -447,7 +447,7 @@ class Orchestrator {
if (isFailure) { if (isFailure) {
this.updateContextAndOutput( this.updateContextAndOutput(
pathStepIdx, pathStepIdx + 1,
steps[stepToLoopIndex], steps[stepToLoopIndex],
{ {
items: this.loopStepOutputs, items: this.loopStepOutputs,

View File

@ -18,6 +18,7 @@ jest.mock("@budibase/pro", () => ({
ai: { ai: {
LargeLanguageModel: { LargeLanguageModel: {
forCurrentTenant: async () => ({ forCurrentTenant: async () => ({
initialised: true,
run: jest.fn(() => "response from LLM"), run: jest.fn(() => "response from LLM"),
buildPromptFromAIOperation: buildPromptMock, buildPromptFromAIOperation: buildPromptMock,
}), }),

View File

@ -108,7 +108,7 @@ export async function processAIColumns<T extends Row | Row[]>(
span?.addTags({ table_id: table._id, numRows }) span?.addTags({ table_id: table._id, numRows })
const rows = Array.isArray(inputRows) ? inputRows : [inputRows] const rows = Array.isArray(inputRows) ? inputRows : [inputRows]
const llm = await pro.ai.LargeLanguageModel.forCurrentTenant("gpt-4o-mini") const llm = await pro.ai.LargeLanguageModel.forCurrentTenant("gpt-4o-mini")
if (rows) { if (rows && llm.initialised) {
// Ensure we have snippet context // Ensure we have snippet context
await context.ensureSnippetContext() await context.ensureSnippetContext()

View File

@ -6,7 +6,6 @@ export enum FeatureFlag {
AI_CUSTOM_CONFIGS = "AI_CUSTOM_CONFIGS", AI_CUSTOM_CONFIGS = "AI_CUSTOM_CONFIGS",
DEFAULT_VALUES = "DEFAULT_VALUES", DEFAULT_VALUES = "DEFAULT_VALUES",
ENRICHED_RELATIONSHIPS = "ENRICHED_RELATIONSHIPS", ENRICHED_RELATIONSHIPS = "ENRICHED_RELATIONSHIPS",
TABLES_DEFAULT_ADMIN = "TABLES_DEFAULT_ADMIN",
BUDIBASE_AI = "BUDIBASE_AI", BUDIBASE_AI = "BUDIBASE_AI",
} }

View File

@ -12,27 +12,29 @@ import {
} from "@budibase/backend-core" } from "@budibase/backend-core"
import { checkAnyUserExists } from "../../../utilities/users" import { checkAnyUserExists } from "../../../utilities/users"
import { import {
AIConfig,
AIInnerConfig,
Config, Config,
ConfigType, ConfigType,
Ctx, Ctx,
GetPublicOIDCConfigResponse, GetPublicOIDCConfigResponse,
GetPublicSettingsResponse, GetPublicSettingsResponse,
GoogleInnerConfig, GoogleInnerConfig,
isAIConfig,
isGoogleConfig, isGoogleConfig,
isOIDCConfig, isOIDCConfig,
isSettingsConfig, isSettingsConfig,
isSMTPConfig, isSMTPConfig,
OIDCConfigs, OIDCConfigs,
OIDCLogosConfig,
PASSWORD_REPLACEMENT,
QuotaUsageType,
SettingsBrandingConfig, SettingsBrandingConfig,
SettingsInnerConfig, SettingsInnerConfig,
SSOConfig, SSOConfig,
SSOConfigType, SSOConfigType,
StaticQuotaName,
UserCtx, UserCtx,
OIDCLogosConfig,
AIConfig,
PASSWORD_REPLACEMENT,
isAIConfig,
AIInnerConfig,
} from "@budibase/types" } from "@budibase/types"
import * as pro from "@budibase/pro" import * as pro from "@budibase/pro"
@ -83,6 +85,12 @@ const getEventFns = async (config: Config, existing?: Config) => {
fns.push(events.email.SMTPUpdated) fns.push(events.email.SMTPUpdated)
} else if (isAIConfig(config)) { } else if (isAIConfig(config)) {
fns.push(() => events.ai.AIConfigUpdated) fns.push(() => events.ai.AIConfigUpdated)
if (
Object.keys(existing.config).length > Object.keys(config.config).length
) {
fns.push(() => pro.quotas.removeCustomAIConfig())
}
fns.push(() => pro.quotas.addCustomAIConfig())
} else if (isGoogleConfig(config)) { } else if (isGoogleConfig(config)) {
fns.push(() => events.auth.SSOUpdated(ConfigType.GOOGLE)) fns.push(() => events.auth.SSOUpdated(ConfigType.GOOGLE))
if (!existing.config.activated && config.config.activated) { if (!existing.config.activated && config.config.activated) {
@ -248,7 +256,6 @@ export async function save(ctx: UserCtx<Config>) {
if (existingConfig) { if (existingConfig) {
await verifyAIConfig(config, existingConfig) await verifyAIConfig(config, existingConfig)
} }
await pro.quotas.addCustomAIConfig()
break break
} }
} catch (err: any) { } catch (err: any) {
@ -518,7 +525,11 @@ export async function destroy(ctx: UserCtx) {
await db.remove(id, rev) await db.remove(id, rev)
await cache.destroy(cache.CacheKey.CHECKLIST) await cache.destroy(cache.CacheKey.CHECKLIST)
if (id === configs.generateConfigID(ConfigType.AI)) { if (id === configs.generateConfigID(ConfigType.AI)) {
await pro.quotas.removeCustomAIConfig() await pro.quotas.set(
StaticQuotaName.AI_CUSTOM_CONFIGS,
QuotaUsageType.STATIC,
0
)
} }
ctx.body = { message: "Config deleted successfully" } ctx.body = { message: "Config deleted successfully" }
} catch (err: any) { } catch (err: any) {