public commit

This commit is contained in:
dswbx
2024-11-16 12:01:47 +01:00
commit 90f80c4280
582 changed files with 49291 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
.DS_Store
node_modules
/.cache
/.wrangler
/build
/public/build
packages/media/.env
*.sqlite
/data.sqld/
/dist
**/*/dist
**/*/build
**/*/.cache
**/*/.env
**/*/.dev.vars
**/*/.wrangler
**/*/vite.config.ts.timestamp*
.history
**/*/.db/*
.npmrc
/.verdaccio
.idea
.vscode
.git_old

110
LICENSE.md Normal file
View File

@@ -0,0 +1,110 @@
# Functional Source License, Version 1.1, MIT Future License
## Abbreviation
FSL-1.1-MIT
## Notice
Copyright 2024 Webintex GmbH
## Terms and Conditions
### Licensor ("We")
The party offering the Software under these Terms and Conditions.
### The Software
The "Software" is each version of the software that we make available under
these Terms and Conditions, as indicated by our inclusion of these Terms and
Conditions with the Software.
### License Grant
Subject to your compliance with this License Grant and the Patents,
Redistribution and Trademark clauses below, we hereby grant you the right to
use, copy, modify, create derivative works, publicly perform, publicly display
and redistribute the Software for any Permitted Purpose identified below.
### Permitted Purpose
A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
means making the Software available to others in a commercial product or
service that:
1. substitutes for the Software;
2. substitutes for any other product or service we offer using the Software
that exists as of the date we make the Software available; or
3. offers the same or substantially similar functionality as the Software.
Permitted Purposes specifically include using the Software:
1. for your internal use and access;
2. for non-commercial education;
3. for non-commercial research; and
4. in connection with professional services that you provide to a licensee
using the Software in accordance with these Terms and Conditions.
### Patents
To the extent your use for a Permitted Purpose would necessarily infringe our
patents, the license grant above includes a license under our patents. If you
make a claim against any party that the Software infringes or contributes to
the infringement of any patent, then your patent license to the Software ends
immediately.
### Redistribution
The Terms and Conditions apply to all copies, modifications and derivatives of
the Software.
If you redistribute any copies, modifications or derivatives of the Software,
you must include a copy of or a link to these Terms and Conditions and not
remove any copyright notices provided in or with the Software.
### Disclaimer
THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
### Trademarks
Except for displaying the License Details and identifying us as the origin of
the Software, you have no right under these Terms and Conditions to use our
trademarks, trade names, service marks or product names.
## Grant of Future License
We hereby irrevocably grant you an additional license to use the Software under
the MIT license that is effective on the second anniversary of the date we make
the Software available. On or after that date, you may use the Software under
the MIT license, in which case the following will apply:
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

58
README.md Normal file
View File

@@ -0,0 +1,58 @@
# bknd
**Feature-rich backend built to run anywhere.**
bknd simplifies backend development by providing powerful tools for data management, workflows, authentication, and media handling—all seamlessly integrated into a developer-friendly platform.
**For documentation and examples, please visit https://docs.bknd.io.**
> [!WARNING]
> Please keep in mind that **bknd** is still under active development
> and therefore full backward compatibility is not guaranteed before reaching v1.0.0.
## Why bknd?
**Developer-Centric**: Focus on building your app—bknd handles the heavy lifting.
**Scalable**: Designed to run in any JavaScript environment (cloud or edge)
databases.
**Integrated**: Everything from data to workflows, auth, and media, in one cohesive platform.
## ✨ Features
- **📊 Data**: Define, query, and control your data with ease.
- Define entities with fields and relationships, synced directly to your database.
- Supported field types: `primary`, `text`, `number`, `date`, `boolean`, `enum`, `json`, `jsonschema`.
- Relationship types: `one-to-one`, `many-to-one`, `many-to-many`, and `polymorphic`.
- Advanced querying with the **Repository**: filtering, sorting, pagination, and relational data handling.
- Seamlessly manage data with mutators and a robust event system.
- Extend database functionality with batching, introspection, and support for multiple SQL dialects.
- **🔐 Auth**: Easily implement reliable authentication strategies.
- Built-in `user` entity with customizable fields.
- Supports multiple authentication strategies:
- Email/password (with hashed storage).
- OAuth/OIDC (Google, GitHub, and more).
- Secure JWT generation and session management.
- **🖼️ Media**: Effortlessly manage and serve all your media files.
- Upload files with ease.
- Adapter-based support for S3, S3-compatible storage (e.g., R2, Tigris), and Cloudinary.
- **🔄 Flows**: Design and run workflows with seamless automation.
- Create and run workflows with trigger-based automation:
- Manual triggers or events from data, auth, media, or server actions.
- HTTP triggers for external integrations.
- Define tasks in sequence, parallel, or loops, with conditional execution.
- Use reusable sub-workflows to organize complex processes.
- Leverage OpenAPI specifications for API-based tasks.
## 🚀 Quick start
To quickly spin up an instance, run:
```bash
npx bknd run
```
### Installation
```bash
npm install bknd
```

3
app/.ncurc.json Normal file
View File

@@ -0,0 +1,3 @@
{
"reject": ["react-icons", "@tabler/icons-react", "@tanstack/react-form"]
}

15
app/__test__/App.spec.ts Normal file
View File

@@ -0,0 +1,15 @@
import { afterAll, describe, expect, test } from "bun:test";
import { App } from "../src";
import { getDummyConnection } from "./helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterAll(afterAllCleanup);
describe("App tests", async () => {
test("boots and pongs", async () => {
const app = new App(dummyConnection);
await app.build();
//expect(await app.data?.em.ping()).toBeTrue();
});
});

View File

@@ -0,0 +1,38 @@
import { describe, expect, test } from "bun:test";
import { type TSchema, Type, stripMark } from "../src/core/utils";
import { Module } from "../src/modules/Module";
function createModule<Schema extends TSchema>(schema: Schema) {
class TestModule extends Module<typeof schema> {
getSchema() {
return schema;
}
toJSON() {
return this.config;
}
useForceParse() {
return true;
}
}
return TestModule;
}
describe("Module", async () => {
test("basic", async () => {});
test("listener", async () => {
let result: any;
const module = createModule(Type.Object({ a: Type.String() }));
const m = new module({ a: "test" });
await m.schema().set({ a: "test2" });
m.setListener(async (c) => {
await new Promise((r) => setTimeout(r, 10));
result = stripMark(c);
});
await m.schema().set({ a: "test3" });
expect(result).toEqual({ a: "test3" });
});
});

View File

@@ -0,0 +1,197 @@
import { describe, expect, test } from "bun:test";
import { mark, stripMark } from "../src/core/utils";
import { ModuleManager } from "../src/modules/ModuleManager";
import { CURRENT_VERSION, TABLE_NAME, migrateSchema } from "../src/modules/migrations";
import { getDummyConnection } from "./helper";
describe("ModuleManager", async () => {
test("s1: no config, no build", async () => {
const { dummyConnection } = getDummyConnection();
const mm = new ModuleManager(dummyConnection);
// that is because no module is built
expect(mm.toJSON()).toEqual({ version: 0 } as any);
});
test("s2: no config, build", async () => {
const { dummyConnection } = getDummyConnection();
const mm = new ModuleManager(dummyConnection);
await mm.build();
expect(mm.version()).toBe(CURRENT_VERSION);
expect(mm.built()).toBe(true);
});
test("s3: config given, table exists, version matches", async () => {
const c = getDummyConnection();
const mm = new ModuleManager(c.dummyConnection);
await mm.build();
const version = mm.version();
const json = mm.configs();
//const { version, ...json } = mm.toJSON() as any;
const c2 = getDummyConnection();
const db = c2.dummyConnection.kysely;
await migrateSchema(CURRENT_VERSION, { db });
await db
.updateTable(TABLE_NAME)
.set({ json: JSON.stringify(json), version: CURRENT_VERSION })
.execute();
const mm2 = new ModuleManager(c2.dummyConnection, { initial: { version, ...json } });
await mm2.build();
expect(json).toEqual(mm2.configs());
});
test("s4: config given, table exists, version outdated, migrate", async () => {
const c = getDummyConnection();
const mm = new ModuleManager(c.dummyConnection);
await mm.build();
const version = mm.version();
const json = mm.configs();
//const { version, ...json } = mm.toJSON() as any;
const c2 = getDummyConnection();
const db = c2.dummyConnection.kysely;
console.log("here2");
await migrateSchema(CURRENT_VERSION, { db });
await db
.updateTable(TABLE_NAME)
.set({ json: JSON.stringify(json), version: CURRENT_VERSION - 1 })
.execute();
const mm2 = new ModuleManager(c2.dummyConnection, {
initial: { version: version - 1, ...json }
});
console.log("here3");
await mm2.build();
});
test("s5: config given, table exists, version mismatch", async () => {
const c = getDummyConnection();
const mm = new ModuleManager(c.dummyConnection);
await mm.build();
const version = mm.version();
const json = mm.configs();
//const { version, ...json } = mm.toJSON() as any;
const c2 = getDummyConnection();
const db = c2.dummyConnection.kysely;
await migrateSchema(CURRENT_VERSION, { db });
await db
.updateTable(TABLE_NAME)
.set({ json: JSON.stringify(json), version: CURRENT_VERSION })
.execute();
const mm2 = new ModuleManager(c2.dummyConnection, {
initial: { version: version - 1, ...json }
});
expect(mm2.build()).rejects.toThrow(/version.*do not match/);
});
test("s6: no config given, table exists, fetch", async () => {
const c = getDummyConnection();
const mm = new ModuleManager(c.dummyConnection);
await mm.build();
const json = mm.configs();
//const { version, ...json } = mm.toJSON() as any;
const c2 = getDummyConnection();
const db = c2.dummyConnection.kysely;
await migrateSchema(CURRENT_VERSION, { db });
const config = {
...json,
data: {
...json.data,
basepath: "/api/data2"
}
};
await db
.updateTable(TABLE_NAME)
.set({ json: JSON.stringify(config), version: CURRENT_VERSION })
.execute();
// run without config given
const mm2 = new ModuleManager(c2.dummyConnection);
await mm2.build();
expect(mm2.configs().data.basepath).toBe("/api/data2");
});
test("blank app, modify config", async () => {
const { dummyConnection } = getDummyConnection();
const mm = new ModuleManager(dummyConnection);
await mm.build();
const configs = stripMark(mm.configs());
expect(mm.configs().server.admin.color_scheme).toBe("light");
expect(() => mm.get("server").schema().patch("admin", { color_scheme: "violet" })).toThrow();
await mm.get("server").schema().patch("admin", { color_scheme: "dark" });
await mm.save();
expect(mm.configs().server.admin.color_scheme).toBe("dark");
expect(stripMark(mm.configs())).toEqual({
...configs,
server: {
...configs.server,
admin: {
...configs.server.admin,
color_scheme: "dark"
}
}
});
});
// @todo: check what happens here
/*test("blank app, modify deep config", async () => {
const { dummyConnection } = getDummyConnection();
const mm = new ModuleManager(dummyConnection);
await mm.build();
/!* await mm
.get("data")
.schema()
.patch("entities.test", {
fields: {
content: {
type: "text"
}
}
});
await mm.build();
expect(mm.configs().data.entities?.users?.fields?.email.type).toBe("text");
expect(
mm.get("data").schema().patch("desc", "entities.users.config.sort_dir")
).rejects.toThrow();
await mm.build();*!/
expect(mm.configs().data.entities?.users?.fields?.email.type).toBe("text");
console.log("here", mm.configs());
await mm
.get("data")
.schema()
.patch("entities.users", { config: { sort_dir: "desc" } });
await mm.build();
expect(mm.toJSON());
//console.log(_jsonp(mm.toJSON().data));
/!*expect(mm.configs().data.entities!.test!.fields!.content.type).toBe("text");
expect(mm.configs().data.entities!.users!.config!.sort_dir).toBe("desc");*!/
});*/
/*test("accessing modules", async () => {
const { dummyConnection } = getDummyConnection();
const mm = new ModuleManager(dummyConnection);
//mm.get("auth").mutate().set({});
});*/
});

View File

@@ -0,0 +1,15 @@
import { describe, test } from "bun:test";
import { DataApi } from "../../src/modules/data/api/DataApi";
describe("Api", async () => {
test("...", async () => {
/*const dataApi = new DataApi({
host: "https://dev-config-soma.bknd.run"
});
const one = await dataApi.readOne("users", 1);
const many = await dataApi.readMany("users", { limit: 2 });
console.log("one", one);
console.log("many", many);*/
});
});

View File

@@ -0,0 +1,41 @@
/*import { describe, expect, test } from "bun:test";
import { decodeJwt, jwtVerify } from "jose";
import { Authenticator, type User, type UserPool } from "../authenticate/Authenticator";
import { PasswordStrategy } from "../authenticate/strategies/PasswordStrategy";
import * as hash from "../utils/hash";*/
/*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 () => {
const userpool = new MemoryUserPool([
{ id: 1, email: "d", username: "test", password: await hash.sha256("test") },
]);
test("sha256 login", async () => {
const auth = new Authenticator(userpool, {
password: new PasswordStrategy({
hashing: "sha256",
}),
});
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);
});
});*/

View File

@@ -0,0 +1,89 @@
import { describe, expect, test } from "bun:test";
import { Guard } from "../../../src/auth";
describe("authorize", () => {
test("basic", async () => {
const guard = Guard.create(
["read", "write"],
{
admin: {
permissions: ["read", "write"]
}
},
{ enabled: true }
);
const user = {
role: "admin"
};
guard.setUserContext(user);
expect(guard.granted("read")).toBe(true);
expect(guard.granted("write")).toBe(true);
expect(() => guard.granted("something")).toThrow();
});
test("with default", async () => {
const guard = Guard.create(
["read", "write"],
{
admin: {
permissions: ["read", "write"]
},
guest: {
permissions: ["read"],
is_default: true
}
},
{ enabled: true }
);
expect(guard.granted("read")).toBe(true);
expect(guard.granted("write")).toBe(false);
const user = {
role: "admin"
};
guard.setUserContext(user);
expect(guard.granted("read")).toBe(true);
expect(guard.granted("write")).toBe(true);
});
test("guard implicit allow", async () => {
const guard = Guard.create([], {}, { enabled: false });
expect(guard.granted("read")).toBe(true);
expect(guard.granted("write")).toBe(true);
});
test("role implicit allow", async () => {
const guard = Guard.create(["read", "write"], {
admin: {
implicit_allow: true
}
});
guard.setUserContext({
role: "admin"
});
expect(guard.granted("read")).toBe(true);
expect(guard.granted("write")).toBe(true);
});
test("guard with guest role implicit allow", async () => {
const guard = Guard.create(["read", "write"], {
guest: {
implicit_allow: true,
is_default: true
}
});
expect(guard.getUserRole()?.name).toBe("guest");
expect(guard.granted("read")).toBe(true);
expect(guard.granted("write")).toBe(true);
});
});

View File

@@ -0,0 +1,46 @@
import { describe, test } from "bun:test";
import { OAuthStrategy } from "../../../src/auth/authenticate/strategies";
const ALL_TESTS = !!process.env.ALL_TESTS;
describe("OAuthStrategy", async () => {
const strategy = new OAuthStrategy({
type: "oidc",
client: {
client_id: process.env.OAUTH_CLIENT_ID,
client_secret: process.env.OAUTH_CLIENT_SECRET
},
name: "google"
});
const state = "---";
const redirect_uri = "http://localhost:3000/auth/google/callback";
test.skipIf(ALL_TESTS)("...", async () => {
const config = await strategy.getConfig();
console.log("config", JSON.stringify(config, null, 2));
const request = await strategy.request({
redirect_uri,
state
});
const server = Bun.serve({
fetch: async (req) => {
const url = new URL(req.url);
if (url.pathname === "/auth/google/callback") {
console.log("req", req);
const user = await strategy.callback(url, {
redirect_uri,
state
});
console.log("---user", user);
}
return new Response("Bun!");
}
});
console.log("request", request);
await new Promise((resolve) => setTimeout(resolve, 100000));
});
});

View File

@@ -0,0 +1,54 @@
import { describe, expect, it, test } from "bun:test";
import { Endpoint } from "../../src/core";
import { mockFetch2, unmockFetch } from "./helper";
const testC: any = {
json: (res: any) => Response.json(res)
};
const testNext = async () => {};
describe("Endpoint", async () => {
it("behaves as expected", async () => {
const endpoint = new Endpoint("GET", "/test", async () => {
return { hello: "test" };
});
expect(endpoint.method).toBe("GET");
expect(endpoint.path).toBe("/test");
const handler = endpoint.toHandler();
const response = await handler(testC, testNext);
expect(response.ok).toBe(true);
expect(await response.json()).toEqual({ hello: "test" });
});
it("can be $request(ed)", async () => {
const obj = { hello: "test" };
const baseUrl = "https://local.com:123";
const endpoint = Endpoint.get("/test", async () => obj);
mockFetch2(async (input: RequestInfo, init: RequestInit) => {
expect(input).toBe(`${baseUrl}/test`);
return new Response(JSON.stringify(obj), { status: 200 });
});
const response = await endpoint.$request({}, baseUrl);
expect(response).toEqual({
status: 200,
ok: true,
response: obj
});
unmockFetch();
});
it("resolves helper functions", async () => {
const params = ["/test", () => ({ hello: "test" })];
["get", "post", "patch", "put", "delete"].forEach((method) => {
const endpoint = Endpoint[method](...params);
expect(endpoint.method).toBe(method.toUpperCase());
expect(endpoint.path).toBe(params[0]);
});
});
});

View File

@@ -0,0 +1,46 @@
import { describe, expect, test } from "bun:test";
import { Event, EventManager, NoParamEvent } from "../../src/core/events";
class SpecialEvent extends Event<{ foo: string }> {
static slug = "special-event";
isBar() {
return this.params.foo === "bar";
}
}
class InformationalEvent extends NoParamEvent {
static slug = "informational-event";
}
describe("EventManager", async () => {
test("test", async () => {
const emgr = new EventManager();
emgr.registerEvents([SpecialEvent, InformationalEvent]);
emgr.onEvent(
SpecialEvent,
async (event, name) => {
console.log("Event: ", name, event.params.foo, event.isBar());
console.log("wait...");
await new Promise((resolve) => setTimeout(resolve, 100));
console.log("done waiting");
},
"sync"
);
emgr.onEvent(InformationalEvent, async (event, name) => {
console.log("Event: ", name, event.params);
});
await emgr.emit(new SpecialEvent({ foo: "bar" }));
console.log("done");
// expect construct signatures to not cause ts errors
new SpecialEvent({ foo: "bar" });
new InformationalEvent();
expect(true).toBe(true);
});
});

View File

@@ -0,0 +1,56 @@
import { describe, test } from "bun:test";
import type { TObject, TString } from "@sinclair/typebox";
import { Registry } from "../../src/core/registry/Registry";
import { type TSchema, Type } from "../../src/core/utils";
type Constructor<T> = new (...args: any[]) => T;
type ClassRef<T> = Constructor<T> & (new (...args: any[]) => T);
class What {
method() {
return null;
}
}
class What2 extends What {}
class NotAllowed {}
type Test1 = {
cls: new (...args: any[]) => What;
schema: TObject<{ type: TString }>;
enabled: boolean;
};
describe("Registry", () => {
test("adds an item", async () => {
const registry = new Registry<Test1>().set({
first: {
cls: What,
schema: Type.Object({ type: Type.String(), what: Type.String() }),
enabled: true
}
} satisfies Record<string, Test1>);
const item = registry.get("first");
registry.add("second", {
cls: What2,
schema: Type.Object({ type: Type.String(), what: Type.String() }),
enabled: true
});
registry.add("third", {
// @ts-expect-error
cls: NotAllowed,
schema: Type.Object({ type: Type.String({ default: "1" }), what22: Type.String() }),
enabled: true
});
registry.add("fourth", {
cls: What,
// @ts-expect-error
schema: Type.Object({ type: Type.Number(), what22: Type.String() }),
enabled: true
});
console.log("list", registry.all());
});
});

View File

@@ -0,0 +1,31 @@
import { baseline, bench, group, run } from "mitata";
import * as crypt from "../../../src/core/utils/crypto";
// deno
// import { ... } from 'npm:mitata';
// d8/jsc
// import { ... } from '<path to mitata>/src/cli.mjs';
const small = "hello";
const big = "hello".repeat(1000);
group("hashing (small)", () => {
baseline("baseline", () => JSON.parse(JSON.stringify({ small })));
bench("sha-1", async () => await crypt.hash.sha256(small));
bench("sha-256", async () => await crypt.hash.sha256(small));
});
group("hashing (big)", () => {
baseline("baseline", () => JSON.parse(JSON.stringify({ big })));
bench("sha-1", async () => await crypt.hash.sha256(big));
bench("sha-256", async () => await crypt.hash.sha256(big));
});
/*group({ name: 'group2', summary: false }, () => {
bench('new Array(0)', () => new Array(0));
bench('new Array(1024)', () => new Array(1024));
});*/
// @ts-ignore
await run();

View File

@@ -0,0 +1,57 @@
import * as assert from "node:assert/strict";
import { createWriteStream } from "node:fs";
import { after, beforeEach, describe, test } from "node:test";
import { Miniflare } from "miniflare";
import {
CloudflareKVCacheItem,
CloudflareKVCachePool
} from "../../../src/core/cache/adapters/CloudflareKvCache";
import { runTests } from "./cache-test-suite";
// https://github.com/nodejs/node/issues/44372#issuecomment-1736530480
console.log = async (message: any) => {
const tty = createWriteStream("/dev/tty");
const msg = typeof message === "string" ? message : JSON.stringify(message, null, 2);
return tty.write(`${msg}\n`);
};
describe("CloudflareKv", async () => {
let mf: Miniflare;
runTests({
createCache: async () => {
if (mf) {
await mf.dispose();
}
mf = new Miniflare({
modules: true,
script: "export default { async fetch() { return new Response(null); } }",
kvNamespaces: ["TEST"]
});
const kv = await mf.getKVNamespace("TEST");
return new CloudflareKVCachePool(kv as any);
},
createItem: (key, value) => new CloudflareKVCacheItem(key, value),
tester: {
test,
beforeEach,
expect: (actual?: any) => {
return {
toBe(expected: any) {
assert.equal(actual, expected);
},
toEqual(expected: any) {
assert.deepEqual(actual, expected);
},
toBeUndefined() {
assert.equal(actual, undefined);
}
};
}
}
});
after(async () => {
await mf?.dispose();
});
});

View File

@@ -0,0 +1,15 @@
import { beforeEach, describe, expect, test } from "bun:test";
import { MemoryCache, MemoryCacheItem } from "../../../src/core/cache/adapters/MemoryCache";
import { runTests } from "./cache-test-suite";
describe("MemoryCache", () => {
runTests({
createCache: async () => new MemoryCache(),
createItem: (key, value) => new MemoryCacheItem(key, value),
tester: {
test,
beforeEach,
expect
}
});
});

View File

@@ -0,0 +1,84 @@
//import { beforeEach as bunBeforeEach, expect as bunExpect, test as bunTest } from "bun:test";
import type { ICacheItem, ICachePool } from "../../../src/core/cache/cache-interface";
export type TestOptions = {
createCache: () => Promise<ICachePool>;
createItem: (key: string, value: any) => ICacheItem;
tester: {
test: (name: string, fn: () => Promise<void>) => void;
beforeEach: (fn: () => Promise<void>) => void;
expect: (actual?: any) => {
toBe(expected: any): void;
toEqual(expected: any): void;
toBeUndefined(): void;
};
};
};
export function runTests({ createCache, createItem, tester }: TestOptions) {
let cache: ICachePool<string>;
const { test, beforeEach, expect } = tester;
beforeEach(async () => {
cache = await createCache();
});
test("getItem returns correct item", async () => {
const item = createItem("key1", "value1");
await cache.save(item);
const retrievedItem = await cache.get("key1");
expect(retrievedItem.value()).toEqual(item.value());
});
test("getItem returns new item when key does not exist", async () => {
const retrievedItem = await cache.get("key1");
expect(retrievedItem.key()).toEqual("key1");
expect(retrievedItem.value()).toBeUndefined();
});
test("getItems returns correct items", async () => {
const item1 = createItem("key1", "value1");
const item2 = createItem("key2", "value2");
await cache.save(item1);
await cache.save(item2);
const retrievedItems = await cache.getMany(["key1", "key2"]);
expect(retrievedItems.get("key1")?.value()).toEqual(item1.value());
expect(retrievedItems.get("key2")?.value()).toEqual(item2.value());
});
test("hasItem returns true when item exists and is a hit", async () => {
const item = createItem("key1", "value1");
await cache.save(item);
expect(await cache.has("key1")).toBe(true);
});
test("clear and deleteItem correctly clear the cache and delete items", async () => {
const item = createItem("key1", "value1");
await cache.save(item);
if (cache.supports().clear) {
await cache.clear();
} else {
await cache.delete("key1");
}
expect(await cache.has("key1")).toBe(false);
});
test("save correctly saves items to the cache", async () => {
const item = createItem("key1", "value1");
await cache.save(item);
expect(await cache.has("key1")).toBe(true);
});
test("putItem correctly puts items in the cache ", async () => {
await cache.put("key1", "value1", { ttl: 60 });
const item = await cache.get("key1");
expect(item.value()).toEqual("value1");
expect(item.hit()).toBe(true);
});
/*test("commit returns true", async () => {
expect(await cache.commit()).toBe(true);
});*/
}

View File

@@ -0,0 +1,14 @@
import { describe, test } from "bun:test";
import { checksum, hash } from "../../src/core/utils";
describe("crypto", async () => {
test("sha256", async () => {
console.log(await hash.sha256("test"));
});
test("sha1", async () => {
console.log(await hash.sha1("test"));
});
test("checksum", async () => {
console.log(checksum("hello world"));
});
});

View File

@@ -0,0 +1,18 @@
import { jest } from "bun:test";
let _oldFetch: typeof fetch;
export function mockFetch(responseMethods: Partial<Response>) {
_oldFetch = global.fetch;
// @ts-ignore
global.fetch = jest.fn(() => Promise.resolve(responseMethods));
}
export function mockFetch2(newFetch: (input: RequestInfo, init: RequestInit) => Promise<Response>) {
_oldFetch = global.fetch;
// @ts-ignore
global.fetch = jest.fn(newFetch);
}
export function unmockFetch() {
global.fetch = _oldFetch;
}

View File

@@ -0,0 +1,332 @@
import { describe, expect, test } from "bun:test";
import { SchemaObject } from "../../../src/core";
import { Type } from "../../../src/core/utils";
describe("SchemaObject", async () => {
test("basic", async () => {
const m = new SchemaObject(
Type.Object({ a: Type.String({ default: "b" }) }),
{ a: "test" },
{
forceParse: true
}
);
expect(m.get()).toEqual({ a: "test" });
expect(m.default()).toEqual({ a: "b" });
// direct modification is not allowed
expect(() => {
m.get().a = "test2";
}).toThrow();
});
test("patch", async () => {
const m = new SchemaObject(
Type.Object({
s: Type.Object(
{
a: Type.String({ default: "b" }),
b: Type.Object(
{
c: Type.String({ default: "d" }),
e: Type.String({ default: "f" })
},
{ default: {} }
)
},
{ default: {}, additionalProperties: false }
)
})
);
expect(m.get()).toEqual({ s: { a: "b", b: { c: "d", e: "f" } } });
await m.patch("s.a", "c");
// non-existing path on no additional properties
expect(() => m.patch("s.s.s", "c")).toThrow();
// wrong type
expect(() => m.patch("s.a", 1)).toThrow();
// should have only the valid change applied
expect(m.get().s.b.c).toBe("d");
expect(m.get()).toEqual({ s: { a: "c", b: { c: "d", e: "f" } } });
await m.patch("s.b.c", "d2");
expect(m.get()).toEqual({ s: { a: "c", b: { c: "d2", e: "f" } } });
});
test("patch array", async () => {
const m = new SchemaObject(
Type.Object({
methods: Type.Array(Type.String(), { default: ["GET", "PATCH"] })
})
);
expect(m.get()).toEqual({ methods: ["GET", "PATCH"] });
// array values are fully overwritten, whether accessed by index ...
m.patch("methods[0]", "POST");
expect(m.get()).toEqual({ methods: ["POST"] });
// or by path!
m.patch("methods", ["GET", "DELETE"]);
expect(m.get()).toEqual({ methods: ["GET", "DELETE"] });
});
test("remove", async () => {
const m = new SchemaObject(
Type.Object({
s: Type.Object(
{
a: Type.String({ default: "b" }),
b: Type.Object(
{
c: Type.String({ default: "d" })
},
{ default: {} }
)
},
{ default: {} }
)
})
);
expect(m.get()).toEqual({ s: { a: "b", b: { c: "d" } } });
// expect no change, because the default then applies
m.remove("s.a");
expect(m.get()).toEqual({ s: { a: "b", b: { c: "d" } } });
// adding another path, and then deleting it
m.patch("s.c", "d");
expect(m.get()).toEqual({ s: { a: "b", b: { c: "d" }, c: "d" } } as any);
// now it should be removed without applying again
m.remove("s.c");
expect(m.get()).toEqual({ s: { a: "b", b: { c: "d" } } });
});
test("set", async () => {
const m = new SchemaObject(
Type.Object({
methods: Type.Array(Type.String(), { default: ["GET", "PATCH"] })
})
);
expect(m.get()).toEqual({ methods: ["GET", "PATCH"] });
m.set({ methods: ["GET", "POST"] });
expect(m.get()).toEqual({ methods: ["GET", "POST"] });
// wrong type
expect(() => m.set({ methods: [1] as any })).toThrow();
});
test("listener", async () => {
let called = false;
let result: any;
const m = new SchemaObject(
Type.Object({
methods: Type.Array(Type.String(), { default: ["GET", "PATCH"] })
}),
undefined,
{
onUpdate: async (config) => {
await new Promise((r) => setTimeout(r, 10));
called = true;
result = config;
}
}
);
await m.set({ methods: ["GET", "POST"] });
expect(called).toBe(true);
expect(result).toEqual({ methods: ["GET", "POST"] });
});
test("throwIfRestricted", async () => {
const m = new SchemaObject(Type.Object({}), undefined, {
restrictPaths: ["a.b"]
});
expect(() => m.throwIfRestricted("a.b")).toThrow();
expect(m.throwIfRestricted("a.c")).toBeUndefined();
expect(() => m.throwIfRestricted({ a: { b: "c" } })).toThrow();
expect(m.throwIfRestricted({ a: { c: "d" } })).toBeUndefined();
});
test("restriction bypass", async () => {
const m = new SchemaObject(
Type.Object({
s: Type.Object(
{
a: Type.String({ default: "b" }),
b: Type.Object(
{
c: Type.String({ default: "d" })
},
{ default: {} }
)
},
{ default: {} }
)
}),
undefined,
{
restrictPaths: ["s.b"]
}
);
expect(() => m.patch("s.b.c", "e")).toThrow();
expect(m.bypass().patch("s.b.c", "e")).toBeDefined();
expect(() => m.patch("s.b.c", "f")).toThrow();
expect(m.get()).toEqual({ s: { a: "b", b: { c: "e" } } });
});
const dataEntitiesSchema = Type.Object(
{
entities: Type.Object(
{},
{
additionalProperties: Type.Object({
fields: Type.Object(
{},
{
additionalProperties: Type.Object({
type: Type.String(),
config: Type.Optional(
Type.Object({}, { additionalProperties: Type.String() })
)
})
}
),
config: Type.Optional(Type.Object({}, { additionalProperties: Type.String() }))
})
}
)
},
{
additionalProperties: false
}
);
test("patch safe object, overwrite", async () => {
const data = {
entities: {
some: {
fields: {
a: { type: "string", config: { some: "thing" } }
}
}
}
};
const m = new SchemaObject(dataEntitiesSchema, data, {
forceParse: true,
overwritePaths: [/^entities\..*\.fields\..*\.config/]
});
m.patch("entities.some.fields.a", { type: "string", config: { another: "one" } });
expect(m.get()).toEqual({
entities: {
some: {
fields: {
a: { type: "string", config: { another: "one" } }
}
}
}
});
});
test("patch safe object, overwrite 2", async () => {
const data = {
entities: {
users: {
fields: {
email: { type: "string" },
password: { type: "string" }
}
}
}
};
const m = new SchemaObject(dataEntitiesSchema, data, {
forceParse: true,
overwritePaths: [/^entities\..*\.fields\..*\.config\.html_config$/]
});
m.patch("entities.test", {
fields: {
content: {
type: "text"
}
}
});
expect(m.get()).toEqual({
entities: {
users: {
fields: {
email: { type: "string" },
password: { type: "string" }
}
},
test: {
fields: {
content: {
type: "text"
}
}
}
}
});
});
test("patch safe object, overwrite 3", async () => {
const data = {
entities: {
users: {
fields: {
email: { type: "string" },
password: { type: "string" }
}
}
}
};
const m = new SchemaObject(dataEntitiesSchema, data, {
forceParse: true,
overwritePaths: [/^entities\..*\.fields\..*\.config\.html_config$/]
});
expect(m.patch("desc", "entities.users.config.sort_dir")).rejects.toThrow();
m.patch("entities.test", {
fields: {
content: {
type: "text"
}
}
});
m.patch("entities.users.config", {
sort_dir: "desc"
});
expect(m.get()).toEqual({
entities: {
users: {
fields: {
email: { type: "string" },
password: { type: "string" }
},
config: {
sort_dir: "desc"
}
},
test: {
fields: {
content: {
type: "text"
}
}
}
}
});
});
});

View File

@@ -0,0 +1,83 @@
import { describe, expect, test } from "bun:test";
import { type ObjectQuery, convert, validate } from "../../../src/core/object/query/object-query";
import { deprecated__whereRepoSchema } from "../../../src/data";
describe("object-query", () => {
const q: ObjectQuery = { name: "Michael" };
const q2: ObjectQuery = { name: { $isnull: 1 } };
const q3: ObjectQuery = { name: "Michael", age: { $gt: 18 } };
const bag = { q, q2, q3 };
test("translates into legacy", async () => {
for (const [key, value] of Object.entries(bag)) {
const obj = convert(value);
try {
const parsed = deprecated__whereRepoSchema.parse(obj);
expect(parsed).toBeDefined();
} catch (e) {
console.log("errored", { obj, value });
console.error(key, e);
}
}
});
test("validates", async () => {
const converted = convert({
name: { $eq: "ch" }
});
validate(converted, { name: "Michael" });
});
test("single validation", () => {
const tests: [ObjectQuery, any, boolean][] = [
[{ name: { $eq: 1 } }, { name: "Michael" }, false],
[{ name: "Michael", age: 40 }, { name: "Michael", age: 40 }, true],
[{ name: "Michael", age: 40 }, { name: "Michael", age: 41 }, false],
[{ name: { $eq: "Michael" } }, { name: "Michael" }, true],
[{ int: { $between: [1, 2] } }, { int: 1 }, true],
[{ int: { $between: [1, 2] } }, { int: 3 }, false],
[{ some: { $isnull: 1 } }, { some: null }, true],
[{ some: { $isnull: true } }, { some: null }, true],
[{ some: { $isnull: 0 } }, { some: null }, false],
[{ some: { $isnull: false } }, { some: null }, false],
[{ some: { $isnull: 1 } }, { some: 1 }, false],
[{ val: { $notnull: 1 } }, { val: 1 }, true],
[{ val: { $notnull: 1 } }, { val: null }, false],
[{ val: { $regex: ".*" } }, { val: "test" }, true],
[{ val: { $regex: /^t.*/ } }, { val: "test" }, true],
[{ val: { $regex: /^b.*/ } }, { val: "test" }, false]
];
for (const [query, object, expected] of tests) {
const result = validate(query, object);
expect(result).toBe(expected);
}
});
test("multiple validations", () => {
const tests: [ObjectQuery, any, boolean][] = [
// multiple constraints per property
[{ val: { $lt: 10, $gte: 3 } }, { val: 7 }, true],
[{ val: { $lt: 10, $gte: 3 } }, { val: 2 }, false],
[{ val: { $lt: 10, $gte: 3 } }, { val: 11 }, false],
// multiple properties
[{ val1: { $eq: "foo" }, val2: { $eq: "bar" } }, { val1: "foo", val2: "bar" }, true],
[{ val1: { $eq: "foo" }, val2: { $eq: "bar" } }, { val1: "bar", val2: "foo" }, false],
// or constructs
[
{ $or: { val1: { $eq: "foo" }, val2: { $eq: "bar" } } },
{ val1: "foo", val2: "bar" },
true
],
[{ val1: { $eq: 1 }, $or: { val1: { $eq: 2 } } }, { val1: 1 }, true],
[{ val1: { $eq: 1 }, $or: { val1: { $eq: 2 } } }, { val1: 3 }, false]
];
for (const [query, object, expected] of tests) {
const result = validate(query, object);
expect(result).toBe(expected);
}
});
});

View File

@@ -0,0 +1,111 @@
import { describe, expect, test } from "bun:test";
import { Perf } from "../../src/core/utils";
import * as reqres from "../../src/core/utils/reqres";
import * as strings from "../../src/core/utils/strings";
async function wait(ms: number) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
describe("Core Utils", async () => {
describe("[core] strings", async () => {
test("objectToKeyValueArray", async () => {
const obj = { a: 1, b: 2, c: 3 };
const result = strings.objectToKeyValueArray(obj);
expect(result).toEqual([
{ key: "a", value: 1 },
{ key: "b", value: 2 },
{ key: "c", value: 3 }
]);
});
test("snakeToPascalWithSpaces", async () => {
const result = strings.snakeToPascalWithSpaces("snake_to_pascal");
expect(result).toBe("Snake To Pascal");
});
test("randomString", async () => {
const result = strings.randomString(10);
expect(result).toHaveLength(10);
});
test("pascalToKebab", async () => {
const result = strings.pascalToKebab("PascalCase");
expect(result).toBe("pascal-case");
});
test("replaceSimplePlaceholders", async () => {
const str = "Hello, {$name}!";
const vars = { name: "John" };
const result = strings.replaceSimplePlaceholders(str, vars);
expect(result).toBe("Hello, John!");
});
});
describe("reqres", async () => {
test("headersToObject", () => {
const headers = new Headers();
headers.append("Content-Type", "application/json");
headers.append("Authorization", "Bearer 123");
const obj = reqres.headersToObject(headers);
expect(obj).toEqual({
"content-type": "application/json",
authorization: "Bearer 123"
});
});
test("replaceUrlParam", () => {
const url = "/api/:id/:name";
const params = { id: "123", name: "test" };
const result = reqres.replaceUrlParam(url, params);
expect(result).toBe("/api/123/test");
});
test("encode", () => {
const obj = { id: "123", name: "test" };
const result = reqres.encodeSearch(obj);
expect(result).toBe("id=123&name=test");
const obj2 = { id: "123", name: ["test1", "test2"] };
const result2 = reqres.encodeSearch(obj2);
expect(result2).toBe("id=123&name=test1&name=test2");
const obj3 = { id: "123", name: { test: "test" } };
const result3 = reqres.encodeSearch(obj3, { encode: true });
expect(result3).toBe("id=123&name=%7B%22test%22%3A%22test%22%7D");
});
});
describe("perf", async () => {
test("marks", async () => {
const perf = Perf.start();
await wait(20);
perf.mark("boot");
await wait(10);
perf.mark("another");
perf.close();
const perf2 = Perf.start();
await wait(40);
perf2.mark("booted");
await wait(10);
perf2.mark("what");
perf2.close();
expect(perf.result().total).toBeLessThan(perf2.result().total);
});
test("executes correctly", async () => {
// write a test for "execute" method
let count = 0;
await Perf.execute(async () => {
count += 1;
}, 2);
expect(count).toBe(2);
});
});
});

View File

@@ -0,0 +1,235 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
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 { disableConsoleLog, enableConsoleLog, getDummyConnection } from "../helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
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 }),
new TextField("bio")
]);
const posts = new Entity("posts", [new TextField("content")]);
const em = new EntityManager([users, posts], dummyConnection, [
new ManyToOneRelation(posts, users)
]);
await em.schema().sync({ force: true });
const fixtures = {
users: [
{ name: "foo", bio: "bar" },
{ name: "bar", bio: null },
{ name: "baz", bio: "!!!" }
],
posts: [
{ content: "post 1", users_id: 1 },
{ content: "post 2", users_id: 2 }
]
};
const ctx: any = { em, guard: new Guard() };
const controller = new DataController(ctx, dataConfig);
const app = controller.getController();
test("entityExists", async () => {
expect(controller.entityExists("users")).toBe(true);
expect(controller.entityExists("posts")).toBe(true);
expect(controller.entityExists("settings")).toBe(false);
});
// @todo: update
test("/ (get info)", async () => {
const res = await app.request("/");
const data = (await res.json()) as any;
const entities = Object.keys(data.entities);
const relations = Object.values(data.relations).map((r: any) => r.type);
expect(entities).toEqual(["users", "posts"]);
expect(relations).toEqual(["n:1"]);
});
test("/:entity (insert one)", async () => {
//console.log("app.routes", app.routes);
// create users
for await (const _user of fixtures.users) {
const res = await app.request("/users", {
method: "POST",
body: JSON.stringify(_user)
});
//console.log("res", { _user }, res);
const result = (await res.json()) as MutatorResponse;
const { id, ...data } = result.data as any;
expect(res.status).toBe(201);
expect(res.ok).toBe(true);
expect(data as any).toEqual(_user);
}
// create posts
for await (const _post of fixtures.posts) {
const res = await app.request("/posts", {
method: "POST",
body: JSON.stringify(_post)
});
const result = (await res.json()) as MutatorResponse;
const { id, ...data } = result.data as any;
expect(res.status).toBe(201);
expect(res.ok).toBe(true);
expect(data as any).toEqual(_post);
}
});
test("/:entity (read many)", async () => {
const res = await app.request("/users");
const data = (await res.json()) as RepositoryResponse;
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");
});
test("/:entity/query (func query)", async () => {
const res = await app.request("/users/query", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
where: { bio: { $isnull: 1 } }
})
});
const data = (await res.json()) as RepositoryResponse;
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");
});
test("/:entity (read many, paginated)", async () => {
const res = await app.request("/users?limit=1&offset=2");
const data = (await res.json()) as RepositoryResponse;
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");
});
test("/:entity/:id (read one)", async () => {
const res = await app.request("/users/3");
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.items).toBe(1);
expect(data.data).toEqual({ id: 3, ...fixtures.users[2] });
});
test("/:entity (update one)", async () => {
const res = await app.request("/users/3", {
method: "PATCH",
body: JSON.stringify({ name: "new name" })
});
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" });
});
test("/:entity/:id/:reference (read references)", async () => {
const res = await app.request("/users/1/posts");
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.items).toBe(1);
expect(data.data.length).toBe(1);
expect(data.data[0].content).toBe("post 1");
});
test("/:entity/:id (delete one)", async () => {
const res = await app.request("/posts/2", {
method: "DELETE"
});
const { data } = (await res.json()) as RepositoryResponse<EntityData>;
expect(data).toEqual({ id: 2, ...fixtures.posts[1] });
// verify
const res2 = await app.request("/posts");
const data2 = (await res2.json()) as RepositoryResponse;
expect(data2.meta.total).toBe(1);
});
});
});

View File

@@ -0,0 +1,92 @@
import { describe, expect, test } from "bun:test";
import type { QueryObject } from "ufo";
import { WhereBuilder, type WhereQuery } from "../../src/data/entities/query/WhereBuilder";
import { getDummyConnection } from "./helper";
const t = "t";
describe("data-query-impl", () => {
function qb() {
const c = getDummyConnection();
const kysely = c.dummyConnection.kysely;
return kysely.selectFrom(t).selectAll();
}
function compile(q: QueryObject) {
const { sql, parameters } = WhereBuilder.addClause(qb(), q).compile();
return { sql, parameters };
}
test("single validation", () => {
const tests: [WhereQuery, string, any[]][] = [
[{ name: "Michael", age: 40 }, '("name" = ? and "age" = ?)', ["Michael", 40]],
[{ name: { $eq: "Michael" } }, '"name" = ?', ["Michael"]],
[{ int: { $between: [1, 2] } }, '"int" between ? and ?', [1, 2]],
[{ val: { $isnull: 1 } }, '"val" is null', []],
[{ val: { $isnull: true } }, '"val" is null', []],
[{ val: { $isnull: 0 } }, '"val" is not null', []],
[{ val: { $isnull: false } }, '"val" is not null', []],
[{ val: { $like: "what" } }, '"val" like ?', ["what"]],
[{ val: { $like: "w*t" } }, '"val" like ?', ["w%t"]]
];
for (const [query, expectedSql, expectedParams] of tests) {
const { sql, parameters } = compile(query);
expect(sql).toContain(`select * from "t" where ${expectedSql}`);
expect(parameters).toEqual(expectedParams);
}
});
test("multiple validations", () => {
const tests: [WhereQuery, string, any[]][] = [
// multiple constraints per property
[{ val: { $lt: 10, $gte: 3 } }, '("val" < ? and "val" >= ?)', [10, 3]],
[{ val: { $lt: 10, $gte: 3 } }, '("val" < ? and "val" >= ?)', [10, 3]],
[{ val: { $lt: 10, $gte: 3 } }, '("val" < ? and "val" >= ?)', [10, 3]],
// multiple properties
[
{ val1: { $eq: "foo" }, val2: { $eq: "bar" } },
'("val1" = ? and "val2" = ?)',
["foo", "bar"]
],
[
{ val1: { $eq: "foo" }, val2: { $eq: "bar" } },
'("val1" = ? and "val2" = ?)',
["foo", "bar"]
],
// or constructs
[
{ $or: { val1: { $eq: "foo" }, val2: { $eq: "bar" } } },
'("val1" = ? or "val2" = ?)',
["foo", "bar"]
],
[{ val1: { $eq: 1 }, $or: { val1: { $eq: 2 } } }, '("val1" = ? or "val1" = ?)', [1, 2]],
[{ val1: { $eq: 1 }, $or: { val1: { $eq: 2 } } }, '("val1" = ? or "val1" = ?)', [1, 2]]
];
for (const [query, expectedSql, expectedParams] of tests) {
const { sql, parameters } = compile(query);
expect(sql).toContain(`select * from "t" where ${expectedSql}`);
expect(parameters).toEqual(expectedParams);
}
});
test("keys", () => {
const tests: [WhereQuery, string[]][] = [
// multiple constraints per property
[{ val: { $lt: 10, $gte: 3 } }, ["val"]],
// multiple properties
[{ val1: { $eq: "foo" }, val2: { $eq: "bar" } }, ["val1", "val2"]],
// or constructs
[{ $or: { val1: { $eq: "foo" }, val2: { $eq: "bar" } } }, ["val1", "val2"]],
[{ val1: { $eq: 1 }, $or: { val1: { $eq: 2 } } }, ["val1"]]
];
for (const [query, expectedKeys] of tests) {
const keys = WhereBuilder.getPropertyNames(query);
expect(keys).toEqual(expectedKeys);
}
});
});

View File

@@ -0,0 +1,113 @@
import { afterAll, describe, expect, test } from "bun:test";
import {
Entity,
EntityManager,
NumberField,
PrimaryField,
Repository,
TextField
} from "../../src/data";
import { getDummyConnection } from "./helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterAll(afterAllCleanup);
describe("some tests", async () => {
//const connection = getLocalLibsqlConnection();
const connection = dummyConnection;
const users = new Entity("users", [
new TextField("username", { required: true, default_value: "nobody" }),
new TextField("email", { max_length: 3 })
]);
const posts = new Entity("posts", [
new TextField("title"),
new TextField("content"),
new TextField("created_at"),
new NumberField("likes", { default_value: 0 })
]);
const em = new EntityManager([users, posts], connection);
await em.schema().sync({ force: true });
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" = ? limit ?'
);
expect(query.parameters).toEqual([1, 1]);
expect(query.result).toEqual([]);
});
test("findMany", async () => {
const query = await em.repository(users).findMany();
expect(query.sql).toBe(
'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.result).toEqual([]);
});
test("findMany with number", async () => {
const query = await em.repository(posts).findMany();
expect(query.sql).toBe(
'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.result).toEqual([]);
});
test("try adding an existing field name", async () => {
expect(() => {
new Entity("users", [
new TextField("username"),
new TextField("email"),
new TextField("email") // not throwing, it's just being ignored
]);
}).toBeDefined();
expect(() => {
new Entity("users", [
new TextField("username"),
new TextField("email"),
// field config differs, will throw
new TextField("email", { required: true })
]);
}).toThrow();
expect(() => {
new Entity("users", [
new PrimaryField(),
new TextField("username"),
new TextField("email")
]);
}).toBeDefined();
});
test("try adding duplicate entities", async () => {
const entity = new Entity("users", [new TextField("username")]);
const entity2 = new Entity("users", [new TextField("userna1me")]);
expect(() => {
// will not throw, just ignored
new EntityManager([entity, entity], connection);
}).toBeDefined();
expect(() => {
// the config differs, so it throws
new EntityManager([entity, entity2], connection);
}).toThrow();
});
});

View File

@@ -0,0 +1,35 @@
import { unlink } from "node:fs/promises";
import type { SqliteDatabase } from "kysely";
// @ts-ignore
import Database from "libsql";
import { SqliteLocalConnection } from "../../src/data";
export function getDummyDatabase(memory: boolean = true): {
dummyDb: SqliteDatabase;
afterAllCleanup: () => Promise<boolean>;
} {
const DB_NAME = memory ? ":memory:" : `${Math.random().toString(36).substring(7)}.db`;
const dummyDb = new Database(DB_NAME);
return {
dummyDb,
afterAllCleanup: async () => {
if (!memory) await unlink(DB_NAME);
return true;
}
};
}
export function getDummyConnection(memory: boolean = true) {
const { dummyDb, afterAllCleanup } = getDummyDatabase(memory);
const dummyConnection = new SqliteLocalConnection(dummyDb);
return {
dummyConnection,
afterAllCleanup
};
}
export function getLocalLibsqlConnection() {
return { url: "http://127.0.0.1:8080" };
}

View File

@@ -0,0 +1,50 @@
// eslint-disable-next-line import/no-unresolved
import { afterAll, describe, expect, test } from "bun:test";
import {
Entity,
EntityManager,
ManyToOneRelation,
NumberField,
SchemaManager,
TextField
} from "../../src/data";
import { getDummyConnection } from "./helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterAll(afterAllCleanup);
describe("Mutator relation", async () => {
const connection = dummyConnection;
//const connection = getLocalLibsqlConnection();
//const connection = getCreds("DB_DATA");
const posts = new Entity("posts", [
new TextField("title"),
new TextField("content", { default_value: "..." }),
new NumberField("count", { default_value: 0 })
]);
const users = new Entity("users", [new TextField("username")]);
const relations = [new ManyToOneRelation(posts, users)];
const em = new EntityManager([posts, users], connection, relations);
const schema = new SchemaManager(em);
await schema.sync({ force: true });
test("add users", async () => {
const { data } = await em.mutator(users).insertOne({ username: "user1" });
await em.mutator(users).insertOne({ username: "user2" });
// create some posts
await em.mutator(posts).insertOne({ title: "post1", content: "content1" });
// expect to throw
expect(em.mutator(posts).insertOne({ title: "post2", users_id: 10 })).rejects.toThrow();
expect(
em.mutator(posts).insertOne({ title: "post2", users_id: data.id })
).resolves.toBeDefined();
});
});

View File

@@ -0,0 +1,145 @@
// eslint-disable-next-line import/no-unresolved
import { afterAll, describe, expect, test } from "bun:test";
import { Entity, EntityManager, Mutator, NumberField, TextField } from "../../src/data";
import { TransformPersistFailedException } from "../../src/data/errors";
import { getDummyConnection } from "./helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterAll(afterAllCleanup);
describe("Mutator simple", async () => {
const connection = dummyConnection;
//const connection = getLocalLibsqlConnection();
//const connection = getCreds("DB_DATA");
const items = new Entity("items", [
new TextField("label", { required: true, minLength: 1 }),
new NumberField("count", { default_value: 0 })
]);
const em = new EntityManager([items], connection);
await em.connection.kysely.schema
.createTable("items")
.ifNotExists()
.addColumn("id", "integer", (col) => col.primaryKey().autoIncrement().notNull())
.addColumn("label", "text")
.addColumn("count", "integer")
.execute();
test("insert single row", async () => {
const mutation = await em.mutator(items).insertOne({
label: "test",
count: 1
});
expect(mutation.sql).toBe(
'insert into "items" ("count", "label") values (?, ?) returning "id", "label", "count"'
);
expect(mutation.data).toEqual({ id: 1, label: "test", count: 1 });
const query = await em.repository(items).findMany({
limit: 1,
sort: {
by: "id",
dir: "desc"
}
});
expect(query.result).toEqual([{ id: 1, label: "test", count: 1 }]);
});
test("update inserted row", async () => {
const query = await em.repository(items).findMany({
limit: 1,
sort: {
by: "id",
dir: "desc"
}
});
const id = query.data![0].id as number;
const mutation = await em.mutator(items).updateOne(id, {
label: "new label",
count: 100
});
expect(mutation.sql).toBe(
'update "items" set "label" = ?, "count" = ? where "id" = ? returning "id", "label", "count"'
);
expect(mutation.data).toEqual({ id, label: "new label", count: 100 });
});
test("delete updated row", async () => {
const query = await em.repository(items).findMany({
limit: 1,
sort: {
by: "id",
dir: "desc"
}
});
const id = query.data![0].id as number;
const mutation = await em.mutator(items).deleteOne(id);
expect(mutation.sql).toBe(
'delete from "items" where "id" = ? returning "id", "label", "count"'
);
expect(mutation.data).toEqual({ id, label: "new label", count: 100 });
const query2 = await em.repository(items).findId(id);
expect(query2.result.length).toBe(0);
});
test("validation: insert incomplete row", async () => {
const incompleteCreate = async () =>
await em.mutator(items).insertOne({
//label: "test",
count: 1
});
expect(incompleteCreate()).rejects.toThrow();
});
test("validation: insert invalid row", async () => {
const invalidCreate1 = async () =>
await em.mutator(items).insertOne({
label: 111, // this should work
count: "1" // this should fail
});
expect(invalidCreate1()).rejects.toThrow(TransformPersistFailedException);
const invalidCreate2 = async () =>
await em.mutator(items).insertOne({
label: "", // this should fail
count: 1
});
expect(invalidCreate2()).rejects.toThrow(TransformPersistFailedException);
});
test("test default value", async () => {
const res = await em.mutator(items).insertOne({ label: "yo" });
expect(res.data.count).toBe(0);
});
test("deleteMany", async () => {
await em.mutator(items).insertOne({ label: "keep" });
await em.mutator(items).insertOne({ label: "delete" });
await em.mutator(items).insertOne({ label: "delete" });
const data = (await em.repository(items).findMany()).data;
//console.log(data);
await em.mutator(items).deleteMany({ label: "delete" });
expect((await em.repository(items).findMany()).data.length).toBe(data.length - 2);
//console.log((await em.repository(items).findMany()).data);
await em.mutator(items).deleteMany();
expect((await em.repository(items).findMany()).data.length).toBe(0);
//expect(res.data.count).toBe(0);
});
});

View File

@@ -0,0 +1,96 @@
import { afterAll, expect as bunExpect, describe, test } from "bun:test";
import { stripMark } from "../../src/core/utils";
import { Entity, EntityManager, PolymorphicRelation, TextField } from "../../src/data";
import { getDummyConnection } from "./helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterAll(afterAllCleanup);
const expect = (value: any) => bunExpect(stripMark(value));
describe("Polymorphic", async () => {
test("Simple", async () => {
const categories = new Entity("categories", [new TextField("name")]);
const media = new Entity("media", [new TextField("path")]);
const entities = [media, categories];
const relation = new PolymorphicRelation(categories, media, { mappedBy: "image" });
const em = new EntityManager(entities, dummyConnection, [relation]);
expect(em.relationsOf(categories.name).map((r) => r.toJSON())[0]).toEqual({
type: "poly",
source: "categories",
target: "media",
config: {
mappedBy: "image"
}
});
// media should not see categories
expect(em.relationsOf(media.name).map((r) => r.toJSON())).toEqual([]);
// it's important that media cannot access categories
expect(em.relations.targetRelationsOf(categories).map((r) => r.source.entity.name)).toEqual(
[]
);
expect(em.relations.targetRelationsOf(media).map((r) => r.source.entity.name)).toEqual([]);
expect(em.relations.sourceRelationsOf(categories).map((r) => r.target.entity.name)).toEqual([
"media"
]);
expect(em.relations.sourceRelationsOf(categories).map((r) => r.target.reference)).toEqual([
"image"
]);
expect(em.relations.sourceRelationsOf(media).map((r) => r.target.entity.name)).toEqual([]);
// expect that polymorphic fields are added to media
expect(media.getFields().map((f) => f.name)).toEqual([
"id",
"path",
"reference",
"entity_id"
]);
expect(media.getSelect()).toEqual(["id", "path"]);
});
test("Multiple to the same", async () => {
const categories = new Entity("categories", [new TextField("name")]);
const media = new Entity("media", [new TextField("path")]);
const entities = [media, categories];
const single = new PolymorphicRelation(categories, media, {
mappedBy: "single",
targetCardinality: 1
});
const multiple = new PolymorphicRelation(categories, media, { mappedBy: "multiple" });
const em = new EntityManager(entities, dummyConnection, [single, multiple]);
// media should not see categories
expect(em.relationsOf(media.name).map((r) => r.toJSON())).toEqual([]);
// it's important that media cannot access categories
expect(em.relations.targetRelationsOf(categories).map((r) => r.source.entity.name)).toEqual(
[]
);
expect(em.relations.targetRelationsOf(media).map((r) => r.source.entity.name)).toEqual([]);
expect(em.relations.sourceRelationsOf(categories).map((r) => r.target.entity.name)).toEqual([
"media",
"media"
]);
expect(em.relations.sourceRelationsOf(categories).map((r) => r.target.reference)).toEqual([
"single",
"multiple"
]);
expect(em.relations.sourceRelationsOf(media).map((r) => r.target.entity.name)).toEqual([]);
// expect that polymorphic fields are added to media
expect(media.getFields().map((f) => f.name)).toEqual([
"id",
"path",
"reference",
"entity_id"
]);
});
});

View File

@@ -0,0 +1,267 @@
import { describe, expect, test } from "bun:test";
import { MediaField } from "../../src";
import {
BooleanField,
DateField,
Entity,
EnumField,
JsonField,
ManyToManyRelation,
ManyToOneRelation,
NumberField,
OneToOneRelation,
PolymorphicRelation,
TextField
} from "../../src/data";
import {
FieldPrototype,
type FieldSchema,
type InsertSchema,
type Schema,
boolean,
date,
datetime,
entity,
enumm,
json,
media,
medium,
number,
relation,
text
} from "../../src/data/prototype";
describe("prototype", () => {
test("...", () => {
const fieldPrototype = new FieldPrototype("text", {}, false);
//console.log("field", fieldPrototype, fieldPrototype.getField("name"));
/*const user = entity("users", {
name: text().required(),
bio: text(),
age: number(),
some: number().required(),
});
console.log("user", user);*/
});
test("...2", async () => {
const user = entity("users", {
name: text().required(),
bio: text(),
age: number(),
some: number().required()
});
//console.log("user", user.toJSON());
});
test("...3", async () => {
const user = entity("users", {
name: text({ default_value: "hello" }).required(),
bio: text(),
age: number(),
some: number().required()
});
const obj: InsertSchema<typeof user> = { name: "yo", some: 1 };
//console.log("user2", user.toJSON());
});
test("Post example", async () => {
const posts1 = new Entity("posts", [
new TextField("title", { required: true }),
new TextField("content"),
new DateField("created_at", {
type: "datetime"
}),
new MediaField("images", { entity: "posts" }),
new MediaField("cover", { entity: "posts", max_items: 1 })
]);
const posts2 = entity("posts", {
title: text().required(),
content: text(),
created_at: datetime(),
images: media(),
cover: medium()
});
type Posts = Schema<typeof posts2>;
expect(posts1.toJSON()).toEqual(posts2.toJSON());
});
test("test example", async () => {
const test = new Entity("test", [
new TextField("name"),
new BooleanField("checked", { default_value: false }),
new NumberField("count"),
new DateField("created_at"),
new DateField("updated_at", { type: "datetime" }),
new TextField("description"),
new EnumField("status", {
options: {
type: "objects",
values: [
{ value: "active", label: "Active" },
{ value: "inactive", label: "Not active" }
]
}
}),
new JsonField("json")
]);
const test2 = entity("test", {
name: text(),
checked: boolean({ default_value: false }),
count: number(),
created_at: date(),
updated_at: datetime(),
description: text(),
status: enumm<"active" | "inactive">({
enum: [
{ value: "active", label: "Active" },
{ value: "inactive", label: "Not active" }
]
}),
json: json<{ some: number }>()
});
expect(test.toJSON()).toEqual(test2.toJSON());
});
test("relations", async () => {
const posts = entity("posts", {});
const users = entity("users", {});
const comments = entity("comments", {});
const categories = entity("categories", {});
const settings = entity("settings", {});
const _media = entity("media", {});
const relations = [
new ManyToOneRelation(posts, users, { mappedBy: "author", required: true }),
new OneToOneRelation(users, settings),
new ManyToManyRelation(posts, categories),
new ManyToOneRelation(comments, users, { required: true }),
new ManyToOneRelation(comments, posts, { required: true }),
// category has single image
new PolymorphicRelation(categories, _media, {
mappedBy: "image",
targetCardinality: 1
}),
// post has multiple images
new PolymorphicRelation(posts, _media, { mappedBy: "images" }),
new PolymorphicRelation(posts, _media, { mappedBy: "cover", targetCardinality: 1 })
];
const relations2 = [
relation(posts).manyToOne(users, { mappedBy: "author", required: true }),
relation(users).oneToOne(settings),
relation(posts).manyToMany(categories),
relation(comments).manyToOne(users, { required: true }),
relation(comments).manyToOne(posts, { required: true }),
relation(categories).polyToOne(_media, { mappedBy: "image" }),
relation(posts).polyToMany(_media, { mappedBy: "images" }),
relation(posts).polyToOne(_media, { mappedBy: "cover" })
];
expect(relations.map((r) => r.toJSON())).toEqual(relations2.map((r) => r.toJSON()));
});
test("many to many fields", async () => {
const posts = entity("posts", {});
const categories = entity("categories", {});
const rel = new ManyToManyRelation(
posts,
categories,
{
connectionTableMappedName: "custom"
},
[new TextField("description")]
);
const fields = {
description: text()
};
let o: FieldSchema<typeof fields>;
const rel2 = relation(posts).manyToMany(
categories,
{
connectionTableMappedName: "custom"
},
fields
);
expect(rel.toJSON()).toEqual(rel2.toJSON());
});
test("devexample", async () => {
const users = entity("users", {
username: text()
});
const comments = entity("comments", {
content: text()
});
const posts = entity("posts", {
title: text().required(),
content: text(),
created_at: datetime(),
images: media(),
cover: medium()
});
const categories = entity("categories", {
name: text(),
description: text(),
image: medium()
});
const settings = entity("settings", {
theme: text()
});
const test = entity("test", {
name: text(),
checked: boolean({ default_value: false }),
count: number(),
created_at: date(),
updated_at: datetime(),
description: text(),
status: enumm<"active" | "inactive">({
enum: [
{ value: "active", label: "Active" },
{ value: "inactive", label: "Not active" }
]
}),
json: json<{ some: number }>()
});
const _media = entity("media", {});
const relations = [
relation(posts).manyToOne(users, { mappedBy: "author", required: true }),
relation(posts).manyToMany(categories),
relation(posts).polyToMany(_media, { mappedBy: "images" }),
relation(posts).polyToOne(_media, { mappedBy: "cover" }),
relation(categories).polyToOne(_media, { mappedBy: "image" }),
relation(users).oneToOne(settings),
relation(comments).manyToOne(users, { required: true }),
relation(comments).manyToOne(posts, { required: true })
];
const obj: Schema<typeof test> = {} as any;
});
});

View File

@@ -0,0 +1,368 @@
// eslint-disable-next-line import/no-unresolved
import { afterAll, describe, expect, test } from "bun:test";
import { Entity, EntityManager, TextField } from "../../src/data";
import {
ManyToManyRelation,
ManyToOneRelation,
OneToOneRelation,
PolymorphicRelation,
RelationField
} from "../../src/data/relations";
import { getDummyConnection } from "./helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterAll(afterAllCleanup);
describe("Relations", async () => {
test("RelationField", async () => {
const em = new EntityManager([], dummyConnection);
const schema = em.connection.kysely.schema;
//const r1 = new RelationField(new Entity("users"));
const r1 = new RelationField("users_id", {
reference: "users",
target: "users",
target_field: "id"
});
const sql1 = schema
.createTable("posts")
.addColumn(...r1.schema()!)
.compile().sql;
expect(sql1).toBe(
'create table "posts" ("users_id" integer references "users" ("id") on delete set null)'
);
//const r2 = new RelationField(new Entity("users"), "author");
const r2 = new RelationField("author_id", {
reference: "author",
target: "users",
target_field: "id"
});
const sql2 = schema
.createTable("posts")
.addColumn(...r2.schema()!)
.compile().sql;
expect(sql2).toBe(
'create table "posts" ("author_id" integer references "users" ("id") on delete set null)'
);
});
test("Required RelationField", async () => {
//const r1 = new RelationField(new Entity("users"), undefined, { required: true });
const r1 = new RelationField("users_id", {
reference: "users",
target: "users",
target_field: "id",
required: true
});
expect(r1.isRequired()).toBeTrue();
});
test("ManyToOne", async () => {
const users = new Entity("users", [new TextField("username")]);
const posts = new Entity("posts", [
new TextField("title", {
maxLength: 2
})
]);
const entities = [users, posts];
const relationName = "author";
const relations = [new ManyToOneRelation(posts, users, { mappedBy: relationName })];
const em = new EntityManager(entities, dummyConnection, relations);
// verify naming
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);
expect(rel.target.reference).toBe(relationName);
// verify field
expect(posts.field(relationName + "_id")).toBeInstanceOf(RelationField);
// 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(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);
// verify high level relation (from users)
const userPostsRel = em.relationOf(users.name, "posts");
expect(userPostsRel).toBeInstanceOf(ManyToOneRelation);
expect(userPostsRel?.other(users).entity).toBe(posts);
// verify high level relation (from posts)
const postAuthorRel = em.relationOf(posts.name, "author")! as ManyToOneRelation;
expect(postAuthorRel).toBeInstanceOf(ManyToOneRelation);
expect(postAuthorRel?.other(posts).entity).toBe(users);
const kysely = em.connection.kysely;
const jsonFrom = (e) => e;
/**
* Relation Helper
*/
/**
* FROM POSTS
* ----------
- lhs: posts.author_id
- rhs: users.id
- as: author
- select: users.*
- cardinality: 1
*/
const selectPostsFromUsers = postAuthorRel.buildWith(
users,
kysely.selectFrom(users.name),
jsonFrom,
"posts"
);
expect(selectPostsFromUsers.compile().sql).toBe(
'select (select "posts"."id" as "id", "posts"."title" as "title", "posts"."author_id" as "author_id" from "posts" as "posts" where "posts"."author_id" = "users"."id" limit ?) as "posts" from "users"'
);
expect(postAuthorRel!.getField()).toBeInstanceOf(RelationField);
const userObj = { id: 1, username: "test" };
expect(postAuthorRel.hydrate(users, [userObj], em)).toEqual(userObj);
/**
FROM USERS
----------
- lhs: posts.author_id
- rhs: users.id
- as: posts
- select: posts.*
- cardinality:
*/
const selectUsersFromPosts = postAuthorRel.buildWith(
posts,
kysely.selectFrom(posts.name),
jsonFrom,
"author"
);
expect(selectUsersFromPosts.compile().sql).toBe(
'select (select "author"."id" as "id", "author"."username" as "username" from "users" as "author" where "author"."id" = "posts"."author_id" limit ?) as "author" from "posts"'
);
expect(postAuthorRel.getField()).toBeInstanceOf(RelationField);
const postObj = { id: 1, title: "test" };
expect(postAuthorRel.hydrate(posts, [postObj], em)).toEqual([postObj]);
// mutation info
expect(postAuthorRel!.helper(users.name)!.getMutationInfo()).toEqual({
reference: "posts",
local_field: undefined,
$set: false,
$create: false,
$attach: false,
$detach: false,
primary: undefined,
cardinality: undefined,
relation_type: "n:1"
});
expect(postAuthorRel!.helper(posts.name)!.getMutationInfo()).toEqual({
reference: "author",
local_field: "author_id",
$set: true,
$create: false,
$attach: false,
$detach: false,
primary: "id",
cardinality: 1,
relation_type: "n:1"
});
/*console.log("ManyToOne (source=posts, target=users)");
// prettier-ignore
console.log("users perspective",postAuthorRel!.helper(users.name)!.getMutationInfo());
// prettier-ignore
console.log("posts perspective", postAuthorRel!.helper(posts.name)!.getMutationInfo());
console.log("");*/
});
test("OneToOne", async () => {
const users = new Entity("users", [new TextField("username")]);
const settings = new Entity("settings", [new TextField("theme")]);
const entities = [users, settings];
const relations = [new OneToOneRelation(users, settings)];
const em = new EntityManager(entities, dummyConnection, relations);
// verify naming
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);
expect(rel.target.reference).toBe(settings.name);
// verify fields (only one added to users (source))
expect(users.field("settings_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(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);
expect(userSettingRel).toBeInstanceOf(OneToOneRelation);
expect(userSettingRel?.other(users).entity.name).toBe(settings.name);
// verify high level relation (from settings)
const settingUserRel = em.relationOf(settings.name, users.name);
expect(settingUserRel).toBeInstanceOf(OneToOneRelation);
expect(settingUserRel?.other(settings).entity.name).toBe(users.name);
// mutation info
expect(userSettingRel!.helper(users.name)!.getMutationInfo()).toEqual({
reference: "settings",
local_field: "settings_id",
$set: true,
$create: true,
$attach: false,
$detach: false,
primary: "id",
cardinality: 1,
relation_type: "1:1"
});
expect(userSettingRel!.helper(settings.name)!.getMutationInfo()).toEqual({
reference: "users",
local_field: undefined,
$set: false,
$create: false,
$attach: false,
$detach: false,
primary: undefined,
cardinality: 1,
relation_type: "1:1"
});
/*console.log("");
console.log("OneToOne (source=users, target=settings)");
// prettier-ignore
console.log("users perspective",userSettingRel!.helper(users.name)!.getMutationInfo());
// prettier-ignore
console.log("settings perspective", userSettingRel!.helper(settings.name)!.getMutationInfo());
console.log("");*/
});
test("ManyToMany", async () => {
const posts = new Entity("posts", [new TextField("title")]);
const categories = new Entity("categories", [new TextField("label")]);
const entities = [posts, categories];
const relations = [new ManyToManyRelation(posts, categories)];
const em = new EntityManager(entities, dummyConnection, relations);
//console.log((await em.schema().sync(true)).map((s) => s.sql).join(";\n"));
// don't expect new fields bc of connection table
expect(posts.getFields().length).toBe(2);
expect(categories.getFields().length).toBe(2);
// expect relations set
expect(em.relationsOf(posts.name).length).toBe(1);
expect(em.relationsOf(categories.name).length).toBe(1);
// expect connection table with fields
expect(em.entity("posts_categories")).toBeInstanceOf(Entity);
expect(em.entity("posts_categories").getFields().length).toBe(3);
expect(em.entity("posts_categories").field("posts_id")).toBeInstanceOf(RelationField);
expect(em.entity("posts_categories").field("categories_id")).toBeInstanceOf(RelationField);
// verify high level relation (from posts)
const postCategoriesRel = em.relationOf(posts.name, categories.name);
expect(postCategoriesRel).toBeInstanceOf(ManyToManyRelation);
expect(postCategoriesRel?.other(posts).entity.name).toBe(categories.name);
//console.log("relation", postCategoriesRel);
// verify high level relation (from posts)
const categoryPostsRel = em.relationOf(categories.name, posts.name);
expect(categoryPostsRel).toBeInstanceOf(ManyToManyRelation);
expect(categoryPostsRel?.other(categories.name).entity.name).toBe(posts.name);
// now get connection table from relation (from posts)
if (postCategoriesRel instanceof ManyToManyRelation) {
expect(postCategoriesRel.connectionEntity.name).toBe("posts_categories");
expect(em.entity(postCategoriesRel.connectionEntity.name).name).toBe("posts_categories");
} else {
throw new Error("Expected ManyToManyRelation");
}
/**
* Relation Helper
*/
const kysely = em.connection.kysely;
const jsonFrom = (e) => e;
/**
* FROM POSTS
* ----------
- lhs: posts.author_id
- rhs: users.id
- as: author
- select: users.*
- cardinality: 1
*/
const selectCategoriesFromPosts = postCategoriesRel.buildWith(
posts,
kysely.selectFrom(posts.name),
jsonFrom
);
expect(selectCategoriesFromPosts.compile().sql).toBe(
'select (select "categories"."id" as "id", "categories"."label" as "label" from "categories" inner join "posts_categories" on "categories"."id" = "posts_categories"."categories_id" where "posts"."id" = "posts_categories"."posts_id" limit ?) as "categories" from "posts"'
);
const selectPostsFromCategories = postCategoriesRel.buildWith(
categories,
kysely.selectFrom(categories.name),
jsonFrom
);
expect(selectPostsFromCategories.compile().sql).toBe(
'select (select "posts"."id" as "id", "posts"."title" as "title" from "posts" inner join "posts_categories" on "posts"."id" = "posts_categories"."posts_id" where "categories"."id" = "posts_categories"."categories_id" limit ?) as "posts" from "categories"'
);
// mutation info
expect(relations[0].helper(posts.name)!.getMutationInfo()).toEqual({
reference: "categories",
local_field: undefined,
$set: false,
$create: false,
$attach: true,
$detach: true,
primary: "id",
cardinality: undefined,
relation_type: "m:n"
});
expect(relations[0].helper(categories.name)!.getMutationInfo()).toEqual({
reference: "posts",
local_field: undefined,
$set: false,
$create: false,
$attach: false,
$detach: false,
primary: undefined,
cardinality: undefined,
relation_type: "m:n"
});
/*console.log("");
console.log("ManyToMany (source=posts, target=categories)");
// prettier-ignore
console.log("posts perspective",relations[0].helper(posts.name)!.getMutationInfo());
// prettier-ignore
console.log("categories perspective", relations[0]!.helper(categories.name)!.getMutationInfo());
console.log("");*/
});
});

View File

@@ -0,0 +1,60 @@
import { describe, expect, test } from "bun:test";
import { Entity, NumberField, TextField } from "../../../src/data";
describe("[data] Entity", async () => {
const entity = new Entity("test", [
new TextField("name", { required: true }),
new TextField("description"),
new NumberField("age", { fillable: false, default_value: 18 }),
new TextField("hidden", { hidden: true, default_value: "secret" })
]);
test("getSelect", async () => {
expect(entity.getSelect()).toEqual(["id", "name", "description", "age"]);
});
test("getFillableFields", async () => {
expect(entity.getFillableFields().map((f) => f.name)).toEqual([
"name",
"description",
"hidden"
]);
});
test("getRequiredFields", async () => {
expect(entity.getRequiredFields().map((f) => f.name)).toEqual(["name"]);
});
test("getDefaultObject", async () => {
expect(entity.getDefaultObject()).toEqual({
age: 18,
hidden: "secret"
});
});
test("getField", async () => {
expect(entity.getField("name")).toBeInstanceOf(TextField);
expect(entity.getField("age")).toBeInstanceOf(NumberField);
});
test("getPrimaryField", async () => {
expect(entity.getPrimaryField().name).toEqual("id");
});
test("addField", async () => {
const field = new TextField("new_field");
entity.addField(field);
expect(entity.getField("new_field")).toBe(field);
});
// @todo: move this to ClientApp
/*test("serialize and deserialize", async () => {
const json = entity.toJSON();
//sconsole.log("json", json.fields);
const newEntity = Entity.deserialize(json);
//console.log("newEntity", newEntity.toJSON().fields);
expect(newEntity).toBeInstanceOf(Entity);
expect(json).toEqual(newEntity.toJSON());
expect(json.fields).toEqual(newEntity.toJSON().fields);
});*/
});

View File

@@ -0,0 +1,106 @@
import { afterAll, describe, expect, test } from "bun:test";
import {
Entity,
EntityManager,
ManyToManyRelation,
ManyToOneRelation,
SchemaManager
} from "../../../src/data";
import { UnableToConnectException } from "../../../src/data/errors";
import { getDummyConnection } from "../helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterAll(afterAllCleanup);
describe("[data] EntityManager", async () => {
test("base empty throw", async () => {
// @ts-expect-error - testing invalid input, connection is required
expect(() => new EntityManager([], {})).toThrow(UnableToConnectException);
});
test("base w/o entities & relations", async () => {
const em = new EntityManager([], dummyConnection);
expect(em.entities).toEqual([]);
expect(em.relations.all).toEqual([]);
expect(await em.ping()).toBe(true);
expect(() => em.entity("...")).toThrow();
expect(() =>
em.addRelation(new ManyToOneRelation(new Entity("1"), new Entity("2")))
).toThrow();
expect(em.schema()).toBeInstanceOf(SchemaManager);
// the rest will all throw, since they depend on em.entity()
});
test("w/ 2 entities but no initial relations", async () => {
const users = new Entity("users");
const posts = new Entity("posts");
const em = new EntityManager([users, posts], dummyConnection);
expect(em.entities).toEqual([users, posts]);
expect(em.relations.all).toEqual([]);
expect(em.entity("users")).toBe(users);
expect(em.entity("posts")).toBe(posts);
// expect adding relation to pass
em.addRelation(new ManyToOneRelation(posts, users));
expect(em.relations.all.length).toBe(1);
expect(em.relations.all[0]).toBeInstanceOf(ManyToOneRelation);
expect(em.relationsOf("users")).toEqual([em.relations.all[0]]);
expect(em.relationsOf("posts")).toEqual([em.relations.all[0]]);
expect(em.hasRelations("users")).toBe(true);
expect(em.hasRelations("posts")).toBe(true);
expect(em.relatedEntitiesOf("users")).toEqual([posts]);
expect(em.relatedEntitiesOf("posts")).toEqual([users]);
expect(em.relationReferencesOf("users")).toEqual(["posts"]);
expect(em.relationReferencesOf("posts")).toEqual(["users"]);
});
test("test target relations", async () => {
const users = new Entity("users");
const posts = new Entity("posts");
const comments = new Entity("comments");
const categories = new Entity("categories");
const em = new EntityManager([users, posts, comments, categories], dummyConnection);
em.addRelation(new ManyToOneRelation(posts, users));
em.addRelation(new ManyToOneRelation(comments, users));
em.addRelation(new ManyToOneRelation(comments, posts));
em.addRelation(new ManyToManyRelation(posts, categories));
const userTargetRel = em.relations.targetRelationsOf(users);
const postTargetRel = em.relations.targetRelationsOf(posts);
const commentTargetRel = em.relations.targetRelationsOf(comments);
expect(userTargetRel.map((r) => r.source.entity.name)).toEqual(["posts", "comments"]);
expect(postTargetRel.map((r) => r.source.entity.name)).toEqual(["comments"]);
expect(commentTargetRel.map((r) => r.source.entity.name)).toEqual([]);
});
test("test listable relations", async () => {
const users = new Entity("users");
const posts = new Entity("posts");
const comments = new Entity("comments");
const categories = new Entity("categories");
const em = new EntityManager([users, posts, comments, categories], dummyConnection);
em.addRelation(new ManyToOneRelation(posts, users));
em.addRelation(new ManyToOneRelation(comments, users));
em.addRelation(new ManyToOneRelation(comments, posts));
em.addRelation(new ManyToManyRelation(posts, categories));
const userTargetRel = em.relations.listableRelationsOf(users);
const postTargetRel = em.relations.listableRelationsOf(posts);
const commentTargetRel = em.relations.listableRelationsOf(comments);
const categoriesTargetRel = em.relations.listableRelationsOf(categories);
expect(userTargetRel.map((r) => r.other(users).entity.name)).toEqual(["posts", "comments"]);
expect(postTargetRel.map((r) => r.other(posts).entity.name)).toEqual([
"comments",
"categories"
]);
expect(commentTargetRel.map((r) => r.other(comments).entity.name)).toEqual([]);
expect(categoriesTargetRel.map((r) => r.other(categories).entity.name)).toEqual(["posts"]);
});
});

View File

@@ -0,0 +1,43 @@
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";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterAll(afterAllCleanup);
describe("[data] JoinBuilder", async () => {
test("missing relation", async () => {
const users = new Entity("users", [new TextField("username")]);
const em = new EntityManager([users], dummyConnection);
expect(() =>
JoinBuilder.addClause(em, em.connection.kysely.selectFrom("users"), users, ["posts"])
).toThrow('Relation "posts" not found');
});
test("addClause: ManyToOne", async () => {
const users = new Entity("users", [new TextField("username")]);
const posts = new Entity("posts", [new TextField("content")]);
const relations = [new ManyToOneRelation(posts, users, { mappedBy: "author" })];
const em = new EntityManager([users, posts], dummyConnection, relations);
const qb = JoinBuilder.addClause(em, em.connection.kysely.selectFrom("users"), users, [
"posts"
]);
const res = qb.compile();
console.log("compiled", res.sql);
/*expect(res.sql).toBe(
'select from "users" inner join "posts" on "posts"."author_id" = "users"."id" group by "users"."id"',
);*/
const qb2 = JoinBuilder.addClause(em, em.connection.kysely.selectFrom("posts"), posts, [
"author"
]);
const res2 = qb2.compile();
console.log("compiled2", res2.sql);
});
});

View File

@@ -0,0 +1,302 @@
import { afterAll, describe, expect, test } from "bun:test";
import {
Entity,
EntityManager,
ManyToOneRelation,
MutatorEvents,
NumberField,
OneToOneRelation,
type RelationField,
RelationMutator,
TextField
} from "../../../src/data";
import { getDummyConnection } from "../helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterAll(afterAllCleanup);
describe("[data] Mutator (base)", async () => {
const entity = new Entity("items", [
new TextField("label", { required: true }),
new NumberField("count"),
new TextField("hidden", { hidden: true }),
new TextField("not_fillable", { fillable: false })
]);
const em = new EntityManager([entity], dummyConnection);
await em.schema().sync({ force: true });
const payload = { label: "item 1", count: 1 };
test("insertOne", async () => {
expect(em.mutator(entity).getValidatedData(payload, "create")).resolves.toEqual(payload);
const res = await em.mutator(entity).insertOne(payload);
// checking params, because we can't know the id
// if it wouldn't be successful, it would throw an error
expect(res.parameters).toEqual(Object.values(payload));
// but expect additional fields to be present
expect((res.data as any).not_fillable).toBeDefined();
});
test("updateOne", async () => {
const { data } = await em.mutator(entity).insertOne(payload);
const updated = await em.mutator(entity).updateOne(data.id, {
count: 2
});
expect(updated.parameters).toEqual([2, data.id]);
expect(updated.data.count).toBe(2);
});
test("deleteOne", async () => {
const { data } = await em.mutator(entity).insertOne(payload);
const deleted = await em.mutator(entity).deleteOne(data.id);
expect(deleted.parameters).toEqual([data.id]);
});
});
describe("[data] Mutator (ManyToOne)", async () => {
const posts = new Entity("posts", [new TextField("title")]);
const users = new Entity("users", [new TextField("username")]);
const relations = [new ManyToOneRelation(posts, users)];
const em = new EntityManager([posts, users], dummyConnection, relations);
await em.schema().sync({ force: true });
test("RelationMutator", async () => {
// create entries
const userData = await em.mutator(users).insertOne({ username: "user1" });
const postData = await em.mutator(posts).insertOne({ title: "post1" });
const postRelMutator = new RelationMutator(posts, em);
const postRelField = posts.getField("users_id")! as RelationField;
expect(postRelMutator.getRelationalKeys()).toEqual(["users", "users_id"]);
// persisting relational field should just return key value to be added
expect(
postRelMutator.persistRelationField(postRelField, "users_id", userData.data.id)
).resolves.toEqual(["users_id", userData.data.id]);
// persisting invalid value should throw
expect(postRelMutator.persistRelationField(postRelField, "users_id", 0)).rejects.toThrow();
// persisting reference should ...
expect(
postRelMutator.persistReference(relations[0], "users", {
$set: { id: userData.data.id }
})
).resolves.toEqual(["users_id", userData.data.id]);
// @todo: add what methods are allowed to relation, like $create should not be allowed for post<>users
process.exit(0);
const userRelMutator = new RelationMutator(users, em);
expect(userRelMutator.getRelationalKeys()).toEqual(["posts"]);
});
test("insertOne: missing ref", async () => {
expect(
em.mutator(posts).insertOne({
title: "post1",
users_id: 1 // user does not exist yet
})
).rejects.toThrow();
});
test("insertOne: missing required relation", async () => {
const items = new Entity("items", [new TextField("label")]);
const cats = new Entity("cats");
const relations = [new ManyToOneRelation(items, cats, { required: true })];
const em = new EntityManager([items, cats], dummyConnection, relations);
expect(em.mutator(items).insertOne({ label: "test" })).rejects.toThrow(
'Field "cats_id" is required'
);
});
test("insertOne: using field name", async () => {
const { data } = await em.mutator(users).insertOne({ username: "user1" });
const res = await em.mutator(posts).insertOne({
title: "post1",
users_id: data.id
});
expect(res.data.users_id).toBe(data.id);
// setting "null" should be allowed
const res2 = await em.mutator(posts).insertOne({
title: "post1",
users_id: null
});
expect(res2.data.users_id).toBe(null);
});
test("insertOne: using reference", async () => {
const { data } = await em.mutator(users).insertOne({ username: "user1" });
const res = await em.mutator(posts).insertOne({
title: "post1",
users: { $set: { id: data.id } }
});
expect(res.data.users_id).toBe(data.id);
// setting "null" should be allowed
const res2 = await em.mutator(posts).insertOne({
title: "post1",
users: { $set: { id: null } }
});
expect(res2.data.users_id).toBe(null);
});
test("insertOne: performing unsupported operations", async () => {
expect(
em.mutator(posts).insertOne({
title: "test",
users: { $create: { username: "test" } }
})
).rejects.toThrow();
});
test("updateOne", async () => {
const res1 = await em.mutator(users).insertOne({ username: "user1" });
const res1_1 = await em.mutator(users).insertOne({ username: "user1" });
const res2 = await em.mutator(posts).insertOne({ title: "post1" });
const up1 = await em.mutator(posts).updateOne(res2.data.id, {
users: { $set: { id: res1.data.id } }
});
expect(up1.data.users_id).toBe(res1.data.id);
const up2 = await em.mutator(posts).updateOne(res2.data.id, {
users: { $set: { id: res1_1.data.id } }
});
expect(up2.data.users_id).toBe(res1_1.data.id);
const up3_1 = await em.mutator(posts).updateOne(res2.data.id, {
users_id: res1.data.id
});
expect(up3_1.data.users_id).toBe(res1.data.id);
const up3_2 = await em.mutator(posts).updateOne(res2.data.id, {
users_id: res1_1.data.id
});
expect(up3_2.data.users_id).toBe(res1_1.data.id);
const up4 = await em.mutator(posts).updateOne(res2.data.id, {
users_id: null
});
expect(up4.data.users_id).toBe(null);
});
});
describe("[data] Mutator (OneToOne)", async () => {
const users = new Entity("users", [new TextField("username")]);
const settings = new Entity("settings", [new TextField("theme")]);
const relations = [new OneToOneRelation(users, settings)];
const em = new EntityManager([users, settings], dummyConnection, relations);
await em.schema().sync({ force: true });
test("insertOne: missing ref", async () => {
expect(
em.mutator(users).insertOne({
username: "test",
settings_id: 1 // todo: throws because it doesn't exist, but it shouldn't be allowed
})
).rejects.toThrow();
});
test("insertOne: using reference", async () => {
// $set is not allowed in OneToOne
const { data } = await em.mutator(settings).insertOne({ theme: "dark" });
expect(
em.mutator(users).insertOne({
username: "test",
settings: { $set: { id: data.id } }
})
).rejects.toThrow();
});
test("insertOne: using $create", async () => {
const res = await em.mutator(users).insertOne({
username: "test",
settings: { $create: { theme: "dark" } }
});
expect(res.data.settings_id).toBeDefined();
});
});
/*
describe("[data] Mutator (ManyToMany)", async () => {
const posts = new Entity("posts", [new TextField("title")]);
const tags = new Entity("tags", [new TextField("name")]);
const relations = [new ManyToOneRelation(posts, tags)];
const em = new EntityManager([posts, tags], dummyConnection, relations);
await em.schema().sync({ force: true });
test("insertOne: missing ref", async () => {
expect(
em.mutator(posts).insertOne({
title: "post1",
tags_id: 1, // tag does not exist yet
}),
).rejects.toThrow();
});
test("insertOne: using reference", async () => {
const { data } = await em.mutator(tags).insertOne({ name: "tag1" });
const res = await em.mutator(posts).insertOne({
title: "post1",
tags: { $attach: { id: data.id } },
});
expect(res.data.tags).toContain(data.id);
});
test("insertOne: using $create", async () => {
const res = await em.mutator(posts).insertOne({
title: "post1",
tags: { $create: { name: "tag1" } },
});
expect(res.data.tags).toBeDefined();
});
test("insertOne: using $detach", async () => {
const { data: tagData } = await em.mutator(tags).insertOne({ name: "tag1" });
const { data: postData } = await em.mutator(posts).insertOne({ title: "post1" });
const res = await em.mutator(posts).insertOne({
title: "post1",
tags: { $attach: { id: tagData.id } },
});
expect(res.data.tags).toContain(tagData.id);
const res2 = await em.mutator(posts).updateOne(postData.id, {
tags: { $detach: { id: tagData.id } },
});
expect(res2.data.tags).not.toContain(tagData.id);
});
});*/
describe("[data] Mutator (Events)", async () => {
const entity = new Entity("test", [new TextField("label")]);
const em = new EntityManager([entity], dummyConnection);
await em.schema().sync({ force: true });
const events = new Map<string, any>();
const mutator = em.mutator(entity);
mutator.emgr.onAny((event) => {
// @ts-ignore
events.set(event.constructor.slug, event);
});
test("events were fired", async () => {
const { data } = await mutator.insertOne({ label: "test" });
expect(events.has(MutatorEvents.MutatorInsertBefore.slug)).toBeTrue();
expect(events.has(MutatorEvents.MutatorInsertAfter.slug)).toBeTrue();
await mutator.updateOne(data.id, { label: "test2" });
expect(events.has(MutatorEvents.MutatorUpdateBefore.slug)).toBeTrue();
expect(events.has(MutatorEvents.MutatorUpdateAfter.slug)).toBeTrue();
await mutator.deleteOne(data.id);
expect(events.has(MutatorEvents.MutatorDeleteBefore.slug)).toBeTrue();
expect(events.has(MutatorEvents.MutatorDeleteAfter.slug)).toBeTrue();
});
});

View File

@@ -0,0 +1,222 @@
import { afterAll, describe, expect, test } from "bun:test";
// @ts-ignore
import { Perf } from "@bknd/core/utils";
import type { Kysely, Transaction } from "kysely";
import {
Entity,
EntityManager,
LibsqlConnection,
ManyToOneRelation,
RepositoryEvents,
TextField
} from "../../../src/data";
import { getDummyConnection } from "../helper";
type E = Kysely<any> | Transaction<any>;
const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterAll(afterAllCleanup);
async function sleep(ms: number) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
describe("[Repository]", async () => {
test("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);
await em.connection.kysely.schema
.createTable("items")
.ifNotExists()
.addColumn("id", "integer", (col) => col.primaryKey().autoIncrement().notNull())
.addColumn("label", "text")
.execute();
// fill
await em.connection.kysely
.insertInto("items")
.values([{ label: "a" }, { label: "b" }, { label: "c" }])
.execute();
// count all
const res = await em.repository(items).count();
expect(res.sql).toBe('select count(*) as "count" from "items"');
expect(res.count).toBe(3);
// count filtered
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.count).toBe(2);
// check exists
const res3 = await em.repository(items).exists({ label: "a" });
expect(res3.exists).toBe(true);
const res4 = await em.repository(items).exists({ label: "d" });
expect(res4.exists).toBe(false);
// for now, allow empty filter
const res5 = await em.repository(items).exists({});
expect(res5.exists).toBe(true);
});
});
describe("[data] Repository (Events)", async () => {
const items = new Entity("items", [new TextField("label")]);
const categories = new Entity("categories", [new TextField("label")]);
const em = new EntityManager([items, categories], dummyConnection, [
new ManyToOneRelation(categories, items)
]);
await em.schema().sync({ force: true });
const events = new Map<string, any>();
em.repository(items).emgr.onAny((event) => {
// @ts-ignore
events.set(event.constructor.slug, event);
});
em.repository(categories).emgr.onAny((event) => {
// @ts-ignore
events.set(event.constructor.slug, event);
});
test("events were fired", async () => {
await em.repository(items).findId(1);
expect(events.has(RepositoryEvents.RepositoryFindOneBefore.slug)).toBeTrue();
expect(events.has(RepositoryEvents.RepositoryFindOneAfter.slug)).toBeTrue();
events.clear();
await em.repository(items).findOne({ id: 1 });
expect(events.has(RepositoryEvents.RepositoryFindOneBefore.slug)).toBeTrue();
expect(events.has(RepositoryEvents.RepositoryFindOneAfter.slug)).toBeTrue();
events.clear();
await em.repository(items).findMany({ where: { id: 1 } });
expect(events.has(RepositoryEvents.RepositoryFindManyBefore.slug)).toBeTrue();
expect(events.has(RepositoryEvents.RepositoryFindManyAfter.slug)).toBeTrue();
events.clear();
await em.repository(items).findManyByReference(1, "categories");
expect(events.has(RepositoryEvents.RepositoryFindManyBefore.slug)).toBeTrue();
expect(events.has(RepositoryEvents.RepositoryFindManyAfter.slug)).toBeTrue();
events.clear();
});
});

View File

@@ -0,0 +1,269 @@
// eslint-disable-next-line import/no-unresolved
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();
afterAll(afterAllCleanup);
describe("SchemaManager tests", async () => {
test("introspect entity", async () => {
const email = new TextField("email");
const entity = new Entity("test", [new TextField("username"), email, new TextField("bio")]);
const index = new EntityIndex(entity, [email]);
const em = new EntityManager([entity], dummyConnection, [], [index]);
const schema = new SchemaManager(em);
const introspection = schema.getIntrospectionFromEntity(em.entities[0]);
expect(introspection).toEqual({
name: "test",
isView: false,
columns: [
{
name: "id",
dataType: "TEXT",
isNullable: true,
isAutoIncrementing: true,
hasDefaultValue: false,
comment: undefined
},
{
name: "username",
dataType: "TEXT",
isNullable: true,
isAutoIncrementing: false,
hasDefaultValue: false,
comment: undefined
},
{
name: "email",
dataType: "TEXT",
isNullable: true,
isAutoIncrementing: false,
hasDefaultValue: false,
comment: undefined
},
{
name: "bio",
dataType: "TEXT",
isNullable: true,
isAutoIncrementing: false,
hasDefaultValue: false,
comment: undefined
}
],
indices: [
{
name: "idx_test_email",
table: "test",
isUnique: false,
columns: [
{
name: "email",
order: 0
}
]
}
]
});
});
test("add column", async () => {
const table = "add_column";
const index = "idx_add_column";
const em = new EntityManager(
[
new Entity(table, [
new TextField("username"),
new TextField("email"),
new TextField("bio")
])
],
dummyConnection
);
const kysely = em.connection.kysely;
await kysely.schema
.createTable(table)
.ifNotExists()
.addColumn("id", "integer", (col) => col.primaryKey().autoIncrement().notNull())
.addColumn("username", "text")
.addColumn("email", "text")
.execute();
await kysely.schema.createIndex(index).on(table).columns(["username"]).execute();
const schema = new SchemaManager(em);
const diff = await schema.getDiff();
expect(diff).toEqual([
{
name: table,
isNew: false,
columns: { add: ["bio"], drop: [], change: [] },
indices: { add: [], drop: [index] }
}
]);
// now sync
await schema.sync({ force: true, drop: true });
const diffAfter = await schema.getDiff();
console.log("diffAfter", diffAfter);
expect(diffAfter.length).toBe(0);
await kysely.schema.dropTable(table).execute();
});
test("drop column", async () => {
const table = "drop_column";
const em = new EntityManager(
[new Entity(table, [new TextField("username")])],
dummyConnection
);
const kysely = em.connection.kysely;
await kysely.schema
.createTable(table)
.ifNotExists()
.addColumn("id", "integer", (col) => col.primaryKey().autoIncrement().notNull())
.addColumn("username", "text")
.addColumn("email", "text")
.execute();
const schema = new SchemaManager(em);
const diff = await schema.getDiff();
expect(diff).toEqual([
{
name: table,
isNew: false,
columns: {
add: [],
drop: ["email"],
change: []
},
indices: { add: [], drop: [] }
}
]);
// now sync
await schema.sync({ force: true, drop: true });
const diffAfter = await schema.getDiff();
//console.log("diffAfter", diffAfter);
expect(diffAfter.length).toBe(0);
await kysely.schema.dropTable(table).execute();
});
test("create table and add column", async () => {
const usersTable = "create_users";
const postsTable = "create_posts";
const em = new EntityManager(
[
new Entity(usersTable, [
new TextField("username"),
new TextField("email"),
new TextField("bio")
]),
new Entity(postsTable, [
new TextField("title"),
new TextField("content"),
new TextField("created_at")
])
],
dummyConnection
);
const kysely = em.connection.kysely;
await kysely.schema
.createTable(usersTable)
.addColumn("id", "integer", (col) => col.primaryKey().autoIncrement().notNull())
.addColumn("username", "text")
.addColumn("email", "text")
.execute();
const schema = new SchemaManager(em);
const diff = await schema.getDiff();
expect(diff).toEqual([
{
name: usersTable,
isNew: false,
columns: { add: ["bio"], drop: [], change: [] },
indices: { add: [], drop: [] }
},
{
name: postsTable,
isNew: true,
columns: {
add: ["id", "title", "content", "created_at"],
drop: [],
change: []
},
indices: { add: [], drop: [] }
}
]);
// now sync
await schema.sync({ force: true });
const diffAfter = await schema.getDiff();
//console.log("diffAfter", diffAfter);
expect(diffAfter.length).toBe(0);
await kysely.schema.dropTable(usersTable).execute();
await kysely.schema.dropTable(postsTable).execute();
});
test("adds index on create", async () => {
const entity = new Entity(randomString(16), [new TextField("email")]);
const index = new EntityIndex(entity, [entity.getField("email")!]);
const em = new EntityManager([entity], dummyConnection, [], [index]);
const diff = await em.schema().getDiff();
expect(diff).toEqual([
{
name: entity.name,
isNew: true,
columns: { add: ["id", "email"], drop: [], change: [] },
indices: { add: [index.name!], drop: [] }
}
]);
// sync and then check again
await em.schema().sync({ force: true });
const diffAfter = await em.schema().getDiff();
expect(diffAfter.length).toBe(0);
});
test("adds index after", async () => {
const { dummyConnection } = getDummyConnection();
const entity = new Entity(randomString(16), [new TextField("email", { required: true })]);
const em = new EntityManager([entity], dummyConnection);
await em.schema().sync({ force: true });
// now add index
const index = new EntityIndex(entity, [entity.getField("email")!], true);
em.addIndex(index);
const diff = await em.schema().getDiff();
expect(diff).toEqual([
{
name: entity.name,
isNew: false,
columns: { add: [], drop: [], change: [] },
indices: { add: [index.name!], drop: [] }
}
]);
// sync and then check again
await em.schema().sync({ force: true });
const diffAfter = await em.schema().getDiff();
expect(diffAfter.length).toBe(0);
});
});

View File

@@ -0,0 +1,195 @@
import { afterAll, describe, expect, test } from "bun:test";
import {
Entity,
EntityManager,
ManyToManyRelation,
ManyToOneRelation,
PolymorphicRelation,
TextField,
WithBuilder
} from "../../../src/data";
import { getDummyConnection } from "../helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterAll(afterAllCleanup);
describe("[data] WithBuilder", async () => {
test("missing relation", async () => {
const users = new Entity("users", [new TextField("username")]);
const em = new EntityManager([users], dummyConnection);
expect(() =>
WithBuilder.addClause(em, em.connection.kysely.selectFrom("users"), users, ["posts"])
).toThrow('Relation "posts" not found');
});
test("addClause: ManyToOne", async () => {
const users = new Entity("users", [new TextField("username")]);
const posts = new Entity("posts", [new TextField("content")]);
const relations = [new ManyToOneRelation(posts, users, { mappedBy: "author" })];
const em = new EntityManager([users, posts], dummyConnection, relations);
const qb = WithBuilder.addClause(em, em.connection.kysely.selectFrom("users"), users, [
"posts"
]);
const res = qb.compile();
expect(res.sql).toBe(
'select (select coalesce(json_group_array(json_object(\'id\', "agg"."id", \'content\', "agg"."content", \'author_id\', "agg"."author_id")), \'[]\') from (select "posts"."id" as "id", "posts"."content" as "content", "posts"."author_id" as "author_id" from "posts" where "users"."id" = "posts"."author_id" limit ?) as agg) as "posts" from "users"'
);
expect(res.parameters).toEqual([5]);
const qb2 = WithBuilder.addClause(
em,
em.connection.kysely.selectFrom("posts"),
posts, // @todo: try with "users", it gives output!
["author"]
);
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" where "posts"."author_id" = "users"."id" limit ?) as obj) as "author" from "posts"'
);
expect(res2.parameters).toEqual([1]);
});
test("test with empty join", async () => {
const qb = { qb: 1 } as any;
expect(WithBuilder.addClause(null as any, qb, null as any, [])).toBe(qb);
});
test("test manytomany", async () => {
const posts = new Entity("posts", [new TextField("title")]);
const categories = new Entity("categories", [new TextField("label")]);
const entities = [posts, categories];
const relations = [new ManyToManyRelation(posts, categories)];
const em = new EntityManager(entities, dummyConnection, relations);
await em.schema().sync({ force: true });
await em.mutator(posts).insertOne({ title: "fashion post" });
await em.mutator(posts).insertOne({ title: "beauty post" });
await em.mutator(categories).insertOne({ label: "fashion" });
await em.mutator(categories).insertOne({ label: "beauty" });
await em.mutator(categories).insertOne({ label: "tech" });
await em.connection.kysely
.insertInto("posts_categories")
.values([
{ posts_id: 1, categories_id: 1 },
{ posts_id: 2, categories_id: 2 },
{ posts_id: 1, categories_id: 2 }
])
.execute();
//console.log((await em.repository().findMany("posts_categories")).result);
const res = await em.repository(posts).findMany({ with: ["categories"] });
expect(res.data).toEqual([
{
id: 1,
title: "fashion post",
categories: [
{ id: 1, label: "fashion" },
{ id: 2, label: "beauty" }
]
},
{
id: 2,
title: "beauty post",
categories: [{ id: 2, label: "beauty" }]
}
]);
const res2 = await em.repository(categories).findMany({ with: ["posts"] });
//console.log(res2.sql, res2.data);
expect(res2.data).toEqual([
{
id: 1,
label: "fashion",
posts: [{ id: 1, title: "fashion post" }]
},
{
id: 2,
label: "beauty",
posts: [
{ id: 2, title: "beauty post" },
{ id: 1, title: "fashion post" }
]
},
{
id: 3,
label: "tech",
posts: []
}
]);
});
test("polymorphic", async () => {
const categories = new Entity("categories", [new TextField("name")]);
const media = new Entity("media", [new TextField("path")]);
const entities = [media, categories];
const single = new PolymorphicRelation(categories, media, {
mappedBy: "single",
targetCardinality: 1
});
const multiple = new PolymorphicRelation(categories, media, { mappedBy: "multiple" });
const em = new EntityManager(entities, dummyConnection, [single, multiple]);
const qb = WithBuilder.addClause(
em,
em.connection.kysely.selectFrom("categories"),
categories,
["single"]
);
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" limit ?) as obj) as "single" from "categories"'
);
expect(res.parameters).toEqual(["categories.single", 1]);
const qb2 = WithBuilder.addClause(
em,
em.connection.kysely.selectFrom("categories"),
categories,
["multiple"]
);
const res2 = qb2.compile();
expect(res2.sql).toBe(
'select (select coalesce(json_group_array(json_object(\'id\', "agg"."id", \'path\', "agg"."path")), \'[]\') from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" limit ?) as agg) as "multiple" from "categories"'
);
expect(res2.parameters).toEqual(["categories.multiple", 5]);
});
/*test("test manytoone", async () => {
const posts = new Entity("posts", [new TextField("title")]);
const users = new Entity("users", [new TextField("username")]);
const relations = [
new ManyToOneRelation(posts, users, { mappedBy: "author" }),
];
const em = new EntityManager([users, posts], dummyConnection, relations);
console.log((await em.schema().sync(true)).map((s) => s.sql).join("\n"));
await em.schema().sync();
await em.mutator().insertOne("users", { username: "user1" });
await em.mutator().insertOne("users", { username: "user2" });
await em.mutator().insertOne("posts", { title: "post1", author_id: 1 });
await em.mutator().insertOne("posts", { title: "post2", author_id: 2 });
console.log((await em.repository().findMany("posts")).result);
const res = await em.repository().findMany("posts", { join: ["author"] });
console.log(res.sql, res.parameters, res.result);
});*/
});

View File

@@ -0,0 +1,92 @@
import { afterAll, describe, expect, test } from "bun:test";
import { EntityManager } from "../../../../src/data";
import { getDummyConnection } from "../../helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterAll(afterAllCleanup);
describe("Connection", async () => {
test("it introspects indices correctly", async () => {
const em = new EntityManager([], dummyConnection);
const kysely = em.connection.kysely;
await kysely.schema.createTable("items").ifNotExists().addColumn("name", "text").execute();
await kysely.schema.createIndex("idx_items_name").on("items").columns(["name"]).execute();
const indices = await em.connection.getIntrospector().getIndices("items");
expect(indices).toEqual([
{
name: "idx_items_name",
table: "items",
isUnique: false,
columns: [
{
name: "name",
order: 0
}
]
}
]);
});
test("it introspects indices on multiple columns correctly", async () => {
const em = new EntityManager([], dummyConnection);
const kysely = em.connection.kysely;
await kysely.schema
.createTable("items_multiple")
.ifNotExists()
.addColumn("name", "text")
.addColumn("desc", "text")
.execute();
await kysely.schema
.createIndex("idx_items_multiple")
.on("items_multiple")
.columns(["name", "desc"])
.execute();
const indices = await em.connection.getIntrospector().getIndices("items_multiple");
expect(indices).toEqual([
{
name: "idx_items_multiple",
table: "items_multiple",
isUnique: false,
columns: [
{
name: "name",
order: 0
},
{
name: "desc",
order: 1
}
]
}
]);
});
test("it introspects unique indices correctly", async () => {
const em = new EntityManager([], dummyConnection);
const kysely = em.connection.kysely;
const tbl_name = "items_unique";
const idx_name = "idx_items_unique";
await kysely.schema.createTable(tbl_name).ifNotExists().addColumn("name", "text").execute();
await kysely.schema.createIndex(idx_name).on(tbl_name).columns(["name"]).unique().execute();
const indices = await em.connection.getIntrospector().getIndices(tbl_name);
expect(indices).toEqual([
{
name: idx_name,
table: tbl_name,
isUnique: true,
columns: [
{
name: "name",
order: 0
}
]
}
]);
});
});

View File

@@ -0,0 +1,29 @@
import { describe, expect, test } from "bun:test";
import { BooleanField } from "../../../../src/data";
import { runBaseFieldTests, transformPersist } from "./inc";
describe("[data] BooleanField", async () => {
runBaseFieldTests(BooleanField, { defaultValue: true, schemaType: "boolean" });
test("transformRetrieve", async () => {
const field = new BooleanField("test");
expect(field.transformRetrieve(1)).toBe(true);
expect(field.transformRetrieve(0)).toBe(false);
expect(field.transformRetrieve("1")).toBe(true);
expect(field.transformRetrieve("0")).toBe(false);
expect(field.transformRetrieve(true)).toBe(true);
expect(field.transformRetrieve(false)).toBe(false);
expect(field.transformRetrieve(null)).toBe(null);
expect(field.transformRetrieve(undefined)).toBe(null);
});
test("transformPersist (specific)", async () => {
const field = new BooleanField("test");
expect(transformPersist(field, 1)).resolves.toBe(true);
expect(transformPersist(field, 0)).resolves.toBe(false);
expect(transformPersist(field, "1")).rejects.toThrow();
expect(transformPersist(field, "0")).rejects.toThrow();
expect(transformPersist(field, true)).resolves.toBe(true);
expect(transformPersist(field, false)).resolves.toBe(false);
});
});

View File

@@ -0,0 +1,13 @@
import { describe, expect, test } from "bun:test";
import { DateField } from "../../../../src/data";
import { runBaseFieldTests } from "./inc";
describe("[data] DateField", async () => {
runBaseFieldTests(DateField, { defaultValue: new Date(), schemaType: "date" });
// @todo: add datefield tests
test("week", async () => {
const field = new DateField("test", { type: "week" });
console.log(field.getValue("2021-W01", "submit"));
});
});

View File

@@ -0,0 +1,44 @@
import { describe, expect, test } from "bun:test";
import { EnumField } from "../../../../src/data";
import { runBaseFieldTests, transformPersist } from "./inc";
function options(strings: string[]) {
return { type: "strings", values: strings };
}
describe("[data] EnumField", async () => {
runBaseFieldTests(
EnumField,
{ defaultValue: "a", schemaType: "text" },
{ options: options(["a", "b", "c"]) }
);
test("yields if no options", async () => {
expect(() => new EnumField("test", { options: options([]) })).toThrow();
});
test("yields if default value is not a valid option", async () => {
expect(
() => new EnumField("test", { options: options(["a", "b"]), default_value: "c" })
).toThrow();
});
test("transformPersist (config)", async () => {
const field = new EnumField("test", { options: options(["a", "b", "c"]) });
expect(transformPersist(field, null)).resolves.toBeUndefined();
expect(transformPersist(field, "a")).resolves.toBe("a");
expect(transformPersist(field, "d")).rejects.toThrow();
});
test("transformRetrieve", async () => {
const field = new EnumField("test", {
options: options(["a", "b", "c"]),
default_value: "a",
required: true
});
expect(field.transformRetrieve(null)).toBe("a");
expect(field.transformRetrieve("d")).toBe("a");
});
});

View File

@@ -0,0 +1,45 @@
import { describe, expect, test } from "bun:test";
import { Default, parse, stripMark } from "../../../../src/core/utils";
import { Field, type SchemaResponse, TextField, baseFieldConfigSchema } from "../../../../src/data";
import { runBaseFieldTests, transformPersist } from "./inc";
describe("[data] Field", async () => {
class FieldSpec extends Field {
schema(): SchemaResponse {
return this.useSchemaHelper("text");
}
getSchema() {
return baseFieldConfigSchema;
}
}
runBaseFieldTests(FieldSpec, { defaultValue: "test", schemaType: "text" });
test.only("default config", async () => {
const field = new FieldSpec("test");
const config = Default(baseFieldConfigSchema, {});
expect(stripMark(new FieldSpec("test").config)).toEqual(config);
console.log("config", new TextField("test", { required: true }).toJSON());
});
test("transformPersist (specific)", async () => {
const required = new FieldSpec("test", { required: true });
const requiredDefault = new FieldSpec("test", {
required: true,
default_value: "test"
});
expect(required.transformPersist(null, undefined as any, undefined as any)).rejects.toThrow();
expect(
required.transformPersist(undefined, undefined as any, undefined as any)
).rejects.toThrow();
// works because it has a default value
expect(
requiredDefault.transformPersist(null, undefined as any, undefined as any)
).resolves.toBeDefined();
expect(
requiredDefault.transformPersist(undefined, undefined as any, undefined as any)
).resolves.toBeDefined();
});
});

View File

@@ -0,0 +1,38 @@
import { describe, expect, test } from "bun:test";
import { Type } from "../../../../src/core/utils";
import {
Entity,
EntityIndex,
type EntityManager,
Field,
type SchemaResponse
} from "../../../../src/data";
class TestField extends Field {
protected getSchema(): any {
return Type.Any();
}
schema(em: EntityManager<any>): SchemaResponse {
return undefined as any;
}
}
describe("FieldIndex", async () => {
const entity = new Entity("test", []);
test("it constructs", async () => {
const field = new TestField("name");
const index = new EntityIndex(entity, [field]);
expect(index.fields).toEqual([field]);
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();
});
});

View File

@@ -0,0 +1,47 @@
import { describe, expect, test } from "bun:test";
import { JsonField } from "../../../../src/data";
import { runBaseFieldTests, transformPersist } from "./inc";
describe("[data] JsonField", async () => {
const field = new JsonField("test");
runBaseFieldTests(JsonField, {
defaultValue: { a: 1 },
sampleValues: ["string", { test: 1 }, 1],
schemaType: "text"
});
test("transformPersist (no config)", async () => {
expect(transformPersist(field, Function)).rejects.toThrow();
expect(transformPersist(field, undefined)).resolves.toBeUndefined();
});
test("isSerializable", async () => {
expect(field.isSerializable(1)).toBe(true);
expect(field.isSerializable("test")).toBe(true);
expect(field.isSerializable({ test: 1 })).toBe(true);
expect(field.isSerializable({ test: [1, 2] })).toBe(true);
expect(field.isSerializable(Function)).toBe(false);
expect(field.isSerializable(undefined)).toBe(false);
});
test("isSerialized", async () => {
expect(field.isSerialized(1)).toBe(false);
expect(field.isSerialized({ test: 1 })).toBe(false);
expect(field.isSerialized('{"test":1}')).toBe(true);
expect(field.isSerialized("1")).toBe(true);
});
test("getValue", async () => {
expect(field.getValue({ test: 1 }, "form")).toBe('{"test":1}');
expect(field.getValue("string", "form")).toBe('"string"');
expect(field.getValue(1, "form")).toBe("1");
expect(field.getValue('{"test":1}', "submit")).toEqual({ test: 1 });
expect(field.getValue('"string"', "submit")).toBe("string");
expect(field.getValue("1", "submit")).toBe(1);
expect(field.getValue({ test: 1 }, "table")).toBe('{"test":1}');
expect(field.getValue("string", "table")).toBe('"string"');
expect(field.getValue(1, "form")).toBe("1");
});
});

View File

@@ -0,0 +1,9 @@
import { describe, expect, test } from "bun:test";
import { JsonSchemaField } from "../../../../src/data";
import { runBaseFieldTests } from "./inc";
describe("[data] JsonSchemaField", async () => {
runBaseFieldTests(JsonSchemaField, { defaultValue: {}, schemaType: "text" });
// @todo: add JsonSchemaField tests
});

View File

@@ -0,0 +1,19 @@
import { describe, expect, test } from "bun:test";
import { NumberField } from "../../../../src/data";
import { runBaseFieldTests, transformPersist } from "./inc";
describe("[data] NumberField", async () => {
test("transformPersist (config)", async () => {
const field = new NumberField("test", { minimum: 3, maximum: 5 });
expect(transformPersist(field, 2)).rejects.toThrow();
expect(transformPersist(field, 6)).rejects.toThrow();
expect(transformPersist(field, 4)).resolves.toBe(4);
const field2 = new NumberField("test");
expect(transformPersist(field2, 0)).resolves.toBe(0);
expect(transformPersist(field2, 10000)).resolves.toBe(10000);
});
runBaseFieldTests(NumberField, { defaultValue: 12, schemaType: "integer" });
});

View File

@@ -0,0 +1,37 @@
import { describe, expect, test } from "bun:test";
import { PrimaryField } from "../../../../src/data";
describe("[data] PrimaryField", async () => {
const field = new PrimaryField("primary");
test("name", async () => {
expect(field.name).toBe("primary");
});
test("schema", () => {
expect(field.name).toBe("primary");
expect(field.schema()).toEqual(["primary", "integer", expect.any(Function)]);
});
test("hasDefault", async () => {
expect(field.hasDefault()).toBe(false);
expect(field.getDefault()).toBe(undefined);
});
test("isFillable", async () => {
expect(field.isFillable()).toBe(false);
});
test("isHidden", async () => {
expect(field.isHidden()).toBe(false);
});
test("isRequired", async () => {
expect(field.isRequired()).toBe(false);
});
test("transformPersist/Retrieve", async () => {
expect(field.transformPersist(1)).rejects.toThrow();
expect(field.transformRetrieve(1)).toBe(1);
});
});

View File

@@ -0,0 +1,15 @@
import { describe, expect, test } from "bun:test";
import { TextField } from "../../../../src/data";
import { runBaseFieldTests, transformPersist } from "./inc";
describe("[data] TextField", async () => {
test("transformPersist (config)", async () => {
const field = new TextField("test", { minLength: 3, maxLength: 5 });
expect(transformPersist(field, "a")).rejects.toThrow();
expect(transformPersist(field, "abcdefghijklmn")).rejects.toThrow();
expect(transformPersist(field, "abc")).resolves.toBe("abc");
});
runBaseFieldTests(TextField, { defaultValue: "abc", schemaType: "text" });
});

View File

@@ -0,0 +1,162 @@
import { expect, test } from "bun:test";
import type { ColumnDataType } from "kysely";
import { omit } from "lodash-es";
import type { BaseFieldConfig, Field, TActionContext } from "../../../../src/data";
type ConstructableField = new (name: string, config?: Partial<BaseFieldConfig>) => Field;
type FieldTestConfig = {
defaultValue: any;
sampleValues?: any[];
schemaType: ColumnDataType;
};
export function transformPersist(field: Field, value: any, context?: TActionContext) {
return field.transformPersist(value, undefined as any, context as any);
}
export function runBaseFieldTests(
fieldClass: ConstructableField,
config: FieldTestConfig,
_requiredConfig: any = {}
) {
const noConfigField = new fieldClass("no_config", _requiredConfig);
const fillable = new fieldClass("fillable", { ..._requiredConfig, fillable: true });
const required = new fieldClass("required", { ..._requiredConfig, required: true });
const hidden = new fieldClass("hidden", { ..._requiredConfig, hidden: true });
const dflt = new fieldClass("dflt", { ..._requiredConfig, default_value: config.defaultValue });
const requiredAndDefault = new fieldClass("full", {
..._requiredConfig,
fillable: true,
required: true,
default_value: config.defaultValue
});
test("schema", () => {
expect(noConfigField.name).toBe("no_config");
expect(noConfigField.schema(null as any)).toEqual([
"no_config",
config.schemaType,
expect.any(Function)
]);
});
test("hasDefault", async () => {
expect(noConfigField.hasDefault()).toBe(false);
expect(noConfigField.getDefault()).toBeUndefined();
expect(dflt.hasDefault()).toBe(true);
expect(dflt.getDefault()).toBe(config.defaultValue);
});
test("isFillable", async () => {
expect(noConfigField.isFillable()).toBe(true);
expect(fillable.isFillable()).toBe(true);
expect(hidden.isFillable()).toBe(true);
expect(required.isFillable()).toBe(true);
});
test("isHidden", async () => {
expect(noConfigField.isHidden()).toBe(false);
expect(hidden.isHidden()).toBe(true);
expect(fillable.isHidden()).toBe(false);
expect(required.isHidden()).toBe(false);
});
test("isRequired", async () => {
expect(noConfigField.isRequired()).toBe(false);
expect(required.isRequired()).toBe(true);
expect(hidden.isRequired()).toBe(false);
expect(fillable.isRequired()).toBe(false);
});
test.if(Array.isArray(config.sampleValues))("getValue (RenderContext)", async () => {
const isPrimitive = (v) => ["string", "number"].includes(typeof v);
for (const value of config.sampleValues!) {
// "form"
expect(isPrimitive(noConfigField.getValue(value, "form"))).toBeTrue();
// "table"
expect(isPrimitive(noConfigField.getValue(value, "table"))).toBeTrue();
// "read"
// "submit"
}
});
test("transformPersist", async () => {
const persist = await transformPersist(noConfigField, config.defaultValue);
expect(config.defaultValue).toEqual(noConfigField.transformRetrieve(config.defaultValue));
expect(transformPersist(noConfigField, null)).resolves.toBeUndefined();
expect(transformPersist(noConfigField, undefined)).resolves.toBeUndefined();
expect(transformPersist(requiredAndDefault, null)).resolves.toBe(persist);
expect(transformPersist(dflt, null)).resolves.toBe(persist);
});
test("toJSON", async () => {
const _config = {
..._requiredConfig,
//order: 1,
fillable: true,
required: false,
hidden: false
//virtual: false,
//default_value: undefined
};
function fieldJson(field: Field) {
const json = field.toJSON();
return {
...json,
config: omit(json.config, ["html"])
};
}
expect(fieldJson(noConfigField)).toEqual({
//name: "no_config",
type: noConfigField.type,
config: _config
});
expect(fieldJson(fillable)).toEqual({
//name: "fillable",
type: noConfigField.type,
config: _config
});
expect(fieldJson(required)).toEqual({
//name: "required",
type: required.type,
config: {
..._config,
required: true
}
});
expect(fieldJson(hidden)).toEqual({
//name: "hidden",
type: required.type,
config: {
..._config,
hidden: true
}
});
expect(fieldJson(dflt)).toEqual({
//name: "dflt",
type: dflt.type,
config: {
..._config,
default_value: config.defaultValue
}
});
expect(fieldJson(requiredAndDefault)).toEqual({
//name: "full",
type: requiredAndDefault.type,
config: {
..._config,
fillable: true,
required: true,
default_value: config.defaultValue
}
});
});
}

View File

@@ -0,0 +1,78 @@
import { describe, expect, it, test } from "bun:test";
import { Entity, type EntityManager } from "../../../../src/data";
import {
type BaseRelationConfig,
EntityRelation,
EntityRelationAnchor,
RelationTypes
} from "../../../../src/data/relations";
class TestEntityRelation extends EntityRelation {
constructor(config?: BaseRelationConfig) {
super(
new EntityRelationAnchor(new Entity("source"), "source"),
new EntityRelationAnchor(new Entity("target"), "target"),
config
);
}
initialize(em: EntityManager<any>) {}
type() {
return RelationTypes.ManyToOne; /* doesn't matter */
}
setDirections(directions: ("source" | "target")[]) {
this.directions = directions;
return this;
}
buildWith(a: any, b: any, c: any): any {
return;
}
buildJoin(a: any, b: any): any {
return;
}
}
describe("[data] EntityRelation", async () => {
test("other", async () => {
const relation = new TestEntityRelation();
expect(relation.other("source").entity.name).toBe("target");
expect(relation.other("target").entity.name).toBe("source");
});
it("visibleFrom", async () => {
const relation = new TestEntityRelation();
// by default, both sides are visible
expect(relation.visibleFrom("source")).toBe(true);
expect(relation.visibleFrom("target")).toBe(true);
// make source invisible
relation.setDirections(["target"]);
expect(relation.visibleFrom("source")).toBe(false);
expect(relation.visibleFrom("target")).toBe(true);
// make target invisible
relation.setDirections(["source"]);
expect(relation.visibleFrom("source")).toBe(true);
expect(relation.visibleFrom("target")).toBe(false);
});
it("hydrate", async () => {
// @todo: implement
});
it("isListableFor", async () => {
// by default, the relation is listable from target side
const relation = new TestEntityRelation();
expect(relation.isListableFor(relation.target.entity)).toBe(true);
expect(relation.isListableFor(relation.source.entity)).toBe(false);
});
it("required", async () => {
const relation1 = new TestEntityRelation();
expect(relation1.config.required).toBe(false);
const relation2 = new TestEntityRelation({ required: true });
expect(relation2.config.required).toBe(true);
});
});

View File

@@ -0,0 +1,114 @@
import { afterAll, beforeAll, describe, expect, jest, test } from "bun:test";
import { FetchTask, Flow } from "../../src/flows";
let _oldFetch: typeof fetch;
function mockFetch(responseMethods: Partial<Response>) {
_oldFetch = global.fetch;
// @ts-ignore
global.fetch = jest.fn(() => Promise.resolve(responseMethods));
}
function mockFetch2(newFetch: (input: RequestInfo, init: RequestInit) => Promise<Response>) {
_oldFetch = global.fetch;
// @ts-ignore
global.fetch = jest.fn(newFetch);
}
function unmockFetch() {
global.fetch = _oldFetch;
}
beforeAll(() =>
/*mockFetch({
ok: true,
status: 200,
json: () => Promise.resolve({ todos: [1, 2] })
})*/
mockFetch2(async (input, init) => {
const request = {
url: String(input),
method: init?.method ?? "GET",
// @ts-ignore
headers: Object.fromEntries(init?.headers?.entries() ?? []),
body: init?.body
};
return new Response(JSON.stringify({ todos: [1, 2], request }), {
status: 200,
headers: { "Content-Type": "application/json" }
});
})
);
afterAll(unmockFetch);
describe("FetchTask", async () => {
test("Simple fetch", async () => {
const task = new FetchTask("Fetch Something", {
url: "https://jsonplaceholder.typicode.com/todos/1",
method: "GET",
headers: [{ key: "Content-Type", value: "application/json" }]
});
const result = await task.run();
//console.log("result", result);
expect(result.output!.todos).toEqual([1, 2]);
expect(result.error).toBeUndefined();
expect(result.success).toBe(true);
});
test("verify config", async () => {
// // @ts-expect-error
expect(() => new FetchTask("", {})).toThrow();
expect(
// // @ts-expect-error
() => new FetchTask("", { url: "https://jsonplaceholder.typicode.com", method: 1 })
).toThrow();
expect(
new FetchTask("", {
url: "https://jsonplaceholder.typicode.com",
method: "invalid"
}).execute()
).rejects.toThrow(/^Invalid method/);
expect(
() => new FetchTask("", { url: "https://jsonplaceholder.typicode.com", method: "GET" })
).toBeDefined();
expect(() => new FetchTask("", { url: "", method: "Invalid" })).toThrow();
});
test("template", async () => {
const task = new FetchTask("fetch", {
url: "https://example.com/?email={{ flow.output.email }}",
method: "{{ flow.output.method }}",
headers: [
{ key: "Content-{{ flow.output.headerKey }}", value: "application/json" },
{ key: "Authorization", value: "Bearer {{ flow.output.apiKey }}" }
],
body: JSON.stringify({
email: "{{ flow.output.email }}"
})
});
const inputs = {
headerKey: "Type",
apiKey: 123,
email: "what@else.com",
method: "PATCH"
};
const flow = new Flow("", [task]);
const exec = await flow.start(inputs);
console.log("errors", exec.getErrors());
expect(exec.hasErrors()).toBe(false);
const { request } = exec.getResponse();
expect(request.url).toBe(`https://example.com/?email=${inputs.email}`);
expect(request.method).toBe(inputs.method);
expect(request.headers["content-type"]).toBe("application/json");
expect(request.headers.authorization).toBe(`Bearer ${inputs.apiKey}`);
expect(request.body).toBe(JSON.stringify({ email: inputs.email }));
});
});

View File

@@ -0,0 +1,91 @@
import { describe, expect, test } from "bun:test";
import { Flow, LogTask, RenderTask, SubFlowTask } from "../../src/flows";
describe("SubFlowTask", async () => {
test("Simple Subflow", async () => {
const subTask = new RenderTask("render", {
render: "subflow"
});
const subflow = new Flow("subflow", [subTask]);
const task = new LogTask("log");
const task2 = new SubFlowTask("sub", {
flow: subflow
});
const task3 = new RenderTask("render2", {
render: "Subflow output: {{ sub.output }}"
});
const flow = new Flow("test", [task, task2, task3], []);
flow.task(task).asInputFor(task2);
flow.task(task2).asInputFor(task3);
const execution = flow.createExecution();
await execution.start();
/*console.log(execution.logs);
console.log(execution.getResponse());*/
expect(execution.getResponse()).toEqual("Subflow output: subflow");
});
test("Simple loop", async () => {
const subTask = new RenderTask("render", {
render: "run {{ flow.output }}"
});
const subflow = new Flow("subflow", [subTask]);
const task = new LogTask("log");
const task2 = new SubFlowTask("sub", {
flow: subflow,
loop: true,
input: [1, 2, 3]
});
const task3 = new RenderTask("render2", {
render: `Subflow output: {{ sub.output | join: ", " }}`
});
const flow = new Flow("test", [task, task2, task3], []);
flow.task(task).asInputFor(task2);
flow.task(task2).asInputFor(task3);
const execution = flow.createExecution();
await execution.start();
console.log("errors", execution.getErrors());
/*console.log(execution.logs);
console.log(execution.getResponse());*/
expect(execution.getResponse()).toEqual("Subflow output: run 1, run 2, run 3");
});
test("Simple loop from flow input", async () => {
const subTask = new RenderTask("render", {
render: "run {{ flow.output }}"
});
const subflow = new Flow("subflow", [subTask]);
const task = new LogTask("log");
const task2 = new SubFlowTask("sub", {
flow: subflow,
loop: true,
input: "{{ flow.output | json }}"
});
const task3 = new RenderTask("render2", {
render: `Subflow output: {{ sub.output | join: ", " }}`
});
const flow = new Flow("test", [task, task2, task3], []);
flow.task(task).asInputFor(task2);
flow.task(task2).asInputFor(task3);
const execution = flow.createExecution();
await execution.start([4, 5, 6]);
/*console.log(execution.logs);
console.log(execution.getResponse());*/
expect(execution.getResponse()).toEqual("Subflow output: run 4, run 5, run 6");
});
});

View File

@@ -0,0 +1,112 @@
import { describe, expect, test } from "bun:test";
import { Type } from "../../src/core/utils";
import { Task } from "../../src/flows";
import { dynamic } from "../../src/flows/tasks/Task";
describe("Task", async () => {
test("resolveParams: template with parse", async () => {
const result = await Task.resolveParams(
Type.Object({ test: dynamic(Type.Number()) }),
{
test: "{{ some.path }}"
},
{
some: {
path: 1
}
}
);
expect(result.test).toBe(1);
});
test("resolveParams: with string", async () => {
const result = await Task.resolveParams(
Type.Object({ test: Type.String() }),
{
test: "{{ some.path }}"
},
{
some: {
path: "1/1"
}
}
);
expect(result.test).toBe("1/1");
});
test("resolveParams: with object", async () => {
const result = await Task.resolveParams(
Type.Object({ test: dynamic(Type.Object({ key: Type.String(), value: Type.String() })) }),
{
test: { key: "path", value: "{{ some.path }}" }
},
{
some: {
path: "1/1"
}
}
);
expect(result.test).toEqual({ key: "path", value: "1/1" });
});
test("resolveParams: with json", async () => {
const result = await Task.resolveParams(
Type.Object({
test: dynamic(Type.Object({ key: Type.String(), value: Type.String() }))
}),
{
test: "{{ some | json }}"
},
{
some: {
key: "path",
value: "1/1"
}
}
);
expect(result.test).toEqual({ key: "path", value: "1/1" });
});
test("resolveParams: with array", async () => {
const result = await Task.resolveParams(
Type.Object({
test: dynamic(Type.Array(Type.String()))
}),
{
test: '{{ "1,2,3" | split: "," | json }}'
}
);
expect(result.test).toEqual(["1", "2", "3"]);
});
test("resolveParams: boolean", async () => {
const result = await Task.resolveParams(
Type.Object({
test: dynamic(Type.Boolean())
}),
{
test: "{{ true }}"
}
);
expect(result.test).toEqual(true);
});
test("resolveParams: float", async () => {
const result = await Task.resolveParams(
Type.Object({
test: dynamic(Type.Number(), Number.parseFloat)
}),
{
test: "{{ 3.14 }}"
}
);
expect(result.test).toEqual(3.14);
});
});

View File

@@ -0,0 +1,24 @@
import { Condition, Flow } from "../../../src/flows";
import { getNamedTask } from "./helper";
const first = getNamedTask("first");
const second = getNamedTask("second");
const fourth = getNamedTask("fourth");
let thirdRuns = 0;
const third = getNamedTask("third", async () => {
thirdRuns++;
if (thirdRuns === 3) {
return true;
}
throw new Error("Third failed");
});
const back = new Flow("back", [first, second, third, fourth]);
back.task(first).asInputFor(second);
back.task(second).asInputFor(third);
back.task(third).asInputFor(second, Condition.error(), 2);
back.task(third).asInputFor(fourth, Condition.success());
export { back };

View File

@@ -0,0 +1,23 @@
import { Condition, Flow } from "../../../src/flows";
import { getNamedTask } from "./helper";
const first = getNamedTask(
"first",
async () => {
//throw new Error("Error");
return {
inner: {
result: 2
}
};
},
1000
);
const second = getNamedTask("second (if match)");
const third = getNamedTask("third (if error)");
const fanout = new Flow("fanout", [first, second, third]);
fanout.task(first).asInputFor(third, Condition.error(), 2);
fanout.task(first).asInputFor(second, Condition.matches("inner.result", 2));
export { fanout };

View File

@@ -0,0 +1,61 @@
import { Task } from "../../../src/flows";
// @todo: polyfill
const Handle = (props: any) => null;
type NodeProps<T> = any;
const Position = { Top: "top", Bottom: "bottom" };
class ExecTask extends Task {
type = "exec";
constructor(
name: string,
params: any,
private fn: () => any
) {
super(name, params);
}
override clone(name: string, params: any) {
return new ExecTask(name, params, this.fn);
}
async execute() {
//console.log("executing", this.name);
return await this.fn();
}
}
/*const ExecNode = ({
data,
isConnectable,
targetPosition = Position.Top,
sourcePosition = Position.Bottom,
selected,
}: NodeProps<ExecTask>) => {
//console.log("data", data, data.hasDelay());
return (
<>
<Handle type="target" position={targetPosition} isConnectable={isConnectable} />
{data?.name} ({selected ? "selected" : "exec"})
<Handle type="source" position={sourcePosition} isConnectable={isConnectable} />
</>
);
};*/
export function getNamedTask(name: string, _func?: () => Promise<any>, delay?: number) {
const func =
_func ??
(async () => {
//console.log(`[DONE] Task: ${name}`);
return true;
});
return new ExecTask(
name,
{
delay
},
func
);
}

View File

@@ -0,0 +1,15 @@
import { Flow } from "../../../src/flows";
import { getNamedTask } from "./helper";
const first = getNamedTask("first");
const second = getNamedTask("second", undefined, 1000);
const third = getNamedTask("third");
const fourth = getNamedTask("fourth");
const fifth = getNamedTask("fifth"); // without connection
const parallel = new Flow("Parallel", [first, second, third, fourth, fifth]);
parallel.task(first).asInputFor(second);
parallel.task(first).asInputFor(third);
parallel.task(third).asInputFor(fourth);
export { parallel };

View File

@@ -0,0 +1,18 @@
import { FetchTask, Flow, LogTask } from "../../../src/flows";
const first = new LogTask("First", { delay: 1000 });
const second = new LogTask("Second", { delay: 1000 });
const third = new LogTask("Long Third", { delay: 2500 });
const fourth = new FetchTask("Fetch Something", {
url: "https://jsonplaceholder.typicode.com/todos/1"
});
const fifth = new LogTask("Task 4", { delay: 500 }); // without connection
const simpleFetch = new Flow("simpleFetch", [first, second, third, fourth, fifth]);
simpleFetch.task(first).asInputFor(second);
simpleFetch.task(first).asInputFor(third);
simpleFetch.task(fourth).asOutputFor(third);
simpleFetch.setRespondingTask(fourth);
export { simpleFetch };

View File

@@ -0,0 +1,175 @@
import { describe, expect, test } from "bun:test";
import { Hono } from "hono";
import { Event, EventManager } from "../../src/core/events";
import { type Static, type StaticDecode, Type, parse } from "../../src/core/utils";
import { EventTrigger, Flow, HttpTrigger, type InputsMap, Task } from "../../src/flows";
import { dynamic } from "../../src/flows/tasks/Task";
class Passthrough extends Task {
type = "passthrough";
async execute(inputs: Map<string, any>) {
//console.log("executing passthrough", this.name, inputs);
return Array.from(inputs.values()).pop().output + "/" + this.name;
}
}
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 = Type.Object({
number: dynamic(
Type.Number({
title: "Output number"
}),
Number.parseInt
)
});
async execute(inputs: InputsMap) {
//console.log("--***--- executing output", this.params);
return this.params.number;
}
}
class PassthroughFlowInput extends Task {
type = "passthrough-flow-input";
async execute(inputs: InputsMap) {
return inputs.get("flow")?.output;
}
}
describe("Flow task inputs", async () => {
test("types", async () => {
const schema = OutputParamTask.schema;
expect(parse(schema, { number: 123 })).toBeDefined();
expect(parse(schema, { number: "{{ some.path }}" })).toBeDefined();
const task = new OutputParamTask("", { number: 123 });
expect(task.params.number).toBe(123);
});
test("passthrough", async () => {
const task = new Passthrough("log");
const task2 = new Passthrough("log_2");
const flow = new Flow("test", [task, task2]);
flow.task(task).asInputFor(task2);
flow.setRespondingTask(task2);
const exec = await flow.start("pass-through");
/*console.log(
"---- log",
exec.logs.map(({ task, ...l }) => ({ ...l, ...task.toJSON() })),
);
console.log("---- result", exec.getResponse());*/
expect(exec.getResponse()).toBe("pass-through/log/log_2");
});
test("output/input", async () => {
const task = new OutputParamTask("task1", { number: 111 });
const task2 = new OutputParamTask("task2", {
number: "{{ task1.output }}"
});
const flow = new Flow("test", [task, task2]);
flow.task(task).asInputFor(task2);
flow.setRespondingTask(task2);
const exec = await flow.start();
/*console.log(
"---- log",
exec.logs.map(({ task, ...l }) => ({ ...l, ...task.toJSON() })),
);
console.log("---- result", exec.getResponse());*/
expect(exec.getResponse()).toBe(111);
});
test("input from flow", async () => {
const task = new OutputParamTask("task1", {
number: "{{flow.output.someFancyParam}}"
});
const task2 = new OutputParamTask("task2", {
number: "{{task1.output}}"
});
const flow = new Flow("test", [task, task2]);
flow.task(task).asInputFor(task2);
flow.setRespondingTask(task2);
// expect to throw because of missing input
//expect(flow.start()).rejects.toThrow();
const exec = await flow.start({ someFancyParam: 123 });
/*console.log(
"---- log",
exec.logs.map(({ task, ...l }) => ({ ...l, ...task.toJSON() })),
);
console.log("---- result", exec.getResponse());*/
expect(exec.getResponse()).toBe(123);
});
test("manual event trigger with inputs", async () => {
class EventTriggerClass extends Event<{ number: number }> {
static override slug = "test-event";
}
const emgr = new EventManager({ EventTriggerClass });
const task = new OutputParamTask("event", {
number: "{{flow.output.number}}"
});
const flow = new Flow(
"test",
[task],
[],
new EventTrigger({
event: "test-event",
mode: "sync"
})
);
flow.setRespondingTask(task);
flow.trigger.register(flow, emgr);
await emgr.emit(new EventTriggerClass({ number: 120 }));
const execs = flow.trigger.executions;
expect(execs.length).toBe(1);
expect(execs[0]!.getResponse()).toBe(120);
});
test("http trigger with response", async () => {
const task = new PassthroughFlowInput("");
const flow = new Flow(
"test",
[task],
[],
new HttpTrigger({
path: "/test",
method: "GET",
mode: "sync"
})
);
flow.setRespondingTask(task);
const hono = new Hono();
flow.trigger.register(flow, hono);
const res = await hono.request("/test?input=123");
const data = await res.json();
//console.log("response", data);
const execs = flow.trigger.executions;
expect(execs.length).toBe(1);
expect(execs[0]!.getResponse()).toBeInstanceOf(Request);
expect(execs[0]!.getResponse()?.url).toBe("http://localhost/test?input=123");
});
});

View File

@@ -0,0 +1,186 @@
import { Box, Text, render, useApp, useInput } from "ink";
import React, { useEffect } from "react";
import { ExecutionEvent, type Flow, type Task } from "../../src/flows";
import { back } from "./inc/back";
import { fanout } from "./inc/fanout-condition";
import { parallel } from "./inc/parallel";
import { simpleFetch } from "./inc/simple-fetch";
const flows = {
back,
fanout,
parallel,
simpleFetch
};
const arg = process.argv[2];
if (!arg) {
console.log("Please provide a flow name:", Object.keys(flows).join(", "));
process.exit(1);
}
if (!flows[arg]) {
console.log("Flow not found:", arg, Object.keys(flows).join(", "));
process.exit(1);
}
console.log(JSON.stringify(flows[arg].toJSON(), null, 2));
process.exit();
const colors = [
"#B5E61D", // Lime Green
"#4A90E2", // Bright Blue
"#F78F1E", // Saffron
"#BD10E0", // Vivid Purple
"#50E3C2", // Turquoise
"#9013FE" // Grape
];
const colorsCache: Record<string, string> = {};
type Sequence = { source: string; target: string }[];
type Layout = Task[][];
type State = { layout: Layout; connections: Sequence };
type TaskWithStatus = { task: Task; status: string };
function TerminalFlow({ flow }: { flow: Flow }) {
const [tasks, setTasks] = React.useState<TaskWithStatus[]>([]);
const sequence = flow.getSequence();
const connections = flow.connections;
const { exit } = useApp();
useInput((input, key) => {
if (input === "q") {
exit();
return;
}
if (key.return) {
// Left arrow key pressed
console.log("Enter pressed");
} else {
console.log(input);
}
});
useEffect(() => {
setTasks(flow.tasks.map((t) => ({ task: t, status: "pending" })));
const execution = flow.createExecution();
execution.subscribe((event) => {
if (event instanceof ExecutionEvent) {
setTasks((prev) =>
prev.map((t) => {
if (t.task.name === event.task().name) {
let newStatus = "pending";
if (event.isStart()) {
newStatus = "running";
} else {
newStatus = event.succeeded() ? "success" : "failure";
}
return { task: t.task, status: newStatus };
}
return t;
})
);
}
});
execution.start().then(() => {
const response = execution.getResponse();
console.log("done", response ? response : "(no response)");
console.log(
"Executed tasks:",
execution.logs.map((l) => l.task.name)
);
console.log("Executed count:", execution.logs.length);
});
}, []);
function getColor(key: string) {
if (!colorsCache[key]) {
colorsCache[key] = colors[Object.keys(colorsCache).length];
}
return colorsCache[key];
}
if (tasks.length === 0) {
return <Text>Loading...</Text>;
}
return (
<Box flexDirection="column">
{sequence.map((step, stepIndex) => {
return (
<Box key={stepIndex} flexDirection="row">
{step.map((_task, index) => {
const find = tasks.find((t) => t.task.name === _task.name)!;
if (!find) {
//console.log("couldnt find", _task.name);
return null;
}
const { task, status } = find;
const inTasks = flow.task(task).getInTasks();
return (
<Box
key={index}
borderStyle="single"
marginX={1}
paddingX={1}
flexDirection="column"
>
{inTasks.length > 0 && (
<Box>
<Text dimColor>In: </Text>
<Box>
{inTasks.map((inTask, i) => (
<Text key={i} color={getColor(inTask.name)}>
{i > 0 ? ", " : ""}
{inTask.name}
</Text>
))}
</Box>
</Box>
)}
<Box flexDirection="column">
<Text bold color={getColor(task.name)}>
{task.name}
</Text>
<Status status={status} />
</Box>
</Box>
);
})}
</Box>
);
})}
</Box>
);
}
const Status = ({ status }: { status: string }) => {
let color: string | undefined;
switch (status) {
case "running":
color = "orange";
break;
case "success":
color = "green";
break;
case "failure":
color = "red";
break;
}
return (
<Text color={color} dimColor={!color}>
{status}
</Text>
);
};
render(<TerminalFlow flow={flows[arg]} />);

View File

@@ -0,0 +1,175 @@
import { describe, expect, test } from "bun:test";
import { Hono } from "hono";
import { Event, EventManager } from "../../src/core/events";
import { EventTrigger, Flow, HttpTrigger, Task } from "../../src/flows";
const ALL_TESTS = !!process.env.ALL_TESTS;
class ExecTask extends Task {
type = "exec";
constructor(
name: string,
params: any,
private fn: () => any
) {
super(name, params);
}
static create(name: string, fn: () => any) {
return new ExecTask(name, undefined, fn);
}
override clone(name: string, params: any) {
return new ExecTask(name, params, this.fn);
}
async execute() {
//console.log("executing", this.name);
return await this.fn();
}
}
describe("Flow trigger", async () => {
test("manual trigger", async () => {
let called = false;
const task = ExecTask.create("manual", () => {
called = true;
});
const flow = new Flow("", [task]);
expect(flow.trigger.type).toBe("manual");
await flow.trigger.register(flow);
expect(called).toBe(true);
});
test("event trigger", async () => {
class EventTriggerClass extends Event {
static override slug = "test-event";
}
const emgr = new EventManager({ EventTriggerClass });
let called = false;
const task = ExecTask.create("event", () => {
called = true;
});
const flow = new Flow(
"test",
[task],
[],
new EventTrigger({ event: "test-event", mode: "sync" })
);
flow.trigger.register(flow, emgr);
await emgr.emit(new EventTriggerClass({ test: 1 }));
expect(called).toBe(true);
});
/*test("event trigger with match", async () => {
class EventTriggerClass extends Event<{ number: number }> {
static slug = "test-event";
}
const emgr = new EventManager({ EventTriggerClass });
let called: number = 0;
const task = ExecTask.create("event", () => {
called++;
});
const flow = new Flow(
"test",
[task],
[],
new EventTrigger(EventTriggerClass, "sync", (e) => e.params.number === 2)
);
flow.trigger.register(flow, emgr);
await emgr.emit(new EventTriggerClass({ number: 1 }));
await emgr.emit(new EventTriggerClass({ number: 2 }));
expect(called).toBe(1);
});*/
test("http trigger", async () => {
let called = false;
const task = ExecTask.create("http", () => {
called = true;
});
const flow = new Flow(
"test",
[task],
[],
new HttpTrigger({
path: "/test",
method: "GET",
mode: "sync"
})
);
const hono = new Hono();
flow.trigger.register(flow, hono);
const res = await hono.request("/test");
//const data = await res.json();
//console.log("response", data);
expect(called).toBe(true);
});
test("http trigger with response", async () => {
const task = ExecTask.create("http", () => ({
called: true
}));
const flow = new Flow(
"test",
[task],
[],
new HttpTrigger({
path: "/test",
method: "GET",
mode: "sync"
})
);
flow.setRespondingTask(task);
const hono = new Hono();
flow.trigger.register(flow, hono);
const res = await hono.request("/test");
const data = await res.json();
//console.log("response", data);
expect(data).toEqual({ called: true });
});
/*test.skipIf(ALL_TESTS)("template with email", async () => {
console.log("apikey", process.env.RESEND_API_KEY);
const task = new FetchTask("fetch", {
url: "https://api.resend.com/emails",
method: "POST",
headers: [
{ key: "Content-Type", value: "application/json" },
{ key: "Authorization", value: "Bearer {{ flow.output.apiKey }}" }
],
body: JSON.stringify({
from: "onboarding@resend.dev",
to: "dennis.senn@gmail.com",
subject:
"test from {% if flow.output.someFancyParam > 100 %}flow{% else %}task{% endif %}!",
html: "Hello"
})
});
const flow = new Flow("test", [task]);
const exec = await flow.start({ someFancyParam: 80, apiKey: process.env.RESEND_API_KEY });
//console.log("exec", exec.logs, exec.finished());
expect(exec.finished()).toBe(true);
expect(exec.hasErrors()).toBe(false);
});*/
});

View File

@@ -0,0 +1,449 @@
// eslint-disable-next-line import/no-unresolved
import { describe, expect, test } from "bun:test";
import { isEqual } from "lodash-es";
import { type Static, Type, _jsonp } from "../../src/core/utils";
import { Condition, ExecutionEvent, FetchTask, Flow, LogTask, Task } from "../../src/flows";
/*beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);*/
class ExecTask extends Task<typeof ExecTask.schema> {
type = "exec";
static override schema = Type.Object({
delay: Type.Number({ default: 10 })
});
constructor(
name: string,
params: Static<typeof ExecTask.schema>,
private func: () => Promise<any>
) {
super(name, params);
}
override clone(name: string, params: Static<typeof ExecTask.schema>) {
return new ExecTask(name, params, this.func);
}
async execute() {
await new Promise((resolve) => setTimeout(resolve, this.params.delay ?? 0));
return await this.func();
}
}
function getTask(num: number = 0, delay: number = 5) {
return new ExecTask(
`Task ${num}`,
{
delay
},
async () => {
//console.log(`[DONE] Task: ${num}`);
return true;
}
);
//return new LogTask(`Log ${num}`, { delay });
}
function getNamedTask(name: string, _func?: () => Promise<any>, delay?: number) {
const func =
_func ??
(async () => {
//console.log(`[DONE] Task: ${name}`);
return true;
});
return new ExecTask(
name,
{
delay: delay ?? 0
},
func
);
}
function getObjectDiff(obj1, obj2) {
const diff = Object.keys(obj1).reduce((result, key) => {
// biome-ignore lint/suspicious/noPrototypeBuiltins: <explanation>
if (!obj2.hasOwnProperty(key)) {
result.push(key);
} else if (isEqual(obj1[key], obj2[key])) {
const resultKeyIndex = result.indexOf(key);
result.splice(resultKeyIndex, 1);
}
return result;
}, Object.keys(obj2));
return diff;
}
describe("Flow tests", async () => {
test("Simple single task", async () => {
const simple = getTask(0);
const result = await simple.run();
expect(result.success).toBe(true);
// @todo: add more
});
function getNamedQueue(flow: Flow) {
const namedSequence = flow.getSequence().map((step) => step.map((t) => t.name));
//console.log(namedSequence);
return namedSequence;
}
test("Simple flow", async () => {
const first = getTask(0);
const second = getTask(1);
// simple
const simple = new Flow("simple", [first, second]);
simple.task(first).asInputFor(second);
expect(getNamedQueue(simple)).toEqual([["Task 0"], ["Task 1"]]);
expect(simple.task(first).getDepth()).toBe(0);
expect(simple.task(second).getDepth()).toBe(1);
const execution = simple.createExecution();
await execution.start();
//console.log("execution", execution.logs);
//process.exit(0);
expect(execution.logs.length).toBe(2);
expect(execution.logs.every((log) => log.success)).toBe(true);
});
test("Test connection uniqueness", async () => {
const first = getTask(0);
const second = getTask(1);
const third = getTask(2, 5);
const fourth = getTask(3);
// should be fine
expect(() => {
const condition = new Flow("", [first, second, third]);
condition.task(first).asInputFor(second);
condition.task(first).asInputFor(third);
}).toBeDefined();
// should throw
expect(() => {
const condition = new Flow("", [first, second, third]);
condition.task(first).asInputFor(second);
condition.task(first).asInputFor(second);
}).toThrow();
expect(() => {
const condition = new Flow("", [first, second, third]);
condition.task(first).asInputFor(second);
condition.task(second).asInputFor(third);
condition.task(third).asInputFor(second);
condition.task(third).asInputFor(fourth); // this should fail
}).toThrow();
expect(() => {
const condition = new Flow("", [first, second, third]);
condition.task(first).asInputFor(second);
condition.task(second).asInputFor(third);
condition.task(third).asInputFor(second);
condition.task(third).asInputFor(fourth, Condition.error());
}).toBeDefined();
});
test("Flow with 3 steps", async () => {
const first = getTask(0);
const second = getTask(1);
const third = getTask(2);
const three = new Flow("", [first, second, third]);
three.task(first).asInputFor(second);
three.task(second).asInputFor(third);
expect(getNamedQueue(three)).toEqual([["Task 0"], ["Task 1"], ["Task 2"]]);
expect(three.task(first).getDepth()).toBe(0);
expect(three.task(second).getDepth()).toBe(1);
expect(three.task(third).getDepth()).toBe(2);
const execution = three.createExecution();
await execution.start();
expect(execution.logs.length).toBe(3);
expect(execution.logs.every((log) => log.success)).toBe(true);
});
test("Flow with parallel tasks", async () => {
const first = getTask(0);
const second = getTask(1);
const third = getTask(2);
const fourth = getTask(3);
const fifth = getTask(4); // without connection
const parallel = new Flow("", [first, second, third, fourth, fifth]);
parallel.task(first).asInputFor(second);
parallel.task(first).asInputFor(third);
parallel.task(third).asInputFor(fourth);
expect(getNamedQueue(parallel)).toEqual([["Task 0"], ["Task 1", "Task 2"], ["Task 3"]]);
expect(parallel.task(first).getDepth()).toBe(0);
expect(parallel.task(second).getDepth()).toBe(1);
expect(parallel.task(third).getDepth()).toBe(1);
expect(parallel.task(fourth).getDepth()).toBe(2);
const execution = parallel.createExecution();
await execution.start();
expect(execution.logs.length).toBe(4);
expect(execution.logs.every((log) => log.success)).toBe(true);
});
test("Flow with condition", async () => {
const first = getTask(0);
const second = getTask(1);
const third = getTask(2);
const condition = new Flow("", [first, second, third]);
condition.task(first).asInputFor(second);
condition.task(first).asInputFor(third);
});
test("Flow with back step", async () => {
const first = getNamedTask("first");
const second = getNamedTask("second");
const fourth = getNamedTask("fourth");
let thirdRuns: number = 0;
const third = getNamedTask("third", async () => {
thirdRuns++;
if (thirdRuns === 4) {
return true;
}
throw new Error("Third failed");
});
const back = new Flow("", [first, second, third, fourth]);
back.task(first).asInputFor(second);
back.task(second).asInputFor(third);
back.task(third).asInputFor(second, Condition.error(), 2);
back.task(third).asInputFor(fourth, Condition.success());
expect(getNamedQueue(back)).toEqual([["first"], ["second"], ["third"], ["fourth"]]);
expect(
back
.task(third)
.getOutTasks()
.map((t) => t.name)
).toEqual(["second", "fourth"]);
const execution = back.createExecution();
expect(execution.start()).rejects.toThrow();
});
test("Flow with back step: enough retries", async () => {
const first = getNamedTask("first");
const second = getNamedTask("second");
const fourth = getNamedTask("fourth");
let thirdRuns: number = 0;
const third = getNamedTask("third", async () => {
thirdRuns++;
//console.log("--- third runs", thirdRuns);
if (thirdRuns === 2) {
return true;
}
throw new Error("Third failed");
});
const back = new Flow("", [first, second, third, fourth]);
back.task(first).asInputFor(second);
back.task(second).asInputFor(third);
back.task(third).asInputFor(second, Condition.error(), 1);
back.task(third).asInputFor(fourth, Condition.success());
expect(getNamedQueue(back)).toEqual([["first"], ["second"], ["third"], ["fourth"]]);
expect(
back
.task(third)
.getOutTasks()
.map((t) => t.name)
).toEqual(["second", "fourth"]);
const execution = back.createExecution();
await execution.start();
});
test("flow fanout", async () => {
const first = getTask(0);
const second = getTask(1);
const third = getTask(2, 20);
const fanout = new Flow("", [first, second, third]);
fanout.task(first).asInputFor(second);
fanout.task(first).asInputFor(third);
const execution = fanout.createExecution();
await execution.start();
expect(execution.logs.length).toBe(3);
expect(execution.logs.every((log) => log.success)).toBe(true);
});
test("flow fanout with condition", async () => {
const first = getTask(0);
const second = getTask(1);
const third = getTask(2);
const fanout = new Flow("", [first, second, third]);
fanout.task(first).asInputFor(second, Condition.success());
fanout.task(first).asInputFor(third, Condition.error());
const execution = fanout.createExecution();
await execution.start();
expect(execution.logs.length).toBe(2);
expect(execution.logs.every((log) => log.success)).toBe(true);
});
test("flow fanout with condition error", async () => {
const first = getNamedTask("first", async () => {
throw new Error("Error");
});
const second = getNamedTask("second");
const third = getNamedTask("third");
const fanout = new Flow("", [first, second, third]);
fanout.task(first).asInputFor(third, Condition.error());
fanout.task(first).asInputFor(second, Condition.success());
const execution = fanout.createExecution();
await execution.start();
expect(execution.logs.length).toBe(2);
expect(execution.logs.map((l) => l.task.name)).toEqual(["first", "third"]);
});
test("flow fanout with condition matches", async () => {
const first = getNamedTask("first", async () => {
return {
inner: {
result: 2
}
};
});
const second = getNamedTask("second");
const third = getNamedTask("third");
const fanout = new Flow("", [first, second, third]);
fanout.task(first).asInputFor(third, Condition.error());
fanout.task(first).asInputFor(second, Condition.matches("inner.result", 2));
const execution = fanout.createExecution();
await execution.start();
expect(execution.logs.length).toBe(2);
expect(execution.logs.map((l) => l.task.name)).toEqual(["first", "second"]);
});
test("flow: responding task", async () => {
const first = getNamedTask("first");
const second = getNamedTask("second", async () => ({ result: 2 }));
const third = getNamedTask("third");
const flow = new Flow("", [first, second, third]);
flow.task(first).asInputFor(second);
flow.task(second).asInputFor(third);
flow.setRespondingTask(second);
const execution = flow.createExecution();
execution.subscribe(async (event) => {
if (event instanceof ExecutionEvent) {
console.log(
"[event]",
event.isStart() ? "start" : "end",
event.task().name,
event.isStart() ? undefined : event.succeeded()
);
}
});
await execution.start();
const response = execution.getResponse();
expect(response).toEqual({ result: 2 });
expect(execution.logs.length).toBe(2);
expect(execution.logs.map((l) => l.task.name)).toEqual(["first", "second"]);
/*console.log("response", response);
console.log("execution.logs.length", execution.logs.length);
console.log(
"executed",
execution.logs.map((l) => l.task.name),
);*/
/*expect(execution.logs.length).toBe(3);
expect(execution.logs.every((log) => log.success)).toBe(true);*/
});
test("serialize/deserialize", async () => {
const first = new LogTask("Task 0");
const second = new LogTask("Task 1");
const third = new LogTask("Task 2", { delay: 50 });
const fourth = new FetchTask("Fetch Something", {
url: "https://jsonplaceholder.typicode.com/todos/1"
});
const fifth = new LogTask("Task 4"); // without connection
const flow = new Flow("", [first, second, third, fourth, fifth]);
flow.task(first).asInputFor(second);
flow.task(first).asInputFor(third);
flow.task(fourth).asOutputFor(third, Condition.matches("some", 1));
flow.setRespondingTask(fourth);
const original = flow.toJSON();
//console.log("flow", original);
// @todo: fix
const deserialized = Flow.fromObject("", original, {
fetch: { cls: FetchTask },
log: { cls: LogTask }
} as any);
const diffdeep = getObjectDiff(original, deserialized.toJSON());
expect(diffdeep).toEqual([]);
expect(flow.startTask.name).toEqual(deserialized.startTask.name);
expect(flow.respondingTask?.name).toEqual(
// @ts-ignore
deserialized.respondingTask?.name
);
//console.log("--- creating original sequence");
const originalSequence = flow.getSequence();
//console.log("--- creating deserialized sequence");
const deserializedSequence = deserialized.getSequence();
//console.log("--- ");
expect(originalSequence).toEqual(deserializedSequence);
});
test("error end", async () => {
const first = getNamedTask("first", async () => "first");
const second = getNamedTask("error", async () => {
throw new Error("error");
});
const third = getNamedTask("third", async () => "third");
const errorhandlertask = getNamedTask("errorhandler", async () => "errorhandler");
const flow = new Flow("", [first, second, third, errorhandlertask]);
flow.task(first).asInputFor(second);
flow.task(second).asInputFor(third);
flow.task(second).asInputFor(errorhandlertask, Condition.error());
const exec = await flow.start();
//console.log("logs", JSON.stringify(exec.logs, null, 2));
//console.log("errors", exec.hasErrors(), exec.errorCount());
expect(exec.hasErrors()).toBe(true);
expect(exec.errorCount()).toBe(1);
expect(exec.getResponse()).toBe("errorhandler");
});
});

53
app/__test__/helper.ts Normal file
View File

@@ -0,0 +1,53 @@
import { unlink } from "node:fs/promises";
import type { SqliteDatabase } from "kysely";
import Database from "libsql";
import { SqliteLocalConnection } from "../src/data";
export function getDummyDatabase(memory: boolean = true): {
dummyDb: SqliteDatabase;
afterAllCleanup: () => Promise<boolean>;
} {
const DB_NAME = memory ? ":memory:" : `${Math.random().toString(36).substring(7)}.db`;
const dummyDb = new Database(DB_NAME);
return {
dummyDb,
afterAllCleanup: async () => {
if (!memory) await unlink(DB_NAME);
return true;
}
};
}
export function getDummyConnection(memory: boolean = true) {
const { dummyDb, afterAllCleanup } = getDummyDatabase(memory);
const dummyConnection = new SqliteLocalConnection(dummyDb);
return {
dummyConnection,
afterAllCleanup
};
}
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"]) {
severities.forEach((severity) => {
console[severity] = () => null;
});
}
export function enableConsoleLog() {
Object.entries(_oldConsoles).forEach(([severity, fn]) => {
console[severity as ConsoleSeverity] = fn;
});
}

View File

@@ -0,0 +1,56 @@
import { describe, test } from "bun:test";
import { Hono } from "hono";
import { Guard } from "../../src/auth";
import { EventManager } from "../../src/core/events";
import { EntityManager } from "../../src/data";
import { AppMedia } from "../../src/media/AppMedia";
import { MediaController } from "../../src/media/api/MediaController";
import { getDummyConnection } from "../helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
/**
* R2
* value: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob | null,
* Node writefile
* data: string | NodeJS.ArrayBufferView | Iterable<string | NodeJS.ArrayBufferView> | AsyncIterable<string | NodeJS.ArrayBufferView> | Stream,
*/
describe("MediaController", () => {
test("..", async () => {
const ctx: any = {
em: new EntityManager([], dummyConnection, []),
guard: new Guard(),
emgr: new EventManager(),
server: new Hono()
};
const media = new AppMedia(
// @ts-ignore
{
enabled: true,
adapter: {
type: "s3",
config: {
access_key: process.env.R2_ACCESS_KEY as string,
secret_access_key: process.env.R2_SECRET_ACCESS_KEY as string,
url: process.env.R2_URL as string
}
}
},
ctx
);
await media.build();
const app = new MediaController(media).getController();
const file = Bun.file(`${import.meta.dir}/adapters/icon.png`);
console.log("file", file);
const form = new FormData();
form.append("file", file);
await app.request("/upload/test.png", {
method: "POST",
body: file
});
});
});

View File

@@ -0,0 +1,81 @@
import { describe, expect, test } from "bun:test";
import { type FileBody, Storage, type StorageAdapter } from "../../src/media/storage/Storage";
import * as StorageEvents from "../../src/media/storage/events";
class TestAdapter implements StorageAdapter {
files: Record<string, FileBody> = {};
getName() {
return "test";
}
getSchema() {
return undefined;
}
async listObjects(prefix?: string) {
return [];
}
async putObject(key: string, body: FileBody) {
this.files[key] = body;
return "etag-string";
}
async deleteObject(key: string) {
delete this.files[key];
}
async objectExists(key: string) {
return key in this.files;
}
async getObject(key: string) {
return new Response(this.files[key]);
}
getObjectUrl(key: string) {
return key;
}
async getObjectMeta(key: string) {
return { type: "text/plain", size: 0 };
}
toJSON(secrets?: boolean): any {
return { name: this.getName() };
}
}
describe("Storage", async () => {
const adapter = new TestAdapter();
const storage = new Storage(adapter);
const events = new Map<string, any>();
storage.emgr.onAny((event) => {
// @ts-ignore
events.set(event.constructor.slug, event);
//console.log("event", event.constructor.slug, event);
});
test("uploads a file", async () => {
const {
meta: { type, size }
} = await storage.uploadFile("hello", "world.txt");
expect({ type, size }).toEqual({ type: "text/plain", size: 0 });
});
test("deletes the file", async () => {
expect(await storage.deleteFile("hello")).toBeUndefined();
expect(await storage.fileExists("hello")).toBeFalse();
});
test("events were fired", async () => {
expect(events.has(StorageEvents.FileUploadedEvent.slug)).toBeTrue();
expect(events.has(StorageEvents.FileDeletedEvent.slug)).toBeTrue();
// @todo: file access must be tested in controllers
//expect(events.has(StorageEvents.FileAccessEvent.slug)).toBeTrue();
});
// @todo: test controllers
});

View File

@@ -0,0 +1,34 @@
import * as assert from "node:assert/strict";
import { createWriteStream } from "node:fs";
import { test } from "node:test";
import { Miniflare } from "miniflare";
// https://github.com/nodejs/node/issues/44372#issuecomment-1736530480
console.log = async (message: any) => {
const tty = createWriteStream("/dev/tty");
const msg = typeof message === "string" ? message : JSON.stringify(message, null, 2);
return tty.write(`${msg}\n`);
};
test("what", async () => {
const mf = new Miniflare({
modules: true,
script: "export default { async fetch() { return new Response(null); } }",
r2Buckets: ["BUCKET"]
});
const bucket = await mf.getR2Bucket("BUCKET");
console.log(await bucket.put("count", "1"));
const object = await bucket.get("count");
if (object) {
/*const headers = new Headers();
object.writeHttpMetadata(headers);
headers.set("etag", object.httpEtag);*/
console.log("yo -->", await object.text());
assert.strictEqual(await object.text(), "1");
}
await mf.dispose();
});

View File

@@ -0,0 +1,61 @@
import { describe, expect, test } from "bun:test";
import { randomString } from "../../../src/core/utils";
import { StorageCloudinaryAdapter } from "../../../src/media";
import { config } from "dotenv";
const dotenvOutput = config({ path: `${import.meta.dir}/../../.env` });
const {
CLOUDINARY_CLOUD_NAME,
CLOUDINARY_API_KEY,
CLOUDINARY_API_SECRET,
CLOUDINARY_UPLOAD_PRESET
} = dotenvOutput.parsed!;
const ALL_TESTS = !!process.env.ALL_TESTS;
describe.skipIf(ALL_TESTS)("StorageCloudinaryAdapter", () => {
const adapter = new StorageCloudinaryAdapter({
cloud_name: CLOUDINARY_CLOUD_NAME as string,
api_key: CLOUDINARY_API_KEY as string,
api_secret: CLOUDINARY_API_SECRET as string,
upload_preset: CLOUDINARY_UPLOAD_PRESET as string
});
const file = Bun.file(`${import.meta.dir}/icon.png`);
const _filename = randomString(10);
const filename = `${_filename}.png`;
test("object exists", async () => {
expect(await adapter.objectExists("7fCTBi6L8c.png")).toBeTrue();
process.exit();
});
test("puts object", async () => {
expect(await adapter.objectExists(filename)).toBeFalse();
const result = await adapter.putObject(filename, file);
console.log("result", result);
expect(result).toBeDefined();
expect(result?.name).toBe(filename);
});
test("object exists", async () => {
await Bun.sleep(10000);
const one = await adapter.objectExists(_filename);
const two = await adapter.objectExists(filename);
expect(await adapter.objectExists(filename)).toBeTrue();
});
test("object meta", async () => {
const result = await adapter.getObjectMeta(filename);
console.log("objectMeta:result", result);
expect(result).toBeDefined();
expect(result.type).toBe("image/png");
expect(result.size).toBeGreaterThan(0);
});
test("list objects", async () => {
const result = await adapter.listObjects();
console.log("listObjects:result", result);
});
});

View File

@@ -0,0 +1,46 @@
import { describe, expect, test } from "bun:test";
import { randomString } from "../../../src/core/utils";
import { StorageLocalAdapter } from "../../../src/media/storage/adapters/StorageLocalAdapter";
describe("StorageLocalAdapter", () => {
const adapter = new StorageLocalAdapter({
path: `${import.meta.dir}/local`
});
const file = Bun.file(`${import.meta.dir}/icon.png`);
const _filename = randomString(10);
const filename = `${_filename}.png`;
let objects = 0;
test("puts an object", async () => {
objects = (await adapter.listObjects()).length;
expect(await adapter.putObject(filename, await file.arrayBuffer())).toBeString();
});
test("lists objects", async () => {
expect((await adapter.listObjects()).length).toBe(objects + 1);
});
test("file exists", async () => {
expect(await adapter.objectExists(filename)).toBeTrue();
});
test("gets an object", async () => {
const res = await adapter.getObject(filename, new Headers());
expect(res.ok).toBeTrue();
// @todo: check the content
});
test("gets object meta", async () => {
expect(await adapter.getObjectMeta(filename)).toEqual({
type: file.type, // image/png
size: file.size
});
});
test("deletes an object", async () => {
expect(await adapter.deleteObject(filename)).toBeUndefined();
expect(await adapter.objectExists(filename)).toBeFalse();
});
});

View File

@@ -0,0 +1,96 @@
import { describe, expect, test } from "bun:test";
import { randomString } from "../../../src/core/utils";
import { StorageS3Adapter } from "../../../src/media";
import { config } from "dotenv";
const dotenvOutput = config({ path: `${import.meta.dir}/../../.env` });
const { R2_ACCESS_KEY, R2_SECRET_ACCESS_KEY, R2_URL, AWS_ACCESS_KEY, AWS_SECRET_KEY, AWS_S3_URL } =
dotenvOutput.parsed!;
// @todo: mock r2/s3 responses for faster tests
const ALL_TESTS = process.env.ALL_TESTS;
describe("Storage", async () => {
console.log("ALL_TESTS", process.env.ALL_TESTS);
const versions = [
[
"r2",
new StorageS3Adapter({
access_key: R2_ACCESS_KEY as string,
secret_access_key: R2_SECRET_ACCESS_KEY as string,
url: R2_URL as string
})
],
[
"s3",
new StorageS3Adapter({
access_key: AWS_ACCESS_KEY as string,
secret_access_key: AWS_SECRET_KEY as string,
url: AWS_S3_URL as string
})
]
] as const;
const _conf = {
adapters: ["r2", "s3"],
tests: [
"listObjects",
"putObject",
"objectExists",
"getObject",
"deleteObject",
"getObjectMeta"
]
};
const file = Bun.file(`${import.meta.dir}/icon.png`);
const filename = `${randomString(10)}.png`;
// single (dev)
//_conf = { adapters: [/*"r2",*/ "s3"], tests: [/*"putObject",*/ "listObjects"] };
function disabled(test: (typeof _conf.tests)[number]) {
return !_conf.tests.includes(test);
}
// @todo: add mocked fetch for faster tests
describe.each(versions)("StorageS3Adapter for %s", async (name, adapter) => {
if (!_conf.adapters.includes(name)) {
console.log("Skipping", name);
return;
}
let objects = 0;
test.skipIf(disabled("putObject"))("puts an object", async () => {
objects = (await adapter.listObjects()).length;
expect(await adapter.putObject(filename, file)).toBeString();
});
test.skipIf(disabled("listObjects"))("lists objects", async () => {
expect((await adapter.listObjects()).length).toBe(objects + 1);
});
test.skipIf(disabled("objectExists"))("file exists", async () => {
expect(await adapter.objectExists(filename)).toBeTrue();
});
test.skipIf(disabled("getObject"))("gets an object", async () => {
const res = await adapter.getObject(filename, new Headers());
expect(res.ok).toBeTrue();
// @todo: check the content
});
test.skipIf(disabled("getObjectMeta"))("gets object meta", async () => {
expect(await adapter.getObjectMeta(filename)).toEqual({
type: file.type, // image/png
size: file.size
});
});
test.skipIf(disabled("deleteObject"))("deletes an object", async () => {
expect(await adapter.deleteObject(filename)).toBeUndefined();
expect(await adapter.objectExists(filename)).toBeFalse();
});
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -0,0 +1,60 @@
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
import { AuthController } from "../../src/auth/api/AuthController";
import { AppAuth, type ModuleBuildContext } from "../../src/modules";
import { disableConsoleLog, enableConsoleLog } from "../helper";
import { makeCtx, moduleTestSuite } from "./module-test-suite";
describe("AppAuth", () => {
moduleTestSuite(AppAuth);
let ctx: ModuleBuildContext;
beforeEach(() => {
ctx = makeCtx();
});
test("secrets", async () => {
// auth must be enabled, otherwise default config is returned
const auth = new AppAuth({ enabled: true }, ctx);
await auth.build();
const config = auth.toJSON();
expect(config.jwt).toBeUndefined();
expect(config.strategies.password.config).toBeUndefined();
});
test("creates user on register", async () => {
const auth = new AppAuth(
{
enabled: true
},
ctx
);
await auth.build();
await ctx.em.schema().sync({ force: true });
// expect no users, but the query to pass
const res = await ctx.em.repository("users").findMany();
expect(res.data.length).toBe(0);
const app = new AuthController(auth).getController();
{
disableConsoleLog();
const res = await app.request("/password/register", {
method: "POST",
body: JSON.stringify({
email: "some@body.com",
password: "123456"
})
});
enableConsoleLog();
expect(res.status).toBe(200);
const { data: users } = await ctx.em.repository("users").findMany();
expect(users.length).toBe(1);
expect(users[0].email).toBe("some@body.com");
}
});
});

View File

@@ -0,0 +1,13 @@
import { describe, expect, test } from "bun:test";
import { parse } from "../../src/core/utils";
import { fieldsSchema } from "../../src/data/data-schema";
import { AppData } from "../../src/modules";
import { moduleTestSuite } from "./module-test-suite";
describe("AppData", () => {
moduleTestSuite(AppData);
test("field config construction", () => {
expect(parse(fieldsSchema, { type: "text" })).toBeDefined();
});
});

View File

@@ -0,0 +1,7 @@
import { describe } from "bun:test";
import { AppMedia } from "../../src/modules";
import { moduleTestSuite } from "./module-test-suite";
describe("AppMedia", () => {
moduleTestSuite(AppMedia);
});

View File

@@ -0,0 +1,43 @@
import { beforeEach, describe, expect, it } from "bun:test";
import { Hono } from "hono";
import { Guard } from "../../src/auth";
import { EventManager } from "../../src/core/events";
import { Default, stripMark } from "../../src/core/utils";
import { EntityManager } from "../../src/data";
import type { Module, ModuleBuildContext } from "../../src/modules/Module";
import { getDummyConnection } from "../helper";
export function makeCtx(overrides?: Partial<ModuleBuildContext>): ModuleBuildContext {
const { dummyConnection } = getDummyConnection();
return {
connection: dummyConnection,
server: new Hono(),
em: new EntityManager([], dummyConnection),
emgr: new EventManager(),
guard: new Guard(),
...overrides
};
}
export function moduleTestSuite(module: { new (): Module }) {
let ctx: ModuleBuildContext;
beforeEach(() => {
ctx = makeCtx();
});
describe("Module Tests", () => {
it("should build without exceptions", async () => {
const m = new module();
await m.setContext(ctx).build();
expect(m.toJSON()).toBeDefined();
});
it("uses the default config", async () => {
const m = new module();
await m.setContext(ctx).build();
expect(stripMark(m.toJSON())).toEqual(Default(m.getSchema(), {}));
});
});
}

12
app/bknd.config.js Normal file
View File

@@ -0,0 +1,12 @@
//import type { BkndConfig } from "./src";
export default {
app: {
connection: {
type: "libsql",
config: {
url: "http://localhost:8080"
}
}
}
};

42
app/build-cf.ts Normal file
View File

@@ -0,0 +1,42 @@
import process from "node:process";
import { $ } from "bun";
import * as esbuild from "esbuild";
import type { BuildOptions } from "esbuild";
const isDev = process.env.NODE_ENV !== "production";
const metafile = true;
const sourcemap = false;
const config: BuildOptions = {
entryPoints: ["worker.ts"],
bundle: true,
format: "esm",
external: ["__STATIC_CONTENT_MANIFEST", "@xyflow/react"],
platform: "browser",
conditions: ["worker", "browser"],
target: "es2022",
sourcemap,
metafile,
minify: !isDev,
loader: {
".html": "copy"
},
outfile: "dist/worker.js"
};
const dist = config.outfile!.split("/")[0];
if (!isDev) {
await $`rm -rf ${dist}`;
}
const result = await esbuild.build(config);
if (result.metafile) {
console.log("writing metafile to", `${dist}/meta.json`);
await Bun.write(`${dist}/meta.json`, JSON.stringify(result.metafile!));
}
if (!isDev) {
await $`gzip ${dist}/worker.js -c > ${dist}/worker.js.gz`;
}

2
app/bunfig.toml Normal file
View File

@@ -0,0 +1,2 @@
[install]
registry = "http://localhost:4873"

56
app/env.d.ts vendored Normal file
View File

@@ -0,0 +1,56 @@
/// <reference types="@cloudflare/workers-types" />
/// <reference types="vite/client" />
/// <reference lib="dom" />
import {} from "hono";
import type { App } from "./src/App";
import type { Env as AppEnv } from "./src/core/env";
declare module "__STATIC_CONTENT_MANIFEST" {
const manifest: string;
export default manifest;
}
type TURSO_DB = {
url: string;
authToken: string;
};
/*
// automatically add bindings everywhere (also when coming from controllers)
declare module "hono" {
interface Env {
// c.var types
Variables: {
app: App;
};
// c.env types
Bindings: AppEnv;
}
}*/
declare const __isDev: boolean;
declare global {
/*interface Request {
cf: IncomingRequestCfProperties;
}*/
type AppContext = {
app: App;
};
type HonoEnv = {
Variables: {
app: App;
};
Bindings: AppEnv;
};
type Prettify<T> = {
[K in keyof T]: T[K];
} & NonNullable<unknown>;
// prettify recursively
type PrettifyRec<T> = {
[K in keyof T]: T[K] extends object ? Prettify<T[K]> : T[K];
} & NonNullable<unknown>;
}

13
app/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en" class="light">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<title>BKND</title>
</head>
<body>
<div id="root"></div>
<div id="app"></div>
<script type="module" src="/src/ui/main.tsx"></script>
</body>
</html>

175
app/package.json Normal file
View File

@@ -0,0 +1,175 @@
{
"name": "bknd",
"type": "module",
"sideEffects": false,
"bin": "./dist/cli/index.js",
"version": "0.0.75",
"scripts": {
"build:all": "rm -rf dist && bun build:css && bun run build && bun build:vite && bun build:adapters && bun build:cli",
"dev": "vite",
"test": "ALL_TESTS=1 bun test --bail",
"build": "bun tsup && bun build:types",
"watch": "bun tsup --watch --onSuccess 'bun run build:types'",
"types": "bun tsc --noEmit",
"build:types": "tsc --emitDeclarationOnly",
"build:css": "bun tailwindcss -i ./src/ui/styles.css -o ./dist/styles.css",
"watch:css": "bun tailwindcss --watch -i ./src/ui/styles.css -o ./dist/styles.css",
"build:vite": "vite build",
"build:adapters": "bun tsup.adapters.ts --minify",
"watch:adapters": "bun tsup.adapters.ts --watch",
"updater": "bun x npm-check-updates -ui",
"build:cli": "bun build src/cli/index.ts --target node --outdir dist/cli --minify",
"cli": "LOCAL=1 bun src/cli/index.ts"
},
"dependencies": {
"@cfworker/json-schema": "^2.0.1",
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-liquid": "^6.2.1",
"@dagrejs/dagre": "^1.1.4",
"@hello-pangea/dnd": "^17.0.0",
"@hono/typebox-validator": "^0.2.4",
"@hono/zod-validator": "^0.2.2",
"@hookform/resolvers": "^3.9.1",
"@libsql/client": "^0.14.0",
"@libsql/kysely-libsql": "^0.4.1",
"@mantine/core": "^7.13.4",
"@mantine/hooks": "^7.13.4",
"@mantine/modals": "^7.13.4",
"@mantine/notifications": "^7.13.5",
"@radix-ui/react-scroll-area": "^1.2.0",
"@rjsf/core": "^5.22.2",
"@sinclair/typebox": "^0.32.34",
"@tabler/icons-react": "3.18.0",
"@tanstack/react-form": "0.19.2",
"@tanstack/react-query": "^5.59.16",
"@uiw/react-codemirror": "^4.23.6",
"@xyflow/react": "^12.3.2",
"aws4fetch": "^1.0.18",
"codemirror-lang-liquid": "^1.0.0",
"dayjs": "^1.11.13",
"fast-xml-parser": "^4.4.0",
"hono": "^4.4.12",
"jose": "^5.6.3",
"jotai": "^2.10.1",
"kysely": "^0.27.4",
"liquidjs": "^10.15.0",
"lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1",
"react-hook-form": "^7.53.1",
"react-icons": "5.2.1",
"react-json-view-lite": "^2.0.1",
"reactflow": "^11.11.4",
"tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7",
"wouter": "^3.3.5",
"zod": "^3.23.8",
"zod-to-json-schema": "^3.23.2"
},
"devDependencies": {
"@aws-sdk/client-s3": "^3.613.0",
"@hono/node-server": "^1.13.3",
"@hono/vite-dev-server": "^0.16.0",
"@tanstack/react-query-devtools": "^5.59.16",
"@types/diff": "^5.2.3",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"node-fetch": "^3.3.2",
"openapi-types": "^12.1.3",
"postcss": "^8.4.47",
"postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1",
"tailwindcss": "^3.4.14",
"tsup": "^8.3.5",
"vite": "^5.4.10",
"vite-plugin-static-copy": "^2.0.0",
"vite-tsconfig-paths": "^5.0.1"
},
"tsup": {
"entry": ["src/index.ts", "src/ui/index.ts", "src/data/index.ts", "src/core/index.ts", "src/core/utils/index.ts"],
"minify": true,
"outDir": "dist",
"external": ["bun:test"],
"sourcemap": true,
"metafile": true,
"platform": "browser",
"format": ["esm", "cjs"],
"splitting": false,
"loader": {
".svg": "dataurl"
}
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./ui": {
"types": "./dist/ui/index.d.ts",
"import": "./dist/ui/index.js",
"require": "./dist/ui/index.cjs"
},
"./data": {
"types": "./dist/data/index.d.ts",
"import": "./dist/data/index.js",
"require": "./dist/data/index.cjs"
},
"./core": {
"types": "./dist/core/index.d.ts",
"import": "./dist/core/index.js",
"require": "./dist/core/index.cjs"
},
"./utils": {
"types": "./dist/core/utils/index.d.ts",
"import": "./dist/core/utils/index.js",
"require": "./dist/core/utils/index.cjs"
},
"./cli": {
"types": "./dist/cli/index.d.ts",
"import": "./dist/cli/index.js",
"require": "./dist/cli/index.cjs"
},
"./adapter/cloudflare": {
"types": "./dist/adapter/cloudflare/index.d.ts",
"import": "./dist/adapter/cloudflare/index.js",
"require": "./dist/adapter/cloudflare/index.cjs"
},
"./adapter/vite": {
"types": "./dist/adapter/vite/index.d.ts",
"import": "./dist/adapter/vite/index.js",
"require": "./dist/adapter/vite/index.cjs"
},
"./adapter/nextjs": {
"types": "./dist/adapter/nextjs/index.d.ts",
"import": "./dist/adapter/nextjs/index.js",
"require": "./dist/adapter/nextjs/index.cjs"
},
"./adapter/remix": {
"types": "./dist/adapter/remix/index.d.ts",
"import": "./dist/adapter/remix/index.js",
"require": "./dist/adapter/remix/index.cjs"
},
"./adapter/bun": {
"types": "./dist/adapter/bun/index.d.ts",
"import": "./dist/adapter/bun/index.js",
"require": "./dist/adapter/bun/index.cjs"
},
"./dist/static/manifest.json": "./dist/static/.vite/manifest.json",
"./dist/styles.css": "./dist/styles.css",
"./dist/index.html": "./dist/static/index.html"
},
"files": [
"dist",
"!dist/*.tsbuildinfo",
"!dist/*.map",
"!dist/**/*.map",
"!dist/metafile*"
]
}

18
app/postcss.config.js Normal file
View File

@@ -0,0 +1,18 @@
export default {
plugins: {
"postcss-import": {},
"tailwindcss/nesting": {},
tailwindcss: {},
autoprefixer: {},
"postcss-preset-mantine": {},
"postcss-simple-vars": {
variables: {
"mantine-breakpoint-xs": "36em",
"mantine-breakpoint-sm": "48em",
"mantine-breakpoint-md": "62em",
"mantine-breakpoint-lg": "75em",
"mantine-breakpoint-xl": "88em"
}
}
}
};

95
app/src/Api.ts Normal file
View File

@@ -0,0 +1,95 @@
import { AuthApi } from "auth/api/AuthApi";
import { DataApi } from "data/api/DataApi";
import { decodeJwt } from "jose";
import { MediaApi } from "media/api/MediaApi";
import { SystemApi } from "modules/SystemApi";
export type ApiOptions = {
host: string;
token?: string;
tokenStorage?: "localStorage";
localStorage?: {
key?: string;
};
};
export class Api {
private token?: string;
private user?: object;
private verified = false;
public system!: SystemApi;
public data!: DataApi;
public auth!: AuthApi;
public media!: MediaApi;
constructor(private readonly options: ApiOptions) {
if (options.token) {
this.updateToken(options.token);
} else {
this.extractToken();
}
this.buildApis();
}
private extractToken() {
if (this.options.tokenStorage === "localStorage") {
const key = this.options.localStorage?.key ?? "auth";
const raw = localStorage.getItem(key);
if (raw) {
const { token } = JSON.parse(raw);
this.token = token;
this.user = decodeJwt(token) as any;
}
}
}
updateToken(token?: string, rebuild?: boolean) {
this.token = token;
this.user = token ? (decodeJwt(token) as any) : undefined;
if (this.options.tokenStorage === "localStorage") {
const key = this.options.localStorage?.key ?? "auth";
if (token) {
localStorage.setItem(key, JSON.stringify({ token }));
} else {
localStorage.removeItem(key);
}
}
if (rebuild) this.buildApis();
}
markAuthVerified(verfied: boolean) {
this.verified = verfied;
return this;
}
getAuthState() {
if (!this.token) return;
return {
token: this.token,
user: this.user,
verified: this.verified
};
}
private buildApis() {
const baseParams = {
host: this.options.host,
token: this.token
};
this.system = new SystemApi(baseParams);
this.data = new DataApi(baseParams);
this.auth = new AuthApi({
...baseParams,
onTokenUpdate: (token) => this.updateToken(token, true)
});
this.media = new MediaApi(baseParams);
}
}

142
app/src/App.ts Normal file
View File

@@ -0,0 +1,142 @@
import { Event } from "core/events";
import { Connection, type LibSqlCredentials, LibsqlConnection } from "data";
import {
type InitialModuleConfigs,
ModuleManager,
type ModuleManagerOptions,
type Modules
} from "modules/ModuleManager";
import * as SystemPermissions from "modules/permissions";
import { SystemController } from "modules/server/SystemController";
export type AppPlugin<DB> = (app: App<DB>) => void;
export class AppConfigUpdatedEvent extends Event<{ app: App }> {
static override slug = "app-config-updated";
}
export class AppBuiltEvent extends Event<{ app: App }> {
static override slug = "app-built";
}
export const AppEvents = { AppConfigUpdatedEvent, AppBuiltEvent } as const;
export type CreateAppConfig = {
connection:
| Connection
| {
type: "libsql";
config: LibSqlCredentials;
};
initialConfig?: InitialModuleConfigs;
plugins?: AppPlugin<any>[];
options?: ModuleManagerOptions;
};
export type AppConfig = InitialModuleConfigs;
export class App<DB = any> {
modules: ModuleManager;
static readonly Events = AppEvents;
constructor(
private connection: Connection,
_initialConfig?: InitialModuleConfigs,
private plugins: AppPlugin<DB>[] = [],
moduleManagerOptions?: ModuleManagerOptions
) {
this.modules = new ModuleManager(connection, {
...moduleManagerOptions,
initial: _initialConfig,
onUpdated: async (key, config) => {
//console.log("[APP] config updated", key, config);
await this.build({ sync: true, save: true });
await this.emgr.emit(new AppConfigUpdatedEvent({ app: this }));
}
});
this.modules.ctx().emgr.registerEvents(AppEvents);
}
static create(config: CreateAppConfig) {
let connection: Connection | undefined = undefined;
if (config.connection instanceof Connection) {
connection = config.connection;
} else if (typeof config.connection === "object") {
switch (config.connection.type) {
case "libsql":
connection = new LibsqlConnection(config.connection.config);
break;
}
}
if (!connection) {
throw new Error("Invalid connection");
}
return new App(connection, config.initialConfig, config.plugins, config.options);
}
get emgr() {
return this.modules.ctx().emgr;
}
async build(options?: { sync?: boolean; drop?: boolean; save?: boolean }) {
//console.log("building");
await this.modules.build();
if (options?.sync) {
const syncResult = await this.module.data.em
.schema()
.sync({ force: true, drop: options.drop });
//console.log("syncing", syncResult);
}
// load system controller
this.modules.ctx().guard.registerPermissions(Object.values(SystemPermissions));
this.modules.server.route("/api/system", new SystemController(this).getController());
// load plugins
if (this.plugins.length > 0) {
this.plugins.forEach((plugin) => plugin(this));
}
//console.log("emitting built", options);
await this.emgr.emit(new AppBuiltEvent({ app: this }));
// not found on any not registered api route
this.modules.server.all("/api/*", async (c) => c.notFound());
if (options?.save) {
await this.modules.save();
}
}
mutateConfig<Module extends keyof Modules>(module: Module) {
return this.modules.get(module).schema();
}
get fetch(): any {
return this.modules.server.fetch;
}
get module() {
return new Proxy(
{},
{
get: (_, module: keyof Modules) => {
return this.modules.get(module);
}
}
) as Modules;
}
getSchema() {
return this.modules.getSchema();
}
version() {
return this.modules.version();
}
toJSON(secrets?: boolean) {
return this.modules.toJSON(secrets);
}
}

View File

@@ -0,0 +1,33 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { App, type CreateAppConfig } from "bknd";
import { serveStatic } from "hono/bun";
let app: App;
export function serve(config: CreateAppConfig, distPath?: string) {
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
return async (req: Request) => {
if (!app) {
app = App.create(config);
app.emgr.on(
"app-built",
async () => {
app.modules.server.get(
"/assets/*",
serveStatic({
root
})
);
app.module?.server?.setAdminHtml(await readFile(root + "/index.html", "utf-8"));
},
"sync"
);
await app.build();
}
return app.modules.server.fetch(req);
};
}

View File

@@ -0,0 +1 @@
export * from "./bun.adapter";

View File

@@ -0,0 +1,267 @@
import { DurableObject } from "cloudflare:workers";
import { App, type CreateAppConfig } from "bknd";
import { Hono } from "hono";
import { serveStatic } from "hono/cloudflare-workers";
import type { BkndConfig, CfBkndModeCache } from "../index";
// @ts-ignore
//import manifest from "__STATIC_CONTENT_MANIFEST";
import _html from "../../static/index.html";
type Context = {
request: Request;
env: any;
ctx: ExecutionContext;
manifest: any;
html: string;
};
export function serve(_config: BkndConfig, manifest?: string, overrideHtml?: string) {
const html = overrideHtml ?? _html;
return {
async fetch(request: Request, env: any, ctx: ExecutionContext) {
const url = new URL(request.url);
if (manifest) {
const pathname = url.pathname.slice(1);
const assetManifest = JSON.parse(manifest);
if (pathname && pathname in assetManifest) {
const hono = new Hono();
hono.all("*", async (c, next) => {
const res = await serveStatic({
path: `./${pathname}`,
manifest,
onNotFound: (path) => console.log("not found", path)
})(c as any, next);
if (res instanceof Response) {
const ttl = pathname.startsWith("assets/")
? 60 * 60 * 24 * 365 // 1 year
: 60 * 5; // 5 minutes
res.headers.set("Cache-Control", `public, max-age=${ttl}`);
return res;
}
return c.notFound();
});
return hono.fetch(request, env);
}
}
const config = {
..._config,
setAdminHtml: _config.setAdminHtml ?? !!manifest
};
const context = { request, env, ctx, manifest, html };
const mode = config.cloudflare?.mode?.(env);
if (!mode) {
console.log("serving fresh...");
const app = await getFresh(config, context);
return app.fetch(request, env);
} else if ("cache" in mode) {
console.log("serving cached...");
const app = await getCached(config as any, context);
return app.fetch(request, env);
} else if ("durableObject" in mode) {
console.log("serving durable...");
if (config.onBuilt) {
console.log("onBuilt() is not supported with DurableObject mode");
}
const start = performance.now();
const durable = mode.durableObject;
const id = durable.idFromName(mode.key);
const stub = durable.get(id) as unknown as DurableBkndApp;
const create_config = typeof config.app === "function" ? config.app(env) : config.app;
const res = await stub.fire(request, {
config: create_config,
html,
keepAliveSeconds: mode.keepAliveSeconds,
setAdminHtml: config.setAdminHtml
});
const headers = new Headers(res.headers);
headers.set("X-TTDO", String(performance.now() - start));
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers
});
}
}
};
}
async function getFresh(config: BkndConfig, { env, html }: Context) {
const create_config = typeof config.app === "function" ? config.app(env) : config.app;
const app = App.create(create_config);
if (config.onBuilt) {
app.emgr.onEvent(
App.Events.AppBuiltEvent,
async ({ params: { app } }) => {
config.onBuilt!(app);
},
"sync"
);
}
await app.build();
if (config?.setAdminHtml !== false) {
app.module.server.setAdminHtml(html);
}
return app;
}
async function getCached(
config: BkndConfig & { cloudflare: { mode: CfBkndModeCache } },
{ env, html, ctx }: Context
) {
const { cache, key } = config.cloudflare.mode(env) as ReturnType<CfBkndModeCache>;
const create_config = typeof config.app === "function" ? config.app(env) : config.app;
const cachedConfig = await cache.get(key);
const initialConfig = cachedConfig ? JSON.parse(cachedConfig) : undefined;
const app = App.create({ ...create_config, initialConfig });
async function saveConfig(__config: any) {
ctx.waitUntil(cache.put(key, JSON.stringify(__config)));
}
if (config.onBuilt) {
app.emgr.onEvent(
App.Events.AppBuiltEvent,
async ({ params: { app } }) => {
app.module.server.client.get("/__bknd/cache", async (c) => {
await cache.delete(key);
return c.json({ message: "Cache cleared" });
});
config.onBuilt!(app);
},
"sync"
);
}
app.emgr.onEvent(
App.Events.AppConfigUpdatedEvent,
async ({ params: { app } }) => {
saveConfig(app.toJSON(true));
},
"sync"
);
await app.build();
if (!cachedConfig) {
saveConfig(app.toJSON(true));
}
//addAssetsRoute(app, manifest);
if (config?.setAdminHtml !== false) {
app.module.server.setAdminHtml(html);
}
return app;
}
export class DurableBkndApp extends DurableObject {
protected id = Math.random().toString(36).slice(2);
protected app?: App;
protected interval?: any;
async fire(
request: Request,
options: {
config: CreateAppConfig;
html: string;
keepAliveSeconds?: number;
setAdminHtml?: boolean;
}
) {
let buildtime = 0;
if (!this.app) {
const start = performance.now();
const config = options.config;
// change protocol to websocket if libsql
if ("type" in config.connection && config.connection.type === "libsql") {
config.connection.config.protocol = "wss";
}
this.app = App.create(config);
this.app.emgr.onEvent(
App.Events.AppBuiltEvent,
async ({ params: { app } }) => {
app.modules.server.get("/__do", async (c) => {
// @ts-ignore
const context: any = c.req.raw.cf ? c.req.raw.cf : c.env.cf;
return c.json({
id: this.id,
keepAlive: options?.keepAliveSeconds,
colo: context.colo
});
});
if (options?.setAdminHtml !== false) {
app.module.server.setAdminHtml(options.html);
}
},
"sync"
);
await this.app.build();
buildtime = performance.now() - start;
}
if (options?.keepAliveSeconds) {
this.keepAlive(options.keepAliveSeconds);
}
console.log("id", this.id);
const res = await this.app!.fetch(request);
const headers = new Headers(res.headers);
headers.set("X-BuildTime", buildtime.toString());
headers.set("X-DO-ID", this.id);
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers
});
}
protected keepAlive(seconds: number) {
console.log("keep alive for", seconds);
if (this.interval) {
console.log("clearing, there is a new");
clearInterval(this.interval);
}
let i = 0;
this.interval = setInterval(() => {
i += 1;
//console.log("keep-alive", i);
if (i === seconds) {
console.log("cleared");
clearInterval(this.interval);
// ping every 30 seconds
} else if (i % 30 === 0) {
console.log("ping");
this.app?.modules.ctx().connection.ping();
}
}, 1000);
}
}

View File

@@ -0,0 +1 @@
export * from "./cloudflare-workers.adapter";

36
app/src/adapter/index.ts Normal file
View File

@@ -0,0 +1,36 @@
import type { App, CreateAppConfig } from "bknd";
export type CfBkndModeCache<Env = any> = (env: Env) => {
cache: KVNamespace;
key: string;
};
export type CfBkndModeDurableObject<Env = any> = (env: Env) => {
durableObject: DurableObjectNamespace;
key: string;
keepAliveSeconds?: number;
};
export type CloudflareBkndConfig<Env = any> = {
mode?: CfBkndModeCache | CfBkndModeDurableObject;
forceHttps?: boolean;
};
export type BkndConfig<Env = any> = {
app: CreateAppConfig | ((env: Env) => CreateAppConfig);
setAdminHtml?: boolean;
server?: {
port?: number;
platform?: "node" | "bun";
};
cloudflare?: CloudflareBkndConfig<Env>;
onBuilt?: (app: App) => Promise<void>;
};
export type BkndConfigJson = {
app: CreateAppConfig;
setAdminHtml?: boolean;
server?: {
port?: number;
};
};

View File

@@ -0,0 +1 @@
export * from "./nextjs.adapter";

View File

@@ -0,0 +1,25 @@
import { App, type CreateAppConfig } from "bknd";
import { isDebug } from "bknd/core";
function getCleanRequest(req: Request) {
// clean search params from "route" attribute
const url = new URL(req.url);
url.searchParams.delete("route");
return new Request(url.toString(), {
method: req.method,
headers: req.headers,
body: req.body
});
}
let app: App;
export function serve(config: CreateAppConfig) {
return async (req: Request) => {
if (!app || isDebug()) {
app = App.create(config);
await app.build();
}
const request = getCleanRequest(req);
return app.fetch(request, process.env);
};
}

View File

@@ -0,0 +1 @@
export * from "./remix.adapter";

View File

@@ -0,0 +1,12 @@
import { App, type CreateAppConfig } from "../../App";
let app: App;
export function serve(config: CreateAppConfig) {
return async (args: { request: Request }) => {
if (!app) {
app = App.create(config);
await app.build();
}
return app.fetch(args.request);
};
}

View File

@@ -0,0 +1 @@
export * from "./vite.adapter";

View File

@@ -0,0 +1,82 @@
import { readFile } from "node:fs/promises";
import { serveStatic } from "@hono/node-server/serve-static";
import { App } from "../../App";
import type { BkndConfig } from "../index";
async function getHtml() {
return readFile("index.html", "utf8");
}
function addViteScripts(html: string) {
return html.replace(
"<head>",
`<script type="module">
import RefreshRuntime from "/@react-refresh"
RefreshRuntime.injectIntoGlobalHook(window)
window.$RefreshReg$ = () => {}
window.$RefreshSig$ = () => (type) => type
window.__vite_plugin_react_preamble_installed__ = true
</script>
<script type="module" src="/@vite/client"></script>
`
);
}
function createApp(config: BkndConfig, env: any) {
const create_config = typeof config.app === "function" ? config.app(env) : config.app;
return App.create(create_config);
}
function setAppBuildListener(app: App, config: BkndConfig, html: string) {
app.emgr.on(
"app-built",
async () => {
await config.onBuilt?.(app);
app.module.server.setAdminHtml(html);
app.module.server.client.get("/assets/!*", serveStatic({ root: "./" }));
},
"sync"
);
}
export async function serveFresh(config: BkndConfig, _html?: string) {
let html = _html;
if (!html) {
html = await getHtml();
}
html = addViteScripts(html);
return {
async fetch(request: Request, env: any, ctx: ExecutionContext) {
const app = createApp(config, env);
setAppBuildListener(app, config, html);
await app.build();
//console.log("routes", app.module.server.client.routes);
return app.fetch(request, env, ctx);
}
};
}
let app: App;
export async function serveCached(config: BkndConfig, _html?: string) {
let html = _html;
if (!html) {
html = await getHtml();
}
html = addViteScripts(html);
return {
async fetch(request: Request, env: any, ctx: ExecutionContext) {
if (!app) {
app = createApp(config, env);
setAppBuildListener(app, config, html);
await app.build();
}
return app.fetch(request, env, ctx);
}
};
}

269
app/src/auth/AppAuth.ts Normal file
View File

@@ -0,0 +1,269 @@
import { type AuthAction, Authenticator, type ProfileExchange, Role, type Strategy } from "auth";
import { Exception } from "core";
import { Const, StringRecord, Type, transformObject } from "core/utils";
import {
type Entity,
EntityIndex,
type EntityManager,
EnumField,
type Field,
type Mutator
} from "data";
import { type FieldSchema, entity, enumm, make, text } from "data/prototype";
import { cloneDeep, mergeWith, omit, pick } from "lodash-es";
import { Module } from "modules/Module";
import { AuthController } from "./api/AuthController";
import { type AppAuthSchema, STRATEGIES, authConfigSchema } from "./auth-schema";
export type UserFieldSchema = FieldSchema<typeof AppAuth.usersFields>;
declare global {
interface DB {
users: UserFieldSchema;
}
}
export class AppAuth extends Module<typeof authConfigSchema> {
private _authenticator?: Authenticator;
cache: Record<string, any> = {};
override async build() {
if (!this.config.enabled) {
this.setBuilt();
return;
}
// register roles
const roles = transformObject(this.config.roles ?? {}, (role, name) => {
//console.log("role", role, name);
return Role.create({ name, ...role });
});
this.ctx.guard.setRoles(Object.values(roles));
this.ctx.guard.setConfig(this.config.guard ?? {});
// build strategies
const strategies = transformObject(this.config.strategies ?? {}, (strategy, name) => {
try {
return new STRATEGIES[strategy.type].cls(strategy.config as any);
} catch (e) {
throw new Error(
`Could not build strategy ${String(name)} with config ${JSON.stringify(strategy.config)}`
);
}
});
const { fields, ...jwt } = this.config.jwt;
this._authenticator = new Authenticator(strategies, this.resolveUser.bind(this), {
jwt
});
this.registerEntities();
super.setBuilt();
const controller = new AuthController(this);
//this.ctx.server.use(controller.getMiddleware);
this.ctx.server.route(this.config.basepath, controller.getController());
}
getMiddleware() {
if (!this.config.enabled) {
return;
}
return new AuthController(this).getMiddleware;
}
getSchema() {
return authConfigSchema;
}
get authenticator(): Authenticator {
this.throwIfNotBuilt();
return this._authenticator!;
}
get em(): EntityManager<DB> {
return this.ctx.em as any;
}
private async resolveUser(
action: AuthAction,
strategy: Strategy,
identifier: string,
profile: ProfileExchange
): Promise<any> {
console.log("***** AppAuth:resolveUser", {
action,
strategy: strategy.getName(),
identifier,
profile
});
const fields = this.getUsersEntity()
.getFillableFields("create")
.map((f) => f.name);
const filteredProfile = Object.fromEntries(
Object.entries(profile).filter(([key]) => fields.includes(key))
);
switch (action) {
case "login":
return this.login(strategy, identifier, filteredProfile);
case "register":
return this.register(strategy, identifier, filteredProfile);
}
}
private filterUserData(user: any) {
console.log(
"--filterUserData",
user,
this.config.jwt.fields,
pick(user, this.config.jwt.fields)
);
return pick(user, this.config.jwt.fields);
}
private async login(strategy: Strategy, identifier: string, profile: ProfileExchange) {
console.log("--- trying to login", { strategy: strategy.getName(), identifier, profile });
if (!("email" in profile)) {
throw new Exception("Profile must have email");
}
if (typeof identifier !== "string" || identifier.length === 0) {
throw new Exception("Identifier must be a string");
}
const users = this.getUsersEntity();
this.toggleStrategyValueVisibility(true);
const result = await this.em.repo(users).findOne({ email: profile.email! });
this.toggleStrategyValueVisibility(false);
if (!result.data) {
throw new Exception("User not found", 404);
}
console.log("---login data", result.data, result);
// compare strategy and identifier
console.log("strategy comparison", result.data.strategy, strategy.getName());
if (result.data.strategy !== strategy.getName()) {
console.log("!!! User registered with different strategy");
throw new Exception("User registered with different strategy");
}
console.log("identifier comparison", result.data.strategy_value, identifier);
if (result.data.strategy_value !== identifier) {
console.log("!!! Invalid credentials");
throw new Exception("Invalid credentials");
}
return this.filterUserData(result.data);
}
private async register(strategy: Strategy, identifier: string, profile: ProfileExchange) {
if (!("email" in profile)) {
throw new Exception("Profile must have an email");
}
if (typeof identifier !== "string" || identifier.length === 0) {
throw new Exception("Identifier must be a string");
}
const users = this.getUsersEntity();
const { data } = await this.em.repo(users).findOne({ email: profile.email! });
if (data) {
throw new Exception("User already exists");
}
const payload = {
...profile,
strategy: strategy.getName(),
strategy_value: identifier
};
const mutator = this.em.mutator(users);
mutator.__unstable_toggleSystemEntityCreation(false);
this.toggleStrategyValueVisibility(true);
const createResult = await mutator.insertOne(payload);
mutator.__unstable_toggleSystemEntityCreation(true);
this.toggleStrategyValueVisibility(false);
if (!createResult.data) {
throw new Error("Could not create user");
}
return this.filterUserData(createResult.data);
}
private toggleStrategyValueVisibility(visible: boolean) {
const field = this.getUsersEntity().field("strategy_value")!;
field.config.hidden = !visible;
field.config.fillable = visible;
// @todo: think about a PasswordField that automatically hashes on save?
}
getUsersEntity(forceCreate?: boolean): Entity<"users", typeof AppAuth.usersFields> {
const entity_name = this.config.entity_name;
if (forceCreate || !this.em.hasEntity(entity_name)) {
return entity(entity_name as "users", AppAuth.usersFields, undefined, "system");
}
return this.em.entity(entity_name) as any;
}
static usersFields = {
email: text().required(),
strategy: text({ fillable: ["create"], hidden: ["form"] }).required(),
strategy_value: text({
fillable: ["create"],
hidden: ["read", "table", "update", "form"]
}).required(),
role: text()
};
registerEntities() {
const users = this.getUsersEntity();
if (!this.em.hasEntity(users.name)) {
this.em.addEntity(users);
} else {
// if exists, check all fields required are there
// @todo: add to context: "needs sync" flag
const _entity = this.getUsersEntity(true);
for (const field of _entity.fields) {
const _field = users.field(field.name);
if (!_field) {
users.addField(field);
}
}
}
const indices = [
new EntityIndex(users, [users.field("email")!], true),
new EntityIndex(users, [users.field("strategy")!]),
new EntityIndex(users, [users.field("strategy_value")!])
];
indices.forEach((index) => {
if (!this.em.hasIndex(index)) {
this.em.addIndex(index);
}
});
try {
const roles = Object.keys(this.config.roles ?? {});
const field = make("role", enumm({ enum: roles }));
this.em.entity(users.name).__experimental_replaceField("role", field);
} catch (e) {}
try {
const strategies = Object.keys(this.config.strategies ?? {});
const field = make("strategy", enumm({ enum: strategies }));
this.em.entity(users.name).__experimental_replaceField("strategy", field);
} catch (e) {}
}
override toJSON(secrets?: boolean): AppAuthSchema {
if (!this.config.enabled) {
return this.configDefault;
}
// fixes freezed config object
return mergeWith({ ...this.config }, this.authenticator.toJSON(secrets));
}
}

View File

@@ -0,0 +1,41 @@
import type { AppAuthSchema, AppAuthStrategies } from "auth/auth-schema";
import type { AuthResponse, SafeUser, Strategy } from "auth/authenticate/Authenticator";
import { type BaseModuleApiOptions, ModuleApi } from "modules/ModuleApi";
export type AuthApiOptions = BaseModuleApiOptions & {
onTokenUpdate?: (token: string) => void | Promise<void>;
};
export class AuthApi extends ModuleApi<AuthApiOptions> {
protected override getDefaultOptions(): Partial<AuthApiOptions> {
return {
basepath: "/api/auth"
};
}
async loginWithPassword(input: any) {
const res = await this.post<AuthResponse>(["password", "login"], input);
if (res.res.ok && res.body.token) {
await this.options.onTokenUpdate?.(res.body.token);
}
return res;
}
async registerWithPassword(input: any) {
const res = await this.post<AuthResponse>(["password", "register"], input);
if (res.res.ok && res.body.token) {
await this.options.onTokenUpdate?.(res.body.token);
}
return res;
}
async me() {
return this.get<{ user: SafeUser | null }>(["me"]);
}
async strategies() {
return this.get<{ strategies: AppAuthSchema["strategies"] }>(["strategies"]);
}
async logout() {}
}

View File

@@ -0,0 +1,57 @@
import type { AppAuth } from "auth";
import type { ClassController } from "core";
import { Hono, type MiddlewareHandler } from "hono";
export class AuthController implements ClassController {
constructor(private auth: AppAuth) {}
getMiddleware: MiddlewareHandler = async (c, next) => {
// @todo: consider adding app name to the payload, because user is not refetched
//try {
if (c.req.raw.headers.has("Authorization")) {
const bearerHeader = String(c.req.header("Authorization"));
const token = bearerHeader.replace("Bearer ", "");
const verified = await this.auth.authenticator.verify(token);
// @todo: don't extract user from token, but from the database or cache
this.auth.ctx.guard.setUserContext(this.auth.authenticator.getUser());
/*console.log("jwt verified?", {
verified,
auth: this.auth.authenticator.isUserLoggedIn()
});*/
} else {
this.auth.authenticator.__setUserNull();
}
/* } catch (e) {
this.auth.authenticator.__setUserNull();
}*/
await next();
};
getController(): Hono<any> {
const hono = new Hono();
const strategies = this.auth.authenticator.getStrategies();
//console.log("strategies", strategies);
for (const [name, strategy] of Object.entries(strategies)) {
//console.log("registering", name, "at", `/${name}`);
hono.route(`/${name}`, strategy.getController(this.auth.authenticator));
}
hono.get("/me", async (c) => {
if (this.auth.authenticator.isUserLoggedIn()) {
return c.json({ user: await this.auth.authenticator.getUser() });
}
return c.json({ user: null }, 403);
});
hono.get("/strategies", async (c) => {
return c.json({ strategies: this.auth.toJSON(false).strategies });
});
return hono;
}
}

View File

@@ -0,0 +1,85 @@
import { jwtConfig } from "auth/authenticate/Authenticator";
import { CustomOAuthStrategy, OAuthStrategy, PasswordStrategy } from "auth/authenticate/strategies";
import { type Static, StringRecord, Type, objectTransform } from "core/utils";
export const Strategies = {
password: {
cls: PasswordStrategy,
schema: PasswordStrategy.prototype.getSchema()
},
oauth: {
cls: OAuthStrategy,
schema: OAuthStrategy.prototype.getSchema()
},
custom_oauth: {
cls: CustomOAuthStrategy,
schema: CustomOAuthStrategy.prototype.getSchema()
}
} as const;
export const STRATEGIES = Strategies;
const strategiesSchemaObject = objectTransform(STRATEGIES, (strategy, name) => {
return Type.Object(
{
type: Type.Const(name, { default: name, readOnly: true }),
config: strategy.schema
},
{
title: name,
additionalProperties: false
}
);
});
const strategiesSchema = Type.Union(Object.values(strategiesSchemaObject));
export type AppAuthStrategies = Static<typeof strategiesSchema>;
export type AppAuthOAuthStrategy = Static<typeof STRATEGIES.oauth.schema>;
const guardConfigSchema = Type.Object({
enabled: Type.Optional(Type.Boolean({ default: false }))
});
export const guardRoleSchema = Type.Object(
{
permissions: Type.Optional(Type.Array(Type.String())),
is_default: Type.Optional(Type.Boolean()),
implicit_allow: Type.Optional(Type.Boolean())
},
{ additionalProperties: false }
);
export const authConfigSchema = Type.Object(
{
enabled: Type.Boolean({ default: false }),
basepath: Type.String({ default: "/api/auth" }),
entity_name: Type.String({ default: "users" }),
jwt: Type.Composite(
[
jwtConfig,
Type.Object({
fields: Type.Array(Type.String(), { default: ["id", "email", "role"] })
})
],
{ default: {}, additionalProperties: false }
),
strategies: Type.Optional(
StringRecord(strategiesSchema, {
title: "Strategies",
default: {
password: {
type: "password",
config: {
hashing: "sha256"
}
}
}
})
),
guard: Type.Optional(guardConfigSchema),
roles: Type.Optional(StringRecord(guardRoleSchema, { default: {} }))
},
{
title: "Authentication",
additionalProperties: false
}
);
export type AppAuthSchema = Static<typeof authConfigSchema>;

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