mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-17 12:56:05 +00:00
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:
@@ -19,7 +19,7 @@ describe("App tests", async () => {
|
|||||||
test("plugins", async () => {
|
test("plugins", async () => {
|
||||||
const called: string[] = [];
|
const called: string[] = [];
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
initialConfig: {
|
config: {
|
||||||
auth: {
|
auth: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,52 +19,16 @@ describe("adapter", () => {
|
|||||||
expect(
|
expect(
|
||||||
omitKeys(
|
omitKeys(
|
||||||
await adapter.makeConfig(
|
await adapter.makeConfig(
|
||||||
{ app: (a) => ({ initialConfig: { server: { cors: { origin: a.env.TEST } } } }) },
|
{ app: (a) => ({ config: { server: { cors: { origin: a.env.TEST } } } }) },
|
||||||
{ env: { TEST: "test" } },
|
{ env: { TEST: "test" } },
|
||||||
),
|
),
|
||||||
["connection"],
|
["connection"],
|
||||||
),
|
),
|
||||||
).toEqual({
|
).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, {
|
adapterTestSuite(bunTestRunner, {
|
||||||
makeApp: adapter.createFrameworkApp,
|
makeApp: adapter.createFrameworkApp,
|
||||||
label: "framework app",
|
label: "framework app",
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import { describe, expect, mock, test } from "bun:test";
|
import { describe, expect, mock, test } from "bun:test";
|
||||||
import type { ModuleBuildContext } from "../../src";
|
import type { ModuleBuildContext } from "../../src";
|
||||||
import { App, createApp } from "core/test/utils";
|
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", () => {
|
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 () => {
|
test("seed includes ctx and app", async () => {
|
||||||
const called = mock(() => null);
|
const called = mock(() => null);
|
||||||
await createApp({
|
await createApp({
|
||||||
@@ -29,7 +39,7 @@ describe("App", () => {
|
|||||||
expect(called).toHaveBeenCalled();
|
expect(called).toHaveBeenCalled();
|
||||||
|
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
initialConfig: {
|
config: {
|
||||||
data: proto
|
data: proto
|
||||||
.em({
|
.em({
|
||||||
todos: proto.entity("todos", {
|
todos: proto.entity("todos", {
|
||||||
@@ -139,7 +149,7 @@ describe("App", () => {
|
|||||||
|
|
||||||
test("getMcpClient", async () => {
|
test("getMcpClient", async () => {
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
initialConfig: {
|
config: {
|
||||||
server: {
|
server: {
|
||||||
mcp: {
|
mcp: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ describe("mcp auth", async () => {
|
|||||||
let server: McpServer;
|
let server: McpServer;
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
app = createApp({
|
app = createApp({
|
||||||
initialConfig: {
|
config: {
|
||||||
auth: {
|
auth: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
jwt: {
|
jwt: {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ describe("mcp", () => {
|
|||||||
registries.media.register("local", StorageLocalAdapter);
|
registries.media.register("local", StorageLocalAdapter);
|
||||||
|
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
initialConfig: {
|
config: {
|
||||||
auth: {
|
auth: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ describe("mcp data", async () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const time = performance.now();
|
const time = performance.now();
|
||||||
app = createApp({
|
app = createApp({
|
||||||
initialConfig: {
|
config: {
|
||||||
server: {
|
server: {
|
||||||
mcp: {
|
mcp: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ describe("mcp media", async () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
registries.media.register("local", StorageLocalAdapter);
|
registries.media.register("local", StorageLocalAdapter);
|
||||||
app = createApp({
|
app = createApp({
|
||||||
initialConfig: {
|
config: {
|
||||||
media: {
|
media: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
adapter: {
|
adapter: {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ describe("mcp system", async () => {
|
|||||||
let server: McpServer;
|
let server: McpServer;
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = createApp({
|
app = createApp({
|
||||||
initialConfig: {
|
config: {
|
||||||
server: {
|
server: {
|
||||||
mcp: {
|
mcp: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ describe("mcp system", async () => {
|
|||||||
let server: McpServer;
|
let server: McpServer;
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = createApp({
|
app = createApp({
|
||||||
initialConfig: {
|
config: {
|
||||||
server: {
|
server: {
|
||||||
mcp: {
|
mcp: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ describe("repros", async () => {
|
|||||||
fns.relation(schema.product_likes).manyToOne(schema.users);
|
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();
|
await app.build();
|
||||||
|
|
||||||
const info = (await (await app.server.request("/api/data/info/products")).json()) as any;
|
const info = (await (await app.server.request("/api/data/info/products")).json()) as any;
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test";
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test";
|
||||||
import { App, createApp } from "../../src";
|
import { App, createApp, type AuthResponse } from "../../src";
|
||||||
import type { AuthResponse } from "../../src/auth";
|
|
||||||
import { auth } from "../../src/auth/middlewares";
|
import { auth } from "../../src/auth/middlewares";
|
||||||
import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils";
|
import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils";
|
||||||
import { disableConsoleLog, enableConsoleLog, getDummyConnection } from "../helper";
|
import { disableConsoleLog, enableConsoleLog, getDummyConnection } from "../helper";
|
||||||
|
|
||||||
const { dummyConnection, afterAllCleanup } = getDummyConnection();
|
|
||||||
afterEach(afterAllCleanup);
|
|
||||||
|
|
||||||
beforeAll(disableConsoleLog);
|
beforeAll(disableConsoleLog);
|
||||||
afterAll(enableConsoleLog);
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
@@ -66,9 +62,10 @@ const configs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function createAuthApp() {
|
function createAuthApp() {
|
||||||
|
const { dummyConnection } = getDummyConnection();
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
connection: dummyConnection,
|
connection: dummyConnection,
|
||||||
initialConfig: {
|
config: {
|
||||||
auth: configs.auth,
|
auth: configs.auth,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const path = `${assetsPath}/image.png`;
|
|||||||
|
|
||||||
async function makeApp(mediaOverride: Partial<TAppMediaConfig> = {}) {
|
async function makeApp(mediaOverride: Partial<TAppMediaConfig> = {}) {
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
initialConfig: {
|
config: {
|
||||||
media: mergeObject(
|
media: mergeObject(
|
||||||
{
|
{
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ describe("AppAuth", () => {
|
|||||||
|
|
||||||
test("registers auth middleware for bknd routes only", async () => {
|
test("registers auth middleware for bknd routes only", async () => {
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
initialConfig: {
|
config: {
|
||||||
auth: {
|
auth: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
jwt: {
|
jwt: {
|
||||||
@@ -177,7 +177,7 @@ describe("AppAuth", () => {
|
|||||||
|
|
||||||
test("should allow additional user fields", async () => {
|
test("should allow additional user fields", async () => {
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
initialConfig: {
|
config: {
|
||||||
auth: {
|
auth: {
|
||||||
entity_name: "users",
|
entity_name: "users",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -201,7 +201,7 @@ describe("AppAuth", () => {
|
|||||||
|
|
||||||
test("ensure user field configs is always correct", async () => {
|
test("ensure user field configs is always correct", async () => {
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
initialConfig: {
|
config: {
|
||||||
auth: {
|
auth: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ describe("AppMedia", () => {
|
|||||||
registries.media.register("local", StorageLocalAdapter);
|
registries.media.register("local", StorageLocalAdapter);
|
||||||
|
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
initialConfig: {
|
config: {
|
||||||
media: {
|
media: {
|
||||||
entity_name: "media",
|
entity_name: "media",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|||||||
22
app/__test__/modules/DbModuleManager.spec.ts
Normal file
22
app/__test__/modules/DbModuleManager.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,8 +2,10 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|||||||
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
||||||
|
|
||||||
import { Module } from "modules/Module";
|
import { Module } from "modules/Module";
|
||||||
import { type ConfigTable, getDefaultConfig, ModuleManager } from "modules/ModuleManager";
|
import { getDefaultConfig } from "modules/ModuleManager";
|
||||||
import { CURRENT_VERSION, TABLE_NAME } from "modules/migrations";
|
import { type ConfigTable, DbModuleManager as ModuleManager } from "modules/db/DbModuleManager";
|
||||||
|
|
||||||
|
import { CURRENT_VERSION, TABLE_NAME } from "modules/db/migrations";
|
||||||
import { getDummyConnection } from "../helper";
|
import { getDummyConnection } from "../helper";
|
||||||
import { s, stripMark } from "core/utils/schema";
|
import { s, stripMark } from "core/utils/schema";
|
||||||
import { Connection } from "data/connection/Connection";
|
import { Connection } from "data/connection/Connection";
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { createApp } from "bknd/adapter/bun";
|
|||||||
async function generate() {
|
async function generate() {
|
||||||
console.info("Generating MCP documentation...");
|
console.info("Generating MCP documentation...");
|
||||||
const app = await createApp({
|
const app = await createApp({
|
||||||
initialConfig: {
|
config: {
|
||||||
server: {
|
server: {
|
||||||
mcp: {
|
mcp: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import type { em as prototypeEm } from "data/prototype";
|
|||||||
import { Connection } from "data/connection/Connection";
|
import { Connection } from "data/connection/Connection";
|
||||||
import type { Hono } from "hono";
|
import type { Hono } from "hono";
|
||||||
import {
|
import {
|
||||||
ModuleManager,
|
|
||||||
type InitialModuleConfigs,
|
type InitialModuleConfigs,
|
||||||
type ModuleBuildContext,
|
|
||||||
type ModuleConfigs,
|
type ModuleConfigs,
|
||||||
type ModuleManagerOptions,
|
|
||||||
type Modules,
|
type Modules,
|
||||||
|
ModuleManager,
|
||||||
|
type ModuleBuildContext,
|
||||||
|
type ModuleManagerOptions,
|
||||||
} from "modules/ModuleManager";
|
} from "modules/ModuleManager";
|
||||||
|
import { DbModuleManager } from "modules/db/DbModuleManager";
|
||||||
import * as SystemPermissions from "modules/permissions";
|
import * as SystemPermissions from "modules/permissions";
|
||||||
import { AdminController, type AdminControllerOptions } from "modules/server/AdminController";
|
import { AdminController, type AdminControllerOptions } from "modules/server/AdminController";
|
||||||
import { SystemController } from "modules/server/SystemController";
|
import { SystemController } from "modules/server/SystemController";
|
||||||
@@ -93,17 +94,19 @@ export type AppOptions = {
|
|||||||
email?: IEmailDriver;
|
email?: IEmailDriver;
|
||||||
cache?: ICacheDriver;
|
cache?: ICacheDriver;
|
||||||
};
|
};
|
||||||
|
mode?: "db" | "code";
|
||||||
|
readonly?: boolean;
|
||||||
};
|
};
|
||||||
export type CreateAppConfig = {
|
export type CreateAppConfig = {
|
||||||
/**
|
/**
|
||||||
* bla
|
* bla
|
||||||
*/
|
*/
|
||||||
connection?: Connection | { url: string };
|
connection?: Connection | { url: string };
|
||||||
initialConfig?: InitialModuleConfigs;
|
config?: InitialModuleConfigs;
|
||||||
options?: AppOptions;
|
options?: AppOptions;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AppConfig = InitialModuleConfigs;
|
export type AppConfig = { version: number } & ModuleConfigs;
|
||||||
export type LocalApiOptions = Request | ApiOptions;
|
export type LocalApiOptions = Request | ApiOptions;
|
||||||
|
|
||||||
export class App<C extends Connection = Connection, Options extends AppOptions = AppOptions> {
|
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(
|
constructor(
|
||||||
public connection: C,
|
public connection: C,
|
||||||
_initialConfig?: InitialModuleConfigs,
|
_config?: InitialModuleConfigs,
|
||||||
private options?: Options,
|
public options?: Options,
|
||||||
) {
|
) {
|
||||||
this.drivers = options?.drivers ?? {};
|
this.drivers = options?.drivers ?? {};
|
||||||
|
|
||||||
@@ -134,9 +137,13 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
|
|||||||
this.plugins.set(config.name, config);
|
this.plugins.set(config.name, config);
|
||||||
}
|
}
|
||||||
this.runPlugins("onBoot");
|
this.runPlugins("onBoot");
|
||||||
this.modules = new ModuleManager(connection, {
|
|
||||||
|
// use db manager by default
|
||||||
|
const Manager = this.mode === "db" ? DbModuleManager : ModuleManager;
|
||||||
|
|
||||||
|
this.modules = new Manager(connection, {
|
||||||
...(options?.manager ?? {}),
|
...(options?.manager ?? {}),
|
||||||
initial: _initialConfig,
|
initial: _config,
|
||||||
onUpdated: this.onUpdated.bind(this),
|
onUpdated: this.onUpdated.bind(this),
|
||||||
onFirstBoot: this.onFirstBoot.bind(this),
|
onFirstBoot: this.onFirstBoot.bind(this),
|
||||||
onServerInit: this.onServerInit.bind(this),
|
onServerInit: this.onServerInit.bind(this),
|
||||||
@@ -145,6 +152,14 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
|
|||||||
this.modules.ctx().emgr.registerEvents(AppEvents);
|
this.modules.ctx().emgr.registerEvents(AppEvents);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get mode() {
|
||||||
|
return this.options?.mode ?? "db";
|
||||||
|
}
|
||||||
|
|
||||||
|
isReadOnly() {
|
||||||
|
return Boolean(this.mode === "code" || this.options?.readonly);
|
||||||
|
}
|
||||||
|
|
||||||
get emgr() {
|
get emgr() {
|
||||||
return this.modules.ctx().emgr;
|
return this.modules.ctx().emgr;
|
||||||
}
|
}
|
||||||
@@ -175,7 +190,7 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
|
|||||||
return results as any;
|
return results as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
async build(options?: { sync?: boolean; fetch?: boolean; forceBuild?: boolean }) {
|
async build(options?: { sync?: boolean; forceBuild?: boolean; [key: string]: any }) {
|
||||||
// prevent multiple concurrent builds
|
// prevent multiple concurrent builds
|
||||||
if (this._building) {
|
if (this._building) {
|
||||||
while (this._building) {
|
while (this._building) {
|
||||||
@@ -188,7 +203,7 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
|
|||||||
this._building = true;
|
this._building = true;
|
||||||
|
|
||||||
if (options?.sync) this.modules.ctx().flags.sync_required = true;
|
if (options?.sync) this.modules.ctx().flags.sync_required = true;
|
||||||
await this.modules.build({ fetch: options?.fetch });
|
await this.modules.build();
|
||||||
|
|
||||||
const { guard } = this.modules.ctx();
|
const { guard } = this.modules.ctx();
|
||||||
|
|
||||||
@@ -215,10 +230,6 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
|
|||||||
this._building = false;
|
this._building = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
mutateConfig<Module extends keyof Modules>(module: Module) {
|
|
||||||
return this.modules.mutateConfigSafe(module);
|
|
||||||
}
|
|
||||||
|
|
||||||
get server() {
|
get server() {
|
||||||
return this.modules.server;
|
return this.modules.server;
|
||||||
}
|
}
|
||||||
@@ -377,5 +388,5 @@ export function createApp(config: CreateAppConfig = {}) {
|
|||||||
throw new Error("Invalid connection");
|
throw new Error("Invalid connection");
|
||||||
}
|
}
|
||||||
|
|
||||||
return new App(config.connection, config.initialConfig, config.options);
|
return new App(config.connection, config.config, config.options);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { TestRunner } from "core/test";
|
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";
|
import type { App } from "App";
|
||||||
|
|
||||||
export function adapterTestSuite<
|
export function adapterTestSuite<
|
||||||
@@ -13,16 +13,8 @@ export function adapterTestSuite<
|
|||||||
label = "app",
|
label = "app",
|
||||||
overrides = {},
|
overrides = {},
|
||||||
}: {
|
}: {
|
||||||
makeApp: (
|
makeApp: (config: Config, args?: Args) => Promise<App>;
|
||||||
config: Config,
|
makeHandler?: (config?: Config, args?: Args) => (request: Request) => Promise<Response>;
|
||||||
args?: Args,
|
|
||||||
opts?: RuntimeOptions | FrameworkOptions,
|
|
||||||
) => Promise<App>;
|
|
||||||
makeHandler?: (
|
|
||||||
config?: Config,
|
|
||||||
args?: Args,
|
|
||||||
opts?: RuntimeOptions | FrameworkOptions,
|
|
||||||
) => (request: Request) => Promise<Response>;
|
|
||||||
label?: string;
|
label?: string;
|
||||||
overrides?: {
|
overrides?: {
|
||||||
dbUrl?: string;
|
dbUrl?: string;
|
||||||
@@ -30,7 +22,6 @@ export function adapterTestSuite<
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const { test, expect, mock } = testRunner;
|
const { test, expect, mock } = testRunner;
|
||||||
const id = crypto.randomUUID();
|
|
||||||
|
|
||||||
test(`creates ${label}`, async () => {
|
test(`creates ${label}`, async () => {
|
||||||
const beforeBuild = mock(async () => null) as any;
|
const beforeBuild = mock(async () => null) as any;
|
||||||
@@ -39,7 +30,7 @@ export function adapterTestSuite<
|
|||||||
const config = {
|
const config = {
|
||||||
app: (env) => ({
|
app: (env) => ({
|
||||||
connection: { url: env.url },
|
connection: { url: env.url },
|
||||||
initialConfig: {
|
config: {
|
||||||
server: { cors: { origin: env.origin } },
|
server: { cors: { origin: env.origin } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -53,7 +44,6 @@ export function adapterTestSuite<
|
|||||||
url: overrides.dbUrl ?? ":memory:",
|
url: overrides.dbUrl ?? ":memory:",
|
||||||
origin: "localhost",
|
origin: "localhost",
|
||||||
} as any,
|
} as any,
|
||||||
{ force: false, id },
|
|
||||||
);
|
);
|
||||||
expect(app).toBeDefined();
|
expect(app).toBeDefined();
|
||||||
expect(app.toJSON().server.cors.origin).toEqual("localhost");
|
expect(app.toJSON().server.cors.origin).toEqual("localhost");
|
||||||
@@ -68,8 +58,8 @@ export function adapterTestSuite<
|
|||||||
return { res, data };
|
return { res, data };
|
||||||
};
|
};
|
||||||
|
|
||||||
test("responds with the same app id", async () => {
|
/* test.skip("responds with the same app id", async () => {
|
||||||
const fetcher = makeHandler(undefined, undefined, { force: false, id });
|
const fetcher = makeHandler(undefined, undefined, { id });
|
||||||
|
|
||||||
const { res, data } = await getConfig(fetcher);
|
const { res, data } = await getConfig(fetcher);
|
||||||
expect(res.ok).toBe(true);
|
expect(res.ok).toBe(true);
|
||||||
@@ -77,14 +67,14 @@ export function adapterTestSuite<
|
|||||||
expect(data.server.cors.origin).toEqual("localhost");
|
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
|
// 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);
|
const { res, data } = await getConfig(fetcher);
|
||||||
expect(res.ok).toBe(true);
|
expect(res.ok).toBe(true);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(data.server.cors.origin).toEqual("*");
|
expect(data.server.cors.origin).toEqual("*");
|
||||||
});
|
}); */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,6 @@ afterAll(enableConsoleLog);
|
|||||||
describe("astro adapter", () => {
|
describe("astro adapter", () => {
|
||||||
adapterTestSuite(bunTestRunner, {
|
adapterTestSuite(bunTestRunner, {
|
||||||
makeApp: astro.getApp,
|
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 }),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 AstroEnv = NodeJS.ProcessEnv;
|
||||||
type TAstro = {
|
type TAstro = {
|
||||||
@@ -9,17 +9,12 @@ export type AstroBkndConfig<Env = AstroEnv> = FrameworkBkndConfig<Env>;
|
|||||||
export async function getApp<Env = AstroEnv>(
|
export async function getApp<Env = AstroEnv>(
|
||||||
config: AstroBkndConfig<Env> = {},
|
config: AstroBkndConfig<Env> = {},
|
||||||
args: Env = {} as 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>(
|
export function serve<Env = AstroEnv>(config: AstroBkndConfig<Env> = {}, args: Env = {} as Env) {
|
||||||
config: AstroBkndConfig<Env> = {},
|
|
||||||
args: Env = {} as Env,
|
|
||||||
opts?: FrameworkOptions,
|
|
||||||
) {
|
|
||||||
return async (fnArgs: TAstro) => {
|
return async (fnArgs: TAstro) => {
|
||||||
return (await getApp(config, args, opts)).fetch(fnArgs.request);
|
return (await getApp(config, args)).fetch(fnArgs.request);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { App } from "bknd";
|
import type { App } from "bknd";
|
||||||
import { handle } from "hono/aws-lambda";
|
import { handle } from "hono/aws-lambda";
|
||||||
import { serveStatic } from "@hono/node-server/serve-static";
|
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;
|
type AwsLambdaEnv = object;
|
||||||
export type AwsLambdaBkndConfig<Env extends AwsLambdaEnv = AwsLambdaEnv> =
|
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>(
|
export async function createApp<Env extends AwsLambdaEnv = AwsLambdaEnv>(
|
||||||
{ adminOptions = false, assets, ...config }: AwsLambdaBkndConfig<Env> = {},
|
{ adminOptions = false, assets, ...config }: AwsLambdaBkndConfig<Env> = {},
|
||||||
args: Env = {} as Env,
|
args: Env = {} as Env,
|
||||||
opts?: RuntimeOptions,
|
|
||||||
): Promise<App> {
|
): Promise<App> {
|
||||||
let additional: Partial<RuntimeBkndConfig> = {
|
let additional: Partial<RuntimeBkndConfig> = {
|
||||||
adminOptions,
|
adminOptions,
|
||||||
@@ -57,17 +56,15 @@ export async function createApp<Env extends AwsLambdaEnv = AwsLambdaEnv>(
|
|||||||
...additional,
|
...additional,
|
||||||
},
|
},
|
||||||
args ?? process.env,
|
args ?? process.env,
|
||||||
opts,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serve<Env extends AwsLambdaEnv = AwsLambdaEnv>(
|
export function serve<Env extends AwsLambdaEnv = AwsLambdaEnv>(
|
||||||
config: AwsLambdaBkndConfig<Env> = {},
|
config: AwsLambdaBkndConfig<Env> = {},
|
||||||
args: Env = {} as Env,
|
args: Env = {} as Env,
|
||||||
opts?: RuntimeOptions,
|
|
||||||
) {
|
) {
|
||||||
return async (event) => {
|
return async (event) => {
|
||||||
const app = await createApp(config, args, opts);
|
const app = await createApp(config, args);
|
||||||
return await handle(app.server)(event);
|
return await handle(app.server)(event);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ describe("aws adapter", () => {
|
|||||||
adapterTestSuite(bunTestRunner, {
|
adapterTestSuite(bunTestRunner, {
|
||||||
makeApp: awsLambda.createApp,
|
makeApp: awsLambda.createApp,
|
||||||
// @todo: add a request to lambda event translator?
|
// @todo: add a request to lambda event translator?
|
||||||
makeHandler: (c, a, o) => async (request: Request) => {
|
makeHandler: (c, a) => async (request: Request) => {
|
||||||
const app = await awsLambda.createApp(c, a, o);
|
const app = await awsLambda.createApp(c, a);
|
||||||
return app.fetch(request);
|
return app.fetch(request);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/// <reference types="bun-types" />
|
/// <reference types="bun-types" />
|
||||||
|
|
||||||
import path from "node:path";
|
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 { registerLocalMediaAdapter } from ".";
|
||||||
import { config, type App } from "bknd";
|
import { config, type App } from "bknd";
|
||||||
import type { ServeOptions } from "bun";
|
import type { ServeOptions } from "bun";
|
||||||
@@ -13,7 +13,6 @@ export type BunBkndConfig<Env = BunEnv> = RuntimeBkndConfig<Env> & Omit<ServeOpt
|
|||||||
export async function createApp<Env = BunEnv>(
|
export async function createApp<Env = BunEnv>(
|
||||||
{ distPath, serveStatic: _serveStatic, ...config }: BunBkndConfig<Env> = {},
|
{ distPath, serveStatic: _serveStatic, ...config }: BunBkndConfig<Env> = {},
|
||||||
args: Env = {} as Env,
|
args: Env = {} as Env,
|
||||||
opts?: RuntimeOptions,
|
|
||||||
) {
|
) {
|
||||||
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
|
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
|
||||||
registerLocalMediaAdapter();
|
registerLocalMediaAdapter();
|
||||||
@@ -28,19 +27,17 @@ export async function createApp<Env = BunEnv>(
|
|||||||
...config,
|
...config,
|
||||||
},
|
},
|
||||||
args ?? (process.env as Env),
|
args ?? (process.env as Env),
|
||||||
opts,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createHandler<Env = BunEnv>(
|
export function createHandler<Env = BunEnv>(
|
||||||
config: BunBkndConfig<Env> = {},
|
config: BunBkndConfig<Env> = {},
|
||||||
args: Env = {} as Env,
|
args: Env = {} as Env,
|
||||||
opts?: RuntimeOptions,
|
|
||||||
) {
|
) {
|
||||||
let app: App | undefined;
|
let app: App | undefined;
|
||||||
return async (req: Request) => {
|
return async (req: Request) => {
|
||||||
if (!app) {
|
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);
|
return app.fetch(req);
|
||||||
};
|
};
|
||||||
@@ -50,7 +47,7 @@ export function serve<Env = BunEnv>(
|
|||||||
{
|
{
|
||||||
distPath,
|
distPath,
|
||||||
connection,
|
connection,
|
||||||
initialConfig,
|
config: _config,
|
||||||
options,
|
options,
|
||||||
port = config.server.default_port,
|
port = config.server.default_port,
|
||||||
onBuilt,
|
onBuilt,
|
||||||
@@ -60,7 +57,6 @@ export function serve<Env = BunEnv>(
|
|||||||
...serveOptions
|
...serveOptions
|
||||||
}: BunBkndConfig<Env> = {},
|
}: BunBkndConfig<Env> = {},
|
||||||
args: Env = {} as Env,
|
args: Env = {} as Env,
|
||||||
opts?: RuntimeOptions,
|
|
||||||
) {
|
) {
|
||||||
Bun.serve({
|
Bun.serve({
|
||||||
...serveOptions,
|
...serveOptions,
|
||||||
@@ -68,7 +64,7 @@ export function serve<Env = BunEnv>(
|
|||||||
fetch: createHandler(
|
fetch: createHandler(
|
||||||
{
|
{
|
||||||
connection,
|
connection,
|
||||||
initialConfig,
|
config: _config,
|
||||||
options,
|
options,
|
||||||
onBuilt,
|
onBuilt,
|
||||||
buildConfig,
|
buildConfig,
|
||||||
@@ -77,7 +73,6 @@ export function serve<Env = BunEnv>(
|
|||||||
serveStatic,
|
serveStatic,
|
||||||
},
|
},
|
||||||
args,
|
args,
|
||||||
opts,
|
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { adapterTestSuite } from "adapter/adapter-test-suite";
|
|||||||
import { bunTestRunner } from "adapter/bun/test";
|
import { bunTestRunner } from "adapter/bun/test";
|
||||||
import { type CloudflareBkndConfig, createApp } from "./cloudflare-workers.adapter";
|
import { type CloudflareBkndConfig, createApp } from "./cloudflare-workers.adapter";
|
||||||
|
|
||||||
beforeAll(disableConsoleLog);
|
/* beforeAll(disableConsoleLog);
|
||||||
afterAll(enableConsoleLog);
|
afterAll(enableConsoleLog); */
|
||||||
|
|
||||||
describe("cf adapter", () => {
|
describe("cf adapter", () => {
|
||||||
const DB_URL = ":memory:";
|
const DB_URL = ":memory:";
|
||||||
@@ -20,31 +20,31 @@ describe("cf adapter", () => {
|
|||||||
const staticConfig = await makeConfig(
|
const staticConfig = await makeConfig(
|
||||||
{
|
{
|
||||||
connection: { url: DB_URL },
|
connection: { url: DB_URL },
|
||||||
initialConfig: { data: { basepath: DB_URL } },
|
config: { data: { basepath: DB_URL } },
|
||||||
},
|
},
|
||||||
$ctx({ 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();
|
expect(staticConfig.connection).toBeDefined();
|
||||||
|
|
||||||
const dynamicConfig = await makeConfig(
|
const dynamicConfig = await makeConfig(
|
||||||
{
|
{
|
||||||
app: (env) => ({
|
app: (env) => ({
|
||||||
initialConfig: { data: { basepath: env.DB_URL } },
|
config: { data: { basepath: env.DB_URL } },
|
||||||
connection: { url: env.DB_URL },
|
connection: { url: env.DB_URL },
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
$ctx({ 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();
|
expect(dynamicConfig.connection).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
adapterTestSuite<CloudflareBkndConfig, CloudflareContext<any>>(bunTestRunner, {
|
adapterTestSuite<CloudflareBkndConfig, CloudflareContext<any>>(bunTestRunner, {
|
||||||
makeApp: async (c, a, o) => {
|
makeApp: async (c, a) => {
|
||||||
return await createApp(c, { env: a } as any, o);
|
return await createApp(c, { env: a } as any);
|
||||||
},
|
},
|
||||||
makeHandler: (c, a, o) => {
|
makeHandler: (c, a) => {
|
||||||
console.log("args", a);
|
console.log("args", a);
|
||||||
return async (request: any) => {
|
return async (request: any) => {
|
||||||
const app = await createApp(
|
const app = await createApp(
|
||||||
@@ -53,7 +53,6 @@ describe("cf adapter", () => {
|
|||||||
connection: { url: DB_URL },
|
connection: { url: DB_URL },
|
||||||
},
|
},
|
||||||
a as any,
|
a as any,
|
||||||
o,
|
|
||||||
);
|
);
|
||||||
return app.fetch(request);
|
return app.fetch(request);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Hono } from "hono";
|
|||||||
import { serveStatic } from "hono/cloudflare-workers";
|
import { serveStatic } from "hono/cloudflare-workers";
|
||||||
import type { MaybePromise } from "bknd";
|
import type { MaybePromise } from "bknd";
|
||||||
import { $console } from "bknd/utils";
|
import { $console } from "bknd/utils";
|
||||||
import { createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
|
import { createRuntimeApp } from "bknd/adapter";
|
||||||
import { registerAsyncsExecutionContext, makeConfig, type CloudflareContext } from "./config";
|
import { registerAsyncsExecutionContext, makeConfig, type CloudflareContext } from "./config";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -34,12 +34,8 @@ export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> &
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function createApp<Env extends CloudflareEnv = CloudflareEnv>(
|
export async function createApp<Env extends CloudflareEnv = CloudflareEnv>(
|
||||||
config: CloudflareBkndConfig<Env>,
|
config: CloudflareBkndConfig<Env> = {},
|
||||||
ctx: Partial<CloudflareContext<Env>> = {},
|
ctx: Partial<CloudflareContext<Env>> = {},
|
||||||
opts: RuntimeOptions = {
|
|
||||||
// by default, require the app to be rebuilt every time
|
|
||||||
force: true,
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
const appConfig = await makeConfig(
|
const appConfig = await makeConfig(
|
||||||
{
|
{
|
||||||
@@ -53,7 +49,7 @@ export async function createApp<Env extends CloudflareEnv = CloudflareEnv>(
|
|||||||
},
|
},
|
||||||
ctx,
|
ctx,
|
||||||
);
|
);
|
||||||
return await createRuntimeApp<Env>(appConfig, ctx?.env, opts);
|
return await createRuntimeApp<Env>(appConfig, ctx?.env);
|
||||||
}
|
}
|
||||||
|
|
||||||
// compatiblity
|
// compatiblity
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { d1Sqlite, type D1ConnectionConfig } from "./connection/D1Connection";
|
|||||||
export {
|
export {
|
||||||
getFresh,
|
getFresh,
|
||||||
createApp,
|
createApp,
|
||||||
|
serve,
|
||||||
type CloudflareEnv,
|
type CloudflareEnv,
|
||||||
type CloudflareBkndConfig,
|
type CloudflareBkndConfig,
|
||||||
} from "./cloudflare-workers.adapter";
|
} from "./cloudflare-workers.adapter";
|
||||||
|
|||||||
@@ -21,13 +21,6 @@ export type BkndConfig<Args = any> = CreateAppConfig & {
|
|||||||
|
|
||||||
export type FrameworkBkndConfig<Args = any> = BkndConfig<Args>;
|
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> & {
|
export type RuntimeBkndConfig<Args = any> = BkndConfig<Args> & {
|
||||||
distPath?: string;
|
distPath?: string;
|
||||||
serveStatic?: MiddlewareHandler | [string, MiddlewareHandler];
|
serveStatic?: MiddlewareHandler | [string, MiddlewareHandler];
|
||||||
@@ -63,11 +56,7 @@ const apps = new Map<string, App>();
|
|||||||
export async function createAdapterApp<Config extends BkndConfig = BkndConfig, Args = DefaultArgs>(
|
export async function createAdapterApp<Config extends BkndConfig = BkndConfig, Args = DefaultArgs>(
|
||||||
config: Config = {} as Config,
|
config: Config = {} as Config,
|
||||||
args?: Args,
|
args?: Args,
|
||||||
opts?: CreateAdapterAppOptions,
|
|
||||||
): Promise<App> {
|
): Promise<App> {
|
||||||
const id = opts?.id ?? "app";
|
|
||||||
let app = apps.get(id);
|
|
||||||
if (!app || opts?.force) {
|
|
||||||
const appConfig = await makeConfig(config, args);
|
const appConfig = await makeConfig(config, args);
|
||||||
if (!appConfig.connection || !Connection.isConnection(appConfig.connection)) {
|
if (!appConfig.connection || !Connection.isConnection(appConfig.connection)) {
|
||||||
let connection: Connection | undefined;
|
let connection: Connection | undefined;
|
||||||
@@ -82,22 +71,14 @@ export async function createAdapterApp<Config extends BkndConfig = BkndConfig, A
|
|||||||
appConfig.connection = connection;
|
appConfig.connection = connection;
|
||||||
}
|
}
|
||||||
|
|
||||||
app = App.create(appConfig);
|
return App.create(appConfig);
|
||||||
|
|
||||||
if (!opts?.force) {
|
|
||||||
apps.set(id, app);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return app;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createFrameworkApp<Args = DefaultArgs>(
|
export async function createFrameworkApp<Args = DefaultArgs>(
|
||||||
config: FrameworkBkndConfig = {},
|
config: FrameworkBkndConfig = {},
|
||||||
args?: Args,
|
args?: Args,
|
||||||
opts?: FrameworkOptions,
|
|
||||||
): Promise<App> {
|
): Promise<App> {
|
||||||
const app = await createAdapterApp(config, args, opts);
|
const app = await createAdapterApp(config, args);
|
||||||
|
|
||||||
if (!app.isBuilt()) {
|
if (!app.isBuilt()) {
|
||||||
if (config.onBuilt) {
|
if (config.onBuilt) {
|
||||||
@@ -120,9 +101,8 @@ export async function createFrameworkApp<Args = DefaultArgs>(
|
|||||||
export async function createRuntimeApp<Args = DefaultArgs>(
|
export async function createRuntimeApp<Args = DefaultArgs>(
|
||||||
{ serveStatic, adminOptions, ...config }: RuntimeBkndConfig<Args> = {},
|
{ serveStatic, adminOptions, ...config }: RuntimeBkndConfig<Args> = {},
|
||||||
args?: Args,
|
args?: Args,
|
||||||
opts?: RuntimeOptions,
|
|
||||||
): Promise<App> {
|
): Promise<App> {
|
||||||
const app = await createAdapterApp(config, args, opts);
|
const app = await createAdapterApp(config, args);
|
||||||
|
|
||||||
if (!app.isBuilt()) {
|
if (!app.isBuilt()) {
|
||||||
app.emgr.onEvent(
|
app.emgr.onEvent(
|
||||||
|
|||||||
@@ -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 { isNode } from "bknd/utils";
|
||||||
import type { NextApiRequest } from "next";
|
import type { NextApiRequest } from "next";
|
||||||
|
|
||||||
@@ -10,9 +10,8 @@ export type NextjsBkndConfig<Env = NextjsEnv> = FrameworkBkndConfig<Env> & {
|
|||||||
export async function getApp<Env = NextjsEnv>(
|
export async function getApp<Env = NextjsEnv>(
|
||||||
config: NextjsBkndConfig<Env>,
|
config: NextjsBkndConfig<Env>,
|
||||||
args: Env = {} as 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"]) {
|
function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequest"]) {
|
||||||
@@ -41,10 +40,9 @@ function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequ
|
|||||||
export function serve<Env = NextjsEnv>(
|
export function serve<Env = NextjsEnv>(
|
||||||
{ cleanRequest, ...config }: NextjsBkndConfig<Env> = {},
|
{ cleanRequest, ...config }: NextjsBkndConfig<Env> = {},
|
||||||
args: Env = {} as Env,
|
args: Env = {} as Env,
|
||||||
opts?: FrameworkOptions,
|
|
||||||
) {
|
) {
|
||||||
return async (req: Request) => {
|
return async (req: Request) => {
|
||||||
const app = await getApp(config, args, opts);
|
const app = await getApp(config, args);
|
||||||
const request = getCleanRequest(req, cleanRequest);
|
const request = getCleanRequest(req, cleanRequest);
|
||||||
return app.fetch(request);
|
return app.fetch(request);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import path from "node:path";
|
|||||||
import { serve as honoServe } from "@hono/node-server";
|
import { serve as honoServe } from "@hono/node-server";
|
||||||
import { serveStatic } from "@hono/node-server/serve-static";
|
import { serveStatic } from "@hono/node-server/serve-static";
|
||||||
import { registerLocalMediaAdapter } from "adapter/node/storage";
|
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 { config as $config, type App } from "bknd";
|
||||||
import { $console } from "bknd/utils";
|
import { $console } from "bknd/utils";
|
||||||
|
|
||||||
@@ -18,7 +18,6 @@ export type NodeBkndConfig<Env = NodeEnv> = RuntimeBkndConfig<Env> & {
|
|||||||
export async function createApp<Env = NodeEnv>(
|
export async function createApp<Env = NodeEnv>(
|
||||||
{ distPath, relativeDistPath, ...config }: NodeBkndConfig<Env> = {},
|
{ distPath, relativeDistPath, ...config }: NodeBkndConfig<Env> = {},
|
||||||
args: Env = {} as Env,
|
args: Env = {} as Env,
|
||||||
opts?: RuntimeOptions,
|
|
||||||
) {
|
) {
|
||||||
const root = path.relative(
|
const root = path.relative(
|
||||||
process.cwd(),
|
process.cwd(),
|
||||||
@@ -36,19 +35,17 @@ export async function createApp<Env = NodeEnv>(
|
|||||||
},
|
},
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
args ?? { env: process.env },
|
args ?? { env: process.env },
|
||||||
opts,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createHandler<Env = NodeEnv>(
|
export function createHandler<Env = NodeEnv>(
|
||||||
config: NodeBkndConfig<Env> = {},
|
config: NodeBkndConfig<Env> = {},
|
||||||
args: Env = {} as Env,
|
args: Env = {} as Env,
|
||||||
opts?: RuntimeOptions,
|
|
||||||
) {
|
) {
|
||||||
let app: App | undefined;
|
let app: App | undefined;
|
||||||
return async (req: Request) => {
|
return async (req: Request) => {
|
||||||
if (!app) {
|
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);
|
return app.fetch(req);
|
||||||
};
|
};
|
||||||
@@ -57,13 +54,12 @@ export function createHandler<Env = NodeEnv>(
|
|||||||
export function serve<Env = NodeEnv>(
|
export function serve<Env = NodeEnv>(
|
||||||
{ port = $config.server.default_port, hostname, listener, ...config }: NodeBkndConfig<Env> = {},
|
{ port = $config.server.default_port, hostname, listener, ...config }: NodeBkndConfig<Env> = {},
|
||||||
args: Env = {} as Env,
|
args: Env = {} as Env,
|
||||||
opts?: RuntimeOptions,
|
|
||||||
) {
|
) {
|
||||||
honoServe(
|
honoServe(
|
||||||
{
|
{
|
||||||
port,
|
port,
|
||||||
hostname,
|
hostname,
|
||||||
fetch: createHandler(config, args, opts),
|
fetch: createHandler(config, args),
|
||||||
},
|
},
|
||||||
(connInfo) => {
|
(connInfo) => {
|
||||||
$console.log(`Server is running on http://localhost:${connInfo.port}`);
|
$console.log(`Server is running on http://localhost:${connInfo.port}`);
|
||||||
|
|||||||
@@ -10,6 +10,6 @@ afterAll(enableConsoleLog);
|
|||||||
describe("react-router adapter", () => {
|
describe("react-router adapter", () => {
|
||||||
adapterTestSuite(bunTestRunner, {
|
adapterTestSuite(bunTestRunner, {
|
||||||
makeApp: rr.getApp,
|
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 }),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
|
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
|
||||||
import type { FrameworkOptions } from "adapter";
|
|
||||||
|
|
||||||
type ReactRouterEnv = NodeJS.ProcessEnv;
|
type ReactRouterEnv = NodeJS.ProcessEnv;
|
||||||
type ReactRouterFunctionArgs = {
|
type ReactRouterFunctionArgs = {
|
||||||
@@ -10,17 +9,15 @@ export type ReactRouterBkndConfig<Env = ReactRouterEnv> = FrameworkBkndConfig<En
|
|||||||
export async function getApp<Env = ReactRouterEnv>(
|
export async function getApp<Env = ReactRouterEnv>(
|
||||||
config: ReactRouterBkndConfig<Env>,
|
config: ReactRouterBkndConfig<Env>,
|
||||||
args: Env = {} as 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>(
|
export function serve<Env = ReactRouterEnv>(
|
||||||
config: ReactRouterBkndConfig<Env> = {},
|
config: ReactRouterBkndConfig<Env> = {},
|
||||||
args: Env = {} as Env,
|
args: Env = {} as Env,
|
||||||
opts?: FrameworkOptions,
|
|
||||||
) {
|
) {
|
||||||
return async (fnArgs: ReactRouterFunctionArgs) => {
|
return async (fnArgs: ReactRouterFunctionArgs) => {
|
||||||
return (await getApp(config, args, opts)).fetch(fnArgs.request);
|
return (await getApp(config, args)).fetch(fnArgs.request);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { serveStatic } from "@hono/node-server/serve-static";
|
import { serveStatic } from "@hono/node-server/serve-static";
|
||||||
import { type DevServerOptions, default as honoViteDevServer } from "@hono/vite-dev-server";
|
import { type DevServerOptions, default as honoViteDevServer } from "@hono/vite-dev-server";
|
||||||
import type { App } from "bknd";
|
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 { registerLocalMediaAdapter } from "bknd/adapter/node";
|
||||||
import { devServerConfig } from "./dev-server-config";
|
import { devServerConfig } from "./dev-server-config";
|
||||||
import type { MiddlewareHandler } from "hono";
|
import type { MiddlewareHandler } from "hono";
|
||||||
@@ -30,7 +30,6 @@ ${addBkndContext ? "<!-- BKND_CONTEXT -->" : ""}
|
|||||||
async function createApp<ViteEnv>(
|
async function createApp<ViteEnv>(
|
||||||
config: ViteBkndConfig<ViteEnv> = {},
|
config: ViteBkndConfig<ViteEnv> = {},
|
||||||
env: ViteEnv = {} as ViteEnv,
|
env: ViteEnv = {} as ViteEnv,
|
||||||
opts: FrameworkOptions = {},
|
|
||||||
): Promise<App> {
|
): Promise<App> {
|
||||||
registerLocalMediaAdapter();
|
registerLocalMediaAdapter();
|
||||||
return await createRuntimeApp(
|
return await createRuntimeApp(
|
||||||
@@ -47,18 +46,13 @@ async function createApp<ViteEnv>(
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
env,
|
env,
|
||||||
opts,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serve<ViteEnv>(
|
export function serve<ViteEnv>(config: ViteBkndConfig<ViteEnv> = {}, args?: ViteEnv) {
|
||||||
config: ViteBkndConfig<ViteEnv> = {},
|
|
||||||
args?: ViteEnv,
|
|
||||||
opts?: FrameworkOptions,
|
|
||||||
) {
|
|
||||||
return {
|
return {
|
||||||
async fetch(request: Request, env: any, ctx: ExecutionContext) {
|
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);
|
return app.fetch(request, env, ctx);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,3 +4,5 @@ export interface Serializable<Class, Json extends object = object> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type MaybePromise<T> = T | Promise<T>;
|
export type MaybePromise<T> = T | Promise<T>;
|
||||||
|
|
||||||
|
export type PartialRec<T> = { [P in keyof T]?: PartialRec<T[P]> };
|
||||||
|
|||||||
@@ -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 {
|
export function objectToJsLiteral(value: object, indent: number = 0, _level: number = 0): string {
|
||||||
const nl = indent ? "\n" : "";
|
const nl = indent ? "\n" : "";
|
||||||
const pad = (lvl: number) => (indent ? " ".repeat(indent * lvl) : "");
|
const pad = (lvl: number) => (indent ? " ".repeat(indent * lvl) : "");
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export class DataController extends Controller {
|
|||||||
"/sync",
|
"/sync",
|
||||||
permission(DataPermissions.databaseSync),
|
permission(DataPermissions.databaseSync),
|
||||||
mcpTool("data_sync", {
|
mcpTool("data_sync", {
|
||||||
|
// @todo: should be removed if readonly
|
||||||
annotations: {
|
annotations: {
|
||||||
destructiveHint: true,
|
destructiveHint: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ export function connectionTestSuite(
|
|||||||
|
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
connection: ctx.connection,
|
connection: ctx.connection,
|
||||||
initialConfig: {
|
config: {
|
||||||
data: schema.toJSON(),
|
data: schema.toJSON(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -333,7 +333,7 @@ export function connectionTestSuite(
|
|||||||
|
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
connection: ctx.connection,
|
connection: ctx.connection,
|
||||||
initialConfig: {
|
config: {
|
||||||
data: schema.toJSON(),
|
data: schema.toJSON(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 type { FileBody, FileListObject, FileMeta } from "../../Storage";
|
||||||
import { StorageAdapter } from "../../StorageAdapter";
|
import { StorageAdapter } from "../../StorageAdapter";
|
||||||
|
|
||||||
export const cloudinaryAdapterConfig = s.object(
|
export const cloudinaryAdapterConfig = s.object(
|
||||||
{
|
{
|
||||||
cloud_name: s.string(),
|
cloud_name: s.string(),
|
||||||
api_key: s.string(),
|
api_key: secret(),
|
||||||
api_secret: s.string(),
|
api_secret: secret(),
|
||||||
upload_preset: s.string().optional(),
|
upload_preset: s.string().optional(),
|
||||||
},
|
},
|
||||||
{ title: "Cloudinary", description: "Cloudinary media storage" },
|
{ title: "Cloudinary", description: "Cloudinary media storage" },
|
||||||
|
|||||||
@@ -8,15 +8,15 @@ import type {
|
|||||||
} from "@aws-sdk/client-s3";
|
} from "@aws-sdk/client-s3";
|
||||||
import { AwsClient } from "core/clients/aws/AwsClient";
|
import { AwsClient } from "core/clients/aws/AwsClient";
|
||||||
import { isDebug } from "core/env";
|
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 { transform } from "lodash-es";
|
||||||
import type { FileBody, FileListObject } from "../../Storage";
|
import type { FileBody, FileListObject } from "../../Storage";
|
||||||
import { StorageAdapter } from "../../StorageAdapter";
|
import { StorageAdapter } from "../../StorageAdapter";
|
||||||
|
|
||||||
export const s3AdapterConfig = s.object(
|
export const s3AdapterConfig = s.object(
|
||||||
{
|
{
|
||||||
access_key: s.string(),
|
access_key: secret(),
|
||||||
secret_access_key: s.string(),
|
secret_access_key: secret(),
|
||||||
url: s.string({
|
url: s.string({
|
||||||
pattern: "^https?://(?:.*)?[^/.]+$",
|
pattern: "^https?://(?:.*)?[^/.]+$",
|
||||||
description: "URL to S3 compatible endpoint without trailing slash",
|
description: "URL to S3 compatible endpoint without trailing slash",
|
||||||
|
|||||||
@@ -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 { DebugLogger } from "core/utils/DebugLogger";
|
||||||
import { Guard } from "auth/authorize/Guard";
|
import { Guard } from "auth/authorize/Guard";
|
||||||
import { env } from "core/env";
|
import { env } from "core/env";
|
||||||
import { BkndError } from "core/errors";
|
|
||||||
import { EventManager, Event } from "core/events";
|
import { EventManager, Event } from "core/events";
|
||||||
import * as $diff from "core/object/diff";
|
|
||||||
import type { Connection } from "data/connection";
|
import type { Connection } from "data/connection";
|
||||||
import { EntityManager } from "data/entities/EntityManager";
|
import { EntityManager } from "data/entities/EntityManager";
|
||||||
import * as proto from "data/prototype";
|
|
||||||
import { TransformPersistFailedException } from "data/errors";
|
|
||||||
import { Hono } from "hono";
|
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 type { ServerEnv } from "./Controller";
|
||||||
import { Module, type ModuleBuildContext } from "./Module";
|
import { Module, type ModuleBuildContext } from "./Module";
|
||||||
import { ModuleHelper } from "./ModuleHelper";
|
import { ModuleHelper } from "./ModuleHelper";
|
||||||
|
import { AppServer } from "modules/server/AppServer";
|
||||||
|
import { AppAuth } from "auth/AppAuth";
|
||||||
|
import { AppData } from "data/AppData";
|
||||||
|
import { AppFlows } from "flows/AppFlows";
|
||||||
|
import { AppMedia } from "media/AppMedia";
|
||||||
|
import type { PartialRec } from "core/types";
|
||||||
|
|
||||||
export type { ModuleBuildContext };
|
export type { ModuleBuildContext };
|
||||||
|
|
||||||
@@ -47,13 +41,8 @@ export type ModuleSchemas = {
|
|||||||
export type ModuleConfigs = {
|
export type ModuleConfigs = {
|
||||||
[K in keyof ModuleSchemas]: s.Static<ModuleSchemas[K]>;
|
[K in keyof ModuleSchemas]: s.Static<ModuleSchemas[K]>;
|
||||||
};
|
};
|
||||||
type PartialRec<T> = { [P in keyof T]?: PartialRec<T[P]> };
|
|
||||||
|
|
||||||
export type InitialModuleConfigs =
|
export type InitialModuleConfigs = { version?: number } & PartialRec<ModuleConfigs>;
|
||||||
| ({
|
|
||||||
version: number;
|
|
||||||
} & ModuleConfigs)
|
|
||||||
| PartialRec<ModuleConfigs>;
|
|
||||||
|
|
||||||
enum Verbosity {
|
enum Verbosity {
|
||||||
silent = 0,
|
silent = 0,
|
||||||
@@ -80,42 +69,14 @@ export type ModuleManagerOptions = {
|
|||||||
seed?: (ctx: ModuleBuildContext) => Promise<void>;
|
seed?: (ctx: ModuleBuildContext) => Promise<void>;
|
||||||
// called right after modules are built, before finish
|
// called right after modules are built, before finish
|
||||||
onModulesBuilt?: (ctx: ModuleBuildContext) => Promise<void>;
|
onModulesBuilt?: (ctx: ModuleBuildContext) => Promise<void>;
|
||||||
|
// whether to store secrets in the database
|
||||||
|
storeSecrets?: boolean;
|
||||||
|
// provided secrets
|
||||||
|
secrets?: Record<string, any>;
|
||||||
/** @deprecated */
|
/** @deprecated */
|
||||||
verbosity?: Verbosity;
|
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");
|
const debug_modules = env("modules_debug");
|
||||||
|
|
||||||
abstract class ModuleManagerEvent<A = {}> extends Event<{ ctx: ModuleBuildContext } & A> {}
|
abstract class ModuleManagerEvent<A = {}> extends Event<{ ctx: ModuleBuildContext } & A> {}
|
||||||
@@ -127,8 +88,14 @@ export class ModuleManagerConfigUpdateEvent<
|
|||||||
}> {
|
}> {
|
||||||
static override slug = "mm-config-update";
|
static override slug = "mm-config-update";
|
||||||
}
|
}
|
||||||
|
export class ModuleManagerSecretsExtractedEvent extends ModuleManagerEvent<{
|
||||||
|
secrets: Record<string, any>;
|
||||||
|
}> {
|
||||||
|
static override slug = "mm-secrets-extracted";
|
||||||
|
}
|
||||||
export const ModuleManagerEvents = {
|
export const ModuleManagerEvents = {
|
||||||
ModuleManagerConfigUpdateEvent,
|
ModuleManagerConfigUpdateEvent,
|
||||||
|
ModuleManagerSecretsExtractedEvent,
|
||||||
};
|
};
|
||||||
|
|
||||||
// @todo: cleanup old diffs on upgrade
|
// @todo: cleanup old diffs on upgrade
|
||||||
@@ -137,8 +104,6 @@ export class ModuleManager {
|
|||||||
static Events = ModuleManagerEvents;
|
static Events = ModuleManagerEvents;
|
||||||
|
|
||||||
protected modules: Modules;
|
protected modules: Modules;
|
||||||
// internal em for __bknd config table
|
|
||||||
__em!: EntityManager<T_INTERNAL_EM>;
|
|
||||||
// ctx for modules
|
// ctx for modules
|
||||||
em!: EntityManager;
|
em!: EntityManager;
|
||||||
server!: Hono<ServerEnv>;
|
server!: Hono<ServerEnv>;
|
||||||
@@ -146,42 +111,24 @@ export class ModuleManager {
|
|||||||
guard!: Guard;
|
guard!: Guard;
|
||||||
mcp!: ModuleBuildContext["mcp"];
|
mcp!: ModuleBuildContext["mcp"];
|
||||||
|
|
||||||
private _version: number = 0;
|
protected _built = false;
|
||||||
private _built = false;
|
|
||||||
private readonly _booted_with?: "provided" | "partial";
|
|
||||||
private _stable_configs: ModuleConfigs | undefined;
|
|
||||||
|
|
||||||
private logger: DebugLogger;
|
protected logger: DebugLogger;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly connection: Connection,
|
protected readonly connection: Connection,
|
||||||
private options?: Partial<ModuleManagerOptions>,
|
protected options?: Partial<ModuleManagerOptions>,
|
||||||
) {
|
) {
|
||||||
this.__em = new EntityManager([__bknd], this.connection);
|
|
||||||
this.modules = {} as Modules;
|
this.modules = {} as Modules;
|
||||||
this.emgr = new EventManager({ ...ModuleManagerEvents });
|
this.emgr = new EventManager({ ...ModuleManagerEvents });
|
||||||
this.logger = new DebugLogger(debug_modules);
|
this.logger = new DebugLogger(debug_modules);
|
||||||
let initial = {} as Partial<ModuleConfigs>;
|
|
||||||
|
|
||||||
if (options?.initial) {
|
this.createModules(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);
|
protected onModuleConfigUpdated(key: string, config: any) {}
|
||||||
|
|
||||||
this.createModules(initial);
|
private createModules(initial: PartialRec<ModuleConfigs>) {
|
||||||
}
|
|
||||||
|
|
||||||
private createModules(initial: Partial<ModuleConfigs>) {
|
|
||||||
this.logger.context("createModules").log("creating modules");
|
this.logger.context("createModules").log("creating modules");
|
||||||
try {
|
try {
|
||||||
const context = this.ctx(true);
|
const context = this.ctx(true);
|
||||||
@@ -211,46 +158,7 @@ export class ModuleManager {
|
|||||||
return this._built;
|
return this._built;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
protected rebuildServer() {
|
||||||
* 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() {
|
|
||||||
this.server = new Hono<ServerEnv>();
|
this.server = new Hono<ServerEnv>();
|
||||||
if (this.options?.basePath) {
|
if (this.options?.basePath) {
|
||||||
this.server = this.server.basePath(this.options.basePath);
|
this.server = this.server.basePath(this.options.basePath);
|
||||||
@@ -299,252 +207,33 @@ export class ModuleManager {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fetch(): Promise<ConfigTable | undefined> {
|
protected async setConfigs(configs: ModuleConfigs): Promise<void> {
|
||||||
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 {
|
|
||||||
this.logger.log("setting configs");
|
this.logger.log("setting configs");
|
||||||
objectEach(configs, (config, key) => {
|
for await (const [key, config] of Object.entries(configs)) {
|
||||||
try {
|
try {
|
||||||
// setting "noEmit" to true, to not force listeners to update
|
// 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) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to set config for module ${key}: ${JSON.stringify(config, null, 2)}`,
|
`Failed to set config for module ${key}: ${JSON.stringify(config, null, 2)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async build(opts?: { fetch?: boolean }) {
|
async build(opts?: any) {
|
||||||
this.logger.context("build").log("version", this.version());
|
this.createModules(this.options?.initial ?? {});
|
||||||
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) {
|
|
||||||
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();
|
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;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async buildModules(options?: { graceful?: boolean; ignoreFlags?: boolean }) {
|
protected async buildModules(options?: {
|
||||||
|
graceful?: boolean;
|
||||||
|
ignoreFlags?: boolean;
|
||||||
|
drop?: boolean;
|
||||||
|
}) {
|
||||||
const state = {
|
const state = {
|
||||||
built: false,
|
built: false,
|
||||||
modules: [] as ModuleKey[],
|
modules: [] as ModuleKey[],
|
||||||
@@ -580,12 +269,8 @@ export class ModuleManager {
|
|||||||
this.logger.log("db sync requested");
|
this.logger.log("db sync requested");
|
||||||
|
|
||||||
// sync db
|
// sync db
|
||||||
await ctx.em.schema().sync({ force: true });
|
await ctx.em.schema().sync({ force: true, drop: options?.drop });
|
||||||
state.synced = true;
|
state.synced = true;
|
||||||
|
|
||||||
// save
|
|
||||||
await this.save();
|
|
||||||
state.saved = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ctx.flags.ctx_reload_required) {
|
if (ctx.flags.ctx_reload_required) {
|
||||||
@@ -601,92 +286,12 @@ export class ModuleManager {
|
|||||||
ctx.flags = Module.ctx_flags;
|
ctx.flags = Module.ctx_flags;
|
||||||
|
|
||||||
// storing last stable config version
|
// storing last stable config version
|
||||||
this._stable_configs = $diff.clone(this.configs());
|
//this._stable_configs = $diff.clone(this.configs());
|
||||||
|
|
||||||
this.logger.clear();
|
this.logger.clear();
|
||||||
return state;
|
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] {
|
get<K extends keyof Modules>(key: K): Modules[K] {
|
||||||
if (!(key in this.modules)) {
|
if (!(key in this.modules)) {
|
||||||
throw new Error(`Module "${key}" doesn't exist, cannot get`);
|
throw new Error(`Module "${key}" doesn't exist, cannot get`);
|
||||||
@@ -695,7 +300,7 @@ export class ModuleManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
version() {
|
version() {
|
||||||
return this._version;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
built() {
|
built() {
|
||||||
|
|||||||
594
app/src/modules/db/DbModuleManager.ts
Normal file
594
app/src/modules/db/DbModuleManager.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
type McpSchema,
|
type McpSchema,
|
||||||
type SchemaWithMcpOptions,
|
type SchemaWithMcpOptions,
|
||||||
} from "./McpSchemaHelper";
|
} from "./McpSchemaHelper";
|
||||||
import type { Module } from "modules/Module";
|
|
||||||
|
|
||||||
export interface ObjectToolSchemaOptions extends s.IObjectOptions, SchemaWithMcpOptions {}
|
export interface ObjectToolSchemaOptions extends s.IObjectOptions, SchemaWithMcpOptions {}
|
||||||
|
|
||||||
@@ -79,6 +78,7 @@ export class ObjectToolSchema<
|
|||||||
|
|
||||||
private toolUpdate(node: s.Node<ObjectToolSchema>) {
|
private toolUpdate(node: s.Node<ObjectToolSchema>) {
|
||||||
const schema = this.mcp.cleanSchema;
|
const schema = this.mcp.cleanSchema;
|
||||||
|
|
||||||
return new Tool(
|
return new Tool(
|
||||||
[this.mcp.name, "update"].join("_"),
|
[this.mcp.name, "update"].join("_"),
|
||||||
{
|
{
|
||||||
@@ -97,11 +97,12 @@ export class ObjectToolSchema<
|
|||||||
async (params, ctx: AppToolHandlerCtx) => {
|
async (params, ctx: AppToolHandlerCtx) => {
|
||||||
const { full, value, return_config } = params;
|
const { full, value, return_config } = params;
|
||||||
const [module_name] = node.instancePath;
|
const [module_name] = node.instancePath;
|
||||||
|
const manager = this.mcp.getManager(ctx);
|
||||||
|
|
||||||
if (full) {
|
if (full) {
|
||||||
await ctx.context.app.mutateConfig(module_name as any).set(value);
|
await manager.mutateConfigSafe(module_name as any).set(value);
|
||||||
} else {
|
} else {
|
||||||
await ctx.context.app.mutateConfig(module_name as any).patch("", value);
|
await manager.mutateConfigSafe(module_name as any).patch("", value);
|
||||||
}
|
}
|
||||||
|
|
||||||
let config: any = undefined;
|
let config: any = undefined;
|
||||||
|
|||||||
@@ -129,13 +129,14 @@ export class RecordToolSchema<
|
|||||||
const configs = ctx.context.app.toJSON(true);
|
const configs = ctx.context.app.toJSON(true);
|
||||||
const config = getPath(configs, node.instancePath);
|
const config = getPath(configs, node.instancePath);
|
||||||
const [module_name, ...rest] = node.instancePath;
|
const [module_name, ...rest] = node.instancePath;
|
||||||
|
const manager = this.mcp.getManager(ctx);
|
||||||
|
|
||||||
if (params.key in config) {
|
if (params.key in config) {
|
||||||
throw new Error(`Key "${params.key}" already exists in config`);
|
throw new Error(`Key "${params.key}" already exists in config`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.context.app
|
await manager
|
||||||
.mutateConfig(module_name as any)
|
.mutateConfigSafe(module_name as any)
|
||||||
.patch([...rest, params.key], params.value);
|
.patch([...rest, params.key], params.value);
|
||||||
|
|
||||||
const newConfig = getPath(ctx.context.app.toJSON(), node.instancePath);
|
const newConfig = getPath(ctx.context.app.toJSON(), node.instancePath);
|
||||||
@@ -175,13 +176,14 @@ export class RecordToolSchema<
|
|||||||
const configs = ctx.context.app.toJSON(true);
|
const configs = ctx.context.app.toJSON(true);
|
||||||
const config = getPath(configs, node.instancePath);
|
const config = getPath(configs, node.instancePath);
|
||||||
const [module_name, ...rest] = node.instancePath;
|
const [module_name, ...rest] = node.instancePath;
|
||||||
|
const manager = this.mcp.getManager(ctx);
|
||||||
|
|
||||||
if (!(params.key in config)) {
|
if (!(params.key in config)) {
|
||||||
throw new Error(`Key "${params.key}" not found in config`);
|
throw new Error(`Key "${params.key}" not found in config`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.context.app
|
await manager
|
||||||
.mutateConfig(module_name as any)
|
.mutateConfigSafe(module_name as any)
|
||||||
.patch([...rest, params.key], params.value);
|
.patch([...rest, params.key], params.value);
|
||||||
|
|
||||||
const newConfig = getPath(ctx.context.app.toJSON(), node.instancePath);
|
const newConfig = getPath(ctx.context.app.toJSON(), node.instancePath);
|
||||||
@@ -220,13 +222,14 @@ export class RecordToolSchema<
|
|||||||
const configs = ctx.context.app.toJSON(true);
|
const configs = ctx.context.app.toJSON(true);
|
||||||
const config = getPath(configs, node.instancePath);
|
const config = getPath(configs, node.instancePath);
|
||||||
const [module_name, ...rest] = node.instancePath;
|
const [module_name, ...rest] = node.instancePath;
|
||||||
|
const manager = this.mcp.getManager(ctx);
|
||||||
|
|
||||||
if (!(params.key in config)) {
|
if (!(params.key in config)) {
|
||||||
throw new Error(`Key "${params.key}" not found in config`);
|
throw new Error(`Key "${params.key}" not found in config`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.context.app
|
await manager
|
||||||
.mutateConfig(module_name as any)
|
.mutateConfigSafe(module_name as any)
|
||||||
.remove([...rest, params.key].join("."));
|
.remove([...rest, params.key].join("."));
|
||||||
|
|
||||||
const newConfig = getPath(ctx.context.app.toJSON(), node.instancePath);
|
const newConfig = getPath(ctx.context.app.toJSON(), node.instancePath);
|
||||||
|
|||||||
@@ -58,8 +58,9 @@ export const $schema = <
|
|||||||
async (params, ctx: AppToolHandlerCtx) => {
|
async (params, ctx: AppToolHandlerCtx) => {
|
||||||
const { value, return_config, secrets } = params;
|
const { value, return_config, secrets } = params;
|
||||||
const [module_name, ...rest] = node.instancePath;
|
const [module_name, ...rest] = node.instancePath;
|
||||||
|
const manager = mcp.getManager(ctx);
|
||||||
|
|
||||||
await ctx.context.app.mutateConfig(module_name as any).overwrite(rest, value);
|
await manager.mutateConfigSafe(module_name as any).overwrite(rest, value);
|
||||||
|
|
||||||
let config: any = undefined;
|
let config: any = undefined;
|
||||||
if (return_config) {
|
if (return_config) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from "bknd/utils";
|
} from "bknd/utils";
|
||||||
import type { ModuleBuildContext } from "modules";
|
import type { ModuleBuildContext } from "modules";
|
||||||
import { excludePropertyTypes, rescursiveClean } from "./utils";
|
import { excludePropertyTypes, rescursiveClean } from "./utils";
|
||||||
|
import type { DbModuleManager } from "modules/db/DbModuleManager";
|
||||||
|
|
||||||
export const mcpSchemaSymbol = Symbol.for("bknd-mcp-schema");
|
export const mcpSchemaSymbol = Symbol.for("bknd-mcp-schema");
|
||||||
|
|
||||||
@@ -74,4 +75,13 @@ export class McpSchemaHelper<AdditionalOptions = {}> {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getManager(ctx: AppToolHandlerCtx): DbModuleManager {
|
||||||
|
const manager = ctx.context.app.modules;
|
||||||
|
if ("mutateConfigSafe" in manager) {
|
||||||
|
return manager as DbModuleManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Manager not found");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,9 +19,11 @@ export function getSystemMcp(app: App) {
|
|||||||
].sort((a, b) => a.name.localeCompare(b.name));
|
].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
// tools from app schema
|
// tools from app schema
|
||||||
|
if (!app.isReadOnly()) {
|
||||||
tools.push(
|
tools.push(
|
||||||
...nodes.flatMap((n) => n.schema.getTools(n)).sort((a, b) => a.name.localeCompare(b.name)),
|
...nodes.flatMap((n) => n.schema.getTools(n)).sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const resources = [...middlewareServer.resources, ...app.modules.ctx().mcp.resources];
|
const resources = [...middlewareServer.resources, ...app.modules.ctx().mcp.resources];
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export const serverConfigSchema = $object(
|
|||||||
{
|
{
|
||||||
description: "Server configuration",
|
description: "Server configuration",
|
||||||
},
|
},
|
||||||
);
|
).strict();
|
||||||
|
|
||||||
export type AppServerConfig = s.Static<typeof serverConfigSchema>;
|
export type AppServerConfig = s.Static<typeof serverConfigSchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import * as SystemPermissions from "modules/permissions";
|
|||||||
import { getVersion } from "core/env";
|
import { getVersion } from "core/env";
|
||||||
import type { Module } from "modules/Module";
|
import type { Module } from "modules/Module";
|
||||||
import { getSystemMcp } from "modules/mcp/system-mcp";
|
import { getSystemMcp } from "modules/mcp/system-mcp";
|
||||||
|
import type { DbModuleManager } from "modules/db/DbModuleManager";
|
||||||
|
|
||||||
export type ConfigUpdate<Key extends ModuleKey = ModuleKey> = {
|
export type ConfigUpdate<Key extends ModuleKey = ModuleKey> = {
|
||||||
success: true;
|
success: true;
|
||||||
@@ -43,6 +44,7 @@ export type ConfigUpdateResponse<Key extends ModuleKey = ModuleKey> =
|
|||||||
export type SchemaResponse = {
|
export type SchemaResponse = {
|
||||||
version: string;
|
version: string;
|
||||||
schema: ModuleSchemas;
|
schema: ModuleSchemas;
|
||||||
|
readonly: boolean;
|
||||||
config: ModuleConfigs;
|
config: ModuleConfigs;
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
};
|
};
|
||||||
@@ -109,9 +111,10 @@ export class SystemController extends Controller {
|
|||||||
private registerConfigController(client: Hono<any>): void {
|
private registerConfigController(client: Hono<any>): void {
|
||||||
const { permission } = this.middlewares;
|
const { permission } = this.middlewares;
|
||||||
// don't add auth again, it's already added in getController
|
// don't add auth again, it's already added in getController
|
||||||
const hono = this.create();
|
const hono = this.create().use(permission(SystemPermissions.configRead));
|
||||||
|
|
||||||
hono.use(permission(SystemPermissions.configRead));
|
if (!this.app.isReadOnly()) {
|
||||||
|
const manager = this.app.modules as DbModuleManager;
|
||||||
|
|
||||||
hono.get(
|
hono.get(
|
||||||
"/raw",
|
"/raw",
|
||||||
@@ -122,10 +125,150 @@ export class SystemController extends Controller {
|
|||||||
permission([SystemPermissions.configReadSecrets]),
|
permission([SystemPermissions.configReadSecrets]),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
// @ts-expect-error "fetch" is private
|
// @ts-expect-error "fetch" is private
|
||||||
return c.json(await this.app.modules.fetch());
|
return c.json(await this.app.modules.fetch().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(
|
hono.get(
|
||||||
"/:module?",
|
"/:module?",
|
||||||
describeRoute({
|
describeRoute({
|
||||||
@@ -160,124 +303,6 @@ export class SystemController extends Controller {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
async function handleConfigUpdateResponse(c: Context<any>, cb: () => Promise<ConfigUpdate>) {
|
|
||||||
try {
|
|
||||||
return c.json(await cb(), { status: 202 });
|
|
||||||
} catch (e) {
|
|
||||||
$console.error("config update error", e);
|
|
||||||
|
|
||||||
if (e instanceof InvalidSchemaError) {
|
|
||||||
return c.json(
|
|
||||||
{ success: false, type: "type-invalid", errors: e.errors },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (e instanceof Error) {
|
|
||||||
return c.json({ success: false, type: "error", error: e.message }, { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json({ success: false, type: "unknown" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
hono.post(
|
|
||||||
"/set/:module",
|
|
||||||
permission(SystemPermissions.configWrite),
|
|
||||||
jsc("query", s.object({ force: s.boolean().optional() }), { skipOpenAPI: true }),
|
|
||||||
async (c) => {
|
|
||||||
const module = c.req.param("module") as any;
|
|
||||||
const { force } = c.req.valid("query");
|
|
||||||
const value = await c.req.json();
|
|
||||||
|
|
||||||
return await handleConfigUpdateResponse(c, async () => {
|
|
||||||
// you must explicitly set force to override existing values
|
|
||||||
// because omitted values gets removed
|
|
||||||
if (force === true) {
|
|
||||||
// force overwrite defined keys
|
|
||||||
const newConfig = {
|
|
||||||
...this.app.module[module].config,
|
|
||||||
...value,
|
|
||||||
};
|
|
||||||
await this.app.mutateConfig(module).set(newConfig);
|
|
||||||
} else {
|
|
||||||
await this.app.mutateConfig(module).patch("", value);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
module,
|
|
||||||
config: this.app.module[module].config,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
hono.post("/add/:module/:path", permission(SystemPermissions.configWrite), async (c) => {
|
|
||||||
// @todo: require auth (admin)
|
|
||||||
const module = c.req.param("module") as any;
|
|
||||||
const value = await c.req.json();
|
|
||||||
const path = c.req.param("path") as string;
|
|
||||||
|
|
||||||
if (this.app.modules.get(module).schema().has(path)) {
|
|
||||||
return c.json({ success: false, path, error: "Path already exists" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return await handleConfigUpdateResponse(c, async () => {
|
|
||||||
await this.app.mutateConfig(module).patch(path, value);
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
module,
|
|
||||||
config: this.app.module[module].config,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
hono.patch("/patch/:module/:path", permission(SystemPermissions.configWrite), async (c) => {
|
|
||||||
// @todo: require auth (admin)
|
|
||||||
const module = c.req.param("module") as any;
|
|
||||||
const value = await c.req.json();
|
|
||||||
const path = c.req.param("path");
|
|
||||||
|
|
||||||
return await handleConfigUpdateResponse(c, async () => {
|
|
||||||
await this.app.mutateConfig(module).patch(path, value);
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
module,
|
|
||||||
config: this.app.module[module].config,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
hono.put("/overwrite/:module/:path", permission(SystemPermissions.configWrite), async (c) => {
|
|
||||||
// @todo: require auth (admin)
|
|
||||||
const module = c.req.param("module") as any;
|
|
||||||
const value = await c.req.json();
|
|
||||||
const path = c.req.param("path");
|
|
||||||
|
|
||||||
return await handleConfigUpdateResponse(c, async () => {
|
|
||||||
await this.app.mutateConfig(module).overwrite(path, value);
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
module,
|
|
||||||
config: this.app.module[module].config,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
hono.delete("/remove/:module/:path", permission(SystemPermissions.configWrite), async (c) => {
|
|
||||||
// @todo: require auth (admin)
|
|
||||||
const module = c.req.param("module") as any;
|
|
||||||
const path = c.req.param("path")!;
|
|
||||||
|
|
||||||
return await handleConfigUpdateResponse(c, async () => {
|
|
||||||
await this.app.mutateConfig(module).remove(path);
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
module,
|
|
||||||
config: this.app.module[module].config,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
client.route("/config", hono);
|
client.route("/config", hono);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,6 +332,7 @@ export class SystemController extends Controller {
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const module = c.req.param("module") as ModuleKey | undefined;
|
const module = c.req.param("module") as ModuleKey | undefined;
|
||||||
const { config, secrets, fresh } = c.req.valid("query");
|
const { config, secrets, fresh } = c.req.valid("query");
|
||||||
|
const readonly = this.app.isReadOnly();
|
||||||
|
|
||||||
config && this.ctx.guard.throwUnlessGranted(SystemPermissions.configRead, c);
|
config && this.ctx.guard.throwUnlessGranted(SystemPermissions.configRead, c);
|
||||||
secrets && this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets, c);
|
secrets && this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets, c);
|
||||||
@@ -321,6 +347,7 @@ export class SystemController extends Controller {
|
|||||||
if (module) {
|
if (module) {
|
||||||
return c.json({
|
return c.json({
|
||||||
module,
|
module,
|
||||||
|
readonly,
|
||||||
version,
|
version,
|
||||||
schema: schema[module],
|
schema: schema[module],
|
||||||
config: config ? this.app.module[module].toJSON(secrets) : undefined,
|
config: config ? this.app.module[module].toJSON(secrets) : undefined,
|
||||||
@@ -330,6 +357,7 @@ export class SystemController extends Controller {
|
|||||||
return c.json({
|
return c.json({
|
||||||
module,
|
module,
|
||||||
version,
|
version,
|
||||||
|
readonly,
|
||||||
schema,
|
schema,
|
||||||
config: config ? this.app.toJSON(secrets) : undefined,
|
config: config ? this.app.toJSON(secrets) : undefined,
|
||||||
permissions: this.app.modules.ctx().guard.getPermissionNames(),
|
permissions: this.app.modules.ctx().guard.getPermissionNames(),
|
||||||
@@ -381,6 +409,8 @@ export class SystemController extends Controller {
|
|||||||
config: c.get("app")?.version(),
|
config: c.get("app")?.version(),
|
||||||
bknd: getVersion(),
|
bknd: getVersion(),
|
||||||
},
|
},
|
||||||
|
mode: this.app.mode,
|
||||||
|
readonly: this.app.isReadOnly(),
|
||||||
runtime: getRuntimeKey(),
|
runtime: getRuntimeKey(),
|
||||||
connection: {
|
connection: {
|
||||||
name: this.app.em.connection.name,
|
name: this.app.em.connection.name,
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export function syncConfig({
|
|||||||
|
|
||||||
if (firstBoot) {
|
if (firstBoot) {
|
||||||
firstBoot = false;
|
firstBoot = false;
|
||||||
await write?.(app.toJSON(true));
|
await write?.(app.toJSON(includeSecrets));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
37
app/src/plugins/dev/sync-secrets.plugin.ts
Normal file
37
app/src/plugins/dev/sync-secrets.plugin.ts
Normal 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -6,3 +6,4 @@ export {
|
|||||||
export { showRoutes, type ShowRoutesOptions } from "./dev/show-routes.plugin";
|
export { showRoutes, type ShowRoutesOptions } from "./dev/show-routes.plugin";
|
||||||
export { syncConfig, type SyncConfigOptions } from "./dev/sync-config.plugin";
|
export { syncConfig, type SyncConfigOptions } from "./dev/sync-config.plugin";
|
||||||
export { syncTypes, type SyncTypesOptions } from "./dev/sync-types.plugin";
|
export { syncTypes, type SyncTypesOptions } from "./dev/sync-types.plugin";
|
||||||
|
export { syncSecrets, type SyncSecretsOptions } from "./dev/sync-secrets.plugin";
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import type { ModuleConfigs, ModuleSchemas } from "modules";
|
import type { ModuleConfigs, ModuleSchemas } from "modules";
|
||||||
import { getDefaultConfig, getDefaultSchema } from "modules/ModuleManager";
|
import { getDefaultConfig, getDefaultSchema } from "modules/ModuleManager";
|
||||||
import { createContext, startTransition, useContext, useEffect, useRef, useState } from "react";
|
import {
|
||||||
|
createContext,
|
||||||
|
startTransition,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
import { useApi } from "ui/client";
|
import { useApi } from "ui/client";
|
||||||
import { type TSchemaActions, getSchemaActions } from "./schema/actions";
|
import { type TSchemaActions, getSchemaActions } from "./schema/actions";
|
||||||
import { AppReduced } from "./utils/AppReduced";
|
import { AppReduced } from "./utils/AppReduced";
|
||||||
@@ -15,6 +23,7 @@ export type BkndAdminOptions = {
|
|||||||
};
|
};
|
||||||
type BkndContext = {
|
type BkndContext = {
|
||||||
version: number;
|
version: number;
|
||||||
|
readonly: boolean;
|
||||||
schema: ModuleSchemas;
|
schema: ModuleSchemas;
|
||||||
config: ModuleConfigs;
|
config: ModuleConfigs;
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
@@ -48,7 +57,12 @@ export function BkndProvider({
|
|||||||
}) {
|
}) {
|
||||||
const [withSecrets, setWithSecrets] = useState<boolean>(includeSecrets);
|
const [withSecrets, setWithSecrets] = useState<boolean>(includeSecrets);
|
||||||
const [schema, setSchema] =
|
const [schema, setSchema] =
|
||||||
useState<Pick<BkndContext, "version" | "schema" | "config" | "permissions" | "fallback">>();
|
useState<
|
||||||
|
Pick<
|
||||||
|
BkndContext,
|
||||||
|
"version" | "schema" | "config" | "permissions" | "fallback" | "readonly"
|
||||||
|
>
|
||||||
|
>();
|
||||||
const [fetched, setFetched] = useState(false);
|
const [fetched, setFetched] = useState(false);
|
||||||
const [error, setError] = useState<boolean>();
|
const [error, setError] = useState<boolean>();
|
||||||
const errorShown = useRef<boolean>(false);
|
const errorShown = useRef<boolean>(false);
|
||||||
@@ -97,6 +111,7 @@ export function BkndProvider({
|
|||||||
? res.body
|
? res.body
|
||||||
: ({
|
: ({
|
||||||
version: 0,
|
version: 0,
|
||||||
|
mode: "db",
|
||||||
schema: getDefaultSchema(),
|
schema: getDefaultSchema(),
|
||||||
config: getDefaultConfig(),
|
config: getDefaultConfig(),
|
||||||
permissions: [],
|
permissions: [],
|
||||||
@@ -173,3 +188,8 @@ export function useBkndOptions(): BkndAdminOptions {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SchemaEditable({ children }: { children: ReactNode }) {
|
||||||
|
const { readonly } = useBknd();
|
||||||
|
return !readonly ? children : null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export { BkndProvider, type BkndAdminOptions, useBknd } from "./BkndProvider";
|
export { BkndProvider, type BkndAdminOptions, useBknd, SchemaEditable } from "./BkndProvider";
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
|||||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||||
import { routes, useNavigate, useRouteNavigate } from "ui/lib/routes";
|
import { routes, useNavigate, useRouteNavigate } from "ui/lib/routes";
|
||||||
import { testIds } from "ui/lib/config";
|
import { testIds } from "ui/lib/config";
|
||||||
|
import { SchemaEditable, useBknd } from "ui/client/bknd";
|
||||||
|
|
||||||
export function DataRoot({ children }) {
|
export function DataRoot({ children }) {
|
||||||
// @todo: settings routes should be centralized
|
// @todo: settings routes should be centralized
|
||||||
@@ -73,9 +74,11 @@ export function DataRoot({ children }) {
|
|||||||
value={context}
|
value={context}
|
||||||
onChange={handleSegmentChange}
|
onChange={handleSegmentChange}
|
||||||
/>
|
/>
|
||||||
|
<SchemaEditable>
|
||||||
<Tooltip label="New Entity">
|
<Tooltip label="New Entity">
|
||||||
<IconButton Icon={TbDatabasePlus} onClick={$data.modals.createEntity} />
|
<IconButton Icon={TbDatabasePlus} onClick={$data.modals.createEntity} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
</SchemaEditable>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -254,11 +257,26 @@ export function DataEmpty() {
|
|||||||
useBrowserTitle(["Data"]);
|
useBrowserTitle(["Data"]);
|
||||||
const [navigate] = useNavigate();
|
const [navigate] = useNavigate();
|
||||||
const { $data } = useBkndData();
|
const { $data } = useBkndData();
|
||||||
|
const { readonly } = useBknd();
|
||||||
|
|
||||||
function handleButtonClick() {
|
function handleButtonClick() {
|
||||||
navigate(routes.data.schema.root());
|
navigate(routes.data.schema.root());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (readonly) {
|
||||||
|
return (
|
||||||
|
<Empty
|
||||||
|
Icon={IconDatabase}
|
||||||
|
title="No entity selected"
|
||||||
|
description="Please select an entity from the left sidebar."
|
||||||
|
primary={{
|
||||||
|
children: "Go to schema",
|
||||||
|
onClick: handleButtonClick,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Empty
|
<Empty
|
||||||
Icon={IconDatabase}
|
Icon={IconDatabase}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { fieldSpecs } from "ui/modules/data/components/fields-specs";
|
|||||||
import { extractSchema } from "../settings/utils/schema";
|
import { extractSchema } from "../settings/utils/schema";
|
||||||
import { EntityFieldsForm, type EntityFieldsFormRef } from "./forms/entity.fields.form";
|
import { EntityFieldsForm, type EntityFieldsFormRef } from "./forms/entity.fields.form";
|
||||||
import { RoutePathStateProvider } from "ui/hooks/use-route-path-state";
|
import { RoutePathStateProvider } from "ui/hooks/use-route-path-state";
|
||||||
|
import { SchemaEditable, useBknd } from "ui/client/bknd";
|
||||||
|
|
||||||
export function DataSchemaEntity({ params }) {
|
export function DataSchemaEntity({ params }) {
|
||||||
const { $data } = useBkndData();
|
const { $data } = useBkndData();
|
||||||
@@ -67,6 +68,7 @@ export function DataSchemaEntity({ params }) {
|
|||||||
>
|
>
|
||||||
<IconButton Icon={TbDots} />
|
<IconButton Icon={TbDots} />
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
<SchemaEditable>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
@@ -90,6 +92,7 @@ export function DataSchemaEntity({ params }) {
|
|||||||
>
|
>
|
||||||
<Button IconRight={TbPlus}>Add</Button>
|
<Button IconRight={TbPlus}>Add</Button>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
</SchemaEditable>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
className="pl-3"
|
className="pl-3"
|
||||||
@@ -149,6 +152,7 @@ const Fields = ({ entity }: { entity: Entity }) => {
|
|||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [updates, setUpdates] = useState(0);
|
const [updates, setUpdates] = useState(0);
|
||||||
const { actions, $data, config } = useBkndData();
|
const { actions, $data, config } = useBkndData();
|
||||||
|
const { readonly } = useBknd();
|
||||||
const [res, setRes] = useState<any>();
|
const [res, setRes] = useState<any>();
|
||||||
const ref = useRef<EntityFieldsFormRef>(null);
|
const ref = useRef<EntityFieldsFormRef>(null);
|
||||||
async function handleUpdate() {
|
async function handleUpdate() {
|
||||||
@@ -169,7 +173,7 @@ const Fields = ({ entity }: { entity: Entity }) => {
|
|||||||
title="Fields"
|
title="Fields"
|
||||||
ActiveIcon={IconAlignJustified}
|
ActiveIcon={IconAlignJustified}
|
||||||
renderHeaderRight={({ open }) =>
|
renderHeaderRight={({ open }) =>
|
||||||
open ? (
|
open && !readonly ? (
|
||||||
<Button variant="primary" disabled={!open} onClick={handleUpdate}>
|
<Button variant="primary" disabled={!open} onClick={handleUpdate}>
|
||||||
Update
|
Update
|
||||||
</Button>
|
</Button>
|
||||||
@@ -181,11 +185,12 @@ const Fields = ({ entity }: { entity: Entity }) => {
|
|||||||
<div className="animate-fade-in absolute w-full h-full top-0 bottom-0 left-0 right-0 bg-background/65 z-50" />
|
<div className="animate-fade-in absolute w-full h-full top-0 bottom-0 left-0 right-0 bg-background/65 z-50" />
|
||||||
)}
|
)}
|
||||||
<EntityFieldsForm
|
<EntityFieldsForm
|
||||||
|
readonly={readonly}
|
||||||
routePattern={`/entity/${entity.name}/fields/:sub?`}
|
routePattern={`/entity/${entity.name}/fields/:sub?`}
|
||||||
fields={initialFields}
|
fields={initialFields}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
key={String(updates)}
|
key={String(updates)}
|
||||||
sortable
|
sortable={!readonly}
|
||||||
additionalFieldTypes={fieldSpecs
|
additionalFieldTypes={fieldSpecs
|
||||||
.filter((f) => ["relation", "media"].includes(f.type))
|
.filter((f) => ["relation", "media"].includes(f.type))
|
||||||
.map((i) => ({
|
.map((i) => ({
|
||||||
@@ -205,7 +210,7 @@ const Fields = ({ entity }: { entity: Entity }) => {
|
|||||||
isNew={false}
|
isNew={false}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isDebug() && (
|
{isDebug() && !readonly && (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex flex-row gap-1 justify-center">
|
<div className="flex flex-row gap-1 justify-center">
|
||||||
<Button size="small" onClick={() => setRes(ref.current?.isValid())}>
|
<Button size="small" onClick={() => setRes(ref.current?.isValid())}>
|
||||||
@@ -237,6 +242,7 @@ const BasicSettings = ({ entity }: { entity: Entity }) => {
|
|||||||
const d = useBkndData();
|
const d = useBkndData();
|
||||||
const config = d.entities?.[entity.name]?.config;
|
const config = d.entities?.[entity.name]?.config;
|
||||||
const formRef = useRef<JsonSchemaFormRef>(null);
|
const formRef = useRef<JsonSchemaFormRef>(null);
|
||||||
|
const { readonly } = useBknd();
|
||||||
|
|
||||||
const schema = cloneDeep(
|
const schema = cloneDeep(
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -264,7 +270,7 @@ const BasicSettings = ({ entity }: { entity: Entity }) => {
|
|||||||
title="Settings"
|
title="Settings"
|
||||||
ActiveIcon={IconSettings}
|
ActiveIcon={IconSettings}
|
||||||
renderHeaderRight={({ open }) =>
|
renderHeaderRight={({ open }) =>
|
||||||
open ? (
|
open && !readonly ? (
|
||||||
<Button variant="primary" disabled={!open} onClick={handleUpdate}>
|
<Button variant="primary" disabled={!open} onClick={handleUpdate}>
|
||||||
Update
|
Update
|
||||||
</Button>
|
</Button>
|
||||||
@@ -278,6 +284,7 @@ const BasicSettings = ({ entity }: { entity: Entity }) => {
|
|||||||
formData={_config}
|
formData={_config}
|
||||||
onSubmit={console.log}
|
onSubmit={console.log}
|
||||||
className="legacy hide-required-mark fieldset-alternative mute-root"
|
className="legacy hide-required-mark fieldset-alternative mute-root"
|
||||||
|
readonly={readonly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</AppShell.RouteAwareSectionHeaderAccordionItem>
|
</AppShell.RouteAwareSectionHeaderAccordionItem>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Suspense, lazy } from "react";
|
import { Suspense, lazy } from "react";
|
||||||
|
import { SchemaEditable } from "ui/client/bknd";
|
||||||
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
|
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
|
||||||
import { Button } from "ui/components/buttons/Button";
|
import { Button } from "ui/components/buttons/Button";
|
||||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||||
@@ -15,9 +16,11 @@ export function DataSchemaIndex() {
|
|||||||
<>
|
<>
|
||||||
<AppShell.SectionHeader
|
<AppShell.SectionHeader
|
||||||
right={
|
right={
|
||||||
|
<SchemaEditable>
|
||||||
<Button type="button" variant="primary" onClick={$data.modals.createAny}>
|
<Button type="button" variant="primary" onClick={$data.modals.createAny}>
|
||||||
Create new
|
Create new
|
||||||
</Button>
|
</Button>
|
||||||
|
</SchemaEditable>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Schema Overview
|
Schema Overview
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { MantineSelect } from "ui/components/form/hook-form-mantine/MantineSelec
|
|||||||
import type { TPrimaryFieldFormat } from "data/fields/PrimaryField";
|
import type { TPrimaryFieldFormat } from "data/fields/PrimaryField";
|
||||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import ErrorBoundary from "ui/components/display/ErrorBoundary";
|
import ErrorBoundary from "ui/components/display/ErrorBoundary";
|
||||||
|
import { SchemaEditable } from "ui/client/bknd";
|
||||||
|
|
||||||
const fieldsSchemaObject = originalFieldsSchemaObject;
|
const fieldsSchemaObject = originalFieldsSchemaObject;
|
||||||
const fieldsSchema = s.anyOf(Object.values(fieldsSchemaObject));
|
const fieldsSchema = s.anyOf(Object.values(fieldsSchemaObject));
|
||||||
@@ -64,6 +65,7 @@ export type EntityFieldsFormProps = {
|
|||||||
routePattern?: string;
|
routePattern?: string;
|
||||||
defaultPrimaryFormat?: TPrimaryFieldFormat;
|
defaultPrimaryFormat?: TPrimaryFieldFormat;
|
||||||
isNew?: boolean;
|
isNew?: boolean;
|
||||||
|
readonly?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EntityFieldsFormRef = {
|
export type EntityFieldsFormRef = {
|
||||||
@@ -76,7 +78,7 @@ export type EntityFieldsFormRef = {
|
|||||||
|
|
||||||
export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsFormProps>(
|
export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsFormProps>(
|
||||||
function EntityFieldsForm(
|
function EntityFieldsForm(
|
||||||
{ fields: _fields, sortable, additionalFieldTypes, routePattern, isNew, ...props },
|
{ fields: _fields, sortable, additionalFieldTypes, routePattern, isNew, readonly, ...props },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const entityFields = Object.entries(_fields).map(([name, field]) => ({
|
const entityFields = Object.entries(_fields).map(([name, field]) => ({
|
||||||
@@ -162,6 +164,7 @@ export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsForm
|
|||||||
disableIndices={[0]}
|
disableIndices={[0]}
|
||||||
renderItem={({ dnd, ...props }, index) => (
|
renderItem={({ dnd, ...props }, index) => (
|
||||||
<EntityFieldMemo
|
<EntityFieldMemo
|
||||||
|
readonly={readonly}
|
||||||
key={props.id}
|
key={props.id}
|
||||||
field={props as any}
|
field={props as any}
|
||||||
index={index}
|
index={index}
|
||||||
@@ -181,6 +184,7 @@ export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsForm
|
|||||||
<div>
|
<div>
|
||||||
{fields.map((field, index) => (
|
{fields.map((field, index) => (
|
||||||
<EntityField
|
<EntityField
|
||||||
|
readonly={readonly}
|
||||||
key={field.id}
|
key={field.id}
|
||||||
field={field as any}
|
field={field as any}
|
||||||
index={index}
|
index={index}
|
||||||
@@ -197,6 +201,7 @@ export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsForm
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<SchemaEditable>
|
||||||
<Popover
|
<Popover
|
||||||
className="flex flex-col w-full"
|
className="flex flex-col w-full"
|
||||||
target={({ toggle }) => (
|
target={({ toggle }) => (
|
||||||
@@ -211,6 +216,7 @@ export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsForm
|
|||||||
>
|
>
|
||||||
<Button className="justify-center">Add Field</Button>
|
<Button className="justify-center">Add Field</Button>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
</SchemaEditable>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -288,6 +294,7 @@ function EntityField({
|
|||||||
dnd,
|
dnd,
|
||||||
routePattern,
|
routePattern,
|
||||||
primary,
|
primary,
|
||||||
|
readonly,
|
||||||
}: {
|
}: {
|
||||||
field: FieldArrayWithId<TFieldsFormSchema, "fields", "id">;
|
field: FieldArrayWithId<TFieldsFormSchema, "fields", "id">;
|
||||||
index: number;
|
index: number;
|
||||||
@@ -303,6 +310,7 @@ function EntityField({
|
|||||||
defaultFormat?: TPrimaryFieldFormat;
|
defaultFormat?: TPrimaryFieldFormat;
|
||||||
editable?: boolean;
|
editable?: boolean;
|
||||||
};
|
};
|
||||||
|
readonly?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const prefix = `fields.${index}.field` as const;
|
const prefix = `fields.${index}.field` as const;
|
||||||
const type = field.field.type;
|
const type = field.field.type;
|
||||||
@@ -393,6 +401,7 @@ function EntityField({
|
|||||||
<span className="text-xs text-primary/50 leading-none">Required</span>
|
<span className="text-xs text-primary/50 leading-none">Required</span>
|
||||||
<MantineSwitch
|
<MantineSwitch
|
||||||
size="sm"
|
size="sm"
|
||||||
|
disabled={readonly}
|
||||||
name={`${prefix}.config.required`}
|
name={`${prefix}.config.required`}
|
||||||
control={control}
|
control={control}
|
||||||
/>
|
/>
|
||||||
@@ -433,6 +442,7 @@ function EntityField({
|
|||||||
<div className="flex flex-row">
|
<div className="flex flex-row">
|
||||||
<MantineSwitch
|
<MantineSwitch
|
||||||
label="Required"
|
label="Required"
|
||||||
|
disabled={readonly}
|
||||||
name={`${prefix}.config.required`}
|
name={`${prefix}.config.required`}
|
||||||
control={control}
|
control={control}
|
||||||
/>
|
/>
|
||||||
@@ -440,11 +450,13 @@ function EntityField({
|
|||||||
<TextInput
|
<TextInput
|
||||||
label="Label"
|
label="Label"
|
||||||
placeholder="Label"
|
placeholder="Label"
|
||||||
|
disabled={readonly}
|
||||||
{...register(`${prefix}.config.label`)}
|
{...register(`${prefix}.config.label`)}
|
||||||
/>
|
/>
|
||||||
<Textarea
|
<Textarea
|
||||||
label="Description"
|
label="Description"
|
||||||
placeholder="Description"
|
placeholder="Description"
|
||||||
|
disabled={readonly}
|
||||||
{...register(`${prefix}.config.description`)}
|
{...register(`${prefix}.config.description`)}
|
||||||
/>
|
/>
|
||||||
{!hidden.includes("virtual") && (
|
{!hidden.includes("virtual") && (
|
||||||
@@ -452,7 +464,7 @@ function EntityField({
|
|||||||
label="Virtual"
|
label="Virtual"
|
||||||
name={`${prefix}.config.virtual`}
|
name={`${prefix}.config.virtual`}
|
||||||
control={control}
|
control={control}
|
||||||
disabled={disabled.includes("virtual")}
|
disabled={disabled.includes("virtual") || readonly}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -468,6 +480,7 @@ function EntityField({
|
|||||||
...value,
|
...value,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
readonly={readonly}
|
||||||
/>
|
/>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
@@ -478,6 +491,7 @@ function EntityField({
|
|||||||
return <JsonViewer json={json} expand={4} />;
|
return <JsonViewer json={json} expand={4} />;
|
||||||
})()}
|
})()}
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
{!readonly && (
|
||||||
<div className="flex flex-row justify-end">
|
<div className="flex flex-row justify-end">
|
||||||
<Button
|
<Button
|
||||||
IconLeft={TbTrash}
|
IconLeft={TbTrash}
|
||||||
@@ -488,6 +502,7 @@ function EntityField({
|
|||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -498,9 +513,11 @@ function EntityField({
|
|||||||
const SpecificForm = ({
|
const SpecificForm = ({
|
||||||
field,
|
field,
|
||||||
onChange,
|
onChange,
|
||||||
|
readonly,
|
||||||
}: {
|
}: {
|
||||||
field: FieldArrayWithId<TFieldsFormSchema, "fields", "id">;
|
field: FieldArrayWithId<TFieldsFormSchema, "fields", "id">;
|
||||||
onChange: (value: any) => void;
|
onChange: (value: any) => void;
|
||||||
|
readonly?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const type = field.field.type;
|
const type = field.field.type;
|
||||||
const specificData = omit(field.field.config, commonProps);
|
const specificData = omit(field.field.config, commonProps);
|
||||||
@@ -513,6 +530,7 @@ const SpecificForm = ({
|
|||||||
uiSchema={dataFieldsUiSchema.config}
|
uiSchema={dataFieldsUiSchema.config}
|
||||||
className="legacy hide-required-mark fieldset-alternative mute-root"
|
className="legacy hide-required-mark fieldset-alternative mute-root"
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
readonly={readonly}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export function Setting<Schema extends s.ObjectSchema = s.ObjectSchema>({
|
|||||||
properties,
|
properties,
|
||||||
}: SettingProps<Schema>) {
|
}: SettingProps<Schema>) {
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const { actions } = useBknd();
|
const { actions, readonly } = useBknd();
|
||||||
const formRef = useRef<JsonSchemaFormRef>(null);
|
const formRef = useRef<JsonSchemaFormRef>(null);
|
||||||
const schemaLocalModalRef = useRef<SettingsSchemaModalRef>(null);
|
const schemaLocalModalRef = useRef<SettingsSchemaModalRef>(null);
|
||||||
const schemaModalRef = useRef<SettingsSchemaModalRef>(null);
|
const schemaModalRef = useRef<SettingsSchemaModalRef>(null);
|
||||||
@@ -120,14 +120,14 @@ export function Setting<Schema extends s.ObjectSchema = s.ObjectSchema>({
|
|||||||
extractedKeys.find((key) => window.location.pathname.endsWith(key)) ?? extractedKeys[0];
|
extractedKeys.find((key) => window.location.pathname.endsWith(key)) ?? extractedKeys[0];
|
||||||
|
|
||||||
const onToggleEdit = useEvent(() => {
|
const onToggleEdit = useEvent(() => {
|
||||||
if (!editAllowed) return;
|
if (!editAllowed || readonly) return;
|
||||||
|
|
||||||
setEditing((prev) => !prev);
|
setEditing((prev) => !prev);
|
||||||
//formRef.current?.cancel();
|
//formRef.current?.cancel();
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSave = useEvent(async () => {
|
const onSave = useEvent(async () => {
|
||||||
if (!editAllowed || !editing) return;
|
if (!editAllowed || !editing || readonly) return;
|
||||||
|
|
||||||
if (formRef.current?.validateForm()) {
|
if (formRef.current?.validateForm()) {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
@@ -215,14 +215,14 @@ export function Setting<Schema extends s.ObjectSchema = s.ObjectSchema>({
|
|||||||
>
|
>
|
||||||
<IconButton Icon={TbSettings} />
|
<IconButton Icon={TbSettings} />
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
<Button onClick={onToggleEdit} disabled={!editAllowed}>
|
<Button onClick={onToggleEdit} disabled={!editAllowed || readonly}>
|
||||||
{editing ? "Cancel" : "Edit"}
|
{editing ? "Cancel" : "Edit"}
|
||||||
</Button>
|
</Button>
|
||||||
{editing && (
|
{editing && (
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={onSave}
|
onClick={onSave}
|
||||||
disabled={submitting || !editAllowed}
|
disabled={submitting || !editAllowed || readonly}
|
||||||
>
|
>
|
||||||
{submitting ? "Save..." : "Save"}
|
{submitting ? "Save..." : "Save"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export default defineConfig({
|
|||||||
devServer({
|
devServer({
|
||||||
...devServerConfig,
|
...devServerConfig,
|
||||||
entry: "./vite.dev.ts",
|
entry: "./vite.dev.ts",
|
||||||
|
//entry: "./vite.dev.code.ts",
|
||||||
}),
|
}),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { showRoutes } from "hono/dev";
|
|||||||
import { App, registries } from "./src";
|
import { App, registries } from "./src";
|
||||||
import { StorageLocalAdapter } from "./src/adapter/node";
|
import { StorageLocalAdapter } from "./src/adapter/node";
|
||||||
import type { Connection } from "./src/data/connection/Connection";
|
import type { Connection } from "./src/data/connection/Connection";
|
||||||
import { __bknd } from "modules/ModuleManager";
|
import { __bknd } from "modules/db/DbModuleManager";
|
||||||
import { nodeSqlite } from "./src/adapter/node/connection/NodeSqliteConnection";
|
import { nodeSqlite } from "./src/adapter/node/connection/NodeSqliteConnection";
|
||||||
import { libsql } from "./src/data/connection/sqlite/libsql/LibsqlConnection";
|
import { libsql } from "./src/data/connection/sqlite/libsql/LibsqlConnection";
|
||||||
import { $console } from "core/utils/console";
|
import { $console } from "core/utils/console";
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export default {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
// an initial config is only applied if the database is empty
|
// an initial config is only applied if the database is empty
|
||||||
initialConfig: {
|
config: {
|
||||||
data: schema.toJSON(),
|
data: schema.toJSON(),
|
||||||
// we're enabling auth ...
|
// we're enabling auth ...
|
||||||
auth: {
|
auth: {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const config: BunBkndConfig = {
|
|||||||
connection: {
|
connection: {
|
||||||
url: "file:data.db",
|
url: "file:data.db",
|
||||||
},
|
},
|
||||||
initialConfig: {
|
config: {
|
||||||
media: {
|
media: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
adapter: {
|
adapter: {
|
||||||
|
|||||||
5
examples/cloudflare-worker/.gitignore
vendored
5
examples/cloudflare-worker/.gitignore
vendored
@@ -95,10 +95,7 @@ web_modules/
|
|||||||
# dotenv environment variable files
|
# dotenv environment variable files
|
||||||
|
|
||||||
.env
|
.env
|
||||||
.env.development.local
|
.env.*
|
||||||
.env.test.local
|
|
||||||
.env.production.local
|
|
||||||
.env.local
|
|
||||||
|
|
||||||
# parcel-bundler cache (https://parceljs.org/)
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^5.9.2",
|
||||||
"wrangler": "^4.28.1"
|
"wrangler": "^4.34.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export default {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
// an initial config is only applied if the database is empty
|
// an initial config is only applied if the database is empty
|
||||||
initialConfig: {
|
config: {
|
||||||
data: schema.toJSON(),
|
data: schema.toJSON(),
|
||||||
// we're enabling auth ...
|
// we're enabling auth ...
|
||||||
auth: {
|
auth: {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { App, boolean, em, entity, text } from "bknd";
|
|||||||
import { secureRandomString } from "bknd/utils";
|
import { secureRandomString } from "bknd/utils";
|
||||||
|
|
||||||
export default serve({
|
export default serve({
|
||||||
initialConfig: {
|
config: {
|
||||||
data: em({
|
data: em({
|
||||||
todos: entity("todos", {
|
todos: entity("todos", {
|
||||||
title: text(),
|
title: text(),
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export default {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
// an initial config is only applied if the database is empty
|
// an initial config is only applied if the database is empty
|
||||||
initialConfig: {
|
config: {
|
||||||
data: schema.toJSON(),
|
data: schema.toJSON(),
|
||||||
// we're enabling auth ...
|
// we're enabling auth ...
|
||||||
auth: {
|
auth: {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default {
|
|||||||
url: process.env.DB_URL ?? "file:data.db",
|
url: process.env.DB_URL ?? "file:data.db",
|
||||||
},
|
},
|
||||||
// an initial config is only applied if the database is empty
|
// an initial config is only applied if the database is empty
|
||||||
initialConfig: {
|
config: {
|
||||||
data: schema.toJSON(),
|
data: schema.toJSON(),
|
||||||
// we're enabling auth ...
|
// we're enabling auth ...
|
||||||
auth: {
|
auth: {
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export function testSuite(config: TestSuiteConfig) {
|
|||||||
|
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
connection,
|
connection,
|
||||||
initialConfig: {
|
config: {
|
||||||
data: schema.toJSON(),
|
data: schema.toJSON(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -177,7 +177,7 @@ export function testSuite(config: TestSuiteConfig) {
|
|||||||
|
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
connection,
|
connection,
|
||||||
initialConfig: {
|
config: {
|
||||||
data: schema.toJSON(),
|
data: schema.toJSON(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ describe("integration", () => {
|
|||||||
|
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
connection: create(),
|
connection: create(),
|
||||||
initialConfig: {
|
config: {
|
||||||
data: schema.toJSON(),
|
data: schema.toJSON(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user