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 type { TObject, TString } from "@sinclair/typebox";
import { Registry } from "../../src/core/registry/Registry"; import { Registry } from "../../src/core/registry/Registry";
import { type TSchema, Type } from "../../src/core/utils"; import { type TSchema, Type } from "../../src/core/utils";
@@ -11,6 +11,9 @@ class What {
method() { method() {
return null; return null;
} }
getType() {
return Type.Object({ type: Type.String() });
}
} }
class What2 extends What {} class What2 extends What {}
class NotAllowed {} class NotAllowed {}
@@ -32,25 +35,53 @@ describe("Registry", () => {
} satisfies Record<string, Test1>); } satisfies Record<string, Test1>);
const item = registry.get("first"); 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", { registry.add("second", {
cls: What2, cls: What2,
schema: Type.Object({ type: Type.String(), what: Type.String() }), schema: second,
enabled: true 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", { registry.add("third", {
// @ts-expect-error // @ts-expect-error
cls: NotAllowed, cls: NotAllowed,
schema: Type.Object({ type: Type.String({ default: "1" }), what22: Type.String() }), schema: third,
enabled: true enabled: true
}); });
// @ts-ignore
expect(registry.get("third").schema).toEqual(third);
const fourth = Type.Object({ type: Type.Number(), what22: Type.String() });
registry.add("fourth", { registry.add("fourth", {
cls: What, cls: What,
// @ts-expect-error // @ts-expect-error
schema: Type.Object({ type: Type.Number(), what22: Type.String() }), schema: fourth,
enabled: true 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 TextField("label", { required: true, minLength: 1 }),
new NumberField("count", { default_value: 0 }) new NumberField("count", { default_value: 0 })
]); ]);
const em = new EntityManager([items], connection); const em = new EntityManager<any>([items], connection);
await em.connection.kysely.schema await em.connection.kysely.schema
.createTable("items") .createTable("items")
@@ -175,4 +175,18 @@ describe("Mutator simple", async () => {
{ id: 8, label: "keep", count: 0 } { 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, BooleanField,
DateField, DateField,
Entity, Entity,
EntityIndex,
EntityManager,
EnumField, EnumField,
JsonField, JsonField,
ManyToManyRelation, ManyToManyRelation,
@@ -12,6 +14,7 @@ import {
PolymorphicRelation, PolymorphicRelation,
TextField TextField
} from "../../src/data"; } from "../../src/data";
import { DummyConnection } from "../../src/data/connection/DummyConnection";
import { import {
FieldPrototype, FieldPrototype,
type FieldSchema, type FieldSchema,
@@ -20,6 +23,7 @@ import {
boolean, boolean,
date, date,
datetime, datetime,
em,
entity, entity,
enumm, enumm,
json, json,
@@ -46,12 +50,17 @@ describe("prototype", () => {
}); });
test("...2", async () => { test("...2", async () => {
const user = entity("users", { const users = entity("users", {
name: text().required(), name: text(),
bio: text(), bio: text(),
age: number(), 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()); //console.log("user", user.toJSON());
}); });
@@ -266,4 +275,38 @@ describe("prototype", () => {
const obj: Schema<typeof test> = {} as any; 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("hidden", { hidden: true }),
new TextField("not_fillable", { fillable: false }) 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 }); await em.schema().sync({ force: true });
const payload = { label: "item 1", count: 1 }; 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 posts = new Entity("posts", [new TextField("title")]);
const users = new Entity("users", [new TextField("username")]); const users = new Entity("users", [new TextField("username")]);
const relations = [new ManyToOneRelation(posts, users)]; 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 }); await em.schema().sync({ force: true });
test("RelationMutator", async () => { test("RelationMutator", async () => {
@@ -192,7 +192,7 @@ describe("[data] Mutator (OneToOne)", async () => {
const users = new Entity("users", [new TextField("username")]); const users = new Entity("users", [new TextField("username")]);
const settings = new Entity("settings", [new TextField("theme")]); const settings = new Entity("settings", [new TextField("theme")]);
const relations = [new OneToOneRelation(users, settings)]; 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 }); await em.schema().sync({ force: true });
test("insertOne: missing ref", async () => { test("insertOne: missing ref", async () => {
@@ -276,7 +276,7 @@ describe("[data] Mutator (ManyToMany)", async () => {
describe("[data] Mutator (Events)", async () => { describe("[data] Mutator (Events)", async () => {
const entity = new Entity("test", [new TextField("label")]); 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 }); await em.schema().sync({ force: true });
const events = new Map<string, any>(); 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,15 +9,43 @@ const watch = args.includes("--watch");
const minify = args.includes("--minify"); const minify = args.includes("--minify");
const types = args.includes("--types"); const types = args.includes("--types");
const sourcemap = args.includes("--sourcemap"); const sourcemap = args.includes("--sourcemap");
const clean = args.includes("--clean");
if (clean) {
console.log("Cleaning dist");
await $`rm -rf dist`; await $`rm -rf dist`;
if (types) { }
let types_running = false;
function buildTypes() {
if (types_running) return;
types_running = true;
Bun.spawn(["bun", "build:types"], { Bun.spawn(["bun", "build:types"], {
onExit: () => { onExit: () => {
console.log("Types built"); 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 * Build static assets
@@ -46,7 +74,8 @@ const result = await esbuild.build({
__isDev: "0", __isDev: "0",
"process.env.NODE_ENV": '"production"' "process.env.NODE_ENV": '"production"'
}, },
chunkNames: "chunks/[name]-[hash]" chunkNames: "chunks/[name]-[hash]",
logLevel: "error"
}); });
// Write manifest // Write manifest
@@ -96,6 +125,9 @@ await tsup.build({
treeshake: true, treeshake: true,
loader: { loader: {
".svg": "dataurl" ".svg": "dataurl"
},
onSuccess: async () => {
delayTypes();
} }
}); });
@@ -117,11 +149,12 @@ await tsup.build({
loader: { loader: {
".svg": "dataurl" ".svg": "dataurl"
}, },
onSuccess: async () => {
console.log("--- ui built");
},
esbuildOptions: (options) => { esbuildOptions: (options) => {
options.logLevel = "silent";
options.chunkNames = "chunks/[name]-[hash]"; options.chunkNames = "chunks/[name]-[hash]";
},
onSuccess: async () => {
delayTypes();
} }
}); });
@@ -148,7 +181,10 @@ function baseConfig(adapter: string): tsup.Options {
], ],
metafile: true, metafile: true,
splitting: false, splitting: false,
treeshake: true treeshake: true,
onSuccess: async () => {
delayTypes();
}
}; };
} }

View File

@@ -3,16 +3,16 @@
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,
"bin": "./dist/cli/index.js", "bin": "./dist/cli/index.js",
"version": "0.3.4-alpha1", "version": "0.4.0",
"scripts": { "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", "dev": "vite",
"test": "ALL_TESTS=1 bun test --bail", "test": "ALL_TESTS=1 bun test --bail",
"build": "NODE_ENV=production bun run build.ts --minify --types", "build": "NODE_ENV=production bun run build.ts --minify --types",
"watch": "bun run build.ts --types --watch", "watch": "bun run build.ts --types --watch",
"types": "bun tsc --noEmit", "types": "bun tsc --noEmit",
"clean:types": "find ./dist -name '*.d.ts' -delete && rm -f ./dist/tsconfig.tsbuildinfo", "clean:types": "find ./dist -name '*.d.ts' -delete && rm -f ./dist/tsconfig.tsbuildinfo",
"build:types": "tsc --emitDeclarationOnly", "build:types": "tsc --emitDeclarationOnly && tsc-alias",
"build:css": "bun tailwindcss -i src/ui/main.css -o ./dist/static/styles.css", "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", "watch:css": "bun tailwindcss --watch -i src/ui/main.css -o ./dist/styles.css",
"updater": "bun x npm-check-updates -ui", "updater": "bun x npm-check-updates -ui",
@@ -75,6 +75,7 @@
"tailwind-merge": "^2.5.4", "tailwind-merge": "^2.5.4",
"tailwindcss": "^3.4.14", "tailwindcss": "^3.4.14",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tsc-alias": "^1.8.10",
"tsup": "^8.3.5", "tsup": "^8.3.5",
"vite": "^5.4.10", "vite": "^5.4.10",
"vite-plugin-static-copy": "^2.0.0", "vite-plugin-static-copy": "^2.0.0",
@@ -90,75 +91,75 @@
}, },
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/types/index.d.ts",
"exports": { "exports": {
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/types/index.d.ts",
"import": "./dist/index.js", "import": "./dist/index.js",
"require": "./dist/index.cjs" "require": "./dist/index.cjs"
}, },
"./ui": { "./ui": {
"types": "./dist/ui/index.d.ts", "types": "./dist/types/ui/index.d.ts",
"import": "./dist/ui/index.js", "import": "./dist/ui/index.js",
"require": "./dist/ui/index.cjs" "require": "./dist/ui/index.cjs"
}, },
"./client": { "./client": {
"types": "./dist/ui/client/index.d.ts", "types": "./dist/types/ui/client/index.d.ts",
"import": "./dist/ui/client/index.js", "import": "./dist/ui/client/index.js",
"require": "./dist/ui/client/index.cjs" "require": "./dist/ui/client/index.cjs"
}, },
"./data": { "./data": {
"types": "./dist/data/index.d.ts", "types": "./dist/types/data/index.d.ts",
"import": "./dist/data/index.js", "import": "./dist/data/index.js",
"require": "./dist/data/index.cjs" "require": "./dist/data/index.cjs"
}, },
"./core": { "./core": {
"types": "./dist/core/index.d.ts", "types": "./dist/types/core/index.d.ts",
"import": "./dist/core/index.js", "import": "./dist/core/index.js",
"require": "./dist/core/index.cjs" "require": "./dist/core/index.cjs"
}, },
"./utils": { "./utils": {
"types": "./dist/core/utils/index.d.ts", "types": "./dist/types/core/utils/index.d.ts",
"import": "./dist/core/utils/index.js", "import": "./dist/core/utils/index.js",
"require": "./dist/core/utils/index.cjs" "require": "./dist/core/utils/index.cjs"
}, },
"./cli": { "./cli": {
"types": "./dist/cli/index.d.ts", "types": "./dist/types/cli/index.d.ts",
"import": "./dist/cli/index.js", "import": "./dist/cli/index.js",
"require": "./dist/cli/index.cjs" "require": "./dist/cli/index.cjs"
}, },
"./adapter/cloudflare": { "./adapter/cloudflare": {
"types": "./dist/adapter/cloudflare/index.d.ts", "types": "./dist/types/adapter/cloudflare/index.d.ts",
"import": "./dist/adapter/cloudflare/index.js", "import": "./dist/adapter/cloudflare/index.js",
"require": "./dist/adapter/cloudflare/index.cjs" "require": "./dist/adapter/cloudflare/index.cjs"
}, },
"./adapter/vite": { "./adapter/vite": {
"types": "./dist/adapter/vite/index.d.ts", "types": "./dist/types/adapter/vite/index.d.ts",
"import": "./dist/adapter/vite/index.js", "import": "./dist/adapter/vite/index.js",
"require": "./dist/adapter/vite/index.cjs" "require": "./dist/adapter/vite/index.cjs"
}, },
"./adapter/nextjs": { "./adapter/nextjs": {
"types": "./dist/adapter/nextjs/index.d.ts", "types": "./dist/types/adapter/nextjs/index.d.ts",
"import": "./dist/adapter/nextjs/index.js", "import": "./dist/adapter/nextjs/index.js",
"require": "./dist/adapter/nextjs/index.cjs" "require": "./dist/adapter/nextjs/index.cjs"
}, },
"./adapter/remix": { "./adapter/remix": {
"types": "./dist/adapter/remix/index.d.ts", "types": "./dist/types/adapter/remix/index.d.ts",
"import": "./dist/adapter/remix/index.js", "import": "./dist/adapter/remix/index.js",
"require": "./dist/adapter/remix/index.cjs" "require": "./dist/adapter/remix/index.cjs"
}, },
"./adapter/bun": { "./adapter/bun": {
"types": "./dist/adapter/bun/index.d.ts", "types": "./dist/types/adapter/bun/index.d.ts",
"import": "./dist/adapter/bun/index.js", "import": "./dist/adapter/bun/index.js",
"require": "./dist/adapter/bun/index.cjs" "require": "./dist/adapter/bun/index.cjs"
}, },
"./adapter/node": { "./adapter/node": {
"types": "./dist/adapter/node/index.d.ts", "types": "./dist/types/adapter/node/index.d.ts",
"import": "./dist/adapter/node/index.js", "import": "./dist/adapter/node/index.js",
"require": "./dist/adapter/node/index.cjs" "require": "./dist/adapter/node/index.cjs"
}, },
"./adapter/astro": { "./adapter/astro": {
"types": "./dist/adapter/astro/index.d.ts", "types": "./dist/types/adapter/astro/index.d.ts",
"import": "./dist/adapter/astro/index.js", "import": "./dist/adapter/astro/index.js",
"require": "./dist/adapter/astro/index.cjs" "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() { async verifyAuth() {
try { try {
const res = await this.auth.me(); 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 { AdminController, type AdminControllerOptions } from "modules/server/AdminController";
import { SystemController } from "modules/server/SystemController"; 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"; static override slug = "app-config-updated";
} }
export class AppBuiltEvent extends Event<{ app: App }> { export class AppBuiltEvent extends AppEvent {
static override slug = "app-built"; 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 = { export type CreateAppConfig = {
connection?: connection?:
@@ -28,29 +32,42 @@ export type CreateAppConfig = {
config: LibSqlCredentials; config: LibSqlCredentials;
}; };
initialConfig?: InitialModuleConfigs; initialConfig?: InitialModuleConfigs;
plugins?: AppPlugin<any>[]; plugins?: AppPlugin[];
options?: Omit<ModuleManagerOptions, "initial" | "onUpdated">; options?: Omit<ModuleManagerOptions, "initial" | "onUpdated">;
}; };
export type AppConfig = InitialModuleConfigs; export type AppConfig = InitialModuleConfigs;
export class App<DB = any> { export class App {
modules: ModuleManager; modules: ModuleManager;
static readonly Events = AppEvents; static readonly Events = AppEvents;
adminController?: AdminController;
private trigger_first_boot = false;
constructor( constructor(
private connection: Connection, private connection: Connection,
_initialConfig?: InitialModuleConfigs, _initialConfig?: InitialModuleConfigs,
private plugins: AppPlugin<DB>[] = [], private plugins: AppPlugin[] = [],
moduleManagerOptions?: ModuleManagerOptions moduleManagerOptions?: ModuleManagerOptions
) { ) {
this.modules = new ModuleManager(connection, { this.modules = new ModuleManager(connection, {
...moduleManagerOptions, ...moduleManagerOptions,
initial: _initialConfig, initial: _initialConfig,
onUpdated: async (key, config) => { 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.build({ sync: true, save: true });
await this.emgr.emit(new AppConfigUpdatedEvent({ app: this })); 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); this.modules.ctx().emgr.registerEvents(AppEvents);
@@ -76,7 +93,7 @@ export class App<DB = any> {
// load plugins // load plugins
if (this.plugins.length > 0) { 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); //console.log("emitting built", options);
@@ -88,14 +105,24 @@ export class App<DB = any> {
if (options?.save) { if (options?.save) {
await this.modules.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) { mutateConfig<Module extends keyof Modules>(module: Module) {
return this.modules.get(module).schema(); return this.modules.get(module).schema();
} }
get server() {
return this.modules.server;
}
get fetch(): any { get fetch(): any {
return this.modules.server.fetch; return this.server.fetch;
} }
get module() { get module() {
@@ -119,7 +146,8 @@ export class App<DB = any> {
registerAdminController(config?: AdminControllerOptions) { registerAdminController(config?: AdminControllerOptions) {
// register admin // 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; 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 = { type TAstro = {
request: Request; request: Request;
@@ -18,12 +21,10 @@ export function getApi(Astro: TAstro, options: Options = { mode: "static" }) {
} }
let app: App; let app: App;
export function serve(config: CreateAppConfig) { export function serve(config: AstroBkndConfig = {}) {
return async (args: TAstro) => { return async (args: TAstro) => {
if (!app) { if (!app) {
app = App.create(config); app = await createFrameworkApp(config);
await app.build();
} }
return app.fetch(args.request); return app.fetch(args.request);
}; };

View File

@@ -1,56 +1,60 @@
/// <reference types="bun-types" /> /// <reference types="bun-types" />
import path from "node:path"; import path from "node:path";
import { App, type CreateAppConfig } from "bknd"; import type { App } from "bknd";
import type { Serve, ServeOptions } from "bun"; import type { ServeOptions } from "bun";
import { config } from "core";
import { serveStatic } from "hono/bun"; import { serveStatic } from "hono/bun";
import { type RuntimeBkndConfig, createRuntimeApp } from "../index";
let app: App; 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"); const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
if (!app) { if (!app) {
app = App.create(_config); app = await createRuntimeApp({
...config,
app.emgr.on( registerLocalMedia: true,
"app-built", serveStatic: serveStatic({ root })
async () => { });
app.modules.server.get(
"/*",
serveStatic({
root
})
);
app.registerAdminController();
},
"sync"
);
await app.build();
} }
return app; return app;
} }
export type BunAdapterOptions = Omit<ServeOptions, "fetch"> &
CreateAppConfig & {
distPath?: string;
};
export function serve({ export function serve({
distPath, distPath,
connection, connection,
initialConfig, initialConfig,
plugins, plugins,
options, options,
port = 1337, port = config.server.default_port,
onBuilt,
buildConfig,
...serveOptions ...serveOptions
}: BunAdapterOptions = {}) { }: BunBkndConfig = {}) {
Bun.serve({ Bun.serve({
...serveOptions, ...serveOptions,
port, port,
fetch: async (request: Request) => { 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); return app.fetch(request);
} }
}); });

View File

@@ -1,21 +1,37 @@
import { DurableObject } from "cloudflare:workers"; import type { CreateAppConfig } from "bknd";
import { App, type CreateAppConfig } from "bknd";
import { Hono } from "hono"; import { Hono } from "hono";
import { serveStatic } from "hono/cloudflare-workers"; 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 = { export type CloudflareBkndConfig<Env = any> = Omit<FrameworkBkndConfig, "app"> & {
request: Request; app: CreateAppConfig | ((env: Env) => CreateAppConfig);
env: any; mode?: "warm" | "fresh" | "cache" | "durable";
ctx: ExecutionContext; bindings?: (env: Env) => {
manifest: any; kv?: KVNamespace;
dobj?: DurableObjectNamespace;
};
key?: string;
keepAliveSeconds?: number;
forceHttps?: boolean;
manifest?: string;
setAdminHtml?: boolean;
html?: string; 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 { return {
async fetch(request: Request, env: any, ctx: ExecutionContext) { async fetch(request: Request, env: any, ctx: ExecutionContext) {
const url = new URL(request.url); const url = new URL(request.url);
const manifest = config.manifest;
if (manifest) { if (manifest) {
const pathname = url.pathname.slice(1); const pathname = url.pathname.slice(1);
@@ -26,13 +42,10 @@ export function serve(_config: BkndConfig, manifest?: string, html?: string) {
hono.all("*", async (c, next) => { hono.all("*", async (c, next) => {
const res = await serveStatic({ const res = await serveStatic({
path: `./${pathname}`, path: `./${pathname}`,
manifest, manifest
onNotFound: (path) => console.log("not found", path)
})(c as any, next); })(c as any, next);
if (res instanceof Response) { if (res instanceof Response) {
const ttl = pathname.startsWith("assets/") const ttl = 60 * 60 * 24 * 365;
? 60 * 60 * 24 * 365 // 1 year
: 60 * 5; // 5 minutes
res.headers.set("Cache-Control", `public, max-age=${ttl}`); res.headers.set("Cache-Control", `public, max-age=${ttl}`);
return res; return res;
} }
@@ -44,218 +57,23 @@ export function serve(_config: BkndConfig, manifest?: string, html?: string) {
} }
} }
const config = { config.setAdminHtml = config.setAdminHtml && !!config.manifest;
..._config,
setAdminHtml: _config.setAdminHtml ?? !!manifest
};
const context = { request, env, ctx, manifest, html };
const mode = config.cloudflare?.mode?.(env);
if (!mode) { const context = { request, env, ctx } as Context;
console.log("serving fresh..."); const mode = config.mode ?? "warm";
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...");
if (config.onBuilt) { switch (mode) {
console.log("onBuilt() is not supported with DurableObject mode"); case "fresh":
} return await getFresh(config, context);
case "warm":
const start = performance.now(); return await getWarm(config, context);
case "cache":
const durable = mode.durableObject; return await getCached(config, context);
const id = durable.idFromName(mode.key); case "durable":
const stub = durable.get(id) as unknown as DurableBkndApp; return await getDurable(config, context);
default:
const create_config = typeof config.app === "function" ? config.app(env) : config.app; throw new Error(`Unknown mode ${mode}`);
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
});
} }
} }
}; };
} }
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 * 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 { 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) => { export type BkndConfig<Env = any> = CreateAppConfig & {
cache: KVNamespace; app?: CreateAppConfig | ((env: Env) => CreateAppConfig);
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>;
onBuilt?: (app: App) => Promise<void>; onBuilt?: (app: App) => Promise<void>;
beforeBuild?: (app: App) => Promise<void>;
buildConfig?: Parameters<App["build"]>[0];
}; };
export type BkndConfigJson = { export type FrameworkBkndConfig<Env = any> = BkndConfig<Env>;
app: CreateAppConfig;
setAdminHtml?: boolean; export type RuntimeBkndConfig<Env = any> = BkndConfig<Env> & {
server?: { distPath?: string;
port?: number;
};
}; };
export function nodeRequestToRequest(req: IncomingMessage): Request { export function nodeRequestToRequest(req: IncomingMessage): Request {
@@ -60,3 +40,90 @@ export function nodeRequestToRequest(req: IncomingMessage): Request {
headers 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 type { IncomingMessage, ServerResponse } from "node:http";
import { Api, App, type CreateAppConfig } from "bknd"; import { Api, type App } from "bknd";
import { nodeRequestToRequest } from "../index"; import { type FrameworkBkndConfig, createFrameworkApp, nodeRequestToRequest } from "../index";
export type NextjsBkndConfig = FrameworkBkndConfig;
type GetServerSidePropsContext = { type GetServerSidePropsContext = {
req: IncomingMessage; req: IncomingMessage;
@@ -18,7 +20,6 @@ type GetServerSidePropsContext = {
export function createApi({ req }: GetServerSidePropsContext) { export function createApi({ req }: GetServerSidePropsContext) {
const request = nodeRequestToRequest(req); const request = nodeRequestToRequest(req);
//console.log("createApi:request.headers", request.headers);
return new Api({ return new Api({
host: new URL(request.url).origin, host: new URL(request.url).origin,
headers: request.headers headers: request.headers
@@ -43,11 +44,10 @@ function getCleanRequest(req: Request) {
} }
let app: App; let app: App;
export function serve(config: CreateAppConfig) { export function serve(config: NextjsBkndConfig = {}) {
return async (req: Request) => { return async (req: Request) => {
if (!app) { if (!app) {
app = App.create(config); app = await createFrameworkApp(config);
await app.build();
} }
const request = getCleanRequest(req); const request = getCleanRequest(req);
return app.fetch(request, process.env); return app.fetch(request, process.env);

View File

@@ -1,59 +1,6 @@
import path from "node:path"; export * from "./node.adapter";
import { serve as honoServe } from "@hono/node-server"; export {
import { serveStatic } from "@hono/node-server/serve-static"; StorageLocalAdapter,
import { App, type CreateAppConfig } from "bknd"; type LocalAdapterConfig
} from "../../media/storage/adapters/StorageLocalAdapter";
export type NodeAdapterOptions = CreateAppConfig & { export { registerLocalMediaAdapter } from "../index";
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);
}
);
}

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; let app: App;
export function serve(config: CreateAppConfig) { export function serve(config: RemixBkndConfig = {}) {
return async (args: { request: Request }) => { return async (args: { request: Request }) => {
if (!app) { if (!app) {
app = App.create(config); app = await createFrameworkApp(config);
await app.build();
} }
return app.fetch(args.request); return app.fetch(args.request);
}; };

View File

@@ -1,47 +1,57 @@
import { serveStatic } from "@hono/node-server/serve-static"; import { serveStatic } from "@hono/node-server/serve-static";
import type { BkndConfig } from "bknd"; import { type RuntimeBkndConfig, createRuntimeApp } from "adapter";
import { App } from "bknd"; import type { App } from "bknd";
function createApp(config: BkndConfig, env: any) { export type ViteBkndConfig<Env = any> = RuntimeBkndConfig<Env> & {
const create_config = typeof config.app === "function" ? config.app(env) : config.app; setAdminHtml?: boolean;
return App.create(create_config); forceDev?: boolean;
} html?: string;
};
function setAppBuildListener(app: App, config: BkndConfig, html?: string) { export function addViteScript(html: string, addBkndContext: boolean = true) {
app.emgr.on( return html.replace(
"app-built", "</head>",
async () => { `<script type="module">
await config.onBuilt?.(app); import RefreshRuntime from "/@react-refresh"
if (config.setAdminHtml) { RefreshRuntime.injectIntoGlobalHook(window)
app.registerAdminController({ html, forceDev: true }); window.$RefreshReg$ = () => {}
app.module.server.client.get("/assets/*", serveStatic({ root: "./" })); window.$RefreshSig$ = () => (type) => type
} window.__vite_plugin_react_preamble_installed__ = true
}, </script>
"sync" <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 { return {
async fetch(request: Request, env: any, ctx: ExecutionContext) { async fetch(request: Request, env: any, ctx: ExecutionContext) {
const app = createApp(config, env); const app = await createApp(config, env);
setAppBuildListener(app, config, _html);
await app.build();
return app.fetch(request, env, ctx); return app.fetch(request, env, ctx);
} }
}; };
} }
let app: App; let app: App;
export async function serveCached(config: BkndConfig, _html?: string) { export async function serveCached(config: ViteBkndConfig) {
return { return {
async fetch(request: Request, env: any, ctx: ExecutionContext) { async fetch(request: Request, env: any, ctx: ExecutionContext) {
if (!app) { if (!app) {
app = createApp(config, env); app = await createApp(config, env);
setAppBuildListener(app, config, _html);
await app.build();
} }
return app.fetch(request, env, ctx); return app.fetch(request, env, ctx);

View File

@@ -1,5 +1,6 @@
import { type AuthAction, Authenticator, type ProfileExchange, Role, type Strategy } from "auth"; 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 Static, secureRandomString, transformObject } from "core/utils";
import { type Entity, EntityIndex, type EntityManager } from "data"; import { type Entity, EntityIndex, type EntityManager } from "data";
import { type FieldSchema, entity, enumm, make, text } from "data/prototype"; 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"; import { type AppAuthSchema, STRATEGIES, authConfigSchema } from "./auth-schema";
export type UserFieldSchema = FieldSchema<typeof AppAuth.usersFields>; export type UserFieldSchema = FieldSchema<typeof AppAuth.usersFields>;
declare global { declare module "core" {
interface DB { interface DB {
users: UserFieldSchema; users: { id: PrimaryFieldType } & UserFieldSchema;
} }
} }
@@ -100,7 +101,7 @@ export class AppAuth extends Module<typeof authConfigSchema> {
return this._authenticator!; return this._authenticator!;
} }
get em(): EntityManager<DB> { get em(): EntityManager {
return this.ctx.em as any; return this.ctx.em as any;
} }
@@ -160,7 +161,9 @@ export class AppAuth extends Module<typeof authConfigSchema> {
const users = this.getUsersEntity(); const users = this.getUsersEntity();
this.toggleStrategyValueVisibility(true); 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); this.toggleStrategyValueVisibility(false);
if (!result.data) { if (!result.data) {
throw new Exception("User not found", 404); throw new Exception("User not found", 404);
@@ -197,7 +200,7 @@ export class AppAuth extends Module<typeof authConfigSchema> {
throw new Exception("User already exists"); throw new Exception("User already exists");
} }
const payload = { const payload: any = {
...profile, ...profile,
strategy: strategy.getName(), strategy: strategy.getName(),
strategy_value: identifier strategy_value: identifier
@@ -284,6 +287,25 @@ export class AppAuth extends Module<typeof authConfigSchema> {
} catch (e) {} } 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 { override toJSON(secrets?: boolean): AppAuthSchema {
if (!this.config.enabled) { if (!this.config.enabled) {
return this.configDefault; return this.configDefault;

View File

@@ -220,6 +220,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
} }
private async getAuthCookie(c: Context): Promise<string | undefined> { private async getAuthCookie(c: Context): Promise<string | undefined> {
try {
const secret = this.config.jwt.secret; const secret = this.config.jwt.secret;
const token = await getSignedCookie(c, secret, "auth"); const token = await getSignedCookie(c, secret, "auth");
@@ -229,6 +230,13 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
} }
return token; return token;
} catch (e: any) {
if (e instanceof Error) {
console.error("[Error:getAuthCookie]", e.message);
}
return undefined;
}
} }
async requestCookieRefresh(c: Context) { async requestCookieRefresh(c: Context) {

View File

@@ -1,8 +1,10 @@
import type { Config } from "@libsql/client/node"; import type { Config } from "@libsql/client/node";
import { App, type CreateAppConfig } from "App"; import { App, type CreateAppConfig } from "App";
import type { BkndConfig } from "adapter"; import { StorageLocalAdapter } from "adapter/node";
import type { CliCommand } from "cli/types"; import type { CliBkndConfig, CliCommand } from "cli/types";
import { Option } from "commander"; import { Option } from "commander";
import { config } from "core";
import { registries } from "modules/registries";
import { import {
PLATFORMS, PLATFORMS,
type Platform, type Platform,
@@ -19,7 +21,7 @@ export const run: CliCommand = (program) => {
.addOption( .addOption(
new Option("-p, --port <port>", "port to run on") new Option("-p, --port <port>", "port to run on")
.env("PORT") .env("PORT")
.default(1337) .default(config.server.default_port)
.argParser((v) => Number.parseInt(v)) .argParser((v) => Number.parseInt(v))
) )
.addOption(new Option("-c, --config <config>", "config file")) .addOption(new Option("-c, --config <config>", "config file"))
@@ -37,6 +39,12 @@ export const run: CliCommand = (program) => {
.action(action); .action(action);
}; };
// automatically register local adapter
const local = StorageLocalAdapter.prototype.getName();
if (!registries.media.has(local)) {
registries.media.register(local, StorageLocalAdapter);
}
type MakeAppConfig = { type MakeAppConfig = {
connection?: CreateAppConfig["connection"]; connection?: CreateAppConfig["connection"];
server?: { platform?: Platform }; server?: { platform?: Platform };
@@ -47,8 +55,8 @@ type MakeAppConfig = {
async function makeApp(config: MakeAppConfig) { async function makeApp(config: MakeAppConfig) {
const app = App.create({ connection: config.connection }); const app = App.create({ connection: config.connection });
app.emgr.on( app.emgr.onEvent(
"app-built", App.Events.AppBuiltEvent,
async () => { async () => {
await attachServeStatic(app, config.server?.platform ?? "node"); await attachServeStatic(app, config.server?.platform ?? "node");
app.registerAdminController(); app.registerAdminController();
@@ -64,24 +72,23 @@ async function makeApp(config: MakeAppConfig) {
return app; 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 appConfig = typeof config.app === "function" ? config.app(process.env) : config.app;
const app = App.create(appConfig); const app = App.create(appConfig);
app.emgr.on( app.emgr.onEvent(
"app-built", App.Events.AppBuiltEvent,
async () => { async () => {
await attachServeStatic(app, platform ?? "node"); await attachServeStatic(app, platform ?? "node");
app.registerAdminController(); app.registerAdminController();
if (config.onBuilt) { await config.onBuilt?.(app);
await config.onBuilt(app);
}
}, },
"sync" "sync"
); );
await app.build(); await config.beforeBuild?.(app);
await app.build(config.buildConfig);
return app; return app;
} }
@@ -102,7 +109,7 @@ async function action(options: {
app = await makeApp({ connection, server: { platform: options.server } }); app = await makeApp({ connection, server: { platform: options.server } });
} else { } else {
console.log("Using config from:", configFilePath); console.log("Using config from:", configFilePath);
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); app = await makeConfigApp(config, options.server);
} }

View File

@@ -1,9 +1,9 @@
import { password as $password, text as $text } from "@clack/prompts"; import { password as $password, text as $text } from "@clack/prompts";
import type { App } from "App";
import type { PasswordStrategy } from "auth/authenticate/strategies"; import type { PasswordStrategy } from "auth/authenticate/strategies";
import type { App, BkndConfig } from "bknd";
import { makeConfigApp } from "cli/commands/run"; import { makeConfigApp } from "cli/commands/run";
import { getConfigPath } from "cli/commands/run/platform"; import { getConfigPath } from "cli/commands/run/platform";
import type { CliCommand } from "cli/types"; import type { CliBkndConfig, CliCommand } from "cli/types";
import { Argument } from "commander"; import { Argument } from "commander";
export const user: CliCommand = (program) => { export const user: CliCommand = (program) => {
@@ -21,7 +21,7 @@ async function action(action: "create" | "update", options: any) {
return; 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); const app = await makeConfigApp(config, options.server);
switch (action) { switch (action) {
@@ -37,7 +37,7 @@ async function action(action: "create" | "update", options: any) {
async function create(app: App, options: any) { async function create(app: App, options: any) {
const config = app.module.auth.toJSON(true); const config = app.module.auth.toJSON(true);
const strategy = app.module.auth.authenticator.strategy("password") as PasswordStrategy; 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({ const email = await $text({
message: "Enter email", message: "Enter email",
@@ -83,7 +83,7 @@ async function create(app: App, options: any) {
async function update(app: App, options: any) { async function update(app: App, options: any) {
const config = app.module.auth.toJSON(true); const config = app.module.auth.toJSON(true);
const strategy = app.module.auth.authenticator.strategy("password") as PasswordStrategy; 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 em = app.modules.ctx().em;
const email = (await $text({ 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"; import type { Command } from "commander";
export type CliCommand = (program: Command) => void; 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>; export type PrimaryFieldType = number | Generated<number>;
// biome-ignore lint/suspicious/noEmptyInterface: <explanation>
export interface DB {}
export const config = { export const config = {
server: {
default_port: 1337
},
data: { data: {
default_primary_field: "id" default_primary_field: "id"
} }

View File

@@ -15,6 +15,7 @@ export class EventManager<
> { > {
protected events: EventClass[] = []; protected events: EventClass[] = [];
protected listeners: EventListener[] = []; protected listeners: EventListener[] = [];
enabled: boolean = true;
constructor(events?: RegisteredEvents, listeners?: EventListener[]) { constructor(events?: RegisteredEvents, listeners?: EventListener[]) {
if (events) { if (events) {
@@ -28,6 +29,16 @@ export class EventManager<
} }
} }
enable() {
this.enabled = true;
return this;
}
disable() {
this.enabled = false;
return this;
}
clearEvents() { clearEvents() {
this.events = []; this.events = [];
return this; return this;
@@ -39,6 +50,10 @@ export class EventManager<
return this; return this;
} }
getListeners(): EventListener[] {
return [...this.listeners];
}
get Events(): { [K in keyof RegisteredEvents]: RegisteredEvents[K] } { get Events(): { [K in keyof RegisteredEvents]: RegisteredEvents[K] } {
// proxy class to access events // proxy class to access events
return new Proxy(this, { return new Proxy(this, {
@@ -133,6 +148,11 @@ export class EventManager<
async emit(event: Event) { async emit(event: Event) {
// @ts-expect-error slug is static // @ts-expect-error slug is static
const slug = event.constructor.slug; const slug = event.constructor.slug;
if (!this.enabled) {
console.log("EventManager disabled, not emitting", slug);
return;
}
if (!this.eventExists(event)) { if (!this.eventExists(event)) {
throw new Error(`Event "${slug}" not registered`); 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 { tbValidator } from "./server/lib/tbValidator";
export { Exception, BkndError } from "./errors"; export { Exception, BkndError } from "./errors";
export { isDebug } from "./env"; 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 { AwsClient } from "./clients/aws/AwsClient";
export { export {
SimpleRenderer, SimpleRenderer,

View File

@@ -69,7 +69,8 @@ export class SchemaObject<Schema extends TObject> {
forceParse: true, forceParse: true,
skipMark: this.isForceParse() 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._value = updatedConfig;
this._config = Object.freeze(updatedConfig); this._config = Object.freeze(updatedConfig);

View File

@@ -1,29 +1,50 @@
export type Constructor<T> = new (...args: any[]) => T; 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 is_set: boolean = false;
private items: Items = {} as Items; 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) { if (this.is_set) {
throw new Error("Registry is already set"); throw new Error("Registry is already set");
} }
// @ts-ignore this.items = items as unknown as Items;
this.items = items;
this.is_set = true; 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) { add(name: string, item: Item) {
// @ts-ignore this.items[name as keyof Items] = item as Items[keyof Items];
this.items[name] = item;
return this; 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] { get<Name extends keyof Items>(name: Name): Items[Name] {
return this.items[name]; return this.items[name];
} }
has(name: keyof Items): boolean {
return name in this.items;
}
all() { all() {
return this.items; return this.items;
} }

View File

@@ -20,11 +20,16 @@ export class DebugLogger {
return this; return this;
} }
reset() {
this.last = 0;
return this;
}
log(...args: any[]) { log(...args: any[]) {
if (!this._enabled) return this; if (!this._enabled) return this;
const now = performance.now(); 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 indents = " ".repeat(this._context.length);
const context = const context =
this._context.length > 0 ? `[${this._context[this._context.length - 1]}]` : ""; this._context.length > 0 ? `[${this._context[this._context.length - 1]}]` : "";

View File

@@ -9,10 +9,25 @@ export async function withDisabledConsole<R>(
fn: () => Promise<R>, fn: () => Promise<R>,
severities: ConsoleSeverity[] = ["log"] severities: ConsoleSeverity[] = ["log"]
): Promise<R> { ): Promise<R> {
const enable = disableConsoleLog(severities); 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(); const result = await fn();
enable(); enable();
return result; return result;
} catch (e) {
enable();
throw e;
}
} }
export function disableConsoleLog(severities: ConsoleSeverity[] = ["log"]) { export function disableConsoleLog(severities: ConsoleSeverity[] = ["log"]) {

View File

@@ -1,52 +1,20 @@
import { transformObject } from "core/utils"; 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 { Module } from "modules/Module";
import { DataController } from "./api/DataController"; import { DataController } from "./api/DataController";
import { import { type AppDataConfig, dataConfigSchema } from "./data-schema";
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
);
}
export class AppData extends Module<typeof dataConfigSchema> {
override async build() { override async build() {
const entities = transformObject(this.config.entities ?? {}, (entityConfig, name) => { const entities = transformObject(this.config.entities ?? {}, (entityConfig, name) => {
return AppData.constructEntity(name, entityConfig); return constructEntity(name, entityConfig);
}); });
const _entity = (_e: Entity | string): Entity => { 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) => const relations = transformObject(this.config.relations ?? {}, (relation) =>
AppData.constructRelation(relation, _entity) constructRelation(relation, _entity)
); );
const indices = transformObject(this.config.indices ?? {}, (index, name) => { const indices = transformObject(this.config.indices ?? {}, (index, name) => {
@@ -91,7 +59,7 @@ export class AppData<DB> extends Module<typeof dataConfigSchema> {
return dataConfigSchema; return dataConfigSchema;
} }
get em(): EntityManager<DB> { get em(): EntityManager {
this.throwIfNotBuilt(); this.throwIfNotBuilt();
return this.ctx.em; return this.ctx.em;
} }

View File

@@ -1,3 +1,4 @@
import type { DB } from "core";
import type { EntityData, RepoQuery, RepositoryResponse } from "data"; import type { EntityData, RepoQuery, RepositoryResponse } from "data";
import { type BaseModuleApiOptions, ModuleApi, type PrimaryFieldType } from "modules"; import { type BaseModuleApiOptions, ModuleApi, type PrimaryFieldType } from "modules";
@@ -15,48 +16,60 @@ export class DataApi extends ModuleApi<DataApiOptions> {
}; };
} }
readOne( readOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: string, entity: E,
id: PrimaryFieldType, id: PrimaryFieldType,
query: Partial<Omit<RepoQuery, "where" | "limit" | "offset">> = {} 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> = {}) { readMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
return this.get<Pick<RepositoryResponse, "meta" | "data">>( entity: E,
[entity],
query ?? this.options.defaultQuery
);
}
readManyByReference(
entity: string,
id: PrimaryFieldType,
reference: string,
query: Partial<RepoQuery> = {} query: Partial<RepoQuery> = {}
) { ) {
return this.get<Pick<RepositoryResponse, "meta" | "data">>( return this.get<Pick<RepositoryResponse<Data[]>, "meta" | "data">>(
[entity, id, reference], [entity as any],
query ?? this.options.defaultQuery query ?? this.options.defaultQuery
); );
} }
createOne(entity: string, input: EntityData) { readManyByReference<
return this.post<RepositoryResponse<EntityData>>([entity], input); 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) { createOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
return this.patch<RepositoryResponse<EntityData>>([entity, id], input); entity: E,
input: Omit<Data, "id">
) {
return this.post<RepositoryResponse<Data>>([entity as any], input);
} }
deleteOne(entity: string, id: PrimaryFieldType) { updateOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
return this.delete<RepositoryResponse<EntityData>>([entity, id]); 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"] = {}) { deleteOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
return this.post<RepositoryResponse<{ entity: string; count: number }>>( entity: E,
[entity, "fn", "count"], 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 where
); );
} }

View File

@@ -1,5 +1,5 @@
import { type ClassController, isDebug, tbValidator as tb } from "core"; 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 { import {
DataPermissions, DataPermissions,
type EntityData, type EntityData,
@@ -165,13 +165,12 @@ export class DataController implements ClassController {
// read entity schema // read entity schema
.get("/schema.json", async (c) => { .get("/schema.json", async (c) => {
this.guard.throwUnlessGranted(DataPermissions.entityRead); this.guard.throwUnlessGranted(DataPermissions.entityRead);
const url = new URL(c.req.url); const $id = `${this.config.basepath}/schema.json`;
const $id = `${url.origin}${this.config.basepath}/schema.json`;
const schemas = Object.fromEntries( const schemas = Object.fromEntries(
this.em.entities.map((e) => [ this.em.entities.map((e) => [
e.name, e.name,
{ {
$ref: `schemas/${e.name}` $ref: `${this.config.basepath}/schemas/${e.name}`
} }
]) ])
); );
@@ -183,22 +182,28 @@ export class DataController implements ClassController {
}) })
// read schema // read schema
.get( .get(
"/schemas/:entity", "/schemas/:entity/:context?",
tb("param", Type.Object({ entity: Type.String() })), tb(
"param",
Type.Object({
entity: Type.String(),
context: Type.Optional(StringEnum(["create", "update"]))
})
),
async (c) => { async (c) => {
this.guard.throwUnlessGranted(DataPermissions.entityRead); this.guard.throwUnlessGranted(DataPermissions.entityRead);
//console.log("request", c.req.raw); //console.log("request", c.req.raw);
const { entity } = c.req.param(); const { entity, context } = c.req.param();
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
console.log("not found", entity, definedEntities); console.log("not found", entity, definedEntities);
return c.notFound(); return c.notFound();
} }
const _entity = this.em.entity(entity); 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 url = new URL(c.req.url);
const base = `${url.origin}${this.config.basepath}`; const base = `${url.origin}${this.config.basepath}`;
const $id = `${base}/schemas/${entity}`; const $id = `${this.config.basepath}/schemas/${entity}`;
return c.json({ return c.json({
$schema: `${base}/schema.json`, $schema: `${base}/schema.json`,
$id, $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 { get label(): string {
return snakeToPascalWithSpaces(this.config.name || this.name); return this.config.name ?? snakeToPascalWithSpaces(this.name);
} }
field(name: string): Field | undefined { field(name: string): Field | undefined {
@@ -210,20 +210,34 @@ export class Entity<
return true; return true;
} }
toSchema(clean?: boolean): object { toSchema(options?: { clean: boolean; context?: "create" | "update" }): object {
const fields = Object.fromEntries(this.fields.map((field) => [field.name, field])); 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( const schema = Type.Object(
transformObject(fields, (field) => ({ transformObject(_fields, (field) => {
//const hidden = field.isHidden(options?.context);
const fillable = field.isFillable(options?.context);
return {
title: field.config.label, title: field.config.label,
$comment: field.config.description, $comment: field.config.description,
$field: field.type, $field: field.type,
readOnly: !field.isFillable("update") ? true : undefined, readOnly: !fillable ? true : undefined,
writeOnly: !field.isFillable("create") ? true : undefined,
...field.toJsonSchema() ...field.toJsonSchema()
})) };
}),
{ additionalProperties: false }
); );
return clean ? JSON.parse(JSON.stringify(schema)) : schema; return options?.clean ? JSON.parse(JSON.stringify(schema)) : schema;
} }
toJSON() { toJSON() {

View File

@@ -1,3 +1,4 @@
import type { DB as DefaultDB } from "core";
import { EventManager } from "core/events"; import { EventManager } from "core/events";
import { sql } from "kysely"; import { sql } from "kysely";
import { Connection } from "../connection/Connection"; import { Connection } from "../connection/Connection";
@@ -14,7 +15,18 @@ import { SchemaManager } from "../schema/SchemaManager";
import { Entity } from "./Entity"; import { Entity } from "./Entity";
import { type EntityData, Mutator, Repository } from "./index"; 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; connection: Connection;
private _entities: Entity[] = []; private _entities: Entity[] = [];
@@ -50,7 +62,7 @@ export class EntityManager<DB> {
* Forks the EntityManager without the EventManager. * Forks the EntityManager without the EventManager.
* This is useful when used inside an event handler. * 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); return new EntityManager(this._entities, this.connection, this._relations, this._indices);
} }
@@ -87,10 +99,17 @@ export class EntityManager<DB> {
this.entities.push(entity); this.entities.push(entity);
} }
entity(name: string): Entity { entity(e: Entity | keyof TBD | string): Entity {
const entity = this.entities.find((e) => e.name === name); 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) { if (!entity) {
throw new EntityNotDefinedException(name); // @ts-ignore
throw new EntityNotDefinedException(e instanceof Entity ? e.name : e);
} }
return entity; return entity;
@@ -162,28 +181,18 @@ export class EntityManager<DB> {
return this.relations.relationReferencesOf(this.entity(entity_name)); return this.relations.relationReferencesOf(this.entity(entity_name));
} }
repository(_entity: Entity | string) { repository<E extends Entity | keyof TBD | string>(
const entity = _entity instanceof Entity ? _entity : this.entity(_entity); entity: E
return new Repository(this, entity, this.emgr); ): Repository<TBD, EntitySchema<TBD, E>> {
return this.repo(entity);
} }
repo<E extends Entity>( repo<E extends Entity | keyof TBD | string>(entity: E): Repository<TBD, EntitySchema<TBD, E>> {
_entity: E return new Repository(this, this.entity(entity), this.emgr);
): Repository<
DB,
E extends Entity<infer Name> ? (Name extends keyof DB ? Name : never) : never
> {
return new Repository(this, _entity, this.emgr);
} }
_repo<TB extends keyof DB>(_entity: TB): Repository<DB, TB> { mutator<E extends Entity | keyof TBD | string>(entity: E): Mutator<TBD, EntitySchema<TBD, E>> {
const entity = this.entity(_entity as any); return new Mutator(this, this.entity(entity), this.emgr);
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);
} }
addIndex(index: EntityIndex, force = false) { 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 EmitsEvents, EventManager } from "core/events";
import type { DeleteQueryBuilder, InsertQueryBuilder, UpdateQueryBuilder } from "kysely"; import type { DeleteQueryBuilder, InsertQueryBuilder, UpdateQueryBuilder } from "kysely";
import { type TActionContext, WhereBuilder } from ".."; import { type TActionContext, WhereBuilder } from "..";
@@ -25,8 +25,14 @@ export type MutatorResponse<T = EntityData[]> = {
data: T; data: T;
}; };
export class Mutator<DB> implements EmitsEvents { export class Mutator<
em: EntityManager<DB>; TBD extends object = DefaultDB,
TB extends keyof TBD = any,
Output = TBD[TB],
Input = Omit<Output, "id">
> implements EmitsEvents
{
em: EntityManager<TBD>;
entity: Entity; entity: Entity;
static readonly Events = MutatorEvents; static readonly Events = MutatorEvents;
emgr: EventManager<typeof MutatorEvents>; emgr: EventManager<typeof MutatorEvents>;
@@ -37,7 +43,7 @@ export class Mutator<DB> implements EmitsEvents {
this.__unstable_disable_system_entity_creation = value; 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.em = em;
this.entity = entity; this.entity = entity;
this.emgr = emgr ?? new EventManager(MutatorEvents); this.emgr = emgr ?? new EventManager(MutatorEvents);
@@ -47,13 +53,13 @@ export class Mutator<DB> implements EmitsEvents {
return this.em.connection.kysely; 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; const entity = this.entity;
if (!context) { if (!context) {
throw new Error("Context must be provided for validation"); throw new Error("Context must be provided for validation");
} }
const keys = Object.keys(data); const keys = Object.keys(data as any);
const validatedData: EntityData = {}; const validatedData: EntityData = {};
// get relational references/keys // get relational references/keys
@@ -95,7 +101,7 @@ export class Mutator<DB> implements EmitsEvents {
throw new Error(`No data left to update "${entity.name}"`); throw new Error(`No data left to update "${entity.name}"`);
} }
return validatedData; return validatedData as Given;
} }
protected async many(qb: MutatorQB): Promise<MutatorResponse> { protected async many(qb: MutatorQB): Promise<MutatorResponse> {
@@ -120,7 +126,7 @@ export class Mutator<DB> implements EmitsEvents {
return { ...response, data: data[0]! }; return { ...response, data: data[0]! };
} }
async insertOne(data: EntityData): Promise<MutatorResponse<EntityData>> { async insertOne(data: Input): Promise<MutatorResponse<Output>> {
const entity = this.entity; const entity = this.entity;
if (entity.type === "system" && this.__unstable_disable_system_entity_creation) { if (entity.type === "system" && this.__unstable_disable_system_entity_creation) {
throw new Error(`Creation of system entity "${entity.name}" is disabled`); 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 })); 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; const entity = this.entity;
if (!Number.isInteger(id)) { if (!Number.isInteger(id)) {
throw new Error("ID must be provided for update"); 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"); const validatedData = await this.getValidatedData(data, "update");
await this.emgr.emit( 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 const query = this.conn
.updateTable(entity.name) .updateTable(entity.name)
.set(validatedData) .set(validatedData as any)
.where(entity.id().name, "=", id) .where(entity.id().name, "=", id)
.returning(entity.getSelect()); .returning(entity.getSelect());
@@ -181,10 +191,10 @@ export class Mutator<DB> implements EmitsEvents {
new Mutator.Events.MutatorUpdateAfter({ entity, entityId: id, data: res.data }) 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; const entity = this.entity;
if (!Number.isInteger(id)) { if (!Number.isInteger(id)) {
throw new Error("ID must be provided for deletion"); 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 }) new Mutator.Events.MutatorDeleteAfter({ entity, entityId: id, data: res.data })
); );
return res; return res as any;
} }
private getValidOptions(options?: Partial<RepoQuery>): Partial<RepoQuery> { 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) // @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 entity = this.entity;
const qb = this.appendWhere(this.conn.deleteFrom(entity.name), where).returning( const qb = this.appendWhere(this.conn.deleteFrom(entity.name), where).returning(
entity.getSelect() entity.getSelect()
); );
//await this.emgr.emit(new Mutator.Events.MutatorDeleteBefore({ entity, entityId: id })); return (await this.many(qb)) as any;
const res = await this.many(qb);
/*await this.emgr.emit(
new Mutator.Events.MutatorDeleteAfter({ entity, entityId: id, data: res.data })
);*/
return res;
} }
async updateWhere( async updateWhere(
data: EntityData, data: Partial<Input>,
where?: RepoQuery["where"] where?: RepoQuery["where"]
): Promise<MutatorResponse<EntityData>> { ): Promise<MutatorResponse<Output[]>> {
const entity = this.entity; const entity = this.entity;
const validatedData = await this.getValidatedData(data, "update"); 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) const query = this.appendWhere(this.conn.updateTable(entity.name), where)
.set(validatedData) .set(validatedData as any)
//.where(entity.id().name, "=", id)
.returning(entity.getSelect()); .returning(entity.getSelect());
const res = await this.many(query); return (await this.many(query)) as any;
}
/*await this.emgr.emit( async insertMany(data: Input[]): Promise<MutatorResponse<Output[]>> {
new Mutator.Events.MutatorUpdateAfter({ entity, entityId: id, data: res.data }) 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 EmitsEvents, EventManager } from "core/events";
import { type SelectQueryBuilder, sql } from "kysely"; import { type SelectQueryBuilder, sql } from "kysely";
import { cloneDeep } from "lodash-es"; import { cloneDeep } from "lodash-es";
@@ -43,13 +43,15 @@ export type RepositoryExistsResponse = RepositoryRawResponse & {
exists: boolean; exists: boolean;
}; };
export class Repository<DB = any, TB extends keyof DB = any> implements EmitsEvents { export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = any>
em: EntityManager<DB>; implements EmitsEvents
{
em: EntityManager<TBD>;
entity: Entity; entity: Entity;
static readonly Events = RepositoryEvents; static readonly Events = RepositoryEvents;
emgr: EventManager<typeof Repository.Events>; 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.em = em;
this.entity = entity; this.entity = entity;
this.emgr = emgr ?? new EventManager(MutatorEvents); this.emgr = emgr ?? new EventManager(MutatorEvents);
@@ -272,7 +274,7 @@ export class Repository<DB = any, TB extends keyof DB = any> implements EmitsEve
async findId( async findId(
id: PrimaryFieldType, id: PrimaryFieldType,
_options?: Partial<Omit<RepoQuery, "where" | "limit" | "offset">> _options?: Partial<Omit<RepoQuery, "where" | "limit" | "offset">>
): Promise<RepositoryResponse<DB[TB]>> { ): Promise<RepositoryResponse<TBD[TB] | undefined>> {
const { qb, options } = this.buildQuery( const { qb, options } = this.buildQuery(
{ {
..._options, ..._options,
@@ -288,7 +290,7 @@ export class Repository<DB = any, TB extends keyof DB = any> implements EmitsEve
async findOne( async findOne(
where: RepoQuery["where"], where: RepoQuery["where"],
_options?: Partial<Omit<RepoQuery, "where" | "limit" | "offset">> _options?: Partial<Omit<RepoQuery, "where" | "limit" | "offset">>
): Promise<RepositoryResponse<DB[TB] | undefined>> { ): Promise<RepositoryResponse<TBD[TB] | undefined>> {
const { qb, options } = this.buildQuery({ const { qb, options } = this.buildQuery({
..._options, ..._options,
where, where,
@@ -298,7 +300,7 @@ export class Repository<DB = any, TB extends keyof DB = any> implements EmitsEve
return this.single(qb, options) as any; 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); const { qb, options } = this.buildQuery(_options);
//console.log("findMany:options", 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; return value;
} }

View File

@@ -18,6 +18,8 @@ export { LibsqlConnection, type LibSqlCredentials } from "./connection/LibsqlCon
export { SqliteConnection } from "./connection/SqliteConnection"; export { SqliteConnection } from "./connection/SqliteConnection";
export { SqliteLocalConnection } from "./connection/SqliteLocalConnection"; export { SqliteLocalConnection } from "./connection/SqliteLocalConnection";
export { constructEntity, constructRelation } from "./schema/constructor";
export const DatabaseEvents = { export const DatabaseEvents = {
...MutatorEvents, ...MutatorEvents,
...RepositoryEvents ...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 { import {
BooleanField, BooleanField,
type BooleanFieldConfig, type BooleanFieldConfig,
@@ -5,6 +10,8 @@ import {
type DateFieldConfig, type DateFieldConfig,
Entity, Entity,
type EntityConfig, type EntityConfig,
EntityIndex,
type EntityRelation,
EnumField, EnumField,
type EnumFieldConfig, type EnumFieldConfig,
type Field, type Field,
@@ -25,15 +32,14 @@ import {
type TEntityType, type TEntityType,
TextField, TextField,
type TextFieldConfig type TextFieldConfig
} from "data"; } from "../index";
import type { Generated } from "kysely";
import { MediaField, type MediaFieldConfig, type MediaItem } from "media/MediaField";
type Options<Config = any> = { type Options<Config = any> = {
entity: { name: string; fields: Record<string, Field<any, any, any>> }; entity: { name: string; fields: Record<string, Field<any, any, any>> };
field_name: string; field_name: string;
config: Config; config: Config;
is_required: boolean; is_required: boolean;
another?: string;
}; };
const FieldMap = { 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 } [K in keyof Fields]: Fields[K] extends { _type: infer Type; _required: infer Required }
? Required extends true ? 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 ? Required extends true
? Type ? Type
: Type | undefined : Type | undefined
: never; : 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 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>>>; 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, getDefaultConfig,
getDefaultSchema, getDefaultSchema,
type ModuleConfigs, type ModuleConfigs,
type ModuleSchemas type ModuleSchemas,
} from "modules/ModuleManager"; type ModuleManagerOptions,
type ModuleBuildContext
} from "./modules/ModuleManager";
export { registries } from "modules/registries";
export type * from "./adapter"; export type * from "./adapter";
export { Api, type ApiOptions } from "./Api"; export { Api, type ApiOptions } from "./Api";

View File

@@ -1,24 +1,15 @@
import type { PrimaryFieldType } from "core";
import { EntityIndex, type EntityManager } from "data"; import { EntityIndex, type EntityManager } from "data";
import { type FileUploadedEventData, Storage, type StorageAdapter } from "media"; import { type FileUploadedEventData, Storage, type StorageAdapter } from "media";
import { Module } from "modules/Module"; import { Module } from "modules/Module";
import { import { type FieldSchema, boolean, datetime, entity, json, number, text } from "../data/prototype";
type FieldSchema,
type InferFields,
type Schema,
boolean,
datetime,
entity,
json,
number,
text
} from "../data/prototype";
import { MediaController } from "./api/MediaController"; import { MediaController } from "./api/MediaController";
import { ADAPTERS, buildMediaSchema, type mediaConfigSchema, registry } from "./media-schema"; import { ADAPTERS, buildMediaSchema, type mediaConfigSchema, registry } from "./media-schema";
export type MediaFieldSchema = FieldSchema<typeof AppMedia.mediaFields>; export type MediaFieldSchema = FieldSchema<typeof AppMedia.mediaFields>;
declare global { declare module "core" {
interface DB { 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); return this.em.entity(entity_name);
} }
get em(): EntityManager<DB> { get em(): EntityManager {
return this.ctx.em; return this.ctx.em;
} }
private setupListeners() { private setupListeners() {
//const media = this._entity; //const media = this._entity;
const { emgr, em } = this.ctx; const { emgr, em } = this.ctx;
const media = this.getMediaEntity(); const media = this.getMediaEntity().name as "media";
// when file is uploaded, sync with media entity // when file is uploaded, sync with media entity
// @todo: need a way for singleton events! // @todo: need a way for singleton events!
@@ -140,10 +131,10 @@ export class AppMedia extends Module<typeof mediaConfigSchema> {
Storage.Events.FileDeletedEvent, Storage.Events.FileDeletedEvent,
async (e) => { async (e) => {
// simple file deletion sync // simple file deletion sync
const item = await em.repo(media).findOne({ path: e.params.name }); const { data } = await em.repo(media).findOne({ path: e.params.name });
if (item.data) { if (data) {
console.log("item.data", item.data); console.log("item.data", data);
await em.mutator(media).deleteOne(item.data.id); await em.mutator(media).deleteOne(data.id);
} }
console.log("App:storage:file deleted", e); console.log("App:storage:file deleted", e);

View File

@@ -174,7 +174,7 @@ export class MediaController implements ClassController {
const result = await mutator.insertOne({ const result = await mutator.insertOne({
...this.media.uploadedEventDataToMediaPayload(info), ...this.media.uploadedEventDataToMediaPayload(info),
...mediaRef ...mediaRef
}); } as any);
mutator.__unstable_toggleSystemEntityCreation(true); mutator.__unstable_toggleSystemEntityCreation(true);
// delete items if needed // delete items if needed

View File

@@ -17,10 +17,6 @@ import {
import { type S3AdapterConfig, StorageS3Adapter } from "./storage/adapters/StorageS3Adapter"; import { type S3AdapterConfig, StorageS3Adapter } from "./storage/adapters/StorageS3Adapter";
export { StorageS3Adapter, type S3AdapterConfig, StorageCloudinaryAdapter, type CloudinaryConfig }; export { StorageS3Adapter, type S3AdapterConfig, StorageCloudinaryAdapter, type CloudinaryConfig };
/*export {
StorageLocalAdapter,
type LocalAdapterConfig
} from "./storage/adapters/StorageLocalAdapter";*/
export * as StorageEvents from "./storage/events"; export * as StorageEvents from "./storage/events";
export { type FileUploadedEventData } 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<{ export const MediaAdapterRegistry = new Registry<{
cls: ClassThatImplements<StorageAdapter>; cls: ClassThatImplements<StorageAdapter>;
schema: TObject; schema: TObject;
}>().set({ }>((cls: ClassThatImplements<StorageAdapter>) => ({
s3: { cls,
cls: StorageS3Adapter, schema: cls.prototype.getSchema() as TObject
schema: StorageS3Adapter.prototype.getSchema() }))
}, .register("s3", StorageS3Adapter)
cloudinary: { .register("cloudinary", StorageCloudinaryAdapter);
cls: StorageCloudinaryAdapter,
schema: StorageCloudinaryAdapter.prototype.getSchema()
}
});
export const Adapters = { export const Adapters = {
s3: { s3: {

View File

@@ -1,17 +1,11 @@
import { readFile, readdir, stat, unlink, writeFile } from "node:fs/promises"; import { readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
import { type Static, Type, parse } from "core/utils"; import { type Static, Type, parse } from "core/utils";
import type { import type { FileBody, FileListObject, FileMeta, StorageAdapter } from "../../Storage";
FileBody, import { guess } from "../../mime-types-tiny";
FileListObject,
FileMeta,
FileUploadPayload,
StorageAdapter
} from "../../Storage";
import { guessMimeType } from "../../mime-types";
export const localAdapterConfig = Type.Object( export const localAdapterConfig = Type.Object(
{ {
path: Type.String() path: Type.String({ default: "./" })
}, },
{ title: "Local" } { title: "Local" }
); );
@@ -89,7 +83,7 @@ export class StorageLocalAdapter implements StorageAdapter {
async getObject(key: string, headers: Headers): Promise<Response> { async getObject(key: string, headers: Headers): Promise<Response> {
try { try {
const content = await readFile(`${this.config.path}/${key}`); const content = await readFile(`${this.config.path}/${key}`);
const mimeType = guessMimeType(key); const mimeType = guess(key);
return new Response(content, { return new Response(content, {
status: 200, status: 200,
@@ -111,7 +105,7 @@ export class StorageLocalAdapter implements StorageAdapter {
async getObjectMeta(key: string): Promise<FileMeta> { async getObjectMeta(key: string): Promise<FileMeta> {
const stats = await stat(`${this.config.path}/${key}`); const stats = await stat(`${this.config.path}/${key}`);
return { return {
type: guessMimeType(key) || "application/octet-stream", type: guess(key) || "application/octet-stream",
size: stats.size 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 = { export type ModuleBuildContext = {
connection: Connection; connection: Connection;
server: Hono<any>; server: Hono<any>;
em: EntityManager<any>; em: EntityManager;
emgr: EventManager<any>; emgr: EventManager<any>;
guard: Guard; guard: Guard;
}; };

View File

@@ -1,5 +1,5 @@
import { Guard } from "auth"; import { Guard } from "auth";
import { BkndError, DebugLogger, Exception, isDebug } from "core"; import { BkndError, DebugLogger } from "core";
import { EventManager } from "core/events"; import { EventManager } from "core/events";
import { clone, diff } from "core/object/diff"; import { clone, diff } from "core/object/diff";
import { import {
@@ -35,9 +35,11 @@ import { AppFlows } from "../flows/AppFlows";
import { AppMedia } from "../media/AppMedia"; import { AppMedia } from "../media/AppMedia";
import type { Module, ModuleBuildContext } from "./Module"; import type { Module, ModuleBuildContext } from "./Module";
export type { ModuleBuildContext };
export const MODULES = { export const MODULES = {
server: AppServer, server: AppServer,
data: AppData<any>, data: AppData,
auth: AppAuth, auth: AppAuth,
media: AppMedia, media: AppMedia,
flows: AppFlows flows: AppFlows
@@ -73,9 +75,14 @@ export type ModuleManagerOptions = {
module: Module, module: Module,
config: ModuleConfigs[Module] config: ModuleConfigs[Module]
) => Promise<void>; ) => Promise<void>;
// triggered when no config table existed
onFirstBoot?: () => Promise<void>;
// base path for the hono instance // base path for the hono instance
basePath?: string; basePath?: string;
// doesn't perform validity checks for given/fetched config
trustFetched?: boolean; trustFetched?: boolean;
// runs when initial config provided on a fresh database
seed?: (ctx: ModuleBuildContext) => Promise<void>;
}; };
type ConfigTable<Json = ModuleConfigs> = { type ConfigTable<Json = ModuleConfigs> = {
@@ -105,9 +112,9 @@ const __bknd = entity(TABLE_NAME, {
updated_at: datetime() updated_at: datetime()
}); });
type ConfigTable2 = Schema<typeof __bknd>; type ConfigTable2 = Schema<typeof __bknd>;
type T_INTERNAL_EM = { interface T_INTERNAL_EM {
__bknd: ConfigTable2; __bknd: ConfigTable2;
}; }
// @todo: cleanup old diffs on upgrade // @todo: cleanup old diffs on upgrade
// @todo: cleanup multiple backups on upgrade // @todo: cleanup multiple backups on upgrade
@@ -116,7 +123,7 @@ export class ModuleManager {
// internal em for __bknd config table // internal em for __bknd config table
__em!: EntityManager<T_INTERNAL_EM>; __em!: EntityManager<T_INTERNAL_EM>;
// ctx for modules // ctx for modules
em!: EntityManager<any>; em!: EntityManager;
server!: Hono; server!: Hono;
emgr!: EventManager; emgr!: EventManager;
guard!: Guard; guard!: Guard;
@@ -294,7 +301,7 @@ export class ModuleManager {
version, version,
json: configs, json: configs,
updated_at: new Date() updated_at: new Date()
}, } as any,
{ {
type: "config", type: "config",
version version
@@ -448,6 +455,9 @@ export class ModuleManager {
await this.buildModules(); await this.buildModules();
await this.save(); await this.save();
// run initial setup
await this.setupInitial();
this.logger.clear(); this.logger.clear();
return this; return this;
} }
@@ -462,6 +472,21 @@ export class ModuleManager {
return this; 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] { get<K extends keyof Modules>(key: K): Modules[K] {
if (!(key in this.modules)) { if (!(key in this.modules)) {
throw new Error(`Module "${key}" doesn't exist, cannot get`); 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) => { this.client.onError((err, c) => {
//throw err; //throw err;
console.error(err); console.error(err);
@@ -82,21 +97,6 @@ export class AppServer extends Module<typeof serverConfigSchema> {
return err; 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) { if (err instanceof Exception) {
console.log("---is exception", err.code); console.log("---is exception", err.code);
return c.json(err.toJSON(), err.code as any); return c.json(err.toJSON(), err.code as any);
@@ -107,32 +107,6 @@ export class AppServer extends Module<typeof serverConfigSchema> {
this.setBuilt(); 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) { override toJSON(secrets?: boolean) {
return this.config; 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" 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"> <div className="max-h-full flex hover:bg-primary/5 link p-2.5 w-[134px] outline-none">
<Logo /> <Logo theme={theme} />
</div> </div>
<nav className="hidden md:flex flex-row gap-2.5 pl-0 p-2.5 items-center"> <nav className="hidden md:flex flex-row gap-2.5 pl-0 p-2.5 items-center">
{[...new Array(5)].map((item, key) => ( {[...new Array(5)].map((item, key) => (

View File

@@ -5,14 +5,14 @@ import { useApi } from "ui/client";
export const useApiQuery = < export const useApiQuery = <
Data, 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>, fn: (api: Api) => FetchPromise<Data>,
options?: SWRConfiguration & { enabled?: boolean; refine?: RefineFn } options?: SWRConfiguration & { enabled?: boolean; refine?: RefineFn }
) => { ) => {
const api = useApi(); const api = useApi();
const promise = fn(api); 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 fetcher = () => promise.execute().then(refine);
const key = promise.key(); 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 type { DB, PrimaryFieldType } from "core";
import { objectTransform } from "core/utils"; import { encodeSearch, objectTransform } from "core/utils";
import type { EntityData, RepoQuery } from "data"; import type { EntityData, RepoQuery } from "data";
import type { ResponseObject } from "modules/ModuleApi"; import type { ModuleApi, ResponseObject } from "modules/ModuleApi";
import useSWR, { type SWRConfiguration } from "swr"; import useSWR, { type SWRConfiguration, mutate } from "swr";
import { useApi } from "ui/client"; import { type Api, useApi } from "ui/client";
export class UseEntityApiError<Payload = any> extends Error { export class UseEntityApiError<Payload = any> extends Error {
constructor( constructor(
public payload: Payload, public response: ResponseObject<Payload>,
public response: Response, fallback?: string
message?: string
) { ) {
let message = fallback;
if ("error" in response) {
message = response.error as string;
if (fallback) {
message = `${fallback}: ${message}`;
}
}
super(message ?? "UseEntityApiError"); super(message ?? "UseEntityApiError");
} }
} }
function Test() {
const { read } = useEntity("users");
async () => {
const data = await read();
};
return null;
}
export const useEntity = < export const useEntity = <
Entity extends string, Entity extends keyof DB | string,
Id extends PrimaryFieldType | undefined = undefined Id extends PrimaryFieldType | undefined = undefined,
Data = Entity extends keyof DB ? DB[Entity] : EntityData
>( >(
entity: Entity, entity: Entity,
id?: Id id?: Id
@@ -25,27 +42,30 @@ export const useEntity = <
const api = useApi().data; const api = useApi().data;
return { return {
create: async (input: EntityData) => { create: async (input: Omit<Data, "id">) => {
const res = await api.createOne(entity, input); const res = await api.createOne(entity, input);
if (!res.ok) { 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; return res;
}, },
read: async (query: Partial<RepoQuery> = {}) => { read: async (query: Partial<RepoQuery> = {}) => {
const res = id ? await api.readOne(entity, id!, query) : await api.readMany(entity, query); const res = id ? await api.readOne(entity, id!, query) : await api.readMany(entity, query);
if (!res.ok) { 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) { if (!_id) {
throw new Error("id is required"); throw new Error("id is required");
} }
const res = await api.updateOne(entity, _id, input); const res = await api.updateOne(entity, _id, input);
if (!res.ok) { 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; return res;
}, },
@@ -56,44 +76,67 @@ export const useEntity = <
const res = await api.deleteOne(entity, _id); const res = await api.deleteOne(entity, _id);
if (!res.ok) { 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; 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 = < export const useEntityQuery = <
Entity extends string, Entity extends keyof DB | string,
Id extends PrimaryFieldType | undefined = undefined Id extends PrimaryFieldType | undefined = undefined
>( >(
entity: Entity, entity: Entity,
id?: Id, id?: Id,
query?: Partial<RepoQuery>, query?: Partial<RepoQuery>,
options?: SWRConfiguration & { enabled?: boolean } options?: SWRConfiguration & { enabled?: boolean; revalidateOnMutate?: boolean }
) => { ) => {
const api = useApi().data; const api = useApi().data;
const key = const key = makeKey(api, entity, id, query);
options?.enabled !== false const { read, ...actions } = useEntity<Entity, Id>(entity, id);
? [...(api.options?.basepath?.split("/") ?? []), entity, ...(id ? [id] : [])].filter(
Boolean
)
: null;
const { read, ...actions } = useEntity(entity, id) as any;
const fetcher = () => read(query); const fetcher = () => read(query);
type T = Awaited<ReturnType<(typeof api)[Id extends undefined ? "readMany" : "readOne"]>>; type T = Awaited<ReturnType<typeof fetcher>>;
const swr = useSWR<T>(key, fetcher, { const swr = useSWR<T>(options?.enabled === false ? null : key, fetcher as any, {
revalidateOnFocus: false, revalidateOnFocus: false,
keepPreviousData: false, keepPreviousData: true,
...options ...options
}); });
const mapped = objectTransform(actions, (action) => { const mutateAll = async () => {
if (action === "read") return; const entityKey = makeKey(api, entity);
return mutate((key) => typeof key === "string" && key.startsWith(entityKey), undefined, {
revalidate: true
});
};
return async (...args) => { const mapped = objectTransform(actions, (action) => {
return swr.mutate(action(...args)) as any; 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">; }) 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 = < export const useEntityMutate = <
Entity extends string, Entity extends keyof DB | string,
Id extends PrimaryFieldType | undefined = undefined Id extends PrimaryFieldType | undefined = undefined,
Data = Entity extends keyof DB ? Omit<DB[Entity], "id"> : EntityData
>( >(
entity: Entity, entity: Entity,
id?: Id, id?: Id,
options?: SWRConfiguration options?: SWRConfiguration
) => { ) => {
const { data, ...$q } = useEntityQuery(entity, id, undefined, { const { data, ...$q } = useEntityQuery<Entity, Id>(entity, id, undefined, {
...options, ...options,
enabled: false 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"; } from "./ClientProvider";
export * from "./api/use-api"; export * from "./api/use-api";
export * from "./api/use-data";
export * from "./api/use-entity"; export * from "./api/use-entity";
export { useAuth } from "./schema/auth/use-auth"; export { useAuth } from "./schema/auth/use-auth";
export { Api } from "../../Api"; export { Api } from "../../Api";

View File

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

View File

@@ -1,6 +1,5 @@
import type { App } from "App"; import type { App } from "App";
import type { Entity, EntityRelation } from "data"; import { type Entity, type EntityRelation, constructEntity, constructRelation } from "data";
import { AppData } from "data/AppData";
import { RelationAccessor } from "data/relations/RelationAccessor"; import { RelationAccessor } from "data/relations/RelationAccessor";
import { Flow, TaskMap } from "flows"; import { Flow, TaskMap } from "flows";
@@ -20,11 +19,11 @@ export class AppReduced {
//console.log("received appjson", appJson); //console.log("received appjson", appJson);
this._entities = Object.entries(this.appJson.data.entities ?? {}).map(([name, entity]) => { 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]) => { 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 ?? {})) { 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 { default as CodeMirror, type ReactCodeMirrorProps } from "@uiw/react-codemirror";
import { Suspense, lazy } from "react";
import { useBknd } from "ui/client/bknd"; import { useBknd } from "ui/client/bknd";
const CodeMirror = lazy(() => import("@uiw/react-codemirror"));
export default function CodeEditor({ editable, basicSetup, ...props }: ReactCodeMirrorProps) { export default function CodeEditor({ editable, basicSetup, ...props }: ReactCodeMirrorProps) {
const b = useBknd(); const b = useBknd();
@@ -15,13 +14,11 @@ export default function CodeEditor({ editable, basicSetup, ...props }: ReactCode
: basicSetup; : basicSetup;
return ( return (
<Suspense>
<CodeMirror <CodeMirror
theme={theme === "dark" ? "dark" : "light"} theme={theme === "dark" ? "dark" : "light"}
editable={editable} editable={editable}
basicSetup={_basicSetup} basicSetup={_basicSetup}
{...props} {...props}
/> />
</Suspense>
); );
} }

View File

@@ -1,15 +1,12 @@
import type { Schema } from "@cfworker/json-schema"; import type { Schema } from "@cfworker/json-schema";
import Form from "@rjsf/core"; import Form from "@rjsf/core";
import type { RJSFSchema, UiSchema } from "@rjsf/utils"; import type { RJSFSchema, UiSchema } from "@rjsf/utils";
import { cloneDeep } from "lodash-es";
import { forwardRef, useId, useImperativeHandle, useRef, useState } from "react"; import { forwardRef, useId, useImperativeHandle, useRef, useState } from "react";
//import { JsonSchemaValidator } from "./JsonSchemaValidator";
import { fields as Fields } from "./fields"; import { fields as Fields } from "./fields";
import { templates as Templates } from "./templates"; 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 { RJSFTypeboxValidator } from "./typebox/RJSFTypeboxValidator";
import { widgets as Widgets } from "./widgets";
const validator = new RJSFTypeboxValidator(); 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 router = useRouter();
const [path, navigate] = useLocationFromRouter(router); const [path, navigate] = useLocationFromRouter(router);
@@ -55,8 +59,6 @@ export function Link({ className, ...props }: { className?: string } & LinkProps
return false; return false;
} }
function handleClick(e) {}
const _href = props.href ?? props.to; const _href = props.href ?? props.to;
const href = router const href = router
.hrefs( .hrefs(
@@ -72,6 +74,10 @@ export function Link({ className, ...props }: { className?: string } & LinkProps
/*if (active) { /*if (active) {
console.log("link", { a, path, absPath, href, to, active, router }); console.log("link", { a, path, absPath, href, to, active, router });
}*/ }*/
if (native) {
return <a className={`${active ? "active " : ""}${className}`} {...props} />;
}
return ( return (
// @ts-expect-error className is not typed on WouterLink // @ts-expect-error className is not typed on WouterLink
<WouterLink className={`${active ? "active " : ""}${className}`} {...props} /> <WouterLink className={`${active ? "active " : ""}${className}`} {...props} />

View File

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

View File

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

View File

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

View File

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

View File

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

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 { useBkndData } from "ui/client/schema/data/use-bknd-data";
import { Button } from "ui/components/buttons/Button"; import { Button } from "ui/components/buttons/Button";
import { Alert } from "ui/components/display/Alert"; import { Alert } from "ui/components/display/Alert";
import { import { JsonSchemaForm, type JsonSchemaFormRef } from "ui/components/form/json-schema";
JsonSchemaForm,
type JsonSchemaFormRef
} from "ui/components/form/json-schema/JsonSchemaForm";
import * as AppShell from "ui/layouts/AppShell/AppShell"; import * as AppShell from "ui/layouts/AppShell/AppShell";
import { useNavigate } from "ui/lib/routes"; import { useNavigate } from "ui/lib/routes";
import { extractSchema } from "../settings/utils/schema"; import { extractSchema } from "../settings/utils/schema";

View File

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

View File

@@ -101,7 +101,7 @@ export function DataEntityUpdate({ params }) {
data: { data: {
data: data as any, data: data as any,
entity: entity.toJSON(), entity: entity.toJSON(),
schema: entity.toSchema(true), schema: entity.toSchema({ clean: true }),
form: Form.state.values, form: Form.state.values,
state: Form.state 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 { Button } from "ui/components/buttons/Button";
import { IconButton } from "ui/components/buttons/IconButton"; import { IconButton } from "ui/components/buttons/IconButton";
import { Empty } from "ui/components/display/Empty"; import { Empty } from "ui/components/display/Empty";
import { import { JsonSchemaForm, type JsonSchemaFormRef } from "ui/components/form/json-schema";
JsonSchemaForm,
type JsonSchemaFormRef
} from "ui/components/form/json-schema/JsonSchemaForm";
import { Dropdown } from "ui/components/overlay/Dropdown"; import { Dropdown } from "ui/components/overlay/Dropdown";
import * as AppShell from "ui/layouts/AppShell/AppShell"; import * as AppShell from "ui/layouts/AppShell/AppShell";
import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2"; 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 { IconButton } from "ui/components/buttons/IconButton";
import { JsonViewer } from "ui/components/code/JsonViewer"; import { JsonViewer } from "ui/components/code/JsonViewer";
import { MantineSwitch } from "ui/components/form/hook-form-mantine/MantineSwitch"; import { MantineSwitch } from "ui/components/form/hook-form-mantine/MantineSwitch";
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 { type SortableItemProps, SortableList } from "ui/components/list/SortableList";
import { Popover } from "ui/components/overlay/Popover"; import { Popover } from "ui/components/overlay/Popover";
import { fieldSpecs } from "ui/modules/data/components/fields-specs"; import { fieldSpecs } from "ui/modules/data/components/fields-specs";

View File

@@ -1,14 +1,19 @@
import { Suspense, lazy } from "react"; import { Suspense, lazy } from "react";
import { useBknd } from "ui/client/bknd"; import { useBknd } from "ui/client/bknd";
import { Route, Router, Switch } from "wouter"; import { Route, Router, Switch } from "wouter";
import AuthRoutes from "./auth";
import { AuthLogin } from "./auth/auth.login"; import { AuthLogin } from "./auth/auth.login";
import DataRoutes from "./data";
import FlowRoutes from "./flows";
import MediaRoutes from "./media";
import { Root, RootEmpty } from "./root"; 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 AuthRoutes = lazy(() => import("./auth"));
const MediaRoutes = lazy(() => import("./media")); const MediaRoutes = lazy(() => import("./media"));
const FlowRoutes = lazy(() => import("./flows")); const FlowRoutes = lazy(() => import("./flows"));
const SettingsRoutes = lazy(() => import("./settings")); const SettingsRoutes = lazy(() => import("./settings"));*/
// @ts-ignore // @ts-ignore
const TestRoutes = lazy(() => import("./test")); 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 { IconButton } from "ui/components/buttons/IconButton";
import { Alert } from "ui/components/display/Alert"; import { Alert } from "ui/components/display/Alert";
import { Empty } from "ui/components/display/Empty"; import { Empty } from "ui/components/display/Empty";
import { import { JsonSchemaForm, type JsonSchemaFormRef } from "ui/components/form/json-schema";
JsonSchemaForm,
type JsonSchemaFormRef
} from "ui/components/form/json-schema/JsonSchemaForm";
import { Dropdown } from "ui/components/overlay/Dropdown"; import { Dropdown } from "ui/components/overlay/Dropdown";
import { DataTable } from "ui/components/table/DataTable"; import { DataTable } from "ui/components/table/DataTable";
import { useEvent } from "ui/hooks/use-event"; 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 { omit } from "lodash-es";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { TbCirclePlus, TbVariable } from "react-icons/tb"; 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 { 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 = { export type SettingsNewModalProps = {
schema: TObject; schema: TObject;

View File

@@ -2,7 +2,7 @@ import { parse } from "core/utils";
import { AppFlows } from "flows/AppFlows"; import { AppFlows } from "flows/AppFlows";
import { useState } from "react"; import { useState } from "react";
import { JsonViewer } from "../../../components/code/JsonViewer"; 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"; import { Scrollable } from "../../../layouts/AppShell/AppShell";
export default function FlowCreateSchemaTest() { export default function FlowCreateSchemaTest() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,10 @@
import { serveStatic } from "@hono/node-server/serve-static"; import { serveStatic } from "@hono/node-server/serve-static";
import { createClient } from "@libsql/client/node"; import { createClient } from "@libsql/client/node";
import { App } from "./src"; import { App, registries } from "./src";
import { LibsqlConnection } from "./src/data"; import { LibsqlConnection } from "./src/data";
import { StorageLocalAdapter } from "./src/media/storage/adapters/StorageLocalAdapter"; import { StorageLocalAdapter } from "./src/media/storage/adapters/StorageLocalAdapter";
import { registries } from "./src/modules/registries";
registries.media.add("local", { registries.media.register("local", StorageLocalAdapter);
cls: StorageLocalAdapter,
schema: StorageLocalAdapter.prototype.getSchema()
});
const credentials = { const credentials = {
url: import.meta.env.VITE_DB_URL!, url: import.meta.env.VITE_DB_URL!,
@@ -24,8 +20,8 @@ export default {
async fetch(request: Request) { async fetch(request: Request) {
const app = App.create({ connection }); const app = App.create({ connection });
app.emgr.on( app.emgr.onEvent(
"app-built", App.Events.AppBuiltEvent,
async () => { async () => {
app.registerAdminController({ forceDev: true }); app.registerAdminController({ forceDev: true });
app.module.server.client.get("/assets/*", serveStatic({ root: "./" })); 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: { connection: {
type: "libsql", type: "libsql",
config: { 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 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. 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> <body>
<Admin <Admin
withProvider={{ user }} withProvider={{ user }}
config={{ basepath: "/admin", color_scheme: "dark" }} config={{
basepath: "/admin",
color_scheme: "dark",
logo_return_path: "/../"
}}
client:only client:only
/> />
</body> </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: Run the application using Bun by executing:
```bash ```bash

View File

@@ -13,11 +13,13 @@ and then install bknd as a dependency:
## Serve the API ## 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 ``` ts
import { serve } from "bknd/adapter/cloudflare"; import { serve } from "bknd/adapter/cloudflare";
export default serve( export default serve({
{
app: (env: Env) => ({ app: (env: Env) => ({
connection: { connection: {
type: "libsql", type: "libsql",
@@ -27,10 +29,9 @@ 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.
Now run the worker: Now run the worker:
```bash ```bash
@@ -49,12 +50,11 @@ bucket = "node_modules/bknd/dist/static"
``` ```
And then modify the worker entry as follows: And then modify the worker entry as follows:
``` ts {2, 15, 17} ``` ts {2, 14, 15}
import { serve } from "bknd/adapter/cloudflare"; import { serve } from "bknd/adapter/cloudflare";
import manifest from "__STATIC_CONTENT_MANIFEST"; import manifest from "__STATIC_CONTENT_MANIFEST";
export default serve( export default serve({
{
app: (env: Env) => ({ app: (env: Env) => ({
connection: { connection: {
type: "libsql", type: "libsql",
@@ -64,20 +64,18 @@ export default serve(
} }
} }
}), }),
manifest,
setAdminHtml: true setAdminHtml: true
}, });
manifest
);
``` ```
## Adding custom routes ## Adding custom routes
You can also add custom routes by defining them after the app has been built, like so: 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 { serve } from "bknd/adapter/cloudflare";
import manifest from "__STATIC_CONTENT_MANIFEST"; import manifest from "__STATIC_CONTENT_MANIFEST";
export default serve( export default serve({
{
app: (env: Env) => ({ app: (env: Env) => ({
connection: { connection: {
type: "libsql", type: "libsql",
@@ -90,8 +88,107 @@ export default serve(
onBuilt: async (app) => { onBuilt: async (app) => {
app.modules.server.get("/hello", (c) => c.json({ hello: "world" })); app.modules.server.get("/hello", (c) => c.json({ hello: "world" }));
}, },
manifest,
setAdminHtml: true setAdminHtml: true
}, });
manifest ```
);
## 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"; import { serve } from "bknd/adapter/nextjs";
export const config = { export const config = {
runtime: "experimental-edge", runtime: "experimental-edge", // or "edge", depending on your nextjs version
unstable_allowDynamic: ["**/*.js"] 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 ## Enabling the Admin UI
Create a file `[[...admin]].tsx` inside the `pages/admin` folder: Create a file `[[...admin]].tsx` inside the `pages/admin` folder:
```tsx ```tsx
// pages/admin/[[...admin]].tsx // pages/admin/[[...admin]].tsx
import type { InferGetServerSidePropsType as InferProps } from "next";
import { withApi } from "bknd/adapter/nextjs"; import { withApi } from "bknd/adapter/nextjs";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import "bknd/dist/styles.css"; 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; 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); 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: Run the application using node by executing:
```bash ```bash

View File

@@ -26,7 +26,7 @@ const handler = serve({
export const loader = handler; export const loader = handler;
export const action = 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 Now make sure that you wrap your root layout with the `ClientProvider` so that all components
share the same context: share the same context:

View File

@@ -61,7 +61,11 @@
"navigation": [ "navigation": [
{ {
"group": "Getting Started", "group": "Getting Started",
"pages": ["introduction", "setup", "sdk", "react", "cli"] "pages": ["introduction", "sdk", "react", "cli"]
},
{
"group": "Setup",
"pages": ["setup/introduction", "setup/database"]
}, },
{ {
"group": "Modules", "group": "Modules",
@@ -74,39 +78,17 @@
"modules/flows" "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", "group": "Integration",
"pages": [ "pages": [
"integration/extending", "integration/extending",
"integration/hono",
"integration/nextjs", "integration/nextjs",
"integration/remix", "integration/remix",
"integration/cloudflare", "integration/cloudflare",
"integration/bun", "integration/bun",
"integration/vite",
"integration/express",
"integration/astro", "integration/astro",
"integration/node", "integration/node",
"integration/deno", "integration/deno",
"integration/browser",
"integration/docker" "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": { "_variables": {
"lastUpdateCheck": 1732785435939 "lastUpdateCheck": 1734966049246
} }
} }

View File

@@ -22,3 +22,4 @@ pnpm-debug.log*
# jetbrains setting folder # jetbrains setting folder
.idea/ .idea/
*.db

View File

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

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