Compare commits

...

35 Commits

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