mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
Merge remote-tracking branch 'origin/release/0.6' into refactor/optimize-ui-bundle-size
# Conflicts: # app/build.ts # app/package.json
This commit is contained in:
70
app/__test__/api/DataApi.spec.ts
Normal file
70
app/__test__/api/DataApi.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,8 @@ describe("ModuleApi", () => {
|
||||
it("fetches endpoint", async () => {
|
||||
const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" }));
|
||||
const api = new Api({ host });
|
||||
|
||||
// @ts-expect-error it's protected
|
||||
api.fetcher = app.request as typeof fetch;
|
||||
|
||||
const res = await api.get("/endpoint");
|
||||
@@ -40,6 +42,8 @@ describe("ModuleApi", () => {
|
||||
it("has accessible request", async () => {
|
||||
const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" }));
|
||||
const api = new Api({ host });
|
||||
|
||||
// @ts-expect-error it's protected
|
||||
api.fetcher = app.request as typeof fetch;
|
||||
|
||||
const promise = api.get("/endpoint");
|
||||
|
||||
73
app/__test__/app/repro.spec.ts
Normal file
73
app/__test__/app/repro.spec.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,12 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { Event, EventManager, NoParamEvent } from "../../src/core/events";
|
||||
import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test";
|
||||
import { Event, EventManager, InvalidEventReturn, NoParamEvent } from "../../src/core/events";
|
||||
import { disableConsoleLog, enableConsoleLog } from "../helper";
|
||||
|
||||
beforeAll(disableConsoleLog);
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
class SpecialEvent extends Event<{ foo: string }> {
|
||||
static slug = "special-event";
|
||||
static override slug = "special-event";
|
||||
|
||||
isBar() {
|
||||
return this.params.foo === "bar";
|
||||
@@ -10,37 +14,139 @@ class SpecialEvent extends Event<{ foo: string }> {
|
||||
}
|
||||
|
||||
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 () => {
|
||||
test("test", async () => {
|
||||
test("executes", async () => {
|
||||
const call = mock(() => null);
|
||||
const delayed = mock(() => null);
|
||||
|
||||
const emgr = new EventManager();
|
||||
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(
|
||||
SpecialEvent,
|
||||
async (event, name) => {
|
||||
console.log("Event: ", name, event.params.foo, event.isBar());
|
||||
console.log("wait...");
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
console.log("done waiting");
|
||||
expect(name).toBe("special-event");
|
||||
expect(event.isBar()).toBe(true);
|
||||
call();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
delayed();
|
||||
},
|
||||
"sync"
|
||||
);
|
||||
|
||||
// don't allow unknown
|
||||
expect(() => emgr.on("unknown", () => void 0)).toThrow();
|
||||
|
||||
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" }));
|
||||
console.log("done");
|
||||
await emgr.emit(new InformationalEvent());
|
||||
|
||||
// expect construct signatures to not cause ts errors
|
||||
new SpecialEvent({ foo: "bar" });
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { Value } from "../../src/core/utils";
|
||||
import { WhereBuilder, type WhereQuery, querySchema } from "../../src/data";
|
||||
import { Value, _jsonp } from "../../src/core/utils";
|
||||
import { type RepoQuery, WhereBuilder, type WhereQuery, querySchema } from "../../src/data";
|
||||
import type { RepoQueryIn } from "../../src/data/server/data-query-impl";
|
||||
import { getDummyConnection } from "./helper";
|
||||
|
||||
const decode = (input: RepoQueryIn, expected: RepoQuery) => {
|
||||
const result = Value.Decode(querySchema, input);
|
||||
expect(result).toEqual(expected);
|
||||
};
|
||||
|
||||
describe("data-query-impl", () => {
|
||||
function qb() {
|
||||
const c = getDummyConnection();
|
||||
@@ -88,21 +94,47 @@ describe("data-query-impl", () => {
|
||||
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", () => {
|
||||
test("sort", async () => {
|
||||
const decode = (input: any, expected: any) => {
|
||||
const result = Value.Decode(querySchema, input);
|
||||
expect(result.sort).toEqual(expected);
|
||||
};
|
||||
const _dflt = { by: "id", dir: "asc" };
|
||||
const _dflt = { sort: { by: "id", dir: "asc" } };
|
||||
|
||||
decode({ sort: "" }, _dflt);
|
||||
decode({ sort: "name" }, { by: "name", dir: "asc" });
|
||||
decode({ sort: "-name" }, { by: "name", dir: "desc" });
|
||||
decode({ sort: "-posts.name" }, { by: "posts.name", dir: "desc" });
|
||||
decode({ sort: "name" }, { sort: { by: "name", dir: "asc" } });
|
||||
decode({ sort: "-name" }, { sort: { by: "name", dir: "desc" } });
|
||||
decode({ sort: "-posts.name" }, { sort: { by: "posts.name", dir: "desc" } });
|
||||
decode({ sort: "-1name" }, _dflt);
|
||||
decode({ sort: { by: "name", dir: "desc" } }, { by: "name", dir: "desc" });
|
||||
decode({ sort: { by: "name", dir: "desc" } }, { sort: { by: "name", dir: "desc" } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -106,7 +106,6 @@ describe("Relations", async () => {
|
||||
expect(postAuthorRel?.other(posts).entity).toBe(users);
|
||||
|
||||
const kysely = em.connection.kysely;
|
||||
const jsonFrom = (e) => e;
|
||||
/**
|
||||
* Relation Helper
|
||||
*/
|
||||
@@ -119,14 +118,11 @@ describe("Relations", async () => {
|
||||
- select: users.*
|
||||
- cardinality: 1
|
||||
*/
|
||||
const selectPostsFromUsers = postAuthorRel.buildWith(
|
||||
users,
|
||||
kysely.selectFrom(users.name),
|
||||
jsonFrom,
|
||||
"posts"
|
||||
);
|
||||
const selectPostsFromUsers = kysely
|
||||
.selectFrom(users.name)
|
||||
.select((eb) => postAuthorRel.buildWith(users, "posts")(eb).as("posts"));
|
||||
expect(selectPostsFromUsers.compile().sql).toBe(
|
||||
'select (select "posts"."id" as "id", "posts"."title" as "title", "posts"."author_id" as "author_id" from "posts" as "posts" where "posts"."author_id" = "users"."id" limit ?) as "posts" from "users"'
|
||||
'select (select from "posts" as "posts" where "posts"."author_id" = "users"."id") as "posts" from "users"'
|
||||
);
|
||||
expect(postAuthorRel!.getField()).toBeInstanceOf(RelationField);
|
||||
const userObj = { id: 1, username: "test" };
|
||||
@@ -141,15 +137,12 @@ describe("Relations", async () => {
|
||||
- select: posts.*
|
||||
- cardinality:
|
||||
*/
|
||||
const selectUsersFromPosts = postAuthorRel.buildWith(
|
||||
posts,
|
||||
kysely.selectFrom(posts.name),
|
||||
jsonFrom,
|
||||
"author"
|
||||
);
|
||||
const selectUsersFromPosts = kysely
|
||||
.selectFrom(posts.name)
|
||||
.select((eb) => postAuthorRel.buildWith(posts, "author")(eb).as("author"));
|
||||
|
||||
expect(selectUsersFromPosts.compile().sql).toBe(
|
||||
'select (select "author"."id" as "id", "author"."username" as "username" from "users" as "author" where "author"."id" = "posts"."author_id" limit ?) as "author" from "posts"'
|
||||
'select (select from "users" as "author" where "author"."id" = "posts"."author_id" limit ?) as "author" from "posts"'
|
||||
);
|
||||
expect(postAuthorRel.getField()).toBeInstanceOf(RelationField);
|
||||
const postObj = { id: 1, title: "test" };
|
||||
@@ -315,20 +308,16 @@ describe("Relations", async () => {
|
||||
- select: users.*
|
||||
- cardinality: 1
|
||||
*/
|
||||
const selectCategoriesFromPosts = postCategoriesRel.buildWith(
|
||||
posts,
|
||||
kysely.selectFrom(posts.name),
|
||||
jsonFrom
|
||||
);
|
||||
const selectCategoriesFromPosts = kysely
|
||||
.selectFrom(posts.name)
|
||||
.select((eb) => postCategoriesRel.buildWith(posts)(eb).as("categories"));
|
||||
expect(selectCategoriesFromPosts.compile().sql).toBe(
|
||||
'select (select "categories"."id" as "id", "categories"."label" as "label" from "categories" inner join "posts_categories" on "categories"."id" = "posts_categories"."categories_id" where "posts"."id" = "posts_categories"."posts_id" limit ?) as "categories" from "posts"'
|
||||
);
|
||||
|
||||
const selectPostsFromCategories = postCategoriesRel.buildWith(
|
||||
categories,
|
||||
kysely.selectFrom(categories.name),
|
||||
jsonFrom
|
||||
);
|
||||
const selectPostsFromCategories = kysely
|
||||
.selectFrom(categories.name)
|
||||
.select((eb) => postCategoriesRel.buildWith(categories)(eb).as("posts"));
|
||||
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"'
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import type { EventManager } from "../../../src/core/events";
|
||||
import {
|
||||
Entity,
|
||||
EntityManager,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
RelationMutator,
|
||||
TextField
|
||||
} from "../../../src/data";
|
||||
import * as proto from "../../../src/data/prototype";
|
||||
import { getDummyConnection } from "../helper";
|
||||
|
||||
const { dummyConnection, afterAllCleanup } = getDummyConnection();
|
||||
@@ -83,14 +85,12 @@ describe("[data] Mutator (ManyToOne)", async () => {
|
||||
|
||||
// persisting reference should ...
|
||||
expect(
|
||||
postRelMutator.persistReference(relations[0], "users", {
|
||||
postRelMutator.persistReference(relations[0]!, "users", {
|
||||
$set: { id: userData.data.id }
|
||||
})
|
||||
).resolves.toEqual(["users_id", userData.data.id]);
|
||||
// @todo: add what methods are allowed to relation, like $create should not be allowed for post<>users
|
||||
|
||||
process.exit(0);
|
||||
|
||||
const userRelMutator = new RelationMutator(users, em);
|
||||
expect(userRelMutator.getRelationalKeys()).toEqual(["posts"]);
|
||||
});
|
||||
@@ -99,7 +99,7 @@ describe("[data] Mutator (ManyToOne)", async () => {
|
||||
expect(
|
||||
em.mutator(posts).insertOne({
|
||||
title: "post1",
|
||||
users_id: 1 // user does not exist yet
|
||||
users_id: 100 // user does not exist yet
|
||||
})
|
||||
).rejects.toThrow();
|
||||
});
|
||||
@@ -299,4 +299,71 @@ describe("[data] Mutator (Events)", async () => {
|
||||
expect(events.has(MutatorEvents.MutatorDeleteBefore.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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
// @ts-ignore
|
||||
import { Perf } from "@bknd/core/utils";
|
||||
import type { Kysely, Transaction } from "kysely";
|
||||
import { Perf } from "../../../src/core/utils";
|
||||
import {
|
||||
Entity,
|
||||
EntityManager,
|
||||
@@ -24,7 +23,7 @@ async function sleep(ms: number) {
|
||||
}
|
||||
|
||||
describe("[Repository]", async () => {
|
||||
test("bulk", async () => {
|
||||
test.skip("bulk", async () => {
|
||||
//const connection = dummyConnection;
|
||||
//const connection = getLocalLibsqlConnection();
|
||||
const credentials = null as any; // @todo: determine what to do here
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import { _jsonp } from "../../../src/core/utils";
|
||||
import {
|
||||
Entity,
|
||||
EntityManager,
|
||||
@@ -8,19 +9,56 @@ import {
|
||||
TextField,
|
||||
WithBuilder
|
||||
} from "../../../src/data";
|
||||
import * as proto from "../../../src/data/prototype";
|
||||
import { compileQb, prettyPrintQb, schemaToEm } from "../../helper";
|
||||
import { getDummyConnection } from "../helper";
|
||||
|
||||
const { dummyConnection, afterAllCleanup } = getDummyConnection();
|
||||
afterAll(afterAllCleanup);
|
||||
const { dummyConnection } = getDummyConnection();
|
||||
|
||||
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 () => {
|
||||
const users = new Entity("users", [new TextField("username")]);
|
||||
const em = new EntityManager([users], dummyConnection);
|
||||
|
||||
expect(() =>
|
||||
WithBuilder.addClause(em, em.connection.kysely.selectFrom("users"), users, ["posts"])
|
||||
).toThrow('Relation "posts" not found');
|
||||
WithBuilder.addClause(em, em.connection.kysely.selectFrom("users"), users, {
|
||||
posts: {}
|
||||
})
|
||||
).toThrow('Relation "users<>posts" not found');
|
||||
});
|
||||
|
||||
test("addClause: ManyToOne", async () => {
|
||||
@@ -29,36 +67,39 @@ describe("[data] WithBuilder", async () => {
|
||||
const relations = [new ManyToOneRelation(posts, users, { mappedBy: "author" })];
|
||||
const em = new EntityManager([users, posts], dummyConnection, relations);
|
||||
|
||||
const qb = WithBuilder.addClause(em, em.connection.kysely.selectFrom("users"), users, [
|
||||
"posts"
|
||||
]);
|
||||
const qb = WithBuilder.addClause(em, em.connection.kysely.selectFrom("users"), users, {
|
||||
posts: {}
|
||||
});
|
||||
|
||||
const res = qb.compile();
|
||||
|
||||
expect(res.sql).toBe(
|
||||
'select (select coalesce(json_group_array(json_object(\'id\', "agg"."id", \'content\', "agg"."content", \'author_id\', "agg"."author_id")), \'[]\') from (select "posts"."id" as "id", "posts"."content" as "content", "posts"."author_id" as "author_id" from "posts" where "users"."id" = "posts"."author_id" limit ?) as agg) as "posts" from "users"'
|
||||
'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(
|
||||
em,
|
||||
em.connection.kysely.selectFrom("posts"),
|
||||
posts, // @todo: try with "users", it gives output!
|
||||
["author"]
|
||||
{
|
||||
author: {}
|
||||
}
|
||||
);
|
||||
|
||||
const res2 = qb2.compile();
|
||||
|
||||
expect(res2.sql).toBe(
|
||||
'select (select json_object(\'id\', "obj"."id", \'username\', "obj"."username") from (select "users"."id" as "id", "users"."username" as "username" from "users" where "posts"."author_id" = "users"."id" limit ?) as obj) as "author" from "posts"'
|
||||
'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 () => {
|
||||
const em = new EntityManager([], dummyConnection);
|
||||
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 () => {
|
||||
@@ -89,7 +130,7 @@ describe("[data] WithBuilder", async () => {
|
||||
|
||||
//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([
|
||||
{
|
||||
@@ -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);
|
||||
|
||||
@@ -121,8 +162,8 @@ describe("[data] WithBuilder", async () => {
|
||||
id: 2,
|
||||
label: "beauty",
|
||||
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.connection.kysely.selectFrom("categories"),
|
||||
categories,
|
||||
["single"]
|
||||
{ single: {} }
|
||||
);
|
||||
const res = qb.compile();
|
||||
expect(res.sql).toBe(
|
||||
'select (select json_object(\'id\', "obj"."id", \'path\', "obj"."path") from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" limit ?) as obj) as "single" from "categories"'
|
||||
'select (select json_object(\'id\', "obj"."id", \'path\', "obj"."path") from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" order by "media"."id" asc limit ? offset ?) as obj) as "single" from "categories"'
|
||||
);
|
||||
expect(res.parameters).toEqual(["categories.single", 1]);
|
||||
expect(res.parameters).toEqual(["categories.single", 1, 0]);
|
||||
|
||||
const qb2 = WithBuilder.addClause(
|
||||
em,
|
||||
em.connection.kysely.selectFrom("categories"),
|
||||
categories,
|
||||
["multiple"]
|
||||
{ multiple: {} }
|
||||
);
|
||||
const res2 = qb2.compile();
|
||||
expect(res2.sql).toBe(
|
||||
'select (select coalesce(json_group_array(json_object(\'id\', "agg"."id", \'path\', "agg"."path")), \'[]\') from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" limit ?) as agg) as "multiple" from "categories"'
|
||||
'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 () => {
|
||||
@@ -192,4 +233,205 @@ describe("[data] WithBuilder", async () => {
|
||||
const res = await em.repository().findMany("posts", { join: ["author"] });
|
||||
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));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,10 +13,6 @@ describe("[data] EnumField", async () => {
|
||||
{ options: options(["a", "b", "c"]) }
|
||||
);
|
||||
|
||||
test("yields if no options", async () => {
|
||||
expect(() => new EnumField("test", { options: options([]) })).toThrow();
|
||||
});
|
||||
|
||||
test("yields if default value is not a valid option", async () => {
|
||||
expect(
|
||||
() => new EnumField("test", { options: options(["a", "b"]), default_value: "c" })
|
||||
|
||||
@@ -15,11 +15,9 @@ describe("[data] Field", async () => {
|
||||
|
||||
runBaseFieldTests(FieldSpec, { defaultValue: "test", schemaType: "text" });
|
||||
|
||||
test.only("default config", async () => {
|
||||
const field = new FieldSpec("test");
|
||||
test("default config", async () => {
|
||||
const config = Default(baseFieldConfigSchema, {});
|
||||
expect(stripMark(new FieldSpec("test").config)).toEqual(config);
|
||||
console.log("config", new TextField("test", { required: true }).toJSON());
|
||||
});
|
||||
|
||||
test("transformPersist (specific)", async () => {
|
||||
|
||||
@@ -32,7 +32,7 @@ describe("[data] JsonField", 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(1, "form")).toBe("1");
|
||||
|
||||
|
||||
@@ -70,9 +70,9 @@ describe("[data] EntityRelation", async () => {
|
||||
|
||||
it("required", async () => {
|
||||
const relation1 = new TestEntityRelation();
|
||||
expect(relation1.config.required).toBe(false);
|
||||
expect(relation1.required).toBe(false);
|
||||
|
||||
const relation2 = new TestEntityRelation({ required: true });
|
||||
expect(relation2.config.required).toBe(true);
|
||||
expect(relation2.required).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { unlink } from "node:fs/promises";
|
||||
import type { SqliteDatabase } from "kysely";
|
||||
import type { SelectQueryBuilder, SqliteDatabase } from "kysely";
|
||||
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): {
|
||||
dummyDb: SqliteDatabase;
|
||||
@@ -51,3 +53,18 @@ export function enableConsoleLog() {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { randomString } from "../../../src/core/utils";
|
||||
import { StorageCloudinaryAdapter } from "../../../src/media";
|
||||
|
||||
import { config } from "dotenv";
|
||||
const dotenvOutput = config({ path: `${import.meta.dir}/../../.env` });
|
||||
const dotenvOutput = config({ path: `${import.meta.dir}/../../../.env` });
|
||||
const {
|
||||
CLOUDINARY_CLOUD_NAME,
|
||||
CLOUDINARY_API_KEY,
|
||||
|
||||
@@ -15,7 +15,7 @@ describe("StorageLocalAdapter", () => {
|
||||
|
||||
test("puts an object", async () => {
|
||||
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 () => {
|
||||
|
||||
@@ -3,14 +3,14 @@ import { randomString } from "../../../src/core/utils";
|
||||
import { StorageS3Adapter } from "../../../src/media";
|
||||
|
||||
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 } =
|
||||
dotenvOutput.parsed!;
|
||||
|
||||
// @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);
|
||||
const versions = [
|
||||
[
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
||||
import { createApp } from "../../src";
|
||||
import { AuthController } from "../../src/auth/api/AuthController";
|
||||
import { em, entity, text } from "../../src/data";
|
||||
import { em, entity, make, text } from "../../src/data";
|
||||
import { AppAuth, type ModuleBuildContext } from "../../src/modules";
|
||||
import { disableConsoleLog, enableConsoleLog } from "../helper";
|
||||
import { makeCtx, moduleTestSuite } from "./module-test-suite";
|
||||
@@ -125,6 +125,40 @@ describe("AppAuth", () => {
|
||||
const fields = e.fields.map((f) => f.name);
|
||||
expect(e.type).toBe("system");
|
||||
expect(fields).toContain("additional");
|
||||
expect(fields).toEqual(["id", "email", "strategy", "strategy_value", "role", "additional"]);
|
||||
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]);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,6 +39,7 @@ describe("AppMedia", () => {
|
||||
expect(fields).toContain("additional");
|
||||
expect(fields).toEqual([
|
||||
"id",
|
||||
"additional",
|
||||
"path",
|
||||
"folder",
|
||||
"mime_type",
|
||||
@@ -48,8 +49,7 @@ describe("AppMedia", () => {
|
||||
"modified_at",
|
||||
"reference",
|
||||
"entity_id",
|
||||
"metadata",
|
||||
"additional"
|
||||
"metadata"
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -157,8 +157,7 @@ describe("Module", async () => {
|
||||
entities: [
|
||||
{
|
||||
name: "u",
|
||||
// ensured properties must come first
|
||||
fields: ["id", "important", "name"],
|
||||
fields: ["id", "name", "important"],
|
||||
// ensured type must be present
|
||||
type: "system"
|
||||
},
|
||||
|
||||
60
app/build.ts
60
app/build.ts
@@ -15,7 +15,7 @@ if (clean) {
|
||||
|
||||
let types_running = false;
|
||||
function buildTypes() {
|
||||
if (types_running) return;
|
||||
if (types_running || !types) return;
|
||||
types_running = true;
|
||||
|
||||
Bun.spawn(["bun", "build:types"], {
|
||||
@@ -72,12 +72,16 @@ await tsup.build({
|
||||
/**
|
||||
* Building UI for direct imports
|
||||
*/
|
||||
const ui_splitting = false;
|
||||
await tsup.build({
|
||||
minify,
|
||||
sourcemap,
|
||||
watch,
|
||||
entry: ["src/ui/index.ts", "src/ui/client/index.ts", "src/ui/main.css"],
|
||||
entry: [
|
||||
"src/ui/index.ts",
|
||||
"src/ui/client/index.ts",
|
||||
"src/ui/main.css",
|
||||
"src/ui/styles.css"
|
||||
],
|
||||
outDir: "dist/ui",
|
||||
external: [
|
||||
"bun:test",
|
||||
@@ -91,22 +95,64 @@ await tsup.build({
|
||||
metafile: true,
|
||||
platform: "browser",
|
||||
format: ["esm"],
|
||||
splitting: ui_splitting,
|
||||
splitting: true,
|
||||
treeshake: true,
|
||||
loader: {
|
||||
".svg": "dataurl"
|
||||
},
|
||||
esbuildOptions: (options) => {
|
||||
options.logLevel = "silent";
|
||||
if (ui_splitting) {
|
||||
options.chunkNames = "chunks/[name]-[hash]";
|
||||
}
|
||||
options.chunkNames = "chunks/[name]-[hash]";
|
||||
},
|
||||
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"
|
||||
],
|
||||
metafile: true,
|
||||
platform: "browser",
|
||||
format: ["esm"],
|
||||
splitting: false,
|
||||
bundle: true,
|
||||
treeshake: true,
|
||||
loader: {
|
||||
".svg": "dataurl"
|
||||
},
|
||||
esbuildOptions: (options) => {
|
||||
options.alias = {
|
||||
// not important for elements, mock to reduce bundle
|
||||
"tailwind-merge": "./src/ui/elements/mocks/tailwind-merge.ts"
|
||||
};
|
||||
},
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Building adapters
|
||||
*/
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
[install]
|
||||
registry = "http://localhost:4873"
|
||||
#registry = "http://localhost:4873"
|
||||
|
||||
[test]
|
||||
coverageSkipTestFiles = true
|
||||
@@ -3,10 +3,20 @@
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"bin": "./dist/cli/index.js",
|
||||
"version": "0.5.0",
|
||||
"version": "0.6.0-rc.13",
|
||||
"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": {
|
||||
"dev": "vite",
|
||||
"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: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",
|
||||
@@ -17,7 +27,8 @@
|
||||
"build:types": "tsc --emitDeclarationOnly && tsc-alias",
|
||||
"updater": "bun x npm-check-updates -ui",
|
||||
"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",
|
||||
"dependencies": {
|
||||
@@ -62,6 +73,7 @@
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"@xyflow/react": "^12.3.2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"clsx": "^2.1.1",
|
||||
"esbuild-postcss": "^0.0.4",
|
||||
"jotai": "^2.10.1",
|
||||
"open": "^10.1.0",
|
||||
@@ -72,6 +84,7 @@
|
||||
"react-hook-form": "^7.53.1",
|
||||
"react-icons": "5.2.1",
|
||||
"react-json-view-lite": "^2.0.1",
|
||||
"sql-formatter": "^15.4.9",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
@@ -168,7 +181,8 @@
|
||||
"import": "./dist/adapter/astro/index.js",
|
||||
"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"
|
||||
},
|
||||
"publishConfig": {
|
||||
@@ -182,5 +196,21 @@
|
||||
"!dist/**/*.map",
|
||||
"!dist/metafile*",
|
||||
"!dist/**/metafile*"
|
||||
],
|
||||
"keywords": [
|
||||
"api",
|
||||
"backend",
|
||||
"database",
|
||||
"authentication",
|
||||
"jwt",
|
||||
"workflows",
|
||||
"media",
|
||||
"serverless",
|
||||
"cloudflare",
|
||||
"nextjs",
|
||||
"remix",
|
||||
"astro",
|
||||
"bun",
|
||||
"node"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -128,15 +128,17 @@ export class Api {
|
||||
};
|
||||
}
|
||||
|
||||
async getVerifiedAuthState(force?: boolean): Promise<AuthState> {
|
||||
if (force === true || !this.verified) {
|
||||
await this.verifyAuth();
|
||||
}
|
||||
|
||||
async getVerifiedAuthState(): Promise<AuthState> {
|
||||
await this.verifyAuth();
|
||||
return this.getAuthState();
|
||||
}
|
||||
|
||||
async verifyAuth() {
|
||||
if (!this.token) {
|
||||
this.markAuthVerified(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await this.auth.me();
|
||||
if (!res.ok || !res.body.user) {
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import type { CreateUserPayload } from "auth/AppAuth";
|
||||
import { auth } from "auth/middlewares";
|
||||
import { config } from "core";
|
||||
import { Event } from "core/events";
|
||||
import { patternMatch } from "core/utils";
|
||||
import { Connection, type LibSqlCredentials, LibsqlConnection } from "data";
|
||||
import {
|
||||
type InitialModuleConfigs,
|
||||
|
||||
@@ -2,7 +2,9 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { Api, type App } from "bknd";
|
||||
import { type FrameworkBkndConfig, createFrameworkApp, nodeRequestToRequest } from "../index";
|
||||
|
||||
export type NextjsBkndConfig = FrameworkBkndConfig;
|
||||
export type NextjsBkndConfig = FrameworkBkndConfig & {
|
||||
cleanSearch?: string[];
|
||||
};
|
||||
|
||||
type GetServerSidePropsContext = {
|
||||
req: IncomingMessage;
|
||||
@@ -32,10 +34,13 @@ export function withApi<T>(handler: (ctx: GetServerSidePropsContext & { api: Api
|
||||
};
|
||||
}
|
||||
|
||||
function getCleanRequest(req: Request) {
|
||||
// clean search params from "route" attribute
|
||||
function getCleanRequest(
|
||||
req: Request,
|
||||
{ cleanSearch = ["route"] }: Pick<NextjsBkndConfig, "cleanSearch">
|
||||
) {
|
||||
const url = new URL(req.url);
|
||||
url.searchParams.delete("route");
|
||||
cleanSearch?.forEach((k) => url.searchParams.delete(k));
|
||||
|
||||
return new Request(url.toString(), {
|
||||
method: req.method,
|
||||
headers: req.headers,
|
||||
@@ -44,12 +49,12 @@ function getCleanRequest(req: Request) {
|
||||
}
|
||||
|
||||
let app: App;
|
||||
export function serve(config: NextjsBkndConfig = {}) {
|
||||
export function serve({ cleanSearch, ...config }: NextjsBkndConfig = {}) {
|
||||
return async (req: Request) => {
|
||||
if (!app) {
|
||||
app = await createFrameworkApp(config);
|
||||
}
|
||||
const request = getCleanRequest(req);
|
||||
const request = getCleanRequest(req, { cleanSearch });
|
||||
return app.fetch(request, process.env);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 { auth } from "auth/middlewares";
|
||||
import { type DB, Exception, type PrimaryFieldType } from "core";
|
||||
import { type Static, secureRandomString, transformObject } from "core/utils";
|
||||
import { type Entity, EntityIndex, type EntityManager } from "data";
|
||||
import { type FieldSchema, em, entity, enumm, make, text } from "data/prototype";
|
||||
import type { Hono } from "hono";
|
||||
import type { Entity, EntityManager } from "data";
|
||||
import { type FieldSchema, em, entity, enumm, text } from "data/prototype";
|
||||
import { pick } from "lodash-es";
|
||||
import { Module } from "modules/Module";
|
||||
import { AuthController } from "./api/AuthController";
|
||||
@@ -79,8 +84,8 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
||||
super.setBuilt();
|
||||
|
||||
this._controller = new AuthController(this);
|
||||
//this.ctx.server.use(controller.getMiddleware);
|
||||
this.ctx.server.route(this.config.basepath, this._controller.getController());
|
||||
this.ctx.guard.registerPermissions(Object.values(AuthPermissions));
|
||||
}
|
||||
|
||||
get controller(): AuthController {
|
||||
@@ -219,10 +224,23 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
||||
}
|
||||
|
||||
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?
|
||||
}
|
||||
|
||||
@@ -237,7 +255,10 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
||||
|
||||
static usersFields = {
|
||||
email: text().required(),
|
||||
strategy: text({ fillable: ["create"], hidden: ["form"] }).required(),
|
||||
strategy: text({
|
||||
fillable: ["create"],
|
||||
hidden: ["update", "form"]
|
||||
}).required(),
|
||||
strategy_value: text({
|
||||
fillable: ["create"],
|
||||
hidden: ["read", "table", "update", "form"]
|
||||
@@ -260,14 +281,12 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
||||
|
||||
try {
|
||||
const roles = Object.keys(this.config.roles ?? {});
|
||||
const field = make("role", enumm({ enum: roles }));
|
||||
users.__replaceField("role", field);
|
||||
this.replaceEntityField(users, "role", enumm({ enum: roles }));
|
||||
} catch (e) {}
|
||||
|
||||
try {
|
||||
const strategies = Object.keys(this.config.strategies ?? {});
|
||||
const field = make("strategy", enumm({ enum: strategies }));
|
||||
users.__replaceField("strategy", field);
|
||||
this.replaceEntityField(users, "strategy", enumm({ enum: strategies }));
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AuthActionResponse } from "auth/api/AuthController";
|
||||
import type { AppAuthSchema, AppAuthStrategies } from "auth/auth-schema";
|
||||
import type { AuthResponse, SafeUser, Strategy } from "auth/authenticate/Authenticator";
|
||||
import { type BaseModuleApiOptions, ModuleApi } from "modules/ModuleApi";
|
||||
@@ -13,22 +14,46 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
|
||||
};
|
||||
}
|
||||
|
||||
async loginWithPassword(input: any) {
|
||||
const res = await this.post<AuthResponse>(["password", "login"], input);
|
||||
async login(strategy: string, input: any) {
|
||||
const res = await this.post<AuthResponse>([strategy, "login"], input);
|
||||
if (res.ok && res.body.token) {
|
||||
await this.options.onTokenUpdate?.(res.body.token);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
async registerWithPassword(input: any) {
|
||||
const res = await this.post<AuthResponse>(["password", "register"], input);
|
||||
async register(strategy: string, input: any) {
|
||||
const res = await this.post<AuthResponse>([strategy, "register"], input);
|
||||
if (res.ok && res.body.token) {
|
||||
await this.options.onTokenUpdate?.(res.body.token);
|
||||
}
|
||||
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() {
|
||||
return this.get<{ user: SafeUser | null }>(["me"]);
|
||||
}
|
||||
|
||||
@@ -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 type { ServerEnv } from "modules/Module";
|
||||
|
||||
export type AuthActionResponse = {
|
||||
success: boolean;
|
||||
action: string;
|
||||
data?: SafeUser;
|
||||
errors?: any;
|
||||
};
|
||||
|
||||
export class AuthController extends Controller {
|
||||
constructor(private auth: AppAuth) {
|
||||
@@ -10,6 +21,68 @@ export class AuthController extends Controller {
|
||||
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() {
|
||||
const { auth } = this.middlewares;
|
||||
const hono = this.create();
|
||||
@@ -18,11 +91,12 @@ export class AuthController extends Controller {
|
||||
for (const [name, strategy] of Object.entries(strategies)) {
|
||||
//console.log("registering", name, "at", `/${name}`);
|
||||
hono.route(`/${name}`, strategy.getController(this.auth.authenticator));
|
||||
this.registerStrategyActions(strategy, hono);
|
||||
}
|
||||
|
||||
hono.get("/me", auth(), async (c) => {
|
||||
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);
|
||||
|
||||
4
app/src/auth/auth-permissions.ts
Normal file
4
app/src/auth/auth-permissions.ts
Normal 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");
|
||||
@@ -1,6 +1,14 @@
|
||||
import { Exception } from "core";
|
||||
import { type DB, Exception } from "core";
|
||||
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 { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie";
|
||||
import { sign, verify } from "hono/jwt";
|
||||
@@ -10,6 +18,14 @@ import type { ServerEnv } from "modules/Module";
|
||||
type Input = any; // workaround
|
||||
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
|
||||
export interface Strategy {
|
||||
getController: (auth: Authenticator) => Hono<any>;
|
||||
@@ -17,6 +33,7 @@ export interface Strategy {
|
||||
getMode: () => "form" | "external";
|
||||
getName: () => string;
|
||||
toJSON: (secrets?: boolean) => any;
|
||||
getActions?: () => StrategyActions;
|
||||
}
|
||||
|
||||
export type User = {
|
||||
@@ -274,6 +291,14 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
||||
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) {
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Authenticator, Strategy } from "auth";
|
||||
import { type Static, StringEnum, Type, parse } from "core/utils";
|
||||
import { hash } from "core/utils";
|
||||
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 RegisterSchema = { email: string; password: string; [key: string]: any };
|
||||
@@ -54,17 +55,9 @@ export class PasswordStrategy implements Strategy {
|
||||
getController(authenticator: Authenticator): Hono<any> {
|
||||
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
|
||||
.post("/login", async (c) => {
|
||||
const body = await getBody(c);
|
||||
const body = await authenticator.getBody(c);
|
||||
|
||||
try {
|
||||
const payload = await this.login(body);
|
||||
@@ -76,7 +69,7 @@ export class PasswordStrategy implements Strategy {
|
||||
}
|
||||
})
|
||||
.post("/register", async (c) => {
|
||||
const body = await getBody(c);
|
||||
const body = await authenticator.getBody(c);
|
||||
|
||||
const payload = await this.register(body);
|
||||
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() {
|
||||
return schema;
|
||||
}
|
||||
|
||||
@@ -19,3 +19,5 @@ export { AppAuth, type UserFieldSchema } from "./AppAuth";
|
||||
|
||||
export { Guard, type GuardUserContext, type GuardConfig } from "./authorize/Guard";
|
||||
export { Role } from "./authorize/Role";
|
||||
|
||||
export * as AuthPermissions from "./auth-permissions";
|
||||
|
||||
@@ -26,25 +26,28 @@ export const auth = (options?: {
|
||||
skip?: (string | RegExp)[];
|
||||
}) =>
|
||||
createMiddleware<ServerEnv>(async (c, next) => {
|
||||
// make sure to only register once
|
||||
if (c.get("auth_registered")) {
|
||||
throw new Error(`auth middleware already registered for ${getPath(c)}`);
|
||||
}
|
||||
c.set("auth_registered", true);
|
||||
|
||||
const app = c.get("app");
|
||||
const skipped = shouldSkip(c, options?.skip) || !app?.module.auth.enabled;
|
||||
const guard = app?.modules.ctx().guard;
|
||||
const authenticator = app?.module.auth.authenticator;
|
||||
|
||||
if (!skipped) {
|
||||
const resolved = c.get("auth_resolved");
|
||||
if (!resolved) {
|
||||
if (!app.module.auth.enabled) {
|
||||
guard?.setUserContext(undefined);
|
||||
} else {
|
||||
guard?.setUserContext(await authenticator?.resolveAuthFromRequest(c));
|
||||
c.set("auth_resolved", true);
|
||||
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) {
|
||||
const resolved = c.get("auth_resolved");
|
||||
if (!resolved) {
|
||||
if (!app?.module.auth.enabled) {
|
||||
guard?.setUserContext(undefined);
|
||||
} else {
|
||||
guard?.setUserContext(await authenticator?.resolveAuthFromRequest(c));
|
||||
c.set("auth_resolved", true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,13 @@ import type { Generated } from "kysely";
|
||||
|
||||
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 = {
|
||||
server: {
|
||||
|
||||
@@ -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
|
||||
* Must be static, because registering events is done by class
|
||||
*/
|
||||
static slug: string = "untitled-event";
|
||||
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) {
|
||||
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> {
|
||||
static override slug: string = "noparam-event";
|
||||
|
||||
@@ -19,3 +40,19 @@ export class NoParamEvent extends Event<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`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,15 +4,16 @@ import type { EventClass } from "./EventManager";
|
||||
export const ListenerModes = ["sync", "async"] as const;
|
||||
export type ListenerMode = (typeof ListenerModes)[number];
|
||||
|
||||
export type ListenerHandler<E extends Event = Event> = (
|
||||
export type ListenerHandler<E extends Event<any, any>> = (
|
||||
event: E,
|
||||
slug: string,
|
||||
) => Promise<void> | void;
|
||||
slug: string
|
||||
) => E extends Event<any, infer R> ? R | Promise<R | void> : never;
|
||||
|
||||
export class EventListener<E extends Event = Event> {
|
||||
mode: ListenerMode = "async";
|
||||
event: EventClass;
|
||||
handler: ListenerHandler<E>;
|
||||
once: boolean = false;
|
||||
|
||||
constructor(event: EventClass, handler: ListenerHandler<E>, mode: ListenerMode = "async") {
|
||||
this.event = event;
|
||||
|
||||
@@ -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";
|
||||
|
||||
export type RegisterListenerConfig =
|
||||
| ListenerMode
|
||||
| {
|
||||
mode?: ListenerMode;
|
||||
once?: boolean;
|
||||
};
|
||||
|
||||
export interface EmitsEvents {
|
||||
emgr: EventManager;
|
||||
}
|
||||
|
||||
export type EventClass = {
|
||||
new (params: any): Event;
|
||||
slug: string;
|
||||
};
|
||||
// for compatibility, moved it to Event.ts
|
||||
export type { EventClass };
|
||||
|
||||
export class EventManager<
|
||||
RegisteredEvents extends Record<string, EventClass> = Record<string, EventClass>
|
||||
@@ -17,16 +22,20 @@ export class EventManager<
|
||||
protected listeners: EventListener[] = [];
|
||||
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) {
|
||||
this.registerEvents(events);
|
||||
}
|
||||
|
||||
if (listeners) {
|
||||
for (const listener of listeners) {
|
||||
this.addListener(listener);
|
||||
}
|
||||
}
|
||||
options?.listeners?.forEach((l) => this.addListener(l));
|
||||
}
|
||||
|
||||
enable() {
|
||||
@@ -82,9 +91,11 @@ export class EventManager<
|
||||
return !!this.events.find((e) => slug === e.slug);
|
||||
}
|
||||
|
||||
protected throwIfEventNotRegistered(event: EventClass) {
|
||||
if (!this.eventExists(event)) {
|
||||
throw new Error(`Event "${event.slug}" not registered`);
|
||||
protected throwIfEventNotRegistered(event: EventClass | Event | string) {
|
||||
if (!this.eventExists(event as any)) {
|
||||
// @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;
|
||||
}
|
||||
|
||||
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>>(
|
||||
event: ActualEvent,
|
||||
handler: ListenerHandler<Instance>,
|
||||
mode: ListenerMode = "async"
|
||||
config?: RegisterListenerConfig
|
||||
) {
|
||||
this.throwIfEventNotRegistered(event);
|
||||
|
||||
const listener = new EventListener(event, handler, mode);
|
||||
this.addListener(listener as any);
|
||||
this.createEventListener(event, handler, config);
|
||||
}
|
||||
|
||||
on<Params = any>(
|
||||
slug: string,
|
||||
handler: ListenerHandler<Event<Params>>,
|
||||
mode: ListenerMode = "async"
|
||||
config?: RegisterListenerConfig
|
||||
) {
|
||||
const event = this.events.find((e) => e.slug === slug);
|
||||
if (!event) {
|
||||
throw new Error(`Event "${slug}" not registered`);
|
||||
}
|
||||
|
||||
this.onEvent(event, handler, mode);
|
||||
this.createEventListener(slug, handler, config);
|
||||
}
|
||||
|
||||
onAny(handler: ListenerHandler<Event<unknown>>, mode: ListenerMode = "async") {
|
||||
this.events.forEach((event) => this.onEvent(event, handler, mode));
|
||||
onAny(handler: ListenerHandler<Event<unknown>>, config?: RegisterListenerConfig) {
|
||||
this.events.forEach((event) => this.onEvent(event, handler, config));
|
||||
}
|
||||
|
||||
async emit(event: Event) {
|
||||
protected executeAsyncs(promises: (() => Promise<void>)[]) {
|
||||
const executor = this.options?.asyncExecutor ?? ((e) => Promise.all(e));
|
||||
executor(promises.map((p) => p())).then(() => void 0);
|
||||
}
|
||||
|
||||
async emit<Actual extends Event<any, any>>(event: Actual): Promise<Actual> {
|
||||
// @ts-expect-error slug is static
|
||||
const slug = event.constructor.slug;
|
||||
if (!this.enabled) {
|
||||
console.log("EventManager disabled, not emitting", slug);
|
||||
return;
|
||||
return event;
|
||||
}
|
||||
|
||||
if (!this.eventExists(event)) {
|
||||
throw new Error(`Event "${slug}" not registered`);
|
||||
}
|
||||
|
||||
const listeners = this.listeners.filter((listener) => listener.event.slug === slug);
|
||||
//console.log("---!-- emitting", slug, listeners.length);
|
||||
const syncs: EventListener[] = [];
|
||||
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") {
|
||||
await listener.handler(event, listener.event.slug);
|
||||
syncs.push(listener);
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export { Event, NoParamEvent } from "./Event";
|
||||
export { Event, NoParamEvent, InvalidEventReturn } from "./Event";
|
||||
export {
|
||||
EventListener,
|
||||
ListenerModes,
|
||||
type ListenerMode,
|
||||
type ListenerHandler,
|
||||
type ListenerHandler
|
||||
} from "./EventListener";
|
||||
export { EventManager, type EmitsEvents, type EventClass } from "./EventManager";
|
||||
|
||||
@@ -130,7 +130,10 @@ export class SchemaObject<Schema extends TObject> {
|
||||
|
||||
//console.log("overwritePaths", 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) => {
|
||||
return this.options?.overwritePaths?.some((p) => {
|
||||
if (typeof p === "string") {
|
||||
|
||||
@@ -49,7 +49,7 @@ type LiteralExpressionCondition<Exps extends Expressions> = {
|
||||
[key: string]: Primitive | ExpressionCondition<Exps>;
|
||||
};
|
||||
|
||||
const OperandOr = "$or";
|
||||
const OperandOr = "$or" as const;
|
||||
type OperandCondition<Exps extends Expressions> = {
|
||||
[OperandOr]?: LiteralExpressionCondition<Exps> | ExpressionCondition<Exps>;
|
||||
};
|
||||
|
||||
@@ -12,3 +12,4 @@ export * from "./uuid";
|
||||
export { FromSchema } from "./typebox/from-schema";
|
||||
export * from "./test";
|
||||
export * from "./runtime";
|
||||
export * from "./numbers";
|
||||
|
||||
5
app/src/core/utils/numbers.ts
Normal file
5
app/src/core/utils/numbers.ts
Normal 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));
|
||||
}
|
||||
@@ -115,6 +115,7 @@ export function parse<Schema extends TSchema = TSchema>(
|
||||
} else if (options?.onError) {
|
||||
options.onError(Errors(schema, data));
|
||||
} else {
|
||||
//console.warn("errors", JSON.stringify([...Errors(schema, data)], null, 2));
|
||||
throw new TypeInvalidError(schema, data);
|
||||
}
|
||||
|
||||
|
||||
@@ -69,18 +69,9 @@ export class AppData extends Module<typeof dataConfigSchema> {
|
||||
}
|
||||
|
||||
override getOverwritePaths() {
|
||||
return [
|
||||
/^entities\..*\.config$/,
|
||||
/^entities\..*\.fields\..*\.config$/
|
||||
///^entities\..*\.fields\..*\.config\.schema$/
|
||||
];
|
||||
return [/^entities\..*\.config$/, /^entities\..*\.fields\..*\.config$/];
|
||||
}
|
||||
|
||||
/*registerController(server: AppServer) {
|
||||
console.log("adding data controller to", this.basepath);
|
||||
server.add(this.basepath, new DataController(this.em));
|
||||
}*/
|
||||
|
||||
override toJSON(secrets?: boolean): AppDataConfig {
|
||||
return {
|
||||
...this.config,
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
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";
|
||||
|
||||
export type DataApiOptions = BaseModuleApiOptions & {
|
||||
defaultQuery?: Partial<RepoQuery>;
|
||||
queryLengthLimit: number;
|
||||
defaultQuery: Partial<RepoQuery>;
|
||||
};
|
||||
|
||||
export class DataApi extends ModuleApi<DataApiOptions> {
|
||||
protected override getDefaultOptions(): Partial<DataApiOptions> {
|
||||
return {
|
||||
basepath: "/api/data",
|
||||
queryLengthLimit: 1000,
|
||||
defaultQuery: {
|
||||
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>(
|
||||
entity: E,
|
||||
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);
|
||||
}
|
||||
|
||||
readMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||
entity: E,
|
||||
query: Partial<RepoQuery> = {}
|
||||
query: RepoQueryIn = {}
|
||||
) {
|
||||
return this.get<Pick<RepositoryResponse<Data[]>, "meta" | "data">>(
|
||||
[entity as any],
|
||||
query ?? this.options.defaultQuery
|
||||
);
|
||||
type T = Pick<RepositoryResponse<Data[]>, "meta" | "data">;
|
||||
|
||||
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<
|
||||
E extends keyof DB | string,
|
||||
R extends keyof DB | string,
|
||||
Data = R extends keyof DB ? DB[R] : EntityData
|
||||
>(entity: E, id: PrimaryFieldType, reference: R, query: Partial<RepoQuery> = {}) {
|
||||
>(entity: E, id: PrimaryFieldType, reference: R, query: RepoQueryIn = {}) {
|
||||
return this.get<Pick<RepositoryResponse<Data[]>, "meta" | "data">>(
|
||||
[entity as any, id, reference],
|
||||
query ?? this.options.defaultQuery
|
||||
|
||||
@@ -70,7 +70,7 @@ export class DataController extends Controller {
|
||||
|
||||
override getController() {
|
||||
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 tbNumber = Type.Transform(Type.String({ pattern: "^[1-9][0-9]{0,}$" }))
|
||||
@@ -85,8 +85,6 @@ export class DataController extends Controller {
|
||||
return func;
|
||||
}
|
||||
|
||||
hono.use("*", permission(SystemPermissions.accessApi));
|
||||
|
||||
// info
|
||||
hono.get(
|
||||
"/",
|
||||
@@ -283,7 +281,7 @@ export class DataController extends Controller {
|
||||
return c.notFound();
|
||||
}
|
||||
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);
|
||||
|
||||
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { DatabaseIntrospector, SqliteDatabase } from "kysely";
|
||||
import { type DatabaseIntrospector, ParseJSONResultsPlugin, type SqliteDatabase } from "kysely";
|
||||
import { Kysely, SqliteDialect } from "kysely";
|
||||
import { DeserializeJsonValuesPlugin } from "../plugins/DeserializeJsonValuesPlugin";
|
||||
import { SqliteConnection } from "./SqliteConnection";
|
||||
import { SqliteIntrospector } from "./SqliteIntrospector";
|
||||
|
||||
@@ -14,7 +13,7 @@ class CustomSqliteDialect extends SqliteDialect {
|
||||
|
||||
export class SqliteLocalConnection extends SqliteConnection {
|
||||
constructor(private database: SqliteDatabase) {
|
||||
const plugins = [new DeserializeJsonValuesPlugin()];
|
||||
const plugins = [new ParseJSONResultsPlugin()];
|
||||
const kysely = new Kysely({
|
||||
dialect: new CustomSqliteDialect({ database }),
|
||||
plugins
|
||||
|
||||
@@ -98,8 +98,8 @@ export class Entity<
|
||||
|
||||
getDefaultSort() {
|
||||
return {
|
||||
by: this.config.sort_field,
|
||||
dir: this.config.sort_dir
|
||||
by: this.config.sort_field ?? "id",
|
||||
dir: this.config.sort_dir ?? "asc"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -192,14 +192,41 @@ export class Entity<
|
||||
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.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) {
|
||||
if (!field.isValid(data[field.name], context)) {
|
||||
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]}"`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
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
|
||||
const entity = this.entities.find((entity) =>
|
||||
e instanceof Entity ? entity.name === e.name : entity.name === e
|
||||
);
|
||||
|
||||
if (!entity) {
|
||||
// @ts-ignore
|
||||
throw new EntityNotDefinedException(e instanceof Entity ? e.name : e);
|
||||
if (silent === true) return undefined as any;
|
||||
throw new EntityNotDefinedException(e instanceof Entity ? e.name : (e as string));
|
||||
}
|
||||
|
||||
return entity;
|
||||
|
||||
@@ -132,14 +132,17 @@ export class Mutator<
|
||||
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 = {
|
||||
...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
|
||||
const required = entity.getRequiredFields();
|
||||
for (const field of required) {
|
||||
@@ -169,16 +172,17 @@ export class Mutator<
|
||||
throw new Error("ID must be provided for update");
|
||||
}
|
||||
|
||||
const validatedData = await this.getValidatedData(data, "update");
|
||||
|
||||
await this.emgr.emit(
|
||||
const result = await this.emgr.emit(
|
||||
new Mutator.Events.MutatorUpdateBefore({
|
||||
entity,
|
||||
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
|
||||
.updateTable(entity.name)
|
||||
.set(validatedData as any)
|
||||
|
||||
@@ -65,7 +65,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
||||
return this.em.connection.kysely;
|
||||
}
|
||||
|
||||
private getValidOptions(options?: Partial<RepoQuery>): RepoQuery {
|
||||
getValidOptions(options?: Partial<RepoQuery>): RepoQuery {
|
||||
const entity = this.entity;
|
||||
// @todo: if not cloned deep, it will keep references and error if multiple requests come in
|
||||
const validated = {
|
||||
@@ -103,17 +103,10 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
||||
validated.select = options.select;
|
||||
}
|
||||
|
||||
if (options.with && options.with.length > 0) {
|
||||
for (const entry of options.with) {
|
||||
const related = this.em.relationOf(entity.name, entry);
|
||||
if (!related) {
|
||||
throw new InvalidSearchParamsException(
|
||||
`WITH: "${entry}" is not a relation of "${entity.name}"`
|
||||
);
|
||||
}
|
||||
|
||||
validated.with.push(entry);
|
||||
}
|
||||
if (options.with) {
|
||||
const depth = WithBuilder.validateWiths(this.em, entity.name, options.with);
|
||||
// @todo: determine allowed depth
|
||||
validated.with = options.with;
|
||||
}
|
||||
|
||||
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]! };
|
||||
}
|
||||
|
||||
private buildQuery(
|
||||
addOptionsToQueryBuilder(
|
||||
_qb?: RepositoryQB,
|
||||
_options?: Partial<RepoQuery>,
|
||||
exclude_options: (keyof RepoQuery)[] = []
|
||||
): { qb: RepositoryQB; options: RepoQuery } {
|
||||
config?: {
|
||||
validate?: boolean;
|
||||
ignore?: (keyof RepoQuery)[];
|
||||
alias?: string;
|
||||
defaults?: Pick<RepoQuery, "limit" | "offset">;
|
||||
}
|
||||
) {
|
||||
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}`;
|
||||
let qb = this.conn
|
||||
.selectFrom(entity.name)
|
||||
.select(entity.getAliasedSelectFrom(options.select, alias));
|
||||
const ignore = config?.ignore ?? [];
|
||||
const defaults = {
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
...config?.defaults
|
||||
};
|
||||
|
||||
//console.log("build query options", options);
|
||||
if (!exclude_options.includes("with") && options.with) {
|
||||
/*console.log("build query options", {
|
||||
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);
|
||||
}
|
||||
|
||||
if (!exclude_options.includes("join") && options.join) {
|
||||
if (!ignore.includes("join") && options.join) {
|
||||
qb = JoinBuilder.addClause(this.em, qb, entity, options.join);
|
||||
}
|
||||
|
||||
// add where if present
|
||||
if (!exclude_options.includes("where") && options.where) {
|
||||
if (!ignore.includes("where") && options.where) {
|
||||
qb = WhereBuilder.addClause(qb, options.where);
|
||||
}
|
||||
|
||||
if (!exclude_options.includes("limit")) qb = qb.limit(options.limit);
|
||||
if (!exclude_options.includes("offset")) qb = qb.offset(options.offset);
|
||||
if (!ignore.includes("limit")) qb = qb.limit(options.limit ?? defaults.limit);
|
||||
if (!ignore.includes("offset")) qb = qb.offset(options.offset ?? defaults.offset);
|
||||
|
||||
// sorting
|
||||
if (!exclude_options.includes("sort")) {
|
||||
qb = qb.orderBy(aliased(options.sort.by), options.sort.dir);
|
||||
if (!ignore.includes("sort")) {
|
||||
qb = qb.orderBy(aliased(options.sort?.by ?? "id"), options.sort?.dir ?? "asc");
|
||||
}
|
||||
|
||||
//console.log("options", { _options, options, exclude_options });
|
||||
return { qb, options };
|
||||
return qb as RepositoryQB;
|
||||
}
|
||||
|
||||
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(
|
||||
|
||||
@@ -30,7 +30,7 @@ function key(e: unknown): string {
|
||||
return e as string;
|
||||
}
|
||||
|
||||
const expressions: TExpression<any, any, any>[] = [
|
||||
const expressions = [
|
||||
exp(
|
||||
"$eq",
|
||||
(v: Primitive) => isPrimitive(v),
|
||||
|
||||
@@ -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";
|
||||
|
||||
export class WithBuilder {
|
||||
private static buildClause(
|
||||
static addClause(
|
||||
em: EntityManager<any>,
|
||||
qb: RepositoryQB,
|
||||
entity: Entity,
|
||||
withString: string
|
||||
withs: RepoQuery["with"]
|
||||
) {
|
||||
const relation = em.relationOf(entity.name, withString);
|
||||
if (!relation) {
|
||||
throw new Error(`Relation "${withString}" not found`);
|
||||
if (!withs || !isObject(withs)) {
|
||||
console.warn(`'withs' undefined or invalid, given: ${JSON.stringify(withs)}`);
|
||||
return qb;
|
||||
}
|
||||
|
||||
const cardinality = relation.ref(withString).cardinality;
|
||||
//console.log("with--builder", { entity: entity.name, withString, cardinality });
|
||||
|
||||
const fns = em.connection.fn;
|
||||
const jsonFrom = cardinality === 1 ? fns.jsonObjectFrom : fns.jsonArrayFrom;
|
||||
|
||||
if (!jsonFrom) {
|
||||
throw new Error("Connection does not support jsonObjectFrom/jsonArrayFrom");
|
||||
}
|
||||
|
||||
try {
|
||||
return relation.buildWith(entity, qb, jsonFrom, withString);
|
||||
} catch (e) {
|
||||
throw new Error(`Could not build "with" relation "${withString}": ${(e as any).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
static addClause(em: EntityManager<any>, qb: RepositoryQB, entity: Entity, withs: string[]) {
|
||||
if (withs.length === 0) return qb;
|
||||
|
||||
let newQb = qb;
|
||||
for (const entry of withs) {
|
||||
newQb = WithBuilder.buildClause(em, newQb, entity, entry);
|
||||
|
||||
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) {
|
||||
throw new Error("Connection does not support jsonObjectFrom/jsonArrayFrom");
|
||||
}
|
||||
|
||||
const other = relation.other(entity);
|
||||
newQb = newQb.select((eb) => {
|
||||
let subQuery = relation.buildWith(entity, ref)(eb);
|
||||
if (query) {
|
||||
subQuery = em.repo(other.entity).addOptionsToQueryBuilder(subQuery, query as any, {
|
||||
ignore: ["with", "join", cardinality === 1 ? "limit" : undefined].filter(
|
||||
Boolean
|
||||
) as any
|
||||
});
|
||||
}
|
||||
|
||||
if (query.with) {
|
||||
subQuery = WithBuilder.addClause(em, subQuery, other.entity, query.with as any);
|
||||
}
|
||||
|
||||
return jsonFrom(subQuery).as(other.reference);
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,48 @@
|
||||
import type { PrimaryFieldType } from "core";
|
||||
import { Event } from "core/events";
|
||||
import { Event, InvalidEventReturn } from "core/events";
|
||||
import type { Entity, EntityData } from "../entities";
|
||||
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";
|
||||
|
||||
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 }> {
|
||||
static override slug = "mutator-insert-after";
|
||||
}
|
||||
export class MutatorUpdateBefore extends Event<{
|
||||
entity: Entity;
|
||||
entityId: PrimaryFieldType;
|
||||
data: EntityData;
|
||||
}> {
|
||||
export class MutatorUpdateBefore extends Event<
|
||||
{
|
||||
entity: Entity;
|
||||
entityId: PrimaryFieldType;
|
||||
data: EntityData;
|
||||
},
|
||||
EntityData
|
||||
> {
|
||||
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<{
|
||||
entity: Entity;
|
||||
|
||||
@@ -12,6 +12,9 @@ import type { HTMLInputTypeAttribute, InputHTMLAttributes } from "react";
|
||||
import type { EntityManager } from "../entities";
|
||||
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 type TActionContext = (typeof ActionContext)[number];
|
||||
|
||||
@@ -157,8 +160,12 @@ export abstract class Field<
|
||||
return this.config.virtual ?? false;
|
||||
}
|
||||
|
||||
getLabel(): string {
|
||||
return this.config.label ?? snakeToPascalWithSpaces(this.name);
|
||||
getLabel(options?: { fallback?: boolean }): string | undefined {
|
||||
return this.config.label
|
||||
? this.config.label
|
||||
: options?.fallback !== false
|
||||
? snakeToPascalWithSpaces(this.name)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
getDescription(): string | undefined {
|
||||
|
||||
@@ -8,6 +8,7 @@ export * from "./prototype";
|
||||
|
||||
export {
|
||||
type RepoQuery,
|
||||
type RepoQueryIn,
|
||||
defaultQuerySchema,
|
||||
querySchema,
|
||||
whereSchema
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 EntityRelationAnchor,
|
||||
@@ -67,10 +67,8 @@ export abstract class EntityRelation<
|
||||
*/
|
||||
abstract buildWith(
|
||||
entity: Entity,
|
||||
qb: KyselyQueryBuilder,
|
||||
jsonFrom: KyselyJsonFrom,
|
||||
reference: string
|
||||
): KyselyQueryBuilder;
|
||||
): (eb: ExpressionBuilder<any, any>) => KyselyQueryBuilder;
|
||||
|
||||
abstract buildJoin(
|
||||
entity: Entity,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type Static, Type } from "core/utils";
|
||||
import type { ExpressionBuilder } from "kysely";
|
||||
import { Entity, type EntityManager } from "../entities";
|
||||
import { type Field, PrimaryField, VirtualField } from "../fields";
|
||||
import type { RepoQuery } from "../server/data-query-impl";
|
||||
@@ -123,7 +124,7 @@ export class ManyToManyRelation extends EntityRelation<typeof ManyToManyRelation
|
||||
.groupBy(groupBy);
|
||||
}
|
||||
|
||||
buildWith(entity: Entity, qb: KyselyQueryBuilder, jsonFrom: KyselyJsonFrom) {
|
||||
buildWith(entity: Entity) {
|
||||
if (!this.em) {
|
||||
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)
|
||||
);
|
||||
|
||||
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);
|
||||
// @todo: also add to find by references
|
||||
if (additionalFields.length > 0) {
|
||||
@@ -160,7 +183,7 @@ export class ManyToManyRelation extends EntityRelation<typeof ManyToManyRelation
|
||||
.innerJoin(...join)
|
||||
.limit(limit)
|
||||
).as(other.reference);
|
||||
});
|
||||
});*/
|
||||
}
|
||||
|
||||
initialize(em: EntityManager<any>) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PrimaryFieldType } from "core";
|
||||
import { snakeToPascalWithSpaces } from "core/utils";
|
||||
import { type Static, Type } from "core/utils";
|
||||
import type { ExpressionBuilder } from "kysely";
|
||||
import type { Entity, EntityManager } from "../entities";
|
||||
import type { RepoQuery } from "../server/data-query-impl";
|
||||
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);
|
||||
}
|
||||
|
||||
buildWith(entity: Entity, qb: KyselyQueryBuilder, jsonFrom: KyselyJsonFrom, reference: string) {
|
||||
buildWith(entity: Entity, reference: string) {
|
||||
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) =>
|
||||
jsonFrom(
|
||||
eb
|
||||
.selectFrom(`${self.entity.name} as ${relationRef}`)
|
||||
.select(self.entity.getSelect(relationRef))
|
||||
.whereRef(entityRef, "=", otherRef)
|
||||
.limit(limit)
|
||||
).as(relationRef)
|
||||
);
|
||||
return (eb: ExpressionBuilder<any, any>) =>
|
||||
eb
|
||||
.selectFrom(`${self.entity.name} as ${relationRef}`)
|
||||
.whereRef(entityRef, "=", otherRef)
|
||||
.$if(self.cardinality === 1, (qb) => qb.limit(1));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type Static, Type } from "core/utils";
|
||||
import type { ExpressionBuilder } from "kysely";
|
||||
import type { Entity, EntityManager } from "../entities";
|
||||
import { NumberField, TextField } from "../fields";
|
||||
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 limit = other.cardinality === 1 ? 1 : 5;
|
||||
|
||||
return qb.select((eb) =>
|
||||
jsonFrom(
|
||||
eb
|
||||
.selectFrom(other.entity.name)
|
||||
.select(other.entity.getSelect(other.entity.name))
|
||||
.where(whereLhs, "=", reference)
|
||||
.whereRef(entityRef, "=", otherRef)
|
||||
.limit(limit)
|
||||
).as(other.reference)
|
||||
);
|
||||
return (eb: ExpressionBuilder<any, any>) =>
|
||||
eb
|
||||
.selectFrom(other.entity.name)
|
||||
.where(whereLhs, "=", reference)
|
||||
.whereRef(entityRef, "=", otherRef)
|
||||
.$if(other.cardinality === 1, (qb) => qb.limit(1));
|
||||
}
|
||||
|
||||
override isListableFor(entity: Entity): boolean {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { TThis } from "@sinclair/typebox";
|
||||
import {
|
||||
type SchemaOptions,
|
||||
type Static,
|
||||
@@ -6,7 +7,7 @@ import {
|
||||
Type,
|
||||
Value
|
||||
} from "core/utils";
|
||||
import { WhereBuilder } from "../entities";
|
||||
import { WhereBuilder, type WhereQuery } from "../entities";
|
||||
|
||||
const NumberOrString = (options: SchemaOptions = {}) =>
|
||||
Type.Transform(Type.Union([Type.Number(), Type.String()], options))
|
||||
@@ -14,10 +15,8 @@ const NumberOrString = (options: SchemaOptions = {}) =>
|
||||
.Encode(String);
|
||||
|
||||
const limit = NumberOrString({ default: 10 });
|
||||
|
||||
const offset = NumberOrString({ default: 0 });
|
||||
|
||||
// @todo: allow "id" and "-id"
|
||||
const sort_default = { by: "id", dir: "asc" };
|
||||
const sort = Type.Transform(
|
||||
Type.Union(
|
||||
@@ -27,20 +26,20 @@ const sort = Type.Transform(
|
||||
}
|
||||
)
|
||||
)
|
||||
.Decode((value) => {
|
||||
.Decode((value): { by: string; dir: "asc" | "desc" } => {
|
||||
if (typeof value === "string") {
|
||||
if (/^-?[a-zA-Z_][a-zA-Z0-9_.]*$/.test(value)) {
|
||||
const dir = value[0] === "-" ? "desc" : "asc";
|
||||
return { by: dir === "desc" ? value.slice(1) : value, dir };
|
||||
return { by: dir === "desc" ? value.slice(1) : value, dir } as any;
|
||||
} 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(
|
||||
Type.Union([Type.String(), Type.Array(Type.String())], { default: [] })
|
||||
@@ -64,21 +63,63 @@ export const whereSchema = Type.Transform(
|
||||
})
|
||||
.Encode(JSON.stringify);
|
||||
|
||||
export const querySchema = Type.Object(
|
||||
{
|
||||
limit: Type.Optional(limit),
|
||||
offset: Type.Optional(offset),
|
||||
sort: Type.Optional(sort),
|
||||
select: Type.Optional(stringArray),
|
||||
with: Type.Optional(stringArray),
|
||||
join: Type.Optional(stringArray),
|
||||
where: Type.Optional(whereSchema)
|
||||
},
|
||||
{
|
||||
additionalProperties: false
|
||||
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: limit,
|
||||
offset: offset,
|
||||
sort: sort,
|
||||
select: stringArray,
|
||||
with: withSchema(Self),
|
||||
join: stringArray,
|
||||
where: whereSchema
|
||||
},
|
||||
{
|
||||
// @todo: determine if unknown is allowed, it's ignore anyway
|
||||
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 const defaultQuerySchema = Value.Default(querySchema, {}) as RepoQuery;
|
||||
|
||||
@@ -12,6 +12,18 @@ export type { TAppFlowTaskSchema } from "./flows-schema";
|
||||
export class AppFlows extends Module<typeof flowsConfigSchema> {
|
||||
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() {
|
||||
//console.log("building flows", this.config);
|
||||
const flows = transformObject(this.config.flows, (flowConfig, name) => {
|
||||
@@ -67,15 +79,10 @@ export class AppFlows extends Module<typeof flowsConfigSchema> {
|
||||
this.setBuilt();
|
||||
}
|
||||
|
||||
getSchema() {
|
||||
return flowsConfigSchema;
|
||||
}
|
||||
|
||||
private getFlowInfo(flow: Flow) {
|
||||
override toJSON() {
|
||||
return {
|
||||
...flow.toJSON(),
|
||||
tasks: flow.tasks.length,
|
||||
connections: flow.connections
|
||||
...this.config,
|
||||
flows: transformObject(this.flows, (flow) => flow.toJSON())
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ export const flowSchema = Type.Object(
|
||||
{
|
||||
trigger: Type.Union(Object.values(triggerSchemaObject)),
|
||||
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()),
|
||||
responding_task: Type.Optional(Type.String())
|
||||
},
|
||||
|
||||
@@ -162,8 +162,8 @@ export class Flow {
|
||||
trigger: this.trigger.toJSON(),
|
||||
tasks: Object.fromEntries(this.tasks.map((t) => [t.name, t.toJSON()])),
|
||||
connections: Object.fromEntries(this.connections.map((c) => [c.id, c.toJSON()])),
|
||||
start_task: this.startTask.name,
|
||||
responding_task: this.respondingTask ? this.respondingTask.name : null
|
||||
start_task: this.startTask?.name,
|
||||
responding_task: this.respondingTask?.name
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { uuid } from "core/utils";
|
||||
import { objectCleanEmpty, uuid } from "core/utils";
|
||||
import { get } from "lodash-es";
|
||||
import type { Task, TaskResult } from "./Task";
|
||||
|
||||
@@ -34,14 +34,14 @@ export class TaskConnection {
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
return objectCleanEmpty({
|
||||
source: this.source.name,
|
||||
target: this.target.name,
|
||||
config: {
|
||||
...this.config,
|
||||
condition: this.config.condition?.toJSON()
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,8 @@ export class AppMedia extends Module<typeof mediaConfigSchema> {
|
||||
index(media).on(["path"], true).on(["reference"]);
|
||||
})
|
||||
);
|
||||
|
||||
this.setBuilt();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
throw new Error(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 & {};
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { TObject, TString } from "@sinclair/typebox";
|
||||
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 {
|
||||
Storage,
|
||||
type StorageAdapter,
|
||||
@@ -19,7 +20,7 @@ import { type S3AdapterConfig, StorageS3Adapter } from "./storage/adapters/Stora
|
||||
export { StorageS3Adapter, type S3AdapterConfig, StorageCloudinaryAdapter, type CloudinaryConfig };
|
||||
|
||||
export * as StorageEvents from "./storage/events";
|
||||
export { type FileUploadedEventData } from "./storage/events";
|
||||
export type { FileUploadedEventData } from "./storage/events";
|
||||
export * from "./utils";
|
||||
|
||||
type ClassThatImplements<T> = Constructor<T> & { prototype: T };
|
||||
|
||||
@@ -10,7 +10,7 @@ export function getExtension(filename: string): string | undefined {
|
||||
export function getRandomizedFilename(file: File, length?: number): string;
|
||||
export function getRandomizedFilename(file: string, length?: number): 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") {
|
||||
console.error("Couldn't extract filename from", file);
|
||||
|
||||
@@ -3,9 +3,18 @@ import type { Guard } from "auth";
|
||||
import { SchemaObject } from "core";
|
||||
import type { EventManager } from "core/events";
|
||||
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 type { Hono } from "hono";
|
||||
import { isEqual } from "lodash-es";
|
||||
|
||||
export type ServerEnv = {
|
||||
Variables: {
|
||||
@@ -146,28 +155,33 @@ export abstract class Module<Schema extends TSchema = TSchema, ConfigSchema = St
|
||||
}
|
||||
|
||||
protected ensureEntity(entity: Entity) {
|
||||
const instance = this.ctx.em.entity(entity.name, true);
|
||||
|
||||
// check fields
|
||||
if (!this.ctx.em.hasEntity(entity.name)) {
|
||||
if (!instance) {
|
||||
this.ctx.em.addEntity(entity);
|
||||
this.ctx.flags.sync_required = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const instance = this.ctx.em.entity(entity.name);
|
||||
|
||||
// if exists, check all fields required are there
|
||||
// @todo: check if the field also equal
|
||||
for (const field of instance.fields) {
|
||||
const _field = entity.field(field.name);
|
||||
if (!_field) {
|
||||
entity.addField(field);
|
||||
for (const field of entity.fields) {
|
||||
const instanceField = instance.field(field.name);
|
||||
if (!instanceField) {
|
||||
instance.addField(field);
|
||||
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)
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,13 @@ export abstract class ModuleApi<Options extends BaseModuleApiOptions = BaseModul
|
||||
} as Options;
|
||||
}
|
||||
|
||||
/**
|
||||
* used for SWR invalidation of basepath
|
||||
*/
|
||||
key(): string {
|
||||
return this.options.basepath ?? "";
|
||||
}
|
||||
|
||||
protected getUrl(path: string) {
|
||||
const basepath = this.options.basepath ?? "";
|
||||
return this.options.host + (basepath + "/" + path).replace(/\/{2,}/g, "/").replace(/\/$/, "");
|
||||
|
||||
@@ -88,6 +88,7 @@ export type ModuleManagerOptions = {
|
||||
};
|
||||
|
||||
type ConfigTable<Json = ModuleConfigs> = {
|
||||
id?: number;
|
||||
version: number;
|
||||
type: "config" | "diff" | "backup";
|
||||
json: Json;
|
||||
@@ -236,10 +237,10 @@ export class ModuleManager {
|
||||
|
||||
private async fetch(): Promise<ConfigTable> {
|
||||
this.logger.context("fetch").log("fetching");
|
||||
const startTime = performance.now();
|
||||
|
||||
// disabling console log, because the table might not exist yet
|
||||
return await withDisabledConsole(async () => {
|
||||
const startTime = performance.now();
|
||||
const result = await withDisabledConsole(async () => {
|
||||
const { data: result } = await this.repo().findOne(
|
||||
{ type: "config" },
|
||||
{
|
||||
@@ -251,9 +252,16 @@ export class ModuleManager {
|
||||
throw BkndError.with("no config");
|
||||
}
|
||||
|
||||
this.logger.log("took", performance.now() - startTime, "ms", result.version).clear();
|
||||
return result as ConfigTable;
|
||||
return result as unknown as ConfigTable;
|
||||
}, ["log", "error", "warn"]);
|
||||
|
||||
this.logger
|
||||
.log("took", performance.now() - startTime, "ms", {
|
||||
version: result.version,
|
||||
id: result.id
|
||||
})
|
||||
.clear();
|
||||
return result;
|
||||
}
|
||||
|
||||
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?
|
||||
|
||||
this.logger.clear();
|
||||
@@ -387,6 +398,7 @@ export class ModuleManager {
|
||||
}
|
||||
|
||||
private setConfigs(configs: ModuleConfigs): void {
|
||||
this.logger.log("setting configs");
|
||||
objectEach(configs, (config, key) => {
|
||||
try {
|
||||
// setting "noEmit" to true, to not force listeners to update
|
||||
|
||||
@@ -44,6 +44,11 @@ export class SystemController extends Controller {
|
||||
|
||||
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(
|
||||
"/:module?",
|
||||
tb("param", Type.Object({ module: Type.Optional(StringEnum(MODULE_NAMES)) })),
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { IconAlertHexagon } from "@tabler/icons-react";
|
||||
import type { ModuleConfigs, ModuleSchemas } from "modules";
|
||||
import { getDefaultConfig, getDefaultSchema } from "modules/ModuleManager";
|
||||
import { createContext, startTransition, useContext, useEffect, useRef, useState } from "react";
|
||||
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 { AppReduced } from "./utils/AppReduced";
|
||||
|
||||
@@ -10,6 +13,7 @@ type BkndContext = {
|
||||
schema: ModuleSchemas;
|
||||
config: ModuleConfigs;
|
||||
permissions: string[];
|
||||
hasSecrets: boolean;
|
||||
requireSecrets: () => Promise<void>;
|
||||
actions: ReturnType<typeof getSchemaActions>;
|
||||
app: AppReduced;
|
||||
@@ -32,7 +36,9 @@ export function BkndProvider({
|
||||
const [schema, setSchema] =
|
||||
useState<Pick<BkndContext, "version" | "schema" | "config" | "permissions">>();
|
||||
const [fetched, setFetched] = useState(false);
|
||||
const [error, setError] = useState<boolean>();
|
||||
const errorShown = useRef<boolean>();
|
||||
const [local_version, set_local_version] = useState(0);
|
||||
const api = useApi();
|
||||
|
||||
async function reloadSchema() {
|
||||
@@ -49,15 +55,11 @@ export function BkndProvider({
|
||||
if (!res.ok) {
|
||||
if (errorShown.current) return;
|
||||
errorShown.current = true;
|
||||
/*notifications.show({
|
||||
title: "Failed to fetch schema",
|
||||
// @ts-ignore
|
||||
message: body.error,
|
||||
color: "red",
|
||||
position: "top-right",
|
||||
autoClose: false,
|
||||
withCloseButton: true
|
||||
});*/
|
||||
|
||||
setError(true);
|
||||
return;
|
||||
} else if (error) {
|
||||
setError(false);
|
||||
}
|
||||
|
||||
const schema = res.ok
|
||||
@@ -80,6 +82,7 @@ export function BkndProvider({
|
||||
setSchema(schema);
|
||||
setWithSecrets(_includeSecrets);
|
||||
setFetched(true);
|
||||
set_local_version((v) => v + 1);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -96,9 +99,24 @@ export function BkndProvider({
|
||||
if (!fetched || !schema) return fallback;
|
||||
const app = new AppReduced(schema?.config as any);
|
||||
const actions = getSchemaActions({ api, setSchema, reloadSchema });
|
||||
const hasSecrets = withSecrets && !error;
|
||||
|
||||
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}
|
||||
</BkndContext.Provider>
|
||||
);
|
||||
|
||||
@@ -12,7 +12,6 @@ export type ClientProviderProps = {
|
||||
};
|
||||
|
||||
export const ClientProvider = ({ children, baseUrl, user }: ClientProviderProps) => {
|
||||
//const [actualBaseUrl, setActualBaseUrl] = useState<string | null>(null);
|
||||
const winCtx = useBkndWindowContext();
|
||||
const _ctx_baseUrl = useBaseUrl();
|
||||
let actualBaseUrl = baseUrl ?? _ctx_baseUrl ?? "";
|
||||
@@ -31,6 +30,7 @@ export const ClientProvider = ({ children, baseUrl, user }: ClientProviderProps)
|
||||
console.error("error .....", e);
|
||||
}
|
||||
|
||||
console.log("api init", { host: actualBaseUrl, user: user ?? winCtx.user });
|
||||
const api = new Api({ host: actualBaseUrl, user: user ?? winCtx.user });
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { 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 api = useApi();
|
||||
|
||||
return async (arg?: string | ((api: Api) => FetchPromise<any>)) => {
|
||||
if (!arg) return async () => mutate("");
|
||||
return mutate(typeof arg === "string" ? arg : arg(api).key());
|
||||
return async (arg?: string | ((api: Api) => FetchPromise<any> | ModuleApi<any>)) => {
|
||||
let 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));
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { DB, PrimaryFieldType } from "core";
|
||||
import { encodeSearch, objectTransform } from "core/utils";
|
||||
import type { EntityData, RepoQuery } from "data";
|
||||
import type { EntityData, RepoQuery, RepoQueryIn } from "data";
|
||||
import type { ModuleApi, ResponseObject } from "modules/ModuleApi";
|
||||
import useSWR, { type SWRConfiguration, mutate } from "swr";
|
||||
import { type Api, useApi } from "ui/client";
|
||||
@@ -22,15 +22,6 @@ export class UseEntityApiError<Payload = any> extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function Test() {
|
||||
const { read } = useEntity("users");
|
||||
async () => {
|
||||
const data = await read();
|
||||
};
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const useEntity = <
|
||||
Entity extends keyof DB | string,
|
||||
Id extends PrimaryFieldType | undefined = undefined,
|
||||
@@ -49,7 +40,7 @@ export const useEntity = <
|
||||
}
|
||||
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);
|
||||
if (!res.ok) {
|
||||
throw new UseEntityApiError(res as any, `Failed to read entity "${entity}"`);
|
||||
@@ -88,7 +79,7 @@ export function makeKey(
|
||||
api: ModuleApi,
|
||||
entity: string,
|
||||
id?: PrimaryFieldType,
|
||||
query?: Partial<RepoQuery>
|
||||
query?: RepoQueryIn
|
||||
) {
|
||||
return (
|
||||
"/" +
|
||||
@@ -105,11 +96,11 @@ export const useEntityQuery = <
|
||||
>(
|
||||
entity: Entity,
|
||||
id?: Id,
|
||||
query?: Partial<RepoQuery>,
|
||||
query?: RepoQueryIn,
|
||||
options?: SWRConfiguration & { enabled?: boolean; revalidateOnMutate?: boolean }
|
||||
) => {
|
||||
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 fetcher = () => read(query);
|
||||
|
||||
@@ -121,7 +112,7 @@ export const useEntityQuery = <
|
||||
});
|
||||
|
||||
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, {
|
||||
revalidate: true
|
||||
});
|
||||
@@ -167,7 +158,7 @@ export async function mutateEntityCache<
|
||||
return prev;
|
||||
}
|
||||
|
||||
const entityKey = makeKey(api, entity);
|
||||
const entityKey = makeKey(api, entity as string);
|
||||
|
||||
return mutate(
|
||||
(key) => typeof key === "string" && key.startsWith(entityKey),
|
||||
|
||||
@@ -73,23 +73,3 @@ export const useAuth = (options?: { baseUrl?: string }): UseAuth => {
|
||||
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 };
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "data/data-schema";
|
||||
import { useBknd } from "ui/client/bknd";
|
||||
import type { TSchemaActions } from "ui/client/schema/actions";
|
||||
import { bkndModals } from "ui/modals";
|
||||
|
||||
export function useBkndData() {
|
||||
const { config, app, schema, actions: bkndActions } = useBknd();
|
||||
@@ -62,7 +63,13 @@ export function useBkndData() {
|
||||
}
|
||||
};
|
||||
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 {
|
||||
@@ -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: (rel: { source?: string; target?: string; type?: string }) =>
|
||||
bkndModals.open(bkndModals.ids.dataCreate, {
|
||||
initialPath: ["entities", "relation"],
|
||||
initialState: {
|
||||
action: "relation",
|
||||
relations: {
|
||||
create: [rel 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) {
|
||||
return {
|
||||
add: async (name: string, field: TAppDataField) => {
|
||||
|
||||
@@ -4,15 +4,15 @@ import { twMerge } from "tailwind-merge";
|
||||
import { Link } from "ui/components/wouter/Link";
|
||||
|
||||
const sizes = {
|
||||
small: "px-2 py-1.5 rounded-md gap-1.5 text-sm",
|
||||
default: "px-3 py-2.5 rounded-md gap-2.5",
|
||||
large: "px-4 py-3 rounded-md gap-3 text-lg"
|
||||
small: "px-2 py-1.5 rounded-md gap-1 text-sm",
|
||||
default: "px-3 py-2.5 rounded-md gap-1.5",
|
||||
large: "px-4 py-3 rounded-md gap-2.5 text-lg"
|
||||
};
|
||||
|
||||
const iconSizes = {
|
||||
small: 15,
|
||||
default: 18,
|
||||
large: 22
|
||||
small: 12,
|
||||
default: 16,
|
||||
large: 20
|
||||
};
|
||||
|
||||
const styles = {
|
||||
|
||||
@@ -10,9 +10,9 @@ export type IconType =
|
||||
|
||||
const styles = {
|
||||
xs: { className: "p-0.5", size: 13 },
|
||||
sm: { className: "p-0.5", size: 16 },
|
||||
md: { className: "p-1", size: 20 },
|
||||
lg: { className: "p-1.5", size: 24 }
|
||||
sm: { className: "p-0.5", size: 15 },
|
||||
md: { className: "p-1", size: 18 },
|
||||
lg: { className: "p-1.5", size: 22 }
|
||||
} as const;
|
||||
|
||||
interface IconButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
|
||||
@@ -6,16 +6,27 @@ export type AlertProps = ComponentPropsWithoutRef<"div"> & {
|
||||
visible?: boolean;
|
||||
title?: 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 ? (
|
||||
<div
|
||||
{...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>}
|
||||
{message}
|
||||
{message || children}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
import { Button } from "../buttons/Button";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Button, type ButtonProps } from "../buttons/Button";
|
||||
|
||||
export type EmptyProps = {
|
||||
Icon?: any;
|
||||
title?: string;
|
||||
description?: string;
|
||||
buttonText?: string;
|
||||
buttonOnClick?: () => void;
|
||||
primary?: ButtonProps;
|
||||
secondary?: ButtonProps;
|
||||
className?: string;
|
||||
};
|
||||
export const Empty: React.FC<EmptyProps> = ({
|
||||
Icon = undefined,
|
||||
title = undefined,
|
||||
description = "Check back later my friend.",
|
||||
buttonText,
|
||||
buttonOnClick
|
||||
primary,
|
||||
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">
|
||||
{Icon && <Icon size={48} className="opacity-50" stroke={1} />}
|
||||
<div className="flex flex-col gap-1">
|
||||
{title && <h3 className="text-center text-lg font-bold">{title}</h3>}
|
||||
<p className="text-center text-primary/60">{description}</p>
|
||||
</div>
|
||||
{buttonText && (
|
||||
<div className="mt-1.5">
|
||||
<Button variant="primary" onClick={buttonOnClick}>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-1.5 flex flex-row gap-2">
|
||||
{secondary && <Button variant="default" {...secondary} />}
|
||||
{primary && <Button variant="primary" {...primary} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,24 @@
|
||||
import { IconLockAccessOff } from "@tabler/icons-react";
|
||||
import { Empty, type EmptyProps } from "./Empty";
|
||||
|
||||
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 = {
|
||||
NotFound
|
||||
NotFound,
|
||||
NotAllowed,
|
||||
MissingPermission
|
||||
};
|
||||
|
||||
29
app/src/ui/components/form/Formy/BooleanInputMantine.tsx
Normal file
29
app/src/ui/components/form/Formy/BooleanInputMantine.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Switch } from "@mantine/core";
|
||||
import { getBrowser } from "core/utils";
|
||||
import type { Field } from "data";
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
|
||||
import { TbCalendar, TbChevronDown, TbInfoCircle } from "react-icons/tb";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useEvent } from "../../hooks/use-event";
|
||||
import { IconButton } from "../buttons/IconButton";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { useEvent } from "ui/hooks/use-event";
|
||||
|
||||
export const Group: React.FC<React.ComponentProps<"div"> & { error?: boolean }> = ({
|
||||
error,
|
||||
@@ -131,17 +130,6 @@ export const BooleanInput = forwardRef<HTMLInputElement, React.ComponentProps<"i
|
||||
}
|
||||
|
||||
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">
|
||||
<input
|
||||
{...props}
|
||||
@@ -153,7 +141,7 @@ export const BooleanInput = forwardRef<HTMLInputElement, React.ComponentProps<"i
|
||||
disabled={props.disabled}
|
||||
/>
|
||||
</div>
|
||||
);*/
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
17
app/src/ui/components/form/Formy/index.ts
Normal file
17
app/src/ui/components/form/Formy/index.ts
Normal 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";
|
||||
@@ -15,12 +15,13 @@ export type JsonSchemaFormProps = any & {
|
||||
schema: RJSFSchema | Schema;
|
||||
uiSchema?: any;
|
||||
direction?: "horizontal" | "vertical";
|
||||
onChange?: (value: any) => void;
|
||||
onChange?: (value: any, isValid: () => boolean) => void;
|
||||
};
|
||||
|
||||
export type JsonSchemaFormRef = {
|
||||
formData: () => any;
|
||||
validateForm: () => boolean;
|
||||
silentValidate: () => boolean;
|
||||
cancel: () => void;
|
||||
};
|
||||
|
||||
@@ -52,15 +53,18 @@ export const JsonSchemaForm = forwardRef<JsonSchemaFormRef, JsonSchemaFormProps>
|
||||
const handleChange = ({ formData }: any, e) => {
|
||||
const clean = JSON.parse(JSON.stringify(formData));
|
||||
//console.log("Data changed: ", clean, JSON.stringify(formData, null, 2));
|
||||
onChange?.(clean);
|
||||
setValue(clean);
|
||||
onChange?.(clean, () => isValid(clean));
|
||||
};
|
||||
|
||||
const isValid = (data: any) => validator.validateFormData(data, schema).errors.length === 0;
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
formData: () => value,
|
||||
validateForm: () => formRef.current!.validateForm(),
|
||||
silentValidate: () => isValid(value),
|
||||
cancel: () => formRef.current!.reset()
|
||||
}),
|
||||
[value]
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
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 { useEvent } from "../../hooks/use-event";
|
||||
import { useEvent } from "ui/hooks/use-event";
|
||||
|
||||
export type DropdownItem =
|
||||
| (() => JSX.Element)
|
||||
@@ -14,26 +21,33 @@ export type DropdownItem =
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
export type DropdownClickableChild = ReactElement<{ onClick: () => void }>;
|
||||
export type DropdownProps = {
|
||||
className?: string;
|
||||
openEvent?: "onClick" | "onContextMenu";
|
||||
defaultOpen?: boolean;
|
||||
title?: string | ReactElement;
|
||||
dropdownWrapperProps?: Omit<ComponentPropsWithoutRef<"div">, "style">;
|
||||
position?: "bottom-start" | "bottom-end" | "top-start" | "top-end";
|
||||
hideOnEmpty?: boolean;
|
||||
items: (DropdownItem | undefined | boolean)[];
|
||||
itemsClassName?: string;
|
||||
children: ReactElement<{ onClick: () => void }>;
|
||||
children: DropdownClickableChild;
|
||||
onClickItem?: (item: DropdownItem) => void;
|
||||
renderItem?: (
|
||||
item: DropdownItem,
|
||||
props: { key: number; onClick: () => void }
|
||||
) => ReactElement<{ onClick: () => void }>;
|
||||
) => DropdownClickableChild;
|
||||
};
|
||||
|
||||
export function Dropdown({
|
||||
children,
|
||||
defaultOpen = false,
|
||||
position = "bottom-start",
|
||||
openEvent = "onClick",
|
||||
position: initialPosition = "bottom-start",
|
||||
dropdownWrapperProps,
|
||||
items,
|
||||
title,
|
||||
hideOnEmpty = true,
|
||||
onClickItem,
|
||||
renderItem,
|
||||
@@ -41,19 +55,58 @@ export function Dropdown({
|
||||
className
|
||||
}: DropdownProps) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const [position, setPosition] = useState(initialPosition);
|
||||
const clickoutsideRef = useClickOutside(() => setOpen(false));
|
||||
const menuItems = items.filter(Boolean) as DropdownItem[];
|
||||
const [_offset, _setOffset] = useState(0);
|
||||
|
||||
const toggle = useEvent((delay: number = 50) =>
|
||||
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 dropdownStyle = {
|
||||
"bottom-start": { top: "100%", left: 0, marginTop: offset },
|
||||
"bottom-end": { right: 0, top: "100%", marginTop: offset },
|
||||
"bottom-start": { top: "100%", left: _offset, marginTop: offset },
|
||||
"bottom-end": { right: _offset, top: "100%", marginTop: offset },
|
||||
"top-start": { bottom: "100%", marginBottom: offset },
|
||||
"top-end": { bottom: "100%", right: 0, marginBottom: offset }
|
||||
"top-end": { bottom: "100%", right: _offset, marginBottom: offset }
|
||||
}[position];
|
||||
|
||||
const internalOnClickItem = useEvent((item) => {
|
||||
@@ -94,13 +147,25 @@ export function Dropdown({
|
||||
));
|
||||
|
||||
return (
|
||||
<div role="dropdown" className={twMerge("relative flex", className)} ref={clickoutsideRef}>
|
||||
{cloneElement(children as any, { onClick: toggle })}
|
||||
<div
|
||||
role="dropdown"
|
||||
className={twMerge("relative flex", className)}
|
||||
ref={clickoutsideRef}
|
||||
onContextMenu={onContextMenuHandler}
|
||||
>
|
||||
{cloneElement(children as any, { onClick: onClickHandler })}
|
||||
{open && (
|
||||
<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}
|
||||
>
|
||||
{title && (
|
||||
<div className="text-sm font-bold px-2.5 mb-1 mt-1 opacity-50">{title}</div>
|
||||
)}
|
||||
{menuItems.map((item, i) =>
|
||||
itemRenderer(item, { key: i, onClick: () => internalOnClickItem(item) })
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
export type TStepsProps = {
|
||||
children: any;
|
||||
initialPath?: string[];
|
||||
initialState?: any;
|
||||
lastBack?: () => void;
|
||||
[key: string]: any;
|
||||
};
|
||||
@@ -19,13 +20,14 @@ type TStepContext<T = any> = {
|
||||
stepBack: () => void;
|
||||
close: () => void;
|
||||
state: T;
|
||||
path: string[];
|
||||
setState: Dispatch<SetStateAction<T>>;
|
||||
};
|
||||
|
||||
const StepContext = createContext<TStepContext>(undefined as any);
|
||||
|
||||
export function Steps({ children, initialPath = [], lastBack }: TStepsProps) {
|
||||
const [state, setState] = useState<any>({});
|
||||
export function Steps({ children, initialPath = [], initialState = {}, lastBack }: TStepsProps) {
|
||||
const [state, setState] = useState<any>(initialState);
|
||||
const [path, setPath] = useState<string[]>(initialPath);
|
||||
const steps: any[] = Children.toArray(children).filter(
|
||||
(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];
|
||||
|
||||
return (
|
||||
<StepContext.Provider value={{ nextStep, stepBack, state, setState, close: lastBack! }}>
|
||||
<StepContext.Provider value={{ nextStep, stepBack, state, path, setState, close: lastBack! }}>
|
||||
{current}
|
||||
</StepContext.Provider>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { ValueError } from "@sinclair/typebox/value";
|
||||
import type { AppAuthOAuthStrategy, AppAuthSchema } from "auth/auth-schema";
|
||||
import clsx from "clsx";
|
||||
import { type TSchema, Type, Value } from "core/utils";
|
||||
import { Form, type Validator } from "json-schema-form-react";
|
||||
import { transform } from "lodash-es";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { Group, Input, Label } from "ui/components/form/Formy";
|
||||
import { SocialLink } from "ui/modules/auth/SocialLink";
|
||||
import { Group, Input, Label } from "ui/components/form/Formy/components";
|
||||
import { SocialLink } from "./SocialLink";
|
||||
|
||||
export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "onSubmit" | "action"> & {
|
||||
className?: string;
|
||||
@@ -86,7 +86,7 @@ export function AuthForm({
|
||||
schema={schema}
|
||||
validator={validator}
|
||||
validationMode="change"
|
||||
className={twMerge("flex flex-col gap-3 w-full", className)}
|
||||
className={clsx("flex flex-col gap-3 w-full", className)}
|
||||
>
|
||||
{({ errors, submitting }) => (
|
||||
<>
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useAuthStrategies } from "ui/client/schema/auth/use-auth";
|
||||
import { Logo } from "ui/components/display/Logo";
|
||||
import { Link } from "ui/components/wouter/Link";
|
||||
import { AuthForm } from "ui/modules/auth/AuthForm";
|
||||
import { useAuthStrategies } from "../hooks/use-auth";
|
||||
import { AuthForm } from "./AuthForm";
|
||||
|
||||
export type AuthScreenProps = {
|
||||
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">
|
||||
{!loading && (
|
||||
<div className="flex flex-col gap-4 items-center w-96 px-6 py-7">
|
||||
{typeof logo !== "undefined" ? (
|
||||
logo
|
||||
) : (
|
||||
<Link href={"/"} className="link">
|
||||
<Logo scale={0.25} />
|
||||
</Link>
|
||||
)}
|
||||
{logo ? logo : null}
|
||||
{typeof intro !== "undefined" ? (
|
||||
intro
|
||||
) : (
|
||||
9
app/src/ui/elements/auth/index.ts
Normal file
9
app/src/ui/elements/auth/index.ts
Normal 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
|
||||
};
|
||||
23
app/src/ui/elements/hooks/use-auth.ts
Normal file
23
app/src/ui/elements/hooks/use-auth.ts
Normal 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 };
|
||||
};
|
||||
@@ -1,2 +1,2 @@
|
||||
export { Auth } from "ui/modules/auth/index";
|
||||
export { Auth } from "./auth";
|
||||
export * from "./media";
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { PreviewWrapperMemoized } from "ui/modules/media/components/dropzone/Dropzone";
|
||||
import { DropzoneContainer } from "ui/modules/media/components/dropzone/DropzoneContainer";
|
||||
|
||||
export const Media = {
|
||||
Dropzone: DropzoneContainer,
|
||||
Preview: PreviewWrapperMemoized
|
||||
};
|
||||
|
||||
export type {
|
||||
PreviewComponentProps,
|
||||
FileState,
|
||||
DropzoneProps,
|
||||
DropzoneRenderProps
|
||||
} from "ui/modules/media/components/dropzone/Dropzone";
|
||||
export type { DropzoneContainerProps } from "ui/modules/media/components/dropzone/DropzoneContainer";
|
||||
@@ -1,16 +1,11 @@
|
||||
import type { RepoQuery } from "data";
|
||||
import type { RepoQuery, RepoQueryIn } from "data";
|
||||
import type { MediaFieldSchema } from "media/AppMedia";
|
||||
import type { TAppMediaConfig } from "media/media-schema";
|
||||
import { useId } from "react";
|
||||
import { useApi, useBaseUrl, useEntityQuery, useInvalidate } from "ui/client";
|
||||
import { useEvent } from "ui/hooks/use-event";
|
||||
import {
|
||||
Dropzone,
|
||||
type DropzoneProps,
|
||||
type DropzoneRenderProps,
|
||||
type FileState
|
||||
} from "ui/modules/media/components/dropzone/Dropzone";
|
||||
import { mediaItemsToFileStates } from "ui/modules/media/helper";
|
||||
import { Dropzone, type DropzoneProps, type DropzoneRenderProps, type FileState } from "./Dropzone";
|
||||
import { mediaItemsToFileStates } from "./helper";
|
||||
|
||||
export type DropzoneContainerProps = {
|
||||
children?: (props: DropzoneRenderProps) => JSX.Element;
|
||||
@@ -20,7 +15,7 @@ export type DropzoneContainerProps = {
|
||||
id: number;
|
||||
field: string;
|
||||
};
|
||||
query?: Partial<RepoQuery>;
|
||||
query?: RepoQueryIn;
|
||||
} & Partial<Pick<TAppMediaConfig, "basepath" | "entity_name" | "storage">> &
|
||||
Partial<DropzoneProps>;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user