mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-17 04:46:05 +00:00
init code-first mode by splitting module manager
This commit is contained in:
@@ -10,7 +10,7 @@ export {
|
||||
type ModuleSchemas,
|
||||
MODULE_NAMES,
|
||||
type ModuleKey,
|
||||
} from "./ModuleManager";
|
||||
} from "./manager/ModuleManager";
|
||||
export type { ModuleBuildContext } from "./Module";
|
||||
|
||||
export {
|
||||
|
||||
@@ -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<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> = {
|
||||
id?: number;
|
||||
version: number;
|
||||
@@ -116,99 +55,41 @@ interface T_INTERNAL_EM {
|
||||
__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 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<T_INTERNAL_EM>;
|
||||
// ctx for modules
|
||||
em!: EntityManager;
|
||||
server!: Hono<ServerEnv>;
|
||||
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<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>;
|
||||
constructor(connection: Connection, options?: Partial<ModuleManagerOptions>) {
|
||||
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<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
|
||||
* 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<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> {
|
||||
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<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 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<ObjectToolSchema>) {
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<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));
|
||||
|
||||
// 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];
|
||||
|
||||
|
||||
@@ -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<Key extends ModuleKey = ModuleKey> = {
|
||||
success: true;
|
||||
@@ -43,6 +44,7 @@ export type ConfigUpdateResponse<Key extends ModuleKey = ModuleKey> =
|
||||
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<any>): 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<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(
|
||||
"/: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);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user