Compare commits

..

2 Commits

Author SHA1 Message Date
dswbx 2fdaf594f4 experimenting with config history 2025-09-20 14:44:09 +02:00
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
109 changed files with 824 additions and 2752 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
+1 -145
View File
@@ -1,4 +1,4 @@
import { afterAll, beforeAll, describe, expect, it, expectTypeOf } from "bun:test";
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { Guard } from "../../src/auth/authorize/Guard";
import { DataApi } from "../../src/data/api/DataApi";
import { DataController } from "../../src/data/api/DataController";
@@ -7,7 +7,6 @@ import * as proto from "../../src/data/prototype";
import { schemaToEm } from "../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import { parse } from "core/utils/schema";
import type { Generated } from "kysely";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
@@ -220,147 +219,4 @@ describe("DataApi", () => {
expect(() => api.createMany("posts", [])).toThrow();
}
});
describe("types", async () => {
const schema = proto.em(
{
posts: proto.entity("posts", { title: proto.text(), count: proto.number() }),
comments: proto.entity("comments", { text: proto.text() }),
},
(fn, s) => {
fn.relation(s.comments).manyToOne(s.posts);
},
);
const em = schemaToEm(schema);
await em.schema().sync({ force: true });
const data = {
posts: [
{ title: "foo", count: 0 },
{ title: "bar", count: 0 },
{ title: "baz", count: 0 },
{ title: "bla", count: 2 },
],
comments: [
{ text: "comment1", posts_id: 1 },
{ text: "comment2", posts_id: 1 },
{ text: "comment3", posts_id: 2 },
],
};
const ctx: any = { em, guard: new Guard() };
const controller = new DataController(ctx, dataConfig);
const app = controller.getController();
type Posts = {
id: Generated<number>;
title?: string;
count?: number;
comments?: Comments[];
};
type Comments = {
id: Generated<number>;
text?: string;
posts_id?: number;
posts?: Posts;
};
type DB = {
posts: Posts;
comments: Comments;
};
const api = new DataApi<DB>({ basepath: "/" }, app.request);
for (const [entity, payload] of Object.entries(data)) {
await api.createMany(entity as any, payload);
}
it("readOne", async () => {
const result = await api.readOne("posts", 1);
const expected = { id: 1, title: "foo", count: 0 } as any;
expect(result.res).toBeInstanceOf(Response);
expect(result.data).toEqual(expected);
expect(result.body.meta.items).toEqual(1);
expectTypeOf<(typeof result)["data"]>().toEqualTypeOf<Posts | null>();
{
// not found
const result = await api.readOne("posts", 0);
expect(result.res.status).toEqual(404);
expect(result.data).toBeNull();
expect(result.body.meta.items).toEqual(0);
expectTypeOf<(typeof result)["data"]>().toEqualTypeOf<Posts | null>();
}
});
it("readOneBy", async () => {
const result = await api.readOneBy("posts", { where: { title: "foo" } });
const expected = { id: 1, title: "foo", count: 0 } as any;
expect(result.res.status).toEqual(200);
expect(result.data).toEqual(expected);
// @ts-expect-error body data is typed same as data...
expect(result.body.data).toEqual([expected]); // should be array
expect(result.body.meta.items).toEqual(1);
expectTypeOf<(typeof result)["data"]>().toEqualTypeOf<Posts | null>();
{
// not found
const result = await api.readOneBy("posts", { where: { title: "not found" } });
// since we're filtering, the result is okay, but empty
expect(result.res.status).toEqual(200);
expect(result.data).toBeNull();
// @ts-expect-error body data is typed same as data...
expect(result.body.data).toEqual([]);
expect(result.body.meta.items).toEqual(0);
expectTypeOf<(typeof result)["data"]>().toEqualTypeOf<Posts | null>();
}
});
it("readMany", async () => {
const result = await api.readMany("posts", { where: { title: "foo" } });
const expected = [{ id: 1, title: "foo", count: 0 }] as any;
expect(result.res.status).toEqual(200);
expect(result.data).toEqual(expected);
expect(result.body.data).toEqual(expected);
expect(result.body.meta.items).toEqual(1);
expectTypeOf<(typeof result)["data"]>().toEqualTypeOf<Posts[]>();
{
// not found
const result = await api.readMany("posts", { where: { title: "not found" } });
expect(result.res.status).toEqual(200);
expect(result.data).toEqual([]);
expect(result.body.meta.items).toEqual(0);
expectTypeOf<(typeof result)["data"]>().toEqualTypeOf<Posts[]>();
}
});
it("readManyByReference", async () => {
const result = await api.readManyByReference("posts", 1, "comments");
const expected = [
{ id: 1, text: "comment1", posts_id: 1 },
{ id: 2, text: "comment2", posts_id: 1 },
] as any;
expect(result.res.status).toEqual(200);
expect(result.data).toEqual(expected);
expect(result.body.meta.items).toEqual(2);
expectTypeOf<(typeof result)["data"]>().toEqualTypeOf<Comments[]>();
{
// empty
const result = await api.readManyByReference("posts", 3, "comments");
expect(result.res.status).toEqual(200);
expect(result.data).toEqual([]);
expect(result.body.meta.items).toEqual(0);
expectTypeOf<(typeof result)["data"]>().toEqualTypeOf<Comments[]>();
}
{
// non existing (expected, since only 1 query is performed)
const result = await api.readManyByReference("posts", 100, "comments");
expect(result.res.status).toEqual(200);
expect(result.data).toEqual([]);
}
});
});
});
-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.1",
"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.5",
"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");
+1 -2
View File
@@ -1,7 +1,6 @@
/**
* These are package global defaults.
*/
import type { EntityData } from "data/entities";
import type { Generated } from "kysely";
export type PrimaryFieldType<IdType = number | string> = IdType | Generated<IdType>;
@@ -17,7 +16,7 @@ export interface DB {
[key: string]: any;
}; */
// @todo: that's not good, but required for admin options
[key: string]: EntityData;
[key: string]: any;
}
export const config = {
+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
+26 -27
View File
@@ -1,4 +1,4 @@
import type { DB as DefaultDB, EntityData, RepoQueryIn } from "bknd";
import type { DB, EntityData, RepoQueryIn } from "bknd";
import type { Insertable, Selectable, Updateable } from "kysely";
import { type BaseModuleApiOptions, ModuleApi, type PrimaryFieldType } from "modules";
@@ -10,9 +10,7 @@ export type DataApiOptions = BaseModuleApiOptions & {
defaultQuery: Partial<RepoQueryIn>;
};
type EntityObject<DB, E> = E extends keyof DB ? DB[E] : EntityData;
export class DataApi<DB = DefaultDB> extends ModuleApi<DataApiOptions> {
export class DataApi extends ModuleApi<DataApiOptions> {
protected override getDefaultOptions(): Partial<DataApiOptions> {
return {
basepath: "/api/data",
@@ -34,26 +32,29 @@ export class DataApi<DB = DefaultDB> extends ModuleApi<DataApiOptions> {
id: PrimaryFieldType,
query: Omit<RepoQueryIn, "where" | "limit" | "offset"> = {},
) {
type T = RepositoryResultJSON<EntityObject<DB, E> | null>;
return this.get<T>(["entity", entity as any, id], query);
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
return this.get<RepositoryResultJSON<Data>>(["entity", entity as any, id], query);
}
readOneBy<E extends keyof DB | string>(
entity: E,
query: Omit<RepoQueryIn, "limit" | "offset" | "sort"> = {},
) {
// @todo: if none found, it still returns 200, since it's readMany
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
type T = RepositoryResultJSON<Data>;
// @todo: if none found, still returns meta...
return this.readMany(entity, {
...query,
limit: 1,
offset: 0,
}).refine((data) => data[0] ?? null) as unknown as FetchPromise<
ResponseObject<RepositoryResultJSON<EntityObject<DB, E> | null>>
>;
}).refine((data) => data[0]) as unknown as FetchPromise<ResponseObject<T>>;
}
readMany<E extends keyof DB | string>(entity: E, query: RepoQueryIn = {}) {
type T = RepositoryResultJSON<EntityObject<DB, E>[]>;
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
type T = RepositoryResultJSON<Data[]>;
const input = query ?? this.options.defaultQuery;
const req = this.get<T>(["entity", entity as any], input);
@@ -71,7 +72,8 @@ export class DataApi<DB = DefaultDB> extends ModuleApi<DataApiOptions> {
reference: R,
query: RepoQueryIn = {},
) {
return this.get<RepositoryResultJSON<EntityObject<DB, R>[]>>(
type Data = R extends keyof DB ? Selectable<DB[R]> : EntityData;
return this.get<RepositoryResultJSON<Data[]>>(
["entity", entity as any, id, reference],
query ?? this.options.defaultQuery,
);
@@ -81,7 +83,8 @@ export class DataApi<DB = DefaultDB> extends ModuleApi<DataApiOptions> {
entity: E,
input: Insertable<Input>,
) {
return this.post<RepositoryResultJSON<EntityObject<DB, E>>>(["entity", entity as any], input);
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
return this.post<RepositoryResultJSON<Data>>(["entity", entity as any], input);
}
createMany<E extends keyof DB | string, Input = E extends keyof DB ? DB[E] : EntityData>(
@@ -91,10 +94,8 @@ export class DataApi<DB = DefaultDB> extends ModuleApi<DataApiOptions> {
if (!input || !Array.isArray(input) || input.length === 0) {
throw new Error("input is required");
}
return this.post<RepositoryResultJSON<EntityObject<DB, E>[]>>(
["entity", entity as any],
input,
);
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
return this.post<RepositoryResultJSON<Data[]>>(["entity", entity as any], input);
}
updateOne<E extends keyof DB | string, Input = E extends keyof DB ? DB[E] : EntityData>(
@@ -103,10 +104,8 @@ export class DataApi<DB = DefaultDB> extends ModuleApi<DataApiOptions> {
input: Updateable<Input>,
) {
if (!id) throw new Error("ID is required");
return this.patch<RepositoryResultJSON<EntityObject<DB, E>>>(
["entity", entity as any, id],
input,
);
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
return this.patch<RepositoryResultJSON<Data>>(["entity", entity as any, id], input);
}
updateMany<E extends keyof DB | string, Input = E extends keyof DB ? DB[E] : EntityData>(
@@ -115,7 +114,8 @@ export class DataApi<DB = DefaultDB> extends ModuleApi<DataApiOptions> {
update: Updateable<Input>,
) {
this.requireObjectSet(where);
return this.patch<RepositoryResultJSON<EntityObject<DB, E>[]>>(["entity", entity as any], {
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
return this.patch<RepositoryResultJSON<Data[]>>(["entity", entity as any], {
update,
where,
});
@@ -123,15 +123,14 @@ export class DataApi<DB = DefaultDB> extends ModuleApi<DataApiOptions> {
deleteOne<E extends keyof DB | string>(entity: E, id: PrimaryFieldType) {
if (!id) throw new Error("ID is required");
return this.delete<RepositoryResultJSON<EntityObject<DB, E>>>(["entity", entity as any, id]);
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
return this.delete<RepositoryResultJSON<Data>>(["entity", entity as any, id]);
}
deleteMany<E extends keyof DB | string>(entity: E, where: RepoQueryIn["where"]) {
this.requireObjectSet(where);
return this.delete<RepositoryResultJSON<EntityObject<DB, E>[]>>(
["entity", entity as any],
where,
);
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
return this.delete<RepositoryResultJSON<Data>>(["entity", entity as any], where);
}
count<E extends keyof DB | string>(entity: E, where: RepoQueryIn["where"] = {}) {
-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')
+31 -11
View File
@@ -1,4 +1,4 @@
import { config, type PrimaryFieldType } from "core/config";
import { config } from "core/config";
import { snakeToPascalWithSpaces, transformObject, $console, s, parse } from "bknd/utils";
import {
type Field,
@@ -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: {} },
)
@@ -25,10 +26,7 @@ export const entityConfigSchema = s
export type EntityConfig = s.Static<typeof entityConfigSchema>;
export type EntityData = {
id: PrimaryFieldType;
[key: string]: any;
};
export type EntityData = Record<string, any>;
export type EntityJSON = ReturnType<Entity["toJSON"]>;
/**
@@ -140,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[] {
@@ -192,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) {
@@ -278,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]));
@@ -312,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 = {},
) {}
+1 -1
View File
@@ -119,7 +119,7 @@ export class Result<T = unknown> {
const keys = isDebug() ? ["items", "time", "sql", "parameters"] : ["items", "time"];
const meta = pick(metaRaw, [...keys, ...this.additionalMetaKeys()] as any);
return {
data: this.data ? this.data : ((this.options.single ? null : []) as T),
data: this.data,
meta,
};
}
+7 -7
View File
@@ -54,7 +54,7 @@ export class Mutator<
}
const keys = Object.keys(data as any);
const validatedData: Partial<EntityData> = {};
const validatedData: EntityData = {};
// get relational references/keys
const relationMutator = new RelationMutator(entity, this.em);
@@ -101,10 +101,10 @@ export class Mutator<
return validatedData as Given;
}
protected async performQuery<O extends MutatorResultOptions>(
protected async performQuery<T = EntityData[]>(
qb: MutatorQB,
opts?: O,
): Promise<MutatorResult<O extends { single: true } ? EntityData : EntityData[]>> {
opts?: MutatorResultOptions,
): Promise<MutatorResult<T>> {
const result = new MutatorResult(this.em, this.entity, {
silent: false,
...opts,
@@ -282,7 +282,7 @@ export class Mutator<
entity.getSelect(),
);
return (await this.performQuery(qb)) as any;
return await this.performQuery(qb);
}
async updateWhere(
@@ -301,7 +301,7 @@ export class Mutator<
.set(validatedData as any)
.returning(entity.getSelect());
return (await this.performQuery(query)) as any;
return await this.performQuery(query);
}
async insertMany(data: Input[]): Promise<MutatorResult<Output[]>> {
@@ -336,6 +336,6 @@ export class Mutator<
.values(validated)
.returning(entity.getSelect());
return (await this.performQuery(query)) as any;
return await this.performQuery(query);
}
}
+3 -11
View File
@@ -180,23 +180,15 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
private async triggerFindAfter(
entity: Entity,
options: RepoQuery,
data: EntityData | EntityData[],
data: EntityData[],
): Promise<void> {
if (options.limit === 1) {
await this.emgr.emit(
new Repository.Events.RepositoryFindOneAfter({
entity,
options,
data: data as EntityData,
}),
new Repository.Events.RepositoryFindOneAfter({ entity, options, data }),
);
} else {
await this.emgr.emit(
new Repository.Events.RepositoryFindManyAfter({
entity,
options,
data: data as EntityData[],
}),
new Repository.Events.RepositoryFindManyAfter({ entity, options, data }),
);
}
}
+5 -7
View File
@@ -4,8 +4,6 @@ import { Event, InvalidEventReturn } from "core/events";
import type { Entity, EntityData } from "../entities";
import type { RepoQuery } from "data/server/query";
type PartialEntityData = Omit<EntityData, "id">;
export class MutatorInsertBefore extends Event<{ entity: Entity; data: EntityData }, EntityData> {
static override slug = "mutator-insert-before";
@@ -28,7 +26,7 @@ export class MutatorInsertBefore extends Event<{ entity: Entity; data: EntityDat
export class MutatorInsertAfter extends Event<{
entity: Entity;
data: EntityData;
changed: PartialEntityData;
changed: EntityData;
}> {
static override slug = "mutator-insert-after";
}
@@ -36,7 +34,7 @@ export class MutatorUpdateBefore extends Event<
{
entity: Entity;
entityId: PrimaryFieldType;
data: PartialEntityData;
data: EntityData;
},
EntityData
> {
@@ -63,8 +61,8 @@ export class MutatorUpdateBefore extends Event<
export class MutatorUpdateAfter extends Event<{
entity: Entity;
entityId: PrimaryFieldType;
data: PartialEntityData;
changed: PartialEntityData;
data: EntityData;
changed: EntityData;
}> {
static override slug = "mutator-update-after";
}
@@ -106,7 +104,7 @@ export class RepositoryFindManyBefore extends Event<{ entity: Entity; options: R
export class RepositoryFindManyAfter extends Event<{
entity: Entity;
options: RepoQuery;
data: EntityData[];
data: EntityData;
}> {
static override slug = "repository-find-many-after";
}
+1 -1
View File
@@ -16,7 +16,7 @@ export function getDefaultValues(fields: Field[], data: EntityData): EntityData
export function getChangeSet(
action: string,
formData: EntityData,
data: EntityData | object,
data: EntityData,
fields: Field[],
): EntityData {
return transform(
+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 -11
View File
@@ -1,14 +1,11 @@
import type { FileListObject } from "media/storage/Storage";
import {
type BaseModuleApiOptions,
type FetchPromise,
type ResponseObject,
ModuleApi,
type PrimaryFieldType,
type TInput,
} from "modules/ModuleApi";
import type { ApiFetcher } from "Api";
import type { DB, FileUploadedEventData } from "bknd";
export type MediaApiOptions = BaseModuleApiOptions & {
upload_fetcher: ApiFetcher;
@@ -70,14 +67,14 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
return new Headers();
}
protected uploadFile<T extends FileUploadedEventData>(
protected uploadFile(
body: File | Blob | ReadableStream | Buffer<ArrayBufferLike>,
opts?: {
filename?: string;
path?: TInput;
_init?: Omit<RequestInit, "body">;
},
): FetchPromise<ResponseObject<T>> {
) {
const headers = {
"Content-Type": "application/octet-stream",
...(opts?._init?.headers || {}),
@@ -109,10 +106,10 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
throw new Error("Invalid filename");
}
return this.post<T>(opts?.path ?? ["upload", name], body, init);
return this.post(opts?.path ?? ["upload", name], body, init);
}
async upload<T extends FileUploadedEventData>(
async upload(
item: Request | Response | string | File | Blob | ReadableStream | Buffer<ArrayBufferLike>,
opts: {
filename?: string;
@@ -127,12 +124,12 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
if (!res.ok || !res.body) {
throw new Error("Failed to fetch file");
}
return this.uploadFile<T>(res.body, opts);
return this.uploadFile(res.body, opts);
} else if (item instanceof Response) {
if (!item.body) {
throw new Error("Invalid response");
}
return this.uploadFile<T>(item.body, {
return this.uploadFile(item.body, {
...(opts ?? {}),
_init: {
...(opts._init ?? {}),
@@ -144,7 +141,7 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
});
}
return this.uploadFile<T>(item, opts);
return this.uploadFile(item, opts);
}
async uploadToEntity(
@@ -156,7 +153,7 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
_init?: Omit<RequestInit, "body">;
fetcher?: typeof fetch;
},
): Promise<ResponseObject<FileUploadedEventData & { result: DB["media"] }>> {
) {
return this.upload(item, {
...opts,
path: ["entity", entity, id, field],
+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`],
+4 -2
View File
@@ -167,6 +167,7 @@ export type ResponseObject<Body = any, Data = Body extends { data: infer R } ? R
data: Data;
body: Body;
ok: boolean;
status: number;
toJSON(): Data;
};
@@ -176,10 +177,10 @@ export function createResponseProxy<Body = any, Data = any>(
data?: Data,
): ResponseObject<Body, Data> {
let actualData: any = typeof data !== "undefined" ? data : body;
const _props = ["raw", "body", "ok", "res", "data", "toJSON"];
const _props = ["raw", "body", "ok", "status", "res", "data", "toJSON"];
// that's okay, since you have to check res.ok anyway
if (typeof actualData !== "object" || actualData === null) {
if (typeof actualData !== "object") {
actualData = {};
}
@@ -189,6 +190,7 @@ export function createResponseProxy<Body = any, Data = any>(
if (prop === "body") return body;
if (prop === "data") return data;
if (prop === "ok") return raw.ok;
if (prop === "status") return raw.status;
if (prop === "toJSON") {
return () => target;
}
+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",
}),
}),
},
{
+43 -31
View File
@@ -32,6 +32,7 @@ import { getVersion } from "core/env";
import type { Module } from "modules/Module";
import { getSystemMcp } from "modules/mcp/system-mcp";
import type { DbModuleManager } from "modules/db/DbModuleManager";
import type { Repository } from "data/entities";
export type ConfigUpdate<Key extends ModuleKey = ModuleKey> = {
success: true;
@@ -70,41 +71,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: {
@@ -137,6 +130,25 @@ export class SystemController extends Controller {
},
);
hono.get("/history", async (c) => {
// @ts-expect-error "repo" is private
const repo = manager.repo() as Repository;
const { data } = await repo.findMany({
where: { type: "diff", version: manager.version() },
sort: { by: "id", dir: "desc" },
});
return c.json(data);
});
hono.get("/history/:id", async (c) => {
// @ts-expect-error "repo" is private
const repo = manager.repo() as Repository;
const { data } = await repo.findId(c.req.param("id"), {
where: { type: "diff", version: manager.version() },
});
return c.json(data);
});
async function handleConfigUpdateResponse(
c: Context<any>,
cb: () => Promise<ConfigUpdate>,
+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;
};
+2 -2
View File
@@ -35,7 +35,7 @@ export const useApiInfiniteQuery = <
RefineFn extends (data: ResponseObject<Data>) => unknown = (data: ResponseObject<Data>) => Data,
>(
fn: (api: Api, page: number) => FetchPromise<Data>,
options?: SWRConfiguration & { refine?: RefineFn; pageSize?: number },
options?: SWRConfiguration & { refine?: RefineFn },
) => {
const [endReached, setEndReached] = useState(false);
const api = useApi();
@@ -47,7 +47,7 @@ export const useApiInfiniteQuery = <
// @ts-ignore
const swr = useSWRInfinite<RefinedData>(
(index, previousPageData: any) => {
if (index > 0 && previousPageData && previousPageData.length < (options?.pageSize ?? 0)) {
if (previousPageData && !previousPageData.length) {
setEndReached(true);
return null; // reached the end
}
+31 -36
View File
@@ -1,14 +1,9 @@
import type {
DB,
PrimaryFieldType,
EntityData,
RepoQueryIn,
ResponseObject,
ModuleApi,
} from "bknd";
import type { DB, PrimaryFieldType, EntityData, RepoQueryIn } from "bknd";
import { objectTransform, encodeSearch } from "bknd/utils";
import type { Insertable, Updateable } from "kysely";
import useSWR, { type SWRConfiguration, type SWRResponse, mutate, type KeyedMutator } from "swr";
import type { RepositoryResult } from "data/entities";
import type { Insertable, Selectable, Updateable } from "kysely";
import type { FetchPromise, ModuleApi, ResponseObject } from "modules/ModuleApi";
import useSWR, { type SWRConfiguration, type SWRResponse, mutate } from "swr";
import { type Api, useApi } from "ui/client";
export class UseEntityApiError<Payload = any> extends Error {
@@ -32,16 +27,18 @@ interface UseEntityReturn<
Entity extends keyof DB | string,
Id extends PrimaryFieldType | undefined,
Data = Entity extends keyof DB ? DB[Entity] : EntityData,
Response = ResponseObject<Data>,
Response = ResponseObject<RepositoryResult<Selectable<Data>>>,
> {
create: (input: Insertable<Data>) => Promise<Response>;
read: (query?: RepoQueryIn) => Promise<ResponseObject<Id extends undefined ? Data[] : Data>>;
read: (
query?: RepoQueryIn,
) => Promise<
ResponseObject<RepositoryResult<Id extends undefined ? Selectable<Data>[] : Selectable<Data>>>
>;
update: Id extends undefined
? (input: Updateable<Data>, id: PrimaryFieldType) => Promise<Response>
? (input: Updateable<Data>, id: Id) => Promise<Response>
: (input: Updateable<Data>) => Promise<Response>;
_delete: Id extends undefined
? (id: PrimaryFieldType) => Promise<Response>
: () => Promise<Response>;
_delete: Id extends undefined ? (id: Id) => Promise<Response> : () => Promise<Response>;
}
export const useEntity = <
@@ -57,14 +54,14 @@ export const useEntity = <
return {
create: async (input: Insertable<Data>) => {
const res = await api.createOne(entity, input as any);
if (!res.res.ok) {
if (!res.ok) {
throw new UseEntityApiError(res, `Failed to create entity "${entity}"`);
}
return res as any;
},
read: async (query?: RepoQueryIn) => {
const res = id ? await api.readOne(entity, id!, query) : await api.readMany(entity, query);
if (!res.res.ok) {
if (!res.ok) {
throw new UseEntityApiError(res as any, `Failed to read entity "${entity}"`);
}
return res as any;
@@ -75,7 +72,7 @@ export const useEntity = <
throw new Error("id is required");
}
const res = await api.updateOne(entity, _id, input);
if (!res.res.ok) {
if (!res.ok) {
throw new UseEntityApiError(res, `Failed to update entity "${entity}"`);
}
return res as any;
@@ -87,7 +84,7 @@ export const useEntity = <
}
const res = await api.deleteOne(entity, _id);
if (!res.res.ok) {
if (!res.ok) {
throw new UseEntityApiError(res, `Failed to delete entity "${entity}"`);
}
return res as any;
@@ -111,15 +108,15 @@ export function makeKey(
);
}
export interface UseEntityQueryReturn<
interface UseEntityQueryReturn<
Entity extends keyof DB | string,
Id extends PrimaryFieldType | undefined = undefined,
Data = DB[Entity],
Return = Id extends undefined ? Data[] : Data,
Data = Entity extends keyof DB ? Selectable<DB[Entity]> : EntityData,
Return = Id extends undefined ? ResponseObject<Data[]> : ResponseObject<Data>,
> extends Omit<SWRResponse<Return>, "mutate">,
Omit<ReturnType<typeof useEntity<Entity, Id>>, "read"> {
mutate: (id?: PrimaryFieldType) => Promise<any>;
mutateRaw: KeyedMutator<Data | Data[]>;
mutateRaw: SWRResponse<Return>["mutate"];
api: Api["data"];
key: string;
}
@@ -139,11 +136,11 @@ export const useEntityQuery = <
const fetcher = () => read(query ?? {});
type T = Awaited<ReturnType<typeof fetcher>>;
const swr = useSWR(options?.enabled === false ? null : key, fetcher as any, {
const swr = useSWR<T>(options?.enabled === false ? null : key, fetcher as any, {
revalidateOnFocus: false,
keepPreviousData: true,
...options,
}) as ReturnType<typeof useSWR<T>>;
});
const mutateFn = async (id?: PrimaryFieldType) => {
const entityKey = makeKey(api, entity as string, id);
@@ -159,30 +156,27 @@ 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;
};
}) as Omit<ReturnType<typeof useEntity<Entity, Id>>, "read">;
const data = swr.data ? swr.data.data : id ? null : [];
return {
...swr,
...mapped,
data,
mutate: mutateFn,
// @ts-ignore
mutateRaw: swr.mutate,
api,
key,
} as any;
};
};
export async function mutateEntityCache<
Entity extends keyof DB | string,
Data = Entity extends keyof DB ? DB[Entity] : EntityData,
>(api: Api["data"], entity: Entity, id: PrimaryFieldType, partialData: Partial<Data>) {
>(api: Api["data"], entity: Entity, id: PrimaryFieldType, partialData: Partial<Selectable<Data>>) {
function update(prev: any, partialNext: any) {
if (
typeof prev !== "undefined" &&
@@ -219,8 +213,8 @@ interface UseEntityMutateReturn<
Data = Entity extends keyof DB ? DB[Entity] : EntityData,
> extends Omit<ReturnType<typeof useEntityQuery<Entity, Id>>, "mutate"> {
mutate: Id extends undefined
? (id: PrimaryFieldType, data: Partial<Data>) => Promise<void>
: (data: Partial<Data>) => Promise<void>;
? (id: PrimaryFieldType, data: Partial<Selectable<Data>>) => Promise<void>
: (data: Partial<Selectable<Data>>) => Promise<void>;
}
export const useEntityMutate = <
@@ -238,8 +232,9 @@ export const useEntityMutate = <
});
const _mutate = id
? (data: Partial<Data>) => mutateEntityCache($q.api, entity, id, data)
: (id: PrimaryFieldType, data: Partial<Data>) => mutateEntityCache($q.api, entity, id, data);
? (data: Partial<Selectable<Data>>) => mutateEntityCache($q.api, entity, id, data)
: (id: PrimaryFieldType, data: Partial<Selectable<Data>>) =>
mutateEntityCache($q.api, entity, id, data);
return {
...$q,
@@ -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) {
+30 -59
View File
@@ -53,7 +53,7 @@ export type DataTableProps<Data> = {
};
export function DataTable<Data extends Record<string, any> = Record<string, any>>({
data: _data = [],
data = [],
columns,
checkable,
onClickRow,
@@ -71,14 +71,11 @@ export function DataTable<Data extends Record<string, any> = Record<string, any>
renderValue,
onClickNew,
}: DataTableProps<Data>) {
const hasTotal = !!total;
const data = Array.isArray(_data) ? _data.slice(0, perPage) : _data;
total = total || data?.length || 0;
page = page || 1;
const select = columns && columns.length > 0 ? columns : Object.keys(data?.[0] || {});
const pages = Math.max(Math.ceil(total / perPage), 1);
const hasNext = hasTotal ? pages > page : (_data?.length || 0) > perPage;
const CellRender = renderValue || CellValue;
return (
@@ -162,7 +159,7 @@ export function DataTable<Data extends Record<string, any> = Record<string, any>
)}
{Object.entries(row).map(([key, value], index) => (
<td key={index} onClick={rowClick}>
<td key={index} onClick={rowClick} valign="top">
<div className="flex flex-row items-start py-3 px-3.5 font-normal ">
<CellRender property={key} value={value} />
</div>
@@ -205,7 +202,7 @@ export function DataTable<Data extends Record<string, any> = Record<string, any>
perPage={perPage}
page={page}
items={data?.length || 0}
total={hasTotal ? total : undefined}
total={total}
/>
</div>
<div className="flex flex-row gap-2 md:gap-10 items-center">
@@ -225,17 +222,11 @@ export function DataTable<Data extends Record<string, any> = Record<string, any>
</div>
)}
<div className="text-primary/40">
Page {page}
{hasTotal ? <> of {pages}</> : ""}
Page {page} of {pages}
</div>
{onClickPage && (
<div className="flex flex-row gap-1.5">
<TableNav
current={page}
total={hasTotal ? pages : page + (hasNext ? 1 : 0)}
onClick={onClickPage}
hasLast={hasTotal}
/>
<TableNav current={page} total={pages} onClick={onClickPage} />
</div>
)}
</div>
@@ -277,23 +268,17 @@ const SortIndicator = ({
};
const TableDisplay = ({ perPage, page, items, total }) => {
if (items === 0 && page === 1) {
if (total === 0) {
return <>No rows to show</>;
}
const start = Math.max(perPage * (page - 1), 1);
if (!total) {
return (
<>
Showing {start}-{perPage * (page - 1) + items}
</>
);
if (total === 1) {
return <>Showing 1 row</>;
}
return (
<>
Showing {start}-{perPage * (page - 1) + items} of {total} rows
Showing {perPage * (page - 1) + 1}-{perPage * (page - 1) + items} of {total} rows
</>
);
};
@@ -302,44 +287,30 @@ type TableNavProps = {
current: number;
total: number;
onClick?: (page: number) => void;
hasLast?: boolean;
};
const TableNav: React.FC<TableNavProps> = ({
current,
total,
onClick,
hasLast = true,
}: TableNavProps) => {
const TableNav: React.FC<TableNavProps> = ({ current, total, onClick }: TableNavProps) => {
const navMap = [
{ enabled: true, value: 1, Icon: TbChevronsLeft, disabled: current === 1 },
{ enabled: true, value: current - 1, Icon: TbChevronLeft, disabled: current === 1 },
{
enabled: true,
value: current + 1,
Icon: TbChevronRight,
disabled: current === total,
},
{ enabled: hasLast, value: total, Icon: TbChevronsRight, disabled: current === total },
{ value: 1, Icon: TbChevronsLeft, disabled: current === 1 },
{ value: current - 1, Icon: TbChevronLeft, disabled: current === 1 },
{ value: current + 1, Icon: TbChevronRight, disabled: current === total },
{ value: total, Icon: TbChevronsRight, disabled: current === total },
] as const;
return navMap.map(
(nav, key) =>
nav.enabled && (
<button
role="button"
type="button"
key={key}
disabled={nav.disabled}
className="px-2 py-2 border-muted border rounded-md enabled:link text-lg enabled:hover:bg-primary/5 text-primary/90 disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed"
onClick={() => {
const page = nav.value;
const safePage = page < 1 ? 1 : page > total ? total : page;
onClick?.(safePage);
}}
>
<nav.Icon />
</button>
),
);
return navMap.map((nav, key) => (
<button
role="button"
type="button"
key={key}
disabled={nav.disabled}
className="px-2 py-2 border-muted border rounded-md enabled:link text-lg enabled:hover:bg-primary/5 text-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => {
const page = nav.value;
const safePage = page < 1 ? 1 : page > total ? total : page;
onClick?.(safePage);
}}
>
<nav.Icon />
</button>
));
};
+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 (
+25 -44
View File
@@ -77,9 +77,7 @@ export function DropzoneContainer({
});
const $q = infinite
? useApiInfiniteQuery(selectApi, {
pageSize,
})
? useApiInfiniteQuery(selectApi, {})
: useApiQuery(selectApi, {
enabled: initialItems !== false && !initialItems,
revalidateOnFocus: false,
@@ -110,48 +108,31 @@ export function DropzoneContainer({
[]) as MediaFieldSchema[];
const _initialItems = mediaItemsToFileStates(actualItems, { baseUrl });
const key = id + JSON.stringify(initialItems);
// check if endpoint reeturns a total, then reaching end is easy
const total = "_data" in $q ? $q._data?.[0]?.body.meta.count : undefined;
let placeholderLength = 0;
if (infinite && "setSize" in $q) {
placeholderLength =
typeof total === "number"
? total
: $q.endReached
? _initialItems.length
: _initialItems.length + pageSize;
// in case there is no total, we overfetch but SWR don't reflect an empty result
// therefore we check if it stopped loading, but has a bigger page size than the total.
// if that's the case, we assume we reached the end.
if (!total && !$q.isValidating && pageSize * $q.size >= placeholderLength) {
placeholderLength = _initialItems.length;
}
}
const key = id + JSON.stringify(_initialItems);
return (
<>
<Dropzone
key={key}
getUploadInfo={getUploadInfo}
handleDelete={handleDelete}
autoUpload
initialItems={_initialItems}
footer={
infinite &&
"setSize" in $q && (
<Footer
items={_initialItems.length}
length={placeholderLength}
onFirstVisible={() => $q.setSize($q.size + 1)}
/>
)
}
{...props}
/>
</>
<Dropzone
key={id + key}
getUploadInfo={getUploadInfo}
handleDelete={handleDelete}
/* onUploaded={refresh}
onDeleted={refresh} */
autoUpload
initialItems={_initialItems}
footer={
infinite &&
"setSize" in $q && (
<Footer
items={_initialItems.length}
length={Math.min(
$q._data?.[0]?.body.meta.count ?? 0,
_initialItems.length + pageSize,
)}
onFirstVisible={() => $q.setSize($q.size + 1)}
/>
)
}
{...props}
/>
);
}
+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)),
})),
}),
),
);
+4 -1
View File
@@ -14,6 +14,10 @@ export function useSearch<Schema extends s.Schema = s.Schema>(
) {
const searchString = useWouterSearch();
const [location, navigate] = useLocation();
const [value, setValue] = useState<s.StaticCoerced<Schema>>(
options?.defaultValue ?? ({} as any),
);
const defaults = useMemo(() => {
return mergeObject(
// @ts-ignore
@@ -21,7 +25,6 @@ export function useSearch<Schema extends s.Schema = s.Schema>(
options?.defaultValue ?? {},
);
}, [JSON.stringify({ schema, dflt: options?.defaultValue })]);
const [value, setValue] = useState<s.StaticCoerced<Schema>>(defaults);
useEffect(() => {
const initial =
+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>
@@ -301,9 +293,19 @@ function EntityJsonFormField({
onChange={handleUpdate}
onBlur={fieldApi.handleBlur}
minHeight="100"
/*required={field.isRequired()}*/
{...props}
/>
</Suspense>
{/*<Formy.Textarea
name={fieldApi.name}
id={fieldApi.name}
value={fieldApi.state.value}
onBlur={fieldApi.handleBlur}
onChange={handleUpdate}
required={field.isRequired()}
{...props}
/>*/}
</Formy.Group>
);
}
@@ -330,8 +332,8 @@ function EntityEnumFormField({
{...props}
>
{!field.isRequired() && <option value="">- Select -</option>}
{field.getOptions().map((option, i) => (
<option key={`${option.value}-${i}`} value={option.value}>
{field.getOptions().map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
@@ -44,7 +44,7 @@ export function EntityRelationalFormField({
const ref = useRef<any>(null);
const $q = useEntityQuery(field.target(), undefined, {
select: query.select,
limit: query.limit + 1 /* overfetch for softscan=false */,
limit: query.limit,
offset: (query.page - 1) * query.limit,
});
const [_value, _setValue] = useState<{ id: number | undefined; [key: string]: any }>();
@@ -5,7 +5,7 @@ import { getChangeSet, getDefaultValues } from "data/helper";
type EntityFormProps = {
action: "create" | "update";
entity: Entity;
initialData?: EntityData | object | null;
initialData?: EntityData | null;
onSubmitted?: (changeSet?: EntityData) => Promise<void>;
};
@@ -20,7 +20,7 @@ export function useEntityForm({
const fields = entity.getFillableFields(action, true);
// filter defaultValues to only contain fillable fields
const defaultValues = getDefaultValues(fields, data as any);
const defaultValues = getDefaultValues(fields, data);
//console.log("useEntityForm", { data, defaultValues });
const Form = useForm({
@@ -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");
}
@@ -120,7 +120,7 @@ export function DataEntityCreate({ params }) {
entity={entity}
handleSubmit={handleSubmit}
fieldsDisabled={fieldsDisabled}
data={search.value as any}
data={search.value}
Form={Form}
action="create"
className="flex flex-grow flex-col gap-3 p-3"
@@ -61,7 +61,7 @@ function DataEntityListImpl({ params }) {
(api) =>
api.data.readMany(entity?.name as any, {
select: search.value.select,
limit: search.value.perPage + 1 /* overfetch for softscan=false */,
limit: search.value.perPage,
offset: (search.value.page - 1) * search.value.perPage,
sort: `${search.value.sort.dir === "asc" ? "" : "-"}${search.value.sort.by}`,
}),
+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"
+2
View File
@@ -13,6 +13,7 @@ import { DataSettings } from "./routes/data.settings";
import { FlowsSettings } from "./routes/flows.settings";
import { ServerSettings } from "./routes/server.settings";
import { IconButton } from "ui/components/buttons/IconButton";
import { SettingsHistory } from "ui/routes/settings/routes/history";
function SettingsSidebar() {
const { version, schema, actions, app } = useBknd();
@@ -75,6 +76,7 @@ export default function SettingsRoutes() {
/>
)}
/>
<Route path="/history" nest component={SettingsHistory} />
<SettingRoutesRoutes />
@@ -0,0 +1,152 @@
import { AppShell } from "ui/layouts/AppShell";
import { Route, Switch, useParams } from "wouter";
import { useEffect, useState } from "react";
import { CellValue, DataTable } from "ui/components/table/DataTable";
import { twMerge } from "tailwind-merge";
import { JsonViewer } from "ui/components/code/JsonViewer";
import { useNavigate } from "ui/lib/routes";
import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2";
export function SettingsHistory() {
return (
<Switch>
<Route path="/" component={SettingsHistoryList} />
<Route path="/:id" component={SettingsHistoryDetail} />
</Switch>
);
}
function SettingsHistoryList() {
const [history, setHistory] = useState<any[]>([]);
const [navigate] = useNavigate();
useEffect(() => {
fetch("/api/system/config/history")
.then((res) => res.json())
.then((data: any) =>
data.map((item) => ({
id: item.id,
timestamp: item.created_at,
actions: Array.from(new Set(item.json.map((j) => j.t))),
paths: Array.from(new Set(item.json.map((j) => j.p))),
})),
)
.then(setHistory);
}, []);
const onClickRow = (row) => {
navigate(`/${row.id}`);
};
return (
<>
<AppShell.SectionHeader>History</AppShell.SectionHeader>
<AppShell.Scrollable>
<div className="flex flex-col flex-grow p-3 gap-1">
<DataTable
data={history}
renderValue={renderValue}
perPage={50}
onClickRow={onClickRow}
/>
</div>
</AppShell.Scrollable>
</>
);
}
const Labels = ({
items = [],
max = 3,
wrap = false,
}: { items: string[]; max?: number; wrap?: boolean }) => {
const count = items.length;
if (count > max) {
items = [...items.slice(0, max), `+${count - max}`];
}
return (
<div className={twMerge("flex gap-1", wrap ? "flex-col items-start" : "flex-row")}>
{items.map((p, i) => (
<span
key={i}
className="inline-block px-2 py-1.5 text-sm bg-primary/5 rounded font-mono leading-none"
>
{p}
</span>
))}
</div>
);
};
const renderValue = ({ value, property }) => {
if (property === "timestamp") {
return <span>{new Date(value).toLocaleString()}</span>;
}
if (property === "actions") {
return (
<Labels
items={value.map((a) => {
switch (a) {
case "a":
return "Add";
case "r":
return "Remove";
case "e":
return "Edit";
default:
return "Unknown";
}
})}
/>
);
}
if (property === "paths") {
return <Labels wrap items={value.map((p) => p.join("."))} />;
}
return <CellValue value={value} property={property} />;
};
function SettingsHistoryDetail() {
const { id } = useParams();
const [item, setItem] = useState<any>();
useEffect(() => {
fetch(`/api/system/config/history/${id}`)
.then((res) => res.json())
.then(setItem);
}, []);
return (
<>
<AppShell.SectionHeader className="pl-3">
<Breadcrumbs2 path={[{ label: "History", href: "/" }, { label: `#${id}` }]} />
</AppShell.SectionHeader>
<AppShell.Scrollable>
<div className="flex flex-col flex-grow p-3 gap-1">
{item?.json?.map((item, i) => (
<DiffItem key={i} item={item} />
))}
</div>
</AppShell.Scrollable>
</>
);
}
const DiffItem = ({ item }: { item: any }) => {
return (
<div className="flex flex-col gap-1 w-full border-b border-muted p-3">
<div className="flex flex-row gap-1 w-full">
<span className="font-mono">{item.t}</span>
<span className="font-mono">{item.p.join(".")}</span>
</div>
<div className="flex flex-row gap-1 w-full">
<JsonViewer json={item.o} expand={10} className="w-1/2" title="Old" />
<JsonViewer json={item.n} expand={10} className="w-1/2" title="New" />
</div>
</div>
);
};
+8 -45
View File
@@ -1,4 +1,4 @@
import { readFile, writeFile } from "node:fs/promises";
import { readFile } from "node:fs/promises";
import { serveStatic } from "@hono/node-server/serve-static";
import { showRoutes } from "hono/dev";
import { App, registries, type CreateAppConfig } from "./src";
@@ -9,7 +9,6 @@ import { $console } from "core/utils/console";
import { createClient } from "@libsql/client";
import util from "node:util";
import { d1Sqlite } from "adapter/cloudflare/connection/D1Connection";
import { slugify } from "./src/core/utils/strings";
util.inspect.defaultOptions.depth = 5;
registries.media.register("local", StorageLocalAdapter);
@@ -22,19 +21,16 @@ $console.debug("Using db type", dbType);
let dbUrl = import.meta.env.VITE_DB_URL ?? ":memory:";
const example = import.meta.env.VITE_EXAMPLE;
async function loadExampleConfig() {
if (example) {
const configPath = `.configs/${example}.json`;
$console.debug("Loading config from", configPath);
const exampleConfig = JSON.parse(await readFile(configPath, "utf-8"));
config.config = exampleConfig;
dbUrl = `file:.configs/${example}.db`;
}
if (example) {
const configPath = `.configs/${example}.json`;
$console.debug("Loading config from", configPath);
const exampleConfig = JSON.parse(await readFile(configPath, "utf-8"));
config.config = exampleConfig;
dbUrl = `file:.configs/${example}.db`;
}
switch (dbType) {
case "libsql": {
await loadExampleConfig();
$console.debug("Using libsql connection", dbUrl);
const authToken = import.meta.env.VITE_DB_LIBSQL_TOKEN;
config.connection = libsql(
@@ -47,48 +43,15 @@ switch (dbType) {
}
case "d1": {
$console.debug("Using d1 connection");
const wranglerConfig = {
name: "vite-dev",
main: "src/index.ts",
compatibility_date: "2025-08-03",
compatibility_flags: ["nodejs_compat"],
d1_databases: [
{
binding: "DB",
database_name: "vite-dev",
database_id: "00000000-0000-0000-0000-000000000000",
},
],
r2_buckets: [
{
binding: "BUCKET",
bucket_name: "vite-dev",
},
],
};
let configPath = ".configs/vite.wrangler.json";
if (example) {
const name = slugify(example);
configPath = `.configs/${slugify(example)}.wrangler.json`;
const exists = await readFile(configPath, "utf-8");
if (!exists) {
wranglerConfig.name = name;
wranglerConfig.d1_databases[0]!.database_name = name;
wranglerConfig.d1_databases[0]!.database_id = crypto.randomUUID();
wranglerConfig.r2_buckets[0]!.bucket_name = name;
await writeFile(configPath, JSON.stringify(wranglerConfig, null, 2));
}
}
const { getPlatformProxy } = await import("wrangler");
const platformProxy = await getPlatformProxy({
configPath,
configPath: "./vite.wrangler.json",
});
config.connection = d1Sqlite({ binding: platformProxy.env.DB as any });
break;
}
default: {
await loadExampleConfig();
$console.debug("Using node-sqlite connection", dbUrl);
config.connection = nodeSqlite({ url: dbUrl });
break;
+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.
@@ -167,10 +137,6 @@ export default {
### `cloudflareImageOptimization`
<Callout type="info">
This plugin doesn't work on the development server, or on workers deployed with a `workers.dev` subdomain. It requires [Cloudflare Image transformations to be enabled](https://developers.cloudflare.com/images/get-started/#enable-transformations-on-your-zone) on your zone.
</Callout>
A plugin that add Cloudflare Image Optimization to your app's media storage.
```typescript title="bknd.config.ts"
@@ -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"]
---

Some files were not shown because too many files have changed in this diff Show More