Compare commits

..

1 Commits

Author SHA1 Message Date
dswbx 7d89fe4894 feat: enhance entity field management with configurable order
Added optional `fields_order` to the entity configuration schema, allowing for custom field sorting. Updated `getFields` method to support sorting based on this configuration. Adjusted related methods and UI components to handle field order during updates and serialization. Improved handling of virtual fields in various contexts.
2025-09-20 14:00:19 +02:00
88 changed files with 474 additions and 2336 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: "1.2.22"
bun-version: "1.2.19"
- name: Install dependencies
working-directory: ./app
Binary file not shown.
-1
View File
@@ -1 +0,0 @@
hello
-2
View File
@@ -16,7 +16,6 @@ describe("AppServer", () => {
mcp: {
enabled: false,
path: "/api/system/mcp",
logLevel: "warning",
},
});
}
@@ -39,7 +38,6 @@ describe("AppServer", () => {
mcp: {
enabled: false,
path: "/api/system/mcp",
logLevel: "warning",
},
});
}
-127
View File
@@ -1,127 +0,0 @@
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();
});
});
-1
View File
@@ -44,7 +44,6 @@ describe("mcp auth", async () => {
},
});
await app.build();
await app.getMcpClient().ping();
server = app.mcp!;
server.setLogLevel("error");
server.onNotification((message) => {
-5
View File
@@ -34,11 +34,6 @@ 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);
});
});
-1
View File
@@ -50,7 +50,6 @@ describe("mcp data", async () => {
},
});
await app.build();
await app.getMcpClient().ping();
server = app.mcp!;
server.setLogLevel("error");
server.onNotification((message) => {
-1
View File
@@ -39,7 +39,6 @@ describe("mcp media", async () => {
},
});
await app.build();
await app.getMcpClient().ping();
server = app.mcp!;
server.setLogLevel("error");
server.onNotification((message) => {
-1
View File
@@ -24,7 +24,6 @@ describe("mcp system", async () => {
},
});
await app.build();
await app.getMcpClient().ping();
server = app.mcp!;
});
-1
View File
@@ -23,7 +23,6 @@ describe("mcp system", async () => {
},
});
await app.build();
await app.getMcpClient().ping();
server = app.mcp!;
});
+1 -35
View File
@@ -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,38 +94,4 @@ 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();
});
});
-37
View File
@@ -71,8 +71,6 @@ 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) {
@@ -90,9 +88,6 @@ 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) {
@@ -107,36 +102,4 @@ 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,22 +1,18 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { App, type InitialModuleConfigs, createApp } from "/";
import { type InitialModuleConfigs, createApp } from "../../../src";
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,
opts?: { beforeCreateApp?: (db: Kysely<any>) => Promise<void> },
) {
async function createVersionedApp(config: InitialModuleConfigs | any) {
const { dummyConnection } = getDummyConnection();
if (!("version" in config)) throw new Error("config must have a version");
@@ -42,10 +38,6 @@ async function createVersionedApp(
})
.execute();
if (opts?.beforeCreateApp) {
await opts.beforeCreateApp(db);
}
const app = createApp({
connection: dummyConnection,
});
@@ -53,19 +45,6 @@ async function createVersionedApp(
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
@@ -103,30 +82,4 @@ 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^^",
);
});
});
@@ -1,612 +0,0 @@
{
"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
View File
@@ -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", "bknd", "bknd/*", ...deps];
const external = ["jsonv-ts/*", "wrangler", ...deps];
if (process.env.DEBUG) {
const result = await esbuild.build({
-1
View File
@@ -272,7 +272,6 @@ async function buildAdapters() {
),
tsup.build(
baseConfig("cloudflare/proxy", {
target: "esnext",
entry: ["src/adapter/cloudflare/proxy.ts"],
outDir: "dist/adapter/cloudflare",
metafile: false,
+4 -4
View File
@@ -3,7 +3,7 @@
"type": "module",
"sideEffects": false,
"bin": "./dist/cli/index.js",
"version": "0.18.0",
"version": "0.18.0-rc.4",
"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.22",
"packageManager": "bun@1.2.19",
"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": "NODE_NO_WARNINGS=1 bun run e2e/adapters.ts",
"test:e2e:adapters": "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.4",
"jsonv-ts": "0.8.2",
"kysely": "0.27.6",
"lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1",
+3 -7
View File
@@ -244,10 +244,6 @@ 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;
}
@@ -306,13 +302,13 @@ export class App<
}
getMcpClient() {
const config = this.modules.get("server").config.mcp;
if (!config.enabled) {
if (!this.mcp) {
throw new Error("MCP is not enabled");
}
const mcpPath = this.modules.get("server").config.mcp.path;
return new McpClient({
url: "http://localhost" + config.path,
url: "http://localhost" + mcpPath,
fetch: this.server.request,
});
}
+1 -1
View File
@@ -16,7 +16,7 @@ export {
type GetBindingType,
type BindingMap,
} from "./bindings";
export { constants, makeConfig, type CloudflareContext } from "./config";
export { constants, type CloudflareContext } from "./config";
export { StorageR2Adapter, registerMedia } from "./storage/StorageR2Adapter";
export { registries } from "bknd";
export { devFsVitePlugin, devFsWrite } from "./vite";
+1 -23
View File
@@ -18,29 +18,6 @@ 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,
@@ -54,6 +31,7 @@ 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,8 +28,7 @@ describe("StorageR2Adapter", async () => {
const buffer = readFileSync(path.join(basePath, "image.png"));
const file = new File([buffer], "image.png", { type: "image/png" });
// miniflare doesn't support range requests
await adapterTestSuite(viTestRunner, adapter, file, { testRange: false });
await adapterTestSuite(viTestRunner, adapter, file);
});
afterAll(async () => {
@@ -80,79 +80,18 @@ 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 filePath = `${this.config.path}/${key}`;
const stats = await stat(filePath);
const fileSize = stats.size;
const content = await readFile(`${this.config.path}/${key}`);
const mimeType = guessMimeType(key);
const responseHeaders = new Headers({
"Accept-Ranges": "bytes",
"Content-Type": mimeType || "application/octet-stream",
return new Response(content, {
status: 200,
headers: {
"Content-Type": mimeType || "application/octet-stream",
"Content-Length": content.length.toString(),
},
});
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 });
+2 -5
View File
@@ -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.debug("Serving static files from", root);
$console.log("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.debug("Serving static files from", root);
$console.log("Serving static files from", root);
return m.serveStatic({
root,
onNotFound,
@@ -76,9 +76,6 @@ 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 -1
View File
@@ -2,8 +2,9 @@ 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, registries } from "bknd";
import { config, type App, type CreateAppConfig, type MaybePromise } from "bknd";
import dotenv from "dotenv";
import { registries } from "modules/registries";
import c from "picocolors";
import path from "node:path";
import {
+1 -16
View File
@@ -8,7 +8,6 @@ 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")
@@ -30,22 +29,8 @@ 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.dim(`Executed ${c.cyan(stmts.length)} statement(s)`)}`);
console.info(`\n${c.gray(`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);
+1 -1
View File
@@ -7,7 +7,7 @@ import { getVersion } from "./utils/sys";
import { capture, flush, init } from "cli/utils/telemetry";
const program = new Command();
async function main() {
export async function main() {
await init();
capture("start");
+2 -34
View File
@@ -12,7 +12,6 @@ export {
getMcpServer,
stdioTransport,
McpClient,
logLevels as mcpLogLevels,
type McpClientConfig,
type ToolAnnotation,
type ToolHandlerCtx,
@@ -22,35 +21,8 @@ export { secret, SecretSchema } from "./secret";
export { s };
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 stripMark = <O extends object>(o: O): O => o;
export const mark = <O extends object>(o: O): O => o;
export const stringIdentifier = s.string({
pattern: "^[a-zA-Z_][a-zA-Z0-9_]*$",
@@ -102,10 +74,6 @@ 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
-12
View File
@@ -230,15 +230,3 @@ 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,8 +55,7 @@ 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'
WHERE i.name not like 'sqlite_%'
AND im.type = 'index'
) AS indices
FROM sqlite_master m
WHERE m.type IN ('table', 'view')
+29 -6
View File
@@ -18,6 +18,7 @@ export const entityConfigSchema = s
sort_field: s.string({ default: config.data.default_primary_field }),
sort_dir: s.string({ enum: ["asc", "desc"], default: "asc" }),
primary_format: s.string({ enum: primaryFieldTypes }),
fields_order: s.array(s.string()).optional(),
},
{ default: {} },
)
@@ -137,7 +138,9 @@ export class Entity<
}
getFillableFields(context?: TActionContext, include_virtual?: boolean): Field[] {
return this.getFields(include_virtual).filter((field) => field.isFillable(context));
return this.getFields({ virtual: include_virtual }).filter((field) =>
field.isFillable(context),
);
}
getRequiredFields(): Field[] {
@@ -189,9 +192,24 @@ export class Entity<
return this.fields.findIndex((field) => field.name === name) !== -1;
}
getFields(include_virtual: boolean = false): Field[] {
if (include_virtual) return this.fields;
return this.fields.filter((f) => !f.isVirtual());
getFields(opts?: { virtual?: boolean; sorted?: boolean }): Field[] {
const fields = [...this.fields];
const sort = opts?.sorted !== false;
const fields_order = this.config.fields_order;
if (fields_order && fields_order.length > 0 && sort) {
if (fields_order.length !== this.fields.length) {
$console.warn("Fields order must contain all fields");
} else {
// sort fields by order
fields.sort((a, b) => {
const a_index = fields_order.indexOf(a.name);
const b_index = fields_order.indexOf(b.name);
return a_index - b_index;
});
}
}
if (opts?.virtual) return fields;
return fields.filter((f) => !f.isVirtual());
}
addField(field: Field) {
@@ -275,7 +293,7 @@ export class Entity<
fields = this.getFillableFields(options.context);
break;
default:
fields = this.getFields(true);
fields = this.getFields({ virtual: true });
}
const _fields = Object.fromEntries(fields.map((field) => [field.name, field]));
@@ -309,7 +327,12 @@ export class Entity<
toJSON() {
return {
type: this.type,
fields: Object.fromEntries(this.fields.map((field) => [field.name, field.toJSON()])),
fields: Object.fromEntries(
this.getFields({ virtual: true, sorted: true }).map((field) => [
field.name,
field.toJSON(),
]),
),
config: this.config,
};
}
@@ -1,54 +0,0 @@
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;");
});
});
+1 -1
View File
@@ -40,7 +40,7 @@ const systemEntities = {
export class EntityTypescript {
constructor(
protected em: EntityManager<any>,
protected em: EntityManager,
protected _options: EntityTypescriptOptions = {},
) {}
+2 -2
View File
@@ -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, config?: EntityConfig) {
return entity<E, SystemEntities[E] & Fields>(name, fields as any, config, "system");
>(name: E, fields: Fields) {
return entity<E, SystemEntities[E] & Fields>(name, fields as any);
}
export function relation<Local extends Entity>(local: Local) {
@@ -5,7 +5,6 @@ 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;
@@ -18,7 +17,6 @@ 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(): TextField {
return new TextField("entity_id", { hidden: true, fillable: ["create"] });
getEntityIdField(): NumberField {
return new NumberField("entity_id", { hidden: true, fillable: ["create"] });
}
initialize(em: EntityManager<any>) {
+1 -2
View File
@@ -84,10 +84,9 @@ export class RelationField extends Field<RelationFieldConfig> {
}
override toType(): TFieldTSType {
const type = this.config.target_field_type === "integer" ? "number" : "string";
return {
...super.toType(),
type,
type: "number",
};
}
}
+1 -2
View File
@@ -65,7 +65,6 @@ export class SchemaManager {
if (SchemaManager.EXCLUDE_TABLES.includes(table.name)) {
continue;
}
if (!table.name) continue;
cleanTables.push({
...table,
@@ -77,7 +76,7 @@ export class SchemaManager {
}
getIntrospectionFromEntity(entity: Entity): IntrospectedTable {
const fields = entity.getFields(false);
const fields = entity.getFields({ virtual: false, sorted: true });
const indices = this.em.getIndicesOf(entity);
// this is intentionally setting values to defaults, like "nullable" and "default"
-25
View File
@@ -8,7 +8,6 @@ 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>;
@@ -140,30 +139,6 @@ 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() {
-4
View File
@@ -43,10 +43,6 @@ export class MediaField<
return this.config.max_items;
}
getAllowedMimeTypes(): string[] | undefined {
return this.config.mime_types;
}
getMinItems(): number | undefined {
return this.config.min_items;
}
+8 -15
View File
@@ -71,29 +71,22 @@ export class Storage implements EmitsEvents {
let info: FileUploadPayload = {
name,
meta: isFile(file)
? {
size: file.size,
type: file.type,
}
: {
size: 0,
type: "application/octet-stream",
},
meta: {
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 (
!info.meta.type ||
["application/octet-stream", "application/json"].includes(info.meta.type) ||
!info.meta.size
) {
if (!isMimeType(info.meta.type, ["application/octet-stream", "application/json"])) {
const meta = await this.#adapter.getObjectMeta(name);
if (!meta) {
throw new Error("Failed to get object meta");
@@ -11,14 +11,12 @@ 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;
@@ -55,34 +53,9 @@ 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,19 +22,6 @@ 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,7 +45,6 @@ export class StorageS3Adapter extends StorageAdapter {
accessKeyId: config.access_key,
secretAccessKey: config.secret_access_key,
retries: isDebug() ? 0 : 10,
service: "s3",
},
{
convertParams: "pascalToKebab",
+6 -7
View File
@@ -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", "toml", "json", "json5", "pdf", "xml"],
application: ["zip", "xml", "toml", "json", "json5"],
font: ["woff", "woff2", "ttf", "otf"],
} as const;
@@ -15,13 +15,11 @@ 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],
["txt", c.t()],
["ai", c.a("postscript")],
["ai", c.a("pdf")],
["apk", c.a("vnd.android.package-archive")],
["doc", c.a("msword")],
["docx", `${c.vnd}.wordprocessingml.document`],
@@ -34,12 +32,12 @@ export const M = new Map<string, string>([
["jpg", c.i("jpeg")],
["js", c.t("javascript")],
["log", c.t()],
["m3u", c.au("x-mpegurl")],
["m3u", c.t()],
["m3u8", c.a("vnd.apple.mpegurl")],
["manifest", c.t("cache-manifest")],
["md", c.t("markdown")],
["mkv", c.v("x-matroska")],
["mp3", c.au("mpeg")],
["mp3", c.a("mpeg")],
["mobi", c.a("x-mobipocket-ebook")],
["ppt", c.a("powerpoint")],
["pptx", `${c.vnd}.presentationml.presentation`],
@@ -48,10 +46,11 @@ 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.au("vnd.wav")],
["wav", c.a("x-wav")],
["webmanifest", c.a("manifest+json")],
["xls", c.a("vnd.ms-excel")],
["xlsx", `${c.vnd}.spreadsheetml.sheet`],
+7 -20
View File
@@ -1,13 +1,4 @@
import {
objectEach,
transformObject,
McpServer,
type s,
SecretSchema,
setPath,
mark,
$console,
} from "bknd/utils";
import { objectEach, transformObject, McpServer, type s, SecretSchema, setPath } from "bknd/utils";
import { DebugLogger } from "core/utils/DebugLogger";
import { Guard } from "auth/authorize/Guard";
import { env } from "core/env";
@@ -74,7 +65,7 @@ export type ModuleManagerOptions = {
// callback after server was created
onServerInit?: (server: Hono<ServerEnv>) => void;
// doesn't perform validity checks for given/fetched config
skipValidation?: boolean;
trustFetched?: boolean;
// runs when initial config provided on a fresh database
seed?: (ctx: ModuleBuildContext) => Promise<void>;
// called right after modules are built, before finish
@@ -127,18 +118,13 @@ export class ModuleManager {
constructor(
protected readonly connection: Connection,
public options?: Partial<ModuleManagerOptions>,
protected options?: Partial<ModuleManagerOptions>,
) {
this.modules = {} as Modules;
this.emgr = new EventManager({ ...ModuleManagerEvents });
this.logger = new DebugLogger(debug_modules);
const config = options?.initial ?? {};
if (options?.skipValidation) {
mark(config, true);
}
this.createModules(config);
this.createModules(options?.initial ?? {});
}
protected onModuleConfigUpdated(key: string, config: any) {}
@@ -331,8 +317,9 @@ export class ModuleManager {
ctx.flags.sync_required = false;
this.logger.log("db sync requested");
// ignore sync request on code mode since system tables
// are probably never fully in provided config
// sync db
await ctx.em.schema().sync({ force: true, drop: options?.drop });
state.synced = true;
}
if (ctx.flags.ctx_reload_required) {
+5 -5
View File
@@ -205,7 +205,7 @@ export class DbModuleManager extends ModuleManager {
if (store_secrets) {
updates.push({
version: version,
version: state.configs.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,
version: state.configs.version,
type: "secrets",
json: secrets,
created_at: date,
@@ -380,8 +380,8 @@ export class DbModuleManager extends ModuleManager {
}
}
if (this.options?.skipValidation === true) {
this.logger.log("skipping validation (mark)");
if (this.options?.trustFetched === true) {
this.logger.log("trusting fetched config (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;
-8
View File
@@ -99,14 +99,6 @@ 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;
+1 -1
View File
@@ -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 -5
View File
@@ -1,6 +1,6 @@
import { Exception } from "core/errors";
import { isDebug } from "core/env";
import { $console, mcpLogLevels, s } from "bknd/utils";
import { $console, s } from "bknd/utils";
import { $object } from "modules/mcp";
import { cors } from "hono/cors";
import { Module } from "modules/Module";
@@ -25,10 +25,6 @@ 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",
}),
}),
},
{
+23 -31
View File
@@ -70,41 +70,33 @@ 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({
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,
};
},
server: this._mcpServer,
sessionsEnabled: true,
debug: {
logLevel: config.mcp.logLevel as any,
logLevel: "debug",
explainEndpoint: true,
},
endpoint: {
+27 -29
View File
@@ -8,33 +8,7 @@ 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 "./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;
};
import type { BkndAdminAppShellOptions, BkndAdminEntitiesOptions } from "ui/options";
export type BkndAdminProps = {
/**
@@ -42,13 +16,37 @@ 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?: BkndAdminConfig;
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;
};
children?: ReactNode;
};
-1
View File
@@ -156,7 +156,6 @@ 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;
@@ -120,17 +120,20 @@ function entityFieldActions(bkndActions: TSchemaActions, entityName: string) {
return await bkndActions.add("data", `entities.${entityName}.fields.${name}`, validated);
},
patch: () => null,
set: async (fields: TAppDataEntityFields) => {
set: async (fields: TAppDataEntityFields, order?: string[]) => {
try {
const validated = parse(entityFields, fields, {
skipMark: true,
forceParse: true,
});
const res = await bkndActions.overwrite(
"data",
`entities.${entityName}.fields`,
validated,
);
await bkndActions.overwrite("data", `entities.${entityName}.fields`, validated);
if (order) {
await bkndActions.overwrite(
"data",
`entities.${entityName}.config.fields_order`,
order,
);
}
} catch (e) {
console.error("error", e);
if (e instanceof InvalidSchemaError) {
+6 -21
View File
@@ -42,10 +42,7 @@ export type DropzoneRenderProps = {
showPlaceholder: boolean;
onClick?: (file: { path: string }) => void;
footer?: ReactNode;
dropzoneProps: Pick<
DropzoneProps,
"maxItems" | "placeholder" | "autoUpload" | "flow" | "allowedMimeTypes"
>;
dropzoneProps: Pick<DropzoneProps, "maxItems" | "placeholder" | "autoUpload" | "flow">;
};
export type DropzoneProps = {
@@ -154,7 +151,6 @@ 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);
@@ -173,14 +169,9 @@ export function Dropzone({
return specs.every((spec) => {
if (spec.kind !== "file") {
console.log("not a file", spec.kind);
return false;
}
if (allowedMimeTypes && allowedMimeTypes.length > 0) {
console.log("not allowed mimetype", spec.type);
return allowedMimeTypes.includes(spec.type);
}
return true;
return !(allowedMimeTypes && !allowedMimeTypes.includes(spec.type));
});
}
@@ -368,7 +359,7 @@ export function Dropzone({
state: "uploaded",
};
overrideFile(file.path, newState);
setFileState(file.path, newState.state);
resolve({ ...response, ...file, ...newState });
} catch (e) {
setFileState(file.path, "uploaded", 1);
@@ -391,9 +382,7 @@ export function Dropzone({
};
xhr.setRequestHeader("Accept", "application/json");
const formData = new FormData();
formData.append("file", file.body);
xhr.send(formData);
xhr.send(file.body);
});
}
@@ -422,9 +411,7 @@ export function Dropzone({
const openFileInput = useCallback(() => inputRef.current?.click(), [inputRef]);
const showPlaceholder = useMemo(
() =>
Boolean(
placeholder?.show !== false && (!maxItems || (maxItems && files.length < maxItems)),
),
Boolean(placeholder?.show === true || !maxItems || (maxItems && files.length < maxItems)),
[placeholder, maxItems, files.length],
);
@@ -437,7 +424,6 @@ export function Dropzone({
type: "file",
multiple: !maxItems || maxItems > 1,
onChange: handleFileInputChange,
accept: allowedMimeTypes?.join(","),
},
showPlaceholder,
actions: {
@@ -451,12 +437,11 @@ export function Dropzone({
placeholder,
autoUpload,
flow,
allowedMimeTypes,
},
onClick,
footer,
}),
[maxItems, files.length, flow, placeholder, autoUpload, footer, allowedMimeTypes],
[maxItems, flow, placeholder, autoUpload, footer],
) as unknown as DropzoneRenderProps;
return (
+6 -74
View File
@@ -1,22 +1,7 @@
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,
TbFileTypeCsv,
TbFileText,
TbJson,
TbFileTypePdf,
TbMarkdown,
TbMusic,
TbTrash,
TbUpload,
TbFileTypeTxt,
TbFileTypeXml,
TbZip,
TbFileTypeSql,
} from "react-icons/tb";
import { TbDots, TbExternalLink, TbTrash, TbUpload } 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";
@@ -37,7 +22,7 @@ export const DropzoneInner = ({
inputProps,
showPlaceholder,
actions: { uploadFile, deleteFile, openFileInput },
dropzoneProps: { placeholder, flow, maxItems, allowedMimeTypes },
dropzoneProps: { placeholder, flow },
onClick,
footer,
}: DropzoneRenderProps) => {
@@ -100,7 +85,7 @@ const UploadPlaceholder = ({ onClick, text = "Upload files" }) => {
);
};
type ReducedFile = Omit<FileState, "state" | "progress">;
type ReducedFile = Pick<FileState, "body" | "type" | "path" | "name" | "size">;
export type PreviewComponentProps = {
file: ReducedFile;
fallback?: (props: { file: ReducedFile }) => ReactNode;
@@ -174,9 +159,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-xs md:text-sm font-mono opacity-50 text-nowrap gap-2">
<div className="flex flex-row justify-between text-sm font-mono opacity-50 text-nowrap gap-2">
<span className="truncate select-text">{file.type}</span>
<span className="whitespace-nowrap">{formatNumber.fileSize(file.size)}</span>
<span>{formatNumber.fileSize(file.size)}</span>
</div>
</div>
</div>
@@ -286,59 +271,6 @@ 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 }) => {
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>
);
return <div className="text-xs text-primary/50 text-center">{file.type}</div>;
};
@@ -36,10 +36,6 @@ 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
View File
@@ -1,4 +1,4 @@
export { default as Admin, type BkndAdminProps, type BkndAdminConfig } from "./Admin";
export { default as Admin, type BkndAdminProps } from "./Admin";
export * from "./components/form/json-schema-form";
export { JsonViewer } from "./components/code/JsonViewer";
export type * from "./options";
+2 -66
View File
@@ -1,5 +1,5 @@
import type { ContextModalProps } from "@mantine/modals";
import { type ReactNode, useEffect, useMemo, useState } from "react";
import type { ReactNode } 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-50">
<div className="flex w-full md:w-[calc(100%-300px)] justify-center items-center bg-lightest min-w-0">
<FilePreview file={file} />
</div>
<div className="w-full md:!w-[300px] flex flex-col">
@@ -156,38 +156,12 @@ 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>
@@ -195,44 +169,6 @@ 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,8 +121,6 @@ export function EntityForm({
return custom;
}
}
if (field.isHidden(action)) return;
return (
<EntityFormField
field={field}
@@ -240,10 +238,6 @@ 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];
@@ -264,9 +258,8 @@ function EntityMediaFormField({
<FieldLabel field={field} />
<Media.Dropzone
key={key}
maxItems={maxItems}
allowedMimeTypes={field.getAllowedMimeTypes()}
initialItems={isSingle ? value : undefined}
maxItems={field.getMaxItems()}
initialItems={value} /* @todo: test if better be omitted, so it fetches */
onClick={onClick}
entity={{
name: entity.name,
@@ -275,7 +268,6 @@ function EntityMediaFormField({
}}
query={{
sort: "-id",
limit,
}}
/>
</Formy.Group>
@@ -77,9 +77,6 @@ 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");
}
+10 -3
View File
@@ -155,18 +155,25 @@ const Fields = ({ entity }: { entity: Entity }) => {
const { readonly } = useBknd();
const [res, setRes] = useState<any>();
const ref = useRef<EntityFieldsFormRef>(null);
// @todo: the return of toJSON from Fields doesn't match "type" enum
const initialFields = Object.fromEntries(entity.fields.map((f) => [f.name, f.toJSON()])) as any;
async function handleUpdate() {
if (submitting) return;
setSubmitting(true);
const fields = ref.current?.getData()!;
await actions.entity.patch(entity.name).fields.set(fields);
// check if field order has changed
if (Object.keys(initialFields).join(",") !== Object.keys(fields).join(",")) {
await actions.entity.patch(entity.name).fields.set(fields, Object.keys(fields));
}
setSubmitting(false);
setUpdates((u) => u + 1);
}
// @todo: the return of toJSON from Fields doesn't match "type" enum
const initialFields = Object.fromEntries(entity.fields.map((f) => [f.name, f.toJSON()])) as any;
return (
<AppShell.RouteAwareSectionHeaderAccordionItem
identifier="fields"
+3 -3
View File
@@ -15,7 +15,7 @@
},
"app": {
"name": "bknd",
"version": "0.18.0-rc.6",
"version": "0.18.0-rc.4",
"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.4",
"jsonv-ts": "0.8.2",
"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.4", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-TZOyAVGBZxHuzk09NgJCx2dbeh0XqVWVKHU1PtIuvjT9XO7zhvAD02RcVisJoUdt2rJNt3zlyeNQ2b8MMPc+ug=="],
"jsonv-ts": ["jsonv-ts@0.8.2", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-1Z7+maCfoGGqBPu5vN8rU9gIsW7OatYmn+STBTPkybbtNqeMzAoJDDrXHjsZ89x5dPH9W+OgMpNLtN0ouwiMYg=="],
"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=="],
@@ -1,201 +0,0 @@
---
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,6 +23,26 @@ 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
@@ -54,177 +74,7 @@ export default {
} satisfies BkndConfig;
```
## 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`
### `app` (CreateAppConfig)
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.
@@ -279,52 +129,6 @@ 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,36 +116,6 @@ 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,7 +20,6 @@
"./extending/config",
"./extending/events",
"./extending/plugins",
"./extending/admin",
"---Integration---",
"./integration/introduction",
"./integration/(frameworks)/",
@@ -4,12 +4,6 @@ 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,12 +4,6 @@ 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,12 +4,6 @@ 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**
@@ -42,13 +36,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 corresponding configuration using the `config` option in your `bknd.config.ts` file.
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.
```typescript title="bknd.config.ts"
import type { BkndConfig } from "bknd/adapter";
export default {
config: {
initialConfig: {
media: {
enabled: true,
adapter: {
@@ -76,7 +70,7 @@ import type { BkndConfig } from "bknd/adapter";
const local = registerLocalMediaAdapter();
export default {
config: {
initialConfig: {
media: {
enabled: true,
adapter: local({
@@ -6,12 +6,6 @@ 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
+11 -32
View File
@@ -6,34 +6,29 @@ 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 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)
- Multiple run modes (standalone, runtime, framework)
- Official API and React SDK with type-safety
- React elements for auto-configured authentication and media components
## Preview
Here is a preview of **bknd** in StackBlitz:
<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.
<StackBlitz path="github/bknd-io/bknd-demo" initialPath="/" />
To ensure there are users defined on first boot, it hooks into the `App.Events.AppFirstBoot` event to create them (documentation pending).
<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.
</Accordion>
</Accordions>
</Card>
To ensure there are users defined on first boot, it hooks into the `App.Events.AppFirstBoot` event to create them (documentation pending).
</Accordion>
</Accordions>
## Quickstart
@@ -161,19 +156,3 @@ 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>
+13 -89
View File
@@ -1,22 +1,23 @@
---
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
@@ -39,7 +40,7 @@ Commands:
To see all available `run` options, execute `npx bknd run --help`.
```sh
```
$ npx bknd run --help
Usage: bknd run [options]
@@ -97,7 +98,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)
@@ -122,7 +123,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>
```
@@ -132,7 +133,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
```
@@ -142,7 +143,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]
@@ -156,13 +157,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 title="bknd-types.d.ts"
```typescript bknd-types.d.ts
import type { DB } from "bknd";
import type { Insertable, Selectable, Updateable, Generated } from "kysely";
@@ -189,7 +190,7 @@ declare module "bknd" {
Make sure to add the generated file in your `tsconfig.json` file:
```json title="tsconfig.json"
```json tsconfig.json
{
"include": ["bknd-types.d.ts"]
}
@@ -203,82 +204,5 @@ import type { DB } from "bknd";
type Todo = DB["todos"];
```
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
```
All bknd methods that involve your database schema will be automatically typed.
@@ -1,7 +1,6 @@
---
title: "Database"
description: "Choosing the right database configuration"
icon: Database
tags: ["documentation"]
---
@@ -314,11 +313,15 @@ const connection = new CustomConnection();
const app = createApp({ connection });
```
## Data Structure
## Initial Structure
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:
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>
```typescript
import { createApp, em, entity, text, number } from "bknd";
@@ -363,7 +366,10 @@ type Database = (typeof schema)["DB"];
// pass the schema to the app
const app = createApp({
config: {
connection: {
/* ... */
},
initialConfig: {
data: schema.toJSON(),
},
});
@@ -466,7 +472,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) (recommended)
1. Use the CLI to [generate the types](/usage/cli#generating-types-types)
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:
@@ -475,14 +481,18 @@ 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"]
```
@@ -494,13 +504,21 @@ 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: { /* ... */ },
config: { /* ... */ },
connection: {
/* ... */
},
initialConfig: {
/* ... */
},
options: {
seed: async (ctx: ModuleBuildContext) => {
await ctx.em.mutator("posts").insertMany([
@@ -511,13 +529,3 @@ 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,7 +1,6 @@
---
title: "React Elements"
description: "Speed up your frontend development"
icon: Box
tags: ["documentation"]
---
@@ -1,13 +1,9 @@
---
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.
@@ -26,12 +22,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 BkndConfig } from "bknd";
import { createApp, type CreateAppConfig } from "bknd";
// create the app
const config = {
/* ... */
} satisfies BkndConfig;
} satisfies CreateAppConfig;
const app = createApp(config);
// build the app
@@ -44,144 +40,189 @@ 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.
## Modes
## Configuration (`CreateAppConfig`)
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:
<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>
In the following sections, we'll cover the different modes in more detail. The configuration properties involved are the following:
```typescript title="bknd.config.ts"
import type { BkndConfig } from "bknd";
export default {
config: { /* ... */ }
options: {
mode: "db", // or "code"
manager: {
secrets: { /* ... */ },
storeSecrets: true,
},
}
} 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: {
/* ... */
}
},
["options.mode"]: {
description: "The options for the app.",
type: '"db" | "code"',
default: '"db"'
},
["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: {
/* ... */
}
},
["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.
The `CreateAppConfig` type is the main configuration object for the `createApp` function. It has
the following properties:
```typescript
import type { BkndConfig } from "bknd";
import type { App, InitialModuleConfigs, ModuleBuildContext, Connection } from "bknd";
import type { Config } from "@libsql/client";
export default {
// this will only be applied if the database is empty
config: { /* ... */ },
} satisfies BkndConfig;
type AppPlugin = (app: App) => Promise<void> | void;
type ManagerOptions = {
basePath?: string;
trustFetched?: boolean;
onFirstBoot?: () => Promise<void>;
seed?: (ctx: ModuleBuildContext) => Promise<void>;
};
type CreateAppConfig = {
connection?: Connection | Config;
initialConfig?: InitialModuleConfigs;
options?: {
plugins?: AppPlugin[];
manager?: ManagerOptions;
};
};
```
### Code-only mode
### `connection`
This mode allows you to configure your backend programmatically, and define your data structure with a Drizzle-like API. Visual configuration controls are disabled.
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.
```typescript title="bknd.config.ts"
import { type BkndConfig, em, entity, text, boolean } from "bknd";
import { secureRandomString } from "bknd/utils";
```ts
const connection = {
url: "<url>",
authToken: "<token>",
};
```
const schema = em({
todos: entity("todos", {
title: text(),
done: boolean(),
}),
});
Alternatively, you can pass an instance of a `Connection` class directly,
see [Custom Connection](/usage/database#custom-connection) as a reference.
export default {
// example configuration
config: {
data: schema.toJSON(),
auth: {
enabled: true,
jwt: {
secret: secureRandomString(64),
},
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": "/"
},
"cors": {
"origin": "*",
"allow_methods": ["GET", "POST", "PATCH", "PUT", "DELETE"],
"allow_headers": [
"Content-Type",
"Content-Length",
"Authorization",
"Accept"
]
}
},
options: {
// this ensures that the provided configuration is always used
mode: "code",
"data": {
"basepath": "/api/data",
"entities": {},
"relations": {},
"indices": {}
},
} satisfies BkndConfig;
"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": {}
},
"media": {
"enabled": false,
"basepath": "/api/media",
"entity_name": "media",
"storage": {}
},
"flows": {
"basepath": "/api/flows",
"flows": {}
}
}
```
### Hybrid mode
You can use the CLI to get the default configuration:
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;
```sh
npx bknd config --pretty
```
To keep your config, secrets and types in sync, you can either use the CLI or the plugins.
To validate your configuration against a JSON schema, you can also dump the schema using the CLI:
```sh
npx bknd schema
```
| 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) |
To create an initial data structure, you can use helpers [described here](/usage/database#initial-structure).
### `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.
### `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,5 +1,4 @@
{
"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 a `config`, you can enable the MCP server by setting the `server.mcp.enabled` property to `true`.
If you're using `initialConfig`, you can enable the MCP server by setting the `server.mcp.enabled` property to `true`.
```typescript
import type { BkndConfig } from "bknd";
export default {
config: {
initialConfig: {
server: {
mcp: {
enabled: true,
@@ -1,5 +1,5 @@
---
title: "Tools & Resources"
title: "MCP"
description: "Tools & Resources of the built-in full featured MCP server."
tags: ["documentation"]
---
@@ -1,7 +1,6 @@
---
title: "SDK (React)"
description: "Use the bknd SDK for React"
icon: React
tags: ["documentation"]
---
@@ -1,7 +1,6 @@
---
title: "SDK (TypeScript)"
description: "Use the bknd SDK in TypeScript"
icon: TypeScript
tags: ["documentation"]
---
-25
View File
@@ -1,25 +0,0 @@
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,
};
+14 -20
View File
@@ -1,29 +1,23 @@
import { loader } from "fumadocs-core/source";
import { docs } from "@/.source";
import { createOpenAPI, attachFile } from "fumadocs-openapi/server";
import icons from "./icons";
import { icons } from "lucide-react";
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();
-10
View File
@@ -23,7 +23,6 @@
"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"
},
@@ -10353,15 +10352,6 @@
"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",
-1
View File
@@ -31,7 +31,6 @@
"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"
},
+30 -24
View File
@@ -1,5 +1,11 @@
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";
@@ -8,31 +14,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",
},
transformers: [
...(rehypeCodeDefaultOptions.transformers ?? []),
transformerTwoslash({
typesCache: createFileSystemTypesCache(),
}),
],
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(),
}),
],
},
},
});
+1 -1
View File
@@ -9,7 +9,7 @@ const config = {
connection: {
url: "file:data.db",
},
config: {
initialConfig: {
media: {
enabled: true,
adapter: {
+1 -1
View File
@@ -87,7 +87,7 @@ async function setup(opts?: {
const app = App.create({
connection,
// an initial config is only applied if the database is empty
config: {
initialConfig: {
data: schema.toJSON(),
},
options: {