From a559a2eabc8148c3d03f7f5a116718b0853fea8e Mon Sep 17 00:00:00 2001 From: dswbx Date: Wed, 3 Sep 2025 17:17:07 +0200 Subject: [PATCH 01/66] cli: externalize all direct dependencies to prevent being bundled --- app/build.cli.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/app/build.cli.ts b/app/build.cli.ts index 999a6a1..098ef97 100644 --- a/app/build.cli.ts +++ b/app/build.cli.ts @@ -3,20 +3,25 @@ import c from "picocolors"; import { formatNumber } from "bknd/utils"; import * as esbuild from "esbuild"; +const deps = Object.keys(pkg.dependencies); +const external = ["jsonv-ts/*", ...deps]; + if (process.env.DEBUG) { - await esbuild.build({ + const result = await esbuild.build({ entryPoints: ["./src/cli/index.ts"], outdir: "./dist/cli", platform: "node", - minify: false, + minify: true, format: "esm", + metafile: true, bundle: true, - external: ["jsonv-ts", "jsonv-ts/*"], + external, define: { __isDev: "0", __version: JSON.stringify(pkg.version), }, }); + await Bun.write("./dist/cli/metafile-esm.json", JSON.stringify(result.metafile, null, 2)); process.exit(0); } @@ -26,7 +31,7 @@ const result = await Bun.build({ outdir: "./dist/cli", env: "PUBLIC_*", minify: true, - external: ["jsonv-ts", "jsonv-ts/*"], + external, define: { __isDev: "0", __version: JSON.stringify(pkg.version), From e3888537f9394cbb9453ee472115d3e6f0c88242 Mon Sep 17 00:00:00 2001 From: dswbx Date: Thu, 4 Sep 2025 09:21:35 +0200 Subject: [PATCH 02/66] init code-first mode by splitting module manager --- app/src/App.ts | 51 ++- app/src/adapter/index.ts | 34 +- app/src/core/types.ts | 2 + app/src/data/api/DataController.ts | 1 + app/src/index.ts | 2 +- app/src/modules/index.ts | 2 +- .../DbModuleManager.ts} | 322 ++-------------- app/src/modules/manager/ModuleManager.ts | 354 ++++++++++++++++++ app/src/modules/mcp/$object.ts | 7 +- app/src/modules/mcp/$record.ts | 15 +- app/src/modules/mcp/$schema.ts | 3 +- app/src/modules/mcp/McpSchemaHelper.ts | 10 + app/src/modules/mcp/system-mcp.ts | 8 +- app/src/modules/server/SystemController.ts | 294 ++++++++------- app/src/ui/client/BkndProvider.tsx | 26 +- app/src/ui/client/bknd.ts | 2 +- app/src/ui/routes/data/_data.root.tsx | 24 +- .../ui/routes/data/data.schema.$entity.tsx | 61 +-- app/src/ui/routes/data/data.schema.index.tsx | 9 +- .../routes/data/forms/entity.fields.form.tsx | 70 ++-- .../ui/routes/settings/components/Setting.tsx | 10 +- app/vite.dev.ts | 2 +- 22 files changed, 768 insertions(+), 541 deletions(-) rename app/src/modules/{ModuleManager.ts => manager/DbModuleManager.ts} (64%) create mode 100644 app/src/modules/manager/ModuleManager.ts diff --git a/app/src/App.ts b/app/src/App.ts index bd62092..c58d20d 100644 --- a/app/src/App.ts +++ b/app/src/App.ts @@ -5,13 +5,14 @@ import type { em as prototypeEm } from "data/prototype"; import { Connection } from "data/connection/Connection"; import type { Hono } from "hono"; import { - ModuleManager, type InitialModuleConfigs, - type ModuleBuildContext, type ModuleConfigs, - type ModuleManagerOptions, type Modules, -} from "modules/ModuleManager"; + ModuleManager, + type ModuleBuildContext, + type ModuleManagerOptions, +} from "modules/manager/ModuleManager"; +import { DbModuleManager } from "modules/manager/DbModuleManager"; import * as SystemPermissions from "modules/permissions"; import { AdminController, type AdminControllerOptions } from "modules/server/AdminController"; import { SystemController } from "modules/server/SystemController"; @@ -93,13 +94,21 @@ export type AppOptions = { email?: IEmailDriver; cache?: ICacheDriver; }; -}; + mode?: "db" | "code"; + readonly?: boolean; +} & ( + | { + mode: "db"; + secrets?: Record; + } + | { mode: "code" } +); export type CreateAppConfig = { /** * bla */ connection?: Connection | { url: string }; - initialConfig?: InitialModuleConfigs; + config?: InitialModuleConfigs; options?: AppOptions; }; @@ -121,8 +130,8 @@ export class App(module: Module) { - return this.modules.mutateConfigSafe(module); - } - get server() { return this.modules.server; } @@ -377,5 +394,5 @@ export function createApp(config: CreateAppConfig = {}) { throw new Error("Invalid connection"); } - return new App(config.connection, config.initialConfig, config.options); + return new App(config.connection, config.config, config.options); } diff --git a/app/src/adapter/index.ts b/app/src/adapter/index.ts index 91ffcf7..e79c3a4 100644 --- a/app/src/adapter/index.ts +++ b/app/src/adapter/index.ts @@ -65,31 +65,21 @@ export async function createAdapterApp { - const id = opts?.id ?? "app"; - let app = apps.get(id); - if (!app || opts?.force) { - const appConfig = await makeConfig(config, args); - if (!appConfig.connection || !Connection.isConnection(appConfig.connection)) { - let connection: Connection | undefined; - if (Connection.isConnection(config.connection)) { - connection = config.connection; - } else { - const sqlite = (await import("bknd/adapter/sqlite")).sqlite; - const conf = appConfig.connection ?? { url: ":memory:" }; - connection = sqlite(conf) as any; - $console.info(`Using ${connection!.name} connection`, conf.url); - } - appConfig.connection = connection; - } - - app = App.create(appConfig); - - if (!opts?.force) { - apps.set(id, app); + const appConfig = await makeConfig(config, args); + if (!appConfig.connection || !Connection.isConnection(appConfig.connection)) { + let connection: Connection | undefined; + if (Connection.isConnection(config.connection)) { + connection = config.connection; + } else { + const sqlite = (await import("bknd/adapter/sqlite")).sqlite; + const conf = appConfig.connection ?? { url: ":memory:" }; + connection = sqlite(conf) as any; + $console.info(`Using ${connection!.name} connection`, conf.url); } + appConfig.connection = connection; } - return app; + return App.create(appConfig); } export async function createFrameworkApp( diff --git a/app/src/core/types.ts b/app/src/core/types.ts index 1751766..03beae5 100644 --- a/app/src/core/types.ts +++ b/app/src/core/types.ts @@ -4,3 +4,5 @@ export interface Serializable { } export type MaybePromise = T | Promise; + +export type PartialRec = { [P in keyof T]?: PartialRec }; diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index a0d7f02..18b26c2 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -51,6 +51,7 @@ export class DataController extends Controller { "/sync", permission(DataPermissions.databaseSync), mcpTool("data_sync", { + // @todo: should be removed if readonly annotations: { destructiveHint: true, }, diff --git a/app/src/index.ts b/app/src/index.ts index 46902cd..81d8185 100644 --- a/app/src/index.ts +++ b/app/src/index.ts @@ -28,7 +28,7 @@ export { type ModuleBuildContext, type InitialModuleConfigs, ModuleManagerEvents, -} from "./modules/ModuleManager"; +} from "./modules/manager/ModuleManager"; export type { ServerEnv } from "modules/Controller"; export type { BkndConfig } from "bknd/adapter"; diff --git a/app/src/modules/index.ts b/app/src/modules/index.ts index 84a50cc..efa525d 100644 --- a/app/src/modules/index.ts +++ b/app/src/modules/index.ts @@ -10,7 +10,7 @@ export { type ModuleSchemas, MODULE_NAMES, type ModuleKey, -} from "./ModuleManager"; +} from "./manager/ModuleManager"; export type { ModuleBuildContext } from "./Module"; export { diff --git a/app/src/modules/ModuleManager.ts b/app/src/modules/manager/DbModuleManager.ts similarity index 64% rename from app/src/modules/ModuleManager.ts rename to app/src/modules/manager/DbModuleManager.ts index 2948b5c..6095470 100644 --- a/app/src/modules/ModuleManager.ts +++ b/app/src/modules/manager/DbModuleManager.ts @@ -1,89 +1,28 @@ -import { mark, stripMark, $console, s, objectEach, transformObject, McpServer } from "bknd/utils"; -import { DebugLogger } from "core/utils/DebugLogger"; -import { Guard } from "auth/authorize/Guard"; -import { env } from "core/env"; +import { mark, stripMark, $console, s } from "bknd/utils"; import { BkndError } from "core/errors"; -import { EventManager, Event } from "core/events"; import * as $diff from "core/object/diff"; import type { Connection } from "data/connection"; import { EntityManager } from "data/entities/EntityManager"; import * as proto from "data/prototype"; import { TransformPersistFailedException } from "data/errors"; -import { Hono } from "hono"; import type { Kysely } from "kysely"; import { mergeWith } from "lodash-es"; import { CURRENT_VERSION, TABLE_NAME, migrate } from "modules/migrations"; -import { AppServer } from "modules/server/AppServer"; -import { AppAuth } from "../auth/AppAuth"; -import { AppData } from "../data/AppData"; -import { AppFlows } from "../flows/AppFlows"; -import { AppMedia } from "../media/AppMedia"; -import type { ServerEnv } from "./Controller"; -import { Module, type ModuleBuildContext } from "./Module"; -import { ModuleHelper } from "./ModuleHelper"; +import { Module, type ModuleBuildContext } from "../Module"; +import { + type InitialModuleConfigs, + type ModuleConfigs, + type Modules, + type ModuleKey, + getDefaultSchema, + getDefaultConfig, + ModuleManager, + ModuleManagerConfigUpdateEvent, + type ModuleManagerOptions, +} from "./ModuleManager"; export type { ModuleBuildContext }; -export const MODULES = { - server: AppServer, - data: AppData, - auth: AppAuth, - media: AppMedia, - flows: AppFlows, -} as const; - -// get names of MODULES as an array -export const MODULE_NAMES = Object.keys(MODULES) as ModuleKey[]; - -export type ModuleKey = keyof typeof MODULES; -export type Modules = { - [K in keyof typeof MODULES]: InstanceType<(typeof MODULES)[K]>; -}; - -export type ModuleSchemas = { - [K in keyof typeof MODULES]: ReturnType<(typeof MODULES)[K]["prototype"]["getSchema"]>; -}; - -export type ModuleConfigs = { - [K in keyof ModuleSchemas]: s.Static; -}; -type PartialRec = { [P in keyof T]?: PartialRec }; - -export type InitialModuleConfigs = - | ({ - version: number; - } & ModuleConfigs) - | PartialRec; - -enum Verbosity { - silent = 0, - error = 1, - log = 2, -} - -export type ModuleManagerOptions = { - initial?: InitialModuleConfigs; - eventManager?: EventManager; - onUpdated?: ( - module: Module, - config: ModuleConfigs[Module], - ) => Promise; - // triggered when no config table existed - onFirstBoot?: () => Promise; - // base path for the hono instance - basePath?: string; - // callback after server was created - onServerInit?: (server: Hono) => void; - // doesn't perform validity checks for given/fetched config - trustFetched?: boolean; - // runs when initial config provided on a fresh database - seed?: (ctx: ModuleBuildContext) => Promise; - // called right after modules are built, before finish - onModulesBuilt?: (ctx: ModuleBuildContext) => Promise; - /** @deprecated */ - verbosity?: Verbosity; -}; - export type ConfigTable = { id?: number; version: number; @@ -116,99 +55,41 @@ interface T_INTERNAL_EM { __bknd: ConfigTable2; } -const debug_modules = env("modules_debug"); - -abstract class ModuleManagerEvent extends Event<{ ctx: ModuleBuildContext } & A> {} -export class ModuleManagerConfigUpdateEvent< - Module extends keyof ModuleConfigs, -> extends ModuleManagerEvent<{ - module: Module; - config: ModuleConfigs[Module]; -}> { - static override slug = "mm-config-update"; -} -export const ModuleManagerEvents = { - ModuleManagerConfigUpdateEvent, -}; - // @todo: cleanup old diffs on upgrade // @todo: cleanup multiple backups on upgrade -export class ModuleManager { - static Events = ModuleManagerEvents; - - protected modules: Modules; +export class DbModuleManager extends ModuleManager { // internal em for __bknd config table __em!: EntityManager; - // ctx for modules - em!: EntityManager; - server!: Hono; - emgr!: EventManager; - guard!: Guard; - mcp!: ModuleBuildContext["mcp"]; private _version: number = 0; - private _built = false; private readonly _booted_with?: "provided" | "partial"; private _stable_configs: ModuleConfigs | undefined; - private logger: DebugLogger; - - constructor( - private readonly connection: Connection, - private options?: Partial, - ) { - this.__em = new EntityManager([__bknd], this.connection); - this.modules = {} as Modules; - this.emgr = new EventManager({ ...ModuleManagerEvents }); - this.logger = new DebugLogger(debug_modules); - let initial = {} as Partial; + constructor(connection: Connection, options?: Partial) { + let initial = {} as InitialModuleConfigs; + let booted_with = "partial" as any; + let version = 0; if (options?.initial) { - if ("version" in options.initial) { - const { version, ...initialConfig } = options.initial; - this._version = version; - initial = stripMark(initialConfig); + if ("version" in options.initial && options.initial.version) { + const { version: _v, ...initialConfig } = options.initial; + version = _v as number; + initial = stripMark(initialConfig) as any; - this._booted_with = "provided"; + booted_with = "provided"; } else { initial = mergeWith(getDefaultConfig(), options.initial); - this._booted_with = "partial"; + booted_with = "partial"; } } + super(connection, { ...options, initial }); + + this.__em = new EntityManager([__bknd], this.connection); + this._version = version; + this._booted_with = booted_with; + this.logger.log("booted with", this._booted_with); - - this.createModules(initial); - } - - private createModules(initial: Partial) { - this.logger.context("createModules").log("creating modules"); - try { - const context = this.ctx(true); - - for (const key in MODULES) { - const moduleConfig = initial && key in initial ? initial[key] : {}; - const module = new MODULES[key](moduleConfig, context) as Module; - module.setListener(async (c) => { - await this.onModuleConfigUpdated(key, c); - }); - - this.modules[key] = module; - } - this.logger.log("modules created"); - } catch (e) { - this.logger.log("failed to create modules", e); - throw e; - } - this.logger.clear(); - } - - private get verbosity() { - return this.options?.verbosity ?? Verbosity.silent; - } - - isBuilt(): boolean { - return this._built; } /** @@ -216,7 +97,7 @@ export class ModuleManager { * It's called everytime a module's config is updated in SchemaObject * Needs to rebuild modules and save to database */ - private async onModuleConfigUpdated(key: string, config: any) { + protected override async onModuleConfigUpdated(key: string, config: any) { if (this.options?.onUpdated) { await this.options.onUpdated(key as any, config); } else { @@ -250,55 +131,6 @@ export class ModuleManager { return result; } - private rebuildServer() { - this.server = new Hono(); - if (this.options?.basePath) { - this.server = this.server.basePath(this.options.basePath); - } - if (this.options?.onServerInit) { - this.options.onServerInit(this.server); - } - - // optional method for each module to register global middlewares, etc. - objectEach(this.modules, (module) => { - module.onServerInit(this.server); - }); - } - - ctx(rebuild?: boolean): ModuleBuildContext { - if (rebuild) { - this.rebuildServer(); - this.em = this.em - ? this.em.clear() - : new EntityManager([], this.connection, [], [], this.emgr); - this.guard = new Guard(); - this.mcp = new McpServer(undefined as any, { - app: new Proxy(this, { - get: () => { - throw new Error("app is not available in mcp context"); - }, - }) as any, - ctx: () => this.ctx(), - }); - } - - const ctx = { - connection: this.connection, - server: this.server, - em: this.em, - emgr: this.emgr, - guard: this.guard, - flags: Module.ctx_flags, - logger: this.logger, - mcp: this.mcp, - }; - - return { - ...ctx, - helper: new ModuleHelper(ctx), - }; - } - private async fetch(): Promise { this.logger.context("fetch").log("fetching"); const startTime = performance.now(); @@ -463,22 +295,7 @@ export class ModuleManager { } } - private setConfigs(configs: ModuleConfigs): void { - this.logger.log("setting configs"); - objectEach(configs, (config, key) => { - try { - // setting "noEmit" to true, to not force listeners to update - this.modules[key].schema().set(config as any, true); - } catch (e) { - console.error(e); - throw new Error( - `Failed to set config for module ${key}: ${JSON.stringify(config, null, 2)}`, - ); - } - }); - } - - async build(opts?: { fetch?: boolean }) { + override async build(opts?: { fetch?: boolean }) { this.logger.context("build").log("version", this.version()); await this.ctx().connection.init(); @@ -521,11 +338,13 @@ export class ModuleManager { this.logger.log("migrated to", _version); $console.log("Migrated config from", version_before, "to", this.version()); - this.createModules(_configs); + // @ts-expect-error + this.setConfigs(_configs); await this.buildModules(); } else { this.logger.log("version is current", this.version()); - this.createModules(result.json); + + this.setConfigs(result.json); await this.buildModules(); } } @@ -544,7 +363,7 @@ export class ModuleManager { return this; } - private async buildModules(options?: { graceful?: boolean; ignoreFlags?: boolean }) { + protected override async buildModules(options?: { graceful?: boolean; ignoreFlags?: boolean }) { const state = { built: false, modules: [] as ModuleKey[], @@ -686,71 +505,4 @@ export class ModuleManager { }, }); } - - get(key: K): Modules[K] { - if (!(key in this.modules)) { - throw new Error(`Module "${key}" doesn't exist, cannot get`); - } - return this.modules[key]; - } - - version() { - return this._version; - } - - built() { - return this._built; - } - - configs(): ModuleConfigs { - return transformObject(this.modules, (module) => module.toJSON(true)) as any; - } - - getSchema() { - const schemas = transformObject(this.modules, (module) => module.getSchema()); - - return { - version: this.version(), - ...schemas, - } as { version: number } & ModuleSchemas; - } - - toJSON(secrets?: boolean): { version: number } & ModuleConfigs { - const modules = transformObject(this.modules, (module) => { - if (this._built) { - return module.isBuilt() ? module.toJSON(secrets) : module.configDefault; - } - - // returns no config if the all modules are not built - return undefined; - }); - - return { - version: this.version(), - ...modules, - } as any; - } -} - -export function getDefaultSchema() { - const schema = { - type: "object", - ...transformObject(MODULES, (module) => module.prototype.getSchema()), - }; - - return schema as any; -} - -export function getDefaultConfig(): ModuleConfigs { - const config = transformObject(MODULES, (module) => { - return module.prototype.getSchema().template( - {}, - { - withOptional: true, - withExtendedOptional: true, - }, - ); - }); - - return structuredClone(config) as any; } diff --git a/app/src/modules/manager/ModuleManager.ts b/app/src/modules/manager/ModuleManager.ts new file mode 100644 index 0000000..f52a8e0 --- /dev/null +++ b/app/src/modules/manager/ModuleManager.ts @@ -0,0 +1,354 @@ +import { objectEach, transformObject, McpServer, type s } from "bknd/utils"; +import { DebugLogger } from "core/utils/DebugLogger"; +import { Guard } from "auth/authorize/Guard"; +import { env } from "core/env"; +import { EventManager, Event } from "core/events"; +import type { Connection } from "data/connection"; +import { EntityManager } from "data/entities/EntityManager"; +import { Hono } from "hono"; +import { CURRENT_VERSION } from "modules/migrations"; +import type { ServerEnv } from "../Controller"; +import { Module, type ModuleBuildContext } from "../Module"; +import { ModuleHelper } from "../ModuleHelper"; +import { AppServer } from "modules/server/AppServer"; +import { AppAuth } from "auth/AppAuth"; +import { AppData } from "data/AppData"; +import { AppFlows } from "flows/AppFlows"; +import { AppMedia } from "media/AppMedia"; +import type { PartialRec } from "core/types"; + +export type { ModuleBuildContext }; + +export const MODULES = { + server: AppServer, + data: AppData, + auth: AppAuth, + media: AppMedia, + flows: AppFlows, +} as const; + +// get names of MODULES as an array +export const MODULE_NAMES = Object.keys(MODULES) as ModuleKey[]; + +export type ModuleKey = keyof typeof MODULES; +export type Modules = { + [K in keyof typeof MODULES]: InstanceType<(typeof MODULES)[K]>; +}; + +export type ModuleSchemas = { + [K in keyof typeof MODULES]: ReturnType<(typeof MODULES)[K]["prototype"]["getSchema"]>; +}; + +export type ModuleConfigs = { + [K in keyof ModuleSchemas]: s.Static; +}; + +export type InitialModuleConfigs = { + version?: number; +} & PartialRec; + +enum Verbosity { + silent = 0, + error = 1, + log = 2, +} + +export type ModuleManagerOptions = { + initial?: InitialModuleConfigs; + eventManager?: EventManager; + onUpdated?: ( + module: Module, + config: ModuleConfigs[Module], + ) => Promise; + // triggered when no config table existed + onFirstBoot?: () => Promise; + // base path for the hono instance + basePath?: string; + // callback after server was created + onServerInit?: (server: Hono) => void; + // doesn't perform validity checks for given/fetched config + trustFetched?: boolean; + // runs when initial config provided on a fresh database + seed?: (ctx: ModuleBuildContext) => Promise; + // called right after modules are built, before finish + onModulesBuilt?: (ctx: ModuleBuildContext) => Promise; + /** @deprecated */ + verbosity?: Verbosity; +}; + +const debug_modules = env("modules_debug"); + +abstract class ModuleManagerEvent extends Event<{ ctx: ModuleBuildContext } & A> {} +export class ModuleManagerConfigUpdateEvent< + Module extends keyof ModuleConfigs, +> extends ModuleManagerEvent<{ + module: Module; + config: ModuleConfigs[Module]; +}> { + static override slug = "mm-config-update"; +} +export const ModuleManagerEvents = { + ModuleManagerConfigUpdateEvent, +}; + +// @todo: cleanup old diffs on upgrade +// @todo: cleanup multiple backups on upgrade +export class ModuleManager { + static Events = ModuleManagerEvents; + + protected modules: Modules; + // ctx for modules + em!: EntityManager; + server!: Hono; + emgr!: EventManager; + guard!: Guard; + mcp!: ModuleBuildContext["mcp"]; + + protected _built = false; + + protected logger: DebugLogger; + + constructor( + protected readonly connection: Connection, + protected options?: Partial, + ) { + this.modules = {} as Modules; + this.emgr = new EventManager({ ...ModuleManagerEvents }); + this.logger = new DebugLogger(debug_modules); + + this.createModules(options?.initial ?? {}); + } + + protected onModuleConfigUpdated(key: string, config: any) {} + + private createModules(initial: PartialRec) { + this.logger.context("createModules").log("creating modules"); + try { + const context = this.ctx(true); + + for (const key in MODULES) { + const moduleConfig = initial && key in initial ? initial[key] : {}; + const module = new MODULES[key](moduleConfig, context) as Module; + module.setListener(async (c) => { + await this.onModuleConfigUpdated(key, c); + }); + + this.modules[key] = module; + } + this.logger.log("modules created"); + } catch (e) { + this.logger.log("failed to create modules", e); + throw e; + } + this.logger.clear(); + } + + private get verbosity() { + return this.options?.verbosity ?? Verbosity.silent; + } + + isBuilt(): boolean { + return this._built; + } + + protected rebuildServer() { + this.server = new Hono(); + if (this.options?.basePath) { + this.server = this.server.basePath(this.options.basePath); + } + if (this.options?.onServerInit) { + this.options.onServerInit(this.server); + } + + // optional method for each module to register global middlewares, etc. + objectEach(this.modules, (module) => { + module.onServerInit(this.server); + }); + } + + ctx(rebuild?: boolean): ModuleBuildContext { + if (rebuild) { + this.rebuildServer(); + this.em = this.em + ? this.em.clear() + : new EntityManager([], this.connection, [], [], this.emgr); + this.guard = new Guard(); + this.mcp = new McpServer(undefined as any, { + app: new Proxy(this, { + get: () => { + throw new Error("app is not available in mcp context"); + }, + }) as any, + ctx: () => this.ctx(), + }); + } + + const ctx = { + connection: this.connection, + server: this.server, + em: this.em, + emgr: this.emgr, + guard: this.guard, + flags: Module.ctx_flags, + logger: this.logger, + mcp: this.mcp, + }; + + return { + ...ctx, + helper: new ModuleHelper(ctx), + }; + } + + protected setConfigs(configs: ModuleConfigs): void { + this.logger.log("setting configs"); + objectEach(configs, (config, key) => { + try { + // setting "noEmit" to true, to not force listeners to update + this.modules[key].schema().set(config as any, true); + } catch (e) { + console.error(e); + throw new Error( + `Failed to set config for module ${key}: ${JSON.stringify(config, null, 2)}`, + ); + } + }); + } + + async build(opts?: any) { + this.createModules(this.options?.initial ?? {}); + await this.buildModules(); + + return this; + } + + protected async buildModules(options?: { + graceful?: boolean; + ignoreFlags?: boolean; + drop?: boolean; + }) { + const state = { + built: false, + modules: [] as ModuleKey[], + synced: false, + saved: false, + reloaded: false, + }; + + this.logger.context("buildModules").log("triggered", options, this._built); + if (options?.graceful && this._built) { + this.logger.log("skipping build (graceful)"); + return state; + } + + this.logger.log("building"); + const ctx = this.ctx(true); + for (const key in this.modules) { + await this.modules[key].setContext(ctx).build(); + this.logger.log("built", key); + state.modules.push(key as ModuleKey); + } + + this._built = state.built = true; + this.logger.log("modules built", ctx.flags); + + if (this.options?.onModulesBuilt) { + await this.options.onModulesBuilt(ctx); + } + + if (options?.ignoreFlags !== true) { + if (ctx.flags.sync_required) { + ctx.flags.sync_required = false; + this.logger.log("db sync requested"); + + // sync db + await ctx.em.schema().sync({ force: true, drop: options?.drop }); + state.synced = true; + } + + if (ctx.flags.ctx_reload_required) { + ctx.flags.ctx_reload_required = false; + this.logger.log("ctx reload requested"); + this.ctx(true); + state.reloaded = true; + } + } + + // reset all falgs + this.logger.log("resetting flags"); + ctx.flags = Module.ctx_flags; + + // storing last stable config version + //this._stable_configs = $diff.clone(this.configs()); + + this.logger.clear(); + return state; + } + + get(key: K): Modules[K] { + if (!(key in this.modules)) { + throw new Error(`Module "${key}" doesn't exist, cannot get`); + } + return this.modules[key]; + } + + version() { + return CURRENT_VERSION; + } + + built() { + return this._built; + } + + configs(): ModuleConfigs { + return transformObject(this.modules, (module) => module.toJSON(true)) as any; + } + + getSchema() { + const schemas = transformObject(this.modules, (module) => module.getSchema()); + + return { + version: this.version(), + ...schemas, + } as { version: number } & ModuleSchemas; + } + + toJSON(secrets?: boolean): { version: number } & ModuleConfigs { + const modules = transformObject(this.modules, (module) => { + if (this._built) { + return module.isBuilt() ? module.toJSON(secrets) : module.configDefault; + } + + // returns no config if the all modules are not built + return undefined; + }); + + return { + version: this.version(), + ...modules, + } as any; + } +} + +export function getDefaultSchema() { + const schema = { + type: "object", + ...transformObject(MODULES, (module) => module.prototype.getSchema()), + }; + + return schema as any; +} + +export function getDefaultConfig(): ModuleConfigs { + const config = transformObject(MODULES, (module) => { + return module.prototype.getSchema().template( + {}, + { + withOptional: true, + withExtendedOptional: true, + }, + ); + }); + + return structuredClone(config) as any; +} diff --git a/app/src/modules/mcp/$object.ts b/app/src/modules/mcp/$object.ts index a57257b..f5fa6b4 100644 --- a/app/src/modules/mcp/$object.ts +++ b/app/src/modules/mcp/$object.ts @@ -6,7 +6,6 @@ import { type McpSchema, type SchemaWithMcpOptions, } from "./McpSchemaHelper"; -import type { Module } from "modules/Module"; export interface ObjectToolSchemaOptions extends s.IObjectOptions, SchemaWithMcpOptions {} @@ -79,6 +78,7 @@ export class ObjectToolSchema< private toolUpdate(node: s.Node) { const schema = this.mcp.cleanSchema; + return new Tool( [this.mcp.name, "update"].join("_"), { @@ -97,11 +97,12 @@ export class ObjectToolSchema< async (params, ctx: AppToolHandlerCtx) => { const { full, value, return_config } = params; const [module_name] = node.instancePath; + const manager = this.mcp.getManager(ctx); if (full) { - await ctx.context.app.mutateConfig(module_name as any).set(value); + await manager.mutateConfigSafe(module_name as any).set(value); } else { - await ctx.context.app.mutateConfig(module_name as any).patch("", value); + await manager.mutateConfigSafe(module_name as any).patch("", value); } let config: any = undefined; diff --git a/app/src/modules/mcp/$record.ts b/app/src/modules/mcp/$record.ts index a10054a..fc6dfaa 100644 --- a/app/src/modules/mcp/$record.ts +++ b/app/src/modules/mcp/$record.ts @@ -129,13 +129,14 @@ export class RecordToolSchema< const configs = ctx.context.app.toJSON(true); const config = getPath(configs, node.instancePath); const [module_name, ...rest] = node.instancePath; + const manager = this.mcp.getManager(ctx); if (params.key in config) { throw new Error(`Key "${params.key}" already exists in config`); } - await ctx.context.app - .mutateConfig(module_name as any) + await manager + .mutateConfigSafe(module_name as any) .patch([...rest, params.key], params.value); const newConfig = getPath(ctx.context.app.toJSON(), node.instancePath); @@ -175,13 +176,14 @@ export class RecordToolSchema< const configs = ctx.context.app.toJSON(true); const config = getPath(configs, node.instancePath); const [module_name, ...rest] = node.instancePath; + const manager = this.mcp.getManager(ctx); if (!(params.key in config)) { throw new Error(`Key "${params.key}" not found in config`); } - await ctx.context.app - .mutateConfig(module_name as any) + await manager + .mutateConfigSafe(module_name as any) .patch([...rest, params.key], params.value); const newConfig = getPath(ctx.context.app.toJSON(), node.instancePath); @@ -220,13 +222,14 @@ export class RecordToolSchema< const configs = ctx.context.app.toJSON(true); const config = getPath(configs, node.instancePath); const [module_name, ...rest] = node.instancePath; + const manager = this.mcp.getManager(ctx); if (!(params.key in config)) { throw new Error(`Key "${params.key}" not found in config`); } - await ctx.context.app - .mutateConfig(module_name as any) + await manager + .mutateConfigSafe(module_name as any) .remove([...rest, params.key].join(".")); const newConfig = getPath(ctx.context.app.toJSON(), node.instancePath); diff --git a/app/src/modules/mcp/$schema.ts b/app/src/modules/mcp/$schema.ts index 9c86d4a..c71424b 100644 --- a/app/src/modules/mcp/$schema.ts +++ b/app/src/modules/mcp/$schema.ts @@ -58,8 +58,9 @@ export const $schema = < async (params, ctx: AppToolHandlerCtx) => { const { value, return_config, secrets } = params; const [module_name, ...rest] = node.instancePath; + const manager = mcp.getManager(ctx); - await ctx.context.app.mutateConfig(module_name as any).overwrite(rest, value); + await manager.mutateConfigSafe(module_name as any).overwrite(rest, value); let config: any = undefined; if (return_config) { diff --git a/app/src/modules/mcp/McpSchemaHelper.ts b/app/src/modules/mcp/McpSchemaHelper.ts index 686e7ff..7b1db61 100644 --- a/app/src/modules/mcp/McpSchemaHelper.ts +++ b/app/src/modules/mcp/McpSchemaHelper.ts @@ -10,6 +10,7 @@ import { } from "bknd/utils"; import type { ModuleBuildContext } from "modules"; import { excludePropertyTypes, rescursiveClean } from "./utils"; +import type { DbModuleManager } from "modules/manager/DbModuleManager"; export const mcpSchemaSymbol = Symbol.for("bknd-mcp-schema"); @@ -74,4 +75,13 @@ export class McpSchemaHelper { }, }; } + + getManager(ctx: AppToolHandlerCtx): DbModuleManager { + const manager = ctx.context.app.modules; + if ("mutateConfigSafe" in manager) { + return manager as DbModuleManager; + } + + throw new Error("Manager not found"); + } } diff --git a/app/src/modules/mcp/system-mcp.ts b/app/src/modules/mcp/system-mcp.ts index bd278db..b1e7fa8 100644 --- a/app/src/modules/mcp/system-mcp.ts +++ b/app/src/modules/mcp/system-mcp.ts @@ -19,9 +19,11 @@ export function getSystemMcp(app: App) { ].sort((a, b) => a.name.localeCompare(b.name)); // tools from app schema - tools.push( - ...nodes.flatMap((n) => n.schema.getTools(n)).sort((a, b) => a.name.localeCompare(b.name)), - ); + if (!app.isReadOnly()) { + tools.push( + ...nodes.flatMap((n) => n.schema.getTools(n)).sort((a, b) => a.name.localeCompare(b.name)), + ); + } const resources = [...middlewareServer.resources, ...app.modules.ctx().mcp.resources]; diff --git a/app/src/modules/server/SystemController.ts b/app/src/modules/server/SystemController.ts index 7beda79..824801a 100644 --- a/app/src/modules/server/SystemController.ts +++ b/app/src/modules/server/SystemController.ts @@ -26,11 +26,12 @@ import { type ModuleConfigs, type ModuleSchemas, type ModuleKey, -} from "modules/ModuleManager"; +} from "modules/manager/ModuleManager"; import * as SystemPermissions from "modules/permissions"; import { getVersion } from "core/env"; import type { Module } from "modules/Module"; import { getSystemMcp } from "modules/mcp/system-mcp"; +import { DbModuleManager } from "modules/manager/DbModuleManager"; export type ConfigUpdate = { success: true; @@ -43,6 +44,7 @@ export type ConfigUpdateResponse = export type SchemaResponse = { version: string; schema: ModuleSchemas; + readonly: boolean; config: ModuleConfigs; permissions: string[]; }; @@ -109,22 +111,163 @@ export class SystemController extends Controller { private registerConfigController(client: Hono): void { const { permission } = this.middlewares; // don't add auth again, it's already added in getController - const hono = this.create(); + const hono = this.create().use(permission(SystemPermissions.configRead)); - hono.use(permission(SystemPermissions.configRead)); + if (!this.app.isReadOnly()) { + const manager = this.app.modules as DbModuleManager; - hono.get( - "/raw", - describeRoute({ - summary: "Get the raw config", - tags: ["system"], - }), - permission([SystemPermissions.configReadSecrets]), - async (c) => { - // @ts-expect-error "fetch" is private - return c.json(await this.app.modules.fetch()); - }, - ); + hono.get( + "/raw", + describeRoute({ + summary: "Get the raw config", + tags: ["system"], + }), + permission([SystemPermissions.configReadSecrets]), + async (c) => { + // @ts-expect-error "fetch" is private + return c.json(await this.app.modules.fetch()); + }, + ); + + async function handleConfigUpdateResponse( + c: Context, + cb: () => Promise, + ) { + try { + return c.json(await cb(), { status: 202 }); + } catch (e) { + $console.error("config update error", e); + + if (e instanceof InvalidSchemaError) { + return c.json( + { success: false, type: "type-invalid", errors: e.errors }, + { status: 400 }, + ); + } + if (e instanceof Error) { + return c.json( + { success: false, type: "error", error: e.message }, + { status: 500 }, + ); + } + + return c.json({ success: false, type: "unknown" }, { status: 500 }); + } + } + + hono.post( + "/set/:module", + permission(SystemPermissions.configWrite), + jsc("query", s.object({ force: s.boolean().optional() }), { skipOpenAPI: true }), + async (c) => { + const module = c.req.param("module") as any; + const { force } = c.req.valid("query"); + const value = await c.req.json(); + + return await handleConfigUpdateResponse(c, async () => { + // you must explicitly set force to override existing values + // because omitted values gets removed + if (force === true) { + // force overwrite defined keys + const newConfig = { + ...this.app.module[module].config, + ...value, + }; + await manager.mutateConfigSafe(module).set(newConfig); + } else { + await manager.mutateConfigSafe(module).patch("", value); + } + return { + success: true, + module, + config: this.app.module[module].config, + }; + }); + }, + ); + + hono.post("/add/:module/:path", permission(SystemPermissions.configWrite), async (c) => { + // @todo: require auth (admin) + const module = c.req.param("module") as any; + const value = await c.req.json(); + const path = c.req.param("path") as string; + + if (this.app.modules.get(module).schema().has(path)) { + return c.json( + { success: false, path, error: "Path already exists" }, + { status: 400 }, + ); + } + + return await handleConfigUpdateResponse(c, async () => { + await manager.mutateConfigSafe(module).patch(path, value); + return { + success: true, + module, + config: this.app.module[module].config, + }; + }); + }); + + hono.patch( + "/patch/:module/:path", + permission(SystemPermissions.configWrite), + async (c) => { + // @todo: require auth (admin) + const module = c.req.param("module") as any; + const value = await c.req.json(); + const path = c.req.param("path"); + + return await handleConfigUpdateResponse(c, async () => { + await manager.mutateConfigSafe(module).patch(path, value); + return { + success: true, + module, + config: this.app.module[module].config, + }; + }); + }, + ); + + hono.put( + "/overwrite/:module/:path", + permission(SystemPermissions.configWrite), + async (c) => { + // @todo: require auth (admin) + const module = c.req.param("module") as any; + const value = await c.req.json(); + const path = c.req.param("path"); + + return await handleConfigUpdateResponse(c, async () => { + await manager.mutateConfigSafe(module).overwrite(path, value); + return { + success: true, + module, + config: this.app.module[module].config, + }; + }); + }, + ); + + hono.delete( + "/remove/:module/:path", + permission(SystemPermissions.configWrite), + async (c) => { + // @todo: require auth (admin) + const module = c.req.param("module") as any; + const path = c.req.param("path")!; + + return await handleConfigUpdateResponse(c, async () => { + await manager.mutateConfigSafe(module).remove(path); + return { + success: true, + module, + config: this.app.module[module].config, + }; + }); + }, + ); + } hono.get( "/:module?", @@ -160,124 +303,6 @@ export class SystemController extends Controller { }, ); - async function handleConfigUpdateResponse(c: Context, cb: () => Promise) { - try { - return c.json(await cb(), { status: 202 }); - } catch (e) { - $console.error("config update error", e); - - if (e instanceof InvalidSchemaError) { - return c.json( - { success: false, type: "type-invalid", errors: e.errors }, - { status: 400 }, - ); - } - if (e instanceof Error) { - return c.json({ success: false, type: "error", error: e.message }, { status: 500 }); - } - - return c.json({ success: false, type: "unknown" }, { status: 500 }); - } - } - - hono.post( - "/set/:module", - permission(SystemPermissions.configWrite), - jsc("query", s.object({ force: s.boolean().optional() }), { skipOpenAPI: true }), - async (c) => { - const module = c.req.param("module") as any; - const { force } = c.req.valid("query"); - const value = await c.req.json(); - - return await handleConfigUpdateResponse(c, async () => { - // you must explicitly set force to override existing values - // because omitted values gets removed - if (force === true) { - // force overwrite defined keys - const newConfig = { - ...this.app.module[module].config, - ...value, - }; - await this.app.mutateConfig(module).set(newConfig); - } else { - await this.app.mutateConfig(module).patch("", value); - } - return { - success: true, - module, - config: this.app.module[module].config, - }; - }); - }, - ); - - hono.post("/add/:module/:path", permission(SystemPermissions.configWrite), async (c) => { - // @todo: require auth (admin) - const module = c.req.param("module") as any; - const value = await c.req.json(); - const path = c.req.param("path") as string; - - if (this.app.modules.get(module).schema().has(path)) { - return c.json({ success: false, path, error: "Path already exists" }, { status: 400 }); - } - - return await handleConfigUpdateResponse(c, async () => { - await this.app.mutateConfig(module).patch(path, value); - return { - success: true, - module, - config: this.app.module[module].config, - }; - }); - }); - - hono.patch("/patch/:module/:path", permission(SystemPermissions.configWrite), async (c) => { - // @todo: require auth (admin) - const module = c.req.param("module") as any; - const value = await c.req.json(); - const path = c.req.param("path"); - - return await handleConfigUpdateResponse(c, async () => { - await this.app.mutateConfig(module).patch(path, value); - return { - success: true, - module, - config: this.app.module[module].config, - }; - }); - }); - - hono.put("/overwrite/:module/:path", permission(SystemPermissions.configWrite), async (c) => { - // @todo: require auth (admin) - const module = c.req.param("module") as any; - const value = await c.req.json(); - const path = c.req.param("path"); - - return await handleConfigUpdateResponse(c, async () => { - await this.app.mutateConfig(module).overwrite(path, value); - return { - success: true, - module, - config: this.app.module[module].config, - }; - }); - }); - - hono.delete("/remove/:module/:path", permission(SystemPermissions.configWrite), async (c) => { - // @todo: require auth (admin) - const module = c.req.param("module") as any; - const path = c.req.param("path")!; - - return await handleConfigUpdateResponse(c, async () => { - await this.app.mutateConfig(module).remove(path); - return { - success: true, - module, - config: this.app.module[module].config, - }; - }); - }); - client.route("/config", hono); } @@ -307,6 +332,7 @@ export class SystemController extends Controller { async (c) => { const module = c.req.param("module") as ModuleKey | undefined; const { config, secrets, fresh } = c.req.valid("query"); + const readonly = this.app.isReadOnly(); config && this.ctx.guard.throwUnlessGranted(SystemPermissions.configRead, c); secrets && this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets, c); @@ -321,6 +347,7 @@ export class SystemController extends Controller { if (module) { return c.json({ module, + readonly, version, schema: schema[module], config: config ? this.app.module[module].toJSON(secrets) : undefined, @@ -330,6 +357,7 @@ export class SystemController extends Controller { return c.json({ module, version, + readonly, schema, config: config ? this.app.toJSON(secrets) : undefined, permissions: this.app.modules.ctx().guard.getPermissionNames(), diff --git a/app/src/ui/client/BkndProvider.tsx b/app/src/ui/client/BkndProvider.tsx index 4e938d1..2e6e6d2 100644 --- a/app/src/ui/client/BkndProvider.tsx +++ b/app/src/ui/client/BkndProvider.tsx @@ -1,6 +1,14 @@ import type { ModuleConfigs, ModuleSchemas } from "modules"; -import { getDefaultConfig, getDefaultSchema } from "modules/ModuleManager"; -import { createContext, startTransition, useContext, useEffect, useRef, useState } from "react"; +import { getDefaultConfig, getDefaultSchema } from "modules/manager/ModuleManager"; +import { + createContext, + startTransition, + useContext, + useEffect, + useRef, + useState, + type ReactNode, +} from "react"; import { useApi } from "ui/client"; import { type TSchemaActions, getSchemaActions } from "./schema/actions"; import { AppReduced } from "./utils/AppReduced"; @@ -15,6 +23,7 @@ export type BkndAdminOptions = { }; type BkndContext = { version: number; + readonly: boolean; schema: ModuleSchemas; config: ModuleConfigs; permissions: string[]; @@ -48,7 +57,12 @@ export function BkndProvider({ }) { const [withSecrets, setWithSecrets] = useState(includeSecrets); const [schema, setSchema] = - useState>(); + useState< + Pick< + BkndContext, + "version" | "schema" | "config" | "permissions" | "fallback" | "readonly" + > + >(); const [fetched, setFetched] = useState(false); const [error, setError] = useState(); const errorShown = useRef(false); @@ -97,6 +111,7 @@ export function BkndProvider({ ? res.body : ({ version: 0, + mode: "db", schema: getDefaultSchema(), config: getDefaultConfig(), permissions: [], @@ -173,3 +188,8 @@ export function useBkndOptions(): BkndAdminOptions { } ); } + +export function SchemaEditable({ children }: { children: ReactNode }) { + const { readonly } = useBknd(); + return !readonly ? children : null; +} diff --git a/app/src/ui/client/bknd.ts b/app/src/ui/client/bknd.ts index 55159a7..e384b07 100644 --- a/app/src/ui/client/bknd.ts +++ b/app/src/ui/client/bknd.ts @@ -1 +1 @@ -export { BkndProvider, type BkndAdminOptions, useBknd } from "./BkndProvider"; +export { BkndProvider, type BkndAdminOptions, useBknd, SchemaEditable } from "./BkndProvider"; diff --git a/app/src/ui/routes/data/_data.root.tsx b/app/src/ui/routes/data/_data.root.tsx index 344ef69..ce195ed 100644 --- a/app/src/ui/routes/data/_data.root.tsx +++ b/app/src/ui/routes/data/_data.root.tsx @@ -22,6 +22,7 @@ import { useBrowserTitle } from "ui/hooks/use-browser-title"; import * as AppShell from "ui/layouts/AppShell/AppShell"; import { routes, useNavigate, useRouteNavigate } from "ui/lib/routes"; import { testIds } from "ui/lib/config"; +import { SchemaEditable, useBknd } from "ui/client/bknd"; export function DataRoot({ children }) { // @todo: settings routes should be centralized @@ -73,9 +74,11 @@ export function DataRoot({ children }) { value={context} onChange={handleSegmentChange} /> - - - + + + + + } > @@ -254,11 +257,26 @@ export function DataEmpty() { useBrowserTitle(["Data"]); const [navigate] = useNavigate(); const { $data } = useBkndData(); + const { readonly } = useBknd(); function handleButtonClick() { navigate(routes.data.schema.root()); } + if (readonly) { + return ( + + ); + } + return ( - $data.modals.createRelation(entity.name), - }, - { - icon: TbPhoto, - label: "Add media", - onClick: () => $data.modals.createMedia(entity.name), - }, - () =>
, - { - icon: TbDatabasePlus, - label: "Create Entity", - onClick: () => $data.modals.createEntity(), - }, - ]} - position="bottom-end" - > - - + + $data.modals.createRelation(entity.name), + }, + { + icon: TbPhoto, + label: "Add media", + onClick: () => $data.modals.createMedia(entity.name), + }, + () =>
, + { + icon: TbDatabasePlus, + label: "Create Entity", + onClick: () => $data.modals.createEntity(), + }, + ]} + position="bottom-end" + > + + + } className="pl-3" @@ -149,6 +152,7 @@ const Fields = ({ entity }: { entity: Entity }) => { const [submitting, setSubmitting] = useState(false); const [updates, setUpdates] = useState(0); const { actions, $data, config } = useBkndData(); + const { readonly } = useBknd(); const [res, setRes] = useState(); const ref = useRef(null); async function handleUpdate() { @@ -169,7 +173,7 @@ const Fields = ({ entity }: { entity: Entity }) => { title="Fields" ActiveIcon={IconAlignJustified} renderHeaderRight={({ open }) => - open ? ( + open && !readonly ? ( @@ -181,11 +185,12 @@ const Fields = ({ entity }: { entity: Entity }) => {
)} ["relation", "media"].includes(f.type)) .map((i) => ({ @@ -205,7 +210,7 @@ const Fields = ({ entity }: { entity: Entity }) => { isNew={false} /> - {isDebug() && ( + {isDebug() && !readonly && (
@@ -278,6 +284,7 @@ const BasicSettings = ({ entity }: { entity: Entity }) => { formData={_config} onSubmit={console.log} className="legacy hide-required-mark fieldset-alternative mute-root" + readonly={readonly} />
diff --git a/app/src/ui/routes/data/data.schema.index.tsx b/app/src/ui/routes/data/data.schema.index.tsx index 2c01ac3..d8ccbde 100644 --- a/app/src/ui/routes/data/data.schema.index.tsx +++ b/app/src/ui/routes/data/data.schema.index.tsx @@ -1,4 +1,5 @@ import { Suspense, lazy } from "react"; +import { SchemaEditable } from "ui/client/bknd"; import { useBkndData } from "ui/client/schema/data/use-bknd-data"; import { Button } from "ui/components/buttons/Button"; import * as AppShell from "ui/layouts/AppShell/AppShell"; @@ -15,9 +16,11 @@ export function DataSchemaIndex() { <> - Create new - + + + } > Schema Overview diff --git a/app/src/ui/routes/data/forms/entity.fields.form.tsx b/app/src/ui/routes/data/forms/entity.fields.form.tsx index d8f7d53..4272f89 100644 --- a/app/src/ui/routes/data/forms/entity.fields.form.tsx +++ b/app/src/ui/routes/data/forms/entity.fields.form.tsx @@ -29,6 +29,7 @@ import { MantineSelect } from "ui/components/form/hook-form-mantine/MantineSelec import type { TPrimaryFieldFormat } from "data/fields/PrimaryField"; import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; import ErrorBoundary from "ui/components/display/ErrorBoundary"; +import { SchemaEditable } from "ui/client/bknd"; const fieldsSchemaObject = originalFieldsSchemaObject; const fieldsSchema = s.anyOf(Object.values(fieldsSchemaObject)); @@ -64,6 +65,7 @@ export type EntityFieldsFormProps = { routePattern?: string; defaultPrimaryFormat?: TPrimaryFieldFormat; isNew?: boolean; + readonly?: boolean; }; export type EntityFieldsFormRef = { @@ -76,7 +78,7 @@ export type EntityFieldsFormRef = { export const EntityFieldsForm = forwardRef( function EntityFieldsForm( - { fields: _fields, sortable, additionalFieldTypes, routePattern, isNew, ...props }, + { fields: _fields, sortable, additionalFieldTypes, routePattern, isNew, readonly, ...props }, ref, ) { const entityFields = Object.entries(_fields).map(([name, field]) => ({ @@ -162,6 +164,7 @@ export const EntityFieldsForm = forwardRef ( {fields.map((field, index) => ( )} - ( - { - handleAppend(type as any); - }} - /> - )} - > - - + + ( + { + handleAppend(type as any); + }} + /> + )} + > + + +
@@ -288,6 +294,7 @@ function EntityField({ dnd, routePattern, primary, + readonly, }: { field: FieldArrayWithId; index: number; @@ -303,6 +310,7 @@ function EntityField({ defaultFormat?: TPrimaryFieldFormat; editable?: boolean; }; + readonly?: boolean; }) { const prefix = `fields.${index}.field` as const; const type = field.field.type; @@ -393,6 +401,7 @@ function EntityField({ Required @@ -433,6 +442,7 @@ function EntityField({
@@ -440,11 +450,13 @@ function EntityField({