Merge pull request #33 from bknd-io/release/0.4.0

Release 0.4.0
This commit is contained in:
dswbx
2024-12-24 16:11:26 +01:00
committed by GitHub
118 changed files with 2505 additions and 1164 deletions

View File

@@ -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<string, Test1>);
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<Test1>((a: ClassRef<What>) => {
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());
});
});

View File

@@ -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<any>([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);
});
});

View File

@@ -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<typeof users>;
};
const obj: Schema<typeof users> = {} as any;
//console.log("user", user.toJSON());
});
@@ -266,4 +275,38 @@ describe("prototype", () => {
const obj: Schema<typeof test> = {} 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());
});
});

View File

@@ -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<any>([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<any>([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<any>([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<any>([entity], dummyConnection);
await em.schema().sync({ force: true });
const events = new Map<string, any>();

View File

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

View File

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

View File

@@ -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"
},

View File

@@ -128,6 +128,14 @@ export class Api {
};
}
async getVerifiedAuthState(force?: boolean): Promise<AuthState> {
if (force === true || !this.verified) {
await this.verifyAuth();
}
return this.getAuthState();
}
async verifyAuth() {
try {
const res = await this.auth.me();

View File

@@ -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<DB> = (app: App<DB>) => void;
export type AppPlugin = (app: App) => Promise<void> | void;
export class AppConfigUpdatedEvent extends Event<{ app: App }> {
abstract class AppEvent<A = {}> 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<any>[];
plugins?: AppPlugin[];
options?: Omit<ModuleManagerOptions, "initial" | "onUpdated">;
};
export type AppConfig = InitialModuleConfigs;
export class App<DB = any> {
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<DB>[] = [],
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<DB = any> {
// 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<DB = any> {
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 extends keyof Modules>(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<DB = any> {
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;
}

View File

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

View File

@@ -1,56 +1,60 @@
/// <reference types="bun-types" />
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<CreateAppConfig> = {}, distPath?: string) {
export type BunBkndConfig = RuntimeBkndConfig & Omit<ServeOptions, "fetch">;
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<ServeOptions, "fetch"> &
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);
}
});

View File

@@ -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<Env = any> = Omit<FrameworkBkndConfig, "app"> & {
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<CfBkndModeCache>;
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);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = any> = (env: Env) => {
cache: KVNamespace;
key: string;
};
export type CfBkndModeDurableObject<Env = any> = (env: Env) => {
durableObject: DurableObjectNamespace;
key: string;
keepAliveSeconds?: number;
};
export type CloudflareBkndConfig<Env = any> = {
mode?: CfBkndModeCache | CfBkndModeDurableObject;
forceHttps?: boolean;
};
// @todo: move to App
export type BkndConfig<Env = any> = {
app: CreateAppConfig | ((env: Env) => CreateAppConfig);
setAdminHtml?: boolean;
server?: {
port?: number;
platform?: "node" | "bun";
};
cloudflare?: CloudflareBkndConfig<Env>;
export type BkndConfig<Env = any> = CreateAppConfig & {
app?: CreateAppConfig | ((env: Env) => CreateAppConfig);
onBuilt?: (app: App) => Promise<void>;
beforeBuild?: (app: App) => Promise<void>;
buildConfig?: Parameters<App["build"]>[0];
};
export type BkndConfigJson = {
app: CreateAppConfig;
setAdminHtml?: boolean;
server?: {
port?: number;
};
export type FrameworkBkndConfig<Env = any> = BkndConfig<Env>;
export type RuntimeBkndConfig<Env = any> = BkndConfig<Env> & {
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<Env = any>(config: BkndConfig<Env>, 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<Env = any>(
config: FrameworkBkndConfig,
env?: Env
): Promise<App> {
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<Env = any>(
{
serveStatic,
registerLocalMedia,
adminOptions,
...config
}: RuntimeBkndConfig & {
serveStatic?: MiddlewareHandler | [string, MiddlewareHandler];
registerLocalMedia?: boolean;
adminOptions?: AdminControllerOptions | false;
},
env?: Env
): Promise<App> {
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;
}

View File

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

View File

@@ -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<typeof honoServe>[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";

View File

@@ -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<typeof honoServe>[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);
}
);
}

View File

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

View File

@@ -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<Env = any> = RuntimeBkndConfig<Env> & {
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(
"</head>",
`<script type="module">
import RefreshRuntime from "/@react-refresh"
RefreshRuntime.injectIntoGlobalHook(window)
window.$RefreshReg$ = () => {}
window.$RefreshSig$ = () => (type) => type
window.__vite_plugin_react_preamble_installed__ = true
</script>
<script type="module" src="/@vite/client"></script>
${addBkndContext ? "<!-- BKND_CONTEXT -->" : ""}
</head>`
);
}
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);

View File

@@ -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<typeof AppAuth.usersFields>;
declare global {
declare module "core" {
interface DB {
users: UserFieldSchema;
users: { id: PrimaryFieldType } & UserFieldSchema;
}
}
@@ -100,7 +101,7 @@ export class AppAuth extends Module<typeof authConfigSchema> {
return this._authenticator!;
}
get em(): EntityManager<DB> {
get em(): EntityManager {
return this.ctx.em as any;
}
@@ -160,7 +161,9 @@ export class AppAuth extends Module<typeof authConfigSchema> {
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<typeof authConfigSchema> {
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<typeof authConfigSchema> {
} 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;

View File

@@ -220,15 +220,23 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
}
private async getAuthCookie(c: Context): Promise<string | undefined> {
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) {

View File

@@ -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>", "port to run on")
.env("PORT")
.default(1337)
.default(config.server.default_port)
.argParser((v) => Number.parseInt(v))
)
.addOption(new Option("-c, --config <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);
}

View File

@@ -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({

View File

@@ -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<Env = any> = FrameworkBkndConfig & {
app: CreateAppConfig | ((env: Env) => CreateAppConfig);
setAdminHtml?: boolean;
server?: {
port?: number;
platform?: "node" | "bun";
};
};

View File

@@ -5,7 +5,13 @@ import type { Generated } from "kysely";
export type PrimaryFieldType = number | Generated<number>;
// biome-ignore lint/suspicious/noEmptyInterface: <explanation>
export interface DB {}
export const config = {
server: {
default_port: 1337
},
data: {
default_primary_field: "id"
}

View File

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

View File

@@ -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,

View File

@@ -69,7 +69,8 @@ export class SchemaObject<Schema extends TObject> {
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);

View File

@@ -1,29 +1,50 @@
export type Constructor<T> = new (...args: any[]) => T;
export class Registry<Item, Items extends Record<string, object> = Record<string, object>> {
export type RegisterFn<Item> = (unknown: any) => Item;
export class Registry<
Item,
Items extends Record<string, Item> = Record<string, Item>,
Fn extends RegisterFn<Item> = RegisterFn<Item>
> {
private is_set: boolean = false;
private items: Items = {} as Items;
set<Actual extends Record<string, object>>(items: Actual) {
constructor(private registerFn?: Fn) {}
set<Actual extends Record<string, Item>>(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<Item, Actual>;
return this as unknown as Registry<Item, Actual, Fn>;
}
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<Fn>[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 extends keyof Items>(name: Name): Items[Name] {
return this.items[name];
}
has(name: keyof Items): boolean {
return name in this.items;
}
all() {
return this.items;
}

View File

@@ -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]}]` : "";

View File

@@ -9,10 +9,25 @@ export async function withDisabledConsole<R>(
fn: () => Promise<R>,
severities: ConsoleSeverity[] = ["log"]
): Promise<R> {
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"]) {

View File

@@ -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<DB> extends Module<typeof dataConfigSchema> {
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<typeof dataConfigSchema> {
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<DB> extends Module<typeof dataConfigSchema> {
};
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<DB> extends Module<typeof dataConfigSchema> {
return dataConfigSchema;
}
get em(): EntityManager<DB> {
get em(): EntityManager {
this.throwIfNotBuilt();
return this.ctx.em;
}

View File

@@ -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<DataApiOptions> {
};
}
readOne(
entity: string,
readOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: E,
id: PrimaryFieldType,
query: Partial<Omit<RepoQuery, "where" | "limit" | "offset">> = {}
) {
return this.get<RepositoryResponse<EntityData>>([entity, id], query);
return this.get<Pick<RepositoryResponse<Data>, "meta" | "data">>([entity as any, id], query);
}
readMany(entity: string, query: Partial<RepoQuery> = {}) {
return this.get<Pick<RepositoryResponse, "meta" | "data">>(
[entity],
query ?? this.options.defaultQuery
);
}
readManyByReference(
entity: string,
id: PrimaryFieldType,
reference: string,
readMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: E,
query: Partial<RepoQuery> = {}
) {
return this.get<Pick<RepositoryResponse, "meta" | "data">>(
[entity, id, reference],
return this.get<Pick<RepositoryResponse<Data[]>, "meta" | "data">>(
[entity as any],
query ?? this.options.defaultQuery
);
}
createOne(entity: string, input: EntityData) {
return this.post<RepositoryResponse<EntityData>>([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<RepoQuery> = {}) {
return this.get<Pick<RepositoryResponse<Data[]>, "meta" | "data">>(
[entity as any, id, reference],
query ?? this.options.defaultQuery
);
}
updateOne(entity: string, id: PrimaryFieldType, input: EntityData) {
return this.patch<RepositoryResponse<EntityData>>([entity, id], input);
createOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: E,
input: Omit<Data, "id">
) {
return this.post<RepositoryResponse<Data>>([entity as any], input);
}
deleteOne(entity: string, id: PrimaryFieldType) {
return this.delete<RepositoryResponse<EntityData>>([entity, id]);
updateOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: E,
id: PrimaryFieldType,
input: Partial<Omit<Data, "id">>
) {
return this.patch<RepositoryResponse<Data>>([entity as any, id], input);
}
count(entity: string, where: RepoQuery["where"] = {}) {
return this.post<RepositoryResponse<{ entity: string; count: number }>>(
[entity, "fn", "count"],
deleteOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: E,
id: PrimaryFieldType
) {
return this.delete<RepositoryResponse<Data>>([entity as any, id]);
}
count<E extends keyof DB | string>(entity: E, where: RepoQuery["where"] = {}) {
return this.post<RepositoryResponse<{ entity: E; count: number }>>(
[entity as any, "fn", "count"],
where
);
}

View File

@@ -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,

View File

@@ -0,0 +1,7 @@
import { Connection } from "./Connection";
export class DummyConnection extends Connection {
constructor() {
super(undefined as any);
}
}

View File

@@ -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() {

View File

@@ -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<DB> {
type EntitySchema<
TBD extends object = DefaultDB,
E extends Entity | keyof TBD | string = string
> = E extends Entity<infer Name>
? Name extends keyof TBD
? Name
: never
: E extends keyof TBD
? E
: never;
export class EntityManager<TBD extends object = DefaultDB> {
connection: Connection;
private _entities: Entity[] = [];
@@ -50,7 +62,7 @@ export class EntityManager<DB> {
* Forks the EntityManager without the EventManager.
* This is useful when used inside an event handler.
*/
fork(): EntityManager<DB> {
fork(): EntityManager {
return new EntityManager(this._entities, this.connection, this._relations, this._indices);
}
@@ -87,10 +99,17 @@ export class EntityManager<DB> {
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<DB> {
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<E extends Entity | keyof TBD | string>(
entity: E
): Repository<TBD, EntitySchema<TBD, E>> {
return this.repo(entity);
}
repo<E extends Entity>(
_entity: E
): Repository<
DB,
E extends Entity<infer Name> ? (Name extends keyof DB ? Name : never) : never
> {
return new Repository(this, _entity, this.emgr);
repo<E extends Entity | keyof TBD | string>(entity: E): Repository<TBD, EntitySchema<TBD, E>> {
return new Repository(this, this.entity(entity), this.emgr);
}
_repo<TB extends keyof DB>(_entity: TB): Repository<DB, TB> {
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<E extends Entity | keyof TBD | string>(entity: E): Mutator<TBD, EntitySchema<TBD, E>> {
return new Mutator(this, this.entity(entity), this.emgr);
}
addIndex(index: EntityIndex, force = false) {

View File

@@ -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<T = EntityData[]> = {
data: T;
};
export class Mutator<DB> implements EmitsEvents {
em: EntityManager<DB>;
export class Mutator<
TBD extends object = DefaultDB,
TB extends keyof TBD = any,
Output = TBD[TB],
Input = Omit<Output, "id">
> implements EmitsEvents
{
em: EntityManager<TBD>;
entity: Entity;
static readonly Events = MutatorEvents;
emgr: EventManager<typeof MutatorEvents>;
@@ -37,7 +43,7 @@ export class Mutator<DB> implements EmitsEvents {
this.__unstable_disable_system_entity_creation = value;
}
constructor(em: EntityManager<DB>, entity: Entity, emgr?: EventManager<any>) {
constructor(em: EntityManager<TBD>, entity: Entity, emgr?: EventManager<any>) {
this.em = em;
this.entity = entity;
this.emgr = emgr ?? new EventManager(MutatorEvents);
@@ -47,13 +53,13 @@ export class Mutator<DB> implements EmitsEvents {
return this.em.connection.kysely;
}
async getValidatedData(data: EntityData, context: TActionContext): Promise<EntityData> {
async getValidatedData<Given = any>(data: Given, context: TActionContext): Promise<Given> {
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<DB> implements EmitsEvents {
throw new Error(`No data left to update "${entity.name}"`);
}
return validatedData;
return validatedData as Given;
}
protected async many(qb: MutatorQB): Promise<MutatorResponse> {
@@ -120,7 +126,7 @@ export class Mutator<DB> implements EmitsEvents {
return { ...response, data: data[0]! };
}
async insertOne(data: EntityData): Promise<MutatorResponse<EntityData>> {
async insertOne(data: Input): Promise<MutatorResponse<Output>> {
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<DB> 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<MutatorResponse<EntityData>> {
async updateOne(id: PrimaryFieldType, data: Partial<Input>): Promise<MutatorResponse<Output>> {
const entity = this.entity;
if (!Number.isInteger(id)) {
throw new Error("ID must be provided for update");
@@ -166,12 +172,16 @@ export class Mutator<DB> 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<DB> implements EmitsEvents {
new Mutator.Events.MutatorUpdateAfter({ entity, entityId: id, data: res.data })
);
return res;
return res as any;
}
async deleteOne(id: PrimaryFieldType): Promise<MutatorResponse<EntityData>> {
async deleteOne(id: PrimaryFieldType): Promise<MutatorResponse<Output>> {
const entity = this.entity;
if (!Number.isInteger(id)) {
throw new Error("ID must be provided for deletion");
@@ -203,7 +213,7 @@ export class Mutator<DB> implements EmitsEvents {
new Mutator.Events.MutatorDeleteAfter({ entity, entityId: id, data: res.data })
);
return res;
return res as any;
}
private getValidOptions(options?: Partial<RepoQuery>): Partial<RepoQuery> {
@@ -250,47 +260,62 @@ export class Mutator<DB> implements EmitsEvents {
}
// @todo: decide whether entries should be deleted all at once or one by one (for events)
async deleteWhere(where?: RepoQuery["where"]): Promise<MutatorResponse<EntityData>> {
async deleteWhere(where?: RepoQuery["where"]): Promise<MutatorResponse<Output[]>> {
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<Input>,
where?: RepoQuery["where"]
): Promise<MutatorResponse<EntityData>> {
): Promise<MutatorResponse<Output[]>> {
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<MutatorResponse<Output[]>> {
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;
}
}

View File

@@ -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<DB = any, TB extends keyof DB = any> implements EmitsEvents {
em: EntityManager<DB>;
export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = any>
implements EmitsEvents
{
em: EntityManager<TBD>;
entity: Entity;
static readonly Events = RepositoryEvents;
emgr: EventManager<typeof Repository.Events>;
constructor(em: EntityManager<DB>, entity: Entity, emgr?: EventManager<any>) {
constructor(em: EntityManager<TBD>, entity: Entity, emgr?: EventManager<any>) {
this.em = em;
this.entity = entity;
this.emgr = emgr ?? new EventManager(MutatorEvents);
@@ -272,7 +274,7 @@ export class Repository<DB = any, TB extends keyof DB = any> implements EmitsEve
async findId(
id: PrimaryFieldType,
_options?: Partial<Omit<RepoQuery, "where" | "limit" | "offset">>
): Promise<RepositoryResponse<DB[TB]>> {
): Promise<RepositoryResponse<TBD[TB] | undefined>> {
const { qb, options } = this.buildQuery(
{
..._options,
@@ -288,7 +290,7 @@ export class Repository<DB = any, TB extends keyof DB = any> implements EmitsEve
async findOne(
where: RepoQuery["where"],
_options?: Partial<Omit<RepoQuery, "where" | "limit" | "offset">>
): Promise<RepositoryResponse<DB[TB] | undefined>> {
): Promise<RepositoryResponse<TBD[TB] | undefined>> {
const { qb, options } = this.buildQuery({
..._options,
where,
@@ -298,7 +300,7 @@ export class Repository<DB = any, TB extends keyof DB = any> implements EmitsEve
return this.single(qb, options) as any;
}
async findMany(_options?: Partial<RepoQuery>): Promise<RepositoryResponse<DB[TB][]>> {
async findMany(_options?: Partial<RepoQuery>): Promise<RepositoryResponse<TBD[TB][]>> {
const { qb, options } = this.buildQuery(_options);
//console.log("findMany:options", options);

View File

@@ -104,6 +104,12 @@ export class TextField<Required extends true | false = false> 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;
}

View File

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

View File

@@ -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<Config = any> = {
entity: { name: string; fields: Record<string, Field<any, any, any>> };
field_name: string;
config: Config;
is_required: boolean;
another?: string;
};
const FieldMap = {
@@ -239,7 +245,89 @@ export function relation<Local extends Entity>(local: Local) {
};
}
type InferEntityFields<T> = T extends Entity<infer _N, infer Fields>
export function index<E extends Entity>(entity: E) {
return {
on: (fields: (keyof InsertSchema<E>)[], 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<Entities extends Record<string, Entity>> extends EntityManager<
Schema<Entities>
> {
constructor(
public __entities: Entities,
relations: EntityRelation[] = [],
indices: EntityIndex[] = []
) {
super(Object.values(__entities), new DummyConnection(), relations, indices);
}
}
type Chained<Fn extends (...args: any[]) => any, Rt = ReturnType<Fn>> = <E extends Entity>(
e: E
) => {
[K in keyof Rt]: Rt[K] extends (...args: any[]) => any
? (...args: Parameters<Rt[K]>) => Rt
: never;
};
export function em<Entities extends Record<string, Entity>>(
entities: Entities,
schema?: (
fns: { relation: Chained<typeof relation>; index: Chained<typeof index> },
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>,
entities: e.__entities,
relations,
indices,
toJSON: () =>
e.toJSON() as unknown as Pick<ModuleConfigs["data"], "entities" | "relations" | "indices">
};
}
export type InferEntityFields<T> = T extends Entity<infer _N, infer Fields>
? {
[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> = Field extends { _type: infer Type; _required: infer Required }
export type InferField<Field> = Field extends { _type: infer Type; _required: infer Required }
? Required extends true
? Type
: Type | undefined
: never;
export type Schemas<T extends Record<string, Entity>> = {
[K in keyof T]: Schema<T[K]>;
};
export type InsertSchema<T> = Simplify<OptionalUndefined<InferEntityFields<T>>>;
export type Schema<T> = { id: Generated<number> } & InsertSchema<T>;
export type Schema<T> = Simplify<{ id: Generated<number> } & InsertSchema<T>>;
export type FieldSchema<T> = Simplify<OptionalUndefined<InferFields<T>>>;

View File

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

View File

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

View File

@@ -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<typeof AppMedia.mediaFields>;
declare global {
declare module "core" {
interface DB {
media: MediaFieldSchema;
media: { id: PrimaryFieldType } & MediaFieldSchema;
}
}
@@ -112,14 +103,14 @@ export class AppMedia extends Module<typeof mediaConfigSchema> {
return this.em.entity(entity_name);
}
get em(): EntityManager<DB> {
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<typeof mediaConfigSchema> {
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);

View File

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

View File

@@ -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<T> = Constructor<T> & { prototype: T };
export const MediaAdapterRegistry = new Registry<{
cls: ClassThatImplements<StorageAdapter>;
schema: TObject;
}>().set({
s3: {
cls: StorageS3Adapter,
schema: StorageS3Adapter.prototype.getSchema()
},
cloudinary: {
cls: StorageCloudinaryAdapter,
schema: StorageCloudinaryAdapter.prototype.getSchema()
}
});
}>((cls: ClassThatImplements<StorageAdapter>) => ({
cls,
schema: cls.prototype.getSchema() as TObject
}))
.register("s3", StorageS3Adapter)
.register("cloudinary", StorageCloudinaryAdapter);
export const Adapters = {
s3: {

View File

@@ -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<Response> {
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<FileMeta> {
const stats = await stat(`${this.config.path}/${key}`);
return {
type: guessMimeType(key) || "application/octet-stream",
type: guess(key) || "application/octet-stream",
size: stats.size
};
}

View File

@@ -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<string, string>([
["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();
}
}

View File

@@ -8,7 +8,7 @@ import type { Hono } from "hono";
export type ModuleBuildContext = {
connection: Connection;
server: Hono<any>;
em: EntityManager<any>;
em: EntityManager;
emgr: EventManager<any>;
guard: Guard;
};

View File

@@ -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<any>,
data: AppData,
auth: AppAuth,
media: AppMedia,
flows: AppFlows
@@ -73,9 +75,14 @@ export type ModuleManagerOptions = {
module: Module,
config: ModuleConfigs[Module]
) => Promise<void>;
// triggered when no config table existed
onFirstBoot?: () => Promise<void>;
// 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<void>;
};
type ConfigTable<Json = ModuleConfigs> = {
@@ -105,9 +112,9 @@ const __bknd = entity(TABLE_NAME, {
updated_at: datetime()
});
type ConfigTable2 = Schema<typeof __bknd>;
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<T_INTERNAL_EM>;
// ctx for modules
em!: EntityManager<any>;
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<K extends keyof Modules>(key: K): Modules[K] {
if (!(key in this.modules)) {
throw new Error(`Module "${key}" doesn't exist, cannot get`);

View File

@@ -74,6 +74,21 @@ export class AppServer extends Module<typeof serverConfigSchema> {
})
);
// 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<typeof serverConfigSchema> {
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<typeof serverConfigSchema> {
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;
}

View File

@@ -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"
>
<div className="max-h-full flex hover:bg-primary/5 link p-2.5 w-[134px] outline-none">
<Logo />
<Logo theme={theme} />
</div>
<nav className="hidden md:flex flex-row gap-2.5 pl-0 p-2.5 items-center">
{[...new Array(5)].map((item, key) => (

View File

@@ -5,14 +5,14 @@ import { useApi } from "ui/client";
export const useApiQuery = <
Data,
RefineFn extends (data: ResponseObject<Data>) => any = (data: ResponseObject<Data>) => Data
RefineFn extends (data: ResponseObject<Data>) => unknown = (data: ResponseObject<Data>) => Data
>(
fn: (api: Api) => FetchPromise<Data>,
options?: SWRConfiguration & { enabled?: boolean; refine?: RefineFn }
) => {
const api = useApi();
const promise = fn(api);
const refine = options?.refine ?? ((data: ResponseObject<Data>) => data);
const refine = options?.refine ?? ((data: any) => data);
const fetcher = () => promise.execute().then(refine);
const key = promise.key();

View File

@@ -1,37 +0,0 @@
import type { DataApi } from "data/api/DataApi";
import { useApi } from "ui/client";
type OmitFirstArg<F> = F extends (x: any, ...args: infer P) => any
? (...args: P) => ReturnType<F>
: never;
/**
* Maps all DataApi functions and omits
* the first argument "entity" for convenience
* @param entity
*/
export const useData = <T extends keyof DataApi>(entity: string) => {
const api = useApi().data;
const methods = [
"readOne",
"readMany",
"readManyByReference",
"createOne",
"updateOne",
"deleteOne"
] as const;
return methods.reduce(
(acc, method) => {
// @ts-ignore
acc[method] = (...params) => {
// @ts-ignore
return api[method](entity, ...params);
};
return acc;
},
{} as {
[K in (typeof methods)[number]]: OmitFirstArg<(typeof api)[K]>;
}
);
};

View File

@@ -1,23 +1,40 @@
import type { PrimaryFieldType } from "core";
import { objectTransform } from "core/utils";
import type { DB, PrimaryFieldType } from "core";
import { encodeSearch, objectTransform } from "core/utils";
import type { EntityData, RepoQuery } from "data";
import type { ResponseObject } from "modules/ModuleApi";
import useSWR, { type SWRConfiguration } from "swr";
import { useApi } from "ui/client";
import type { ModuleApi, ResponseObject } from "modules/ModuleApi";
import useSWR, { type SWRConfiguration, mutate } from "swr";
import { type Api, useApi } from "ui/client";
export class UseEntityApiError<Payload = any> extends Error {
constructor(
public payload: Payload,
public response: Response,
message?: string
public response: ResponseObject<Payload>,
fallback?: string
) {
let message = fallback;
if ("error" in response) {
message = response.error as string;
if (fallback) {
message = `${fallback}: ${message}`;
}
}
super(message ?? "UseEntityApiError");
}
}
function Test() {
const { read } = useEntity("users");
async () => {
const data = await read();
};
return null;
}
export const useEntity = <
Entity extends string,
Id extends PrimaryFieldType | undefined = undefined
Entity extends keyof DB | string,
Id extends PrimaryFieldType | undefined = undefined,
Data = Entity extends keyof DB ? DB[Entity] : EntityData
>(
entity: Entity,
id?: Id
@@ -25,27 +42,30 @@ export const useEntity = <
const api = useApi().data;
return {
create: async (input: EntityData) => {
create: async (input: Omit<Data, "id">) => {
const res = await api.createOne(entity, input);
if (!res.ok) {
throw new UseEntityApiError(res.data, res.res, "Failed to create entity");
throw new UseEntityApiError(res, `Failed to create entity "${entity}"`);
}
return res;
},
read: async (query: Partial<RepoQuery> = {}) => {
const res = id ? await api.readOne(entity, id!, query) : await api.readMany(entity, query);
if (!res.ok) {
throw new UseEntityApiError(res.data, res.res, "Failed to read entity");
throw new UseEntityApiError(res as any, `Failed to read entity "${entity}"`);
}
return res;
// must be manually typed
return res as unknown as Id extends undefined
? ResponseObject<Data[]>
: ResponseObject<Data>;
},
update: async (input: Partial<EntityData>, _id: PrimaryFieldType | undefined = id) => {
update: async (input: Partial<Omit<Data, "id">>, _id: PrimaryFieldType | undefined = id) => {
if (!_id) {
throw new Error("id is required");
}
const res = await api.updateOne(entity, _id, input);
if (!res.ok) {
throw new UseEntityApiError(res.data, res.res, "Failed to update entity");
throw new UseEntityApiError(res, `Failed to update entity "${entity}"`);
}
return res;
},
@@ -56,44 +76,67 @@ export const useEntity = <
const res = await api.deleteOne(entity, _id);
if (!res.ok) {
throw new UseEntityApiError(res.data, res.res, "Failed to delete entity");
throw new UseEntityApiError(res, `Failed to delete entity "${entity}"`);
}
return res;
}
};
};
// @todo: try to get from ModuleApi directly
export function makeKey(
api: ModuleApi,
entity: string,
id?: PrimaryFieldType,
query?: Partial<RepoQuery>
) {
return (
"/" +
[...(api.options?.basepath?.split("/") ?? []), entity, ...(id ? [id] : [])]
.filter(Boolean)
.join("/") +
(query ? "?" + encodeSearch(query) : "")
);
}
export const useEntityQuery = <
Entity extends string,
Entity extends keyof DB | string,
Id extends PrimaryFieldType | undefined = undefined
>(
entity: Entity,
id?: Id,
query?: Partial<RepoQuery>,
options?: SWRConfiguration & { enabled?: boolean }
options?: SWRConfiguration & { enabled?: boolean; revalidateOnMutate?: boolean }
) => {
const api = useApi().data;
const key =
options?.enabled !== false
? [...(api.options?.basepath?.split("/") ?? []), entity, ...(id ? [id] : [])].filter(
Boolean
)
: null;
const { read, ...actions } = useEntity(entity, id) as any;
const key = makeKey(api, entity, id, query);
const { read, ...actions } = useEntity<Entity, Id>(entity, id);
const fetcher = () => read(query);
type T = Awaited<ReturnType<(typeof api)[Id extends undefined ? "readMany" : "readOne"]>>;
const swr = useSWR<T>(key, fetcher, {
type T = Awaited<ReturnType<typeof fetcher>>;
const swr = useSWR<T>(options?.enabled === false ? null : key, fetcher as any, {
revalidateOnFocus: false,
keepPreviousData: false,
keepPreviousData: true,
...options
});
const mapped = objectTransform(actions, (action) => {
if (action === "read") return;
const mutateAll = async () => {
const entityKey = makeKey(api, entity);
return mutate((key) => typeof key === "string" && key.startsWith(entityKey), undefined, {
revalidate: true
});
};
return async (...args) => {
return swr.mutate(action(...args)) as any;
const mapped = objectTransform(actions, (action) => {
return async (...args: any) => {
// @ts-ignore
const res = await action(...args);
// mutate all keys of entity by default
if (options?.revalidateOnMutate !== false) {
await mutateAll();
}
return res;
};
}) as Omit<ReturnType<typeof useEntity<Entity, Id>>, "read">;
@@ -105,17 +148,62 @@ export const useEntityQuery = <
};
};
export async function mutateEntityCache<
Entity extends keyof DB | string,
Data = Entity extends keyof DB ? Omit<DB[Entity], "id"> : EntityData
>(api: Api["data"], entity: Entity, id: PrimaryFieldType, partialData: Partial<Data>) {
function update(prev: any, partialNext: any) {
if (
typeof prev !== "undefined" &&
typeof partialNext !== "undefined" &&
"id" in prev &&
prev.id === id
) {
return { ...prev, ...partialNext };
}
return prev;
}
const entityKey = makeKey(api, entity);
return mutate(
(key) => typeof key === "string" && key.startsWith(entityKey),
async (data) => {
if (typeof data === "undefined") return;
if (Array.isArray(data)) {
return data.map((item) => update(item, partialData));
}
return update(data, partialData);
},
{
revalidate: false
}
);
}
export const useEntityMutate = <
Entity extends string,
Id extends PrimaryFieldType | undefined = undefined
Entity extends keyof DB | string,
Id extends PrimaryFieldType | undefined = undefined,
Data = Entity extends keyof DB ? Omit<DB[Entity], "id"> : EntityData
>(
entity: Entity,
id?: Id,
options?: SWRConfiguration
) => {
const { data, ...$q } = useEntityQuery(entity, id, undefined, {
const { data, ...$q } = useEntityQuery<Entity, Id>(entity, id, undefined, {
...options,
enabled: false
});
return $q;
const _mutate = id
? (data) => mutateEntityCache($q.api, entity, id, data)
: (id, data) => mutateEntityCache($q.api, entity, id, data);
return {
...$q,
mutate: _mutate as unknown as Id extends undefined
? (id: PrimaryFieldType, data: Partial<Data>) => Promise<void>
: (data: Partial<Data>) => Promise<void>
};
};

View File

@@ -7,7 +7,6 @@ export {
} from "./ClientProvider";
export * from "./api/use-api";
export * from "./api/use-data";
export * from "./api/use-entity";
export { useAuth } from "./schema/auth/use-auth";
export { Api } from "../../Api";

View File

@@ -1,6 +1,5 @@
import { Type, TypeInvalidError, parse, transformObject } from "core/utils";
import type { Entity } from "data";
import { AppData } from "data/AppData";
import { constructEntity } from "data";
import {
type TAppDataEntity,
type TAppDataEntityFields,
@@ -19,7 +18,7 @@ export function useBkndData() {
// @todo: potentially store in ref, so it doesn't get recomputed? or use memo?
const entities = transformObject(config.data.entities ?? {}, (entity, name) => {
return AppData.constructEntity(name, entity);
return constructEntity(name, entity);
});
const actions = {

View File

@@ -1,6 +1,5 @@
import type { App } from "App";
import type { Entity, EntityRelation } from "data";
import { AppData } from "data/AppData";
import { type Entity, type EntityRelation, constructEntity, constructRelation } from "data";
import { RelationAccessor } from "data/relations/RelationAccessor";
import { Flow, TaskMap } from "flows";
@@ -20,11 +19,11 @@ export class AppReduced {
//console.log("received appjson", appJson);
this._entities = Object.entries(this.appJson.data.entities ?? {}).map(([name, entity]) => {
return AppData.constructEntity(name, entity);
return constructEntity(name, entity);
});
this._relations = Object.entries(this.appJson.data.relations ?? {}).map(([, relation]) => {
return AppData.constructRelation(relation, this.entity.bind(this));
return constructRelation(relation, this.entity.bind(this));
});
for (const [name, obj] of Object.entries(this.appJson.flows.flows ?? {})) {

View File

@@ -1,7 +1,6 @@
import type { ReactCodeMirrorProps } from "@uiw/react-codemirror";
import { Suspense, lazy } from "react";
import { default as CodeMirror, type ReactCodeMirrorProps } from "@uiw/react-codemirror";
import { useBknd } from "ui/client/bknd";
const CodeMirror = lazy(() => import("@uiw/react-codemirror"));
export default function CodeEditor({ editable, basicSetup, ...props }: ReactCodeMirrorProps) {
const b = useBknd();
@@ -15,13 +14,11 @@ export default function CodeEditor({ editable, basicSetup, ...props }: ReactCode
: basicSetup;
return (
<Suspense>
<CodeMirror
theme={theme === "dark" ? "dark" : "light"}
editable={editable}
basicSetup={_basicSetup}
{...props}
/>
</Suspense>
<CodeMirror
theme={theme === "dark" ? "dark" : "light"}
editable={editable}
basicSetup={_basicSetup}
{...props}
/>
);
}

View File

@@ -1,15 +1,12 @@
import type { Schema } from "@cfworker/json-schema";
import Form from "@rjsf/core";
import type { RJSFSchema, UiSchema } from "@rjsf/utils";
import { cloneDeep } from "lodash-es";
import { forwardRef, useId, useImperativeHandle, useRef, useState } from "react";
//import { JsonSchemaValidator } from "./JsonSchemaValidator";
import { fields as Fields } from "./fields";
import { templates as Templates } from "./templates";
import { widgets as Widgets } from "./widgets";
import "./styles.css";
import { filterKeys } from "core/utils";
import { cloneDeep } from "lodash-es";
import { RJSFTypeboxValidator } from "./typebox/RJSFTypeboxValidator";
import { widgets as Widgets } from "./widgets";
const validator = new RJSFTypeboxValidator();

View File

@@ -0,0 +1,18 @@
import { Suspense, forwardRef, lazy } from "react";
import type { JsonSchemaFormProps, JsonSchemaFormRef } from "./JsonSchemaForm";
export type { JsonSchemaFormProps, JsonSchemaFormRef };
const Module = lazy(() =>
import("./JsonSchemaForm").then((m) => ({
default: m.JsonSchemaForm
}))
);
export const JsonSchemaForm = forwardRef<JsonSchemaFormRef, JsonSchemaFormProps>((props, ref) => {
return (
<Suspense>
<Module ref={ref} {...props} />
</Suspense>
);
});

View File

@@ -42,7 +42,11 @@ const useLocationFromRouter = (router) => {
];
};
export function Link({ className, ...props }: { className?: string } & LinkProps) {
export function Link({
className,
native,
...props
}: { className?: string; native?: boolean } & LinkProps) {
const router = useRouter();
const [path, navigate] = useLocationFromRouter(router);
@@ -55,8 +59,6 @@ export function Link({ className, ...props }: { className?: string } & LinkProps
return false;
}
function handleClick(e) {}
const _href = props.href ?? props.to;
const href = router
.hrefs(
@@ -72,6 +74,10 @@ export function Link({ className, ...props }: { className?: string } & LinkProps
/*if (active) {
console.log("link", { a, path, absPath, href, to, active, router });
}*/
if (native) {
return <a className={`${active ? "active " : ""}${className}`} {...props} />;
}
return (
// @ts-expect-error className is not typed on WouterLink
<WouterLink className={`${active ? "active " : ""}${className}`} {...props} />

View File

@@ -116,7 +116,7 @@ function SidebarToggler() {
export function Header({ hasSidebar = true }) {
//const logoReturnPath = "";
const { app } = useBknd();
const logoReturnPath = app.getAdminConfig().logo_return_path ?? "/";
const { logo_return_path = "/", color_scheme = "light" } = app.getAdminConfig();
return (
<header
@@ -124,11 +124,11 @@ export function Header({ hasSidebar = true }) {
className="flex flex-row w-full h-16 gap-2.5 border-muted border-b justify-start bg-muted/10"
>
<Link
href={logoReturnPath}
replace
href={logo_return_path}
native={logo_return_path !== "/"}
className="max-h-full flex hover:bg-primary/5 link p-2.5 w-[134px] outline-none"
>
<Logo />
<Logo theme={color_scheme} />
</Link>
<HeaderNavigation />
<div className="flex flex-grow" />

View File

@@ -4,7 +4,7 @@ import {
JsonSchemaForm,
type JsonSchemaFormProps,
type JsonSchemaFormRef
} from "ui/components/form/json-schema/JsonSchemaForm";
} from "ui/components/form/json-schema";
import type { ContextModalProps } from "@mantine/modals";

View File

@@ -1,14 +1,8 @@
import type { FieldApi } from "@tanstack/react-form";
import type { EntityData, JsonSchemaField } from "data";
import { Suspense, lazy } from "react";
import * as Formy from "ui/components/form/Formy";
import { FieldLabel } from "ui/components/form/Formy";
const JsonSchemaForm = lazy(() =>
import("ui/components/form/json-schema/JsonSchemaForm").then((m) => ({
default: m.JsonSchemaForm
}))
);
import { JsonSchemaForm } from "ui/components/form/json-schema";
export function EntityJsonSchemaFormField({
fieldApi,
@@ -34,23 +28,21 @@ export function EntityJsonSchemaFormField({
return (
<Formy.Group>
<FieldLabel htmlFor={fieldApi.name} field={field} />
<Suspense fallback={<div>Loading...</div>}>
<div
data-disabled={disabled ? 1 : undefined}
className="data-[disabled]:opacity-70 data-[disabled]:pointer-events-none"
>
<JsonSchemaForm
schema={field.getJsonSchema()}
onChange={handleChange}
direction="horizontal"
formData={formData}
uiSchema={{
"ui:globalOptions": { flexDirection: "row" },
...field.getJsonUiSchema()
}}
/>
</div>
</Suspense>
<div
data-disabled={disabled ? 1 : undefined}
className="data-[disabled]:opacity-70 data-[disabled]:pointer-events-none"
>
<JsonSchemaForm
schema={field.getJsonSchema()}
onChange={handleChange}
direction="horizontal"
formData={formData}
uiSchema={{
"ui:globalOptions": { flexDirection: "row" },
...field.getJsonUiSchema()
}}
/>
</div>
</Formy.Group>
);
}

View File

@@ -1,14 +1,9 @@
import { Handle, type Node, type NodeProps, Position } from "@xyflow/react";
import { Const, Type, transformObject } from "core/utils";
import { type TaskRenderProps, type Trigger, TriggerMap } from "flows";
import { Suspense, lazy } from "react";
import { type Trigger, TriggerMap } from "flows";
import type { IconType } from "react-icons";
import { TbCircleLetterT } from "react-icons/tb";
const JsonSchemaForm = lazy(() =>
import("ui/components/form/json-schema/JsonSchemaForm").then((m) => ({
default: m.JsonSchemaForm
}))
);
import { JsonSchemaForm } from "ui/components/form/json-schema";
export type TaskComponentProps = NodeProps<Node<{ trigger: Trigger }>> & {
Icon?: IconType;
@@ -48,17 +43,15 @@ export function TriggerComponent({
</div>
<div className="w-full h-px bg-primary/10" />
<div className="flex flex-col gap-2 px-3 py-2">
<Suspense fallback={<div>Loading...</div>}>
<JsonSchemaForm
className="legacy"
schema={Type.Union(triggerSchemas)}
onChange={console.log}
formData={trigger}
{...props}
/*uiSchema={uiSchema}*/
/*fields={{ template: TemplateField }}*/
/>
</Suspense>
<JsonSchemaForm
className="legacy"
schema={Type.Union(triggerSchemas)}
onChange={console.log}
formData={trigger}
{...props}
/*uiSchema={uiSchema}*/
/*fields={{ template: TemplateField }}*/
/>
</div>
</div>
<Handle

View File

@@ -1,12 +1,5 @@
import type { Task } from "flows";
import { Suspense, lazy } from "react";
import { TemplateField } from "./TemplateField";
const JsonSchemaForm = lazy(() =>
import("ui/components/form/json-schema/JsonSchemaForm").then((m) => ({
default: m.JsonSchemaForm
}))
);
import { JsonSchemaForm } from "ui/components/form/json-schema";
export type TaskFormProps = {
task: Task;
@@ -26,16 +19,14 @@ export function TaskForm({ task, onChange, ...props }: TaskFormProps) {
//console.log("uiSchema", uiSchema);
return (
<Suspense fallback={<div>Loading...</div>}>
<JsonSchemaForm
className="legacy"
schema={schema}
onChange={onChange}
formData={params}
{...props}
/*uiSchema={uiSchema}*/
/*fields={{ template: TemplateField }}*/
/>
</Suspense>
<JsonSchemaForm
className="legacy"
schema={schema}
onChange={onChange}
formData={params}
{...props}
/*uiSchema={uiSchema}*/
/*fields={{ template: TemplateField }}*/
/>
);
}

View File

@@ -5,10 +5,7 @@ import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
import { Button } from "ui/components/buttons/Button";
import { Alert } from "ui/components/display/Alert";
import {
JsonSchemaForm,
type JsonSchemaFormRef
} from "ui/components/form/json-schema/JsonSchemaForm";
import { JsonSchemaForm, type JsonSchemaFormRef } from "ui/components/form/json-schema";
import * as AppShell from "ui/layouts/AppShell/AppShell";
import { useNavigate } from "ui/lib/routes";
import { extractSchema } from "../settings/utils/schema";

View File

@@ -1,9 +1,7 @@
import { cloneDeep, omit } from "lodash-es";
import { useBknd } from "ui/client/bknd";
import { Button } from "ui/components/buttons/Button";
import { JsonSchemaForm } from "ui/components/form/json-schema/JsonSchemaForm";
import * as AppShell from "../../layouts/AppShell/AppShell";
import { extractSchema } from "../settings/utils/schema";
export function AuthStrategiesList() {
useBknd({ withSecrets: true });

View File

@@ -101,7 +101,7 @@ export function DataEntityUpdate({ params }) {
data: {
data: data as any,
entity: entity.toJSON(),
schema: entity.toSchema(true),
schema: entity.toSchema({ clean: true }),
form: Form.state.values,
state: Form.state
}

View File

@@ -13,10 +13,7 @@ import { useBkndData } from "ui/client/schema/data/use-bknd-data";
import { Button } from "ui/components/buttons/Button";
import { IconButton } from "ui/components/buttons/IconButton";
import { Empty } from "ui/components/display/Empty";
import {
JsonSchemaForm,
type JsonSchemaFormRef
} from "ui/components/form/json-schema/JsonSchemaForm";
import { JsonSchemaForm, type JsonSchemaFormRef } from "ui/components/form/json-schema";
import { Dropdown } from "ui/components/overlay/Dropdown";
import * as AppShell from "ui/layouts/AppShell/AppShell";
import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2";

View File

@@ -22,7 +22,7 @@ import { Button } from "ui/components/buttons/Button";
import { IconButton } from "ui/components/buttons/IconButton";
import { JsonViewer } from "ui/components/code/JsonViewer";
import { MantineSwitch } from "ui/components/form/hook-form-mantine/MantineSwitch";
import { JsonSchemaForm } from "ui/components/form/json-schema/JsonSchemaForm";
import { JsonSchemaForm } from "ui/components/form/json-schema";
import { type SortableItemProps, SortableList } from "ui/components/list/SortableList";
import { Popover } from "ui/components/overlay/Popover";
import { fieldSpecs } from "ui/modules/data/components/fields-specs";

View File

@@ -1,14 +1,19 @@
import { Suspense, lazy } from "react";
import { useBknd } from "ui/client/bknd";
import { Route, Router, Switch } from "wouter";
import AuthRoutes from "./auth";
import { AuthLogin } from "./auth/auth.login";
import DataRoutes from "./data";
import FlowRoutes from "./flows";
import MediaRoutes from "./media";
import { Root, RootEmpty } from "./root";
import SettingsRoutes from "./settings";
const DataRoutes = lazy(() => import("./data"));
/*const DataRoutes = lazy(() => import("./data"));
const AuthRoutes = lazy(() => import("./auth"));
const MediaRoutes = lazy(() => import("./media"));
const FlowRoutes = lazy(() => import("./flows"));
const SettingsRoutes = lazy(() => import("./settings"));
const SettingsRoutes = lazy(() => import("./settings"));*/
// @ts-ignore
const TestRoutes = lazy(() => import("./test"));

View File

@@ -8,10 +8,7 @@ import { Button } from "ui/components/buttons/Button";
import { IconButton } from "ui/components/buttons/IconButton";
import { Alert } from "ui/components/display/Alert";
import { Empty } from "ui/components/display/Empty";
import {
JsonSchemaForm,
type JsonSchemaFormRef
} from "ui/components/form/json-schema/JsonSchemaForm";
import { JsonSchemaForm, type JsonSchemaFormRef } from "ui/components/form/json-schema";
import { Dropdown } from "ui/components/overlay/Dropdown";
import { DataTable } from "ui/components/table/DataTable";
import { useEvent } from "ui/hooks/use-event";

View File

@@ -3,16 +3,13 @@ import type { TObject } from "core/utils";
import { omit } from "lodash-es";
import { useRef, useState } from "react";
import { TbCirclePlus, TbVariable } from "react-icons/tb";
import { useBknd } from "ui/client/BkndProvider";
import { Button } from "ui/components/buttons/Button";
import * as Formy from "ui/components/form/Formy";
import { JsonSchemaForm, type JsonSchemaFormRef } from "ui/components/form/json-schema";
import { Dropdown } from "ui/components/overlay/Dropdown";
import { Modal } from "ui/components/overlay/Modal";
import { useLocation } from "wouter";
import { useBknd } from "../../../client/BkndProvider";
import { Button } from "../../../components/buttons/Button";
import * as Formy from "../../../components/form/Formy";
import {
JsonSchemaForm,
type JsonSchemaFormRef
} from "../../../components/form/json-schema/JsonSchemaForm";
import { Dropdown } from "../../../components/overlay/Dropdown";
import { Modal } from "../../../components/overlay/Modal";
export type SettingsNewModalProps = {
schema: TObject;

View File

@@ -2,7 +2,7 @@ import { parse } from "core/utils";
import { AppFlows } from "flows/AppFlows";
import { useState } from "react";
import { JsonViewer } from "../../../components/code/JsonViewer";
import { JsonSchemaForm } from "../../../components/form/json-schema/JsonSchemaForm";
import { JsonSchemaForm } from "../../../components/form/json-schema";
import { Scrollable } from "../../../layouts/AppShell/AppShell";
export default function FlowCreateSchemaTest() {

View File

@@ -2,12 +2,9 @@ import Form from "@rjsf/core";
import type { RJSFSchema, UiSchema } from "@rjsf/utils";
import { useRef } from "react";
import { TbPlus, TbTrash } from "react-icons/tb";
import { Button } from "../../../../components/buttons/Button";
import { Button } from "ui/components/buttons/Button";
import { JsonSchemaForm, type JsonSchemaFormRef } from "ui/components/form/json-schema";
import * as Formy from "../../../../components/form/Formy";
import {
JsonSchemaForm,
type JsonSchemaFormRef
} from "../../../../components/form/json-schema/JsonSchemaForm";
import * as AppShell from "../../../../layouts/AppShell/AppShell";
class CfJsonSchemaValidator {}

View File

@@ -1,7 +1,7 @@
import type { Schema } from "@cfworker/json-schema";
import { useState } from "react";
import { JsonSchemaForm } from "../../../components/form/json-schema/JsonSchemaForm";
import { Scrollable } from "../../../layouts/AppShell/AppShell";
import { JsonSchemaForm } from "ui/components/form/json-schema";
import { Scrollable } from "ui/layouts/AppShell/AppShell";
const schema: Schema = {
definitions: {
@@ -9,52 +9,52 @@ const schema: Schema = {
anyOf: [
{
title: "String",
type: "string",
type: "string"
},
{
title: "Number",
type: "number",
type: "number"
},
{
title: "Boolean",
type: "boolean",
},
],
type: "boolean"
}
]
},
numeric: {
anyOf: [
{
title: "Number",
type: "number",
type: "number"
},
{
title: "Datetime",
type: "string",
format: "date-time",
format: "date-time"
},
{
title: "Date",
type: "string",
format: "date",
format: "date"
},
{
title: "Time",
type: "string",
format: "time",
},
],
format: "time"
}
]
},
boolean: {
title: "Boolean",
type: "boolean",
},
type: "boolean"
}
},
type: "object",
properties: {
operand: {
enum: ["$and", "$or"],
default: "$and",
type: "string",
type: "string"
},
conditions: {
type: "array",
@@ -64,10 +64,10 @@ const schema: Schema = {
operand: {
enum: ["$and", "$or"],
default: "$and",
type: "string",
type: "string"
},
key: {
type: "string",
type: "string"
},
operator: {
type: "array",
@@ -78,30 +78,30 @@ const schema: Schema = {
type: "object",
properties: {
$eq: {
$ref: "#/definitions/primitive",
},
$ref: "#/definitions/primitive"
}
},
required: ["$eq"],
required: ["$eq"]
},
{
title: "Lower than",
type: "object",
properties: {
$lt: {
$ref: "#/definitions/numeric",
},
$ref: "#/definitions/numeric"
}
},
required: ["$lt"],
required: ["$lt"]
},
{
title: "Greather than",
type: "object",
properties: {
$gt: {
$ref: "#/definitions/numeric",
},
$ref: "#/definitions/numeric"
}
},
required: ["$gt"],
required: ["$gt"]
},
{
title: "Between",
@@ -110,13 +110,13 @@ const schema: Schema = {
$between: {
type: "array",
items: {
$ref: "#/definitions/numeric",
$ref: "#/definitions/numeric"
},
minItems: 2,
maxItems: 2,
},
maxItems: 2
}
},
required: ["$between"],
required: ["$between"]
},
{
title: "In",
@@ -125,23 +125,23 @@ const schema: Schema = {
$in: {
type: "array",
items: {
$ref: "#/definitions/primitive",
$ref: "#/definitions/primitive"
},
minItems: 1,
},
},
},
],
minItems: 1
}
}
}
]
},
minItems: 1,
},
minItems: 1
}
},
required: ["key", "operator"],
required: ["key", "operator"]
},
minItems: 1,
},
minItems: 1
}
},
required: ["operand", "conditions"],
required: ["operand", "conditions"]
};
export default function QueryJsonFormTest() {

View File

@@ -1,8 +1,8 @@
import { useEffect, useState } from "react";
import { twMerge } from "tailwind-merge";
import { useBknd } from "../../../client/BkndProvider";
import { JsonSchemaForm } from "../../../components/form/json-schema/JsonSchemaForm";
import { Scrollable } from "../../../layouts/AppShell/AppShell";
import { useBknd } from "ui/client/BkndProvider";
import { JsonSchemaForm } from "ui/components/form/json-schema";
import { Scrollable } from "ui/layouts/AppShell/AppShell";
function useSchema() {
const [schema, setSchema] = useState<any>();

View File

@@ -1,7 +1,20 @@
import { useEffect, useState } from "react";
import { useApiQuery } from "ui/client";
import { useApi, useApiQuery } from "ui/client";
import { Scrollable } from "ui/layouts/AppShell/AppShell";
function Bla() {
const api = useApi();
useEffect(() => {
(async () => {
const one = await api.data.readOne("users", 1);
const many = await api.data.readMany("users");
})();
}, []);
return null;
}
export default function SWRAndAPI() {
const [text, setText] = useState("");
const { data, ...r } = useApiQuery((api) => api.data.readOne("comments", 1), {
@@ -16,7 +29,7 @@ export default function SWRAndAPI() {
return (
<Scrollable>
<pre>{JSON.stringify(r.promise.keyArray({ search: false }))}</pre>
<pre>{JSON.stringify(r.key)}</pre>
{r.error && <div>failed to load</div>}
{r.isLoading && <div>loading...</div>}
{data && <pre>{JSON.stringify(data, null, 2)}</pre>}
@@ -26,12 +39,12 @@ export default function SWRAndAPI() {
e.preventDefault();
if (!comment) return;
await r.mutate(async () => {
/*await r.mutate(async () => {
const res = await r.api.data.updateOne("comments", comment.id, {
content: text
});
return res.data;
});
});*/
return false;
}}

View File

@@ -1,54 +1,72 @@
import { useEffect, useState } from "react";
import { useEntity, useEntityQuery } from "ui/client/api/use-entity";
import { useEntity, useEntityMutate, useEntityQuery } from "ui/client/api/use-entity";
import { Scrollable } from "ui/layouts/AppShell/AppShell";
export default function SwrAndDataApi() {
return (
<div>
<Scrollable>
asdf
<DirectDataApi />
<QueryDataApi />
</div>
<QueryMutateDataApi />
</Scrollable>
);
}
function QueryDataApi() {
const [text, setText] = useState("");
const { data, update, ...r } = useEntityQuery("comments", 1, {});
const comment = data ? data : null;
useEffect(() => {
setText(comment?.content ?? "");
}, [comment]);
function QueryMutateDataApi() {
const { mutate } = useEntityMutate("comments");
const { data, ...r } = useEntityQuery("comments", undefined, {
limit: 2
});
return (
<Scrollable>
<div>
bla
<pre>{JSON.stringify(r.key)}</pre>
{r.error && <div>failed to load</div>}
{r.isLoading && <div>loading...</div>}
{data && <pre>{JSON.stringify(data, null, 2)}</pre>}
{data && (
<form
onSubmit={async (e) => {
e.preventDefault();
if (!comment) return;
await update({ content: text });
return false;
}}
>
<input type="text" value={text} onChange={(e) => setText(e.target.value)} />
<button type="submit">submit</button>
</form>
<div>
{data.map((comment) => (
<input
key={String(comment.id)}
type="text"
value={comment.content}
onChange={async (e) => {
await mutate(comment.id, { content: e.target.value });
}}
className="border border-black"
/>
))}
</div>
)}
</Scrollable>
</div>
);
}
function QueryDataApi() {
const { data, update, ...r } = useEntityQuery("comments", undefined, {
sort: { by: "id", dir: "asc" },
limit: 3
});
return (
<div>
<pre>{JSON.stringify(r.key)}</pre>
{r.error && <div>failed to load</div>}
{r.isLoading && <div>loading...</div>}
{data && <pre>{JSON.stringify(data, null, 2)}</pre>}
</div>
);
}
function DirectDataApi() {
const [data, setData] = useState<any>();
const { create, read, update, _delete } = useEntity("comments", 1);
const { create, read, update, _delete } = useEntity("comments");
useEffect(() => {
read().then(setData);
read().then((data) => setData(data));
}, []);
return <pre>{JSON.stringify(data, null, 2)}</pre>;

View File

@@ -26,14 +26,13 @@
"esModuleInterop": true,
"skipLibCheck": true,
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo",
"outDir": "./dist/types",
"baseUrl": ".",
"paths": {
"*": ["./src/*"],
"bknd": ["./src/*"]
}
},
"include": ["./src/**/*.ts", "./src/**/*.tsx", "./env.d.ts"],
"exclude": ["node_modules", "dist/**/*", "../examples/bun"]
"include": ["./src/**/*.ts", "./src/**/*.tsx"],
"exclude": ["node_modules", "dist", "dist/types", "**/*.d.ts"]
}

View File

@@ -1,14 +1,10 @@
import { serveStatic } from "@hono/node-server/serve-static";
import { createClient } from "@libsql/client/node";
import { App } from "./src";
import { App, registries } from "./src";
import { LibsqlConnection } from "./src/data";
import { StorageLocalAdapter } from "./src/media/storage/adapters/StorageLocalAdapter";
import { registries } from "./src/modules/registries";
registries.media.add("local", {
cls: StorageLocalAdapter,
schema: StorageLocalAdapter.prototype.getSchema()
});
registries.media.register("local", StorageLocalAdapter);
const credentials = {
url: import.meta.env.VITE_DB_URL!,
@@ -24,8 +20,8 @@ export default {
async fetch(request: Request) {
const app = App.create({ connection });
app.emgr.on(
"app-built",
app.emgr.onEvent(
App.Events.AppBuiltEvent,
async () => {
app.registerAdminController({ forceDev: true });
app.module.server.client.get("/assets/*", serveStatic({ root: "./" }));

BIN
bun.lockb

Binary file not shown.

View File

@@ -45,12 +45,14 @@ export const ALL = serve({
connection: {
type: "libsql",
config: {
url: "file:data.db"
// location of your local Astro DB
// make sure to use a remote URL in production
url: "file:.astro/content.db"
}
}
});
```
For more information about the connection object, refer to the [Setup](/setup) guide. In the
For more information about the connection object, refer to the [Setup](/setup/introduction) guide. In the
special case of astro, you may also use your Astro DB credentials since it's also using LibSQL
under the hood. Refer to the [Astro DB documentation](https://docs.astro.build/en/guides/astro-db/) for more information.
@@ -73,7 +75,11 @@ export const prerender = false;
<body>
<Admin
withProvider={{ user }}
config={{ basepath: "/admin", color_scheme: "dark" }}
config={{
basepath: "/admin",
color_scheme: "dark",
logo_return_path: "/../"
}}
client:only
/>
</body>

View File

@@ -27,7 +27,7 @@ serve({
}
});
```
For more information about the connection object, refer to the [Setup](/setup) guide.
For more information about the connection object, refer to the [Setup](/setup/introduction) guide.
Run the application using Bun by executing:
```bash

View File

@@ -13,24 +13,25 @@ and then install bknd as a dependency:
## Serve the API
If you don't choose anything specific, the following code will use the `warm` mode. See the
chapter [Using a different mode](#using-a-different-mode) for available modes.
``` ts
import { serve } from "bknd/adapter/cloudflare";
export default serve(
{
app: (env: Env) => ({
connection: {
type: "libsql",
config: {
url: env.DB_URL,
authToken: env.DB_TOKEN
}
export default serve({
app: (env: Env) => ({
connection: {
type: "libsql",
config: {
url: env.DB_URL,
authToken: env.DB_TOKEN
}
})
}
);
}
})
});
```
For more information about the connection object, refer to the [Setup](/setup) guide.
For more information about the connection object, refer to the [Setup](/setup/introduction) guide.
Now run the worker:
```bash
@@ -49,49 +50,145 @@ bucket = "node_modules/bknd/dist/static"
```
And then modify the worker entry as follows:
``` ts {2, 15, 17}
``` ts {2, 14, 15}
import { serve } from "bknd/adapter/cloudflare";
import manifest from "__STATIC_CONTENT_MANIFEST";
export default serve(
{
app: (env: Env) => ({
connection: {
type: "libsql",
config: {
url: env.DB_URL,
authToken: env.DB_TOKEN
}
export default serve({
app: (env: Env) => ({
connection: {
type: "libsql",
config: {
url: env.DB_URL,
authToken: env.DB_TOKEN
}
}),
setAdminHtml: true
},
manifest
);
}
}),
manifest,
setAdminHtml: true
});
```
## Adding custom routes
You can also add custom routes by defining them after the app has been built, like so:
```ts {15-17}
```ts {14-16}
import { serve } from "bknd/adapter/cloudflare";
import manifest from "__STATIC_CONTENT_MANIFEST";
export default serve(
{
app: (env: Env) => ({
connection: {
type: "libsql",
config: {
url: env.DB_URL,
authToken: env.DB_TOKEN
}
export default serve({
app: (env: Env) => ({
connection: {
type: "libsql",
config: {
url: env.DB_URL,
authToken: env.DB_TOKEN
}
}),
onBuilt: async (app) => {
app.modules.server.get("/hello", (c) => c.json({ hello: "world" }));
},
setAdminHtml: true
}
}),
onBuilt: async (app) => {
app.modules.server.get("/hello", (c) => c.json({ hello: "world" }));
},
manifest
);
manifest,
setAdminHtml: true
});
```
## Using a different mode
With the Cloudflare Workers adapter, you're being offered to 4 modes to choose from (default:
`warm`):
| Mode | Description | Use Case |
|:----------|:-------------------------------------------------------------------------------------------|:----------------------------------------------------------------------------------------------------------------------------------------|
| `fresh` | On every request, the configuration gets refetched, app built and then served. | Ideal if you don't want to deal with eviction, KV or Durable Objects. |
| `warm` | It tries to keep the built app in memory for as long as possible, and rebuilds if evicted. | Better response times, should be the default choice. |
| `cache` | The configuration is fetched from KV to reduce the initial roundtrip to the database. | Generally faster response times with irregular access patterns. |
| `durable` | The bknd app is ran inside a Durable Object and can be configured to stay alive. | Slowest boot time, but fastest responses. Can be kept alive for as long as you want, giving similar response times as server instances. |
### Modes: `fresh` and `warm`
To use either `fresh` or `warm`, all you have to do is adding the desired mode to `cloudflare.
mode`, like so:
```ts
import { serve } from "bknd/adapter/cloudflare";
export default serve({
/* ... */,
mode: "fresh" // mode: "fresh" | "warm" | "cache" | "durable"
});
```
### Mode: `cache`
For the cache mode to work, you also need to specify the KV to be used. For this, use the
`bindings` property:
```ts
import { serve } from "bknd/adapter/cloudflare";
export default serve({
/* ... */,
mode: "cache",
bindings: (env: Env) => ({ kv: env.KV })
});
```
### Mode: `durable` (advanced)
To use the `durable` mode, you have to specify the Durable Object to extract from your
environment, and additionally export the `DurableBkndApp` class:
```ts
import { serve, DurableBkndApp } from "bknd/adapter/cloudflare";
export { DurableBkndApp };
export default serve({
/* ... */,
mode: "durable",
bindings: (env: Env) => ({ dobj: env.DOBJ }),
keepAliveSeconds: 60 // optional
});
```
Next, you need to define the Durable Object in your `wrangler.toml` file (refer to the [Durable
Objects](https://developers.cloudflare.com/durable-objects/) documentation):
```toml
[[durable_objects.bindings]]
name = "DOBJ"
class_name = "DurableBkndApp"
[[migrations]]
tag = "v1"
new_classes = ["DurableBkndApp"]
```
Since the communication between the Worker and Durable Object is serialized, the `onBuilt`
property won't work. To use it (e.g. to specify special routes), you need to extend from the
`DurableBkndApp`:
```ts
import type { App } from "bknd";
import { serve, DurableBkndApp } from "bknd/adapter/cloudflare";
export default serve({
/* ... */,
mode: "durable",
bindings: (env: Env) => ({ dobj: env.DOBJ }),
keepAliveSeconds: 60 // optional
});
export class CustomDurableBkndApp extends DurableBkndApp {
async onBuilt(app: App) {
app.modules.server.get("/custom/endpoint", (c) => c.text("Custom"));
}
}
```
In case you've already deployed your Worker, the deploy command may complain about a new class
being used. To fix this issue, you need to add a "rename migration":
```toml
[[durable_objects.bindings]]
name = "DOBJ"
class_name = "CustomDurableBkndApp"
[[migrations]]
tag = "v1"
new_classes = ["DurableBkndApp"]
[[migrations]]
tag = "v2"
renamed_classes = [{from = "DurableBkndApp", to = "CustomDurableBkndApp"}]
deleted_classes = ["DurableBkndApp"]
```

View File

@@ -14,7 +14,7 @@ Install bknd as a dependency:
import { serve } from "bknd/adapter/nextjs";
export const config = {
runtime: "experimental-edge",
runtime: "experimental-edge", // or "edge", depending on your nextjs version
unstable_allowDynamic: ["**/*.js"]
};
@@ -28,12 +28,13 @@ export default serve({
}
});
```
For more information about the connection object, refer to the [Setup](/setup) guide.
For more information about the connection object, refer to the [Setup](/setup/introduction) guide.
## Enabling the Admin UI
Create a file `[[...admin]].tsx` inside the `pages/admin` folder:
```tsx
// pages/admin/[[...admin]].tsx
import type { InferGetServerSidePropsType as InferProps } from "next";
import { withApi } from "bknd/adapter/nextjs";
import dynamic from "next/dynamic";
import "bknd/dist/styles.css";
@@ -50,9 +51,12 @@ export const getServerSideProps = withApi(async (context) => {
};
});
export default function AdminPage() {
export default function AdminPage({ user }: InferProps<typeof getServerSideProps>) {
if (typeof document === "undefined") return null;
return <Admin withProvider config={{ basepath: "/admin" }} />;
return <Admin
withProvider={{ user }}
config={{ basepath: "/admin", logo_return_path: "/../" }}
/>;
}
```

View File

@@ -29,7 +29,7 @@ const config = {
serve(config);
```
For more information about the connection object, refer to the [Setup](/setup) guide.
For more information about the connection object, refer to the [Setup](/setup/introduction) guide.
Run the application using node by executing:
```bash

View File

@@ -26,7 +26,7 @@ const handler = serve({
export const loader = handler;
export const action = handler;
```
For more information about the connection object, refer to the [Setup](/setup) guide.
For more information about the connection object, refer to the [Setup](/setup/introduction) guide.
Now make sure that you wrap your root layout with the `ClientProvider` so that all components
share the same context:

View File

@@ -61,7 +61,11 @@
"navigation": [
{
"group": "Getting Started",
"pages": ["introduction", "setup", "sdk", "react", "cli"]
"pages": ["introduction", "sdk", "react", "cli"]
},
{
"group": "Setup",
"pages": ["setup/introduction", "setup/database"]
},
{
"group": "Modules",
@@ -74,39 +78,17 @@
"modules/flows"
]
},
{
"group": "Configuration",
"pages": [
"config/overview",
"config/migration",
{
"group": "Modules",
"pages": [
"config/modules/overview",
"config/modules/server",
"config/modules/data",
"config/modules/auth",
"config/modules/flows",
"config/modules/media"
]
}
]
},
{
"group": "Integration",
"pages": [
"integration/extending",
"integration/hono",
"integration/nextjs",
"integration/remix",
"integration/cloudflare",
"integration/bun",
"integration/vite",
"integration/express",
"integration/astro",
"integration/node",
"integration/deno",
"integration/browser",
"integration/docker"
]
},

183
docs/setup/database.mdx Normal file
View File

@@ -0,0 +1,183 @@
---
title: 'Database'
description: 'Choosing the right database configuration'
---
In order to use **bknd**, you need to prepare access information to your database and install
the dependencies.
<Note>
Connections to the database are managed using Kysely. Therefore, all its dialects are
theoretically supported. However, only the `SQLite` dialect is implemented as of now.
</Note>
## Database
### SQLite as file
The easiest to get started is using SQLite as a file. When serving the API in the "Integrations",
the function accepts an object with connection details. To use a file, use the following:
```json
{
"type": "libsql",
"config": {
"url": "file:<path/to/your/database.db>"
}
}
```
Please note that using SQLite as a file is only supported in server environments.
### SQLite using LibSQL
Turso offers a SQLite-fork called LibSQL that runs a server around your SQLite database. To
point **bknd** to a local instance of LibSQL, [install Turso's CLI](https://docs.turso.tech/cli/introduction) and run the following command:
```bash
turso dev
```
The command will yield a URL. Use it in the connection object:
```json
{
"type": "libsql",
"config": {
"url": "http://localhost:8080"
}
}
```
### SQLite using LibSQL on Turso
If you want to use LibSQL on Turso, [sign up for a free account](https://turso.tech/), create a database and point your
connection object to your new database:
```json
{
"type": "libsql",
"config": {
"url": "libsql://your-database-url.turso.io",
"authToken": "your-auth-token"
}
}
```
### Custom Connection
<Note>
Follow the progress of custom connections on its [Github Issue](https://github.com/bknd-io/bknd/issues/24).
If you're interested, make sure to upvote so it can be prioritized.
</Note>
Any bknd app instantiation accepts as connection either `undefined`, a connection object like
described above, or an class instance that extends from `Connection`:
```ts
import { createApp } from "bknd";
import { Connection } from "bknd/data";
class CustomConnection extends Connection {
constructor() {
const kysely = new Kysely(/* ... */);
super(kysely);
}
}
const connection = new CustomConnection();
// e.g. and then, create an instance
const app = createApp({ connection })
```
## Initial Structure
To provide an initial database structure, you can pass `initialConfig` to the creation of an app. This will only be used if there isn't an existing configuration found in the database given. Here is a quick example:
```ts
import { em, entity, text, number } from "bknd/data";
const schema = em({
posts: entity("posts", {
// "id" is automatically added
title: text().required(),
slug: text().required(),
content: text(),
views: number()
}),
comments: entity("comments", {
content: text()
})
// relations and indices are defined separately.
// the first argument are the helper functions, the second the entities.
}, ({ relation, index }, { posts, comments }) => {
relation(comments).manyToOne(posts);
// relation as well as index can be chained!
index(posts).on(["title"]).on(["slug"], true);
});
// to get a type from your schema, use:
type Database = (typeof schema)["DB"];
// type Database = {
// posts: {
// id: number;
// title: string;
// content: string;
// views: number;
// },
// comments: {
// id: number;
// content: string;
// }
// }
// pass the schema to the app
const app = createApp({
connection: { /* ... */ },
initialConfig: {
data: schema.toJSON()
}
});
```
Note that we didn't add relational fields directly to the entity, but instead defined them afterwards. That is because the relations are managed outside the entity scope to have an unified expierence for all kinds of relations (e.g. many-to-many).
<Note>
Defined relations are currently not part of the produced types for the structure. We're working on that, but in the meantime, you can define them manually.
</Note>
### Type completion
All entity related functions use the types defined in `DB` from `bknd/core`. To get type completion, you can extend that interface with your own schema:
```ts
import { em } from "bknd/data";
import { Api } from "bknd";
// const schema = em({ ... });
type Database = (typeof schema)["DB"];
declare module "bknd/core" {
interface DB extends Database {}
}
const api = new Api({ /* ... */ });
const { data: posts } = await api.data.readMany("posts", {})
// `posts` is now typed as Database["posts"]
```
The type completion is available for the API as well as all provided [React hooks](/react).
### Seeding the database
To seed your database with initial data, you can pass a `seed` function to the configuration. It
provides the `ModuleBuildContext` ([reference](/setup/introduction#modulebuildcontext)) as the first argument.
Note that the seed function will only be executed on app's first boot. If a configuration
already exists in the database, it will not be executed.
```ts
import { createApp, type ModuleBuildContext } from "bknd";
const app = createApp({
connection: { /* ... */ },
initialConfig: { /* ... */ },
options: {
seed: async (ctx: ModuleBuildContext) => {
await ctx.em.mutator("posts").insertMany([
{ title: "First post", slug: "first-post", content: "..." },
{ title: "Second post", slug: "second-post" }
]);
}
}
});
```

205
docs/setup/introduction.mdx Normal file
View File

@@ -0,0 +1,205 @@
---
title: 'Introduction'
description: 'Setting up bknd'
---
There are several methods to get **bknd** up and running. You can choose between these options:
1. [Run it using the CLI](/cli): That's the easiest and fastest way to get started.
2. Use a runtime like [Node](/integration/node), [Bun](/integration/bun) or
[Cloudflare](/integration/cloudflare) (workerd). This will run the API and UI in the runtime's
native server and serves the UI assets statically from `node_modules`.
3. Run it inside your React framework of choice like [Next.js](/integration/nextjs),
[Astro](/integration/astro) or [Remix](/integration/remix).
There is also a fourth option, which is running it inside a
[Docker container](/integration/docker). This is essentially a wrapper around the CLI.
## Basic setup
Regardless of the method you choose, at the end all adapters come down to the actual
instantiation of the `App`, which in raw looks like this:
```ts
import { createApp, type CreateAppConfig } from "bknd";
// create the app
const config = { /* ... */ } satisfies CreateAppConfig;
const app = createApp(config);
// build the app
await app.build();
// export for Web API compliant envs
export default app;
```
In Web API compliant environments, all you have to do is to default exporting the app, as it
implements the `Fetch` API.
## Configuration (`CreateAppConfig`)
The `CreateAppConfig` type is the main configuration object for the `createApp` function. It has
the following properties:
```ts
import type { Connection } from "bknd/data";
import type { Config } from "@libsql/client";
type AppPlugin = (app: App) => Promise<void> | void;
type LibSqlCredentials = Config;
type CreateAppConfig = {
connection?:
| Connection
| {
type: "libsql";
config: LibSqlCredentials;
};
initialConfig?: InitialModuleConfigs;
plugins?: AppPlugin[];
options?: {
basePath?: string;
trustFetched?: boolean;
onFirstBoot?: () => Promise<void>;
seed?: (ctx: ModuleBuildContext) => Promise<void>;
};
};
```
### `connection`
The `connection` property is the main connection object to the database. It can be either an
object with a type specifier (only `libsql` is supported at the moment) and the actual
`Connection` class. The `libsql` connection object looks like this:
```ts
const connection = {
type: "libsql",
config: {
url: string;
authToken?: string;
};
}
```
Alternatively, you can pass an instance of a `Connection` class directly,
see [Custom Connection](/setup/database#custom-connection) as a reference.
If the connection object is omitted, the app will try to use an in-memory database.
### `initialConfig`
As initial configuration, you can either pass a partial configuration object or a complete one
with a version number. The version number is used to automatically migrate the configuration up
to the latest version upon boot. The default configuration looks like this:
```json
{
"server": {
"admin": {
"basepath": "",
"color_scheme": "light",
"logo_return_path": "/"
},
"cors": {
"origin": "*",
"allow_methods": ["GET", "POST", "PATCH", "PUT", "DELETE" ],
"allow_headers": ["Content-Type", "Content-Length", "Authorization", "Accept"]
}
},
"data": {
"basepath": "/api/data",
"entities": {},
"relations": {},
"indices": {}
},
"auth": {
"enabled": false,
"basepath": "/api/auth",
"entity_name": "users",
"allow_register": true,
"jwt": {
"secret": "",
"alg": "HS256",
"fields": ["id", "email", "role"]
},
"cookie": {
"path": "/",
"sameSite": "lax",
"secure": true,
"httpOnly": true,
"expires": 604800,
"renew": true,
"pathSuccess": "/",
"pathLoggedOut": "/"
},
"strategies": {
"password": {
"type": "password",
"config": {
"hashing": "sha256"
}
}
},
"roles": {}
},
"media": {
"enabled": false,
"basepath": "/api/media",
"entity_name": "media",
"storage": {}
},
"flows": {
"basepath": "/api/flows",
"flows": {}
}
}
```
You can use the CLI to get the default configuration:
```sh
npx bknd config --pretty
```
To validate your configuration against a JSON schema, you can also dump the schema using the CLI:
```sh
npx bknd schema
```
To create an initial data structure, you can use helpers [described here](/setup/database#initial-structure).
### `plugins`
The `plugins` property is an array of functions that are called after the app has been built,
but before its event is emitted. This is useful for adding custom routes or other functionality.
A simple plugin that adds a custom route looks like this:
```ts
export const myPlugin: AppPlugin = (app) => {
app.server.get("/hello", (c) => c.json({ hello: "world" }));
};
```
Since each plugin has full access to the `app` instance, it can add routes, modify the database
structure, add custom middlewares, respond to or add events, etc. Plugins are very powerful, so
make sure to only run trusted ones.
### `options`
This object is passed to the `ModuleManager` which is responsible for:
- validating and maintaining configuration of all modules
- building all modules (data, auth, media, flows)
- maintaining the `ModuleBuildContext` used by the modules
The `options` object has the following properties:
- `basePath` (`string`): The base path for the Hono instance. This is used to prefix all routes.
- `trustFetched` (`boolean`): If set to `true`, the app will not perform any validity checks for
the given or fetched configuration.
- `onFirstBoot` (`() => Promise<void>`): A function that is called when the app is booted for
the first time.
- `seed` (`(ctx: ModuleBuildContext) => Promise<void>`): A function that is called when the app is
booted for the first time and an initial partial configuration is provided.
## `ModuleBuildContext`
```ts
type ModuleBuildContext = {
connection: Connection;
server: Hono;
em: EntityManager;
emgr: EventManager;
guard: Guard;
};
```

View File

@@ -1,5 +1,5 @@
{
"_variables": {
"lastUpdateCheck": 1732785435939
"lastUpdateCheck": 1734966049246
}
}

View File

@@ -21,4 +21,5 @@ pnpm-debug.log*
.DS_Store
# jetbrains setting folder
.idea/
.idea/
*.db

View File

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

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