mirror of
https://github.com/bknd-io/bknd/
synced 2026-08-02 08:06:00 +00:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db58911df3 | |||
| 2540c83382 | |||
| 569d021316 | |||
| ba9713587c | |||
| eecaeb7c06 | |||
| 610e263477 | |||
| 0f54e8267f | |||
| 1eeb23232a | |||
| 6102759da8 | |||
| 6cea581e42 | |||
| 5e553a7fce | |||
| 5e71fc8947 | |||
| d1ba638cd5 | |||
| d31416f85d | |||
| 55082e9d0e | |||
| 0d74625270 | |||
| 800f14ede2 | |||
| 560379bd89 | |||
| daafee2c06 | |||
| ab3e8ce55f | |||
| a655c990ed | |||
| 2c976adb77 | |||
| 1128ac500d | |||
| 832eb6ac31 | |||
| aa8bf156b0 | |||
| 83c1c86eff | |||
| ffe53d3fb5 | |||
| 49aee37199 | |||
| 54eee8cd34 | |||
| 5e62e681e7 | |||
| 99c1645411 | |||
| 564eab23af | |||
| f2da54c92b | |||
| cd262097dc | |||
| d1726b23f1 |
@@ -20,7 +20,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: "1.2.19"
|
||||
bun-version: "1.2.22"
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./app
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
hello
|
||||
@@ -16,6 +16,7 @@ describe("AppServer", () => {
|
||||
mcp: {
|
||||
enabled: false,
|
||||
path: "/api/system/mcp",
|
||||
logLevel: "warning",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -38,6 +39,7 @@ describe("AppServer", () => {
|
||||
mcp: {
|
||||
enabled: false,
|
||||
path: "/api/system/mcp",
|
||||
logLevel: "warning",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, mock, test } from "bun:test";
|
||||
import { createApp as internalCreateApp, type CreateAppConfig } from "bknd";
|
||||
import { getDummyConnection } from "../../__test__/helper";
|
||||
import { ModuleManager } from "modules/ModuleManager";
|
||||
import { em, entity, text } from "data/prototype";
|
||||
|
||||
async function createApp(config: CreateAppConfig = {}) {
|
||||
const app = internalCreateApp({
|
||||
connection: getDummyConnection().dummyConnection,
|
||||
...config,
|
||||
options: {
|
||||
...config.options,
|
||||
mode: "code",
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("code-only", () => {
|
||||
test("should create app with correct manager", async () => {
|
||||
const app = await createApp();
|
||||
await app.build();
|
||||
|
||||
expect(app.version()).toBeDefined();
|
||||
expect(app.modules).toBeInstanceOf(ModuleManager);
|
||||
});
|
||||
|
||||
test("should not perform database syncs", async () => {
|
||||
const app = await createApp({
|
||||
config: {
|
||||
data: em({
|
||||
test: entity("test", {
|
||||
name: text(),
|
||||
}),
|
||||
}).toJSON(),
|
||||
},
|
||||
});
|
||||
expect(app.em.entities.map((e) => e.name)).toEqual(["test"]);
|
||||
expect(
|
||||
await app.em.connection.kysely
|
||||
.selectFrom("sqlite_master")
|
||||
.where("type", "=", "table")
|
||||
.selectAll()
|
||||
.execute(),
|
||||
).toEqual([]);
|
||||
|
||||
// only perform when explicitly forced
|
||||
await app.em.schema().sync({ force: true });
|
||||
expect(
|
||||
await app.em.connection.kysely
|
||||
.selectFrom("sqlite_master")
|
||||
.where("type", "=", "table")
|
||||
.selectAll()
|
||||
.execute()
|
||||
.then((r) => r.map((r) => r.name)),
|
||||
).toEqual(["test", "sqlite_sequence"]);
|
||||
});
|
||||
|
||||
test("should not perform seeding", async () => {
|
||||
const called = mock(() => null);
|
||||
const app = await createApp({
|
||||
config: {
|
||||
data: em({
|
||||
test: entity("test", {
|
||||
name: text(),
|
||||
}),
|
||||
}).toJSON(),
|
||||
},
|
||||
options: {
|
||||
seed: async (ctx) => {
|
||||
called();
|
||||
await ctx.em.mutator("test").insertOne({ name: "test" });
|
||||
},
|
||||
},
|
||||
});
|
||||
await app.em.schema().sync({ force: true });
|
||||
expect(called).not.toHaveBeenCalled();
|
||||
expect(
|
||||
await app.em
|
||||
.repo("test")
|
||||
.findMany({})
|
||||
.then((r) => r.data),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("should sync and perform seeding", async () => {
|
||||
const called = mock(() => null);
|
||||
const app = await createApp({
|
||||
config: {
|
||||
data: em({
|
||||
test: entity("test", {
|
||||
name: text(),
|
||||
}),
|
||||
}).toJSON(),
|
||||
},
|
||||
options: {
|
||||
seed: async (ctx) => {
|
||||
called();
|
||||
await ctx.em.mutator("test").insertOne({ name: "test" });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await app.em.schema().sync({ force: true });
|
||||
await app.options?.seed?.({
|
||||
...app.modules.ctx(),
|
||||
app: app,
|
||||
});
|
||||
expect(called).toHaveBeenCalled();
|
||||
expect(
|
||||
await app.em
|
||||
.repo("test")
|
||||
.findMany({})
|
||||
.then((r) => r.data),
|
||||
).toEqual([{ id: 1, name: "test" }]);
|
||||
});
|
||||
|
||||
test("should not allow to modify config", async () => {
|
||||
const app = await createApp();
|
||||
// biome-ignore lint/suspicious/noPrototypeBuiltins: <explanation>
|
||||
expect(app.modules.hasOwnProperty("mutateConfigSafe")).toBe(false);
|
||||
expect(() => {
|
||||
app.modules.configs().auth.enabled = true;
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,7 @@ describe("mcp auth", async () => {
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
await app.getMcpClient().ping();
|
||||
server = app.mcp!;
|
||||
server.setLogLevel("error");
|
||||
server.onNotification((message) => {
|
||||
|
||||
@@ -34,6 +34,11 @@ describe("mcp", () => {
|
||||
});
|
||||
await app.build();
|
||||
|
||||
// expect mcp to not be loaded yet
|
||||
expect(app.mcp).toBeNull();
|
||||
|
||||
// after first request, mcp should be loaded
|
||||
await app.getMcpClient().listTools();
|
||||
expect(app.mcp?.tools.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,6 +50,7 @@ describe("mcp data", async () => {
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
await app.getMcpClient().ping();
|
||||
server = app.mcp!;
|
||||
server.setLogLevel("error");
|
||||
server.onNotification((message) => {
|
||||
|
||||
@@ -39,6 +39,7 @@ describe("mcp media", async () => {
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
await app.getMcpClient().ping();
|
||||
server = app.mcp!;
|
||||
server.setLogLevel("error");
|
||||
server.onNotification((message) => {
|
||||
|
||||
@@ -24,6 +24,7 @@ describe("mcp system", async () => {
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
await app.getMcpClient().ping();
|
||||
server = app.mcp!;
|
||||
});
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ describe("mcp system", async () => {
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
await app.getMcpClient().ping();
|
||||
server = app.mcp!;
|
||||
});
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { assetsPath, assetsTmpPath } from "../helper";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
beforeAll(() => {
|
||||
disableConsoleLog();
|
||||
//disableConsoleLog();
|
||||
registries.media.register("local", StorageLocalAdapter);
|
||||
});
|
||||
afterAll(enableConsoleLog);
|
||||
@@ -94,4 +94,38 @@ describe("MediaController", () => {
|
||||
expect(res.status).toBe(413);
|
||||
expect(await Bun.file(assetsTmpPath + "/" + name).exists()).toBe(false);
|
||||
});
|
||||
|
||||
test("audio files", async () => {
|
||||
const app = await makeApp();
|
||||
const file = Bun.file(`${assetsPath}/test.mp3`);
|
||||
const name = makeName("mp3");
|
||||
const res = await app.server.request("/api/media/upload/" + name, {
|
||||
method: "POST",
|
||||
body: file,
|
||||
});
|
||||
const result = (await res.json()) as any;
|
||||
expect(result.data.mime_type).toStartWith("audio/mpeg");
|
||||
expect(result.name).toBe(name);
|
||||
|
||||
const destFile = Bun.file(assetsTmpPath + "/" + name);
|
||||
expect(destFile.exists()).resolves.toBe(true);
|
||||
await destFile.delete();
|
||||
});
|
||||
|
||||
test("text files", async () => {
|
||||
const app = await makeApp();
|
||||
const file = Bun.file(`${assetsPath}/test.txt`);
|
||||
const name = makeName("txt");
|
||||
const res = await app.server.request("/api/media/upload/" + name, {
|
||||
method: "POST",
|
||||
body: file,
|
||||
});
|
||||
const result = (await res.json()) as any;
|
||||
expect(result.data.mime_type).toStartWith("text/plain");
|
||||
expect(result.name).toBe(name);
|
||||
|
||||
const destFile = Bun.file(assetsTmpPath + "/" + name);
|
||||
expect(destFile.exists()).resolves.toBe(true);
|
||||
await destFile.delete();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,6 +71,8 @@ describe("media/mime-types", () => {
|
||||
["application/zip", "zip"],
|
||||
["text/tab-separated-values", "tsv"],
|
||||
["application/zip", "zip"],
|
||||
["application/pdf", "pdf"],
|
||||
["audio/mpeg", "mp3"],
|
||||
] as const;
|
||||
|
||||
for (const [mime, ext] of tests) {
|
||||
@@ -88,6 +90,9 @@ describe("media/mime-types", () => {
|
||||
["image.jpeg", "jpeg"],
|
||||
["-473Wx593H-466453554-black-MODEL.jpg", "jpg"],
|
||||
["-473Wx593H-466453554-black-MODEL.avif", "avif"],
|
||||
["file.pdf", "pdf"],
|
||||
["file.mp3", "mp3"],
|
||||
["robots.txt", "txt"],
|
||||
] as const;
|
||||
|
||||
for (const [filename, ext] of tests) {
|
||||
@@ -102,4 +107,36 @@ describe("media/mime-types", () => {
|
||||
const [, ext] = getRandomizedFilename(file).split(".");
|
||||
expect(ext).toBe("jpg");
|
||||
});
|
||||
|
||||
test("getRandomizedFilename with body", async () => {
|
||||
// should keep "pdf"
|
||||
const [, ext] = getRandomizedFilename(
|
||||
new File([""], "file.pdf", { type: "application/pdf" }),
|
||||
).split(".");
|
||||
expect(ext).toBe("pdf");
|
||||
|
||||
{
|
||||
// no ext, should use "pdf" only for known formats
|
||||
const [, ext] = getRandomizedFilename(
|
||||
new File([""], "file", { type: "application/pdf" }),
|
||||
).split(".");
|
||||
expect(ext).toBe("pdf");
|
||||
}
|
||||
|
||||
{
|
||||
// wrong ext, should keep the wrong one
|
||||
const [, ext] = getRandomizedFilename(
|
||||
new File([""], "file.what", { type: "application/pdf" }),
|
||||
).split(".");
|
||||
expect(ext).toBe("what");
|
||||
}
|
||||
|
||||
{
|
||||
// txt
|
||||
const [, ext] = getRandomizedFilename(
|
||||
new File([""], "file.txt", { type: "text/plain" }),
|
||||
).split(".");
|
||||
expect(ext).toBe("txt");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { type InitialModuleConfigs, createApp } from "../../../src";
|
||||
import { App, type InitialModuleConfigs, createApp } from "/";
|
||||
|
||||
import { type Kysely, sql } from "kysely";
|
||||
import { getDummyConnection } from "../../helper";
|
||||
import v7 from "./samples/v7.json";
|
||||
import v8 from "./samples/v8.json";
|
||||
import v8_2 from "./samples/v8-2.json";
|
||||
import v9 from "./samples/v9.json";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
// app expects migratable config to be present in database
|
||||
async function createVersionedApp(config: InitialModuleConfigs | any) {
|
||||
async function createVersionedApp(
|
||||
config: InitialModuleConfigs | any,
|
||||
opts?: { beforeCreateApp?: (db: Kysely<any>) => Promise<void> },
|
||||
) {
|
||||
const { dummyConnection } = getDummyConnection();
|
||||
|
||||
if (!("version" in config)) throw new Error("config must have a version");
|
||||
@@ -38,6 +42,10 @@ async function createVersionedApp(config: InitialModuleConfigs | any) {
|
||||
})
|
||||
.execute();
|
||||
|
||||
if (opts?.beforeCreateApp) {
|
||||
await opts.beforeCreateApp(db);
|
||||
}
|
||||
|
||||
const app = createApp({
|
||||
connection: dummyConnection,
|
||||
});
|
||||
@@ -45,6 +53,19 @@ async function createVersionedApp(config: InitialModuleConfigs | any) {
|
||||
return app;
|
||||
}
|
||||
|
||||
async function getRawConfig(
|
||||
app: App,
|
||||
opts?: { version?: number; types?: ("config" | "diff" | "backup" | "secrets")[] },
|
||||
) {
|
||||
const db = app.em.connection.kysely;
|
||||
return await db
|
||||
.selectFrom("__bknd")
|
||||
.selectAll()
|
||||
.$if(!!opts?.version, (qb) => qb.where("version", "=", opts?.version))
|
||||
.$if((opts?.types?.length ?? 0) > 0, (qb) => qb.where("type", "in", opts?.types))
|
||||
.execute();
|
||||
}
|
||||
|
||||
describe("Migrations", () => {
|
||||
/**
|
||||
* updated auth strategies to have "enabled" prop
|
||||
@@ -82,4 +103,30 @@ describe("Migrations", () => {
|
||||
// @ts-expect-error
|
||||
expect(app.toJSON(true).server.admin).toBeUndefined();
|
||||
});
|
||||
|
||||
test("migration from 9 to 10", async () => {
|
||||
expect(v9.version).toBe(9);
|
||||
|
||||
const app = await createVersionedApp(v9);
|
||||
|
||||
expect(app.version()).toBeGreaterThan(9);
|
||||
// @ts-expect-error
|
||||
expect(app.toJSON(true).media.adapter.config.secret_access_key).toBe(
|
||||
"^^s3.secret_access_key^^",
|
||||
);
|
||||
const [config, secrets] = (await getRawConfig(app, {
|
||||
version: 10,
|
||||
types: ["config", "secrets"],
|
||||
})) as any;
|
||||
|
||||
expect(config.json.auth.jwt.secret).toBe("");
|
||||
expect(config.json.media.adapter.config.access_key).toBe("");
|
||||
expect(config.json.media.adapter.config.secret_access_key).toBe("");
|
||||
|
||||
expect(secrets.json["auth.jwt.secret"]).toBe("^^jwt.secret^^");
|
||||
expect(secrets.json["media.adapter.config.access_key"]).toBe("^^s3.access_key^^");
|
||||
expect(secrets.json["media.adapter.config.secret_access_key"]).toBe(
|
||||
"^^s3.secret_access_key^^",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,612 @@
|
||||
{
|
||||
"version": 9,
|
||||
"server": {
|
||||
"cors": {
|
||||
"origin": "*",
|
||||
"allow_methods": ["GET", "POST", "PATCH", "PUT", "DELETE"],
|
||||
"allow_headers": [
|
||||
"Content-Type",
|
||||
"Content-Length",
|
||||
"Authorization",
|
||||
"Accept"
|
||||
],
|
||||
"allow_credentials": true
|
||||
},
|
||||
"mcp": { "enabled": false, "path": "/api/system/mcp" }
|
||||
},
|
||||
"data": {
|
||||
"basepath": "/api/data",
|
||||
"default_primary_format": "integer",
|
||||
"entities": {
|
||||
"media": {
|
||||
"type": "system",
|
||||
"fields": {
|
||||
"id": {
|
||||
"type": "primary",
|
||||
"config": {
|
||||
"format": "integer",
|
||||
"fillable": false,
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"path": { "type": "text", "config": { "required": true } },
|
||||
"folder": {
|
||||
"type": "boolean",
|
||||
"config": {
|
||||
"default_value": false,
|
||||
"hidden": true,
|
||||
"fillable": ["create"],
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"mime_type": { "type": "text", "config": { "required": false } },
|
||||
"size": { "type": "number", "config": { "required": false } },
|
||||
"scope": {
|
||||
"type": "text",
|
||||
"config": {
|
||||
"hidden": true,
|
||||
"fillable": ["create"],
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"etag": { "type": "text", "config": { "required": false } },
|
||||
"modified_at": {
|
||||
"type": "date",
|
||||
"config": { "type": "datetime", "required": false }
|
||||
},
|
||||
"reference": { "type": "text", "config": { "required": false } },
|
||||
"entity_id": { "type": "number", "config": { "required": false } },
|
||||
"metadata": { "type": "json", "config": { "required": false } }
|
||||
},
|
||||
"config": { "sort_field": "id", "sort_dir": "asc" }
|
||||
},
|
||||
"users": {
|
||||
"type": "system",
|
||||
"fields": {
|
||||
"id": {
|
||||
"type": "primary",
|
||||
"config": {
|
||||
"format": "integer",
|
||||
"fillable": false,
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"email": { "type": "text", "config": { "required": true } },
|
||||
"strategy": {
|
||||
"type": "enum",
|
||||
"config": {
|
||||
"options": { "type": "strings", "values": ["password"] },
|
||||
"required": true,
|
||||
"hidden": ["update", "form"],
|
||||
"fillable": ["create"]
|
||||
}
|
||||
},
|
||||
"strategy_value": {
|
||||
"type": "text",
|
||||
"config": {
|
||||
"fillable": ["create"],
|
||||
"hidden": ["read", "table", "update", "form"],
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"role": {
|
||||
"type": "enum",
|
||||
"config": {
|
||||
"options": { "type": "strings", "values": ["admin", "guest"] },
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"age": {
|
||||
"type": "enum",
|
||||
"config": {
|
||||
"options": {
|
||||
"type": "strings",
|
||||
"values": ["18-24", "25-34", "35-44", "45-64", "65+"]
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"height": { "type": "number", "config": { "required": false } },
|
||||
"gender": {
|
||||
"type": "enum",
|
||||
"config": {
|
||||
"options": { "type": "strings", "values": ["male", "female"] },
|
||||
"required": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"config": { "sort_field": "id", "sort_dir": "asc" }
|
||||
},
|
||||
"avatars": {
|
||||
"type": "regular",
|
||||
"fields": {
|
||||
"id": {
|
||||
"type": "primary",
|
||||
"config": {
|
||||
"format": "integer",
|
||||
"fillable": false,
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"identifier": { "type": "text", "config": { "required": false } },
|
||||
"payload": {
|
||||
"type": "json",
|
||||
"config": { "required": false, "hidden": ["table"] }
|
||||
},
|
||||
"created_at": {
|
||||
"type": "date",
|
||||
"config": { "type": "datetime", "required": false }
|
||||
},
|
||||
"started_at": {
|
||||
"type": "date",
|
||||
"config": { "type": "datetime", "required": false }
|
||||
},
|
||||
"completed_at": {
|
||||
"type": "date",
|
||||
"config": { "type": "datetime", "required": false }
|
||||
},
|
||||
"input": {
|
||||
"type": "media",
|
||||
"config": {
|
||||
"required": false,
|
||||
"fillable": ["update"],
|
||||
"hidden": false,
|
||||
"mime_types": [],
|
||||
"virtual": true,
|
||||
"entity": "avatars"
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"type": "media",
|
||||
"config": {
|
||||
"required": false,
|
||||
"fillable": ["update"],
|
||||
"hidden": false,
|
||||
"mime_types": [],
|
||||
"virtual": true,
|
||||
"entity": "avatars"
|
||||
}
|
||||
},
|
||||
"users_id": {
|
||||
"type": "relation",
|
||||
"config": {
|
||||
"label": "Users",
|
||||
"required": false,
|
||||
"reference": "users",
|
||||
"target": "users",
|
||||
"target_field": "id",
|
||||
"target_field_type": "integer",
|
||||
"on_delete": "set null"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config": { "sort_field": "id", "sort_dir": "desc" }
|
||||
},
|
||||
"tryons": {
|
||||
"type": "regular",
|
||||
"fields": {
|
||||
"id": {
|
||||
"type": "primary",
|
||||
"config": {
|
||||
"format": "integer",
|
||||
"fillable": false,
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"created_at": {
|
||||
"type": "date",
|
||||
"config": { "type": "datetime", "required": false }
|
||||
},
|
||||
"completed_at": {
|
||||
"type": "date",
|
||||
"config": { "type": "datetime", "required": false }
|
||||
},
|
||||
"avatars_id": {
|
||||
"type": "relation",
|
||||
"config": {
|
||||
"label": "Avatars",
|
||||
"required": false,
|
||||
"reference": "avatars",
|
||||
"target": "avatars",
|
||||
"target_field": "id",
|
||||
"target_field_type": "integer",
|
||||
"on_delete": "set null"
|
||||
}
|
||||
},
|
||||
"users_id": {
|
||||
"type": "relation",
|
||||
"config": {
|
||||
"label": "Users",
|
||||
"required": false,
|
||||
"reference": "users",
|
||||
"target": "users",
|
||||
"target_field": "id",
|
||||
"target_field_type": "integer",
|
||||
"on_delete": "set null"
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"type": "media",
|
||||
"config": {
|
||||
"required": false,
|
||||
"fillable": ["update"],
|
||||
"hidden": false,
|
||||
"mime_types": [],
|
||||
"virtual": true,
|
||||
"entity": "tryons",
|
||||
"max_items": 1
|
||||
}
|
||||
},
|
||||
"products_id": {
|
||||
"type": "relation",
|
||||
"config": {
|
||||
"label": "Products",
|
||||
"required": false,
|
||||
"reference": "products",
|
||||
"target": "products",
|
||||
"target_field": "id",
|
||||
"target_field_type": "integer",
|
||||
"on_delete": "set null"
|
||||
}
|
||||
},
|
||||
"payload": {
|
||||
"type": "json",
|
||||
"config": { "required": false, "hidden": ["table"] }
|
||||
}
|
||||
},
|
||||
"config": { "sort_field": "id", "sort_dir": "desc" }
|
||||
},
|
||||
"products": {
|
||||
"type": "regular",
|
||||
"fields": {
|
||||
"id": {
|
||||
"type": "primary",
|
||||
"config": {
|
||||
"format": "integer",
|
||||
"fillable": false,
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"enabled": { "type": "boolean", "config": { "required": false } },
|
||||
"title": { "type": "text", "config": { "required": false } },
|
||||
"url": { "type": "text", "config": { "required": false } },
|
||||
"image": {
|
||||
"type": "media",
|
||||
"config": {
|
||||
"required": false,
|
||||
"fillable": ["update"],
|
||||
"hidden": false,
|
||||
"mime_types": [],
|
||||
"virtual": true,
|
||||
"entity": "products",
|
||||
"max_items": 1
|
||||
}
|
||||
},
|
||||
"created_at": {
|
||||
"type": "date",
|
||||
"config": { "type": "datetime", "required": false }
|
||||
},
|
||||
"sites_id": {
|
||||
"type": "relation",
|
||||
"config": {
|
||||
"label": "Sites",
|
||||
"required": false,
|
||||
"reference": "sites",
|
||||
"target": "sites",
|
||||
"target_field": "id",
|
||||
"target_field_type": "integer",
|
||||
"on_delete": "set null"
|
||||
}
|
||||
},
|
||||
"garment_type": {
|
||||
"type": "enum",
|
||||
"config": {
|
||||
"options": {
|
||||
"type": "strings",
|
||||
"values": ["auto", "tops", "bottoms", "one-pieces"]
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"config": { "sort_field": "id", "sort_dir": "desc" }
|
||||
},
|
||||
"sites": {
|
||||
"type": "regular",
|
||||
"fields": {
|
||||
"id": {
|
||||
"type": "primary",
|
||||
"config": {
|
||||
"format": "integer",
|
||||
"fillable": false,
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"origin": {
|
||||
"type": "text",
|
||||
"config": {
|
||||
"pattern": "^(https?):\\/\\/([a-zA-Z0-9.-]+)(:\\d+)?$",
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"name": { "type": "text", "config": { "required": false } },
|
||||
"active": { "type": "boolean", "config": { "required": false } },
|
||||
"logo": {
|
||||
"type": "media",
|
||||
"config": {
|
||||
"required": false,
|
||||
"fillable": ["update"],
|
||||
"hidden": false,
|
||||
"mime_types": [],
|
||||
"virtual": true,
|
||||
"entity": "sites",
|
||||
"max_items": 1
|
||||
}
|
||||
},
|
||||
"instructions": {
|
||||
"type": "text",
|
||||
"config": {
|
||||
"html_config": {
|
||||
"element": "textarea",
|
||||
"props": { "rows": "2" }
|
||||
},
|
||||
"required": false,
|
||||
"hidden": ["table"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"config": { "sort_field": "id", "sort_dir": "desc" }
|
||||
},
|
||||
"sessions": {
|
||||
"type": "regular",
|
||||
"fields": {
|
||||
"id": {
|
||||
"type": "primary",
|
||||
"config": { "format": "uuid", "fillable": false, "required": false }
|
||||
},
|
||||
"created_at": {
|
||||
"type": "date",
|
||||
"config": { "type": "datetime", "required": true }
|
||||
},
|
||||
"claimed_at": {
|
||||
"type": "date",
|
||||
"config": { "type": "datetime", "required": false }
|
||||
},
|
||||
"url": { "type": "text", "config": { "required": false } },
|
||||
"sites_id": {
|
||||
"type": "relation",
|
||||
"config": {
|
||||
"label": "Sites",
|
||||
"required": false,
|
||||
"reference": "sites",
|
||||
"target": "sites",
|
||||
"target_field": "id",
|
||||
"target_field_type": "integer",
|
||||
"on_delete": "set null"
|
||||
}
|
||||
},
|
||||
"users_id": {
|
||||
"type": "relation",
|
||||
"config": {
|
||||
"label": "Users",
|
||||
"required": false,
|
||||
"reference": "users",
|
||||
"target": "users",
|
||||
"target_field": "id",
|
||||
"target_field_type": "integer",
|
||||
"on_delete": "set null"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config": { "sort_field": "id", "sort_dir": "desc" }
|
||||
}
|
||||
},
|
||||
"relations": {
|
||||
"poly_avatars_media_input": {
|
||||
"type": "poly",
|
||||
"source": "avatars",
|
||||
"target": "media",
|
||||
"config": { "mappedBy": "input" }
|
||||
},
|
||||
"poly_avatars_media_output": {
|
||||
"type": "poly",
|
||||
"source": "avatars",
|
||||
"target": "media",
|
||||
"config": { "mappedBy": "output" }
|
||||
},
|
||||
"n1_avatars_users": {
|
||||
"type": "n:1",
|
||||
"source": "avatars",
|
||||
"target": "users",
|
||||
"config": {
|
||||
"mappedBy": "",
|
||||
"inversedBy": "",
|
||||
"required": false,
|
||||
"with_limit": 5
|
||||
}
|
||||
},
|
||||
"n1_tryons_avatars": {
|
||||
"type": "n:1",
|
||||
"source": "tryons",
|
||||
"target": "avatars",
|
||||
"config": {
|
||||
"mappedBy": "",
|
||||
"inversedBy": "",
|
||||
"required": false,
|
||||
"with_limit": 5
|
||||
}
|
||||
},
|
||||
"n1_tryons_users": {
|
||||
"type": "n:1",
|
||||
"source": "tryons",
|
||||
"target": "users",
|
||||
"config": {
|
||||
"mappedBy": "",
|
||||
"inversedBy": "",
|
||||
"required": false,
|
||||
"with_limit": 5
|
||||
}
|
||||
},
|
||||
"poly_tryons_media_output": {
|
||||
"type": "poly",
|
||||
"source": "tryons",
|
||||
"target": "media",
|
||||
"config": { "mappedBy": "output", "targetCardinality": 1 }
|
||||
},
|
||||
"poly_products_media_image": {
|
||||
"type": "poly",
|
||||
"source": "products",
|
||||
"target": "media",
|
||||
"config": { "mappedBy": "image", "targetCardinality": 1 }
|
||||
},
|
||||
"n1_tryons_products": {
|
||||
"type": "n:1",
|
||||
"source": "tryons",
|
||||
"target": "products",
|
||||
"config": {
|
||||
"mappedBy": "",
|
||||
"inversedBy": "",
|
||||
"required": false,
|
||||
"with_limit": 5
|
||||
}
|
||||
},
|
||||
"poly_sites_media_logo": {
|
||||
"type": "poly",
|
||||
"source": "sites",
|
||||
"target": "media",
|
||||
"config": { "mappedBy": "logo", "targetCardinality": 1 }
|
||||
},
|
||||
"n1_sessions_sites": {
|
||||
"type": "n:1",
|
||||
"source": "sessions",
|
||||
"target": "sites",
|
||||
"config": {
|
||||
"mappedBy": "",
|
||||
"inversedBy": "",
|
||||
"required": false,
|
||||
"with_limit": 5
|
||||
}
|
||||
},
|
||||
"n1_sessions_users": {
|
||||
"type": "n:1",
|
||||
"source": "sessions",
|
||||
"target": "users",
|
||||
"config": {
|
||||
"mappedBy": "",
|
||||
"inversedBy": "",
|
||||
"required": false,
|
||||
"with_limit": 5
|
||||
}
|
||||
},
|
||||
"n1_products_sites": {
|
||||
"type": "n:1",
|
||||
"source": "products",
|
||||
"target": "sites",
|
||||
"config": {
|
||||
"mappedBy": "",
|
||||
"inversedBy": "",
|
||||
"required": false,
|
||||
"with_limit": 5
|
||||
}
|
||||
}
|
||||
},
|
||||
"indices": {
|
||||
"idx_unique_media_path": {
|
||||
"entity": "media",
|
||||
"fields": ["path"],
|
||||
"unique": true
|
||||
},
|
||||
"idx_media_reference": {
|
||||
"entity": "media",
|
||||
"fields": ["reference"],
|
||||
"unique": false
|
||||
},
|
||||
"idx_media_entity_id": {
|
||||
"entity": "media",
|
||||
"fields": ["entity_id"],
|
||||
"unique": false
|
||||
},
|
||||
"idx_unique_users_email": {
|
||||
"entity": "users",
|
||||
"fields": ["email"],
|
||||
"unique": true
|
||||
},
|
||||
"idx_users_strategy": {
|
||||
"entity": "users",
|
||||
"fields": ["strategy"],
|
||||
"unique": false
|
||||
},
|
||||
"idx_users_strategy_value": {
|
||||
"entity": "users",
|
||||
"fields": ["strategy_value"],
|
||||
"unique": false
|
||||
},
|
||||
"idx_sites_origin_active": {
|
||||
"entity": "sites",
|
||||
"fields": ["origin", "active"],
|
||||
"unique": false
|
||||
},
|
||||
"idx_sites_active": {
|
||||
"entity": "sites",
|
||||
"fields": ["active"],
|
||||
"unique": false
|
||||
},
|
||||
"idx_products_url": {
|
||||
"entity": "products",
|
||||
"fields": ["url"],
|
||||
"unique": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"enabled": true,
|
||||
"basepath": "/api/auth",
|
||||
"entity_name": "users",
|
||||
"allow_register": true,
|
||||
"jwt": {
|
||||
"secret": "^^jwt.secret^^",
|
||||
"alg": "HS256",
|
||||
"expires": 999999999,
|
||||
"issuer": "issuer",
|
||||
"fields": ["id", "email", "role"]
|
||||
},
|
||||
"cookie": {
|
||||
"path": "/",
|
||||
"sameSite": "none",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"expires": 604800,
|
||||
"partitioned": false,
|
||||
"renew": true,
|
||||
"pathSuccess": "/admin",
|
||||
"pathLoggedOut": "/"
|
||||
},
|
||||
"strategies": {
|
||||
"password": {
|
||||
"enabled": true,
|
||||
"type": "password",
|
||||
"config": { "hashing": "sha256" }
|
||||
}
|
||||
},
|
||||
"guard": { "enabled": false },
|
||||
"roles": {
|
||||
"admin": { "implicit_allow": true },
|
||||
"guest": { "is_default": true }
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"enabled": true,
|
||||
"basepath": "/api/media",
|
||||
"entity_name": "media",
|
||||
"storage": { "body_max_size": 0 },
|
||||
"adapter": {
|
||||
"type": "s3",
|
||||
"config": {
|
||||
"access_key": "^^s3.access_key^^",
|
||||
"secret_access_key": "^^s3.secret_access_key^^",
|
||||
"url": "https://1234.r2.cloudflarestorage.com/bucket-name"
|
||||
}
|
||||
}
|
||||
},
|
||||
"flows": { "basepath": "/api/flows", "flows": {} }
|
||||
}
|
||||
+1
-1
@@ -4,7 +4,7 @@ import { formatNumber } from "bknd/utils";
|
||||
import * as esbuild from "esbuild";
|
||||
|
||||
const deps = Object.keys(pkg.dependencies);
|
||||
const external = ["jsonv-ts/*", "wrangler", ...deps];
|
||||
const external = ["jsonv-ts/*", "wrangler", "bknd", "bknd/*", ...deps];
|
||||
|
||||
if (process.env.DEBUG) {
|
||||
const result = await esbuild.build({
|
||||
|
||||
@@ -272,6 +272,7 @@ async function buildAdapters() {
|
||||
),
|
||||
tsup.build(
|
||||
baseConfig("cloudflare/proxy", {
|
||||
target: "esnext",
|
||||
entry: ["src/adapter/cloudflare/proxy.ts"],
|
||||
outDir: "dist/adapter/cloudflare",
|
||||
metafile: false,
|
||||
|
||||
+4
-4
@@ -3,7 +3,7 @@
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"bin": "./dist/cli/index.js",
|
||||
"version": "0.18.0-rc.4",
|
||||
"version": "0.18.0",
|
||||
"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",
|
||||
"repository": {
|
||||
@@ -13,7 +13,7 @@
|
||||
"bugs": {
|
||||
"url": "https://github.com/bknd-io/bknd/issues"
|
||||
},
|
||||
"packageManager": "bun@1.2.19",
|
||||
"packageManager": "bun@1.2.22",
|
||||
"engines": {
|
||||
"node": ">=22.13"
|
||||
},
|
||||
@@ -40,7 +40,7 @@
|
||||
"test:coverage": "ALL_TESTS=1 bun test --bail --coverage",
|
||||
"test:vitest:coverage": "vitest run --coverage",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:adapters": "bun run e2e/adapters.ts",
|
||||
"test:e2e:adapters": "NODE_NO_WARNINGS=1 bun run e2e/adapters.ts",
|
||||
"test:e2e:ui": "VITE_DB_URL=:memory: playwright test --ui",
|
||||
"test:e2e:debug": "VITE_DB_URL=:memory: playwright test --debug",
|
||||
"test:e2e:report": "VITE_DB_URL=:memory: playwright show-report",
|
||||
@@ -65,7 +65,7 @@
|
||||
"hono": "4.8.3",
|
||||
"json-schema-library": "10.0.0-rc7",
|
||||
"json-schema-to-ts": "^3.1.1",
|
||||
"jsonv-ts": "0.8.2",
|
||||
"jsonv-ts": "0.8.4",
|
||||
"kysely": "0.27.6",
|
||||
"lodash-es": "^4.17.21",
|
||||
"oauth4webapi": "^2.11.1",
|
||||
|
||||
+7
-3
@@ -244,6 +244,10 @@ export class App<
|
||||
}
|
||||
|
||||
get fetch(): Hono["fetch"] {
|
||||
if (!this.isBuilt()) {
|
||||
throw new Error("App is not built yet, run build() first");
|
||||
}
|
||||
|
||||
return this.server.fetch as any;
|
||||
}
|
||||
|
||||
@@ -302,13 +306,13 @@ export class App<
|
||||
}
|
||||
|
||||
getMcpClient() {
|
||||
if (!this.mcp) {
|
||||
const config = this.modules.get("server").config.mcp;
|
||||
if (!config.enabled) {
|
||||
throw new Error("MCP is not enabled");
|
||||
}
|
||||
const mcpPath = this.modules.get("server").config.mcp.path;
|
||||
|
||||
return new McpClient({
|
||||
url: "http://localhost" + mcpPath,
|
||||
url: "http://localhost" + config.path,
|
||||
fetch: this.server.request,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export {
|
||||
type GetBindingType,
|
||||
type BindingMap,
|
||||
} from "./bindings";
|
||||
export { constants, type CloudflareContext } from "./config";
|
||||
export { constants, makeConfig, type CloudflareContext } from "./config";
|
||||
export { StorageR2Adapter, registerMedia } from "./storage/StorageR2Adapter";
|
||||
export { registries } from "bknd";
|
||||
export { devFsVitePlugin, devFsWrite } from "./vite";
|
||||
|
||||
@@ -18,6 +18,29 @@ export type WithPlatformProxyOptions = {
|
||||
proxyOptions?: GetPlatformProxyOptions;
|
||||
};
|
||||
|
||||
async function getPlatformProxy(opts?: GetPlatformProxyOptions) {
|
||||
try {
|
||||
const { version } = await import("wrangler/package.json", { with: { type: "json" } }).then(
|
||||
(pkg) => pkg.default,
|
||||
);
|
||||
$console.log("Using wrangler version", version);
|
||||
const { getPlatformProxy } = await import("wrangler");
|
||||
return getPlatformProxy(opts);
|
||||
} catch (e) {
|
||||
$console.error("Failed to import wrangler", String(e));
|
||||
const resolved = import.meta.resolve("wrangler");
|
||||
$console.log("Wrangler resolved to", resolved);
|
||||
const file = resolved?.split("/").pop();
|
||||
if (file?.endsWith(".json")) {
|
||||
$console.error(
|
||||
"You have a `wrangler.json` in your current directory. Please change to .jsonc or .toml",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
export function withPlatformProxy<Env extends CloudflareEnv>(
|
||||
config: CloudflareBkndConfig<Env> = {},
|
||||
opts?: WithPlatformProxyOptions,
|
||||
@@ -31,7 +54,6 @@ export function withPlatformProxy<Env extends CloudflareEnv>(
|
||||
async function getEnv(env?: Env): Promise<Env> {
|
||||
if (use_proxy) {
|
||||
if (!proxy) {
|
||||
const getPlatformProxy = await import("wrangler").then((mod) => mod.getPlatformProxy);
|
||||
proxy = await getPlatformProxy(opts?.proxyOptions);
|
||||
process.on("exit", () => {
|
||||
proxy?.dispose();
|
||||
|
||||
@@ -28,7 +28,8 @@ describe("StorageR2Adapter", async () => {
|
||||
const buffer = readFileSync(path.join(basePath, "image.png"));
|
||||
const file = new File([buffer], "image.png", { type: "image/png" });
|
||||
|
||||
await adapterTestSuite(viTestRunner, adapter, file);
|
||||
// miniflare doesn't support range requests
|
||||
await adapterTestSuite(viTestRunner, adapter, file, { testRange: false });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -80,18 +80,79 @@ export class StorageLocalAdapter extends StorageAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
private parseRangeHeader(
|
||||
rangeHeader: string,
|
||||
fileSize: number,
|
||||
): { start: number; end: number } | null {
|
||||
// Parse "bytes=start-end" format
|
||||
const match = rangeHeader.match(/^bytes=(\d*)-(\d*)$/);
|
||||
if (!match) return null;
|
||||
|
||||
const [, startStr, endStr] = match;
|
||||
let start = startStr ? Number.parseInt(startStr, 10) : 0;
|
||||
let end = endStr ? Number.parseInt(endStr, 10) : fileSize - 1;
|
||||
|
||||
// Handle suffix-byte-range-spec (e.g., "bytes=-500")
|
||||
if (!startStr && endStr) {
|
||||
start = Math.max(0, fileSize - Number.parseInt(endStr, 10));
|
||||
end = fileSize - 1;
|
||||
}
|
||||
|
||||
// Validate range
|
||||
if (start < 0 || end >= fileSize || start > end) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
async getObject(key: string, headers: Headers): Promise<Response> {
|
||||
try {
|
||||
const content = await readFile(`${this.config.path}/${key}`);
|
||||
const filePath = `${this.config.path}/${key}`;
|
||||
const stats = await stat(filePath);
|
||||
const fileSize = stats.size;
|
||||
const mimeType = guessMimeType(key);
|
||||
|
||||
return new Response(content, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": mimeType || "application/octet-stream",
|
||||
"Content-Length": content.length.toString(),
|
||||
},
|
||||
const responseHeaders = new Headers({
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Type": mimeType || "application/octet-stream",
|
||||
});
|
||||
|
||||
const rangeHeader = headers.get("range");
|
||||
|
||||
if (rangeHeader) {
|
||||
const range = this.parseRangeHeader(rangeHeader, fileSize);
|
||||
|
||||
if (!range) {
|
||||
// Invalid range - return 416 Range Not Satisfiable
|
||||
responseHeaders.set("Content-Range", `bytes */${fileSize}`);
|
||||
return new Response("", {
|
||||
status: 416,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
const { start, end } = range;
|
||||
const content = await readFile(filePath, { encoding: null });
|
||||
const chunk = content.slice(start, end + 1);
|
||||
|
||||
responseHeaders.set("Content-Range", `bytes ${start}-${end}/${fileSize}`);
|
||||
responseHeaders.set("Content-Length", chunk.length.toString());
|
||||
|
||||
return new Response(chunk, {
|
||||
status: 206, // Partial Content
|
||||
headers: responseHeaders,
|
||||
});
|
||||
} else {
|
||||
// Normal request - return entire file
|
||||
const content = await readFile(filePath);
|
||||
responseHeaders.set("Content-Length", content.length.toString());
|
||||
|
||||
return new Response(content, {
|
||||
status: 200,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Handle file reading errors
|
||||
return new Response("", { status: 404 });
|
||||
|
||||
@@ -17,7 +17,7 @@ export async function serveStatic(server: Platform): Promise<MiddlewareHandler>
|
||||
case "node": {
|
||||
const m = await import("@hono/node-server/serve-static");
|
||||
const root = getRelativeDistPath() + "/static";
|
||||
$console.log("Serving static files from", root);
|
||||
$console.debug("Serving static files from", root);
|
||||
return m.serveStatic({
|
||||
// somehow different for node
|
||||
root,
|
||||
@@ -27,7 +27,7 @@ export async function serveStatic(server: Platform): Promise<MiddlewareHandler>
|
||||
case "bun": {
|
||||
const m = await import("hono/bun");
|
||||
const root = path.resolve(getRelativeDistPath(), "static");
|
||||
$console.log("Serving static files from", root);
|
||||
$console.debug("Serving static files from", root);
|
||||
return m.serveStatic({
|
||||
root,
|
||||
onNotFound,
|
||||
@@ -76,6 +76,9 @@ export async function getConfigPath(filePath?: string) {
|
||||
const config_path = path.resolve(process.cwd(), filePath);
|
||||
if (await fileExists(config_path)) {
|
||||
return config_path;
|
||||
} else {
|
||||
$console.error(`Config file could not be resolved: ${config_path}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,8 @@ import type { Config } from "@libsql/client/node";
|
||||
import { StorageLocalAdapter } from "adapter/node/storage";
|
||||
import type { CliBkndConfig, CliCommand } from "cli/types";
|
||||
import { Option } from "commander";
|
||||
import { config, type App, type CreateAppConfig, type MaybePromise } from "bknd";
|
||||
import { config, type App, type CreateAppConfig, type MaybePromise, registries } from "bknd";
|
||||
import dotenv from "dotenv";
|
||||
import { registries } from "modules/registries";
|
||||
import c from "picocolors";
|
||||
import path from "node:path";
|
||||
import {
|
||||
|
||||
@@ -8,6 +8,7 @@ export const sync: CliCommand = (program) => {
|
||||
withConfigOptions(program.command("sync"))
|
||||
.description("sync database")
|
||||
.option("--force", "perform database syncing operations")
|
||||
.option("--seed", "perform seeding operations")
|
||||
.option("--drop", "include destructive DDL operations")
|
||||
.option("--out <file>", "output file")
|
||||
.option("--sql", "use sql output")
|
||||
@@ -29,8 +30,22 @@ export const sync: CliCommand = (program) => {
|
||||
console.info(c.dim("Executing:") + "\n" + c.cyan(sql));
|
||||
await schema.sync({ force: true, drop: options.drop });
|
||||
|
||||
console.info(`\n${c.gray(`Executed ${c.cyan(stmts.length)} statement(s)`)}`);
|
||||
console.info(`\n${c.dim(`Executed ${c.cyan(stmts.length)} statement(s)`)}`);
|
||||
console.info(`${c.green("Database synced")}`);
|
||||
|
||||
if (options.seed) {
|
||||
console.info(c.dim("\nExecuting seed..."));
|
||||
const seed = app.options?.seed;
|
||||
if (seed) {
|
||||
await app.options?.seed?.({
|
||||
...app.modules.ctx(),
|
||||
app: app,
|
||||
});
|
||||
console.info(c.green("Seed executed"));
|
||||
} else {
|
||||
console.info(c.yellow("No seed function provided"));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (options.out) {
|
||||
const output = options.sql ? sql : JSON.stringify(stmts, null, 2);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { getVersion } from "./utils/sys";
|
||||
import { capture, flush, init } from "cli/utils/telemetry";
|
||||
const program = new Command();
|
||||
|
||||
export async function main() {
|
||||
async function main() {
|
||||
await init();
|
||||
capture("start");
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ export {
|
||||
getMcpServer,
|
||||
stdioTransport,
|
||||
McpClient,
|
||||
logLevels as mcpLogLevels,
|
||||
type McpClientConfig,
|
||||
type ToolAnnotation,
|
||||
type ToolHandlerCtx,
|
||||
@@ -21,8 +22,35 @@ export { secret, SecretSchema } from "./secret";
|
||||
|
||||
export { s };
|
||||
|
||||
export const stripMark = <O extends object>(o: O): O => o;
|
||||
export const mark = <O extends object>(o: O): O => o;
|
||||
const symbol = Symbol("bknd-validation-mark");
|
||||
|
||||
export function stripMark<O = any>(obj: O) {
|
||||
const newObj = structuredClone(obj);
|
||||
mark(newObj, false);
|
||||
return newObj as O;
|
||||
}
|
||||
|
||||
export function mark(obj: any, validated = true) {
|
||||
try {
|
||||
if (typeof obj === "object" && obj !== null && !Array.isArray(obj)) {
|
||||
if (validated) {
|
||||
obj[symbol] = true;
|
||||
} else {
|
||||
delete obj[symbol];
|
||||
}
|
||||
for (const key in obj) {
|
||||
if (typeof obj[key] === "object" && obj[key] !== null) {
|
||||
mark(obj[key], validated);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
export function isMarked(obj: any) {
|
||||
if (typeof obj !== "object" || obj === null) return false;
|
||||
return obj[symbol] === true;
|
||||
}
|
||||
|
||||
export const stringIdentifier = s.string({
|
||||
pattern: "^[a-zA-Z_][a-zA-Z0-9_]*$",
|
||||
@@ -74,6 +102,10 @@ export function parse<S extends s.Schema, Options extends ParseOptions = ParseOp
|
||||
v: unknown,
|
||||
opts?: Options,
|
||||
): Options extends { coerce: true } ? s.StaticCoerced<S> : s.Static<S> {
|
||||
if (!opts?.forceParse && !opts?.coerce && isMarked(v)) {
|
||||
return v as any;
|
||||
}
|
||||
|
||||
const schema = (opts?.clone ? cloneSchema(_schema as any) : _schema) as s.Schema;
|
||||
let value =
|
||||
opts?.coerce !== false
|
||||
|
||||
@@ -230,3 +230,15 @@ export function customIntrospector<T extends Constructor<Dialect>>(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class DummyConnection extends Connection {
|
||||
override name = "dummy";
|
||||
|
||||
constructor() {
|
||||
super(undefined as any);
|
||||
}
|
||||
|
||||
override getFieldSchema(): SchemaResponse {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,8 @@ export class SqliteIntrospector extends BaseIntrospector {
|
||||
)) FROM pragma_index_info(i.name) ii)
|
||||
)) FROM pragma_index_list(m.name) i
|
||||
LEFT JOIN sqlite_master im ON im.name = i.name
|
||||
AND im.type = 'index'
|
||||
AND im.type = 'index'
|
||||
WHERE i.name not like 'sqlite_%'
|
||||
) AS indices
|
||||
FROM sqlite_master m
|
||||
WHERE m.type IN ('table', 'view')
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { EntityTypescript } from "./EntityTypescript";
|
||||
import * as proto from "../prototype";
|
||||
import { DummyConnection } from "../connection/Connection";
|
||||
|
||||
describe("EntityTypescript", () => {
|
||||
it("should generate correct typescript for system entities", () => {
|
||||
const schema = proto.em(
|
||||
{
|
||||
test: proto.entity("test", {
|
||||
name: proto.text(),
|
||||
}),
|
||||
users: proto.systemEntity("users", {
|
||||
name: proto.text(),
|
||||
}),
|
||||
},
|
||||
({ relation }, { test, users }) => {
|
||||
relation(test).manyToOne(users);
|
||||
},
|
||||
);
|
||||
const et = new EntityTypescript(schema.proto.withConnection(new DummyConnection()));
|
||||
expect(et.toString()).toContain('users?: DB["users"];');
|
||||
});
|
||||
|
||||
it("should generate correct typescript for system entities with uuid primary field", () => {
|
||||
const schema = proto.em(
|
||||
{
|
||||
test: proto.entity(
|
||||
"test",
|
||||
{
|
||||
name: proto.text(),
|
||||
},
|
||||
{
|
||||
primary_format: "uuid",
|
||||
},
|
||||
),
|
||||
users: proto.systemEntity(
|
||||
"users",
|
||||
{
|
||||
name: proto.text(),
|
||||
},
|
||||
{
|
||||
primary_format: "uuid",
|
||||
},
|
||||
),
|
||||
},
|
||||
({ relation }, { test, users }) => {
|
||||
relation(test).manyToOne(users);
|
||||
},
|
||||
);
|
||||
const et = new EntityTypescript(schema.proto.withConnection(new DummyConnection()));
|
||||
expect(et.toString()).toContain("users_id?: string;");
|
||||
});
|
||||
});
|
||||
@@ -40,7 +40,7 @@ const systemEntities = {
|
||||
|
||||
export class EntityTypescript {
|
||||
constructor(
|
||||
protected em: EntityManager,
|
||||
protected em: EntityManager<any>,
|
||||
protected _options: EntityTypescriptOptions = {},
|
||||
) {}
|
||||
|
||||
|
||||
@@ -210,8 +210,8 @@ type SystemEntities = {
|
||||
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);
|
||||
>(name: E, fields: Fields, config?: EntityConfig) {
|
||||
return entity<E, SystemEntities[E] & Fields>(name, fields as any, config, "system");
|
||||
}
|
||||
|
||||
export function relation<Local extends Entity>(local: Local) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { type RelationType, RelationTypes } from "./relation-types";
|
||||
|
||||
/**
|
||||
* Both source and target receive a mapping field
|
||||
* Source gets the mapping field, and can $create the target
|
||||
* @todo: determine if it should be removed
|
||||
*/
|
||||
export type OneToOneRelationConfig = ManyToOneRelationConfig;
|
||||
@@ -17,6 +18,7 @@ export class OneToOneRelation extends ManyToOneRelation {
|
||||
inversedBy,
|
||||
sourceCardinality: 1,
|
||||
required,
|
||||
with_limit: 1,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -102,8 +102,8 @@ export class PolymorphicRelation extends EntityRelation<typeof PolymorphicRelati
|
||||
return new TextField("reference", { hidden: true, fillable: ["create"] });
|
||||
}
|
||||
|
||||
getEntityIdField(): NumberField {
|
||||
return new NumberField("entity_id", { hidden: true, fillable: ["create"] });
|
||||
getEntityIdField(): TextField {
|
||||
return new TextField("entity_id", { hidden: true, fillable: ["create"] });
|
||||
}
|
||||
|
||||
initialize(em: EntityManager<any>) {
|
||||
|
||||
@@ -84,9 +84,10 @@ export class RelationField extends Field<RelationFieldConfig> {
|
||||
}
|
||||
|
||||
override toType(): TFieldTSType {
|
||||
const type = this.config.target_field_type === "integer" ? "number" : "string";
|
||||
return {
|
||||
...super.toType(),
|
||||
type: "number",
|
||||
type,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ export class SchemaManager {
|
||||
if (SchemaManager.EXCLUDE_TABLES.includes(table.name)) {
|
||||
continue;
|
||||
}
|
||||
if (!table.name) continue;
|
||||
|
||||
cleanTables.push({
|
||||
...table,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { MediaController } from "./api/MediaController";
|
||||
import { buildMediaSchema, registry, type TAppMediaConfig } from "./media-schema";
|
||||
import { mediaFields } from "./media-entities";
|
||||
import * as MediaPermissions from "media/media-permissions";
|
||||
import * as DatabaseEvents from "data/events";
|
||||
|
||||
export type MediaFields = typeof AppMedia.mediaFields;
|
||||
export type MediaFieldSchema = FieldSchema<typeof AppMedia.mediaFields>;
|
||||
@@ -139,6 +140,30 @@ export class AppMedia extends Module<Required<TAppMediaConfig>> {
|
||||
},
|
||||
{ mode: "sync", id: "delete-data-media" },
|
||||
);
|
||||
|
||||
emgr.onEvent(
|
||||
DatabaseEvents.MutatorDeleteAfter,
|
||||
async (e) => {
|
||||
const { entity, data } = e.params;
|
||||
const fields = entity.fields.filter((f) => f.type === "media");
|
||||
if (fields.length > 0) {
|
||||
const references = fields.map((f) => `${entity.name}.${f.name}`);
|
||||
$console.log("App:storage:file cleaning up", {
|
||||
reference: { $in: references },
|
||||
entity_id: String(data.id),
|
||||
});
|
||||
const { data: deleted } = await em.mutator(media).deleteWhere({
|
||||
reference: { $in: references },
|
||||
entity_id: String(data.id),
|
||||
});
|
||||
for (const file of deleted) {
|
||||
await this.storage.deleteFile(file.path);
|
||||
}
|
||||
$console.log("App:storage:file cleaned up files:", deleted.length);
|
||||
}
|
||||
},
|
||||
{ mode: "async", id: "delete-data-media-after" },
|
||||
);
|
||||
}
|
||||
|
||||
override getOverwritePaths() {
|
||||
|
||||
@@ -43,6 +43,10 @@ export class MediaField<
|
||||
return this.config.max_items;
|
||||
}
|
||||
|
||||
getAllowedMimeTypes(): string[] | undefined {
|
||||
return this.config.mime_types;
|
||||
}
|
||||
|
||||
getMinItems(): number | undefined {
|
||||
return this.config.min_items;
|
||||
}
|
||||
|
||||
@@ -71,22 +71,29 @@ export class Storage implements EmitsEvents {
|
||||
|
||||
let info: FileUploadPayload = {
|
||||
name,
|
||||
meta: {
|
||||
size: 0,
|
||||
type: "application/octet-stream",
|
||||
},
|
||||
meta: isFile(file)
|
||||
? {
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
}
|
||||
: {
|
||||
size: 0,
|
||||
type: "application/octet-stream",
|
||||
},
|
||||
etag: typeof result === "string" ? result : "",
|
||||
};
|
||||
|
||||
// normally only etag is returned
|
||||
if (typeof result === "object") {
|
||||
info = result;
|
||||
} else if (isFile(file)) {
|
||||
info.meta.size = file.size;
|
||||
info.meta.type = file.type;
|
||||
}
|
||||
|
||||
// try to get better meta info
|
||||
if (!isMimeType(info.meta.type, ["application/octet-stream", "application/json"])) {
|
||||
if (
|
||||
!info.meta.type ||
|
||||
["application/octet-stream", "application/json"].includes(info.meta.type) ||
|
||||
!info.meta.size
|
||||
) {
|
||||
const meta = await this.#adapter.getObjectMeta(name);
|
||||
if (!meta) {
|
||||
throw new Error("Failed to get object meta");
|
||||
|
||||
@@ -11,12 +11,14 @@ export async function adapterTestSuite(
|
||||
retries?: number;
|
||||
retryTimeout?: number;
|
||||
skipExistsAfterDelete?: boolean;
|
||||
testRange?: boolean;
|
||||
},
|
||||
) {
|
||||
const { test, expect } = testRunner;
|
||||
const options = {
|
||||
retries: opts?.retries ?? 1,
|
||||
retryTimeout: opts?.retryTimeout ?? 1000,
|
||||
testRange: opts?.testRange ?? true,
|
||||
};
|
||||
|
||||
let objects = 0;
|
||||
@@ -53,9 +55,34 @@ export async function adapterTestSuite(
|
||||
await test("gets an object", async () => {
|
||||
const res = await adapter.getObject(filename, new Headers());
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.headers.get("Accept-Ranges")).toBe("bytes");
|
||||
// @todo: check the content
|
||||
});
|
||||
|
||||
if (options.testRange) {
|
||||
await test("handles range request - partial content", async () => {
|
||||
const headers = new Headers({ Range: "bytes=0-99" });
|
||||
const res = await adapter.getObject(filename, headers);
|
||||
expect(res.status).toBe(206); // Partial Content
|
||||
expect(/^bytes 0-99\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true);
|
||||
expect(res.headers.get("Accept-Ranges")).toBe("bytes");
|
||||
});
|
||||
|
||||
await test("handles range request - suffix range", async () => {
|
||||
const headers = new Headers({ Range: "bytes=-100" });
|
||||
const res = await adapter.getObject(filename, headers);
|
||||
expect(res.status).toBe(206); // Partial Content
|
||||
expect(/^bytes \d+-\d+\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true);
|
||||
});
|
||||
|
||||
await test("handles invalid range request", async () => {
|
||||
const headers = new Headers({ Range: "bytes=invalid" });
|
||||
const res = await adapter.getObject(filename, headers);
|
||||
expect(res.status).toBe(416); // Range Not Satisfiable
|
||||
expect(/^bytes \*\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
await test("gets object meta", async () => {
|
||||
expect(await adapter.getObjectMeta(filename)).toEqual({
|
||||
type: file.type, // image/png
|
||||
|
||||
@@ -22,6 +22,19 @@ afterAll(() => {
|
||||
cleanup();
|
||||
}); */
|
||||
|
||||
describe("StorageS3Adapter", async () => {
|
||||
test("verify client's service is set to s3", async () => {
|
||||
const adapter = new StorageS3Adapter({
|
||||
access_key: "",
|
||||
secret_access_key: "",
|
||||
url: "https://localhost",
|
||||
});
|
||||
// this is important for minio to produce the correct headers
|
||||
// and it won't harm s3 or r2
|
||||
expect(adapter.client.service).toBe("s3");
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(ALL_TESTS)("StorageS3Adapter", async () => {
|
||||
if (ALL_TESTS) return;
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ export class StorageS3Adapter extends StorageAdapter {
|
||||
accessKeyId: config.access_key,
|
||||
secretAccessKey: config.secret_access_key,
|
||||
retries: isDebug() ? 0 : 10,
|
||||
service: "s3",
|
||||
},
|
||||
{
|
||||
convertParams: "pascalToKebab",
|
||||
|
||||
@@ -3,7 +3,7 @@ export const Q = {
|
||||
audio: ["ogg"],
|
||||
image: ["jpeg", "png", "gif", "webp", "bmp", "tiff", "avif", "heic", "heif"],
|
||||
text: ["html", "css", "mdx", "yaml", "vcard", "csv", "vtt"],
|
||||
application: ["zip", "xml", "toml", "json", "json5"],
|
||||
application: ["zip", "toml", "json", "json5", "pdf", "xml"],
|
||||
font: ["woff", "woff2", "ttf", "otf"],
|
||||
} as const;
|
||||
|
||||
@@ -15,11 +15,13 @@ const c = {
|
||||
a: (w = "octet-stream") => `application/${w}`,
|
||||
i: (w) => `image/${w}`,
|
||||
v: (w) => `video/${w}`,
|
||||
au: (w) => `audio/${w}`,
|
||||
} as const;
|
||||
export const M = new Map<string, string>([
|
||||
["7z", c.z],
|
||||
["7zip", c.z],
|
||||
["ai", c.a("pdf")],
|
||||
["txt", c.t()],
|
||||
["ai", c.a("postscript")],
|
||||
["apk", c.a("vnd.android.package-archive")],
|
||||
["doc", c.a("msword")],
|
||||
["docx", `${c.vnd}.wordprocessingml.document`],
|
||||
@@ -32,12 +34,12 @@ export const M = new Map<string, string>([
|
||||
["jpg", c.i("jpeg")],
|
||||
["js", c.t("javascript")],
|
||||
["log", c.t()],
|
||||
["m3u", c.t()],
|
||||
["m3u", c.au("x-mpegurl")],
|
||||
["m3u8", c.a("vnd.apple.mpegurl")],
|
||||
["manifest", c.t("cache-manifest")],
|
||||
["md", c.t("markdown")],
|
||||
["mkv", c.v("x-matroska")],
|
||||
["mp3", c.a("mpeg")],
|
||||
["mp3", c.au("mpeg")],
|
||||
["mobi", c.a("x-mobipocket-ebook")],
|
||||
["ppt", c.a("powerpoint")],
|
||||
["pptx", `${c.vnd}.presentationml.presentation`],
|
||||
@@ -46,11 +48,10 @@ export const M = new Map<string, string>([
|
||||
["tif", c.i("tiff")],
|
||||
["tsv", c.t("tab-separated-values")],
|
||||
["tgz", c.a("x-tar")],
|
||||
["txt", c.t()],
|
||||
["text", c.t()],
|
||||
["vcd", c.a("x-cdlink")],
|
||||
["vcs", c.t("x-vcalendar")],
|
||||
["wav", c.a("x-wav")],
|
||||
["wav", c.au("vnd.wav")],
|
||||
["webmanifest", c.a("manifest+json")],
|
||||
["xls", c.a("vnd.ms-excel")],
|
||||
["xlsx", `${c.vnd}.spreadsheetml.sheet`],
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import { objectEach, transformObject, McpServer, type s, SecretSchema, setPath } from "bknd/utils";
|
||||
import {
|
||||
objectEach,
|
||||
transformObject,
|
||||
McpServer,
|
||||
type s,
|
||||
SecretSchema,
|
||||
setPath,
|
||||
mark,
|
||||
$console,
|
||||
} from "bknd/utils";
|
||||
import { DebugLogger } from "core/utils/DebugLogger";
|
||||
import { Guard } from "auth/authorize/Guard";
|
||||
import { env } from "core/env";
|
||||
@@ -65,7 +74,7 @@ export type ModuleManagerOptions = {
|
||||
// callback after server was created
|
||||
onServerInit?: (server: Hono<ServerEnv>) => void;
|
||||
// doesn't perform validity checks for given/fetched config
|
||||
trustFetched?: boolean;
|
||||
skipValidation?: boolean;
|
||||
// runs when initial config provided on a fresh database
|
||||
seed?: (ctx: ModuleBuildContext) => Promise<void>;
|
||||
// called right after modules are built, before finish
|
||||
@@ -118,13 +127,18 @@ export class ModuleManager {
|
||||
|
||||
constructor(
|
||||
protected readonly connection: Connection,
|
||||
protected options?: Partial<ModuleManagerOptions>,
|
||||
public options?: Partial<ModuleManagerOptions>,
|
||||
) {
|
||||
this.modules = {} as Modules;
|
||||
this.emgr = new EventManager({ ...ModuleManagerEvents });
|
||||
this.logger = new DebugLogger(debug_modules);
|
||||
|
||||
this.createModules(options?.initial ?? {});
|
||||
const config = options?.initial ?? {};
|
||||
if (options?.skipValidation) {
|
||||
mark(config, true);
|
||||
}
|
||||
|
||||
this.createModules(config);
|
||||
}
|
||||
|
||||
protected onModuleConfigUpdated(key: string, config: any) {}
|
||||
@@ -317,9 +331,8 @@ export class ModuleManager {
|
||||
ctx.flags.sync_required = false;
|
||||
this.logger.log("db sync requested");
|
||||
|
||||
// sync db
|
||||
await ctx.em.schema().sync({ force: true, drop: options?.drop });
|
||||
state.synced = true;
|
||||
// ignore sync request on code mode since system tables
|
||||
// are probably never fully in provided config
|
||||
}
|
||||
|
||||
if (ctx.flags.ctx_reload_required) {
|
||||
|
||||
@@ -205,7 +205,7 @@ export class DbModuleManager extends ModuleManager {
|
||||
|
||||
if (store_secrets) {
|
||||
updates.push({
|
||||
version: state.configs.version,
|
||||
version: version,
|
||||
type: "secrets",
|
||||
json: secrets as any,
|
||||
});
|
||||
@@ -252,7 +252,7 @@ export class DbModuleManager extends ModuleManager {
|
||||
if (store_secrets) {
|
||||
if (!state.secrets || state.secrets?.version !== version) {
|
||||
await this.mutator().insertOne({
|
||||
version: state.configs.version,
|
||||
version,
|
||||
type: "secrets",
|
||||
json: secrets,
|
||||
created_at: date,
|
||||
@@ -380,8 +380,8 @@ export class DbModuleManager extends ModuleManager {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.options?.trustFetched === true) {
|
||||
this.logger.log("trusting fetched config (mark)");
|
||||
if (this.options?.skipValidation === true) {
|
||||
this.logger.log("skipping validation (mark)");
|
||||
mark(result.configs.json);
|
||||
}
|
||||
|
||||
@@ -393,7 +393,7 @@ export class DbModuleManager extends ModuleManager {
|
||||
|
||||
const version_before = this.version();
|
||||
const [_version, _configs] = await migrate(version_before, result.configs.json, {
|
||||
db: this.db,
|
||||
db: this.db
|
||||
});
|
||||
|
||||
this._version = _version;
|
||||
|
||||
@@ -99,6 +99,14 @@ export const migrations: Migration[] = [
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
// remove secrets, automatic
|
||||
// change media table `entity_id` from integer to text
|
||||
version: 10,
|
||||
up: async (config) => {
|
||||
return config;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const CURRENT_VERSION = migrations[migrations.length - 1]?.version ?? 0;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getVersion } from "core/env";
|
||||
export function getSystemMcp(app: App) {
|
||||
const middlewareServer = getMcpServer(app.server);
|
||||
|
||||
const appConfig = app.modules.configs();
|
||||
//const appConfig = app.modules.configs();
|
||||
const { version, ...appSchema } = app.getSchema();
|
||||
const schema = s.strictObject(appSchema);
|
||||
const result = [...schema.walk({ maxDepth: 3 })];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Exception } from "core/errors";
|
||||
import { isDebug } from "core/env";
|
||||
import { $console, s } from "bknd/utils";
|
||||
import { $console, mcpLogLevels, s } from "bknd/utils";
|
||||
import { $object } from "modules/mcp";
|
||||
import { cors } from "hono/cors";
|
||||
import { Module } from "modules/Module";
|
||||
@@ -25,6 +25,10 @@ export const serverConfigSchema = $object(
|
||||
mcp: s.strictObject({
|
||||
enabled: s.boolean({ default: false }),
|
||||
path: s.string({ default: "/api/system/mcp" }),
|
||||
logLevel: s.string({
|
||||
enum: mcpLogLevels,
|
||||
default: "warning",
|
||||
}),
|
||||
}),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -70,33 +70,41 @@ export class SystemController extends Controller {
|
||||
|
||||
this.registerMcp();
|
||||
|
||||
this._mcpServer = getSystemMcp(app);
|
||||
this._mcpServer.onNotification((message) => {
|
||||
if (message.method === "notification/message") {
|
||||
const consoleMap = {
|
||||
emergency: "error",
|
||||
alert: "error",
|
||||
critical: "error",
|
||||
error: "error",
|
||||
warning: "warn",
|
||||
notice: "log",
|
||||
info: "info",
|
||||
debug: "debug",
|
||||
};
|
||||
|
||||
const level = consoleMap[message.params.level];
|
||||
if (!level) return;
|
||||
|
||||
$console[level]("MCP notification", message.params.message ?? message.params);
|
||||
}
|
||||
});
|
||||
|
||||
app.server.use(
|
||||
mcpMiddleware({
|
||||
server: this._mcpServer,
|
||||
setup: async () => {
|
||||
if (!this._mcpServer) {
|
||||
this._mcpServer = getSystemMcp(app);
|
||||
this._mcpServer.onNotification((message) => {
|
||||
if (message.method === "notification/message") {
|
||||
const consoleMap = {
|
||||
emergency: "error",
|
||||
alert: "error",
|
||||
critical: "error",
|
||||
error: "error",
|
||||
warning: "warn",
|
||||
notice: "log",
|
||||
info: "info",
|
||||
debug: "debug",
|
||||
};
|
||||
|
||||
const level = consoleMap[message.params.level];
|
||||
if (!level) return;
|
||||
|
||||
$console[level](
|
||||
"MCP notification",
|
||||
message.params.message ?? message.params,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
return {
|
||||
server: this._mcpServer,
|
||||
};
|
||||
},
|
||||
sessionsEnabled: true,
|
||||
debug: {
|
||||
logLevel: "debug",
|
||||
logLevel: config.mcp.logLevel as any,
|
||||
explainEndpoint: true,
|
||||
},
|
||||
endpoint: {
|
||||
|
||||
+29
-27
@@ -8,7 +8,33 @@ import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||
import { ClientProvider, useBkndWindowContext, type ClientProviderProps } from "./client";
|
||||
import { createMantineTheme } from "./lib/mantine/theme";
|
||||
import { Routes } from "./routes";
|
||||
import type { BkndAdminAppShellOptions, BkndAdminEntitiesOptions } from "ui/options";
|
||||
import type { BkndAdminAppShellOptions, BkndAdminEntitiesOptions } from "./options";
|
||||
|
||||
export type BkndAdminConfig = {
|
||||
/**
|
||||
* Base path of the Admin UI
|
||||
* @default `/`
|
||||
*/
|
||||
basepath?: string;
|
||||
/**
|
||||
* Path to return to when clicking the logo
|
||||
* @default `/`
|
||||
*/
|
||||
logo_return_path?: string;
|
||||
/**
|
||||
* Theme of the Admin UI
|
||||
* @default `system`
|
||||
*/
|
||||
theme?: AppTheme;
|
||||
/**
|
||||
* Entities configuration like headers, footers, actions, field renders, etc.
|
||||
*/
|
||||
entities?: BkndAdminEntitiesOptions;
|
||||
/**
|
||||
* App shell configuration like user menu actions.
|
||||
*/
|
||||
appShell?: BkndAdminAppShellOptions;
|
||||
};
|
||||
|
||||
export type BkndAdminProps = {
|
||||
/**
|
||||
@@ -16,37 +42,13 @@ export type BkndAdminProps = {
|
||||
*/
|
||||
baseUrl?: string;
|
||||
/**
|
||||
* Whether to wrap Admin in a <ClientProvider />
|
||||
* Whether to wrap Admin in a `<ClientProvider />`
|
||||
*/
|
||||
withProvider?: boolean | ClientProviderProps;
|
||||
/**
|
||||
* Admin UI customization options
|
||||
*/
|
||||
config?: {
|
||||
/**
|
||||
* Base path of the Admin UI
|
||||
* @default `/`
|
||||
*/
|
||||
basepath?: string;
|
||||
/**
|
||||
* Path to return to when clicking the logo
|
||||
* @default `/`
|
||||
*/
|
||||
logo_return_path?: string;
|
||||
/**
|
||||
* Theme of the Admin UI
|
||||
* @default `system`
|
||||
*/
|
||||
theme?: AppTheme;
|
||||
/**
|
||||
* Entities configuration like headers, footers, actions, field renders, etc.
|
||||
*/
|
||||
entities?: BkndAdminEntitiesOptions;
|
||||
/**
|
||||
* App shell configuration like user menu actions.
|
||||
*/
|
||||
appShell?: BkndAdminAppShellOptions;
|
||||
};
|
||||
config?: BkndAdminConfig;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
|
||||
@@ -156,6 +156,7 @@ export const useEntityQuery = <
|
||||
|
||||
// mutate all keys of entity by default
|
||||
if (options?.revalidateOnMutate !== false) {
|
||||
// don't use the id, to also update lists
|
||||
await mutateFn();
|
||||
}
|
||||
return res;
|
||||
|
||||
@@ -42,7 +42,10 @@ export type DropzoneRenderProps = {
|
||||
showPlaceholder: boolean;
|
||||
onClick?: (file: { path: string }) => void;
|
||||
footer?: ReactNode;
|
||||
dropzoneProps: Pick<DropzoneProps, "maxItems" | "placeholder" | "autoUpload" | "flow">;
|
||||
dropzoneProps: Pick<
|
||||
DropzoneProps,
|
||||
"maxItems" | "placeholder" | "autoUpload" | "flow" | "allowedMimeTypes"
|
||||
>;
|
||||
};
|
||||
|
||||
export type DropzoneProps = {
|
||||
@@ -151,6 +154,7 @@ export function Dropzone({
|
||||
const setIsOver = useStore(store, (state) => state.setIsOver);
|
||||
const uploading = useStore(store, (state) => state.uploading);
|
||||
const setFileState = useStore(store, (state) => state.setFileState);
|
||||
const overrideFile = useStore(store, (state) => state.overrideFile);
|
||||
const removeFile = useStore(store, (state) => state.removeFile);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -169,9 +173,14 @@ export function Dropzone({
|
||||
|
||||
return specs.every((spec) => {
|
||||
if (spec.kind !== "file") {
|
||||
console.log("not a file", spec.kind);
|
||||
return false;
|
||||
}
|
||||
return !(allowedMimeTypes && !allowedMimeTypes.includes(spec.type));
|
||||
if (allowedMimeTypes && allowedMimeTypes.length > 0) {
|
||||
console.log("not allowed mimetype", spec.type);
|
||||
return allowedMimeTypes.includes(spec.type);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -359,7 +368,7 @@ export function Dropzone({
|
||||
state: "uploaded",
|
||||
};
|
||||
|
||||
setFileState(file.path, newState.state);
|
||||
overrideFile(file.path, newState);
|
||||
resolve({ ...response, ...file, ...newState });
|
||||
} catch (e) {
|
||||
setFileState(file.path, "uploaded", 1);
|
||||
@@ -382,7 +391,9 @@ export function Dropzone({
|
||||
};
|
||||
|
||||
xhr.setRequestHeader("Accept", "application/json");
|
||||
xhr.send(file.body);
|
||||
const formData = new FormData();
|
||||
formData.append("file", file.body);
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -411,7 +422,9 @@ export function Dropzone({
|
||||
const openFileInput = useCallback(() => inputRef.current?.click(), [inputRef]);
|
||||
const showPlaceholder = useMemo(
|
||||
() =>
|
||||
Boolean(placeholder?.show === true || !maxItems || (maxItems && files.length < maxItems)),
|
||||
Boolean(
|
||||
placeholder?.show !== false && (!maxItems || (maxItems && files.length < maxItems)),
|
||||
),
|
||||
[placeholder, maxItems, files.length],
|
||||
);
|
||||
|
||||
@@ -424,6 +437,7 @@ export function Dropzone({
|
||||
type: "file",
|
||||
multiple: !maxItems || maxItems > 1,
|
||||
onChange: handleFileInputChange,
|
||||
accept: allowedMimeTypes?.join(","),
|
||||
},
|
||||
showPlaceholder,
|
||||
actions: {
|
||||
@@ -437,11 +451,12 @@ export function Dropzone({
|
||||
placeholder,
|
||||
autoUpload,
|
||||
flow,
|
||||
allowedMimeTypes,
|
||||
},
|
||||
onClick,
|
||||
footer,
|
||||
}),
|
||||
[maxItems, flow, placeholder, autoUpload, footer],
|
||||
[maxItems, files.length, flow, placeholder, autoUpload, footer, allowedMimeTypes],
|
||||
) as unknown as DropzoneRenderProps;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
import { type ComponentPropsWithoutRef, memo, type ReactNode, useCallback, useMemo } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useRenderCount } from "ui/hooks/use-render-count";
|
||||
import { TbDots, TbExternalLink, TbTrash, TbUpload } from "react-icons/tb";
|
||||
import {
|
||||
TbDots,
|
||||
TbExternalLink,
|
||||
TbFileTypeCsv,
|
||||
TbFileText,
|
||||
TbJson,
|
||||
TbFileTypePdf,
|
||||
TbMarkdown,
|
||||
TbMusic,
|
||||
TbTrash,
|
||||
TbUpload,
|
||||
TbFileTypeTxt,
|
||||
TbFileTypeXml,
|
||||
TbZip,
|
||||
TbFileTypeSql,
|
||||
} from "react-icons/tb";
|
||||
import { Dropdown, type DropdownItem } from "ui/components/overlay/Dropdown";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { formatNumber } from "core/utils";
|
||||
@@ -22,7 +37,7 @@ export const DropzoneInner = ({
|
||||
inputProps,
|
||||
showPlaceholder,
|
||||
actions: { uploadFile, deleteFile, openFileInput },
|
||||
dropzoneProps: { placeholder, flow },
|
||||
dropzoneProps: { placeholder, flow, maxItems, allowedMimeTypes },
|
||||
onClick,
|
||||
footer,
|
||||
}: DropzoneRenderProps) => {
|
||||
@@ -85,7 +100,7 @@ const UploadPlaceholder = ({ onClick, text = "Upload files" }) => {
|
||||
);
|
||||
};
|
||||
|
||||
type ReducedFile = Pick<FileState, "body" | "type" | "path" | "name" | "size">;
|
||||
type ReducedFile = Omit<FileState, "state" | "progress">;
|
||||
export type PreviewComponentProps = {
|
||||
file: ReducedFile;
|
||||
fallback?: (props: { file: ReducedFile }) => ReactNode;
|
||||
@@ -159,9 +174,9 @@ const Preview = memo(
|
||||
<p className="truncate select-text w-[calc(100%-10px)]">{file.name}</p>
|
||||
<StateIndicator file={file} />
|
||||
</div>
|
||||
<div className="flex flex-row justify-between text-sm font-mono opacity-50 text-nowrap gap-2">
|
||||
<div className="flex flex-row justify-between text-xs md:text-sm font-mono opacity-50 text-nowrap gap-2">
|
||||
<span className="truncate select-text">{file.type}</span>
|
||||
<span>{formatNumber.fileSize(file.size)}</span>
|
||||
<span className="whitespace-nowrap">{formatNumber.fileSize(file.size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -271,6 +286,59 @@ const VideoPreview = ({
|
||||
return <video {...props} src={objectUrl} />;
|
||||
};
|
||||
|
||||
const Previews = [
|
||||
{
|
||||
mime: "text/plain",
|
||||
Icon: TbFileTypeTxt,
|
||||
},
|
||||
{
|
||||
mime: "text/csv",
|
||||
Icon: TbFileTypeCsv,
|
||||
},
|
||||
{
|
||||
mime: /(text|application)\/xml/,
|
||||
Icon: TbFileTypeXml,
|
||||
},
|
||||
{
|
||||
mime: "text/markdown",
|
||||
Icon: TbMarkdown,
|
||||
},
|
||||
{
|
||||
mime: /^text\/.*$/,
|
||||
Icon: TbFileText,
|
||||
},
|
||||
{
|
||||
mime: "application/json",
|
||||
Icon: TbJson,
|
||||
},
|
||||
{
|
||||
mime: "application/pdf",
|
||||
Icon: TbFileTypePdf,
|
||||
},
|
||||
{
|
||||
mime: /^audio\/.*$/,
|
||||
Icon: TbMusic,
|
||||
},
|
||||
{
|
||||
mime: "application/zip",
|
||||
Icon: TbZip,
|
||||
},
|
||||
{
|
||||
mime: "application/sql",
|
||||
Icon: TbFileTypeSql,
|
||||
},
|
||||
];
|
||||
|
||||
const FallbackPreview = ({ file }: { file: ReducedFile }) => {
|
||||
return <div className="text-xs text-primary/50 text-center">{file.type}</div>;
|
||||
const previewIcon = Previews.find((p) =>
|
||||
p.mime instanceof RegExp ? p.mime.test(file.type) : p.mime === file.type,
|
||||
);
|
||||
if (previewIcon) {
|
||||
return <previewIcon.Icon className="size-10 text-gray-400" />;
|
||||
}
|
||||
return (
|
||||
<div className="text-xs text-primary/50 text-center font-mono leading-none max-w-[90%] truncate">
|
||||
{file.type}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -36,6 +36,10 @@ export const createDropzoneStore = () => {
|
||||
: f,
|
||||
),
|
||||
})),
|
||||
overrideFile: (path: string, newState: Partial<FileState>) =>
|
||||
set((state) => ({
|
||||
files: state.files.map((f) => (f.path === path ? { ...f, ...newState } : f)),
|
||||
})),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
export { default as Admin, type BkndAdminProps } from "./Admin";
|
||||
export { default as Admin, type BkndAdminProps, type BkndAdminConfig } from "./Admin";
|
||||
export * from "./components/form/json-schema-form";
|
||||
export { JsonViewer } from "./components/code/JsonViewer";
|
||||
export type * from "./options";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ContextModalProps } from "@mantine/modals";
|
||||
import type { ReactNode } from "react";
|
||||
import { type ReactNode, useEffect, useMemo, useState } from "react";
|
||||
import { useEntityQuery } from "ui/client";
|
||||
import { type FileState, Media } from "ui/elements";
|
||||
import { autoFormatString, datetimeStringLocal, formatNumber } from "core/utils";
|
||||
@@ -43,7 +43,7 @@ export function MediaInfoModal({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row">
|
||||
<div className="flex w-full md:w-[calc(100%-300px)] justify-center items-center bg-lightest min-w-0">
|
||||
<div className="flex w-full md:w-[calc(100%-300px)] justify-center items-center bg-lightest min-w-50">
|
||||
<FilePreview file={file} />
|
||||
</div>
|
||||
<div className="w-full md:!w-[300px] flex flex-col">
|
||||
@@ -156,12 +156,38 @@ const Item = ({
|
||||
);
|
||||
};
|
||||
|
||||
const textFormats = [/^text\/.*$/, /application\/(x\-)?(json|json|yaml|javascript|xml|rtf|sql)/];
|
||||
|
||||
const FilePreview = ({ file }: { file: FileState }) => {
|
||||
const objectUrl = typeof file.body === "string" ? file.body : URL.createObjectURL(file.body);
|
||||
|
||||
if (file.type.startsWith("image/") || file.type.startsWith("video/")) {
|
||||
// @ts-ignore
|
||||
return <Media.Preview file={file} className="max-h-[70dvh]" controls muted />;
|
||||
}
|
||||
|
||||
if (file.type === "application/pdf") {
|
||||
// use browser preview
|
||||
return (
|
||||
<iframe
|
||||
title="PDF preview"
|
||||
src={`${objectUrl}#view=fitH&zoom=page-width&toolbar=1`}
|
||||
className="w-250 max-w-[80dvw] h-[80dvh]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (textFormats.some((f) => f.test(file.type))) {
|
||||
return <TextPreview file={file} />;
|
||||
}
|
||||
|
||||
if (file.type.startsWith("audio/")) {
|
||||
return (
|
||||
<div className="p-5">
|
||||
<audio src={objectUrl} controls />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-96 min-h-48 flex justify-center items-center h-full max-h-[70dvh]">
|
||||
<span className="opacity-50 font-mono">No Preview Available</span>
|
||||
@@ -169,6 +195,44 @@ const FilePreview = ({ file }: { file: FileState }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const TextPreview = ({ file }: { file: FileState }) => {
|
||||
const [text, setText] = useState("");
|
||||
const objectUrl = typeof file.body === "string" ? file.body : URL.createObjectURL(file.body);
|
||||
const maxBytes = 1024 * 256;
|
||||
const useRange = file.size > maxBytes;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (file) {
|
||||
fetch(objectUrl, {
|
||||
headers: useRange ? { Range: `bytes=0-${maxBytes - 1}` } : undefined,
|
||||
})
|
||||
.then((r) => r.text())
|
||||
.then((t) => {
|
||||
if (!cancelled) setText(t);
|
||||
});
|
||||
} else {
|
||||
setText("");
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [file, useRange]);
|
||||
|
||||
return (
|
||||
<pre className="text-sm font-mono whitespace-pre-wrap break-all overflow-y-scroll w-250 md:max-w-[80dvw] h-[60dvh] md:h-[80dvh] py-4 px-6">
|
||||
{text}
|
||||
|
||||
{useRange && (
|
||||
<div className="mt-3 opacity-50 text-xs text-center">
|
||||
Showing first {formatNumber.fileSize(maxBytes)}
|
||||
</div>
|
||||
)}
|
||||
</pre>
|
||||
);
|
||||
};
|
||||
|
||||
MediaInfoModal.defaultTitle = undefined;
|
||||
MediaInfoModal.modalProps = {
|
||||
withCloseButton: false,
|
||||
|
||||
@@ -121,6 +121,8 @@ export function EntityForm({
|
||||
return custom;
|
||||
}
|
||||
}
|
||||
if (field.isHidden(action)) return;
|
||||
|
||||
return (
|
||||
<EntityFormField
|
||||
field={field}
|
||||
@@ -238,6 +240,10 @@ function EntityMediaFormField({
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
if (!entityId) return;
|
||||
const maxLimit = 50;
|
||||
const maxItems = field.getMaxItems();
|
||||
const isSingle = maxItems === 1;
|
||||
const limit = isSingle ? 1 : maxItems && maxItems > maxLimit ? maxLimit : maxItems;
|
||||
|
||||
const value = useStore(formApi.store, (state) => {
|
||||
const val = state.values[field.name];
|
||||
@@ -258,8 +264,9 @@ function EntityMediaFormField({
|
||||
<FieldLabel field={field} />
|
||||
<Media.Dropzone
|
||||
key={key}
|
||||
maxItems={field.getMaxItems()}
|
||||
initialItems={value} /* @todo: test if better be omitted, so it fetches */
|
||||
maxItems={maxItems}
|
||||
allowedMimeTypes={field.getAllowedMimeTypes()}
|
||||
initialItems={isSingle ? value : undefined}
|
||||
onClick={onClick}
|
||||
entity={{
|
||||
name: entity.name,
|
||||
@@ -268,6 +275,7 @@ function EntityMediaFormField({
|
||||
}}
|
||||
query={{
|
||||
sort: "-id",
|
||||
limit,
|
||||
}}
|
||||
/>
|
||||
</Formy.Group>
|
||||
|
||||
@@ -77,6 +77,9 @@ function DataEntityUpdateImpl({ params }) {
|
||||
message: `Successfully updated ID ${entityId}`,
|
||||
color: "green",
|
||||
});
|
||||
|
||||
// make sure form picks up the latest data
|
||||
Form.reset();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to update");
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
},
|
||||
"app": {
|
||||
"name": "bknd",
|
||||
"version": "0.18.0-rc.4",
|
||||
"version": "0.18.0-rc.6",
|
||||
"bin": "./dist/cli/index.js",
|
||||
"dependencies": {
|
||||
"@cfworker/json-schema": "^4.1.1",
|
||||
@@ -35,7 +35,7 @@
|
||||
"hono": "4.8.3",
|
||||
"json-schema-library": "10.0.0-rc7",
|
||||
"json-schema-to-ts": "^3.1.1",
|
||||
"jsonv-ts": "0.8.2",
|
||||
"jsonv-ts": "0.8.4",
|
||||
"kysely": "0.27.6",
|
||||
"lodash-es": "^4.17.21",
|
||||
"oauth4webapi": "^2.11.1",
|
||||
@@ -2529,7 +2529,7 @@
|
||||
|
||||
"jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="],
|
||||
|
||||
"jsonv-ts": ["jsonv-ts@0.8.2", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-1Z7+maCfoGGqBPu5vN8rU9gIsW7OatYmn+STBTPkybbtNqeMzAoJDDrXHjsZ89x5dPH9W+OgMpNLtN0ouwiMYg=="],
|
||||
"jsonv-ts": ["jsonv-ts@0.8.4", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-TZOyAVGBZxHuzk09NgJCx2dbeh0XqVWVKHU1PtIuvjT9XO7zhvAD02RcVisJoUdt2rJNt3zlyeNQ2b8MMPc+ug=="],
|
||||
|
||||
"jsonwebtoken": ["jsonwebtoken@9.0.2", "", { "dependencies": { "jws": "^3.2.2", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ=="],
|
||||
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
---
|
||||
title: Admin UI
|
||||
tags: ["documentation"]
|
||||
---
|
||||
|
||||
import { TypeTable } from "fumadocs-ui/components/type-table";
|
||||
|
||||
|
||||
bknd features an integrated Admin UI that can be used to:
|
||||
- fully manage your backend visually when run in [`db` mode](/usage/introduction/#ui-only-mode)
|
||||
- manage your database contents
|
||||
- manage your media contents
|
||||
|
||||
In case you're using bknd with a [React framework](integration/introduction/#start-with-a-framework) and render the Admin as React component, you can go further and customize the Admin UI to your liking.
|
||||
|
||||
<AutoTypeTable path="../app/src/ui/Admin.tsx" name="BkndAdminProps" />
|
||||
|
||||
|
||||
## Advanced Example
|
||||
|
||||
The following example shows how to customize the Admin UI for each entity.
|
||||
|
||||
- adds a custom action item to the user menu (top right)
|
||||
- adds a custom action item to the entity list
|
||||
- adds a custom action item to the entity create/update form
|
||||
- overrides the rendering of the title field
|
||||
- renders a custom header for the entity
|
||||
- renders a custom footer for the entity
|
||||
- adds a custom route
|
||||
|
||||
```tsx
|
||||
import { Admin } from "bknd/ui";
|
||||
import { Route } from "wouter";
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<Admin
|
||||
withProvider
|
||||
config={{
|
||||
appShell: {
|
||||
// add a custom user menu item (top right)
|
||||
userMenu: [
|
||||
{
|
||||
label: "Custom",
|
||||
onClick: () => alert("custom"),
|
||||
},
|
||||
],
|
||||
},
|
||||
entities: {
|
||||
// use any entity that is registered
|
||||
tests: {
|
||||
actions: (context, entity, data) => ({
|
||||
primary: [
|
||||
// this action is only rendered in the update context
|
||||
context === "update" && {
|
||||
children: "another",
|
||||
onClick: () => alert("another"),
|
||||
},
|
||||
],
|
||||
context: [
|
||||
// this action is always rendered in the dropdown
|
||||
{
|
||||
label: "Custom",
|
||||
onClick: () =>
|
||||
alert(
|
||||
"custom: " +
|
||||
JSON.stringify({ context, entity: entity.name, data }),
|
||||
),
|
||||
},
|
||||
],
|
||||
}),
|
||||
// render a header for the entity
|
||||
header: (context, entity, data) => <div>test header</div>,
|
||||
// override the rendering of the title field
|
||||
fields: {
|
||||
title: {
|
||||
render: (context, entity, field, ctx) => {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={ctx.value}
|
||||
onChange={(e) => ctx.handleChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// system entities work too
|
||||
users: {
|
||||
header: () => {
|
||||
return <div>System entity</div>;
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* You may also add custom routes, these always have precedence over the Admin routes */}
|
||||
<Route path="/data/custom">
|
||||
<div>custom</div>
|
||||
</Route>
|
||||
</Admin>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
## `config`
|
||||
|
||||
<AutoTypeTable path="../app/src/ui/Admin.tsx" name="BkndAdminConfig" />
|
||||
|
||||
### `entities`
|
||||
|
||||
With the `entities` option, you can customize the Admin UI for each entity. You can override the header, footer, add additional actions, and override each field rendering.
|
||||
|
||||
```ts
|
||||
export type BkndAdminEntityContext = "list" | "create" | "update";
|
||||
|
||||
export type BkndAdminEntitiesOptions = {
|
||||
[E in keyof DB]?: BkndAdminEntityOptions<E>;
|
||||
};
|
||||
|
||||
export type BkndAdminEntityOptions<E extends keyof DB | string> = {
|
||||
/**
|
||||
* Header to be rendered depending on the context
|
||||
*/
|
||||
header?: (
|
||||
context: BkndAdminEntityContext,
|
||||
entity: Entity,
|
||||
data?: DB[E],
|
||||
) => ReactNode | void | undefined;
|
||||
/**
|
||||
* Footer to be rendered depending on the context
|
||||
*/
|
||||
footer?: (
|
||||
context: BkndAdminEntityContext,
|
||||
entity: Entity,
|
||||
data?: DB[E],
|
||||
) => ReactNode | void | undefined;
|
||||
/**
|
||||
* Actions to be rendered depending on the context
|
||||
*/
|
||||
actions?: (
|
||||
context: BkndAdminEntityContext,
|
||||
entity: Entity,
|
||||
data?: DB[E],
|
||||
) => {
|
||||
/**
|
||||
* Primary actions are always visible
|
||||
*/
|
||||
primary?: (ButtonProps | undefined | null | false)[];
|
||||
/**
|
||||
* Context actions are rendered in a dropdown
|
||||
*/
|
||||
context?: DropdownProps["items"];
|
||||
};
|
||||
/**
|
||||
* Field UI overrides
|
||||
*/
|
||||
fields?: {
|
||||
[F in keyof DB[E]]?: BkndAdminEntityFieldOptions<E>;
|
||||
};
|
||||
};
|
||||
|
||||
export type BkndAdminEntityFieldOptions<E extends keyof DB | string> = {
|
||||
/**
|
||||
* Override the rendering of a certain field
|
||||
*/
|
||||
render?: (
|
||||
context: BkndAdminEntityContext,
|
||||
entity: Entity,
|
||||
field: Field,
|
||||
ctx: {
|
||||
data?: DB[E];
|
||||
value?: DB[E][keyof DB[E]];
|
||||
handleChange: (value: any) => void;
|
||||
},
|
||||
) => ReactNode | void | undefined;
|
||||
};
|
||||
```
|
||||
|
||||
|
||||
### `appShell`
|
||||
|
||||
```ts
|
||||
export type DropdownItem =
|
||||
| (() => ReactNode)
|
||||
| {
|
||||
label: string | ReactElement;
|
||||
icon?: any;
|
||||
onClick?: () => void;
|
||||
destructive?: boolean;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
export type BkndAdminAppShellOptions = {
|
||||
userMenu?: (DropdownItem | undefined | boolean)[];
|
||||
};
|
||||
```
|
||||
@@ -23,26 +23,6 @@ export default {
|
||||
} satisfies BkndConfig;
|
||||
```
|
||||
|
||||
## Overview
|
||||
|
||||
The `BkndConfig` extends the [`CreateAppConfig`](/usage/introduction#configuration-createappconfig) type with the following properties:
|
||||
|
||||
{/* <AutoTypeTable path="../app/src/adapter/index.ts" name="BkndConfig" /> */}
|
||||
|
||||
|
||||
```typescript
|
||||
export type BkndConfig = CreateAppConfig & {
|
||||
// return the app configuration as object or from a function
|
||||
app?: CreateAppConfig | ((args: Args) => CreateAppConfig);
|
||||
// called before the app is built
|
||||
beforeBuild?: (app: App) => Promise<void>;
|
||||
// called after the app has been built
|
||||
onBuilt?: (app: App) => Promise<void>;
|
||||
// passed as the first argument to the `App.build` method
|
||||
buildConfig?: Parameters<App["build"]>[0];
|
||||
};
|
||||
```
|
||||
|
||||
The supported configuration file extensions are `js`, `ts`, `mjs`, `cjs` and `json`. Throughout the documentation, we'll use `ts` for the file extension.
|
||||
|
||||
## Example
|
||||
@@ -74,7 +54,177 @@ export default {
|
||||
} satisfies BkndConfig;
|
||||
```
|
||||
|
||||
### `app` (CreateAppConfig)
|
||||
|
||||
## Configuration (`BkndConfig`)
|
||||
|
||||
The `BkndConfig` type is the main configuration object for the `createApp` function. It has
|
||||
the following properties:
|
||||
|
||||
```typescript
|
||||
import type { App, InitialModuleConfigs, ModuleBuildContext, Connection, MaybePromise } from "bknd";
|
||||
import type { Config } from "@libsql/client";
|
||||
|
||||
type AppPlugin = (app: App) => Promise<void> | void;
|
||||
type ManagerOptions = {
|
||||
basePath?: string;
|
||||
trustFetched?: boolean;
|
||||
onFirstBoot?: () => Promise<void>;
|
||||
seed?: (ctx: ModuleBuildContext) => Promise<void>;
|
||||
};
|
||||
|
||||
type BkndConfig<Args = any> = {
|
||||
connection?: Connection | Config;
|
||||
config?: InitialModuleConfigs;
|
||||
options?: {
|
||||
plugins?: AppPlugin[];
|
||||
manager?: ManagerOptions;
|
||||
};
|
||||
app?: BkndConfig<Args> | ((args: Args) => MaybePromise<BkndConfig<Args>>);
|
||||
onBuilt?: (app: App) => Promise<void>;
|
||||
beforeBuild?: (app?: App) => Promise<void>;
|
||||
buildConfig?: {
|
||||
sync?: boolean;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### `connection`
|
||||
|
||||
The `connection` property is the main connection object to the database. It can be either an object with libsql config or the actual `Connection` class.
|
||||
|
||||
```ts
|
||||
// uses the default SQLite connection depending on the runtime
|
||||
const connection = { url: "<url>" };
|
||||
|
||||
// the same as above, but more explicit
|
||||
import { sqlite } from "bknd/adapter/sqlite";
|
||||
const connection = sqlite({ url: "<url>" });
|
||||
|
||||
// Node.js SQLite, default on Node.js
|
||||
import { nodeSqlite } from "bknd/adapter/node";
|
||||
const connection = nodeSqlite({ url: "<url>" });
|
||||
|
||||
// Bun SQLite, default on Bun
|
||||
import { bunSqlite } from "bknd/adapter/bun";
|
||||
const connection = bunSqlite({ url: "<url>" });
|
||||
|
||||
// LibSQL, default on Cloudflare
|
||||
import { libsql } from "bknd";
|
||||
const connection = libsql({ url: "<url>" });
|
||||
```
|
||||
|
||||
See a full list of available connections in the [Database](/usage/database) section. Alternatively, you can pass an instance of a `Connection` class directly, see [Custom Connection](/usage/database#custom-connection) as a reference.
|
||||
|
||||
If the connection object is omitted, the app will try to use an in-memory database.
|
||||
|
||||
### `config`
|
||||
|
||||
As configuration, you can either pass a partial configuration object or a complete one
|
||||
with a version number. The version number is used to automatically migrate the configuration up
|
||||
to the latest version upon boot ([`db` mode](/usage/introduction#ui-only-mode) only). The default configuration looks like this:
|
||||
|
||||
```json
|
||||
{
|
||||
"server": {
|
||||
"cors": {
|
||||
"origin": "*",
|
||||
"allow_methods": [
|
||||
"GET",
|
||||
"POST",
|
||||
"PATCH",
|
||||
"PUT",
|
||||
"DELETE"
|
||||
],
|
||||
"allow_headers": [
|
||||
"Content-Type",
|
||||
"Content-Length",
|
||||
"Authorization",
|
||||
"Accept"
|
||||
],
|
||||
"allow_credentials": true
|
||||
},
|
||||
"mcp": {
|
||||
"enabled": false,
|
||||
"path": "/api/system/mcp",
|
||||
"logLevel": "emergency"
|
||||
}
|
||||
},
|
||||
"data": {
|
||||
"basepath": "/api/data",
|
||||
"default_primary_format": "integer",
|
||||
"entities": {},
|
||||
"relations": {},
|
||||
"indices": {}
|
||||
},
|
||||
"auth": {
|
||||
"enabled": false,
|
||||
"basepath": "/api/auth",
|
||||
"entity_name": "users",
|
||||
"allow_register": true,
|
||||
"jwt": {
|
||||
"secret": "",
|
||||
"alg": "HS256",
|
||||
"expires": 0,
|
||||
"issuer": "",
|
||||
"fields": [
|
||||
"id",
|
||||
"email",
|
||||
"role"
|
||||
]
|
||||
},
|
||||
"cookie": {
|
||||
"path": "/",
|
||||
"sameSite": "strict",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"expires": 604800,
|
||||
"partitioned": false,
|
||||
"renew": true,
|
||||
"pathSuccess": "/",
|
||||
"pathLoggedOut": "/"
|
||||
},
|
||||
"strategies": {
|
||||
"password": {
|
||||
"type": "password",
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"hashing": "sha256"
|
||||
}
|
||||
}
|
||||
},
|
||||
"guard": {
|
||||
"enabled": false
|
||||
},
|
||||
"roles": {}
|
||||
},
|
||||
"media": {
|
||||
"enabled": false,
|
||||
"basepath": "/api/media",
|
||||
"entity_name": "media",
|
||||
"storage": {}
|
||||
},
|
||||
"flows": {
|
||||
"basepath": "/api/flows",
|
||||
"flows": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You can use the [CLI](/usage/cli/#getting-the-configuration-config) to get the default configuration:
|
||||
|
||||
```sh
|
||||
npx bknd config --default --pretty
|
||||
```
|
||||
|
||||
To validate your configuration against a JSON schema, you can also dump the schema using the CLI:
|
||||
|
||||
```sh
|
||||
npx bknd schema
|
||||
```
|
||||
|
||||
To create an initial data structure, you can use helpers [described here](/usage/database#data-structure).
|
||||
|
||||
### `app`
|
||||
|
||||
The `app` property is a function that returns a `CreateAppConfig` object. It allows accessing the adapter specific environment variables. This is especially useful when using the [Cloudflare Workers](/integration/cloudflare) runtime, where the environment variables are only available inside the request handler.
|
||||
|
||||
@@ -129,6 +279,52 @@ export default {
|
||||
};
|
||||
```
|
||||
|
||||
### `options.plugins`
|
||||
|
||||
The `plugins` property is an array of functions that are called after the app has been built,
|
||||
but before its event is emitted. This is useful for adding custom routes or other functionality.
|
||||
A simple plugin that adds a custom route looks like this:
|
||||
|
||||
```ts
|
||||
import type { AppPlugin } from "bknd";
|
||||
|
||||
export const myPlugin: AppPlugin = (app) => {
|
||||
app.server.get("/hello", (c) => c.json({ hello: "world" }));
|
||||
};
|
||||
```
|
||||
|
||||
Since each plugin has full access to the `app` instance, it can add routes, modify the database
|
||||
structure, add custom middlewares, respond to or add events, etc. Plugins are very powerful, so
|
||||
make sure to only run trusted ones.
|
||||
|
||||
Read more about plugins in the [Plugins](/extending/plugins) section.
|
||||
|
||||
### `options.seed`
|
||||
|
||||
<Callout type="info">
|
||||
The seed function will only be executed on app's first boot in `"db"` mode. If a configuration already exists in the database, or in `"code"` mode, it will not be executed.
|
||||
</Callout>
|
||||
|
||||
The `seed` property is a function that is called when the app is booted for the first time. It is used to seed the database with initial data. The function is passed a `ModuleBuildContext` object:
|
||||
|
||||
```ts
|
||||
type ModuleBuildContext = {
|
||||
connection: Connection;
|
||||
server: Hono;
|
||||
em: EntityManager;
|
||||
emgr: EventManager;
|
||||
guard: Guard;
|
||||
};
|
||||
|
||||
const seed = async (ctx: ModuleBuildContext) => {
|
||||
// seed the database
|
||||
await ctx.em.mutator("todos").insertMany([
|
||||
{ title: "Learn bknd", done: true },
|
||||
{ title: "Build something cool", done: false },
|
||||
]);
|
||||
};
|
||||
```
|
||||
|
||||
## Framework & Runtime configuration
|
||||
|
||||
Depending on which framework or runtime you're using to run bknd, the configuration object will extend the `BkndConfig` type with additional properties.
|
||||
|
||||
@@ -116,6 +116,36 @@ export default {
|
||||
} satisfies BkndConfig;
|
||||
```
|
||||
|
||||
### `syncSecrets`
|
||||
|
||||
A simple plugin that writes down the app secrets on boot and each build.
|
||||
|
||||
```typescript title="bknd.config.ts"
|
||||
import { syncSecrets } from "bknd/plugins";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
|
||||
export default {
|
||||
options: {
|
||||
plugins: [
|
||||
syncSecrets({
|
||||
// whether to enable the plugin, make sure to disable in production
|
||||
enabled: true,
|
||||
// your writing function (required)
|
||||
write: async (secrets) => {
|
||||
// apply your writing logic, e.g. writing a template file in an env format
|
||||
await writeFile(
|
||||
".env.example",
|
||||
Object.entries(secrets)
|
||||
.map(([key]) => `${key}=`)
|
||||
.join("\n")
|
||||
);
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
} satisfies BkndConfig;
|
||||
```
|
||||
|
||||
### `showRoutes`
|
||||
|
||||
A simple plugin that logs the routes of your app in the console.
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"./extending/config",
|
||||
"./extending/events",
|
||||
"./extending/plugins",
|
||||
"./extending/admin",
|
||||
"---Integration---",
|
||||
"./integration/introduction",
|
||||
"./integration/(frameworks)/",
|
||||
|
||||
@@ -4,6 +4,12 @@ description: "Easily implement reliable authentication strategies."
|
||||
tags: ["documentation"]
|
||||
---
|
||||
|
||||
<Callout type="info" title="The documentation is currently a work in progress">
|
||||
Check back soon — or stay updated on our progress on
|
||||
[GitHub](https://github.com/bknd-io/bknd) and join the conversation in
|
||||
[Discord](https://discord.gg/952SFk8Tb8).
|
||||
</Callout>
|
||||
|
||||
Authentication is essential for securing applications, and **bknd** provides a straightforward approach to implementing robust strategies.
|
||||
|
||||
### **Core Features**
|
||||
|
||||
@@ -4,6 +4,12 @@ description: "Define, query, and control your data with ease."
|
||||
tags: ["documentation"]
|
||||
---
|
||||
|
||||
<Callout type="info" title="The documentation is currently a work in progress">
|
||||
Check back soon — or stay updated on our progress on
|
||||
[GitHub](https://github.com/bknd-io/bknd) and join the conversation in
|
||||
[Discord](https://discord.gg/952SFk8Tb8).
|
||||
</Callout>
|
||||
|
||||
Data is the lifeblood of any application, and **bknd** provides the tools to handle it effortlessly. From defining entities to executing complex queries, bknd offers a developer-friendly, intuitive, and powerful data management experience.
|
||||
|
||||
### **Define Your Data**
|
||||
|
||||
@@ -4,6 +4,12 @@ description: "Effortlessly manage and serve all your media files."
|
||||
tags: ["documentation"]
|
||||
---
|
||||
|
||||
<Callout type="info" title="The documentation is currently a work in progress">
|
||||
Check back soon — or stay updated on our progress on
|
||||
[GitHub](https://github.com/bknd-io/bknd) and join the conversation in
|
||||
[Discord](https://discord.gg/952SFk8Tb8).
|
||||
</Callout>
|
||||
|
||||
**bknd** provides a flexible and efficient way to handle media files, making it easy to upload, manage, and deliver content across various platforms.
|
||||
|
||||
### **Core Features**
|
||||
@@ -36,13 +42,13 @@ There are two ways to enable S3 or S3 compatible storage in bknd.
|
||||
|
||||
2. Enable programmatically.
|
||||
|
||||
To enable using a code-first approach, create an [Initial Structure](/usage/database#initial-structure) using the `initialConfig` option in your `bknd.config.ts` file.
|
||||
To enable using a code-first approach, create corresponding configuration using the `config` option in your `bknd.config.ts` file.
|
||||
|
||||
```typescript title="bknd.config.ts"
|
||||
import type { BkndConfig } from "bknd/adapter";
|
||||
|
||||
export default {
|
||||
initialConfig: {
|
||||
config: {
|
||||
media: {
|
||||
enabled: true,
|
||||
adapter: {
|
||||
@@ -70,7 +76,7 @@ import type { BkndConfig } from "bknd/adapter";
|
||||
const local = registerLocalMediaAdapter();
|
||||
|
||||
export default {
|
||||
initialConfig: {
|
||||
config: {
|
||||
media: {
|
||||
enabled: true,
|
||||
adapter: local({
|
||||
|
||||
@@ -6,6 +6,12 @@ tags: ["documentation"]
|
||||
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
<Callout type="info" title="The documentation is currently a work in progress">
|
||||
Check back soon — or stay updated on our progress on
|
||||
[GitHub](https://github.com/bknd-io/bknd) and join the conversation in
|
||||
[Discord](https://discord.gg/952SFk8Tb8).
|
||||
</Callout>
|
||||
|
||||
This backend system focuses on 4 essential building blocks which can be tightly connected:
|
||||
Data, Auth, Media and Flows.
|
||||
The main idea is to supply all baseline functionality required in order to accomplish complex
|
||||
|
||||
@@ -6,29 +6,34 @@ tags: ["documentation"]
|
||||
|
||||
import { Icon } from "@iconify/react";
|
||||
import { examples } from "@/app/_components/StackBlitz";
|
||||
import { SquareMousePointer, Code, Blend } from 'lucide-react';
|
||||
|
||||
|
||||
Glad you're here! **bknd** is a lightweight, infrastructure agnostic and feature-rich backend that runs in any JavaScript environment.
|
||||
|
||||
- Instant backend with full REST API
|
||||
- Built on Web Standards for maximum compatibility
|
||||
- Multiple run modes (standalone, runtime, framework)
|
||||
- Official API and React SDK with type-safety
|
||||
- React elements for auto-configured authentication and media components
|
||||
- Multiple ready-made [integrations](/integration/introduction) (standalone, runtime, framework)
|
||||
- Official [API SDK](/usage/sdk) and [React SDK](/usage/react) with type-safety
|
||||
- [React elements](/usage/elements) for auto-configured authentication and media components
|
||||
- Built-in [MCP server](/usage/mcp/overview) for controlling your backend
|
||||
- Multiple run [modes](/usage/introduction#modes) (ui-only, code-only, hybrid)
|
||||
|
||||
## Preview
|
||||
|
||||
Here is a preview of **bknd** in StackBlitz:
|
||||
|
||||
<StackBlitz path="github/bknd-io/bknd-demo" initialPath="/" />
|
||||
<Card className="p-0 pb-1">
|
||||
<StackBlitz path="github/bknd-io/bknd-demo" initialPath="/" />
|
||||
<Accordions className="m-1">
|
||||
<Accordion title="What's going on?">
|
||||
The example shown is starting a [node server](/integration/node) using an [in-memory database](/usage/database#sqlite-in-memory). To ensure there are a few entities defined, it is using an [initial structure](/usage/database#initial-structure) using the prototype methods. Furthermore it uses the [seed option](/usage/database#seeding-the-database) to seed some data in the structure created.
|
||||
|
||||
<Accordions>
|
||||
<Accordion title="What's going on?">
|
||||
The example shown is starting a [node server](/integration/node) using an [in-memory database](/usage/database#sqlite-in-memory). To ensure there are a few entities defined, it is using an [initial structure](/usage/database#initial-structure) using the prototype methods. Furthermore it uses the [seed option](/usage/database#seeding-the-database) to seed some data in the structure created.
|
||||
To ensure there are users defined on first boot, it hooks into the `App.Events.AppFirstBoot` event to create them (documentation pending).
|
||||
|
||||
To ensure there are users defined on first boot, it hooks into the `App.Events.AppFirstBoot` event to create them (documentation pending).
|
||||
|
||||
</Accordion>
|
||||
</Accordions>
|
||||
</Accordion>
|
||||
</Accordions>
|
||||
</Card>
|
||||
|
||||
## Quickstart
|
||||
|
||||
@@ -156,3 +161,19 @@ The following databases are currently supported. Request a new integration if yo
|
||||
Create a new issue to request a new database integration.
|
||||
</Card>
|
||||
</Cards>
|
||||
|
||||
## Choose a mode
|
||||
|
||||
**bknd** supports multiple modes to suit your needs.
|
||||
|
||||
<Cards className="grid-cols-3">
|
||||
<Card title="UI-only" href="/usage/introduction#ui-only-mode" icon={<SquareMousePointer className="text-fd-primary !size-6" />}>
|
||||
Configure your backend and manage your data visually with the built-in Admin UI.
|
||||
</Card>
|
||||
<Card title="Code-only" href="/usage/introduction#code-only-mode" icon={<Code className="text-fd-primary !size-6" />}>
|
||||
Configure your backend programmatically with a Drizzle-like API, manage your data with the Admin UI.
|
||||
</Card>
|
||||
<Card title="Hybrid" href="/usage/introduction#hybrid-mode" icon={<Blend className="text-fd-primary !size-6" />}>
|
||||
Configure your backend visually while in development, use a read-only configuration in production.
|
||||
</Card>
|
||||
</Cards>
|
||||
@@ -1,23 +1,22 @@
|
||||
---
|
||||
title: "Using the CLI"
|
||||
description: "How to start a bknd instance using the CLI."
|
||||
icon: Terminal
|
||||
tags: ["documentation"]
|
||||
---
|
||||
|
||||
The bknd package includes a command-line interface (CLI) that allows you to run a bknd instance and perform various tasks.
|
||||
|
||||
```
|
||||
```sh
|
||||
npx bknd
|
||||
```
|
||||
|
||||
Here is the output:
|
||||
|
||||
```
|
||||
```sh
|
||||
$ npx bknd
|
||||
Usage: bknd [options] [command]
|
||||
|
||||
⚡ bknd cli v0.17.0
|
||||
|
||||
Options:
|
||||
-V, --version output the version number
|
||||
-h, --help display help for command
|
||||
@@ -40,7 +39,7 @@ Commands:
|
||||
|
||||
To see all available `run` options, execute `npx bknd run --help`.
|
||||
|
||||
```
|
||||
```sh
|
||||
$ npx bknd run --help
|
||||
Usage: bknd run [options]
|
||||
|
||||
@@ -98,7 +97,7 @@ The `app` function is useful if you need a cross-platform way to access the envi
|
||||
|
||||
If you're using `npx bknd run`, make sure to create a file in a file format that `node` can load, otherwise you may run into an error that the file couldn't be found:
|
||||
|
||||
```sh
|
||||
```
|
||||
[INF] 2025-03-28 18:02:21 Using config from bknd.config.ts
|
||||
[ERR] 2025-03-28 18:02:21 Failed to load config: Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'bknd.config.ts' imported from [...]
|
||||
at packageResolve (node:internal/modules/esm/resolve:857:9)
|
||||
@@ -123,7 +122,7 @@ npx tsx node_modules/.bin/bknd run
|
||||
|
||||
To start an instance with a Turso/LibSQL database, run the following:
|
||||
|
||||
```
|
||||
```sh
|
||||
npx bknd run --db-url libsql://your-db.turso.io --db-token <your-token>
|
||||
```
|
||||
|
||||
@@ -133,7 +132,7 @@ The `--db-token` option is optional and only required if the database is protect
|
||||
|
||||
To start an instance with an ephemeral in-memory database, run the following:
|
||||
|
||||
```
|
||||
```sh
|
||||
npx bknd run --memory
|
||||
```
|
||||
|
||||
@@ -143,7 +142,7 @@ Keep in mind that the database is not persisted and will be lost when the proces
|
||||
|
||||
To see all available `types` options, execute `npx bknd types --help`.
|
||||
|
||||
```
|
||||
```sh
|
||||
$ npx bknd types --help
|
||||
Usage: bknd types [options]
|
||||
|
||||
@@ -157,13 +156,13 @@ Options:
|
||||
|
||||
To generate types for the database, run the following:
|
||||
|
||||
```
|
||||
```sh
|
||||
npx bknd types
|
||||
```
|
||||
|
||||
This will generate types for your database schema in `bknd-types.d.ts`. The generated file could look like this:
|
||||
|
||||
```typescript bknd-types.d.ts
|
||||
```typescript title="bknd-types.d.ts"
|
||||
import type { DB } from "bknd";
|
||||
import type { Insertable, Selectable, Updateable, Generated } from "kysely";
|
||||
|
||||
@@ -190,7 +189,7 @@ declare module "bknd" {
|
||||
|
||||
Make sure to add the generated file in your `tsconfig.json` file:
|
||||
|
||||
```json tsconfig.json
|
||||
```json title="tsconfig.json"
|
||||
{
|
||||
"include": ["bknd-types.d.ts"]
|
||||
}
|
||||
@@ -204,5 +203,82 @@ import type { DB } from "bknd";
|
||||
type Todo = DB["todos"];
|
||||
```
|
||||
|
||||
All bknd methods that involve your database schema will be automatically typed.
|
||||
All bknd methods that involve your database schema will be automatically typed. You may use the [`syncTypes`](/extending/plugins/#synctypes) plugin to automatically write the types to a file.
|
||||
|
||||
## Getting the configuration (`config`)
|
||||
|
||||
To see all available `config` options, execute `npx bknd config --help`.
|
||||
|
||||
```sh
|
||||
$ npx bknd config --help
|
||||
Usage: bknd config [options]
|
||||
|
||||
get app config
|
||||
|
||||
Options:
|
||||
-c, --config <config> config file
|
||||
--db-url <db> database url, can be any valid sqlite url
|
||||
--pretty pretty print
|
||||
--default use default config
|
||||
--secrets include secrets in output
|
||||
--out <file> output file
|
||||
-h, --help display help for command
|
||||
```
|
||||
|
||||
To get the configuration of your app, and to write it to a file, run the following:
|
||||
|
||||
```sh
|
||||
npx bknd config --out appconfig.json
|
||||
```
|
||||
|
||||
To get a template configuration instead, run the following:
|
||||
|
||||
```sh
|
||||
npx bknd config --default
|
||||
```
|
||||
|
||||
To automatically sync your configuration to a file, you may also use the [`syncConfig`](/extending/plugins/#syncconfig) plugin.
|
||||
|
||||
## Getting the secrets (`secrets`)
|
||||
|
||||
To see all available `secrets` options, execute `npx bknd secrets --help`.
|
||||
|
||||
```sh
|
||||
$ npx bknd secrets --help
|
||||
Usage: bknd secrets [options]
|
||||
|
||||
get app secrets
|
||||
|
||||
Options:
|
||||
-c, --config <config> config file
|
||||
--db-url <db> database url, can be any valid sqlite url
|
||||
--template template output without the actual secrets
|
||||
--format <format> format output (choices: "json", "env", default:
|
||||
"json")
|
||||
--out <file> output file
|
||||
-h, --help display help for command
|
||||
```
|
||||
|
||||
To automatically sync your secrets to a file, you may also use the [`syncSecrets`](/extending/plugins/#syncsecrets) plugin.
|
||||
|
||||
## Syncing the database (`sync`)
|
||||
|
||||
Sync your database can be useful when running in [`code`](/usage/introduction/#code-only-mode) mode. When you're ready to deploy, you can point to the production configuration and sync the database. Schema mutations are only applied when running with the `--force` option.
|
||||
|
||||
```bash
|
||||
$ npx bknd sync --help
|
||||
Usage: bknd sync [options]
|
||||
|
||||
sync database
|
||||
|
||||
Options:
|
||||
-c, --config <config> config file
|
||||
--db-url <db> database url, can be any valid sqlite url
|
||||
--seed perform seeding operations
|
||||
--force perform database syncing operations
|
||||
--drop include destructive DDL operations
|
||||
--out <file> output file
|
||||
--sql use sql output
|
||||
-h, --help display help for command
|
||||
```
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
title: "Database"
|
||||
description: "Choosing the right database configuration"
|
||||
icon: Database
|
||||
tags: ["documentation"]
|
||||
---
|
||||
|
||||
@@ -313,15 +314,11 @@ const connection = new CustomConnection();
|
||||
const app = createApp({ connection });
|
||||
```
|
||||
|
||||
## Initial Structure
|
||||
## Data Structure
|
||||
|
||||
To provide an initial database structure, you can pass `initialConfig` to the creation of an app. This will only be used if there isn't an existing configuration found in the database given. Here is a quick example:
|
||||
|
||||
<Callout type="info">
|
||||
The initial structure is only respected if the database is empty! If you made
|
||||
updates, ensure to delete the database first, or perform updates through the
|
||||
Admin UI.
|
||||
</Callout>
|
||||
To provide a database structure, you can pass `config` to the creation of an app. In [`db` mode](/usage/introduction#ui-only-mode), the data structure is only respected if the database is empty. If you made updates, ensure to delete the database first, or perform updates through the Admin UI.
|
||||
|
||||
Here is a quick example:
|
||||
|
||||
```typescript
|
||||
import { createApp, em, entity, text, number } from "bknd";
|
||||
@@ -366,10 +363,7 @@ type Database = (typeof schema)["DB"];
|
||||
|
||||
// pass the schema to the app
|
||||
const app = createApp({
|
||||
connection: {
|
||||
/* ... */
|
||||
},
|
||||
initialConfig: {
|
||||
config: {
|
||||
data: schema.toJSON(),
|
||||
},
|
||||
});
|
||||
@@ -472,7 +466,7 @@ const schema = em(
|
||||
|
||||
To get type completion, there are two options:
|
||||
|
||||
1. Use the CLI to [generate the types](/usage/cli#generating-types-types)
|
||||
1. Use the CLI to [generate the types](/usage/cli#generating-types-types) (recommended)
|
||||
2. If you have an initial structure created with the prototype functions, you can extend the `DB` interface with your own schema.
|
||||
|
||||
All entity related functions use the types defined in `DB` from `bknd`. To get type completion, you can extend that interface with your own schema:
|
||||
@@ -481,18 +475,14 @@ All entity related functions use the types defined in `DB` from `bknd`. To get t
|
||||
import { em } from "bknd";
|
||||
import { Api } from "bknd/client";
|
||||
|
||||
const schema = em({
|
||||
/* ... */
|
||||
});
|
||||
const schema = em({ /* ... */ });
|
||||
|
||||
type Database = (typeof schema)["DB"];
|
||||
declare module "bknd" {
|
||||
interface DB extends Database {}
|
||||
}
|
||||
|
||||
const api = new Api({
|
||||
/* ... */
|
||||
});
|
||||
const api = new Api({ /* ... */ });
|
||||
const { data: posts } = await api.data.readMany("posts", {});
|
||||
// `posts` is now typed as Database["posts"]
|
||||
```
|
||||
@@ -504,21 +494,13 @@ The type completion is available for the API as well as all provided [React hook
|
||||
To seed your database with initial data, you can pass a `seed` function to the configuration. It
|
||||
provides the `ModuleBuildContext` as the first argument.
|
||||
|
||||
<Callout type="info">
|
||||
Note that the seed function will only be executed on app's first boot. If a
|
||||
configuration already exists in the database, it will not be executed.
|
||||
</Callout>
|
||||
|
||||
```typescript
|
||||
import { createApp, type ModuleBuildContext } from "bknd";
|
||||
|
||||
const app = createApp({
|
||||
connection: {
|
||||
/* ... */
|
||||
},
|
||||
initialConfig: {
|
||||
/* ... */
|
||||
},
|
||||
connection: { /* ... */ },
|
||||
config: { /* ... */ },
|
||||
options: {
|
||||
seed: async (ctx: ModuleBuildContext) => {
|
||||
await ctx.em.mutator("posts").insertMany([
|
||||
@@ -529,3 +511,13 @@ const app = createApp({
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Note that in [`db` mode](/usage/introduction#ui-only-mode), the seed function will only be executed on app's first boot. If a configuration already exists in the database, it will not be executed.
|
||||
|
||||
In [`code` mode](/usage/introduction#code-only-mode), the seed function will not be automatically executed. You can manually execute it by running the following command:
|
||||
|
||||
```bash
|
||||
npx bknd sync --seed --force
|
||||
```
|
||||
|
||||
See the [sync command](/usage/cli#syncing-the-database-sync) documentation for more details.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
title: "React Elements"
|
||||
description: "Speed up your frontend development"
|
||||
icon: Box
|
||||
tags: ["documentation"]
|
||||
---
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
---
|
||||
title: "Introduction"
|
||||
description: "Setting up bknd"
|
||||
icon: Pin
|
||||
tags: ["documentation"]
|
||||
---
|
||||
|
||||
import { TypeTable } from "fumadocs-ui/components/type-table";
|
||||
import { SquareMousePointer, Code, Blend } from 'lucide-react';
|
||||
|
||||
There are several methods to get **bknd** up and running. You can choose between these options:
|
||||
|
||||
1. [Run it using the CLI](/usage/cli): That's the easiest and fastest way to get started.
|
||||
@@ -22,12 +26,12 @@ Regardless of the method you choose, at the end all adapters come down to the ac
|
||||
instantiation of the `App`, which in raw looks like this:
|
||||
|
||||
```typescript
|
||||
import { createApp, type CreateAppConfig } from "bknd";
|
||||
import { createApp, type BkndConfig } from "bknd";
|
||||
|
||||
// create the app
|
||||
const config = {
|
||||
/* ... */
|
||||
} satisfies CreateAppConfig;
|
||||
} satisfies BkndConfig;
|
||||
const app = createApp(config);
|
||||
|
||||
// build the app
|
||||
@@ -40,189 +44,144 @@ export default app;
|
||||
In Web API compliant environments, all you have to do is to default exporting the app, as it
|
||||
implements the `Fetch` API.
|
||||
|
||||
## Configuration (`CreateAppConfig`)
|
||||
## Modes
|
||||
|
||||
The `CreateAppConfig` type is the main configuration object for the `createApp` function. It has
|
||||
the following properties:
|
||||
Main project goal is to provide a backend that can be configured visually with the built-in Admin UI. However, you may instead want to configure your backend programmatically, and define your data structure with a Drizzle-like API:
|
||||
|
||||
```typescript
|
||||
import type { App, InitialModuleConfigs, ModuleBuildContext, Connection } from "bknd";
|
||||
import type { Config } from "@libsql/client";
|
||||
<Cards className="grid-cols-1 sm:grid-cols-2 md:grid-cols-3">
|
||||
<Card title="UI-only" href="/usage/introduction#ui-only-mode" icon={<SquareMousePointer className="text-fd-primary !size-6" />}>
|
||||
This is the default mode, it allows visual configuration and saves the configuration to the database. Expects you to deploy your backend separately from your frontend.
|
||||
</Card>
|
||||
<Card title="Code-only" href="/usage/introduction#code-only-mode" icon={<Code className="text-fd-primary !size-6" />}>
|
||||
This mode allows you to configure your backend programmatically, and define your data structure with a Drizzle-like API. Visual configuration controls are disabled.
|
||||
</Card>
|
||||
<Card title={"Hybrid"} href="/usage/introduction#hybrid-mode" icon={<Blend className="text-fd-primary !size-6" />}>
|
||||
This mode allows you to configure your backend visually while in development, and uses the produced configuration in a code-only mode for maximum performance.
|
||||
</Card>
|
||||
</Cards>
|
||||
|
||||
type AppPlugin = (app: App) => Promise<void> | void;
|
||||
type ManagerOptions = {
|
||||
basePath?: string;
|
||||
trustFetched?: boolean;
|
||||
onFirstBoot?: () => Promise<void>;
|
||||
seed?: (ctx: ModuleBuildContext) => Promise<void>;
|
||||
};
|
||||
In the following sections, we'll cover the different modes in more detail. The configuration properties involved are the following:
|
||||
|
||||
type CreateAppConfig = {
|
||||
connection?: Connection | Config;
|
||||
initialConfig?: InitialModuleConfigs;
|
||||
options?: {
|
||||
plugins?: AppPlugin[];
|
||||
manager?: ManagerOptions;
|
||||
};
|
||||
};
|
||||
```
|
||||
```typescript title="bknd.config.ts"
|
||||
import type { BkndConfig } from "bknd";
|
||||
|
||||
### `connection`
|
||||
|
||||
The `connection` property is the main connection object to the database. It can be either an object with libsql config or the actual `Connection` class.
|
||||
|
||||
```ts
|
||||
const connection = {
|
||||
url: "<url>",
|
||||
authToken: "<token>",
|
||||
};
|
||||
```
|
||||
|
||||
Alternatively, you can pass an instance of a `Connection` class directly,
|
||||
see [Custom Connection](/usage/database#custom-connection) as a reference.
|
||||
|
||||
If the connection object is omitted, the app will try to use an in-memory database.
|
||||
|
||||
### `initialConfig`
|
||||
|
||||
As [initial configuration](/usage/database#initial-structure), you can either pass a partial configuration object or a complete one
|
||||
with a version number. The version number is used to automatically migrate the configuration up
|
||||
to the latest version upon boot. The default configuration looks like this:
|
||||
|
||||
```json
|
||||
{
|
||||
"server": {
|
||||
"admin": {
|
||||
"basepath": "",
|
||||
"color_scheme": "light",
|
||||
"logo_return_path": "/"
|
||||
export default {
|
||||
config: { /* ... */ }
|
||||
options: {
|
||||
mode: "db", // or "code"
|
||||
manager: {
|
||||
secrets: { /* ... */ },
|
||||
storeSecrets: true,
|
||||
},
|
||||
"cors": {
|
||||
"origin": "*",
|
||||
"allow_methods": ["GET", "POST", "PATCH", "PUT", "DELETE"],
|
||||
"allow_headers": [
|
||||
"Content-Type",
|
||||
"Content-Length",
|
||||
"Authorization",
|
||||
"Accept"
|
||||
]
|
||||
}
|
||||
} satisfies BkndConfig;
|
||||
```
|
||||
|
||||
<TypeTable type={{
|
||||
config: {
|
||||
description: "The initial configuration when `mode` is `\"db\"`, and as the produced configuration when `mode` is `\"code\"`.",
|
||||
type: "object",
|
||||
properties: {
|
||||
/* ... */
|
||||
}
|
||||
},
|
||||
"data": {
|
||||
"basepath": "/api/data",
|
||||
"entities": {},
|
||||
"relations": {},
|
||||
"indices": {}
|
||||
["options.mode"]: {
|
||||
description: "The options for the app.",
|
||||
type: '"db" | "code"',
|
||||
default: '"db"'
|
||||
},
|
||||
"auth": {
|
||||
"enabled": false,
|
||||
"basepath": "/api/auth",
|
||||
"entity_name": "users",
|
||||
"allow_register": true,
|
||||
"jwt": {
|
||||
"secret": "",
|
||||
"alg": "HS256",
|
||||
"fields": ["id", "email", "role"]
|
||||
},
|
||||
"cookie": {
|
||||
"path": "/",
|
||||
"sameSite": "lax",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"expires": 604800,
|
||||
"renew": true,
|
||||
"pathSuccess": "/",
|
||||
"pathLoggedOut": "/"
|
||||
},
|
||||
"strategies": {
|
||||
"password": {
|
||||
"type": "password",
|
||||
"config": {
|
||||
"hashing": "sha256"
|
||||
}
|
||||
}
|
||||
},
|
||||
"roles": {}
|
||||
["options.manager.secrets"]: {
|
||||
description: "The app secrets to be provided when using `\"db\"` mode. This is required since secrets are extracted and stored separately to the database.",
|
||||
type: "object",
|
||||
properties: {
|
||||
/* ... */
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"enabled": false,
|
||||
"basepath": "/api/media",
|
||||
"entity_name": "media",
|
||||
"storage": {}
|
||||
},
|
||||
"flows": {
|
||||
"basepath": "/api/flows",
|
||||
"flows": {}
|
||||
["options.manager.storeSecrets"]: {
|
||||
description: "Whether to store secrets in the database when using `\"db\"` mode.",
|
||||
type: "boolean",
|
||||
default: "true"
|
||||
}
|
||||
}
|
||||
}} />
|
||||
|
||||
### UI-only mode
|
||||
|
||||
This mode is the default mode. It allows you to configure your backend visually with the built-in Admin UI. It expects that you deploy your backend separately from your frontend, and make changes there. No configuration is needed, however, if you want to provide an initial configuration, you can do so by passing a `config` object.
|
||||
|
||||
```typescript
|
||||
import type { BkndConfig } from "bknd";
|
||||
|
||||
export default {
|
||||
// this will only be applied if the database is empty
|
||||
config: { /* ... */ },
|
||||
} satisfies BkndConfig;
|
||||
```
|
||||
|
||||
You can use the CLI to get the default configuration:
|
||||
### Code-only mode
|
||||
|
||||
```sh
|
||||
npx bknd config --pretty
|
||||
This mode allows you to configure your backend programmatically, and define your data structure with a Drizzle-like API. Visual configuration controls are disabled.
|
||||
|
||||
```typescript title="bknd.config.ts"
|
||||
import { type BkndConfig, em, entity, text, boolean } from "bknd";
|
||||
import { secureRandomString } from "bknd/utils";
|
||||
|
||||
const schema = em({
|
||||
todos: entity("todos", {
|
||||
title: text(),
|
||||
done: boolean(),
|
||||
}),
|
||||
});
|
||||
|
||||
export default {
|
||||
// example configuration
|
||||
config: {
|
||||
data: schema.toJSON(),
|
||||
auth: {
|
||||
enabled: true,
|
||||
jwt: {
|
||||
secret: secureRandomString(64),
|
||||
},
|
||||
}
|
||||
},
|
||||
options: {
|
||||
// this ensures that the provided configuration is always used
|
||||
mode: "code",
|
||||
},
|
||||
} satisfies BkndConfig;
|
||||
```
|
||||
|
||||
To validate your configuration against a JSON schema, you can also dump the schema using the CLI:
|
||||
### Hybrid mode
|
||||
|
||||
```sh
|
||||
npx bknd schema
|
||||
This mode allows you to configure your backend visually while in development, and uses the produced configuration in a code-only mode for maximum performance. It gives you the best of both worlds.
|
||||
|
||||
While in development, we set the mode to `"db"` where the configuration is stored in the database. When it's time to deploy, we export the configuration, and set the mode to `"code"`. While in `"db"` mode, the `config` property interprets the value as an initial configuration to use when the database is empty.
|
||||
|
||||
```typescript title="bknd.config.ts"
|
||||
import type { BkndConfig } from "bknd";
|
||||
|
||||
// import your produced configuration
|
||||
import appConfig from "./appconfig.json" with { type: "json" };
|
||||
|
||||
export default {
|
||||
config: appConfig,
|
||||
options: {
|
||||
mode: process.env.NODE_ENV === "development" ? "db" : "code",
|
||||
manager: {
|
||||
secrets: process.env
|
||||
}
|
||||
},
|
||||
} satisfies BkndConfig;
|
||||
```
|
||||
|
||||
To create an initial data structure, you can use helpers [described here](/usage/database#initial-structure).
|
||||
To keep your config, secrets and types in sync, you can either use the CLI or the plugins.
|
||||
|
||||
### `options.plugins`
|
||||
|
||||
The `plugins` property is an array of functions that are called after the app has been built,
|
||||
but before its event is emitted. This is useful for adding custom routes or other functionality.
|
||||
A simple plugin that adds a custom route looks like this:
|
||||
| Type | Plugin | CLI Command |
|
||||
|----------------|-----------------------------------------------------------------------|----------------------------|
|
||||
| Configuration | [`syncConfig`](/extending/plugins/#syncconfig) | [`config`](/usage/cli/#getting-the-configuration-config) |
|
||||
| Secrets | [`syncSecrets`](/extending/plugins/#syncsecrets) | [`secrets`](/usage/cli/#getting-the-secrets-secrets) |
|
||||
| Types | [`syncTypes`](/extending/plugins/#synctypes) | [`types`](/usage/cli/#generating-types-types) |
|
||||
|
||||
```ts
|
||||
import type { AppPlugin } from "bknd";
|
||||
|
||||
export const myPlugin: AppPlugin = (app) => {
|
||||
app.server.get("/hello", (c) => c.json({ hello: "world" }));
|
||||
};
|
||||
```
|
||||
|
||||
Since each plugin has full access to the `app` instance, it can add routes, modify the database
|
||||
structure, add custom middlewares, respond to or add events, etc. Plugins are very powerful, so
|
||||
make sure to only run trusted ones.
|
||||
|
||||
### `options.seed`
|
||||
|
||||
The `seed` property is a function that is called when the app is booted for the first time. It is used to seed the database with initial data. The function is passed a `ModuleBuildContext` object:
|
||||
|
||||
```ts
|
||||
type ModuleBuildContext = {
|
||||
connection: Connection;
|
||||
server: Hono;
|
||||
em: EntityManager;
|
||||
emgr: EventManager;
|
||||
guard: Guard;
|
||||
};
|
||||
|
||||
const seed = async (ctx: ModuleBuildContext) => {
|
||||
// seed the database
|
||||
await ctx.em.mutator("todos").insertMany([
|
||||
{ title: "Learn bknd", done: true },
|
||||
{ title: "Build something cool", done: false },
|
||||
]);
|
||||
};
|
||||
```
|
||||
|
||||
### `options.manager`
|
||||
|
||||
This object is passed to the `ModuleManager` which is responsible for:
|
||||
|
||||
- validating and maintaining configuration of all modules
|
||||
- building all modules (data, auth, media, flows)
|
||||
- maintaining the `ModuleBuildContext` used by the modules
|
||||
|
||||
The `options.manager` object has the following properties:
|
||||
|
||||
- `basePath` (`string`): The base path for the Hono instance. This is used to prefix all routes.
|
||||
- `trustFetched` (`boolean`): If set to `true`, the app will not perform any validity checks for
|
||||
the given or fetched configuration.
|
||||
- `onFirstBoot` (`() => Promise<void>`): A function that is called when the app is booted for
|
||||
the first time.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"title": "MCP",
|
||||
"icon": "Mcp",
|
||||
"pages": ["overview", "tools-resources"]
|
||||
}
|
||||
|
||||
@@ -28,13 +28,13 @@ Once enabled, you can access the MCP UI at `/mcp`, or choose "MCP" from the top
|
||||
|
||||
## Enable MCP
|
||||
|
||||
If you're using `initialConfig`, you can enable the MCP server by setting the `server.mcp.enabled` property to `true`.
|
||||
If you're using a `config`, you can enable the MCP server by setting the `server.mcp.enabled` property to `true`.
|
||||
|
||||
```typescript
|
||||
import type { BkndConfig } from "bknd";
|
||||
|
||||
export default {
|
||||
initialConfig: {
|
||||
config: {
|
||||
server: {
|
||||
mcp: {
|
||||
enabled: true,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: "MCP"
|
||||
title: "Tools & Resources"
|
||||
description: "Tools & Resources of the built-in full featured MCP server."
|
||||
tags: ["documentation"]
|
||||
---
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
title: "SDK (React)"
|
||||
description: "Use the bknd SDK for React"
|
||||
icon: React
|
||||
tags: ["documentation"]
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
title: "SDK (TypeScript)"
|
||||
description: "Use the bknd SDK in TypeScript"
|
||||
icon: TypeScript
|
||||
tags: ["documentation"]
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { icons } from "lucide-react";
|
||||
import { TbBrandReact, TbBrandTypescript } from "react-icons/tb";
|
||||
|
||||
const McpIcon = () => (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
height="1em"
|
||||
style={{ flex: "none", lineHeight: "1" }}
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>ModelContextProtocol</title>
|
||||
<path d="M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z" />
|
||||
<path d="M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default {
|
||||
...icons,
|
||||
React: TbBrandReact,
|
||||
TypeScript: TbBrandTypescript,
|
||||
Mcp: McpIcon,
|
||||
};
|
||||
+20
-14
@@ -1,23 +1,29 @@
|
||||
import { loader } from "fumadocs-core/source";
|
||||
import { docs } from "@/.source";
|
||||
import { createOpenAPI, attachFile } from "fumadocs-openapi/server";
|
||||
import { icons } from "lucide-react";
|
||||
import icons from "./icons";
|
||||
import { createElement } from "react";
|
||||
import { TbBrandReact, TbBrandTypescript } from "react-icons/tb";
|
||||
|
||||
const add_icons = {
|
||||
TypeScript: TbBrandTypescript,
|
||||
React: TbBrandReact,
|
||||
};
|
||||
|
||||
export const source = loader({
|
||||
baseUrl: "/",
|
||||
source: docs.toFumadocsSource(),
|
||||
pageTree: {
|
||||
// adds a badge to each page item in page tree
|
||||
attachFile,
|
||||
},
|
||||
icon(icon) {
|
||||
if (!icon) {
|
||||
// You may set a default icon
|
||||
return;
|
||||
}
|
||||
if (icon in icons) return createElement(icons[icon as keyof typeof icons]);
|
||||
},
|
||||
baseUrl: "/",
|
||||
source: docs.toFumadocsSource(),
|
||||
pageTree: {
|
||||
// adds a badge to each page item in page tree
|
||||
attachFile,
|
||||
},
|
||||
icon(icon) {
|
||||
if (!icon) {
|
||||
// You may set a default icon
|
||||
return;
|
||||
}
|
||||
if (icon in icons) return createElement(icons[icon as keyof typeof icons]);
|
||||
},
|
||||
});
|
||||
|
||||
export const openapi = createOpenAPI();
|
||||
|
||||
Generated
+10
@@ -23,6 +23,7 @@
|
||||
"next": "15.3.5",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"twoslash": "^0.3.2"
|
||||
},
|
||||
@@ -10352,6 +10353,15 @@
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-icons": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
|
||||
"integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"next": "15.3.5",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"twoslash": "^0.3.2"
|
||||
},
|
||||
|
||||
+24
-30
@@ -1,11 +1,5 @@
|
||||
import { remarkInstall } from "fumadocs-docgen";
|
||||
import {
|
||||
defineConfig,
|
||||
defineDocs,
|
||||
frontmatterSchema,
|
||||
metaSchema,
|
||||
} from "fumadocs-mdx/config";
|
||||
|
||||
import { defineConfig, defineDocs, frontmatterSchema, metaSchema } from "fumadocs-mdx/config";
|
||||
import { transformerTwoslash } from "fumadocs-twoslash";
|
||||
import { createFileSystemTypesCache } from "fumadocs-twoslash/cache-fs";
|
||||
import { rehypeCodeDefaultOptions } from "fumadocs-core/mdx-plugins";
|
||||
@@ -14,31 +8,31 @@ import { remarkAutoTypeTable } from "fumadocs-typescript";
|
||||
// You can customise Zod schemas for frontmatter and `meta.json` here
|
||||
// see https://fumadocs.vercel.app/docs/mdx/collections#define-docs
|
||||
export const docs = defineDocs({
|
||||
docs: {
|
||||
schema: frontmatterSchema,
|
||||
},
|
||||
meta: {
|
||||
schema: metaSchema,
|
||||
},
|
||||
docs: {
|
||||
schema: frontmatterSchema,
|
||||
},
|
||||
meta: {
|
||||
schema: metaSchema,
|
||||
},
|
||||
});
|
||||
|
||||
export default defineConfig({
|
||||
mdxOptions: {
|
||||
remarkPlugins: [remarkInstall, remarkAutoTypeTable],
|
||||
rehypeCodeOptions: {
|
||||
lazy: true,
|
||||
experimentalJSEngine: true,
|
||||
langs: ["ts", "js", "html", "tsx", "mdx"],
|
||||
themes: {
|
||||
light: "light-plus",
|
||||
dark: "dark-plus",
|
||||
mdxOptions: {
|
||||
remarkPlugins: [remarkInstall, remarkAutoTypeTable],
|
||||
rehypeCodeOptions: {
|
||||
lazy: true,
|
||||
experimentalJSEngine: true,
|
||||
langs: ["ts", "js", "html", "tsx", "mdx"],
|
||||
themes: {
|
||||
light: "light-plus",
|
||||
dark: "dark-plus",
|
||||
},
|
||||
transformers: [
|
||||
...(rehypeCodeDefaultOptions.transformers ?? []),
|
||||
transformerTwoslash({
|
||||
typesCache: createFileSystemTypesCache(),
|
||||
}),
|
||||
],
|
||||
},
|
||||
transformers: [
|
||||
...(rehypeCodeDefaultOptions.transformers ?? []),
|
||||
transformerTwoslash({
|
||||
typesCache: createFileSystemTypesCache(),
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ const config = {
|
||||
connection: {
|
||||
url: "file:data.db",
|
||||
},
|
||||
initialConfig: {
|
||||
config: {
|
||||
media: {
|
||||
enabled: true,
|
||||
adapter: {
|
||||
|
||||
@@ -87,7 +87,7 @@ async function setup(opts?: {
|
||||
const app = App.create({
|
||||
connection,
|
||||
// an initial config is only applied if the database is empty
|
||||
initialConfig: {
|
||||
config: {
|
||||
data: schema.toJSON(),
|
||||
},
|
||||
options: {
|
||||
|
||||
Reference in New Issue
Block a user