Merge pull request #249 from bknd-io/feat/code-first-and-secrets-extract

init code-first mode by splitting module manager
This commit is contained in:
dswbx
2025-09-04 20:49:45 +02:00
committed by GitHub
71 changed files with 1203 additions and 876 deletions

View File

@@ -19,7 +19,7 @@ describe("App tests", async () => {
test("plugins", async () => {
const called: string[] = [];
const app = createApp({
initialConfig: {
config: {
auth: {
enabled: true,
},

View File

@@ -19,52 +19,16 @@ describe("adapter", () => {
expect(
omitKeys(
await adapter.makeConfig(
{ app: (a) => ({ initialConfig: { server: { cors: { origin: a.env.TEST } } } }) },
{ app: (a) => ({ config: { server: { cors: { origin: a.env.TEST } } } }) },
{ env: { TEST: "test" } },
),
["connection"],
),
).toEqual({
initialConfig: { server: { cors: { origin: "test" } } },
config: { server: { cors: { origin: "test" } } },
});
});
/* it.only("...", async () => {
const app = await adapter.createAdapterApp();
}); */
it("reuses apps correctly", async () => {
const id = crypto.randomUUID();
const first = await adapter.createAdapterApp(
{
initialConfig: { server: { cors: { origin: "random" } } },
},
undefined,
{ id },
);
const second = await adapter.createAdapterApp();
const third = await adapter.createAdapterApp(undefined, undefined, { id });
await first.build();
await second.build();
await third.build();
expect(first.toJSON().server.cors.origin).toEqual("random");
expect(first).toBe(third);
expect(first).not.toBe(second);
expect(second).not.toBe(third);
expect(second.toJSON().server.cors.origin).toEqual("*");
// recreate the first one
const first2 = await adapter.createAdapterApp(undefined, undefined, { id, force: true });
await first2.build();
expect(first2).not.toBe(first);
expect(first2).not.toBe(third);
expect(first2).not.toBe(second);
expect(first2.toJSON().server.cors.origin).toEqual("*");
});
adapterTestSuite(bunTestRunner, {
makeApp: adapter.createFrameworkApp,
label: "framework app",

View File

@@ -1,9 +1,19 @@
import { describe, expect, mock, test } from "bun:test";
import type { ModuleBuildContext } from "../../src";
import { App, createApp } from "core/test/utils";
import * as proto from "../../src/data/prototype";
import * as proto from "data/prototype";
import { DbModuleManager } from "modules/db/DbModuleManager";
describe("App", () => {
test("use db mode by default", async () => {
const app = createApp();
await app.build();
expect(app.mode).toBe("db");
expect(app.isReadOnly()).toBe(false);
expect(app.modules instanceof DbModuleManager).toBe(true);
});
test("seed includes ctx and app", async () => {
const called = mock(() => null);
await createApp({
@@ -29,7 +39,7 @@ describe("App", () => {
expect(called).toHaveBeenCalled();
const app = createApp({
initialConfig: {
config: {
data: proto
.em({
todos: proto.entity("todos", {
@@ -139,7 +149,7 @@ describe("App", () => {
test("getMcpClient", async () => {
const app = createApp({
initialConfig: {
config: {
server: {
mcp: {
enabled: true,

View File

@@ -29,7 +29,7 @@ describe("mcp auth", async () => {
let server: McpServer;
beforeEach(async () => {
app = createApp({
initialConfig: {
config: {
auth: {
enabled: true,
jwt: {

View File

@@ -8,7 +8,7 @@ describe("mcp", () => {
registries.media.register("local", StorageLocalAdapter);
const app = createApp({
initialConfig: {
config: {
auth: {
enabled: true,
},

View File

@@ -41,7 +41,7 @@ describe("mcp data", async () => {
beforeEach(async () => {
const time = performance.now();
app = createApp({
initialConfig: {
config: {
server: {
mcp: {
enabled: true,

View File

@@ -21,7 +21,7 @@ describe("mcp media", async () => {
beforeEach(async () => {
registries.media.register("local", StorageLocalAdapter);
app = createApp({
initialConfig: {
config: {
media: {
enabled: true,
adapter: {

View File

@@ -11,7 +11,7 @@ describe("mcp system", async () => {
let server: McpServer;
beforeAll(async () => {
app = createApp({
initialConfig: {
config: {
server: {
mcp: {
enabled: true,

View File

@@ -14,7 +14,7 @@ describe("mcp system", async () => {
let server: McpServer;
beforeAll(async () => {
app = createApp({
initialConfig: {
config: {
server: {
mcp: {
enabled: true,

View File

@@ -88,7 +88,7 @@ describe("repros", async () => {
fns.relation(schema.product_likes).manyToOne(schema.users);
},
);
const app = createApp({ initialConfig: { data: schema.toJSON() } });
const app = createApp({ config: { data: schema.toJSON() } });
await app.build();
const info = (await (await app.server.request("/api/data/info/products")).json()) as any;

View File

@@ -1,13 +1,9 @@
import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test";
import { App, createApp } from "../../src";
import type { AuthResponse } from "../../src/auth";
import { App, createApp, type AuthResponse } from "../../src";
import { auth } from "../../src/auth/middlewares";
import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils";
import { disableConsoleLog, enableConsoleLog, getDummyConnection } from "../helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterEach(afterAllCleanup);
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
@@ -66,9 +62,10 @@ const configs = {
};
function createAuthApp() {
const { dummyConnection } = getDummyConnection();
const app = createApp({
connection: dummyConnection,
initialConfig: {
config: {
auth: configs.auth,
},
});

View File

@@ -16,7 +16,7 @@ const path = `${assetsPath}/image.png`;
async function makeApp(mediaOverride: Partial<TAppMediaConfig> = {}) {
const app = createApp({
initialConfig: {
config: {
media: mergeObject(
{
enabled: true,

View File

@@ -147,7 +147,7 @@ describe("AppAuth", () => {
test("registers auth middleware for bknd routes only", async () => {
const app = createApp({
initialConfig: {
config: {
auth: {
enabled: true,
jwt: {
@@ -177,7 +177,7 @@ describe("AppAuth", () => {
test("should allow additional user fields", async () => {
const app = createApp({
initialConfig: {
config: {
auth: {
entity_name: "users",
enabled: true,
@@ -201,7 +201,7 @@ describe("AppAuth", () => {
test("ensure user field configs is always correct", async () => {
const app = createApp({
initialConfig: {
config: {
auth: {
enabled: true,
},

View File

@@ -18,7 +18,7 @@ describe("AppMedia", () => {
registries.media.register("local", StorageLocalAdapter);
const app = createApp({
initialConfig: {
config: {
media: {
entity_name: "media",
enabled: true,

View File

@@ -0,0 +1,22 @@
import { it, expect, describe } from "bun:test";
import { DbModuleManager } from "modules/db/DbModuleManager";
import { getDummyConnection } from "../helper";
describe("DbModuleManager", () => {
it("should extract secrets", async () => {
const { dummyConnection } = getDummyConnection(false);
const m = new DbModuleManager(dummyConnection, {
initial: {
auth: {
enabled: true,
jwt: {
secret: "test",
},
},
},
});
await m.build();
expect(m.toJSON(true).auth.jwt.secret).toBe("test");
await m.save();
});
});

View File

@@ -2,8 +2,10 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
import { Module } from "modules/Module";
import { type ConfigTable, getDefaultConfig, ModuleManager } from "modules/ModuleManager";
import { CURRENT_VERSION, TABLE_NAME } from "modules/migrations";
import { getDefaultConfig } from "modules/ModuleManager";
import { type ConfigTable, DbModuleManager as ModuleManager } from "modules/db/DbModuleManager";
import { CURRENT_VERSION, TABLE_NAME } from "modules/db/migrations";
import { getDummyConnection } from "../helper";
import { s, stripMark } from "core/utils/schema";
import { Connection } from "data/connection/Connection";

View File

@@ -3,7 +3,7 @@ import { createApp } from "bknd/adapter/bun";
async function generate() {
console.info("Generating MCP documentation...");
const app = await createApp({
initialConfig: {
config: {
server: {
mcp: {
enabled: true,

View File

@@ -5,13 +5,14 @@ import type { em as prototypeEm } from "data/prototype";
import { Connection } from "data/connection/Connection";
import type { Hono } from "hono";
import {
ModuleManager,
type InitialModuleConfigs,
type ModuleBuildContext,
type ModuleConfigs,
type ModuleManagerOptions,
type Modules,
ModuleManager,
type ModuleBuildContext,
type ModuleManagerOptions,
} from "modules/ModuleManager";
import { DbModuleManager } from "modules/db/DbModuleManager";
import * as SystemPermissions from "modules/permissions";
import { AdminController, type AdminControllerOptions } from "modules/server/AdminController";
import { SystemController } from "modules/server/SystemController";
@@ -93,17 +94,19 @@ export type AppOptions = {
email?: IEmailDriver;
cache?: ICacheDriver;
};
mode?: "db" | "code";
readonly?: boolean;
};
export type CreateAppConfig = {
/**
* bla
*/
connection?: Connection | { url: string };
initialConfig?: InitialModuleConfigs;
config?: InitialModuleConfigs;
options?: AppOptions;
};
export type AppConfig = InitialModuleConfigs;
export type AppConfig = { version: number } & ModuleConfigs;
export type LocalApiOptions = Request | ApiOptions;
export class App<C extends Connection = Connection, Options extends AppOptions = AppOptions> {
@@ -121,8 +124,8 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
constructor(
public connection: C,
_initialConfig?: InitialModuleConfigs,
private options?: Options,
_config?: InitialModuleConfigs,
public options?: Options,
) {
this.drivers = options?.drivers ?? {};
@@ -134,9 +137,13 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
this.plugins.set(config.name, config);
}
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 ?? {}),
initial: _initialConfig,
initial: _config,
onUpdated: this.onUpdated.bind(this),
onFirstBoot: this.onFirstBoot.bind(this),
onServerInit: this.onServerInit.bind(this),
@@ -145,6 +152,14 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
this.modules.ctx().emgr.registerEvents(AppEvents);
}
get mode() {
return this.options?.mode ?? "db";
}
isReadOnly() {
return Boolean(this.mode === "code" || this.options?.readonly);
}
get emgr() {
return this.modules.ctx().emgr;
}
@@ -175,7 +190,7 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
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
if (this._building) {
while (this._building) {
@@ -188,7 +203,7 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
this._building = 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();
@@ -215,10 +230,6 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
this._building = false;
}
mutateConfig<Module extends keyof Modules>(module: Module) {
return this.modules.mutateConfigSafe(module);
}
get server() {
return this.modules.server;
}
@@ -377,5 +388,5 @@ export function createApp(config: CreateAppConfig = {}) {
throw new Error("Invalid connection");
}
return new App(config.connection, config.initialConfig, config.options);
return new App(config.connection, config.config, config.options);
}

View File

@@ -1,5 +1,5 @@
import type { TestRunner } from "core/test";
import type { BkndConfig, DefaultArgs, FrameworkOptions, RuntimeOptions } from "./index";
import type { BkndConfig, DefaultArgs } from "./index";
import type { App } from "App";
export function adapterTestSuite<
@@ -13,16 +13,8 @@ export function adapterTestSuite<
label = "app",
overrides = {},
}: {
makeApp: (
config: Config,
args?: Args,
opts?: RuntimeOptions | FrameworkOptions,
) => Promise<App>;
makeHandler?: (
config?: Config,
args?: Args,
opts?: RuntimeOptions | FrameworkOptions,
) => (request: Request) => Promise<Response>;
makeApp: (config: Config, args?: Args) => Promise<App>;
makeHandler?: (config?: Config, args?: Args) => (request: Request) => Promise<Response>;
label?: string;
overrides?: {
dbUrl?: string;
@@ -30,7 +22,6 @@ export function adapterTestSuite<
},
) {
const { test, expect, mock } = testRunner;
const id = crypto.randomUUID();
test(`creates ${label}`, async () => {
const beforeBuild = mock(async () => null) as any;
@@ -39,7 +30,7 @@ export function adapterTestSuite<
const config = {
app: (env) => ({
connection: { url: env.url },
initialConfig: {
config: {
server: { cors: { origin: env.origin } },
},
}),
@@ -53,7 +44,6 @@ export function adapterTestSuite<
url: overrides.dbUrl ?? ":memory:",
origin: "localhost",
} as any,
{ force: false, id },
);
expect(app).toBeDefined();
expect(app.toJSON().server.cors.origin).toEqual("localhost");
@@ -68,8 +58,8 @@ export function adapterTestSuite<
return { res, data };
};
test("responds with the same app id", async () => {
const fetcher = makeHandler(undefined, undefined, { force: false, id });
/* test.skip("responds with the same app id", async () => {
const fetcher = makeHandler(undefined, undefined, { id });
const { res, data } = await getConfig(fetcher);
expect(res.ok).toBe(true);
@@ -77,14 +67,14 @@ export function adapterTestSuite<
expect(data.server.cors.origin).toEqual("localhost");
});
test("creates fresh & responds to api config", async () => {
test.skip("creates fresh & responds to api config", async () => {
// set the same id, but force recreate
const fetcher = makeHandler(undefined, undefined, { id, force: true });
const fetcher = makeHandler(undefined, undefined, { id });
const { res, data } = await getConfig(fetcher);
expect(res.ok).toBe(true);
expect(res.status).toBe(200);
expect(data.server.cors.origin).toEqual("*");
});
}); */
}
}

View File

@@ -10,6 +10,6 @@ afterAll(enableConsoleLog);
describe("astro adapter", () => {
adapterTestSuite(bunTestRunner, {
makeApp: astro.getApp,
makeHandler: (c, a, o) => (request: Request) => astro.serve(c, a, o)({ request }),
makeHandler: (c, a) => (request: Request) => astro.serve(c, a)({ request }),
});
});

View File

@@ -1,4 +1,4 @@
import { type FrameworkBkndConfig, createFrameworkApp, type FrameworkOptions } from "bknd/adapter";
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
type AstroEnv = NodeJS.ProcessEnv;
type TAstro = {
@@ -9,17 +9,12 @@ export type AstroBkndConfig<Env = AstroEnv> = FrameworkBkndConfig<Env>;
export async function getApp<Env = AstroEnv>(
config: AstroBkndConfig<Env> = {},
args: Env = {} as Env,
opts: FrameworkOptions = {},
) {
return await createFrameworkApp(config, args ?? import.meta.env, opts);
return await createFrameworkApp(config, args ?? import.meta.env);
}
export function serve<Env = AstroEnv>(
config: AstroBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: FrameworkOptions,
) {
export function serve<Env = AstroEnv>(config: AstroBkndConfig<Env> = {}, args: Env = {} as Env) {
return async (fnArgs: TAstro) => {
return (await getApp(config, args, opts)).fetch(fnArgs.request);
return (await getApp(config, args)).fetch(fnArgs.request);
};
}

View File

@@ -1,7 +1,7 @@
import type { App } from "bknd";
import { handle } from "hono/aws-lambda";
import { serveStatic } from "@hono/node-server/serve-static";
import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
type AwsLambdaEnv = object;
export type AwsLambdaBkndConfig<Env extends AwsLambdaEnv = AwsLambdaEnv> =
@@ -20,7 +20,6 @@ export type AwsLambdaBkndConfig<Env extends AwsLambdaEnv = AwsLambdaEnv> =
export async function createApp<Env extends AwsLambdaEnv = AwsLambdaEnv>(
{ adminOptions = false, assets, ...config }: AwsLambdaBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
): Promise<App> {
let additional: Partial<RuntimeBkndConfig> = {
adminOptions,
@@ -57,17 +56,15 @@ export async function createApp<Env extends AwsLambdaEnv = AwsLambdaEnv>(
...additional,
},
args ?? process.env,
opts,
);
}
export function serve<Env extends AwsLambdaEnv = AwsLambdaEnv>(
config: AwsLambdaBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
) {
return async (event) => {
const app = await createApp(config, args, opts);
const app = await createApp(config, args);
return await handle(app.server)(event);
};
}

View File

@@ -11,8 +11,8 @@ describe("aws adapter", () => {
adapterTestSuite(bunTestRunner, {
makeApp: awsLambda.createApp,
// @todo: add a request to lambda event translator?
makeHandler: (c, a, o) => async (request: Request) => {
const app = await awsLambda.createApp(c, a, o);
makeHandler: (c, a) => async (request: Request) => {
const app = await awsLambda.createApp(c, a);
return app.fetch(request);
},
});

View File

@@ -1,7 +1,7 @@
/// <reference types="bun-types" />
import path from "node:path";
import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
import { registerLocalMediaAdapter } from ".";
import { config, type App } from "bknd";
import type { ServeOptions } from "bun";
@@ -13,7 +13,6 @@ export type BunBkndConfig<Env = BunEnv> = RuntimeBkndConfig<Env> & Omit<ServeOpt
export async function createApp<Env = BunEnv>(
{ distPath, serveStatic: _serveStatic, ...config }: BunBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
) {
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
registerLocalMediaAdapter();
@@ -28,19 +27,17 @@ export async function createApp<Env = BunEnv>(
...config,
},
args ?? (process.env as Env),
opts,
);
}
export function createHandler<Env = BunEnv>(
config: BunBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
) {
let app: App | undefined;
return async (req: Request) => {
if (!app) {
app = await createApp(config, args ?? (process.env as Env), opts);
app = await createApp(config, args ?? (process.env as Env));
}
return app.fetch(req);
};
@@ -50,7 +47,7 @@ export function serve<Env = BunEnv>(
{
distPath,
connection,
initialConfig,
config: _config,
options,
port = config.server.default_port,
onBuilt,
@@ -60,7 +57,6 @@ export function serve<Env = BunEnv>(
...serveOptions
}: BunBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
) {
Bun.serve({
...serveOptions,
@@ -68,7 +64,7 @@ export function serve<Env = BunEnv>(
fetch: createHandler(
{
connection,
initialConfig,
config: _config,
options,
onBuilt,
buildConfig,
@@ -77,7 +73,6 @@ export function serve<Env = BunEnv>(
serveStatic,
},
args,
opts,
),
});

View File

@@ -5,8 +5,8 @@ import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test";
import { type CloudflareBkndConfig, createApp } from "./cloudflare-workers.adapter";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
/* beforeAll(disableConsoleLog);
afterAll(enableConsoleLog); */
describe("cf adapter", () => {
const DB_URL = ":memory:";
@@ -20,31 +20,31 @@ describe("cf adapter", () => {
const staticConfig = await makeConfig(
{
connection: { url: DB_URL },
initialConfig: { data: { basepath: DB_URL } },
config: { data: { basepath: DB_URL } },
},
$ctx({ DB_URL }),
);
expect(staticConfig.initialConfig).toEqual({ data: { basepath: DB_URL } });
expect(staticConfig.config).toEqual({ data: { basepath: DB_URL } });
expect(staticConfig.connection).toBeDefined();
const dynamicConfig = await makeConfig(
{
app: (env) => ({
initialConfig: { data: { basepath: env.DB_URL } },
config: { data: { basepath: env.DB_URL } },
connection: { url: env.DB_URL },
}),
},
$ctx({ DB_URL }),
);
expect(dynamicConfig.initialConfig).toEqual({ data: { basepath: DB_URL } });
expect(dynamicConfig.config).toEqual({ data: { basepath: DB_URL } });
expect(dynamicConfig.connection).toBeDefined();
});
adapterTestSuite<CloudflareBkndConfig, CloudflareContext<any>>(bunTestRunner, {
makeApp: async (c, a, o) => {
return await createApp(c, { env: a } as any, o);
makeApp: async (c, a) => {
return await createApp(c, { env: a } as any);
},
makeHandler: (c, a, o) => {
makeHandler: (c, a) => {
console.log("args", a);
return async (request: any) => {
const app = await createApp(
@@ -53,7 +53,6 @@ describe("cf adapter", () => {
connection: { url: DB_URL },
},
a as any,
o,
);
return app.fetch(request);
};

View File

@@ -5,7 +5,7 @@ import { Hono } from "hono";
import { serveStatic } from "hono/cloudflare-workers";
import type { MaybePromise } from "bknd";
import { $console } from "bknd/utils";
import { createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
import { createRuntimeApp } from "bknd/adapter";
import { registerAsyncsExecutionContext, makeConfig, type CloudflareContext } from "./config";
declare global {
@@ -34,12 +34,8 @@ export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> &
};
export async function createApp<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>,
config: CloudflareBkndConfig<Env> = {},
ctx: Partial<CloudflareContext<Env>> = {},
opts: RuntimeOptions = {
// by default, require the app to be rebuilt every time
force: true,
},
) {
const appConfig = await makeConfig(
{
@@ -53,7 +49,7 @@ export async function createApp<Env extends CloudflareEnv = CloudflareEnv>(
},
ctx,
);
return await createRuntimeApp<Env>(appConfig, ctx?.env, opts);
return await createRuntimeApp<Env>(appConfig, ctx?.env);
}
// compatiblity

View File

@@ -3,6 +3,7 @@ import { d1Sqlite, type D1ConnectionConfig } from "./connection/D1Connection";
export {
getFresh,
createApp,
serve,
type CloudflareEnv,
type CloudflareBkndConfig,
} from "./cloudflare-workers.adapter";

View File

@@ -21,13 +21,6 @@ export type BkndConfig<Args = any> = CreateAppConfig & {
export type FrameworkBkndConfig<Args = any> = BkndConfig<Args>;
export type CreateAdapterAppOptions = {
force?: boolean;
id?: string;
};
export type FrameworkOptions = CreateAdapterAppOptions;
export type RuntimeOptions = CreateAdapterAppOptions;
export type RuntimeBkndConfig<Args = any> = BkndConfig<Args> & {
distPath?: string;
serveStatic?: MiddlewareHandler | [string, MiddlewareHandler];
@@ -63,41 +56,29 @@ const apps = new Map<string, App>();
export async function createAdapterApp<Config extends BkndConfig = BkndConfig, Args = DefaultArgs>(
config: Config = {} as Config,
args?: Args,
opts?: CreateAdapterAppOptions,
): Promise<App> {
const id = opts?.id ?? "app";
let app = apps.get(id);
if (!app || opts?.force) {
const appConfig = await makeConfig(config, args);
if (!appConfig.connection || !Connection.isConnection(appConfig.connection)) {
let connection: Connection | undefined;
if (Connection.isConnection(config.connection)) {
connection = config.connection;
} else {
const sqlite = (await import("bknd/adapter/sqlite")).sqlite;
const conf = appConfig.connection ?? { url: ":memory:" };
connection = sqlite(conf) as any;
$console.info(`Using ${connection!.name} connection`, conf.url);
}
appConfig.connection = connection;
}
app = App.create(appConfig);
if (!opts?.force) {
apps.set(id, app);
const appConfig = await makeConfig(config, args);
if (!appConfig.connection || !Connection.isConnection(appConfig.connection)) {
let connection: Connection | undefined;
if (Connection.isConnection(config.connection)) {
connection = config.connection;
} else {
const sqlite = (await import("bknd/adapter/sqlite")).sqlite;
const conf = appConfig.connection ?? { url: ":memory:" };
connection = sqlite(conf) as any;
$console.info(`Using ${connection!.name} connection`, conf.url);
}
appConfig.connection = connection;
}
return app;
return App.create(appConfig);
}
export async function createFrameworkApp<Args = DefaultArgs>(
config: FrameworkBkndConfig = {},
args?: Args,
opts?: FrameworkOptions,
): Promise<App> {
const app = await createAdapterApp(config, args, opts);
const app = await createAdapterApp(config, args);
if (!app.isBuilt()) {
if (config.onBuilt) {
@@ -120,9 +101,8 @@ export async function createFrameworkApp<Args = DefaultArgs>(
export async function createRuntimeApp<Args = DefaultArgs>(
{ serveStatic, adminOptions, ...config }: RuntimeBkndConfig<Args> = {},
args?: Args,
opts?: RuntimeOptions,
): Promise<App> {
const app = await createAdapterApp(config, args, opts);
const app = await createAdapterApp(config, args);
if (!app.isBuilt()) {
app.emgr.onEvent(

View File

@@ -1,4 +1,4 @@
import { createFrameworkApp, type FrameworkBkndConfig, type FrameworkOptions } from "bknd/adapter";
import { createFrameworkApp, type FrameworkBkndConfig } from "bknd/adapter";
import { isNode } from "bknd/utils";
import type { NextApiRequest } from "next";
@@ -10,9 +10,8 @@ export type NextjsBkndConfig<Env = NextjsEnv> = FrameworkBkndConfig<Env> & {
export async function getApp<Env = NextjsEnv>(
config: NextjsBkndConfig<Env>,
args: Env = {} as Env,
opts?: FrameworkOptions,
) {
return await createFrameworkApp(config, args ?? (process.env as Env), opts);
return await createFrameworkApp(config, args ?? (process.env as Env));
}
function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequest"]) {
@@ -41,10 +40,9 @@ function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequ
export function serve<Env = NextjsEnv>(
{ cleanRequest, ...config }: NextjsBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: FrameworkOptions,
) {
return async (req: Request) => {
const app = await getApp(config, args, opts);
const app = await getApp(config, args);
const request = getCleanRequest(req, cleanRequest);
return app.fetch(request);
};

View File

@@ -2,7 +2,7 @@ import path from "node:path";
import { serve as honoServe } from "@hono/node-server";
import { serveStatic } from "@hono/node-server/serve-static";
import { registerLocalMediaAdapter } from "adapter/node/storage";
import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
import { config as $config, type App } from "bknd";
import { $console } from "bknd/utils";
@@ -18,7 +18,6 @@ export type NodeBkndConfig<Env = NodeEnv> = RuntimeBkndConfig<Env> & {
export async function createApp<Env = NodeEnv>(
{ distPath, relativeDistPath, ...config }: NodeBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
) {
const root = path.relative(
process.cwd(),
@@ -36,19 +35,17 @@ export async function createApp<Env = NodeEnv>(
},
// @ts-ignore
args ?? { env: process.env },
opts,
);
}
export function createHandler<Env = NodeEnv>(
config: NodeBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
) {
let app: App | undefined;
return async (req: Request) => {
if (!app) {
app = await createApp(config, args ?? (process.env as Env), opts);
app = await createApp(config, args ?? (process.env as Env));
}
return app.fetch(req);
};
@@ -57,13 +54,12 @@ export function createHandler<Env = NodeEnv>(
export function serve<Env = NodeEnv>(
{ port = $config.server.default_port, hostname, listener, ...config }: NodeBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
) {
honoServe(
{
port,
hostname,
fetch: createHandler(config, args, opts),
fetch: createHandler(config, args),
},
(connInfo) => {
$console.log(`Server is running on http://localhost:${connInfo.port}`);

View File

@@ -10,6 +10,6 @@ afterAll(enableConsoleLog);
describe("react-router adapter", () => {
adapterTestSuite(bunTestRunner, {
makeApp: rr.getApp,
makeHandler: (c, a, o) => (request: Request) => rr.serve(c, a?.env, o)({ request }),
makeHandler: (c, a) => (request: Request) => rr.serve(c, a?.env)({ request }),
});
});

View File

@@ -1,5 +1,4 @@
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
import type { FrameworkOptions } from "adapter";
type ReactRouterEnv = NodeJS.ProcessEnv;
type ReactRouterFunctionArgs = {
@@ -10,17 +9,15 @@ export type ReactRouterBkndConfig<Env = ReactRouterEnv> = FrameworkBkndConfig<En
export async function getApp<Env = ReactRouterEnv>(
config: ReactRouterBkndConfig<Env>,
args: Env = {} as Env,
opts?: FrameworkOptions,
) {
return await createFrameworkApp(config, args ?? process.env, opts);
return await createFrameworkApp(config, args ?? process.env);
}
export function serve<Env = ReactRouterEnv>(
config: ReactRouterBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: FrameworkOptions,
) {
return async (fnArgs: ReactRouterFunctionArgs) => {
return (await getApp(config, args, opts)).fetch(fnArgs.request);
return (await getApp(config, args)).fetch(fnArgs.request);
};
}

View File

@@ -1,7 +1,7 @@
import { serveStatic } from "@hono/node-server/serve-static";
import { type DevServerOptions, default as honoViteDevServer } from "@hono/vite-dev-server";
import type { App } from "bknd";
import { type RuntimeBkndConfig, createRuntimeApp, type FrameworkOptions } from "bknd/adapter";
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
import { registerLocalMediaAdapter } from "bknd/adapter/node";
import { devServerConfig } from "./dev-server-config";
import type { MiddlewareHandler } from "hono";
@@ -30,7 +30,6 @@ ${addBkndContext ? "<!-- BKND_CONTEXT -->" : ""}
async function createApp<ViteEnv>(
config: ViteBkndConfig<ViteEnv> = {},
env: ViteEnv = {} as ViteEnv,
opts: FrameworkOptions = {},
): Promise<App> {
registerLocalMediaAdapter();
return await createRuntimeApp(
@@ -47,18 +46,13 @@ async function createApp<ViteEnv>(
],
},
env,
opts,
);
}
export function serve<ViteEnv>(
config: ViteBkndConfig<ViteEnv> = {},
args?: ViteEnv,
opts?: FrameworkOptions,
) {
export function serve<ViteEnv>(config: ViteBkndConfig<ViteEnv> = {}, args?: ViteEnv) {
return {
async fetch(request: Request, env: any, ctx: ExecutionContext) {
const app = await createApp(config, env, opts);
const app = await createApp(config, env);
return app.fetch(request, env, ctx);
},
};

View File

@@ -4,3 +4,5 @@ export interface Serializable<Class, Json extends object = object> {
}
export type MaybePromise<T> = T | Promise<T>;
export type PartialRec<T> = { [P in keyof T]?: PartialRec<T[P]> };

View File

@@ -396,6 +396,38 @@ export function getPath(
}
}
export function setPath(object: object, _path: string | (string | number)[], value: any) {
let path = _path;
// Optional string-path support.
// You can remove this `if` block if you don't need it.
if (typeof path === "string") {
const isQuoted = (str) => str[0] === '"' && str.at(-1) === '"';
path = path
.split(/[.\[\]]+/)
.filter((x) => x)
.map((x) => (!Number.isNaN(Number(x)) ? Number(x) : x))
.map((x) => (typeof x === "string" && isQuoted(x) ? x.slice(1, -1) : x));
}
if (path.length === 0) {
throw new Error("The path must have at least one entry in it");
}
const [head, ...tail] = path as any;
if (tail.length === 0) {
object[head] = value;
return object;
}
if (!(head in object)) {
object[head] = typeof tail[0] === "number" ? [] : {};
}
setPath(object[head], tail, value);
return object;
}
export function objectToJsLiteral(value: object, indent: number = 0, _level: number = 0): string {
const nl = indent ? "\n" : "";
const pad = (lvl: number) => (indent ? " ".repeat(indent * lvl) : "");

View File

@@ -51,6 +51,7 @@ export class DataController extends Controller {
"/sync",
permission(DataPermissions.databaseSync),
mcpTool("data_sync", {
// @todo: should be removed if readonly
annotations: {
destructiveHint: true,
},

View File

@@ -247,7 +247,7 @@ export function connectionTestSuite(
const app = createApp({
connection: ctx.connection,
initialConfig: {
config: {
data: schema.toJSON(),
},
});
@@ -333,7 +333,7 @@ export function connectionTestSuite(
const app = createApp({
connection: ctx.connection,
initialConfig: {
config: {
data: schema.toJSON(),
},
});

View File

@@ -1,12 +1,12 @@
import { hash, pickHeaders, s, parse } from "bknd/utils";
import { hash, pickHeaders, s, parse, secret } from "bknd/utils";
import type { FileBody, FileListObject, FileMeta } from "../../Storage";
import { StorageAdapter } from "../../StorageAdapter";
export const cloudinaryAdapterConfig = s.object(
{
cloud_name: s.string(),
api_key: s.string(),
api_secret: s.string(),
api_key: secret(),
api_secret: secret(),
upload_preset: s.string().optional(),
},
{ title: "Cloudinary", description: "Cloudinary media storage" },

View File

@@ -8,15 +8,15 @@ import type {
} from "@aws-sdk/client-s3";
import { AwsClient } from "core/clients/aws/AwsClient";
import { isDebug } from "core/env";
import { isFile, pickHeaders2, parse, s } from "bknd/utils";
import { isFile, pickHeaders2, parse, s, secret } from "bknd/utils";
import { transform } from "lodash-es";
import type { FileBody, FileListObject } from "../../Storage";
import { StorageAdapter } from "../../StorageAdapter";
export const s3AdapterConfig = s.object(
{
access_key: s.string(),
secret_access_key: s.string(),
access_key: secret(),
secret_access_key: secret(),
url: s.string({
pattern: "^https?://(?:.*)?[^/.]+$",
description: "URL to S3 compatible endpoint without trailing slash",

View File

@@ -1,26 +1,20 @@
import { mark, stripMark, $console, s, objectEach, transformObject, McpServer } from "bknd/utils";
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 { 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 { 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 };
@@ -47,13 +41,8 @@ export type ModuleSchemas = {
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>;
export type InitialModuleConfigs = { version?: number } & PartialRec<ModuleConfigs>;
enum Verbosity {
silent = 0,
@@ -80,42 +69,14 @@ export type ModuleManagerOptions = {
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;
};
export type ConfigTable<Json = ModuleConfigs> = {
id?: number;
version: number;
type: "config" | "diff" | "backup";
json: Json;
created_at?: Date;
updated_at?: Date;
};
const configJsonSchema = s.anyOf([
getDefaultSchema(),
s.array(
s.strictObject({
t: s.string({ enum: ["a", "r", "e"] }),
p: s.array(s.anyOf([s.string(), s.number()])),
o: s.any().optional(),
n: s.any().optional(),
}),
),
]);
export const __bknd = proto.entity(TABLE_NAME, {
version: proto.number().required(),
type: proto.enumm({ enum: ["config", "diff", "backup"] }).required(),
json: proto.jsonSchema({ schema: configJsonSchema.toJSON() }).required(),
created_at: proto.datetime(),
updated_at: proto.datetime(),
});
type ConfigTable2 = proto.Schema<typeof __bknd>;
interface T_INTERNAL_EM {
__bknd: ConfigTable2;
}
const debug_modules = env("modules_debug");
abstract class ModuleManagerEvent<A = {}> extends Event<{ ctx: ModuleBuildContext } & A> {}
@@ -127,8 +88,14 @@ export class ModuleManagerConfigUpdateEvent<
}> {
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
@@ -137,8 +104,6 @@ export class ModuleManager {
static Events = ModuleManagerEvents;
protected modules: Modules;
// internal em for __bknd config table
__em!: EntityManager<T_INTERNAL_EM>;
// ctx for modules
em!: EntityManager;
server!: Hono<ServerEnv>;
@@ -146,42 +111,24 @@ export class ModuleManager {
guard!: Guard;
mcp!: ModuleBuildContext["mcp"];
private _version: number = 0;
private _built = false;
private readonly _booted_with?: "provided" | "partial";
private _stable_configs: ModuleConfigs | undefined;
protected _built = false;
private logger: DebugLogger;
protected logger: DebugLogger;
constructor(
private readonly connection: Connection,
private options?: Partial<ModuleManagerOptions>,
protected readonly connection: Connection,
protected 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 ("version" in options.initial) {
const { version, ...initialConfig } = options.initial;
this._version = version;
initial = stripMark(initialConfig);
this._booted_with = "provided";
} else {
initial = mergeWith(getDefaultConfig(), options.initial);
this._booted_with = "partial";
}
}
this.logger.log("booted with", this._booted_with);
this.createModules(initial);
this.createModules(options?.initial ?? {});
}
private createModules(initial: Partial<ModuleConfigs>) {
protected onModuleConfigUpdated(key: string, config: any) {}
private createModules(initial: PartialRec<ModuleConfigs>) {
this.logger.context("createModules").log("creating modules");
try {
const context = this.ctx(true);
@@ -211,46 +158,7 @@ export class ModuleManager {
return this._built;
}
/**
* This is set through module's setListener
* 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) {
if (this.options?.onUpdated) {
await this.options.onUpdated(key as any, config);
} else {
await this.buildModules();
}
}
private repo() {
return this.__em.repo(__bknd, {
// to prevent exceptions when table doesn't exist
silent: true,
// disable counts for performance and compatibility
includeCounts: false,
});
}
private mutator() {
return this.__em.mutator(__bknd);
}
private get db() {
// @todo: check why this is neccessary
return this.connection.kysely as unknown as Kysely<{ table: ConfigTable }>;
}
// @todo: add indices for: version, type
async syncConfigTable() {
this.logger.context("sync").log("start");
const result = await this.__em.schema().sync({ force: true });
this.logger.log("done").clear();
return result;
}
private rebuildServer() {
protected rebuildServer() {
this.server = new Hono<ServerEnv>();
if (this.options?.basePath) {
this.server = this.server.basePath(this.options.basePath);
@@ -299,252 +207,33 @@ export class ModuleManager {
};
}
private async fetch(): Promise<ConfigTable | undefined> {
this.logger.context("fetch").log("fetching");
const startTime = performance.now();
// disabling console log, because the table might not exist yet
const { data: result } = await this.repo().findOne(
{ type: "config" },
{
sort: { by: "version", dir: "desc" },
},
);
if (!result) {
this.logger.log("error fetching").clear();
return undefined;
}
this.logger
.log("took", performance.now() - startTime, "ms", {
version: result.version,
id: result.id,
})
.clear();
return result as unknown as ConfigTable;
}
async save() {
this.logger.context("save").log("saving version", this.version());
const configs = this.configs();
const version = this.version();
try {
const state = await this.fetch();
if (!state) throw new BkndError("no config found");
this.logger.log("fetched version", state.version);
if (state.version !== version) {
// @todo: mark all others as "backup"
this.logger.log("version conflict, storing new version", state.version, version);
await this.mutator().insertOne({
version: state.version,
type: "backup",
json: configs,
});
await this.mutator().insertOne({
version: version,
type: "config",
json: configs,
});
} else {
this.logger.log("version matches", state.version);
// clean configs because of Diff() function
const diffs = $diff.diff(state.json, $diff.clone(configs));
this.logger.log("checking diff", [diffs.length]);
if (diffs.length > 0) {
// validate diffs, it'll throw on invalid
this.validateDiffs(diffs);
const date = new Date();
// store diff
await this.mutator().insertOne({
version,
type: "diff",
json: $diff.clone(diffs),
created_at: date,
updated_at: date,
});
// store new version
await this.mutator().updateWhere(
{
version,
json: configs,
updated_at: date,
} as any,
{
type: "config",
version,
},
);
} else {
this.logger.log("no diff, not saving");
}
}
} catch (e) {
if (e instanceof BkndError && e.message === "no config found") {
this.logger.log("no config, just save fresh");
// no config, just save
await this.mutator().insertOne({
type: "config",
version,
json: configs,
created_at: new Date(),
updated_at: new Date(),
});
} else if (e instanceof TransformPersistFailedException) {
$console.error("ModuleManager: Cannot save invalid config");
this.revertModules();
throw e;
} else {
$console.error("ModuleManager: Aborting");
this.revertModules();
throw e;
}
}
// re-apply configs to all modules (important for system entities)
this.setConfigs(configs);
// @todo: cleanup old versions?
this.logger.clear();
return this;
}
private revertModules() {
if (this._stable_configs) {
$console.warn("ModuleManager: Reverting modules");
this.setConfigs(this._stable_configs as any);
} else {
$console.error("ModuleManager: No stable configs to revert to");
}
}
/**
* Validates received diffs for an additional security control.
* Checks:
* - check if module is registered
* - run modules onBeforeUpdate() for added protection
*
* **Important**: Throw `Error` so it won't get catched.
*
* @param diffs
* @private
*/
private validateDiffs(diffs: $diff.DiffEntry[]): void {
// check top level paths, and only allow a single module to be modified in a single transaction
const modules = [...new Set(diffs.map((d) => d.p[0]))];
if (modules.length === 0) {
return;
}
for (const moduleName of modules) {
const name = moduleName as ModuleKey;
const module = this.get(name) as Module;
if (!module) {
const msg = "validateDiffs: module not registered";
// biome-ignore format: ...
$console.error(msg, JSON.stringify({ module: name, diffs }, null, 2));
throw new Error(msg);
}
// pass diffs to the module to allow it to throw
if (this._stable_configs?.[name]) {
const current = $diff.clone(this._stable_configs?.[name]);
const modified = $diff.apply({ [name]: current }, diffs)[name];
module.onBeforeUpdate(current, modified);
}
}
}
private setConfigs(configs: ModuleConfigs): void {
protected async setConfigs(configs: ModuleConfigs): Promise<void> {
this.logger.log("setting configs");
objectEach(configs, (config, key) => {
for await (const [key, config] of Object.entries(configs)) {
try {
// setting "noEmit" to true, to not force listeners to update
this.modules[key].schema().set(config as any, true);
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?: { fetch?: boolean }) {
this.logger.context("build").log("version", this.version());
await this.ctx().connection.init();
async build(opts?: any) {
this.createModules(this.options?.initial ?? {});
await this.buildModules();
// if no config provided, try fetch from db
if (this.version() === 0 || opts?.fetch === true) {
if (opts?.fetch) {
this.logger.log("force fetch");
}
const result = await this.fetch();
// if no version, and nothing found, go with initial
if (!result) {
this.logger.log("nothing in database, go initial");
await this.setupInitial();
} else {
this.logger.log("db has", result.version);
// set version and config from fetched
this._version = result.version;
if (this.options?.trustFetched === true) {
this.logger.log("trusting fetched config (mark)");
mark(result.json);
}
// if version doesn't match, migrate before building
if (this.version() !== CURRENT_VERSION) {
this.logger.log("now migrating");
await this.syncConfigTable();
const version_before = this.version();
const [_version, _configs] = await migrate(version_before, result.json, {
db: this.db,
});
this._version = _version;
this.ctx().flags.sync_required = true;
this.logger.log("migrated to", _version);
$console.log("Migrated config from", version_before, "to", this.version());
this.createModules(_configs);
await this.buildModules();
} else {
this.logger.log("version is current", this.version());
this.createModules(result.json);
await this.buildModules();
}
}
} else {
if (this.version() !== CURRENT_VERSION) {
throw new Error(
`Given version (${this.version()}) and current version (${CURRENT_VERSION}) do not match.`,
);
}
this.logger.log("current version is up to date", this.version());
await this.buildModules();
}
this.logger.log("done");
this.logger.clear();
return this;
}
private async buildModules(options?: { graceful?: boolean; ignoreFlags?: boolean }) {
protected async buildModules(options?: {
graceful?: boolean;
ignoreFlags?: boolean;
drop?: boolean;
}) {
const state = {
built: false,
modules: [] as ModuleKey[],
@@ -580,12 +269,8 @@ export class ModuleManager {
this.logger.log("db sync requested");
// sync db
await ctx.em.schema().sync({ force: true });
await ctx.em.schema().sync({ force: true, drop: options?.drop });
state.synced = true;
// save
await this.save();
state.saved = true;
}
if (ctx.flags.ctx_reload_required) {
@@ -601,92 +286,12 @@ export class ModuleManager {
ctx.flags = Module.ctx_flags;
// storing last stable config version
this._stable_configs = $diff.clone(this.configs());
//this._stable_configs = $diff.clone(this.configs());
this.logger.clear();
return state;
}
protected async setupInitial() {
this.logger.context("initial").log("start");
this._version = CURRENT_VERSION;
await this.syncConfigTable();
const state = await this.buildModules();
if (!state.saved) {
await this.save();
}
const ctx = {
...this.ctx(),
// disable events for initial setup
em: this.ctx().em.fork(),
};
// perform a sync
await ctx.em.schema().sync({ force: true });
await this.options?.seed?.(ctx);
// run first boot event
await this.options?.onFirstBoot?.();
this.logger.clear();
}
mutateConfigSafe<Module extends keyof Modules>(
name: Module,
): Pick<ReturnType<Modules[Module]["schema"]>, "set" | "patch" | "overwrite" | "remove"> {
const module = this.modules[name];
return new Proxy(module.schema(), {
get: (target, prop: string) => {
if (!["set", "patch", "overwrite", "remove"].includes(prop)) {
throw new Error(`Method ${prop} is not allowed`);
}
return async (...args) => {
$console.log("[Safe Mutate]", name);
try {
// overwrite listener to run build inside this try/catch
module.setListener(async () => {
await this.emgr.emit(
new ModuleManagerConfigUpdateEvent({
ctx: this.ctx(),
module: name,
config: module.config as any,
}),
);
await this.buildModules();
});
const result = await target[prop](...args);
// revert to original listener
module.setListener(async (c) => {
await this.onModuleConfigUpdated(name, c);
});
// if there was an onUpdated listener, call it after success
// e.g. App uses it to register module routes
if (this.options?.onUpdated) {
await this.options.onUpdated(name, module.config as any);
}
return result;
} catch (e) {
$console.error(`[Safe Mutate] failed "${name}":`, e);
// revert to previous config & rebuild using original listener
this.revertModules();
await this.onModuleConfigUpdated(name, module.config as any);
$console.warn(`[Safe Mutate] reverted "${name}":`);
// make sure to throw the error
throw e;
}
};
},
});
}
get<K extends keyof Modules>(key: K): Modules[K] {
if (!(key in this.modules)) {
throw new Error(`Module "${key}" doesn't exist, cannot get`);
@@ -695,7 +300,7 @@ export class ModuleManager {
}
version() {
return this._version;
return 0;
}
built() {

View File

@@ -0,0 +1,594 @@
import { mark, stripMark, $console, s, SecretSchema, setPath } from "bknd/utils";
import { BkndError } from "core/errors";
import * as $diff from "core/object/diff";
import type { Connection } from "data/connection";
import type { EntityManager } from "data/entities/EntityManager";
import * as proto from "data/prototype";
import { TransformPersistFailedException } from "data/errors";
import type { Kysely } from "kysely";
import { mergeWith } from "lodash-es";
import { CURRENT_VERSION, TABLE_NAME, migrate } from "./migrations";
import { Module, type ModuleBuildContext } from "../Module";
import {
type InitialModuleConfigs,
type ModuleConfigs,
type Modules,
type ModuleKey,
getDefaultSchema,
getDefaultConfig,
ModuleManager,
ModuleManagerConfigUpdateEvent,
type ModuleManagerOptions,
ModuleManagerSecretsExtractedEvent,
} from "../ModuleManager";
export type { ModuleBuildContext };
export type ConfigTable<Json = ModuleConfigs> = {
id?: number;
version: number;
type: "config" | "diff" | "backup";
json: Json;
created_at?: Date;
updated_at?: Date;
};
const configJsonSchema = s.anyOf([
getDefaultSchema(),
s.array(
s.strictObject({
t: s.string({ enum: ["a", "r", "e"] }),
p: s.array(s.anyOf([s.string(), s.number()])),
o: s.any().optional(),
n: s.any().optional(),
}),
),
]);
export const __bknd = proto.entity(TABLE_NAME, {
version: proto.number().required(),
type: proto.enumm({ enum: ["config", "diff", "backup", "secrets"] }).required(),
json: proto.jsonSchema({ schema: configJsonSchema.toJSON() }).required(),
created_at: proto.datetime(),
updated_at: proto.datetime(),
});
const __schema = proto.em({ __bknd }, ({ index }, { __bknd }) => {
index(__bknd).on(["version", "type"]);
});
type ConfigTable2 = proto.Schema<typeof __bknd>;
interface T_INTERNAL_EM {
__bknd: ConfigTable2;
}
// @todo: cleanup old diffs on upgrade
// @todo: cleanup multiple backups on upgrade
export class DbModuleManager extends ModuleManager {
// internal em for __bknd config table
__em!: EntityManager<T_INTERNAL_EM>;
private _version: number = 0;
private readonly _booted_with?: "provided" | "partial";
private _stable_configs: ModuleConfigs | undefined;
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 && options.initial.version) {
const { version: _v, ...config } = options.initial;
version = _v as number;
initial = stripMark(config) as any;
booted_with = "provided";
} else {
initial = mergeWith(getDefaultConfig(), options.initial);
booted_with = "partial";
}
}
super(connection, { ...options, initial });
this.__em = __schema.proto.withConnection(this.connection) as any;
//this.__em = new EntityManager(__schema.entities, this.connection);
this._version = version;
this._booted_with = booted_with;
this.logger.log("booted with", this._booted_with);
}
/**
* This is set through module's setListener
* It's called everytime a module's config is updated in SchemaObject
* Needs to rebuild modules and save to database
*/
protected override async onModuleConfigUpdated(key: string, config: any) {
if (this.options?.onUpdated) {
await this.options.onUpdated(key as any, config);
} else {
await this.buildModules();
}
}
private repo() {
return this.__em.repo(__bknd, {
// to prevent exceptions when table doesn't exist
silent: true,
// disable counts for performance and compatibility
includeCounts: false,
});
}
private mutator() {
return this.__em.mutator(__bknd);
}
private get db() {
// @todo: check why this is neccessary
return this.connection.kysely as unknown as Kysely<{ table: ConfigTable }>;
}
// @todo: add indices for: version, type
async syncConfigTable() {
this.logger.context("sync").log("start");
const result = await this.__em.schema().sync({ force: true });
this.logger.log("done").clear();
return result;
}
private async fetch(): Promise<{ configs?: ConfigTable; secrets?: ConfigTable } | undefined> {
this.logger.context("fetch").log("fetching");
const startTime = performance.now();
// disabling console log, because the table might not exist yet
const { data: result } = await this.repo().findMany({
where: { type: { $in: ["config", "secrets"] } },
sort: { by: "version", dir: "desc" },
});
if (!result.length) {
this.logger.log("error fetching").clear();
return undefined;
}
const configs = result.filter((r) => r.type === "config")[0];
const secrets = result.filter((r) => r.type === "secrets")[0];
this.logger
.log("took", performance.now() - startTime, "ms", {
version: result.version,
id: result.id,
})
.clear();
return { configs, secrets };
}
extractSecrets() {
const moduleConfigs = structuredClone(this.configs());
const secrets = this.options?.secrets || ({} as any);
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,
);
//console.log("extracted", key, extracted, config);
for (const n of extracted) {
const path = [key, ...n.instancePath].join(".");
if (typeof n.data === "string" && n.data.length > 0) {
secrets[path] = n.data;
setPath(moduleConfigs, path, "");
}
}
}
return {
configs: moduleConfigs,
secrets,
};
}
async save() {
this.logger.context("save").log("saving version", this.version());
const { configs, secrets } = this.extractSecrets();
const version = this.version();
const store_secrets = this.options?.storeSecrets !== false;
await this.emgr.emit(
new ModuleManagerSecretsExtractedEvent({
ctx: this.ctx(),
secrets,
}),
);
try {
const state = await this.fetch();
if (!state || !state.configs) throw new BkndError("no config found");
this.logger.log("fetched version", state.configs.version);
if (state.configs.version !== version) {
// @todo: mark all others as "backup"
this.logger.log(
"version conflict, storing new version",
state.configs.version,
version,
);
const updates = [
{
version: state.configs.version,
type: "backup",
json: state.configs.json,
},
{
version: version,
type: "config",
json: configs,
},
];
if (store_secrets) {
updates.push({
version: state.configs.version,
type: "secrets",
json: secrets,
});
}
await this.mutator().insertMany(updates);
} else {
this.logger.log("version matches", state.configs.version);
// clean configs because of Diff() function
const diffs = $diff.diff(state.configs.json, $diff.clone(configs));
this.logger.log("checking diff", [diffs.length]);
const date = new Date();
if (diffs.length > 0) {
// validate diffs, it'll throw on invalid
this.validateDiffs(diffs);
// store diff
await this.mutator().insertOne({
version,
type: "diff",
json: $diff.clone(diffs),
created_at: date,
updated_at: date,
});
// store new version
await this.mutator().updateWhere(
{
version,
json: configs,
updated_at: date,
} as any,
{
type: "config",
version,
},
);
} else {
this.logger.log("no diff, not saving");
}
// store secrets
if (store_secrets) {
if (!state.secrets || state.secrets?.version !== version) {
await this.mutator().insertOne({
version: state.configs.version,
type: "secrets",
json: secrets,
created_at: date,
updated_at: date,
});
} else {
await this.mutator().updateOne(state.secrets.id!, {
version,
json: secrets,
updated_at: date,
} as any);
}
}
}
} catch (e) {
if (e instanceof BkndError && e.message === "no config found") {
this.logger.log("no config, just save fresh");
// no config, just save
await this.mutator().insertOne({
type: "config",
version,
json: configs,
created_at: new Date(),
updated_at: new Date(),
});
} else if (e instanceof TransformPersistFailedException) {
$console.error("ModuleManager: Cannot save invalid config");
this.revertModules();
throw e;
} else {
$console.error("ModuleManager: Aborting");
this.revertModules();
throw e;
}
}
// re-apply configs to all modules (important for system entities)
await this.setConfigs(this.configs());
// @todo: cleanup old versions?
this.logger.clear();
return this;
}
private async revertModules() {
if (this._stable_configs) {
$console.warn("ModuleManager: Reverting modules");
await this.setConfigs(this._stable_configs as any);
} else {
$console.error("ModuleManager: No stable configs to revert to");
}
}
/**
* Validates received diffs for an additional security control.
* Checks:
* - check if module is registered
* - run modules onBeforeUpdate() for added protection
*
* **Important**: Throw `Error` so it won't get catched.
*
* @param diffs
* @private
*/
private validateDiffs(diffs: $diff.DiffEntry[]): void {
// check top level paths, and only allow a single module to be modified in a single transaction
const modules = [...new Set(diffs.map((d) => d.p[0]))];
if (modules.length === 0) {
return;
}
for (const moduleName of modules) {
const name = moduleName as ModuleKey;
const module = this.get(name) as Module;
if (!module) {
const msg = "validateDiffs: module not registered";
// biome-ignore format: ...
$console.error(msg, JSON.stringify({ module: name, diffs }, null, 2));
throw new Error(msg);
}
// pass diffs to the module to allow it to throw
if (this._stable_configs?.[name]) {
const current = $diff.clone(this._stable_configs?.[name]);
const modified = $diff.apply({ [name]: current }, diffs)[name];
module.onBeforeUpdate(current, modified);
}
}
}
override async build(opts?: { fetch?: boolean }) {
this.logger.context("build").log("version", this.version());
await this.ctx().connection.init();
// if no config provided, try fetch from db
if (this.version() === 0 || opts?.fetch === true) {
if (opts?.fetch) {
this.logger.log("force fetch");
}
const result = await this.fetch();
// if no version, and nothing found, go with initial
if (!result?.configs) {
this.logger.log("nothing in database, go initial");
await this.setupInitial();
} else {
this.logger.log("db has", result.configs.version);
// set version and config from fetched
this._version = result.configs.version;
if (result?.configs && result?.secrets) {
for (const [key, value] of Object.entries(result.secrets.json)) {
setPath(result.configs.json, key, value);
}
}
if (this.options?.trustFetched === true) {
this.logger.log("trusting fetched config (mark)");
mark(result.configs.json);
}
// if version doesn't match, migrate before building
if (this.version() !== CURRENT_VERSION) {
this.logger.log("now migrating");
await this.syncConfigTable();
const version_before = this.version();
const [_version, _configs] = await migrate(version_before, result.configs.json, {
db: this.db,
});
this._version = _version;
this.ctx().flags.sync_required = true;
this.logger.log("migrated to", _version);
$console.log("Migrated config from", version_before, "to", this.version());
// @ts-expect-error
await this.setConfigs(_configs);
await this.buildModules();
} else {
this.logger.log("version is current", this.version());
await this.setConfigs(result.configs.json);
await this.buildModules();
}
}
} else {
if (this.version() !== CURRENT_VERSION) {
throw new Error(
`Given version (${this.version()}) and current version (${CURRENT_VERSION}) do not match.`,
);
}
this.logger.log("current version is up to date", this.version());
await this.buildModules();
}
this.logger.log("done");
this.logger.clear();
return this;
}
protected override async buildModules(options?: { graceful?: boolean; ignoreFlags?: 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 });
state.synced = true;
// save
await this.save();
state.saved = 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;
}
protected async setupInitial() {
this.logger.context("initial").log("start");
this._version = CURRENT_VERSION;
await this.syncConfigTable();
const state = await this.buildModules();
if (!state.saved) {
await this.save();
}
const ctx = {
...this.ctx(),
// disable events for initial setup
em: this.ctx().em.fork(),
};
// perform a sync
await ctx.em.schema().sync({ force: true });
await this.options?.seed?.(ctx);
// run first boot event
await this.options?.onFirstBoot?.();
this.logger.clear();
}
mutateConfigSafe<Module extends keyof Modules>(
name: Module,
): Pick<ReturnType<Modules[Module]["schema"]>, "set" | "patch" | "overwrite" | "remove"> {
const module = this.modules[name];
return new Proxy(module.schema(), {
get: (target, prop: string) => {
if (!["set", "patch", "overwrite", "remove"].includes(prop)) {
throw new Error(`Method ${prop} is not allowed`);
}
return async (...args) => {
$console.log("[Safe Mutate]", name);
try {
// overwrite listener to run build inside this try/catch
module.setListener(async () => {
await this.emgr.emit(
new ModuleManagerConfigUpdateEvent({
ctx: this.ctx(),
module: name,
config: module.config as any,
}),
);
await this.buildModules();
});
const result = await target[prop](...args);
// revert to original listener
module.setListener(async (c) => {
await this.onModuleConfigUpdated(name, c);
});
// if there was an onUpdated listener, call it after success
// e.g. App uses it to register module routes
if (this.options?.onUpdated) {
await this.options.onUpdated(name, module.config as any);
}
return result;
} catch (e) {
$console.error(`[Safe Mutate] failed "${name}":`, e);
// revert to previous config & rebuild using original listener
this.revertModules();
await this.onModuleConfigUpdated(name, module.config as any);
$console.warn(`[Safe Mutate] reverted "${name}":`);
// make sure to throw the error
throw e;
}
};
},
});
}
override version() {
return this._version;
}
}

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ import {
} from "bknd/utils";
import type { ModuleBuildContext } from "modules";
import { excludePropertyTypes, rescursiveClean } from "./utils";
import type { DbModuleManager } from "modules/db/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");
}
}

View File

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

View File

@@ -30,7 +30,7 @@ export const serverConfigSchema = $object(
{
description: "Server configuration",
},
);
).strict();
export type AppServerConfig = s.Static<typeof serverConfigSchema>;

View File

@@ -31,6 +31,7 @@ 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 type { DbModuleManager } from "modules/db/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().then((r) => r?.configs));
},
);
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(),
@@ -381,6 +409,8 @@ export class SystemController extends Controller {
config: c.get("app")?.version(),
bknd: getVersion(),
},
mode: this.app.mode,
readonly: this.app.isReadOnly(),
runtime: getRuntimeKey(),
connection: {
name: this.app.em.connection.name,

View File

@@ -28,7 +28,7 @@ export function syncConfig({
if (firstBoot) {
firstBoot = false;
await write?.(app.toJSON(true));
await write?.(app.toJSON(includeSecrets));
}
},
});

View File

@@ -0,0 +1,37 @@
import { type App, ModuleManagerEvents, type AppPlugin } from "bknd";
import type { DbModuleManager } from "modules/db/DbModuleManager";
export type SyncSecretsOptions = {
enabled?: boolean;
write: (secrets: Record<string, any>) => Promise<void>;
};
export function syncSecrets({ enabled = true, write }: SyncSecretsOptions): AppPlugin {
let firstBoot = true;
return (app: App) => ({
name: "bknd-sync-secrets",
onBuilt: async () => {
if (!enabled) return;
const manager = app.modules as DbModuleManager;
if (!("extractSecrets" in manager)) {
throw new Error("ModuleManager does not support secrets");
}
app.emgr.onEvent(
ModuleManagerEvents.ModuleManagerSecretsExtractedEvent,
async ({ params: { secrets } }) => {
await write?.(secrets);
},
{
id: "sync-secrets",
},
);
if (firstBoot) {
firstBoot = false;
await write?.(manager.extractSecrets().secrets);
}
},
});
}

View File

@@ -6,3 +6,4 @@ export {
export { showRoutes, type ShowRoutesOptions } from "./dev/show-routes.plugin";
export { syncConfig, type SyncConfigOptions } from "./dev/sync-config.plugin";
export { syncTypes, type SyncTypesOptions } from "./dev/sync-types.plugin";
export { syncSecrets, type SyncSecretsOptions } from "./dev/sync-secrets.plugin";

View File

@@ -1,6 +1,14 @@
import type { ModuleConfigs, ModuleSchemas } from "modules";
import { getDefaultConfig, getDefaultSchema } from "modules/ModuleManager";
import { createContext, startTransition, useContext, useEffect, useRef, useState } from "react";
import {
createContext,
startTransition,
useContext,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { useApi } from "ui/client";
import { type TSchemaActions, getSchemaActions } from "./schema/actions";
import { AppReduced } from "./utils/AppReduced";
@@ -15,6 +23,7 @@ export type BkndAdminOptions = {
};
type BkndContext = {
version: number;
readonly: boolean;
schema: ModuleSchemas;
config: ModuleConfigs;
permissions: string[];
@@ -48,7 +57,12 @@ export function BkndProvider({
}) {
const [withSecrets, setWithSecrets] = useState<boolean>(includeSecrets);
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 [error, setError] = useState<boolean>();
const errorShown = useRef<boolean>(false);
@@ -97,6 +111,7 @@ export function BkndProvider({
? res.body
: ({
version: 0,
mode: "db",
schema: getDefaultSchema(),
config: getDefaultConfig(),
permissions: [],
@@ -173,3 +188,8 @@ export function useBkndOptions(): BkndAdminOptions {
}
);
}
export function SchemaEditable({ children }: { children: ReactNode }) {
const { readonly } = useBknd();
return !readonly ? children : null;
}

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 { routes, useNavigate, useRouteNavigate } from "ui/lib/routes";
import { testIds } from "ui/lib/config";
import { SchemaEditable, useBknd } from "ui/client/bknd";
export function DataRoot({ children }) {
// @todo: settings routes should be centralized
@@ -73,9 +74,11 @@ export function DataRoot({ children }) {
value={context}
onChange={handleSegmentChange}
/>
<Tooltip label="New Entity">
<IconButton Icon={TbDatabasePlus} onClick={$data.modals.createEntity} />
</Tooltip>
<SchemaEditable>
<Tooltip label="New Entity">
<IconButton Icon={TbDatabasePlus} onClick={$data.modals.createEntity} />
</Tooltip>
</SchemaEditable>
</>
}
>
@@ -254,11 +257,26 @@ export function DataEmpty() {
useBrowserTitle(["Data"]);
const [navigate] = useNavigate();
const { $data } = useBkndData();
const { readonly } = useBknd();
function handleButtonClick() {
navigate(routes.data.schema.root());
}
if (readonly) {
return (
<Empty
Icon={IconDatabase}
title="No entity selected"
description="Please select an entity from the left sidebar."
primary={{
children: "Go to schema",
onClick: handleButtonClick,
}}
/>
);
}
return (
<Empty
Icon={IconDatabase}

View File

@@ -31,6 +31,7 @@ import { fieldSpecs } from "ui/modules/data/components/fields-specs";
import { extractSchema } from "../settings/utils/schema";
import { EntityFieldsForm, type EntityFieldsFormRef } from "./forms/entity.fields.form";
import { RoutePathStateProvider } from "ui/hooks/use-route-path-state";
import { SchemaEditable, useBknd } from "ui/client/bknd";
export function DataSchemaEntity({ params }) {
const { $data } = useBkndData();
@@ -67,29 +68,31 @@ export function DataSchemaEntity({ params }) {
>
<IconButton Icon={TbDots} />
</Dropdown>
<Dropdown
items={[
{
icon: TbCirclesRelation,
label: "Add relation",
onClick: () => $data.modals.createRelation(entity.name),
},
{
icon: TbPhoto,
label: "Add media",
onClick: () => $data.modals.createMedia(entity.name),
},
() => <div className="h-px my-1 w-full bg-primary/5" />,
{
icon: TbDatabasePlus,
label: "Create Entity",
onClick: () => $data.modals.createEntity(),
},
]}
position="bottom-end"
>
<Button IconRight={TbPlus}>Add</Button>
</Dropdown>
<SchemaEditable>
<Dropdown
items={[
{
icon: TbCirclesRelation,
label: "Add relation",
onClick: () => $data.modals.createRelation(entity.name),
},
{
icon: TbPhoto,
label: "Add media",
onClick: () => $data.modals.createMedia(entity.name),
},
() => <div className="h-px my-1 w-full bg-primary/5" />,
{
icon: TbDatabasePlus,
label: "Create Entity",
onClick: () => $data.modals.createEntity(),
},
]}
position="bottom-end"
>
<Button IconRight={TbPlus}>Add</Button>
</Dropdown>
</SchemaEditable>
</>
}
className="pl-3"
@@ -149,6 +152,7 @@ const Fields = ({ entity }: { entity: Entity }) => {
const [submitting, setSubmitting] = useState(false);
const [updates, setUpdates] = useState(0);
const { actions, $data, config } = useBkndData();
const { readonly } = useBknd();
const [res, setRes] = useState<any>();
const ref = useRef<EntityFieldsFormRef>(null);
async function handleUpdate() {
@@ -169,7 +173,7 @@ const Fields = ({ entity }: { entity: Entity }) => {
title="Fields"
ActiveIcon={IconAlignJustified}
renderHeaderRight={({ open }) =>
open ? (
open && !readonly ? (
<Button variant="primary" disabled={!open} onClick={handleUpdate}>
Update
</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" />
)}
<EntityFieldsForm
readonly={readonly}
routePattern={`/entity/${entity.name}/fields/:sub?`}
fields={initialFields}
ref={ref}
key={String(updates)}
sortable
sortable={!readonly}
additionalFieldTypes={fieldSpecs
.filter((f) => ["relation", "media"].includes(f.type))
.map((i) => ({
@@ -205,7 +210,7 @@ const Fields = ({ entity }: { entity: Entity }) => {
isNew={false}
/>
{isDebug() && (
{isDebug() && !readonly && (
<div>
<div className="flex flex-row gap-1 justify-center">
<Button size="small" onClick={() => setRes(ref.current?.isValid())}>
@@ -237,6 +242,7 @@ const BasicSettings = ({ entity }: { entity: Entity }) => {
const d = useBkndData();
const config = d.entities?.[entity.name]?.config;
const formRef = useRef<JsonSchemaFormRef>(null);
const { readonly } = useBknd();
const schema = cloneDeep(
// @ts-ignore
@@ -264,7 +270,7 @@ const BasicSettings = ({ entity }: { entity: Entity }) => {
title="Settings"
ActiveIcon={IconSettings}
renderHeaderRight={({ open }) =>
open ? (
open && !readonly ? (
<Button variant="primary" disabled={!open} onClick={handleUpdate}>
Update
</Button>
@@ -278,6 +284,7 @@ const BasicSettings = ({ entity }: { entity: Entity }) => {
formData={_config}
onSubmit={console.log}
className="legacy hide-required-mark fieldset-alternative mute-root"
readonly={readonly}
/>
</div>
</AppShell.RouteAwareSectionHeaderAccordionItem>

View File

@@ -1,4 +1,5 @@
import { Suspense, lazy } from "react";
import { SchemaEditable } from "ui/client/bknd";
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
import { Button } from "ui/components/buttons/Button";
import * as AppShell from "ui/layouts/AppShell/AppShell";
@@ -15,9 +16,11 @@ export function DataSchemaIndex() {
<>
<AppShell.SectionHeader
right={
<Button type="button" variant="primary" onClick={$data.modals.createAny}>
Create new
</Button>
<SchemaEditable>
<Button type="button" variant="primary" onClick={$data.modals.createAny}>
Create new
</Button>
</SchemaEditable>
}
>
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 { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
import ErrorBoundary from "ui/components/display/ErrorBoundary";
import { SchemaEditable } from "ui/client/bknd";
const fieldsSchemaObject = originalFieldsSchemaObject;
const fieldsSchema = s.anyOf(Object.values(fieldsSchemaObject));
@@ -64,6 +65,7 @@ export type EntityFieldsFormProps = {
routePattern?: string;
defaultPrimaryFormat?: TPrimaryFieldFormat;
isNew?: boolean;
readonly?: boolean;
};
export type EntityFieldsFormRef = {
@@ -76,7 +78,7 @@ export type EntityFieldsFormRef = {
export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsFormProps>(
function EntityFieldsForm(
{ fields: _fields, sortable, additionalFieldTypes, routePattern, isNew, ...props },
{ fields: _fields, sortable, additionalFieldTypes, routePattern, isNew, readonly, ...props },
ref,
) {
const entityFields = Object.entries(_fields).map(([name, field]) => ({
@@ -162,6 +164,7 @@ export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsForm
disableIndices={[0]}
renderItem={({ dnd, ...props }, index) => (
<EntityFieldMemo
readonly={readonly}
key={props.id}
field={props as any}
index={index}
@@ -181,6 +184,7 @@ export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsForm
<div>
{fields.map((field, index) => (
<EntityField
readonly={readonly}
key={field.id}
field={field as any}
index={index}
@@ -197,20 +201,22 @@ export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsForm
</div>
)}
<Popover
className="flex flex-col w-full"
target={({ toggle }) => (
<SelectType
additionalFieldTypes={additionalFieldTypes}
onSelected={toggle}
onSelect={(type) => {
handleAppend(type as any);
}}
/>
)}
>
<Button className="justify-center">Add Field</Button>
</Popover>
<SchemaEditable>
<Popover
className="flex flex-col w-full"
target={({ toggle }) => (
<SelectType
additionalFieldTypes={additionalFieldTypes}
onSelected={toggle}
onSelect={(type) => {
handleAppend(type as any);
}}
/>
)}
>
<Button className="justify-center">Add Field</Button>
</Popover>
</SchemaEditable>
</div>
</div>
</div>
@@ -288,6 +294,7 @@ function EntityField({
dnd,
routePattern,
primary,
readonly,
}: {
field: FieldArrayWithId<TFieldsFormSchema, "fields", "id">;
index: number;
@@ -303,6 +310,7 @@ function EntityField({
defaultFormat?: TPrimaryFieldFormat;
editable?: boolean;
};
readonly?: boolean;
}) {
const prefix = `fields.${index}.field` as const;
const type = field.field.type;
@@ -393,6 +401,7 @@ function EntityField({
<span className="text-xs text-primary/50 leading-none">Required</span>
<MantineSwitch
size="sm"
disabled={readonly}
name={`${prefix}.config.required`}
control={control}
/>
@@ -433,6 +442,7 @@ function EntityField({
<div className="flex flex-row">
<MantineSwitch
label="Required"
disabled={readonly}
name={`${prefix}.config.required`}
control={control}
/>
@@ -440,11 +450,13 @@ function EntityField({
<TextInput
label="Label"
placeholder="Label"
disabled={readonly}
{...register(`${prefix}.config.label`)}
/>
<Textarea
label="Description"
placeholder="Description"
disabled={readonly}
{...register(`${prefix}.config.description`)}
/>
{!hidden.includes("virtual") && (
@@ -452,7 +464,7 @@ function EntityField({
label="Virtual"
name={`${prefix}.config.virtual`}
control={control}
disabled={disabled.includes("virtual")}
disabled={disabled.includes("virtual") || readonly}
/>
)}
</div>
@@ -468,6 +480,7 @@ function EntityField({
...value,
});
}}
readonly={readonly}
/>
</ErrorBoundary>
</div>
@@ -478,16 +491,18 @@ function EntityField({
return <JsonViewer json={json} expand={4} />;
})()}
</Tabs.Panel>
<div className="flex flex-row justify-end">
<Button
IconLeft={TbTrash}
onClick={handleDelete(index)}
size="small"
variant="subtlered"
>
Delete
</Button>
</div>
{!readonly && (
<div className="flex flex-row justify-end">
<Button
IconLeft={TbTrash}
onClick={handleDelete(index)}
size="small"
variant="subtlered"
>
Delete
</Button>
</div>
)}
</Tabs>
</div>
)}
@@ -498,9 +513,11 @@ function EntityField({
const SpecificForm = ({
field,
onChange,
readonly,
}: {
field: FieldArrayWithId<TFieldsFormSchema, "fields", "id">;
onChange: (value: any) => void;
readonly?: boolean;
}) => {
const type = field.field.type;
const specificData = omit(field.field.config, commonProps);
@@ -513,6 +530,7 @@ const SpecificForm = ({
uiSchema={dataFieldsUiSchema.config}
className="legacy hide-required-mark fieldset-alternative mute-root"
onChange={onChange}
readonly={readonly}
/>
);
};

View File

@@ -54,7 +54,7 @@ export function Setting<Schema extends s.ObjectSchema = s.ObjectSchema>({
properties,
}: SettingProps<Schema>) {
const [submitting, setSubmitting] = useState(false);
const { actions } = useBknd();
const { actions, readonly } = useBknd();
const formRef = useRef<JsonSchemaFormRef>(null);
const schemaLocalModalRef = 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];
const onToggleEdit = useEvent(() => {
if (!editAllowed) return;
if (!editAllowed || readonly) return;
setEditing((prev) => !prev);
//formRef.current?.cancel();
});
const onSave = useEvent(async () => {
if (!editAllowed || !editing) return;
if (!editAllowed || !editing || readonly) return;
if (formRef.current?.validateForm()) {
setSubmitting(true);
@@ -215,14 +215,14 @@ export function Setting<Schema extends s.ObjectSchema = s.ObjectSchema>({
>
<IconButton Icon={TbSettings} />
</Dropdown>
<Button onClick={onToggleEdit} disabled={!editAllowed}>
<Button onClick={onToggleEdit} disabled={!editAllowed || readonly}>
{editing ? "Cancel" : "Edit"}
</Button>
{editing && (
<Button
variant="primary"
onClick={onSave}
disabled={submitting || !editAllowed}
disabled={submitting || !editAllowed || readonly}
>
{submitting ? "Save..." : "Save"}
</Button>

View File

@@ -30,6 +30,7 @@ export default defineConfig({
devServer({
...devServerConfig,
entry: "./vite.dev.ts",
//entry: "./vite.dev.code.ts",
}),
tailwindcss(),
],

View File

@@ -4,7 +4,7 @@ import { showRoutes } from "hono/dev";
import { App, registries } from "./src";
import { StorageLocalAdapter } from "./src/adapter/node";
import type { Connection } from "./src/data/connection/Connection";
import { __bknd } from "modules/ModuleManager";
import { __bknd } from "modules/db/DbModuleManager";
import { nodeSqlite } from "./src/adapter/node/connection/NodeSqliteConnection";
import { libsql } from "./src/data/connection/sqlite/libsql/LibsqlConnection";
import { $console } from "core/utils/console";

View File

@@ -28,7 +28,7 @@ export default {
},
}),
// an initial config is only applied if the database is empty
initialConfig: {
config: {
data: schema.toJSON(),
// we're enabling auth ...
auth: {

View File

@@ -8,7 +8,7 @@ const config: BunBkndConfig = {
connection: {
url: "file:data.db",
},
initialConfig: {
config: {
media: {
enabled: true,
adapter: {

View File

@@ -95,10 +95,7 @@ web_modules/
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
.env.*
# parcel-bundler cache (https://parceljs.org/)

View File

@@ -15,6 +15,6 @@
},
"devDependencies": {
"typescript": "^5.9.2",
"wrangler": "^4.28.1"
"wrangler": "^4.34.0"
}
}

View File

@@ -33,7 +33,7 @@ export default {
},
}),
// an initial config is only applied if the database is empty
initialConfig: {
config: {
data: schema.toJSON(),
// we're enabling auth ...
auth: {

View File

@@ -3,7 +3,7 @@ import { App, boolean, em, entity, text } from "bknd";
import { secureRandomString } from "bknd/utils";
export default serve({
initialConfig: {
config: {
data: em({
todos: entity("todos", {
title: text(),

View File

@@ -27,7 +27,7 @@ export default {
},
}),
// an initial config is only applied if the database is empty
initialConfig: {
config: {
data: schema.toJSON(),
// we're enabling auth ...
auth: {

View File

@@ -25,7 +25,7 @@ export default {
url: process.env.DB_URL ?? "file:data.db",
},
// an initial config is only applied if the database is empty
initialConfig: {
config: {
data: schema.toJSON(),
// we're enabling auth ...
auth: {

View File

@@ -91,7 +91,7 @@ export function testSuite(config: TestSuiteConfig) {
const app = createApp({
connection,
initialConfig: {
config: {
data: schema.toJSON(),
},
});
@@ -177,7 +177,7 @@ export function testSuite(config: TestSuiteConfig) {
const app = createApp({
connection,
initialConfig: {
config: {
data: schema.toJSON(),
},
});

View File

@@ -37,7 +37,7 @@ describe("integration", () => {
const app = createApp({
connection: create(),
initialConfig: {
config: {
data: schema.toJSON(),
},
});