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
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 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
42 changed files with 843 additions and 682 deletions
+2 -2
View File
@@ -16,8 +16,8 @@ jobs:
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_DB: bknd POSTGRES_DB: bknd
ports: # ports:
- 5430:5432 # - 5430:5432
options: >- options: >-
--health-cmd pg_isready --health-cmd pg_isready
--health-interval 10s --health-interval 10s
+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, "biome.enabled": true,
"editor.defaultFormatter": "biomejs.biome", "editor.defaultFormatter": "biomejs.biome",
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
//"source.organizeImports.biome": "explicit", //"source.organizeImports.biome": "explicit",
"source.fixAll.biome": "explicit" "source.fixAll.biome": "explicit"
}, },
"typescript.preferences.importModuleSpecifier": "non-relative", "js/ts.preferences.importModuleSpecifier": "non-relative",
"typescript.preferences.autoImportFileExcludePatterns": [ "js/ts.preferences.autoImportFileExcludePatterns": [
"**/dist/**", "**/dist/**",
"**/node_modules/**/dist/**", "**/node_modules/**/dist/**",
"**/node_modules/**/!(src|lib|esm)/**" // optional, stricter "**/node_modules/**/!(src|lib|esm)/**" // optional, stricter
], ],
"typescript.preferences.includePackageJsonAutoImports": "on", "js/ts.preferences.includePackageJsonAutoImports": "on",
"typescript.tsserver.watchOptions": { "js/ts.tsserver.watchOptions": {
"excludeDirectories": ["**/dist", "**/node_modules/**/dist"] "excludeDirectories": ["**/dist", "**/node_modules/**/dist"]
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
} }
} }
+53 -54
View File
@@ -4,73 +4,72 @@ import { disableConsoleLog, enableConsoleLog, omitKeys } from "core/utils";
import { adapterTestSuite } from "adapter/adapter-test-suite"; import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test"; import { bunTestRunner } from "adapter/bun/test";
const stripConnection = <T extends Record<string, any>>(cfg: T) => const stripConnection = <T extends Record<string, any>>(cfg: T) => omitKeys(cfg, ["connection"]);
omitKeys(cfg, ["connection"]);
beforeAll(disableConsoleLog); beforeAll(disableConsoleLog);
afterAll(enableConsoleLog); afterAll(enableConsoleLog);
describe("adapter", () => { describe("adapter", () => {
describe("makeConfig", () => { describe("makeConfig", () => {
it("returns empty config for empty inputs", async () => { it("returns empty config for empty inputs", async () => {
const cases: Array<Parameters<typeof adapter.makeConfig>> = [ const cases: Array<Parameters<typeof adapter.makeConfig>> = [
[{}], [{}],
[{}, { env: { TEST: "test" } }], [{}, { env: { TEST: "test" } }],
]; ];
for (const args of cases) { for (const args of cases) {
const cfg = await adapter.makeConfig(...(args as any)); const cfg = await adapter.makeConfig(...(args as any));
expect(stripConnection(cfg)).toEqual({}); 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" } } },
}); });
});
it("allows all properties in app() result", async () => { it("merges app output into config", async () => {
const called = mock(() => null); const cfg = await adapter.makeConfig(
{ app: (a) => ({ config: { server: { cors: { origin: a.env.TEST } } } }) },
{ env: { TEST: "test" } },
);
const cfg = await adapter.makeConfig( expect(stripConnection(cfg)).toEqual({
{
app: (env) => ({
connection: { url: "test" },
config: { server: { cors: { origin: "test" } } }, 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.connection).toEqual({ url: "test" });
expect(cfg.config).toEqual({ server: { cors: { origin: "test" } } }); expect(cfg.config).toEqual({ server: { cors: { origin: "test" } } });
expect(cfg.options).toEqual({ mode: "db" }); expect(cfg.options).toEqual({ mode: "db" });
await cfg.onBuilt?.({} as any); await cfg.onBuilt?.({} as any);
expect(called).toHaveBeenCalledTimes(1); expect(called).toHaveBeenCalledTimes(1);
}); });
}); });
describe("adapter test suites", () => { describe("adapter test suites", () => {
adapterTestSuite(bunTestRunner, { adapterTestSuite(bunTestRunner, {
makeApp: adapter.createFrameworkApp, makeApp: adapter.createFrameworkApp,
label: "framework app", label: "framework app",
}); });
adapterTestSuite(bunTestRunner, { adapterTestSuite(bunTestRunner, {
makeApp: adapter.createRuntimeApp, makeApp: adapter.createRuntimeApp,
label: "runtime app", label: "runtime app",
}); });
}); });
}); });
+9 -3
View File
@@ -73,7 +73,13 @@ describe("Api", async () => {
it("should extract tokens case insensitive", async () => { it("should extract tokens case insensitive", async () => {
const token = await sign({ sub: "test" }, "1234"); const token = await sign({ sub: "test" }, "1234");
expect(new Api({ headers: new Headers({ Authorization: `Bearer ${token}` }) }).getAuthState().token).toBe(token); expect(
expect(new Api({ headers: new Headers({ Authorization: `bearer ${token}` }) }).getAuthState().token).toBe(token); 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 token = await auth.jwt({ sub: "test" });
const res = await auth.resolveAuthFromRequest(new Headers({ const res = await auth.resolveAuthFromRequest(
Authorization: `Bearer ${token}`, new Headers({
})); Authorization: `Bearer ${token}`,
expect((res as any).sub).toBe("test") }),
);
expect((res as any).sub).toBe("test");
const res2 = await auth.resolveAuthFromRequest(new Headers({ const res2 = await auth.resolveAuthFromRequest(
Authorization: `bearer ${token}`, new Headers({
})); Authorization: `bearer ${token}`,
expect((res2 as any).sub).toBe("test") }),
}) );
expect((res2 as any).sub).toBe("test");
});
}); });
+1
View File
@@ -26,6 +26,7 @@ describe("Polymorphic", async () => {
target: "media", target: "media",
config: { config: {
mappedBy: "image", mappedBy: "image",
on_delete: "set null",
}, },
}); });
// media should not see categories // 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 { connectionTestSuite } from "data/connection/connection-test-suite";
import { bunTestRunner } from "adapter/bun/test"; import { bunTestRunner } from "adapter/bun/test";
const isCI = !!(process.env.GITEA_ACTIONS || process.env.GITHUB_ACTIONS);
const credentials = { const credentials = {
host: "localhost", host: isCI ? "postgres" : "localhost",
port: 5430, port: 5432,
user: "postgres", user: "postgres",
password: "postgres", password: "postgres",
database: "bknd", database: "bknd",
@@ -39,6 +41,11 @@ async function isPostgresRunning() {
describe("postgres", () => { describe("postgres", () => {
beforeAll(async () => { beforeAll(async () => {
if (process.env.GITEA_ACTIONS || process.env.GITHUB_ACTIONS) {
console.log("Running in CI: Skipping manual docker run");
return;
}
if (!(await isPostgresRunning())) { 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 $`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); await $waitUntil("Postgres is running", isPostgresRunning, 500, 20);
+297 -317
View File
@@ -14,15 +14,15 @@ const clean = args.includes("--clean");
// silence tsup // silence tsup
const oldConsole = { const oldConsole = {
log: console.log, log: console.log,
warn: console.warn, warn: console.warn,
}; };
console.log = () => {}; console.log = () => {};
console.warn = () => {}; console.warn = () => {};
const define = { const define = {
__isDev: "0", __isDev: "0",
__version: JSON.stringify(pkg.version), __version: JSON.stringify(pkg.version),
}; };
if (clean) { if (clean) {
@@ -43,149 +43,146 @@ if (clean) {
let types_running = false; let types_running = false;
function buildTypes() { function buildTypes() {
if (types_running || !types) return; if (types_running || !types) return;
types_running = true; types_running = true;
Bun.spawn(["bun", "build:types"], { Bun.spawn(["bun", "build:types"], {
stdout: "inherit", stdout: "inherit",
onExit: () => { onExit: () => {
oldConsole.log(c.cyan("[Types]"), c.green("built")); oldConsole.log(c.cyan("[Types]"), c.green("built"));
Bun.spawn(["bun", "tsc-alias"], { Bun.spawn(["bun", "tsc-alias"], {
stdout: "inherit", stdout: "inherit",
onExit: () => { onExit: () => {
oldConsole.log(c.cyan("[Types]"), c.green("aliased")); oldConsole.log(c.cyan("[Types]"), c.green("aliased"));
types_running = false; types_running = false;
}, },
}); });
}, },
}); });
} }
if (types && !watch) { if (types && !watch) {
buildTypes(); buildTypes();
} }
let watcher_timeout: any; let watcher_timeout: any;
function delayTypes() { function delayTypes() {
if (!watch || !types) return; if (!watch || !types) return;
if (watcher_timeout) { if (watcher_timeout) {
clearTimeout(watcher_timeout); clearTimeout(watcher_timeout);
} }
watcher_timeout = setTimeout(buildTypes, 1000); watcher_timeout = setTimeout(buildTypes, 1000);
} }
const dependencies = Object.keys(pkg.dependencies); const dependencies = Object.keys(pkg.dependencies);
// collection of always-external packages // collection of always-external packages
const external = [ const external = [
...dependencies, ...dependencies,
"bun:test", "bun:test",
"node:test", "node:test",
"node:assert/strict", "node:assert/strict",
"@libsql/client", "@libsql/client",
"bknd", "bknd",
/^bknd\/.*/, /^bknd\/.*/,
"jsonv-ts", "jsonv-ts",
/^jsonv-ts\/.*/, /^jsonv-ts\/.*/,
] as const; ] as const;
/** /**
* Building backend and general API * Building backend and general API
*/ */
async function buildApi() { async function buildApi() {
await tsup.build({ await tsup.build({
minify, minify,
sourcemap, sourcemap,
// don't use tsup's broken watch, we'll handle it ourselves // don't use tsup's broken watch, we'll handle it ourselves
watch: false, watch: false,
define, define,
entry: [ entry: [
"src/index.ts", "src/index.ts",
"src/core/utils/index.ts", "src/core/utils/index.ts",
"src/plugins/index.ts", "src/plugins/index.ts",
"src/modes/index.ts", "src/modes/index.ts",
], ],
outDir: "dist", outDir: "dist",
external: [...external], external: [...external],
metafile: true, metafile: true,
target: "esnext", target: "esnext",
platform: "browser", platform: "browser",
removeNodeProtocol: false, removeNodeProtocol: false,
format: ["esm"], format: ["esm"],
splitting: false, splitting: false,
loader: { loader: {
".svg": "dataurl", ".svg": "dataurl",
}, },
onSuccess: async () => { onSuccess: async () => {
delayTypes(); delayTypes();
oldConsole.log(c.cyan("[API]"), c.green("built")); oldConsole.log(c.cyan("[API]"), c.green("built"));
}, },
}); });
} }
async function rewriteClient(path: string) { async function rewriteClient(path: string) {
const bundle = await Bun.file(path).text(); const bundle = await Bun.file(path).text();
await Bun.write( await Bun.write(path, '"use client";\n' + bundle.replaceAll("ui/client", "bknd/client"));
path,
'"use client";\n' + bundle.replaceAll("ui/client", "bknd/client"),
);
} }
/** /**
* Building UI for direct imports * Building UI for direct imports
*/ */
async function buildUi() { async function buildUi() {
const base = { const base = {
minify, minify,
sourcemap, sourcemap,
watch: false, watch: false,
define, define,
external: [ external: [
...external, ...external,
"react", "react",
"react-dom", "react-dom",
"react/jsx-runtime", "react/jsx-runtime",
"react/jsx-dev-runtime", "react/jsx-dev-runtime",
"use-sync-external-store", "use-sync-external-store",
/codemirror/, /codemirror/,
"@xyflow/react", "@xyflow/react",
"@mantine/core", "@mantine/core",
], ],
metafile: true, metafile: true,
platform: "browser", platform: "browser",
format: ["esm"], format: ["esm"],
splitting: false, splitting: false,
bundle: true, bundle: true,
treeshake: true, treeshake: true,
loader: { loader: {
".svg": "dataurl", ".svg": "dataurl",
}, },
esbuildOptions: (options) => { esbuildOptions: (options) => {
options.logLevel = "silent"; options.logLevel = "silent";
}, },
} satisfies tsup.Options; } satisfies tsup.Options;
await tsup.build({ await tsup.build({
...base, ...base,
entry: ["src/ui/index.ts", "src/ui/main.css", "src/ui/styles.css"], entry: ["src/ui/index.ts", "src/ui/main.css", "src/ui/styles.css"],
outDir: "dist/ui", outDir: "dist/ui",
onSuccess: async () => { onSuccess: async () => {
await rewriteClient("./dist/ui/index.js"); await rewriteClient("./dist/ui/index.js");
delayTypes(); delayTypes();
oldConsole.log(c.cyan("[UI]"), c.green("built")); oldConsole.log(c.cyan("[UI]"), c.green("built"));
}, },
}); });
await tsup.build({ await tsup.build({
...base, ...base,
entry: ["src/ui/client/index.ts"], entry: ["src/ui/client/index.ts"],
outDir: "dist/ui/client", outDir: "dist/ui/client",
onSuccess: async () => { onSuccess: async () => {
await rewriteClient("./dist/ui/client/index.js"); await rewriteClient("./dist/ui/client/index.js");
delayTypes(); delayTypes();
oldConsole.log(c.cyan("[UI]"), "Client", c.green("built")); oldConsole.log(c.cyan("[UI]"), "Client", c.green("built"));
}, },
}); });
} }
/** /**
@@ -194,195 +191,186 @@ async function buildUi() {
* - ui/client is external, and after built replaced with "bknd/client" * - ui/client is external, and after built replaced with "bknd/client"
*/ */
async function buildUiElements() { async function buildUiElements() {
await tsup.build({ await tsup.build({
minify, minify,
sourcemap, sourcemap,
watch: false, watch: false,
define, define,
entry: ["src/ui/elements/index.ts"], entry: ["src/ui/elements/index.ts"],
outDir: "dist/ui/elements", outDir: "dist/ui/elements",
external: [ external: [
"ui/client", "ui/client",
"bknd", "bknd",
/^bknd\/.*/, /^bknd\/.*/,
"wouter", "wouter",
"react", "react",
"react-dom", "react-dom",
"react/jsx-runtime", "react/jsx-runtime",
"react/jsx-dev-runtime", "react/jsx-dev-runtime",
"use-sync-external-store", "use-sync-external-store",
], ],
metafile: true, metafile: true,
platform: "browser", platform: "browser",
format: ["esm"], format: ["esm"],
splitting: false, splitting: false,
bundle: true, bundle: true,
treeshake: true, treeshake: true,
loader: { loader: {
".svg": "dataurl", ".svg": "dataurl",
}, },
esbuildOptions: (options) => { esbuildOptions: (options) => {
options.alias = { options.alias = {
// not important for elements, mock to reduce bundle // not important for elements, mock to reduce bundle
"tailwind-merge": "./src/ui/elements/mocks/tailwind-merge.ts", "tailwind-merge": "./src/ui/elements/mocks/tailwind-merge.ts",
}; };
}, },
onSuccess: async () => { onSuccess: async () => {
await rewriteClient("./dist/ui/elements/index.js"); await rewriteClient("./dist/ui/elements/index.js");
delayTypes(); delayTypes();
oldConsole.log(c.cyan("[UI]"), "Elements", c.green("built")); oldConsole.log(c.cyan("[UI]"), "Elements", c.green("built"));
}, },
}); });
} }
/** /**
* Building adapters * Building adapters
*/ */
function baseConfig( function baseConfig(adapter: string, overrides: Partial<tsup.Options> = {}): tsup.Options {
adapter: string, return {
overrides: Partial<tsup.Options> = {}, minify,
): tsup.Options { sourcemap,
return { watch: false,
minify, entry: [`src/adapter/${adapter}/index.ts`],
sourcemap, format: ["esm"],
watch: false, platform: "neutral",
entry: [`src/adapter/${adapter}/index.ts`], outDir: `dist/adapter/${adapter}`,
format: ["esm"], metafile: true,
platform: "neutral", splitting: false,
outDir: `dist/adapter/${adapter}`, removeNodeProtocol: false,
metafile: true, onSuccess: async () => {
splitting: false, delayTypes();
removeNodeProtocol: false, oldConsole.log(c.cyan("[Adapter]"), adapter || "base", c.green("built"));
onSuccess: async () => { },
delayTypes(); ...overrides,
oldConsole.log(c.cyan("[Adapter]"), adapter || "base", c.green("built")); define: {
}, ...define,
...overrides, ...overrides.define,
define: { },
...define, external: [
...overrides.define, /^cloudflare*/,
}, /^@?hono.*?/,
external: [ /^(bknd|react|next|node).*?/,
/^cloudflare*/, /.*\.(html)$/,
/^@?hono.*?/, ...external,
/^(bknd|react|next|node).*?/, ...(Array.isArray(overrides.external) ? overrides.external : []),
/.*\.(html)$/, ],
...external, };
...(Array.isArray(overrides.external) ? overrides.external : []),
],
};
} }
async function buildAdapters() { async function buildAdapters() {
await Promise.all([ await Promise.all([
// base adapter handles // base adapter handles
tsup.build({ tsup.build({
...baseConfig(""), ...baseConfig(""),
target: "esnext", target: "esnext",
platform: "neutral", platform: "neutral",
entry: ["src/adapter/index.ts"], entry: ["src/adapter/index.ts"],
outDir: "dist/adapter", outDir: "dist/adapter",
// only way to keep @vite-ignore comments // only way to keep @vite-ignore comments
minify: false, minify: false,
}),
// specific adatpers
tsup.build(baseConfig("react-router")),
tsup.build(
baseConfig("browser", {
external: [/^sqlocal\/?.*?/, "wouter"],
}), }),
),
tsup.build( // specific adatpers
baseConfig("bun", { tsup.build(baseConfig("react-router")),
external: [/^bun\:.*/], tsup.build(
baseConfig("browser", {
external: [/^sqlocal\/?.*?/, "wouter"],
}),
),
tsup.build(
baseConfig("bun", {
external: [/^bun\:.*/],
}),
),
tsup.build(baseConfig("astro")),
tsup.build(baseConfig("aws")),
tsup.build(
baseConfig("cloudflare", {
external: ["wrangler", "node:process"],
}),
),
tsup.build(
baseConfig("cloudflare/proxy", {
target: "esnext",
entry: ["src/adapter/cloudflare/proxy.ts"],
outDir: "dist/adapter/cloudflare",
metafile: false,
external: [/bknd/, "wrangler", "node:process"],
}),
),
tsup.build({
...baseConfig("vite"),
platform: "node",
}), }),
),
tsup.build(baseConfig("astro")), tsup.build({
tsup.build(baseConfig("aws")), ...baseConfig("nextjs"),
tsup.build( platform: "node",
baseConfig("cloudflare", {
external: ["wrangler", "node:process"],
}), }),
),
tsup.build( tsup.build({
baseConfig("cloudflare/proxy", { ...baseConfig("tanstack-start"),
target: "esnext", platform: "node",
entry: ["src/adapter/cloudflare/proxy.ts"],
outDir: "dist/adapter/cloudflare",
metafile: false,
external: [/bknd/, "wrangler", "node:process"],
}), }),
),
tsup.build({ tsup.build({
...baseConfig("vite"), ...baseConfig("sveltekit"),
platform: "node", platform: "node",
}), }),
tsup.build({ tsup.build({
...baseConfig("nextjs"), ...baseConfig("nuxt"),
platform: "node", platform: "node",
}), }),
tsup.build({ tsup.build({
...baseConfig("tanstack-start"), ...baseConfig("node"),
platform: "node", platform: "node",
}), }),
tsup.build({ tsup.build({
...baseConfig("sveltekit"), ...baseConfig("solid-start"),
platform: "node", platform: "node",
}), }),
tsup.build({ tsup.build({
...baseConfig("nuxt"), ...baseConfig("sqlite/edge"),
platform: "node", entry: ["src/adapter/sqlite/edge.ts"],
}), outDir: "dist/adapter/sqlite",
metafile: false,
}),
tsup.build({ tsup.build({
...baseConfig("node"), ...baseConfig("sqlite/node"),
platform: "node", entry: ["src/adapter/sqlite/node.ts"],
}), outDir: "dist/adapter/sqlite",
platform: "node",
metafile: false,
}),
tsup.build({ tsup.build({
...baseConfig("solid-start"), ...baseConfig("sqlite/bun"),
platform: "node", entry: ["src/adapter/sqlite/bun.ts"],
}), outDir: "dist/adapter/sqlite",
metafile: false,
tsup.build({ external: [/^bun\:.*/],
...baseConfig("sqlite/edge"), }),
entry: ["src/adapter/sqlite/edge.ts"], ]);
outDir: "dist/adapter/sqlite",
metafile: false,
}),
tsup.build({
...baseConfig("sqlite/node"),
entry: ["src/adapter/sqlite/node.ts"],
outDir: "dist/adapter/sqlite",
platform: "node",
metafile: false,
}),
tsup.build({
...baseConfig("sqlite/bun"),
entry: ["src/adapter/sqlite/bun.ts"],
outDir: "dist/adapter/sqlite",
metafile: false,
external: [/^bun\:.*/],
}),
]);
} }
async function buildAll() { async function buildAll() {
await Promise.all([ await Promise.all([buildApi(), buildUi(), buildUiElements(), buildAdapters()]);
buildApi(),
buildUi(),
buildUiElements(),
buildAdapters(),
]);
} }
// initial build // initial build
@@ -390,47 +378,39 @@ await buildAll();
// custom watcher since tsup's watch is broken in 8.3.5+ // custom watcher since tsup's watch is broken in 8.3.5+
if (watch) { if (watch) {
oldConsole.log(c.cyan("[Watch]"), "watching for changes in src/..."); oldConsole.log(c.cyan("[Watch]"), "watching for changes in src/...");
let debounceTimer: ReturnType<typeof setTimeout> | null = null; let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let isBuilding = false; let isBuilding = false;
const rebuild = async () => { const rebuild = async () => {
if (isBuilding) return; if (isBuilding) return;
isBuilding = true; isBuilding = true;
oldConsole.log(c.cyan("[Watch]"), "rebuilding..."); oldConsole.log(c.cyan("[Watch]"), "rebuilding...");
try { try {
await buildAll(); await buildAll();
oldConsole.log(c.cyan("[Watch]"), c.green("done")); oldConsole.log(c.cyan("[Watch]"), c.green("done"));
} catch (e) { } catch (e) {
oldConsole.warn(c.cyan("[Watch]"), c.red("build failed"), e); oldConsole.warn(c.cyan("[Watch]"), c.red("build failed"), e);
} }
isBuilding = false; isBuilding = false;
}; };
const debouncedRebuild = () => { const debouncedRebuild = () => {
if (debounceTimer) clearTimeout(debounceTimer); if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(rebuild, 100); debounceTimer = setTimeout(rebuild, 100);
}; };
// watch src directory recursively // watch src directory recursively
fsWatch( fsWatch(join(import.meta.dir, "src"), { recursive: true }, (event, filename) => {
join(import.meta.dir, "src"),
{ recursive: true },
(event, filename) => {
if (!filename) return; if (!filename) return;
// ignore non-source files // ignore non-source files
if ( if (!filename.endsWith(".ts") && !filename.endsWith(".tsx") && !filename.endsWith(".css"))
!filename.endsWith(".ts") && return;
!filename.endsWith(".tsx") &&
!filename.endsWith(".css")
)
return;
oldConsole.log(c.cyan("[Watch]"), c.dim(`${event}: ${filename}`)); oldConsole.log(c.cyan("[Watch]"), c.dim(`${event}: ${filename}`));
debouncedRebuild(); debouncedRebuild();
}, });
);
// keep process alive // keep process alive
await new Promise(() => {}); await new Promise(() => {});
} }
+4 -1
View File
@@ -3,7 +3,10 @@ import path from "node:path";
import c from "picocolors"; import c from "picocolors";
const basePath = new URL(import.meta.resolve("../../")).pathname.slice(0, -1); 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( async function run(
cmd: string[] | string, cmd: string[] | string,
+39 -39
View File
@@ -1,53 +1,53 @@
const adapter = process.env.TEST_ADAPTER; const adapter = process.env.TEST_ADAPTER;
const default_config = { const default_config = {
media_adapter: "local", media_adapter: "local",
base_path: "", base_path: "",
} as const; } as const;
const configs = { const configs = {
cloudflare: { cloudflare: {
media_adapter: "r2", media_adapter: "r2",
}, },
"react-router": { "react-router": {
base_path: "/admin", base_path: "/admin",
}, },
nextjs: { nextjs: {
base_path: "/admin", base_path: "/admin",
}, },
nuxt: { nuxt: {
base_path: "/admin", base_path: "/admin",
}, },
"solid-start": { "solid-start": {
base_path: "/admin", base_path: "/admin",
}, },
astro: { astro: {
base_path: "/admin", base_path: "/admin",
}, },
node: { node: {
base_path: "", base_path: "",
}, },
bun: { bun: {
base_path: "", base_path: "",
}, },
"tanstack-start": { "tanstack-start": {
base_path: "/admin", base_path: "/admin",
}, },
}; };
export function getAdapterConfig(): typeof default_config { export function getAdapterConfig(): typeof default_config {
if (adapter) { if (adapter) {
if (!configs[adapter]) { if (!configs[adapter]) {
console.warn( console.warn(
`Adapter "${adapter}" not found. Available adapters: ${Object.keys(configs).join(", ")}`, `Adapter "${adapter}" not found. Available adapters: ${Object.keys(configs).join(", ")}`,
); );
} else { } else {
return { return {
...default_config, ...default_config,
...configs[adapter], ...configs[adapter],
}; };
} }
} }
return default_config; return default_config;
} }
+1 -4
View File
@@ -20,10 +20,7 @@ export async function getApp<Env>(
* @param config - bknd configuration * @param config - bknd configuration
* @param args - environment variables * @param args - environment variables
*/ */
export function serve<Env>( export function serve<Env>(config: NuxtBkndConfig<Env> = {} as NuxtBkndConfig<Env>, args: Env) {
config: NuxtBkndConfig<Env> = {} as NuxtBkndConfig<Env>,
args: Env,
) {
return async (request: Request) => { return async (request: Request) => {
return (await getApp(config, args)).fetch(request); return (await getApp(config, args)).fetch(request);
}; };
@@ -2,8 +2,7 @@ import { createFrameworkApp, type FrameworkBkndConfig } from "bknd/adapter";
export type TanstackStartEnv = NodeJS.ProcessEnv; export type TanstackStartEnv = NodeJS.ProcessEnv;
export type TanstackStartConfig<Env = TanstackStartEnv> = export type TanstackStartConfig<Env = TanstackStartEnv> = FrameworkBkndConfig<Env>;
FrameworkBkndConfig<Env>;
/** /**
* Get bknd app instance * Get bknd app instance
@@ -11,10 +10,10 @@ export type TanstackStartConfig<Env = TanstackStartEnv> =
* @param args - environment variables * @param args - environment variables
*/ */
export async function getApp<Env = TanstackStartEnv>( export async function getApp<Env = TanstackStartEnv>(
config: TanstackStartConfig<Env> = {}, config: TanstackStartConfig<Env> = {},
args: Env = process.env as 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 * @param args - environment variables
*/ */
export function serve<Env = TanstackStartEnv>( export function serve<Env = TanstackStartEnv>(
config: TanstackStartConfig<Env> = {}, config: TanstackStartConfig<Env> = {},
args: Env = process.env as Env, args: Env = process.env as Env,
) { ) {
return async (request: Request) => { return async (request: Request) => {
const app = await getApp(config, args); const app = await getApp(config, args);
return app.fetch(request); return app.fetch(request);
}; };
} }
+1 -3
View File
@@ -26,9 +26,7 @@ export function shouldSkip(c: Context<ServerEnv>, skip?: (string | RegExp)[]) {
return result; return result;
} }
export const auth = (options?: { export const auth = (options?: { skip?: (string | RegExp)[] }) =>
skip?: (string | RegExp)[];
}) =>
createMiddleware<ServerEnv>(async (c, next) => { createMiddleware<ServerEnv>(async (c, next) => {
if (!c.get("auth")) { if (!c.get("auth")) {
c.set("auth", { c.set("auth", {
+41 -45
View File
@@ -4,52 +4,48 @@ import { plunkEmail } from "./plunk";
const ALL_TESTS = !!process.env.ALL_TESTS; const ALL_TESTS = !!process.env.ALL_TESTS;
describe.skipIf(ALL_TESTS)("plunk", () => { describe.skipIf(ALL_TESTS)("plunk", () => {
it("should throw on failed", async () => { it("should throw on failed", async () => {
const driver = plunkEmail({ apiKey: "invalid" }); const driver = plunkEmail({ apiKey: "invalid" });
expect(driver.send("foo@bar.com", "Test", "Test")).rejects.toThrow(); expect(driver.send("foo@bar.com", "Test", "Test")).rejects.toThrow();
}); });
it("should send an email", async () => { it("should send an email", async () => {
const driver = plunkEmail({ const driver = plunkEmail({
apiKey: process.env.PLUNK_API_KEY!, apiKey: process.env.PLUNK_API_KEY!,
from: undefined, // Default to what Plunk sets from: undefined, // Default to what Plunk sets
}); });
const response = await driver.send( const response = await driver.send(
"help@bknd.io", "help@bknd.io",
"Test Email from Plunk", "Test Email from Plunk",
"This is a test email", "This is a test email",
); );
expect(response).toBeDefined(); expect(response).toBeDefined();
expect(response.success).toBe(true); expect(response.success).toBe(true);
expect(response.emails).toBeDefined(); expect(response.emails).toBeDefined();
expect(response.timestamp).toBeDefined(); expect(response.timestamp).toBeDefined();
}); });
it("should send HTML email", async () => { it("should send HTML email", async () => {
const driver = plunkEmail({ const driver = plunkEmail({
apiKey: process.env.PLUNK_API_KEY!, apiKey: process.env.PLUNK_API_KEY!,
from: undefined, from: undefined,
}); });
const htmlBody = "<h1>Test Email</h1><p>This is a test email</p>"; const htmlBody = "<h1>Test Email</h1><p>This is a test email</p>";
const response = await driver.send( const response = await driver.send("help@bknd.io", "HTML Test", htmlBody);
"help@bknd.io", expect(response).toBeDefined();
"HTML Test", expect(response.success).toBe(true);
htmlBody, });
);
expect(response).toBeDefined();
expect(response.success).toBe(true);
});
it("should send with text and html", async () => { it("should send with text and html", async () => {
const driver = plunkEmail({ const driver = plunkEmail({
apiKey: process.env.PLUNK_API_KEY!, apiKey: process.env.PLUNK_API_KEY!,
from: undefined, from: undefined,
}); });
const response = await driver.send("test@example.com", "Test Email", { const response = await driver.send("test@example.com", "Test Email", {
text: "help@bknd.io", text: "help@bknd.io",
html: "<p>This is HTML</p>", html: "<p>This is HTML</p>",
}); });
expect(response).toBeDefined(); expect(response).toBeDefined();
expect(response.success).toBe(true); expect(response.success).toBe(true);
}); });
}); });
+51 -51
View File
@@ -1,70 +1,70 @@
import type { IEmailDriver } from "./index"; import type { IEmailDriver } from "./index";
export type PlunkEmailOptions = { export type PlunkEmailOptions = {
apiKey: string; apiKey: string;
host?: string; host?: string;
from?: string; from?: string;
}; };
export type PlunkEmailSendOptions = { export type PlunkEmailSendOptions = {
subscribed?: boolean; subscribed?: boolean;
name?: string; name?: string;
from?: string; from?: string;
reply?: string; reply?: string;
headers?: Record<string, string>; headers?: Record<string, string>;
}; };
export type PlunkEmailResponse = { export type PlunkEmailResponse = {
success: boolean; success: boolean;
emails: Array<{ emails: Array<{
contact: { contact: {
id: string; id: string;
email: string; email: string;
}; };
email: string; email: string;
}>; }>;
timestamp: string; timestamp: string;
}; };
export const plunkEmail = ( export const plunkEmail = (
config: PlunkEmailOptions, config: PlunkEmailOptions,
): IEmailDriver<PlunkEmailResponse, PlunkEmailSendOptions> => { ): IEmailDriver<PlunkEmailResponse, PlunkEmailSendOptions> => {
const host = config.host ?? "https://api.useplunk.com/v1/send"; const host = config.host ?? "https://api.useplunk.com/v1/send";
const from = config.from; const from = config.from;
return { return {
send: async ( send: async (
to: string, to: string,
subject: string, subject: string,
body: string | { text: string; html: string }, body: string | { text: string; html: string },
options?: PlunkEmailSendOptions, options?: PlunkEmailSendOptions,
) => { ) => {
const payload: any = { const payload: any = {
from, from,
to, to,
subject, subject,
}; };
if (typeof body === "string") { if (typeof body === "string") {
payload.body = body; payload.body = body;
} else { } else {
payload.body = body.html; payload.body = body.html;
} }
const res = await fetch(host, { const res = await fetch(host, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${config.apiKey}`, Authorization: `Bearer ${config.apiKey}`,
}, },
body: JSON.stringify({ ...payload, ...options }), body: JSON.stringify({ ...payload, ...options }),
}); });
if (!res.ok) { if (!res.ok) {
throw new Error(`Plunk API error: ${await res.text()}`); throw new Error(`Plunk API error: ${await res.text()}`);
} }
return (await res.json()) as PlunkEmailResponse; return (await res.json()) as PlunkEmailResponse;
}, },
}; };
}; };
+3 -3
View File
@@ -2,15 +2,15 @@ import { v4, v7, validate, version as uuidVersion } from "uuid";
// generates v4 // generates v4
export function uuid(): string { export function uuid(): string {
return v4(); return v4();
} }
// generates v7 // generates v7
export function uuidv7(): string { export function uuidv7(): string {
return v7(); return v7();
} }
// validate uuid // validate uuid
export function uuidValidate(uuid: string, version: 4 | 7): boolean { export function uuidValidate(uuid: string, version: 4 | 7): boolean {
return validate(uuid) && uuidVersion(uuid) === version; return validate(uuid) && uuidVersion(uuid) === version;
} }
@@ -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 { PostgresIntrospector } from "./PostgresIntrospector";
import { PostgresConnection, plugins } from "./PostgresConnection"; import { PostgresConnection, plugins } from "./PostgresConnection";
import { customIntrospector } from "../Connection"; import { customIntrospector } from "../Connection";
@@ -24,8 +24,6 @@ export class PostgresJsConnection extends PostgresConnection<PostgresJSDialectCo
} }
} }
export function postgresJs( export function postgresJs(config: PostgresJSDialectConfig): PostgresJsConnection {
config: PostgresJSDialectConfig,
): PostgresJsConnection {
return new PostgresJsConnection(config); return new PostgresJsConnection(config);
} }
+4 -1
View File
@@ -194,7 +194,10 @@ export class Entity<
getFields({ getFields({
virtual = false, virtual = false,
primary = true, primary = true,
}: { virtual?: boolean; primary?: boolean } = {}): Field[] { }: {
virtual?: boolean;
primary?: boolean;
} = {}): Field[] {
return this.fields.filter((f) => { return this.fields.filter((f) => {
if (!virtual && f.isVirtual()) return false; if (!virtual && f.isVirtual()) return false;
if (!primary && f instanceof PrimaryField) return false; if (!primary && f instanceof PrimaryField) return false;
+3 -3
View File
@@ -45,9 +45,9 @@ export class EntityManager<TBD extends object = DefaultDB> {
emgr?: EventManager<any>, emgr?: EventManager<any>,
) { ) {
// add entities & relations // add entities & relations
entities.forEach((entity) => this.addEntity(entity)); entities.forEach((entity) => void this.addEntity(entity));
relations.forEach((relation) => this.addRelation(relation)); relations.forEach((relation) => void this.addRelation(relation));
indices.forEach((index) => this.addIndex(index)); indices.forEach((index) => void this.addIndex(index));
if (!Connection.isConnection(connection)) { if (!Connection.isConnection(connection)) {
throw new UnableToConnectException(""); throw new UnableToConnectException("");
+2 -1
View File
@@ -8,7 +8,7 @@ import {
RelationHelper, RelationHelper,
} from "../relations"; } from "../relations";
import type { RepoQuery } from "../server/query"; 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; const directions = ["source", "target"] as const;
export type TDirection = (typeof directions)[number]; export type TDirection = (typeof directions)[number];
@@ -35,6 +35,7 @@ export abstract class EntityRelation<
mappedBy: s.string().optional(), mappedBy: s.string().optional(),
inversedBy: s.string().optional(), inversedBy: s.string().optional(),
required: s.boolean().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 // 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 { EntityRelationAnchor } from "./EntityRelationAnchor";
import { RelationField, type RelationFieldBaseConfig } from "./RelationField"; import { RelationField, type RelationFieldBaseConfig } from "./RelationField";
import type { MutationInstructionResponse } from "./RelationMutator"; 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 * Source entity receives the mapping field
@@ -31,6 +31,7 @@ export class ManyToOneRelation extends EntityRelation<typeof ManyToOneRelation.s
fieldConfig: s fieldConfig: s
.object({ .object({
label: s.string(), label: s.string(),
on_delete: s.string({ enum: RelationCascades }).optional(),
}) })
.optional(), .optional(),
...EntityRelation.schema.properties, ...EntityRelation.schema.properties,
+13 -5
View File
@@ -4,20 +4,22 @@ import type { EntityRelation } from "./EntityRelation";
import type { EntityRelationAnchor } from "./EntityRelationAnchor"; import type { EntityRelationAnchor } from "./EntityRelationAnchor";
import type { TFieldTSType } from "data/entities/EntityTypescript"; import type { TFieldTSType } from "data/entities/EntityTypescript";
import { s } from "bknd/utils"; import { s } from "bknd/utils";
import { DEFAULT_RELATION_CASCADE, RelationCascades, type RelationCascade } from "./relation-types";
const CASCADES = ["cascade", "set null", "set default", "restrict", "no action"] as const;
export const relationFieldConfigSchema = s.strictObject({ export const relationFieldConfigSchema = s.strictObject({
reference: s.string(), reference: s.string(),
target: s.string(), // @todo: potentially has to be an instance! target: s.string(), // @todo: potentially has to be an instance!
target_field: s.string({ default: "id" }).optional(), target_field: s.string({ default: "id" }).optional(),
target_field_type: s.string({ enum: ["text", "integer"], default: "integer" }).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, ...baseFieldConfigSchema.properties,
}); });
export type RelationFieldConfig = s.Static<typeof relationFieldConfigSchema>; 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> { export class RelationField extends Field<RelationFieldConfig> {
override readonly type = "relation"; override readonly type = "relation";
@@ -39,6 +41,8 @@ export class RelationField extends Field<RelationFieldConfig> {
return new RelationField(name, { return new RelationField(name, {
...config, ...config,
required: relation.required, 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, reference: target.reference,
target: target.entity.name, target: target.entity.name,
target_field: target.entity.getPrimaryField().name, target_field: target.entity.getPrimaryField().name,
@@ -50,6 +54,10 @@ export class RelationField extends Field<RelationFieldConfig> {
return this.config.reference; return this.config.reference;
} }
onDelete(): RelationCascade | undefined {
return this.config.on_delete;
}
target() { target() {
return this.config.target; return this.config.target;
} }
@@ -63,7 +71,7 @@ export class RelationField extends Field<RelationFieldConfig> {
...super.schema()!, ...super.schema()!,
type: this.config.target_field_type ?? "integer", type: this.config.target_field_type ?? "integer",
references: `${this.config.target}.${this.config.target_field}`, 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 { OneToOneRelation, type OneToOneRelationConfig } from "./OneToOneRelation";
import { PolymorphicRelation, type PolymorphicRelationConfig } from "./PolymorphicRelation"; import { PolymorphicRelation, type PolymorphicRelationConfig } from "./PolymorphicRelation";
import { type RelationType, RelationTypes } from "./relation-types"; import { type RelationType, RelationTypes } from "./relation-types";
import { DEFAULT_RELATION_CASCADE, RelationCascades, type RelationCascade } from "./relation-types";
export * from "./EntityRelation"; export * from "./EntityRelation";
export * from "./EntityRelationAnchor"; export * from "./EntityRelationAnchor";
@@ -28,6 +29,9 @@ export {
type PolymorphicRelationConfig, type PolymorphicRelationConfig,
RelationTypes, RelationTypes,
type RelationType, type RelationType,
RelationCascades,
type RelationCascade,
DEFAULT_RELATION_CASCADE,
// field // field
RelationField, RelationField,
relationFieldConfigSchema, relationFieldConfigSchema,
+10
View File
@@ -5,3 +5,13 @@ export const RelationTypes = {
Polymorphic: "poly", Polymorphic: "poly",
} as const; } as const;
export type RelationType = (typeof RelationTypes)[keyof typeof RelationTypes]; 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";
+7 -9
View File
@@ -77,14 +77,6 @@ export default function Admin(props: BkndAdminProps) {
); );
} }
function BkndWrapper({ options, children }: { options?: BkndAdminConfig; children: ReactNode }) {
return (
<BkndProvider options={options} fallback={<Skeleton theme={options?.theme} />}>
{children}
</BkndProvider>
);
}
function AdminInner(props: BkndAdminProps) { function AdminInner(props: BkndAdminProps) {
const { theme } = useTheme(); const { theme } = useTheme();
const config = { const config = {
@@ -92,10 +84,16 @@ function AdminInner(props: BkndAdminProps) {
...useBkndWindowContext(), ...useBkndWindowContext(),
}; };
const BkndWrapper = ({ children }: { children: ReactNode }) => (
<BkndProvider options={config} fallback={<Skeleton theme={config?.theme} />}>
{children}
</BkndProvider>
);
return ( return (
<MantineProvider {...createMantineTheme(theme as any)}> <MantineProvider {...createMantineTheme(theme as any)}>
<Notifications position="top-right" /> <Notifications position="top-right" />
<Routes BkndWrapper={BkndWrapper} options={config} basePath={config?.basepath}> <Routes BkndWrapper={BkndWrapper} basePath={config?.basepath}>
{props.children} {props.children}
</Routes> </Routes>
</MantineProvider> </MantineProvider>
+1 -1
View File
@@ -4,7 +4,7 @@ export {
type ClientProviderProps, type ClientProviderProps,
useApi, useApi,
useBaseUrl, useBaseUrl,
useClientContext useClientContext,
} from "./ClientProvider"; } from "./ClientProvider";
export * from "./api/use-api"; export * from "./api/use-api";
-1
View File
@@ -22,7 +22,6 @@ export class AppReduced {
protected appJson: AppType, protected appJson: AppType,
protected _options: BkndAdminProps["config"] = {}, protected _options: BkndAdminProps["config"] = {},
) { ) {
this._entities = Object.entries(this.appJson.data.entities ?? {}).map(([name, entity]) => { this._entities = Object.entries(this.appJson.data.entities ?? {}).map(([name, entity]) => {
return constructEntity(name, entity); return constructEntity(name, entity);
}); });
+5 -1
View File
@@ -4,7 +4,11 @@ export function Logo({
scale = 0.2, scale = 0.2,
fill, fill,
...props ...props
}: { scale?: number; fill?: string; theme?: string }) { }: {
scale?: number;
fill?: string;
theme?: string;
}) {
const t = useTheme(); const t = useTheme();
const theme = props.theme ?? t.theme; const theme = props.theme ?? t.theme;
const svgFill = fill ? fill : theme === "light" ? "black" : "white"; const svgFill = fill ? fill : theme === "light" ? "black" : "white";
@@ -117,7 +117,11 @@ const ArrayAdd = ({
schema, schema,
path: _path, path: _path,
label = "Add", label = "Add",
}: { schema: JsonSchema; path: string; label?: string }) => { }: {
schema: JsonSchema;
path: string;
label?: string;
}) => {
const { const {
setValue, setValue,
value: { currentIndex }, value: { currentIndex },
+6 -1
View File
@@ -64,6 +64,11 @@ export function Step({
path = [], path = [],
id, id,
...rest ...rest
}: { children: React.ReactNode; disabled?: boolean; id: string; path?: string[] }) { }: {
children: React.ReactNode;
disabled?: boolean;
id: string;
path?: string[];
}) {
return <div {...rest}>{children}</div>; return <div {...rest}>{children}</div>;
} }
+6 -1
View File
@@ -35,7 +35,12 @@ export function checkMaxReached({
current = 0, current = 0,
overwrite, overwrite,
added, added,
}: { maxItems?: number; current?: number; overwrite?: boolean; added: number }) { }: {
maxItems?: number;
current?: number;
overwrite?: boolean;
added: number;
}) {
if (!maxItems) { if (!maxItems) {
return { return {
reject: false, reject: false,
+6 -1
View File
@@ -101,7 +101,12 @@ export function useNavigate() {
} }
} }
const _url = options?.absolute ? `~/${app.options.basepath}/${app.options.admin_basepath}${url}`.replace(/\/+/g, "/") : url; const _url = options?.absolute
? `~/${app.options.basepath}/${app.options.admin_basepath}${url}`.replace(
/\/+/g,
"/",
)
: url;
const state = { const state = {
...options?.state, ...options?.state,
referrer: location, referrer: location,
@@ -283,7 +283,10 @@ function EntityJsonFormField({
fieldApi, fieldApi,
field, field,
...props ...props
}: { fieldApi: TFieldApi; field: JsonField }) { }: {
fieldApi: TFieldApi;
field: JsonField;
}) {
const [error, setError] = useState<any>(null); const [error, setError] = useState<any>(null);
const handleUpdate = useEvent((value: any) => { const handleUpdate = useEvent((value: any) => {
setError(null); setError(null);
@@ -312,7 +315,10 @@ function EntityEnumFormField({
fieldApi, fieldApi,
field, field,
...props ...props
}: { fieldApi: TFieldApi; field: EnumField }) { }: {
fieldApi: TFieldApi;
field: EnumField;
}) {
const handleUpdate = useEvent((e: React.ChangeEvent<HTMLTextAreaElement>) => { const handleUpdate = useEvent((e: React.ChangeEvent<HTMLTextAreaElement>) => {
fieldApi.handleChange(e.target.value); fieldApi.handleChange(e.target.value);
}); });
@@ -1,6 +1,12 @@
import { Switch, TextInput } from "@mantine/core"; import { Switch, TextInput } from "@mantine/core";
import { IconDatabase } from "@tabler/icons-react"; 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 { ReactNode } from "react";
import { type Control, type FieldValues, type UseFormRegister, useForm } from "react-hook-form"; import { type Control, type FieldValues, type UseFormRegister, useForm } from "react-hook-form";
import { TbRefresh } from "react-icons/tb"; import { TbRefresh } from "react-icons/tb";
@@ -46,7 +52,9 @@ const schema = s.strictObject({
type: s.string({ enum: Relations.map((r) => r.type) }), type: s.string({ enum: Relations.map((r) => r.type) }),
source: stringIdentifier, source: stringIdentifier,
target: stringIdentifier, target: stringIdentifier,
config: s.object({}), config: s.object({
on_delete: s.string({ enum: RelationCascades }).optional(),
}),
}); });
type ComponentCtx<T extends FieldValues = FieldValues> = { 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> <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) { function ManyToOne({ register, control, data: { source, target, config } }: ComponentCtx) {
return ( return (
<> <>
@@ -209,6 +236,7 @@ function ManyToOne({ register, control, data: { source, target, config } }: Comp
control={control} control={control}
placeholder={String(ManyToOneRelation.DEFAULTS.with_limit)} placeholder={String(ManyToOneRelation.DEFAULTS.with_limit)}
/> />
<OnDelete control={control} />
</div> </div>
<div /> <div />
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
@@ -222,30 +250,32 @@ function ManyToOne({ register, control, data: { source, target, config } }: Comp
</div> </div>
{source && target && config && ( {source && target && config && (
<Callout> <Callout>
<> <pre className="mb-2 opacity-70 flex flex-row items-center gap-2">
<pre className="mb-2 opacity-70 flex flex-row items-center gap-2"> <IconDatabase className="size-4" />
<IconDatabase className="size-4" /> {source}.{config.mappedBy || target}_id {"→"} {target}
{source}.{config.mappedBy || target}_id {"→"} {target} </pre>
</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> <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>
<p> ) : null}
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}
</>
</Callout> </Callout>
)} )}
</> </>
@@ -258,7 +288,7 @@ function OneToOne({
data: { data: {
source, source,
target, target,
config: { mappedBy, required }, config: { mappedBy, required, on_delete },
}, },
}: ComponentCtx) { }: ComponentCtx) {
return ( return (
@@ -271,6 +301,7 @@ function OneToOne({
placeholder={target} placeholder={target}
/> />
<Switch label="Required" {...register("config.required")} /> <Switch label="Required" {...register("config.required")} />
<OnDelete control={control} />
</div> </div>
<div /> <div />
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
@@ -283,24 +314,26 @@ function OneToOne({
</div> </div>
{source && target && ( {source && target && (
<Callout> <Callout>
<> <pre className="mb-2 opacity-70 flex flex-row items-center gap-2">
<pre className="mb-2 opacity-70 flex flex-row items-center gap-2"> <IconDatabase className="size-4" />
<IconDatabase className="size-4" /> {source}.{mappedBy || target}_id {"↔"} {target}
{source}.{mappedBy || target}_id {"↔"} {target} </pre>
</pre> <p>
<p> A single entry of <Pre>{source}</Pre> will have a reference to <Pre>{target}</Pre>
A single entry of <Pre>{source}</Pre> will have a reference to{" "} .
<Pre>{target}</Pre>. </p>
</p> <p>
<p> A property <Pre>{mappedBy || target}_id</Pre> will be added to <Pre>{source}</Pre>{" "}
A property <Pre>{mappedBy || target}_id</Pre> will be added to{" "} (which references <Pre>{target}</Pre>).
<Pre>{source}</Pre> (which references <Pre>{target}</Pre>). </p>
</p> <p>
<p> When creating <Pre>{source}</Pre>, a reference to <Pre>{target}</Pre> is{" "}
When creating <Pre>{source}</Pre>, a reference to <Pre>{target}</Pre> is{" "} <i>{required ? "required" : "optional"}</i>.
<i>{required ? "required" : "optional"}</i>. </p>
</p> <p>
</> If a <Pre>{target}</Pre> entry is deleted, the reference is{" "}
<i>{on_delete ?? DEFAULT_RELATION_CASCADE}</i>.
</p>
</Callout> </Callout>
)} )}
</> </>
@@ -334,6 +367,7 @@ function ManyToMany({ register, control, data: { source, target, config } }: Com
{...register("config.connectionTableMappedName")} {...register("config.connectionTableMappedName")}
placeholder={table} placeholder={table}
/> />
<OnDelete control={control} label="On delete (both references)" />
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/*<TextInput {/*<TextInput
@@ -345,18 +379,20 @@ function ManyToMany({ register, control, data: { source, target, config } }: Com
</div> </div>
{source && target && ( {source && target && (
<Callout> <Callout>
<> <pre className="mb-2 opacity-70 flex flex-row items-center gap-2">
<pre className="mb-2 opacity-70 flex flex-row items-center gap-2"> <IconDatabase className="size-4" />
<IconDatabase className="size-4" /> {source} {"→"} {table} {"←"} {target}
{source} {"→"} {table} {"←"} {target} </pre>
</pre> <p>
<p> Many <Pre>{source}</Pre> will have many <Pre>{target}</Pre>.
Many <Pre>{source}</Pre> will have many <Pre>{target}</Pre>. </p>
</p> <p>
<p> A connection table <Pre>{table}</Pre> will be created to store the relations.
A connection table <Pre>{table}</Pre> will be created to store the relations. </p>
</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> </Callout>
)} )}
</> </>
@@ -391,26 +427,24 @@ function Polymorphic({ register, control, data: { type, source, target, config }
</div> </div>
{source && target && ( {source && target && (
<Callout> <Callout>
<> <pre className="mb-2 opacity-70 flex flex-row items-center gap-2">
<pre className="mb-2 opacity-70 flex flex-row items-center gap-2"> <IconDatabase className="size-4" />
<IconDatabase className="size-4" /> {source} {"←"} {target}
{source} {"←"} {target} </pre>
</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> <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>
<p> ) : null}
<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}
</>
</Callout> </Callout>
)} )}
</> </>
+11 -2
View File
@@ -110,7 +110,12 @@ const EntityLinkList = ({
title, title,
context, context,
suggestCreate = false, suggestCreate = false,
}: { entities: Entity[]; title?: string; context: "data" | "schema"; suggestCreate?: boolean }) => { }: {
entities: Entity[];
title?: string;
context: "data" | "schema";
suggestCreate?: boolean;
}) => {
const { $data } = useBkndData(); const { $data } = useBkndData();
const { readonly } = useBknd(); const { readonly } = useBknd();
const navigate = useRouteNavigate(); const navigate = useRouteNavigate();
@@ -195,7 +200,11 @@ const EntityContextMenu = ({
entity, entity,
children, children,
enabled = true, enabled = true,
}: { entity: Entity; children: DropdownClickableChild; enabled?: boolean }) => { }: {
entity: Entity;
children: DropdownClickableChild;
enabled?: boolean;
}) => {
if (!enabled) return children; if (!enabled) return children;
const [navigate] = useNavigate(); const [navigate] = useNavigate();
const { $data } = useBkndData(); const { $data } = useBkndData();
+2 -5
View File
@@ -12,7 +12,6 @@ import { FlashMessage } from "ui/modules/server/FlashMessage";
import { AuthRegister } from "ui/routes/auth/auth.register"; import { AuthRegister } from "ui/routes/auth/auth.register";
import { BkndModalsProvider } from "ui/modals"; import { BkndModalsProvider } from "ui/modals";
import { useBkndWindowContext } from "bknd/client"; import { useBkndWindowContext } from "bknd/client";
import type { BkndAdminProps } from "ui/Admin";
import ToolsRoutes from "./tools"; import ToolsRoutes from "./tools";
// @ts-ignore // @ts-ignore
@@ -25,12 +24,10 @@ try {
export function Routes({ export function Routes({
BkndWrapper, BkndWrapper,
options,
basePath = "", basePath = "",
children, children,
}: { }: {
BkndWrapper: ComponentType<{ children: ReactNode; options?: BkndAdminProps["config"] }>; BkndWrapper: ComponentType<{ children: ReactNode }>;
options?: BkndAdminProps["config"];
basePath?: string; basePath?: string;
children?: ReactNode; children?: ReactNode;
}) { }) {
@@ -46,7 +43,7 @@ export function Routes({
<Route path="/auth/login" component={AuthLogin} /> <Route path="/auth/login" component={AuthLogin} />
<Route path="/auth/register" component={AuthRegister} /> <Route path="/auth/register" component={AuthRegister} />
<BkndWrapper options={options}> <BkndWrapper>
<BkndModalsProvider> <BkndModalsProvider>
<Route path="/" nest> <Route path="/" nest>
<Root> <Root>
@@ -66,7 +66,10 @@ const relationAnyOfValues = {
export const DataSettings = ({ export const DataSettings = ({
schema, schema,
config, config,
}: { schema: ModuleSchemas["data"]; config: ModuleConfigs["data"] }) => { }: {
schema: ModuleSchemas["data"];
config: ModuleConfigs["data"];
}) => {
const { app, readonly } = useBknd(); const { app, readonly } = useBknd();
const prefix = app.getAbsolutePath("settings"); const prefix = app.getAbsolutePath("settings");
const entities = Object.keys(config.entities ?? {}); const entities = Object.keys(config.entities ?? {});
@@ -69,11 +69,7 @@ function CustomUserAvatarDropzone() {
showPlaceholder, showPlaceholder,
actions: { openFileInput }, actions: { openFileInput },
} = Media.useDropzone(); } = Media.useDropzone();
const { const { isOver, isOverAccepted, files: [file] = [] } = Media.useDropzoneState();
isOver,
isOverAccepted,
files: [file] = [],
} = Media.useDropzoneState();
return ( return (
<div <div
+3 -1
View File
@@ -98,7 +98,9 @@ input[disabled]::placeholder {
} }
.flex-animate { .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-initial {
flex: 0 1 auto; flex: 0 1 auto;
+1 -1
View File
@@ -1 +1 @@
declare module '*.css'; declare module "*.css";
@@ -71,7 +71,7 @@ export type NextjsBkndConfig<Env = NextjsEnv> = FrameworkBkndConfig<Env> & {
## Serve the API ## 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" ```ts title="src/bknd.ts"
import { import {
@@ -127,31 +127,80 @@ export const DELETE = handler;
``` ```
## Enabling the Admin UI ## 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`: Create a page at `admin/[[...admin]]/page.tsx`:
```tsx title="admin/[[...admin]]/page.tsx" ```tsx title="admin/[[...admin]]/page.tsx"
import { Admin } from "bknd/ui";
import { getApi } from "@/bknd"; import { getApi } from "@/bknd";
import "bknd/dist/styles.css"; import "bknd/dist/styles.css";
import { Admin } from "./page.client";
import { Suspense } from "react";
import { redirect } from "next/navigation";
export default async function AdminPage() { export default async function AdminPage() {
// make sure to verify auth using headers // make sure to verify auth using headers
const api = await getApi({ verify: true }); const api = await getApi({ verify: true });
// early return if not logged in
if (!api.getUser()) {
return redirect("/unauthorized");
}
return ( return (
<Admin <Suspense>
withProvider={{ user: api.getUser() }} <Admin
config={{ withProvider={{ user: api.getUser() }}
basepath: "/admin", config={{
logo_return_path: "/../", basepath: "/admin",
theme: "system", 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 ## 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: 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;
```