Merge remote-tracking branch 'origin/main' into feat/jsonv-refactor

# Conflicts:
#	bun.lock
This commit is contained in:
dswbx
2025-07-05 11:11:06 +02:00
16 changed files with 114 additions and 62 deletions
+8
View File
@@ -9,10 +9,18 @@
</p> </p>
bknd simplifies app development by providing a fully functional backend for database management, authentication, media and workflows. Being lightweight and built on Web Standards, it can be deployed nearly anywhere, including running inside your framework of choice. No more deploying multiple separate services! bknd simplifies app development by providing a fully functional backend for database management, authentication, media and workflows. Being lightweight and built on Web Standards, it can be deployed nearly anywhere, including running inside your framework of choice. No more deploying multiple separate services!
* **Runtimes**: Node.js 22+, Bun 1.0+, Deno, Browser, Cloudflare Workers/Pages, Vercel, Netlify, AWS Lambda, etc.
* **Databases**:
* SQLite: LibSQL, Node SQLite, Bun SQLite, Cloudflare D1, Cloudflare Durable Objects SQLite, SQLocal
* Postgres: Vanilla Postgres, Supabase, Neon, Xata
* **Frameworks**: React, Next.js, React Router, Astro, Vite, Waku
* **Storage**: AWS S3, S3-compatible (Tigris, R2, Minio, etc.), Cloudflare R2 (binding), Cloudinary, Filesystem
**For documentation and examples, please visit https://docs.bknd.io.** **For documentation and examples, please visit https://docs.bknd.io.**
> [!WARNING] > [!WARNING]
> This project requires Node.js 22 or higher (because of `node:sqlite`).
>
> Please keep in mind that **bknd** is still under active development > Please keep in mind that **bknd** is still under active development
> and therefore full backward compatibility is not guaranteed before reaching v1.0.0. > and therefore full backward compatibility is not guaranteed before reaching v1.0.0.
+1 -1
View File
@@ -3,7 +3,7 @@
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,
"bin": "./dist/cli/index.js", "bin": "./dist/cli/index.js",
"version": "0.15.0-rc.8", "version": "0.15.0",
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.", "description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
"homepage": "https://bknd.io", "homepage": "https://bknd.io",
"repository": { "repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
import type { Connection } from "bknd/data"; import type { Connection } from "bknd/data";
import { bunSqlite } from "../bun/connection/BunSqliteConnection"; import { bunSqlite } from "../bun/connection/BunSqliteConnection";
export function sqlite(config: { url: string }): Connection { export function sqlite(config?: { url: string }): Connection {
return bunSqlite(config); return bunSqlite(config);
} }
+1 -1
View File
@@ -1,6 +1,6 @@
import type { Connection } from "bknd/data"; import type { Connection } from "bknd/data";
import { nodeSqlite } from "../node/connection/NodeSqliteConnection"; import { nodeSqlite } from "../node/connection/NodeSqliteConnection";
export function sqlite(config: { url: string }): Connection { export function sqlite(config?: { url: string }): Connection {
return nodeSqlite(config); return nodeSqlite(config);
} }
+8 -4
View File
@@ -334,9 +334,9 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
await setSignedCookie(c, "auth", token, secret, this.cookieOptions); await setSignedCookie(c, "auth", token, secret, this.cookieOptions);
} }
private async deleteAuthCookie(c: Context) { private deleteAuthCookie(c: Context) {
$console.debug("deleting auth cookie"); $console.debug("deleting auth cookie");
await deleteCookie(c, "auth", this.cookieOptions); deleteCookie(c, "auth", this.cookieOptions);
} }
async logout(c: Context<ServerEnv>) { async logout(c: Context<ServerEnv>) {
@@ -345,9 +345,13 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
const cookie = await this.getAuthCookie(c); const cookie = await this.getAuthCookie(c);
if (cookie) { if (cookie) {
await this.deleteAuthCookie(c); addFlashMessage(c, "Signed out", "info");
await addFlashMessage(c, "Signed out", "info");
} }
// on waku, only one cookie setting is performed
// therefore adding deleting cookie at the end
// as the flash isn't that important
this.deleteAuthCookie(c);
} }
// @todo: move this to a server helper // @todo: move this to a server helper
@@ -1,4 +1,4 @@
import type { Client, Config, ResultSet } from "@libsql/client"; import type { Client, Config, InStatement, ResultSet, TransactionMode } from "@libsql/client";
import { createClient } from "libsql-stateless-easy"; import { createClient } from "libsql-stateless-easy";
import { FilterNumericKeysPlugin } from "data/plugins/FilterNumericKeysPlugin"; import { FilterNumericKeysPlugin } from "data/plugins/FilterNumericKeysPlugin";
import { import {
@@ -10,7 +10,12 @@ import type { QueryResult } from "kysely";
export type LibsqlConnection = GenericSqliteConnection<Client>; export type LibsqlConnection = GenericSqliteConnection<Client>;
export type LibSqlCredentials = Config; export type LibSqlCredentials = Config;
function getClient(clientOrCredentials: Client | LibSqlCredentials): Client { export type LibsqlClientFns = {
execute: (statement: InStatement) => Promise<ResultSet>;
batch: (statements: InStatement[], mode?: TransactionMode) => Promise<ResultSet[]>;
};
function getClient(clientOrCredentials: Client | LibSqlCredentials | LibsqlClientFns): Client {
if (clientOrCredentials && "url" in clientOrCredentials) { if (clientOrCredentials && "url" in clientOrCredentials) {
const { url, authToken } = clientOrCredentials; const { url, authToken } = clientOrCredentials;
return createClient({ url, authToken }); return createClient({ url, authToken });
@@ -19,7 +24,7 @@ function getClient(clientOrCredentials: Client | LibSqlCredentials): Client {
return clientOrCredentials as Client; return clientOrCredentials as Client;
} }
export function libsql(config: LibSqlCredentials | Client) { export function libsql(config: LibSqlCredentials | Client | LibsqlClientFns) {
const db = getClient(config); const db = getClient(config);
return genericSqlite( return genericSqlite(
+2 -2
View File
@@ -51,7 +51,7 @@ export class AdminController extends Controller {
basepath: this._options.basepath ?? "/", basepath: this._options.basepath ?? "/",
adminBasepath: this._options.adminBasepath ?? "", adminBasepath: this._options.adminBasepath ?? "",
assetsPath: this._options.assetsPath ?? config.server.assets_path, assetsPath: this._options.assetsPath ?? config.server.assets_path,
theme: this._options.theme ?? "system", //theme: this._options.theme ?? "system",
logo_return_path: this._options.logoReturnPath ?? "/", logo_return_path: this._options.logoReturnPath ?? "/",
}; };
} }
@@ -195,7 +195,7 @@ export class AdminController extends Controller {
if (isProd) { if (isProd) {
let manifest: any; let manifest: any;
if (this.options.assetsPath.startsWith("http")) { if (this.options.assetsPath.startsWith("http")) {
manifest = await fetch(this.options.assetsPath + "manifest.json", { manifest = await fetch(this.options.assetsPath + ".vite/manifest.json", {
headers: { headers: {
Accept: "application/json", Accept: "application/json",
}, },
+1 -1
View File
@@ -55,7 +55,7 @@ export default function Admin({
const Skeleton = ({ theme }: { theme?: any }) => { const Skeleton = ({ theme }: { theme?: any }) => {
const t = useTheme(); const t = useTheme();
const actualTheme = theme ?? t.theme; const actualTheme = theme && ["dark", "light"].includes(theme) ? theme : t.theme;
return ( return (
<div id="bknd-admin" className={actualTheme + " antialiased"}> <div id="bknd-admin" className={actualTheme + " antialiased"}>
+3 -17
View File
@@ -4,6 +4,7 @@ import { Link, useLocation } from "wouter";
import { IconButton } from "../../components/buttons/IconButton"; import { IconButton } from "../../components/buttons/IconButton";
import { Dropdown } from "../../components/overlay/Dropdown"; import { Dropdown } from "../../components/overlay/Dropdown";
import { useEvent } from "../../hooks/use-event"; import { useEvent } from "../../hooks/use-event";
import { useNavigate } from "ui/lib/routes";
type Breadcrumb = { type Breadcrumb = {
label: string | Element; label: string | Element;
@@ -17,26 +18,11 @@ export type Breadcrumbs2Props = {
}; };
export const Breadcrumbs2 = ({ path: _path, backTo, onBack }: Breadcrumbs2Props) => { export const Breadcrumbs2 = ({ path: _path, backTo, onBack }: Breadcrumbs2Props) => {
const [_, navigate] = useLocation(); const [, , _goBack] = useNavigate();
const location = window.location.pathname;
const path = Array.isArray(_path) ? _path : [_path]; const path = Array.isArray(_path) ? _path : [_path];
const loc = location.split("/").filter((v) => v !== "");
const hasBack = path.length > 1; const hasBack = path.length > 1;
const goBack = onBack const goBack = onBack ? onBack : () => _goBack({ fallback: backTo });
? onBack
: useEvent(() => {
if (backTo) {
navigate(backTo, { replace: true });
return;
} else if (_path.length > 0 && _path[0]?.href) {
navigate(_path[0].href, { replace: true });
return;
}
const href = loc.slice(0, path.length + 1).join("/");
navigate(`~/${href}`, { replace: true });
});
const crumbs = useMemo( const crumbs = useMemo(
() => () =>
+1 -3
View File
@@ -185,9 +185,7 @@ function UserMenu() {
} }
} }
if (!options.theme) { items.push(() => <UserMenuThemeToggler />);
items.push(() => <UserMenuThemeToggler />);
}
items.push(() => ( items.push(() => (
<div className="font-mono leading-none text-xs text-primary/50 text-center pb-1 pt-2 mt-1 border-t border-primary/5"> <div className="font-mono leading-none text-xs text-primary/50 text-center pb-1 pt-2 mt-1 border-t border-primary/5">
{getVersion()} {getVersion()}
+17 -1
View File
@@ -102,13 +102,29 @@ export function useNavigate() {
} }
const _url = options?.absolute ? `~/${basepath}${url}`.replace(/\/+/g, "/") : url; const _url = options?.absolute ? `~/${basepath}${url}`.replace(/\/+/g, "/") : url;
const state = {
...options?.state,
referrer: location,
};
navigate(options?.query ? withQuery(_url, options?.query) : _url, { navigate(options?.query ? withQuery(_url, options?.query) : _url, {
replace: options?.replace, replace: options?.replace,
state: options?.state, state,
}); });
}); });
}, },
location, location,
(opts?: { fallback?: string }) => {
const state = window.history.state;
if (state?.referrer) {
//window.history.replaceState(state, "", state.referrer);
navigate(state.referrer, { replace: true });
} else if (opts?.fallback) {
navigate(opts.fallback, { replace: true });
} else {
window.history.back();
}
},
] as const; ] as const;
} }
+5 -8
View File
@@ -31,7 +31,7 @@ function DataEntityUpdateImpl({ params }) {
const entityId = params.id as PrimaryFieldType; const entityId = params.id as PrimaryFieldType;
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [navigate] = useNavigate(); const [navigate, _, _goBack] = useNavigate();
useBrowserTitle(["Data", entity.label, `#${entityId}`]); useBrowserTitle(["Data", entity.label, `#${entityId}`]);
const targetRelations = relations.listableRelationsOf(entity); const targetRelations = relations.listableRelationsOf(entity);
@@ -52,9 +52,8 @@ function DataEntityUpdateImpl({ params }) {
}, },
); );
function goBack() { const backHref = routes.data.entity.list(entity.name);
window.history.go(-1); const goBack = () => _goBack({ fallback: backHref });
}
async function onSubmitted(changeSet?: EntityData) { async function onSubmitted(changeSet?: EntityData) {
//return; //return;
@@ -162,10 +161,8 @@ function DataEntityUpdateImpl({ params }) {
className="pl-3" className="pl-3"
> >
<Breadcrumbs2 <Breadcrumbs2
path={[ backTo={backHref}
{ label: entity.label, href: routes.data.entity.list(entity.name) }, path={[{ label: entity.label, href: backHref }, { label: `Edit #${entityId}` }]}
{ label: `Edit #${entityId}` },
]}
/> />
</AppShell.SectionHeader> </AppShell.SectionHeader>
{$q.isLoading ? ( {$q.isLoading ? (
+5 -10
View File
@@ -8,13 +8,14 @@ import { useBrowserTitle } from "ui/hooks/use-browser-title";
import { useSearch } from "ui/hooks/use-search"; import { useSearch } from "ui/hooks/use-search";
import * as AppShell from "ui/layouts/AppShell/AppShell"; import * as AppShell from "ui/layouts/AppShell/AppShell";
import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2"; import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2";
import { routes } from "ui/lib/routes"; import { routes, useNavigate } from "ui/lib/routes";
import { EntityForm } from "ui/modules/data/components/EntityForm"; import { EntityForm } from "ui/modules/data/components/EntityForm";
import { useEntityForm } from "ui/modules/data/hooks/useEntityForm"; import { useEntityForm } from "ui/modules/data/hooks/useEntityForm";
import { s } from "core/object/schema"; import { s } from "core/object/schema";
export function DataEntityCreate({ params }) { export function DataEntityCreate({ params }) {
const { $data } = useBkndData(); const { $data } = useBkndData();
const [navigate, _, _goBack] = useNavigate();
const entity = $data.entity(params.entity as string); const entity = $data.entity(params.entity as string);
if (!entity) { if (!entity) {
return <Message.NotFound description={`Entity "${params.entity}" doesn't exist.`} />; return <Message.NotFound description={`Entity "${params.entity}" doesn't exist.`} />;
@@ -30,9 +31,8 @@ export function DataEntityCreate({ params }) {
// @todo: use entity schema for prefilling // @todo: use entity schema for prefilling
const search = useSearch(s.object({}), {}); const search = useSearch(s.object({}), {});
function goBack() { const backHref = routes.data.entity.list(entity.name);
window.history.go(-1); const goBack = () => _goBack({ fallback: backHref });
}
async function onSubmitted(changeSet?: EntityData) { async function onSubmitted(changeSet?: EntityData) {
console.log("create:changeSet", changeSet); console.log("create:changeSet", changeSet);
@@ -80,12 +80,7 @@ export function DataEntityCreate({ params }) {
</> </>
} }
> >
<Breadcrumbs2 <Breadcrumbs2 backTo={backHref} path={[{ label: entity.label }, { label: "Create" }]} />
path={[
{ label: entity.label, href: routes.data.entity.list(entity.name) },
{ label: "Create" },
]}
/>
</AppShell.SectionHeader> </AppShell.SectionHeader>
<AppShell.Scrollable key={entity.name}> <AppShell.Scrollable key={entity.name}>
{error && ( {error && (
+4 -3
View File
@@ -15,7 +15,7 @@
}, },
"app": { "app": {
"name": "bknd", "name": "bknd",
"version": "0.15.0-rc.8", "version": "0.15.0",
"bin": "./dist/cli/index.js", "bin": "./dist/cli/index.js",
"dependencies": { "dependencies": {
"@cfworker/json-schema": "^4.1.1", "@cfworker/json-schema": "^4.1.1",
@@ -125,6 +125,7 @@
"version": "0.5.1", "version": "0.5.1",
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
"bknd": "workspace:*",
"tsdx": "^0.14.1", "tsdx": "^0.14.1",
"typescript": "^5.0.0", "typescript": "^5.0.0",
}, },
@@ -1220,7 +1221,7 @@
"@types/babel__traverse": ["@types/babel__traverse@7.20.6", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg=="], "@types/babel__traverse": ["@types/babel__traverse@7.20.6", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg=="],
"@types/bun": ["@types/bun@1.2.17", "", { "dependencies": { "bun-types": "1.2.17" } }, "sha512-l/BYs/JYt+cXA/0+wUhulYJB6a6p//GTPiJ7nV+QHa8iiId4HZmnu/3J/SowP5g0rTiERY2kfGKXEK5Ehltx4Q=="], "@types/bun": ["@types/bun@1.2.18", "", { "dependencies": { "bun-types": "1.2.18" } }, "sha512-Xf6RaWVheyemaThV0kUfaAUvCNokFr+bH8Jxp+tTZfx7dAPA8z9ePnP9S9+Vspzuxxx9JRAXhnyccRj3GyCMdQ=="],
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
@@ -4026,7 +4027,7 @@
"@testing-library/jest-dom/chalk": ["chalk@3.0.0", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg=="], "@testing-library/jest-dom/chalk": ["chalk@3.0.0", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg=="],
"@types/bun/bun-types": ["bun-types@1.2.17", "", { "dependencies": { "@types/node": "*" } }, "sha512-ElC7ItwT3SCQwYZDYoAH+q6KT4Fxjl8DtZ6qDulUFBmXA8YB4xo+l54J9ZJN+k2pphfn9vk7kfubeSd5QfTVJQ=="], "@types/bun/bun-types": ["bun-types@1.2.18", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-04+Eha5NP7Z0A9YgDAzMk5PHR16ZuLVa83b26kH5+cp1qZW4F6FmAURngE7INf4tKOvCE69vYvDEwoNl1tGiWw=="],
"@types/pg/pg-types": ["pg-types@4.0.2", "", { "dependencies": { "pg-int8": "1.0.1", "pg-numeric": "1.0.2", "postgres-array": "~3.0.1", "postgres-bytea": "~3.0.0", "postgres-date": "~2.1.0", "postgres-interval": "^3.0.0", "postgres-range": "^1.1.1" } }, "sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng=="], "@types/pg/pg-types": ["pg-types@4.0.2", "", { "dependencies": { "pg-int8": "1.0.1", "pg-numeric": "1.0.2", "postgres-array": "~3.0.1", "postgres-bytea": "~3.0.0", "postgres-date": "~2.1.0", "postgres-interval": "^3.0.0", "postgres-range": "^1.1.1" } }, "sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng=="],
+41 -1
View File
@@ -36,4 +36,44 @@ docker run -p 1337:1337 -e ARGS="--db-url file:/data/data.db" bknd
To mount the data directory to the host, you can use the `-v` flag: To mount the data directory to the host, you can use the `-v` flag:
```bash ```bash
docker run -p 1337:1337 -v /path/to/data:/data bknd docker run -p 1337:1337 -v /path/to/data:/data bknd
``` ```
## Docker compose example
If you want to use docker compose and build the image directly from the git repository.
```yaml compose.yml
services:
bknd:
pull_policy: build
build: https://github.com/bknd-io/bknd.git#main:docker
ports:
- 1337:1337
environment:
ARGS: "--db-url file:/data/data.db"
volumes:
- ${DATA_DIR:-.}/data:/data
```
The docker compose file can be extended to build a specific version of bknd.
Extend the `build` section with `args` and `labels`.
Inside `args`, you can pass a `VERSION` build argument, and use `labels` so the built image receives a unique identifier.
```yaml compose.yml
services:
bknd:
pull_policy: build
build:
context: https://github.com/bknd-io/bknd.git#main:docker
args:
VERSION: <version>
labels:
- x-bknd-version=<version>
ports:
- 1337:1337
environment:
ARGS: "--db-url file:/data/data.db"
volumes:
- ${DATA_DIR:-.}/data:/data
```
+8 -6
View File
@@ -107,8 +107,8 @@ import { libsql } from "bknd/data";
export default { export default {
connection: libsql({ connection: libsql({
url: "libsql://your-database-url.turso.io", url: "libsql://<database>.turso.io",
authToken: "your-auth-token", authToken: "<auth-token>",
}), }),
} as const satisfies BkndConfig; } as const satisfies BkndConfig;
``` ```
@@ -120,11 +120,13 @@ import type { BkndConfig } from "bknd";
import { libsql } from "bknd/data"; import { libsql } from "bknd/data";
import { createClient } from "@libsql/client"; import { createClient } from "@libsql/client";
const client = createClient({
url: "libsql://<database>.turso.io",
authToken: "<auth-token>",
})
export default { export default {
connection: libsql(createClient({ connection: libsql(client),
url: "libsql://your-database-url.turso.io",
authToken: "your-auth-token",
})),
} as const satisfies BkndConfig; } as const satisfies BkndConfig;
``` ```