Compare commits

...

61 Commits

Author SHA1 Message Date
dswbx 35959aaf6d hotfix release 3.0.2 2024-12-11 18:38:53 +01:00
dswbx 56fb779f99 Merge pull request #29 from bknd-io/fix/admin-ui-fixes-2
admin ui: fix initial entity state in create modal
2024-12-11 18:37:46 +01:00
dswbx 134fbd6d34 admin ui: fix initial entity state in create modal 2024-12-11 18:36:42 +01:00
dswbx d2c75b1605 Merge pull request #26 from bknd-io/fix/mm-sync
fix: only sync config table once certain it has to happen
2024-12-11 18:29:22 +01:00
dswbx a474e3fe3a Merge pull request #28 from bknd-io/fix/admin-ui-fixes
Admin UI fixes
2024-12-11 18:29:05 +01:00
dswbx d81b3acb94 mm: disable console as it might error trying to fetch config 2024-12-11 18:25:31 +01:00
dswbx 2ca66f4fc9 admin ui: fix "enter" on create new modal (inside fields) 2024-12-11 17:46:15 +01:00
dswbx a8bbb6e760 admin ui: fix data erd layout 2024-12-11 17:30:33 +01:00
dswbx 2395d7fe97 fix: only sync config table once certain it has to happen 2024-12-11 15:51:00 +01:00
dswbx 10d606f8e9 docs: added docker instructions 2024-12-10 09:57:05 +01:00
dswbx 13a88be8d1 Merge pull request #23 from bknd-io/release/0.2.3
Release 0.3
2024-12-10 08:52:20 +01:00
dswbx a5264e22ee bump version 0.3.0 2024-12-10 08:51:54 +01:00
dswbx 847b08b505 fix createApp() fn doesn't require config, fix admin controller main entry 2024-12-10 08:44:50 +01:00
dswbx 86a7bee3d9 fix prototype tests 2024-12-07 19:35:34 +01:00
dswbx 3932396084 fixing imports and ssr support + updated remix/nextjs/astro examples 2024-12-07 19:31:12 +01:00
dswbx 94cc4042d3 small refactorings and cleanups, improved bun/node adapter, updated docs 2024-12-07 18:55:02 +01:00
dswbx 154703f873 added treeshake option to build + finalized package json (for now) 2024-12-07 17:19:27 +01:00
dswbx 71dbfc5469 moved main pkg back to deps to reduce tarball size + removed zod + cleanup old code + fixed tests 2024-12-07 10:11:26 +01:00
dswbx e5924b33e5 exclude metafiles in npm package to reduce tarball size 2024-12-07 09:46:30 +01:00
dswbx c25abd4a48 Merge remote-tracking branch 'origin/refactor/admin-basepath' into release/0.2.3 2024-12-07 09:30:32 +01:00
dswbx 7c58f20cca Merge remote-tracking branch 'origin/refactor/ext-deps' into release/0.2.3 2024-12-07 09:30:06 +01:00
dswbx ef18d8d955 Merge remote-tracking branch 'origin/misc/docker' into release/0.2.3 2024-12-07 09:29:48 +01:00
dswbx c7c6aa7df8 Merge remote-tracking branch 'origin/refactor/module-manager' into release/0.2.3 2024-12-07 09:29:31 +01:00
dswbx 060af0a1e3 fix react peer dependency version constraint (to prevent ERESOLVE warns) 2024-12-07 09:03:31 +01:00
dswbx 44a427de67 moved almost all deps to dev to speed up installs, updated nextjs docs 2024-12-07 08:48:48 +01:00
dswbx aa75355a69 use admin routes registering basepath from registration method instead of configuration
Signed-off-by: dswbx <dennis.senn@gmx.ch>
2024-12-07 06:34:40 +01:00
dswbx 110c7681b7 externalize libsql dependency to let installing env install the correct dep
Signed-off-by: dswbx <dennis.senn@gmx.ch>
2024-12-07 06:29:29 +01:00
dswbx ba517feab5 fix typings for initial partial config
Signed-off-by: dswbx <dennis.senn@gmx.ch>
2024-12-05 20:48:49 +01:00
dswbx 7e990feb99 added new diffing method to module manager
Signed-off-by: dswbx <dennis.senn@gmx.ch>
2024-12-05 20:23:51 +01:00
dswbx 77a6b6e7f5 fix repository's findOne to allow offset and sort – fixes module manager's config retrieval 2024-12-05 10:37:05 +01:00
dswbx a3348122e6 module manager: use json schema field for additional validation 2024-12-05 09:50:25 +01:00
dswbx 3757157a06 module manager: switched config table to use entity 2024-12-05 09:23:34 +01:00
dswbx 6c2f7b32e5 module manager: allow initial config without config as fallback 2024-12-05 08:12:46 +01:00
dswbx 067213ff60 docs: updated astro to add external example + added hint for astro db issues on v5 2024-12-04 15:54:33 +01:00
dswbx a4079804c2 release 0.2.2 2024-12-04 14:49:55 +01:00
dswbx 2bd4b720bb Merge pull request #18 from bknd-io/17-cannot-find-module-node_moduleslodash-esget
fix: lodash-es/* imports causing crash on astro
2024-12-04 14:49:19 +01:00
dswbx f3619bee26 fix lodash-es/* imports causing crash on astro 2024-12-04 14:47:52 +01:00
dswbx c54455870c fix: docs logo 2024-12-03 19:34:14 +01:00
dswbx 20477a655e update docs to navigate to web on logo click 2024-12-03 19:30:16 +01:00
dswbx 1497470d33 bump version for patch release 0.2.1 2024-12-03 17:21:06 +01:00
dswbx abe38fe69d Merge pull request #12 from bknd-io/chores/remove-deps
Remove unusued deps and fixing auth tests
2024-12-03 17:18:18 +01:00
dswbx 665c41f051 Merge pull request #16 from bknd-io/chores/export-data-prototype
export data prototype
2024-12-03 17:17:53 +01:00
dswbx 0184f47a41 added prototype to the list of exports from "bknd/data" 2024-12-03 17:15:53 +01:00
dswbx 5374afc9c8 remove the quick theme switcher if admin settings are overridden 2024-12-03 17:09:27 +01:00
dswbx d6978f9873 fix auth tests 2024-12-03 09:16:00 +01:00
dswbx 3c5bd95988 removed unused deps 2024-12-03 09:15:39 +01:00
dswbx b3e02394ac added an initial docker image 2024-12-03 08:51:30 +01:00
dswbx 91470d530f bump version for release 0.2.0 2024-12-01 09:02:32 +01:00
dswbx 0c8f3cd22a Merge pull request #9 from bknd-io/feat/astro-adapter-2
improved astro adapter (serving api) + added documentation
2024-12-01 09:00:40 +01:00
dswbx feeb13c053 improved astro adapter (serving api) + added documentation 2024-12-01 08:58:08 +01:00
dswbx b55fdd7516 fix invalid discord invite + added missing updates from astro adapter 2024-11-30 07:20:17 +01:00
dswbx 894a90fca9 Merge pull request #5 from bknd-io/feat/astro-adapter
Feat: Astro Adapter
2024-11-29 21:23:26 +01:00
dswbx 582dbd4272 added minimal astro adapter + improved the example 2024-11-29 21:21:59 +01:00
dswbx 6eb0d2242f Merge remote-tracking branch 'origin/main' into feat/astro-adapter 2024-11-29 20:14:31 +01:00
dswbx 85c9f35860 Merge pull request #8 from bknd-io/fix/auth-cookie-middleware
hotfix: cookies may not be added to static assets
2024-11-28 18:08:03 +01:00
dswbx 509d6826f0 hotfix: cookies may not be added to static assets 2024-11-28 18:07:29 +01:00
dswbx 4693be5615 added db for astro 2024-11-28 11:22:02 +01:00
dswbx bdc6eb55bf added bknd admin config override to make it easier to include <Admin /> 2024-11-28 11:15:07 +01:00
dswbx 54b38401d8 added fully working astro example 2024-11-28 10:22:18 +01:00
dswbx 49c2a7b4db Merge remote-tracking branch 'origin/main' into feat/astro-adapter 2024-11-28 10:12:37 +01:00
dswbx 6718419d41 prepare admin for astro: inject clientprovider props directly from admin component 2024-11-28 10:04:14 +01:00
123 changed files with 2395 additions and 1340 deletions
+4 -1
View File
@@ -17,10 +17,13 @@ packages/media/.env
**/*/vite.config.ts.timestamp*
.history
**/*/.db/*
**/*/*.db
**/*/*.db-shm
**/*/*.db-wal
**/*/.tmp
.npmrc
/.verdaccio
.idea
.vscode
.git_old
.git_old
docker/tmp
+124 -66
View File
@@ -1,7 +1,8 @@
import { describe, expect, test } from "bun:test";
import { mark, stripMark } from "../src/core/utils";
import { ModuleManager } from "../src/modules/ModuleManager";
import { CURRENT_VERSION, TABLE_NAME, migrateSchema } from "../src/modules/migrations";
import { entity, text } from "../src/data";
import { ModuleManager, getDefaultConfig } from "../src/modules/ModuleManager";
import { CURRENT_VERSION, TABLE_NAME } from "../src/modules/migrations";
import { getDummyConnection } from "./helper";
describe("ModuleManager", async () => {
@@ -29,21 +30,68 @@ describe("ModuleManager", async () => {
const mm = new ModuleManager(c.dummyConnection);
await mm.build();
const version = mm.version();
const json = mm.configs();
const configs = mm.configs();
const json = stripMark({
...configs,
data: {
...configs.data,
basepath: "/api/data2",
entities: {
test: entity("test", {
content: text()
}).toJSON()
}
}
});
//const { version, ...json } = mm.toJSON() as any;
const c2 = getDummyConnection();
const db = c2.dummyConnection.kysely;
await migrateSchema(CURRENT_VERSION, { db });
const mm2 = new ModuleManager(c2.dummyConnection, { initial: { version, ...json } });
await mm2.syncConfigTable();
await db
.updateTable(TABLE_NAME)
.set({ json: JSON.stringify(json), version: CURRENT_VERSION })
.insertInto(TABLE_NAME)
.values({ type: "config", json: JSON.stringify(json), version: CURRENT_VERSION })
.execute();
const mm2 = new ModuleManager(c2.dummyConnection, { initial: { version, ...json } });
await mm2.build();
expect(json).toEqual(mm2.configs());
expect(json).toEqual(stripMark(mm2.configs()));
});
test("s3.1: (fetch) config given, table exists, version matches", async () => {
const configs = getDefaultConfig();
const json = {
...configs,
data: {
...configs.data,
basepath: "/api/data2",
entities: {
test: entity("test", {
content: text()
}).toJSON()
}
}
};
//const { version, ...json } = mm.toJSON() as any;
const { dummyConnection } = getDummyConnection();
const db = dummyConnection.kysely;
const mm2 = new ModuleManager(dummyConnection);
await mm2.syncConfigTable();
// assume an initial version
await db.insertInto(TABLE_NAME).values({ type: "config", json: null, version: 1 }).execute();
await db
.insertInto(TABLE_NAME)
.values({ type: "config", json: JSON.stringify(json), version: CURRENT_VERSION })
.execute();
await mm2.build();
expect(stripMark(json)).toEqual(stripMark(mm2.configs()));
expect(mm2.configs().data.entities.test).toBeDefined();
expect(mm2.configs().data.entities.test.fields.content).toBeDefined();
expect(mm2.get("data").toJSON().entities.test.fields.content).toBeDefined();
});
test("s4: config given, table exists, version outdated, migrate", async () => {
@@ -52,21 +100,19 @@ describe("ModuleManager", async () => {
await mm.build();
const version = mm.version();
const json = mm.configs();
//const { version, ...json } = mm.toJSON() as any;
const c2 = getDummyConnection();
const db = c2.dummyConnection.kysely;
console.log("here2");
await migrateSchema(CURRENT_VERSION, { db });
await db
.updateTable(TABLE_NAME)
.set({ json: JSON.stringify(json), version: CURRENT_VERSION - 1 })
.execute();
const mm2 = new ModuleManager(c2.dummyConnection, {
initial: { version: version - 1, ...json }
});
console.log("here3");
await mm2.syncConfigTable();
await db
.insertInto(TABLE_NAME)
.values({ json: JSON.stringify(json), type: "config", version: CURRENT_VERSION - 1 })
.execute();
await mm2.build();
});
@@ -80,15 +126,15 @@ describe("ModuleManager", async () => {
const c2 = getDummyConnection();
const db = c2.dummyConnection.kysely;
await migrateSchema(CURRENT_VERSION, { db });
await db
.updateTable(TABLE_NAME)
.set({ json: JSON.stringify(json), version: CURRENT_VERSION })
.execute();
const mm2 = new ModuleManager(c2.dummyConnection, {
initial: { version: version - 1, ...json }
});
await mm2.syncConfigTable();
await db
.insertInto(TABLE_NAME)
.values({ type: "config", json: JSON.stringify(json), version: CURRENT_VERSION })
.execute();
expect(mm2.build()).rejects.toThrow(/version.*do not match/);
});
@@ -102,7 +148,9 @@ describe("ModuleManager", async () => {
const c2 = getDummyConnection();
const db = c2.dummyConnection.kysely;
await migrateSchema(CURRENT_VERSION, { db });
const mm2 = new ModuleManager(c2.dummyConnection);
await mm2.syncConfigTable();
const config = {
...json,
@@ -112,12 +160,11 @@ describe("ModuleManager", async () => {
}
};
await db
.updateTable(TABLE_NAME)
.set({ json: JSON.stringify(config), version: CURRENT_VERSION })
.insertInto(TABLE_NAME)
.values({ type: "config", json: JSON.stringify(config), version: CURRENT_VERSION })
.execute();
// run without config given
const mm2 = new ModuleManager(c2.dummyConnection);
await mm2.build();
expect(mm2.configs().data.basepath).toBe("/api/data2");
@@ -148,50 +195,61 @@ describe("ModuleManager", async () => {
});
});
// @todo: check what happens here
/*test("blank app, modify deep config", async () => {
test("partial config given", async () => {
const { dummyConnection } = getDummyConnection();
const mm = new ModuleManager(dummyConnection);
const partial = {
auth: {
enabled: true
}
};
const mm = new ModuleManager(dummyConnection, {
initial: partial
});
await mm.build();
/!* await mm
.get("data")
.schema()
.patch("entities.test", {
fields: {
content: {
type: "text"
}
expect(mm.version()).toBe(CURRENT_VERSION);
expect(mm.built()).toBe(true);
expect(mm.configs().auth.enabled).toBe(true);
expect(mm.configs().data.entities.users).toBeDefined();
});
test("partial config given, but db version exists", async () => {
const c = getDummyConnection();
const mm = new ModuleManager(c.dummyConnection);
await mm.build();
const json = mm.configs();
const c2 = getDummyConnection();
const db = c2.dummyConnection.kysely;
const mm2 = new ModuleManager(c2.dummyConnection, {
initial: {
auth: {
basepath: "/shouldnt/take/this"
}
});
await mm.build();
}
});
await mm2.syncConfigTable();
const payload = {
...json,
auth: {
...json.auth,
enabled: true,
basepath: "/api/auth2"
}
};
await db
.insertInto(TABLE_NAME)
.values({
type: "config",
json: JSON.stringify(payload),
version: CURRENT_VERSION
})
.execute();
await mm2.build();
expect(mm2.configs().auth.basepath).toBe("/api/auth2");
});
expect(mm.configs().data.entities?.users?.fields?.email.type).toBe("text");
expect(
mm.get("data").schema().patch("desc", "entities.users.config.sort_dir")
).rejects.toThrow();
await mm.build();*!/
expect(mm.configs().data.entities?.users?.fields?.email.type).toBe("text");
console.log("here", mm.configs());
await mm
.get("data")
.schema()
.patch("entities.users", { config: { sort_dir: "desc" } });
await mm.build();
expect(mm.toJSON());
//console.log(_jsonp(mm.toJSON().data));
/!*expect(mm.configs().data.entities!.test!.fields!.content.type).toBe("text");
expect(mm.configs().data.entities!.users!.config!.sort_dir).toBe("desc");*!/
});*/
/*test("accessing modules", async () => {
const { dummyConnection } = getDummyConnection();
const mm = new ModuleManager(dummyConnection);
//mm.get("auth").mutate().set({});
});*/
// @todo: add tests for migrations (check "backup" and new version)
});
-54
View File
@@ -1,54 +0,0 @@
import { describe, expect, it, test } from "bun:test";
import { Endpoint } from "../../src/core";
import { mockFetch2, unmockFetch } from "./helper";
const testC: any = {
json: (res: any) => Response.json(res)
};
const testNext = async () => {};
describe("Endpoint", async () => {
it("behaves as expected", async () => {
const endpoint = new Endpoint("GET", "/test", async () => {
return { hello: "test" };
});
expect(endpoint.method).toBe("GET");
expect(endpoint.path).toBe("/test");
const handler = endpoint.toHandler();
const response = await handler(testC, testNext);
expect(response.ok).toBe(true);
expect(await response.json()).toEqual({ hello: "test" });
});
it("can be $request(ed)", async () => {
const obj = { hello: "test" };
const baseUrl = "https://local.com:123";
const endpoint = Endpoint.get("/test", async () => obj);
mockFetch2(async (input: RequestInfo, init: RequestInit) => {
expect(input).toBe(`${baseUrl}/test`);
return new Response(JSON.stringify(obj), { status: 200 });
});
const response = await endpoint.$request({}, baseUrl);
expect(response).toEqual({
status: 200,
ok: true,
response: obj
});
unmockFetch();
});
it("resolves helper functions", async () => {
const params = ["/test", () => ({ hello: "test" })];
["get", "post", "patch", "put", "delete"].forEach((method) => {
const endpoint = Endpoint[method](...params);
expect(endpoint.method).toBe(method.toUpperCase());
expect(endpoint.path).toBe(params[0]);
});
});
});
+443
View File
@@ -0,0 +1,443 @@
import { describe, expect, it, test } from "bun:test";
import { apply, diff, revert } from "../../../src/core/object/diff";
describe("diff", () => {
it("should detect added properties", () => {
const oldObj = { a: 1 };
const newObj = { a: 1, b: 2 };
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "a",
p: ["b"],
o: undefined,
n: 2
}
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should detect removed properties", () => {
const oldObj = { a: 1, b: 2 };
const newObj = { a: 1 };
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "r",
p: ["b"],
o: 2,
n: undefined
}
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should detect edited properties", () => {
const oldObj = { a: 1 };
const newObj = { a: 2 };
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "e",
p: ["a"],
o: 1,
n: 2
}
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should detect changes in nested objects", () => {
const oldObj = { a: { b: 1 } };
const newObj = { a: { b: 2 } };
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "e",
p: ["a", "b"],
o: 1,
n: 2
}
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should detect changes in arrays", () => {
const oldObj = { a: [1, 2, 3] };
const newObj = { a: [1, 4, 3, 5] };
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "e",
p: ["a", 1],
o: 2,
n: 4
},
{
t: "a",
p: ["a", 3],
o: undefined,
n: 5
}
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should handle adding elements to an empty array", () => {
const oldObj = { a: [] };
const newObj = { a: [1, 2, 3] };
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "a",
p: ["a", 0],
o: undefined,
n: 1
},
{
t: "a",
p: ["a", 1],
o: undefined,
n: 2
},
{
t: "a",
p: ["a", 2],
o: undefined,
n: 3
}
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should handle removing elements from an array", () => {
const oldObj = { a: [1, 2, 3] };
const newObj = { a: [1, 3] };
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "e",
p: ["a", 1],
o: 2,
n: 3
},
{
t: "r",
p: ["a", 2],
o: 3,
n: undefined
}
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should handle complex nested changes", () => {
const oldObj = {
a: {
b: [1, 2, { c: 3 }]
}
};
const newObj = {
a: {
b: [1, 2, { c: 4 }, 5]
}
};
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "e",
p: ["a", "b", 2, "c"],
o: 3,
n: 4
},
{
t: "a",
p: ["a", "b", 3],
o: undefined,
n: 5
}
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should handle undefined and null values", () => {
const oldObj = { a: undefined, b: null };
const newObj = { a: null, b: undefined };
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "e",
p: ["a"],
o: undefined,
n: null
},
{
t: "e",
p: ["b"],
o: null,
n: undefined
}
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should handle type changes", () => {
const oldObj = { a: 1 };
const newObj = { a: "1" };
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "e",
p: ["a"],
o: 1,
n: "1"
}
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should handle properties added and removed simultaneously", () => {
const oldObj = { a: 1, b: 2 };
const newObj = { a: 1, c: 3 };
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "r",
p: ["b"],
o: 2,
n: undefined
},
{
t: "a",
p: ["c"],
o: undefined,
n: 3
}
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should handle arrays replaced with objects", () => {
const oldObj = { a: [1, 2, 3] };
const newObj = { a: { b: 4 } };
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "e",
p: ["a"],
o: [1, 2, 3],
n: { b: 4 }
}
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should handle objects replaced with primitives", () => {
const oldObj = { a: { b: 1 } };
const newObj = { a: 2 };
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "e",
p: ["a"],
o: { b: 1 },
n: 2
}
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should handle root object changes", () => {
const oldObj = { a: 1 };
const newObj = { b: 2 };
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "r",
p: ["a"],
o: 1,
n: undefined
},
{
t: "a",
p: ["b"],
o: undefined,
n: 2
}
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should handle identical objects", () => {
const oldObj = { a: 1, b: { c: 2 } };
const newObj = { a: 1, b: { c: 2 } };
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should handle empty objects", () => {
const oldObj = {};
const newObj = {};
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should handle changes from empty object to non-empty object", () => {
const oldObj = {};
const newObj = { a: 1 };
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "a",
p: ["a"],
o: undefined,
n: 1
}
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
it("should handle changes from non-empty object to empty object", () => {
const oldObj = { a: 1 };
const newObj = {};
const diffs = diff(oldObj, newObj);
expect(diffs).toEqual([
{
t: "r",
p: ["a"],
o: 1,
n: undefined
}
]);
const appliedObj = apply(oldObj, diffs);
expect(appliedObj).toEqual(newObj);
const revertedObj = revert(newObj, diffs);
expect(revertedObj).toEqual(oldObj);
});
});
@@ -1,6 +1,5 @@
import { describe, expect, test } from "bun:test";
import { type ObjectQuery, convert, validate } from "../../../src/core/object/query/object-query";
import { deprecated__whereRepoSchema } from "../../../src/data";
describe("object-query", () => {
const q: ObjectQuery = { name: "Michael" };
@@ -8,19 +7,6 @@ describe("object-query", () => {
const q3: ObjectQuery = { name: "Michael", age: { $gt: 18 } };
const bag = { q, q2, q3 };
test("translates into legacy", async () => {
for (const [key, value] of Object.entries(bag)) {
const obj = convert(value);
try {
const parsed = deprecated__whereRepoSchema.parse(obj);
expect(parsed).toBeDefined();
} catch (e) {
console.log("errored", { obj, value });
console.error(key, e);
}
}
});
test("validates", async () => {
const converted = convert({
name: { $eq: "ch" }
+35 -2
View File
@@ -132,14 +132,47 @@ describe("Mutator simple", async () => {
const data = (await em.repository(items).findMany()).data;
//console.log(data);
await em.mutator(items).deleteMany({ label: "delete" });
await em.mutator(items).deleteWhere({ label: "delete" });
expect((await em.repository(items).findMany()).data.length).toBe(data.length - 2);
//console.log((await em.repository(items).findMany()).data);
await em.mutator(items).deleteMany();
await em.mutator(items).deleteWhere();
expect((await em.repository(items).findMany()).data.length).toBe(0);
//expect(res.data.count).toBe(0);
});
test("updateMany", async () => {
await em.mutator(items).insertOne({ label: "update", count: 1 });
await em.mutator(items).insertOne({ label: "update too", count: 1 });
await em.mutator(items).insertOne({ label: "keep" });
// expect no update
await em.mutator(items).updateWhere(
{ count: 2 },
{
count: 10
}
);
expect((await em.repository(items).findMany()).data).toEqual([
{ id: 6, label: "update", count: 1 },
{ id: 7, label: "update too", count: 1 },
{ id: 8, label: "keep", count: 0 }
]);
// expect 2 to be updated
await em.mutator(items).updateWhere(
{ count: 2 },
{
count: 1
}
);
expect((await em.repository(items).findMany()).data).toEqual([
{ id: 6, label: "update", count: 2 },
{ id: 7, label: "update too", count: 2 },
{ id: 8, label: "keep", count: 0 }
]);
});
});
+3 -1
View File
@@ -1,5 +1,4 @@
import { describe, expect, test } from "bun:test";
import { MediaField } from "../../src";
import {
BooleanField,
DateField,
@@ -30,6 +29,7 @@ import {
relation,
text
} from "../../src/data/prototype";
import { MediaField } from "../../src/media/MediaField";
describe("prototype", () => {
test("...", () => {
@@ -76,7 +76,9 @@ describe("prototype", () => {
new DateField("created_at", {
type: "datetime"
}),
// @ts-ignore
new MediaField("images", { entity: "posts" }),
// @ts-ignore
new MediaField("cover", { entity: "posts", max_items: 1 })
]);
+7 -1
View File
@@ -39,7 +39,10 @@ describe("AppAuth", () => {
test("creates user on register", async () => {
const auth = new AppAuth(
{
enabled: true
enabled: true,
jwt: {
secret: "123456"
}
},
ctx
);
@@ -57,6 +60,9 @@ describe("AppAuth", () => {
disableConsoleLog();
const res = await app.request("/password/register", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
email: "some@body.com",
password: "123456"
+9 -2
View File
@@ -88,11 +88,12 @@ await tsup.build({
watch,
entry: ["src/index.ts", "src/data/index.ts", "src/core/index.ts", "src/core/utils/index.ts"],
outDir: "dist",
external: ["bun:test"],
external: ["bun:test", "@libsql/client"],
metafile: true,
platform: "browser",
format: ["esm", "cjs"],
splitting: false,
treeshake: true,
loader: {
".svg": "dataurl"
}
@@ -107,11 +108,12 @@ await tsup.build({
watch,
entry: ["src/ui/index.ts", "src/ui/client/index.ts", "src/ui/main.css"],
outDir: "dist/ui",
external: ["bun:test"],
external: ["bun:test", "react", "react-dom", "use-sync-external-store"],
metafile: true,
platform: "browser",
format: ["esm", "cjs"],
splitting: true,
treeshake: true,
loader: {
".svg": "dataurl"
},
@@ -179,3 +181,8 @@ await tsup.build({
platform: "node",
format: ["esm", "cjs"]
});
await tsup.build({
...baseConfig("astro"),
format: ["esm", "cjs"]
});
+43 -36
View File
@@ -3,12 +3,12 @@
"type": "module",
"sideEffects": false,
"bin": "./dist/cli/index.js",
"version": "0.1.0",
"version": "0.3.2",
"scripts": {
"build:all": "bun run build && bun run build:cli",
"dev": "vite",
"test": "ALL_TESTS=1 bun test --bail",
"build": "bun run build.ts --minify --types",
"build": "NODE_ENV=production bun run build.ts --minify --types",
"watch": "bun run build.ts --types --watch",
"types": "bun tsc --noEmit",
"clean:types": "find ./dist -name '*.d.ts' -delete && rm -f ./dist/tsconfig.tsbuildinfo",
@@ -17,20 +17,35 @@
"watch:css": "bun tailwindcss --watch -i src/ui/main.css -o ./dist/styles.css",
"updater": "bun x npm-check-updates -ui",
"build:cli": "bun build src/cli/index.ts --target node --outdir dist/cli --minify",
"cli": "LOCAL=1 bun src/cli/index.ts"
"cli": "LOCAL=1 bun src/cli/index.ts",
"prepublishOnly": "bun run build:all"
},
"license": "FSL-1.1-MIT",
"dependencies": {
"@libsql/client": "^0.14.0",
"@tanstack/react-form": "0.19.2",
"@sinclair/typebox": "^0.32.34",
"kysely": "^0.27.4",
"liquidjs": "^10.15.0",
"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",
"aws4fetch": "^1.0.18"
},
"devDependencies": {
"@aws-sdk/client-s3": "^3.613.0",
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-liquid": "^6.2.1",
"@dagrejs/dagre": "^1.1.4",
"@hello-pangea/dnd": "^17.0.0",
"@hono/typebox-validator": "^0.2.6",
"@hono/vite-dev-server": "^0.17.0",
"@hono/zod-validator": "^0.4.1",
"@hookform/resolvers": "^3.9.1",
"@libsql/client": "^0.14.0",
"@libsql/kysely-libsql": "^0.4.1",
"@mantine/core": "^7.13.4",
"@mantine/hooks": "^7.13.4",
@@ -38,54 +53,37 @@
"@mantine/notifications": "^7.13.5",
"@radix-ui/react-scroll-area": "^1.2.0",
"@rjsf/core": "^5.22.2",
"@sinclair/typebox": "^0.32.34",
"@tabler/icons-react": "3.18.0",
"@tanstack/react-form": "0.19.2",
"@tanstack/react-query": "^5.59.16",
"@uiw/react-codemirror": "^4.23.6",
"@xyflow/react": "^12.3.2",
"aws4fetch": "^1.0.18",
"codemirror-lang-liquid": "^1.0.0",
"dayjs": "^1.11.13",
"fast-xml-parser": "^4.4.0",
"hono": "^4.6.12",
"jotai": "^2.10.1",
"kysely": "^0.27.4",
"liquidjs": "^10.15.0",
"lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1",
"react-hook-form": "^7.53.1",
"react-icons": "5.2.1",
"react-json-view-lite": "^2.0.1",
"reactflow": "^11.11.4",
"tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7",
"wouter": "^3.3.5",
"zod": "^3.23.8",
"zod-to-json-schema": "^3.23.2"
},
"devDependencies": {
"@aws-sdk/client-s3": "^3.613.0",
"@hono/node-server": "^1.13.7",
"@hono/vite-dev-server": "^0.17.0",
"@tanstack/react-query-devtools": "^5.59.16",
"@types/diff": "^5.2.3",
"@types/node": "^22.10.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@uiw/react-codemirror": "^4.23.6",
"@vitejs/plugin-react": "^4.3.3",
"@xyflow/react": "^12.3.2",
"autoprefixer": "^10.4.20",
"esbuild-postcss": "^0.0.4",
"node-fetch": "^3.3.2",
"jotai": "^2.10.1",
"open": "^10.1.0",
"openapi-types": "^12.1.3",
"postcss": "^8.4.47",
"postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1",
"react-hook-form": "^7.53.1",
"react-icons": "5.2.1",
"react-json-view-lite": "^2.0.1",
"tailwind-merge": "^2.5.4",
"tailwindcss": "^3.4.14",
"tailwindcss-animate": "^1.0.7",
"tsup": "^8.3.5",
"vite": "^5.4.10",
"vite-plugin-static-copy": "^2.0.0",
"vite-tsconfig-paths": "^5.0.1"
"vite-tsconfig-paths": "^5.0.1",
"wouter": "^3.3.5"
},
"optionalDependencies": {
"@hono/node-server": "^1.13.7"
},
"peerDependencies": {
"react": ">=18",
@@ -160,15 +158,24 @@
"import": "./dist/adapter/node/index.js",
"require": "./dist/adapter/node/index.cjs"
},
"./adapter/astro": {
"types": "./dist/adapter/astro/index.d.ts",
"import": "./dist/adapter/astro/index.js",
"require": "./dist/adapter/astro/index.cjs"
},
"./dist/styles.css": "./dist/ui/main.css",
"./dist/manifest.json": "./dist/static/manifest.json"
},
"publishConfig": {
"access": "public"
},
"files": [
"dist",
"README.md",
"!dist/*.tsbuildinfo",
"!dist/*.map",
"!dist/**/*.map",
"!dist/metafile*"
"!dist/metafile*",
"!dist/**/metafile*"
]
}
+29 -23
View File
@@ -21,7 +21,7 @@ export class AppBuiltEvent extends Event<{ app: App }> {
export const AppEvents = { AppConfigUpdatedEvent, AppBuiltEvent } as const;
export type CreateAppConfig = {
connection:
connection?:
| Connection
| {
type: "libsql";
@@ -29,7 +29,7 @@ export type CreateAppConfig = {
};
initialConfig?: InitialModuleConfigs;
plugins?: AppPlugin<any>[];
options?: ModuleManagerOptions;
options?: Omit<ModuleManagerOptions, "initial" | "onUpdated">;
};
export type AppConfig = InitialModuleConfigs;
@@ -56,27 +56,6 @@ export class App<DB = any> {
this.modules.ctx().emgr.registerEvents(AppEvents);
}
static create(config: CreateAppConfig) {
let connection: Connection | undefined = undefined;
if (Connection.isConnection(config.connection)) {
connection = config.connection;
} else if (typeof config.connection === "object") {
switch (config.connection.type) {
case "libsql":
connection = new LibsqlConnection(config.connection.config);
break;
}
} else {
throw new Error(`Unknown connection of type ${typeof config.connection} given.`);
}
if (!connection) {
throw new Error("Invalid connection");
}
return new App(connection, config.initialConfig, config.plugins, config.options);
}
get emgr() {
return this.modules.ctx().emgr;
}
@@ -147,4 +126,31 @@ export class App<DB = any> {
toJSON(secrets?: boolean) {
return this.modules.toJSON(secrets);
}
static create(config: CreateAppConfig) {
return createApp(config);
}
}
export function createApp(config: CreateAppConfig = {}) {
let connection: Connection | undefined = undefined;
try {
if (Connection.isConnection(config.connection)) {
connection = config.connection;
} else if (typeof config.connection === "object") {
connection = new LibsqlConnection(config.connection.config);
} else {
connection = new LibsqlConnection({ url: ":memory:" });
console.warn("[!] No connection provided, using in-memory database");
}
} catch (e) {
console.error("Could not create connection", e);
}
if (!connection) {
throw new Error("Invalid connection");
}
return new App(connection, config.initialConfig, config.plugins, config.options);
}
+30
View File
@@ -0,0 +1,30 @@
import { Api, type ApiOptions, App, type CreateAppConfig } from "bknd";
type TAstro = {
request: Request;
};
export type Options = {
mode?: "static" | "dynamic";
} & Omit<ApiOptions, "host"> & {
host?: string;
};
export function getApi(Astro: TAstro, options: Options = { mode: "static" }) {
return new Api({
host: new URL(Astro.request.url).origin,
headers: options.mode === "dynamic" ? Astro.request.headers : undefined
});
}
let app: App;
export function serve(config: CreateAppConfig) {
return async (args: TAstro) => {
if (!app) {
app = App.create(config);
await app.build();
}
return app.fetch(args.request);
};
}
+1
View File
@@ -0,0 +1 @@
export * from "./astro.adapter";
+49 -45
View File
@@ -1,55 +1,59 @@
/// <reference types="bun-types" />
import path from "node:path";
import { App, type CreateAppConfig } from "bknd";
import { LibsqlConnection } from "bknd/data";
import type { Serve, ServeOptions } from "bun";
import { serveStatic } from "hono/bun";
async function getConnection(conn?: CreateAppConfig["connection"]) {
if (conn) {
if (LibsqlConnection.isConnection(conn)) {
return conn;
}
return new LibsqlConnection(conn.config);
}
const createClient = await import("@libsql/client/node").then((m) => m.createClient);
if (!createClient) {
throw new Error('libsql client not found, you need to install "@libsql/client/node"');
}
console.log("Using in-memory database");
return new LibsqlConnection(createClient({ url: ":memory:" }));
}
export function serve(_config: Partial<CreateAppConfig> = {}, distPath?: string) {
let app: App;
export async function createApp(_config: Partial<CreateAppConfig> = {}, distPath?: string) {
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
let app: App;
return async (req: Request) => {
if (!app) {
const connection = await getConnection(_config.connection);
app = App.create({
..._config,
connection
});
if (!app) {
app = App.create(_config);
app.emgr.on(
"app-built",
async () => {
app.modules.server.get(
"/*",
serveStatic({
root
})
);
app.registerAdminController();
},
"sync"
);
app.emgr.on(
"app-built",
async () => {
app.modules.server.get(
"/*",
serveStatic({
root
})
);
app.registerAdminController();
},
"sync"
);
await app.build();
}
await app.build();
}
return app.fetch(req);
};
return app;
}
export type BunAdapterOptions = Omit<ServeOptions, "fetch"> &
CreateAppConfig & {
distPath?: string;
};
export function serve({
distPath,
connection,
initialConfig,
plugins,
options,
port = 1337,
...serveOptions
}: BunAdapterOptions = {}) {
Bun.serve({
...serveOptions,
port,
fetch: async (request: Request) => {
const app = await createApp({ connection, initialConfig, plugins, options }, distPath);
return app.fetch(request);
}
});
console.log(`Server is running on http://localhost:${port}`);
}
@@ -189,7 +189,11 @@ export class DurableBkndApp extends DurableObject {
const config = options.config;
// change protocol to websocket if libsql
if ("type" in config.connection && config.connection.type === "libsql") {
if (
config?.connection &&
"type" in config.connection &&
config.connection.type === "libsql"
) {
config.connection.config.protocol = "wss";
}
-24
View File
@@ -1,24 +0,0 @@
import { withApi } from "bknd/adapter/nextjs";
import type { InferGetServerSidePropsType } from "next";
import dynamic from "next/dynamic";
export const getServerSideProps = withApi(async (context) => {
return {
props: {
user: context.api.getUser()
}
};
});
export function adminPage() {
const Admin = dynamic(() => import("bknd/ui").then((mod) => mod.Admin), { ssr: false });
const ClientProvider = dynamic(() => import("bknd/ui").then((mod) => mod.ClientProvider));
return (props: InferGetServerSidePropsType<typeof getServerSideProps>) => {
if (typeof document === "undefined") return null;
return (
<ClientProvider user={props.user}>
<Admin />
</ClientProvider>
);
};
}
-1
View File
@@ -1,2 +1 @@
export * from "./nextjs.adapter";
export * from "./AdminPage";
+16 -30
View File
@@ -2,51 +2,34 @@ import path from "node:path";
import { serve as honoServe } from "@hono/node-server";
import { serveStatic } from "@hono/node-server/serve-static";
import { App, type CreateAppConfig } from "bknd";
import { LibsqlConnection } from "bknd/data";
async function getConnection(conn?: CreateAppConfig["connection"]) {
if (conn) {
if (LibsqlConnection.isConnection(conn)) {
return conn;
}
return new LibsqlConnection(conn.config);
}
const createClient = await import("@libsql/client/node").then((m) => m.createClient);
if (!createClient) {
throw new Error('libsql client not found, you need to install "@libsql/client/node"');
}
console.log("Using in-memory database");
return new LibsqlConnection(createClient({ url: ":memory:" }));
}
export type NodeAdapterOptions = {
export type NodeAdapterOptions = CreateAppConfig & {
relativeDistPath?: string;
port?: number;
hostname?: string;
listener?: Parameters<typeof honoServe>[1];
};
export function serve(_config: Partial<CreateAppConfig> = {}, options: NodeAdapterOptions = {}) {
export function serve({
relativeDistPath,
port = 1337,
hostname,
listener,
...config
}: NodeAdapterOptions = {}) {
const root = path.relative(
process.cwd(),
path.resolve(options.relativeDistPath ?? "./node_modules/bknd/dist", "static")
path.resolve(relativeDistPath ?? "./node_modules/bknd/dist", "static")
);
let app: App;
honoServe(
{
port: options.port ?? 1337,
hostname: options.hostname,
port,
hostname,
fetch: async (req: Request) => {
if (!app) {
const connection = await getConnection(_config.connection);
app = App.create({
..._config,
connection
});
app = App.create(config);
app.emgr.on(
"app-built",
@@ -68,6 +51,9 @@ export function serve(_config: Partial<CreateAppConfig> = {}, options: NodeAdapt
return app.fetch(req);
}
},
options.listener
(connInfo) => {
console.log(`Server is running on http://localhost:${connInfo.port}`);
listener?.(connInfo);
}
);
}
+3 -2
View File
@@ -1,6 +1,7 @@
import type { BkndAdminProps } from "bknd/ui";
import { Suspense, lazy, useEffect, useState } from "react";
export function adminPage() {
export function adminPage(props?: BkndAdminProps) {
const Admin = lazy(() => import("bknd/ui").then((mod) => ({ default: mod.Admin })));
return () => {
const [loaded, setLoaded] = useState(false);
@@ -12,7 +13,7 @@ export function adminPage() {
return (
<Suspense>
<Admin />
<Admin {...props} />
</Suspense>
);
};
+22 -3
View File
@@ -1,5 +1,5 @@
import type { AppAuth } from "auth";
import type { ClassController } from "core";
import { type ClassController, isDebug } from "core";
import { Hono, type MiddlewareHandler } from "hono";
export class AuthController implements ClassController {
@@ -10,8 +10,27 @@ export class AuthController implements ClassController {
}
getMiddleware: MiddlewareHandler = async (c, next) => {
const user = await this.auth.authenticator.resolveAuthFromRequest(c);
this.auth.ctx.guard.setUserContext(user);
// @todo: ONLY HOTFIX
// middlewares are added for all routes are registered. But we need to make sure that
// only HTML/JSON routes are adding a cookie to the response. Config updates might
// also use an extension "syntax", e.g. /api/system/patch/data/entities.posts
// This middleware should be extracted and added by each Controller individually,
// but it requires access to the auth secret.
// Note: This doesn't mean endpoints aren't protected, just the cookie is not set.
const url = new URL(c.req.url);
const last = url.pathname.split("/")?.pop();
const ext = last?.includes(".") ? last.split(".")?.pop() : undefined;
if (
!this.auth.authenticator.isJsonRequest(c) &&
["GET", "HEAD", "OPTIONS"].includes(c.req.method) &&
ext &&
["js", "css", "png", "jpg", "jpeg", "svg", "ico"].includes(ext)
) {
isDebug() && console.log("Skipping auth", { ext }, url.pathname);
} else {
const user = await this.auth.authenticator.resolveAuthFromRequest(c);
this.auth.ctx.guard.setUserContext(user);
}
await next();
};
+9 -3
View File
@@ -11,7 +11,7 @@ import {
} from "core/utils";
import type { Context, Hono } from "hono";
import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie";
import { decode, sign, verify } from "hono/jwt";
import { sign, verify } from "hono/jwt";
import type { CookieOptions } from "hono/utils/cookie";
import { omit } from "lodash-es";
@@ -177,7 +177,12 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
payload.exp = Math.floor(Date.now() / 1000) + this.config.jwt.expires;
}
return sign(payload, this.config.jwt?.secret ?? "", this.config.jwt?.alg ?? "HS256");
const secret = this.config.jwt.secret;
if (!secret || secret.length === 0) {
throw new Error("Cannot sign JWT without a secret");
}
return sign(payload, secret, this.config.jwt?.alg ?? "HS256");
}
async verify(jwt: string): Promise<boolean> {
@@ -228,9 +233,9 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
async requestCookieRefresh(c: Context) {
if (this.config.cookie.renew) {
console.log("renewing cookie", c.req.url);
const token = await this.getAuthCookie(c);
if (token) {
console.log("renewing cookie", c.req.url);
await this.setAuthCookie(c, token);
}
}
@@ -249,6 +254,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
}
}
// @todo: move this to a server helper
isJsonRequest(c: Context): boolean {
//return c.req.header("Content-Type") === "application/x-www-form-urlencoded";
return c.req.header("Content-Type") === "application/json";
+8 -27
View File
@@ -1,10 +1,8 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import type { ServeStaticOptions } from "@hono/node-server/serve-static";
import { type Config, createClient } from "@libsql/client/node";
import { Connection, LibsqlConnection, SqliteLocalConnection } from "data";
import type { Config } from "@libsql/client/node";
import type { MiddlewareHandler } from "hono";
import { fileExists, getDistPath, getRelativeDistPath } from "../../utils/sys";
import open from "open";
import { fileExists, getRelativeDistPath } from "../../utils/sys";
export const PLATFORMS = ["node", "bun"] as const;
export type Platform = (typeof PLATFORMS)[number];
@@ -33,7 +31,8 @@ export async function attachServeStatic(app: any, platform: Platform) {
export async function startServer(server: Platform, app: any, options: { port: number }) {
const port = options.port;
console.log("running on", server, port);
console.log(`(using ${server} serve)`);
switch (server) {
case "node": {
// https://github.com/honojs/node-server/blob/main/src/response.ts#L88
@@ -53,27 +52,9 @@ export async function startServer(server: Platform, app: any, options: { port: n
}
}
console.log("Server listening on", "http://localhost:" + port);
}
export async function getHtml() {
return await readFile(path.resolve(getDistPath(), "static/index.html"), "utf-8");
}
export function getConnection(connectionOrConfig?: Connection | Config): Connection {
if (connectionOrConfig) {
if (connectionOrConfig instanceof Connection) {
return connectionOrConfig;
}
if ("url" in connectionOrConfig) {
return new LibsqlConnection(createClient(connectionOrConfig));
}
}
console.log("Using in-memory database");
return new LibsqlConnection(createClient({ url: ":memory:" }));
//return new SqliteLocalConnection(new Database(":memory:"));
const url = `http://localhost:${port}`;
console.log(`Server listening on ${url}`);
await open(url);
}
export async function getConfigPath(filePath?: string) {
+6 -9
View File
@@ -1,16 +1,13 @@
import type { Config } from "@libsql/client/node";
import { App } from "App";
import { App, type CreateAppConfig } from "App";
import type { BkndConfig } from "adapter";
import type { CliCommand } from "cli/types";
import { Option } from "commander";
import type { Connection } from "data";
import {
PLATFORMS,
type Platform,
attachServeStatic,
getConfigPath,
getConnection,
getHtml,
startServer
} from "./platform";
@@ -41,14 +38,14 @@ export const run: CliCommand = (program) => {
};
type MakeAppConfig = {
connection: Connection;
connection?: CreateAppConfig["connection"];
server?: { platform?: Platform };
setAdminHtml?: boolean;
onBuilt?: (app: App) => Promise<void>;
};
async function makeApp(config: MakeAppConfig) {
const app = new App(config.connection);
const app = App.create({ connection: config.connection });
app.emgr.on(
"app-built",
@@ -99,9 +96,9 @@ async function action(options: {
let app: App;
if (options.dbUrl || !configFilePath) {
const connection = getConnection(
options.dbUrl ? { url: options.dbUrl, authToken: options.dbToken } : undefined
);
const connection = options.dbUrl
? { type: "libsql" as const, config: { url: options.dbUrl, authToken: options.dbToken } }
: undefined;
app = await makeApp({ connection, server: { platform: options.server } });
} else {
console.log("Using config from:", configFilePath);
+4
View File
@@ -27,6 +27,10 @@ export class BkndError extends Error {
super(message);
}
static with(message: string, details?: Record<string, any>, type?: string) {
throw new BkndError(message, details, type);
}
toJSON() {
return {
type: this.type ?? "unknown",
+9 -3
View File
@@ -1,5 +1,5 @@
export { Endpoint, type RequestResponse, type Middleware } from "./server/Endpoint";
export { zValidator } from "./server/lib/zValidator";
import type { Hono, MiddlewareHandler } from "hono";
export { tbValidator } from "./server/lib/tbValidator";
export { Exception, BkndError } from "./errors";
export { isDebug } from "./env";
@@ -11,7 +11,6 @@ export {
type TemplateTypes,
type SimpleRendererOptions
} from "./template/SimpleRenderer";
export { Controller, type ClassController } from "./server/Controller";
export { SchemaObject } from "./object/SchemaObject";
export { DebugLogger } from "./utils/DebugLogger";
export { Permission } from "./security/Permission";
@@ -26,3 +25,10 @@ export {
isBooleanLike
} from "./object/query/query";
export { Registry, type Constructor } from "./registry/Registry";
// compatibility
export type Middleware = MiddlewareHandler<any, any, any>;
export interface ClassController {
getController: () => Hono<any, any, any>;
getMiddleware?: MiddlewareHandler<any, any, any>;
}
+181
View File
@@ -0,0 +1,181 @@
enum Change {
Add = "a",
Remove = "r",
Edit = "e"
}
type Object = object;
type Primitive = string | number | boolean | null | object | any[] | undefined;
interface DiffEntry {
t: Change | string;
p: (string | number)[];
o: Primitive;
n: Primitive;
}
function isObject(value: any): value is Object {
return value !== null && value.constructor.name === "Object";
}
function isPrimitive(value: any): value is Primitive {
try {
return (
value === null ||
value === undefined ||
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean" ||
Array.isArray(value) ||
isObject(value)
);
} catch (e) {
return false;
}
}
function diff(oldObj: Object, newObj: Object): DiffEntry[] {
const diffs: DiffEntry[] = [];
function recurse(oldValue: Primitive, newValue: Primitive, path: (string | number)[]) {
if (!isPrimitive(oldValue) || !isPrimitive(newValue)) {
throw new Error("Diff: Only primitive types are supported");
}
if (oldValue === newValue) {
return;
}
if (typeof oldValue !== typeof newValue) {
diffs.push({
t: Change.Edit,
p: path,
o: oldValue,
n: newValue
});
} else if (Array.isArray(oldValue) && Array.isArray(newValue)) {
const maxLength = Math.max(oldValue.length, newValue.length);
for (let i = 0; i < maxLength; i++) {
if (i >= oldValue.length) {
diffs.push({
t: Change.Add,
p: [...path, i],
o: undefined,
n: newValue[i]
});
} else if (i >= newValue.length) {
diffs.push({
t: Change.Remove,
p: [...path, i],
o: oldValue[i],
n: undefined
});
} else {
recurse(oldValue[i], newValue[i], [...path, i]);
}
}
} else if (isObject(oldValue) && isObject(newValue)) {
const oKeys = Object.keys(oldValue);
const nKeys = Object.keys(newValue);
const allKeys = new Set([...oKeys, ...nKeys]);
for (const key of allKeys) {
if (!(key in oldValue)) {
diffs.push({
t: Change.Add,
p: [...path, key],
o: undefined,
n: newValue[key]
});
} else if (!(key in newValue)) {
diffs.push({
t: Change.Remove,
p: [...path, key],
o: oldValue[key],
n: undefined
});
} else {
recurse(oldValue[key], newValue[key], [...path, key]);
}
}
} else {
diffs.push({
t: Change.Edit,
p: path,
o: oldValue,
n: newValue
});
}
}
recurse(oldObj, newObj, []);
return diffs;
}
function apply(obj: Object, diffs: DiffEntry[]): any {
const clonedObj = clone(obj);
for (const diff of diffs) {
applyChange(clonedObj, diff);
}
return clonedObj;
}
function revert(obj: Object, diffs: DiffEntry[]): any {
const clonedObj = clone(obj);
const reversedDiffs = diffs.slice().reverse();
for (const diff of reversedDiffs) {
revertChange(clonedObj, diff);
}
return clonedObj;
}
function applyChange(obj: Object, diff: DiffEntry) {
const { p: path, t: type, n: newValue } = diff;
const parent = getParent(obj, path.slice(0, -1));
const key = path[path.length - 1]!;
if (type === Change.Add || type === Change.Edit) {
parent[key] = newValue;
} else if (type === Change.Remove) {
if (Array.isArray(parent)) {
parent.splice(key as number, 1);
} else {
delete parent[key];
}
}
}
function revertChange(obj: Object, diff: DiffEntry) {
const { p: path, t: type, o: oldValue } = diff;
const parent = getParent(obj, path.slice(0, -1));
const key = path[path.length - 1]!;
if (type === Change.Add) {
if (Array.isArray(parent)) {
parent.splice(key as number, 1);
} else {
delete parent[key];
}
} else if (type === Change.Remove || type === Change.Edit) {
parent[key] = oldValue;
}
}
function getParent(obj: Object, path: (string | number)[]): any {
let current = obj;
for (const key of path) {
if (current[key] === undefined) {
current[key] = typeof key === "number" ? [] : {};
}
current = current[key];
}
return current;
}
function clone<In extends Object>(obj: In): In {
return JSON.parse(JSON.stringify(obj));
}
export { diff, apply, revert, clone };
-155
View File
@@ -1,155 +0,0 @@
import { Hono, type MiddlewareHandler, type ValidationTargets } from "hono";
import type { H } from "hono/types";
import { safelyParseObjectValues } from "../utils";
import type { Endpoint, Middleware } from "./Endpoint";
import { zValidator } from "./lib/zValidator";
type RouteProxy<Endpoints> = {
[K in keyof Endpoints]: Endpoints[K];
};
export interface ClassController {
getController: () => Hono<any, any, any>;
getMiddleware?: MiddlewareHandler<any, any, any>;
}
/**
* @deprecated
*/
export class Controller<
Endpoints extends Record<string, Endpoint> = Record<string, Endpoint>,
Middlewares extends Record<string, Middleware> = Record<string, Middleware>
> {
protected endpoints: Endpoints = {} as Endpoints;
protected middlewares: Middlewares = {} as Middlewares;
public prefix: string = "/";
public routes: RouteProxy<Endpoints>;
constructor(
prefix: string = "/",
endpoints: Endpoints = {} as Endpoints,
middlewares: Middlewares = {} as Middlewares
) {
this.prefix = prefix;
this.endpoints = endpoints;
this.middlewares = middlewares;
this.routes = new Proxy(
{},
{
get: (_, name: string) => {
return this.endpoints[name];
}
}
) as RouteProxy<Endpoints>;
}
add<Name extends string, E extends Endpoint>(
this: Controller<Endpoints>,
name: Name,
endpoint: E
): Controller<Endpoints & Record<Name, E>> {
const newEndpoints = {
...this.endpoints,
[name]: endpoint
} as Endpoints & Record<Name, E>;
const newController: Controller<Endpoints & Record<Name, E>> = new Controller<
Endpoints & Record<Name, E>
>();
newController.endpoints = newEndpoints;
newController.middlewares = this.middlewares;
return newController;
}
get<Name extends keyof Endpoints>(name: Name): Endpoints[Name] {
return this.endpoints[name];
}
honoify(_hono: Hono = new Hono()) {
const hono = _hono.basePath(this.prefix);
// apply middlewares
for (const m_name in this.middlewares) {
const middleware = this.middlewares[m_name];
if (typeof middleware === "function") {
//if (isDebug()) console.log("+++ appyling middleware", m_name, middleware);
hono.use(middleware);
}
}
// apply endpoints
for (const name in this.endpoints) {
const endpoint = this.endpoints[name];
if (!endpoint) continue;
const handlers: H[] = [];
const supportedValidations: Array<keyof ValidationTargets> = ["param", "query", "json"];
// if validations are present, add them to the handlers
for (const validation of supportedValidations) {
if (endpoint.validation[validation]) {
handlers.push(async (c, next) => {
// @todo: potentially add "strict" to all schemas?
const res = await zValidator(
validation,
endpoint.validation[validation] as any,
(target, value, c) => {
if (["query", "param"].includes(target)) {
return safelyParseObjectValues(value);
}
//console.log("preprocess", target, value, c.req.raw.url);
return value;
}
)(c, next);
if (res instanceof Response && res.status === 400) {
const error = await res.json();
return c.json(
{
error: "Validation error",
target: validation,
message: error
},
400
);
}
return res;
});
}
}
// add actual handler
handlers.push(endpoint.toHandler());
const method = endpoint.method.toLowerCase() as
| "get"
| "post"
| "put"
| "delete"
| "patch";
//if (isDebug()) console.log("--- adding", method, endpoint.path);
hono[method](endpoint.path, ...handlers);
}
return hono;
}
toJSON() {
const endpoints: any = {};
for (const name in this.endpoints) {
const endpoint = this.endpoints[name];
if (!endpoint) continue;
endpoints[name] = {
method: endpoint.method,
path: (this.prefix + endpoint.path).replace("//", "/")
};
}
return endpoints;
}
}
-147
View File
@@ -1,147 +0,0 @@
import type { Context, MiddlewareHandler, Next, ValidationTargets } from "hono";
import type { Handler } from "hono/types";
import { encodeSearch, replaceUrlParam } from "../utils";
import type { Prettify } from "../utils";
type ZodSchema = { [key: string]: any };
type Method = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
type Validation<P, Q, B> = {
[K in keyof ValidationTargets]?: any;
} & {
param?: P extends ZodSchema ? P : undefined;
query?: Q extends ZodSchema ? Q : undefined;
json?: B extends ZodSchema ? B : undefined;
};
type ValidationInput<P, Q, B> = {
param?: P extends ZodSchema ? P["_input"] : undefined;
query?: Q extends ZodSchema ? Q["_input"] : undefined;
json?: B extends ZodSchema ? B["_input"] : undefined;
};
type HonoEnv = any;
export type Middleware = MiddlewareHandler<any, any, any>;
type HandlerFunction<P extends string, R> = (c: Context<HonoEnv, P, any>, next: Next) => R;
export type RequestResponse<R> = {
status: number;
ok: boolean;
response: Awaited<R>;
};
/**
* @deprecated
*/
export class Endpoint<
Path extends string = any,
P extends ZodSchema = any,
Q extends ZodSchema = any,
B extends ZodSchema = any,
R = any
> {
constructor(
readonly method: Method,
readonly path: Path,
readonly handler: HandlerFunction<Path, R>,
readonly validation: Validation<P, Q, B> = {}
) {}
// @todo: typing is not ideal
async $request(
args?: ValidationInput<P, Q, B>,
baseUrl: string = "http://localhost:28623"
): Promise<Prettify<RequestResponse<R>>> {
let path = this.path as string;
if (args?.param) {
path = replaceUrlParam(path, args.param);
}
if (args?.query) {
path += "?" + encodeSearch(args.query);
}
const url = [baseUrl, path].join("").replace(/\/$/, "");
const options: RequestInit = {
method: this.method,
headers: {} as any
};
if (!["GET", "HEAD"].includes(this.method)) {
if (args?.json) {
options.body = JSON.stringify(args.json);
options.headers!["Content-Type"] = "application/json";
}
}
const res = await fetch(url, options);
return {
status: res.status,
ok: res.ok,
response: (await res.json()) as any
};
}
toHandler(): Handler {
return async (c, next) => {
const res = await this.handler(c, next);
//console.log("toHandler:isResponse", res instanceof Response);
//return res;
if (res instanceof Response) {
return res;
}
return c.json(res as any) as unknown as Handler;
};
}
static get<
Path extends string = any,
P extends ZodSchema = any,
Q extends ZodSchema = any,
B extends ZodSchema = any,
R = any
>(path: Path, handler: HandlerFunction<Path, R>, validation?: Validation<P, Q, B>) {
return new Endpoint<Path, P, Q, B, R>("GET", path, handler, validation);
}
static post<
Path extends string = any,
P extends ZodSchema = any,
Q extends ZodSchema = any,
B extends ZodSchema = any,
R = any
>(path: Path, handler: HandlerFunction<Path, R>, validation?: Validation<P, Q, B>) {
return new Endpoint<Path, P, Q, B, R>("POST", path, handler, validation);
}
static patch<
Path extends string = any,
P extends ZodSchema = any,
Q extends ZodSchema = any,
B extends ZodSchema = any,
R = any
>(path: Path, handler: HandlerFunction<Path, R>, validation?: Validation<P, Q, B>) {
return new Endpoint<Path, P, Q, B, R>("PATCH", path, handler, validation);
}
static put<
Path extends string = any,
P extends ZodSchema = any,
Q extends ZodSchema = any,
B extends ZodSchema = any,
R = any
>(path: Path, handler: HandlerFunction<Path, R>, validation?: Validation<P, Q, B>) {
return new Endpoint<Path, P, Q, B, R>("PUT", path, handler, validation);
}
static delete<
Path extends string = any,
P extends ZodSchema = any,
Q extends ZodSchema = any,
B extends ZodSchema = any,
R = any
>(path: Path, handler: HandlerFunction<Path, R>, validation?: Validation<P, Q, B>) {
return new Endpoint<Path, P, Q, B, R>("DELETE", path, handler, validation);
}
}
-75
View File
@@ -1,75 +0,0 @@
import type {
Context,
Env,
Input,
MiddlewareHandler,
TypedResponse,
ValidationTargets,
} from "hono";
import { validator } from "hono/validator";
import type { ZodError, ZodSchema, z } from "zod";
export type Hook<T, E extends Env, P extends string, O = {}> = (
result: { success: true; data: T } | { success: false; error: ZodError; data: T },
c: Context<E, P>,
) => Response | void | TypedResponse<O> | Promise<Response | void | TypedResponse<O>>;
type HasUndefined<T> = undefined extends T ? true : false;
export const zValidator = <
T extends ZodSchema,
Target extends keyof ValidationTargets,
E extends Env,
P extends string,
In = z.input<T>,
Out = z.output<T>,
I extends Input = {
in: HasUndefined<In> extends true
? {
[K in Target]?: K extends "json"
? In
: HasUndefined<keyof ValidationTargets[K]> extends true
? { [K2 in keyof In]?: ValidationTargets[K][K2] }
: { [K2 in keyof In]: ValidationTargets[K][K2] };
}
: {
[K in Target]: K extends "json"
? In
: HasUndefined<keyof ValidationTargets[K]> extends true
? { [K2 in keyof In]?: ValidationTargets[K][K2] }
: { [K2 in keyof In]: ValidationTargets[K][K2] };
};
out: { [K in Target]: Out };
},
V extends I = I,
>(
target: Target,
schema: T,
preprocess?: (target: string, value: In, c: Context<E, P>) => V, // <-- added
hook?: Hook<z.infer<T>, E, P>,
): MiddlewareHandler<E, P, V> =>
// @ts-expect-error not typed well
validator(target, async (value, c) => {
// added: preprocess value first if given
const _value = preprocess ? preprocess(target, value, c) : (value as any);
const result = await schema.safeParseAsync(_value);
if (hook) {
const hookResult = await hook({ data: value, ...result }, c);
if (hookResult) {
if (hookResult instanceof Response) {
return hookResult;
}
if ("response" in hookResult) {
return hookResult.response;
}
}
}
if (!result.success) {
return c.json(result, 400);
}
return result.data as z.infer<T>;
});
+11
View File
@@ -5,10 +5,21 @@ const _oldConsoles = {
error: console.error
};
export async function withDisabledConsole<R>(
fn: () => Promise<R>,
severities: ConsoleSeverity[] = ["log"]
): Promise<R> {
const enable = disableConsoleLog(severities);
const result = await fn();
enable();
return result;
}
export function disableConsoleLog(severities: ConsoleSeverity[] = ["log"]) {
severities.forEach((severity) => {
console[severity] = () => null;
});
return enableConsoleLog;
}
export function enableConsoleLog() {
+2 -2
View File
@@ -72,10 +72,10 @@ export class TypeInvalidError extends Error {
}
}
export function stripMark(obj: any) {
export function stripMark<O = any>(obj: O) {
const newObj = cloneDeep(obj);
mark(newObj, false);
return newObj;
return newObj as O;
}
export function mark(obj: any, validated = true) {
+2 -2
View File
@@ -369,9 +369,9 @@ export class DataController implements ClassController {
return c.notFound();
}
const where = c.req.valid("json") as RepoQuery["where"];
console.log("where", where);
//console.log("where", where);
const result = await this.em.mutator(entity).deleteMany(where);
const result = await this.em.mutator(entity).deleteWhere(where);
return c.json(this.mutatorResult(result));
}
+9 -6
View File
@@ -41,16 +41,18 @@ export type DbFunctions = {
>;
};
export abstract class Connection {
cls = "bknd:connection";
kysely: Kysely<any>;
const CONN_SYMBOL = Symbol.for("bknd:connection");
export abstract class Connection<DB = any> {
kysely: Kysely<DB>;
constructor(
kysely: Kysely<any>,
kysely: Kysely<DB>,
public fn: Partial<DbFunctions> = {},
protected plugins: KyselyPlugin[] = []
) {
this.kysely = kysely;
this[CONN_SYMBOL] = true;
}
/**
@@ -58,8 +60,9 @@ export abstract class Connection {
* coming from different places
* @param conn
*/
static isConnection(conn: any): conn is Connection {
return conn?.cls === "bknd:connection";
static isConnection(conn: unknown): conn is Connection {
if (!conn) return false;
return conn[CONN_SYMBOL] === true;
}
getIntrospector(): ConnectionIntrospector {
+2 -4
View File
@@ -1,4 +1,4 @@
import { type Client, type InStatement, createClient } from "@libsql/client/web";
import { type Client, type Config, type InStatement, createClient } from "@libsql/client";
import { LibsqlDialect } from "@libsql/kysely-libsql";
import { type DatabaseIntrospector, Kysely, ParseJSONResultsPlugin, sql } from "kysely";
import { FilterNumericKeysPlugin } from "../plugins/FilterNumericKeysPlugin";
@@ -8,9 +8,7 @@ import { SqliteConnection } from "./SqliteConnection";
import { SqliteIntrospector } from "./SqliteIntrospector";
export const LIBSQL_PROTOCOLS = ["wss", "https", "libsql"] as const;
export type LibSqlCredentials = {
url: string;
authToken?: string;
export type LibSqlCredentials = Config & {
protocol?: (typeof LIBSQL_PROTOCOLS)[number];
};
+27 -1
View File
@@ -250,7 +250,7 @@ export class Mutator<DB> implements EmitsEvents {
}
// @todo: decide whether entries should be deleted all at once or one by one (for events)
async deleteMany(where?: RepoQuery["where"]): Promise<MutatorResponse<EntityData>> {
async deleteWhere(where?: RepoQuery["where"]): Promise<MutatorResponse<EntityData>> {
const entity = this.entity;
const qb = this.appendWhere(this.conn.deleteFrom(entity.name), where).returning(
@@ -267,4 +267,30 @@ export class Mutator<DB> implements EmitsEvents {
return res;
}
async updateWhere(
data: EntityData,
where?: RepoQuery["where"]
): Promise<MutatorResponse<EntityData>> {
const entity = this.entity;
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)
.set(validatedData)
//.where(entity.id().name, "=", id)
.returning(entity.getSelect());
const res = await this.many(query);
/*await this.emgr.emit(
new Mutator.Events.MutatorUpdateAfter({ entity, entityId: id, data: res.data })
);*/
return res;
}
}
+11 -11
View File
@@ -162,8 +162,7 @@ export class Repository<DB = any, TB extends keyof DB = any> implements EmitsEve
protected async performQuery(qb: RepositoryQB): Promise<RepositoryResponse> {
const entity = this.entity;
const compiled = qb.compile();
/*const { sql, parameters } = qb.compile();
console.log("many", sql, parameters);*/
//console.log("performQuery", compiled.sql, compiled.parameters);
const start = performance.now();
const selector = (as = "count") => this.conn.fn.countAll<number>().as(as);
@@ -202,7 +201,10 @@ export class Repository<DB = any, TB extends keyof DB = any> implements EmitsEve
}
};
} catch (e) {
console.error("many error", e, compiled);
if (e instanceof Error) {
console.error("[ERROR] Repository.performQuery", e.message);
}
throw e;
}
}
@@ -263,6 +265,7 @@ export class Repository<DB = any, TB extends keyof DB = any> implements EmitsEve
qb = qb.orderBy(aliased(options.sort.by), options.sort.dir);
}
//console.log("options", { _options, options, exclude_options });
return { qb, options };
}
@@ -286,14 +289,11 @@ export class Repository<DB = any, TB extends keyof DB = any> implements EmitsEve
where: RepoQuery["where"],
_options?: Partial<Omit<RepoQuery, "where" | "limit" | "offset">>
): Promise<RepositoryResponse<DB[TB] | undefined>> {
const { qb, options } = this.buildQuery(
{
..._options,
where,
limit: 1
},
["offset", "sort"]
);
const { qb, options } = this.buildQuery({
..._options,
where,
limit: 1
});
return this.single(qb, options) as any;
}
@@ -15,7 +15,6 @@ import type {
SelectQueryBuilder,
UpdateQueryBuilder
} from "kysely";
import type { RepositoryQB } from "./Repository";
type Builder = ExpressionBuilder<any, any>;
type Wrapper = ExpressionWrapper<any, any, any>;
+2 -2
View File
@@ -236,8 +236,8 @@ export abstract class Field<
toJSON() {
return {
//name: this.name,
type: this.type,
// @todo: current workaround because of fixed string type
type: this.type as any,
config: this.config
};
}
+6 -1
View File
@@ -54,7 +54,7 @@ export class JsonSchemaField<
if (parentValid) {
// already checked in parent
if (!value || typeof value !== "object") {
if (!this.isRequired() && (!value || typeof value !== "object")) {
//console.log("jsonschema:valid: not checking", this.name, value, context);
return true;
}
@@ -65,6 +65,7 @@ export class JsonSchemaField<
} else {
//console.log("jsonschema:invalid", this.name, value, context);
}
//console.log("jsonschema:invalid:fromParent", this.name, value, context);
return false;
}
@@ -110,9 +111,13 @@ export class JsonSchemaField<
): Promise<string | undefined> {
const value = await super.transformPersist(_value, em, context);
if (this.nullish(value)) return value;
//console.log("jsonschema:transformPersist", this.name, _value, context);
if (!this.isValid(value)) {
//console.error("jsonschema:transformPersist:invalid", this.name, value);
throw new TransformPersistFailedException(this.name, value);
} else {
//console.log("jsonschema:transformPersist:valid", this.name, value);
}
if (!value || typeof value !== "object") return this.getDefault();
+1 -2
View File
@@ -4,6 +4,7 @@ export * from "./fields";
export * from "./entities";
export * from "./relations";
export * from "./schema/SchemaManager";
export * from "./prototype";
export {
type RepoQuery,
@@ -12,8 +13,6 @@ export {
whereSchema
} from "./server/data-query-impl";
export { whereRepoSchema as deprecated__whereRepoSchema } from "./server/query";
export { Connection } from "./connection/Connection";
export { LibsqlConnection, type LibSqlCredentials } from "./connection/LibsqlConnection";
export { SqliteConnection } from "./connection/SqliteConnection";
-112
View File
@@ -1,112 +0,0 @@
import { z } from "zod";
const date = z.union([z.date(), z.string()]);
const numeric = z.union([z.number(), date]);
const boolean = z.union([z.boolean(), z.literal(1), z.literal(0)]);
const value = z.union([z.string(), boolean, numeric]);
const expressionCond = z.union([
z.object({ $eq: value }).strict(),
z.object({ $ne: value }).strict(),
z.object({ $isnull: boolean }).strict(),
z.object({ $notnull: boolean }).strict(),
z.object({ $in: z.array(value) }).strict(),
z.object({ $notin: z.array(value) }).strict(),
z.object({ $gt: numeric }).strict(),
z.object({ $gte: numeric }).strict(),
z.object({ $lt: numeric }).strict(),
z.object({ $lte: numeric }).strict(),
z.object({ $between: z.array(numeric).min(2).max(2) }).strict()
] as const);
// prettier-ignore
const nonOperandString = z
.string()
.regex(/^(?!\$).*/)
.min(1);
// {name: 'Michael'}
const literalCond = z.record(nonOperandString, value);
// { status: { $eq: 1 } }
const literalExpressionCond = z.record(nonOperandString, value.or(expressionCond));
const operandCond = z
.object({
$and: z.array(literalCond.or(expressionCond).or(literalExpressionCond)).optional(),
$or: z.array(literalCond.or(expressionCond).or(literalExpressionCond)).optional()
})
.strict();
const literalSchema = literalCond.or(literalExpressionCond);
export type LiteralSchemaIn = z.input<typeof literalSchema>;
export type LiteralSchema = z.output<typeof literalSchema>;
export const filterSchema = literalSchema.or(operandCond);
export type FilterSchemaIn = z.input<typeof filterSchema>;
export type FilterSchema = z.output<typeof filterSchema>;
const stringArray = z
.union([
z.string().transform((v) => {
if (v.includes(",")) return v.split(",");
return v;
}),
z.array(z.string())
])
.default([])
.transform((v) => (Array.isArray(v) ? v : [v]));
export const whereRepoSchema = z
.preprocess((v: unknown) => {
try {
return JSON.parse(v as string);
} catch {
return v;
}
}, filterSchema)
.default({});
const repoQuerySchema = z.object({
limit: z.coerce.number().default(10),
offset: z.coerce.number().default(0),
sort: z
.preprocess(
(v: unknown) => {
try {
return JSON.parse(v as string);
} catch {
return v;
}
},
z.union([
z.string().transform((v) => {
if (v.includes(":")) {
let [field, dir] = v.split(":") as [string, string];
if (!["asc", "desc"].includes(dir)) dir = "asc";
return { by: field, dir } as { by: string; dir: "asc" | "desc" };
} else {
return { by: v, dir: "asc" } as { by: string; dir: "asc" | "desc" };
}
}),
z.object({
by: z.string(),
dir: z.enum(["asc", "desc"])
})
])
)
.default({ by: "id", dir: "asc" }),
select: stringArray,
with: stringArray,
join: stringArray,
debug: z
.preprocess((v) => {
if (["0", "false"].includes(String(v))) return false;
return Boolean(v);
}, z.boolean())
.default(false), //z.coerce.boolean().catch(false),
where: whereRepoSchema
});
type RepoQueryIn = z.input<typeof repoQuerySchema>;
type RepoQuery = z.output<typeof repoQuerySchema>;
+2 -3
View File
@@ -1,6 +1,5 @@
export { App, type AppConfig, type CreateAppConfig } from "./App";
export { App, createApp, AppEvents, type AppConfig, type CreateAppConfig } from "./App";
export { MediaField } from "media/MediaField";
export {
getDefaultConfig,
getDefaultSchema,
@@ -8,5 +7,5 @@ export {
type ModuleSchemas
} from "modules/ModuleManager";
export * from "./adapter";
export type * from "./adapter";
export { Api, type ApiOptions } from "./Api";
+1 -1
View File
@@ -181,7 +181,7 @@ export class MediaController implements ClassController {
if (ids_to_delete.length > 0) {
await this.media.em
.mutator(mediaEntity)
.deleteMany({ [id_field]: { $in: ids_to_delete } });
.deleteWhere({ [id_field]: { $in: ids_to_delete } });
}
return c.json({ ok: true, result: result.data, ...info });
+191 -109
View File
@@ -1,12 +1,33 @@
import { Diff } from "@sinclair/typebox/value";
import { Guard } from "auth";
import { DebugLogger, isDebug } from "core";
import { BkndError, DebugLogger, Exception, isDebug } from "core";
import { EventManager } from "core/events";
import { Default, type Static, objectEach, transformObject } from "core/utils";
import { type Connection, EntityManager } from "data";
import { clone, diff } from "core/object/diff";
import {
Default,
type Static,
StringEnum,
Type,
mark,
objectEach,
stripMark,
transformObject,
withDisabledConsole
} from "core/utils";
import {
type Connection,
EntityManager,
type Schema,
datetime,
entity,
enumm,
jsonSchema,
number
} from "data";
import { TransformPersistFailedException } from "data/errors";
import { Hono } from "hono";
import { type Kysely, sql } from "kysely";
import { CURRENT_VERSION, TABLE_NAME, migrate, migrateSchema } from "modules/migrations";
import type { Kysely } from "kysely";
import { mergeWith } from "lodash-es";
import { CURRENT_VERSION, TABLE_NAME, migrate } from "modules/migrations";
import { AppServer } from "modules/server/AppServer";
import { AppAuth } from "../auth/AppAuth";
import { AppData } from "../data/AppData";
@@ -37,10 +58,13 @@ export type ModuleSchemas = {
export type ModuleConfigs = {
[K in keyof ModuleSchemas]: Static<ModuleSchemas[K]>;
};
type PartialRec<T> = { [P in keyof T]?: PartialRec<T[P]> };
export type InitialModuleConfigs = {
version: number;
} & Partial<ModuleConfigs>;
export type InitialModuleConfigs =
| ({
version: number;
} & ModuleConfigs)
| PartialRec<ModuleConfigs>;
export type ModuleManagerOptions = {
initial?: InitialModuleConfigs;
@@ -51,6 +75,7 @@ export type ModuleManagerOptions = {
) => Promise<void>;
// base path for the hono instance
basePath?: string;
trustFetched?: boolean;
};
type ConfigTable<Json = ModuleConfigs> = {
@@ -61,8 +86,36 @@ type ConfigTable<Json = ModuleConfigs> = {
updated_at?: Date;
};
const configJsonSchema = Type.Union([
getDefaultSchema(),
Type.Array(
Type.Object({
t: StringEnum(["a", "r", "e"]),
p: Type.Array(Type.Union([Type.String(), Type.Number()])),
o: Type.Optional(Type.Any()),
n: Type.Optional(Type.Any())
})
)
]);
const __bknd = entity(TABLE_NAME, {
version: number().required(),
type: enumm({ enum: ["config", "diff", "backup"] }).required(),
json: jsonSchema({ schema: configJsonSchema }).required(),
created_at: datetime(),
updated_at: datetime()
});
type ConfigTable2 = Schema<typeof __bknd>;
type T_INTERNAL_EM = {
__bknd: ConfigTable2;
};
// @todo: cleanup old diffs on upgrade
// @todo: cleanup multiple backups on upgrade
export class ModuleManager {
private modules: Modules;
// internal em for __bknd config table
__em!: EntityManager<T_INTERNAL_EM>;
// ctx for modules
em!: EntityManager<any>;
server!: Hono;
emgr!: EventManager;
@@ -71,28 +124,32 @@ export class ModuleManager {
private _version: number = 0;
private _built = false;
private _fetched = false;
private readonly _provided;
private logger = new DebugLogger(isDebug() && false);
// @todo: keep? not doing anything with it
private readonly _booted_with?: "provided" | "partial";
private logger = new DebugLogger(false);
constructor(
private readonly connection: Connection,
private options?: Partial<ModuleManagerOptions>
) {
this.__em = new EntityManager([__bknd], this.connection);
this.modules = {} as Modules;
this.emgr = new EventManager();
const context = this.ctx(true);
let initial = {} as Partial<ModuleConfigs>;
if (options?.initial) {
const { version, ...initialConfig } = options.initial;
if (version && initialConfig) {
if ("version" in options.initial) {
const { version, ...initialConfig } = options.initial;
this._version = version;
initial = initialConfig;
initial = stripMark(initialConfig);
this._provided = true;
this._booted_with = "provided";
} else {
throw new Error("Initial was provided, but it needs a version!");
initial = mergeWith(getDefaultConfig(), options.initial);
this._booted_with = "partial";
}
}
@@ -120,6 +177,25 @@ export class ModuleManager {
}
}
private repo() {
return this.__em.repo(__bknd);
}
private mutator() {
return this.__em.mutator(__bknd);
}
private get db() {
return this.connection.kysely as Kysely<{ table: ConfigTable }>;
}
async syncConfigTable() {
this.logger.context("sync").log("start");
const result = await this.__em.schema().sync({ force: true });
this.logger.log("done").clear();
return result;
}
private rebuildServer() {
this.server = new Hono();
if (this.options?.basePath) {
@@ -153,27 +229,26 @@ export class ModuleManager {
};
}
private get db() {
return this.connection.kysely as Kysely<{ table: ConfigTable }>;
}
get table() {
return TABLE_NAME as "table";
}
private async fetch(): Promise<ConfigTable> {
this.logger.context("fetch").log("fetching");
const startTime = performance.now();
const result = await this.db
.selectFrom(this.table)
.selectAll()
.where("type", "=", "config")
.orderBy("version", "desc")
.executeTakeFirstOrThrow();
// disabling console log, because the table might not exist yet
return await withDisabledConsole(async () => {
const startTime = performance.now();
const { data: result } = await this.repo().findOne(
{ type: "config" },
{
sort: { by: "version", dir: "desc" }
}
);
this.logger.log("took", performance.now() - startTime, "ms", result).clear();
return result;
if (!result) {
throw BkndError.with("no config");
}
this.logger.log("took", performance.now() - startTime, "ms", result.version).clear();
return result as ConfigTable;
}, ["log", "error", "warn"]);
}
async save() {
@@ -181,65 +256,75 @@ export class ModuleManager {
const configs = this.configs();
const version = this.version();
const json = JSON.stringify(configs) as any;
const state = await this.fetch();
try {
const state = await this.fetch();
this.logger.log("fetched version", state.version);
if (state.version !== version) {
// @todo: mark all others as "backup"
this.logger.log("version conflict, storing new version", state.version, version);
await this.db
.insertInto(this.table)
.values({
version,
if (state.version !== version) {
// @todo: mark all others as "backup"
this.logger.log("version conflict, storing new version", state.version, version);
await this.mutator().insertOne({
version: state.version,
type: "backup",
json: configs
});
await this.mutator().insertOne({
version: version,
type: "config",
json
})
.execute();
} else {
this.logger.log("version matches");
json: configs
});
} else {
this.logger.log("version matches");
const diff = Diff(state.json, JSON.parse(json));
this.logger.log("checking diff", diff);
// clean configs because of Diff() function
const diffs = diff(state.json, clone(configs));
this.logger.log("checking diff", diffs);
if (diff.length > 0) {
// store diff
await this.db
.insertInto(this.table)
.values({
if (diff.length > 0) {
// store diff
await this.mutator().insertOne({
version,
type: "diff",
json: JSON.stringify(diff) as any
})
.execute();
json: clone(diffs)
});
await this.db
.updateTable(this.table)
.set({ version, json, updated_at: sql`CURRENT_TIMESTAMP` })
.where((eb) => eb.and([eb("type", "=", "config"), eb("version", "=", version)]))
.execute();
// store new version
await this.mutator().updateWhere(
{
version,
json: configs,
updated_at: new Date()
},
{
type: "config",
version
}
);
} else {
this.logger.log("no diff, not saving");
}
}
} catch (e) {
if (e instanceof BkndError) {
this.logger.log("no config, just save fresh");
// no config, just save
await this.mutator().insertOne({
type: "config",
version,
json: configs,
created_at: new Date(),
updated_at: new Date()
});
} else if (e instanceof TransformPersistFailedException) {
console.error("Cannot save invalid config");
throw e;
} else {
this.logger.log("no diff, not saving");
console.error("Aborting");
throw e;
}
}
// cleanup
/*this.logger.log("cleaning up");
const result = await this.db
.deleteFrom(this.table)
.where((eb) =>
eb.or([
// empty migrations
eb.and([
eb("type", "=", "config"),
eb("version", "<", version),
eb("json", "is", null)
]),
// past diffs
eb.and([eb("type", "=", "diff"), eb("version", "<", version)])
])
)
.executeTakeFirst();
this.logger.log("cleaned up", result.numDeletedRows);*/
// @todo: cleanup old versions?
this.logger.clear();
return this;
@@ -250,6 +335,8 @@ export class ModuleManager {
if (this.version() < CURRENT_VERSION) {
this.logger.log("there are migrations, verify version");
// sync __bknd table
await this.syncConfigTable();
// modules must be built before migration
await this.buildModules({ graceful: true });
@@ -264,14 +351,7 @@ export class ModuleManager {
}
} catch (e: any) {
this.logger.clear(); // fetch couldn't clear
// if table doesn't exist, migrate schema to version
if (e.message.includes("no such table")) {
this.logger.log("table has to created, migrating schema up to", this.version());
await migrateSchema(this.version(), { db: this.db });
} else {
throw new Error(`Version is ${this.version()}, fetch failed: ${e.message}`);
}
throw new Error(`Version is ${this.version()}, fetch failed: ${e.message}`);
}
this.logger.log("now migrating");
@@ -289,9 +369,6 @@ export class ModuleManager {
configs = _configs;
this.setConfigs(configs);
/* objectEach(configs, (config, key) => {
this.get(key as any).setConfig(config);
}); */
this._version = version;
this.logger.log("migrated to", version);
@@ -325,10 +402,11 @@ export class ModuleManager {
return;
}
this.logger.log("building");
const ctx = this.ctx(true);
for (const key in this.modules) {
this.logger.log(`building "${key}"`);
await this.modules[key].setContext(ctx).build();
this.logger.log("built", key);
}
this._built = true;
@@ -337,37 +415,41 @@ export class ModuleManager {
async build() {
this.logger.context("build").log("version", this.version());
this.logger.log("booted with", this._booted_with);
// if no config provided, try fetch from db
if (this.version() === 0) {
this.logger.context("build no config").log("version is 0");
this.logger.context("no version").log("version is 0");
try {
const result = await this.fetch();
// set version and config from fetched
this._version = result.version;
if (this.version() !== CURRENT_VERSION) {
await this.syncConfigTable();
}
if (this.options?.trustFetched === true) {
this.logger.log("trusting fetched config (mark)");
mark(result.json);
}
this.setConfigs(result.json);
} catch (e: any) {
this.logger.clear(); // fetch couldn't clear
this.logger.context("error handler").log("fetch failed", e.message);
// if table doesn't exist, migrate schema, set default config and latest version
if (e.message.includes("no such table")) {
this.logger.log("migrate schema to", CURRENT_VERSION);
await migrateSchema(CURRENT_VERSION, { db: this.db });
this._version = CURRENT_VERSION;
// we can safely build modules, since config version is up to date
// it's up to date because we use default configs (no fetch result)
await this.buildModules();
await this.save();
// we can safely build modules, since config version is up to date
// it's up to date because we use default configs (no fetch result)
this._version = CURRENT_VERSION;
await this.syncConfigTable();
await this.buildModules();
await this.save();
this.logger.clear();
return this;
} else {
throw e;
//throw new Error("Issues connecting to the database. Reason: " + e.message);
}
this.logger.clear();
return this;
}
this.logger.clear();
}
+11 -48
View File
@@ -16,26 +16,8 @@ export type Migration = {
export const migrations: Migration[] = [
{
version: 1,
schema: true,
up: async (config, { db }) => {
//console.log("config given", config);
await db.schema
.createTable(TABLE_NAME)
.addColumn("id", "integer", (col) => col.primaryKey().notNull().autoIncrement())
.addColumn("version", "integer", (col) => col.notNull())
.addColumn("type", "text", (col) => col.notNull())
.addColumn("json", "text")
.addColumn("created_at", "datetime", (col) => col.defaultTo(sql`CURRENT_TIMESTAMP`))
.addColumn("updated_at", "datetime", (col) => col.defaultTo(sql`CURRENT_TIMESTAMP`))
.execute();
await db
.insertInto(TABLE_NAME)
.values({ version: 1, type: "config", json: null })
.execute();
return config;
}
//schema: true,
up: async (config) => config
},
{
version: 2,
@@ -45,12 +27,8 @@ export const migrations: Migration[] = [
},
{
version: 3,
schema: true,
up: async (config, { db }) => {
await db.schema.alterTable(TABLE_NAME).addColumn("deleted_at", "datetime").execute();
return config;
}
//schema: true,
up: async (config) => config
},
{
version: 4,
@@ -94,6 +72,13 @@ export const migrations: Migration[] = [
};
}
}
/*{
version: 8,
up: async (config, { db }) => {
await db.deleteFrom(TABLE_NAME).where("type", "=", "diff").execute();
return config;
}
}*/
];
export const CURRENT_VERSION = migrations[migrations.length - 1]?.version ?? 0;
@@ -127,28 +112,6 @@ export async function migrateTo(
return [version, updated];
}
export async function migrateSchema(to: number, ctx: MigrationContext, current: number = 0) {
console.log("migrating SCHEMA to", to, "from", current);
const todo = migrations.filter((m) => m.version > current && m.version <= to && m.schema);
console.log("todo", todo.length);
let i = 0;
let version = 0;
for (const migration of todo) {
console.log("-- running migration", i + 1, "of", todo.length);
try {
await migration.up({}, ctx);
version = migration.version;
i++;
} catch (e: any) {
console.error(e);
throw new Error(`Migration ${migration.version} failed: ${e.message}`);
}
}
return version;
}
export async function migrate(
current: number,
config: GenericConfigObject,
+18 -4
View File
@@ -10,9 +10,11 @@ import * as SystemPermissions from "modules/permissions";
const htmlBkndContextReplace = "<!-- BKND_CONTEXT -->";
// @todo: add migration to remove admin path from config
export type AdminControllerOptions = {
basepath?: string;
html?: string;
forceDev?: boolean;
forceDev?: boolean | { mainPath: string };
};
export class AdminController implements ClassController {
@@ -25,8 +27,12 @@ export class AdminController implements ClassController {
return this.app.modules.ctx();
}
get basepath() {
return this.options.basepath ?? "/";
}
private withBasePath(route: string = "") {
return (this.app.modules.configs().server.admin.basepath + route).replace(/\/+$/, "/");
return (this.basepath + route).replace(/\/+$/, "/");
}
getController(): Hono<any> {
@@ -102,7 +108,10 @@ export class AdminController implements ClassController {
if (this.options.html) {
if (this.options.html.includes(htmlBkndContextReplace)) {
return this.options.html.replace(htmlBkndContextReplace, bknd_context);
return this.options.html.replace(
htmlBkndContextReplace,
"<script>" + bknd_context + "</script>"
);
}
console.warn(
@@ -113,6 +122,10 @@ export class AdminController implements ClassController {
const configs = this.app.modules.configs();
const isProd = !isDebug() && !this.options.forceDev;
const mainPath =
typeof this.options.forceDev === "object" && "mainPath" in this.options.forceDev
? this.options.forceDev.mainPath
: "/src/ui/main.tsx";
const assets = {
js: "main.js",
@@ -166,13 +179,14 @@ export class AdminController implements ClassController {
)}
</head>
<body>
<div id="root" />
<div id="app" />
<script
dangerouslySetInnerHTML={{
__html: bknd_context
}}
/>
{!isProd && <script type="module" src="/src/ui/main.tsx" />}
{!isProd && <script type="module" src={mainPath} />}
</body>
</html>
</Fragment>
+17 -6
View File
@@ -1,26 +1,37 @@
import { MantineProvider } from "@mantine/core";
import { Notifications } from "@mantine/notifications";
import type { ModuleConfigs } from "modules";
import React from "react";
import { BkndProvider, useBknd } from "ui/client/bknd";
import { FlashMessage } from "ui/modules/server/FlashMessage";
import { BkndProvider, ClientProvider, useBknd } from "./client";
import { ClientProvider, type ClientProviderProps } from "./client";
import { createMantineTheme } from "./lib/mantine/theme";
import { BkndModalsProvider } from "./modals";
import { Routes } from "./routes";
export type BkndAdminProps = {
baseUrl?: string;
withProvider?: boolean;
// @todo: add admin config override
withProvider?: boolean | ClientProviderProps;
config?: ModuleConfigs["server"]["admin"];
};
export default function Admin({ baseUrl: baseUrlOverride, withProvider = false }: BkndAdminProps) {
export default function Admin({
baseUrl: baseUrlOverride,
withProvider = false,
config
}: BkndAdminProps) {
const Component = (
<BkndProvider>
<BkndProvider adminOverride={config}>
<AdminInternal />
</BkndProvider>
);
return withProvider ? (
<ClientProvider baseUrl={baseUrlOverride}>{Component}</ClientProvider>
<ClientProvider
baseUrl={baseUrlOverride}
{...(typeof withProvider === "object" ? withProvider : {})}
>
{Component}
</ClientProvider>
) : (
Component
);
+11 -17
View File
@@ -1,4 +1,3 @@
//import { notifications } from "@mantine/notifications";
import { getDefaultConfig, getDefaultSchema } from "modules/ModuleManager";
import { createContext, startTransition, useContext, useEffect, useRef, useState } from "react";
import type { ModuleConfigs, ModuleSchemas } from "../../modules";
@@ -14,6 +13,7 @@ type BkndContext = {
requireSecrets: () => Promise<void>;
actions: ReturnType<typeof getSchemaActions>;
app: AppReduced;
adminOverride?: ModuleConfigs["server"]["admin"];
};
const BkndContext = createContext<BkndContext>(undefined!);
@@ -21,8 +21,9 @@ export type { TSchemaActions };
export function BkndProvider({
includeSecrets = false,
adminOverride,
children
}: { includeSecrets?: boolean; children: any }) {
}: { includeSecrets?: boolean; children: any } & Pick<BkndContext, "adminOverride">) {
const [withSecrets, setWithSecrets] = useState<boolean>(includeSecrets);
const [schema, setSchema] =
useState<Pick<BkndContext, "version" | "schema" | "config" | "permissions">>();
@@ -64,6 +65,13 @@ export function BkndProvider({
permissions: []
} as any);
if (adminOverride) {
schema.config.server.admin = {
...schema.config.server.admin,
...adminOverride
};
}
startTransition(() => {
setSchema(schema);
setWithSecrets(_includeSecrets);
@@ -86,26 +94,12 @@ export function BkndProvider({
const actions = getSchemaActions({ client, setSchema, reloadSchema });
return (
<BkndContext.Provider value={{ ...schema, actions, requireSecrets, app }}>
<BkndContext.Provider value={{ ...schema, actions, requireSecrets, app, adminOverride }}>
{children}
</BkndContext.Provider>
);
}
type BkndWindowContext = {
user?: object;
logout_route: string;
};
export function useBkndWindowContext(): BkndWindowContext {
if (typeof window !== "undefined" && window.__BKND__) {
return window.__BKND__ as any;
} else {
return {
logout_route: "/api/auth/logout"
};
}
}
export function useBknd({ withSecrets }: { withSecrets?: boolean } = {}): BkndContext {
const ctx = useContext(BkndContext);
if (withSecrets) ctx.requireSecrets();
+22 -6
View File
@@ -1,7 +1,7 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { TApiUser } from "Api";
import { createContext, useContext, useEffect, useState } from "react";
import { useBkndWindowContext } from "ui/client/BkndProvider";
//import { useBkndWindowContext } from "ui/client/BkndProvider";
import { AppQueryClient } from "./utils/AppQueryClient";
const ClientContext = createContext<{ baseUrl: string; client: AppQueryClient }>({
@@ -17,11 +17,13 @@ export const queryClient = new QueryClient({
}
});
export const ClientProvider = ({
children,
baseUrl,
user
}: { children?: any; baseUrl?: string; user?: TApiUser | null }) => {
export type ClientProviderProps = {
children?: any;
baseUrl?: string;
user?: TApiUser | null | undefined;
};
export const ClientProvider = ({ children, baseUrl, user }: ClientProviderProps) => {
const [actualBaseUrl, setActualBaseUrl] = useState<string | null>(null);
const winCtx = useBkndWindowContext();
@@ -87,3 +89,17 @@ export const useBaseUrl = () => {
const context = useContext(ClientContext);
return context.baseUrl;
};
type BkndWindowContext = {
user?: object;
logout_route: string;
};
export function useBkndWindowContext(): BkndWindowContext {
if (typeof window !== "undefined" && window.__BKND__) {
return window.__BKND__ as any;
} else {
return {
logout_route: "/api/auth/logout"
};
}
}
+1
View File
@@ -0,0 +1 @@
export { BkndProvider, useBknd } from "./BkndProvider";
+7 -2
View File
@@ -1,5 +1,10 @@
export { ClientProvider, useClient, useBaseUrl } from "./ClientProvider";
export { BkndProvider, useBknd } from "./BkndProvider";
export {
ClientProvider,
useBkndWindowContext,
type ClientProviderProps,
useClient,
useBaseUrl
} from "./ClientProvider";
export { useAuth } from "./schema/auth/use-auth";
export { Api } from "../../Api";
@@ -1,4 +1,4 @@
import { useBknd } from "ui/client";
import { useBknd } from "ui/client/bknd";
export function useBkndAuth() {
//const client = useClient();
@@ -11,7 +11,7 @@ import {
fieldsSchema,
relationsSchema
} from "data/data-schema";
import { useBknd } from "ui/client";
import { useBknd } from "ui/client/bknd";
import type { TSchemaActions } from "ui/client/schema/actions";
export function useBkndData() {
@@ -1,4 +1,4 @@
import { useBknd } from "ui/client";
import { useBknd } from "ui/client/bknd";
export function useBkndSystem() {
const { config, schema, actions: bkndActions } = useBknd();
+1 -1
View File
@@ -1,4 +1,4 @@
import { useBknd } from "ui";
import { useBknd } from "ui/client/bknd";
export function useTheme(): { theme: "light" | "dark" } {
const b = useBknd();
@@ -25,12 +25,6 @@ export const layoutWithDagre = ({ nodes, edges, graph }: LayoutProps) => {
const dagreGraph = new Dagre.graphlib.Graph();
dagreGraph.setDefaultEdgeLabel(() => ({}));
dagreGraph.setGraph(graph || {});
/*dagreGraph.setGraph({
rankdir: "LR",
align: "UR",
nodesep: NODE_SEP,
ranksep: RANK_SEP
});*/
nodes.forEach((node) => {
dagreGraph.setNode(node.id, {
@@ -48,7 +42,11 @@ export const layoutWithDagre = ({ nodes, edges, graph }: LayoutProps) => {
return {
nodes: nodes.map((node) => {
const position = dagreGraph.node(node.id);
return { ...node, x: position.x, y: position.y };
return {
...node,
x: position.x - (node.width ?? 0) / 2,
y: position.y - (node.height ?? 0) / 2
};
}),
edges
};
+1 -1
View File
@@ -1,6 +1,6 @@
import type { ReactCodeMirrorProps } from "@uiw/react-codemirror";
import { Suspense, lazy } from "react";
import { useBknd } from "ui/client";
import { useBknd } from "ui/client/bknd";
const CodeMirror = lazy(() => import("@uiw/react-codemirror"));
export default function CodeEditor({ editable, basicSetup, ...props }: ReactCodeMirrorProps) {
+2 -2
View File
@@ -1,11 +1,11 @@
import type { ComponentPropsWithoutRef } from "react";
import type { ComponentPropsWithoutRef, ReactNode } from "react";
import { twMerge } from "tailwind-merge";
export type AlertProps = ComponentPropsWithoutRef<"div"> & {
className?: string;
visible?: boolean;
title?: string;
message?: string;
message?: ReactNode | string;
};
const Base: React.FC<AlertProps> = ({ visible = true, title, message, className, ...props }) =>
@@ -12,7 +12,7 @@ import type {
ValidatorType
} from "@rjsf/utils";
import { toErrorSchema } from "@rjsf/utils";
import get from "lodash-es/get";
import { get } from "lodash-es";
function removeUndefinedKeys(obj: any): any {
if (!obj) return obj;
@@ -14,9 +14,7 @@ import {
getWidget,
mergeSchemas
} from "@rjsf/utils";
import get from "lodash-es/get";
import isEmpty from "lodash-es/isEmpty";
import omit from "lodash-es/omit";
import { get, isEmpty, omit } from "lodash-es";
import { Component } from "react";
import { twMerge } from "tailwind-merge";
import { Label } from "../templates/FieldTemplate";
+1 -11
View File
@@ -1,14 +1,4 @@
export { default as Admin } from "./Admin";
export { Button } from "./components/buttons/Button";
export { Context } from "./components/Context";
export {
useClient,
ClientProvider,
BkndProvider,
useBknd,
useAuth,
useBaseUrl
} from "./client";
export { default as Admin, type BkndAdminProps } from "./Admin";
export {
EntitiesContainer,
useEntities,
+8 -7
View File
@@ -1,21 +1,19 @@
import { Menu, Popover, SegmentedControl, Tooltip } from "@mantine/core";
import { SegmentedControl, Tooltip } from "@mantine/core";
import { IconKeyOff, IconSettings, IconUser } from "@tabler/icons-react";
import {
TbDatabase,
TbFingerprint,
TbHierarchy2,
TbMenu2,
TbMoon,
TbPhoto,
TbSelector,
TbSun,
TbUser,
TbX
} from "react-icons/tb";
import { Button } from "ui";
import { useAuth, useBknd } from "ui/client";
import { useBkndWindowContext } from "ui/client/BkndProvider";
import { useAuth, useBkndWindowContext } from "ui/client";
import { useBknd } from "ui/client/bknd";
import { useBkndSystemTheme } from "ui/client/schema/system/use-bknd-system";
import { Button } from "ui/components/buttons/Button";
import { IconButton } from "ui/components/buttons/IconButton";
import { Logo } from "ui/components/display/Logo";
import { Dropdown, type DropdownItem } from "ui/components/overlay/Dropdown";
@@ -146,6 +144,7 @@ export function Header({ hasSidebar = true }) {
}
function UserMenu() {
const { adminOverride } = useBknd();
const auth = useAuth();
const [navigate] = useNavigate();
const { logout_route } = useBkndWindowContext();
@@ -170,7 +169,9 @@ function UserMenu() {
items.push({ label: `Logout ${auth.user.email}`, onClick: handleLogout, icon: IconKeyOff });
}
items.push(() => <UserMenuThemeToggler />);
if (!adminOverride) {
items.push(() => <UserMenuThemeToggler />);
}
return (
<>
+1 -1
View File
@@ -1,5 +1,5 @@
import { useRef, useState } from "react";
import { Button } from "ui";
import { Button } from "ui/components/buttons/Button";
import {
JsonSchemaForm,
type JsonSchemaFormProps,
+1 -1
View File
@@ -3,7 +3,7 @@ import { Type } from "core/utils";
import type { ComponentPropsWithoutRef } from "react";
import { useForm } from "react-hook-form";
import { twMerge } from "tailwind-merge";
import { Button } from "ui";
import { Button } from "ui/components/buttons/Button";
import * as Formy from "ui/components/form/Formy";
export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "onSubmit"> & {
@@ -135,10 +135,10 @@ type FormInputElement = HTMLInputElement | HTMLTextAreaElement;
function EntityFormField({ fieldApi, field, action, data, ...props }: EntityFormFieldProps) {
const handleUpdate = useEvent((e: React.ChangeEvent<FormInputElement> | any) => {
if (typeof e === "object" && "target" in e) {
console.log("handleUpdate", e.target.value);
//console.log("handleUpdate", e.target.value);
fieldApi.handleChange(e.target.value);
} else {
console.log("handleUpdate-", e);
//console.log("handleUpdate-", e);
fieldApi.handleChange(e);
}
});
@@ -69,15 +69,13 @@ export function DataSchemaCanvas() {
const nodeLayout = layoutWithDagre({
nodes: nodes.map((n) => ({
id: n.id,
...EntityTableNode.getSize(n)
...EntityTableNode.getSize(n.data)
})),
edges,
graph: {
rankdir: "LR",
//align: "UR",
ranker: "network-simplex",
nodesep: 350,
ranksep: 50
marginx: 50,
marginy: 50
}
});
@@ -88,12 +86,6 @@ export function DataSchemaCanvas() {
}
});
/*const _edges = edges.map((e) => ({
...e,
source: e.source + `-${e.target}_id`,
target: e.target + "-id"
}));*/
return (
<ReactFlowProvider>
<Canvas
@@ -4,8 +4,9 @@ import { ucFirst } from "core/utils";
import type { EntityData, RelationField } from "data";
import { useEffect, useRef, useState } from "react";
import { TbEye } from "react-icons/tb";
import { Button } from "ui";
import { useBknd, useClient } from "ui/client";
import { useClient } from "ui/client";
import { useBknd } from "ui/client/bknd";
import { Button } from "ui/components/buttons/Button";
import * as Formy from "ui/components/form/Formy";
import { Popover } from "ui/components/overlay/Popover";
import { useEntities } from "ui/container";
@@ -1,6 +1,8 @@
import { typeboxResolver } from "@hookform/resolvers/typebox";
import type { Static } from "core/utils";
import { type TAppDataEntityFields, entitiesSchema } from "data/data-schema";
import { useRef, useState } from "react";
import { mergeWith } from "lodash-es";
import { useRef } from "react";
import { useForm } from "react-hook-form";
import { MantineSelect } from "ui/components/form/hook-form-mantine/MantineSelect";
import { useEvent } from "ui/hooks/use-event";
@@ -11,31 +13,31 @@ import {
import { ModalBody, ModalFooter, type TCreateModalSchema, useStepContext } from "./CreateModal";
const schema = entitiesSchema;
type Schema = Static<typeof schema>;
export function StepEntityFields() {
const { nextStep, stepBack, state, setState } = useStepContext<TCreateModalSchema>();
const entity = state.entities?.create?.[0]!;
const defaultFields = { id: { type: "primary", name: "id" } } as const;
const ref = useRef<EntityFieldsFormRef>(null);
const initial = mergeWith(entity, {
fields: defaultFields,
config: {
sort_field: "id",
sort_dir: "asc"
}
});
const {
control,
formState: { isValid, errors },
getValues,
handleSubmit,
watch,
register,
setValue
} = useForm({
mode: "onTouched",
resolver: typeboxResolver(schema),
defaultValues: {
...entity,
fields: defaultFields,
config: {
sort_field: "id",
sort_dir: "asc"
}
}
defaultValues: initial as NonNullable<Schema>
});
const values = watch();
@@ -74,7 +76,11 @@ export function StepEntityFields() {
Add fields to <strong>{entity.name}</strong>:
</p>
<div className="flex flex-col gap-1">
<EntityFieldsForm ref={ref} fields={defaultFields} onChange={updateListener} />
<EntityFieldsForm
ref={ref}
fields={initial.fields as any}
onChange={updateListener}
/>
</div>
</div>
<div className="flex flex-col gap-3">
@@ -82,7 +88,7 @@ export function StepEntityFields() {
<div className="flex flex-row gap-2">
<MantineSelect
label="Field"
data={Object.keys(values.fields).filter((name) => name.length > 0)}
data={Object.keys(values.fields ?? {}).filter((name) => name.length > 0)}
placeholder="Select field"
name="config.sort_field"
allowDeselect={false}
@@ -102,7 +108,7 @@ export function StepEntityFields() {
<div>
{Object.entries(errors).map(([key, value]) => (
<p key={key}>
{key}: {value.message}
{key}: {(value as any).message}
</p>
))}
</div>
@@ -11,7 +11,7 @@ import {
import { ManyToOneRelation, type RelationType, RelationTypes } from "data";
import { type ReactNode, useEffect } from "react";
import { type Control, type FieldValues, type UseFormRegister, useForm } from "react-hook-form";
import { useBknd } from "ui/client";
import { useBknd } from "ui/client/bknd";
import { MantineNumberInput } from "ui/components/form/hook-form-mantine/MantineNumberInput";
import { MantineSelect } from "ui/components/form/hook-form-mantine/MantineSelect";
import { useStepContext } from "ui/components/steps/Steps";
@@ -10,7 +10,7 @@ import {
} from "core/utils";
import type { MediaFieldConfig } from "media/MediaField";
import { useForm } from "react-hook-form";
import { useBknd } from "ui/client";
import { useBknd } from "ui/client/bknd";
import { MantineNumberInput } from "ui/components/form/hook-form-mantine/MantineNumberInput";
import { MantineRadio } from "ui/components/form/hook-form-mantine/MantineRadio";
import { MantineSelect } from "ui/components/form/hook-form-mantine/MantineSelect";
@@ -8,7 +8,7 @@ import { Type } from "core/utils";
import { FetchTask } from "flows";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { Button } from "ui";
import { Button } from "ui/components/buttons/Button";
import { JsonViewer } from "ui/components/code/JsonViewer";
import { SegmentedControl } from "ui/components/form/SegmentedControl";
import { MantineSelect } from "ui/components/form/hook-form-mantine/MantineSelect";
@@ -5,7 +5,7 @@ import { selectAtom } from "jotai/utils";
import { isEqual } from "lodash-es";
import type { ModuleSchemas } from "modules/ModuleManager";
import { createContext, useCallback, useContext, useEffect } from "react";
import { useBknd } from "ui/client";
import { useBknd } from "ui/client/bknd";
export type TFlowNodeData = {
label: string;
+1 -1
View File
@@ -1,6 +1,6 @@
import { IconFingerprint } from "@tabler/icons-react";
import { TbSettings } from "react-icons/tb";
import { useBknd } from "ui/client";
import { useBknd } from "ui/client/bknd";
import { IconButton } from "ui/components/buttons/IconButton";
import { Empty } from "ui/components/display/Empty";
import { Link } from "ui/components/wouter/Link";
+2 -1
View File
@@ -1,4 +1,5 @@
import { useBknd, useClient } from "ui/client";
import { useClient } from "ui/client";
import { useBknd } from "ui/client/bknd";
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
import { Alert } from "ui/components/display/Alert";
import { routes } from "ui/lib/routes";
@@ -1,7 +1,7 @@
import { notifications } from "@mantine/notifications";
import { useRef } from "react";
import { TbDots } from "react-icons/tb";
import { useBknd } from "ui/client";
import { useBknd } from "ui/client/bknd";
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
import { Button } from "ui/components/buttons/Button";
import { IconButton } from "ui/components/buttons/IconButton";
+1 -1
View File
@@ -1,6 +1,6 @@
import { cloneDeep, omit } from "lodash-es";
import { useEffect, useRef } from "react";
import { useBknd } from "ui/client";
import { useBknd } from "ui/client/bknd";
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
import { Button } from "ui/components/buttons/Button";
+1 -1
View File
@@ -1,5 +1,5 @@
import { cloneDeep, omit } from "lodash-es";
import { useBknd } from "ui/client";
import { useBknd } from "ui/client/bknd";
import { Button } from "ui/components/buttons/Button";
import { JsonSchemaForm } from "ui/components/form/json-schema/JsonSchemaForm";
import * as AppShell from "../../layouts/AppShell/AppShell";
+2 -2
View File
@@ -2,10 +2,10 @@ import { typeboxResolver } from "@hookform/resolvers/typebox";
import { Input, Switch, Tooltip } from "@mantine/core";
import { guardRoleSchema } from "auth/auth-schema";
import { type Static, ucFirst } from "core/utils";
import type { TAppDataEntityFields } from "data/data-schema";
import { forwardRef, useImperativeHandle } from "react";
import { type UseControllerProps, useController, useForm } from "react-hook-form";
import { Button, useBknd } from "ui";
import { useBknd } from "ui/client/bknd";
import { Button } from "ui/components/buttons/Button";
import { MantineSwitch } from "ui/components/form/hook-form-mantine/MantineSwitch";
const schema = guardRoleSchema;
+6 -6
View File
@@ -2,12 +2,12 @@ import { SegmentedControl } from "@mantine/core";
import { IconDatabase } from "@tabler/icons-react";
import type { Entity, TEntityType } from "data";
import { twMerge } from "tailwind-merge";
import { useBknd } from "../../client";
import { Empty } from "../../components/display/Empty";
import { Link } from "../../components/wouter/Link";
import { useBrowserTitle } from "../../hooks/use-browser-title";
import * as AppShell from "../../layouts/AppShell/AppShell";
import { routes, useNavigate } from "../../lib/routes";
import { useBknd } from "ui/client/bknd";
import { Empty } from "ui/components/display/Empty";
import { Link } from "ui/components/wouter/Link";
import { useBrowserTitle } from "ui/hooks/use-browser-title";
import * as AppShell from "ui/layouts/AppShell/AppShell";
import { routes, useNavigate } from "ui/lib/routes";
export function DataRoot({ children }) {
// @todo: settings routes should be centralized
+14 -17
View File
@@ -1,24 +1,21 @@
import { encodeSearch, ucFirst } from "core/utils";
import type { Entity, EntityData } from "data";
import type { EntityRelation } from "data";
import { Fragment, memo, useState } from "react";
import { TbArrowLeft, TbDots } from "react-icons/tb";
import { ucFirst } from "core/utils";
import type { Entity, EntityData, EntityRelation } from "data";
import { Fragment, useState } from "react";
import { TbDots } from "react-icons/tb";
import { useClient } from "ui/client";
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
import { Button } from "ui/components/buttons/Button";
import { IconButton } from "ui/components/buttons/IconButton";
import { Dropdown } from "ui/components/overlay/Dropdown";
import { useEntity } from "ui/container";
import { useBrowserTitle } from "ui/hooks/use-browser-title";
import * as AppShell from "ui/layouts/AppShell/AppShell";
import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2";
import { routes, useNavigate } from "ui/lib/routes";
import { bkndModals } from "ui/modals";
import { EntityForm } from "ui/modules/data/components/EntityForm";
import { EntityTable2 } from "ui/modules/data/components/EntityTable2";
import { useEntityForm } from "ui/modules/data/hooks/useEntityForm";
import { useClient } from "../../client";
import { useBknd } from "../../client";
import { Button } from "../../components/buttons/Button";
import { IconButton } from "../../components/buttons/IconButton";
import { Dropdown } from "../../components/overlay/Dropdown";
import { useEntity } from "../../container";
import { useBrowserTitle } from "../../hooks/use-browser-title";
import * as AppShell from "../../layouts/AppShell/AppShell";
import { SectionHeaderLink } from "../../layouts/AppShell/AppShell";
import { Breadcrumbs2 } from "../../layouts/AppShell/Breadcrumbs2";
import { routes, useNavigate } from "../../lib/routes";
import { bkndModals } from "../../modals";
export function DataEntityUpdate({ params }) {
const { $data, relations } = useBkndData();
+8 -10
View File
@@ -2,18 +2,16 @@ import { Type } from "core/utils";
import { querySchema } from "data";
import { TbDots } from "react-icons/tb";
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
import { Empty } from "ui/components/display/Empty";
import { Button } from "ui/components/buttons/Button";
import { IconButton } from "ui/components/buttons/IconButton";
import { Message } from "ui/components/display/Message";
import { Dropdown } from "ui/components/overlay/Dropdown";
import { EntitiesContainer } from "ui/container";
import { useBrowserTitle } from "ui/hooks/use-browser-title";
import { useSearch } from "ui/hooks/use-search";
import * as AppShell from "ui/layouts/AppShell/AppShell";
import { routes, useNavigate } from "ui/lib/routes";
import { EntityTable2 } from "ui/modules/data/components/EntityTable2";
import { useBknd } from "../../client";
import { Button } from "../../components/buttons/Button";
import { IconButton } from "../../components/buttons/IconButton";
import { Dropdown } from "../../components/overlay/Dropdown";
import { EntitiesContainer } from "../../container";
import { useBrowserTitle } from "../../hooks/use-browser-title";
import { useSearch } from "../../hooks/use-search";
import * as AppShell from "../../layouts/AppShell/AppShell";
import { routes, useNavigate } from "../../lib/routes";
// @todo: migrate to Typebox
const searchSchema = Type.Composite(
@@ -18,9 +18,7 @@ import { forwardRef, memo, useEffect, useImperativeHandle } from "react";
import { type FieldArrayWithId, type UseFormReturn, useFieldArray, useForm } from "react-hook-form";
import { TbGripVertical, TbSettings, TbTrash } from "react-icons/tb";
import { twMerge } from "tailwind-merge";
import { Button } from "ui";
import { useBknd } from "ui/client";
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
import { Button } from "ui/components/buttons/Button";
import { IconButton } from "ui/components/buttons/IconButton";
import { JsonViewer } from "ui/components/code/JsonViewer";
import { MantineSwitch } from "ui/components/form/hook-form-mantine/MantineSwitch";
@@ -74,22 +72,16 @@ export const EntityFieldsForm = forwardRef<
name,
field
}));
/*const entityFields = entity.fields.map((field) => ({
name: field.name,
field: field.toJSON()
}));*/
const {
control,
formState: { isValid, errors },
getValues,
handleSubmit,
watch,
register,
setValue,
setError,
reset,
clearErrors
reset
} = useForm({
mode: "all",
resolver: typeboxResolver(schema),
@@ -116,7 +108,6 @@ export const EntityFieldsForm = forwardRef<
props?.onChange?.(toCleanValues(data));
});
}
//props?.onChange?.()
}, []);
useImperativeHandle(ref, () => ({
@@ -124,25 +115,9 @@ export const EntityFieldsForm = forwardRef<
getValues: () => getValues(),
getData: () => {
return toCleanValues(getValues());
/*return Object.fromEntries(
getValues().fields.map((field) => [field.name, objectCleanEmpty(field.field)])
);*/
},
isValid: () => isValid
}));
console.log("errors", errors.fields);
/*useEffect(() => {
console.log("change", values);
onSubmit(values);
}, [values]);*/
function onSubmit(data: TFieldsFormSchema) {
console.log("submit", isValid, data, errors);
}
function onSubmitInvalid(a, b) {
console.log("submit invalid", a, b);
}
function handleAppend(_type: keyof typeof fieldsSchemaObject) {
const newField = {
@@ -153,7 +128,6 @@ export const EntityFieldsForm = forwardRef<
config: {}
}
};
console.log("handleAppend", _type, newField);
append(newField);
}
@@ -167,10 +141,7 @@ export const EntityFieldsForm = forwardRef<
};
return (
<>
<form
onSubmit={handleSubmit(onSubmit as any, onSubmitInvalid)}
className="flex flex-col gap-6"
>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-4">
{sortable ? (
@@ -222,9 +193,7 @@ export const EntityFieldsForm = forwardRef<
</Popover>
</div>
</div>
<button type="submit" className="hidden" />
{/*<Debug watch={watch} errors={errors} />*/}
</form>
</div>
</>
);
});
+1 -1
View File
@@ -1,5 +1,5 @@
import { Suspense, lazy } from "react";
import { useBknd } from "ui/client";
import { useBknd } from "ui/client/bknd";
import { Route, Router, Switch } from "wouter";
import { AuthLogin } from "./auth/auth.login";
import { Root, RootEmpty } from "./root";
+3 -4
View File
@@ -1,7 +1,6 @@
import { IconFingerprint, IconHome } from "@tabler/icons-react";
import { isDebug } from "core";
import { Suspense, lazy, useEffect } from "react";
import { useAuth } from "ui";
import { IconHome } from "@tabler/icons-react";
import { Suspense, useEffect } from "react";
import { useAuth } from "ui/client";
import { Empty } from "../components/display/Empty";
import { useBrowserTitle } from "../hooks/use-browser-title";
import * as AppShell from "../layouts/AppShell/AppShell";
@@ -3,23 +3,21 @@ import { type TObject, ucFirst } from "core/utils";
import { omit } from "lodash-es";
import { type ReactNode, useMemo, useRef, useState } from "react";
import { TbSettings } from "react-icons/tb";
import { useAuth } from "ui";
import { useBknd } from "ui/client/bknd";
import { Button } from "ui/components/buttons/Button";
import { IconButton } from "ui/components/buttons/IconButton";
import { Alert } from "ui/components/display/Alert";
import { Link, Route, useLocation } from "wouter";
import { useBknd } from "../../../client/BkndProvider";
import { Button } from "../../../components/buttons/Button";
import { IconButton } from "../../../components/buttons/IconButton";
import { Empty } from "../../../components/display/Empty";
import { Empty } from "ui/components/display/Empty";
import {
JsonSchemaForm,
type JsonSchemaFormRef
} from "../../../components/form/json-schema/JsonSchemaForm";
import { Dropdown } from "../../../components/overlay/Dropdown";
import { DataTable } from "../../../components/table/DataTable";
import { useEvent } from "../../../hooks/use-event";
import * as AppShell from "../../../layouts/AppShell/AppShell";
import { SectionHeaderTabs } from "../../../layouts/AppShell/AppShell";
import { Breadcrumbs } from "../../../layouts/AppShell/Breadcrumbs";
} from "ui/components/form/json-schema/JsonSchemaForm";
import { Dropdown } from "ui/components/overlay/Dropdown";
import { DataTable } from "ui/components/table/DataTable";
import { useEvent } from "ui/hooks/use-event";
import * as AppShell from "ui/layouts/AppShell/AppShell";
import { Breadcrumbs } from "ui/layouts/AppShell/Breadcrumbs";
import { Link, Route, useLocation } from "wouter";
import { extractSchema } from "../utils/schema";
import { SettingNewModal, type SettingsNewModalProps } from "./SettingNewModal";
import { SettingSchemaModal, type SettingsSchemaModalRef } from "./SettingSchemaModal";
@@ -234,7 +232,7 @@ export function Setting<Schema extends TObject = any>({
<Breadcrumbs path={path} />
</AppShell.SectionHeader>
<AppShell.Scrollable key={path.join("-")}>
{typeof showAlert === "string" && <Alert.Warning message={showAlert} />}
{typeof showAlert !== "undefined" && <Alert.Warning message={showAlert} />}
<div className="flex flex-col flex-grow p-3 gap-3">
<JsonSchemaForm
+7 -43
View File
@@ -1,17 +1,16 @@
import { modals } from "@mantine/modals";
import { IconSettings } from "@tabler/icons-react";
import { ucFirst } from "core/utils";
import { useBknd } from "ui/client/bknd";
import { Empty } from "ui/components/display/Empty";
import { Link } from "ui/components/wouter/Link";
import { useBrowserTitle } from "ui/hooks/use-browser-title";
import * as AppShell from "ui/layouts/AppShell/AppShell";
import { Route, Switch } from "wouter";
import { useBknd } from "../../client";
import { Empty } from "../../components/display/Empty";
import { Link } from "../../components/wouter/Link";
import { useBrowserTitle } from "../../hooks/use-browser-title";
import * as AppShell from "../../layouts/AppShell/AppShell";
import { bkndModals } from "../../modals";
import { Setting } from "./components/Setting";
import { AuthSettings } from "./routes/auth.settings";
import { DataSettings } from "./routes/data.settings";
import { FlowsSettings } from "./routes/flows.settings";
import { ServerSettings } from "./routes/server.settings";
function SettingsSidebar() {
const { version, schema } = useBknd();
@@ -39,36 +38,6 @@ function SettingsSidebar() {
))}
</nav>
</div>
{/*<button
onClick={() =>
modals.openContextModal({
modal: "test",
title: "Test Modal",
innerProps: { modalBody: "This is a test modal" }
})
}
>
modal
</button>
<button
onClick={() =>
bkndModals.open(bkndModals.ids.test, { modalBody: "test" }, { title: "what" })
}
>
modal2
</button>
<button onClick={() => bkndModals.open("test", { modalBody: "test" })}>modal</button>
<button
onClick={() =>
bkndModals.open("debug", {
data: {
one: { what: 1 }
}
})
}
>
debug
</button>*/}
</AppShell.Scrollable>
</AppShell.Sidebar>
);
@@ -145,12 +114,7 @@ const SettingRoutesRoutes = () => {
return (
<>
<FallbackRoutes
module="server"
schema={schema}
config={config}
uiSchema={uiSchema.server}
/>
<ServerSettings schema={schema.server} config={config.server} />
<DataSettings schema={schema.data} config={config.data} />
<AuthSettings schema={schema.auth} config={config.auth} />
<FallbackRoutes module="media" schema={schema} config={config} uiSchema={uiSchema.media} />
@@ -1,8 +1,8 @@
import { cloneDeep, transform } from "lodash-es";
import type { ModuleConfigs, ModuleSchemas } from "modules";
import { useBknd } from "ui/client/bknd";
import { fieldSpecs } from "ui/modules/data/components/fields-specs";
import { Route, Switch } from "wouter";
import type { ModuleConfigs, ModuleSchemas } from "../../../../modules";
import { useBknd } from "../../../client";
import { Setting } from "../components/Setting";
export const dataFieldsUiSchema = {
@@ -0,0 +1,55 @@
import { cloneDeep } from "lodash-es";
import { useBknd } from "ui/client/bknd";
import { Setting } from "ui/routes/settings/components/Setting";
import { Route } from "wouter";
const uiSchema = {
cors: {
allow_methods: {
"ui:widget": "checkboxes"
},
allow_headers: {
"ui:options": {
orderable: false
}
}
}
};
export const ServerSettings = ({ schema: _unsafe_copy, config }) => {
const { app, adminOverride } = useBknd();
const { basepath } = app.getAdminConfig();
const _schema = cloneDeep(_unsafe_copy);
const prefix = `~/${basepath}/settings`.replace(/\/+/g, "/");
const schema = _schema;
if (adminOverride) {
schema.properties.admin.readOnly = true;
}
return (
<Route path="/server" nest>
<Route
path="/"
component={() => (
<Setting
options={{
showAlert: () => {
if (adminOverride) {
return "The admin settings are read-only as they are overriden. Remaining server configuration can be edited.";
}
return;
}
}}
schema={schema}
uiSchema={uiSchema}
config={config}
prefix={`${prefix}/server`}
path={["server"]}
/>
)}
nest
/>
</Route>
);
};
BIN
View File
Binary file not shown.
+28
View File
@@ -0,0 +1,28 @@
# Stage 1: Build stage
FROM node:20 as builder
WORKDIR /app
# Install & copy required cli
RUN npm install --omit=dev bknd
RUN mkdir /output && cp -r node_modules/bknd/dist /output/dist
# Stage 2: Final minimal image
FROM node:20-alpine
WORKDIR /app
# Install pm2 and libsql
RUN npm install -g pm2
RUN echo '{"type":"module"}' > package.json
RUN npm install @libsql/client
# Create volume and init args
VOLUME /data
ENV DEFAULT_ARGS "--db-url file:/data/data.db"
# Copy output from builder
COPY --from=builder /output/dist ./dist
EXPOSE 1337
CMD ["pm2-runtime", "dist/cli/index.js run ${ARGS:-${DEFAULT_ARGS}}"]
+31
View File
@@ -0,0 +1,31 @@
# Official `bknd` Docker image
The docker image intentially doesn't copy any data into the image for now, so you can copy the Dockerfile and build the image anywhere.
## Building the Docker image
To build the Docker image, run the following command:
```bash
docker build -t bknd .
```
## Running the Docker container
To run the Docker container, run the following command:
```bash
docker run -p 1337:1337 bknd
```
You can pass the same CLI arguments (see [Using the CLI](https://docs.bknd.io/cli) guide) to the docker container as you'd do with `npx bknd run`, like so:
```bash
docker run -p 1337:1337 -e ARGS="--db-url file:/data/data.db" bknd
```
Or connect to a remote turso database:
```bash
docker run -p 1337:1337 -e ARGS="--db-url libsql://<db>.turso.io --db-token <token>" bknd
```
To mount the data directory to the host, you can use the `-v` flag:
```bash
docker run -p 1337:1337 -v /path/to/data:/data bknd
```
+124
View File
@@ -0,0 +1,124 @@
---
title: 'Astro'
description: 'Run bknd inside Astro'
---
import InstallBknd from '/snippets/install-bknd.mdx';
## Installation
Install bknd as a dependency:
<InstallBknd />
<Note>The guide below assumes you're using Astro v4. We've experienced issues with Astro DB
using v5, see [this issue](https://github.com/withastro/astro/issues/12474).</Note>
For the Astro integration to work, you also need to [add the react integration](https://docs.astro.build/en/guides/integrations-guide/react/):
```bash
npx astro add react
```
You also need to make sure to set the output to `hybrid` in your Astro config:
```js {6}
// astro.config.mjs
import { defineConfig } from "astro/config";
import react from "@astrojs/react";
export default defineConfig({
output: "hybrid",
integrations: [react()]
});
```
<Note>
If you don't want to use React with Astro, there is also an option to serve the bknd Admin UI
statically using Astro's middleware. In case you're interested in this, feel free to reach
out in [Discord](https://discord.gg/952SFk8Tb8) or open an [issue on GitHub](https://github.com/bknd-io/bknd/issues/new).
</Note>
## Serve the API
Create a new catch-all route at `src/pages/api/[...api].ts`:
```ts src/pages/api/[...api].ts
import { serve } from "bknd/adapter/astro";
export const prerender = false;
export const ALL = serve({
connection: {
type: "libsql",
config: {
url: "file:data.db"
}
}
});
```
For more information about the connection object, refer to the [Setup](/setup) guide. In the
special case of astro, you may also use your Astro DB credentials since it's also using LibSQL
under the hood. Refer to the [Astro DB documentation](https://docs.astro.build/en/guides/astro-db/) for more information.
## Enabling the Admin UI
Create a new catch-all route at `src/pages/admin/[...admin].astro`:
```jsx src/pages/admin/[...admin].astro
---
import { Admin } from "bknd/ui";
import "bknd/dist/styles.css";
import { getApi } from "bknd/adapter/astro";
const api = getApi(Astro, { mode: "dynamic" });
const user = api.getUser();
export const prerender = false;
---
<html>
<body>
<Admin
withProvider={{ user }}
config={{ basepath: "/admin", color_scheme: "dark" }}
client:only
/>
</body>
</html>
```
## Example usage of the API
You use the API in both static and SSR pages. Just note that on static pages, authentication
might not work as expected, because Cookies are not available in the static context.
Here is an example of using the API in static context:
```jsx
---
import { getApi } from "bknd/adapter/astro";
const api = getApi(Astro);
const { data } = await api.data.readMany("todos");
---
<ul>
{data.map((todo) => (
<li>{todo.title}</li>
))}
</ul>
```
On SSR pages, you can also access the authenticated user:
```jsx
---
import { getApi } from "bknd/adapter/astro";
const api = getApi(Astro, { mode: "dynamic" });
const user = api.getUser();
const { data } = await api.data.readMany("todos");
export const prerender = false;
---
{user
? <p>Logged in as <b>{user.email}</b>.</p>
: <p>Not authenticated.</p>}
<ul>
{data.map((todo) => (
<li>{todo.title}</li>
))}
</ul>
```
Check the [astro repository example](https://github.com/bknd-io/bknd/tree/main/examples/astro)
for more implementation details or a [fully working example using Astro DB](https://github.com/dswbx/bknd-astro-example).
+2 -8
View File
@@ -16,7 +16,8 @@ the admin panel.
// index.ts
import { serve } from "bknd/adapter/bun";
const handler = serve({
// if the configuration is omitted, it uses an in-memory database
serve({
connection: {
type: "libsql",
config: {
@@ -25,13 +26,6 @@ const handler = serve({
}
}
});
Bun.serve({
port: 1337,
fetch: handler
});
console.log("Server running at http://localhost:1337");
```
For more information about the connection object, refer to the [Setup](/setup) guide.
+45
View File
@@ -0,0 +1,45 @@
---
title: 'Docker'
description: 'Official docker image for bknd'
---
# Official `bknd` Docker image
The docker image intentially doesn't copy any data into the image for now, so you can copy the
Dockerfile and build the image anywhere.
Locate the Dockerfile either by pulling the [repository](https://github.com/bknd-io/bknd) and navigating to the `docker`
directory,
or download from [here](https://github.com/bknd-io/bknd/blob/main/docker/Dockerfile).
## Building the Docker image
To build the Docker image, run the following command:
```bash
docker build -t bknd .
```
## Running the Docker container
To run the Docker container, run the following command:
```bash
docker run -p 1337:1337 bknd
```
You can pass the same CLI arguments (see [Using the CLI](https://docs.bknd.io/cli) guide) to the
docker container as you'd do with
`npx bknd
run`,
like so:
```bash
docker run -p 1337:1337 -e ARGS="--db-url file:/data/data.db" bknd
```
Or connect to a remote turso database:
```bash
docker run -p 1337:1337 -e ARGS="--db-url libsql://<db>.turso.io --db-token <token>" bknd
```
To mount the data directory to the host, you can use the `-v` flag:
```bash
docker run -p 1337:1337 -v /path/to/data:/data bknd
```
+18 -3
View File
@@ -34,11 +34,26 @@ For more information about the connection object, refer to the [Setup](/setup) g
Create a file `[[...admin]].tsx` inside the `pages/admin` folder:
```tsx
// pages/admin/[[...admin]].tsx
import { adminPage, getServerSideProps } from "bknd/adapter/nextjs";
import { withApi } from "bknd/adapter/nextjs";
import dynamic from "next/dynamic";
import "bknd/dist/styles.css";
export { getServerSideProps };
export default adminPage();
const Admin = dynamic(() => import("bknd/ui").then((mod) => mod.Admin), {
ssr: false,
});
export const getServerSideProps = withApi(async (context) => {
return {
props: {
user: context.api.getUser(),
},
};
});
export default function AdminPage() {
if (typeof document === "undefined") return null;
return <Admin withProvider config={{ basepath: "/admin" }} />;
}
```
## Example usage of the API in pages dir
+37
View File
@@ -0,0 +1,37 @@
---
title: 'Node'
description: 'Run bknd inside Node'
---
import InstallBknd from '/snippets/install-bknd.mdx';
## Installation
Install bknd as a dependency:
<InstallBknd />
## Serve the API & static files
The `serve` function of the Node adapter makes sure to also serve the static files required for
the admin panel.
``` tsx
// index.js
import { serve } from "bknd/adapter/node";
// if the configuration is omitted, it uses an in-memory database
/** @type {import("bknd/adapter/node").NodeAdapterOptions} */
const config = {
connection: {
type: "libsql",
config: {
url: ":memory:"
}
}
};
serve(config);
```
For more information about the connection object, refer to the [Setup](/setup) guide.
Run the application using node by executing:
```bash
node index.js
```
+3 -1
View File
@@ -81,7 +81,9 @@ Create a new splat route file at `app/routes/admin.$.tsx`:
import { adminPage } from "bknd/adapter/remix";
import "bknd/dist/styles.css";
export default adminPage();
export default adminPage({
config: { basepath: "/admin" }
});
```
## Example usage of the API
+26
View File
@@ -46,6 +46,15 @@ in the future, so stay tuned!
</div>}
href="/integration/remix"
/>
<Card
title="Astro"
icon={<div className="text-primary-light">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect width="24" height="24" fill="none"/><path fill="currentColor" d="M9.24 19.035c-.901-.826-1.164-2.561-.789-3.819c.65.793 1.552 1.044 2.486 1.186c1.44.218 2.856.137 4.195-.524c.153-.076.295-.177.462-.278c.126.365.159.734.115 1.11c-.107.915-.56 1.622-1.283 2.158c-.289.215-.594.406-.892.608c-.916.622-1.164 1.35-.82 2.41l.034.114a2.4 2.4 0 0 1-1.07-.918a2.6 2.6 0 0 1-.412-1.401c-.003-.248-.003-.497-.036-.74c-.081-.595-.36-.86-.883-.876a1.034 1.034 0 0 0-1.075.843q-.013.058-.033.126M4.1 15.007s2.666-1.303 5.34-1.303l2.016-6.26c.075-.304.296-.51.544-.51c.25 0 .47.206.545.51l2.016 6.26c3.167 0 5.34 1.303 5.34 1.303L15.363 2.602c-.13-.366-.35-.602-.645-.602H9.283c-.296 0-.506.236-.645.602c-.01.024-4.538 12.405-4.538 12.405"/>
</svg>
</div>}
href="/integration/astro"
/>
<Card
title="Cloudflare"
icon={<div className="text-primary-light">
@@ -64,4 +73,21 @@ in the future, so stay tuned!
</div>}
href="/integration/bun"
/>
<Card
title="Node"
icon={<div className="text-primary-light">
<svg xmlns="http://www.w3.org/2000/svg" width={24} height={24} viewBox="0 0 24 24">
<path fill="currentColor" d="M12 23.956c-.342 0-.66-.089-.957-.243l-3.029-1.738c-.455-.242-.227-.33-.09-.374c.614-.198.728-.242 1.366-.595c.068-.044.16-.022.228.022l2.323 1.343c.09.044.205.044.273 0l9.087-5.084c.09-.044.136-.132.136-.242V6.899c0-.11-.045-.198-.136-.242l-9.087-5.061c-.091-.044-.205-.044-.273 0L2.754 6.657c-.091.044-.137.154-.137.242v10.146c0 .088.046.198.137.242l2.482 1.387c1.344.66 2.186-.11 2.186-.88V7.78c0-.132.114-.264.274-.264h1.161c.137 0 .273.11.273.264v10.013c0 1.739-.979 2.751-2.687 2.751c-.524 0-.934 0-2.095-.55l-2.391-1.32A1.85 1.85 0 0 1 1 17.067V6.921c0-.66.364-1.276.957-1.606L11.044.23a2.1 2.1 0 0 1 1.912 0l9.088 5.084c.592.33.956.946.956 1.606v10.146c0 .66-.364 1.276-.956 1.607l-9.087 5.083a2.4 2.4 0 0 1-.957.198m2.801-6.977c-3.985 0-4.805-1.76-4.805-3.257c0-.132.114-.264.273-.264h1.184c.137 0 .25.088.25.22c.183 1.166.707 1.738 3.121 1.738c1.913 0 2.733-.418 2.733-1.408c0-.572-.228-.99-3.211-1.276c-2.483-.243-4.031-.77-4.031-2.685c0-1.783 1.548-2.84 4.145-2.84c2.915 0 4.35.969 4.532 3.082a.35.35 0 0 1-.069.198c-.045.044-.113.088-.182.088h-1.184a.265.265 0 0 1-.25-.198c-.274-1.21-.98-1.607-2.847-1.607c-2.096 0-2.346.704-2.346 1.233c0 .638.296.836 3.12 1.188c2.801.352 4.122.858 4.122 2.75c-.023 1.938-1.662 3.038-4.555 3.038"></path>
</svg>
</div>}
href="/integration/node"
/>
<Card
title="Docker"
icon={<div className="text-primary-light">
<svg xmlns="http://www.w3.org/2000/svg" width={30} height={30} viewBox="0 0 24 24"><path
fill="currentColor" d="M21.81 10.25c-.06-.04-.56-.43-1.64-.43c-.28 0-.56.03-.84.08c-.21-1.4-1.38-2.11-1.43-2.14l-.29-.17l-.18.27c-.24.36-.43.77-.51 1.19c-.2.8-.08 1.56.33 2.21c-.49.28-1.29.35-1.46.35H2.62c-.34 0-.62.28-.62.63c0 1.15.18 2.3.58 3.38c.45 1.19 1.13 2.07 2 2.61c.98.6 2.59.94 4.42.94c.79 0 1.61-.07 2.42-.22c1.12-.2 2.2-.59 3.19-1.16A8.3 8.3 0 0 0 16.78 16c1.05-1.17 1.67-2.5 2.12-3.65h.19c1.14 0 1.85-.46 2.24-.85c.26-.24.45-.53.59-.87l.08-.24zm-17.96.99h1.76c.08 0 .16-.07.16-.16V9.5c0-.08-.07-.16-.16-.16H3.85c-.09 0-.16.07-.16.16v1.58c.01.09.07.16.16.16m2.43 0h1.76c.08 0 .16-.07.16-.16V9.5c0-.08-.07-.16-.16-.16H6.28c-.09 0-.16.07-.16.16v1.58c.01.09.07.16.16.16m2.47 0h1.75c.1 0 .17-.07.17-.16V9.5c0-.08-.06-.16-.17-.16H8.75c-.08 0-.15.07-.15.16v1.58c0 .09.06.16.15.16m2.44 0h1.77c.08 0 .15-.07.15-.16V9.5c0-.08-.06-.16-.15-.16h-1.77c-.08 0-.15.07-.15.16v1.58c0 .09.07.16.15.16M6.28 9h1.76c.08 0 .16-.09.16-.18V7.25c0-.09-.07-.16-.16-.16H6.28c-.09 0-.16.06-.16.16v1.57c.01.09.07.18.16.18m2.47 0h1.75c.1 0 .17-.09.17-.18V7.25c0-.09-.06-.16-.17-.16H8.75c-.08 0-.15.06-.15.16v1.57c0 .09.06.18.15.18m2.44 0h1.77c.08 0 .15-.09.15-.18V7.25c0-.09-.07-.16-.15-.16h-1.77c-.08 0-.15.06-.15.16v1.57c0 .09.07.18.15.18m0-2.28h1.77c.08 0 .15-.07.15-.16V5c0-.1-.07-.17-.15-.17h-1.77c-.08 0-.15.06-.15.17v1.56c0 .08.07.16.15.16m2.46 4.52h1.76c.09 0 .16-.07.16-.16V9.5c0-.08-.07-.16-.16-.16h-1.76c-.08 0-.15.07-.15.16v1.58c0 .09.07.16.15.16"></path></svg>
</div>}
href="/integration/docker"
/>
</CardGroup>
+8 -18
View File
@@ -3,7 +3,8 @@
"name": "bknd",
"logo": {
"dark": "/_assets/logo/bknd_logo_white.svg",
"light": "/_assets/logo/bknd_logo_black.svg"
"light": "/_assets/logo/bknd_logo_black.svg",
"href": "https://bknd.io"
},
"theme": "prism",
"layout": "sidenav",
@@ -22,8 +23,8 @@
},
"topbarLinks": [
{
"name": "Support",
"url": "mailto:hi@help.bknd.io"
"name": "Discord",
"url": "https://discord.gg/952SFk8Tb8"
}
],
"topbarCtaButton": {
@@ -44,24 +45,12 @@
}
],
"anchors": [
{
"name": "Web",
"icon": "globe",
"iconType": "solid",
"url": "https://bknd.io"
},
{
"name": "Docs",
"icon": "book-open-cover",
"iconType": "solid",
"url": "/"
},
{
"name": "Discord",
"icon": "discord",
"iconType": "solid",
"url": "https://discord.gg/YAHnKXr5"
},
{
"name": "GitHub",
"icon": "github",
@@ -114,9 +103,11 @@
"integration/bun",
"integration/vite",
"integration/express",
"integration/nodejs",
"integration/astro",
"integration/node",
"integration/deno",
"integration/browser"
"integration/browser",
"integration/docker"
]
},
{
@@ -166,7 +157,6 @@
"guide/media/transformations"
]
},
{
"group": "API Documentation",
"pages": ["api-reference/introduction"]
+1 -1
View File
@@ -5,6 +5,6 @@
"dev": "mintlify dev"
},
"devDependencies": {
"mintlify": "^4.0.269"
"mintlify": "^4.0.285"
}
}

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