Compare commits

...

33 Commits

Author SHA1 Message Date
dswbx ab46611839 app auth: fix missing email when creating user on seeding 2024-12-24 16:16:45 +01:00
dswbx 24226382e8 Merge pull request #33 from bknd-io/release/0.4.0
Release 0.4.0
2024-12-24 16:11:26 +01:00
dswbx d04c4a6191 bump to 0.4.0 2024-12-24 16:10:55 +01:00
dswbx 06125f1afe added fallback route to server, created extensive setup instructions in docs 2024-12-24 16:01:42 +01:00
dswbx 8ef11aa382 Merge pull request #34 from bknd-io/refactor/adapter-options
Refactor: Unified framework and runtime adapters
2024-12-24 09:45:03 +01:00
dswbx 76da14294c unified runtime and framework adapters 2024-12-24 09:43:16 +01:00
dswbx c1e92e503b update & fix typing, updated examples 2024-12-23 19:28:31 +01:00
dswbx 70e42a02d7 updated examples: astro, nextjs, remix, bun, node 2024-12-23 16:50:26 +01:00
dswbx a17fd2df67 Merge pull request #32 from bknd-io/feat/optimize-seeding
Feat: optimize seeding, adding api typing support
2024-12-23 11:45:15 +01:00
dswbx 0648f71a9e Merge pull request #27 from bknd-io/feat/optimize-cf-adapter
improved cloudflare worker adapter
2024-12-23 11:44:52 +01:00
dswbx 131b7dd52e Merge pull request #25 from bknd-io/feat/node-media-local
Feat: Node media local
2024-12-23 11:44:40 +01:00
dswbx 3a79ce2cf8 added a new mutate replacement for useEntityMutate to quickly update cache without revalidating 2024-12-21 15:03:14 +01:00
dswbx deddf00c38 added pausing to event manager, added context aware entity schemas, fixed typings, first boot event, improved useEntityQuery mutation behavior 2024-12-20 20:11:49 +01:00
dswbx a7e3ce878a added chainable functions in em() 2024-12-19 16:12:30 +01:00
dswbx d7ee13011f isolated constructor functions for entities and relations from config 2024-12-19 08:16:47 +01:00
dswbx 1d1ebff64d Merge remote-tracking branch 'origin/main' into feat/optimize-seeding 2024-12-19 07:59:51 +01:00
dswbx ac2ae2657e Merge pull request #31 from bknd-io/feat/optimize-api-and-replace-react-query
optimize client-side api & hooks
2024-12-19 07:59:16 +01:00
dswbx 8e987d58f2 to em() toJSON to match expected type 2024-12-19 07:58:41 +01:00
dswbx 386c0d3ff0 added new em() shorthand for prototyping, added insertMany mutator function for seeding 2024-12-19 07:38:40 +01:00
dswbx 602235b372 optimized module manager seeding, added type support for new api hooks and reduced amount of dist chunks 2024-12-18 18:22:01 +01:00
dswbx c4138ef823 updated plasmic package to work with new hooks + added example 2024-12-18 08:41:11 +01:00
dswbx 1631bbb754 replaced all react-query usages with new hooks + removed react-query 2024-12-13 17:36:09 +01:00
dswbx 8c91dff94d updated admin to use swr hooks instead of react-query 2024-12-13 16:24:55 +01:00
dswbx 50c5adce0c added useEntity and useEntityQuery hooks 2024-12-12 17:00:10 +01:00
dswbx 9d9aa7b7a5 added useApi and useApiQuery to work seamlessly with SWR (tbc) 2024-12-12 10:37:52 +01:00
dswbx 43ec075a32 reworked api to return a custom promise to extract request info 2024-12-12 10:01:05 +01:00
dswbx 29ae6c6f9d hotfix release 0.3.3 2024-12-11 18:49:57 +01:00
dswbx bd86f8ef91 Merge pull request #30 from bknd-io/fix/admin-ui-fixes-3
admin ui: fixing condition where entity has been created with empty label
2024-12-11 18:49:22 +01:00
dswbx 25027429df fixing condition where entity has been created with empty label 2024-12-11 18:48:57 +01:00
dswbx 32459a1562 improved cloudflare worker adapter + added docs about different modes 2024-12-11 17:09:28 +01:00
dswbx b7ec4982dc cli: automatically register local adapter 2024-12-10 15:50:41 +01:00
dswbx 5cca911e9d Merge remote-tracking branch 'origin/main' into feat/node-media-local 2024-12-10 08:53:09 +01:00
dswbx 290498de6e refactor registries to make registration more convenient 2024-12-10 08:47:23 +01:00
180 changed files with 4033 additions and 2345 deletions
+1
View File
@@ -14,6 +14,7 @@ packages/media/.env
**/*/.env **/*/.env
**/*/.dev.vars **/*/.dev.vars
**/*/.wrangler **/*/.wrangler
**/*/*.tgz
**/*/vite.config.ts.timestamp* **/*/vite.config.ts.timestamp*
.history .history
**/*/.db/* **/*/.db/*
+96
View File
@@ -0,0 +1,96 @@
import { describe, expect, it } from "bun:test";
import { Hono } from "hono";
import { secureRandomString } from "../../src/core/utils";
import { ModuleApi } from "../../src/modules";
class Api extends ModuleApi {
_getUrl(path: string) {
return this.getUrl(path);
}
}
const host = "http://localhost";
describe("ModuleApi", () => {
it("resolves options correctly", () => {
const api = new Api({ host });
expect(api.options).toEqual({ host });
});
it("returns correct url from path", () => {
const api = new Api({ host });
expect(api._getUrl("/test")).toEqual("http://localhost/test");
expect(api._getUrl("test")).toEqual("http://localhost/test");
expect(api._getUrl("test/")).toEqual("http://localhost/test");
expect(api._getUrl("//test?foo=1")).toEqual("http://localhost/test?foo=1");
});
it("fetches endpoint", async () => {
const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" }));
const api = new Api({ host });
api.fetcher = app.request as typeof fetch;
const res = await api.get("/endpoint");
expect(res.res.ok).toEqual(true);
expect(res.res.status).toEqual(200);
expect(res.data).toEqual({ foo: "bar" });
expect(res.body).toEqual({ foo: "bar" });
});
it("has accessible request", async () => {
const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" }));
const api = new Api({ host });
api.fetcher = app.request as typeof fetch;
const promise = api.get("/endpoint");
expect(promise.request).toBeDefined();
expect(promise.request.url).toEqual("http://localhost/endpoint");
expect((await promise).body).toEqual({ foo: "bar" });
});
it("adds token to headers when given in options", () => {
const token = secureRandomString(20);
const api = new Api({ host, token, token_transport: "header" });
expect(api.get("/").request.headers.get("Authorization")).toEqual(`Bearer ${token}`);
});
it("sets header to accept json", () => {
const api = new Api({ host });
expect(api.get("/").request.headers.get("Accept")).toEqual("application/json");
});
it("adds additional headers from options", () => {
const headers = new Headers({
"X-Test": "123"
});
const api = new Api({ host, headers });
expect(api.get("/").request.headers.get("X-Test")).toEqual("123");
});
it("uses basepath & removes trailing slash", () => {
const api = new Api({ host, basepath: "/api" });
expect(api.get("/").request.url).toEqual("http://localhost/api");
});
it("uses search params", () => {
const api = new Api({ host });
const search = new URLSearchParams({
foo: "bar"
});
expect(api.get("/", search).request.url).toEqual("http://localhost/?" + search.toString());
});
it("resolves method shortcut fns correctly", () => {
const api = new Api({ host });
expect(api.get("/").request.method).toEqual("GET");
expect(api.post("/").request.method).toEqual("POST");
expect(api.put("/").request.method).toEqual("PUT");
expect(api.patch("/").request.method).toEqual("PATCH");
expect(api.delete("/").request.method).toEqual("DELETE");
});
// @todo: test error response
// @todo: test method shortcut functions
});
-15
View File
@@ -1,15 +0,0 @@
import { describe, test } from "bun:test";
import { DataApi } from "../../src/modules/data/api/DataApi";
describe("Api", async () => {
test("...", async () => {
/*const dataApi = new DataApi({
host: "https://dev-config-soma.bknd.run"
});
const one = await dataApi.readOne("users", 1);
const many = await dataApi.readMany("users", { limit: 2 });
console.log("one", one);
console.log("many", many);*/
});
});
+36 -5
View File
@@ -1,4 +1,4 @@
import { describe, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import type { TObject, TString } from "@sinclair/typebox"; import type { TObject, TString } from "@sinclair/typebox";
import { Registry } from "../../src/core/registry/Registry"; import { Registry } from "../../src/core/registry/Registry";
import { type TSchema, Type } from "../../src/core/utils"; import { type TSchema, Type } from "../../src/core/utils";
@@ -11,6 +11,9 @@ class What {
method() { method() {
return null; return null;
} }
getType() {
return Type.Object({ type: Type.String() });
}
} }
class What2 extends What {} class What2 extends What {}
class NotAllowed {} class NotAllowed {}
@@ -32,25 +35,53 @@ describe("Registry", () => {
} satisfies Record<string, Test1>); } satisfies Record<string, Test1>);
const item = registry.get("first"); const item = registry.get("first");
expect(item).toBeDefined();
expect(item?.cls).toBe(What);
const second = Type.Object({ type: Type.String(), what: Type.String() });
registry.add("second", { registry.add("second", {
cls: What2, cls: What2,
schema: Type.Object({ type: Type.String(), what: Type.String() }), schema: second,
enabled: true enabled: true
}); });
// @ts-ignore
expect(registry.get("second").schema).toEqual(second);
const third = Type.Object({ type: Type.String({ default: "1" }), what22: Type.String() });
registry.add("third", { registry.add("third", {
// @ts-expect-error // @ts-expect-error
cls: NotAllowed, cls: NotAllowed,
schema: Type.Object({ type: Type.String({ default: "1" }), what22: Type.String() }), schema: third,
enabled: true enabled: true
}); });
// @ts-ignore
expect(registry.get("third").schema).toEqual(third);
const fourth = Type.Object({ type: Type.Number(), what22: Type.String() });
registry.add("fourth", { registry.add("fourth", {
cls: What, cls: What,
// @ts-expect-error // @ts-expect-error
schema: Type.Object({ type: Type.Number(), what22: Type.String() }), schema: fourth,
enabled: true enabled: true
}); });
// @ts-ignore
expect(registry.get("fourth").schema).toEqual(fourth);
console.log("list", registry.all()); expect(Object.keys(registry.all()).length).toBe(4);
});
test("uses registration fn", async () => {
const registry = new Registry<Test1>((a: ClassRef<What>) => {
return {
cls: a,
schema: a.prototype.getType(),
enabled: true
};
});
registry.register("what2", What2);
expect(registry.get("what2")).toBeDefined();
expect(registry.get("what2").cls).toBe(What2);
expect(registry.get("what2").schema).toEqual(What2.prototype.getType());
}); });
}); });
+15 -1
View File
@@ -16,7 +16,7 @@ describe("Mutator simple", async () => {
new TextField("label", { required: true, minLength: 1 }), new TextField("label", { required: true, minLength: 1 }),
new NumberField("count", { default_value: 0 }) new NumberField("count", { default_value: 0 })
]); ]);
const em = new EntityManager([items], connection); const em = new EntityManager<any>([items], connection);
await em.connection.kysely.schema await em.connection.kysely.schema
.createTable("items") .createTable("items")
@@ -175,4 +175,18 @@ describe("Mutator simple", async () => {
{ id: 8, label: "keep", count: 0 } { id: 8, label: "keep", count: 0 }
]); ]);
}); });
test("insertMany", async () => {
const oldCount = (await em.repo(items).count()).count;
const inserts = [{ label: "insert 1" }, { label: "insert 2" }];
const { data } = await em.mutator(items).insertMany(inserts);
expect(data.length).toBe(2);
expect(data.map((d) => ({ label: d.label }))).toEqual(inserts);
const newCount = (await em.repo(items).count()).count;
expect(newCount).toBe(oldCount + inserts.length);
const { data: data2 } = await em.repo(items).findMany({ offset: oldCount });
expect(data2).toEqual(data);
});
}); });
+46 -3
View File
@@ -3,6 +3,8 @@ import {
BooleanField, BooleanField,
DateField, DateField,
Entity, Entity,
EntityIndex,
EntityManager,
EnumField, EnumField,
JsonField, JsonField,
ManyToManyRelation, ManyToManyRelation,
@@ -12,6 +14,7 @@ import {
PolymorphicRelation, PolymorphicRelation,
TextField TextField
} from "../../src/data"; } from "../../src/data";
import { DummyConnection } from "../../src/data/connection/DummyConnection";
import { import {
FieldPrototype, FieldPrototype,
type FieldSchema, type FieldSchema,
@@ -20,6 +23,7 @@ import {
boolean, boolean,
date, date,
datetime, datetime,
em,
entity, entity,
enumm, enumm,
json, json,
@@ -46,12 +50,17 @@ describe("prototype", () => {
}); });
test("...2", async () => { test("...2", async () => {
const user = entity("users", { const users = entity("users", {
name: text().required(), name: text(),
bio: text(), bio: text(),
age: number(), age: number(),
some: number().required() some: number()
}); });
type db = {
users: Schema<typeof users>;
};
const obj: Schema<typeof users> = {} as any;
//console.log("user", user.toJSON()); //console.log("user", user.toJSON());
}); });
@@ -266,4 +275,38 @@ describe("prototype", () => {
const obj: Schema<typeof test> = {} as any; const obj: Schema<typeof test> = {} as any;
}); });
test("schema", async () => {
const _em = em(
{
posts: entity("posts", { name: text(), slug: text().required() }),
comments: entity("comments", { some: text() }),
users: entity("users", { email: text() })
},
({ relation, index }, { posts, comments, users }) => {
relation(posts).manyToOne(comments).manyToOne(users);
index(posts).on(["name"]).on(["slug"], true);
}
);
type LocalDb = (typeof _em)["DB"];
const es = [
new Entity("posts", [new TextField("name"), new TextField("slug", { required: true })]),
new Entity("comments", [new TextField("some")]),
new Entity("users", [new TextField("email")])
];
const _em2 = new EntityManager(
es,
new DummyConnection(),
[new ManyToOneRelation(es[0], es[1]), new ManyToOneRelation(es[0], es[2])],
[
new EntityIndex(es[0], [es[0].field("name")!]),
new EntityIndex(es[0], [es[0].field("slug")!], true)
]
);
// @ts-ignore
expect(_em2.toJSON()).toEqual(_em.toJSON());
});
}); });
+4 -4
View File
@@ -22,7 +22,7 @@ describe("[data] Mutator (base)", async () => {
new TextField("hidden", { hidden: true }), new TextField("hidden", { hidden: true }),
new TextField("not_fillable", { fillable: false }) new TextField("not_fillable", { fillable: false })
]); ]);
const em = new EntityManager([entity], dummyConnection); const em = new EntityManager<any>([entity], dummyConnection);
await em.schema().sync({ force: true }); await em.schema().sync({ force: true });
const payload = { label: "item 1", count: 1 }; const payload = { label: "item 1", count: 1 };
@@ -61,7 +61,7 @@ describe("[data] Mutator (ManyToOne)", async () => {
const posts = new Entity("posts", [new TextField("title")]); const posts = new Entity("posts", [new TextField("title")]);
const users = new Entity("users", [new TextField("username")]); const users = new Entity("users", [new TextField("username")]);
const relations = [new ManyToOneRelation(posts, users)]; const relations = [new ManyToOneRelation(posts, users)];
const em = new EntityManager([posts, users], dummyConnection, relations); const em = new EntityManager<any>([posts, users], dummyConnection, relations);
await em.schema().sync({ force: true }); await em.schema().sync({ force: true });
test("RelationMutator", async () => { test("RelationMutator", async () => {
@@ -192,7 +192,7 @@ describe("[data] Mutator (OneToOne)", async () => {
const users = new Entity("users", [new TextField("username")]); const users = new Entity("users", [new TextField("username")]);
const settings = new Entity("settings", [new TextField("theme")]); const settings = new Entity("settings", [new TextField("theme")]);
const relations = [new OneToOneRelation(users, settings)]; const relations = [new OneToOneRelation(users, settings)];
const em = new EntityManager([users, settings], dummyConnection, relations); const em = new EntityManager<any>([users, settings], dummyConnection, relations);
await em.schema().sync({ force: true }); await em.schema().sync({ force: true });
test("insertOne: missing ref", async () => { test("insertOne: missing ref", async () => {
@@ -276,7 +276,7 @@ describe("[data] Mutator (ManyToMany)", async () => {
describe("[data] Mutator (Events)", async () => { describe("[data] Mutator (Events)", async () => {
const entity = new Entity("test", [new TextField("label")]); const entity = new Entity("test", [new TextField("label")]);
const em = new EntityManager([entity], dummyConnection); const em = new EntityManager<any>([entity], dummyConnection);
await em.schema().sync({ force: true }); await em.schema().sync({ force: true });
const events = new Map<string, any>(); const events = new Map<string, any>();
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, test } from "bun:test";
import * as large from "../../src/media/storage/mime-types";
import * as tiny from "../../src/media/storage/mime-types-tiny";
describe("media/mime-types", () => {
test("tiny resolves", () => {
const tests = [[".mp4", "video/mp4", ".jpg", "image/jpeg", ".zip", "application/zip"]];
for (const [ext, mime] of tests) {
expect(tiny.guess(ext)).toBe(mime);
}
});
test("all tiny resolves to large", () => {
for (const [ext, mime] of Object.entries(tiny.M)) {
expect(large.guessMimeType("." + ext)).toBe(mime);
}
for (const [type, exts] of Object.entries(tiny.Q)) {
for (const ext of exts) {
const ex = `${type}/${ext}`;
try {
expect(large.guessMimeType("." + ext)).toBe(ex);
} catch (e) {
console.log(`Failed for ${ext}`, {
type,
exts,
ext,
expected: ex,
actual: large.guessMimeType("." + ext)
});
throw new Error(`Failed for ${ext}`);
}
}
}
});
});
+42 -6
View File
@@ -9,15 +9,43 @@ const watch = args.includes("--watch");
const minify = args.includes("--minify"); const minify = args.includes("--minify");
const types = args.includes("--types"); const types = args.includes("--types");
const sourcemap = args.includes("--sourcemap"); const sourcemap = args.includes("--sourcemap");
const clean = args.includes("--clean");
if (clean) {
console.log("Cleaning dist");
await $`rm -rf dist`; await $`rm -rf dist`;
if (types) { }
let types_running = false;
function buildTypes() {
if (types_running) return;
types_running = true;
Bun.spawn(["bun", "build:types"], { Bun.spawn(["bun", "build:types"], {
onExit: () => { onExit: () => {
console.log("Types built"); console.log("Types built");
Bun.spawn(["bun", "tsc-alias"], {
onExit: () => {
console.log("Types aliased");
types_running = false;
} }
}); });
} }
});
}
let watcher_timeout: any;
function delayTypes() {
if (!watch) return;
if (watcher_timeout) {
clearTimeout(watcher_timeout);
}
watcher_timeout = setTimeout(buildTypes, 1000);
}
if (types && !watch) {
buildTypes();
}
/** /**
* Build static assets * Build static assets
@@ -46,7 +74,8 @@ const result = await esbuild.build({
__isDev: "0", __isDev: "0",
"process.env.NODE_ENV": '"production"' "process.env.NODE_ENV": '"production"'
}, },
chunkNames: "chunks/[name]-[hash]" chunkNames: "chunks/[name]-[hash]",
logLevel: "error"
}); });
// Write manifest // Write manifest
@@ -96,6 +125,9 @@ await tsup.build({
treeshake: true, treeshake: true,
loader: { loader: {
".svg": "dataurl" ".svg": "dataurl"
},
onSuccess: async () => {
delayTypes();
} }
}); });
@@ -117,11 +149,12 @@ await tsup.build({
loader: { loader: {
".svg": "dataurl" ".svg": "dataurl"
}, },
onSuccess: async () => {
console.log("--- ui built");
},
esbuildOptions: (options) => { esbuildOptions: (options) => {
options.logLevel = "silent";
options.chunkNames = "chunks/[name]-[hash]"; options.chunkNames = "chunks/[name]-[hash]";
},
onSuccess: async () => {
delayTypes();
} }
}); });
@@ -148,7 +181,10 @@ function baseConfig(adapter: string): tsup.Options {
], ],
metafile: true, metafile: true,
splitting: false, splitting: false,
treeshake: true treeshake: true,
onSuccess: async () => {
delayTypes();
}
}; };
} }
+26 -26
View File
@@ -3,16 +3,16 @@
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,
"bin": "./dist/cli/index.js", "bin": "./dist/cli/index.js",
"version": "0.3.2", "version": "0.4.0",
"scripts": { "scripts": {
"build:all": "bun run build && bun run build:cli", "build:all": "NODE_ENV=production bun run build.ts --minify --types --clean && bun run build:cli",
"dev": "vite", "dev": "vite",
"test": "ALL_TESTS=1 bun test --bail", "test": "ALL_TESTS=1 bun test --bail",
"build": "NODE_ENV=production bun run build.ts --minify --types", "build": "NODE_ENV=production bun run build.ts --minify --types",
"watch": "bun run build.ts --types --watch", "watch": "bun run build.ts --types --watch",
"types": "bun tsc --noEmit", "types": "bun tsc --noEmit",
"clean:types": "find ./dist -name '*.d.ts' -delete && rm -f ./dist/tsconfig.tsbuildinfo", "clean:types": "find ./dist -name '*.d.ts' -delete && rm -f ./dist/tsconfig.tsbuildinfo",
"build:types": "tsc --emitDeclarationOnly", "build:types": "tsc --emitDeclarationOnly && tsc-alias",
"build:css": "bun tailwindcss -i src/ui/main.css -o ./dist/static/styles.css", "build:css": "bun tailwindcss -i src/ui/main.css -o ./dist/static/styles.css",
"watch:css": "bun tailwindcss --watch -i src/ui/main.css -o ./dist/styles.css", "watch:css": "bun tailwindcss --watch -i src/ui/main.css -o ./dist/styles.css",
"updater": "bun x npm-check-updates -ui", "updater": "bun x npm-check-updates -ui",
@@ -22,18 +22,19 @@
}, },
"license": "FSL-1.1-MIT", "license": "FSL-1.1-MIT",
"dependencies": { "dependencies": {
"@cfworker/json-schema": "^2.0.1",
"@libsql/client": "^0.14.0", "@libsql/client": "^0.14.0",
"@tanstack/react-form": "0.19.2",
"@sinclair/typebox": "^0.32.34", "@sinclair/typebox": "^0.32.34",
"@tanstack/react-form": "0.19.2",
"aws4fetch": "^1.0.18",
"dayjs": "^1.11.13",
"fast-xml-parser": "^4.4.0",
"hono": "^4.6.12",
"kysely": "^0.27.4", "kysely": "^0.27.4",
"liquidjs": "^10.15.0", "liquidjs": "^10.15.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"hono": "^4.6.12",
"fast-xml-parser": "^4.4.0",
"@cfworker/json-schema": "^2.0.1",
"dayjs": "^1.11.13",
"oauth4webapi": "^2.11.1", "oauth4webapi": "^2.11.1",
"aws4fetch": "^1.0.18" "swr": "^2.2.5"
}, },
"devDependencies": { "devDependencies": {
"@aws-sdk/client-s3": "^3.613.0", "@aws-sdk/client-s3": "^3.613.0",
@@ -54,8 +55,6 @@
"@radix-ui/react-scroll-area": "^1.2.0", "@radix-ui/react-scroll-area": "^1.2.0",
"@rjsf/core": "^5.22.2", "@rjsf/core": "^5.22.2",
"@tabler/icons-react": "3.18.0", "@tabler/icons-react": "3.18.0",
"@tanstack/react-query": "^5.59.16",
"@tanstack/react-query-devtools": "^5.59.16",
"@types/node": "^22.10.0", "@types/node": "^22.10.0",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
@@ -76,6 +75,7 @@
"tailwind-merge": "^2.5.4", "tailwind-merge": "^2.5.4",
"tailwindcss": "^3.4.14", "tailwindcss": "^3.4.14",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tsc-alias": "^1.8.10",
"tsup": "^8.3.5", "tsup": "^8.3.5",
"vite": "^5.4.10", "vite": "^5.4.10",
"vite-plugin-static-copy": "^2.0.0", "vite-plugin-static-copy": "^2.0.0",
@@ -91,75 +91,75 @@
}, },
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/types/index.d.ts",
"exports": { "exports": {
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/types/index.d.ts",
"import": "./dist/index.js", "import": "./dist/index.js",
"require": "./dist/index.cjs" "require": "./dist/index.cjs"
}, },
"./ui": { "./ui": {
"types": "./dist/ui/index.d.ts", "types": "./dist/types/ui/index.d.ts",
"import": "./dist/ui/index.js", "import": "./dist/ui/index.js",
"require": "./dist/ui/index.cjs" "require": "./dist/ui/index.cjs"
}, },
"./client": { "./client": {
"types": "./dist/ui/client/index.d.ts", "types": "./dist/types/ui/client/index.d.ts",
"import": "./dist/ui/client/index.js", "import": "./dist/ui/client/index.js",
"require": "./dist/ui/client/index.cjs" "require": "./dist/ui/client/index.cjs"
}, },
"./data": { "./data": {
"types": "./dist/data/index.d.ts", "types": "./dist/types/data/index.d.ts",
"import": "./dist/data/index.js", "import": "./dist/data/index.js",
"require": "./dist/data/index.cjs" "require": "./dist/data/index.cjs"
}, },
"./core": { "./core": {
"types": "./dist/core/index.d.ts", "types": "./dist/types/core/index.d.ts",
"import": "./dist/core/index.js", "import": "./dist/core/index.js",
"require": "./dist/core/index.cjs" "require": "./dist/core/index.cjs"
}, },
"./utils": { "./utils": {
"types": "./dist/core/utils/index.d.ts", "types": "./dist/types/core/utils/index.d.ts",
"import": "./dist/core/utils/index.js", "import": "./dist/core/utils/index.js",
"require": "./dist/core/utils/index.cjs" "require": "./dist/core/utils/index.cjs"
}, },
"./cli": { "./cli": {
"types": "./dist/cli/index.d.ts", "types": "./dist/types/cli/index.d.ts",
"import": "./dist/cli/index.js", "import": "./dist/cli/index.js",
"require": "./dist/cli/index.cjs" "require": "./dist/cli/index.cjs"
}, },
"./adapter/cloudflare": { "./adapter/cloudflare": {
"types": "./dist/adapter/cloudflare/index.d.ts", "types": "./dist/types/adapter/cloudflare/index.d.ts",
"import": "./dist/adapter/cloudflare/index.js", "import": "./dist/adapter/cloudflare/index.js",
"require": "./dist/adapter/cloudflare/index.cjs" "require": "./dist/adapter/cloudflare/index.cjs"
}, },
"./adapter/vite": { "./adapter/vite": {
"types": "./dist/adapter/vite/index.d.ts", "types": "./dist/types/adapter/vite/index.d.ts",
"import": "./dist/adapter/vite/index.js", "import": "./dist/adapter/vite/index.js",
"require": "./dist/adapter/vite/index.cjs" "require": "./dist/adapter/vite/index.cjs"
}, },
"./adapter/nextjs": { "./adapter/nextjs": {
"types": "./dist/adapter/nextjs/index.d.ts", "types": "./dist/types/adapter/nextjs/index.d.ts",
"import": "./dist/adapter/nextjs/index.js", "import": "./dist/adapter/nextjs/index.js",
"require": "./dist/adapter/nextjs/index.cjs" "require": "./dist/adapter/nextjs/index.cjs"
}, },
"./adapter/remix": { "./adapter/remix": {
"types": "./dist/adapter/remix/index.d.ts", "types": "./dist/types/adapter/remix/index.d.ts",
"import": "./dist/adapter/remix/index.js", "import": "./dist/adapter/remix/index.js",
"require": "./dist/adapter/remix/index.cjs" "require": "./dist/adapter/remix/index.cjs"
}, },
"./adapter/bun": { "./adapter/bun": {
"types": "./dist/adapter/bun/index.d.ts", "types": "./dist/types/adapter/bun/index.d.ts",
"import": "./dist/adapter/bun/index.js", "import": "./dist/adapter/bun/index.js",
"require": "./dist/adapter/bun/index.cjs" "require": "./dist/adapter/bun/index.cjs"
}, },
"./adapter/node": { "./adapter/node": {
"types": "./dist/adapter/node/index.d.ts", "types": "./dist/types/adapter/node/index.d.ts",
"import": "./dist/adapter/node/index.js", "import": "./dist/adapter/node/index.js",
"require": "./dist/adapter/node/index.cjs" "require": "./dist/adapter/node/index.cjs"
}, },
"./adapter/astro": { "./adapter/astro": {
"types": "./dist/adapter/astro/index.d.ts", "types": "./dist/types/adapter/astro/index.d.ts",
"import": "./dist/adapter/astro/index.js", "import": "./dist/adapter/astro/index.js",
"require": "./dist/adapter/astro/index.cjs" "require": "./dist/adapter/astro/index.cjs"
}, },
+40 -3
View File
@@ -1,3 +1,4 @@
import type { SafeUser } from "auth";
import { AuthApi } from "auth/api/AuthApi"; import { AuthApi } from "auth/api/AuthApi";
import { DataApi } from "data/api/DataApi"; import { DataApi } from "data/api/DataApi";
import { decode } from "hono/jwt"; import { decode } from "hono/jwt";
@@ -5,7 +6,7 @@ import { omit } from "lodash-es";
import { MediaApi } from "media/api/MediaApi"; import { MediaApi } from "media/api/MediaApi";
import { SystemApi } from "modules/SystemApi"; import { SystemApi } from "modules/SystemApi";
export type TApiUser = object; export type TApiUser = SafeUser;
declare global { declare global {
interface Window { interface Window {
@@ -24,6 +25,12 @@ export type ApiOptions = {
localStorage?: boolean; localStorage?: boolean;
}; };
export type AuthState = {
token?: string;
user?: TApiUser;
verified: boolean;
};
export class Api { export class Api {
private token?: string; private token?: string;
private user?: TApiUser; private user?: TApiUser;
@@ -50,6 +57,10 @@ export class Api {
this.buildApis(); this.buildApis();
} }
get baseUrl() {
return this.options.host;
}
get tokenKey() { get tokenKey() {
return this.options.key ?? "auth"; return this.options.key ?? "auth";
} }
@@ -85,7 +96,11 @@ export class Api {
updateToken(token?: string, rebuild?: boolean) { updateToken(token?: string, rebuild?: boolean) {
this.token = token; this.token = token;
this.user = token ? omit(decode(token).payload as any, ["iat", "iss", "exp"]) : undefined; if (token) {
this.user = omit(decode(token).payload as any, ["iat", "iss", "exp"]) as any;
} else {
this.user = undefined;
}
if (this.options.localStorage) { if (this.options.localStorage) {
const key = this.tokenKey; const key = this.tokenKey;
@@ -105,7 +120,7 @@ export class Api {
return this; return this;
} }
getAuthState() { getAuthState(): AuthState {
return { return {
token: this.token, token: this.token,
user: this.user, user: this.user,
@@ -113,6 +128,28 @@ export class Api {
}; };
} }
async getVerifiedAuthState(force?: boolean): Promise<AuthState> {
if (force === true || !this.verified) {
await this.verifyAuth();
}
return this.getAuthState();
}
async verifyAuth() {
try {
const res = await this.auth.me();
if (!res.ok || !res.body.user) {
throw new Error();
}
this.markAuthVerified(true);
} catch (e) {
this.markAuthVerified(false);
this.updateToken(undefined);
}
}
getUser(): TApiUser | null { getUser(): TApiUser | null {
return this.user || null; return this.user || null;
} }
+39 -11
View File
@@ -10,15 +10,19 @@ import * as SystemPermissions from "modules/permissions";
import { AdminController, type AdminControllerOptions } from "modules/server/AdminController"; import { AdminController, type AdminControllerOptions } from "modules/server/AdminController";
import { SystemController } from "modules/server/SystemController"; import { SystemController } from "modules/server/SystemController";
export type AppPlugin<DB> = (app: App<DB>) => void; export type AppPlugin = (app: App) => Promise<void> | void;
export class AppConfigUpdatedEvent extends Event<{ app: App }> { abstract class AppEvent<A = {}> extends Event<{ app: App } & A> {}
export class AppConfigUpdatedEvent extends AppEvent {
static override slug = "app-config-updated"; static override slug = "app-config-updated";
} }
export class AppBuiltEvent extends Event<{ app: App }> { export class AppBuiltEvent extends AppEvent {
static override slug = "app-built"; static override slug = "app-built";
} }
export const AppEvents = { AppConfigUpdatedEvent, AppBuiltEvent } as const; export class AppFirstBoot extends AppEvent {
static override slug = "app-first-boot";
}
export const AppEvents = { AppConfigUpdatedEvent, AppBuiltEvent, AppFirstBoot } as const;
export type CreateAppConfig = { export type CreateAppConfig = {
connection?: connection?:
@@ -28,29 +32,42 @@ export type CreateAppConfig = {
config: LibSqlCredentials; config: LibSqlCredentials;
}; };
initialConfig?: InitialModuleConfigs; initialConfig?: InitialModuleConfigs;
plugins?: AppPlugin<any>[]; plugins?: AppPlugin[];
options?: Omit<ModuleManagerOptions, "initial" | "onUpdated">; options?: Omit<ModuleManagerOptions, "initial" | "onUpdated">;
}; };
export type AppConfig = InitialModuleConfigs; export type AppConfig = InitialModuleConfigs;
export class App<DB = any> { export class App {
modules: ModuleManager; modules: ModuleManager;
static readonly Events = AppEvents; static readonly Events = AppEvents;
adminController?: AdminController;
private trigger_first_boot = false;
constructor( constructor(
private connection: Connection, private connection: Connection,
_initialConfig?: InitialModuleConfigs, _initialConfig?: InitialModuleConfigs,
private plugins: AppPlugin<DB>[] = [], private plugins: AppPlugin[] = [],
moduleManagerOptions?: ModuleManagerOptions moduleManagerOptions?: ModuleManagerOptions
) { ) {
this.modules = new ModuleManager(connection, { this.modules = new ModuleManager(connection, {
...moduleManagerOptions, ...moduleManagerOptions,
initial: _initialConfig, initial: _initialConfig,
onUpdated: async (key, config) => { onUpdated: async (key, config) => {
//console.log("[APP] config updated", key, config); // if the EventManager was disabled, we assume we shouldn't
// respond to events, such as "onUpdated".
if (!this.emgr.enabled) {
console.warn("[APP] config updated, but event manager is disabled, skip.");
return;
}
console.log("[APP] config updated", key);
await this.build({ sync: true, save: true }); await this.build({ sync: true, save: true });
await this.emgr.emit(new AppConfigUpdatedEvent({ app: this })); await this.emgr.emit(new AppConfigUpdatedEvent({ app: this }));
},
onFirstBoot: async () => {
console.log("[APP] first boot");
this.trigger_first_boot = true;
} }
}); });
this.modules.ctx().emgr.registerEvents(AppEvents); this.modules.ctx().emgr.registerEvents(AppEvents);
@@ -76,7 +93,7 @@ export class App<DB = any> {
// load plugins // load plugins
if (this.plugins.length > 0) { if (this.plugins.length > 0) {
this.plugins.forEach((plugin) => plugin(this)); await Promise.all(this.plugins.map((plugin) => plugin(this)));
} }
//console.log("emitting built", options); //console.log("emitting built", options);
@@ -88,14 +105,24 @@ export class App<DB = any> {
if (options?.save) { if (options?.save) {
await this.modules.save(); await this.modules.save();
} }
// first boot is set from ModuleManager when there wasn't a config table
if (this.trigger_first_boot) {
this.trigger_first_boot = false;
await this.emgr.emit(new AppFirstBoot({ app: this }));
}
} }
mutateConfig<Module extends keyof Modules>(module: Module) { mutateConfig<Module extends keyof Modules>(module: Module) {
return this.modules.get(module).schema(); return this.modules.get(module).schema();
} }
get server() {
return this.modules.server;
}
get fetch(): any { get fetch(): any {
return this.modules.server.fetch; return this.server.fetch;
} }
get module() { get module() {
@@ -119,7 +146,8 @@ export class App<DB = any> {
registerAdminController(config?: AdminControllerOptions) { registerAdminController(config?: AdminControllerOptions) {
// register admin // register admin
this.modules.server.route("/", new AdminController(this, config).getController()); this.adminController = new AdminController(this, config);
this.modules.server.route("/", this.adminController.getController());
return this; return this;
} }
+6 -5
View File
@@ -1,4 +1,7 @@
import { Api, type ApiOptions, App, type CreateAppConfig } from "bknd"; import { type FrameworkBkndConfig, createFrameworkApp } from "adapter";
import { Api, type ApiOptions, type App } from "bknd";
export type AstroBkndConfig = FrameworkBkndConfig;
type TAstro = { type TAstro = {
request: Request; request: Request;
@@ -18,12 +21,10 @@ export function getApi(Astro: TAstro, options: Options = { mode: "static" }) {
} }
let app: App; let app: App;
export function serve(config: CreateAppConfig) { export function serve(config: AstroBkndConfig = {}) {
return async (args: TAstro) => { return async (args: TAstro) => {
if (!app) { if (!app) {
app = App.create(config); app = await createFrameworkApp(config);
await app.build();
} }
return app.fetch(args.request); return app.fetch(args.request);
}; };
+32 -28
View File
@@ -1,56 +1,60 @@
/// <reference types="bun-types" /> /// <reference types="bun-types" />
import path from "node:path"; import path from "node:path";
import { App, type CreateAppConfig } from "bknd"; import type { App } from "bknd";
import type { Serve, ServeOptions } from "bun"; import type { ServeOptions } from "bun";
import { config } from "core";
import { serveStatic } from "hono/bun"; import { serveStatic } from "hono/bun";
import { type RuntimeBkndConfig, createRuntimeApp } from "../index";
let app: App; let app: App;
export async function createApp(_config: Partial<CreateAppConfig> = {}, distPath?: string) {
export type BunBkndConfig = RuntimeBkndConfig & Omit<ServeOptions, "fetch">;
export async function createApp({
distPath,
onBuilt,
buildConfig,
beforeBuild,
...config
}: RuntimeBkndConfig = {}) {
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static"); const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
if (!app) { if (!app) {
app = App.create(_config); app = await createRuntimeApp({
...config,
app.emgr.on( registerLocalMedia: true,
"app-built", serveStatic: serveStatic({ root })
async () => { });
app.modules.server.get(
"/*",
serveStatic({
root
})
);
app.registerAdminController();
},
"sync"
);
await app.build();
} }
return app; return app;
} }
export type BunAdapterOptions = Omit<ServeOptions, "fetch"> &
CreateAppConfig & {
distPath?: string;
};
export function serve({ export function serve({
distPath, distPath,
connection, connection,
initialConfig, initialConfig,
plugins, plugins,
options, options,
port = 1337, port = config.server.default_port,
onBuilt,
buildConfig,
...serveOptions ...serveOptions
}: BunAdapterOptions = {}) { }: BunBkndConfig = {}) {
Bun.serve({ Bun.serve({
...serveOptions, ...serveOptions,
port, port,
fetch: async (request: Request) => { fetch: async (request: Request) => {
const app = await createApp({ connection, initialConfig, plugins, options }, distPath); const app = await createApp({
connection,
initialConfig,
plugins,
options,
onBuilt,
buildConfig,
distPath
});
return app.fetch(request); return app.fetch(request);
} }
}); });
@@ -1,21 +1,37 @@
import { DurableObject } from "cloudflare:workers"; import type { CreateAppConfig } from "bknd";
import { App, type CreateAppConfig } from "bknd";
import { Hono } from "hono"; import { Hono } from "hono";
import { serveStatic } from "hono/cloudflare-workers"; import { serveStatic } from "hono/cloudflare-workers";
import type { BkndConfig, CfBkndModeCache } from "../index"; import type { FrameworkBkndConfig } from "../index";
import { getCached } from "./modes/cached";
import { getDurable } from "./modes/durable";
import { getFresh, getWarm } from "./modes/fresh";
type Context = { export type CloudflareBkndConfig<Env = any> = Omit<FrameworkBkndConfig, "app"> & {
request: Request; app: CreateAppConfig | ((env: Env) => CreateAppConfig);
env: any; mode?: "warm" | "fresh" | "cache" | "durable";
ctx: ExecutionContext; bindings?: (env: Env) => {
manifest: any; kv?: KVNamespace;
dobj?: DurableObjectNamespace;
};
key?: string;
keepAliveSeconds?: number;
forceHttps?: boolean;
manifest?: string;
setAdminHtml?: boolean;
html?: string; html?: string;
}; };
export function serve(_config: BkndConfig, manifest?: string, html?: string) { export type Context = {
request: Request;
env: any;
ctx: ExecutionContext;
};
export function serve(config: CloudflareBkndConfig) {
return { return {
async fetch(request: Request, env: any, ctx: ExecutionContext) { async fetch(request: Request, env: any, ctx: ExecutionContext) {
const url = new URL(request.url); const url = new URL(request.url);
const manifest = config.manifest;
if (manifest) { if (manifest) {
const pathname = url.pathname.slice(1); const pathname = url.pathname.slice(1);
@@ -26,13 +42,10 @@ export function serve(_config: BkndConfig, manifest?: string, html?: string) {
hono.all("*", async (c, next) => { hono.all("*", async (c, next) => {
const res = await serveStatic({ const res = await serveStatic({
path: `./${pathname}`, path: `./${pathname}`,
manifest, manifest
onNotFound: (path) => console.log("not found", path)
})(c as any, next); })(c as any, next);
if (res instanceof Response) { if (res instanceof Response) {
const ttl = pathname.startsWith("assets/") const ttl = 60 * 60 * 24 * 365;
? 60 * 60 * 24 * 365 // 1 year
: 60 * 5; // 5 minutes
res.headers.set("Cache-Control", `public, max-age=${ttl}`); res.headers.set("Cache-Control", `public, max-age=${ttl}`);
return res; return res;
} }
@@ -44,218 +57,23 @@ export function serve(_config: BkndConfig, manifest?: string, html?: string) {
} }
} }
const config = { config.setAdminHtml = config.setAdminHtml && !!config.manifest;
..._config,
setAdminHtml: _config.setAdminHtml ?? !!manifest
};
const context = { request, env, ctx, manifest, html };
const mode = config.cloudflare?.mode?.(env);
if (!mode) { const context = { request, env, ctx } as Context;
console.log("serving fresh..."); const mode = config.mode ?? "warm";
const app = await getFresh(config, context);
return app.fetch(request, env);
} else if ("cache" in mode) {
console.log("serving cached...");
const app = await getCached(config as any, context);
return app.fetch(request, env);
} else if ("durableObject" in mode) {
console.log("serving durable...");
if (config.onBuilt) { switch (mode) {
console.log("onBuilt() is not supported with DurableObject mode"); case "fresh":
} return await getFresh(config, context);
case "warm":
const start = performance.now(); return await getWarm(config, context);
case "cache":
const durable = mode.durableObject; return await getCached(config, context);
const id = durable.idFromName(mode.key); case "durable":
const stub = durable.get(id) as unknown as DurableBkndApp; return await getDurable(config, context);
default:
const create_config = typeof config.app === "function" ? config.app(env) : config.app; throw new Error(`Unknown mode ${mode}`);
const res = await stub.fire(request, {
config: create_config,
html,
keepAliveSeconds: mode.keepAliveSeconds,
setAdminHtml: config.setAdminHtml
});
const headers = new Headers(res.headers);
headers.set("X-TTDO", String(performance.now() - start));
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers
});
} }
} }
}; };
} }
async function getFresh(config: BkndConfig, { env, html }: Context) {
const create_config = typeof config.app === "function" ? config.app(env) : config.app;
const app = App.create(create_config);
if (config.onBuilt) {
app.emgr.onEvent(
App.Events.AppBuiltEvent,
async ({ params: { app } }) => {
config.onBuilt!(app);
},
"sync"
);
}
await app.build();
if (config.setAdminHtml) {
app.registerAdminController({ html });
}
return app;
}
async function getCached(
config: BkndConfig & { cloudflare: { mode: CfBkndModeCache } },
{ env, html, ctx }: Context
) {
const { cache, key } = config.cloudflare.mode(env) as ReturnType<CfBkndModeCache>;
const create_config = typeof config.app === "function" ? config.app(env) : config.app;
const cachedConfig = await cache.get(key);
const initialConfig = cachedConfig ? JSON.parse(cachedConfig) : undefined;
const app = App.create({ ...create_config, initialConfig });
async function saveConfig(__config: any) {
ctx.waitUntil(cache.put(key, JSON.stringify(__config)));
}
if (config.onBuilt) {
app.emgr.onEvent(
App.Events.AppBuiltEvent,
async ({ params: { app } }) => {
app.module.server.client.get("/__bknd/cache", async (c) => {
await cache.delete(key);
return c.json({ message: "Cache cleared" });
});
app.registerAdminController({ html });
config.onBuilt!(app);
},
"sync"
);
}
app.emgr.onEvent(
App.Events.AppConfigUpdatedEvent,
async ({ params: { app } }) => {
saveConfig(app.toJSON(true));
},
"sync"
);
await app.build();
if (config.setAdminHtml) {
app.registerAdminController({ html });
}
if (!cachedConfig) {
saveConfig(app.toJSON(true));
}
return app;
}
export class DurableBkndApp extends DurableObject {
protected id = Math.random().toString(36).slice(2);
protected app?: App;
protected interval?: any;
async fire(
request: Request,
options: {
config: CreateAppConfig;
html?: string;
keepAliveSeconds?: number;
setAdminHtml?: boolean;
}
) {
let buildtime = 0;
if (!this.app) {
const start = performance.now();
const config = options.config;
// change protocol to websocket if libsql
if (
config?.connection &&
"type" in config.connection &&
config.connection.type === "libsql"
) {
config.connection.config.protocol = "wss";
}
this.app = App.create(config);
this.app.emgr.onEvent(
App.Events.AppBuiltEvent,
async ({ params: { app } }) => {
app.modules.server.get("/__do", async (c) => {
// @ts-ignore
const context: any = c.req.raw.cf ? c.req.raw.cf : c.env.cf;
return c.json({
id: this.id,
keepAlive: options?.keepAliveSeconds,
colo: context.colo
});
});
},
"sync"
);
await this.app.build();
buildtime = performance.now() - start;
}
if (options?.keepAliveSeconds) {
this.keepAlive(options.keepAliveSeconds);
}
console.log("id", this.id);
const res = await this.app!.fetch(request);
const headers = new Headers(res.headers);
headers.set("X-BuildTime", buildtime.toString());
headers.set("X-DO-ID", this.id);
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers
});
}
protected keepAlive(seconds: number) {
console.log("keep alive for", seconds);
if (this.interval) {
console.log("clearing, there is a new");
clearInterval(this.interval);
}
let i = 0;
this.interval = setInterval(() => {
i += 1;
//console.log("keep-alive", i);
if (i === seconds) {
console.log("cleared");
clearInterval(this.interval);
// ping every 30 seconds
} else if (i % 30 === 0) {
console.log("ping");
this.app?.modules.ctx().connection.ping();
}
}, 1000);
}
}
+3
View File
@@ -1 +1,4 @@
export * from "./cloudflare-workers.adapter"; export * from "./cloudflare-workers.adapter";
export { makeApp, getFresh, getWarm } from "./modes/fresh";
export { getCached } from "./modes/cached";
export { DurableBkndApp, getDurable } from "./modes/durable";
@@ -0,0 +1,48 @@
import { createRuntimeApp } from "adapter";
import { App } from "bknd";
import type { CloudflareBkndConfig, Context } from "../index";
export async function getCached(config: CloudflareBkndConfig, { env, ctx }: Context) {
const { kv } = config.bindings?.(env)!;
if (!kv) throw new Error("kv namespace is not defined in cloudflare.bindings");
const key = config.key ?? "app";
const cachedConfig = await kv.get(key);
const initialConfig = cachedConfig ? JSON.parse(cachedConfig) : undefined;
async function saveConfig(__config: any) {
ctx.waitUntil(kv!.put(key, JSON.stringify(__config)));
}
const app = await createRuntimeApp(
{
...config,
initialConfig,
onBuilt: async (app) => {
app.module.server.client.get("/__bknd/cache", async (c) => {
await kv.delete(key);
return c.json({ message: "Cache cleared" });
});
await config.onBuilt?.(app);
},
beforeBuild: async (app) => {
app.emgr.onEvent(
App.Events.AppConfigUpdatedEvent,
async ({ params: { app } }) => {
saveConfig(app.toJSON(true));
},
"sync"
);
await config.beforeBuild?.(app);
},
adminOptions: { html: config.html }
},
env
);
if (!cachedConfig) {
saveConfig(app.toJSON(true));
}
return app;
}
+133
View File
@@ -0,0 +1,133 @@
import { DurableObject } from "cloudflare:workers";
import { createRuntimeApp } from "adapter";
import type { CloudflareBkndConfig, Context } from "adapter/cloudflare";
import type { App, CreateAppConfig } from "bknd";
export async function getDurable(config: CloudflareBkndConfig, ctx: Context) {
const { dobj } = config.bindings?.(ctx.env)!;
if (!dobj) throw new Error("durable object is not defined in cloudflare.bindings");
const key = config.key ?? "app";
if ([config.onBuilt, config.beforeBuild].some((x) => x)) {
console.log("onBuilt and beforeBuild are not supported with DurableObject mode");
}
const start = performance.now();
const id = dobj.idFromName(key);
const stub = dobj.get(id) as unknown as DurableBkndApp;
const create_config = typeof config.app === "function" ? config.app(ctx.env) : config.app;
const res = await stub.fire(ctx.request, {
config: create_config,
html: config.html,
keepAliveSeconds: config.keepAliveSeconds,
setAdminHtml: config.setAdminHtml
});
const headers = new Headers(res.headers);
headers.set("X-TTDO", String(performance.now() - start));
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers
});
}
export class DurableBkndApp extends DurableObject {
protected id = Math.random().toString(36).slice(2);
protected app?: App;
protected interval?: any;
async fire(
request: Request,
options: {
config: CreateAppConfig;
html?: string;
keepAliveSeconds?: number;
setAdminHtml?: boolean;
}
) {
let buildtime = 0;
if (!this.app) {
const start = performance.now();
const config = options.config;
// change protocol to websocket if libsql
if (
config?.connection &&
"type" in config.connection &&
config.connection.type === "libsql"
) {
config.connection.config.protocol = "wss";
}
this.app = await createRuntimeApp({
...config,
onBuilt: async (app) => {
app.modules.server.get("/__do", async (c) => {
// @ts-ignore
const context: any = c.req.raw.cf ? c.req.raw.cf : c.env.cf;
return c.json({
id: this.id,
keepAliveSeconds: options?.keepAliveSeconds ?? 0,
colo: context.colo
});
});
await this.onBuilt(app);
},
adminOptions: { html: options.html },
beforeBuild: async (app) => {
await this.beforeBuild(app);
}
});
buildtime = performance.now() - start;
}
if (options?.keepAliveSeconds) {
this.keepAlive(options.keepAliveSeconds);
}
console.log("id", this.id);
const res = await this.app!.fetch(request);
const headers = new Headers(res.headers);
headers.set("X-BuildTime", buildtime.toString());
headers.set("X-DO-ID", this.id);
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers
});
}
async onBuilt(app: App) {}
async beforeBuild(app: App) {}
protected keepAlive(seconds: number) {
console.log("keep alive for", seconds);
if (this.interval) {
console.log("clearing, there is a new");
clearInterval(this.interval);
}
let i = 0;
this.interval = setInterval(() => {
i += 1;
//console.log("keep-alive", i);
if (i === seconds) {
console.log("cleared");
clearInterval(this.interval);
// ping every 30 seconds
} else if (i % 30 === 0) {
console.log("ping");
this.app?.modules.ctx().connection.ping();
}
}, 1000);
}
}
+27
View File
@@ -0,0 +1,27 @@
import { createRuntimeApp } from "adapter";
import type { App } from "bknd";
import type { CloudflareBkndConfig, Context } from "../index";
export async function makeApp(config: CloudflareBkndConfig, { env }: Context) {
return await createRuntimeApp(
{
...config,
adminOptions: config.html ? { html: config.html } : undefined
},
env
);
}
export async function getFresh(config: CloudflareBkndConfig, ctx: Context) {
const app = await makeApp(config, ctx);
return app.fetch(ctx.request);
}
let warm_app: App;
export async function getWarm(config: CloudflareBkndConfig, ctx: Context) {
if (!warm_app) {
warm_app = await makeApp(config, ctx);
}
return warm_app.fetch(ctx.request);
}
+99 -32
View File
@@ -1,40 +1,20 @@
import type { IncomingMessage } from "node:http"; import type { IncomingMessage } from "node:http";
import type { App, CreateAppConfig } from "bknd"; import { App, type CreateAppConfig, registries } from "bknd";
import type { MiddlewareHandler } from "hono";
import { StorageLocalAdapter } from "media/storage/adapters/StorageLocalAdapter";
import type { AdminControllerOptions } from "modules/server/AdminController";
export type CfBkndModeCache<Env = any> = (env: Env) => { export type BkndConfig<Env = any> = CreateAppConfig & {
cache: KVNamespace; app?: CreateAppConfig | ((env: Env) => CreateAppConfig);
key: string;
};
export type CfBkndModeDurableObject<Env = any> = (env: Env) => {
durableObject: DurableObjectNamespace;
key: string;
keepAliveSeconds?: number;
};
export type CloudflareBkndConfig<Env = any> = {
mode?: CfBkndModeCache | CfBkndModeDurableObject;
forceHttps?: boolean;
};
// @todo: move to App
export type BkndConfig<Env = any> = {
app: CreateAppConfig | ((env: Env) => CreateAppConfig);
setAdminHtml?: boolean;
server?: {
port?: number;
platform?: "node" | "bun";
};
cloudflare?: CloudflareBkndConfig<Env>;
onBuilt?: (app: App) => Promise<void>; onBuilt?: (app: App) => Promise<void>;
beforeBuild?: (app: App) => Promise<void>;
buildConfig?: Parameters<App["build"]>[0];
}; };
export type BkndConfigJson = { export type FrameworkBkndConfig<Env = any> = BkndConfig<Env>;
app: CreateAppConfig;
setAdminHtml?: boolean; export type RuntimeBkndConfig<Env = any> = BkndConfig<Env> & {
server?: { distPath?: string;
port?: number;
};
}; };
export function nodeRequestToRequest(req: IncomingMessage): Request { export function nodeRequestToRequest(req: IncomingMessage): Request {
@@ -60,3 +40,90 @@ export function nodeRequestToRequest(req: IncomingMessage): Request {
headers headers
}); });
} }
export function registerLocalMediaAdapter() {
registries.media.register("local", StorageLocalAdapter);
}
export function makeConfig<Env = any>(config: BkndConfig<Env>, env?: Env): CreateAppConfig {
let additionalConfig: CreateAppConfig = {};
if ("app" in config && config.app) {
if (typeof config.app === "function") {
if (!env) {
throw new Error("env is required when config.app is a function");
}
additionalConfig = config.app(env);
} else {
additionalConfig = config.app;
}
}
return { ...config, ...additionalConfig };
}
export async function createFrameworkApp<Env = any>(
config: FrameworkBkndConfig,
env?: Env
): Promise<App> {
const app = App.create(makeConfig(config, env));
if (config.onBuilt) {
app.emgr.onEvent(
App.Events.AppBuiltEvent,
async () => {
await config.onBuilt?.(app);
},
"sync"
);
}
await config.beforeBuild?.(app);
await app.build(config.buildConfig);
return app;
}
export async function createRuntimeApp<Env = any>(
{
serveStatic,
registerLocalMedia,
adminOptions,
...config
}: RuntimeBkndConfig & {
serveStatic?: MiddlewareHandler | [string, MiddlewareHandler];
registerLocalMedia?: boolean;
adminOptions?: AdminControllerOptions | false;
},
env?: Env
): Promise<App> {
if (registerLocalMedia) {
registerLocalMediaAdapter();
}
const app = App.create(makeConfig(config, env));
app.emgr.onEvent(
App.Events.AppBuiltEvent,
async () => {
if (serveStatic) {
if (Array.isArray(serveStatic)) {
const [path, handler] = serveStatic;
app.modules.server.get(path, handler);
} else {
app.modules.server.get("/*", serveStatic);
}
}
await config.onBuilt?.(app);
if (adminOptions !== false) {
app.registerAdminController(adminOptions);
}
},
"sync"
);
await config.beforeBuild?.(app);
await app.build(config.buildConfig);
return app;
}
+6 -6
View File
@@ -1,6 +1,8 @@
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import { Api, App, type CreateAppConfig } from "bknd"; import { Api, type App } from "bknd";
import { nodeRequestToRequest } from "../index"; import { type FrameworkBkndConfig, createFrameworkApp, nodeRequestToRequest } from "../index";
export type NextjsBkndConfig = FrameworkBkndConfig;
type GetServerSidePropsContext = { type GetServerSidePropsContext = {
req: IncomingMessage; req: IncomingMessage;
@@ -18,7 +20,6 @@ type GetServerSidePropsContext = {
export function createApi({ req }: GetServerSidePropsContext) { export function createApi({ req }: GetServerSidePropsContext) {
const request = nodeRequestToRequest(req); const request = nodeRequestToRequest(req);
//console.log("createApi:request.headers", request.headers);
return new Api({ return new Api({
host: new URL(request.url).origin, host: new URL(request.url).origin,
headers: request.headers headers: request.headers
@@ -43,11 +44,10 @@ function getCleanRequest(req: Request) {
} }
let app: App; let app: App;
export function serve(config: CreateAppConfig) { export function serve(config: NextjsBkndConfig = {}) {
return async (req: Request) => { return async (req: Request) => {
if (!app) { if (!app) {
app = App.create(config); app = await createFrameworkApp(config);
await app.build();
} }
const request = getCleanRequest(req); const request = getCleanRequest(req);
return app.fetch(request, process.env); return app.fetch(request, process.env);
+6 -59
View File
@@ -1,59 +1,6 @@
import path from "node:path"; export * from "./node.adapter";
import { serve as honoServe } from "@hono/node-server"; export {
import { serveStatic } from "@hono/node-server/serve-static"; StorageLocalAdapter,
import { App, type CreateAppConfig } from "bknd"; type LocalAdapterConfig
} from "../../media/storage/adapters/StorageLocalAdapter";
export type NodeAdapterOptions = CreateAppConfig & { export { registerLocalMediaAdapter } from "../index";
relativeDistPath?: string;
port?: number;
hostname?: string;
listener?: Parameters<typeof honoServe>[1];
};
export function serve({
relativeDistPath,
port = 1337,
hostname,
listener,
...config
}: NodeAdapterOptions = {}) {
const root = path.relative(
process.cwd(),
path.resolve(relativeDistPath ?? "./node_modules/bknd/dist", "static")
);
let app: App;
honoServe(
{
port,
hostname,
fetch: async (req: Request) => {
if (!app) {
app = App.create(config);
app.emgr.on(
"app-built",
async () => {
app.modules.server.get(
"/*",
serveStatic({
root
})
);
app.registerAdminController();
},
"sync"
);
await app.build();
}
return app.fetch(req);
}
},
(connInfo) => {
console.log(`Server is running on http://localhost:${connInfo.port}`);
listener?.(connInfo);
}
);
}
+58
View File
@@ -0,0 +1,58 @@
import path from "node:path";
import { serve as honoServe } from "@hono/node-server";
import { serveStatic } from "@hono/node-server/serve-static";
import type { App } from "bknd";
import { config as $config } from "core";
import { type RuntimeBkndConfig, createRuntimeApp } from "../index";
export type NodeBkndConfig = RuntimeBkndConfig & {
port?: number;
hostname?: string;
listener?: Parameters<typeof honoServe>[1];
/** @deprecated */
relativeDistPath?: string;
};
export function serve({
distPath,
relativeDistPath,
port = $config.server.default_port,
hostname,
listener,
onBuilt,
buildConfig = {},
beforeBuild,
...config
}: NodeBkndConfig = {}) {
const root = path.relative(
process.cwd(),
path.resolve(distPath ?? relativeDistPath ?? "./node_modules/bknd/dist", "static")
);
if (relativeDistPath) {
console.warn("relativeDistPath is deprecated, please use distPath instead");
}
let app: App;
honoServe(
{
port,
hostname,
fetch: async (req: Request) => {
if (!app) {
app = await createRuntimeApp({
...config,
registerLocalMedia: true,
serveStatic: serveStatic({ root })
});
}
return app.fetch(req);
}
},
(connInfo) => {
console.log(`Server is running on http://localhost:${connInfo.port}`);
listener?.(connInfo);
}
);
}
+6 -4
View File
@@ -1,11 +1,13 @@
import { App, type CreateAppConfig } from "bknd"; import { type FrameworkBkndConfig, createFrameworkApp } from "adapter";
import type { App } from "bknd";
export type RemixBkndConfig = FrameworkBkndConfig;
let app: App; let app: App;
export function serve(config: CreateAppConfig) { export function serve(config: RemixBkndConfig = {}) {
return async (args: { request: Request }) => { return async (args: { request: Request }) => {
if (!app) { if (!app) {
app = App.create(config); app = await createFrameworkApp(config);
await app.build();
} }
return app.fetch(args.request); return app.fetch(args.request);
}; };
+37 -27
View File
@@ -1,47 +1,57 @@
import { serveStatic } from "@hono/node-server/serve-static"; import { serveStatic } from "@hono/node-server/serve-static";
import type { BkndConfig } from "bknd"; import { type RuntimeBkndConfig, createRuntimeApp } from "adapter";
import { App } from "bknd"; import type { App } from "bknd";
function createApp(config: BkndConfig, env: any) { export type ViteBkndConfig<Env = any> = RuntimeBkndConfig<Env> & {
const create_config = typeof config.app === "function" ? config.app(env) : config.app; setAdminHtml?: boolean;
return App.create(create_config); forceDev?: boolean;
} html?: string;
};
function setAppBuildListener(app: App, config: BkndConfig, html?: string) { export function addViteScript(html: string, addBkndContext: boolean = true) {
app.emgr.on( return html.replace(
"app-built", "</head>",
async () => { `<script type="module">
await config.onBuilt?.(app); import RefreshRuntime from "/@react-refresh"
if (config.setAdminHtml) { RefreshRuntime.injectIntoGlobalHook(window)
app.registerAdminController({ html, forceDev: true }); window.$RefreshReg$ = () => {}
app.module.server.client.get("/assets/*", serveStatic({ root: "./" })); window.$RefreshSig$ = () => (type) => type
} window.__vite_plugin_react_preamble_installed__ = true
}, </script>
"sync" <script type="module" src="/@vite/client"></script>
${addBkndContext ? "<!-- BKND_CONTEXT -->" : ""}
</head>`
); );
} }
export async function serveFresh(config: BkndConfig, _html?: string) { async function createApp(config: ViteBkndConfig, env?: any) {
return await createRuntimeApp(
{
...config,
adminOptions: config.setAdminHtml
? { html: config.html, forceDev: config.forceDev }
: undefined,
serveStatic: ["/assets/*", serveStatic({ root: config.distPath ?? "./" })]
},
env
);
}
export async function serveFresh(config: ViteBkndConfig) {
return { return {
async fetch(request: Request, env: any, ctx: ExecutionContext) { async fetch(request: Request, env: any, ctx: ExecutionContext) {
const app = createApp(config, env); const app = await createApp(config, env);
setAppBuildListener(app, config, _html);
await app.build();
return app.fetch(request, env, ctx); return app.fetch(request, env, ctx);
} }
}; };
} }
let app: App; let app: App;
export async function serveCached(config: BkndConfig, _html?: string) { export async function serveCached(config: ViteBkndConfig) {
return { return {
async fetch(request: Request, env: any, ctx: ExecutionContext) { async fetch(request: Request, env: any, ctx: ExecutionContext) {
if (!app) { if (!app) {
app = createApp(config, env); app = await createApp(config, env);
setAppBuildListener(app, config, _html);
await app.build();
} }
return app.fetch(request, env, ctx); return app.fetch(request, env, ctx);
+29 -6
View File
@@ -1,5 +1,6 @@
import { type AuthAction, Authenticator, type ProfileExchange, Role, type Strategy } from "auth"; import { type AuthAction, Authenticator, type ProfileExchange, Role, type Strategy } from "auth";
import { Exception } from "core"; import type { PasswordStrategy } from "auth/authenticate/strategies";
import { Exception, type PrimaryFieldType } from "core";
import { type Static, secureRandomString, transformObject } from "core/utils"; import { type Static, secureRandomString, transformObject } from "core/utils";
import { type Entity, EntityIndex, type EntityManager } from "data"; import { type Entity, EntityIndex, type EntityManager } from "data";
import { type FieldSchema, entity, enumm, make, text } from "data/prototype"; import { type FieldSchema, entity, enumm, make, text } from "data/prototype";
@@ -9,9 +10,9 @@ import { AuthController } from "./api/AuthController";
import { type AppAuthSchema, STRATEGIES, authConfigSchema } from "./auth-schema"; import { type AppAuthSchema, STRATEGIES, authConfigSchema } from "./auth-schema";
export type UserFieldSchema = FieldSchema<typeof AppAuth.usersFields>; export type UserFieldSchema = FieldSchema<typeof AppAuth.usersFields>;
declare global { declare module "core" {
interface DB { interface DB {
users: UserFieldSchema; users: { id: PrimaryFieldType } & UserFieldSchema;
} }
} }
@@ -100,7 +101,7 @@ export class AppAuth extends Module<typeof authConfigSchema> {
return this._authenticator!; return this._authenticator!;
} }
get em(): EntityManager<DB> { get em(): EntityManager {
return this.ctx.em as any; return this.ctx.em as any;
} }
@@ -160,7 +161,9 @@ export class AppAuth extends Module<typeof authConfigSchema> {
const users = this.getUsersEntity(); const users = this.getUsersEntity();
this.toggleStrategyValueVisibility(true); this.toggleStrategyValueVisibility(true);
const result = await this.em.repo(users).findOne({ email: profile.email! }); const result = await this.em
.repo(users as unknown as "users")
.findOne({ email: profile.email! });
this.toggleStrategyValueVisibility(false); this.toggleStrategyValueVisibility(false);
if (!result.data) { if (!result.data) {
throw new Exception("User not found", 404); throw new Exception("User not found", 404);
@@ -197,7 +200,7 @@ export class AppAuth extends Module<typeof authConfigSchema> {
throw new Exception("User already exists"); throw new Exception("User already exists");
} }
const payload = { const payload: any = {
...profile, ...profile,
strategy: strategy.getName(), strategy: strategy.getName(),
strategy_value: identifier strategy_value: identifier
@@ -284,6 +287,26 @@ export class AppAuth extends Module<typeof authConfigSchema> {
} catch (e) {} } catch (e) {}
} }
async createUser({
email,
password,
...additional
}: { email: string; password: string; [key: string]: any }) {
const strategy = "password";
const pw = this.authenticator.strategy(strategy) as PasswordStrategy;
const strategy_value = await pw.hash(password);
const mutator = this.em.mutator(this.config.entity_name as "users");
mutator.__unstable_toggleSystemEntityCreation(false);
const { data: created } = await mutator.insertOne({
...(additional as any),
email,
strategy,
strategy_value
});
mutator.__unstable_toggleSystemEntityCreation(true);
return created;
}
override toJSON(secrets?: boolean): AppAuthSchema { override toJSON(secrets?: boolean): AppAuthSchema {
if (!this.config.enabled) { if (!this.config.enabled) {
return this.configDefault; return this.configDefault;
+4 -4
View File
@@ -15,7 +15,7 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
async loginWithPassword(input: any) { async loginWithPassword(input: any) {
const res = await this.post<AuthResponse>(["password", "login"], input); const res = await this.post<AuthResponse>(["password", "login"], input);
if (res.res.ok && res.body.token) { if (res.ok && res.body.token) {
await this.options.onTokenUpdate?.(res.body.token); await this.options.onTokenUpdate?.(res.body.token);
} }
return res; return res;
@@ -23,17 +23,17 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
async registerWithPassword(input: any) { async registerWithPassword(input: any) {
const res = await this.post<AuthResponse>(["password", "register"], input); const res = await this.post<AuthResponse>(["password", "register"], input);
if (res.res.ok && res.body.token) { if (res.ok && res.body.token) {
await this.options.onTokenUpdate?.(res.body.token); await this.options.onTokenUpdate?.(res.body.token);
} }
return res; return res;
} }
async me() { me() {
return this.get<{ user: SafeUser | null }>(["me"]); return this.get<{ user: SafeUser | null }>(["me"]);
} }
async strategies() { strategies() {
return this.get<Pick<AppAuthSchema, "strategies" | "basepath">>(["strategies"]); return this.get<Pick<AppAuthSchema, "strategies" | "basepath">>(["strategies"]);
} }
@@ -220,6 +220,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
} }
private async getAuthCookie(c: Context): Promise<string | undefined> { private async getAuthCookie(c: Context): Promise<string | undefined> {
try {
const secret = this.config.jwt.secret; const secret = this.config.jwt.secret;
const token = await getSignedCookie(c, secret, "auth"); const token = await getSignedCookie(c, secret, "auth");
@@ -229,6 +230,13 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
} }
return token; return token;
} catch (e: any) {
if (e instanceof Error) {
console.error("[Error:getAuthCookie]", e.message);
}
return undefined;
}
} }
async requestCookieRefresh(c: Context) { async requestCookieRefresh(c: Context) {
+20 -13
View File
@@ -1,8 +1,10 @@
import type { Config } from "@libsql/client/node"; import type { Config } from "@libsql/client/node";
import { App, type CreateAppConfig } from "App"; import { App, type CreateAppConfig } from "App";
import type { BkndConfig } from "adapter"; import { StorageLocalAdapter } from "adapter/node";
import type { CliCommand } from "cli/types"; import type { CliBkndConfig, CliCommand } from "cli/types";
import { Option } from "commander"; import { Option } from "commander";
import { config } from "core";
import { registries } from "modules/registries";
import { import {
PLATFORMS, PLATFORMS,
type Platform, type Platform,
@@ -19,7 +21,7 @@ export const run: CliCommand = (program) => {
.addOption( .addOption(
new Option("-p, --port <port>", "port to run on") new Option("-p, --port <port>", "port to run on")
.env("PORT") .env("PORT")
.default(1337) .default(config.server.default_port)
.argParser((v) => Number.parseInt(v)) .argParser((v) => Number.parseInt(v))
) )
.addOption(new Option("-c, --config <config>", "config file")) .addOption(new Option("-c, --config <config>", "config file"))
@@ -37,6 +39,12 @@ export const run: CliCommand = (program) => {
.action(action); .action(action);
}; };
// automatically register local adapter
const local = StorageLocalAdapter.prototype.getName();
if (!registries.media.has(local)) {
registries.media.register(local, StorageLocalAdapter);
}
type MakeAppConfig = { type MakeAppConfig = {
connection?: CreateAppConfig["connection"]; connection?: CreateAppConfig["connection"];
server?: { platform?: Platform }; server?: { platform?: Platform };
@@ -47,8 +55,8 @@ type MakeAppConfig = {
async function makeApp(config: MakeAppConfig) { async function makeApp(config: MakeAppConfig) {
const app = App.create({ connection: config.connection }); const app = App.create({ connection: config.connection });
app.emgr.on( app.emgr.onEvent(
"app-built", App.Events.AppBuiltEvent,
async () => { async () => {
await attachServeStatic(app, config.server?.platform ?? "node"); await attachServeStatic(app, config.server?.platform ?? "node");
app.registerAdminController(); app.registerAdminController();
@@ -64,24 +72,23 @@ async function makeApp(config: MakeAppConfig) {
return app; return app;
} }
export async function makeConfigApp(config: BkndConfig, platform?: Platform) { export async function makeConfigApp(config: CliBkndConfig, platform?: Platform) {
const appConfig = typeof config.app === "function" ? config.app(process.env) : config.app; const appConfig = typeof config.app === "function" ? config.app(process.env) : config.app;
const app = App.create(appConfig); const app = App.create(appConfig);
app.emgr.on( app.emgr.onEvent(
"app-built", App.Events.AppBuiltEvent,
async () => { async () => {
await attachServeStatic(app, platform ?? "node"); await attachServeStatic(app, platform ?? "node");
app.registerAdminController(); app.registerAdminController();
if (config.onBuilt) { await config.onBuilt?.(app);
await config.onBuilt(app);
}
}, },
"sync" "sync"
); );
await app.build(); await config.beforeBuild?.(app);
await app.build(config.buildConfig);
return app; return app;
} }
@@ -102,7 +109,7 @@ async function action(options: {
app = await makeApp({ connection, server: { platform: options.server } }); app = await makeApp({ connection, server: { platform: options.server } });
} else { } else {
console.log("Using config from:", configFilePath); console.log("Using config from:", configFilePath);
const config = (await import(configFilePath).then((m) => m.default)) as BkndConfig; const config = (await import(configFilePath).then((m) => m.default)) as CliBkndConfig;
app = await makeConfigApp(config, options.server); app = await makeConfigApp(config, options.server);
} }
+5 -5
View File
@@ -1,9 +1,9 @@
import { password as $password, text as $text } from "@clack/prompts"; import { password as $password, text as $text } from "@clack/prompts";
import type { App } from "App";
import type { PasswordStrategy } from "auth/authenticate/strategies"; import type { PasswordStrategy } from "auth/authenticate/strategies";
import type { App, BkndConfig } from "bknd";
import { makeConfigApp } from "cli/commands/run"; import { makeConfigApp } from "cli/commands/run";
import { getConfigPath } from "cli/commands/run/platform"; import { getConfigPath } from "cli/commands/run/platform";
import type { CliCommand } from "cli/types"; import type { CliBkndConfig, CliCommand } from "cli/types";
import { Argument } from "commander"; import { Argument } from "commander";
export const user: CliCommand = (program) => { export const user: CliCommand = (program) => {
@@ -21,7 +21,7 @@ async function action(action: "create" | "update", options: any) {
return; return;
} }
const config = (await import(configFilePath).then((m) => m.default)) as BkndConfig; const config = (await import(configFilePath).then((m) => m.default)) as CliBkndConfig;
const app = await makeConfigApp(config, options.server); const app = await makeConfigApp(config, options.server);
switch (action) { switch (action) {
@@ -37,7 +37,7 @@ async function action(action: "create" | "update", options: any) {
async function create(app: App, options: any) { async function create(app: App, options: any) {
const config = app.module.auth.toJSON(true); const config = app.module.auth.toJSON(true);
const strategy = app.module.auth.authenticator.strategy("password") as PasswordStrategy; const strategy = app.module.auth.authenticator.strategy("password") as PasswordStrategy;
const users_entity = config.entity_name; const users_entity = config.entity_name as "users";
const email = await $text({ const email = await $text({
message: "Enter email", message: "Enter email",
@@ -83,7 +83,7 @@ async function create(app: App, options: any) {
async function update(app: App, options: any) { async function update(app: App, options: any) {
const config = app.module.auth.toJSON(true); const config = app.module.auth.toJSON(true);
const strategy = app.module.auth.authenticator.strategy("password") as PasswordStrategy; const strategy = app.module.auth.authenticator.strategy("password") as PasswordStrategy;
const users_entity = config.entity_name; const users_entity = config.entity_name as "users";
const em = app.modules.ctx().em; const em = app.modules.ctx().em;
const email = (await $text({ const email = (await $text({
+11
View File
@@ -1,3 +1,14 @@
import type { CreateAppConfig } from "App";
import type { FrameworkBkndConfig } from "adapter";
import type { Command } from "commander"; import type { Command } from "commander";
export type CliCommand = (program: Command) => void; export type CliCommand = (program: Command) => void;
export type CliBkndConfig<Env = any> = FrameworkBkndConfig & {
app: CreateAppConfig | ((env: Env) => CreateAppConfig);
setAdminHtml?: boolean;
server?: {
port?: number;
platform?: "node" | "bun";
};
};
+6
View File
@@ -5,7 +5,13 @@ import type { Generated } from "kysely";
export type PrimaryFieldType = number | Generated<number>; export type PrimaryFieldType = number | Generated<number>;
// biome-ignore lint/suspicious/noEmptyInterface: <explanation>
export interface DB {}
export const config = { export const config = {
server: {
default_port: 1337
},
data: { data: {
default_primary_field: "id" default_primary_field: "id"
} }
+20
View File
@@ -15,6 +15,7 @@ export class EventManager<
> { > {
protected events: EventClass[] = []; protected events: EventClass[] = [];
protected listeners: EventListener[] = []; protected listeners: EventListener[] = [];
enabled: boolean = true;
constructor(events?: RegisteredEvents, listeners?: EventListener[]) { constructor(events?: RegisteredEvents, listeners?: EventListener[]) {
if (events) { if (events) {
@@ -28,6 +29,16 @@ export class EventManager<
} }
} }
enable() {
this.enabled = true;
return this;
}
disable() {
this.enabled = false;
return this;
}
clearEvents() { clearEvents() {
this.events = []; this.events = [];
return this; return this;
@@ -39,6 +50,10 @@ export class EventManager<
return this; return this;
} }
getListeners(): EventListener[] {
return [...this.listeners];
}
get Events(): { [K in keyof RegisteredEvents]: RegisteredEvents[K] } { get Events(): { [K in keyof RegisteredEvents]: RegisteredEvents[K] } {
// proxy class to access events // proxy class to access events
return new Proxy(this, { return new Proxy(this, {
@@ -133,6 +148,11 @@ export class EventManager<
async emit(event: Event) { async emit(event: Event) {
// @ts-expect-error slug is static // @ts-expect-error slug is static
const slug = event.constructor.slug; const slug = event.constructor.slug;
if (!this.enabled) {
console.log("EventManager disabled, not emitting", slug);
return;
}
if (!this.eventExists(event)) { if (!this.eventExists(event)) {
throw new Error(`Event "${slug}" not registered`); throw new Error(`Event "${slug}" not registered`);
} }
+1 -1
View File
@@ -3,7 +3,7 @@ import type { Hono, MiddlewareHandler } from "hono";
export { tbValidator } from "./server/lib/tbValidator"; export { tbValidator } from "./server/lib/tbValidator";
export { Exception, BkndError } from "./errors"; export { Exception, BkndError } from "./errors";
export { isDebug } from "./env"; export { isDebug } from "./env";
export { type PrimaryFieldType, config } from "./config"; export { type PrimaryFieldType, config, type DB } from "./config";
export { AwsClient } from "./clients/aws/AwsClient"; export { AwsClient } from "./clients/aws/AwsClient";
export { export {
SimpleRenderer, SimpleRenderer,
+2 -1
View File
@@ -69,7 +69,8 @@ export class SchemaObject<Schema extends TObject> {
forceParse: true, forceParse: true,
skipMark: this.isForceParse() skipMark: this.isForceParse()
}); });
const updatedConfig = noEmit ? valid : await this.onBeforeUpdate(this._config, valid); // regardless of "noEmit" this should always be triggered
const updatedConfig = await this.onBeforeUpdate(this._config, valid);
this._value = updatedConfig; this._value = updatedConfig;
this._config = Object.freeze(updatedConfig); this._config = Object.freeze(updatedConfig);
+28 -7
View File
@@ -1,29 +1,50 @@
export type Constructor<T> = new (...args: any[]) => T; export type Constructor<T> = new (...args: any[]) => T;
export class Registry<Item, Items extends Record<string, object> = Record<string, object>> {
export type RegisterFn<Item> = (unknown: any) => Item;
export class Registry<
Item,
Items extends Record<string, Item> = Record<string, Item>,
Fn extends RegisterFn<Item> = RegisterFn<Item>
> {
private is_set: boolean = false; private is_set: boolean = false;
private items: Items = {} as Items; private items: Items = {} as Items;
set<Actual extends Record<string, object>>(items: Actual) { constructor(private registerFn?: Fn) {}
set<Actual extends Record<string, Item>>(items: Actual) {
if (this.is_set) { if (this.is_set) {
throw new Error("Registry is already set"); throw new Error("Registry is already set");
} }
// @ts-ignore this.items = items as unknown as Items;
this.items = items;
this.is_set = true; this.is_set = true;
return this as unknown as Registry<Item, Actual>; return this as unknown as Registry<Item, Actual, Fn>;
} }
add(name: string, item: Item) { add(name: string, item: Item) {
// @ts-ignore this.items[name as keyof Items] = item as Items[keyof Items];
this.items[name] = item;
return this; return this;
} }
register(name: string, specific: Parameters<Fn>[0]) {
if (this.registerFn) {
const item = this.registerFn(specific);
this.items[name as keyof Items] = item as Items[keyof Items];
return this;
}
return this.add(name, specific);
}
get<Name extends keyof Items>(name: Name): Items[Name] { get<Name extends keyof Items>(name: Name): Items[Name] {
return this.items[name]; return this.items[name];
} }
has(name: keyof Items): boolean {
return name in this.items;
}
all() { all() {
return this.items; return this.items;
} }
+6 -1
View File
@@ -20,11 +20,16 @@ export class DebugLogger {
return this; return this;
} }
reset() {
this.last = 0;
return this;
}
log(...args: any[]) { log(...args: any[]) {
if (!this._enabled) return this; if (!this._enabled) return this;
const now = performance.now(); const now = performance.now();
const time = Number.parseInt(String(now - this.last)); const time = this.last === 0 ? 0 : Number.parseInt(String(now - this.last));
const indents = " ".repeat(this._context.length); const indents = " ".repeat(this._context.length);
const context = const context =
this._context.length > 0 ? `[${this._context[this._context.length - 1]}]` : ""; this._context.length > 0 ? `[${this._context[this._context.length - 1]}]` : "";
+16 -1
View File
@@ -9,10 +9,25 @@ export async function withDisabledConsole<R>(
fn: () => Promise<R>, fn: () => Promise<R>,
severities: ConsoleSeverity[] = ["log"] severities: ConsoleSeverity[] = ["log"]
): Promise<R> { ): Promise<R> {
const enable = disableConsoleLog(severities); const _oldConsoles = {
log: console.log,
warn: console.warn,
error: console.error
};
disableConsoleLog(severities);
const enable = () => {
Object.entries(_oldConsoles).forEach(([severity, fn]) => {
console[severity as ConsoleSeverity] = fn;
});
};
try {
const result = await fn(); const result = await fn();
enable(); enable();
return result; return result;
} catch (e) {
enable();
throw e;
}
} }
export function disableConsoleLog(severities: ConsoleSeverity[] = ["log"]) { export function disableConsoleLog(severities: ConsoleSeverity[] = ["log"]) {
+13 -45
View File
@@ -1,52 +1,20 @@
import { transformObject } from "core/utils"; import { transformObject } from "core/utils";
import { DataPermissions, Entity, EntityIndex, type EntityManager, type Field } from "data"; import {
DataPermissions,
type Entity,
EntityIndex,
type EntityManager,
constructEntity,
constructRelation
} from "data";
import { Module } from "modules/Module"; import { Module } from "modules/Module";
import { DataController } from "./api/DataController"; import { DataController } from "./api/DataController";
import { import { type AppDataConfig, dataConfigSchema } from "./data-schema";
type AppDataConfig,
FIELDS,
RELATIONS,
type TAppDataEntity,
type TAppDataRelation,
dataConfigSchema
} from "./data-schema";
export class AppData<DB> extends Module<typeof dataConfigSchema> {
static constructEntity(name: string, entityConfig: TAppDataEntity) {
const fields = transformObject(entityConfig.fields ?? {}, (fieldConfig, name) => {
const { type } = fieldConfig;
if (!(type in FIELDS)) {
throw new Error(`Field type "${type}" not found`);
}
const { field } = FIELDS[type as any];
const returnal = new field(name, fieldConfig.config) as Field;
return returnal;
});
// @todo: entity must be migrated to typebox
return new Entity(
name,
Object.values(fields),
entityConfig.config as any,
entityConfig.type as any
);
}
static constructRelation(
relationConfig: TAppDataRelation,
resolver: (name: Entity | string) => Entity
) {
return new RELATIONS[relationConfig.type].cls(
resolver(relationConfig.source),
resolver(relationConfig.target),
relationConfig.config
);
}
export class AppData extends Module<typeof dataConfigSchema> {
override async build() { override async build() {
const entities = transformObject(this.config.entities ?? {}, (entityConfig, name) => { const entities = transformObject(this.config.entities ?? {}, (entityConfig, name) => {
return AppData.constructEntity(name, entityConfig); return constructEntity(name, entityConfig);
}); });
const _entity = (_e: Entity | string): Entity => { const _entity = (_e: Entity | string): Entity => {
@@ -57,7 +25,7 @@ export class AppData<DB> extends Module<typeof dataConfigSchema> {
}; };
const relations = transformObject(this.config.relations ?? {}, (relation) => const relations = transformObject(this.config.relations ?? {}, (relation) =>
AppData.constructRelation(relation, _entity) constructRelation(relation, _entity)
); );
const indices = transformObject(this.config.indices ?? {}, (index, name) => { const indices = transformObject(this.config.indices ?? {}, (index, name) => {
@@ -91,7 +59,7 @@ export class AppData<DB> extends Module<typeof dataConfigSchema> {
return dataConfigSchema; return dataConfigSchema;
} }
get em(): EntityManager<DB> { get em(): EntityManager {
this.throwIfNotBuilt(); this.throwIfNotBuilt();
return this.ctx.em; return this.ctx.em;
} }
+38 -25
View File
@@ -1,3 +1,4 @@
import type { DB } from "core";
import type { EntityData, RepoQuery, RepositoryResponse } from "data"; import type { EntityData, RepoQuery, RepositoryResponse } from "data";
import { type BaseModuleApiOptions, ModuleApi, type PrimaryFieldType } from "modules"; import { type BaseModuleApiOptions, ModuleApi, type PrimaryFieldType } from "modules";
@@ -15,48 +16,60 @@ export class DataApi extends ModuleApi<DataApiOptions> {
}; };
} }
async readOne( readOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: string, entity: E,
id: PrimaryFieldType, id: PrimaryFieldType,
query: Partial<Omit<RepoQuery, "where" | "limit" | "offset">> = {} query: Partial<Omit<RepoQuery, "where" | "limit" | "offset">> = {}
) { ) {
return this.get<RepositoryResponse<EntityData>>([entity, id], query); return this.get<Pick<RepositoryResponse<Data>, "meta" | "data">>([entity as any, id], query);
} }
async readMany(entity: string, query: Partial<RepoQuery> = {}) { readMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
return this.get<Pick<RepositoryResponse, "meta" | "data">>( entity: E,
[entity],
query ?? this.options.defaultQuery
);
}
async readManyByReference(
entity: string,
id: PrimaryFieldType,
reference: string,
query: Partial<RepoQuery> = {} query: Partial<RepoQuery> = {}
) { ) {
return this.get<Pick<RepositoryResponse, "meta" | "data">>( return this.get<Pick<RepositoryResponse<Data[]>, "meta" | "data">>(
[entity, id, reference], [entity as any],
query ?? this.options.defaultQuery query ?? this.options.defaultQuery
); );
} }
async createOne(entity: string, input: EntityData) { readManyByReference<
return this.post<RepositoryResponse<EntityData>>([entity], input); E extends keyof DB | string,
R extends keyof DB | string,
Data = R extends keyof DB ? DB[R] : EntityData
>(entity: E, id: PrimaryFieldType, reference: R, query: Partial<RepoQuery> = {}) {
return this.get<Pick<RepositoryResponse<Data[]>, "meta" | "data">>(
[entity as any, id, reference],
query ?? this.options.defaultQuery
);
} }
async updateOne(entity: string, id: PrimaryFieldType, input: EntityData) { createOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
return this.patch<RepositoryResponse<EntityData>>([entity, id], input); entity: E,
input: Omit<Data, "id">
) {
return this.post<RepositoryResponse<Data>>([entity as any], input);
} }
async deleteOne(entity: string, id: PrimaryFieldType) { updateOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
return this.delete<RepositoryResponse<EntityData>>([entity, id]); entity: E,
id: PrimaryFieldType,
input: Partial<Omit<Data, "id">>
) {
return this.patch<RepositoryResponse<Data>>([entity as any, id], input);
} }
async count(entity: string, where: RepoQuery["where"] = {}) { deleteOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
return this.post<RepositoryResponse<{ entity: string; count: number }>>( entity: E,
[entity, "fn", "count"], id: PrimaryFieldType
) {
return this.delete<RepositoryResponse<Data>>([entity as any, id]);
}
count<E extends keyof DB | string>(entity: E, where: RepoQuery["where"] = {}) {
return this.post<RepositoryResponse<{ entity: E; count: number }>>(
[entity as any, "fn", "count"],
where where
); );
} }
+14 -9
View File
@@ -1,5 +1,5 @@
import { type ClassController, isDebug, tbValidator as tb } from "core"; import { type ClassController, isDebug, tbValidator as tb } from "core";
import { Type, objectCleanEmpty, objectTransform } from "core/utils"; import { StringEnum, Type, objectCleanEmpty, objectTransform } from "core/utils";
import { import {
DataPermissions, DataPermissions,
type EntityData, type EntityData,
@@ -165,13 +165,12 @@ export class DataController implements ClassController {
// read entity schema // read entity schema
.get("/schema.json", async (c) => { .get("/schema.json", async (c) => {
this.guard.throwUnlessGranted(DataPermissions.entityRead); this.guard.throwUnlessGranted(DataPermissions.entityRead);
const url = new URL(c.req.url); const $id = `${this.config.basepath}/schema.json`;
const $id = `${url.origin}${this.config.basepath}/schema.json`;
const schemas = Object.fromEntries( const schemas = Object.fromEntries(
this.em.entities.map((e) => [ this.em.entities.map((e) => [
e.name, e.name,
{ {
$ref: `schemas/${e.name}` $ref: `${this.config.basepath}/schemas/${e.name}`
} }
]) ])
); );
@@ -183,22 +182,28 @@ export class DataController implements ClassController {
}) })
// read schema // read schema
.get( .get(
"/schemas/:entity", "/schemas/:entity/:context?",
tb("param", Type.Object({ entity: Type.String() })), tb(
"param",
Type.Object({
entity: Type.String(),
context: Type.Optional(StringEnum(["create", "update"]))
})
),
async (c) => { async (c) => {
this.guard.throwUnlessGranted(DataPermissions.entityRead); this.guard.throwUnlessGranted(DataPermissions.entityRead);
//console.log("request", c.req.raw); //console.log("request", c.req.raw);
const { entity } = c.req.param(); const { entity, context } = c.req.param();
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
console.log("not found", entity, definedEntities); console.log("not found", entity, definedEntities);
return c.notFound(); return c.notFound();
} }
const _entity = this.em.entity(entity); const _entity = this.em.entity(entity);
const schema = _entity.toSchema(); const schema = _entity.toSchema({ context } as any);
const url = new URL(c.req.url); const url = new URL(c.req.url);
const base = `${url.origin}${this.config.basepath}`; const base = `${url.origin}${this.config.basepath}`;
const $id = `${base}/schemas/${entity}`; const $id = `${this.config.basepath}/schemas/${entity}`;
return c.json({ return c.json({
$schema: `${base}/schema.json`, $schema: `${base}/schema.json`,
$id, $id,
@@ -0,0 +1,7 @@
import { Connection } from "./Connection";
export class DummyConnection extends Connection {
constructor() {
super(undefined as any);
}
}
+22 -8
View File
@@ -158,7 +158,7 @@ export class Entity<
} }
get label(): string { get label(): string {
return snakeToPascalWithSpaces(this.config.name ?? this.name); return this.config.name ?? snakeToPascalWithSpaces(this.name);
} }
field(name: string): Field | undefined { field(name: string): Field | undefined {
@@ -210,20 +210,34 @@ export class Entity<
return true; return true;
} }
toSchema(clean?: boolean): object { toSchema(options?: { clean: boolean; context?: "create" | "update" }): object {
const fields = Object.fromEntries(this.fields.map((field) => [field.name, field])); let fields: Field[];
switch (options?.context) {
case "create":
case "update":
fields = this.getFillableFields(options.context);
break;
default:
fields = this.getFields(true);
}
const _fields = Object.fromEntries(fields.map((field) => [field.name, field]));
const schema = Type.Object( const schema = Type.Object(
transformObject(fields, (field) => ({ transformObject(_fields, (field) => {
//const hidden = field.isHidden(options?.context);
const fillable = field.isFillable(options?.context);
return {
title: field.config.label, title: field.config.label,
$comment: field.config.description, $comment: field.config.description,
$field: field.type, $field: field.type,
readOnly: !field.isFillable("update") ? true : undefined, readOnly: !fillable ? true : undefined,
writeOnly: !field.isFillable("create") ? true : undefined,
...field.toJsonSchema() ...field.toJsonSchema()
})) };
}),
{ additionalProperties: false }
); );
return clean ? JSON.parse(JSON.stringify(schema)) : schema; return options?.clean ? JSON.parse(JSON.stringify(schema)) : schema;
} }
toJSON() { toJSON() {
+32 -23
View File
@@ -1,3 +1,4 @@
import type { DB as DefaultDB } from "core";
import { EventManager } from "core/events"; import { EventManager } from "core/events";
import { sql } from "kysely"; import { sql } from "kysely";
import { Connection } from "../connection/Connection"; import { Connection } from "../connection/Connection";
@@ -14,7 +15,18 @@ import { SchemaManager } from "../schema/SchemaManager";
import { Entity } from "./Entity"; import { Entity } from "./Entity";
import { type EntityData, Mutator, Repository } from "./index"; import { type EntityData, Mutator, Repository } from "./index";
export class EntityManager<DB> { type EntitySchema<
TBD extends object = DefaultDB,
E extends Entity | keyof TBD | string = string
> = E extends Entity<infer Name>
? Name extends keyof TBD
? Name
: never
: E extends keyof TBD
? E
: never;
export class EntityManager<TBD extends object = DefaultDB> {
connection: Connection; connection: Connection;
private _entities: Entity[] = []; private _entities: Entity[] = [];
@@ -50,7 +62,7 @@ export class EntityManager<DB> {
* Forks the EntityManager without the EventManager. * Forks the EntityManager without the EventManager.
* This is useful when used inside an event handler. * This is useful when used inside an event handler.
*/ */
fork(): EntityManager<DB> { fork(): EntityManager {
return new EntityManager(this._entities, this.connection, this._relations, this._indices); return new EntityManager(this._entities, this.connection, this._relations, this._indices);
} }
@@ -87,10 +99,17 @@ export class EntityManager<DB> {
this.entities.push(entity); this.entities.push(entity);
} }
entity(name: string): Entity { entity(e: Entity | keyof TBD | string): Entity {
const entity = this.entities.find((e) => e.name === name); let entity: Entity | undefined;
if (typeof e === "string") {
entity = this.entities.find((entity) => entity.name === e);
} else if (e instanceof Entity) {
entity = e;
}
if (!entity) { if (!entity) {
throw new EntityNotDefinedException(name); // @ts-ignore
throw new EntityNotDefinedException(e instanceof Entity ? e.name : e);
} }
return entity; return entity;
@@ -162,28 +181,18 @@ export class EntityManager<DB> {
return this.relations.relationReferencesOf(this.entity(entity_name)); return this.relations.relationReferencesOf(this.entity(entity_name));
} }
repository(_entity: Entity | string) { repository<E extends Entity | keyof TBD | string>(
const entity = _entity instanceof Entity ? _entity : this.entity(_entity); entity: E
return new Repository(this, entity, this.emgr); ): Repository<TBD, EntitySchema<TBD, E>> {
return this.repo(entity);
} }
repo<E extends Entity>( repo<E extends Entity | keyof TBD | string>(entity: E): Repository<TBD, EntitySchema<TBD, E>> {
_entity: E return new Repository(this, this.entity(entity), this.emgr);
): Repository<
DB,
E extends Entity<infer Name> ? (Name extends keyof DB ? Name : never) : never
> {
return new Repository(this, _entity, this.emgr);
} }
_repo<TB extends keyof DB>(_entity: TB): Repository<DB, TB> { mutator<E extends Entity | keyof TBD | string>(entity: E): Mutator<TBD, EntitySchema<TBD, E>> {
const entity = this.entity(_entity as any); return new Mutator(this, this.entity(entity), this.emgr);
return new Repository(this, entity, this.emgr);
}
mutator(_entity: Entity | string) {
const entity = _entity instanceof Entity ? _entity : this.entity(_entity);
return new Mutator(this, entity, this.emgr);
} }
addIndex(index: EntityIndex, force = false) { addIndex(index: EntityIndex, force = false) {
+64 -39
View File
@@ -1,4 +1,4 @@
import type { PrimaryFieldType } from "core"; import type { DB as DefaultDB, PrimaryFieldType } from "core";
import { type EmitsEvents, EventManager } from "core/events"; import { type EmitsEvents, EventManager } from "core/events";
import type { DeleteQueryBuilder, InsertQueryBuilder, UpdateQueryBuilder } from "kysely"; import type { DeleteQueryBuilder, InsertQueryBuilder, UpdateQueryBuilder } from "kysely";
import { type TActionContext, WhereBuilder } from ".."; import { type TActionContext, WhereBuilder } from "..";
@@ -25,8 +25,14 @@ export type MutatorResponse<T = EntityData[]> = {
data: T; data: T;
}; };
export class Mutator<DB> implements EmitsEvents { export class Mutator<
em: EntityManager<DB>; TBD extends object = DefaultDB,
TB extends keyof TBD = any,
Output = TBD[TB],
Input = Omit<Output, "id">
> implements EmitsEvents
{
em: EntityManager<TBD>;
entity: Entity; entity: Entity;
static readonly Events = MutatorEvents; static readonly Events = MutatorEvents;
emgr: EventManager<typeof MutatorEvents>; emgr: EventManager<typeof MutatorEvents>;
@@ -37,7 +43,7 @@ export class Mutator<DB> implements EmitsEvents {
this.__unstable_disable_system_entity_creation = value; this.__unstable_disable_system_entity_creation = value;
} }
constructor(em: EntityManager<DB>, entity: Entity, emgr?: EventManager<any>) { constructor(em: EntityManager<TBD>, entity: Entity, emgr?: EventManager<any>) {
this.em = em; this.em = em;
this.entity = entity; this.entity = entity;
this.emgr = emgr ?? new EventManager(MutatorEvents); this.emgr = emgr ?? new EventManager(MutatorEvents);
@@ -47,13 +53,13 @@ export class Mutator<DB> implements EmitsEvents {
return this.em.connection.kysely; return this.em.connection.kysely;
} }
async getValidatedData(data: EntityData, context: TActionContext): Promise<EntityData> { async getValidatedData<Given = any>(data: Given, context: TActionContext): Promise<Given> {
const entity = this.entity; const entity = this.entity;
if (!context) { if (!context) {
throw new Error("Context must be provided for validation"); throw new Error("Context must be provided for validation");
} }
const keys = Object.keys(data); const keys = Object.keys(data as any);
const validatedData: EntityData = {}; const validatedData: EntityData = {};
// get relational references/keys // get relational references/keys
@@ -95,7 +101,7 @@ export class Mutator<DB> implements EmitsEvents {
throw new Error(`No data left to update "${entity.name}"`); throw new Error(`No data left to update "${entity.name}"`);
} }
return validatedData; return validatedData as Given;
} }
protected async many(qb: MutatorQB): Promise<MutatorResponse> { protected async many(qb: MutatorQB): Promise<MutatorResponse> {
@@ -120,7 +126,7 @@ export class Mutator<DB> implements EmitsEvents {
return { ...response, data: data[0]! }; return { ...response, data: data[0]! };
} }
async insertOne(data: EntityData): Promise<MutatorResponse<EntityData>> { async insertOne(data: Input): Promise<MutatorResponse<Output>> {
const entity = this.entity; const entity = this.entity;
if (entity.type === "system" && this.__unstable_disable_system_entity_creation) { if (entity.type === "system" && this.__unstable_disable_system_entity_creation) {
throw new Error(`Creation of system entity "${entity.name}" is disabled`); throw new Error(`Creation of system entity "${entity.name}" is disabled`);
@@ -154,10 +160,10 @@ export class Mutator<DB> implements EmitsEvents {
await this.emgr.emit(new Mutator.Events.MutatorInsertAfter({ entity, data: res.data })); await this.emgr.emit(new Mutator.Events.MutatorInsertAfter({ entity, data: res.data }));
return res; return res as any;
} }
async updateOne(id: PrimaryFieldType, data: EntityData): Promise<MutatorResponse<EntityData>> { async updateOne(id: PrimaryFieldType, data: Partial<Input>): Promise<MutatorResponse<Output>> {
const entity = this.entity; const entity = this.entity;
if (!Number.isInteger(id)) { if (!Number.isInteger(id)) {
throw new Error("ID must be provided for update"); throw new Error("ID must be provided for update");
@@ -166,12 +172,16 @@ export class Mutator<DB> implements EmitsEvents {
const validatedData = await this.getValidatedData(data, "update"); const validatedData = await this.getValidatedData(data, "update");
await this.emgr.emit( await this.emgr.emit(
new Mutator.Events.MutatorUpdateBefore({ entity, entityId: id, data: validatedData }) new Mutator.Events.MutatorUpdateBefore({
entity,
entityId: id,
data: validatedData as any
})
); );
const query = this.conn const query = this.conn
.updateTable(entity.name) .updateTable(entity.name)
.set(validatedData) .set(validatedData as any)
.where(entity.id().name, "=", id) .where(entity.id().name, "=", id)
.returning(entity.getSelect()); .returning(entity.getSelect());
@@ -181,10 +191,10 @@ export class Mutator<DB> implements EmitsEvents {
new Mutator.Events.MutatorUpdateAfter({ entity, entityId: id, data: res.data }) new Mutator.Events.MutatorUpdateAfter({ entity, entityId: id, data: res.data })
); );
return res; return res as any;
} }
async deleteOne(id: PrimaryFieldType): Promise<MutatorResponse<EntityData>> { async deleteOne(id: PrimaryFieldType): Promise<MutatorResponse<Output>> {
const entity = this.entity; const entity = this.entity;
if (!Number.isInteger(id)) { if (!Number.isInteger(id)) {
throw new Error("ID must be provided for deletion"); throw new Error("ID must be provided for deletion");
@@ -203,7 +213,7 @@ export class Mutator<DB> implements EmitsEvents {
new Mutator.Events.MutatorDeleteAfter({ entity, entityId: id, data: res.data }) new Mutator.Events.MutatorDeleteAfter({ entity, entityId: id, data: res.data })
); );
return res; return res as any;
} }
private getValidOptions(options?: Partial<RepoQuery>): Partial<RepoQuery> { private getValidOptions(options?: Partial<RepoQuery>): Partial<RepoQuery> {
@@ -250,47 +260,62 @@ export class Mutator<DB> implements EmitsEvents {
} }
// @todo: decide whether entries should be deleted all at once or one by one (for events) // @todo: decide whether entries should be deleted all at once or one by one (for events)
async deleteWhere(where?: RepoQuery["where"]): Promise<MutatorResponse<EntityData>> { async deleteWhere(where?: RepoQuery["where"]): Promise<MutatorResponse<Output[]>> {
const entity = this.entity; const entity = this.entity;
const qb = this.appendWhere(this.conn.deleteFrom(entity.name), where).returning( const qb = this.appendWhere(this.conn.deleteFrom(entity.name), where).returning(
entity.getSelect() entity.getSelect()
); );
//await this.emgr.emit(new Mutator.Events.MutatorDeleteBefore({ entity, entityId: id })); return (await this.many(qb)) as any;
const res = await this.many(qb);
/*await this.emgr.emit(
new Mutator.Events.MutatorDeleteAfter({ entity, entityId: id, data: res.data })
);*/
return res;
} }
async updateWhere( async updateWhere(
data: EntityData, data: Partial<Input>,
where?: RepoQuery["where"] where?: RepoQuery["where"]
): Promise<MutatorResponse<EntityData>> { ): Promise<MutatorResponse<Output[]>> {
const entity = this.entity; const entity = this.entity;
const validatedData = await this.getValidatedData(data, "update"); const validatedData = await this.getValidatedData(data, "update");
/*await this.emgr.emit(
new Mutator.Events.MutatorUpdateBefore({ entity, entityId: id, data: validatedData })
);*/
const query = this.appendWhere(this.conn.updateTable(entity.name), where) const query = this.appendWhere(this.conn.updateTable(entity.name), where)
.set(validatedData) .set(validatedData as any)
//.where(entity.id().name, "=", id)
.returning(entity.getSelect()); .returning(entity.getSelect());
const res = await this.many(query); return (await this.many(query)) as any;
}
/*await this.emgr.emit( async insertMany(data: Input[]): Promise<MutatorResponse<Output[]>> {
new Mutator.Events.MutatorUpdateAfter({ entity, entityId: id, data: res.data }) const entity = this.entity;
);*/ if (entity.type === "system" && this.__unstable_disable_system_entity_creation) {
throw new Error(`Creation of system entity "${entity.name}" is disabled`);
}
return res; const validated: any[] = [];
for (const row of data) {
const validatedData = {
...entity.getDefaultObject(),
...(await this.getValidatedData(row, "create"))
};
// check if required fields are present
const required = entity.getRequiredFields();
for (const field of required) {
if (
typeof validatedData[field.name] === "undefined" ||
validatedData[field.name] === null
) {
throw new Error(`Field "${field.name}" is required`);
}
}
validated.push(validatedData);
}
const query = this.conn
.insertInto(entity.name)
.values(validated)
.returning(entity.getSelect());
return (await this.many(query)) as any;
} }
} }
+9 -7
View File
@@ -1,4 +1,4 @@
import type { PrimaryFieldType } from "core"; import type { DB as DefaultDB, PrimaryFieldType } from "core";
import { type EmitsEvents, EventManager } from "core/events"; import { type EmitsEvents, EventManager } from "core/events";
import { type SelectQueryBuilder, sql } from "kysely"; import { type SelectQueryBuilder, sql } from "kysely";
import { cloneDeep } from "lodash-es"; import { cloneDeep } from "lodash-es";
@@ -43,13 +43,15 @@ export type RepositoryExistsResponse = RepositoryRawResponse & {
exists: boolean; exists: boolean;
}; };
export class Repository<DB = any, TB extends keyof DB = any> implements EmitsEvents { export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = any>
em: EntityManager<DB>; implements EmitsEvents
{
em: EntityManager<TBD>;
entity: Entity; entity: Entity;
static readonly Events = RepositoryEvents; static readonly Events = RepositoryEvents;
emgr: EventManager<typeof Repository.Events>; emgr: EventManager<typeof Repository.Events>;
constructor(em: EntityManager<DB>, entity: Entity, emgr?: EventManager<any>) { constructor(em: EntityManager<TBD>, entity: Entity, emgr?: EventManager<any>) {
this.em = em; this.em = em;
this.entity = entity; this.entity = entity;
this.emgr = emgr ?? new EventManager(MutatorEvents); this.emgr = emgr ?? new EventManager(MutatorEvents);
@@ -272,7 +274,7 @@ export class Repository<DB = any, TB extends keyof DB = any> implements EmitsEve
async findId( async findId(
id: PrimaryFieldType, id: PrimaryFieldType,
_options?: Partial<Omit<RepoQuery, "where" | "limit" | "offset">> _options?: Partial<Omit<RepoQuery, "where" | "limit" | "offset">>
): Promise<RepositoryResponse<DB[TB]>> { ): Promise<RepositoryResponse<TBD[TB] | undefined>> {
const { qb, options } = this.buildQuery( const { qb, options } = this.buildQuery(
{ {
..._options, ..._options,
@@ -288,7 +290,7 @@ export class Repository<DB = any, TB extends keyof DB = any> implements EmitsEve
async findOne( async findOne(
where: RepoQuery["where"], where: RepoQuery["where"],
_options?: Partial<Omit<RepoQuery, "where" | "limit" | "offset">> _options?: Partial<Omit<RepoQuery, "where" | "limit" | "offset">>
): Promise<RepositoryResponse<DB[TB] | undefined>> { ): Promise<RepositoryResponse<TBD[TB] | undefined>> {
const { qb, options } = this.buildQuery({ const { qb, options } = this.buildQuery({
..._options, ..._options,
where, where,
@@ -298,7 +300,7 @@ export class Repository<DB = any, TB extends keyof DB = any> implements EmitsEve
return this.single(qb, options) as any; return this.single(qb, options) as any;
} }
async findMany(_options?: Partial<RepoQuery>): Promise<RepositoryResponse<DB[TB][]>> { async findMany(_options?: Partial<RepoQuery>): Promise<RepositoryResponse<TBD[TB][]>> {
const { qb, options } = this.buildQuery(_options); const { qb, options } = this.buildQuery(_options);
//console.log("findMany:options", options); //console.log("findMany:options", options);
+6
View File
@@ -104,6 +104,12 @@ export class TextField<Required extends true | false = false> extends Field<
); );
} }
if (this.config.pattern && value && !new RegExp(this.config.pattern).test(value)) {
throw new TransformPersistFailedException(
`Field "${this.name}" must match the pattern ${this.config.pattern}`
);
}
return value; return value;
} }
+5 -3
View File
@@ -18,6 +18,7 @@ export function getChangeSet(
data: EntityData, data: EntityData,
fields: Field[] fields: Field[]
): EntityData { ): EntityData {
//console.log("getChangeSet", formData, data);
return transform( return transform(
formData, formData,
(acc, _value, key) => { (acc, _value, key) => {
@@ -26,11 +27,12 @@ export function getChangeSet(
if (!field || field.isVirtual()) return; if (!field || field.isVirtual()) return;
const value = _value === "" ? null : _value; const value = _value === "" ? null : _value;
const newValue = field.getValue(value, "submit"); // normalize to null if undefined
const newValue = field.getValue(value, "submit") || null;
// @todo: add typing for "action" // @todo: add typing for "action"
if (action === "create" || newValue !== data[key]) { if (action === "create" || newValue !== data[key]) {
acc[key] = newValue; acc[key] = newValue;
console.log("changed", { /*console.log("changed", {
key, key,
value, value,
valueType: typeof value, valueType: typeof value,
@@ -38,7 +40,7 @@ export function getChangeSet(
newValue, newValue,
new: value, new: value,
sent: acc[key] sent: acc[key]
}); });*/
} else { } else {
//console.log("no change", key, value, data[key]); //console.log("no change", key, value, data[key]);
} }
+2
View File
@@ -18,6 +18,8 @@ export { LibsqlConnection, type LibSqlCredentials } from "./connection/LibsqlCon
export { SqliteConnection } from "./connection/SqliteConnection"; export { SqliteConnection } from "./connection/SqliteConnection";
export { SqliteLocalConnection } from "./connection/SqliteLocalConnection"; export { SqliteLocalConnection } from "./connection/SqliteLocalConnection";
export { constructEntity, constructRelation } from "./schema/constructor";
export const DatabaseEvents = { export const DatabaseEvents = {
...MutatorEvents, ...MutatorEvents,
...RepositoryEvents ...RepositoryEvents
+98 -6
View File
@@ -1,3 +1,8 @@
import { DummyConnection } from "data/connection/DummyConnection";
import { EntityManager } from "data/entities/EntityManager";
import type { Generated } from "kysely";
import { MediaField, type MediaFieldConfig, type MediaItem } from "media/MediaField";
import type { ModuleConfigs } from "modules";
import { import {
BooleanField, BooleanField,
type BooleanFieldConfig, type BooleanFieldConfig,
@@ -5,6 +10,8 @@ import {
type DateFieldConfig, type DateFieldConfig,
Entity, Entity,
type EntityConfig, type EntityConfig,
EntityIndex,
type EntityRelation,
EnumField, EnumField,
type EnumFieldConfig, type EnumFieldConfig,
type Field, type Field,
@@ -25,15 +32,14 @@ import {
type TEntityType, type TEntityType,
TextField, TextField,
type TextFieldConfig type TextFieldConfig
} from "data"; } from "../index";
import type { Generated } from "kysely";
import { MediaField, type MediaFieldConfig, type MediaItem } from "media/MediaField";
type Options<Config = any> = { type Options<Config = any> = {
entity: { name: string; fields: Record<string, Field<any, any, any>> }; entity: { name: string; fields: Record<string, Field<any, any, any>> };
field_name: string; field_name: string;
config: Config; config: Config;
is_required: boolean; is_required: boolean;
another?: string;
}; };
const FieldMap = { const FieldMap = {
@@ -239,7 +245,89 @@ export function relation<Local extends Entity>(local: Local) {
}; };
} }
type InferEntityFields<T> = T extends Entity<infer _N, infer Fields> export function index<E extends Entity>(entity: E) {
return {
on: (fields: (keyof InsertSchema<E>)[], unique?: boolean) => {
const _fields = fields.map((f) => {
const field = entity.field(f as any);
if (!field) {
throw new Error(`Field "${String(f)}" not found on entity "${entity.name}"`);
}
return field;
});
return new EntityIndex(entity, _fields, unique);
}
};
}
class EntityManagerPrototype<Entities extends Record<string, Entity>> extends EntityManager<
Schema<Entities>
> {
constructor(
public __entities: Entities,
relations: EntityRelation[] = [],
indices: EntityIndex[] = []
) {
super(Object.values(__entities), new DummyConnection(), relations, indices);
}
}
type Chained<Fn extends (...args: any[]) => any, Rt = ReturnType<Fn>> = <E extends Entity>(
e: E
) => {
[K in keyof Rt]: Rt[K] extends (...args: any[]) => any
? (...args: Parameters<Rt[K]>) => Rt
: never;
};
export function em<Entities extends Record<string, Entity>>(
entities: Entities,
schema?: (
fns: { relation: Chained<typeof relation>; index: Chained<typeof index> },
entities: Entities
) => void
) {
const relations: EntityRelation[] = [];
const indices: EntityIndex[] = [];
const relationProxy = (e: Entity) => {
return new Proxy(relation(e), {
get(target, prop) {
return (...args: any[]) => {
relations.push(target[prop](...args));
return relationProxy(e);
};
}
}) as any;
};
const indexProxy = (e: Entity) => {
return new Proxy(index(e), {
get(target, prop) {
return (...args: any[]) => {
indices.push(target[prop](...args));
return indexProxy(e);
};
}
}) as any;
};
if (schema) {
schema({ relation: relationProxy, index: indexProxy }, entities);
}
const e = new EntityManagerPrototype(entities, relations, indices);
return {
DB: e.__entities as unknown as Schemas<Entities>,
entities: e.__entities,
relations,
indices,
toJSON: () =>
e.toJSON() as unknown as Pick<ModuleConfigs["data"], "entities" | "relations" | "indices">
};
}
export type InferEntityFields<T> = T extends Entity<infer _N, infer Fields>
? { ? {
[K in keyof Fields]: Fields[K] extends { _type: infer Type; _required: infer Required } [K in keyof Fields]: Fields[K] extends { _type: infer Type; _required: infer Required }
? Required extends true ? Required extends true
@@ -284,12 +372,16 @@ type OptionalUndefined<
} }
>; >;
type InferField<Field> = Field extends { _type: infer Type; _required: infer Required } export type InferField<Field> = Field extends { _type: infer Type; _required: infer Required }
? Required extends true ? Required extends true
? Type ? Type
: Type | undefined : Type | undefined
: never; : never;
export type Schemas<T extends Record<string, Entity>> = {
[K in keyof T]: Schema<T[K]>;
};
export type InsertSchema<T> = Simplify<OptionalUndefined<InferEntityFields<T>>>; export type InsertSchema<T> = Simplify<OptionalUndefined<InferEntityFields<T>>>;
export type Schema<T> = { id: Generated<number> } & InsertSchema<T>; export type Schema<T> = Simplify<{ id: Generated<number> } & InsertSchema<T>>;
export type FieldSchema<T> = Simplify<OptionalUndefined<InferFields<T>>>; export type FieldSchema<T> = Simplify<OptionalUndefined<InferFields<T>>>;
+34
View File
@@ -0,0 +1,34 @@
import { transformObject } from "core/utils";
import { Entity, type Field } from "data";
import { FIELDS, RELATIONS, type TAppDataEntity, type TAppDataRelation } from "data/data-schema";
export function constructEntity(name: string, entityConfig: TAppDataEntity) {
const fields = transformObject(entityConfig.fields ?? {}, (fieldConfig, name) => {
const { type } = fieldConfig;
if (!(type in FIELDS)) {
throw new Error(`Field type "${type}" not found`);
}
const { field } = FIELDS[type as any];
const returnal = new field(name, fieldConfig.config) as Field;
return returnal;
});
return new Entity(
name,
Object.values(fields),
entityConfig.config as any,
entityConfig.type as any
);
}
export function constructRelation(
relationConfig: TAppDataRelation,
resolver: (name: Entity | string) => Entity
) {
return new RELATIONS[relationConfig.type].cls(
resolver(relationConfig.source),
resolver(relationConfig.target),
relationConfig.config
);
}
+1 -1
View File
@@ -72,6 +72,6 @@ export const querySchema = Type.Object(
} }
); );
export type RepoQueryIn = Simplify<Static<typeof querySchema>>; export type RepoQueryIn = Static<typeof querySchema>;
export type RepoQuery = Required<StaticDecode<typeof querySchema>>; export type RepoQuery = Required<StaticDecode<typeof querySchema>>;
export const defaultQuerySchema = Value.Default(querySchema, {}) as RepoQuery; export const defaultQuerySchema = Value.Default(querySchema, {}) as RepoQuery;
+6 -2
View File
@@ -4,8 +4,12 @@ export {
getDefaultConfig, getDefaultConfig,
getDefaultSchema, getDefaultSchema,
type ModuleConfigs, type ModuleConfigs,
type ModuleSchemas type ModuleSchemas,
} from "modules/ModuleManager"; type ModuleManagerOptions,
type ModuleBuildContext
} from "./modules/ModuleManager";
export { registries } from "modules/registries";
export type * from "./adapter"; export type * from "./adapter";
export { Api, type ApiOptions } from "./Api"; export { Api, type ApiOptions } from "./Api";
+10 -19
View File
@@ -1,24 +1,15 @@
import type { PrimaryFieldType } from "core";
import { EntityIndex, type EntityManager } from "data"; import { EntityIndex, type EntityManager } from "data";
import { type FileUploadedEventData, Storage, type StorageAdapter } from "media"; import { type FileUploadedEventData, Storage, type StorageAdapter } from "media";
import { Module } from "modules/Module"; import { Module } from "modules/Module";
import { import { type FieldSchema, boolean, datetime, entity, json, number, text } from "../data/prototype";
type FieldSchema,
type InferFields,
type Schema,
boolean,
datetime,
entity,
json,
number,
text
} from "../data/prototype";
import { MediaController } from "./api/MediaController"; import { MediaController } from "./api/MediaController";
import { ADAPTERS, buildMediaSchema, type mediaConfigSchema, registry } from "./media-schema"; import { ADAPTERS, buildMediaSchema, type mediaConfigSchema, registry } from "./media-schema";
export type MediaFieldSchema = FieldSchema<typeof AppMedia.mediaFields>; export type MediaFieldSchema = FieldSchema<typeof AppMedia.mediaFields>;
declare global { declare module "core" {
interface DB { interface DB {
media: MediaFieldSchema; media: { id: PrimaryFieldType } & MediaFieldSchema;
} }
} }
@@ -112,14 +103,14 @@ export class AppMedia extends Module<typeof mediaConfigSchema> {
return this.em.entity(entity_name); return this.em.entity(entity_name);
} }
get em(): EntityManager<DB> { get em(): EntityManager {
return this.ctx.em; return this.ctx.em;
} }
private setupListeners() { private setupListeners() {
//const media = this._entity; //const media = this._entity;
const { emgr, em } = this.ctx; const { emgr, em } = this.ctx;
const media = this.getMediaEntity(); const media = this.getMediaEntity().name as "media";
// when file is uploaded, sync with media entity // when file is uploaded, sync with media entity
// @todo: need a way for singleton events! // @todo: need a way for singleton events!
@@ -140,10 +131,10 @@ export class AppMedia extends Module<typeof mediaConfigSchema> {
Storage.Events.FileDeletedEvent, Storage.Events.FileDeletedEvent,
async (e) => { async (e) => {
// simple file deletion sync // simple file deletion sync
const item = await em.repo(media).findOne({ path: e.params.name }); const { data } = await em.repo(media).findOne({ path: e.params.name });
if (item.data) { if (data) {
console.log("item.data", item.data); console.log("item.data", data);
await em.mutator(media).deleteOne(item.data.id); await em.mutator(media).deleteOne(data.id);
} }
console.log("App:storage:file deleted", e); console.log("App:storage:file deleted", e);
+4 -4
View File
@@ -10,11 +10,11 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
}; };
} }
async getFiles() { getFiles() {
return this.get(["files"]); return this.get(["files"]);
} }
async getFile(filename: string) { getFile(filename: string) {
return this.get(["file", filename]); return this.get(["file", filename]);
} }
@@ -32,13 +32,13 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
}); });
} }
async uploadFile(file: File) { uploadFile(file: File) {
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
return this.post(["upload"], formData); return this.post(["upload"], formData);
} }
async deleteFile(filename: string) { deleteFile(filename: string) {
return this.delete(["file", filename]); return this.delete(["file", filename]);
} }
} }
+1 -1
View File
@@ -174,7 +174,7 @@ export class MediaController implements ClassController {
const result = await mutator.insertOne({ const result = await mutator.insertOne({
...this.media.uploadedEventDataToMediaPayload(info), ...this.media.uploadedEventDataToMediaPayload(info),
...mediaRef ...mediaRef
}); } as any);
mutator.__unstable_toggleSystemEntityCreation(true); mutator.__unstable_toggleSystemEntityCreation(true);
// delete items if needed // delete items if needed
+6 -14
View File
@@ -17,10 +17,6 @@ import {
import { type S3AdapterConfig, StorageS3Adapter } from "./storage/adapters/StorageS3Adapter"; import { type S3AdapterConfig, StorageS3Adapter } from "./storage/adapters/StorageS3Adapter";
export { StorageS3Adapter, type S3AdapterConfig, StorageCloudinaryAdapter, type CloudinaryConfig }; export { StorageS3Adapter, type S3AdapterConfig, StorageCloudinaryAdapter, type CloudinaryConfig };
/*export {
StorageLocalAdapter,
type LocalAdapterConfig
} from "./storage/adapters/StorageLocalAdapter";*/
export * as StorageEvents from "./storage/events"; export * as StorageEvents from "./storage/events";
export { type FileUploadedEventData } from "./storage/events"; export { type FileUploadedEventData } from "./storage/events";
@@ -31,16 +27,12 @@ type ClassThatImplements<T> = Constructor<T> & { prototype: T };
export const MediaAdapterRegistry = new Registry<{ export const MediaAdapterRegistry = new Registry<{
cls: ClassThatImplements<StorageAdapter>; cls: ClassThatImplements<StorageAdapter>;
schema: TObject; schema: TObject;
}>().set({ }>((cls: ClassThatImplements<StorageAdapter>) => ({
s3: { cls,
cls: StorageS3Adapter, schema: cls.prototype.getSchema() as TObject
schema: StorageS3Adapter.prototype.getSchema() }))
}, .register("s3", StorageS3Adapter)
cloudinary: { .register("cloudinary", StorageCloudinaryAdapter);
cls: StorageCloudinaryAdapter,
schema: StorageCloudinaryAdapter.prototype.getSchema()
}
});
export const Adapters = { export const Adapters = {
s3: { s3: {
@@ -1,17 +1,11 @@
import { readFile, readdir, stat, unlink, writeFile } from "node:fs/promises"; import { readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
import { type Static, Type, parse } from "core/utils"; import { type Static, Type, parse } from "core/utils";
import type { import type { FileBody, FileListObject, FileMeta, StorageAdapter } from "../../Storage";
FileBody, import { guess } from "../../mime-types-tiny";
FileListObject,
FileMeta,
FileUploadPayload,
StorageAdapter
} from "../../Storage";
import { guessMimeType } from "../../mime-types";
export const localAdapterConfig = Type.Object( export const localAdapterConfig = Type.Object(
{ {
path: Type.String() path: Type.String({ default: "./" })
}, },
{ title: "Local" } { title: "Local" }
); );
@@ -89,7 +83,7 @@ export class StorageLocalAdapter implements StorageAdapter {
async getObject(key: string, headers: Headers): Promise<Response> { async getObject(key: string, headers: Headers): Promise<Response> {
try { try {
const content = await readFile(`${this.config.path}/${key}`); const content = await readFile(`${this.config.path}/${key}`);
const mimeType = guessMimeType(key); const mimeType = guess(key);
return new Response(content, { return new Response(content, {
status: 200, status: 200,
@@ -111,7 +105,7 @@ export class StorageLocalAdapter implements StorageAdapter {
async getObjectMeta(key: string): Promise<FileMeta> { async getObjectMeta(key: string): Promise<FileMeta> {
const stats = await stat(`${this.config.path}/${key}`); const stats = await stat(`${this.config.path}/${key}`);
return { return {
type: guessMimeType(key) || "application/octet-stream", type: guess(key) || "application/octet-stream",
size: stats.size size: stats.size
}; };
} }
+77
View File
@@ -0,0 +1,77 @@
export const Q = {
video: ["mp4", "webm"],
audio: ["ogg"],
image: ["jpeg", "png", "gif", "webp", "bmp", "tiff"],
text: ["html", "css", "mdx", "yaml", "vcard", "csv", "vtt"],
application: ["zip", "xml", "toml", "json", "json5"],
font: ["woff", "woff2", "ttf", "otf"]
} as const;
// reduced
const c = {
vnd: "vnd.openxmlformats-officedocument",
z: "application/x-7z-compressed",
t: (w = "plain") => `text/${w}`,
a: (w = "octet-stream") => `application/${w}`,
i: (w) => `image/${w}`,
v: (w) => `video/${w}`
} as const;
export const M = new Map<string, string>([
["7z", c.z],
["7zip", c.z],
["ai", c.a("pdf")],
["apk", c.a("vnd.android.package-archive")],
["doc", c.a("msword")],
["docx", `${c.vnd}.wordprocessingml.document`],
["eps", c.a("postscript")],
["epub", c.a("epub+zip")],
["ini", c.t()],
["jar", c.a("java-archive")],
["jsonld", c.a("ld+json")],
["jpg", c.i("jpeg")],
["log", c.t()],
["m3u", c.t()],
["m3u8", c.a("vnd.apple.mpegurl")],
["manifest", c.t("cache-manifest")],
["md", c.t("markdown")],
["mkv", c.v("x-matroska")],
["mp3", c.a("mpeg")],
["mobi", c.a("x-mobipocket-ebook")],
["ppt", c.a("powerpoint")],
["pptx", `${c.vnd}.presentationml.presentation`],
["qt", c.v("quicktime")],
["svg", c.i("svg+xml")],
["tif", c.i("tiff")],
["tsv", c.t("tab-separated-values")],
["tgz", c.a("x-tar")],
["txt", c.t()],
["text", c.t()],
["vcd", c.a("x-cdlink")],
["vcs", c.t("x-vcalendar")],
["wav", c.a("x-wav")],
["webmanifest", c.a("manifest+json")],
["xls", c.a("vnd.ms-excel")],
["xlsx", `${c.vnd}.spreadsheetml.sheet`],
["yml", c.t("yaml")]
]);
export function guess(f: string): string {
try {
const e = f.split(".").pop() as string;
if (!e) {
return c.a();
}
// try quick first
for (const [t, _e] of Object.entries(Q)) {
// @ts-ignore
if (_e.includes(e)) {
return `${t}/${e}`;
}
}
return M.get(e!) as string;
} catch (e) {
return c.a();
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ import type { Hono } from "hono";
export type ModuleBuildContext = { export type ModuleBuildContext = {
connection: Connection; connection: Connection;
server: Hono<any>; server: Hono<any>;
em: EntityManager<any>; em: EntityManager;
emgr: EventManager<any>; emgr: EventManager<any>;
guard: Guard; guard: Guard;
}; };
+166 -62
View File
@@ -1,4 +1,4 @@
import type { PrimaryFieldType } from "core"; import { type PrimaryFieldType, isDebug } from "core";
import { encodeSearch } from "core/utils"; import { encodeSearch } from "core/utils";
export type { PrimaryFieldType }; export type { PrimaryFieldType };
@@ -10,6 +10,7 @@ export type BaseModuleApiOptions = {
token_transport?: "header" | "cookie" | "none"; token_transport?: "header" | "cookie" | "none";
}; };
/** @deprecated */
export type ApiResponse<Data = any> = { export type ApiResponse<Data = any> = {
success: boolean; success: boolean;
status: number; status: number;
@@ -18,7 +19,11 @@ export type ApiResponse<Data = any> = {
res: Response; res: Response;
}; };
export abstract class ModuleApi<Options extends BaseModuleApiOptions> { export type TInput = string | (string | number | PrimaryFieldType)[];
export abstract class ModuleApi<Options extends BaseModuleApiOptions = BaseModuleApiOptions> {
protected fetcher?: typeof fetch;
constructor(protected readonly _options: Partial<Options> = {}) {} constructor(protected readonly _options: Partial<Options> = {}) {}
protected getDefaultOptions(): Partial<Options> { protected getDefaultOptions(): Partial<Options> {
@@ -35,14 +40,15 @@ export abstract class ModuleApi<Options extends BaseModuleApiOptions> {
} }
protected getUrl(path: string) { protected getUrl(path: string) {
return this.options.host + (this.options.basepath + "/" + path).replace(/\/\//g, "/"); const basepath = this.options.basepath ?? "";
return this.options.host + (basepath + "/" + path).replace(/\/{2,}/g, "/").replace(/\/$/, "");
} }
protected async request<Data = any>( protected request<Data = any>(
_input: string | (string | number | PrimaryFieldType)[], _input: TInput,
_query?: Record<string, any> | URLSearchParams, _query?: Record<string, any> | URLSearchParams,
_init?: RequestInit _init?: RequestInit
): Promise<ApiResponse<Data>> { ): FetchPromise<ResponseObject<Data>> {
const method = _init?.method ?? "GET"; const method = _init?.method ?? "GET";
const input = Array.isArray(_input) ? _input.join("/") : _input; const input = Array.isArray(_input) ? _input.join("/") : _input;
let url = this.getUrl(input); let url = this.getUrl(input);
@@ -78,14 +84,130 @@ export abstract class ModuleApi<Options extends BaseModuleApiOptions> {
} }
} }
//console.log("url", url); const request = new Request(url, {
const res = await fetch(url, {
..._init, ..._init,
method, method,
body, body,
headers headers
}); });
return new FetchPromise(request, {
fetcher: this.fetcher
});
}
get<Data = any>(
_input: TInput,
_query?: Record<string, any> | URLSearchParams,
_init?: RequestInit
) {
return this.request<Data>(_input, _query, {
..._init,
method: "GET"
});
}
post<Data = any>(_input: TInput, body?: any, _init?: RequestInit) {
return this.request<Data>(_input, undefined, {
..._init,
body,
method: "POST"
});
}
patch<Data = any>(_input: TInput, body?: any, _init?: RequestInit) {
return this.request<Data>(_input, undefined, {
..._init,
body,
method: "PATCH"
});
}
put<Data = any>(_input: TInput, body?: any, _init?: RequestInit) {
return this.request<Data>(_input, undefined, {
..._init,
body,
method: "PUT"
});
}
delete<Data = any>(_input: TInput, _init?: RequestInit) {
return this.request<Data>(_input, undefined, {
..._init,
method: "DELETE"
});
}
}
export type ResponseObject<Body = any, Data = Body extends { data: infer R } ? R : Body> = Data & {
raw: Response;
res: Response;
data: Data;
body: Body;
ok: boolean;
status: number;
toJSON(): Data;
};
export function createResponseProxy<Body = any, Data = any>(
raw: Response,
body: Body,
data?: Data
): ResponseObject<Body, Data> {
const actualData = data ?? (body as unknown as Data);
const _props = ["raw", "body", "ok", "status", "res", "data", "toJSON"];
return new Proxy(actualData as any, {
get(target, prop, receiver) {
if (prop === "raw" || prop === "res") return raw;
if (prop === "body") return body;
if (prop === "data") return data;
if (prop === "ok") return raw.ok;
if (prop === "status") return raw.status;
if (prop === "toJSON") {
return () => target;
}
return Reflect.get(target, prop, receiver);
},
has(target, prop) {
if (_props.includes(prop as string)) {
return true;
}
return Reflect.has(target, prop);
},
ownKeys(target) {
return Array.from(new Set([...Reflect.ownKeys(target), ..._props]));
},
getOwnPropertyDescriptor(target, prop) {
if (_props.includes(prop as string)) {
return {
configurable: true,
enumerable: true,
value: Reflect.get({ raw, body, ok: raw.ok, status: raw.status }, prop)
};
}
return Reflect.getOwnPropertyDescriptor(target, prop);
}
}) as ResponseObject<Body, Data>;
}
export class FetchPromise<T = ApiResponse<any>> implements Promise<T> {
// @ts-ignore
[Symbol.toStringTag]: "FetchPromise";
constructor(
public request: Request,
protected options?: {
fetcher?: typeof fetch;
}
) {}
async execute(): Promise<ResponseObject<T>> {
// delay in dev environment
isDebug() && (await new Promise((resolve) => setTimeout(resolve, 200)));
const fetcher = this.options?.fetcher ?? fetch;
const res = await fetcher(this.request);
let resBody: any; let resBody: any;
let resData: any; let resData: any;
@@ -99,69 +221,51 @@ export abstract class ModuleApi<Options extends BaseModuleApiOptions> {
resBody = await res.text(); resBody = await res.text();
} }
return { return createResponseProxy<T>(res, resBody, resData);
success: res.ok,
status: res.status,
body: resBody,
data: resData,
res
};
} }
protected async get<Data = any>( // biome-ignore lint/suspicious/noThenProperty: it's a promise :)
_input: string | (string | number | PrimaryFieldType)[], then<TResult1 = T, TResult2 = never>(
_query?: Record<string, any> | URLSearchParams, onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null | undefined,
_init?: RequestInit onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null | undefined
) { ): Promise<TResult1 | TResult2> {
return this.request<Data>(_input, _query, { return this.execute().then(onfulfilled as any, onrejected);
..._init,
method: "GET"
});
} }
protected async post<Data = any>( catch<TResult = never>(
_input: string | (string | number | PrimaryFieldType)[], onrejected?: ((reason: any) => TResult | PromiseLike<TResult>) | null | undefined
body?: any, ): Promise<T | TResult> {
_init?: RequestInit return this.then(undefined, onrejected);
) {
return this.request<Data>(_input, undefined, {
..._init,
body,
method: "POST"
});
} }
protected async patch<Data = any>( finally(onfinally?: (() => void) | null | undefined): Promise<T> {
_input: string | (string | number | PrimaryFieldType)[], return this.then(
body?: any, (value) => {
_init?: RequestInit onfinally?.();
) { return value;
return this.request<Data>(_input, undefined, { },
..._init, (reason) => {
body, onfinally?.();
method: "PATCH" throw reason;
}); }
);
} }
protected async put<Data = any>( path(): string {
_input: string | (string | number | PrimaryFieldType)[], const url = new URL(this.request.url);
body?: any, return url.pathname;
_init?: RequestInit
) {
return this.request<Data>(_input, undefined, {
..._init,
body,
method: "PUT"
});
} }
protected async delete<Data = any>( key(options?: { search: boolean }): string {
_input: string | (string | number | PrimaryFieldType)[], const url = new URL(this.request.url);
_init?: RequestInit return options?.search !== false ? this.path() + url.search : this.path();
) { }
return this.request<Data>(_input, undefined, {
..._init, keyArray(options?: { search: boolean }): string[] {
method: "DELETE" const url = new URL(this.request.url);
}); const path = this.path().split("/");
return (options?.search !== false ? [...path, url.searchParams.toString()] : path).filter(
Boolean
);
} }
} }
+31 -6
View File
@@ -1,5 +1,5 @@
import { Guard } from "auth"; import { Guard } from "auth";
import { BkndError, DebugLogger, Exception, isDebug } from "core"; import { BkndError, DebugLogger } from "core";
import { EventManager } from "core/events"; import { EventManager } from "core/events";
import { clone, diff } from "core/object/diff"; import { clone, diff } from "core/object/diff";
import { import {
@@ -35,9 +35,11 @@ import { AppFlows } from "../flows/AppFlows";
import { AppMedia } from "../media/AppMedia"; import { AppMedia } from "../media/AppMedia";
import type { Module, ModuleBuildContext } from "./Module"; import type { Module, ModuleBuildContext } from "./Module";
export type { ModuleBuildContext };
export const MODULES = { export const MODULES = {
server: AppServer, server: AppServer,
data: AppData<any>, data: AppData,
auth: AppAuth, auth: AppAuth,
media: AppMedia, media: AppMedia,
flows: AppFlows flows: AppFlows
@@ -73,9 +75,14 @@ export type ModuleManagerOptions = {
module: Module, module: Module,
config: ModuleConfigs[Module] config: ModuleConfigs[Module]
) => Promise<void>; ) => Promise<void>;
// triggered when no config table existed
onFirstBoot?: () => Promise<void>;
// base path for the hono instance // base path for the hono instance
basePath?: string; basePath?: string;
// doesn't perform validity checks for given/fetched config
trustFetched?: boolean; trustFetched?: boolean;
// runs when initial config provided on a fresh database
seed?: (ctx: ModuleBuildContext) => Promise<void>;
}; };
type ConfigTable<Json = ModuleConfigs> = { type ConfigTable<Json = ModuleConfigs> = {
@@ -105,9 +112,9 @@ const __bknd = entity(TABLE_NAME, {
updated_at: datetime() updated_at: datetime()
}); });
type ConfigTable2 = Schema<typeof __bknd>; type ConfigTable2 = Schema<typeof __bknd>;
type T_INTERNAL_EM = { interface T_INTERNAL_EM {
__bknd: ConfigTable2; __bknd: ConfigTable2;
}; }
// @todo: cleanup old diffs on upgrade // @todo: cleanup old diffs on upgrade
// @todo: cleanup multiple backups on upgrade // @todo: cleanup multiple backups on upgrade
@@ -116,7 +123,7 @@ export class ModuleManager {
// internal em for __bknd config table // internal em for __bknd config table
__em!: EntityManager<T_INTERNAL_EM>; __em!: EntityManager<T_INTERNAL_EM>;
// ctx for modules // ctx for modules
em!: EntityManager<any>; em!: EntityManager;
server!: Hono; server!: Hono;
emgr!: EventManager; emgr!: EventManager;
guard!: Guard; guard!: Guard;
@@ -294,7 +301,7 @@ export class ModuleManager {
version, version,
json: configs, json: configs,
updated_at: new Date() updated_at: new Date()
}, } as any,
{ {
type: "config", type: "config",
version version
@@ -448,6 +455,9 @@ export class ModuleManager {
await this.buildModules(); await this.buildModules();
await this.save(); await this.save();
// run initial setup
await this.setupInitial();
this.logger.clear(); this.logger.clear();
return this; return this;
} }
@@ -462,6 +472,21 @@ export class ModuleManager {
return this; return this;
} }
protected async setupInitial() {
const ctx = {
...this.ctx(),
// disable events for initial setup
em: this.ctx().em.fork()
};
// perform a sync
await ctx.em.schema().sync({ force: true });
await this.options?.seed?.(ctx);
// run first boot event
await this.options?.onFirstBoot?.();
}
get<K extends keyof Modules>(key: K): Modules[K] { get<K extends keyof Modules>(key: K): Modules[K] {
if (!(key in this.modules)) { if (!(key in this.modules)) {
throw new Error(`Module "${key}" doesn't exist, cannot get`); throw new Error(`Module "${key}" doesn't exist, cannot get`);
+17 -12
View File
@@ -1,3 +1,4 @@
import type { ConfigUpdateResponse } from "modules/server/SystemController";
import { ModuleApi } from "./ModuleApi"; import { ModuleApi } from "./ModuleApi";
import type { ModuleConfigs, ModuleKey, ModuleSchemas } from "./ModuleManager"; import type { ModuleConfigs, ModuleKey, ModuleSchemas } from "./ModuleManager";
@@ -15,37 +16,41 @@ export class SystemApi extends ModuleApi<any> {
}; };
} }
async readSchema(options?: { config?: boolean; secrets?: boolean }) { readConfig() {
return await this.get<ApiSchemaResponse>("schema", { return this.get<{ version: number } & ModuleConfigs>("config");
}
readSchema(options?: { config?: boolean; secrets?: boolean }) {
return this.get<ApiSchemaResponse>("schema", {
config: options?.config ? 1 : 0, config: options?.config ? 1 : 0,
secrets: options?.secrets ? 1 : 0 secrets: options?.secrets ? 1 : 0
}); });
} }
async setConfig<Module extends ModuleKey>( setConfig<Module extends ModuleKey>(
module: Module, module: Module,
value: ModuleConfigs[Module], value: ModuleConfigs[Module],
force?: boolean force?: boolean
) { ) {
return await this.post<any>( return this.post<ConfigUpdateResponse>(
["config", "set", module].join("/") + `?force=${force ? 1 : 0}`, ["config", "set", module].join("/") + `?force=${force ? 1 : 0}`,
value value
); );
} }
async addConfig<Module extends ModuleKey>(module: Module, path: string, value: any) { addConfig<Module extends ModuleKey>(module: Module, path: string, value: any) {
return await this.post<any>(["config", "add", module, path], value); return this.post<ConfigUpdateResponse>(["config", "add", module, path], value);
} }
async patchConfig<Module extends ModuleKey>(module: Module, path: string, value: any) { patchConfig<Module extends ModuleKey>(module: Module, path: string, value: any) {
return await this.patch<any>(["config", "patch", module, path], value); return this.patch<ConfigUpdateResponse>(["config", "patch", module, path], value);
} }
async overwriteConfig<Module extends ModuleKey>(module: Module, path: string, value: any) { overwriteConfig<Module extends ModuleKey>(module: Module, path: string, value: any) {
return await this.put<any>(["config", "overwrite", module, path], value); return this.put<ConfigUpdateResponse>(["config", "overwrite", module, path], value);
} }
async removeConfig<Module extends ModuleKey>(module: Module, path: string) { removeConfig<Module extends ModuleKey>(module: Module, path: string) {
return await this.delete<any>(["config", "remove", module, path]); return this.delete<ConfigUpdateResponse>(["config", "remove", module, path]);
} }
} }
+15 -41
View File
@@ -74,6 +74,21 @@ export class AppServer extends Module<typeof serverConfigSchema> {
}) })
); );
// add an initial fallback route
this.client.use("/", async (c, next) => {
await next();
// if not finalized or giving a 404
if (!c.finalized || c.res.status === 404) {
// double check it's root
if (new URL(c.req.url).pathname === "/") {
c.res = undefined;
c.res = Response.json({
bknd: "hello world!"
});
}
}
});
this.client.onError((err, c) => { this.client.onError((err, c) => {
//throw err; //throw err;
console.error(err); console.error(err);
@@ -82,21 +97,6 @@ export class AppServer extends Module<typeof serverConfigSchema> {
return err; return err;
} }
/*if (isDebug()) {
console.log("accept", c.req.header("Accept"));
if (c.req.header("Accept") === "application/json") {
const stack = err.stack;
if ("toJSON" in err && typeof err.toJSON === "function") {
return c.json({ ...err.toJSON(), stack }, 500);
}
return c.json({ message: String(err), stack }, 500);
} else {
throw err;
}
}*/
if (err instanceof Exception) { if (err instanceof Exception) {
console.log("---is exception", err.code); console.log("---is exception", err.code);
return c.json(err.toJSON(), err.code as any); return c.json(err.toJSON(), err.code as any);
@@ -107,32 +107,6 @@ export class AppServer extends Module<typeof serverConfigSchema> {
this.setBuilt(); this.setBuilt();
} }
/*setAdminHtml(html: string) {
this.admin_html = html;
const basepath = (String(this.config.admin.basepath) + "/").replace(/\/+$/, "/");
const allowed_prefix = basepath + "auth";
const login_path = basepath + "auth/login";
this.client.get(basepath + "*", async (c, next) => {
const path = new URL(c.req.url).pathname;
if (!path.startsWith(allowed_prefix)) {
console.log("guard check permissions");
try {
this.ctx.guard.throwUnlessGranted(SystemPermissions.admin);
} catch (e) {
return c.redirect(login_path);
}
}
return c.html(this.admin_html!);
});
}
getAdminHtml() {
return this.admin_html;
}*/
override toJSON(secrets?: boolean) { override toJSON(secrets?: boolean) {
return this.config; return this.config;
} }
+17 -3
View File
@@ -1,18 +1,32 @@
/// <reference types="@cloudflare/workers-types" /> /// <reference types="@cloudflare/workers-types" />
import type { App } from "App";
import type { ClassController } from "core"; import type { ClassController } from "core";
import { tbValidator as tb } from "core"; import { tbValidator as tb } from "core";
import { StringEnum, Type, TypeInvalidError } from "core/utils"; import { StringEnum, Type, TypeInvalidError } from "core/utils";
import { type Context, Hono } from "hono"; import { type Context, Hono } from "hono";
import { MODULE_NAMES, type ModuleKey, getDefaultConfig } from "modules/ModuleManager"; import {
MODULE_NAMES,
type ModuleConfigs,
type ModuleKey,
getDefaultConfig
} from "modules/ModuleManager";
import * as SystemPermissions from "modules/permissions"; import * as SystemPermissions from "modules/permissions";
import { generateOpenAPI } from "modules/server/openapi"; import { generateOpenAPI } from "modules/server/openapi";
import type { App } from "../../App";
const booleanLike = Type.Transform(Type.String()) const booleanLike = Type.Transform(Type.String())
.Decode((v) => v === "1") .Decode((v) => v === "1")
.Encode((v) => (v ? "1" : "0")); .Encode((v) => (v ? "1" : "0"));
export type ConfigUpdate<Key extends ModuleKey = ModuleKey> = {
success: true;
module: Key;
config: ModuleConfigs[Key];
};
export type ConfigUpdateResponse<Key extends ModuleKey = ModuleKey> =
| ConfigUpdate<Key>
| { success: false; type: "type-invalid" | "error" | "unknown"; error?: any; errors?: any };
export class SystemController implements ClassController { export class SystemController implements ClassController {
constructor(private readonly app: App) {} constructor(private readonly app: App) {}
@@ -60,7 +74,7 @@ export class SystemController implements ClassController {
} }
); );
async function handleConfigUpdateResponse(c: Context<any>, cb: () => Promise<object>) { async function handleConfigUpdateResponse(c: Context<any>, cb: () => Promise<ConfigUpdate>) {
try { try {
return c.json(await cb(), { status: 202 }); return c.json(await cb(), { status: 202 });
} catch (e) { } catch (e) {
+41 -1
View File
@@ -3,6 +3,8 @@ import { Notifications } from "@mantine/notifications";
import type { ModuleConfigs } from "modules"; import type { ModuleConfigs } from "modules";
import React from "react"; import React from "react";
import { BkndProvider, useBknd } from "ui/client/bknd"; import { BkndProvider, useBknd } from "ui/client/bknd";
import { Logo } from "ui/components/display/Logo";
import * as AppShell from "ui/layouts/AppShell/AppShell";
import { FlashMessage } from "ui/modules/server/FlashMessage"; import { FlashMessage } from "ui/modules/server/FlashMessage";
import { ClientProvider, type ClientProviderProps } from "./client"; import { ClientProvider, type ClientProviderProps } from "./client";
import { createMantineTheme } from "./lib/mantine/theme"; import { createMantineTheme } from "./lib/mantine/theme";
@@ -21,7 +23,7 @@ export default function Admin({
config config
}: BkndAdminProps) { }: BkndAdminProps) {
const Component = ( const Component = (
<BkndProvider adminOverride={config}> <BkndProvider adminOverride={config} fallback={<Skeleton theme={config?.color_scheme} />}>
<AdminInternal /> <AdminInternal />
</BkndProvider> </BkndProvider>
); );
@@ -51,3 +53,41 @@ function AdminInternal() {
</MantineProvider> </MantineProvider>
); );
} }
const Skeleton = ({ theme = "light" }: { theme?: string }) => {
return (
<div id="bknd-admin" className={(theme ?? "light") + " antialiased"}>
<AppShell.Root>
<header
data-shell="header"
className="flex flex-row w-full h-16 gap-2.5 border-muted border-b justify-start bg-muted/10"
>
<div className="max-h-full flex hover:bg-primary/5 link p-2.5 w-[134px] outline-none">
<Logo theme={theme} />
</div>
<nav className="hidden md:flex flex-row gap-2.5 pl-0 p-2.5 items-center">
{[...new Array(5)].map((item, key) => (
<AppShell.NavLink key={key} as="span" className="active h-full opacity-50">
<div className="w-10 h-3" />
</AppShell.NavLink>
))}
</nav>
<nav className="flex md:hidden flex-row items-center">
<AppShell.NavLink as="span" className="active h-full opacity-50">
<div className="w-10 h-3" />
</AppShell.NavLink>
</nav>
<div className="flex flex-grow" />
<div className="hidden lg:flex flex-row items-center px-4 gap-2 opacity-50">
<div className="size-11 rounded-full bg-primary/10" />
</div>
</header>
<AppShell.Content>
<div className="flex flex-col w-full h-full justify-center items-center">
<span className="font-mono opacity-30">Loading</span>
</div>
</AppShell.Content>
</AppShell.Root>
</div>
);
};
+13 -9
View File
@@ -1,7 +1,7 @@
import type { ModuleConfigs, ModuleSchemas } from "modules";
import { getDefaultConfig, getDefaultSchema } from "modules/ModuleManager"; import { getDefaultConfig, getDefaultSchema } from "modules/ModuleManager";
import { createContext, startTransition, useContext, useEffect, useRef, useState } from "react"; import { createContext, startTransition, useContext, useEffect, useRef, useState } from "react";
import type { ModuleConfigs, ModuleSchemas } from "../../modules"; import { useApi } from "ui/client";
import { useClient } from "./ClientProvider";
import { type TSchemaActions, getSchemaActions } from "./schema/actions"; import { type TSchemaActions, getSchemaActions } from "./schema/actions";
import { AppReduced } from "./utils/AppReduced"; import { AppReduced } from "./utils/AppReduced";
@@ -22,14 +22,18 @@ export type { TSchemaActions };
export function BkndProvider({ export function BkndProvider({
includeSecrets = false, includeSecrets = false,
adminOverride, adminOverride,
children children,
}: { includeSecrets?: boolean; children: any } & Pick<BkndContext, "adminOverride">) { fallback = null
}: { includeSecrets?: boolean; children: any; fallback?: React.ReactNode } & Pick<
BkndContext,
"adminOverride"
>) {
const [withSecrets, setWithSecrets] = useState<boolean>(includeSecrets); const [withSecrets, setWithSecrets] = useState<boolean>(includeSecrets);
const [schema, setSchema] = const [schema, setSchema] =
useState<Pick<BkndContext, "version" | "schema" | "config" | "permissions">>(); useState<Pick<BkndContext, "version" | "schema" | "config" | "permissions">>();
const [fetched, setFetched] = useState(false); const [fetched, setFetched] = useState(false);
const errorShown = useRef<boolean>(); const errorShown = useRef<boolean>();
const client = useClient(); const api = useApi();
async function reloadSchema() { async function reloadSchema() {
await fetchSchema(includeSecrets, true); await fetchSchema(includeSecrets, true);
@@ -37,7 +41,7 @@ export function BkndProvider({
async function fetchSchema(_includeSecrets: boolean = false, force?: boolean) { async function fetchSchema(_includeSecrets: boolean = false, force?: boolean) {
if (withSecrets && !force) return; if (withSecrets && !force) return;
const { body, res } = await client.api.system.readSchema({ const res = await api.system.readSchema({
config: true, config: true,
secrets: _includeSecrets secrets: _includeSecrets
}); });
@@ -57,7 +61,7 @@ export function BkndProvider({
} }
const schema = res.ok const schema = res.ok
? body ? res.body
: ({ : ({
version: 0, version: 0,
schema: getDefaultSchema(), schema: getDefaultSchema(),
@@ -89,9 +93,9 @@ export function BkndProvider({
fetchSchema(includeSecrets); fetchSchema(includeSecrets);
}, []); }, []);
if (!fetched || !schema) return null; if (!fetched || !schema) return fallback;
const app = new AppReduced(schema?.config as any); const app = new AppReduced(schema?.config as any);
const actions = getSchemaActions({ client, setSchema, reloadSchema }); const actions = getSchemaActions({ api, setSchema, reloadSchema });
return ( return (
<BkndContext.Provider value={{ ...schema, actions, requireSecrets, app, adminOverride }}> <BkndContext.Provider value={{ ...schema, actions, requireSecrets, app, adminOverride }}>
+23 -56
View File
@@ -1,22 +1,10 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { Api, type ApiOptions, type TApiUser } from "Api";
import type { TApiUser } from "Api";
import { createContext, useContext, useEffect, useState } from "react"; import { createContext, useContext, useEffect, useState } from "react";
//import { useBkndWindowContext } from "ui/client/BkndProvider";
import { AppQueryClient } from "./utils/AppQueryClient";
const ClientContext = createContext<{ baseUrl: string; client: AppQueryClient }>({ const ClientContext = createContext<{ baseUrl: string; api: Api }>({
baseUrl: undefined baseUrl: undefined
} as any); } as any);
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
refetchOnWindowFocus: false
}
}
});
export type ClientProviderProps = { export type ClientProviderProps = {
children?: any; children?: any;
baseUrl?: string; baseUrl?: string;
@@ -24,74 +12,53 @@ export type ClientProviderProps = {
}; };
export const ClientProvider = ({ children, baseUrl, user }: ClientProviderProps) => { export const ClientProvider = ({ children, baseUrl, user }: ClientProviderProps) => {
const [actualBaseUrl, setActualBaseUrl] = useState<string | null>(null); //const [actualBaseUrl, setActualBaseUrl] = useState<string | null>(null);
const winCtx = useBkndWindowContext(); const winCtx = useBkndWindowContext();
const _ctx_baseUrl = useBaseUrl();
let actualBaseUrl = baseUrl ?? _ctx_baseUrl ?? "";
try { try {
const _ctx_baseUrl = useBaseUrl(); if (!baseUrl) {
if (_ctx_baseUrl) { if (_ctx_baseUrl) {
console.warn("wrapped many times"); actualBaseUrl = _ctx_baseUrl;
setActualBaseUrl(_ctx_baseUrl); console.warn("wrapped many times, take from context", actualBaseUrl);
} else if (typeof window !== "undefined") {
actualBaseUrl = window.location.origin;
console.log("setting from window", actualBaseUrl);
}
} }
} catch (e) { } catch (e) {
console.error("error", e); console.error("error .....", e);
} }
useEffect(() => { const api = new Api({ host: actualBaseUrl, user: user ?? winCtx.user });
// Only set base URL if running on the client side
if (typeof window !== "undefined") {
setActualBaseUrl(baseUrl || window.location.origin);
}
}, [baseUrl]);
if (!actualBaseUrl) {
// Optionally, return a fallback during SSR rendering
return null; // or a loader/spinner if desired
}
//console.log("client provider11 with", { baseUrl, fallback: actualBaseUrl, user });
const client = createClient(actualBaseUrl, user ?? winCtx.user);
return ( return (
<QueryClientProvider client={queryClient}> <ClientContext.Provider value={{ baseUrl: api.baseUrl, api }}>
<ClientContext.Provider value={{ baseUrl: actualBaseUrl, client }}>
{children} {children}
</ClientContext.Provider> </ClientContext.Provider>
</QueryClientProvider>
); );
}; };
export function createClient(baseUrl: string, user?: object) { export const useApi = (host?: ApiOptions["host"]): Api => {
return new AppQueryClient(baseUrl, user);
}
export function createOrUseClient(baseUrl: string) {
const context = useContext(ClientContext); const context = useContext(ClientContext);
if (!context) { if (!context?.api || (host && host.length > 0 && host !== context.baseUrl)) {
console.warn("createOrUseClient returned a new client"); return new Api({ host: host ?? "" });
return createClient(baseUrl);
} }
return context.client; return context.api;
}
export const useClient = () => {
const context = useContext(ClientContext);
if (!context) {
throw new Error("useClient must be used within a ClientProvider");
}
console.log("useClient", context.baseUrl);
return context.client;
}; };
/**
* @deprecated use useApi().baseUrl instead
*/
export const useBaseUrl = () => { export const useBaseUrl = () => {
const context = useContext(ClientContext); const context = useContext(ClientContext);
return context.baseUrl; return context.baseUrl;
}; };
type BkndWindowContext = { type BkndWindowContext = {
user?: object; user?: TApiUser;
logout_route: string; logout_route: string;
}; };
export function useBkndWindowContext(): BkndWindowContext { export function useBkndWindowContext(): BkndWindowContext {
+38
View File
@@ -0,0 +1,38 @@
import type { Api } from "Api";
import type { FetchPromise, ResponseObject } from "modules/ModuleApi";
import useSWR, { type SWRConfiguration, useSWRConfig } from "swr";
import { useApi } from "ui/client";
export const useApiQuery = <
Data,
RefineFn extends (data: ResponseObject<Data>) => unknown = (data: ResponseObject<Data>) => Data
>(
fn: (api: Api) => FetchPromise<Data>,
options?: SWRConfiguration & { enabled?: boolean; refine?: RefineFn }
) => {
const api = useApi();
const promise = fn(api);
const refine = options?.refine ?? ((data: any) => data);
const fetcher = () => promise.execute().then(refine);
const key = promise.key();
type RefinedData = RefineFn extends (data: ResponseObject<Data>) => infer R ? R : Data;
const swr = useSWR<RefinedData>(options?.enabled === false ? null : key, fetcher, options);
return {
...swr,
promise,
key,
api
};
};
export const useInvalidate = () => {
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());
};
};
+209
View File
@@ -0,0 +1,209 @@
import type { DB, PrimaryFieldType } from "core";
import { encodeSearch, objectTransform } from "core/utils";
import type { EntityData, RepoQuery } from "data";
import type { ModuleApi, ResponseObject } from "modules/ModuleApi";
import useSWR, { type SWRConfiguration, mutate } from "swr";
import { type Api, useApi } from "ui/client";
export class UseEntityApiError<Payload = any> extends Error {
constructor(
public response: ResponseObject<Payload>,
fallback?: string
) {
let message = fallback;
if ("error" in response) {
message = response.error as string;
if (fallback) {
message = `${fallback}: ${message}`;
}
}
super(message ?? "UseEntityApiError");
}
}
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,
Data = Entity extends keyof DB ? DB[Entity] : EntityData
>(
entity: Entity,
id?: Id
) => {
const api = useApi().data;
return {
create: async (input: Omit<Data, "id">) => {
const res = await api.createOne(entity, input);
if (!res.ok) {
throw new UseEntityApiError(res, `Failed to create entity "${entity}"`);
}
return res;
},
read: async (query: Partial<RepoQuery> = {}) => {
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}"`);
}
// must be manually typed
return res as unknown as Id extends undefined
? ResponseObject<Data[]>
: ResponseObject<Data>;
},
update: async (input: Partial<Omit<Data, "id">>, _id: PrimaryFieldType | undefined = id) => {
if (!_id) {
throw new Error("id is required");
}
const res = await api.updateOne(entity, _id, input);
if (!res.ok) {
throw new UseEntityApiError(res, `Failed to update entity "${entity}"`);
}
return res;
},
_delete: async (_id: PrimaryFieldType | undefined = id) => {
if (!_id) {
throw new Error("id is required");
}
const res = await api.deleteOne(entity, _id);
if (!res.ok) {
throw new UseEntityApiError(res, `Failed to delete entity "${entity}"`);
}
return res;
}
};
};
// @todo: try to get from ModuleApi directly
export function makeKey(
api: ModuleApi,
entity: string,
id?: PrimaryFieldType,
query?: Partial<RepoQuery>
) {
return (
"/" +
[...(api.options?.basepath?.split("/") ?? []), entity, ...(id ? [id] : [])]
.filter(Boolean)
.join("/") +
(query ? "?" + encodeSearch(query) : "")
);
}
export const useEntityQuery = <
Entity extends keyof DB | string,
Id extends PrimaryFieldType | undefined = undefined
>(
entity: Entity,
id?: Id,
query?: Partial<RepoQuery>,
options?: SWRConfiguration & { enabled?: boolean; revalidateOnMutate?: boolean }
) => {
const api = useApi().data;
const key = makeKey(api, entity, id, query);
const { read, ...actions } = useEntity<Entity, Id>(entity, id);
const fetcher = () => read(query);
type T = Awaited<ReturnType<typeof fetcher>>;
const swr = useSWR<T>(options?.enabled === false ? null : key, fetcher as any, {
revalidateOnFocus: false,
keepPreviousData: true,
...options
});
const mutateAll = async () => {
const entityKey = makeKey(api, entity);
return mutate((key) => typeof key === "string" && key.startsWith(entityKey), undefined, {
revalidate: true
});
};
const mapped = objectTransform(actions, (action) => {
return async (...args: any) => {
// @ts-ignore
const res = await action(...args);
// mutate all keys of entity by default
if (options?.revalidateOnMutate !== false) {
await mutateAll();
}
return res;
};
}) as Omit<ReturnType<typeof useEntity<Entity, Id>>, "read">;
return {
...swr,
...mapped,
api,
key
};
};
export async function mutateEntityCache<
Entity extends keyof DB | string,
Data = Entity extends keyof DB ? Omit<DB[Entity], "id"> : EntityData
>(api: Api["data"], entity: Entity, id: PrimaryFieldType, partialData: Partial<Data>) {
function update(prev: any, partialNext: any) {
if (
typeof prev !== "undefined" &&
typeof partialNext !== "undefined" &&
"id" in prev &&
prev.id === id
) {
return { ...prev, ...partialNext };
}
return prev;
}
const entityKey = makeKey(api, entity);
return mutate(
(key) => typeof key === "string" && key.startsWith(entityKey),
async (data) => {
if (typeof data === "undefined") return;
if (Array.isArray(data)) {
return data.map((item) => update(item, partialData));
}
return update(data, partialData);
},
{
revalidate: false
}
);
}
export const useEntityMutate = <
Entity extends keyof DB | string,
Id extends PrimaryFieldType | undefined = undefined,
Data = Entity extends keyof DB ? Omit<DB[Entity], "id"> : EntityData
>(
entity: Entity,
id?: Id,
options?: SWRConfiguration
) => {
const { data, ...$q } = useEntityQuery<Entity, Id>(entity, id, undefined, {
...options,
enabled: false
});
const _mutate = id
? (data) => mutateEntityCache($q.api, entity, id, data)
: (id, data) => mutateEntityCache($q.api, entity, id, data);
return {
...$q,
mutate: _mutate as unknown as Id extends undefined
? (id: PrimaryFieldType, data: Partial<Data>) => Promise<void>
: (data: Partial<Data>) => Promise<void>
};
};
+3 -1
View File
@@ -2,9 +2,11 @@ export {
ClientProvider, ClientProvider,
useBkndWindowContext, useBkndWindowContext,
type ClientProviderProps, type ClientProviderProps,
useClient, useApi,
useBaseUrl useBaseUrl
} from "./ClientProvider"; } from "./ClientProvider";
export * from "./api/use-api";
export * from "./api/use-entity";
export { useAuth } from "./schema/auth/use-auth"; export { useAuth } from "./schema/auth/use-auth";
export { Api } from "../../Api"; export { Api } from "../../Api";
+20 -27
View File
@@ -1,23 +1,23 @@
import { type NotificationData, notifications } from "@mantine/notifications"; import { type NotificationData, notifications } from "@mantine/notifications";
import type { Api } from "Api";
import { ucFirst } from "core/utils"; import { ucFirst } from "core/utils";
import type { ApiResponse, ModuleConfigs } from "../../../modules"; import type { ModuleConfigs } from "modules";
import type { AppQueryClient } from "../utils/AppQueryClient"; import type { ResponseObject } from "modules/ModuleApi";
import type { ConfigUpdateResponse } from "modules/server/SystemController";
export type SchemaActionsProps = { export type SchemaActionsProps = {
client: AppQueryClient; api: Api;
setSchema: React.Dispatch<React.SetStateAction<any>>; setSchema: React.Dispatch<React.SetStateAction<any>>;
reloadSchema: () => Promise<void>; reloadSchema: () => Promise<void>;
}; };
export type TSchemaActions = ReturnType<typeof getSchemaActions>; export type TSchemaActions = ReturnType<typeof getSchemaActions>;
export function getSchemaActions({ client, setSchema, reloadSchema }: SchemaActionsProps) { export function getSchemaActions({ api, setSchema, reloadSchema }: SchemaActionsProps) {
const api = client.api; async function handleConfigUpdate<Module extends keyof ModuleConfigs>(
async function handleConfigUpdate(
action: string, action: string,
module: string, module: Module,
res: ApiResponse, res: ResponseObject<ConfigUpdateResponse<Module>>,
path?: string path?: string
): Promise<boolean> { ): Promise<boolean> {
const base: Partial<NotificationData> = { const base: Partial<NotificationData> = {
@@ -26,7 +26,7 @@ export function getSchemaActions({ client, setSchema, reloadSchema }: SchemaActi
autoClose: 3000 autoClose: 3000
}; };
if (res.res.ok && res.body.success) { if (res.success === true) {
console.log("update config", action, module, path, res.body); console.log("update config", action, module, path, res.body);
if (res.body.success) { if (res.body.success) {
setSchema((prev) => { setSchema((prev) => {
@@ -35,7 +35,7 @@ export function getSchemaActions({ client, setSchema, reloadSchema }: SchemaActi
...prev, ...prev,
config: { config: {
...prev.config, ...prev.config,
[module]: res.body.config [module]: res.config
} }
}; };
}); });
@@ -47,18 +47,18 @@ export function getSchemaActions({ client, setSchema, reloadSchema }: SchemaActi
color: "blue", color: "blue",
message: `Operation ${action.toUpperCase()} at ${module}${path ? "." + path : ""}` message: `Operation ${action.toUpperCase()} at ${module}${path ? "." + path : ""}`
}); });
return true; } else {
}
notifications.show({ notifications.show({
...base, ...base,
title: `Config Update failed: ${ucFirst(module)}${path ? "." + path : ""}`, title: `Config Update failed: ${ucFirst(module)}${path ? "." + path : ""}`,
color: "red", color: "red",
withCloseButton: true, withCloseButton: true,
autoClose: false, autoClose: false,
message: res.body.error ?? "Failed to complete config update" message: res.error ?? "Failed to complete config update"
}); });
return false; }
return res.success;
} }
return { return {
@@ -72,7 +72,7 @@ export function getSchemaActions({ client, setSchema, reloadSchema }: SchemaActi
return await handleConfigUpdate("set", module, res); return await handleConfigUpdate("set", module, res);
}, },
patch: async <Module extends keyof ModuleConfigs>( patch: async <Module extends keyof ModuleConfigs>(
module: keyof ModuleConfigs, module: Module,
path: string, path: string,
value: any value: any
): Promise<boolean> => { ): Promise<boolean> => {
@@ -80,25 +80,18 @@ export function getSchemaActions({ client, setSchema, reloadSchema }: SchemaActi
return await handleConfigUpdate("patch", module, res, path); return await handleConfigUpdate("patch", module, res, path);
}, },
overwrite: async <Module extends keyof ModuleConfigs>( overwrite: async <Module extends keyof ModuleConfigs>(
module: keyof ModuleConfigs, module: Module,
path: string, path: string,
value: any value: any
) => { ) => {
const res = await api.system.overwriteConfig(module, path, value); const res = await api.system.overwriteConfig(module, path, value);
return await handleConfigUpdate("overwrite", module, res, path); return await handleConfigUpdate("overwrite", module, res, path);
}, },
add: async <Module extends keyof ModuleConfigs>( add: async <Module extends keyof ModuleConfigs>(module: Module, path: string, value: any) => {
module: keyof ModuleConfigs,
path: string,
value: any
) => {
const res = await api.system.addConfig(module, path, value); const res = await api.system.addConfig(module, path, value);
return await handleConfigUpdate("add", module, res, path); return await handleConfigUpdate("add", module, res, path);
}, },
remove: async <Module extends keyof ModuleConfigs>( remove: async <Module extends keyof ModuleConfigs>(module: Module, path: string) => {
module: keyof ModuleConfigs,
path: string
) => {
const res = await api.system.removeConfig(module, path); const res = await api.system.removeConfig(module, path);
return await handleConfigUpdate("remove", module, res, path); return await handleConfigUpdate("remove", module, res, path);
} }
+27 -38
View File
@@ -1,15 +1,8 @@
import { Api } from "Api"; import { Api, type AuthState } from "Api";
import type { AuthResponse } from "auth"; import type { AuthResponse } from "auth";
import type { AppAuthSchema } from "auth/auth-schema"; import type { AppAuthSchema } from "auth/auth-schema";
import type { ApiResponse } from "modules/ModuleApi";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import { useApi, useInvalidate } from "ui/client";
createClient,
createOrUseClient,
queryClient,
useBaseUrl,
useClient
} from "../../ClientProvider";
type LoginData = { type LoginData = {
email: string; email: string;
@@ -18,55 +11,54 @@ type LoginData = {
}; };
type UseAuth = { type UseAuth = {
data: (AuthResponse & { verified: boolean }) | undefined; data: AuthState | undefined;
user: AuthResponse["user"] | undefined; user: AuthState["user"] | undefined;
token: AuthResponse["token"] | undefined; token: AuthState["token"] | undefined;
verified: boolean; verified: boolean;
login: (data: LoginData) => Promise<ApiResponse<AuthResponse>>; login: (data: LoginData) => Promise<AuthResponse>;
register: (data: LoginData) => Promise<ApiResponse<AuthResponse>>; register: (data: LoginData) => Promise<AuthResponse>;
logout: () => void; logout: () => void;
verify: () => void; verify: () => void;
setToken: (token: string) => void; setToken: (token: string) => void;
}; };
// @todo: needs to use a specific auth endpoint to get strategy information
export const useAuth = (options?: { baseUrl?: string }): UseAuth => { export const useAuth = (options?: { baseUrl?: string }): UseAuth => {
const ctxBaseUrl = useBaseUrl(); const api = useApi(options?.baseUrl);
//const client = useClient(); const invalidate = useInvalidate();
const client = createOrUseClient(options?.baseUrl ? options?.baseUrl : ctxBaseUrl); const authState = api.getAuthState();
const authState = client.auth().state();
const [authData, setAuthData] = useState<UseAuth["data"]>(authState); const [authData, setAuthData] = useState<UseAuth["data"]>(authState);
const verified = authState?.verified ?? false; const verified = authState?.verified ?? false;
async function login(input: LoginData) { function updateAuthState() {
const res = await client.auth().login(input); setAuthData(api.getAuthState());
if (res.res.ok && res.data && "user" in res.data) {
setAuthData(res.data);
} }
return res;
async function login(input: LoginData) {
const res = await api.auth.loginWithPassword(input);
updateAuthState();
return res.data;
} }
async function register(input: LoginData) { async function register(input: LoginData) {
const res = await client.auth().register(input); const res = await api.auth.registerWithPassword(input);
if (res.res.ok && res.data && "user" in res.data) { updateAuthState();
setAuthData(res.data); return res.data;
}
return res;
} }
function setToken(token: string) { function setToken(token: string) {
setAuthData(client.auth().setToken(token) as any); api.updateToken(token);
updateAuthState();
} }
async function logout() { async function logout() {
await client.auth().logout(); await api.updateToken(undefined);
setAuthData(undefined); setAuthData(undefined);
queryClient.clear(); invalidate();
} }
async function verify() { async function verify() {
await client.auth().verify(); await api.verifyAuth();
setAuthData(client.auth().state()); updateAuthState();
} }
return { return {
@@ -87,10 +79,7 @@ export const useAuthStrategies = (options?: { baseUrl?: string }): Partial<AuthS
loading: boolean; loading: boolean;
} => { } => {
const [data, setData] = useState<AuthStrategyData>(); const [data, setData] = useState<AuthStrategyData>();
const ctxBaseUrl = useBaseUrl(); const api = useApi(options?.baseUrl);
const api = new Api({
host: options?.baseUrl ? options?.baseUrl : ctxBaseUrl
});
useEffect(() => { useEffect(() => {
(async () => { (async () => {
@@ -1,8 +1,7 @@
import { useBknd } from "ui/client/bknd"; import { useBknd } from "ui/client/bknd";
export function useBkndAuth() { export function useBkndAuth() {
//const client = useClient(); const { config, schema, actions: bkndActions } = useBknd();
const { config, app, schema, actions: bkndActions } = useBknd();
const actions = { const actions = {
roles: { roles: {
@@ -1,6 +1,5 @@
import { Type, TypeInvalidError, parse, transformObject } from "core/utils"; import { Type, TypeInvalidError, parse, transformObject } from "core/utils";
import type { Entity } from "data"; import { constructEntity } from "data";
import { AppData } from "data/AppData";
import { import {
type TAppDataEntity, type TAppDataEntity,
type TAppDataEntityFields, type TAppDataEntityFields,
@@ -19,7 +18,7 @@ export function useBkndData() {
// @todo: potentially store in ref, so it doesn't get recomputed? or use memo? // @todo: potentially store in ref, so it doesn't get recomputed? or use memo?
const entities = transformObject(config.data.entities ?? {}, (entity, name) => { const entities = transformObject(config.data.entities ?? {}, (entity, name) => {
return AppData.constructEntity(name, entity); return constructEntity(name, entity);
}); });
const actions = { const actions = {
@@ -1,10 +1,8 @@
import { type Static, parse } from "core/utils"; import { type Static, parse } from "core/utils";
import { type TAppFlowSchema, flowSchema } from "flows/flows-schema"; import { type TAppFlowSchema, flowSchema } from "flows/flows-schema";
import { useBknd } from "../../BkndProvider"; import { useBknd } from "../../BkndProvider";
import { useClient } from "../../ClientProvider";
export function useFlows() { export function useFlows() {
const client = useClient();
const { config, app, actions: bkndActions } = useBknd(); const { config, app, actions: bkndActions } = useBknd();
const actions = { const actions = {
-218
View File
@@ -1,218 +0,0 @@
import {
type QueryObserverOptions,
type UseQueryResult,
keepPreviousData,
useMutation,
useQuery
} from "@tanstack/react-query";
import type { AuthResponse } from "auth";
import type { EntityData, RepoQuery, RepositoryResponse } from "data";
import { Api } from "../../../Api";
import type { ApiResponse } from "../../../modules/ModuleApi";
import { queryClient } from "../ClientProvider";
export class AppQueryClient {
api: Api;
constructor(
public baseUrl: string,
user?: object
) {
this.api = new Api({
host: baseUrl,
user
});
}
queryOptions(options?: Partial<QueryObserverOptions>): Partial<QueryObserverOptions> {
return {
staleTime: 1000 * 60 * 5,
placeholderData: keepPreviousData,
...options
};
}
auth = () => {
return {
state: (): (AuthResponse & { verified: boolean }) | undefined => {
return this.api.getAuthState() as any;
},
login: async (data: { email: string; password: string }): Promise<
ApiResponse<AuthResponse>
> => {
return await this.api.auth.loginWithPassword(data);
},
register: async (data: any): Promise<ApiResponse<AuthResponse>> => {
return await this.api.auth.registerWithPassword(data);
},
logout: async () => {
this.api.updateToken(undefined);
return true;
},
setToken: (token) => {
this.api.updateToken(token);
return this.api.getAuthState();
},
verify: async () => {
try {
//console.log("verifiying");
const res = await this.api.auth.me();
//console.log("verifying result", res);
if (!res.res.ok || !res.body.user) {
throw new Error();
}
this.api.markAuthVerified(true);
} catch (e) {
this.api.markAuthVerified(false);
this.api.updateToken(undefined);
}
}
};
};
media = (options?: Partial<QueryObserverOptions>) => {
const queryOptions = this.queryOptions(options);
return {
api: () => {
return this.api.media;
},
list: (query: Partial<RepoQuery> = { limit: 10 }): UseQueryResult<ApiResponse> => {
return useQuery({
...(queryOptions as any), // @todo: fix typing
queryKey: ["data", "entity", "media", { query }],
queryFn: async () => {
return await this.api.data.readMany("media", query);
}
});
},
deleteFile: async (filename: string | { path: string }) => {
const res = await this.api.media.deleteFile(
typeof filename === "string" ? filename : filename.path
);
if (res.res.ok) {
queryClient.invalidateQueries({ queryKey: ["data", "entity", "media"] });
return true;
}
return false;
}
};
};
query = (options?: Partial<QueryObserverOptions>) => {
const queryOptions = this.queryOptions(options);
return {
data: {
entity: (name: string) => {
return {
readOne: (
id: number,
query: Partial<Omit<RepoQuery, "where" | "limit" | "offset">> = {}
): any => {
return useQuery({
...queryOptions,
queryKey: ["data", "entity", name, id, { query }],
queryFn: async () => {
return await this.api.data.readOne(name, id, query);
}
});
},
readMany: (
query: Partial<RepoQuery> = { limit: 10, offset: 0 }
): UseQueryResult<ApiResponse> => {
return useQuery({
...(queryOptions as any), // @todo: fix typing
queryKey: ["data", "entity", name, { query }],
queryFn: async () => {
return await this.api.data.readMany(name, query);
}
});
},
readManyByReference: (
id: number,
reference: string,
referenced_entity?: string, // required for query invalidation
query: Partial<RepoQuery> = { limit: 10, offset: 0 }
): UseQueryResult<Pick<RepositoryResponse, "meta" | "data">> => {
return useQuery({
...(queryOptions as any), // @todo: fix typing
queryKey: [
"data",
"entity",
referenced_entity ?? reference,
{ name, id, reference, query }
],
queryFn: async () => {
return await this.api.data.readManyByReference(
name,
id,
reference,
query
);
}
});
},
count: (
where: RepoQuery["where"] = {}
): UseQueryResult<ApiResponse<{ entity: string; count: number }>> => {
return useQuery({
...(queryOptions as any), // @todo: fix typing
queryKey: ["data", "entity", name, "fn", "count", { where }],
queryFn: async () => {
return await this.api.data.count(name, where);
}
});
}
};
}
}
};
};
// @todo: centralize, improve
__invalidate = (...args: any[]) => {
console.log("___invalidate", ["data", "entity", ...args]);
queryClient.invalidateQueries({ queryKey: ["data", "entity", ...args] });
};
// @todo: must return response... why?
mutation = {
data: {
entity: (name: string) => {
return {
update: (id: number): any => {
return useMutation({
mutationFn: async (input: EntityData) => {
return await this.api.data.updateOne(name, id, input);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["data", "entity", name] });
}
});
},
create: (): any => {
return useMutation({
mutationFn: async (input: EntityData) => {
return await this.api.data.createOne(name, input);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["data", "entity", name] });
}
});
},
delete: (id: number): any => {
return useMutation({
mutationFn: async () => {
return await this.api.data.deleteOne(name, id);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["data", "entity", name] });
}
});
}
};
}
}
};
}
+3 -4
View File
@@ -1,6 +1,5 @@
import type { App } from "App"; import type { App } from "App";
import type { Entity, EntityRelation } from "data"; import { type Entity, type EntityRelation, constructEntity, constructRelation } from "data";
import { AppData } from "data/AppData";
import { RelationAccessor } from "data/relations/RelationAccessor"; import { RelationAccessor } from "data/relations/RelationAccessor";
import { Flow, TaskMap } from "flows"; import { Flow, TaskMap } from "flows";
@@ -20,11 +19,11 @@ export class AppReduced {
//console.log("received appjson", appJson); //console.log("received appjson", appJson);
this._entities = Object.entries(this.appJson.data.entities ?? {}).map(([name, entity]) => { this._entities = Object.entries(this.appJson.data.entities ?? {}).map(([name, entity]) => {
return AppData.constructEntity(name, entity); return constructEntity(name, entity);
}); });
this._relations = Object.entries(this.appJson.data.relations ?? {}).map(([, relation]) => { this._relations = Object.entries(this.appJson.data.relations ?? {}).map(([, relation]) => {
return AppData.constructRelation(relation, this.entity.bind(this)); return constructRelation(relation, this.entity.bind(this));
}); });
for (const [name, obj] of Object.entries(this.appJson.flows.flows ?? {})) { for (const [name, obj] of Object.entries(this.appJson.flows.flows ?? {})) {
+2 -5
View File
@@ -1,7 +1,6 @@
import type { ReactCodeMirrorProps } from "@uiw/react-codemirror"; import { default as CodeMirror, type ReactCodeMirrorProps } from "@uiw/react-codemirror";
import { Suspense, lazy } from "react";
import { useBknd } from "ui/client/bknd"; import { useBknd } from "ui/client/bknd";
const CodeMirror = lazy(() => import("@uiw/react-codemirror"));
export default function CodeEditor({ editable, basicSetup, ...props }: ReactCodeMirrorProps) { export default function CodeEditor({ editable, basicSetup, ...props }: ReactCodeMirrorProps) {
const b = useBknd(); const b = useBknd();
@@ -15,13 +14,11 @@ export default function CodeEditor({ editable, basicSetup, ...props }: ReactCode
: basicSetup; : basicSetup;
return ( return (
<Suspense>
<CodeMirror <CodeMirror
theme={theme === "dark" ? "dark" : "light"} theme={theme === "dark" ? "dark" : "light"}
editable={editable} editable={editable}
basicSetup={_basicSetup} basicSetup={_basicSetup}
{...props} {...props}
/> />
</Suspense>
); );
} }
+9 -5
View File
@@ -1,9 +1,13 @@
import { useBknd } from "../../client/BkndProvider"; import { useBknd } from "ui/client/bknd";
export function Logo({ scale = 0.2, fill }: { scale?: number; fill?: string }) { export function Logo({
const { app } = useBknd(); scale = 0.2,
const theme = app.getAdminConfig().color_scheme; fill,
const svgFill = fill ? fill : theme === "light" ? "black" : "white"; theme = "light"
}: { scale?: number; fill?: string; theme?: string }) {
const $bknd = useBknd();
const _theme = theme ?? $bknd?.app?.getAdminConfig().color_scheme ?? "light";
const svgFill = fill ? fill : _theme === "light" ? "black" : "white";
const dim = { const dim = {
width: Math.round(578 * scale), width: Math.round(578 * scale),
@@ -1,15 +1,12 @@
import type { Schema } from "@cfworker/json-schema"; import type { Schema } from "@cfworker/json-schema";
import Form from "@rjsf/core"; import Form from "@rjsf/core";
import type { RJSFSchema, UiSchema } from "@rjsf/utils"; import type { RJSFSchema, UiSchema } from "@rjsf/utils";
import { cloneDeep } from "lodash-es";
import { forwardRef, useId, useImperativeHandle, useRef, useState } from "react"; import { forwardRef, useId, useImperativeHandle, useRef, useState } from "react";
//import { JsonSchemaValidator } from "./JsonSchemaValidator";
import { fields as Fields } from "./fields"; import { fields as Fields } from "./fields";
import { templates as Templates } from "./templates"; import { templates as Templates } from "./templates";
import { widgets as Widgets } from "./widgets";
import "./styles.css";
import { filterKeys } from "core/utils";
import { cloneDeep } from "lodash-es";
import { RJSFTypeboxValidator } from "./typebox/RJSFTypeboxValidator"; import { RJSFTypeboxValidator } from "./typebox/RJSFTypeboxValidator";
import { widgets as Widgets } from "./widgets";
const validator = new RJSFTypeboxValidator(); const validator = new RJSFTypeboxValidator();
@@ -0,0 +1,18 @@
import { Suspense, forwardRef, lazy } from "react";
import type { JsonSchemaFormProps, JsonSchemaFormRef } from "./JsonSchemaForm";
export type { JsonSchemaFormProps, JsonSchemaFormRef };
const Module = lazy(() =>
import("./JsonSchemaForm").then((m) => ({
default: m.JsonSchemaForm
}))
);
export const JsonSchemaForm = forwardRef<JsonSchemaFormRef, JsonSchemaFormProps>((props, ref) => {
return (
<Suspense>
<Module ref={ref} {...props} />
</Suspense>
);
});
+12 -5
View File
@@ -29,7 +29,7 @@ export const Check = () => {
}; };
export type DataTableProps<Data> = { export type DataTableProps<Data> = {
data: Data[]; data: Data[] | null; // "null" implies loading
columns?: string[]; columns?: string[];
checkable?: boolean; checkable?: boolean;
onClickRow?: (row: Data) => void; onClickRow?: (row: Data) => void;
@@ -71,10 +71,10 @@ export function DataTable<Data extends Record<string, any> = Record<string, any>
renderValue, renderValue,
onClickNew onClickNew
}: DataTableProps<Data>) { }: DataTableProps<Data>) {
total = total || data.length; total = total || data?.length || 0;
page = page || 1; page = page || 1;
const select = columns && columns.length > 0 ? columns : Object.keys(data[0] || {}); const select = columns && columns.length > 0 ? columns : Object.keys(data?.[0] || {});
const pages = Math.max(Math.ceil(total / perPage), 1); const pages = Math.max(Math.ceil(total / perPage), 1);
const CellRender = renderValue || CellValue; const CellRender = renderValue || CellValue;
@@ -129,7 +129,9 @@ export function DataTable<Data extends Record<string, any> = Record<string, any>
<tr> <tr>
<td colSpan={select.length + (checkable ? 1 : 0)}> <td colSpan={select.length + (checkable ? 1 : 0)}>
<div className="flex flex-col gap-2 p-8 justify-center items-center border-t border-muted"> <div className="flex flex-col gap-2 p-8 justify-center items-center border-t border-muted">
<i className="opacity-50">No data to show</i> <i className="opacity-50">
{Array.isArray(data) ? "No data to show" : "Loading..."}
</i>
</div> </div>
</td> </td>
</tr> </tr>
@@ -188,7 +190,12 @@ export function DataTable<Data extends Record<string, any> = Record<string, any>
</div> </div>
<div className="flex flex-row items-center justify-between"> <div className="flex flex-row items-center justify-between">
<div className="hidden md:flex text-primary/40"> <div className="hidden md:flex text-primary/40">
<TableDisplay perPage={perPage} page={page} items={data.length} total={total} /> <TableDisplay
perPage={perPage}
page={page}
items={data?.length || 0}
total={total}
/>
</div> </div>
<div className="flex flex-row gap-2 md:gap-10 items-center"> <div className="flex flex-row gap-2 md:gap-10 items-center">
{perPageOptions && ( {perPageOptions && (
+9 -3
View File
@@ -42,7 +42,11 @@ const useLocationFromRouter = (router) => {
]; ];
}; };
export function Link({ className, ...props }: { className?: string } & LinkProps) { export function Link({
className,
native,
...props
}: { className?: string; native?: boolean } & LinkProps) {
const router = useRouter(); const router = useRouter();
const [path, navigate] = useLocationFromRouter(router); const [path, navigate] = useLocationFromRouter(router);
@@ -55,8 +59,6 @@ export function Link({ className, ...props }: { className?: string } & LinkProps
return false; return false;
} }
function handleClick(e) {}
const _href = props.href ?? props.to; const _href = props.href ?? props.to;
const href = router const href = router
.hrefs( .hrefs(
@@ -72,6 +74,10 @@ export function Link({ className, ...props }: { className?: string } & LinkProps
/*if (active) { /*if (active) {
console.log("link", { a, path, absPath, href, to, active, router }); console.log("link", { a, path, absPath, href, to, active, router });
}*/ }*/
if (native) {
return <a className={`${active ? "active " : ""}${className}`} {...props} />;
}
return ( return (
// @ts-expect-error className is not typed on WouterLink // @ts-expect-error className is not typed on WouterLink
<WouterLink className={`${active ? "active " : ""}${className}`} {...props} /> <WouterLink className={`${active ? "active " : ""}${className}`} {...props} />
@@ -1,95 +0,0 @@
import type { UseQueryOptions, UseQueryResult } from "@tanstack/react-query";
import type { RepositoryResponse } from "data";
import type { RepoQuery } from "data";
import { useClient } from "../client";
import { type EntityData, type QueryStatus, getStatus } from "./EntityContainer";
export type RenderParams<Data extends EntityData = EntityData> = {
data: Data[] | undefined;
meta: RepositoryResponse["meta"] | undefined;
status: {
fetch: QueryStatus;
};
raw: {
fetch: UseQueryResult;
};
actions: {
create(obj: any): any;
update(id: number, obj: any): any;
};
};
export type EntitiesContainerProps = {
entity: string;
query?: Partial<RepoQuery>;
queryOptions?: Partial<UseQueryOptions>;
};
export function useEntities(
entity: string,
query?: Partial<RepoQuery>,
queryOptions?: Partial<UseQueryOptions>
): RenderParams {
const client = useClient();
let data: any = null;
let meta: any = null;
const fetchQuery = client.query(queryOptions).data.entity(entity).readMany(query);
const createMutation = client.mutation.data.entity(entity).create();
const updateMutation = (id: number) => client.mutation.data.entity(entity).update(id);
if (fetchQuery?.isSuccess) {
meta = fetchQuery.data?.body.meta;
data = fetchQuery.data?.body.data;
}
function create(obj: any) {
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: <explanation>
return new Promise(async (resolve, reject) => {
await createMutation?.mutate(obj, {
onSuccess: resolve,
onError: reject
});
});
}
function update(id: number, obj: any) {
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: <explanation>
return new Promise(async (resolve, reject) => {
await updateMutation(id).mutate(obj, {
onSuccess: resolve,
onError: reject
});
});
}
return {
data,
meta,
actions: {
create,
update
// remove
},
status: {
fetch: getStatus(fetchQuery)
},
raw: {
fetch: fetchQuery
}
};
}
export function EntitiesContainer<Data extends EntityData = EntityData>({
entity,
query,
queryOptions,
children
}: EntitiesContainerProps & {
children(params: RenderParams<Data>): any;
}) {
const params = useEntities(entity, query, queryOptions);
return children(params as any);
}
export const Entities = EntitiesContainer;
-136
View File
@@ -1,136 +0,0 @@
import type { UseQueryResult } from "@tanstack/react-query";
import type { RepoQuery } from "data";
import { useClient } from "../client";
export type EntityData = Record<string, any>;
export type EntityContainerRenderParams<Data extends EntityData = EntityData> = {
data: Data | null;
client: ReturnType<typeof useClient>;
initialValues: object;
raw: {
fetch?: UseQueryResult;
};
status: {
fetch: QueryStatus;
};
actions: {
create(obj: any): any;
update(obj: any): any;
remove(): any;
};
};
export type MutationStatus = {
isLoading: boolean;
isSuccess: boolean;
isError: boolean;
};
export type QueryStatus = MutationStatus & {
isUpdating: boolean;
};
export function getStatus(query?: UseQueryResult): QueryStatus {
return {
isLoading: query ? query.isPending : false,
isUpdating: query ? !query.isInitialLoading && query.isFetching : false,
isSuccess: query ? query.isSuccess : false,
isError: query ? query.isError : false
};
}
export type EntityContainerProps = {
entity: string;
id?: number;
};
type FetchOptions = {
disabled?: boolean;
query?: Partial<Omit<RepoQuery, "where" | "limit" | "offset">>;
};
// @todo: add option to disable fetches (for form updates)
// @todo: must return a way to indicate error!
export function useEntity<Data extends EntityData = EntityData>(
entity: string,
id?: number,
options?: { fetch?: FetchOptions }
): EntityContainerRenderParams<Data> {
const client = useClient();
let data: any = null;
const fetchQuery = id
? client.query().data.entity(entity).readOne(id, options?.fetch?.query)
: undefined;
const createMutation = id ? null : client.mutation.data.entity(entity).create();
const updateMutation = id ? client.mutation.data.entity(entity).update(id) : undefined;
const deleteMutation = id ? client.mutation.data.entity(entity).delete(id) : undefined;
if (fetchQuery?.isSuccess) {
data = fetchQuery.data?.body.data;
}
const initialValues = { one: 1 };
function create(obj: any) {
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: <explanation>
return new Promise(async (resolve, reject) => {
await createMutation?.mutate(obj, {
onSuccess: resolve,
onError: reject
});
});
}
function update(obj: any) {
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: <explanation>
return new Promise(async (resolve, reject) => {
//await new Promise((r) => setTimeout(r, 4000));
await updateMutation?.mutate(obj, {
onSuccess: resolve,
onError: reject
});
});
}
function remove() {
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: <explanation>
return new Promise(async (resolve, reject) => {
//await new Promise((r) => setTimeout(r, 4000));
await deleteMutation?.mutate(undefined, {
onSuccess: resolve,
onError: reject
});
});
}
return {
data,
client,
initialValues,
actions: {
create,
update,
remove
},
status: {
fetch: getStatus(fetchQuery)
//update: getMutationStatus(updateMutation),
},
raw: {
fetch: fetchQuery
}
};
}
export function EntityContainer({
entity,
id,
children
}: EntityContainerProps & { children(params: EntityContainerRenderParams): any }) {
const params = useEntity(entity, id);
return children(params);
}
export const Entity = EntityContainer;
-2
View File
@@ -1,2 +0,0 @@
export * from "./EntitiesContainer";
export * from "./EntityContainer";
-10
View File
@@ -1,11 +1 @@
export { default as Admin, type BkndAdminProps } from "./Admin"; export { default as Admin, type BkndAdminProps } from "./Admin";
export {
EntitiesContainer,
useEntities,
type EntitiesContainerProps
} from "./container/EntitiesContainer";
export {
EntityContainer,
useEntity,
type EntityContainerProps
} from "./container/EntityContainer";
+2 -4
View File
@@ -1,14 +1,12 @@
import { useClickOutside, useDisclosure, useHotkeys, useViewportSize } from "@mantine/hooks"; import { useClickOutside, useHotkeys } from "@mantine/hooks";
import * as ScrollArea from "@radix-ui/react-scroll-area"; import * as ScrollArea from "@radix-ui/react-scroll-area";
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react"; import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
import { ucFirst } from "core/utils";
import { throttle } from "lodash-es"; import { throttle } from "lodash-es";
import { type ComponentProps, createContext, useContext, useEffect, useRef, useState } from "react"; import { type ComponentProps, useEffect, useRef, useState } from "react";
import type { IconType } from "react-icons"; import type { IconType } from "react-icons";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { IconButton } from "ui/components/buttons/IconButton"; import { IconButton } from "ui/components/buttons/IconButton";
import { AppShellProvider, useAppShell } from "ui/layouts/AppShell/use-appshell"; import { AppShellProvider, useAppShell } from "ui/layouts/AppShell/use-appshell";
import { Link } from "wouter";
import { useEvent } from "../../hooks/use-event"; import { useEvent } from "../../hooks/use-event";
export function Root({ children }) { export function Root({ children }) {
+5 -5
View File
@@ -24,7 +24,7 @@ import { useNavigate } from "ui/lib/routes";
import { useLocation } from "wouter"; import { useLocation } from "wouter";
import { NavLink } from "./AppShell"; import { NavLink } from "./AppShell";
function HeaderNavigation() { export function HeaderNavigation() {
const [location, navigate] = useLocation(); const [location, navigate] = useLocation();
const items: { const items: {
@@ -116,7 +116,7 @@ function SidebarToggler() {
export function Header({ hasSidebar = true }) { export function Header({ hasSidebar = true }) {
//const logoReturnPath = ""; //const logoReturnPath = "";
const { app } = useBknd(); const { app } = useBknd();
const logoReturnPath = app.getAdminConfig().logo_return_path ?? "/"; const { logo_return_path = "/", color_scheme = "light" } = app.getAdminConfig();
return ( return (
<header <header
@@ -124,11 +124,11 @@ export function Header({ hasSidebar = true }) {
className="flex flex-row w-full h-16 gap-2.5 border-muted border-b justify-start bg-muted/10" className="flex flex-row w-full h-16 gap-2.5 border-muted border-b justify-start bg-muted/10"
> >
<Link <Link
href={logoReturnPath} href={logo_return_path}
replace native={logo_return_path !== "/"}
className="max-h-full flex hover:bg-primary/5 link p-2.5 w-[134px] outline-none" className="max-h-full flex hover:bg-primary/5 link p-2.5 w-[134px] outline-none"
> >
<Logo /> <Logo theme={color_scheme} />
</Link> </Link>
<HeaderNavigation /> <HeaderNavigation />
<div className="flex flex-grow" /> <div className="flex flex-grow" />
+1 -1
View File
@@ -15,7 +15,7 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
// default: https://github.com/mantinedev/mantine/blob/master/src/mantine-core/src/core/MantineProvider/default-theme.ts // default: https://github.com/mantinedev/mantine/blob/master/packages/%40mantine/core/src/core/MantineProvider/default-theme.ts
export function createMantineTheme(scheme: "light" | "dark"): { export function createMantineTheme(scheme: "light" | "dark"): {
theme: ReturnType<typeof createTheme>; theme: ReturnType<typeof createTheme>;
+1 -1
View File
@@ -4,7 +4,7 @@ import {
JsonSchemaForm, JsonSchemaForm,
type JsonSchemaFormProps, type JsonSchemaFormProps,
type JsonSchemaFormRef type JsonSchemaFormRef
} from "ui/components/form/json-schema/JsonSchemaForm"; } from "ui/components/form/json-schema";
import type { ContextModalProps } from "@mantine/modals"; import type { ContextModalProps } from "@mantine/modals";
@@ -10,7 +10,7 @@ import {
} from "data"; } from "data";
import { MediaField } from "media/MediaField"; import { MediaField } from "media/MediaField";
import { type ComponentProps, Suspense } from "react"; import { type ComponentProps, Suspense } from "react";
import { useClient } from "ui/client"; import { useApi, useBaseUrl, useInvalidate } from "ui/client";
import { JsonEditor } from "ui/components/code/JsonEditor"; import { JsonEditor } from "ui/components/code/JsonEditor";
import * as Formy from "ui/components/form/Formy"; import * as Formy from "ui/components/form/Formy";
import { FieldLabel } from "ui/components/form/Formy"; import { FieldLabel } from "ui/components/form/Formy";
@@ -215,7 +215,9 @@ function EntityMediaFormField({
}) { }) {
if (!entityId) return; if (!entityId) return;
const client = useClient(); const api = useApi();
const baseUrl = useBaseUrl();
const invalidate = useInvalidate();
const value = formApi.useStore((state) => { const value = formApi.useStore((state) => {
const val = state.values[field.name]; const val = state.values[field.name];
if (!val || typeof val === "undefined") return []; if (!val || typeof val === "undefined") return [];
@@ -227,22 +229,21 @@ function EntityMediaFormField({
value.length === 0 value.length === 0
? [] ? []
: mediaItemsToFileStates(value, { : mediaItemsToFileStates(value, {
baseUrl: client.baseUrl, baseUrl: api.baseUrl,
overrides: { state: "uploaded" } overrides: { state: "uploaded" }
}); });
const getUploadInfo = useEvent(() => { const getUploadInfo = useEvent(() => {
const api = client.media().api();
return { return {
url: api.getEntityUploadUrl(entity.name, entityId, field.name), url: api.media.getEntityUploadUrl(entity.name, entityId, field.name),
headers: api.getUploadHeaders(), headers: api.media.getUploadHeaders(),
method: "POST" method: "POST"
}; };
}); });
const handleDelete = useEvent(async (file) => { const handleDelete = useEvent(async (file: FileState) => {
client.__invalidate(entity.name, entityId); invalidate((api) => api.data.readOne(entity.name, entityId));
return await client.media().deleteFile(file); return api.media.deleteFile(file.path);
}); });
return ( return (
@@ -1,14 +1,8 @@
import type { FieldApi } from "@tanstack/react-form"; import type { FieldApi } from "@tanstack/react-form";
import type { EntityData, JsonSchemaField } from "data"; import type { EntityData, JsonSchemaField } from "data";
import { Suspense, lazy } from "react";
import * as Formy from "ui/components/form/Formy"; import * as Formy from "ui/components/form/Formy";
import { FieldLabel } from "ui/components/form/Formy"; import { FieldLabel } from "ui/components/form/Formy";
import { JsonSchemaForm } from "ui/components/form/json-schema";
const JsonSchemaForm = lazy(() =>
import("ui/components/form/json-schema/JsonSchemaForm").then((m) => ({
default: m.JsonSchemaForm
}))
);
export function EntityJsonSchemaFormField({ export function EntityJsonSchemaFormField({
fieldApi, fieldApi,
@@ -34,7 +28,6 @@ export function EntityJsonSchemaFormField({
return ( return (
<Formy.Group> <Formy.Group>
<FieldLabel htmlFor={fieldApi.name} field={field} /> <FieldLabel htmlFor={fieldApi.name} field={field} />
<Suspense fallback={<div>Loading...</div>}>
<div <div
data-disabled={disabled ? 1 : undefined} data-disabled={disabled ? 1 : undefined}
className="data-[disabled]:opacity-70 data-[disabled]:pointer-events-none" className="data-[disabled]:opacity-70 data-[disabled]:pointer-events-none"
@@ -50,7 +43,6 @@ export function EntityJsonSchemaFormField({
}} }}
/> />
</div> </div>
</Suspense>
</Formy.Group> </Formy.Group>
); );
} }
@@ -4,12 +4,11 @@ import { ucFirst } from "core/utils";
import type { EntityData, RelationField } from "data"; import type { EntityData, RelationField } from "data";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { TbEye } from "react-icons/tb"; import { TbEye } from "react-icons/tb";
import { useClient } from "ui/client"; import { useEntityQuery } from "ui/client";
import { useBknd } from "ui/client/bknd"; import { useBknd } from "ui/client/bknd";
import { Button } from "ui/components/buttons/Button"; import { Button } from "ui/components/buttons/Button";
import * as Formy from "ui/components/form/Formy"; import * as Formy from "ui/components/form/Formy";
import { Popover } from "ui/components/overlay/Popover"; import { Popover } from "ui/components/overlay/Popover";
import { useEntities } from "ui/container";
import { routes } from "ui/lib/routes"; import { routes } from "ui/lib/routes";
import { useLocation } from "wouter"; import { useLocation } from "wouter";
import { EntityTable } from "../EntityTable"; import { EntityTable } from "../EntityTable";
@@ -31,25 +30,20 @@ export function EntityRelationalFormField({
const { app } = useBknd(); const { app } = useBknd();
const entity = app.entity(field.target())!; const entity = app.entity(field.target())!;
const [query, setQuery] = useState<any>({ limit: 10, page: 1, perPage: 10 }); const [query, setQuery] = useState<any>({ limit: 10, page: 1, perPage: 10 });
const [location, navigate] = useLocation(); const [, navigate] = useLocation();
const ref = useRef<any>(null); const ref = useRef<any>(null);
const client = useClient(); const $q = useEntityQuery(field.target(), undefined, {
const container = useEntities(
field.target(),
{
limit: query.limit, limit: query.limit,
offset: (query.page - 1) * query.limit offset: (query.page - 1) * query.limit
//select: entity.getSelect(undefined, "form") });
},
{ enabled: true }
);
const [_value, _setValue] = useState<{ id: number | undefined; [key: string]: any }>(); const [_value, _setValue] = useState<{ id: number | undefined; [key: string]: any }>();
const referenceField = data?.[field.reference()]; const referenceField = data?.[field.reference()];
const relationalField = data?.[field.name]; const relationalField = data?.[field.name];
useEffect(() => { useEffect(() => {
_setValue(data?.[field.reference()]); const value = data?.[field.reference()];
_setValue(value);
}, [referenceField]); }, [referenceField]);
useEffect(() => { useEffect(() => {
@@ -57,62 +51,40 @@ export function EntityRelationalFormField({
const rel_value = field.target(); const rel_value = field.target();
if (!rel_value || !relationalField) return; if (!rel_value || !relationalField) return;
console.log("-- need to fetch", field.target(), relationalField); const fetched = await $q.api.readOne(field.target(), relationalField);
const fetched = await client.api.data.readOne(field.target(), relationalField); if (fetched.ok && fetched.data) {
if (fetched.res.ok && fetched.data) {
_setValue(fetched.data as any); _setValue(fetched.data as any);
} }
console.log("-- fetched", fetched);
console.log("relation", {
referenceField,
relationalField,
data,
field,
entity
});
})(); })();
}, [relationalField]); }, [relationalField]);
/*const initialValue: { id: number | undefined; [key: string]: any } = data?.[
field.reference()
] ?? {
id: data?.[field.name],
};*/
function handleViewItem(e: React.MouseEvent<HTMLButtonElement>) { function handleViewItem(e: React.MouseEvent<HTMLButtonElement>) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
console.log("yo");
if (_value) { if (_value) {
navigate(routes.data.entity.edit(entity.name, _value.id as any)); navigate(routes.data.entity.edit(entity.name, _value.id as any));
} }
} }
/*console.log(
"relationfield:data",
{ _value, initialValue },
data,
field.reference(),
entity,
//container.entity,
//data[field.reference()],
data?.[field.name],
field,
);*/
// fix missing value on fields that are required // fix missing value on fields that are required
useEffect(() => { useEffect(() => {
if (field.isRequired() && !fieldApi.state.value) { if (field.isRequired() && !fieldApi.state.value) {
fieldApi.setValue(container.data?.[0]?.id); const firstValue = $q.data?.[0];
if (!firstValue) return;
console.warn("setting first value because field is required", field.name, firstValue.id);
fieldApi.setValue(firstValue.id);
_setValue(firstValue as any);
} }
}, [container.data]); }, [$q.data]);
const fetching = $q.isLoading || $q.isValidating;
return ( return (
<Formy.Group> <Formy.Group>
<Formy.Label htmlFor={fieldApi.name}>{field.getLabel()}</Formy.Label> <Formy.Label htmlFor={fieldApi.name}>{field.getLabel()}</Formy.Label>
<div <div
data-disabled={!Array.isArray(container.data) || disabled ? 1 : undefined} data-disabled={fetching || disabled ? 1 : undefined}
className="data-[disabled]:opacity-70 data-[disabled]:pointer-events-none" className="data-[disabled]:opacity-70 data-[disabled]:pointer-events-none"
> >
<Popover <Popover
@@ -120,7 +92,7 @@ export function EntityRelationalFormField({
className="" className=""
target={({ toggle }) => ( target={({ toggle }) => (
<PopoverTable <PopoverTable
container={container} container={$q.data}
entity={entity} entity={entity}
query={query} query={query}
toggle={toggle} toggle={toggle}
@@ -198,28 +170,6 @@ export function EntityRelationalFormField({
onChange={console.log} onChange={console.log}
tabIndex={-1} tabIndex={-1}
/> />
{/*<Formy.Select
ref={ref}
name={fieldApi.name}
id={fieldApi.name}
value={fieldApi.state.value}
data-value={fieldApi.state.value}
onBlur={fieldApi.handleBlur}
onChange={handleUpdate}
disabled={!Array.isArray(container.data)}
>
{container.data ? (
<>
{emptyOption}
{!field.isRequired() && emptyOption}
{container.data?.map(renderRow)}
</>
) : (
<option value={undefined} disabled>
Loading...
</option>
)}
</Formy.Select>*/}
</Formy.Group> </Formy.Group>
); );
} }
@@ -1,5 +1,5 @@
import { typeboxResolver } from "@hookform/resolvers/typebox"; import { typeboxResolver } from "@hookform/resolvers/typebox";
import type { Static } from "core/utils"; import { type Static, objectCleanEmpty } from "core/utils";
import { type TAppDataEntityFields, entitiesSchema } from "data/data-schema"; import { type TAppDataEntityFields, entitiesSchema } from "data/data-schema";
import { mergeWith } from "lodash-es"; import { mergeWith } from "lodash-es";
import { useRef } from "react"; import { useRef } from "react";
@@ -20,13 +20,15 @@ export function StepEntityFields() {
const entity = state.entities?.create?.[0]!; const entity = state.entities?.create?.[0]!;
const defaultFields = { id: { type: "primary", name: "id" } } as const; const defaultFields = { id: { type: "primary", name: "id" } } as const;
const ref = useRef<EntityFieldsFormRef>(null); const ref = useRef<EntityFieldsFormRef>(null);
const initial = mergeWith(entity, { const initial = objectCleanEmpty(
mergeWith(entity, {
fields: defaultFields, fields: defaultFields,
config: { config: {
sort_field: "id", sort_field: "id",
sort_dir: "asc" sort_dir: "asc"
} }
}); })
);
const { const {
control, control,
formState: { isValid, errors }, formState: { isValid, errors },
@@ -19,18 +19,16 @@ export function useEntityForm({
// @todo: check if virtual must be filtered // @todo: check if virtual must be filtered
const fields = entity.getFillableFields(action, true); const fields = entity.getFillableFields(action, true);
console.log("useEntityForm:data", data);
// filter defaultValues to only contain fillable fields // filter defaultValues to only contain fillable fields
const defaultValues = getDefaultValues(fields, data); const defaultValues = getDefaultValues(fields, data);
console.log("useEntityForm:defaultValues", data); //console.log("useEntityForm", { data, defaultValues });
const Form = useForm({ const Form = useForm({
defaultValues, defaultValues,
validators: { validators: {
onSubmitAsync: async ({ value }): Promise<any> => { onSubmitAsync: async ({ value }): Promise<any> => {
try { try {
console.log("validating", value, entity.isValidData(value, action)); //console.log("validating", value, entity.isValidData(value, action));
entity.isValidData(value, action, true); entity.isValidData(value, action, true);
return undefined; return undefined;
} catch (e) { } catch (e) {
@@ -40,7 +38,7 @@ export function useEntityForm({
} }
}, },
onSubmit: async ({ value, formApi }) => { onSubmit: async ({ value, formApi }) => {
console.log("onSubmit", value); //console.log("onSubmit", value);
if (!entity.isValidData(value, action)) { if (!entity.isValidData(value, action)) {
console.error("invalid data", value); console.error("invalid data", value);
return; return;
@@ -49,7 +47,7 @@ export function useEntityForm({
if (!data) return; if (!data) return;
const changeSet = getChangeSet(action, value, data, fields); const changeSet = getChangeSet(action, value, data, fields);
console.log("changesSet", action, changeSet); //console.log("changesSet", action, changeSet, { data });
// only submit change set if there were changes // only submit change set if there were changes
await onSubmitted?.(Object.keys(changeSet).length === 0 ? undefined : changeSet); await onSubmitted?.(Object.keys(changeSet).length === 0 ? undefined : changeSet);
@@ -1,14 +1,9 @@
import { Handle, type Node, type NodeProps, Position } from "@xyflow/react"; import { Handle, type Node, type NodeProps, Position } from "@xyflow/react";
import { Const, Type, transformObject } from "core/utils"; import { Const, Type, transformObject } from "core/utils";
import { type TaskRenderProps, type Trigger, TriggerMap } from "flows"; import { type Trigger, TriggerMap } from "flows";
import { Suspense, lazy } from "react";
import type { IconType } from "react-icons"; import type { IconType } from "react-icons";
import { TbCircleLetterT } from "react-icons/tb"; import { TbCircleLetterT } from "react-icons/tb";
const JsonSchemaForm = lazy(() => import { JsonSchemaForm } from "ui/components/form/json-schema";
import("ui/components/form/json-schema/JsonSchemaForm").then((m) => ({
default: m.JsonSchemaForm
}))
);
export type TaskComponentProps = NodeProps<Node<{ trigger: Trigger }>> & { export type TaskComponentProps = NodeProps<Node<{ trigger: Trigger }>> & {
Icon?: IconType; Icon?: IconType;
@@ -48,7 +43,6 @@ export function TriggerComponent({
</div> </div>
<div className="w-full h-px bg-primary/10" /> <div className="w-full h-px bg-primary/10" />
<div className="flex flex-col gap-2 px-3 py-2"> <div className="flex flex-col gap-2 px-3 py-2">
<Suspense fallback={<div>Loading...</div>}>
<JsonSchemaForm <JsonSchemaForm
className="legacy" className="legacy"
schema={Type.Union(triggerSchemas)} schema={Type.Union(triggerSchemas)}
@@ -58,7 +52,6 @@ export function TriggerComponent({
/*uiSchema={uiSchema}*/ /*uiSchema={uiSchema}*/
/*fields={{ template: TemplateField }}*/ /*fields={{ template: TemplateField }}*/
/> />
</Suspense>
</div> </div>
</div> </div>
<Handle <Handle
@@ -1,12 +1,5 @@
import type { Task } from "flows"; import type { Task } from "flows";
import { Suspense, lazy } from "react"; import { JsonSchemaForm } from "ui/components/form/json-schema";
import { TemplateField } from "./TemplateField";
const JsonSchemaForm = lazy(() =>
import("ui/components/form/json-schema/JsonSchemaForm").then((m) => ({
default: m.JsonSchemaForm
}))
);
export type TaskFormProps = { export type TaskFormProps = {
task: Task; task: Task;
@@ -26,7 +19,6 @@ export function TaskForm({ task, onChange, ...props }: TaskFormProps) {
//console.log("uiSchema", uiSchema); //console.log("uiSchema", uiSchema);
return ( return (
<Suspense fallback={<div>Loading...</div>}>
<JsonSchemaForm <JsonSchemaForm
className="legacy" className="legacy"
schema={schema} schema={schema}
@@ -36,6 +28,5 @@ export function TaskForm({ task, onChange, ...props }: TaskFormProps) {
/*uiSchema={uiSchema}*/ /*uiSchema={uiSchema}*/
/*fields={{ template: TemplateField }}*/ /*fields={{ template: TemplateField }}*/
/> />
</Suspense>
); );
} }
+5 -11
View File
@@ -1,25 +1,19 @@
import { useClient } from "ui/client"; import { useApiQuery } from "ui/client";
import { useBknd } from "ui/client/bknd"; import { useBknd } from "ui/client/bknd";
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth"; import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
import { ButtonLink, type ButtonLinkProps } from "ui/components/buttons/Button";
import { Alert } from "ui/components/display/Alert"; import { Alert } from "ui/components/display/Alert";
import * as AppShell from "ui/layouts/AppShell/AppShell";
import { routes } from "ui/lib/routes"; import { routes } from "ui/lib/routes";
import {
Button,
ButtonLink,
type ButtonLinkProps,
type ButtonProps
} from "../../components/buttons/Button";
import * as AppShell from "../../layouts/AppShell/AppShell";
export function AuthIndex() { export function AuthIndex() {
const client = useClient();
const { app } = useBknd(); const { app } = useBknd();
const { const {
config: { roles, strategies, entity_name, enabled } config: { roles, strategies, entity_name, enabled }
} = useBkndAuth(); } = useBkndAuth();
const users_entity = entity_name; const users_entity = entity_name;
const query = client.query().data.entity("users").count(); const $q = useApiQuery((api) => api.data.count(users_entity));
const usersTotal = query.data?.body.count ?? 0; const usersTotal = $q.data?.count ?? 0;
const rolesTotal = Object.keys(roles ?? {}).length ?? 0; const rolesTotal = Object.keys(roles ?? {}).length ?? 0;
const strategiesTotal = Object.keys(strategies ?? {}).length ?? 0; const strategiesTotal = Object.keys(strategies ?? {}).length ?? 0;

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