diff --git a/.gitignore b/.gitignore index a513830..9727332 100644 --- a/.gitignore +++ b/.gitignore @@ -20,8 +20,10 @@ packages/media/.env **/*/*.db **/*/*.db-shm **/*/*.db-wal +**/*/.tmp .npmrc /.verdaccio .idea .vscode -.git_old \ No newline at end of file +.git_old +docker/tmp \ No newline at end of file diff --git a/app/__test__/ModuleManager.spec.ts b/app/__test__/ModuleManager.spec.ts index d58b98a..2e928d6 100644 --- a/app/__test__/ModuleManager.spec.ts +++ b/app/__test__/ModuleManager.spec.ts @@ -1,7 +1,8 @@ import { describe, expect, test } from "bun:test"; import { mark, stripMark } from "../src/core/utils"; -import { ModuleManager } from "../src/modules/ModuleManager"; -import { CURRENT_VERSION, TABLE_NAME, migrateSchema } from "../src/modules/migrations"; +import { entity, text } from "../src/data"; +import { ModuleManager, getDefaultConfig } from "../src/modules/ModuleManager"; +import { CURRENT_VERSION, TABLE_NAME } from "../src/modules/migrations"; import { getDummyConnection } from "./helper"; describe("ModuleManager", async () => { @@ -29,21 +30,68 @@ describe("ModuleManager", async () => { const mm = new ModuleManager(c.dummyConnection); await mm.build(); const version = mm.version(); - const json = mm.configs(); + const configs = mm.configs(); + const json = stripMark({ + ...configs, + data: { + ...configs.data, + basepath: "/api/data2", + entities: { + test: entity("test", { + content: text() + }).toJSON() + } + } + }); //const { version, ...json } = mm.toJSON() as any; const c2 = getDummyConnection(); const db = c2.dummyConnection.kysely; - await migrateSchema(CURRENT_VERSION, { db }); + const mm2 = new ModuleManager(c2.dummyConnection, { initial: { version, ...json } }); + await mm2.syncConfigTable(); await db - .updateTable(TABLE_NAME) - .set({ json: JSON.stringify(json), version: CURRENT_VERSION }) + .insertInto(TABLE_NAME) + .values({ type: "config", json: JSON.stringify(json), version: CURRENT_VERSION }) .execute(); - const mm2 = new ModuleManager(c2.dummyConnection, { initial: { version, ...json } }); await mm2.build(); - expect(json).toEqual(mm2.configs()); + expect(json).toEqual(stripMark(mm2.configs())); + }); + + test("s3.1: (fetch) config given, table exists, version matches", async () => { + const configs = getDefaultConfig(); + const json = { + ...configs, + data: { + ...configs.data, + basepath: "/api/data2", + entities: { + test: entity("test", { + content: text() + }).toJSON() + } + } + }; + //const { version, ...json } = mm.toJSON() as any; + + const { dummyConnection } = getDummyConnection(); + const db = dummyConnection.kysely; + const mm2 = new ModuleManager(dummyConnection); + await mm2.syncConfigTable(); + // assume an initial version + await db.insertInto(TABLE_NAME).values({ type: "config", json: null, version: 1 }).execute(); + await db + .insertInto(TABLE_NAME) + .values({ type: "config", json: JSON.stringify(json), version: CURRENT_VERSION }) + .execute(); + + await mm2.build(); + + expect(stripMark(json)).toEqual(stripMark(mm2.configs())); + expect(mm2.configs().data.entities.test).toBeDefined(); + expect(mm2.configs().data.entities.test.fields.content).toBeDefined(); + expect(mm2.get("data").toJSON().entities.test.fields.content).toBeDefined(); }); test("s4: config given, table exists, version outdated, migrate", async () => { @@ -52,21 +100,19 @@ describe("ModuleManager", async () => { await mm.build(); const version = mm.version(); const json = mm.configs(); - //const { version, ...json } = mm.toJSON() as any; const c2 = getDummyConnection(); const db = c2.dummyConnection.kysely; - console.log("here2"); - await migrateSchema(CURRENT_VERSION, { db }); - await db - .updateTable(TABLE_NAME) - .set({ json: JSON.stringify(json), version: CURRENT_VERSION - 1 }) - .execute(); - const mm2 = new ModuleManager(c2.dummyConnection, { initial: { version: version - 1, ...json } }); - console.log("here3"); + await mm2.syncConfigTable(); + + await db + .insertInto(TABLE_NAME) + .values({ json: JSON.stringify(json), type: "config", version: CURRENT_VERSION - 1 }) + .execute(); + await mm2.build(); }); @@ -80,15 +126,15 @@ describe("ModuleManager", async () => { const c2 = getDummyConnection(); const db = c2.dummyConnection.kysely; - await migrateSchema(CURRENT_VERSION, { db }); - await db - .updateTable(TABLE_NAME) - .set({ json: JSON.stringify(json), version: CURRENT_VERSION }) - .execute(); const mm2 = new ModuleManager(c2.dummyConnection, { initial: { version: version - 1, ...json } }); + await mm2.syncConfigTable(); + await db + .insertInto(TABLE_NAME) + .values({ type: "config", json: JSON.stringify(json), version: CURRENT_VERSION }) + .execute(); expect(mm2.build()).rejects.toThrow(/version.*do not match/); }); @@ -102,7 +148,9 @@ describe("ModuleManager", async () => { const c2 = getDummyConnection(); const db = c2.dummyConnection.kysely; - await migrateSchema(CURRENT_VERSION, { db }); + + const mm2 = new ModuleManager(c2.dummyConnection); + await mm2.syncConfigTable(); const config = { ...json, @@ -112,12 +160,11 @@ describe("ModuleManager", async () => { } }; await db - .updateTable(TABLE_NAME) - .set({ json: JSON.stringify(config), version: CURRENT_VERSION }) + .insertInto(TABLE_NAME) + .values({ type: "config", json: JSON.stringify(config), version: CURRENT_VERSION }) .execute(); // run without config given - const mm2 = new ModuleManager(c2.dummyConnection); await mm2.build(); expect(mm2.configs().data.basepath).toBe("/api/data2"); @@ -148,50 +195,61 @@ describe("ModuleManager", async () => { }); }); - // @todo: check what happens here - /*test("blank app, modify deep config", async () => { + test("partial config given", async () => { const { dummyConnection } = getDummyConnection(); - const mm = new ModuleManager(dummyConnection); + const partial = { + auth: { + enabled: true + } + }; + const mm = new ModuleManager(dummyConnection, { + initial: partial + }); await mm.build(); - /!* await mm - .get("data") - .schema() - .patch("entities.test", { - fields: { - content: { - type: "text" - } + expect(mm.version()).toBe(CURRENT_VERSION); + expect(mm.built()).toBe(true); + expect(mm.configs().auth.enabled).toBe(true); + expect(mm.configs().data.entities.users).toBeDefined(); + }); + + test("partial config given, but db version exists", async () => { + const c = getDummyConnection(); + const mm = new ModuleManager(c.dummyConnection); + await mm.build(); + const json = mm.configs(); + + const c2 = getDummyConnection(); + const db = c2.dummyConnection.kysely; + + const mm2 = new ModuleManager(c2.dummyConnection, { + initial: { + auth: { + basepath: "/shouldnt/take/this" } - }); - await mm.build(); + } + }); + await mm2.syncConfigTable(); + const payload = { + ...json, + auth: { + ...json.auth, + enabled: true, + basepath: "/api/auth2" + } + }; + await db + .insertInto(TABLE_NAME) + .values({ + type: "config", + json: JSON.stringify(payload), + version: CURRENT_VERSION + }) + .execute(); + await mm2.build(); + expect(mm2.configs().auth.basepath).toBe("/api/auth2"); + }); - expect(mm.configs().data.entities?.users?.fields?.email.type).toBe("text"); - - expect( - mm.get("data").schema().patch("desc", "entities.users.config.sort_dir") - ).rejects.toThrow(); - await mm.build();*!/ - expect(mm.configs().data.entities?.users?.fields?.email.type).toBe("text"); - console.log("here", mm.configs()); - await mm - .get("data") - .schema() - .patch("entities.users", { config: { sort_dir: "desc" } }); - await mm.build(); - expect(mm.toJSON()); - - //console.log(_jsonp(mm.toJSON().data)); - /!*expect(mm.configs().data.entities!.test!.fields!.content.type).toBe("text"); - expect(mm.configs().data.entities!.users!.config!.sort_dir).toBe("desc");*!/ - });*/ - - /*test("accessing modules", async () => { - const { dummyConnection } = getDummyConnection(); - - const mm = new ModuleManager(dummyConnection); - - //mm.get("auth").mutate().set({}); - });*/ + // @todo: add tests for migrations (check "backup" and new version) }); diff --git a/app/__test__/core/Endpoint.spec.ts b/app/__test__/core/Endpoint.spec.ts deleted file mode 100644 index 8dd9c10..0000000 --- a/app/__test__/core/Endpoint.spec.ts +++ /dev/null @@ -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]); - }); - }); -}); diff --git a/app/__test__/core/object/diff.test.ts b/app/__test__/core/object/diff.test.ts new file mode 100644 index 0000000..b6ac6e4 --- /dev/null +++ b/app/__test__/core/object/diff.test.ts @@ -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); + }); +}); diff --git a/app/__test__/core/object/object-query.spec.ts b/app/__test__/core/object/object-query.spec.ts index 66e7215..a6940a9 100644 --- a/app/__test__/core/object/object-query.spec.ts +++ b/app/__test__/core/object/object-query.spec.ts @@ -1,6 +1,5 @@ import { describe, expect, test } from "bun:test"; import { type ObjectQuery, convert, validate } from "../../../src/core/object/query/object-query"; -import { deprecated__whereRepoSchema } from "../../../src/data"; describe("object-query", () => { const q: ObjectQuery = { name: "Michael" }; @@ -8,19 +7,6 @@ describe("object-query", () => { const q3: ObjectQuery = { name: "Michael", age: { $gt: 18 } }; const bag = { q, q2, q3 }; - test("translates into legacy", async () => { - for (const [key, value] of Object.entries(bag)) { - const obj = convert(value); - try { - const parsed = deprecated__whereRepoSchema.parse(obj); - expect(parsed).toBeDefined(); - } catch (e) { - console.log("errored", { obj, value }); - console.error(key, e); - } - } - }); - test("validates", async () => { const converted = convert({ name: { $eq: "ch" } diff --git a/app/__test__/data/mutation.simple.test.ts b/app/__test__/data/mutation.simple.test.ts index b3f0c77..dd385af 100644 --- a/app/__test__/data/mutation.simple.test.ts +++ b/app/__test__/data/mutation.simple.test.ts @@ -132,14 +132,47 @@ describe("Mutator simple", async () => { const data = (await em.repository(items).findMany()).data; //console.log(data); - await em.mutator(items).deleteMany({ label: "delete" }); + await em.mutator(items).deleteWhere({ label: "delete" }); expect((await em.repository(items).findMany()).data.length).toBe(data.length - 2); //console.log((await em.repository(items).findMany()).data); - await em.mutator(items).deleteMany(); + await em.mutator(items).deleteWhere(); expect((await em.repository(items).findMany()).data.length).toBe(0); //expect(res.data.count).toBe(0); }); + + test("updateMany", async () => { + await em.mutator(items).insertOne({ label: "update", count: 1 }); + await em.mutator(items).insertOne({ label: "update too", count: 1 }); + await em.mutator(items).insertOne({ label: "keep" }); + + // expect no update + await em.mutator(items).updateWhere( + { count: 2 }, + { + count: 10 + } + ); + expect((await em.repository(items).findMany()).data).toEqual([ + { id: 6, label: "update", count: 1 }, + { id: 7, label: "update too", count: 1 }, + { id: 8, label: "keep", count: 0 } + ]); + + // expect 2 to be updated + await em.mutator(items).updateWhere( + { count: 2 }, + { + count: 1 + } + ); + + expect((await em.repository(items).findMany()).data).toEqual([ + { id: 6, label: "update", count: 2 }, + { id: 7, label: "update too", count: 2 }, + { id: 8, label: "keep", count: 0 } + ]); + }); }); diff --git a/app/__test__/data/prototype.test.ts b/app/__test__/data/prototype.test.ts index 955546e..e5d3753 100644 --- a/app/__test__/data/prototype.test.ts +++ b/app/__test__/data/prototype.test.ts @@ -1,5 +1,4 @@ import { describe, expect, test } from "bun:test"; -import { MediaField } from "../../src"; import { BooleanField, DateField, @@ -30,6 +29,7 @@ import { relation, text } from "../../src/data/prototype"; +import { MediaField } from "../../src/media/MediaField"; describe("prototype", () => { test("...", () => { @@ -76,7 +76,9 @@ describe("prototype", () => { new DateField("created_at", { type: "datetime" }), + // @ts-ignore new MediaField("images", { entity: "posts" }), + // @ts-ignore new MediaField("cover", { entity: "posts", max_items: 1 }) ]); diff --git a/app/build.ts b/app/build.ts index faa81d0..60251f8 100644 --- a/app/build.ts +++ b/app/build.ts @@ -88,11 +88,12 @@ await tsup.build({ watch, entry: ["src/index.ts", "src/data/index.ts", "src/core/index.ts", "src/core/utils/index.ts"], outDir: "dist", - external: ["bun:test"], + external: ["bun:test", "@libsql/client"], metafile: true, platform: "browser", format: ["esm", "cjs"], splitting: false, + treeshake: true, loader: { ".svg": "dataurl" } @@ -107,11 +108,12 @@ await tsup.build({ watch, entry: ["src/ui/index.ts", "src/ui/client/index.ts", "src/ui/main.css"], outDir: "dist/ui", - external: ["bun:test"], + external: ["bun:test", "react", "react-dom", "use-sync-external-store"], metafile: true, platform: "browser", format: ["esm", "cjs"], splitting: true, + treeshake: true, loader: { ".svg": "dataurl" }, diff --git a/app/package.json b/app/package.json index e19a4e5..7c539a7 100644 --- a/app/package.json +++ b/app/package.json @@ -3,12 +3,12 @@ "type": "module", "sideEffects": false, "bin": "./dist/cli/index.js", - "version": "0.2.2", + "version": "0.3.0", "scripts": { "build:all": "bun run build && bun run build:cli", "dev": "vite", "test": "ALL_TESTS=1 bun test --bail", - "build": "bun run build.ts --minify --types", + "build": "NODE_ENV=production bun run build.ts --minify --types", "watch": "bun run build.ts --types --watch", "types": "bun tsc --noEmit", "clean:types": "find ./dist -name '*.d.ts' -delete && rm -f ./dist/tsconfig.tsbuildinfo", @@ -17,20 +17,35 @@ "watch:css": "bun tailwindcss --watch -i src/ui/main.css -o ./dist/styles.css", "updater": "bun x npm-check-updates -ui", "build:cli": "bun build src/cli/index.ts --target node --outdir dist/cli --minify", - "cli": "LOCAL=1 bun src/cli/index.ts" + "cli": "LOCAL=1 bun src/cli/index.ts", + "prepublishOnly": "bun run build:all" }, "license": "FSL-1.1-MIT", "dependencies": { + "@libsql/client": "^0.14.0", + "@tanstack/react-form": "0.19.2", + "@sinclair/typebox": "^0.32.34", + "kysely": "^0.27.4", + "liquidjs": "^10.15.0", + "lodash-es": "^4.17.21", + "hono": "^4.6.12", + "fast-xml-parser": "^4.4.0", "@cfworker/json-schema": "^2.0.1", + "dayjs": "^1.11.13", + "oauth4webapi": "^2.11.1", + "aws4fetch": "^1.0.18" + }, + "devDependencies": { + "@aws-sdk/client-s3": "^3.613.0", "@codemirror/lang-html": "^6.4.9", "@codemirror/lang-json": "^6.0.1", "@codemirror/lang-liquid": "^6.2.1", "@dagrejs/dagre": "^1.1.4", "@hello-pangea/dnd": "^17.0.0", "@hono/typebox-validator": "^0.2.6", + "@hono/vite-dev-server": "^0.17.0", "@hono/zod-validator": "^0.4.1", "@hookform/resolvers": "^3.9.1", - "@libsql/client": "^0.14.0", "@libsql/kysely-libsql": "^0.4.1", "@mantine/core": "^7.13.4", "@mantine/hooks": "^7.13.4", @@ -38,51 +53,37 @@ "@mantine/notifications": "^7.13.5", "@radix-ui/react-scroll-area": "^1.2.0", "@rjsf/core": "^5.22.2", - "@sinclair/typebox": "^0.32.34", "@tabler/icons-react": "3.18.0", - "@tanstack/react-form": "0.19.2", "@tanstack/react-query": "^5.59.16", - "@uiw/react-codemirror": "^4.23.6", - "@xyflow/react": "^12.3.2", - "aws4fetch": "^1.0.18", - "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", - "@types/diff": "^5.2.3", "@types/node": "^22.10.0", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", + "@uiw/react-codemirror": "^4.23.6", "@vitejs/plugin-react": "^4.3.3", + "@xyflow/react": "^12.3.2", "autoprefixer": "^10.4.20", "esbuild-postcss": "^0.0.4", - "node-fetch": "^3.3.2", + "jotai": "^2.10.1", + "open": "^10.1.0", "openapi-types": "^12.1.3", "postcss": "^8.4.47", "postcss-preset-mantine": "^1.17.0", "postcss-simple-vars": "^7.0.1", + "react-hook-form": "^7.53.1", + "react-icons": "5.2.1", + "react-json-view-lite": "^2.0.1", + "tailwind-merge": "^2.5.4", "tailwindcss": "^3.4.14", + "tailwindcss-animate": "^1.0.7", "tsup": "^8.3.5", "vite": "^5.4.10", "vite-plugin-static-copy": "^2.0.0", - "vite-tsconfig-paths": "^5.0.1" + "vite-tsconfig-paths": "^5.0.1", + "wouter": "^3.3.5" + }, + "optionalDependencies": { + "@hono/node-server": "^1.13.7" }, "peerDependencies": { "react": ">=18", @@ -165,12 +166,16 @@ "./dist/styles.css": "./dist/ui/main.css", "./dist/manifest.json": "./dist/static/manifest.json" }, + "publishConfig": { + "access": "public" + }, "files": [ "dist", "README.md", "!dist/*.tsbuildinfo", "!dist/*.map", "!dist/**/*.map", - "!dist/metafile*" + "!dist/metafile*", + "!dist/**/metafile*" ] } diff --git a/app/src/App.ts b/app/src/App.ts index a617a0a..d180a51 100644 --- a/app/src/App.ts +++ b/app/src/App.ts @@ -21,7 +21,7 @@ export class AppBuiltEvent extends Event<{ app: App }> { export const AppEvents = { AppConfigUpdatedEvent, AppBuiltEvent } as const; export type CreateAppConfig = { - connection: + connection?: | Connection | { type: "libsql"; @@ -29,7 +29,7 @@ export type CreateAppConfig = { }; initialConfig?: InitialModuleConfigs; plugins?: AppPlugin[]; - options?: ModuleManagerOptions; + options?: Omit; }; export type AppConfig = InitialModuleConfigs; @@ -56,27 +56,6 @@ export class App { this.modules.ctx().emgr.registerEvents(AppEvents); } - static create(config: CreateAppConfig) { - let connection: Connection | undefined = undefined; - - if (Connection.isConnection(config.connection)) { - connection = config.connection; - } else if (typeof config.connection === "object") { - switch (config.connection.type) { - case "libsql": - connection = new LibsqlConnection(config.connection.config); - break; - } - } else { - throw new Error(`Unknown connection of type ${typeof config.connection} given.`); - } - if (!connection) { - throw new Error("Invalid connection"); - } - - return new App(connection, config.initialConfig, config.plugins, config.options); - } - get emgr() { return this.modules.ctx().emgr; } @@ -147,4 +126,31 @@ export class App { toJSON(secrets?: boolean) { return this.modules.toJSON(secrets); } + + static create(config: CreateAppConfig) { + return createApp(config); + } +} + +export function createApp(config: CreateAppConfig = {}) { + let connection: Connection | undefined = undefined; + + try { + if (Connection.isConnection(config.connection)) { + connection = config.connection; + } else if (typeof config.connection === "object") { + connection = new LibsqlConnection(config.connection.config); + } else { + connection = new LibsqlConnection({ url: ":memory:" }); + console.warn("[!] No connection provided, using in-memory database"); + } + } catch (e) { + console.error("Could not create connection", e); + } + + if (!connection) { + throw new Error("Invalid connection"); + } + + return new App(connection, config.initialConfig, config.plugins, config.options); } diff --git a/app/src/adapter/astro/astro.adapter.ts b/app/src/adapter/astro/astro.adapter.ts index 71ad9f6..f86410a 100644 --- a/app/src/adapter/astro/astro.adapter.ts +++ b/app/src/adapter/astro/astro.adapter.ts @@ -1,5 +1,4 @@ -import { Api, type ApiOptions } from "bknd"; -import { App, type CreateAppConfig } from "bknd"; +import { Api, type ApiOptions, App, type CreateAppConfig } from "bknd"; type TAstro = { request: Request; diff --git a/app/src/adapter/bun/bun.adapter.ts b/app/src/adapter/bun/bun.adapter.ts index 9c62047..ae14be5 100644 --- a/app/src/adapter/bun/bun.adapter.ts +++ b/app/src/adapter/bun/bun.adapter.ts @@ -1,55 +1,59 @@ +/// + import path from "node:path"; import { App, type CreateAppConfig } from "bknd"; -import { LibsqlConnection } from "bknd/data"; +import type { Serve, ServeOptions } from "bun"; import { serveStatic } from "hono/bun"; -async function getConnection(conn?: CreateAppConfig["connection"]) { - if (conn) { - if (LibsqlConnection.isConnection(conn)) { - return conn; - } - - return new LibsqlConnection(conn.config); - } - - const createClient = await import("@libsql/client/node").then((m) => m.createClient); - if (!createClient) { - throw new Error('libsql client not found, you need to install "@libsql/client/node"'); - } - - console.log("Using in-memory database"); - return new LibsqlConnection(createClient({ url: ":memory:" })); -} - -export function serve(_config: Partial = {}, distPath?: string) { +let app: App; +export async function createApp(_config: Partial = {}, distPath?: string) { const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static"); - let app: App; - return async (req: Request) => { - if (!app) { - const connection = await getConnection(_config.connection); - app = App.create({ - ..._config, - connection - }); + if (!app) { + app = App.create(_config); - app.emgr.on( - "app-built", - async () => { - app.modules.server.get( - "/*", - serveStatic({ - root - }) - ); - app.registerAdminController(); - }, - "sync" - ); + app.emgr.on( + "app-built", + async () => { + app.modules.server.get( + "/*", + serveStatic({ + root + }) + ); + app.registerAdminController(); + }, + "sync" + ); - await app.build(); - } + await app.build(); + } - return app.fetch(req); - }; + return app; +} + +export type BunAdapterOptions = Omit & + 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}`); } diff --git a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts index 5224c10..0d2126a 100644 --- a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts +++ b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts @@ -189,7 +189,11 @@ export class DurableBkndApp extends DurableObject { const config = options.config; // change protocol to websocket if libsql - if ("type" in config.connection && config.connection.type === "libsql") { + if ( + config?.connection && + "type" in config.connection && + config.connection.type === "libsql" + ) { config.connection.config.protocol = "wss"; } diff --git a/app/src/adapter/nextjs/AdminPage.tsx b/app/src/adapter/nextjs/AdminPage.tsx deleted file mode 100644 index bd0a81e..0000000 --- a/app/src/adapter/nextjs/AdminPage.tsx +++ /dev/null @@ -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) => { - if (typeof document === "undefined") return null; - return ; - }; -} diff --git a/app/src/adapter/nextjs/index.ts b/app/src/adapter/nextjs/index.ts index ef03af0..957fa9e 100644 --- a/app/src/adapter/nextjs/index.ts +++ b/app/src/adapter/nextjs/index.ts @@ -1,2 +1 @@ export * from "./nextjs.adapter"; -export * from "./AdminPage"; diff --git a/app/src/adapter/node/index.ts b/app/src/adapter/node/index.ts index 5360ddb..47d4c97 100644 --- a/app/src/adapter/node/index.ts +++ b/app/src/adapter/node/index.ts @@ -2,51 +2,34 @@ import path from "node:path"; import { serve as honoServe } from "@hono/node-server"; import { serveStatic } from "@hono/node-server/serve-static"; import { App, type CreateAppConfig } from "bknd"; -import { LibsqlConnection } from "bknd/data"; -async function getConnection(conn?: CreateAppConfig["connection"]) { - if (conn) { - if (LibsqlConnection.isConnection(conn)) { - return conn; - } - - return new LibsqlConnection(conn.config); - } - - const createClient = await import("@libsql/client/node").then((m) => m.createClient); - if (!createClient) { - throw new Error('libsql client not found, you need to install "@libsql/client/node"'); - } - - console.log("Using in-memory database"); - return new LibsqlConnection(createClient({ url: ":memory:" })); -} - -export type NodeAdapterOptions = { +export type NodeAdapterOptions = CreateAppConfig & { relativeDistPath?: string; port?: number; hostname?: string; listener?: Parameters[1]; }; -export function serve(_config: Partial = {}, options: NodeAdapterOptions = {}) { +export function serve({ + relativeDistPath, + port = 1337, + hostname, + listener, + ...config +}: NodeAdapterOptions = {}) { const root = path.relative( process.cwd(), - path.resolve(options.relativeDistPath ?? "./node_modules/bknd/dist", "static") + path.resolve(relativeDistPath ?? "./node_modules/bknd/dist", "static") ); let app: App; honoServe( { - port: options.port ?? 1337, - hostname: options.hostname, + port, + hostname, fetch: async (req: Request) => { if (!app) { - const connection = await getConnection(_config.connection); - app = App.create({ - ..._config, - connection - }); + app = App.create(config); app.emgr.on( "app-built", @@ -68,6 +51,9 @@ export function serve(_config: Partial = {}, options: NodeAdapt return app.fetch(req); } }, - options.listener + (connInfo) => { + console.log(`Server is running on http://localhost:${connInfo.port}`); + listener?.(connInfo); + } ); } diff --git a/app/src/cli/commands/run/platform.ts b/app/src/cli/commands/run/platform.ts index 46a725b..309df7f 100644 --- a/app/src/cli/commands/run/platform.ts +++ b/app/src/cli/commands/run/platform.ts @@ -1,10 +1,8 @@ -import { readFile } from "node:fs/promises"; import path from "node:path"; -import type { ServeStaticOptions } from "@hono/node-server/serve-static"; -import { type Config, createClient } from "@libsql/client/node"; -import { Connection, LibsqlConnection, SqliteLocalConnection } from "data"; +import type { Config } from "@libsql/client/node"; import type { MiddlewareHandler } from "hono"; -import { fileExists, getDistPath, getRelativeDistPath } from "../../utils/sys"; +import open from "open"; +import { fileExists, getRelativeDistPath } from "../../utils/sys"; export const PLATFORMS = ["node", "bun"] as const; export type Platform = (typeof PLATFORMS)[number]; @@ -33,7 +31,8 @@ export async function attachServeStatic(app: any, platform: Platform) { export async function startServer(server: Platform, app: any, options: { port: number }) { const port = options.port; - console.log("running on", server, port); + console.log(`(using ${server} serve)`); + switch (server) { case "node": { // https://github.com/honojs/node-server/blob/main/src/response.ts#L88 @@ -53,27 +52,9 @@ export async function startServer(server: Platform, app: any, options: { port: n } } - console.log("Server listening on", "http://localhost:" + port); -} - -export async function getHtml() { - return await readFile(path.resolve(getDistPath(), "static/index.html"), "utf-8"); -} - -export function getConnection(connectionOrConfig?: Connection | Config): Connection { - if (connectionOrConfig) { - if (connectionOrConfig instanceof Connection) { - return connectionOrConfig; - } - - if ("url" in connectionOrConfig) { - return new LibsqlConnection(createClient(connectionOrConfig)); - } - } - - console.log("Using in-memory database"); - return new LibsqlConnection(createClient({ url: ":memory:" })); - //return new SqliteLocalConnection(new Database(":memory:")); + const url = `http://localhost:${port}`; + console.log(`Server listening on ${url}`); + await open(url); } export async function getConfigPath(filePath?: string) { diff --git a/app/src/cli/commands/run/run.ts b/app/src/cli/commands/run/run.ts index 0663f2f..7ad9568 100644 --- a/app/src/cli/commands/run/run.ts +++ b/app/src/cli/commands/run/run.ts @@ -1,16 +1,13 @@ import type { Config } from "@libsql/client/node"; -import { App } from "App"; +import { App, type CreateAppConfig } from "App"; import type { BkndConfig } from "adapter"; import type { CliCommand } from "cli/types"; import { Option } from "commander"; -import type { Connection } from "data"; import { PLATFORMS, type Platform, attachServeStatic, getConfigPath, - getConnection, - getHtml, startServer } from "./platform"; @@ -41,14 +38,14 @@ export const run: CliCommand = (program) => { }; type MakeAppConfig = { - connection: Connection; + connection?: CreateAppConfig["connection"]; server?: { platform?: Platform }; setAdminHtml?: boolean; onBuilt?: (app: App) => Promise; }; async function makeApp(config: MakeAppConfig) { - const app = new App(config.connection); + const app = App.create({ connection: config.connection }); app.emgr.on( "app-built", @@ -99,9 +96,9 @@ async function action(options: { let app: App; if (options.dbUrl || !configFilePath) { - const connection = getConnection( - options.dbUrl ? { url: options.dbUrl, authToken: options.dbToken } : undefined - ); + const connection = options.dbUrl + ? { type: "libsql" as const, config: { url: options.dbUrl, authToken: options.dbToken } } + : undefined; app = await makeApp({ connection, server: { platform: options.server } }); } else { console.log("Using config from:", configFilePath); diff --git a/app/src/core/errors.ts b/app/src/core/errors.ts index ce63ed9..860bd9d 100644 --- a/app/src/core/errors.ts +++ b/app/src/core/errors.ts @@ -27,6 +27,10 @@ export class BkndError extends Error { super(message); } + static with(message: string, details?: Record, type?: string) { + throw new BkndError(message, details, type); + } + toJSON() { return { type: this.type ?? "unknown", diff --git a/app/src/core/index.ts b/app/src/core/index.ts index 5229bd9..e296c1d 100644 --- a/app/src/core/index.ts +++ b/app/src/core/index.ts @@ -1,5 +1,5 @@ -export { Endpoint, type RequestResponse, type Middleware } from "./server/Endpoint"; -export { zValidator } from "./server/lib/zValidator"; +import type { Hono, MiddlewareHandler } from "hono"; + export { tbValidator } from "./server/lib/tbValidator"; export { Exception, BkndError } from "./errors"; export { isDebug } from "./env"; @@ -11,7 +11,6 @@ export { type TemplateTypes, type SimpleRendererOptions } from "./template/SimpleRenderer"; -export { Controller, type ClassController } from "./server/Controller"; export { SchemaObject } from "./object/SchemaObject"; export { DebugLogger } from "./utils/DebugLogger"; export { Permission } from "./security/Permission"; @@ -26,3 +25,10 @@ export { isBooleanLike } from "./object/query/query"; export { Registry, type Constructor } from "./registry/Registry"; + +// compatibility +export type Middleware = MiddlewareHandler; +export interface ClassController { + getController: () => Hono; + getMiddleware?: MiddlewareHandler; +} diff --git a/app/src/core/object/diff.ts b/app/src/core/object/diff.ts new file mode 100644 index 0000000..9a182bd --- /dev/null +++ b/app/src/core/object/diff.ts @@ -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(obj: In): In { + return JSON.parse(JSON.stringify(obj)); +} + +export { diff, apply, revert, clone }; diff --git a/app/src/core/server/Controller.ts b/app/src/core/server/Controller.ts deleted file mode 100644 index 9c74571..0000000 --- a/app/src/core/server/Controller.ts +++ /dev/null @@ -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 = { - [K in keyof Endpoints]: Endpoints[K]; -}; - -export interface ClassController { - getController: () => Hono; - getMiddleware?: MiddlewareHandler; -} - -/** - * @deprecated - */ -export class Controller< - Endpoints extends Record = Record, - Middlewares extends Record = Record -> { - protected endpoints: Endpoints = {} as Endpoints; - protected middlewares: Middlewares = {} as Middlewares; - - public prefix: string = "/"; - public routes: RouteProxy; - - 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; - } - - add( - this: Controller, - name: Name, - endpoint: E - ): Controller> { - const newEndpoints = { - ...this.endpoints, - [name]: endpoint - } as Endpoints & Record; - const newController: Controller> = new Controller< - Endpoints & Record - >(); - newController.endpoints = newEndpoints; - newController.middlewares = this.middlewares; - return newController; - } - - get(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 = ["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; - } -} diff --git a/app/src/core/server/Endpoint.ts b/app/src/core/server/Endpoint.ts deleted file mode 100644 index c45b644..0000000 --- a/app/src/core/server/Endpoint.ts +++ /dev/null @@ -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 = { - [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 = { - 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; - -type HandlerFunction

= (c: Context, next: Next) => R; -export type RequestResponse = { - status: number; - ok: boolean; - response: Awaited; -}; - -/** - * @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, - readonly validation: Validation = {} - ) {} - - // @todo: typing is not ideal - async $request( - args?: ValidationInput, - baseUrl: string = "http://localhost:28623" - ): Promise>> { - 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, validation?: Validation) { - return new Endpoint("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, validation?: Validation) { - return new Endpoint("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, validation?: Validation) { - return new Endpoint("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, validation?: Validation) { - return new Endpoint("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, validation?: Validation) { - return new Endpoint("DELETE", path, handler, validation); - } -} diff --git a/app/src/core/server/lib/zValidator.ts b/app/src/core/server/lib/zValidator.ts deleted file mode 100644 index 4d16a59..0000000 --- a/app/src/core/server/lib/zValidator.ts +++ /dev/null @@ -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 = ( - result: { success: true; data: T } | { success: false; error: ZodError; data: T }, - c: Context, -) => Response | void | TypedResponse | Promise>; - -type HasUndefined = undefined extends T ? true : false; - -export const zValidator = < - T extends ZodSchema, - Target extends keyof ValidationTargets, - E extends Env, - P extends string, - In = z.input, - Out = z.output, - I extends Input = { - in: HasUndefined extends true - ? { - [K in Target]?: K extends "json" - ? In - : HasUndefined extends true - ? { [K2 in keyof In]?: ValidationTargets[K][K2] } - : { [K2 in keyof In]: ValidationTargets[K][K2] }; - } - : { - [K in Target]: K extends "json" - ? In - : HasUndefined 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) => V, // <-- added - hook?: Hook, E, P>, -): MiddlewareHandler => - // @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; - }); diff --git a/app/src/core/utils/typebox/index.ts b/app/src/core/utils/typebox/index.ts index 2b923c1..2e08d7a 100644 --- a/app/src/core/utils/typebox/index.ts +++ b/app/src/core/utils/typebox/index.ts @@ -72,10 +72,10 @@ export class TypeInvalidError extends Error { } } -export function stripMark(obj: any) { +export function stripMark(obj: O) { const newObj = cloneDeep(obj); mark(newObj, false); - return newObj; + return newObj as O; } export function mark(obj: any, validated = true) { diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index 4036b44..3f459dc 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -369,9 +369,9 @@ export class DataController implements ClassController { return c.notFound(); } const where = c.req.valid("json") as RepoQuery["where"]; - console.log("where", where); + //console.log("where", where); - const result = await this.em.mutator(entity).deleteMany(where); + const result = await this.em.mutator(entity).deleteWhere(where); return c.json(this.mutatorResult(result)); } diff --git a/app/src/data/connection/Connection.ts b/app/src/data/connection/Connection.ts index bc97ff0..e4ce455 100644 --- a/app/src/data/connection/Connection.ts +++ b/app/src/data/connection/Connection.ts @@ -41,16 +41,18 @@ export type DbFunctions = { >; }; -export abstract class Connection { - cls = "bknd:connection"; - kysely: Kysely; +const CONN_SYMBOL = Symbol.for("bknd:connection"); + +export abstract class Connection { + kysely: Kysely; constructor( - kysely: Kysely, + kysely: Kysely, public fn: Partial = {}, protected plugins: KyselyPlugin[] = [] ) { this.kysely = kysely; + this[CONN_SYMBOL] = true; } /** @@ -58,8 +60,9 @@ export abstract class Connection { * coming from different places * @param conn */ - static isConnection(conn: any): conn is Connection { - return conn?.cls === "bknd:connection"; + static isConnection(conn: unknown): conn is Connection { + if (!conn) return false; + return conn[CONN_SYMBOL] === true; } getIntrospector(): ConnectionIntrospector { diff --git a/app/src/data/connection/LibsqlConnection.ts b/app/src/data/connection/LibsqlConnection.ts index 9f6ebcb..e60fa32 100644 --- a/app/src/data/connection/LibsqlConnection.ts +++ b/app/src/data/connection/LibsqlConnection.ts @@ -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 { type DatabaseIntrospector, Kysely, ParseJSONResultsPlugin, sql } from "kysely"; import { FilterNumericKeysPlugin } from "../plugins/FilterNumericKeysPlugin"; diff --git a/app/src/data/entities/Mutator.ts b/app/src/data/entities/Mutator.ts index aef2bf1..ed7f9ef 100644 --- a/app/src/data/entities/Mutator.ts +++ b/app/src/data/entities/Mutator.ts @@ -250,7 +250,7 @@ export class Mutator implements EmitsEvents { } // @todo: decide whether entries should be deleted all at once or one by one (for events) - async deleteMany(where?: RepoQuery["where"]): Promise> { + async deleteWhere(where?: RepoQuery["where"]): Promise> { const entity = this.entity; const qb = this.appendWhere(this.conn.deleteFrom(entity.name), where).returning( @@ -267,4 +267,30 @@ export class Mutator implements EmitsEvents { return res; } + + async updateWhere( + data: EntityData, + where?: RepoQuery["where"] + ): Promise> { + 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; + } } diff --git a/app/src/data/entities/query/Repository.ts b/app/src/data/entities/query/Repository.ts index f296adf..8156869 100644 --- a/app/src/data/entities/query/Repository.ts +++ b/app/src/data/entities/query/Repository.ts @@ -162,8 +162,7 @@ export class Repository implements EmitsEve protected async performQuery(qb: RepositoryQB): Promise { const entity = this.entity; const compiled = qb.compile(); - /*const { sql, parameters } = qb.compile(); - console.log("many", sql, parameters);*/ + //console.log("performQuery", compiled.sql, compiled.parameters); const start = performance.now(); const selector = (as = "count") => this.conn.fn.countAll().as(as); @@ -263,6 +262,7 @@ export class Repository implements EmitsEve qb = qb.orderBy(aliased(options.sort.by), options.sort.dir); } + //console.log("options", { _options, options, exclude_options }); return { qb, options }; } @@ -286,14 +286,11 @@ export class Repository implements EmitsEve where: RepoQuery["where"], _options?: Partial> ): Promise> { - const { qb, options } = this.buildQuery( - { - ..._options, - where, - limit: 1 - }, - ["offset", "sort"] - ); + const { qb, options } = this.buildQuery({ + ..._options, + where, + limit: 1 + }); return this.single(qb, options) as any; } diff --git a/app/src/data/entities/query/WhereBuilder.ts b/app/src/data/entities/query/WhereBuilder.ts index 455ecf4..5168d0e 100644 --- a/app/src/data/entities/query/WhereBuilder.ts +++ b/app/src/data/entities/query/WhereBuilder.ts @@ -15,7 +15,6 @@ import type { SelectQueryBuilder, UpdateQueryBuilder } from "kysely"; -import type { RepositoryQB } from "./Repository"; type Builder = ExpressionBuilder; type Wrapper = ExpressionWrapper; diff --git a/app/src/data/fields/Field.ts b/app/src/data/fields/Field.ts index b5e332d..5260d61 100644 --- a/app/src/data/fields/Field.ts +++ b/app/src/data/fields/Field.ts @@ -236,8 +236,8 @@ export abstract class Field< toJSON() { return { - //name: this.name, - type: this.type, + // @todo: current workaround because of fixed string type + type: this.type as any, config: this.config }; } diff --git a/app/src/data/fields/JsonSchemaField.ts b/app/src/data/fields/JsonSchemaField.ts index 5f4e2c4..b414866 100644 --- a/app/src/data/fields/JsonSchemaField.ts +++ b/app/src/data/fields/JsonSchemaField.ts @@ -54,7 +54,7 @@ export class JsonSchemaField< if (parentValid) { // already checked in parent - if (!value || typeof value !== "object") { + if (!this.isRequired() && (!value || typeof value !== "object")) { //console.log("jsonschema:valid: not checking", this.name, value, context); return true; } @@ -65,6 +65,7 @@ export class JsonSchemaField< } else { //console.log("jsonschema:invalid", this.name, value, context); } + //console.log("jsonschema:invalid:fromParent", this.name, value, context); return false; } @@ -110,9 +111,13 @@ export class JsonSchemaField< ): Promise { const value = await super.transformPersist(_value, em, context); if (this.nullish(value)) return value; + //console.log("jsonschema:transformPersist", this.name, _value, context); if (!this.isValid(value)) { + //console.error("jsonschema:transformPersist:invalid", this.name, value); throw new TransformPersistFailedException(this.name, value); + } else { + //console.log("jsonschema:transformPersist:valid", this.name, value); } if (!value || typeof value !== "object") return this.getDefault(); diff --git a/app/src/data/index.ts b/app/src/data/index.ts index f4a67bc..284c653 100644 --- a/app/src/data/index.ts +++ b/app/src/data/index.ts @@ -13,8 +13,6 @@ export { whereSchema } from "./server/data-query-impl"; -export { whereRepoSchema as deprecated__whereRepoSchema } from "./server/query"; - export { Connection } from "./connection/Connection"; export { LibsqlConnection, type LibSqlCredentials } from "./connection/LibsqlConnection"; export { SqliteConnection } from "./connection/SqliteConnection"; diff --git a/app/src/data/server/query.ts b/app/src/data/server/query.ts deleted file mode 100644 index ddc03db..0000000 --- a/app/src/data/server/query.ts +++ /dev/null @@ -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; -export type LiteralSchema = z.output; - -export const filterSchema = literalSchema.or(operandCond); -export type FilterSchemaIn = z.input; -export type FilterSchema = z.output; - -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; -type RepoQuery = z.output; diff --git a/app/src/index.ts b/app/src/index.ts index 649bf71..578ab5d 100644 --- a/app/src/index.ts +++ b/app/src/index.ts @@ -1,6 +1,5 @@ -export { App, type AppConfig, type CreateAppConfig } from "./App"; +export { App, createApp, AppEvents, type AppConfig, type CreateAppConfig } from "./App"; -export { MediaField } from "media/MediaField"; export { getDefaultConfig, getDefaultSchema, diff --git a/app/src/media/api/MediaController.ts b/app/src/media/api/MediaController.ts index 59665fa..9597759 100644 --- a/app/src/media/api/MediaController.ts +++ b/app/src/media/api/MediaController.ts @@ -181,7 +181,7 @@ export class MediaController implements ClassController { if (ids_to_delete.length > 0) { await this.media.em .mutator(mediaEntity) - .deleteMany({ [id_field]: { $in: ids_to_delete } }); + .deleteWhere({ [id_field]: { $in: ids_to_delete } }); } return c.json({ ok: true, result: result.data, ...info }); diff --git a/app/src/modules/ModuleManager.ts b/app/src/modules/ModuleManager.ts index 51a1768..e43dfa7 100644 --- a/app/src/modules/ModuleManager.ts +++ b/app/src/modules/ModuleManager.ts @@ -1,12 +1,31 @@ -import { Diff } from "@sinclair/typebox/value"; import { Guard } from "auth"; -import { DebugLogger, isDebug } from "core"; +import { BkndError, DebugLogger, Exception, isDebug } from "core"; import { EventManager } from "core/events"; -import { Default, type Static, objectEach, transformObject } from "core/utils"; -import { type Connection, EntityManager } from "data"; +import { clone, diff } from "core/object/diff"; +import { + Default, + type Static, + StringEnum, + Type, + 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 { 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 { AppAuth } from "../auth/AppAuth"; import { AppData } from "../data/AppData"; @@ -37,10 +56,13 @@ export type ModuleSchemas = { export type ModuleConfigs = { [K in keyof ModuleSchemas]: Static; }; +type PartialRec = { [P in keyof T]?: PartialRec }; -export type InitialModuleConfigs = { - version: number; -} & Partial; +export type InitialModuleConfigs = + | ({ + version: number; + } & ModuleConfigs) + | PartialRec; export type ModuleManagerOptions = { initial?: InitialModuleConfigs; @@ -61,8 +83,36 @@ type ConfigTable = { 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; +type T_INTERNAL_EM = { + __bknd: ConfigTable2; +}; + +// @todo: cleanup old diffs on upgrade +// @todo: cleanup multiple backups on upgrade export class ModuleManager { private modules: Modules; + // internal em for __bknd config table + __em!: EntityManager; + // ctx for modules em!: EntityManager; server!: Hono; emgr!: EventManager; @@ -71,28 +121,32 @@ export class ModuleManager { private _version: number = 0; private _built = false; private _fetched = false; - private readonly _provided; - private logger = new DebugLogger(isDebug() && false); + // @todo: keep? not doing anything with it + private readonly _booted_with?: "provided" | "partial"; + + private logger = new DebugLogger(false); constructor( private readonly connection: Connection, private options?: Partial ) { + this.__em = new EntityManager([__bknd], this.connection); this.modules = {} as Modules; this.emgr = new EventManager(); const context = this.ctx(true); let initial = {} as Partial; if (options?.initial) { - const { version, ...initialConfig } = options.initial; - if (version && initialConfig) { + if ("version" in options.initial) { + const { version, ...initialConfig } = options.initial; this._version = version; - initial = initialConfig; + initial = stripMark(initialConfig); - this._provided = true; + this._booted_with = "provided"; } else { - throw new Error("Initial was provided, but it needs a version!"); + initial = mergeWith(getDefaultConfig(), options.initial); + this._booted_with = "partial"; } } @@ -120,6 +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() { this.server = new Hono(); 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 { this.logger.context("fetch").log("fetching"); const startTime = performance.now(); - const result = await this.db - .selectFrom(this.table) - .selectAll() - .where("type", "=", "config") - .orderBy("version", "desc") - .executeTakeFirstOrThrow(); + const { data: result } = await this.repo().findOne( + { type: "config" }, + { + sort: { by: "version", dir: "desc" } + } + ); + if (!result) { + throw BkndError.with("no config"); + } this.logger.log("took", performance.now() - startTime, "ms", result).clear(); - return result; + return result as ConfigTable; } async save() { @@ -181,65 +246,75 @@ export class ModuleManager { const configs = this.configs(); const version = this.version(); - const json = JSON.stringify(configs) as any; - const state = await this.fetch(); + try { + const state = await this.fetch(); + this.logger.log("fetched version", state.version); - if (state.version !== version) { - // @todo: mark all others as "backup" - this.logger.log("version conflict, storing new version", state.version, version); - await this.db - .insertInto(this.table) - .values({ - version, + if (state.version !== version) { + // @todo: mark all others as "backup" + this.logger.log("version conflict, storing new version", state.version, version); + await this.mutator().insertOne({ + version: state.version, + type: "backup", + json: configs + }); + await this.mutator().insertOne({ + version: version, type: "config", - json - }) - .execute(); - } else { - this.logger.log("version matches"); + json: configs + }); + } else { + this.logger.log("version matches"); - const diff = Diff(state.json, JSON.parse(json)); - this.logger.log("checking diff", diff); + // clean configs because of Diff() function + const diffs = diff(state.json, clone(configs)); + this.logger.log("checking diff", diffs); - if (diff.length > 0) { - // store diff - await this.db - .insertInto(this.table) - .values({ + if (diff.length > 0) { + // store diff + await this.mutator().insertOne({ version, type: "diff", - json: JSON.stringify(diff) as any - }) - .execute(); + json: clone(diffs) + }); - await this.db - .updateTable(this.table) - .set({ version, json, updated_at: sql`CURRENT_TIMESTAMP` }) - .where((eb) => eb.and([eb("type", "=", "config"), eb("version", "=", version)])) - .execute(); + // store new version + await this.mutator().updateWhere( + { + version, + json: configs, + updated_at: new Date() + }, + { + type: "config", + version + } + ); + } else { + this.logger.log("no diff, not saving"); + } + } + } catch (e) { + if (e instanceof BkndError) { + this.logger.log("no config, just save fresh"); + // no config, just save + await this.mutator().insertOne({ + type: "config", + version, + json: configs, + created_at: new Date(), + updated_at: new Date() + }); + } else if (e instanceof TransformPersistFailedException) { + console.error("Cannot save invalid config"); + throw e; } else { - this.logger.log("no diff, not saving"); + console.error("Aborting"); + throw e; } } - // cleanup - /*this.logger.log("cleaning up"); - const result = await this.db - .deleteFrom(this.table) - .where((eb) => - eb.or([ - // empty migrations - eb.and([ - eb("type", "=", "config"), - eb("version", "<", version), - eb("json", "is", null) - ]), - // past diffs - eb.and([eb("type", "=", "diff"), eb("version", "<", version)]) - ]) - ) - .executeTakeFirst(); - this.logger.log("cleaned up", result.numDeletedRows);*/ + // @todo: cleanup old versions? this.logger.clear(); return this; @@ -250,6 +325,8 @@ export class ModuleManager { if (this.version() < CURRENT_VERSION) { this.logger.log("there are migrations, verify version"); + // sync __bknd table + await this.syncConfigTable(); // modules must be built before migration await this.buildModules({ graceful: true }); @@ -264,14 +341,7 @@ export class ModuleManager { } } catch (e: any) { this.logger.clear(); // fetch couldn't clear - - // if table doesn't exist, migrate schema to version - if (e.message.includes("no such table")) { - this.logger.log("table has to created, migrating schema up to", this.version()); - await migrateSchema(this.version(), { db: this.db }); - } else { - throw new Error(`Version is ${this.version()}, fetch failed: ${e.message}`); - } + throw new Error(`Version is ${this.version()}, fetch failed: ${e.message}`); } this.logger.log("now migrating"); @@ -337,10 +407,16 @@ export class ModuleManager { async build() { 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 (this.version() === 0) { - this.logger.context("build no config").log("version is 0"); + this.logger.context("no version").log("version is 0"); try { const result = await this.fetch(); @@ -351,23 +427,15 @@ export class ModuleManager { this.logger.clear(); // fetch couldn't clear this.logger.context("error handler").log("fetch failed", e.message); - // if table doesn't exist, migrate schema, set default config and latest version - if (e.message.includes("no such table")) { - this.logger.log("migrate schema to", CURRENT_VERSION); - await migrateSchema(CURRENT_VERSION, { db: this.db }); - this._version = CURRENT_VERSION; - // we can safely build modules, since config version is up to date - // it's up to date because we use default configs (no fetch result) - await this.buildModules(); - await this.save(); + // we can safely build modules, since config version is up to date + // it's up to date because we use default configs (no fetch result) + this._version = CURRENT_VERSION; + await this.buildModules(); + await this.save(); - this.logger.clear(); - return this; - } else { - throw e; - //throw new Error("Issues connecting to the database. Reason: " + e.message); - } + this.logger.clear(); + return this; } this.logger.clear(); } diff --git a/app/src/modules/migrations.ts b/app/src/modules/migrations.ts index 9cf0beb..b85f01a 100644 --- a/app/src/modules/migrations.ts +++ b/app/src/modules/migrations.ts @@ -16,26 +16,8 @@ export type Migration = { export const migrations: Migration[] = [ { version: 1, - schema: true, - up: async (config, { db }) => { - //console.log("config given", config); - await db.schema - .createTable(TABLE_NAME) - .addColumn("id", "integer", (col) => col.primaryKey().notNull().autoIncrement()) - .addColumn("version", "integer", (col) => col.notNull()) - .addColumn("type", "text", (col) => col.notNull()) - .addColumn("json", "text") - .addColumn("created_at", "datetime", (col) => col.defaultTo(sql`CURRENT_TIMESTAMP`)) - .addColumn("updated_at", "datetime", (col) => col.defaultTo(sql`CURRENT_TIMESTAMP`)) - .execute(); - - await db - .insertInto(TABLE_NAME) - .values({ version: 1, type: "config", json: null }) - .execute(); - - return config; - } + //schema: true, + up: async (config) => config }, { version: 2, @@ -45,12 +27,8 @@ export const migrations: Migration[] = [ }, { version: 3, - schema: true, - up: async (config, { db }) => { - await db.schema.alterTable(TABLE_NAME).addColumn("deleted_at", "datetime").execute(); - - return config; - } + //schema: true, + up: async (config) => config }, { version: 4, @@ -94,6 +72,13 @@ export const migrations: Migration[] = [ }; } } + /*{ + version: 8, + up: async (config, { db }) => { + await db.deleteFrom(TABLE_NAME).where("type", "=", "diff").execute(); + return config; + } + }*/ ]; export const CURRENT_VERSION = migrations[migrations.length - 1]?.version ?? 0; @@ -127,28 +112,6 @@ export async function migrateTo( return [version, updated]; } -export async function migrateSchema(to: number, ctx: MigrationContext, current: number = 0) { - console.log("migrating SCHEMA to", to, "from", current); - const todo = migrations.filter((m) => m.version > current && m.version <= to && m.schema); - console.log("todo", todo.length); - - let i = 0; - let version = 0; - for (const migration of todo) { - console.log("-- running migration", i + 1, "of", todo.length); - try { - await migration.up({}, ctx); - version = migration.version; - i++; - } catch (e: any) { - console.error(e); - throw new Error(`Migration ${migration.version} failed: ${e.message}`); - } - } - - return version; -} - export async function migrate( current: number, config: GenericConfigObject, diff --git a/app/src/modules/server/AdminController.tsx b/app/src/modules/server/AdminController.tsx index 522c55a..f573132 100644 --- a/app/src/modules/server/AdminController.tsx +++ b/app/src/modules/server/AdminController.tsx @@ -10,9 +10,11 @@ import * as SystemPermissions from "modules/permissions"; const htmlBkndContextReplace = ""; +// @todo: add migration to remove admin path from config export type AdminControllerOptions = { + basepath?: string; html?: string; - forceDev?: boolean; + forceDev?: boolean | { mainPath: string }; }; export class AdminController implements ClassController { @@ -25,8 +27,12 @@ export class AdminController implements ClassController { return this.app.modules.ctx(); } + get basepath() { + return this.options.basepath ?? "/"; + } + private withBasePath(route: string = "") { - return (this.app.modules.configs().server.admin.basepath + route).replace(/\/+$/, "/"); + return (this.basepath + route).replace(/\/+$/, "/"); } getController(): Hono { @@ -102,7 +108,10 @@ export class AdminController implements ClassController { if (this.options.html) { if (this.options.html.includes(htmlBkndContextReplace)) { - return this.options.html.replace(htmlBkndContextReplace, bknd_context); + return this.options.html.replace( + htmlBkndContextReplace, + "" + ); } console.warn( @@ -113,6 +122,10 @@ export class AdminController implements ClassController { const configs = this.app.modules.configs(); const isProd = !isDebug() && !this.options.forceDev; + const mainPath = + typeof this.options.forceDev === "object" && "mainPath" in this.options.forceDev + ? this.options.forceDev.mainPath + : "/src/ui/main.tsx"; const assets = { js: "main.js", @@ -166,13 +179,14 @@ export class AdminController implements ClassController { )} +