mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-15 20:17:22 +00:00
init code-first mode by splitting module manager
This commit is contained in:
@@ -5,13 +5,14 @@ import type { em as prototypeEm } from "data/prototype";
|
|||||||
import { Connection } from "data/connection/Connection";
|
import { Connection } from "data/connection/Connection";
|
||||||
import type { Hono } from "hono";
|
import type { Hono } from "hono";
|
||||||
import {
|
import {
|
||||||
ModuleManager,
|
|
||||||
type InitialModuleConfigs,
|
type InitialModuleConfigs,
|
||||||
type ModuleBuildContext,
|
|
||||||
type ModuleConfigs,
|
type ModuleConfigs,
|
||||||
type ModuleManagerOptions,
|
|
||||||
type Modules,
|
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 * as SystemPermissions from "modules/permissions";
|
||||||
import { AdminController, type AdminControllerOptions } from "modules/server/AdminController";
|
import { AdminController, type AdminControllerOptions } from "modules/server/AdminController";
|
||||||
import { SystemController } from "modules/server/SystemController";
|
import { SystemController } from "modules/server/SystemController";
|
||||||
@@ -93,13 +94,21 @@ export type AppOptions = {
|
|||||||
email?: IEmailDriver;
|
email?: IEmailDriver;
|
||||||
cache?: ICacheDriver;
|
cache?: ICacheDriver;
|
||||||
};
|
};
|
||||||
};
|
mode?: "db" | "code";
|
||||||
|
readonly?: boolean;
|
||||||
|
} & (
|
||||||
|
| {
|
||||||
|
mode: "db";
|
||||||
|
secrets?: Record<string, any>;
|
||||||
|
}
|
||||||
|
| { mode: "code" }
|
||||||
|
);
|
||||||
export type CreateAppConfig = {
|
export type CreateAppConfig = {
|
||||||
/**
|
/**
|
||||||
* bla
|
* bla
|
||||||
*/
|
*/
|
||||||
connection?: Connection | { url: string };
|
connection?: Connection | { url: string };
|
||||||
initialConfig?: InitialModuleConfigs;
|
config?: InitialModuleConfigs;
|
||||||
options?: AppOptions;
|
options?: AppOptions;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -121,8 +130,8 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public connection: C,
|
public connection: C,
|
||||||
_initialConfig?: InitialModuleConfigs,
|
_config?: InitialModuleConfigs,
|
||||||
private options?: Options,
|
public options?: Options,
|
||||||
) {
|
) {
|
||||||
this.drivers = options?.drivers ?? {};
|
this.drivers = options?.drivers ?? {};
|
||||||
|
|
||||||
@@ -134,9 +143,13 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
|
|||||||
this.plugins.set(config.name, config);
|
this.plugins.set(config.name, config);
|
||||||
}
|
}
|
||||||
this.runPlugins("onBoot");
|
this.runPlugins("onBoot");
|
||||||
this.modules = new ModuleManager(connection, {
|
|
||||||
|
// use db manager by default
|
||||||
|
const Manager = this.mode === "db" ? DbModuleManager : ModuleManager;
|
||||||
|
|
||||||
|
this.modules = new Manager(connection, {
|
||||||
...(options?.manager ?? {}),
|
...(options?.manager ?? {}),
|
||||||
initial: _initialConfig,
|
initial: _config,
|
||||||
onUpdated: this.onUpdated.bind(this),
|
onUpdated: this.onUpdated.bind(this),
|
||||||
onFirstBoot: this.onFirstBoot.bind(this),
|
onFirstBoot: this.onFirstBoot.bind(this),
|
||||||
onServerInit: this.onServerInit.bind(this),
|
onServerInit: this.onServerInit.bind(this),
|
||||||
@@ -145,6 +158,14 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
|
|||||||
this.modules.ctx().emgr.registerEvents(AppEvents);
|
this.modules.ctx().emgr.registerEvents(AppEvents);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get mode() {
|
||||||
|
return this.options?.mode ?? "db";
|
||||||
|
}
|
||||||
|
|
||||||
|
isReadOnly() {
|
||||||
|
return this.mode === "code" || this.options?.readonly;
|
||||||
|
}
|
||||||
|
|
||||||
get emgr() {
|
get emgr() {
|
||||||
return this.modules.ctx().emgr;
|
return this.modules.ctx().emgr;
|
||||||
}
|
}
|
||||||
@@ -175,7 +196,7 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
|
|||||||
return results as any;
|
return results as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
async build(options?: { sync?: boolean; fetch?: boolean; forceBuild?: boolean }) {
|
async build(options?: { sync?: boolean; forceBuild?: boolean; [key: string]: any }) {
|
||||||
// prevent multiple concurrent builds
|
// prevent multiple concurrent builds
|
||||||
if (this._building) {
|
if (this._building) {
|
||||||
while (this._building) {
|
while (this._building) {
|
||||||
@@ -188,7 +209,7 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
|
|||||||
this._building = true;
|
this._building = true;
|
||||||
|
|
||||||
if (options?.sync) this.modules.ctx().flags.sync_required = true;
|
if (options?.sync) this.modules.ctx().flags.sync_required = true;
|
||||||
await this.modules.build({ fetch: options?.fetch });
|
await this.modules.build();
|
||||||
|
|
||||||
const { guard } = this.modules.ctx();
|
const { guard } = this.modules.ctx();
|
||||||
|
|
||||||
@@ -215,10 +236,6 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
|
|||||||
this._building = false;
|
this._building = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
mutateConfig<Module extends keyof Modules>(module: Module) {
|
|
||||||
return this.modules.mutateConfigSafe(module);
|
|
||||||
}
|
|
||||||
|
|
||||||
get server() {
|
get server() {
|
||||||
return this.modules.server;
|
return this.modules.server;
|
||||||
}
|
}
|
||||||
@@ -377,5 +394,5 @@ export function createApp(config: CreateAppConfig = {}) {
|
|||||||
throw new Error("Invalid connection");
|
throw new Error("Invalid connection");
|
||||||
}
|
}
|
||||||
|
|
||||||
return new App(config.connection, config.initialConfig, config.options);
|
return new App(config.connection, config.config, config.options);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,31 +65,21 @@ export async function createAdapterApp<Config extends BkndConfig = BkndConfig, A
|
|||||||
args?: Args,
|
args?: Args,
|
||||||
opts?: CreateAdapterAppOptions,
|
opts?: CreateAdapterAppOptions,
|
||||||
): Promise<App> {
|
): Promise<App> {
|
||||||
const id = opts?.id ?? "app";
|
const appConfig = await makeConfig(config, args);
|
||||||
let app = apps.get(id);
|
if (!appConfig.connection || !Connection.isConnection(appConfig.connection)) {
|
||||||
if (!app || opts?.force) {
|
let connection: Connection | undefined;
|
||||||
const appConfig = await makeConfig(config, args);
|
if (Connection.isConnection(config.connection)) {
|
||||||
if (!appConfig.connection || !Connection.isConnection(appConfig.connection)) {
|
connection = config.connection;
|
||||||
let connection: Connection | undefined;
|
} else {
|
||||||
if (Connection.isConnection(config.connection)) {
|
const sqlite = (await import("bknd/adapter/sqlite")).sqlite;
|
||||||
connection = config.connection;
|
const conf = appConfig.connection ?? { url: ":memory:" };
|
||||||
} else {
|
connection = sqlite(conf) as any;
|
||||||
const sqlite = (await import("bknd/adapter/sqlite")).sqlite;
|
$console.info(`Using ${connection!.name} connection`, conf.url);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
appConfig.connection = connection;
|
||||||
}
|
}
|
||||||
|
|
||||||
return app;
|
return App.create(appConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createFrameworkApp<Args = DefaultArgs>(
|
export async function createFrameworkApp<Args = DefaultArgs>(
|
||||||
|
|||||||
@@ -4,3 +4,5 @@ export interface Serializable<Class, Json extends object = object> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type MaybePromise<T> = T | Promise<T>;
|
export type MaybePromise<T> = T | Promise<T>;
|
||||||
|
|
||||||
|
export type PartialRec<T> = { [P in keyof T]?: PartialRec<T[P]> };
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export class DataController extends Controller {
|
|||||||
"/sync",
|
"/sync",
|
||||||
permission(DataPermissions.databaseSync),
|
permission(DataPermissions.databaseSync),
|
||||||
mcpTool("data_sync", {
|
mcpTool("data_sync", {
|
||||||
|
// @todo: should be removed if readonly
|
||||||
annotations: {
|
annotations: {
|
||||||
destructiveHint: true,
|
destructiveHint: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export {
|
|||||||
type ModuleBuildContext,
|
type ModuleBuildContext,
|
||||||
type InitialModuleConfigs,
|
type InitialModuleConfigs,
|
||||||
ModuleManagerEvents,
|
ModuleManagerEvents,
|
||||||
} from "./modules/ModuleManager";
|
} from "./modules/manager/ModuleManager";
|
||||||
|
|
||||||
export type { ServerEnv } from "modules/Controller";
|
export type { ServerEnv } from "modules/Controller";
|
||||||
export type { BkndConfig } from "bknd/adapter";
|
export type { BkndConfig } from "bknd/adapter";
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export {
|
|||||||
type ModuleSchemas,
|
type ModuleSchemas,
|
||||||
MODULE_NAMES,
|
MODULE_NAMES,
|
||||||
type ModuleKey,
|
type ModuleKey,
|
||||||
} from "./ModuleManager";
|
} from "./manager/ModuleManager";
|
||||||
export type { ModuleBuildContext } from "./Module";
|
export type { ModuleBuildContext } from "./Module";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -1,89 +1,28 @@
|
|||||||
import { mark, stripMark, $console, s, objectEach, transformObject, McpServer } from "bknd/utils";
|
import { mark, stripMark, $console, s } from "bknd/utils";
|
||||||
import { DebugLogger } from "core/utils/DebugLogger";
|
|
||||||
import { Guard } from "auth/authorize/Guard";
|
|
||||||
import { env } from "core/env";
|
|
||||||
import { BkndError } from "core/errors";
|
import { BkndError } from "core/errors";
|
||||||
import { EventManager, Event } from "core/events";
|
|
||||||
import * as $diff from "core/object/diff";
|
import * as $diff from "core/object/diff";
|
||||||
import type { Connection } from "data/connection";
|
import type { Connection } from "data/connection";
|
||||||
import { EntityManager } from "data/entities/EntityManager";
|
import { EntityManager } from "data/entities/EntityManager";
|
||||||
import * as proto from "data/prototype";
|
import * as proto from "data/prototype";
|
||||||
import { TransformPersistFailedException } from "data/errors";
|
import { TransformPersistFailedException } from "data/errors";
|
||||||
import { Hono } from "hono";
|
|
||||||
import type { Kysely } from "kysely";
|
import type { Kysely } from "kysely";
|
||||||
import { mergeWith } from "lodash-es";
|
import { mergeWith } from "lodash-es";
|
||||||
import { CURRENT_VERSION, TABLE_NAME, migrate } from "modules/migrations";
|
import { CURRENT_VERSION, TABLE_NAME, migrate } from "modules/migrations";
|
||||||
import { AppServer } from "modules/server/AppServer";
|
import { Module, type ModuleBuildContext } from "../Module";
|
||||||
import { AppAuth } from "../auth/AppAuth";
|
import {
|
||||||
import { AppData } from "../data/AppData";
|
type InitialModuleConfigs,
|
||||||
import { AppFlows } from "../flows/AppFlows";
|
type ModuleConfigs,
|
||||||
import { AppMedia } from "../media/AppMedia";
|
type Modules,
|
||||||
import type { ServerEnv } from "./Controller";
|
type ModuleKey,
|
||||||
import { Module, type ModuleBuildContext } from "./Module";
|
getDefaultSchema,
|
||||||
import { ModuleHelper } from "./ModuleHelper";
|
getDefaultConfig,
|
||||||
|
ModuleManager,
|
||||||
|
ModuleManagerConfigUpdateEvent,
|
||||||
|
type ModuleManagerOptions,
|
||||||
|
} from "./ModuleManager";
|
||||||
|
|
||||||
export type { ModuleBuildContext };
|
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<ModuleSchemas[K]>;
|
|
||||||
};
|
|
||||||
type PartialRec<T> = { [P in keyof T]?: PartialRec<T[P]> };
|
|
||||||
|
|
||||||
export type InitialModuleConfigs =
|
|
||||||
| ({
|
|
||||||
version: number;
|
|
||||||
} & ModuleConfigs)
|
|
||||||
| PartialRec<ModuleConfigs>;
|
|
||||||
|
|
||||||
enum Verbosity {
|
|
||||||
silent = 0,
|
|
||||||
error = 1,
|
|
||||||
log = 2,
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ModuleManagerOptions = {
|
|
||||||
initial?: InitialModuleConfigs;
|
|
||||||
eventManager?: EventManager<any>;
|
|
||||||
onUpdated?: <Module extends keyof Modules>(
|
|
||||||
module: Module,
|
|
||||||
config: ModuleConfigs[Module],
|
|
||||||
) => Promise<void>;
|
|
||||||
// triggered when no config table existed
|
|
||||||
onFirstBoot?: () => Promise<void>;
|
|
||||||
// base path for the hono instance
|
|
||||||
basePath?: string;
|
|
||||||
// callback after server was created
|
|
||||||
onServerInit?: (server: Hono<ServerEnv>) => 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<void>;
|
|
||||||
// called right after modules are built, before finish
|
|
||||||
onModulesBuilt?: (ctx: ModuleBuildContext) => Promise<void>;
|
|
||||||
/** @deprecated */
|
|
||||||
verbosity?: Verbosity;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ConfigTable<Json = ModuleConfigs> = {
|
export type ConfigTable<Json = ModuleConfigs> = {
|
||||||
id?: number;
|
id?: number;
|
||||||
version: number;
|
version: number;
|
||||||
@@ -116,99 +55,41 @@ interface T_INTERNAL_EM {
|
|||||||
__bknd: ConfigTable2;
|
__bknd: ConfigTable2;
|
||||||
}
|
}
|
||||||
|
|
||||||
const debug_modules = env("modules_debug");
|
|
||||||
|
|
||||||
abstract class ModuleManagerEvent<A = {}> 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 old diffs on upgrade
|
||||||
// @todo: cleanup multiple backups on upgrade
|
// @todo: cleanup multiple backups on upgrade
|
||||||
export class ModuleManager {
|
export class DbModuleManager extends ModuleManager {
|
||||||
static Events = ModuleManagerEvents;
|
|
||||||
|
|
||||||
protected modules: Modules;
|
|
||||||
// internal em for __bknd config table
|
// internal em for __bknd config table
|
||||||
__em!: EntityManager<T_INTERNAL_EM>;
|
__em!: EntityManager<T_INTERNAL_EM>;
|
||||||
// ctx for modules
|
|
||||||
em!: EntityManager;
|
|
||||||
server!: Hono<ServerEnv>;
|
|
||||||
emgr!: EventManager;
|
|
||||||
guard!: Guard;
|
|
||||||
mcp!: ModuleBuildContext["mcp"];
|
|
||||||
|
|
||||||
private _version: number = 0;
|
private _version: number = 0;
|
||||||
private _built = false;
|
|
||||||
private readonly _booted_with?: "provided" | "partial";
|
private readonly _booted_with?: "provided" | "partial";
|
||||||
private _stable_configs: ModuleConfigs | undefined;
|
private _stable_configs: ModuleConfigs | undefined;
|
||||||
|
|
||||||
private logger: DebugLogger;
|
constructor(connection: Connection, options?: Partial<ModuleManagerOptions>) {
|
||||||
|
let initial = {} as InitialModuleConfigs;
|
||||||
constructor(
|
let booted_with = "partial" as any;
|
||||||
private readonly connection: Connection,
|
let version = 0;
|
||||||
private options?: Partial<ModuleManagerOptions>,
|
|
||||||
) {
|
|
||||||
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<ModuleConfigs>;
|
|
||||||
|
|
||||||
if (options?.initial) {
|
if (options?.initial) {
|
||||||
if ("version" in options.initial) {
|
if ("version" in options.initial && options.initial.version) {
|
||||||
const { version, ...initialConfig } = options.initial;
|
const { version: _v, ...initialConfig } = options.initial;
|
||||||
this._version = version;
|
version = _v as number;
|
||||||
initial = stripMark(initialConfig);
|
initial = stripMark(initialConfig) as any;
|
||||||
|
|
||||||
this._booted_with = "provided";
|
booted_with = "provided";
|
||||||
} else {
|
} else {
|
||||||
initial = mergeWith(getDefaultConfig(), options.initial);
|
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.logger.log("booted with", this._booted_with);
|
||||||
|
|
||||||
this.createModules(initial);
|
|
||||||
}
|
|
||||||
|
|
||||||
private createModules(initial: Partial<ModuleConfigs>) {
|
|
||||||
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
|
* It's called everytime a module's config is updated in SchemaObject
|
||||||
* Needs to rebuild modules and save to database
|
* 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) {
|
if (this.options?.onUpdated) {
|
||||||
await this.options.onUpdated(key as any, config);
|
await this.options.onUpdated(key as any, config);
|
||||||
} else {
|
} else {
|
||||||
@@ -250,55 +131,6 @@ export class ModuleManager {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private rebuildServer() {
|
|
||||||
this.server = new Hono<ServerEnv>();
|
|
||||||
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<ConfigTable | undefined> {
|
private async fetch(): Promise<ConfigTable | undefined> {
|
||||||
this.logger.context("fetch").log("fetching");
|
this.logger.context("fetch").log("fetching");
|
||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
@@ -463,22 +295,7 @@ export class ModuleManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private setConfigs(configs: ModuleConfigs): void {
|
override async build(opts?: { fetch?: boolean }) {
|
||||||
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 }) {
|
|
||||||
this.logger.context("build").log("version", this.version());
|
this.logger.context("build").log("version", this.version());
|
||||||
await this.ctx().connection.init();
|
await this.ctx().connection.init();
|
||||||
|
|
||||||
@@ -521,11 +338,13 @@ export class ModuleManager {
|
|||||||
this.logger.log("migrated to", _version);
|
this.logger.log("migrated to", _version);
|
||||||
$console.log("Migrated config from", version_before, "to", this.version());
|
$console.log("Migrated config from", version_before, "to", this.version());
|
||||||
|
|
||||||
this.createModules(_configs);
|
// @ts-expect-error
|
||||||
|
this.setConfigs(_configs);
|
||||||
await this.buildModules();
|
await this.buildModules();
|
||||||
} else {
|
} else {
|
||||||
this.logger.log("version is current", this.version());
|
this.logger.log("version is current", this.version());
|
||||||
this.createModules(result.json);
|
|
||||||
|
this.setConfigs(result.json);
|
||||||
await this.buildModules();
|
await this.buildModules();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -544,7 +363,7 @@ export class ModuleManager {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async buildModules(options?: { graceful?: boolean; ignoreFlags?: boolean }) {
|
protected override async buildModules(options?: { graceful?: boolean; ignoreFlags?: boolean }) {
|
||||||
const state = {
|
const state = {
|
||||||
built: false,
|
built: false,
|
||||||
modules: [] as ModuleKey[],
|
modules: [] as ModuleKey[],
|
||||||
@@ -686,71 +505,4 @@ export class ModuleManager {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get<K extends keyof Modules>(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;
|
|
||||||
}
|
}
|
||||||
354
app/src/modules/manager/ModuleManager.ts
Normal file
354
app/src/modules/manager/ModuleManager.ts
Normal file
@@ -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<ModuleSchemas[K]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InitialModuleConfigs = {
|
||||||
|
version?: number;
|
||||||
|
} & PartialRec<ModuleConfigs>;
|
||||||
|
|
||||||
|
enum Verbosity {
|
||||||
|
silent = 0,
|
||||||
|
error = 1,
|
||||||
|
log = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModuleManagerOptions = {
|
||||||
|
initial?: InitialModuleConfigs;
|
||||||
|
eventManager?: EventManager<any>;
|
||||||
|
onUpdated?: <Module extends keyof Modules>(
|
||||||
|
module: Module,
|
||||||
|
config: ModuleConfigs[Module],
|
||||||
|
) => Promise<void>;
|
||||||
|
// triggered when no config table existed
|
||||||
|
onFirstBoot?: () => Promise<void>;
|
||||||
|
// base path for the hono instance
|
||||||
|
basePath?: string;
|
||||||
|
// callback after server was created
|
||||||
|
onServerInit?: (server: Hono<ServerEnv>) => 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<void>;
|
||||||
|
// called right after modules are built, before finish
|
||||||
|
onModulesBuilt?: (ctx: ModuleBuildContext) => Promise<void>;
|
||||||
|
/** @deprecated */
|
||||||
|
verbosity?: Verbosity;
|
||||||
|
};
|
||||||
|
|
||||||
|
const debug_modules = env("modules_debug");
|
||||||
|
|
||||||
|
abstract class ModuleManagerEvent<A = {}> 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<ServerEnv>;
|
||||||
|
emgr!: EventManager;
|
||||||
|
guard!: Guard;
|
||||||
|
mcp!: ModuleBuildContext["mcp"];
|
||||||
|
|
||||||
|
protected _built = false;
|
||||||
|
|
||||||
|
protected logger: DebugLogger;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
protected readonly connection: Connection,
|
||||||
|
protected options?: Partial<ModuleManagerOptions>,
|
||||||
|
) {
|
||||||
|
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<ModuleConfigs>) {
|
||||||
|
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<ServerEnv>();
|
||||||
|
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<K extends keyof Modules>(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;
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
type McpSchema,
|
type McpSchema,
|
||||||
type SchemaWithMcpOptions,
|
type SchemaWithMcpOptions,
|
||||||
} from "./McpSchemaHelper";
|
} from "./McpSchemaHelper";
|
||||||
import type { Module } from "modules/Module";
|
|
||||||
|
|
||||||
export interface ObjectToolSchemaOptions extends s.IObjectOptions, SchemaWithMcpOptions {}
|
export interface ObjectToolSchemaOptions extends s.IObjectOptions, SchemaWithMcpOptions {}
|
||||||
|
|
||||||
@@ -79,6 +78,7 @@ export class ObjectToolSchema<
|
|||||||
|
|
||||||
private toolUpdate(node: s.Node<ObjectToolSchema>) {
|
private toolUpdate(node: s.Node<ObjectToolSchema>) {
|
||||||
const schema = this.mcp.cleanSchema;
|
const schema = this.mcp.cleanSchema;
|
||||||
|
|
||||||
return new Tool(
|
return new Tool(
|
||||||
[this.mcp.name, "update"].join("_"),
|
[this.mcp.name, "update"].join("_"),
|
||||||
{
|
{
|
||||||
@@ -97,11 +97,12 @@ export class ObjectToolSchema<
|
|||||||
async (params, ctx: AppToolHandlerCtx) => {
|
async (params, ctx: AppToolHandlerCtx) => {
|
||||||
const { full, value, return_config } = params;
|
const { full, value, return_config } = params;
|
||||||
const [module_name] = node.instancePath;
|
const [module_name] = node.instancePath;
|
||||||
|
const manager = this.mcp.getManager(ctx);
|
||||||
|
|
||||||
if (full) {
|
if (full) {
|
||||||
await ctx.context.app.mutateConfig(module_name as any).set(value);
|
await manager.mutateConfigSafe(module_name as any).set(value);
|
||||||
} else {
|
} 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;
|
let config: any = undefined;
|
||||||
|
|||||||
@@ -129,13 +129,14 @@ export class RecordToolSchema<
|
|||||||
const configs = ctx.context.app.toJSON(true);
|
const configs = ctx.context.app.toJSON(true);
|
||||||
const config = getPath(configs, node.instancePath);
|
const config = getPath(configs, node.instancePath);
|
||||||
const [module_name, ...rest] = node.instancePath;
|
const [module_name, ...rest] = node.instancePath;
|
||||||
|
const manager = this.mcp.getManager(ctx);
|
||||||
|
|
||||||
if (params.key in config) {
|
if (params.key in config) {
|
||||||
throw new Error(`Key "${params.key}" already exists in config`);
|
throw new Error(`Key "${params.key}" already exists in config`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.context.app
|
await manager
|
||||||
.mutateConfig(module_name as any)
|
.mutateConfigSafe(module_name as any)
|
||||||
.patch([...rest, params.key], params.value);
|
.patch([...rest, params.key], params.value);
|
||||||
|
|
||||||
const newConfig = getPath(ctx.context.app.toJSON(), node.instancePath);
|
const newConfig = getPath(ctx.context.app.toJSON(), node.instancePath);
|
||||||
@@ -175,13 +176,14 @@ export class RecordToolSchema<
|
|||||||
const configs = ctx.context.app.toJSON(true);
|
const configs = ctx.context.app.toJSON(true);
|
||||||
const config = getPath(configs, node.instancePath);
|
const config = getPath(configs, node.instancePath);
|
||||||
const [module_name, ...rest] = node.instancePath;
|
const [module_name, ...rest] = node.instancePath;
|
||||||
|
const manager = this.mcp.getManager(ctx);
|
||||||
|
|
||||||
if (!(params.key in config)) {
|
if (!(params.key in config)) {
|
||||||
throw new Error(`Key "${params.key}" not found in config`);
|
throw new Error(`Key "${params.key}" not found in config`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.context.app
|
await manager
|
||||||
.mutateConfig(module_name as any)
|
.mutateConfigSafe(module_name as any)
|
||||||
.patch([...rest, params.key], params.value);
|
.patch([...rest, params.key], params.value);
|
||||||
|
|
||||||
const newConfig = getPath(ctx.context.app.toJSON(), node.instancePath);
|
const newConfig = getPath(ctx.context.app.toJSON(), node.instancePath);
|
||||||
@@ -220,13 +222,14 @@ export class RecordToolSchema<
|
|||||||
const configs = ctx.context.app.toJSON(true);
|
const configs = ctx.context.app.toJSON(true);
|
||||||
const config = getPath(configs, node.instancePath);
|
const config = getPath(configs, node.instancePath);
|
||||||
const [module_name, ...rest] = node.instancePath;
|
const [module_name, ...rest] = node.instancePath;
|
||||||
|
const manager = this.mcp.getManager(ctx);
|
||||||
|
|
||||||
if (!(params.key in config)) {
|
if (!(params.key in config)) {
|
||||||
throw new Error(`Key "${params.key}" not found in config`);
|
throw new Error(`Key "${params.key}" not found in config`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.context.app
|
await manager
|
||||||
.mutateConfig(module_name as any)
|
.mutateConfigSafe(module_name as any)
|
||||||
.remove([...rest, params.key].join("."));
|
.remove([...rest, params.key].join("."));
|
||||||
|
|
||||||
const newConfig = getPath(ctx.context.app.toJSON(), node.instancePath);
|
const newConfig = getPath(ctx.context.app.toJSON(), node.instancePath);
|
||||||
|
|||||||
@@ -58,8 +58,9 @@ export const $schema = <
|
|||||||
async (params, ctx: AppToolHandlerCtx) => {
|
async (params, ctx: AppToolHandlerCtx) => {
|
||||||
const { value, return_config, secrets } = params;
|
const { value, return_config, secrets } = params;
|
||||||
const [module_name, ...rest] = node.instancePath;
|
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;
|
let config: any = undefined;
|
||||||
if (return_config) {
|
if (return_config) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from "bknd/utils";
|
} from "bknd/utils";
|
||||||
import type { ModuleBuildContext } from "modules";
|
import type { ModuleBuildContext } from "modules";
|
||||||
import { excludePropertyTypes, rescursiveClean } from "./utils";
|
import { excludePropertyTypes, rescursiveClean } from "./utils";
|
||||||
|
import type { DbModuleManager } from "modules/manager/DbModuleManager";
|
||||||
|
|
||||||
export const mcpSchemaSymbol = Symbol.for("bknd-mcp-schema");
|
export const mcpSchemaSymbol = Symbol.for("bknd-mcp-schema");
|
||||||
|
|
||||||
@@ -74,4 +75,13 @@ export class McpSchemaHelper<AdditionalOptions = {}> {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getManager(ctx: AppToolHandlerCtx): DbModuleManager {
|
||||||
|
const manager = ctx.context.app.modules;
|
||||||
|
if ("mutateConfigSafe" in manager) {
|
||||||
|
return manager as DbModuleManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Manager not found");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,9 +19,11 @@ export function getSystemMcp(app: App) {
|
|||||||
].sort((a, b) => a.name.localeCompare(b.name));
|
].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
// tools from app schema
|
// tools from app schema
|
||||||
tools.push(
|
if (!app.isReadOnly()) {
|
||||||
...nodes.flatMap((n) => n.schema.getTools(n)).sort((a, b) => a.name.localeCompare(b.name)),
|
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];
|
const resources = [...middlewareServer.resources, ...app.modules.ctx().mcp.resources];
|
||||||
|
|
||||||
|
|||||||
@@ -26,11 +26,12 @@ import {
|
|||||||
type ModuleConfigs,
|
type ModuleConfigs,
|
||||||
type ModuleSchemas,
|
type ModuleSchemas,
|
||||||
type ModuleKey,
|
type ModuleKey,
|
||||||
} from "modules/ModuleManager";
|
} from "modules/manager/ModuleManager";
|
||||||
import * as SystemPermissions from "modules/permissions";
|
import * as SystemPermissions from "modules/permissions";
|
||||||
import { getVersion } from "core/env";
|
import { getVersion } from "core/env";
|
||||||
import type { Module } from "modules/Module";
|
import type { Module } from "modules/Module";
|
||||||
import { getSystemMcp } from "modules/mcp/system-mcp";
|
import { getSystemMcp } from "modules/mcp/system-mcp";
|
||||||
|
import { DbModuleManager } from "modules/manager/DbModuleManager";
|
||||||
|
|
||||||
export type ConfigUpdate<Key extends ModuleKey = ModuleKey> = {
|
export type ConfigUpdate<Key extends ModuleKey = ModuleKey> = {
|
||||||
success: true;
|
success: true;
|
||||||
@@ -43,6 +44,7 @@ export type ConfigUpdateResponse<Key extends ModuleKey = ModuleKey> =
|
|||||||
export type SchemaResponse = {
|
export type SchemaResponse = {
|
||||||
version: string;
|
version: string;
|
||||||
schema: ModuleSchemas;
|
schema: ModuleSchemas;
|
||||||
|
readonly: boolean;
|
||||||
config: ModuleConfigs;
|
config: ModuleConfigs;
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
};
|
};
|
||||||
@@ -109,22 +111,163 @@ export class SystemController extends Controller {
|
|||||||
private registerConfigController(client: Hono<any>): void {
|
private registerConfigController(client: Hono<any>): void {
|
||||||
const { permission } = this.middlewares;
|
const { permission } = this.middlewares;
|
||||||
// don't add auth again, it's already added in getController
|
// 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(
|
hono.get(
|
||||||
"/raw",
|
"/raw",
|
||||||
describeRoute({
|
describeRoute({
|
||||||
summary: "Get the raw config",
|
summary: "Get the raw config",
|
||||||
tags: ["system"],
|
tags: ["system"],
|
||||||
}),
|
}),
|
||||||
permission([SystemPermissions.configReadSecrets]),
|
permission([SystemPermissions.configReadSecrets]),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
// @ts-expect-error "fetch" is private
|
// @ts-expect-error "fetch" is private
|
||||||
return c.json(await this.app.modules.fetch());
|
return c.json(await this.app.modules.fetch());
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
async function handleConfigUpdateResponse(
|
||||||
|
c: Context<any>,
|
||||||
|
cb: () => Promise<ConfigUpdate>,
|
||||||
|
) {
|
||||||
|
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(
|
hono.get(
|
||||||
"/:module?",
|
"/:module?",
|
||||||
@@ -160,124 +303,6 @@ export class SystemController extends Controller {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
async function handleConfigUpdateResponse(c: Context<any>, cb: () => Promise<ConfigUpdate>) {
|
|
||||||
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);
|
client.route("/config", hono);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,6 +332,7 @@ export class SystemController extends Controller {
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const module = c.req.param("module") as ModuleKey | undefined;
|
const module = c.req.param("module") as ModuleKey | undefined;
|
||||||
const { config, secrets, fresh } = c.req.valid("query");
|
const { config, secrets, fresh } = c.req.valid("query");
|
||||||
|
const readonly = this.app.isReadOnly();
|
||||||
|
|
||||||
config && this.ctx.guard.throwUnlessGranted(SystemPermissions.configRead, c);
|
config && this.ctx.guard.throwUnlessGranted(SystemPermissions.configRead, c);
|
||||||
secrets && this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets, c);
|
secrets && this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets, c);
|
||||||
@@ -321,6 +347,7 @@ export class SystemController extends Controller {
|
|||||||
if (module) {
|
if (module) {
|
||||||
return c.json({
|
return c.json({
|
||||||
module,
|
module,
|
||||||
|
readonly,
|
||||||
version,
|
version,
|
||||||
schema: schema[module],
|
schema: schema[module],
|
||||||
config: config ? this.app.module[module].toJSON(secrets) : undefined,
|
config: config ? this.app.module[module].toJSON(secrets) : undefined,
|
||||||
@@ -330,6 +357,7 @@ export class SystemController extends Controller {
|
|||||||
return c.json({
|
return c.json({
|
||||||
module,
|
module,
|
||||||
version,
|
version,
|
||||||
|
readonly,
|
||||||
schema,
|
schema,
|
||||||
config: config ? this.app.toJSON(secrets) : undefined,
|
config: config ? this.app.toJSON(secrets) : undefined,
|
||||||
permissions: this.app.modules.ctx().guard.getPermissionNames(),
|
permissions: this.app.modules.ctx().guard.getPermissionNames(),
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import type { ModuleConfigs, ModuleSchemas } from "modules";
|
import type { ModuleConfigs, ModuleSchemas } from "modules";
|
||||||
import { getDefaultConfig, getDefaultSchema } from "modules/ModuleManager";
|
import { getDefaultConfig, getDefaultSchema } from "modules/manager/ModuleManager";
|
||||||
import { createContext, startTransition, useContext, useEffect, useRef, useState } from "react";
|
import {
|
||||||
|
createContext,
|
||||||
|
startTransition,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
import { useApi } from "ui/client";
|
import { useApi } from "ui/client";
|
||||||
import { type TSchemaActions, getSchemaActions } from "./schema/actions";
|
import { type TSchemaActions, getSchemaActions } from "./schema/actions";
|
||||||
import { AppReduced } from "./utils/AppReduced";
|
import { AppReduced } from "./utils/AppReduced";
|
||||||
@@ -15,6 +23,7 @@ export type BkndAdminOptions = {
|
|||||||
};
|
};
|
||||||
type BkndContext = {
|
type BkndContext = {
|
||||||
version: number;
|
version: number;
|
||||||
|
readonly: boolean;
|
||||||
schema: ModuleSchemas;
|
schema: ModuleSchemas;
|
||||||
config: ModuleConfigs;
|
config: ModuleConfigs;
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
@@ -48,7 +57,12 @@ export function BkndProvider({
|
|||||||
}) {
|
}) {
|
||||||
const [withSecrets, setWithSecrets] = useState<boolean>(includeSecrets);
|
const [withSecrets, setWithSecrets] = useState<boolean>(includeSecrets);
|
||||||
const [schema, setSchema] =
|
const [schema, setSchema] =
|
||||||
useState<Pick<BkndContext, "version" | "schema" | "config" | "permissions" | "fallback">>();
|
useState<
|
||||||
|
Pick<
|
||||||
|
BkndContext,
|
||||||
|
"version" | "schema" | "config" | "permissions" | "fallback" | "readonly"
|
||||||
|
>
|
||||||
|
>();
|
||||||
const [fetched, setFetched] = useState(false);
|
const [fetched, setFetched] = useState(false);
|
||||||
const [error, setError] = useState<boolean>();
|
const [error, setError] = useState<boolean>();
|
||||||
const errorShown = useRef<boolean>(false);
|
const errorShown = useRef<boolean>(false);
|
||||||
@@ -97,6 +111,7 @@ export function BkndProvider({
|
|||||||
? res.body
|
? res.body
|
||||||
: ({
|
: ({
|
||||||
version: 0,
|
version: 0,
|
||||||
|
mode: "db",
|
||||||
schema: getDefaultSchema(),
|
schema: getDefaultSchema(),
|
||||||
config: getDefaultConfig(),
|
config: getDefaultConfig(),
|
||||||
permissions: [],
|
permissions: [],
|
||||||
@@ -173,3 +188,8 @@ export function useBkndOptions(): BkndAdminOptions {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SchemaEditable({ children }: { children: ReactNode }) {
|
||||||
|
const { readonly } = useBknd();
|
||||||
|
return !readonly ? children : null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export { BkndProvider, type BkndAdminOptions, useBknd } from "./BkndProvider";
|
export { BkndProvider, type BkndAdminOptions, useBknd, SchemaEditable } from "./BkndProvider";
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
|||||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||||
import { routes, useNavigate, useRouteNavigate } from "ui/lib/routes";
|
import { routes, useNavigate, useRouteNavigate } from "ui/lib/routes";
|
||||||
import { testIds } from "ui/lib/config";
|
import { testIds } from "ui/lib/config";
|
||||||
|
import { SchemaEditable, useBknd } from "ui/client/bknd";
|
||||||
|
|
||||||
export function DataRoot({ children }) {
|
export function DataRoot({ children }) {
|
||||||
// @todo: settings routes should be centralized
|
// @todo: settings routes should be centralized
|
||||||
@@ -73,9 +74,11 @@ export function DataRoot({ children }) {
|
|||||||
value={context}
|
value={context}
|
||||||
onChange={handleSegmentChange}
|
onChange={handleSegmentChange}
|
||||||
/>
|
/>
|
||||||
<Tooltip label="New Entity">
|
<SchemaEditable>
|
||||||
<IconButton Icon={TbDatabasePlus} onClick={$data.modals.createEntity} />
|
<Tooltip label="New Entity">
|
||||||
</Tooltip>
|
<IconButton Icon={TbDatabasePlus} onClick={$data.modals.createEntity} />
|
||||||
|
</Tooltip>
|
||||||
|
</SchemaEditable>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -254,11 +257,26 @@ export function DataEmpty() {
|
|||||||
useBrowserTitle(["Data"]);
|
useBrowserTitle(["Data"]);
|
||||||
const [navigate] = useNavigate();
|
const [navigate] = useNavigate();
|
||||||
const { $data } = useBkndData();
|
const { $data } = useBkndData();
|
||||||
|
const { readonly } = useBknd();
|
||||||
|
|
||||||
function handleButtonClick() {
|
function handleButtonClick() {
|
||||||
navigate(routes.data.schema.root());
|
navigate(routes.data.schema.root());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (readonly) {
|
||||||
|
return (
|
||||||
|
<Empty
|
||||||
|
Icon={IconDatabase}
|
||||||
|
title="No entity selected"
|
||||||
|
description="Please select an entity from the left sidebar."
|
||||||
|
primary={{
|
||||||
|
children: "Go to schema",
|
||||||
|
onClick: handleButtonClick,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Empty
|
<Empty
|
||||||
Icon={IconDatabase}
|
Icon={IconDatabase}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { fieldSpecs } from "ui/modules/data/components/fields-specs";
|
|||||||
import { extractSchema } from "../settings/utils/schema";
|
import { extractSchema } from "../settings/utils/schema";
|
||||||
import { EntityFieldsForm, type EntityFieldsFormRef } from "./forms/entity.fields.form";
|
import { EntityFieldsForm, type EntityFieldsFormRef } from "./forms/entity.fields.form";
|
||||||
import { RoutePathStateProvider } from "ui/hooks/use-route-path-state";
|
import { RoutePathStateProvider } from "ui/hooks/use-route-path-state";
|
||||||
|
import { SchemaEditable, useBknd } from "ui/client/bknd";
|
||||||
|
|
||||||
export function DataSchemaEntity({ params }) {
|
export function DataSchemaEntity({ params }) {
|
||||||
const { $data } = useBkndData();
|
const { $data } = useBkndData();
|
||||||
@@ -67,29 +68,31 @@ export function DataSchemaEntity({ params }) {
|
|||||||
>
|
>
|
||||||
<IconButton Icon={TbDots} />
|
<IconButton Icon={TbDots} />
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
<Dropdown
|
<SchemaEditable>
|
||||||
items={[
|
<Dropdown
|
||||||
{
|
items={[
|
||||||
icon: TbCirclesRelation,
|
{
|
||||||
label: "Add relation",
|
icon: TbCirclesRelation,
|
||||||
onClick: () => $data.modals.createRelation(entity.name),
|
label: "Add relation",
|
||||||
},
|
onClick: () => $data.modals.createRelation(entity.name),
|
||||||
{
|
},
|
||||||
icon: TbPhoto,
|
{
|
||||||
label: "Add media",
|
icon: TbPhoto,
|
||||||
onClick: () => $data.modals.createMedia(entity.name),
|
label: "Add media",
|
||||||
},
|
onClick: () => $data.modals.createMedia(entity.name),
|
||||||
() => <div className="h-px my-1 w-full bg-primary/5" />,
|
},
|
||||||
{
|
() => <div className="h-px my-1 w-full bg-primary/5" />,
|
||||||
icon: TbDatabasePlus,
|
{
|
||||||
label: "Create Entity",
|
icon: TbDatabasePlus,
|
||||||
onClick: () => $data.modals.createEntity(),
|
label: "Create Entity",
|
||||||
},
|
onClick: () => $data.modals.createEntity(),
|
||||||
]}
|
},
|
||||||
position="bottom-end"
|
]}
|
||||||
>
|
position="bottom-end"
|
||||||
<Button IconRight={TbPlus}>Add</Button>
|
>
|
||||||
</Dropdown>
|
<Button IconRight={TbPlus}>Add</Button>
|
||||||
|
</Dropdown>
|
||||||
|
</SchemaEditable>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
className="pl-3"
|
className="pl-3"
|
||||||
@@ -149,6 +152,7 @@ const Fields = ({ entity }: { entity: Entity }) => {
|
|||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [updates, setUpdates] = useState(0);
|
const [updates, setUpdates] = useState(0);
|
||||||
const { actions, $data, config } = useBkndData();
|
const { actions, $data, config } = useBkndData();
|
||||||
|
const { readonly } = useBknd();
|
||||||
const [res, setRes] = useState<any>();
|
const [res, setRes] = useState<any>();
|
||||||
const ref = useRef<EntityFieldsFormRef>(null);
|
const ref = useRef<EntityFieldsFormRef>(null);
|
||||||
async function handleUpdate() {
|
async function handleUpdate() {
|
||||||
@@ -169,7 +173,7 @@ const Fields = ({ entity }: { entity: Entity }) => {
|
|||||||
title="Fields"
|
title="Fields"
|
||||||
ActiveIcon={IconAlignJustified}
|
ActiveIcon={IconAlignJustified}
|
||||||
renderHeaderRight={({ open }) =>
|
renderHeaderRight={({ open }) =>
|
||||||
open ? (
|
open && !readonly ? (
|
||||||
<Button variant="primary" disabled={!open} onClick={handleUpdate}>
|
<Button variant="primary" disabled={!open} onClick={handleUpdate}>
|
||||||
Update
|
Update
|
||||||
</Button>
|
</Button>
|
||||||
@@ -181,11 +185,12 @@ const Fields = ({ entity }: { entity: Entity }) => {
|
|||||||
<div className="animate-fade-in absolute w-full h-full top-0 bottom-0 left-0 right-0 bg-background/65 z-50" />
|
<div className="animate-fade-in absolute w-full h-full top-0 bottom-0 left-0 right-0 bg-background/65 z-50" />
|
||||||
)}
|
)}
|
||||||
<EntityFieldsForm
|
<EntityFieldsForm
|
||||||
|
readonly={readonly}
|
||||||
routePattern={`/entity/${entity.name}/fields/:sub?`}
|
routePattern={`/entity/${entity.name}/fields/:sub?`}
|
||||||
fields={initialFields}
|
fields={initialFields}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
key={String(updates)}
|
key={String(updates)}
|
||||||
sortable
|
sortable={!readonly}
|
||||||
additionalFieldTypes={fieldSpecs
|
additionalFieldTypes={fieldSpecs
|
||||||
.filter((f) => ["relation", "media"].includes(f.type))
|
.filter((f) => ["relation", "media"].includes(f.type))
|
||||||
.map((i) => ({
|
.map((i) => ({
|
||||||
@@ -205,7 +210,7 @@ const Fields = ({ entity }: { entity: Entity }) => {
|
|||||||
isNew={false}
|
isNew={false}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isDebug() && (
|
{isDebug() && !readonly && (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex flex-row gap-1 justify-center">
|
<div className="flex flex-row gap-1 justify-center">
|
||||||
<Button size="small" onClick={() => setRes(ref.current?.isValid())}>
|
<Button size="small" onClick={() => setRes(ref.current?.isValid())}>
|
||||||
@@ -237,6 +242,7 @@ const BasicSettings = ({ entity }: { entity: Entity }) => {
|
|||||||
const d = useBkndData();
|
const d = useBkndData();
|
||||||
const config = d.entities?.[entity.name]?.config;
|
const config = d.entities?.[entity.name]?.config;
|
||||||
const formRef = useRef<JsonSchemaFormRef>(null);
|
const formRef = useRef<JsonSchemaFormRef>(null);
|
||||||
|
const { readonly } = useBknd();
|
||||||
|
|
||||||
const schema = cloneDeep(
|
const schema = cloneDeep(
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -264,7 +270,7 @@ const BasicSettings = ({ entity }: { entity: Entity }) => {
|
|||||||
title="Settings"
|
title="Settings"
|
||||||
ActiveIcon={IconSettings}
|
ActiveIcon={IconSettings}
|
||||||
renderHeaderRight={({ open }) =>
|
renderHeaderRight={({ open }) =>
|
||||||
open ? (
|
open && !readonly ? (
|
||||||
<Button variant="primary" disabled={!open} onClick={handleUpdate}>
|
<Button variant="primary" disabled={!open} onClick={handleUpdate}>
|
||||||
Update
|
Update
|
||||||
</Button>
|
</Button>
|
||||||
@@ -278,6 +284,7 @@ const BasicSettings = ({ entity }: { entity: Entity }) => {
|
|||||||
formData={_config}
|
formData={_config}
|
||||||
onSubmit={console.log}
|
onSubmit={console.log}
|
||||||
className="legacy hide-required-mark fieldset-alternative mute-root"
|
className="legacy hide-required-mark fieldset-alternative mute-root"
|
||||||
|
readonly={readonly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</AppShell.RouteAwareSectionHeaderAccordionItem>
|
</AppShell.RouteAwareSectionHeaderAccordionItem>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Suspense, lazy } from "react";
|
import { Suspense, lazy } from "react";
|
||||||
|
import { SchemaEditable } from "ui/client/bknd";
|
||||||
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
|
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
|
||||||
import { Button } from "ui/components/buttons/Button";
|
import { Button } from "ui/components/buttons/Button";
|
||||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||||
@@ -15,9 +16,11 @@ export function DataSchemaIndex() {
|
|||||||
<>
|
<>
|
||||||
<AppShell.SectionHeader
|
<AppShell.SectionHeader
|
||||||
right={
|
right={
|
||||||
<Button type="button" variant="primary" onClick={$data.modals.createAny}>
|
<SchemaEditable>
|
||||||
Create new
|
<Button type="button" variant="primary" onClick={$data.modals.createAny}>
|
||||||
</Button>
|
Create new
|
||||||
|
</Button>
|
||||||
|
</SchemaEditable>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Schema Overview
|
Schema Overview
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { MantineSelect } from "ui/components/form/hook-form-mantine/MantineSelec
|
|||||||
import type { TPrimaryFieldFormat } from "data/fields/PrimaryField";
|
import type { TPrimaryFieldFormat } from "data/fields/PrimaryField";
|
||||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import ErrorBoundary from "ui/components/display/ErrorBoundary";
|
import ErrorBoundary from "ui/components/display/ErrorBoundary";
|
||||||
|
import { SchemaEditable } from "ui/client/bknd";
|
||||||
|
|
||||||
const fieldsSchemaObject = originalFieldsSchemaObject;
|
const fieldsSchemaObject = originalFieldsSchemaObject;
|
||||||
const fieldsSchema = s.anyOf(Object.values(fieldsSchemaObject));
|
const fieldsSchema = s.anyOf(Object.values(fieldsSchemaObject));
|
||||||
@@ -64,6 +65,7 @@ export type EntityFieldsFormProps = {
|
|||||||
routePattern?: string;
|
routePattern?: string;
|
||||||
defaultPrimaryFormat?: TPrimaryFieldFormat;
|
defaultPrimaryFormat?: TPrimaryFieldFormat;
|
||||||
isNew?: boolean;
|
isNew?: boolean;
|
||||||
|
readonly?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EntityFieldsFormRef = {
|
export type EntityFieldsFormRef = {
|
||||||
@@ -76,7 +78,7 @@ export type EntityFieldsFormRef = {
|
|||||||
|
|
||||||
export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsFormProps>(
|
export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsFormProps>(
|
||||||
function EntityFieldsForm(
|
function EntityFieldsForm(
|
||||||
{ fields: _fields, sortable, additionalFieldTypes, routePattern, isNew, ...props },
|
{ fields: _fields, sortable, additionalFieldTypes, routePattern, isNew, readonly, ...props },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const entityFields = Object.entries(_fields).map(([name, field]) => ({
|
const entityFields = Object.entries(_fields).map(([name, field]) => ({
|
||||||
@@ -162,6 +164,7 @@ export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsForm
|
|||||||
disableIndices={[0]}
|
disableIndices={[0]}
|
||||||
renderItem={({ dnd, ...props }, index) => (
|
renderItem={({ dnd, ...props }, index) => (
|
||||||
<EntityFieldMemo
|
<EntityFieldMemo
|
||||||
|
readonly={readonly}
|
||||||
key={props.id}
|
key={props.id}
|
||||||
field={props as any}
|
field={props as any}
|
||||||
index={index}
|
index={index}
|
||||||
@@ -181,6 +184,7 @@ export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsForm
|
|||||||
<div>
|
<div>
|
||||||
{fields.map((field, index) => (
|
{fields.map((field, index) => (
|
||||||
<EntityField
|
<EntityField
|
||||||
|
readonly={readonly}
|
||||||
key={field.id}
|
key={field.id}
|
||||||
field={field as any}
|
field={field as any}
|
||||||
index={index}
|
index={index}
|
||||||
@@ -197,20 +201,22 @@ export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsForm
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Popover
|
<SchemaEditable>
|
||||||
className="flex flex-col w-full"
|
<Popover
|
||||||
target={({ toggle }) => (
|
className="flex flex-col w-full"
|
||||||
<SelectType
|
target={({ toggle }) => (
|
||||||
additionalFieldTypes={additionalFieldTypes}
|
<SelectType
|
||||||
onSelected={toggle}
|
additionalFieldTypes={additionalFieldTypes}
|
||||||
onSelect={(type) => {
|
onSelected={toggle}
|
||||||
handleAppend(type as any);
|
onSelect={(type) => {
|
||||||
}}
|
handleAppend(type as any);
|
||||||
/>
|
}}
|
||||||
)}
|
/>
|
||||||
>
|
)}
|
||||||
<Button className="justify-center">Add Field</Button>
|
>
|
||||||
</Popover>
|
<Button className="justify-center">Add Field</Button>
|
||||||
|
</Popover>
|
||||||
|
</SchemaEditable>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -288,6 +294,7 @@ function EntityField({
|
|||||||
dnd,
|
dnd,
|
||||||
routePattern,
|
routePattern,
|
||||||
primary,
|
primary,
|
||||||
|
readonly,
|
||||||
}: {
|
}: {
|
||||||
field: FieldArrayWithId<TFieldsFormSchema, "fields", "id">;
|
field: FieldArrayWithId<TFieldsFormSchema, "fields", "id">;
|
||||||
index: number;
|
index: number;
|
||||||
@@ -303,6 +310,7 @@ function EntityField({
|
|||||||
defaultFormat?: TPrimaryFieldFormat;
|
defaultFormat?: TPrimaryFieldFormat;
|
||||||
editable?: boolean;
|
editable?: boolean;
|
||||||
};
|
};
|
||||||
|
readonly?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const prefix = `fields.${index}.field` as const;
|
const prefix = `fields.${index}.field` as const;
|
||||||
const type = field.field.type;
|
const type = field.field.type;
|
||||||
@@ -393,6 +401,7 @@ function EntityField({
|
|||||||
<span className="text-xs text-primary/50 leading-none">Required</span>
|
<span className="text-xs text-primary/50 leading-none">Required</span>
|
||||||
<MantineSwitch
|
<MantineSwitch
|
||||||
size="sm"
|
size="sm"
|
||||||
|
disabled={readonly}
|
||||||
name={`${prefix}.config.required`}
|
name={`${prefix}.config.required`}
|
||||||
control={control}
|
control={control}
|
||||||
/>
|
/>
|
||||||
@@ -433,6 +442,7 @@ function EntityField({
|
|||||||
<div className="flex flex-row">
|
<div className="flex flex-row">
|
||||||
<MantineSwitch
|
<MantineSwitch
|
||||||
label="Required"
|
label="Required"
|
||||||
|
disabled={readonly}
|
||||||
name={`${prefix}.config.required`}
|
name={`${prefix}.config.required`}
|
||||||
control={control}
|
control={control}
|
||||||
/>
|
/>
|
||||||
@@ -440,11 +450,13 @@ function EntityField({
|
|||||||
<TextInput
|
<TextInput
|
||||||
label="Label"
|
label="Label"
|
||||||
placeholder="Label"
|
placeholder="Label"
|
||||||
|
disabled={readonly}
|
||||||
{...register(`${prefix}.config.label`)}
|
{...register(`${prefix}.config.label`)}
|
||||||
/>
|
/>
|
||||||
<Textarea
|
<Textarea
|
||||||
label="Description"
|
label="Description"
|
||||||
placeholder="Description"
|
placeholder="Description"
|
||||||
|
disabled={readonly}
|
||||||
{...register(`${prefix}.config.description`)}
|
{...register(`${prefix}.config.description`)}
|
||||||
/>
|
/>
|
||||||
{!hidden.includes("virtual") && (
|
{!hidden.includes("virtual") && (
|
||||||
@@ -452,7 +464,7 @@ function EntityField({
|
|||||||
label="Virtual"
|
label="Virtual"
|
||||||
name={`${prefix}.config.virtual`}
|
name={`${prefix}.config.virtual`}
|
||||||
control={control}
|
control={control}
|
||||||
disabled={disabled.includes("virtual")}
|
disabled={disabled.includes("virtual") || readonly}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -468,6 +480,7 @@ function EntityField({
|
|||||||
...value,
|
...value,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
readonly={readonly}
|
||||||
/>
|
/>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
@@ -478,16 +491,18 @@ function EntityField({
|
|||||||
return <JsonViewer json={json} expand={4} />;
|
return <JsonViewer json={json} expand={4} />;
|
||||||
})()}
|
})()}
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
<div className="flex flex-row justify-end">
|
{!readonly && (
|
||||||
<Button
|
<div className="flex flex-row justify-end">
|
||||||
IconLeft={TbTrash}
|
<Button
|
||||||
onClick={handleDelete(index)}
|
IconLeft={TbTrash}
|
||||||
size="small"
|
onClick={handleDelete(index)}
|
||||||
variant="subtlered"
|
size="small"
|
||||||
>
|
variant="subtlered"
|
||||||
Delete
|
>
|
||||||
</Button>
|
Delete
|
||||||
</div>
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -498,9 +513,11 @@ function EntityField({
|
|||||||
const SpecificForm = ({
|
const SpecificForm = ({
|
||||||
field,
|
field,
|
||||||
onChange,
|
onChange,
|
||||||
|
readonly,
|
||||||
}: {
|
}: {
|
||||||
field: FieldArrayWithId<TFieldsFormSchema, "fields", "id">;
|
field: FieldArrayWithId<TFieldsFormSchema, "fields", "id">;
|
||||||
onChange: (value: any) => void;
|
onChange: (value: any) => void;
|
||||||
|
readonly?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const type = field.field.type;
|
const type = field.field.type;
|
||||||
const specificData = omit(field.field.config, commonProps);
|
const specificData = omit(field.field.config, commonProps);
|
||||||
@@ -513,6 +530,7 @@ const SpecificForm = ({
|
|||||||
uiSchema={dataFieldsUiSchema.config}
|
uiSchema={dataFieldsUiSchema.config}
|
||||||
className="legacy hide-required-mark fieldset-alternative mute-root"
|
className="legacy hide-required-mark fieldset-alternative mute-root"
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
readonly={readonly}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export function Setting<Schema extends s.ObjectSchema = s.ObjectSchema>({
|
|||||||
properties,
|
properties,
|
||||||
}: SettingProps<Schema>) {
|
}: SettingProps<Schema>) {
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const { actions } = useBknd();
|
const { actions, readonly } = useBknd();
|
||||||
const formRef = useRef<JsonSchemaFormRef>(null);
|
const formRef = useRef<JsonSchemaFormRef>(null);
|
||||||
const schemaLocalModalRef = useRef<SettingsSchemaModalRef>(null);
|
const schemaLocalModalRef = useRef<SettingsSchemaModalRef>(null);
|
||||||
const schemaModalRef = useRef<SettingsSchemaModalRef>(null);
|
const schemaModalRef = useRef<SettingsSchemaModalRef>(null);
|
||||||
@@ -120,14 +120,14 @@ export function Setting<Schema extends s.ObjectSchema = s.ObjectSchema>({
|
|||||||
extractedKeys.find((key) => window.location.pathname.endsWith(key)) ?? extractedKeys[0];
|
extractedKeys.find((key) => window.location.pathname.endsWith(key)) ?? extractedKeys[0];
|
||||||
|
|
||||||
const onToggleEdit = useEvent(() => {
|
const onToggleEdit = useEvent(() => {
|
||||||
if (!editAllowed) return;
|
if (!editAllowed || readonly) return;
|
||||||
|
|
||||||
setEditing((prev) => !prev);
|
setEditing((prev) => !prev);
|
||||||
//formRef.current?.cancel();
|
//formRef.current?.cancel();
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSave = useEvent(async () => {
|
const onSave = useEvent(async () => {
|
||||||
if (!editAllowed || !editing) return;
|
if (!editAllowed || !editing || readonly) return;
|
||||||
|
|
||||||
if (formRef.current?.validateForm()) {
|
if (formRef.current?.validateForm()) {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
@@ -215,14 +215,14 @@ export function Setting<Schema extends s.ObjectSchema = s.ObjectSchema>({
|
|||||||
>
|
>
|
||||||
<IconButton Icon={TbSettings} />
|
<IconButton Icon={TbSettings} />
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
<Button onClick={onToggleEdit} disabled={!editAllowed}>
|
<Button onClick={onToggleEdit} disabled={!editAllowed || readonly}>
|
||||||
{editing ? "Cancel" : "Edit"}
|
{editing ? "Cancel" : "Edit"}
|
||||||
</Button>
|
</Button>
|
||||||
{editing && (
|
{editing && (
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={onSave}
|
onClick={onSave}
|
||||||
disabled={submitting || !editAllowed}
|
disabled={submitting || !editAllowed || readonly}
|
||||||
>
|
>
|
||||||
{submitting ? "Save..." : "Save"}
|
{submitting ? "Save..." : "Save"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { showRoutes } from "hono/dev";
|
|||||||
import { App, registries } from "./src";
|
import { App, registries } from "./src";
|
||||||
import { StorageLocalAdapter } from "./src/adapter/node";
|
import { StorageLocalAdapter } from "./src/adapter/node";
|
||||||
import type { Connection } from "./src/data/connection/Connection";
|
import type { Connection } from "./src/data/connection/Connection";
|
||||||
import { __bknd } from "modules/ModuleManager";
|
import { __bknd } from "modules/manager/DbModuleManager";
|
||||||
import { nodeSqlite } from "./src/adapter/node/connection/NodeSqliteConnection";
|
import { nodeSqlite } from "./src/adapter/node/connection/NodeSqliteConnection";
|
||||||
import { libsql } from "./src/data/connection/sqlite/libsql/LibsqlConnection";
|
import { libsql } from "./src/data/connection/sqlite/libsql/LibsqlConnection";
|
||||||
import { $console } from "core/utils/console";
|
import { $console } from "core/utils/console";
|
||||||
|
|||||||
Reference in New Issue
Block a user