diff --git a/app/__test__/api/MediaApi.spec.ts b/app/__test__/api/MediaApi.spec.ts index 785f1ab..a479d19 100644 --- a/app/__test__/api/MediaApi.spec.ts +++ b/app/__test__/api/MediaApi.spec.ts @@ -32,7 +32,7 @@ describe("MediaApi", () => { host, basepath, }); - expect(api.getFileUploadUrl({ path: "path" })).toBe(`${host}${basepath}/upload/path`); + expect(api.getFileUploadUrl({ path: "path" } as any)).toBe(`${host}${basepath}/upload/path`); }); it("should have correct upload headers", () => { diff --git a/app/__test__/auth/strategies/OAuthStrategy.spec.ts b/app/__test__/auth/strategies/OAuthStrategy.spec.ts index eb1a397..93ceae0 100644 --- a/app/__test__/auth/strategies/OAuthStrategy.spec.ts +++ b/app/__test__/auth/strategies/OAuthStrategy.spec.ts @@ -7,8 +7,8 @@ describe("OAuthStrategy", async () => { const strategy = new OAuthStrategy({ type: "oidc", client: { - client_id: process.env.OAUTH_CLIENT_ID, - client_secret: process.env.OAUTH_CLIENT_SECRET, + client_id: process.env.OAUTH_CLIENT_ID!, + client_secret: process.env.OAUTH_CLIENT_SECRET!, }, name: "google", }); @@ -19,11 +19,6 @@ describe("OAuthStrategy", async () => { const config = await strategy.getConfig(); console.log("config", JSON.stringify(config, null, 2)); - const request = await strategy.request({ - redirect_uri, - state, - }); - const server = Bun.serve({ fetch: async (req) => { const url = new URL(req.url); @@ -39,6 +34,11 @@ describe("OAuthStrategy", async () => { return new Response("Bun!"); }, }); + + const request = await strategy.request({ + redirect_uri, + state, + }); console.log("request", request); await new Promise((resolve) => setTimeout(resolve, 100000)); diff --git a/app/__test__/helper.ts b/app/__test__/helper.ts index ae6454f..405e46f 100644 --- a/app/__test__/helper.ts +++ b/app/__test__/helper.ts @@ -4,6 +4,9 @@ import Database from "libsql"; import { format as sqlFormat } from "sql-formatter"; import { type Connection, EntityManager, SqliteLocalConnection } from "../src/data"; import type { em as protoEm } from "../src/data/prototype"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { slugify } from "core/utils/strings"; export function getDummyDatabase(memory: boolean = true): { dummyDb: SqliteDatabase; @@ -71,3 +74,46 @@ export function schemaToEm(s: ReturnType, conn?: Connection): En export const assetsPath = `${import.meta.dir}/_assets`; export const assetsTmpPath = `${import.meta.dir}/_assets/tmp`; + +export async function enableFetchLogging() { + const originalFetch = global.fetch; + + global.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const response = await originalFetch(input, init); + const url = input instanceof URL || typeof input === "string" ? input : input.url; + + // Only clone if it's a supported content type + const contentType = response.headers.get("content-type") || ""; + const isSupported = + contentType.includes("json") || + contentType.includes("text") || + contentType.includes("xml"); + + if (isSupported) { + const clonedResponse = response.clone(); + let extension = "txt"; + let body: string; + + if (contentType.includes("json")) { + body = JSON.stringify(await clonedResponse.json(), null, 2); + extension = "json"; + } else if (contentType.includes("xml")) { + body = await clonedResponse.text(); + extension = "xml"; + } else { + body = await clonedResponse.text(); + } + + const fileName = `${new Date().getTime()}_${init?.method ?? "GET"}_${slugify(String(url))}.${extension}`; + const filePath = join(assetsTmpPath, fileName); + + await writeFile(filePath, body); + } + + return response; + }; + + return () => { + global.fetch = originalFetch; + }; +} diff --git a/app/__test__/media/MediaController.spec.ts b/app/__test__/media/MediaController.spec.ts index 71b9cbb..3584317 100644 --- a/app/__test__/media/MediaController.spec.ts +++ b/app/__test__/media/MediaController.spec.ts @@ -39,8 +39,8 @@ function makeName(ext: string) { return randomString(10) + "." + ext; } -/*beforeAll(disableConsoleLog); -afterAll(enableConsoleLog);*/ +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); describe("MediaController", () => { test.only("accepts direct", async () => { @@ -56,9 +56,9 @@ describe("MediaController", () => { console.log(result); expect(result.name).toBe(name); - /*const destFile = Bun.file(assetsTmpPath + "/" + name); + const destFile = Bun.file(assetsTmpPath + "/" + name); expect(destFile.exists()).resolves.toBe(true); - await destFile.delete();*/ + await destFile.delete(); }); test("accepts form data", async () => { diff --git a/app/__test__/media/adapters/StorageS3Adapter.spec.ts b/app/__test__/media/adapters/StorageS3Adapter.spec.ts index ad777e7..7b4a0a4 100644 --- a/app/__test__/media/adapters/StorageS3Adapter.spec.ts +++ b/app/__test__/media/adapters/StorageS3Adapter.spec.ts @@ -1,8 +1,9 @@ -import { describe, expect, test } from "bun:test"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { randomString } from "../../../src/core/utils"; import { StorageS3Adapter } from "../../../src/media"; import { config } from "dotenv"; +//import { enableFetchLogging } from "../../helper"; const dotenvOutput = config({ path: `${import.meta.dir}/../../../.env` }); const { R2_ACCESS_KEY, R2_SECRET_ACCESS_KEY, R2_URL, AWS_ACCESS_KEY, AWS_SECRET_KEY, AWS_S3_URL } = dotenvOutput.parsed!; @@ -11,7 +12,17 @@ const { R2_ACCESS_KEY, R2_SECRET_ACCESS_KEY, R2_URL, AWS_ACCESS_KEY, AWS_SECRET_ const ALL_TESTS = !!process.env.ALL_TESTS; console.log("ALL_TESTS?", ALL_TESTS); -describe.skipIf(true)("StorageS3Adapter", async () => { +/* +// @todo: preparation to mock s3 calls + replace fast-xml-parser +let cleanup: () => void; +beforeAll(async () => { + cleanup = await enableFetchLogging(); +}); +afterAll(() => { + cleanup(); +}); */ + +describe.skipIf(ALL_TESTS)("StorageS3Adapter", async () => { if (ALL_TESTS) return; const versions = [ @@ -66,7 +77,7 @@ describe.skipIf(true)("StorageS3Adapter", async () => { test.skipIf(disabled("putObject"))("puts an object", async () => { objects = (await adapter.listObjects()).length; - expect(await adapter.putObject(filename, file)).toBeString(); + expect(await adapter.putObject(filename, file as any)).toBeString(); }); test.skipIf(disabled("listObjects"))("lists objects", async () => { diff --git a/app/__test__/modules/AppAuth.spec.ts b/app/__test__/modules/AppAuth.spec.ts index 332b999..9091fbb 100644 --- a/app/__test__/modules/AppAuth.spec.ts +++ b/app/__test__/modules/AppAuth.spec.ts @@ -4,6 +4,7 @@ import { AuthController } from "../../src/auth/api/AuthController"; import { em, entity, make, text } from "../../src/data"; import { AppAuth, type ModuleBuildContext } from "../../src/modules"; import { disableConsoleLog, enableConsoleLog } from "../helper"; +// @ts-ignore import { makeCtx, moduleTestSuite } from "./module-test-suite"; describe("AppAuth", () => { @@ -22,7 +23,7 @@ describe("AppAuth", () => { const config = auth.toJSON(); expect(config.jwt).toBeUndefined(); - expect(config.strategies.password.config).toBeUndefined(); + expect(config.strategies?.password?.config).toBeUndefined(); }); test("enabling auth: generate secret", async () => { @@ -42,6 +43,7 @@ describe("AppAuth", () => { const auth = new AppAuth( { enabled: true, + // @ts-ignore jwt: { secret: "123456", }, @@ -75,7 +77,7 @@ describe("AppAuth", () => { const { data: users } = await ctx.em.repository("users").findMany(); expect(users.length).toBe(1); - expect(users[0].email).toBe("some@body.com"); + expect(users[0]?.email).toBe("some@body.com"); } }); @@ -157,7 +159,7 @@ describe("AppAuth", () => { const authField = make(name, _authFieldProto as any); const field = users.field(name)!; for (const prop of props) { - expect(field.config[prop]).toBe(authField.config[prop]); + expect(field.config[prop]).toEqual(authField.config[prop]); } } }); diff --git a/app/__test__/modules/ModuleManager.spec.ts b/app/__test__/modules/ModuleManager.spec.ts index c556795..31b2027 100644 --- a/app/__test__/modules/ModuleManager.spec.ts +++ b/app/__test__/modules/ModuleManager.spec.ts @@ -43,7 +43,7 @@ describe("ModuleManager", async () => { }).toJSON(), }, }, - }); + }) as any; //const { version, ...json } = mm.toJSON() as any; const c2 = getDummyConnection(); @@ -73,7 +73,7 @@ describe("ModuleManager", async () => { }).toJSON(), }, }, - }; + } as any; //const { version, ...json } = mm.toJSON() as any; const { dummyConnection } = getDummyConnection(); @@ -90,23 +90,20 @@ describe("ModuleManager", async () => { 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(); + 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 () => { const c = getDummyConnection(); const mm = new ModuleManager(c.dummyConnection); await mm.build(); - const version = mm.version(); const json = mm.configs(); const c2 = getDummyConnection(); const db = c2.dummyConnection.kysely; - const mm2 = new ModuleManager(c2.dummyConnection, { - initial: { version: version - 1, ...json }, - }); + const mm2 = new ModuleManager(c2.dummyConnection); await mm2.syncConfigTable(); await db @@ -171,7 +168,7 @@ describe("ModuleManager", async () => { expect(mm2.configs().data.basepath).toBe("/api/data2"); }); - test("blank app, modify config", async () => { + /*test("blank app, modify config", async () => { const { dummyConnection } = getDummyConnection(); const mm = new ModuleManager(dummyConnection); @@ -194,7 +191,7 @@ describe("ModuleManager", async () => { }, }, }); - }); + });*/ test("partial config given", async () => { const { dummyConnection } = getDummyConnection(); @@ -212,13 +209,15 @@ describe("ModuleManager", async () => { 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(); + 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(); + console.log("==".repeat(30)); + console.log(""); const json = mm.configs(); const c2 = getDummyConnection(); @@ -265,8 +264,8 @@ describe("ModuleManager", async () => { override async build() { //console.log("building FailingModule", this.config); - if (this.config.value < 0) { - throw new Error("value must be positive"); + if (this.config.value && this.config.value < 0) { + throw new Error("value must be positive, given: " + this.config.value); } this.setBuilt(); } @@ -332,6 +331,7 @@ describe("ModuleManager", async () => { // @ts-ignore const f = mm.mutateConfigSafe("failing"); + // @ts-ignore expect(f.set({ value: 2 })).resolves.toBeDefined(); expect(mockOnUpdated).toHaveBeenCalled(); }); diff --git a/app/__test__/modules/migrations/migrations.spec.ts b/app/__test__/modules/migrations/migrations.spec.ts index de004ac..4bd83ab 100644 --- a/app/__test__/modules/migrations/migrations.spec.ts +++ b/app/__test__/modules/migrations/migrations.spec.ts @@ -1,39 +1,44 @@ import { describe, expect, test } from "bun:test"; import { type InitialModuleConfigs, createApp } from "../../../src"; -import type { Kysely } from "kysely"; +import { type Kysely, sql } from "kysely"; import { getDummyConnection } from "../../helper"; import v7 from "./samples/v7.json"; +import v8 from "./samples/v8.json"; +import v8_2 from "./samples/v8-2.json"; // app expects migratable config to be present in database -async function createVersionedApp(config: InitialModuleConfigs) { +async function createVersionedApp(config: InitialModuleConfigs | any) { const { dummyConnection } = getDummyConnection(); if (!("version" in config)) throw new Error("config must have a version"); const { version, ...rest } = config; - const app = createApp({ connection: dummyConnection }); - await app.build(); + const db = dummyConnection.kysely as Kysely; + await sql`CREATE TABLE "__bknd" ( + "id" integer not null primary key autoincrement, + "version" integer, + "type" text, + "json" text, + "created_at" datetime, + "updated_at" datetime + )`.execute(db); - const qb = app.modules.ctx().connection.kysely as Kysely; - const current = await qb - .selectFrom("__bknd") - .selectAll() - .where("type", "=", "config") - .executeTakeFirst(); - - await qb - .updateTable("__bknd") - .set("json", JSON.stringify(rest)) - .set("version", 7) - .where("id", "=", current!.id) + await db + .insertInto("__bknd") + .values({ + version, + type: "config", + created_at: new Date().toISOString(), + json: JSON.stringify(rest), + }) .execute(); - const app2 = createApp({ + const app = createApp({ connection: dummyConnection, }); - await app2.build(); - return app2; + await app.build(); + return app; } describe("Migrations", () => { @@ -46,8 +51,8 @@ describe("Migrations", () => { const app = await createVersionedApp(v7); - expect(app.version()).toBe(8); - expect(app.toJSON(true).auth.strategies.password.enabled).toBe(true); + expect(app.version()).toBeGreaterThan(7); + expect(app.toJSON(true).auth.strategies?.password?.enabled).toBe(true); const req = await app.server.request("/api/auth/password/register", { method: "POST", @@ -60,7 +65,17 @@ describe("Migrations", () => { }), }); expect(req.ok).toBe(true); - const res = await req.json(); + const res = (await req.json()) as any; expect(res.user.email).toBe("test@test.com"); }); + + test("migration from 8 to 9", async () => { + expect(v8.version).toBe(8); + + const app = await createVersionedApp(v8); + + expect(app.version()).toBeGreaterThan(8); + // @ts-expect-error + expect(app.toJSON(true).server.admin).toBeUndefined(); + }); }); diff --git a/app/__test__/modules/migrations/samples/v8-2.json b/app/__test__/modules/migrations/samples/v8-2.json new file mode 100644 index 0000000..c94e85a --- /dev/null +++ b/app/__test__/modules/migrations/samples/v8-2.json @@ -0,0 +1,709 @@ +{ + "version": 8, + "server": { + "admin": { + "basepath": "", + "color_scheme": "light", + "logo_return_path": "/" + }, + "cors": { + "origin": "*", + "allow_methods": ["GET", "POST", "PATCH", "PUT", "DELETE"], + "allow_headers": [ + "Content-Type", + "Content-Length", + "Authorization", + "Accept" + ] + } + }, + "data": { + "basepath": "/api/data", + "entities": { + "products": { + "type": "regular", + "fields": { + "id": { + "type": "primary", + "config": { + "fillable": false, + "required": false, + "hidden": false + } + }, + "title": { + "type": "text", + "config": { + "required": true, + "fillable": true, + "hidden": false + } + }, + "brand": { + "type": "text", + "config": { + "required": true, + "fillable": true, + "hidden": false + } + }, + "currency": { + "type": "text", + "config": { + "required": false, + "fillable": true, + "hidden": false + } + }, + "price": { + "type": "number", + "config": { + "required": true, + "fillable": true, + "hidden": false + } + }, + "price_compare": { + "type": "number", + "config": { + "required": false, + "fillable": true, + "hidden": ["table"] + } + }, + "url": { + "type": "text", + "config": { + "html_config": { + "element": "input" + }, + "required": true, + "fillable": true, + "hidden": ["table"] + } + }, + "created_at": { + "type": "date", + "config": { + "type": "date", + "required": false, + "fillable": true, + "hidden": ["table"] + } + }, + "description": { + "type": "text", + "config": { + "html_config": { + "element": "textarea", + "props": { + "rows": 4 + } + }, + "required": false, + "fillable": true, + "hidden": ["table"] + } + }, + "images": { + "type": "media", + "config": { + "required": false, + "fillable": ["update"], + "hidden": false, + "mime_types": [], + "virtual": true, + "entity": "products" + } + }, + "identifier": { + "type": "text", + "config": { + "required": false, + "fillable": true, + "hidden": false + } + }, + "metadata": { + "type": "jsonschema", + "config": { + "schema": { + "type": "object", + "properties": { + "size": { + "type": "string" + }, + "gender": { + "type": "string" + }, + "ai_description": { + "type": "string" + } + }, + "additionalProperties": { + "type": ["string", "number"] + } + }, + "required": false, + "fillable": true, + "hidden": ["table"] + } + }, + "_likes": { + "type": "number", + "config": { + "default_value": 0, + "required": false, + "fillable": true, + "hidden": false + } + } + }, + "config": { + "sort_field": "id", + "sort_dir": "desc" + } + }, + "media": { + "type": "system", + "fields": { + "id": { + "type": "primary", + "config": { + "fillable": false, + "required": false, + "hidden": false + } + }, + "path": { + "type": "text", + "config": { + "required": true, + "fillable": true, + "hidden": false + } + }, + "folder": { + "type": "boolean", + "config": { + "default_value": false, + "hidden": true, + "fillable": ["create"], + "required": false + } + }, + "mime_type": { + "type": "text", + "config": { + "required": false, + "fillable": true, + "hidden": false + } + }, + "size": { + "type": "number", + "config": { + "required": false, + "fillable": true, + "hidden": false + } + }, + "scope": { + "type": "text", + "config": { + "hidden": true, + "fillable": ["create"], + "required": false + } + }, + "etag": { + "type": "text", + "config": { + "required": false, + "fillable": true, + "hidden": false + } + }, + "modified_at": { + "type": "date", + "config": { + "type": "datetime", + "required": false, + "fillable": true, + "hidden": false + } + }, + "reference": { + "type": "text", + "config": { + "required": false, + "fillable": true, + "hidden": false + } + }, + "entity_id": { + "type": "number", + "config": { + "required": false, + "fillable": true, + "hidden": false + } + }, + "metadata": { + "type": "json", + "config": { + "required": false, + "fillable": true, + "hidden": false + } + }, + "temporary": { + "type": "boolean", + "config": { + "required": false, + "fillable": true, + "hidden": false + } + } + }, + "config": { + "sort_field": "id", + "sort_dir": "desc" + } + }, + "users": { + "type": "system", + "fields": { + "id": { + "type": "primary", + "config": { + "fillable": false, + "required": false, + "hidden": false + } + }, + "email": { + "type": "text", + "config": { + "required": true, + "fillable": true, + "hidden": false + } + }, + "strategy": { + "type": "enum", + "config": { + "options": { + "type": "strings", + "values": ["password"] + }, + "required": true, + "fillable": ["create"], + "hidden": ["read", "table", "update", "form"] + } + }, + "strategy_value": { + "type": "text", + "config": { + "fillable": ["create"], + "hidden": ["read", "table", "update", "form"], + "required": true + } + }, + "role": { + "type": "enum", + "config": { + "options": { + "type": "strings", + "values": ["guest", "admin"] + }, + "required": false, + "fillable": true, + "hidden": false + } + }, + "username": { + "type": "text", + "config": { + "required": false, + "fillable": true, + "hidden": false + } + }, + "name": { + "type": "text", + "config": { + "required": false, + "fillable": true, + "hidden": false + } + }, + "_boards": { + "type": "number", + "config": { + "default_value": 0, + "required": false, + "fillable": true, + "hidden": false + } + } + }, + "config": { + "sort_field": "id", + "sort_dir": "desc" + } + }, + "product_likes": { + "type": "regular", + "fields": { + "id": { + "type": "primary", + "config": { + "fillable": false, + "required": false, + "hidden": false + } + }, + "created_at": { + "type": "date", + "config": { + "type": "datetime", + "required": false, + "fillable": true, + "hidden": false + } + }, + "users_id": { + "type": "relation", + "config": { + "label": "User", + "required": true, + "reference": "users", + "target": "users", + "target_field": "id", + "fillable": true, + "hidden": false, + "on_delete": "set null" + } + }, + "products_id": { + "type": "relation", + "config": { + "label": "Product", + "required": true, + "reference": "products", + "target": "products", + "target_field": "id", + "fillable": true, + "hidden": false, + "on_delete": "set null" + } + } + }, + "config": { + "sort_field": "id", + "sort_dir": "desc", + "name": "Product Likes" + } + }, + "boards": { + "type": "regular", + "fields": { + "id": { + "type": "primary", + "config": { + "fillable": false, + "required": false, + "hidden": false + } + }, + "private": { + "type": "boolean", + "config": { + "required": false, + "fillable": true, + "hidden": false + } + }, + "title": { + "type": "text", + "config": { + "required": false, + "fillable": true, + "hidden": false + } + }, + "description": { + "type": "text", + "config": { + "required": false, + "fillable": true, + "hidden": false + } + }, + "users_id": { + "type": "relation", + "config": { + "label": "Users", + "required": true, + "reference": "users", + "target": "users", + "target_field": "id", + "fillable": true, + "hidden": false, + "on_delete": "set null" + } + }, + "images": { + "type": "media", + "config": { + "required": false, + "fillable": ["update"], + "hidden": false, + "mime_types": [], + "virtual": true, + "entity": "boards", + "max_items": 5 + } + }, + "cover": { + "type": "number", + "config": { + "default_value": 0, + "required": false, + "fillable": true, + "hidden": false + } + }, + "_products": { + "type": "number", + "config": { + "default_value": 0, + "required": false, + "fillable": true, + "hidden": false + } + } + }, + "config": { + "sort_field": "id", + "sort_dir": "desc" + } + }, + "boards_products": { + "type": "generated", + "fields": { + "id": { + "type": "primary", + "config": { + "fillable": false, + "required": false, + "hidden": false + } + }, + "boards_id": { + "type": "relation", + "config": { + "required": true, + "reference": "boards", + "target": "boards", + "target_field": "id", + "fillable": true, + "hidden": false, + "on_delete": "set null" + } + }, + "products_id": { + "type": "relation", + "config": { + "required": true, + "reference": "products", + "target": "products", + "target_field": "id", + "fillable": true, + "hidden": false, + "on_delete": "set null" + } + }, + "manual": { + "type": "boolean", + "config": { + "required": false, + "fillable": true, + "hidden": false + } + } + }, + "config": { + "sort_field": "id", + "sort_dir": "desc" + } + } + }, + "relations": { + "poly_products_media_images": { + "type": "poly", + "source": "products", + "target": "media", + "config": { + "mappedBy": "images" + } + }, + "n1_product_likes_users": { + "type": "n:1", + "source": "product_likes", + "target": "users", + "config": { + "mappedBy": "", + "inversedBy": "", + "required": true, + "with_limit": 5 + } + }, + "n1_product_likes_products": { + "type": "n:1", + "source": "product_likes", + "target": "products", + "config": { + "mappedBy": "", + "inversedBy": "", + "required": true, + "with_limit": 5 + } + }, + "n1_boards_users": { + "type": "n:1", + "source": "boards", + "target": "users", + "config": { + "mappedBy": "", + "inversedBy": "", + "required": true, + "with_limit": 5 + } + }, + "poly_boards_media_images": { + "type": "poly", + "source": "boards", + "target": "media", + "config": { + "mappedBy": "images", + "targetCardinality": 5 + } + }, + "mn_boards_products_boards_products,boards_products": { + "type": "m:n", + "source": "boards", + "target": "products", + "config": {} + } + }, + "indices": { + "idx_unique_media_path": { + "entity": "media", + "fields": ["path"], + "unique": true + }, + "idx_media_reference": { + "entity": "media", + "fields": ["reference"], + "unique": false + }, + "idx_unique_users_email": { + "entity": "users", + "fields": ["email"], + "unique": true + }, + "idx_users_strategy": { + "entity": "users", + "fields": ["strategy"], + "unique": false + }, + "idx_users_strategy_value": { + "entity": "users", + "fields": ["strategy_value"], + "unique": false + }, + "idx_product_likes_unique_products_id_users_id": { + "entity": "product_likes", + "fields": ["products_id", "users_id"], + "unique": true + }, + "idx_boards_products_unique_boards_id_products_id": { + "entity": "boards_products", + "fields": ["boards_id", "products_id"], + "unique": true + }, + "idx_media_entity_id": { + "entity": "media", + "fields": ["entity_id"], + "unique": false + }, + "idx_products_identifier": { + "entity": "products", + "fields": ["identifier"], + "unique": false + } + } + }, + "auth": { + "enabled": true, + "basepath": "/api/auth", + "entity_name": "users", + "allow_register": true, + "jwt": { + "secret": "2wY76Z$JQg(3t?Wn8g,^ZqZhlmjNx<@uQjI!7i^XZBF11Xa1>zZK2??Y[D|]cc%k", + "alg": "HS256", + "fields": ["id", "email", "role"] + }, + "cookie": { + "path": "/", + "sameSite": "lax", + "secure": true, + "httpOnly": true, + "expires": 604800, + "renew": true, + "pathSuccess": "/", + "pathLoggedOut": "/" + }, + "strategies": { + "password": { + "enabled": true, + "type": "password", + "config": { + "hashing": "sha256" + } + } + }, + "roles": { + "guest": { + "is_default": true, + "permissions": ["system.access.api", "data.entity.read"] + }, + "admin": { + "implicit_allow": true + } + }, + "guard": { + "enabled": true + } + }, + "media": { + "enabled": true, + "basepath": "/api/media", + "entity_name": "media", + "storage": {}, + "adapter": { + "type": "s3", + "config": { + "access_key": "123", + "secret_access_key": "123", + "url": "https://123.r2.cloudflarestorage.com/enly" + } + } + }, + "flows": { + "basepath": "/api/flows", + "flows": {} + } +} diff --git a/app/__test__/modules/migrations/samples/v8.json b/app/__test__/modules/migrations/samples/v8.json new file mode 100644 index 0000000..e4bacee --- /dev/null +++ b/app/__test__/modules/migrations/samples/v8.json @@ -0,0 +1,598 @@ +{ + "version": 8, + "server": { + "admin": { + "basepath": "", + "logo_return_path": "/", + "color_scheme": "dark" + }, + "cors": { + "origin": "*", + "allow_methods": ["GET", "POST", "PATCH", "PUT", "DELETE"], + "allow_headers": [ + "Content-Type", + "Content-Length", + "Authorization", + "Accept" + ] + } + }, + "data": { + "basepath": "/api/data", + "entities": { + "posts": { + "type": "regular", + "fields": { + "id": { + "type": "primary", + "config": { + "fillable": false, + "required": false, + "hidden": false + } + }, + "title": { + "type": "text", + "config": { + "required": true, + "fillable": true, + "hidden": false + } + }, + "slug": { + "type": "text", + "config": { + "html_config": { + "element": "input" + }, + "pattern": "^[a-z\\-\\_0-9]+$", + "required": false, + "fillable": true, + "hidden": false, + "label": "Slug" + } + }, + "content": { + "type": "text", + "config": { + "html_config": { + "element": "textarea", + "props": { + "rows": 5 + } + }, + "required": false, + "fillable": true, + "hidden": ["form"] + } + }, + "active": { + "type": "boolean", + "config": { + "required": false, + "fillable": true, + "hidden": false + } + }, + "images": { + "type": "media", + "config": { + "required": false, + "fillable": ["update"], + "hidden": false, + "mime_types": [], + "virtual": true, + "entity": "posts" + } + }, + "tags": { + "type": "jsonschema", + "config": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "ui_schema": { + "ui:options": { + "orderable": false + } + }, + "required": false, + "fillable": true, + "hidden": false + } + }, + "users_id": { + "type": "relation", + "config": { + "label": "Users", + "required": false, + "reference": "users", + "target": "users", + "target_field": "id", + "fillable": true, + "hidden": false, + "on_delete": "set null" + } + } + }, + "config": { + "sort_field": "title", + "sort_dir": "desc" + } + }, + "comments": { + "type": "regular", + "fields": { + "id": { + "type": "primary", + "config": { + "fillable": false, + "required": false, + "hidden": false + } + }, + "content": { + "type": "text", + "config": { + "required": false, + "fillable": true, + "hidden": false + } + }, + "created_at": { + "type": "date", + "config": { + "type": "date", + "required": false, + "fillable": true, + "hidden": false + } + }, + "posts_id": { + "type": "relation", + "config": { + "label": "Posts", + "required": true, + "reference": "posts", + "target": "posts", + "target_field": "id", + "fillable": true, + "hidden": false, + "on_delete": "set null" + } + }, + "users_id": { + "type": "relation", + "config": { + "label": "Users", + "required": true, + "reference": "users", + "target": "users", + "target_field": "id", + "fillable": true, + "hidden": false, + "on_delete": "set null" + } + } + }, + "config": { + "sort_field": "id", + "sort_dir": "asc" + } + }, + "media": { + "type": "system", + "fields": { + "id": { + "type": "primary", + "config": { + "fillable": false, + "required": false, + "hidden": false + } + }, + "path": { + "type": "text", + "config": { + "required": true, + "fillable": true, + "hidden": false + } + }, + "folder": { + "type": "boolean", + "config": { + "default_value": false, + "hidden": true, + "fillable": ["create"], + "required": false + } + }, + "mime_type": { + "type": "text", + "config": { + "required": false, + "fillable": true, + "hidden": false + } + }, + "size": { + "type": "number", + "config": { + "required": false, + "fillable": true, + "hidden": false + } + }, + "scope": { + "type": "text", + "config": { + "hidden": true, + "fillable": ["create"], + "required": false + } + }, + "etag": { + "type": "text", + "config": { + "required": false, + "fillable": true, + "hidden": false + } + }, + "modified_at": { + "type": "date", + "config": { + "type": "datetime", + "required": false, + "fillable": true, + "hidden": false + } + }, + "reference": { + "type": "text", + "config": { + "required": false, + "fillable": true, + "hidden": false + } + }, + "entity_id": { + "type": "number", + "config": { + "required": false, + "fillable": true, + "hidden": false + } + }, + "metadata": { + "type": "json", + "config": { + "required": false, + "fillable": true, + "hidden": false + } + } + }, + "config": { + "sort_field": "id", + "sort_dir": "asc" + } + }, + "users": { + "type": "system", + "fields": { + "id": { + "type": "primary", + "config": { + "fillable": false, + "required": false, + "hidden": false + } + }, + "email": { + "type": "text", + "config": { + "required": true, + "fillable": true, + "hidden": false + } + }, + "strategy": { + "type": "enum", + "config": { + "options": { + "type": "strings", + "values": ["password", "google"] + }, + "required": true, + "fillable": ["create"], + "hidden": ["update", "form"] + } + }, + "strategy_value": { + "type": "text", + "config": { + "fillable": ["create"], + "hidden": ["read", "table", "update", "form"], + "required": true + } + }, + "role": { + "type": "enum", + "config": { + "options": { + "type": "strings", + "values": ["guest", "admin", "editor"] + }, + "required": false, + "fillable": true, + "hidden": false + } + }, + "avatar": { + "type": "media", + "config": { + "required": false, + "fillable": ["update"], + "hidden": false, + "mime_types": [], + "virtual": true, + "entity": "users", + "max_items": 1 + } + } + }, + "config": { + "sort_field": "id", + "sort_dir": "asc" + } + }, + "test": { + "type": "regular", + "fields": { + "id": { + "type": "primary", + "config": { + "fillable": false, + "required": false, + "hidden": false + } + }, + "title": { + "type": "text", + "config": { + "required": false, + "fillable": true, + "hidden": false + } + }, + "number": { + "type": "number", + "config": { + "required": false, + "fillable": true, + "hidden": false + } + } + }, + "config": { + "sort_field": "id", + "sort_dir": "asc" + } + } + }, + "relations": { + "n1_comments_posts": { + "type": "n:1", + "source": "comments", + "target": "posts", + "config": { + "required": true, + "with_limit": 5 + } + }, + "poly_posts_media_images": { + "type": "poly", + "source": "posts", + "target": "media", + "config": { + "mappedBy": "images" + } + }, + "n1_posts_users": { + "type": "n:1", + "source": "posts", + "target": "users", + "config": { + "with_limit": 5 + } + }, + "n1_comments_users": { + "type": "n:1", + "source": "comments", + "target": "users", + "config": { + "required": true, + "with_limit": 5 + } + }, + "poly_users_media_avatar": { + "type": "poly", + "source": "users", + "target": "media", + "config": { + "mappedBy": "avatar", + "targetCardinality": 1 + } + } + }, + "indices": { + "idx_unique_media_path": { + "entity": "media", + "fields": ["path"], + "unique": true + }, + "idx_unique_users_email": { + "entity": "users", + "fields": ["email"], + "unique": true + }, + "idx_users_strategy": { + "entity": "users", + "fields": ["strategy"], + "unique": false + }, + "idx_users_strategy_value": { + "entity": "users", + "fields": ["strategy_value"], + "unique": false + }, + "idx_media_reference": { + "entity": "media", + "fields": ["reference"], + "unique": false + }, + "idx_media_entity_id": { + "entity": "media", + "fields": ["entity_id"], + "unique": false + } + } + }, + "auth": { + "enabled": true, + "basepath": "/api/auth", + "entity_name": "users", + "jwt": { + "secret": "A%3jk*wD!Zruj123123123j$Wm8qS8m8qS8", + "alg": "HS256", + "fields": ["id", "email", "role"], + "issuer": "showoff" + }, + "guard": { + "enabled": true + }, + "strategies": { + "password": { + "enabled": true, + "type": "password", + "config": { + "hashing": "sha256" + } + }, + "google": { + "enabled": true, + "type": "oauth", + "config": { + "type": "oidc", + "client": { + "client_id": "545948917277-123ieuifrag.apps.googleusercontent.com", + "client_secret": "123-123hTTZfDDGPDPp" + }, + "name": "google" + } + } + }, + "roles": { + "guest": { + "permissions": [ + "data.entity.read", + "system.access.api", + "system.config.read" + ], + "is_default": true + }, + "admin": { + "is_default": false, + "implicit_allow": true + }, + "editor": { + "permissions": [ + "system.access.admin", + "system.config.read", + "system.schema.read", + "system.config.read.secrets", + "system.access.api", + "data.entity.read", + "data.entity.update", + "data.entity.delete", + "data.entity.create" + ] + } + }, + "allow_register": true, + "cookie": { + "path": "/", + "sameSite": "lax", + "secure": true, + "httpOnly": true, + "expires": 604800, + "renew": true, + "pathSuccess": "/", + "pathLoggedOut": "/" + } + }, + "media": { + "enabled": true, + "basepath": "/api/media", + "entity_name": "media", + "storage": {}, + "adapter": { + "type": "s3", + "config": { + "access_key": "123", + "secret_access_key": "123", + "url": "https://123.r2.cloudflarestorage.com/bknd-123" + } + } + }, + "flows": { + "basepath": "/api/flows", + "flows": { + "test": { + "trigger": { + "type": "http", + "config": { + "mode": "sync", + "method": "GET", + "response_type": "html", + "path": "/json-posts" + } + }, + "tasks": { + "fetching": { + "type": "fetch", + "params": { + "method": "GET", + "headers": [], + "url": "https://jsonplaceholder.typicode.com/posts" + } + }, + "render": { + "type": "render", + "params": { + "render": "

Posts

\n
    \n {% for post in fetching.output %}\n
  • {{ post.title }}
  • \n {% endfor %}\n
" + } + } + }, + "connections": { + "5cce66b5-57c6-4541-88ac-b298794c6c52": { + "source": "fetching", + "target": "render", + "config": { + "condition": { + "type": "success" + } + } + } + }, + "start_task": "fetching", + "responding_task": "render" + } + } + } +} diff --git a/app/bknd.config.js.ignore b/app/bknd.config.js.ignore deleted file mode 100644 index 5f279d3..0000000 --- a/app/bknd.config.js.ignore +++ /dev/null @@ -1,13 +0,0 @@ -//import type { BkndConfig } from "./src"; - -export default { - app: { - connection: { - type: "libsql", - config: { - //url: "http://localhost:8080" - url: ":memory:" - } - } - } -}; diff --git a/app/build.esbuild.ts b/app/build.esbuild.ts deleted file mode 100644 index d9fa6f4..0000000 --- a/app/build.esbuild.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { $, type Subprocess } from "bun"; -import * as esbuild from "esbuild"; -import postcss from "esbuild-postcss"; -import { entryOutputMeta } from "./internal/esbuild.entry-output-meta.plugin"; -import { guessMimeType } from "./src/media/storage/mime-types"; - -const args = process.argv.slice(2); -const watch = args.includes("--watch"); -const minify = args.includes("--minify"); -const types = args.includes("--types"); -const sourcemap = args.includes("--sourcemap"); - -type BuildOptions = esbuild.BuildOptions & { name: string }; - -const baseOptions: Partial> & { plugins?: any[] } = { - minify, - sourcemap, - metafile: true, - format: "esm", - drop: ["console", "debugger"], - loader: { - ".svg": "dataurl", - }, - define: { - __isDev: "0", - }, -}; - -// @ts-ignore -type BuildFn = (format?: "esm" | "cjs") => BuildOptions; - -// build BE -const builds: Record = { - backend: (format = "esm") => ({ - ...baseOptions, - name: `backend ${format}`, - entryPoints: [ - "src/index.ts", - "src/data/index.ts", - "src/core/index.ts", - "src/core/utils/index.ts", - "src/ui/index.ts", - "src/ui/main.css", - ], - outdir: "dist", - outExtension: { ".js": format === "esm" ? ".js" : ".cjs" }, - platform: "browser", - splitting: false, - bundle: true, - plugins: [postcss()], - //target: "es2022", - format, - }), - /*components: (format = "esm") => ({ - ...baseOptions, - name: `components ${format}`, - entryPoints: ["src/ui/index.ts", "src/ui/main.css"], - outdir: "dist/ui", - outExtension: { ".js": format === "esm" ? ".js" : ".cjs" }, - format, - platform: "browser", - splitting: false, - //target: "es2022", - bundle: true, - //external: ["react", "react-dom", "@tanstack/react-query-devtools"], - plugins: [postcss()], - loader: { - ".svg": "dataurl", - ".js": "jsx" - } - }),*/ - static: (format = "esm") => ({ - ...baseOptions, - name: `static ${format}`, - entryPoints: ["src/ui/main.tsx", "src/ui/main.css"], - entryNames: "[dir]/[name]-[hash]", - outdir: "dist/static", - outExtension: { ".js": format === "esm" ? ".js" : ".cjs" }, - platform: "browser", - bundle: true, - splitting: true, - inject: ["src/ui/inject.js"], - target: "es2022", - format, - loader: { - ".svg": "dataurl", - ".js": "jsx", - }, - define: { - __isDev: "0", - "process.env.NODE_ENV": '"production"', - }, - chunkNames: "chunks/[name]-[hash]", - plugins: [ - postcss(), - entryOutputMeta(async (info) => { - const manifest: Record = {}; - const toAsset = (output: string) => { - const name = output.split("/").pop()!; - return { - name, - path: output, - mime: guessMimeType(name), - }; - }; - for (const { output, meta } of info) { - manifest[meta.entryPoint as string] = toAsset(output); - if (meta.cssBundle) { - manifest["src/ui/main.css"] = toAsset(meta.cssBundle); - } - } - - const manifest_file = "dist/static/manifest.json"; - await Bun.write(manifest_file, JSON.stringify(manifest, null, 2)); - console.log(`Manifest written to ${manifest_file}`, manifest); - }), - ], - }), -}; - -function adapter(adapter: string, overrides: Partial = {}): BuildOptions { - return { - ...baseOptions, - name: `adapter ${adapter} ${overrides?.format === "cjs" ? "cjs" : "esm"}`, - entryPoints: [`src/adapter/${adapter}`], - platform: "neutral", - outfile: `dist/adapter/${adapter}/index.${overrides?.format === "cjs" ? "cjs" : "js"}`, - external: [ - "cloudflare:workers", - "@hono*", - "hono*", - "bknd*", - "*.html", - "node*", - "react*", - "next*", - "libsql", - "@libsql*", - ], - splitting: false, - treeShaking: true, - bundle: true, - ...overrides, - }; -} -const adapters = [ - adapter("vite", { platform: "node" }), - adapter("cloudflare"), - adapter("nextjs", { platform: "node", format: "esm" }), - adapter("nextjs", { platform: "node", format: "cjs" }), - adapter("remix", { format: "esm" }), - adapter("remix", { format: "cjs" }), - adapter("bun"), - adapter("node", { platform: "node", format: "esm" }), - adapter("node", { platform: "node", format: "cjs" }), -]; - -const collect = [ - builds.static(), - builds.backend(), - //builds.components(), - builds.backend("cjs"), - //builds.components("cjs"), - ...adapters, -]; - -if (watch) { - const _state: { - timeout: Timer | undefined; - cleanup: Subprocess | undefined; - building: Subprocess | undefined; - } = { - timeout: undefined, - cleanup: undefined, - building: undefined, - }; - - async function rebuildTypes() { - if (!types) return; - if (_state.timeout) { - clearTimeout(_state.timeout); - if (_state.cleanup) _state.cleanup.kill(); - if (_state.building) _state.building.kill(); - } - _state.timeout = setTimeout(async () => { - _state.cleanup = Bun.spawn(["bun", "clean:types"], { - onExit: () => { - _state.cleanup = undefined; - _state.building = Bun.spawn(["bun", "build:types"], { - onExit: () => { - _state.building = undefined; - console.log("Types rebuilt"); - }, - }); - }, - }); - }, 1000); - } - - for (const { name, ...build } of collect) { - const ctx = await esbuild.context({ - ...build, - plugins: [ - ...(build.plugins ?? []), - { - name: "rebuild-notify", - setup(build) { - build.onEnd((result) => { - console.log(`rebuilt ${name} with ${result.errors.length} errors`); - rebuildTypes(); - }); - }, - }, - ], - }); - ctx.watch(); - } -} else { - await $`rm -rf dist`; - - async function _build() { - let i = 0; - const count = collect.length; - for await (const { name, ...build } of collect) { - await esbuild.build({ - ...build, - plugins: [ - ...(build.plugins || []), - { - name: "progress", - setup(build) { - i++; - build.onEnd((result) => { - const errors = result.errors.length; - const from = String(i).padStart(String(count).length); - console.log(`[${from}/${count}] built ${name} with ${errors} errors`); - }); - }, - }, - ], - }); - } - - console.log("All builds complete"); - } - - async function _buildtypes() { - if (!types) return; - Bun.spawn(["bun", "build:types"], { - onExit: () => { - console.log("Types rebuilt"); - }, - }); - } - - await Promise.all([_build(), _buildtypes()]); -} diff --git a/app/build.ts b/app/build.ts index 6f7872a..0022a80 100644 --- a/app/build.ts +++ b/app/build.ts @@ -46,10 +46,18 @@ if (types && !watch) { buildTypes(); } +function banner(title: string) { + console.log(""); + console.log("=".repeat(40)); + console.log(title.toUpperCase()); + console.log("-".repeat(40)); +} + /** * Building backend and general API */ async function buildApi() { + banner("Building API"); await tsup.build({ minify, sourcemap, @@ -109,6 +117,7 @@ async function buildUi() { }, } satisfies tsup.Options; + banner("Building UI"); await tsup.build({ ...base, entry: ["src/ui/index.ts", "src/ui/main.css", "src/ui/styles.css"], @@ -119,6 +128,7 @@ async function buildUi() { }, }); + banner("Building Client"); await tsup.build({ ...base, entry: ["src/ui/client/index.ts"], @@ -136,6 +146,7 @@ async function buildUi() { * - ui/client is external, and after built replaced with "bknd/client" */ async function buildUiElements() { + banner("Building UI Elements"); await tsup.build({ minify, sourcemap, @@ -205,6 +216,7 @@ function baseConfig(adapter: string, overrides: Partial = {}): tsu } async function buildAdapters() { + banner("Building Adapters"); // base adapter handles await tsup.build({ ...baseConfig(""), diff --git a/app/package.json b/app/package.json index 14ff0a0..26c41d0 100644 --- a/app/package.json +++ b/app/package.json @@ -3,7 +3,7 @@ "type": "module", "sideEffects": false, "bin": "./dist/cli/index.js", - "version": "0.9.0", + "version": "0.9.1", "description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, Remix, Astro, Cloudflare, Bun, Node, AWS Lambda & more.", "homepage": "https://bknd.io", "repository": { @@ -32,83 +32,84 @@ }, "license": "FSL-1.1-MIT", "dependencies": { - "@cfworker/json-schema": "^2.0.1", + "@cfworker/json-schema": "^4.1.1", "@codemirror/lang-html": "^6.4.9", "@codemirror/lang-json": "^6.0.1", - "@codemirror/lang-liquid": "^6.2.1", - "@hello-pangea/dnd": "^17.0.0", + "@codemirror/lang-liquid": "^6.2.2", + "@hello-pangea/dnd": "^18.0.1", "@libsql/client": "^0.14.0", - "@mantine/core": "^7.13.4", - "@sinclair/typebox": "^0.32.34", - "@tanstack/react-form": "0.19.2", - "@uiw/react-codemirror": "^4.23.6", - "@xyflow/react": "^12.3.2", - "aws4fetch": "^1.0.18", + "@mantine/core": "^7.17.1", + "@mantine/hooks": "^7.17.1", + "@sinclair/typebox": "^0.34.30", + "@tanstack/react-form": "^1.0.5", + "@uiw/react-codemirror": "^4.23.10", + "@xyflow/react": "^12.4.4", + "aws4fetch": "^1.0.20", "dayjs": "^1.11.13", - "fast-xml-parser": "^4.4.0", - "hono": "^4.6.12", + "fast-xml-parser": "^5.0.8", + "hono": "^4.7.4", "json-schema-form-react": "^0.0.2", "json-schema-library": "^10.0.0-rc7", "json-schema-to-ts": "^3.1.1", - "kysely": "^0.27.4", - "liquidjs": "^10.15.0", + "kysely": "^0.27.6", + "liquidjs": "^10.21.0", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", "object-path-immutable": "^4.1.2", "picocolors": "^1.1.1", - "radix-ui": "^1.1.2", - "swr": "^2.2.5" + "radix-ui": "^1.1.3", + "swr": "^2.3.3" }, "devDependencies": { - "@aws-sdk/client-s3": "^3.613.0", + "@aws-sdk/client-s3": "^3.758.0", "@bluwy/giget-core": "^0.1.2", "@dagrejs/dagre": "^1.1.4", - "@hono/typebox-validator": "^0.2.6", - "@hono/vite-dev-server": "^0.17.0", - "@hono/zod-validator": "^0.4.1", - "@hookform/resolvers": "^3.9.1", + "@hono/typebox-validator": "^0.3.2", + "@hono/vite-dev-server": "^0.19.0", + "@hookform/resolvers": "^4.1.3", "@libsql/kysely-libsql": "^0.4.1", - "@mantine/modals": "^7.13.4", - "@mantine/notifications": "^7.13.4", + "@mantine/modals": "^7.17.1", + "@mantine/notifications": "^7.17.1", "@rjsf/core": "5.22.2", "@tabler/icons-react": "3.18.0", - "@types/node": "^22.10.0", - "@types/pg": "^8.11.11", - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", - "@vitejs/plugin-react": "^4.3.3", - "autoprefixer": "^10.4.20", + "@tailwindcss/postcss": "^4.0.12", + "@tailwindcss/vite": "^4.0.12", + "@types/node": "^22.13.10", + "@types/react": "^19.0.10", + "@types/react-dom": "^19.0.4", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.21", "clsx": "^2.1.1", "dotenv": "^16.4.7", - "esbuild-postcss": "^0.0.4", - "jotai": "^2.10.1", + "jotai": "^2.12.2", "kysely-d1": "^0.3.0", "open": "^10.1.0", "openapi-types": "^12.1.3", - "pg": "^8.13.3", - "postcss": "^8.4.47", + "pg": "^8.13.3", + "postcss": "^8.5.3", "postcss-preset-mantine": "^1.17.0", "postcss-simple-vars": "^7.0.1", - "react-hook-form": "^7.53.1", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-hook-form": "^7.54.2", "react-icons": "5.2.1", - "react-json-view-lite": "^2.0.1", - "sql-formatter": "^15.4.9", - "tailwind-merge": "^2.5.4", - "tailwindcss": "^3.4.14", + "react-json-view-lite": "^2.4.1", + "sql-formatter": "^15.4.11", + "tailwind-merge": "^3.0.2", + "tailwindcss": "^4.0.12", "tailwindcss-animate": "^1.0.7", - "tsc-alias": "^1.8.10", - "tsup": "^8.3.5", - "vite": "^5.4.10", - "vite-plugin-static-copy": "^2.0.0", - "vite-tsconfig-paths": "^5.0.1", - "wouter": "^3.3.5" + "tsc-alias": "^1.8.11", + "tsup": "^8.4.0", + "vite": "^6.2.1", + "vite-tsconfig-paths": "^5.1.4", + "wouter": "^3.6.0" }, "optionalDependencies": { - "@hono/node-server": "^1.13.7" + "@hono/node-server": "^1.13.8" }, "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" + "react": "^19.x", + "react-dom": "^19.x" }, "main": "./dist/index.js", "module": "./dist/index.js", @@ -230,4 +231,4 @@ "bun", "node" ] -} \ No newline at end of file +} diff --git a/app/postcss.config.js b/app/postcss.config.js index 5633993..61ce526 100644 --- a/app/postcss.config.js +++ b/app/postcss.config.js @@ -1,9 +1,6 @@ export default { plugins: { - "postcss-import": {}, - "tailwindcss/nesting": {}, - tailwindcss: {}, - autoprefixer: {}, + "@tailwindcss/postcss": {}, "postcss-preset-mantine": {}, "postcss-simple-vars": { variables: { diff --git a/app/src/adapter/nextjs/nextjs.adapter.ts b/app/src/adapter/nextjs/nextjs.adapter.ts index 5c2a364..32ff102 100644 --- a/app/src/adapter/nextjs/nextjs.adapter.ts +++ b/app/src/adapter/nextjs/nextjs.adapter.ts @@ -1,15 +1,22 @@ import type { App } from "bknd"; import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter"; -import { getRuntimeKey, isNode } from "core/utils"; +import { isNode } from "core/utils"; export type NextjsBkndConfig = FrameworkBkndConfig & { cleanRequest?: { searchParams?: string[] }; }; +type NextjsContext = { + env: Record; +}; + let app: App; let building: boolean = false; -export async function getApp(config: NextjsBkndConfig) { +export async function getApp( + config: NextjsBkndConfig, + args?: Args, +) { if (building) { while (building) { await new Promise((resolve) => setTimeout(resolve, 5)); @@ -19,7 +26,7 @@ export async function getApp(config: NextjsBkndConfig) { building = true; if (!app) { - app = await createFrameworkApp(config); + app = await createFrameworkApp(config, args); await app.build(); } building = false; @@ -52,7 +59,7 @@ function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequ export function serve({ cleanRequest, ...config }: NextjsBkndConfig = {}) { return async (req: Request) => { if (!app) { - app = await getApp(config); + app = await getApp(config, { env: process.env ?? {} }); } const request = getCleanRequest(req, cleanRequest); return app.fetch(request); diff --git a/app/src/cli/commands/run/platform.ts b/app/src/cli/commands/run/platform.ts index eda3d44..62707bd 100644 --- a/app/src/cli/commands/run/platform.ts +++ b/app/src/cli/commands/run/platform.ts @@ -30,7 +30,11 @@ export async function attachServeStatic(app: any, platform: Platform) { app.module.server.client.get(config.server.assets_path + "*", await serveStatic(platform)); } -export async function startServer(server: Platform, app: any, options: { port: number }) { +export async function startServer( + server: Platform, + app: any, + options: { port: number; open?: boolean }, +) { const port = options.port; console.log(`Using ${server} serve`); @@ -55,7 +59,9 @@ export async function startServer(server: Platform, app: any, options: { port: n const url = `http://localhost:${port}`; console.info("Server listening on", url); - await open(url); + if (options.open) { + 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 c3a5755..ddfeb1a 100644 --- a/app/src/cli/commands/run/run.ts +++ b/app/src/cli/commands/run/run.ts @@ -47,6 +47,7 @@ export const run: CliCommand = (program) => { .choices(PLATFORMS) .default(isBun ? "bun" : "node"), ) + .addOption(new Option("--no-open", "don't open browser window on start")) .action(action); }; @@ -110,6 +111,7 @@ async function action(options: { dbUrl?: string; dbToken?: string; server: Platform; + open?: boolean; }) { colorizeConsole(console); const configFilePath = await getConfigPath(options.config); @@ -145,5 +147,5 @@ async function action(options: { }); } - await startServer(options.server, app, { port: options.port }); + await startServer(options.server, app, { port: options.port, open: options.open }); } diff --git a/app/src/core/utils/DebugLogger.ts b/app/src/core/utils/DebugLogger.ts index e58b45d..23a1b44 100644 --- a/app/src/core/utils/DebugLogger.ts +++ b/app/src/core/utils/DebugLogger.ts @@ -30,7 +30,7 @@ export class DebugLogger { const now = performance.now(); const time = this.last === 0 ? 0 : Number.parseInt(String(now - this.last)); - const indents = " ".repeat(this._context.length); + const indents = " ".repeat(Math.max(this._context.length - 1, 0)); const context = this._context.length > 0 ? `[${this._context[this._context.length - 1]}]` : ""; console.log(indents, context, time, ...args); diff --git a/app/src/core/utils/strings.ts b/app/src/core/utils/strings.ts index a28d7ea..be115f5 100644 --- a/app/src/core/utils/strings.ts +++ b/app/src/core/utils/strings.ts @@ -118,3 +118,17 @@ export function patternMatch(target: string, pattern: RegExp | string): boolean } return false; } + +export function slugify(str: string): string { + return ( + String(str) + .normalize("NFKD") // split accented characters into their base characters and diacritical marks + // biome-ignore lint/suspicious/noMisleadingCharacterClass: + .replace(/[\u0300-\u036f]/g, "") // remove all the accents, which happen to be all in the \u03xx UNICODE block. + .trim() // trim leading or trailing whitespace + .toLowerCase() // convert to lowercase + .replace(/[^a-z0-9 -]/g, "") // remove non-alphanumeric characters + .replace(/\s+/g, "-") // replace spaces with hyphens + .replace(/-+/g, "-") // remove consecutive hyphens + ); +} diff --git a/app/src/data/AppData.ts b/app/src/data/AppData.ts index 185ee09..22fad1b 100644 --- a/app/src/data/AppData.ts +++ b/app/src/data/AppData.ts @@ -19,8 +19,6 @@ export class AppData extends Module { indices: _indices = {}, } = this.config; - this.ctx.logger.context("AppData").log("building with entities", Object.keys(_entities)); - const entities = transformObject(_entities, (entityConfig, name) => { return constructEntity(name, entityConfig); }); @@ -60,7 +58,6 @@ export class AppData extends Module { ); this.ctx.guard.registerPermissions(Object.values(DataPermissions)); - this.ctx.logger.clear(); this.setBuilt(); } diff --git a/app/src/modules/ModuleManager.ts b/app/src/modules/ModuleManager.ts index 955b6c4..8f47582 100644 --- a/app/src/modules/ModuleManager.ts +++ b/app/src/modules/ModuleManager.ts @@ -115,7 +115,7 @@ const configJsonSchema = Type.Union([ }), ), ]); -const __bknd = entity(TABLE_NAME, { +export const __bknd = entity(TABLE_NAME, { version: number().required(), type: enumm({ enum: ["config", "diff", "backup"] }).required(), json: jsonSchema({ schema: configJsonSchema }).required(), @@ -170,6 +170,8 @@ export class ModuleManager { } } + this.logger.log("booted with", this._booted_with); + this.createModules(initial); } @@ -218,7 +220,8 @@ export class ModuleManager { private repo() { return this.__em.repo(__bknd, { - silent: !debug_modules, + // to prevent exceptions when table doesn't exist + silent: true, }); } @@ -271,7 +274,7 @@ export class ModuleManager { }; } - private async fetch(): Promise { + private async fetch(): Promise { this.logger.context("fetch").log("fetching"); const startTime = performance.now(); @@ -285,7 +288,7 @@ export class ModuleManager { if (!result) { this.logger.log("error fetching").clear(); - throw BkndError.with("no config"); + return undefined; } this.logger @@ -305,6 +308,7 @@ export class ModuleManager { try { const state = await this.fetch(); + if (!state) throw new BkndError("save: no config found"); this.logger.log("fetched version", state.version); if (state.version !== version) { @@ -321,11 +325,11 @@ export class ModuleManager { json: configs, }); } else { - this.logger.log("version matches"); + this.logger.log("version matches", state.version); // clean configs because of Diff() function const diffs = diff(state.json, clone(configs)); - this.logger.log("checking diff", diffs); + this.logger.log("checking diff", [diffs.length]); if (diff.length > 0) { // store diff @@ -380,78 +384,6 @@ export class ModuleManager { return this; } - private async migrate() { - const state = { - success: false, - migrated: false, - version: { - before: this.version(), - after: this.version(), - }, - }; - this.logger.context("migrate").log("migrating?", this.version(), CURRENT_VERSION); - - if (this.version() < CURRENT_VERSION) { - state.version.before = this.version(); - - this.logger.log("there are migrations, verify version"); - // sync __bknd table - await this.syncConfigTable(); - - // modules must be built before migration - this.logger.log("building modules"); - await this.buildModules({ graceful: true }); - this.logger.log("modules built"); - - try { - const state = await this.fetch(); - if (state.version !== this.version()) { - // @todo: potentially drop provided config and use database version - throw new Error( - `Given version (${this.version()}) and fetched version (${state.version}) do not match.`, - ); - } - } catch (e: any) { - throw new Error(`Version is ${this.version()}, fetch failed: ${e.message}`); - } - - this.logger.log("now migrating"); - let version = this.version(); - let configs: any = this.configs(); - //console.log("migrating with", version, configs); - if (Object.keys(configs).length === 0) { - throw new Error("No config to migrate"); - } - - const [_version, _configs] = await migrate(version, configs, { - db: this.db, - }); - version = _version; - configs = _configs; - - this._version = version; - state.version.after = version; - state.migrated = true; - this.ctx().flags.sync_required = true; - - this.logger.log("setting configs"); - this.createModules(configs); - await this.buildModules(); - - this.logger.log("migrated to", version); - $console.log("Migrated config from", state.version.before, "to", state.version.after); - - await this.save(); - } else { - this.logger.log("no migrations needed"); - } - - state.success = true; - this.logger.clear(); - - return state; - } - private setConfigs(configs: ModuleConfigs): void { this.logger.log("setting configs"); objectEach(configs, (config, key) => { @@ -469,66 +401,66 @@ export class ModuleManager { async build(opts?: { fetch?: boolean }) { this.logger.context("build").log("version", this.version()); - this.logger.log("booted with", this._booted_with); // if no config provided, try fetch from db if (this.version() === 0 || opts?.fetch === true) { - if (this.version() === 0) { - this.logger.context("no version").log("version is 0"); - } else { - this.logger.context("force fetch").log("force fetch"); + if (opts?.fetch) { + this.logger.log("force fetch"); } - try { - const result = await this.fetch(); + const result = await this.fetch(); + // if no version, and nothing found, go with initial + if (!result) { + this.logger.log("nothing in database, go initial"); + await this.setupInitial(); + } else { + this.logger.log("db has", result.version); // set version and config from fetched this._version = result.version; - if (this.version() !== CURRENT_VERSION) { - await this.syncConfigTable(); - } - if (this.options?.trustFetched === true) { this.logger.log("trusting fetched config (mark)"); mark(result.json); } - this.setConfigs(result.json); - } catch (e: any) { - this.logger.clear(); // fetch couldn't clear + // if version doesn't match, migrate before building + if (this.version() !== CURRENT_VERSION) { + this.logger.log("now migrating"); - this.logger.context("error handler").log("fetch failed", e.message); + await this.syncConfigTable(); - // we can safely build modules, since config version is up to date - // it's up to date because we use default configs (no fetch result) - this._version = CURRENT_VERSION; - await this.syncConfigTable(); - const state = await this.buildModules(); - if (!state.saved) { - await this.save(); + const version_before = this.version(); + const [_version, _configs] = await migrate(version_before, result.json, { + db: this.db, + }); + + this._version = _version; + this.ctx().flags.sync_required = true; + + this.logger.log("migrated to", _version); + $console.log("Migrated config from", version_before, "to", this.version()); + + this.createModules(_configs); + await this.buildModules(); + } else { + this.logger.log("version is current", this.version()); + this.createModules(result.json); + await this.buildModules(); } - - // run initial setup - await this.setupInitial(); - - this.logger.clear(); - return this; } - this.logger.clear(); - } - - // migrate to latest if needed - this.logger.log("check migrate"); - const migration = await this.migrate(); - if (migration.success && migration.migrated) { - this.logger.log("skipping build after migration"); } else { - this.logger.log("trigger build modules"); + if (this.version() !== CURRENT_VERSION) { + throw new Error( + `Given version (${this.version()}) and current version (${CURRENT_VERSION}) do not match.`, + ); + } + this.logger.log("current version is up to date", this.version()); await this.buildModules(); } this.logger.log("done"); + this.logger.clear(); return this; } @@ -589,6 +521,14 @@ export class ModuleManager { } protected async setupInitial() { + this.logger.context("initial").log("start"); + this._version = CURRENT_VERSION; + await this.syncConfigTable(); + const state = await this.buildModules(); + if (!state.saved) { + await this.save(); + } + const ctx = { ...this.ctx(), // disable events for initial setup @@ -601,6 +541,7 @@ export class ModuleManager { // run first boot event await this.options?.onFirstBoot?.(); + this.logger.clear(); } mutateConfigSafe( diff --git a/app/src/modules/migrations.ts b/app/src/modules/migrations.ts index 8297a45..59911b1 100644 --- a/app/src/modules/migrations.ts +++ b/app/src/modules/migrations.ts @@ -1,6 +1,7 @@ import { _jsonp, transformObject } from "core/utils"; import { type Kysely, sql } from "kysely"; import { set } from "lodash-es"; +import type { InitialModuleConfigs } from "modules/ModuleManager"; export type MigrationContext = { db: Kysely; @@ -91,6 +92,17 @@ export const migrations: Migration[] = [ }; }, }, + { + // remove admin config + version: 9, + up: async (config) => { + const { admin, ...server } = config.server; + return { + ...config, + server, + }; + }, + }, ]; export const CURRENT_VERSION = migrations[migrations.length - 1]?.version ?? 0; diff --git a/app/src/modules/server/AdminController.tsx b/app/src/modules/server/AdminController.tsx index 5957467..f6a665a 100644 --- a/app/src/modules/server/AdminController.tsx +++ b/app/src/modules/server/AdminController.tsx @@ -5,9 +5,9 @@ import { config, isDebug } from "core"; import { addFlashMessage } from "core/server/flash"; import { html } from "hono/html"; import { Fragment } from "hono/jsx"; +import { css, Style } from "hono/css"; import { Controller } from "modules/Controller"; import * as SystemPermissions from "modules/permissions"; -import type { AppTheme } from "modules/server/AppServer"; const htmlBkndContextReplace = ""; @@ -73,7 +73,6 @@ export class AdminController extends Controller { const obj = { user: c.get("auth")?.user, logout_route: this.withBasePath(authRoutes.logout), - color_scheme: configs.server.admin.color_scheme, }; const html = await this.getHtml(obj); if (!html) { @@ -183,14 +182,13 @@ export class AdminController extends Controller { assets.css = manifest["src/ui/main.tsx"].css[0] as any; } - const theme = configs.server.admin.color_scheme ?? "light"; const favicon = isProd ? this.options.assets_path + "favicon.ico" : "/favicon.ico"; return ( {/* dnd complains otherwise */} {html``} - +
-
- - Initializing... - +