Compare commits

..

2 Commits

Author SHA1 Message Date
dswbx f18072fe8e docs: updated react-router and react sdk 2025-11-25 11:09:59 +01:00
dswbx 821525fba5 update bun to 1.3.3 and add typescript native preview
Bumps bun version to 1.3.3 in configs and workflow. Adds `@typescript/native-preview` dependency for enhanced TypeScript support. Updates linker setting in `bunfig.toml` for isolated installations.
2025-11-22 19:54:31 +01:00
147 changed files with 2449 additions and 3625 deletions
+1 -16
View File
@@ -9,21 +9,6 @@ jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
POSTGRES_DB: bknd
ports:
- 5430:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -39,7 +24,7 @@ jobs:
- name: Install dependencies - name: Install dependencies
working-directory: ./app working-directory: ./app
run: bun install #--linker=hoisted run: bun install
- name: Build - name: Build
working-directory: ./app working-directory: ./app
+1 -1
View File
@@ -17,7 +17,7 @@ It's designed to avoid vendor lock-in and architectural limitations. Built exclu
* SQLite: LibSQL, Node SQLite, Bun SQLite, Cloudflare D1, Cloudflare Durable Objects SQLite, SQLocal * SQLite: LibSQL, Node SQLite, Bun SQLite, Cloudflare D1, Cloudflare Durable Objects SQLite, SQLocal
* Postgres: Vanilla Postgres, Supabase, Neon, Xata * Postgres: Vanilla Postgres, Supabase, Neon, Xata
* **Frameworks**: React, Next.js, React Router, Astro, Vite, Waku * **Frameworks**: React, Next.js, React Router, Astro, Vite, Waku
* **Storage**: AWS S3, S3-compatible (Tigris, R2, Minio, etc.), Cloudflare R2 (binding), Cloudinary, Filesystem, Origin Private File System (OPFS) * **Storage**: AWS S3, S3-compatible (Tigris, R2, Minio, etc.), Cloudflare R2 (binding), Cloudinary, Filesystem
* **Deployment**: Standalone, Docker, Cloudflare Workers, Vercel, Netlify, Deno Deploy, AWS Lambda, Valtown etc. * **Deployment**: Standalone, Docker, Cloudflare Workers, Vercel, Netlify, Deno Deploy, AWS Lambda, Valtown etc.
**For documentation and examples, please visit https://docs.bknd.io.** **For documentation and examples, please visit https://docs.bknd.io.**
+6 -3
View File
@@ -67,7 +67,7 @@ describe("MediaApi", () => {
const res = await mockedBackend.request("/api/media/file/" + name); const res = await mockedBackend.request("/api/media/file/" + name);
await Bun.write(path, res); await Bun.write(path, res);
const file = Bun.file(path); const file = await Bun.file(path);
expect(file.size).toBeGreaterThan(0); expect(file.size).toBeGreaterThan(0);
expect(file.type).toBe("image/png"); expect(file.type).toBe("image/png");
await file.delete(); await file.delete();
@@ -154,12 +154,15 @@ describe("MediaApi", () => {
} }
// upload via readable from bun // upload via readable from bun
await matches(api.upload(file.stream(), { filename: "readable.png" }), "readable.png"); await matches(await api.upload(file.stream(), { filename: "readable.png" }), "readable.png");
// upload via readable from response // upload via readable from response
{ {
const response = (await mockedBackend.request(url)) as Response; const response = (await mockedBackend.request(url)) as Response;
await matches(api.upload(response.body!, { filename: "readable.png" }), "readable.png"); await matches(
await api.upload(response.body!, { filename: "readable.png" }),
"readable.png",
);
} }
}); });
}); });
+1 -5
View File
@@ -1,12 +1,8 @@
import { describe, expect, mock, test, beforeAll, afterAll } from "bun:test"; import { describe, expect, mock, test } from "bun:test";
import { createApp as internalCreateApp, type CreateAppConfig } from "bknd"; import { createApp as internalCreateApp, type CreateAppConfig } from "bknd";
import { getDummyConnection } from "../../__test__/helper"; import { getDummyConnection } from "../../__test__/helper";
import { ModuleManager } from "modules/ModuleManager"; import { ModuleManager } from "modules/ModuleManager";
import { em, entity, text } from "data/prototype"; import { em, entity, text } from "data/prototype";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
async function createApp(config: CreateAppConfig = {}) { async function createApp(config: CreateAppConfig = {}) {
const app = internalCreateApp({ const app = internalCreateApp({
+1 -5
View File
@@ -1,11 +1,7 @@
import { AppEvents } from "App"; import { AppEvents } from "App";
import { describe, test, expect, beforeAll, mock, afterAll } from "bun:test"; import { describe, test, expect, beforeAll, mock } from "bun:test";
import { type App, createApp, createMcpToolCaller } from "core/test/utils"; import { type App, createApp, createMcpToolCaller } from "core/test/utils";
import type { McpServer } from "bknd/utils"; import type { McpServer } from "bknd/utils";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
/** /**
* - [x] system_config * - [x] system_config
@@ -1,12 +1,8 @@
import { describe, expect, test, beforeAll, afterAll } from "bun:test"; import { describe, expect, test } from "bun:test";
import { Guard, type GuardConfig } from "auth/authorize/Guard"; import { Guard, type GuardConfig } from "auth/authorize/Guard";
import { Permission } from "auth/authorize/Permission"; import { Permission } from "auth/authorize/Permission";
import { Role, type RoleSchema } from "auth/authorize/Role"; import { Role, type RoleSchema } from "auth/authorize/Role";
import { objectTransform, s } from "bknd/utils"; import { objectTransform, s } from "bknd/utils";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
function createGuard( function createGuard(
permissionNames: string[], permissionNames: string[],
@@ -7,8 +7,8 @@ import type { App, DB } from "bknd";
import type { CreateUserPayload } from "auth/AppAuth"; import type { CreateUserPayload } from "auth/AppAuth";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog); beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog); afterAll(() => enableConsoleLog());
async function makeApp(config: Partial<CreateAppConfig["config"]> = {}) { async function makeApp(config: Partial<CreateAppConfig["config"]> = {}) {
const app = createApp({ const app = createApp({
@@ -1,40 +0,0 @@
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { createAuthTestApp } from "./shared";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import { em, entity, text } from "data/prototype";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
const schema = em(
{
posts: entity("posts", {
title: text(),
content: text(),
}),
comments: entity("comments", {
content: text(),
}),
},
({ relation }, { posts, comments }) => {
relation(posts).manyToOne(comments);
},
);
describe("DataController (auth)", () => {
test("reading schema.json", async () => {
const { request } = await createAuthTestApp(
{
permission: ["system.access.api", "data.entity.read", "system.schema.read"],
request: new Request("http://localhost/api/data/schema.json"),
},
{
config: { data: schema.toJSON() },
},
);
expect((await request.guest()).status).toBe(403);
expect((await request.member()).status).toBe(403);
expect((await request.authorized()).status).toBe(200);
expect((await request.admin()).status).toBe(200);
});
});
@@ -0,0 +1,20 @@
import { describe, it, expect } from "bun:test";
import { SystemController } from "modules/server/SystemController";
import { createApp } from "core/test/utils";
import type { CreateAppConfig } from "App";
import { getPermissionRoutes } from "auth/middlewares/permission.middleware";
async function makeApp(config: Partial<CreateAppConfig> = {}) {
const app = createApp(config);
await app.build();
return app;
}
describe.skip("SystemController", () => {
it("...", async () => {
const app = await makeApp();
const controller = new SystemController(app);
const hono = controller.getController();
console.log(getPermissionRoutes(hono));
});
});
@@ -1,41 +0,0 @@
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { createAuthTestApp } from "./shared";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("SystemController (auth)", () => {
test("reading info", async () => {
const { request } = await createAuthTestApp({
permission: ["system.access.api", "system.info"],
request: new Request("http://localhost/api/system/info"),
});
expect((await request.guest()).status).toBe(403);
expect((await request.member()).status).toBe(403);
expect((await request.authorized()).status).toBe(200);
expect((await request.admin()).status).toBe(200);
});
test("reading permissions", async () => {
const { request } = await createAuthTestApp({
permission: ["system.access.api", "system.schema.read"],
request: new Request("http://localhost/api/system/permissions"),
});
expect((await request.guest()).status).toBe(403);
expect((await request.member()).status).toBe(403);
expect((await request.authorized()).status).toBe(200);
expect((await request.admin()).status).toBe(200);
});
test("access openapi", async () => {
const { request } = await createAuthTestApp({
permission: ["system.access.api", "system.openapi"],
request: new Request("http://localhost/api/system/openapi.json"),
});
expect((await request.guest()).status).toBe(403);
expect((await request.member()).status).toBe(403);
expect((await request.authorized()).status).toBe(200);
expect((await request.admin()).status).toBe(200);
});
});
-171
View File
@@ -1,171 +0,0 @@
import { createApp } from "core/test/utils";
import type { CreateAppConfig } from "App";
import type { RoleSchema } from "auth/authorize/Role";
import { isPlainObject } from "core/utils";
export type AuthTestConfig = {
guest?: RoleSchema;
member?: RoleSchema;
authorized?: RoleSchema;
};
export async function createAuthTestApp(
testConfig: {
permission: AuthTestConfig | string | string[];
request: Request;
},
config: Partial<CreateAppConfig> = {},
) {
let member: RoleSchema | undefined;
let authorized: RoleSchema | undefined;
let guest: RoleSchema | undefined;
if (isPlainObject(testConfig.permission)) {
if (testConfig.permission.guest)
guest = {
...testConfig.permission.guest,
is_default: true,
};
if (testConfig.permission.member) member = testConfig.permission.member;
if (testConfig.permission.authorized) authorized = testConfig.permission.authorized;
} else {
member = {
permissions: [],
};
authorized = {
permissions: Array.isArray(testConfig.permission)
? testConfig.permission
: [testConfig.permission],
};
guest = {
permissions: [],
is_default: true,
};
}
console.log("authorized", authorized);
const app = createApp({
...config,
config: {
...config.config,
auth: {
...config.config?.auth,
enabled: true,
guard: {
enabled: true,
...config.config?.auth?.guard,
},
jwt: {
...config.config?.auth?.jwt,
secret: "secret",
},
roles: {
...config.config?.auth?.roles,
guest,
member,
authorized,
admin: {
implicit_allow: true,
},
},
},
},
});
await app.build();
const users = {
guest: null,
member: await app.createUser({
email: "member@test.com",
password: "12345678",
role: "member",
}),
authorized: await app.createUser({
email: "authorized@test.com",
password: "12345678",
role: "authorized",
}),
admin: await app.createUser({
email: "admin@test.com",
password: "12345678",
role: "admin",
}),
} as const;
const tokens = {} as Record<keyof typeof users, string>;
for (const [key, user] of Object.entries(users)) {
if (user) {
tokens[key as keyof typeof users] = await app.module.auth.authenticator.jwt(user);
}
}
async function makeRequest(user: keyof typeof users, input: string, init: RequestInit = {}) {
const headers = new Headers(init.headers ?? {});
if (user in tokens) {
headers.set("Authorization", `Bearer ${tokens[user as keyof typeof tokens]}`);
}
const res = await app.server.request(input, {
...init,
headers,
});
let data: any;
if (res.headers.get("Content-Type")?.startsWith("application/json")) {
data = await res.json();
} else if (res.headers.get("Content-Type")?.startsWith("text/")) {
data = await res.text();
}
return {
status: res.status,
ok: res.ok,
headers: Object.fromEntries(res.headers.entries()),
data,
};
}
const requestFn = new Proxy(
{},
{
get(_, prop: keyof typeof users) {
return async (input: string, init: RequestInit = {}) => {
return makeRequest(prop, input, init);
};
},
},
) as {
[K in keyof typeof users]: (
input: string,
init?: RequestInit,
) => Promise<{
status: number;
ok: boolean;
headers: Record<string, string>;
data: any;
}>;
};
const request = new Proxy(
{},
{
get(_, prop: keyof typeof users) {
return async () => {
return makeRequest(prop, testConfig.request.url, {
headers: testConfig.request.headers,
method: testConfig.request.method,
body: testConfig.request.body,
});
};
},
},
) as {
[K in keyof typeof users]: () => Promise<{
status: number;
ok: boolean;
headers: Record<string, string>;
data: any;
}>;
};
return { app, users, request, requestFn };
}
-85
View File
@@ -1,85 +0,0 @@
import { describe, beforeAll, afterAll, test } from "bun:test";
import type { PostgresConnection } from "data/connection/postgres/PostgresConnection";
import { pg, postgresJs } from "bknd";
import { Pool } from "pg";
import postgres from "postgres";
import { disableConsoleLog, enableConsoleLog, $waitUntil } from "bknd/utils";
import { $ } from "bun";
import { connectionTestSuite } from "data/connection/connection-test-suite";
import { bunTestRunner } from "adapter/bun/test";
const credentials = {
host: "localhost",
port: 5430,
user: "postgres",
password: "postgres",
database: "bknd",
};
async function cleanDatabase(connection: InstanceType<typeof PostgresConnection>) {
const kysely = connection.kysely;
// drop all tables+indexes & create new schema
await kysely.schema.dropSchema("public").ifExists().cascade().execute();
await kysely.schema.dropIndex("public").ifExists().cascade().execute();
await kysely.schema.createSchema("public").execute();
}
async function isPostgresRunning() {
try {
// Try to actually connect to PostgreSQL
const conn = pg({ pool: new Pool(credentials) });
await conn.ping();
await conn.close();
return true;
} catch (e) {
return false;
}
}
describe("postgres", () => {
beforeAll(async () => {
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);
await new Promise((resolve) => setTimeout(resolve, 500));
}
disableConsoleLog();
});
afterAll(async () => {
if (await isPostgresRunning()) {
try {
await $`docker stop bknd-test-postgres`;
} catch (e) {}
}
enableConsoleLog();
});
describe.serial.each([
["pg", () => pg({ pool: new Pool(credentials) })],
["postgresjs", () => postgresJs({ postgres: postgres(credentials) })],
])("%s", (name, createConnection) => {
connectionTestSuite(
{
...bunTestRunner,
test: test.serial,
},
{
makeConnection: () => {
const connection = createConnection();
return {
connection,
dispose: async () => {
await cleanDatabase(connection);
await connection.close();
},
};
},
rawDialectDetails: [],
disableConsoleLog: false,
},
);
});
});
@@ -124,81 +124,6 @@ describe("[Repository]", async () => {
.then((r) => [r.count, r.total]), .then((r) => [r.count, r.total]),
).resolves.toEqual([undefined, undefined]); ).resolves.toEqual([undefined, undefined]);
}); });
test("auto join", async () => {
const schema = $em(
{
posts: $entity("posts", {
title: $text(),
content: $text(),
}),
comments: $entity("comments", {
content: $text(),
}),
another: $entity("another", {
title: $text(),
}),
},
({ relation }, { posts, comments }) => {
relation(comments).manyToOne(posts);
},
);
const em = schema.proto.withConnection(getDummyConnection().dummyConnection);
await em.schema().sync({ force: true });
await em.mutator("posts").insertOne({ title: "post1", content: "content1" });
await em
.mutator("comments")
.insertMany([{ content: "comment1", posts_id: 1 }, { content: "comment2" }] as any);
const res = await em.repo("comments").findMany({
where: {
"posts.title": "post1",
},
});
expect(res.data as any).toEqual([
{
id: 1,
content: "comment1",
posts_id: 1,
},
]);
{
// manual join should still work
const res = await em.repo("comments").findMany({
join: ["posts"],
where: {
"posts.title": "post1",
},
});
expect(res.data as any).toEqual([
{
id: 1,
content: "comment1",
posts_id: 1,
},
]);
}
// inexistent should be detected and thrown
expect(
em.repo("comments").findMany({
where: {
"random.title": "post1",
},
}),
).rejects.toThrow(/Invalid where field/);
// existing alias, but not a relation should throw
expect(
em.repo("comments").findMany({
where: {
"another.title": "post1",
},
}),
).rejects.toThrow(/Invalid where field/);
});
}); });
describe("[data] Repository (Events)", async () => { describe("[data] Repository (Events)", async () => {
@@ -59,7 +59,7 @@ describe("SqliteIntrospector", () => {
dataType: "INTEGER", dataType: "INTEGER",
isNullable: false, isNullable: false,
isAutoIncrementing: true, isAutoIncrementing: true,
hasDefaultValue: true, hasDefaultValue: false,
comment: undefined, comment: undefined,
}, },
{ {
@@ -89,7 +89,7 @@ describe("SqliteIntrospector", () => {
dataType: "INTEGER", dataType: "INTEGER",
isNullable: false, isNullable: false,
isAutoIncrementing: true, isAutoIncrementing: true,
hasDefaultValue: true, hasDefaultValue: false,
comment: undefined, comment: undefined,
}, },
{ {
+1 -1
View File
@@ -10,7 +10,7 @@ import { assetsPath, assetsTmpPath } from "../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => { beforeAll(() => {
disableConsoleLog(); //disableConsoleLog();
registries.media.register("local", StorageLocalAdapter); registries.media.register("local", StorageLocalAdapter);
}); });
afterAll(enableConsoleLog); afterAll(enableConsoleLog);
+12
View File
@@ -10,6 +10,12 @@ beforeAll(disableConsoleLog);
afterAll(enableConsoleLog); afterAll(enableConsoleLog);
describe("AppAuth", () => { describe("AppAuth", () => {
test.skip("...", () => {
const auth = new AppAuth({});
console.log(auth.toJSON());
console.log(auth.config);
});
moduleTestSuite(AppAuth); moduleTestSuite(AppAuth);
let ctx: ModuleBuildContext; let ctx: ModuleBuildContext;
@@ -33,9 +39,11 @@ describe("AppAuth", () => {
await auth.build(); await auth.build();
const oldConfig = auth.toJSON(true); const oldConfig = auth.toJSON(true);
//console.log(oldConfig);
await auth.schema().patch("enabled", true); await auth.schema().patch("enabled", true);
await auth.build(); await auth.build();
const newConfig = auth.toJSON(true); const newConfig = auth.toJSON(true);
//console.log(newConfig);
expect(newConfig.jwt.secret).not.toBe(oldConfig.jwt.secret); expect(newConfig.jwt.secret).not.toBe(oldConfig.jwt.secret);
}); });
@@ -61,6 +69,7 @@ describe("AppAuth", () => {
const app = new AuthController(auth).getController(); const app = new AuthController(auth).getController();
{ {
disableConsoleLog();
const res = await app.request("/password/register", { const res = await app.request("/password/register", {
method: "POST", method: "POST",
headers: { headers: {
@@ -71,6 +80,7 @@ describe("AppAuth", () => {
password: "12345678", password: "12345678",
}), }),
}); });
enableConsoleLog();
expect(res.status).toBe(200); expect(res.status).toBe(200);
const { data: users } = await ctx.em.repository("users").findMany(); const { data: users } = await ctx.em.repository("users").findMany();
@@ -109,6 +119,7 @@ describe("AppAuth", () => {
const app = new AuthController(auth).getController(); const app = new AuthController(auth).getController();
{ {
disableConsoleLog();
const res = await app.request("/password/register", { const res = await app.request("/password/register", {
method: "POST", method: "POST",
headers: { headers: {
@@ -119,6 +130,7 @@ describe("AppAuth", () => {
password: "12345678", password: "12345678",
}), }),
}); });
enableConsoleLog();
expect(res.status).toBe(200); expect(res.status).toBe(200);
const { data: users } = await ctx.em.repository("users").findMany(); const { data: users } = await ctx.em.repository("users").findMany();
+1 -5
View File
@@ -1,14 +1,10 @@
import { describe, expect, test, beforeAll, afterAll } from "bun:test"; import { describe, expect, test } from "bun:test";
import { createApp } from "core/test/utils"; import { createApp } from "core/test/utils";
import { em, entity, text } from "data/prototype"; import { em, entity, text } from "data/prototype";
import { registries } from "modules/registries"; import { registries } from "modules/registries";
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter"; import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
import { AppMedia } from "../../src/media/AppMedia"; import { AppMedia } from "../../src/media/AppMedia";
import { moduleTestSuite } from "./module-test-suite"; import { moduleTestSuite } from "./module-test-suite";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("AppMedia", () => { describe("AppMedia", () => {
test.skip("...", () => { test.skip("...", () => {
+1 -5
View File
@@ -1,11 +1,7 @@
import { it, expect, describe, beforeAll, afterAll } from "bun:test"; import { it, expect, describe } from "bun:test";
import { DbModuleManager } from "modules/db/DbModuleManager"; import { DbModuleManager } from "modules/db/DbModuleManager";
import { getDummyConnection } from "../helper"; import { getDummyConnection } from "../helper";
import { TABLE_NAME } from "modules/db/migrations"; import { TABLE_NAME } from "modules/db/migrations";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("DbModuleManager", () => { describe("DbModuleManager", () => {
it("should extract secrets", async () => { it("should extract secrets", async () => {
+12 -1
View File
@@ -11,7 +11,7 @@ import { s, stripMark } from "core/utils/schema";
import { Connection } from "data/connection/Connection"; import { Connection } from "data/connection/Connection";
import { entity, text } from "data/prototype"; import { entity, text } from "data/prototype";
beforeAll(() => disableConsoleLog()); beforeAll(disableConsoleLog);
afterAll(enableConsoleLog); afterAll(enableConsoleLog);
describe("ModuleManager", async () => { describe("ModuleManager", async () => {
@@ -82,6 +82,7 @@ describe("ModuleManager", async () => {
}, },
}, },
} as any; } as any;
//const { version, ...json } = mm.toJSON() as any;
const { dummyConnection } = getDummyConnection(); const { dummyConnection } = getDummyConnection();
const db = dummyConnection.kysely; const db = dummyConnection.kysely;
@@ -96,6 +97,10 @@ describe("ModuleManager", async () => {
await mm2.build(); await mm2.build();
/* console.log({
json,
configs: mm2.configs(),
}); */
//expect(stripMark(json)).toEqual(stripMark(mm2.configs())); //expect(stripMark(json)).toEqual(stripMark(mm2.configs()));
expect(mm2.configs().data.entities?.test).toBeDefined(); expect(mm2.configs().data.entities?.test).toBeDefined();
expect(mm2.configs().data.entities?.test?.fields?.content).toBeDefined(); expect(mm2.configs().data.entities?.test?.fields?.content).toBeDefined();
@@ -223,6 +228,8 @@ describe("ModuleManager", async () => {
const c = getDummyConnection(); const c = getDummyConnection();
const mm = new ModuleManager(c.dummyConnection); const mm = new ModuleManager(c.dummyConnection);
await mm.build(); await mm.build();
console.log("==".repeat(30));
console.log("");
const json = mm.configs(); const json = mm.configs();
const c2 = getDummyConnection(); const c2 = getDummyConnection();
@@ -268,6 +275,7 @@ describe("ModuleManager", async () => {
} }
override async build() { override async build() {
//console.log("building FailingModule", this.config);
if (this.config.value && this.config.value < 0) { if (this.config.value && this.config.value < 0) {
throw new Error("value must be positive, given: " + this.config.value); throw new Error("value must be positive, given: " + this.config.value);
} }
@@ -288,6 +296,9 @@ describe("ModuleManager", async () => {
} }
} }
beforeEach(() => disableConsoleLog(["log", "warn", "error"]));
afterEach(enableConsoleLog);
test("it builds", async () => { test("it builds", async () => {
const { dummyConnection } = getDummyConnection(); const { dummyConnection } = getDummyConnection();
const mm = new TestModuleManager(dummyConnection); const mm = new TestModuleManager(dummyConnection);
+1 -12
View File
@@ -3,17 +3,7 @@ import c from "picocolors";
import { formatNumber } from "bknd/utils"; import { formatNumber } from "bknd/utils";
const deps = Object.keys(pkg.dependencies); const deps = Object.keys(pkg.dependencies);
const external = [ const external = ["jsonv-ts/*", "wrangler", "bknd", "bknd/*", ...deps];
"jsonv-ts/*",
"wrangler",
"bknd",
"bknd/*",
"@vitejs/plugin-react",
"vite",
"@tailwindcss/vite",
"@cloudflare/vite-plugin",
...deps,
];
const result = await Bun.build({ const result = await Bun.build({
entrypoints: ["./src/cli/index.ts"], entrypoints: ["./src/cli/index.ts"],
@@ -21,7 +11,6 @@ const result = await Bun.build({
outdir: "./dist/cli", outdir: "./dist/cli",
env: "PUBLIC_*", env: "PUBLIC_*",
minify: true, minify: true,
banner: `const __originalLog=console.log;console.log=(...o)=>{const n=o[0];"string"==typeof n&&n.includes("[dotenv@")||__originalLog.apply(console,o)};`,
external, external,
define: { define: {
__isDev: "0", __isDev: "0",
-8
View File
@@ -186,9 +186,6 @@ async function buildUiElements() {
outDir: "dist/ui/elements", outDir: "dist/ui/elements",
external: [ external: [
"ui/client", "ui/client",
"bknd",
/^bknd\/.*/,
"wouter",
"react", "react",
"react-dom", "react-dom",
"react/jsx-runtime", "react/jsx-runtime",
@@ -268,11 +265,6 @@ async function buildAdapters() {
// specific adatpers // specific adatpers
tsup.build(baseConfig("react-router")), tsup.build(baseConfig("react-router")),
tsup.build(
baseConfig("browser", {
external: [/^sqlocal\/?.*?/, "wouter"],
}),
),
tsup.build( tsup.build(
baseConfig("bun", { baseConfig("bun", {
external: [/^bun\:.*/], external: [/^bun\:.*/],
+3 -14
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.20.0-rc.1", "version": "0.19.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": {
@@ -65,7 +65,7 @@
"hono": "4.10.4", "hono": "4.10.4",
"json-schema-library": "10.0.0-rc7", "json-schema-library": "10.0.0-rc7",
"json-schema-to-ts": "^3.1.1", "json-schema-to-ts": "^3.1.1",
"jsonv-ts": "^0.10.1", "jsonv-ts": "0.9.3",
"kysely": "0.28.8", "kysely": "0.28.8",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1", "oauth4webapi": "^2.11.1",
@@ -80,7 +80,6 @@
"@aws-sdk/client-s3": "^3.922.0", "@aws-sdk/client-s3": "^3.922.0",
"@bluwy/giget-core": "^0.1.6", "@bluwy/giget-core": "^0.1.6",
"@clack/prompts": "^0.11.0", "@clack/prompts": "^0.11.0",
"@cloudflare/vite-plugin": "^1.15.3",
"@cloudflare/vitest-pool-workers": "^0.10.4", "@cloudflare/vitest-pool-workers": "^0.10.4",
"@cloudflare/workers-types": "^4.20251014.0", "@cloudflare/workers-types": "^4.20251014.0",
"@dagrejs/dagre": "^1.1.4", "@dagrejs/dagre": "^1.1.4",
@@ -100,7 +99,6 @@
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0", "@testing-library/react": "^16.2.0",
"@types/node": "^24.10.0", "@types/node": "^24.10.0",
"@types/pg": "^8.15.6",
"@types/react": "^19.0.10", "@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^5.1.0", "@vitejs/plugin-react": "^5.1.0",
@@ -112,17 +110,14 @@
"jotai": "^2.12.2", "jotai": "^2.12.2",
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"kysely-generic-sqlite": "^1.2.1", "kysely-generic-sqlite": "^1.2.1",
"kysely-postgres-js": "^2.0.0",
"libsql": "^0.5.22", "libsql": "^0.5.22",
"libsql-stateless-easy": "^1.8.0", "libsql-stateless-easy": "^1.8.0",
"miniflare": "^4.20251011.2", "miniflare": "^4.20251011.2",
"open": "^10.2.0", "open": "^10.2.0",
"openapi-types": "^12.1.3", "openapi-types": "^12.1.3",
"pg": "^8.16.3",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"postcss-preset-mantine": "^1.18.0", "postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1", "postcss-simple-vars": "^7.0.1",
"postgres": "^3.4.7",
"posthog-js-lite": "^3.6.0", "posthog-js-lite": "^3.6.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@@ -130,7 +125,6 @@
"react-icons": "5.5.0", "react-icons": "5.5.0",
"react-json-view-lite": "^2.5.0", "react-json-view-lite": "^2.5.0",
"sql-formatter": "^15.6.10", "sql-formatter": "^15.6.10",
"sqlocal": "^0.16.0",
"tailwind-merge": "^3.0.2", "tailwind-merge": "^3.0.2",
"tailwindcss": "^4.1.16", "tailwindcss": "^4.1.16",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
@@ -143,7 +137,7 @@
"vite-tsconfig-paths": "^5.1.4", "vite-tsconfig-paths": "^5.1.4",
"vitest": "3.0.9", "vitest": "3.0.9",
"wouter": "^3.7.1", "wouter": "^3.7.1",
"wrangler": "^4.52.1" "wrangler": "^4.45.4"
}, },
"optionalDependencies": { "optionalDependencies": {
"@hono/node-server": "^1.19.6" "@hono/node-server": "^1.19.6"
@@ -259,11 +253,6 @@
"import": "./dist/adapter/aws/index.js", "import": "./dist/adapter/aws/index.js",
"require": "./dist/adapter/aws/index.js" "require": "./dist/adapter/aws/index.js"
}, },
"./adapter/browser": {
"types": "./dist/types/adapter/browser/index.d.ts",
"import": "./dist/adapter/browser/index.js",
"require": "./dist/adapter/browser/index.js"
},
"./dist/main.css": "./dist/ui/main.css", "./dist/main.css": "./dist/ui/main.css",
"./dist/styles.css": "./dist/ui/styles.css", "./dist/styles.css": "./dist/ui/styles.css",
"./dist/manifest.json": "./dist/static/.vite/manifest.json", "./dist/manifest.json": "./dist/static/.vite/manifest.json",
+1 -1
View File
@@ -61,7 +61,7 @@ export class Api {
private token?: string; private token?: string;
private user?: TApiUser; private user?: TApiUser;
private verified = false; private verified = false;
public token_transport: "header" | "cookie" | "none" = "header"; private token_transport: "header" | "cookie" | "none" = "header";
public system!: SystemApi; public system!: SystemApi;
public data!: DataApi; public data!: DataApi;
-153
View File
@@ -1,153 +0,0 @@
import {
createContext,
lazy,
Suspense,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
import { checksum } from "bknd/utils";
import { App, registries, sqlocal, type BkndConfig } from "bknd";
import { Route, Router, Switch } from "wouter";
import { ClientProvider } from "bknd/client";
import { SQLocalKysely } from "sqlocal/kysely";
import type { ClientConfig, DatabasePath } from "sqlocal";
import { OpfsStorageAdapter } from "bknd/adapter/browser";
import type { BkndAdminConfig } from "bknd/ui";
const Admin = lazy(() =>
Promise.all([
import("bknd/ui"),
// @ts-ignore
import("bknd/dist/styles.css"),
]).then(([mod]) => ({
default: mod.Admin,
})),
);
function safeViewTransition(fn: () => void) {
if (document.startViewTransition) {
document.startViewTransition(fn);
} else {
fn();
}
}
export type BrowserBkndConfig<Args = ImportMetaEnv> = Omit<
BkndConfig<Args>,
"connection" | "app"
> & {
adminConfig?: BkndAdminConfig;
connection?: ClientConfig | DatabasePath;
};
export type BkndBrowserAppProps = {
children: ReactNode;
header?: ReactNode;
loading?: ReactNode;
notFound?: ReactNode;
} & BrowserBkndConfig;
const BkndBrowserAppContext = createContext<{
app: App;
hash: string;
}>(undefined!);
export function BkndBrowserApp({
children,
adminConfig,
header,
loading,
notFound,
...config
}: BkndBrowserAppProps) {
const [app, setApp] = useState<App | undefined>(undefined);
const [hash, setHash] = useState<string>("");
const adminRoutePath = (adminConfig?.basepath ?? "") + "/*?";
async function onBuilt(app: App) {
safeViewTransition(async () => {
setApp(app);
setHash(await checksum(app.toJSON()));
});
}
useEffect(() => {
setup({ ...config, adminConfig })
.then((app) => onBuilt(app as any))
.catch(console.error);
}, []);
if (!app) {
return (
loading ?? (
<Center>
<span style={{ opacity: 0.2 }}>Loading...</span>
</Center>
)
);
}
return (
<BkndBrowserAppContext.Provider value={{ app, hash }}>
<ClientProvider storage={window.localStorage} fetcher={app.server.request}>
{header}
<Router key={hash}>
<Switch>
{children}
<Route path={adminRoutePath}>
<Suspense>
<Admin config={adminConfig} />
</Suspense>
</Route>
<Route path="*">
{notFound ?? (
<Center style={{ fontSize: "48px", fontFamily: "monospace" }}>404</Center>
)}
</Route>
</Switch>
</Router>
</ClientProvider>
</BkndBrowserAppContext.Provider>
);
}
export function useApp() {
return useContext(BkndBrowserAppContext);
}
const Center = (props: React.HTMLAttributes<HTMLDivElement>) => (
<div
{...props}
style={{
width: "100%",
minHeight: "100vh",
display: "flex",
justifyContent: "center",
alignItems: "center",
...(props.style ?? {}),
}}
/>
);
let initialized = false;
async function setup(config: BrowserBkndConfig = {}) {
if (initialized) return;
initialized = true;
registries.media.register("opfs", OpfsStorageAdapter);
const app = App.create({
...config,
// @ts-ignore
connection: sqlocal(new SQLocalKysely(config.connection ?? ":localStorage:")),
});
await config.beforeBuild?.(app);
await app.build({ sync: true });
await config.onBuilt?.(app);
return app;
}
@@ -1,34 +0,0 @@
import { describe, beforeAll, vi, afterAll, spyOn } from "bun:test";
import { OpfsStorageAdapter } from "./OpfsStorageAdapter";
// @ts-ignore
import { assetsPath } from "../../../__test__/helper";
import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test";
import { MockFileSystemDirectoryHandle } from "adapter/browser/mock";
describe("OpfsStorageAdapter", async () => {
let mockRoot: MockFileSystemDirectoryHandle;
let testSuiteAdapter: OpfsStorageAdapter;
const _mock = spyOn(global, "navigator");
beforeAll(() => {
// mock navigator.storage.getDirectory()
mockRoot = new MockFileSystemDirectoryHandle("opfs-root");
const mockNavigator = {
storage: {
getDirectory: vi.fn().mockResolvedValue(mockRoot),
},
};
// @ts-ignore
_mock.mockReturnValue(mockNavigator);
testSuiteAdapter = new OpfsStorageAdapter();
});
afterAll(() => {
_mock.mockRestore();
});
const file = Bun.file(`${assetsPath}/image.png`);
await adapterTestSuite(bunTestRunner, () => testSuiteAdapter, file);
});
@@ -1,265 +0,0 @@
import type { FileBody, FileListObject, FileMeta, FileUploadPayload } from "bknd";
import { StorageAdapter, guessMimeType } from "bknd";
import { parse, s, isFile, isBlob } from "bknd/utils";
export const opfsAdapterConfig = s.object(
{
root: s.string({ default: "" }).optional(),
},
{
title: "OPFS",
description: "Origin Private File System storage",
additionalProperties: false,
},
);
export type OpfsAdapterConfig = s.Static<typeof opfsAdapterConfig>;
/**
* Storage adapter for OPFS (Origin Private File System)
* Provides browser-based file storage using the File System Access API
*/
export class OpfsStorageAdapter extends StorageAdapter {
private config: OpfsAdapterConfig;
private rootPromise: Promise<FileSystemDirectoryHandle>;
constructor(config: Partial<OpfsAdapterConfig> = {}) {
super();
this.config = parse(opfsAdapterConfig, config);
this.rootPromise = this.initializeRoot();
}
private async initializeRoot(): Promise<FileSystemDirectoryHandle> {
const opfsRoot = await navigator.storage.getDirectory();
if (!this.config.root) {
return opfsRoot;
}
// navigate to or create nested directory structure
const parts = this.config.root.split("/").filter(Boolean);
let current = opfsRoot;
for (const part of parts) {
current = await current.getDirectoryHandle(part, { create: true });
}
return current;
}
getSchema() {
return opfsAdapterConfig;
}
getName(): string {
return "opfs";
}
async listObjects(prefix?: string): Promise<FileListObject[]> {
const root = await this.rootPromise;
const files: FileListObject[] = [];
for await (const [name, handle] of root.entries()) {
if (handle.kind === "file") {
if (!prefix || name.startsWith(prefix)) {
const file = await (handle as FileSystemFileHandle).getFile();
files.push({
key: name,
last_modified: new Date(file.lastModified),
size: file.size,
});
}
}
}
return files;
}
private async computeEtagFromArrayBuffer(buffer: ArrayBuffer): Promise<string> {
const hashBuffer = await crypto.subtle.digest("SHA-256", buffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map((byte) => byte.toString(16).padStart(2, "0")).join("");
// wrap the hex string in quotes for ETag format
return `"${hashHex}"`;
}
async putObject(key: string, body: FileBody): Promise<string | FileUploadPayload> {
if (body === null) {
throw new Error("Body is empty");
}
const root = await this.rootPromise;
const fileHandle = await root.getFileHandle(key, { create: true });
const writable = await fileHandle.createWritable();
try {
let contentBuffer: ArrayBuffer;
if (isFile(body)) {
contentBuffer = await body.arrayBuffer();
await writable.write(contentBuffer);
} else if (body instanceof ReadableStream) {
const chunks: Uint8Array[] = [];
const reader = body.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
await writable.write(value);
}
} finally {
reader.releaseLock();
}
// compute total size and combine chunks for etag
const totalSize = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const combined = new Uint8Array(totalSize);
let offset = 0;
for (const chunk of chunks) {
combined.set(chunk, offset);
offset += chunk.length;
}
contentBuffer = combined.buffer;
} else if (isBlob(body)) {
contentBuffer = await (body as Blob).arrayBuffer();
await writable.write(contentBuffer);
} else {
// body is ArrayBuffer or ArrayBufferView
if (ArrayBuffer.isView(body)) {
const view = body as ArrayBufferView;
contentBuffer = view.buffer.slice(
view.byteOffset,
view.byteOffset + view.byteLength,
) as ArrayBuffer;
} else {
contentBuffer = body as ArrayBuffer;
}
await writable.write(body);
}
await writable.close();
return await this.computeEtagFromArrayBuffer(contentBuffer);
} catch (error) {
await writable.abort();
throw error;
}
}
async deleteObject(key: string): Promise<void> {
try {
const root = await this.rootPromise;
await root.removeEntry(key);
} catch {
// file doesn't exist, which is fine
}
}
async objectExists(key: string): Promise<boolean> {
try {
const root = await this.rootPromise;
await root.getFileHandle(key);
return true;
} catch {
return false;
}
}
private parseRangeHeader(
rangeHeader: string,
fileSize: number,
): { start: number; end: number } | null {
// parse "bytes=start-end" format
const match = rangeHeader.match(/^bytes=(\d*)-(\d*)$/);
if (!match) return null;
const [, startStr, endStr] = match;
let start = startStr ? Number.parseInt(startStr, 10) : 0;
let end = endStr ? Number.parseInt(endStr, 10) : fileSize - 1;
// handle suffix-byte-range-spec (e.g., "bytes=-500")
if (!startStr && endStr) {
start = Math.max(0, fileSize - Number.parseInt(endStr, 10));
end = fileSize - 1;
}
// validate range
if (start < 0 || end >= fileSize || start > end) {
return null;
}
return { start, end };
}
async getObject(key: string, headers: Headers): Promise<Response> {
try {
const root = await this.rootPromise;
const fileHandle = await root.getFileHandle(key);
const file = await fileHandle.getFile();
const fileSize = file.size;
const mimeType = guessMimeType(key);
const responseHeaders = new Headers({
"Accept-Ranges": "bytes",
"Content-Type": mimeType || "application/octet-stream",
});
const rangeHeader = headers.get("range");
if (rangeHeader) {
const range = this.parseRangeHeader(rangeHeader, fileSize);
if (!range) {
// invalid range - return 416 Range Not Satisfiable
responseHeaders.set("Content-Range", `bytes */${fileSize}`);
return new Response("", {
status: 416,
headers: responseHeaders,
});
}
const { start, end } = range;
const arrayBuffer = await file.arrayBuffer();
const chunk = arrayBuffer.slice(start, end + 1);
responseHeaders.set("Content-Range", `bytes ${start}-${end}/${fileSize}`);
responseHeaders.set("Content-Length", chunk.byteLength.toString());
return new Response(chunk, {
status: 206, // Partial Content
headers: responseHeaders,
});
} else {
// normal request - return entire file
const content = await file.arrayBuffer();
responseHeaders.set("Content-Length", content.byteLength.toString());
return new Response(content, {
status: 200,
headers: responseHeaders,
});
}
} catch {
// handle file reading errors
return new Response("", { status: 404 });
}
}
getObjectUrl(_key: string): string {
throw new Error("Method not implemented.");
}
async getObjectMeta(key: string): Promise<FileMeta> {
const root = await this.rootPromise;
const fileHandle = await root.getFileHandle(key);
const file = await fileHandle.getFile();
return {
type: guessMimeType(key) || "application/octet-stream",
size: file.size,
};
}
toJSON(_secrets?: boolean) {
return {
type: this.getName(),
config: this.config,
};
}
}
-2
View File
@@ -1,2 +0,0 @@
export * from "./OpfsStorageAdapter";
export * from "./BkndBrowserApp";
-136
View File
@@ -1,136 +0,0 @@
// mock OPFS API for testing
class MockFileSystemFileHandle {
kind: "file" = "file";
name: string;
private content: ArrayBuffer;
private lastModified: number;
constructor(name: string, content: ArrayBuffer = new ArrayBuffer(0)) {
this.name = name;
this.content = content;
this.lastModified = Date.now();
}
async getFile(): Promise<File> {
return new File([this.content], this.name, {
lastModified: this.lastModified,
type: this.guessMimeType(),
});
}
async createWritable(): Promise<FileSystemWritableFileStream> {
const handle = this;
return {
async write(data: any) {
if (data instanceof ArrayBuffer) {
handle.content = data;
} else if (ArrayBuffer.isView(data)) {
handle.content = data.buffer.slice(
data.byteOffset,
data.byteOffset + data.byteLength,
) as ArrayBuffer;
} else if (data instanceof Blob) {
handle.content = await data.arrayBuffer();
}
handle.lastModified = Date.now();
},
async close() {},
async abort() {},
async seek(_position: number) {},
async truncate(_size: number) {},
} as FileSystemWritableFileStream;
}
private guessMimeType(): string {
const ext = this.name.split(".").pop()?.toLowerCase();
const mimeTypes: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
svg: "image/svg+xml",
txt: "text/plain",
json: "application/json",
pdf: "application/pdf",
};
return mimeTypes[ext || ""] || "application/octet-stream";
}
}
export class MockFileSystemDirectoryHandle {
kind: "directory" = "directory";
name: string;
private files: Map<string, MockFileSystemFileHandle> = new Map();
private directories: Map<string, MockFileSystemDirectoryHandle> = new Map();
constructor(name: string = "root") {
this.name = name;
}
async getFileHandle(
name: string,
options?: FileSystemGetFileOptions,
): Promise<FileSystemFileHandle> {
if (this.files.has(name)) {
return this.files.get(name) as any;
}
if (options?.create) {
const handle = new MockFileSystemFileHandle(name);
this.files.set(name, handle);
return handle as any;
}
throw new Error(`File not found: ${name}`);
}
async getDirectoryHandle(
name: string,
options?: FileSystemGetDirectoryOptions,
): Promise<FileSystemDirectoryHandle> {
if (this.directories.has(name)) {
return this.directories.get(name) as any;
}
if (options?.create) {
const handle = new MockFileSystemDirectoryHandle(name);
this.directories.set(name, handle);
return handle as any;
}
throw new Error(`Directory not found: ${name}`);
}
async removeEntry(name: string, _options?: FileSystemRemoveOptions): Promise<void> {
this.files.delete(name);
this.directories.delete(name);
}
async *entries(): AsyncIterableIterator<[string, FileSystemHandle]> {
for (const [name, handle] of this.files) {
yield [name, handle as any];
}
for (const [name, handle] of this.directories) {
yield [name, handle as any];
}
}
async *keys(): AsyncIterableIterator<string> {
for (const name of this.files.keys()) {
yield name;
}
for (const name of this.directories.keys()) {
yield name;
}
}
async *values(): AsyncIterableIterator<FileSystemHandle> {
for (const handle of this.files.values()) {
yield handle as any;
}
for (const handle of this.directories.values()) {
yield handle as any;
}
}
[Symbol.asyncIterator](): AsyncIterableIterator<[string, FileSystemHandle]> {
return this.entries();
}
}
-5
View File
@@ -69,11 +69,6 @@ export async function createAdapterApp<Config extends BkndConfig = BkndConfig, A
if (Connection.isConnection(config.connection)) { if (Connection.isConnection(config.connection)) {
connection = config.connection; connection = config.connection;
} else { } else {
if (connection) {
$console.warn(
"Connection is not a valid connection object, using default SQLite connection",
);
}
const sqlite = (await import("bknd/adapter/sqlite")).sqlite; const sqlite = (await import("bknd/adapter/sqlite")).sqlite;
const conf = appConfig.connection ?? { url: "file:data.db" }; const conf = appConfig.connection ?? { url: "file:data.db" };
connection = sqlite(conf) as any; connection = sqlite(conf) as any;
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe } from "vitest"; import { describe, beforeAll, afterAll } from "vitest";
import * as node from "./node.adapter"; import * as node from "./node.adapter";
import { adapterTestSuite } from "adapter/adapter-test-suite"; import { adapterTestSuite } from "adapter/adapter-test-suite";
import { viTestRunner } from "adapter/node/vitest"; import { viTestRunner } from "adapter/node/vitest";
-27
View File
@@ -1,27 +0,0 @@
import type { CliCommand } from "cli/types";
import { Option } from "commander";
import { withConfigOptions, type WithConfigOptions } from "cli/utils/options";
import * as utils from "./utils";
import { $console } from "core/utils";
import { Project } from "./lib/Project";
export const dev: CliCommand = (program) =>
withConfigOptions(program.command("dev"))
.description("dev server")
.addOption(new Option("--build", "build the project"))
.action(action);
async function action(options: WithConfigOptions<{ build?: boolean }>) {
console.log("options", options);
const project = new Project({
templatePath: utils.TEMPLATE_PATH,
});
await project.init();
if (options.build) {
await project.build();
process.exit(0);
}
await project.listen();
}
-108
View File
@@ -1,108 +0,0 @@
import { RelativeFS } from "./RelativeFS";
//import { $console } from "bknd/utils";
import { type ViteDevServer, createServer, type UserConfig, createBuilder } from "vite";
import { readdir, copyFile, stat } from "node:fs/promises";
import path from "node:path";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { cloudflare } from "@cloudflare/vite-plugin";
import { getRelativeDistPath } from "cli/utils/sys";
import { injectMain } from "./vite/inject-main";
import { devFsVitePlugin } from "adapter/cloudflare";
export type ProjectOptions = {
userPath?: string;
templatePath?: string;
};
// @todo: add multiple public dirs
// @todo: add npm install (first time)
// @todo: add package.json
export class Project {
public userFs: RelativeFS;
public templateFs: RelativeFS;
private _server?: ViteDevServer;
constructor(public options: ProjectOptions) {
this.userFs = new RelativeFS(options.userPath ?? process.cwd());
this.templateFs = new RelativeFS(options.templatePath ?? "src/cli/commands/dev/template");
}
get server() {
if (!this._server) {
throw new Error("Server not initialized");
}
return this._server!;
}
async init() {
const source = this.templateFs.root;
const destination = this.userFs.root;
// recursively copy all files and directories from source to dest
const copyRecursive = async (src: string, dst: string) => {
const entries = await readdir(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const dstPath = path.join(dst, entry.name);
if (entry.isDirectory()) {
await this.userFs.makeDir(path.relative(destination, dstPath));
await copyRecursive(srcPath, dstPath);
} else if (entry.isFile()) {
const exists = await stat(dstPath)
.then((s) => s.isFile())
.catch(() => null);
// only copy everything from ".bknd" with override
if (!exists || (dstPath.includes(".bknd") && !dstPath.includes("bknd-types.d.ts"))) {
await copyFile(srcPath, dstPath);
}
}
}
};
await copyRecursive(source, destination);
this._server = await createServer(this.getViteConfig());
}
private getViteConfig(): UserConfig {
return {
clearScreen: false,
publicDir: getRelativeDistPath() + "/static",
plugins: [
react(),
tailwindcss(),
devFsVitePlugin({ configFile: ".bknd/bknd.config.ts" }) as any,
cloudflare({
configPath: this.userFs.path(".bknd/wrangler.json"),
persistState: {
path: this.userFs.path(".bknd/state"),
},
}),
injectMain({
appPath: "./src/App.tsx",
rootId: "root",
}),
],
build: {
minify: true,
},
resolve: {
dedupe: ["react", "react-dom"],
},
};
}
async build() {
const builder = await createBuilder(this.getViteConfig());
await builder.buildApp();
}
async listen() {
await this.server.listen();
this.server.printUrls();
this.server.bindCLIShortcuts({ print: true });
}
}
@@ -1,50 +0,0 @@
import {
mkdir,
stat,
readFile as nodeReadFile,
writeFile as nodeWriteFile,
} from "node:fs/promises";
import { getRootPath } from "cli/utils/sys";
import path from "node:path";
export class RelativeFS {
public root: string;
constructor(p: string) {
this.root = path.resolve(getRootPath(), p);
}
path(p: string) {
return path.join(this.root, p);
}
async hasFile(path: string) {
try {
const s = await stat(this.path(path));
return s.isFile();
} catch (_) {
return false;
}
}
async hasDir(path: string) {
try {
const s = await stat(this.path(path));
return s.isDirectory();
} catch (_) {
return false;
}
}
async readFile(p: string) {
return await nodeReadFile(this.path(p), "utf-8");
}
async writeFile(p: string, content: string) {
return await nodeWriteFile(this.path(p), content);
}
async makeDir(p: string) {
return await mkdir(this.path(p), { recursive: true });
}
}
@@ -1,60 +0,0 @@
import type { PluginOption } from "vite";
export function injectMain(options?: { appPath?: string; rootId?: string }): PluginOption {
const appPath = options?.appPath ?? "/App.tsx";
const rootId = options?.rootId ?? "root";
const publicId = "/@virtual/react-main.js";
const internalId = "\0virtual-react-main.js";
return [
{
name: "bknd-virtual-react-entry",
transformIndexHtml(html) {
return {
html,
tags: [
{
tag: "script",
injectTo: "body",
attrs: {
type: "module",
src: publicId,
},
},
],
};
},
resolveId(id) {
if (id === publicId) {
return internalId;
}
return null;
},
load(id) {
if (id === internalId) {
return `
import React from "react";
import ReactDOM from "react-dom/client";
import App from "${appPath}";
import { ClientProvider } from "bknd/client";
const container = document.getElementById("${rootId}");
if (!container) {
throw new Error("Cannot find #${rootId} element");
}
const root = ReactDOM.createRoot(container);
root.render(
React.createElement(
React.StrictMode,
null,
React.createElement(ClientProvider, null, React.createElement(App, null))
)
);
`;
}
return null;
},
},
];
}
@@ -1,8 +0,0 @@
import type { DB } from "bknd";
import type { Insertable, Selectable, Updateable } from "kysely";
declare global {
type BkndEntity<T extends keyof DB> = Selectable<DB[T]>;
type BkndEntityCreate<T extends keyof DB> = Insertable<DB[T]>;
type BkndEntityUpdate<T extends keyof DB> = Updateable<DB[T]>;
}
@@ -1,22 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"types": ["vite/client"]
},
"include": ["bknd-types.d.ts"]
}
@@ -1,4 +0,0 @@
import { serve } from "bknd/adapter/cloudflare";
import config from "./bknd.config";
export default serve(config);
@@ -1,28 +0,0 @@
{
"name": "bknd-dev",
"main": "./worker.ts",
"compatibility_date": "2025-10-08",
"compatibility_flags": ["nodejs_compat"],
"observability": {
"enabled": true
},
"assets": {
"binding": "ASSETS",
"directory": "../dist/client",
"not_found_handling": "single-page-application",
"run_worker_first": ["!/", "/admin*", "/api*", "!/assets/*"]
},
"vars": {
"ENVIRONMENT": "development"
},
"d1_databases": [
{
"binding": "DB"
}
],
"r2_buckets": [
{
"binding": "BUCKET"
}
]
}
@@ -1,13 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>bknd + Vite + Cloudflare + React + TS</title>
</head>
<body>
<div id="root"></div>
</body>
</html>
@@ -1,5 +0,0 @@
import "./styles.css";
export default function App() {
return <div>Hello World</div>;
}
@@ -1,11 +0,0 @@
import { Hono } from "hono";
import type { ServerEnv } from "bknd";
/**
* Add custom routes to the API here. Base path is `/api`.
*/
export default new Hono<ServerEnv>().get("/", (c) => {
// const app = c.var.app;
// const api = app.getApi();
return c.json({ message: "Hello, world!" });
});
@@ -1,7 +0,0 @@
import { AppEvents, DatabaseEvents, type EventManager } from "bknd";
export default function (emgr: EventManager) {
// emgr.onEvent(AppEvents.AppRequest, async (event) => {
// console.log("Request received", event.params.request.url);
// });
}
@@ -1,8 +0,0 @@
import { em, entity, text, boolean, number, datetime, json, jsonSchema, enumm } from "bknd";
export default em({
// todos: entity("todos", {
// title: text(),
// done: boolean(),
// }),
});
@@ -1 +0,0 @@
@import "tailwindcss";
@@ -1,4 +0,0 @@
{
"extends": "./.bknd/tsconfig.json",
"include": ["src"]
}
-40
View File
@@ -1,40 +0,0 @@
import path from "node:path";
import {
mkdir,
stat,
readFile as nodeReadFile,
writeFile as nodeWriteFile,
} from "node:fs/promises";
import { getRootPath } from "cli/utils/sys";
export const TEMPLATE_PATH = "src/cli/commands/dev/template";
export const currentDir = process.cwd();
export const fs = (dir: string) => {
const PATH = path.resolve(getRootPath(), dir);
return {
PATH,
path: (_path: string) => path.join(PATH, _path),
hasFile: async (file: string) => {
try {
const s = await stat(path.join(PATH, file));
return s.isFile();
} catch (_) {
return false;
}
},
hasDir: async (_dir: string) => {
try {
const s = await stat(path.join(PATH, _dir));
return s.isDirectory();
} catch (_) {
return false;
}
},
readFile: (file: string) => nodeReadFile(path.join(PATH, file), "utf-8"),
readJsonFile: async (file: string) =>
JSON.parse(await nodeReadFile(path.join(PATH, file), "utf-8")),
writeFile: (file: string, content: string) => nodeWriteFile(path.join(PATH, file), content),
makeDir: (_newDir: string) => mkdir(path.join(PATH, _newDir)),
};
};
-1
View File
@@ -9,4 +9,3 @@ export { types } from "./types";
export { mcp } from "./mcp/mcp"; export { mcp } from "./mcp/mcp";
export { sync } from "./sync"; export { sync } from "./sync";
export { secrets } from "./secrets"; export { secrets } from "./secrets";
export { dev } from "./dev/dev";
+1
View File
@@ -1,4 +1,5 @@
#!/usr/bin/env node #!/usr/bin/env node
import { Command } from "commander"; import { Command } from "commander";
import color from "picocolors"; import color from "picocolors";
import * as commands from "./commands"; import * as commands from "./commands";
+1 -2
View File
@@ -1,4 +1,3 @@
import type { MaybePromise } from "bknd";
import type { Event } from "./Event"; import type { Event } from "./Event";
import type { EventClass } from "./EventManager"; import type { EventClass } from "./EventManager";
@@ -8,7 +7,7 @@ export type ListenerMode = (typeof ListenerModes)[number];
export type ListenerHandler<E extends Event<any, any>> = ( export type ListenerHandler<E extends Event<any, any>> = (
event: E, event: E,
slug: string, slug: string,
) => E extends Event<any, infer R> ? MaybePromise<R | void> : never; ) => E extends Event<any, infer R> ? R | Promise<R | void> : never;
export class EventListener<E extends Event = Event> { export class EventListener<E extends Event = Event> {
mode: ListenerMode = "async"; mode: ListenerMode = "async";
-1
View File
@@ -32,7 +32,6 @@ export function getFlashMessage(
): { type: FlashMessageType; message: string } | undefined { ): { type: FlashMessageType; message: string } | undefined {
const flash = getCookieValue(flash_key); const flash = getCookieValue(flash_key);
if (flash && clear) { if (flash && clear) {
// biome-ignore lint/suspicious/noDocumentCookie: .
document.cookie = `${flash_key}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`; document.cookie = `${flash_key}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
} }
return flash ? JSON.parse(flash) : undefined; return flash ? JSON.parse(flash) : undefined;
+2 -2
View File
@@ -14,9 +14,9 @@ export function isObject(value: unknown): value is Record<string, unknown> {
export function omitKeys<T extends object, K extends keyof T>( export function omitKeys<T extends object, K extends keyof T>(
obj: T, obj: T,
keys_: readonly K[] | K[] | string[], keys_: readonly K[],
): Omit<T, Extract<K, keyof T>> { ): Omit<T, Extract<K, keyof T>> {
const keys = new Set(keys_ as readonly K[]); const keys = new Set(keys_);
const result = {} as Omit<T, Extract<K, keyof T>>; const result = {} as Omit<T, Extract<K, keyof T>>;
for (const [key, value] of Object.entries(obj) as [keyof T, T[keyof T]][]) { for (const [key, value] of Object.entries(obj) as [keyof T, T[keyof T]][]) {
if (!keys.has(key as K)) { if (!keys.has(key as K)) {
-35
View File
@@ -1,4 +1,3 @@
import type { MaybePromise } from "core/types";
import { getRuntimeKey as honoGetRuntimeKey } from "hono/adapter"; import { getRuntimeKey as honoGetRuntimeKey } from "hono/adapter";
/** /**
@@ -78,37 +77,3 @@ export function threw(fn: () => any, instance?: new (...args: any[]) => Error) {
return true; return true;
} }
} }
export async function threwAsync(fn: Promise<any>, instance?: new (...args: any[]) => Error) {
try {
await fn;
return false;
} catch (e) {
if (instance) {
if (e instanceof instance) {
return true;
}
// if instance given but not what expected, throw
throw e;
}
return true;
}
}
export async function $waitUntil(
message: string,
condition: () => MaybePromise<boolean>,
delay = 100,
maxAttempts = 10,
) {
let attempts = 0;
while (attempts < maxAttempts) {
if (await condition()) {
return;
}
await new Promise((resolve) => setTimeout(resolve, delay));
attempts++;
}
throw new Error(`$waitUntil: "${message}" failed after ${maxAttempts} attempts`);
}
+1 -10
View File
@@ -96,9 +96,6 @@ export class DataController extends Controller {
// read entity schema // read entity schema
hono.get( hono.get(
"/schema.json", "/schema.json",
permission(SystemPermissions.schemaRead, {
context: (_c) => ({ module: "data" }),
}),
permission(DataPermissions.entityRead, { permission(DataPermissions.entityRead, {
context: (c) => ({ entity: c.req.param("entity") }), context: (c) => ({ entity: c.req.param("entity") }),
}), }),
@@ -127,9 +124,6 @@ export class DataController extends Controller {
// read schema // read schema
hono.get( hono.get(
"/schemas/:entity/:context?", "/schemas/:entity/:context?",
permission(SystemPermissions.schemaRead, {
context: (_c) => ({ module: "data" }),
}),
permission(DataPermissions.entityRead, { permission(DataPermissions.entityRead, {
context: (c) => ({ entity: c.req.param("entity") }), context: (c) => ({ entity: c.req.param("entity") }),
}), }),
@@ -167,7 +161,7 @@ export class DataController extends Controller {
hono.get( hono.get(
"/types", "/types",
permission(SystemPermissions.schemaRead, { permission(SystemPermissions.schemaRead, {
context: (_c) => ({ module: "data" }), context: (c) => ({ module: "data" }),
}), }),
describeRoute({ describeRoute({
summary: "Retrieve data typescript definitions", summary: "Retrieve data typescript definitions",
@@ -188,9 +182,6 @@ export class DataController extends Controller {
*/ */
hono.get( hono.get(
"/info/:entity", "/info/:entity",
permission(SystemPermissions.schemaRead, {
context: (_c) => ({ module: "data" }),
}),
permission(DataPermissions.entityRead, { permission(DataPermissions.entityRead, {
context: (c) => ({ entity: c.req.param("entity") }), context: (c) => ({ entity: c.req.param("entity") }),
}), }),
+3
View File
@@ -6,12 +6,15 @@ import {
type CompiledQuery, type CompiledQuery,
type DatabaseIntrospector, type DatabaseIntrospector,
type Dialect, type Dialect,
type Expression,
type Kysely, type Kysely,
type KyselyPlugin, type KyselyPlugin,
type OnModifyForeignAction, type OnModifyForeignAction,
type QueryResult, type QueryResult,
type RawBuilder,
type SelectQueryBuilder, type SelectQueryBuilder,
type SelectQueryNode, type SelectQueryNode,
type Simplify,
sql, sql,
} from "kysely"; } from "kysely";
import type { jsonArrayFrom, jsonBuildObject, jsonObjectFrom } from "kysely/helpers/sqlite"; import type { jsonArrayFrom, jsonBuildObject, jsonObjectFrom } from "kysely/helpers/sqlite";
@@ -14,31 +14,27 @@ export function connectionTestSuite(
{ {
makeConnection, makeConnection,
rawDialectDetails, rawDialectDetails,
disableConsoleLog: _disableConsoleLog = true,
}: { }: {
makeConnection: () => MaybePromise<{ makeConnection: () => MaybePromise<{
connection: Connection; connection: Connection;
dispose: () => MaybePromise<void>; dispose: () => MaybePromise<void>;
}>; }>;
rawDialectDetails: string[]; rawDialectDetails: string[];
disableConsoleLog?: boolean;
}, },
) { ) {
const { test, expect, describe, beforeEach, afterEach, afterAll, beforeAll } = testRunner; const { test, expect, describe, beforeEach, afterEach, afterAll, beforeAll } = testRunner;
if (_disableConsoleLog) { beforeAll(() => disableConsoleLog());
beforeAll(() => disableConsoleLog()); afterAll(() => enableConsoleLog());
afterAll(() => enableConsoleLog());
}
let ctx: Awaited<ReturnType<typeof makeConnection>>; describe("base", () => {
beforeEach(async () => { let ctx: Awaited<ReturnType<typeof makeConnection>>;
ctx = await makeConnection(); beforeEach(async () => {
}); ctx = await makeConnection();
afterEach(async () => { });
await ctx.dispose(); afterEach(async () => {
}); await ctx.dispose();
});
describe("base", async () => {
test("pings", async () => { test("pings", async () => {
const res = await ctx.connection.ping(); const res = await ctx.connection.ping();
expect(res).toBe(true); expect(res).toBe(true);
@@ -102,54 +98,52 @@ export function connectionTestSuite(
}); });
describe("schema", async () => { describe("schema", async () => {
const makeSchema = async () => { const { connection, dispose } = await makeConnection();
const fields = [ afterAll(async () => {
{ await dispose();
type: "integer", });
name: "id",
primary: true,
},
{
type: "text",
name: "text",
},
{
type: "json",
name: "json",
},
] as const satisfies FieldSpec[];
let b = ctx.connection.kysely.schema.createTable("test"); const fields = [
for (const field of fields) { {
// @ts-expect-error type: "integer",
b = b.addColumn(...ctx.connection.getFieldSchema(field)); name: "id",
} primary: true,
await b.execute(); },
{
type: "text",
name: "text",
},
{
type: "json",
name: "json",
},
] as const satisfies FieldSpec[];
// add index let b = connection.kysely.schema.createTable("test");
await ctx.connection.kysely.schema for (const field of fields) {
.createIndex("test_index") // @ts-expect-error
.on("test") b = b.addColumn(...connection.getFieldSchema(field));
.columns(["id"]) }
.execute(); await b.execute();
};
// add index
await connection.kysely.schema.createIndex("test_index").on("test").columns(["id"]).execute();
test("executes query", async () => { test("executes query", async () => {
await makeSchema(); await connection.kysely
await ctx.connection.kysely
.insertInto("test") .insertInto("test")
.values({ id: 1, text: "test", json: JSON.stringify({ a: 1 }) }) .values({ id: 1, text: "test", json: JSON.stringify({ a: 1 }) })
.execute(); .execute();
const expected = { id: 1, text: "test", json: { a: 1 } }; const expected = { id: 1, text: "test", json: { a: 1 } };
const qb = ctx.connection.kysely.selectFrom("test").selectAll(); const qb = connection.kysely.selectFrom("test").selectAll();
const res = await ctx.connection.executeQuery(qb); const res = await connection.executeQuery(qb);
expect(res.rows).toEqual([expected]); expect(res.rows).toEqual([expected]);
expect(rawDialectDetails.every((detail) => getPath(res, detail) !== undefined)).toBe(true); expect(rawDialectDetails.every((detail) => getPath(res, detail) !== undefined)).toBe(true);
{ {
const res = await ctx.connection.executeQueries(qb, qb); const res = await connection.executeQueries(qb, qb);
expect(res.length).toBe(2); expect(res.length).toBe(2);
res.map((r) => { res.map((r) => {
expect(r.rows).toEqual([expected]); expect(r.rows).toEqual([expected]);
@@ -161,21 +155,15 @@ export function connectionTestSuite(
}); });
test("introspects", async () => { test("introspects", async () => {
await makeSchema(); const tables = await connection.getIntrospector().getTables({
const tables = await ctx.connection.getIntrospector().getTables({
withInternalKyselyTables: false, withInternalKyselyTables: false,
}); });
const clean = tables.map((t) => ({ const clean = tables.map((t) => ({
...t, ...t,
columns: t.columns columns: t.columns.map((c) => ({
.map((c) => ({ ...c,
...c, dataType: undefined,
// ignore data type })),
dataType: undefined,
// ignore default value if "id"
hasDefaultValue: c.name !== "id" ? c.hasDefaultValue : undefined,
}))
.sort((a, b) => a.name.localeCompare(b.name)),
})); }));
expect(clean).toEqual([ expect(clean).toEqual([
@@ -188,16 +176,7 @@ export function connectionTestSuite(
dataType: undefined, dataType: undefined,
isNullable: false, isNullable: false,
isAutoIncrementing: true, isAutoIncrementing: true,
hasDefaultValue: undefined,
comment: undefined,
},
{
name: "json",
dataType: undefined,
isNullable: true,
isAutoIncrementing: false,
hasDefaultValue: false, hasDefaultValue: false,
comment: undefined,
}, },
{ {
name: "text", name: "text",
@@ -205,26 +184,32 @@ export function connectionTestSuite(
isNullable: true, isNullable: true,
isAutoIncrementing: false, isAutoIncrementing: false,
hasDefaultValue: false, hasDefaultValue: false,
comment: undefined,
}, },
],
},
]);
expect(await ctx.connection.getIntrospector().getIndices()).toEqual([
{
name: "test_index",
table: "test",
isUnique: false,
columns: [
{ {
name: "id", name: "json",
order: 0, dataType: undefined,
isNullable: true,
isAutoIncrementing: false,
hasDefaultValue: false,
}, },
], ],
}, },
]); ]);
}); });
expect(await connection.getIntrospector().getIndices()).toEqual([
{
name: "test_index",
table: "test",
isUnique: false,
columns: [
{
name: "id",
order: 0,
},
],
},
]);
}); });
describe("integration", async () => { describe("integration", async () => {
@@ -1,33 +0,0 @@
import { Kysely, PostgresDialect, type PostgresDialectConfig as KyselyPostgresDialectConfig } from "kysely";
import { PostgresIntrospector } from "./PostgresIntrospector";
import { PostgresConnection, plugins } from "./PostgresConnection";
import { customIntrospector } from "../Connection";
import type { Pool } from "pg";
export type PostgresDialectConfig = Omit<KyselyPostgresDialectConfig, "pool"> & {
pool: Pool;
};
export class PgPostgresConnection extends PostgresConnection<Pool> {
override name = "pg";
constructor(config: PostgresDialectConfig) {
const kysely = new Kysely({
dialect: customIntrospector(PostgresDialect, PostgresIntrospector, {
excludeTables: [],
}).create(config),
plugins,
});
super(kysely);
this.client = config.pool;
}
override async close(): Promise<void> {
await this.client.end();
}
}
export function pg(config: PostgresDialectConfig): PgPostgresConnection {
return new PgPostgresConnection(config);
}
@@ -1,31 +0,0 @@
import { Kysely } from "kysely";
import { PostgresIntrospector } from "./PostgresIntrospector";
import { PostgresConnection, plugins } from "./PostgresConnection";
import { customIntrospector } from "../Connection";
import { PostgresJSDialect, type PostgresJSDialectConfig } from "kysely-postgres-js";
export class PostgresJsConnection extends PostgresConnection<PostgresJSDialectConfig["postgres"]> {
override name = "postgres-js";
constructor(config: PostgresJSDialectConfig) {
const kysely = new Kysely({
dialect: customIntrospector(PostgresJSDialect, PostgresIntrospector, {
excludeTables: [],
}).create(config),
plugins,
});
super(kysely);
this.client = config.postgres;
}
override async close(): Promise<void> {
await this.client.end();
}
}
export function postgresJs(
config: PostgresJSDialectConfig,
): PostgresJsConnection {
return new PostgresJsConnection(config);
}
@@ -13,43 +13,31 @@ import { customIntrospector } from "../Connection";
import { SqliteIntrospector } from "./SqliteIntrospector"; import { SqliteIntrospector } from "./SqliteIntrospector";
import type { Field } from "data/fields/Field"; import type { Field } from "data/fields/Field";
// @todo: add pragmas
export type SqliteConnectionConfig< export type SqliteConnectionConfig<
CustomDialect extends Constructor<Dialect> = Constructor<Dialect>, CustomDialect extends Constructor<Dialect> = Constructor<Dialect>,
> = { > = {
excludeTables?: string[]; excludeTables?: string[];
dialect: CustomDialect;
dialectArgs?: ConstructorParameters<CustomDialect>;
additionalPlugins?: KyselyPlugin[]; additionalPlugins?: KyselyPlugin[];
customFn?: Partial<DbFunctions>; customFn?: Partial<DbFunctions>;
} & ( };
| {
dialect: CustomDialect;
dialectArgs?: ConstructorParameters<CustomDialect>;
}
| {
kysely: Kysely<any>;
}
);
export abstract class SqliteConnection<Client = unknown> extends Connection<Client> { export abstract class SqliteConnection<Client = unknown> extends Connection<Client> {
override name = "sqlite"; override name = "sqlite";
constructor(config: SqliteConnectionConfig) { constructor(config: SqliteConnectionConfig) {
const { excludeTables, additionalPlugins } = config; const { excludeTables, dialect, dialectArgs = [], additionalPlugins } = config;
const plugins = [new ParseJSONResultsPlugin(), ...(additionalPlugins ?? [])]; const plugins = [new ParseJSONResultsPlugin(), ...(additionalPlugins ?? [])];
let kysely: Kysely<any>; const kysely = new Kysely({
if ("dialect" in config) { dialect: customIntrospector(dialect, SqliteIntrospector, {
kysely = new Kysely({ excludeTables,
dialect: customIntrospector(config.dialect, SqliteIntrospector, {
excludeTables,
plugins,
}).create(...(config.dialectArgs ?? [])),
plugins, plugins,
}); }).create(...dialectArgs),
} else if ("kysely" in config) { plugins,
kysely = config.kysely; });
} else {
throw new Error("Either dialect or kysely must be provided");
}
super( super(
kysely, kysely,
@@ -83,7 +83,7 @@ export class SqliteIntrospector extends BaseIntrospector {
dataType: col.type, dataType: col.type,
isNullable: !col.notnull, isNullable: !col.notnull,
isAutoIncrementing: col.name === autoIncrementCol, isAutoIncrementing: col.name === autoIncrementCol,
hasDefaultValue: col.name === autoIncrementCol ? true : col.dflt_value != null, hasDefaultValue: col.dflt_value != null,
comment: undefined, comment: undefined,
}; };
}) ?? [], }) ?? [],
@@ -1,15 +0,0 @@
import { describe } from "bun:test";
import { SQLocalConnection } from "./SQLocalConnection";
import { connectionTestSuite } from "data/connection/connection-test-suite";
import { bunTestRunner } from "adapter/bun/test";
import { SQLocalKysely } from "sqlocal/kysely";
describe("SQLocalConnection", () => {
connectionTestSuite(bunTestRunner, {
makeConnection: () => ({
connection: new SQLocalConnection(new SQLocalKysely({ databasePath: ":memory:" })),
dispose: async () => {},
}),
rawDialectDetails: [],
});
});
@@ -1,50 +0,0 @@
import { Kysely, ParseJSONResultsPlugin } from "kysely";
import { SqliteConnection } from "../SqliteConnection";
import { SqliteIntrospector } from "../SqliteIntrospector";
import type { DB } from "bknd";
import type { SQLocalKysely } from "sqlocal/kysely";
const plugins = [new ParseJSONResultsPlugin()];
export class SQLocalConnection extends SqliteConnection<SQLocalKysely> {
private connected: boolean = false;
constructor(client: SQLocalKysely) {
// @ts-expect-error - config is protected
client.config.onConnect = () => {
// we need to listen for the connection, it will be awaited in init()
this.connected = true;
};
super({
kysely: new Kysely<any>({
dialect: {
...client.dialect,
createIntrospector: (db: Kysely<DB>) => {
return new SqliteIntrospector(db as any, {
plugins,
});
},
},
plugins,
}) as any,
});
this.client = client;
}
override async init() {
if (this.initialized) return;
let tries = 0;
while (!this.connected && tries < 100) {
tries++;
await new Promise((resolve) => setTimeout(resolve, 5));
}
if (!this.connected) {
throw new Error("Failed to connect to SQLite database");
}
this.initialized = true;
}
}
export function sqlocal(instance: InstanceType<typeof SQLocalKysely>): SQLocalConnection {
return new SQLocalConnection(instance);
}
+3 -20
View File
@@ -103,7 +103,6 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
validated.with = options.with; validated.with = options.with;
} }
// add explicit joins. Implicit joins are added in `where` builder
if (options.join && options.join.length > 0) { if (options.join && options.join.length > 0) {
for (const entry of options.join) { for (const entry of options.join) {
const related = this.em.relationOf(entity.name, entry); const related = this.em.relationOf(entity.name, entry);
@@ -128,28 +127,12 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
const invalid = WhereBuilder.getPropertyNames(options.where).filter((field) => { const invalid = WhereBuilder.getPropertyNames(options.where).filter((field) => {
if (field.includes(".")) { if (field.includes(".")) {
const [alias, prop] = field.split(".") as [string, string]; const [alias, prop] = field.split(".") as [string, string];
// check aliases first (added joins) if (!aliases.includes(alias)) {
if (aliases.includes(alias)) {
this.checkIndex(alias, prop, "where");
return !this.em.entity(alias).getField(prop);
}
// check if alias (entity) exists
if (!this.em.hasEntity(alias)) {
return true; return true;
} }
// check related fields for auto join
const related = this.em.relationOf(entity.name, alias);
if (related) {
const other = related.other(entity);
if (other.entity.getField(prop)) {
// if related field is found, add join to validated options
validated.join?.push(alias);
this.checkIndex(alias, prop, "where");
return false;
}
}
return true; this.checkIndex(alias, prop, "where");
return !this.em.entity(alias).getField(prop);
} }
this.checkIndex(entity.name, field, "where"); this.checkIndex(entity.name, field, "where");
+1 -1
View File
@@ -52,7 +52,7 @@ export class NumberField<Required extends true | false = false> extends Field<
switch (context) { switch (context) {
case "submit": case "submit":
return Number.parseInt(value, 10); return Number.parseInt(value);
} }
return value; return value;
+1 -1
View File
@@ -28,7 +28,7 @@ export function getChangeSet(
const value = _value === "" ? null : _value; const value = _value === "" ? null : _value;
// normalize to null if undefined // normalize to null if undefined
const newValue = field.getValue(value, "submit") ?? null; const newValue = field.getValue(value, "submit") || null;
// @todo: add typing for "action" // @todo: add typing for "action"
if (action === "create" || newValue !== data[key]) { if (action === "create" || newValue !== data[key]) {
acc[key] = newValue; acc[key] = newValue;
+1 -1
View File
@@ -289,7 +289,7 @@ class EntityManagerPrototype<Entities extends Record<string, Entity>> extends En
super(Object.values(__entities), new DummyConnection(), relations, indices); super(Object.values(__entities), new DummyConnection(), relations, indices);
} }
withConnection(connection: Connection): EntityManager<Schemas<Entities>> { withConnection(connection: Connection): EntityManager<Schema<Entities>> {
return new EntityManager(this.entities, connection, this.relations.all, this.indices); return new EntityManager(this.entities, connection, this.relations.all, this.indices);
} }
} }
+1 -5
View File
@@ -1,9 +1,8 @@
import { test, describe, expect, beforeAll, afterAll } from "bun:test"; import { test, describe, expect } from "bun:test";
import * as q from "./query"; import * as q from "./query";
import { parse as $parse, type ParseOptions } from "bknd/utils"; import { parse as $parse, type ParseOptions } from "bknd/utils";
import type { PrimaryFieldType } from "modules"; import type { PrimaryFieldType } from "modules";
import type { Generated } from "kysely"; import type { Generated } from "kysely";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
const parse = (v: unknown, o: ParseOptions = {}) => const parse = (v: unknown, o: ParseOptions = {}) =>
$parse(q.repoQuery, v, { $parse(q.repoQuery, v, {
@@ -16,9 +15,6 @@ const decode = (input: any, output: any) => {
expect(parse(input)).toEqual(output); expect(parse(input)).toEqual(output);
}; };
beforeAll(() => disableConsoleLog());
afterAll(() => enableConsoleLog());
describe("server/query", () => { describe("server/query", () => {
test("limit & offset", () => { test("limit & offset", () => {
//expect(() => parse({ limit: false })).toThrow(); //expect(() => parse({ limit: false })).toThrow();
-25
View File
@@ -132,8 +132,6 @@ export type * from "data/entities/Entity";
export type { EntityManager } from "data/entities/EntityManager"; export type { EntityManager } from "data/entities/EntityManager";
export type { SchemaManager } from "data/schema/SchemaManager"; export type { SchemaManager } from "data/schema/SchemaManager";
export type * from "data/entities"; export type * from "data/entities";
// data connection
export { export {
BaseIntrospector, BaseIntrospector,
Connection, Connection,
@@ -146,32 +144,9 @@ export {
type ConnQuery, type ConnQuery,
type ConnQueryResults, type ConnQueryResults,
} from "data/connection"; } from "data/connection";
// data sqlite
export { SqliteConnection } from "data/connection/sqlite/SqliteConnection"; export { SqliteConnection } from "data/connection/sqlite/SqliteConnection";
export { SqliteIntrospector } from "data/connection/sqlite/SqliteIntrospector"; export { SqliteIntrospector } from "data/connection/sqlite/SqliteIntrospector";
export { SqliteLocalConnection } from "data/connection/sqlite/SqliteLocalConnection"; export { SqliteLocalConnection } from "data/connection/sqlite/SqliteLocalConnection";
// data sqlocal
export { SQLocalConnection, sqlocal } from "data/connection/sqlite/sqlocal/SQLocalConnection";
// data postgres
export {
pg,
PgPostgresConnection,
} from "data/connection/postgres/PgPostgresConnection";
export { PostgresIntrospector } from "data/connection/postgres/PostgresIntrospector";
export { PostgresConnection } from "data/connection/postgres/PostgresConnection";
export {
postgresJs,
PostgresJsConnection,
} from "data/connection/postgres/PostgresJsConnection";
export {
createCustomPostgresConnection,
type CustomPostgresConnection,
} from "data/connection/postgres/custom";
// data prototype
export { export {
text, text,
number, number,
@@ -5,7 +5,7 @@ import type { BunFile } from "bun";
export async function adapterTestSuite( export async function adapterTestSuite(
testRunner: TestRunner, testRunner: TestRunner,
_adapter: StorageAdapter | (() => StorageAdapter), adapter: StorageAdapter,
file: File | BunFile, file: File | BunFile,
opts?: { opts?: {
retries?: number; retries?: number;
@@ -25,12 +25,7 @@ export async function adapterTestSuite(
const _filename = randomString(10); const _filename = randomString(10);
const filename = `${_filename}.png`; const filename = `${_filename}.png`;
const getAdapter = (
typeof _adapter === "function" ? _adapter : () => _adapter
) as () => StorageAdapter;
await test("puts an object", async () => { await test("puts an object", async () => {
const adapter = getAdapter();
objects = (await adapter.listObjects()).length; objects = (await adapter.listObjects()).length;
const result = await adapter.putObject(filename, file as unknown as File); const result = await adapter.putObject(filename, file as unknown as File);
expect(result).toBeDefined(); expect(result).toBeDefined();
@@ -43,7 +38,6 @@ export async function adapterTestSuite(
}); });
await test("lists objects", async () => { await test("lists objects", async () => {
const adapter = getAdapter();
const length = await retry( const length = await retry(
() => adapter.listObjects().then((res) => res.length), () => adapter.listObjects().then((res) => res.length),
(length) => length > objects, (length) => length > objects,
@@ -55,12 +49,10 @@ export async function adapterTestSuite(
}); });
await test("file exists", async () => { await test("file exists", async () => {
const adapter = getAdapter();
expect(await adapter.objectExists(filename)).toBe(true); expect(await adapter.objectExists(filename)).toBe(true);
}); });
await test("gets an object", async () => { await test("gets an object", async () => {
const adapter = getAdapter();
const res = await adapter.getObject(filename, new Headers()); const res = await adapter.getObject(filename, new Headers());
expect(res.ok).toBe(true); expect(res.ok).toBe(true);
expect(res.headers.get("Accept-Ranges")).toBe("bytes"); expect(res.headers.get("Accept-Ranges")).toBe("bytes");
@@ -70,7 +62,6 @@ export async function adapterTestSuite(
if (options.testRange) { if (options.testRange) {
await test("handles range request - partial content", async () => { await test("handles range request - partial content", async () => {
const headers = new Headers({ Range: "bytes=0-99" }); const headers = new Headers({ Range: "bytes=0-99" });
const adapter = getAdapter();
const res = await adapter.getObject(filename, headers); const res = await adapter.getObject(filename, headers);
expect(res.status).toBe(206); // Partial Content expect(res.status).toBe(206); // Partial Content
expect(/^bytes 0-99\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true); expect(/^bytes 0-99\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true);
@@ -79,7 +70,6 @@ export async function adapterTestSuite(
await test("handles range request - suffix range", async () => { await test("handles range request - suffix range", async () => {
const headers = new Headers({ Range: "bytes=-100" }); const headers = new Headers({ Range: "bytes=-100" });
const adapter = getAdapter();
const res = await adapter.getObject(filename, headers); const res = await adapter.getObject(filename, headers);
expect(res.status).toBe(206); // Partial Content expect(res.status).toBe(206); // Partial Content
expect(/^bytes \d+-\d+\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true); expect(/^bytes \d+-\d+\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true);
@@ -87,7 +77,6 @@ export async function adapterTestSuite(
await test("handles invalid range request", async () => { await test("handles invalid range request", async () => {
const headers = new Headers({ Range: "bytes=invalid" }); const headers = new Headers({ Range: "bytes=invalid" });
const adapter = getAdapter();
const res = await adapter.getObject(filename, headers); const res = await adapter.getObject(filename, headers);
expect(res.status).toBe(416); // Range Not Satisfiable expect(res.status).toBe(416); // Range Not Satisfiable
expect(/^bytes \*\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true); expect(/^bytes \*\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true);
@@ -95,7 +84,6 @@ export async function adapterTestSuite(
} }
await test("gets object meta", async () => { await test("gets object meta", async () => {
const adapter = getAdapter();
expect(await adapter.getObjectMeta(filename)).toEqual({ expect(await adapter.getObjectMeta(filename)).toEqual({
type: file.type, // image/png type: file.type, // image/png
size: file.size, size: file.size,
@@ -103,7 +91,6 @@ export async function adapterTestSuite(
}); });
await test("deletes an object", async () => { await test("deletes an object", async () => {
const adapter = getAdapter();
expect(await adapter.deleteObject(filename)).toBeUndefined(); expect(await adapter.deleteObject(filename)).toBeUndefined();
if (opts?.skipExistsAfterDelete !== true) { if (opts?.skipExistsAfterDelete !== true) {
+1 -1
View File
@@ -87,7 +87,7 @@ export async function makeModeConfig<
const { typesFilePath, configFilePath, writer, syncSecrets: syncSecretsOptions } = config; const { typesFilePath, configFilePath, writer, syncSecrets: syncSecretsOptions } = config;
const isProd = config.isProduction ?? _isProd(); const isProd = config.isProduction ?? _isProd();
const plugins = config?.options?.plugins ?? ([] as AppPlugin[]); const plugins = appConfig?.options?.plugins ?? ([] as AppPlugin[]);
const syncFallback = typeof config.syncSchema === "boolean" ? config.syncSchema : !isProd; const syncFallback = typeof config.syncSchema === "boolean" ? config.syncSchema : !isProd;
const syncSchemaOptions = const syncSchemaOptions =
typeof config.syncSchema === "object" typeof config.syncSchema === "object"
+1 -1
View File
@@ -87,7 +87,7 @@ export type ModuleManagerOptions = {
verbosity?: Verbosity; verbosity?: Verbosity;
}; };
const debug_modules = env("modules_debug", false); const debug_modules = env("modules_debug");
abstract class ModuleManagerEvent<A = {}> extends Event<{ ctx: ModuleBuildContext } & A> {} abstract class ModuleManagerEvent<A = {}> extends Event<{ ctx: ModuleBuildContext } & A> {}
export class ModuleManagerConfigUpdateEvent< export class ModuleManagerConfigUpdateEvent<
-2
View File
@@ -33,5 +33,3 @@ export const schemaRead = new Permission(
); );
export const build = new Permission("system.build"); export const build = new Permission("system.build");
export const mcp = new Permission("system.mcp"); export const mcp = new Permission("system.mcp");
export const info = new Permission("system.info");
export const openapi = new Permission("system.openapi");
+5 -18
View File
@@ -1,3 +1,5 @@
/// <reference types="@cloudflare/workers-types" />
import type { App } from "App"; import type { App } from "App";
import { import {
datetimeStringLocal, datetimeStringLocal,
@@ -123,7 +125,7 @@ export class SystemController extends Controller {
private registerConfigController(client: Hono<any>): void { private registerConfigController(client: Hono<any>): void {
const { permission } = this.middlewares; const { permission } = this.middlewares;
// don't add auth again, it's already added in getController // don't add auth again, it's already added in getController
const hono = this.create(); const hono = this.create(); /* .use(permission(SystemPermissions.configRead)); */
if (!this.app.isReadOnly()) { if (!this.app.isReadOnly()) {
const manager = this.app.modules as DbModuleManager; const manager = this.app.modules as DbModuleManager;
@@ -315,11 +317,6 @@ export class SystemController extends Controller {
summary: "Get the config for a module", summary: "Get the config for a module",
tags: ["system"], tags: ["system"],
}), }),
permission(SystemPermissions.configRead, {
context: (c) => ({
module: c.req.param("module"),
}),
}),
mcpTool("system_config", { mcpTool("system_config", {
annotations: { annotations: {
readOnlyHint: true, readOnlyHint: true,
@@ -357,7 +354,7 @@ export class SystemController extends Controller {
override getController() { override getController() {
const { permission, auth } = this.middlewares; const { permission, auth } = this.middlewares;
const hono = this.create().use(auth()).use(permission(SystemPermissions.accessApi, {})); const hono = this.create().use(auth());
this.registerConfigController(hono); this.registerConfigController(hono);
@@ -432,9 +429,6 @@ export class SystemController extends Controller {
hono.get( hono.get(
"/permissions", "/permissions",
permission(SystemPermissions.schemaRead, {
context: (_c) => ({ module: "auth" }),
}),
describeRoute({ describeRoute({
summary: "Get the permissions", summary: "Get the permissions",
tags: ["system"], tags: ["system"],
@@ -447,7 +441,6 @@ export class SystemController extends Controller {
hono.post( hono.post(
"/build", "/build",
permission(SystemPermissions.build, {}),
describeRoute({ describeRoute({
summary: "Build the app", summary: "Build the app",
tags: ["system"], tags: ["system"],
@@ -478,7 +471,6 @@ export class SystemController extends Controller {
hono.get( hono.get(
"/info", "/info",
permission(SystemPermissions.info, {}),
mcpTool("system_info"), mcpTool("system_info"),
describeRoute({ describeRoute({
summary: "Get the server info", summary: "Get the server info",
@@ -512,7 +504,6 @@ export class SystemController extends Controller {
hono.get( hono.get(
"/openapi.json", "/openapi.json",
permission(SystemPermissions.openapi, {}),
openAPISpecs(this.ctx.server, { openAPISpecs(this.ctx.server, {
info: { info: {
title: "bknd API", title: "bknd API",
@@ -520,11 +511,7 @@ export class SystemController extends Controller {
}, },
}), }),
); );
hono.get( hono.get("/swagger", swaggerUI({ url: "/api/system/openapi.json" }));
"/swagger",
permission(SystemPermissions.openapi, {}),
swaggerUI({ url: "/api/system/openapi.json" }),
);
return hono; return hono;
} }
@@ -1,683 +0,0 @@
import { afterAll, beforeAll, describe, expect, mock, test, setSystemTime } from "bun:test";
import { emailOTP } from "./email-otp.plugin";
import { createApp } from "core/test/utils";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("otp plugin", () => {
test("should not work if auth is not enabled", async () => {
const app = createApp({
options: {
plugins: [emailOTP({ showActualErrors: true })],
},
});
await app.build();
const res = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
expect(res.status).toBe(404);
});
test("should require email driver if sendEmail is true", async () => {
const app = createApp({
config: {
auth: {
enabled: true,
},
},
options: {
plugins: [emailOTP()],
},
});
await app.build();
const res = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
expect(res.status).toBe(404);
{
const app = createApp({
config: {
auth: {
enabled: true,
},
},
options: {
plugins: [emailOTP({ sendEmail: false })],
},
});
await app.build();
const res = await app.server.request("/api/auth/otp/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
expect(res.status).toBe(201);
}
});
test("should prevent mutations of the OTP entity", async () => {
const app = createApp({
config: {
auth: {
enabled: true,
},
},
options: {
drivers: {
email: {
send: async () => {},
},
},
plugins: [emailOTP({ showActualErrors: true })],
},
});
await app.build();
const payload = {
email: "test@test.com",
code: "123456",
action: "login",
created_at: new Date(),
expires_at: new Date(Date.now() + 1000 * 60 * 60 * 24),
used_at: null,
};
expect(app.em.mutator("users_otp").insertOne(payload)).rejects.toThrow();
expect(
await app
.getApi()
.data.createOne("users_otp", payload)
.then((r) => r.ok),
).toBe(false);
});
test("should generate a token", async () => {
const called = mock(() => null);
const app = createApp({
config: {
auth: {
enabled: true,
},
},
options: {
plugins: [emailOTP({ showActualErrors: true })],
drivers: {
email: {
send: async (to) => {
expect(to).toBe("test@test.com");
called();
},
},
},
seed: async (ctx) => {
await ctx.app.createUser({ email: "test@test.com", password: "12345678" });
},
},
});
await app.build();
const res = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
expect(res.status).toBe(201);
const data = (await res.json()) as any;
expect(data.sent).toBe(true);
expect(data.data.email).toBe("test@test.com");
expect(data.data.action).toBe("login");
expect(data.data.expires_at).toBeDefined();
{
const { data } = await app.em.fork().repo("users_otp").findOne({ email: "test@test.com" });
expect(data?.code).toBeDefined();
expect(data?.code?.length).toBe(6);
expect(data?.code?.split("").every((char: string) => Number.isInteger(Number(char)))).toBe(
true,
);
expect(data?.email).toBe("test@test.com");
}
expect(called).toHaveBeenCalled();
});
test("should login with a code", async () => {
let code = "";
const app = createApp({
config: {
auth: {
enabled: true,
jwt: {
secret: "test",
},
},
},
options: {
plugins: [
emailOTP({
showActualErrors: true,
generateEmail: (otp) => ({ subject: "test", body: otp.code }),
}),
],
drivers: {
email: {
send: async (to, _subject, body) => {
expect(to).toBe("test@test.com");
code = String(body);
},
},
},
seed: async (ctx) => {
await ctx.app.createUser({ email: "test@test.com", password: "12345678" });
},
},
});
await app.build();
await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
{
const res = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com", code }),
});
expect(res.status).toBe(200);
expect(res.headers.get("set-cookie")).toBeDefined();
const userData = (await res.json()) as any;
expect(userData.user.email).toBe("test@test.com");
expect(userData.token).toBeDefined();
}
});
test("should register with a code", async () => {
let code = "";
const app = createApp({
config: {
auth: {
enabled: true,
jwt: {
secret: "test",
},
},
},
options: {
plugins: [
emailOTP({
showActualErrors: true,
generateEmail: (otp) => ({ subject: "test", body: otp.code }),
}),
],
drivers: {
email: {
send: async (to, _subject, body) => {
expect(to).toBe("test@test.com");
code = String(body);
},
},
},
},
});
await app.build();
const res = await app.server.request("/api/auth/otp/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
const data = (await res.json()) as any;
expect(data.sent).toBe(true);
expect(data.data.email).toBe("test@test.com");
expect(data.data.action).toBe("register");
expect(data.data.expires_at).toBeDefined();
{
const res = await app.server.request("/api/auth/otp/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com", code }),
});
expect(res.status).toBe(200);
expect(res.headers.get("set-cookie")).toBeDefined();
const userData = (await res.json()) as any;
expect(userData.user.email).toBe("test@test.com");
expect(userData.token).toBeDefined();
}
});
test("should not send email if sendEmail is false", async () => {
const called = mock(() => null);
const app = createApp({
config: {
auth: {
enabled: true,
},
},
options: {
plugins: [emailOTP({ sendEmail: false })],
drivers: {
email: {
send: async () => {
called();
},
},
},
},
});
await app.build();
const res = await app.server.request("/api/auth/otp/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
expect(res.status).toBe(201);
expect(called).not.toHaveBeenCalled();
});
test("should reject invalid codes", async () => {
const app = createApp({
config: {
auth: {
enabled: true,
jwt: {
secret: "test",
},
},
},
options: {
plugins: [
emailOTP({
showActualErrors: true,
generateEmail: (otp) => ({ subject: "test", body: otp.code }),
}),
],
drivers: {
email: {
send: async () => {},
},
},
seed: async (ctx) => {
await ctx.app.createUser({ email: "test@test.com", password: "12345678" });
},
},
});
await app.build();
// First send a code
await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
// Try to use an invalid code
const res = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com", code: "999999" }),
});
expect(res.status).toBe(400);
const error = await res.json();
expect(error).toBeDefined();
});
test("should reject code reuse", async () => {
let code = "";
const app = createApp({
config: {
auth: {
enabled: true,
jwt: {
secret: "test",
},
},
},
options: {
plugins: [
emailOTP({
showActualErrors: true,
generateEmail: (otp) => ({ subject: "test", body: otp.code }),
}),
],
drivers: {
email: {
send: async (_to, _subject, body) => {
code = String(body);
},
},
},
seed: async (ctx) => {
await ctx.app.createUser({ email: "test@test.com", password: "12345678" });
},
},
});
await app.build();
// Send a code
await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
// Use the code successfully
{
const res = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com", code }),
});
expect(res.status).toBe(200);
}
// Try to use the same code again
{
const res = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com", code }),
});
expect(res.status).toBe(400);
const error = await res.json();
expect(error).toBeDefined();
}
});
test("should reject expired codes", async () => {
// Set a fixed system time
const baseTime = Date.now();
setSystemTime(new Date(baseTime));
try {
const app = createApp({
config: {
auth: {
enabled: true,
jwt: {
secret: "test",
},
},
},
options: {
plugins: [
emailOTP({
showActualErrors: true,
ttl: 1, // 1 second TTL
generateEmail: (otp) => ({ subject: "test", body: otp.code }),
}),
],
drivers: {
email: {
send: async () => {},
},
},
seed: async (ctx) => {
await ctx.app.createUser({ email: "test@test.com", password: "12345678" });
},
},
});
await app.build();
// Send a code
const sendRes = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
expect(sendRes.status).toBe(201);
// Get the code from the database
const { data: otpData } = await app.em
.fork()
.repo("users_otp")
.findOne({ email: "test@test.com" });
expect(otpData?.code).toBeDefined();
// Advance system time by more than 1 second to expire the code
setSystemTime(new Date(baseTime + 1100));
// Try to use the expired code
const res = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com", code: otpData?.code }),
});
expect(res.status).toBe(400);
const error = await res.json();
expect(error).toBeDefined();
} finally {
// Reset system time
setSystemTime();
}
});
test("should reject codes with different actions", async () => {
let loginCode = "";
let registerCode = "";
const app = createApp({
config: {
auth: {
enabled: true,
jwt: {
secret: "test",
},
},
},
options: {
plugins: [
emailOTP({
showActualErrors: true,
generateEmail: (otp) => ({ subject: "test", body: otp.code }),
}),
],
drivers: {
email: {
send: async () => {},
},
},
seed: async (ctx) => {
await ctx.app.createUser({ email: "test@test.com", password: "12345678" });
},
},
});
await app.build();
// Send a login code
await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
// Get the login code
const { data: loginOtp } = await app
.getApi()
.data.readOneBy("users_otp", { where: { email: "test@test.com", action: "login" } });
loginCode = loginOtp?.code || "";
// Send a register code
await app.server.request("/api/auth/otp/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
// Get the register code
const { data: registerOtp } = await app
.getApi()
.data.readOneBy("users_otp", { where: { email: "test@test.com", action: "register" } });
registerCode = registerOtp?.code || "";
// Try to use login code for register
{
const res = await app.server.request("/api/auth/otp/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com", code: loginCode }),
});
expect(res.status).toBe(400);
const error = await res.json();
expect(error).toBeDefined();
}
// Try to use register code for login
{
const res = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com", code: registerCode }),
});
expect(res.status).toBe(400);
const error = await res.json();
expect(error).toBeDefined();
}
});
test("should invalidate previous codes when sending new code", async () => {
let firstCode = "";
let secondCode = "";
const app = createApp({
config: {
auth: {
enabled: true,
jwt: {
secret: "test",
},
},
},
options: {
plugins: [
emailOTP({
showActualErrors: true,
generateEmail: (otp) => ({ subject: "test", body: otp.code }),
}),
],
drivers: {
email: {
send: async () => {},
},
},
seed: async (ctx) => {
await ctx.app.createUser({ email: "test@test.com", password: "12345678" });
},
},
});
await app.build();
const em = app.em.fork();
// Send first code
await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
// Get the first code
const { data: firstOtp } = await em
.repo("users_otp")
.findOne({ email: "test@test.com", action: "login" });
firstCode = firstOtp?.code || "";
expect(firstCode).toBeDefined();
// Send second code (should invalidate the first)
await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com" }),
});
// Get the second code
const { data: secondOtp } = await em
.repo("users_otp")
.findOne({ email: "test@test.com", action: "login" });
secondCode = secondOtp?.code || "";
expect(secondCode).toBeDefined();
expect(secondCode).not.toBe(firstCode);
// Try to use the first code (should fail as it's been invalidated)
{
const res = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com", code: firstCode }),
});
expect(res.status).toBe(400);
const error = await res.json();
expect(error).toBeDefined();
}
// The second code should work
{
const res = await app.server.request("/api/auth/otp/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "test@test.com", code: secondCode }),
});
expect(res.status).toBe(200);
}
});
});
-387
View File
@@ -1,387 +0,0 @@
import {
datetime,
em,
entity,
enumm,
Exception,
text,
type App,
type AppPlugin,
type DB,
type FieldSchema,
type MaybePromise,
type EntityConfig,
DatabaseEvents,
} from "bknd";
import {
invariant,
s,
jsc,
HttpStatus,
threwAsync,
randomString,
$console,
pickKeys,
} from "bknd/utils";
import { Hono } from "hono";
export type EmailOTPPluginOptions = {
/**
* Customize code generation. If not provided, a random 6-digit code will be generated.
*/
generateCode?: (user: Pick<DB["users"], "email">) => string;
/**
* The base path for the API endpoints.
* @default "/api/auth/otp"
*/
apiBasePath?: string;
/**
* The TTL for the OTP tokens in seconds.
* @default 600 (10 minutes)
*/
ttl?: number;
/**
* The name of the OTP entity.
* @default "users_otp"
*/
entity?: string;
/**
* The config for the OTP entity.
*/
entityConfig?: EntityConfig;
/**
* Customize email content. If not provided, a default email will be sent.
*/
generateEmail?: (
otp: EmailOTPFieldSchema,
) => MaybePromise<{ subject: string; body: string | { text: string; html: string } }>;
/**
* Enable debug mode for error messages.
* @default false
*/
showActualErrors?: boolean;
/**
* Allow direct mutations (create/update) of OTP codes outside of this plugin,
* e.g. via API or admin UI. If false, mutations are only allowed via the plugin's flows.
* @default false
*/
allowExternalMutations?: boolean;
/**
* Whether to send the email with the OTP code.
* @default true
*/
sendEmail?: boolean;
};
const otpFields = {
action: enumm({
enum: ["login", "register"],
}),
code: text().required(),
email: text().required(),
created_at: datetime(),
expires_at: datetime().required(),
used_at: datetime(),
};
export type EmailOTPFieldSchema = FieldSchema<typeof otpFields>;
class OTPError extends Exception {
override name = "OTPError";
override code = HttpStatus.BAD_REQUEST;
}
export function emailOTP({
generateCode: _generateCode,
apiBasePath = "/api/auth/otp",
ttl = 600,
entity: entityName = "users_otp",
entityConfig,
generateEmail: _generateEmail,
showActualErrors = false,
allowExternalMutations = false,
sendEmail = true,
}: EmailOTPPluginOptions = {}): AppPlugin {
return (app: App) => {
return {
name: "email-otp",
schema: () =>
em(
{
[entityName]: entity(
entityName,
otpFields,
{
name: "Users OTP",
sort_dir: "desc",
primary_format: app.module.data.config.default_primary_format,
...entityConfig,
},
"generated",
) as any,
},
({ index }, schema) => {
const otp = schema[entityName]!;
index(otp).on(["email", "expires_at", "code"]);
},
),
onBuilt: async () => {
const auth = app.module.auth;
invariant(auth && auth.enabled === true, "Auth is not enabled");
invariant(!sendEmail || app.drivers?.email, "Email driver is not registered");
const generateCode =
_generateCode ?? (() => Math.floor(100000 + Math.random() * 900000).toString());
const generateEmail =
_generateEmail ??
((otp: EmailOTPFieldSchema) => ({
subject: "OTP Code",
body: `Your OTP code is: ${otp.code}`,
}));
const em = app.em.fork();
const hono = new Hono()
.post(
"/login",
jsc(
"json",
s.object({
email: s.string({ format: "email" }),
code: s.string({ minLength: 1 }).optional(),
}),
),
jsc("query", s.object({ redirect: s.string().optional() })),
async (c) => {
const { email, code } = c.req.valid("json");
const { redirect } = c.req.valid("query");
const user = await findUser(app, email);
if (code) {
const otpData = await getValidatedCode(
app,
entityName,
email,
code,
"login",
);
await em.mutator(entityName).updateOne(otpData.id, { used_at: new Date() });
const jwt = await auth.authenticator.jwt(user);
// @ts-expect-error private method
return auth.authenticator.respondWithUser(
c,
{ user, token: jwt },
{ redirect },
);
} else {
const otpData = await invalidateAndGenerateCode(
app,
{ generateCode, ttl, entity: entityName },
user,
"login",
);
if (sendEmail) {
await sendCode(app, otpData, { generateEmail });
}
return c.json(
{
sent: true,
data: pickKeys(otpData, ["email", "action", "expires_at"]),
},
HttpStatus.CREATED,
);
}
},
)
.post(
"/register",
jsc(
"json",
s.object({
email: s.string({ format: "email" }),
code: s.string({ minLength: 1 }).optional(),
}),
),
jsc("query", s.object({ redirect: s.string().optional() })),
async (c) => {
const { email, code } = c.req.valid("json");
const { redirect } = c.req.valid("query");
// throw if user exists
if (!(await threwAsync(findUser(app, email)))) {
throw new Exception("User already exists", HttpStatus.BAD_REQUEST);
}
if (code) {
const otpData = await getValidatedCode(
app,
entityName,
email,
code,
"register",
);
await em.mutator(entityName).updateOne(otpData.id, { used_at: new Date() });
const user = await app.createUser({
email,
password: randomString(32, true),
});
const jwt = await auth.authenticator.jwt(user);
// @ts-expect-error private method
return auth.authenticator.respondWithUser(
c,
{ user, token: jwt },
{ redirect },
);
} else {
const otpData = await invalidateAndGenerateCode(
app,
{ generateCode, ttl, entity: entityName },
{ email },
"register",
);
if (sendEmail) {
await sendCode(app, otpData, { generateEmail });
}
return c.json(
{
sent: true,
data: pickKeys(otpData, ["email", "action", "expires_at"]),
},
HttpStatus.CREATED,
);
}
},
)
.onError((err) => {
if (showActualErrors || err instanceof OTPError) {
throw err;
}
throw new Exception("Invalid credentials", HttpStatus.BAD_REQUEST);
});
app.server.route(apiBasePath, hono);
if (allowExternalMutations !== true) {
registerListeners(app, entityName);
}
},
};
};
}
async function findUser(app: App, email: string) {
const user_entity = app.module.auth.config.entity_name as "users";
const { data: user } = await app.em.repo(user_entity).findOne({ email });
if (!user) {
throw new Exception("User not found", HttpStatus.BAD_REQUEST);
}
return user;
}
async function invalidateAndGenerateCode(
app: App,
opts: Required<Pick<EmailOTPPluginOptions, "generateCode" | "ttl" | "entity">>,
user: Pick<DB["users"], "email">,
action: EmailOTPFieldSchema["action"],
) {
const { generateCode, ttl, entity: entityName } = opts;
const newCode = generateCode?.(user);
if (!newCode) {
throw new OTPError("Failed to generate code");
}
await invalidateAllUserCodes(app, entityName, user.email, ttl);
const { data: otpData } = await app.em
.fork()
.mutator(entityName)
.insertOne({
code: newCode,
email: user.email,
action,
created_at: new Date(),
expires_at: new Date(Date.now() + ttl * 1000),
});
$console.log("[OTP Code]", newCode);
return otpData;
}
async function sendCode(
app: App,
otpData: EmailOTPFieldSchema,
opts: Required<Pick<EmailOTPPluginOptions, "generateEmail">>,
) {
const { generateEmail } = opts;
const { subject, body } = await generateEmail(otpData);
await app.drivers?.email?.send(otpData.email, subject, body);
}
async function getValidatedCode(
app: App,
entityName: string,
email: string,
code: string,
action: EmailOTPFieldSchema["action"],
) {
invariant(email, "[OTP Plugin]: Email is required");
invariant(code, "[OTP Plugin]: Code is required");
const em = app.em.fork();
const { data: otpData } = await em.repo(entityName).findOne({ email, code, action });
if (!otpData) {
throw new OTPError("Invalid code");
}
if (otpData.expires_at < new Date()) {
throw new OTPError("Code expired");
}
if (otpData.used_at) {
throw new OTPError("Code already used");
}
return otpData;
}
async function invalidateAllUserCodes(app: App, entityName: string, email: string, ttl: number) {
invariant(ttl > 0, "[OTP Plugin]: TTL must be greater than 0");
invariant(email, "[OTP Plugin]: Email is required");
const em = app.em.fork();
await em
.mutator(entityName)
.updateWhere(
{ expires_at: new Date(Date.now() - 1000) },
{ email, used_at: { $isnull: true } },
);
}
function registerListeners(app: App, entityName: string) {
[DatabaseEvents.MutatorInsertBefore, DatabaseEvents.MutatorUpdateBefore].forEach((event) => {
app.emgr.onEvent(
event,
(e: { params: { entity: { name: string } } }) => {
if (e.params.entity.name === entityName) {
throw new OTPError("Mutations of the OTP entity are not allowed");
}
},
{
mode: "sync",
id: "bknd-email-otp",
},
);
});
}
-1
View File
@@ -8,4 +8,3 @@ export { syncConfig, type SyncConfigOptions } from "./dev/sync-config.plugin";
export { syncTypes, type SyncTypesOptions } from "./dev/sync-types.plugin"; export { syncTypes, type SyncTypesOptions } from "./dev/sync-types.plugin";
export { syncSecrets, type SyncSecretsOptions } from "./dev/sync-secrets.plugin"; export { syncSecrets, type SyncSecretsOptions } from "./dev/sync-secrets.plugin";
export { timestamps, type TimestampsPluginOptions } from "./data/timestamps.plugin"; export { timestamps, type TimestampsPluginOptions } from "./data/timestamps.plugin";
export { emailOTP, type EmailOTPPluginOptions } from "./auth/email-otp.plugin";
+20 -22
View File
@@ -5,7 +5,7 @@ import { BkndProvider } from "ui/client/bknd";
import { useTheme, type AppTheme } from "ui/client/use-theme"; import { useTheme, type AppTheme } from "ui/client/use-theme";
import { Logo } from "ui/components/display/Logo"; import { Logo } from "ui/components/display/Logo";
import * as AppShell from "ui/layouts/AppShell/AppShell"; import * as AppShell from "ui/layouts/AppShell/AppShell";
import { ClientProvider, useBkndWindowContext, type ClientProviderProps } from "bknd/client"; import { ClientProvider, useBkndWindowContext, type ClientProviderProps } from "./client";
import { createMantineTheme } from "./lib/mantine/theme"; import { createMantineTheme } from "./lib/mantine/theme";
import { Routes } from "./routes"; import { Routes } from "./routes";
import type { BkndAdminAppShellOptions, BkndAdminEntitiesOptions } from "./options"; import type { BkndAdminAppShellOptions, BkndAdminEntitiesOptions } from "./options";
@@ -52,30 +52,26 @@ export type BkndAdminProps = {
children?: ReactNode; children?: ReactNode;
}; };
export default function Admin(props: BkndAdminProps) { export default function Admin({
baseUrl: baseUrlOverride,
withProvider = false,
config: _config = {},
children,
}: BkndAdminProps) {
const { theme } = useTheme();
const Provider = ({ children }: any) => const Provider = ({ children }: any) =>
props.withProvider ? ( withProvider ? (
<ClientProvider <ClientProvider
baseUrl={props.baseUrl} baseUrl={baseUrlOverride}
{...(typeof props.withProvider === "object" ? props.withProvider : {})} {...(typeof withProvider === "object" ? withProvider : {})}
> >
{children} {children}
</ClientProvider> </ClientProvider>
) : ( ) : (
children children
); );
return (
<Provider>
<AdminInner {...props} />
</Provider>
);
}
function AdminInner(props: BkndAdminProps) {
const { theme } = useTheme();
const config = { const config = {
...props.config, ..._config,
...useBkndWindowContext(), ...useBkndWindowContext(),
}; };
@@ -86,12 +82,14 @@ function AdminInner(props: BkndAdminProps) {
); );
return ( return (
<MantineProvider {...createMantineTheme(theme as any)}> <Provider>
<Notifications position="top-right" /> <MantineProvider {...createMantineTheme(theme as any)}>
<Routes BkndWrapper={BkndWrapper} basePath={config?.basepath}> <Notifications position="top-right" />
{props.children} <Routes BkndWrapper={BkndWrapper} basePath={config?.basepath}>
</Routes> {children}
</MantineProvider> </Routes>
</MantineProvider>
</Provider>
); );
} }
+1 -1
View File
@@ -9,7 +9,7 @@ import {
useState, useState,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { useApi } from "bknd/client"; import { useApi } from "ui/client";
import { type TSchemaActions, getSchemaActions } from "./schema/actions"; import { type TSchemaActions, getSchemaActions } from "./schema/actions";
import { AppReduced } from "./utils/AppReduced"; import { AppReduced } from "./utils/AppReduced";
import { Message } from "ui/components/display/Message"; import { Message } from "ui/components/display/Message";
+2 -10
View File
@@ -14,20 +14,18 @@ const ClientContext = createContext<BkndClientContext>(undefined!);
export type ClientProviderProps = { export type ClientProviderProps = {
children?: ReactNode; children?: ReactNode;
baseUrl?: string; baseUrl?: string;
api?: Api;
} & ApiOptions; } & ApiOptions;
export const ClientProvider = ({ export const ClientProvider = ({
children, children,
host, host,
baseUrl: _baseUrl = host, baseUrl: _baseUrl = host,
api: _api,
...props ...props
}: ClientProviderProps) => { }: ClientProviderProps) => {
const winCtx = useBkndWindowContext(); const winCtx = useBkndWindowContext();
const _ctx = useClientContext(); const _ctx = useClientContext();
let actualBaseUrl = _baseUrl ?? _ctx?.baseUrl ?? ""; let actualBaseUrl = _baseUrl ?? _ctx?.baseUrl ?? "";
let user: any; let user: any = undefined;
if (winCtx) { if (winCtx) {
user = winCtx.user; user = winCtx.user;
@@ -42,7 +40,6 @@ export const ClientProvider = ({
const apiProps = { user, ...props, host: actualBaseUrl }; const apiProps = { user, ...props, host: actualBaseUrl };
const api = useMemo( const api = useMemo(
() => () =>
_api ??
new Api({ new Api({
...apiProps, ...apiProps,
verbose: isDebug(), verbose: isDebug(),
@@ -53,7 +50,7 @@ export const ClientProvider = ({
} }
}, },
}), }),
[_api, JSON.stringify(apiProps)], [JSON.stringify(apiProps)],
); );
const [authState, setAuthState] = useState<Partial<AuthState> | undefined>(api.getAuthState()); const [authState, setAuthState] = useState<Partial<AuthState> | undefined>(api.getAuthState());
@@ -67,14 +64,9 @@ export const ClientProvider = ({
export const useApi = (host?: ApiOptions["host"]): Api => { export const useApi = (host?: ApiOptions["host"]): Api => {
const context = useContext(ClientContext); const context = useContext(ClientContext);
if (!context?.api || (host && host.length > 0 && host !== context.baseUrl)) { if (!context?.api || (host && host.length > 0 && host !== context.baseUrl)) {
console.info("creating new api", { host });
return new Api({ host: host ?? "" }); return new Api({ host: host ?? "" });
} }
if (!context) {
throw new Error("useApi must be used within a ClientProvider");
}
return context.api; return context.api;
}; };
+1 -1
View File
@@ -2,7 +2,7 @@ import type { Api } from "Api";
import { FetchPromise, type ModuleApi, type ResponseObject } from "modules/ModuleApi"; import { FetchPromise, type ModuleApi, type ResponseObject } from "modules/ModuleApi";
import useSWR, { type SWRConfiguration, useSWRConfig, type Middleware, type SWRHook } from "swr"; import useSWR, { type SWRConfiguration, useSWRConfig, type Middleware, type SWRHook } from "swr";
import useSWRInfinite from "swr/infinite"; import useSWRInfinite from "swr/infinite";
import { useApi } from "../ClientProvider"; import { useApi } from "ui/client";
import { useState } from "react"; import { useState } from "react";
export const useApiQuery = < export const useApiQuery = <
+1 -1
View File
@@ -10,7 +10,7 @@ import type {
import { objectTransform, encodeSearch } from "bknd/utils"; import { objectTransform, encodeSearch } from "bknd/utils";
import type { Insertable, Selectable, Updateable, Generated } from "kysely"; import type { Insertable, Selectable, Updateable, Generated } from "kysely";
import useSWR, { type SWRConfiguration, type SWRResponse, mutate } from "swr"; import useSWR, { type SWRConfiguration, type SWRResponse, mutate } from "swr";
import { type Api, useApi } from "bknd/client"; import { type Api, useApi } from "ui/client";
export class UseEntityApiError<Payload = any> extends Error { export class UseEntityApiError<Payload = any> extends Error {
constructor( constructor(
-1
View File
@@ -4,7 +4,6 @@ export {
type ClientProviderProps, type ClientProviderProps,
useApi, useApi,
useBaseUrl, useBaseUrl,
useClientContext
} from "./ClientProvider"; } from "./ClientProvider";
export * from "./api/use-api"; export * from "./api/use-api";
+2 -3
View File
@@ -1,6 +1,7 @@
import type { AuthState } from "Api"; import type { AuthState } from "Api";
import type { AuthResponse } from "bknd"; import type { AuthResponse } from "bknd";
import { useApi, useInvalidate, useClientContext } from "bknd/client"; import { useApi, useInvalidate } from "ui/client";
import { useClientContext } from "ui/client/ClientProvider";
type LoginData = { type LoginData = {
email: string; email: string;
@@ -18,7 +19,6 @@ type UseAuth = {
logout: () => Promise<void>; logout: () => Promise<void>;
verify: () => Promise<void>; verify: () => Promise<void>;
setToken: (token: string) => void; setToken: (token: string) => void;
local: boolean;
}; };
export const useAuth = (options?: { baseUrl?: string }): UseAuth => { export const useAuth = (options?: { baseUrl?: string }): UseAuth => {
@@ -61,6 +61,5 @@ export const useAuth = (options?: { baseUrl?: string }): UseAuth => {
logout, logout,
setToken, setToken,
verify, verify,
local: !!api.options.storage,
}; };
}; };
+2 -1
View File
@@ -1,5 +1,6 @@
import type React from "react"; import type React from "react";
import { Children, forwardRef } from "react"; import { Children } from "react";
import { forwardRef } from "react";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { Link } from "ui/components/wouter/Link"; import { Link } from "ui/components/wouter/Link";
@@ -1,6 +1,6 @@
import { Tooltip } from "@mantine/core"; import { Tooltip } from "@mantine/core";
import clsx from "clsx"; import clsx from "clsx";
import { getBrowser } from "bknd/utils"; import { getBrowser } from "core/utils";
import type { Field } from "data/fields"; import type { Field } from "data/fields";
import { Switch as RadixSwitch } from "radix-ui"; import { Switch as RadixSwitch } from "radix-ui";
import { import {
@@ -16,18 +16,15 @@ import {
setPath, setPath,
} from "./utils"; } from "./utils";
export type NativeFormProps = Omit<ComponentPropsWithoutRef<"form">, "onChange" | "onSubmit"> & { export type NativeFormProps = {
hiddenSubmit?: boolean; hiddenSubmit?: boolean;
validateOn?: "change" | "submit"; validateOn?: "change" | "submit";
errorFieldSelector?: (selector: string) => any | null; errorFieldSelector?: <K extends keyof HTMLElementTagNameMap>(name: string) => any | null;
reportValidity?: boolean; reportValidity?: boolean;
onSubmit?: ( onSubmit?: (data: any, ctx: { event: FormEvent<HTMLFormElement> }) => Promise<void> | void;
data: any,
ctx: { event: FormEvent<HTMLFormElement>; form: HTMLFormElement },
) => Promise<void> | void;
onSubmitInvalid?: ( onSubmitInvalid?: (
errors: InputError[], errors: InputError[],
ctx: { event: FormEvent<HTMLFormElement>; form: HTMLFormElement }, ctx: { event: FormEvent<HTMLFormElement> },
) => Promise<void> | void; ) => Promise<void> | void;
onError?: (errors: InputError[]) => void; onError?: (errors: InputError[]) => void;
disableSubmitOnError?: boolean; disableSubmitOnError?: boolean;
@@ -36,7 +33,7 @@ export type NativeFormProps = Omit<ComponentPropsWithoutRef<"form">, "onChange"
ctx: { event: ChangeEvent<HTMLFormElement>; key: string; value: any; errors: InputError[] }, ctx: { event: ChangeEvent<HTMLFormElement>; key: string; value: any; errors: InputError[] },
) => Promise<void> | void; ) => Promise<void> | void;
clean?: CleanOptions | true; clean?: CleanOptions | true;
}; } & Omit<ComponentPropsWithoutRef<"form">, "onChange" | "onSubmit">;
export type InputError = { export type InputError = {
name: string; name: string;
@@ -191,12 +188,12 @@ export function NativeForm({
const errors = validate({ report: true }); const errors = validate({ report: true });
if (errors.length > 0) { if (errors.length > 0) {
onSubmitInvalid?.(errors, { event: e, form }); onSubmitInvalid?.(errors, { event: e });
return; return;
} }
if (onSubmit) { if (onSubmit) {
await onSubmit(getFormValues(), { event: e, form }); await onSubmit(getFormValues(), { event: e });
} else { } else {
form.submit(); form.submit();
} }
+2 -1
View File
@@ -1,4 +1,5 @@
import { type LinkProps, Link as WouterLink, useRouter } from "wouter"; import { useInsertionEffect, useRef } from "react";
import { type LinkProps, Link as WouterLink, useRoute, useRouter } from "wouter";
import { useEvent } from "../../hooks/use-event"; import { useEvent } from "../../hooks/use-event";
/* /*
+12 -42
View File
@@ -1,16 +1,13 @@
import type { AppAuthOAuthStrategy, AppAuthSchema } from "auth/auth-schema"; import type { AppAuthOAuthStrategy, AppAuthSchema } from "auth/auth-schema";
import clsx from "clsx"; import clsx from "clsx";
import { NativeForm } from "ui/components/form/native-form/NativeForm"; import { NativeForm } from "ui/components/form/native-form/NativeForm";
import { transformObject } from "bknd/utils"; import { transform } from "lodash-es";
import { useEffect, useState, type ComponentPropsWithoutRef, type FormEvent } from "react"; import type { ComponentPropsWithoutRef } from "react";
import { Button } from "ui/components/buttons/Button"; import { Button } from "ui/components/buttons/Button";
import { Group, Input, Password, Label } from "ui/components/form/Formy/components"; import { Group, Input, Password, Label } from "ui/components/form/Formy/components";
import { SocialLink } from "./SocialLink"; import { SocialLink } from "./SocialLink";
import { useAuth } from "bknd/client";
import { Alert } from "ui/components/display/Alert";
import { useLocation } from "wouter";
export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "action"> & { export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "onSubmit" | "action"> & {
className?: string; className?: string;
formData?: any; formData?: any;
action: "login" | "register"; action: "login" | "register";
@@ -26,49 +23,24 @@ export function AuthForm({
action, action,
auth, auth,
buttonLabel = action === "login" ? "Sign in" : "Sign up", buttonLabel = action === "login" ? "Sign in" : "Sign up",
onSubmit: _onSubmit,
...props ...props
}: LoginFormProps) { }: LoginFormProps) {
const $auth = useAuth();
const basepath = auth?.basepath ?? "/api/auth"; const basepath = auth?.basepath ?? "/api/auth";
const [error, setError] = useState<string>();
const [, navigate] = useLocation();
const password = { const password = {
action: `${basepath}/password/${action}`, action: `${basepath}/password/${action}`,
strategy: auth?.strategies?.password ?? ({ type: "password" } as const), strategy: auth?.strategies?.password ?? ({ type: "password" } as const),
}; };
const oauth = transformObject(auth?.strategies ?? {}, (value) => { const oauth = transform(
return value.type !== "password" ? value.config : undefined; auth?.strategies ?? {},
}) as Record<string, AppAuthOAuthStrategy>; (result, value, key) => {
const has_oauth = Object.keys(oauth).length > 0; if (value.type !== "password") {
result[key] = value.config;
async function onSubmit(
data: any,
ctx: { event: FormEvent<HTMLFormElement>; form: HTMLFormElement },
) {
if ($auth?.local) {
ctx.event.preventDefault();
const res = await $auth.login(data);
if ("token" in res) {
navigate("/");
} else {
setError((res as any).error);
return;
} }
} },
{},
await _onSubmit?.(ctx.event); ) as Record<string, AppAuthOAuthStrategy>;
// submit form const has_oauth = Object.keys(oauth).length > 0;
ctx.form.submit();
}
useEffect(() => {
if ($auth.user) {
navigate("/");
}
}, [$auth.user]);
return ( return (
<div className="flex flex-col gap-4 w-full"> <div className="flex flex-col gap-4 w-full">
@@ -91,12 +63,10 @@ export function AuthForm({
<NativeForm <NativeForm
method={method} method={method}
action={password.action} action={password.action}
onSubmit={onSubmit}
{...(props as any)} {...(props as any)}
validateOn="change" validateOn="change"
className={clsx("flex flex-col gap-3 w-full", className)} className={clsx("flex flex-col gap-3 w-full", className)}
> >
{error && <Alert.Exception message={error} className="justify-center" />}
<Group> <Group>
<Label htmlFor="email">Email address</Label> <Label htmlFor="email">Email address</Label>
<Input type="email" name="email" required /> <Input type="email" name="email" required />
+1 -1
View File
@@ -1,4 +1,4 @@
import { ucFirstAllSnakeToPascalWithSpaces } from "bknd/utils"; import { ucFirstAllSnakeToPascalWithSpaces } from "core/utils";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Button } from "ui/components/buttons/Button"; import { Button } from "ui/components/buttons/Button";
import type { IconType } from "ui/components/buttons/IconButton"; import type { IconType } from "ui/components/buttons/IconButton";
+2 -4
View File
@@ -1,11 +1,9 @@
import type { AppAuthSchema } from "auth/auth-schema"; import type { AppAuthSchema } from "auth/auth-schema";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useApi } from "bknd/client"; import { useApi } from "ui/client";
type AuthStrategyData = Pick<AppAuthSchema, "strategies" | "basepath">; type AuthStrategyData = Pick<AppAuthSchema, "strategies" | "basepath">;
export const useAuthStrategies = (options?: { export const useAuthStrategies = (options?: { baseUrl?: string }): Partial<AuthStrategyData> & {
baseUrl?: string;
}): Partial<AuthStrategyData> & {
loading: boolean; loading: boolean;
} => { } => {
const [data, setData] = useState<AuthStrategyData>(); const [data, setData] = useState<AuthStrategyData>();
+1 -1
View File
@@ -14,7 +14,7 @@ import { isFileAccepted } from "bknd/utils";
import { type FileWithPath, useDropzone } from "./use-dropzone"; import { type FileWithPath, useDropzone } from "./use-dropzone";
import { checkMaxReached } from "./helper"; import { checkMaxReached } from "./helper";
import { DropzoneInner } from "./DropzoneInner"; import { DropzoneInner } from "./DropzoneInner";
import { createDropzoneStore } from "./dropzone-state"; import { createDropzoneStore } from "ui/elements/media/dropzone-state";
import { useStore } from "zustand"; import { useStore } from "zustand";
export type FileState = { export type FileState = {
+22 -19
View File
@@ -1,8 +1,9 @@
import type { Api } from "bknd/client";
import type { PrimaryFieldType, RepoQueryIn } from "bknd"; import type { PrimaryFieldType, RepoQueryIn } from "bknd";
import type { MediaFieldSchema } from "media/AppMedia"; import type { MediaFieldSchema } from "media/AppMedia";
import type { TAppMediaConfig } from "media/media-schema"; import type { TAppMediaConfig } from "media/media-schema";
import { useId, useEffect, useRef, useState } from "react"; import { useId, useEffect, useRef, useState } from "react";
import { type Api, useApi, useApiInfiniteQuery, useApiQuery, useInvalidate } from "bknd/client"; import { useApi, useApiInfiniteQuery, useApiQuery, useInvalidate } from "bknd/client";
import { useEvent } from "ui/hooks/use-event"; import { useEvent } from "ui/hooks/use-event";
import { Dropzone, type DropzoneProps } from "./Dropzone"; import { Dropzone, type DropzoneProps } from "./Dropzone";
import { mediaItemsToFileStates } from "./helper"; import { mediaItemsToFileStates } from "./helper";
@@ -131,24 +132,26 @@ export function DropzoneContainer({
} }
return ( return (
<Dropzone <>
key={key} <Dropzone
getUploadInfo={getUploadInfo} key={key}
handleDelete={handleDelete} getUploadInfo={getUploadInfo}
autoUpload handleDelete={handleDelete}
initialItems={_initialItems} autoUpload
footer={ initialItems={_initialItems}
infinite && footer={
"setSize" in $q && ( infinite &&
<Footer "setSize" in $q && (
items={_initialItems.length} <Footer
length={placeholderLength} items={_initialItems.length}
onFirstVisible={() => $q.setSize($q.size + 1)} length={placeholderLength}
/> onFirstVisible={() => $q.setSize($q.size + 1)}
) />
} )
{...props} }
/> {...props}
/>
</>
); );
} }
+2 -2
View File
@@ -19,8 +19,8 @@ import {
} from "react-icons/tb"; } from "react-icons/tb";
import { Dropdown, type DropdownItem } from "ui/components/overlay/Dropdown"; import { Dropdown, type DropdownItem } from "ui/components/overlay/Dropdown";
import { IconButton } from "ui/components/buttons/IconButton"; import { IconButton } from "ui/components/buttons/IconButton";
import { formatNumber } from "bknd/utils"; import { formatNumber } from "core/utils";
import type { DropzoneRenderProps, FileState } from "./Dropzone"; import type { DropzoneRenderProps, FileState } from "ui/elements";
import { useDropzoneFileState, useDropzoneState } from "./Dropzone"; import { useDropzoneFileState, useDropzoneState } from "./Dropzone";
function handleUploadError(e: unknown) { function handleUploadError(e: unknown) {
+3 -5
View File
@@ -10,7 +10,7 @@ import {
TbUser, TbUser,
TbX, TbX,
} from "react-icons/tb"; } from "react-icons/tb";
import { useAuth, useBkndWindowContext } from "bknd/client"; import { useAuth, useBkndWindowContext } from "ui/client";
import { useBknd } from "ui/client/bknd"; import { useBknd } from "ui/client/bknd";
import { useTheme } from "ui/client/use-theme"; import { useTheme } from "ui/client/use-theme";
import { Button } from "ui/components/buttons/Button"; import { Button } from "ui/components/buttons/Button";
@@ -154,10 +154,8 @@ function UserMenu() {
async function handleLogout() { async function handleLogout() {
await auth.logout(); await auth.logout();
// @todo: grab from somewhere constant
if (!auth.local) { navigate(logout_route, { reload: true });
navigate(logout_route, { reload: true });
}
} }
async function handleLogin() { async function handleLogin() {
+1 -1
View File
@@ -1,6 +1,6 @@
import type { ContextModalProps } from "@mantine/modals"; import type { ContextModalProps } from "@mantine/modals";
import { type ReactNode, useEffect, useMemo, useState } from "react"; import { type ReactNode, useEffect, useMemo, useState } from "react";
import { useEntityQuery } from "bknd/client"; import { useEntityQuery } from "ui/client";
import { type FileState, Media } from "ui/elements"; import { type FileState, Media } from "ui/elements";
import { autoFormatString, datetimeStringLocal, formatNumber } from "core/utils"; import { autoFormatString, datetimeStringLocal, formatNumber } from "core/utils";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
@@ -1,4 +1,4 @@
import { useApi, useInvalidate } from "bknd/client"; import { useApi, useInvalidate } from "ui/client";
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth"; import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
import { routes, useNavigate } from "ui/lib/routes"; import { routes, useNavigate } from "ui/lib/routes";
import { bkndModals } from "ui/modals"; import { bkndModals } from "ui/modals";
@@ -4,7 +4,7 @@ import type { EntityData } from "bknd";
import type { RelationField } from "data/relations"; import type { RelationField } from "data/relations";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { TbEye } from "react-icons/tb"; import { TbEye } from "react-icons/tb";
import { useEntityQuery } from "bknd/client"; import { useEntityQuery } from "ui/client";
import { useBknd } from "ui/client/bknd"; import { useBknd } from "ui/client/bknd";
import { Button } from "ui/components/buttons/Button"; import { Button } from "ui/components/buttons/Button";
import * as Formy from "ui/components/form/Formy"; import * as Formy from "ui/components/form/Formy";
+1 -1
View File
@@ -1,6 +1,6 @@
import clsx from "clsx"; import clsx from "clsx";
import { TbArrowRight, TbCircle, TbCircleCheckFilled, TbFingerprint } from "react-icons/tb"; import { TbArrowRight, TbCircle, TbCircleCheckFilled, TbFingerprint } from "react-icons/tb";
import { useApiQuery } from "bknd/client"; import { useApiQuery } from "ui/client";
import { useBknd } from "ui/client/bknd"; import { useBknd } from "ui/client/bknd";
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth"; import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
import { ButtonLink, type ButtonLinkProps } from "ui/components/buttons/Button"; import { ButtonLink, type ButtonLinkProps } from "ui/components/buttons/Button";
@@ -35,7 +35,7 @@ import { SegmentedControl, Tooltip } from "@mantine/core";
import { Popover } from "ui/components/overlay/Popover"; import { Popover } from "ui/components/overlay/Popover";
import { cn } from "ui/lib/utils"; import { cn } from "ui/lib/utils";
import { JsonViewer } from "ui/components/code/JsonViewer"; import { JsonViewer } from "ui/components/code/JsonViewer";
import { mountOnce, useApiQuery } from "bknd/client"; import { mountOnce, useApiQuery } from "ui/client";
import { CodePreview } from "ui/components/code/CodePreview"; import { CodePreview } from "ui/components/code/CodePreview";
import type { JsonError } from "json-schema-library"; import type { JsonError } from "json-schema-library";
import { Alert } from "ui/components/display/Alert"; import { Alert } from "ui/components/display/Alert";
+1 -1
View File
@@ -3,7 +3,7 @@ import { ucFirst } from "bknd/utils";
import type { Entity, EntityData, EntityRelation } from "bknd"; import type { Entity, EntityData, EntityRelation } from "bknd";
import { Fragment, useState } from "react"; import { Fragment, useState } from "react";
import { TbDots } from "react-icons/tb"; import { TbDots } from "react-icons/tb";
import { useApiQuery, useEntityQuery } from "bknd/client"; import { useApiQuery, useEntityQuery } from "ui/client";
import { useBkndData } from "ui/client/schema/data/use-bknd-data"; import { useBkndData } from "ui/client/schema/data/use-bknd-data";
import { Button } from "ui/components/buttons/Button"; import { Button } from "ui/components/buttons/Button";
import { IconButton } from "ui/components/buttons/IconButton"; import { IconButton } from "ui/components/buttons/IconButton";
@@ -1,6 +1,6 @@
import type { EntityData } from "bknd"; import type { EntityData } from "bknd";
import { useState } from "react"; import { useState } from "react";
import { useEntityMutate } from "bknd/client"; import { useEntityMutate } from "ui/client";
import { useBkndData } from "ui/client/schema/data/use-bknd-data"; import { useBkndData } from "ui/client/schema/data/use-bknd-data";
import { Button } from "ui/components/buttons/Button"; import { Button } from "ui/components/buttons/Button";
import { Message } from "ui/components/display/Message"; import { Message } from "ui/components/display/Message";

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