diff --git a/app/__test__/core/Registry.spec.ts b/app/__test__/core/Registry.spec.ts index 1be9310..557b39a 100644 --- a/app/__test__/core/Registry.spec.ts +++ b/app/__test__/core/Registry.spec.ts @@ -1,4 +1,4 @@ -import { describe, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import type { TObject, TString } from "@sinclair/typebox"; import { Registry } from "../../src/core/registry/Registry"; import { type TSchema, Type } from "../../src/core/utils"; @@ -11,6 +11,9 @@ class What { method() { return null; } + getType() { + return Type.Object({ type: Type.String() }); + } } class What2 extends What {} class NotAllowed {} @@ -32,25 +35,53 @@ describe("Registry", () => { } satisfies Record); const item = registry.get("first"); + expect(item).toBeDefined(); + expect(item?.cls).toBe(What); + const second = Type.Object({ type: Type.String(), what: Type.String() }); registry.add("second", { cls: What2, - schema: Type.Object({ type: Type.String(), what: Type.String() }), + schema: second, enabled: true }); + // @ts-ignore + expect(registry.get("second").schema).toEqual(second); + + const third = Type.Object({ type: Type.String({ default: "1" }), what22: Type.String() }); registry.add("third", { // @ts-expect-error cls: NotAllowed, - schema: Type.Object({ type: Type.String({ default: "1" }), what22: Type.String() }), + schema: third, enabled: true }); + // @ts-ignore + expect(registry.get("third").schema).toEqual(third); + + const fourth = Type.Object({ type: Type.Number(), what22: Type.String() }); registry.add("fourth", { cls: What, // @ts-expect-error - schema: Type.Object({ type: Type.Number(), what22: Type.String() }), + schema: fourth, enabled: true }); + // @ts-ignore + expect(registry.get("fourth").schema).toEqual(fourth); - console.log("list", registry.all()); + expect(Object.keys(registry.all()).length).toBe(4); + }); + + test("uses registration fn", async () => { + const registry = new Registry((a: ClassRef) => { + return { + cls: a, + schema: a.prototype.getType(), + enabled: true + }; + }); + + registry.register("what2", What2); + expect(registry.get("what2")).toBeDefined(); + expect(registry.get("what2").cls).toBe(What2); + expect(registry.get("what2").schema).toEqual(What2.prototype.getType()); }); }); diff --git a/app/__test__/data/mutation.simple.test.ts b/app/__test__/data/mutation.simple.test.ts index dd385af..ac19935 100644 --- a/app/__test__/data/mutation.simple.test.ts +++ b/app/__test__/data/mutation.simple.test.ts @@ -16,7 +16,7 @@ describe("Mutator simple", async () => { new TextField("label", { required: true, minLength: 1 }), new NumberField("count", { default_value: 0 }) ]); - const em = new EntityManager([items], connection); + const em = new EntityManager([items], connection); await em.connection.kysely.schema .createTable("items") @@ -175,4 +175,18 @@ describe("Mutator simple", async () => { { id: 8, label: "keep", count: 0 } ]); }); + + test("insertMany", async () => { + const oldCount = (await em.repo(items).count()).count; + const inserts = [{ label: "insert 1" }, { label: "insert 2" }]; + const { data } = await em.mutator(items).insertMany(inserts); + + expect(data.length).toBe(2); + expect(data.map((d) => ({ label: d.label }))).toEqual(inserts); + const newCount = (await em.repo(items).count()).count; + expect(newCount).toBe(oldCount + inserts.length); + + const { data: data2 } = await em.repo(items).findMany({ offset: oldCount }); + expect(data2).toEqual(data); + }); }); diff --git a/app/__test__/data/prototype.test.ts b/app/__test__/data/prototype.test.ts index e5d3753..8b12aa4 100644 --- a/app/__test__/data/prototype.test.ts +++ b/app/__test__/data/prototype.test.ts @@ -3,6 +3,8 @@ import { BooleanField, DateField, Entity, + EntityIndex, + EntityManager, EnumField, JsonField, ManyToManyRelation, @@ -12,6 +14,7 @@ import { PolymorphicRelation, TextField } from "../../src/data"; +import { DummyConnection } from "../../src/data/connection/DummyConnection"; import { FieldPrototype, type FieldSchema, @@ -20,6 +23,7 @@ import { boolean, date, datetime, + em, entity, enumm, json, @@ -46,12 +50,17 @@ describe("prototype", () => { }); test("...2", async () => { - const user = entity("users", { - name: text().required(), + const users = entity("users", { + name: text(), bio: text(), age: number(), - some: number().required() + some: number() }); + type db = { + users: Schema; + }; + + const obj: Schema = {} as any; //console.log("user", user.toJSON()); }); @@ -266,4 +275,38 @@ describe("prototype", () => { const obj: Schema = {} as any; }); + + test("schema", async () => { + const _em = em( + { + posts: entity("posts", { name: text(), slug: text().required() }), + comments: entity("comments", { some: text() }), + users: entity("users", { email: text() }) + }, + ({ relation, index }, { posts, comments, users }) => { + relation(posts).manyToOne(comments).manyToOne(users); + index(posts).on(["name"]).on(["slug"], true); + } + ); + + type LocalDb = (typeof _em)["DB"]; + + const es = [ + new Entity("posts", [new TextField("name"), new TextField("slug", { required: true })]), + new Entity("comments", [new TextField("some")]), + new Entity("users", [new TextField("email")]) + ]; + const _em2 = new EntityManager( + es, + new DummyConnection(), + [new ManyToOneRelation(es[0], es[1]), new ManyToOneRelation(es[0], es[2])], + [ + new EntityIndex(es[0], [es[0].field("name")!]), + new EntityIndex(es[0], [es[0].field("slug")!], true) + ] + ); + + // @ts-ignore + expect(_em2.toJSON()).toEqual(_em.toJSON()); + }); }); diff --git a/app/__test__/data/specs/Mutator.spec.ts b/app/__test__/data/specs/Mutator.spec.ts index 04bd8a3..5552543 100644 --- a/app/__test__/data/specs/Mutator.spec.ts +++ b/app/__test__/data/specs/Mutator.spec.ts @@ -22,7 +22,7 @@ describe("[data] Mutator (base)", async () => { new TextField("hidden", { hidden: true }), new TextField("not_fillable", { fillable: false }) ]); - const em = new EntityManager([entity], dummyConnection); + const em = new EntityManager([entity], dummyConnection); await em.schema().sync({ force: true }); const payload = { label: "item 1", count: 1 }; @@ -61,7 +61,7 @@ describe("[data] Mutator (ManyToOne)", async () => { const posts = new Entity("posts", [new TextField("title")]); const users = new Entity("users", [new TextField("username")]); const relations = [new ManyToOneRelation(posts, users)]; - const em = new EntityManager([posts, users], dummyConnection, relations); + const em = new EntityManager([posts, users], dummyConnection, relations); await em.schema().sync({ force: true }); test("RelationMutator", async () => { @@ -192,7 +192,7 @@ describe("[data] Mutator (OneToOne)", async () => { const users = new Entity("users", [new TextField("username")]); const settings = new Entity("settings", [new TextField("theme")]); const relations = [new OneToOneRelation(users, settings)]; - const em = new EntityManager([users, settings], dummyConnection, relations); + const em = new EntityManager([users, settings], dummyConnection, relations); await em.schema().sync({ force: true }); test("insertOne: missing ref", async () => { @@ -276,7 +276,7 @@ describe("[data] Mutator (ManyToMany)", async () => { describe("[data] Mutator (Events)", async () => { const entity = new Entity("test", [new TextField("label")]); - const em = new EntityManager([entity], dummyConnection); + const em = new EntityManager([entity], dummyConnection); await em.schema().sync({ force: true }); const events = new Map(); diff --git a/app/__test__/media/mime-types.spec.ts b/app/__test__/media/mime-types.spec.ts new file mode 100644 index 0000000..f00016a --- /dev/null +++ b/app/__test__/media/mime-types.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test"; +import * as large from "../../src/media/storage/mime-types"; +import * as tiny from "../../src/media/storage/mime-types-tiny"; + +describe("media/mime-types", () => { + test("tiny resolves", () => { + const tests = [[".mp4", "video/mp4", ".jpg", "image/jpeg", ".zip", "application/zip"]]; + + for (const [ext, mime] of tests) { + expect(tiny.guess(ext)).toBe(mime); + } + }); + + test("all tiny resolves to large", () => { + for (const [ext, mime] of Object.entries(tiny.M)) { + expect(large.guessMimeType("." + ext)).toBe(mime); + } + + for (const [type, exts] of Object.entries(tiny.Q)) { + for (const ext of exts) { + const ex = `${type}/${ext}`; + try { + expect(large.guessMimeType("." + ext)).toBe(ex); + } catch (e) { + console.log(`Failed for ${ext}`, { + type, + exts, + ext, + expected: ex, + actual: large.guessMimeType("." + ext) + }); + throw new Error(`Failed for ${ext}`); + } + } + } + }); +}); diff --git a/app/build.ts b/app/build.ts index 60251f8..6511124 100644 --- a/app/build.ts +++ b/app/build.ts @@ -9,16 +9,44 @@ const watch = args.includes("--watch"); const minify = args.includes("--minify"); const types = args.includes("--types"); const sourcemap = args.includes("--sourcemap"); +const clean = args.includes("--clean"); + +if (clean) { + console.log("Cleaning dist"); + await $`rm -rf dist`; +} + +let types_running = false; +function buildTypes() { + if (types_running) return; + types_running = true; -await $`rm -rf dist`; -if (types) { Bun.spawn(["bun", "build:types"], { onExit: () => { console.log("Types built"); + Bun.spawn(["bun", "tsc-alias"], { + onExit: () => { + console.log("Types aliased"); + types_running = false; + } + }); } }); } +let watcher_timeout: any; +function delayTypes() { + if (!watch) return; + if (watcher_timeout) { + clearTimeout(watcher_timeout); + } + watcher_timeout = setTimeout(buildTypes, 1000); +} + +if (types && !watch) { + buildTypes(); +} + /** * Build static assets * Using esbuild because tsup doesn't include "react" @@ -46,7 +74,8 @@ const result = await esbuild.build({ __isDev: "0", "process.env.NODE_ENV": '"production"' }, - chunkNames: "chunks/[name]-[hash]" + chunkNames: "chunks/[name]-[hash]", + logLevel: "error" }); // Write manifest @@ -96,6 +125,9 @@ await tsup.build({ treeshake: true, loader: { ".svg": "dataurl" + }, + onSuccess: async () => { + delayTypes(); } }); @@ -117,11 +149,12 @@ await tsup.build({ loader: { ".svg": "dataurl" }, - onSuccess: async () => { - console.log("--- ui built"); - }, esbuildOptions: (options) => { + options.logLevel = "silent"; options.chunkNames = "chunks/[name]-[hash]"; + }, + onSuccess: async () => { + delayTypes(); } }); @@ -148,7 +181,10 @@ function baseConfig(adapter: string): tsup.Options { ], metafile: true, splitting: false, - treeshake: true + treeshake: true, + onSuccess: async () => { + delayTypes(); + } }; } diff --git a/app/package.json b/app/package.json index fb02b8e..8efb6c9 100644 --- a/app/package.json +++ b/app/package.json @@ -3,16 +3,16 @@ "type": "module", "sideEffects": false, "bin": "./dist/cli/index.js", - "version": "0.3.4-alpha1", + "version": "0.4.0", "scripts": { - "build:all": "bun run build && bun run build:cli", + "build:all": "NODE_ENV=production bun run build.ts --minify --types --clean && bun run build:cli", "dev": "vite", "test": "ALL_TESTS=1 bun test --bail", "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", - "build:types": "tsc --emitDeclarationOnly", + "build:types": "tsc --emitDeclarationOnly && tsc-alias", "build:css": "bun tailwindcss -i src/ui/main.css -o ./dist/static/styles.css", "watch:css": "bun tailwindcss --watch -i src/ui/main.css -o ./dist/styles.css", "updater": "bun x npm-check-updates -ui", @@ -75,6 +75,7 @@ "tailwind-merge": "^2.5.4", "tailwindcss": "^3.4.14", "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", @@ -90,75 +91,75 @@ }, "main": "./dist/index.js", "module": "./dist/index.js", - "types": "./dist/index.d.ts", + "types": "./dist/types/index.d.ts", "exports": { ".": { - "types": "./dist/index.d.ts", + "types": "./dist/types/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" }, "./ui": { - "types": "./dist/ui/index.d.ts", + "types": "./dist/types/ui/index.d.ts", "import": "./dist/ui/index.js", "require": "./dist/ui/index.cjs" }, "./client": { - "types": "./dist/ui/client/index.d.ts", + "types": "./dist/types/ui/client/index.d.ts", "import": "./dist/ui/client/index.js", "require": "./dist/ui/client/index.cjs" }, "./data": { - "types": "./dist/data/index.d.ts", + "types": "./dist/types/data/index.d.ts", "import": "./dist/data/index.js", "require": "./dist/data/index.cjs" }, "./core": { - "types": "./dist/core/index.d.ts", + "types": "./dist/types/core/index.d.ts", "import": "./dist/core/index.js", "require": "./dist/core/index.cjs" }, "./utils": { - "types": "./dist/core/utils/index.d.ts", + "types": "./dist/types/core/utils/index.d.ts", "import": "./dist/core/utils/index.js", "require": "./dist/core/utils/index.cjs" }, "./cli": { - "types": "./dist/cli/index.d.ts", + "types": "./dist/types/cli/index.d.ts", "import": "./dist/cli/index.js", "require": "./dist/cli/index.cjs" }, "./adapter/cloudflare": { - "types": "./dist/adapter/cloudflare/index.d.ts", + "types": "./dist/types/adapter/cloudflare/index.d.ts", "import": "./dist/adapter/cloudflare/index.js", "require": "./dist/adapter/cloudflare/index.cjs" }, "./adapter/vite": { - "types": "./dist/adapter/vite/index.d.ts", + "types": "./dist/types/adapter/vite/index.d.ts", "import": "./dist/adapter/vite/index.js", "require": "./dist/adapter/vite/index.cjs" }, "./adapter/nextjs": { - "types": "./dist/adapter/nextjs/index.d.ts", + "types": "./dist/types/adapter/nextjs/index.d.ts", "import": "./dist/adapter/nextjs/index.js", "require": "./dist/adapter/nextjs/index.cjs" }, "./adapter/remix": { - "types": "./dist/adapter/remix/index.d.ts", + "types": "./dist/types/adapter/remix/index.d.ts", "import": "./dist/adapter/remix/index.js", "require": "./dist/adapter/remix/index.cjs" }, "./adapter/bun": { - "types": "./dist/adapter/bun/index.d.ts", + "types": "./dist/types/adapter/bun/index.d.ts", "import": "./dist/adapter/bun/index.js", "require": "./dist/adapter/bun/index.cjs" }, "./adapter/node": { - "types": "./dist/adapter/node/index.d.ts", + "types": "./dist/types/adapter/node/index.d.ts", "import": "./dist/adapter/node/index.js", "require": "./dist/adapter/node/index.cjs" }, "./adapter/astro": { - "types": "./dist/adapter/astro/index.d.ts", + "types": "./dist/types/adapter/astro/index.d.ts", "import": "./dist/adapter/astro/index.js", "require": "./dist/adapter/astro/index.cjs" }, diff --git a/app/src/Api.ts b/app/src/Api.ts index 5196622..5e288fe 100644 --- a/app/src/Api.ts +++ b/app/src/Api.ts @@ -128,6 +128,14 @@ export class Api { }; } + async getVerifiedAuthState(force?: boolean): Promise { + if (force === true || !this.verified) { + await this.verifyAuth(); + } + + return this.getAuthState(); + } + async verifyAuth() { try { const res = await this.auth.me(); diff --git a/app/src/App.ts b/app/src/App.ts index d180a51..9c856da 100644 --- a/app/src/App.ts +++ b/app/src/App.ts @@ -10,15 +10,19 @@ import * as SystemPermissions from "modules/permissions"; import { AdminController, type AdminControllerOptions } from "modules/server/AdminController"; import { SystemController } from "modules/server/SystemController"; -export type AppPlugin = (app: App) => void; +export type AppPlugin = (app: App) => Promise | void; -export class AppConfigUpdatedEvent extends Event<{ app: App }> { +abstract class AppEvent extends Event<{ app: App } & A> {} +export class AppConfigUpdatedEvent extends AppEvent { static override slug = "app-config-updated"; } -export class AppBuiltEvent extends Event<{ app: App }> { +export class AppBuiltEvent extends AppEvent { static override slug = "app-built"; } -export const AppEvents = { AppConfigUpdatedEvent, AppBuiltEvent } as const; +export class AppFirstBoot extends AppEvent { + static override slug = "app-first-boot"; +} +export const AppEvents = { AppConfigUpdatedEvent, AppBuiltEvent, AppFirstBoot } as const; export type CreateAppConfig = { connection?: @@ -28,29 +32,42 @@ export type CreateAppConfig = { config: LibSqlCredentials; }; initialConfig?: InitialModuleConfigs; - plugins?: AppPlugin[]; + plugins?: AppPlugin[]; options?: Omit; }; export type AppConfig = InitialModuleConfigs; -export class App { +export class App { modules: ModuleManager; static readonly Events = AppEvents; + adminController?: AdminController; + private trigger_first_boot = false; constructor( private connection: Connection, _initialConfig?: InitialModuleConfigs, - private plugins: AppPlugin[] = [], + private plugins: AppPlugin[] = [], moduleManagerOptions?: ModuleManagerOptions ) { this.modules = new ModuleManager(connection, { ...moduleManagerOptions, initial: _initialConfig, onUpdated: async (key, config) => { - //console.log("[APP] config updated", key, config); + // if the EventManager was disabled, we assume we shouldn't + // respond to events, such as "onUpdated". + if (!this.emgr.enabled) { + console.warn("[APP] config updated, but event manager is disabled, skip."); + return; + } + + console.log("[APP] config updated", key); await this.build({ sync: true, save: true }); await this.emgr.emit(new AppConfigUpdatedEvent({ app: this })); + }, + onFirstBoot: async () => { + console.log("[APP] first boot"); + this.trigger_first_boot = true; } }); this.modules.ctx().emgr.registerEvents(AppEvents); @@ -76,7 +93,7 @@ export class App { // load plugins if (this.plugins.length > 0) { - this.plugins.forEach((plugin) => plugin(this)); + await Promise.all(this.plugins.map((plugin) => plugin(this))); } //console.log("emitting built", options); @@ -88,14 +105,24 @@ export class App { if (options?.save) { await this.modules.save(); } + + // first boot is set from ModuleManager when there wasn't a config table + if (this.trigger_first_boot) { + this.trigger_first_boot = false; + await this.emgr.emit(new AppFirstBoot({ app: this })); + } } mutateConfig(module: Module) { return this.modules.get(module).schema(); } + get server() { + return this.modules.server; + } + get fetch(): any { - return this.modules.server.fetch; + return this.server.fetch; } get module() { @@ -119,7 +146,8 @@ export class App { registerAdminController(config?: AdminControllerOptions) { // register admin - this.modules.server.route("/", new AdminController(this, config).getController()); + this.adminController = new AdminController(this, config); + this.modules.server.route("/", this.adminController.getController()); return this; } diff --git a/app/src/adapter/astro/astro.adapter.ts b/app/src/adapter/astro/astro.adapter.ts index f86410a..479b873 100644 --- a/app/src/adapter/astro/astro.adapter.ts +++ b/app/src/adapter/astro/astro.adapter.ts @@ -1,4 +1,7 @@ -import { Api, type ApiOptions, App, type CreateAppConfig } from "bknd"; +import { type FrameworkBkndConfig, createFrameworkApp } from "adapter"; +import { Api, type ApiOptions, type App } from "bknd"; + +export type AstroBkndConfig = FrameworkBkndConfig; type TAstro = { request: Request; @@ -18,12 +21,10 @@ export function getApi(Astro: TAstro, options: Options = { mode: "static" }) { } let app: App; -export function serve(config: CreateAppConfig) { +export function serve(config: AstroBkndConfig = {}) { return async (args: TAstro) => { if (!app) { - app = App.create(config); - - await app.build(); + app = await createFrameworkApp(config); } return app.fetch(args.request); }; diff --git a/app/src/adapter/bun/bun.adapter.ts b/app/src/adapter/bun/bun.adapter.ts index ae14be5..7e1334c 100644 --- a/app/src/adapter/bun/bun.adapter.ts +++ b/app/src/adapter/bun/bun.adapter.ts @@ -1,56 +1,60 @@ /// import path from "node:path"; -import { App, type CreateAppConfig } from "bknd"; -import type { Serve, ServeOptions } from "bun"; +import type { App } from "bknd"; +import type { ServeOptions } from "bun"; +import { config } from "core"; import { serveStatic } from "hono/bun"; +import { type RuntimeBkndConfig, createRuntimeApp } from "../index"; let app: App; -export async function createApp(_config: Partial = {}, distPath?: string) { + +export type BunBkndConfig = RuntimeBkndConfig & Omit; + +export async function createApp({ + distPath, + onBuilt, + buildConfig, + beforeBuild, + ...config +}: RuntimeBkndConfig = {}) { const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static"); if (!app) { - app = App.create(_config); - - app.emgr.on( - "app-built", - async () => { - app.modules.server.get( - "/*", - serveStatic({ - root - }) - ); - app.registerAdminController(); - }, - "sync" - ); - - await app.build(); + app = await createRuntimeApp({ + ...config, + registerLocalMedia: true, + serveStatic: serveStatic({ root }) + }); } return app; } -export type BunAdapterOptions = Omit & - CreateAppConfig & { - distPath?: string; - }; - export function serve({ distPath, connection, initialConfig, plugins, options, - port = 1337, + port = config.server.default_port, + onBuilt, + buildConfig, ...serveOptions -}: BunAdapterOptions = {}) { +}: BunBkndConfig = {}) { Bun.serve({ ...serveOptions, port, fetch: async (request: Request) => { - const app = await createApp({ connection, initialConfig, plugins, options }, distPath); + const app = await createApp({ + connection, + initialConfig, + plugins, + options, + onBuilt, + buildConfig, + distPath + }); return app.fetch(request); } }); diff --git a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts index 0d2126a..da3c762 100644 --- a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts +++ b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts @@ -1,21 +1,37 @@ -import { DurableObject } from "cloudflare:workers"; -import { App, type CreateAppConfig } from "bknd"; +import type { CreateAppConfig } from "bknd"; import { Hono } from "hono"; import { serveStatic } from "hono/cloudflare-workers"; -import type { BkndConfig, CfBkndModeCache } from "../index"; +import type { FrameworkBkndConfig } from "../index"; +import { getCached } from "./modes/cached"; +import { getDurable } from "./modes/durable"; +import { getFresh, getWarm } from "./modes/fresh"; -type Context = { - request: Request; - env: any; - ctx: ExecutionContext; - manifest: any; +export type CloudflareBkndConfig = Omit & { + app: CreateAppConfig | ((env: Env) => CreateAppConfig); + mode?: "warm" | "fresh" | "cache" | "durable"; + bindings?: (env: Env) => { + kv?: KVNamespace; + dobj?: DurableObjectNamespace; + }; + key?: string; + keepAliveSeconds?: number; + forceHttps?: boolean; + manifest?: string; + setAdminHtml?: boolean; html?: string; }; -export function serve(_config: BkndConfig, manifest?: string, html?: string) { +export type Context = { + request: Request; + env: any; + ctx: ExecutionContext; +}; + +export function serve(config: CloudflareBkndConfig) { return { async fetch(request: Request, env: any, ctx: ExecutionContext) { const url = new URL(request.url); + const manifest = config.manifest; if (manifest) { const pathname = url.pathname.slice(1); @@ -26,13 +42,10 @@ export function serve(_config: BkndConfig, manifest?: string, html?: string) { hono.all("*", async (c, next) => { const res = await serveStatic({ path: `./${pathname}`, - manifest, - onNotFound: (path) => console.log("not found", path) + manifest })(c as any, next); if (res instanceof Response) { - const ttl = pathname.startsWith("assets/") - ? 60 * 60 * 24 * 365 // 1 year - : 60 * 5; // 5 minutes + const ttl = 60 * 60 * 24 * 365; res.headers.set("Cache-Control", `public, max-age=${ttl}`); return res; } @@ -44,218 +57,23 @@ export function serve(_config: BkndConfig, manifest?: string, html?: string) { } } - const config = { - ..._config, - setAdminHtml: _config.setAdminHtml ?? !!manifest - }; - const context = { request, env, ctx, manifest, html }; - const mode = config.cloudflare?.mode?.(env); + config.setAdminHtml = config.setAdminHtml && !!config.manifest; - if (!mode) { - console.log("serving fresh..."); - const app = await getFresh(config, context); - return app.fetch(request, env); - } else if ("cache" in mode) { - console.log("serving cached..."); - const app = await getCached(config as any, context); - return app.fetch(request, env); - } else if ("durableObject" in mode) { - console.log("serving durable..."); + const context = { request, env, ctx } as Context; + const mode = config.mode ?? "warm"; - if (config.onBuilt) { - console.log("onBuilt() is not supported with DurableObject mode"); - } - - const start = performance.now(); - - const durable = mode.durableObject; - const id = durable.idFromName(mode.key); - const stub = durable.get(id) as unknown as DurableBkndApp; - - const create_config = typeof config.app === "function" ? config.app(env) : config.app; - - const res = await stub.fire(request, { - config: create_config, - html, - keepAliveSeconds: mode.keepAliveSeconds, - setAdminHtml: config.setAdminHtml - }); - - const headers = new Headers(res.headers); - headers.set("X-TTDO", String(performance.now() - start)); - - return new Response(res.body, { - status: res.status, - statusText: res.statusText, - headers - }); + switch (mode) { + case "fresh": + return await getFresh(config, context); + case "warm": + return await getWarm(config, context); + case "cache": + return await getCached(config, context); + case "durable": + return await getDurable(config, context); + default: + throw new Error(`Unknown mode ${mode}`); } } }; } - -async function getFresh(config: BkndConfig, { env, html }: Context) { - const create_config = typeof config.app === "function" ? config.app(env) : config.app; - const app = App.create(create_config); - - if (config.onBuilt) { - app.emgr.onEvent( - App.Events.AppBuiltEvent, - async ({ params: { app } }) => { - config.onBuilt!(app); - }, - "sync" - ); - } - await app.build(); - - if (config.setAdminHtml) { - app.registerAdminController({ html }); - } - - return app; -} - -async function getCached( - config: BkndConfig & { cloudflare: { mode: CfBkndModeCache } }, - { env, html, ctx }: Context -) { - const { cache, key } = config.cloudflare.mode(env) as ReturnType; - const create_config = typeof config.app === "function" ? config.app(env) : config.app; - - const cachedConfig = await cache.get(key); - const initialConfig = cachedConfig ? JSON.parse(cachedConfig) : undefined; - - const app = App.create({ ...create_config, initialConfig }); - - async function saveConfig(__config: any) { - ctx.waitUntil(cache.put(key, JSON.stringify(__config))); - } - - if (config.onBuilt) { - app.emgr.onEvent( - App.Events.AppBuiltEvent, - async ({ params: { app } }) => { - app.module.server.client.get("/__bknd/cache", async (c) => { - await cache.delete(key); - return c.json({ message: "Cache cleared" }); - }); - app.registerAdminController({ html }); - - config.onBuilt!(app); - }, - "sync" - ); - } - - app.emgr.onEvent( - App.Events.AppConfigUpdatedEvent, - async ({ params: { app } }) => { - saveConfig(app.toJSON(true)); - }, - "sync" - ); - - await app.build(); - - if (config.setAdminHtml) { - app.registerAdminController({ html }); - } - - if (!cachedConfig) { - saveConfig(app.toJSON(true)); - } - - return app; -} - -export class DurableBkndApp extends DurableObject { - protected id = Math.random().toString(36).slice(2); - protected app?: App; - protected interval?: any; - - async fire( - request: Request, - options: { - config: CreateAppConfig; - html?: string; - keepAliveSeconds?: number; - setAdminHtml?: boolean; - } - ) { - let buildtime = 0; - if (!this.app) { - const start = performance.now(); - const config = options.config; - - // change protocol to websocket if libsql - if ( - config?.connection && - "type" in config.connection && - config.connection.type === "libsql" - ) { - config.connection.config.protocol = "wss"; - } - - this.app = App.create(config); - this.app.emgr.onEvent( - App.Events.AppBuiltEvent, - async ({ params: { app } }) => { - app.modules.server.get("/__do", async (c) => { - // @ts-ignore - const context: any = c.req.raw.cf ? c.req.raw.cf : c.env.cf; - return c.json({ - id: this.id, - keepAlive: options?.keepAliveSeconds, - colo: context.colo - }); - }); - }, - "sync" - ); - - await this.app.build(); - - buildtime = performance.now() - start; - } - - if (options?.keepAliveSeconds) { - this.keepAlive(options.keepAliveSeconds); - } - - console.log("id", this.id); - const res = await this.app!.fetch(request); - const headers = new Headers(res.headers); - headers.set("X-BuildTime", buildtime.toString()); - headers.set("X-DO-ID", this.id); - - return new Response(res.body, { - status: res.status, - statusText: res.statusText, - headers - }); - } - - protected keepAlive(seconds: number) { - console.log("keep alive for", seconds); - if (this.interval) { - console.log("clearing, there is a new"); - clearInterval(this.interval); - } - - let i = 0; - this.interval = setInterval(() => { - i += 1; - //console.log("keep-alive", i); - if (i === seconds) { - console.log("cleared"); - clearInterval(this.interval); - - // ping every 30 seconds - } else if (i % 30 === 0) { - console.log("ping"); - this.app?.modules.ctx().connection.ping(); - } - }, 1000); - } -} diff --git a/app/src/adapter/cloudflare/index.ts b/app/src/adapter/cloudflare/index.ts index f2d3cdd..c2dd1c5 100644 --- a/app/src/adapter/cloudflare/index.ts +++ b/app/src/adapter/cloudflare/index.ts @@ -1 +1,4 @@ export * from "./cloudflare-workers.adapter"; +export { makeApp, getFresh, getWarm } from "./modes/fresh"; +export { getCached } from "./modes/cached"; +export { DurableBkndApp, getDurable } from "./modes/durable"; diff --git a/app/src/adapter/cloudflare/modes/cached.ts b/app/src/adapter/cloudflare/modes/cached.ts new file mode 100644 index 0000000..a238ae0 --- /dev/null +++ b/app/src/adapter/cloudflare/modes/cached.ts @@ -0,0 +1,48 @@ +import { createRuntimeApp } from "adapter"; +import { App } from "bknd"; +import type { CloudflareBkndConfig, Context } from "../index"; + +export async function getCached(config: CloudflareBkndConfig, { env, ctx }: Context) { + const { kv } = config.bindings?.(env)!; + if (!kv) throw new Error("kv namespace is not defined in cloudflare.bindings"); + const key = config.key ?? "app"; + + const cachedConfig = await kv.get(key); + const initialConfig = cachedConfig ? JSON.parse(cachedConfig) : undefined; + + async function saveConfig(__config: any) { + ctx.waitUntil(kv!.put(key, JSON.stringify(__config))); + } + + const app = await createRuntimeApp( + { + ...config, + initialConfig, + onBuilt: async (app) => { + app.module.server.client.get("/__bknd/cache", async (c) => { + await kv.delete(key); + return c.json({ message: "Cache cleared" }); + }); + await config.onBuilt?.(app); + }, + beforeBuild: async (app) => { + app.emgr.onEvent( + App.Events.AppConfigUpdatedEvent, + async ({ params: { app } }) => { + saveConfig(app.toJSON(true)); + }, + "sync" + ); + await config.beforeBuild?.(app); + }, + adminOptions: { html: config.html } + }, + env + ); + + if (!cachedConfig) { + saveConfig(app.toJSON(true)); + } + + return app; +} diff --git a/app/src/adapter/cloudflare/modes/durable.ts b/app/src/adapter/cloudflare/modes/durable.ts new file mode 100644 index 0000000..3787b5c --- /dev/null +++ b/app/src/adapter/cloudflare/modes/durable.ts @@ -0,0 +1,133 @@ +import { DurableObject } from "cloudflare:workers"; +import { createRuntimeApp } from "adapter"; +import type { CloudflareBkndConfig, Context } from "adapter/cloudflare"; +import type { App, CreateAppConfig } from "bknd"; + +export async function getDurable(config: CloudflareBkndConfig, ctx: Context) { + const { dobj } = config.bindings?.(ctx.env)!; + if (!dobj) throw new Error("durable object is not defined in cloudflare.bindings"); + const key = config.key ?? "app"; + + if ([config.onBuilt, config.beforeBuild].some((x) => x)) { + console.log("onBuilt and beforeBuild are not supported with DurableObject mode"); + } + + const start = performance.now(); + + const id = dobj.idFromName(key); + const stub = dobj.get(id) as unknown as DurableBkndApp; + + const create_config = typeof config.app === "function" ? config.app(ctx.env) : config.app; + + const res = await stub.fire(ctx.request, { + config: create_config, + html: config.html, + keepAliveSeconds: config.keepAliveSeconds, + setAdminHtml: config.setAdminHtml + }); + + const headers = new Headers(res.headers); + headers.set("X-TTDO", String(performance.now() - start)); + + return new Response(res.body, { + status: res.status, + statusText: res.statusText, + headers + }); +} + +export class DurableBkndApp extends DurableObject { + protected id = Math.random().toString(36).slice(2); + protected app?: App; + protected interval?: any; + + async fire( + request: Request, + options: { + config: CreateAppConfig; + html?: string; + keepAliveSeconds?: number; + setAdminHtml?: boolean; + } + ) { + let buildtime = 0; + if (!this.app) { + const start = performance.now(); + const config = options.config; + + // change protocol to websocket if libsql + if ( + config?.connection && + "type" in config.connection && + config.connection.type === "libsql" + ) { + config.connection.config.protocol = "wss"; + } + + this.app = await createRuntimeApp({ + ...config, + onBuilt: async (app) => { + app.modules.server.get("/__do", async (c) => { + // @ts-ignore + const context: any = c.req.raw.cf ? c.req.raw.cf : c.env.cf; + return c.json({ + id: this.id, + keepAliveSeconds: options?.keepAliveSeconds ?? 0, + colo: context.colo + }); + }); + + await this.onBuilt(app); + }, + adminOptions: { html: options.html }, + beforeBuild: async (app) => { + await this.beforeBuild(app); + } + }); + + buildtime = performance.now() - start; + } + + if (options?.keepAliveSeconds) { + this.keepAlive(options.keepAliveSeconds); + } + + console.log("id", this.id); + const res = await this.app!.fetch(request); + const headers = new Headers(res.headers); + headers.set("X-BuildTime", buildtime.toString()); + headers.set("X-DO-ID", this.id); + + return new Response(res.body, { + status: res.status, + statusText: res.statusText, + headers + }); + } + + async onBuilt(app: App) {} + async beforeBuild(app: App) {} + + protected keepAlive(seconds: number) { + console.log("keep alive for", seconds); + if (this.interval) { + console.log("clearing, there is a new"); + clearInterval(this.interval); + } + + let i = 0; + this.interval = setInterval(() => { + i += 1; + //console.log("keep-alive", i); + if (i === seconds) { + console.log("cleared"); + clearInterval(this.interval); + + // ping every 30 seconds + } else if (i % 30 === 0) { + console.log("ping"); + this.app?.modules.ctx().connection.ping(); + } + }, 1000); + } +} diff --git a/app/src/adapter/cloudflare/modes/fresh.ts b/app/src/adapter/cloudflare/modes/fresh.ts new file mode 100644 index 0000000..cb5ece7 --- /dev/null +++ b/app/src/adapter/cloudflare/modes/fresh.ts @@ -0,0 +1,27 @@ +import { createRuntimeApp } from "adapter"; +import type { App } from "bknd"; +import type { CloudflareBkndConfig, Context } from "../index"; + +export async function makeApp(config: CloudflareBkndConfig, { env }: Context) { + return await createRuntimeApp( + { + ...config, + adminOptions: config.html ? { html: config.html } : undefined + }, + env + ); +} + +export async function getFresh(config: CloudflareBkndConfig, ctx: Context) { + const app = await makeApp(config, ctx); + return app.fetch(ctx.request); +} + +let warm_app: App; +export async function getWarm(config: CloudflareBkndConfig, ctx: Context) { + if (!warm_app) { + warm_app = await makeApp(config, ctx); + } + + return warm_app.fetch(ctx.request); +} diff --git a/app/src/adapter/index.ts b/app/src/adapter/index.ts index b4b3682..8b86f0e 100644 --- a/app/src/adapter/index.ts +++ b/app/src/adapter/index.ts @@ -1,40 +1,20 @@ import type { IncomingMessage } from "node:http"; -import type { App, CreateAppConfig } from "bknd"; +import { App, type CreateAppConfig, registries } from "bknd"; +import type { MiddlewareHandler } from "hono"; +import { StorageLocalAdapter } from "media/storage/adapters/StorageLocalAdapter"; +import type { AdminControllerOptions } from "modules/server/AdminController"; -export type CfBkndModeCache = (env: Env) => { - cache: KVNamespace; - key: string; -}; - -export type CfBkndModeDurableObject = (env: Env) => { - durableObject: DurableObjectNamespace; - key: string; - keepAliveSeconds?: number; -}; - -export type CloudflareBkndConfig = { - mode?: CfBkndModeCache | CfBkndModeDurableObject; - forceHttps?: boolean; -}; - -// @todo: move to App -export type BkndConfig = { - app: CreateAppConfig | ((env: Env) => CreateAppConfig); - setAdminHtml?: boolean; - server?: { - port?: number; - platform?: "node" | "bun"; - }; - cloudflare?: CloudflareBkndConfig; +export type BkndConfig = CreateAppConfig & { + app?: CreateAppConfig | ((env: Env) => CreateAppConfig); onBuilt?: (app: App) => Promise; + beforeBuild?: (app: App) => Promise; + buildConfig?: Parameters[0]; }; -export type BkndConfigJson = { - app: CreateAppConfig; - setAdminHtml?: boolean; - server?: { - port?: number; - }; +export type FrameworkBkndConfig = BkndConfig; + +export type RuntimeBkndConfig = BkndConfig & { + distPath?: string; }; export function nodeRequestToRequest(req: IncomingMessage): Request { @@ -60,3 +40,90 @@ export function nodeRequestToRequest(req: IncomingMessage): Request { headers }); } + +export function registerLocalMediaAdapter() { + registries.media.register("local", StorageLocalAdapter); +} + +export function makeConfig(config: BkndConfig, env?: Env): CreateAppConfig { + let additionalConfig: CreateAppConfig = {}; + if ("app" in config && config.app) { + if (typeof config.app === "function") { + if (!env) { + throw new Error("env is required when config.app is a function"); + } + additionalConfig = config.app(env); + } else { + additionalConfig = config.app; + } + } + + return { ...config, ...additionalConfig }; +} + +export async function createFrameworkApp( + config: FrameworkBkndConfig, + env?: Env +): Promise { + const app = App.create(makeConfig(config, env)); + + if (config.onBuilt) { + app.emgr.onEvent( + App.Events.AppBuiltEvent, + async () => { + await config.onBuilt?.(app); + }, + "sync" + ); + } + + await config.beforeBuild?.(app); + await app.build(config.buildConfig); + + return app; +} + +export async function createRuntimeApp( + { + serveStatic, + registerLocalMedia, + adminOptions, + ...config + }: RuntimeBkndConfig & { + serveStatic?: MiddlewareHandler | [string, MiddlewareHandler]; + registerLocalMedia?: boolean; + adminOptions?: AdminControllerOptions | false; + }, + env?: Env +): Promise { + if (registerLocalMedia) { + registerLocalMediaAdapter(); + } + + const app = App.create(makeConfig(config, env)); + + app.emgr.onEvent( + App.Events.AppBuiltEvent, + async () => { + if (serveStatic) { + if (Array.isArray(serveStatic)) { + const [path, handler] = serveStatic; + app.modules.server.get(path, handler); + } else { + app.modules.server.get("/*", serveStatic); + } + } + + await config.onBuilt?.(app); + if (adminOptions !== false) { + app.registerAdminController(adminOptions); + } + }, + "sync" + ); + + await config.beforeBuild?.(app); + await app.build(config.buildConfig); + + return app; +} diff --git a/app/src/adapter/nextjs/nextjs.adapter.ts b/app/src/adapter/nextjs/nextjs.adapter.ts index a888210..eaee4ab 100644 --- a/app/src/adapter/nextjs/nextjs.adapter.ts +++ b/app/src/adapter/nextjs/nextjs.adapter.ts @@ -1,6 +1,8 @@ import type { IncomingMessage, ServerResponse } from "node:http"; -import { Api, App, type CreateAppConfig } from "bknd"; -import { nodeRequestToRequest } from "../index"; +import { Api, type App } from "bknd"; +import { type FrameworkBkndConfig, createFrameworkApp, nodeRequestToRequest } from "../index"; + +export type NextjsBkndConfig = FrameworkBkndConfig; type GetServerSidePropsContext = { req: IncomingMessage; @@ -18,7 +20,6 @@ type GetServerSidePropsContext = { export function createApi({ req }: GetServerSidePropsContext) { const request = nodeRequestToRequest(req); - //console.log("createApi:request.headers", request.headers); return new Api({ host: new URL(request.url).origin, headers: request.headers @@ -43,11 +44,10 @@ function getCleanRequest(req: Request) { } let app: App; -export function serve(config: CreateAppConfig) { +export function serve(config: NextjsBkndConfig = {}) { return async (req: Request) => { if (!app) { - app = App.create(config); - await app.build(); + app = await createFrameworkApp(config); } const request = getCleanRequest(req); return app.fetch(request, process.env); diff --git a/app/src/adapter/node/index.ts b/app/src/adapter/node/index.ts index 47d4c97..bc5b7e5 100644 --- a/app/src/adapter/node/index.ts +++ b/app/src/adapter/node/index.ts @@ -1,59 +1,6 @@ -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"; - -export type NodeAdapterOptions = CreateAppConfig & { - relativeDistPath?: string; - port?: number; - hostname?: string; - listener?: Parameters[1]; -}; - -export function serve({ - relativeDistPath, - port = 1337, - hostname, - listener, - ...config -}: NodeAdapterOptions = {}) { - const root = path.relative( - process.cwd(), - path.resolve(relativeDistPath ?? "./node_modules/bknd/dist", "static") - ); - let app: App; - - honoServe( - { - port, - hostname, - fetch: async (req: Request) => { - if (!app) { - app = App.create(config); - - app.emgr.on( - "app-built", - async () => { - app.modules.server.get( - "/*", - serveStatic({ - root - }) - ); - app.registerAdminController(); - }, - "sync" - ); - - await app.build(); - } - - return app.fetch(req); - } - }, - (connInfo) => { - console.log(`Server is running on http://localhost:${connInfo.port}`); - listener?.(connInfo); - } - ); -} +export * from "./node.adapter"; +export { + StorageLocalAdapter, + type LocalAdapterConfig +} from "../../media/storage/adapters/StorageLocalAdapter"; +export { registerLocalMediaAdapter } from "../index"; diff --git a/app/src/adapter/node/node.adapter.ts b/app/src/adapter/node/node.adapter.ts new file mode 100644 index 0000000..835b886 --- /dev/null +++ b/app/src/adapter/node/node.adapter.ts @@ -0,0 +1,58 @@ +import path from "node:path"; +import { serve as honoServe } from "@hono/node-server"; +import { serveStatic } from "@hono/node-server/serve-static"; +import type { App } from "bknd"; +import { config as $config } from "core"; +import { type RuntimeBkndConfig, createRuntimeApp } from "../index"; + +export type NodeBkndConfig = RuntimeBkndConfig & { + port?: number; + hostname?: string; + listener?: Parameters[1]; + /** @deprecated */ + relativeDistPath?: string; +}; + +export function serve({ + distPath, + relativeDistPath, + port = $config.server.default_port, + hostname, + listener, + onBuilt, + buildConfig = {}, + beforeBuild, + ...config +}: NodeBkndConfig = {}) { + const root = path.relative( + process.cwd(), + path.resolve(distPath ?? relativeDistPath ?? "./node_modules/bknd/dist", "static") + ); + if (relativeDistPath) { + console.warn("relativeDistPath is deprecated, please use distPath instead"); + } + + let app: App; + + honoServe( + { + port, + hostname, + fetch: async (req: Request) => { + if (!app) { + app = await createRuntimeApp({ + ...config, + registerLocalMedia: true, + serveStatic: serveStatic({ root }) + }); + } + + return app.fetch(req); + } + }, + (connInfo) => { + console.log(`Server is running on http://localhost:${connInfo.port}`); + listener?.(connInfo); + } + ); +} diff --git a/app/src/adapter/remix/remix.adapter.ts b/app/src/adapter/remix/remix.adapter.ts index 80d6b93..c3d0c78 100644 --- a/app/src/adapter/remix/remix.adapter.ts +++ b/app/src/adapter/remix/remix.adapter.ts @@ -1,11 +1,13 @@ -import { App, type CreateAppConfig } from "bknd"; +import { type FrameworkBkndConfig, createFrameworkApp } from "adapter"; +import type { App } from "bknd"; + +export type RemixBkndConfig = FrameworkBkndConfig; let app: App; -export function serve(config: CreateAppConfig) { +export function serve(config: RemixBkndConfig = {}) { return async (args: { request: Request }) => { if (!app) { - app = App.create(config); - await app.build(); + app = await createFrameworkApp(config); } return app.fetch(args.request); }; diff --git a/app/src/adapter/vite/vite.adapter.ts b/app/src/adapter/vite/vite.adapter.ts index 6faaefe..e94ece6 100644 --- a/app/src/adapter/vite/vite.adapter.ts +++ b/app/src/adapter/vite/vite.adapter.ts @@ -1,47 +1,57 @@ import { serveStatic } from "@hono/node-server/serve-static"; -import type { BkndConfig } from "bknd"; -import { App } from "bknd"; +import { type RuntimeBkndConfig, createRuntimeApp } from "adapter"; +import type { App } from "bknd"; -function createApp(config: BkndConfig, env: any) { - const create_config = typeof config.app === "function" ? config.app(env) : config.app; - return App.create(create_config); -} +export type ViteBkndConfig = RuntimeBkndConfig & { + setAdminHtml?: boolean; + forceDev?: boolean; + html?: string; +}; -function setAppBuildListener(app: App, config: BkndConfig, html?: string) { - app.emgr.on( - "app-built", - async () => { - await config.onBuilt?.(app); - if (config.setAdminHtml) { - app.registerAdminController({ html, forceDev: true }); - app.module.server.client.get("/assets/*", serveStatic({ root: "./" })); - } - }, - "sync" +export function addViteScript(html: string, addBkndContext: boolean = true) { + return html.replace( + "", + ` + +${addBkndContext ? "" : ""} +` ); } -export async function serveFresh(config: BkndConfig, _html?: string) { +async function createApp(config: ViteBkndConfig, env?: any) { + return await createRuntimeApp( + { + ...config, + adminOptions: config.setAdminHtml + ? { html: config.html, forceDev: config.forceDev } + : undefined, + serveStatic: ["/assets/*", serveStatic({ root: config.distPath ?? "./" })] + }, + env + ); +} + +export async function serveFresh(config: ViteBkndConfig) { return { async fetch(request: Request, env: any, ctx: ExecutionContext) { - const app = createApp(config, env); - - setAppBuildListener(app, config, _html); - await app.build(); - + const app = await createApp(config, env); return app.fetch(request, env, ctx); } }; } let app: App; -export async function serveCached(config: BkndConfig, _html?: string) { +export async function serveCached(config: ViteBkndConfig) { return { async fetch(request: Request, env: any, ctx: ExecutionContext) { if (!app) { - app = createApp(config, env); - setAppBuildListener(app, config, _html); - await app.build(); + app = await createApp(config, env); } return app.fetch(request, env, ctx); diff --git a/app/src/auth/AppAuth.ts b/app/src/auth/AppAuth.ts index ba7b00d..797d061 100644 --- a/app/src/auth/AppAuth.ts +++ b/app/src/auth/AppAuth.ts @@ -1,5 +1,6 @@ import { type AuthAction, Authenticator, type ProfileExchange, Role, type Strategy } from "auth"; -import { Exception } from "core"; +import type { PasswordStrategy } from "auth/authenticate/strategies"; +import { Exception, type PrimaryFieldType } from "core"; import { type Static, secureRandomString, transformObject } from "core/utils"; import { type Entity, EntityIndex, type EntityManager } from "data"; import { type FieldSchema, entity, enumm, make, text } from "data/prototype"; @@ -9,9 +10,9 @@ import { AuthController } from "./api/AuthController"; import { type AppAuthSchema, STRATEGIES, authConfigSchema } from "./auth-schema"; export type UserFieldSchema = FieldSchema; -declare global { +declare module "core" { interface DB { - users: UserFieldSchema; + users: { id: PrimaryFieldType } & UserFieldSchema; } } @@ -100,7 +101,7 @@ export class AppAuth extends Module { return this._authenticator!; } - get em(): EntityManager { + get em(): EntityManager { return this.ctx.em as any; } @@ -160,7 +161,9 @@ export class AppAuth extends Module { const users = this.getUsersEntity(); this.toggleStrategyValueVisibility(true); - const result = await this.em.repo(users).findOne({ email: profile.email! }); + const result = await this.em + .repo(users as unknown as "users") + .findOne({ email: profile.email! }); this.toggleStrategyValueVisibility(false); if (!result.data) { throw new Exception("User not found", 404); @@ -197,7 +200,7 @@ export class AppAuth extends Module { throw new Exception("User already exists"); } - const payload = { + const payload: any = { ...profile, strategy: strategy.getName(), strategy_value: identifier @@ -284,6 +287,25 @@ export class AppAuth extends Module { } catch (e) {} } + async createUser({ + email, + password, + ...additional + }: { email: string; password: string; [key: string]: any }) { + const strategy = "password"; + const pw = this.authenticator.strategy(strategy) as PasswordStrategy; + const strategy_value = await pw.hash(password); + const mutator = this.em.mutator(this.config.entity_name as "users"); + mutator.__unstable_toggleSystemEntityCreation(false); + const { data: created } = await mutator.insertOne({ + ...(additional as any), + strategy, + strategy_value + }); + mutator.__unstable_toggleSystemEntityCreation(true); + return created; + } + override toJSON(secrets?: boolean): AppAuthSchema { if (!this.config.enabled) { return this.configDefault; diff --git a/app/src/auth/authenticate/Authenticator.ts b/app/src/auth/authenticate/Authenticator.ts index 426023b..46fa586 100644 --- a/app/src/auth/authenticate/Authenticator.ts +++ b/app/src/auth/authenticate/Authenticator.ts @@ -220,15 +220,23 @@ export class Authenticator = Record< } private async getAuthCookie(c: Context): Promise { - const secret = this.config.jwt.secret; + try { + const secret = this.config.jwt.secret; + + const token = await getSignedCookie(c, secret, "auth"); + if (typeof token !== "string") { + await deleteCookie(c, "auth", this.cookieOptions); + return undefined; + } + + return token; + } catch (e: any) { + if (e instanceof Error) { + console.error("[Error:getAuthCookie]", e.message); + } - const token = await getSignedCookie(c, secret, "auth"); - if (typeof token !== "string") { - await deleteCookie(c, "auth", this.cookieOptions); return undefined; } - - return token; } async requestCookieRefresh(c: Context) { diff --git a/app/src/cli/commands/run/run.ts b/app/src/cli/commands/run/run.ts index 7ad9568..0b6c843 100644 --- a/app/src/cli/commands/run/run.ts +++ b/app/src/cli/commands/run/run.ts @@ -1,8 +1,10 @@ import type { Config } from "@libsql/client/node"; import { App, type CreateAppConfig } from "App"; -import type { BkndConfig } from "adapter"; -import type { CliCommand } from "cli/types"; +import { StorageLocalAdapter } from "adapter/node"; +import type { CliBkndConfig, CliCommand } from "cli/types"; import { Option } from "commander"; +import { config } from "core"; +import { registries } from "modules/registries"; import { PLATFORMS, type Platform, @@ -19,7 +21,7 @@ export const run: CliCommand = (program) => { .addOption( new Option("-p, --port ", "port to run on") .env("PORT") - .default(1337) + .default(config.server.default_port) .argParser((v) => Number.parseInt(v)) ) .addOption(new Option("-c, --config ", "config file")) @@ -37,6 +39,12 @@ export const run: CliCommand = (program) => { .action(action); }; +// automatically register local adapter +const local = StorageLocalAdapter.prototype.getName(); +if (!registries.media.has(local)) { + registries.media.register(local, StorageLocalAdapter); +} + type MakeAppConfig = { connection?: CreateAppConfig["connection"]; server?: { platform?: Platform }; @@ -47,8 +55,8 @@ type MakeAppConfig = { async function makeApp(config: MakeAppConfig) { const app = App.create({ connection: config.connection }); - app.emgr.on( - "app-built", + app.emgr.onEvent( + App.Events.AppBuiltEvent, async () => { await attachServeStatic(app, config.server?.platform ?? "node"); app.registerAdminController(); @@ -64,24 +72,23 @@ async function makeApp(config: MakeAppConfig) { return app; } -export async function makeConfigApp(config: BkndConfig, platform?: Platform) { +export async function makeConfigApp(config: CliBkndConfig, platform?: Platform) { const appConfig = typeof config.app === "function" ? config.app(process.env) : config.app; const app = App.create(appConfig); - app.emgr.on( - "app-built", + app.emgr.onEvent( + App.Events.AppBuiltEvent, async () => { await attachServeStatic(app, platform ?? "node"); app.registerAdminController(); - if (config.onBuilt) { - await config.onBuilt(app); - } + await config.onBuilt?.(app); }, "sync" ); - await app.build(); + await config.beforeBuild?.(app); + await app.build(config.buildConfig); return app; } @@ -102,7 +109,7 @@ async function action(options: { app = await makeApp({ connection, server: { platform: options.server } }); } else { console.log("Using config from:", configFilePath); - const config = (await import(configFilePath).then((m) => m.default)) as BkndConfig; + const config = (await import(configFilePath).then((m) => m.default)) as CliBkndConfig; app = await makeConfigApp(config, options.server); } diff --git a/app/src/cli/commands/user.ts b/app/src/cli/commands/user.ts index cdf51a1..0883c67 100644 --- a/app/src/cli/commands/user.ts +++ b/app/src/cli/commands/user.ts @@ -1,9 +1,9 @@ import { password as $password, text as $text } from "@clack/prompts"; +import type { App } from "App"; import type { PasswordStrategy } from "auth/authenticate/strategies"; -import type { App, BkndConfig } from "bknd"; import { makeConfigApp } from "cli/commands/run"; import { getConfigPath } from "cli/commands/run/platform"; -import type { CliCommand } from "cli/types"; +import type { CliBkndConfig, CliCommand } from "cli/types"; import { Argument } from "commander"; export const user: CliCommand = (program) => { @@ -21,7 +21,7 @@ async function action(action: "create" | "update", options: any) { return; } - const config = (await import(configFilePath).then((m) => m.default)) as BkndConfig; + const config = (await import(configFilePath).then((m) => m.default)) as CliBkndConfig; const app = await makeConfigApp(config, options.server); switch (action) { @@ -37,7 +37,7 @@ async function action(action: "create" | "update", options: any) { async function create(app: App, options: any) { const config = app.module.auth.toJSON(true); const strategy = app.module.auth.authenticator.strategy("password") as PasswordStrategy; - const users_entity = config.entity_name; + const users_entity = config.entity_name as "users"; const email = await $text({ message: "Enter email", @@ -83,7 +83,7 @@ async function create(app: App, options: any) { async function update(app: App, options: any) { const config = app.module.auth.toJSON(true); const strategy = app.module.auth.authenticator.strategy("password") as PasswordStrategy; - const users_entity = config.entity_name; + const users_entity = config.entity_name as "users"; const em = app.modules.ctx().em; const email = (await $text({ diff --git a/app/src/cli/types.d.ts b/app/src/cli/types.d.ts index 6dd97aa..30bde3a 100644 --- a/app/src/cli/types.d.ts +++ b/app/src/cli/types.d.ts @@ -1,3 +1,14 @@ +import type { CreateAppConfig } from "App"; +import type { FrameworkBkndConfig } from "adapter"; import type { Command } from "commander"; export type CliCommand = (program: Command) => void; + +export type CliBkndConfig = FrameworkBkndConfig & { + app: CreateAppConfig | ((env: Env) => CreateAppConfig); + setAdminHtml?: boolean; + server?: { + port?: number; + platform?: "node" | "bun"; + }; +}; diff --git a/app/src/core/config.ts b/app/src/core/config.ts index 445b67c..9a70a5c 100644 --- a/app/src/core/config.ts +++ b/app/src/core/config.ts @@ -5,7 +5,13 @@ import type { Generated } from "kysely"; export type PrimaryFieldType = number | Generated; +// biome-ignore lint/suspicious/noEmptyInterface: +export interface DB {} + export const config = { + server: { + default_port: 1337 + }, data: { default_primary_field: "id" } diff --git a/app/src/core/events/EventManager.ts b/app/src/core/events/EventManager.ts index 3b85d23..9233666 100644 --- a/app/src/core/events/EventManager.ts +++ b/app/src/core/events/EventManager.ts @@ -15,6 +15,7 @@ export class EventManager< > { protected events: EventClass[] = []; protected listeners: EventListener[] = []; + enabled: boolean = true; constructor(events?: RegisteredEvents, listeners?: EventListener[]) { if (events) { @@ -28,6 +29,16 @@ export class EventManager< } } + enable() { + this.enabled = true; + return this; + } + + disable() { + this.enabled = false; + return this; + } + clearEvents() { this.events = []; return this; @@ -39,6 +50,10 @@ export class EventManager< return this; } + getListeners(): EventListener[] { + return [...this.listeners]; + } + get Events(): { [K in keyof RegisteredEvents]: RegisteredEvents[K] } { // proxy class to access events return new Proxy(this, { @@ -133,6 +148,11 @@ export class EventManager< async emit(event: Event) { // @ts-expect-error slug is static const slug = event.constructor.slug; + if (!this.enabled) { + console.log("EventManager disabled, not emitting", slug); + return; + } + if (!this.eventExists(event)) { throw new Error(`Event "${slug}" not registered`); } diff --git a/app/src/core/index.ts b/app/src/core/index.ts index e296c1d..330e9fe 100644 --- a/app/src/core/index.ts +++ b/app/src/core/index.ts @@ -3,7 +3,7 @@ import type { Hono, MiddlewareHandler } from "hono"; export { tbValidator } from "./server/lib/tbValidator"; export { Exception, BkndError } from "./errors"; export { isDebug } from "./env"; -export { type PrimaryFieldType, config } from "./config"; +export { type PrimaryFieldType, config, type DB } from "./config"; export { AwsClient } from "./clients/aws/AwsClient"; export { SimpleRenderer, diff --git a/app/src/core/object/SchemaObject.ts b/app/src/core/object/SchemaObject.ts index c70ef28..aad5b14 100644 --- a/app/src/core/object/SchemaObject.ts +++ b/app/src/core/object/SchemaObject.ts @@ -69,7 +69,8 @@ export class SchemaObject { forceParse: true, skipMark: this.isForceParse() }); - const updatedConfig = noEmit ? valid : await this.onBeforeUpdate(this._config, valid); + // regardless of "noEmit" – this should always be triggered + const updatedConfig = await this.onBeforeUpdate(this._config, valid); this._value = updatedConfig; this._config = Object.freeze(updatedConfig); diff --git a/app/src/core/registry/Registry.ts b/app/src/core/registry/Registry.ts index 12209ca..b895f51 100644 --- a/app/src/core/registry/Registry.ts +++ b/app/src/core/registry/Registry.ts @@ -1,29 +1,50 @@ export type Constructor = new (...args: any[]) => T; -export class Registry = Record> { + +export type RegisterFn = (unknown: any) => Item; + +export class Registry< + Item, + Items extends Record = Record, + Fn extends RegisterFn = RegisterFn +> { private is_set: boolean = false; private items: Items = {} as Items; - set>(items: Actual) { + constructor(private registerFn?: Fn) {} + + set>(items: Actual) { if (this.is_set) { throw new Error("Registry is already set"); } - // @ts-ignore - this.items = items; + this.items = items as unknown as Items; this.is_set = true; - return this as unknown as Registry; + return this as unknown as Registry; } add(name: string, item: Item) { - // @ts-ignore - this.items[name] = item; + this.items[name as keyof Items] = item as Items[keyof Items]; return this; } + register(name: string, specific: Parameters[0]) { + if (this.registerFn) { + const item = this.registerFn(specific); + this.items[name as keyof Items] = item as Items[keyof Items]; + return this; + } + + return this.add(name, specific); + } + get(name: Name): Items[Name] { return this.items[name]; } + has(name: keyof Items): boolean { + return name in this.items; + } + all() { return this.items; } diff --git a/app/src/core/utils/DebugLogger.ts b/app/src/core/utils/DebugLogger.ts index aada58d..e58b45d 100644 --- a/app/src/core/utils/DebugLogger.ts +++ b/app/src/core/utils/DebugLogger.ts @@ -20,11 +20,16 @@ export class DebugLogger { return this; } + reset() { + this.last = 0; + return this; + } + log(...args: any[]) { if (!this._enabled) return this; const now = performance.now(); - const time = Number.parseInt(String(now - this.last)); + const time = this.last === 0 ? 0 : Number.parseInt(String(now - this.last)); const indents = " ".repeat(this._context.length); const context = this._context.length > 0 ? `[${this._context[this._context.length - 1]}]` : ""; diff --git a/app/src/core/utils/test.ts b/app/src/core/utils/test.ts index cf33e1a..b06ac55 100644 --- a/app/src/core/utils/test.ts +++ b/app/src/core/utils/test.ts @@ -9,10 +9,25 @@ export async function withDisabledConsole( fn: () => Promise, severities: ConsoleSeverity[] = ["log"] ): Promise { - const enable = disableConsoleLog(severities); - const result = await fn(); - enable(); - return result; + const _oldConsoles = { + log: console.log, + warn: console.warn, + error: console.error + }; + disableConsoleLog(severities); + const enable = () => { + Object.entries(_oldConsoles).forEach(([severity, fn]) => { + console[severity as ConsoleSeverity] = fn; + }); + }; + try { + const result = await fn(); + enable(); + return result; + } catch (e) { + enable(); + throw e; + } } export function disableConsoleLog(severities: ConsoleSeverity[] = ["log"]) { diff --git a/app/src/data/AppData.ts b/app/src/data/AppData.ts index 821a3c6..df90b57 100644 --- a/app/src/data/AppData.ts +++ b/app/src/data/AppData.ts @@ -1,52 +1,20 @@ import { transformObject } from "core/utils"; -import { DataPermissions, Entity, EntityIndex, type EntityManager, type Field } from "data"; +import { + DataPermissions, + type Entity, + EntityIndex, + type EntityManager, + constructEntity, + constructRelation +} from "data"; import { Module } from "modules/Module"; import { DataController } from "./api/DataController"; -import { - type AppDataConfig, - FIELDS, - RELATIONS, - type TAppDataEntity, - type TAppDataRelation, - dataConfigSchema -} from "./data-schema"; - -export class AppData extends Module { - static constructEntity(name: string, entityConfig: TAppDataEntity) { - const fields = transformObject(entityConfig.fields ?? {}, (fieldConfig, name) => { - const { type } = fieldConfig; - if (!(type in FIELDS)) { - throw new Error(`Field type "${type}" not found`); - } - - const { field } = FIELDS[type as any]; - const returnal = new field(name, fieldConfig.config) as Field; - return returnal; - }); - - // @todo: entity must be migrated to typebox - return new Entity( - name, - Object.values(fields), - entityConfig.config as any, - entityConfig.type as any - ); - } - - static constructRelation( - relationConfig: TAppDataRelation, - resolver: (name: Entity | string) => Entity - ) { - return new RELATIONS[relationConfig.type].cls( - resolver(relationConfig.source), - resolver(relationConfig.target), - relationConfig.config - ); - } +import { type AppDataConfig, dataConfigSchema } from "./data-schema"; +export class AppData extends Module { override async build() { const entities = transformObject(this.config.entities ?? {}, (entityConfig, name) => { - return AppData.constructEntity(name, entityConfig); + return constructEntity(name, entityConfig); }); const _entity = (_e: Entity | string): Entity => { @@ -57,7 +25,7 @@ export class AppData extends Module { }; const relations = transformObject(this.config.relations ?? {}, (relation) => - AppData.constructRelation(relation, _entity) + constructRelation(relation, _entity) ); const indices = transformObject(this.config.indices ?? {}, (index, name) => { @@ -91,7 +59,7 @@ export class AppData extends Module { return dataConfigSchema; } - get em(): EntityManager { + get em(): EntityManager { this.throwIfNotBuilt(); return this.ctx.em; } diff --git a/app/src/data/api/DataApi.ts b/app/src/data/api/DataApi.ts index 967a5f1..47144c2 100644 --- a/app/src/data/api/DataApi.ts +++ b/app/src/data/api/DataApi.ts @@ -1,3 +1,4 @@ +import type { DB } from "core"; import type { EntityData, RepoQuery, RepositoryResponse } from "data"; import { type BaseModuleApiOptions, ModuleApi, type PrimaryFieldType } from "modules"; @@ -15,48 +16,60 @@ export class DataApi extends ModuleApi { }; } - readOne( - entity: string, + readOne( + entity: E, id: PrimaryFieldType, query: Partial> = {} ) { - return this.get>([entity, id], query); + return this.get, "meta" | "data">>([entity as any, id], query); } - readMany(entity: string, query: Partial = {}) { - return this.get>( - [entity], - query ?? this.options.defaultQuery - ); - } - - readManyByReference( - entity: string, - id: PrimaryFieldType, - reference: string, + readMany( + entity: E, query: Partial = {} ) { - return this.get>( - [entity, id, reference], + return this.get, "meta" | "data">>( + [entity as any], query ?? this.options.defaultQuery ); } - createOne(entity: string, input: EntityData) { - return this.post>([entity], input); + readManyByReference< + E extends keyof DB | string, + R extends keyof DB | string, + Data = R extends keyof DB ? DB[R] : EntityData + >(entity: E, id: PrimaryFieldType, reference: R, query: Partial = {}) { + return this.get, "meta" | "data">>( + [entity as any, id, reference], + query ?? this.options.defaultQuery + ); } - updateOne(entity: string, id: PrimaryFieldType, input: EntityData) { - return this.patch>([entity, id], input); + createOne( + entity: E, + input: Omit + ) { + return this.post>([entity as any], input); } - deleteOne(entity: string, id: PrimaryFieldType) { - return this.delete>([entity, id]); + updateOne( + entity: E, + id: PrimaryFieldType, + input: Partial> + ) { + return this.patch>([entity as any, id], input); } - count(entity: string, where: RepoQuery["where"] = {}) { - return this.post>( - [entity, "fn", "count"], + deleteOne( + entity: E, + id: PrimaryFieldType + ) { + return this.delete>([entity as any, id]); + } + + count(entity: E, where: RepoQuery["where"] = {}) { + return this.post>( + [entity as any, "fn", "count"], where ); } diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index 3f459dc..68a5417 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -1,5 +1,5 @@ import { type ClassController, isDebug, tbValidator as tb } from "core"; -import { Type, objectCleanEmpty, objectTransform } from "core/utils"; +import { StringEnum, Type, objectCleanEmpty, objectTransform } from "core/utils"; import { DataPermissions, type EntityData, @@ -165,13 +165,12 @@ export class DataController implements ClassController { // read entity schema .get("/schema.json", async (c) => { this.guard.throwUnlessGranted(DataPermissions.entityRead); - const url = new URL(c.req.url); - const $id = `${url.origin}${this.config.basepath}/schema.json`; + const $id = `${this.config.basepath}/schema.json`; const schemas = Object.fromEntries( this.em.entities.map((e) => [ e.name, { - $ref: `schemas/${e.name}` + $ref: `${this.config.basepath}/schemas/${e.name}` } ]) ); @@ -183,22 +182,28 @@ export class DataController implements ClassController { }) // read schema .get( - "/schemas/:entity", - tb("param", Type.Object({ entity: Type.String() })), + "/schemas/:entity/:context?", + tb( + "param", + Type.Object({ + entity: Type.String(), + context: Type.Optional(StringEnum(["create", "update"])) + }) + ), async (c) => { this.guard.throwUnlessGranted(DataPermissions.entityRead); //console.log("request", c.req.raw); - const { entity } = c.req.param(); + const { entity, context } = c.req.param(); if (!this.entityExists(entity)) { console.log("not found", entity, definedEntities); return c.notFound(); } const _entity = this.em.entity(entity); - const schema = _entity.toSchema(); + const schema = _entity.toSchema({ context } as any); const url = new URL(c.req.url); const base = `${url.origin}${this.config.basepath}`; - const $id = `${base}/schemas/${entity}`; + const $id = `${this.config.basepath}/schemas/${entity}`; return c.json({ $schema: `${base}/schema.json`, $id, diff --git a/app/src/data/connection/DummyConnection.ts b/app/src/data/connection/DummyConnection.ts new file mode 100644 index 0000000..451575d --- /dev/null +++ b/app/src/data/connection/DummyConnection.ts @@ -0,0 +1,7 @@ +import { Connection } from "./Connection"; + +export class DummyConnection extends Connection { + constructor() { + super(undefined as any); + } +} diff --git a/app/src/data/entities/Entity.ts b/app/src/data/entities/Entity.ts index 0a285e7..aa3d75c 100644 --- a/app/src/data/entities/Entity.ts +++ b/app/src/data/entities/Entity.ts @@ -158,7 +158,7 @@ export class Entity< } get label(): string { - return snakeToPascalWithSpaces(this.config.name || this.name); + return this.config.name ?? snakeToPascalWithSpaces(this.name); } field(name: string): Field | undefined { @@ -210,20 +210,34 @@ export class Entity< return true; } - toSchema(clean?: boolean): object { - const fields = Object.fromEntries(this.fields.map((field) => [field.name, field])); + toSchema(options?: { clean: boolean; context?: "create" | "update" }): object { + let fields: Field[]; + switch (options?.context) { + case "create": + case "update": + fields = this.getFillableFields(options.context); + break; + default: + fields = this.getFields(true); + } + + const _fields = Object.fromEntries(fields.map((field) => [field.name, field])); const schema = Type.Object( - transformObject(fields, (field) => ({ - title: field.config.label, - $comment: field.config.description, - $field: field.type, - readOnly: !field.isFillable("update") ? true : undefined, - writeOnly: !field.isFillable("create") ? true : undefined, - ...field.toJsonSchema() - })) + transformObject(_fields, (field) => { + //const hidden = field.isHidden(options?.context); + const fillable = field.isFillable(options?.context); + return { + title: field.config.label, + $comment: field.config.description, + $field: field.type, + readOnly: !fillable ? true : undefined, + ...field.toJsonSchema() + }; + }), + { additionalProperties: false } ); - return clean ? JSON.parse(JSON.stringify(schema)) : schema; + return options?.clean ? JSON.parse(JSON.stringify(schema)) : schema; } toJSON() { diff --git a/app/src/data/entities/EntityManager.ts b/app/src/data/entities/EntityManager.ts index 674d7e2..fea93aa 100644 --- a/app/src/data/entities/EntityManager.ts +++ b/app/src/data/entities/EntityManager.ts @@ -1,3 +1,4 @@ +import type { DB as DefaultDB } from "core"; import { EventManager } from "core/events"; import { sql } from "kysely"; import { Connection } from "../connection/Connection"; @@ -14,7 +15,18 @@ import { SchemaManager } from "../schema/SchemaManager"; import { Entity } from "./Entity"; import { type EntityData, Mutator, Repository } from "./index"; -export class EntityManager { +type EntitySchema< + TBD extends object = DefaultDB, + E extends Entity | keyof TBD | string = string +> = E extends Entity + ? Name extends keyof TBD + ? Name + : never + : E extends keyof TBD + ? E + : never; + +export class EntityManager { connection: Connection; private _entities: Entity[] = []; @@ -50,7 +62,7 @@ export class EntityManager { * Forks the EntityManager without the EventManager. * This is useful when used inside an event handler. */ - fork(): EntityManager { + fork(): EntityManager { return new EntityManager(this._entities, this.connection, this._relations, this._indices); } @@ -87,10 +99,17 @@ export class EntityManager { this.entities.push(entity); } - entity(name: string): Entity { - const entity = this.entities.find((e) => e.name === name); + entity(e: Entity | keyof TBD | string): Entity { + let entity: Entity | undefined; + if (typeof e === "string") { + entity = this.entities.find((entity) => entity.name === e); + } else if (e instanceof Entity) { + entity = e; + } + if (!entity) { - throw new EntityNotDefinedException(name); + // @ts-ignore + throw new EntityNotDefinedException(e instanceof Entity ? e.name : e); } return entity; @@ -162,28 +181,18 @@ export class EntityManager { return this.relations.relationReferencesOf(this.entity(entity_name)); } - repository(_entity: Entity | string) { - const entity = _entity instanceof Entity ? _entity : this.entity(_entity); - return new Repository(this, entity, this.emgr); + repository( + entity: E + ): Repository> { + return this.repo(entity); } - repo( - _entity: E - ): Repository< - DB, - E extends Entity ? (Name extends keyof DB ? Name : never) : never - > { - return new Repository(this, _entity, this.emgr); + repo(entity: E): Repository> { + return new Repository(this, this.entity(entity), this.emgr); } - _repo(_entity: TB): Repository { - const entity = this.entity(_entity as any); - return new Repository(this, entity, this.emgr); - } - - mutator(_entity: Entity | string) { - const entity = _entity instanceof Entity ? _entity : this.entity(_entity); - return new Mutator(this, entity, this.emgr); + mutator(entity: E): Mutator> { + return new Mutator(this, this.entity(entity), this.emgr); } addIndex(index: EntityIndex, force = false) { diff --git a/app/src/data/entities/Mutator.ts b/app/src/data/entities/Mutator.ts index ed7f9ef..d9bff38 100644 --- a/app/src/data/entities/Mutator.ts +++ b/app/src/data/entities/Mutator.ts @@ -1,4 +1,4 @@ -import type { PrimaryFieldType } from "core"; +import type { DB as DefaultDB, PrimaryFieldType } from "core"; import { type EmitsEvents, EventManager } from "core/events"; import type { DeleteQueryBuilder, InsertQueryBuilder, UpdateQueryBuilder } from "kysely"; import { type TActionContext, WhereBuilder } from ".."; @@ -25,8 +25,14 @@ export type MutatorResponse = { data: T; }; -export class Mutator implements EmitsEvents { - em: EntityManager; +export class Mutator< + TBD extends object = DefaultDB, + TB extends keyof TBD = any, + Output = TBD[TB], + Input = Omit +> implements EmitsEvents +{ + em: EntityManager; entity: Entity; static readonly Events = MutatorEvents; emgr: EventManager; @@ -37,7 +43,7 @@ export class Mutator implements EmitsEvents { this.__unstable_disable_system_entity_creation = value; } - constructor(em: EntityManager, entity: Entity, emgr?: EventManager) { + constructor(em: EntityManager, entity: Entity, emgr?: EventManager) { this.em = em; this.entity = entity; this.emgr = emgr ?? new EventManager(MutatorEvents); @@ -47,13 +53,13 @@ export class Mutator implements EmitsEvents { return this.em.connection.kysely; } - async getValidatedData(data: EntityData, context: TActionContext): Promise { + async getValidatedData(data: Given, context: TActionContext): Promise { const entity = this.entity; if (!context) { throw new Error("Context must be provided for validation"); } - const keys = Object.keys(data); + const keys = Object.keys(data as any); const validatedData: EntityData = {}; // get relational references/keys @@ -95,7 +101,7 @@ export class Mutator implements EmitsEvents { throw new Error(`No data left to update "${entity.name}"`); } - return validatedData; + return validatedData as Given; } protected async many(qb: MutatorQB): Promise { @@ -120,7 +126,7 @@ export class Mutator implements EmitsEvents { return { ...response, data: data[0]! }; } - async insertOne(data: EntityData): Promise> { + async insertOne(data: Input): Promise> { const entity = this.entity; if (entity.type === "system" && this.__unstable_disable_system_entity_creation) { throw new Error(`Creation of system entity "${entity.name}" is disabled`); @@ -154,10 +160,10 @@ export class Mutator implements EmitsEvents { await this.emgr.emit(new Mutator.Events.MutatorInsertAfter({ entity, data: res.data })); - return res; + return res as any; } - async updateOne(id: PrimaryFieldType, data: EntityData): Promise> { + async updateOne(id: PrimaryFieldType, data: Partial): Promise> { const entity = this.entity; if (!Number.isInteger(id)) { throw new Error("ID must be provided for update"); @@ -166,12 +172,16 @@ export class Mutator implements EmitsEvents { const validatedData = await this.getValidatedData(data, "update"); await this.emgr.emit( - new Mutator.Events.MutatorUpdateBefore({ entity, entityId: id, data: validatedData }) + new Mutator.Events.MutatorUpdateBefore({ + entity, + entityId: id, + data: validatedData as any + }) ); const query = this.conn .updateTable(entity.name) - .set(validatedData) + .set(validatedData as any) .where(entity.id().name, "=", id) .returning(entity.getSelect()); @@ -181,10 +191,10 @@ export class Mutator implements EmitsEvents { new Mutator.Events.MutatorUpdateAfter({ entity, entityId: id, data: res.data }) ); - return res; + return res as any; } - async deleteOne(id: PrimaryFieldType): Promise> { + async deleteOne(id: PrimaryFieldType): Promise> { const entity = this.entity; if (!Number.isInteger(id)) { throw new Error("ID must be provided for deletion"); @@ -203,7 +213,7 @@ export class Mutator implements EmitsEvents { new Mutator.Events.MutatorDeleteAfter({ entity, entityId: id, data: res.data }) ); - return res; + return res as any; } private getValidOptions(options?: Partial): Partial { @@ -250,47 +260,62 @@ export class Mutator implements EmitsEvents { } // @todo: decide whether entries should be deleted all at once or one by one (for events) - async deleteWhere(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( entity.getSelect() ); - //await this.emgr.emit(new Mutator.Events.MutatorDeleteBefore({ entity, entityId: id })); - - const res = await this.many(qb); - - /*await this.emgr.emit( - new Mutator.Events.MutatorDeleteAfter({ entity, entityId: id, data: res.data }) - );*/ - - return res; + return (await this.many(qb)) as any; } async updateWhere( - data: EntityData, + data: Partial, where?: RepoQuery["where"] - ): Promise> { + ): 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) + .set(validatedData as any) .returning(entity.getSelect()); - const res = await this.many(query); + return (await this.many(query)) as any; + } - /*await this.emgr.emit( - new Mutator.Events.MutatorUpdateAfter({ entity, entityId: id, data: res.data }) - );*/ + async insertMany(data: Input[]): Promise> { + const entity = this.entity; + if (entity.type === "system" && this.__unstable_disable_system_entity_creation) { + throw new Error(`Creation of system entity "${entity.name}" is disabled`); + } - return res; + const validated: any[] = []; + for (const row of data) { + const validatedData = { + ...entity.getDefaultObject(), + ...(await this.getValidatedData(row, "create")) + }; + + // check if required fields are present + const required = entity.getRequiredFields(); + for (const field of required) { + if ( + typeof validatedData[field.name] === "undefined" || + validatedData[field.name] === null + ) { + throw new Error(`Field "${field.name}" is required`); + } + } + + validated.push(validatedData); + } + + const query = this.conn + .insertInto(entity.name) + .values(validated) + .returning(entity.getSelect()); + + return (await this.many(query)) as any; } } diff --git a/app/src/data/entities/query/Repository.ts b/app/src/data/entities/query/Repository.ts index f5b576c..171fc3b 100644 --- a/app/src/data/entities/query/Repository.ts +++ b/app/src/data/entities/query/Repository.ts @@ -1,4 +1,4 @@ -import type { PrimaryFieldType } from "core"; +import type { DB as DefaultDB, PrimaryFieldType } from "core"; import { type EmitsEvents, EventManager } from "core/events"; import { type SelectQueryBuilder, sql } from "kysely"; import { cloneDeep } from "lodash-es"; @@ -43,13 +43,15 @@ export type RepositoryExistsResponse = RepositoryRawResponse & { exists: boolean; }; -export class Repository implements EmitsEvents { - em: EntityManager; +export class Repository + implements EmitsEvents +{ + em: EntityManager; entity: Entity; static readonly Events = RepositoryEvents; emgr: EventManager; - constructor(em: EntityManager, entity: Entity, emgr?: EventManager) { + constructor(em: EntityManager, entity: Entity, emgr?: EventManager) { this.em = em; this.entity = entity; this.emgr = emgr ?? new EventManager(MutatorEvents); @@ -272,7 +274,7 @@ export class Repository implements EmitsEve async findId( id: PrimaryFieldType, _options?: Partial> - ): Promise> { + ): Promise> { const { qb, options } = this.buildQuery( { ..._options, @@ -288,7 +290,7 @@ export class Repository implements EmitsEve async findOne( where: RepoQuery["where"], _options?: Partial> - ): Promise> { + ): Promise> { const { qb, options } = this.buildQuery({ ..._options, where, @@ -298,7 +300,7 @@ export class Repository implements EmitsEve return this.single(qb, options) as any; } - async findMany(_options?: Partial): Promise> { + async findMany(_options?: Partial): Promise> { const { qb, options } = this.buildQuery(_options); //console.log("findMany:options", options); diff --git a/app/src/data/fields/TextField.ts b/app/src/data/fields/TextField.ts index 6314618..6dc17d3 100644 --- a/app/src/data/fields/TextField.ts +++ b/app/src/data/fields/TextField.ts @@ -104,6 +104,12 @@ export class TextField extends Field< ); } + if (this.config.pattern && value && !new RegExp(this.config.pattern).test(value)) { + throw new TransformPersistFailedException( + `Field "${this.name}" must match the pattern ${this.config.pattern}` + ); + } + return value; } diff --git a/app/src/data/index.ts b/app/src/data/index.ts index 284c653..3a287e6 100644 --- a/app/src/data/index.ts +++ b/app/src/data/index.ts @@ -18,6 +18,8 @@ export { LibsqlConnection, type LibSqlCredentials } from "./connection/LibsqlCon export { SqliteConnection } from "./connection/SqliteConnection"; export { SqliteLocalConnection } from "./connection/SqliteLocalConnection"; +export { constructEntity, constructRelation } from "./schema/constructor"; + export const DatabaseEvents = { ...MutatorEvents, ...RepositoryEvents diff --git a/app/src/data/prototype/index.ts b/app/src/data/prototype/index.ts index d526c2e..e9e868f 100644 --- a/app/src/data/prototype/index.ts +++ b/app/src/data/prototype/index.ts @@ -1,3 +1,8 @@ +import { DummyConnection } from "data/connection/DummyConnection"; +import { EntityManager } from "data/entities/EntityManager"; +import type { Generated } from "kysely"; +import { MediaField, type MediaFieldConfig, type MediaItem } from "media/MediaField"; +import type { ModuleConfigs } from "modules"; import { BooleanField, type BooleanFieldConfig, @@ -5,6 +10,8 @@ import { type DateFieldConfig, Entity, type EntityConfig, + EntityIndex, + type EntityRelation, EnumField, type EnumFieldConfig, type Field, @@ -25,15 +32,14 @@ import { type TEntityType, TextField, type TextFieldConfig -} from "data"; -import type { Generated } from "kysely"; -import { MediaField, type MediaFieldConfig, type MediaItem } from "media/MediaField"; +} from "../index"; type Options = { entity: { name: string; fields: Record> }; field_name: string; config: Config; is_required: boolean; + another?: string; }; const FieldMap = { @@ -239,7 +245,89 @@ export function relation(local: Local) { }; } -type InferEntityFields = T extends Entity +export function index(entity: E) { + return { + on: (fields: (keyof InsertSchema)[], unique?: boolean) => { + const _fields = fields.map((f) => { + const field = entity.field(f as any); + if (!field) { + throw new Error(`Field "${String(f)}" not found on entity "${entity.name}"`); + } + return field; + }); + return new EntityIndex(entity, _fields, unique); + } + }; +} + +class EntityManagerPrototype> extends EntityManager< + Schema +> { + constructor( + public __entities: Entities, + relations: EntityRelation[] = [], + indices: EntityIndex[] = [] + ) { + super(Object.values(__entities), new DummyConnection(), relations, indices); + } +} + +type Chained any, Rt = ReturnType> = ( + e: E +) => { + [K in keyof Rt]: Rt[K] extends (...args: any[]) => any + ? (...args: Parameters) => Rt + : never; +}; + +export function em>( + entities: Entities, + schema?: ( + fns: { relation: Chained; index: Chained }, + entities: Entities + ) => void +) { + const relations: EntityRelation[] = []; + const indices: EntityIndex[] = []; + + const relationProxy = (e: Entity) => { + return new Proxy(relation(e), { + get(target, prop) { + return (...args: any[]) => { + relations.push(target[prop](...args)); + return relationProxy(e); + }; + } + }) as any; + }; + + const indexProxy = (e: Entity) => { + return new Proxy(index(e), { + get(target, prop) { + return (...args: any[]) => { + indices.push(target[prop](...args)); + return indexProxy(e); + }; + } + }) as any; + }; + + if (schema) { + schema({ relation: relationProxy, index: indexProxy }, entities); + } + + const e = new EntityManagerPrototype(entities, relations, indices); + return { + DB: e.__entities as unknown as Schemas, + entities: e.__entities, + relations, + indices, + toJSON: () => + e.toJSON() as unknown as Pick + }; +} + +export type InferEntityFields = T extends Entity ? { [K in keyof Fields]: Fields[K] extends { _type: infer Type; _required: infer Required } ? Required extends true @@ -284,12 +372,16 @@ type OptionalUndefined< } >; -type InferField = Field extends { _type: infer Type; _required: infer Required } +export type InferField = Field extends { _type: infer Type; _required: infer Required } ? Required extends true ? Type : Type | undefined : never; +export type Schemas> = { + [K in keyof T]: Schema; +}; + export type InsertSchema = Simplify>>; -export type Schema = { id: Generated } & InsertSchema; +export type Schema = Simplify<{ id: Generated } & InsertSchema>; export type FieldSchema = Simplify>>; diff --git a/app/src/data/schema/constructor.ts b/app/src/data/schema/constructor.ts new file mode 100644 index 0000000..a88ba5e --- /dev/null +++ b/app/src/data/schema/constructor.ts @@ -0,0 +1,34 @@ +import { transformObject } from "core/utils"; +import { Entity, type Field } from "data"; +import { FIELDS, RELATIONS, type TAppDataEntity, type TAppDataRelation } from "data/data-schema"; + +export function constructEntity(name: string, entityConfig: TAppDataEntity) { + const fields = transformObject(entityConfig.fields ?? {}, (fieldConfig, name) => { + const { type } = fieldConfig; + if (!(type in FIELDS)) { + throw new Error(`Field type "${type}" not found`); + } + + const { field } = FIELDS[type as any]; + const returnal = new field(name, fieldConfig.config) as Field; + return returnal; + }); + + return new Entity( + name, + Object.values(fields), + entityConfig.config as any, + entityConfig.type as any + ); +} + +export function constructRelation( + relationConfig: TAppDataRelation, + resolver: (name: Entity | string) => Entity +) { + return new RELATIONS[relationConfig.type].cls( + resolver(relationConfig.source), + resolver(relationConfig.target), + relationConfig.config + ); +} diff --git a/app/src/index.ts b/app/src/index.ts index 578ab5d..1e5b71d 100644 --- a/app/src/index.ts +++ b/app/src/index.ts @@ -4,8 +4,12 @@ export { getDefaultConfig, getDefaultSchema, type ModuleConfigs, - type ModuleSchemas -} from "modules/ModuleManager"; + type ModuleSchemas, + type ModuleManagerOptions, + type ModuleBuildContext +} from "./modules/ModuleManager"; + +export { registries } from "modules/registries"; export type * from "./adapter"; export { Api, type ApiOptions } from "./Api"; diff --git a/app/src/media/AppMedia.ts b/app/src/media/AppMedia.ts index 1e8e692..789dae9 100644 --- a/app/src/media/AppMedia.ts +++ b/app/src/media/AppMedia.ts @@ -1,24 +1,15 @@ +import type { PrimaryFieldType } from "core"; import { EntityIndex, type EntityManager } from "data"; import { type FileUploadedEventData, Storage, type StorageAdapter } from "media"; import { Module } from "modules/Module"; -import { - type FieldSchema, - type InferFields, - type Schema, - boolean, - datetime, - entity, - json, - number, - text -} from "../data/prototype"; +import { type FieldSchema, boolean, datetime, entity, json, number, text } from "../data/prototype"; import { MediaController } from "./api/MediaController"; import { ADAPTERS, buildMediaSchema, type mediaConfigSchema, registry } from "./media-schema"; export type MediaFieldSchema = FieldSchema; -declare global { +declare module "core" { interface DB { - media: MediaFieldSchema; + media: { id: PrimaryFieldType } & MediaFieldSchema; } } @@ -112,14 +103,14 @@ export class AppMedia extends Module { return this.em.entity(entity_name); } - get em(): EntityManager { + get em(): EntityManager { return this.ctx.em; } private setupListeners() { //const media = this._entity; const { emgr, em } = this.ctx; - const media = this.getMediaEntity(); + const media = this.getMediaEntity().name as "media"; // when file is uploaded, sync with media entity // @todo: need a way for singleton events! @@ -140,10 +131,10 @@ export class AppMedia extends Module { Storage.Events.FileDeletedEvent, async (e) => { // simple file deletion sync - const item = await em.repo(media).findOne({ path: e.params.name }); - if (item.data) { - console.log("item.data", item.data); - await em.mutator(media).deleteOne(item.data.id); + const { data } = await em.repo(media).findOne({ path: e.params.name }); + if (data) { + console.log("item.data", data); + await em.mutator(media).deleteOne(data.id); } console.log("App:storage:file deleted", e); diff --git a/app/src/media/api/MediaController.ts b/app/src/media/api/MediaController.ts index 9597759..2a1a304 100644 --- a/app/src/media/api/MediaController.ts +++ b/app/src/media/api/MediaController.ts @@ -174,7 +174,7 @@ export class MediaController implements ClassController { const result = await mutator.insertOne({ ...this.media.uploadedEventDataToMediaPayload(info), ...mediaRef - }); + } as any); mutator.__unstable_toggleSystemEntityCreation(true); // delete items if needed diff --git a/app/src/media/index.ts b/app/src/media/index.ts index bdc94c1..7a1cdc7 100644 --- a/app/src/media/index.ts +++ b/app/src/media/index.ts @@ -17,10 +17,6 @@ import { import { type S3AdapterConfig, StorageS3Adapter } from "./storage/adapters/StorageS3Adapter"; export { StorageS3Adapter, type S3AdapterConfig, StorageCloudinaryAdapter, type CloudinaryConfig }; -/*export { - StorageLocalAdapter, - type LocalAdapterConfig -} from "./storage/adapters/StorageLocalAdapter";*/ export * as StorageEvents from "./storage/events"; export { type FileUploadedEventData } from "./storage/events"; @@ -31,16 +27,12 @@ type ClassThatImplements = Constructor & { prototype: T }; export const MediaAdapterRegistry = new Registry<{ cls: ClassThatImplements; schema: TObject; -}>().set({ - s3: { - cls: StorageS3Adapter, - schema: StorageS3Adapter.prototype.getSchema() - }, - cloudinary: { - cls: StorageCloudinaryAdapter, - schema: StorageCloudinaryAdapter.prototype.getSchema() - } -}); +}>((cls: ClassThatImplements) => ({ + cls, + schema: cls.prototype.getSchema() as TObject +})) + .register("s3", StorageS3Adapter) + .register("cloudinary", StorageCloudinaryAdapter); export const Adapters = { s3: { diff --git a/app/src/media/storage/adapters/StorageLocalAdapter/StorageLocalAdapter.ts b/app/src/media/storage/adapters/StorageLocalAdapter/StorageLocalAdapter.ts index f6c1bb1..b6c2650 100644 --- a/app/src/media/storage/adapters/StorageLocalAdapter/StorageLocalAdapter.ts +++ b/app/src/media/storage/adapters/StorageLocalAdapter/StorageLocalAdapter.ts @@ -1,17 +1,11 @@ import { readFile, readdir, stat, unlink, writeFile } from "node:fs/promises"; import { type Static, Type, parse } from "core/utils"; -import type { - FileBody, - FileListObject, - FileMeta, - FileUploadPayload, - StorageAdapter -} from "../../Storage"; -import { guessMimeType } from "../../mime-types"; +import type { FileBody, FileListObject, FileMeta, StorageAdapter } from "../../Storage"; +import { guess } from "../../mime-types-tiny"; export const localAdapterConfig = Type.Object( { - path: Type.String() + path: Type.String({ default: "./" }) }, { title: "Local" } ); @@ -89,7 +83,7 @@ export class StorageLocalAdapter implements StorageAdapter { async getObject(key: string, headers: Headers): Promise { try { const content = await readFile(`${this.config.path}/${key}`); - const mimeType = guessMimeType(key); + const mimeType = guess(key); return new Response(content, { status: 200, @@ -111,7 +105,7 @@ export class StorageLocalAdapter implements StorageAdapter { async getObjectMeta(key: string): Promise { const stats = await stat(`${this.config.path}/${key}`); return { - type: guessMimeType(key) || "application/octet-stream", + type: guess(key) || "application/octet-stream", size: stats.size }; } diff --git a/app/src/media/storage/mime-types-tiny.ts b/app/src/media/storage/mime-types-tiny.ts new file mode 100644 index 0000000..a231734 --- /dev/null +++ b/app/src/media/storage/mime-types-tiny.ts @@ -0,0 +1,77 @@ +export const Q = { + video: ["mp4", "webm"], + audio: ["ogg"], + image: ["jpeg", "png", "gif", "webp", "bmp", "tiff"], + text: ["html", "css", "mdx", "yaml", "vcard", "csv", "vtt"], + application: ["zip", "xml", "toml", "json", "json5"], + font: ["woff", "woff2", "ttf", "otf"] +} as const; + +// reduced +const c = { + vnd: "vnd.openxmlformats-officedocument", + z: "application/x-7z-compressed", + t: (w = "plain") => `text/${w}`, + a: (w = "octet-stream") => `application/${w}`, + i: (w) => `image/${w}`, + v: (w) => `video/${w}` +} as const; +export const M = new Map([ + ["7z", c.z], + ["7zip", c.z], + ["ai", c.a("pdf")], + ["apk", c.a("vnd.android.package-archive")], + ["doc", c.a("msword")], + ["docx", `${c.vnd}.wordprocessingml.document`], + ["eps", c.a("postscript")], + ["epub", c.a("epub+zip")], + ["ini", c.t()], + ["jar", c.a("java-archive")], + ["jsonld", c.a("ld+json")], + ["jpg", c.i("jpeg")], + ["log", c.t()], + ["m3u", c.t()], + ["m3u8", c.a("vnd.apple.mpegurl")], + ["manifest", c.t("cache-manifest")], + ["md", c.t("markdown")], + ["mkv", c.v("x-matroska")], + ["mp3", c.a("mpeg")], + ["mobi", c.a("x-mobipocket-ebook")], + ["ppt", c.a("powerpoint")], + ["pptx", `${c.vnd}.presentationml.presentation`], + ["qt", c.v("quicktime")], + ["svg", c.i("svg+xml")], + ["tif", c.i("tiff")], + ["tsv", c.t("tab-separated-values")], + ["tgz", c.a("x-tar")], + ["txt", c.t()], + ["text", c.t()], + ["vcd", c.a("x-cdlink")], + ["vcs", c.t("x-vcalendar")], + ["wav", c.a("x-wav")], + ["webmanifest", c.a("manifest+json")], + ["xls", c.a("vnd.ms-excel")], + ["xlsx", `${c.vnd}.spreadsheetml.sheet`], + ["yml", c.t("yaml")] +]); + +export function guess(f: string): string { + try { + const e = f.split(".").pop() as string; + if (!e) { + return c.a(); + } + + // try quick first + for (const [t, _e] of Object.entries(Q)) { + // @ts-ignore + if (_e.includes(e)) { + return `${t}/${e}`; + } + } + + return M.get(e!) as string; + } catch (e) { + return c.a(); + } +} diff --git a/app/src/modules/Module.ts b/app/src/modules/Module.ts index ecdf4ce..32f098c 100644 --- a/app/src/modules/Module.ts +++ b/app/src/modules/Module.ts @@ -8,7 +8,7 @@ import type { Hono } from "hono"; export type ModuleBuildContext = { connection: Connection; server: Hono; - em: EntityManager; + em: EntityManager; emgr: EventManager; guard: Guard; }; diff --git a/app/src/modules/ModuleManager.ts b/app/src/modules/ModuleManager.ts index db7b285..566384c 100644 --- a/app/src/modules/ModuleManager.ts +++ b/app/src/modules/ModuleManager.ts @@ -1,5 +1,5 @@ import { Guard } from "auth"; -import { BkndError, DebugLogger, Exception, isDebug } from "core"; +import { BkndError, DebugLogger } from "core"; import { EventManager } from "core/events"; import { clone, diff } from "core/object/diff"; import { @@ -35,9 +35,11 @@ import { AppFlows } from "../flows/AppFlows"; import { AppMedia } from "../media/AppMedia"; import type { Module, ModuleBuildContext } from "./Module"; +export type { ModuleBuildContext }; + export const MODULES = { server: AppServer, - data: AppData, + data: AppData, auth: AppAuth, media: AppMedia, flows: AppFlows @@ -73,9 +75,14 @@ export type ModuleManagerOptions = { module: Module, config: ModuleConfigs[Module] ) => Promise; + // triggered when no config table existed + onFirstBoot?: () => Promise; // base path for the hono instance basePath?: string; + // doesn't perform validity checks for given/fetched config trustFetched?: boolean; + // runs when initial config provided on a fresh database + seed?: (ctx: ModuleBuildContext) => Promise; }; type ConfigTable = { @@ -105,9 +112,9 @@ const __bknd = entity(TABLE_NAME, { updated_at: datetime() }); type ConfigTable2 = Schema; -type T_INTERNAL_EM = { +interface T_INTERNAL_EM { __bknd: ConfigTable2; -}; +} // @todo: cleanup old diffs on upgrade // @todo: cleanup multiple backups on upgrade @@ -116,7 +123,7 @@ export class ModuleManager { // internal em for __bknd config table __em!: EntityManager; // ctx for modules - em!: EntityManager; + em!: EntityManager; server!: Hono; emgr!: EventManager; guard!: Guard; @@ -294,7 +301,7 @@ export class ModuleManager { version, json: configs, updated_at: new Date() - }, + } as any, { type: "config", version @@ -448,6 +455,9 @@ export class ModuleManager { await this.buildModules(); await this.save(); + // run initial setup + await this.setupInitial(); + this.logger.clear(); return this; } @@ -462,6 +472,21 @@ export class ModuleManager { return this; } + protected async setupInitial() { + const ctx = { + ...this.ctx(), + // disable events for initial setup + em: this.ctx().em.fork() + }; + + // perform a sync + await ctx.em.schema().sync({ force: true }); + await this.options?.seed?.(ctx); + + // run first boot event + await this.options?.onFirstBoot?.(); + } + get(key: K): Modules[K] { if (!(key in this.modules)) { throw new Error(`Module "${key}" doesn't exist, cannot get`); diff --git a/app/src/modules/server/AppServer.ts b/app/src/modules/server/AppServer.ts index 3ce7774..a19c6c9 100644 --- a/app/src/modules/server/AppServer.ts +++ b/app/src/modules/server/AppServer.ts @@ -74,6 +74,21 @@ export class AppServer extends Module { }) ); + // add an initial fallback route + this.client.use("/", async (c, next) => { + await next(); + // if not finalized or giving a 404 + if (!c.finalized || c.res.status === 404) { + // double check it's root + if (new URL(c.req.url).pathname === "/") { + c.res = undefined; + c.res = Response.json({ + bknd: "hello world!" + }); + } + } + }); + this.client.onError((err, c) => { //throw err; console.error(err); @@ -82,21 +97,6 @@ export class AppServer extends Module { return err; } - /*if (isDebug()) { - console.log("accept", c.req.header("Accept")); - if (c.req.header("Accept") === "application/json") { - const stack = err.stack; - - if ("toJSON" in err && typeof err.toJSON === "function") { - return c.json({ ...err.toJSON(), stack }, 500); - } - - return c.json({ message: String(err), stack }, 500); - } else { - throw err; - } - }*/ - if (err instanceof Exception) { console.log("---is exception", err.code); return c.json(err.toJSON(), err.code as any); @@ -107,32 +107,6 @@ export class AppServer extends Module { this.setBuilt(); } - /*setAdminHtml(html: string) { - this.admin_html = html; - const basepath = (String(this.config.admin.basepath) + "/").replace(/\/+$/, "/"); - - const allowed_prefix = basepath + "auth"; - const login_path = basepath + "auth/login"; - - this.client.get(basepath + "*", async (c, next) => { - const path = new URL(c.req.url).pathname; - if (!path.startsWith(allowed_prefix)) { - console.log("guard check permissions"); - try { - this.ctx.guard.throwUnlessGranted(SystemPermissions.admin); - } catch (e) { - return c.redirect(login_path); - } - } - - return c.html(this.admin_html!); - }); - } - - getAdminHtml() { - return this.admin_html; - }*/ - override toJSON(secrets?: boolean) { return this.config; } diff --git a/app/src/ui/Admin.tsx b/app/src/ui/Admin.tsx index bdfe5bc..f6af592 100644 --- a/app/src/ui/Admin.tsx +++ b/app/src/ui/Admin.tsx @@ -63,7 +63,7 @@ const Skeleton = ({ theme = "light" }: { theme?: string }) => { className="flex flex-row w-full h-16 gap-2.5 border-muted border-b justify-start bg-muted/10" >
- +