Merge pull request #47 from bknd-io/release/0.6

Release 0.6
This commit is contained in:
dswbx
2025-01-21 08:04:39 +01:00
committed by GitHub
153 changed files with 3185 additions and 1470 deletions

View File

@@ -1,4 +1,8 @@
![bknd](docs/_assets/poster.png) [![npm version](https://img.shields.io/npm/v/bknd.svg)](https://npmjs.org/package/bknd
"View this project on NPM")
[![npm downloads](https://img.shields.io/npm/dm/bknd)](https://www.npmjs.com/package/bknd)
![bknd](https://raw.githubusercontent.com/bknd-io/bknd/refs/heads/main/docs/_assets/poster.png)
bknd simplifies app development by providing fully functional backend for data management, 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 authentication, workflows and media. Since it's lightweight and built on Web Standards, it can

View File

@@ -0,0 +1,70 @@
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { Guard } from "../../src/auth";
import { parse } from "../../src/core/utils";
import { DataApi } from "../../src/data/api/DataApi";
import { DataController } from "../../src/data/api/DataController";
import { dataConfigSchema } from "../../src/data/data-schema";
import * as proto from "../../src/data/prototype";
import { disableConsoleLog, enableConsoleLog, schemaToEm } from "../helper";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
const dataConfig = parse(dataConfigSchema, {});
describe("DataApi", () => {
it("should switch to post for long url reads", async () => {
const api = new DataApi();
const get = api.readMany("a".repeat(300), { select: ["id", "name"] });
expect(get.request.method).toBe("GET");
expect(new URL(get.request.url).pathname).toBe(`/api/data/${"a".repeat(300)}`);
const post = api.readMany("a".repeat(1000), { select: ["id", "name"] });
expect(post.request.method).toBe("POST");
expect(new URL(post.request.url).pathname).toBe(`/api/data/${"a".repeat(1000)}/query`);
});
it("returns result", async () => {
const schema = proto.em({
posts: proto.entity("posts", { title: proto.text() })
});
const em = schemaToEm(schema);
await em.schema().sync({ force: true });
const payload = [{ title: "foo" }, { title: "bar" }, { title: "baz" }];
await em.mutator("posts").insertMany(payload);
const ctx: any = { em, guard: new Guard() };
const controller = new DataController(ctx, dataConfig);
const app = controller.getController();
{
const res = (await app.request("/posts")) as Response;
const { data } = await res.json();
expect(data.length).toEqual(3);
}
// @ts-ignore tests
const api = new DataApi({ basepath: "/", queryLengthLimit: 50 });
// @ts-ignore protected
api.fetcher = app.request as typeof fetch;
{
const req = api.readMany("posts", { select: ["title"] });
expect(req.request.method).toBe("GET");
const res = await req;
expect(res.data).toEqual(payload);
}
{
const req = api.readMany("posts", {
select: ["title"],
limit: 100000,
offset: 0,
sort: "id"
});
expect(req.request.method).toBe("POST");
const res = await req;
expect(res.data).toEqual(payload);
}
});
});

View File

@@ -28,6 +28,8 @@ describe("ModuleApi", () => {
it("fetches endpoint", async () => { it("fetches endpoint", async () => {
const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" })); const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" }));
const api = new Api({ host }); const api = new Api({ host });
// @ts-expect-error it's protected
api.fetcher = app.request as typeof fetch; api.fetcher = app.request as typeof fetch;
const res = await api.get("/endpoint"); const res = await api.get("/endpoint");
@@ -40,6 +42,8 @@ describe("ModuleApi", () => {
it("has accessible request", async () => { it("has accessible request", async () => {
const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" })); const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" }));
const api = new Api({ host }); const api = new Api({ host });
// @ts-expect-error it's protected
api.fetcher = app.request as typeof fetch; api.fetcher = app.request as typeof fetch;
const promise = api.get("/endpoint"); const promise = api.get("/endpoint");

View File

@@ -0,0 +1,73 @@
import { describe, expect, test } from "bun:test";
import { createApp, registries } from "../../src";
import * as proto from "../../src/data/prototype";
import { StorageLocalAdapter } from "../../src/media/storage/adapters/StorageLocalAdapter";
describe("repros", async () => {
/**
* steps:
* 1. enable media
* 2. create 'test' entity
* 3. add media to 'test'
*
* There was an issue that AppData had old configs because of system entity "media"
*/
test("registers media entity correctly to relate to it", async () => {
registries.media.register("local", StorageLocalAdapter);
const app = createApp();
await app.build();
{
// 1. enable media
const [, config] = await app.module.media.schema().patch("", {
enabled: true,
adapter: {
type: "local",
config: {
path: "./"
}
}
});
expect(config.enabled).toBe(true);
}
{
// 2. create 'test' entity
await app.module.data.schema().patch(
"entities.test",
proto
.entity("test", {
content: proto.text()
})
.toJSON()
);
expect(app.em.entities.map((e) => e.name)).toContain("test");
}
{
await app.module.data.schema().patch("entities.test.fields.files", {
type: "media",
config: {
required: false,
fillable: ["update"],
hidden: false,
mime_types: [],
virtual: true,
entity: "test"
}
});
expect(
app.module.data.schema().patch("relations.000", {
type: "poly",
source: "test",
target: "media",
config: { mappedBy: "files" }
})
).resolves.toBeDefined();
}
expect(app.em.entities.map((e) => e.name)).toEqual(["media", "test"]);
});
});

View File

@@ -1,8 +1,12 @@
import { describe, expect, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test";
import { Event, EventManager, NoParamEvent } from "../../src/core/events"; import { Event, EventManager, InvalidEventReturn, NoParamEvent } from "../../src/core/events";
import { disableConsoleLog, enableConsoleLog } from "../helper";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
class SpecialEvent extends Event<{ foo: string }> { class SpecialEvent extends Event<{ foo: string }> {
static slug = "special-event"; static override slug = "special-event";
isBar() { isBar() {
return this.params.foo === "bar"; return this.params.foo === "bar";
@@ -10,37 +14,139 @@ class SpecialEvent extends Event<{ foo: string }> {
} }
class InformationalEvent extends NoParamEvent { class InformationalEvent extends NoParamEvent {
static slug = "informational-event"; static override slug = "informational-event";
}
class ReturnEvent extends Event<{ foo: string }, string> {
static override slug = "return-event";
override validate(value: string) {
if (typeof value !== "string") {
throw new InvalidEventReturn("string", typeof value);
}
return this.clone({
foo: [this.params.foo, value].join("-")
});
}
} }
describe("EventManager", async () => { describe("EventManager", async () => {
test("test", async () => { test("executes", async () => {
const call = mock(() => null);
const delayed = mock(() => null);
const emgr = new EventManager(); const emgr = new EventManager();
emgr.registerEvents([SpecialEvent, InformationalEvent]); emgr.registerEvents([SpecialEvent, InformationalEvent]);
expect(emgr.eventExists("special-event")).toBe(true);
expect(emgr.eventExists("informational-event")).toBe(true);
expect(emgr.eventExists("unknown-event")).toBe(false);
emgr.onEvent( emgr.onEvent(
SpecialEvent, SpecialEvent,
async (event, name) => { async (event, name) => {
console.log("Event: ", name, event.params.foo, event.isBar()); expect(name).toBe("special-event");
console.log("wait..."); expect(event.isBar()).toBe(true);
call();
await new Promise((resolve) => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 50));
console.log("done waiting"); delayed();
}, },
"sync" "sync"
); );
// don't allow unknown
expect(() => emgr.on("unknown", () => void 0)).toThrow();
emgr.onEvent(InformationalEvent, async (event, name) => { emgr.onEvent(InformationalEvent, async (event, name) => {
console.log("Event: ", name, event.params); call();
expect(name).toBe("informational-event");
}); });
await emgr.emit(new SpecialEvent({ foo: "bar" })); await emgr.emit(new SpecialEvent({ foo: "bar" }));
console.log("done"); await emgr.emit(new InformationalEvent());
// expect construct signatures to not cause ts errors // expect construct signatures to not cause ts errors
new SpecialEvent({ foo: "bar" }); new SpecialEvent({ foo: "bar" });
new InformationalEvent(); new InformationalEvent();
expect(true).toBe(true); expect(call).toHaveBeenCalledTimes(2);
expect(delayed).toHaveBeenCalled();
});
test("custom async executor", async () => {
const call = mock(() => null);
const asyncExecutor = (p: Promise<any>[]) => {
call();
return Promise.all(p);
};
const emgr = new EventManager(
{ InformationalEvent },
{
asyncExecutor
}
);
emgr.onEvent(InformationalEvent, async () => {});
await emgr.emit(new InformationalEvent());
expect(call).toHaveBeenCalled();
});
test("piping", async () => {
const onInvalidReturn = mock(() => null);
const asyncEventCallback = mock(() => null);
const emgr = new EventManager(
{ ReturnEvent, InformationalEvent },
{
onInvalidReturn
}
);
// @ts-expect-error InformationalEvent has no return value
emgr.onEvent(InformationalEvent, async () => {
asyncEventCallback();
return 1;
});
emgr.onEvent(ReturnEvent, async () => "1", "sync");
emgr.onEvent(ReturnEvent, async () => "0", "sync");
// @ts-expect-error must be string
emgr.onEvent(ReturnEvent, async () => 0, "sync");
// return is not required
emgr.onEvent(ReturnEvent, async () => {}, "sync");
// was "async", will not return
const e1 = await emgr.emit(new InformationalEvent());
expect(e1.returned).toBe(false);
const e2 = await emgr.emit(new ReturnEvent({ foo: "bar" }));
expect(e2.returned).toBe(true);
expect(e2.params.foo).toBe("bar-1-0");
expect(onInvalidReturn).toHaveBeenCalled();
expect(asyncEventCallback).toHaveBeenCalled();
});
test("once", async () => {
const call = mock(() => null);
const emgr = new EventManager({ InformationalEvent });
emgr.onEvent(
InformationalEvent,
async (event, slug) => {
expect(event).toBeInstanceOf(InformationalEvent);
expect(slug).toBe("informational-event");
call();
},
{ mode: "sync", once: true }
);
expect(emgr.getListeners().length).toBe(1);
await emgr.emit(new InformationalEvent());
expect(emgr.getListeners().length).toBe(0);
await emgr.emit(new InformationalEvent());
expect(emgr.getListeners().length).toBe(0);
expect(call).toHaveBeenCalledTimes(1);
}); });
}); });

View File

@@ -1,8 +1,14 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { Value } from "../../src/core/utils"; import { Value, _jsonp } from "../../src/core/utils";
import { WhereBuilder, type WhereQuery, querySchema } from "../../src/data"; import { type RepoQuery, WhereBuilder, type WhereQuery, querySchema } from "../../src/data";
import type { RepoQueryIn } from "../../src/data/server/data-query-impl";
import { getDummyConnection } from "./helper"; import { getDummyConnection } from "./helper";
const decode = (input: RepoQueryIn, expected: RepoQuery) => {
const result = Value.Decode(querySchema, input);
expect(result).toEqual(expected);
};
describe("data-query-impl", () => { describe("data-query-impl", () => {
function qb() { function qb() {
const c = getDummyConnection(); const c = getDummyConnection();
@@ -88,21 +94,47 @@ describe("data-query-impl", () => {
expect(keys).toEqual(expectedKeys); expect(keys).toEqual(expectedKeys);
} }
}); });
test("with", () => {
decode({ with: ["posts"] }, { with: { posts: {} } });
decode({ with: { posts: {} } }, { with: { posts: {} } });
decode({ with: { posts: { limit: 1 } } }, { with: { posts: { limit: 1 } } });
decode(
{
with: {
posts: {
with: {
images: {
select: ["id"]
}
}
}
}
},
{
with: {
posts: {
with: {
images: {
select: ["id"]
}
}
}
}
}
);
});
}); });
describe("data-query-impl: Typebox", () => { describe("data-query-impl: Typebox", () => {
test("sort", async () => { test("sort", async () => {
const decode = (input: any, expected: any) => { const _dflt = { sort: { by: "id", dir: "asc" } };
const result = Value.Decode(querySchema, input);
expect(result.sort).toEqual(expected);
};
const _dflt = { by: "id", dir: "asc" };
decode({ sort: "" }, _dflt); decode({ sort: "" }, _dflt);
decode({ sort: "name" }, { by: "name", dir: "asc" }); decode({ sort: "name" }, { sort: { by: "name", dir: "asc" } });
decode({ sort: "-name" }, { by: "name", dir: "desc" }); decode({ sort: "-name" }, { sort: { by: "name", dir: "desc" } });
decode({ sort: "-posts.name" }, { by: "posts.name", dir: "desc" }); decode({ sort: "-posts.name" }, { sort: { by: "posts.name", dir: "desc" } });
decode({ sort: "-1name" }, _dflt); decode({ sort: "-1name" }, _dflt);
decode({ sort: { by: "name", dir: "desc" } }, { by: "name", dir: "desc" }); decode({ sort: { by: "name", dir: "desc" } }, { sort: { by: "name", dir: "desc" } });
}); });
}); });

View File

@@ -106,7 +106,6 @@ describe("Relations", async () => {
expect(postAuthorRel?.other(posts).entity).toBe(users); expect(postAuthorRel?.other(posts).entity).toBe(users);
const kysely = em.connection.kysely; const kysely = em.connection.kysely;
const jsonFrom = (e) => e;
/** /**
* Relation Helper * Relation Helper
*/ */
@@ -119,14 +118,11 @@ describe("Relations", async () => {
- select: users.* - select: users.*
- cardinality: 1 - cardinality: 1
*/ */
const selectPostsFromUsers = postAuthorRel.buildWith( const selectPostsFromUsers = kysely
users, .selectFrom(users.name)
kysely.selectFrom(users.name), .select((eb) => postAuthorRel.buildWith(users, "posts")(eb).as("posts"));
jsonFrom,
"posts"
);
expect(selectPostsFromUsers.compile().sql).toBe( expect(selectPostsFromUsers.compile().sql).toBe(
'select (select "posts"."id" as "id", "posts"."title" as "title", "posts"."author_id" as "author_id" from "posts" as "posts" where "posts"."author_id" = "users"."id" limit ?) as "posts" from "users"' 'select (select from "posts" as "posts" where "posts"."author_id" = "users"."id") as "posts" from "users"'
); );
expect(postAuthorRel!.getField()).toBeInstanceOf(RelationField); expect(postAuthorRel!.getField()).toBeInstanceOf(RelationField);
const userObj = { id: 1, username: "test" }; const userObj = { id: 1, username: "test" };
@@ -141,15 +137,12 @@ describe("Relations", async () => {
- select: posts.* - select: posts.*
- cardinality: - cardinality:
*/ */
const selectUsersFromPosts = postAuthorRel.buildWith( const selectUsersFromPosts = kysely
posts, .selectFrom(posts.name)
kysely.selectFrom(posts.name), .select((eb) => postAuthorRel.buildWith(posts, "author")(eb).as("author"));
jsonFrom,
"author"
);
expect(selectUsersFromPosts.compile().sql).toBe( expect(selectUsersFromPosts.compile().sql).toBe(
'select (select "author"."id" as "id", "author"."username" as "username" from "users" as "author" where "author"."id" = "posts"."author_id" limit ?) as "author" from "posts"' 'select (select from "users" as "author" where "author"."id" = "posts"."author_id" limit ?) as "author" from "posts"'
); );
expect(postAuthorRel.getField()).toBeInstanceOf(RelationField); expect(postAuthorRel.getField()).toBeInstanceOf(RelationField);
const postObj = { id: 1, title: "test" }; const postObj = { id: 1, title: "test" };
@@ -315,20 +308,16 @@ describe("Relations", async () => {
- select: users.* - select: users.*
- cardinality: 1 - cardinality: 1
*/ */
const selectCategoriesFromPosts = postCategoriesRel.buildWith( const selectCategoriesFromPosts = kysely
posts, .selectFrom(posts.name)
kysely.selectFrom(posts.name), .select((eb) => postCategoriesRel.buildWith(posts)(eb).as("categories"));
jsonFrom
);
expect(selectCategoriesFromPosts.compile().sql).toBe( expect(selectCategoriesFromPosts.compile().sql).toBe(
'select (select "categories"."id" as "id", "categories"."label" as "label" from "categories" inner join "posts_categories" on "categories"."id" = "posts_categories"."categories_id" where "posts"."id" = "posts_categories"."posts_id" limit ?) as "categories" from "posts"' 'select (select "categories"."id" as "id", "categories"."label" as "label" from "categories" inner join "posts_categories" on "categories"."id" = "posts_categories"."categories_id" where "posts"."id" = "posts_categories"."posts_id" limit ?) as "categories" from "posts"'
); );
const selectPostsFromCategories = postCategoriesRel.buildWith( const selectPostsFromCategories = kysely
categories, .selectFrom(categories.name)
kysely.selectFrom(categories.name), .select((eb) => postCategoriesRel.buildWith(categories)(eb).as("posts"));
jsonFrom
);
expect(selectPostsFromCategories.compile().sql).toBe( expect(selectPostsFromCategories.compile().sql).toBe(
'select (select "posts"."id" as "id", "posts"."title" as "title" from "posts" inner join "posts_categories" on "posts"."id" = "posts_categories"."posts_id" where "categories"."id" = "posts_categories"."categories_id" limit ?) as "posts" from "categories"' 'select (select "posts"."id" as "id", "posts"."title" as "title" from "posts" inner join "posts_categories" on "posts"."id" = "posts_categories"."posts_id" where "categories"."id" = "posts_categories"."categories_id" limit ?) as "posts" from "categories"'
); );

View File

@@ -1,4 +1,5 @@
import { afterAll, describe, expect, test } from "bun:test"; import { afterAll, describe, expect, test } from "bun:test";
import type { EventManager } from "../../../src/core/events";
import { import {
Entity, Entity,
EntityManager, EntityManager,
@@ -10,6 +11,7 @@ import {
RelationMutator, RelationMutator,
TextField TextField
} from "../../../src/data"; } from "../../../src/data";
import * as proto from "../../../src/data/prototype";
import { getDummyConnection } from "../helper"; import { getDummyConnection } from "../helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection(); const { dummyConnection, afterAllCleanup } = getDummyConnection();
@@ -83,14 +85,12 @@ describe("[data] Mutator (ManyToOne)", async () => {
// persisting reference should ... // persisting reference should ...
expect( expect(
postRelMutator.persistReference(relations[0], "users", { postRelMutator.persistReference(relations[0]!, "users", {
$set: { id: userData.data.id } $set: { id: userData.data.id }
}) })
).resolves.toEqual(["users_id", userData.data.id]); ).resolves.toEqual(["users_id", userData.data.id]);
// @todo: add what methods are allowed to relation, like $create should not be allowed for post<>users // @todo: add what methods are allowed to relation, like $create should not be allowed for post<>users
process.exit(0);
const userRelMutator = new RelationMutator(users, em); const userRelMutator = new RelationMutator(users, em);
expect(userRelMutator.getRelationalKeys()).toEqual(["posts"]); expect(userRelMutator.getRelationalKeys()).toEqual(["posts"]);
}); });
@@ -99,7 +99,7 @@ describe("[data] Mutator (ManyToOne)", async () => {
expect( expect(
em.mutator(posts).insertOne({ em.mutator(posts).insertOne({
title: "post1", title: "post1",
users_id: 1 // user does not exist yet users_id: 100 // user does not exist yet
}) })
).rejects.toThrow(); ).rejects.toThrow();
}); });
@@ -299,4 +299,71 @@ describe("[data] Mutator (Events)", async () => {
expect(events.has(MutatorEvents.MutatorDeleteBefore.slug)).toBeTrue(); expect(events.has(MutatorEvents.MutatorDeleteBefore.slug)).toBeTrue();
expect(events.has(MutatorEvents.MutatorDeleteAfter.slug)).toBeTrue(); expect(events.has(MutatorEvents.MutatorDeleteAfter.slug)).toBeTrue();
}); });
test("insertOne event return is respected", async () => {
const posts = proto.entity("posts", {
title: proto.text(),
views: proto.number()
});
const conn = getDummyConnection();
const em = new EntityManager([posts], conn.dummyConnection);
await em.schema().sync({ force: true });
const emgr = em.emgr as EventManager<any>;
emgr.onEvent(
// @ts-ignore
EntityManager.Events.MutatorInsertBefore,
async (event) => {
return {
...event.params.data,
views: 2
};
},
"sync"
);
const mutator = em.mutator("posts");
const result = await mutator.insertOne({ title: "test", views: 1 });
expect(result.data).toEqual({
id: 1,
title: "test",
views: 2
});
});
test("updateOne event return is respected", async () => {
const posts = proto.entity("posts", {
title: proto.text(),
views: proto.number()
});
const conn = getDummyConnection();
const em = new EntityManager([posts], conn.dummyConnection);
await em.schema().sync({ force: true });
const emgr = em.emgr as EventManager<any>;
emgr.onEvent(
// @ts-ignore
EntityManager.Events.MutatorUpdateBefore,
async (event) => {
return {
...event.params.data,
views: event.params.data.views + 1
};
},
"sync"
);
const mutator = em.mutator("posts");
const created = await mutator.insertOne({ title: "test", views: 1 });
const result = await mutator.updateOne(created.data.id, { views: 2 });
expect(result.data).toEqual({
id: 1,
title: "test",
views: 3
});
});
}); });

View File

@@ -1,7 +1,6 @@
import { afterAll, describe, expect, test } from "bun:test"; import { afterAll, describe, expect, test } from "bun:test";
// @ts-ignore
import { Perf } from "@bknd/core/utils";
import type { Kysely, Transaction } from "kysely"; import type { Kysely, Transaction } from "kysely";
import { Perf } from "../../../src/core/utils";
import { import {
Entity, Entity,
EntityManager, EntityManager,
@@ -24,7 +23,7 @@ async function sleep(ms: number) {
} }
describe("[Repository]", async () => { describe("[Repository]", async () => {
test("bulk", async () => { test.skip("bulk", async () => {
//const connection = dummyConnection; //const connection = dummyConnection;
//const connection = getLocalLibsqlConnection(); //const connection = getLocalLibsqlConnection();
const credentials = null as any; // @todo: determine what to do here const credentials = null as any; // @todo: determine what to do here

View File

@@ -1,4 +1,5 @@
import { afterAll, describe, expect, test } from "bun:test"; import { afterAll, describe, expect, test } from "bun:test";
import { _jsonp } from "../../../src/core/utils";
import { import {
Entity, Entity,
EntityManager, EntityManager,
@@ -8,19 +9,56 @@ import {
TextField, TextField,
WithBuilder WithBuilder
} from "../../../src/data"; } from "../../../src/data";
import * as proto from "../../../src/data/prototype";
import { compileQb, prettyPrintQb, schemaToEm } from "../../helper";
import { getDummyConnection } from "../helper"; import { getDummyConnection } from "../helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection(); const { dummyConnection } = getDummyConnection();
afterAll(afterAllCleanup);
describe("[data] WithBuilder", async () => { describe("[data] WithBuilder", async () => {
test("validate withs", async () => {
const schema = proto.em(
{
posts: proto.entity("posts", {}),
users: proto.entity("users", {}),
media: proto.entity("media", {})
},
({ relation }, { posts, users, media }) => {
relation(posts).manyToOne(users);
relation(users).polyToOne(media, { mappedBy: "avatar" });
}
);
const em = schemaToEm(schema);
expect(WithBuilder.validateWiths(em, "posts", undefined)).toBe(0);
expect(WithBuilder.validateWiths(em, "posts", {})).toBe(0);
expect(WithBuilder.validateWiths(em, "posts", { users: {} })).toBe(1);
expect(
WithBuilder.validateWiths(em, "posts", {
users: {
with: { avatar: {} }
}
})
).toBe(2);
expect(() => WithBuilder.validateWiths(em, "posts", { author: {} })).toThrow();
expect(() =>
WithBuilder.validateWiths(em, "posts", {
users: {
with: { glibberish: {} }
}
})
).toThrow();
});
test("missing relation", async () => { test("missing relation", async () => {
const users = new Entity("users", [new TextField("username")]); const users = new Entity("users", [new TextField("username")]);
const em = new EntityManager([users], dummyConnection); const em = new EntityManager([users], dummyConnection);
expect(() => expect(() =>
WithBuilder.addClause(em, em.connection.kysely.selectFrom("users"), users, ["posts"]) WithBuilder.addClause(em, em.connection.kysely.selectFrom("users"), users, {
).toThrow('Relation "posts" not found'); posts: {}
})
).toThrow('Relation "users<>posts" not found');
}); });
test("addClause: ManyToOne", async () => { test("addClause: ManyToOne", async () => {
@@ -29,36 +67,39 @@ describe("[data] WithBuilder", async () => {
const relations = [new ManyToOneRelation(posts, users, { mappedBy: "author" })]; const relations = [new ManyToOneRelation(posts, users, { mappedBy: "author" })];
const em = new EntityManager([users, posts], dummyConnection, relations); const em = new EntityManager([users, posts], dummyConnection, relations);
const qb = WithBuilder.addClause(em, em.connection.kysely.selectFrom("users"), users, [ const qb = WithBuilder.addClause(em, em.connection.kysely.selectFrom("users"), users, {
"posts" posts: {}
]); });
const res = qb.compile(); const res = qb.compile();
expect(res.sql).toBe( expect(res.sql).toBe(
'select (select coalesce(json_group_array(json_object(\'id\', "agg"."id", \'content\', "agg"."content", \'author_id\', "agg"."author_id")), \'[]\') from (select "posts"."id" as "id", "posts"."content" as "content", "posts"."author_id" as "author_id" from "posts" where "users"."id" = "posts"."author_id" limit ?) as agg) as "posts" from "users"' 'select (select coalesce(json_group_array(json_object(\'id\', "agg"."id", \'content\', "agg"."content", \'author_id\', "agg"."author_id")), \'[]\') from (select "posts"."id" as "id", "posts"."content" as "content", "posts"."author_id" as "author_id" from "posts" as "posts" where "posts"."author_id" = "users"."id" order by "posts"."id" asc limit ? offset ?) as agg) as "posts" from "users"'
); );
expect(res.parameters).toEqual([5]); expect(res.parameters).toEqual([10, 0]);
const qb2 = WithBuilder.addClause( const qb2 = WithBuilder.addClause(
em, em,
em.connection.kysely.selectFrom("posts"), em.connection.kysely.selectFrom("posts"),
posts, // @todo: try with "users", it gives output! posts, // @todo: try with "users", it gives output!
["author"] {
author: {}
}
); );
const res2 = qb2.compile(); const res2 = qb2.compile();
expect(res2.sql).toBe( expect(res2.sql).toBe(
'select (select json_object(\'id\', "obj"."id", \'username\', "obj"."username") from (select "users"."id" as "id", "users"."username" as "username" from "users" where "posts"."author_id" = "users"."id" limit ?) as obj) as "author" from "posts"' 'select (select json_object(\'id\', "obj"."id", \'username\', "obj"."username") from (select "users"."id" as "id", "users"."username" as "username" from "users" as "author" where "author"."id" = "posts"."author_id" order by "users"."id" asc limit ? offset ?) as obj) as "author" from "posts"'
); );
expect(res2.parameters).toEqual([1]); expect(res2.parameters).toEqual([1, 0]);
}); });
test("test with empty join", async () => { test("test with empty join", async () => {
const em = new EntityManager([], dummyConnection);
const qb = { qb: 1 } as any; const qb = { qb: 1 } as any;
expect(WithBuilder.addClause(null as any, qb, null as any, [])).toBe(qb); expect(WithBuilder.addClause(em, qb, null as any, {})).toBe(qb);
}); });
test("test manytomany", async () => { test("test manytomany", async () => {
@@ -89,7 +130,7 @@ describe("[data] WithBuilder", async () => {
//console.log((await em.repository().findMany("posts_categories")).result); //console.log((await em.repository().findMany("posts_categories")).result);
const res = await em.repository(posts).findMany({ with: ["categories"] }); const res = await em.repository(posts).findMany({ with: { categories: {} } });
expect(res.data).toEqual([ expect(res.data).toEqual([
{ {
@@ -107,7 +148,7 @@ describe("[data] WithBuilder", async () => {
} }
]); ]);
const res2 = await em.repository(categories).findMany({ with: ["posts"] }); const res2 = await em.repository(categories).findMany({ with: { posts: {} } });
//console.log(res2.sql, res2.data); //console.log(res2.sql, res2.data);
@@ -121,8 +162,8 @@ describe("[data] WithBuilder", async () => {
id: 2, id: 2,
label: "beauty", label: "beauty",
posts: [ posts: [
{ id: 2, title: "beauty post" }, { id: 1, title: "fashion post" },
{ id: 1, title: "fashion post" } { id: 2, title: "beauty post" }
] ]
}, },
{ {
@@ -150,25 +191,25 @@ describe("[data] WithBuilder", async () => {
em, em,
em.connection.kysely.selectFrom("categories"), em.connection.kysely.selectFrom("categories"),
categories, categories,
["single"] { single: {} }
); );
const res = qb.compile(); const res = qb.compile();
expect(res.sql).toBe( expect(res.sql).toBe(
'select (select json_object(\'id\', "obj"."id", \'path\', "obj"."path") from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" limit ?) as obj) as "single" from "categories"' 'select (select json_object(\'id\', "obj"."id", \'path\', "obj"."path") from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" order by "media"."id" asc limit ? offset ?) as obj) as "single" from "categories"'
); );
expect(res.parameters).toEqual(["categories.single", 1]); expect(res.parameters).toEqual(["categories.single", 1, 0]);
const qb2 = WithBuilder.addClause( const qb2 = WithBuilder.addClause(
em, em,
em.connection.kysely.selectFrom("categories"), em.connection.kysely.selectFrom("categories"),
categories, categories,
["multiple"] { multiple: {} }
); );
const res2 = qb2.compile(); const res2 = qb2.compile();
expect(res2.sql).toBe( expect(res2.sql).toBe(
'select (select coalesce(json_group_array(json_object(\'id\', "agg"."id", \'path\', "agg"."path")), \'[]\') from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" limit ?) as agg) as "multiple" from "categories"' 'select (select coalesce(json_group_array(json_object(\'id\', "agg"."id", \'path\', "agg"."path")), \'[]\') from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" order by "media"."id" asc limit ? offset ?) as agg) as "multiple" from "categories"'
); );
expect(res2.parameters).toEqual(["categories.multiple", 5]); expect(res2.parameters).toEqual(["categories.multiple", 10, 0]);
}); });
/*test("test manytoone", async () => { /*test("test manytoone", async () => {
@@ -192,4 +233,205 @@ describe("[data] WithBuilder", async () => {
const res = await em.repository().findMany("posts", { join: ["author"] }); const res = await em.repository().findMany("posts", { join: ["author"] });
console.log(res.sql, res.parameters, res.result); console.log(res.sql, res.parameters, res.result);
});*/ });*/
describe("recursive", () => {
test("compiles with singles", async () => {
const schema = proto.em(
{
posts: proto.entity("posts", {}),
users: proto.entity("users", {
username: proto.text()
}),
media: proto.entity("media", {
path: proto.text()
})
},
({ relation }, { posts, users, media }) => {
relation(posts).manyToOne(users);
relation(users).polyToOne(media, { mappedBy: "avatar" });
}
);
const em = schemaToEm(schema);
const qb = WithBuilder.addClause(
em,
em.connection.kysely.selectFrom("posts"),
schema.entities.posts,
{
users: {
limit: 5, // ignored
select: ["id", "username"],
sort: { by: "username", dir: "asc" },
with: {
avatar: {
select: ["id", "path"],
limit: 2 // ignored
}
}
}
}
);
//prettyPrintQb(qb);
expect(qb.compile().sql).toBe(
'select (select json_object(\'id\', "obj"."id", \'username\', "obj"."username", \'avatar\', "obj"."avatar") from (select "users"."id" as "id", "users"."username" as "username", (select json_object(\'id\', "obj"."id", \'path\', "obj"."path") from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "users"."id" = "media"."entity_id" order by "media"."id" asc limit ? offset ?) as obj) as "avatar" from "users" as "users" where "users"."id" = "posts"."users_id" order by "users"."username" asc limit ? offset ?) as obj) as "users" from "posts"'
);
expect(qb.compile().parameters).toEqual(["users.avatar", 1, 0, 1, 0]);
});
test("compiles with many", async () => {
const schema = proto.em(
{
posts: proto.entity("posts", {}),
comments: proto.entity("comments", {}),
users: proto.entity("users", {
username: proto.text()
}),
media: proto.entity("media", {
path: proto.text()
})
},
({ relation }, { posts, comments, users, media }) => {
relation(posts).manyToOne(users).polyToOne(media, { mappedBy: "images" });
relation(users).polyToOne(media, { mappedBy: "avatar" });
relation(comments).manyToOne(posts).manyToOne(users);
}
);
const em = schemaToEm(schema);
const qb = WithBuilder.addClause(
em,
em.connection.kysely.selectFrom("posts"),
schema.entities.posts,
{
comments: {
limit: 12,
with: {
users: {
select: ["username"]
}
}
}
}
);
expect(qb.compile().sql).toBe(
'select (select coalesce(json_group_array(json_object(\'id\', "agg"."id", \'posts_id\', "agg"."posts_id", \'users_id\', "agg"."users_id", \'users\', "agg"."users")), \'[]\') from (select "comments"."id" as "id", "comments"."posts_id" as "posts_id", "comments"."users_id" as "users_id", (select json_object(\'username\', "obj"."username") from (select "users"."username" as "username" from "users" as "users" where "users"."id" = "comments"."users_id" order by "users"."id" asc limit ? offset ?) as obj) as "users" from "comments" as "comments" where "comments"."posts_id" = "posts"."id" order by "comments"."id" asc limit ? offset ?) as agg) as "comments" from "posts"'
);
expect(qb.compile().parameters).toEqual([1, 0, 12, 0]);
});
test("returns correct result", async () => {
const schema = proto.em(
{
posts: proto.entity("posts", {
title: proto.text()
}),
comments: proto.entity("comments", {
content: proto.text()
}),
users: proto.entity("users", {
username: proto.text()
}),
media: proto.entity("media", {
path: proto.text()
})
},
({ relation }, { posts, comments, users, media }) => {
relation(posts).manyToOne(users).polyToOne(media, { mappedBy: "images" });
relation(users).polyToOne(media, { mappedBy: "avatar" });
relation(comments).manyToOne(posts).manyToOne(users);
}
);
const em = schemaToEm(schema);
await em.schema().sync({ force: true });
// add data
await em.mutator("users").insertMany([{ username: "user1" }, { username: "user2" }]);
await em.mutator("posts").insertMany([
{ title: "post1", users_id: 1 },
{ title: "post2", users_id: 1 },
{ title: "post3", users_id: 2 }
]);
await em.mutator("comments").insertMany([
{ content: "comment1", posts_id: 1, users_id: 1 },
{ content: "comment1-1", posts_id: 1, users_id: 1 },
{ content: "comment2", posts_id: 1, users_id: 2 },
{ content: "comment3", posts_id: 2, users_id: 1 },
{ content: "comment4", posts_id: 2, users_id: 2 },
{ content: "comment5", posts_id: 3, users_id: 1 },
{ content: "comment6", posts_id: 3, users_id: 2 }
]);
const result = await em.repo("posts").findMany({
select: ["title"],
with: {
comments: {
limit: 2,
select: ["content"],
with: {
users: {
select: ["username"]
}
}
}
}
});
expect(result.data).toEqual([
{
title: "post1",
comments: [
{
content: "comment1",
users: {
username: "user1"
}
},
{
content: "comment1-1",
users: {
username: "user1"
}
}
]
},
{
title: "post2",
comments: [
{
content: "comment3",
users: {
username: "user1"
}
},
{
content: "comment4",
users: {
username: "user2"
}
}
]
},
{
title: "post3",
comments: [
{
content: "comment5",
users: {
username: "user1"
}
},
{
content: "comment6",
users: {
username: "user2"
}
}
]
}
]);
//console.log(_jsonp(result.data));
});
});
}); });

View File

@@ -13,10 +13,6 @@ describe("[data] EnumField", async () => {
{ options: options(["a", "b", "c"]) } { options: options(["a", "b", "c"]) }
); );
test("yields if no options", async () => {
expect(() => new EnumField("test", { options: options([]) })).toThrow();
});
test("yields if default value is not a valid option", async () => { test("yields if default value is not a valid option", async () => {
expect( expect(
() => new EnumField("test", { options: options(["a", "b"]), default_value: "c" }) () => new EnumField("test", { options: options(["a", "b"]), default_value: "c" })

View File

@@ -15,11 +15,9 @@ describe("[data] Field", async () => {
runBaseFieldTests(FieldSpec, { defaultValue: "test", schemaType: "text" }); runBaseFieldTests(FieldSpec, { defaultValue: "test", schemaType: "text" });
test.only("default config", async () => { test("default config", async () => {
const field = new FieldSpec("test");
const config = Default(baseFieldConfigSchema, {}); const config = Default(baseFieldConfigSchema, {});
expect(stripMark(new FieldSpec("test").config)).toEqual(config); expect(stripMark(new FieldSpec("test").config)).toEqual(config);
console.log("config", new TextField("test", { required: true }).toJSON());
}); });
test("transformPersist (specific)", async () => { test("transformPersist (specific)", async () => {

View File

@@ -32,7 +32,7 @@ describe("[data] JsonField", async () => {
}); });
test("getValue", async () => { test("getValue", async () => {
expect(field.getValue({ test: 1 }, "form")).toBe('{"test":1}'); expect(field.getValue({ test: 1 }, "form")).toBe('{\n "test": 1\n}');
expect(field.getValue("string", "form")).toBe('"string"'); expect(field.getValue("string", "form")).toBe('"string"');
expect(field.getValue(1, "form")).toBe("1"); expect(field.getValue(1, "form")).toBe("1");

View File

@@ -70,9 +70,9 @@ describe("[data] EntityRelation", async () => {
it("required", async () => { it("required", async () => {
const relation1 = new TestEntityRelation(); const relation1 = new TestEntityRelation();
expect(relation1.config.required).toBe(false); expect(relation1.required).toBe(false);
const relation2 = new TestEntityRelation({ required: true }); const relation2 = new TestEntityRelation({ required: true });
expect(relation2.config.required).toBe(true); expect(relation2.required).toBe(true);
}); });
}); });

View File

@@ -1,7 +1,9 @@
import { unlink } from "node:fs/promises"; import { unlink } from "node:fs/promises";
import type { SqliteDatabase } from "kysely"; import type { SelectQueryBuilder, SqliteDatabase } from "kysely";
import Database from "libsql"; import Database from "libsql";
import { SqliteLocalConnection } from "../src/data"; import { format as sqlFormat } from "sql-formatter";
import { type Connection, EntityManager, SqliteLocalConnection } from "../src/data";
import type { em as protoEm } from "../src/data/prototype";
export function getDummyDatabase(memory: boolean = true): { export function getDummyDatabase(memory: boolean = true): {
dummyDb: SqliteDatabase; dummyDb: SqliteDatabase;
@@ -51,3 +53,18 @@ export function enableConsoleLog() {
console[severity as ConsoleSeverity] = fn; console[severity as ConsoleSeverity] = fn;
}); });
} }
export function compileQb(qb: SelectQueryBuilder<any, any, any>) {
const { sql, parameters } = qb.compile();
return { sql, parameters };
}
export function prettyPrintQb(qb: SelectQueryBuilder<any, any, any>) {
const { sql, parameters } = qb.compile();
console.log("$", sqlFormat(sql), "\n[params]", parameters);
}
export function schemaToEm(s: ReturnType<typeof protoEm>, conn?: Connection): EntityManager<any> {
const connection = conn ? conn : getDummyConnection().dummyConnection;
return new EntityManager(Object.values(s.entities), connection, s.relations, s.indices);
}

View File

@@ -3,7 +3,7 @@ import { randomString } from "../../../src/core/utils";
import { StorageCloudinaryAdapter } from "../../../src/media"; import { StorageCloudinaryAdapter } from "../../../src/media";
import { config } from "dotenv"; import { config } from "dotenv";
const dotenvOutput = config({ path: `${import.meta.dir}/../../.env` }); const dotenvOutput = config({ path: `${import.meta.dir}/../../../.env` });
const { const {
CLOUDINARY_CLOUD_NAME, CLOUDINARY_CLOUD_NAME,
CLOUDINARY_API_KEY, CLOUDINARY_API_KEY,

View File

@@ -15,7 +15,7 @@ describe("StorageLocalAdapter", () => {
test("puts an object", async () => { test("puts an object", async () => {
objects = (await adapter.listObjects()).length; objects = (await adapter.listObjects()).length;
expect(await adapter.putObject(filename, await file.arrayBuffer())).toBeString(); expect(await adapter.putObject(filename, file)).toBeString();
}); });
test("lists objects", async () => { test("lists objects", async () => {

View File

@@ -3,14 +3,14 @@ import { randomString } from "../../../src/core/utils";
import { StorageS3Adapter } from "../../../src/media"; import { StorageS3Adapter } from "../../../src/media";
import { config } from "dotenv"; import { config } from "dotenv";
const dotenvOutput = config({ path: `${import.meta.dir}/../../.env` }); const dotenvOutput = config({ path: `${import.meta.dir}/../../../.env` });
const { R2_ACCESS_KEY, R2_SECRET_ACCESS_KEY, R2_URL, AWS_ACCESS_KEY, AWS_SECRET_KEY, AWS_S3_URL } = const { R2_ACCESS_KEY, R2_SECRET_ACCESS_KEY, R2_URL, AWS_ACCESS_KEY, AWS_SECRET_KEY, AWS_S3_URL } =
dotenvOutput.parsed!; dotenvOutput.parsed!;
// @todo: mock r2/s3 responses for faster tests // @todo: mock r2/s3 responses for faster tests
const ALL_TESTS = process.env.ALL_TESTS; const ALL_TESTS = !!process.env.ALL_TESTS;
describe("Storage", async () => { describe.skipIf(ALL_TESTS)("StorageS3Adapter", async () => {
console.log("ALL_TESTS", process.env.ALL_TESTS); console.log("ALL_TESTS", process.env.ALL_TESTS);
const versions = [ const versions = [
[ [

View File

@@ -1,7 +1,7 @@
import { afterAll, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { afterAll, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test";
import { createApp } from "../../src"; import { createApp } from "../../src";
import { AuthController } from "../../src/auth/api/AuthController"; import { AuthController } from "../../src/auth/api/AuthController";
import { em, entity, text } from "../../src/data"; import { em, entity, make, text } from "../../src/data";
import { AppAuth, type ModuleBuildContext } from "../../src/modules"; import { AppAuth, type ModuleBuildContext } from "../../src/modules";
import { disableConsoleLog, enableConsoleLog } from "../helper"; import { disableConsoleLog, enableConsoleLog } from "../helper";
import { makeCtx, moduleTestSuite } from "./module-test-suite"; import { makeCtx, moduleTestSuite } from "./module-test-suite";
@@ -125,6 +125,40 @@ describe("AppAuth", () => {
const fields = e.fields.map((f) => f.name); const fields = e.fields.map((f) => f.name);
expect(e.type).toBe("system"); expect(e.type).toBe("system");
expect(fields).toContain("additional"); expect(fields).toContain("additional");
expect(fields).toEqual(["id", "email", "strategy", "strategy_value", "role", "additional"]); expect(fields).toEqual(["id", "additional", "email", "strategy", "strategy_value", "role"]);
});
test("ensure user field configs is always correct", async () => {
const app = createApp({
initialConfig: {
auth: {
enabled: true
},
data: em({
users: entity("users", {
strategy: text({
fillable: true,
hidden: false
}),
strategy_value: text({
fillable: true,
hidden: false
})
})
}).toJSON()
}
});
await app.build();
const users = app.em.entity("users");
const props = ["hidden", "fillable", "required"];
for (const [name, _authFieldProto] of Object.entries(AppAuth.usersFields)) {
const authField = make(name, _authFieldProto as any);
const field = users.field(name)!;
for (const prop of props) {
expect(field.config[prop]).toBe(authField.config[prop]);
}
}
}); });
}); });

View File

@@ -39,6 +39,7 @@ describe("AppMedia", () => {
expect(fields).toContain("additional"); expect(fields).toContain("additional");
expect(fields).toEqual([ expect(fields).toEqual([
"id", "id",
"additional",
"path", "path",
"folder", "folder",
"mime_type", "mime_type",
@@ -48,8 +49,7 @@ describe("AppMedia", () => {
"modified_at", "modified_at",
"reference", "reference",
"entity_id", "entity_id",
"metadata", "metadata"
"additional"
]); ]);
}); });
}); });

View File

@@ -157,8 +157,7 @@ describe("Module", async () => {
entities: [ entities: [
{ {
name: "u", name: "u",
// ensured properties must come first fields: ["id", "name", "important"],
fields: ["id", "important", "name"],
// ensured type must be present // ensured type must be present
type: "system" type: "system"
}, },

View File

@@ -15,7 +15,7 @@ if (clean) {
let types_running = false; let types_running = false;
function buildTypes() { function buildTypes() {
if (types_running) return; if (types_running || !types) return;
types_running = true; types_running = true;
Bun.spawn(["bun", "build:types"], { Bun.spawn(["bun", "build:types"], {
@@ -76,12 +76,7 @@ await tsup.build({
minify, minify,
sourcemap, sourcemap,
watch, watch,
entry: [ entry: ["src/ui/index.ts", "src/ui/client/index.ts", "src/ui/main.css", "src/ui/styles.css"],
"src/ui/index.ts",
"src/ui/client/index.ts",
"src/ui/elements/index.ts",
"src/ui/main.css"
],
outDir: "dist/ui", outDir: "dist/ui",
external: [ external: [
"bun:test", "bun:test",
@@ -89,21 +84,68 @@ await tsup.build({
"react-dom", "react-dom",
"react/jsx-runtime", "react/jsx-runtime",
"react/jsx-dev-runtime", "react/jsx-dev-runtime",
"use-sync-external-store",
/codemirror/,
"@xyflow/react",
"@mantine/core"
],
metafile: true,
platform: "browser",
format: ["esm"],
splitting: false,
bundle: true,
treeshake: true,
loader: {
".svg": "dataurl"
},
esbuildOptions: (options) => {
options.logLevel = "silent";
},
onSuccess: async () => {
delayTypes();
}
});
/**
* Building UI Elements
* - tailwind-merge is mocked, no exclude
* - ui/client is external, and after built replaced with "bknd/client"
*/
await tsup.build({
minify,
sourcemap,
watch,
entry: ["src/ui/elements/index.ts"],
outDir: "dist/ui/elements",
external: [
"ui/client",
"react",
"react-dom",
"react/jsx-runtime",
"react/jsx-dev-runtime",
"use-sync-external-store" "use-sync-external-store"
], ],
metafile: true, metafile: true,
platform: "browser", platform: "browser",
format: ["esm"], format: ["esm"],
splitting: true, splitting: false,
bundle: true,
treeshake: true, treeshake: true,
loader: { loader: {
".svg": "dataurl" ".svg": "dataurl"
}, },
esbuildOptions: (options) => { esbuildOptions: (options) => {
options.logLevel = "silent"; options.alias = {
options.chunkNames = "chunks/[name]-[hash]"; // not important for elements, mock to reduce bundle
"tailwind-merge": "./src/ui/elements/mocks/tailwind-merge.ts"
};
}, },
onSuccess: async () => { onSuccess: async () => {
// manually replace ui/client with bknd/client
const path = "./dist/ui/elements/index.js";
const bundle = await Bun.file(path).text();
await Bun.write(path, bundle.replaceAll("ui/client", "bknd/client"));
delayTypes(); delayTypes();
} }
}); });

View File

@@ -1,2 +1,5 @@
[install] [install]
registry = "http://localhost:4873" #registry = "http://localhost:4873"
[test]
coverageSkipTestFiles = true

View File

@@ -3,10 +3,20 @@
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,
"bin": "./dist/cli/index.js", "bin": "./dist/cli/index.js",
"version": "0.5.0", "version": "0.6.0",
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, Remix, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
"homepage": "https://bknd.io",
"repository": {
"type": "git",
"url": "https://github.com/bknd-io/bknd.git"
},
"bugs": {
"url": "https://github.com/bknd-io/bknd/issues"
},
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"test": "ALL_TESTS=1 bun test --bail", "test": "ALL_TESTS=1 bun test --bail",
"test:coverage": "ALL_TESTS=1 bun test --bail --coverage",
"build": "NODE_ENV=production 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: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:cli": "bun build src/cli/index.ts --target node --outdir dist/cli --minify",
@@ -17,7 +27,8 @@
"build:types": "tsc --emitDeclarationOnly && tsc-alias", "build:types": "tsc --emitDeclarationOnly && tsc-alias",
"updater": "bun x npm-check-updates -ui", "updater": "bun x npm-check-updates -ui",
"cli": "LOCAL=1 bun src/cli/index.ts", "cli": "LOCAL=1 bun src/cli/index.ts",
"prepublishOnly": "bun run test && bun run build:all" "prepublishOnly": "bun run types && bun run test && bun run build:all && cp ../README.md ./",
"postpublish": "rm -f README.md"
}, },
"license": "FSL-1.1-MIT", "license": "FSL-1.1-MIT",
"dependencies": { "dependencies": {
@@ -34,34 +45,32 @@
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1", "oauth4webapi": "^2.11.1",
"swr": "^2.2.5", "swr": "^2.2.5",
"json-schema-form-react": "^0.0.2" "json-schema-form-react": "^0.0.2",
}, "@uiw/react-codemirror": "^4.23.6",
"devDependencies": {
"@aws-sdk/client-s3": "^3.613.0",
"@codemirror/lang-html": "^6.4.9", "@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-json": "^6.0.1", "@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-liquid": "^6.2.1", "@codemirror/lang-liquid": "^6.2.1",
"@xyflow/react": "^12.3.2",
"@mantine/core": "^7.13.4",
"@hello-pangea/dnd": "^17.0.0"
},
"devDependencies": {
"@aws-sdk/client-s3": "^3.613.0",
"@dagrejs/dagre": "^1.1.4", "@dagrejs/dagre": "^1.1.4",
"@hello-pangea/dnd": "^17.0.0",
"@hono/typebox-validator": "^0.2.6", "@hono/typebox-validator": "^0.2.6",
"@hono/vite-dev-server": "^0.17.0", "@hono/vite-dev-server": "^0.17.0",
"@hono/zod-validator": "^0.4.1", "@hono/zod-validator": "^0.4.1",
"@hookform/resolvers": "^3.9.1", "@hookform/resolvers": "^3.9.1",
"@libsql/kysely-libsql": "^0.4.1", "@libsql/kysely-libsql": "^0.4.1",
"@mantine/core": "^7.13.4",
"@mantine/hooks": "^7.13.4",
"@mantine/modals": "^7.13.4",
"@mantine/notifications": "^7.13.5",
"@radix-ui/react-scroll-area": "^1.2.0", "@radix-ui/react-scroll-area": "^1.2.0",
"@rjsf/core": "^5.22.2", "@rjsf/core": "^5.22.2",
"@tabler/icons-react": "3.18.0", "@tabler/icons-react": "3.18.0",
"@types/node": "^22.10.0", "@types/node": "^22.10.0",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@uiw/react-codemirror": "^4.23.6",
"@vitejs/plugin-react": "^4.3.3", "@vitejs/plugin-react": "^4.3.3",
"@xyflow/react": "^12.3.2",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"clsx": "^2.1.1",
"esbuild-postcss": "^0.0.4", "esbuild-postcss": "^0.0.4",
"jotai": "^2.10.1", "jotai": "^2.10.1",
"open": "^10.1.0", "open": "^10.1.0",
@@ -72,6 +81,7 @@
"react-hook-form": "^7.53.1", "react-hook-form": "^7.53.1",
"react-icons": "5.2.1", "react-icons": "5.2.1",
"react-json-view-lite": "^2.0.1", "react-json-view-lite": "^2.0.1",
"sql-formatter": "^15.4.9",
"tailwind-merge": "^2.5.4", "tailwind-merge": "^2.5.4",
"tailwindcss": "^3.4.14", "tailwindcss": "^3.4.14",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
@@ -168,7 +178,8 @@
"import": "./dist/adapter/astro/index.js", "import": "./dist/adapter/astro/index.js",
"require": "./dist/adapter/astro/index.cjs" "require": "./dist/adapter/astro/index.cjs"
}, },
"./dist/styles.css": "./dist/ui/main.css", "./dist/main.css": "./dist/ui/main.css",
"./dist/styles.css": "./dist/ui/styles.css",
"./dist/manifest.json": "./dist/static/.vite/manifest.json" "./dist/manifest.json": "./dist/static/.vite/manifest.json"
}, },
"publishConfig": { "publishConfig": {
@@ -182,5 +193,21 @@
"!dist/**/*.map", "!dist/**/*.map",
"!dist/metafile*", "!dist/metafile*",
"!dist/**/metafile*" "!dist/**/metafile*"
],
"keywords": [
"backend",
"database",
"authentication",
"media",
"workflows",
"api",
"jwt",
"serverless",
"cloudflare",
"nextjs",
"remix",
"astro",
"bun",
"node"
] ]
} }

View File

@@ -128,15 +128,17 @@ export class Api {
}; };
} }
async getVerifiedAuthState(force?: boolean): Promise<AuthState> { async getVerifiedAuthState(): Promise<AuthState> {
if (force === true || !this.verified) {
await this.verifyAuth(); await this.verifyAuth();
}
return this.getAuthState(); return this.getAuthState();
} }
async verifyAuth() { async verifyAuth() {
if (!this.token) {
this.markAuthVerified(false);
return;
}
try { try {
const res = await this.auth.me(); const res = await this.auth.me();
if (!res.ok || !res.body.user) { if (!res.ok || !res.body.user) {

View File

@@ -1,8 +1,5 @@
import type { CreateUserPayload } from "auth/AppAuth"; import type { CreateUserPayload } from "auth/AppAuth";
import { auth } from "auth/middlewares";
import { config } from "core";
import { Event } from "core/events"; import { Event } from "core/events";
import { patternMatch } from "core/utils";
import { Connection, type LibSqlCredentials, LibsqlConnection } from "data"; import { Connection, type LibSqlCredentials, LibsqlConnection } from "data";
import { import {
type InitialModuleConfigs, type InitialModuleConfigs,

View File

@@ -1,11 +1,16 @@
import { type AuthAction, Authenticator, type ProfileExchange, Role, type Strategy } from "auth"; import {
type AuthAction,
AuthPermissions,
Authenticator,
type ProfileExchange,
Role,
type Strategy
} from "auth";
import type { PasswordStrategy } from "auth/authenticate/strategies"; import type { PasswordStrategy } from "auth/authenticate/strategies";
import { auth } from "auth/middlewares";
import { type DB, Exception, type PrimaryFieldType } from "core"; import { type DB, Exception, type PrimaryFieldType } from "core";
import { type Static, secureRandomString, transformObject } from "core/utils"; import { type Static, secureRandomString, transformObject } from "core/utils";
import { type Entity, EntityIndex, type EntityManager } from "data"; import type { Entity, EntityManager } from "data";
import { type FieldSchema, em, entity, enumm, make, text } from "data/prototype"; import { type FieldSchema, em, entity, enumm, text } from "data/prototype";
import type { Hono } from "hono";
import { pick } from "lodash-es"; import { pick } from "lodash-es";
import { Module } from "modules/Module"; import { Module } from "modules/Module";
import { AuthController } from "./api/AuthController"; import { AuthController } from "./api/AuthController";
@@ -79,8 +84,8 @@ export class AppAuth extends Module<typeof authConfigSchema> {
super.setBuilt(); super.setBuilt();
this._controller = new AuthController(this); this._controller = new AuthController(this);
//this.ctx.server.use(controller.getMiddleware);
this.ctx.server.route(this.config.basepath, this._controller.getController()); this.ctx.server.route(this.config.basepath, this._controller.getController());
this.ctx.guard.registerPermissions(Object.values(AuthPermissions));
} }
get controller(): AuthController { get controller(): AuthController {
@@ -219,10 +224,23 @@ export class AppAuth extends Module<typeof authConfigSchema> {
} }
private toggleStrategyValueVisibility(visible: boolean) { private toggleStrategyValueVisibility(visible: boolean) {
const field = this.getUsersEntity().field("strategy_value")!; const toggle = (name: string, visible: boolean) => {
const field = this.getUsersEntity().field(name)!;
if (visible) {
field.config.hidden = false;
field.config.fillable = true;
} else {
// reset to normal
const template = AppAuth.usersFields.strategy_value.config;
field.config.hidden = template.hidden;
field.config.fillable = template.fillable;
}
};
toggle("strategy_value", visible);
toggle("strategy", visible);
field.config.hidden = !visible;
field.config.fillable = visible;
// @todo: think about a PasswordField that automatically hashes on save? // @todo: think about a PasswordField that automatically hashes on save?
} }
@@ -237,7 +255,10 @@ export class AppAuth extends Module<typeof authConfigSchema> {
static usersFields = { static usersFields = {
email: text().required(), email: text().required(),
strategy: text({ fillable: ["create"], hidden: ["form"] }).required(), strategy: text({
fillable: ["create"],
hidden: ["update", "form"]
}).required(),
strategy_value: text({ strategy_value: text({
fillable: ["create"], fillable: ["create"],
hidden: ["read", "table", "update", "form"] hidden: ["read", "table", "update", "form"]
@@ -260,14 +281,12 @@ export class AppAuth extends Module<typeof authConfigSchema> {
try { try {
const roles = Object.keys(this.config.roles ?? {}); const roles = Object.keys(this.config.roles ?? {});
const field = make("role", enumm({ enum: roles })); this.replaceEntityField(users, "role", enumm({ enum: roles }));
users.__replaceField("role", field);
} catch (e) {} } catch (e) {}
try { try {
const strategies = Object.keys(this.config.strategies ?? {}); const strategies = Object.keys(this.config.strategies ?? {});
const field = make("strategy", enumm({ enum: strategies })); this.replaceEntityField(users, "strategy", enumm({ enum: strategies }));
users.__replaceField("strategy", field);
} catch (e) {} } catch (e) {}
} }

View File

@@ -1,4 +1,5 @@
import type { AppAuthSchema, AppAuthStrategies } from "auth/auth-schema"; import type { AuthActionResponse } from "auth/api/AuthController";
import type { AppAuthSchema } from "auth/auth-schema";
import type { AuthResponse, SafeUser, Strategy } from "auth/authenticate/Authenticator"; import type { AuthResponse, SafeUser, Strategy } from "auth/authenticate/Authenticator";
import { type BaseModuleApiOptions, ModuleApi } from "modules/ModuleApi"; import { type BaseModuleApiOptions, ModuleApi } from "modules/ModuleApi";
@@ -13,22 +14,46 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
}; };
} }
async loginWithPassword(input: any) { async login(strategy: string, input: any) {
const res = await this.post<AuthResponse>(["password", "login"], input); const res = await this.post<AuthResponse>([strategy, "login"], input);
if (res.ok && res.body.token) { if (res.ok && res.body.token) {
await this.options.onTokenUpdate?.(res.body.token); await this.options.onTokenUpdate?.(res.body.token);
} }
return res; return res;
} }
async registerWithPassword(input: any) { async register(strategy: string, input: any) {
const res = await this.post<AuthResponse>(["password", "register"], input); const res = await this.post<AuthResponse>([strategy, "register"], input);
if (res.ok && res.body.token) { if (res.ok && res.body.token) {
await this.options.onTokenUpdate?.(res.body.token); await this.options.onTokenUpdate?.(res.body.token);
} }
return res; return res;
} }
async actionSchema(strategy: string, action: string) {
return this.get<Strategy>([strategy, "actions", action, "schema.json"]);
}
async action(strategy: string, action: string, input: any) {
return this.post<AuthActionResponse>([strategy, "actions", action], input);
}
/**
* @deprecated use login("password", ...) instead
* @param input
*/
async loginWithPassword(input: any) {
return this.login("password", input);
}
/**
* @deprecated use register("password", ...) instead
* @param input
*/
async registerWithPassword(input: any) {
return this.register("password", input);
}
me() { me() {
return this.get<{ user: SafeUser | null }>(["me"]); return this.get<{ user: SafeUser | null }>(["me"]);
} }

View File

@@ -1,5 +1,16 @@
import type { AppAuth } from "auth"; import { type AppAuth, AuthPermissions, type SafeUser, type Strategy } from "auth";
import { TypeInvalidError, parse } from "core/utils";
import { DataPermissions } from "data";
import type { Hono } from "hono";
import { Controller } from "modules/Controller"; import { Controller } from "modules/Controller";
import type { ServerEnv } from "modules/Module";
export type AuthActionResponse = {
success: boolean;
action: string;
data?: SafeUser;
errors?: any;
};
export class AuthController extends Controller { export class AuthController extends Controller {
constructor(private auth: AppAuth) { constructor(private auth: AppAuth) {
@@ -10,6 +21,68 @@ export class AuthController extends Controller {
return this.auth.ctx.guard; return this.auth.ctx.guard;
} }
private registerStrategyActions(strategy: Strategy, mainHono: Hono<ServerEnv>) {
const actions = strategy.getActions?.();
if (!actions) {
return;
}
const { auth, permission } = this.middlewares;
const hono = this.create().use(auth());
const name = strategy.getName();
const { create, change } = actions;
const em = this.auth.em;
if (create) {
hono.post(
"/create",
permission([AuthPermissions.createUser, DataPermissions.entityCreate]),
async (c) => {
try {
const body = await this.auth.authenticator.getBody(c);
const valid = parse(create.schema, body, {
skipMark: true
});
const processed = (await create.preprocess?.(valid)) ?? valid;
// @todo: check processed for "role" and check permissions
const mutator = em.mutator(this.auth.config.entity_name as "users");
mutator.__unstable_toggleSystemEntityCreation(false);
const { data: created } = await mutator.insertOne({
...processed,
strategy: name
});
mutator.__unstable_toggleSystemEntityCreation(true);
return c.json({
success: true,
action: "create",
strategy: name,
data: created as unknown as SafeUser
} as AuthActionResponse);
} catch (e) {
if (e instanceof TypeInvalidError) {
return c.json(
{
success: false,
errors: e.errors
},
400
);
}
throw e;
}
}
);
hono.get("create/schema.json", async (c) => {
return c.json(create.schema);
});
}
mainHono.route(`/${name}/actions`, hono);
}
override getController() { override getController() {
const { auth } = this.middlewares; const { auth } = this.middlewares;
const hono = this.create(); const hono = this.create();
@@ -18,11 +91,12 @@ export class AuthController extends Controller {
for (const [name, strategy] of Object.entries(strategies)) { for (const [name, strategy] of Object.entries(strategies)) {
//console.log("registering", name, "at", `/${name}`); //console.log("registering", name, "at", `/${name}`);
hono.route(`/${name}`, strategy.getController(this.auth.authenticator)); hono.route(`/${name}`, strategy.getController(this.auth.authenticator));
this.registerStrategyActions(strategy, hono);
} }
hono.get("/me", auth(), async (c) => { hono.get("/me", auth(), async (c) => {
if (this.auth.authenticator.isUserLoggedIn()) { if (this.auth.authenticator.isUserLoggedIn()) {
return c.json({ user: await this.auth.authenticator.getUser() }); return c.json({ user: this.auth.authenticator.getUser() });
} }
return c.json({ user: null }, 403); return c.json({ user: null }, 403);

View File

@@ -0,0 +1,4 @@
import { Permission } from "core";
export const createUser = new Permission("auth.user.create");
//export const updateUser = new Permission("auth.user.update");

View File

@@ -1,6 +1,14 @@
import { Exception } from "core"; import { type DB, Exception } from "core";
import { addFlashMessage } from "core/server/flash"; import { addFlashMessage } from "core/server/flash";
import { type Static, StringEnum, Type, parse, runtimeSupports, transformObject } from "core/utils"; import {
type Static,
StringEnum,
type TObject,
Type,
parse,
runtimeSupports,
transformObject
} from "core/utils";
import type { Context, Hono } from "hono"; import type { Context, Hono } from "hono";
import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie"; import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie";
import { sign, verify } from "hono/jwt"; import { sign, verify } from "hono/jwt";
@@ -10,6 +18,14 @@ import type { ServerEnv } from "modules/Module";
type Input = any; // workaround type Input = any; // workaround
export type JWTPayload = Parameters<typeof sign>[0]; export type JWTPayload = Parameters<typeof sign>[0];
export const strategyActions = ["create", "change"] as const;
export type StrategyActionName = (typeof strategyActions)[number];
export type StrategyAction<S extends TObject = TObject> = {
schema: S;
preprocess: (input: unknown) => Promise<Omit<DB["users"], "id" | "strategy">>;
};
export type StrategyActions = Partial<Record<StrategyActionName, StrategyAction>>;
// @todo: add schema to interface to ensure proper inference // @todo: add schema to interface to ensure proper inference
export interface Strategy { export interface Strategy {
getController: (auth: Authenticator) => Hono<any>; getController: (auth: Authenticator) => Hono<any>;
@@ -17,6 +33,7 @@ export interface Strategy {
getMode: () => "form" | "external"; getMode: () => "form" | "external";
getName: () => string; getName: () => string;
toJSON: (secrets?: boolean) => any; toJSON: (secrets?: boolean) => any;
getActions?: () => StrategyActions;
} }
export type User = { export type User = {
@@ -274,6 +291,14 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
return c.req.header("Content-Type") === "application/json"; return c.req.header("Content-Type") === "application/json";
} }
async getBody(c: Context) {
if (this.isJsonRequest(c)) {
return await c.req.json();
} else {
return Object.fromEntries((await c.req.formData()).entries());
}
}
private getSuccessPath(c: Context) { private getSuccessPath(c: Context) {
const p = (this.config.cookie.pathSuccess ?? "/").replace(/\/+$/, "/"); const p = (this.config.cookie.pathSuccess ?? "/").replace(/\/+$/, "/");
@@ -338,3 +363,13 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
}; };
} }
} }
export function createStrategyAction<S extends TObject>(
schema: S,
preprocess: (input: Static<S>) => Promise<Partial<DB["users"]>>
) {
return {
schema,
preprocess
} as StrategyAction<S>;
}

View File

@@ -2,6 +2,7 @@ import type { Authenticator, Strategy } from "auth";
import { type Static, StringEnum, Type, parse } from "core/utils"; import { type Static, StringEnum, Type, parse } from "core/utils";
import { hash } from "core/utils"; import { hash } from "core/utils";
import { type Context, Hono } from "hono"; import { type Context, Hono } from "hono";
import { type StrategyAction, type StrategyActions, createStrategyAction } from "../Authenticator";
type LoginSchema = { username: string; password: string } | { email: string; password: string }; type LoginSchema = { username: string; password: string } | { email: string; password: string };
type RegisterSchema = { email: string; password: string; [key: string]: any }; type RegisterSchema = { email: string; password: string; [key: string]: any };
@@ -54,17 +55,9 @@ export class PasswordStrategy implements Strategy {
getController(authenticator: Authenticator): Hono<any> { getController(authenticator: Authenticator): Hono<any> {
const hono = new Hono(); const hono = new Hono();
async function getBody(c: Context) {
if (authenticator.isJsonRequest(c)) {
return await c.req.json();
} else {
return Object.fromEntries((await c.req.formData()).entries());
}
}
return hono return hono
.post("/login", async (c) => { .post("/login", async (c) => {
const body = await getBody(c); const body = await authenticator.getBody(c);
try { try {
const payload = await this.login(body); const payload = await this.login(body);
@@ -76,7 +69,7 @@ export class PasswordStrategy implements Strategy {
} }
}) })
.post("/register", async (c) => { .post("/register", async (c) => {
const body = await getBody(c); const body = await authenticator.getBody(c);
const payload = await this.register(body); const payload = await this.register(body);
const data = await authenticator.resolve("register", this, payload.password, payload); const data = await authenticator.resolve("register", this, payload.password, payload);
@@ -85,6 +78,27 @@ export class PasswordStrategy implements Strategy {
}); });
} }
getActions(): StrategyActions {
return {
create: createStrategyAction(
Type.Object({
email: Type.String({
pattern: "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$"
}),
password: Type.String({
minLength: 8 // @todo: this should be configurable
})
}),
async ({ password, ...input }) => {
return {
...input,
strategy_value: await this.hash(password)
};
}
)
};
}
getSchema() { getSchema() {
return schema; return schema;
} }

View File

@@ -19,3 +19,5 @@ export { AppAuth, type UserFieldSchema } from "./AppAuth";
export { Guard, type GuardUserContext, type GuardConfig } from "./authorize/Guard"; export { Guard, type GuardUserContext, type GuardConfig } from "./authorize/Guard";
export { Role } from "./authorize/Role"; export { Role } from "./authorize/Role";
export * as AuthPermissions from "./auth-permissions";

View File

@@ -26,21 +26,23 @@ export const auth = (options?: {
skip?: (string | RegExp)[]; skip?: (string | RegExp)[];
}) => }) =>
createMiddleware<ServerEnv>(async (c, next) => { 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 app = c.get("app");
const skipped = shouldSkip(c, options?.skip) || !app?.module.auth.enabled;
const guard = app?.modules.ctx().guard; const guard = app?.modules.ctx().guard;
const authenticator = app?.module.auth.authenticator; const authenticator = app?.module.auth.authenticator;
let skipped = shouldSkip(c, options?.skip) || !app?.module.auth.enabled;
// make sure to only register once
if (c.get("auth_registered")) {
skipped = true;
console.warn(`auth middleware already registered for ${getPath(c)}`);
} else {
c.set("auth_registered", true);
if (!skipped) { if (!skipped) {
const resolved = c.get("auth_resolved"); const resolved = c.get("auth_resolved");
if (!resolved) { if (!resolved) {
if (!app.module.auth.enabled) { if (!app?.module.auth.enabled) {
guard?.setUserContext(undefined); guard?.setUserContext(undefined);
} else { } else {
guard?.setUserContext(await authenticator?.resolveAuthFromRequest(c)); guard?.setUserContext(await authenticator?.resolveAuthFromRequest(c));
@@ -48,6 +50,7 @@ export const auth = (options?: {
} }
} }
} }
}
await next(); await next();

View File

@@ -5,8 +5,13 @@ import type { Generated } from "kysely";
export type PrimaryFieldType = number | Generated<number>; export type PrimaryFieldType = number | Generated<number>;
// biome-ignore lint/suspicious/noEmptyInterface: <explanation> export interface DB {
export interface DB {} // make sure to make unknown as "any"
[key: string]: {
id: PrimaryFieldType;
[key: string]: any;
};
}
export const config = { export const config = {
server: { server: {

View File

@@ -1,17 +1,38 @@
export abstract class Event<Params = any> { export type EventClass = {
new (params: any): Event<any, any>;
slug: string;
};
export abstract class Event<Params = any, Returning = void> {
_returning!: Returning;
/** /**
* Unique event slug * Unique event slug
* Must be static, because registering events is done by class * Must be static, because registering events is done by class
*/ */
static slug: string = "untitled-event"; static slug: string = "untitled-event";
params: Params; params: Params;
returned: boolean = false;
validate(value: Returning): Event<Params, Returning> | void {
throw new EventReturnedWithoutValidation(this as any, value);
}
protected clone<This extends Event<Params, Returning> = Event<Params, Returning>>(
this: This,
params: Params
): This {
const cloned = new (this.constructor as any)(params);
cloned.returned = true;
return cloned as This;
}
constructor(params: Params) { constructor(params: Params) {
this.params = params; this.params = params;
} }
} }
// @todo: current workaround: potentially there is none and that's the way // @todo: current workaround: potentially there is "none" and that's the way
export class NoParamEvent extends Event<null> { export class NoParamEvent extends Event<null> {
static override slug: string = "noparam-event"; static override slug: string = "noparam-event";
@@ -19,3 +40,19 @@ export class NoParamEvent extends Event<null> {
super(null); super(null);
} }
} }
export class InvalidEventReturn extends Error {
constructor(expected: string, given: string) {
super(`Expected "${expected}", got "${given}"`);
}
}
export class EventReturnedWithoutValidation extends Error {
constructor(
event: EventClass,
public data: any
) {
// @ts-expect-error slug is static
super(`Event "${event.constructor.slug}" returned without validation`);
}
}

View File

@@ -4,15 +4,16 @@ import type { EventClass } from "./EventManager";
export const ListenerModes = ["sync", "async"] as const; export const ListenerModes = ["sync", "async"] as const;
export type ListenerMode = (typeof ListenerModes)[number]; export type ListenerMode = (typeof ListenerModes)[number];
export type ListenerHandler<E extends Event = Event> = ( export type ListenerHandler<E extends Event<any, any>> = (
event: E, event: E,
slug: string, slug: string
) => Promise<void> | void; ) => E extends Event<any, infer R> ? R | Promise<R | void> : never;
export class EventListener<E extends Event = Event> { export class EventListener<E extends Event = Event> {
mode: ListenerMode = "async"; mode: ListenerMode = "async";
event: EventClass; event: EventClass;
handler: ListenerHandler<E>; handler: ListenerHandler<E>;
once: boolean = false;
constructor(event: EventClass, handler: ListenerHandler<E>, mode: ListenerMode = "async") { constructor(event: EventClass, handler: ListenerHandler<E>, mode: ListenerMode = "async") {
this.event = event; this.event = event;

View File

@@ -1,14 +1,19 @@
import type { Event } from "./Event"; import { type Event, type EventClass, InvalidEventReturn } from "./Event";
import { EventListener, type ListenerHandler, type ListenerMode } from "./EventListener"; import { EventListener, type ListenerHandler, type ListenerMode } from "./EventListener";
export type RegisterListenerConfig =
| ListenerMode
| {
mode?: ListenerMode;
once?: boolean;
};
export interface EmitsEvents { export interface EmitsEvents {
emgr: EventManager; emgr: EventManager;
} }
export type EventClass = { // for compatibility, moved it to Event.ts
new (params: any): Event; export type { EventClass };
slug: string;
};
export class EventManager< export class EventManager<
RegisteredEvents extends Record<string, EventClass> = Record<string, EventClass> RegisteredEvents extends Record<string, EventClass> = Record<string, EventClass>
@@ -17,16 +22,20 @@ export class EventManager<
protected listeners: EventListener[] = []; protected listeners: EventListener[] = [];
enabled: boolean = true; enabled: boolean = true;
constructor(events?: RegisteredEvents, listeners?: EventListener[]) { constructor(
events?: RegisteredEvents,
private options?: {
listeners?: EventListener[];
onError?: (event: Event, e: unknown) => void;
onInvalidReturn?: (event: Event, e: InvalidEventReturn) => void;
asyncExecutor?: typeof Promise.all;
}
) {
if (events) { if (events) {
this.registerEvents(events); this.registerEvents(events);
} }
if (listeners) { options?.listeners?.forEach((l) => this.addListener(l));
for (const listener of listeners) {
this.addListener(listener);
}
}
} }
enable() { enable() {
@@ -82,9 +91,11 @@ export class EventManager<
return !!this.events.find((e) => slug === e.slug); return !!this.events.find((e) => slug === e.slug);
} }
protected throwIfEventNotRegistered(event: EventClass) { protected throwIfEventNotRegistered(event: EventClass | Event | string) {
if (!this.eventExists(event)) { if (!this.eventExists(event as any)) {
throw new Error(`Event "${event.slug}" not registered`); // @ts-expect-error
const name = event.constructor?.slug ?? event.slug ?? event;
throw new Error(`Event "${name}" not registered`);
} }
} }
@@ -117,55 +128,108 @@ export class EventManager<
return this; return this;
} }
protected createEventListener(
_event: EventClass | string,
handler: ListenerHandler<any>,
_config: RegisterListenerConfig = "async"
) {
const event =
typeof _event === "string" ? this.events.find((e) => e.slug === _event)! : _event;
const config = typeof _config === "string" ? { mode: _config } : _config;
const listener = new EventListener(event, handler, config.mode);
if (config.once) {
listener.once = true;
}
this.addListener(listener as any);
}
onEvent<ActualEvent extends EventClass, Instance extends InstanceType<ActualEvent>>( onEvent<ActualEvent extends EventClass, Instance extends InstanceType<ActualEvent>>(
event: ActualEvent, event: ActualEvent,
handler: ListenerHandler<Instance>, handler: ListenerHandler<Instance>,
mode: ListenerMode = "async" config?: RegisterListenerConfig
) { ) {
this.throwIfEventNotRegistered(event); this.createEventListener(event, handler, config);
const listener = new EventListener(event, handler, mode);
this.addListener(listener as any);
} }
on<Params = any>( on<Params = any>(
slug: string, slug: string,
handler: ListenerHandler<Event<Params>>, handler: ListenerHandler<Event<Params>>,
mode: ListenerMode = "async" config?: RegisterListenerConfig
) { ) {
const event = this.events.find((e) => e.slug === slug); this.createEventListener(slug, handler, config);
if (!event) {
throw new Error(`Event "${slug}" not registered`);
} }
this.onEvent(event, handler, mode); onAny(handler: ListenerHandler<Event<unknown>>, config?: RegisterListenerConfig) {
this.events.forEach((event) => this.onEvent(event, handler, config));
} }
onAny(handler: ListenerHandler<Event<unknown>>, mode: ListenerMode = "async") { protected executeAsyncs(promises: (() => Promise<void>)[]) {
this.events.forEach((event) => this.onEvent(event, handler, mode)); const executor = this.options?.asyncExecutor ?? ((e) => Promise.all(e));
executor(promises.map((p) => p())).then(() => void 0);
} }
async emit(event: Event) { async emit<Actual extends Event<any, any>>(event: Actual): Promise<Actual> {
// @ts-expect-error slug is static // @ts-expect-error slug is static
const slug = event.constructor.slug; const slug = event.constructor.slug;
if (!this.enabled) { if (!this.enabled) {
console.log("EventManager disabled, not emitting", slug); console.log("EventManager disabled, not emitting", slug);
return; return event;
} }
if (!this.eventExists(event)) { if (!this.eventExists(event)) {
throw new Error(`Event "${slug}" not registered`); throw new Error(`Event "${slug}" not registered`);
} }
const listeners = this.listeners.filter((listener) => listener.event.slug === slug); const syncs: EventListener[] = [];
//console.log("---!-- emitting", slug, listeners.length); const asyncs: (() => Promise<void>)[] = [];
this.listeners = this.listeners.filter((listener) => {
// if no match, keep and ignore
if (listener.event.slug !== slug) return true;
for (const listener of listeners) {
if (listener.mode === "sync") { if (listener.mode === "sync") {
await listener.handler(event, listener.event.slug); syncs.push(listener);
} else { } else {
listener.handler(event, listener.event.slug); asyncs.push(async () => await listener.handler(event, listener.event.slug));
}
// Remove if `once` is true, otherwise keep
return !listener.once;
});
// execute asyncs
this.executeAsyncs(asyncs);
// execute syncs
let _event: Actual = event;
for (const listener of syncs) {
try {
const return_value = (await listener.handler(_event, listener.event.slug)) as any;
if (typeof return_value !== "undefined") {
const newEvent = _event.validate(return_value);
// @ts-expect-error slug is static
if (newEvent && newEvent.constructor.slug === slug) {
if (!newEvent.returned) {
throw new Error(
// @ts-expect-error slug is static
`Returned event ${newEvent.constructor.slug} must be marked as returned.`
);
}
_event = newEvent as Actual;
} }
} }
} catch (e) {
if (e instanceof InvalidEventReturn) {
this.options?.onInvalidReturn?.(_event, e);
console.warn(`Invalid return of event listener for "${slug}": ${e.message}`);
} else if (this.options?.onError) {
this.options.onError(_event, e);
} else {
throw e;
}
}
}
return _event;
} }
} }

View File

@@ -1,8 +1,8 @@
export { Event, NoParamEvent } from "./Event"; export { Event, NoParamEvent, InvalidEventReturn } from "./Event";
export { export {
EventListener, EventListener,
ListenerModes, ListenerModes,
type ListenerMode, type ListenerMode,
type ListenerHandler, type ListenerHandler
} from "./EventListener"; } from "./EventListener";
export { EventManager, type EmitsEvents, type EventClass } from "./EventManager"; export { EventManager, type EmitsEvents, type EventClass } from "./EventManager";

View File

@@ -130,7 +130,10 @@ export class SchemaObject<Schema extends TObject> {
//console.log("overwritePaths", this.options?.overwritePaths); //console.log("overwritePaths", this.options?.overwritePaths);
if (this.options?.overwritePaths) { if (this.options?.overwritePaths) {
const keys = getFullPathKeys(value).map((k) => path + "." + k); const keys = getFullPathKeys(value).map((k) => {
// only prepend path if given
return path.length > 0 ? path + "." + k : k;
});
const overwritePaths = keys.filter((k) => { const overwritePaths = keys.filter((k) => {
return this.options?.overwritePaths?.some((p) => { return this.options?.overwritePaths?.some((p) => {
if (typeof p === "string") { if (typeof p === "string") {

View File

@@ -49,7 +49,7 @@ type LiteralExpressionCondition<Exps extends Expressions> = {
[key: string]: Primitive | ExpressionCondition<Exps>; [key: string]: Primitive | ExpressionCondition<Exps>;
}; };
const OperandOr = "$or"; const OperandOr = "$or" as const;
type OperandCondition<Exps extends Expressions> = { type OperandCondition<Exps extends Expressions> = {
[OperandOr]?: LiteralExpressionCondition<Exps> | ExpressionCondition<Exps>; [OperandOr]?: LiteralExpressionCondition<Exps> | ExpressionCondition<Exps>;
}; };

View File

@@ -12,3 +12,4 @@ export * from "./uuid";
export { FromSchema } from "./typebox/from-schema"; export { FromSchema } from "./typebox/from-schema";
export * from "./test"; export * from "./test";
export * from "./runtime"; export * from "./runtime";
export * from "./numbers";

View File

@@ -0,0 +1,5 @@
export function clampNumber(value: number, min: number, max: number): number {
const lower = Math.min(min, max);
const upper = Math.max(min, max);
return Math.max(lower, Math.min(value, upper));
}

View File

@@ -115,6 +115,7 @@ export function parse<Schema extends TSchema = TSchema>(
} else if (options?.onError) { } else if (options?.onError) {
options.onError(Errors(schema, data)); options.onError(Errors(schema, data));
} else { } else {
//console.warn("errors", JSON.stringify([...Errors(schema, data)], null, 2));
throw new TypeInvalidError(schema, data); throw new TypeInvalidError(schema, data);
} }

View File

@@ -69,18 +69,9 @@ export class AppData extends Module<typeof dataConfigSchema> {
} }
override getOverwritePaths() { override getOverwritePaths() {
return [ return [/^entities\..*\.config$/, /^entities\..*\.fields\..*\.config$/];
/^entities\..*\.config$/,
/^entities\..*\.fields\..*\.config$/
///^entities\..*\.fields\..*\.config\.schema$/
];
} }
/*registerController(server: AppServer) {
console.log("adding data controller to", this.basepath);
server.add(this.basepath, new DataController(this.em));
}*/
override toJSON(secrets?: boolean): AppDataConfig { override toJSON(secrets?: boolean): AppDataConfig {
return { return {
...this.config, ...this.config,

View File

@@ -1,15 +1,17 @@
import type { DB } from "core"; import type { DB } from "core";
import type { EntityData, RepoQuery, RepositoryResponse } from "data"; import type { EntityData, RepoQuery, RepoQueryIn, RepositoryResponse } from "data";
import { type BaseModuleApiOptions, ModuleApi, type PrimaryFieldType } from "modules"; import { type BaseModuleApiOptions, ModuleApi, type PrimaryFieldType } from "modules";
export type DataApiOptions = BaseModuleApiOptions & { export type DataApiOptions = BaseModuleApiOptions & {
defaultQuery?: Partial<RepoQuery>; queryLengthLimit: number;
defaultQuery: Partial<RepoQuery>;
}; };
export class DataApi extends ModuleApi<DataApiOptions> { export class DataApi extends ModuleApi<DataApiOptions> {
protected override getDefaultOptions(): Partial<DataApiOptions> { protected override getDefaultOptions(): Partial<DataApiOptions> {
return { return {
basepath: "/api/data", basepath: "/api/data",
queryLengthLimit: 1000,
defaultQuery: { defaultQuery: {
limit: 10 limit: 10
} }
@@ -19,26 +21,32 @@ export class DataApi extends ModuleApi<DataApiOptions> {
readOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>( readOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: E, entity: E,
id: PrimaryFieldType, id: PrimaryFieldType,
query: Partial<Omit<RepoQuery, "where" | "limit" | "offset">> = {} query: Omit<RepoQueryIn, "where" | "limit" | "offset"> = {}
) { ) {
return this.get<Pick<RepositoryResponse<Data>, "meta" | "data">>([entity as any, id], query); return this.get<Pick<RepositoryResponse<Data>, "meta" | "data">>([entity as any, id], query);
} }
readMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>( readMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: E, entity: E,
query: Partial<RepoQuery> = {} query: RepoQueryIn = {}
) { ) {
return this.get<Pick<RepositoryResponse<Data[]>, "meta" | "data">>( type T = Pick<RepositoryResponse<Data[]>, "meta" | "data">;
[entity as any],
query ?? this.options.defaultQuery const input = query ?? this.options.defaultQuery;
); const req = this.get<T>([entity as any], input);
if (req.request.url.length <= this.options.queryLengthLimit) {
return req;
}
return this.post<T>([entity as any, "query"], input);
} }
readManyByReference< readManyByReference<
E extends keyof DB | string, E extends keyof DB | string,
R extends keyof DB | string, R extends keyof DB | string,
Data = R extends keyof DB ? DB[R] : EntityData Data = R extends keyof DB ? DB[R] : EntityData
>(entity: E, id: PrimaryFieldType, reference: R, query: Partial<RepoQuery> = {}) { >(entity: E, id: PrimaryFieldType, reference: R, query: RepoQueryIn = {}) {
return this.get<Pick<RepositoryResponse<Data[]>, "meta" | "data">>( return this.get<Pick<RepositoryResponse<Data[]>, "meta" | "data">>(
[entity as any, id, reference], [entity as any, id, reference],
query ?? this.options.defaultQuery query ?? this.options.defaultQuery

View File

@@ -70,7 +70,7 @@ export class DataController extends Controller {
override getController() { override getController() {
const { permission, auth } = this.middlewares; const { permission, auth } = this.middlewares;
const hono = this.create().use(auth()); const hono = this.create().use(auth(), permission(SystemPermissions.accessApi));
const definedEntities = this.em.entities.map((e) => e.name); const definedEntities = this.em.entities.map((e) => e.name);
const tbNumber = Type.Transform(Type.String({ pattern: "^[1-9][0-9]{0,}$" })) const tbNumber = Type.Transform(Type.String({ pattern: "^[1-9][0-9]{0,}$" }))
@@ -85,8 +85,6 @@ export class DataController extends Controller {
return func; return func;
} }
hono.use("*", permission(SystemPermissions.accessApi));
// info // info
hono.get( hono.get(
"/", "/",
@@ -283,7 +281,7 @@ export class DataController extends Controller {
return c.notFound(); return c.notFound();
} }
const options = (await c.req.valid("json")) as RepoQuery; const options = (await c.req.valid("json")) as RepoQuery;
console.log("options", options); //console.log("options", options);
const result = await this.em.repository(entity).findMany(options); const result = await this.em.repository(entity).findMany(options);
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 }); return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });

View File

@@ -1,6 +1,5 @@
import type { DatabaseIntrospector, SqliteDatabase } from "kysely"; import { type DatabaseIntrospector, ParseJSONResultsPlugin, type SqliteDatabase } from "kysely";
import { Kysely, SqliteDialect } from "kysely"; import { Kysely, SqliteDialect } from "kysely";
import { DeserializeJsonValuesPlugin } from "../plugins/DeserializeJsonValuesPlugin";
import { SqliteConnection } from "./SqliteConnection"; import { SqliteConnection } from "./SqliteConnection";
import { SqliteIntrospector } from "./SqliteIntrospector"; import { SqliteIntrospector } from "./SqliteIntrospector";
@@ -14,7 +13,7 @@ class CustomSqliteDialect extends SqliteDialect {
export class SqliteLocalConnection extends SqliteConnection { export class SqliteLocalConnection extends SqliteConnection {
constructor(private database: SqliteDatabase) { constructor(private database: SqliteDatabase) {
const plugins = [new DeserializeJsonValuesPlugin()]; const plugins = [new ParseJSONResultsPlugin()];
const kysely = new Kysely({ const kysely = new Kysely({
dialect: new CustomSqliteDialect({ database }), dialect: new CustomSqliteDialect({ database }),
plugins plugins

View File

@@ -98,8 +98,8 @@ export class Entity<
getDefaultSort() { getDefaultSort() {
return { return {
by: this.config.sort_field, by: this.config.sort_field ?? "id",
dir: this.config.sort_dir dir: this.config.sort_dir ?? "asc"
}; };
} }
@@ -192,14 +192,41 @@ export class Entity<
this.data = data; this.data = data;
} }
isValidData(data: EntityData, context: TActionContext, explain?: boolean): boolean { // @todo: add tests
isValidData(
data: EntityData,
context: TActionContext,
options?: {
explain?: boolean;
ignoreUnknown?: boolean;
}
): boolean {
if (typeof data !== "object") {
if (options?.explain) {
throw new Error(`Entity "${this.name}" data must be an object`);
}
}
const fields = this.getFillableFields(context, false); const fields = this.getFillableFields(context, false);
//const fields = this.fields;
//console.log("data", data); if (options?.ignoreUnknown !== true) {
const field_names = fields.map((f) => f.name);
const given_keys = Object.keys(data);
const unknown_keys = given_keys.filter((key) => !field_names.includes(key));
if (unknown_keys.length > 0) {
if (options?.explain) {
throw new Error(
`Entity "${this.name}" data must only contain known keys, unknown: "${unknown_keys}"`
);
}
}
}
for (const field of fields) { for (const field of fields) {
if (!field.isValid(data[field.name], context)) { if (!field.isValid(data[field.name], context)) {
console.log("Entity.isValidData:invalid", context, field.name, data[field.name]); console.log("Entity.isValidData:invalid", context, field.name, data[field.name]);
if (explain) { if (options?.explain) {
throw new Error(`Field "${field.name}" has invalid data: "${data[field.name]}"`); throw new Error(`Field "${field.name}" has invalid data: "${data[field.name]}"`);
} }

View File

@@ -111,15 +111,18 @@ export class EntityManager<TBD extends object = DefaultDB> {
// caused issues because this.entity() was using a reference (for when initial config was given) // caused issues because this.entity() was using a reference (for when initial config was given)
} }
entity(e: Entity | keyof TBD | string): Entity { entity<Silent extends true | false = false>(
e: Entity | keyof TBD | string,
silent?: Silent
): Silent extends true ? Entity | undefined : Entity {
// make sure to always retrieve by name // make sure to always retrieve by name
const entity = this.entities.find((entity) => const entity = this.entities.find((entity) =>
e instanceof Entity ? entity.name === e.name : entity.name === e e instanceof Entity ? entity.name === e.name : entity.name === e
); );
if (!entity) { if (!entity) {
// @ts-ignore if (silent === true) return undefined as any;
throw new EntityNotDefinedException(e instanceof Entity ? e.name : e); throw new EntityNotDefinedException(e instanceof Entity ? e.name : (e as string));
} }
return entity; return entity;

View File

@@ -132,14 +132,17 @@ export class Mutator<
throw new Error(`Creation of system entity "${entity.name}" is disabled`); throw new Error(`Creation of system entity "${entity.name}" is disabled`);
} }
// @todo: establish the original order from "data" const result = await this.emgr.emit(
new Mutator.Events.MutatorInsertBefore({ entity, data: data as any })
);
// if listener returned, take what's returned
const _data = result.returned ? result.params.data : data;
const validatedData = { const validatedData = {
...entity.getDefaultObject(), ...entity.getDefaultObject(),
...(await this.getValidatedData(data, "create")) ...(await this.getValidatedData(_data, "create"))
}; };
await this.emgr.emit(new Mutator.Events.MutatorInsertBefore({ entity, data: validatedData }));
// check if required fields are present // check if required fields are present
const required = entity.getRequiredFields(); const required = entity.getRequiredFields();
for (const field of required) { for (const field of required) {
@@ -169,16 +172,17 @@ export class Mutator<
throw new Error("ID must be provided for update"); throw new Error("ID must be provided for update");
} }
const validatedData = await this.getValidatedData(data, "update"); const result = await this.emgr.emit(
await this.emgr.emit(
new Mutator.Events.MutatorUpdateBefore({ new Mutator.Events.MutatorUpdateBefore({
entity, entity,
entityId: id, entityId: id,
data: validatedData as any data
}) })
); );
const _data = result.returned ? result.params.data : data;
const validatedData = await this.getValidatedData(_data, "update");
const query = this.conn const query = this.conn
.updateTable(entity.name) .updateTable(entity.name)
.set(validatedData as any) .set(validatedData as any)

View File

@@ -65,7 +65,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
return this.em.connection.kysely; return this.em.connection.kysely;
} }
private getValidOptions(options?: Partial<RepoQuery>): RepoQuery { getValidOptions(options?: Partial<RepoQuery>): RepoQuery {
const entity = this.entity; const entity = this.entity;
// @todo: if not cloned deep, it will keep references and error if multiple requests come in // @todo: if not cloned deep, it will keep references and error if multiple requests come in
const validated = { const validated = {
@@ -103,17 +103,10 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
validated.select = options.select; validated.select = options.select;
} }
if (options.with && options.with.length > 0) { if (options.with) {
for (const entry of options.with) { const depth = WithBuilder.validateWiths(this.em, entity.name, options.with);
const related = this.em.relationOf(entity.name, entry); // @todo: determine allowed depth
if (!related) { validated.with = options.with;
throw new InvalidSearchParamsException(
`WITH: "${entry}" is not a relation of "${entity.name}"`
);
}
validated.with.push(entry);
}
} }
if (options.join && options.join.length > 0) { if (options.join && options.join.length > 0) {
@@ -235,43 +228,79 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
return { ...response, data: data[0]! }; return { ...response, data: data[0]! };
} }
private buildQuery( addOptionsToQueryBuilder(
_qb?: RepositoryQB,
_options?: Partial<RepoQuery>, _options?: Partial<RepoQuery>,
exclude_options: (keyof RepoQuery)[] = [] config?: {
): { qb: RepositoryQB; options: RepoQuery } { validate?: boolean;
ignore?: (keyof RepoQuery)[];
alias?: string;
defaults?: Pick<RepoQuery, "limit" | "offset">;
}
) {
const entity = this.entity; const entity = this.entity;
const options = this.getValidOptions(_options); let qb = _qb ?? (this.conn.selectFrom(entity.name) as RepositoryQB);
const alias = entity.name; const options = config?.validate !== false ? this.getValidOptions(_options) : _options;
if (!options) return qb;
const alias = config?.alias ?? entity.name;
const aliased = (field: string) => `${alias}.${field}`; const aliased = (field: string) => `${alias}.${field}`;
let qb = this.conn const ignore = config?.ignore ?? [];
.selectFrom(entity.name) const defaults = {
.select(entity.getAliasedSelectFrom(options.select, alias)); limit: 10,
offset: 0,
...config?.defaults
};
//console.log("build query options", options); /*console.log("build query options", {
if (!exclude_options.includes("with") && options.with) { entity: entity.name,
options,
config
});*/
if (!ignore.includes("select") && options.select) {
qb = qb.select(entity.getAliasedSelectFrom(options.select, alias));
}
if (!ignore.includes("with") && options.with) {
qb = WithBuilder.addClause(this.em, qb, entity, options.with); qb = WithBuilder.addClause(this.em, qb, entity, options.with);
} }
if (!exclude_options.includes("join") && options.join) { if (!ignore.includes("join") && options.join) {
qb = JoinBuilder.addClause(this.em, qb, entity, options.join); qb = JoinBuilder.addClause(this.em, qb, entity, options.join);
} }
// add where if present // add where if present
if (!exclude_options.includes("where") && options.where) { if (!ignore.includes("where") && options.where) {
qb = WhereBuilder.addClause(qb, options.where); qb = WhereBuilder.addClause(qb, options.where);
} }
if (!exclude_options.includes("limit")) qb = qb.limit(options.limit); if (!ignore.includes("limit")) qb = qb.limit(options.limit ?? defaults.limit);
if (!exclude_options.includes("offset")) qb = qb.offset(options.offset); if (!ignore.includes("offset")) qb = qb.offset(options.offset ?? defaults.offset);
// sorting // sorting
if (!exclude_options.includes("sort")) { if (!ignore.includes("sort")) {
qb = qb.orderBy(aliased(options.sort.by), options.sort.dir); qb = qb.orderBy(aliased(options.sort?.by ?? "id"), options.sort?.dir ?? "asc");
} }
//console.log("options", { _options, options, exclude_options }); return qb as RepositoryQB;
return { qb, options }; }
private buildQuery(
_options?: Partial<RepoQuery>,
ignore: (keyof RepoQuery)[] = []
): { qb: RepositoryQB; options: RepoQuery } {
const entity = this.entity;
const options = this.getValidOptions(_options);
return {
qb: this.addOptionsToQueryBuilder(undefined, options, {
ignore,
alias: entity.name
}),
options
};
} }
async findId( async findId(

View File

@@ -30,7 +30,7 @@ function key(e: unknown): string {
return e as string; return e as string;
} }
const expressions: TExpression<any, any, any>[] = [ const expressions = [
exp( exp(
"$eq", "$eq",
(v: Primitive) => isPrimitive(v), (v: Primitive) => isPrimitive(v),

View File

@@ -1,42 +1,82 @@
import { isObject } from "core/utils";
import type { KyselyJsonFrom, RepoQuery } from "data";
import { InvalidSearchParamsException } from "data/errors";
import type { Entity, EntityManager, RepositoryQB } from "../../entities"; import type { Entity, EntityManager, RepositoryQB } from "../../entities";
export class WithBuilder { export class WithBuilder {
private static buildClause( static addClause(
em: EntityManager<any>, em: EntityManager<any>,
qb: RepositoryQB, qb: RepositoryQB,
entity: Entity, entity: Entity,
withString: string withs: RepoQuery["with"]
) { ) {
const relation = em.relationOf(entity.name, withString); if (!withs || !isObject(withs)) {
if (!relation) { console.warn(`'withs' undefined or invalid, given: ${JSON.stringify(withs)}`);
throw new Error(`Relation "${withString}" not found`); return qb;
} }
const cardinality = relation.ref(withString).cardinality;
//console.log("with--builder", { entity: entity.name, withString, cardinality });
const fns = em.connection.fn; const fns = em.connection.fn;
const jsonFrom = cardinality === 1 ? fns.jsonObjectFrom : fns.jsonArrayFrom; let newQb = qb;
for (const [ref, query] of Object.entries(withs)) {
const relation = em.relationOf(entity.name, ref);
if (!relation) {
throw new Error(`Relation "${entity.name}<>${ref}" not found`);
}
const cardinality = relation.ref(ref).cardinality;
const jsonFrom: KyselyJsonFrom =
cardinality === 1 ? fns.jsonObjectFrom : fns.jsonArrayFrom;
if (!jsonFrom) { if (!jsonFrom) {
throw new Error("Connection does not support jsonObjectFrom/jsonArrayFrom"); throw new Error("Connection does not support jsonObjectFrom/jsonArrayFrom");
} }
try { const other = relation.other(entity);
return relation.buildWith(entity, qb, jsonFrom, withString); newQb = newQb.select((eb) => {
} catch (e) { let subQuery = relation.buildWith(entity, ref)(eb);
throw new Error(`Could not build "with" relation "${withString}": ${(e as any).message}`); if (query) {
} subQuery = em.repo(other.entity).addOptionsToQueryBuilder(subQuery, query as any, {
ignore: ["with", "join", cardinality === 1 ? "limit" : undefined].filter(
Boolean
) as any
});
} }
static addClause(em: EntityManager<any>, qb: RepositoryQB, entity: Entity, withs: string[]) { if (query.with) {
if (withs.length === 0) return qb; subQuery = WithBuilder.addClause(em, subQuery, other.entity, query.with as any);
}
let newQb = qb; return jsonFrom(subQuery).as(other.reference);
for (const entry of withs) { });
newQb = WithBuilder.buildClause(em, newQb, entity, entry);
} }
return newQb; return newQb;
} }
static validateWiths(em: EntityManager<any>, entity: string, withs: RepoQuery["with"]) {
let depth = 0;
if (!withs || !isObject(withs)) {
withs && console.warn(`'withs' invalid, given: ${JSON.stringify(withs)}`);
return depth;
}
const child_depths: number[] = [];
for (const [ref, query] of Object.entries(withs)) {
const related = em.relationOf(entity, ref);
if (!related) {
throw new InvalidSearchParamsException(
`WITH: "${ref}" is not a relation of "${entity}"`
);
}
depth++;
if ("with" in query) {
child_depths.push(WithBuilder.validateWiths(em, ref, query.with as any));
}
}
if (child_depths.length > 0) {
depth += Math.max(...child_depths);
}
return depth;
}
} }

View File

@@ -1,20 +1,48 @@
import type { PrimaryFieldType } from "core"; import type { PrimaryFieldType } from "core";
import { Event } from "core/events"; import { Event, InvalidEventReturn } from "core/events";
import type { Entity, EntityData } from "../entities"; import type { Entity, EntityData } from "../entities";
import type { RepoQuery } from "../server/data-query-impl"; import type { RepoQuery } from "../server/data-query-impl";
export class MutatorInsertBefore extends Event<{ entity: Entity; data: EntityData }> { export class MutatorInsertBefore extends Event<{ entity: Entity; data: EntityData }, EntityData> {
static override slug = "mutator-insert-before"; static override slug = "mutator-insert-before";
override validate(data: EntityData) {
const { entity } = this.params;
if (!entity.isValidData(data, "create")) {
throw new InvalidEventReturn("EntityData", "invalid");
}
return this.clone({
entity,
data
});
}
} }
export class MutatorInsertAfter extends Event<{ entity: Entity; data: EntityData }> { export class MutatorInsertAfter extends Event<{ entity: Entity; data: EntityData }> {
static override slug = "mutator-insert-after"; static override slug = "mutator-insert-after";
} }
export class MutatorUpdateBefore extends Event<{ export class MutatorUpdateBefore extends Event<
{
entity: Entity; entity: Entity;
entityId: PrimaryFieldType; entityId: PrimaryFieldType;
data: EntityData; data: EntityData;
}> { },
EntityData
> {
static override slug = "mutator-update-before"; static override slug = "mutator-update-before";
override validate(data: EntityData) {
const { entity, ...rest } = this.params;
if (!entity.isValidData(data, "update")) {
throw new InvalidEventReturn("EntityData", "invalid");
}
return this.clone({
...rest,
entity,
data
});
}
} }
export class MutatorUpdateAfter extends Event<{ export class MutatorUpdateAfter extends Event<{
entity: Entity; entity: Entity;

View File

@@ -12,6 +12,9 @@ import type { HTMLInputTypeAttribute, InputHTMLAttributes } from "react";
import type { EntityManager } from "../entities"; import type { EntityManager } from "../entities";
import { InvalidFieldConfigException, TransformPersistFailedException } from "../errors"; import { InvalidFieldConfigException, TransformPersistFailedException } from "../errors";
// @todo: contexts need to be reworked
// e.g. "table" is irrelevant, because if read is not given, it fails
export const ActionContext = ["create", "read", "update", "delete"] as const; export const ActionContext = ["create", "read", "update", "delete"] as const;
export type TActionContext = (typeof ActionContext)[number]; export type TActionContext = (typeof ActionContext)[number];
@@ -157,8 +160,12 @@ export abstract class Field<
return this.config.virtual ?? false; return this.config.virtual ?? false;
} }
getLabel(): string { getLabel(options?: { fallback?: boolean }): string | undefined {
return this.config.label ?? snakeToPascalWithSpaces(this.name); return this.config.label
? this.config.label
: options?.fallback !== false
? snakeToPascalWithSpaces(this.name)
: undefined;
} }
getDescription(): string | undefined { getDescription(): string | undefined {

View File

@@ -8,6 +8,7 @@ export * from "./prototype";
export { export {
type RepoQuery, type RepoQuery,
type RepoQueryIn,
defaultQuerySchema, defaultQuerySchema,
querySchema, querySchema,
whereSchema whereSchema

View File

@@ -1,5 +1,5 @@
import { type Static, Type, parse } from "core/utils"; import { type Static, Type, parse } from "core/utils";
import type { SelectQueryBuilder } from "kysely"; import type { ExpressionBuilder, SelectQueryBuilder } from "kysely";
import type { Entity, EntityData, EntityManager } from "../entities"; import type { Entity, EntityData, EntityManager } from "../entities";
import { import {
type EntityRelationAnchor, type EntityRelationAnchor,
@@ -67,10 +67,8 @@ export abstract class EntityRelation<
*/ */
abstract buildWith( abstract buildWith(
entity: Entity, entity: Entity,
qb: KyselyQueryBuilder,
jsonFrom: KyselyJsonFrom,
reference: string reference: string
): KyselyQueryBuilder; ): (eb: ExpressionBuilder<any, any>) => KyselyQueryBuilder;
abstract buildJoin( abstract buildJoin(
entity: Entity, entity: Entity,

View File

@@ -1,4 +1,5 @@
import { type Static, Type } from "core/utils"; import { type Static, Type } from "core/utils";
import type { ExpressionBuilder } from "kysely";
import { Entity, type EntityManager } from "../entities"; import { Entity, type EntityManager } from "../entities";
import { type Field, PrimaryField, VirtualField } from "../fields"; import { type Field, PrimaryField, VirtualField } from "../fields";
import type { RepoQuery } from "../server/data-query-impl"; import type { RepoQuery } from "../server/data-query-impl";
@@ -123,7 +124,7 @@ export class ManyToManyRelation extends EntityRelation<typeof ManyToManyRelation
.groupBy(groupBy); .groupBy(groupBy);
} }
buildWith(entity: Entity, qb: KyselyQueryBuilder, jsonFrom: KyselyJsonFrom) { buildWith(entity: Entity) {
if (!this.em) { if (!this.em) {
throw new Error("EntityManager not set, can't build"); throw new Error("EntityManager not set, can't build");
} }
@@ -138,7 +139,29 @@ export class ManyToManyRelation extends EntityRelation<typeof ManyToManyRelation
(f) => !(f instanceof RelationField || f instanceof PrimaryField) (f) => !(f instanceof RelationField || f instanceof PrimaryField)
); );
return qb.select((eb) => { return (eb: ExpressionBuilder<any, any>) =>
eb
.selectFrom(other.entity.name)
.select((eb2) => {
const select: any[] = other.entity.getSelect(other.entity.name);
if (additionalFields.length > 0) {
const conn = this.connectionEntity.name;
select.push(
jsonBuildObject(
Object.fromEntries(
additionalFields.map((f) => [f.name, eb2.ref(`${conn}.${f.name}`)])
)
).as(this.connectionTableMappedName)
);
}
return select;
})
.whereRef(entityRef, "=", otherRef)
.innerJoin(...join)
.limit(limit);
/*return qb.select((eb) => {
const select: any[] = other.entity.getSelect(other.entity.name); const select: any[] = other.entity.getSelect(other.entity.name);
// @todo: also add to find by references // @todo: also add to find by references
if (additionalFields.length > 0) { if (additionalFields.length > 0) {
@@ -160,7 +183,7 @@ export class ManyToManyRelation extends EntityRelation<typeof ManyToManyRelation
.innerJoin(...join) .innerJoin(...join)
.limit(limit) .limit(limit)
).as(other.reference); ).as(other.reference);
}); });*/
} }
initialize(em: EntityManager<any>) { initialize(em: EntityManager<any>) {

View File

@@ -1,6 +1,7 @@
import type { PrimaryFieldType } from "core"; import type { PrimaryFieldType } from "core";
import { snakeToPascalWithSpaces } from "core/utils"; import { snakeToPascalWithSpaces } from "core/utils";
import { type Static, Type } from "core/utils"; import { type Static, Type } from "core/utils";
import type { ExpressionBuilder } from "kysely";
import type { Entity, EntityManager } from "../entities"; import type { Entity, EntityManager } from "../entities";
import type { RepoQuery } from "../server/data-query-impl"; import type { RepoQuery } from "../server/data-query-impl";
import { EntityRelation, type KyselyJsonFrom, type KyselyQueryBuilder } from "./EntityRelation"; import { EntityRelation, type KyselyJsonFrom, type KyselyQueryBuilder } from "./EntityRelation";
@@ -155,23 +156,14 @@ export class ManyToOneRelation extends EntityRelation<typeof ManyToOneRelation.s
return qb.innerJoin(self.entity.name, entityRef, otherRef).groupBy(groupBy); return qb.innerJoin(self.entity.name, entityRef, otherRef).groupBy(groupBy);
} }
buildWith(entity: Entity, qb: KyselyQueryBuilder, jsonFrom: KyselyJsonFrom, reference: string) { buildWith(entity: Entity, reference: string) {
const { self, entityRef, otherRef, relationRef } = this.queryInfo(entity, reference); const { self, entityRef, otherRef, relationRef } = this.queryInfo(entity, reference);
const limit =
self.cardinality === 1
? 1
: this.config.with_limit ?? ManyToOneRelation.DEFAULTS.with_limit;
//console.log("buildWith", entity.name, reference, { limit });
return qb.select((eb) => return (eb: ExpressionBuilder<any, any>) =>
jsonFrom(
eb eb
.selectFrom(`${self.entity.name} as ${relationRef}`) .selectFrom(`${self.entity.name} as ${relationRef}`)
.select(self.entity.getSelect(relationRef))
.whereRef(entityRef, "=", otherRef) .whereRef(entityRef, "=", otherRef)
.limit(limit) .$if(self.cardinality === 1, (qb) => qb.limit(1));
).as(relationRef)
);
} }
/** /**

View File

@@ -1,4 +1,5 @@
import { type Static, Type } from "core/utils"; import { type Static, Type } from "core/utils";
import type { ExpressionBuilder } from "kysely";
import type { Entity, EntityManager } from "../entities"; import type { Entity, EntityManager } from "../entities";
import { NumberField, TextField } from "../fields"; import { NumberField, TextField } from "../fields";
import type { RepoQuery } from "../server/data-query-impl"; import type { RepoQuery } from "../server/data-query-impl";
@@ -87,20 +88,15 @@ export class PolymorphicRelation extends EntityRelation<typeof PolymorphicRelati
}; };
} }
buildWith(entity: Entity, qb: KyselyQueryBuilder, jsonFrom: KyselyJsonFrom) { buildWith(entity: Entity) {
const { other, whereLhs, reference, entityRef, otherRef } = this.queryInfo(entity); const { other, whereLhs, reference, entityRef, otherRef } = this.queryInfo(entity);
const limit = other.cardinality === 1 ? 1 : 5;
return qb.select((eb) => return (eb: ExpressionBuilder<any, any>) =>
jsonFrom(
eb eb
.selectFrom(other.entity.name) .selectFrom(other.entity.name)
.select(other.entity.getSelect(other.entity.name))
.where(whereLhs, "=", reference) .where(whereLhs, "=", reference)
.whereRef(entityRef, "=", otherRef) .whereRef(entityRef, "=", otherRef)
.limit(limit) .$if(other.cardinality === 1, (qb) => qb.limit(1));
).as(other.reference)
);
} }
override isListableFor(entity: Entity): boolean { override isListableFor(entity: Entity): boolean {

View File

@@ -1,3 +1,4 @@
import type { TThis } from "@sinclair/typebox";
import { import {
type SchemaOptions, type SchemaOptions,
type Static, type Static,
@@ -6,7 +7,7 @@ import {
Type, Type,
Value Value
} from "core/utils"; } from "core/utils";
import { WhereBuilder } from "../entities"; import { WhereBuilder, type WhereQuery } from "../entities";
const NumberOrString = (options: SchemaOptions = {}) => const NumberOrString = (options: SchemaOptions = {}) =>
Type.Transform(Type.Union([Type.Number(), Type.String()], options)) Type.Transform(Type.Union([Type.Number(), Type.String()], options))
@@ -14,10 +15,8 @@ const NumberOrString = (options: SchemaOptions = {}) =>
.Encode(String); .Encode(String);
const limit = NumberOrString({ default: 10 }); const limit = NumberOrString({ default: 10 });
const offset = NumberOrString({ default: 0 }); const offset = NumberOrString({ default: 0 });
// @todo: allow "id" and "-id"
const sort_default = { by: "id", dir: "asc" }; const sort_default = { by: "id", dir: "asc" };
const sort = Type.Transform( const sort = Type.Transform(
Type.Union( Type.Union(
@@ -27,20 +26,20 @@ const sort = Type.Transform(
} }
) )
) )
.Decode((value) => { .Decode((value): { by: string; dir: "asc" | "desc" } => {
if (typeof value === "string") { if (typeof value === "string") {
if (/^-?[a-zA-Z_][a-zA-Z0-9_.]*$/.test(value)) { if (/^-?[a-zA-Z_][a-zA-Z0-9_.]*$/.test(value)) {
const dir = value[0] === "-" ? "desc" : "asc"; const dir = value[0] === "-" ? "desc" : "asc";
return { by: dir === "desc" ? value.slice(1) : value, dir }; return { by: dir === "desc" ? value.slice(1) : value, dir } as any;
} else if (/^{.*}$/.test(value)) { } else if (/^{.*}$/.test(value)) {
return JSON.parse(value); return JSON.parse(value) as any;
} }
return sort_default; return sort_default as any;
} }
return value; return value as any;
}) })
.Encode(JSON.stringify); .Encode((value) => value);
const stringArray = Type.Transform( const stringArray = Type.Transform(
Type.Union([Type.String(), Type.Array(Type.String())], { default: [] }) Type.Union([Type.String(), Type.Array(Type.String())], { default: [] })
@@ -64,21 +63,63 @@ export const whereSchema = Type.Transform(
}) })
.Encode(JSON.stringify); .Encode(JSON.stringify);
export const querySchema = Type.Object( export type RepoWithSchema = Record<
string,
Omit<RepoQueryIn, "with"> & {
with?: unknown;
}
>;
export const withSchema = <TSelf extends TThis>(Self: TSelf) =>
Type.Transform(Type.Union([stringArray, Type.Record(Type.String(), Self)]))
.Decode((value) => {
let _value = typeof value === "string" ? [value] : value;
if (Array.isArray(value)) {
if (!value.every((v) => typeof v === "string")) {
throw new Error("Invalid 'with' schema");
}
_value = value.reduce((acc, v) => {
acc[v] = {};
return acc;
}, {} as RepoWithSchema);
}
return _value as RepoWithSchema;
})
.Encode((value) => value);
export const querySchema = Type.Recursive(
(Self) =>
Type.Partial(
Type.Object(
{ {
limit: Type.Optional(limit), limit: limit,
offset: Type.Optional(offset), offset: offset,
sort: Type.Optional(sort), sort: sort,
select: Type.Optional(stringArray), select: stringArray,
with: Type.Optional(stringArray), with: withSchema(Self),
join: Type.Optional(stringArray), join: stringArray,
where: Type.Optional(whereSchema) where: whereSchema
}, },
{ {
// @todo: determine if unknown is allowed, it's ignore anyway
additionalProperties: false additionalProperties: false
} }
)
),
{ $id: "query-schema" }
); );
export type RepoQueryIn = Static<typeof querySchema>; export type RepoQueryIn = {
limit?: number;
offset?: number;
sort?: string | { by: string; dir: "asc" | "desc" };
select?: string[];
with?: string[] | Record<string, RepoQueryIn>;
join?: string[];
where?: WhereQuery;
};
export type RepoQuery = Required<StaticDecode<typeof querySchema>>; export type RepoQuery = Required<StaticDecode<typeof querySchema>>;
export const defaultQuerySchema = Value.Default(querySchema, {}) as RepoQuery; export const defaultQuerySchema = Value.Default(querySchema, {}) as RepoQuery;

View File

@@ -12,6 +12,18 @@ export type { TAppFlowTaskSchema } from "./flows-schema";
export class AppFlows extends Module<typeof flowsConfigSchema> { export class AppFlows extends Module<typeof flowsConfigSchema> {
private flows: Record<string, Flow> = {}; private flows: Record<string, Flow> = {};
getSchema() {
return flowsConfigSchema;
}
private getFlowInfo(flow: Flow) {
return {
...flow.toJSON(),
tasks: flow.tasks.length,
connections: flow.connections
};
}
override async build() { override async build() {
//console.log("building flows", this.config); //console.log("building flows", this.config);
const flows = transformObject(this.config.flows, (flowConfig, name) => { const flows = transformObject(this.config.flows, (flowConfig, name) => {
@@ -67,15 +79,10 @@ export class AppFlows extends Module<typeof flowsConfigSchema> {
this.setBuilt(); this.setBuilt();
} }
getSchema() { override toJSON() {
return flowsConfigSchema;
}
private getFlowInfo(flow: Flow) {
return { return {
...flow.toJSON(), ...this.config,
tasks: flow.tasks.length, flows: transformObject(this.flows, (flow) => flow.toJSON())
connections: flow.connections
}; };
} }
} }

View File

@@ -62,7 +62,7 @@ export const flowSchema = Type.Object(
{ {
trigger: Type.Union(Object.values(triggerSchemaObject)), trigger: Type.Union(Object.values(triggerSchemaObject)),
tasks: Type.Optional(StringRecord(Type.Union(Object.values(taskSchemaObject)))), tasks: Type.Optional(StringRecord(Type.Union(Object.values(taskSchemaObject)))),
connections: Type.Optional(StringRecord(connectionSchema, { default: {} })), connections: Type.Optional(StringRecord(connectionSchema)),
start_task: Type.Optional(Type.String()), start_task: Type.Optional(Type.String()),
responding_task: Type.Optional(Type.String()) responding_task: Type.Optional(Type.String())
}, },

View File

@@ -162,8 +162,8 @@ export class Flow {
trigger: this.trigger.toJSON(), trigger: this.trigger.toJSON(),
tasks: Object.fromEntries(this.tasks.map((t) => [t.name, t.toJSON()])), tasks: Object.fromEntries(this.tasks.map((t) => [t.name, t.toJSON()])),
connections: Object.fromEntries(this.connections.map((c) => [c.id, c.toJSON()])), connections: Object.fromEntries(this.connections.map((c) => [c.id, c.toJSON()])),
start_task: this.startTask.name, start_task: this.startTask?.name,
responding_task: this.respondingTask ? this.respondingTask.name : null responding_task: this.respondingTask?.name
}; };
} }

View File

@@ -1,4 +1,4 @@
import { uuid } from "core/utils"; import { objectCleanEmpty, uuid } from "core/utils";
import { get } from "lodash-es"; import { get } from "lodash-es";
import type { Task, TaskResult } from "./Task"; import type { Task, TaskResult } from "./Task";
@@ -34,14 +34,14 @@ export class TaskConnection {
} }
toJSON() { toJSON() {
return { return objectCleanEmpty({
source: this.source.name, source: this.source.name,
target: this.target.name, target: this.target.name,
config: { config: {
...this.config, ...this.config,
condition: this.config.condition?.toJSON() condition: this.config.condition?.toJSON()
} }
}; });
} }
} }

View File

@@ -53,6 +53,8 @@ export class AppMedia extends Module<typeof mediaConfigSchema> {
index(media).on(["path"], true).on(["reference"]); index(media).on(["path"], true).on(["reference"]);
}) })
); );
this.setBuilt();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
throw new Error( throw new Error(

View File

@@ -1,5 +1,5 @@
import { type BaseModuleApiOptions, ModuleApi, type PrimaryFieldType } from "modules/ModuleApi"; import { type BaseModuleApiOptions, ModuleApi, type PrimaryFieldType } from "modules/ModuleApi";
import type { FileWithPath } from "ui/modules/media/components/dropzone/file-selector"; import type { FileWithPath } from "ui/elements/media/file-selector";
export type MediaApiOptions = BaseModuleApiOptions & {}; export type MediaApiOptions = BaseModuleApiOptions & {};

View File

@@ -1,7 +1,8 @@
import type { TObject, TString } from "@sinclair/typebox"; import type { TObject, TString } from "@sinclair/typebox";
import { type Constructor, Registry } from "core"; import { type Constructor, Registry } from "core";
export { MIME_TYPES } from "./storage/mime-types"; //export { MIME_TYPES } from "./storage/mime-types";
export { guess as guessMimeType } from "./storage/mime-types-tiny";
export { export {
Storage, Storage,
type StorageAdapter, type StorageAdapter,
@@ -19,7 +20,7 @@ import { type S3AdapterConfig, StorageS3Adapter } from "./storage/adapters/Stora
export { StorageS3Adapter, type S3AdapterConfig, StorageCloudinaryAdapter, type CloudinaryConfig }; export { StorageS3Adapter, type S3AdapterConfig, StorageCloudinaryAdapter, type CloudinaryConfig };
export * as StorageEvents from "./storage/events"; export * as StorageEvents from "./storage/events";
export { type FileUploadedEventData } from "./storage/events"; export type { FileUploadedEventData } from "./storage/events";
export * from "./utils"; export * from "./utils";
type ClassThatImplements<T> = Constructor<T> & { prototype: T }; type ClassThatImplements<T> = Constructor<T> & { prototype: T };

View File

@@ -10,7 +10,7 @@ export function getExtension(filename: string): string | undefined {
export function getRandomizedFilename(file: File, length?: number): string; export function getRandomizedFilename(file: File, length?: number): string;
export function getRandomizedFilename(file: string, length?: number): string; export function getRandomizedFilename(file: string, length?: number): string;
export function getRandomizedFilename(file: File | string, length = 16): string { export function getRandomizedFilename(file: File | string, length = 16): string {
const filename = file instanceof File ? file.name : file; const filename = typeof file === "string" ? file : file.name;
if (typeof filename !== "string") { if (typeof filename !== "string") {
console.error("Couldn't extract filename from", file); console.error("Couldn't extract filename from", file);

View File

@@ -3,9 +3,18 @@ import type { Guard } from "auth";
import { SchemaObject } from "core"; import { SchemaObject } from "core";
import type { EventManager } from "core/events"; import type { EventManager } from "core/events";
import type { Static, TSchema } from "core/utils"; import type { Static, TSchema } from "core/utils";
import type { Connection, EntityIndex, EntityManager, em as prototypeEm } from "data"; import {
type Connection,
type EntityIndex,
type EntityManager,
type Field,
FieldPrototype,
make,
type em as prototypeEm
} from "data";
import { Entity } from "data"; import { Entity } from "data";
import type { Hono } from "hono"; import type { Hono } from "hono";
import { isEqual } from "lodash-es";
export type ServerEnv = { export type ServerEnv = {
Variables: { Variables: {
@@ -146,28 +155,33 @@ export abstract class Module<Schema extends TSchema = TSchema, ConfigSchema = St
} }
protected ensureEntity(entity: Entity) { protected ensureEntity(entity: Entity) {
const instance = this.ctx.em.entity(entity.name, true);
// check fields // check fields
if (!this.ctx.em.hasEntity(entity.name)) { if (!instance) {
this.ctx.em.addEntity(entity); this.ctx.em.addEntity(entity);
this.ctx.flags.sync_required = true; this.ctx.flags.sync_required = true;
return; return;
} }
const instance = this.ctx.em.entity(entity.name);
// if exists, check all fields required are there // if exists, check all fields required are there
// @todo: check if the field also equal // @todo: check if the field also equal
for (const field of instance.fields) { for (const field of entity.fields) {
const _field = entity.field(field.name); const instanceField = instance.field(field.name);
if (!_field) { if (!instanceField) {
entity.addField(field); instance.addField(field);
this.ctx.flags.sync_required = true; this.ctx.flags.sync_required = true;
} else {
const changes = this.setEntityFieldConfigs(field, instanceField);
if (changes > 0) {
this.ctx.flags.sync_required = true;
}
} }
} }
// replace entity (mainly to keep the ensured type) // replace entity (mainly to keep the ensured type)
this.ctx.em.__replaceEntity( this.ctx.em.__replaceEntity(
new Entity(entity.name, entity.fields, instance.config, entity.type) new Entity(instance.name, instance.fields, instance.config, entity.type)
); );
} }
@@ -184,4 +198,35 @@ export abstract class Module<Schema extends TSchema = TSchema, ConfigSchema = St
return schema; return schema;
} }
protected setEntityFieldConfigs(
parent: Field,
child: Field,
props: string[] = ["hidden", "fillable", "required"]
) {
let changes = 0;
for (const prop of props) {
if (!isEqual(child.config[prop], parent.config[prop])) {
child.config[prop] = parent.config[prop];
changes++;
}
}
return changes;
}
protected replaceEntityField(
_entity: string | Entity,
field: Field | string,
_newField: Field | FieldPrototype
) {
const entity = this.ctx.em.entity(_entity);
const name = typeof field === "string" ? field : field.name;
const newField =
_newField instanceof FieldPrototype ? make(name, _newField as any) : _newField;
// ensure keeping vital config
this.setEntityFieldConfigs(entity.field(name)!, newField);
entity.__replaceField(name, newField);
}
} }

View File

@@ -1,5 +1,6 @@
import { type PrimaryFieldType, isDebug } from "core"; import type { PrimaryFieldType } from "core";
import { encodeSearch } from "core/utils"; import { isDebug } from "core/env";
import { encodeSearch } from "core/utils/reqres";
export type { PrimaryFieldType }; export type { PrimaryFieldType };
export type BaseModuleApiOptions = { export type BaseModuleApiOptions = {
@@ -39,6 +40,13 @@ export abstract class ModuleApi<Options extends BaseModuleApiOptions = BaseModul
} as Options; } as Options;
} }
/**
* used for SWR invalidation of basepath
*/
key(): string {
return this.options.basepath ?? "";
}
protected getUrl(path: string) { protected getUrl(path: string) {
const basepath = this.options.basepath ?? ""; const basepath = this.options.basepath ?? "";
return this.options.host + (basepath + "/" + path).replace(/\/{2,}/g, "/").replace(/\/$/, ""); return this.options.host + (basepath + "/" + path).replace(/\/{2,}/g, "/").replace(/\/$/, "");

View File

@@ -88,6 +88,7 @@ export type ModuleManagerOptions = {
}; };
type ConfigTable<Json = ModuleConfigs> = { type ConfigTable<Json = ModuleConfigs> = {
id?: number;
version: number; version: number;
type: "config" | "diff" | "backup"; type: "config" | "diff" | "backup";
json: Json; json: Json;
@@ -236,10 +237,10 @@ export class ModuleManager {
private async fetch(): Promise<ConfigTable> { private async fetch(): Promise<ConfigTable> {
this.logger.context("fetch").log("fetching"); this.logger.context("fetch").log("fetching");
const startTime = performance.now();
// disabling console log, because the table might not exist yet // disabling console log, because the table might not exist yet
return await withDisabledConsole(async () => { const result = await withDisabledConsole(async () => {
const startTime = performance.now();
const { data: result } = await this.repo().findOne( const { data: result } = await this.repo().findOne(
{ type: "config" }, { type: "config" },
{ {
@@ -251,9 +252,16 @@ export class ModuleManager {
throw BkndError.with("no config"); throw BkndError.with("no config");
} }
this.logger.log("took", performance.now() - startTime, "ms", result.version).clear(); return result as unknown as ConfigTable;
return result as ConfigTable;
}, ["log", "error", "warn"]); }, ["log", "error", "warn"]);
this.logger
.log("took", performance.now() - startTime, "ms", {
version: result.version,
id: result.id
})
.clear();
return result;
} }
async save() { async save() {
@@ -329,6 +337,9 @@ export class ModuleManager {
} }
} }
// re-apply configs to all modules (important for system entities)
this.setConfigs(configs);
// @todo: cleanup old versions? // @todo: cleanup old versions?
this.logger.clear(); this.logger.clear();
@@ -387,6 +398,7 @@ export class ModuleManager {
} }
private setConfigs(configs: ModuleConfigs): void { private setConfigs(configs: ModuleConfigs): void {
this.logger.log("setting configs");
objectEach(configs, (config, key) => { objectEach(configs, (config, key) => {
try { try {
// setting "noEmit" to true, to not force listeners to update // setting "noEmit" to true, to not force listeners to update

View File

@@ -44,6 +44,11 @@ export class SystemController extends Controller {
hono.use(permission(SystemPermissions.configRead)); hono.use(permission(SystemPermissions.configRead));
hono.get("/raw", permission([SystemPermissions.configReadSecrets]), async (c) => {
// @ts-expect-error "fetch" is private
return c.json(await this.app.modules.fetch());
});
hono.get( hono.get(
"/:module?", "/:module?",
tb("param", Type.Object({ module: Type.Optional(StringEnum(MODULE_NAMES)) })), tb("param", Type.Object({ module: Type.Optional(StringEnum(MODULE_NAMES)) })),

View File

@@ -1,7 +1,10 @@
import { IconAlertHexagon } from "@tabler/icons-react";
import type { ModuleConfigs, ModuleSchemas } from "modules"; import type { ModuleConfigs, ModuleSchemas } from "modules";
import { getDefaultConfig, getDefaultSchema } from "modules/ModuleManager"; import { getDefaultConfig, getDefaultSchema } from "modules/ModuleManager";
import { createContext, startTransition, useContext, useEffect, useRef, useState } from "react"; import { createContext, startTransition, useContext, useEffect, useRef, useState } from "react";
import { useApi } from "ui/client"; import { useApi } from "ui/client";
import { Button } from "ui/components/buttons/Button";
import { Alert } from "ui/components/display/Alert";
import { type TSchemaActions, getSchemaActions } from "./schema/actions"; import { type TSchemaActions, getSchemaActions } from "./schema/actions";
import { AppReduced } from "./utils/AppReduced"; import { AppReduced } from "./utils/AppReduced";
@@ -10,6 +13,7 @@ type BkndContext = {
schema: ModuleSchemas; schema: ModuleSchemas;
config: ModuleConfigs; config: ModuleConfigs;
permissions: string[]; permissions: string[];
hasSecrets: boolean;
requireSecrets: () => Promise<void>; requireSecrets: () => Promise<void>;
actions: ReturnType<typeof getSchemaActions>; actions: ReturnType<typeof getSchemaActions>;
app: AppReduced; app: AppReduced;
@@ -32,7 +36,9 @@ export function BkndProvider({
const [schema, setSchema] = const [schema, setSchema] =
useState<Pick<BkndContext, "version" | "schema" | "config" | "permissions">>(); useState<Pick<BkndContext, "version" | "schema" | "config" | "permissions">>();
const [fetched, setFetched] = useState(false); const [fetched, setFetched] = useState(false);
const [error, setError] = useState<boolean>();
const errorShown = useRef<boolean>(); const errorShown = useRef<boolean>();
const [local_version, set_local_version] = useState(0);
const api = useApi(); const api = useApi();
async function reloadSchema() { async function reloadSchema() {
@@ -49,15 +55,11 @@ export function BkndProvider({
if (!res.ok) { if (!res.ok) {
if (errorShown.current) return; if (errorShown.current) return;
errorShown.current = true; errorShown.current = true;
/*notifications.show({
title: "Failed to fetch schema", setError(true);
// @ts-ignore return;
message: body.error, } else if (error) {
color: "red", setError(false);
position: "top-right",
autoClose: false,
withCloseButton: true
});*/
} }
const schema = res.ok const schema = res.ok
@@ -80,6 +82,7 @@ export function BkndProvider({
setSchema(schema); setSchema(schema);
setWithSecrets(_includeSecrets); setWithSecrets(_includeSecrets);
setFetched(true); setFetched(true);
set_local_version((v) => v + 1);
}); });
} }
@@ -96,9 +99,24 @@ export function BkndProvider({
if (!fetched || !schema) return fallback; if (!fetched || !schema) return fallback;
const app = new AppReduced(schema?.config as any); const app = new AppReduced(schema?.config as any);
const actions = getSchemaActions({ api, setSchema, reloadSchema }); const actions = getSchemaActions({ api, setSchema, reloadSchema });
const hasSecrets = withSecrets && !error;
return ( return (
<BkndContext.Provider value={{ ...schema, actions, requireSecrets, app, adminOverride }}> <BkndContext.Provider
value={{ ...schema, actions, requireSecrets, app, adminOverride, hasSecrets }}
key={local_version}
>
{error && (
<Alert.Exception className="gap-2">
<IconAlertHexagon />
You attempted to load system configuration with secrets without having proper
permission.
<a href={schema.config.server.admin.basepath || "/"}>
<Button variant="red">Reload</Button>
</a>
</Alert.Exception>
)}
{children} {children}
</BkndContext.Provider> </BkndContext.Provider>
); );

View File

@@ -1,5 +1,5 @@
import { Api, type ApiOptions, type TApiUser } from "Api"; import { Api, type ApiOptions, type TApiUser } from "Api";
import { createContext, useContext, useEffect, useState } from "react"; import { createContext, useContext } from "react";
const ClientContext = createContext<{ baseUrl: string; api: Api }>({ const ClientContext = createContext<{ baseUrl: string; api: Api }>({
baseUrl: undefined baseUrl: undefined
@@ -12,7 +12,6 @@ export type ClientProviderProps = {
}; };
export const ClientProvider = ({ children, baseUrl, user }: ClientProviderProps) => { export const ClientProvider = ({ children, baseUrl, user }: ClientProviderProps) => {
//const [actualBaseUrl, setActualBaseUrl] = useState<string | null>(null);
const winCtx = useBkndWindowContext(); const winCtx = useBkndWindowContext();
const _ctx_baseUrl = useBaseUrl(); const _ctx_baseUrl = useBaseUrl();
let actualBaseUrl = baseUrl ?? _ctx_baseUrl ?? ""; let actualBaseUrl = baseUrl ?? _ctx_baseUrl ?? "";
@@ -31,6 +30,7 @@ export const ClientProvider = ({ children, baseUrl, user }: ClientProviderProps)
console.error("error .....", e); console.error("error .....", e);
} }
console.log("api init", { host: actualBaseUrl, user: user ?? winCtx.user });
const api = new Api({ host: actualBaseUrl, user: user ?? winCtx.user }); const api = new Api({ host: actualBaseUrl, user: user ?? winCtx.user });
return ( return (

View File

@@ -1,5 +1,5 @@
import type { Api } from "Api"; import type { Api } from "Api";
import type { FetchPromise, ResponseObject } from "modules/ModuleApi"; import type { FetchPromise, ModuleApi, ResponseObject } from "modules/ModuleApi";
import useSWR, { type SWRConfiguration, useSWRConfig } from "swr"; import useSWR, { type SWRConfiguration, useSWRConfig } from "swr";
import { useApi } from "ui/client"; import { useApi } from "ui/client";
@@ -27,12 +27,19 @@ export const useApiQuery = <
}; };
}; };
export const useInvalidate = () => { export const useInvalidate = (options?: { exact?: boolean }) => {
const mutate = useSWRConfig().mutate; const mutate = useSWRConfig().mutate;
const api = useApi(); const api = useApi();
return async (arg?: string | ((api: Api) => FetchPromise<any>)) => { return async (arg?: string | ((api: Api) => FetchPromise<any> | ModuleApi<any>)) => {
if (!arg) return async () => mutate(""); let key = "";
return mutate(typeof arg === "string" ? arg : arg(api).key()); if (typeof arg === "string") {
key = arg;
} else if (typeof arg === "function") {
key = arg(api).key();
}
if (options?.exact) return mutate(key);
return mutate((k) => typeof k === "string" && k.startsWith(key));
}; };
}; };

View File

@@ -1,6 +1,7 @@
import type { DB, PrimaryFieldType } from "core"; import type { DB, PrimaryFieldType } from "core";
import { encodeSearch, objectTransform } from "core/utils"; import { objectTransform } from "core/utils/objects";
import type { EntityData, RepoQuery } from "data"; import { encodeSearch } from "core/utils/reqres";
import type { EntityData, RepoQueryIn } from "data";
import type { ModuleApi, ResponseObject } from "modules/ModuleApi"; import type { ModuleApi, ResponseObject } from "modules/ModuleApi";
import useSWR, { type SWRConfiguration, mutate } from "swr"; import useSWR, { type SWRConfiguration, mutate } from "swr";
import { type Api, useApi } from "ui/client"; import { type Api, useApi } from "ui/client";
@@ -22,15 +23,6 @@ export class UseEntityApiError<Payload = any> extends Error {
} }
} }
function Test() {
const { read } = useEntity("users");
async () => {
const data = await read();
};
return null;
}
export const useEntity = < export const useEntity = <
Entity extends keyof DB | string, Entity extends keyof DB | string,
Id extends PrimaryFieldType | undefined = undefined, Id extends PrimaryFieldType | undefined = undefined,
@@ -49,7 +41,7 @@ export const useEntity = <
} }
return res; return res;
}, },
read: async (query: Partial<RepoQuery> = {}) => { read: async (query: RepoQueryIn = {}) => {
const res = id ? await api.readOne(entity, id!, query) : await api.readMany(entity, query); const res = id ? await api.readOne(entity, id!, query) : await api.readMany(entity, query);
if (!res.ok) { if (!res.ok) {
throw new UseEntityApiError(res as any, `Failed to read entity "${entity}"`); throw new UseEntityApiError(res as any, `Failed to read entity "${entity}"`);
@@ -88,7 +80,7 @@ export function makeKey(
api: ModuleApi, api: ModuleApi,
entity: string, entity: string,
id?: PrimaryFieldType, id?: PrimaryFieldType,
query?: Partial<RepoQuery> query?: RepoQueryIn
) { ) {
return ( return (
"/" + "/" +
@@ -105,11 +97,11 @@ export const useEntityQuery = <
>( >(
entity: Entity, entity: Entity,
id?: Id, id?: Id,
query?: Partial<RepoQuery>, query?: RepoQueryIn,
options?: SWRConfiguration & { enabled?: boolean; revalidateOnMutate?: boolean } options?: SWRConfiguration & { enabled?: boolean; revalidateOnMutate?: boolean }
) => { ) => {
const api = useApi().data; const api = useApi().data;
const key = makeKey(api, entity, id, query); const key = makeKey(api, entity as string, id, query);
const { read, ...actions } = useEntity<Entity, Id>(entity, id); const { read, ...actions } = useEntity<Entity, Id>(entity, id);
const fetcher = () => read(query); const fetcher = () => read(query);
@@ -121,7 +113,7 @@ export const useEntityQuery = <
}); });
const mutateAll = async () => { const mutateAll = async () => {
const entityKey = makeKey(api, entity); const entityKey = makeKey(api, entity as string);
return mutate((key) => typeof key === "string" && key.startsWith(entityKey), undefined, { return mutate((key) => typeof key === "string" && key.startsWith(entityKey), undefined, {
revalidate: true revalidate: true
}); });
@@ -167,7 +159,7 @@ export async function mutateEntityCache<
return prev; return prev;
} }
const entityKey = makeKey(api, entity); const entityKey = makeKey(api, entity as string);
return mutate( return mutate(
(key) => typeof key === "string" && key.startsWith(entityKey), (key) => typeof key === "string" && key.startsWith(entityKey),

View File

@@ -1,7 +1,6 @@
import { Api, type AuthState } from "Api"; import type { AuthState } from "Api";
import type { AuthResponse } from "auth"; import type { AuthResponse } from "auth";
import type { AppAuthSchema } from "auth/auth-schema"; import { useState } from "react";
import { useEffect, useState } from "react";
import { useApi, useInvalidate } from "ui/client"; import { useApi, useInvalidate } from "ui/client";
type LoginData = { type LoginData = {
@@ -73,23 +72,3 @@ export const useAuth = (options?: { baseUrl?: string }): UseAuth => {
verify verify
}; };
}; };
type AuthStrategyData = Pick<AppAuthSchema, "strategies" | "basepath">;
export const useAuthStrategies = (options?: { baseUrl?: string }): Partial<AuthStrategyData> & {
loading: boolean;
} => {
const [data, setData] = useState<AuthStrategyData>();
const api = useApi(options?.baseUrl);
useEffect(() => {
(async () => {
const res = await api.auth.strategies();
//console.log("res", res);
if (res.res.ok) {
setData(res.body);
}
})();
}, [options?.baseUrl]);
return { strategies: data?.strategies, basepath: data?.basepath, loading: !data };
};

View File

@@ -12,6 +12,7 @@ import {
} from "data/data-schema"; } from "data/data-schema";
import { useBknd } from "ui/client/bknd"; import { useBknd } from "ui/client/bknd";
import type { TSchemaActions } from "ui/client/schema/actions"; import type { TSchemaActions } from "ui/client/schema/actions";
import { bkndModals } from "ui/modals";
export function useBkndData() { export function useBkndData() {
const { config, app, schema, actions: bkndActions } = useBknd(); const { config, app, schema, actions: bkndActions } = useBknd();
@@ -62,7 +63,13 @@ export function useBkndData() {
} }
}; };
const $data = { const $data = {
entity: (name: string) => entities[name] entity: (name: string) => entities[name],
modals,
system: (name: string) => ({
any: entities[name]?.type === "system",
users: name === config.auth.entity_name,
media: name === config.media.entity_name
})
}; };
return { return {
@@ -75,6 +82,35 @@ export function useBkndData() {
}; };
} }
const modals = {
createAny: () => bkndModals.open(bkndModals.ids.dataCreate, {}),
createEntity: () =>
bkndModals.open(bkndModals.ids.dataCreate, {
initialPath: ["entities", "entity"],
initialState: { action: "entity" }
}),
createRelation: (entity?: string) =>
bkndModals.open(bkndModals.ids.dataCreate, {
initialPath: ["entities", "relation"],
initialState: {
action: "relation",
relations: {
create: [{ source: entity, type: "n:1" } as any]
}
}
}),
createMedia: (entity?: string) =>
bkndModals.open(bkndModals.ids.dataCreate, {
initialPath: ["entities", "template-media"],
initialState: {
action: "template-media",
initial: {
entity
}
}
})
};
function entityFieldActions(bkndActions: TSchemaActions, entityName: string) { function entityFieldActions(bkndActions: TSchemaActions, entityName: string) {
return { return {
add: async (name: string, field: TAppDataField) => { add: async (name: string, field: TAppDataField) => {

View File

@@ -4,15 +4,15 @@ import { twMerge } from "tailwind-merge";
import { Link } from "ui/components/wouter/Link"; import { Link } from "ui/components/wouter/Link";
const sizes = { const sizes = {
small: "px-2 py-1.5 rounded-md gap-1.5 text-sm", small: "px-2 py-1.5 rounded-md gap-1 text-sm",
default: "px-3 py-2.5 rounded-md gap-2.5", default: "px-3 py-2.5 rounded-md gap-1.5",
large: "px-4 py-3 rounded-md gap-3 text-lg" large: "px-4 py-3 rounded-md gap-2.5 text-lg"
}; };
const iconSizes = { const iconSizes = {
small: 15, small: 12,
default: 18, default: 16,
large: 22 large: 20
}; };
const styles = { const styles = {

View File

@@ -10,9 +10,9 @@ export type IconType =
const styles = { const styles = {
xs: { className: "p-0.5", size: 13 }, xs: { className: "p-0.5", size: 13 },
sm: { className: "p-0.5", size: 16 }, sm: { className: "p-0.5", size: 15 },
md: { className: "p-1", size: 20 }, md: { className: "p-1", size: 18 },
lg: { className: "p-1.5", size: 24 } lg: { className: "p-1.5", size: 22 }
} as const; } as const;
interface IconButtonProps extends ComponentPropsWithoutRef<"button"> { interface IconButtonProps extends ComponentPropsWithoutRef<"button"> {

View File

@@ -1,8 +1,22 @@
import { default as CodeMirror, type ReactCodeMirrorProps } from "@uiw/react-codemirror"; import { default as CodeMirror, type ReactCodeMirrorProps } from "@uiw/react-codemirror";
import { useBknd } from "ui/client/bknd"; import { useBknd } from "ui/client/bknd";
export default function CodeEditor({ editable, basicSetup, ...props }: ReactCodeMirrorProps) { import { json } from "@codemirror/lang-json";
import { type LiquidCompletionConfig, liquid } from "@codemirror/lang-liquid";
export type CodeEditorProps = ReactCodeMirrorProps & {
_extensions?: Partial<{
json: boolean;
liquid: LiquidCompletionConfig;
}>;
};
export default function CodeEditor({
editable,
basicSetup,
_extensions = {},
...props
}: CodeEditorProps) {
const b = useBknd(); const b = useBknd();
const theme = b.app.getAdminConfig().color_scheme; const theme = b.app.getAdminConfig().color_scheme;
const _basicSetup: Partial<ReactCodeMirrorProps["basicSetup"]> = !editable const _basicSetup: Partial<ReactCodeMirrorProps["basicSetup"]> = !editable
@@ -13,11 +27,24 @@ export default function CodeEditor({ editable, basicSetup, ...props }: ReactCode
} }
: basicSetup; : basicSetup;
const extensions = Object.entries(_extensions ?? {})
.map(([ext, config]: any) => {
switch (ext) {
case "json":
return json();
case "liquid":
return liquid(config);
}
return undefined;
})
.filter(Boolean) as any;
return ( return (
<CodeMirror <CodeMirror
theme={theme === "dark" ? "dark" : "light"} theme={theme === "dark" ? "dark" : "light"}
editable={editable} editable={editable}
basicSetup={_basicSetup} basicSetup={_basicSetup}
extensions={extensions}
{...props} {...props}
/> />
); );

View File

@@ -1,10 +1,9 @@
import { json } from "@codemirror/lang-json";
import type { ReactCodeMirrorProps } from "@uiw/react-codemirror";
import { Suspense, lazy } from "react"; import { Suspense, lazy } from "react";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import type { CodeEditorProps } from "./CodeEditor";
const CodeEditor = lazy(() => import("./CodeEditor")); const CodeEditor = lazy(() => import("./CodeEditor"));
export function JsonEditor({ editable, className, ...props }: ReactCodeMirrorProps) { export function JsonEditor({ editable, className, ...props }: CodeEditorProps) {
return ( return (
<Suspense fallback={null}> <Suspense fallback={null}>
<CodeEditor <CodeEditor
@@ -14,7 +13,7 @@ export function JsonEditor({ editable, className, ...props }: ReactCodeMirrorPro
className className
)} )}
editable={editable} editable={editable}
extensions={[json()]} _extensions={{ json: true }}
{...props} {...props}
/> />
</Suspense> </Suspense>

View File

@@ -1,7 +1,7 @@
import { liquid } from "@codemirror/lang-liquid";
import type { ReactCodeMirrorProps } from "@uiw/react-codemirror";
import { Suspense, lazy } from "react"; import { Suspense, lazy } from "react";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import type { CodeEditorProps } from "./CodeEditor";
const CodeEditor = lazy(() => import("./CodeEditor")); const CodeEditor = lazy(() => import("./CodeEditor"));
const filters = [ const filters = [
@@ -106,7 +106,7 @@ const tags = [
{ label: "when" } { label: "when" }
]; ];
export function LiquidJsEditor({ editable, ...props }: ReactCodeMirrorProps) { export function LiquidJsEditor({ editable, ...props }: CodeEditorProps) {
return ( return (
<Suspense fallback={null}> <Suspense fallback={null}>
<CodeEditor <CodeEditor
@@ -115,7 +115,9 @@ export function LiquidJsEditor({ editable, ...props }: ReactCodeMirrorProps) {
!editable && "opacity-70" !editable && "opacity-70"
)} )}
editable={editable} editable={editable}
extensions={[liquid({ filters, tags })]} _extensions={{
liquid: { filters, tags }
}}
{...props} {...props}
/> />
</Suspense> </Suspense>

View File

@@ -6,16 +6,27 @@ export type AlertProps = ComponentPropsWithoutRef<"div"> & {
visible?: boolean; visible?: boolean;
title?: string; title?: string;
message?: ReactNode | string; message?: ReactNode | string;
children?: ReactNode;
}; };
const Base: React.FC<AlertProps> = ({ visible = true, title, message, className, ...props }) => const Base: React.FC<AlertProps> = ({
visible = true,
title,
message,
className,
children,
...props
}) =>
visible ? ( visible ? (
<div <div
{...props} {...props}
className={twMerge("flex flex-row dark:bg-amber-300/20 bg-amber-200 p-4", className)} className={twMerge(
"flex flex-row items-center dark:bg-amber-300/20 bg-amber-200 p-4",
className
)}
> >
{title && <b className="mr-2">{title}:</b>} {title && <b className="mr-2">{title}:</b>}
{message} {message || children}
</div> </div>
) : null; ) : null;

View File

@@ -1,33 +1,33 @@
import { Button } from "../buttons/Button"; import { twMerge } from "tailwind-merge";
import { Button, type ButtonProps } from "../buttons/Button";
export type EmptyProps = { export type EmptyProps = {
Icon?: any; Icon?: any;
title?: string; title?: string;
description?: string; description?: string;
buttonText?: string; primary?: ButtonProps;
buttonOnClick?: () => void; secondary?: ButtonProps;
className?: string;
}; };
export const Empty: React.FC<EmptyProps> = ({ export const Empty: React.FC<EmptyProps> = ({
Icon = undefined, Icon = undefined,
title = undefined, title = undefined,
description = "Check back later my friend.", description = "Check back later my friend.",
buttonText, primary,
buttonOnClick secondary,
className
}) => ( }) => (
<div className="flex flex-col h-full w-full justify-center items-center"> <div className={twMerge("flex flex-col h-full w-full justify-center items-center", className)}>
<div className="flex flex-col gap-3 items-center max-w-80"> <div className="flex flex-col gap-3 items-center max-w-80">
{Icon && <Icon size={48} className="opacity-50" stroke={1} />} {Icon && <Icon size={48} className="opacity-50" stroke={1} />}
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{title && <h3 className="text-center text-lg font-bold">{title}</h3>} {title && <h3 className="text-center text-lg font-bold">{title}</h3>}
<p className="text-center text-primary/60">{description}</p> <p className="text-center text-primary/60">{description}</p>
</div> </div>
{buttonText && ( <div className="mt-1.5 flex flex-row gap-2">
<div className="mt-1.5"> {secondary && <Button variant="default" {...secondary} />}
<Button variant="primary" onClick={buttonOnClick}> {primary && <Button variant="primary" {...primary} />}
{buttonText}
</Button>
</div> </div>
)}
</div> </div>
</div> </div>
); );

View File

@@ -1,7 +1,24 @@
import { IconLockAccessOff } from "@tabler/icons-react";
import { Empty, type EmptyProps } from "./Empty"; import { Empty, type EmptyProps } from "./Empty";
const NotFound = (props: Partial<EmptyProps>) => <Empty title="Not Found" {...props} />; const NotFound = (props: Partial<EmptyProps>) => <Empty title="Not Found" {...props} />;
const NotAllowed = (props: Partial<EmptyProps>) => <Empty title="Not Allowed" {...props} />;
const MissingPermission = ({
what,
...props
}: Partial<EmptyProps> & {
what?: string;
}) => (
<Empty
Icon={IconLockAccessOff}
title="Missing Permission"
description={`You're not allowed to access ${what ?? "this"}.`}
{...props}
/>
);
export const Message = { export const Message = {
NotFound NotFound,
NotAllowed,
MissingPermission
}; };

View File

@@ -0,0 +1,29 @@
import { Switch } from "@mantine/core";
import { forwardRef, useEffect, useState } from "react";
export const BooleanInputMantine = forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
(props, ref) => {
const [checked, setChecked] = useState(Boolean(props.value));
useEffect(() => {
setChecked(Boolean(props.value));
}, [props.value]);
function handleCheck(e) {
setChecked(e.target.checked);
props.onChange?.(e.target.checked);
}
return (
<div className="flex flex-row">
<Switch
ref={ref}
checked={checked}
onChange={handleCheck}
disabled={props.disabled}
id={props.id}
/>
</div>
);
}
);

View File

@@ -1,11 +1,10 @@
import { Switch } from "@mantine/core";
import { getBrowser } from "core/utils"; import { getBrowser } from "core/utils";
import type { Field } from "data"; import type { Field } from "data";
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react"; import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
import { TbCalendar, TbChevronDown, TbInfoCircle } from "react-icons/tb"; import { TbCalendar, TbChevronDown, TbInfoCircle } from "react-icons/tb";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { useEvent } from "../../hooks/use-event"; import { IconButton } from "ui/components/buttons/IconButton";
import { IconButton } from "../buttons/IconButton"; import { useEvent } from "ui/hooks/use-event";
export const Group: React.FC<React.ComponentProps<"div"> & { error?: boolean }> = ({ export const Group: React.FC<React.ComponentProps<"div"> & { error?: boolean }> = ({
error, error,
@@ -131,17 +130,6 @@ export const BooleanInput = forwardRef<HTMLInputElement, React.ComponentProps<"i
} }
return ( return (
<div className="flex flex-row">
<Switch
ref={ref}
checked={checked}
onChange={handleCheck}
disabled={props.disabled}
id={props.id}
/>
</div>
);
/*return (
<div className="h-11 flex items-center"> <div className="h-11 flex items-center">
<input <input
{...props} {...props}
@@ -153,7 +141,7 @@ export const BooleanInput = forwardRef<HTMLInputElement, React.ComponentProps<"i
disabled={props.disabled} disabled={props.disabled}
/> />
</div> </div>
);*/ );
} }
); );

View File

@@ -0,0 +1,17 @@
import { BooleanInputMantine } from "./BooleanInputMantine";
import { DateInput, Input, Textarea } from "./components";
export const formElementFactory = (element: string, props: any) => {
switch (element) {
case "date":
return DateInput;
case "boolean":
return BooleanInputMantine;
case "textarea":
return Textarea;
default:
return Input;
}
};
export * from "./components";

View File

@@ -15,12 +15,13 @@ export type JsonSchemaFormProps = any & {
schema: RJSFSchema | Schema; schema: RJSFSchema | Schema;
uiSchema?: any; uiSchema?: any;
direction?: "horizontal" | "vertical"; direction?: "horizontal" | "vertical";
onChange?: (value: any) => void; onChange?: (value: any, isValid: () => boolean) => void;
}; };
export type JsonSchemaFormRef = { export type JsonSchemaFormRef = {
formData: () => any; formData: () => any;
validateForm: () => boolean; validateForm: () => boolean;
silentValidate: () => boolean;
cancel: () => void; cancel: () => void;
}; };
@@ -52,15 +53,18 @@ export const JsonSchemaForm = forwardRef<JsonSchemaFormRef, JsonSchemaFormProps>
const handleChange = ({ formData }: any, e) => { const handleChange = ({ formData }: any, e) => {
const clean = JSON.parse(JSON.stringify(formData)); const clean = JSON.parse(JSON.stringify(formData));
//console.log("Data changed: ", clean, JSON.stringify(formData, null, 2)); //console.log("Data changed: ", clean, JSON.stringify(formData, null, 2));
onChange?.(clean);
setValue(clean); setValue(clean);
onChange?.(clean, () => isValid(clean));
}; };
const isValid = (data: any) => validator.validateFormData(data, schema).errors.length === 0;
useImperativeHandle( useImperativeHandle(
ref, ref,
() => ({ () => ({
formData: () => value, formData: () => value,
validateForm: () => formRef.current!.validateForm(), validateForm: () => formRef.current!.validateForm(),
silentValidate: () => isValid(value),
cancel: () => formRef.current!.reset() cancel: () => formRef.current!.reset()
}), }),
[value] [value]

View File

@@ -1,7 +1,14 @@
import { useClickOutside } from "@mantine/hooks"; import { useClickOutside } from "@mantine/hooks";
import { Fragment, type ReactElement, cloneElement, useState } from "react"; import { clampNumber } from "core/utils";
import {
type ComponentPropsWithoutRef,
Fragment,
type ReactElement,
cloneElement,
useState
} from "react";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { useEvent } from "../../hooks/use-event"; import { useEvent } from "ui/hooks/use-event";
export type DropdownItem = export type DropdownItem =
| (() => JSX.Element) | (() => JSX.Element)
@@ -14,26 +21,33 @@ export type DropdownItem =
[key: string]: any; [key: string]: any;
}; };
export type DropdownClickableChild = ReactElement<{ onClick: () => void }>;
export type DropdownProps = { export type DropdownProps = {
className?: string; className?: string;
openEvent?: "onClick" | "onContextMenu";
defaultOpen?: boolean; defaultOpen?: boolean;
title?: string | ReactElement;
dropdownWrapperProps?: Omit<ComponentPropsWithoutRef<"div">, "style">;
position?: "bottom-start" | "bottom-end" | "top-start" | "top-end"; position?: "bottom-start" | "bottom-end" | "top-start" | "top-end";
hideOnEmpty?: boolean; hideOnEmpty?: boolean;
items: (DropdownItem | undefined | boolean)[]; items: (DropdownItem | undefined | boolean)[];
itemsClassName?: string; itemsClassName?: string;
children: ReactElement<{ onClick: () => void }>; children: DropdownClickableChild;
onClickItem?: (item: DropdownItem) => void; onClickItem?: (item: DropdownItem) => void;
renderItem?: ( renderItem?: (
item: DropdownItem, item: DropdownItem,
props: { key: number; onClick: () => void } props: { key: number; onClick: () => void }
) => ReactElement<{ onClick: () => void }>; ) => DropdownClickableChild;
}; };
export function Dropdown({ export function Dropdown({
children, children,
defaultOpen = false, defaultOpen = false,
position = "bottom-start", openEvent = "onClick",
position: initialPosition = "bottom-start",
dropdownWrapperProps,
items, items,
title,
hideOnEmpty = true, hideOnEmpty = true,
onClickItem, onClickItem,
renderItem, renderItem,
@@ -41,19 +55,58 @@ export function Dropdown({
className className
}: DropdownProps) { }: DropdownProps) {
const [open, setOpen] = useState(defaultOpen); const [open, setOpen] = useState(defaultOpen);
const [position, setPosition] = useState(initialPosition);
const clickoutsideRef = useClickOutside(() => setOpen(false)); const clickoutsideRef = useClickOutside(() => setOpen(false));
const menuItems = items.filter(Boolean) as DropdownItem[]; const menuItems = items.filter(Boolean) as DropdownItem[];
const [_offset, _setOffset] = useState(0);
const toggle = useEvent((delay: number = 50) => const toggle = useEvent((delay: number = 50) =>
setTimeout(() => setOpen((prev) => !prev), typeof delay === "number" ? delay : 0) setTimeout(() => setOpen((prev) => !prev), typeof delay === "number" ? delay : 0)
); );
const onClickHandler = openEvent === "onClick" ? toggle : undefined;
const onContextMenuHandler = useEvent((e) => {
if (openEvent !== "onContextMenu") return;
e.preventDefault();
if (open) {
toggle(0);
setTimeout(() => {
setPosition(initialPosition);
_setOffset(0);
}, 10);
return;
}
// minimal popper impl, get pos and boundaries
const x = e.clientX - e.currentTarget.getBoundingClientRect().left;
const { left = 0, right = 0 } = clickoutsideRef.current?.getBoundingClientRect() ?? {};
// only if boundaries gien
if (left > 0 && right > 0) {
const safe = clampNumber(x, left, right);
// if pos less than half, go left
if (x < (left + right) / 2) {
setPosition("bottom-start");
_setOffset(safe);
} else {
setPosition("bottom-end");
_setOffset(right - safe);
}
} else {
setPosition(initialPosition);
_setOffset(0);
}
toggle();
});
const offset = 4; const offset = 4;
const dropdownStyle = { const dropdownStyle = {
"bottom-start": { top: "100%", left: 0, marginTop: offset }, "bottom-start": { top: "100%", left: _offset, marginTop: offset },
"bottom-end": { right: 0, top: "100%", marginTop: offset }, "bottom-end": { right: _offset, top: "100%", marginTop: offset },
"top-start": { bottom: "100%", marginBottom: offset }, "top-start": { bottom: "100%", marginBottom: offset },
"top-end": { bottom: "100%", right: 0, marginBottom: offset } "top-end": { bottom: "100%", right: _offset, marginBottom: offset }
}[position]; }[position];
const internalOnClickItem = useEvent((item) => { const internalOnClickItem = useEvent((item) => {
@@ -94,13 +147,25 @@ export function Dropdown({
)); ));
return ( return (
<div role="dropdown" className={twMerge("relative flex", className)} ref={clickoutsideRef}> <div
{cloneElement(children as any, { onClick: toggle })} role="dropdown"
className={twMerge("relative flex", className)}
ref={clickoutsideRef}
onContextMenu={onContextMenuHandler}
>
{cloneElement(children as any, { onClick: onClickHandler })}
{open && ( {open && (
<div <div
className="absolute z-30 flex flex-col bg-background border border-muted px-1 py-1 rounded-lg shadow-lg min-w-full" {...dropdownWrapperProps}
className={twMerge(
"absolute z-30 flex flex-col bg-background border border-muted px-1 py-1 rounded-lg shadow-lg min-w-full",
dropdownWrapperProps?.className
)}
style={dropdownStyle} style={dropdownStyle}
> >
{title && (
<div className="text-sm font-bold px-2.5 mb-1 mt-1 opacity-50">{title}</div>
)}
{menuItems.map((item, i) => {menuItems.map((item, i) =>
itemRenderer(item, { key: i, onClick: () => internalOnClickItem(item) }) itemRenderer(item, { key: i, onClick: () => internalOnClickItem(item) })
)} )}

View File

@@ -10,6 +10,7 @@ import {
export type TStepsProps = { export type TStepsProps = {
children: any; children: any;
initialPath?: string[]; initialPath?: string[];
initialState?: any;
lastBack?: () => void; lastBack?: () => void;
[key: string]: any; [key: string]: any;
}; };
@@ -19,13 +20,14 @@ type TStepContext<T = any> = {
stepBack: () => void; stepBack: () => void;
close: () => void; close: () => void;
state: T; state: T;
path: string[];
setState: Dispatch<SetStateAction<T>>; setState: Dispatch<SetStateAction<T>>;
}; };
const StepContext = createContext<TStepContext>(undefined as any); const StepContext = createContext<TStepContext>(undefined as any);
export function Steps({ children, initialPath = [], lastBack }: TStepsProps) { export function Steps({ children, initialPath = [], initialState = {}, lastBack }: TStepsProps) {
const [state, setState] = useState<any>({}); const [state, setState] = useState<any>(initialState);
const [path, setPath] = useState<string[]>(initialPath); const [path, setPath] = useState<string[]>(initialPath);
const steps: any[] = Children.toArray(children).filter( const steps: any[] = Children.toArray(children).filter(
(child: any) => child.props.disabled !== true (child: any) => child.props.disabled !== true
@@ -46,7 +48,7 @@ export function Steps({ children, initialPath = [], lastBack }: TStepsProps) {
const current = steps.find((step) => step.props.id === path[path.length - 1]) || steps[0]; const current = steps.find((step) => step.props.id === path[path.length - 1]) || steps[0];
return ( return (
<StepContext.Provider value={{ nextStep, stepBack, state, setState, close: lastBack! }}> <StepContext.Provider value={{ nextStep, stepBack, state, path, setState, close: lastBack! }}>
{current} {current}
</StepContext.Provider> </StepContext.Provider>
); );

View File

@@ -1,13 +1,13 @@
import type { ValueError } from "@sinclair/typebox/value"; import type { ValueError } from "@sinclair/typebox/value";
import type { AppAuthOAuthStrategy, AppAuthSchema } from "auth/auth-schema"; import type { AppAuthOAuthStrategy, AppAuthSchema } from "auth/auth-schema";
import clsx from "clsx";
import { type TSchema, Type, Value } from "core/utils"; import { type TSchema, Type, Value } from "core/utils";
import { Form, type Validator } from "json-schema-form-react"; import { Form, type Validator } from "json-schema-form-react";
import { transform } from "lodash-es"; import { transform } from "lodash-es";
import type { ComponentPropsWithoutRef } from "react"; import type { ComponentPropsWithoutRef } from "react";
import { twMerge } from "tailwind-merge";
import { Button } from "ui/components/buttons/Button"; import { Button } from "ui/components/buttons/Button";
import { Group, Input, Label } from "ui/components/form/Formy"; import { Group, Input, Label } from "ui/components/form/Formy/components";
import { SocialLink } from "ui/modules/auth/SocialLink"; import { SocialLink } from "./SocialLink";
export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "onSubmit" | "action"> & { export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "onSubmit" | "action"> & {
className?: string; className?: string;
@@ -86,7 +86,7 @@ export function AuthForm({
schema={schema} schema={schema}
validator={validator} validator={validator}
validationMode="change" validationMode="change"
className={twMerge("flex flex-col gap-3 w-full", className)} className={clsx("flex flex-col gap-3 w-full", className)}
> >
{({ errors, submitting }) => ( {({ errors, submitting }) => (
<> <>

View File

@@ -1,8 +1,6 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useAuthStrategies } from "ui/client/schema/auth/use-auth"; import { useAuthStrategies } from "../hooks/use-auth";
import { Logo } from "ui/components/display/Logo"; import { AuthForm } from "./AuthForm";
import { Link } from "ui/components/wouter/Link";
import { AuthForm } from "ui/modules/auth/AuthForm";
export type AuthScreenProps = { export type AuthScreenProps = {
method?: "POST" | "GET"; method?: "POST" | "GET";
@@ -18,13 +16,7 @@ export function AuthScreen({ method = "POST", action = "login", logo, intro }: A
<div className="flex flex-1 flex-col select-none h-dvh w-dvw justify-center items-center bknd-admin"> <div className="flex flex-1 flex-col select-none h-dvh w-dvw justify-center items-center bknd-admin">
{!loading && ( {!loading && (
<div className="flex flex-col gap-4 items-center w-96 px-6 py-7"> <div className="flex flex-col gap-4 items-center w-96 px-6 py-7">
{typeof logo !== "undefined" ? ( {logo ? logo : null}
logo
) : (
<Link href={"/"} className="link">
<Logo scale={0.25} />
</Link>
)}
{typeof intro !== "undefined" ? ( {typeof intro !== "undefined" ? (
intro intro
) : ( ) : (

View File

@@ -0,0 +1,9 @@
import { AuthForm } from "./AuthForm";
import { AuthScreen } from "./AuthScreen";
import { SocialLink } from "./SocialLink";
export const Auth = {
Screen: AuthScreen,
Form: AuthForm,
SocialLink: SocialLink
};

View File

@@ -0,0 +1,23 @@
import type { AppAuthSchema } from "auth/auth-schema";
import { useEffect, useState } from "react";
import { useApi } from "ui/client";
type AuthStrategyData = Pick<AppAuthSchema, "strategies" | "basepath">;
export const useAuthStrategies = (options?: { baseUrl?: string }): Partial<AuthStrategyData> & {
loading: boolean;
} => {
const [data, setData] = useState<AuthStrategyData>();
const api = useApi(options?.baseUrl);
useEffect(() => {
(async () => {
const res = await api.auth.strategies();
//console.log("res", res);
if (res.res.ok) {
setData(res.body);
}
})();
}, [options?.baseUrl]);
return { strategies: data?.strategies, basepath: data?.basepath, loading: !data };
};

View File

@@ -1,2 +1,2 @@
export { Auth } from "ui/modules/auth/index"; export { Auth } from "./auth";
export * from "./media"; export * from "./media";

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