mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-15 20:17:22 +00:00
Merge branch 'main' into cp/216-fix-users-link
This commit is contained in:
7
.cursor/mcp.json
Normal file
7
.cursor/mcp.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"bknd": {
|
||||
"url": "http://localhost:28623/api/system/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: "1.2.19"
|
||||
bun-version: "1.2.22"
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./app
|
||||
|
||||
29
README.md
29
README.md
@@ -1,34 +1,50 @@
|
||||
[](https://npmjs.org/package/bknd)
|
||||
|
||||

|
||||

|
||||
|
||||
<p align="center" width="100%">
|
||||
<a href="https://stackblitz.com/github/bknd-io/bknd-examples?hideExplorer=1&embed=1&view=preview&startScript=example-admin-rich&initialPath=%2Fdata%2Fschema" target="_blank">
|
||||
<a href="https://stackblitz.com/github/bknd-io/bknd-demo?hideExplorer=1&embed=1&view=preview&initialPath=%2Fdata%2Fschema" target="_blank">
|
||||
<strong>⭐ Live Demo</strong>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
bknd simplifies app development by providing a fully functional backend for database management, authentication, media and workflows. Being lightweight and built on Web Standards, it can be deployed nearly anywhere, including running inside your framework of choice. No more deploying multiple separate services!
|
||||
bknd simplifies app development by providing a fully functional visual backend for database management, authentication, media and workflows. Being lightweight and built on Web Standards, it can be deployed nearly anywhere, including running inside your framework of choice. No more deploying multiple separate services!
|
||||
|
||||
It's designed to avoid vendor lock-in and architectural limitations. Built exclusively on [WinterTC Minimum Common Web Platform API](https://min-common-api.proposal.wintertc.org/) for universal compatibility, all functionality (data, auth, media, flows) is modular and opt-in, and infrastructure access is adapter-based with direct access to underlying drivers giving you full control without abstractions getting in your way.
|
||||
|
||||
* **Runtimes**: Node.js 22+, Bun 1.0+, Deno, Browser, Cloudflare Workers/Pages, Vercel, Netlify, AWS Lambda, etc.
|
||||
* **Databases**:
|
||||
* SQLite: LibSQL, Node SQLite, Bun SQLite, Cloudflare D1, Cloudflare Durable Objects SQLite, SQLocal
|
||||
* Postgres: Vanilla Postgres, Supabase, Neon, Xata
|
||||
* **Frameworks**: React, Next.js, React Router, Astro, Vite, Waku
|
||||
* **Storage**: AWS S3, S3-compatible (Tigris, R2, Minio, etc.), Cloudflare R2 (binding), Cloudinary, Filesystem
|
||||
* **Deployment**: Standalone, Docker, Cloudflare Workers, Vercel, Netlify, Deno Deploy, AWS Lambda, Valtown etc.
|
||||
|
||||
**For documentation and examples, please visit https://docs.bknd.io.**
|
||||
|
||||
> [!WARNING]
|
||||
> This project requires Node.js 22 or higher (because of `node:sqlite`).
|
||||
> This project requires Node.js 22.13 or higher (because of `node:sqlite`).
|
||||
>
|
||||
> Please keep in mind that **bknd** is still under active development
|
||||
> and therefore full backward compatibility is not guaranteed before reaching v1.0.0.
|
||||
|
||||
## Use Cases
|
||||
|
||||
bknd is a general purpose backend system that implements the primitives almost any backend needs. This way, you can use it for any backend use case, including but not limited to:
|
||||
|
||||
- **Content Management System (CMS)** as Wordpress alternative, hosted separately or embedded in your frontend
|
||||
- **AI Agent Backends** for managing agent state with built-in data persistence, regardless where it is hosted. Optionally communicate over the integrated MCP server.
|
||||
- **SaaS Products** with multi-tenant data isolation (RLS) and user management, with freedom to choose your own database and storage provider
|
||||
- **Prototypes & MVPs** to validate ideas quickly without infrastructure overhead
|
||||
- **API-First Applications** where you need a reliable, type-safe backend without vendor lock-in either with the integrated TypeScript SDK or REST API using OpenAPI
|
||||
- **IoT & Embedded Devices** where minimal footprint matters
|
||||
|
||||
|
||||
## Size
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
The size on npm is misleading, as the `bknd` package includes the backend, the ui components as well as the whole backend bundled into the cli including static assets.
|
||||
|
||||
@@ -46,6 +62,7 @@ Creating digital products always requires developing both the backend (the logic
|
||||
* **Media**: Effortlessly manage and serve all your media files.
|
||||
* **Flows**: Design and run workflows with seamless automation. (UI integration coming soon!)
|
||||
* 🌐 Built on Web Standards for maximum compatibility
|
||||
* 🛠️ MCP server, client and UI built-in to control your backend
|
||||
* 🏃♂️ Multiple run modes
|
||||
* standalone using the CLI
|
||||
* using a JavaScript runtime (Node, Bun, workerd)
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { afterEach, describe, test, expect } from "bun:test";
|
||||
import { afterEach, describe, test, expect, beforeAll, afterAll } from "bun:test";
|
||||
import { App, createApp } from "core/test/utils";
|
||||
import { getDummyConnection } from "./helper";
|
||||
import { Hono } from "hono";
|
||||
import * as proto from "../src/data/prototype";
|
||||
import { pick } from "lodash-es";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
beforeAll(() => disableConsoleLog());
|
||||
|
||||
const { dummyConnection, afterAllCleanup } = getDummyConnection();
|
||||
afterEach(afterAllCleanup);
|
||||
afterEach(async () => (await afterAllCleanup()) && enableConsoleLog());
|
||||
|
||||
describe("App tests", async () => {
|
||||
test("boots and pongs", async () => {
|
||||
@@ -19,7 +22,7 @@ describe("App tests", async () => {
|
||||
test("plugins", async () => {
|
||||
const called: string[] = [];
|
||||
const app = createApp({
|
||||
initialConfig: {
|
||||
config: {
|
||||
auth: {
|
||||
enabled: true,
|
||||
},
|
||||
|
||||
BIN
app/__test__/_assets/test.mp3
Normal file
BIN
app/__test__/_assets/test.mp3
Normal file
Binary file not shown.
1
app/__test__/_assets/test.txt
Normal file
1
app/__test__/_assets/test.txt
Normal file
@@ -0,0 +1 @@
|
||||
hello
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, describe, it, beforeAll, afterAll } from "bun:test";
|
||||
import { expect, describe, it, beforeAll, afterAll, mock } from "bun:test";
|
||||
import * as adapter from "adapter";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
||||
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
||||
@@ -9,60 +9,49 @@ beforeAll(disableConsoleLog);
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
describe("adapter", () => {
|
||||
it("makes config", () => {
|
||||
expect(omitKeys(adapter.makeConfig({}), ["connection"])).toEqual({});
|
||||
expect(omitKeys(adapter.makeConfig({}, { env: { TEST: "test" } }), ["connection"])).toEqual(
|
||||
{},
|
||||
);
|
||||
it("makes config", async () => {
|
||||
expect(omitKeys(await adapter.makeConfig({}), ["connection"])).toEqual({});
|
||||
expect(
|
||||
omitKeys(await adapter.makeConfig({}, { env: { TEST: "test" } }), ["connection"]),
|
||||
).toEqual({});
|
||||
|
||||
// merges everything returned from `app` with the config
|
||||
expect(
|
||||
omitKeys(
|
||||
adapter.makeConfig(
|
||||
{ app: (a) => ({ initialConfig: { server: { cors: { origin: a.env.TEST } } } }) },
|
||||
await adapter.makeConfig(
|
||||
{ app: (a) => ({ config: { server: { cors: { origin: a.env.TEST } } } }) },
|
||||
{ env: { TEST: "test" } },
|
||||
),
|
||||
["connection"],
|
||||
),
|
||||
).toEqual({
|
||||
initialConfig: { server: { cors: { origin: "test" } } },
|
||||
config: { server: { cors: { origin: "test" } } },
|
||||
});
|
||||
});
|
||||
|
||||
/* it.only("...", async () => {
|
||||
const app = await adapter.createAdapterApp();
|
||||
}); */
|
||||
|
||||
it("reuses apps correctly", async () => {
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
const first = await adapter.createAdapterApp(
|
||||
it("allows all properties in app function", async () => {
|
||||
const called = mock(() => null);
|
||||
const config = await adapter.makeConfig(
|
||||
{
|
||||
initialConfig: { server: { cors: { origin: "random" } } },
|
||||
app: (env) => ({
|
||||
connection: { url: "test" },
|
||||
config: { server: { cors: { origin: "test" } } },
|
||||
options: {
|
||||
mode: "db",
|
||||
},
|
||||
onBuilt: () => {
|
||||
called();
|
||||
expect(env).toEqual({ foo: "bar" });
|
||||
},
|
||||
}),
|
||||
},
|
||||
undefined,
|
||||
{ id },
|
||||
{ foo: "bar" },
|
||||
);
|
||||
const second = await adapter.createAdapterApp();
|
||||
const third = await adapter.createAdapterApp(undefined, undefined, { id });
|
||||
|
||||
await first.build();
|
||||
await second.build();
|
||||
await third.build();
|
||||
|
||||
expect(first.toJSON().server.cors.origin).toEqual("random");
|
||||
expect(first).toBe(third);
|
||||
expect(first).not.toBe(second);
|
||||
expect(second).not.toBe(third);
|
||||
expect(second.toJSON().server.cors.origin).toEqual("*");
|
||||
|
||||
// recreate the first one
|
||||
const first2 = await adapter.createAdapterApp(undefined, undefined, { id, force: true });
|
||||
await first2.build();
|
||||
expect(first2).not.toBe(first);
|
||||
expect(first2).not.toBe(third);
|
||||
expect(first2).not.toBe(second);
|
||||
expect(first2.toJSON().server.cors.origin).toEqual("*");
|
||||
expect(config.connection).toEqual({ url: "test" });
|
||||
expect(config.config).toEqual({ server: { cors: { origin: "test" } } });
|
||||
expect(config.options).toEqual({ mode: "db" });
|
||||
await config.onBuilt?.(null as any);
|
||||
expect(called).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
adapterTestSuite(bunTestRunner, {
|
||||
|
||||
@@ -6,13 +6,16 @@ describe("Api", async () => {
|
||||
it("should construct without options", () => {
|
||||
const api = new Api();
|
||||
expect(api.baseUrl).toBe("http://localhost");
|
||||
expect(api.isAuthVerified()).toBe(false);
|
||||
|
||||
// verified is true, because no token, user, headers or request given
|
||||
// therefore nothing to check, auth state is verified
|
||||
expect(api.isAuthVerified()).toBe(true);
|
||||
});
|
||||
|
||||
it("should ignore force verify if no claims given", () => {
|
||||
const api = new Api({ verified: true });
|
||||
expect(api.baseUrl).toBe("http://localhost");
|
||||
expect(api.isAuthVerified()).toBe(false);
|
||||
expect(api.isAuthVerified()).toBe(true);
|
||||
});
|
||||
|
||||
it("should construct from request (token)", async () => {
|
||||
@@ -42,7 +45,6 @@ describe("Api", async () => {
|
||||
expect(api.isAuthVerified()).toBe(false);
|
||||
|
||||
const params = api.getParams();
|
||||
console.log(params);
|
||||
expect(params.token).toBe(token);
|
||||
expect(params.token_transport).toBe("cookie");
|
||||
expect(params.host).toBe("http://example.com");
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
import { describe, expect, mock, test } from "bun:test";
|
||||
import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test";
|
||||
import type { ModuleBuildContext } from "../../src";
|
||||
import { App, createApp } from "core/test/utils";
|
||||
import * as proto from "../../src/data/prototype";
|
||||
import * as proto from "data/prototype";
|
||||
import { DbModuleManager } from "modules/db/DbModuleManager";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
beforeAll(disableConsoleLog);
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
describe("App", () => {
|
||||
test("use db mode by default", async () => {
|
||||
const app = createApp();
|
||||
await app.build();
|
||||
|
||||
expect(app.mode).toBe("db");
|
||||
expect(app.isReadOnly()).toBe(false);
|
||||
expect(app.modules instanceof DbModuleManager).toBe(true);
|
||||
});
|
||||
|
||||
test("seed includes ctx and app", async () => {
|
||||
const called = mock(() => null);
|
||||
await createApp({
|
||||
@@ -20,6 +34,7 @@ describe("App", () => {
|
||||
"guard",
|
||||
"flags",
|
||||
"logger",
|
||||
"mcp",
|
||||
"helper",
|
||||
]);
|
||||
},
|
||||
@@ -28,7 +43,7 @@ describe("App", () => {
|
||||
expect(called).toHaveBeenCalled();
|
||||
|
||||
const app = createApp({
|
||||
initialConfig: {
|
||||
config: {
|
||||
data: proto
|
||||
.em({
|
||||
todos: proto.entity("todos", {
|
||||
@@ -135,4 +150,21 @@ describe("App", () => {
|
||||
// expect async listeners to be executed sync after request
|
||||
expect(called).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("getMcpClient", async () => {
|
||||
const app = createApp({
|
||||
config: {
|
||||
server: {
|
||||
mcp: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
const client = app.getMcpClient();
|
||||
const res = await client.listTools();
|
||||
expect(res).toBeDefined();
|
||||
expect(res?.tools.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,11 @@ describe("AppServer", () => {
|
||||
allow_methods: ["GET", "POST", "PATCH", "PUT", "DELETE"],
|
||||
allow_headers: ["Content-Type", "Content-Length", "Authorization", "Accept"],
|
||||
},
|
||||
mcp: {
|
||||
enabled: false,
|
||||
path: "/api/system/mcp",
|
||||
logLevel: "warning",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -31,6 +36,11 @@ describe("AppServer", () => {
|
||||
allow_methods: ["GET", "POST"],
|
||||
allow_headers: ["Content-Type", "Content-Length", "Authorization", "Accept"],
|
||||
},
|
||||
mcp: {
|
||||
enabled: false,
|
||||
path: "/api/system/mcp",
|
||||
logLevel: "warning",
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
127
app/__test__/app/code-only.test.ts
Normal file
127
app/__test__/app/code-only.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, mock, test } from "bun:test";
|
||||
import { createApp as internalCreateApp, type CreateAppConfig } from "bknd";
|
||||
import { getDummyConnection } from "../../__test__/helper";
|
||||
import { ModuleManager } from "modules/ModuleManager";
|
||||
import { em, entity, text } from "data/prototype";
|
||||
|
||||
async function createApp(config: CreateAppConfig = {}) {
|
||||
const app = internalCreateApp({
|
||||
connection: getDummyConnection().dummyConnection,
|
||||
...config,
|
||||
options: {
|
||||
...config.options,
|
||||
mode: "code",
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("code-only", () => {
|
||||
test("should create app with correct manager", async () => {
|
||||
const app = await createApp();
|
||||
await app.build();
|
||||
|
||||
expect(app.version()).toBeDefined();
|
||||
expect(app.modules).toBeInstanceOf(ModuleManager);
|
||||
});
|
||||
|
||||
test("should not perform database syncs", async () => {
|
||||
const app = await createApp({
|
||||
config: {
|
||||
data: em({
|
||||
test: entity("test", {
|
||||
name: text(),
|
||||
}),
|
||||
}).toJSON(),
|
||||
},
|
||||
});
|
||||
expect(app.em.entities.map((e) => e.name)).toEqual(["test"]);
|
||||
expect(
|
||||
await app.em.connection.kysely
|
||||
.selectFrom("sqlite_master")
|
||||
.where("type", "=", "table")
|
||||
.selectAll()
|
||||
.execute(),
|
||||
).toEqual([]);
|
||||
|
||||
// only perform when explicitly forced
|
||||
await app.em.schema().sync({ force: true });
|
||||
expect(
|
||||
await app.em.connection.kysely
|
||||
.selectFrom("sqlite_master")
|
||||
.where("type", "=", "table")
|
||||
.selectAll()
|
||||
.execute()
|
||||
.then((r) => r.map((r) => r.name)),
|
||||
).toEqual(["test", "sqlite_sequence"]);
|
||||
});
|
||||
|
||||
test("should not perform seeding", async () => {
|
||||
const called = mock(() => null);
|
||||
const app = await createApp({
|
||||
config: {
|
||||
data: em({
|
||||
test: entity("test", {
|
||||
name: text(),
|
||||
}),
|
||||
}).toJSON(),
|
||||
},
|
||||
options: {
|
||||
seed: async (ctx) => {
|
||||
called();
|
||||
await ctx.em.mutator("test").insertOne({ name: "test" });
|
||||
},
|
||||
},
|
||||
});
|
||||
await app.em.schema().sync({ force: true });
|
||||
expect(called).not.toHaveBeenCalled();
|
||||
expect(
|
||||
await app.em
|
||||
.repo("test")
|
||||
.findMany({})
|
||||
.then((r) => r.data),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("should sync and perform seeding", async () => {
|
||||
const called = mock(() => null);
|
||||
const app = await createApp({
|
||||
config: {
|
||||
data: em({
|
||||
test: entity("test", {
|
||||
name: text(),
|
||||
}),
|
||||
}).toJSON(),
|
||||
},
|
||||
options: {
|
||||
seed: async (ctx) => {
|
||||
called();
|
||||
await ctx.em.mutator("test").insertOne({ name: "test" });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await app.em.schema().sync({ force: true });
|
||||
await app.options?.seed?.({
|
||||
...app.modules.ctx(),
|
||||
app: app,
|
||||
});
|
||||
expect(called).toHaveBeenCalled();
|
||||
expect(
|
||||
await app.em
|
||||
.repo("test")
|
||||
.findMany({})
|
||||
.then((r) => r.data),
|
||||
).toEqual([{ id: 1, name: "test" }]);
|
||||
});
|
||||
|
||||
test("should not allow to modify config", async () => {
|
||||
const app = await createApp();
|
||||
// biome-ignore lint/suspicious/noPrototypeBuiltins: <explanation>
|
||||
expect(app.modules.hasOwnProperty("mutateConfigSafe")).toBe(false);
|
||||
expect(() => {
|
||||
app.modules.configs().auth.enabled = true;
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
232
app/__test__/app/mcp/mcp.auth.test.ts
Normal file
232
app/__test__/app/mcp/mcp.auth.test.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { describe, test, expect, beforeEach, beforeAll, afterAll } from "bun:test";
|
||||
import { type App, createApp, createMcpToolCaller } from "core/test/utils";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
||||
import type { McpServer } from "bknd/utils";
|
||||
|
||||
beforeAll(disableConsoleLog);
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
/**
|
||||
* - [x] auth_me
|
||||
* - [x] auth_strategies
|
||||
* - [x] auth_user_create
|
||||
* - [x] auth_user_token
|
||||
* - [x] auth_user_password_change
|
||||
* - [x] auth_user_password_test
|
||||
* - [x] config_auth_get
|
||||
* - [x] config_auth_update
|
||||
* - [x] config_auth_strategies_get
|
||||
* - [x] config_auth_strategies_add
|
||||
* - [x] config_auth_strategies_update
|
||||
* - [x] config_auth_strategies_remove
|
||||
* - [x] config_auth_roles_get
|
||||
* - [x] config_auth_roles_add
|
||||
* - [x] config_auth_roles_update
|
||||
* - [x] config_auth_roles_remove
|
||||
*/
|
||||
describe("mcp auth", async () => {
|
||||
let app: App;
|
||||
let server: McpServer;
|
||||
beforeEach(async () => {
|
||||
app = createApp({
|
||||
config: {
|
||||
auth: {
|
||||
enabled: true,
|
||||
jwt: {
|
||||
secret: "secret",
|
||||
},
|
||||
},
|
||||
server: {
|
||||
mcp: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
await app.getMcpClient().ping();
|
||||
server = app.mcp!;
|
||||
server.setLogLevel("error");
|
||||
server.onNotification((message) => {
|
||||
console.dir(message, { depth: null });
|
||||
});
|
||||
});
|
||||
|
||||
const tool = createMcpToolCaller();
|
||||
|
||||
test("auth_*", async () => {
|
||||
const me = await tool(server, "auth_me", {});
|
||||
expect(me.user).toBeNull();
|
||||
|
||||
// strategies
|
||||
const strategies = await tool(server, "auth_strategies", {});
|
||||
expect(Object.keys(strategies.strategies).length).toEqual(1);
|
||||
expect(strategies.strategies.password.enabled).toBe(true);
|
||||
|
||||
// create user
|
||||
const user = await tool(
|
||||
server,
|
||||
"auth_user_create",
|
||||
{
|
||||
email: "test@test.com",
|
||||
password: "12345678",
|
||||
},
|
||||
new Headers(),
|
||||
);
|
||||
expect(user.email).toBe("test@test.com");
|
||||
|
||||
// create token
|
||||
const token = await tool(
|
||||
server,
|
||||
"auth_user_token",
|
||||
{
|
||||
email: "test@test.com",
|
||||
},
|
||||
new Headers(),
|
||||
);
|
||||
expect(token.token).toBeDefined();
|
||||
expect(token.user.email).toBe("test@test.com");
|
||||
|
||||
// me
|
||||
const me2 = await tool(
|
||||
server,
|
||||
"auth_me",
|
||||
{},
|
||||
new Request("http://localhost", {
|
||||
headers: new Headers({
|
||||
Authorization: `Bearer ${token.token}`,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(me2.user.email).toBe("test@test.com");
|
||||
|
||||
// change password
|
||||
const changePassword = await tool(
|
||||
server,
|
||||
"auth_user_password_change",
|
||||
{
|
||||
email: "test@test.com",
|
||||
password: "87654321",
|
||||
},
|
||||
new Headers(),
|
||||
);
|
||||
expect(changePassword.changed).toBe(true);
|
||||
|
||||
// test password
|
||||
const testPassword = await tool(
|
||||
server,
|
||||
"auth_user_password_test",
|
||||
{
|
||||
email: "test@test.com",
|
||||
password: "87654321",
|
||||
},
|
||||
new Headers(),
|
||||
);
|
||||
expect(testPassword.valid).toBe(true);
|
||||
});
|
||||
|
||||
test("config_auth_{get,update}", async () => {
|
||||
expect(await tool(server, "config_auth_get", {})).toEqual({
|
||||
path: "",
|
||||
secrets: false,
|
||||
partial: false,
|
||||
value: app.toJSON().auth,
|
||||
});
|
||||
|
||||
// update
|
||||
await tool(server, "config_auth_update", {
|
||||
value: {
|
||||
allow_register: false,
|
||||
},
|
||||
});
|
||||
expect(app.toJSON().auth.allow_register).toBe(false);
|
||||
});
|
||||
|
||||
test("config_auth_strategies_{get,add,update,remove}", async () => {
|
||||
const strategies = await tool(server, "config_auth_strategies_get", {
|
||||
key: "password",
|
||||
});
|
||||
expect(strategies).toEqual({
|
||||
secrets: false,
|
||||
module: "auth",
|
||||
key: "password",
|
||||
value: {
|
||||
enabled: true,
|
||||
type: "password",
|
||||
},
|
||||
});
|
||||
|
||||
// add google oauth
|
||||
const addGoogleOauth = await tool(server, "config_auth_strategies_add", {
|
||||
key: "google",
|
||||
value: {
|
||||
type: "oauth",
|
||||
enabled: true,
|
||||
config: {
|
||||
name: "google",
|
||||
type: "oidc",
|
||||
client: {
|
||||
client_id: "client_id",
|
||||
client_secret: "client_secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
return_config: true,
|
||||
});
|
||||
expect(addGoogleOauth.config.google.enabled).toBe(true);
|
||||
expect(app.toJSON().auth.strategies.google?.enabled).toBe(true);
|
||||
|
||||
// update (disable) google oauth
|
||||
await tool(server, "config_auth_strategies_update", {
|
||||
key: "google",
|
||||
value: {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
expect(app.toJSON().auth.strategies.google?.enabled).toBe(false);
|
||||
|
||||
// remove google oauth
|
||||
await tool(server, "config_auth_strategies_remove", {
|
||||
key: "google",
|
||||
});
|
||||
expect(app.toJSON().auth.strategies.google).toBeUndefined();
|
||||
});
|
||||
|
||||
test("config_auth_roles_{get,add,update,remove}", async () => {
|
||||
// add role
|
||||
const addGuestRole = await tool(server, "config_auth_roles_add", {
|
||||
key: "guest",
|
||||
value: {
|
||||
permissions: ["read", "write"],
|
||||
},
|
||||
return_config: true,
|
||||
});
|
||||
expect(addGuestRole.config.guest.permissions.map((p) => p.permission)).toEqual([
|
||||
"read",
|
||||
"write",
|
||||
]);
|
||||
|
||||
// update role
|
||||
await tool(server, "config_auth_roles_update", {
|
||||
key: "guest",
|
||||
value: {
|
||||
permissions: ["read"],
|
||||
},
|
||||
});
|
||||
expect(app.toJSON().auth.roles?.guest?.permissions?.map((p) => p.permission)).toEqual([
|
||||
"read",
|
||||
]);
|
||||
|
||||
// get role
|
||||
const getGuestRole = await tool(server, "config_auth_roles_get", {
|
||||
key: "guest",
|
||||
});
|
||||
expect(getGuestRole.value.permissions.map((p) => p.permission)).toEqual(["read"]);
|
||||
|
||||
// remove role
|
||||
await tool(server, "config_auth_roles_remove", {
|
||||
key: "guest",
|
||||
});
|
||||
expect(app.toJSON().auth.roles?.guest).toBeUndefined();
|
||||
});
|
||||
});
|
||||
44
app/__test__/app/mcp/mcp.base.test.ts
Normal file
44
app/__test__/app/mcp/mcp.base.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
|
||||
import { createApp } from "core/test/utils";
|
||||
import { registries } from "index";
|
||||
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
describe("mcp", () => {
|
||||
it("should have tools", async () => {
|
||||
registries.media.register("local", StorageLocalAdapter);
|
||||
|
||||
const app = createApp({
|
||||
config: {
|
||||
auth: {
|
||||
enabled: true,
|
||||
},
|
||||
media: {
|
||||
enabled: true,
|
||||
adapter: {
|
||||
type: "local",
|
||||
config: {
|
||||
path: "./",
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
mcp: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
|
||||
// expect mcp to not be loaded yet
|
||||
expect(app.mcp).toBeNull();
|
||||
|
||||
// after first request, mcp should be loaded
|
||||
await app.getMcpClient().listTools();
|
||||
expect(app.mcp?.tools.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
347
app/__test__/app/mcp/mcp.data.test.ts
Normal file
347
app/__test__/app/mcp/mcp.data.test.ts
Normal file
@@ -0,0 +1,347 @@
|
||||
import { describe, test, expect, beforeEach, beforeAll, afterAll } from "bun:test";
|
||||
import { type App, createApp, createMcpToolCaller } from "core/test/utils";
|
||||
import { getSystemMcp } from "modules/mcp/system-mcp";
|
||||
import { pickKeys, type McpServer } from "bknd/utils";
|
||||
import { entity, text } from "bknd";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
||||
|
||||
beforeAll(disableConsoleLog);
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
/**
|
||||
* - [ ] data_sync
|
||||
* - [x] data_entity_fn_count
|
||||
* - [x] data_entity_fn_exists
|
||||
* - [x] data_entity_read_one
|
||||
* - [x] data_entity_read_many
|
||||
* - [x] data_entity_insert
|
||||
* - [x] data_entity_update_many
|
||||
* - [x] data_entity_update_one
|
||||
* - [x] data_entity_delete_one
|
||||
* - [x] data_entity_delete_many
|
||||
* - [x] data_entity_info
|
||||
* - [ ] config_data_get
|
||||
* - [ ] config_data_update
|
||||
* - [x] config_data_entities_get
|
||||
* - [x] config_data_entities_add
|
||||
* - [x] config_data_entities_update
|
||||
* - [x] config_data_entities_remove
|
||||
* - [x] config_data_relations_add
|
||||
* - [x] config_data_relations_get
|
||||
* - [x] config_data_relations_update
|
||||
* - [x] config_data_relations_remove
|
||||
* - [x] config_data_indices_get
|
||||
* - [x] config_data_indices_add
|
||||
* - [x] config_data_indices_update
|
||||
* - [x] config_data_indices_remove
|
||||
*/
|
||||
describe("mcp data", async () => {
|
||||
let app: App;
|
||||
let server: McpServer;
|
||||
beforeEach(async () => {
|
||||
const time = performance.now();
|
||||
app = createApp({
|
||||
config: {
|
||||
server: {
|
||||
mcp: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
await app.getMcpClient().ping();
|
||||
server = app.mcp!;
|
||||
server.setLogLevel("error");
|
||||
server.onNotification((message) => {
|
||||
console.dir(message, { depth: null });
|
||||
});
|
||||
});
|
||||
|
||||
const tool = createMcpToolCaller();
|
||||
|
||||
test("config_data_entities_{add,get,update,remove}", async () => {
|
||||
const result = await tool(server, "config_data_entities_add", {
|
||||
key: "test",
|
||||
return_config: true,
|
||||
value: {},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.module).toBe("data");
|
||||
expect(result.config.test?.type).toEqual("regular");
|
||||
|
||||
const entities = Object.keys(app.toJSON().data.entities ?? {});
|
||||
expect(entities).toContain("test");
|
||||
|
||||
{
|
||||
// get
|
||||
const result = await tool(server, "config_data_entities_get", {
|
||||
key: "test",
|
||||
});
|
||||
expect(result.module).toBe("data");
|
||||
expect(result.key).toBe("test");
|
||||
expect(result.value.type).toEqual("regular");
|
||||
}
|
||||
|
||||
{
|
||||
// update
|
||||
const result = await tool(server, "config_data_entities_update", {
|
||||
key: "test",
|
||||
return_config: true,
|
||||
value: {
|
||||
config: {
|
||||
name: "Test",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.module).toBe("data");
|
||||
expect(result.config.test.config?.name).toEqual("Test");
|
||||
expect(app.toJSON().data.entities?.test?.config?.name).toEqual("Test");
|
||||
}
|
||||
|
||||
{
|
||||
// remove
|
||||
const result = await tool(server, "config_data_entities_remove", {
|
||||
key: "test",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.module).toBe("data");
|
||||
expect(app.toJSON().data.entities?.test).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("config_data_relations_{add,get,update,remove}", async () => {
|
||||
// create posts and comments
|
||||
await tool(server, "config_data_entities_add", {
|
||||
key: "posts",
|
||||
value: {},
|
||||
});
|
||||
await tool(server, "config_data_entities_add", {
|
||||
key: "comments",
|
||||
value: {},
|
||||
});
|
||||
|
||||
expect(Object.keys(app.toJSON().data.entities ?? {})).toEqual(["posts", "comments"]);
|
||||
|
||||
// create relation
|
||||
await tool(server, "config_data_relations_add", {
|
||||
key: "", // doesn't matter
|
||||
value: {
|
||||
type: "n:1",
|
||||
source: "comments",
|
||||
target: "posts",
|
||||
},
|
||||
});
|
||||
|
||||
const config = app.toJSON().data;
|
||||
expect(
|
||||
pickKeys((config.relations?.n1_comments_posts as any) ?? {}, ["type", "source", "target"]),
|
||||
).toEqual({
|
||||
type: "n:1",
|
||||
source: "comments",
|
||||
target: "posts",
|
||||
});
|
||||
|
||||
expect(config.entities?.comments?.fields?.posts_id?.type).toBe("relation");
|
||||
|
||||
{
|
||||
// info
|
||||
const postsInfo = await tool(server, "data_entity_info", {
|
||||
entity: "posts",
|
||||
});
|
||||
expect(postsInfo.fields).toEqual(["id"]);
|
||||
expect(postsInfo.relations.all.length).toBe(1);
|
||||
|
||||
const commentsInfo = await tool(server, "data_entity_info", {
|
||||
entity: "comments",
|
||||
});
|
||||
expect(commentsInfo.fields).toEqual(["id", "posts_id"]);
|
||||
expect(commentsInfo.relations.all.length).toBe(1);
|
||||
}
|
||||
|
||||
// update
|
||||
await tool(server, "config_data_relations_update", {
|
||||
key: "n1_comments_posts",
|
||||
value: {
|
||||
config: {
|
||||
with_limit: 10,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect((app.toJSON().data.relations?.n1_comments_posts?.config as any)?.with_limit).toBe(10);
|
||||
|
||||
// delete
|
||||
await tool(server, "config_data_relations_remove", {
|
||||
key: "n1_comments_posts",
|
||||
});
|
||||
expect(app.toJSON().data.relations?.n1_comments_posts).toBeUndefined();
|
||||
});
|
||||
|
||||
test("config_data_indices_update", async () => {
|
||||
expect(server.tools.map((t) => t.name).includes("config_data_indices_update")).toBe(false);
|
||||
});
|
||||
|
||||
test("config_data_indices_{add,get,remove}", async () => {
|
||||
// create posts and comments
|
||||
await tool(server, "config_data_entities_add", {
|
||||
key: "posts",
|
||||
value: entity("posts", {
|
||||
title: text(),
|
||||
content: text(),
|
||||
}).toJSON(),
|
||||
});
|
||||
|
||||
// add index on title
|
||||
await tool(server, "config_data_indices_add", {
|
||||
key: "", // auto generated
|
||||
value: {
|
||||
entity: "posts",
|
||||
fields: ["title"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(app.toJSON().data.indices?.idx_posts_title).toEqual({
|
||||
entity: "posts",
|
||||
fields: ["title"],
|
||||
unique: false,
|
||||
});
|
||||
|
||||
// delete
|
||||
await tool(server, "config_data_indices_remove", {
|
||||
key: "idx_posts_title",
|
||||
});
|
||||
expect(app.toJSON().data.indices?.idx_posts_title).toBeUndefined();
|
||||
});
|
||||
|
||||
test("data_entity_*", async () => {
|
||||
// create posts and comments
|
||||
await tool(server, "config_data_entities_add", {
|
||||
key: "posts",
|
||||
value: entity("posts", {
|
||||
title: text(),
|
||||
content: text(),
|
||||
}).toJSON(),
|
||||
});
|
||||
await tool(server, "config_data_entities_add", {
|
||||
key: "comments",
|
||||
value: entity("comments", {
|
||||
content: text(),
|
||||
}).toJSON(),
|
||||
});
|
||||
|
||||
// insert a few posts
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await tool(server, "data_entity_insert", {
|
||||
entity: "posts",
|
||||
json: {
|
||||
title: `Post ${i}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
// insert a few comments
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await tool(server, "data_entity_insert", {
|
||||
entity: "comments",
|
||||
json: {
|
||||
content: `Comment ${i}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const result = await tool(server, "data_entity_read_many", {
|
||||
entity: "posts",
|
||||
limit: 5,
|
||||
});
|
||||
expect(result.data.length).toBe(5);
|
||||
expect(result.meta.items).toBe(5);
|
||||
expect(result.meta.total).toBe(10);
|
||||
expect(result.data[0].title).toBe("Post 0");
|
||||
|
||||
{
|
||||
// count
|
||||
const result = await tool(server, "data_entity_fn_count", {
|
||||
entity: "posts",
|
||||
});
|
||||
expect(result.count).toBe(10);
|
||||
}
|
||||
|
||||
{
|
||||
// exists
|
||||
const res = await tool(server, "data_entity_fn_exists", {
|
||||
entity: "posts",
|
||||
json: {
|
||||
id: result.data[0].id,
|
||||
},
|
||||
});
|
||||
expect(res.exists).toBe(true);
|
||||
|
||||
const res2 = await tool(server, "data_entity_fn_exists", {
|
||||
entity: "posts",
|
||||
json: {
|
||||
id: "123",
|
||||
},
|
||||
});
|
||||
expect(res2.exists).toBe(false);
|
||||
}
|
||||
|
||||
// update
|
||||
await tool(server, "data_entity_update_one", {
|
||||
entity: "posts",
|
||||
id: result.data[0].id,
|
||||
json: {
|
||||
title: "Post 0 updated",
|
||||
},
|
||||
});
|
||||
const result2 = await tool(server, "data_entity_read_one", {
|
||||
entity: "posts",
|
||||
id: result.data[0].id,
|
||||
});
|
||||
expect(result2.data.title).toBe("Post 0 updated");
|
||||
|
||||
// delete the second post
|
||||
await tool(server, "data_entity_delete_one", {
|
||||
entity: "posts",
|
||||
id: result.data[1].id,
|
||||
});
|
||||
const result3 = await tool(server, "data_entity_read_many", {
|
||||
entity: "posts",
|
||||
limit: 2,
|
||||
});
|
||||
expect(result3.data.map((p) => p.id)).toEqual([1, 3]);
|
||||
|
||||
// update many
|
||||
await tool(server, "data_entity_update_many", {
|
||||
entity: "posts",
|
||||
update: {
|
||||
title: "Post updated",
|
||||
},
|
||||
where: {
|
||||
title: { $isnull: 0 },
|
||||
},
|
||||
});
|
||||
const result4 = await tool(server, "data_entity_read_many", {
|
||||
entity: "posts",
|
||||
limit: 10,
|
||||
});
|
||||
expect(result4.data.length).toBe(9);
|
||||
expect(result4.data.map((p) => p.title)).toEqual(
|
||||
Array.from({ length: 9 }, () => "Post updated"),
|
||||
);
|
||||
|
||||
// delete many
|
||||
await tool(server, "data_entity_delete_many", {
|
||||
entity: "posts",
|
||||
json: {
|
||||
title: { $isnull: 0 },
|
||||
},
|
||||
});
|
||||
const result5 = await tool(server, "data_entity_read_many", {
|
||||
entity: "posts",
|
||||
limit: 10,
|
||||
});
|
||||
expect(result5.data.length).toBe(0);
|
||||
expect(result5.meta.items).toBe(0);
|
||||
expect(result5.meta.total).toBe(0);
|
||||
});
|
||||
});
|
||||
119
app/__test__/app/mcp/mcp.media.test.ts
Normal file
119
app/__test__/app/mcp/mcp.media.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { describe, test, expect, beforeAll, beforeEach, afterAll } from "bun:test";
|
||||
import { type App, createApp, createMcpToolCaller } from "core/test/utils";
|
||||
import { getSystemMcp } from "modules/mcp/system-mcp";
|
||||
import { registries } from "index";
|
||||
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
||||
import type { McpServer } from "bknd/utils";
|
||||
|
||||
beforeAll(disableConsoleLog);
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
/**
|
||||
* - [x] config_media_get
|
||||
* - [x] config_media_update
|
||||
* - [x] config_media_adapter_get
|
||||
* - [x] config_media_adapter_update
|
||||
*/
|
||||
describe("mcp media", async () => {
|
||||
let app: App;
|
||||
let server: McpServer;
|
||||
beforeEach(async () => {
|
||||
registries.media.register("local", StorageLocalAdapter);
|
||||
app = createApp({
|
||||
config: {
|
||||
media: {
|
||||
enabled: true,
|
||||
adapter: {
|
||||
type: "local",
|
||||
config: {
|
||||
path: "./",
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
mcp: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
await app.getMcpClient().ping();
|
||||
server = app.mcp!;
|
||||
server.setLogLevel("error");
|
||||
server.onNotification((message) => {
|
||||
console.dir(message, { depth: null });
|
||||
});
|
||||
});
|
||||
|
||||
const tool = createMcpToolCaller();
|
||||
|
||||
test("config_media_{get,update}", async () => {
|
||||
const result = await tool(server, "config_media_get", {});
|
||||
expect(result).toEqual({
|
||||
path: "",
|
||||
secrets: false,
|
||||
partial: false,
|
||||
value: app.toJSON().media,
|
||||
});
|
||||
|
||||
// partial
|
||||
expect((await tool(server, "config_media_get", { path: "adapter" })).value).toEqual({
|
||||
type: "local",
|
||||
config: {
|
||||
path: "./",
|
||||
},
|
||||
});
|
||||
|
||||
// update
|
||||
await tool(server, "config_media_update", {
|
||||
value: {
|
||||
storage: {
|
||||
body_max_size: 1024 * 1024 * 10,
|
||||
},
|
||||
},
|
||||
return_config: true,
|
||||
});
|
||||
expect(app.toJSON().media.storage.body_max_size).toBe(1024 * 1024 * 10);
|
||||
});
|
||||
|
||||
test("config_media_adapter_{get,update}", async () => {
|
||||
const result = await tool(server, "config_media_adapter_get", {});
|
||||
expect(result).toEqual({
|
||||
secrets: false,
|
||||
value: app.toJSON().media.adapter,
|
||||
});
|
||||
|
||||
// update
|
||||
await tool(server, "config_media_adapter_update", {
|
||||
value: {
|
||||
type: "local",
|
||||
config: {
|
||||
path: "./subdir",
|
||||
},
|
||||
},
|
||||
});
|
||||
const adapter = app.toJSON().media.adapter as any;
|
||||
expect(adapter.config.path).toBe("./subdir");
|
||||
expect(adapter.type).toBe("local");
|
||||
|
||||
// set to s3
|
||||
{
|
||||
await tool(server, "config_media_adapter_update", {
|
||||
value: {
|
||||
type: "s3",
|
||||
config: {
|
||||
access_key: "123",
|
||||
secret_access_key: "456",
|
||||
url: "https://example.com/what",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const adapter = app.toJSON(true).media.adapter as any;
|
||||
expect(adapter.type).toBe("s3");
|
||||
expect(adapter.config.url).toBe("https://example.com/what");
|
||||
}
|
||||
});
|
||||
});
|
||||
77
app/__test__/app/mcp/mcp.server.test.ts
Normal file
77
app/__test__/app/mcp/mcp.server.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
||||
import { type App, createApp, createMcpToolCaller } from "core/test/utils";
|
||||
import type { McpServer } from "bknd/utils";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
/**
|
||||
* - [x] config_server_get
|
||||
* - [x] config_server_update
|
||||
*/
|
||||
describe("mcp system", async () => {
|
||||
let app: App;
|
||||
let server: McpServer;
|
||||
beforeAll(async () => {
|
||||
app = createApp({
|
||||
config: {
|
||||
server: {
|
||||
mcp: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
await app.getMcpClient().ping();
|
||||
server = app.mcp!;
|
||||
});
|
||||
|
||||
const tool = createMcpToolCaller();
|
||||
|
||||
test("config_server_get", async () => {
|
||||
const result = await tool(server, "config_server_get", {});
|
||||
expect(JSON.parse(JSON.stringify(result))).toEqual({
|
||||
path: "",
|
||||
secrets: false,
|
||||
partial: false,
|
||||
value: JSON.parse(JSON.stringify(app.toJSON().server)),
|
||||
});
|
||||
});
|
||||
|
||||
test("config_server_get2", async () => {
|
||||
const result = await tool(server, "config_server_get", {});
|
||||
expect(JSON.parse(JSON.stringify(result))).toEqual({
|
||||
path: "",
|
||||
secrets: false,
|
||||
partial: false,
|
||||
value: JSON.parse(JSON.stringify(app.toJSON().server)),
|
||||
});
|
||||
});
|
||||
|
||||
test("config_server_update", async () => {
|
||||
const original = JSON.parse(JSON.stringify(app.toJSON().server));
|
||||
const result = await tool(server, "config_server_update", {
|
||||
value: {
|
||||
cors: {
|
||||
origin: "http://localhost",
|
||||
},
|
||||
},
|
||||
return_config: true,
|
||||
});
|
||||
|
||||
expect(JSON.parse(JSON.stringify(result))).toEqual({
|
||||
success: true,
|
||||
module: "server",
|
||||
config: {
|
||||
...original,
|
||||
cors: {
|
||||
...original.cors,
|
||||
origin: "http://localhost",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(app.toJSON().server.cors.origin).toBe("http://localhost");
|
||||
});
|
||||
});
|
||||
57
app/__test__/app/mcp/mcp.system.test.ts
Normal file
57
app/__test__/app/mcp/mcp.system.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { AppEvents } from "App";
|
||||
import { describe, test, expect, beforeAll, mock } from "bun:test";
|
||||
import { type App, createApp, createMcpToolCaller } from "core/test/utils";
|
||||
import type { McpServer } from "bknd/utils";
|
||||
|
||||
/**
|
||||
* - [x] system_config
|
||||
* - [x] system_build
|
||||
* - [x] system_ping
|
||||
* - [x] system_info
|
||||
*/
|
||||
describe("mcp system", async () => {
|
||||
let app: App;
|
||||
let server: McpServer;
|
||||
beforeAll(async () => {
|
||||
app = createApp({
|
||||
config: {
|
||||
server: {
|
||||
mcp: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
await app.getMcpClient().ping();
|
||||
server = app.mcp!;
|
||||
});
|
||||
|
||||
const tool = createMcpToolCaller();
|
||||
|
||||
test("system_ping", async () => {
|
||||
const result = await tool(server, "system_ping", {});
|
||||
expect(result).toEqual({ pong: true });
|
||||
});
|
||||
|
||||
test("system_info", async () => {
|
||||
const result = await tool(server, "system_info", {});
|
||||
expect(Object.keys(result).length).toBeGreaterThan(0);
|
||||
expect(Object.keys(result)).toContainValues(["version", "runtime", "connection"]);
|
||||
});
|
||||
|
||||
test("system_build", async () => {
|
||||
const called = mock(() => null);
|
||||
|
||||
app.emgr.onEvent(AppEvents.AppBuiltEvent, () => void called(), { once: true });
|
||||
|
||||
const result = await tool(server, "system_build", {});
|
||||
expect(called).toHaveBeenCalledTimes(1);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("system_config", async () => {
|
||||
const result = await tool(server, "system_config", {});
|
||||
expect(result).toEqual(app.toJSON());
|
||||
});
|
||||
});
|
||||
80
app/__test__/app/plugins/sync-config.test.ts
Normal file
80
app/__test__/app/plugins/sync-config.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect, mock, beforeAll, afterAll } from "bun:test";
|
||||
import { createApp } from "core/test/utils";
|
||||
import { syncConfig } from "plugins/dev/sync-config.plugin";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
describe("syncConfig", () => {
|
||||
it("should only sync if enabled", async () => {
|
||||
const called = mock(() => null);
|
||||
const app = createApp();
|
||||
await app.build();
|
||||
|
||||
await syncConfig({
|
||||
write: () => {
|
||||
called();
|
||||
},
|
||||
enabled: false,
|
||||
includeFirstBoot: false,
|
||||
})(app).onBuilt?.();
|
||||
expect(called).not.toHaveBeenCalled();
|
||||
|
||||
await syncConfig({
|
||||
write: () => {
|
||||
called();
|
||||
},
|
||||
enabled: false,
|
||||
includeFirstBoot: true,
|
||||
})(app).onBuilt?.();
|
||||
expect(called).not.toHaveBeenCalled();
|
||||
|
||||
await syncConfig({
|
||||
write: () => {
|
||||
called();
|
||||
},
|
||||
enabled: true,
|
||||
includeFirstBoot: true,
|
||||
})(app).onBuilt?.();
|
||||
expect(called).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should respect secrets", async () => {
|
||||
const called = mock(() => null);
|
||||
const app = createApp({
|
||||
config: {
|
||||
auth: {
|
||||
enabled: true,
|
||||
jwt: {
|
||||
secret: "test",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
|
||||
await syncConfig({
|
||||
write: async (config) => {
|
||||
called();
|
||||
expect(config.auth.jwt.secret).toBe("test");
|
||||
},
|
||||
enabled: true,
|
||||
includeSecrets: true,
|
||||
includeFirstBoot: true,
|
||||
})(app).onBuilt?.();
|
||||
|
||||
await syncConfig({
|
||||
write: async (config) => {
|
||||
called();
|
||||
// it's an important test, because the `jwt` part is omitted if secrets=false in general app.toJSON()
|
||||
// but it's required to get the app running
|
||||
expect(config.auth.jwt.secret).toBe("");
|
||||
},
|
||||
enabled: true,
|
||||
includeSecrets: false,
|
||||
includeFirstBoot: true,
|
||||
})(app).onBuilt?.();
|
||||
expect(called).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,12 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { registries } from "../../src";
|
||||
import { createApp } from "core/test/utils";
|
||||
import * as proto from "../../src/data/prototype";
|
||||
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
describe("repros", async () => {
|
||||
/**
|
||||
@@ -88,7 +92,7 @@ describe("repros", async () => {
|
||||
fns.relation(schema.product_likes).manyToOne(schema.users);
|
||||
},
|
||||
);
|
||||
const app = createApp({ initialConfig: { data: schema.toJSON() } });
|
||||
const app = createApp({ config: { data: schema.toJSON() } });
|
||||
await app.build();
|
||||
|
||||
const info = (await (await app.server.request("/api/data/info/products")).json()) as any;
|
||||
|
||||
@@ -1,3 +1,41 @@
|
||||
import { Authenticator } from "auth/authenticate/Authenticator";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
describe("Authenticator", async () => {});
|
||||
describe("Authenticator", async () => {
|
||||
test("should return auth cookie headers", async () => {
|
||||
const auth = new Authenticator({}, null as any, {
|
||||
jwt: {
|
||||
secret: "secret",
|
||||
fields: [],
|
||||
},
|
||||
cookie: {
|
||||
sameSite: "strict",
|
||||
},
|
||||
});
|
||||
const headers = await auth.getAuthCookieHeader("token");
|
||||
const cookie = headers.get("Set-Cookie");
|
||||
expect(cookie).toStartWith("auth=");
|
||||
expect(cookie).toEndWith("HttpOnly; Secure; SameSite=Strict");
|
||||
|
||||
// now expect it to be removed
|
||||
const headers2 = await auth.removeAuthCookieHeader(headers);
|
||||
const cookie2 = headers2.get("Set-Cookie");
|
||||
expect(cookie2).toStartWith("auth=; Max-Age=0; Path=/; Expires=");
|
||||
expect(cookie2).toEndWith("HttpOnly; Secure; SameSite=Strict");
|
||||
});
|
||||
|
||||
test("should return auth cookie string", async () => {
|
||||
const auth = new Authenticator({}, null as any, {
|
||||
jwt: {
|
||||
secret: "secret",
|
||||
fields: [],
|
||||
},
|
||||
cookie: {
|
||||
sameSite: "strict",
|
||||
},
|
||||
});
|
||||
const cookie = await auth.unsafeGetAuthCookie("token");
|
||||
expect(cookie).toStartWith("auth=");
|
||||
expect(cookie).toEndWith("HttpOnly; Secure; SameSite=Strict");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,31 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { Guard } from "../../../src/auth/authorize/Guard";
|
||||
import { Guard, type GuardConfig } from "auth/authorize/Guard";
|
||||
import { Permission } from "auth/authorize/Permission";
|
||||
import { Role, type RoleSchema } from "auth/authorize/Role";
|
||||
import { objectTransform, s } from "bknd/utils";
|
||||
|
||||
function createGuard(
|
||||
permissionNames: string[],
|
||||
roles?: Record<string, Omit<RoleSchema, "name">>,
|
||||
config?: GuardConfig,
|
||||
) {
|
||||
const _roles = roles
|
||||
? objectTransform(roles, ({ permissions = [], is_default, implicit_allow }, name) => {
|
||||
return Role.create(name, { permissions, is_default, implicit_allow });
|
||||
})
|
||||
: {};
|
||||
const _permissions = permissionNames.map((name) => new Permission(name));
|
||||
return new Guard(_permissions, Object.values(_roles), config);
|
||||
}
|
||||
|
||||
describe("authorize", () => {
|
||||
const read = new Permission("read", {
|
||||
filterable: true,
|
||||
});
|
||||
const write = new Permission("write");
|
||||
|
||||
test("basic", async () => {
|
||||
const guard = Guard.create(
|
||||
const guard = createGuard(
|
||||
["read", "write"],
|
||||
{
|
||||
admin: {
|
||||
@@ -16,14 +38,14 @@ describe("authorize", () => {
|
||||
role: "admin",
|
||||
};
|
||||
|
||||
expect(guard.granted("read", user)).toBe(true);
|
||||
expect(guard.granted("write", user)).toBe(true);
|
||||
expect(guard.granted(read, user)).toBeUndefined();
|
||||
expect(guard.granted(write, user)).toBeUndefined();
|
||||
|
||||
expect(() => guard.granted("something")).toThrow();
|
||||
expect(() => guard.granted(new Permission("something"), {})).toThrow();
|
||||
});
|
||||
|
||||
test("with default", async () => {
|
||||
const guard = Guard.create(
|
||||
const guard = createGuard(
|
||||
["read", "write"],
|
||||
{
|
||||
admin: {
|
||||
@@ -37,26 +59,26 @@ describe("authorize", () => {
|
||||
{ enabled: true },
|
||||
);
|
||||
|
||||
expect(guard.granted("read")).toBe(true);
|
||||
expect(guard.granted("write")).toBe(false);
|
||||
expect(guard.granted(read, {})).toBeUndefined();
|
||||
expect(() => guard.granted(write, {})).toThrow();
|
||||
|
||||
const user = {
|
||||
role: "admin",
|
||||
};
|
||||
|
||||
expect(guard.granted("read", user)).toBe(true);
|
||||
expect(guard.granted("write", user)).toBe(true);
|
||||
expect(guard.granted(read, user)).toBeUndefined();
|
||||
expect(guard.granted(write, user)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("guard implicit allow", async () => {
|
||||
const guard = Guard.create([], {}, { enabled: false });
|
||||
const guard = createGuard([], {}, { enabled: false });
|
||||
|
||||
expect(guard.granted("read")).toBe(true);
|
||||
expect(guard.granted("write")).toBe(true);
|
||||
expect(guard.granted(read, {})).toBeUndefined();
|
||||
expect(guard.granted(write, {})).toBeUndefined();
|
||||
});
|
||||
|
||||
test("role implicit allow", async () => {
|
||||
const guard = Guard.create(["read", "write"], {
|
||||
const guard = createGuard(["read", "write"], {
|
||||
admin: {
|
||||
implicit_allow: true,
|
||||
},
|
||||
@@ -66,12 +88,12 @@ describe("authorize", () => {
|
||||
role: "admin",
|
||||
};
|
||||
|
||||
expect(guard.granted("read", user)).toBe(true);
|
||||
expect(guard.granted("write", user)).toBe(true);
|
||||
expect(guard.granted(read, user)).toBeUndefined();
|
||||
expect(guard.granted(write, user)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("guard with guest role implicit allow", async () => {
|
||||
const guard = Guard.create(["read", "write"], {
|
||||
const guard = createGuard(["read", "write"], {
|
||||
guest: {
|
||||
implicit_allow: true,
|
||||
is_default: true,
|
||||
@@ -79,7 +101,143 @@ describe("authorize", () => {
|
||||
});
|
||||
|
||||
expect(guard.getUserRole()?.name).toBe("guest");
|
||||
expect(guard.granted("read")).toBe(true);
|
||||
expect(guard.granted("write")).toBe(true);
|
||||
expect(guard.granted(read, {})).toBeUndefined();
|
||||
expect(guard.granted(write, {})).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("cases", () => {
|
||||
test("guest none, member deny if user.enabled is false", () => {
|
||||
const guard = createGuard(
|
||||
["read"],
|
||||
{
|
||||
guest: {
|
||||
is_default: true,
|
||||
},
|
||||
member: {
|
||||
permissions: [
|
||||
{
|
||||
permission: "read",
|
||||
policies: [
|
||||
{
|
||||
condition: {},
|
||||
effect: "filter",
|
||||
filter: {
|
||||
type: "member",
|
||||
},
|
||||
},
|
||||
{
|
||||
condition: {
|
||||
"user.enabled": false,
|
||||
},
|
||||
effect: "deny",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ enabled: true },
|
||||
);
|
||||
|
||||
expect(() => guard.granted(read, { role: "guest" })).toThrow();
|
||||
|
||||
// member is allowed, because default role permission effect is allow
|
||||
// and no deny policy is met
|
||||
expect(guard.granted(read, { role: "member" })).toBeUndefined();
|
||||
|
||||
// member is allowed, because deny policy is not met
|
||||
expect(guard.granted(read, { role: "member", enabled: true })).toBeUndefined();
|
||||
|
||||
// member is denied, because deny policy is met
|
||||
expect(() => guard.granted(read, { role: "member", enabled: false })).toThrow();
|
||||
|
||||
// get the filter for member role
|
||||
expect(guard.filters(read, { role: "member" }).filter).toEqual({
|
||||
type: "member",
|
||||
});
|
||||
|
||||
// get filter for guest
|
||||
expect(guard.filters(read, {}).filter).toBeUndefined();
|
||||
});
|
||||
|
||||
test("guest should only read posts that are public", () => {
|
||||
const read = new Permission(
|
||||
"read",
|
||||
{
|
||||
// make this permission filterable
|
||||
// without this, `filter` policies have no effect
|
||||
filterable: true,
|
||||
},
|
||||
// expect the context to match this schema
|
||||
// otherwise exit with 500 to ensure proper policy checking
|
||||
s.object({
|
||||
entity: s.string(),
|
||||
}),
|
||||
);
|
||||
const guard = createGuard(
|
||||
["read"],
|
||||
{
|
||||
guest: {
|
||||
// this permission is applied if no (or invalid) role is provided
|
||||
is_default: true,
|
||||
permissions: [
|
||||
{
|
||||
permission: "read",
|
||||
// effect deny means only having this permission, doesn't guarantee access
|
||||
effect: "deny",
|
||||
policies: [
|
||||
{
|
||||
// only if this condition is met
|
||||
condition: {
|
||||
entity: {
|
||||
$in: ["posts"],
|
||||
},
|
||||
},
|
||||
// the effect is allow
|
||||
effect: "allow",
|
||||
},
|
||||
{
|
||||
condition: {
|
||||
entity: "posts",
|
||||
},
|
||||
effect: "filter",
|
||||
filter: {
|
||||
public: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
// members should be allowed to read all
|
||||
member: {
|
||||
permissions: [
|
||||
{
|
||||
permission: "read",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ enabled: true },
|
||||
);
|
||||
|
||||
// guest can only read posts
|
||||
expect(guard.granted(read, {}, { entity: "posts" })).toBeUndefined();
|
||||
expect(() => guard.granted(read, {}, { entity: "users" })).toThrow();
|
||||
|
||||
// and guests can only read public posts
|
||||
expect(guard.filters(read, {}, { entity: "posts" }).filter).toEqual({
|
||||
public: true,
|
||||
});
|
||||
|
||||
// member can read posts and users
|
||||
expect(guard.granted(read, { role: "member" }, { entity: "posts" })).toBeUndefined();
|
||||
expect(guard.granted(read, { role: "member" }, { entity: "users" })).toBeUndefined();
|
||||
|
||||
// member should not have a filter
|
||||
expect(
|
||||
guard.filters(read, { role: "member" }, { entity: "posts" }).filter,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
327
app/__test__/auth/authorize/data.permissions.test.ts
Normal file
327
app/__test__/auth/authorize/data.permissions.test.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
|
||||
import { createApp } from "core/test/utils";
|
||||
import type { CreateAppConfig } from "App";
|
||||
import * as proto from "data/prototype";
|
||||
import { mergeObject } from "core/utils/objects";
|
||||
import type { App, DB } from "bknd";
|
||||
import type { CreateUserPayload } from "auth/AppAuth";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(() => enableConsoleLog());
|
||||
|
||||
async function makeApp(config: Partial<CreateAppConfig["config"]> = {}) {
|
||||
const app = createApp({
|
||||
config: mergeObject(
|
||||
{
|
||||
data: proto
|
||||
.em(
|
||||
{
|
||||
users: proto.systemEntity("users", {}),
|
||||
posts: proto.entity("posts", {
|
||||
title: proto.text(),
|
||||
content: proto.text(),
|
||||
}),
|
||||
comments: proto.entity("comments", {
|
||||
content: proto.text(),
|
||||
}),
|
||||
},
|
||||
({ relation }, { users, posts, comments }) => {
|
||||
relation(posts).manyToOne(users);
|
||||
relation(comments).manyToOne(posts);
|
||||
},
|
||||
)
|
||||
.toJSON(),
|
||||
auth: {
|
||||
enabled: true,
|
||||
jwt: {
|
||||
secret: "secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
config,
|
||||
),
|
||||
});
|
||||
await app.build();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
async function createUsers(app: App, users: CreateUserPayload[]) {
|
||||
return Promise.all(
|
||||
users.map(async (user) => {
|
||||
return await app.createUser(user);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function loadFixtures(app: App, fixtures: Record<string, any[]> = {}) {
|
||||
const results = {} as any;
|
||||
for (const [entity, data] of Object.entries(fixtures)) {
|
||||
results[entity] = await app.em
|
||||
.mutator(entity as any)
|
||||
.insertMany(data)
|
||||
.then((result) => result.data);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
describe("data permissions", async () => {
|
||||
const app = await makeApp({
|
||||
server: {
|
||||
mcp: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
guard: {
|
||||
enabled: true,
|
||||
},
|
||||
roles: {
|
||||
guest: {
|
||||
is_default: true,
|
||||
permissions: [
|
||||
{
|
||||
permission: "system.access.api",
|
||||
},
|
||||
{
|
||||
permission: "data.entity.read",
|
||||
policies: [
|
||||
{
|
||||
condition: {
|
||||
entity: "posts",
|
||||
},
|
||||
effect: "filter",
|
||||
filter: {
|
||||
users_id: { $isnull: 1 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
permission: "data.entity.create",
|
||||
policies: [
|
||||
{
|
||||
condition: {
|
||||
entity: "posts",
|
||||
},
|
||||
effect: "filter",
|
||||
filter: {
|
||||
users_id: { $isnull: 1 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
permission: "data.entity.update",
|
||||
policies: [
|
||||
{
|
||||
condition: {
|
||||
entity: "posts",
|
||||
},
|
||||
effect: "filter",
|
||||
filter: {
|
||||
users_id: { $isnull: 1 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
permission: "data.entity.delete",
|
||||
policies: [
|
||||
{
|
||||
condition: { entity: "posts" },
|
||||
},
|
||||
{
|
||||
condition: { entity: "posts" },
|
||||
effect: "filter",
|
||||
filter: {
|
||||
users_id: { $isnull: 1 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const users = [
|
||||
{ email: "foo@example.com", password: "password" },
|
||||
{ email: "bar@example.com", password: "password" },
|
||||
];
|
||||
const fixtures = {
|
||||
posts: [
|
||||
{ content: "post 1", users_id: 1 },
|
||||
{ content: "post 2", users_id: 2 },
|
||||
{ content: "post 3", users_id: null },
|
||||
],
|
||||
comments: [
|
||||
{ content: "comment 1", posts_id: 1 },
|
||||
{ content: "comment 2", posts_id: 2 },
|
||||
{ content: "comment 3", posts_id: 3 },
|
||||
],
|
||||
};
|
||||
await createUsers(app, users);
|
||||
const results = await loadFixtures(app, fixtures);
|
||||
|
||||
describe("http", async () => {
|
||||
it("read many", async () => {
|
||||
// many only includes posts with users_id is null
|
||||
const res = await app.server.request("/api/data/entity/posts");
|
||||
const data = await res.json().then((r: any) => r.data);
|
||||
expect(data).toEqual([results.posts[2]]);
|
||||
|
||||
// same with /query
|
||||
{
|
||||
const res = await app.server.request("/api/data/entity/posts/query", {
|
||||
method: "POST",
|
||||
});
|
||||
const data = await res.json().then((r: any) => r.data);
|
||||
expect(data).toEqual([results.posts[2]]);
|
||||
}
|
||||
});
|
||||
|
||||
it("read one", async () => {
|
||||
// one only includes posts with users_id is null
|
||||
{
|
||||
const res = await app.server.request("/api/data/entity/posts/1");
|
||||
const data = await res.json().then((r: any) => r.data);
|
||||
expect(res.status).toBe(404);
|
||||
expect(data).toBeUndefined();
|
||||
}
|
||||
|
||||
// read one by allowed id
|
||||
{
|
||||
const res = await app.server.request("/api/data/entity/posts/3");
|
||||
const data = await res.json().then((r: any) => r.data);
|
||||
expect(res.status).toBe(200);
|
||||
expect(data).toEqual(results.posts[2]);
|
||||
}
|
||||
});
|
||||
|
||||
it("read many by reference", async () => {
|
||||
const res = await app.server.request("/api/data/entity/posts/1/comments");
|
||||
const data = await res.json().then((r: any) => r.data);
|
||||
expect(res.status).toBe(200);
|
||||
expect(data).toEqual(results.comments.filter((c: any) => c.posts_id === 1));
|
||||
});
|
||||
|
||||
it("mutation create one", async () => {
|
||||
// not allowed
|
||||
{
|
||||
const res = await app.server.request("/api/data/entity/posts", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ content: "post 4" }),
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
}
|
||||
// allowed
|
||||
{
|
||||
const res = await app.server.request("/api/data/entity/posts", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ content: "post 4", users_id: null }),
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
}
|
||||
});
|
||||
|
||||
it("mutation update one", async () => {
|
||||
// update one: not allowed
|
||||
const res = await app.server.request("/api/data/entity/posts/1", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ content: "post 4" }),
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
|
||||
{
|
||||
// update one: allowed
|
||||
const res = await app.server.request("/api/data/entity/posts/3", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ content: "post 3 (updated)" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json().then((r: any) => r.data.content)).toBe("post 3 (updated)");
|
||||
}
|
||||
});
|
||||
|
||||
it("mutation update many", async () => {
|
||||
// update many: not allowed
|
||||
const res = await app.server.request("/api/data/entity/posts", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
update: { content: "post 4" },
|
||||
where: { users_id: { $isnull: 0 } },
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(200); // because filtered
|
||||
const _data = await res.json().then((r: any) => r.data.map((p: any) => p.users_id));
|
||||
expect(_data.every((u: any) => u === null)).toBe(true);
|
||||
|
||||
// verify
|
||||
const data = await app.em
|
||||
.repo("posts")
|
||||
.findMany({ select: ["content", "users_id"] })
|
||||
.then((r) => r.data);
|
||||
|
||||
// expect non null users_id to not have content "post 4"
|
||||
expect(
|
||||
data.filter((p: any) => p.users_id !== null).every((p: any) => p.content !== "post 4"),
|
||||
).toBe(true);
|
||||
// expect null users_id to have content "post 4"
|
||||
expect(
|
||||
data.filter((p: any) => p.users_id === null).every((p: any) => p.content === "post 4"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
const count = async () => {
|
||||
const {
|
||||
data: { count: _count },
|
||||
} = await app.em.repo("posts").count();
|
||||
return _count;
|
||||
};
|
||||
it("mutation delete one", async () => {
|
||||
const initial = await count();
|
||||
|
||||
// delete one: not allowed
|
||||
const res = await app.server.request("/api/data/entity/posts/1", {
|
||||
method: "DELETE",
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
expect(await count()).toBe(initial);
|
||||
|
||||
{
|
||||
// delete one: allowed
|
||||
const res = await app.server.request("/api/data/entity/posts/3", {
|
||||
method: "DELETE",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(await count()).toBe(initial - 1);
|
||||
}
|
||||
});
|
||||
|
||||
it("mutation delete many", async () => {
|
||||
// delete many: not allowed
|
||||
const res = await app.server.request("/api/data/entity/posts", {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
where: {},
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// only deleted posts with users_id is null
|
||||
const remaining = await app.em
|
||||
.repo("posts")
|
||||
.findMany()
|
||||
.then((r) => r.data);
|
||||
expect(remaining.every((p: any) => p.users_id !== null)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
20
app/__test__/auth/authorize/http/SystemController.spec.ts
Normal file
20
app/__test__/auth/authorize/http/SystemController.spec.ts
Normal file
@@ -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));
|
||||
});
|
||||
});
|
||||
543
app/__test__/auth/authorize/permissions.spec.ts
Normal file
543
app/__test__/auth/authorize/permissions.spec.ts
Normal file
@@ -0,0 +1,543 @@
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { s } from "bknd/utils";
|
||||
import { Permission } from "auth/authorize/Permission";
|
||||
import { Policy } from "auth/authorize/Policy";
|
||||
import { Hono } from "hono";
|
||||
import { getPermissionRoutes, permission } from "auth/middlewares/permission.middleware";
|
||||
import { auth } from "auth/middlewares/auth.middleware";
|
||||
import { Guard, mergeFilters, type GuardConfig } from "auth/authorize/Guard";
|
||||
import { Role, RolePermission } from "auth/authorize/Role";
|
||||
import { Exception } from "bknd";
|
||||
import { convert } from "core/object/query/object-query";
|
||||
|
||||
describe("Permission", () => {
|
||||
it("works with minimal schema", () => {
|
||||
expect(() => new Permission("test")).not.toThrow();
|
||||
});
|
||||
|
||||
it("parses context", () => {
|
||||
const p = new Permission(
|
||||
"test3",
|
||||
{
|
||||
filterable: true,
|
||||
},
|
||||
s.object({
|
||||
a: s.string(),
|
||||
}),
|
||||
);
|
||||
|
||||
// @ts-expect-error
|
||||
expect(() => p.parseContext({ a: [] })).toThrow();
|
||||
expect(p.parseContext({ a: "test" })).toEqual({ a: "test" });
|
||||
// @ts-expect-error
|
||||
expect(p.parseContext({ a: 1 })).toEqual({ a: "1" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Policy", () => {
|
||||
it("works with minimal schema", () => {
|
||||
expect(() => new Policy().toJSON()).not.toThrow();
|
||||
});
|
||||
|
||||
it("checks condition", () => {
|
||||
const p = new Policy({
|
||||
condition: {
|
||||
a: 1,
|
||||
},
|
||||
});
|
||||
|
||||
expect(p.meetsCondition({ a: 1 })).toBe(true);
|
||||
expect(p.meetsCondition({ a: 2 })).toBe(false);
|
||||
expect(p.meetsCondition({ a: 1, b: 1 })).toBe(true);
|
||||
expect(p.meetsCondition({})).toBe(false);
|
||||
|
||||
const p2 = new Policy({
|
||||
condition: {
|
||||
a: { $gt: 1 },
|
||||
$or: {
|
||||
b: { $lt: 2 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(p2.meetsCondition({ a: 2 })).toBe(true);
|
||||
expect(p2.meetsCondition({ a: 1 })).toBe(false);
|
||||
expect(p2.meetsCondition({ a: 1, b: 1 })).toBe(true);
|
||||
});
|
||||
|
||||
it("filters", () => {
|
||||
const p = new Policy({
|
||||
filter: {
|
||||
age: { $gt: 18 },
|
||||
},
|
||||
});
|
||||
const subjects = [{ age: 19 }, { age: 17 }, { age: 12 }];
|
||||
|
||||
expect(p.getFiltered(subjects)).toEqual([{ age: 19 }]);
|
||||
|
||||
expect(p.meetsFilter({ age: 19 })).toBe(true);
|
||||
expect(p.meetsFilter({ age: 17 })).toBe(false);
|
||||
expect(p.meetsFilter({ age: 12 })).toBe(false);
|
||||
});
|
||||
|
||||
it("replaces placeholders", () => {
|
||||
const p = new Policy({
|
||||
condition: {
|
||||
a: "@auth.username",
|
||||
},
|
||||
filter: {
|
||||
a: "@auth.username",
|
||||
},
|
||||
});
|
||||
const vars = { auth: { username: "test" } };
|
||||
|
||||
expect(p.meetsCondition({ a: "test" }, vars)).toBe(true);
|
||||
expect(p.meetsCondition({ a: "test2" }, vars)).toBe(false);
|
||||
expect(p.meetsCondition({ a: "test2" })).toBe(false);
|
||||
expect(p.meetsFilter({ a: "test" }, vars)).toBe(true);
|
||||
expect(p.meetsFilter({ a: "test2" }, vars)).toBe(false);
|
||||
expect(p.meetsFilter({ a: "test2" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Guard", () => {
|
||||
it("collects filters", () => {
|
||||
const p = new Permission(
|
||||
"test",
|
||||
{
|
||||
filterable: true,
|
||||
},
|
||||
s.object({
|
||||
a: s.number(),
|
||||
}),
|
||||
);
|
||||
const r = new Role("test", [
|
||||
new RolePermission(p, [
|
||||
new Policy({
|
||||
condition: { a: { $eq: 1 } },
|
||||
filter: { foo: "bar" },
|
||||
effect: "filter",
|
||||
}),
|
||||
]),
|
||||
]);
|
||||
const guard = new Guard([p], [r], {
|
||||
enabled: true,
|
||||
});
|
||||
expect(guard.filters(p, { role: r.name }, { a: 1 }).filter).toEqual({ foo: "bar" });
|
||||
expect(guard.filters(p, { role: r.name }, { a: 2 }).filter).toBeUndefined();
|
||||
// if no user context given, filter cannot be applied
|
||||
expect(guard.filters(p, {}, { a: 1 }).filter).toBeUndefined();
|
||||
});
|
||||
|
||||
it("collects filters for default role", () => {
|
||||
const p = new Permission(
|
||||
"test",
|
||||
{
|
||||
filterable: true,
|
||||
},
|
||||
s.object({
|
||||
a: s.number(),
|
||||
}),
|
||||
);
|
||||
const r = new Role(
|
||||
"test",
|
||||
[
|
||||
new RolePermission(p, [
|
||||
new Policy({
|
||||
condition: { a: { $eq: 1 } },
|
||||
filter: { foo: "bar" },
|
||||
effect: "filter",
|
||||
}),
|
||||
]),
|
||||
],
|
||||
true,
|
||||
);
|
||||
const guard = new Guard([p], [r], {
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
guard.filters(
|
||||
p,
|
||||
{
|
||||
role: r.name,
|
||||
},
|
||||
{ a: 1 },
|
||||
).filter,
|
||||
).toEqual({ foo: "bar" });
|
||||
expect(
|
||||
guard.filters(
|
||||
p,
|
||||
{
|
||||
role: r.name,
|
||||
},
|
||||
{ a: 2 },
|
||||
).filter,
|
||||
).toBeUndefined();
|
||||
// if no user context given, the default role is applied
|
||||
// hence it can be found
|
||||
expect(guard.filters(p, {}, { a: 1 }).filter).toEqual({ foo: "bar" });
|
||||
});
|
||||
|
||||
it("merges filters correctly", () => {
|
||||
expect(mergeFilters({ foo: "bar" }, { baz: "qux" })).toEqual({
|
||||
foo: { $eq: "bar" },
|
||||
baz: { $eq: "qux" },
|
||||
});
|
||||
expect(mergeFilters({ foo: "bar" }, { baz: { $eq: "qux" } })).toEqual({
|
||||
foo: { $eq: "bar" },
|
||||
baz: { $eq: "qux" },
|
||||
});
|
||||
expect(mergeFilters({ foo: "bar" }, { foo: "baz" })).toEqual({ foo: { $eq: "baz" } });
|
||||
|
||||
expect(mergeFilters({ foo: "bar" }, { foo: { $lt: 1 } })).toEqual({
|
||||
foo: { $eq: "bar", $lt: 1 },
|
||||
});
|
||||
|
||||
// overwrite base $or with priority
|
||||
expect(mergeFilters({ $or: { foo: "one" } }, { foo: "bar" })).toEqual({
|
||||
$or: {
|
||||
foo: {
|
||||
$eq: "bar",
|
||||
},
|
||||
},
|
||||
foo: {
|
||||
$eq: "bar",
|
||||
},
|
||||
});
|
||||
|
||||
// ignore base $or if priority has different key
|
||||
expect(mergeFilters({ $or: { other: "one" } }, { foo: "bar" })).toEqual({
|
||||
$or: {
|
||||
other: {
|
||||
$eq: "one",
|
||||
},
|
||||
},
|
||||
foo: {
|
||||
$eq: "bar",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("permission middleware", () => {
|
||||
const makeApp = (
|
||||
permissions: Permission<any, any, any, any>[],
|
||||
roles: Role[] = [],
|
||||
config: Partial<GuardConfig> = {},
|
||||
) => {
|
||||
const app = {
|
||||
module: {
|
||||
auth: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
modules: {
|
||||
ctx: () => ({
|
||||
guard: new Guard(permissions, roles, {
|
||||
enabled: true,
|
||||
...config,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
};
|
||||
return new Hono()
|
||||
.use(async (c, next) => {
|
||||
// @ts-expect-error
|
||||
c.set("app", app);
|
||||
await next();
|
||||
})
|
||||
.use(auth())
|
||||
.onError((err, c) => {
|
||||
if (err instanceof Exception) {
|
||||
return c.json(err.toJSON(), err.code as any);
|
||||
}
|
||||
return c.json({ error: err.message }, "code" in err ? (err.code as any) : 500);
|
||||
});
|
||||
};
|
||||
|
||||
it("allows if guard is disabled", async () => {
|
||||
const p = new Permission("test");
|
||||
const hono = makeApp([p], [], { enabled: false }).get("/test", permission(p, {}), async (c) =>
|
||||
c.text("test"),
|
||||
);
|
||||
|
||||
const res = await hono.request("/test");
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.text()).toBe("test");
|
||||
});
|
||||
|
||||
it("denies if guard is enabled", async () => {
|
||||
const p = new Permission("test");
|
||||
const hono = makeApp([p]).get("/test", permission(p, {}), async (c) => c.text("test"));
|
||||
|
||||
const res = await hono.request("/test");
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("allows if user has (plain) role", async () => {
|
||||
const p = new Permission("test");
|
||||
const r = Role.create("test", { permissions: [p.name] });
|
||||
const hono = makeApp([p], [r])
|
||||
.use(async (c, next) => {
|
||||
// @ts-expect-error
|
||||
c.set("auth", { registered: true, user: { id: 0, role: r.name } });
|
||||
await next();
|
||||
})
|
||||
.get("/test", permission(p, {}), async (c) => c.text("test"));
|
||||
|
||||
const res = await hono.request("/test");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("allows if user has role with policy", async () => {
|
||||
const p = new Permission("test");
|
||||
const r = new Role("test", [
|
||||
new RolePermission(p, [
|
||||
new Policy({
|
||||
condition: {
|
||||
a: { $gte: 1 },
|
||||
},
|
||||
}),
|
||||
]),
|
||||
]);
|
||||
const hono = makeApp([p], [r], {
|
||||
context: {
|
||||
a: 1,
|
||||
},
|
||||
})
|
||||
.use(async (c, next) => {
|
||||
// @ts-expect-error
|
||||
c.set("auth", { registered: true, user: { id: 0, role: r.name } });
|
||||
await next();
|
||||
})
|
||||
.get("/test", permission(p, {}), async (c) => c.text("test"));
|
||||
|
||||
const res = await hono.request("/test");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("denies if user with role doesn't meet condition", async () => {
|
||||
const p = new Permission("test");
|
||||
const r = new Role("test", [
|
||||
new RolePermission(
|
||||
p,
|
||||
[
|
||||
new Policy({
|
||||
condition: {
|
||||
a: { $lt: 1 },
|
||||
},
|
||||
// default effect is allow
|
||||
}),
|
||||
],
|
||||
// change default effect to deny if no condition is met
|
||||
"deny",
|
||||
),
|
||||
]);
|
||||
const hono = makeApp([p], [r], {
|
||||
context: {
|
||||
a: 1,
|
||||
},
|
||||
})
|
||||
.use(async (c, next) => {
|
||||
// @ts-expect-error
|
||||
c.set("auth", { registered: true, user: { id: 0, role: r.name } });
|
||||
await next();
|
||||
})
|
||||
.get("/test", permission(p, {}), async (c) => c.text("test"));
|
||||
|
||||
const res = await hono.request("/test");
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("allows if user with role doesn't meet condition (from middleware)", async () => {
|
||||
const p = new Permission(
|
||||
"test",
|
||||
{},
|
||||
s.object({
|
||||
a: s.number(),
|
||||
}),
|
||||
);
|
||||
const r = new Role("test", [
|
||||
new RolePermission(p, [
|
||||
new Policy({
|
||||
condition: {
|
||||
a: { $eq: 1 },
|
||||
},
|
||||
}),
|
||||
]),
|
||||
]);
|
||||
const hono = makeApp([p], [r])
|
||||
.use(async (c, next) => {
|
||||
// @ts-expect-error
|
||||
c.set("auth", { registered: true, user: { id: 0, role: r.name } });
|
||||
await next();
|
||||
})
|
||||
.get(
|
||||
"/test",
|
||||
permission(p, {
|
||||
context: (c) => ({
|
||||
a: 1,
|
||||
}),
|
||||
}),
|
||||
async (c) => c.text("test"),
|
||||
);
|
||||
|
||||
const res = await hono.request("/test");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("throws if permission context is invalid", async () => {
|
||||
const p = new Permission(
|
||||
"test",
|
||||
{},
|
||||
s.object({
|
||||
a: s.number({ minimum: 2 }),
|
||||
}),
|
||||
);
|
||||
const r = new Role("test", [
|
||||
new RolePermission(p, [
|
||||
new Policy({
|
||||
condition: {
|
||||
a: { $eq: 1 },
|
||||
},
|
||||
}),
|
||||
]),
|
||||
]);
|
||||
const hono = makeApp([p], [r])
|
||||
.use(async (c, next) => {
|
||||
// @ts-expect-error
|
||||
c.set("auth", { registered: true, user: { id: 0, role: r.name } });
|
||||
await next();
|
||||
})
|
||||
.get(
|
||||
"/test",
|
||||
permission(p, {
|
||||
context: (c) => ({
|
||||
a: 1,
|
||||
}),
|
||||
}),
|
||||
async (c) => c.text("test"),
|
||||
);
|
||||
|
||||
const res = await hono.request("/test");
|
||||
// expecting 500 because bknd should have handled it correctly
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("checks context on routes with permissions", async () => {
|
||||
const make = (user: any) => {
|
||||
const p = new Permission(
|
||||
"test",
|
||||
{},
|
||||
s.object({
|
||||
a: s.number(),
|
||||
}),
|
||||
);
|
||||
const r = new Role("test", [
|
||||
new RolePermission(p, [
|
||||
new Policy({
|
||||
condition: {
|
||||
a: { $eq: 1 },
|
||||
},
|
||||
}),
|
||||
]),
|
||||
]);
|
||||
return makeApp([p], [r])
|
||||
.use(async (c, next) => {
|
||||
// @ts-expect-error
|
||||
c.set("auth", { registered: true, user });
|
||||
await next();
|
||||
})
|
||||
.get(
|
||||
"/valid",
|
||||
permission(p, {
|
||||
context: (c) => ({
|
||||
a: 1,
|
||||
}),
|
||||
}),
|
||||
async (c) => c.text("test"),
|
||||
)
|
||||
.get(
|
||||
"/invalid",
|
||||
permission(p, {
|
||||
// @ts-expect-error
|
||||
context: (c) => ({
|
||||
b: "1",
|
||||
}),
|
||||
}),
|
||||
async (c) => c.text("test"),
|
||||
)
|
||||
.get(
|
||||
"/invalid2",
|
||||
permission(p, {
|
||||
// @ts-expect-error
|
||||
context: (c) => ({}),
|
||||
}),
|
||||
async (c) => c.text("test"),
|
||||
)
|
||||
.get(
|
||||
"/invalid3",
|
||||
// @ts-expect-error
|
||||
permission(p),
|
||||
async (c) => c.text("test"),
|
||||
);
|
||||
};
|
||||
|
||||
const hono = make({ id: 0, role: "test" });
|
||||
const valid = await hono.request("/valid");
|
||||
expect(valid.status).toBe(200);
|
||||
const invalid = await hono.request("/invalid");
|
||||
expect(invalid.status).toBe(500);
|
||||
const invalid2 = await hono.request("/invalid2");
|
||||
expect(invalid2.status).toBe(500);
|
||||
const invalid3 = await hono.request("/invalid3");
|
||||
expect(invalid3.status).toBe(500);
|
||||
|
||||
{
|
||||
const hono = make(null);
|
||||
const valid = await hono.request("/valid");
|
||||
expect(valid.status).toBe(403);
|
||||
const invalid = await hono.request("/invalid");
|
||||
expect(invalid.status).toBe(500);
|
||||
const invalid2 = await hono.request("/invalid2");
|
||||
expect(invalid2.status).toBe(500);
|
||||
const invalid3 = await hono.request("/invalid3");
|
||||
expect(invalid3.status).toBe(500);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Role", () => {
|
||||
it("serializes and deserializes", () => {
|
||||
const p = new Permission(
|
||||
"test",
|
||||
{
|
||||
filterable: true,
|
||||
},
|
||||
s.object({
|
||||
a: s.number({ minimum: 2 }),
|
||||
}),
|
||||
);
|
||||
const r = new Role(
|
||||
"test",
|
||||
[
|
||||
new RolePermission(p, [
|
||||
new Policy({
|
||||
condition: {
|
||||
a: { $eq: 1 },
|
||||
},
|
||||
effect: "deny",
|
||||
filter: {
|
||||
b: { $lt: 1 },
|
||||
},
|
||||
}),
|
||||
]),
|
||||
],
|
||||
true,
|
||||
);
|
||||
const json = JSON.parse(JSON.stringify(r.toJSON()));
|
||||
const r2 = Role.create(p.name, json);
|
||||
expect(r2.toJSON()).toEqual(r.toJSON());
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test";
|
||||
import { Event, EventManager, InvalidEventReturn, NoParamEvent } from "../../src/core/events";
|
||||
import { disableConsoleLog, enableConsoleLog } from "../helper";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
beforeAll(disableConsoleLog);
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
@@ -66,4 +66,14 @@ describe("object-query", () => {
|
||||
expect(result).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
test("paths", () => {
|
||||
const result = validate({ "user.age": { $lt: 18 } }, { user: { age: 17 } });
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("empty filters", () => {
|
||||
const result = validate({}, { user: { age: 17 } });
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
120
app/__test__/core/object/query.spec.ts
Normal file
120
app/__test__/core/object/query.spec.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
makeValidator,
|
||||
exp,
|
||||
Expression,
|
||||
isPrimitive,
|
||||
type Primitive,
|
||||
} from "../../../src/core/object/query/query";
|
||||
|
||||
describe("query", () => {
|
||||
test("isPrimitive", () => {
|
||||
expect(isPrimitive(1)).toBe(true);
|
||||
expect(isPrimitive("1")).toBe(true);
|
||||
expect(isPrimitive(true)).toBe(true);
|
||||
expect(isPrimitive(false)).toBe(true);
|
||||
|
||||
// not primitives
|
||||
expect(isPrimitive(null)).toBe(false);
|
||||
expect(isPrimitive(undefined)).toBe(false);
|
||||
expect(isPrimitive([])).toBe(false);
|
||||
expect(isPrimitive({})).toBe(false);
|
||||
expect(isPrimitive(Symbol("test"))).toBe(false);
|
||||
expect(isPrimitive(new Date())).toBe(false);
|
||||
expect(isPrimitive(new Error())).toBe(false);
|
||||
expect(isPrimitive(new Set())).toBe(false);
|
||||
expect(isPrimitive(new Map())).toBe(false);
|
||||
});
|
||||
|
||||
test("strict expression creation", () => {
|
||||
// @ts-expect-error
|
||||
expect(() => exp()).toThrow();
|
||||
// @ts-expect-error
|
||||
expect(() => exp("")).toThrow();
|
||||
// @ts-expect-error
|
||||
expect(() => exp("invalid")).toThrow();
|
||||
// @ts-expect-error
|
||||
expect(() => exp("$eq")).toThrow();
|
||||
// @ts-expect-error
|
||||
expect(() => exp("$eq", 1)).toThrow();
|
||||
// @ts-expect-error
|
||||
expect(() => exp("$eq", () => null)).toThrow();
|
||||
// @ts-expect-error
|
||||
expect(() => exp("$eq", () => null, 1)).toThrow();
|
||||
expect(
|
||||
exp(
|
||||
"$eq",
|
||||
() => true,
|
||||
() => null,
|
||||
),
|
||||
).toBeInstanceOf(Expression);
|
||||
});
|
||||
|
||||
test("$eq is required", () => {
|
||||
expect(() => makeValidator([])).toThrow();
|
||||
expect(() =>
|
||||
makeValidator([
|
||||
exp(
|
||||
"$valid",
|
||||
() => true,
|
||||
() => null,
|
||||
),
|
||||
]),
|
||||
).toThrow();
|
||||
expect(
|
||||
makeValidator([
|
||||
exp(
|
||||
"$eq",
|
||||
() => true,
|
||||
() => null,
|
||||
),
|
||||
]),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
test("validates filter structure", () => {
|
||||
const validator = makeValidator([
|
||||
exp(
|
||||
"$eq",
|
||||
(v: Primitive) => isPrimitive(v),
|
||||
(e, a) => e === a,
|
||||
),
|
||||
exp(
|
||||
"$like",
|
||||
(v: string) => typeof v === "string",
|
||||
(e, a) => e === a,
|
||||
),
|
||||
]);
|
||||
|
||||
// @ts-expect-error intentionally typed as union of given expression keys
|
||||
expect(validator.expressionKeys).toEqual(["$eq", "$like"]);
|
||||
|
||||
// @ts-expect-error "$and" is not allowed
|
||||
expect(() => validator.convert({ $and: {} })).toThrow();
|
||||
|
||||
// @ts-expect-error "$or" must be an object
|
||||
expect(() => validator.convert({ $or: [] })).toThrow();
|
||||
|
||||
// @ts-expect-error "invalid" is not a valid expression key
|
||||
expect(() => validator.convert({ foo: { invalid: "bar" } })).toThrow();
|
||||
|
||||
// @ts-expect-error "invalid" is not a valid expression key
|
||||
expect(() => validator.convert({ foo: { $invalid: "bar" } })).toThrow();
|
||||
|
||||
// @ts-expect-error "null" is not a valid value
|
||||
expect(() => validator.convert({ foo: null })).toThrow();
|
||||
|
||||
// @ts-expect-error only primitives are allowed for $eq
|
||||
expect(() => validator.convert({ foo: { $eq: [] } })).toThrow();
|
||||
|
||||
// @ts-expect-error only strings are allowed for $like
|
||||
expect(() => validator.convert({ foo: { $like: 1 } })).toThrow();
|
||||
|
||||
// undefined values are ignored
|
||||
expect(validator.convert({ foo: undefined })).toEqual({});
|
||||
|
||||
expect(validator.convert({ foo: "bar" })).toEqual({ foo: { $eq: "bar" } });
|
||||
expect(validator.convert({ foo: { $eq: "bar" } })).toEqual({ foo: { $eq: "bar" } });
|
||||
expect(validator.convert({ foo: { $like: "bar" } })).toEqual({ foo: { $like: "bar" } });
|
||||
});
|
||||
});
|
||||
@@ -194,6 +194,182 @@ describe("Core Utils", async () => {
|
||||
expect(result).toEqual(expected);
|
||||
}
|
||||
});
|
||||
|
||||
test("recursivelyReplacePlaceholders", () => {
|
||||
// test basic replacement with simple pattern
|
||||
const obj1 = { a: "Hello, {$name}!", b: { c: "Hello, {$name}!" } };
|
||||
const variables1 = { name: "John" };
|
||||
const result1 = utils.recursivelyReplacePlaceholders(obj1, /\{\$(\w+)\}/g, variables1);
|
||||
expect(result1).toEqual({ a: "Hello, John!", b: { c: "Hello, John!" } });
|
||||
|
||||
// test the specific example from the user request
|
||||
const obj2 = { some: "value", here: "@auth.user" };
|
||||
const variables2 = { auth: { user: "what" } };
|
||||
const result2 = utils.recursivelyReplacePlaceholders(obj2, /^@([a-z\.]+)$/, variables2);
|
||||
expect(result2).toEqual({ some: "value", here: "what" });
|
||||
|
||||
// test with arrays
|
||||
const obj3 = { items: ["@config.name", "static", "@config.version"] };
|
||||
const variables3 = { config: { name: "MyApp", version: "1.0.0" } };
|
||||
const result3 = utils.recursivelyReplacePlaceholders(obj3, /^@([a-z\.]+)$/, variables3);
|
||||
expect(result3).toEqual({ items: ["MyApp", "static", "1.0.0"] });
|
||||
|
||||
// test with nested objects and deep paths
|
||||
const obj4 = {
|
||||
user: "@auth.user.name",
|
||||
settings: {
|
||||
theme: "@ui.theme",
|
||||
nested: {
|
||||
value: "@deep.nested.value",
|
||||
},
|
||||
},
|
||||
};
|
||||
const variables4 = {
|
||||
auth: { user: { name: "Alice" } },
|
||||
ui: { theme: "dark" },
|
||||
deep: { nested: { value: "found" } },
|
||||
};
|
||||
const result4 = utils.recursivelyReplacePlaceholders(obj4, /^@([a-z\.]+)$/, variables4);
|
||||
expect(result4).toEqual({
|
||||
user: "Alice",
|
||||
settings: {
|
||||
theme: "dark",
|
||||
nested: {
|
||||
value: "found",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// test with missing paths (should return original match)
|
||||
const obj5 = { value: "@missing.path" };
|
||||
const variables5 = { existing: "value" };
|
||||
const result5 = utils.recursivelyReplacePlaceholders(obj5, /^@([a-z\.]+)$/, variables5);
|
||||
expect(result5).toEqual({ value: "@missing.path" });
|
||||
|
||||
// test with non-matching strings (should remain unchanged)
|
||||
const obj6 = { value: "normal string", other: "not@matching" };
|
||||
const variables6 = { some: "value" };
|
||||
const result6 = utils.recursivelyReplacePlaceholders(obj6, /^@([a-z\.]+)$/, variables6);
|
||||
expect(result6).toEqual({ value: "normal string", other: "not@matching" });
|
||||
|
||||
// test with primitive values (should handle gracefully)
|
||||
expect(
|
||||
utils.recursivelyReplacePlaceholders("@test.value", /^@([a-z\.]+)$/, {
|
||||
test: { value: "replaced" },
|
||||
}),
|
||||
).toBe("replaced");
|
||||
expect(utils.recursivelyReplacePlaceholders(123, /^@([a-z\.]+)$/, {})).toBe(123);
|
||||
expect(utils.recursivelyReplacePlaceholders(null, /^@([a-z\.]+)$/, {})).toBe(null);
|
||||
|
||||
// test type preservation for full string matches
|
||||
const variables7 = { test: { value: 123, flag: true, data: null, arr: [1, 2, 3] } };
|
||||
const result7 = utils.recursivelyReplacePlaceholders(
|
||||
{
|
||||
number: "@test.value",
|
||||
boolean: "@test.flag",
|
||||
nullValue: "@test.data",
|
||||
array: "@test.arr",
|
||||
},
|
||||
/^@([a-z\.]+)$/,
|
||||
variables7,
|
||||
null,
|
||||
);
|
||||
expect(result7).toEqual({
|
||||
number: 123,
|
||||
boolean: true,
|
||||
nullValue: null,
|
||||
array: [1, 2, 3],
|
||||
});
|
||||
|
||||
// test partial string replacement (should convert to string)
|
||||
const result8 = utils.recursivelyReplacePlaceholders(
|
||||
{ message: "The value is @test.value!" },
|
||||
/@([a-z\.]+)/g,
|
||||
variables7,
|
||||
);
|
||||
expect(result8).toEqual({ message: "The value is 123!" });
|
||||
|
||||
// test with fallback parameter
|
||||
const obj9 = { user: "@user.id", config: "@config.theme" };
|
||||
const variables9 = {}; // empty context
|
||||
const result9 = utils.recursivelyReplacePlaceholders(
|
||||
obj9,
|
||||
/^@([a-z\.]+)$/,
|
||||
variables9,
|
||||
null,
|
||||
);
|
||||
expect(result9).toEqual({ user: null, config: null });
|
||||
|
||||
// test with fallback for partial matches
|
||||
const obj10 = { message: "Hello @user.name, welcome!" };
|
||||
const variables10 = {}; // empty context
|
||||
const result10 = utils.recursivelyReplacePlaceholders(
|
||||
obj10,
|
||||
/@([a-z\.]+)/g,
|
||||
variables10,
|
||||
"Guest",
|
||||
);
|
||||
expect(result10).toEqual({ message: "Hello Guest, welcome!" });
|
||||
|
||||
// test with different fallback types
|
||||
const obj11 = {
|
||||
stringFallback: "@missing.string",
|
||||
numberFallback: "@missing.number",
|
||||
booleanFallback: "@missing.boolean",
|
||||
objectFallback: "@missing.object",
|
||||
};
|
||||
const variables11 = {};
|
||||
const result11 = utils.recursivelyReplacePlaceholders(
|
||||
obj11,
|
||||
/^@([a-z\.]+)$/,
|
||||
variables11,
|
||||
"default",
|
||||
);
|
||||
expect(result11).toEqual({
|
||||
stringFallback: "default",
|
||||
numberFallback: "default",
|
||||
booleanFallback: "default",
|
||||
objectFallback: "default",
|
||||
});
|
||||
|
||||
// test fallback with arrays
|
||||
const obj12 = { items: ["@item1", "@item2", "static"] };
|
||||
const variables12 = { item1: "found" }; // item2 is missing
|
||||
const result12 = utils.recursivelyReplacePlaceholders(
|
||||
obj12,
|
||||
/^@([a-zA-Z0-9\.]+)$/,
|
||||
variables12,
|
||||
"missing",
|
||||
);
|
||||
expect(result12).toEqual({ items: ["found", "missing", "static"] });
|
||||
|
||||
// test fallback with nested objects
|
||||
const obj13 = {
|
||||
user: "@user.id",
|
||||
settings: {
|
||||
theme: "@theme.name",
|
||||
nested: {
|
||||
value: "@deep.value",
|
||||
},
|
||||
},
|
||||
};
|
||||
const variables13 = {}; // empty context
|
||||
const result13 = utils.recursivelyReplacePlaceholders(
|
||||
obj13,
|
||||
/^@([a-z\.]+)$/,
|
||||
variables13,
|
||||
null,
|
||||
);
|
||||
expect(result13).toEqual({
|
||||
user: null,
|
||||
settings: {
|
||||
theme: null,
|
||||
nested: {
|
||||
value: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("file", async () => {
|
||||
@@ -248,7 +424,7 @@ describe("Core Utils", async () => {
|
||||
expect(utils.getContentName(request)).toBe(name);
|
||||
});
|
||||
|
||||
test.only("detectImageDimensions", async () => {
|
||||
test("detectImageDimensions", async () => {
|
||||
// wrong
|
||||
// @ts-expect-error
|
||||
expect(utils.detectImageDimensions(new ArrayBuffer(), "text/plain")).rejects.toThrow();
|
||||
@@ -264,15 +440,44 @@ describe("Core Utils", async () => {
|
||||
height: 512,
|
||||
});
|
||||
});
|
||||
|
||||
test("isFileAccepted", () => {
|
||||
const file = new File([""], "file.txt", {
|
||||
type: "text/plain",
|
||||
});
|
||||
expect(utils.isFileAccepted(file, "text/plain")).toBe(true);
|
||||
expect(utils.isFileAccepted(file, "text/plain,text/html")).toBe(true);
|
||||
expect(utils.isFileAccepted(file, "text/html")).toBe(false);
|
||||
|
||||
{
|
||||
const file = new File([""], "file.jpg", {
|
||||
type: "image/jpeg",
|
||||
});
|
||||
expect(utils.isFileAccepted(file, "image/jpeg")).toBe(true);
|
||||
expect(utils.isFileAccepted(file, "image/jpeg,image/png")).toBe(true);
|
||||
expect(utils.isFileAccepted(file, "image/png")).toBe(false);
|
||||
expect(utils.isFileAccepted(file, "image/*")).toBe(true);
|
||||
expect(utils.isFileAccepted(file, ".jpg")).toBe(true);
|
||||
expect(utils.isFileAccepted(file, ".jpg,.png")).toBe(true);
|
||||
expect(utils.isFileAccepted(file, ".png")).toBe(false);
|
||||
}
|
||||
|
||||
{
|
||||
const file = new File([""], "file.png");
|
||||
expect(utils.isFileAccepted(file, undefined as any)).toBe(true);
|
||||
}
|
||||
|
||||
expect(() => utils.isFileAccepted(null as any, "text/plain")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("dates", () => {
|
||||
test.only("formats local time", () => {
|
||||
test("formats local time", () => {
|
||||
expect(utils.datetimeStringUTC("2025-02-21T16:48:25.841Z")).toBe("2025-02-21 16:48:25");
|
||||
console.log(utils.datetimeStringUTC(new Date()));
|
||||
/*console.log(utils.datetimeStringUTC(new Date()));
|
||||
console.log(utils.datetimeStringUTC());
|
||||
console.log(new Date());
|
||||
console.log("timezone", Intl.DateTimeFormat().resolvedOptions().timeZone);
|
||||
console.log("timezone", Intl.DateTimeFormat().resolvedOptions().timeZone); */
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,8 @@ import { parse } from "core/utils/schema";
|
||||
|
||||
import { DataController } from "../../src/data/api/DataController";
|
||||
import { dataConfigSchema } from "../../src/data/data-schema";
|
||||
import { disableConsoleLog, enableConsoleLog, getDummyConnection } from "../helper";
|
||||
import { getDummyConnection } from "../helper";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
import type { RepositoryResultJSON } from "data/entities/query/RepositoryResult";
|
||||
import type { MutatorResultJSON } from "data/entities/mutation/MutatorResult";
|
||||
import { Entity, EntityManager, type EntityData } from "data/entities";
|
||||
@@ -13,7 +14,7 @@ import { TextField } from "data/fields";
|
||||
import { ManyToOneRelation } from "data/relations";
|
||||
|
||||
const { dummyConnection, afterAllCleanup } = getDummyConnection();
|
||||
beforeAll(() => disableConsoleLog(["log", "warn"]));
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
|
||||
|
||||
const dataConfig = parse(dataConfigSchema, {});
|
||||
|
||||
@@ -30,9 +30,9 @@ describe("some tests", async () => {
|
||||
const query = await em.repository(users).findId(1);
|
||||
|
||||
expect(query.sql).toBe(
|
||||
'select "users"."id" as "id", "users"."username" as "username", "users"."email" as "email" from "users" where "id" = ? limit ?',
|
||||
'select "users"."id" as "id", "users"."username" as "username", "users"."email" as "email" from "users" where "id" = ? order by "users"."id" asc limit ? offset ?',
|
||||
);
|
||||
expect(query.parameters).toEqual([1, 1]);
|
||||
expect(query.parameters).toEqual([1, 1, 0]);
|
||||
expect(query.data).toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -47,8 +47,4 @@ describe("[data] Entity", async () => {
|
||||
entity.addField(field);
|
||||
expect(entity.getField("new_field")).toBe(field);
|
||||
});
|
||||
|
||||
test.only("types", async () => {
|
||||
console.log(entity.toTypes());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { Entity, EntityManager } from "data/entities";
|
||||
import { ManyToOneRelation } from "data/relations";
|
||||
import { TextField } from "data/fields";
|
||||
import { JoinBuilder } from "data/entities/query/JoinBuilder";
|
||||
import { getDummyConnection } from "../helper";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
beforeAll(() => disableConsoleLog());
|
||||
|
||||
const { dummyConnection, afterAllCleanup } = getDummyConnection();
|
||||
afterAll(afterAllCleanup);
|
||||
afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
|
||||
|
||||
describe("[data] JoinBuilder", async () => {
|
||||
test("missing relation", async () => {
|
||||
|
||||
@@ -9,13 +9,14 @@ import {
|
||||
} from "data/relations";
|
||||
import { NumberField, TextField } from "data/fields";
|
||||
import * as proto from "data/prototype";
|
||||
import { getDummyConnection, disableConsoleLog, enableConsoleLog } from "../../helper";
|
||||
import { getDummyConnection } from "../../helper";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
import { MutatorEvents } from "data/events";
|
||||
|
||||
const { dummyConnection, afterAllCleanup } = getDummyConnection();
|
||||
afterAll(afterAllCleanup);
|
||||
|
||||
beforeAll(() => disableConsoleLog(["log", "warn"]));
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
|
||||
|
||||
describe("[data] Mutator (base)", async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import type { Kysely, Transaction } from "kysely";
|
||||
import { TextField } from "data/fields";
|
||||
import { em as $em, entity as $entity, text as $text } from "data/prototype";
|
||||
@@ -6,11 +6,13 @@ import { Entity, EntityManager } from "data/entities";
|
||||
import { ManyToOneRelation } from "data/relations";
|
||||
import { RepositoryEvents } from "data/events";
|
||||
import { getDummyConnection } from "../helper";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
type E = Kysely<any> | Transaction<any>;
|
||||
|
||||
const { dummyConnection, afterAllCleanup } = getDummyConnection();
|
||||
afterAll(afterAllCleanup);
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
|
||||
|
||||
async function sleep(ms: number) {
|
||||
return new Promise((resolve) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// eslint-disable-next-line import/no-unresolved
|
||||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import { afterAll, describe, expect, spyOn, test } from "bun:test";
|
||||
import { randomString } from "core/utils";
|
||||
import { Entity, EntityManager } from "data/entities";
|
||||
import { TextField, EntityIndex } from "data/fields";
|
||||
@@ -268,4 +268,39 @@ describe("SchemaManager tests", async () => {
|
||||
const diffAfter = await em.schema().getDiff();
|
||||
expect(diffAfter.length).toBe(0);
|
||||
});
|
||||
|
||||
test("returns statements", async () => {
|
||||
const amount = 5;
|
||||
const entities = new Array(amount)
|
||||
.fill(0)
|
||||
.map(() => new Entity(randomString(16), [new TextField("text")]));
|
||||
const em = new EntityManager(entities, dummyConnection);
|
||||
const statements = await em.schema().sync({ force: true });
|
||||
expect(statements.length).toBe(amount);
|
||||
expect(statements.every((stmt) => Object.keys(stmt).join(",") === "sql,parameters")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("batches statements", async () => {
|
||||
const { dummyConnection } = getDummyConnection();
|
||||
const entities = new Array(20)
|
||||
.fill(0)
|
||||
.map(() => new Entity(randomString(16), [new TextField("text")]));
|
||||
const em = new EntityManager(entities, dummyConnection);
|
||||
const spy = spyOn(em.connection, "executeQueries");
|
||||
const statements = await em.schema().sync();
|
||||
expect(statements.length).toBe(entities.length);
|
||||
expect(statements.every((stmt) => Object.keys(stmt).join(",") === "sql,parameters")).toBe(
|
||||
true,
|
||||
);
|
||||
await em.schema().sync({ force: true });
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
const tables = await em.connection.kysely
|
||||
.selectFrom("sqlite_master")
|
||||
.where("type", "=", "table")
|
||||
.selectAll()
|
||||
.execute();
|
||||
expect(tables.length).toBe(entities.length + 1); /* 1+ for sqlite_sequence */
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { Entity, EntityManager } from "data/entities";
|
||||
import { ManyToManyRelation, ManyToOneRelation, PolymorphicRelation } from "data/relations";
|
||||
import { TextField } from "data/fields";
|
||||
@@ -6,6 +6,10 @@ import * as proto from "data/prototype";
|
||||
import { WithBuilder } from "data/entities/query/WithBuilder";
|
||||
import { schemaToEm } from "../../helper";
|
||||
import { getDummyConnection } from "../helper";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
const { dummyConnection } = getDummyConnection();
|
||||
|
||||
|
||||
@@ -23,11 +23,4 @@ describe("FieldIndex", async () => {
|
||||
expect(index.name).toEqual("idx_test_name");
|
||||
expect(index.unique).toEqual(false);
|
||||
});
|
||||
|
||||
test("it fails on non-unique", async () => {
|
||||
const field = new TestField("name", { required: false });
|
||||
|
||||
expect(() => new EntityIndex(entity, [field], true)).toThrowError();
|
||||
expect(() => new EntityIndex(entity, [field])).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ describe("[data] JsonField", async () => {
|
||||
const field = new JsonField("test");
|
||||
fieldTestSuite(bunTestRunner, JsonField, {
|
||||
defaultValue: { a: 1 },
|
||||
sampleValues: ["string", { test: 1 }, 1],
|
||||
//sampleValues: ["string", { test: 1 }, 1],
|
||||
schemaType: "text",
|
||||
});
|
||||
|
||||
@@ -33,9 +33,9 @@ describe("[data] JsonField", async () => {
|
||||
});
|
||||
|
||||
test("getValue", async () => {
|
||||
expect(field.getValue({ test: 1 }, "form")).toBe('{\n "test": 1\n}');
|
||||
expect(field.getValue("string", "form")).toBe('"string"');
|
||||
expect(field.getValue(1, "form")).toBe("1");
|
||||
expect(field.getValue({ test: 1 }, "form")).toEqual({ test: 1 });
|
||||
expect(field.getValue("string", "form")).toBe("string");
|
||||
expect(field.getValue(1, "form")).toBe(1);
|
||||
|
||||
expect(field.getValue('{"test":1}', "submit")).toEqual({ test: 1 });
|
||||
expect(field.getValue('"string"', "submit")).toBe("string");
|
||||
@@ -43,6 +43,5 @@ describe("[data] JsonField", async () => {
|
||||
|
||||
expect(field.getValue({ test: 1 }, "table")).toBe('{"test":1}');
|
||||
expect(field.getValue("string", "table")).toBe('"string"');
|
||||
expect(field.getValue(1, "form")).toBe("1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,8 +4,10 @@ import {
|
||||
type BaseRelationConfig,
|
||||
EntityRelation,
|
||||
EntityRelationAnchor,
|
||||
ManyToManyRelation,
|
||||
RelationTypes,
|
||||
} from "data/relations";
|
||||
import * as proto from "data/prototype";
|
||||
|
||||
class TestEntityRelation extends EntityRelation {
|
||||
constructor(config?: BaseRelationConfig) {
|
||||
@@ -75,4 +77,15 @@ describe("[data] EntityRelation", async () => {
|
||||
const relation2 = new TestEntityRelation({ required: true });
|
||||
expect(relation2.required).toBe(true);
|
||||
});
|
||||
|
||||
it("correctly produces the relation name", async () => {
|
||||
const relation = new ManyToManyRelation(new Entity("apps"), new Entity("organizations"));
|
||||
expect(relation.getName()).not.toContain(",");
|
||||
expect(relation.getName()).toBe("mn_apps_organizations");
|
||||
|
||||
const relation2 = new ManyToManyRelation(new Entity("apps"), new Entity("organizations"), {
|
||||
connectionTableMappedName: "appOrganizations",
|
||||
});
|
||||
expect(relation2.getName()).toBe("mn_apps_organizations_appOrganizations");
|
||||
});
|
||||
});
|
||||
|
||||
24
app/__test__/debug/jsonv-resolution.test.ts
Normal file
24
app/__test__/debug/jsonv-resolution.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import * as sDirect from "jsonv-ts";
|
||||
import { s as sFromBknd } from "bknd/utils";
|
||||
|
||||
describe("jsonv-ts resolution", () => {
|
||||
it("should resolve to a single instance", () => {
|
||||
const sameNamespace = sDirect === (sFromBknd as unknown as typeof sDirect);
|
||||
// If this fails, two instances are being loaded via different specifiers/paths
|
||||
expect(sameNamespace).toBe(true);
|
||||
});
|
||||
|
||||
it("should resolve specifiers to a single package path", async () => {
|
||||
const base = await import.meta.resolve("jsonv-ts");
|
||||
const hono = await import.meta.resolve("jsonv-ts/hono");
|
||||
const mcp = await import.meta.resolve("jsonv-ts/mcp");
|
||||
expect(typeof base).toBe("string");
|
||||
expect(typeof hono).toBe("string");
|
||||
expect(typeof mcp).toBe("string");
|
||||
// They can be different files (subpath exports), but they should share the same package root
|
||||
const pkgRoot = (p: string) => p.slice(0, p.lastIndexOf("jsonv-ts") + "jsonv-ts".length);
|
||||
expect(pkgRoot(base)).toBe(pkgRoot(hono));
|
||||
expect(pkgRoot(base)).toBe(pkgRoot(mcp));
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { format as sqlFormat } from "sql-formatter";
|
||||
import type { em as protoEm } from "../src/data/prototype";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { slugify } from "core/utils/strings";
|
||||
import { slugify } from "bknd/utils";
|
||||
import { type Connection, SqliteLocalConnection } from "data/connection";
|
||||
import { EntityManager } from "data/entities/EntityManager";
|
||||
|
||||
@@ -39,26 +39,6 @@ export function getLocalLibsqlConnection() {
|
||||
return { url: "http://127.0.0.1:8080" };
|
||||
}
|
||||
|
||||
type ConsoleSeverity = "debug" | "log" | "warn" | "error";
|
||||
const _oldConsoles = {
|
||||
debug: console.debug,
|
||||
log: console.log,
|
||||
warn: console.warn,
|
||||
error: console.error,
|
||||
};
|
||||
|
||||
export function disableConsoleLog(severities: ConsoleSeverity[] = ["debug", "log", "warn"]) {
|
||||
severities.forEach((severity) => {
|
||||
console[severity] = () => null;
|
||||
});
|
||||
}
|
||||
|
||||
export function enableConsoleLog() {
|
||||
Object.entries(_oldConsoles).forEach(([severity, fn]) => {
|
||||
console[severity as ConsoleSeverity] = fn;
|
||||
});
|
||||
}
|
||||
|
||||
export function compileQb(qb: SelectQueryBuilder<any, any, any>) {
|
||||
const { sql, parameters } = qb.compile();
|
||||
return { sql, parameters };
|
||||
@@ -66,7 +46,7 @@ export function compileQb(qb: SelectQueryBuilder<any, any, any>) {
|
||||
|
||||
export function prettyPrintQb(qb: SelectQueryBuilder<any, any, any>) {
|
||||
const { sql, parameters } = qb.compile();
|
||||
console.log("$", sqlFormat(sql), "\n[params]", parameters);
|
||||
console.info("$", sqlFormat(sql), "\n[params]", parameters);
|
||||
}
|
||||
|
||||
export function schemaToEm(s: ReturnType<typeof protoEm>, conn?: Connection): EntityManager<any> {
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test";
|
||||
import { App, createApp } from "../../src";
|
||||
import type { AuthResponse } from "../../src/auth";
|
||||
import { auth } from "../../src/auth/middlewares";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||
import { App, createApp, type AuthResponse } from "../../src";
|
||||
import { auth } from "../../src/modules/middlewares";
|
||||
import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils";
|
||||
import { disableConsoleLog, enableConsoleLog, getDummyConnection } from "../helper";
|
||||
|
||||
const { dummyConnection, afterAllCleanup } = getDummyConnection();
|
||||
afterEach(afterAllCleanup);
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
import { getDummyConnection } from "../helper";
|
||||
|
||||
beforeAll(disableConsoleLog);
|
||||
afterAll(enableConsoleLog);
|
||||
@@ -66,9 +63,10 @@ const configs = {
|
||||
};
|
||||
|
||||
function createAuthApp() {
|
||||
const { dummyConnection } = getDummyConnection();
|
||||
const app = createApp({
|
||||
connection: dummyConnection,
|
||||
initialConfig: {
|
||||
config: {
|
||||
auth: configs.auth,
|
||||
},
|
||||
});
|
||||
@@ -151,8 +149,8 @@ describe("integration auth", () => {
|
||||
|
||||
const { data: users } = await app.em.repository("users").findMany();
|
||||
expect(users.length).toBe(2);
|
||||
expect(users[0].email).toBe(configs.users.normal.email);
|
||||
expect(users[1].email).toBe(configs.users.admin.email);
|
||||
expect(users[0]?.email).toBe(configs.users.normal.email);
|
||||
expect(users[1]?.email).toBe(configs.users.admin.email);
|
||||
});
|
||||
|
||||
it("should log you in with API", async () => {
|
||||
@@ -223,7 +221,7 @@ describe("integration auth", () => {
|
||||
|
||||
app.server.get("/get", auth(), async (c) => {
|
||||
return c.json({
|
||||
user: c.get("auth").user ?? null,
|
||||
user: c.get("auth")?.user ?? null,
|
||||
});
|
||||
});
|
||||
app.server.get("/wait", auth(), async (c) => {
|
||||
@@ -242,7 +240,7 @@ describe("integration auth", () => {
|
||||
{
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const res = await app.server.request("/get");
|
||||
const data = await res.json();
|
||||
const data = (await res.json()) as any;
|
||||
expect(data.user).toBe(null);
|
||||
expect(await $fns.me()).toEqual({ user: null as any });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||
import { createApp } from "core/test/utils";
|
||||
import { Api } from "../../src/Api";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
beforeAll(disableConsoleLog);
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
describe("integration config", () => {
|
||||
it("should create an entity", async () => {
|
||||
|
||||
@@ -6,17 +6,20 @@ import { createApp } from "core/test/utils";
|
||||
import { mergeObject, randomString } from "../../src/core/utils";
|
||||
import type { TAppMediaConfig } from "../../src/media/media-schema";
|
||||
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
|
||||
import { assetsPath, assetsTmpPath, disableConsoleLog, enableConsoleLog } from "../helper";
|
||||
import { assetsPath, assetsTmpPath } from "../helper";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
beforeAll(() => {
|
||||
//disableConsoleLog();
|
||||
registries.media.register("local", StorageLocalAdapter);
|
||||
});
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
const path = `${assetsPath}/image.png`;
|
||||
|
||||
async function makeApp(mediaOverride: Partial<TAppMediaConfig> = {}) {
|
||||
const app = createApp({
|
||||
initialConfig: {
|
||||
config: {
|
||||
media: mergeObject(
|
||||
{
|
||||
enabled: true,
|
||||
@@ -40,9 +43,6 @@ function makeName(ext: string) {
|
||||
return randomString(10) + "." + ext;
|
||||
}
|
||||
|
||||
beforeAll(disableConsoleLog);
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
describe("MediaController", () => {
|
||||
test("accepts direct", async () => {
|
||||
const app = await makeApp();
|
||||
@@ -94,4 +94,38 @@ describe("MediaController", () => {
|
||||
expect(res.status).toBe(413);
|
||||
expect(await Bun.file(assetsTmpPath + "/" + name).exists()).toBe(false);
|
||||
});
|
||||
|
||||
test("audio files", async () => {
|
||||
const app = await makeApp();
|
||||
const file = Bun.file(`${assetsPath}/test.mp3`);
|
||||
const name = makeName("mp3");
|
||||
const res = await app.server.request("/api/media/upload/" + name, {
|
||||
method: "POST",
|
||||
body: file,
|
||||
});
|
||||
const result = (await res.json()) as any;
|
||||
expect(result.data.mime_type).toStartWith("audio/mpeg");
|
||||
expect(result.name).toBe(name);
|
||||
|
||||
const destFile = Bun.file(assetsTmpPath + "/" + name);
|
||||
expect(destFile.exists()).resolves.toBe(true);
|
||||
await destFile.delete();
|
||||
});
|
||||
|
||||
test("text files", async () => {
|
||||
const app = await makeApp();
|
||||
const file = Bun.file(`${assetsPath}/test.txt`);
|
||||
const name = makeName("txt");
|
||||
const res = await app.server.request("/api/media/upload/" + name, {
|
||||
method: "POST",
|
||||
body: file,
|
||||
});
|
||||
const result = (await res.json()) as any;
|
||||
expect(result.data.mime_type).toStartWith("text/plain");
|
||||
expect(result.name).toBe(name);
|
||||
|
||||
const destFile = Bun.file(assetsTmpPath + "/" + name);
|
||||
expect(destFile.exists()).resolves.toBe(true);
|
||||
await destFile.delete();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,6 +71,8 @@ describe("media/mime-types", () => {
|
||||
["application/zip", "zip"],
|
||||
["text/tab-separated-values", "tsv"],
|
||||
["application/zip", "zip"],
|
||||
["application/pdf", "pdf"],
|
||||
["audio/mpeg", "mp3"],
|
||||
] as const;
|
||||
|
||||
for (const [mime, ext] of tests) {
|
||||
@@ -88,6 +90,9 @@ describe("media/mime-types", () => {
|
||||
["image.jpeg", "jpeg"],
|
||||
["-473Wx593H-466453554-black-MODEL.jpg", "jpg"],
|
||||
["-473Wx593H-466453554-black-MODEL.avif", "avif"],
|
||||
["file.pdf", "pdf"],
|
||||
["file.mp3", "mp3"],
|
||||
["robots.txt", "txt"],
|
||||
] as const;
|
||||
|
||||
for (const [filename, ext] of tests) {
|
||||
@@ -102,4 +107,36 @@ describe("media/mime-types", () => {
|
||||
const [, ext] = getRandomizedFilename(file).split(".");
|
||||
expect(ext).toBe("jpg");
|
||||
});
|
||||
|
||||
test("getRandomizedFilename with body", async () => {
|
||||
// should keep "pdf"
|
||||
const [, ext] = getRandomizedFilename(
|
||||
new File([""], "file.pdf", { type: "application/pdf" }),
|
||||
).split(".");
|
||||
expect(ext).toBe("pdf");
|
||||
|
||||
{
|
||||
// no ext, should use "pdf" only for known formats
|
||||
const [, ext] = getRandomizedFilename(
|
||||
new File([""], "file", { type: "application/pdf" }),
|
||||
).split(".");
|
||||
expect(ext).toBe("pdf");
|
||||
}
|
||||
|
||||
{
|
||||
// wrong ext, should keep the wrong one
|
||||
const [, ext] = getRandomizedFilename(
|
||||
new File([""], "file.what", { type: "application/pdf" }),
|
||||
).split(".");
|
||||
expect(ext).toBe("what");
|
||||
}
|
||||
|
||||
{
|
||||
// txt
|
||||
const [, ext] = getRandomizedFilename(
|
||||
new File([""], "file.txt", { type: "text/plain" }),
|
||||
).split(".");
|
||||
expect(ext).toBe("txt");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,11 +3,14 @@ import { createApp } from "core/test/utils";
|
||||
import { AuthController } from "../../src/auth/api/AuthController";
|
||||
import { em, entity, make, text } from "data/prototype";
|
||||
import { AppAuth, type ModuleBuildContext } from "modules";
|
||||
import { disableConsoleLog, enableConsoleLog } from "../helper";
|
||||
import { makeCtx, moduleTestSuite } from "./module-test-suite";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
beforeAll(disableConsoleLog);
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
describe("AppAuth", () => {
|
||||
test.only("...", () => {
|
||||
test.skip("...", () => {
|
||||
const auth = new AppAuth({});
|
||||
console.log(auth.toJSON());
|
||||
console.log(auth.config);
|
||||
@@ -147,7 +150,7 @@ describe("AppAuth", () => {
|
||||
|
||||
test("registers auth middleware for bknd routes only", async () => {
|
||||
const app = createApp({
|
||||
initialConfig: {
|
||||
config: {
|
||||
auth: {
|
||||
enabled: true,
|
||||
jwt: {
|
||||
@@ -177,7 +180,7 @@ describe("AppAuth", () => {
|
||||
|
||||
test("should allow additional user fields", async () => {
|
||||
const app = createApp({
|
||||
initialConfig: {
|
||||
config: {
|
||||
auth: {
|
||||
entity_name: "users",
|
||||
enabled: true,
|
||||
@@ -201,7 +204,7 @@ describe("AppAuth", () => {
|
||||
|
||||
test("ensure user field configs is always correct", async () => {
|
||||
const app = createApp({
|
||||
initialConfig: {
|
||||
config: {
|
||||
auth: {
|
||||
enabled: true,
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@ import { AppMedia } from "../../src/media/AppMedia";
|
||||
import { moduleTestSuite } from "./module-test-suite";
|
||||
|
||||
describe("AppMedia", () => {
|
||||
test.only("...", () => {
|
||||
test.skip("...", () => {
|
||||
const media = new AppMedia();
|
||||
console.log(media.toJSON());
|
||||
});
|
||||
@@ -18,7 +18,7 @@ describe("AppMedia", () => {
|
||||
registries.media.register("local", StorageLocalAdapter);
|
||||
|
||||
const app = createApp({
|
||||
initialConfig: {
|
||||
config: {
|
||||
media: {
|
||||
entity_name: "media",
|
||||
enabled: true,
|
||||
|
||||
76
app/__test__/modules/DbModuleManager.spec.ts
Normal file
76
app/__test__/modules/DbModuleManager.spec.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { it, expect, describe } from "bun:test";
|
||||
import { DbModuleManager } from "modules/db/DbModuleManager";
|
||||
import { getDummyConnection } from "../helper";
|
||||
import { TABLE_NAME } from "modules/db/migrations";
|
||||
|
||||
describe("DbModuleManager", () => {
|
||||
it("should extract secrets", async () => {
|
||||
const { dummyConnection } = getDummyConnection();
|
||||
const m = new DbModuleManager(dummyConnection, {
|
||||
initial: {
|
||||
auth: {
|
||||
enabled: true,
|
||||
jwt: {
|
||||
secret: "test",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await m.build();
|
||||
expect(m.toJSON(true).auth.jwt.secret).toBe("test");
|
||||
await m.save();
|
||||
});
|
||||
|
||||
it("should work with initial secrets", async () => {
|
||||
const { dummyConnection } = getDummyConnection();
|
||||
const db = dummyConnection.kysely;
|
||||
const m = new DbModuleManager(dummyConnection, {
|
||||
initial: {
|
||||
auth: {
|
||||
enabled: true,
|
||||
jwt: {
|
||||
secret: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
"auth.jwt.secret": "test",
|
||||
},
|
||||
});
|
||||
await m.build();
|
||||
expect(m.toJSON(true).auth.jwt.secret).toBe("test");
|
||||
|
||||
const getSecrets = () =>
|
||||
db
|
||||
.selectFrom(TABLE_NAME)
|
||||
.selectAll()
|
||||
.where("type", "=", "secrets")
|
||||
.executeTakeFirst()
|
||||
.then((r) => r?.json);
|
||||
|
||||
expect(await getSecrets()).toEqual({ "auth.jwt.secret": "test" });
|
||||
|
||||
// also after rebuild
|
||||
await m.build();
|
||||
await m.save();
|
||||
expect(await getSecrets()).toEqual({ "auth.jwt.secret": "test" });
|
||||
|
||||
// and ignore if already present
|
||||
const m2 = new DbModuleManager(dummyConnection, {
|
||||
initial: {
|
||||
auth: {
|
||||
enabled: true,
|
||||
jwt: {
|
||||
secret: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
"auth.jwt.secret": "something completely different",
|
||||
},
|
||||
});
|
||||
await m2.build();
|
||||
await m2.save();
|
||||
expect(await getSecrets()).toEqual({ "auth.jwt.secret": "test" });
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,19 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
import { Module } from "modules/Module";
|
||||
import { type ConfigTable, getDefaultConfig, ModuleManager } from "modules/ModuleManager";
|
||||
import { CURRENT_VERSION, TABLE_NAME } from "modules/migrations";
|
||||
import { getDefaultConfig } from "modules/ModuleManager";
|
||||
import { type ConfigTable, DbModuleManager as ModuleManager } from "modules/db/DbModuleManager";
|
||||
|
||||
import { CURRENT_VERSION, TABLE_NAME } from "modules/db/migrations";
|
||||
import { getDummyConnection } from "../helper";
|
||||
import { s, stripMark } from "core/utils/schema";
|
||||
import { Connection } from "data/connection/Connection";
|
||||
import { entity, text } from "data/prototype";
|
||||
|
||||
beforeAll(disableConsoleLog);
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
describe("ModuleManager", async () => {
|
||||
test("s1: no config, no build", async () => {
|
||||
const { dummyConnection } = getDummyConnection();
|
||||
@@ -133,7 +138,7 @@ describe("ModuleManager", async () => {
|
||||
const db = c2.dummyConnection.kysely;
|
||||
|
||||
const mm2 = new ModuleManager(c2.dummyConnection, {
|
||||
initial: { version: version - 1, ...json },
|
||||
initial: { version: version - 1, ...json } as any,
|
||||
});
|
||||
await mm2.syncConfigTable();
|
||||
await db
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { type InitialModuleConfigs, createApp } from "../../../src";
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { App, type InitialModuleConfigs, createApp } from "/";
|
||||
|
||||
import { type Kysely, sql } from "kysely";
|
||||
import { getDummyConnection } from "../../helper";
|
||||
import v7 from "./samples/v7.json";
|
||||
import v8 from "./samples/v8.json";
|
||||
import v8_2 from "./samples/v8-2.json";
|
||||
import v9 from "./samples/v9.json";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
// app expects migratable config to be present in database
|
||||
async function createVersionedApp(config: InitialModuleConfigs | any) {
|
||||
async function createVersionedApp(
|
||||
config: InitialModuleConfigs | any,
|
||||
opts?: { beforeCreateApp?: (db: Kysely<any>) => Promise<void> },
|
||||
) {
|
||||
const { dummyConnection } = getDummyConnection();
|
||||
|
||||
if (!("version" in config)) throw new Error("config must have a version");
|
||||
@@ -34,6 +42,10 @@ async function createVersionedApp(config: InitialModuleConfigs | any) {
|
||||
})
|
||||
.execute();
|
||||
|
||||
if (opts?.beforeCreateApp) {
|
||||
await opts.beforeCreateApp(db);
|
||||
}
|
||||
|
||||
const app = createApp({
|
||||
connection: dummyConnection,
|
||||
});
|
||||
@@ -41,6 +53,19 @@ async function createVersionedApp(config: InitialModuleConfigs | any) {
|
||||
return app;
|
||||
}
|
||||
|
||||
async function getRawConfig(
|
||||
app: App,
|
||||
opts?: { version?: number; types?: ("config" | "diff" | "backup" | "secrets")[] },
|
||||
) {
|
||||
const db = app.em.connection.kysely;
|
||||
return await db
|
||||
.selectFrom("__bknd")
|
||||
.selectAll()
|
||||
.$if(!!opts?.version, (qb) => qb.where("version", "=", opts?.version))
|
||||
.$if((opts?.types?.length ?? 0) > 0, (qb) => qb.where("type", "in", opts?.types))
|
||||
.execute();
|
||||
}
|
||||
|
||||
describe("Migrations", () => {
|
||||
/**
|
||||
* updated auth strategies to have "enabled" prop
|
||||
@@ -78,4 +103,30 @@ describe("Migrations", () => {
|
||||
// @ts-expect-error
|
||||
expect(app.toJSON(true).server.admin).toBeUndefined();
|
||||
});
|
||||
|
||||
test("migration from 9 to 10", async () => {
|
||||
expect(v9.version).toBe(9);
|
||||
|
||||
const app = await createVersionedApp(v9);
|
||||
|
||||
expect(app.version()).toBeGreaterThan(9);
|
||||
// @ts-expect-error
|
||||
expect(app.toJSON(true).media.adapter.config.secret_access_key).toBe(
|
||||
"^^s3.secret_access_key^^",
|
||||
);
|
||||
const [config, secrets] = (await getRawConfig(app, {
|
||||
version: 10,
|
||||
types: ["config", "secrets"],
|
||||
})) as any;
|
||||
|
||||
expect(config.json.auth.jwt.secret).toBe("");
|
||||
expect(config.json.media.adapter.config.access_key).toBe("");
|
||||
expect(config.json.media.adapter.config.secret_access_key).toBe("");
|
||||
|
||||
expect(secrets.json["auth.jwt.secret"]).toBe("^^jwt.secret^^");
|
||||
expect(secrets.json["media.adapter.config.access_key"]).toBe("^^s3.access_key^^");
|
||||
expect(secrets.json["media.adapter.config.secret_access_key"]).toBe(
|
||||
"^^s3.secret_access_key^^",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
612
app/__test__/modules/migrations/samples/v9.json
Normal file
612
app/__test__/modules/migrations/samples/v9.json
Normal file
@@ -0,0 +1,612 @@
|
||||
{
|
||||
"version": 9,
|
||||
"server": {
|
||||
"cors": {
|
||||
"origin": "*",
|
||||
"allow_methods": ["GET", "POST", "PATCH", "PUT", "DELETE"],
|
||||
"allow_headers": [
|
||||
"Content-Type",
|
||||
"Content-Length",
|
||||
"Authorization",
|
||||
"Accept"
|
||||
],
|
||||
"allow_credentials": true
|
||||
},
|
||||
"mcp": { "enabled": false, "path": "/api/system/mcp" }
|
||||
},
|
||||
"data": {
|
||||
"basepath": "/api/data",
|
||||
"default_primary_format": "integer",
|
||||
"entities": {
|
||||
"media": {
|
||||
"type": "system",
|
||||
"fields": {
|
||||
"id": {
|
||||
"type": "primary",
|
||||
"config": {
|
||||
"format": "integer",
|
||||
"fillable": false,
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"path": { "type": "text", "config": { "required": true } },
|
||||
"folder": {
|
||||
"type": "boolean",
|
||||
"config": {
|
||||
"default_value": false,
|
||||
"hidden": true,
|
||||
"fillable": ["create"],
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"mime_type": { "type": "text", "config": { "required": false } },
|
||||
"size": { "type": "number", "config": { "required": false } },
|
||||
"scope": {
|
||||
"type": "text",
|
||||
"config": {
|
||||
"hidden": true,
|
||||
"fillable": ["create"],
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"etag": { "type": "text", "config": { "required": false } },
|
||||
"modified_at": {
|
||||
"type": "date",
|
||||
"config": { "type": "datetime", "required": false }
|
||||
},
|
||||
"reference": { "type": "text", "config": { "required": false } },
|
||||
"entity_id": { "type": "number", "config": { "required": false } },
|
||||
"metadata": { "type": "json", "config": { "required": false } }
|
||||
},
|
||||
"config": { "sort_field": "id", "sort_dir": "asc" }
|
||||
},
|
||||
"users": {
|
||||
"type": "system",
|
||||
"fields": {
|
||||
"id": {
|
||||
"type": "primary",
|
||||
"config": {
|
||||
"format": "integer",
|
||||
"fillable": false,
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"email": { "type": "text", "config": { "required": true } },
|
||||
"strategy": {
|
||||
"type": "enum",
|
||||
"config": {
|
||||
"options": { "type": "strings", "values": ["password"] },
|
||||
"required": true,
|
||||
"hidden": ["update", "form"],
|
||||
"fillable": ["create"]
|
||||
}
|
||||
},
|
||||
"strategy_value": {
|
||||
"type": "text",
|
||||
"config": {
|
||||
"fillable": ["create"],
|
||||
"hidden": ["read", "table", "update", "form"],
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"role": {
|
||||
"type": "enum",
|
||||
"config": {
|
||||
"options": { "type": "strings", "values": ["admin", "guest"] },
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"age": {
|
||||
"type": "enum",
|
||||
"config": {
|
||||
"options": {
|
||||
"type": "strings",
|
||||
"values": ["18-24", "25-34", "35-44", "45-64", "65+"]
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"height": { "type": "number", "config": { "required": false } },
|
||||
"gender": {
|
||||
"type": "enum",
|
||||
"config": {
|
||||
"options": { "type": "strings", "values": ["male", "female"] },
|
||||
"required": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"config": { "sort_field": "id", "sort_dir": "asc" }
|
||||
},
|
||||
"avatars": {
|
||||
"type": "regular",
|
||||
"fields": {
|
||||
"id": {
|
||||
"type": "primary",
|
||||
"config": {
|
||||
"format": "integer",
|
||||
"fillable": false,
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"identifier": { "type": "text", "config": { "required": false } },
|
||||
"payload": {
|
||||
"type": "json",
|
||||
"config": { "required": false, "hidden": ["table"] }
|
||||
},
|
||||
"created_at": {
|
||||
"type": "date",
|
||||
"config": { "type": "datetime", "required": false }
|
||||
},
|
||||
"started_at": {
|
||||
"type": "date",
|
||||
"config": { "type": "datetime", "required": false }
|
||||
},
|
||||
"completed_at": {
|
||||
"type": "date",
|
||||
"config": { "type": "datetime", "required": false }
|
||||
},
|
||||
"input": {
|
||||
"type": "media",
|
||||
"config": {
|
||||
"required": false,
|
||||
"fillable": ["update"],
|
||||
"hidden": false,
|
||||
"mime_types": [],
|
||||
"virtual": true,
|
||||
"entity": "avatars"
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"type": "media",
|
||||
"config": {
|
||||
"required": false,
|
||||
"fillable": ["update"],
|
||||
"hidden": false,
|
||||
"mime_types": [],
|
||||
"virtual": true,
|
||||
"entity": "avatars"
|
||||
}
|
||||
},
|
||||
"users_id": {
|
||||
"type": "relation",
|
||||
"config": {
|
||||
"label": "Users",
|
||||
"required": false,
|
||||
"reference": "users",
|
||||
"target": "users",
|
||||
"target_field": "id",
|
||||
"target_field_type": "integer",
|
||||
"on_delete": "set null"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config": { "sort_field": "id", "sort_dir": "desc" }
|
||||
},
|
||||
"tryons": {
|
||||
"type": "regular",
|
||||
"fields": {
|
||||
"id": {
|
||||
"type": "primary",
|
||||
"config": {
|
||||
"format": "integer",
|
||||
"fillable": false,
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"created_at": {
|
||||
"type": "date",
|
||||
"config": { "type": "datetime", "required": false }
|
||||
},
|
||||
"completed_at": {
|
||||
"type": "date",
|
||||
"config": { "type": "datetime", "required": false }
|
||||
},
|
||||
"avatars_id": {
|
||||
"type": "relation",
|
||||
"config": {
|
||||
"label": "Avatars",
|
||||
"required": false,
|
||||
"reference": "avatars",
|
||||
"target": "avatars",
|
||||
"target_field": "id",
|
||||
"target_field_type": "integer",
|
||||
"on_delete": "set null"
|
||||
}
|
||||
},
|
||||
"users_id": {
|
||||
"type": "relation",
|
||||
"config": {
|
||||
"label": "Users",
|
||||
"required": false,
|
||||
"reference": "users",
|
||||
"target": "users",
|
||||
"target_field": "id",
|
||||
"target_field_type": "integer",
|
||||
"on_delete": "set null"
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"type": "media",
|
||||
"config": {
|
||||
"required": false,
|
||||
"fillable": ["update"],
|
||||
"hidden": false,
|
||||
"mime_types": [],
|
||||
"virtual": true,
|
||||
"entity": "tryons",
|
||||
"max_items": 1
|
||||
}
|
||||
},
|
||||
"products_id": {
|
||||
"type": "relation",
|
||||
"config": {
|
||||
"label": "Products",
|
||||
"required": false,
|
||||
"reference": "products",
|
||||
"target": "products",
|
||||
"target_field": "id",
|
||||
"target_field_type": "integer",
|
||||
"on_delete": "set null"
|
||||
}
|
||||
},
|
||||
"payload": {
|
||||
"type": "json",
|
||||
"config": { "required": false, "hidden": ["table"] }
|
||||
}
|
||||
},
|
||||
"config": { "sort_field": "id", "sort_dir": "desc" }
|
||||
},
|
||||
"products": {
|
||||
"type": "regular",
|
||||
"fields": {
|
||||
"id": {
|
||||
"type": "primary",
|
||||
"config": {
|
||||
"format": "integer",
|
||||
"fillable": false,
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"enabled": { "type": "boolean", "config": { "required": false } },
|
||||
"title": { "type": "text", "config": { "required": false } },
|
||||
"url": { "type": "text", "config": { "required": false } },
|
||||
"image": {
|
||||
"type": "media",
|
||||
"config": {
|
||||
"required": false,
|
||||
"fillable": ["update"],
|
||||
"hidden": false,
|
||||
"mime_types": [],
|
||||
"virtual": true,
|
||||
"entity": "products",
|
||||
"max_items": 1
|
||||
}
|
||||
},
|
||||
"created_at": {
|
||||
"type": "date",
|
||||
"config": { "type": "datetime", "required": false }
|
||||
},
|
||||
"sites_id": {
|
||||
"type": "relation",
|
||||
"config": {
|
||||
"label": "Sites",
|
||||
"required": false,
|
||||
"reference": "sites",
|
||||
"target": "sites",
|
||||
"target_field": "id",
|
||||
"target_field_type": "integer",
|
||||
"on_delete": "set null"
|
||||
}
|
||||
},
|
||||
"garment_type": {
|
||||
"type": "enum",
|
||||
"config": {
|
||||
"options": {
|
||||
"type": "strings",
|
||||
"values": ["auto", "tops", "bottoms", "one-pieces"]
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"config": { "sort_field": "id", "sort_dir": "desc" }
|
||||
},
|
||||
"sites": {
|
||||
"type": "regular",
|
||||
"fields": {
|
||||
"id": {
|
||||
"type": "primary",
|
||||
"config": {
|
||||
"format": "integer",
|
||||
"fillable": false,
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"origin": {
|
||||
"type": "text",
|
||||
"config": {
|
||||
"pattern": "^(https?):\\/\\/([a-zA-Z0-9.-]+)(:\\d+)?$",
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"name": { "type": "text", "config": { "required": false } },
|
||||
"active": { "type": "boolean", "config": { "required": false } },
|
||||
"logo": {
|
||||
"type": "media",
|
||||
"config": {
|
||||
"required": false,
|
||||
"fillable": ["update"],
|
||||
"hidden": false,
|
||||
"mime_types": [],
|
||||
"virtual": true,
|
||||
"entity": "sites",
|
||||
"max_items": 1
|
||||
}
|
||||
},
|
||||
"instructions": {
|
||||
"type": "text",
|
||||
"config": {
|
||||
"html_config": {
|
||||
"element": "textarea",
|
||||
"props": { "rows": "2" }
|
||||
},
|
||||
"required": false,
|
||||
"hidden": ["table"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"config": { "sort_field": "id", "sort_dir": "desc" }
|
||||
},
|
||||
"sessions": {
|
||||
"type": "regular",
|
||||
"fields": {
|
||||
"id": {
|
||||
"type": "primary",
|
||||
"config": { "format": "uuid", "fillable": false, "required": false }
|
||||
},
|
||||
"created_at": {
|
||||
"type": "date",
|
||||
"config": { "type": "datetime", "required": true }
|
||||
},
|
||||
"claimed_at": {
|
||||
"type": "date",
|
||||
"config": { "type": "datetime", "required": false }
|
||||
},
|
||||
"url": { "type": "text", "config": { "required": false } },
|
||||
"sites_id": {
|
||||
"type": "relation",
|
||||
"config": {
|
||||
"label": "Sites",
|
||||
"required": false,
|
||||
"reference": "sites",
|
||||
"target": "sites",
|
||||
"target_field": "id",
|
||||
"target_field_type": "integer",
|
||||
"on_delete": "set null"
|
||||
}
|
||||
},
|
||||
"users_id": {
|
||||
"type": "relation",
|
||||
"config": {
|
||||
"label": "Users",
|
||||
"required": false,
|
||||
"reference": "users",
|
||||
"target": "users",
|
||||
"target_field": "id",
|
||||
"target_field_type": "integer",
|
||||
"on_delete": "set null"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config": { "sort_field": "id", "sort_dir": "desc" }
|
||||
}
|
||||
},
|
||||
"relations": {
|
||||
"poly_avatars_media_input": {
|
||||
"type": "poly",
|
||||
"source": "avatars",
|
||||
"target": "media",
|
||||
"config": { "mappedBy": "input" }
|
||||
},
|
||||
"poly_avatars_media_output": {
|
||||
"type": "poly",
|
||||
"source": "avatars",
|
||||
"target": "media",
|
||||
"config": { "mappedBy": "output" }
|
||||
},
|
||||
"n1_avatars_users": {
|
||||
"type": "n:1",
|
||||
"source": "avatars",
|
||||
"target": "users",
|
||||
"config": {
|
||||
"mappedBy": "",
|
||||
"inversedBy": "",
|
||||
"required": false,
|
||||
"with_limit": 5
|
||||
}
|
||||
},
|
||||
"n1_tryons_avatars": {
|
||||
"type": "n:1",
|
||||
"source": "tryons",
|
||||
"target": "avatars",
|
||||
"config": {
|
||||
"mappedBy": "",
|
||||
"inversedBy": "",
|
||||
"required": false,
|
||||
"with_limit": 5
|
||||
}
|
||||
},
|
||||
"n1_tryons_users": {
|
||||
"type": "n:1",
|
||||
"source": "tryons",
|
||||
"target": "users",
|
||||
"config": {
|
||||
"mappedBy": "",
|
||||
"inversedBy": "",
|
||||
"required": false,
|
||||
"with_limit": 5
|
||||
}
|
||||
},
|
||||
"poly_tryons_media_output": {
|
||||
"type": "poly",
|
||||
"source": "tryons",
|
||||
"target": "media",
|
||||
"config": { "mappedBy": "output", "targetCardinality": 1 }
|
||||
},
|
||||
"poly_products_media_image": {
|
||||
"type": "poly",
|
||||
"source": "products",
|
||||
"target": "media",
|
||||
"config": { "mappedBy": "image", "targetCardinality": 1 }
|
||||
},
|
||||
"n1_tryons_products": {
|
||||
"type": "n:1",
|
||||
"source": "tryons",
|
||||
"target": "products",
|
||||
"config": {
|
||||
"mappedBy": "",
|
||||
"inversedBy": "",
|
||||
"required": false,
|
||||
"with_limit": 5
|
||||
}
|
||||
},
|
||||
"poly_sites_media_logo": {
|
||||
"type": "poly",
|
||||
"source": "sites",
|
||||
"target": "media",
|
||||
"config": { "mappedBy": "logo", "targetCardinality": 1 }
|
||||
},
|
||||
"n1_sessions_sites": {
|
||||
"type": "n:1",
|
||||
"source": "sessions",
|
||||
"target": "sites",
|
||||
"config": {
|
||||
"mappedBy": "",
|
||||
"inversedBy": "",
|
||||
"required": false,
|
||||
"with_limit": 5
|
||||
}
|
||||
},
|
||||
"n1_sessions_users": {
|
||||
"type": "n:1",
|
||||
"source": "sessions",
|
||||
"target": "users",
|
||||
"config": {
|
||||
"mappedBy": "",
|
||||
"inversedBy": "",
|
||||
"required": false,
|
||||
"with_limit": 5
|
||||
}
|
||||
},
|
||||
"n1_products_sites": {
|
||||
"type": "n:1",
|
||||
"source": "products",
|
||||
"target": "sites",
|
||||
"config": {
|
||||
"mappedBy": "",
|
||||
"inversedBy": "",
|
||||
"required": false,
|
||||
"with_limit": 5
|
||||
}
|
||||
}
|
||||
},
|
||||
"indices": {
|
||||
"idx_unique_media_path": {
|
||||
"entity": "media",
|
||||
"fields": ["path"],
|
||||
"unique": true
|
||||
},
|
||||
"idx_media_reference": {
|
||||
"entity": "media",
|
||||
"fields": ["reference"],
|
||||
"unique": false
|
||||
},
|
||||
"idx_media_entity_id": {
|
||||
"entity": "media",
|
||||
"fields": ["entity_id"],
|
||||
"unique": false
|
||||
},
|
||||
"idx_unique_users_email": {
|
||||
"entity": "users",
|
||||
"fields": ["email"],
|
||||
"unique": true
|
||||
},
|
||||
"idx_users_strategy": {
|
||||
"entity": "users",
|
||||
"fields": ["strategy"],
|
||||
"unique": false
|
||||
},
|
||||
"idx_users_strategy_value": {
|
||||
"entity": "users",
|
||||
"fields": ["strategy_value"],
|
||||
"unique": false
|
||||
},
|
||||
"idx_sites_origin_active": {
|
||||
"entity": "sites",
|
||||
"fields": ["origin", "active"],
|
||||
"unique": false
|
||||
},
|
||||
"idx_sites_active": {
|
||||
"entity": "sites",
|
||||
"fields": ["active"],
|
||||
"unique": false
|
||||
},
|
||||
"idx_products_url": {
|
||||
"entity": "products",
|
||||
"fields": ["url"],
|
||||
"unique": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"enabled": true,
|
||||
"basepath": "/api/auth",
|
||||
"entity_name": "users",
|
||||
"allow_register": true,
|
||||
"jwt": {
|
||||
"secret": "^^jwt.secret^^",
|
||||
"alg": "HS256",
|
||||
"expires": 999999999,
|
||||
"issuer": "issuer",
|
||||
"fields": ["id", "email", "role"]
|
||||
},
|
||||
"cookie": {
|
||||
"path": "/",
|
||||
"sameSite": "none",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"expires": 604800,
|
||||
"partitioned": false,
|
||||
"renew": true,
|
||||
"pathSuccess": "/admin",
|
||||
"pathLoggedOut": "/"
|
||||
},
|
||||
"strategies": {
|
||||
"password": {
|
||||
"enabled": true,
|
||||
"type": "password",
|
||||
"config": { "hashing": "sha256" }
|
||||
}
|
||||
},
|
||||
"guard": { "enabled": false },
|
||||
"roles": {
|
||||
"admin": { "implicit_allow": true },
|
||||
"guest": { "is_default": true }
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"enabled": true,
|
||||
"basepath": "/api/media",
|
||||
"entity_name": "media",
|
||||
"storage": { "body_max_size": 0 },
|
||||
"adapter": {
|
||||
"type": "s3",
|
||||
"config": {
|
||||
"access_key": "^^s3.access_key^^",
|
||||
"secret_access_key": "^^s3.secret_access_key^^",
|
||||
"url": "https://1234.r2.cloudflarestorage.com/bucket-name"
|
||||
}
|
||||
}
|
||||
},
|
||||
"flows": { "basepath": "/api/flows", "flows": {} }
|
||||
}
|
||||
@@ -2,12 +2,12 @@ import { beforeEach, describe, expect, it } from "bun:test";
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { Guard } from "auth/authorize/Guard";
|
||||
import { DebugLogger } from "core/utils/DebugLogger";
|
||||
import { EventManager } from "core/events";
|
||||
import { EntityManager } from "data/entities/EntityManager";
|
||||
import { Module, type ModuleBuildContext } from "modules/Module";
|
||||
import { getDummyConnection } from "../helper";
|
||||
import { ModuleHelper } from "modules/ModuleHelper";
|
||||
import { DebugLogger, McpServer } from "bknd/utils";
|
||||
|
||||
export function makeCtx(overrides?: Partial<ModuleBuildContext>): ModuleBuildContext {
|
||||
const { dummyConnection } = getDummyConnection();
|
||||
@@ -19,6 +19,7 @@ export function makeCtx(overrides?: Partial<ModuleBuildContext>): ModuleBuildCon
|
||||
guard: new Guard(),
|
||||
flags: Module.ctx_flags,
|
||||
logger: new DebugLogger(false),
|
||||
mcp: new McpServer(),
|
||||
...overrides,
|
||||
};
|
||||
return {
|
||||
|
||||
@@ -102,7 +102,9 @@ describe("json form", () => {
|
||||
] satisfies [string, Exclude<JSONSchema, boolean>, boolean][];
|
||||
|
||||
for (const [pointer, schema, output] of examples) {
|
||||
expect(utils.isRequired(new Draft2019(schema), pointer, schema)).toBe(output);
|
||||
expect(utils.isRequired(new Draft2019(schema), pointer, schema), `${pointer} `).toBe(
|
||||
output,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,29 @@
|
||||
import pkg from "./package.json" with { type: "json" };
|
||||
import c from "picocolors";
|
||||
import { formatNumber } from "core/utils";
|
||||
import { formatNumber } from "bknd/utils";
|
||||
import * as esbuild from "esbuild";
|
||||
|
||||
const deps = Object.keys(pkg.dependencies);
|
||||
const external = ["jsonv-ts/*", "wrangler", "bknd", "bknd/*", ...deps];
|
||||
|
||||
if (process.env.DEBUG) {
|
||||
const result = await esbuild.build({
|
||||
entryPoints: ["./src/cli/index.ts"],
|
||||
outdir: "./dist/cli",
|
||||
platform: "node",
|
||||
minify: true,
|
||||
format: "esm",
|
||||
metafile: true,
|
||||
bundle: true,
|
||||
external,
|
||||
define: {
|
||||
__isDev: "0",
|
||||
__version: JSON.stringify(pkg.version),
|
||||
},
|
||||
});
|
||||
await Bun.write("./dist/cli/metafile-esm.json", JSON.stringify(result.metafile, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const result = await Bun.build({
|
||||
entrypoints: ["./src/cli/index.ts"],
|
||||
@@ -8,6 +31,7 @@ const result = await Bun.build({
|
||||
outdir: "./dist/cli",
|
||||
env: "PUBLIC_*",
|
||||
minify: true,
|
||||
external,
|
||||
define: {
|
||||
__isDev: "0",
|
||||
__version: JSON.stringify(pkg.version),
|
||||
|
||||
33
app/build.ts
33
app/build.ts
@@ -61,14 +61,19 @@ function delayTypes() {
|
||||
watcher_timeout = setTimeout(buildTypes, 1000);
|
||||
}
|
||||
|
||||
const dependencies = Object.keys(pkg.dependencies);
|
||||
|
||||
// collection of always-external packages
|
||||
const external = [
|
||||
...dependencies,
|
||||
"bun:test",
|
||||
"node:test",
|
||||
"node:assert/strict",
|
||||
"@libsql/client",
|
||||
"bknd",
|
||||
/^bknd\/.*/,
|
||||
"jsonv-ts",
|
||||
/^jsonv-ts\/.*/,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
@@ -80,14 +85,19 @@ async function buildApi() {
|
||||
sourcemap,
|
||||
watch,
|
||||
define,
|
||||
entry: ["src/index.ts", "src/core/utils/index.ts", "src/plugins/index.ts"],
|
||||
entry: [
|
||||
"src/index.ts",
|
||||
"src/core/utils/index.ts",
|
||||
"src/plugins/index.ts",
|
||||
"src/modes/index.ts",
|
||||
],
|
||||
outDir: "dist",
|
||||
external: [...external],
|
||||
metafile: true,
|
||||
target: "esnext",
|
||||
platform: "browser",
|
||||
format: ["esm"],
|
||||
splitting: false,
|
||||
treeshake: true,
|
||||
loader: {
|
||||
".svg": "dataurl",
|
||||
},
|
||||
@@ -243,8 +253,12 @@ async function buildAdapters() {
|
||||
// base adapter handles
|
||||
tsup.build({
|
||||
...baseConfig(""),
|
||||
target: "esnext",
|
||||
platform: "neutral",
|
||||
entry: ["src/adapter/index.ts"],
|
||||
outDir: "dist/adapter",
|
||||
// only way to keep @vite-ignore comments
|
||||
minify: false,
|
||||
}),
|
||||
|
||||
// specific adatpers
|
||||
@@ -256,7 +270,20 @@ async function buildAdapters() {
|
||||
),
|
||||
tsup.build(baseConfig("astro")),
|
||||
tsup.build(baseConfig("aws")),
|
||||
tsup.build(baseConfig("cloudflare")),
|
||||
tsup.build(
|
||||
baseConfig("cloudflare", {
|
||||
external: ["wrangler", "node:process"],
|
||||
}),
|
||||
),
|
||||
tsup.build(
|
||||
baseConfig("cloudflare/proxy", {
|
||||
target: "esnext",
|
||||
entry: ["src/adapter/cloudflare/proxy.ts"],
|
||||
outDir: "dist/adapter/cloudflare",
|
||||
metafile: false,
|
||||
external: [/bknd/, "wrangler", "node:process"],
|
||||
}),
|
||||
),
|
||||
|
||||
tsup.build({
|
||||
...baseConfig("vite"),
|
||||
|
||||
@@ -3,3 +3,4 @@
|
||||
|
||||
[test]
|
||||
coverageSkipTestFiles = true
|
||||
console.depth = 10
|
||||
@@ -17,7 +17,7 @@ async function run(
|
||||
});
|
||||
|
||||
// Read from stdout
|
||||
const reader = proc.stdout.getReader();
|
||||
const reader = (proc.stdout as ReadableStream).getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
// Function to read chunks
|
||||
@@ -30,7 +30,7 @@ async function run(
|
||||
|
||||
const text = decoder.decode(value);
|
||||
if (!resolveCalled) {
|
||||
console.log(c.dim(text.replace(/\n$/, "")));
|
||||
console.info(c.dim(text.replace(/\n$/, "")));
|
||||
}
|
||||
onChunk(
|
||||
text,
|
||||
@@ -189,21 +189,21 @@ const adapters = {
|
||||
|
||||
async function testAdapter(name: keyof typeof adapters) {
|
||||
const config = adapters[name];
|
||||
console.log("adapter", c.cyan(name));
|
||||
console.info("adapter", c.cyan(name));
|
||||
await config.clean();
|
||||
|
||||
const { proc, data } = await config.start();
|
||||
console.log("proc:", proc.pid, "data:", c.cyan(data));
|
||||
console.info("proc:", proc.pid, "data:", c.cyan(data));
|
||||
//proc.kill();process.exit(0);
|
||||
|
||||
const add_env = "env" in config && config.env ? config.env : "";
|
||||
await $`TEST_URL=${data} TEST_ADAPTER=${name} ${add_env} bun run test:e2e`;
|
||||
console.log("DONE!");
|
||||
console.info("DONE!");
|
||||
|
||||
while (!proc.killed) {
|
||||
proc.kill("SIGINT");
|
||||
await Bun.sleep(250);
|
||||
console.log("Waiting for process to exit...");
|
||||
console.info("Waiting for process to exit...");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
39
app/internal/docs.build-assets.ts
Normal file
39
app/internal/docs.build-assets.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { createApp } from "bknd/adapter/bun";
|
||||
|
||||
async function generate() {
|
||||
console.info("Generating MCP documentation...");
|
||||
const app = await createApp({
|
||||
connection: {
|
||||
url: ":memory:",
|
||||
},
|
||||
config: {
|
||||
server: {
|
||||
mcp: {
|
||||
enabled: true,
|
||||
path: "/mcp2",
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
enabled: true,
|
||||
},
|
||||
media: {
|
||||
enabled: true,
|
||||
adapter: {
|
||||
type: "local",
|
||||
config: {
|
||||
path: "./",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
await app.getMcpClient().ping();
|
||||
|
||||
const { tools, resources } = app.mcp!.toJSON();
|
||||
await Bun.write("../docs/mcp.json", JSON.stringify({ tools, resources }, null, 2));
|
||||
|
||||
console.info("MCP documentation generated.");
|
||||
}
|
||||
|
||||
void generate();
|
||||
@@ -3,7 +3,7 @@
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"bin": "./dist/cli/index.js",
|
||||
"version": "0.16.0",
|
||||
"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.",
|
||||
"homepage": "https://bknd.io",
|
||||
"repository": {
|
||||
@@ -13,9 +13,9 @@
|
||||
"bugs": {
|
||||
"url": "https://github.com/bknd-io/bknd/issues"
|
||||
},
|
||||
"packageManager": "bun@1.2.19",
|
||||
"packageManager": "bun@1.2.22",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": ">=22.13"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "BKND_CLI_LOG_LEVEL=debug vite",
|
||||
@@ -30,7 +30,7 @@
|
||||
"build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly && tsc-alias",
|
||||
"updater": "bun x npm-check-updates -ui",
|
||||
"cli": "LOCAL=1 bun src/cli/index.ts",
|
||||
"prepublishOnly": "bun run types && bun run test && bun run test:node && bun run test:e2e && bun run build:all && cp ../README.md ./",
|
||||
"prepublishOnly": "bun run types && bun run test && bun run test:node && NODE_NO_WARNINGS=1 VITE_DB_URL=:memory: bun run test:e2e && bun run build:all && cp ../README.md ./",
|
||||
"postpublish": "rm -f README.md",
|
||||
"test": "ALL_TESTS=1 bun test --bail",
|
||||
"test:all": "bun run test && bun run test:node",
|
||||
@@ -40,10 +40,11 @@
|
||||
"test:coverage": "ALL_TESTS=1 bun test --bail --coverage",
|
||||
"test:vitest:coverage": "vitest run --coverage",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:adapters": "bun run e2e/adapters.ts",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:debug": "playwright test --debug",
|
||||
"test:e2e:report": "playwright show-report"
|
||||
"test:e2e:adapters": "NODE_NO_WARNINGS=1 bun run e2e/adapters.ts",
|
||||
"test:e2e:ui": "VITE_DB_URL=:memory: playwright test --ui",
|
||||
"test:e2e:debug": "VITE_DB_URL=:memory: playwright test --debug",
|
||||
"test:e2e:report": "VITE_DB_URL=:memory: playwright show-report",
|
||||
"docs:build-assets": "bun internal/docs.build-assets.ts"
|
||||
},
|
||||
"license": "FSL-1.1-MIT",
|
||||
"dependencies": {
|
||||
@@ -65,20 +66,23 @@
|
||||
"hono": "4.8.3",
|
||||
"json-schema-library": "10.0.0-rc7",
|
||||
"json-schema-to-ts": "^3.1.1",
|
||||
"kysely": "^0.27.6",
|
||||
"jsonv-ts": "0.9.1",
|
||||
"kysely": "0.27.6",
|
||||
"lodash-es": "^4.17.21",
|
||||
"oauth4webapi": "^2.11.1",
|
||||
"object-path-immutable": "^4.1.2",
|
||||
"radix-ui": "^1.1.3",
|
||||
"picocolors": "^1.1.1",
|
||||
"swr": "^2.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@aws-sdk/client-s3": "^3.758.0",
|
||||
"@bluwy/giget-core": "^0.1.2",
|
||||
"@cloudflare/vitest-pool-workers": "^0.8.38",
|
||||
"@clack/prompts": "^0.11.0",
|
||||
"@cloudflare/vitest-pool-workers": "^0.9.3",
|
||||
"@cloudflare/workers-types": "^4.20250606.0",
|
||||
"@dagrejs/dagre": "^1.1.4",
|
||||
"@hono/vite-dev-server": "^0.19.1",
|
||||
"@hono/vite-dev-server": "^0.21.0",
|
||||
"@hookform/resolvers": "^4.1.3",
|
||||
"@libsql/client": "^0.15.9",
|
||||
"@mantine/modals": "^7.17.1",
|
||||
@@ -101,13 +105,11 @@
|
||||
"dotenv": "^16.4.7",
|
||||
"jotai": "^2.12.2",
|
||||
"jsdom": "^26.0.0",
|
||||
"jsonv-ts": "^0.3.2",
|
||||
"kysely-d1": "^0.3.0",
|
||||
"kysely-generic-sqlite": "^1.2.1",
|
||||
"libsql-stateless-easy": "^1.8.0",
|
||||
"open": "^10.1.0",
|
||||
"openapi-types": "^12.1.3",
|
||||
"picocolors": "^1.1.1",
|
||||
"postcss": "^8.5.3",
|
||||
"postcss-preset-mantine": "^1.17.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
@@ -129,7 +131,9 @@
|
||||
"vite-plugin-circular-dependency": "^0.5.0",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^3.0.9",
|
||||
"wouter": "^3.6.0"
|
||||
"wouter": "^3.6.0",
|
||||
"wrangler": "^4.37.1",
|
||||
"miniflare": "^4.20250913.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@hono/node-server": "^1.14.3"
|
||||
@@ -177,6 +181,11 @@
|
||||
"import": "./dist/plugins/index.js",
|
||||
"require": "./dist/plugins/index.js"
|
||||
},
|
||||
"./modes": {
|
||||
"types": "./dist/types/modes/index.d.ts",
|
||||
"import": "./dist/modes/index.js",
|
||||
"require": "./dist/modes/index.js"
|
||||
},
|
||||
"./adapter/sqlite": {
|
||||
"types": "./dist/types/adapter/sqlite/edge.d.ts",
|
||||
"import": {
|
||||
@@ -196,6 +205,11 @@
|
||||
"import": "./dist/adapter/cloudflare/index.js",
|
||||
"require": "./dist/adapter/cloudflare/index.js"
|
||||
},
|
||||
"./adapter/cloudflare/proxy": {
|
||||
"types": "./dist/types/adapter/cloudflare/proxy.d.ts",
|
||||
"import": "./dist/adapter/cloudflare/proxy.js",
|
||||
"require": "./dist/adapter/cloudflare/proxy.js"
|
||||
},
|
||||
"./adapter": {
|
||||
"types": "./dist/types/adapter/index.d.ts",
|
||||
"import": "./dist/adapter/index.js"
|
||||
|
||||
@@ -4,7 +4,7 @@ import { DataApi, type DataApiOptions } from "data/api/DataApi";
|
||||
import { decode } from "hono/jwt";
|
||||
import { MediaApi, type MediaApiOptions } from "media/api/MediaApi";
|
||||
import { SystemApi } from "modules/SystemApi";
|
||||
import { omitKeys } from "core/utils";
|
||||
import { omitKeys } from "bknd/utils";
|
||||
import type { BaseModuleApiOptions } from "modules";
|
||||
|
||||
export type TApiUser = SafeUser;
|
||||
@@ -40,10 +40,11 @@ export type ApiOptions = {
|
||||
data?: SubApiOptions<DataApiOptions>;
|
||||
auth?: SubApiOptions<AuthApiOptions>;
|
||||
media?: SubApiOptions<MediaApiOptions>;
|
||||
credentials?: RequestCredentials;
|
||||
} & (
|
||||
| {
|
||||
token?: string;
|
||||
user?: TApiUser;
|
||||
user?: TApiUser | null;
|
||||
}
|
||||
| {
|
||||
request: Request;
|
||||
@@ -67,7 +68,7 @@ export class Api {
|
||||
public auth!: AuthApi;
|
||||
public media!: MediaApi;
|
||||
|
||||
constructor(private options: ApiOptions = {}) {
|
||||
constructor(public options: ApiOptions = {}) {
|
||||
// only mark verified if forced
|
||||
this.verified = options.verified === true;
|
||||
|
||||
@@ -129,29 +130,45 @@ export class Api {
|
||||
} else if (this.storage) {
|
||||
this.storage.getItem(this.tokenKey).then((token) => {
|
||||
this.token_transport = "header";
|
||||
this.updateToken(token ? String(token) : undefined);
|
||||
this.updateToken(token ? String(token) : undefined, {
|
||||
verified: true,
|
||||
trigger: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make storage async to allow async storages even if sync given
|
||||
* @private
|
||||
*/
|
||||
private get storage() {
|
||||
if (!this.options.storage) return null;
|
||||
return {
|
||||
getItem: async (key: string) => {
|
||||
return await this.options.storage!.getItem(key);
|
||||
const storage = this.options.storage;
|
||||
return new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_, prop) {
|
||||
return (...args: any[]) => {
|
||||
const response = storage ? storage[prop](...args) : undefined;
|
||||
if (response instanceof Promise) {
|
||||
return response;
|
||||
}
|
||||
return {
|
||||
// biome-ignore lint/suspicious/noThenProperty: it's a promise :)
|
||||
then: (fn) => fn(response),
|
||||
};
|
||||
};
|
||||
},
|
||||
},
|
||||
setItem: async (key: string, value: string) => {
|
||||
return await this.options.storage!.setItem(key, value);
|
||||
},
|
||||
removeItem: async (key: string) => {
|
||||
return await this.options.storage!.removeItem(key);
|
||||
},
|
||||
};
|
||||
) as any;
|
||||
}
|
||||
|
||||
updateToken(token?: string, opts?: { rebuild?: boolean; trigger?: boolean }) {
|
||||
updateToken(
|
||||
token?: string,
|
||||
opts?: { rebuild?: boolean; verified?: boolean; trigger?: boolean },
|
||||
) {
|
||||
this.token = token;
|
||||
this.verified = false;
|
||||
this.verified = opts?.verified === true;
|
||||
|
||||
if (token) {
|
||||
this.user = omitKeys(decode(token).payload as any, ["iat", "iss", "exp"]) as any;
|
||||
@@ -159,21 +176,22 @@ export class Api {
|
||||
this.user = undefined;
|
||||
}
|
||||
|
||||
const emit = () => {
|
||||
if (opts?.trigger !== false) {
|
||||
this.options.onAuthStateChange?.(this.getAuthState());
|
||||
}
|
||||
};
|
||||
if (this.storage) {
|
||||
const key = this.tokenKey;
|
||||
|
||||
if (token) {
|
||||
this.storage.setItem(key, token).then(() => {
|
||||
this.options.onAuthStateChange?.(this.getAuthState());
|
||||
});
|
||||
this.storage.setItem(key, token).then(emit);
|
||||
} else {
|
||||
this.storage.removeItem(key).then(() => {
|
||||
this.options.onAuthStateChange?.(this.getAuthState());
|
||||
});
|
||||
this.storage.removeItem(key).then(emit);
|
||||
}
|
||||
} else {
|
||||
if (opts?.trigger !== false) {
|
||||
this.options.onAuthStateChange?.(this.getAuthState());
|
||||
emit();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,6 +200,7 @@ export class Api {
|
||||
|
||||
private markAuthVerified(verfied: boolean) {
|
||||
this.verified = verfied;
|
||||
this.options.onAuthStateChange?.(this.getAuthState());
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -208,11 +227,6 @@ export class Api {
|
||||
}
|
||||
|
||||
async verifyAuth() {
|
||||
if (!this.token) {
|
||||
this.markAuthVerified(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { ok, data } = await this.auth.me();
|
||||
const user = data?.user;
|
||||
@@ -221,10 +235,10 @@ export class Api {
|
||||
}
|
||||
|
||||
this.user = user;
|
||||
this.markAuthVerified(true);
|
||||
} catch (e) {
|
||||
this.markAuthVerified(false);
|
||||
this.updateToken(undefined);
|
||||
} finally {
|
||||
this.markAuthVerified(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,6 +253,7 @@ export class Api {
|
||||
headers: this.options.headers,
|
||||
token_transport: this.token_transport,
|
||||
verbose: this.options.verbose,
|
||||
credentials: this.options.credentials,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -257,10 +272,9 @@ export class Api {
|
||||
this.auth = new AuthApi(
|
||||
{
|
||||
...baseParams,
|
||||
credentials: this.options.storage ? "omit" : "include",
|
||||
...this.options.auth,
|
||||
onTokenUpdate: (token) => {
|
||||
this.updateToken(token, { rebuild: true });
|
||||
onTokenUpdate: (token, verified) => {
|
||||
this.updateToken(token, { rebuild: true, verified, trigger: true });
|
||||
this.options.auth?.onTokenUpdate?.(token);
|
||||
},
|
||||
},
|
||||
|
||||
107
app/src/App.ts
107
app/src/App.ts
@@ -1,21 +1,22 @@
|
||||
import type { CreateUserPayload } from "auth/AppAuth";
|
||||
import { $console } from "core/utils";
|
||||
import { $console, McpClient } from "bknd/utils";
|
||||
import { Event } from "core/events";
|
||||
import type { em as prototypeEm } from "data/prototype";
|
||||
import { Connection } from "data/connection/Connection";
|
||||
import type { Hono } from "hono";
|
||||
import {
|
||||
ModuleManager,
|
||||
type InitialModuleConfigs,
|
||||
type ModuleBuildContext,
|
||||
type ModuleConfigs,
|
||||
type ModuleManagerOptions,
|
||||
type Modules,
|
||||
ModuleManager,
|
||||
type ModuleBuildContext,
|
||||
type ModuleManagerOptions,
|
||||
} from "modules/ModuleManager";
|
||||
import { DbModuleManager } from "modules/db/DbModuleManager";
|
||||
import * as SystemPermissions from "modules/permissions";
|
||||
import { AdminController, type AdminControllerOptions } from "modules/server/AdminController";
|
||||
import { SystemController } from "modules/server/SystemController";
|
||||
import type { MaybePromise } from "core/types";
|
||||
import type { MaybePromise, PartialRec } from "core/types";
|
||||
import type { ServerEnv } from "modules/Controller";
|
||||
import type { IEmailDriver, ICacheDriver } from "core/drivers";
|
||||
|
||||
@@ -23,13 +24,34 @@ import type { IEmailDriver, ICacheDriver } from "core/drivers";
|
||||
import { Api, type ApiOptions } from "Api";
|
||||
|
||||
export type AppPluginConfig = {
|
||||
/**
|
||||
* The name of the plugin.
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* The schema of the plugin.
|
||||
*/
|
||||
schema?: () => MaybePromise<ReturnType<typeof prototypeEm> | void>;
|
||||
/**
|
||||
* Called before the app is built.
|
||||
*/
|
||||
beforeBuild?: () => MaybePromise<void>;
|
||||
/**
|
||||
* Called after the app is built.
|
||||
*/
|
||||
onBuilt?: () => MaybePromise<void>;
|
||||
/**
|
||||
* Called when the server is initialized.
|
||||
*/
|
||||
onServerInit?: (server: Hono<ServerEnv>) => MaybePromise<void>;
|
||||
onFirstBoot?: () => MaybePromise<void>;
|
||||
/**
|
||||
* Called when the app is booted.
|
||||
*/
|
||||
onBoot?: () => MaybePromise<void>;
|
||||
/**
|
||||
* Called when the app is first booted.
|
||||
*/
|
||||
onFirstBoot?: () => MaybePromise<void>;
|
||||
};
|
||||
export type AppPlugin = (app: App) => AppPluginConfig;
|
||||
|
||||
@@ -72,20 +94,23 @@ export type AppOptions = {
|
||||
email?: IEmailDriver;
|
||||
cache?: ICacheDriver;
|
||||
};
|
||||
mode?: "db" | "code";
|
||||
readonly?: boolean;
|
||||
};
|
||||
export type CreateAppConfig = {
|
||||
/**
|
||||
* bla
|
||||
*/
|
||||
connection?: Connection | { url: string };
|
||||
initialConfig?: InitialModuleConfigs;
|
||||
config?: PartialRec<ModuleConfigs>;
|
||||
options?: AppOptions;
|
||||
};
|
||||
|
||||
export type AppConfig = InitialModuleConfigs;
|
||||
export type AppConfig = { version: number } & ModuleConfigs;
|
||||
export type LocalApiOptions = Request | ApiOptions;
|
||||
|
||||
export class App<C extends Connection = Connection, Options extends AppOptions = AppOptions> {
|
||||
export class App<
|
||||
C extends Connection = Connection,
|
||||
Config extends PartialRec<ModuleConfigs> = PartialRec<ModuleConfigs>,
|
||||
Options extends AppOptions = AppOptions,
|
||||
> {
|
||||
static readonly Events = AppEvents;
|
||||
|
||||
modules: ModuleManager;
|
||||
@@ -96,11 +121,12 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
|
||||
|
||||
private trigger_first_boot = false;
|
||||
private _building: boolean = false;
|
||||
private _systemController: SystemController | null = null;
|
||||
|
||||
constructor(
|
||||
public connection: C,
|
||||
_initialConfig?: InitialModuleConfigs,
|
||||
private options?: Options,
|
||||
_config?: Config,
|
||||
public options?: Options,
|
||||
) {
|
||||
this.drivers = options?.drivers ?? {};
|
||||
|
||||
@@ -112,9 +138,13 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
|
||||
this.plugins.set(config.name, config);
|
||||
}
|
||||
this.runPlugins("onBoot");
|
||||
this.modules = new ModuleManager(connection, {
|
||||
|
||||
// use db manager by default
|
||||
const Manager = this.mode === "db" ? DbModuleManager : ModuleManager;
|
||||
|
||||
this.modules = new Manager(connection, {
|
||||
...(options?.manager ?? {}),
|
||||
initial: _initialConfig,
|
||||
initial: _config,
|
||||
onUpdated: this.onUpdated.bind(this),
|
||||
onFirstBoot: this.onFirstBoot.bind(this),
|
||||
onServerInit: this.onServerInit.bind(this),
|
||||
@@ -123,6 +153,14 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
|
||||
this.modules.ctx().emgr.registerEvents(AppEvents);
|
||||
}
|
||||
|
||||
get mode() {
|
||||
return this.options?.mode ?? "db";
|
||||
}
|
||||
|
||||
isReadOnly() {
|
||||
return Boolean(this.mode === "code" || this.options?.readonly);
|
||||
}
|
||||
|
||||
get emgr() {
|
||||
return this.modules.ctx().emgr;
|
||||
}
|
||||
@@ -153,7 +191,7 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
|
||||
return results as any;
|
||||
}
|
||||
|
||||
async build(options?: { sync?: boolean; fetch?: boolean; forceBuild?: boolean }) {
|
||||
async build(options?: { sync?: boolean; forceBuild?: boolean; [key: string]: any }) {
|
||||
// prevent multiple concurrent builds
|
||||
if (this._building) {
|
||||
while (this._building) {
|
||||
@@ -166,13 +204,14 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
|
||||
this._building = true;
|
||||
|
||||
if (options?.sync) this.modules.ctx().flags.sync_required = true;
|
||||
await this.modules.build({ fetch: options?.fetch });
|
||||
await this.modules.build();
|
||||
|
||||
const { guard, server } = this.modules.ctx();
|
||||
const { guard } = this.modules.ctx();
|
||||
|
||||
// load system controller
|
||||
guard.registerPermissions(Object.values(SystemPermissions));
|
||||
server.route("/api/system", new SystemController(this).getController());
|
||||
this._systemController = new SystemController(this);
|
||||
this._systemController.register(this);
|
||||
|
||||
// emit built event
|
||||
$console.log("App built");
|
||||
@@ -192,10 +231,6 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
|
||||
this._building = false;
|
||||
}
|
||||
|
||||
mutateConfig<Module extends keyof Modules>(module: Module) {
|
||||
return this.modules.mutateConfigSafe(module);
|
||||
}
|
||||
|
||||
get server() {
|
||||
return this.modules.server;
|
||||
}
|
||||
@@ -204,7 +239,14 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
|
||||
return this.modules.ctx().em;
|
||||
}
|
||||
|
||||
get mcp() {
|
||||
return this._systemController?._mcpServer;
|
||||
}
|
||||
|
||||
get fetch(): Hono["fetch"] {
|
||||
if (!this.isBuilt()) {
|
||||
console.error("App is not built yet, run build() first");
|
||||
}
|
||||
return this.server.fetch as any;
|
||||
}
|
||||
|
||||
@@ -253,6 +295,7 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
|
||||
return this.module.auth.createUser(p);
|
||||
}
|
||||
|
||||
// @todo: potentially add option to clone the app, so that when used in listeners, it won't trigger listeners
|
||||
getApi(options?: LocalApiOptions) {
|
||||
const fetcher = this.server.request as typeof fetch;
|
||||
if (options && options instanceof Request) {
|
||||
@@ -262,6 +305,19 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
|
||||
return new Api({ host: "http://localhost", ...(options ?? {}), fetcher });
|
||||
}
|
||||
|
||||
getMcpClient() {
|
||||
const config = this.modules.get("server").config.mcp;
|
||||
if (!config.enabled) {
|
||||
throw new Error("MCP is not enabled");
|
||||
}
|
||||
|
||||
const url = new URL(config.path, "http://localhost").toString();
|
||||
return new McpClient({
|
||||
url,
|
||||
fetch: this.server.request,
|
||||
});
|
||||
}
|
||||
|
||||
async onUpdated<Module extends keyof Modules>(module: Module, config: ModuleConfigs[Module]) {
|
||||
// if the EventManager was disabled, we assume we shouldn't
|
||||
// respond to events, such as "onUpdated".
|
||||
@@ -330,6 +386,7 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
|
||||
}
|
||||
}
|
||||
}
|
||||
await this.options?.manager?.onModulesBuilt?.(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,5 +395,5 @@ export function createApp(config: CreateAppConfig = {}) {
|
||||
throw new Error("Invalid connection");
|
||||
}
|
||||
|
||||
return new App(config.connection, config.initialConfig, config.options);
|
||||
return new App(config.connection, config.config, config.options);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { TestRunner } from "core/test";
|
||||
import type { BkndConfig, DefaultArgs, FrameworkOptions, RuntimeOptions } from "./index";
|
||||
import type { BkndConfig, DefaultArgs } from "./index";
|
||||
import type { App } from "App";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
export function adapterTestSuite<
|
||||
Config extends BkndConfig = BkndConfig,
|
||||
@@ -13,24 +14,17 @@ export function adapterTestSuite<
|
||||
label = "app",
|
||||
overrides = {},
|
||||
}: {
|
||||
makeApp: (
|
||||
config: Config,
|
||||
args?: Args,
|
||||
opts?: RuntimeOptions | FrameworkOptions,
|
||||
) => Promise<App>;
|
||||
makeHandler?: (
|
||||
config?: Config,
|
||||
args?: Args,
|
||||
opts?: RuntimeOptions | FrameworkOptions,
|
||||
) => (request: Request) => Promise<Response>;
|
||||
makeApp: (config: Config, args?: Args) => Promise<App>;
|
||||
makeHandler?: (config?: Config, args?: Args) => (request: Request) => Promise<Response>;
|
||||
label?: string;
|
||||
overrides?: {
|
||||
dbUrl?: string;
|
||||
};
|
||||
},
|
||||
) {
|
||||
const { test, expect, mock } = testRunner;
|
||||
const id = crypto.randomUUID();
|
||||
const { test, expect, mock, beforeAll, afterAll } = testRunner;
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(() => enableConsoleLog());
|
||||
|
||||
test(`creates ${label}`, async () => {
|
||||
const beforeBuild = mock(async () => null) as any;
|
||||
@@ -39,7 +33,7 @@ export function adapterTestSuite<
|
||||
const config = {
|
||||
app: (env) => ({
|
||||
connection: { url: env.url },
|
||||
initialConfig: {
|
||||
config: {
|
||||
server: { cors: { origin: env.origin } },
|
||||
},
|
||||
}),
|
||||
@@ -53,11 +47,10 @@ export function adapterTestSuite<
|
||||
url: overrides.dbUrl ?? ":memory:",
|
||||
origin: "localhost",
|
||||
} as any,
|
||||
{ id },
|
||||
);
|
||||
expect(app).toBeDefined();
|
||||
expect(app.toJSON().server.cors.origin).toEqual("localhost");
|
||||
expect(beforeBuild).toHaveBeenCalledTimes(1);
|
||||
expect(beforeBuild).toHaveBeenCalledTimes(2);
|
||||
expect(onBuilt).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -68,7 +61,7 @@ export function adapterTestSuite<
|
||||
return { res, data };
|
||||
};
|
||||
|
||||
test("responds with the same app id", async () => {
|
||||
/* test.skip("responds with the same app id", async () => {
|
||||
const fetcher = makeHandler(undefined, undefined, { id });
|
||||
|
||||
const { res, data } = await getConfig(fetcher);
|
||||
@@ -77,14 +70,14 @@ export function adapterTestSuite<
|
||||
expect(data.server.cors.origin).toEqual("localhost");
|
||||
});
|
||||
|
||||
test("creates fresh & responds to api config", async () => {
|
||||
test.skip("creates fresh & responds to api config", async () => {
|
||||
// set the same id, but force recreate
|
||||
const fetcher = makeHandler(undefined, undefined, { id, force: true });
|
||||
const fetcher = makeHandler(undefined, undefined, { id });
|
||||
|
||||
const { res, data } = await getConfig(fetcher);
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.status).toBe(200);
|
||||
expect(data.server.cors.origin).toEqual("*");
|
||||
});
|
||||
}); */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,6 @@ afterAll(enableConsoleLog);
|
||||
describe("astro adapter", () => {
|
||||
adapterTestSuite(bunTestRunner, {
|
||||
makeApp: astro.getApp,
|
||||
makeHandler: (c, a, o) => (request: Request) => astro.serve(c, a, o)({ request }),
|
||||
makeHandler: (c, a) => (request: Request) => astro.serve(c, a)({ request }),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type FrameworkBkndConfig, createFrameworkApp, type FrameworkOptions } from "bknd/adapter";
|
||||
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
|
||||
|
||||
type AstroEnv = NodeJS.ProcessEnv;
|
||||
type TAstro = {
|
||||
@@ -8,18 +8,16 @@ export type AstroBkndConfig<Env = AstroEnv> = FrameworkBkndConfig<Env>;
|
||||
|
||||
export async function getApp<Env = AstroEnv>(
|
||||
config: AstroBkndConfig<Env> = {},
|
||||
args: Env = {} as Env,
|
||||
opts: FrameworkOptions = {},
|
||||
args: Env = import.meta.env as Env,
|
||||
) {
|
||||
return await createFrameworkApp(config, args ?? import.meta.env, opts);
|
||||
return await createFrameworkApp(config, args);
|
||||
}
|
||||
|
||||
export function serve<Env = AstroEnv>(
|
||||
config: AstroBkndConfig<Env> = {},
|
||||
args: Env = {} as Env,
|
||||
opts?: FrameworkOptions,
|
||||
args: Env = import.meta.env as Env,
|
||||
) {
|
||||
return async (fnArgs: TAstro) => {
|
||||
return (await getApp(config, args, opts)).fetch(fnArgs.request);
|
||||
return (await getApp(config, args)).fetch(fnArgs.request);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { App } from "bknd";
|
||||
import { handle } from "hono/aws-lambda";
|
||||
import { serveStatic } from "@hono/node-server/serve-static";
|
||||
import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
|
||||
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
|
||||
|
||||
type AwsLambdaEnv = object;
|
||||
export type AwsLambdaBkndConfig<Env extends AwsLambdaEnv = AwsLambdaEnv> =
|
||||
@@ -20,7 +20,6 @@ export type AwsLambdaBkndConfig<Env extends AwsLambdaEnv = AwsLambdaEnv> =
|
||||
export async function createApp<Env extends AwsLambdaEnv = AwsLambdaEnv>(
|
||||
{ adminOptions = false, assets, ...config }: AwsLambdaBkndConfig<Env> = {},
|
||||
args: Env = {} as Env,
|
||||
opts?: RuntimeOptions,
|
||||
): Promise<App> {
|
||||
let additional: Partial<RuntimeBkndConfig> = {
|
||||
adminOptions,
|
||||
@@ -57,17 +56,15 @@ export async function createApp<Env extends AwsLambdaEnv = AwsLambdaEnv>(
|
||||
...additional,
|
||||
},
|
||||
args ?? process.env,
|
||||
opts,
|
||||
);
|
||||
}
|
||||
|
||||
export function serve<Env extends AwsLambdaEnv = AwsLambdaEnv>(
|
||||
config: AwsLambdaBkndConfig<Env> = {},
|
||||
args: Env = {} as Env,
|
||||
opts?: RuntimeOptions,
|
||||
) {
|
||||
return async (event) => {
|
||||
const app = await createApp(config, args, opts);
|
||||
const app = await createApp(config, args);
|
||||
return await handle(app.server)(event);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ describe("aws adapter", () => {
|
||||
adapterTestSuite(bunTestRunner, {
|
||||
makeApp: awsLambda.createApp,
|
||||
// @todo: add a request to lambda event translator?
|
||||
makeHandler: (c, a, o) => async (request: Request) => {
|
||||
const app = await awsLambda.createApp(c, a, o);
|
||||
makeHandler: (c, a) => async (request: Request) => {
|
||||
const app = await awsLambda.createApp(c, a);
|
||||
return app.fetch(request);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import path from "node:path";
|
||||
import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
|
||||
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
|
||||
import { registerLocalMediaAdapter } from ".";
|
||||
import { config, type App } from "bknd";
|
||||
import type { ServeOptions } from "bun";
|
||||
@@ -11,32 +11,33 @@ type BunEnv = Bun.Env;
|
||||
export type BunBkndConfig<Env = BunEnv> = RuntimeBkndConfig<Env> & Omit<ServeOptions, "fetch">;
|
||||
|
||||
export async function createApp<Env = BunEnv>(
|
||||
{ distPath, ...config }: BunBkndConfig<Env> = {},
|
||||
args: Env = {} as Env,
|
||||
opts?: RuntimeOptions,
|
||||
{ distPath, serveStatic: _serveStatic, ...config }: BunBkndConfig<Env> = {},
|
||||
args: Env = Bun.env as Env,
|
||||
) {
|
||||
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
|
||||
registerLocalMediaAdapter();
|
||||
|
||||
return await createRuntimeApp(
|
||||
{
|
||||
serveStatic: serveStatic({ root }),
|
||||
serveStatic:
|
||||
_serveStatic ??
|
||||
serveStatic({
|
||||
root,
|
||||
}),
|
||||
...config,
|
||||
},
|
||||
args ?? (process.env as Env),
|
||||
opts,
|
||||
args,
|
||||
);
|
||||
}
|
||||
|
||||
export function createHandler<Env = BunEnv>(
|
||||
config: BunBkndConfig<Env> = {},
|
||||
args: Env = {} as Env,
|
||||
opts?: RuntimeOptions,
|
||||
args: Env = Bun.env as Env,
|
||||
) {
|
||||
let app: App | undefined;
|
||||
return async (req: Request) => {
|
||||
if (!app) {
|
||||
app = await createApp(config, args ?? (process.env as Env), opts);
|
||||
app = await createApp(config, args);
|
||||
}
|
||||
return app.fetch(req);
|
||||
};
|
||||
@@ -46,17 +47,17 @@ export function serve<Env = BunEnv>(
|
||||
{
|
||||
distPath,
|
||||
connection,
|
||||
initialConfig,
|
||||
config: _config,
|
||||
options,
|
||||
port = config.server.default_port,
|
||||
onBuilt,
|
||||
buildConfig,
|
||||
adminOptions,
|
||||
serveStatic,
|
||||
beforeBuild,
|
||||
...serveOptions
|
||||
}: BunBkndConfig<Env> = {},
|
||||
args: Env = {} as Env,
|
||||
opts?: RuntimeOptions,
|
||||
args: Env = Bun.env as Env,
|
||||
) {
|
||||
Bun.serve({
|
||||
...serveOptions,
|
||||
@@ -64,16 +65,16 @@ export function serve<Env = BunEnv>(
|
||||
fetch: createHandler(
|
||||
{
|
||||
connection,
|
||||
initialConfig,
|
||||
config: _config,
|
||||
options,
|
||||
onBuilt,
|
||||
buildConfig,
|
||||
adminOptions,
|
||||
distPath,
|
||||
serveStatic,
|
||||
beforeBuild,
|
||||
},
|
||||
args,
|
||||
opts,
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { connectionTestSuite } from "data/connection/connection-test-suite";
|
||||
import { bunSqlite } from "./BunSqliteConnection";
|
||||
import { bunTestRunner } from "adapter/bun/test";
|
||||
import { describe } from "bun:test";
|
||||
import { describe, test, mock, expect } from "bun:test";
|
||||
import { Database } from "bun:sqlite";
|
||||
import { GenericSqliteConnection } from "data/connection/sqlite/GenericSqliteConnection";
|
||||
|
||||
describe("BunSqliteConnection", () => {
|
||||
connectionTestSuite(bunTestRunner, {
|
||||
@@ -12,4 +13,20 @@ describe("BunSqliteConnection", () => {
|
||||
}),
|
||||
rawDialectDetails: [],
|
||||
});
|
||||
|
||||
test("onCreateConnection", async () => {
|
||||
const called = mock(() => null);
|
||||
|
||||
const conn = bunSqlite({
|
||||
onCreateConnection: (db) => {
|
||||
expect(db).toBeInstanceOf(Database);
|
||||
called();
|
||||
},
|
||||
});
|
||||
await conn.ping();
|
||||
|
||||
expect(conn).toBeInstanceOf(GenericSqliteConnection);
|
||||
expect(conn.db).toBeInstanceOf(Database);
|
||||
expect(called).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,40 +1,53 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { genericSqlite, type GenericSqliteConnection } from "bknd";
|
||||
import {
|
||||
genericSqlite,
|
||||
type GenericSqliteConnection,
|
||||
type GenericSqliteConnectionConfig,
|
||||
} from "bknd";
|
||||
import { omitKeys } from "bknd/utils";
|
||||
|
||||
export type BunSqliteConnection = GenericSqliteConnection<Database>;
|
||||
export type BunSqliteConnectionConfig = {
|
||||
database: Database;
|
||||
};
|
||||
export type BunSqliteConnectionConfig = Omit<
|
||||
GenericSqliteConnectionConfig<Database>,
|
||||
"name" | "supports"
|
||||
> &
|
||||
({ database?: Database; url?: never } | { url?: string; database?: never });
|
||||
|
||||
export function bunSqlite(config?: BunSqliteConnectionConfig | { url: string }) {
|
||||
let db: Database;
|
||||
export function bunSqlite(config?: BunSqliteConnectionConfig) {
|
||||
let db: Database | undefined;
|
||||
if (config) {
|
||||
if ("database" in config) {
|
||||
if ("database" in config && config.database) {
|
||||
db = config.database;
|
||||
} else {
|
||||
} else if (config.url) {
|
||||
db = new Database(config.url);
|
||||
}
|
||||
} else {
|
||||
}
|
||||
|
||||
if (!db) {
|
||||
db = new Database(":memory:");
|
||||
}
|
||||
|
||||
return genericSqlite("bun-sqlite", db, (utils) => {
|
||||
//const fn = cache ? "query" : "prepare";
|
||||
const getStmt = (sql: string) => db.prepare(sql);
|
||||
return genericSqlite(
|
||||
"bun-sqlite",
|
||||
db,
|
||||
(utils) => {
|
||||
const getStmt = (sql: string) => db.prepare(sql);
|
||||
|
||||
return {
|
||||
db,
|
||||
query: utils.buildQueryFn({
|
||||
all: (sql, parameters) => getStmt(sql).all(...(parameters || [])),
|
||||
run: (sql, parameters) => {
|
||||
const { changes, lastInsertRowid } = getStmt(sql).run(...(parameters || []));
|
||||
return {
|
||||
insertId: utils.parseBigInt(lastInsertRowid),
|
||||
numAffectedRows: utils.parseBigInt(changes),
|
||||
};
|
||||
},
|
||||
}),
|
||||
close: () => db.close(),
|
||||
};
|
||||
});
|
||||
return {
|
||||
db,
|
||||
query: utils.buildQueryFn({
|
||||
all: (sql, parameters) => getStmt(sql).all(...(parameters || [])),
|
||||
run: (sql, parameters) => {
|
||||
const { changes, lastInsertRowid } = getStmt(sql).run(...(parameters || []));
|
||||
return {
|
||||
insertId: utils.parseBigInt(lastInsertRowid),
|
||||
numAffectedRows: utils.parseBigInt(changes),
|
||||
};
|
||||
},
|
||||
}),
|
||||
close: () => db.close(),
|
||||
};
|
||||
},
|
||||
omitKeys(config ?? ({} as any), ["database", "url", "name", "supports"]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
export * from "./bun.adapter";
|
||||
export * from "../node/storage";
|
||||
export * from "./connection/BunSqliteConnection";
|
||||
|
||||
export async function writer(path: string, content: string) {
|
||||
await Bun.write(path, content);
|
||||
}
|
||||
|
||||
export async function reader(path: string) {
|
||||
return await Bun.file(path).text();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test, mock, describe, beforeEach, afterEach, afterAll } from "bun:test";
|
||||
import { expect, test, mock, describe, beforeEach, afterEach, afterAll, beforeAll } from "bun:test";
|
||||
|
||||
export const bunTestRunner = {
|
||||
describe,
|
||||
@@ -8,4 +8,5 @@ export const bunTestRunner = {
|
||||
beforeEach,
|
||||
afterEach,
|
||||
afterAll,
|
||||
beforeAll,
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { inspect } from "node:util";
|
||||
|
||||
export type BindingTypeMap = {
|
||||
D1Database: D1Database;
|
||||
KVNamespace: KVNamespace;
|
||||
@@ -13,8 +15,9 @@ export function getBindings<T extends GetBindingType>(env: any, type: T): Bindin
|
||||
for (const key in env) {
|
||||
try {
|
||||
if (
|
||||
env[key] &&
|
||||
((env[key] as any).constructor.name === type || String(env[key]) === `[object ${type}]`)
|
||||
(env[key] as any).constructor.name === type ||
|
||||
String(env[key]) === `[object ${type}]` ||
|
||||
inspect(env[key]).includes(type)
|
||||
) {
|
||||
bindings.push({
|
||||
key,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||
import { makeApp } from "./modes/fresh";
|
||||
import { makeConfig, type CfMakeConfigArgs } from "./config";
|
||||
import { makeConfig, type CloudflareContext } from "./config";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
||||
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
||||
import { bunTestRunner } from "adapter/bun/test";
|
||||
import type { CloudflareBkndConfig } from "./cloudflare-workers.adapter";
|
||||
import { type CloudflareBkndConfig, createApp } from "./cloudflare-workers.adapter";
|
||||
|
||||
beforeAll(disableConsoleLog);
|
||||
afterAll(enableConsoleLog);
|
||||
@@ -18,42 +17,42 @@ describe("cf adapter", () => {
|
||||
});
|
||||
|
||||
it("makes config", async () => {
|
||||
const staticConfig = makeConfig(
|
||||
const staticConfig = await makeConfig(
|
||||
{
|
||||
connection: { url: DB_URL },
|
||||
initialConfig: { data: { basepath: DB_URL } },
|
||||
config: { data: { basepath: DB_URL } },
|
||||
},
|
||||
$ctx({ DB_URL }),
|
||||
);
|
||||
expect(staticConfig.initialConfig).toEqual({ data: { basepath: DB_URL } });
|
||||
expect(staticConfig.config).toEqual({ data: { basepath: DB_URL } });
|
||||
expect(staticConfig.connection).toBeDefined();
|
||||
|
||||
const dynamicConfig = makeConfig(
|
||||
const dynamicConfig = await makeConfig(
|
||||
{
|
||||
app: (env) => ({
|
||||
initialConfig: { data: { basepath: env.DB_URL } },
|
||||
config: { data: { basepath: env.DB_URL } },
|
||||
connection: { url: env.DB_URL },
|
||||
}),
|
||||
},
|
||||
$ctx({ DB_URL }),
|
||||
);
|
||||
expect(dynamicConfig.initialConfig).toEqual({ data: { basepath: DB_URL } });
|
||||
expect(dynamicConfig.config).toEqual({ data: { basepath: DB_URL } });
|
||||
expect(dynamicConfig.connection).toBeDefined();
|
||||
});
|
||||
|
||||
adapterTestSuite<CloudflareBkndConfig, CfMakeConfigArgs<any>>(bunTestRunner, {
|
||||
makeApp: async (c, a, o) => {
|
||||
return await makeApp(c, { env: a } as any, o);
|
||||
adapterTestSuite<CloudflareBkndConfig, CloudflareContext<any>>(bunTestRunner, {
|
||||
makeApp: async (c, a) => {
|
||||
return await createApp(c, { env: a } as any);
|
||||
},
|
||||
makeHandler: (c, a, o) => {
|
||||
makeHandler: (c, a) => {
|
||||
console.log("args", a);
|
||||
return async (request: any) => {
|
||||
const app = await makeApp(
|
||||
const app = await createApp(
|
||||
// needs a fallback, otherwise tries to launch D1
|
||||
c ?? {
|
||||
connection: { url: DB_URL },
|
||||
},
|
||||
a!,
|
||||
o,
|
||||
a as any,
|
||||
);
|
||||
return app.fetch(request);
|
||||
};
|
||||
|
||||
@@ -3,11 +3,10 @@
|
||||
import type { RuntimeBkndConfig } from "bknd/adapter";
|
||||
import { Hono } from "hono";
|
||||
import { serveStatic } from "hono/cloudflare-workers";
|
||||
import { getFresh } from "./modes/fresh";
|
||||
import { getCached } from "./modes/cached";
|
||||
import { getDurable } from "./modes/durable";
|
||||
import type { App } from "bknd";
|
||||
import { $console } from "core/utils";
|
||||
import type { MaybePromise } from "bknd";
|
||||
import { $console } from "bknd/utils";
|
||||
import { createRuntimeApp } from "bknd/adapter";
|
||||
import { registerAsyncsExecutionContext, makeConfig, type CloudflareContext } from "./config";
|
||||
|
||||
declare global {
|
||||
namespace Cloudflare {
|
||||
@@ -17,12 +16,10 @@ declare global {
|
||||
|
||||
export type CloudflareEnv = Cloudflare.Env;
|
||||
export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> & {
|
||||
mode?: "warm" | "fresh" | "cache" | "durable";
|
||||
bindings?: (args: Env) => {
|
||||
bindings?: (args: Env) => MaybePromise<{
|
||||
kv?: KVNamespace;
|
||||
dobj?: DurableObjectNamespace;
|
||||
db?: D1Database;
|
||||
};
|
||||
}>;
|
||||
d1?: {
|
||||
session?: boolean;
|
||||
transport?: "header" | "cookie";
|
||||
@@ -36,11 +33,27 @@ export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> &
|
||||
registerMedia?: boolean | ((env: Env) => void);
|
||||
};
|
||||
|
||||
export type Context<Env = CloudflareEnv> = {
|
||||
request: Request;
|
||||
env: Env;
|
||||
ctx: ExecutionContext;
|
||||
};
|
||||
export async function createApp<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
config: CloudflareBkndConfig<Env> = {},
|
||||
ctx: Partial<CloudflareContext<Env>> = {},
|
||||
) {
|
||||
const appConfig = await makeConfig(config, ctx);
|
||||
return await createRuntimeApp<Env>(
|
||||
{
|
||||
...appConfig,
|
||||
onBuilt: async (app) => {
|
||||
if (ctx.ctx) {
|
||||
registerAsyncsExecutionContext(app, ctx?.ctx);
|
||||
}
|
||||
await appConfig.onBuilt?.(app);
|
||||
},
|
||||
},
|
||||
ctx?.env,
|
||||
);
|
||||
}
|
||||
|
||||
// compatiblity
|
||||
export const getFresh = createApp;
|
||||
|
||||
export function serve<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
config: CloudflareBkndConfig<Env> = {},
|
||||
@@ -79,25 +92,8 @@ export function serve<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
}
|
||||
}
|
||||
|
||||
const context = { request, env, ctx } as Context<Env>;
|
||||
const mode = config.mode ?? "warm";
|
||||
|
||||
let app: App;
|
||||
switch (mode) {
|
||||
case "fresh":
|
||||
app = await getFresh(config, context, { force: true });
|
||||
break;
|
||||
case "warm":
|
||||
app = await getFresh(config, context);
|
||||
break;
|
||||
case "cache":
|
||||
app = await getCached(config, context);
|
||||
break;
|
||||
case "durable":
|
||||
return await getDurable(config, context);
|
||||
default:
|
||||
throw new Error(`Unknown mode ${mode}`);
|
||||
}
|
||||
const context = { request, env, ctx } as CloudflareContext<Env>;
|
||||
const app = await createApp(config, context);
|
||||
|
||||
return app.fetch(request, env, ctx);
|
||||
},
|
||||
|
||||
@@ -8,8 +8,8 @@ import { getBinding } from "./bindings";
|
||||
import { d1Sqlite } from "./connection/D1Connection";
|
||||
import type { CloudflareBkndConfig, CloudflareEnv } from ".";
|
||||
import { App } from "bknd";
|
||||
import type { Context, ExecutionContext } from "hono";
|
||||
import { $console } from "core/utils";
|
||||
import type { Context as HonoContext, ExecutionContext } from "hono";
|
||||
import { $console } from "bknd/utils";
|
||||
import { setCookie } from "hono/cookie";
|
||||
|
||||
export const constants = {
|
||||
@@ -22,10 +22,10 @@ export const constants = {
|
||||
},
|
||||
};
|
||||
|
||||
export type CfMakeConfigArgs<Env extends CloudflareEnv = CloudflareEnv> = {
|
||||
export type CloudflareContext<Env extends CloudflareEnv = CloudflareEnv> = {
|
||||
env: Env;
|
||||
ctx?: ExecutionContext;
|
||||
request?: Request;
|
||||
ctx: ExecutionContext;
|
||||
request: Request;
|
||||
};
|
||||
|
||||
function getCookieValue(cookies: string | null, name: string) {
|
||||
@@ -67,7 +67,7 @@ export function d1SessionHelper(config: CloudflareBkndConfig<any>) {
|
||||
|
||||
return undefined;
|
||||
},
|
||||
set: (c: Context, d1?: D1DatabaseSession) => {
|
||||
set: (c: HonoContext, d1?: D1DatabaseSession) => {
|
||||
if (!d1 || !config.d1?.session) return;
|
||||
|
||||
const session = d1.getBookmark();
|
||||
@@ -89,9 +89,9 @@ export function d1SessionHelper(config: CloudflareBkndConfig<any>) {
|
||||
}
|
||||
|
||||
let media_registered: boolean = false;
|
||||
export function makeConfig<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
export async function makeConfig<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
config: CloudflareBkndConfig<Env>,
|
||||
args?: CfMakeConfigArgs<Env>,
|
||||
args?: Partial<CloudflareContext<Env>>,
|
||||
) {
|
||||
if (!media_registered && config.registerMedia !== false) {
|
||||
if (typeof config.registerMedia === "function") {
|
||||
@@ -102,7 +102,7 @@ export function makeConfig<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
media_registered = true;
|
||||
}
|
||||
|
||||
const appConfig = makeAdapterConfig(config, args?.env);
|
||||
const appConfig = await makeAdapterConfig(config, args?.env);
|
||||
|
||||
// if connection instance is given, don't do anything
|
||||
// other than checking if D1 session is defined
|
||||
@@ -115,12 +115,12 @@ export function makeConfig<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
}
|
||||
// if connection is given, try to open with unified sqlite adapter
|
||||
} else if (appConfig.connection) {
|
||||
appConfig.connection = sqlite(appConfig.connection);
|
||||
appConfig.connection = sqlite(appConfig.connection) as any;
|
||||
|
||||
// if connection is not given, but env is set
|
||||
// try to make D1 from bindings
|
||||
} else if (args?.env) {
|
||||
const bindings = config.bindings?.(args?.env);
|
||||
const bindings = await config.bindings?.(args?.env);
|
||||
const sessionHelper = d1SessionHelper(config);
|
||||
const sessionId = sessionHelper.get(args.request);
|
||||
let session: D1DatabaseSession | undefined;
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
import { genericSqlite, type GenericSqliteConnection } from "bknd";
|
||||
import type { QueryResult } from "kysely";
|
||||
|
||||
export type D1SqliteConnection = GenericSqliteConnection<D1Database>;
|
||||
export type DoSqliteConnection = GenericSqliteConnection<DurableObjectState["storage"]["sql"]>;
|
||||
export type DurableObjecSql = DurableObjectState["storage"]["sql"];
|
||||
|
||||
export type D1ConnectionConfig<DB extends DurableObjecSql> =
|
||||
export type DoConnectionConfig<DB extends DurableObjecSql> =
|
||||
| DurableObjectState
|
||||
| {
|
||||
sql: DB;
|
||||
};
|
||||
|
||||
export function doSqlite<DB extends DurableObjecSql>(config: D1ConnectionConfig<DB>) {
|
||||
export function doSqlite<DB extends DurableObjecSql>(config: DoConnectionConfig<DB>) {
|
||||
const db = "sql" in config ? config.sql : config.storage.sql;
|
||||
|
||||
return genericSqlite(
|
||||
@@ -21,7 +21,7 @@ export function doSqlite<DB extends DurableObjecSql>(config: D1ConnectionConfig<
|
||||
(utils) => {
|
||||
// must be async to work with the miniflare mock
|
||||
const getStmt = async (sql: string, parameters?: any[] | readonly any[]) =>
|
||||
await db.exec(sql, ...(parameters || []));
|
||||
db.exec(sql, ...(parameters || []));
|
||||
|
||||
const mapResult = (
|
||||
cursor: SqlStorageCursor<Record<string, SqlStorageValue>>,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/// <reference types="@cloudflare/workers-types" />
|
||||
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { describe, beforeAll, afterAll } from "vitest";
|
||||
|
||||
import { viTestRunner } from "adapter/node/vitest";
|
||||
import { connectionTestSuite } from "data/connection/connection-test-suite";
|
||||
import { Miniflare } from "miniflare";
|
||||
import { doSqlite } from "./DoConnection";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
const script = `
|
||||
import { DurableObject } from "cloudflare:workers";
|
||||
@@ -40,6 +41,9 @@ export default {
|
||||
}
|
||||
`;
|
||||
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(() => enableConsoleLog());
|
||||
|
||||
describe("doSqlite", async () => {
|
||||
connectionTestSuite(viTestRunner, {
|
||||
makeConnection: async () => {
|
||||
|
||||
@@ -3,6 +3,10 @@ import { cacheWorkersKV } from "./cache";
|
||||
import { viTestRunner } from "adapter/node/vitest";
|
||||
import { cacheDriverTestSuite } from "core/drivers/cache/cache-driver-test-suite";
|
||||
import { Miniflare } from "miniflare";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(() => enableConsoleLog());
|
||||
|
||||
describe("cacheWorkersKV", async () => {
|
||||
beforeAll(() => {
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { d1Sqlite, type D1ConnectionConfig } from "./connection/D1Connection";
|
||||
|
||||
export * from "./cloudflare-workers.adapter";
|
||||
export { makeApp, getFresh } from "./modes/fresh";
|
||||
export { getCached } from "./modes/cached";
|
||||
export { DurableBkndApp, getDurable } from "./modes/durable";
|
||||
export {
|
||||
getFresh,
|
||||
createApp,
|
||||
serve,
|
||||
type CloudflareEnv,
|
||||
type CloudflareBkndConfig,
|
||||
} from "./cloudflare-workers.adapter";
|
||||
export { d1Sqlite, type D1ConnectionConfig };
|
||||
export { doSqlite, type DoConnectionConfig } from "./connection/DoConnection";
|
||||
export {
|
||||
getBinding,
|
||||
getBindings,
|
||||
@@ -12,9 +16,10 @@ export {
|
||||
type GetBindingType,
|
||||
type BindingMap,
|
||||
} from "./bindings";
|
||||
export { constants } from "./config";
|
||||
export { constants, makeConfig, type CloudflareContext } from "./config";
|
||||
export { StorageR2Adapter, registerMedia } from "./storage/StorageR2Adapter";
|
||||
export { registries } from "bknd";
|
||||
export { devFsVitePlugin, devFsWrite } from "./vite";
|
||||
|
||||
// for compatibility with old code
|
||||
export function d1<DB extends D1Database | D1DatabaseSession = D1Database>(
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { App } from "bknd";
|
||||
import { createRuntimeApp } from "bknd/adapter";
|
||||
import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index";
|
||||
import { makeConfig, registerAsyncsExecutionContext, constants } from "../config";
|
||||
|
||||
export async function getCached<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
config: CloudflareBkndConfig<Env>,
|
||||
args: Context<Env>,
|
||||
) {
|
||||
const { env, ctx } = args;
|
||||
const { kv } = config.bindings?.(env)!;
|
||||
if (!kv) throw new Error("kv namespace is not defined in cloudflare.bindings");
|
||||
const key = config.key ?? "app";
|
||||
|
||||
const cachedConfig = await kv.get(key);
|
||||
const initialConfig = cachedConfig ? JSON.parse(cachedConfig) : undefined;
|
||||
|
||||
async function saveConfig(__config: any) {
|
||||
ctx.waitUntil(kv!.put(key, JSON.stringify(__config)));
|
||||
}
|
||||
|
||||
const app = await createRuntimeApp(
|
||||
{
|
||||
...makeConfig(config, args),
|
||||
initialConfig,
|
||||
onBuilt: async (app) => {
|
||||
registerAsyncsExecutionContext(app, ctx);
|
||||
app.module.server.client.get(constants.cache_endpoint, async (c) => {
|
||||
await kv.delete(key);
|
||||
return c.json({ message: "Cache cleared" });
|
||||
});
|
||||
await config.onBuilt?.(app);
|
||||
},
|
||||
beforeBuild: async (app) => {
|
||||
app.emgr.onEvent(
|
||||
App.Events.AppConfigUpdatedEvent,
|
||||
async ({ params: { app } }) => {
|
||||
saveConfig(app.toJSON(true));
|
||||
},
|
||||
"sync",
|
||||
);
|
||||
await config.beforeBuild?.(app);
|
||||
},
|
||||
},
|
||||
args,
|
||||
);
|
||||
|
||||
if (!cachedConfig) {
|
||||
saveConfig(app.toJSON(true));
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import { DurableObject } from "cloudflare:workers";
|
||||
import type { App, CreateAppConfig } from "bknd";
|
||||
import { createRuntimeApp, makeConfig } from "bknd/adapter";
|
||||
import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index";
|
||||
import { constants, registerAsyncsExecutionContext } from "../config";
|
||||
import { $console } from "core/utils";
|
||||
|
||||
export async function getDurable<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
config: CloudflareBkndConfig<Env>,
|
||||
ctx: Context<Env>,
|
||||
) {
|
||||
const { dobj } = config.bindings?.(ctx.env)!;
|
||||
if (!dobj) throw new Error("durable object is not defined in cloudflare.bindings");
|
||||
const key = config.key ?? "app";
|
||||
|
||||
if ([config.onBuilt, config.beforeBuild].some((x) => x)) {
|
||||
$console.warn("onBuilt and beforeBuild are not supported with DurableObject mode");
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
|
||||
const id = dobj.idFromName(key);
|
||||
const stub = dobj.get(id) as unknown as DurableBkndApp;
|
||||
|
||||
const create_config = makeConfig(config, ctx.env);
|
||||
|
||||
const res = await stub.fire(ctx.request, {
|
||||
config: create_config,
|
||||
keepAliveSeconds: config.keepAliveSeconds,
|
||||
});
|
||||
|
||||
const headers = new Headers(res.headers);
|
||||
headers.set("X-TTDO", String(performance.now() - start));
|
||||
|
||||
return new Response(res.body, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
export class DurableBkndApp extends DurableObject {
|
||||
protected id = Math.random().toString(36).slice(2);
|
||||
protected app?: App;
|
||||
protected interval?: any;
|
||||
|
||||
async fire(
|
||||
request: Request,
|
||||
options: {
|
||||
config: CreateAppConfig;
|
||||
html?: string;
|
||||
keepAliveSeconds?: number;
|
||||
setAdminHtml?: boolean;
|
||||
},
|
||||
) {
|
||||
let buildtime = 0;
|
||||
if (!this.app) {
|
||||
const start = performance.now();
|
||||
const config = options.config;
|
||||
|
||||
// change protocol to websocket if libsql
|
||||
if (
|
||||
config?.connection &&
|
||||
"type" in config.connection &&
|
||||
config.connection.type === "libsql"
|
||||
) {
|
||||
//config.connection.config.protocol = "wss";
|
||||
}
|
||||
|
||||
this.app = await createRuntimeApp({
|
||||
...config,
|
||||
onBuilt: async (app) => {
|
||||
registerAsyncsExecutionContext(app, this.ctx);
|
||||
app.modules.server.get(constants.do_endpoint, async (c) => {
|
||||
// @ts-ignore
|
||||
const context: any = c.req.raw.cf ? c.req.raw.cf : c.env.cf;
|
||||
return c.json({
|
||||
id: this.id,
|
||||
keepAliveSeconds: options?.keepAliveSeconds ?? 0,
|
||||
colo: context.colo,
|
||||
});
|
||||
});
|
||||
|
||||
await this.onBuilt(app);
|
||||
},
|
||||
adminOptions: { html: options.html },
|
||||
beforeBuild: async (app) => {
|
||||
await this.beforeBuild(app);
|
||||
},
|
||||
});
|
||||
|
||||
buildtime = performance.now() - start;
|
||||
}
|
||||
|
||||
if (options?.keepAliveSeconds) {
|
||||
this.keepAlive(options.keepAliveSeconds);
|
||||
}
|
||||
|
||||
const res = await this.app!.fetch(request);
|
||||
const headers = new Headers(res.headers);
|
||||
headers.set("X-BuildTime", buildtime.toString());
|
||||
headers.set("X-DO-ID", this.id);
|
||||
|
||||
return new Response(res.body, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
async onBuilt(app: App) {}
|
||||
|
||||
async beforeBuild(app: App) {}
|
||||
|
||||
protected keepAlive(seconds: number) {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
this.interval = setInterval(() => {
|
||||
i += 1;
|
||||
if (i === seconds) {
|
||||
console.log("cleared");
|
||||
clearInterval(this.interval);
|
||||
|
||||
// ping every 30 seconds
|
||||
} else if (i % 30 === 0) {
|
||||
console.log("ping");
|
||||
this.app?.modules.ctx().connection.ping();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
|
||||
import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index";
|
||||
import { makeConfig, registerAsyncsExecutionContext, type CfMakeConfigArgs } from "../config";
|
||||
|
||||
export async function makeApp<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
config: CloudflareBkndConfig<Env>,
|
||||
args?: CfMakeConfigArgs<Env>,
|
||||
opts?: RuntimeOptions,
|
||||
) {
|
||||
return await createRuntimeApp<Env>(makeConfig(config, args), args?.env, opts);
|
||||
}
|
||||
|
||||
export async function getFresh<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
config: CloudflareBkndConfig<Env>,
|
||||
ctx: Context<Env>,
|
||||
opts: RuntimeOptions = {},
|
||||
) {
|
||||
return await makeApp(
|
||||
{
|
||||
...config,
|
||||
onBuilt: async (app) => {
|
||||
registerAsyncsExecutionContext(app, ctx.ctx);
|
||||
await config.onBuilt?.(app);
|
||||
},
|
||||
},
|
||||
ctx,
|
||||
opts,
|
||||
);
|
||||
}
|
||||
101
app/src/adapter/cloudflare/proxy.ts
Normal file
101
app/src/adapter/cloudflare/proxy.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
d1Sqlite,
|
||||
getBinding,
|
||||
registerMedia,
|
||||
type CloudflareBkndConfig,
|
||||
type CloudflareEnv,
|
||||
} from "bknd/adapter/cloudflare";
|
||||
import type { GetPlatformProxyOptions, PlatformProxy } from "wrangler";
|
||||
import process from "node:process";
|
||||
import { $console } from "bknd/utils";
|
||||
|
||||
export type WithPlatformProxyOptions = {
|
||||
/**
|
||||
* By default, proxy is used if the PROXY environment variable is set to 1.
|
||||
* You can override/force this by setting this option.
|
||||
*/
|
||||
useProxy?: boolean;
|
||||
proxyOptions?: GetPlatformProxyOptions;
|
||||
};
|
||||
|
||||
async function getPlatformProxy(opts?: GetPlatformProxyOptions) {
|
||||
try {
|
||||
const { version } = await import("wrangler/package.json", { with: { type: "json" } }).then(
|
||||
(pkg) => pkg.default,
|
||||
);
|
||||
$console.log("Using wrangler version", version);
|
||||
const { getPlatformProxy } = await import("wrangler");
|
||||
return getPlatformProxy(opts);
|
||||
} catch (e) {
|
||||
$console.error("Failed to import wrangler", String(e));
|
||||
const resolved = import.meta.resolve("wrangler");
|
||||
$console.log("Wrangler resolved to", resolved);
|
||||
const file = resolved?.split("/").pop();
|
||||
if (file?.endsWith(".json")) {
|
||||
$console.error(
|
||||
"You have a `wrangler.json` in your current directory. Please change to .jsonc or .toml",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
export function withPlatformProxy<Env extends CloudflareEnv>(
|
||||
config: CloudflareBkndConfig<Env> = {},
|
||||
opts?: WithPlatformProxyOptions,
|
||||
) {
|
||||
const use_proxy =
|
||||
typeof opts?.useProxy === "boolean" ? opts.useProxy : process.env.PROXY === "1";
|
||||
let proxy: PlatformProxy | undefined;
|
||||
|
||||
$console.log("Using cloudflare platform proxy");
|
||||
|
||||
async function getEnv(env?: Env): Promise<Env> {
|
||||
if (use_proxy) {
|
||||
if (!proxy) {
|
||||
proxy = await getPlatformProxy(opts?.proxyOptions);
|
||||
process.on("exit", () => {
|
||||
proxy?.dispose();
|
||||
});
|
||||
}
|
||||
return proxy.env as unknown as Env;
|
||||
}
|
||||
return env || ({} as Env);
|
||||
}
|
||||
|
||||
return {
|
||||
...config,
|
||||
beforeBuild: async (app, registries) => {
|
||||
if (!use_proxy) return;
|
||||
const env = await getEnv();
|
||||
registerMedia(env, registries as any);
|
||||
await config?.beforeBuild?.(app, registries);
|
||||
},
|
||||
bindings: async (env) => {
|
||||
return (await config?.bindings?.(await getEnv(env))) || {};
|
||||
},
|
||||
// @ts-ignore
|
||||
app: async (_env) => {
|
||||
const env = await getEnv(_env);
|
||||
const binding = use_proxy ? getBinding(env, "D1Database") : undefined;
|
||||
|
||||
if (config?.app === undefined && use_proxy && binding) {
|
||||
return {
|
||||
connection: d1Sqlite({
|
||||
binding: binding.value,
|
||||
}),
|
||||
};
|
||||
} else if (typeof config?.app === "function") {
|
||||
const appConfig = await config?.app(env);
|
||||
if (binding) {
|
||||
appConfig.connection = d1Sqlite({
|
||||
binding: binding.value,
|
||||
}) as any;
|
||||
}
|
||||
return appConfig;
|
||||
}
|
||||
return config?.app || {};
|
||||
},
|
||||
} satisfies CloudflareBkndConfig<Env>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { registries, isDebug, guessMimeType } from "bknd";
|
||||
import { registries as $registries, isDebug, guessMimeType } from "bknd";
|
||||
import { getBindings } from "../bindings";
|
||||
import { s } from "bknd/utils";
|
||||
import { StorageAdapter, type FileBody } from "bknd";
|
||||
@@ -12,7 +12,10 @@ export function makeSchema(bindings: string[] = []) {
|
||||
);
|
||||
}
|
||||
|
||||
export function registerMedia(env: Record<string, any>) {
|
||||
export function registerMedia(
|
||||
env: Record<string, any>,
|
||||
registries: typeof $registries = $registries,
|
||||
) {
|
||||
const r2_bindings = getBindings(env, "R2Bucket");
|
||||
|
||||
registries.media.register(
|
||||
@@ -46,6 +49,8 @@ export function registerMedia(env: Record<string, any>) {
|
||||
* @todo: add tests (bun tests won't work, need node native tests)
|
||||
*/
|
||||
export class StorageR2Adapter extends StorageAdapter {
|
||||
public keyPrefix: string = "";
|
||||
|
||||
constructor(private readonly bucket: R2Bucket) {
|
||||
super();
|
||||
}
|
||||
@@ -172,6 +177,9 @@ export class StorageR2Adapter extends StorageAdapter {
|
||||
}
|
||||
|
||||
protected getKey(key: string) {
|
||||
if (this.keyPrefix.length > 0) {
|
||||
return `${this.keyPrefix}/${key}`.replace(/^\/\//, "/");
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,12 @@ import { Miniflare } from "miniflare";
|
||||
import { StorageR2Adapter } from "./StorageR2Adapter";
|
||||
import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite";
|
||||
import path from "node:path";
|
||||
import { describe, afterAll, test, expect } from "vitest";
|
||||
import { describe, afterAll, test, expect, beforeAll } from "vitest";
|
||||
import { viTestRunner } from "adapter/node/vitest";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(() => enableConsoleLog());
|
||||
|
||||
let mf: Miniflare | undefined;
|
||||
describe("StorageR2Adapter", async () => {
|
||||
@@ -24,7 +28,8 @@ describe("StorageR2Adapter", async () => {
|
||||
const buffer = readFileSync(path.join(basePath, "image.png"));
|
||||
const file = new File([buffer], "image.png", { type: "image/png" });
|
||||
|
||||
await adapterTestSuite(viTestRunner, adapter, file);
|
||||
// miniflare doesn't support range requests
|
||||
await adapterTestSuite(viTestRunner, adapter, file, { testRange: false });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
272
app/src/adapter/cloudflare/vite.ts
Normal file
272
app/src/adapter/cloudflare/vite.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import type { Plugin } from "vite";
|
||||
import { writeFile as nodeWriteFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
/**
|
||||
* Vite plugin that provides Node.js filesystem access during development
|
||||
* by injecting a polyfill into the SSR environment
|
||||
*/
|
||||
export function devFsVitePlugin({
|
||||
verbose = false,
|
||||
configFile = "bknd.config.ts",
|
||||
}: {
|
||||
verbose?: boolean;
|
||||
configFile?: string;
|
||||
} = {}): any {
|
||||
let isDev = false;
|
||||
let projectRoot = "";
|
||||
|
||||
return {
|
||||
name: "dev-fs-plugin",
|
||||
enforce: "pre",
|
||||
configResolved(config) {
|
||||
isDev = config.command === "serve";
|
||||
projectRoot = config.root;
|
||||
},
|
||||
configureServer(server) {
|
||||
if (!isDev) {
|
||||
verbose && console.debug("[dev-fs-plugin] Not in dev mode, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
// Track active chunked requests
|
||||
const activeRequests = new Map<
|
||||
string,
|
||||
{
|
||||
totalChunks: number;
|
||||
filename: string;
|
||||
chunks: string[];
|
||||
receivedChunks: number;
|
||||
}
|
||||
>();
|
||||
|
||||
// Intercept stdout to watch for our write requests
|
||||
const originalStdoutWrite = process.stdout.write;
|
||||
process.stdout.write = function (chunk: any, encoding?: any, callback?: any) {
|
||||
const output = chunk.toString();
|
||||
|
||||
// Skip our own debug output
|
||||
if (output.includes("[dev-fs-plugin]") || output.includes("[dev-fs-polyfill]")) {
|
||||
// @ts-ignore
|
||||
// biome-ignore lint/style/noArguments: <explanation>
|
||||
return originalStdoutWrite.apply(process.stdout, arguments);
|
||||
}
|
||||
|
||||
// Track if we process any protocol messages (to suppress output)
|
||||
let processedProtocolMessage = false;
|
||||
|
||||
// Process all start markers in this output
|
||||
if (output.includes("{{DEV_FS_START}}")) {
|
||||
const startMatches = [
|
||||
...output.matchAll(/{{DEV_FS_START}} ([a-z0-9]+) (\d+) (.+)/g),
|
||||
];
|
||||
for (const startMatch of startMatches) {
|
||||
const requestId = startMatch[1];
|
||||
const totalChunks = Number.parseInt(startMatch[2]);
|
||||
const filename = startMatch[3];
|
||||
|
||||
activeRequests.set(requestId, {
|
||||
totalChunks,
|
||||
filename,
|
||||
chunks: new Array(totalChunks),
|
||||
receivedChunks: 0,
|
||||
});
|
||||
|
||||
verbose &&
|
||||
console.debug(
|
||||
`[dev-fs-plugin] Started request ${requestId} for ${filename} (${totalChunks} chunks)`,
|
||||
);
|
||||
}
|
||||
processedProtocolMessage = true;
|
||||
}
|
||||
|
||||
// Process all chunk data in this output
|
||||
if (output.includes("{{DEV_FS_CHUNK}}")) {
|
||||
const chunkMatches = [
|
||||
...output.matchAll(/{{DEV_FS_CHUNK}} ([a-z0-9]+) (\d+) ([A-Za-z0-9+/=]+)/g),
|
||||
];
|
||||
for (const chunkMatch of chunkMatches) {
|
||||
const requestId = chunkMatch[1];
|
||||
const chunkIndex = Number.parseInt(chunkMatch[2]);
|
||||
const chunkData = chunkMatch[3];
|
||||
|
||||
const request = activeRequests.get(requestId);
|
||||
if (request) {
|
||||
request.chunks[chunkIndex] = chunkData;
|
||||
request.receivedChunks++;
|
||||
verbose &&
|
||||
console.debug(
|
||||
`[dev-fs-plugin] Received chunk ${chunkIndex}/${request.totalChunks - 1} for ${request.filename} (length: ${chunkData.length})`,
|
||||
);
|
||||
|
||||
// Validate base64 chunk
|
||||
if (chunkData.length < 1000 && chunkIndex < request.totalChunks - 1) {
|
||||
verbose &&
|
||||
console.warn(
|
||||
`[dev-fs-plugin] WARNING: Chunk ${chunkIndex} seems truncated (length: ${chunkData.length})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
processedProtocolMessage = true;
|
||||
}
|
||||
|
||||
// Process all end markers in this output
|
||||
if (output.includes("{{DEV_FS_END}}")) {
|
||||
const endMatches = [...output.matchAll(/{{DEV_FS_END}} ([a-z0-9]+)/g)];
|
||||
for (const endMatch of endMatches) {
|
||||
const requestId = endMatch[1];
|
||||
const request = activeRequests.get(requestId);
|
||||
|
||||
if (request && request.receivedChunks === request.totalChunks) {
|
||||
try {
|
||||
// Reconstruct the base64 string
|
||||
const fullBase64 = request.chunks.join("");
|
||||
verbose &&
|
||||
console.debug(
|
||||
`[dev-fs-plugin] Reconstructed ${request.filename} - base64 length: ${fullBase64.length}`,
|
||||
);
|
||||
|
||||
// Decode and parse
|
||||
const decodedJson = atob(fullBase64);
|
||||
const writeRequest = JSON.parse(decodedJson);
|
||||
|
||||
if (writeRequest.type === "DEV_FS_WRITE_REQUEST") {
|
||||
verbose &&
|
||||
console.debug(
|
||||
`[dev-fs-plugin] Processing write request for ${writeRequest.filename}`,
|
||||
);
|
||||
|
||||
// Process the write request
|
||||
(async () => {
|
||||
try {
|
||||
const fullPath = resolve(projectRoot, writeRequest.filename);
|
||||
verbose &&
|
||||
console.debug(`[dev-fs-plugin] Writing to: ${fullPath}`);
|
||||
await nodeWriteFile(fullPath, writeRequest.data);
|
||||
verbose &&
|
||||
console.debug("[dev-fs-plugin] File written successfully!");
|
||||
} catch (error) {
|
||||
console.error("[dev-fs-plugin] Error writing file:", error);
|
||||
}
|
||||
})();
|
||||
|
||||
// Clean up
|
||||
activeRequests.delete(requestId);
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[dev-fs-plugin] Error processing chunked request:",
|
||||
String(error),
|
||||
);
|
||||
activeRequests.delete(requestId);
|
||||
}
|
||||
} else if (request) {
|
||||
verbose &&
|
||||
console.debug(
|
||||
`[dev-fs-plugin] Request ${requestId} incomplete: ${request.receivedChunks}/${request.totalChunks} chunks`,
|
||||
);
|
||||
}
|
||||
}
|
||||
processedProtocolMessage = true;
|
||||
}
|
||||
|
||||
// If we processed any protocol messages, suppress output
|
||||
if (processedProtocolMessage) {
|
||||
return callback ? callback() : true;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
// biome-ignore lint:
|
||||
return originalStdoutWrite.apply(process.stdout, arguments);
|
||||
};
|
||||
|
||||
// Restore stdout when server closes
|
||||
server.httpServer?.on("close", () => {
|
||||
process.stdout.write = originalStdoutWrite;
|
||||
});
|
||||
},
|
||||
// @ts-ignore
|
||||
transform(code, id, options) {
|
||||
// Only transform in SSR mode during development
|
||||
//if (!isDev || !options?.ssr) return;
|
||||
if (!isDev) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is the bknd config file
|
||||
if (id.includes(configFile)) {
|
||||
if (verbose) {
|
||||
console.debug("[dev-fs-plugin] Transforming", configFile);
|
||||
}
|
||||
|
||||
// Inject our filesystem polyfill at the top of the file
|
||||
const polyfill = `
|
||||
// Dev-fs polyfill injected by vite-plugin-dev-fs
|
||||
if (typeof globalThis !== 'undefined') {
|
||||
globalThis.__devFsPolyfill = {
|
||||
writeFile: async (filename, data) => {
|
||||
${verbose ? "console.debug('[dev-fs-polyfill] Intercepting write request for', filename);" : ""}
|
||||
|
||||
// Use console logging as a communication channel
|
||||
// The main process will watch for this specific log pattern
|
||||
const writeRequest = {
|
||||
type: 'DEV_FS_WRITE_REQUEST',
|
||||
filename: filename,
|
||||
data: data,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
// Output as a specially formatted console message with end delimiter
|
||||
// Base64 encode the JSON to avoid any control character issues
|
||||
const jsonString = JSON.stringify(writeRequest);
|
||||
const encodedJson = btoa(jsonString);
|
||||
|
||||
// Split into reasonable chunks that balance performance vs reliability
|
||||
const chunkSize = 2000; // 2KB chunks - safe for most environments
|
||||
const chunks = [];
|
||||
for (let i = 0; i < encodedJson.length; i += chunkSize) {
|
||||
chunks.push(encodedJson.slice(i, i + chunkSize));
|
||||
}
|
||||
|
||||
const requestId = Date.now().toString(36) + Math.random().toString(36).substr(2, 5);
|
||||
|
||||
// Send start marker (use stdout.write to avoid console display)
|
||||
process.stdout.write('{{DEV_FS_START}} ' + requestId + ' ' + chunks.length + ' ' + filename + '\\n');
|
||||
|
||||
// Send each chunk
|
||||
chunks.forEach((chunk, index) => {
|
||||
process.stdout.write('{{DEV_FS_CHUNK}} ' + requestId + ' ' + index + ' ' + chunk + '\\n');
|
||||
});
|
||||
|
||||
// Send end marker
|
||||
process.stdout.write('{{DEV_FS_END}} ' + requestId + '\\n');
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
};
|
||||
}`;
|
||||
return polyfill + code;
|
||||
} else {
|
||||
verbose && console.debug("[dev-fs-plugin] Not transforming", id);
|
||||
}
|
||||
},
|
||||
} satisfies Plugin;
|
||||
}
|
||||
|
||||
// Write function that uses the dev-fs polyfill injected by our Vite plugin
|
||||
export async function devFsWrite(filename: string, data: string): Promise<void> {
|
||||
try {
|
||||
// Check if the dev-fs polyfill is available (injected by our Vite plugin)
|
||||
if (typeof globalThis !== "undefined" && (globalThis as any).__devFsPolyfill) {
|
||||
return (globalThis as any).__devFsPolyfill.writeFile(filename, data);
|
||||
}
|
||||
|
||||
// Fallback to Node.js fs for other environments (Node.js, Bun)
|
||||
const { writeFile } = await import("node:fs/promises");
|
||||
return writeFile(filename, data);
|
||||
} catch (error) {
|
||||
console.error("[dev-fs-write] Error writing file:", error);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,31 @@
|
||||
import { config as $config, App, type CreateAppConfig, Connection, guessMimeType } from "bknd";
|
||||
import {
|
||||
config as $config,
|
||||
App,
|
||||
type CreateAppConfig,
|
||||
Connection,
|
||||
guessMimeType,
|
||||
type MaybePromise,
|
||||
registries as $registries,
|
||||
type Merge,
|
||||
} from "bknd";
|
||||
import { $console } from "bknd/utils";
|
||||
import type { Context, MiddlewareHandler, Next } from "hono";
|
||||
import type { AdminControllerOptions } from "modules/server/AdminController";
|
||||
import type { Manifest } from "vite";
|
||||
|
||||
export type BkndConfig<Args = any> = CreateAppConfig & {
|
||||
app?: CreateAppConfig | ((args: Args) => CreateAppConfig);
|
||||
onBuilt?: (app: App) => Promise<void>;
|
||||
beforeBuild?: (app: App) => Promise<void>;
|
||||
buildConfig?: Parameters<App["build"]>[0];
|
||||
};
|
||||
export type BkndConfig<Args = any, Additional = {}> = Merge<
|
||||
CreateAppConfig & {
|
||||
app?:
|
||||
| Merge<Omit<BkndConfig, "app"> & Additional>
|
||||
| ((args: Args) => MaybePromise<Merge<Omit<BkndConfig<Args>, "app"> & Additional>>);
|
||||
onBuilt?: (app: App) => MaybePromise<void>;
|
||||
beforeBuild?: (app?: App, registries?: typeof $registries) => MaybePromise<void>;
|
||||
buildConfig?: Parameters<App["build"]>[0];
|
||||
} & Additional
|
||||
>;
|
||||
|
||||
export type FrameworkBkndConfig<Args = any> = BkndConfig<Args>;
|
||||
|
||||
export type CreateAdapterAppOptions = {
|
||||
force?: boolean;
|
||||
id?: string;
|
||||
};
|
||||
export type FrameworkOptions = CreateAdapterAppOptions;
|
||||
export type RuntimeOptions = CreateAdapterAppOptions;
|
||||
|
||||
export type RuntimeBkndConfig<Args = any> = BkndConfig<Args> & {
|
||||
distPath?: string;
|
||||
serveStatic?: MiddlewareHandler | [string, MiddlewareHandler];
|
||||
@@ -30,10 +36,10 @@ export type DefaultArgs = {
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
export function makeConfig<Args = DefaultArgs>(
|
||||
export async function makeConfig<Args = DefaultArgs>(
|
||||
config: BkndConfig<Args>,
|
||||
args?: Args,
|
||||
): CreateAppConfig {
|
||||
): Promise<Omit<BkndConfig<Args>, "app">> {
|
||||
let additionalConfig: CreateAppConfig = {};
|
||||
const { app, ...rest } = config;
|
||||
if (app) {
|
||||
@@ -41,7 +47,7 @@ export function makeConfig<Args = DefaultArgs>(
|
||||
if (!args) {
|
||||
throw new Error("args is required when config.app is a function");
|
||||
}
|
||||
additionalConfig = app(args);
|
||||
additionalConfig = await app(args);
|
||||
} else {
|
||||
additionalConfig = app;
|
||||
}
|
||||
@@ -50,55 +56,50 @@ export function makeConfig<Args = DefaultArgs>(
|
||||
return { ...rest, ...additionalConfig };
|
||||
}
|
||||
|
||||
// a map that contains all apps by id
|
||||
const apps = new Map<string, App>();
|
||||
export async function createAdapterApp<Config extends BkndConfig = BkndConfig, Args = DefaultArgs>(
|
||||
config: Config = {} as Config,
|
||||
args?: Args,
|
||||
opts?: CreateAdapterAppOptions,
|
||||
): Promise<App> {
|
||||
const id = opts?.id ?? "app";
|
||||
let app = apps.get(id);
|
||||
if (!app || opts?.force) {
|
||||
const appConfig = makeConfig(config, args);
|
||||
if (!appConfig.connection || !Connection.isConnection(appConfig.connection)) {
|
||||
let connection: Connection | undefined;
|
||||
if (Connection.isConnection(config.connection)) {
|
||||
connection = config.connection;
|
||||
} else {
|
||||
const sqlite = (await import("bknd/adapter/sqlite")).sqlite;
|
||||
const conf = appConfig.connection ?? { url: ":memory:" };
|
||||
connection = sqlite(conf);
|
||||
$console.info(`Using ${connection!.name} connection`, conf.url);
|
||||
}
|
||||
appConfig.connection = connection;
|
||||
}
|
||||
): Promise<{ app: App; config: BkndConfig<Args> }> {
|
||||
await config.beforeBuild?.(undefined, $registries);
|
||||
|
||||
app = App.create(appConfig);
|
||||
apps.set(id, app);
|
||||
const appConfig = await makeConfig(config, args);
|
||||
if (!appConfig.connection || !Connection.isConnection(appConfig.connection)) {
|
||||
let connection: Connection | undefined;
|
||||
if (Connection.isConnection(config.connection)) {
|
||||
connection = config.connection;
|
||||
} else {
|
||||
const sqlite = (await import("bknd/adapter/sqlite")).sqlite;
|
||||
const conf = appConfig.connection ?? { url: "file:data.db" };
|
||||
connection = sqlite(conf) as any;
|
||||
$console.info(`Using ${connection!.name} connection`, conf.url);
|
||||
}
|
||||
appConfig.connection = connection;
|
||||
}
|
||||
return app;
|
||||
|
||||
return {
|
||||
app: App.create(appConfig),
|
||||
config: appConfig,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createFrameworkApp<Args = DefaultArgs>(
|
||||
config: FrameworkBkndConfig = {},
|
||||
args?: Args,
|
||||
opts?: FrameworkOptions,
|
||||
): Promise<App> {
|
||||
const app = await createAdapterApp(config, args, opts);
|
||||
const { app, config: appConfig } = await createAdapterApp(config, args);
|
||||
|
||||
if (!app.isBuilt()) {
|
||||
if (config.onBuilt) {
|
||||
app.emgr.onEvent(
|
||||
App.Events.AppBuiltEvent,
|
||||
async () => {
|
||||
await config.onBuilt?.(app);
|
||||
await appConfig.onBuilt?.(app);
|
||||
},
|
||||
"sync",
|
||||
);
|
||||
}
|
||||
|
||||
await config.beforeBuild?.(app);
|
||||
await appConfig.beforeBuild?.(app, $registries);
|
||||
await app.build(config.buildConfig);
|
||||
}
|
||||
|
||||
@@ -108,9 +109,8 @@ export async function createFrameworkApp<Args = DefaultArgs>(
|
||||
export async function createRuntimeApp<Args = DefaultArgs>(
|
||||
{ serveStatic, adminOptions, ...config }: RuntimeBkndConfig<Args> = {},
|
||||
args?: Args,
|
||||
opts?: RuntimeOptions,
|
||||
): Promise<App> {
|
||||
const app = await createAdapterApp(config, args, opts);
|
||||
const { app, config: appConfig } = await createAdapterApp(config, args);
|
||||
|
||||
if (!app.isBuilt()) {
|
||||
app.emgr.onEvent(
|
||||
@@ -123,7 +123,7 @@ export async function createRuntimeApp<Args = DefaultArgs>(
|
||||
app.modules.server.get(path, handler);
|
||||
}
|
||||
|
||||
await config.onBuilt?.(app);
|
||||
await appConfig.onBuilt?.(app);
|
||||
if (adminOptions !== false) {
|
||||
app.registerAdminController(adminOptions);
|
||||
}
|
||||
@@ -131,7 +131,7 @@ export async function createRuntimeApp<Args = DefaultArgs>(
|
||||
"sync",
|
||||
);
|
||||
|
||||
await config.beforeBuild?.(app);
|
||||
await appConfig.beforeBuild?.(app, $registries);
|
||||
await app.build(config.buildConfig);
|
||||
}
|
||||
|
||||
@@ -154,22 +154,33 @@ export async function createRuntimeApp<Args = DefaultArgs>(
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function serveStaticViaImport(opts?: { manifest?: Manifest }) {
|
||||
export function serveStaticViaImport(opts?: {
|
||||
manifest?: Manifest;
|
||||
appendRaw?: boolean;
|
||||
package?: string;
|
||||
}) {
|
||||
let files: string[] | undefined;
|
||||
const pkg = opts?.package ?? "bknd";
|
||||
|
||||
// @ts-ignore
|
||||
return async (c: Context, next: Next) => {
|
||||
if (!files) {
|
||||
const manifest =
|
||||
opts?.manifest || ((await import("bknd/dist/manifest.json")).default as Manifest);
|
||||
opts?.manifest ||
|
||||
((
|
||||
await import(/* @vite-ignore */ `${pkg}/dist/manifest.json`, {
|
||||
with: { type: "json" },
|
||||
})
|
||||
).default as Manifest);
|
||||
files = Object.values(manifest).flatMap((asset) => [asset.file, ...(asset.css || [])]);
|
||||
}
|
||||
|
||||
const path = c.req.path.substring(1);
|
||||
if (files.includes(path)) {
|
||||
try {
|
||||
const content = await import(/* @vite-ignore */ `bknd/static/${path}?raw`, {
|
||||
assert: { type: "text" },
|
||||
const url = `${pkg}/static/${path}${opts?.appendRaw ? "?raw" : ""}`;
|
||||
const content = await import(/* @vite-ignore */ url, {
|
||||
with: { type: "text" },
|
||||
}).then((m) => m.default);
|
||||
|
||||
if (content) {
|
||||
@@ -181,7 +192,7 @@ export function serveStaticViaImport(opts?: { manifest?: Manifest }) {
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error serving static file:", e);
|
||||
console.error(`Error serving static file "${path}":`, String(e));
|
||||
return c.text("File not found", 404);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createFrameworkApp, type FrameworkBkndConfig, type FrameworkOptions } from "bknd/adapter";
|
||||
import { createFrameworkApp, type FrameworkBkndConfig } from "bknd/adapter";
|
||||
import { isNode } from "bknd/utils";
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
@@ -9,10 +9,9 @@ export type NextjsBkndConfig<Env = NextjsEnv> = FrameworkBkndConfig<Env> & {
|
||||
|
||||
export async function getApp<Env = NextjsEnv>(
|
||||
config: NextjsBkndConfig<Env>,
|
||||
args: Env = {} as Env,
|
||||
opts?: FrameworkOptions,
|
||||
args: Env = process.env as Env,
|
||||
) {
|
||||
return await createFrameworkApp(config, args ?? (process.env as Env), opts);
|
||||
return await createFrameworkApp(config, args);
|
||||
}
|
||||
|
||||
function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequest"]) {
|
||||
@@ -40,11 +39,10 @@ function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequ
|
||||
|
||||
export function serve<Env = NextjsEnv>(
|
||||
{ cleanRequest, ...config }: NextjsBkndConfig<Env> = {},
|
||||
args: Env = {} as Env,
|
||||
opts?: FrameworkOptions,
|
||||
args: Env = process.env as Env,
|
||||
) {
|
||||
return async (req: Request) => {
|
||||
const app = await getApp(config, args, opts);
|
||||
const app = await getApp(config, args);
|
||||
const request = getCleanRequest(req, cleanRequest);
|
||||
return app.fetch(request);
|
||||
};
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
import { genericSqlite } from "bknd";
|
||||
import {
|
||||
genericSqlite,
|
||||
type GenericSqliteConnection,
|
||||
type GenericSqliteConnectionConfig,
|
||||
} from "bknd";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { omitKeys } from "bknd/utils";
|
||||
|
||||
export type NodeSqliteConnectionConfig = {
|
||||
database: DatabaseSync;
|
||||
};
|
||||
export type NodeSqliteConnection = GenericSqliteConnection<DatabaseSync>;
|
||||
export type NodeSqliteConnectionConfig = Omit<
|
||||
GenericSqliteConnectionConfig<DatabaseSync>,
|
||||
"name" | "supports"
|
||||
> &
|
||||
({ database?: DatabaseSync; url?: never } | { url?: string; database?: never });
|
||||
|
||||
export function nodeSqlite(config?: NodeSqliteConnectionConfig | { url: string }) {
|
||||
let db: DatabaseSync;
|
||||
export function nodeSqlite(config?: NodeSqliteConnectionConfig) {
|
||||
let db: DatabaseSync | undefined;
|
||||
if (config) {
|
||||
if ("database" in config) {
|
||||
if ("database" in config && config.database) {
|
||||
db = config.database;
|
||||
} else {
|
||||
} else if (config.url) {
|
||||
db = new DatabaseSync(config.url);
|
||||
}
|
||||
} else {
|
||||
}
|
||||
|
||||
if (!db) {
|
||||
db = new DatabaseSync(":memory:");
|
||||
}
|
||||
|
||||
@@ -21,11 +31,7 @@ export function nodeSqlite(config?: NodeSqliteConnectionConfig | { url: string }
|
||||
"node-sqlite",
|
||||
db,
|
||||
(utils) => {
|
||||
const getStmt = (sql: string) => {
|
||||
const stmt = db.prepare(sql);
|
||||
//stmt.setReadBigInts(true);
|
||||
return stmt;
|
||||
};
|
||||
const getStmt = (sql: string) => db.prepare(sql);
|
||||
|
||||
return {
|
||||
db,
|
||||
@@ -49,6 +55,7 @@ export function nodeSqlite(config?: NodeSqliteConnectionConfig | { url: string }
|
||||
};
|
||||
},
|
||||
{
|
||||
...omitKeys(config ?? ({} as any), ["database", "url", "name", "supports"]),
|
||||
supports: {
|
||||
batching: false,
|
||||
},
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { nodeSqlite } from "./NodeSqliteConnection";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { connectionTestSuite } from "data/connection/connection-test-suite";
|
||||
import { describe } from "vitest";
|
||||
import { describe, beforeAll, afterAll, test, expect, vi } from "vitest";
|
||||
import { viTestRunner } from "../vitest";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
import { GenericSqliteConnection } from "data/connection/sqlite/GenericSqliteConnection";
|
||||
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(() => enableConsoleLog());
|
||||
|
||||
describe("NodeSqliteConnection", () => {
|
||||
connectionTestSuite(viTestRunner, {
|
||||
@@ -12,4 +17,20 @@ describe("NodeSqliteConnection", () => {
|
||||
}),
|
||||
rawDialectDetails: [],
|
||||
});
|
||||
|
||||
test("onCreateConnection", async () => {
|
||||
const called = vi.fn(() => null);
|
||||
|
||||
const conn = nodeSqlite({
|
||||
onCreateConnection: (db) => {
|
||||
expect(db).toBeInstanceOf(DatabaseSync);
|
||||
called();
|
||||
},
|
||||
});
|
||||
await conn.ping();
|
||||
|
||||
expect(conn).toBeInstanceOf(GenericSqliteConnection);
|
||||
expect(conn.db).toBeInstanceOf(DatabaseSync);
|
||||
expect(called).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
|
||||
export * from "./node.adapter";
|
||||
export * from "./storage";
|
||||
export * from "./connection/NodeSqliteConnection";
|
||||
|
||||
export async function writer(path: string, content: string) {
|
||||
await writeFile(path, content);
|
||||
}
|
||||
|
||||
export async function reader(path: string) {
|
||||
return await readFile(path, "utf-8");
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import path from "node:path";
|
||||
import { serve as honoServe } from "@hono/node-server";
|
||||
import { serveStatic } from "@hono/node-server/serve-static";
|
||||
import { registerLocalMediaAdapter } from "adapter/node/storage";
|
||||
import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
|
||||
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
|
||||
import { config as $config, type App } from "bknd";
|
||||
import { $console } from "bknd/utils";
|
||||
|
||||
@@ -17,8 +17,7 @@ export type NodeBkndConfig<Env = NodeEnv> = RuntimeBkndConfig<Env> & {
|
||||
|
||||
export async function createApp<Env = NodeEnv>(
|
||||
{ distPath, relativeDistPath, ...config }: NodeBkndConfig<Env> = {},
|
||||
args: Env = {} as Env,
|
||||
opts?: RuntimeOptions,
|
||||
args: Env = process.env as Env,
|
||||
) {
|
||||
const root = path.relative(
|
||||
process.cwd(),
|
||||
@@ -34,21 +33,18 @@ export async function createApp<Env = NodeEnv>(
|
||||
serveStatic: serveStatic({ root }),
|
||||
...config,
|
||||
},
|
||||
// @ts-ignore
|
||||
args ?? { env: process.env },
|
||||
opts,
|
||||
args,
|
||||
);
|
||||
}
|
||||
|
||||
export function createHandler<Env = NodeEnv>(
|
||||
config: NodeBkndConfig<Env> = {},
|
||||
args: Env = {} as Env,
|
||||
opts?: RuntimeOptions,
|
||||
args: Env = process.env as Env,
|
||||
) {
|
||||
let app: App | undefined;
|
||||
return async (req: Request) => {
|
||||
if (!app) {
|
||||
app = await createApp(config, args ?? (process.env as Env), opts);
|
||||
app = await createApp(config, args);
|
||||
}
|
||||
return app.fetch(req);
|
||||
};
|
||||
@@ -56,14 +52,13 @@ export function createHandler<Env = NodeEnv>(
|
||||
|
||||
export function serve<Env = NodeEnv>(
|
||||
{ port = $config.server.default_port, hostname, listener, ...config }: NodeBkndConfig<Env> = {},
|
||||
args: Env = {} as Env,
|
||||
opts?: RuntimeOptions,
|
||||
args: Env = process.env as Env,
|
||||
) {
|
||||
honoServe(
|
||||
{
|
||||
port,
|
||||
hostname,
|
||||
fetch: createHandler(config, args, opts),
|
||||
fetch: createHandler(config, args),
|
||||
},
|
||||
(connInfo) => {
|
||||
$console.log(`Server is running on http://localhost:${connInfo.port}`);
|
||||
|
||||
@@ -2,10 +2,6 @@ import { describe, beforeAll, afterAll } from "vitest";
|
||||
import * as node from "./node.adapter";
|
||||
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
||||
import { viTestRunner } from "adapter/node/vitest";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
||||
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
describe("node adapter", () => {
|
||||
adapterTestSuite(viTestRunner, {
|
||||
|
||||
@@ -80,18 +80,79 @@ export class StorageLocalAdapter extends StorageAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
private parseRangeHeader(
|
||||
rangeHeader: string,
|
||||
fileSize: number,
|
||||
): { start: number; end: number } | null {
|
||||
// Parse "bytes=start-end" format
|
||||
const match = rangeHeader.match(/^bytes=(\d*)-(\d*)$/);
|
||||
if (!match) return null;
|
||||
|
||||
const [, startStr, endStr] = match;
|
||||
let start = startStr ? Number.parseInt(startStr, 10) : 0;
|
||||
let end = endStr ? Number.parseInt(endStr, 10) : fileSize - 1;
|
||||
|
||||
// Handle suffix-byte-range-spec (e.g., "bytes=-500")
|
||||
if (!startStr && endStr) {
|
||||
start = Math.max(0, fileSize - Number.parseInt(endStr, 10));
|
||||
end = fileSize - 1;
|
||||
}
|
||||
|
||||
// Validate range
|
||||
if (start < 0 || end >= fileSize || start > end) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
async getObject(key: string, headers: Headers): Promise<Response> {
|
||||
try {
|
||||
const content = await readFile(`${this.config.path}/${key}`);
|
||||
const filePath = `${this.config.path}/${key}`;
|
||||
const stats = await stat(filePath);
|
||||
const fileSize = stats.size;
|
||||
const mimeType = guessMimeType(key);
|
||||
|
||||
return new Response(content, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": mimeType || "application/octet-stream",
|
||||
"Content-Length": content.length.toString(),
|
||||
},
|
||||
const responseHeaders = new Headers({
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Type": mimeType || "application/octet-stream",
|
||||
});
|
||||
|
||||
const rangeHeader = headers.get("range");
|
||||
|
||||
if (rangeHeader) {
|
||||
const range = this.parseRangeHeader(rangeHeader, fileSize);
|
||||
|
||||
if (!range) {
|
||||
// Invalid range - return 416 Range Not Satisfiable
|
||||
responseHeaders.set("Content-Range", `bytes */${fileSize}`);
|
||||
return new Response("", {
|
||||
status: 416,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
const { start, end } = range;
|
||||
const content = await readFile(filePath, { encoding: null });
|
||||
const chunk = content.slice(start, end + 1);
|
||||
|
||||
responseHeaders.set("Content-Range", `bytes ${start}-${end}/${fileSize}`);
|
||||
responseHeaders.set("Content-Length", chunk.length.toString());
|
||||
|
||||
return new Response(chunk, {
|
||||
status: 206, // Partial Content
|
||||
headers: responseHeaders,
|
||||
});
|
||||
} else {
|
||||
// Normal request - return entire file
|
||||
const content = await readFile(filePath);
|
||||
responseHeaders.set("Content-Length", content.length.toString());
|
||||
|
||||
return new Response(content, {
|
||||
status: 200,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Handle file reading errors
|
||||
return new Response("", { status: 404 });
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { describe } from "vitest";
|
||||
import { describe, beforeAll, afterAll } from "vitest";
|
||||
import { viTestRunner } from "adapter/node/vitest";
|
||||
import { StorageLocalAdapter } from "adapter/node";
|
||||
import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(() => enableConsoleLog());
|
||||
|
||||
describe("StorageLocalAdapter (node)", async () => {
|
||||
const basePath = path.resolve(import.meta.dirname, "../../../../__test__/_assets");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import nodeAssert from "node:assert/strict";
|
||||
import { test, describe, beforeEach, afterEach } from "node:test";
|
||||
import { test, describe, beforeEach, afterEach, after, before } from "node:test";
|
||||
import type { Matcher, Test, TestFn, TestRunner } from "core/test";
|
||||
|
||||
// Track mock function calls
|
||||
@@ -99,5 +99,6 @@ export const nodeTestRunner: TestRunner = {
|
||||
}),
|
||||
beforeEach: beforeEach,
|
||||
afterEach: afterEach,
|
||||
afterAll: () => {},
|
||||
afterAll: after,
|
||||
beforeAll: before,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { TestFn, TestRunner, Test } from "core/test";
|
||||
import { describe, test, expect, vi, beforeEach, afterEach, afterAll } from "vitest";
|
||||
import { describe, test, expect, vi, beforeEach, afterEach, afterAll, beforeAll } from "vitest";
|
||||
|
||||
function vitestTest(label: string, fn: TestFn, options?: any) {
|
||||
return test(label, fn as any);
|
||||
@@ -50,4 +50,5 @@ export const viTestRunner: TestRunner = {
|
||||
beforeEach: beforeEach,
|
||||
afterEach: afterEach,
|
||||
afterAll: afterAll,
|
||||
beforeAll: beforeAll,
|
||||
};
|
||||
|
||||
@@ -10,6 +10,6 @@ afterAll(enableConsoleLog);
|
||||
describe("react-router adapter", () => {
|
||||
adapterTestSuite(bunTestRunner, {
|
||||
makeApp: rr.getApp,
|
||||
makeHandler: (c, a, o) => (request: Request) => rr.serve(c, a?.env, o)({ request }),
|
||||
makeHandler: (c, a) => (request: Request) => rr.serve(c, a?.env)({ request }),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
|
||||
import type { FrameworkOptions } from "adapter";
|
||||
|
||||
type ReactRouterEnv = NodeJS.ProcessEnv;
|
||||
type ReactRouterFunctionArgs = {
|
||||
@@ -9,18 +8,16 @@ export type ReactRouterBkndConfig<Env = ReactRouterEnv> = FrameworkBkndConfig<En
|
||||
|
||||
export async function getApp<Env = ReactRouterEnv>(
|
||||
config: ReactRouterBkndConfig<Env>,
|
||||
args: Env = {} as Env,
|
||||
opts?: FrameworkOptions,
|
||||
args: Env = process.env as Env,
|
||||
) {
|
||||
return await createFrameworkApp(config, args ?? process.env, opts);
|
||||
return await createFrameworkApp(config, args);
|
||||
}
|
||||
|
||||
export function serve<Env = ReactRouterEnv>(
|
||||
config: ReactRouterBkndConfig<Env> = {},
|
||||
args: Env = {} as Env,
|
||||
opts?: FrameworkOptions,
|
||||
args: Env = process.env as Env,
|
||||
) {
|
||||
return async (fnArgs: ReactRouterFunctionArgs) => {
|
||||
return (await getApp(config, args, opts)).fetch(fnArgs.request);
|
||||
return (await getApp(config, args)).fetch(fnArgs.request);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { serveStatic } from "@hono/node-server/serve-static";
|
||||
import { type DevServerOptions, default as honoViteDevServer } from "@hono/vite-dev-server";
|
||||
import type { App } from "bknd";
|
||||
import { type RuntimeBkndConfig, createRuntimeApp, type FrameworkOptions } from "bknd/adapter";
|
||||
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
|
||||
import { registerLocalMediaAdapter } from "bknd/adapter/node";
|
||||
import { devServerConfig } from "./dev-server-config";
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
@@ -30,7 +30,6 @@ ${addBkndContext ? "<!-- BKND_CONTEXT -->" : ""}
|
||||
async function createApp<ViteEnv>(
|
||||
config: ViteBkndConfig<ViteEnv> = {},
|
||||
env: ViteEnv = {} as ViteEnv,
|
||||
opts: FrameworkOptions = {},
|
||||
): Promise<App> {
|
||||
registerLocalMediaAdapter();
|
||||
return await createRuntimeApp(
|
||||
@@ -47,18 +46,13 @@ async function createApp<ViteEnv>(
|
||||
],
|
||||
},
|
||||
env,
|
||||
opts,
|
||||
);
|
||||
}
|
||||
|
||||
export function serve<ViteEnv>(
|
||||
config: ViteBkndConfig<ViteEnv> = {},
|
||||
args?: ViteEnv,
|
||||
opts?: FrameworkOptions,
|
||||
) {
|
||||
export function serve<ViteEnv>(config: ViteBkndConfig<ViteEnv> = {}, args?: ViteEnv) {
|
||||
return {
|
||||
async fetch(request: Request, env: any, ctx: ExecutionContext) {
|
||||
const app = await createApp(config, env, opts);
|
||||
const app = await createApp(config, env);
|
||||
return app.fetch(request, env, ctx);
|
||||
},
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user