Compare commits

..

1 Commits

Author SHA1 Message Date
dswbx 66959fe789 extended data canvas and init indices management 2025-05-03 17:58:58 +02:00
714 changed files with 18074 additions and 41076 deletions
-7
View File
@@ -1,7 +0,0 @@
{
"mcpServers": {
"bknd": {
"url": "http://localhost:28623/api/system/mcp"
}
}
}
+1 -6
View File
@@ -12,15 +12,10 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22.x"
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: "1.2.22"
bun-version: "1.2.5"
- name: Install dependencies
working-directory: ./app
+1 -5
View File
@@ -29,8 +29,4 @@ packages/media/.env
.idea
.vscode
.git_old
docker/tmp
.debug
.history
.aider*
.vercel
docker/tmp
-1
View File
@@ -1 +0,0 @@
22
+8 -32
View File
@@ -1,54 +1,31 @@
[![npm version](https://img.shields.io/npm/v/bknd.svg)](https://npmjs.org/package/bknd)
[![npm downloads](https://img.shields.io/npm/dm/bknd)](https://www.npmjs.com/package/bknd)
![bknd](https://raw.githubusercontent.com/bknd-io/bknd/refs/heads/main/docs/public/assets/poster.png)
![bknd](https://raw.githubusercontent.com/bknd-io/bknd/refs/heads/main/docs/_assets/poster.png)
<p align="center" width="100%">
<a href="https://stackblitz.com/github/bknd-io/bknd-demo?hideExplorer=1&embed=1&view=preview&initialPath=%2Fdata%2Fschema" target="_blank">
<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">
<strong>⭐ Live Demo</strong>
</a>
</p>
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.
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!
**For documentation and examples, please visit https://docs.bknd.io.**
> [!WARNING]
> 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
![gzipped size of bknd](https://img.shields.io/bundlejs/size/bknd?label=bknd)
![gzipped size of bknd](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/index.js?compression=gzip&label=bknd)
![gzipped size of bknd/client](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/client/index.js?compression=gzip&label=bknd/client)
![gzipped size of bknd/elements](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/elements/index.js?compression=gzip&label=bknd/elements&t=1)
![gzipped size of bknd/ui](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/index.js?compression=gzip&label=bknd/ui&t=1)
![gzipped size of bknd/elements](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/elements/index.js?compression=gzip&label=bknd/elements)
![gzipped size of bknd/ui](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/index.js?compression=gzip&label=bknd/ui)
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.
Depending on what you use, the size can be higher as additional dependencies are getting pulled in. The minimal size of a full `bknd` app as an API is around 300 kB gzipped (e.g. deployed as Cloudflare Worker).
Depending on what you use, the size can be higher as additional dependencies are getting pulled in. The minimal size of a full `bknd` app as an API is around 212 kB gzipped (e.g. deployed as Cloudflare Worker).
## Motivation
Creating digital products always requires developing both the backend (the logic) and the frontend (the appearance). Building a backend from scratch demands deep knowledge in areas such as authentication and database management. Using a backend framework can speed up initial development, but it still requires ongoing effort to work within its constraints (e.g., *"how to do X with Y?"*), which can quickly slow you down. Choosing a backend system is a tough decision, as you might not be aware of its limitations until you encounter them.
@@ -62,7 +39,6 @@ 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)
-42
View File
@@ -1,42 +0,0 @@
# ===== DB Settings =====
VITE_DB_URL=:memory:
# you can set a location for a database here, it'll overwrite the previous setting
# ideally use the ".db" folder (create it first), it's git ignored
VITE_DB_URL=file:.db/dev.db
# alternatively, you can use url/token combination
#VITE_DB_URL=
#VITE_DB_TOKEN=
# ===== DEV Server =====
# restart the dev server on every change (enable with "1")
VITE_APP_FRESH=
# displays react-scan widget (enable with "1")
VITE_DEBUG_RERENDERS=
# console logs registered routes on start (enable with "1")
VITE_SHOW_ROUTES=
# ===== Test Credentials =====
RESEND_API_KEY=
R2_TOKEN=
R2_ACCESS_KEY=
R2_SECRET_ACCESS_KEY=
R2_URL=
AWS_ACCESS_KEY=
AWS_SECRET_KEY=
AWS_S3_URL=
OAUTH_CLIENT_ID=
OAUTH_CLIENT_SECRET=
PUBLIC_POSTHOG_KEY=
PUBLIC_POSTHOG_HOST=
# ===== Internals =====
BKND_CLI_CREATE_REF=main
BKND_CLI_LOG_LEVEL=debug
BKND_MODULES_DEBUG=1
+13 -134
View File
@@ -1,148 +1,27 @@
import { afterEach, describe, test, expect, beforeAll, afterAll } from "bun:test";
import { App, createApp } from "core/test/utils";
import { afterAll, afterEach, describe, expect, test } from "bun:test";
import { App } from "../src";
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(async () => (await afterAllCleanup()) && enableConsoleLog());
afterEach(afterAllCleanup);
describe("App tests", async () => {
test("boots and pongs", async () => {
const app = new App(dummyConnection);
await app.build();
expect(await app.em.ping()).toBeTrue();
//expect(await app.data?.em.ping()).toBeTrue();
});
test("plugins", async () => {
const called: string[] = [];
const app = createApp({
config: {
auth: {
enabled: true,
},
},
options: {
plugins: [
(app) => {
expect(app).toBeDefined();
expect(app).toBeInstanceOf(App);
return {
name: "test",
schema: () => {
called.push("schema");
return proto.em(
{
posts: proto.entity("posts", {
title: proto.text(),
}),
comments: proto.entity("comments", {
content: proto.text(),
}),
users: proto.entity("users", {
email_verified: proto.boolean(),
}),
},
(fn, s) => {
fn.relation(s.comments).manyToOne(s.posts);
fn.index(s.posts).on(["title"]);
},
);
},
onBoot: async () => {
called.push("onBoot");
},
beforeBuild: async () => {
called.push("beforeBuild");
},
onBuilt: async () => {
called.push("onBuilt");
},
onServerInit: async (server) => {
called.push("onServerInit");
expect(server).toBeDefined();
expect(server).toBeInstanceOf(Hono);
},
onFirstBoot: async () => {
called.push("onFirstBoot");
},
};
},
],
/*test.only("what", async () => {
const app = new App(dummyConnection, {
auth: {
enabled: true,
},
});
await app.build();
expect(app.em.entities.map((e) => e.name)).toEqual(["users", "posts", "comments"]);
expect(app.em.indices.map((i) => i.name)).toEqual([
"idx_unique_users_email",
"idx_users_strategy",
"idx_users_strategy_value",
"idx_posts_title",
]);
expect(
app.em.relations.all.map((r) => pick(r.toJSON(), ["type", "source", "target"])),
).toEqual([
{
type: "n:1",
source: "comments",
target: "posts",
},
]);
expect(called).toEqual([
"onBoot",
"onServerInit",
"beforeBuild",
"onServerInit",
"schema",
"onFirstBoot",
"onBuilt",
]);
expect(app.plugins.size).toBe(1);
expect(Array.from(app.plugins.keys())).toEqual(["test"]);
});
test.only("drivers", async () => {
const called: string[] = [];
const app = new App(dummyConnection, undefined, {
drivers: {
email: {
send: async (to, subject, body) => {
called.push("email.send");
return {
id: "",
};
},
},
cache: {
get: async (key) => {
called.push("cache.get");
return "";
},
set: async (key, value, ttl) => {
called.push("cache.set");
},
del: async (key) => {
called.push("cache.del");
},
},
},
});
await app.build();
expect(app.drivers.cache).toBeDefined();
expect(app.drivers.email).toBeDefined();
await app.drivers.email.send("", "", "");
await app.drivers.cache.get("");
await app.drivers.cache.set("", "", 0);
await app.drivers.cache.del("");
expect(called).toEqual(["email.send", "cache.get", "cache.set", "cache.del"]);
});
await app.module.auth.build();
await app.module.data.build();
console.log(app.em.entities.map((e) => e.name));
console.log(await app.em.schema().getDiff());
});*/
});
+1 -2
View File
@@ -1,2 +1 @@
tmp/*
!tmp/.gitkeep
tmp/*
Binary file not shown.
-1
View File
@@ -1 +0,0 @@
hello
+34 -38
View File
@@ -1,57 +1,53 @@
import { expect, describe, it, beforeAll, afterAll, mock } from "bun:test";
import { expect, describe, it, beforeAll, afterAll } from "bun:test";
import * as adapter from "adapter";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test";
import { omitKeys } from "core/utils";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("adapter", () => {
it("makes config", async () => {
expect(omitKeys(await adapter.makeConfig({}), ["connection"])).toEqual({});
expect(
omitKeys(await adapter.makeConfig({}, { env: { TEST: "test" } }), ["connection"]),
).toEqual({});
it("makes config", () => {
expect(adapter.makeConfig({})).toEqual({});
expect(adapter.makeConfig({}, { env: { TEST: "test" } })).toEqual({});
// merges everything returned from `app` with the config
expect(
omitKeys(
await adapter.makeConfig(
{ app: (a) => ({ config: { server: { cors: { origin: a.env.TEST } } } }) },
{ env: { TEST: "test" } },
),
["connection"],
),
).toEqual({
config: { server: { cors: { origin: "test" } } },
});
expect(adapter.makeConfig({ app: (a) => a as any }, { env: { TEST: "test" } })).toEqual({
env: { TEST: "test" },
} as any);
});
it("allows all properties in app function", async () => {
const called = mock(() => null);
const config = await adapter.makeConfig(
it("reuses apps correctly", async () => {
const id = crypto.randomUUID();
const first = await adapter.createAdapterApp(
{
app: (env) => ({
connection: { url: "test" },
config: { server: { cors: { origin: "test" } } },
options: {
mode: "db",
},
onBuilt: () => {
called();
expect(env).toEqual({ foo: "bar" });
},
}),
initialConfig: { server: { cors: { origin: "random" } } },
},
{ foo: "bar" },
undefined,
{ id },
);
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();
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("*");
});
adapterTestSuite(bunTestRunner, {
+3 -5
View File
@@ -6,16 +6,13 @@ describe("Api", async () => {
it("should construct without options", () => {
const api = new Api();
expect(api.baseUrl).toBe("http://localhost");
// verified is true, because no token, user, headers or request given
// therefore nothing to check, auth state is verified
expect(api.isAuthVerified()).toBe(true);
expect(api.isAuthVerified()).toBe(false);
});
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(true);
expect(api.isAuthVerified()).toBe(false);
});
it("should construct from request (token)", async () => {
@@ -45,6 +42,7 @@ 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");
+4 -4
View File
@@ -1,12 +1,12 @@
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { Guard } from "../../src/auth/authorize/Guard";
import { Guard } from "../../src/auth";
import { parse } from "../../src/core/utils";
import { DataApi } from "../../src/data/api/DataApi";
import { DataController } from "../../src/data/api/DataController";
import { dataConfigSchema } from "../../src/data/data-schema";
import * as proto from "../../src/data/prototype";
import { schemaToEm } from "../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import { parse } from "core/utils/schema";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
@@ -153,7 +153,7 @@ describe("DataApi", () => {
const oneBy = api.readOneBy("posts", { where: { title: "baz" }, select: ["title"] });
const oneByRes = await oneBy;
expect(oneByRes.data).toEqual({ title: "baz" } as any);
expect(oneByRes.body.meta.items).toEqual(1);
expect(oneByRes.body.meta.count).toEqual(1);
});
it("exists/count", async () => {
@@ -202,7 +202,7 @@ describe("DataApi", () => {
{
// create many
const res = await api.createMany("posts", payload);
expect(res.data?.length).toEqual(4);
expect(res.data.length).toEqual(4);
expect(res.ok).toBeTrue();
}
+4 -37
View File
@@ -1,23 +1,9 @@
import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test";
import { describe, expect, mock, test } from "bun:test";
import type { ModuleBuildContext } from "../../src";
import { App, createApp } from "core/test/utils";
import * as proto from "data/prototype";
import { DbModuleManager } from "modules/db/DbModuleManager";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
import { App, createApp } from "../../src/App";
import * as proto from "../../src/data/prototype";
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({
@@ -34,8 +20,6 @@ describe("App", () => {
"guard",
"flags",
"logger",
"mcp",
"helper",
]);
},
},
@@ -43,7 +27,7 @@ describe("App", () => {
expect(called).toHaveBeenCalled();
const app = createApp({
config: {
initialConfig: {
data: proto
.em({
todos: proto.entity("todos", {
@@ -150,21 +134,4 @@ 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);
});
});
-47
View File
@@ -1,47 +0,0 @@
import { AppServer, serverConfigSchema } from "modules/server/AppServer";
import { describe, test, expect } from "bun:test";
describe("AppServer", () => {
test("config", () => {
{
const server = new AppServer();
expect(server).toBeDefined();
expect(server.config).toEqual({
cors: {
allow_credentials: true,
origin: "*",
allow_methods: ["GET", "POST", "PATCH", "PUT", "DELETE"],
allow_headers: ["Content-Type", "Content-Length", "Authorization", "Accept"],
},
mcp: {
enabled: false,
path: "/api/system/mcp",
logLevel: "warning",
},
});
}
{
const server = new AppServer({
cors: {
origin: "https",
allow_methods: ["GET", "POST"],
},
});
expect(server).toBeDefined();
expect(server.config).toEqual({
cors: {
allow_credentials: true,
origin: "https",
allow_methods: ["GET", "POST"],
allow_headers: ["Content-Type", "Content-Length", "Authorization", "Accept"],
},
mcp: {
enabled: false,
path: "/api/system/mcp",
logLevel: "warning",
},
});
}
});
});
-127
View File
@@ -1,127 +0,0 @@
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
View File
@@ -1,232 +0,0 @@
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
View File
@@ -1,44 +0,0 @@
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
View File
@@ -1,347 +0,0 @@
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
View File
@@ -1,119 +0,0 @@
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
View File
@@ -1,77 +0,0 @@
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
View File
@@ -1,57 +0,0 @@
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());
});
});
@@ -1,80 +0,0 @@
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);
});
});
+3 -8
View File
@@ -1,12 +1,7 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { registries } from "../../src";
import { createApp } from "core/test/utils";
import { describe, expect, test } from "bun:test";
import { createApp, registries } from "../../src";
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 () => {
/**
@@ -92,7 +87,7 @@ describe("repros", async () => {
fns.relation(schema.product_likes).manyToOne(schema.users);
},
);
const app = createApp({ config: { data: schema.toJSON() } });
const app = createApp({ initialConfig: { data: schema.toJSON() } });
await app.build();
const info = (await (await app.server.request("/api/data/info/products")).json()) as any;
+39 -34
View File
@@ -1,41 +1,46 @@
import { Authenticator } from "auth/authenticate/Authenticator";
import { describe, expect, test } from "bun:test";
import { Authenticator, type User, type UserPool } from "../../src/auth";
import { cookieConfig } from "../../src/auth/authenticate/Authenticator";
import { PasswordStrategy } from "../../src/auth/authenticate/strategies/PasswordStrategy";
import * as hash from "../../src/auth/utils/hash";
import { Default, parse } from "../../src/core/utils";
/*class MemoryUserPool implements UserPool {
constructor(private users: User[] = []) {}
async findBy(prop: "id" | "email" | "username", value: string | number) {
return this.users.find((user) => user[prop] === value);
}
async create(user: Pick<User, "email" | "password">) {
const id = this.users.length + 1;
const newUser = { ...user, id, username: user.email };
this.users.push(newUser);
return newUser;
}
}*/
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("cookie options", async () => {
console.log("parsed", parse(cookieConfig, undefined));
console.log(Default(cookieConfig, {}));
});
/*const userpool = new MemoryUserPool([
{ id: 1, email: "d", username: "test", password: await hash.sha256("test") },
]);
test("should return auth cookie string", async () => {
const auth = new Authenticator({}, null as any, {
jwt: {
secret: "secret",
fields: [],
},
cookie: {
sameSite: "strict",
},
test("sha256 login", async () => {
const auth = new Authenticator(userpool, {
password: new PasswordStrategy({
hashing: "sha256",
}),
});
const cookie = await auth.unsafeGetAuthCookie("token");
expect(cookie).toStartWith("auth=");
expect(cookie).toEndWith("HttpOnly; Secure; SameSite=Strict");
});
const { token } = await auth.login("password", { email: "d", password: "test" });
expect(token).toBeDefined();
const { iat, ...decoded } = decodeJwt<any>(token);
expect(decoded).toEqual({ id: 1, email: "d", username: "test" });
expect(await auth.verify(token)).toBe(true);
});*/
});
+19 -177
View File
@@ -1,31 +1,9 @@
import { describe, expect, test } from "bun:test";
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);
}
import { Guard } from "../../../src/auth";
describe("authorize", () => {
const read = new Permission("read", {
filterable: true,
});
const write = new Permission("write");
test("basic", async () => {
const guard = createGuard(
const guard = Guard.create(
["read", "write"],
{
admin: {
@@ -38,14 +16,14 @@ describe("authorize", () => {
role: "admin",
};
expect(guard.granted(read, user)).toBeUndefined();
expect(guard.granted(write, user)).toBeUndefined();
expect(guard.granted("read", user)).toBe(true);
expect(guard.granted("write", user)).toBe(true);
expect(() => guard.granted(new Permission("something"), {})).toThrow();
expect(() => guard.granted("something")).toThrow();
});
test("with default", async () => {
const guard = createGuard(
const guard = Guard.create(
["read", "write"],
{
admin: {
@@ -59,26 +37,26 @@ describe("authorize", () => {
{ enabled: true },
);
expect(guard.granted(read, {})).toBeUndefined();
expect(() => guard.granted(write, {})).toThrow();
expect(guard.granted("read")).toBe(true);
expect(guard.granted("write")).toBe(false);
const user = {
role: "admin",
};
expect(guard.granted(read, user)).toBeUndefined();
expect(guard.granted(write, user)).toBeUndefined();
expect(guard.granted("read", user)).toBe(true);
expect(guard.granted("write", user)).toBe(true);
});
test("guard implicit allow", async () => {
const guard = createGuard([], {}, { enabled: false });
const guard = Guard.create([], {}, { enabled: false });
expect(guard.granted(read, {})).toBeUndefined();
expect(guard.granted(write, {})).toBeUndefined();
expect(guard.granted("read")).toBe(true);
expect(guard.granted("write")).toBe(true);
});
test("role implicit allow", async () => {
const guard = createGuard(["read", "write"], {
const guard = Guard.create(["read", "write"], {
admin: {
implicit_allow: true,
},
@@ -88,12 +66,12 @@ describe("authorize", () => {
role: "admin",
};
expect(guard.granted(read, user)).toBeUndefined();
expect(guard.granted(write, user)).toBeUndefined();
expect(guard.granted("read", user)).toBe(true);
expect(guard.granted("write", user)).toBe(true);
});
test("guard with guest role implicit allow", async () => {
const guard = createGuard(["read", "write"], {
const guard = Guard.create(["read", "write"], {
guest: {
implicit_allow: true,
is_default: true,
@@ -101,143 +79,7 @@ describe("authorize", () => {
});
expect(guard.getUserRole()?.name).toBe("guest");
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();
});
expect(guard.granted("read")).toBe(true);
expect(guard.granted("write")).toBe(true);
});
});
@@ -1,327 +0,0 @@
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);
});
});
});
@@ -1,20 +0,0 @@
import { describe, it, expect } from "bun:test";
import { SystemController } from "modules/server/SystemController";
import { createApp } from "core/test/utils";
import type { CreateAppConfig } from "App";
import { getPermissionRoutes } from "auth/middlewares/permission.middleware";
async function makeApp(config: Partial<CreateAppConfig> = {}) {
const app = createApp(config);
await app.build();
return app;
}
describe.skip("SystemController", () => {
it("...", async () => {
const app = await makeApp();
const controller = new SystemController(app);
const hono = controller.getController();
console.log(getPermissionRoutes(hono));
});
});
@@ -1,543 +0,0 @@
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 -1
View File
@@ -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 "core/utils/test";
import { disableConsoleLog, enableConsoleLog } from "../helper";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
+9 -11
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { Registry } from "core/registry/Registry";
import { s } from "core/utils/schema";
import { type TObject, type TString, Type } from "@sinclair/typebox";
import { Registry } from "core";
type Constructor<T> = new (...args: any[]) => T;
@@ -11,7 +11,7 @@ class What {
return null;
}
getType() {
return s.object({ type: s.string() });
return Type.Object({ type: Type.String() });
}
}
class What2 extends What {}
@@ -19,7 +19,7 @@ class NotAllowed {}
type Test1 = {
cls: new (...args: any[]) => What;
schema: s.ObjectSchema<{ type: s.StringSchema }>;
schema: TObject<{ type: TString }>;
enabled: boolean;
};
@@ -28,7 +28,7 @@ describe("Registry", () => {
const registry = new Registry<Test1>().set({
first: {
cls: What,
schema: s.object({ type: s.string(), what: s.string() }),
schema: Type.Object({ type: Type.String(), what: Type.String() }),
enabled: true,
},
} satisfies Record<string, Test1>);
@@ -37,7 +37,7 @@ describe("Registry", () => {
expect(item).toBeDefined();
expect(item?.cls).toBe(What);
const second = s.object({ type: s.string(), what: s.string() });
const second = Type.Object({ type: Type.String(), what: Type.String() });
registry.add("second", {
cls: What2,
schema: second,
@@ -46,7 +46,7 @@ describe("Registry", () => {
// @ts-ignore
expect(registry.get("second").schema).toEqual(second);
const third = s.object({ type: s.string({ default: "1" }), what22: s.string() });
const third = Type.Object({ type: Type.String({ default: "1" }), what22: Type.String() });
registry.add("third", {
// @ts-expect-error
cls: NotAllowed,
@@ -56,7 +56,7 @@ describe("Registry", () => {
// @ts-ignore
expect(registry.get("third").schema).toEqual(third);
const fourth = s.object({ type: s.number(), what22: s.string() });
const fourth = Type.Object({ type: Type.Number(), what22: Type.String() });
registry.add("fourth", {
cls: What,
// @ts-expect-error
@@ -81,8 +81,6 @@ describe("Registry", () => {
registry.register("what2", What2);
expect(registry.get("what2")).toBeDefined();
expect(registry.get("what2").cls).toBe(What2);
expect(JSON.stringify(registry.get("what2").schema)).toEqual(
JSON.stringify(What2.prototype.getType()),
);
expect(registry.get("what2").schema).toEqual(What2.prototype.getType());
});
});
+32 -46
View File
@@ -1,11 +1,11 @@
import { describe, expect, test } from "bun:test";
import { s } from "core/utils/schema";
import { SchemaObject } from "core/object/SchemaObject";
import { SchemaObject } from "../../../src/core";
import { Type } from "@sinclair/typebox";
describe("SchemaObject", async () => {
test("basic", async () => {
const m = new SchemaObject(
s.strictObject({ a: s.string({ default: "b" }) }),
Type.Object({ a: Type.String({ default: "b" }) }),
{ a: "test" },
{
forceParse: true,
@@ -23,19 +23,19 @@ describe("SchemaObject", async () => {
test("patch", async () => {
const m = new SchemaObject(
s.strictObject({
s: s.strictObject(
Type.Object({
s: Type.Object(
{
a: s.string({ default: "b" }),
b: s.strictObject(
a: Type.String({ default: "b" }),
b: Type.Object(
{
c: s.string({ default: "d" }),
e: s.string({ default: "f" }),
c: Type.String({ default: "d" }),
e: Type.String({ default: "f" }),
},
{ default: {} },
),
},
{ default: {} },
{ default: {}, additionalProperties: false },
),
}),
);
@@ -44,7 +44,7 @@ describe("SchemaObject", async () => {
await m.patch("s.a", "c");
// non-existing path on no additional properties
expect(m.patch("s.s.s", "c")).rejects.toThrow();
expect(() => m.patch("s.s.s", "c")).toThrow();
// wrong type
expect(() => m.patch("s.a", 1)).toThrow();
@@ -58,8 +58,8 @@ describe("SchemaObject", async () => {
test("patch array", async () => {
const m = new SchemaObject(
s.strictObject({
methods: s.array(s.string(), { default: ["GET", "PATCH"] }),
Type.Object({
methods: Type.Array(Type.String(), { default: ["GET", "PATCH"] }),
}),
);
expect(m.get()).toEqual({ methods: ["GET", "PATCH"] });
@@ -75,13 +75,13 @@ describe("SchemaObject", async () => {
test("remove", async () => {
const m = new SchemaObject(
s.object({
s: s.object(
Type.Object({
s: Type.Object(
{
a: s.string({ default: "b" }),
b: s.object(
a: Type.String({ default: "b" }),
b: Type.Object(
{
c: s.string({ default: "d" }),
c: Type.String({ default: "d" }),
},
{ default: {} },
),
@@ -107,8 +107,8 @@ describe("SchemaObject", async () => {
test("set", async () => {
const m = new SchemaObject(
s.strictObject({
methods: s.array(s.string(), { default: ["GET", "PATCH"] }),
Type.Object({
methods: Type.Array(Type.String(), { default: ["GET", "PATCH"] }),
}),
);
expect(m.get()).toEqual({ methods: ["GET", "PATCH"] });
@@ -124,8 +124,8 @@ describe("SchemaObject", async () => {
let called = false;
let result: any;
const m = new SchemaObject(
s.strictObject({
methods: s.array(s.string(), { default: ["GET", "PATCH"] }),
Type.Object({
methods: Type.Array(Type.String(), { default: ["GET", "PATCH"] }),
}),
undefined,
{
@@ -145,8 +145,8 @@ describe("SchemaObject", async () => {
test("listener: onBeforeUpdate", async () => {
let called = false;
const m = new SchemaObject(
s.strictObject({
methods: s.array(s.string(), { default: ["GET", "PATCH"] }),
Type.Object({
methods: Type.Array(Type.String(), { default: ["GET", "PATCH"] }),
}),
undefined,
{
@@ -167,7 +167,7 @@ describe("SchemaObject", async () => {
});
test("throwIfRestricted", async () => {
const m = new SchemaObject(s.strictObject({}), undefined, {
const m = new SchemaObject(Type.Object({}), undefined, {
restrictPaths: ["a.b"],
});
@@ -179,13 +179,13 @@ describe("SchemaObject", async () => {
test("restriction bypass", async () => {
const m = new SchemaObject(
s.strictObject({
s: s.strictObject(
Type.Object({
s: Type.Object(
{
a: s.string({ default: "b" }),
b: s.strictObject(
a: Type.String({ default: "b" }),
b: Type.Object(
{
c: s.string({ default: "d" }),
c: Type.String({ default: "d" }),
},
{ default: {} },
),
@@ -205,21 +205,7 @@ describe("SchemaObject", async () => {
expect(m.get()).toEqual({ s: { a: "b", b: { c: "e" } } });
});
const dataEntitiesSchema = s.strictObject({
entities: s.record(
s.object({
fields: s.record(
s.object({
type: s.string(),
config: s.object({}).optional(),
}),
),
config: s.record(s.string()).optional(),
}),
),
});
/* const dataEntitiesSchema = Type.Object(
const dataEntitiesSchema = Type.Object(
{
entities: Type.Object(
{},
@@ -244,7 +230,7 @@ describe("SchemaObject", async () => {
{
additionalProperties: false,
},
); */
);
test("patch safe object, overwrite", async () => {
const data = {
entities: {
+1 -11
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { type ObjectQuery, convert, validate } from "core/object/query/object-query";
import { type ObjectQuery, convert, validate } from "../../../src/core/object/query/object-query";
describe("object-query", () => {
const q: ObjectQuery = { name: "Michael" };
@@ -66,14 +66,4 @@ 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
View File
@@ -1,120 +0,0 @@
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" } });
});
});
+4 -209
View File
@@ -194,182 +194,6 @@ 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 () => {
@@ -424,7 +248,7 @@ describe("Core Utils", async () => {
expect(utils.getContentName(request)).toBe(name);
});
test("detectImageDimensions", async () => {
test.only("detectImageDimensions", async () => {
// wrong
// @ts-expect-error
expect(utils.detectImageDimensions(new ArrayBuffer(), "text/plain")).rejects.toThrow();
@@ -440,44 +264,15 @@ 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("formats local time", () => {
test.only("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);
});
});
});
+87 -36
View File
@@ -1,24 +1,72 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { Guard } from "../../src/auth/authorize/Guard";
import { parse } from "core/utils/schema";
import { Guard } from "../../src/auth";
import { parse } from "../../src/core/utils";
import {
Entity,
type EntityData,
EntityManager,
ManyToOneRelation,
type MutatorResponse,
type RepositoryResponse,
TextField,
} from "../../src/data";
import { DataController } from "../../src/data/api/DataController";
import { dataConfigSchema } from "../../src/data/data-schema";
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";
import { TextField } from "data/fields";
import { ManyToOneRelation } from "data/relations";
import { disableConsoleLog, enableConsoleLog, getDummyConnection } from "../helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
beforeAll(() => disableConsoleLog());
beforeAll(() => disableConsoleLog(["log", "warn"]));
afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
const dataConfig = parse(dataConfigSchema, {});
describe("[data] DataController", async () => {
test("repoResult", async () => {
const em = new EntityManager<any>([], dummyConnection);
const ctx: any = { em, guard: new Guard() };
const controller = new DataController(ctx, dataConfig);
const res = controller.repoResult({
entity: null as any,
data: [] as any,
sql: "",
parameters: [] as any,
result: [] as any,
meta: {
total: 0,
count: 0,
items: 0,
},
});
expect(res).toEqual({
meta: {
total: 0,
count: 0,
items: 0,
},
data: [],
});
});
test("mutatorResult", async () => {
const em = new EntityManager([], dummyConnection);
const ctx: any = { em, guard: new Guard() };
const controller = new DataController(ctx, dataConfig);
const res = controller.mutatorResult({
entity: null as any,
data: [] as any,
sql: "",
parameters: [] as any,
result: [] as any,
});
expect(res).toEqual({
data: [],
});
});
describe("getController", async () => {
const users = new Entity("users", [
new TextField("name", { required: true }),
@@ -72,7 +120,8 @@ describe("[data] DataController", async () => {
method: "POST",
body: JSON.stringify(_user),
});
const result = (await res.json()) as MutatorResultJSON;
//console.log("res", { _user }, res);
const result = (await res.json()) as MutatorResponse;
const { id, ...data } = result.data as any;
expect(res.status).toBe(201);
@@ -86,7 +135,7 @@ describe("[data] DataController", async () => {
method: "POST",
body: JSON.stringify(_post),
});
const result = (await res.json()) as MutatorResultJSON;
const result = (await res.json()) as MutatorResponse;
const { id, ...data } = result.data as any;
expect(res.status).toBe(201);
@@ -97,13 +146,13 @@ describe("[data] DataController", async () => {
test("/:entity (read many)", async () => {
const res = await app.request("/entity/users");
const data = (await res.json()) as RepositoryResultJSON;
const data = (await res.json()) as RepositoryResponse;
//expect(data.meta.total).toBe(3);
//expect(data.meta.count).toBe(3);
expect(data.meta.total).toBe(3);
expect(data.meta.count).toBe(3);
expect(data.meta.items).toBe(3);
expect(data.data.length).toBe(3);
expect(data.data[0]?.name).toBe("foo");
expect(data.data[0].name).toBe("foo");
});
test("/:entity/query (func query)", async () => {
@@ -116,32 +165,33 @@ describe("[data] DataController", async () => {
where: { bio: { $isnull: 1 } },
}),
});
const data = (await res.json()) as RepositoryResultJSON;
const data = (await res.json()) as RepositoryResponse;
//expect(data.meta.total).toBe(3);
//expect(data.meta.count).toBe(1);
expect(data.meta.total).toBe(3);
expect(data.meta.count).toBe(1);
expect(data.meta.items).toBe(1);
expect(data.data.length).toBe(1);
expect(data.data[0]?.name).toBe("bar");
expect(data.data[0].name).toBe("bar");
});
test("/:entity (read many, paginated)", async () => {
const res = await app.request("/entity/users?limit=1&offset=2");
const data = (await res.json()) as RepositoryResultJSON;
const data = (await res.json()) as RepositoryResponse;
//expect(data.meta.total).toBe(3);
//expect(data.meta.count).toBe(3);
expect(data.meta.total).toBe(3);
expect(data.meta.count).toBe(3);
expect(data.meta.items).toBe(1);
expect(data.data.length).toBe(1);
expect(data.data[0]?.name).toBe("baz");
expect(data.data[0].name).toBe("baz");
});
test("/:entity/:id (read one)", async () => {
const res = await app.request("/entity/users/3");
const data = (await res.json()) as RepositoryResultJSON<EntityData>;
const data = (await res.json()) as RepositoryResponse<EntityData>;
console.log("data", data);
//expect(data.meta.total).toBe(3);
//expect(data.meta.count).toBe(1);
expect(data.meta.total).toBe(3);
expect(data.meta.count).toBe(1);
expect(data.meta.items).toBe(1);
expect(data.data).toEqual({ id: 3, ...fixtures.users[2] });
});
@@ -151,7 +201,7 @@ describe("[data] DataController", async () => {
method: "PATCH",
body: JSON.stringify({ name: "new name" }),
});
const { data } = (await res.json()) as MutatorResultJSON;
const { data } = (await res.json()) as MutatorResponse;
expect(res.ok).toBe(true);
expect(data as any).toEqual({ id: 3, ...fixtures.users[2], name: "new name" });
@@ -159,26 +209,27 @@ describe("[data] DataController", async () => {
test("/:entity/:id/:reference (read references)", async () => {
const res = await app.request("/entity/users/1/posts");
const data = (await res.json()) as RepositoryResultJSON;
const data = (await res.json()) as RepositoryResponse;
console.log("data", data);
//expect(data.meta.total).toBe(2);
//expect(data.meta.count).toBe(1);
expect(data.meta.total).toBe(2);
expect(data.meta.count).toBe(1);
expect(data.meta.items).toBe(1);
expect(data.data.length).toBe(1);
expect(data.data[0]?.content).toBe("post 1");
expect(data.data[0].content).toBe("post 1");
});
test("/:entity/:id (delete one)", async () => {
const res = await app.request("/entity/posts/2", {
method: "DELETE",
});
const { data } = (await res.json()) as RepositoryResultJSON<EntityData>;
const { data } = (await res.json()) as RepositoryResponse<EntityData>;
expect(data).toEqual({ id: 2, ...fixtures.posts[1] });
// verify
const res2 = await app.request("/entity/posts");
const data2 = (await res2.json()) as RepositoryResultJSON;
//expect(data2.meta.total).toBe(1);
const data2 = (await res2.json()) as RepositoryResponse;
expect(data2.meta.total).toBe(1);
});
});
});
@@ -1,18 +1,25 @@
import { describe, test, expect } from "bun:test";
import { getDummyConnection } from "../helper";
import { WhereBuilder, type WhereQuery } from "data/entities/query/WhereBuilder";
import { describe, expect, test } from "bun:test";
import { Value, _jsonp } from "../../src/core/utils";
import { type RepoQuery, WhereBuilder, type WhereQuery, querySchema } from "../../src/data";
import type { RepoQueryIn } from "../../src/data/server/data-query-impl";
import { getDummyConnection } from "./helper";
function qb() {
const c = getDummyConnection();
const kysely = c.dummyConnection.kysely;
return kysely.selectFrom("t").selectAll();
}
function compile(q: WhereQuery) {
const { sql, parameters } = WhereBuilder.addClause(qb(), q).compile();
return { sql, parameters };
}
const decode = (input: RepoQueryIn, expected: RepoQuery) => {
const result = Value.Decode(querySchema, input);
expect(result).toEqual(expected);
};
describe("data-query-impl", () => {
function qb() {
const c = getDummyConnection();
const kysely = c.dummyConnection.kysely;
return kysely.selectFrom("t").selectAll();
}
function compile(q: WhereQuery) {
const { sql, parameters } = WhereBuilder.addClause(qb(), q).compile();
return { sql, parameters };
}
describe("WhereBuilder", () => {
test("single validation", () => {
const tests: [WhereQuery, string, any[]][] = [
[{ name: "Michael", age: 40 }, '("name" = ? and "age" = ?)', ["Michael", 40]],
@@ -87,4 +94,64 @@ describe("WhereBuilder", () => {
expect(keys).toEqual(expectedKeys);
}
});
test("with", () => {
decode({ with: ["posts"] }, { with: { posts: {} } });
decode({ with: { posts: {} } }, { with: { posts: {} } });
decode({ with: { posts: { limit: 1 } } }, { with: { posts: { limit: 1 } } });
decode(
{
with: {
posts: {
with: {
images: {
select: ["id"],
},
},
},
},
},
{
with: {
posts: {
with: {
images: {
select: ["id"],
},
},
},
},
},
);
// over http
{
const output = { with: { images: {} } };
decode({ with: "images" }, output);
decode({ with: '["images"]' }, output);
decode({ with: ["images"] }, output);
decode({ with: { images: {} } }, output);
}
{
const output = { with: { images: {}, comments: {} } };
decode({ with: "images,comments" }, output);
decode({ with: ["images", "comments"] }, output);
decode({ with: '["images", "comments"]' }, output);
decode({ with: { images: {}, comments: {} } }, output);
}
});
});
describe("data-query-impl: Typebox", () => {
test("sort", async () => {
const _dflt = { sort: { by: "id", dir: "asc" } };
decode({ sort: "" }, _dflt);
decode({ sort: "name" }, { sort: { by: "name", dir: "asc" } });
decode({ sort: "-name" }, { sort: { by: "name", dir: "desc" } });
decode({ sort: "-posts.name" }, { sort: { by: "posts.name", dir: "desc" } });
decode({ sort: "-1name" }, _dflt);
decode({ sort: { by: "name", dir: "desc" } }, { sort: { by: "name", dir: "desc" } });
});
});
+20 -21
View File
@@ -1,6 +1,12 @@
import { afterAll, describe, expect, test } from "bun:test";
import { Entity, EntityManager } from "data/entities";
import { TextField, PrimaryField, NumberField } from "data/fields";
import {
Entity,
EntityManager,
NumberField,
PrimaryField,
Repository,
TextField,
} from "../../src/data";
import { getDummyConnection } from "./helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
@@ -28,12 +34,19 @@ describe("some tests", async () => {
test("findId", async () => {
const query = await em.repository(users).findId(1);
/*const { result, total, count, time } = query;
console.log("query", query.result, {
result,
total,
count,
time,
});*/
expect(query.sql).toBe(
'select "users"."id" as "id", "users"."username" as "username", "users"."email" as "email" from "users" where "id" = ? order by "users"."id" asc limit ? offset ?',
'select "users"."id" as "id", "users"."username" as "username", "users"."email" as "email" from "users" where "id" = ? limit ?',
);
expect(query.parameters).toEqual([1, 1, 0]);
expect(query.data).toBeUndefined();
expect(query.parameters).toEqual([1, 1]);
expect(query.result).toEqual([]);
});
test("findMany", async () => {
@@ -43,7 +56,7 @@ describe("some tests", async () => {
'select "users"."id" as "id", "users"."username" as "username", "users"."email" as "email" from "users" order by "users"."id" asc limit ? offset ?',
);
expect(query.parameters).toEqual([10, 0]);
expect(query.data).toEqual([]);
expect(query.result).toEqual([]);
});
test("findMany with number", async () => {
@@ -53,7 +66,7 @@ describe("some tests", async () => {
'select "posts"."id" as "id", "posts"."title" as "title", "posts"."content" as "content", "posts"."created_at" as "created_at", "posts"."likes" as "likes" from "posts" order by "posts"."id" asc limit ? offset ?',
);
expect(query.parameters).toEqual([10, 0]);
expect(query.data).toEqual([]);
expect(query.result).toEqual([]);
});
test("try adding an existing field name", async () => {
@@ -97,18 +110,4 @@ describe("some tests", async () => {
new EntityManager([entity, entity2], connection);
}).toThrow();
});
test("primary uuid", async () => {
const entity = new Entity("users", [
new PrimaryField("id", { format: "uuid" }),
new TextField("username"),
]);
const em = new EntityManager([entity], getDummyConnection().dummyConnection);
await em.schema().sync({ force: true });
const mutator = em.mutator(entity);
const data = await mutator.insertOne({ username: "test" });
expect(data.data.id).toBeDefined();
expect(data.data.id).toBeString();
});
});
+1 -1
View File
@@ -2,7 +2,7 @@ import { unlink } from "node:fs/promises";
import type { SqliteDatabase } from "kysely";
// @ts-ignore
import Database from "libsql";
import { SqliteLocalConnection } from "data/connection/sqlite/SqliteLocalConnection";
import { SqliteLocalConnection } from "../../src/data";
export function getDummyDatabase(memory: boolean = true): {
dummyDb: SqliteDatabase;
+8 -5
View File
@@ -1,10 +1,13 @@
// eslint-disable-next-line import/no-unresolved
import { afterAll, describe, expect, test } from "bun:test";
import { Entity } from "data/entities";
import { EntityManager } from "data/entities/EntityManager";
import { ManyToOneRelation } from "data/relations";
import { NumberField, TextField } from "data/fields";
import { SchemaManager } from "data/schema/SchemaManager";
import {
Entity,
EntityManager,
ManyToOneRelation,
NumberField,
SchemaManager,
TextField,
} from "../../src/data";
import { getDummyConnection } from "./helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
+6 -7
View File
@@ -1,8 +1,7 @@
// eslint-disable-next-line import/no-unresolved
import { afterAll, describe, expect, test } from "bun:test";
import { Entity, EntityManager } from "data/entities";
import { NumberField, TextField } from "data/fields";
import { TransformPersistFailedException } from "data/errors";
import { Entity, EntityManager, Mutator, NumberField, TextField } from "../../src/data";
import { TransformPersistFailedException } from "../../src/data/errors";
import { getDummyConnection } from "./helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
@@ -46,7 +45,7 @@ describe("Mutator simple", async () => {
},
});
expect(query.data).toEqual([{ id: 1, label: "test", count: 1 }]);
expect(query.result).toEqual([{ id: 1, label: "test", count: 1 }]);
});
test("update inserted row", async () => {
@@ -88,7 +87,7 @@ describe("Mutator simple", async () => {
expect(mutation.data).toEqual({ id, label: "new label", count: 100 });
const query2 = await em.repository(items).findId(id);
expect(query2.data).toBeUndefined();
expect(query2.result.length).toBe(0);
});
test("validation: insert incomplete row", async () => {
@@ -178,13 +177,13 @@ describe("Mutator simple", async () => {
});
test("insertMany", async () => {
const oldCount = (await em.repo(items).count()).data.count;
const oldCount = (await em.repo(items).count()).count;
const inserts = [{ label: "insert 1" }, { label: "insert 2" }];
const { data } = await em.mutator(items).insertMany(inserts);
expect(data.length).toBe(2);
expect(data.map((d) => ({ label: d.label }))).toEqual(inserts);
const newCount = (await em.repo(items).count()).data.count;
const newCount = (await em.repo(items).count()).count;
expect(newCount).toBe(oldCount + inserts.length);
const { data: data2 } = await em.repo(items).findMany({ offset: oldCount });
+2 -4
View File
@@ -1,8 +1,6 @@
import { afterAll, expect as bunExpect, describe, test } from "bun:test";
import { stripMark } from "core/utils/schema";
import { Entity, EntityManager } from "data/entities";
import { TextField } from "data/fields";
import { PolymorphicRelation } from "data/relations";
import { stripMark } from "../../src/core/utils";
import { Entity, EntityManager, PolymorphicRelation, TextField } from "../../src/data";
import { getDummyConnection } from "./helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
+12 -14
View File
@@ -2,20 +2,19 @@ import { describe, expect, test } from "bun:test";
import {
BooleanField,
DateField,
Entity,
EntityIndex,
EntityManager,
EnumField,
JsonField,
NumberField,
TextField,
EntityIndex,
} from "data/fields";
import { Entity, EntityManager } from "data/entities";
import {
ManyToManyRelation,
ManyToOneRelation,
NumberField,
OneToOneRelation,
PolymorphicRelation,
} from "data/relations";
import { DummyConnection } from "data/connection/DummyConnection";
TextField,
} from "../../src/data";
import { DummyConnection } from "../../src/data/connection/DummyConnection";
import {
FieldPrototype,
type FieldSchema,
@@ -33,8 +32,8 @@ import {
number,
relation,
text,
} from "data/prototype";
import { MediaField } from "media/MediaField";
} from "../../src/data/prototype";
import { MediaField } from "../../src/media/MediaField";
describe("prototype", () => {
test("...", () => {
@@ -102,8 +101,7 @@ describe("prototype", () => {
type Posts = Schema<typeof posts2>;
// @todo: check
//expect(posts1.toJSON()).toEqual(posts2.toJSON());
expect(posts1.toJSON()).toEqual(posts2.toJSON());
});
test("test example", async () => {
@@ -297,9 +295,9 @@ describe("prototype", () => {
new Entity("posts", [new TextField("name"), new TextField("slug", { required: true })]),
new Entity("comments", [new TextField("some")]),
new Entity("users", [new TextField("email")]),
] as const;
];
const _em2 = new EntityManager(
[...es],
es,
new DummyConnection(),
[new ManyToOneRelation(es[0], es[1]), new ManyToOneRelation(es[0], es[2])],
[
+10 -11
View File
@@ -1,14 +1,13 @@
// eslint-disable-next-line import/no-unresolved
import { afterAll, describe, expect, test } from "bun:test";
import { Entity, EntityManager } from "data/entities";
import { TextField } from "data/fields";
import { Entity, EntityManager, TextField } from "../../src/data";
import {
ManyToManyRelation,
ManyToOneRelation,
OneToOneRelation,
PolymorphicRelation,
RelationField,
} from "data/relations";
} from "../../src/data/relations";
import { getDummyConnection } from "./helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
@@ -78,7 +77,7 @@ describe("Relations", async () => {
const em = new EntityManager(entities, dummyConnection, relations);
// verify naming
const rel = em.relations.all[0]!;
const rel = em.relations.all[0];
expect(rel.source.entity.name).toBe(posts.name);
expect(rel.source.reference).toBe(posts.name);
expect(rel.target.entity.name).toBe(users.name);
@@ -90,11 +89,11 @@ describe("Relations", async () => {
// verify low level relation
expect(em.relationsOf(users.name).length).toBe(1);
expect(em.relationsOf(users.name).length).toBe(1);
expect(em.relationsOf(users.name)[0]!.source.entity).toBe(posts);
expect(em.relationsOf(users.name)[0].source.entity).toBe(posts);
expect(posts.field("author_id")).toBeInstanceOf(RelationField);
expect(em.relationsOf(users.name).length).toBe(1);
expect(em.relationsOf(users.name).length).toBe(1);
expect(em.relationsOf(users.name)[0]!.source.entity).toBe(posts);
expect(em.relationsOf(users.name)[0].source.entity).toBe(posts);
// verify high level relation (from users)
const userPostsRel = em.relationOf(users.name, "posts");
@@ -192,7 +191,7 @@ describe("Relations", async () => {
const em = new EntityManager(entities, dummyConnection, relations);
// verify naming
const rel = em.relations.all[0]!;
const rel = em.relations.all[0];
expect(rel.source.entity.name).toBe(users.name);
expect(rel.source.reference).toBe(users.name);
expect(rel.target.entity.name).toBe(settings.name);
@@ -203,8 +202,8 @@ describe("Relations", async () => {
expect(em.relationsOf(users.name).length).toBe(1);
expect(em.relationsOf(users.name).length).toBe(1);
expect(em.relationsOf(users.name)[0]!.source.entity).toBe(users);
expect(em.relationsOf(users.name)[0]!.target.entity).toBe(settings);
expect(em.relationsOf(users.name)[0].source.entity).toBe(users);
expect(em.relationsOf(users.name)[0].target.entity).toBe(settings);
// verify high level relation (from users)
const userSettingRel = em.relationOf(users.name, settings.name);
@@ -324,7 +323,7 @@ describe("Relations", async () => {
);
// mutation info
expect(relations[0]!.helper(posts.name)!.getMutationInfo()).toEqual({
expect(relations[0].helper(posts.name)!.getMutationInfo()).toEqual({
reference: "categories",
local_field: undefined,
$set: false,
@@ -335,7 +334,7 @@ describe("Relations", async () => {
cardinality: undefined,
relation_type: "m:n",
});
expect(relations[0]!.helper(categories.name)!.getMutationInfo()).toEqual({
expect(relations[0].helper(categories.name)!.getMutationInfo()).toEqual({
reference: "posts",
local_field: undefined,
$set: false,
+6 -2
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { Entity } from "data/entities";
import { NumberField, TextField } from "data/fields";
import { Entity, NumberField, TextField } from "data";
import * as p from "data/prototype";
describe("[data] Entity", async () => {
const entity = new Entity("test", [
@@ -47,4 +47,8 @@ describe("[data] Entity", async () => {
entity.addField(field);
expect(entity.getField("new_field")).toBe(field);
});
test.only("types", async () => {
console.log(entity.toTypes());
});
});
@@ -1,8 +1,12 @@
import { afterAll, describe, expect, test } from "bun:test";
import { Entity, EntityManager } from "data/entities";
import { ManyToManyRelation, ManyToOneRelation } from "data/relations";
import { SchemaManager } from "data/schema/SchemaManager";
import { UnableToConnectException } from "data/errors";
import {
Entity,
EntityManager,
ManyToManyRelation,
ManyToOneRelation,
SchemaManager,
} from "../../../src/data";
import { UnableToConnectException } from "../../../src/data/errors";
import { getDummyConnection } from "../helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
+4 -9
View File
@@ -1,15 +1,10 @@
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 { afterAll, describe, expect, test } from "bun:test";
import { Entity, EntityManager, ManyToOneRelation, TextField } from "../../../src/data";
import { JoinBuilder } from "../../../src/data/entities/query/JoinBuilder";
import { getDummyConnection } from "../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
afterAll(afterAllCleanup);
describe("[data] JoinBuilder", async () => {
test("missing relation", async () => {
+10 -12
View File
@@ -1,24 +1,22 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { afterAll, describe, expect, test } from "bun:test";
import type { EventManager } from "../../../src/core/events";
import { Entity, EntityManager } from "data/entities";
import {
Entity,
EntityManager,
ManyToOneRelation,
MutatorEvents,
NumberField,
OneToOneRelation,
RelationField,
type RelationField,
RelationMutator,
} from "data/relations";
import { NumberField, TextField } from "data/fields";
import * as proto from "data/prototype";
import { getDummyConnection } from "../../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import { MutatorEvents } from "data/events";
TextField,
} from "../../../src/data";
import * as proto from "../../../src/data/prototype";
import { getDummyConnection } from "../helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterAll(afterAllCleanup);
beforeAll(() => disableConsoleLog());
afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
describe("[data] Mutator (base)", async () => {
const entity = new Entity("items", [
new TextField("label", { required: true }),
+137 -40
View File
@@ -1,18 +1,23 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { afterAll, 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";
import { Entity, EntityManager } from "data/entities";
import { ManyToOneRelation } from "data/relations";
import { RepositoryEvents } from "data/events";
import { Perf } from "core/utils";
import {
Entity,
EntityManager,
LibsqlConnection,
ManyToOneRelation,
RepositoryEvents,
TextField,
entity as $entity,
text as $text,
em as $em,
} from "data";
import { getDummyConnection } from "../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
type E = Kysely<any> | Transaction<any>;
const { dummyConnection, afterAllCleanup } = getDummyConnection();
beforeAll(() => disableConsoleLog());
afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
afterAll(afterAllCleanup);
async function sleep(ms: number) {
return new Promise((resolve) => {
@@ -21,6 +26,120 @@ async function sleep(ms: number) {
}
describe("[Repository]", async () => {
test.skip("bulk", async () => {
//const connection = dummyConnection;
//const connection = getLocalLibsqlConnection();
const credentials = null as any; // @todo: determine what to do here
const connection = new LibsqlConnection(credentials);
const em = new EntityManager([], connection);
/*const emLibsql = new EntityManager([], {
url: connection.url.replace("https", "libsql"),
authToken: connection.authToken,
});*/
const table = "posts";
const client = connection.getClient();
if (!client) {
console.log("Cannot perform test without libsql connection");
return;
}
const conn = em.connection.kysely;
const selectQ = (e: E) => e.selectFrom(table).selectAll().limit(2);
const countQ = (e: E) => e.selectFrom(table).select(e.fn.count("*").as("count"));
async function executeTransaction(em: EntityManager<any>) {
return await em.connection.kysely.transaction().execute(async (e) => {
const res = await selectQ(e).execute();
const count = await countQ(e).execute();
return [res, count];
});
}
async function executeBatch(em: EntityManager<any>) {
const queries = [selectQ(conn), countQ(conn)];
return await em.connection.batchQuery(queries);
}
async function executeSingleKysely(em: EntityManager<any>) {
const res = await selectQ(conn).execute();
const count = await countQ(conn).execute();
return [res, count];
}
async function executeSingleClient(em: EntityManager<any>) {
const q1 = selectQ(conn).compile();
const res = await client.execute({
sql: q1.sql,
args: q1.parameters as any,
});
const q2 = countQ(conn).compile();
const count = await client.execute({
sql: q2.sql,
args: q2.parameters as any,
});
return [res, count];
}
const transaction = await executeTransaction(em);
const batch = await executeBatch(em);
expect(batch).toEqual(transaction as any);
const testperf = false;
if (testperf) {
const times = 5;
const exec = async (
name: string,
fn: (em: EntityManager<any>) => Promise<any>,
em: EntityManager<any>,
) => {
const res = await Perf.execute(() => fn(em), times);
await sleep(1000);
const info = {
name,
total: res.total.toFixed(2),
avg: (res.total / times).toFixed(2),
first: res.marks[0].time.toFixed(2),
last: res.marks[res.marks.length - 1].time.toFixed(2),
};
console.log(info.name, info, res.marks);
return info;
};
const data: any[] = [];
data.push(await exec("transaction.http", executeTransaction, em));
data.push(await exec("bulk.http", executeBatch, em));
data.push(await exec("singleKy.http", executeSingleKysely, em));
data.push(await exec("singleCl.http", executeSingleClient, em));
/*data.push(await exec("transaction.libsql", executeTransaction, emLibsql));
data.push(await exec("bulk.libsql", executeBatch, emLibsql));
data.push(await exec("singleKy.libsql", executeSingleKysely, emLibsql));
data.push(await exec("singleCl.libsql", executeSingleClient, emLibsql));*/
console.table(data);
/**
* ┌───┬────────────────────┬────────┬────────┬────────┬────────┐
* │ │ name │ total │ avg │ first │ last │
* ├───┼────────────────────┼────────┼────────┼────────┼────────┤
* │ 0 │ transaction.http │ 681.29 │ 136.26 │ 136.46 │ 396.09 │
* │ 1 │ bulk.http │ 164.82 │ 32.96 │ 32.95 │ 99.91 │
* │ 2 │ singleKy.http │ 330.01 │ 66.00 │ 65.86 │ 195.41 │
* │ 3 │ singleCl.http │ 326.17 │ 65.23 │ 61.32 │ 198.08 │
* │ 4 │ transaction.libsql │ 856.79 │ 171.36 │ 132.31 │ 595.24 │
* │ 5 │ bulk.libsql │ 180.63 │ 36.13 │ 35.39 │ 107.71 │
* │ 6 │ singleKy.libsql │ 347.11 │ 69.42 │ 65.00 │ 207.14 │
* │ 7 │ singleCl.libsql │ 328.60 │ 65.72 │ 62.19 │ 195.04 │
* └───┴────────────────────┴────────┴────────┴────────┴────────┘
*/
}
});
test("count & exists", async () => {
const items = new Entity("items", [new TextField("label")]);
const em = new EntityManager([items], dummyConnection);
@@ -41,44 +160,25 @@ describe("[Repository]", async () => {
// count all
const res = await em.repository(items).count();
expect(res.sql).toBe('select count(*) as "count" from "items"');
expect(res.data.count).toBe(3);
//
{
const res = await em.repository(items).findMany();
expect(res.count).toBe(3);
}
{
const res = await em
.repository(items, {
includeCounts: true,
})
.findMany();
expect(res.count).toBe(3);
}
expect(res.count).toBe(3);
// count filtered
const res2 = await em
.repository(items, {
includeCounts: true,
})
.count({ label: { $in: ["a", "b"] } });
const res2 = await em.repository(items).count({ label: { $in: ["a", "b"] } });
expect(res2.sql).toBe('select count(*) as "count" from "items" where "label" in (?, ?)');
expect(res2.parameters).toEqual(["a", "b"]);
expect(res2.data.count).toBe(2);
expect(res2.count).toBe(2);
// check exists
const res3 = await em.repository(items).exists({ label: "a" });
expect(res3.data.exists).toBe(true);
expect(res3.exists).toBe(true);
const res4 = await em.repository(items).exists({ label: "d" });
expect(res4.data.exists).toBe(false);
expect(res4.exists).toBe(false);
// for now, allow empty filter
const res5 = await em.repository(items).exists({});
expect(res5.data.exists).toBe(true);
expect(res5.exists).toBe(true);
});
test("option: silent", async () => {
@@ -91,9 +191,6 @@ describe("[Repository]", async () => {
// should throw because table doesn't exist
expect(em.repo("items").findMany({})).rejects.toThrow(/no such table/);
// should silently return empty result
em.repo("items", { silent: true })
.findMany({})
.then((r) => r.data);
expect(
em
.repo("items", { silent: true })
@@ -112,16 +209,16 @@ describe("[Repository]", async () => {
expect(
em
.repo("items", { includeCounts: true })
.repo("items")
.findMany({})
.then((r) => [r.count, r.total]),
.then((r) => [r.meta.count, r.meta.total]),
).resolves.toEqual([0, 0]);
expect(
em
.repo("items", { includeCounts: false })
.findMany({})
.then((r) => [r.count, r.total]),
.then((r) => [r.meta.count, r.meta.total]),
).resolves.toEqual([undefined, undefined]);
});
});
+3 -40
View File
@@ -1,9 +1,7 @@
// eslint-disable-next-line import/no-unresolved
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";
import { SchemaManager } from "data/schema/SchemaManager";
import { afterAll, describe, expect, test } from "bun:test";
import { randomString } from "../../../src/core/utils";
import { Entity, EntityIndex, EntityManager, SchemaManager, TextField } from "../../../src/data";
import { getDummyConnection } from "../helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
@@ -268,39 +266,4 @@ 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 */
});
});
+19 -18
View File
@@ -1,15 +1,16 @@
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";
import * as proto from "data/prototype";
import { WithBuilder } from "data/entities/query/WithBuilder";
import { describe, expect, test } from "bun:test";
import {
Entity,
EntityManager,
ManyToManyRelation,
ManyToOneRelation,
PolymorphicRelation,
TextField,
WithBuilder,
} from "../../../src/data";
import * as proto from "../../../src/data/prototype";
import { schemaToEm } from "../../helper";
import { getDummyConnection } from "../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
const { dummyConnection } = getDummyConnection();
@@ -88,9 +89,9 @@ describe("[data] WithBuilder", async () => {
const res2 = qb2.compile();
expect(res2.sql).toBe(
'select (select json_object(\'id\', "obj"."id", \'username\', "obj"."username") from (select "users"."id" as "id", "users"."username" as "username" from "users" as "author" where "author"."id" = "posts"."author_id" order by "users"."id" asc limit ?) as obj) as "author" from "posts"',
'select (select json_object(\'id\', "obj"."id", \'username\', "obj"."username") from (select "users"."id" as "id", "users"."username" as "username" from "users" as "author" where "author"."id" = "posts"."author_id" order by "users"."id" asc limit ? offset ?) as obj) as "author" from "posts"',
);
expect(res2.parameters).toEqual([1]);
expect(res2.parameters).toEqual([1, 0]);
});
test("test with empty join", async () => {
@@ -193,9 +194,9 @@ describe("[data] WithBuilder", async () => {
);
const res = qb.compile();
expect(res.sql).toBe(
'select (select json_object(\'id\', "obj"."id", \'path\', "obj"."path") from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" order by "media"."id" asc limit ?) as obj) as "single" from "categories"',
'select (select json_object(\'id\', "obj"."id", \'path\', "obj"."path") from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" order by "media"."id" asc limit ? offset ?) as obj) as "single" from "categories"',
);
expect(res.parameters).toEqual(["categories.single", 1]);
expect(res.parameters).toEqual(["categories.single", 1, 0]);
const qb2 = WithBuilder.addClause(
em,
@@ -272,9 +273,9 @@ describe("[data] WithBuilder", async () => {
//prettyPrintQb(qb);
expect(qb.compile().sql).toBe(
'select (select json_object(\'id\', "obj"."id", \'username\', "obj"."username", \'avatar\', "obj"."avatar") from (select "users"."id" as "id", "users"."username" as "username", (select json_object(\'id\', "obj"."id", \'path\', "obj"."path") from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "users"."id" = "media"."entity_id" order by "media"."id" asc limit ?) as obj) as "avatar" from "users" as "users" where "users"."id" = "posts"."users_id" order by "users"."username" asc limit ?) as obj) as "users" from "posts"',
'select (select json_object(\'id\', "obj"."id", \'username\', "obj"."username", \'avatar\', "obj"."avatar") from (select "users"."id" as "id", "users"."username" as "username", (select json_object(\'id\', "obj"."id", \'path\', "obj"."path") from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "users"."id" = "media"."entity_id" order by "media"."id" asc limit ? offset ?) as obj) as "avatar" from "users" as "users" where "users"."id" = "posts"."users_id" order by "users"."username" asc limit ? offset ?) as obj) as "users" from "posts"',
);
expect(qb.compile().parameters).toEqual(["users.avatar", 1, 1]);
expect(qb.compile().parameters).toEqual(["users.avatar", 1, 0, 1, 0]);
});
test("compiles with many", async () => {
@@ -314,9 +315,9 @@ describe("[data] WithBuilder", async () => {
);
expect(qb.compile().sql).toBe(
'select (select coalesce(json_group_array(json_object(\'id\', "agg"."id", \'posts_id\', "agg"."posts_id", \'users_id\', "agg"."users_id", \'users\', "agg"."users")), \'[]\') from (select "comments"."id" as "id", "comments"."posts_id" as "posts_id", "comments"."users_id" as "users_id", (select json_object(\'username\', "obj"."username") from (select "users"."username" as "username" from "users" as "users" where "users"."id" = "comments"."users_id" order by "users"."id" asc limit ?) as obj) as "users" from "comments" as "comments" where "comments"."posts_id" = "posts"."id" order by "comments"."id" asc limit ? offset ?) as agg) as "comments" from "posts"',
'select (select coalesce(json_group_array(json_object(\'id\', "agg"."id", \'posts_id\', "agg"."posts_id", \'users_id\', "agg"."users_id", \'users\', "agg"."users")), \'[]\') from (select "comments"."id" as "id", "comments"."posts_id" as "posts_id", "comments"."users_id" as "users_id", (select json_object(\'username\', "obj"."username") from (select "users"."username" as "username" from "users" as "users" where "users"."id" = "comments"."users_id" order by "users"."id" asc limit ? offset ?) as obj) as "users" from "comments" as "comments" where "comments"."posts_id" = "posts"."id" order by "comments"."id" asc limit ? offset ?) as agg) as "comments" from "posts"',
);
expect(qb.compile().parameters).toEqual([1, 12, 0]);
expect(qb.compile().parameters).toEqual([1, 0, 12, 0]);
});
test("returns correct result", async () => {
@@ -1,5 +1,5 @@
import { afterAll, describe, expect, test } from "bun:test";
import { EntityManager } from "data/entities/EntityManager";
import { EntityManager } from "../../../../src/data";
import { getDummyConnection } from "../../helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
@@ -1,10 +1,9 @@
import { bunTestRunner } from "adapter/bun/test";
import { describe, expect, test } from "bun:test";
import { BooleanField } from "data/fields";
import { BooleanField } from "../../../../src/data";
import { fieldTestSuite, transformPersist } from "data/fields/field-test-suite";
describe("[data] BooleanField", async () => {
fieldTestSuite(bunTestRunner, BooleanField, { defaultValue: true, schemaType: "boolean" });
fieldTestSuite({ expect, test }, BooleanField, { defaultValue: true, schemaType: "boolean" });
test("transformRetrieve", async () => {
const field = new BooleanField("test");
@@ -1,15 +1,9 @@
import { describe, test } from "bun:test";
import { DateField } from "data/fields";
import { describe, expect, test } from "bun:test";
import { DateField } from "../../../../src/data";
import { fieldTestSuite } from "data/fields/field-test-suite";
import { bunTestRunner } from "adapter/bun/test";
describe("[data] DateField", async () => {
fieldTestSuite(
bunTestRunner,
DateField,
{ defaultValue: new Date(), schemaType: "date" },
{ type: "date" },
);
fieldTestSuite({ expect, test }, DateField, { defaultValue: new Date(), schemaType: "date" });
// @todo: add datefield tests
test("week", async () => {
@@ -1,6 +1,5 @@
import { bunTestRunner } from "adapter/bun/test";
import { describe, expect, test } from "bun:test";
import { EnumField } from "data/fields";
import { EnumField } from "../../../../src/data";
import { fieldTestSuite, transformPersist } from "data/fields/field-test-suite";
function options(strings: string[]) {
@@ -9,7 +8,7 @@ function options(strings: string[]) {
describe("[data] EnumField", async () => {
fieldTestSuite(
bunTestRunner,
{ expect, test },
// @ts-ignore
EnumField,
{ defaultValue: "a", schemaType: "text" },
+3 -4
View File
@@ -1,8 +1,7 @@
import { describe, expect, test } from "bun:test";
import { Default, stripMark } from "../../../../src/core/utils";
import { baseFieldConfigSchema, Field } from "../../../../src/data/fields/Field";
import { fieldTestSuite } from "data/fields/field-test-suite";
import { bunTestRunner } from "adapter/bun/test";
import { stripMark } from "core/utils/schema";
describe("[data] Field", async () => {
class FieldSpec extends Field {
@@ -20,10 +19,10 @@ describe("[data] Field", async () => {
});
});
fieldTestSuite(bunTestRunner, FieldSpec, { defaultValue: "test", schemaType: "text" });
fieldTestSuite({ expect, test }, FieldSpec, { defaultValue: "test", schemaType: "text" });
test("default config", async () => {
const config = baseFieldConfigSchema.template({});
const config = Default(baseFieldConfigSchema, {});
expect(stripMark(new FieldSpec("test").config)).toEqual(config as any);
});
@@ -1,11 +1,10 @@
import { describe, expect, test } from "bun:test";
import { Entity } from "data/entities";
import { Field, EntityIndex } from "data/fields";
import { s } from "core/utils/schema";
import { Type } from "@sinclair/typebox";
import { Entity, EntityIndex, Field } from "../../../../src/data";
class TestField extends Field {
protected getSchema(): any {
return s.any();
return Type.Any();
}
override schema() {
@@ -23,4 +22,11 @@ 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();
});
});
@@ -1,13 +1,12 @@
import { bunTestRunner } from "adapter/bun/test";
import { describe, expect, test } from "bun:test";
import { JsonField } from "data/fields";
import { JsonField } from "../../../../src/data";
import { fieldTestSuite, transformPersist } from "data/fields/field-test-suite";
describe("[data] JsonField", async () => {
const field = new JsonField("test");
fieldTestSuite(bunTestRunner, JsonField, {
fieldTestSuite({ expect, test }, JsonField, {
defaultValue: { a: 1 },
//sampleValues: ["string", { test: 1 }, 1],
sampleValues: ["string", { test: 1 }, 1],
schemaType: "text",
});
@@ -33,9 +32,9 @@ describe("[data] JsonField", async () => {
});
test("getValue", async () => {
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 }, "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}', "submit")).toEqual({ test: 1 });
expect(field.getValue('"string"', "submit")).toBe("string");
@@ -43,5 +42,6 @@ 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");
});
});
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { JsonSchemaField } from "data/fields";
import { JsonSchemaField } from "../../../../src/data";
import { fieldTestSuite } from "data/fields/field-test-suite";
describe("[data] JsonSchemaField", async () => {
@@ -1,6 +1,5 @@
import { bunTestRunner } from "adapter/bun/test";
import { describe, expect, test } from "bun:test";
import { NumberField } from "data/fields";
import { NumberField } from "../../../../src/data";
import { fieldTestSuite, transformPersist } from "data/fields/field-test-suite";
describe("[data] NumberField", async () => {
@@ -16,5 +15,5 @@ describe("[data] NumberField", async () => {
expect(transformPersist(field2, 10000)).resolves.toBe(10000);
});
fieldTestSuite(bunTestRunner, NumberField, { defaultValue: 12, schemaType: "integer" });
fieldTestSuite({ expect, test }, NumberField, { defaultValue: 12, schemaType: "integer" });
});
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { PrimaryField } from "data/fields";
import { PrimaryField } from "../../../../src/data";
describe("[data] PrimaryField", async () => {
const field = new PrimaryField("primary");
@@ -39,28 +39,4 @@ describe("[data] PrimaryField", async () => {
expect(field.transformPersist(1)).rejects.toThrow();
expect(field.transformRetrieve(1)).toBe(1);
});
test("format", () => {
const uuid = new PrimaryField("uuid", { format: "uuid" });
expect(uuid.format).toBe("uuid");
expect(uuid.fieldType).toBe("text");
expect(uuid.getNewValue()).toBeString();
expect(uuid.toType()).toEqual({
required: true,
comment: undefined,
type: "Generated<string>",
import: [{ package: "kysely", name: "Generated" }],
});
const integer = new PrimaryField("integer", { format: "integer" });
expect(integer.format).toBe("integer");
expect(integer.fieldType).toBe("integer");
expect(integer.getNewValue()).toBeUndefined();
expect(integer.toType()).toEqual({
required: true,
comment: undefined,
type: "Generated<number>",
import: [{ package: "kysely", name: "Generated" }],
});
});
});
@@ -1,7 +1,6 @@
import { describe, expect, test } from "bun:test";
import { TextField } from "data/fields";
import { TextField } from "../../../../src/data";
import { fieldTestSuite, transformPersist } from "data/fields/field-test-suite";
import { bunTestRunner } from "adapter/bun/test";
describe("[data] TextField", async () => {
test("transformPersist (config)", async () => {
@@ -12,5 +11,5 @@ describe("[data] TextField", async () => {
expect(transformPersist(field, "abc")).resolves.toBe("abc");
});
fieldTestSuite(bunTestRunner, TextField, { defaultValue: "abc", schemaType: "text" });
fieldTestSuite({ expect, test }, TextField, { defaultValue: "abc", schemaType: "text" });
});
@@ -1,13 +1,11 @@
import { describe, expect, it, test } from "bun:test";
import { Entity, type EntityManager } from "data/entities";
import { Entity, type EntityManager } from "../../../../src/data";
import {
type BaseRelationConfig,
EntityRelation,
EntityRelationAnchor,
ManyToManyRelation,
RelationTypes,
} from "data/relations";
import * as proto from "data/prototype";
} from "../../../../src/data/relations";
class TestEntityRelation extends EntityRelation {
constructor(config?: BaseRelationConfig) {
@@ -26,11 +24,11 @@ class TestEntityRelation extends EntityRelation {
return this;
}
buildWith(): any {
buildWith(a: any, b: any, c: any): any {
return;
}
buildJoin(): any {
buildJoin(a: any, b: any): any {
return;
}
}
@@ -77,15 +75,4 @@ 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");
});
});
@@ -1,24 +0,0 @@
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));
});
});
+1 -1
View File
@@ -41,7 +41,7 @@ beforeAll(() =>
);
afterAll(unmockFetch);
describe.skip("FetchTask", async () => {
describe("FetchTask", async () => {
test("Simple fetch", async () => {
const task = new FetchTask("Fetch Something", {
url: "https://jsonplaceholder.typicode.com/todos/1",
+7 -5
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { Flow, LogTask, SubFlowTask, RenderTask, Task } from "../../src/flows";
import { s } from "core/utils/schema";
import { Type } from "@sinclair/typebox";
export class StringifyTask<Output extends string> extends Task<
typeof StringifyTask.schema,
@@ -8,16 +8,18 @@ export class StringifyTask<Output extends string> extends Task<
> {
type = "stringify";
static override schema = s.object({
input: s.string().optional(),
});
static override schema = Type.Optional(
Type.Object({
input: Type.Optional(Type.String()),
}),
);
async execute() {
return JSON.stringify(this.params.input) as Output;
}
}
describe.skip("SubFlowTask", async () => {
describe("SubFlowTask", async () => {
test("Simple Subflow", async () => {
const subTask = new RenderTask("render", {
render: "subflow",
+5 -5
View File
@@ -1,12 +1,12 @@
import { describe, expect, test } from "bun:test";
import { Type } from "@sinclair/typebox";
import { Task } from "../../src/flows";
import { dynamic } from "../../src/flows/tasks/Task";
import { s } from "core/utils/schema";
describe.skip("Task", async () => {
describe("Task", async () => {
test("resolveParams: template with parse", async () => {
const result = await Task.resolveParams(
s.object({ test: dynamic(s.number()) }),
Type.Object({ test: dynamic(Type.Number()) }),
{
test: "{{ some.path }}",
},
@@ -22,7 +22,7 @@ describe.skip("Task", async () => {
test("resolveParams: with string", async () => {
const result = await Task.resolveParams(
s.object({ test: s.string() }),
Type.Object({ test: Type.String() }),
{
test: "{{ some.path }}",
},
@@ -38,7 +38,7 @@ describe.skip("Task", async () => {
test("resolveParams: with object", async () => {
const result = await Task.resolveParams(
s.object({ test: dynamic(s.object({ key: s.string(), value: s.string() })) }),
Type.Object({ test: dynamic(Type.Object({ key: Type.String(), value: Type.String() })) }),
{
test: { key: "path", value: "{{ some.path }}" },
},
+7 -6
View File
@@ -1,7 +1,8 @@
import { describe, expect, test } from "bun:test";
import { Hono } from "hono";
import { Event, EventManager } from "../../src/core/events";
import { s, parse } from "core/utils/schema";
import { parse } from "../../src/core/utils";
import { type Static, type StaticDecode, Type } from "@sinclair/typebox";
import { EventTrigger, Flow, HttpTrigger, type InputsMap, Task } from "../../src/flows";
import { dynamic } from "../../src/flows/tasks/Task";
@@ -14,15 +15,15 @@ class Passthrough extends Task {
}
}
type OutputIn = s.Static<typeof OutputParamTask.schema>;
type OutputOut = s.StaticCoerced<typeof OutputParamTask.schema>;
type OutputIn = Static<typeof OutputParamTask.schema>;
type OutputOut = StaticDecode<typeof OutputParamTask.schema>;
class OutputParamTask extends Task<typeof OutputParamTask.schema> {
type = "output-param";
static override schema = s.strictObject({
static override schema = Type.Object({
number: dynamic(
s.number({
Type.Number({
title: "Output number",
}),
Number.parseInt,
@@ -43,7 +44,7 @@ class PassthroughFlowInput extends Task {
}
}
describe.skip("Flow task inputs", async () => {
describe("Flow task inputs", async () => {
test("types", async () => {
const schema = OutputParamTask.schema;
+1 -1
View File
@@ -30,7 +30,7 @@ class ExecTask extends Task {
}
}
describe.skip("Flow trigger", async () => {
describe("Flow trigger", async () => {
test("manual trigger", async () => {
let called = false;
+6 -6
View File
@@ -2,7 +2,7 @@
import { describe, expect, test } from "bun:test";
import { isEqual } from "lodash-es";
import { _jsonp, withDisabledConsole } from "../../src/core/utils";
import { s } from "core/utils/schema";
import { type Static, Type } from "@sinclair/typebox";
import { Condition, ExecutionEvent, FetchTask, Flow, LogTask, Task } from "../../src/flows";
/*beforeAll(disableConsoleLog);
@@ -11,19 +11,19 @@ afterAll(enableConsoleLog);*/
class ExecTask extends Task<typeof ExecTask.schema> {
type = "exec";
static override schema = s.object({
delay: s.number({ default: 10 }),
static override schema = Type.Object({
delay: Type.Number({ default: 10 }),
});
constructor(
name: string,
params: s.Static<typeof ExecTask.schema>,
params: Static<typeof ExecTask.schema>,
private func: () => Promise<any>,
) {
super(name, params);
}
override clone(name: string, params: s.Static<typeof ExecTask.schema>) {
override clone(name: string, params: Static<typeof ExecTask.schema>) {
return new ExecTask(name, params, this.func);
}
@@ -78,7 +78,7 @@ function getObjectDiff(obj1, obj2) {
return diff;
}
describe.skip("Flow tests", async () => {
describe("Flow tests", async () => {
test("Simple single task", async () => {
const simple = getTask(0);
+22 -4
View File
@@ -2,12 +2,11 @@ import { unlink } from "node:fs/promises";
import type { SelectQueryBuilder, SqliteDatabase } from "kysely";
import Database from "libsql";
import { format as sqlFormat } from "sql-formatter";
import { type Connection, EntityManager, SqliteLocalConnection } from "../src/data";
import type { em as protoEm } from "../src/data/prototype";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { slugify } from "bknd/utils";
import { type Connection, SqliteLocalConnection } from "data/connection";
import { EntityManager } from "data/entities/EntityManager";
import { slugify } from "core/utils/strings";
export function getDummyDatabase(memory: boolean = true): {
dummyDb: SqliteDatabase;
@@ -39,6 +38,25 @@ export function getLocalLibsqlConnection() {
return { url: "http://127.0.0.1:8080" };
}
type ConsoleSeverity = "log" | "warn" | "error";
const _oldConsoles = {
log: console.log,
warn: console.warn,
error: console.error,
};
export function disableConsoleLog(severities: ConsoleSeverity[] = ["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 };
@@ -46,7 +64,7 @@ export function compileQb(qb: SelectQueryBuilder<any, any, any>) {
export function prettyPrintQb(qb: SelectQueryBuilder<any, any, any>) {
const { sql, parameters } = qb.compile();
console.info("$", sqlFormat(sql), "\n[params]", parameters);
console.log("$", sqlFormat(sql), "\n[params]", parameters);
}
export function schemaToEm(s: ReturnType<typeof protoEm>, conn?: Connection): EntityManager<any> {
@@ -1,9 +1,12 @@
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { App, createApp, type AuthResponse } from "../../src";
import { auth } from "../../src/modules/middlewares";
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 { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import { getDummyConnection } from "../helper";
import { disableConsoleLog, enableConsoleLog, getDummyConnection } from "../helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterEach(afterAllCleanup);
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
@@ -63,10 +66,9 @@ const configs = {
};
function createAuthApp() {
const { dummyConnection } = getDummyConnection();
const app = createApp({
connection: dummyConnection,
config: {
initialConfig: {
auth: configs.auth,
},
});
@@ -149,8 +151,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 () => {
@@ -221,7 +223,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) => {
@@ -240,7 +242,7 @@ describe("integration auth", () => {
{
await new Promise((r) => setTimeout(r, 10));
const res = await app.server.request("/get");
const data = (await res.json()) as any;
const data = await res.json();
expect(data.user).toBe(null);
expect(await $fns.me()).toEqual({ user: null as any });
}
@@ -1,10 +1,6 @@
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { createApp } from "core/test/utils";
import { describe, expect, it } from "bun:test";
import { createApp } from "../../src";
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 () => {
@@ -17,8 +13,9 @@ describe("integration config", () => {
// create entity
await api.system.addConfig("data", "entities.posts", {
name: "posts",
config: { sort_field: "id", sort_dir: "asc" },
fields: { id: { type: "primary" }, asdf: { type: "text" } },
fields: { id: { type: "primary", name: "id" }, asdf: { type: "text" } },
type: "regular",
});
+8 -42
View File
@@ -1,25 +1,21 @@
/// <reference types="@types/bun" />
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { registries } from "../../src";
import { createApp } from "core/test/utils";
import { createApp, registries } from "../../src";
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 } from "../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import { assetsPath, assetsTmpPath, disableConsoleLog, enableConsoleLog } from "../helper";
beforeAll(() => {
//disableConsoleLog();
registries.media.register("local", StorageLocalAdapter);
});
afterAll(enableConsoleLog);
const path = `${assetsPath}/image.png`;
async function makeApp(mediaOverride: Partial<TAppMediaConfig> = {}) {
const app = createApp({
config: {
initialConfig: {
media: mergeObject(
{
enabled: true,
@@ -43,8 +39,11 @@ function makeName(ext: string) {
return randomString(10) + "." + ext;
}
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("MediaController", () => {
test("accepts direct", async () => {
test.only("accepts direct", async () => {
const app = await makeApp();
const file = Bun.file(path);
@@ -54,6 +53,7 @@ describe("MediaController", () => {
body: file,
});
const result = (await res.json()) as any;
console.log(result);
expect(result.name).toBe(name);
const destFile = Bun.file(assetsTmpPath + "/" + name);
@@ -94,38 +94,4 @@ 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();
});
});
+1 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test";
import { type FileBody, Storage } from "../../src/media/storage/Storage";
import * as StorageEvents from "../../src/media/storage/events";
import { StorageAdapter } from "media/storage/StorageAdapter";
import { StorageAdapter } from "media";
class TestAdapter extends StorageAdapter {
files: Record<string, FileBody> = {};
-37
View File
@@ -71,8 +71,6 @@ 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) {
@@ -90,9 +88,6 @@ 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) {
@@ -107,36 +102,4 @@ 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");
}
});
});
+8 -21
View File
@@ -1,21 +1,13 @@
import { afterAll, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test";
import { createApp } from "core/test/utils";
import { createApp } from "../../src";
import { AuthController } from "../../src/auth/api/AuthController";
import { em, entity, make, text } from "data/prototype";
import { AppAuth, type ModuleBuildContext } from "modules";
import { em, entity, make, text } from "../../src/data";
import { AppAuth, type ModuleBuildContext } from "../../src/modules";
import { disableConsoleLog, enableConsoleLog } from "../helper";
// @ts-ignore
import { makeCtx, moduleTestSuite } from "./module-test-suite";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("AppAuth", () => {
test.skip("...", () => {
const auth = new AppAuth({});
console.log(auth.toJSON());
console.log(auth.config);
});
moduleTestSuite(AppAuth);
let ctx: ModuleBuildContext;
@@ -150,7 +142,7 @@ describe("AppAuth", () => {
test("registers auth middleware for bknd routes only", async () => {
const app = createApp({
config: {
initialConfig: {
auth: {
enabled: true,
jwt: {
@@ -161,7 +153,6 @@ describe("AppAuth", () => {
});
await app.build();
app.registerAdminController();
const spy = spyOn(app.module.auth.authenticator, "requestCookieRefresh");
// register custom route
@@ -171,16 +162,12 @@ describe("AppAuth", () => {
await app.server.request("/api/system/ping");
await app.server.request("/test");
expect(spy.mock.calls.length).toBe(0);
// admin route
await app.server.request("/");
expect(spy.mock.calls.length).toBe(1);
});
test("should allow additional user fields", async () => {
const app = createApp({
config: {
initialConfig: {
auth: {
entity_name: "users",
enabled: true,
@@ -204,7 +191,7 @@ describe("AppAuth", () => {
test("ensure user field configs is always correct", async () => {
const app = createApp({
config: {
initialConfig: {
auth: {
enabled: true,
},
+1 -1
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, test } from "bun:test";
import { parse } from "core/utils/schema";
import { parse } from "../../src/core/utils";
import { fieldsSchema } from "../../src/data/data-schema";
import { AppData, type ModuleBuildContext } from "../../src/modules";
import { makeCtx, moduleTestSuite } from "./module-test-suite";
+4 -10
View File
@@ -1,24 +1,18 @@
import { describe, expect, test } from "bun:test";
import { createApp } from "core/test/utils";
import { em, entity, text } from "data/prototype";
import { registries } from "modules/registries";
import { createApp, registries } from "../../src";
import { em, entity, text } from "../../src/data";
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
import { AppMedia } from "../../src/media/AppMedia";
import { AppMedia } from "../../src/modules";
import { moduleTestSuite } from "./module-test-suite";
describe("AppMedia", () => {
test.skip("...", () => {
const media = new AppMedia();
console.log(media.toJSON());
});
moduleTestSuite(AppMedia);
test("should allow additional fields", async () => {
registries.media.register("local", StorageLocalAdapter);
const app = createApp({
config: {
initialConfig: {
media: {
entity_name: "media",
enabled: true,
@@ -1,76 +0,0 @@
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" });
});
});
+24 -21
View File
@@ -1,13 +1,12 @@
import { describe, expect, test } from "bun:test";
import { s, stripMark } from "core/utils/schema";
import { em, entity, index, text } from "data/prototype";
import { EntityManager } from "data/entities/EntityManager";
import { stripMark } from "../../src/core/utils";
import { type TSchema, Type } from "@sinclair/typebox";
import { EntityManager, em, entity, index, text } from "../../src/data";
import { DummyConnection } from "../../src/data/connection/DummyConnection";
import { Module } from "../../src/modules/Module";
import { ModuleHelper } from "modules/ModuleHelper";
function createModule<Schema extends s.Schema>(schema: Schema) {
return class TestModule extends Module<Schema> {
function createModule<Schema extends TSchema>(schema: Schema) {
class TestModule extends Module<typeof schema> {
getSchema() {
return schema;
}
@@ -17,7 +16,9 @@ function createModule<Schema extends s.Schema>(schema: Schema) {
override useForceParse() {
return true;
}
};
}
return TestModule;
}
describe("Module", async () => {
@@ -25,7 +26,7 @@ describe("Module", async () => {
test("listener", async () => {
let result: any;
const module = createModule(s.object({ a: s.string() }));
const module = createModule(Type.Object({ a: Type.String() }));
const m = new module({ a: "test" });
await m.schema().set({ a: "test2" });
@@ -41,13 +42,13 @@ describe("Module", async () => {
describe("db schema", () => {
class M extends Module {
override getSchema() {
return s.object({});
return Type.Object({});
}
prt = {
ensureEntity: this.ctx.helper.ensureEntity.bind(this.ctx.helper),
ensureIndex: this.ctx.helper.ensureIndex.bind(this.ctx.helper),
ensureSchema: this.ctx.helper.ensureSchema.bind(this.ctx.helper),
ensureEntity: this.ensureEntity.bind(this),
ensureIndex: this.ensureIndex.bind(this),
ensureSchema: this.ensureSchema.bind(this),
};
get em() {
@@ -62,11 +63,7 @@ describe("Module", async () => {
_em.relations,
_em.indices,
);
const ctx = {
em,
flags: Module.ctx_flags,
};
return new M({} as any, { ...ctx, helper: new ModuleHelper(ctx as any) } as any);
return new M({} as any, { em, flags: Module.ctx_flags } as any);
}
function flat(_em: EntityManager) {
return {
@@ -146,9 +143,14 @@ describe("Module", async () => {
// this should only add the field "important"
m.prt.ensureEntity(
entity("u", {
important: text(),
}),
entity(
"u",
{
important: text(),
},
undefined,
"system",
),
);
expect(m.ctx.flags.sync_required).toBe(true);
@@ -157,7 +159,8 @@ describe("Module", async () => {
{
name: "u",
fields: ["id", "name", "important"],
type: "regular",
// ensured type must be present
type: "system",
},
{
name: "p",
+17 -26
View File
@@ -1,18 +1,13 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { disableConsoleLog, enableConsoleLog, stripMark } from "core/utils";
import { Type } from "@sinclair/typebox";
import { Connection, entity, text } from "data";
import { Module } from "modules/Module";
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 { type ConfigTable, getDefaultConfig, ModuleManager } from "modules/ModuleManager";
import { CURRENT_VERSION, TABLE_NAME } from "modules/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);
import { diff } from "core/object/diff";
import type { Static } from "@sinclair/typebox";
describe("ModuleManager", async () => {
test("s1: no config, no build", async () => {
@@ -97,11 +92,7 @@ describe("ModuleManager", async () => {
await mm2.build();
/* console.log({
json,
configs: mm2.configs(),
}); */
//expect(stripMark(json)).toEqual(stripMark(mm2.configs()));
expect(stripMark(json)).toEqual(stripMark(mm2.configs()));
expect(mm2.configs().data.entities?.test).toBeDefined();
expect(mm2.configs().data.entities?.test?.fields?.content).toBeDefined();
expect(mm2.get("data").toJSON().entities?.test?.fields?.content).toBeDefined();
@@ -138,7 +129,7 @@ describe("ModuleManager", async () => {
const db = c2.dummyConnection.kysely;
const mm2 = new ModuleManager(c2.dummyConnection, {
initial: { version: version - 1, ...json } as any,
initial: { version: version - 1, ...json },
});
await mm2.syncConfigTable();
await db
@@ -266,10 +257,10 @@ describe("ModuleManager", async () => {
// @todo: add tests for migrations (check "backup" and new version)
describe("revert", async () => {
const failingModuleSchema = s.partialObject({
value: s.number(),
const failingModuleSchema = Type.Object({
value: Type.Optional(Type.Number()),
});
class FailingModule extends Module<s.Static<typeof failingModuleSchema>> {
class FailingModule extends Module<typeof failingModuleSchema> {
getSchema() {
return failingModuleSchema;
}
@@ -440,11 +431,11 @@ describe("ModuleManager", async () => {
});
describe("validate & revert", () => {
const schema = s.object({
value: s.array(s.number()),
const schema = Type.Object({
value: Type.Array(Type.Number(), { default: [] }),
});
type SampleSchema = s.Static<typeof schema>;
class Sample extends Module<SampleSchema> {
type SampleSchema = Static<typeof schema>;
class Sample extends Module<typeof schema> {
getSchema() {
return schema;
}
@@ -1,24 +1,14 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { App, type InitialModuleConfigs, createApp } from "/";
import { describe, expect, test } from "bun:test";
import { type InitialModuleConfigs, createApp } from "../../../src";
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 v10 from "./samples/v10.json";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import { CURRENT_VERSION } from "modules/db/migrations";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
// app expects migratable config to be present in database
async function createVersionedApp(
config: InitialModuleConfigs | any,
opts?: { beforeCreateApp?: (db: Kysely<any>) => Promise<void> },
) {
async function createVersionedApp(config: InitialModuleConfigs | any) {
const { dummyConnection } = getDummyConnection();
if (!("version" in config)) throw new Error("config must have a version");
@@ -44,10 +34,6 @@ async function createVersionedApp(
})
.execute();
if (opts?.beforeCreateApp) {
await opts.beforeCreateApp(db);
}
const app = createApp({
connection: dummyConnection,
});
@@ -55,19 +41,6 @@ async function createVersionedApp(
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()
.where("version", "=", opts?.version ?? CURRENT_VERSION)
.$if((opts?.types?.length ?? 0) > 0, (qb) => qb.where("type", "in", opts?.types))
.execute();
}
describe("Migrations", () => {
/**
* updated auth strategies to have "enabled" prop
@@ -105,40 +78,4 @@ 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, {
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^^",
);
});
test("migration from 10 to 11", async () => {
expect(v10.version).toBe(10);
expect(v10.data.entities.test.fields.title.config.fillable).toEqual(["read", "update"]);
const app = await createVersionedApp(v10);
expect(app.version()).toBeGreaterThan(10);
const [config] = (await getRawConfig(app, { types: ["config"] })) as any;
expect(config.json.data.entities.test.fields.title.config.fillable).toEqual(true);
});
});
@@ -1,270 +0,0 @@
{
"version": 10,
"server": {
"cors": {
"origin": "*",
"allow_methods": ["GET", "POST", "PATCH", "PUT", "DELETE"],
"allow_headers": [
"Content-Type",
"Content-Length",
"Authorization",
"Accept"
],
"allow_credentials": true
},
"mcp": {
"enabled": true,
"path": "/api/system/mcp",
"logLevel": "warning"
}
},
"data": {
"basepath": "/api/data",
"default_primary_format": "integer",
"entities": {
"test": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"format": "integer",
"fillable": false,
"required": false
}
},
"title": {
"type": "text",
"config": {
"required": false,
"fillable": ["read", "update"]
}
},
"status": {
"type": "enum",
"config": {
"default_value": "INACTIVE",
"options": {
"type": "strings",
"values": ["INACTIVE", "SUBSCRIBED", "UNSUBSCRIBED"]
},
"required": true,
"fillable": true
}
},
"created_at": {
"type": "date",
"config": {
"type": "datetime",
"required": true,
"fillable": true
}
},
"schema": {
"type": "jsonschema",
"config": {
"default_from_schema": true,
"schema": {
"type": "object",
"properties": {
"one": {
"type": "number",
"default": 1
}
}
},
"required": true,
"fillable": true
}
},
"text": {
"type": "text",
"config": {
"required": false,
"fillable": true
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "asc"
}
},
"items": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"format": "integer",
"fillable": false,
"required": false
}
},
"title": {
"type": "text",
"config": {
"required": false,
"fillable": true
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "asc"
}
},
"media": {
"type": "system",
"fields": {
"id": {
"type": "primary",
"config": {
"format": "integer",
"fillable": false,
"required": false
}
},
"path": {
"type": "text",
"config": {
"required": true,
"fillable": true
}
},
"folder": {
"type": "boolean",
"config": {
"default_value": false,
"hidden": true,
"fillable": ["create"],
"required": false
}
},
"mime_type": {
"type": "text",
"config": {
"required": false,
"fillable": true
}
},
"size": {
"type": "number",
"config": {
"required": false,
"fillable": true
}
},
"scope": {
"type": "text",
"config": {
"hidden": true,
"fillable": ["create"],
"required": false
}
},
"etag": {
"type": "text",
"config": {
"required": false,
"fillable": true
}
},
"modified_at": {
"type": "date",
"config": {
"type": "datetime",
"required": false,
"fillable": true
}
},
"reference": {
"type": "text",
"config": {
"required": false,
"fillable": true
}
},
"entity_id": {
"type": "text",
"config": {
"required": false,
"fillable": true
}
},
"metadata": {
"type": "json",
"config": {
"required": false,
"fillable": true
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "asc"
}
}
},
"relations": {},
"indices": {
"idx_unique_media_path": {
"entity": "media",
"fields": ["path"],
"unique": true
},
"idx_media_reference": {
"entity": "media",
"fields": ["reference"],
"unique": false
},
"idx_media_entity_id": {
"entity": "media",
"fields": ["entity_id"],
"unique": false
}
}
},
"auth": {
"enabled": false,
"basepath": "/api/auth",
"entity_name": "users",
"allow_register": true,
"jwt": {
"secret": "",
"alg": "HS256",
"fields": ["id", "email", "role"]
},
"cookie": {
"path": "/",
"sameSite": "lax",
"secure": true,
"httpOnly": true,
"expires": 604800,
"partitioned": false,
"renew": true,
"pathSuccess": "/",
"pathLoggedOut": "/"
},
"strategies": {
"password": {
"type": "password",
"enabled": true,
"config": {
"hashing": "sha256"
}
}
},
"guard": {
"enabled": false
},
"roles": {}
},
"media": {
"enabled": false
},
"flows": {
"basepath": "/api/flows",
"flows": {}
}
}
@@ -1,612 +0,0 @@
{
"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": {} }
}
+8 -14
View File
@@ -1,17 +1,17 @@
import { beforeEach, describe, expect, it } from "bun:test";
import { Hono } from "hono";
import { Guard } from "auth/authorize/Guard";
import { EventManager } from "core/events";
import { EntityManager } from "data/entities/EntityManager";
import { Module, type ModuleBuildContext } from "modules/Module";
import { Guard } from "../../src/auth";
import { DebugLogger } from "../../src/core";
import { EventManager } from "../../src/core/events";
import { Default, stripMark } from "../../src/core/utils";
import { EntityManager } from "../../src/data";
import { Module, type ModuleBuildContext } from "../../src/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();
const ctx = {
return {
connection: dummyConnection,
server: new Hono(),
em: new EntityManager([], dummyConnection),
@@ -19,13 +19,8 @@ export function makeCtx(overrides?: Partial<ModuleBuildContext>): ModuleBuildCon
guard: new Guard(),
flags: Module.ctx_flags,
logger: new DebugLogger(false),
mcp: new McpServer(),
...overrides,
};
return {
...ctx,
helper: new ModuleHelper(ctx as any),
} as any;
}
export function moduleTestSuite(module: { new (): Module }) {
@@ -45,8 +40,7 @@ export function moduleTestSuite(module: { new (): Module }) {
it("uses the default config", async () => {
const m = new module();
await m.setContext(ctx).build();
expect(m.toJSON()).toEqual(m.getSchema().template({}, { withOptional: true }));
//expect(stripMark(m.toJSON())).toEqual(Default(m.getSchema(), {}));
expect(stripMark(m.toJSON())).toEqual(Default(m.getSchema(), {}));
});
});
}
+1 -3
View File
@@ -102,9 +102,7 @@ describe("json form", () => {
] satisfies [string, Exclude<JSONSchema, boolean>, boolean][];
for (const [pointer, schema, output] of examples) {
expect(utils.isRequired(new Draft2019(schema), pointer, schema), `${pointer} `).toBe(
output,
);
expect(utils.isRequired(new Draft2019(schema), pointer, schema)).toBe(output);
}
});
-47
View File
@@ -1,47 +0,0 @@
import pkg from "./package.json" with { type: "json" };
import c from "picocolors";
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"],
target: "node",
outdir: "./dist/cli",
env: "PUBLIC_*",
minify: true,
external,
define: {
__isDev: "0",
__version: JSON.stringify(pkg.version),
},
});
for (const output of result.outputs) {
const size_ = await output.text();
console.info(
c.cyan(formatNumber.fileSize(size_.length)),
c.dim(output.path.replace(import.meta.dir + "/", "")),
);
}
+56 -118
View File
@@ -1,7 +1,5 @@
import { $ } from "bun";
import * as tsup from "tsup";
import pkg from "./package.json" with { type: "json" };
import c from "picocolors";
const args = process.argv.slice(2);
const watch = args.includes("--watch");
@@ -10,21 +8,8 @@ const types = args.includes("--types");
const sourcemap = args.includes("--sourcemap");
const clean = args.includes("--clean");
// silence tsup
const oldConsole = {
log: console.log,
warn: console.warn,
};
console.log = () => {};
console.warn = () => {};
const define = {
__isDev: "0",
__version: JSON.stringify(pkg.version),
};
if (clean) {
console.info("Cleaning dist (w/o static)");
console.log("Cleaning dist (w/o static)");
await $`find dist -mindepth 1 ! -path "dist/static/*" ! -path "dist/static" -exec rm -rf {} +`;
}
@@ -36,11 +21,11 @@ function buildTypes() {
Bun.spawn(["bun", "build:types"], {
stdout: "inherit",
onExit: () => {
oldConsole.log(c.cyan("[Types]"), c.green("built"));
console.log("Types built");
Bun.spawn(["bun", "tsc-alias"], {
stdout: "inherit",
onExit: () => {
oldConsole.log(c.cyan("[Types]"), c.green("aliased"));
console.log("Types aliased");
types_running = false;
},
});
@@ -48,10 +33,6 @@ function buildTypes() {
});
}
if (types && !watch) {
buildTypes();
}
let watcher_timeout: any;
function delayTypes() {
if (!watch || !types) return;
@@ -61,49 +42,48 @@ function delayTypes() {
watcher_timeout = setTimeout(buildTypes, 1000);
}
const dependencies = Object.keys(pkg.dependencies);
if (types && !watch) {
buildTypes();
}
function banner(title: string) {
console.log("");
console.log("=".repeat(40));
console.log(title.toUpperCase());
console.log("-".repeat(40));
}
// 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;
const external = ["bun:test", "node:test", "node:assert/strict", "@libsql/client"] as const;
/**
* Building backend and general API
*/
async function buildApi() {
banner("Building API");
await tsup.build({
minify,
sourcemap,
watch,
define,
entry: [
"src/index.ts",
"src/core/index.ts",
"src/core/utils/index.ts",
"src/plugins/index.ts",
"src/modes/index.ts",
"src/data/index.ts",
"src/media/index.ts",
],
outDir: "dist",
external: [...external],
metafile: true,
target: "esnext",
platform: "browser",
format: ["esm"],
splitting: false,
treeshake: true,
loader: {
".svg": "dataurl",
},
onSuccess: async () => {
delayTypes();
oldConsole.log(c.cyan("[API]"), c.green("built"));
},
});
}
@@ -121,7 +101,6 @@ async function buildUi() {
minify,
sourcemap,
watch,
define,
external: [
...external,
"react",
@@ -147,6 +126,7 @@ async function buildUi() {
},
} satisfies tsup.Options;
banner("Building UI");
await tsup.build({
...base,
entry: ["src/ui/index.ts", "src/ui/main.css", "src/ui/styles.css"],
@@ -154,10 +134,10 @@ async function buildUi() {
onSuccess: async () => {
await rewriteClient("./dist/ui/index.js");
delayTypes();
oldConsole.log(c.cyan("[UI]"), c.green("built"));
},
});
banner("Building Client");
await tsup.build({
...base,
entry: ["src/ui/client/index.ts"],
@@ -165,7 +145,6 @@ async function buildUi() {
onSuccess: async () => {
await rewriteClient("./dist/ui/client/index.js");
delayTypes();
oldConsole.log(c.cyan("[UI]"), "Client", c.green("built"));
},
});
}
@@ -176,11 +155,11 @@ async function buildUi() {
* - ui/client is external, and after built replaced with "bknd/client"
*/
async function buildUiElements() {
banner("Building UI Elements");
await tsup.build({
minify,
sourcemap,
watch,
define,
entry: ["src/ui/elements/index.ts"],
outDir: "dist/ui/elements",
external: [
@@ -209,7 +188,6 @@ async function buildUiElements() {
onSuccess: async () => {
await rewriteClient("./dist/ui/elements/index.js");
delayTypes();
oldConsole.log(c.cyan("[UI]"), "Elements", c.green("built"));
},
});
}
@@ -230,99 +208,59 @@ function baseConfig(adapter: string, overrides: Partial<tsup.Options> = {}): tsu
splitting: false,
onSuccess: async () => {
delayTypes();
oldConsole.log(c.cyan("[Adapter]"), adapter || "base", c.green("built"));
},
...overrides,
define: {
...define,
__isDev: "0",
...overrides.define,
},
external: [
/^cloudflare*/,
/^@?hono.*?/,
/^@?(hono|libsql).*?/,
/^(bknd|react|next|node).*?/,
/.*\.(html)$/,
...external,
...(Array.isArray(overrides.external) ? overrides.external : []),
],
};
}
async function buildAdapters() {
await Promise.all([
// 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,
}),
banner("Building Adapters");
// base adapter handles
await tsup.build({
...baseConfig(""),
entry: ["src/adapter/index.ts"],
outDir: "dist/adapter",
});
// specific adatpers
tsup.build(baseConfig("react-router")),
tsup.build(
baseConfig("bun", {
external: [/^bun\:.*/],
}),
),
tsup.build(baseConfig("astro")),
tsup.build(baseConfig("aws")),
tsup.build(
baseConfig("cloudflare", {
external: ["wrangler", "node:process"],
}),
),
tsup.build(
baseConfig("cloudflare/proxy", {
target: "esnext",
entry: ["src/adapter/cloudflare/proxy.ts"],
outDir: "dist/adapter/cloudflare",
metafile: false,
external: [/bknd/, "wrangler", "node:process"],
}),
),
tsup.build({
...baseConfig("vite"),
platform: "node",
// specific adatpers
await tsup.build(baseConfig("react-router"));
await tsup.build(baseConfig("bun"));
await tsup.build(baseConfig("astro"));
await tsup.build(baseConfig("aws"));
await tsup.build(
baseConfig("cloudflare", {
external: [/^kysely/],
}),
);
tsup.build({
...baseConfig("nextjs"),
platform: "node",
}),
await tsup.build({
...baseConfig("vite"),
platform: "node",
});
tsup.build({
...baseConfig("node"),
platform: "node",
}),
await tsup.build({
...baseConfig("nextjs"),
platform: "node",
});
tsup.build({
...baseConfig("sqlite/edge"),
entry: ["src/adapter/sqlite/edge.ts"],
outDir: "dist/adapter/sqlite",
metafile: false,
}),
tsup.build({
...baseConfig("sqlite/node"),
entry: ["src/adapter/sqlite/node.ts"],
outDir: "dist/adapter/sqlite",
platform: "node",
metafile: false,
}),
tsup.build({
...baseConfig("sqlite/bun"),
entry: ["src/adapter/sqlite/bun.ts"],
outDir: "dist/adapter/sqlite",
metafile: false,
external: [/^bun\:.*/],
}),
]);
await tsup.build({
...baseConfig("node"),
platform: "node",
});
}
await Promise.all([buildApi(), buildUi(), buildUiElements(), buildAdapters()]);
await buildApi();
await buildUi();
await buildUiElements();
await buildAdapters();
+1 -2
View File
@@ -2,5 +2,4 @@
#registry = "http://localhost:4873"
[test]
coverageSkipTestFiles = true
console.depth = 10
coverageSkipTestFiles = true
+7 -11
View File
@@ -17,7 +17,7 @@ async function run(
});
// Read from stdout
const reader = (proc.stdout as ReadableStream).getReader();
const reader = proc.stdout.getReader();
const decoder = new TextDecoder();
// Function to read chunks
@@ -30,7 +30,7 @@ async function run(
const text = decoder.decode(value);
if (!resolveCalled) {
console.info(c.dim(text.replace(/\n$/, "")));
console.log(c.dim(text.replace(/\n$/, "")));
}
onChunk(
text,
@@ -142,7 +142,6 @@ const adapters = {
},
nextjs: {
dir: path.join(basePath, "examples/nextjs"),
env: "TEST_TIMEOUT=20000",
clean: async function () {
const cwd = path.relative(process.cwd(), this.dir);
await $`cd ${cwd} && rm -rf .nextjs data.db`;
@@ -189,26 +188,23 @@ const adapters = {
async function testAdapter(name: keyof typeof adapters) {
const config = adapters[name];
console.info("adapter", c.cyan(name));
console.log("adapter", c.cyan(name));
await config.clean();
const { proc, data } = await config.start();
console.info("proc:", proc.pid, "data:", c.cyan(data));
console.log("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.info("DONE!");
await $`TEST_URL=${data} TEST_ADAPTER=${name} bun run test:e2e`;
console.log("DONE!");
while (!proc.killed) {
proc.kill("SIGINT");
await Bun.sleep(250);
console.info("Waiting for process to exit...");
console.log("Waiting for process to exit...");
}
}
// run with: TEST_ADAPTER=astro bun run e2e/adapters.ts
// (modify `test:e2e` to `test:e2e:ui` to see the UI)
if (process.env.TEST_ADAPTER) {
await testAdapter(process.env.TEST_ADAPTER as any);
} else {
-39
View File
@@ -1,39 +0,0 @@
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();
+39 -80
View File
@@ -3,7 +3,7 @@
"type": "module",
"sideEffects": false,
"bin": "./dist/cli/index.js",
"version": "0.19.0",
"version": "0.12.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,16 +13,12 @@
"bugs": {
"url": "https://github.com/bknd-io/bknd/issues"
},
"packageManager": "bun@1.2.22",
"engines": {
"node": ">=22.13"
},
"scripts": {
"dev": "BKND_CLI_LOG_LEVEL=debug vite",
"build": "NODE_ENV=production bun run build.ts --minify --types",
"build:all": "rm -rf dist && bun run build:static && NODE_ENV=production bun run build.ts --minify --types --clean && bun run build:cli",
"build:ci": "mkdir -p dist/static/.vite && echo '{}' > dist/static/.vite/manifest.json && NODE_ENV=production bun run build.ts",
"build:cli": "bun run build.cli.ts",
"build:cli": "bun build src/cli/index.ts --target node --outdir dist/cli --env PUBLIC_* --minify",
"build:static": "vite build",
"watch": "bun run build.ts --types --watch",
"types": "bun tsc -p tsconfig.build.json --noEmit",
@@ -30,21 +26,22 @@
"build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly && tsc-alias",
"updater": "bun x npm-check-updates -ui",
"cli": "LOCAL=1 bun src/cli/index.ts",
"prepublishOnly": "bun run types && bun run test && bun run test:node && NODE_NO_WARNINGS=1 VITE_DB_URL=:memory: bun run test:e2e && bun run build:all && cp ../README.md ./",
"prepublishOnly": "bun run types && bun run test && bun run test:node && 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",
"test:bun": "ALL_TESTS=1 bun test --bail",
"test:node": "vitest run",
"test:node": "tsx --test $(find . -type f -name '*.native-spec.ts')",
"test:adapters": "bun test src/adapter/**/*.adapter.spec.ts --bail",
"test:coverage": "ALL_TESTS=1 bun test --bail --coverage",
"test:vitest": "vitest run",
"test:vitest:watch": "vitest",
"test:vitest:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"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"
"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"
},
"license": "FSL-1.1-MIT",
"dependencies": {
@@ -52,7 +49,7 @@
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-json": "^6.0.1",
"@hello-pangea/dnd": "^18.0.1",
"@hono/swagger-ui": "^0.5.1",
"@libsql/client": "^0.15.2",
"@mantine/core": "^7.17.1",
"@mantine/hooks": "^7.17.1",
"@tanstack/react-form": "^1.0.5",
@@ -62,33 +59,30 @@
"bcryptjs": "^3.0.2",
"dayjs": "^1.11.13",
"fast-xml-parser": "^5.0.8",
"hono": "4.8.3",
"hono": "^4.7.4",
"json-schema-form-react": "^0.0.2",
"json-schema-library": "10.0.0-rc7",
"json-schema-to-ts": "^3.1.1",
"jsonv-ts": "0.9.1",
"kysely": "0.27.6",
"lodash-es": "^4.17.21",
"kysely": "^0.27.6",
"oauth4webapi": "^2.11.1",
"object-path-immutable": "^4.1.2",
"radix-ui": "^1.1.3",
"picocolors": "^1.1.1",
"swr": "^2.3.3"
"swr": "^2.3.3",
"lodash-es": "^4.17.21",
"@sinclair/typebox": "0.34.30"
},
"devDependencies": {
"@aws-sdk/client-s3": "^3.758.0",
"@bluwy/giget-core": "^0.1.2",
"@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.21.0",
"@hono/typebox-validator": "^0.3.2",
"@hono/vite-dev-server": "^0.19.0",
"@hookform/resolvers": "^4.1.3",
"@libsql/client": "^0.15.9",
"@libsql/kysely-libsql": "^0.4.1",
"@mantine/modals": "^7.17.1",
"@mantine/notifications": "^7.17.1",
"@playwright/test": "^1.51.1",
"@rjsf/core": "5.22.2",
"@standard-schema/spec": "^1.0.0",
"@tabler/icons-react": "3.18.0",
"@tailwindcss/postcss": "^4.0.12",
"@tailwindcss/vite": "^4.0.12",
@@ -105,14 +99,13 @@
"jotai": "^2.12.2",
"jsdom": "^26.0.0",
"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",
"postcss": "^8.5.3",
"postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1",
"posthog-js-lite": "^3.4.2",
"picocolors": "^1.1.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
@@ -125,17 +118,13 @@
"tsc-alias": "^1.8.11",
"tsup": "^8.4.0",
"tsx": "^4.19.3",
"uuid": "^11.1.0",
"vite": "^6.3.5",
"vite-plugin-circular-dependency": "^0.5.0",
"vite": "^6.2.1",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.0.9",
"wouter": "^3.6.0",
"wrangler": "^4.37.1",
"miniflare": "^4.20250913.0"
"wouter": "^3.6.0"
},
"optionalDependencies": {
"@hono/node-server": "^1.14.3"
"@hono/node-server": "^1.13.8"
},
"peerDependencies": {
"react": ">=19",
@@ -165,6 +154,16 @@
"import": "./dist/ui/client/index.js",
"require": "./dist/ui/client/index.js"
},
"./data": {
"types": "./dist/types/data/index.d.ts",
"import": "./dist/data/index.js",
"require": "./dist/data/index.js"
},
"./core": {
"types": "./dist/types/core/index.d.ts",
"import": "./dist/core/index.js",
"require": "./dist/core/index.js"
},
"./utils": {
"types": "./dist/types/core/utils/index.d.ts",
"import": "./dist/core/utils/index.js",
@@ -175,40 +174,16 @@
"import": "./dist/cli/index.js",
"require": "./dist/cli/index.js"
},
"./plugins": {
"types": "./dist/types/plugins/index.d.ts",
"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": {
"workerd": "./dist/adapter/sqlite/edge.js",
"edge-light": "./dist/adapter/sqlite/edge.js",
"netlify": "./dist/adapter/sqlite/edge.js",
"vercel": "./dist/adapter/sqlite/edge.js",
"browser": "./dist/adapter/sqlite/edge.js",
"bun": "./dist/adapter/sqlite/bun.js",
"node": "./dist/adapter/sqlite/node.js",
"default": "./dist/adapter/sqlite/node.js"
},
"require": "./dist/adapter/sqlite/node.js"
"./media": {
"types": "./dist/types/media/index.d.ts",
"import": "./dist/media/index.js",
"require": "./dist/media/index.js"
},
"./adapter/cloudflare": {
"types": "./dist/types/adapter/cloudflare/index.d.ts",
"import": "./dist/adapter/cloudflare/index.js",
"require": "./dist/adapter/cloudflare/index.js"
},
"./adapter/cloudflare/proxy": {
"types": "./dist/types/adapter/cloudflare/proxy.d.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"
@@ -250,23 +225,7 @@
},
"./dist/main.css": "./dist/ui/main.css",
"./dist/styles.css": "./dist/ui/styles.css",
"./dist/manifest.json": "./dist/static/.vite/manifest.json",
"./static/*": "./dist/static/*"
},
"typesVersions": {
"*": {
"utils": ["./dist/types/core/utils/index.d.ts"],
"cli": ["./dist/types/cli/index.d.ts"],
"plugins": ["./dist/types/plugins/index.d.ts"],
"adapter": ["./dist/types/adapter/index.d.ts"],
"adapter/cloudflare": ["./dist/types/adapter/cloudflare/index.d.ts"],
"adapter/vite": ["./dist/types/adapter/vite/index.d.ts"],
"adapter/nextjs": ["./dist/types/adapter/nextjs/index.d.ts"],
"adapter/react-router": ["./dist/types/adapter/react-router/index.d.ts"],
"adapter/bun": ["./dist/types/adapter/bun/index.d.ts"],
"adapter/node": ["./dist/types/adapter/node/index.d.ts"],
"adapter/sqlite": ["./dist/types/adapter/sqlite/edge.d.ts"]
}
"./dist/manifest.json": "./dist/static/.vite/manifest.json"
},
"publishConfig": {
"access": "public"
+1 -2
View File
@@ -3,7 +3,6 @@ import { defineConfig, devices } from "@playwright/test";
const baseUrl = process.env.TEST_URL || "http://localhost:28623";
const startCommand = process.env.TEST_START_COMMAND || "bun run dev";
const autoStart = ["1", "true", undefined].includes(process.env.TEST_AUTO_START);
const timeout = process.env.TEST_TIMEOUT ? Number.parseInt(process.env.TEST_TIMEOUT) : 5000;
export default defineConfig({
testMatch: "**/*.e2e-spec.ts",
@@ -13,7 +12,7 @@ export default defineConfig({
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "html",
timeout,
timeout: 20000,
use: {
baseURL: baseUrl,
trace: "on-first-retry",
+34 -98
View File
@@ -1,11 +1,10 @@
import type { SafeUser } from "bknd";
import { AuthApi, type AuthApiOptions } from "auth/api/AuthApi";
import { DataApi, type DataApiOptions } from "data/api/DataApi";
import type { SafeUser } from "auth";
import { AuthApi } from "auth/api/AuthApi";
import { DataApi } from "data/api/DataApi";
import { decode } from "hono/jwt";
import { MediaApi, type MediaApiOptions } from "media/api/MediaApi";
import { MediaApi } from "media/api/MediaApi";
import { SystemApi } from "modules/SystemApi";
import { omitKeys } from "bknd/utils";
import type { BaseModuleApiOptions } from "modules";
import { omitKeys } from "core/utils";
export type TApiUser = SafeUser;
@@ -22,29 +21,18 @@ declare global {
}
}
type SubApiOptions<T extends BaseModuleApiOptions> = Omit<T, keyof BaseModuleApiOptions>;
export type ApiOptions = {
host?: string;
headers?: Headers;
key?: string;
storage?: {
getItem: (key: string) => string | undefined | null | Promise<string | undefined | null>;
setItem: (key: string, value: string) => void | Promise<void>;
removeItem: (key: string) => void | Promise<void>;
};
onAuthStateChange?: (state: AuthState) => void;
localStorage?: boolean;
fetcher?: ApiFetcher;
verbose?: boolean;
verified?: boolean;
data?: SubApiOptions<DataApiOptions>;
auth?: SubApiOptions<AuthApiOptions>;
media?: SubApiOptions<MediaApiOptions>;
credentials?: RequestCredentials;
} & (
| {
token?: string;
user?: TApiUser | null;
user?: TApiUser;
}
| {
request: Request;
@@ -68,23 +56,23 @@ export class Api {
public auth!: AuthApi;
public media!: MediaApi;
constructor(public options: ApiOptions = {}) {
constructor(private options: ApiOptions = {}) {
// only mark verified if forced
this.verified = options.verified === true;
// prefer request if given
if ("request" in options && options.request) {
if ("request" in options) {
this.options.host = options.host ?? new URL(options.request.url).origin;
this.options.headers = options.headers ?? options.request.headers;
this.extractToken();
// then check for a token
} else if ("token" in options && options.token) {
} else if ("token" in options) {
this.token_transport = "header";
this.updateToken(options.token, { trigger: false });
this.updateToken(options.token);
// then check for an user object
} else if ("user" in options && options.user) {
} else if ("user" in options) {
this.token_transport = "none";
this.user = options.user;
this.verified = options.verified !== false;
@@ -127,48 +115,18 @@ export class Api {
this.updateToken(headerToken);
return;
}
} else if (this.storage) {
this.storage.getItem(this.tokenKey).then((token) => {
} else if (this.options.localStorage) {
const token = localStorage.getItem(this.tokenKey);
if (token) {
this.token_transport = "header";
this.updateToken(token ? String(token) : undefined, {
verified: true,
trigger: false,
});
});
this.updateToken(token);
}
}
}
/**
* Make storage async to allow async storages even if sync given
* @private
*/
private get storage() {
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),
};
};
},
},
) as any;
}
updateToken(
token?: string,
opts?: { rebuild?: boolean; verified?: boolean; trigger?: boolean },
) {
updateToken(token?: string, rebuild?: boolean) {
this.token = token;
this.verified = opts?.verified === true;
this.verified = false;
if (token) {
this.user = omitKeys(decode(token).payload as any, ["iat", "iss", "exp"]) as any;
@@ -176,31 +134,21 @@ export class Api {
this.user = undefined;
}
const emit = () => {
if (opts?.trigger !== false) {
this.options.onAuthStateChange?.(this.getAuthState());
}
};
if (this.storage) {
if (this.options.localStorage) {
const key = this.tokenKey;
if (token) {
this.storage.setItem(key, token).then(emit);
localStorage.setItem(key, token);
} else {
this.storage.removeItem(key).then(emit);
}
} else {
if (opts?.trigger !== false) {
emit();
localStorage.removeItem(key);
}
}
if (opts?.rebuild) this.buildApis();
if (rebuild) this.buildApis();
}
private markAuthVerified(verfied: boolean) {
this.verified = verfied;
this.options.onAuthStateChange?.(this.getAuthState());
return this;
}
@@ -227,6 +175,11 @@ export class Api {
}
async verifyAuth() {
if (!this.token) {
this.markAuthVerified(false);
return;
}
try {
const { ok, data } = await this.auth.me();
const user = data?.user;
@@ -235,10 +188,10 @@ export class Api {
}
this.user = user;
} catch (e) {
this.updateToken(undefined);
} finally {
this.markAuthVerified(true);
} catch (e) {
this.markAuthVerified(false);
this.updateToken(undefined);
}
}
@@ -253,7 +206,6 @@ export class Api {
headers: this.options.headers,
token_transport: this.token_transport,
verbose: this.options.verbose,
credentials: this.options.credentials,
});
}
@@ -262,31 +214,15 @@ export class Api {
const fetcher = this.options.fetcher;
this.system = new SystemApi(baseParams, fetcher);
this.data = new DataApi(
{
...baseParams,
...this.options.data,
},
fetcher,
);
this.data = new DataApi(baseParams, fetcher);
this.auth = new AuthApi(
{
...baseParams,
...this.options.auth,
onTokenUpdate: (token, verified) => {
this.updateToken(token, { rebuild: true, verified, trigger: true });
this.options.auth?.onTokenUpdate?.(token);
},
},
fetcher,
);
this.media = new MediaApi(
{
...baseParams,
...this.options.media,
onTokenUpdate: (token) => this.updateToken(token, true),
},
fetcher,
);
this.media = new MediaApi(baseParams, fetcher);
}
}
+67 -179
View File
@@ -1,70 +1,30 @@
import type { CreateUserPayload } from "auth/AppAuth";
import { $console, McpClient } from "bknd/utils";
import { $console } from "core";
import { Event } from "core/events";
import type { em as prototypeEm } from "data/prototype";
import { Connection } from "data/connection/Connection";
import { Connection, type LibSqlCredentials, LibsqlConnection } from "data";
import type { Hono } from "hono";
import {
type InitialModuleConfigs,
type ModuleConfigs,
type Modules,
ModuleManager,
type InitialModuleConfigs,
type ModuleBuildContext,
type ModuleConfigs,
type ModuleManagerOptions,
type Modules,
} 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, PartialRec } from "core/types";
import type { ServerEnv } from "modules/Controller";
import type { IEmailDriver, ICacheDriver } from "core/drivers";
// biome-ignore format: must be here
import { Api, type ApiOptions } from "Api";
import type { ServerEnv } from "modules/Controller";
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>;
/**
* 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;
export type AppPlugin = (app: App) => Promise<void> | void;
abstract class AppEvent<A = {}> extends Event<{ app: App } & A> {}
export class AppConfigUpdatedEvent extends AppEvent<{
module: string;
config: ModuleConfigs[keyof ModuleConfigs];
}> {
export class AppConfigUpdatedEvent extends AppEvent {
static override slug = "app-config-updated";
}
/**
* @type {Event<{ app: App }>}
*/
export class AppBuiltEvent extends AppEvent {
static override slug = "app-built";
}
@@ -90,108 +50,55 @@ export type AppOptions = {
seed?: (ctx: ModuleBuildContext & { app: App }) => Promise<void>;
manager?: Omit<ModuleManagerOptions, "initial" | "onUpdated" | "seed">;
asyncEventsMode?: "sync" | "async" | "none";
drivers?: {
email?: IEmailDriver;
cache?: ICacheDriver;
};
mode?: "db" | "code";
readonly?: boolean;
};
export type CreateAppConfig = {
connection?: Connection | { url: string };
config?: PartialRec<ModuleConfigs>;
connection?:
| Connection
| {
// @deprecated
type: "libsql";
config: LibSqlCredentials;
}
| LibSqlCredentials;
initialConfig?: InitialModuleConfigs;
options?: AppOptions;
};
export type AppConfig = { version: number } & ModuleConfigs;
export type AppConfig = InitialModuleConfigs;
export type LocalApiOptions = Request | ApiOptions;
export class App<
C extends Connection = Connection,
Config extends PartialRec<ModuleConfigs> = PartialRec<ModuleConfigs>,
Options extends AppOptions = AppOptions,
> {
export class App {
static readonly Events = AppEvents;
modules: ModuleManager;
adminController?: AdminController;
_id: string = crypto.randomUUID();
plugins: Map<string, AppPluginConfig> = new Map();
drivers: Options["drivers"] = {};
private trigger_first_boot = false;
private plugins: AppPlugin[];
private _building: boolean = false;
private _systemController: SystemController | null = null;
constructor(
public connection: C,
_config?: Config,
public options?: Options,
private connection: Connection,
_initialConfig?: InitialModuleConfigs,
private options?: AppOptions,
) {
this.drivers = options?.drivers ?? {};
for (const plugin of options?.plugins ?? []) {
const config = plugin(this);
if (this.plugins.has(config.name)) {
throw new Error(`Plugin ${config.name} already registered`);
}
this.plugins.set(config.name, config);
}
this.runPlugins("onBoot");
// use db manager by default
const Manager = this.mode === "db" ? DbModuleManager : ModuleManager;
this.modules = new Manager(connection, {
this.plugins = options?.plugins ?? [];
this.modules = new ModuleManager(connection, {
...(options?.manager ?? {}),
initial: _config,
initial: _initialConfig,
onUpdated: this.onUpdated.bind(this),
onFirstBoot: this.onFirstBoot.bind(this),
onServerInit: this.onServerInit.bind(this),
onModulesBuilt: this.onModulesBuilt.bind(this),
});
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;
}
protected async runPlugins<Key extends keyof AppPluginConfig>(
key: Key,
...args: any[]
): Promise<{ name: string; result: any }[]> {
const results: { name: string; result: any }[] = [];
for (const [name, config] of this.plugins) {
try {
if (key in config && config[key]) {
const fn = config[key];
if (fn && typeof fn === "function") {
$console.debug(`[Plugin:${name}] ${key}`);
// @ts-expect-error
const result = await fn(...args);
results.push({
name,
result,
});
}
}
} catch (e) {
$console.warn(`[Plugin:${name}] error running "${key}"`, String(e));
}
}
return results as any;
}
async build(options?: { sync?: boolean; forceBuild?: boolean; [key: string]: any }) {
async build(options?: { sync?: boolean; fetch?: boolean; forceBuild?: boolean }) {
// prevent multiple concurrent builds
if (this._building) {
while (this._building) {
@@ -199,24 +106,24 @@ export class App<
}
if (!options?.forceBuild) return;
}
await this.runPlugins("beforeBuild");
this._building = true;
if (options?.sync) this.modules.ctx().flags.sync_required = true;
await this.modules.build();
await this.modules.build({ fetch: options?.fetch });
const { guard } = this.modules.ctx();
const { guard, server } = this.modules.ctx();
// load system controller
guard.registerPermissions(Object.values(SystemPermissions));
this._systemController = new SystemController(this);
this._systemController.register(this);
server.route("/api/system", new SystemController(this).getController());
// load plugins
if (this.plugins.length > 0) {
await Promise.all(this.plugins.map((plugin) => plugin(this)));
}
// emit built event
$console.log("App built");
await this.emgr.emit(new AppBuiltEvent({ app: this }));
await this.runPlugins("onBuilt");
// first boot is set from ModuleManager when there wasn't a config table
if (this.trigger_first_boot) {
@@ -231,6 +138,10 @@ export class App<
this._building = false;
}
mutateConfig<Module extends keyof Modules>(module: Module) {
return this.modules.mutateConfigSafe(module);
}
get server() {
return this.modules.server;
}
@@ -239,15 +150,8 @@ export class App<
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;
return this.server.fetch;
}
get module() {
@@ -295,7 +199,6 @@ export class App<
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) {
@@ -305,19 +208,6 @@ export class App<
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,16 +220,15 @@ export class App<
$console.log("App config updated", module);
// @todo: potentially double syncing
await this.build({ sync: true });
await this.emgr.emit(new AppConfigUpdatedEvent({ app: this, module, config }));
await this.emgr.emit(new AppConfigUpdatedEvent({ app: this }));
}
protected async onFirstBoot() {
async onFirstBoot() {
$console.log("App first boot");
this.trigger_first_boot = true;
await this.runPlugins("onFirstBoot");
}
protected async onServerInit(server: Hono<ServerEnv>) {
async onServerInit(server: Hono<ServerEnv>) {
server.use(async (c, next) => {
c.set("app", this);
await this.emgr.emit(new AppRequest({ app: this, request: c.req.raw }));
@@ -364,36 +253,35 @@ export class App<
break;
}
});
// call server init if set
if (this.options?.manager?.onServerInit) {
this.options.manager.onServerInit(server);
}
await this.runPlugins("onServerInit", server);
}
protected async onModulesBuilt(ctx: ModuleBuildContext) {
const results = (await this.runPlugins("schema")) as {
name: string;
result: ReturnType<typeof prototypeEm>;
}[];
if (results.length > 0) {
for (const { name, result } of results) {
if (result) {
$console.log(`[Plugin:${name}] schema`);
ctx.helper.ensureSchema(result);
}
}
}
await this.options?.manager?.onModulesBuilt?.(ctx);
}
}
export function createApp(config: CreateAppConfig = {}) {
if (!config.connection || !Connection.isConnection(config.connection)) {
let connection: Connection | undefined = undefined;
try {
if (Connection.isConnection(config.connection)) {
connection = config.connection;
} else if (typeof config.connection === "object") {
if ("type" in config.connection) {
$console.warn(
"Using deprecated connection type 'libsql', use the 'config' object directly.",
);
connection = new LibsqlConnection(config.connection.config);
} else {
connection = new LibsqlConnection(config.connection);
}
} else {
connection = new LibsqlConnection({ url: ":memory:" });
$console.warn("No connection provided, using in-memory database");
}
} catch (e) {
$console.error("Could not create connection", e);
}
if (!connection) {
throw new Error("Invalid connection");
}
return new App(config.connection, config.config, config.options);
return new App(connection, config.initialConfig, config.options);
}
+20 -13
View File
@@ -1,7 +1,6 @@
import type { TestRunner } from "core/test";
import type { BkndConfig, DefaultArgs } from "./index";
import type { BkndConfig, DefaultArgs, FrameworkOptions, RuntimeOptions } from "./index";
import type { App } from "App";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
export function adapterTestSuite<
Config extends BkndConfig = BkndConfig,
@@ -14,17 +13,24 @@ export function adapterTestSuite<
label = "app",
overrides = {},
}: {
makeApp: (config: Config, args?: Args) => Promise<App>;
makeHandler?: (config?: Config, args?: Args) => (request: Request) => Promise<Response>;
makeApp: (
config: Config,
args?: Args,
opts?: RuntimeOptions | FrameworkOptions,
) => Promise<App>;
makeHandler?: (
config?: Config,
args?: Args,
opts?: RuntimeOptions | FrameworkOptions,
) => (request: Request) => Promise<Response>;
label?: string;
overrides?: {
dbUrl?: string;
};
},
) {
const { test, expect, mock, beforeAll, afterAll } = testRunner;
beforeAll(() => disableConsoleLog());
afterAll(() => enableConsoleLog());
const { test, expect, mock } = testRunner;
const id = crypto.randomUUID();
test(`creates ${label}`, async () => {
const beforeBuild = mock(async () => null) as any;
@@ -33,7 +39,7 @@ export function adapterTestSuite<
const config = {
app: (env) => ({
connection: { url: env.url },
config: {
initialConfig: {
server: { cors: { origin: env.origin } },
},
}),
@@ -47,10 +53,11 @@ 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(2);
expect(beforeBuild).toHaveBeenCalledTimes(1);
expect(onBuilt).toHaveBeenCalledTimes(1);
});
@@ -61,7 +68,7 @@ export function adapterTestSuite<
return { res, data };
};
/* test.skip("responds with the same app id", async () => {
test("responds with the same app id", async () => {
const fetcher = makeHandler(undefined, undefined, { id });
const { res, data } = await getConfig(fetcher);
@@ -70,14 +77,14 @@ export function adapterTestSuite<
expect(data.server.cors.origin).toEqual("localhost");
});
test.skip("creates fresh & responds to api config", async () => {
test("creates fresh & responds to api config", async () => {
// set the same id, but force recreate
const fetcher = makeHandler(undefined, undefined, { id });
const fetcher = makeHandler(undefined, undefined, { id, force: true });
const { res, data } = await getConfig(fetcher);
expect(res.ok).toBe(true);
expect(res.status).toBe(200);
expect(data.server.cors.origin).toEqual("*");
}); */
});
}
}
+1 -1
View File
@@ -10,6 +10,6 @@ afterAll(enableConsoleLog);
describe("astro adapter", () => {
adapterTestSuite(bunTestRunner, {
makeApp: astro.getApp,
makeHandler: (c, a) => (request: Request) => astro.serve(c, a)({ request }),
makeHandler: (c, a, o) => (request: Request) => astro.serve(c, a, o)({ request }),
});
});
+7 -5
View File
@@ -1,4 +1,4 @@
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
import { type FrameworkBkndConfig, createFrameworkApp, type FrameworkOptions } from "bknd/adapter";
type AstroEnv = NodeJS.ProcessEnv;
type TAstro = {
@@ -8,16 +8,18 @@ export type AstroBkndConfig<Env = AstroEnv> = FrameworkBkndConfig<Env>;
export async function getApp<Env = AstroEnv>(
config: AstroBkndConfig<Env> = {},
args: Env = import.meta.env as Env,
args: Env = {} as Env,
opts: FrameworkOptions = {},
) {
return await createFrameworkApp(config, args);
return await createFrameworkApp(config, args ?? import.meta.env, opts);
}
export function serve<Env = AstroEnv>(
config: AstroBkndConfig<Env> = {},
args: Env = import.meta.env as Env,
args: Env = {} as Env,
opts?: FrameworkOptions,
) {
return async (fnArgs: TAstro) => {
return (await getApp(config, args)).fetch(fnArgs.request);
return (await getApp(config, args, opts)).fetch(fnArgs.request);
};
}
+5 -2
View File
@@ -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 } from "bknd/adapter";
import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
type AwsLambdaEnv = object;
export type AwsLambdaBkndConfig<Env extends AwsLambdaEnv = AwsLambdaEnv> =
@@ -20,6 +20,7 @@ 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,
@@ -56,15 +57,17 @@ 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);
const app = await createApp(config, args, opts);
return await handle(app.server)(event);
};
}

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