Compare commits

...

111 Commits

Author SHA1 Message Date
dswbx 4e923bff69 Merge pull request #40 from bknd-io/release/0.5
Release 0.5
2025-01-14 12:29:32 +01:00
dswbx c7bd0a636b fix admin redirection on default role matched only 2025-01-14 11:55:24 +01:00
dswbx a9b2c613e1 finalized 0.5.0 2025-01-14 08:51:21 +01:00
dswbx 6855e6f7e9 using vite for static bundling to solve external package's react resolution 2025-01-14 07:51:14 +01:00
dswbx b1a32f3705 added nextjs detection and added exception for auth redirection to also work inside stackblitz 2025-01-11 16:23:20 +01:00
dswbx d8671355a6 exposed bknd middlewares to be used for custom routes 2025-01-11 15:45:32 +01:00
dswbx bd4bc14282 reworked authentication and permission handling 2025-01-11 15:27:58 +01:00
dswbx 5823c2d245 fixed auth tests 2025-01-11 11:35:11 +01:00
dswbx c732566f63 Add integration tests for auth, improve auth middleware and cookies handling 2025-01-11 10:52:31 +01:00
dswbx 0d945ab45b fixed auth middleware 2025-01-11 08:28:46 +01:00
dswbx c06ba061f0 inlined JSON schema form library and updated related references to address bundling and React static asset issues 2025-01-11 08:09:52 +01:00
dswbx 3bf92a8c65 Refactor asset handling and authentication logic (for node)
Updated asset path configuration and server-side logic to standardize asset serving. Introduced `shouldSkipAuth` to bypass authentication for asset requests. Added test coverage for the new asset path handling logic.
2025-01-10 20:58:03 +01:00
dswbx 87e07570d4 Refactor admin theme handling and optimize initialization.
Simplified theme detection logic and improved the loading screen for better performance on slower networks. Adjusted styles, reorganized imports, and removed unnecessary rendering logic in `main.tsx`.
2025-01-10 18:33:12 +01:00
dswbx c7d983942f Add loading indicator for admin asset initialization
Introduced a "loading" div to indicate when admin assets are being fetched. Updated rendering logic and styles in related components to account for this state. Prepared groundwork for potential view transitions.
2025-01-10 18:04:40 +01:00
dswbx bb756548a6 Fix entity referencing issue during post-seeded relational fetch 2025-01-10 17:28:43 +01:00
dswbx e94e8d8bd1 Refactor entity handling to preserve config while overriding type
Reworked `ensureEntity` to replace entities while maintaining their configuration and allowing type adjustments. Updated tests to verify type persistence and synchronization of entity properties.
2025-01-10 15:51:47 +01:00
dswbx 1d5f14fae0 Merge pull request #41 from bknd-io/feat/seed-em-media
refactor module schema handling and add sync mechanism
2025-01-10 14:44:48 +01:00
dswbx a8c20d3675 Refactor module schema handling and add sync mechanism
Redesigned entity and index management with methods to streamline schema updates and added a sync flag to signal required DB syncs post-build. Enhanced test coverage and functionality for schema modifications, including support for additional fields.
2025-01-10 14:43:39 +01:00
dswbx 475563b5e1 fixed version of json-schema-form-react 2025-01-10 11:41:48 +01:00
dswbx f0d502133e Refactor Vite integration and update docs
Introduced a Vite adapter with "fresh" and "cached" modes, centralized dev server configuration, and streamlined the example setup. Updated documentation with detailed steps for Vite integration and revised the internal dev environment to align with the changes.
2025-01-10 09:46:00 +01:00
dswbx 0500d4fc8e Merge remote-tracking branch 'origin/refactor/drop-cjs' into release/0.5 2025-01-10 08:50:13 +01:00
dswbx 5bccc910a3 Merge remote-tracking branch 'origin/feat/auth-components' into release/0.5 2025-01-10 08:24:33 +01:00
dswbx da3f3d98d0 Merge remote-tracking branch 'origin/feat/vite-adapter-optimization' into release/0.5 2025-01-10 08:24:02 +01:00
dswbx c8745b3464 Merge remote-tracking branch 'origin/feat/repo-query-sort' into release/0.5
# Conflicts:
#	app/src/data/api/DataController.ts
2025-01-10 08:23:51 +01:00
dswbx eabd57cb4e Delete media files from filesystem in media controller
Updated the logic to ensure media files are deleted from the filesystem alongside database entries when exceeding the maximum allowed items and enabled overwrite. Replaced IDs with file paths for proper file deletion.
2025-01-09 15:58:35 +01:00
dswbx d182640981 Add Media.Dropzone and Media.Preview as isolated elements
Introduce `Media.*` to modularize and customize file upload handling. Refactor media-related components to improve usability, add max item limits, overwrite support, and event callbacks.
2025-01-09 15:43:43 +01:00
dswbx 138a3579cb Update test expectations and fix test utility usage
Replaced `console.log` with `expect` in crypto tests for proper validation. Wrapped a test case with `withDisabledConsole` to suppress expected log output. Fixed typo in data test field configuration (`max_length` to `maxLength`).
2025-01-09 11:23:23 +01:00
dswbx 99df7f1402 Handle missing app context in permission middleware to prevent errors. 2025-01-09 11:06:40 +01:00
dswbx 47f48be514 Refactor module middleware initialization logic.
Replaced `getMiddleware` with `onServerInit` for streamlined middleware registration. Updated `AppAuth` to automatically register its authentication middleware. Added a test case to verify middleware registration. Removed redundant cookie renewal logic from `AdminController` and made related adjustments across modules.
2025-01-09 10:59:48 +01:00
dswbx 5c7bfeab8f Refactor authentication components for modularity
Replaced `LoginForm` with a more extensible `AuthForm` and `AuthScreen`, enabling support for multiple auth strategies (e.g., OAuth) and improved reusability. Updated imports, routes, and configurations accordingly.
2025-01-09 10:20:28 +01:00
dswbx 7f337e25bb optimized vite adapter with better defaults 2025-01-08 17:10:00 +01:00
dswbx fcab042e88 replaced hook form with json schema from 2025-01-08 17:03:50 +01:00
dswbx aa86d54553 clean up data controller 2025-01-08 10:16:41 +01:00
dswbx 6026613d29 feat: allow data query to have single string as sort 2025-01-07 18:08:50 +01:00
dswbx 7d3d1e811f refactor: extracted auth as middleware to be added manually to endpoints 2025-01-07 13:32:50 +01:00
dswbx 9f2c20ccee refactor: remove cjs from build to reduce package size 2025-01-07 08:55:25 +01:00
dswbx 064bbba8aa doc: nextjs swap edge 2025-01-07 07:42:49 +01:00
dswbx d1aa05a9bb chores: repo update 2025-01-07 07:05:45 +01:00
dswbx ab46611839 app auth: fix missing email when creating user on seeding 2024-12-24 16:16:45 +01:00
dswbx 24226382e8 Merge pull request #33 from bknd-io/release/0.4.0
Release 0.4.0
2024-12-24 16:11:26 +01:00
dswbx d04c4a6191 bump to 0.4.0 2024-12-24 16:10:55 +01:00
dswbx 06125f1afe added fallback route to server, created extensive setup instructions in docs 2024-12-24 16:01:42 +01:00
dswbx 8ef11aa382 Merge pull request #34 from bknd-io/refactor/adapter-options
Refactor: Unified framework and runtime adapters
2024-12-24 09:45:03 +01:00
dswbx 76da14294c unified runtime and framework adapters 2024-12-24 09:43:16 +01:00
dswbx c1e92e503b update & fix typing, updated examples 2024-12-23 19:28:31 +01:00
dswbx 70e42a02d7 updated examples: astro, nextjs, remix, bun, node 2024-12-23 16:50:26 +01:00
dswbx a17fd2df67 Merge pull request #32 from bknd-io/feat/optimize-seeding
Feat: optimize seeding, adding api typing support
2024-12-23 11:45:15 +01:00
dswbx 0648f71a9e Merge pull request #27 from bknd-io/feat/optimize-cf-adapter
improved cloudflare worker adapter
2024-12-23 11:44:52 +01:00
dswbx 131b7dd52e Merge pull request #25 from bknd-io/feat/node-media-local
Feat: Node media local
2024-12-23 11:44:40 +01:00
dswbx 3a79ce2cf8 added a new mutate replacement for useEntityMutate to quickly update cache without revalidating 2024-12-21 15:03:14 +01:00
dswbx deddf00c38 added pausing to event manager, added context aware entity schemas, fixed typings, first boot event, improved useEntityQuery mutation behavior 2024-12-20 20:11:49 +01:00
dswbx a7e3ce878a added chainable functions in em() 2024-12-19 16:12:30 +01:00
dswbx d7ee13011f isolated constructor functions for entities and relations from config 2024-12-19 08:16:47 +01:00
dswbx 1d1ebff64d Merge remote-tracking branch 'origin/main' into feat/optimize-seeding 2024-12-19 07:59:51 +01:00
dswbx ac2ae2657e Merge pull request #31 from bknd-io/feat/optimize-api-and-replace-react-query
optimize client-side api & hooks
2024-12-19 07:59:16 +01:00
dswbx 8e987d58f2 to em() toJSON to match expected type 2024-12-19 07:58:41 +01:00
dswbx 386c0d3ff0 added new em() shorthand for prototyping, added insertMany mutator function for seeding 2024-12-19 07:38:40 +01:00
dswbx 602235b372 optimized module manager seeding, added type support for new api hooks and reduced amount of dist chunks 2024-12-18 18:22:01 +01:00
dswbx c4138ef823 updated plasmic package to work with new hooks + added example 2024-12-18 08:41:11 +01:00
dswbx 1631bbb754 replaced all react-query usages with new hooks + removed react-query 2024-12-13 17:36:09 +01:00
dswbx 8c91dff94d updated admin to use swr hooks instead of react-query 2024-12-13 16:24:55 +01:00
dswbx 50c5adce0c added useEntity and useEntityQuery hooks 2024-12-12 17:00:10 +01:00
dswbx 9d9aa7b7a5 added useApi and useApiQuery to work seamlessly with SWR (tbc) 2024-12-12 10:37:52 +01:00
dswbx 43ec075a32 reworked api to return a custom promise to extract request info 2024-12-12 10:01:05 +01:00
dswbx 29ae6c6f9d hotfix release 0.3.3 2024-12-11 18:49:57 +01:00
dswbx bd86f8ef91 Merge pull request #30 from bknd-io/fix/admin-ui-fixes-3
admin ui: fixing condition where entity has been created with empty label
2024-12-11 18:49:22 +01:00
dswbx 25027429df fixing condition where entity has been created with empty label 2024-12-11 18:48:57 +01:00
dswbx 35959aaf6d hotfix release 3.0.2 2024-12-11 18:38:53 +01:00
dswbx 56fb779f99 Merge pull request #29 from bknd-io/fix/admin-ui-fixes-2
admin ui: fix initial entity state in create modal
2024-12-11 18:37:46 +01:00
dswbx 134fbd6d34 admin ui: fix initial entity state in create modal 2024-12-11 18:36:42 +01:00
dswbx d2c75b1605 Merge pull request #26 from bknd-io/fix/mm-sync
fix: only sync config table once certain it has to happen
2024-12-11 18:29:22 +01:00
dswbx a474e3fe3a Merge pull request #28 from bknd-io/fix/admin-ui-fixes
Admin UI fixes
2024-12-11 18:29:05 +01:00
dswbx d81b3acb94 mm: disable console as it might error trying to fetch config 2024-12-11 18:25:31 +01:00
dswbx 2ca66f4fc9 admin ui: fix "enter" on create new modal (inside fields) 2024-12-11 17:46:15 +01:00
dswbx a8bbb6e760 admin ui: fix data erd layout 2024-12-11 17:30:33 +01:00
dswbx 32459a1562 improved cloudflare worker adapter + added docs about different modes 2024-12-11 17:09:28 +01:00
dswbx 2395d7fe97 fix: only sync config table once certain it has to happen 2024-12-11 15:51:00 +01:00
dswbx b7ec4982dc cli: automatically register local adapter 2024-12-10 15:50:41 +01:00
dswbx 10d606f8e9 docs: added docker instructions 2024-12-10 09:57:05 +01:00
dswbx 5cca911e9d Merge remote-tracking branch 'origin/main' into feat/node-media-local 2024-12-10 08:53:09 +01:00
dswbx 13a88be8d1 Merge pull request #23 from bknd-io/release/0.2.3
Release 0.3
2024-12-10 08:52:20 +01:00
dswbx a5264e22ee bump version 0.3.0 2024-12-10 08:51:54 +01:00
dswbx 290498de6e refactor registries to make registration more convenient 2024-12-10 08:47:23 +01:00
dswbx 847b08b505 fix createApp() fn doesn't require config, fix admin controller main entry 2024-12-10 08:44:50 +01:00
dswbx 86a7bee3d9 fix prototype tests 2024-12-07 19:35:34 +01:00
dswbx 3932396084 fixing imports and ssr support + updated remix/nextjs/astro examples 2024-12-07 19:31:12 +01:00
dswbx 94cc4042d3 small refactorings and cleanups, improved bun/node adapter, updated docs 2024-12-07 18:55:02 +01:00
dswbx 154703f873 added treeshake option to build + finalized package json (for now) 2024-12-07 17:19:27 +01:00
dswbx 71dbfc5469 moved main pkg back to deps to reduce tarball size + removed zod + cleanup old code + fixed tests 2024-12-07 10:11:26 +01:00
dswbx e5924b33e5 exclude metafiles in npm package to reduce tarball size 2024-12-07 09:46:30 +01:00
dswbx c25abd4a48 Merge remote-tracking branch 'origin/refactor/admin-basepath' into release/0.2.3 2024-12-07 09:30:32 +01:00
dswbx 7c58f20cca Merge remote-tracking branch 'origin/refactor/ext-deps' into release/0.2.3 2024-12-07 09:30:06 +01:00
dswbx ef18d8d955 Merge remote-tracking branch 'origin/misc/docker' into release/0.2.3 2024-12-07 09:29:48 +01:00
dswbx c7c6aa7df8 Merge remote-tracking branch 'origin/refactor/module-manager' into release/0.2.3 2024-12-07 09:29:31 +01:00
dswbx 060af0a1e3 fix react peer dependency version constraint (to prevent ERESOLVE warns) 2024-12-07 09:03:31 +01:00
dswbx 44a427de67 moved almost all deps to dev to speed up installs, updated nextjs docs 2024-12-07 08:48:48 +01:00
dswbx aa75355a69 use admin routes registering basepath from registration method instead of configuration
Signed-off-by: dswbx <dennis.senn@gmx.ch>
2024-12-07 06:34:40 +01:00
dswbx 110c7681b7 externalize libsql dependency to let installing env install the correct dep
Signed-off-by: dswbx <dennis.senn@gmx.ch>
2024-12-07 06:29:29 +01:00
dswbx ba517feab5 fix typings for initial partial config
Signed-off-by: dswbx <dennis.senn@gmx.ch>
2024-12-05 20:48:49 +01:00
dswbx 7e990feb99 added new diffing method to module manager
Signed-off-by: dswbx <dennis.senn@gmx.ch>
2024-12-05 20:23:51 +01:00
dswbx 77a6b6e7f5 fix repository's findOne to allow offset and sort – fixes module manager's config retrieval 2024-12-05 10:37:05 +01:00
dswbx a3348122e6 module manager: use json schema field for additional validation 2024-12-05 09:50:25 +01:00
dswbx 3757157a06 module manager: switched config table to use entity 2024-12-05 09:23:34 +01:00
dswbx 6c2f7b32e5 module manager: allow initial config without config as fallback 2024-12-05 08:12:46 +01:00
dswbx 067213ff60 docs: updated astro to add external example + added hint for astro db issues on v5 2024-12-04 15:54:33 +01:00
dswbx a4079804c2 release 0.2.2 2024-12-04 14:49:55 +01:00
dswbx 2bd4b720bb Merge pull request #18 from bknd-io/17-cannot-find-module-node_moduleslodash-esget
fix: lodash-es/* imports causing crash on astro
2024-12-04 14:49:19 +01:00
dswbx f3619bee26 fix lodash-es/* imports causing crash on astro 2024-12-04 14:47:52 +01:00
dswbx c54455870c fix: docs logo 2024-12-03 19:34:14 +01:00
dswbx 20477a655e update docs to navigate to web on logo click 2024-12-03 19:30:16 +01:00
dswbx b3e02394ac added an initial docker image 2024-12-03 08:51:30 +01:00
275 changed files with 8039 additions and 4410 deletions
+5 -1
View File
@@ -14,14 +14,18 @@ packages/media/.env
**/*/.env
**/*/.dev.vars
**/*/.wrangler
**/*/*.tgz
**/*/vite.config.ts.timestamp*
.history
**/*/.db/*
**/*/.configs/*
**/*/*.db
**/*/*.db-shm
**/*/*.db-wal
**/*/.tmp
.npmrc
/.verdaccio
.idea
.vscode
.git_old
.git_old
docker/tmp
+1 -1
View File
@@ -6,7 +6,7 @@ FSL-1.1-MIT
## Notice
Copyright 2024 Webintex GmbH
Copyright 2025 Dennis Senn
## Terms and Conditions
+1 -1
View File
@@ -1,4 +1,4 @@
![bknd](https://raw.githubusercontent.com/bknd-io/bknd/refs/heads/main/app/src/ui/assets/poster.png)
![bknd](docs/_assets/poster.png)
bknd simplifies app development by providing fully functional backend for data management,
authentication, workflows and media. Since it's lightweight and built on Web Standards, it can
-38
View File
@@ -1,38 +0,0 @@
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" });
});
});
+96
View File
@@ -0,0 +1,96 @@
import { describe, expect, it } from "bun:test";
import { Hono } from "hono";
import { secureRandomString } from "../../src/core/utils";
import { ModuleApi } from "../../src/modules";
class Api extends ModuleApi {
_getUrl(path: string) {
return this.getUrl(path);
}
}
const host = "http://localhost";
describe("ModuleApi", () => {
it("resolves options correctly", () => {
const api = new Api({ host });
expect(api.options).toEqual({ host });
});
it("returns correct url from path", () => {
const api = new Api({ host });
expect(api._getUrl("/test")).toEqual("http://localhost/test");
expect(api._getUrl("test")).toEqual("http://localhost/test");
expect(api._getUrl("test/")).toEqual("http://localhost/test");
expect(api._getUrl("//test?foo=1")).toEqual("http://localhost/test?foo=1");
});
it("fetches endpoint", async () => {
const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" }));
const api = new Api({ host });
api.fetcher = app.request as typeof fetch;
const res = await api.get("/endpoint");
expect(res.res.ok).toEqual(true);
expect(res.res.status).toEqual(200);
expect(res.data).toEqual({ foo: "bar" });
expect(res.body).toEqual({ foo: "bar" });
});
it("has accessible request", async () => {
const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" }));
const api = new Api({ host });
api.fetcher = app.request as typeof fetch;
const promise = api.get("/endpoint");
expect(promise.request).toBeDefined();
expect(promise.request.url).toEqual("http://localhost/endpoint");
expect((await promise).body).toEqual({ foo: "bar" });
});
it("adds token to headers when given in options", () => {
const token = secureRandomString(20);
const api = new Api({ host, token, token_transport: "header" });
expect(api.get("/").request.headers.get("Authorization")).toEqual(`Bearer ${token}`);
});
it("sets header to accept json", () => {
const api = new Api({ host });
expect(api.get("/").request.headers.get("Accept")).toEqual("application/json");
});
it("adds additional headers from options", () => {
const headers = new Headers({
"X-Test": "123"
});
const api = new Api({ host, headers });
expect(api.get("/").request.headers.get("X-Test")).toEqual("123");
});
it("uses basepath & removes trailing slash", () => {
const api = new Api({ host, basepath: "/api" });
expect(api.get("/").request.url).toEqual("http://localhost/api");
});
it("uses search params", () => {
const api = new Api({ host });
const search = new URLSearchParams({
foo: "bar"
});
expect(api.get("/", search).request.url).toEqual("http://localhost/?" + search.toString());
});
it("resolves method shortcut fns correctly", () => {
const api = new Api({ host });
expect(api.get("/").request.method).toEqual("GET");
expect(api.post("/").request.method).toEqual("POST");
expect(api.put("/").request.method).toEqual("PUT");
expect(api.patch("/").request.method).toEqual("PATCH");
expect(api.delete("/").request.method).toEqual("DELETE");
});
// @todo: test error response
// @todo: test method shortcut functions
});
-15
View File
@@ -1,15 +0,0 @@
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);*/
});
});
+1
View File
@@ -0,0 +1 @@
import { describe, expect, it } from "bun:test";
-54
View File
@@ -1,54 +0,0 @@
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]);
});
});
});
+36 -5
View File
@@ -1,4 +1,4 @@
import { describe, test } from "bun:test";
import { describe, expect, 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";
@@ -11,6 +11,9 @@ class What {
method() {
return null;
}
getType() {
return Type.Object({ type: Type.String() });
}
}
class What2 extends What {}
class NotAllowed {}
@@ -32,25 +35,53 @@ describe("Registry", () => {
} satisfies Record<string, Test1>);
const item = registry.get("first");
expect(item).toBeDefined();
expect(item?.cls).toBe(What);
const second = Type.Object({ type: Type.String(), what: Type.String() });
registry.add("second", {
cls: What2,
schema: Type.Object({ type: Type.String(), what: Type.String() }),
schema: second,
enabled: true
});
// @ts-ignore
expect(registry.get("second").schema).toEqual(second);
const third = Type.Object({ type: Type.String({ default: "1" }), what22: Type.String() });
registry.add("third", {
// @ts-expect-error
cls: NotAllowed,
schema: Type.Object({ type: Type.String({ default: "1" }), what22: Type.String() }),
schema: third,
enabled: true
});
// @ts-ignore
expect(registry.get("third").schema).toEqual(third);
const fourth = Type.Object({ type: Type.Number(), what22: Type.String() });
registry.add("fourth", {
cls: What,
// @ts-expect-error
schema: Type.Object({ type: Type.Number(), what22: Type.String() }),
schema: fourth,
enabled: true
});
// @ts-ignore
expect(registry.get("fourth").schema).toEqual(fourth);
console.log("list", registry.all());
expect(Object.keys(registry.all()).length).toBe(4);
});
test("uses registration fn", async () => {
const registry = new Registry<Test1>((a: ClassRef<What>) => {
return {
cls: a,
schema: a.prototype.getType(),
enabled: true
};
});
registry.register("what2", What2);
expect(registry.get("what2")).toBeDefined();
expect(registry.get("what2").cls).toBe(What2);
expect(registry.get("what2").schema).toEqual(What2.prototype.getType());
});
});
+6 -4
View File
@@ -1,14 +1,16 @@
import { describe, test } from "bun:test";
import { describe, expect, test } from "bun:test";
import { checksum, hash } from "../../src/core/utils";
describe("crypto", async () => {
test("sha256", async () => {
console.log(await hash.sha256("test"));
expect(await hash.sha256("test")).toBe(
"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
);
});
test("sha1", async () => {
console.log(await hash.sha1("test"));
expect(await hash.sha1("test")).toBe("a94a8fe5ccb19ba61c4c0873d391e987982fbbd3");
});
test("checksum", async () => {
console.log(checksum("hello world"));
expect(await checksum("hello world")).toBe("2aae6c35c94fcfb415dbe95f408b9ce91ee846ed");
});
});
+443
View File
@@ -0,0 +1,443 @@
import { describe, expect, it, test } from "bun:test";
import { apply, diff, revert } from "../../../src/core/object/diff";
describe("diff", () => {
it("should detect added properties", () => {
const oldObj = { a: 1 };
const newObj = { a: 1, b: 2 };
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "a",
p: ["b"],
o: undefined,
n: 2
}
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should detect removed properties", () => {
const oldObj = { a: 1, b: 2 };
const newObj = { a: 1 };
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "r",
p: ["b"],
o: 2,
n: undefined
}
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should detect edited properties", () => {
const oldObj = { a: 1 };
const newObj = { a: 2 };
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "e",
p: ["a"],
o: 1,
n: 2
}
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should detect changes in nested objects", () => {
const oldObj = { a: { b: 1 } };
const newObj = { a: { b: 2 } };
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "e",
p: ["a", "b"],
o: 1,
n: 2
}
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should detect changes in arrays", () => {
const oldObj = { a: [1, 2, 3] };
const newObj = { a: [1, 4, 3, 5] };
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "e",
p: ["a", 1],
o: 2,
n: 4
},
{
t: "a",
p: ["a", 3],
o: undefined,
n: 5
}
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should handle adding elements to an empty array", () => {
const oldObj = { a: [] };
const newObj = { a: [1, 2, 3] };
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "a",
p: ["a", 0],
o: undefined,
n: 1
},
{
t: "a",
p: ["a", 1],
o: undefined,
n: 2
},
{
t: "a",
p: ["a", 2],
o: undefined,
n: 3
}
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should handle removing elements from an array", () => {
const oldObj = { a: [1, 2, 3] };
const newObj = { a: [1, 3] };
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "e",
p: ["a", 1],
o: 2,
n: 3
},
{
t: "r",
p: ["a", 2],
o: 3,
n: undefined
}
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should handle complex nested changes", () => {
const oldObj = {
a: {
b: [1, 2, { c: 3 }]
}
};
const newObj = {
a: {
b: [1, 2, { c: 4 }, 5]
}
};
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "e",
p: ["a", "b", 2, "c"],
o: 3,
n: 4
},
{
t: "a",
p: ["a", "b", 3],
o: undefined,
n: 5
}
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should handle undefined and null values", () => {
const oldObj = { a: undefined, b: null };
const newObj = { a: null, b: undefined };
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "e",
p: ["a"],
o: undefined,
n: null
},
{
t: "e",
p: ["b"],
o: null,
n: undefined
}
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should handle type changes", () => {
const oldObj = { a: 1 };
const newObj = { a: "1" };
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "e",
p: ["a"],
o: 1,
n: "1"
}
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should handle properties added and removed simultaneously", () => {
const oldObj = { a: 1, b: 2 };
const newObj = { a: 1, c: 3 };
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "r",
p: ["b"],
o: 2,
n: undefined
},
{
t: "a",
p: ["c"],
o: undefined,
n: 3
}
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should handle arrays replaced with objects", () => {
const oldObj = { a: [1, 2, 3] };
const newObj = { a: { b: 4 } };
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "e",
p: ["a"],
o: [1, 2, 3],
n: { b: 4 }
}
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should handle objects replaced with primitives", () => {
const oldObj = { a: { b: 1 } };
const newObj = { a: 2 };
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "e",
p: ["a"],
o: { b: 1 },
n: 2
}
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should handle root object changes", () => {
const oldObj = { a: 1 };
const newObj = { b: 2 };
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "r",
p: ["a"],
o: 1,
n: undefined
},
{
t: "a",
p: ["b"],
o: undefined,
n: 2
}
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should handle identical objects", () => {
const oldObj = { a: 1, b: { c: 2 } };
const newObj = { a: 1, b: { c: 2 } };
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should handle empty objects", () => {
const oldObj = {};
const newObj = {};
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should handle changes from empty object to non-empty object", () => {
const oldObj = {};
const newObj = { a: 1 };
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "a",
p: ["a"],
o: undefined,
n: 1
}
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should handle changes from non-empty object to empty object", () => {
const oldObj = { a: 1 };
const newObj = {};
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "r",
p: ["a"],
o: 1,
n: undefined
}
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
});
@@ -1,6 +1,5 @@
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" };
@@ -8,19 +7,6 @@ describe("object-query", () => {
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" }
+21 -5
View File
@@ -1,16 +1,15 @@
import { describe, expect, test } from "bun:test";
import type { QueryObject } from "ufo";
import { WhereBuilder, type WhereQuery } from "../../src/data/entities/query/WhereBuilder";
import { Value } from "../../src/core/utils";
import { WhereBuilder, type WhereQuery, querySchema } from "../../src/data";
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();
return kysely.selectFrom("t").selectAll();
}
function compile(q: QueryObject) {
function compile(q: WhereQuery) {
const { sql, parameters } = WhereBuilder.addClause(qb(), q).compile();
return { sql, parameters };
}
@@ -90,3 +89,20 @@ describe("data-query-impl", () => {
}
});
});
describe("data-query-impl: Typebox", () => {
test("sort", async () => {
const decode = (input: any, expected: any) => {
const result = Value.Decode(querySchema, input);
expect(result.sort).toEqual(expected);
};
const _dflt = { by: "id", dir: "asc" };
decode({ sort: "" }, _dflt);
decode({ sort: "name" }, { by: "name", dir: "asc" });
decode({ sort: "-name" }, { by: "name", dir: "desc" });
decode({ sort: "-posts.name" }, { by: "posts.name", dir: "desc" });
decode({ sort: "-1name" }, _dflt);
decode({ sort: { by: "name", dir: "desc" } }, { by: "name", dir: "desc" });
});
});
+1 -1
View File
@@ -18,7 +18,7 @@ describe("some tests", async () => {
const users = new Entity("users", [
new TextField("username", { required: true, default_value: "nobody" }),
new TextField("email", { max_length: 3 })
new TextField("email", { maxLength: 3 })
]);
const posts = new Entity("posts", [
+50 -3
View File
@@ -16,7 +16,7 @@ describe("Mutator simple", async () => {
new TextField("label", { required: true, minLength: 1 }),
new NumberField("count", { default_value: 0 })
]);
const em = new EntityManager([items], connection);
const em = new EntityManager<any>([items], connection);
await em.connection.kysely.schema
.createTable("items")
@@ -132,14 +132,61 @@ describe("Mutator simple", async () => {
const data = (await em.repository(items).findMany()).data;
//console.log(data);
await em.mutator(items).deleteMany({ label: "delete" });
await em.mutator(items).deleteWhere({ 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();
await em.mutator(items).deleteWhere();
expect((await em.repository(items).findMany()).data.length).toBe(0);
//expect(res.data.count).toBe(0);
});
test("updateMany", async () => {
await em.mutator(items).insertOne({ label: "update", count: 1 });
await em.mutator(items).insertOne({ label: "update too", count: 1 });
await em.mutator(items).insertOne({ label: "keep" });
// expect no update
await em.mutator(items).updateWhere(
{ count: 2 },
{
count: 10
}
);
expect((await em.repository(items).findMany()).data).toEqual([
{ id: 6, label: "update", count: 1 },
{ id: 7, label: "update too", count: 1 },
{ id: 8, label: "keep", count: 0 }
]);
// expect 2 to be updated
await em.mutator(items).updateWhere(
{ count: 2 },
{
count: 1
}
);
expect((await em.repository(items).findMany()).data).toEqual([
{ id: 6, label: "update", count: 2 },
{ id: 7, label: "update too", count: 2 },
{ id: 8, label: "keep", count: 0 }
]);
});
test("insertMany", async () => {
const oldCount = (await em.repo(items).count()).count;
const inserts = [{ label: "insert 1" }, { label: "insert 2" }];
const { data } = await em.mutator(items).insertMany(inserts);
expect(data.length).toBe(2);
expect(data.map((d) => ({ label: d.label }))).toEqual(inserts);
const newCount = (await em.repo(items).count()).count;
expect(newCount).toBe(oldCount + inserts.length);
const { data: data2 } = await em.repo(items).findMany({ offset: oldCount });
expect(data2).toEqual(data);
});
});
+49 -4
View File
@@ -1,9 +1,10 @@
import { describe, expect, test } from "bun:test";
import { MediaField } from "../../src";
import {
BooleanField,
DateField,
Entity,
EntityIndex,
EntityManager,
EnumField,
JsonField,
ManyToManyRelation,
@@ -13,6 +14,7 @@ import {
PolymorphicRelation,
TextField
} from "../../src/data";
import { DummyConnection } from "../../src/data/connection/DummyConnection";
import {
FieldPrototype,
type FieldSchema,
@@ -21,6 +23,7 @@ import {
boolean,
date,
datetime,
em,
entity,
enumm,
json,
@@ -30,6 +33,7 @@ import {
relation,
text
} from "../../src/data/prototype";
import { MediaField } from "../../src/media/MediaField";
describe("prototype", () => {
test("...", () => {
@@ -46,12 +50,17 @@ describe("prototype", () => {
});
test("...2", async () => {
const user = entity("users", {
name: text().required(),
const users = entity("users", {
name: text(),
bio: text(),
age: number(),
some: number().required()
some: number()
});
type db = {
users: Schema<typeof users>;
};
const obj: Schema<typeof users> = {} as any;
//console.log("user", user.toJSON());
});
@@ -76,7 +85,9 @@ describe("prototype", () => {
new DateField("created_at", {
type: "datetime"
}),
// @ts-ignore
new MediaField("images", { entity: "posts" }),
// @ts-ignore
new MediaField("cover", { entity: "posts", max_items: 1 })
]);
@@ -264,4 +275,38 @@ describe("prototype", () => {
const obj: Schema<typeof test> = {} as any;
});
test("schema", async () => {
const _em = em(
{
posts: entity("posts", { name: text(), slug: text().required() }),
comments: entity("comments", { some: text() }),
users: entity("users", { email: text() })
},
({ relation, index }, { posts, comments, users }) => {
relation(posts).manyToOne(comments).manyToOne(users);
index(posts).on(["name"]).on(["slug"], true);
}
);
type LocalDb = (typeof _em)["DB"];
const es = [
new Entity("posts", [new TextField("name"), new TextField("slug", { required: true })]),
new Entity("comments", [new TextField("some")]),
new Entity("users", [new TextField("email")])
];
const _em2 = new EntityManager(
es,
new DummyConnection(),
[new ManyToOneRelation(es[0], es[1]), new ManyToOneRelation(es[0], es[2])],
[
new EntityIndex(es[0], [es[0].field("name")!]),
new EntityIndex(es[0], [es[0].field("slug")!], true)
]
);
// @ts-ignore
expect(_em2.toJSON()).toEqual(_em.toJSON());
});
});
+4 -4
View File
@@ -22,7 +22,7 @@ describe("[data] Mutator (base)", async () => {
new TextField("hidden", { hidden: true }),
new TextField("not_fillable", { fillable: false })
]);
const em = new EntityManager([entity], dummyConnection);
const em = new EntityManager<any>([entity], dummyConnection);
await em.schema().sync({ force: true });
const payload = { label: "item 1", count: 1 };
@@ -61,7 +61,7 @@ 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);
const em = new EntityManager<any>([posts, users], dummyConnection, relations);
await em.schema().sync({ force: true });
test("RelationMutator", async () => {
@@ -192,7 +192,7 @@ 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);
const em = new EntityManager<any>([users, settings], dummyConnection, relations);
await em.schema().sync({ force: true });
test("insertOne: missing ref", async () => {
@@ -276,7 +276,7 @@ describe("[data] Mutator (ManyToMany)", async () => {
describe("[data] Mutator (Events)", async () => {
const entity = new Entity("test", [new TextField("label")]);
const em = new EntityManager([entity], dummyConnection);
const em = new EntityManager<any>([entity], dummyConnection);
await em.schema().sync({ force: true });
const events = new Map<string, any>();
+4 -2
View File
@@ -1,7 +1,7 @@
// 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 { type Static, Type, _jsonp, withDisabledConsole } from "../../src/core/utils";
import { Condition, ExecutionEvent, FetchTask, Flow, LogTask, Task } from "../../src/flows";
/*beforeAll(disableConsoleLog);
@@ -232,7 +232,9 @@ describe("Flow tests", async () => {
).toEqual(["second", "fourth"]);
const execution = back.createExecution();
expect(execution.start()).rejects.toThrow();
withDisabledConsole(async () => {
expect(execution.start()).rejects.toThrow();
});
});
test("Flow with back step: enough retries", async () => {
+1 -1
View File
@@ -40,7 +40,7 @@ const _oldConsoles = {
error: console.error
};
export function disableConsoleLog(severities: ConsoleSeverity[] = ["log"]) {
export function disableConsoleLog(severities: ConsoleSeverity[] = ["log", "warn"]) {
severities.forEach((severity) => {
console[severity] = () => null;
});
@@ -0,0 +1,213 @@
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { App, createApp } from "../../src";
import type { AuthResponse } from "../../src/auth";
import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils";
import { disableConsoleLog, enableConsoleLog } from "../helper";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
const roles = {
sloppy: {
guest: {
permissions: [
"system.access.admin",
"system.schema.read",
"system.access.api",
"system.config.read",
"data.entity.read"
],
is_default: true
},
admin: {
is_default: true,
implicit_allow: true
}
},
strict: {
guest: {
permissions: ["system.access.api", "system.config.read", "data.entity.read"],
is_default: true
},
admin: {
is_default: true,
implicit_allow: true
}
}
};
const configs = {
auth: {
enabled: true,
entity_name: "users",
jwt: {
secret: secureRandomString(20),
issuer: randomString(10)
},
roles: roles.strict,
guard: {
enabled: true
}
},
users: {
normal: {
email: "normal@bknd.io",
password: "12345678"
},
admin: {
email: "admin@bknd.io",
password: "12345678",
role: "admin"
}
}
};
function createAuthApp() {
const app = createApp({
initialConfig: {
auth: configs.auth
}
});
app.emgr.onEvent(
App.Events.AppFirstBoot,
async () => {
await app.createUser(configs.users.normal);
await app.createUser(configs.users.admin);
},
"sync"
);
return app;
}
function getCookie(r: Response, name: string) {
const cookies = r.headers.get("cookie") ?? r.headers.get("set-cookie");
if (!cookies) return;
const cookie = cookies.split(";").find((c) => c.trim().startsWith(name));
if (!cookie) return;
return cookie.split("=")[1];
}
const fns = <Mode extends "cookie" | "token" = "token">(app: App, mode?: Mode) => {
function headers(token?: string, additional?: Record<string, string>) {
if (mode === "cookie") {
return {
cookie: `auth=${token};`,
...additional
};
}
return {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
...additional
};
}
function body(obj?: Record<string, any>) {
if (mode === "cookie") {
const formData = new FormData();
for (const key in obj) {
formData.append(key, obj[key]);
}
return formData;
}
return JSON.stringify(obj);
}
return {
login: async (
user: any
): Promise<{ res: Response; data: Mode extends "token" ? AuthResponse : string }> => {
const res = (await app.server.request("/api/auth/password/login", {
method: "POST",
headers: headers(),
body: body(user)
})) as Response;
const data = mode === "cookie" ? getCookie(res, "auth") : await res.json();
return { res, data };
},
me: async (token?: string): Promise<Pick<AuthResponse, "user">> => {
const res = (await app.server.request("/api/auth/me", {
method: "GET",
headers: headers(token)
})) as Response;
return await res.json();
}
};
};
describe("integration auth", () => {
it("should create users on boot", async () => {
const app = createAuthApp();
await app.build();
const { data: users } = await app.em.repository("users").findMany();
expect(users.length).toBe(2);
expect(users[0].email).toBe(configs.users.normal.email);
expect(users[1].email).toBe(configs.users.admin.email);
});
it("should log you in with API", async () => {
const app = createAuthApp();
await app.build();
const $fns = fns(app);
// login api
const { data } = await $fns.login(configs.users.normal);
const me = await $fns.me(data.token);
expect(data.user.email).toBe(me.user.email);
expect(me.user.email).toBe(configs.users.normal.email);
// expect no user with no token
expect(await $fns.me()).toEqual({ user: null as any });
// expect no user with invalid token
expect(await $fns.me("invalid")).toEqual({ user: null as any });
expect(await $fns.me()).toEqual({ user: null as any });
});
it("should log you in with form and cookie", async () => {
const app = createAuthApp();
await app.build();
const $fns = fns(app, "cookie");
const { res, data: token } = await $fns.login(configs.users.normal);
expect(token).toBeDefined();
expect(res.status).toBe(302); // because it redirects
// test cookie jwt interchangability
{
// expect token to not work as-is for api endpoints
expect(await fns(app).me(token)).toEqual({ user: null as any });
// hono adds an additional segment to cookies
const apified_token = token.split(".").slice(0, -1).join(".");
// now it should work
// @todo: maybe add a config to don't allow re-use?
expect((await fns(app).me(apified_token)).user.email).toBe(configs.users.normal.email);
}
// test cookie with me endpoint
{
const me = await $fns.me(token);
expect(me.user.email).toBe(configs.users.normal.email);
// check with invalid & empty
expect(await $fns.me("invalid")).toEqual({ user: null as any });
expect(await $fns.me()).toEqual({ user: null as any });
}
});
it("should check for permissions", async () => {
const app = createAuthApp();
await app.build();
await withDisabledConsole(async () => {
const res = await app.server.request("/api/system/schema");
expect(res.status).toBe(403);
});
});
});
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, test } from "bun:test";
import * as large from "../../src/media/storage/mime-types";
import * as tiny from "../../src/media/storage/mime-types-tiny";
describe("media/mime-types", () => {
test("tiny resolves", () => {
const tests = [[".mp4", "video/mp4", ".jpg", "image/jpeg", ".zip", "application/zip"]];
for (const [ext, mime] of tests) {
expect(tiny.guess(ext)).toBe(mime);
}
});
test("all tiny resolves to large", () => {
for (const [ext, mime] of Object.entries(tiny.M)) {
expect(large.guessMimeType("." + ext)).toBe(mime);
}
for (const [type, exts] of Object.entries(tiny.Q)) {
for (const ext of exts) {
const ex = `${type}/${ext}`;
try {
expect(large.guessMimeType("." + ext)).toBe(ex);
} catch (e) {
console.log(`Failed for ${ext}`, {
type,
exts,
ext,
expected: ex,
actual: large.guessMimeType("." + ext)
});
throw new Error(`Failed for ${ext}`);
}
}
}
});
});
+52 -1
View File
@@ -1,5 +1,7 @@
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
import { afterAll, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test";
import { createApp } from "../../src";
import { AuthController } from "../../src/auth/api/AuthController";
import { em, entity, text } from "../../src/data";
import { AppAuth, type ModuleBuildContext } from "../../src/modules";
import { disableConsoleLog, enableConsoleLog } from "../helper";
import { makeCtx, moduleTestSuite } from "./module-test-suite";
@@ -76,4 +78,53 @@ describe("AppAuth", () => {
expect(users[0].email).toBe("some@body.com");
}
});
test("registers auth middleware for bknd routes only", async () => {
const app = createApp({
initialConfig: {
auth: {
enabled: true,
jwt: {
secret: "123456"
}
}
}
});
await app.build();
const spy = spyOn(app.module.auth.authenticator, "requestCookieRefresh");
// register custom route
app.server.get("/test", async (c) => c.text("test"));
// call a system api and then the custom route
await app.server.request("/api/system/ping");
await app.server.request("/test");
expect(spy.mock.calls.length).toBe(1);
});
test("should allow additional user fields", async () => {
const app = createApp({
initialConfig: {
auth: {
entity_name: "users",
enabled: true
},
data: em({
users: entity("users", {
additional: text()
})
}).toJSON()
}
});
await app.build();
const e = app.modules.em.entity("users");
const fields = e.fields.map((f) => f.name);
expect(e.type).toBe("system");
expect(fields).toContain("additional");
expect(fields).toEqual(["id", "email", "strategy", "strategy_value", "role", "additional"]);
});
});
+49 -1
View File
@@ -1,7 +1,55 @@
import { describe } from "bun:test";
import { describe, expect, test } from "bun:test";
import { createApp, registries } from "../../src";
import { em, entity, text } from "../../src/data";
import { StorageLocalAdapter } from "../../src/media/storage/adapters/StorageLocalAdapter";
import { AppMedia } from "../../src/modules";
import { moduleTestSuite } from "./module-test-suite";
describe("AppMedia", () => {
moduleTestSuite(AppMedia);
test("should allow additional fields", async () => {
registries.media.register("local", StorageLocalAdapter);
const app = createApp({
initialConfig: {
media: {
entity_name: "media",
enabled: true,
adapter: {
type: "local",
config: {
path: "./"
}
}
},
data: em({
media: entity("media", {
additional: text()
})
}).toJSON()
}
});
await app.build();
const e = app.modules.em.entity("media");
const fields = e.fields.map((f) => f.name);
expect(e.type).toBe("system");
expect(fields).toContain("additional");
expect(fields).toEqual([
"id",
"path",
"folder",
"mime_type",
"size",
"scope",
"etag",
"modified_at",
"reference",
"entity_id",
"metadata",
"additional"
]);
});
});
+213
View File
@@ -0,0 +1,213 @@
import { describe, expect, test } from "bun:test";
import { type TSchema, Type, stripMark } from "../../src/core/utils";
import { EntityManager, em, entity, index, text } from "../../src/data";
import { DummyConnection } from "../../src/data/connection/DummyConnection";
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 () => {
describe("basic", () => {
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" });
});
});
describe("db schema", () => {
class M extends Module {
override getSchema() {
return Type.Object({});
}
prt = {
ensureEntity: this.ensureEntity.bind(this),
ensureIndex: this.ensureIndex.bind(this),
ensureSchema: this.ensureSchema.bind(this)
};
get em() {
return this.ctx.em;
}
}
function make(_em: ReturnType<typeof em>) {
const em = new EntityManager(
Object.values(_em.entities),
new DummyConnection(),
_em.relations,
_em.indices
);
return new M({} as any, { em, flags: Module.ctx_flags } as any);
}
function flat(_em: EntityManager) {
return {
entities: _em.entities.map((e) => ({
name: e.name,
fields: e.fields.map((f) => f.name),
type: e.type
})),
indices: _em.indices.map((i) => ({
name: i.name,
entity: i.entity.name,
fields: i.fields.map((f) => f.name),
unique: i.unique
}))
};
}
test("no change", () => {
const initial = em({});
const m = make(initial);
expect(m.ctx.flags.sync_required).toBe(false);
expect(flat(make(initial).em)).toEqual({
entities: [],
indices: []
});
});
test("init", () => {
const initial = em({
users: entity("u", {
name: text()
})
});
const m = make(initial);
expect(m.ctx.flags.sync_required).toBe(false);
expect(flat(m.em)).toEqual({
entities: [
{
name: "u",
fields: ["id", "name"],
type: "regular"
}
],
indices: []
});
});
test("ensure entity", () => {
const initial = em({
users: entity("u", {
name: text()
})
});
const m = make(initial);
expect(flat(m.em)).toEqual({
entities: [
{
name: "u",
fields: ["id", "name"],
type: "regular"
}
],
indices: []
});
// this should add a new entity
m.prt.ensureEntity(
entity("p", {
title: text()
})
);
// this should only add the field "important"
m.prt.ensureEntity(
entity(
"u",
{
important: text()
},
undefined,
"system"
)
);
expect(m.ctx.flags.sync_required).toBe(true);
expect(flat(m.em)).toEqual({
entities: [
{
name: "u",
// ensured properties must come first
fields: ["id", "important", "name"],
// ensured type must be present
type: "system"
},
{
name: "p",
fields: ["id", "title"],
type: "regular"
}
],
indices: []
});
});
test("ensure index", () => {
const users = entity("u", {
name: text(),
title: text()
});
const initial = em({ users }, ({ index }, { users }) => {
index(users).on(["title"]);
});
const m = make(initial);
m.prt.ensureIndex(index(users).on(["name"]));
expect(m.ctx.flags.sync_required).toBe(true);
expect(flat(m.em)).toEqual({
entities: [
{
name: "u",
fields: ["id", "name", "title"],
type: "regular"
}
],
indices: [
{
name: "idx_u_title",
entity: "u",
fields: ["title"],
unique: false
},
{
name: "idx_u_name",
entity: "u",
fields: ["name"],
unique: false
}
]
});
});
});
});
@@ -1,8 +1,9 @@
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";
import { stripMark } from "../../src/core/utils";
import { entity, text } from "../../src/data";
import { ModuleManager, getDefaultConfig } from "../../src/modules/ModuleManager";
import { CURRENT_VERSION, TABLE_NAME } from "../../src/modules/migrations";
import { getDummyConnection } from "../helper";
describe("ModuleManager", async () => {
test("s1: no config, no build", async () => {
@@ -29,21 +30,68 @@ describe("ModuleManager", async () => {
const mm = new ModuleManager(c.dummyConnection);
await mm.build();
const version = mm.version();
const json = mm.configs();
const configs = mm.configs();
const json = stripMark({
...configs,
data: {
...configs.data,
basepath: "/api/data2",
entities: {
test: entity("test", {
content: text()
}).toJSON()
}
}
});
//const { version, ...json } = mm.toJSON() as any;
const c2 = getDummyConnection();
const db = c2.dummyConnection.kysely;
await migrateSchema(CURRENT_VERSION, { db });
const mm2 = new ModuleManager(c2.dummyConnection, { initial: { version, ...json } });
await mm2.syncConfigTable();
await db
.updateTable(TABLE_NAME)
.set({ json: JSON.stringify(json), version: CURRENT_VERSION })
.insertInto(TABLE_NAME)
.values({ type: "config", 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());
expect(json).toEqual(stripMark(mm2.configs()));
});
test("s3.1: (fetch) config given, table exists, version matches", async () => {
const configs = getDefaultConfig();
const json = {
...configs,
data: {
...configs.data,
basepath: "/api/data2",
entities: {
test: entity("test", {
content: text()
}).toJSON()
}
}
};
//const { version, ...json } = mm.toJSON() as any;
const { dummyConnection } = getDummyConnection();
const db = dummyConnection.kysely;
const mm2 = new ModuleManager(dummyConnection);
await mm2.syncConfigTable();
// assume an initial version
await db.insertInto(TABLE_NAME).values({ type: "config", json: null, version: 1 }).execute();
await db
.insertInto(TABLE_NAME)
.values({ type: "config", json: JSON.stringify(json), version: CURRENT_VERSION })
.execute();
await mm2.build();
expect(stripMark(json)).toEqual(stripMark(mm2.configs()));
expect(mm2.configs().data.entities.test).toBeDefined();
expect(mm2.configs().data.entities.test.fields.content).toBeDefined();
expect(mm2.get("data").toJSON().entities.test.fields.content).toBeDefined();
});
test("s4: config given, table exists, version outdated, migrate", async () => {
@@ -52,21 +100,19 @@ describe("ModuleManager", async () => {
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.syncConfigTable();
await db
.insertInto(TABLE_NAME)
.values({ json: JSON.stringify(json), type: "config", version: CURRENT_VERSION - 1 })
.execute();
await mm2.build();
});
@@ -80,15 +126,15 @@ describe("ModuleManager", async () => {
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 }
});
await mm2.syncConfigTable();
await db
.insertInto(TABLE_NAME)
.values({ type: "config", json: JSON.stringify(json), version: CURRENT_VERSION })
.execute();
expect(mm2.build()).rejects.toThrow(/version.*do not match/);
});
@@ -102,7 +148,9 @@ describe("ModuleManager", async () => {
const c2 = getDummyConnection();
const db = c2.dummyConnection.kysely;
await migrateSchema(CURRENT_VERSION, { db });
const mm2 = new ModuleManager(c2.dummyConnection);
await mm2.syncConfigTable();
const config = {
...json,
@@ -112,12 +160,11 @@ describe("ModuleManager", async () => {
}
};
await db
.updateTable(TABLE_NAME)
.set({ json: JSON.stringify(config), version: CURRENT_VERSION })
.insertInto(TABLE_NAME)
.values({ type: "config", 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");
@@ -148,50 +195,61 @@ describe("ModuleManager", async () => {
});
});
// @todo: check what happens here
/*test("blank app, modify deep config", async () => {
test("partial config given", async () => {
const { dummyConnection } = getDummyConnection();
const mm = new ModuleManager(dummyConnection);
const partial = {
auth: {
enabled: true
}
};
const mm = new ModuleManager(dummyConnection, {
initial: partial
});
await mm.build();
/!* await mm
.get("data")
.schema()
.patch("entities.test", {
fields: {
content: {
type: "text"
}
expect(mm.version()).toBe(CURRENT_VERSION);
expect(mm.built()).toBe(true);
expect(mm.configs().auth.enabled).toBe(true);
expect(mm.configs().data.entities.users).toBeDefined();
});
test("partial config given, but db version exists", async () => {
const c = getDummyConnection();
const mm = new ModuleManager(c.dummyConnection);
await mm.build();
const json = mm.configs();
const c2 = getDummyConnection();
const db = c2.dummyConnection.kysely;
const mm2 = new ModuleManager(c2.dummyConnection, {
initial: {
auth: {
basepath: "/shouldnt/take/this"
}
});
await mm.build();
}
});
await mm2.syncConfigTable();
const payload = {
...json,
auth: {
...json.auth,
enabled: true,
basepath: "/api/auth2"
}
};
await db
.insertInto(TABLE_NAME)
.values({
type: "config",
json: JSON.stringify(payload),
version: CURRENT_VERSION
})
.execute();
await mm2.build();
expect(mm2.configs().auth.basepath).toBe("/api/auth2");
});
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({});
});*/
// @todo: add tests for migrations (check "backup" and new version)
});
+2 -1
View File
@@ -5,7 +5,7 @@ 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 { Module, type ModuleBuildContext } from "../../src/modules/Module";
import { getDummyConnection } from "../helper";
export function makeCtx(overrides?: Partial<ModuleBuildContext>): ModuleBuildContext {
@@ -16,6 +16,7 @@ export function makeCtx(overrides?: Partial<ModuleBuildContext>): ModuleBuildCon
em: new EntityManager([], dummyConnection),
emgr: new EventManager(),
guard: new Guard(),
flags: Module.ctx_flags,
...overrides
};
}
+65 -92
View File
@@ -1,82 +1,49 @@
import { $ } from "bun";
import * as esbuild from "esbuild";
import postcss from "esbuild-postcss";
import * as tsup from "tsup";
import { guessMimeType } from "./src/media/storage/mime-types";
const args = process.argv.slice(2);
const watch = args.includes("--watch");
const minify = args.includes("--minify");
const types = args.includes("--types");
const sourcemap = args.includes("--sourcemap");
const clean = args.includes("--clean");
if (clean) {
console.log("Cleaning dist (w/o static)");
await $`find dist -mindepth 1 ! -path "dist/static/*" ! -path "dist/static" -exec rm -rf {} +`;
}
let types_running = false;
function buildTypes() {
if (types_running) return;
types_running = true;
await $`rm -rf dist`;
if (types) {
Bun.spawn(["bun", "build:types"], {
stdout: "inherit",
onExit: () => {
console.log("Types built");
Bun.spawn(["bun", "tsc-alias"], {
stdout: "inherit",
onExit: () => {
console.log("Types aliased");
types_running = false;
}
});
}
});
}
/**
* Build static assets
* Using esbuild because tsup doesn't include "react"
*/
const result = await esbuild.build({
minify,
sourcemap,
entryPoints: ["src/ui/main.tsx"],
entryNames: "[dir]/[name]-[hash]",
outdir: "dist/static",
platform: "browser",
bundle: true,
splitting: true,
metafile: true,
drop: ["console", "debugger"],
inject: ["src/ui/inject.js"],
target: "es2022",
format: "esm",
plugins: [postcss()],
loader: {
".svg": "dataurl",
".js": "jsx"
},
define: {
__isDev: "0",
"process.env.NODE_ENV": '"production"'
},
chunkNames: "chunks/[name]-[hash]"
});
// Write manifest
{
const manifest: Record<string, object> = {};
const toAsset = (output: string) => {
const name = output.split("/").pop()!;
return {
name,
path: output,
mime: guessMimeType(name)
};
};
const info = Object.entries(result.metafile.outputs)
.filter(([, meta]) => {
return meta.entryPoint && meta.entryPoint === "src/ui/main.tsx";
})
.map(([output, meta]) => ({ output, meta }));
for (const { output, meta } of info) {
manifest[meta.entryPoint as string] = toAsset(output);
if (meta.cssBundle) {
manifest["src/ui/main.css"] = toAsset(meta.cssBundle);
}
let watcher_timeout: any;
function delayTypes() {
if (!watch || !types) return;
if (watcher_timeout) {
clearTimeout(watcher_timeout);
}
watcher_timeout = setTimeout(buildTypes, 1000);
}
const manifest_file = "dist/static/manifest.json";
await Bun.write(manifest_file, JSON.stringify(manifest, null, 2));
console.log(`Manifest written to ${manifest_file}`, manifest);
if (types && !watch) {
buildTypes();
}
/**
@@ -88,13 +55,17 @@ await tsup.build({
watch,
entry: ["src/index.ts", "src/data/index.ts", "src/core/index.ts", "src/core/utils/index.ts"],
outDir: "dist",
external: ["bun:test"],
external: ["bun:test", "@libsql/client"],
metafile: true,
platform: "browser",
format: ["esm", "cjs"],
format: ["esm"],
splitting: false,
treeshake: true,
loader: {
".svg": "dataurl"
},
onSuccess: async () => {
delayTypes();
}
});
@@ -105,21 +76,35 @@ await tsup.build({
minify,
sourcemap,
watch,
entry: ["src/ui/index.ts", "src/ui/client/index.ts", "src/ui/main.css"],
entry: [
"src/ui/index.ts",
"src/ui/client/index.ts",
"src/ui/elements/index.ts",
"src/ui/main.css"
],
outDir: "dist/ui",
external: ["bun:test"],
external: [
"bun:test",
"react",
"react-dom",
"react/jsx-runtime",
"react/jsx-dev-runtime",
"use-sync-external-store"
],
metafile: true,
platform: "browser",
format: ["esm", "cjs"],
format: ["esm"],
splitting: true,
treeshake: true,
loader: {
".svg": "dataurl"
},
onSuccess: async () => {
console.log("--- ui built");
},
esbuildOptions: (options) => {
options.logLevel = "silent";
options.chunkNames = "chunks/[name]-[hash]";
},
onSuccess: async () => {
delayTypes();
}
});
@@ -131,7 +116,7 @@ function baseConfig(adapter: string): tsup.Options {
minify,
sourcemap,
watch,
entry: [`src/adapter/${adapter}`],
entry: [`src/adapter/${adapter}/index.ts`],
format: ["esm"],
platform: "neutral",
outDir: `dist/adapter/${adapter}`,
@@ -146,41 +131,29 @@ function baseConfig(adapter: string): tsup.Options {
],
metafile: true,
splitting: false,
treeshake: true
treeshake: true,
onSuccess: async () => {
delayTypes();
}
};
}
await tsup.build(baseConfig("remix"));
await tsup.build(baseConfig("bun"));
await tsup.build(baseConfig("astro"));
await tsup.build(baseConfig("cloudflare"));
await tsup.build({
...baseConfig("vite"),
platform: "node"
});
await tsup.build({
...baseConfig("cloudflare")
});
await tsup.build({
...baseConfig("nextjs"),
format: ["esm", "cjs"],
platform: "node"
});
await tsup.build({
...baseConfig("remix"),
format: ["esm", "cjs"]
});
await tsup.build({
...baseConfig("bun")
});
await tsup.build({
...baseConfig("node"),
platform: "node",
format: ["esm", "cjs"]
});
await tsup.build({
...baseConfig("astro"),
format: ["esm", "cjs"]
platform: "node"
});
+66 -56
View File
@@ -3,34 +3,50 @@
"type": "module",
"sideEffects": false,
"bin": "./dist/cli/index.js",
"version": "0.2.1",
"version": "0.5.0",
"scripts": {
"build:all": "bun run build && bun run build:cli",
"dev": "vite",
"test": "ALL_TESTS=1 bun test --bail",
"build": "bun run build.ts --minify --types",
"build": "NODE_ENV=production bun run build.ts --minify --types",
"build:all": "rm -rf dist && bun run build:static && NODE_ENV=production bun run build.ts --minify --types --clean && bun run build:cli",
"build:cli": "bun build src/cli/index.ts --target node --outdir dist/cli --minify",
"build:static": "vite build",
"watch": "bun run build.ts --types --watch",
"types": "bun tsc --noEmit",
"clean:types": "find ./dist -name '*.d.ts' -delete && rm -f ./dist/tsconfig.tsbuildinfo",
"build:types": "tsc --emitDeclarationOnly",
"build:css": "bun tailwindcss -i src/ui/main.css -o ./dist/static/styles.css",
"watch:css": "bun tailwindcss --watch -i src/ui/main.css -o ./dist/styles.css",
"build:types": "tsc --emitDeclarationOnly && tsc-alias",
"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"
"cli": "LOCAL=1 bun src/cli/index.ts",
"prepublishOnly": "bun run test && bun run build:all"
},
"license": "FSL-1.1-MIT",
"dependencies": {
"@cfworker/json-schema": "^2.0.1",
"@libsql/client": "^0.14.0",
"@sinclair/typebox": "^0.32.34",
"@tanstack/react-form": "0.19.2",
"aws4fetch": "^1.0.18",
"dayjs": "^1.11.13",
"fast-xml-parser": "^4.4.0",
"hono": "^4.6.12",
"kysely": "^0.27.4",
"liquidjs": "^10.15.0",
"lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1",
"swr": "^2.2.5",
"json-schema-form-react": "^0.0.2"
},
"devDependencies": {
"@aws-sdk/client-s3": "^3.613.0",
"@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.6",
"@hono/vite-dev-server": "^0.17.0",
"@hono/zod-validator": "^0.4.1",
"@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",
@@ -38,51 +54,36 @@
"@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",
"dayjs": "^1.11.13",
"fast-xml-parser": "^4.4.0",
"hono": "^4.6.12",
"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",
"tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7",
"wouter": "^3.3.5",
"zod": "^3.23.8"
},
"devDependencies": {
"@aws-sdk/client-s3": "^3.613.0",
"@hono/node-server": "^1.13.7",
"@hono/vite-dev-server": "^0.17.0",
"@tanstack/react-query-devtools": "^5.59.16",
"@types/diff": "^5.2.3",
"@types/node": "^22.10.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@uiw/react-codemirror": "^4.23.6",
"@vitejs/plugin-react": "^4.3.3",
"@xyflow/react": "^12.3.2",
"autoprefixer": "^10.4.20",
"esbuild-postcss": "^0.0.4",
"node-fetch": "^3.3.2",
"jotai": "^2.10.1",
"open": "^10.1.0",
"openapi-types": "^12.1.3",
"postcss": "^8.4.47",
"postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1",
"react-hook-form": "^7.53.1",
"react-icons": "5.2.1",
"react-json-view-lite": "^2.0.1",
"tailwind-merge": "^2.5.4",
"tailwindcss": "^3.4.14",
"tailwindcss-animate": "^1.0.7",
"tsc-alias": "^1.8.10",
"tsup": "^8.3.5",
"vite": "^5.4.10",
"vite-plugin-static-copy": "^2.0.0",
"vite-tsconfig-paths": "^5.0.1"
"vite-tsconfig-paths": "^5.0.1",
"wouter": "^3.3.5"
},
"optionalDependencies": {
"@hono/node-server": "^1.13.7"
},
"peerDependencies": {
"react": ">=18",
@@ -90,80 +91,88 @@
},
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"types": "./dist/types/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"types": "./dist/types/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./ui": {
"types": "./dist/ui/index.d.ts",
"types": "./dist/types/ui/index.d.ts",
"import": "./dist/ui/index.js",
"require": "./dist/ui/index.cjs"
},
"./elements": {
"types": "./dist/types/ui/elements/index.d.ts",
"import": "./dist/ui/elements/index.js",
"require": "./dist/ui/elements/index.cjs"
},
"./client": {
"types": "./dist/ui/client/index.d.ts",
"types": "./dist/types/ui/client/index.d.ts",
"import": "./dist/ui/client/index.js",
"require": "./dist/ui/client/index.cjs"
},
"./data": {
"types": "./dist/data/index.d.ts",
"types": "./dist/types/data/index.d.ts",
"import": "./dist/data/index.js",
"require": "./dist/data/index.cjs"
},
"./core": {
"types": "./dist/core/index.d.ts",
"types": "./dist/types/core/index.d.ts",
"import": "./dist/core/index.js",
"require": "./dist/core/index.cjs"
},
"./utils": {
"types": "./dist/core/utils/index.d.ts",
"types": "./dist/types/core/utils/index.d.ts",
"import": "./dist/core/utils/index.js",
"require": "./dist/core/utils/index.cjs"
},
"./cli": {
"types": "./dist/cli/index.d.ts",
"types": "./dist/types/cli/index.d.ts",
"import": "./dist/cli/index.js",
"require": "./dist/cli/index.cjs"
},
"./adapter/cloudflare": {
"types": "./dist/adapter/cloudflare/index.d.ts",
"types": "./dist/types/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",
"types": "./dist/types/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",
"types": "./dist/types/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",
"types": "./dist/types/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",
"types": "./dist/types/adapter/bun/index.d.ts",
"import": "./dist/adapter/bun/index.js",
"require": "./dist/adapter/bun/index.cjs"
},
"./adapter/node": {
"types": "./dist/adapter/node/index.d.ts",
"types": "./dist/types/adapter/node/index.d.ts",
"import": "./dist/adapter/node/index.js",
"require": "./dist/adapter/node/index.cjs"
},
"./adapter/astro": {
"types": "./dist/adapter/astro/index.d.ts",
"types": "./dist/types/adapter/astro/index.d.ts",
"import": "./dist/adapter/astro/index.js",
"require": "./dist/adapter/astro/index.cjs"
},
"./dist/styles.css": "./dist/ui/main.css",
"./dist/manifest.json": "./dist/static/manifest.json"
"./dist/manifest.json": "./dist/static/.vite/manifest.json"
},
"publishConfig": {
"access": "public"
},
"files": [
"dist",
@@ -171,6 +180,7 @@
"!dist/*.tsbuildinfo",
"!dist/*.map",
"!dist/**/*.map",
"!dist/metafile*"
"!dist/metafile*",
"!dist/**/metafile*"
]
}
+40 -3
View File
@@ -1,3 +1,4 @@
import type { SafeUser } from "auth";
import { AuthApi } from "auth/api/AuthApi";
import { DataApi } from "data/api/DataApi";
import { decode } from "hono/jwt";
@@ -5,7 +6,7 @@ import { omit } from "lodash-es";
import { MediaApi } from "media/api/MediaApi";
import { SystemApi } from "modules/SystemApi";
export type TApiUser = object;
export type TApiUser = SafeUser;
declare global {
interface Window {
@@ -24,6 +25,12 @@ export type ApiOptions = {
localStorage?: boolean;
};
export type AuthState = {
token?: string;
user?: TApiUser;
verified: boolean;
};
export class Api {
private token?: string;
private user?: TApiUser;
@@ -50,6 +57,10 @@ export class Api {
this.buildApis();
}
get baseUrl() {
return this.options.host;
}
get tokenKey() {
return this.options.key ?? "auth";
}
@@ -85,7 +96,11 @@ export class Api {
updateToken(token?: string, rebuild?: boolean) {
this.token = token;
this.user = token ? omit(decode(token).payload as any, ["iat", "iss", "exp"]) : undefined;
if (token) {
this.user = omit(decode(token).payload as any, ["iat", "iss", "exp"]) as any;
} else {
this.user = undefined;
}
if (this.options.localStorage) {
const key = this.tokenKey;
@@ -105,7 +120,7 @@ export class Api {
return this;
}
getAuthState() {
getAuthState(): AuthState {
return {
token: this.token,
user: this.user,
@@ -113,6 +128,28 @@ export class Api {
};
}
async getVerifiedAuthState(force?: boolean): Promise<AuthState> {
if (force === true || !this.verified) {
await this.verifyAuth();
}
return this.getAuthState();
}
async verifyAuth() {
try {
const res = await this.auth.me();
if (!res.ok || !res.body.user) {
throw new Error();
}
this.markAuthVerified(true);
} catch (e) {
this.markAuthVerified(false);
this.updateToken(undefined);
}
}
getUser(): TApiUser | null {
return this.user || null;
}
+91 -39
View File
@@ -1,4 +1,8 @@
import type { CreateUserPayload } from "auth/AppAuth";
import { auth } from "auth/middlewares";
import { config } from "core";
import { Event } from "core/events";
import { patternMatch } from "core/utils";
import { Connection, type LibSqlCredentials, LibsqlConnection } from "data";
import {
type InitialModuleConfigs,
@@ -10,73 +14,75 @@ import * as SystemPermissions from "modules/permissions";
import { AdminController, type AdminControllerOptions } from "modules/server/AdminController";
import { SystemController } from "modules/server/SystemController";
export type AppPlugin<DB> = (app: App<DB>) => void;
export type AppPlugin = (app: App) => Promise<void> | void;
export class AppConfigUpdatedEvent extends Event<{ app: App }> {
abstract class AppEvent<A = {}> extends Event<{ app: App } & A> {}
export class AppConfigUpdatedEvent extends AppEvent {
static override slug = "app-config-updated";
}
export class AppBuiltEvent extends Event<{ app: App }> {
export class AppBuiltEvent extends AppEvent {
static override slug = "app-built";
}
export const AppEvents = { AppConfigUpdatedEvent, AppBuiltEvent } as const;
export class AppFirstBoot extends AppEvent {
static override slug = "app-first-boot";
}
export const AppEvents = { AppConfigUpdatedEvent, AppBuiltEvent, AppFirstBoot } as const;
export type CreateAppConfig = {
connection:
connection?:
| Connection
| {
type: "libsql";
config: LibSqlCredentials;
};
initialConfig?: InitialModuleConfigs;
plugins?: AppPlugin<any>[];
options?: ModuleManagerOptions;
plugins?: AppPlugin[];
options?: Omit<ModuleManagerOptions, "initial" | "onUpdated">;
};
export type AppConfig = InitialModuleConfigs;
export class App<DB = any> {
export class App {
modules: ModuleManager;
static readonly Events = AppEvents;
adminController?: AdminController;
private trigger_first_boot = false;
constructor(
private connection: Connection,
_initialConfig?: InitialModuleConfigs,
private plugins: AppPlugin<DB>[] = [],
private plugins: AppPlugin[] = [],
moduleManagerOptions?: ModuleManagerOptions
) {
this.modules = new ModuleManager(connection, {
...moduleManagerOptions,
initial: _initialConfig,
onUpdated: async (key, config) => {
//console.log("[APP] config updated", key, config);
// if the EventManager was disabled, we assume we shouldn't
// respond to events, such as "onUpdated".
if (!this.emgr.enabled) {
console.warn("[APP] config updated, but event manager is disabled, skip.");
return;
}
console.log("[APP] config updated", key);
await this.build({ sync: true, save: true });
await this.emgr.emit(new AppConfigUpdatedEvent({ app: this }));
},
onFirstBoot: async () => {
console.log("[APP] first boot");
this.trigger_first_boot = true;
},
onServerInit: async (server) => {
server.use(async (c, next) => {
c.set("app", this);
await next();
});
}
});
this.modules.ctx().emgr.registerEvents(AppEvents);
}
static create(config: CreateAppConfig) {
let connection: Connection | undefined = undefined;
if (Connection.isConnection(config.connection)) {
connection = config.connection;
} else if (typeof config.connection === "object") {
switch (config.connection.type) {
case "libsql":
connection = new LibsqlConnection(config.connection.config);
break;
}
} else {
throw new Error(`Unknown connection of type ${typeof config.connection} given.`);
}
if (!connection) {
throw new Error("Invalid connection");
}
return new App(connection, config.initialConfig, config.plugins, config.options);
}
get emgr() {
return this.modules.ctx().emgr;
}
@@ -91,32 +97,46 @@ export class App<DB = any> {
//console.log("syncing", syncResult);
}
const { guard, server } = this.modules.ctx();
// load system controller
this.modules.ctx().guard.registerPermissions(Object.values(SystemPermissions));
this.modules.server.route("/api/system", new SystemController(this).getController());
guard.registerPermissions(Object.values(SystemPermissions));
server.route("/api/system", new SystemController(this).getController());
// load plugins
if (this.plugins.length > 0) {
this.plugins.forEach((plugin) => plugin(this));
await Promise.all(this.plugins.map((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());
server.all("/api/*", async (c) => c.notFound());
if (options?.save) {
await this.modules.save();
}
// first boot is set from ModuleManager when there wasn't a config table
if (this.trigger_first_boot) {
this.trigger_first_boot = false;
await this.emgr.emit(new AppFirstBoot({ app: this }));
}
}
mutateConfig<Module extends keyof Modules>(module: Module) {
return this.modules.get(module).schema();
}
get server() {
return this.modules.server;
}
get em() {
return this.modules.ctx().em;
}
get fetch(): any {
return this.modules.server.fetch;
return this.server.fetch;
}
get module() {
@@ -140,11 +160,43 @@ export class App<DB = any> {
registerAdminController(config?: AdminControllerOptions) {
// register admin
this.modules.server.route("/", new AdminController(this, config).getController());
this.adminController = new AdminController(this, config);
this.modules.server.route(config?.basepath ?? "/", this.adminController.getController());
return this;
}
toJSON(secrets?: boolean) {
return this.modules.toJSON(secrets);
}
static create(config: CreateAppConfig) {
return createApp(config);
}
async createUser(p: CreateUserPayload) {
return this.module.auth.createUser(p);
}
}
export function createApp(config: CreateAppConfig = {}) {
let connection: Connection | undefined = undefined;
try {
if (Connection.isConnection(config.connection)) {
connection = config.connection;
} else if (typeof config.connection === "object") {
connection = new LibsqlConnection(config.connection.config);
} else {
connection = new LibsqlConnection({ url: ":memory:" });
console.warn("[!] No connection provided, using in-memory database");
}
} catch (e) {
console.error("Could not create connection", e);
}
if (!connection) {
throw new Error("Invalid connection");
}
return new App(connection, config.initialConfig, config.plugins, config.options);
}
+6 -6
View File
@@ -1,5 +1,7 @@
import { Api, type ApiOptions } from "bknd";
import { App, type CreateAppConfig } from "bknd";
import { type FrameworkBkndConfig, createFrameworkApp } from "adapter";
import { Api, type ApiOptions, type App } from "bknd";
export type AstroBkndConfig = FrameworkBkndConfig;
type TAstro = {
request: Request;
@@ -19,12 +21,10 @@ export function getApi(Astro: TAstro, options: Options = { mode: "static" }) {
}
let app: App;
export function serve(config: CreateAppConfig) {
export function serve(config: AstroBkndConfig = {}) {
return async (args: TAstro) => {
if (!app) {
app = App.create(config);
await app.build();
app = await createFrameworkApp(config);
}
return app.fetch(args.request);
};
+48 -46
View File
@@ -1,55 +1,57 @@
/// <reference types="bun-types" />
import path from "node:path";
import { App, type CreateAppConfig } from "bknd";
import { LibsqlConnection } from "bknd/data";
import type { App } from "bknd";
import type { ServeOptions } from "bun";
import { config } from "core";
import { serveStatic } from "hono/bun";
import { type RuntimeBkndConfig, createRuntimeApp } from "../index";
async function getConnection(conn?: CreateAppConfig["connection"]) {
if (conn) {
if (LibsqlConnection.isConnection(conn)) {
return conn;
}
let app: App;
return new LibsqlConnection(conn.config);
}
export type BunBkndConfig = RuntimeBkndConfig & Omit<ServeOptions, "fetch">;
const createClient = await import("@libsql/client/node").then((m) => m.createClient);
if (!createClient) {
throw new Error('libsql client not found, you need to install "@libsql/client/node"');
}
console.log("Using in-memory database");
return new LibsqlConnection(createClient({ url: ":memory:" }));
}
export function serve(_config: Partial<CreateAppConfig> = {}, distPath?: string) {
export async function createApp({ distPath, ...config }: RuntimeBkndConfig = {}) {
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
let app: App;
return async (req: Request) => {
if (!app) {
const connection = await getConnection(_config.connection);
app = App.create({
..._config,
connection
});
if (!app) {
app = await createRuntimeApp({
...config,
registerLocalMedia: true,
serveStatic: serveStatic({ root })
});
}
app.emgr.on(
"app-built",
async () => {
app.modules.server.get(
"/*",
serveStatic({
root
})
);
app.registerAdminController();
},
"sync"
);
await app.build();
}
return app.fetch(req);
};
return app;
}
export function serve({
distPath,
connection,
initialConfig,
plugins,
options,
port = config.server.default_port,
onBuilt,
buildConfig,
...serveOptions
}: BunBkndConfig = {}) {
Bun.serve({
...serveOptions,
port,
fetch: async (request: Request) => {
const app = await createApp({
connection,
initialConfig,
plugins,
options,
onBuilt,
buildConfig,
distPath
});
return app.fetch(request);
}
});
console.log(`Server is running on http://localhost:${port}`);
}
@@ -1,21 +1,37 @@
import { DurableObject } from "cloudflare:workers";
import { App, type CreateAppConfig } from "bknd";
import type { CreateAppConfig } from "bknd";
import { Hono } from "hono";
import { serveStatic } from "hono/cloudflare-workers";
import type { BkndConfig, CfBkndModeCache } from "../index";
import type { FrameworkBkndConfig } from "../index";
import { getCached } from "./modes/cached";
import { getDurable } from "./modes/durable";
import { getFresh, getWarm } from "./modes/fresh";
type Context = {
request: Request;
env: any;
ctx: ExecutionContext;
manifest: any;
export type CloudflareBkndConfig<Env = any> = Omit<FrameworkBkndConfig, "app"> & {
app: CreateAppConfig | ((env: Env) => CreateAppConfig);
mode?: "warm" | "fresh" | "cache" | "durable";
bindings?: (env: Env) => {
kv?: KVNamespace;
dobj?: DurableObjectNamespace;
};
key?: string;
keepAliveSeconds?: number;
forceHttps?: boolean;
manifest?: string;
setAdminHtml?: boolean;
html?: string;
};
export function serve(_config: BkndConfig, manifest?: string, html?: string) {
export type Context = {
request: Request;
env: any;
ctx: ExecutionContext;
};
export function serve(config: CloudflareBkndConfig) {
return {
async fetch(request: Request, env: any, ctx: ExecutionContext) {
const url = new URL(request.url);
const manifest = config.manifest;
if (manifest) {
const pathname = url.pathname.slice(1);
@@ -26,13 +42,10 @@ export function serve(_config: BkndConfig, manifest?: string, html?: string) {
hono.all("*", async (c, next) => {
const res = await serveStatic({
path: `./${pathname}`,
manifest,
onNotFound: (path) => console.log("not found", path)
manifest
})(c as any, next);
if (res instanceof Response) {
const ttl = pathname.startsWith("assets/")
? 60 * 60 * 24 * 365 // 1 year
: 60 * 5; // 5 minutes
const ttl = 60 * 60 * 24 * 365;
res.headers.set("Cache-Control", `public, max-age=${ttl}`);
return res;
}
@@ -44,214 +57,23 @@ export function serve(_config: BkndConfig, manifest?: string, html?: string) {
}
}
const config = {
..._config,
setAdminHtml: _config.setAdminHtml ?? !!manifest
};
const context = { request, env, ctx, manifest, html };
const mode = config.cloudflare?.mode?.(env);
config.setAdminHtml = config.setAdminHtml && !!config.manifest;
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...");
const context = { request, env, ctx } as Context;
const mode = config.mode ?? "warm";
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
});
switch (mode) {
case "fresh":
return await getFresh(config, context);
case "warm":
return await getWarm(config, context);
case "cache":
return await getCached(config, context);
case "durable":
return await getDurable(config, context);
default:
throw new Error(`Unknown mode ${mode}`);
}
}
};
}
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) {
app.registerAdminController({ 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" });
});
app.registerAdminController({ html });
config.onBuilt!(app);
},
"sync"
);
}
app.emgr.onEvent(
App.Events.AppConfigUpdatedEvent,
async ({ params: { app } }) => {
saveConfig(app.toJSON(true));
},
"sync"
);
await app.build();
if (config.setAdminHtml) {
app.registerAdminController({ html });
}
if (!cachedConfig) {
saveConfig(app.toJSON(true));
}
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
});
});
},
"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);
}
}
+3
View File
@@ -1 +1,4 @@
export * from "./cloudflare-workers.adapter";
export { makeApp, getFresh, getWarm } from "./modes/fresh";
export { getCached } from "./modes/cached";
export { DurableBkndApp, getDurable } from "./modes/durable";
@@ -0,0 +1,48 @@
import { createRuntimeApp } from "adapter";
import { App } from "bknd";
import type { CloudflareBkndConfig, Context } from "../index";
export async function getCached(config: CloudflareBkndConfig, { env, ctx }: Context) {
const { kv } = config.bindings?.(env)!;
if (!kv) throw new Error("kv namespace is not defined in cloudflare.bindings");
const key = config.key ?? "app";
const cachedConfig = await kv.get(key);
const initialConfig = cachedConfig ? JSON.parse(cachedConfig) : undefined;
async function saveConfig(__config: any) {
ctx.waitUntil(kv!.put(key, JSON.stringify(__config)));
}
const app = await createRuntimeApp(
{
...config,
initialConfig,
onBuilt: async (app) => {
app.module.server.client.get("/__bknd/cache", async (c) => {
await kv.delete(key);
return c.json({ message: "Cache cleared" });
});
await config.onBuilt?.(app);
},
beforeBuild: async (app) => {
app.emgr.onEvent(
App.Events.AppConfigUpdatedEvent,
async ({ params: { app } }) => {
saveConfig(app.toJSON(true));
},
"sync"
);
await config.beforeBuild?.(app);
},
adminOptions: { html: config.html }
},
env
);
if (!cachedConfig) {
saveConfig(app.toJSON(true));
}
return app;
}
+133
View File
@@ -0,0 +1,133 @@
import { DurableObject } from "cloudflare:workers";
import { createRuntimeApp } from "adapter";
import type { CloudflareBkndConfig, Context } from "adapter/cloudflare";
import type { App, CreateAppConfig } from "bknd";
export async function getDurable(config: CloudflareBkndConfig, ctx: Context) {
const { dobj } = config.bindings?.(ctx.env)!;
if (!dobj) throw new Error("durable object is not defined in cloudflare.bindings");
const key = config.key ?? "app";
if ([config.onBuilt, config.beforeBuild].some((x) => x)) {
console.log("onBuilt and beforeBuild are not supported with DurableObject mode");
}
const start = performance.now();
const id = dobj.idFromName(key);
const stub = dobj.get(id) as unknown as DurableBkndApp;
const create_config = typeof config.app === "function" ? config.app(ctx.env) : config.app;
const res = await stub.fire(ctx.request, {
config: create_config,
html: config.html,
keepAliveSeconds: config.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
});
}
export class DurableBkndApp extends DurableObject {
protected id = Math.random().toString(36).slice(2);
protected app?: App;
protected interval?: any;
async fire(
request: Request,
options: {
config: CreateAppConfig;
html?: string;
keepAliveSeconds?: number;
setAdminHtml?: boolean;
}
) {
let buildtime = 0;
if (!this.app) {
const start = performance.now();
const config = options.config;
// change protocol to websocket if libsql
if (
config?.connection &&
"type" in config.connection &&
config.connection.type === "libsql"
) {
config.connection.config.protocol = "wss";
}
this.app = await createRuntimeApp({
...config,
onBuilt: async (app) => {
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,
keepAliveSeconds: options?.keepAliveSeconds ?? 0,
colo: context.colo
});
});
await this.onBuilt(app);
},
adminOptions: { html: options.html },
beforeBuild: async (app) => {
await this.beforeBuild(app);
}
});
buildtime = performance.now() - start;
}
if (options?.keepAliveSeconds) {
this.keepAlive(options.keepAliveSeconds);
}
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
});
}
async onBuilt(app: App) {}
async beforeBuild(app: App) {}
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);
}
}
+27
View File
@@ -0,0 +1,27 @@
import { createRuntimeApp } from "adapter";
import type { App } from "bknd";
import type { CloudflareBkndConfig, Context } from "../index";
export async function makeApp(config: CloudflareBkndConfig, { env }: Context) {
return await createRuntimeApp(
{
...config,
adminOptions: config.html ? { html: config.html } : undefined
},
env
);
}
export async function getFresh(config: CloudflareBkndConfig, ctx: Context) {
const app = await makeApp(config, ctx);
return app.fetch(ctx.request);
}
let warm_app: App;
export async function getWarm(config: CloudflareBkndConfig, ctx: Context) {
if (!warm_app) {
warm_app = await makeApp(config, ctx);
}
return warm_app.fetch(ctx.request);
}
+98 -32
View File
@@ -1,40 +1,21 @@
import type { IncomingMessage } from "node:http";
import type { App, CreateAppConfig } from "bknd";
import { App, type CreateAppConfig, registries } from "bknd";
import { config as $config } from "core";
import type { MiddlewareHandler } from "hono";
import { StorageLocalAdapter } from "media/storage/adapters/StorageLocalAdapter";
import type { AdminControllerOptions } from "modules/server/AdminController";
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;
};
// @todo: move to App
export type BkndConfig<Env = any> = {
app: CreateAppConfig | ((env: Env) => CreateAppConfig);
setAdminHtml?: boolean;
server?: {
port?: number;
platform?: "node" | "bun";
};
cloudflare?: CloudflareBkndConfig<Env>;
export type BkndConfig<Env = any> = CreateAppConfig & {
app?: CreateAppConfig | ((env: Env) => CreateAppConfig);
onBuilt?: (app: App) => Promise<void>;
beforeBuild?: (app: App) => Promise<void>;
buildConfig?: Parameters<App["build"]>[0];
};
export type BkndConfigJson = {
app: CreateAppConfig;
setAdminHtml?: boolean;
server?: {
port?: number;
};
export type FrameworkBkndConfig<Env = any> = BkndConfig<Env>;
export type RuntimeBkndConfig<Env = any> = BkndConfig<Env> & {
distPath?: string;
};
export function nodeRequestToRequest(req: IncomingMessage): Request {
@@ -60,3 +41,88 @@ export function nodeRequestToRequest(req: IncomingMessage): Request {
headers
});
}
export function registerLocalMediaAdapter() {
registries.media.register("local", StorageLocalAdapter);
}
export function makeConfig<Env = any>(config: BkndConfig<Env>, env?: Env): CreateAppConfig {
let additionalConfig: CreateAppConfig = {};
if ("app" in config && config.app) {
if (typeof config.app === "function") {
if (!env) {
throw new Error("env is required when config.app is a function");
}
additionalConfig = config.app(env);
} else {
additionalConfig = config.app;
}
}
return { ...config, ...additionalConfig };
}
export async function createFrameworkApp<Env = any>(
config: FrameworkBkndConfig,
env?: Env
): Promise<App> {
const app = App.create(makeConfig(config, env));
if (config.onBuilt) {
app.emgr.onEvent(
App.Events.AppBuiltEvent,
async () => {
await config.onBuilt?.(app);
},
"sync"
);
}
await config.beforeBuild?.(app);
await app.build(config.buildConfig);
return app;
}
export async function createRuntimeApp<Env = any>(
{
serveStatic,
registerLocalMedia,
adminOptions,
...config
}: RuntimeBkndConfig & {
serveStatic?: MiddlewareHandler | [string, MiddlewareHandler];
registerLocalMedia?: boolean;
adminOptions?: AdminControllerOptions | false;
},
env?: Env
): Promise<App> {
if (registerLocalMedia) {
registerLocalMediaAdapter();
}
const app = App.create(makeConfig(config, env));
app.emgr.onEvent(
App.Events.AppBuiltEvent,
async () => {
if (serveStatic) {
const [path, handler] = Array.isArray(serveStatic)
? serveStatic
: [$config.server.assets_path + "*", serveStatic];
app.modules.server.get(path, handler);
}
await config.onBuilt?.(app);
if (adminOptions !== false) {
app.registerAdminController(adminOptions);
}
},
"sync"
);
await config.beforeBuild?.(app);
await app.build(config.buildConfig);
return app;
}
-20
View File
@@ -1,20 +0,0 @@
import { withApi } from "bknd/adapter/nextjs";
import type { BkndAdminProps } from "bknd/ui";
import type { InferGetServerSidePropsType } from "next";
import dynamic from "next/dynamic";
export const getServerSideProps = withApi(async (context) => {
return {
props: {
user: context.api.getUser()
}
};
});
export function adminPage(adminProps?: BkndAdminProps) {
const Admin = dynamic(() => import("bknd/ui").then((mod) => mod.Admin), { ssr: false });
return (props: InferGetServerSidePropsType<typeof getServerSideProps>) => {
if (typeof document === "undefined") return null;
return <Admin withProvider={{ user: props.user }} {...adminProps} />;
};
}
-1
View File
@@ -1,2 +1 @@
export * from "./nextjs.adapter";
export * from "./AdminPage";
+6 -6
View File
@@ -1,6 +1,8 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { Api, App, type CreateAppConfig } from "bknd";
import { nodeRequestToRequest } from "../index";
import { Api, type App } from "bknd";
import { type FrameworkBkndConfig, createFrameworkApp, nodeRequestToRequest } from "../index";
export type NextjsBkndConfig = FrameworkBkndConfig;
type GetServerSidePropsContext = {
req: IncomingMessage;
@@ -18,7 +20,6 @@ type GetServerSidePropsContext = {
export function createApi({ req }: GetServerSidePropsContext) {
const request = nodeRequestToRequest(req);
//console.log("createApi:request.headers", request.headers);
return new Api({
host: new URL(request.url).origin,
headers: request.headers
@@ -43,11 +44,10 @@ function getCleanRequest(req: Request) {
}
let app: App;
export function serve(config: CreateAppConfig) {
export function serve(config: NextjsBkndConfig = {}) {
return async (req: Request) => {
if (!app) {
app = App.create(config);
await app.build();
app = await createFrameworkApp(config);
}
const request = getCleanRequest(req);
return app.fetch(request, process.env);
+6 -73
View File
@@ -1,73 +1,6 @@
import path from "node:path";
import { serve as honoServe } from "@hono/node-server";
import { serveStatic } from "@hono/node-server/serve-static";
import { App, type CreateAppConfig } from "bknd";
import { LibsqlConnection } from "bknd/data";
async function getConnection(conn?: CreateAppConfig["connection"]) {
if (conn) {
if (LibsqlConnection.isConnection(conn)) {
return conn;
}
return new LibsqlConnection(conn.config);
}
const createClient = await import("@libsql/client/node").then((m) => m.createClient);
if (!createClient) {
throw new Error('libsql client not found, you need to install "@libsql/client/node"');
}
console.log("Using in-memory database");
return new LibsqlConnection(createClient({ url: ":memory:" }));
}
export type NodeAdapterOptions = {
relativeDistPath?: string;
port?: number;
hostname?: string;
listener?: Parameters<typeof honoServe>[1];
};
export function serve(_config: Partial<CreateAppConfig> = {}, options: NodeAdapterOptions = {}) {
const root = path.relative(
process.cwd(),
path.resolve(options.relativeDistPath ?? "./node_modules/bknd/dist", "static")
);
let app: App;
honoServe(
{
port: options.port ?? 1337,
hostname: options.hostname,
fetch: async (req: Request) => {
if (!app) {
const connection = await getConnection(_config.connection);
app = App.create({
..._config,
connection
});
app.emgr.on(
"app-built",
async () => {
app.modules.server.get(
"/*",
serveStatic({
root
})
);
app.registerAdminController();
},
"sync"
);
await app.build();
}
return app.fetch(req);
}
},
options.listener
);
}
export * from "./node.adapter";
export {
StorageLocalAdapter,
type LocalAdapterConfig
} from "../../media/storage/adapters/StorageLocalAdapter";
export { registerLocalMediaAdapter } from "../index";
+55
View File
@@ -0,0 +1,55 @@
import path from "node:path";
import { serve as honoServe } from "@hono/node-server";
import { serveStatic } from "@hono/node-server/serve-static";
import type { App } from "bknd";
import { config as $config } from "core";
import { type RuntimeBkndConfig, createRuntimeApp } from "../index";
export type NodeBkndConfig = RuntimeBkndConfig & {
port?: number;
hostname?: string;
listener?: Parameters<typeof honoServe>[1];
/** @deprecated */
relativeDistPath?: string;
};
export function serve({
distPath,
relativeDistPath,
port = $config.server.default_port,
hostname,
listener,
...config
}: NodeBkndConfig = {}) {
const root = path.relative(
process.cwd(),
path.resolve(distPath ?? relativeDistPath ?? "./node_modules/bknd/dist", "static")
);
if (relativeDistPath) {
console.warn("relativeDistPath is deprecated, please use distPath instead");
}
let app: App;
honoServe(
{
port,
hostname,
fetch: async (req: Request) => {
if (!app) {
app = await createRuntimeApp({
...config,
registerLocalMedia: true,
serveStatic: serveStatic({ root })
});
}
return app.fetch(req);
}
},
(connInfo) => {
console.log(`Server is running on http://localhost:${connInfo.port}`);
listener?.(connInfo);
}
);
}
+6 -4
View File
@@ -1,11 +1,13 @@
import { App, type CreateAppConfig } from "bknd";
import { type FrameworkBkndConfig, createFrameworkApp } from "adapter";
import type { App } from "bknd";
export type RemixBkndConfig = FrameworkBkndConfig;
let app: App;
export function serve(config: CreateAppConfig) {
export function serve(config: RemixBkndConfig = {}) {
return async (args: { request: Request }) => {
if (!app) {
app = App.create(config);
await app.build();
app = await createFrameworkApp(config);
}
return app.fetch(args.request);
};
+14
View File
@@ -0,0 +1,14 @@
export const devServerConfig = {
entry: "./server.ts",
exclude: [
/.*\.tsx?($|\?)/,
/^(?!.*\/__admin).*\.(s?css|less)($|\?)/,
// exclude except /api
/^(?!.*\/api).*\.(ico|mp4|jpg|jpeg|svg|png|vtt|mp3|js)($|\?)/,
/^\/@.+$/,
/\/components.*?\.json.*/, // @todo: improve
/^\/(public|assets|static)\/.+/,
/^\/node_modules\/.*/
] as any,
injectClientScript: false
} as const;
+58 -27
View File
@@ -1,50 +1,81 @@
import { serveStatic } from "@hono/node-server/serve-static";
import type { BkndConfig } from "bknd";
import { App } from "bknd";
import { type DevServerOptions, default as honoViteDevServer } from "@hono/vite-dev-server";
import { type RuntimeBkndConfig, createRuntimeApp } from "adapter";
import type { App } from "bknd";
import { devServerConfig } from "./dev-server-config";
function createApp(config: BkndConfig, env: any) {
const create_config = typeof config.app === "function" ? config.app(env) : config.app;
return App.create(create_config);
}
export type ViteBkndConfig<Env = any> = RuntimeBkndConfig<Env> & {
mode?: "cached" | "fresh";
setAdminHtml?: boolean;
forceDev?: boolean | { mainPath: string };
html?: string;
};
function setAppBuildListener(app: App, config: BkndConfig, html?: string) {
app.emgr.on(
"app-built",
async () => {
await config.onBuilt?.(app);
if (config.setAdminHtml) {
app.registerAdminController({ html, forceDev: true });
app.module.server.client.get("/assets/*", serveStatic({ root: "./" }));
}
},
"sync"
export function addViteScript(html: string, addBkndContext: boolean = true) {
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>
${addBkndContext ? "<!-- BKND_CONTEXT -->" : ""}
</head>`
);
}
export async function serveFresh(config: BkndConfig, _html?: string) {
async function createApp(config: ViteBkndConfig = {}, env?: any) {
return await createRuntimeApp(
{
...config,
registerLocalMedia: true,
adminOptions:
config.setAdminHtml === false
? undefined
: {
html: config.html,
forceDev: config.forceDev ?? {
mainPath: "/src/main.tsx"
}
},
serveStatic: ["/assets/*", serveStatic({ root: config.distPath ?? "./" })]
},
env
);
}
export function serveFresh(config: Omit<ViteBkndConfig, "mode"> = {}) {
return {
async fetch(request: Request, env: any, ctx: ExecutionContext) {
const app = createApp(config, env);
setAppBuildListener(app, config, _html);
await app.build();
const app = await createApp(config, env);
return app.fetch(request, env, ctx);
}
};
}
let app: App;
export async function serveCached(config: BkndConfig, _html?: string) {
export function serveCached(config: Omit<ViteBkndConfig, "mode"> = {}) {
return {
async fetch(request: Request, env: any, ctx: ExecutionContext) {
if (!app) {
app = createApp(config, env);
setAppBuildListener(app, config, _html);
await app.build();
app = await createApp(config, env);
}
return app.fetch(request, env, ctx);
}
};
}
export function serve({ mode, ...config }: ViteBkndConfig = {}) {
return mode === "fresh" ? serveFresh(config) : serveCached(config);
}
export function devServer(options: DevServerOptions) {
return honoViteDevServer({
...devServerConfig,
...options
});
}
+59 -52
View File
@@ -1,21 +1,25 @@
import { type AuthAction, Authenticator, type ProfileExchange, Role, type Strategy } from "auth";
import { Exception } from "core";
import type { PasswordStrategy } from "auth/authenticate/strategies";
import { auth } from "auth/middlewares";
import { type DB, Exception, type PrimaryFieldType } from "core";
import { type Static, secureRandomString, transformObject } from "core/utils";
import { type Entity, EntityIndex, type EntityManager } from "data";
import { type FieldSchema, entity, enumm, make, text } from "data/prototype";
import { type FieldSchema, em, entity, enumm, make, text } from "data/prototype";
import type { Hono } from "hono";
import { 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 {
declare module "core" {
interface DB {
users: UserFieldSchema;
users: { id: PrimaryFieldType } & UserFieldSchema;
}
}
type AuthSchema = Static<typeof authConfigSchema>;
export type CreateUserPayload = { email: string; password: string; [key: string]: any };
export class AppAuth extends Module<typeof authConfigSchema> {
private _authenticator?: Authenticator;
@@ -35,8 +39,12 @@ export class AppAuth extends Module<typeof authConfigSchema> {
return to;
}
get enabled() {
return this.config.enabled;
}
override async build() {
if (!this.config.enabled) {
if (!this.enabled) {
this.setBuilt();
return;
}
@@ -83,14 +91,6 @@ export class AppAuth extends Module<typeof authConfigSchema> {
return this._controller;
}
getMiddleware() {
if (!this.config.enabled) {
return;
}
return new AuthController(this).getMiddleware;
}
getSchema() {
return authConfigSchema;
}
@@ -100,7 +100,7 @@ export class AppAuth extends Module<typeof authConfigSchema> {
return this._authenticator!;
}
get em(): EntityManager<DB> {
get em(): EntityManager {
return this.ctx.em as any;
}
@@ -110,12 +110,12 @@ export class AppAuth extends Module<typeof authConfigSchema> {
identifier: string,
profile: ProfileExchange
): Promise<any> {
console.log("***** AppAuth:resolveUser", {
/*console.log("***** AppAuth:resolveUser", {
action,
strategy: strategy.getName(),
identifier,
profile
});
});*/
if (!this.config.allow_register && action === "register") {
throw new Exception("Registration is not allowed", 403);
}
@@ -136,12 +136,12 @@ export class AppAuth extends Module<typeof authConfigSchema> {
}
private filterUserData(user: any) {
console.log(
/*console.log(
"--filterUserData",
user,
this.config.jwt.fields,
pick(user, this.config.jwt.fields)
);
);*/
return pick(user, this.config.jwt.fields);
}
@@ -160,23 +160,25 @@ export class AppAuth extends Module<typeof authConfigSchema> {
const users = this.getUsersEntity();
this.toggleStrategyValueVisibility(true);
const result = await this.em.repo(users).findOne({ email: profile.email! });
const result = await this.em
.repo(users as unknown as "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);
//console.log("---login data", result.data, result);
// compare strategy and identifier
console.log("strategy comparison", result.data.strategy, strategy.getName());
//console.log("strategy comparison", result.data.strategy, strategy.getName());
if (result.data.strategy !== strategy.getName()) {
console.log("!!! User registered with different strategy");
//console.log("!!! User registered with different strategy");
throw new Exception("User registered with different strategy");
}
console.log("identifier comparison", result.data.strategy_value, identifier);
//console.log("identifier comparison", result.data.strategy_value, identifier);
if (result.data.strategy_value !== identifier) {
console.log("!!! Invalid credentials");
//console.log("!!! Invalid credentials");
throw new Exception("Invalid credentials");
}
@@ -197,7 +199,7 @@ export class AppAuth extends Module<typeof authConfigSchema> {
throw new Exception("User already exists");
}
const payload = {
const payload: any = {
...profile,
strategy: strategy.getName(),
strategy_value: identifier
@@ -244,46 +246,51 @@ export class AppAuth extends Module<typeof authConfigSchema> {
};
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 users = this.getUsersEntity(true);
this.ensureSchema(
em(
{
[users.name as "users"]: users
},
({ index }, { users }) => {
index(users).on(["email"], true).on(["strategy"]).on(["strategy_value"]);
}
}
}
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);
users.__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);
users.__replaceField("strategy", field);
} catch (e) {}
}
async createUser({ email, password, ...additional }: CreateUserPayload): Promise<DB["users"]> {
if (!this.enabled) {
throw new Error("Cannot create user, auth not enabled");
}
const strategy = "password";
const pw = this.authenticator.strategy(strategy) as PasswordStrategy;
const strategy_value = await pw.hash(password);
const mutator = this.em.mutator(this.config.entity_name as "users");
mutator.__unstable_toggleSystemEntityCreation(false);
const { data: created } = await mutator.insertOne({
...(additional as any),
email,
strategy,
strategy_value
});
mutator.__unstable_toggleSystemEntityCreation(true);
return created;
}
override toJSON(secrets?: boolean): AppAuthSchema {
if (!this.config.enabled) {
return this.configDefault;
+4 -4
View File
@@ -15,7 +15,7 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
async loginWithPassword(input: any) {
const res = await this.post<AuthResponse>(["password", "login"], input);
if (res.res.ok && res.body.token) {
if (res.ok && res.body.token) {
await this.options.onTokenUpdate?.(res.body.token);
}
return res;
@@ -23,17 +23,17 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
async registerWithPassword(input: any) {
const res = await this.post<AuthResponse>(["password", "register"], input);
if (res.res.ok && res.body.token) {
if (res.ok && res.body.token) {
await this.options.onTokenUpdate?.(res.body.token);
}
return res;
}
async me() {
me() {
return this.get<{ user: SafeUser | null }>(["me"]);
}
async strategies() {
strategies() {
return this.get<Pick<AppAuthSchema, "strategies" | "basepath">>(["strategies"]);
}
+10 -34
View File
@@ -1,42 +1,18 @@
import type { AppAuth } from "auth";
import { type ClassController, isDebug } from "core";
import { Hono, type MiddlewareHandler } from "hono";
import { Controller } from "modules/Controller";
export class AuthController implements ClassController {
constructor(private auth: AppAuth) {}
export class AuthController extends Controller {
constructor(private auth: AppAuth) {
super();
}
get guard() {
return this.auth.ctx.guard;
}
getMiddleware: MiddlewareHandler = async (c, next) => {
// @todo: ONLY HOTFIX
// middlewares are added for all routes are registered. But we need to make sure that
// only HTML/JSON routes are adding a cookie to the response. Config updates might
// also use an extension "syntax", e.g. /api/system/patch/data/entities.posts
// This middleware should be extracted and added by each Controller individually,
// but it requires access to the auth secret.
// Note: This doesn't mean endpoints aren't protected, just the cookie is not set.
const url = new URL(c.req.url);
const last = url.pathname.split("/")?.pop();
const ext = last?.includes(".") ? last.split(".")?.pop() : undefined;
if (
!this.auth.authenticator.isJsonRequest(c) &&
["GET", "HEAD", "OPTIONS"].includes(c.req.method) &&
ext &&
["js", "css", "png", "jpg", "jpeg", "svg", "ico"].includes(ext)
) {
isDebug() && console.log("Skipping auth", { ext }, url.pathname);
} else {
const user = await this.auth.authenticator.resolveAuthFromRequest(c);
this.auth.ctx.guard.setUserContext(user);
}
await next();
};
getController(): Hono<any> {
const hono = new Hono();
override getController() {
const { auth } = this.middlewares;
const hono = this.create();
const strategies = this.auth.authenticator.getStrategies();
for (const [name, strategy] of Object.entries(strategies)) {
@@ -44,7 +20,7 @@ export class AuthController implements ClassController {
hono.route(`/${name}`, strategy.getController(this.auth.authenticator));
}
hono.get("/me", async (c) => {
hono.get("/me", auth(), async (c) => {
if (this.auth.authenticator.isUserLoggedIn()) {
return c.json({ user: await this.auth.authenticator.getUser() });
}
@@ -52,7 +28,7 @@ export class AuthController implements ClassController {
return c.json({ user: null }, 403);
});
hono.get("/logout", async (c) => {
hono.get("/logout", auth(), async (c) => {
await this.auth.authenticator.logout(c);
if (this.auth.authenticator.isJsonRequest(c)) {
return c.json({ ok: true });
+1
View File
@@ -33,6 +33,7 @@ const strategiesSchemaObject = objectTransform(STRATEGIES, (strategy, name) => {
const strategiesSchema = Type.Union(Object.values(strategiesSchemaObject));
export type AppAuthStrategies = Static<typeof strategiesSchema>;
export type AppAuthOAuthStrategy = Static<typeof STRATEGIES.oauth.schema>;
export type AppAuthCustomOAuthStrategy = Static<typeof STRATEGIES.custom_oauth.schema>;
const guardConfigSchema = Type.Object({
enabled: Type.Optional(Type.Boolean({ default: false }))
+61 -33
View File
@@ -1,19 +1,11 @@
import { Exception } from "core";
import { addFlashMessage } from "core/server/flash";
import {
type Static,
StringEnum,
type TSchema,
Type,
parse,
randomString,
transformObject
} from "core/utils";
import { type Static, StringEnum, Type, parse, runtimeSupports, transformObject } from "core/utils";
import type { Context, Hono } from "hono";
import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie";
import { sign, verify } from "hono/jwt";
import type { CookieOptions } from "hono/utils/cookie";
import { omit } from "lodash-es";
import type { ServerEnv } from "modules/Module";
type Input = any; // workaround
export type JWTPayload = Parameters<typeof sign>[0];
@@ -67,6 +59,9 @@ export const cookieConfig = Type.Partial(
{ default: {}, additionalProperties: false }
);
// @todo: maybe add a config to not allow cookie/api tokens to be used interchangably?
// see auth.integration test for further details
export const jwtConfig = Type.Object(
{
// @todo: autogenerate a secret if not present. But it must be persisted from AppAuth
@@ -98,7 +93,13 @@ export type AuthUserResolver = (
export class Authenticator<Strategies extends Record<string, Strategy> = Record<string, Strategy>> {
private readonly strategies: Strategies;
private readonly config: AuthConfig;
private _user: SafeUser | undefined;
private _claims:
| undefined
| (SafeUser & {
iat: number;
iss?: string;
exp?: number;
});
private readonly userResolver: AuthUserResolver;
constructor(strategies: Strategies, userResolver?: AuthUserResolver, config?: AuthConfig) {
@@ -131,16 +132,18 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
}
isUserLoggedIn(): boolean {
return this._user !== undefined;
return this._claims !== undefined;
}
getUser() {
return this._user;
getUser(): SafeUser | undefined {
if (!this._claims) return;
const { iat, exp, iss, ...user } = this._claims;
return user;
}
// @todo: determine what to do exactly
__setUserNull() {
this._user = undefined;
resetUser() {
this._claims = undefined;
}
strategy<
@@ -154,6 +157,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
}
}
// @todo: add jwt tests
async jwt(user: Omit<User, "password">): Promise<string> {
const prohibited = ["password"];
for (const prop of prohibited) {
@@ -200,11 +204,11 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
}
}
this._user = omit(payload, ["iat", "exp", "iss"]) as SafeUser;
this._claims = payload as any;
return true;
} catch (e) {
this._user = undefined;
console.error(e);
this.resetUser();
//console.error(e);
}
return false;
@@ -220,38 +224,48 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
}
private async getAuthCookie(c: Context): Promise<string | undefined> {
const secret = this.config.jwt.secret;
try {
const secret = this.config.jwt.secret;
const token = await getSignedCookie(c, secret, "auth");
if (typeof token !== "string") {
return undefined;
}
return token;
} catch (e: any) {
if (e instanceof Error) {
console.error("[Error:getAuthCookie]", e.message);
}
const token = await getSignedCookie(c, secret, "auth");
if (typeof token !== "string") {
await deleteCookie(c, "auth", this.cookieOptions);
return undefined;
}
return token;
}
async requestCookieRefresh(c: Context) {
if (this.config.cookie.renew) {
const token = await this.getAuthCookie(c);
if (token) {
console.log("renewing cookie", c.req.url);
await this.setAuthCookie(c, token);
}
}
}
private async setAuthCookie(c: Context, token: string) {
private async setAuthCookie(c: Context<ServerEnv>, token: string) {
const secret = this.config.jwt.secret;
await setSignedCookie(c, "auth", token, secret, this.cookieOptions);
}
private async deleteAuthCookie(c: Context) {
await deleteCookie(c, "auth", this.cookieOptions);
}
async logout(c: Context) {
const cookie = await this.getAuthCookie(c);
if (cookie) {
await deleteCookie(c, "auth", this.cookieOptions);
await this.deleteAuthCookie(c);
await addFlashMessage(c, "Signed out", "info");
}
this.resetUser();
}
// @todo: move this to a server helper
@@ -260,18 +274,31 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
return c.req.header("Content-Type") === "application/json";
}
private getSuccessPath(c: Context) {
const p = (this.config.cookie.pathSuccess ?? "/").replace(/\/+$/, "/");
// nextjs doesn't support non-fq urls
// but env could be proxied (stackblitz), so we shouldn't fq every url
if (!runtimeSupports("redirects_non_fq")) {
return new URL(c.req.url).origin + p;
}
return p;
}
async respond(c: Context, data: AuthResponse | Error | any, redirect?: string) {
if (this.isJsonRequest(c)) {
return c.json(data);
}
const successPath = this.config.cookie.pathSuccess ?? "/";
const successUrl = new URL(c.req.url).origin + successPath.replace(/\/+$/, "/");
const referer = new URL(redirect ?? c.req.header("Referer") ?? successUrl);
const successUrl = this.getSuccessPath(c);
const referer = redirect ?? c.req.header("Referer") ?? successUrl;
//console.log("auth respond", { redirect, successUrl, successPath });
if ("token" in data) {
await this.setAuthCookie(c, data.token);
// can't navigate to "/" doesn't work on nextjs
//console.log("auth success, redirecting to", successUrl);
return c.redirect(successUrl);
}
@@ -281,6 +308,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
}
await addFlashMessage(c, message, "error");
//console.log("auth failed, redirecting to", referer);
return c.redirect(referer);
}
@@ -296,7 +324,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
if (token) {
await this.verify(token);
return this._user;
return this.getUser();
}
return undefined;
+6 -2
View File
@@ -98,12 +98,16 @@ export class Guard {
if (this.user && typeof this.user.role === "string") {
const role = this.roles?.find((role) => role.name === this.user?.role);
if (role) {
debug && console.log("guard: role found", this.user.role);
debug && console.log("guard: role found", [this.user.role]);
return role;
}
}
debug && console.log("guard: role not found", this.user, this.user?.role);
debug &&
console.log("guard: role not found", {
user: this.user,
role: this.user?.role
});
return this.getDefaultRole();
}
+105
View File
@@ -0,0 +1,105 @@
import type { Permission } from "core";
import { patternMatch } from "core/utils";
import type { Context } from "hono";
import { createMiddleware } from "hono/factory";
import type { ServerEnv } from "modules/Module";
function getPath(reqOrCtx: Request | Context) {
const req = reqOrCtx instanceof Request ? reqOrCtx : reqOrCtx.req.raw;
return new URL(req.url).pathname;
}
export function shouldSkip(c: Context<ServerEnv>, skip?: (string | RegExp)[]) {
if (c.get("auth_skip")) return true;
const req = c.req.raw;
if (!skip) return false;
const path = getPath(req);
const result = skip.some((s) => patternMatch(path, s));
c.set("auth_skip", result);
return result;
}
export const auth = (options?: {
skip?: (string | RegExp)[];
}) =>
createMiddleware<ServerEnv>(async (c, next) => {
// make sure to only register once
if (c.get("auth_registered")) {
throw new Error(`auth middleware already registered for ${getPath(c)}`);
}
c.set("auth_registered", true);
const app = c.get("app");
const skipped = shouldSkip(c, options?.skip) || !app?.module.auth.enabled;
const guard = app?.modules.ctx().guard;
const authenticator = app?.module.auth.authenticator;
if (!skipped) {
const resolved = c.get("auth_resolved");
if (!resolved) {
if (!app.module.auth.enabled) {
guard?.setUserContext(undefined);
} else {
guard?.setUserContext(await authenticator?.resolveAuthFromRequest(c));
c.set("auth_resolved", true);
}
}
}
await next();
if (!skipped) {
// renew cookie if applicable
authenticator?.requestCookieRefresh(c);
}
// release
guard?.setUserContext(undefined);
authenticator?.resetUser();
c.set("auth_resolved", false);
});
export const permission = (
permission: Permission | Permission[],
options?: {
onGranted?: (c: Context<ServerEnv>) => Promise<Response | void | undefined>;
onDenied?: (c: Context<ServerEnv>) => Promise<Response | void | undefined>;
}
) =>
// @ts-ignore
createMiddleware<ServerEnv>(async (c, next) => {
const app = c.get("app");
//console.log("skip?", c.get("auth_skip"));
// in tests, app is not defined
if (!c.get("auth_registered") || !app) {
const msg = `auth middleware not registered, cannot check permissions for ${getPath(c)}`;
if (app?.module.auth.enabled) {
throw new Error(msg);
} else {
console.warn(msg);
}
} else if (!c.get("auth_skip")) {
const guard = app.modules.ctx().guard;
const permissions = Array.isArray(permission) ? permission : [permission];
if (options?.onGranted || options?.onDenied) {
let returned: undefined | void | Response;
if (permissions.every((p) => guard.granted(p))) {
returned = await options?.onGranted?.(c);
} else {
returned = await options?.onDenied?.(c);
}
if (returned instanceof Response) {
return returned;
}
} else {
permissions.some((p) => guard.throwUnlessGranted(p));
}
}
await next();
});
+10 -28
View File
@@ -1,10 +1,9 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import type { ServeStaticOptions } from "@hono/node-server/serve-static";
import { type Config, createClient } from "@libsql/client/node";
import { Connection, LibsqlConnection, SqliteLocalConnection } from "data";
import type { Config } from "@libsql/client/node";
import { config } from "core";
import type { MiddlewareHandler } from "hono";
import { fileExists, getDistPath, getRelativeDistPath } from "../../utils/sys";
import open from "open";
import { fileExists, getRelativeDistPath } from "../../utils/sys";
export const PLATFORMS = ["node", "bun"] as const;
export type Platform = (typeof PLATFORMS)[number];
@@ -28,12 +27,13 @@ export async function serveStatic(server: Platform): Promise<MiddlewareHandler>
}
export async function attachServeStatic(app: any, platform: Platform) {
app.module.server.client.get("/*", await serveStatic(platform));
app.module.server.client.get(config.server.assets_path + "*", await serveStatic(platform));
}
export async function startServer(server: Platform, app: any, options: { port: number }) {
const port = options.port;
console.log("running on", server, port);
console.log(`(using ${server} serve)`);
switch (server) {
case "node": {
// https://github.com/honojs/node-server/blob/main/src/response.ts#L88
@@ -53,27 +53,9 @@ export async function startServer(server: Platform, app: any, options: { port: n
}
}
console.log("Server listening on", "http://localhost:" + port);
}
export async function getHtml() {
return await readFile(path.resolve(getDistPath(), "static/index.html"), "utf-8");
}
export function getConnection(connectionOrConfig?: Connection | Config): Connection {
if (connectionOrConfig) {
if (connectionOrConfig instanceof Connection) {
return connectionOrConfig;
}
if ("url" in connectionOrConfig) {
return new LibsqlConnection(createClient(connectionOrConfig));
}
}
console.log("Using in-memory database");
return new LibsqlConnection(createClient({ url: ":memory:" }));
//return new SqliteLocalConnection(new Database(":memory:"));
const url = `http://localhost:${port}`;
console.log(`Server listening on ${url}`);
await open(url);
}
export async function getConfigPath(filePath?: string) {
+26 -22
View File
@@ -1,16 +1,15 @@
import type { Config } from "@libsql/client/node";
import { App } from "App";
import type { BkndConfig } from "adapter";
import type { CliCommand } from "cli/types";
import { App, type CreateAppConfig } from "App";
import { StorageLocalAdapter } from "adapter/node";
import type { CliBkndConfig, CliCommand } from "cli/types";
import { Option } from "commander";
import type { Connection } from "data";
import { config } from "core";
import { registries } from "modules/registries";
import {
PLATFORMS,
type Platform,
attachServeStatic,
getConfigPath,
getConnection,
getHtml,
startServer
} from "./platform";
@@ -22,7 +21,7 @@ export const run: CliCommand = (program) => {
.addOption(
new Option("-p, --port <port>", "port to run on")
.env("PORT")
.default(1337)
.default(config.server.default_port)
.argParser((v) => Number.parseInt(v))
)
.addOption(new Option("-c, --config <config>", "config file"))
@@ -40,18 +39,24 @@ export const run: CliCommand = (program) => {
.action(action);
};
// automatically register local adapter
const local = StorageLocalAdapter.prototype.getName();
if (!registries.media.has(local)) {
registries.media.register(local, StorageLocalAdapter);
}
type MakeAppConfig = {
connection: Connection;
connection?: CreateAppConfig["connection"];
server?: { platform?: Platform };
setAdminHtml?: boolean;
onBuilt?: (app: App) => Promise<void>;
};
async function makeApp(config: MakeAppConfig) {
const app = new App(config.connection);
const app = App.create({ connection: config.connection });
app.emgr.on(
"app-built",
app.emgr.onEvent(
App.Events.AppBuiltEvent,
async () => {
await attachServeStatic(app, config.server?.platform ?? "node");
app.registerAdminController();
@@ -67,24 +72,23 @@ async function makeApp(config: MakeAppConfig) {
return app;
}
export async function makeConfigApp(config: BkndConfig, platform?: Platform) {
export async function makeConfigApp(config: CliBkndConfig, platform?: Platform) {
const appConfig = typeof config.app === "function" ? config.app(process.env) : config.app;
const app = App.create(appConfig);
app.emgr.on(
"app-built",
app.emgr.onEvent(
App.Events.AppBuiltEvent,
async () => {
await attachServeStatic(app, platform ?? "node");
app.registerAdminController();
if (config.onBuilt) {
await config.onBuilt(app);
}
await config.onBuilt?.(app);
},
"sync"
);
await app.build();
await config.beforeBuild?.(app);
await app.build(config.buildConfig);
return app;
}
@@ -99,13 +103,13 @@ async function action(options: {
let app: App;
if (options.dbUrl || !configFilePath) {
const connection = getConnection(
options.dbUrl ? { url: options.dbUrl, authToken: options.dbToken } : undefined
);
const connection = options.dbUrl
? { type: "libsql" as const, config: { url: options.dbUrl, authToken: options.dbToken } }
: undefined;
app = await makeApp({ connection, server: { platform: options.server } });
} else {
console.log("Using config from:", configFilePath);
const config = (await import(configFilePath).then((m) => m.default)) as BkndConfig;
const config = (await import(configFilePath).then((m) => m.default)) as CliBkndConfig;
app = await makeConfigApp(config, options.server);
}
+13 -16
View File
@@ -1,9 +1,9 @@
import { password as $password, text as $text } from "@clack/prompts";
import type { App } from "App";
import type { PasswordStrategy } from "auth/authenticate/strategies";
import type { App, BkndConfig } from "bknd";
import { makeConfigApp } from "cli/commands/run";
import { getConfigPath } from "cli/commands/run/platform";
import type { CliCommand } from "cli/types";
import type { CliBkndConfig, CliCommand } from "cli/types";
import { Argument } from "commander";
export const user: CliCommand = (program) => {
@@ -21,7 +21,7 @@ async function action(action: "create" | "update", options: any) {
return;
}
const config = (await import(configFilePath).then((m) => m.default)) as BkndConfig;
const config = (await import(configFilePath).then((m) => m.default)) as CliBkndConfig;
const app = await makeConfigApp(config, options.server);
switch (action) {
@@ -35,9 +35,11 @@ async function action(action: "create" | "update", options: any) {
}
async function create(app: App, options: any) {
const config = app.module.auth.toJSON(true);
const strategy = app.module.auth.authenticator.strategy("password") as PasswordStrategy;
const users_entity = config.entity_name;
if (!strategy) {
throw new Error("Password strategy not configured");
}
const email = await $text({
message: "Enter email",
@@ -65,16 +67,11 @@ async function create(app: App, options: any) {
}
try {
const mutator = app.modules.ctx().em.mutator(users_entity);
mutator.__unstable_toggleSystemEntityCreation(false);
const res = await mutator.insertOne({
const created = await app.createUser({
email,
strategy: "password",
strategy_value: await strategy.hash(password as string)
});
mutator.__unstable_toggleSystemEntityCreation(true);
console.log("Created:", res.data);
password: await strategy.hash(password as string)
})
console.log("Created:", created);
} catch (e) {
console.error("Error", e);
}
@@ -83,7 +80,7 @@ async function create(app: App, options: any) {
async function update(app: App, options: any) {
const config = app.module.auth.toJSON(true);
const strategy = app.module.auth.authenticator.strategy("password") as PasswordStrategy;
const users_entity = config.entity_name;
const users_entity = config.entity_name as "users";
const em = app.modules.ctx().em;
const email = (await $text({
@@ -141,4 +138,4 @@ async function update(app: App, options: any) {
} catch (e) {
console.error("Error", e);
}
}
}
+11
View File
@@ -1,3 +1,14 @@
import type { CreateAppConfig } from "App";
import type { FrameworkBkndConfig } from "adapter";
import type { Command } from "commander";
export type CliCommand = (program: Command) => void;
export type CliBkndConfig<Env = any> = FrameworkBkndConfig & {
app: CreateAppConfig | ((env: Env) => CreateAppConfig);
setAdminHtml?: boolean;
server?: {
port?: number;
platform?: "node" | "bun";
};
};
+8
View File
@@ -5,7 +5,15 @@ import type { Generated } from "kysely";
export type PrimaryFieldType = number | Generated<number>;
// biome-ignore lint/suspicious/noEmptyInterface: <explanation>
export interface DB {}
export const config = {
server: {
default_port: 1337,
// resetted to root for now, bc bundling with vite
assets_path: "/"
},
data: {
default_primary_field: "id"
}
+12 -2
View File
@@ -1,6 +1,7 @@
export class Exception extends Error {
code = 400;
override name = "Exception";
protected _context = undefined;
constructor(message: string, code?: number) {
super(message);
@@ -9,11 +10,16 @@ export class Exception extends Error {
}
}
context(context: any) {
this._context = context;
return this;
}
toJSON() {
return {
error: this.message,
type: this.name
//message: this.message
type: this.name,
context: this._context
};
}
}
@@ -27,6 +33,10 @@ export class BkndError extends Error {
super(message);
}
static with(message: string, details?: Record<string, any>, type?: string) {
throw new BkndError(message, details, type);
}
toJSON() {
return {
type: this.type ?? "unknown",
+20
View File
@@ -15,6 +15,7 @@ export class EventManager<
> {
protected events: EventClass[] = [];
protected listeners: EventListener[] = [];
enabled: boolean = true;
constructor(events?: RegisteredEvents, listeners?: EventListener[]) {
if (events) {
@@ -28,6 +29,16 @@ export class EventManager<
}
}
enable() {
this.enabled = true;
return this;
}
disable() {
this.enabled = false;
return this;
}
clearEvents() {
this.events = [];
return this;
@@ -39,6 +50,10 @@ export class EventManager<
return this;
}
getListeners(): EventListener[] {
return [...this.listeners];
}
get Events(): { [K in keyof RegisteredEvents]: RegisteredEvents[K] } {
// proxy class to access events
return new Proxy(this, {
@@ -133,6 +148,11 @@ export class EventManager<
async emit(event: Event) {
// @ts-expect-error slug is static
const slug = event.constructor.slug;
if (!this.enabled) {
console.log("EventManager disabled, not emitting", slug);
return;
}
if (!this.eventExists(event)) {
throw new Error(`Event "${slug}" not registered`);
}
+10 -4
View File
@@ -1,9 +1,9 @@
export { Endpoint, type RequestResponse, type Middleware } from "./server/Endpoint";
export { zValidator } from "./server/lib/zValidator";
import type { Hono, MiddlewareHandler } from "hono";
export { tbValidator } from "./server/lib/tbValidator";
export { Exception, BkndError } from "./errors";
export { isDebug } from "./env";
export { type PrimaryFieldType, config } from "./config";
export { type PrimaryFieldType, config, type DB } from "./config";
export { AwsClient } from "./clients/aws/AwsClient";
export {
SimpleRenderer,
@@ -11,7 +11,6 @@ export {
type TemplateTypes,
type SimpleRendererOptions
} from "./template/SimpleRenderer";
export { Controller, type ClassController } from "./server/Controller";
export { SchemaObject } from "./object/SchemaObject";
export { DebugLogger } from "./utils/DebugLogger";
export { Permission } from "./security/Permission";
@@ -26,3 +25,10 @@ export {
isBooleanLike
} from "./object/query/query";
export { Registry, type Constructor } from "./registry/Registry";
// compatibility
export type Middleware = MiddlewareHandler<any, any, any>;
export interface ClassController {
getController: () => Hono<any, any, any>;
getMiddleware?: MiddlewareHandler<any, any, any>;
}
+2 -1
View File
@@ -69,7 +69,8 @@ export class SchemaObject<Schema extends TObject> {
forceParse: true,
skipMark: this.isForceParse()
});
const updatedConfig = noEmit ? valid : await this.onBeforeUpdate(this._config, valid);
// regardless of "noEmit" this should always be triggered
const updatedConfig = await this.onBeforeUpdate(this._config, valid);
this._value = updatedConfig;
this._config = Object.freeze(updatedConfig);
+181
View File
@@ -0,0 +1,181 @@
enum Change {
Add = "a",
Remove = "r",
Edit = "e"
}
type Object = object;
type Primitive = string | number | boolean | null | object | any[] | undefined;
interface DiffEntry {
t: Change | string;
p: (string | number)[];
o: Primitive;
n: Primitive;
}
function isObject(value: any): value is Object {
return value !== null && value.constructor.name === "Object";
}
function isPrimitive(value: any): value is Primitive {
try {
return (
value === null ||
value === undefined ||
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean" ||
Array.isArray(value) ||
isObject(value)
);
} catch (e) {
return false;
}
}
function diff(oldObj: Object, newObj: Object): DiffEntry[] {
const diffs: DiffEntry[] = [];
function recurse(oldValue: Primitive, newValue: Primitive, path: (string | number)[]) {
if (!isPrimitive(oldValue) || !isPrimitive(newValue)) {
throw new Error("Diff: Only primitive types are supported");
}
if (oldValue === newValue) {
return;
}
if (typeof oldValue !== typeof newValue) {
diffs.push({
t: Change.Edit,
p: path,
o: oldValue,
n: newValue
});
} else if (Array.isArray(oldValue) && Array.isArray(newValue)) {
const maxLength = Math.max(oldValue.length, newValue.length);
for (let i = 0; i < maxLength; i++) {
if (i >= oldValue.length) {
diffs.push({
t: Change.Add,
p: [...path, i],
o: undefined,
n: newValue[i]
});
} else if (i >= newValue.length) {
diffs.push({
t: Change.Remove,
p: [...path, i],
o: oldValue[i],
n: undefined
});
} else {
recurse(oldValue[i], newValue[i], [...path, i]);
}
}
} else if (isObject(oldValue) && isObject(newValue)) {
const oKeys = Object.keys(oldValue);
const nKeys = Object.keys(newValue);
const allKeys = new Set([...oKeys, ...nKeys]);
for (const key of allKeys) {
if (!(key in oldValue)) {
diffs.push({
t: Change.Add,
p: [...path, key],
o: undefined,
n: newValue[key]
});
} else if (!(key in newValue)) {
diffs.push({
t: Change.Remove,
p: [...path, key],
o: oldValue[key],
n: undefined
});
} else {
recurse(oldValue[key], newValue[key], [...path, key]);
}
}
} else {
diffs.push({
t: Change.Edit,
p: path,
o: oldValue,
n: newValue
});
}
}
recurse(oldObj, newObj, []);
return diffs;
}
function apply(obj: Object, diffs: DiffEntry[]): any {
const clonedObj = clone(obj);
for (const diff of diffs) {
applyChange(clonedObj, diff);
}
return clonedObj;
}
function revert(obj: Object, diffs: DiffEntry[]): any {
const clonedObj = clone(obj);
const reversedDiffs = diffs.slice().reverse();
for (const diff of reversedDiffs) {
revertChange(clonedObj, diff);
}
return clonedObj;
}
function applyChange(obj: Object, diff: DiffEntry) {
const { p: path, t: type, n: newValue } = diff;
const parent = getParent(obj, path.slice(0, -1));
const key = path[path.length - 1]!;
if (type === Change.Add || type === Change.Edit) {
parent[key] = newValue;
} else if (type === Change.Remove) {
if (Array.isArray(parent)) {
parent.splice(key as number, 1);
} else {
delete parent[key];
}
}
}
function revertChange(obj: Object, diff: DiffEntry) {
const { p: path, t: type, o: oldValue } = diff;
const parent = getParent(obj, path.slice(0, -1));
const key = path[path.length - 1]!;
if (type === Change.Add) {
if (Array.isArray(parent)) {
parent.splice(key as number, 1);
} else {
delete parent[key];
}
} else if (type === Change.Remove || type === Change.Edit) {
parent[key] = oldValue;
}
}
function getParent(obj: Object, path: (string | number)[]): any {
let current = obj;
for (const key of path) {
if (current[key] === undefined) {
current[key] = typeof key === "number" ? [] : {};
}
current = current[key];
}
return current;
}
function clone<In extends Object>(obj: In): In {
return JSON.parse(JSON.stringify(obj));
}
export { diff, apply, revert, clone };
+28 -7
View File
@@ -1,29 +1,50 @@
export type Constructor<T> = new (...args: any[]) => T;
export class Registry<Item, Items extends Record<string, object> = Record<string, object>> {
export type RegisterFn<Item> = (unknown: any) => Item;
export class Registry<
Item,
Items extends Record<string, Item> = Record<string, Item>,
Fn extends RegisterFn<Item> = RegisterFn<Item>
> {
private is_set: boolean = false;
private items: Items = {} as Items;
set<Actual extends Record<string, object>>(items: Actual) {
constructor(private registerFn?: Fn) {}
set<Actual extends Record<string, Item>>(items: Actual) {
if (this.is_set) {
throw new Error("Registry is already set");
}
// @ts-ignore
this.items = items;
this.items = items as unknown as Items;
this.is_set = true;
return this as unknown as Registry<Item, Actual>;
return this as unknown as Registry<Item, Actual, Fn>;
}
add(name: string, item: Item) {
// @ts-ignore
this.items[name] = item;
this.items[name as keyof Items] = item as Items[keyof Items];
return this;
}
register(name: string, specific: Parameters<Fn>[0]) {
if (this.registerFn) {
const item = this.registerFn(specific);
this.items[name as keyof Items] = item as Items[keyof Items];
return this;
}
return this.add(name, specific);
}
get<Name extends keyof Items>(name: Name): Items[Name] {
return this.items[name];
}
has(name: keyof Items): boolean {
return name in this.items;
}
all() {
return this.items;
}
-155
View File
@@ -1,155 +0,0 @@
import { Hono, type MiddlewareHandler, type ValidationTargets } from "hono";
import type { H } from "hono/types";
import { safelyParseObjectValues } from "../utils";
import type { Endpoint, Middleware } from "./Endpoint";
import { zValidator } from "./lib/zValidator";
type RouteProxy<Endpoints> = {
[K in keyof Endpoints]: Endpoints[K];
};
export interface ClassController {
getController: () => Hono<any, any, any>;
getMiddleware?: MiddlewareHandler<any, any, any>;
}
/**
* @deprecated
*/
export class Controller<
Endpoints extends Record<string, Endpoint> = Record<string, Endpoint>,
Middlewares extends Record<string, Middleware> = Record<string, Middleware>
> {
protected endpoints: Endpoints = {} as Endpoints;
protected middlewares: Middlewares = {} as Middlewares;
public prefix: string = "/";
public routes: RouteProxy<Endpoints>;
constructor(
prefix: string = "/",
endpoints: Endpoints = {} as Endpoints,
middlewares: Middlewares = {} as Middlewares
) {
this.prefix = prefix;
this.endpoints = endpoints;
this.middlewares = middlewares;
this.routes = new Proxy(
{},
{
get: (_, name: string) => {
return this.endpoints[name];
}
}
) as RouteProxy<Endpoints>;
}
add<Name extends string, E extends Endpoint>(
this: Controller<Endpoints>,
name: Name,
endpoint: E
): Controller<Endpoints & Record<Name, E>> {
const newEndpoints = {
...this.endpoints,
[name]: endpoint
} as Endpoints & Record<Name, E>;
const newController: Controller<Endpoints & Record<Name, E>> = new Controller<
Endpoints & Record<Name, E>
>();
newController.endpoints = newEndpoints;
newController.middlewares = this.middlewares;
return newController;
}
get<Name extends keyof Endpoints>(name: Name): Endpoints[Name] {
return this.endpoints[name];
}
honoify(_hono: Hono = new Hono()) {
const hono = _hono.basePath(this.prefix);
// apply middlewares
for (const m_name in this.middlewares) {
const middleware = this.middlewares[m_name];
if (typeof middleware === "function") {
//if (isDebug()) console.log("+++ appyling middleware", m_name, middleware);
hono.use(middleware);
}
}
// apply endpoints
for (const name in this.endpoints) {
const endpoint = this.endpoints[name];
if (!endpoint) continue;
const handlers: H[] = [];
const supportedValidations: Array<keyof ValidationTargets> = ["param", "query", "json"];
// if validations are present, add them to the handlers
for (const validation of supportedValidations) {
if (endpoint.validation[validation]) {
handlers.push(async (c, next) => {
// @todo: potentially add "strict" to all schemas?
const res = await zValidator(
validation,
endpoint.validation[validation] as any,
(target, value, c) => {
if (["query", "param"].includes(target)) {
return safelyParseObjectValues(value);
}
//console.log("preprocess", target, value, c.req.raw.url);
return value;
}
)(c, next);
if (res instanceof Response && res.status === 400) {
const error = await res.json();
return c.json(
{
error: "Validation error",
target: validation,
message: error
},
400
);
}
return res;
});
}
}
// add actual handler
handlers.push(endpoint.toHandler());
const method = endpoint.method.toLowerCase() as
| "get"
| "post"
| "put"
| "delete"
| "patch";
//if (isDebug()) console.log("--- adding", method, endpoint.path);
hono[method](endpoint.path, ...handlers);
}
return hono;
}
toJSON() {
const endpoints: any = {};
for (const name in this.endpoints) {
const endpoint = this.endpoints[name];
if (!endpoint) continue;
endpoints[name] = {
method: endpoint.method,
path: (this.prefix + endpoint.path).replace("//", "/")
};
}
return endpoints;
}
}
-147
View File
@@ -1,147 +0,0 @@
import type { Context, MiddlewareHandler, Next, ValidationTargets } from "hono";
import type { Handler } from "hono/types";
import { encodeSearch, replaceUrlParam } from "../utils";
import type { Prettify } from "../utils";
type ZodSchema = { [key: string]: any };
type Method = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
type Validation<P, Q, B> = {
[K in keyof ValidationTargets]?: any;
} & {
param?: P extends ZodSchema ? P : undefined;
query?: Q extends ZodSchema ? Q : undefined;
json?: B extends ZodSchema ? B : undefined;
};
type ValidationInput<P, Q, B> = {
param?: P extends ZodSchema ? P["_input"] : undefined;
query?: Q extends ZodSchema ? Q["_input"] : undefined;
json?: B extends ZodSchema ? B["_input"] : undefined;
};
type HonoEnv = any;
export type Middleware = MiddlewareHandler<any, any, any>;
type HandlerFunction<P extends string, R> = (c: Context<HonoEnv, P, any>, next: Next) => R;
export type RequestResponse<R> = {
status: number;
ok: boolean;
response: Awaited<R>;
};
/**
* @deprecated
*/
export class Endpoint<
Path extends string = any,
P extends ZodSchema = any,
Q extends ZodSchema = any,
B extends ZodSchema = any,
R = any
> {
constructor(
readonly method: Method,
readonly path: Path,
readonly handler: HandlerFunction<Path, R>,
readonly validation: Validation<P, Q, B> = {}
) {}
// @todo: typing is not ideal
async $request(
args?: ValidationInput<P, Q, B>,
baseUrl: string = "http://localhost:28623"
): Promise<Prettify<RequestResponse<R>>> {
let path = this.path as string;
if (args?.param) {
path = replaceUrlParam(path, args.param);
}
if (args?.query) {
path += "?" + encodeSearch(args.query);
}
const url = [baseUrl, path].join("").replace(/\/$/, "");
const options: RequestInit = {
method: this.method,
headers: {} as any
};
if (!["GET", "HEAD"].includes(this.method)) {
if (args?.json) {
options.body = JSON.stringify(args.json);
options.headers!["Content-Type"] = "application/json";
}
}
const res = await fetch(url, options);
return {
status: res.status,
ok: res.ok,
response: (await res.json()) as any
};
}
toHandler(): Handler {
return async (c, next) => {
const res = await this.handler(c, next);
//console.log("toHandler:isResponse", res instanceof Response);
//return res;
if (res instanceof Response) {
return res;
}
return c.json(res as any) as unknown as Handler;
};
}
static get<
Path extends string = any,
P extends ZodSchema = any,
Q extends ZodSchema = any,
B extends ZodSchema = any,
R = any
>(path: Path, handler: HandlerFunction<Path, R>, validation?: Validation<P, Q, B>) {
return new Endpoint<Path, P, Q, B, R>("GET", path, handler, validation);
}
static post<
Path extends string = any,
P extends ZodSchema = any,
Q extends ZodSchema = any,
B extends ZodSchema = any,
R = any
>(path: Path, handler: HandlerFunction<Path, R>, validation?: Validation<P, Q, B>) {
return new Endpoint<Path, P, Q, B, R>("POST", path, handler, validation);
}
static patch<
Path extends string = any,
P extends ZodSchema = any,
Q extends ZodSchema = any,
B extends ZodSchema = any,
R = any
>(path: Path, handler: HandlerFunction<Path, R>, validation?: Validation<P, Q, B>) {
return new Endpoint<Path, P, Q, B, R>("PATCH", path, handler, validation);
}
static put<
Path extends string = any,
P extends ZodSchema = any,
Q extends ZodSchema = any,
B extends ZodSchema = any,
R = any
>(path: Path, handler: HandlerFunction<Path, R>, validation?: Validation<P, Q, B>) {
return new Endpoint<Path, P, Q, B, R>("PUT", path, handler, validation);
}
static delete<
Path extends string = any,
P extends ZodSchema = any,
Q extends ZodSchema = any,
B extends ZodSchema = any,
R = any
>(path: Path, handler: HandlerFunction<Path, R>, validation?: Validation<P, Q, B>) {
return new Endpoint<Path, P, Q, B, R>("DELETE", path, handler, validation);
}
}
+6 -8
View File
@@ -4,14 +4,12 @@ import { setCookie } from "hono/cookie";
const flash_key = "__bknd_flash";
export type FlashMessageType = "error" | "warning" | "success" | "info";
export async function addFlashMessage(
c: Context,
message: string,
type: FlashMessageType = "info"
) {
setCookie(c, flash_key, JSON.stringify({ type, message }), {
path: "/"
});
export function addFlashMessage(c: Context, message: string, type: FlashMessageType = "info") {
if (c.req.header("Accept")?.includes("text/html")) {
setCookie(c, flash_key, JSON.stringify({ type, message }), {
path: "/"
});
}
}
function getCookieValue(name) {
-75
View File
@@ -1,75 +0,0 @@
import type {
Context,
Env,
Input,
MiddlewareHandler,
TypedResponse,
ValidationTargets,
} from "hono";
import { validator } from "hono/validator";
import type { ZodError, ZodSchema, z } from "zod";
export type Hook<T, E extends Env, P extends string, O = {}> = (
result: { success: true; data: T } | { success: false; error: ZodError; data: T },
c: Context<E, P>,
) => Response | void | TypedResponse<O> | Promise<Response | void | TypedResponse<O>>;
type HasUndefined<T> = undefined extends T ? true : false;
export const zValidator = <
T extends ZodSchema,
Target extends keyof ValidationTargets,
E extends Env,
P extends string,
In = z.input<T>,
Out = z.output<T>,
I extends Input = {
in: HasUndefined<In> extends true
? {
[K in Target]?: K extends "json"
? In
: HasUndefined<keyof ValidationTargets[K]> extends true
? { [K2 in keyof In]?: ValidationTargets[K][K2] }
: { [K2 in keyof In]: ValidationTargets[K][K2] };
}
: {
[K in Target]: K extends "json"
? In
: HasUndefined<keyof ValidationTargets[K]> extends true
? { [K2 in keyof In]?: ValidationTargets[K][K2] }
: { [K2 in keyof In]: ValidationTargets[K][K2] };
};
out: { [K in Target]: Out };
},
V extends I = I,
>(
target: Target,
schema: T,
preprocess?: (target: string, value: In, c: Context<E, P>) => V, // <-- added
hook?: Hook<z.infer<T>, E, P>,
): MiddlewareHandler<E, P, V> =>
// @ts-expect-error not typed well
validator(target, async (value, c) => {
// added: preprocess value first if given
const _value = preprocess ? preprocess(target, value, c) : (value as any);
const result = await schema.safeParseAsync(_value);
if (hook) {
const hookResult = await hook({ data: value, ...result }, c);
if (hookResult) {
if (hookResult instanceof Response) {
return hookResult;
}
if ("response" in hookResult) {
return hookResult.response;
}
}
}
if (!result.success) {
return c.json(result, 400);
}
return result.data as z.infer<T>;
});
+6 -1
View File
@@ -20,11 +20,16 @@ export class DebugLogger {
return this;
}
reset() {
this.last = 0;
return this;
}
log(...args: any[]) {
if (!this._enabled) return this;
const now = performance.now();
const time = Number.parseInt(String(now - this.last));
const time = this.last === 0 ? 0 : Number.parseInt(String(now - this.last));
const indents = " ".repeat(this._context.length);
const context =
this._context.length > 0 ? `[${this._context[this._context.length - 1]}]` : "";
+1
View File
@@ -11,3 +11,4 @@ export * from "./crypto";
export * from "./uuid";
export { FromSchema } from "./typebox/from-schema";
export * from "./test";
export * from "./runtime";
+41
View File
@@ -0,0 +1,41 @@
import { getRuntimeKey as honoGetRuntimeKey } from "hono/adapter";
/**
* Adds additional checks for nextjs
*/
export function getRuntimeKey(): string {
const global = globalThis as any;
// Detect Next.js server-side runtime
if (global?.process?.env?.NEXT_RUNTIME === "nodejs") {
return "nextjs";
}
// Detect Next.js edge runtime
if (global?.process?.env?.NEXT_RUNTIME === "edge") {
return "nextjs-edge";
}
// Detect Next.js client-side runtime
// @ts-ignore
if (typeof window !== "undefined" && window.__NEXT_DATA__) {
return "nextjs-client";
}
return honoGetRuntimeKey();
}
const features = {
// supports the redirect of not full qualified addresses
// not supported in nextjs
redirects_non_fq: true
};
export function runtimeSupports(feature: keyof typeof features) {
const runtime = getRuntimeKey();
if (runtime.startsWith("nextjs")) {
features.redirects_non_fq = false;
}
return features[feature];
}
+11
View File
@@ -104,3 +104,14 @@ export function replaceSimplePlaceholders(str: string, vars: Record<string, any>
return key in vars ? vars[key] : match;
});
}
export function patternMatch(target: string, pattern: RegExp | string): boolean {
if (pattern instanceof RegExp) {
return pattern.test(target);
} else if (typeof pattern === "string" && pattern.startsWith("/")) {
return new RegExp(pattern).test(target);
} else if (typeof pattern === "string") {
return target.startsWith(pattern);
}
return false;
}
+27 -1
View File
@@ -5,10 +5,36 @@ const _oldConsoles = {
error: console.error
};
export function disableConsoleLog(severities: ConsoleSeverity[] = ["log"]) {
export async function withDisabledConsole<R>(
fn: () => Promise<R>,
severities: ConsoleSeverity[] = ["log", "warn", "error"]
): Promise<R> {
const _oldConsoles = {
log: console.log,
warn: console.warn,
error: console.error
};
disableConsoleLog(severities);
const enable = () => {
Object.entries(_oldConsoles).forEach(([severity, fn]) => {
console[severity as ConsoleSeverity] = fn;
});
};
try {
const result = await fn();
enable();
return result;
} catch (e) {
enable();
throw e;
}
}
export function disableConsoleLog(severities: ConsoleSeverity[] = ["log", "warn"]) {
severities.forEach((severity) => {
console[severity] = () => null;
});
return enableConsoleLog;
}
export function enableConsoleLog() {
+2 -2
View File
@@ -72,10 +72,10 @@ export class TypeInvalidError extends Error {
}
}
export function stripMark(obj: any) {
export function stripMark<O = any>(obj: O) {
const newObj = cloneDeep(obj);
mark(newObj, false);
return newObj;
return newObj as O;
}
export function mark(obj: any, validated = true) {
+13 -45
View File
@@ -1,52 +1,20 @@
import { transformObject } from "core/utils";
import { DataPermissions, Entity, EntityIndex, type EntityManager, type Field } from "data";
import {
DataPermissions,
type Entity,
EntityIndex,
type EntityManager,
constructEntity,
constructRelation
} from "data";
import { Module } from "modules/Module";
import { DataController } from "./api/DataController";
import {
type AppDataConfig,
FIELDS,
RELATIONS,
type TAppDataEntity,
type TAppDataRelation,
dataConfigSchema
} from "./data-schema";
export class AppData<DB> extends Module<typeof dataConfigSchema> {
static constructEntity(name: string, entityConfig: TAppDataEntity) {
const fields = transformObject(entityConfig.fields ?? {}, (fieldConfig, name) => {
const { type } = fieldConfig;
if (!(type in FIELDS)) {
throw new Error(`Field type "${type}" not found`);
}
const { field } = FIELDS[type as any];
const returnal = new field(name, fieldConfig.config) as Field;
return returnal;
});
// @todo: entity must be migrated to typebox
return new Entity(
name,
Object.values(fields),
entityConfig.config as any,
entityConfig.type as any
);
}
static constructRelation(
relationConfig: TAppDataRelation,
resolver: (name: Entity | string) => Entity
) {
return new RELATIONS[relationConfig.type].cls(
resolver(relationConfig.source),
resolver(relationConfig.target),
relationConfig.config
);
}
import { type AppDataConfig, dataConfigSchema } from "./data-schema";
export class AppData extends Module<typeof dataConfigSchema> {
override async build() {
const entities = transformObject(this.config.entities ?? {}, (entityConfig, name) => {
return AppData.constructEntity(name, entityConfig);
return constructEntity(name, entityConfig);
});
const _entity = (_e: Entity | string): Entity => {
@@ -57,7 +25,7 @@ export class AppData<DB> extends Module<typeof dataConfigSchema> {
};
const relations = transformObject(this.config.relations ?? {}, (relation) =>
AppData.constructRelation(relation, _entity)
constructRelation(relation, _entity)
);
const indices = transformObject(this.config.indices ?? {}, (index, name) => {
@@ -91,7 +59,7 @@ export class AppData<DB> extends Module<typeof dataConfigSchema> {
return dataConfigSchema;
}
get em(): EntityManager<DB> {
get em(): EntityManager {
this.throwIfNotBuilt();
return this.ctx.em;
}
+38 -25
View File
@@ -1,3 +1,4 @@
import type { DB } from "core";
import type { EntityData, RepoQuery, RepositoryResponse } from "data";
import { type BaseModuleApiOptions, ModuleApi, type PrimaryFieldType } from "modules";
@@ -15,48 +16,60 @@ export class DataApi extends ModuleApi<DataApiOptions> {
};
}
async readOne(
entity: string,
readOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: E,
id: PrimaryFieldType,
query: Partial<Omit<RepoQuery, "where" | "limit" | "offset">> = {}
) {
return this.get<RepositoryResponse<EntityData>>([entity, id], query);
return this.get<Pick<RepositoryResponse<Data>, "meta" | "data">>([entity as any, id], query);
}
async readMany(entity: string, query: Partial<RepoQuery> = {}) {
return this.get<Pick<RepositoryResponse, "meta" | "data">>(
[entity],
query ?? this.options.defaultQuery
);
}
async readManyByReference(
entity: string,
id: PrimaryFieldType,
reference: string,
readMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: E,
query: Partial<RepoQuery> = {}
) {
return this.get<Pick<RepositoryResponse, "meta" | "data">>(
[entity, id, reference],
return this.get<Pick<RepositoryResponse<Data[]>, "meta" | "data">>(
[entity as any],
query ?? this.options.defaultQuery
);
}
async createOne(entity: string, input: EntityData) {
return this.post<RepositoryResponse<EntityData>>([entity], input);
readManyByReference<
E extends keyof DB | string,
R extends keyof DB | string,
Data = R extends keyof DB ? DB[R] : EntityData
>(entity: E, id: PrimaryFieldType, reference: R, query: Partial<RepoQuery> = {}) {
return this.get<Pick<RepositoryResponse<Data[]>, "meta" | "data">>(
[entity as any, id, reference],
query ?? this.options.defaultQuery
);
}
async updateOne(entity: string, id: PrimaryFieldType, input: EntityData) {
return this.patch<RepositoryResponse<EntityData>>([entity, id], input);
createOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: E,
input: Omit<Data, "id">
) {
return this.post<RepositoryResponse<Data>>([entity as any], input);
}
async deleteOne(entity: string, id: PrimaryFieldType) {
return this.delete<RepositoryResponse<EntityData>>([entity, id]);
updateOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: E,
id: PrimaryFieldType,
input: Partial<Omit<Data, "id">>
) {
return this.patch<RepositoryResponse<Data>>([entity as any, id], input);
}
async count(entity: string, where: RepoQuery["where"] = {}) {
return this.post<RepositoryResponse<{ entity: string; count: number }>>(
[entity, "fn", "count"],
deleteOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: E,
id: PrimaryFieldType
) {
return this.delete<RepositoryResponse<Data>>([entity as any, id]);
}
count<E extends keyof DB | string>(entity: E, where: RepoQuery["where"] = {}) {
return this.post<RepositoryResponse<{ entity: E; count: number }>>(
[entity as any, "fn", "count"],
where
);
}
+52 -63
View File
@@ -1,32 +1,26 @@
import { type ClassController, isDebug, tbValidator as tb } from "core";
import { Type, objectCleanEmpty, objectTransform } from "core/utils";
import { isDebug, tbValidator as tb } from "core";
import { StringEnum, Type } from "core/utils";
import {
DataPermissions,
type EntityData,
type EntityManager,
FieldClassMap,
type MutatorResponse,
PrimaryField,
type RepoQuery,
type RepositoryResponse,
TextField,
querySchema
} from "data";
import { Hono } from "hono";
import type { Handler } from "hono/types";
import type { ModuleBuildContext } from "modules";
import { Controller } from "modules/Controller";
import * as SystemPermissions from "modules/permissions";
import { type AppDataConfig, FIELDS } from "../data-schema";
import type { AppDataConfig } from "../data-schema";
export class DataController implements ClassController {
export class DataController extends Controller {
constructor(
private readonly ctx: ModuleBuildContext,
private readonly config: AppDataConfig
) {
/*console.log(
"data controller",
this.em.entities.map((e) => e.name)
);*/
super();
}
get em(): EntityManager<any> {
@@ -74,8 +68,10 @@ export class DataController implements ClassController {
}
}
getController(): Hono<any> {
const hono = new Hono();
override getController() {
const { permission, auth } = this.middlewares;
const hono = this.create().use(auth());
const definedEntities = this.em.entities.map((e) => e.name);
const tbNumber = Type.Transform(Type.String({ pattern: "^[1-9][0-9]{0,}$" }))
.Decode(Number.parseInt)
@@ -89,10 +85,7 @@ export class DataController implements ClassController {
return func;
}
hono.use("*", async (c, next) => {
this.ctx.guard.throwUnlessGranted(SystemPermissions.accessApi);
await next();
});
hono.use("*", permission(SystemPermissions.accessApi));
// info
hono.get(
@@ -104,9 +97,7 @@ export class DataController implements ClassController {
);
// sync endpoint
hono.get("/sync", async (c) => {
this.guard.throwUnlessGranted(DataPermissions.databaseSync);
hono.get("/sync", permission(DataPermissions.databaseSync), async (c) => {
const force = c.req.query("force") === "1";
const drop = c.req.query("drop") === "1";
//console.log("force", force);
@@ -126,10 +117,9 @@ export class DataController implements ClassController {
// fn: count
.post(
"/:entity/fn/count",
permission(DataPermissions.entityRead),
tb("param", Type.Object({ entity: Type.String() })),
async (c) => {
this.guard.throwUnlessGranted(DataPermissions.entityRead);
const { entity } = c.req.valid("param");
if (!this.entityExists(entity)) {
return c.notFound();
@@ -143,10 +133,9 @@ export class DataController implements ClassController {
// fn: exists
.post(
"/:entity/fn/exists",
permission(DataPermissions.entityRead),
tb("param", Type.Object({ entity: Type.String() })),
async (c) => {
this.guard.throwUnlessGranted(DataPermissions.entityRead);
const { entity } = c.req.valid("param");
if (!this.entityExists(entity)) {
return c.notFound();
@@ -163,15 +152,13 @@ export class DataController implements ClassController {
*/
hono
// read entity schema
.get("/schema.json", async (c) => {
this.guard.throwUnlessGranted(DataPermissions.entityRead);
const url = new URL(c.req.url);
const $id = `${url.origin}${this.config.basepath}/schema.json`;
.get("/schema.json", permission(DataPermissions.entityRead), async (c) => {
const $id = `${this.config.basepath}/schema.json`;
const schemas = Object.fromEntries(
this.em.entities.map((e) => [
e.name,
{
$ref: `schemas/${e.name}`
$ref: `${this.config.basepath}/schemas/${e.name}`
}
])
);
@@ -183,22 +170,27 @@ export class DataController implements ClassController {
})
// read schema
.get(
"/schemas/:entity",
tb("param", Type.Object({ entity: Type.String() })),
"/schemas/:entity/:context?",
permission(DataPermissions.entityRead),
tb(
"param",
Type.Object({
entity: Type.String(),
context: Type.Optional(StringEnum(["create", "update"]))
})
),
async (c) => {
this.guard.throwUnlessGranted(DataPermissions.entityRead);
//console.log("request", c.req.raw);
const { entity } = c.req.param();
const { entity, context } = c.req.param();
if (!this.entityExists(entity)) {
console.log("not found", entity, definedEntities);
return c.notFound();
}
const _entity = this.em.entity(entity);
const schema = _entity.toSchema();
const schema = _entity.toSchema({ context } as any);
const url = new URL(c.req.url);
const base = `${url.origin}${this.config.basepath}`;
const $id = `${base}/schemas/${entity}`;
const $id = `${this.config.basepath}/schemas/${entity}`;
return c.json({
$schema: `${base}/schema.json`,
$id,
@@ -211,11 +203,10 @@ export class DataController implements ClassController {
// read many
.get(
"/:entity",
permission(DataPermissions.entityRead),
tb("param", Type.Object({ entity: Type.String() })),
tb("query", querySchema),
async (c) => {
this.guard.throwUnlessGranted(DataPermissions.entityRead);
//console.log("request", c.req.raw);
const { entity } = c.req.param();
if (!this.entityExists(entity)) {
@@ -233,6 +224,7 @@ export class DataController implements ClassController {
// read one
.get(
"/:entity/:id",
permission(DataPermissions.entityRead),
tb(
"param",
Type.Object({
@@ -241,11 +233,7 @@ export class DataController implements ClassController {
})
),
tb("query", querySchema),
/*zValidator("param", z.object({ entity: z.string(), id: z.coerce.number() })),
zValidator("query", repoQuerySchema),*/
async (c) => {
this.guard.throwUnlessGranted(DataPermissions.entityRead);
const { entity, id } = c.req.param();
if (!this.entityExists(entity)) {
return c.notFound();
@@ -259,6 +247,7 @@ export class DataController implements ClassController {
// read many by reference
.get(
"/:entity/:id/:reference",
permission(DataPermissions.entityRead),
tb(
"param",
Type.Object({
@@ -269,8 +258,6 @@ export class DataController implements ClassController {
),
tb("query", querySchema),
async (c) => {
this.guard.throwUnlessGranted(DataPermissions.entityRead);
const { entity, id, reference } = c.req.param();
if (!this.entityExists(entity)) {
return c.notFound();
@@ -287,11 +274,10 @@ export class DataController implements ClassController {
// func query
.post(
"/:entity/query",
permission(DataPermissions.entityRead),
tb("param", Type.Object({ entity: Type.String() })),
tb("json", querySchema),
async (c) => {
this.guard.throwUnlessGranted(DataPermissions.entityRead);
const { entity } = c.req.param();
if (!this.entityExists(entity)) {
return c.notFound();
@@ -309,25 +295,27 @@ export class DataController implements ClassController {
*/
// insert one
hono
.post("/:entity", tb("param", Type.Object({ entity: Type.String() })), async (c) => {
this.guard.throwUnlessGranted(DataPermissions.entityCreate);
.post(
"/:entity",
permission(DataPermissions.entityCreate),
tb("param", Type.Object({ entity: Type.String() })),
async (c) => {
const { entity } = c.req.param();
if (!this.entityExists(entity)) {
return c.notFound();
}
const body = (await c.req.json()) as EntityData;
const result = await this.em.mutator(entity).insertOne(body);
const { entity } = c.req.param();
if (!this.entityExists(entity)) {
return c.notFound();
return c.json(this.mutatorResult(result), 201);
}
const body = (await c.req.json()) as EntityData;
const result = await this.em.mutator(entity).insertOne(body);
return c.json(this.mutatorResult(result), 201);
})
)
// update one
.patch(
"/:entity/:id",
permission(DataPermissions.entityUpdate),
tb("param", Type.Object({ entity: Type.String(), id: tbNumber })),
async (c) => {
this.guard.throwUnlessGranted(DataPermissions.entityUpdate);
const { entity, id } = c.req.param();
if (!this.entityExists(entity)) {
return c.notFound();
@@ -341,6 +329,8 @@ export class DataController implements ClassController {
// delete one
.delete(
"/:entity/:id",
permission(DataPermissions.entityDelete),
tb("param", Type.Object({ entity: Type.String(), id: tbNumber })),
async (c) => {
this.guard.throwUnlessGranted(DataPermissions.entityDelete);
@@ -358,20 +348,19 @@ export class DataController implements ClassController {
// delete many
.delete(
"/:entity",
permission(DataPermissions.entityDelete),
tb("param", Type.Object({ entity: Type.String() })),
tb("json", querySchema.properties.where),
async (c) => {
this.guard.throwUnlessGranted(DataPermissions.entityDelete);
//console.log("request", c.req.raw);
const { entity } = c.req.param();
if (!this.entityExists(entity)) {
return c.notFound();
}
const where = c.req.valid("json") as RepoQuery["where"];
console.log("where", where);
//console.log("where", where);
const result = await this.em.mutator(entity).deleteMany(where);
const result = await this.em.mutator(entity).deleteWhere(where);
return c.json(this.mutatorResult(result));
}
+9 -6
View File
@@ -41,16 +41,18 @@ export type DbFunctions = {
>;
};
export abstract class Connection {
cls = "bknd:connection";
kysely: Kysely<any>;
const CONN_SYMBOL = Symbol.for("bknd:connection");
export abstract class Connection<DB = any> {
kysely: Kysely<DB>;
constructor(
kysely: Kysely<any>,
kysely: Kysely<DB>,
public fn: Partial<DbFunctions> = {},
protected plugins: KyselyPlugin[] = []
) {
this.kysely = kysely;
this[CONN_SYMBOL] = true;
}
/**
@@ -58,8 +60,9 @@ export abstract class Connection {
* coming from different places
* @param conn
*/
static isConnection(conn: any): conn is Connection {
return conn?.cls === "bknd:connection";
static isConnection(conn: unknown): conn is Connection {
if (!conn) return false;
return conn[CONN_SYMBOL] === true;
}
getIntrospector(): ConnectionIntrospector {
@@ -0,0 +1,7 @@
import { Connection } from "./Connection";
export class DummyConnection extends Connection {
constructor() {
super(undefined as any);
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { type Client, type Config, type InStatement, createClient } from "@libsql/client/web";
import { type Client, type Config, type InStatement, createClient } from "@libsql/client";
import { LibsqlDialect } from "@libsql/kysely-libsql";
import { type DatabaseIntrospector, Kysely, ParseJSONResultsPlugin, sql } from "kysely";
import { FilterNumericKeysPlugin } from "../plugins/FilterNumericKeysPlugin";
+27 -13
View File
@@ -140,7 +140,7 @@ export class Entity<
return this.fields.find((field) => field.name === name);
}
__experimental_replaceField(name: string, field: Field) {
__replaceField(name: string, field: Field) {
const index = this.fields.findIndex((f) => f.name === name);
if (index === -1) {
throw new Error(`Field "${name}" not found on entity "${this.name}"`);
@@ -158,7 +158,7 @@ export class Entity<
}
get label(): string {
return snakeToPascalWithSpaces(this.config.name ?? this.name);
return this.config.name ?? snakeToPascalWithSpaces(this.name);
}
field(name: string): Field | undefined {
@@ -210,20 +210,34 @@ export class Entity<
return true;
}
toSchema(clean?: boolean): object {
const fields = Object.fromEntries(this.fields.map((field) => [field.name, field]));
toSchema(options?: { clean: boolean; context?: "create" | "update" }): object {
let fields: Field[];
switch (options?.context) {
case "create":
case "update":
fields = this.getFillableFields(options.context);
break;
default:
fields = this.getFields(true);
}
const _fields = Object.fromEntries(fields.map((field) => [field.name, field]));
const schema = Type.Object(
transformObject(fields, (field) => ({
title: field.config.label,
$comment: field.config.description,
$field: field.type,
readOnly: !field.isFillable("update") ? true : undefined,
writeOnly: !field.isFillable("create") ? true : undefined,
...field.toJsonSchema()
}))
transformObject(_fields, (field) => {
//const hidden = field.isHidden(options?.context);
const fillable = field.isFillable(options?.context);
return {
title: field.config.label,
$comment: field.config.description,
$field: field.type,
readOnly: !fillable ? true : undefined,
...field.toJsonSchema()
};
}),
{ additionalProperties: false }
);
return clean ? JSON.parse(JSON.stringify(schema)) : schema;
return options?.clean ? JSON.parse(JSON.stringify(schema)) : schema;
}
toJSON() {
+42 -23
View File
@@ -1,3 +1,4 @@
import type { DB as DefaultDB } from "core";
import { EventManager } from "core/events";
import { sql } from "kysely";
import { Connection } from "../connection/Connection";
@@ -14,7 +15,18 @@ import { SchemaManager } from "../schema/SchemaManager";
import { Entity } from "./Entity";
import { type EntityData, Mutator, Repository } from "./index";
export class EntityManager<DB> {
type EntitySchema<
TBD extends object = DefaultDB,
E extends Entity | keyof TBD | string = string
> = E extends Entity<infer Name>
? Name extends keyof TBD
? Name
: never
: E extends keyof TBD
? E
: never;
export class EntityManager<TBD extends object = DefaultDB> {
connection: Connection;
private _entities: Entity[] = [];
@@ -50,7 +62,7 @@ export class EntityManager<DB> {
* Forks the EntityManager without the EventManager.
* This is useful when used inside an event handler.
*/
fork(): EntityManager<DB> {
fork(): EntityManager {
return new EntityManager(this._entities, this.connection, this._relations, this._indices);
}
@@ -87,10 +99,27 @@ export class EntityManager<DB> {
this.entities.push(entity);
}
entity(name: string): Entity {
const entity = this.entities.find((e) => e.name === name);
__replaceEntity(entity: Entity, name: string | undefined = entity.name) {
const entityIndex = this._entities.findIndex((e) => e.name === name);
if (entityIndex === -1) {
throw new Error(`Entity "${name}" not found and cannot be replaced`);
}
this._entities[entityIndex] = entity;
// caused issues because this.entity() was using a reference (for when initial config was given)
}
entity(e: Entity | keyof TBD | string): Entity {
// make sure to always retrieve by name
const entity = this.entities.find((entity) =>
e instanceof Entity ? entity.name === e.name : entity.name === e
);
if (!entity) {
throw new EntityNotDefinedException(name);
// @ts-ignore
throw new EntityNotDefinedException(e instanceof Entity ? e.name : e);
}
return entity;
@@ -162,28 +191,18 @@ export class EntityManager<DB> {
return this.relations.relationReferencesOf(this.entity(entity_name));
}
repository(_entity: Entity | string) {
const entity = _entity instanceof Entity ? _entity : this.entity(_entity);
return new Repository(this, entity, this.emgr);
repository<E extends Entity | keyof TBD | string>(
entity: E
): Repository<TBD, EntitySchema<TBD, E>> {
return this.repo(entity);
}
repo<E extends Entity>(
_entity: E
): Repository<
DB,
E extends Entity<infer Name> ? (Name extends keyof DB ? Name : never) : never
> {
return new Repository(this, _entity, this.emgr);
repo<E extends Entity | keyof TBD | string>(entity: E): Repository<TBD, EntitySchema<TBD, E>> {
return new Repository(this, this.entity(entity), this.emgr);
}
_repo<TB extends keyof DB>(_entity: TB): Repository<DB, TB> {
const entity = this.entity(_entity as any);
return new Repository(this, entity, this.emgr);
}
mutator(_entity: Entity | string) {
const entity = _entity instanceof Entity ? _entity : this.entity(_entity);
return new Mutator(this, entity, this.emgr);
mutator<E extends Entity | keyof TBD | string>(entity: E): Mutator<TBD, EntitySchema<TBD, E>> {
return new Mutator(this, this.entity(entity), this.emgr);
}
addIndex(index: EntityIndex, force = false) {
+73 -22
View File
@@ -1,4 +1,4 @@
import type { PrimaryFieldType } from "core";
import type { DB as DefaultDB, PrimaryFieldType } from "core";
import { type EmitsEvents, EventManager } from "core/events";
import type { DeleteQueryBuilder, InsertQueryBuilder, UpdateQueryBuilder } from "kysely";
import { type TActionContext, WhereBuilder } from "..";
@@ -25,8 +25,14 @@ export type MutatorResponse<T = EntityData[]> = {
data: T;
};
export class Mutator<DB> implements EmitsEvents {
em: EntityManager<DB>;
export class Mutator<
TBD extends object = DefaultDB,
TB extends keyof TBD = any,
Output = TBD[TB],
Input = Omit<Output, "id">
> implements EmitsEvents
{
em: EntityManager<TBD>;
entity: Entity;
static readonly Events = MutatorEvents;
emgr: EventManager<typeof MutatorEvents>;
@@ -37,7 +43,7 @@ export class Mutator<DB> implements EmitsEvents {
this.__unstable_disable_system_entity_creation = value;
}
constructor(em: EntityManager<DB>, entity: Entity, emgr?: EventManager<any>) {
constructor(em: EntityManager<TBD>, entity: Entity, emgr?: EventManager<any>) {
this.em = em;
this.entity = entity;
this.emgr = emgr ?? new EventManager(MutatorEvents);
@@ -47,13 +53,13 @@ export class Mutator<DB> implements EmitsEvents {
return this.em.connection.kysely;
}
async getValidatedData(data: EntityData, context: TActionContext): Promise<EntityData> {
async getValidatedData<Given = any>(data: Given, context: TActionContext): Promise<Given> {
const entity = this.entity;
if (!context) {
throw new Error("Context must be provided for validation");
}
const keys = Object.keys(data);
const keys = Object.keys(data as any);
const validatedData: EntityData = {};
// get relational references/keys
@@ -95,7 +101,7 @@ export class Mutator<DB> implements EmitsEvents {
throw new Error(`No data left to update "${entity.name}"`);
}
return validatedData;
return validatedData as Given;
}
protected async many(qb: MutatorQB): Promise<MutatorResponse> {
@@ -120,7 +126,7 @@ export class Mutator<DB> implements EmitsEvents {
return { ...response, data: data[0]! };
}
async insertOne(data: EntityData): Promise<MutatorResponse<EntityData>> {
async insertOne(data: Input): Promise<MutatorResponse<Output>> {
const entity = this.entity;
if (entity.type === "system" && this.__unstable_disable_system_entity_creation) {
throw new Error(`Creation of system entity "${entity.name}" is disabled`);
@@ -154,10 +160,10 @@ export class Mutator<DB> implements EmitsEvents {
await this.emgr.emit(new Mutator.Events.MutatorInsertAfter({ entity, data: res.data }));
return res;
return res as any;
}
async updateOne(id: PrimaryFieldType, data: EntityData): Promise<MutatorResponse<EntityData>> {
async updateOne(id: PrimaryFieldType, data: Partial<Input>): Promise<MutatorResponse<Output>> {
const entity = this.entity;
if (!Number.isInteger(id)) {
throw new Error("ID must be provided for update");
@@ -166,12 +172,16 @@ export class Mutator<DB> implements EmitsEvents {
const validatedData = await this.getValidatedData(data, "update");
await this.emgr.emit(
new Mutator.Events.MutatorUpdateBefore({ entity, entityId: id, data: validatedData })
new Mutator.Events.MutatorUpdateBefore({
entity,
entityId: id,
data: validatedData as any
})
);
const query = this.conn
.updateTable(entity.name)
.set(validatedData)
.set(validatedData as any)
.where(entity.id().name, "=", id)
.returning(entity.getSelect());
@@ -181,10 +191,10 @@ export class Mutator<DB> implements EmitsEvents {
new Mutator.Events.MutatorUpdateAfter({ entity, entityId: id, data: res.data })
);
return res;
return res as any;
}
async deleteOne(id: PrimaryFieldType): Promise<MutatorResponse<EntityData>> {
async deleteOne(id: PrimaryFieldType): Promise<MutatorResponse<Output>> {
const entity = this.entity;
if (!Number.isInteger(id)) {
throw new Error("ID must be provided for deletion");
@@ -203,7 +213,7 @@ export class Mutator<DB> implements EmitsEvents {
new Mutator.Events.MutatorDeleteAfter({ entity, entityId: id, data: res.data })
);
return res;
return res as any;
}
private getValidOptions(options?: Partial<RepoQuery>): Partial<RepoQuery> {
@@ -250,21 +260,62 @@ export class Mutator<DB> implements EmitsEvents {
}
// @todo: decide whether entries should be deleted all at once or one by one (for events)
async deleteMany(where?: RepoQuery["where"]): Promise<MutatorResponse<EntityData>> {
async deleteWhere(where?: RepoQuery["where"]): Promise<MutatorResponse<Output[]>> {
const entity = this.entity;
const qb = this.appendWhere(this.conn.deleteFrom(entity.name), where).returning(
entity.getSelect()
);
//await this.emgr.emit(new Mutator.Events.MutatorDeleteBefore({ entity, entityId: id }));
return (await this.many(qb)) as any;
}
const res = await this.many(qb);
async updateWhere(
data: Partial<Input>,
where?: RepoQuery["where"]
): Promise<MutatorResponse<Output[]>> {
const entity = this.entity;
const validatedData = await this.getValidatedData(data, "update");
/*await this.emgr.emit(
new Mutator.Events.MutatorDeleteAfter({ entity, entityId: id, data: res.data })
);*/
const query = this.appendWhere(this.conn.updateTable(entity.name), where)
.set(validatedData as any)
.returning(entity.getSelect());
return res;
return (await this.many(query)) as any;
}
async insertMany(data: Input[]): Promise<MutatorResponse<Output[]>> {
const entity = this.entity;
if (entity.type === "system" && this.__unstable_disable_system_entity_creation) {
throw new Error(`Creation of system entity "${entity.name}" is disabled`);
}
const validated: any[] = [];
for (const row of data) {
const validatedData = {
...entity.getDefaultObject(),
...(await this.getValidatedData(row, "create"))
};
// check if required fields are present
const required = entity.getRequiredFields();
for (const field of required) {
if (
typeof validatedData[field.name] === "undefined" ||
validatedData[field.name] === null
) {
throw new Error(`Field "${field.name}" is required`);
}
}
validated.push(validatedData);
}
const query = this.conn
.insertInto(entity.name)
.values(validated)
.returning(entity.getSelect());
return (await this.many(query)) as any;
}
}
+25 -20
View File
@@ -1,4 +1,4 @@
import type { PrimaryFieldType } from "core";
import type { DB as DefaultDB, PrimaryFieldType } from "core";
import { type EmitsEvents, EventManager } from "core/events";
import { type SelectQueryBuilder, sql } from "kysely";
import { cloneDeep } from "lodash-es";
@@ -43,20 +43,22 @@ export type RepositoryExistsResponse = RepositoryRawResponse & {
exists: boolean;
};
export class Repository<DB = any, TB extends keyof DB = any> implements EmitsEvents {
em: EntityManager<DB>;
export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = any>
implements EmitsEvents
{
em: EntityManager<TBD>;
entity: Entity;
static readonly Events = RepositoryEvents;
emgr: EventManager<typeof Repository.Events>;
constructor(em: EntityManager<DB>, entity: Entity, emgr?: EventManager<any>) {
constructor(em: EntityManager<TBD>, entity: Entity, emgr?: EventManager<any>) {
this.em = em;
this.entity = entity;
this.emgr = emgr ?? new EventManager(MutatorEvents);
}
private cloneFor(entity: Entity) {
return new Repository(this.em, entity, this.emgr);
return new Repository(this.em, this.em.entity(entity), this.emgr);
}
private get conn() {
@@ -92,7 +94,10 @@ export class Repository<DB = any, TB extends keyof DB = any> implements EmitsEve
if (invalid.length > 0) {
throw new InvalidSearchParamsException(
`Invalid select field(s): ${invalid.join(", ")}`
);
).context({
entity: entity.name,
valid: validated.select
});
}
validated.select = options.select;
@@ -162,8 +167,7 @@ export class Repository<DB = any, TB extends keyof DB = any> implements EmitsEve
protected async performQuery(qb: RepositoryQB): Promise<RepositoryResponse> {
const entity = this.entity;
const compiled = qb.compile();
/*const { sql, parameters } = qb.compile();
console.log("many", sql, parameters);*/
//console.log("performQuery", compiled.sql, compiled.parameters);
const start = performance.now();
const selector = (as = "count") => this.conn.fn.countAll<number>().as(as);
@@ -202,7 +206,10 @@ export class Repository<DB = any, TB extends keyof DB = any> implements EmitsEve
}
};
} catch (e) {
console.error("many error", e, compiled);
if (e instanceof Error) {
console.error("[ERROR] Repository.performQuery", e.message);
}
throw e;
}
}
@@ -263,13 +270,14 @@ export class Repository<DB = any, TB extends keyof DB = any> implements EmitsEve
qb = qb.orderBy(aliased(options.sort.by), options.sort.dir);
}
//console.log("options", { _options, options, exclude_options });
return { qb, options };
}
async findId(
id: PrimaryFieldType,
_options?: Partial<Omit<RepoQuery, "where" | "limit" | "offset">>
): Promise<RepositoryResponse<DB[TB]>> {
): Promise<RepositoryResponse<TBD[TB] | undefined>> {
const { qb, options } = this.buildQuery(
{
..._options,
@@ -285,20 +293,17 @@ export class Repository<DB = any, TB extends keyof DB = any> implements EmitsEve
async findOne(
where: RepoQuery["where"],
_options?: Partial<Omit<RepoQuery, "where" | "limit" | "offset">>
): Promise<RepositoryResponse<DB[TB] | undefined>> {
const { qb, options } = this.buildQuery(
{
..._options,
where,
limit: 1
},
["offset", "sort"]
);
): Promise<RepositoryResponse<TBD[TB] | undefined>> {
const { qb, options } = this.buildQuery({
..._options,
where,
limit: 1
});
return this.single(qb, options) as any;
}
async findMany(_options?: Partial<RepoQuery>): Promise<RepositoryResponse<DB[TB][]>> {
async findMany(_options?: Partial<RepoQuery>): Promise<RepositoryResponse<TBD[TB][]>> {
const { qb, options } = this.buildQuery(_options);
//console.log("findMany:options", options);
@@ -15,7 +15,6 @@ import type {
SelectQueryBuilder,
UpdateQueryBuilder
} from "kysely";
import type { RepositoryQB } from "./Repository";
type Builder = ExpressionBuilder<any, any>;
type Wrapper = ExpressionWrapper<any, any, any>;
+2 -2
View File
@@ -236,8 +236,8 @@ export abstract class Field<
toJSON() {
return {
//name: this.name,
type: this.type,
// @todo: current workaround because of fixed string type
type: this.type as any,
config: this.config
};
}
+6 -1
View File
@@ -54,7 +54,7 @@ export class JsonSchemaField<
if (parentValid) {
// already checked in parent
if (!value || typeof value !== "object") {
if (!this.isRequired() && (!value || typeof value !== "object")) {
//console.log("jsonschema:valid: not checking", this.name, value, context);
return true;
}
@@ -65,6 +65,7 @@ export class JsonSchemaField<
} else {
//console.log("jsonschema:invalid", this.name, value, context);
}
//console.log("jsonschema:invalid:fromParent", this.name, value, context);
return false;
}
@@ -110,9 +111,13 @@ export class JsonSchemaField<
): Promise<string | undefined> {
const value = await super.transformPersist(_value, em, context);
if (this.nullish(value)) return value;
//console.log("jsonschema:transformPersist", this.name, _value, context);
if (!this.isValid(value)) {
//console.error("jsonschema:transformPersist:invalid", this.name, value);
throw new TransformPersistFailedException(this.name, value);
} else {
//console.log("jsonschema:transformPersist:valid", this.name, value);
}
if (!value || typeof value !== "object") return this.getDefault();
+6
View File
@@ -104,6 +104,12 @@ export class TextField<Required extends true | false = false> extends Field<
);
}
if (this.config.pattern && value && !new RegExp(this.config.pattern).test(value)) {
throw new TransformPersistFailedException(
`Field "${this.name}" must match the pattern ${this.config.pattern}`
);
}
return value;
}
+26 -4
View File
@@ -1,4 +1,4 @@
import type { EntityData, Field } from "data";
import type { EntityData, EntityManager, Field } from "data";
import { transform } from "lodash-es";
export function getDefaultValues(fields: Field[], data: EntityData): EntityData {
@@ -18,6 +18,7 @@ export function getChangeSet(
data: EntityData,
fields: Field[]
): EntityData {
//console.log("getChangeSet", formData, data);
return transform(
formData,
(acc, _value, key) => {
@@ -26,11 +27,12 @@ export function getChangeSet(
if (!field || field.isVirtual()) return;
const value = _value === "" ? null : _value;
const newValue = field.getValue(value, "submit");
// normalize to null if undefined
const newValue = field.getValue(value, "submit") || null;
// @todo: add typing for "action"
if (action === "create" || newValue !== data[key]) {
acc[key] = newValue;
console.log("changed", {
/*console.log("changed", {
key,
value,
valueType: typeof value,
@@ -38,7 +40,7 @@ export function getChangeSet(
newValue,
new: value,
sent: acc[key]
});
});*/
} else {
//console.log("no change", key, value, data[key]);
}
@@ -46,3 +48,23 @@ export function getChangeSet(
{} as typeof formData
);
}
export function readableEmJson(_em: EntityManager) {
return {
entities: _em.entities.map((e) => ({
name: e.name,
fields: e.fields.map((f) => f.name),
type: e.type
})),
indices: _em.indices.map((i) => ({
name: i.name,
entity: i.entity.name,
fields: i.fields.map((f) => f.name),
unique: i.unique
})),
relations: _em.relations.all.map((r) => ({
name: r.getName(),
...r.toJSON()
}))
};
}
+2 -2
View File
@@ -13,13 +13,13 @@ export {
whereSchema
} from "./server/data-query-impl";
export { whereRepoSchema as deprecated__whereRepoSchema } from "./server/query";
export { Connection } from "./connection/Connection";
export { LibsqlConnection, type LibSqlCredentials } from "./connection/LibsqlConnection";
export { SqliteConnection } from "./connection/SqliteConnection";
export { SqliteLocalConnection } from "./connection/SqliteLocalConnection";
export { constructEntity, constructRelation } from "./schema/constructor";
export const DatabaseEvents = {
...MutatorEvents,
...RepositoryEvents
+102 -6
View File
@@ -1,3 +1,8 @@
import { DummyConnection } from "data/connection/DummyConnection";
import { EntityManager } from "data/entities/EntityManager";
import type { Generated } from "kysely";
import { MediaField, type MediaFieldConfig, type MediaItem } from "media/MediaField";
import type { ModuleConfigs } from "modules";
import {
BooleanField,
type BooleanFieldConfig,
@@ -5,6 +10,8 @@ import {
type DateFieldConfig,
Entity,
type EntityConfig,
EntityIndex,
type EntityRelation,
EnumField,
type EnumFieldConfig,
type Field,
@@ -25,15 +32,14 @@ import {
type TEntityType,
TextField,
type TextFieldConfig
} from "data";
import type { Generated } from "kysely";
import { MediaField, type MediaFieldConfig, type MediaItem } from "media/MediaField";
} from "../index";
type Options<Config = any> = {
entity: { name: string; fields: Record<string, Field<any, any, any>> };
field_name: string;
config: Config;
is_required: boolean;
another?: string;
};
const FieldMap = {
@@ -239,7 +245,93 @@ export function relation<Local extends Entity>(local: Local) {
};
}
type InferEntityFields<T> = T extends Entity<infer _N, infer Fields>
export function index<E extends Entity>(entity: E) {
return {
on: (fields: (keyof InsertSchema<E>)[], unique?: boolean) => {
const _fields = fields.map((f) => {
const field = entity.field(f as any);
if (!field) {
throw new Error(`Field "${String(f)}" not found on entity "${entity.name}"`);
}
return field;
});
return new EntityIndex(entity, _fields, unique);
}
};
}
class EntityManagerPrototype<Entities extends Record<string, Entity>> extends EntityManager<
Schema<Entities>
> {
constructor(
public __entities: Entities,
relations: EntityRelation[] = [],
indices: EntityIndex[] = []
) {
super(Object.values(__entities), new DummyConnection(), relations, indices);
}
}
type Chained<R extends Record<string, (...args: any[]) => any>> = {
[K in keyof R]: R[K] extends (...args: any[]) => any
? (...args: Parameters<R[K]>) => Chained<R>
: never;
};
type ChainedFn<
Fn extends (...args: any[]) => Record<string, (...args: any[]) => any>,
Return extends ReturnType<Fn> = ReturnType<Fn>
> = (e: Entity) => {
[K in keyof Return]: (...args: Parameters<Return[K]>) => Chained<Return>;
};
export function em<Entities extends Record<string, Entity>>(
entities: Entities,
schema?: (
fns: { relation: ChainedFn<typeof relation>; index: ChainedFn<typeof index> },
entities: Entities
) => void
) {
const relations: EntityRelation[] = [];
const indices: EntityIndex[] = [];
const relationProxy = (e: Entity) => {
return new Proxy(relation(e), {
get(target, prop) {
return (...args: any[]) => {
relations.push(target[prop](...args));
return relationProxy(e);
};
}
}) as any;
};
const indexProxy = (e: Entity) => {
return new Proxy(index(e), {
get(target, prop) {
return (...args: any[]) => {
indices.push(target[prop](...args));
return indexProxy(e);
};
}
}) as any;
};
if (schema) {
schema({ relation: relationProxy, index: indexProxy }, entities);
}
const e = new EntityManagerPrototype(entities, relations, indices);
return {
DB: e.__entities as unknown as Schemas<Entities>,
entities: e.__entities,
relations,
indices,
toJSON: () =>
e.toJSON() as unknown as Pick<ModuleConfigs["data"], "entities" | "relations" | "indices">
};
}
export type InferEntityFields<T> = T extends Entity<infer _N, infer Fields>
? {
[K in keyof Fields]: Fields[K] extends { _type: infer Type; _required: infer Required }
? Required extends true
@@ -284,12 +376,16 @@ type OptionalUndefined<
}
>;
type InferField<Field> = Field extends { _type: infer Type; _required: infer Required }
export type InferField<Field> = Field extends { _type: infer Type; _required: infer Required }
? Required extends true
? Type
: Type | undefined
: never;
export type Schemas<T extends Record<string, Entity>> = {
[K in keyof T]: Schema<T[K]>;
};
export type InsertSchema<T> = Simplify<OptionalUndefined<InferEntityFields<T>>>;
export type Schema<T> = { id: Generated<number> } & InsertSchema<T>;
export type Schema<T> = Simplify<{ id: Generated<number> } & InsertSchema<T>>;
export type FieldSchema<T> = Simplify<OptionalUndefined<InferFields<T>>>;
+34
View File
@@ -0,0 +1,34 @@
import { transformObject } from "core/utils";
import { Entity, type Field } from "data";
import { FIELDS, RELATIONS, type TAppDataEntity, type TAppDataRelation } from "data/data-schema";
export function constructEntity(name: string, entityConfig: TAppDataEntity) {
const fields = transformObject(entityConfig.fields ?? {}, (fieldConfig, name) => {
const { type } = fieldConfig;
if (!(type in FIELDS)) {
throw new Error(`Field type "${type}" not found`);
}
const { field } = FIELDS[type as any];
const returnal = new field(name, fieldConfig.config) as Field;
return returnal;
});
return new Entity(
name,
Object.values(fields),
entityConfig.config as any,
entityConfig.type as any
);
}
export function constructRelation(
relationConfig: TAppDataRelation,
resolver: (name: Entity | string) => Entity
) {
return new RELATIONS[relationConfig.type].cls(
resolver(relationConfig.source),
resolver(relationConfig.target),
relationConfig.config
);
}
+11 -4
View File
@@ -6,7 +6,6 @@ import {
Type,
Value
} from "core/utils";
import type { Simplify } from "type-fest";
import { WhereBuilder } from "../entities";
const NumberOrString = (options: SchemaOptions = {}) =>
@@ -19,17 +18,25 @@ const limit = NumberOrString({ default: 10 });
const offset = NumberOrString({ default: 0 });
// @todo: allow "id" and "-id"
const sort_default = { by: "id", dir: "asc" };
const sort = Type.Transform(
Type.Union(
[Type.String(), Type.Object({ by: Type.String(), dir: StringEnum(["asc", "desc"]) })],
{
default: { by: "id", dir: "asc" }
default: sort_default
}
)
)
.Decode((value) => {
if (typeof value === "string") {
return JSON.parse(value);
if (/^-?[a-zA-Z_][a-zA-Z0-9_.]*$/.test(value)) {
const dir = value[0] === "-" ? "desc" : "asc";
return { by: dir === "desc" ? value.slice(1) : value, dir };
} else if (/^{.*}$/.test(value)) {
return JSON.parse(value);
}
return sort_default;
}
return value;
})
@@ -72,6 +79,6 @@ export const querySchema = Type.Object(
}
);
export type RepoQueryIn = Simplify<Static<typeof querySchema>>;
export type RepoQueryIn = Static<typeof querySchema>;
export type RepoQuery = Required<StaticDecode<typeof querySchema>>;
export const defaultQuerySchema = Value.Default(querySchema, {}) as RepoQuery;
-112
View File
@@ -1,112 +0,0 @@
import { z } from "zod";
const date = z.union([z.date(), z.string()]);
const numeric = z.union([z.number(), date]);
const boolean = z.union([z.boolean(), z.literal(1), z.literal(0)]);
const value = z.union([z.string(), boolean, numeric]);
const expressionCond = z.union([
z.object({ $eq: value }).strict(),
z.object({ $ne: value }).strict(),
z.object({ $isnull: boolean }).strict(),
z.object({ $notnull: boolean }).strict(),
z.object({ $in: z.array(value) }).strict(),
z.object({ $notin: z.array(value) }).strict(),
z.object({ $gt: numeric }).strict(),
z.object({ $gte: numeric }).strict(),
z.object({ $lt: numeric }).strict(),
z.object({ $lte: numeric }).strict(),
z.object({ $between: z.array(numeric).min(2).max(2) }).strict()
] as const);
// prettier-ignore
const nonOperandString = z
.string()
.regex(/^(?!\$).*/)
.min(1);
// {name: 'Michael'}
const literalCond = z.record(nonOperandString, value);
// { status: { $eq: 1 } }
const literalExpressionCond = z.record(nonOperandString, value.or(expressionCond));
const operandCond = z
.object({
$and: z.array(literalCond.or(expressionCond).or(literalExpressionCond)).optional(),
$or: z.array(literalCond.or(expressionCond).or(literalExpressionCond)).optional()
})
.strict();
const literalSchema = literalCond.or(literalExpressionCond);
export type LiteralSchemaIn = z.input<typeof literalSchema>;
export type LiteralSchema = z.output<typeof literalSchema>;
export const filterSchema = literalSchema.or(operandCond);
export type FilterSchemaIn = z.input<typeof filterSchema>;
export type FilterSchema = z.output<typeof filterSchema>;
const stringArray = z
.union([
z.string().transform((v) => {
if (v.includes(",")) return v.split(",");
return v;
}),
z.array(z.string())
])
.default([])
.transform((v) => (Array.isArray(v) ? v : [v]));
export const whereRepoSchema = z
.preprocess((v: unknown) => {
try {
return JSON.parse(v as string);
} catch {
return v;
}
}, filterSchema)
.default({});
const repoQuerySchema = z.object({
limit: z.coerce.number().default(10),
offset: z.coerce.number().default(0),
sort: z
.preprocess(
(v: unknown) => {
try {
return JSON.parse(v as string);
} catch {
return v;
}
},
z.union([
z.string().transform((v) => {
if (v.includes(":")) {
let [field, dir] = v.split(":") as [string, string];
if (!["asc", "desc"].includes(dir)) dir = "asc";
return { by: field, dir } as { by: string; dir: "asc" | "desc" };
} else {
return { by: v, dir: "asc" } as { by: string; dir: "asc" | "desc" };
}
}),
z.object({
by: z.string(),
dir: z.enum(["asc", "desc"])
})
])
)
.default({ by: "id", dir: "asc" }),
select: stringArray,
with: stringArray,
join: stringArray,
debug: z
.preprocess((v) => {
if (["0", "false"].includes(String(v))) return false;
return Boolean(v);
}, z.boolean())
.default(false), //z.coerce.boolean().catch(false),
where: whereRepoSchema
});
type RepoQueryIn = z.input<typeof repoQuerySchema>;
type RepoQuery = z.output<typeof repoQuerySchema>;
+8 -4
View File
@@ -1,12 +1,16 @@
export { App, type AppConfig, type CreateAppConfig } from "./App";
export { App, createApp, AppEvents, type AppConfig, type CreateAppConfig } from "./App";
export { MediaField } from "media/MediaField";
export {
getDefaultConfig,
getDefaultSchema,
type ModuleConfigs,
type ModuleSchemas
} from "modules/ModuleManager";
type ModuleSchemas,
type ModuleManagerOptions,
type ModuleBuildContext
} from "./modules/ModuleManager";
export * as middlewares from "modules/middlewares";
export { registries } from "modules/registries";
export type * from "./adapter";
export { Api, type ApiOptions } from "./Api";
+21 -27
View File
@@ -1,12 +1,12 @@
import { EntityIndex, type EntityManager } from "data";
import type { PrimaryFieldType } from "core";
import { type Entity, EntityIndex, type EntityManager } from "data";
import { type FileUploadedEventData, Storage, type StorageAdapter } from "media";
import { Module } from "modules/Module";
import {
type FieldSchema,
type InferFields,
type Schema,
boolean,
datetime,
em,
entity,
json,
number,
@@ -16,9 +16,9 @@ import { MediaController } from "./api/MediaController";
import { ADAPTERS, buildMediaSchema, type mediaConfigSchema, registry } from "./media-schema";
export type MediaFieldSchema = FieldSchema<typeof AppMedia.mediaFields>;
declare global {
declare module "core" {
interface DB {
media: MediaFieldSchema;
media: { id: PrimaryFieldType } & MediaFieldSchema;
}
}
@@ -47,18 +47,12 @@ export class AppMedia extends Module<typeof mediaConfigSchema> {
this.setupListeners();
this.ctx.server.route(this.basepath, new MediaController(this).getController());
// @todo: add check for media entity
const mediaEntity = this.getMediaEntity();
if (!this.ctx.em.hasEntity(mediaEntity)) {
this.ctx.em.addEntity(mediaEntity);
}
const pathIndex = new EntityIndex(mediaEntity, [mediaEntity.field("path")!], true);
if (!this.ctx.em.hasIndex(pathIndex)) {
this.ctx.em.addIndex(pathIndex);
}
// @todo: check indices
const media = this.getMediaEntity(true);
this.ensureSchema(
em({ [media.name as "media"]: media }, ({ index }, { media }) => {
index(media).on(["path"], true).on(["reference"]);
})
);
} catch (e) {
console.error(e);
throw new Error(
@@ -103,23 +97,23 @@ export class AppMedia extends Module<typeof mediaConfigSchema> {
metadata: json()
};
getMediaEntity() {
getMediaEntity(forceCreate?: boolean): Entity<"media", typeof AppMedia.mediaFields> {
const entity_name = this.config.entity_name;
if (!this.em.hasEntity(entity_name)) {
return entity(entity_name, AppMedia.mediaFields, undefined, "system");
if (forceCreate || !this.em.hasEntity(entity_name)) {
return entity(entity_name as "media", AppMedia.mediaFields, undefined, "system");
}
return this.em.entity(entity_name);
return this.em.entity(entity_name) as any;
}
get em(): EntityManager<DB> {
get em(): EntityManager {
return this.ctx.em;
}
private setupListeners() {
//const media = this._entity;
const { emgr, em } = this.ctx;
const media = this.getMediaEntity();
const media = this.getMediaEntity().name as "media";
// when file is uploaded, sync with media entity
// @todo: need a way for singleton events!
@@ -140,10 +134,10 @@ export class AppMedia extends Module<typeof mediaConfigSchema> {
Storage.Events.FileDeletedEvent,
async (e) => {
// simple file deletion sync
const item = await em.repo(media).findOne({ path: e.params.name });
if (item.data) {
console.log("item.data", item.data);
await em.mutator(media).deleteOne(item.data.id);
const { data } = await em.repo(media).findOne({ path: e.params.name });
if (data) {
console.log("item.data", data);
await em.mutator(media).deleteOne(data.id);
}
console.log("App:storage:file deleted", e);
+4 -4
View File
@@ -10,11 +10,11 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
};
}
async getFiles() {
getFiles() {
return this.get(["files"]);
}
async getFile(filename: string) {
getFile(filename: string) {
return this.get(["file", filename]);
}
@@ -32,13 +32,13 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
});
}
async uploadFile(file: File) {
uploadFile(file: File) {
const formData = new FormData();
formData.append("file", file);
return this.post(["upload"], formData);
}
async deleteFile(filename: string) {
deleteFile(filename: string) {
return this.delete(["file", filename]);
}
}
+24 -23
View File
@@ -1,10 +1,9 @@
import { type ClassController, tbValidator as tb } from "core";
import { tbValidator as tb } from "core";
import { Type } from "core/utils";
import { Hono } from "hono";
import { bodyLimit } from "hono/body-limit";
import type { StorageAdapter } from "media";
import { StorageEvents } from "media";
import { getRandomizedFilename } from "media";
import { StorageEvents, getRandomizedFilename } from "media";
import { Controller } from "modules/Controller";
import type { AppMedia } from "../AppMedia";
import { MediaField } from "../MediaField";
@@ -12,8 +11,10 @@ const booleanLike = Type.Transform(Type.String())
.Decode((v) => v === "1")
.Encode((v) => (v ? "1" : "0"));
export class MediaController implements ClassController {
constructor(private readonly media: AppMedia) {}
export class MediaController extends Controller {
constructor(private readonly media: AppMedia) {
super();
}
private getStorageAdapter(): StorageAdapter {
return this.getStorage().getAdapter();
@@ -23,11 +24,11 @@ export class MediaController implements ClassController {
return this.media.storage;
}
getController(): Hono<any> {
override getController() {
// @todo: multiple providers?
// @todo: implement range requests
const hono = new Hono();
const { auth } = this.middlewares;
const hono = this.create().use(auth());
// get files list (temporary)
hono.get("/files", async (c) => {
@@ -107,7 +108,7 @@ export class MediaController implements ClassController {
return c.json({ error: `Invalid field "${field_name}"` }, 400);
}
const mediaEntity = this.media.getMediaEntity();
const media_entity = this.media.getMediaEntity().name as "media";
const reference = `${entity_name}.${field_name}`;
const mediaRef = {
scope: field_name,
@@ -117,11 +118,10 @@ export class MediaController implements ClassController {
// check max items
const max_items = field.getMaxItems();
const ids_to_delete: number[] = [];
const id_field = mediaEntity.getPrimaryField().name;
const paths_to_delete: string[] = [];
if (max_items) {
const { overwrite } = c.req.valid("query");
const { count } = await this.media.em.repository(mediaEntity).count(mediaRef);
const { count } = await this.media.em.repository(media_entity).count(mediaRef);
// if there are more than or equal to max items
if (count >= max_items) {
@@ -140,18 +140,18 @@ export class MediaController implements ClassController {
}
// collect items to delete
const deleteRes = await this.media.em.repo(mediaEntity).findMany({
select: [id_field],
const deleteRes = await this.media.em.repo(media_entity).findMany({
select: ["path"],
where: mediaRef,
sort: {
by: id_field,
by: "id",
dir: "asc"
},
limit: count - max_items + 1
});
if (deleteRes.data && deleteRes.data.length > 0) {
deleteRes.data.map((item) => ids_to_delete.push(item[id_field]));
deleteRes.data.map((item) => paths_to_delete.push(item.path));
}
}
}
@@ -169,19 +169,20 @@ export class MediaController implements ClassController {
const file_name = getRandomizedFilename(file as File);
const info = await this.getStorage().uploadFile(file, file_name, true);
const mutator = this.media.em.mutator(mediaEntity);
const mutator = this.media.em.mutator(media_entity);
mutator.__unstable_toggleSystemEntityCreation(false);
const result = await mutator.insertOne({
...this.media.uploadedEventDataToMediaPayload(info),
...mediaRef
});
} as any);
mutator.__unstable_toggleSystemEntityCreation(true);
// delete items if needed
if (ids_to_delete.length > 0) {
await this.media.em
.mutator(mediaEntity)
.deleteMany({ [id_field]: { $in: ids_to_delete } });
if (paths_to_delete.length > 0) {
// delete files from db & adapter
for (const path of paths_to_delete) {
await this.getStorage().deleteFile(path);
}
}
return c.json({ ok: true, result: result.data, ...info });
+6 -14
View File
@@ -17,10 +17,6 @@ import {
import { type S3AdapterConfig, StorageS3Adapter } from "./storage/adapters/StorageS3Adapter";
export { StorageS3Adapter, type S3AdapterConfig, StorageCloudinaryAdapter, type CloudinaryConfig };
/*export {
StorageLocalAdapter,
type LocalAdapterConfig
} from "./storage/adapters/StorageLocalAdapter";*/
export * as StorageEvents from "./storage/events";
export { type FileUploadedEventData } from "./storage/events";
@@ -31,16 +27,12 @@ type ClassThatImplements<T> = Constructor<T> & { prototype: T };
export const MediaAdapterRegistry = new Registry<{
cls: ClassThatImplements<StorageAdapter>;
schema: TObject;
}>().set({
s3: {
cls: StorageS3Adapter,
schema: StorageS3Adapter.prototype.getSchema()
},
cloudinary: {
cls: StorageCloudinaryAdapter,
schema: StorageCloudinaryAdapter.prototype.getSchema()
}
});
}>((cls: ClassThatImplements<StorageAdapter>) => ({
cls,
schema: cls.prototype.getSchema() as TObject
}))
.register("s3", StorageS3Adapter)
.register("cloudinary", StorageCloudinaryAdapter);
export const Adapters = {
s3: {
+2 -1
View File
@@ -1,4 +1,4 @@
import { Const, Type, objectTransform } from "core/utils";
import { Const, type Static, Type, objectTransform } from "core/utils";
import { Adapters } from "media";
import { registries } from "modules/registries";
@@ -47,3 +47,4 @@ export function buildMediaSchema() {
}
export const mediaConfigSchema = buildMediaSchema();
export type TAppMediaConfig = Static<typeof mediaConfigSchema>;

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