Files
bknd/app/src/modules/ModuleManager.ts
dswbx a2fa11ccd0 refactor modes implementation and improve validation handling
refactor `code` and `hybrid` modes for better type safety and configuration flexibility. add `_isProd` helper to standardize environment checks and improve plugin syncing warnings. adjust validation logic for clean JSON schema handling and enhance test coverage for modes.
2025-11-20 21:08:16 +01:00

424 lines
12 KiB
TypeScript

import {
objectEach,
transformObject,
McpServer,
type s,
SecretSchema,
setPath,
mark,
$console,
} 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 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";
import { mergeWith, pick } from "lodash-es";
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
skipValidation?: 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>;
// whether to store secrets in the database
storeSecrets?: boolean;
// provided secrets
secrets?: Record<string, any>;
/** @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 class ModuleManagerSecretsExtractedEvent extends ModuleManagerEvent<{
secrets: Record<string, any>;
}> {
static override slug = "mm-secrets-extracted";
}
export const ModuleManagerEvents = {
ModuleManagerConfigUpdateEvent,
ModuleManagerSecretsExtractedEvent,
};
// @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,
public options?: Partial<ModuleManagerOptions>,
) {
this.modules = {} as Modules;
this.emgr = new EventManager({ ...ModuleManagerEvents });
this.logger = new DebugLogger(debug_modules);
const config = options?.initial ?? {};
if (options?.skipValidation) {
mark(config, true);
}
this.createModules(config);
}
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),
};
}
extractSecrets() {
const moduleConfigs = JSON.parse(JSON.stringify(this.configs()));
const secrets = { ...this.options?.secrets };
const extractedKeys: string[] = [];
for (const [key, module] of Object.entries(this.modules)) {
const config = moduleConfigs[key];
const schema = module.getSchema();
const extracted = [...schema.walk({ data: config })].filter(
(n) => n.schema instanceof SecretSchema,
);
for (const n of extracted) {
const path = [key, ...n.instancePath].join(".");
if (typeof n.data === "string") {
extractedKeys.push(path);
secrets[path] = n.data;
setPath(moduleConfigs, path, "");
}
}
}
return {
configs: moduleConfigs,
secrets: pick(secrets, extractedKeys),
extractedKeys,
};
}
protected async setConfigs(configs: ModuleConfigs): Promise<void> {
this.logger.log("setting configs");
for await (const [key, config] of Object.entries(configs)) {
if (!(key in this.modules)) continue;
try {
// setting "noEmit" to true, to not force listeners to update
const result = await 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();
// if secrets were provided, extract, merge and build again
const provided_secrets = this.options?.secrets ?? {};
if (Object.keys(provided_secrets).length > 0) {
const { configs, extractedKeys } = this.extractSecrets();
for (const key of extractedKeys) {
if (key in provided_secrets) {
setPath(configs, key, provided_secrets[key]);
}
}
await this.setConfigs(configs);
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");
// ignore sync request on code mode since system tables
// are probably never fully in provided config
}
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 0;
}
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;
}