26 Commits
Author SHA1 Message Date
shishantbiswas 282c9f6054 fix: on delete
Run Tests / test (pull_request) Successful in 7m24s
2026-09-27 17:57:45 +05:30
shishantbiswas b294ee68ac Merge pull request 'Release/0.21' (#6) from release/0.21 into main
Run Tests / test (push) Successful in 3m53s
Reviewed-on: shishantbiswas/bknd#6
2026-09-26 13:17:00 +00:00
shishantbiswas e8a256af2a update: tests and workflow to work the same way it does in github (#5)
Run Tests / test (push) Successful in 8m37s
Reviewed-on: shishantbiswas/bknd#5
2026-09-25 13:37:27 +00:00
Shishant Biswas 4709fec45c Merge pull request #287 from bknd-io/feat/data-ui-fillable-visible
Run Tests / test (pull_request) Canceled after 1m52s
make non-fillable fields visible but disabled in UI
2026-09-24 17:22:42 +05:30
shishantbiswas da80e91074 fix(ui-form): skip non-fillable fields in form data because they fail validation in backend 2026-09-24 14:22:23 +05:30
Shishant Biswas 3ad9fd01e1 Merge pull request #309 from bknd-io/feat/sort-plugin
Add sort plugin for managing orderable entities
2026-09-24 12:02:13 +05:30
Shishant Biswas 89d7e4ea2b Merge pull request #405 from shishantbiswas/fix-sidebar-link-href
fix: sidebar links href to include `adminBasePath`
2026-09-23 17:04:56 +05:30
shishantbiswas 6e3b7515d5 docs(plugin): add sort plugin documentation 2026-09-23 12:44:23 +05:30
shishantbiswas 62d3a9c4f4 fix: data/schema toggle link in /data path to include adminBasePath 2026-09-22 21:47:15 +05:30
shishantbiswas 919d9f15d1 revert: <a> ts expect error 2026-09-22 21:07:37 +05:30
shishantbiswas e68d29760a fix: sidebar link href with 2026-09-18 19:29:31 +05:30
Shishant Biswas eba2f8a535 Merge pull request #365 from shishantbiswas/feature-solid-start-adapter
feat: Solid Start adapter
2026-09-18 10:13:49 +05:30
shishantbiswas 6cfdb77ba0 update solid, vite, and nitro versions 2026-09-18 10:08:48 +05:30
Cameron Pak 8c596781ff Merge pull request #380 from shishantbiswas/next-docs
Run Tests / test (push) Failing after 7m39s
add (docs): section for production build issue, React SDK setup
2026-03-28 06:06:29 -05:00
shishantbiswas b2315ed012 refactor: dark mode styling 2026-03-28 16:21:42 +05:30
shishantbiswas 9e5c77db79 fix(docs): remove solid from home component 2026-03-28 16:21:42 +05:30
shishantbiswas 5892b898bf add: update README in solid example 2026-03-28 16:21:42 +05:30
shishantbiswas fc83a593a0 docs(solid-start): add JSDoc comments to adapter functions 2026-03-28 16:21:42 +05:30
shishantbiswas 8d37f9df3f init: solid start adapter 2026-03-28 16:21:42 +05:30
shishantbiswas 43cbdb3010 add(docs): update next.js docs to use colocation for <Admin /> component 2026-03-28 15:31:43 +05:30
shishantbiswas b3c9f258de add: prod section at the end 2026-03-28 14:55:38 +05:30
dswbx 5c3d3d763e move sort plugin to data plugins 2025-11-26 10:26:58 +01:00
dswbx 407d103a5f fix: remove test.only 2025-11-26 10:12:39 +01:00
dswbx 2fd8b9f9a0 feat: add sort plugin
- Introduced a new sort plugin that allows sorting of entities based on a specified field.
- Added API endpoints for reordering items and recalculating sort order.
- Implemented tests to ensure correct functionality of sorting and reordering features.
2025-11-26 10:08:30 +01:00
dswbx e81636ea95 Merge remote-tracking branch 'origin/main' into feat/data-ui-fillable-visible 2025-10-31 13:34:42 +01:00
dswbx 37c36e5d07 make non-fillable fields visible but disabled in UI 2025-10-24 14:09:07 +02:00
108 changed files with 5626 additions and 1963 deletions
+5 -5
View File
@@ -16,8 +16,8 @@ jobs:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
POSTGRES_DB: bknd
ports:
- 5430:5432
# ports:
# - 5430:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
@@ -25,15 +25,15 @@ jobs:
--health-retries 5
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v7
uses: actions/setup-node@v4
with:
node-version: "22.x"
- name: Setup Bun
uses: oven-sh/setup-bun@v2
uses: oven-sh/setup-bun@v1
with:
bun-version: "1.3.3"
+8 -5
View File
@@ -1,19 +1,22 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"js/ts.tsdk.path": "node_modules/typescript/lib",
"biome.enabled": true,
"editor.defaultFormatter": "biomejs.biome",
"editor.codeActionsOnSave": {
//"source.organizeImports.biome": "explicit",
"source.fixAll.biome": "explicit"
},
"typescript.preferences.importModuleSpecifier": "non-relative",
"typescript.preferences.autoImportFileExcludePatterns": [
"js/ts.preferences.importModuleSpecifier": "non-relative",
"js/ts.preferences.autoImportFileExcludePatterns": [
"**/dist/**",
"**/node_modules/**/dist/**",
"**/node_modules/**/!(src|lib|esm)/**" // optional, stricter
],
"typescript.preferences.includePackageJsonAutoImports": "on",
"typescript.tsserver.watchOptions": {
"js/ts.preferences.includePackageJsonAutoImports": "on",
"js/ts.tsserver.watchOptions": {
"excludeDirectories": ["**/dist", "**/node_modules/**/dist"]
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
}
}
+2 -2
View File
@@ -107,7 +107,7 @@ bknd/
index.ts # Main package exports
__test__/ # Unit tests (mirrors src/ structure)
e2e/ # End-to-end tests (Playwright)
build.ts # Build script (tsdown/rolldown)
build.ts # Build script (tsup/esbuild)
build.cli.ts # CLI build script
packages/ # Small satellite packages
cli/ # Standalone CLI package (bknd-cli)
@@ -146,7 +146,7 @@ Plugins (in `app/src/plugins/`) hook into the app lifecycle via callbacks like `
### Build System
The build is handled by a custom `app/build.ts` script using tsdown (rolldown under the hood). It builds four targets in parallel:
The build is handled by a custom `app/build.ts` script using tsup (esbuild under the hood). It builds four targets in parallel:
- **API** -- the core backend
- **UI** -- the admin interface
+53 -54
View File
@@ -4,73 +4,72 @@ import { disableConsoleLog, enableConsoleLog, omitKeys } from "core/utils";
import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test";
const stripConnection = <T extends Record<string, any>>(cfg: T) =>
omitKeys(cfg, ["connection"]);
const stripConnection = <T extends Record<string, any>>(cfg: T) => omitKeys(cfg, ["connection"]);
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("adapter", () => {
describe("makeConfig", () => {
it("returns empty config for empty inputs", async () => {
const cases: Array<Parameters<typeof adapter.makeConfig>> = [
[{}],
[{}, { env: { TEST: "test" } }],
];
describe("makeConfig", () => {
it("returns empty config for empty inputs", async () => {
const cases: Array<Parameters<typeof adapter.makeConfig>> = [
[{}],
[{}, { env: { TEST: "test" } }],
];
for (const args of cases) {
const cfg = await adapter.makeConfig(...(args as any));
expect(stripConnection(cfg)).toEqual({});
}
});
it("merges app output into config", async () => {
const cfg = await adapter.makeConfig(
{ app: (a) => ({ config: { server: { cors: { origin: a.env.TEST } } } }) },
{ env: { TEST: "test" } },
);
expect(stripConnection(cfg)).toEqual({
config: { server: { cors: { origin: "test" } } },
for (const args of cases) {
const cfg = await adapter.makeConfig(...(args as any));
expect(stripConnection(cfg)).toEqual({});
}
});
});
it("allows all properties in app() result", async () => {
const called = mock(() => null);
it("merges app output into config", async () => {
const cfg = await adapter.makeConfig(
{ app: (a) => ({ config: { server: { cors: { origin: a.env.TEST } } } }) },
{ env: { TEST: "test" } },
);
const cfg = await adapter.makeConfig(
{
app: (env) => ({
connection: { url: "test" },
expect(stripConnection(cfg)).toEqual({
config: { server: { cors: { origin: "test" } } },
options: { mode: "db" as const },
onBuilt: () => {
called();
expect(env).toEqual({ foo: "bar" });
});
});
it("allows all properties in app() result", async () => {
const called = mock(() => null);
const cfg = await adapter.makeConfig(
{
app: (env) => ({
connection: { url: "test" },
config: { server: { cors: { origin: "test" } } },
options: { mode: "db" as const },
onBuilt: () => {
called();
expect(env).toEqual({ foo: "bar" });
},
}),
},
}),
},
{ foo: "bar" },
);
{ foo: "bar" },
);
expect(cfg.connection).toEqual({ url: "test" });
expect(cfg.config).toEqual({ server: { cors: { origin: "test" } } });
expect(cfg.options).toEqual({ mode: "db" });
expect(cfg.connection).toEqual({ url: "test" });
expect(cfg.config).toEqual({ server: { cors: { origin: "test" } } });
expect(cfg.options).toEqual({ mode: "db" });
await cfg.onBuilt?.({} as any);
expect(called).toHaveBeenCalledTimes(1);
});
});
await cfg.onBuilt?.({} as any);
expect(called).toHaveBeenCalledTimes(1);
});
});
describe("adapter test suites", () => {
adapterTestSuite(bunTestRunner, {
makeApp: adapter.createFrameworkApp,
label: "framework app",
});
describe("adapter test suites", () => {
adapterTestSuite(bunTestRunner, {
makeApp: adapter.createFrameworkApp,
label: "framework app",
});
adapterTestSuite(bunTestRunner, {
makeApp: adapter.createRuntimeApp,
label: "runtime app",
});
});
adapterTestSuite(bunTestRunner, {
makeApp: adapter.createRuntimeApp,
label: "runtime app",
});
});
});
+9 -3
View File
@@ -73,7 +73,13 @@ describe("Api", async () => {
it("should extract tokens case insensitive", async () => {
const token = await sign({ sub: "test" }, "1234");
expect(new Api({ headers: new Headers({ Authorization: `Bearer ${token}` }) }).getAuthState().token).toBe(token);
expect(new Api({ headers: new Headers({ Authorization: `bearer ${token}` }) }).getAuthState().token).toBe(token);
})
expect(
new Api({ headers: new Headers({ Authorization: `Bearer ${token}` }) }).getAuthState()
.token,
).toBe(token);
expect(
new Api({ headers: new Headers({ Authorization: `bearer ${token}` }) }).getAuthState()
.token,
).toBe(token);
});
});
+13 -9
View File
@@ -51,14 +51,18 @@ describe("Authenticator", async () => {
});
const token = await auth.jwt({ sub: "test" });
const res = await auth.resolveAuthFromRequest(new Headers({
Authorization: `Bearer ${token}`,
}));
expect((res as any).sub).toBe("test")
const res = await auth.resolveAuthFromRequest(
new Headers({
Authorization: `Bearer ${token}`,
}),
);
expect((res as any).sub).toBe("test");
const res2 = await auth.resolveAuthFromRequest(new Headers({
Authorization: `bearer ${token}`,
}));
expect((res2 as any).sub).toBe("test")
})
const res2 = await auth.resolveAuthFromRequest(
new Headers({
Authorization: `bearer ${token}`,
}),
);
expect((res2 as any).sub).toBe("test");
});
});
+1
View File
@@ -26,6 +26,7 @@ describe("Polymorphic", async () => {
target: "media",
config: {
mappedBy: "image",
on_delete: "set null",
},
});
// media should not see categories
+9 -2
View File
@@ -8,9 +8,11 @@ import { $ } from "bun";
import { connectionTestSuite } from "data/connection/connection-test-suite";
import { bunTestRunner } from "adapter/bun/test";
const isCI = !!(process.env.GITEA_ACTIONS || process.env.GITHUB_ACTIONS);
const credentials = {
host: "localhost",
port: 5430,
host: isCI ? "postgres" : "localhost",
port: 5432,
user: "postgres",
password: "postgres",
database: "bknd",
@@ -39,6 +41,11 @@ async function isPostgresRunning() {
describe("postgres", () => {
beforeAll(async () => {
if (process.env.GITEA_ACTIONS || process.env.GITHUB_ACTIONS) {
console.log("Running in CI: Skipping manual docker run");
return;
}
if (!(await isPostgresRunning())) {
await $`docker run --rm --name bknd-test-postgres -d -e POSTGRES_PASSWORD=${credentials.password} -e POSTGRES_USER=${credentials.user} -e POSTGRES_DB=${credentials.database} -p ${credentials.port}:5432 postgres:17`;
await $waitUntil("Postgres is running", isPostgresRunning, 500, 20);
@@ -7,7 +7,9 @@ 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 v10 from "./samples/v10.json";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import { CURRENT_VERSION } from "modules/db/migrations";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
@@ -61,7 +63,7 @@ async function getRawConfig(
return await db
.selectFrom("__bknd")
.selectAll()
.$if(!!opts?.version, (qb) => qb.where("version", "=", opts?.version))
.where("version", "=", opts?.version ?? CURRENT_VERSION)
.$if((opts?.types?.length ?? 0) > 0, (qb) => qb.where("type", "in", opts?.types))
.execute();
}
@@ -115,7 +117,6 @@ describe("Migrations", () => {
"^^s3.secret_access_key^^",
);
const [config, secrets] = (await getRawConfig(app, {
version: 10,
types: ["config", "secrets"],
})) as any;
@@ -129,4 +130,15 @@ describe("Migrations", () => {
"^^s3.secret_access_key^^",
);
});
test("migration from 10 to 11", async () => {
expect(v10.version).toBe(10);
expect(v10.data.entities.test.fields.title.config.fillable).toEqual(["read", "update"]);
const app = await createVersionedApp(v10);
expect(app.version()).toBeGreaterThan(10);
const [config] = (await getRawConfig(app, { types: ["config"] })) as any;
expect(config.json.data.entities.test.fields.title.config.fillable).toEqual(true);
});
});
@@ -0,0 +1,270 @@
{
"version": 10,
"server": {
"cors": {
"origin": "*",
"allow_methods": ["GET", "POST", "PATCH", "PUT", "DELETE"],
"allow_headers": [
"Content-Type",
"Content-Length",
"Authorization",
"Accept"
],
"allow_credentials": true
},
"mcp": {
"enabled": true,
"path": "/api/system/mcp",
"logLevel": "warning"
}
},
"data": {
"basepath": "/api/data",
"default_primary_format": "integer",
"entities": {
"test": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"format": "integer",
"fillable": false,
"required": false
}
},
"title": {
"type": "text",
"config": {
"required": false,
"fillable": ["read", "update"]
}
},
"status": {
"type": "enum",
"config": {
"default_value": "INACTIVE",
"options": {
"type": "strings",
"values": ["INACTIVE", "SUBSCRIBED", "UNSUBSCRIBED"]
},
"required": true,
"fillable": true
}
},
"created_at": {
"type": "date",
"config": {
"type": "datetime",
"required": true,
"fillable": true
}
},
"schema": {
"type": "jsonschema",
"config": {
"default_from_schema": true,
"schema": {
"type": "object",
"properties": {
"one": {
"type": "number",
"default": 1
}
}
},
"required": true,
"fillable": true
}
},
"text": {
"type": "text",
"config": {
"required": false,
"fillable": true
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "asc"
}
},
"items": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"format": "integer",
"fillable": false,
"required": false
}
},
"title": {
"type": "text",
"config": {
"required": false,
"fillable": true
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "asc"
}
},
"media": {
"type": "system",
"fields": {
"id": {
"type": "primary",
"config": {
"format": "integer",
"fillable": false,
"required": false
}
},
"path": {
"type": "text",
"config": {
"required": true,
"fillable": true
}
},
"folder": {
"type": "boolean",
"config": {
"default_value": false,
"hidden": true,
"fillable": ["create"],
"required": false
}
},
"mime_type": {
"type": "text",
"config": {
"required": false,
"fillable": true
}
},
"size": {
"type": "number",
"config": {
"required": false,
"fillable": true
}
},
"scope": {
"type": "text",
"config": {
"hidden": true,
"fillable": ["create"],
"required": false
}
},
"etag": {
"type": "text",
"config": {
"required": false,
"fillable": true
}
},
"modified_at": {
"type": "date",
"config": {
"type": "datetime",
"required": false,
"fillable": true
}
},
"reference": {
"type": "text",
"config": {
"required": false,
"fillable": true
}
},
"entity_id": {
"type": "text",
"config": {
"required": false,
"fillable": true
}
},
"metadata": {
"type": "json",
"config": {
"required": false,
"fillable": true
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "asc"
}
}
},
"relations": {},
"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
}
}
},
"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,
"partitioned": false,
"renew": true,
"pathSuccess": "/",
"pathLoggedOut": "/"
},
"strategies": {
"password": {
"type": "password",
"enabled": true,
"config": {
"hashing": "sha256"
}
}
},
"guard": {
"enabled": false
},
"roles": {}
},
"media": {
"enabled": false
},
"flows": {
"basepath": "/api/flows",
"flows": {}
}
}
+148 -184
View File
@@ -1,9 +1,9 @@
import { build, type UserConfig } from "tsdown";
import { $ } from "bun";
import * as tsup from "tsup";
import pkg from "./package.json" with { type: "json" };
import c from "picocolors";
import { watch as fsWatch, readdirSync, rmSync } from "node:fs";
import { join, resolve } from "node:path";
import { buildTypes as runTypesBuild } from "./build.types";
import { join } from "node:path";
const args = process.argv.slice(2);
const watch = args.includes("--watch");
@@ -11,15 +11,14 @@ const minify = args.includes("--minify");
const types = args.includes("--types");
const sourcemap = args.includes("--sourcemap");
const clean = args.includes("--clean");
const dev = args.includes("--dev");
// silence tsdown
// silence tsup
const oldConsole = {
log: console.log,
warn: console.warn,
};
console.log = () => { };
console.warn = () => { };
console.log = () => {};
console.warn = () => {};
const define = {
__isDev: "0",
@@ -37,41 +36,48 @@ if (clean) {
const entryPath = join(distPath, entry);
rmSync(entryPath, { recursive: true, force: true });
}
} catch {
} catch (e) {
// dist may not exist yet, ignore
}
}
let types_running = false;
async function buildTypes() {
function buildTypes() {
if (types_running || !types) return;
types_running = true;
try {
await runTypesBuild();
oldConsole.log(c.cyan("[Types]"), c.green("built"));
} finally {
types_running = false;
}
Bun.spawn(["bun", "build:types"], {
stdout: "inherit",
onExit: () => {
oldConsole.log(c.cyan("[Types]"), c.green("built"));
Bun.spawn(["bun", "tsc-alias"], {
stdout: "inherit",
onExit: () => {
oldConsole.log(c.cyan("[Types]"), c.green("aliased"));
types_running = false;
},
});
},
});
}
if (types && !watch) {
buildTypes();
}
let watcher_timeout: any;
function delayTypes(delayTime = 1000) {
function delayTypes() {
if (!watch || !types) return;
if (watcher_timeout) {
clearTimeout(watcher_timeout);
}
watcher_timeout = setTimeout(() => {
void buildTypes().catch((error) => {
oldConsole.warn(c.cyan("[Types]"), c.red("build failed"), error);
});
}, delayTime);
watcher_timeout = setTimeout(buildTypes, 1000);
}
const dependencies = Object.keys(pkg.dependencies);
// collection of always-neverBundle packages
const neverBundle = [
// collection of always-external packages
const external = [
...dependencies,
"bun:test",
"node:test",
@@ -87,14 +93,11 @@ const neverBundle = [
* Building backend and general API
*/
async function buildApi() {
await build({
await tsup.build({
minify,
sourcemap,
// don't use tsdown's broken watch, we'll handle it ourselves
// don't use tsup's broken watch, we'll handle it ourselves
watch: false,
// cleaning is handled manually (--clean) to preserve dist/static
clean: false,
dts: false,
define,
entry: [
"src/index.ts",
@@ -102,19 +105,14 @@ async function buildApi() {
"src/plugins/index.ts",
"src/modes/index.ts",
],
unbundle: false,
outDir: "dist",
deps: {
neverBundle: [
...neverBundle
],
},
external: [...external],
metafile: true,
target: "esnext",
platform: "browser",
nodeProtocol: true,
format: "esm",
removeNodeProtocol: false,
format: ["esm"],
splitting: false,
loader: {
".svg": "dataurl",
},
@@ -138,66 +136,53 @@ async function buildUi() {
minify,
sourcemap,
watch: false,
// cleaning is handled manually (--clean) to preserve dist/static
clean: false,
dts: false,
define,
css: {
transformer: "postcss",
postcss: "postcss.config.js",
minify: true,
splitting: false
},
deps: {
neverBundle: [
...neverBundle,
"react",
"react-dom",
"react/jsx-runtime",
"react/jsx-dev-runtime",
"use-sync-external-store",
/codemirror/,
"@xyflow/react",
"@mantine/core",
],
},
devtools: dev,
external: [
...external,
"react",
"react-dom",
"react/jsx-runtime",
"react/jsx-dev-runtime",
"use-sync-external-store",
/codemirror/,
"@xyflow/react",
"@mantine/core",
],
metafile: true,
platform: "browser",
format: "esm",
unbundle: false,
format: ["esm"],
splitting: false,
bundle: true,
treeshake: true,
loader: {
".svg": "dataurl",
},
esbuildOptions: (options) => {
options.logLevel = "silent";
},
} satisfies tsup.Options;
logLevel: "silent",
} satisfies UserConfig;
await tsup.build({
...base,
entry: ["src/ui/index.ts", "src/ui/main.css", "src/ui/styles.css"],
outDir: "dist/ui",
onSuccess: async () => {
await rewriteClient("./dist/ui/index.js");
delayTypes();
oldConsole.log(c.cyan("[UI]"), c.green("built"));
},
});
await Promise.all([
build({
...base,
entry: ["src/ui/index.ts", "src/ui/main.css", "src/ui/styles.css"],
outDir: "dist/ui",
onSuccess: async () => {
await rewriteClient("./dist/ui/index.js");
delayTypes();
oldConsole.log(c.cyan("[UI]"), c.green("built"));
},
}),
build({
...base,
entry: ["src/ui/client/index.ts"],
outDir: "dist/ui/client",
onSuccess: async () => {
await rewriteClient("./dist/ui/client/index.js");
delayTypes();
oldConsole.log(c.cyan("[UI]"), "Client", c.green("built"));
},
}),
]);
await tsup.build({
...base,
entry: ["src/ui/client/index.ts"],
outDir: "dist/ui/client",
onSuccess: async () => {
await rewriteClient("./dist/ui/client/index.js");
delayTypes();
oldConsole.log(c.cyan("[UI]"), "Client", c.green("built"));
},
});
}
/**
@@ -206,41 +191,39 @@ async function buildUi() {
* - ui/client is external, and after built replaced with "bknd/client"
*/
async function buildUiElements() {
await build({
await tsup.build({
minify,
sourcemap,
watch: false,
// cleaning is handled manually (--clean) to preserve dist/static
clean: false,
dts: false,
define,
entry: ["src/ui/elements/index.ts"],
outDir: "dist/ui/elements",
deps: {
neverBundle: [
"ui/client",
"bknd",
/^bknd\/.*/,
"wouter",
"react",
"react-dom",
"react/jsx-runtime",
"react/jsx-dev-runtime",
"use-sync-external-store",
],
},
external: [
"ui/client",
"bknd",
/^bknd\/.*/,
"wouter",
"react",
"react-dom",
"react/jsx-runtime",
"react/jsx-dev-runtime",
"use-sync-external-store",
],
metafile: true,
platform: "browser",
format: "esm",
format: ["esm"],
splitting: false,
bundle: true,
treeshake: true,
loader: {
".svg": "dataurl",
},
alias: {
// not important for elements, mock to reduce bundle
"tailwind-merge": resolve(import.meta.dir, "src/ui/elements/mocks/tailwind-merge.ts"),
esbuildOptions: (options) => {
options.alias = {
// not important for elements, mock to reduce bundle
"tailwind-merge": "./src/ui/elements/mocks/tailwind-merge.ts",
};
},
onSuccess: async () => {
await rewriteClient("./dist/ui/elements/index.js");
delayTypes();
@@ -252,20 +235,18 @@ async function buildUiElements() {
/**
* Building adapters
*/
function baseConfig(adapter: string, overrides: Partial<UserConfig> = {}): UserConfig {
function baseConfig(adapter: string, overrides: Partial<tsup.Options> = {}): tsup.Options {
return {
minify,
sourcemap,
watch: false,
// cleaning is handled manually (--clean) to preserve dist/static
clean: false,
dts: false,
entry: [`src/adapter/${adapter}/index.ts`],
format: "esm",
format: ["esm"],
platform: "neutral",
outDir: `dist/adapter/${adapter}`,
devtools: dev,
nodeProtocol: true,
metafile: true,
splitting: false,
removeNodeProtocol: false,
onSuccess: async () => {
delayTypes();
oldConsole.log(c.cyan("[Adapter]"), adapter || "base", c.green("built"));
@@ -275,23 +256,21 @@ function baseConfig(adapter: string, overrides: Partial<UserConfig> = {}): UserC
...define,
...overrides.define,
},
deps: {
neverBundle: [
/^cloudflare*/,
/^@?hono.*?/,
/^(bknd|react|next|node).*?/,
/.*\.(html)$/,
...neverBundle,
...(Array.isArray(overrides.deps?.neverBundle) ? overrides.deps.neverBundle : []),
],
},
external: [
/^cloudflare*/,
/^@?hono.*?/,
/^(bknd|react|next|node).*?/,
/.*\.(html)$/,
...external,
...(Array.isArray(overrides.external) ? overrides.external : []),
],
};
}
async function buildAdapters() {
await Promise.all([
// base adapter handles
build({
tsup.build({
...baseConfig(""),
target: "esnext",
platform: "neutral",
@@ -299,120 +278,105 @@ async function buildAdapters() {
outDir: "dist/adapter",
// only way to keep @vite-ignore comments
minify: false,
dts: false,
}),
// specific adapters
build(baseConfig("react-router")),
build(
// specific adatpers
tsup.build(baseConfig("react-router")),
tsup.build(
baseConfig("browser", {
deps: {
neverBundle: [/^sqlocal\/?.*?/, "wouter"],
},
external: [/^sqlocal\/?.*?/, "wouter"],
}),
),
build(
tsup.build(
baseConfig("bun", {
deps: {
neverBundle: [/^bun:.*/],
},
external: [/^bun\:.*/],
}),
),
build(baseConfig("astro")),
build(baseConfig("aws")),
build(
tsup.build(baseConfig("astro")),
tsup.build(baseConfig("aws")),
tsup.build(
baseConfig("cloudflare", {
deps: {
neverBundle: ["wrangler", "node:process", "miniflare"],
},
external: ["wrangler", "node:process"],
}),
),
build(
tsup.build(
baseConfig("cloudflare/proxy", {
target: "esnext",
entry: ["src/adapter/cloudflare/proxy.ts"],
outDir: "dist/adapter/cloudflare",
devtools: false,
deps: {
neverBundle: [/bknd/, "wrangler", "node:process", "miniflare"],
},
metafile: false,
external: [/bknd/, "wrangler", "node:process"],
}),
),
build({
tsup.build({
...baseConfig("vite"),
platform: "node",
}),
build({
tsup.build({
...baseConfig("nextjs"),
platform: "node",
}),
build({
tsup.build({
...baseConfig("tanstack-start"),
platform: "node",
}),
build({
tsup.build({
...baseConfig("sveltekit"),
platform: "node",
}),
build({
tsup.build({
...baseConfig("nuxt"),
platform: "node",
}),
build({
tsup.build({
...baseConfig("node"),
platform: "node",
}),
build({
tsup.build({
...baseConfig("solid-start"),
platform: "node",
}),
tsup.build({
...baseConfig("sqlite/edge"),
entry: ["src/adapter/sqlite/edge.ts"],
outDir: "dist/adapter/sqlite",
devtools: false,
metafile: false,
}),
build({
tsup.build({
...baseConfig("sqlite/node"),
entry: ["src/adapter/sqlite/node.ts"],
outDir: "dist/adapter/sqlite",
platform: "node",
devtools: false,
metafile: false,
}),
build(
baseConfig("sqlite/bun", {
entry: ["src/adapter/sqlite/bun.ts"],
outDir: "dist/adapter/sqlite",
devtools: false,
deps: {
neverBundle: [/^bun:.*/],
},
}),
),
tsup.build({
...baseConfig("sqlite/bun"),
entry: ["src/adapter/sqlite/bun.ts"],
outDir: "dist/adapter/sqlite",
metafile: false,
external: [/^bun\:.*/],
}),
]);
}
const buildPipeline = [buildApi, buildUi, buildUiElements, buildAdapters];
// Run type generation in parallel, since it can be slow and doesn't block the main build
if (types) {
buildPipeline.push(async () => await buildTypes());
async function buildAll() {
await Promise.all([buildApi(), buildUi(), buildUiElements(), buildAdapters()]);
}
// initial build
await buildAll();
async function buildAll() {
await Promise.all(buildPipeline.map(fn => fn()));
}
// custom watcher since tsdown's watch is broken in v0.21.7
// custom watcher since tsup's watch is broken in 8.3.5+
if (watch) {
oldConsole.log(c.cyan("[Watch]"), "watching for changes in src/...");
@@ -448,5 +412,5 @@ if (watch) {
});
// keep process alive
await new Promise(() => { });
await new Promise(() => {});
}
-35
View File
@@ -1,35 +0,0 @@
import { build } from "tsdown";
import c from "picocolors";
export async function buildTypes() {
await build({
tsconfig: "tsconfig.build.json",
watch: false,
clean: true,
logLevel: "silent",
unbundle: true,
root: "src",
outDir: "dist/types",
entry: ["./src/**/*.ts", "./src/**/*.tsx"],
format: "esm",
dts: {
emitDtsOnly: true,
resolver: "oxc",
eager: true,
incremental: true,
},
deps: {
skipNodeModulesBundle: true
},
});
}
if (import.meta.main) {
try {
await buildTypes();
console.log(c.cyan("[Types]"), c.green("built"));
} catch (error) {
console.error(c.cyan("[Types]"), c.red("build failed"), error);
process.exitCode = 1;
}
}
+4 -1
View File
@@ -3,7 +3,10 @@ import path from "node:path";
import c from "picocolors";
const basePath = new URL(import.meta.resolve("../../")).pathname.slice(0, -1);
type RunOptions = Omit<Bun.SpawnOptions.SpawnOptions<"ignore", "pipe", "pipe">, "stdout" | "stderr">;
type RunOptions = Omit<
Bun.SpawnOptions.SpawnOptions<"ignore", "pipe", "pipe">,
"stdout" | "stderr"
>;
async function run(
cmd: string[] | string,
+42 -39
View File
@@ -1,50 +1,53 @@
const adapter = process.env.TEST_ADAPTER;
const default_config = {
media_adapter: "local",
base_path: "",
media_adapter: "local",
base_path: "",
} as const;
const configs = {
cloudflare: {
media_adapter: "r2",
},
"react-router": {
base_path: "/admin",
},
nextjs: {
base_path: "/admin",
},
nuxt: {
base_path: "/admin",
},
astro: {
base_path: "/admin",
},
node: {
base_path: "",
},
bun: {
base_path: "",
},
"tanstack-start": {
base_path: "/admin",
},
cloudflare: {
media_adapter: "r2",
},
"react-router": {
base_path: "/admin",
},
nextjs: {
base_path: "/admin",
},
nuxt: {
base_path: "/admin",
},
"solid-start": {
base_path: "/admin",
},
astro: {
base_path: "/admin",
},
node: {
base_path: "",
},
bun: {
base_path: "",
},
"tanstack-start": {
base_path: "/admin",
},
};
export function getAdapterConfig(): typeof default_config {
if (adapter) {
if (!configs[adapter]) {
console.warn(
`Adapter "${adapter}" not found. Available adapters: ${Object.keys(configs).join(", ")}`,
);
} else {
return {
...default_config,
...configs[adapter],
};
}
}
if (adapter) {
if (!configs[adapter]) {
console.warn(
`Adapter "${adapter}" not found. Available adapters: ${Object.keys(configs).join(", ")}`,
);
} else {
return {
...default_config,
...configs[adapter],
};
}
}
return default_config;
return default_config;
}
+86 -108
View File
@@ -15,8 +15,7 @@
},
"packageManager": "bun@1.3.3",
"engines": {
"node": ">=22.18.0",
"bun": ">=1.3.3"
"node": ">=22.13"
},
"scripts": {
"dev": "BKND_CLI_LOG_LEVEL=debug vite",
@@ -24,11 +23,11 @@
"build:all": "rm -rf dist && bun run build:static && NODE_ENV=production bun run build.ts --minify --types --clean && bun run build:cli",
"build:ci": "mkdir -p dist/static/.vite && echo '{}' > dist/static/.vite/manifest.json && NODE_ENV=production bun run build.ts",
"build:cli": "bun run build.cli.ts",
"build:types": "bun run build.types.ts",
"build:static": "vite build",
"watch": "bun run build.ts --types --watch",
"types": "bun tsc -p tsconfig.build.json --noEmit",
"clean:types": "find ./dist -name '*.d.ts' -delete && rm -f ./dist/tsconfig.tsbuildinfo",
"build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly && tsc-alias",
"updater": "bun x npm-check-updates -ui",
"cli": "LOCAL=1 bun src/cli/index.ts",
"prepublishOnly": "bun run types && bun run test && bun run test:node && NODE_NO_WARNINGS=1 VITE_DB_URL=:memory: bun run test:e2e && bun run build:all && cp ../README.md ./",
@@ -94,18 +93,17 @@
"@rjsf/utils": "5.22.0",
"@standard-schema/spec": "^1.0.0",
"@tabler/icons-react": "3.35.0",
"@tailwindcss/postcss": "^4.2.2",
"@tailwindcss/vite": "^4.2.4",
"@tailwindcss/postcss": "^4.1.16",
"@tailwindcss/vite": "^4.1.16",
"@tanstack/react-store": "^0.8.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@tsdown/css": "^0.22.14",
"@types/node": "^24.13.3",
"@types/node": "^24.10.0",
"@types/pg": "^8.15.6",
"@types/react": "^19.2.16",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^6.0.2",
"@vitest/coverage-v8": "4.1.10",
"@vitejs/plugin-react": "^5.1.0",
"@vitest/coverage-v8": "3.0.9",
"autoprefixer": "^10.4.21",
"clsx": "^2.1.1",
"commander": "^14.0.2",
@@ -116,87 +114,89 @@
"kysely-postgres-js": "^2.0.0",
"libsql": "^0.5.22",
"libsql-stateless-easy": "^1.8.0",
"miniflare": "^4.20260317.3",
"miniflare": "^4.20251011.2",
"open": "^10.2.0",
"openapi-types": "^12.1.3",
"pg": "^8.16.3",
"postcss": "^8.5.26",
"postcss-import": "^16.1.1",
"postcss": "^8.5.3",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"postgres": "^3.4.7",
"posthog-js-lite": "^3.6.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.66.0",
"react-icons": "5.5.0",
"react-json-view-lite": "^2.5.0",
"sql-formatter": "^15.6.10",
"sqlocal": "^0.16.0",
"tailwind-merge": "^3.0.2",
"tailwindcss": "^4.3.3",
"tailwindcss": "^4.1.16",
"tailwindcss-animate": "^1.0.7",
"tsdown": "^0.22.14",
"tsc-alias": "^1.8.16",
"tsup": "^8.5.0",
"tsx": "^4.20.6",
"uuid": "^13.0.0",
"vite": "^8.0.16",
"vite-plugin-circular-dependency": "^0.6.0",
"vitest": "4.1.10",
"vite": "^7.1.12",
"vite-plugin-circular-dependency": "^0.5.0",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "3.0.9",
"wouter": "^3.7.1",
"wrangler": "^4.78.0"
"wrangler": "^4.45.4"
},
"optionalDependencies": {
"@hono/node-server": "^1.19.6"
},
"peerDependencies": {
"react": ">=19.2.6",
"react-dom": ">=19.2.6"
"react": ">=19",
"react-dom": ">=19"
},
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/types/index.d.mts",
"types": "./dist/types/index.d.ts",
"exports": {
".": {
"types": "./dist/types/index.d.mts",
"types": "./dist/types/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.js"
},
"./ui": {
"types": "./dist/types/ui/index.d.mts",
"types": "./dist/types/ui/index.d.ts",
"import": "./dist/ui/index.js",
"require": "./dist/ui/index.js"
},
"./elements": {
"types": "./dist/types/ui/elements/index.d.mts",
"types": "./dist/types/ui/elements/index.d.ts",
"import": "./dist/ui/elements/index.js",
"require": "./dist/ui/elements/index.js"
},
"./client": {
"types": "./dist/types/ui/client/index.d.mts",
"types": "./dist/types/ui/client/index.d.ts",
"import": "./dist/ui/client/index.js",
"require": "./dist/ui/client/index.js"
},
"./utils": {
"types": "./dist/types/core/utils/index.d.mts",
"types": "./dist/types/core/utils/index.d.ts",
"import": "./dist/core/utils/index.js",
"require": "./dist/core/utils/index.js"
},
"./cli": {
"types": "./dist/types/cli/index.d.mts",
"types": "./dist/types/cli/index.d.ts",
"import": "./dist/cli/index.js",
"require": "./dist/cli/index.js"
},
"./plugins": {
"types": "./dist/types/plugins/index.d.mts",
"types": "./dist/types/plugins/index.d.ts",
"import": "./dist/plugins/index.js",
"require": "./dist/plugins/index.js"
},
"./modes": {
"types": "./dist/types/modes/index.d.mts",
"types": "./dist/types/modes/index.d.ts",
"import": "./dist/modes/index.js",
"require": "./dist/modes/index.js"
},
"./adapter/sqlite": {
"types": "./dist/types/adapter/sqlite/edge.d.mts",
"types": "./dist/types/adapter/sqlite/edge.d.ts",
"import": {
"workerd": "./dist/adapter/sqlite/edge.js",
"edge-light": "./dist/adapter/sqlite/edge.js",
@@ -204,130 +204,107 @@
"vercel": "./dist/adapter/sqlite/edge.js",
"browser": "./dist/adapter/sqlite/edge.js",
"bun": "./dist/adapter/sqlite/bun.js",
"node": "./dist/adapter/sqlite/node.mjs",
"default": "./dist/adapter/sqlite/node.mjs"
"node": "./dist/adapter/sqlite/node.js",
"default": "./dist/adapter/sqlite/node.js"
},
"require": "./dist/adapter/sqlite/node.mjs"
"require": "./dist/adapter/sqlite/node.js"
},
"./adapter/cloudflare": {
"types": "./dist/types/adapter/cloudflare/index.d.mts",
"types": "./dist/types/adapter/cloudflare/index.d.ts",
"import": "./dist/adapter/cloudflare/index.js",
"require": "./dist/adapter/cloudflare/index.js"
},
"./adapter/cloudflare/proxy": {
"types": "./dist/types/adapter/cloudflare/proxy.d.mts",
"types": "./dist/types/adapter/cloudflare/proxy.d.ts",
"import": "./dist/adapter/cloudflare/proxy.js",
"require": "./dist/adapter/cloudflare/proxy.js"
},
"./adapter": {
"types": "./dist/types/adapter/index.d.mts",
"types": "./dist/types/adapter/index.d.ts",
"import": "./dist/adapter/index.js"
},
"./adapter/vite": {
"types": "./dist/types/adapter/vite/index.d.mts",
"import": "./dist/adapter/vite/index.mjs",
"require": "./dist/adapter/vite/index.mjs"
"types": "./dist/types/adapter/vite/index.d.ts",
"import": "./dist/adapter/vite/index.js",
"require": "./dist/adapter/vite/index.js"
},
"./adapter/nextjs": {
"types": "./dist/types/adapter/nextjs/index.d.mts",
"import": "./dist/adapter/nextjs/index.mjs",
"require": "./dist/adapter/nextjs/index.mjs"
"types": "./dist/types/adapter/nextjs/index.d.ts",
"import": "./dist/adapter/nextjs/index.js",
"require": "./dist/adapter/nextjs/index.js"
},
"./adapter/nuxt": {
"types": "./dist/types/adapter/nuxt/index.d.mts",
"import": "./dist/adapter/nuxt/index.mjs",
"require": "./dist/adapter/nuxt/index.mjs"
"types": "./dist/types/adapter/nuxt/index.d.ts",
"import": "./dist/adapter/nuxt/index.js",
"require": "./dist/adapter/nuxt/index.js"
},
"./adapter/react-router": {
"types": "./dist/types/adapter/react-router/index.d.mts",
"types": "./dist/types/adapter/react-router/index.d.ts",
"import": "./dist/adapter/react-router/index.js",
"require": "./dist/adapter/react-router/index.js"
},
"./adapter/bun": {
"types": "./dist/types/adapter/bun/index.d.mts",
"types": "./dist/types/adapter/bun/index.d.ts",
"import": "./dist/adapter/bun/index.js",
"require": "./dist/adapter/bun/index.js"
},
"./adapter/node": {
"types": "./dist/types/adapter/node/index.d.mts",
"import": "./dist/adapter/node/index.mjs",
"require": "./dist/adapter/node/index.mjs"
"types": "./dist/types/adapter/node/index.d.ts",
"import": "./dist/adapter/node/index.js",
"require": "./dist/adapter/node/index.js"
},
"./adapter/astro": {
"types": "./dist/types/adapter/astro/index.d.mts",
"types": "./dist/types/adapter/astro/index.d.ts",
"import": "./dist/adapter/astro/index.js",
"require": "./dist/adapter/astro/index.js"
},
"./adapter/sveltekit": {
"types": "./dist/types/adapter/sveltekit/index.d.mts",
"import": "./dist/adapter/sveltekit/index.mjs",
"require": "./dist/adapter/sveltekit/index.mjs"
"types": "./dist/types/adapter/sveltekit/index.d.ts",
"import": "./dist/adapter/sveltekit/index.js",
"require": "./dist/adapter/sveltekit/index.js"
},
"./adapter/solid-start": {
"types": "./dist/types/adapter/solid-start/index.d.ts",
"import": "./dist/adapter/solid-start/index.js",
"require": "./dist/adapter/solid-start/index.js"
},
"./adapter/aws": {
"types": "./dist/types/adapter/aws/index.d.mts",
"types": "./dist/types/adapter/aws/index.d.ts",
"import": "./dist/adapter/aws/index.js",
"require": "./dist/adapter/aws/index.js"
},
"./adapter/browser": {
"types": "./dist/types/adapter/browser/index.d.mts",
"types": "./dist/types/adapter/browser/index.d.ts",
"import": "./dist/adapter/browser/index.js",
"require": "./dist/adapter/browser/index.js"
},
"./adapter/tanstack-start": {
"types": "./dist/types/adapter/tanstack-start/index.d.mts",
"import": "./dist/adapter/tanstack-start/index.mjs",
"require": "./dist/adapter/tanstack-start/index.mjs"
"types": "./dist/types/adapter/tanstack-start/index.d.ts",
"import": "./dist/adapter/tanstack-start/index.js",
"require": "./dist/adapter/tanstack-start/index.js"
},
"./dist/main.css": "./dist/ui/style.css",
"./dist/styles.css": "./dist/ui/style.css",
"./dist/style.css": "./dist/ui/style.css",
"./dist/main.css": "./dist/ui/main.css",
"./dist/styles.css": "./dist/ui/styles.css",
"./dist/manifest.json": "./dist/static/.vite/manifest.json",
"./static/*": "./dist/static/*"
},
"typesVersions": {
"*": {
"utils": [
"./dist/types/core/utils/index.d.mts"
],
"cli": [
"./dist/types/cli/index.d.mts"
],
"plugins": [
"./dist/types/plugins/index.d.mts"
],
"adapter": [
"./dist/types/adapter/index.d.mts"
],
"adapter/cloudflare": [
"./dist/types/adapter/cloudflare/index.d.mts"
],
"adapter/vite": [
"./dist/types/adapter/vite/index.d.mts"
],
"adapter/nextjs": [
"./dist/types/adapter/nextjs/index.d.mts"
],
"adapter/nuxt": [
"./dist/types/adapter/nuxt/index.d.mts"
],
"adapter/react-router": [
"./dist/types/adapter/react-router/index.d.mts"
],
"adapter/bun": [
"./dist/types/adapter/bun/index.d.mts"
],
"adapter/node": [
"./dist/types/adapter/node/index.d.mts"
],
"adapter/sveltekit": [
"./dist/types/adapter/sveltekit/index.d.mts"
],
"adapter/tanstack-start": [
"./dist/types/adapter/tanstack-start/index.d.mts"
],
"adapter/sqlite": [
"./dist/types/adapter/sqlite/edge.d.mts"
]
"utils": ["./dist/types/core/utils/index.d.ts"],
"cli": ["./dist/types/cli/index.d.ts"],
"plugins": ["./dist/types/plugins/index.d.ts"],
"adapter": ["./dist/types/adapter/index.d.ts"],
"adapter/cloudflare": ["./dist/types/adapter/cloudflare/index.d.ts"],
"adapter/vite": ["./dist/types/adapter/vite/index.d.ts"],
"adapter/nextjs": ["./dist/types/adapter/nextjs/index.d.ts"],
"adapter/nuxt": ["./dist/types/adapter/nuxt/index.d.ts"],
"adapter/react-router": ["./dist/types/adapter/react-router/index.d.ts"],
"adapter/bun": ["./dist/types/adapter/bun/index.d.ts"],
"adapter/node": ["./dist/types/adapter/node/index.d.ts"],
"adapter/sveltekit": ["./dist/types/adapter/sveltekit/index.d.ts"],
"adapter/tanstack-start": ["./dist/types/adapter/tanstack-start/index.d.ts"],
"adapter/solid-start": ["./dist/types/adapter/solid-start/index.d.ts"],
"adapter/sqlite": ["./dist/types/adapter/sqlite/edge.d.ts"]
}
},
"publishConfig": {
@@ -360,6 +337,7 @@
"sveltekit",
"svelte",
"bun",
"node"
"node",
"solid-start"
]
}
@@ -8,12 +8,6 @@ import { $console } from "bknd/utils";
import { createRuntimeApp } from "bknd/adapter";
import { registerAsyncsExecutionContext, makeConfig, type CloudflareContext } from "./config";
// Statically import the admin UI build manifest so the bundler (wrangler/esbuild)
// resolves and bundles it at build time, rather than leaving a runtime dynamic
// import that workerd cannot resolve via package.json exports maps.
// @ts-ignore
import adminUiManifest from "bknd/dist/manifest.json";
declare global {
namespace Cloudflare {
interface Env {}
@@ -44,19 +38,6 @@ export async function createApp<Env extends CloudflareEnv = CloudflareEnv>(
ctx: Partial<CloudflareContext<Env>> = {},
) {
const appConfig = await makeConfig(config, ctx);
// The AdminController can't dynamically import "bknd/dist/manifest.json"
// at runtime in the Workers runtime (workerd ignores package.json exports
// for runtime imports). Statically import it here so esbuild bundles the
// manifest into the Worker and pass it as the admin controller's manifest
// unless the caller explicitly provides one (or disables the admin).
if (appConfig.adminOptions !== false && !(appConfig.adminOptions as any)?.manifest) {
appConfig.adminOptions = {
...(appConfig.adminOptions as any),
manifest: adminUiManifest,
};
}
return await createRuntimeApp<Env>(
{
...appConfig,
+1 -4
View File
@@ -20,10 +20,7 @@ export async function getApp<Env>(
* @param config - bknd configuration
* @param args - environment variables
*/
export function serve<Env>(
config: NuxtBkndConfig<Env> = {} as NuxtBkndConfig<Env>,
args: Env,
) {
export function serve<Env>(config: NuxtBkndConfig<Env> = {} as NuxtBkndConfig<Env>, args: Env) {
return async (request: Request) => {
return (await getApp(config, args)).fetch(request);
};
+1
View File
@@ -0,0 +1 @@
export * from "./solid-start.adapter";
@@ -0,0 +1,16 @@
import { afterAll, beforeAll, describe } from "bun:test";
import * as solidStart from "./solid-start.adapter";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test";
import type { SolidStartBkndConfig } from "./solid-start.adapter";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("solid-start adapter", () => {
adapterTestSuite<SolidStartBkndConfig>(bunTestRunner, {
makeApp: solidStart.getApp,
makeHandler: solidStart.serve,
});
});
@@ -0,0 +1,31 @@
import { createRuntimeApp, type RuntimeBkndConfig } from "bknd/adapter";
export type SolidStartEnv = NodeJS.ProcessEnv;
export type SolidStartBkndConfig<Env = SolidStartEnv> = RuntimeBkndConfig<Env>;
/**
* Get bknd app instance
* @param config - bknd configuration
* @param args - environment variables
*/
export async function getApp<Env = SolidStartEnv>(
config: SolidStartBkndConfig<Env>,
args: Env = process.env as Env,
) {
return await createRuntimeApp(config, args);
}
/**
* Create middleware handler for Solid Start
* @param config - bknd configuration
* @param args - environment variables
*/
export function serve<Env = SolidStartEnv>(
config: SolidStartBkndConfig<Env> = {},
args: Env = process.env as Env,
) {
return async (req: Request) => {
const app = await getApp(config, args);
return app.fetch(req);
};
}
@@ -2,8 +2,7 @@ import { createFrameworkApp, type FrameworkBkndConfig } from "bknd/adapter";
export type TanstackStartEnv = NodeJS.ProcessEnv;
export type TanstackStartConfig<Env = TanstackStartEnv> =
FrameworkBkndConfig<Env>;
export type TanstackStartConfig<Env = TanstackStartEnv> = FrameworkBkndConfig<Env>;
/**
* Get bknd app instance
@@ -11,10 +10,10 @@ export type TanstackStartConfig<Env = TanstackStartEnv> =
* @param args - environment variables
*/
export async function getApp<Env = TanstackStartEnv>(
config: TanstackStartConfig<Env> = {},
args: Env = process.env as Env,
config: TanstackStartConfig<Env> = {},
args: Env = process.env as Env,
) {
return await createFrameworkApp(config, args);
return await createFrameworkApp(config, args);
}
/**
@@ -23,11 +22,11 @@ export async function getApp<Env = TanstackStartEnv>(
* @param args - environment variables
*/
export function serve<Env = TanstackStartEnv>(
config: TanstackStartConfig<Env> = {},
args: Env = process.env as Env,
config: TanstackStartConfig<Env> = {},
args: Env = process.env as Env,
) {
return async (request: Request) => {
const app = await getApp(config, args);
return app.fetch(request);
};
return async (request: Request) => {
const app = await getApp(config, args);
return app.fetch(request);
};
}
+1 -3
View File
@@ -26,9 +26,7 @@ export function shouldSkip(c: Context<ServerEnv>, skip?: (string | RegExp)[]) {
return result;
}
export const auth = (options?: {
skip?: (string | RegExp)[];
}) =>
export const auth = (options?: { skip?: (string | RegExp)[] }) =>
createMiddleware<ServerEnv>(async (c, next) => {
if (!c.get("auth")) {
c.set("auth", {
+41 -45
View File
@@ -4,52 +4,48 @@ import { plunkEmail } from "./plunk";
const ALL_TESTS = !!process.env.ALL_TESTS;
describe.skipIf(ALL_TESTS)("plunk", () => {
it("should throw on failed", async () => {
const driver = plunkEmail({ apiKey: "invalid" });
expect(driver.send("foo@bar.com", "Test", "Test")).rejects.toThrow();
});
it("should throw on failed", async () => {
const driver = plunkEmail({ apiKey: "invalid" });
expect(driver.send("foo@bar.com", "Test", "Test")).rejects.toThrow();
});
it("should send an email", async () => {
const driver = plunkEmail({
apiKey: process.env.PLUNK_API_KEY!,
from: undefined, // Default to what Plunk sets
});
const response = await driver.send(
"help@bknd.io",
"Test Email from Plunk",
"This is a test email",
);
expect(response).toBeDefined();
expect(response.success).toBe(true);
expect(response.emails).toBeDefined();
expect(response.timestamp).toBeDefined();
});
it("should send an email", async () => {
const driver = plunkEmail({
apiKey: process.env.PLUNK_API_KEY!,
from: undefined, // Default to what Plunk sets
});
const response = await driver.send(
"help@bknd.io",
"Test Email from Plunk",
"This is a test email",
);
expect(response).toBeDefined();
expect(response.success).toBe(true);
expect(response.emails).toBeDefined();
expect(response.timestamp).toBeDefined();
});
it("should send HTML email", async () => {
const driver = plunkEmail({
apiKey: process.env.PLUNK_API_KEY!,
from: undefined,
});
const htmlBody = "<h1>Test Email</h1><p>This is a test email</p>";
const response = await driver.send(
"help@bknd.io",
"HTML Test",
htmlBody,
);
expect(response).toBeDefined();
expect(response.success).toBe(true);
});
it("should send HTML email", async () => {
const driver = plunkEmail({
apiKey: process.env.PLUNK_API_KEY!,
from: undefined,
});
const htmlBody = "<h1>Test Email</h1><p>This is a test email</p>";
const response = await driver.send("help@bknd.io", "HTML Test", htmlBody);
expect(response).toBeDefined();
expect(response.success).toBe(true);
});
it("should send with text and html", async () => {
const driver = plunkEmail({
apiKey: process.env.PLUNK_API_KEY!,
from: undefined,
});
const response = await driver.send("test@example.com", "Test Email", {
text: "help@bknd.io",
html: "<p>This is HTML</p>",
});
expect(response).toBeDefined();
expect(response.success).toBe(true);
});
it("should send with text and html", async () => {
const driver = plunkEmail({
apiKey: process.env.PLUNK_API_KEY!,
from: undefined,
});
const response = await driver.send("test@example.com", "Test Email", {
text: "help@bknd.io",
html: "<p>This is HTML</p>",
});
expect(response).toBeDefined();
expect(response.success).toBe(true);
});
});
+51 -51
View File
@@ -1,70 +1,70 @@
import type { IEmailDriver } from "./index";
export type PlunkEmailOptions = {
apiKey: string;
host?: string;
from?: string;
apiKey: string;
host?: string;
from?: string;
};
export type PlunkEmailSendOptions = {
subscribed?: boolean;
name?: string;
from?: string;
reply?: string;
headers?: Record<string, string>;
subscribed?: boolean;
name?: string;
from?: string;
reply?: string;
headers?: Record<string, string>;
};
export type PlunkEmailResponse = {
success: boolean;
emails: Array<{
contact: {
id: string;
email: string;
};
email: string;
}>;
timestamp: string;
success: boolean;
emails: Array<{
contact: {
id: string;
email: string;
};
email: string;
}>;
timestamp: string;
};
export const plunkEmail = (
config: PlunkEmailOptions,
config: PlunkEmailOptions,
): IEmailDriver<PlunkEmailResponse, PlunkEmailSendOptions> => {
const host = config.host ?? "https://api.useplunk.com/v1/send";
const from = config.from;
const host = config.host ?? "https://api.useplunk.com/v1/send";
const from = config.from;
return {
send: async (
to: string,
subject: string,
body: string | { text: string; html: string },
options?: PlunkEmailSendOptions,
) => {
const payload: any = {
from,
to,
subject,
};
return {
send: async (
to: string,
subject: string,
body: string | { text: string; html: string },
options?: PlunkEmailSendOptions,
) => {
const payload: any = {
from,
to,
subject,
};
if (typeof body === "string") {
payload.body = body;
} else {
payload.body = body.html;
}
if (typeof body === "string") {
payload.body = body;
} else {
payload.body = body.html;
}
const res = await fetch(host, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${config.apiKey}`,
},
body: JSON.stringify({ ...payload, ...options }),
});
const res = await fetch(host, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${config.apiKey}`,
},
body: JSON.stringify({ ...payload, ...options }),
});
if (!res.ok) {
throw new Error(`Plunk API error: ${await res.text()}`);
}
if (!res.ok) {
throw new Error(`Plunk API error: ${await res.text()}`);
}
return (await res.json()) as PlunkEmailResponse;
},
};
return (await res.json()) as PlunkEmailResponse;
},
};
};
+1 -7
View File
@@ -14,12 +14,6 @@ export interface EmitsEvents {
emgr: EventManager;
}
export type EventRegistryMap<
RegisteredEvents extends Record<string, EventClass> = Record<string, EventClass>,
> = {
[K in keyof RegisteredEvents]: RegisteredEvents[K];
};
// for compatibility, moved it to Event.ts
export type { EventClass };
@@ -71,7 +65,7 @@ export class EventManager<
return [...this.listeners];
}
get Events(): EventRegistryMap<RegisteredEvents> {
get Events(): { [K in keyof RegisteredEvents]: RegisteredEvents[K] } {
// proxy class to access events
return new Proxy(this, {
get: (_, prop: string) => {
+3 -3
View File
@@ -2,15 +2,15 @@ import { v4, v7, validate, version as uuidVersion } from "uuid";
// generates v4
export function uuid(): string {
return v4();
return v4();
}
// generates v7
export function uuidv7(): string {
return v7();
return v7();
}
// validate uuid
export function uuidValidate(uuid: string, version: 4 | 7): boolean {
return validate(uuid) && uuidVersion(uuid) === version;
return validate(uuid) && uuidVersion(uuid) === version;
}
+1 -1
View File
@@ -31,7 +31,7 @@ export type IndexMetadata = {
};
export interface SelectQueryBuilderExpression<O> extends AliasableExpression<O> {
readonly isSelectQueryBuilder: true;
get isSelectQueryBuilder(): true;
toOperationNode(): SelectQueryNode;
}
@@ -1,4 +1,8 @@
import { Kysely, PostgresDialect, type PostgresDialectConfig as KyselyPostgresDialectConfig } from "kysely";
import {
Kysely,
PostgresDialect,
type PostgresDialectConfig as KyselyPostgresDialectConfig,
} from "kysely";
import { PostgresIntrospector } from "./PostgresIntrospector";
import { PostgresConnection, plugins } from "./PostgresConnection";
import { customIntrospector } from "../Connection";
@@ -24,8 +24,6 @@ export class PostgresJsConnection extends PostgresConnection<PostgresJSDialectCo
}
}
export function postgresJs(
config: PostgresJSDialectConfig,
): PostgresJsConnection {
export function postgresJs(config: PostgresJSDialectConfig): PostgresJsConnection {
return new PostgresJsConnection(config);
}
+18 -7
View File
@@ -136,8 +136,10 @@ export class Entity<
.map((field) => (alias ? `${alias}.${field.name} as ${field.name}` : field.name));
}
getFillableFields(context?: TActionContext, include_virtual?: boolean): Field[] {
return this.getFields(include_virtual).filter((field) => field.isFillable(context));
getFillableFields(context?: "create" | "update", include_virtual?: boolean): Field[] {
return this.getFields({ virtual: include_virtual }).filter((field) =>
field.isFillable(context),
);
}
getRequiredFields(): Field[] {
@@ -189,9 +191,18 @@ export class Entity<
return this.fields.findIndex((field) => field.name === name) !== -1;
}
getFields(include_virtual: boolean = false): Field[] {
if (include_virtual) return this.fields;
return this.fields.filter((f) => !f.isVirtual());
getFields({
virtual = false,
primary = true,
}: {
virtual?: boolean;
primary?: boolean;
} = {}): Field[] {
return this.fields.filter((f) => {
if (!virtual && f.isVirtual()) return false;
if (!primary && f instanceof PrimaryField) return false;
return true;
});
}
addField(field: Field) {
@@ -231,7 +242,7 @@ export class Entity<
}
}
const fields = this.getFillableFields(context, false);
const fields = this.getFillableFields(context as any, false);
if (options?.ignoreUnknown !== true) {
const field_names = fields.map((f) => f.name);
@@ -275,7 +286,7 @@ export class Entity<
fields = this.getFillableFields(options.context);
break;
default:
fields = this.getFields(true);
fields = this.getFields({ virtual: true });
}
const _fields = Object.fromEntries(fields.map((field) => [field.name, field]));
+3 -3
View File
@@ -45,9 +45,9 @@ export class EntityManager<TBD extends object = DefaultDB> {
emgr?: EventManager<any>,
) {
// add entities & relations
entities.forEach((entity) => this.addEntity(entity));
relations.forEach((relation) => this.addRelation(relation));
indices.forEach((index) => this.addIndex(index));
entities.forEach((entity) => void this.addEntity(entity));
relations.forEach((relation) => void this.addRelation(relation));
indices.forEach((index) => void this.addIndex(index));
if (!Connection.isConnection(connection)) {
throw new UnableToConnectException("");
+4 -2
View File
@@ -83,8 +83,10 @@ export class Mutator<
}
// we should never get here, but just to be sure (why?)
if (!field.isFillable(context)) {
throw new Error(`Field "${key}" is not fillable on entity "${entity.name}"`);
if (!field.isFillable(context as any)) {
throw new Error(
`Field "${key}" of entity "${entity.name}" is not fillable on context "${context}"`,
);
}
// transform from field
+15 -7
View File
@@ -26,11 +26,19 @@ export const baseFieldConfigSchema = s
.strictObject({
label: s.string(),
description: s.string(),
required: s.boolean({ default: false }),
fillable: s.anyOf([
s.boolean({ title: "Boolean" }),
s.array(s.string({ enum: ActionContext }), { title: "Context", uniqueItems: true }),
]),
required: s.boolean({ default: DEFAULT_REQUIRED }),
fillable: s.anyOf(
[
s.boolean({ title: "Boolean" }),
s.array(s.string({ enum: ["create", "update"] }), {
title: "Context",
uniqueItems: true,
}),
],
{
default: DEFAULT_FILLABLE,
},
),
hidden: s.anyOf([
s.boolean({ title: "Boolean" }),
// @todo: tmp workaround
@@ -103,7 +111,7 @@ export abstract class Field<
return this.config?.default_value;
}
isFillable(context?: TActionContext): boolean {
isFillable(context?: "create" | "update"): boolean {
if (Array.isArray(this.config.fillable)) {
return context ? this.config.fillable.includes(context) : DEFAULT_FILLABLE;
}
@@ -165,7 +173,7 @@ export abstract class Field<
// @todo: add field level validation
isValid(value: any, context: TActionContext): boolean {
if (typeof value !== "undefined") {
return this.isFillable(context);
return this.isFillable(context as any);
} else if (context === "create") {
return !this.isRequired();
}
+2 -5
View File
@@ -99,6 +99,7 @@ export function fieldTestSuite(
const _config = {
..._requiredConfig,
required: false,
fillable: true,
};
function fieldJson(field: Field) {
@@ -116,10 +117,7 @@ export function fieldTestSuite(
expect(fieldJson(fillable)).toEqual({
type: noConfigField.type,
config: {
..._config,
fillable: true,
},
config: _config,
});
expect(fieldJson(required)).toEqual({
@@ -150,7 +148,6 @@ export function fieldTestSuite(
type: requiredAndDefault.type,
config: {
..._config,
fillable: true,
required: true,
default_value: config.defaultValue,
},
+2 -1
View File
@@ -24,7 +24,8 @@ export function getChangeSet(
(acc, _value, key) => {
const field = fields.find((f) => f.name === key);
// @todo: filtering virtual here, need to check (because of media)
if (!field || field.isVirtual()) return;
// @note: adding non-fillable here as it'll fail validaiton on backend
if (!field || field.isVirtual() || !field.isFillable()) return;
const value = _value === "" ? null : _value;
// normalize to null if undefined
+2 -1
View File
@@ -8,7 +8,7 @@ import {
RelationHelper,
} from "../relations";
import type { RepoQuery } from "../server/query";
import type { RelationType } from "./relation-types";
import { DEFAULT_RELATION_CASCADE, RelationCascades, type RelationType } from "./relation-types";
const directions = ["source", "target"] as const;
export type TDirection = (typeof directions)[number];
@@ -35,6 +35,7 @@ export abstract class EntityRelation<
mappedBy: s.string().optional(),
inversedBy: s.string().optional(),
required: s.boolean().optional(),
on_delete: s.string({ enum: RelationCascades, default: DEFAULT_RELATION_CASCADE }).optional(),
});
// don't make protected, App requires it to instantiatable
+2 -1
View File
@@ -7,7 +7,7 @@ import { EntityRelation, type KyselyQueryBuilder } from "./EntityRelation";
import { EntityRelationAnchor } from "./EntityRelationAnchor";
import { RelationField, type RelationFieldBaseConfig } from "./RelationField";
import type { MutationInstructionResponse } from "./RelationMutator";
import { type RelationType, RelationTypes } from "./relation-types";
import { type RelationType, RelationCascades, RelationTypes } from "./relation-types";
/**
* Source entity receives the mapping field
@@ -31,6 +31,7 @@ export class ManyToOneRelation extends EntityRelation<typeof ManyToOneRelation.s
fieldConfig: s
.object({
label: s.string(),
on_delete: s.string({ enum: RelationCascades }).optional(),
})
.optional(),
...EntityRelation.schema.properties,
+13 -5
View File
@@ -4,20 +4,22 @@ import type { EntityRelation } from "./EntityRelation";
import type { EntityRelationAnchor } from "./EntityRelationAnchor";
import type { TFieldTSType } from "data/entities/EntityTypescript";
import { s } from "bknd/utils";
const CASCADES = ["cascade", "set null", "set default", "restrict", "no action"] as const;
import { DEFAULT_RELATION_CASCADE, RelationCascades, type RelationCascade } from "./relation-types";
export const relationFieldConfigSchema = s.strictObject({
reference: s.string(),
target: s.string(), // @todo: potentially has to be an instance!
target_field: s.string({ default: "id" }).optional(),
target_field_type: s.string({ enum: ["text", "integer"], default: "integer" }).optional(),
on_delete: s.string({ enum: CASCADES, default: "set null" }).optional(),
on_delete: s.string({ enum: RelationCascades, default: DEFAULT_RELATION_CASCADE }).optional(),
...baseFieldConfigSchema.properties,
});
export type RelationFieldConfig = s.Static<typeof relationFieldConfigSchema>;
export type RelationFieldBaseConfig = { label?: string };
export type RelationFieldBaseConfig = {
label?: string;
on_delete?: RelationCascade;
};
export class RelationField extends Field<RelationFieldConfig> {
override readonly type = "relation";
@@ -39,6 +41,8 @@ export class RelationField extends Field<RelationFieldConfig> {
return new RelationField(name, {
...config,
required: relation.required,
// the relation owns the referential action, the field only materializes it
on_delete: config?.on_delete ?? relation.config.on_delete,
reference: target.reference,
target: target.entity.name,
target_field: target.entity.getPrimaryField().name,
@@ -50,6 +54,10 @@ export class RelationField extends Field<RelationFieldConfig> {
return this.config.reference;
}
onDelete(): RelationCascade | undefined {
return this.config.on_delete;
}
target() {
return this.config.target;
}
@@ -63,7 +71,7 @@ export class RelationField extends Field<RelationFieldConfig> {
...super.schema()!,
type: this.config.target_field_type ?? "integer",
references: `${this.config.target}.${this.config.target_field}`,
onDelete: this.config.on_delete ?? "set null",
onDelete: this.config.on_delete ?? DEFAULT_RELATION_CASCADE,
});
}
+4
View File
@@ -3,6 +3,7 @@ import { ManyToOneRelation, type ManyToOneRelationConfig } from "./ManyToOneRela
import { OneToOneRelation, type OneToOneRelationConfig } from "./OneToOneRelation";
import { PolymorphicRelation, type PolymorphicRelationConfig } from "./PolymorphicRelation";
import { type RelationType, RelationTypes } from "./relation-types";
import { DEFAULT_RELATION_CASCADE, RelationCascades, type RelationCascade } from "./relation-types";
export * from "./EntityRelation";
export * from "./EntityRelationAnchor";
@@ -28,6 +29,9 @@ export {
type PolymorphicRelationConfig,
RelationTypes,
type RelationType,
RelationCascades,
type RelationCascade,
DEFAULT_RELATION_CASCADE,
// field
RelationField,
relationFieldConfigSchema,
+10
View File
@@ -5,3 +5,13 @@ export const RelationTypes = {
Polymorphic: "poly",
} as const;
export type RelationType = (typeof RelationTypes)[keyof typeof RelationTypes];
export const RelationCascades = [
"cascade",
"set null",
"set default",
"restrict",
"no action",
] as const;
export type RelationCascade = (typeof RelationCascades)[number];
export const DEFAULT_RELATION_CASCADE: RelationCascade = "set null";
+1 -1
View File
@@ -77,7 +77,7 @@ export class SchemaManager {
}
getIntrospectionFromEntity(entity: Entity): IntrospectedTable {
const fields = entity.getFields(false);
const fields = entity.getFields({ virtual: false });
const indices = this.em.getIndicesOf(entity);
// this is intentionally setting values to defaults, like "nullable" and "default"
+2 -5
View File
@@ -82,7 +82,7 @@ export * as AuthPermissions from "auth/auth-permissions";
*/
export { getExtensionFromName, getRandomizedFilename } from "media/utils";
import * as StorageEvents from "media/storage/events";
export const MediaEvents: typeof StorageEvents = {
export const MediaEvents = {
...StorageEvents,
};
export * as MediaPermissions from "media/media-permissions";
@@ -104,10 +104,7 @@ export { StorageCloudinaryAdapter } from "media/storage/adapters/cloudinary/Stor
* Data
*/
import { MutatorEvents, RepositoryEvents } from "data/events";
export const DatabaseEvents: typeof MutatorEvents & typeof RepositoryEvents = {
...MutatorEvents,
...RepositoryEvents,
};
export const DatabaseEvents = { ...MutatorEvents, ...RepositoryEvents };
export type {
RepoQuery,
RepoQueryIn,
@@ -1,11 +1,12 @@
import { retry, type TestRunner } from "core/test";
import type { StorageAdapter } from "media/storage/StorageAdapter";
import { randomString } from "bknd/utils";
import type { BunFile } from "bun";
export async function adapterTestSuite(
testRunner: TestRunner,
_adapter: StorageAdapter | (() => StorageAdapter),
file: File | import("bun").BunFile,
file: File | BunFile,
opts?: {
retries?: number;
retryTimeout?: number;
+24
View File
@@ -1,6 +1,7 @@
import { transformObject } from "bknd/utils";
import type { Kysely } from "kysely";
import { set } from "lodash-es";
import type { InitialModuleConfigs } from "modules/ModuleManager";
export type MigrationContext = {
db: Kysely<any>;
@@ -107,6 +108,29 @@ export const migrations: Migration[] = [
return config;
},
},
{
// change field.config.fillable to only "create" and "update"
version: 11,
up: async (config: InitialModuleConfigs) => {
const { data, ...rest } = config;
return {
...rest,
data: {
...data,
entities: transformObject(data?.entities ?? {}, (entity) => {
return {
...entity,
fields: transformObject(entity?.fields ?? {}, (field) => {
const fillable = field!.config?.fillable;
if (!fillable || typeof fillable === "boolean") return field;
return { ...field, config: { ...field!.config, fillable: true } };
}),
};
}),
},
};
},
},
];
export const CURRENT_VERSION = migrations[migrations.length - 1]?.version ?? 0;
-15
View File
@@ -55,21 +55,6 @@ export class AppServer extends Module<AppServerConfig> {
const origin = this.config.cors.origin ?? "*";
const origins = origin.includes(",") ? origin.split(",").map((o) => o.trim()) : [origin];
const all_origins = origins.includes("*");
// Normalize trailing slashes so that /admin/ matches /admin.
// Hono's app.route doesn't match trailing slashes; host frameworks
// like Nuxt/Nitro redirect /admin -> /admin/. When running standalone
// we emit a 308 ourselves so the route layer sees a clean path without
// re-running the middleware chain (which would re-consume c.req.raw).
this.client.use("*", async (c, next) => {
const url = new URL(c.req.url);
if (url.pathname.length > 1 && url.pathname.endsWith("/")) {
url.pathname = url.pathname.replace(/\/+$/, "");
return c.redirect(url.toString(), 308);
}
return next();
});
this.client.use(
"*",
cors({
+858
View File
@@ -0,0 +1,858 @@
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { sort } from "./sort.plugin";
import { em, entity, text, number } from "bknd";
import { createApp } from "core/test/utils";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
describe("sort plugin", () => {
test("should add sort field to configured entities", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
},
},
}),
],
},
});
await app.build();
const taskEntity = app.em.entity("tasks");
expect(taskEntity).toBeDefined();
expect(taskEntity?.fields.map((f) => f.name)).toContain("position");
expect(taskEntity?.field("position")?.type).toBe("number");
});
test("should auto-assign sort values on insert (starting from 0)", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
},
},
}),
],
},
});
await app.build();
const mutator = app.em.mutator("tasks");
// insert first item
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
expect(task1.position).toBe(0);
// insert second item
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
expect(task2.position).toBe(1);
// insert third item
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
expect(task3.position).toBe(2);
});
test("should preserve manually set sort values on insert", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
},
},
}),
],
},
});
await app.build();
const mutator = app.em.mutator("tasks");
// insert with explicit position
const { data: task } = await mutator.insertOne({ title: "Task 1", position: 10 });
expect(task.position).toBe(10);
});
test("should reorder items via API endpoint", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
},
},
}),
],
},
});
await app.build();
const mutator = app.em.mutator("tasks");
// create tasks at positions 0, 1, 2
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
// move task3 (position 2) to position 0
const res = await app.server.request("/api/sort/tasks/reorder", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ id: task3.id, position: 0 }),
});
expect(res.status).toBe(200);
// verify positions
const repo = app.em.repo("tasks");
const { data: updatedTask1 } = await repo.findOne({ id: task1.id });
const { data: updatedTask2 } = await repo.findOne({ id: task2.id });
const { data: updatedTask3 } = await repo.findOne({ id: task3.id });
expect(updatedTask3.position).toBe(0); // moved to position 0
expect(updatedTask1.position).toBe(1); // shifted down
expect(updatedTask2.position).toBe(2); // shifted down
});
test("should automatically reorder when updating sort field directly", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
},
},
}),
],
},
});
await app.build();
const mutator = app.em.mutator("tasks");
// create tasks at positions 0, 1, 2, 3
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
const { data: task4 } = await mutator.insertOne({ title: "Task 4" });
// move task4 (position 3) to position 1 by updating directly
await mutator.updateOne(task4.id, { position: 1 });
// verify positions
const repo = app.em.repo("tasks");
const { data: updatedTask1 } = await repo.findOne({ id: task1.id });
const { data: updatedTask2 } = await repo.findOne({ id: task2.id });
const { data: updatedTask3 } = await repo.findOne({ id: task3.id });
const { data: updatedTask4 } = await repo.findOne({ id: task4.id });
expect(updatedTask1.position).toBe(0); // unchanged
expect(updatedTask2.position).toBe(2); // shifted down
expect(updatedTask3.position).toBe(3); // shifted down
expect(updatedTask4.position).toBe(1); // moved to position 1
});
test("should automatically reorder when moving items up", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
},
},
}),
],
},
});
await app.build();
const mutator = app.em.mutator("tasks");
// create tasks at positions 0, 1, 2, 3
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
const { data: task4 } = await mutator.insertOne({ title: "Task 4" });
// move task1 (position 0) to position 2 by updating directly
await mutator.updateOne(task1.id, { position: 2 });
// verify positions
const repo = app.em.repo("tasks");
const { data: updatedTask1 } = await repo.findOne({ id: task1.id });
const { data: updatedTask2 } = await repo.findOne({ id: task2.id });
const { data: updatedTask3 } = await repo.findOne({ id: task3.id });
const { data: updatedTask4 } = await repo.findOne({ id: task4.id });
expect(updatedTask1.position).toBe(2); // moved to position 2
expect(updatedTask2.position).toBe(0); // shifted up
expect(updatedTask3.position).toBe(1); // shifted up
expect(updatedTask4.position).toBe(3); // unchanged
});
test("should support scoped sorting", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
project_id: number(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
scope: "project_id",
},
},
}),
],
},
});
await app.build();
const mutator = app.em.mutator("tasks");
// create tasks in project 1
const { data: p1t1 } = await mutator.insertOne({
title: "P1 Task 1",
project_id: 1,
});
const { data: p1t2 } = await mutator.insertOne({
title: "P1 Task 2",
project_id: 1,
});
// create tasks in project 2
const { data: p2t1 } = await mutator.insertOne({
title: "P2 Task 1",
project_id: 2,
});
const { data: p2t2 } = await mutator.insertOne({
title: "P2 Task 2",
project_id: 2,
});
// positions should be scoped per project
expect(p1t1.position).toBe(0);
expect(p1t2.position).toBe(1);
expect(p2t1.position).toBe(0); // resets for new scope
expect(p2t2.position).toBe(1);
});
test("should reorder only within scope", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
project_id: number(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
scope: "project_id",
},
},
}),
],
},
});
await app.build();
const mutator = app.em.mutator("tasks");
// create tasks in project 1
const { data: p1t1 } = await mutator.insertOne({
title: "P1 Task 1",
project_id: 1,
});
const { data: p1t2 } = await mutator.insertOne({
title: "P1 Task 2",
project_id: 1,
});
const { data: p1t3 } = await mutator.insertOne({
title: "P1 Task 3",
project_id: 1,
});
// create tasks in project 2
const { data: p2t1 } = await mutator.insertOne({
title: "P2 Task 1",
project_id: 2,
});
const { data: p2t2 } = await mutator.insertOne({
title: "P2 Task 2",
project_id: 2,
});
// move p1t3 to position 0 (should only affect project 1)
await app.server.request("/api/sort/tasks/reorder", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ id: p1t3.id, position: 0 }),
});
// verify project 1 tasks
const repo = app.em.repo("tasks");
const { data: updatedP1t1 } = await repo.findOne({ id: p1t1.id });
const { data: updatedP1t2 } = await repo.findOne({ id: p1t2.id });
const { data: updatedP1t3 } = await repo.findOne({ id: p1t3.id });
expect(updatedP1t3.position).toBe(0); // moved to position 0
expect(updatedP1t1.position).toBe(1); // shifted
expect(updatedP1t2.position).toBe(2); // shifted
// verify project 2 tasks are unchanged
const { data: updatedP2t1 } = await repo.findOne({ id: p2t1.id });
const { data: updatedP2t2 } = await repo.findOne({ id: p2t2.id });
expect(updatedP2t1.position).toBe(0); // unchanged
expect(updatedP2t2.position).toBe(1); // unchanged
});
test("should recalculate all positions", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
},
},
}),
],
},
});
await app.build();
const mutator = app.em.mutator("tasks");
// create tasks with irregular positions
await mutator.insertOne({ title: "Task 1", position: 5 });
await mutator.insertOne({ title: "Task 2", position: 10 });
await mutator.insertOne({ title: "Task 3", position: 15 });
await mutator.insertOne({ title: "Task 4", position: 100 });
// recalculate
const res = await app.server.request("/api/sort/tasks/recalculate", {
method: "POST",
body: JSON.stringify({}),
headers: {
"Content-Type": "application/json",
},
});
expect(res.status).toBe(200);
// verify positions are now 0, 1, 2, 3
const { data: tasks } = await app.em.repo("tasks").findMany({
orderBy: [{ position: "asc" }],
});
expect(tasks.length).toBe(4);
expect(tasks[0].position).toBe(0);
expect(tasks[1].position).toBe(1);
expect(tasks[2].position).toBe(2);
expect(tasks[3].position).toBe(3);
});
test("should recalculate positions within scope only", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
project_id: number(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
scope: "project_id",
},
},
}),
],
},
});
await app.build();
const mutator = app.em.mutator("tasks");
// create tasks in project 1 with irregular positions
await mutator.insertOne({ title: "P1 Task 1", project_id: 1, position: 5 });
await mutator.insertOne({ title: "P1 Task 2", project_id: 1, position: 15 });
// create tasks in project 2 with irregular positions
await mutator.insertOne({ title: "P2 Task 1", project_id: 2, position: 10 });
await mutator.insertOne({ title: "P2 Task 2", project_id: 2, position: 20 });
// recalculate only project 1
const res = await app.server.request("/api/sort/tasks/recalculate", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ scope: 1 }),
});
expect(res.status).toBe(200);
// verify project 1 tasks are recalculated
const { data: p1Tasks } = await app.em.repo("tasks").findMany({
where: { project_id: 1 },
orderBy: [{ position: "asc" }],
});
expect(p1Tasks.length).toBe(2);
expect(p1Tasks[0].position).toBe(0);
expect(p1Tasks[1].position).toBe(1);
// verify project 2 tasks are unchanged
const { data: p2Tasks } = await app.em.repo("tasks").findMany({
where: { project_id: 2 },
orderBy: [{ position: "asc" }],
});
expect(p2Tasks.length).toBe(2);
expect(p2Tasks[0].position).toBe(10); // unchanged
expect(p2Tasks[1].position).toBe(20); // unchanged
});
test("should handle moving items to the end", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
},
},
}),
],
},
});
await app.build();
const mutator = app.em.mutator("tasks");
// create tasks at positions 0, 1, 2, 3
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
const { data: task4 } = await mutator.insertOne({ title: "Task 4" });
// move task1 to the end (position 3)
await app.server.request("/api/sort/tasks/reorder", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ id: task1.id, position: 3 }),
});
// verify positions
const repo = app.em.repo("tasks");
const { data: updatedTask1 } = await repo.findOne({ id: task1.id });
const { data: updatedTask2 } = await repo.findOne({ id: task2.id });
const { data: updatedTask3 } = await repo.findOne({ id: task3.id });
const { data: updatedTask4 } = await repo.findOne({ id: task4.id });
expect(updatedTask1.position).toBe(3); // moved to end
expect(updatedTask2.position).toBe(0); // shifted up
expect(updatedTask3.position).toBe(1); // shifted up
expect(updatedTask4.position).toBe(2); // shifted up
});
test("should return 400 for invalid item id", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
},
},
}),
],
},
});
await app.build();
const res = await app.server.request("/api/sort/tasks/reorder", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ id: 999999, position: 0 }),
});
expect(res.status).toBe(400);
});
test("should handle multiple entities with different configurations", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
}),
categories: entity("categories", {
name: text(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
},
categories: {
field: "order",
},
},
}),
],
},
});
await app.build();
// verify both entities have their sort fields
const taskEntity = app.em.entity("tasks");
const categoryEntity = app.em.entity("categories");
expect(taskEntity?.fields.map((f) => f.name)).toContain("position");
expect(categoryEntity?.fields.map((f) => f.name)).toContain("order");
// create items in both entities
const { data: task } = await app.em.mutator("tasks").insertOne({ title: "Task 1" });
const { data: category } = await app.em.mutator("categories").insertOne({ name: "Cat 1" });
expect(task.position).toBe(0);
expect(category.order).toBe(0);
// verify both endpoints exist
const taskRes = await app.server.request("/api/sort/tasks/recalculate", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
const catRes = await app.server.request("/api/sort/categories/recalculate", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
expect(taskRes.status).toBe(200);
expect(catRes.status).toBe(200);
});
test("should not trigger reorder when updating other fields", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
},
},
}),
],
},
});
await app.build();
const mutator = app.em.mutator("tasks");
// create tasks
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
const { data: task3 } = await mutator.insertOne({ title: "Task 3" });
// update title only (should not trigger reordering)
await mutator.updateOne(task2.id, { title: "Task 2 Updated" });
// verify positions are unchanged
const repo = app.em.repo("tasks");
const { data: updatedTask1 } = await repo.findOne({ id: task1.id });
const { data: updatedTask2 } = await repo.findOne({ id: task2.id });
const { data: updatedTask3 } = await repo.findOne({ id: task3.id });
expect(updatedTask1.position).toBe(0);
expect(updatedTask2.position).toBe(1);
expect(updatedTask2.title).toBe("Task 2 Updated");
expect(updatedTask3.position).toBe(2);
});
test("should handle null sort values", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
},
},
}),
],
},
});
await app.build();
const mutator = app.em.mutator("tasks");
// create task with null position (bypass listener by using kysely directly)
await app.connection.kysely
.insertInto("tasks")
.values({ title: "Task with null", position: null })
.execute();
// create normal task
await mutator.insertOne({ title: "Task 2" });
// update the null task to have a position
const nullTask = await app.connection.kysely
.selectFrom("tasks")
.selectAll()
.where("title", "=", "Task with null")
.executeTakeFirst();
await mutator.updateOne(nullTask!.id, { position: 0 });
// verify both tasks have proper positions
const { data: tasks } = await app.em.repo("tasks").findMany({
sort: { by: "position", dir: "asc" },
});
expect(tasks.length).toBe(2);
expect(tasks[0].position).toBe(0);
expect(tasks[1].position).toBe(1);
});
test("should not create duplicates when moving to an occupied position", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
},
},
}),
],
},
});
await app.build();
const mutator = app.em.mutator("tasks");
// create two tasks at positions 0 and 1
const { data: task1 } = await mutator.insertOne({ title: "Task 1" });
const { data: task2 } = await mutator.insertOne({ title: "Task 2" });
expect(task1.position).toBe(0);
expect(task2.position).toBe(1);
// move task2 (at position 1) to position 0
await mutator.updateOne(task2.id, { position: 0 });
// verify no duplicates
const { data: tasks } = await app.em.repo("tasks").findMany({
sort: { by: "position", dir: "asc" },
});
expect(tasks.length).toBe(2);
expect(tasks[0].id).toBe(task2.id);
expect(tasks[0].position).toBe(0);
expect(tasks[1].id).toBe(task1.id);
expect(tasks[1].position).toBe(1);
// verify no tasks have the same position
const positions = tasks.map((t) => t.position);
const uniquePositions = new Set(positions);
expect(uniquePositions.size).toBe(positions.length);
});
test("should preserve order when recalculating", async () => {
const app = createApp({
config: {
data: em({
tasks: entity("tasks", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
},
},
}),
],
},
});
await app.build();
const mutator = app.em.mutator("tasks");
// create tasks with specific positions
const { data: taskA } = await mutator.insertOne({ title: "Task A", position: 5 });
const { data: taskB } = await mutator.insertOne({ title: "Task B", position: 3 });
const { data: taskC } = await mutator.insertOne({ title: "Task C", position: 10 });
// recalculate
await app.server.request("/api/sort/tasks/recalculate", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
// verify order is preserved (B, A, C based on original positions)
const { data: tasks } = await app.em.repo("tasks").findMany({
sort: { by: "position", dir: "asc" },
});
expect(tasks[0].id).toBe(taskB.id); // was at 3, now at 0
expect(tasks[0].position).toBe(0);
expect(tasks[1].id).toBe(taskA.id); // was at 5, now at 1
expect(tasks[1].position).toBe(1);
expect(tasks[2].id).toBe(taskC.id); // was at 10, now at 2
expect(tasks[2].position).toBe(2);
});
});
+423
View File
@@ -0,0 +1,423 @@
import { Exception, type App, type AppPlugin, DatabaseEvents, em, entity, number } from "bknd";
import { invariant, HttpStatus, jsc, s, $console } from "bknd/utils";
import { Hono } from "hono";
import { sql } from "kysely";
const DEFAULT_BATCH_SIZE = 1000;
export type SortPluginOptions = {
/**
* The base path for the API endpoints.
* @default "/api/sort"
*/
apiBasePath?: string;
/**
* Configuration for entities that should have sorting enabled.
* Key is the entity name, value is the configuration.
*/
entities: Record<
string,
{
/**
* The name of the sort property (must be a number field).
*/
field: string;
/**
* Optional scope field name. If provided, sorting will only happen within the same scope.
* For example, if scope is "category_id", items will only be sorted within items
* that have the same category_id value.
*/
scope?: string;
}
>;
/**
* The batch size for recalculating sort order.
* @default 1000
*/
recalculateBatchSize?: number;
};
class SortError extends Exception {
override name = "SortError";
override code = HttpStatus.BAD_REQUEST;
}
export function sort({
apiBasePath = "/api/sort",
entities,
recalculateBatchSize = DEFAULT_BATCH_SIZE,
}: SortPluginOptions): AppPlugin {
return (app: App) => {
return {
name: "sort",
schema: () => {
return em(
Object.fromEntries(
Object.entries(entities).map(([entityName, config]) => [
entityName,
entity(entityName, {
[config.field]: number({ default_value: 0 }),
}),
]),
),
({ index }, schema) => {
for (const [entityName, config] of Object.entries(entities)) {
const indexed = app.em.getIndexedFields(entityName);
if (!indexed.some((f) => f.name === config.field)) {
index(schema[entityName]!).on([config.field]);
}
}
},
);
},
onBuilt: async () => {
invariant(
entities && Object.keys(entities).length > 0,
"At least one entity must be configured",
);
// validate entities exist and have the configured fields
for (const [entityName, config] of Object.entries(entities)) {
const entity = app.em.entity(entityName);
invariant(entity, `Entity "${entityName}" not found in schema`);
const sortFieldSchema = entity.field(config.field)!;
invariant(
sortFieldSchema,
`Sort field "${config.field}" not found in entity "${entityName}"`,
);
invariant(
sortFieldSchema.type === "number",
`Sort field "${config.field}" in entity "${entityName}" must be a number field`,
);
if (config.scope) {
const scopeFieldSchema = entity.field(config.scope);
invariant(
scopeFieldSchema,
`Scope field "${config.scope}" not found in entity "${entityName}"`,
);
}
}
const hono = new Hono();
// register recalculate endpoints for each entity
for (const [entityName, config] of Object.entries(entities)) {
hono.post(
`/${entityName}/recalculate`,
jsc(
"json",
s
.object({
scope: s.any().optional(),
})
.optional(),
),
async (c) => {
const body = c.req.valid("json");
const scope = body?.scope;
await recalculateSortOrder(
app,
entityName,
config,
recalculateBatchSize,
scope,
);
return c.json({ success: true, message: "Sort order recalculated" });
},
);
hono.post(
`/${entityName}/reorder`,
jsc(
"json",
s.object({
id: s.any(),
position: s.number(),
}),
),
async (c) => {
const { id, position } = c.req.valid("json");
await reorderItem(app, entityName, config, id, position);
return c.json({ success: true, message: "Item reordered" });
},
);
}
app.server.route(apiBasePath, hono);
// register listeners for automatic reordering
registerListeners(app, entities);
},
};
};
}
async function reorderItem(
app: App,
entityName: string,
config: SortPluginOptions["entities"][string],
id: any,
newPosition: number,
) {
const { field: sortField, scope: scopeField } = config;
const em = app.em.fork();
const kysely = em.connection.kysely;
// get the item
const { data: item } = await em.repo(entityName).findOne({ id });
if (!item) {
throw new SortError(`Item with id "${id}" not found in entity "${entityName}"`);
}
const oldPosition = item[sortField] as number | null | undefined;
const scopeValue = scopeField ? item[scopeField] : undefined;
// update the item with new position (using forked em, so no listeners are triggered)
await em.mutator(entityName).updateOne(id, { [sortField]: newPosition });
// shift other items using kysely
if (oldPosition !== undefined && oldPosition !== null && oldPosition !== newPosition) {
if (newPosition < oldPosition) {
// moving up: increment items between newPosition and oldPosition
let query = kysely
.updateTable(entityName)
.set({ [sortField]: sql`${sql.ref(sortField)} + 1` } as any)
.where(sortField as any, ">=", newPosition)
.where(sortField as any, "<", oldPosition)
.where("id", "!=", id);
if (scopeField && scopeValue !== undefined) {
query = query.where(scopeField as any, "=", scopeValue);
}
await query.execute();
} else {
// moving down: decrement items between oldPosition and newPosition
let query = kysely
.updateTable(entityName)
.set({ [sortField]: sql`${sql.ref(sortField)} - 1` } as any)
.where(sortField as any, ">", oldPosition)
.where(sortField as any, "<=", newPosition)
.where("id", "!=", id);
if (scopeField && scopeValue !== undefined) {
query = query.where(scopeField as any, "=", scopeValue);
}
await query.execute();
}
} else if (oldPosition === undefined || oldPosition === null) {
// new item, shift everything at or after this position
let query = kysely
.updateTable(entityName)
.set({ [sortField]: sql`${sql.ref(sortField)} + 1` } as any)
.where(sortField as any, ">=", newPosition)
.where("id", "!=", id);
if (scopeField && scopeValue !== undefined) {
query = query.where(scopeField as any, "=", scopeValue);
}
await query.execute();
}
}
async function recalculateSortOrder(
app: App,
entityName: string,
config: SortPluginOptions["entities"][string],
batchSize: number,
scope?: any,
) {
const { field: sortField, scope: scopeField } = config;
const db = app.connection.kysely;
const { count } = (await db
.selectFrom(entityName)
.select((eb) => eb.fn.count<number>("id").as("count"))
.$if(Boolean(scopeField && scope !== undefined), (eb) =>
eb.where(scopeField as any, "=", scope),
)
.$castTo<{ count: number }>()
.executeTakeFirst()) ?? { count: 0 };
const batches = Math.ceil(count / batchSize);
for (let i = 0; i < batches; i++) {
// get all items in scope, ordered by current sort value
const items = await db
.selectFrom(entityName)
.select(["id", sortField])
.$if(Boolean(scopeField && scope !== undefined), (eb) =>
eb.where(scopeField as any, "=", scope),
)
.orderBy(sortField, "asc")
.limit(batchSize)
.offset(i * batchSize)
.execute();
const newQbs = items.map((item, index) =>
db
.updateTable(entityName)
.set({ [sortField]: index })
.where("id", "=", item.id),
);
await app.connection.executeQueries(...newQbs);
$console.log(
`[Sort Plugin] Recalculated sort order for ${items.length} items in entity "${entityName}"${scopeField && scope !== undefined ? ` (scope: ${scope})` : ""} [batch ${i + 1}/${batches}]`,
);
}
}
function registerListeners(app: App, entities: SortPluginOptions["entities"]) {
const kysely = app.connection.kysely;
// handle insert events
app.emgr.onEvent(
DatabaseEvents.MutatorInsertBefore,
async (e) => {
const entityName = e.params.entity.name;
const config = entities[entityName];
if (!config) return e.params.data;
const { field: sortField, scope: scopeField } = config;
const data = e.params.data;
const scopeValue = scopeField ? data[scopeField] : undefined;
// if no position provided, set to max + 1
if (data[sortField] === undefined || data[sortField] === null) {
const query = kysely
.selectFrom(entityName)
.select((eb) => [eb.fn.max<number>(eb.ref(sortField)).as("max")])
// add scope filter if needed
.$if(Boolean(scopeField && scopeValue), (eb) =>
eb.where(scopeField as any, "=", scopeValue as any),
);
const result = await query.executeTakeFirst();
const max = result?.max ?? -1;
return {
...data,
[sortField]: max + 1,
};
}
// if position is provided, shift other items at or after that position
const newPosition = data[sortField] as number;
let shiftQuery = kysely
.updateTable(entityName)
.set({ [sortField]: sql`${sql.ref(sortField)} + 1` } as any)
.where(sortField as any, ">=", newPosition);
if (scopeField && scopeValue !== undefined) {
shiftQuery = shiftQuery.where(scopeField as any, "=", scopeValue);
}
await shiftQuery.execute();
return data;
},
{
mode: "sync",
id: "bknd-sort-insert",
},
);
// handle update events
app.emgr.onEvent(
DatabaseEvents.MutatorUpdateBefore,
async (e) => {
const entityName = e.params.entity.name;
const config = entities[entityName];
if (!config) return e.params.data;
const { field: sortField, scope: scopeField } = config;
const data = e.params.data;
// only handle if sort field is being updated
if (!(sortField in data)) return e.params.data;
const newPosition = data[sortField] as number;
const id = e.params.entityId;
// get the current item to know its old position and scope
const item = await kysely
.selectFrom(entityName)
.selectAll()
.where("id" as any, "=", id)
.executeTakeFirst();
if (!item) return data;
const oldPosition = item[sortField] as number | null | undefined;
const scopeValue = scopeField ? item[scopeField] : undefined;
// if oldPosition is null or undefined, treat as inserting at newPosition
if (oldPosition === null || oldPosition === undefined) {
// shift items at or after the new position
let query = kysely
.updateTable(entityName)
.set({ [sortField]: sql`${sql.ref(sortField)} + 1` } as any)
.where(sortField as any, ">=", newPosition)
.where("id" as any, "!=", id);
if (scopeField && scopeValue !== undefined) {
query = query.where(scopeField as any, "=", scopeValue);
}
await query.execute();
return data;
}
// shift other items using kysely
if (oldPosition !== newPosition) {
if (newPosition < oldPosition) {
// moving up: increment items between newPosition and oldPosition
let query = kysely
.updateTable(entityName)
.set({ [sortField]: sql`${sql.ref(sortField)} + 1` } as any)
.where(sortField as any, ">=", newPosition)
.where(sortField as any, "<", oldPosition)
.where("id" as any, "!=", id);
if (scopeField && scopeValue !== undefined) {
query = query.where(scopeField as any, "=", scopeValue);
}
await query.execute();
} else {
// moving down: decrement items between oldPosition and newPosition
let query = kysely
.updateTable(entityName)
.set({ [sortField]: sql`${sql.ref(sortField)} - 1` } as any)
.where(sortField as any, ">", oldPosition)
.where(sortField as any, "<=", newPosition)
.where("id" as any, "!=", id);
if (scopeField && scopeValue !== undefined) {
query = query.where(scopeField as any, "=", scopeValue);
}
await query.execute();
}
}
return data;
},
{
mode: "sync",
id: "bknd-sort-update",
},
);
}
+1
View File
@@ -9,3 +9,4 @@ export { syncTypes, type SyncTypesOptions } from "./dev/sync-types.plugin";
export { syncSecrets, type SyncSecretsOptions } from "./dev/sync-secrets.plugin";
export { timestamps, type TimestampsPluginOptions } from "./data/timestamps.plugin";
export { emailOTP, type EmailOTPPluginOptions } from "./auth/email-otp.plugin";
export { sort, type SortPluginOptions } from "./data/sort.plugin";
+6 -1
View File
@@ -1,6 +1,6 @@
import { MantineProvider } from "@mantine/core";
import { Notifications } from "@mantine/notifications";
import { type ReactNode } from "react";
import React, { type ReactNode } from "react";
import { BkndProvider } from "ui/client/bknd";
import { useTheme, type AppTheme } from "ui/client/use-theme";
import { Logo } from "ui/components/display/Logo";
@@ -16,6 +16,11 @@ export type BkndAdminConfig = {
* @default `/`
*/
basepath?: string;
/**
* Sub-path for the Admin UI within the base path
* @default ``
*/
admin_basepath?: string;
/**
* Path to return to when clicking the logo
* @default `/`
+1 -1
View File
@@ -4,7 +4,7 @@ export {
type ClientProviderProps,
useApi,
useBaseUrl,
useClientContext
useClientContext,
} from "./ClientProvider";
export * from "./api/use-api";
-1
View File
@@ -22,7 +22,6 @@ export class AppReduced {
protected appJson: AppType,
protected _options: BkndAdminProps["config"] = {},
) {
this._entities = Object.entries(this.appJson.data.entities ?? {}).map(([name, entity]) => {
return constructEntity(name, entity);
});
+5 -1
View File
@@ -4,7 +4,11 @@ export function Logo({
scale = 0.2,
fill,
...props
}: { scale?: number; fill?: string; theme?: string }) {
}: {
scale?: number;
fill?: string;
theme?: string;
}) {
const t = useTheme();
const theme = props.theme ?? t.theme;
const svgFill = fill ? fill : theme === "light" ? "black" : "white";
@@ -30,7 +30,7 @@ export const Group = <E extends ElementType = "div">({
{...props}
data-role="group"
className={twMerge(
"flex flex-col gap-1.5 w-full",
"flex flex-col gap-1.5 has-disabled:cursor-not-allowed w-full",
as === "fieldset" && "border border-primary/10 p-3 rounded-md",
as === "fieldset" && error && "border-red-500",
error && "text-red-500",
@@ -97,7 +97,7 @@ export const Input = forwardRef<HTMLInputElement, React.ComponentProps<"input">>
ref={ref}
className={twMerge(
"bg-muted/40 h-11 rounded-md py-2.5 px-4 outline-none w-full disabled:cursor-not-allowed",
disabledOrReadonly && "bg-muted/50 text-primary/50",
disabledOrReadonly && "bg-muted/50 text-primary/50 cursor-not-allowed",
!disabledOrReadonly &&
"focus:bg-muted focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all",
props.className,
@@ -154,7 +154,7 @@ export const Textarea = forwardRef<HTMLTextAreaElement, React.ComponentProps<"te
{...props}
ref={ref}
className={twMerge(
"bg-muted/40 min-h-11 rounded-md py-2.5 px-4 focus:bg-muted outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50",
"bg-muted/40 min-h-11 rounded-md py-2.5 px-4 focus:bg-muted outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50 disabled:cursor-not-allowed",
props.className,
)}
/>
@@ -214,7 +214,7 @@ export const BooleanInput = forwardRef<HTMLInputElement, React.ComponentProps<"i
{...props}
type="checkbox"
ref={ref}
className="outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 transition-all disabled:opacity-70 scale-150 ml-1"
className="outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 transition-all disabled:opacity-70 disabled:cursor-not-allowed scale-150 ml-1"
checked={checked}
onChange={handleCheck}
disabled={props.disabled}
@@ -260,7 +260,6 @@ export const Switch = forwardRef<
props.disabled && "opacity-50 !cursor-not-allowed",
)}
onCheckedChange={(bool) => {
console.log("setting", bool);
props.onChange?.({ target: { value: bool } });
}}
{...(props as any)}
@@ -294,7 +293,7 @@ export const Select = forwardRef<
{...props}
ref={ref}
className={twMerge(
"bg-muted/40 focus:bg-muted rounded-md py-2.5 px-4 outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50",
"bg-muted/40 focus:bg-muted rounded-md py-2.5 px-4 outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50 disabled:cursor-not-allowed",
"appearance-none h-11 w-full",
!props.multiple && "border-r-8 border-r-transparent",
props.className,
@@ -117,7 +117,11 @@ const ArrayAdd = ({
schema,
path: _path,
label = "Add",
}: { schema: JsonSchema; path: string; label?: string }) => {
}: {
schema: JsonSchema;
path: string;
label?: string;
}) => {
const {
setValue,
value: { currentIndex },
+6 -1
View File
@@ -64,6 +64,11 @@ export function Step({
path = [],
id,
...rest
}: { children: React.ReactNode; disabled?: boolean; id: string; path?: string[] }) {
}: {
children: React.ReactNode;
disabled?: boolean;
id: string;
path?: string[];
}) {
return <div {...rest}>{children}</div>;
}
+10 -5
View File
@@ -1,5 +1,6 @@
import { type LinkProps, Link as WouterLink, useRouter } from "wouter";
import { useEvent } from "../../hooks/use-event";
import { useBkndOptions } from "ui/client/BkndProvider";
/*
* Transforms `path` into its relative `base` version
@@ -70,11 +71,13 @@ export function Link({
return false;
}
const bkndOptions = useBkndOptions();
const adminBasePath = bkndOptions?.admin_basepath ?? "";
const _href = props.href ?? props.to;
const href = router
.hrefs(
_href[0] === "~" ? _href.slice(1) : router.base + _href,
_href[0] === "~" ? adminBasePath + _href.slice(1) : router.base + _href,
router, // pass router as a second argument for convinience
)
.replace("//", "/");
@@ -82,8 +85,9 @@ export function Link({
const active =
href.replace(router.base, "").length <= 1 ? href === absPath : isActive(absPath, href);
// console.log({ adminBasePath, _href, href, bkndOptions })
if (native) {
return <a className={`${active ? "active " : ""}${className}`} {...props} />;
return <a className={`${active ? "active " : ""}${className}`} {...props} href={href} />;
}
const wouterOnClick = (e: any) => {
@@ -99,10 +103,11 @@ export function Link({
};
return (
// @ts-expect-error className is not typed on WouterLink
<WouterLink
// @ts-expect-error className is not typed on WouterLink
className={`${active ? "active " : ""}${className}`}
{...props}
className={`${active ? "active " : ""}${className}`}
href={`~${href}`}
onClick={wouterOnClick}
/>
);
+6 -1
View File
@@ -35,7 +35,12 @@ export function checkMaxReached({
current = 0,
overwrite,
added,
}: { maxItems?: number; current?: number; overwrite?: boolean; added: number }) {
}: {
maxItems?: number;
current?: number;
overwrite?: boolean;
added: number;
}) {
if (!maxItems) {
return {
reject: false,
+6 -1
View File
@@ -101,7 +101,12 @@ export function useNavigate() {
}
}
const _url = options?.absolute ? `~/${basepath}${url}`.replace(/\/+/g, "/") : url;
const _url = options?.absolute
? `~/${app.options.basepath}/${app.options.admin_basepath}${url}`.replace(
/\/+/g,
"/",
)
: url;
const state = {
...options?.state,
referrer: location,
+3 -4
View File
@@ -102,10 +102,9 @@ body,
/* Chrome, Edge, and Safari */
& *::-webkit-scrollbar {
@apply w-1;
}
& *::-webkit-scrollbar:horizontal {
@apply h-px;
&:horizontal {
@apply h-px;
}
}
& *::-webkit-scrollbar-track {
@@ -45,7 +45,7 @@ export function EntityForm({
className,
action,
}: EntityFormProps) {
const fields = entity.getFillableFields(action, true);
const fields = entity.getFields({ virtual: true, primary: false });
const options = useEntityAdminOptions(entity, action);
return (
@@ -93,10 +93,6 @@ export function EntityForm({
);
}
if (!field.isFillable(action)) {
return;
}
const _key = `${entity.name}-${field.name}-${key}`;
return (
@@ -128,7 +124,7 @@ export function EntityForm({
<EntityFormField
field={field}
fieldApi={props}
disabled={fieldsDisabled}
disabled={fieldsDisabled || !field.isFillable(action)}
tabIndex={key + 1}
action={action}
data={data}
@@ -287,7 +283,10 @@ function EntityJsonFormField({
fieldApi,
field,
...props
}: { fieldApi: TFieldApi; field: JsonField }) {
}: {
fieldApi: TFieldApi;
field: JsonField;
}) {
const [error, setError] = useState<any>(null);
const handleUpdate = useEvent((value: any) => {
setError(null);
@@ -316,7 +315,10 @@ function EntityEnumFormField({
fieldApi,
field,
...props
}: { fieldApi: TFieldApi; field: EnumField }) {
}: {
fieldApi: TFieldApi;
field: EnumField;
}) {
const handleUpdate = useEvent((e: React.ChangeEvent<HTMLTextAreaElement>) => {
fieldApi.handleChange(e.target.value);
});
@@ -1,6 +1,12 @@
import { Switch, TextInput } from "@mantine/core";
import { IconDatabase } from "@tabler/icons-react";
import { ManyToOneRelation, type RelationType, RelationTypes } from "data/relations";
import {
DEFAULT_RELATION_CASCADE,
ManyToOneRelation,
type RelationType,
RelationCascades,
RelationTypes,
} from "data/relations";
import type { ReactNode } from "react";
import { type Control, type FieldValues, type UseFormRegister, useForm } from "react-hook-form";
import { TbRefresh } from "react-icons/tb";
@@ -46,7 +52,9 @@ const schema = s.strictObject({
type: s.string({ enum: Relations.map((r) => r.type) }),
source: stringIdentifier,
target: stringIdentifier,
config: s.object({}),
config: s.object({
on_delete: s.string({ enum: RelationCascades }).optional(),
}),
});
type ComponentCtx<T extends FieldValues = FieldValues> = {
@@ -186,6 +194,25 @@ const Callout = ({ children }: { children: ReactNode }) => (
<div className="bg-primary/5 py-4 px-5 rounded-lg mt-10">{children}</div>
);
function OnDelete({
control,
label = "On target delete",
}: {
control: Control<any>;
label?: string;
}) {
return (
<MantineSelect
control={control}
name="config.on_delete"
label={label}
allowDeselect={false}
defaultValue={DEFAULT_RELATION_CASCADE}
data={RelationCascades.map((cascade) => ({ value: cascade, label: cascade }))}
/>
);
}
function ManyToOne({ register, control, data: { source, target, config } }: ComponentCtx) {
return (
<>
@@ -209,6 +236,7 @@ function ManyToOne({ register, control, data: { source, target, config } }: Comp
control={control}
placeholder={String(ManyToOneRelation.DEFAULTS.with_limit)}
/>
<OnDelete control={control} />
</div>
<div />
<div className="flex flex-col gap-4">
@@ -222,30 +250,32 @@ function ManyToOne({ register, control, data: { source, target, config } }: Comp
</div>
{source && target && config && (
<Callout>
<>
<pre className="mb-2 opacity-70 flex flex-row items-center gap-2">
<IconDatabase className="size-4" />
{source}.{config.mappedBy || target}_id {"→"} {target}
</pre>
<pre className="mb-2 opacity-70 flex flex-row items-center gap-2">
<IconDatabase className="size-4" />
{source}.{config.mappedBy || target}_id {"→"} {target}
</pre>
<p>
Many <Pre>{source}</Pre> will each have one reference to <Pre>{target}</Pre>.
</p>
<p>
A property <Pre>{config.mappedBy || target}_id</Pre> will be added to{" "}
<Pre>{source}</Pre> (which references <Pre>{target}</Pre>).
</p>
<p>
When creating <Pre>{source}</Pre>, a reference to <Pre>{target}</Pre> is{" "}
<i>{config.required ? "required" : "optional"}</i>.
</p>
<p>
If a <Pre>{target}</Pre> entry is deleted, the reference is{" "}
<i>{config.on_delete ?? DEFAULT_RELATION_CASCADE}</i>.
</p>
{config.sourceCardinality ? (
<p>
Many <Pre>{source}</Pre> will each have one reference to <Pre>{target}</Pre>.
<Pre>{target}</Pre> should not have more than{" "}
<Pre>{config.sourceCardinality}</Pre> referencing entr
{config.sourceCardinality === 1 ? "y" : "ies"} to <Pre>{source}</Pre>.
</p>
<p>
A property <Pre>{config.mappedBy || target}_id</Pre> will be added to{" "}
<Pre>{source}</Pre> (which references <Pre>{target}</Pre>).
</p>
<p>
When creating <Pre>{source}</Pre>, a reference to <Pre>{target}</Pre> is{" "}
<i>{config.required ? "required" : "optional"}</i>.
</p>
{config.sourceCardinality ? (
<p>
<Pre>{target}</Pre> should not have more than{" "}
<Pre>{config.sourceCardinality}</Pre> referencing entr
{config.sourceCardinality === 1 ? "y" : "ies"} to <Pre>{source}</Pre>.
</p>
) : null}
</>
) : null}
</Callout>
)}
</>
@@ -258,7 +288,7 @@ function OneToOne({
data: {
source,
target,
config: { mappedBy, required },
config: { mappedBy, required, on_delete },
},
}: ComponentCtx) {
return (
@@ -271,6 +301,7 @@ function OneToOne({
placeholder={target}
/>
<Switch label="Required" {...register("config.required")} />
<OnDelete control={control} />
</div>
<div />
<div className="flex flex-col gap-4">
@@ -283,24 +314,26 @@ function OneToOne({
</div>
{source && target && (
<Callout>
<>
<pre className="mb-2 opacity-70 flex flex-row items-center gap-2">
<IconDatabase className="size-4" />
{source}.{mappedBy || target}_id {"↔"} {target}
</pre>
<p>
A single entry of <Pre>{source}</Pre> will have a reference to{" "}
<Pre>{target}</Pre>.
</p>
<p>
A property <Pre>{mappedBy || target}_id</Pre> will be added to{" "}
<Pre>{source}</Pre> (which references <Pre>{target}</Pre>).
</p>
<p>
When creating <Pre>{source}</Pre>, a reference to <Pre>{target}</Pre> is{" "}
<i>{required ? "required" : "optional"}</i>.
</p>
</>
<pre className="mb-2 opacity-70 flex flex-row items-center gap-2">
<IconDatabase className="size-4" />
{source}.{mappedBy || target}_id {"↔"} {target}
</pre>
<p>
A single entry of <Pre>{source}</Pre> will have a reference to <Pre>{target}</Pre>
.
</p>
<p>
A property <Pre>{mappedBy || target}_id</Pre> will be added to <Pre>{source}</Pre>{" "}
(which references <Pre>{target}</Pre>).
</p>
<p>
When creating <Pre>{source}</Pre>, a reference to <Pre>{target}</Pre> is{" "}
<i>{required ? "required" : "optional"}</i>.
</p>
<p>
If a <Pre>{target}</Pre> entry is deleted, the reference is{" "}
<i>{on_delete ?? DEFAULT_RELATION_CASCADE}</i>.
</p>
</Callout>
)}
</>
@@ -334,6 +367,7 @@ function ManyToMany({ register, control, data: { source, target, config } }: Com
{...register("config.connectionTableMappedName")}
placeholder={table}
/>
<OnDelete control={control} label="On delete (both references)" />
</div>
<div className="flex flex-col gap-4">
{/*<TextInput
@@ -345,18 +379,20 @@ function ManyToMany({ register, control, data: { source, target, config } }: Com
</div>
{source && target && (
<Callout>
<>
<pre className="mb-2 opacity-70 flex flex-row items-center gap-2">
<IconDatabase className="size-4" />
{source} {"→"} {table} {"←"} {target}
</pre>
<p>
Many <Pre>{source}</Pre> will have many <Pre>{target}</Pre>.
</p>
<p>
A connection table <Pre>{table}</Pre> will be created to store the relations.
</p>
</>
<pre className="mb-2 opacity-70 flex flex-row items-center gap-2">
<IconDatabase className="size-4" />
{source} {"→"} {table} {"←"} {target}
</pre>
<p>
Many <Pre>{source}</Pre> will have many <Pre>{target}</Pre>.
</p>
<p>
A connection table <Pre>{table}</Pre> will be created to store the relations.
</p>
<p>
If an entry of <Pre>{source}</Pre> or <Pre>{target}</Pre> is deleted, the
connection is <i>{config.on_delete ?? DEFAULT_RELATION_CASCADE}</i>.
</p>
</Callout>
)}
</>
@@ -391,26 +427,24 @@ function Polymorphic({ register, control, data: { type, source, target, config }
</div>
{source && target && (
<Callout>
<>
<pre className="mb-2 opacity-70 flex flex-row items-center gap-2">
<IconDatabase className="size-4" />
{source} {"←"} {target}
</pre>
<pre className="mb-2 opacity-70 flex flex-row items-center gap-2">
<IconDatabase className="size-4" />
{source} {"←"} {target}
</pre>
<p>
<Pre>{source}</Pre> will have many <Pre>{target}</Pre>.
</p>
<p>
<Pre>{target}</Pre> will get additional properties <Pre>reference</Pre> and{" "}
<Pre>entity_id</Pre> to make the (polymorphic) reference.
</p>
{config.targetCardinality ? (
<p>
<Pre>{source}</Pre> will have many <Pre>{target}</Pre>.
<Pre>{source}</Pre> should not have more than{" "}
<Pre>{config.targetCardinality}</Pre> reference
{config.targetCardinality === 1 ? "" : "s"} to <Pre>{target}</Pre>.
</p>
<p>
<Pre>{target}</Pre> will get additional properties <Pre>reference</Pre> and{" "}
<Pre>entity_id</Pre> to make the (polymorphic) reference.
</p>
{config.targetCardinality ? (
<p>
<Pre>{source}</Pre> should not have more than{" "}
<Pre>{config.targetCardinality}</Pre> reference
{config.targetCardinality === 1 ? "" : "s"} to <Pre>{target}</Pre>.
</p>
) : null}
</>
) : null}
</Callout>
)}
</>
@@ -17,7 +17,7 @@ export function useEntityForm({
}: EntityFormProps) {
const data = initialData ?? {};
// @todo: check if virtual must be filtered
const fields = entity.getFillableFields(action, true);
const fields = entity.getFields({ virtual: true, primary: false });
// filter defaultValues to only contain fillable fields
const defaultValues = getDefaultValues(fields, data);
+11 -2
View File
@@ -110,7 +110,12 @@ const EntityLinkList = ({
title,
context,
suggestCreate = false,
}: { entities: Entity[]; title?: string; context: "data" | "schema"; suggestCreate?: boolean }) => {
}: {
entities: Entity[];
title?: string;
context: "data" | "schema";
suggestCreate?: boolean;
}) => {
const { $data } = useBkndData();
const { readonly } = useBknd();
const navigate = useRouteNavigate();
@@ -195,7 +200,11 @@ const EntityContextMenu = ({
entity,
children,
enabled = true,
}: { entity: Entity; children: DropdownClickableChild; enabled?: boolean }) => {
}: {
entity: Entity;
children: DropdownClickableChild;
enabled?: boolean;
}) => {
if (!enabled) return children;
const [navigate] = useNavigate();
const { $data } = useBkndData();
@@ -66,7 +66,10 @@ const relationAnyOfValues = {
export const DataSettings = ({
schema,
config,
}: { schema: ModuleSchemas["data"]; config: ModuleConfigs["data"] }) => {
}: {
schema: ModuleSchemas["data"];
config: ModuleConfigs["data"];
}) => {
const { app, readonly } = useBknd();
const prefix = app.getAbsolutePath("settings");
const entities = Object.keys(config.entities ?? {});
@@ -69,11 +69,7 @@ function CustomUserAvatarDropzone() {
showPlaceholder,
actions: { openFileInput },
} = Media.useDropzone();
const {
isOver,
isOverAccepted,
files: [file] = [],
} = Media.useDropzoneState();
const { isOver, isOverAccepted, files: [file] = [] } = Media.useDropzoneState();
return (
<div
+3 -1
View File
@@ -98,7 +98,9 @@ input[disabled]::placeholder {
}
.flex-animate {
transition: flex-grow 0.2s ease, background-color 0.2s ease;
transition:
flex-grow 0.2s ease,
background-color 0.2s ease;
}
.flex-initial {
flex: 0 1 auto;
+1 -1
View File
@@ -1 +1 @@
declare module '*.css';
declare module "*.css";
+1 -3
View File
@@ -3,10 +3,8 @@
"compilerOptions": {
"outDir": "./dist/types",
"rootDir": "./src",
"declaration": true,
"incremental": true,
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src/**/*.ts", "./src/**/*.tsx"],
"exclude": ["node_modules", "__test__", "e2e"]
"exclude": ["./node_modules", "./__test__", "./e2e"]
}
+3 -4
View File
@@ -10,8 +10,7 @@
"noImplicitAny": false,
"allowJs": true,
"verbatimModuleSyntax": true,
"declaration": false,
"declarationMap": false,
"declaration": true,
"strict": true,
"allowUnusedLabels": false,
"allowUnreachableCode": false,
@@ -27,7 +26,7 @@
"esModuleInterop": true,
"skipLibCheck": true,
"rootDir": ".",
"outDir": "./dist",
"outDir": "./dist/types",
"paths": {
"*": ["./src/*"],
"bknd": ["./src/index.ts"],
@@ -48,5 +47,5 @@
"__test__",
"e2e/**/*.ts"
],
"exclude": ["node_modules", "dist", "**/*.d.ts"]
"exclude": ["node_modules", "dist", "dist/types", "**/*.d.ts"]
}
+8 -10
View File
@@ -1,17 +1,13 @@
import devServer from "@hono/vite-dev-server";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
import { devServerConfig } from "./src/adapter/vite/dev-server-config";
import tailwindcss from '@tailwindcss/postcss'
import tailwindcss from "@tailwindcss/vite";
import pkg from "./package.json" with { type: "json" };
// https://vitejs.dev/config/
export default defineConfig({
css: {
postcss: {
plugins: [tailwindcss()],
},
},
define: {
__isDev: process.env.NODE_ENV === "production" ? "0" : "1",
__version: JSON.stringify(pkg.version),
@@ -25,21 +21,23 @@ export default defineConfig({
overlay: true,
},
},
resolve: {
tsconfigPaths: true
},
plugins: [
react(),
tsconfigPaths({
// otherwise it'll throw an error because of examples/astro
ignoreConfigErrors: true,
}),
devServer({
...devServerConfig,
entry: "./vite.dev.ts",
//entry: "./vite.dev.code.ts",
}),
tailwindcss(),
],
build: {
manifest: true,
outDir: "./dist/static",
rolldownOptions: {
rollupOptions: {
input: "./src/ui/main.tsx",
},
},
+7 -3
View File
@@ -1,9 +1,13 @@
import { defineConfig } from "vitest/config";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
resolve: {
tsconfigPaths: true,
},
plugins: [
tsconfigPaths({
root: ".",
ignoreConfigErrors: true,
}) as any,
],
test: {
include: ["**/*.vi-test.ts", "**/*.vitest.ts"],
},
+1 -1
View File
@@ -35,7 +35,7 @@
"files": {
"includes": [
"**",
"!!**/.tsdown",
"!!**/.tsup",
"!!**/node_modules",
"!!**/.cache",
"!!**/.wrangler",
+1983 -1148
View File
File diff suppressed because it is too large Load Diff
@@ -335,3 +335,55 @@ export default {
```
<AutoTypeTable path="../app/src/plugins/auth/email-otp.plugin.ts" name="EmailOTPPluginOptions" />
### `sort`
A plugin that maintains sequential numeric positions for one or more entities. Positions are updated automatically when items are inserted or moved. You can optionally configure a scope so that positions are maintained independently within groups, such as tasks within a project.
```typescript title="bknd.config.ts"
import { sort } from "bknd/plugins";
export default {
options: {
plugins: [
sort({
entities: {
tasks: {
field: "position",
scope: "project_id",
},
},
}),
],
},
} satisfies BkndConfig;
```
The configured `field` is used for the numeric position. If the field does not exist, the plugin adds it as a number field with a default value of `0`; if it already exists, it must be a number field. The plugin also adds an index for the field when one is not already present. The optional `scope` field limits reordering to records with the same scope value.
The plugin registers the following endpoints. The default base path is `/api/sort` and can be changed with `apiBasePath`:
- `POST /api/sort/:entity/reorder` moves an item to a position. The request body must include the item `id` and its new numeric `position`.
- `POST /api/sort/:entity/recalculate` recalculates positions in ascending order of the configured field. To recalculate only one scope, pass its value in the optional `scope` property.
```http title="Move a task"
POST /api/sort/tasks/reorder
Content-Type: application/json
{
"id": "task_123",
"position": 2
}
```
```http title="Recalculate a project's task positions"
POST /api/sort/tasks/recalculate
Content-Type: application/json
{
"scope": "project_456"
}
```
<AutoTypeTable path="../app/src/plugins/data/sort.plugin.ts" name="SortPluginOptions" />
@@ -6,6 +6,7 @@
"sveltekit",
"tanstack-start",
"vite",
"nuxt"
"nuxt",
"solid-start"
]
}
@@ -71,7 +71,7 @@ export type NextjsBkndConfig<Env = NextjsEnv> = FrameworkBkndConfig<Env> & {
## Serve the API
Create a helper file to instantiate the bknd instance and retrieve the API, importing the configurationfrom the `bknd.config.ts` file:
Create a helper file to instantiate the bknd instance and retrieve the API, importing the configuration from the `bknd.config.ts` file:
```ts title="src/bknd.ts"
import {
@@ -127,31 +127,80 @@ export const DELETE = handler;
```
## Enabling the Admin UI
Create a file at `admin/[[...admin]]/page.client.tsx`:
``` tsx title="admin/[[...admin]]/page.client.tsx"
"use client";
import dynamic from "next/dynamic";
export const Admin = dynamic(async () => (await import("bknd/ui")).Admin, {
ssr: false,
});
```
<Callout type="info">
We are using [Colocation](https://nextjs.org/docs/app/getting-started/project-structure#colocation) to prevent server-side hydration error, and the dynamic function to load Admin component only on client side.
</Callout>
Create a page at `admin/[[...admin]]/page.tsx`:
```tsx title="admin/[[...admin]]/page.tsx"
import { Admin } from "bknd/ui";
import { getApi } from "@/bknd";
import "bknd/dist/styles.css";
import { Admin } from "./page.client";
import { Suspense } from "react";
import { redirect } from "next/navigation";
export default async function AdminPage() {
// make sure to verify auth using headers
const api = await getApi({ verify: true });
// early return if not logged in
if (!api.getUser()) {
return redirect("/unauthorized");
}
return (
<Admin
withProvider={{ user: api.getUser() }}
config={{
basepath: "/admin",
logo_return_path: "/../",
theme: "system",
}}
/>
<Suspense>
<Admin
withProvider={{ user: api.getUser() }}
config={{
basepath: "/admin",
logo_return_path: "/../",
theme: "system",
}}
/>
</Suspense>
);
}
};
```
## React SDK configuration
To use queries and mutation on client side, bknd provides first class support for it with it's [React SDK](/usage/react)
to set it up with Next.js add the `ClientProvider` to your root layout
```tsx title="app/layout.tsx"
import { ClientProvider } from "bknd/client";
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body>
<ClientProvider>
{children}
</ClientProvider>
</body>
</html>
);
};
```
## Example usage of the API
You can use the `getApi` helper function we've already set up to fetch and mutate in static pages and server components:
@@ -172,3 +221,26 @@ export default async function Home() {
);
}
```
## Note for Production
If your admin component is not rendering and has a error like `Identifier 'b' has already been declared`, you will need to add these lines to your `next.config.(mjs|ts)` file:
```ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// ...
// for turbopack
experimental: {
turbopackMinify: false,
},
// for webpack
// webpack: (config) => {
// config.optimization.minimize = false;
// return config;
// },
};
export default nextConfig;
```
@@ -0,0 +1,334 @@
---
title: "Solid Start"
description: "Run bknd inside Solid Start"
tags: ["documentation"]
---
## Installation
To get started with Solid Start and bknd you can either install the package manually, and follow the descriptions below, or use the CLI starter.
Create a new Solid Start project by following the [official guide](https://docs.solidjs.com/solid-start/getting-started), and then install bknd as a dependency:
<Tabs groupId='package-manager' persist items={[ 'npm', 'pnpm', 'yarn', 'bun']}>
```bash tab="npm"
npm install bknd
```
```bash tab="pnpm"
pnpm install bknd
```
```bash tab="yarn"
yarn add bknd
```
```bash tab="bun"
bun add bknd
```
</Tabs>
## Configuration
<Callout type="warning">
When run with Node.js, a version of 22 (LTS) or higher is required. Please
verify your version by running `node -v`, and
[upgrade](https://nodejs.org/en/download/) if necessary.
</Callout>
Now create a `bknd.config.ts` file in the root of your project. If you created the project using the CLI starter, this file is already created for you.
```typescript title="bknd.config.ts"
import type { SolidStartBkndConfig } from "bknd/adapter/solid-start";
import { em, entity, text, boolean } from "bknd";
import { secureRandomString } from "bknd/utils";
const schema = em({
todos: entity("todos", {
title: text(),
done: boolean(),
}),
});
// register your schema to get automatic type completion
type Database = (typeof schema)["DB"];
declare module "bknd" {
interface DB extends Database {}
}
export default {
connection: {
url: "file:data.db",
},
options: {
// the seed option is only executed if the database was empty
seed: async (ctx) => {
// create some entries
await ctx.em.mutator("todos").insertMany([
{ title: "Learn bknd", done: true },
{ title: "Build something cool", done: false },
]);
// and create a user
await ctx.app.module.auth.createUser({
email: "test@bknd.io",
password: "12345678",
});
},
},
config: {
data: schema.toJSON(),
auth: {
enabled: true,
jwt: {
secret: secureRandomString(32),
},
},
},
// please run `bun bknd copy-assets --out public/admin` to copy the admin assets into the public directory
// alternatively add this as a postinstall script in your package.json like this:
// "postinstall": "bknd copy-assets --out public/admin"
adminOptions: {
adminBasepath: "/admin",
assetsPath: "/admin/", // trailing slash is important
},
} satisfies SolidStartBkndConfig;
```
See [bknd.config.ts](/extending/config) for more information on how to configure bknd. The `SolidStartBkndConfig` type extends the `BkndConfig` type with the following additional properties:
```typescript
export type SolidStartBkndConfig<Env = SolidStartEnv> = RuntimeBkndConfig<Env>;
```
## Serve the API and Admin UI
The Solid Start adapter uses middleware to handle API requests and serve the Admin UI. Create a `src/middleware/index.ts` file:
```ts title="src/middleware/index.ts"
import { createMiddleware } from "@solidjs/start/middleware";
import config from "../../bknd.config";
import { serve } from "bknd/adapter/solid-start";
const handler = serve(config);
export default createMiddleware({
onRequest: async (event) => {
const url = new URL(event.request.url);
const pathname = url.pathname;
if (pathname.startsWith("/api") || pathname !== "/") {
const res = await handler(event.request);
if (res && res.status !== 404) {
return res;
}
}
},
});
```
### Update the app config (Solid Start v1 based on vinxi)
Add the following to your `app.config.ts` file:
```ts title="app.config.ts"
import { defineConfig } from "@solidjs/start/config";
const app = defineConfig({
// ... your existing config
middleware: "src/middleware/index.ts",
// ... your existing config
});
export default app;
```
### Update the vite config (Solid Start v2)
Add the following to your `vite.config.ts` file:
```ts title="vite.config.ts"
import { defineConfig } from "vite";
import solid from "vite-plugin-solid";
import { solidStart } from "@solidjs/start/vite";
export default defineConfig({
plugins: [
solid(),
solidStart({
middleware: "./src/middleware/index.ts",
}),
],
});
```
<Callout type="success">
You can visit https://localhost:3000/admin to see the admin UI. Additionally you can create more todos as you explore the admin UI.
</Callout>
Create a helper file to instantiate the bknd instance and retrieve the API, importing the configuration from the `bknd.config.ts` file:
```ts title="src/lib/bknd.ts"
import { getApp as getBkndApp } from "bknd/adapter/solid-start";
import bkndConfig from "../../bknd.config";
import type { App } from "bknd";
export const getApp = async () => {
return await getBkndApp(bkndConfig);
};
export async function getApi({
headers,
verify,
}: {
verify?: boolean;
headers?: Headers;
}) {
const app = await getApp();
if (verify) {
const api = app.getApi({ headers });
await api.verifyAuth();
return api;
}
return app.getApi();
};
```
## Example usage of the API
You can use the `getApi` helper function we've already set up to fetch and mutate in static pages and server components:
```tsx title="src/routes/index.tsx"
import { getApi } from "~/lib/bknd";
import { query, createAsync } from "@solidjs/router";
export const getTodo = async () => {
"use server"
const api = await getApi({});
const limit = 5;
const todos = await api.data.readMany("todos");
const total = todos.body.meta.total as number;
return { total, todos: todos as unknown as Todo[], limit };
};
const getTodosFromServer = query(async () => await getTodo(), "getTodosFromServer");
export default function Home() {
const data = createAsync(() => getTodosFromServer());
return (
<ul>
<For each={data()}>
{(todo) => <li>{todo.title}</li>}
</For>
</ul>
);
}
```
## Using authentication
When using authentication, you'll need to pass the headers to the `getApi` function to verify the user is authenticated.
```tsx title="src/routes/auth.tsx"
import { createResource, Suspense } from "solid-js";
import { A } from "@solidjs/router";
import { getRequestEvent } from "solid-js/web";
import { getApi } from "~/lib/bknd";
export const getUser = async () => {
"use server"
const request = getRequestEvent()?.request;
const api = await getApi({ verify: true, headers: request?.headers });
return api.getUser();
}
export default function Auth() {
const [data] = createResource(async () => {
const user = await getUser()
return { user };
}, {
initialValue: {
user: null
}
});
return (
<div class="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
<main class="flex flex-col gap-8 row-start-2 items-center sm:items-start">
<div class="flex flex-row items-center ">
<img
class="dark:invert size-18"
src="/solid.svg"
alt="Solid logo"
/>
<div class="ml-3.5 mr-2 opacity-70">&amp;</div>
<img
class="dark:invert"
src="/bknd.svg"
alt="bknd logo"
width={183}
height={59}
/>
</div>
<Suspense fallback={<p>Loading...</p>}>
<div>
{data()?.user ? (
<>
Logged in as {data()?.user?.email}.
<A
class="underline"
target="_self"
href={"/api/auth/logout"}
>
Logout
</A>
</>
) : (
<div class="flex flex-col gap-1">
<p>
Not logged in.
<A
class="underline"
target="_self"
href={"/admin/auth/login"}
>
Login
</A>
</p>
<p class="text-xs opacity-50">
Sign in with:
<b>
<code>test@bknd.io</code>
</b>
/
<b>
<code>12345678</code>
</b>
</p>
</div>
)}
</div>
</Suspense>
</main>
</div>
);
}
```
## Tips
Use `target="_self"` on links, anytime your traversing to an external route (like `/api/auth/logout` which are handled by bknd's middleware) to prevent solid router from intercepting the link.
```jsx
<A href="/api/auth/logout" target="_self">
Logout
</A>
```
@@ -45,6 +45,12 @@ bknd seamlessly integrates with popular frameworks, allowing you to use what you
href="/integration/nuxt"
/>
<Card
icon={<Icon icon="simple-icons:solid" className="text-fd-primary !size-6" />}
title="Solid Start"
href="/integration/solid-start"
/>
<Card title="Yours missing?" href="https://github.com/bknd-io/bknd/issues/new">
Create a new issue to request a guide for your framework.
</Card>
@@ -162,6 +162,12 @@ Pick your framework or runtime to get started.
href="/integration/nuxt"
/>
<Card
icon={<Icon icon="simple-icons:solid" className="text-fd-primary !size-6" />}
title="Solid Start"
href="/integration/solid-start"
/>
<Card
icon={<Icon icon="tabler:lambda" className="text-fd-primary !size-6" />}
title="AWS Lambda"
+3 -3
View File
@@ -10,11 +10,11 @@
"astro": "astro"
},
"dependencies": {
"@astrojs/check": "^0.9.9",
"@astrojs/react": "^6.0.1",
"@astrojs/check": "^0.9.4",
"@astrojs/react": "^4.2.2",
"@types/react": "^19.0.12",
"@types/react-dom": "^19.0.4",
"astro": "^7.0.7",
"astro": "^5.16.9",
"bknd": "file:../../app",
"react": "^19.1.0",
"react-dom": "^19.1.0",
+1
View File
@@ -1,4 +1,5 @@
---
import Card from "../components/Card.astro";
interface Props {
title: string;
}
+31
View File
@@ -0,0 +1,31 @@
dist
.wrangler
.output
.vercel
.netlify
.vinxi
app.config.timestamp_*.js
# Environment
.env
.env*.local
# dependencies
/node_modules
# IDEs and editors
/.idea
.project
.classpath
*.launch
.settings/
# Temp
gitignore
# System Files
.DS_Store
Thumbs.db
public/admin
data.db
+59
View File
@@ -0,0 +1,59 @@
# bknd starter: Solid Start
A minimal example of a Solid Start project with bknd integration.
## Project Structure
Inside of your Solid Start project, you'll see the following folders and files:
```text
.
├── bknd.config.ts
├── bun.lock
├── package.json
├── public
│ ├── admin # generated by bknd and contains the admin UI
│ ├── bknd.ico
│ ├── bknd.svg
│ ├── file.svg
│ ├── globe.svg
│ ├── solid.svg
│ └── window.svg
├── README.md # you're here
├── src
│ ├── app.css
│ ├── app.tsx
│ ├── components
│ │ ├── Footer.tsx
│ │ └── List.tsx
│ ├── entry-client.tsx
│ ├── entry-server.tsx
│ ├── global.d.ts
│ ├── lib
│ │ └── bknd.ts # initializes bknd instance
│ ├── middleware
│ │ └── index.ts # mounts bknd middleware
│ └── routes
│ ├── [...404].tsx
│ ├── index.tsx
│ └── user.tsx
├── tsconfig.json
└── vite.config.ts
```
Here is a quick overview about how to adjust the behavior of `bknd`:
* Initialization of the `bknd` config with helper functions are located at `src/lib/bknd.ts`
* Admin UI is rendered using `src/middleware/index.ts`
## Commands
All commands are run from the root of the project, from a terminal:
| Command | Action |
|:--------------------------|:-------------------------------------------------|
| `npm install` | Installs dependencies |
| `npm run dev` | Starts local dev server at `localhost:3000` |
| `npm run build` | Build your production site |
## Want to learn more?
Feel free to check [our documentation](https://docs.bknd.io/integration/solid-start) or jump into our [Discord server](https://discord.gg/952SFk8Tb8).
+51
View File
@@ -0,0 +1,51 @@
import { em, entity, text, boolean } from "bknd";
import { secureRandomString } from "bknd/utils";
import type { SolidStartBkndConfig } from "bknd/adapter/solid-start";
const schema = em({
todos: entity("todos", {
title: text(),
done: boolean(),
}),
});
// register your schema to get automatic type completion
type Database = (typeof schema)["DB"];
declare module "bknd" {
interface DB extends Database { }
}
export default {
connection: { url: "file:data.db" },
options: {
// the seed option is only executed if the database was empty
seed: async (ctx) => {
// create some entries
await ctx.em.mutator("todos").insertMany([
{ title: "Learn bknd", done: true },
{ title: "Build something cool", done: false },
]);
// and create a user
await ctx.app.module.auth.createUser({
email: "test@bknd.io",
password: "12345678",
});
},
},
config: {
data: schema.toJSON(),
auth: {
enabled: true,
jwt: {
secret: secureRandomString(32),
},
},
},
adminOptions: {
adminBasepath: "/admin",
assetsPath: "/admin/",
logoReturnPath: "../..",
},
} satisfies SolidStartBkndConfig;
+28
View File
@@ -0,0 +1,28 @@
{
"name": "example-basic",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"start": "vite start",
"preview": "vite preview",
"typegen": "bunx tsx node_modules/.bin/bknd types --outfile bknd-types.d.ts",
"postinstall": "bun run bknd copy-assets --out public/admin"
},
"dependencies": {
"@solidjs/meta": "^0.29.4",
"@solidjs/router": "^1.0.0",
"@solidjs/start": "2.0.5",
"bknd": "file:../../app",
"nitro": "3.0.260903-beta",
"solid-js": "^1.9.5",
"vite": "^8.3.0"
},
"engines": {
"node": ">=22"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.18",
"tailwindcss": "^4.1.18"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+14
View File
@@ -0,0 +1,14 @@
<svg
width="578"
height="188"
viewBox="0 0 578 188"
fill="black"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M41.5 34C37.0817 34 33.5 37.5817 33.5 42V146C33.5 150.418 37.0817 154 41.5 154H158.5C162.918 154 166.5 150.418 166.5 146V42C166.5 37.5817 162.918 34 158.5 34H41.5ZM123.434 113.942C124.126 111.752 124.5 109.42 124.5 107C124.5 94.2975 114.203 84 101.5 84C99.1907 84 96.9608 84.3403 94.8579 84.9736L87.2208 65.1172C90.9181 63.4922 93.5 59.7976 93.5 55.5C93.5 49.701 88.799 45 83 45C77.201 45 72.5 49.701 72.5 55.5C72.5 61.299 77.201 66 83 66C83.4453 66 83.8841 65.9723 84.3148 65.9185L92.0483 86.0256C87.1368 88.2423 83.1434 92.1335 80.7957 96.9714L65.4253 91.1648C65.4746 90.7835 65.5 90.3947 65.5 90C65.5 85.0294 61.4706 81 56.5 81C51.5294 81 47.5 85.0294 47.5 90C47.5 94.9706 51.5294 99 56.5 99C60.0181 99 63.0648 96.9814 64.5449 94.0392L79.6655 99.7514C78.9094 102.03 78.5 104.467 78.5 107C78.5 110.387 79.2321 113.603 80.5466 116.498L69.0273 123.731C67.1012 121.449 64.2199 120 61 120C55.201 120 50.5 124.701 50.5 130.5C50.5 136.299 55.201 141 61 141C66.799 141 71.5 136.299 71.5 130.5C71.5 128.997 71.1844 127.569 70.6158 126.276L81.9667 119.149C86.0275 125.664 93.2574 130 101.5 130C110.722 130 118.677 124.572 122.343 116.737L132.747 120.899C132.585 121.573 132.5 122.276 132.5 123C132.5 127.971 136.529 132 141.5 132C146.471 132 150.5 127.971 150.5 123C150.5 118.029 146.471 114 141.5 114C138.32 114 135.525 115.649 133.925 118.139L123.434 113.942Z"
/>
<path d="M243.9 151.5C240.4 151.5 237 151 233.7 150C230.4 149 227.4 147.65 224.7 145.95C222 144.15 219.75 142.15 217.95 139.95C216.15 137.65 215 135.3 214.5 132.9L219.3 131.1L218.25 149.7H198.15V39H219.45V89.25L215.4 87.6C216 85.2 217.15 82.9 218.85 80.7C220.55 78.4 222.7 76.4 225.3 74.7C227.9 72.9 230.75 71.5 233.85 70.5C236.95 69.5 240.15 69 243.45 69C250.35 69 256.5 70.8 261.9 74.4C267.3 77.9 271.55 82.75 274.65 88.95C277.85 95.15 279.45 102.25 279.45 110.25C279.45 118.25 277.9 125.35 274.8 131.55C271.7 137.75 267.45 142.65 262.05 146.25C256.75 149.75 250.7 151.5 243.9 151.5ZM238.8 133.35C242.8 133.35 246.25 132.4 249.15 130.5C252.15 128.5 254.5 125.8 256.2 122.4C257.9 118.9 258.75 114.85 258.75 110.25C258.75 105.75 257.9 101.75 256.2 98.25C254.6 94.75 252.3 92.05 249.3 90.15C246.3 88.25 242.8 87.3 238.8 87.3C234.8 87.3 231.3 88.25 228.3 90.15C225.3 92.05 222.95 94.75 221.25 98.25C219.55 101.75 218.7 105.75 218.7 110.25C218.7 114.85 219.55 118.9 221.25 122.4C222.95 125.8 225.3 128.5 228.3 130.5C231.3 132.4 234.8 133.35 238.8 133.35ZM308.312 126.15L302.012 108.6L339.512 70.65H367.562L308.312 126.15ZM288.062 150V39H309.362V150H288.062ZM341.762 150L313.262 114.15L328.262 102.15L367.412 150H341.762ZM371.675 150V70.65H392.075L392.675 86.85L388.475 88.65C389.575 85.05 391.525 81.8 394.325 78.9C397.225 75.9 400.675 73.5 404.675 71.7C408.675 69.9 412.875 69 417.275 69C423.275 69 428.275 70.2 432.275 72.6C436.375 75 439.425 78.65 441.425 83.55C443.525 88.35 444.575 94.3 444.575 101.4V150H423.275V103.05C423.275 99.45 422.775 96.45 421.775 94.05C420.775 91.65 419.225 89.9 417.125 88.8C415.125 87.6 412.625 87.1 409.625 87.3C407.225 87.3 404.975 87.7 402.875 88.5C400.875 89.2 399.125 90.25 397.625 91.65C396.225 93.05 395.075 94.65 394.175 96.45C393.375 98.25 392.975 100.2 392.975 102.3V150H382.475C380.175 150 378.125 150 376.325 150C374.525 150 372.975 150 371.675 150ZM488.536 151.5C481.636 151.5 475.436 149.75 469.936 146.25C464.436 142.65 460.086 137.8 456.886 131.7C453.786 125.5 452.236 118.35 452.236 110.25C452.236 102.35 453.786 95.3 456.886 89.1C460.086 82.9 464.386 78 469.786 74.4C475.286 70.8 481.536 69 488.536 69C492.236 69 495.786 69.6 499.186 70.8C502.686 71.9 505.786 73.45 508.486 75.45C511.286 77.45 513.536 79.7 515.236 82.2C516.936 84.6 517.886 87.15 518.086 89.85L512.686 90.75V39H533.986V150H513.886L512.986 131.7L517.186 132.15C516.986 134.65 516.086 137.05 514.486 139.35C512.886 141.65 510.736 143.75 508.036 145.65C505.436 147.45 502.436 148.9 499.036 150C495.736 151 492.236 151.5 488.536 151.5ZM493.336 133.8C497.336 133.8 500.836 132.8 503.836 130.8C506.836 128.8 509.186 126.05 510.886 122.55C512.586 119.05 513.436 114.95 513.436 110.25C513.436 105.65 512.586 101.6 510.886 98.1C509.186 94.5 506.836 91.75 503.836 89.85C500.836 87.85 497.336 86.85 493.336 86.85C489.336 86.85 485.836 87.85 482.836 89.85C479.936 91.75 477.636 94.5 475.936 98.1C474.336 101.6 473.536 105.65 473.536 110.25C473.536 114.95 474.336 119.05 475.936 122.55C477.636 126.05 479.936 128.8 482.836 130.8C485.836 132.8 489.336 133.8 493.336 133.8Z" />
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg data-hk="000000220" width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg" role="presentation" class="drop-shadow-[10px_20px_35px_rgb(125,211,252,0.3)] size-52 md:size-[400px] mx-auto"><path d="M233.205 430.856L304.742 425.279C304.742 425.279 329.208 421.295 343.569 397.659L293.041 385.443L233.205 430.856Z" fill="url(#paint0_linear_1_2)"></path><path d="M134.278 263.278C113.003 264.341 73.6443 268.059 73.6443 268.059L245.173 392.614L284.265 402.44L343.569 397.925L170.977 273.105C170.977 273.105 157.148 263.278 137.203 263.278C136.139 263.278 135.342 263.278 134.278 263.278Z" fill="url(#paint1_linear_1_2)"></path><path d="M355.536 238.58L429.2 234.065C429.2 234.065 454.464 230.348 468.825 206.977L416.435 193.964L355.536 238.58Z" fill="url(#paint2_linear_1_2)"></path><path d="M251.289 68.6128C229.217 69.4095 188.795 72.5964 188.795 72.5964L367.503 200.072L407.926 210.429L469.09 206.712L289.318 78.9702C289.318 78.9702 274.426 68.6128 253.417 68.6128C252.885 68.6128 252.087 68.6128 251.289 68.6128Z" fill="url(#paint3_linear_1_2)"></path><path d="M31.0946 295.679C30.8287 295.945 30.8287 296.21 30.8287 296.475L77.8993 330.469L202.623 420.764C228.95 439.62 264.586 431.653 282.67 402.44L187.465 333.921L110.077 277.62C100.504 270.715 89.8663 267.528 79.2289 267.528C60.6134 267.528 42.2639 277.354 31.0946 295.679Z" fill="url(#paint4_linear_1_2)"></path><path d="M147.043 99.9505C147.043 100.216 146.776 100.482 146.511 100.747L195.442 135.538L244.374 170.062L325.751 227.957C353.142 247.345 389.841 239.642 407.925 210.695L358.461 175.374L308.997 140.318L228.153 82.6881C218.047 75.5177 206.611 72.0652 195.442 72.0652C176.561 72.3308 158.212 81.8915 147.043 99.9505Z" fill="url(#paint5_linear_1_2)"></path><path d="M112.471 139.255L175.497 208.305C178.423 212.289 181.614 216.006 185.337 219.193L308.199 354.105L369.364 350.387C387.448 321.439 380.002 282.135 352.611 262.748L271.234 204.852L222.568 170.328L173.636 135.538L112.471 139.255Z" fill="url(#paint6_linear_1_2)"></path><path d="M111.939 140.052C94.1213 168.734 101.567 207.509 128.427 226.629L209.005 283.994L258.735 319.049L308.199 354.105C326.283 325.158 318.836 285.852 291.445 266.465L112.471 139.255C112.471 139.521 112.204 139.787 111.939 140.052Z" fill="url(#paint7_linear_1_2)"></path><defs><linearGradient id="paint0_linear_1_2" x1="359.728" y1="56.8062" x2="265.623" y2="521.28" gradientUnits="userSpaceOnUse"><stop stop-color="#1593F5"></stop><stop offset="1" stop-color="#0084CE"></stop></linearGradient><linearGradient id="paint1_linear_1_2" x1="350.496" y1="559.872" x2="-44.0802" y2="-73.2062" gradientUnits="userSpaceOnUse"><stop stop-color="#1593F5"></stop><stop offset="1" stop-color="#0084CE"></stop></linearGradient><linearGradient id="paint2_linear_1_2" x1="610.25" y1="570.526" x2="372.635" y2="144.034" gradientUnits="userSpaceOnUse"><stop stop-color="white"></stop><stop offset="1" stop-color="#15ABFF"></stop></linearGradient><linearGradient id="paint3_linear_1_2" x1="188.808" y1="-180.608" x2="390.515" y2="281.703" gradientUnits="userSpaceOnUse"><stop stop-color="white"></stop><stop offset="1" stop-color="#79CFFF"></stop></linearGradient><linearGradient id="paint4_linear_1_2" x1="415.84" y1="-4.74684" x2="95.1922" y2="439.83" gradientUnits="userSpaceOnUse"><stop stop-color="#0057E5"></stop><stop offset="1" stop-color="#0084CE"></stop></linearGradient><linearGradient id="paint5_linear_1_2" x1="343.141" y1="-21.5427" x2="242.301" y2="256.708" gradientUnits="userSpaceOnUse"><stop stop-color="white"></stop><stop offset="1" stop-color="#15ABFF"></stop></linearGradient><linearGradient id="paint6_linear_1_2" x1="469.095" y1="533.421" x2="-37.6939" y2="-135.731" gradientUnits="userSpaceOnUse"><stop stop-color="white"></stop><stop offset="1" stop-color="#79CFFF"></stop></linearGradient><linearGradient id="paint7_linear_1_2" x1="380.676" y1="-89.0869" x2="120.669" y2="424.902" gradientUnits="userSpaceOnUse"><stop stop-color="white"></stop><stop offset="1" stop-color="#79CFFF"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+33
View File
@@ -0,0 +1,33 @@
@import url('https://fonts.googleapis.com/css2?family=Geist+Mono:wght@100..900&display=swap');
@import "tailwindcss";
.geist-mono-100 {
font-optical-sizing: auto;
font-weight: 100;
font-style: normal;
}
:root {
--background: #ffffff;
--foreground: #171717;
font-family: "Geist Mono", monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
@theme {
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-background: var(--background);
--color-foreground: var(--foreground);
}
body {
@apply bg-background text-foreground;
font-family: var(--font-geist-mono-100)
}
+20
View File
@@ -0,0 +1,20 @@
import { MetaProvider, Title } from "@solidjs/meta";
import { Router } from "@solidjs/router";
import { FileRoutes } from "@solidjs/start/router";
import { Suspense } from "solid-js";
import "./app.css";
export default function App() {
return (
<Router
root={props => (
<MetaProvider>
<Title>Solid Start 🤝 Bknd.io</Title>
<Suspense>{props.children}</Suspense>
</MetaProvider>
)}
>
<FileRoutes />
</Router>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { A, useLocation } from "@solidjs/router";
export function Footer() {
const pathname = useLocation().pathname;
return (
<footer class="row-start-3 flex gap-6 flex-wrap items-center justify-center">
<A
class="flex items-center gap-2 hover:underline hover:underline-offset-4"
href={pathname === "/" ? "/user" : "/"}
>
<img
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
{pathname === "/" ? "User" : "Home"}
</A>
<A
target="_self"
class="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="/admin"
rel="noopener noreferrer"
>
<img
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Admin
</A>
<a
class="flex items-center gap-2 hover:underline hover:underline-offset-4"
href={"https://bknd.io"}
target="_blank"
rel="noopener noreferrer"
>
<img
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to bknd.io →
</a>
</footer>
);
}
+11
View File
@@ -0,0 +1,11 @@
import { JSX } from "solid-js";
export const List = ({ items = [] }: { items: JSX.Element[] }) => (
<ol class="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
{items.map((item, i) => (
<li class={i < items.length - 1 ? "mb-2" : ""}>
{item}
</li>
))}
</ol>
);
+4
View File
@@ -0,0 +1,4 @@
// @refresh reload
import { mount, StartClient } from "@solidjs/start/client";
mount(() => <StartClient />, document.getElementById("app")!);
+21
View File
@@ -0,0 +1,21 @@
// @refresh reload
import { createHandler, StartServer } from "@solidjs/start/server";
export default createHandler(() => (
<StartServer
document={({ assets, children, scripts }) => (
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/solid.svg" />
{assets}
</head>
<body>
<div id="app">{children}</div>
{scripts}
</body>
</html>
)}
/>
));
+1
View File
@@ -0,0 +1 @@
/// <reference types="@solidjs/start/env" />
+30
View File
@@ -0,0 +1,30 @@
import { getApp as getBkndApp } from "bknd/adapter/solid-start";
import bkndConfig from "../../bknd.config";
import type { App } from "bknd";
let client: App | null = null;
export const getApp = async () => {
if (!client) {
client = await getBkndApp(bkndConfig);
}
return client;
};
export async function getApi({
headers,
verify,
}: {
verify?: boolean;
headers?: Headers;
}) {
const app = await getApp();
if (verify) {
const api = app.getApi({ headers });
await api.verifyAuth();
return api;
}
return app.getApi();
}
+20
View File
@@ -0,0 +1,20 @@
import { createMiddleware } from "@solidjs/start/middleware";
import config from "../../bknd.config";
import { serve } from "bknd/adapter/solid-start";
const handler = serve(config);
export default createMiddleware({
onRequest: async (event) => {
const url = new URL(event.request.url);
const pathname = url.pathname;
if (pathname.startsWith("/api") || pathname !== "/") {
const res = await handler(event.request);
if (res && res.status !== 404) {
return res;
}
}
},
});

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