init code-first mode by splitting module manager

This commit is contained in:
dswbx
2025-09-04 09:21:35 +02:00
parent c9773d49a6
commit e3888537f9
22 changed files with 768 additions and 541 deletions

View File

@@ -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);
} }

View File

@@ -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>(

View File

@@ -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]> };

View File

@@ -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,
}, },

View File

@@ -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";

View File

@@ -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 {

View File

@@ -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;
} }

View 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;
}

View File

@@ -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;

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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");
}
} }

View File

@@ -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];

View File

@@ -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(),

View File

@@ -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;
}

View File

@@ -1 +1 @@
export { BkndProvider, type BkndAdminOptions, useBknd } from "./BkndProvider"; export { BkndProvider, type BkndAdminOptions, useBknd, SchemaEditable } from "./BkndProvider";

View File

@@ -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}

View File

@@ -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>

View File

@@ -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

View File

@@ -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}
/> />
); );
}; };

View File

@@ -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>

View File

@@ -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";