Merge pull request #23 from bknd-io/release/0.2.3

Release 0.3
This commit is contained in:
dswbx
2024-12-10 08:52:20 +01:00
committed by GitHub
90 changed files with 1507 additions and 1181 deletions

2
.gitignore vendored
View File

@@ -20,8 +20,10 @@ packages/media/.env
**/*/*.db **/*/*.db
**/*/*.db-shm **/*/*.db-shm
**/*/*.db-wal **/*/*.db-wal
**/*/.tmp
.npmrc .npmrc
/.verdaccio /.verdaccio
.idea .idea
.vscode .vscode
.git_old .git_old
docker/tmp

View File

@@ -1,7 +1,8 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { mark, stripMark } from "../src/core/utils"; import { mark, stripMark } from "../src/core/utils";
import { ModuleManager } from "../src/modules/ModuleManager"; import { entity, text } from "../src/data";
import { CURRENT_VERSION, TABLE_NAME, migrateSchema } from "../src/modules/migrations"; import { ModuleManager, getDefaultConfig } from "../src/modules/ModuleManager";
import { CURRENT_VERSION, TABLE_NAME } from "../src/modules/migrations";
import { getDummyConnection } from "./helper"; import { getDummyConnection } from "./helper";
describe("ModuleManager", async () => { describe("ModuleManager", async () => {
@@ -29,21 +30,68 @@ describe("ModuleManager", async () => {
const mm = new ModuleManager(c.dummyConnection); const mm = new ModuleManager(c.dummyConnection);
await mm.build(); await mm.build();
const version = mm.version(); 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 { version, ...json } = mm.toJSON() as any;
const c2 = getDummyConnection(); const c2 = getDummyConnection();
const db = c2.dummyConnection.kysely; const db = c2.dummyConnection.kysely;
await migrateSchema(CURRENT_VERSION, { db }); const mm2 = new ModuleManager(c2.dummyConnection, { initial: { version, ...json } });
await mm2.syncConfigTable();
await db await db
.updateTable(TABLE_NAME) .insertInto(TABLE_NAME)
.set({ json: JSON.stringify(json), version: CURRENT_VERSION }) .values({ type: "config", json: JSON.stringify(json), version: CURRENT_VERSION })
.execute(); .execute();
const mm2 = new ModuleManager(c2.dummyConnection, { initial: { version, ...json } });
await mm2.build(); 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 () => { test("s4: config given, table exists, version outdated, migrate", async () => {
@@ -52,21 +100,19 @@ describe("ModuleManager", async () => {
await mm.build(); await mm.build();
const version = mm.version(); const version = mm.version();
const json = mm.configs(); const json = mm.configs();
//const { version, ...json } = mm.toJSON() as any;
const c2 = getDummyConnection(); const c2 = getDummyConnection();
const db = c2.dummyConnection.kysely; 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, { const mm2 = new ModuleManager(c2.dummyConnection, {
initial: { version: version - 1, ...json } 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(); await mm2.build();
}); });
@@ -80,15 +126,15 @@ describe("ModuleManager", async () => {
const c2 = getDummyConnection(); const c2 = getDummyConnection();
const db = c2.dummyConnection.kysely; 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, { const mm2 = new ModuleManager(c2.dummyConnection, {
initial: { version: version - 1, ...json } 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/); expect(mm2.build()).rejects.toThrow(/version.*do not match/);
}); });
@@ -102,7 +148,9 @@ describe("ModuleManager", async () => {
const c2 = getDummyConnection(); const c2 = getDummyConnection();
const db = c2.dummyConnection.kysely; const db = c2.dummyConnection.kysely;
await migrateSchema(CURRENT_VERSION, { db });
const mm2 = new ModuleManager(c2.dummyConnection);
await mm2.syncConfigTable();
const config = { const config = {
...json, ...json,
@@ -112,12 +160,11 @@ describe("ModuleManager", async () => {
} }
}; };
await db await db
.updateTable(TABLE_NAME) .insertInto(TABLE_NAME)
.set({ json: JSON.stringify(config), version: CURRENT_VERSION }) .values({ type: "config", json: JSON.stringify(config), version: CURRENT_VERSION })
.execute(); .execute();
// run without config given // run without config given
const mm2 = new ModuleManager(c2.dummyConnection);
await mm2.build(); await mm2.build();
expect(mm2.configs().data.basepath).toBe("/api/data2"); expect(mm2.configs().data.basepath).toBe("/api/data2");
@@ -148,50 +195,61 @@ describe("ModuleManager", async () => {
}); });
}); });
// @todo: check what happens here test("partial config given", async () => {
/*test("blank app, modify deep config", async () => {
const { dummyConnection } = getDummyConnection(); const { dummyConnection } = getDummyConnection();
const mm = new ModuleManager(dummyConnection); const partial = {
await mm.build(); auth: {
enabled: true
/!* await mm
.get("data")
.schema()
.patch("entities.test", {
fields: {
content: {
type: "text"
}
} }
};
const mm = new ModuleManager(dummyConnection, {
initial: partial
}); });
await mm.build(); await mm.build();
expect(mm.configs().data.entities?.users?.fields?.email.type).toBe("text"); expect(mm.version()).toBe(CURRENT_VERSION);
expect(mm.built()).toBe(true);
expect( expect(mm.configs().auth.enabled).toBe(true);
mm.get("data").schema().patch("desc", "entities.users.config.sort_dir") expect(mm.configs().data.entities.users).toBeDefined();
).rejects.toThrow(); });
await mm.build();*!/
expect(mm.configs().data.entities?.users?.fields?.email.type).toBe("text"); test("partial config given, but db version exists", async () => {
console.log("here", mm.configs()); const c = getDummyConnection();
await mm const mm = new ModuleManager(c.dummyConnection);
.get("data") await mm.build();
.schema() const json = mm.configs();
.patch("entities.users", { config: { sort_dir: "desc" } });
await mm.build(); const c2 = getDummyConnection();
expect(mm.toJSON()); const db = c2.dummyConnection.kysely;
//console.log(_jsonp(mm.toJSON().data)); const mm2 = new ModuleManager(c2.dummyConnection, {
/!*expect(mm.configs().data.entities!.test!.fields!.content.type).toBe("text"); initial: {
expect(mm.configs().data.entities!.users!.config!.sort_dir).toBe("desc");*!/ auth: {
});*/ basepath: "/shouldnt/take/this"
}
/*test("accessing modules", async () => { }
const { dummyConnection } = getDummyConnection(); });
await mm2.syncConfigTable();
const mm = new ModuleManager(dummyConnection); const payload = {
...json,
//mm.get("auth").mutate().set({}); 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");
});
// @todo: add tests for migrations (check "backup" and new version)
}); });

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]);
});
});
});

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);
});
});

View File

@@ -1,6 +1,5 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { type ObjectQuery, convert, validate } from "../../../src/core/object/query/object-query"; import { type ObjectQuery, convert, validate } from "../../../src/core/object/query/object-query";
import { deprecated__whereRepoSchema } from "../../../src/data";
describe("object-query", () => { describe("object-query", () => {
const q: ObjectQuery = { name: "Michael" }; const q: ObjectQuery = { name: "Michael" };
@@ -8,19 +7,6 @@ describe("object-query", () => {
const q3: ObjectQuery = { name: "Michael", age: { $gt: 18 } }; const q3: ObjectQuery = { name: "Michael", age: { $gt: 18 } };
const bag = { q, q2, q3 }; 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 () => { test("validates", async () => {
const converted = convert({ const converted = convert({
name: { $eq: "ch" } name: { $eq: "ch" }

View File

@@ -132,14 +132,47 @@ describe("Mutator simple", async () => {
const data = (await em.repository(items).findMany()).data; const data = (await em.repository(items).findMany()).data;
//console.log(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); expect((await em.repository(items).findMany()).data.length).toBe(data.length - 2);
//console.log((await em.repository(items).findMany()).data); //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((await em.repository(items).findMany()).data.length).toBe(0);
//expect(res.data.count).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 }
]);
});
}); });

View File

@@ -1,5 +1,4 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { MediaField } from "../../src";
import { import {
BooleanField, BooleanField,
DateField, DateField,
@@ -30,6 +29,7 @@ import {
relation, relation,
text text
} from "../../src/data/prototype"; } from "../../src/data/prototype";
import { MediaField } from "../../src/media/MediaField";
describe("prototype", () => { describe("prototype", () => {
test("...", () => { test("...", () => {
@@ -76,7 +76,9 @@ describe("prototype", () => {
new DateField("created_at", { new DateField("created_at", {
type: "datetime" type: "datetime"
}), }),
// @ts-ignore
new MediaField("images", { entity: "posts" }), new MediaField("images", { entity: "posts" }),
// @ts-ignore
new MediaField("cover", { entity: "posts", max_items: 1 }) new MediaField("cover", { entity: "posts", max_items: 1 })
]); ]);

View File

@@ -88,11 +88,12 @@ await tsup.build({
watch, watch,
entry: ["src/index.ts", "src/data/index.ts", "src/core/index.ts", "src/core/utils/index.ts"], entry: ["src/index.ts", "src/data/index.ts", "src/core/index.ts", "src/core/utils/index.ts"],
outDir: "dist", outDir: "dist",
external: ["bun:test"], external: ["bun:test", "@libsql/client"],
metafile: true, metafile: true,
platform: "browser", platform: "browser",
format: ["esm", "cjs"], format: ["esm", "cjs"],
splitting: false, splitting: false,
treeshake: true,
loader: { loader: {
".svg": "dataurl" ".svg": "dataurl"
} }
@@ -107,11 +108,12 @@ await tsup.build({
watch, watch,
entry: ["src/ui/index.ts", "src/ui/client/index.ts", "src/ui/main.css"], entry: ["src/ui/index.ts", "src/ui/client/index.ts", "src/ui/main.css"],
outDir: "dist/ui", outDir: "dist/ui",
external: ["bun:test"], external: ["bun:test", "react", "react-dom", "use-sync-external-store"],
metafile: true, metafile: true,
platform: "browser", platform: "browser",
format: ["esm", "cjs"], format: ["esm", "cjs"],
splitting: true, splitting: true,
treeshake: true,
loader: { loader: {
".svg": "dataurl" ".svg": "dataurl"
}, },

View File

@@ -3,12 +3,12 @@
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,
"bin": "./dist/cli/index.js", "bin": "./dist/cli/index.js",
"version": "0.2.2", "version": "0.3.0",
"scripts": { "scripts": {
"build:all": "bun run build && bun run build:cli", "build:all": "bun run build && bun run build:cli",
"dev": "vite", "dev": "vite",
"test": "ALL_TESTS=1 bun test --bail", "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", "watch": "bun run build.ts --types --watch",
"types": "bun tsc --noEmit", "types": "bun tsc --noEmit",
"clean:types": "find ./dist -name '*.d.ts' -delete && rm -f ./dist/tsconfig.tsbuildinfo", "clean:types": "find ./dist -name '*.d.ts' -delete && rm -f ./dist/tsconfig.tsbuildinfo",
@@ -17,20 +17,35 @@
"watch:css": "bun tailwindcss --watch -i src/ui/main.css -o ./dist/styles.css", "watch:css": "bun tailwindcss --watch -i src/ui/main.css -o ./dist/styles.css",
"updater": "bun x npm-check-updates -ui", "updater": "bun x npm-check-updates -ui",
"build:cli": "bun build src/cli/index.ts --target node --outdir dist/cli --minify", "build:cli": "bun build src/cli/index.ts --target node --outdir dist/cli --minify",
"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", "license": "FSL-1.1-MIT",
"dependencies": { "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", "@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-html": "^6.4.9",
"@codemirror/lang-json": "^6.0.1", "@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-liquid": "^6.2.1", "@codemirror/lang-liquid": "^6.2.1",
"@dagrejs/dagre": "^1.1.4", "@dagrejs/dagre": "^1.1.4",
"@hello-pangea/dnd": "^17.0.0", "@hello-pangea/dnd": "^17.0.0",
"@hono/typebox-validator": "^0.2.6", "@hono/typebox-validator": "^0.2.6",
"@hono/vite-dev-server": "^0.17.0",
"@hono/zod-validator": "^0.4.1", "@hono/zod-validator": "^0.4.1",
"@hookform/resolvers": "^3.9.1", "@hookform/resolvers": "^3.9.1",
"@libsql/client": "^0.14.0",
"@libsql/kysely-libsql": "^0.4.1", "@libsql/kysely-libsql": "^0.4.1",
"@mantine/core": "^7.13.4", "@mantine/core": "^7.13.4",
"@mantine/hooks": "^7.13.4", "@mantine/hooks": "^7.13.4",
@@ -38,51 +53,37 @@
"@mantine/notifications": "^7.13.5", "@mantine/notifications": "^7.13.5",
"@radix-ui/react-scroll-area": "^1.2.0", "@radix-ui/react-scroll-area": "^1.2.0",
"@rjsf/core": "^5.22.2", "@rjsf/core": "^5.22.2",
"@sinclair/typebox": "^0.32.34",
"@tabler/icons-react": "3.18.0", "@tabler/icons-react": "3.18.0",
"@tanstack/react-form": "0.19.2",
"@tanstack/react-query": "^5.59.16", "@tanstack/react-query": "^5.59.16",
"@uiw/react-codemirror": "^4.23.6",
"@xyflow/react": "^12.3.2",
"aws4fetch": "^1.0.18",
"dayjs": "^1.11.13",
"fast-xml-parser": "^4.4.0",
"hono": "^4.6.12",
"jotai": "^2.10.1",
"kysely": "^0.27.4",
"liquidjs": "^10.15.0",
"lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1",
"react-hook-form": "^7.53.1",
"react-icons": "5.2.1",
"react-json-view-lite": "^2.0.1",
"tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7",
"wouter": "^3.3.5",
"zod": "^3.23.8"
},
"devDependencies": {
"@aws-sdk/client-s3": "^3.613.0",
"@hono/node-server": "^1.13.7",
"@hono/vite-dev-server": "^0.17.0",
"@tanstack/react-query-devtools": "^5.59.16", "@tanstack/react-query-devtools": "^5.59.16",
"@types/diff": "^5.2.3",
"@types/node": "^22.10.0", "@types/node": "^22.10.0",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@uiw/react-codemirror": "^4.23.6",
"@vitejs/plugin-react": "^4.3.3", "@vitejs/plugin-react": "^4.3.3",
"@xyflow/react": "^12.3.2",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"esbuild-postcss": "^0.0.4", "esbuild-postcss": "^0.0.4",
"node-fetch": "^3.3.2", "jotai": "^2.10.1",
"open": "^10.1.0",
"openapi-types": "^12.1.3", "openapi-types": "^12.1.3",
"postcss": "^8.4.47", "postcss": "^8.4.47",
"postcss-preset-mantine": "^1.17.0", "postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1", "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": "^3.4.14",
"tailwindcss-animate": "^1.0.7",
"tsup": "^8.3.5", "tsup": "^8.3.5",
"vite": "^5.4.10", "vite": "^5.4.10",
"vite-plugin-static-copy": "^2.0.0", "vite-plugin-static-copy": "^2.0.0",
"vite-tsconfig-paths": "^5.0.1" "vite-tsconfig-paths": "^5.0.1",
"wouter": "^3.3.5"
},
"optionalDependencies": {
"@hono/node-server": "^1.13.7"
}, },
"peerDependencies": { "peerDependencies": {
"react": ">=18", "react": ">=18",
@@ -165,12 +166,16 @@
"./dist/styles.css": "./dist/ui/main.css", "./dist/styles.css": "./dist/ui/main.css",
"./dist/manifest.json": "./dist/static/manifest.json" "./dist/manifest.json": "./dist/static/manifest.json"
}, },
"publishConfig": {
"access": "public"
},
"files": [ "files": [
"dist", "dist",
"README.md", "README.md",
"!dist/*.tsbuildinfo", "!dist/*.tsbuildinfo",
"!dist/*.map", "!dist/*.map",
"!dist/**/*.map", "!dist/**/*.map",
"!dist/metafile*" "!dist/metafile*",
"!dist/**/metafile*"
] ]
} }

View File

@@ -21,7 +21,7 @@ export class AppBuiltEvent extends Event<{ app: App }> {
export const AppEvents = { AppConfigUpdatedEvent, AppBuiltEvent } as const; export const AppEvents = { AppConfigUpdatedEvent, AppBuiltEvent } as const;
export type CreateAppConfig = { export type CreateAppConfig = {
connection: connection?:
| Connection | Connection
| { | {
type: "libsql"; type: "libsql";
@@ -29,7 +29,7 @@ export type CreateAppConfig = {
}; };
initialConfig?: InitialModuleConfigs; initialConfig?: InitialModuleConfigs;
plugins?: AppPlugin<any>[]; plugins?: AppPlugin<any>[];
options?: ModuleManagerOptions; options?: Omit<ModuleManagerOptions, "initial" | "onUpdated">;
}; };
export type AppConfig = InitialModuleConfigs; export type AppConfig = InitialModuleConfigs;
@@ -56,27 +56,6 @@ export class App<DB = any> {
this.modules.ctx().emgr.registerEvents(AppEvents); 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() { get emgr() {
return this.modules.ctx().emgr; return this.modules.ctx().emgr;
} }
@@ -147,4 +126,31 @@ export class App<DB = any> {
toJSON(secrets?: boolean) { toJSON(secrets?: boolean) {
return this.modules.toJSON(secrets); 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);
} }

View File

@@ -1,5 +1,4 @@
import { Api, type ApiOptions } from "bknd"; import { Api, type ApiOptions, App, type CreateAppConfig } from "bknd";
import { App, type CreateAppConfig } from "bknd";
type TAstro = { type TAstro = {
request: Request; request: Request;

View File

@@ -1,37 +1,16 @@
/// <reference types="bun-types" />
import path from "node:path"; import path from "node:path";
import { App, type CreateAppConfig } from "bknd"; import { App, type CreateAppConfig } from "bknd";
import { LibsqlConnection } from "bknd/data"; import type { Serve, ServeOptions } from "bun";
import { serveStatic } from "hono/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) {
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
let app: App; let app: App;
export async function createApp(_config: Partial<CreateAppConfig> = {}, distPath?: string) {
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
return async (req: Request) => {
if (!app) { if (!app) {
const connection = await getConnection(_config.connection); app = App.create(_config);
app = App.create({
..._config,
connection
});
app.emgr.on( app.emgr.on(
"app-built", "app-built",
@@ -50,6 +29,31 @@ export function serve(_config: Partial<CreateAppConfig> = {}, distPath?: string)
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}`);
} }

View File

@@ -189,7 +189,11 @@ export class DurableBkndApp extends DurableObject {
const config = options.config; const config = options.config;
// change protocol to websocket if libsql // 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"; config.connection.config.protocol = "wss";
} }

View File

@@ -1,20 +0,0 @@
import { withApi } from "bknd/adapter/nextjs";
import type { BkndAdminProps } from "bknd/ui";
import type { InferGetServerSidePropsType } from "next";
import dynamic from "next/dynamic";
export const getServerSideProps = withApi(async (context) => {
return {
props: {
user: context.api.getUser()
}
};
});
export function adminPage(adminProps?: BkndAdminProps) {
const Admin = dynamic(() => import("bknd/ui").then((mod) => mod.Admin), { ssr: false });
return (props: InferGetServerSidePropsType<typeof getServerSideProps>) => {
if (typeof document === "undefined") return null;
return <Admin withProvider={{ user: props.user }} {...adminProps} />;
};
}

View File

@@ -1,2 +1 @@
export * from "./nextjs.adapter"; export * from "./nextjs.adapter";
export * from "./AdminPage";

View File

@@ -2,51 +2,34 @@ import path from "node:path";
import { serve as honoServe } from "@hono/node-server"; import { serve as honoServe } from "@hono/node-server";
import { serveStatic } from "@hono/node-server/serve-static"; import { serveStatic } from "@hono/node-server/serve-static";
import { App, type CreateAppConfig } from "bknd"; import { App, type CreateAppConfig } from "bknd";
import { LibsqlConnection } from "bknd/data";
async function getConnection(conn?: CreateAppConfig["connection"]) { export type NodeAdapterOptions = CreateAppConfig & {
if (conn) {
if (LibsqlConnection.isConnection(conn)) {
return conn;
}
return new LibsqlConnection(conn.config);
}
const createClient = await import("@libsql/client/node").then((m) => m.createClient);
if (!createClient) {
throw new Error('libsql client not found, you need to install "@libsql/client/node"');
}
console.log("Using in-memory database");
return new LibsqlConnection(createClient({ url: ":memory:" }));
}
export type NodeAdapterOptions = {
relativeDistPath?: string; relativeDistPath?: string;
port?: number; port?: number;
hostname?: string; hostname?: string;
listener?: Parameters<typeof honoServe>[1]; 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( const root = path.relative(
process.cwd(), process.cwd(),
path.resolve(options.relativeDistPath ?? "./node_modules/bknd/dist", "static") path.resolve(relativeDistPath ?? "./node_modules/bknd/dist", "static")
); );
let app: App; let app: App;
honoServe( honoServe(
{ {
port: options.port ?? 1337, port,
hostname: options.hostname, hostname,
fetch: async (req: Request) => { fetch: async (req: Request) => {
if (!app) { if (!app) {
const connection = await getConnection(_config.connection); app = App.create(config);
app = App.create({
..._config,
connection
});
app.emgr.on( app.emgr.on(
"app-built", "app-built",
@@ -68,6 +51,9 @@ export function serve(_config: Partial<CreateAppConfig> = {}, options: NodeAdapt
return app.fetch(req); return app.fetch(req);
} }
}, },
options.listener (connInfo) => {
console.log(`Server is running on http://localhost:${connInfo.port}`);
listener?.(connInfo);
}
); );
} }

View File

@@ -1,10 +1,8 @@
import { readFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import type { ServeStaticOptions } from "@hono/node-server/serve-static"; import type { Config } from "@libsql/client/node";
import { type Config, createClient } from "@libsql/client/node";
import { Connection, LibsqlConnection, SqliteLocalConnection } from "data";
import type { MiddlewareHandler } from "hono"; 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 const PLATFORMS = ["node", "bun"] as const;
export type Platform = (typeof PLATFORMS)[number]; 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 }) { export async function startServer(server: Platform, app: any, options: { port: number }) {
const port = options.port; const port = options.port;
console.log("running on", server, port); console.log(`(using ${server} serve)`);
switch (server) { switch (server) {
case "node": { case "node": {
// https://github.com/honojs/node-server/blob/main/src/response.ts#L88 // 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); const url = `http://localhost:${port}`;
} console.log(`Server listening on ${url}`);
await open(url);
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:"));
} }
export async function getConfigPath(filePath?: string) { export async function getConfigPath(filePath?: string) {

View File

@@ -1,16 +1,13 @@
import type { Config } from "@libsql/client/node"; import type { Config } from "@libsql/client/node";
import { App } from "App"; import { App, type CreateAppConfig } from "App";
import type { BkndConfig } from "adapter"; import type { BkndConfig } from "adapter";
import type { CliCommand } from "cli/types"; import type { CliCommand } from "cli/types";
import { Option } from "commander"; import { Option } from "commander";
import type { Connection } from "data";
import { import {
PLATFORMS, PLATFORMS,
type Platform, type Platform,
attachServeStatic, attachServeStatic,
getConfigPath, getConfigPath,
getConnection,
getHtml,
startServer startServer
} from "./platform"; } from "./platform";
@@ -41,14 +38,14 @@ export const run: CliCommand = (program) => {
}; };
type MakeAppConfig = { type MakeAppConfig = {
connection: Connection; connection?: CreateAppConfig["connection"];
server?: { platform?: Platform }; server?: { platform?: Platform };
setAdminHtml?: boolean; setAdminHtml?: boolean;
onBuilt?: (app: App) => Promise<void>; onBuilt?: (app: App) => Promise<void>;
}; };
async function makeApp(config: MakeAppConfig) { async function makeApp(config: MakeAppConfig) {
const app = new App(config.connection); const app = App.create({ connection: config.connection });
app.emgr.on( app.emgr.on(
"app-built", "app-built",
@@ -99,9 +96,9 @@ async function action(options: {
let app: App; let app: App;
if (options.dbUrl || !configFilePath) { if (options.dbUrl || !configFilePath) {
const connection = getConnection( const connection = options.dbUrl
options.dbUrl ? { url: options.dbUrl, authToken: options.dbToken } : undefined ? { type: "libsql" as const, config: { url: options.dbUrl, authToken: options.dbToken } }
); : undefined;
app = await makeApp({ connection, server: { platform: options.server } }); app = await makeApp({ connection, server: { platform: options.server } });
} else { } else {
console.log("Using config from:", configFilePath); console.log("Using config from:", configFilePath);

View File

@@ -27,6 +27,10 @@ export class BkndError extends Error {
super(message); super(message);
} }
static with(message: string, details?: Record<string, any>, type?: string) {
throw new BkndError(message, details, type);
}
toJSON() { toJSON() {
return { return {
type: this.type ?? "unknown", type: this.type ?? "unknown",

View File

@@ -1,5 +1,5 @@
export { Endpoint, type RequestResponse, type Middleware } from "./server/Endpoint"; import type { Hono, MiddlewareHandler } from "hono";
export { zValidator } from "./server/lib/zValidator";
export { tbValidator } from "./server/lib/tbValidator"; export { tbValidator } from "./server/lib/tbValidator";
export { Exception, BkndError } from "./errors"; export { Exception, BkndError } from "./errors";
export { isDebug } from "./env"; export { isDebug } from "./env";
@@ -11,7 +11,6 @@ export {
type TemplateTypes, type TemplateTypes,
type SimpleRendererOptions type SimpleRendererOptions
} from "./template/SimpleRenderer"; } from "./template/SimpleRenderer";
export { Controller, type ClassController } from "./server/Controller";
export { SchemaObject } from "./object/SchemaObject"; export { SchemaObject } from "./object/SchemaObject";
export { DebugLogger } from "./utils/DebugLogger"; export { DebugLogger } from "./utils/DebugLogger";
export { Permission } from "./security/Permission"; export { Permission } from "./security/Permission";
@@ -26,3 +25,10 @@ export {
isBooleanLike isBooleanLike
} from "./object/query/query"; } from "./object/query/query";
export { Registry, type Constructor } from "./registry/Registry"; 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
app/src/core/object/diff.ts Normal file
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 };

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;
}
}

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);
}
}

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>;
});

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); const newObj = cloneDeep(obj);
mark(newObj, false); mark(newObj, false);
return newObj; return newObj as O;
} }
export function mark(obj: any, validated = true) { export function mark(obj: any, validated = true) {

View File

@@ -369,9 +369,9 @@ export class DataController implements ClassController {
return c.notFound(); return c.notFound();
} }
const where = c.req.valid("json") as RepoQuery["where"]; 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)); return c.json(this.mutatorResult(result));
} }

View File

@@ -41,16 +41,18 @@ export type DbFunctions = {
>; >;
}; };
export abstract class Connection { const CONN_SYMBOL = Symbol.for("bknd:connection");
cls = "bknd:connection";
kysely: Kysely<any>; export abstract class Connection<DB = any> {
kysely: Kysely<DB>;
constructor( constructor(
kysely: Kysely<any>, kysely: Kysely<DB>,
public fn: Partial<DbFunctions> = {}, public fn: Partial<DbFunctions> = {},
protected plugins: KyselyPlugin[] = [] protected plugins: KyselyPlugin[] = []
) { ) {
this.kysely = kysely; this.kysely = kysely;
this[CONN_SYMBOL] = true;
} }
/** /**
@@ -58,8 +60,9 @@ export abstract class Connection {
* coming from different places * coming from different places
* @param conn * @param conn
*/ */
static isConnection(conn: any): conn is Connection { static isConnection(conn: unknown): conn is Connection {
return conn?.cls === "bknd:connection"; if (!conn) return false;
return conn[CONN_SYMBOL] === true;
} }
getIntrospector(): ConnectionIntrospector { getIntrospector(): ConnectionIntrospector {

View File

@@ -1,4 +1,4 @@
import { type Client, type Config, type InStatement, createClient } from "@libsql/client/web"; import { type Client, type Config, type InStatement, createClient } from "@libsql/client";
import { LibsqlDialect } from "@libsql/kysely-libsql"; import { LibsqlDialect } from "@libsql/kysely-libsql";
import { type DatabaseIntrospector, Kysely, ParseJSONResultsPlugin, sql } from "kysely"; import { type DatabaseIntrospector, Kysely, ParseJSONResultsPlugin, sql } from "kysely";
import { FilterNumericKeysPlugin } from "../plugins/FilterNumericKeysPlugin"; import { FilterNumericKeysPlugin } from "../plugins/FilterNumericKeysPlugin";

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) // @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 entity = this.entity;
const qb = this.appendWhere(this.conn.deleteFrom(entity.name), where).returning( const qb = this.appendWhere(this.conn.deleteFrom(entity.name), where).returning(
@@ -267,4 +267,30 @@ export class Mutator<DB> implements EmitsEvents {
return res; 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;
}
} }

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> { protected async performQuery(qb: RepositoryQB): Promise<RepositoryResponse> {
const entity = this.entity; const entity = this.entity;
const compiled = qb.compile(); const compiled = qb.compile();
/*const { sql, parameters } = qb.compile(); //console.log("performQuery", compiled.sql, compiled.parameters);
console.log("many", sql, parameters);*/
const start = performance.now(); const start = performance.now();
const selector = (as = "count") => this.conn.fn.countAll<number>().as(as); const selector = (as = "count") => this.conn.fn.countAll<number>().as(as);
@@ -263,6 +262,7 @@ export class Repository<DB = any, TB extends keyof DB = any> implements EmitsEve
qb = qb.orderBy(aliased(options.sort.by), options.sort.dir); qb = qb.orderBy(aliased(options.sort.by), options.sort.dir);
} }
//console.log("options", { _options, options, exclude_options });
return { qb, options }; return { qb, options };
} }
@@ -286,14 +286,11 @@ export class Repository<DB = any, TB extends keyof DB = any> implements EmitsEve
where: RepoQuery["where"], where: RepoQuery["where"],
_options?: Partial<Omit<RepoQuery, "where" | "limit" | "offset">> _options?: Partial<Omit<RepoQuery, "where" | "limit" | "offset">>
): Promise<RepositoryResponse<DB[TB] | undefined>> { ): Promise<RepositoryResponse<DB[TB] | undefined>> {
const { qb, options } = this.buildQuery( const { qb, options } = this.buildQuery({
{
..._options, ..._options,
where, where,
limit: 1 limit: 1
}, });
["offset", "sort"]
);
return this.single(qb, options) as any; return this.single(qb, options) as any;
} }

View File

@@ -15,7 +15,6 @@ import type {
SelectQueryBuilder, SelectQueryBuilder,
UpdateQueryBuilder UpdateQueryBuilder
} from "kysely"; } from "kysely";
import type { RepositoryQB } from "./Repository";
type Builder = ExpressionBuilder<any, any>; type Builder = ExpressionBuilder<any, any>;
type Wrapper = ExpressionWrapper<any, any, any>; type Wrapper = ExpressionWrapper<any, any, any>;

View File

@@ -236,8 +236,8 @@ export abstract class Field<
toJSON() { toJSON() {
return { return {
//name: this.name, // @todo: current workaround because of fixed string type
type: this.type, type: this.type as any,
config: this.config config: this.config
}; };
} }

View File

@@ -54,7 +54,7 @@ export class JsonSchemaField<
if (parentValid) { if (parentValid) {
// already checked in parent // 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); //console.log("jsonschema:valid: not checking", this.name, value, context);
return true; return true;
} }
@@ -65,6 +65,7 @@ export class JsonSchemaField<
} else { } else {
//console.log("jsonschema:invalid", this.name, value, context); //console.log("jsonschema:invalid", this.name, value, context);
} }
//console.log("jsonschema:invalid:fromParent", this.name, value, context);
return false; return false;
} }
@@ -110,9 +111,13 @@ export class JsonSchemaField<
): Promise<string | undefined> { ): Promise<string | undefined> {
const value = await super.transformPersist(_value, em, context); const value = await super.transformPersist(_value, em, context);
if (this.nullish(value)) return value; if (this.nullish(value)) return value;
//console.log("jsonschema:transformPersist", this.name, _value, context);
if (!this.isValid(value)) { if (!this.isValid(value)) {
//console.error("jsonschema:transformPersist:invalid", this.name, value);
throw new TransformPersistFailedException(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(); if (!value || typeof value !== "object") return this.getDefault();

View File

@@ -13,8 +13,6 @@ export {
whereSchema whereSchema
} from "./server/data-query-impl"; } from "./server/data-query-impl";
export { whereRepoSchema as deprecated__whereRepoSchema } from "./server/query";
export { Connection } from "./connection/Connection"; export { Connection } from "./connection/Connection";
export { LibsqlConnection, type LibSqlCredentials } from "./connection/LibsqlConnection"; export { LibsqlConnection, type LibSqlCredentials } from "./connection/LibsqlConnection";
export { SqliteConnection } from "./connection/SqliteConnection"; export { SqliteConnection } from "./connection/SqliteConnection";

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>;

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 { export {
getDefaultConfig, getDefaultConfig,
getDefaultSchema, getDefaultSchema,

View File

@@ -181,7 +181,7 @@ export class MediaController implements ClassController {
if (ids_to_delete.length > 0) { if (ids_to_delete.length > 0) {
await this.media.em await this.media.em
.mutator(mediaEntity) .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 }); return c.json({ ok: true, result: result.data, ...info });

View File

@@ -1,12 +1,31 @@
import { Diff } from "@sinclair/typebox/value";
import { Guard } from "auth"; import { Guard } from "auth";
import { DebugLogger, isDebug } from "core"; import { BkndError, DebugLogger, Exception, isDebug } from "core";
import { EventManager } from "core/events"; import { EventManager } from "core/events";
import { Default, type Static, objectEach, transformObject } from "core/utils"; import { clone, diff } from "core/object/diff";
import { type Connection, EntityManager } from "data"; import {
Default,
type Static,
StringEnum,
Type,
objectEach,
stripMark,
transformObject
} 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 { Hono } from "hono";
import { type Kysely, sql } from "kysely"; import { type Kysely, sql } from "kysely";
import { CURRENT_VERSION, TABLE_NAME, migrate, migrateSchema } from "modules/migrations"; import { mergeWith } from "lodash-es";
import { CURRENT_VERSION, TABLE_NAME, migrate } from "modules/migrations";
import { AppServer } from "modules/server/AppServer"; import { AppServer } from "modules/server/AppServer";
import { AppAuth } from "../auth/AppAuth"; import { AppAuth } from "../auth/AppAuth";
import { AppData } from "../data/AppData"; import { AppData } from "../data/AppData";
@@ -37,10 +56,13 @@ export type ModuleSchemas = {
export type ModuleConfigs = { export type ModuleConfigs = {
[K in keyof ModuleSchemas]: Static<ModuleSchemas[K]>; [K in keyof ModuleSchemas]: Static<ModuleSchemas[K]>;
}; };
type PartialRec<T> = { [P in keyof T]?: PartialRec<T[P]> };
export type InitialModuleConfigs = { export type InitialModuleConfigs =
| ({
version: number; version: number;
} & Partial<ModuleConfigs>; } & ModuleConfigs)
| PartialRec<ModuleConfigs>;
export type ModuleManagerOptions = { export type ModuleManagerOptions = {
initial?: InitialModuleConfigs; initial?: InitialModuleConfigs;
@@ -61,8 +83,36 @@ type ConfigTable<Json = ModuleConfigs> = {
updated_at?: Date; 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 { export class ModuleManager {
private modules: Modules; private modules: Modules;
// internal em for __bknd config table
__em!: EntityManager<T_INTERNAL_EM>;
// ctx for modules
em!: EntityManager<any>; em!: EntityManager<any>;
server!: Hono; server!: Hono;
emgr!: EventManager; emgr!: EventManager;
@@ -71,28 +121,32 @@ export class ModuleManager {
private _version: number = 0; private _version: number = 0;
private _built = false; private _built = false;
private _fetched = 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( constructor(
private readonly connection: Connection, private readonly connection: Connection,
private options?: Partial<ModuleManagerOptions> private options?: Partial<ModuleManagerOptions>
) { ) {
this.__em = new EntityManager([__bknd], this.connection);
this.modules = {} as Modules; this.modules = {} as Modules;
this.emgr = new EventManager(); this.emgr = new EventManager();
const context = this.ctx(true); const context = this.ctx(true);
let initial = {} as Partial<ModuleConfigs>; let initial = {} as Partial<ModuleConfigs>;
if (options?.initial) { if (options?.initial) {
if ("version" in options.initial) {
const { version, ...initialConfig } = options.initial; const { version, ...initialConfig } = options.initial;
if (version && initialConfig) {
this._version = version; this._version = version;
initial = initialConfig; initial = stripMark(initialConfig);
this._provided = true; this._booted_with = "provided";
} else { } else {
throw new Error("Initial was provided, but it needs a version!"); initial = mergeWith(getDefaultConfig(), options.initial);
this._booted_with = "partial";
} }
} }
@@ -120,6 +174,22 @@ 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() {
return await this.__em.schema().sync({ force: true });
}
private rebuildServer() { private rebuildServer() {
this.server = new Hono(); this.server = new Hono();
if (this.options?.basePath) { if (this.options?.basePath) {
@@ -153,27 +223,22 @@ 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> { private async fetch(): Promise<ConfigTable> {
this.logger.context("fetch").log("fetching"); this.logger.context("fetch").log("fetching");
const startTime = performance.now(); const startTime = performance.now();
const result = await this.db const { data: result } = await this.repo().findOne(
.selectFrom(this.table) { type: "config" },
.selectAll() {
.where("type", "=", "config") sort: { by: "version", dir: "desc" }
.orderBy("version", "desc") }
.executeTakeFirstOrThrow(); );
if (!result) {
throw BkndError.with("no config");
}
this.logger.log("took", performance.now() - startTime, "ms", result).clear(); this.logger.log("took", performance.now() - startTime, "ms", result).clear();
return result; return result as ConfigTable;
} }
async save() { async save() {
@@ -181,65 +246,75 @@ export class ModuleManager {
const configs = this.configs(); const configs = this.configs();
const version = this.version(); const version = this.version();
const json = JSON.stringify(configs) as any; try {
const state = await this.fetch(); const state = await this.fetch();
this.logger.log("fetched version", state.version);
if (state.version !== version) { if (state.version !== version) {
// @todo: mark all others as "backup" // @todo: mark all others as "backup"
this.logger.log("version conflict, storing new version", state.version, version); this.logger.log("version conflict, storing new version", state.version, version);
await this.db await this.mutator().insertOne({
.insertInto(this.table) version: state.version,
.values({ type: "backup",
version, json: configs
});
await this.mutator().insertOne({
version: version,
type: "config", type: "config",
json json: configs
}) });
.execute();
} else { } else {
this.logger.log("version matches"); this.logger.log("version matches");
const diff = Diff(state.json, JSON.parse(json)); // clean configs because of Diff() function
this.logger.log("checking diff", diff); const diffs = diff(state.json, clone(configs));
this.logger.log("checking diff", diffs);
if (diff.length > 0) { if (diff.length > 0) {
// store diff // store diff
await this.db await this.mutator().insertOne({
.insertInto(this.table)
.values({
version, version,
type: "diff", type: "diff",
json: JSON.stringify(diff) as any json: clone(diffs)
}) });
.execute();
await this.db // store new version
.updateTable(this.table) await this.mutator().updateWhere(
.set({ version, json, updated_at: sql`CURRENT_TIMESTAMP` }) {
.where((eb) => eb.and([eb("type", "=", "config"), eb("version", "=", version)])) version,
.execute(); json: configs,
updated_at: new Date()
},
{
type: "config",
version
}
);
} else { } else {
this.logger.log("no diff, not saving"); 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 {
console.error("Aborting");
throw e;
}
}
// cleanup // @todo: cleanup old versions?
/*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);*/
this.logger.clear(); this.logger.clear();
return this; return this;
@@ -250,6 +325,8 @@ export class ModuleManager {
if (this.version() < CURRENT_VERSION) { if (this.version() < CURRENT_VERSION) {
this.logger.log("there are migrations, verify version"); this.logger.log("there are migrations, verify version");
// sync __bknd table
await this.syncConfigTable();
// modules must be built before migration // modules must be built before migration
await this.buildModules({ graceful: true }); await this.buildModules({ graceful: true });
@@ -264,15 +341,8 @@ export class ModuleManager {
} }
} catch (e: any) { } catch (e: any) {
this.logger.clear(); // fetch couldn't clear 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"); this.logger.log("now migrating");
let version = this.version(); let version = this.version();
@@ -337,10 +407,16 @@ export class ModuleManager {
async build() { async build() {
this.logger.context("build").log("version", this.version()); this.logger.context("build").log("version", this.version());
this.logger.log("booted with", this._booted_with);
// @todo: check this, because you could start without an initial config
if (this.version() !== CURRENT_VERSION) {
await this.syncConfigTable();
}
// if no config provided, try fetch from db // if no config provided, try fetch from db
if (this.version() === 0) { if (this.version() === 0) {
this.logger.context("build no config").log("version is 0"); this.logger.context("no version").log("version is 0");
try { try {
const result = await this.fetch(); const result = await this.fetch();
@@ -351,23 +427,15 @@ export class ModuleManager {
this.logger.clear(); // fetch couldn't clear this.logger.clear(); // fetch couldn't clear
this.logger.context("error handler").log("fetch failed", e.message); 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 // 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) // it's up to date because we use default configs (no fetch result)
this._version = CURRENT_VERSION;
await this.buildModules(); await this.buildModules();
await this.save(); await this.save();
this.logger.clear(); this.logger.clear();
return this; return this;
} else {
throw e;
//throw new Error("Issues connecting to the database. Reason: " + e.message);
}
} }
this.logger.clear(); this.logger.clear();
} }

View File

@@ -16,26 +16,8 @@ export type Migration = {
export const migrations: Migration[] = [ export const migrations: Migration[] = [
{ {
version: 1, version: 1,
schema: true, //schema: true,
up: async (config, { db }) => { up: async (config) => config
//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;
}
}, },
{ {
version: 2, version: 2,
@@ -45,12 +27,8 @@ export const migrations: Migration[] = [
}, },
{ {
version: 3, version: 3,
schema: true, //schema: true,
up: async (config, { db }) => { up: async (config) => config
await db.schema.alterTable(TABLE_NAME).addColumn("deleted_at", "datetime").execute();
return config;
}
}, },
{ {
version: 4, 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; export const CURRENT_VERSION = migrations[migrations.length - 1]?.version ?? 0;
@@ -127,28 +112,6 @@ export async function migrateTo(
return [version, updated]; 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( export async function migrate(
current: number, current: number,
config: GenericConfigObject, config: GenericConfigObject,

View File

@@ -10,9 +10,11 @@ import * as SystemPermissions from "modules/permissions";
const htmlBkndContextReplace = "<!-- BKND_CONTEXT -->"; const htmlBkndContextReplace = "<!-- BKND_CONTEXT -->";
// @todo: add migration to remove admin path from config
export type AdminControllerOptions = { export type AdminControllerOptions = {
basepath?: string;
html?: string; html?: string;
forceDev?: boolean; forceDev?: boolean | { mainPath: string };
}; };
export class AdminController implements ClassController { export class AdminController implements ClassController {
@@ -25,8 +27,12 @@ export class AdminController implements ClassController {
return this.app.modules.ctx(); return this.app.modules.ctx();
} }
get basepath() {
return this.options.basepath ?? "/";
}
private withBasePath(route: string = "") { private withBasePath(route: string = "") {
return (this.app.modules.configs().server.admin.basepath + route).replace(/\/+$/, "/"); return (this.basepath + route).replace(/\/+$/, "/");
} }
getController(): Hono<any> { getController(): Hono<any> {
@@ -102,7 +108,10 @@ export class AdminController implements ClassController {
if (this.options.html) { if (this.options.html) {
if (this.options.html.includes(htmlBkndContextReplace)) { 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( console.warn(
@@ -113,6 +122,10 @@ export class AdminController implements ClassController {
const configs = this.app.modules.configs(); const configs = this.app.modules.configs();
const isProd = !isDebug() && !this.options.forceDev; 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 = { const assets = {
js: "main.js", js: "main.js",
@@ -166,13 +179,14 @@ export class AdminController implements ClassController {
)} )}
</head> </head>
<body> <body>
<div id="root" />
<div id="app" /> <div id="app" />
<script <script
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: bknd_context __html: bknd_context
}} }}
/> />
{!isProd && <script type="module" src="/src/ui/main.tsx" />} {!isProd && <script type="module" src={mainPath} />}
</body> </body>
</html> </html>
</Fragment> </Fragment>

View File

@@ -2,8 +2,9 @@ import { MantineProvider } from "@mantine/core";
import { Notifications } from "@mantine/notifications"; import { Notifications } from "@mantine/notifications";
import type { ModuleConfigs } from "modules"; import type { ModuleConfigs } from "modules";
import React from "react"; import React from "react";
import { BkndProvider, useBknd } from "ui/client/bknd";
import { FlashMessage } from "ui/modules/server/FlashMessage"; import { FlashMessage } from "ui/modules/server/FlashMessage";
import { BkndProvider, ClientProvider, type ClientProviderProps, useBknd } from "./client"; import { ClientProvider, type ClientProviderProps } from "./client";
import { createMantineTheme } from "./lib/mantine/theme"; import { createMantineTheme } from "./lib/mantine/theme";
import { BkndModalsProvider } from "./modals"; import { BkndModalsProvider } from "./modals";
import { Routes } from "./routes"; import { Routes } from "./routes";

View File

@@ -1,4 +1,3 @@
//import { notifications } from "@mantine/notifications";
import { getDefaultConfig, getDefaultSchema } from "modules/ModuleManager"; import { getDefaultConfig, getDefaultSchema } from "modules/ModuleManager";
import { createContext, startTransition, useContext, useEffect, useRef, useState } from "react"; import { createContext, startTransition, useContext, useEffect, useRef, useState } from "react";
import type { ModuleConfigs, ModuleSchemas } from "../../modules"; import type { ModuleConfigs, ModuleSchemas } from "../../modules";
@@ -101,20 +100,6 @@ export function BkndProvider({
); );
} }
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 { export function useBknd({ withSecrets }: { withSecrets?: boolean } = {}): BkndContext {
const ctx = useContext(BkndContext); const ctx = useContext(BkndContext);
if (withSecrets) ctx.requireSecrets(); if (withSecrets) ctx.requireSecrets();

View File

@@ -1,7 +1,7 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { TApiUser } from "Api"; import type { TApiUser } from "Api";
import { createContext, useContext, useEffect, useState } from "react"; import { createContext, useContext, useEffect, useState } from "react";
import { useBkndWindowContext } from "ui/client/BkndProvider"; //import { useBkndWindowContext } from "ui/client/BkndProvider";
import { AppQueryClient } from "./utils/AppQueryClient"; import { AppQueryClient } from "./utils/AppQueryClient";
const ClientContext = createContext<{ baseUrl: string; client: AppQueryClient }>({ const ClientContext = createContext<{ baseUrl: string; client: AppQueryClient }>({
@@ -89,3 +89,17 @@ export const useBaseUrl = () => {
const context = useContext(ClientContext); const context = useContext(ClientContext);
return context.baseUrl; 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"
};
}
}

View File

@@ -0,0 +1 @@
export { BkndProvider, useBknd } from "./BkndProvider";

View File

@@ -1,5 +1,10 @@
export { ClientProvider, type ClientProviderProps, useClient, useBaseUrl } from "./ClientProvider"; export {
export { BkndProvider, useBknd } from "./BkndProvider"; ClientProvider,
useBkndWindowContext,
type ClientProviderProps,
useClient,
useBaseUrl
} from "./ClientProvider";
export { useAuth } from "./schema/auth/use-auth"; export { useAuth } from "./schema/auth/use-auth";
export { Api } from "../../Api"; export { Api } from "../../Api";

View File

@@ -1,4 +1,4 @@
import { useBknd } from "ui/client"; import { useBknd } from "ui/client/bknd";
export function useBkndAuth() { export function useBkndAuth() {
//const client = useClient(); //const client = useClient();

View File

@@ -11,7 +11,7 @@ import {
fieldsSchema, fieldsSchema,
relationsSchema relationsSchema
} from "data/data-schema"; } from "data/data-schema";
import { useBknd } from "ui/client"; import { useBknd } from "ui/client/bknd";
import type { TSchemaActions } from "ui/client/schema/actions"; import type { TSchemaActions } from "ui/client/schema/actions";
export function useBkndData() { export function useBkndData() {

View File

@@ -1,4 +1,4 @@
import { useBknd } from "ui/client"; import { useBknd } from "ui/client/bknd";
export function useBkndSystem() { export function useBkndSystem() {
const { config, schema, actions: bkndActions } = useBknd(); const { config, schema, actions: bkndActions } = useBknd();

View File

@@ -1,4 +1,4 @@
import { useBknd } from "ui"; import { useBknd } from "ui/client/bknd";
export function useTheme(): { theme: "light" | "dark" } { export function useTheme(): { theme: "light" | "dark" } {
const b = useBknd(); const b = useBknd();

View File

@@ -1,6 +1,6 @@
import type { ReactCodeMirrorProps } from "@uiw/react-codemirror"; import type { ReactCodeMirrorProps } from "@uiw/react-codemirror";
import { Suspense, lazy } from "react"; import { Suspense, lazy } from "react";
import { useBknd } from "ui/client"; import { useBknd } from "ui/client/bknd";
const CodeMirror = lazy(() => import("@uiw/react-codemirror")); const CodeMirror = lazy(() => import("@uiw/react-codemirror"));
export default function CodeEditor({ editable, basicSetup, ...props }: ReactCodeMirrorProps) { export default function CodeEditor({ editable, basicSetup, ...props }: ReactCodeMirrorProps) {

View File

@@ -1,14 +1,4 @@
export { default as Admin, type BkndAdminProps } from "./Admin"; export { default as Admin, type BkndAdminProps } from "./Admin";
export { Button } from "./components/buttons/Button";
export { Context } from "./components/Context";
export {
useClient,
ClientProvider,
BkndProvider,
useBknd,
useAuth,
useBaseUrl
} from "./client";
export { export {
EntitiesContainer, EntitiesContainer,
useEntities, useEntities,

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 { IconKeyOff, IconSettings, IconUser } from "@tabler/icons-react";
import { import {
TbDatabase, TbDatabase,
TbFingerprint, TbFingerprint,
TbHierarchy2, TbHierarchy2,
TbMenu2, TbMenu2,
TbMoon,
TbPhoto, TbPhoto,
TbSelector, TbSelector,
TbSun,
TbUser, TbUser,
TbX TbX
} from "react-icons/tb"; } from "react-icons/tb";
import { Button } from "ui"; import { useAuth, useBkndWindowContext } from "ui/client";
import { useAuth, useBknd } from "ui/client"; import { useBknd } from "ui/client/bknd";
import { useBkndWindowContext } from "ui/client/BkndProvider";
import { useBkndSystemTheme } from "ui/client/schema/system/use-bknd-system"; 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 { IconButton } from "ui/components/buttons/IconButton";
import { Logo } from "ui/components/display/Logo"; import { Logo } from "ui/components/display/Logo";
import { Dropdown, type DropdownItem } from "ui/components/overlay/Dropdown"; import { Dropdown, type DropdownItem } from "ui/components/overlay/Dropdown";

View File

@@ -1,5 +1,5 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { Button } from "ui"; import { Button } from "ui/components/buttons/Button";
import { import {
JsonSchemaForm, JsonSchemaForm,
type JsonSchemaFormProps, type JsonSchemaFormProps,

View File

@@ -3,7 +3,7 @@ import { Type } from "core/utils";
import type { ComponentPropsWithoutRef } from "react"; import type { ComponentPropsWithoutRef } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { Button } from "ui"; import { Button } from "ui/components/buttons/Button";
import * as Formy from "ui/components/form/Formy"; import * as Formy from "ui/components/form/Formy";
export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "onSubmit"> & { export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "onSubmit"> & {

View File

@@ -4,8 +4,9 @@ import { ucFirst } from "core/utils";
import type { EntityData, RelationField } from "data"; import type { EntityData, RelationField } from "data";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { TbEye } from "react-icons/tb"; import { TbEye } from "react-icons/tb";
import { Button } from "ui"; import { useClient } from "ui/client";
import { useBknd, 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 * as Formy from "ui/components/form/Formy";
import { Popover } from "ui/components/overlay/Popover"; import { Popover } from "ui/components/overlay/Popover";
import { useEntities } from "ui/container"; import { useEntities } from "ui/container";

View File

@@ -11,7 +11,7 @@ import {
import { ManyToOneRelation, type RelationType, RelationTypes } from "data"; import { ManyToOneRelation, type RelationType, RelationTypes } from "data";
import { type ReactNode, useEffect } from "react"; import { type ReactNode, useEffect } from "react";
import { type Control, type FieldValues, type UseFormRegister, useForm } from "react-hook-form"; 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 { MantineNumberInput } from "ui/components/form/hook-form-mantine/MantineNumberInput";
import { MantineSelect } from "ui/components/form/hook-form-mantine/MantineSelect"; import { MantineSelect } from "ui/components/form/hook-form-mantine/MantineSelect";
import { useStepContext } from "ui/components/steps/Steps"; import { useStepContext } from "ui/components/steps/Steps";

View File

@@ -10,7 +10,7 @@ import {
} from "core/utils"; } from "core/utils";
import type { MediaFieldConfig } from "media/MediaField"; import type { MediaFieldConfig } from "media/MediaField";
import { useForm } from "react-hook-form"; 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 { MantineNumberInput } from "ui/components/form/hook-form-mantine/MantineNumberInput";
import { MantineRadio } from "ui/components/form/hook-form-mantine/MantineRadio"; import { MantineRadio } from "ui/components/form/hook-form-mantine/MantineRadio";
import { MantineSelect } from "ui/components/form/hook-form-mantine/MantineSelect"; import { MantineSelect } from "ui/components/form/hook-form-mantine/MantineSelect";

View File

@@ -8,7 +8,7 @@ import { Type } from "core/utils";
import { FetchTask } from "flows"; import { FetchTask } from "flows";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { useForm } from "react-hook-form"; 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 { JsonViewer } from "ui/components/code/JsonViewer";
import { SegmentedControl } from "ui/components/form/SegmentedControl"; import { SegmentedControl } from "ui/components/form/SegmentedControl";
import { MantineSelect } from "ui/components/form/hook-form-mantine/MantineSelect"; import { MantineSelect } from "ui/components/form/hook-form-mantine/MantineSelect";

View File

@@ -5,7 +5,7 @@ import { selectAtom } from "jotai/utils";
import { isEqual } from "lodash-es"; import { isEqual } from "lodash-es";
import type { ModuleSchemas } from "modules/ModuleManager"; import type { ModuleSchemas } from "modules/ModuleManager";
import { createContext, useCallback, useContext, useEffect } from "react"; import { createContext, useCallback, useContext, useEffect } from "react";
import { useBknd } from "ui/client"; import { useBknd } from "ui/client/bknd";
export type TFlowNodeData = { export type TFlowNodeData = {
label: string; label: string;

View File

@@ -1,6 +1,6 @@
import { IconFingerprint } from "@tabler/icons-react"; import { IconFingerprint } from "@tabler/icons-react";
import { TbSettings } from "react-icons/tb"; 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 { IconButton } from "ui/components/buttons/IconButton";
import { Empty } from "ui/components/display/Empty"; import { Empty } from "ui/components/display/Empty";
import { Link } from "ui/components/wouter/Link"; import { Link } from "ui/components/wouter/Link";

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 { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
import { Alert } from "ui/components/display/Alert"; import { Alert } from "ui/components/display/Alert";
import { routes } from "ui/lib/routes"; import { routes } from "ui/lib/routes";

View File

@@ -1,7 +1,7 @@
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { useRef } from "react"; import { useRef } from "react";
import { TbDots } from "react-icons/tb"; 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 { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
import { Button } from "ui/components/buttons/Button"; import { Button } from "ui/components/buttons/Button";
import { IconButton } from "ui/components/buttons/IconButton"; import { IconButton } from "ui/components/buttons/IconButton";

View File

@@ -1,6 +1,6 @@
import { cloneDeep, omit } from "lodash-es"; import { cloneDeep, omit } from "lodash-es";
import { useEffect, useRef } from "react"; 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 { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
import { useBkndData } from "ui/client/schema/data/use-bknd-data"; import { useBkndData } from "ui/client/schema/data/use-bknd-data";
import { Button } from "ui/components/buttons/Button"; import { Button } from "ui/components/buttons/Button";

View File

@@ -1,5 +1,5 @@
import { cloneDeep, omit } from "lodash-es"; 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 { Button } from "ui/components/buttons/Button";
import { JsonSchemaForm } from "ui/components/form/json-schema/JsonSchemaForm"; import { JsonSchemaForm } from "ui/components/form/json-schema/JsonSchemaForm";
import * as AppShell from "../../layouts/AppShell/AppShell"; import * as AppShell from "../../layouts/AppShell/AppShell";

View File

@@ -2,10 +2,10 @@ import { typeboxResolver } from "@hookform/resolvers/typebox";
import { Input, Switch, Tooltip } from "@mantine/core"; import { Input, Switch, Tooltip } from "@mantine/core";
import { guardRoleSchema } from "auth/auth-schema"; import { guardRoleSchema } from "auth/auth-schema";
import { type Static, ucFirst } from "core/utils"; import { type Static, ucFirst } from "core/utils";
import type { TAppDataEntityFields } from "data/data-schema";
import { forwardRef, useImperativeHandle } from "react"; import { forwardRef, useImperativeHandle } from "react";
import { type UseControllerProps, useController, useForm } from "react-hook-form"; 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"; import { MantineSwitch } from "ui/components/form/hook-form-mantine/MantineSwitch";
const schema = guardRoleSchema; const schema = guardRoleSchema;

View File

@@ -2,12 +2,12 @@ import { SegmentedControl } from "@mantine/core";
import { IconDatabase } from "@tabler/icons-react"; import { IconDatabase } from "@tabler/icons-react";
import type { Entity, TEntityType } from "data"; import type { Entity, TEntityType } from "data";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { useBknd } from "../../client"; import { useBknd } from "ui/client/bknd";
import { Empty } from "../../components/display/Empty"; import { Empty } from "ui/components/display/Empty";
import { Link } from "../../components/wouter/Link"; import { Link } from "ui/components/wouter/Link";
import { useBrowserTitle } from "../../hooks/use-browser-title"; import { useBrowserTitle } from "ui/hooks/use-browser-title";
import * as AppShell from "../../layouts/AppShell/AppShell"; import * as AppShell from "ui/layouts/AppShell/AppShell";
import { routes, useNavigate } from "../../lib/routes"; import { routes, useNavigate } from "ui/lib/routes";
export function DataRoot({ children }) { export function DataRoot({ children }) {
// @todo: settings routes should be centralized // @todo: settings routes should be centralized

View File

@@ -1,24 +1,21 @@
import { encodeSearch, ucFirst } from "core/utils"; import { ucFirst } from "core/utils";
import type { Entity, EntityData } from "data"; import type { Entity, EntityData, EntityRelation } from "data";
import type { EntityRelation } from "data"; import { Fragment, useState } from "react";
import { Fragment, memo, useState } from "react"; import { TbDots } from "react-icons/tb";
import { TbArrowLeft, TbDots } from "react-icons/tb"; import { useClient } from "ui/client";
import { useBkndData } from "ui/client/schema/data/use-bknd-data"; 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 { EntityForm } from "ui/modules/data/components/EntityForm";
import { EntityTable2 } from "ui/modules/data/components/EntityTable2"; import { EntityTable2 } from "ui/modules/data/components/EntityTable2";
import { useEntityForm } from "ui/modules/data/hooks/useEntityForm"; 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 }) { export function DataEntityUpdate({ params }) {
const { $data, relations } = useBkndData(); const { $data, relations } = useBkndData();

View File

@@ -2,18 +2,16 @@ import { Type } from "core/utils";
import { querySchema } from "data"; import { querySchema } from "data";
import { TbDots } from "react-icons/tb"; import { TbDots } from "react-icons/tb";
import { useBkndData } from "ui/client/schema/data/use-bknd-data"; 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 { 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 { 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 // @todo: migrate to Typebox
const searchSchema = Type.Composite( const searchSchema = Type.Composite(

View File

@@ -18,9 +18,7 @@ import { forwardRef, memo, useEffect, useImperativeHandle } from "react";
import { type FieldArrayWithId, type UseFormReturn, useFieldArray, useForm } from "react-hook-form"; import { type FieldArrayWithId, type UseFormReturn, useFieldArray, useForm } from "react-hook-form";
import { TbGripVertical, TbSettings, TbTrash } from "react-icons/tb"; import { TbGripVertical, TbSettings, TbTrash } from "react-icons/tb";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { Button } from "ui"; import { Button } from "ui/components/buttons/Button";
import { useBknd } from "ui/client";
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
import { IconButton } from "ui/components/buttons/IconButton"; import { IconButton } from "ui/components/buttons/IconButton";
import { JsonViewer } from "ui/components/code/JsonViewer"; import { JsonViewer } from "ui/components/code/JsonViewer";
import { MantineSwitch } from "ui/components/form/hook-form-mantine/MantineSwitch"; import { MantineSwitch } from "ui/components/form/hook-form-mantine/MantineSwitch";

View File

@@ -1,5 +1,5 @@
import { Suspense, lazy } from "react"; import { Suspense, lazy } from "react";
import { useBknd } from "ui/client"; import { useBknd } from "ui/client/bknd";
import { Route, Router, Switch } from "wouter"; import { Route, Router, Switch } from "wouter";
import { AuthLogin } from "./auth/auth.login"; import { AuthLogin } from "./auth/auth.login";
import { Root, RootEmpty } from "./root"; import { Root, RootEmpty } from "./root";

View File

@@ -1,7 +1,6 @@
import { IconFingerprint, IconHome } from "@tabler/icons-react"; import { IconHome } from "@tabler/icons-react";
import { isDebug } from "core"; import { Suspense, useEffect } from "react";
import { Suspense, lazy, useEffect } from "react"; import { useAuth } from "ui/client";
import { useAuth } from "ui";
import { Empty } from "../components/display/Empty"; import { Empty } from "../components/display/Empty";
import { useBrowserTitle } from "../hooks/use-browser-title"; import { useBrowserTitle } from "../hooks/use-browser-title";
import * as AppShell from "../layouts/AppShell/AppShell"; import * as AppShell from "../layouts/AppShell/AppShell";

View File

@@ -3,23 +3,21 @@ import { type TObject, ucFirst } from "core/utils";
import { omit } from "lodash-es"; import { omit } from "lodash-es";
import { type ReactNode, useMemo, useRef, useState } from "react"; import { type ReactNode, useMemo, useRef, useState } from "react";
import { TbSettings } from "react-icons/tb"; 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 { Alert } from "ui/components/display/Alert";
import { Link, Route, useLocation } from "wouter"; import { Empty } from "ui/components/display/Empty";
import { useBknd } from "../../../client/BkndProvider";
import { Button } from "../../../components/buttons/Button";
import { IconButton } from "../../../components/buttons/IconButton";
import { Empty } from "../../../components/display/Empty";
import { import {
JsonSchemaForm, JsonSchemaForm,
type JsonSchemaFormRef type JsonSchemaFormRef
} from "../../../components/form/json-schema/JsonSchemaForm"; } from "ui/components/form/json-schema/JsonSchemaForm";
import { Dropdown } from "../../../components/overlay/Dropdown"; import { Dropdown } from "ui/components/overlay/Dropdown";
import { DataTable } from "../../../components/table/DataTable"; import { DataTable } from "ui/components/table/DataTable";
import { useEvent } from "../../../hooks/use-event"; import { useEvent } from "ui/hooks/use-event";
import * as AppShell from "../../../layouts/AppShell/AppShell"; import * as AppShell from "ui/layouts/AppShell/AppShell";
import { SectionHeaderTabs } from "../../../layouts/AppShell/AppShell"; import { Breadcrumbs } from "ui/layouts/AppShell/Breadcrumbs";
import { Breadcrumbs } from "../../../layouts/AppShell/Breadcrumbs"; import { Link, Route, useLocation } from "wouter";
import { extractSchema } from "../utils/schema"; import { extractSchema } from "../utils/schema";
import { SettingNewModal, type SettingsNewModalProps } from "./SettingNewModal"; import { SettingNewModal, type SettingsNewModalProps } from "./SettingNewModal";
import { SettingSchemaModal, type SettingsSchemaModalRef } from "./SettingSchemaModal"; import { SettingSchemaModal, type SettingsSchemaModalRef } from "./SettingSchemaModal";

View File

@@ -1,13 +1,11 @@
import { modals } from "@mantine/modals";
import { IconSettings } from "@tabler/icons-react"; import { IconSettings } from "@tabler/icons-react";
import { ucFirst } from "core/utils"; 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 { 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 { Setting } from "./components/Setting";
import { AuthSettings } from "./routes/auth.settings"; import { AuthSettings } from "./routes/auth.settings";
import { DataSettings } from "./routes/data.settings"; import { DataSettings } from "./routes/data.settings";

View File

@@ -1,8 +1,8 @@
import { cloneDeep, transform } from "lodash-es"; 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 { fieldSpecs } from "ui/modules/data/components/fields-specs";
import { Route, Switch } from "wouter"; import { Route, Switch } from "wouter";
import type { ModuleConfigs, ModuleSchemas } from "../../../../modules";
import { useBknd } from "../../../client";
import { Setting } from "../components/Setting"; import { Setting } from "../components/Setting";
export const dataFieldsUiSchema = { export const dataFieldsUiSchema = {

View File

@@ -1,5 +1,5 @@
import { cloneDeep } from "lodash-es"; import { cloneDeep } from "lodash-es";
import { useBknd } from "ui"; import { useBknd } from "ui/client/bknd";
import { Setting } from "ui/routes/settings/components/Setting"; import { Setting } from "ui/routes/settings/components/Setting";
import { Route } from "wouter"; import { Route } from "wouter";

BIN
bun.lockb

Binary file not shown.

28
docker/Dockerfile Normal file
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}}"]

36
docker/README.md Normal file
View File

@@ -0,0 +1,36 @@
# 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
```

View File

@@ -45,7 +45,7 @@ export const ALL = serve({
connection: { connection: {
type: "libsql", type: "libsql",
config: { config: {
url: "http://127.0.0.1:8080" url: "file:data.db"
} }
} }
}); });
@@ -74,7 +74,7 @@ export const prerender = false;
<Admin <Admin
withProvider={{ user }} withProvider={{ user }}
config={{ basepath: "/admin", color_scheme: "dark" }} config={{ basepath: "/admin", color_scheme: "dark" }}
client:load client:only
/> />
</body> </body>
</html> </html>

View File

@@ -16,7 +16,8 @@ the admin panel.
// index.ts // index.ts
import { serve } from "bknd/adapter/bun"; import { serve } from "bknd/adapter/bun";
const handler = serve({ // if the configuration is omitted, it uses an in-memory database
serve({
connection: { connection: {
type: "libsql", type: "libsql",
config: { 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. For more information about the connection object, refer to the [Setup](/setup) guide.

View File

@@ -34,13 +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: Create a file `[[...admin]].tsx` inside the `pages/admin` folder:
```tsx ```tsx
// pages/admin/[[...admin]].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"; import "bknd/dist/styles.css";
export { getServerSideProps }; const Admin = dynamic(() => import("bknd/ui").then((mod) => mod.Admin), {
export default adminPage({ ssr: false,
config: { basepath: "/admin" }
}); });
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 ## Example usage of the API in pages dir

37
docs/integration/node.mdx Normal file
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
```

View File

@@ -104,7 +104,7 @@
"integration/vite", "integration/vite",
"integration/express", "integration/express",
"integration/astro", "integration/astro",
"integration/nodejs", "integration/node",
"integration/deno", "integration/deno",
"integration/browser" "integration/browser"
] ]

View File

@@ -18,9 +18,9 @@ The easiest to get started is using SQLite as a file. When serving the API in th
the function accepts an object with connection details. To use a file, use the following: the function accepts an object with connection details. To use a file, use the following:
```json ```json
{ {
"type": "sqlite", "type": "libsql",
"config": { "config": {
"file": "path/to/your/database.db" "url": "file:<path/to/your/database.db>"
} }
} }
``` ```
@@ -56,6 +56,30 @@ connection object to your new database:
} }
``` ```
### Custom Connection (unstable)
<Note>
Follow the progress of custom connections on its [Github Issue](https://github.com/bknd-io/bknd/issues/24).
If you're interested, make sure to upvote so it can be prioritized.
</Note>
Any bknd app instantiation accepts as connection either `undefined`, a connection object like
described above, or an class instance that extends from `Connection`:
```ts
import { createApp } from "bknd";
import { Connection } from "bknd/data";
class CustomConnection extends Connection {
constructor() {
const kysely = new Kysely(/* ... */);
super(kysely);
}
}
const connection = new CustomConnection();
// e.g. and then, create an instance
const app = createApp({ connection })
```
## Installation ## Installation
To install **bknd**, run the following command: To install **bknd**, run the following command:
<InstallBknd /> <InstallBknd />

View File

@@ -15,7 +15,7 @@ export const prerender = false;
<Admin <Admin
withProvider={{ user }} withProvider={{ user }}
config={{ basepath: "/admin", color_scheme: "dark" }} config={{ basepath: "/admin", color_scheme: "dark" }}
client:load client:only
/> />
</body> </body>
</html> </html>

View File

@@ -6,7 +6,7 @@ export const ALL = serve({
connection: { connection: {
type: "libsql", type: "libsql",
config: { config: {
url: "http://127.0.0.1:8080" url: "file:test.db"
} }
} }
}); });

View File

@@ -1,26 +1,20 @@
// @ts-ignore somehow causes types:build issues on app // @ts-ignore somehow causes types:build issues on app
import type { CreateAppConfig } from "bknd"; import { type BunAdapterOptions, serve } from "bknd/adapter/bun";
// @ts-ignore somehow causes types:build issues on app
import { serve } from "bknd/adapter/bun"; // Actually, all it takes is the following line:
// serve();
// this is optional, if omitted, it uses an in-memory database // this is optional, if omitted, it uses an in-memory database
const config = { const config: BunAdapterOptions = {
connection: { connection: {
type: "libsql", type: "libsql",
config: { config: {
url: "http://localhost:8080" url: ":memory:"
} }
} },
} satisfies CreateAppConfig;
Bun.serve({
port: 1337,
fetch: serve(
config,
// this is only required to run inside the same workspace // this is only required to run inside the same workspace
// leave blank if you're running this from a different project // leave blank if you're running this from a different project
"../../app/dist" distPath: "../../app/dist"
) };
});
console.log("Server running at http://localhost:1337"); serve(config);

View File

@@ -1,9 +1,20 @@
import { adminPage, getServerSideProps } from "bknd/adapter/nextjs"; import { withApi } from "bknd/adapter/nextjs";
import dynamic from "next/dynamic";
import "bknd/dist/styles.css"; import "bknd/dist/styles.css";
export { getServerSideProps }; const Admin = dynamic(() => import("bknd/ui").then((mod) => mod.Admin), {
export default adminPage({ ssr: false
config: {
basepath: "/admin"
}
}); });
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" }} />;
}

View File

@@ -1,22 +1,20 @@
import { serve } from "bknd/adapter/node"; import { serve } from "bknd/adapter/node";
// Actually, all it takes is the following line:
// serve();
// this is optional, if omitted, it uses an in-memory database // this is optional, if omitted, it uses an in-memory database
/** @type {import("bknd").CreateAppConfig} */ /** @type {import("bknd/adapter/node").NodeAdapterOptions} */
const config = { const config = {
connection: { connection: {
type: "libsql", type: "libsql",
config: { config: {
url: "http://localhost:8080" url: ":memory:"
} }
}
};
serve(config, {
port: 1337,
listener: ({ port }) => {
console.log(`Server is running on http://localhost:${port}`);
}, },
// this is only required to run inside the same workspace // this is only required to run inside the same workspace
// leave blank if you're running this from a different project // leave blank if you're running this from a different project
relativeDistPath: "../../app/dist" relativeDistPath: "../../app/dist"
}); };
serve(config);

View File

@@ -4,7 +4,7 @@ const handler = serve({
connection: { connection: {
type: "libsql", type: "libsql",
config: { config: {
url: "http://localhost:8080" url: "file:test.db"
} }
} }
}); });