mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-15 20:17:22 +00:00
10
.cursor/mcp.json
Normal file
10
.cursor/mcp.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"bknd": {
|
||||||
|
"url": "http://localhost:3000/mcp",
|
||||||
|
"headers": {
|
||||||
|
"API_KEY": "value"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,16 +9,16 @@ beforeAll(disableConsoleLog);
|
|||||||
afterAll(enableConsoleLog);
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
describe("adapter", () => {
|
describe("adapter", () => {
|
||||||
it("makes config", () => {
|
it("makes config", async () => {
|
||||||
expect(omitKeys(adapter.makeConfig({}), ["connection"])).toEqual({});
|
expect(omitKeys(await adapter.makeConfig({}), ["connection"])).toEqual({});
|
||||||
expect(omitKeys(adapter.makeConfig({}, { env: { TEST: "test" } }), ["connection"])).toEqual(
|
expect(
|
||||||
{},
|
omitKeys(await adapter.makeConfig({}, { env: { TEST: "test" } }), ["connection"]),
|
||||||
);
|
).toEqual({});
|
||||||
|
|
||||||
// merges everything returned from `app` with the config
|
// merges everything returned from `app` with the config
|
||||||
expect(
|
expect(
|
||||||
omitKeys(
|
omitKeys(
|
||||||
adapter.makeConfig(
|
await adapter.makeConfig(
|
||||||
{ app: (a) => ({ initialConfig: { server: { cors: { origin: a.env.TEST } } } }) },
|
{ app: (a) => ({ initialConfig: { server: { cors: { origin: a.env.TEST } } } }) },
|
||||||
{ env: { TEST: "test" } },
|
{ env: { TEST: "test" } },
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ describe("App", () => {
|
|||||||
"guard",
|
"guard",
|
||||||
"flags",
|
"flags",
|
||||||
"logger",
|
"logger",
|
||||||
|
"mcp",
|
||||||
"helper",
|
"helper",
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
@@ -135,4 +136,21 @@ describe("App", () => {
|
|||||||
// expect async listeners to be executed sync after request
|
// expect async listeners to be executed sync after request
|
||||||
expect(called).toHaveBeenCalled();
|
expect(called).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("getMcpClient", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
initialConfig: {
|
||||||
|
server: {
|
||||||
|
mcp: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
const client = app.getMcpClient();
|
||||||
|
const res = await client.listTools();
|
||||||
|
expect(res).toBeDefined();
|
||||||
|
expect(res?.tools.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ describe("AppServer", () => {
|
|||||||
allow_methods: ["GET", "POST", "PATCH", "PUT", "DELETE"],
|
allow_methods: ["GET", "POST", "PATCH", "PUT", "DELETE"],
|
||||||
allow_headers: ["Content-Type", "Content-Length", "Authorization", "Accept"],
|
allow_headers: ["Content-Type", "Content-Length", "Authorization", "Accept"],
|
||||||
},
|
},
|
||||||
|
mcp: {
|
||||||
|
enabled: false,
|
||||||
|
path: "/api/system/mcp",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +35,10 @@ describe("AppServer", () => {
|
|||||||
allow_methods: ["GET", "POST"],
|
allow_methods: ["GET", "POST"],
|
||||||
allow_headers: ["Content-Type", "Content-Length", "Authorization", "Accept"],
|
allow_headers: ["Content-Type", "Content-Length", "Authorization", "Accept"],
|
||||||
},
|
},
|
||||||
|
mcp: {
|
||||||
|
enabled: false,
|
||||||
|
path: "/api/system/mcp",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
226
app/__test__/app/mcp/mcp.auth.test.ts
Normal file
226
app/__test__/app/mcp/mcp.auth.test.ts
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import { describe, test, expect, beforeEach, beforeAll, afterAll } from "bun:test";
|
||||||
|
import { type App, createApp, createMcpToolCaller } from "core/test/utils";
|
||||||
|
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
||||||
|
import type { McpServer } from "bknd/utils";
|
||||||
|
|
||||||
|
beforeAll(disableConsoleLog);
|
||||||
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - [x] auth_me
|
||||||
|
* - [x] auth_strategies
|
||||||
|
* - [x] auth_user_create
|
||||||
|
* - [x] auth_user_token
|
||||||
|
* - [x] auth_user_password_change
|
||||||
|
* - [x] auth_user_password_test
|
||||||
|
* - [x] config_auth_get
|
||||||
|
* - [x] config_auth_update
|
||||||
|
* - [x] config_auth_strategies_get
|
||||||
|
* - [x] config_auth_strategies_add
|
||||||
|
* - [x] config_auth_strategies_update
|
||||||
|
* - [x] config_auth_strategies_remove
|
||||||
|
* - [x] config_auth_roles_get
|
||||||
|
* - [x] config_auth_roles_add
|
||||||
|
* - [x] config_auth_roles_update
|
||||||
|
* - [x] config_auth_roles_remove
|
||||||
|
*/
|
||||||
|
describe("mcp auth", async () => {
|
||||||
|
let app: App;
|
||||||
|
let server: McpServer;
|
||||||
|
beforeEach(async () => {
|
||||||
|
app = createApp({
|
||||||
|
initialConfig: {
|
||||||
|
auth: {
|
||||||
|
enabled: true,
|
||||||
|
jwt: {
|
||||||
|
secret: "secret",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
mcp: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
server = app.mcp!;
|
||||||
|
server.setLogLevel("error");
|
||||||
|
server.onNotification((message) => {
|
||||||
|
console.dir(message, { depth: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const tool = createMcpToolCaller();
|
||||||
|
|
||||||
|
test("auth_*", async () => {
|
||||||
|
const me = await tool(server, "auth_me", {});
|
||||||
|
expect(me.user).toBeNull();
|
||||||
|
|
||||||
|
// strategies
|
||||||
|
const strategies = await tool(server, "auth_strategies", {});
|
||||||
|
expect(Object.keys(strategies.strategies).length).toEqual(1);
|
||||||
|
expect(strategies.strategies.password.enabled).toBe(true);
|
||||||
|
|
||||||
|
// create user
|
||||||
|
const user = await tool(
|
||||||
|
server,
|
||||||
|
"auth_user_create",
|
||||||
|
{
|
||||||
|
email: "test@test.com",
|
||||||
|
password: "12345678",
|
||||||
|
},
|
||||||
|
new Headers(),
|
||||||
|
);
|
||||||
|
expect(user.email).toBe("test@test.com");
|
||||||
|
|
||||||
|
// create token
|
||||||
|
const token = await tool(
|
||||||
|
server,
|
||||||
|
"auth_user_token",
|
||||||
|
{
|
||||||
|
email: "test@test.com",
|
||||||
|
},
|
||||||
|
new Headers(),
|
||||||
|
);
|
||||||
|
expect(token.token).toBeDefined();
|
||||||
|
expect(token.user.email).toBe("test@test.com");
|
||||||
|
|
||||||
|
// me
|
||||||
|
const me2 = await tool(
|
||||||
|
server,
|
||||||
|
"auth_me",
|
||||||
|
{},
|
||||||
|
new Request("http://localhost", {
|
||||||
|
headers: new Headers({
|
||||||
|
Authorization: `Bearer ${token.token}`,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(me2.user.email).toBe("test@test.com");
|
||||||
|
|
||||||
|
// change password
|
||||||
|
const changePassword = await tool(
|
||||||
|
server,
|
||||||
|
"auth_user_password_change",
|
||||||
|
{
|
||||||
|
email: "test@test.com",
|
||||||
|
password: "87654321",
|
||||||
|
},
|
||||||
|
new Headers(),
|
||||||
|
);
|
||||||
|
expect(changePassword.changed).toBe(true);
|
||||||
|
|
||||||
|
// test password
|
||||||
|
const testPassword = await tool(
|
||||||
|
server,
|
||||||
|
"auth_user_password_test",
|
||||||
|
{
|
||||||
|
email: "test@test.com",
|
||||||
|
password: "87654321",
|
||||||
|
},
|
||||||
|
new Headers(),
|
||||||
|
);
|
||||||
|
expect(testPassword.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("config_auth_{get,update}", async () => {
|
||||||
|
expect(await tool(server, "config_auth_get", {})).toEqual({
|
||||||
|
path: "",
|
||||||
|
secrets: false,
|
||||||
|
partial: false,
|
||||||
|
value: app.toJSON().auth,
|
||||||
|
});
|
||||||
|
|
||||||
|
// update
|
||||||
|
await tool(server, "config_auth_update", {
|
||||||
|
value: {
|
||||||
|
allow_register: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(app.toJSON().auth.allow_register).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("config_auth_strategies_{get,add,update,remove}", async () => {
|
||||||
|
const strategies = await tool(server, "config_auth_strategies_get", {
|
||||||
|
key: "password",
|
||||||
|
});
|
||||||
|
expect(strategies).toEqual({
|
||||||
|
secrets: false,
|
||||||
|
module: "auth",
|
||||||
|
key: "password",
|
||||||
|
value: {
|
||||||
|
enabled: true,
|
||||||
|
type: "password",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// add google oauth
|
||||||
|
const addGoogleOauth = await tool(server, "config_auth_strategies_add", {
|
||||||
|
key: "google",
|
||||||
|
value: {
|
||||||
|
type: "oauth",
|
||||||
|
enabled: true,
|
||||||
|
config: {
|
||||||
|
name: "google",
|
||||||
|
type: "oidc",
|
||||||
|
client: {
|
||||||
|
client_id: "client_id",
|
||||||
|
client_secret: "client_secret",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
return_config: true,
|
||||||
|
});
|
||||||
|
expect(addGoogleOauth.config.google.enabled).toBe(true);
|
||||||
|
expect(app.toJSON().auth.strategies.google?.enabled).toBe(true);
|
||||||
|
|
||||||
|
// update (disable) google oauth
|
||||||
|
await tool(server, "config_auth_strategies_update", {
|
||||||
|
key: "google",
|
||||||
|
value: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(app.toJSON().auth.strategies.google?.enabled).toBe(false);
|
||||||
|
|
||||||
|
// remove google oauth
|
||||||
|
await tool(server, "config_auth_strategies_remove", {
|
||||||
|
key: "google",
|
||||||
|
});
|
||||||
|
expect(app.toJSON().auth.strategies.google).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("config_auth_roles_{get,add,update,remove}", async () => {
|
||||||
|
// add role
|
||||||
|
const addGuestRole = await tool(server, "config_auth_roles_add", {
|
||||||
|
key: "guest",
|
||||||
|
value: {
|
||||||
|
permissions: ["read", "write"],
|
||||||
|
},
|
||||||
|
return_config: true,
|
||||||
|
});
|
||||||
|
expect(addGuestRole.config.guest.permissions).toEqual(["read", "write"]);
|
||||||
|
|
||||||
|
// update role
|
||||||
|
await tool(server, "config_auth_roles_update", {
|
||||||
|
key: "guest",
|
||||||
|
value: {
|
||||||
|
permissions: ["read"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(app.toJSON().auth.roles?.guest?.permissions).toEqual(["read"]);
|
||||||
|
|
||||||
|
// get role
|
||||||
|
const getGuestRole = await tool(server, "config_auth_roles_get", {
|
||||||
|
key: "guest",
|
||||||
|
});
|
||||||
|
expect(getGuestRole.value.permissions).toEqual(["read"]);
|
||||||
|
|
||||||
|
// remove role
|
||||||
|
await tool(server, "config_auth_roles_remove", {
|
||||||
|
key: "guest",
|
||||||
|
});
|
||||||
|
expect(app.toJSON().auth.roles?.guest).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
35
app/__test__/app/mcp/mcp.base.test.ts
Normal file
35
app/__test__/app/mcp/mcp.base.test.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, it, expect } from "bun:test";
|
||||||
|
import { createApp } from "core/test/utils";
|
||||||
|
import { registries } from "index";
|
||||||
|
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
|
||||||
|
|
||||||
|
describe("mcp", () => {
|
||||||
|
it("should have tools", async () => {
|
||||||
|
registries.media.register("local", StorageLocalAdapter);
|
||||||
|
|
||||||
|
const app = createApp({
|
||||||
|
initialConfig: {
|
||||||
|
auth: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
media: {
|
||||||
|
enabled: true,
|
||||||
|
adapter: {
|
||||||
|
type: "local",
|
||||||
|
config: {
|
||||||
|
path: "./",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
mcp: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
expect(app.mcp?.tools.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
346
app/__test__/app/mcp/mcp.data.test.ts
Normal file
346
app/__test__/app/mcp/mcp.data.test.ts
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
import { describe, test, expect, beforeEach, beforeAll, afterAll } from "bun:test";
|
||||||
|
import { type App, createApp, createMcpToolCaller } from "core/test/utils";
|
||||||
|
import { getSystemMcp } from "modules/mcp/system-mcp";
|
||||||
|
import { pickKeys, type McpServer } from "bknd/utils";
|
||||||
|
import { entity, text } from "bknd";
|
||||||
|
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
||||||
|
|
||||||
|
beforeAll(disableConsoleLog);
|
||||||
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - [ ] data_sync
|
||||||
|
* - [x] data_entity_fn_count
|
||||||
|
* - [x] data_entity_fn_exists
|
||||||
|
* - [x] data_entity_read_one
|
||||||
|
* - [x] data_entity_read_many
|
||||||
|
* - [x] data_entity_insert
|
||||||
|
* - [x] data_entity_update_many
|
||||||
|
* - [x] data_entity_update_one
|
||||||
|
* - [x] data_entity_delete_one
|
||||||
|
* - [x] data_entity_delete_many
|
||||||
|
* - [x] data_entity_info
|
||||||
|
* - [ ] config_data_get
|
||||||
|
* - [ ] config_data_update
|
||||||
|
* - [x] config_data_entities_get
|
||||||
|
* - [x] config_data_entities_add
|
||||||
|
* - [x] config_data_entities_update
|
||||||
|
* - [x] config_data_entities_remove
|
||||||
|
* - [x] config_data_relations_add
|
||||||
|
* - [x] config_data_relations_get
|
||||||
|
* - [x] config_data_relations_update
|
||||||
|
* - [x] config_data_relations_remove
|
||||||
|
* - [x] config_data_indices_get
|
||||||
|
* - [x] config_data_indices_add
|
||||||
|
* - [x] config_data_indices_update
|
||||||
|
* - [x] config_data_indices_remove
|
||||||
|
*/
|
||||||
|
describe("mcp data", async () => {
|
||||||
|
let app: App;
|
||||||
|
let server: McpServer;
|
||||||
|
beforeEach(async () => {
|
||||||
|
const time = performance.now();
|
||||||
|
app = createApp({
|
||||||
|
initialConfig: {
|
||||||
|
server: {
|
||||||
|
mcp: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
server = app.mcp!;
|
||||||
|
server.setLogLevel("error");
|
||||||
|
server.onNotification((message) => {
|
||||||
|
console.dir(message, { depth: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const tool = createMcpToolCaller();
|
||||||
|
|
||||||
|
test("config_data_entities_{add,get,update,remove}", async () => {
|
||||||
|
const result = await tool(server, "config_data_entities_add", {
|
||||||
|
key: "test",
|
||||||
|
return_config: true,
|
||||||
|
value: {},
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.module).toBe("data");
|
||||||
|
expect(result.config.test?.type).toEqual("regular");
|
||||||
|
|
||||||
|
const entities = Object.keys(app.toJSON().data.entities ?? {});
|
||||||
|
expect(entities).toContain("test");
|
||||||
|
|
||||||
|
{
|
||||||
|
// get
|
||||||
|
const result = await tool(server, "config_data_entities_get", {
|
||||||
|
key: "test",
|
||||||
|
});
|
||||||
|
expect(result.module).toBe("data");
|
||||||
|
expect(result.key).toBe("test");
|
||||||
|
expect(result.value.type).toEqual("regular");
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
// update
|
||||||
|
const result = await tool(server, "config_data_entities_update", {
|
||||||
|
key: "test",
|
||||||
|
return_config: true,
|
||||||
|
value: {
|
||||||
|
config: {
|
||||||
|
name: "Test",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.module).toBe("data");
|
||||||
|
expect(result.config.test.config?.name).toEqual("Test");
|
||||||
|
expect(app.toJSON().data.entities?.test?.config?.name).toEqual("Test");
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
// remove
|
||||||
|
const result = await tool(server, "config_data_entities_remove", {
|
||||||
|
key: "test",
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.module).toBe("data");
|
||||||
|
expect(app.toJSON().data.entities?.test).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("config_data_relations_{add,get,update,remove}", async () => {
|
||||||
|
// create posts and comments
|
||||||
|
await tool(server, "config_data_entities_add", {
|
||||||
|
key: "posts",
|
||||||
|
value: {},
|
||||||
|
});
|
||||||
|
await tool(server, "config_data_entities_add", {
|
||||||
|
key: "comments",
|
||||||
|
value: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(Object.keys(app.toJSON().data.entities ?? {})).toEqual(["posts", "comments"]);
|
||||||
|
|
||||||
|
// create relation
|
||||||
|
await tool(server, "config_data_relations_add", {
|
||||||
|
key: "", // doesn't matter
|
||||||
|
value: {
|
||||||
|
type: "n:1",
|
||||||
|
source: "comments",
|
||||||
|
target: "posts",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = app.toJSON().data;
|
||||||
|
expect(
|
||||||
|
pickKeys((config.relations?.n1_comments_posts as any) ?? {}, ["type", "source", "target"]),
|
||||||
|
).toEqual({
|
||||||
|
type: "n:1",
|
||||||
|
source: "comments",
|
||||||
|
target: "posts",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(config.entities?.comments?.fields?.posts_id?.type).toBe("relation");
|
||||||
|
|
||||||
|
{
|
||||||
|
// info
|
||||||
|
const postsInfo = await tool(server, "data_entity_info", {
|
||||||
|
entity: "posts",
|
||||||
|
});
|
||||||
|
expect(postsInfo.fields).toEqual(["id"]);
|
||||||
|
expect(postsInfo.relations.all.length).toBe(1);
|
||||||
|
|
||||||
|
const commentsInfo = await tool(server, "data_entity_info", {
|
||||||
|
entity: "comments",
|
||||||
|
});
|
||||||
|
expect(commentsInfo.fields).toEqual(["id", "posts_id"]);
|
||||||
|
expect(commentsInfo.relations.all.length).toBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// update
|
||||||
|
await tool(server, "config_data_relations_update", {
|
||||||
|
key: "n1_comments_posts",
|
||||||
|
value: {
|
||||||
|
config: {
|
||||||
|
with_limit: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect((app.toJSON().data.relations?.n1_comments_posts?.config as any)?.with_limit).toBe(10);
|
||||||
|
|
||||||
|
// delete
|
||||||
|
await tool(server, "config_data_relations_remove", {
|
||||||
|
key: "n1_comments_posts",
|
||||||
|
});
|
||||||
|
expect(app.toJSON().data.relations?.n1_comments_posts).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("config_data_indices_update", async () => {
|
||||||
|
expect(server.tools.map((t) => t.name).includes("config_data_indices_update")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("config_data_indices_{add,get,remove}", async () => {
|
||||||
|
// create posts and comments
|
||||||
|
await tool(server, "config_data_entities_add", {
|
||||||
|
key: "posts",
|
||||||
|
value: entity("posts", {
|
||||||
|
title: text(),
|
||||||
|
content: text(),
|
||||||
|
}).toJSON(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// add index on title
|
||||||
|
await tool(server, "config_data_indices_add", {
|
||||||
|
key: "", // auto generated
|
||||||
|
value: {
|
||||||
|
entity: "posts",
|
||||||
|
fields: ["title"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(app.toJSON().data.indices?.idx_posts_title).toEqual({
|
||||||
|
entity: "posts",
|
||||||
|
fields: ["title"],
|
||||||
|
unique: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// delete
|
||||||
|
await tool(server, "config_data_indices_remove", {
|
||||||
|
key: "idx_posts_title",
|
||||||
|
});
|
||||||
|
expect(app.toJSON().data.indices?.idx_posts_title).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("data_entity_*", async () => {
|
||||||
|
// create posts and comments
|
||||||
|
await tool(server, "config_data_entities_add", {
|
||||||
|
key: "posts",
|
||||||
|
value: entity("posts", {
|
||||||
|
title: text(),
|
||||||
|
content: text(),
|
||||||
|
}).toJSON(),
|
||||||
|
});
|
||||||
|
await tool(server, "config_data_entities_add", {
|
||||||
|
key: "comments",
|
||||||
|
value: entity("comments", {
|
||||||
|
content: text(),
|
||||||
|
}).toJSON(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// insert a few posts
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
await tool(server, "data_entity_insert", {
|
||||||
|
entity: "posts",
|
||||||
|
json: {
|
||||||
|
title: `Post ${i}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// insert a few comments
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await tool(server, "data_entity_insert", {
|
||||||
|
entity: "comments",
|
||||||
|
json: {
|
||||||
|
content: `Comment ${i}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await tool(server, "data_entity_read_many", {
|
||||||
|
entity: "posts",
|
||||||
|
limit: 5,
|
||||||
|
});
|
||||||
|
expect(result.data.length).toBe(5);
|
||||||
|
expect(result.meta.items).toBe(5);
|
||||||
|
expect(result.meta.total).toBe(10);
|
||||||
|
expect(result.data[0].title).toBe("Post 0");
|
||||||
|
|
||||||
|
{
|
||||||
|
// count
|
||||||
|
const result = await tool(server, "data_entity_fn_count", {
|
||||||
|
entity: "posts",
|
||||||
|
});
|
||||||
|
expect(result.count).toBe(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
// exists
|
||||||
|
const res = await tool(server, "data_entity_fn_exists", {
|
||||||
|
entity: "posts",
|
||||||
|
json: {
|
||||||
|
id: result.data[0].id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.exists).toBe(true);
|
||||||
|
|
||||||
|
const res2 = await tool(server, "data_entity_fn_exists", {
|
||||||
|
entity: "posts",
|
||||||
|
json: {
|
||||||
|
id: "123",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res2.exists).toBe(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// update
|
||||||
|
await tool(server, "data_entity_update_one", {
|
||||||
|
entity: "posts",
|
||||||
|
id: result.data[0].id,
|
||||||
|
json: {
|
||||||
|
title: "Post 0 updated",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result2 = await tool(server, "data_entity_read_one", {
|
||||||
|
entity: "posts",
|
||||||
|
id: result.data[0].id,
|
||||||
|
});
|
||||||
|
expect(result2.data.title).toBe("Post 0 updated");
|
||||||
|
|
||||||
|
// delete the second post
|
||||||
|
await tool(server, "data_entity_delete_one", {
|
||||||
|
entity: "posts",
|
||||||
|
id: result.data[1].id,
|
||||||
|
});
|
||||||
|
const result3 = await tool(server, "data_entity_read_many", {
|
||||||
|
entity: "posts",
|
||||||
|
limit: 2,
|
||||||
|
});
|
||||||
|
expect(result3.data.map((p) => p.id)).toEqual([1, 3]);
|
||||||
|
|
||||||
|
// update many
|
||||||
|
await tool(server, "data_entity_update_many", {
|
||||||
|
entity: "posts",
|
||||||
|
update: {
|
||||||
|
title: "Post updated",
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
title: { $isnull: 0 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result4 = await tool(server, "data_entity_read_many", {
|
||||||
|
entity: "posts",
|
||||||
|
limit: 10,
|
||||||
|
});
|
||||||
|
expect(result4.data.length).toBe(9);
|
||||||
|
expect(result4.data.map((p) => p.title)).toEqual(
|
||||||
|
Array.from({ length: 9 }, () => "Post updated"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// delete many
|
||||||
|
await tool(server, "data_entity_delete_many", {
|
||||||
|
entity: "posts",
|
||||||
|
json: {
|
||||||
|
title: { $isnull: 0 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result5 = await tool(server, "data_entity_read_many", {
|
||||||
|
entity: "posts",
|
||||||
|
limit: 10,
|
||||||
|
});
|
||||||
|
expect(result5.data.length).toBe(0);
|
||||||
|
expect(result5.meta.items).toBe(0);
|
||||||
|
expect(result5.meta.total).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
118
app/__test__/app/mcp/mcp.media.test.ts
Normal file
118
app/__test__/app/mcp/mcp.media.test.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { describe, test, expect, beforeAll, beforeEach, afterAll } from "bun:test";
|
||||||
|
import { type App, createApp, createMcpToolCaller } from "core/test/utils";
|
||||||
|
import { getSystemMcp } from "modules/mcp/system-mcp";
|
||||||
|
import { registries } from "index";
|
||||||
|
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
|
||||||
|
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
||||||
|
import type { McpServer } from "bknd/utils";
|
||||||
|
|
||||||
|
beforeAll(disableConsoleLog);
|
||||||
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - [x] config_media_get
|
||||||
|
* - [x] config_media_update
|
||||||
|
* - [x] config_media_adapter_get
|
||||||
|
* - [x] config_media_adapter_update
|
||||||
|
*/
|
||||||
|
describe("mcp media", async () => {
|
||||||
|
let app: App;
|
||||||
|
let server: McpServer;
|
||||||
|
beforeEach(async () => {
|
||||||
|
registries.media.register("local", StorageLocalAdapter);
|
||||||
|
app = createApp({
|
||||||
|
initialConfig: {
|
||||||
|
media: {
|
||||||
|
enabled: true,
|
||||||
|
adapter: {
|
||||||
|
type: "local",
|
||||||
|
config: {
|
||||||
|
path: "./",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
mcp: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
server = app.mcp!;
|
||||||
|
server.setLogLevel("error");
|
||||||
|
server.onNotification((message) => {
|
||||||
|
console.dir(message, { depth: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const tool = createMcpToolCaller();
|
||||||
|
|
||||||
|
test("config_media_{get,update}", async () => {
|
||||||
|
const result = await tool(server, "config_media_get", {});
|
||||||
|
expect(result).toEqual({
|
||||||
|
path: "",
|
||||||
|
secrets: false,
|
||||||
|
partial: false,
|
||||||
|
value: app.toJSON().media,
|
||||||
|
});
|
||||||
|
|
||||||
|
// partial
|
||||||
|
expect((await tool(server, "config_media_get", { path: "adapter" })).value).toEqual({
|
||||||
|
type: "local",
|
||||||
|
config: {
|
||||||
|
path: "./",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// update
|
||||||
|
await tool(server, "config_media_update", {
|
||||||
|
value: {
|
||||||
|
storage: {
|
||||||
|
body_max_size: 1024 * 1024 * 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
return_config: true,
|
||||||
|
});
|
||||||
|
expect(app.toJSON().media.storage.body_max_size).toBe(1024 * 1024 * 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("config_media_adapter_{get,update}", async () => {
|
||||||
|
const result = await tool(server, "config_media_adapter_get", {});
|
||||||
|
expect(result).toEqual({
|
||||||
|
secrets: false,
|
||||||
|
value: app.toJSON().media.adapter,
|
||||||
|
});
|
||||||
|
|
||||||
|
// update
|
||||||
|
await tool(server, "config_media_adapter_update", {
|
||||||
|
value: {
|
||||||
|
type: "local",
|
||||||
|
config: {
|
||||||
|
path: "./subdir",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const adapter = app.toJSON().media.adapter as any;
|
||||||
|
expect(adapter.config.path).toBe("./subdir");
|
||||||
|
expect(adapter.type).toBe("local");
|
||||||
|
|
||||||
|
// set to s3
|
||||||
|
{
|
||||||
|
await tool(server, "config_media_adapter_update", {
|
||||||
|
value: {
|
||||||
|
type: "s3",
|
||||||
|
config: {
|
||||||
|
access_key: "123",
|
||||||
|
secret_access_key: "456",
|
||||||
|
url: "https://example.com/what",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const adapter = app.toJSON(true).media.adapter as any;
|
||||||
|
expect(adapter.type).toBe("s3");
|
||||||
|
expect(adapter.config.url).toBe("https://example.com/what");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
72
app/__test__/app/mcp/mcp.server.test.ts
Normal file
72
app/__test__/app/mcp/mcp.server.test.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, test, expect, beforeAll, mock, beforeEach, afterAll } from "bun:test";
|
||||||
|
import { type App, createApp, createMcpToolCaller } from "core/test/utils";
|
||||||
|
import type { McpServer } from "bknd/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - [x] config_server_get
|
||||||
|
* - [x] config_server_update
|
||||||
|
*/
|
||||||
|
describe("mcp system", async () => {
|
||||||
|
let app: App;
|
||||||
|
let server: McpServer;
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = createApp({
|
||||||
|
initialConfig: {
|
||||||
|
server: {
|
||||||
|
mcp: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
server = app.mcp!;
|
||||||
|
});
|
||||||
|
|
||||||
|
const tool = createMcpToolCaller();
|
||||||
|
|
||||||
|
test("config_server_get", async () => {
|
||||||
|
const result = await tool(server, "config_server_get", {});
|
||||||
|
expect(JSON.parse(JSON.stringify(result))).toEqual({
|
||||||
|
path: "",
|
||||||
|
secrets: false,
|
||||||
|
partial: false,
|
||||||
|
value: JSON.parse(JSON.stringify(app.toJSON().server)),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("config_server_get2", async () => {
|
||||||
|
const result = await tool(server, "config_server_get", {});
|
||||||
|
expect(JSON.parse(JSON.stringify(result))).toEqual({
|
||||||
|
path: "",
|
||||||
|
secrets: false,
|
||||||
|
partial: false,
|
||||||
|
value: JSON.parse(JSON.stringify(app.toJSON().server)),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("config_server_update", async () => {
|
||||||
|
const original = JSON.parse(JSON.stringify(app.toJSON().server));
|
||||||
|
const result = await tool(server, "config_server_update", {
|
||||||
|
value: {
|
||||||
|
cors: {
|
||||||
|
origin: "http://localhost",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
return_config: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(JSON.parse(JSON.stringify(result))).toEqual({
|
||||||
|
success: true,
|
||||||
|
module: "server",
|
||||||
|
config: {
|
||||||
|
...original,
|
||||||
|
cors: {
|
||||||
|
...original.cors,
|
||||||
|
origin: "http://localhost",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(app.toJSON().server.cors.origin).toBe("http://localhost");
|
||||||
|
});
|
||||||
|
});
|
||||||
56
app/__test__/app/mcp/mcp.system.test.ts
Normal file
56
app/__test__/app/mcp/mcp.system.test.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { AppEvents } from "App";
|
||||||
|
import { describe, test, expect, beforeAll, mock } from "bun:test";
|
||||||
|
import { type App, createApp, createMcpToolCaller } from "core/test/utils";
|
||||||
|
import type { McpServer } from "bknd/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - [x] system_config
|
||||||
|
* - [x] system_build
|
||||||
|
* - [x] system_ping
|
||||||
|
* - [x] system_info
|
||||||
|
*/
|
||||||
|
describe("mcp system", async () => {
|
||||||
|
let app: App;
|
||||||
|
let server: McpServer;
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = createApp({
|
||||||
|
initialConfig: {
|
||||||
|
server: {
|
||||||
|
mcp: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
server = app.mcp!;
|
||||||
|
});
|
||||||
|
|
||||||
|
const tool = createMcpToolCaller();
|
||||||
|
|
||||||
|
test("system_ping", async () => {
|
||||||
|
const result = await tool(server, "system_ping", {});
|
||||||
|
expect(result).toEqual({ pong: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("system_info", async () => {
|
||||||
|
const result = await tool(server, "system_info", {});
|
||||||
|
expect(Object.keys(result).length).toBeGreaterThan(0);
|
||||||
|
expect(Object.keys(result)).toContainValues(["version", "runtime", "connection"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("system_build", async () => {
|
||||||
|
const called = mock(() => null);
|
||||||
|
|
||||||
|
app.emgr.onEvent(AppEvents.AppBuiltEvent, () => void called(), { once: true });
|
||||||
|
|
||||||
|
const result = await tool(server, "system_build", {});
|
||||||
|
expect(called).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("system_config", async () => {
|
||||||
|
const result = await tool(server, "system_config", {});
|
||||||
|
expect(result).toEqual(app.toJSON());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -47,8 +47,4 @@ describe("[data] Entity", async () => {
|
|||||||
entity.addField(field);
|
entity.addField(field);
|
||||||
expect(entity.getField("new_field")).toBe(field);
|
expect(entity.getField("new_field")).toBe(field);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.only("types", async () => {
|
|
||||||
console.log(entity.toTypes());
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
24
app/__test__/debug/jsonv-resolution.test.ts
Normal file
24
app/__test__/debug/jsonv-resolution.test.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, it, expect } from "bun:test";
|
||||||
|
import * as sDirect from "jsonv-ts";
|
||||||
|
import { s as sFromBknd } from "bknd/utils";
|
||||||
|
|
||||||
|
describe("jsonv-ts resolution", () => {
|
||||||
|
it("should resolve to a single instance", () => {
|
||||||
|
const sameNamespace = sDirect === (sFromBknd as unknown as typeof sDirect);
|
||||||
|
// If this fails, two instances are being loaded via different specifiers/paths
|
||||||
|
expect(sameNamespace).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should resolve specifiers to a single package path", async () => {
|
||||||
|
const base = await import.meta.resolve("jsonv-ts");
|
||||||
|
const hono = await import.meta.resolve("jsonv-ts/hono");
|
||||||
|
const mcp = await import.meta.resolve("jsonv-ts/mcp");
|
||||||
|
expect(typeof base).toBe("string");
|
||||||
|
expect(typeof hono).toBe("string");
|
||||||
|
expect(typeof mcp).toBe("string");
|
||||||
|
// They can be different files (subpath exports), but they should share the same package root
|
||||||
|
const pkgRoot = (p: string) => p.slice(0, p.lastIndexOf("jsonv-ts") + "jsonv-ts".length);
|
||||||
|
expect(pkgRoot(base)).toBe(pkgRoot(hono));
|
||||||
|
expect(pkgRoot(base)).toBe(pkgRoot(mcp));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,7 +5,7 @@ import { format as sqlFormat } from "sql-formatter";
|
|||||||
import type { em as protoEm } from "../src/data/prototype";
|
import type { em as protoEm } from "../src/data/prototype";
|
||||||
import { writeFile } from "node:fs/promises";
|
import { writeFile } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { slugify } from "core/utils/strings";
|
import { slugify } from "bknd/utils";
|
||||||
import { type Connection, SqliteLocalConnection } from "data/connection";
|
import { type Connection, SqliteLocalConnection } from "data/connection";
|
||||||
import { EntityManager } from "data/entities/EntityManager";
|
import { EntityManager } from "data/entities/EntityManager";
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import { beforeEach, describe, expect, it } from "bun:test";
|
|||||||
|
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { Guard } from "auth/authorize/Guard";
|
import { Guard } from "auth/authorize/Guard";
|
||||||
import { DebugLogger } from "core/utils/DebugLogger";
|
|
||||||
import { EventManager } from "core/events";
|
import { EventManager } from "core/events";
|
||||||
import { EntityManager } from "data/entities/EntityManager";
|
import { EntityManager } from "data/entities/EntityManager";
|
||||||
import { Module, type ModuleBuildContext } from "modules/Module";
|
import { Module, type ModuleBuildContext } from "modules/Module";
|
||||||
import { getDummyConnection } from "../helper";
|
import { getDummyConnection } from "../helper";
|
||||||
import { ModuleHelper } from "modules/ModuleHelper";
|
import { ModuleHelper } from "modules/ModuleHelper";
|
||||||
|
import { DebugLogger, McpServer } from "bknd/utils";
|
||||||
|
|
||||||
export function makeCtx(overrides?: Partial<ModuleBuildContext>): ModuleBuildContext {
|
export function makeCtx(overrides?: Partial<ModuleBuildContext>): ModuleBuildContext {
|
||||||
const { dummyConnection } = getDummyConnection();
|
const { dummyConnection } = getDummyConnection();
|
||||||
@@ -19,6 +19,7 @@ export function makeCtx(overrides?: Partial<ModuleBuildContext>): ModuleBuildCon
|
|||||||
guard: new Guard(),
|
guard: new Guard(),
|
||||||
flags: Module.ctx_flags,
|
flags: Module.ctx_flags,
|
||||||
logger: new DebugLogger(false),
|
logger: new DebugLogger(false),
|
||||||
|
mcp: new McpServer(),
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -102,7 +102,9 @@ describe("json form", () => {
|
|||||||
] satisfies [string, Exclude<JSONSchema, boolean>, boolean][];
|
] satisfies [string, Exclude<JSONSchema, boolean>, boolean][];
|
||||||
|
|
||||||
for (const [pointer, schema, output] of examples) {
|
for (const [pointer, schema, output] of examples) {
|
||||||
expect(utils.isRequired(new Draft2019(schema), pointer, schema)).toBe(output);
|
expect(utils.isRequired(new Draft2019(schema), pointer, schema), `${pointer} `).toBe(
|
||||||
|
output,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,24 @@
|
|||||||
import pkg from "./package.json" with { type: "json" };
|
import pkg from "./package.json" with { type: "json" };
|
||||||
import c from "picocolors";
|
import c from "picocolors";
|
||||||
import { formatNumber } from "core/utils";
|
import { formatNumber } from "bknd/utils";
|
||||||
|
import * as esbuild from "esbuild";
|
||||||
|
|
||||||
|
if (process.env.DEBUG) {
|
||||||
|
await esbuild.build({
|
||||||
|
entryPoints: ["./src/cli/index.ts"],
|
||||||
|
outdir: "./dist/cli",
|
||||||
|
platform: "node",
|
||||||
|
minify: false,
|
||||||
|
format: "esm",
|
||||||
|
bundle: true,
|
||||||
|
external: ["jsonv-ts", "jsonv-ts/*"],
|
||||||
|
define: {
|
||||||
|
__isDev: "0",
|
||||||
|
__version: JSON.stringify(pkg.version),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
const result = await Bun.build({
|
const result = await Bun.build({
|
||||||
entrypoints: ["./src/cli/index.ts"],
|
entrypoints: ["./src/cli/index.ts"],
|
||||||
@@ -8,6 +26,7 @@ const result = await Bun.build({
|
|||||||
outdir: "./dist/cli",
|
outdir: "./dist/cli",
|
||||||
env: "PUBLIC_*",
|
env: "PUBLIC_*",
|
||||||
minify: true,
|
minify: true,
|
||||||
|
external: ["jsonv-ts", "jsonv-ts/*"],
|
||||||
define: {
|
define: {
|
||||||
__isDev: "0",
|
__isDev: "0",
|
||||||
__version: JSON.stringify(pkg.version),
|
__version: JSON.stringify(pkg.version),
|
||||||
|
|||||||
16
app/build.ts
16
app/build.ts
@@ -69,6 +69,8 @@ const external = [
|
|||||||
"@libsql/client",
|
"@libsql/client",
|
||||||
"bknd",
|
"bknd",
|
||||||
/^bknd\/.*/,
|
/^bknd\/.*/,
|
||||||
|
"jsonv-ts",
|
||||||
|
/^jsonv-ts\/.*/,
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -256,7 +258,19 @@ async function buildAdapters() {
|
|||||||
),
|
),
|
||||||
tsup.build(baseConfig("astro")),
|
tsup.build(baseConfig("astro")),
|
||||||
tsup.build(baseConfig("aws")),
|
tsup.build(baseConfig("aws")),
|
||||||
tsup.build(baseConfig("cloudflare")),
|
tsup.build(
|
||||||
|
baseConfig("cloudflare", {
|
||||||
|
external: ["wrangler", "node:process"],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
tsup.build(
|
||||||
|
baseConfig("cloudflare/proxy", {
|
||||||
|
entry: ["src/adapter/cloudflare/proxy.ts"],
|
||||||
|
outDir: "dist/adapter/cloudflare",
|
||||||
|
metafile: false,
|
||||||
|
external: [/bknd/, "wrangler", "node:process"],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
|
||||||
tsup.build({
|
tsup.build({
|
||||||
...baseConfig("vite"),
|
...baseConfig("vite"),
|
||||||
|
|||||||
@@ -2,4 +2,5 @@
|
|||||||
#registry = "http://localhost:4873"
|
#registry = "http://localhost:4873"
|
||||||
|
|
||||||
[test]
|
[test]
|
||||||
coverageSkipTestFiles = true
|
coverageSkipTestFiles = true
|
||||||
|
console.depth = 10
|
||||||
35
app/internal/docs.build-assets.ts
Normal file
35
app/internal/docs.build-assets.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { createApp } from "bknd/adapter/bun";
|
||||||
|
|
||||||
|
async function generate() {
|
||||||
|
console.info("Generating MCP documentation...");
|
||||||
|
const app = await createApp({
|
||||||
|
initialConfig: {
|
||||||
|
server: {
|
||||||
|
mcp: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
media: {
|
||||||
|
enabled: true,
|
||||||
|
adapter: {
|
||||||
|
type: "local",
|
||||||
|
config: {
|
||||||
|
path: "./",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const res = await app.server.request("/mcp?explain=1");
|
||||||
|
const { tools, resources } = await res.json();
|
||||||
|
await Bun.write("../docs/mcp.json", JSON.stringify({ tools, resources }, null, 2));
|
||||||
|
|
||||||
|
console.info("MCP documentation generated.");
|
||||||
|
}
|
||||||
|
|
||||||
|
void generate();
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"bin": "./dist/cli/index.js",
|
"bin": "./dist/cli/index.js",
|
||||||
"version": "0.16.1",
|
"version": "0.17.0-rc.2",
|
||||||
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
|
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
|
||||||
"homepage": "https://bknd.io",
|
"homepage": "https://bknd.io",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -39,11 +39,12 @@
|
|||||||
"test:adapters": "bun test src/adapter/**/*.adapter.spec.ts --bail",
|
"test:adapters": "bun test src/adapter/**/*.adapter.spec.ts --bail",
|
||||||
"test:coverage": "ALL_TESTS=1 bun test --bail --coverage",
|
"test:coverage": "ALL_TESTS=1 bun test --bail --coverage",
|
||||||
"test:vitest:coverage": "vitest run --coverage",
|
"test:vitest:coverage": "vitest run --coverage",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "VITE_DB_URL=:memory: playwright test",
|
||||||
"test:e2e:adapters": "bun run e2e/adapters.ts",
|
"test:e2e:adapters": "VITE_DB_URL=:memory: bun run e2e/adapters.ts",
|
||||||
"test:e2e:ui": "playwright test --ui",
|
"test:e2e:ui": "VITE_DB_URL=:memory: playwright test --ui",
|
||||||
"test:e2e:debug": "playwright test --debug",
|
"test:e2e:debug": "VITE_DB_URL=:memory: playwright test --debug",
|
||||||
"test:e2e:report": "playwright show-report"
|
"test:e2e:report": "VITE_DB_URL=:memory: playwright show-report",
|
||||||
|
"docs:build-assets": "bun internal/docs.build-assets.ts"
|
||||||
},
|
},
|
||||||
"license": "FSL-1.1-MIT",
|
"license": "FSL-1.1-MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -64,7 +65,7 @@
|
|||||||
"hono": "4.8.3",
|
"hono": "4.8.3",
|
||||||
"json-schema-library": "10.0.0-rc7",
|
"json-schema-library": "10.0.0-rc7",
|
||||||
"json-schema-to-ts": "^3.1.1",
|
"json-schema-to-ts": "^3.1.1",
|
||||||
"jsonv-ts": "0.3.2",
|
"jsonv-ts": "0.8.2",
|
||||||
"kysely": "0.27.6",
|
"kysely": "0.27.6",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"oauth4webapi": "^2.11.1",
|
"oauth4webapi": "^2.11.1",
|
||||||
@@ -196,6 +197,11 @@
|
|||||||
"import": "./dist/adapter/cloudflare/index.js",
|
"import": "./dist/adapter/cloudflare/index.js",
|
||||||
"require": "./dist/adapter/cloudflare/index.js"
|
"require": "./dist/adapter/cloudflare/index.js"
|
||||||
},
|
},
|
||||||
|
"./adapter/cloudflare/proxy": {
|
||||||
|
"types": "./dist/types/adapter/cloudflare/proxy.d.ts",
|
||||||
|
"import": "./dist/adapter/cloudflare/proxy.js",
|
||||||
|
"require": "./dist/adapter/cloudflare/proxy.js"
|
||||||
|
},
|
||||||
"./adapter": {
|
"./adapter": {
|
||||||
"types": "./dist/types/adapter/index.d.ts",
|
"types": "./dist/types/adapter/index.d.ts",
|
||||||
"import": "./dist/adapter/index.js"
|
"import": "./dist/adapter/index.js"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { DataApi, type DataApiOptions } from "data/api/DataApi";
|
|||||||
import { decode } from "hono/jwt";
|
import { decode } from "hono/jwt";
|
||||||
import { MediaApi, type MediaApiOptions } from "media/api/MediaApi";
|
import { MediaApi, type MediaApiOptions } from "media/api/MediaApi";
|
||||||
import { SystemApi } from "modules/SystemApi";
|
import { SystemApi } from "modules/SystemApi";
|
||||||
import { omitKeys } from "core/utils";
|
import { omitKeys } from "bknd/utils";
|
||||||
import type { BaseModuleApiOptions } from "modules";
|
import type { BaseModuleApiOptions } from "modules";
|
||||||
|
|
||||||
export type TApiUser = SafeUser;
|
export type TApiUser = SafeUser;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { CreateUserPayload } from "auth/AppAuth";
|
import type { CreateUserPayload } from "auth/AppAuth";
|
||||||
import { $console } from "core/utils";
|
import { $console, McpClient } from "bknd/utils";
|
||||||
import { Event } from "core/events";
|
import { Event } from "core/events";
|
||||||
import type { em as prototypeEm } from "data/prototype";
|
import type { em as prototypeEm } from "data/prototype";
|
||||||
import { Connection } from "data/connection/Connection";
|
import { Connection } from "data/connection/Connection";
|
||||||
@@ -23,13 +23,34 @@ import type { IEmailDriver, ICacheDriver } from "core/drivers";
|
|||||||
import { Api, type ApiOptions } from "Api";
|
import { Api, type ApiOptions } from "Api";
|
||||||
|
|
||||||
export type AppPluginConfig = {
|
export type AppPluginConfig = {
|
||||||
|
/**
|
||||||
|
* The name of the plugin.
|
||||||
|
*/
|
||||||
name: string;
|
name: string;
|
||||||
|
/**
|
||||||
|
* The schema of the plugin.
|
||||||
|
*/
|
||||||
schema?: () => MaybePromise<ReturnType<typeof prototypeEm> | void>;
|
schema?: () => MaybePromise<ReturnType<typeof prototypeEm> | void>;
|
||||||
|
/**
|
||||||
|
* Called before the app is built.
|
||||||
|
*/
|
||||||
beforeBuild?: () => MaybePromise<void>;
|
beforeBuild?: () => MaybePromise<void>;
|
||||||
|
/**
|
||||||
|
* Called after the app is built.
|
||||||
|
*/
|
||||||
onBuilt?: () => MaybePromise<void>;
|
onBuilt?: () => MaybePromise<void>;
|
||||||
|
/**
|
||||||
|
* Called when the server is initialized.
|
||||||
|
*/
|
||||||
onServerInit?: (server: Hono<ServerEnv>) => MaybePromise<void>;
|
onServerInit?: (server: Hono<ServerEnv>) => MaybePromise<void>;
|
||||||
onFirstBoot?: () => MaybePromise<void>;
|
/**
|
||||||
|
* Called when the app is booted.
|
||||||
|
*/
|
||||||
onBoot?: () => MaybePromise<void>;
|
onBoot?: () => MaybePromise<void>;
|
||||||
|
/**
|
||||||
|
* Called when the app is first booted.
|
||||||
|
*/
|
||||||
|
onFirstBoot?: () => MaybePromise<void>;
|
||||||
};
|
};
|
||||||
export type AppPlugin = (app: App) => AppPluginConfig;
|
export type AppPlugin = (app: App) => AppPluginConfig;
|
||||||
|
|
||||||
@@ -96,6 +117,7 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
|
|||||||
|
|
||||||
private trigger_first_boot = false;
|
private trigger_first_boot = false;
|
||||||
private _building: boolean = false;
|
private _building: boolean = false;
|
||||||
|
private _systemController: SystemController | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public connection: C,
|
public connection: C,
|
||||||
@@ -168,11 +190,12 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
|
|||||||
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({ fetch: options?.fetch });
|
||||||
|
|
||||||
const { guard, server } = this.modules.ctx();
|
const { guard } = this.modules.ctx();
|
||||||
|
|
||||||
// load system controller
|
// load system controller
|
||||||
guard.registerPermissions(Object.values(SystemPermissions));
|
guard.registerPermissions(Object.values(SystemPermissions));
|
||||||
server.route("/api/system", new SystemController(this).getController());
|
this._systemController = new SystemController(this);
|
||||||
|
this._systemController.register(this);
|
||||||
|
|
||||||
// emit built event
|
// emit built event
|
||||||
$console.log("App built");
|
$console.log("App built");
|
||||||
@@ -204,6 +227,10 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
|
|||||||
return this.modules.ctx().em;
|
return this.modules.ctx().em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get mcp() {
|
||||||
|
return this._systemController?._mcpServer;
|
||||||
|
}
|
||||||
|
|
||||||
get fetch(): Hono["fetch"] {
|
get fetch(): Hono["fetch"] {
|
||||||
return this.server.fetch as any;
|
return this.server.fetch as any;
|
||||||
}
|
}
|
||||||
@@ -262,6 +289,18 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
|
|||||||
return new Api({ host: "http://localhost", ...(options ?? {}), fetcher });
|
return new Api({ host: "http://localhost", ...(options ?? {}), fetcher });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getMcpClient() {
|
||||||
|
if (!this.mcp) {
|
||||||
|
throw new Error("MCP is not enabled");
|
||||||
|
}
|
||||||
|
const mcpPath = this.modules.get("server").config.mcp.path;
|
||||||
|
|
||||||
|
return new McpClient({
|
||||||
|
url: "http://localhost" + mcpPath,
|
||||||
|
fetch: this.server.request,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async onUpdated<Module extends keyof Modules>(module: Module, config: ModuleConfigs[Module]) {
|
async onUpdated<Module extends keyof Modules>(module: Module, config: ModuleConfigs[Module]) {
|
||||||
// if the EventManager was disabled, we assume we shouldn't
|
// if the EventManager was disabled, we assume we shouldn't
|
||||||
// respond to events, such as "onUpdated".
|
// respond to events, such as "onUpdated".
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ type BunEnv = Bun.Env;
|
|||||||
export type BunBkndConfig<Env = BunEnv> = RuntimeBkndConfig<Env> & Omit<ServeOptions, "fetch">;
|
export type BunBkndConfig<Env = BunEnv> = RuntimeBkndConfig<Env> & Omit<ServeOptions, "fetch">;
|
||||||
|
|
||||||
export async function createApp<Env = BunEnv>(
|
export async function createApp<Env = BunEnv>(
|
||||||
{ distPath, ...config }: BunBkndConfig<Env> = {},
|
{ distPath, serveStatic: _serveStatic, ...config }: BunBkndConfig<Env> = {},
|
||||||
args: Env = {} as Env,
|
args: Env = {} as Env,
|
||||||
opts?: RuntimeOptions,
|
opts?: RuntimeOptions,
|
||||||
) {
|
) {
|
||||||
@@ -20,7 +20,11 @@ export async function createApp<Env = BunEnv>(
|
|||||||
|
|
||||||
return await createRuntimeApp(
|
return await createRuntimeApp(
|
||||||
{
|
{
|
||||||
serveStatic: serveStatic({ root }),
|
serveStatic:
|
||||||
|
_serveStatic ??
|
||||||
|
serveStatic({
|
||||||
|
root,
|
||||||
|
}),
|
||||||
...config,
|
...config,
|
||||||
},
|
},
|
||||||
args ?? (process.env as Env),
|
args ?? (process.env as Env),
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { inspect } from "node:util";
|
||||||
|
|
||||||
export type BindingTypeMap = {
|
export type BindingTypeMap = {
|
||||||
D1Database: D1Database;
|
D1Database: D1Database;
|
||||||
KVNamespace: KVNamespace;
|
KVNamespace: KVNamespace;
|
||||||
@@ -13,8 +15,9 @@ export function getBindings<T extends GetBindingType>(env: any, type: T): Bindin
|
|||||||
for (const key in env) {
|
for (const key in env) {
|
||||||
try {
|
try {
|
||||||
if (
|
if (
|
||||||
env[key] &&
|
(env[key] as any).constructor.name === type ||
|
||||||
((env[key] as any).constructor.name === type || String(env[key]) === `[object ${type}]`)
|
String(env[key]) === `[object ${type}]` ||
|
||||||
|
inspect(env[key]).includes(type)
|
||||||
) {
|
) {
|
||||||
bindings.push({
|
bindings.push({
|
||||||
key,
|
key,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ describe("cf adapter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("makes config", async () => {
|
it("makes config", async () => {
|
||||||
const staticConfig = makeConfig(
|
const staticConfig = await makeConfig(
|
||||||
{
|
{
|
||||||
connection: { url: DB_URL },
|
connection: { url: DB_URL },
|
||||||
initialConfig: { data: { basepath: DB_URL } },
|
initialConfig: { data: { basepath: DB_URL } },
|
||||||
@@ -28,7 +28,7 @@ describe("cf adapter", () => {
|
|||||||
expect(staticConfig.initialConfig).toEqual({ data: { basepath: DB_URL } });
|
expect(staticConfig.initialConfig).toEqual({ data: { basepath: DB_URL } });
|
||||||
expect(staticConfig.connection).toBeDefined();
|
expect(staticConfig.connection).toBeDefined();
|
||||||
|
|
||||||
const dynamicConfig = makeConfig(
|
const dynamicConfig = await makeConfig(
|
||||||
{
|
{
|
||||||
app: (env) => ({
|
app: (env) => ({
|
||||||
initialConfig: { data: { basepath: env.DB_URL } },
|
initialConfig: { data: { basepath: env.DB_URL } },
|
||||||
|
|||||||
@@ -5,9 +5,8 @@ import { Hono } from "hono";
|
|||||||
import { serveStatic } from "hono/cloudflare-workers";
|
import { serveStatic } from "hono/cloudflare-workers";
|
||||||
import { getFresh } from "./modes/fresh";
|
import { getFresh } from "./modes/fresh";
|
||||||
import { getCached } from "./modes/cached";
|
import { getCached } from "./modes/cached";
|
||||||
import { getDurable } from "./modes/durable";
|
import type { App, MaybePromise } from "bknd";
|
||||||
import type { App } from "bknd";
|
import { $console } from "bknd/utils";
|
||||||
import { $console } from "core/utils";
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
namespace Cloudflare {
|
namespace Cloudflare {
|
||||||
@@ -17,12 +16,11 @@ declare global {
|
|||||||
|
|
||||||
export type CloudflareEnv = Cloudflare.Env;
|
export type CloudflareEnv = Cloudflare.Env;
|
||||||
export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> & {
|
export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> & {
|
||||||
mode?: "warm" | "fresh" | "cache" | "durable";
|
mode?: "warm" | "fresh" | "cache";
|
||||||
bindings?: (args: Env) => {
|
bindings?: (args: Env) => MaybePromise<{
|
||||||
kv?: KVNamespace;
|
kv?: KVNamespace;
|
||||||
dobj?: DurableObjectNamespace;
|
|
||||||
db?: D1Database;
|
db?: D1Database;
|
||||||
};
|
}>;
|
||||||
d1?: {
|
d1?: {
|
||||||
session?: boolean;
|
session?: boolean;
|
||||||
transport?: "header" | "cookie";
|
transport?: "header" | "cookie";
|
||||||
@@ -93,8 +91,6 @@ export function serve<Env extends CloudflareEnv = CloudflareEnv>(
|
|||||||
case "cache":
|
case "cache":
|
||||||
app = await getCached(config, context);
|
app = await getCached(config, context);
|
||||||
break;
|
break;
|
||||||
case "durable":
|
|
||||||
return await getDurable(config, context);
|
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown mode ${mode}`);
|
throw new Error(`Unknown mode ${mode}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { d1Sqlite } from "./connection/D1Connection";
|
|||||||
import type { CloudflareBkndConfig, CloudflareEnv } from ".";
|
import type { CloudflareBkndConfig, CloudflareEnv } from ".";
|
||||||
import { App } from "bknd";
|
import { App } from "bknd";
|
||||||
import type { Context, ExecutionContext } from "hono";
|
import type { Context, ExecutionContext } from "hono";
|
||||||
import { $console } from "core/utils";
|
import { $console } from "bknd/utils";
|
||||||
import { setCookie } from "hono/cookie";
|
import { setCookie } from "hono/cookie";
|
||||||
|
|
||||||
export const constants = {
|
export const constants = {
|
||||||
@@ -89,7 +89,7 @@ export function d1SessionHelper(config: CloudflareBkndConfig<any>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let media_registered: boolean = false;
|
let media_registered: boolean = false;
|
||||||
export function makeConfig<Env extends CloudflareEnv = CloudflareEnv>(
|
export async function makeConfig<Env extends CloudflareEnv = CloudflareEnv>(
|
||||||
config: CloudflareBkndConfig<Env>,
|
config: CloudflareBkndConfig<Env>,
|
||||||
args?: CfMakeConfigArgs<Env>,
|
args?: CfMakeConfigArgs<Env>,
|
||||||
) {
|
) {
|
||||||
@@ -102,7 +102,7 @@ export function makeConfig<Env extends CloudflareEnv = CloudflareEnv>(
|
|||||||
media_registered = true;
|
media_registered = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const appConfig = makeAdapterConfig(config, args?.env);
|
const appConfig = await makeAdapterConfig(config, args?.env);
|
||||||
|
|
||||||
// if connection instance is given, don't do anything
|
// if connection instance is given, don't do anything
|
||||||
// other than checking if D1 session is defined
|
// other than checking if D1 session is defined
|
||||||
@@ -115,12 +115,12 @@ export function makeConfig<Env extends CloudflareEnv = CloudflareEnv>(
|
|||||||
}
|
}
|
||||||
// if connection is given, try to open with unified sqlite adapter
|
// if connection is given, try to open with unified sqlite adapter
|
||||||
} else if (appConfig.connection) {
|
} else if (appConfig.connection) {
|
||||||
appConfig.connection = sqlite(appConfig.connection);
|
appConfig.connection = sqlite(appConfig.connection) as any;
|
||||||
|
|
||||||
// if connection is not given, but env is set
|
// if connection is not given, but env is set
|
||||||
// try to make D1 from bindings
|
// try to make D1 from bindings
|
||||||
} else if (args?.env) {
|
} else if (args?.env) {
|
||||||
const bindings = config.bindings?.(args?.env);
|
const bindings = await config.bindings?.(args?.env);
|
||||||
const sessionHelper = d1SessionHelper(config);
|
const sessionHelper = d1SessionHelper(config);
|
||||||
const sessionId = sessionHelper.get(args.request);
|
const sessionId = sessionHelper.get(args.request);
|
||||||
let session: D1DatabaseSession | undefined;
|
let session: D1DatabaseSession | undefined;
|
||||||
|
|||||||
@@ -3,16 +3,16 @@
|
|||||||
import { genericSqlite, type GenericSqliteConnection } from "bknd";
|
import { genericSqlite, type GenericSqliteConnection } from "bknd";
|
||||||
import type { QueryResult } from "kysely";
|
import type { QueryResult } from "kysely";
|
||||||
|
|
||||||
export type D1SqliteConnection = GenericSqliteConnection<D1Database>;
|
export type DoSqliteConnection = GenericSqliteConnection<DurableObjectState["storage"]["sql"]>;
|
||||||
export type DurableObjecSql = DurableObjectState["storage"]["sql"];
|
export type DurableObjecSql = DurableObjectState["storage"]["sql"];
|
||||||
|
|
||||||
export type D1ConnectionConfig<DB extends DurableObjecSql> =
|
export type DoConnectionConfig<DB extends DurableObjecSql> =
|
||||||
| DurableObjectState
|
| DurableObjectState
|
||||||
| {
|
| {
|
||||||
sql: DB;
|
sql: DB;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function doSqlite<DB extends DurableObjecSql>(config: D1ConnectionConfig<DB>) {
|
export function doSqlite<DB extends DurableObjecSql>(config: DoConnectionConfig<DB>) {
|
||||||
const db = "sql" in config ? config.sql : config.storage.sql;
|
const db = "sql" in config ? config.sql : config.storage.sql;
|
||||||
|
|
||||||
return genericSqlite(
|
return genericSqlite(
|
||||||
@@ -21,7 +21,7 @@ export function doSqlite<DB extends DurableObjecSql>(config: D1ConnectionConfig<
|
|||||||
(utils) => {
|
(utils) => {
|
||||||
// must be async to work with the miniflare mock
|
// must be async to work with the miniflare mock
|
||||||
const getStmt = async (sql: string, parameters?: any[] | readonly any[]) =>
|
const getStmt = async (sql: string, parameters?: any[] | readonly any[]) =>
|
||||||
await db.exec(sql, ...(parameters || []));
|
db.exec(sql, ...(parameters || []));
|
||||||
|
|
||||||
const mapResult = (
|
const mapResult = (
|
||||||
cursor: SqlStorageCursor<Record<string, SqlStorageValue>>,
|
cursor: SqlStorageCursor<Record<string, SqlStorageValue>>,
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { d1Sqlite, type D1ConnectionConfig } from "./connection/D1Connection";
|
|||||||
export * from "./cloudflare-workers.adapter";
|
export * from "./cloudflare-workers.adapter";
|
||||||
export { makeApp, getFresh } from "./modes/fresh";
|
export { makeApp, getFresh } from "./modes/fresh";
|
||||||
export { getCached } from "./modes/cached";
|
export { getCached } from "./modes/cached";
|
||||||
export { DurableBkndApp, getDurable } from "./modes/durable";
|
|
||||||
export { d1Sqlite, type D1ConnectionConfig };
|
export { d1Sqlite, type D1ConnectionConfig };
|
||||||
|
export { doSqlite, type DoConnectionConfig } from "./connection/DoConnection";
|
||||||
export {
|
export {
|
||||||
getBinding,
|
getBinding,
|
||||||
getBindings,
|
getBindings,
|
||||||
@@ -15,6 +15,7 @@ export {
|
|||||||
export { constants } from "./config";
|
export { constants } from "./config";
|
||||||
export { StorageR2Adapter, registerMedia } from "./storage/StorageR2Adapter";
|
export { StorageR2Adapter, registerMedia } from "./storage/StorageR2Adapter";
|
||||||
export { registries } from "bknd";
|
export { registries } from "bknd";
|
||||||
|
export { devFsVitePlugin, devFsWrite } from "./vite";
|
||||||
|
|
||||||
// for compatibility with old code
|
// for compatibility with old code
|
||||||
export function d1<DB extends D1Database | D1DatabaseSession = D1Database>(
|
export function d1<DB extends D1Database | D1DatabaseSession = D1Database>(
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export async function getCached<Env extends CloudflareEnv = CloudflareEnv>(
|
|||||||
args: Context<Env>,
|
args: Context<Env>,
|
||||||
) {
|
) {
|
||||||
const { env, ctx } = args;
|
const { env, ctx } = args;
|
||||||
const { kv } = config.bindings?.(env)!;
|
const { kv } = await config.bindings?.(env)!;
|
||||||
if (!kv) throw new Error("kv namespace is not defined in cloudflare.bindings");
|
if (!kv) throw new Error("kv namespace is not defined in cloudflare.bindings");
|
||||||
const key = config.key ?? "app";
|
const key = config.key ?? "app";
|
||||||
|
|
||||||
|
|||||||
@@ -1,134 +0,0 @@
|
|||||||
import { DurableObject } from "cloudflare:workers";
|
|
||||||
import type { App, CreateAppConfig } from "bknd";
|
|
||||||
import { createRuntimeApp, makeConfig } from "bknd/adapter";
|
|
||||||
import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index";
|
|
||||||
import { constants, registerAsyncsExecutionContext } from "../config";
|
|
||||||
import { $console } from "core/utils";
|
|
||||||
|
|
||||||
export async function getDurable<Env extends CloudflareEnv = CloudflareEnv>(
|
|
||||||
config: CloudflareBkndConfig<Env>,
|
|
||||||
ctx: Context<Env>,
|
|
||||||
) {
|
|
||||||
const { dobj } = config.bindings?.(ctx.env)!;
|
|
||||||
if (!dobj) throw new Error("durable object is not defined in cloudflare.bindings");
|
|
||||||
const key = config.key ?? "app";
|
|
||||||
|
|
||||||
if ([config.onBuilt, config.beforeBuild].some((x) => x)) {
|
|
||||||
$console.warn("onBuilt and beforeBuild are not supported with DurableObject mode");
|
|
||||||
}
|
|
||||||
|
|
||||||
const start = performance.now();
|
|
||||||
|
|
||||||
const id = dobj.idFromName(key);
|
|
||||||
const stub = dobj.get(id) as unknown as DurableBkndApp;
|
|
||||||
|
|
||||||
const create_config = makeConfig(config, ctx.env);
|
|
||||||
|
|
||||||
const res = await stub.fire(ctx.request, {
|
|
||||||
config: create_config,
|
|
||||||
keepAliveSeconds: config.keepAliveSeconds,
|
|
||||||
});
|
|
||||||
|
|
||||||
const headers = new Headers(res.headers);
|
|
||||||
headers.set("X-TTDO", String(performance.now() - start));
|
|
||||||
|
|
||||||
return new Response(res.body, {
|
|
||||||
status: res.status,
|
|
||||||
statusText: res.statusText,
|
|
||||||
headers,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export class DurableBkndApp extends DurableObject {
|
|
||||||
protected id = Math.random().toString(36).slice(2);
|
|
||||||
protected app?: App;
|
|
||||||
protected interval?: any;
|
|
||||||
|
|
||||||
async fire(
|
|
||||||
request: Request,
|
|
||||||
options: {
|
|
||||||
config: CreateAppConfig;
|
|
||||||
html?: string;
|
|
||||||
keepAliveSeconds?: number;
|
|
||||||
setAdminHtml?: boolean;
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
let buildtime = 0;
|
|
||||||
if (!this.app) {
|
|
||||||
const start = performance.now();
|
|
||||||
const config = options.config;
|
|
||||||
|
|
||||||
// change protocol to websocket if libsql
|
|
||||||
if (
|
|
||||||
config?.connection &&
|
|
||||||
"type" in config.connection &&
|
|
||||||
config.connection.type === "libsql"
|
|
||||||
) {
|
|
||||||
//config.connection.config.protocol = "wss";
|
|
||||||
}
|
|
||||||
|
|
||||||
this.app = await createRuntimeApp({
|
|
||||||
...config,
|
|
||||||
onBuilt: async (app) => {
|
|
||||||
registerAsyncsExecutionContext(app, this.ctx);
|
|
||||||
app.modules.server.get(constants.do_endpoint, async (c) => {
|
|
||||||
// @ts-ignore
|
|
||||||
const context: any = c.req.raw.cf ? c.req.raw.cf : c.env.cf;
|
|
||||||
return c.json({
|
|
||||||
id: this.id,
|
|
||||||
keepAliveSeconds: options?.keepAliveSeconds ?? 0,
|
|
||||||
colo: context.colo,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.onBuilt(app);
|
|
||||||
},
|
|
||||||
adminOptions: { html: options.html },
|
|
||||||
beforeBuild: async (app) => {
|
|
||||||
await this.beforeBuild(app);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
buildtime = performance.now() - start;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options?.keepAliveSeconds) {
|
|
||||||
this.keepAlive(options.keepAliveSeconds);
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await this.app!.fetch(request);
|
|
||||||
const headers = new Headers(res.headers);
|
|
||||||
headers.set("X-BuildTime", buildtime.toString());
|
|
||||||
headers.set("X-DO-ID", this.id);
|
|
||||||
|
|
||||||
return new Response(res.body, {
|
|
||||||
status: res.status,
|
|
||||||
statusText: res.statusText,
|
|
||||||
headers,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async onBuilt(app: App) {}
|
|
||||||
|
|
||||||
async beforeBuild(app: App) {}
|
|
||||||
|
|
||||||
protected keepAlive(seconds: number) {
|
|
||||||
if (this.interval) {
|
|
||||||
clearInterval(this.interval);
|
|
||||||
}
|
|
||||||
|
|
||||||
let i = 0;
|
|
||||||
this.interval = setInterval(() => {
|
|
||||||
i += 1;
|
|
||||||
if (i === seconds) {
|
|
||||||
console.log("cleared");
|
|
||||||
clearInterval(this.interval);
|
|
||||||
|
|
||||||
// ping every 30 seconds
|
|
||||||
} else if (i % 30 === 0) {
|
|
||||||
console.log("ping");
|
|
||||||
this.app?.modules.ctx().connection.ping();
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,7 @@ export async function makeApp<Env extends CloudflareEnv = CloudflareEnv>(
|
|||||||
args?: CfMakeConfigArgs<Env>,
|
args?: CfMakeConfigArgs<Env>,
|
||||||
opts?: RuntimeOptions,
|
opts?: RuntimeOptions,
|
||||||
) {
|
) {
|
||||||
return await createRuntimeApp<Env>(makeConfig(config, args), args?.env, opts);
|
return await createRuntimeApp<Env>(await makeConfig(config, args), args?.env, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getFresh<Env extends CloudflareEnv = CloudflareEnv>(
|
export async function getFresh<Env extends CloudflareEnv = CloudflareEnv>(
|
||||||
|
|||||||
67
app/src/adapter/cloudflare/proxy.ts
Normal file
67
app/src/adapter/cloudflare/proxy.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import {
|
||||||
|
d1Sqlite,
|
||||||
|
getBinding,
|
||||||
|
registerMedia,
|
||||||
|
type CloudflareBkndConfig,
|
||||||
|
type CloudflareEnv,
|
||||||
|
} from "bknd/adapter/cloudflare";
|
||||||
|
import type { PlatformProxy } from "wrangler";
|
||||||
|
import process from "node:process";
|
||||||
|
|
||||||
|
export type WithPlatformProxyOptions = {
|
||||||
|
/**
|
||||||
|
* By default, proxy is used if the PROXY environment variable is set to 1.
|
||||||
|
* You can override/force this by setting this option.
|
||||||
|
*/
|
||||||
|
useProxy?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function withPlatformProxy<Env extends CloudflareEnv>(
|
||||||
|
config?: CloudflareBkndConfig<Env>,
|
||||||
|
opts?: WithPlatformProxyOptions,
|
||||||
|
) {
|
||||||
|
const use_proxy =
|
||||||
|
typeof opts?.useProxy === "boolean" ? opts.useProxy : process.env.PROXY === "1";
|
||||||
|
let proxy: PlatformProxy | undefined;
|
||||||
|
|
||||||
|
async function getEnv(env?: Env): Promise<Env> {
|
||||||
|
if (use_proxy) {
|
||||||
|
if (!proxy) {
|
||||||
|
const getPlatformProxy = await import("wrangler").then((mod) => mod.getPlatformProxy);
|
||||||
|
proxy = await getPlatformProxy();
|
||||||
|
setTimeout(proxy?.dispose, 1000);
|
||||||
|
}
|
||||||
|
return proxy.env as unknown as Env;
|
||||||
|
}
|
||||||
|
return env || ({} as Env);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
beforeBuild: async (app, registries) => {
|
||||||
|
if (!use_proxy) return;
|
||||||
|
const env = await getEnv();
|
||||||
|
registerMedia(env, registries as any);
|
||||||
|
await config?.beforeBuild?.(app, registries);
|
||||||
|
},
|
||||||
|
bindings: async (env) => {
|
||||||
|
return (await config?.bindings?.(await getEnv(env))) || {};
|
||||||
|
},
|
||||||
|
// @ts-ignore
|
||||||
|
app: async (_env) => {
|
||||||
|
const env = await getEnv(_env);
|
||||||
|
|
||||||
|
if (config?.app === undefined && use_proxy) {
|
||||||
|
const binding = getBinding(env, "D1Database");
|
||||||
|
return {
|
||||||
|
connection: d1Sqlite({
|
||||||
|
binding: binding.value,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
} else if (typeof config?.app === "function") {
|
||||||
|
return config?.app(env);
|
||||||
|
}
|
||||||
|
return config?.app || {};
|
||||||
|
},
|
||||||
|
} satisfies CloudflareBkndConfig<Env>;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { registries, isDebug, guessMimeType } from "bknd";
|
import { registries as $registries, isDebug, guessMimeType } from "bknd";
|
||||||
import { getBindings } from "../bindings";
|
import { getBindings } from "../bindings";
|
||||||
import { s } from "bknd/utils";
|
import { s } from "bknd/utils";
|
||||||
import { StorageAdapter, type FileBody } from "bknd";
|
import { StorageAdapter, type FileBody } from "bknd";
|
||||||
@@ -12,7 +12,10 @@ export function makeSchema(bindings: string[] = []) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerMedia(env: Record<string, any>) {
|
export function registerMedia(
|
||||||
|
env: Record<string, any>,
|
||||||
|
registries: typeof $registries = $registries,
|
||||||
|
) {
|
||||||
const r2_bindings = getBindings(env, "R2Bucket");
|
const r2_bindings = getBindings(env, "R2Bucket");
|
||||||
|
|
||||||
registries.media.register(
|
registries.media.register(
|
||||||
|
|||||||
135
app/src/adapter/cloudflare/vite.ts
Normal file
135
app/src/adapter/cloudflare/vite.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import type { Plugin } from "vite";
|
||||||
|
import { writeFile as nodeWriteFile } from "node:fs/promises";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vite plugin that provides Node.js filesystem access during development
|
||||||
|
* by injecting a polyfill into the SSR environment
|
||||||
|
*/
|
||||||
|
export function devFsVitePlugin({
|
||||||
|
verbose = false,
|
||||||
|
configFile = "bknd.config.ts",
|
||||||
|
}: {
|
||||||
|
verbose?: boolean;
|
||||||
|
configFile?: string;
|
||||||
|
}): Plugin {
|
||||||
|
let isDev = false;
|
||||||
|
let projectRoot = "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: "dev-fs-plugin",
|
||||||
|
enforce: "pre",
|
||||||
|
configResolved(config) {
|
||||||
|
isDev = config.command === "serve";
|
||||||
|
projectRoot = config.root;
|
||||||
|
},
|
||||||
|
configureServer(server) {
|
||||||
|
if (!isDev) return;
|
||||||
|
|
||||||
|
// Intercept stdout to watch for our write requests
|
||||||
|
const originalStdoutWrite = process.stdout.write;
|
||||||
|
process.stdout.write = function (chunk: any, encoding?: any, callback?: any) {
|
||||||
|
const output = chunk.toString();
|
||||||
|
|
||||||
|
// Check if this output contains our special write request
|
||||||
|
if (output.includes("{{DEV_FS_WRITE_REQUEST}}")) {
|
||||||
|
try {
|
||||||
|
// Extract the JSON from the log line
|
||||||
|
const match = output.match(/{{DEV_FS_WRITE_REQUEST}} ({.*})/);
|
||||||
|
if (match) {
|
||||||
|
const writeRequest = JSON.parse(match[1]);
|
||||||
|
if (writeRequest.type === "DEV_FS_WRITE_REQUEST") {
|
||||||
|
if (verbose) {
|
||||||
|
console.debug("[dev-fs-plugin] Intercepted write request via stdout");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the write request immediately
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const fullPath = resolve(projectRoot, writeRequest.filename);
|
||||||
|
await nodeWriteFile(fullPath, writeRequest.data);
|
||||||
|
if (verbose) {
|
||||||
|
console.debug("[dev-fs-plugin] File written successfully!");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[dev-fs-plugin] Error writing file:", error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Don't output the raw write request to console
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Not a valid write request, continue with normal output
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
// biome-ignore lint:
|
||||||
|
return originalStdoutWrite.apply(process.stdout, arguments);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Restore stdout when server closes
|
||||||
|
server.httpServer?.on("close", () => {
|
||||||
|
process.stdout.write = originalStdoutWrite;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// @ts-ignore
|
||||||
|
transform(code, id, options) {
|
||||||
|
// Only transform in SSR mode during development
|
||||||
|
if (!isDev || !options?.ssr) return;
|
||||||
|
|
||||||
|
// Check if this is the bknd config file
|
||||||
|
if (id.includes(configFile)) {
|
||||||
|
if (verbose) {
|
||||||
|
console.debug("[dev-fs-plugin] Transforming", configFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject our filesystem polyfill at the top of the file
|
||||||
|
const polyfill = `
|
||||||
|
// Dev-fs polyfill injected by vite-plugin-dev-fs
|
||||||
|
if (typeof globalThis !== 'undefined') {
|
||||||
|
globalThis.__devFsPolyfill = {
|
||||||
|
writeFile: async (filename, data) => {
|
||||||
|
${verbose ? "console.debug('dev-fs polyfill: Intercepting write request for', filename);" : ""}
|
||||||
|
|
||||||
|
// Use console logging as a communication channel
|
||||||
|
// The main process will watch for this specific log pattern
|
||||||
|
const writeRequest = {
|
||||||
|
type: 'DEV_FS_WRITE_REQUEST',
|
||||||
|
filename: filename,
|
||||||
|
data: data,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Output as a specially formatted console message
|
||||||
|
console.log('{{DEV_FS_WRITE_REQUEST}}', JSON.stringify(writeRequest));
|
||||||
|
${verbose ? "console.debug('dev-fs polyfill: Write request sent via console');" : ""}
|
||||||
|
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
return polyfill + code;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write function that uses the dev-fs polyfill injected by our Vite plugin
|
||||||
|
export async function devFsWrite(filename: string, data: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Check if the dev-fs polyfill is available (injected by our Vite plugin)
|
||||||
|
if (typeof globalThis !== "undefined" && (globalThis as any).__devFsPolyfill) {
|
||||||
|
return (globalThis as any).__devFsPolyfill.writeFile(filename, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to Node.js fs for other environments (Node.js, Bun)
|
||||||
|
const { writeFile } = await import("node:fs/promises");
|
||||||
|
return writeFile(filename, data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[dev-fs-write] Error writing file:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,21 @@
|
|||||||
import { config as $config, App, type CreateAppConfig, Connection, guessMimeType } from "bknd";
|
import {
|
||||||
|
config as $config,
|
||||||
|
App,
|
||||||
|
type CreateAppConfig,
|
||||||
|
Connection,
|
||||||
|
guessMimeType,
|
||||||
|
type MaybePromise,
|
||||||
|
registries as $registries,
|
||||||
|
} from "bknd";
|
||||||
import { $console } from "bknd/utils";
|
import { $console } from "bknd/utils";
|
||||||
import type { Context, MiddlewareHandler, Next } from "hono";
|
import type { Context, MiddlewareHandler, Next } from "hono";
|
||||||
import type { AdminControllerOptions } from "modules/server/AdminController";
|
import type { AdminControllerOptions } from "modules/server/AdminController";
|
||||||
import type { Manifest } from "vite";
|
import type { Manifest } from "vite";
|
||||||
|
|
||||||
export type BkndConfig<Args = any> = CreateAppConfig & {
|
export type BkndConfig<Args = any> = CreateAppConfig & {
|
||||||
app?: CreateAppConfig | ((args: Args) => CreateAppConfig);
|
app?: CreateAppConfig | ((args: Args) => MaybePromise<CreateAppConfig>);
|
||||||
onBuilt?: (app: App) => Promise<void>;
|
onBuilt?: (app: App) => Promise<void>;
|
||||||
beforeBuild?: (app: App) => Promise<void>;
|
beforeBuild?: (app: App, registries?: typeof $registries) => Promise<void>;
|
||||||
buildConfig?: Parameters<App["build"]>[0];
|
buildConfig?: Parameters<App["build"]>[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -30,10 +38,10 @@ export type DefaultArgs = {
|
|||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function makeConfig<Args = DefaultArgs>(
|
export async function makeConfig<Args = DefaultArgs>(
|
||||||
config: BkndConfig<Args>,
|
config: BkndConfig<Args>,
|
||||||
args?: Args,
|
args?: Args,
|
||||||
): CreateAppConfig {
|
): Promise<CreateAppConfig> {
|
||||||
let additionalConfig: CreateAppConfig = {};
|
let additionalConfig: CreateAppConfig = {};
|
||||||
const { app, ...rest } = config;
|
const { app, ...rest } = config;
|
||||||
if (app) {
|
if (app) {
|
||||||
@@ -41,7 +49,7 @@ export function makeConfig<Args = DefaultArgs>(
|
|||||||
if (!args) {
|
if (!args) {
|
||||||
throw new Error("args is required when config.app is a function");
|
throw new Error("args is required when config.app is a function");
|
||||||
}
|
}
|
||||||
additionalConfig = app(args);
|
additionalConfig = await app(args);
|
||||||
} else {
|
} else {
|
||||||
additionalConfig = app;
|
additionalConfig = app;
|
||||||
}
|
}
|
||||||
@@ -60,7 +68,7 @@ export async function createAdapterApp<Config extends BkndConfig = BkndConfig, A
|
|||||||
const id = opts?.id ?? "app";
|
const id = opts?.id ?? "app";
|
||||||
let app = apps.get(id);
|
let app = apps.get(id);
|
||||||
if (!app || opts?.force) {
|
if (!app || opts?.force) {
|
||||||
const appConfig = 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;
|
||||||
if (Connection.isConnection(config.connection)) {
|
if (Connection.isConnection(config.connection)) {
|
||||||
@@ -68,7 +76,7 @@ export async function createAdapterApp<Config extends BkndConfig = BkndConfig, A
|
|||||||
} else {
|
} else {
|
||||||
const sqlite = (await import("bknd/adapter/sqlite")).sqlite;
|
const sqlite = (await import("bknd/adapter/sqlite")).sqlite;
|
||||||
const conf = appConfig.connection ?? { url: ":memory:" };
|
const conf = appConfig.connection ?? { url: ":memory:" };
|
||||||
connection = sqlite(conf);
|
connection = sqlite(conf) as any;
|
||||||
$console.info(`Using ${connection!.name} connection`, conf.url);
|
$console.info(`Using ${connection!.name} connection`, conf.url);
|
||||||
}
|
}
|
||||||
appConfig.connection = connection;
|
appConfig.connection = connection;
|
||||||
@@ -98,7 +106,7 @@ export async function createFrameworkApp<Args = DefaultArgs>(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await config.beforeBuild?.(app);
|
await config.beforeBuild?.(app, $registries);
|
||||||
await app.build(config.buildConfig);
|
await app.build(config.buildConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +139,7 @@ export async function createRuntimeApp<Args = DefaultArgs>(
|
|||||||
"sync",
|
"sync",
|
||||||
);
|
);
|
||||||
|
|
||||||
await config.beforeBuild?.(app);
|
await config.beforeBuild?.(app, $registries);
|
||||||
await app.build(config.buildConfig);
|
await app.build(config.buildConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { DB } from "bknd";
|
import type { DB, PrimaryFieldType } from "bknd";
|
||||||
import * as AuthPermissions from "auth/auth-permissions";
|
import * as AuthPermissions from "auth/auth-permissions";
|
||||||
import type { AuthStrategy } from "auth/authenticate/strategies/Strategy";
|
import type { AuthStrategy } from "auth/authenticate/strategies/Strategy";
|
||||||
import type { PasswordStrategy } from "auth/authenticate/strategies/PasswordStrategy";
|
import type { PasswordStrategy } from "auth/authenticate/strategies/PasswordStrategy";
|
||||||
import { $console, secureRandomString, transformObject } from "core/utils";
|
import { $console, secureRandomString, transformObject } from "bknd/utils";
|
||||||
import type { Entity, EntityManager } from "data/entities";
|
import type { Entity, EntityManager } from "data/entities";
|
||||||
import { em, entity, enumm, type FieldSchema } from "data/prototype";
|
import { em, entity, enumm, type FieldSchema } from "data/prototype";
|
||||||
import { Module } from "modules/Module";
|
import { Module } from "modules/Module";
|
||||||
@@ -14,6 +14,7 @@ import { usersFields } from "./auth-entities";
|
|||||||
import { Authenticator } from "./authenticate/Authenticator";
|
import { Authenticator } from "./authenticate/Authenticator";
|
||||||
import { Role } from "./authorize/Role";
|
import { Role } from "./authorize/Role";
|
||||||
|
|
||||||
|
export type UsersFields = typeof AppAuth.usersFields;
|
||||||
export type UserFieldSchema = FieldSchema<typeof AppAuth.usersFields>;
|
export type UserFieldSchema = FieldSchema<typeof AppAuth.usersFields>;
|
||||||
declare module "bknd" {
|
declare module "bknd" {
|
||||||
interface Users extends AppEntity, UserFieldSchema {}
|
interface Users extends AppEntity, UserFieldSchema {}
|
||||||
@@ -87,6 +88,7 @@ export class AppAuth extends Module<AppAuthSchema> {
|
|||||||
super.setBuilt();
|
super.setBuilt();
|
||||||
|
|
||||||
this._controller = new AuthController(this);
|
this._controller = new AuthController(this);
|
||||||
|
this._controller.registerMcp();
|
||||||
this.ctx.server.route(this.config.basepath, this._controller.getController());
|
this.ctx.server.route(this.config.basepath, this._controller.getController());
|
||||||
this.ctx.guard.registerPermissions(AuthPermissions);
|
this.ctx.guard.registerPermissions(AuthPermissions);
|
||||||
}
|
}
|
||||||
@@ -176,6 +178,32 @@ export class AppAuth extends Module<AppAuthSchema> {
|
|||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async changePassword(userId: PrimaryFieldType, newPassword: string) {
|
||||||
|
const users_entity = this.config.entity_name as "users";
|
||||||
|
const { data: user } = await this.em.repository(users_entity).findId(userId);
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("User not found");
|
||||||
|
} else if (user.strategy !== "password") {
|
||||||
|
throw new Error("User is not using password strategy");
|
||||||
|
}
|
||||||
|
|
||||||
|
const togglePw = (visible: boolean) => {
|
||||||
|
const field = this.em.entity(users_entity).field("strategy_value")!;
|
||||||
|
|
||||||
|
field.config.hidden = !visible;
|
||||||
|
field.config.fillable = visible;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pw = this.authenticator.strategy("password" as const) as PasswordStrategy;
|
||||||
|
togglePw(true);
|
||||||
|
await this.em.mutator(users_entity).updateOne(user.id, {
|
||||||
|
strategy_value: await pw.hash(newPassword),
|
||||||
|
});
|
||||||
|
togglePw(false);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
override toJSON(secrets?: boolean): AppAuthSchema {
|
override toJSON(secrets?: boolean): AppAuthSchema {
|
||||||
if (!this.config.enabled) {
|
if (!this.config.enabled) {
|
||||||
return this.configDefault;
|
return this.configDefault;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { AppAuth } from "auth/AppAuth";
|
import { AppAuth } from "auth/AppAuth";
|
||||||
import type { CreateUser, SafeUser, User, UserPool } from "auth/authenticate/Authenticator";
|
import type { CreateUser, SafeUser, User, UserPool } from "auth/authenticate/Authenticator";
|
||||||
import { $console } from "core/utils";
|
import { $console } from "bknd/utils";
|
||||||
import { pick } from "lodash-es";
|
import { pick } from "lodash-es";
|
||||||
import {
|
import {
|
||||||
InvalidConditionsException,
|
InvalidConditionsException,
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
import type { SafeUser } from "bknd";
|
import type { DB, SafeUser } from "bknd";
|
||||||
import type { AuthStrategy } from "auth/authenticate/strategies/Strategy";
|
import type { AuthStrategy } from "auth/authenticate/strategies/Strategy";
|
||||||
import type { AppAuth } from "auth/AppAuth";
|
import type { AppAuth } from "auth/AppAuth";
|
||||||
import * as AuthPermissions from "auth/auth-permissions";
|
import * as AuthPermissions from "auth/auth-permissions";
|
||||||
import * as DataPermissions from "data/permissions";
|
import * as DataPermissions from "data/permissions";
|
||||||
import type { Hono } from "hono";
|
import type { Hono } from "hono";
|
||||||
import { Controller, type ServerEnv } from "modules/Controller";
|
import { Controller, type ServerEnv } from "modules/Controller";
|
||||||
import { describeRoute, jsc, s, parse, InvalidSchemaError, transformObject } from "bknd/utils";
|
import {
|
||||||
|
describeRoute,
|
||||||
|
jsc,
|
||||||
|
s,
|
||||||
|
parse,
|
||||||
|
InvalidSchemaError,
|
||||||
|
transformObject,
|
||||||
|
mcpTool,
|
||||||
|
} from "bknd/utils";
|
||||||
|
import type { PasswordStrategy } from "auth/authenticate/strategies";
|
||||||
|
|
||||||
export type AuthActionResponse = {
|
export type AuthActionResponse = {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -118,6 +127,9 @@ export class AuthController extends Controller {
|
|||||||
summary: "Get the current user",
|
summary: "Get the current user",
|
||||||
tags: ["auth"],
|
tags: ["auth"],
|
||||||
}),
|
}),
|
||||||
|
mcpTool("auth_me", {
|
||||||
|
noErrorCodes: [403],
|
||||||
|
}),
|
||||||
auth(),
|
auth(),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const claims = c.get("auth")?.user;
|
const claims = c.get("auth")?.user;
|
||||||
@@ -159,6 +171,7 @@ export class AuthController extends Controller {
|
|||||||
summary: "Get the available authentication strategies",
|
summary: "Get the available authentication strategies",
|
||||||
tags: ["auth"],
|
tags: ["auth"],
|
||||||
}),
|
}),
|
||||||
|
mcpTool("auth_strategies"),
|
||||||
jsc("query", s.object({ include_disabled: s.boolean().optional() })),
|
jsc("query", s.object({ include_disabled: s.boolean().optional() })),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { include_disabled } = c.req.valid("query");
|
const { include_disabled } = c.req.valid("query");
|
||||||
@@ -188,4 +201,119 @@ export class AuthController extends Controller {
|
|||||||
|
|
||||||
return hono;
|
return hono;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override registerMcp(): void {
|
||||||
|
const { mcp } = this.auth.ctx;
|
||||||
|
const idType = s.anyOf([s.number({ title: "Integer" }), s.string({ title: "UUID" })]);
|
||||||
|
|
||||||
|
const getUser = async (params: { id?: string | number; email?: string }) => {
|
||||||
|
let user: DB["users"] | undefined = undefined;
|
||||||
|
if (params.id) {
|
||||||
|
const { data } = await this.userRepo.findId(params.id);
|
||||||
|
user = data;
|
||||||
|
} else if (params.email) {
|
||||||
|
const { data } = await this.userRepo.findOne({ email: params.email });
|
||||||
|
user = data;
|
||||||
|
}
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("User not found");
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
};
|
||||||
|
|
||||||
|
mcp.tool(
|
||||||
|
// @todo: needs permission
|
||||||
|
"auth_user_create",
|
||||||
|
{
|
||||||
|
description: "Create a new user",
|
||||||
|
inputSchema: s.object({
|
||||||
|
email: s.string({ format: "email" }),
|
||||||
|
password: s.string({ minLength: 8 }),
|
||||||
|
role: s
|
||||||
|
.string({
|
||||||
|
enum: Object.keys(this.auth.config.roles ?? {}),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async (params, c) => {
|
||||||
|
await c.context.ctx().helper.throwUnlessGranted(AuthPermissions.createUser, c);
|
||||||
|
|
||||||
|
return c.json(await this.auth.createUser(params));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
mcp.tool(
|
||||||
|
// @todo: needs permission
|
||||||
|
"auth_user_token",
|
||||||
|
{
|
||||||
|
description: "Get a user token",
|
||||||
|
inputSchema: s.object({
|
||||||
|
id: idType.optional(),
|
||||||
|
email: s.string({ format: "email" }).optional(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async (params, c) => {
|
||||||
|
await c.context.ctx().helper.throwUnlessGranted(AuthPermissions.createToken, c);
|
||||||
|
|
||||||
|
const user = await getUser(params);
|
||||||
|
return c.json({ user, token: await this.auth.authenticator.jwt(user) });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
mcp.tool(
|
||||||
|
// @todo: needs permission
|
||||||
|
"auth_user_password_change",
|
||||||
|
{
|
||||||
|
description: "Change a user's password",
|
||||||
|
inputSchema: s.object({
|
||||||
|
id: idType.optional(),
|
||||||
|
email: s.string({ format: "email" }).optional(),
|
||||||
|
password: s.string({ minLength: 8 }),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async (params, c) => {
|
||||||
|
await c.context.ctx().helper.throwUnlessGranted(AuthPermissions.changePassword, c);
|
||||||
|
|
||||||
|
const user = await getUser(params);
|
||||||
|
if (!(await this.auth.changePassword(user.id, params.password))) {
|
||||||
|
throw new Error("Failed to change password");
|
||||||
|
}
|
||||||
|
return c.json({ changed: true });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
mcp.tool(
|
||||||
|
// @todo: needs permission
|
||||||
|
"auth_user_password_test",
|
||||||
|
{
|
||||||
|
description: "Test a user's password",
|
||||||
|
inputSchema: s.object({
|
||||||
|
email: s.string({ format: "email" }),
|
||||||
|
password: s.string({ minLength: 8 }),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async (params, c) => {
|
||||||
|
await c.context.ctx().helper.throwUnlessGranted(AuthPermissions.testPassword, c);
|
||||||
|
|
||||||
|
const pw = this.auth.authenticator.strategy("password") as PasswordStrategy;
|
||||||
|
const controller = pw.getController(this.auth.authenticator);
|
||||||
|
|
||||||
|
const res = await controller.request(
|
||||||
|
new Request("https://localhost/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: params.email,
|
||||||
|
password: params.password,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json({ valid: res.ok });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,3 +2,6 @@ import { Permission } from "core/security/Permission";
|
|||||||
|
|
||||||
export const createUser = new Permission("auth.user.create");
|
export const createUser = new Permission("auth.user.create");
|
||||||
//export const updateUser = new Permission("auth.user.update");
|
//export const updateUser = new Permission("auth.user.update");
|
||||||
|
export const testPassword = new Permission("auth.user.password.test");
|
||||||
|
export const changePassword = new Permission("auth.user.password.change");
|
||||||
|
export const createToken = new Permission("auth.user.token.create");
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { cookieConfig, jwtConfig } from "auth/authenticate/Authenticator";
|
import { cookieConfig, jwtConfig } from "auth/authenticate/Authenticator";
|
||||||
import { CustomOAuthStrategy, OAuthStrategy, PasswordStrategy } from "auth/authenticate/strategies";
|
import { CustomOAuthStrategy, OAuthStrategy, PasswordStrategy } from "auth/authenticate/strategies";
|
||||||
import { objectTransform, s } from "bknd/utils";
|
import { objectTransform, s } from "bknd/utils";
|
||||||
|
import { $object, $record } from "modules/mcp";
|
||||||
|
|
||||||
export const Strategies = {
|
export const Strategies = {
|
||||||
password: {
|
password: {
|
||||||
@@ -45,7 +46,8 @@ export const guardRoleSchema = s.strictObject({
|
|||||||
implicit_allow: s.boolean().optional(),
|
implicit_allow: s.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const authConfigSchema = s.strictObject(
|
export const authConfigSchema = $object(
|
||||||
|
"config_auth",
|
||||||
{
|
{
|
||||||
enabled: s.boolean({ default: false }),
|
enabled: s.boolean({ default: false }),
|
||||||
basepath: s.string({ default: "/api/auth" }),
|
basepath: s.string({ default: "/api/auth" }),
|
||||||
@@ -53,20 +55,29 @@ export const authConfigSchema = s.strictObject(
|
|||||||
allow_register: s.boolean({ default: true }).optional(),
|
allow_register: s.boolean({ default: true }).optional(),
|
||||||
jwt: jwtConfig,
|
jwt: jwtConfig,
|
||||||
cookie: cookieConfig,
|
cookie: cookieConfig,
|
||||||
strategies: s.record(strategiesSchema, {
|
strategies: $record(
|
||||||
title: "Strategies",
|
"config_auth_strategies",
|
||||||
default: {
|
strategiesSchema,
|
||||||
password: {
|
{
|
||||||
type: "password",
|
title: "Strategies",
|
||||||
enabled: true,
|
default: {
|
||||||
config: {
|
password: {
|
||||||
hashing: "sha256",
|
type: "password",
|
||||||
|
enabled: true,
|
||||||
|
config: {
|
||||||
|
hashing: "sha256",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
s.strictObject({
|
||||||
|
type: s.string(),
|
||||||
|
enabled: s.boolean({ default: true }).optional(),
|
||||||
|
config: s.object({}),
|
||||||
|
}),
|
||||||
|
),
|
||||||
guard: guardConfigSchema.optional(),
|
guard: guardConfigSchema.optional(),
|
||||||
roles: s.record(guardRoleSchema, { default: {} }).optional(),
|
roles: $record("config_auth_roles", guardRoleSchema, { default: {} }).optional(),
|
||||||
},
|
},
|
||||||
{ title: "Authentication" },
|
{ title: "Authentication" },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type { ServerEnv } from "modules/Controller";
|
|||||||
import { pick } from "lodash-es";
|
import { pick } from "lodash-es";
|
||||||
import { InvalidConditionsException } from "auth/errors";
|
import { InvalidConditionsException } from "auth/errors";
|
||||||
import { s, parse, secret, runtimeSupports, truncate, $console } from "bknd/utils";
|
import { s, parse, secret, runtimeSupports, truncate, $console } from "bknd/utils";
|
||||||
|
import { $object } from "modules/mcp";
|
||||||
import type { AuthStrategy } from "./strategies/Strategy";
|
import type { AuthStrategy } from "./strategies/Strategy";
|
||||||
|
|
||||||
type Input = any; // workaround
|
type Input = any; // workaround
|
||||||
@@ -42,7 +43,7 @@ export interface UserPool {
|
|||||||
|
|
||||||
const defaultCookieExpires = 60 * 60 * 24 * 7; // 1 week in seconds
|
const defaultCookieExpires = 60 * 60 * 24 * 7; // 1 week in seconds
|
||||||
export const cookieConfig = s
|
export const cookieConfig = s
|
||||||
.object({
|
.strictObject({
|
||||||
path: s.string({ default: "/" }),
|
path: s.string({ default: "/" }),
|
||||||
sameSite: s.string({ enum: ["strict", "lax", "none"], default: "lax" }),
|
sameSite: s.string({ enum: ["strict", "lax", "none"], default: "lax" }),
|
||||||
secure: s.boolean({ default: true }),
|
secure: s.boolean({ default: true }),
|
||||||
@@ -53,27 +54,24 @@ export const cookieConfig = s
|
|||||||
pathSuccess: s.string({ default: "/" }),
|
pathSuccess: s.string({ default: "/" }),
|
||||||
pathLoggedOut: s.string({ default: "/" }),
|
pathLoggedOut: s.string({ default: "/" }),
|
||||||
})
|
})
|
||||||
.partial()
|
.partial();
|
||||||
.strict();
|
|
||||||
|
|
||||||
// @todo: maybe add a config to not allow cookie/api tokens to be used interchangably?
|
// @todo: maybe add a config to not allow cookie/api tokens to be used interchangably?
|
||||||
// see auth.integration test for further details
|
// see auth.integration test for further details
|
||||||
|
|
||||||
export const jwtConfig = s
|
export const jwtConfig = s.strictObject(
|
||||||
.object(
|
{
|
||||||
{
|
secret: secret({ default: "" }),
|
||||||
// @todo: autogenerate a secret if not present. But it must be persisted from AppAuth
|
alg: s.string({ enum: ["HS256", "HS384", "HS512"], default: "HS256" }).optional(),
|
||||||
secret: secret({ default: "" }),
|
expires: s.number().optional(), // seconds
|
||||||
alg: s.string({ enum: ["HS256", "HS384", "HS512"], default: "HS256" }).optional(),
|
issuer: s.string().optional(),
|
||||||
expires: s.number().optional(), // seconds
|
fields: s.array(s.string(), { default: ["id", "email", "role"] }),
|
||||||
issuer: s.string().optional(),
|
},
|
||||||
fields: s.array(s.string(), { default: ["id", "email", "role"] }),
|
{
|
||||||
},
|
default: {},
|
||||||
{
|
},
|
||||||
default: {},
|
);
|
||||||
},
|
|
||||||
)
|
|
||||||
.strict();
|
|
||||||
export const authenticatorConfig = s.object({
|
export const authenticatorConfig = s.object({
|
||||||
jwt: jwtConfig,
|
jwt: jwtConfig,
|
||||||
cookie: cookieConfig,
|
cookie: cookieConfig,
|
||||||
@@ -378,13 +376,28 @@ export class Authenticator<
|
|||||||
}
|
}
|
||||||
|
|
||||||
// @todo: don't extract user from token, but from the database or cache
|
// @todo: don't extract user from token, but from the database or cache
|
||||||
async resolveAuthFromRequest(c: Context): Promise<SafeUser | undefined> {
|
async resolveAuthFromRequest(c: Context | Request | Headers): Promise<SafeUser | undefined> {
|
||||||
let token: string | undefined;
|
let headers: Headers;
|
||||||
if (c.req.raw.headers.has("Authorization")) {
|
let is_context = false;
|
||||||
const bearerHeader = String(c.req.header("Authorization"));
|
if (c instanceof Headers) {
|
||||||
token = bearerHeader.replace("Bearer ", "");
|
headers = c;
|
||||||
|
} else if (c instanceof Request) {
|
||||||
|
headers = c.headers;
|
||||||
} else {
|
} else {
|
||||||
token = await this.getAuthCookie(c);
|
is_context = true;
|
||||||
|
try {
|
||||||
|
headers = c.req.raw.headers;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Exception("Request/Headers/Context is required to resolve auth", 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let token: string | undefined;
|
||||||
|
if (headers.has("Authorization")) {
|
||||||
|
const bearerHeader = String(headers.get("Authorization"));
|
||||||
|
token = bearerHeader.replace("Bearer ", "");
|
||||||
|
} else if (is_context) {
|
||||||
|
token = await this.getAuthCookie(c as Context);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import type { User } from "bknd";
|
import type { User } from "bknd";
|
||||||
import type { Authenticator } from "auth/authenticate/Authenticator";
|
import type { Authenticator } from "auth/authenticate/Authenticator";
|
||||||
import { InvalidCredentialsException } from "auth/errors";
|
import { InvalidCredentialsException } from "auth/errors";
|
||||||
import { hash, $console } from "core/utils";
|
import { hash, $console, s, parse, jsc } from "bknd/utils";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { compare as bcryptCompare, genSalt as bcryptGenSalt, hash as bcryptHash } from "bcryptjs";
|
import { compare as bcryptCompare, genSalt as bcryptGenSalt, hash as bcryptHash } from "bcryptjs";
|
||||||
import { AuthStrategy } from "./Strategy";
|
import { AuthStrategy } from "./Strategy";
|
||||||
import { s, parse, jsc } from "bknd/utils";
|
|
||||||
|
|
||||||
const schema = s
|
const schema = s
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Exception } from "core/errors";
|
import { Exception } from "core/errors";
|
||||||
import { $console, objectTransform } from "core/utils";
|
import { $console, objectTransform } from "bknd/utils";
|
||||||
import { Permission } from "core/security/Permission";
|
import { Permission } from "core/security/Permission";
|
||||||
import type { Context } from "hono";
|
import type { Context } from "hono";
|
||||||
import type { ServerEnv } from "modules/Controller";
|
import type { ServerEnv } from "modules/Controller";
|
||||||
|
|||||||
@@ -1,15 +1,35 @@
|
|||||||
import { getDefaultConfig } from "modules/ModuleManager";
|
import { getDefaultConfig } from "modules/ModuleManager";
|
||||||
import type { CliCommand } from "../types";
|
import type { CliCommand } from "../types";
|
||||||
|
import { makeAppFromEnv } from "cli/commands/run";
|
||||||
|
import { writeFile } from "node:fs/promises";
|
||||||
|
import c from "picocolors";
|
||||||
|
import { withConfigOptions } from "cli/utils/options";
|
||||||
|
|
||||||
export const config: CliCommand = (program) => {
|
export const config: CliCommand = (program) => {
|
||||||
program
|
withConfigOptions(program.command("config"))
|
||||||
.command("config")
|
.description("get app config")
|
||||||
.description("get default config")
|
|
||||||
.option("--pretty", "pretty print")
|
.option("--pretty", "pretty print")
|
||||||
.action((options) => {
|
.option("--default", "use default config")
|
||||||
const config = getDefaultConfig();
|
.option("--secrets", "include secrets in output")
|
||||||
|
.option("--out <file>", "output file")
|
||||||
|
.action(async (options) => {
|
||||||
|
let config: any = {};
|
||||||
|
|
||||||
// biome-ignore lint/suspicious/noConsoleLog:
|
if (options.default) {
|
||||||
console.log(options.pretty ? JSON.stringify(config, null, 2) : JSON.stringify(config));
|
config = getDefaultConfig();
|
||||||
|
} else {
|
||||||
|
const app = await makeAppFromEnv(options);
|
||||||
|
config = app.toJSON(options.secrets);
|
||||||
|
}
|
||||||
|
|
||||||
|
config = options.pretty ? JSON.stringify(config, null, 2) : JSON.stringify(config);
|
||||||
|
|
||||||
|
console.info("");
|
||||||
|
if (options.out) {
|
||||||
|
await writeFile(options.out, config);
|
||||||
|
console.info(`Config written to ${c.cyan(options.out)}`);
|
||||||
|
} else {
|
||||||
|
console.info(JSON.parse(config));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import * as $p from "@clack/prompts";
|
import * as $p from "@clack/prompts";
|
||||||
import { overrideJson, overridePackageJson } from "cli/commands/create/npm";
|
import { overrideJson } from "cli/commands/create/npm";
|
||||||
import { typewriter, wait } from "cli/utils/cli";
|
import { typewriter } from "cli/utils/cli";
|
||||||
import { uuid } from "core/utils";
|
|
||||||
import c from "picocolors";
|
import c from "picocolors";
|
||||||
import type { Template, TemplateSetupCtx } from ".";
|
import type { Template, TemplateSetupCtx } from ".";
|
||||||
import { exec } from "cli/utils/sys";
|
import { exec } from "cli/utils/sys";
|
||||||
|
|||||||
@@ -6,3 +6,5 @@ export { user } from "./user";
|
|||||||
export { create } from "./create";
|
export { create } from "./create";
|
||||||
export { copyAssets } from "./copy-assets";
|
export { copyAssets } from "./copy-assets";
|
||||||
export { types } from "./types";
|
export { types } from "./types";
|
||||||
|
export { mcp } from "./mcp/mcp";
|
||||||
|
export { sync } from "./sync";
|
||||||
|
|||||||
82
app/src/cli/commands/mcp/mcp.ts
Normal file
82
app/src/cli/commands/mcp/mcp.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import type { CliCommand } from "cli/types";
|
||||||
|
import { makeAppFromEnv } from "../run";
|
||||||
|
import { getSystemMcp } from "modules/mcp/system-mcp";
|
||||||
|
import { $console, stdioTransport } from "bknd/utils";
|
||||||
|
import { withConfigOptions, type WithConfigOptions } from "cli/utils/options";
|
||||||
|
|
||||||
|
export const mcp: CliCommand = (program) =>
|
||||||
|
withConfigOptions(program.command("mcp"))
|
||||||
|
.description("mcp server stdio transport")
|
||||||
|
.option(
|
||||||
|
"--token <token>",
|
||||||
|
"token to authenticate requests, if not provided, uses BEARER_TOKEN environment variable",
|
||||||
|
)
|
||||||
|
.option("--verbose", "verbose output")
|
||||||
|
.option("--log-level <level>", "log level")
|
||||||
|
.option("--force", "force enable mcp")
|
||||||
|
.action(action);
|
||||||
|
|
||||||
|
async function action(
|
||||||
|
options: WithConfigOptions<{
|
||||||
|
verbose?: boolean;
|
||||||
|
token?: string;
|
||||||
|
logLevel?: string;
|
||||||
|
force?: boolean;
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
|
const verbose = !!options.verbose;
|
||||||
|
const __oldConsole = { ...console };
|
||||||
|
|
||||||
|
// disable console
|
||||||
|
if (!verbose) {
|
||||||
|
$console.disable();
|
||||||
|
Object.entries(console).forEach(([key]) => {
|
||||||
|
console[key] = () => null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await makeAppFromEnv({
|
||||||
|
config: options.config,
|
||||||
|
dbUrl: options.dbUrl,
|
||||||
|
server: "node",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!app.modules.get("server").config.mcp.enabled && !options.force) {
|
||||||
|
$console.enable();
|
||||||
|
Object.assign(console, __oldConsole);
|
||||||
|
console.error("MCP is not enabled in the config, use --force to enable it");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = options.token || process.env.BEARER_TOKEN;
|
||||||
|
const server = getSystemMcp(app);
|
||||||
|
|
||||||
|
if (verbose) {
|
||||||
|
console.info(
|
||||||
|
`\n⚙️ Tools (${server.tools.length}):\n${server.tools.map((t) => `- ${t.name}`).join("\n")}\n`,
|
||||||
|
);
|
||||||
|
console.info(
|
||||||
|
`📚 Resources (${server.resources.length}):\n${server.resources.map((r) => `- ${r.name}`).join("\n")}`,
|
||||||
|
);
|
||||||
|
console.info("\nMCP server is running on STDIO transport");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.logLevel) {
|
||||||
|
server.setLogLevel(options.logLevel as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stdout = process.stdout;
|
||||||
|
const stdin = process.stdin;
|
||||||
|
const stderr = process.stderr;
|
||||||
|
|
||||||
|
{
|
||||||
|
using transport = stdioTransport(server, {
|
||||||
|
stdin,
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
raw: new Request("https://localhost", {
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { $console } from "core/utils";
|
import { $console } from "bknd/utils";
|
||||||
import type { MiddlewareHandler } from "hono";
|
import type { MiddlewareHandler } from "hono";
|
||||||
import open from "open";
|
import open from "open";
|
||||||
import { fileExists, getRelativeDistPath } from "../../utils/sys";
|
import { fileExists, getRelativeDistPath } from "../../utils/sys";
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from "./platform";
|
} from "./platform";
|
||||||
import { createRuntimeApp, makeConfig } from "bknd/adapter";
|
import { createRuntimeApp, makeConfig } from "bknd/adapter";
|
||||||
import { colorizeConsole, isBun } from "bknd/utils";
|
import { colorizeConsole, isBun } from "bknd/utils";
|
||||||
|
import { withConfigOptions, type WithConfigOptions } from "cli/utils/options";
|
||||||
|
|
||||||
const env_files = [".env", ".dev.vars"];
|
const env_files = [".env", ".dev.vars"];
|
||||||
dotenv.config({
|
dotenv.config({
|
||||||
@@ -25,8 +26,7 @@ dotenv.config({
|
|||||||
const is_bun = isBun();
|
const is_bun = isBun();
|
||||||
|
|
||||||
export const run: CliCommand = (program) => {
|
export const run: CliCommand = (program) => {
|
||||||
program
|
withConfigOptions(program.command("run"))
|
||||||
.command("run")
|
|
||||||
.description("run an instance")
|
.description("run an instance")
|
||||||
.addOption(
|
.addOption(
|
||||||
new Option("-p, --port <port>", "port to run on")
|
new Option("-p, --port <port>", "port to run on")
|
||||||
@@ -41,12 +41,6 @@ export const run: CliCommand = (program) => {
|
|||||||
"db-token",
|
"db-token",
|
||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
.addOption(new Option("-c, --config <config>", "config file"))
|
|
||||||
.addOption(
|
|
||||||
new Option("--db-url <db>", "database url, can be any valid sqlite url").conflicts(
|
|
||||||
"config",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.addOption(
|
.addOption(
|
||||||
new Option("--server <server>", "server type")
|
new Option("--server <server>", "server type")
|
||||||
.choices(PLATFORMS)
|
.choices(PLATFORMS)
|
||||||
@@ -77,21 +71,21 @@ async function makeApp(config: MakeAppConfig) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function makeConfigApp(_config: CliBkndConfig, platform?: Platform) {
|
export async function makeConfigApp(_config: CliBkndConfig, platform?: Platform) {
|
||||||
const config = makeConfig(_config, process.env);
|
const config = await makeConfig(_config, process.env);
|
||||||
return makeApp({
|
return makeApp({
|
||||||
...config,
|
...config,
|
||||||
server: { platform },
|
server: { platform },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
type RunOptions = {
|
type RunOptions = WithConfigOptions<{
|
||||||
port: number;
|
port: number;
|
||||||
memory?: boolean;
|
memory?: boolean;
|
||||||
config?: string;
|
config?: string;
|
||||||
dbUrl?: string;
|
dbUrl?: string;
|
||||||
server: Platform;
|
server: Platform;
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
};
|
}>;
|
||||||
|
|
||||||
export async function makeAppFromEnv(options: Partial<RunOptions> = {}) {
|
export async function makeAppFromEnv(options: Partial<RunOptions> = {}) {
|
||||||
const configFilePath = await getConfigPath(options.config);
|
const configFilePath = await getConfigPath(options.config);
|
||||||
|
|||||||
45
app/src/cli/commands/sync.ts
Normal file
45
app/src/cli/commands/sync.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import type { CliCommand } from "../types";
|
||||||
|
import { makeAppFromEnv } from "cli/commands/run";
|
||||||
|
import { writeFile } from "node:fs/promises";
|
||||||
|
import c from "picocolors";
|
||||||
|
import { withConfigOptions } from "cli/utils/options";
|
||||||
|
|
||||||
|
export const sync: CliCommand = (program) => {
|
||||||
|
withConfigOptions(program.command("sync"))
|
||||||
|
.description("sync database")
|
||||||
|
.option("--dump", "dump operations to console instead of executing them")
|
||||||
|
.option("--drop", "include destructive DDL operations")
|
||||||
|
.option("--out <file>", "output file")
|
||||||
|
.option("--sql", "use sql output")
|
||||||
|
.action(async (options) => {
|
||||||
|
const app = await makeAppFromEnv(options);
|
||||||
|
const schema = app.em.schema();
|
||||||
|
const stmts = await schema.sync({ drop: options.drop });
|
||||||
|
|
||||||
|
console.info("");
|
||||||
|
if (stmts.length === 0) {
|
||||||
|
console.info(c.yellow("No changes to sync"));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
// @todo: currently assuming parameters aren't used
|
||||||
|
const sql = stmts.map((d) => d.sql).join(";\n") + ";";
|
||||||
|
|
||||||
|
if (options.dump) {
|
||||||
|
if (options.out) {
|
||||||
|
const output = options.sql ? sql : JSON.stringify(stmts, null, 2);
|
||||||
|
await writeFile(options.out, output);
|
||||||
|
console.info(`SQL written to ${c.cyan(options.out)}`);
|
||||||
|
} else {
|
||||||
|
console.info(options.sql ? c.cyan(sql) : stmts);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
await schema.sync({ force: true, drop: options.drop });
|
||||||
|
console.info(c.cyan(sql));
|
||||||
|
|
||||||
|
console.info(`${c.gray(`Executed ${c.cyan(stmts.length)} statement(s)`)}`);
|
||||||
|
console.info(`${c.green("Database synced")}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -4,34 +4,35 @@ import { makeAppFromEnv } from "cli/commands/run";
|
|||||||
import { EntityTypescript } from "data/entities/EntityTypescript";
|
import { EntityTypescript } from "data/entities/EntityTypescript";
|
||||||
import { writeFile } from "cli/utils/sys";
|
import { writeFile } from "cli/utils/sys";
|
||||||
import c from "picocolors";
|
import c from "picocolors";
|
||||||
|
import { withConfigOptions, type WithConfigOptions } from "cli/utils/options";
|
||||||
|
|
||||||
export const types: CliCommand = (program) => {
|
export const types: CliCommand = (program) => {
|
||||||
program
|
withConfigOptions(program.command("types"))
|
||||||
.command("types")
|
|
||||||
.description("generate types")
|
.description("generate types")
|
||||||
.addOption(new Option("-o, --outfile <outfile>", "output file").default("bknd-types.d.ts"))
|
.addOption(new Option("-o, --outfile <outfile>", "output file").default("bknd-types.d.ts"))
|
||||||
.addOption(new Option("--no-write", "do not write to file").default(true))
|
.addOption(new Option("--dump", "dump types to console instead of writing to file"))
|
||||||
.action(action);
|
.action(action);
|
||||||
};
|
};
|
||||||
|
|
||||||
async function action({
|
async function action({
|
||||||
outfile,
|
outfile,
|
||||||
write,
|
dump,
|
||||||
}: {
|
...options
|
||||||
|
}: WithConfigOptions<{
|
||||||
outfile: string;
|
outfile: string;
|
||||||
write: boolean;
|
dump: boolean;
|
||||||
}) {
|
}>) {
|
||||||
const app = await makeAppFromEnv({
|
const app = await makeAppFromEnv({
|
||||||
server: "node",
|
server: "node",
|
||||||
|
...options,
|
||||||
});
|
});
|
||||||
await app.build();
|
|
||||||
|
|
||||||
const et = new EntityTypescript(app.em);
|
const et = new EntityTypescript(app.em);
|
||||||
|
|
||||||
if (write) {
|
if (dump) {
|
||||||
|
console.info(et.toString());
|
||||||
|
} else {
|
||||||
await writeFile(outfile, et.toString());
|
await writeFile(outfile, et.toString());
|
||||||
console.info(`\nTypes written to ${c.cyan(outfile)}`);
|
console.info(`\nTypes written to ${c.cyan(outfile)}`);
|
||||||
} else {
|
|
||||||
console.info(et.toString());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,13 +9,12 @@ import type { PasswordStrategy } from "auth/authenticate/strategies";
|
|||||||
import { makeAppFromEnv } from "cli/commands/run";
|
import { makeAppFromEnv } from "cli/commands/run";
|
||||||
import type { CliCommand } from "cli/types";
|
import type { CliCommand } from "cli/types";
|
||||||
import { Argument } from "commander";
|
import { Argument } from "commander";
|
||||||
import { $console } from "core/utils";
|
import { $console, isBun } from "bknd/utils";
|
||||||
import c from "picocolors";
|
import c from "picocolors";
|
||||||
import { isBun } from "core/utils";
|
import { withConfigOptions, type WithConfigOptions } from "cli/utils/options";
|
||||||
|
|
||||||
export const user: CliCommand = (program) => {
|
export const user: CliCommand = (program) => {
|
||||||
program
|
withConfigOptions(program.command("user"))
|
||||||
.command("user")
|
|
||||||
.description("create/update users, or generate a token (auth)")
|
.description("create/update users, or generate a token (auth)")
|
||||||
.addArgument(
|
.addArgument(
|
||||||
new Argument("<action>", "action to perform").choices(["create", "update", "token"]),
|
new Argument("<action>", "action to perform").choices(["create", "update", "token"]),
|
||||||
@@ -23,8 +22,10 @@ export const user: CliCommand = (program) => {
|
|||||||
.action(action);
|
.action(action);
|
||||||
};
|
};
|
||||||
|
|
||||||
async function action(action: "create" | "update" | "token", options: any) {
|
async function action(action: "create" | "update" | "token", options: WithConfigOptions) {
|
||||||
const app = await makeAppFromEnv({
|
const app = await makeAppFromEnv({
|
||||||
|
config: options.config,
|
||||||
|
dbUrl: options.dbUrl,
|
||||||
server: "node",
|
server: "node",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,9 +86,6 @@ async function create(app: App, options: any) {
|
|||||||
|
|
||||||
async function update(app: App, options: any) {
|
async function update(app: App, options: any) {
|
||||||
const config = app.module.auth.toJSON(true);
|
const config = app.module.auth.toJSON(true);
|
||||||
const strategy = app.module.auth.authenticator.strategy("password") as PasswordStrategy;
|
|
||||||
const users_entity = config.entity_name as "users";
|
|
||||||
const em = app.modules.ctx().em;
|
|
||||||
|
|
||||||
const email = (await $text({
|
const email = (await $text({
|
||||||
message: "Which user? Enter email",
|
message: "Which user? Enter email",
|
||||||
@@ -100,7 +98,10 @@ async function update(app: App, options: any) {
|
|||||||
})) as string;
|
})) as string;
|
||||||
if ($isCancel(email)) process.exit(1);
|
if ($isCancel(email)) process.exit(1);
|
||||||
|
|
||||||
const { data: user } = await em.repository(users_entity).findOne({ email });
|
const { data: user } = await app.modules
|
||||||
|
.ctx()
|
||||||
|
.em.repository(config.entity_name as "users")
|
||||||
|
.findOne({ email });
|
||||||
if (!user) {
|
if (!user) {
|
||||||
$log.error("User not found");
|
$log.error("User not found");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -118,26 +119,10 @@ async function update(app: App, options: any) {
|
|||||||
});
|
});
|
||||||
if ($isCancel(password)) process.exit(1);
|
if ($isCancel(password)) process.exit(1);
|
||||||
|
|
||||||
try {
|
if (await app.module.auth.changePassword(user.id, password)) {
|
||||||
function togglePw(visible: boolean) {
|
|
||||||
const field = em.entity(users_entity).field("strategy_value")!;
|
|
||||||
|
|
||||||
field.config.hidden = !visible;
|
|
||||||
field.config.fillable = visible;
|
|
||||||
}
|
|
||||||
togglePw(true);
|
|
||||||
await app.modules
|
|
||||||
.ctx()
|
|
||||||
.em.mutator(users_entity)
|
|
||||||
.updateOne(user.id, {
|
|
||||||
strategy_value: await strategy.hash(password as string),
|
|
||||||
});
|
|
||||||
togglePw(false);
|
|
||||||
|
|
||||||
$log.success(`Updated user: ${c.cyan(user.email)}`);
|
$log.success(`Updated user: ${c.cyan(user.email)}`);
|
||||||
} catch (e) {
|
} else {
|
||||||
$log.error("Error updating user");
|
$log.error("Error updating user");
|
||||||
$console.error(e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
16
app/src/cli/utils/options.ts
Normal file
16
app/src/cli/utils/options.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { type Command, Option } from "commander";
|
||||||
|
|
||||||
|
export function withConfigOptions(program: Command) {
|
||||||
|
return program
|
||||||
|
.addOption(new Option("-c, --config <config>", "config file"))
|
||||||
|
.addOption(
|
||||||
|
new Option("--db-url <db>", "database url, can be any valid sqlite url").conflicts(
|
||||||
|
"config",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WithConfigOptions<CustomOptions = {}> = {
|
||||||
|
config?: string;
|
||||||
|
dbUrl?: string;
|
||||||
|
} & CustomOptions;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { $console } from "core/utils";
|
import { $console } from "bknd/utils";
|
||||||
import { execSync, exec as nodeExec } from "node:child_process";
|
import { execSync, exec as nodeExec } from "node:child_process";
|
||||||
import { readFile, writeFile as nodeWriteFile } from "node:fs/promises";
|
import { readFile, writeFile as nodeWriteFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -26,7 +26,7 @@ export async function getVersion(_path: string = "") {
|
|||||||
return JSON.parse(pkg).version ?? "preview";
|
return JSON.parse(pkg).version ?? "preview";
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to resolve version");
|
//console.error("Failed to resolve version");
|
||||||
}
|
}
|
||||||
|
|
||||||
return "unknown";
|
return "unknown";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { mergeObject, type RecursivePartial } from "core/utils";
|
import { mergeObject, type RecursivePartial } from "bknd/utils";
|
||||||
import type { IEmailDriver } from "./index";
|
import type { IEmailDriver } from "./index";
|
||||||
|
|
||||||
export type MailchannelsEmailOptions = {
|
export type MailchannelsEmailOptions = {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { type Event, type EventClass, InvalidEventReturn } from "./Event";
|
import { type Event, type EventClass, InvalidEventReturn } from "./Event";
|
||||||
import { EventListener, type ListenerHandler, type ListenerMode } from "./EventListener";
|
import { EventListener, type ListenerHandler, type ListenerMode } from "./EventListener";
|
||||||
import { $console } from "core/utils";
|
import { $console } from "bknd/utils";
|
||||||
|
|
||||||
export type RegisterListenerConfig =
|
export type RegisterListenerConfig =
|
||||||
| ListenerMode
|
| ListenerMode
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export class SchemaObject<Schema extends TSchema = TSchema> {
|
|||||||
) {
|
) {
|
||||||
this._default = deepFreeze(_schema.template({}, { withOptional: true }) as any);
|
this._default = deepFreeze(_schema.template({}, { withOptional: true }) as any);
|
||||||
this._value = deepFreeze(
|
this._value = deepFreeze(
|
||||||
parse(_schema, structuredClone(initial ?? {}), {
|
parse(_schema, initial ?? {}, {
|
||||||
withDefaults: true,
|
withDefaults: true,
|
||||||
//withExtendedDefaults: true,
|
//withExtendedDefaults: true,
|
||||||
forceParse: this.isForceParse(),
|
forceParse: this.isForceParse(),
|
||||||
@@ -177,7 +177,6 @@ export class SchemaObject<Schema extends TSchema = TSchema> {
|
|||||||
|
|
||||||
this.throwIfRestricted(partial);
|
this.throwIfRestricted(partial);
|
||||||
|
|
||||||
// overwrite arrays and primitives, only deep merge objects
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const config = set(current, path, value);
|
const config = set(current, path, value);
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,37 @@
|
|||||||
import { createApp as createAppInternal, type CreateAppConfig } from "App";
|
import { Connection, createApp as createAppInternal, type CreateAppConfig } from "bknd";
|
||||||
import { bunSqlite } from "adapter/bun/connection/BunSqliteConnection";
|
import { bunSqlite } from "bknd/adapter/bun";
|
||||||
import { Connection } from "data/connection/Connection";
|
import type { McpServer } from "bknd/utils";
|
||||||
|
|
||||||
export { App } from "App";
|
export { App } from "bknd";
|
||||||
|
|
||||||
export function createApp({ connection, ...config }: CreateAppConfig = {}) {
|
export function createApp({ connection, ...config }: CreateAppConfig = {}) {
|
||||||
return createAppInternal({
|
return createAppInternal({
|
||||||
...config,
|
...config,
|
||||||
connection: Connection.isConnection(connection) ? connection : bunSqlite(connection as any),
|
connection: Connection.isConnection(connection)
|
||||||
|
? connection
|
||||||
|
: (bunSqlite(connection as any) as any),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createMcpToolCaller() {
|
||||||
|
return async (server: McpServer, name: string, args: any, raw?: any) => {
|
||||||
|
const res = await server.handle(
|
||||||
|
{
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method: "tools/call",
|
||||||
|
params: {
|
||||||
|
name,
|
||||||
|
arguments: args,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
raw,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ((res.result as any)?.isError) {
|
||||||
|
console.dir(res.result, { depth: null });
|
||||||
|
throw new Error((res.result as any)?.content?.[0]?.text ?? "Unknown error");
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse((res.result as any)?.content?.[0]?.text ?? "null");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ declare global {
|
|||||||
| {
|
| {
|
||||||
level: TConsoleSeverity;
|
level: TConsoleSeverity;
|
||||||
id?: string;
|
id?: string;
|
||||||
|
enabled?: boolean;
|
||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
}
|
}
|
||||||
@@ -86,6 +87,7 @@ const defaultLevel = env("cli_log_level", "log") as TConsoleSeverity;
|
|||||||
// biome-ignore lint/suspicious/noAssignInExpressions: <explanation>
|
// biome-ignore lint/suspicious/noAssignInExpressions: <explanation>
|
||||||
const config = (globalThis.__consoleConfig ??= {
|
const config = (globalThis.__consoleConfig ??= {
|
||||||
level: defaultLevel,
|
level: defaultLevel,
|
||||||
|
enabled: true,
|
||||||
//id: crypto.randomUUID(), // for debugging
|
//id: crypto.randomUUID(), // for debugging
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -95,6 +97,14 @@ export const $console = new Proxy(config as any, {
|
|||||||
switch (prop) {
|
switch (prop) {
|
||||||
case "original":
|
case "original":
|
||||||
return console;
|
return console;
|
||||||
|
case "disable":
|
||||||
|
return () => {
|
||||||
|
config.enabled = false;
|
||||||
|
};
|
||||||
|
case "enable":
|
||||||
|
return () => {
|
||||||
|
config.enabled = true;
|
||||||
|
};
|
||||||
case "setLevel":
|
case "setLevel":
|
||||||
return (l: TConsoleSeverity) => {
|
return (l: TConsoleSeverity) => {
|
||||||
config.level = l;
|
config.level = l;
|
||||||
@@ -105,6 +115,10 @@ export const $console = new Proxy(config as any, {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!config.enabled) {
|
||||||
|
return () => null;
|
||||||
|
}
|
||||||
|
|
||||||
const current = keys.indexOf(config.level);
|
const current = keys.indexOf(config.level);
|
||||||
const requested = keys.indexOf(prop as string);
|
const requested = keys.indexOf(prop as string);
|
||||||
|
|
||||||
@@ -118,6 +132,8 @@ export const $console = new Proxy(config as any, {
|
|||||||
} & {
|
} & {
|
||||||
setLevel: (l: TConsoleSeverity) => void;
|
setLevel: (l: TConsoleSeverity) => void;
|
||||||
resetLevel: () => void;
|
resetLevel: () => void;
|
||||||
|
disable: () => void;
|
||||||
|
enable: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function colorizeConsole(con: typeof console) {
|
export function colorizeConsole(con: typeof console) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { extension, guess, isMimeType } from "media/storage/mime-types-tiny";
|
import { extension, guess, isMimeType } from "media/storage/mime-types-tiny";
|
||||||
import { randomString } from "core/utils/strings";
|
import { randomString } from "./strings";
|
||||||
import type { Context } from "hono";
|
import type { Context } from "hono";
|
||||||
import { invariant } from "core/utils/runtime";
|
import { invariant } from "./runtime";
|
||||||
import { $console } from "./console";
|
import { $console } from "./console";
|
||||||
|
|
||||||
export function getContentName(request: Request): string | undefined;
|
export function getContentName(request: Request): string | undefined;
|
||||||
|
|||||||
@@ -13,18 +13,5 @@ export * from "./uuid";
|
|||||||
export * from "./test";
|
export * from "./test";
|
||||||
export * from "./runtime";
|
export * from "./runtime";
|
||||||
export * from "./numbers";
|
export * from "./numbers";
|
||||||
export {
|
export * from "./schema";
|
||||||
s,
|
export { DebugLogger } from "./DebugLogger";
|
||||||
stripMark,
|
|
||||||
mark,
|
|
||||||
stringIdentifier,
|
|
||||||
SecretSchema,
|
|
||||||
secret,
|
|
||||||
parse,
|
|
||||||
jsc,
|
|
||||||
describeRoute,
|
|
||||||
schemaToSpec,
|
|
||||||
openAPISpecs,
|
|
||||||
type ParseOptions,
|
|
||||||
InvalidSchemaError,
|
|
||||||
} from "./schema";
|
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ export function ensureInt(value?: string | number | null | undefined): number {
|
|||||||
|
|
||||||
export const formatNumber = {
|
export const formatNumber = {
|
||||||
fileSize: (bytes: number, decimals = 2): string => {
|
fileSize: (bytes: number, decimals = 2): string => {
|
||||||
if (bytes === 0) return "0 Bytes";
|
if (bytes === 0) return "0 B";
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
const dm = decimals < 0 ? 0 : decimals;
|
const dm = decimals < 0 ? 0 : decimals;
|
||||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
return Number.parseFloat((bytes / k ** i).toFixed(dm)) + " " + sizes[i];
|
return Number.parseFloat((bytes / k ** i).toFixed(dm)) + " " + sizes[i];
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -26,6 +26,20 @@ export function omitKeys<T extends object, K extends keyof T>(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function pickKeys<T extends object, K extends keyof T>(
|
||||||
|
obj: T,
|
||||||
|
keys_: readonly K[],
|
||||||
|
): Pick<T, Extract<K, keyof T>> {
|
||||||
|
const keys = new Set(keys_);
|
||||||
|
const result = {} as Pick<T, Extract<K, keyof T>>;
|
||||||
|
for (const [key, value] of Object.entries(obj) as [keyof T, T[keyof T]][]) {
|
||||||
|
if (keys.has(key as K)) {
|
||||||
|
(result as any)[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export function safelyParseObjectValues<T extends { [key: string]: any }>(obj: T): T {
|
export function safelyParseObjectValues<T extends { [key: string]: any }>(obj: T): T {
|
||||||
return Object.entries(obj).reduce((acc, [key, value]) => {
|
return Object.entries(obj).reduce((acc, [key, value]) => {
|
||||||
try {
|
try {
|
||||||
@@ -189,6 +203,30 @@ export function objectDepth(object: object): number {
|
|||||||
return level;
|
return level;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function limitObjectDepth<T>(obj: T, maxDepth: number): T {
|
||||||
|
function _limit(current: any, depth: number): any {
|
||||||
|
if (isPlainObject(current)) {
|
||||||
|
if (depth > maxDepth) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const result: any = {};
|
||||||
|
for (const key in current) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(current, key)) {
|
||||||
|
result[key] = _limit(current[key], depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (Array.isArray(current)) {
|
||||||
|
// Arrays themselves are not limited, but their object elements are
|
||||||
|
return current.map((item) => _limit(item, depth));
|
||||||
|
}
|
||||||
|
// Primitives are always returned, regardless of depth
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
return _limit(obj, 1);
|
||||||
|
}
|
||||||
|
|
||||||
export function objectCleanEmpty<Obj extends { [key: string]: any }>(obj: Obj): Obj {
|
export function objectCleanEmpty<Obj extends { [key: string]: any }>(obj: Obj): Obj {
|
||||||
if (!obj) return obj;
|
if (!obj) return obj;
|
||||||
return Object.entries(obj).reduce((acc, [key, value]) => {
|
return Object.entries(obj).reduce((acc, [key, value]) => {
|
||||||
|
|||||||
@@ -1,7 +1,21 @@
|
|||||||
import * as s from "jsonv-ts";
|
import * as s from "jsonv-ts";
|
||||||
|
|
||||||
export { validator as jsc, type Options } from "jsonv-ts/hono";
|
export { validator as jsc, type Options } from "jsonv-ts/hono";
|
||||||
export { describeRoute, schemaToSpec, openAPISpecs } from "jsonv-ts/hono";
|
export { describeRoute, schemaToSpec, openAPISpecs, info } from "jsonv-ts/hono";
|
||||||
|
export {
|
||||||
|
mcp,
|
||||||
|
McpServer,
|
||||||
|
Resource,
|
||||||
|
Tool,
|
||||||
|
mcpTool,
|
||||||
|
mcpResource,
|
||||||
|
getMcpServer,
|
||||||
|
stdioTransport,
|
||||||
|
McpClient,
|
||||||
|
type McpClientConfig,
|
||||||
|
type ToolAnnotation,
|
||||||
|
type ToolHandlerCtx,
|
||||||
|
} from "jsonv-ts/mcp";
|
||||||
|
|
||||||
export { secret, SecretSchema } from "./secret";
|
export { secret, SecretSchema } from "./secret";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { StringSchema, type IStringOptions } from "jsonv-ts";
|
import type { s } from "bknd/utils";
|
||||||
|
import { StringSchema } from "jsonv-ts";
|
||||||
|
|
||||||
export class SecretSchema<O extends IStringOptions> extends StringSchema<O> {}
|
export class SecretSchema<O extends s.IStringOptions> extends StringSchema<O> {}
|
||||||
|
|
||||||
export const secret = <O extends IStringOptions>(o?: O): SecretSchema<O> & O =>
|
export const secret = <O extends s.IStringOptions>(o?: O): SecretSchema<O> & O =>
|
||||||
new SecretSchema(o) as any;
|
new SecretSchema(o) as any;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { transformObject } from "core/utils";
|
import { transformObject } from "bknd/utils";
|
||||||
|
|
||||||
import { Module } from "modules/Module";
|
import { Module } from "modules/Module";
|
||||||
import { DataController } from "./api/DataController";
|
import { DataController } from "./api/DataController";
|
||||||
import { type AppDataConfig, dataConfigSchema } from "./data-schema";
|
import { type AppDataConfig, dataConfigSchema } from "./data-schema";
|
||||||
@@ -49,10 +48,9 @@ export class AppData extends Module<AppDataConfig> {
|
|||||||
this.ctx.em.addIndex(index);
|
this.ctx.em.addIndex(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.ctx.server.route(
|
const dataController = new DataController(this.ctx, this.config);
|
||||||
this.basepath,
|
dataController.registerMcp();
|
||||||
new DataController(this.ctx, this.config).getController(),
|
this.ctx.server.route(this.basepath, dataController.getController());
|
||||||
);
|
|
||||||
this.ctx.guard.registerPermissions(Object.values(DataPermissions));
|
this.ctx.guard.registerPermissions(Object.values(DataPermissions));
|
||||||
|
|
||||||
this.setBuilt();
|
this.setBuilt();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Handler } from "hono/types";
|
import type { Handler } from "hono/types";
|
||||||
import type { ModuleBuildContext } from "modules";
|
import type { ModuleBuildContext } from "modules";
|
||||||
import { Controller } from "modules/Controller";
|
import { Controller } from "modules/Controller";
|
||||||
import { jsc, s, describeRoute, schemaToSpec, omitKeys } from "bknd/utils";
|
import { jsc, s, describeRoute, schemaToSpec, omitKeys, pickKeys, mcpTool } from "bknd/utils";
|
||||||
import * as SystemPermissions from "modules/permissions";
|
import * as SystemPermissions from "modules/permissions";
|
||||||
import type { AppDataConfig } from "../data-schema";
|
import type { AppDataConfig } from "../data-schema";
|
||||||
import type { EntityManager, EntityData } from "data/entities";
|
import type { EntityManager, EntityData } from "data/entities";
|
||||||
@@ -62,6 +62,11 @@ export class DataController extends Controller {
|
|||||||
hono.get(
|
hono.get(
|
||||||
"/sync",
|
"/sync",
|
||||||
permission(DataPermissions.databaseSync),
|
permission(DataPermissions.databaseSync),
|
||||||
|
mcpTool("data_sync", {
|
||||||
|
annotations: {
|
||||||
|
destructiveHint: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
describeRoute({
|
describeRoute({
|
||||||
summary: "Sync database schema",
|
summary: "Sync database schema",
|
||||||
tags: ["data"],
|
tags: ["data"],
|
||||||
@@ -77,9 +82,7 @@ export class DataController extends Controller {
|
|||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { force, drop } = c.req.valid("query");
|
const { force, drop } = c.req.valid("query");
|
||||||
//console.log("force", force);
|
|
||||||
const tables = await this.em.schema().introspect();
|
const tables = await this.em.schema().introspect();
|
||||||
//console.log("tables", tables);
|
|
||||||
const changes = await this.em.schema().sync({
|
const changes = await this.em.schema().sync({
|
||||||
force,
|
force,
|
||||||
drop,
|
drop,
|
||||||
@@ -165,6 +168,7 @@ export class DataController extends Controller {
|
|||||||
summary: "Retrieve entity info",
|
summary: "Retrieve entity info",
|
||||||
tags: ["data"],
|
tags: ["data"],
|
||||||
}),
|
}),
|
||||||
|
mcpTool("data_entity_info"),
|
||||||
jsc("param", s.object({ entity: entitiesEnum })),
|
jsc("param", s.object({ entity: entitiesEnum })),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { entity } = c.req.param();
|
const { entity } = c.req.param();
|
||||||
@@ -201,7 +205,9 @@ export class DataController extends Controller {
|
|||||||
|
|
||||||
const entitiesEnum = this.getEntitiesEnum(this.em);
|
const entitiesEnum = this.getEntitiesEnum(this.em);
|
||||||
// @todo: make dynamic based on entity
|
// @todo: make dynamic based on entity
|
||||||
const idType = s.anyOf([s.number(), s.string()], { coerce: (v) => v as number | string });
|
const idType = s.anyOf([s.number({ title: "Integer" }), s.string({ title: "UUID" })], {
|
||||||
|
coerce: (v) => v as number | string,
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Function endpoints
|
* Function endpoints
|
||||||
@@ -214,6 +220,7 @@ export class DataController extends Controller {
|
|||||||
summary: "Count entities",
|
summary: "Count entities",
|
||||||
tags: ["data"],
|
tags: ["data"],
|
||||||
}),
|
}),
|
||||||
|
mcpTool("data_entity_fn_count"),
|
||||||
jsc("param", s.object({ entity: entitiesEnum })),
|
jsc("param", s.object({ entity: entitiesEnum })),
|
||||||
jsc("json", repoQuery.properties.where),
|
jsc("json", repoQuery.properties.where),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
@@ -236,6 +243,7 @@ export class DataController extends Controller {
|
|||||||
summary: "Check if entity exists",
|
summary: "Check if entity exists",
|
||||||
tags: ["data"],
|
tags: ["data"],
|
||||||
}),
|
}),
|
||||||
|
mcpTool("data_entity_fn_exists"),
|
||||||
jsc("param", s.object({ entity: entitiesEnum })),
|
jsc("param", s.object({ entity: entitiesEnum })),
|
||||||
jsc("json", repoQuery.properties.where),
|
jsc("json", repoQuery.properties.where),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
@@ -268,6 +276,9 @@ export class DataController extends Controller {
|
|||||||
(p) => pick.includes(p.name),
|
(p) => pick.includes(p.name),
|
||||||
) as any),
|
) as any),
|
||||||
];
|
];
|
||||||
|
const saveRepoQuerySchema = (pick: string[] = Object.keys(saveRepoQuery.properties)) => {
|
||||||
|
return s.object(pickKeys(saveRepoQuery.properties, pick as any));
|
||||||
|
};
|
||||||
|
|
||||||
hono.get(
|
hono.get(
|
||||||
"/:entity",
|
"/:entity",
|
||||||
@@ -300,6 +311,13 @@ export class DataController extends Controller {
|
|||||||
tags: ["data"],
|
tags: ["data"],
|
||||||
}),
|
}),
|
||||||
permission(DataPermissions.entityRead),
|
permission(DataPermissions.entityRead),
|
||||||
|
mcpTool("data_entity_read_one", {
|
||||||
|
inputSchema: {
|
||||||
|
param: s.object({ entity: entitiesEnum, id: idType }),
|
||||||
|
query: saveRepoQuerySchema(["offset", "sort", "select"]),
|
||||||
|
},
|
||||||
|
noErrorCodes: [404],
|
||||||
|
}),
|
||||||
jsc(
|
jsc(
|
||||||
"param",
|
"param",
|
||||||
s.object({
|
s.object({
|
||||||
@@ -375,6 +393,12 @@ export class DataController extends Controller {
|
|||||||
tags: ["data"],
|
tags: ["data"],
|
||||||
}),
|
}),
|
||||||
permission(DataPermissions.entityRead),
|
permission(DataPermissions.entityRead),
|
||||||
|
mcpTool("data_entity_read_many", {
|
||||||
|
inputSchema: {
|
||||||
|
param: s.object({ entity: entitiesEnum }),
|
||||||
|
json: fnQuery,
|
||||||
|
},
|
||||||
|
}),
|
||||||
jsc("param", s.object({ entity: entitiesEnum })),
|
jsc("param", s.object({ entity: entitiesEnum })),
|
||||||
jsc("json", repoQuery, { skipOpenAPI: true }),
|
jsc("json", repoQuery, { skipOpenAPI: true }),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
@@ -400,6 +424,7 @@ export class DataController extends Controller {
|
|||||||
tags: ["data"],
|
tags: ["data"],
|
||||||
}),
|
}),
|
||||||
permission(DataPermissions.entityCreate),
|
permission(DataPermissions.entityCreate),
|
||||||
|
mcpTool("data_entity_insert"),
|
||||||
jsc("param", s.object({ entity: entitiesEnum })),
|
jsc("param", s.object({ entity: entitiesEnum })),
|
||||||
jsc("json", s.anyOf([s.object({}), s.array(s.object({}))])),
|
jsc("json", s.anyOf([s.object({}), s.array(s.object({}))])),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
@@ -427,6 +452,15 @@ export class DataController extends Controller {
|
|||||||
tags: ["data"],
|
tags: ["data"],
|
||||||
}),
|
}),
|
||||||
permission(DataPermissions.entityUpdate),
|
permission(DataPermissions.entityUpdate),
|
||||||
|
mcpTool("data_entity_update_many", {
|
||||||
|
inputSchema: {
|
||||||
|
param: s.object({ entity: entitiesEnum }),
|
||||||
|
json: s.object({
|
||||||
|
update: s.object({}),
|
||||||
|
where: s.object({}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
jsc("param", s.object({ entity: entitiesEnum })),
|
jsc("param", s.object({ entity: entitiesEnum })),
|
||||||
jsc(
|
jsc(
|
||||||
"json",
|
"json",
|
||||||
@@ -458,6 +492,7 @@ export class DataController extends Controller {
|
|||||||
tags: ["data"],
|
tags: ["data"],
|
||||||
}),
|
}),
|
||||||
permission(DataPermissions.entityUpdate),
|
permission(DataPermissions.entityUpdate),
|
||||||
|
mcpTool("data_entity_update_one"),
|
||||||
jsc("param", s.object({ entity: entitiesEnum, id: idType })),
|
jsc("param", s.object({ entity: entitiesEnum, id: idType })),
|
||||||
jsc("json", s.object({})),
|
jsc("json", s.object({})),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
@@ -480,6 +515,7 @@ export class DataController extends Controller {
|
|||||||
tags: ["data"],
|
tags: ["data"],
|
||||||
}),
|
}),
|
||||||
permission(DataPermissions.entityDelete),
|
permission(DataPermissions.entityDelete),
|
||||||
|
mcpTool("data_entity_delete_one"),
|
||||||
jsc("param", s.object({ entity: entitiesEnum, id: idType })),
|
jsc("param", s.object({ entity: entitiesEnum, id: idType })),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { entity, id } = c.req.valid("param");
|
const { entity, id } = c.req.valid("param");
|
||||||
@@ -500,6 +536,12 @@ export class DataController extends Controller {
|
|||||||
tags: ["data"],
|
tags: ["data"],
|
||||||
}),
|
}),
|
||||||
permission(DataPermissions.entityDelete),
|
permission(DataPermissions.entityDelete),
|
||||||
|
mcpTool("data_entity_delete_many", {
|
||||||
|
inputSchema: {
|
||||||
|
param: s.object({ entity: entitiesEnum }),
|
||||||
|
json: s.object({}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
jsc("param", s.object({ entity: entitiesEnum })),
|
jsc("param", s.object({ entity: entitiesEnum })),
|
||||||
jsc("json", repoQuery.properties.where),
|
jsc("json", repoQuery.properties.where),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
@@ -516,4 +558,35 @@ export class DataController extends Controller {
|
|||||||
|
|
||||||
return hono;
|
return hono;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override registerMcp() {
|
||||||
|
this.ctx.mcp
|
||||||
|
.resource(
|
||||||
|
"data_entities",
|
||||||
|
"bknd://data/entities",
|
||||||
|
(c) => c.json(c.context.ctx().em.toJSON().entities),
|
||||||
|
{
|
||||||
|
title: "Entities",
|
||||||
|
description: "Retrieve all entities",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.resource(
|
||||||
|
"data_relations",
|
||||||
|
"bknd://data/relations",
|
||||||
|
(c) => c.json(c.context.ctx().em.toJSON().relations),
|
||||||
|
{
|
||||||
|
title: "Relations",
|
||||||
|
description: "Retrieve all relations",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.resource(
|
||||||
|
"data_indices",
|
||||||
|
"bknd://data/indices",
|
||||||
|
(c) => c.json(c.context.ctx().em.toJSON().indices),
|
||||||
|
{
|
||||||
|
title: "Indices",
|
||||||
|
description: "Retrieve all indices",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { TestRunner } from "core/test";
|
import type { TestRunner } from "core/test";
|
||||||
import { Connection, type FieldSpec } from "./Connection";
|
import { Connection, type FieldSpec } from "./Connection";
|
||||||
import { getPath } from "core/utils";
|
import { getPath } from "bknd/utils";
|
||||||
import * as proto from "data/prototype";
|
import * as proto from "data/prototype";
|
||||||
import { createApp } from "App";
|
import { createApp } from "App";
|
||||||
import type { MaybePromise } from "core/types";
|
import type { MaybePromise } from "core/types";
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export {
|
|||||||
type ConnQueryResults,
|
type ConnQueryResults,
|
||||||
customIntrospector,
|
customIntrospector,
|
||||||
} from "./Connection";
|
} from "./Connection";
|
||||||
|
export { DummyConnection } from "./DummyConnection";
|
||||||
|
|
||||||
// sqlite
|
// sqlite
|
||||||
export { SqliteConnection } from "./sqlite/SqliteConnection";
|
export { SqliteConnection } from "./sqlite/SqliteConnection";
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import { objectTransform } from "core/utils";
|
import { objectTransform } from "bknd/utils";
|
||||||
import { MediaField, mediaFieldConfigSchema } from "../media/MediaField";
|
import { MediaField, mediaFieldConfigSchema } from "../media/MediaField";
|
||||||
import { FieldClassMap } from "data/fields";
|
import { FieldClassMap } from "data/fields";
|
||||||
import { RelationClassMap, RelationFieldClassMap } from "data/relations";
|
import { RelationClassMap, RelationFieldClassMap } from "data/relations";
|
||||||
import { entityConfigSchema, entityTypes } from "data/entities";
|
import { entityConfigSchema, entityTypes } from "data/entities";
|
||||||
import { primaryFieldTypes } from "./fields";
|
import { primaryFieldTypes, baseFieldConfigSchema } from "./fields";
|
||||||
import { s } from "bknd/utils";
|
import { s } from "bknd/utils";
|
||||||
|
import { $object, $record } from "modules/mcp";
|
||||||
|
|
||||||
export const FIELDS = {
|
export const FIELDS = {
|
||||||
...FieldClassMap,
|
...FieldClassMap,
|
||||||
...RelationFieldClassMap,
|
...RelationFieldClassMap,
|
||||||
media: { schema: mediaFieldConfigSchema, field: MediaField },
|
media: { schema: mediaFieldConfigSchema, field: MediaField },
|
||||||
};
|
};
|
||||||
|
export const FIELD_TYPES = Object.keys(FIELDS);
|
||||||
export type FieldType = keyof typeof FIELDS;
|
export type FieldType = keyof typeof FIELDS;
|
||||||
|
|
||||||
export const RELATIONS = RelationClassMap;
|
export const RELATIONS = RelationClassMap;
|
||||||
@@ -28,17 +30,30 @@ export const fieldsSchemaObject = objectTransform(FIELDS, (field, name) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
export const fieldsSchema = s.anyOf(Object.values(fieldsSchemaObject));
|
export const fieldsSchema = s.anyOf(Object.values(fieldsSchemaObject));
|
||||||
export const entityFields = s.record(fieldsSchema);
|
export const entityFields = s.record(fieldsSchema, { default: {} });
|
||||||
export type TAppDataField = s.Static<typeof fieldsSchema>;
|
export type TAppDataField = s.Static<typeof fieldsSchema>;
|
||||||
export type TAppDataEntityFields = s.Static<typeof entityFields>;
|
export type TAppDataEntityFields = s.Static<typeof entityFields>;
|
||||||
|
|
||||||
export const entitiesSchema = s.strictObject({
|
export const entitiesSchema = s.strictObject({
|
||||||
name: s.string().optional(), // @todo: verify, old schema wasn't strict (req in UI)
|
name: s.string().optional(), // @todo: verify, old schema wasn't strict (req in UI)
|
||||||
type: s.string({ enum: entityTypes, default: "regular" }),
|
type: s.string({ enum: entityTypes, default: "regular" }).optional(),
|
||||||
config: entityConfigSchema,
|
config: entityConfigSchema.optional(),
|
||||||
fields: entityFields,
|
fields: entityFields.optional(),
|
||||||
});
|
});
|
||||||
export type TAppDataEntity = s.Static<typeof entitiesSchema>;
|
export type TAppDataEntity = s.Static<typeof entitiesSchema>;
|
||||||
|
export const simpleEntitiesSchema = s.strictObject({
|
||||||
|
type: s.string({ enum: entityTypes, default: "regular" }).optional(),
|
||||||
|
config: entityConfigSchema.optional(),
|
||||||
|
fields: s
|
||||||
|
.record(
|
||||||
|
s.object({
|
||||||
|
type: s.anyOf([s.string({ enum: FIELD_TYPES }), s.string()]),
|
||||||
|
config: baseFieldConfigSchema.optional(),
|
||||||
|
}),
|
||||||
|
{ default: {} },
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
export const relationsSchema = Object.entries(RelationClassMap).map(([name, relationClass]) => {
|
export const relationsSchema = Object.entries(RelationClassMap).map(([name, relationClass]) => {
|
||||||
return s.strictObject(
|
return s.strictObject(
|
||||||
@@ -61,12 +76,27 @@ export const indicesSchema = s.strictObject({
|
|||||||
unique: s.boolean({ default: false }).optional(),
|
unique: s.boolean({ default: false }).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const dataConfigSchema = s.strictObject({
|
export const dataConfigSchema = $object("config_data", {
|
||||||
basepath: s.string({ default: "/api/data" }).optional(),
|
basepath: s.string({ default: "/api/data" }).optional(),
|
||||||
default_primary_format: s.string({ enum: primaryFieldTypes, default: "integer" }).optional(),
|
default_primary_format: s.string({ enum: primaryFieldTypes, default: "integer" }).optional(),
|
||||||
entities: s.record(entitiesSchema, { default: {} }).optional(),
|
entities: $record("config_data_entities", entitiesSchema, { default: {} }).optional(),
|
||||||
relations: s.record(s.anyOf(relationsSchema), { default: {} }).optional(),
|
relations: $record(
|
||||||
indices: s.record(indicesSchema, { default: {} }).optional(),
|
"config_data_relations",
|
||||||
});
|
s.anyOf(relationsSchema),
|
||||||
|
{
|
||||||
|
default: {},
|
||||||
|
},
|
||||||
|
s.strictObject({
|
||||||
|
type: s.string({ enum: Object.keys(RelationClassMap) }),
|
||||||
|
source: s.string(),
|
||||||
|
target: s.string(),
|
||||||
|
config: s.object({}).optional(),
|
||||||
|
}),
|
||||||
|
).optional(),
|
||||||
|
indices: $record("config_data_indices", indicesSchema, {
|
||||||
|
default: {},
|
||||||
|
mcp: { update: false },
|
||||||
|
}).optional(),
|
||||||
|
}).strict();
|
||||||
|
|
||||||
export type AppDataConfig = s.Static<typeof dataConfigSchema>;
|
export type AppDataConfig = s.Static<typeof dataConfigSchema>;
|
||||||
|
|||||||
@@ -10,14 +10,17 @@ import {
|
|||||||
|
|
||||||
// @todo: entity must be migrated to typebox
|
// @todo: entity must be migrated to typebox
|
||||||
export const entityConfigSchema = s
|
export const entityConfigSchema = s
|
||||||
.strictObject({
|
.strictObject(
|
||||||
name: s.string(),
|
{
|
||||||
name_singular: s.string(),
|
name: s.string(),
|
||||||
description: s.string(),
|
name_singular: s.string(),
|
||||||
sort_field: s.string({ default: config.data.default_primary_field }),
|
description: s.string(),
|
||||||
sort_dir: s.string({ enum: ["asc", "desc"], default: "asc" }),
|
sort_field: s.string({ default: config.data.default_primary_field }),
|
||||||
primary_format: s.string({ enum: primaryFieldTypes }),
|
sort_dir: s.string({ enum: ["asc", "desc"], default: "asc" }),
|
||||||
})
|
primary_format: s.string({ enum: primaryFieldTypes }),
|
||||||
|
},
|
||||||
|
{ default: {} },
|
||||||
|
)
|
||||||
.partial();
|
.partial();
|
||||||
|
|
||||||
export type EntityConfig = s.Static<typeof entityConfigSchema>;
|
export type EntityConfig = s.Static<typeof entityConfigSchema>;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Entity, EntityManager, TEntityType } from "data/entities";
|
import type { Entity, EntityManager, TEntityType } from "data/entities";
|
||||||
import type { EntityRelation } from "data/relations";
|
import type { EntityRelation } from "data/relations";
|
||||||
import { autoFormatString } from "core/utils";
|
import { autoFormatString } from "bknd/utils";
|
||||||
import { usersFields } from "auth/auth-entities";
|
import { usersFields } from "auth/auth-entities";
|
||||||
import { mediaFields } from "media/media-entities";
|
import { mediaFields } from "media/media-entities";
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { isDebug } from "core/env";
|
import { isDebug } from "core/env";
|
||||||
import { pick } from "core/utils";
|
import { pick } from "bknd/utils";
|
||||||
import type { Connection } from "data/connection";
|
import type { Connection } from "data/connection";
|
||||||
import type {
|
import type {
|
||||||
Compilable,
|
Compilable,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { $console } from "core/utils";
|
import { $console } from "bknd/utils";
|
||||||
import type { Entity, EntityData } from "../Entity";
|
import type { Entity, EntityData } from "../Entity";
|
||||||
import type { EntityManager } from "../EntityManager";
|
import type { EntityManager } from "../EntityManager";
|
||||||
import { Result, type ResultJSON, type ResultOptions } from "../Result";
|
import { Result, type ResultJSON, type ResultOptions } from "../Result";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { DB as DefaultDB, PrimaryFieldType } from "bknd";
|
import type { DB as DefaultDB, PrimaryFieldType } from "bknd";
|
||||||
import { $console } from "core/utils";
|
import { $console } from "bknd/utils";
|
||||||
import { type EmitsEvents, EventManager } from "core/events";
|
import { type EmitsEvents, EventManager } from "core/events";
|
||||||
import { type SelectQueryBuilder, sql } from "kysely";
|
import { type SelectQueryBuilder, sql } from "kysely";
|
||||||
import { InvalidSearchParamsException } from "../../errors";
|
import { InvalidSearchParamsException } from "../../errors";
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { Entity, EntityData } from "../Entity";
|
|||||||
import type { EntityManager } from "../EntityManager";
|
import type { EntityManager } from "../EntityManager";
|
||||||
import { Result, type ResultJSON, type ResultOptions } from "../Result";
|
import { Result, type ResultJSON, type ResultOptions } from "../Result";
|
||||||
import type { Compilable, SelectQueryBuilder } from "kysely";
|
import type { Compilable, SelectQueryBuilder } from "kysely";
|
||||||
import { $console, ensureInt } from "core/utils";
|
import { $console, ensureInt } from "bknd/utils";
|
||||||
|
|
||||||
export type RepositoryResultOptions = ResultOptions & {
|
export type RepositoryResultOptions = ResultOptions & {
|
||||||
silent?: boolean;
|
silent?: boolean;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { isObject } from "core/utils";
|
import { isObject } from "bknd/utils";
|
||||||
|
|
||||||
import type { KyselyJsonFrom } from "data/relations/EntityRelation";
|
import type { KyselyJsonFrom } from "data/relations/EntityRelation";
|
||||||
import type { RepoQuery } from "data/server/query";
|
import type { RepoQuery } from "data/server/query";
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { omitKeys } from "core/utils";
|
import { omitKeys, s } from "bknd/utils";
|
||||||
import type { EntityManager } from "data/entities";
|
import type { EntityManager } from "data/entities";
|
||||||
import { TransformPersistFailedException } from "../errors";
|
import { TransformPersistFailedException } from "../errors";
|
||||||
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
|
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
|
||||||
import { s } from "bknd/utils";
|
|
||||||
|
|
||||||
export const booleanFieldConfigSchema = s
|
export const booleanFieldConfigSchema = s
|
||||||
.strictObject({
|
.strictObject({
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { dayjs } from "core/utils";
|
import { dayjs, $console, s } from "bknd/utils";
|
||||||
import type { EntityManager } from "../entities";
|
import type { EntityManager } from "../entities";
|
||||||
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
|
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
|
||||||
import { $console } from "core/utils";
|
|
||||||
import type { TFieldTSType } from "data/entities/EntityTypescript";
|
import type { TFieldTSType } from "data/entities/EntityTypescript";
|
||||||
import { s } from "bknd/utils";
|
|
||||||
|
|
||||||
export const dateFieldConfigSchema = s
|
export const dateFieldConfigSchema = s
|
||||||
.strictObject({
|
.strictObject({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { omitKeys } from "core/utils";
|
import { omitKeys } from "bknd/utils";
|
||||||
import type { EntityManager } from "data/entities";
|
import type { EntityManager } from "data/entities";
|
||||||
import { TransformPersistFailedException } from "../errors";
|
import { TransformPersistFailedException } from "../errors";
|
||||||
import { baseFieldConfigSchema, Field, type TActionContext, type TRenderContext } from "./Field";
|
import { baseFieldConfigSchema, Field, type TActionContext, type TRenderContext } from "./Field";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { omitKeys } from "core/utils";
|
import { omitKeys } from "bknd/utils";
|
||||||
import type { EntityManager } from "data/entities";
|
import type { EntityManager } from "data/entities";
|
||||||
import { TransformPersistFailedException } from "../errors";
|
import { TransformPersistFailedException } from "../errors";
|
||||||
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
|
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { type Schema as JsonSchema, Validator } from "@cfworker/json-schema";
|
import { type Schema as JsonSchema, Validator } from "@cfworker/json-schema";
|
||||||
import { objectToJsLiteral } from "core/utils";
|
import { objectToJsLiteral } from "bknd/utils";
|
||||||
import type { EntityManager } from "data/entities";
|
import type { EntityManager } from "data/entities";
|
||||||
import { TransformPersistFailedException } from "../errors";
|
import { TransformPersistFailedException } from "../errors";
|
||||||
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
|
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import type { EntityManager } from "data/entities";
|
|||||||
import { TransformPersistFailedException } from "../errors";
|
import { TransformPersistFailedException } from "../errors";
|
||||||
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
|
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
|
||||||
import type { TFieldTSType } from "data/entities/EntityTypescript";
|
import type { TFieldTSType } from "data/entities/EntityTypescript";
|
||||||
import { s } from "bknd/utils";
|
import { s, omitKeys } from "bknd/utils";
|
||||||
import { omitKeys } from "core/utils";
|
|
||||||
|
|
||||||
export const numberFieldConfigSchema = s
|
export const numberFieldConfigSchema = s
|
||||||
.strictObject({
|
.strictObject({
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import type { EntityManager } from "data/entities";
|
import type { EntityManager } from "data/entities";
|
||||||
import { omitKeys } from "core/utils";
|
import { omitKeys, s } from "bknd/utils";
|
||||||
import { TransformPersistFailedException } from "../errors";
|
import { TransformPersistFailedException } from "../errors";
|
||||||
import { Field, type TActionContext, baseFieldConfigSchema } from "./Field";
|
import { Field, type TActionContext, baseFieldConfigSchema } from "./Field";
|
||||||
import { s } from "bknd/utils";
|
|
||||||
|
|
||||||
export const textFieldConfigSchema = s
|
export const textFieldConfigSchema = s
|
||||||
.strictObject({
|
.strictObject({
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ import {
|
|||||||
type PolymorphicRelationConfig,
|
type PolymorphicRelationConfig,
|
||||||
} from "data/relations";
|
} from "data/relations";
|
||||||
|
|
||||||
|
import type { MediaFields } from "media/AppMedia";
|
||||||
|
import type { UsersFields } from "auth/AppAuth";
|
||||||
|
|
||||||
type Options<Config = any> = {
|
type Options<Config = any> = {
|
||||||
entity: { name: string; fields: Record<string, Field<any, any, any>> };
|
entity: { name: string; fields: Record<string, Field<any, any, any>> };
|
||||||
field_name: string;
|
field_name: string;
|
||||||
@@ -199,6 +202,18 @@ export function entity<
|
|||||||
return new Entity(name, _fields, config, type);
|
return new Entity(name, _fields, config, type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SystemEntities = {
|
||||||
|
users: UsersFields;
|
||||||
|
media: MediaFields;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function systemEntity<
|
||||||
|
E extends keyof SystemEntities,
|
||||||
|
Fields extends Record<string, Field<any, any, any>>,
|
||||||
|
>(name: E, fields: Fields) {
|
||||||
|
return entity<E, SystemEntities[E] & Fields>(name, fields as any);
|
||||||
|
}
|
||||||
|
|
||||||
export function relation<Local extends Entity>(local: Local) {
|
export function relation<Local extends Entity>(local: Local) {
|
||||||
return {
|
return {
|
||||||
manyToOne: <Foreign extends Entity>(foreign: Foreign, config?: ManyToOneRelationConfig) => {
|
manyToOne: <Foreign extends Entity>(foreign: Foreign, config?: ManyToOneRelationConfig) => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { transformObject } from "core/utils";
|
import { transformObject } from "bknd/utils";
|
||||||
import { Entity } from "data/entities";
|
import { Entity } from "data/entities";
|
||||||
import type { Field } from "data/fields";
|
import type { Field } from "data/fields";
|
||||||
import { FIELDS, RELATIONS, type TAppDataEntity, type TAppDataRelation } from "data/data-schema";
|
import { FIELDS, RELATIONS, type TAppDataEntity, type TAppDataRelation } from "data/data-schema";
|
||||||
|
|||||||
@@ -26,9 +26,6 @@ describe("server/query", () => {
|
|||||||
expect(parse({ select: "id,title" })).toEqual({ select: ["id", "title"] });
|
expect(parse({ select: "id,title" })).toEqual({ select: ["id", "title"] });
|
||||||
expect(parse({ select: "id,title,desc" })).toEqual({ select: ["id", "title", "desc"] });
|
expect(parse({ select: "id,title,desc" })).toEqual({ select: ["id", "title", "desc"] });
|
||||||
expect(parse({ select: ["id", "title"] })).toEqual({ select: ["id", "title"] });
|
expect(parse({ select: ["id", "title"] })).toEqual({ select: ["id", "title"] });
|
||||||
|
|
||||||
expect(() => parse({ select: "not allowed" })).toThrow();
|
|
||||||
expect(() => parse({ select: "id," })).toThrow();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("join", () => {
|
test("join", () => {
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { s } from "bknd/utils";
|
import { s, isObject, $console } from "bknd/utils";
|
||||||
import { WhereBuilder, type WhereQuery } from "data/entities/query/WhereBuilder";
|
import { WhereBuilder, type WhereQuery } from "data/entities/query/WhereBuilder";
|
||||||
import { isObject, $console } from "core/utils";
|
|
||||||
import type { anyOf, CoercionOptions, Schema } from "jsonv-ts";
|
|
||||||
|
|
||||||
// -------
|
// -------
|
||||||
// helpers
|
// helpers
|
||||||
const stringIdentifier = s.string({
|
const stringIdentifier = s.string({
|
||||||
// allow "id", "id,title" – but not "id," or "not allowed"
|
// allow "id", "id,title" – but not "id," or "not allowed"
|
||||||
pattern: "^(?:[a-zA-Z_$][\\w$]*)(?:,[a-zA-Z_$][\\w$]*)*$",
|
//pattern: "^(?:[a-zA-Z_$][\\w$]*)(?:,[a-zA-Z_$][\\w$]*)*$",
|
||||||
});
|
});
|
||||||
const stringArray = s.anyOf(
|
const stringArray = s.anyOf(
|
||||||
[
|
[
|
||||||
@@ -25,7 +23,7 @@ const stringArray = s.anyOf(
|
|||||||
if (v.includes(",")) {
|
if (v.includes(",")) {
|
||||||
return v.split(",");
|
return v.split(",");
|
||||||
}
|
}
|
||||||
return [v];
|
return [v].filter(Boolean);
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
},
|
},
|
||||||
@@ -80,6 +78,8 @@ const where = s.anyOf([s.string(), s.object({})], {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
coerce: (value: unknown) => {
|
coerce: (value: unknown) => {
|
||||||
|
if (value === undefined || value === null || value === "") return {};
|
||||||
|
|
||||||
const q = typeof value === "string" ? JSON.parse(value) : value;
|
const q = typeof value === "string" ? JSON.parse(value) : value;
|
||||||
return WhereBuilder.convert(q);
|
return WhereBuilder.convert(q);
|
||||||
},
|
},
|
||||||
@@ -97,9 +97,9 @@ export type RepoWithSchema = Record<
|
|||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
|
||||||
const withSchema = <Type = unknown>(self: Schema): Schema<{}, Type, Type> =>
|
const withSchema = <Type = unknown>(self: s.Schema): s.Schema<{}, Type, Type> =>
|
||||||
s.anyOf([stringIdentifier, s.array(stringIdentifier), self], {
|
s.anyOf([stringIdentifier, s.array(stringIdentifier), self], {
|
||||||
coerce: function (this: typeof anyOf, _value: unknown, opts: CoercionOptions = {}) {
|
coerce: function (this: typeof s.anyOf, _value: unknown, opts: s.CoercionOptions = {}) {
|
||||||
let value: any = _value;
|
let value: any = _value;
|
||||||
|
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { transformObject } from "core/utils";
|
import { transformObject, s } from "bknd/utils";
|
||||||
import { TaskMap, TriggerMap } from "flows";
|
import { TaskMap, TriggerMap } from "flows";
|
||||||
import { s } from "bknd/utils";
|
|
||||||
|
|
||||||
export const TASKS = {
|
export const TASKS = {
|
||||||
...TaskMap,
|
...TaskMap,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Event, EventManager, type ListenerHandler } from "core/events";
|
|||||||
import type { EmitsEvents } from "core/events";
|
import type { EmitsEvents } from "core/events";
|
||||||
import type { Task, TaskResult } from "../tasks/Task";
|
import type { Task, TaskResult } from "../tasks/Task";
|
||||||
import type { Flow } from "./Flow";
|
import type { Flow } from "./Flow";
|
||||||
import { $console } from "core/utils";
|
import { $console } from "bknd/utils";
|
||||||
|
|
||||||
export type TaskLog = TaskResult & {
|
export type TaskLog = TaskResult & {
|
||||||
task: Task;
|
task: Task;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { $console, transformObject } from "core/utils";
|
import { $console, transformObject } from "bknd/utils";
|
||||||
import { type TaskMapType, TriggerMap } from "../index";
|
import { type TaskMapType, TriggerMap } from "../index";
|
||||||
import type { Task } from "../tasks/Task";
|
import type { Task } from "../tasks/Task";
|
||||||
import { Condition, TaskConnection } from "../tasks/TaskConnection";
|
import { Condition, TaskConnection } from "../tasks/TaskConnection";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Task } from "../../tasks/Task";
|
import type { Task } from "../../tasks/Task";
|
||||||
import { $console } from "core/utils";
|
import { $console } from "bknd/utils";
|
||||||
|
|
||||||
export class RuntimeExecutor {
|
export class RuntimeExecutor {
|
||||||
async run(
|
async run(
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import type { EventManager } from "core/events";
|
import type { EventManager } from "core/events";
|
||||||
import type { Flow } from "../Flow";
|
import type { Flow } from "../Flow";
|
||||||
import { Trigger } from "./Trigger";
|
import { Trigger } from "./Trigger";
|
||||||
import { $console } from "core/utils";
|
import { $console, s } from "bknd/utils";
|
||||||
import { s } from "bknd/utils";
|
|
||||||
|
|
||||||
export class EventTrigger extends Trigger<typeof EventTrigger.schema> {
|
export class EventTrigger extends Trigger<typeof EventTrigger.schema> {
|
||||||
override type = "event";
|
override type = "event";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { objectCleanEmpty, uuid } from "core/utils";
|
import { objectCleanEmpty, uuid } from "bknd/utils";
|
||||||
import { get } from "lodash-es";
|
import { get } from "lodash-es";
|
||||||
import type { Task, TaskResult } from "./Task";
|
import type { Task, TaskResult } from "./Task";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Task } from "../Task";
|
import { Task } from "../Task";
|
||||||
import { $console } from "core/utils";
|
import { $console, s } from "bknd/utils";
|
||||||
import { s } from "bknd/utils";
|
|
||||||
|
|
||||||
export class LogTask extends Task<typeof LogTask.schema> {
|
export class LogTask extends Task<typeof LogTask.schema> {
|
||||||
type = "log";
|
type = "log";
|
||||||
|
|||||||
@@ -35,10 +35,12 @@ export type { BkndConfig } from "bknd/adapter";
|
|||||||
|
|
||||||
export * as middlewares from "modules/middlewares";
|
export * as middlewares from "modules/middlewares";
|
||||||
export { registries } from "modules/registries";
|
export { registries } from "modules/registries";
|
||||||
|
export { getSystemMcp } from "modules/mcp/system-mcp";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Core
|
* Core
|
||||||
*/
|
*/
|
||||||
|
export type { MaybePromise } from "core/types";
|
||||||
export { Exception, BkndError } from "core/errors";
|
export { Exception, BkndError } from "core/errors";
|
||||||
export { isDebug, env } from "core/env";
|
export { isDebug, env } from "core/env";
|
||||||
export { type PrimaryFieldType, config, type DB, type AppEntity } from "core/config";
|
export { type PrimaryFieldType, config, type DB, type AppEntity } from "core/config";
|
||||||
@@ -130,6 +132,7 @@ export {
|
|||||||
BaseIntrospector,
|
BaseIntrospector,
|
||||||
Connection,
|
Connection,
|
||||||
customIntrospector,
|
customIntrospector,
|
||||||
|
DummyConnection,
|
||||||
type FieldSpec,
|
type FieldSpec,
|
||||||
type IndexSpec,
|
type IndexSpec,
|
||||||
type DbFunctions,
|
type DbFunctions,
|
||||||
@@ -154,6 +157,7 @@ export {
|
|||||||
medium,
|
medium,
|
||||||
make,
|
make,
|
||||||
entity,
|
entity,
|
||||||
|
systemEntity,
|
||||||
relation,
|
relation,
|
||||||
index,
|
index,
|
||||||
em,
|
em,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { AppEntity, FileUploadedEventData, StorageAdapter } from "bknd";
|
import type { AppEntity, FileUploadedEventData, StorageAdapter } from "bknd";
|
||||||
import { $console } from "core/utils";
|
import { $console } from "bknd/utils";
|
||||||
import type { Entity, EntityManager } from "data/entities";
|
import type { Entity, EntityManager } from "data/entities";
|
||||||
import { Storage } from "media/storage/Storage";
|
import { Storage } from "media/storage/Storage";
|
||||||
import { Module } from "modules/Module";
|
import { Module } from "modules/Module";
|
||||||
@@ -9,6 +9,7 @@ import { buildMediaSchema, registry, type TAppMediaConfig } from "./media-schema
|
|||||||
import { mediaFields } from "./media-entities";
|
import { mediaFields } from "./media-entities";
|
||||||
import * as MediaPermissions from "media/media-permissions";
|
import * as MediaPermissions from "media/media-permissions";
|
||||||
|
|
||||||
|
export type MediaFields = typeof AppMedia.mediaFields;
|
||||||
export type MediaFieldSchema = FieldSchema<typeof AppMedia.mediaFields>;
|
export type MediaFieldSchema = FieldSchema<typeof AppMedia.mediaFields>;
|
||||||
declare module "bknd" {
|
declare module "bknd" {
|
||||||
interface Media extends AppEntity, MediaFieldSchema {}
|
interface Media extends AppEntity, MediaFieldSchema {}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { MediaAdapters } from "media/media-registry";
|
import { MediaAdapters } from "media/media-registry";
|
||||||
import { registries } from "modules/registries";
|
import { registries } from "modules/registries";
|
||||||
import { s, objectTransform } from "bknd/utils";
|
import { s, objectTransform } from "bknd/utils";
|
||||||
|
import { $object, $record, $schema } from "modules/mcp";
|
||||||
|
|
||||||
export const ADAPTERS = {
|
export const ADAPTERS = {
|
||||||
...MediaAdapters,
|
...MediaAdapters,
|
||||||
@@ -22,7 +23,8 @@ export function buildMediaSchema() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return s.strictObject(
|
return $object(
|
||||||
|
"config_media",
|
||||||
{
|
{
|
||||||
enabled: s.boolean({ default: false }),
|
enabled: s.boolean({ default: false }),
|
||||||
basepath: s.string({ default: "/api/media" }),
|
basepath: s.string({ default: "/api/media" }),
|
||||||
@@ -37,7 +39,11 @@ export function buildMediaSchema() {
|
|||||||
},
|
},
|
||||||
{ default: {} },
|
{ default: {} },
|
||||||
),
|
),
|
||||||
adapter: s.anyOf(Object.values(adapterSchemaObject)).optional(),
|
// @todo: currently cannot be updated partially using mcp
|
||||||
|
adapter: $schema(
|
||||||
|
"config_media_adapter",
|
||||||
|
s.anyOf(Object.values(adapterSchemaObject)),
|
||||||
|
).optional(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
default: {},
|
default: {},
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { type EmitsEvents, EventManager } from "core/events";
|
import { type EmitsEvents, EventManager } from "core/events";
|
||||||
import { $console, isFile, detectImageDimensions } from "core/utils";
|
import { $console, isFile, detectImageDimensions } from "bknd/utils";
|
||||||
import { isMimeType } from "media/storage/mime-types-tiny";
|
import { isMimeType } from "media/storage/mime-types-tiny";
|
||||||
import * as StorageEvents from "./events";
|
import * as StorageEvents from "./events";
|
||||||
import type { FileUploadedEventData } from "./events";
|
import type { FileUploadedEventData } from "./events";
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user