mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-15 20:17:22 +00:00
public commit
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal 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
110
LICENSE.md
Normal 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
58
README.md
Normal 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
3
app/.ncurc.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"reject": ["react-icons", "@tabler/icons-react", "@tanstack/react-form"]
|
||||||
|
}
|
||||||
15
app/__test__/App.spec.ts
Normal file
15
app/__test__/App.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
38
app/__test__/Module.spec.ts
Normal file
38
app/__test__/Module.spec.ts
Normal 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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
197
app/__test__/ModuleManager.spec.ts
Normal file
197
app/__test__/ModuleManager.spec.ts
Normal 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({});
|
||||||
|
});*/
|
||||||
|
});
|
||||||
15
app/__test__/api/api.spec.ts
Normal file
15
app/__test__/api/api.spec.ts
Normal 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);*/
|
||||||
|
});
|
||||||
|
});
|
||||||
41
app/__test__/auth/Authenticator.spec.ts
Normal file
41
app/__test__/auth/Authenticator.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});*/
|
||||||
89
app/__test__/auth/authorize/authorize.spec.ts
Normal file
89
app/__test__/auth/authorize/authorize.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
46
app/__test__/auth/strategies/OAuthStrategy.spec.ts
Normal file
46
app/__test__/auth/strategies/OAuthStrategy.spec.ts
Normal 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));
|
||||||
|
});
|
||||||
|
});
|
||||||
54
app/__test__/core/Endpoint.spec.ts
Normal file
54
app/__test__/core/Endpoint.spec.ts
Normal 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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
46
app/__test__/core/EventManager.spec.ts
Normal file
46
app/__test__/core/EventManager.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
56
app/__test__/core/Registry.spec.ts
Normal file
56
app/__test__/core/Registry.spec.ts
Normal 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());
|
||||||
|
});
|
||||||
|
});
|
||||||
31
app/__test__/core/benchmarks/crypto.bm.ts
Normal file
31
app/__test__/core/benchmarks/crypto.bm.ts
Normal 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();
|
||||||
57
app/__test__/core/cache/CloudflareKvCache.native-spec.ts
vendored
Normal file
57
app/__test__/core/cache/CloudflareKvCache.native-spec.ts
vendored
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
15
app/__test__/core/cache/MemoryCache.spec.ts
vendored
Normal file
15
app/__test__/core/cache/MemoryCache.spec.ts
vendored
Normal 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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
84
app/__test__/core/cache/cache-test-suite.ts
vendored
Normal file
84
app/__test__/core/cache/cache-test-suite.ts
vendored
Normal 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);
|
||||||
|
});*/
|
||||||
|
}
|
||||||
14
app/__test__/core/crypto.spec.ts
Normal file
14
app/__test__/core/crypto.spec.ts
Normal 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"));
|
||||||
|
});
|
||||||
|
});
|
||||||
18
app/__test__/core/helper.ts
Normal file
18
app/__test__/core/helper.ts
Normal 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;
|
||||||
|
}
|
||||||
332
app/__test__/core/object/SchemaObject.spec.ts
Normal file
332
app/__test__/core/object/SchemaObject.spec.ts
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
83
app/__test__/core/object/object-query.spec.ts
Normal file
83
app/__test__/core/object/object-query.spec.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
111
app/__test__/core/utils.spec.ts
Normal file
111
app/__test__/core/utils.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
235
app/__test__/data/DataController.spec.ts
Normal file
235
app/__test__/data/DataController.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
92
app/__test__/data/data-query-impl.spec.ts
Normal file
92
app/__test__/data/data-query-impl.spec.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
113
app/__test__/data/data.test.ts
Normal file
113
app/__test__/data/data.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
35
app/__test__/data/helper.ts
Normal file
35
app/__test__/data/helper.ts
Normal 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" };
|
||||||
|
}
|
||||||
50
app/__test__/data/mutation.relation.test.ts
Normal file
50
app/__test__/data/mutation.relation.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
145
app/__test__/data/mutation.simple.test.ts
Normal file
145
app/__test__/data/mutation.simple.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
96
app/__test__/data/polymorphic.test.ts
Normal file
96
app/__test__/data/polymorphic.test.ts
Normal 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"
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
267
app/__test__/data/prototype.test.ts
Normal file
267
app/__test__/data/prototype.test.ts
Normal 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;
|
||||||
|
});
|
||||||
|
});
|
||||||
368
app/__test__/data/relations.test.ts
Normal file
368
app/__test__/data/relations.test.ts
Normal 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("");*/
|
||||||
|
});
|
||||||
|
});
|
||||||
60
app/__test__/data/specs/Entity.spec.ts
Normal file
60
app/__test__/data/specs/Entity.spec.ts
Normal 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);
|
||||||
|
});*/
|
||||||
|
});
|
||||||
106
app/__test__/data/specs/EntityManager.spec.ts
Normal file
106
app/__test__/data/specs/EntityManager.spec.ts
Normal 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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
43
app/__test__/data/specs/JoinBuilder.spec.ts
Normal file
43
app/__test__/data/specs/JoinBuilder.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
302
app/__test__/data/specs/Mutator.spec.ts
Normal file
302
app/__test__/data/specs/Mutator.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
222
app/__test__/data/specs/Repository.spec.ts
Normal file
222
app/__test__/data/specs/Repository.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
269
app/__test__/data/specs/SchemaManager.spec.ts
Normal file
269
app/__test__/data/specs/SchemaManager.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
195
app/__test__/data/specs/WithBuilder.spec.ts
Normal file
195
app/__test__/data/specs/WithBuilder.spec.ts
Normal 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);
|
||||||
|
});*/
|
||||||
|
});
|
||||||
92
app/__test__/data/specs/connection/Connection.spec.ts
Normal file
92
app/__test__/data/specs/connection/Connection.spec.ts
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
29
app/__test__/data/specs/fields/BooleanField.spec.ts
Normal file
29
app/__test__/data/specs/fields/BooleanField.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
13
app/__test__/data/specs/fields/DateField.spec.ts
Normal file
13
app/__test__/data/specs/fields/DateField.spec.ts
Normal 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"));
|
||||||
|
});
|
||||||
|
});
|
||||||
44
app/__test__/data/specs/fields/EnumField.spec.ts
Normal file
44
app/__test__/data/specs/fields/EnumField.spec.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
45
app/__test__/data/specs/fields/Field.spec.ts
Normal file
45
app/__test__/data/specs/fields/Field.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
38
app/__test__/data/specs/fields/FieldIndex.spec.ts
Normal file
38
app/__test__/data/specs/fields/FieldIndex.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
47
app/__test__/data/specs/fields/JsonField.spec.ts
Normal file
47
app/__test__/data/specs/fields/JsonField.spec.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
9
app/__test__/data/specs/fields/JsonSchemaField.spec.ts
Normal file
9
app/__test__/data/specs/fields/JsonSchemaField.spec.ts
Normal 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
|
||||||
|
});
|
||||||
19
app/__test__/data/specs/fields/NumberField.spec.ts
Normal file
19
app/__test__/data/specs/fields/NumberField.spec.ts
Normal 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" });
|
||||||
|
});
|
||||||
37
app/__test__/data/specs/fields/PrimaryField.spec.ts
Normal file
37
app/__test__/data/specs/fields/PrimaryField.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
15
app/__test__/data/specs/fields/TextField.spec.ts
Normal file
15
app/__test__/data/specs/fields/TextField.spec.ts
Normal 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" });
|
||||||
|
});
|
||||||
162
app/__test__/data/specs/fields/inc.ts
Normal file
162
app/__test__/data/specs/fields/inc.ts
Normal 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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
78
app/__test__/data/specs/relations/EntityRelation.spec.ts
Normal file
78
app/__test__/data/specs/relations/EntityRelation.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
114
app/__test__/flows/FetchTask.spec.ts
Normal file
114
app/__test__/flows/FetchTask.spec.ts
Normal 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 }));
|
||||||
|
});
|
||||||
|
});
|
||||||
91
app/__test__/flows/SubWorkflowTask.spec.ts
Normal file
91
app/__test__/flows/SubWorkflowTask.spec.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
112
app/__test__/flows/Task.spec.ts
Normal file
112
app/__test__/flows/Task.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
24
app/__test__/flows/inc/back.ts
Normal file
24
app/__test__/flows/inc/back.ts
Normal 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 };
|
||||||
23
app/__test__/flows/inc/fanout-condition.ts
Normal file
23
app/__test__/flows/inc/fanout-condition.ts
Normal 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 };
|
||||||
61
app/__test__/flows/inc/helper.tsx
Normal file
61
app/__test__/flows/inc/helper.tsx
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
15
app/__test__/flows/inc/parallel.ts
Normal file
15
app/__test__/flows/inc/parallel.ts
Normal 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 };
|
||||||
18
app/__test__/flows/inc/simple-fetch.ts
Normal file
18
app/__test__/flows/inc/simple-fetch.ts
Normal 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 };
|
||||||
175
app/__test__/flows/inputs.test.ts
Normal file
175
app/__test__/flows/inputs.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
186
app/__test__/flows/render.tsx
Normal file
186
app/__test__/flows/render.tsx
Normal 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]} />);
|
||||||
175
app/__test__/flows/trigger.test.ts
Normal file
175
app/__test__/flows/trigger.test.ts
Normal 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);
|
||||||
|
});*/
|
||||||
|
});
|
||||||
449
app/__test__/flows/workflow-basic.test.ts
Normal file
449
app/__test__/flows/workflow-basic.test.ts
Normal 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
53
app/__test__/helper.ts
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
56
app/__test__/media/MediaController.spec.ts
Normal file
56
app/__test__/media/MediaController.spec.ts
Normal 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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
81
app/__test__/media/Storage.spec.ts
Normal file
81
app/__test__/media/Storage.spec.ts
Normal 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
|
||||||
|
});
|
||||||
34
app/__test__/media/StorageR2Adapter.native-spec.ts
Normal file
34
app/__test__/media/StorageR2Adapter.native-spec.ts
Normal 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();
|
||||||
|
});
|
||||||
61
app/__test__/media/adapters/StorageCloudinaryAdapter.spec.ts
Normal file
61
app/__test__/media/adapters/StorageCloudinaryAdapter.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
46
app/__test__/media/adapters/StorageLocalAdapter.spec.ts
Normal file
46
app/__test__/media/adapters/StorageLocalAdapter.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
96
app/__test__/media/adapters/StorageS3Adapter.spec.ts
Normal file
96
app/__test__/media/adapters/StorageS3Adapter.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
BIN
app/__test__/media/adapters/icon.png
Normal file
BIN
app/__test__/media/adapters/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
60
app/__test__/modules/AppAuth.spec.ts
Normal file
60
app/__test__/modules/AppAuth.spec.ts
Normal 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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
13
app/__test__/modules/AppData.spec.ts
Normal file
13
app/__test__/modules/AppData.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
7
app/__test__/modules/AppMedia.spec.ts
Normal file
7
app/__test__/modules/AppMedia.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
43
app/__test__/modules/module-test-suite.ts
Normal file
43
app/__test__/modules/module-test-suite.ts
Normal 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
12
app/bknd.config.js
Normal 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
42
app/build-cf.ts
Normal 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
2
app/bunfig.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[install]
|
||||||
|
registry = "http://localhost:4873"
|
||||||
56
app/env.d.ts
vendored
Normal file
56
app/env.d.ts
vendored
Normal 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
13
app/index.html
Normal 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
175
app/package.json
Normal 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
18
app/postcss.config.js
Normal 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
95
app/src/Api.ts
Normal 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
142
app/src/App.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/src/adapter/bun/bun.adapter.ts
Normal file
33
app/src/adapter/bun/bun.adapter.ts
Normal 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);
|
||||||
|
};
|
||||||
|
}
|
||||||
1
app/src/adapter/bun/index.ts
Normal file
1
app/src/adapter/bun/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./bun.adapter";
|
||||||
267
app/src/adapter/cloudflare/cloudflare-workers.adapter.ts
Normal file
267
app/src/adapter/cloudflare/cloudflare-workers.adapter.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
app/src/adapter/cloudflare/index.ts
Normal file
1
app/src/adapter/cloudflare/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./cloudflare-workers.adapter";
|
||||||
36
app/src/adapter/index.ts
Normal file
36
app/src/adapter/index.ts
Normal 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;
|
||||||
|
};
|
||||||
|
};
|
||||||
1
app/src/adapter/nextjs/index.ts
Normal file
1
app/src/adapter/nextjs/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./nextjs.adapter";
|
||||||
25
app/src/adapter/nextjs/nextjs.adapter.ts
Normal file
25
app/src/adapter/nextjs/nextjs.adapter.ts
Normal 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);
|
||||||
|
};
|
||||||
|
}
|
||||||
1
app/src/adapter/remix/index.ts
Normal file
1
app/src/adapter/remix/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./remix.adapter";
|
||||||
12
app/src/adapter/remix/remix.adapter.ts
Normal file
12
app/src/adapter/remix/remix.adapter.ts
Normal 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);
|
||||||
|
};
|
||||||
|
}
|
||||||
1
app/src/adapter/vite/index.ts
Normal file
1
app/src/adapter/vite/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./vite.adapter";
|
||||||
82
app/src/adapter/vite/vite.adapter.ts
Normal file
82
app/src/adapter/vite/vite.adapter.ts
Normal 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
269
app/src/auth/AppAuth.ts
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
41
app/src/auth/api/AuthApi.ts
Normal file
41
app/src/auth/api/AuthApi.ts
Normal 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() {}
|
||||||
|
}
|
||||||
57
app/src/auth/api/AuthController.ts
Normal file
57
app/src/auth/api/AuthController.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
85
app/src/auth/auth-schema.ts
Normal file
85
app/src/auth/auth-schema.ts
Normal 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
Reference in New Issue
Block a user