mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-15 20:17:22 +00:00
@@ -1,4 +1,4 @@
|
||||
import { describe, test } from "bun:test";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { TObject, TString } from "@sinclair/typebox";
|
||||
import { Registry } from "../../src/core/registry/Registry";
|
||||
import { type TSchema, Type } from "../../src/core/utils";
|
||||
@@ -11,6 +11,9 @@ class What {
|
||||
method() {
|
||||
return null;
|
||||
}
|
||||
getType() {
|
||||
return Type.Object({ type: Type.String() });
|
||||
}
|
||||
}
|
||||
class What2 extends What {}
|
||||
class NotAllowed {}
|
||||
@@ -32,25 +35,53 @@ describe("Registry", () => {
|
||||
} satisfies Record<string, Test1>);
|
||||
|
||||
const item = registry.get("first");
|
||||
expect(item).toBeDefined();
|
||||
expect(item?.cls).toBe(What);
|
||||
|
||||
const second = Type.Object({ type: Type.String(), what: Type.String() });
|
||||
registry.add("second", {
|
||||
cls: What2,
|
||||
schema: Type.Object({ type: Type.String(), what: Type.String() }),
|
||||
schema: second,
|
||||
enabled: true
|
||||
});
|
||||
// @ts-ignore
|
||||
expect(registry.get("second").schema).toEqual(second);
|
||||
|
||||
const third = Type.Object({ type: Type.String({ default: "1" }), what22: Type.String() });
|
||||
registry.add("third", {
|
||||
// @ts-expect-error
|
||||
cls: NotAllowed,
|
||||
schema: Type.Object({ type: Type.String({ default: "1" }), what22: Type.String() }),
|
||||
schema: third,
|
||||
enabled: true
|
||||
});
|
||||
// @ts-ignore
|
||||
expect(registry.get("third").schema).toEqual(third);
|
||||
|
||||
const fourth = Type.Object({ type: Type.Number(), what22: Type.String() });
|
||||
registry.add("fourth", {
|
||||
cls: What,
|
||||
// @ts-expect-error
|
||||
schema: Type.Object({ type: Type.Number(), what22: Type.String() }),
|
||||
schema: fourth,
|
||||
enabled: true
|
||||
});
|
||||
// @ts-ignore
|
||||
expect(registry.get("fourth").schema).toEqual(fourth);
|
||||
|
||||
console.log("list", registry.all());
|
||||
expect(Object.keys(registry.all()).length).toBe(4);
|
||||
});
|
||||
|
||||
test("uses registration fn", async () => {
|
||||
const registry = new Registry<Test1>((a: ClassRef<What>) => {
|
||||
return {
|
||||
cls: a,
|
||||
schema: a.prototype.getType(),
|
||||
enabled: true
|
||||
};
|
||||
});
|
||||
|
||||
registry.register("what2", What2);
|
||||
expect(registry.get("what2")).toBeDefined();
|
||||
expect(registry.get("what2").cls).toBe(What2);
|
||||
expect(registry.get("what2").schema).toEqual(What2.prototype.getType());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ describe("Mutator simple", async () => {
|
||||
new TextField("label", { required: true, minLength: 1 }),
|
||||
new NumberField("count", { default_value: 0 })
|
||||
]);
|
||||
const em = new EntityManager([items], connection);
|
||||
const em = new EntityManager<any>([items], connection);
|
||||
|
||||
await em.connection.kysely.schema
|
||||
.createTable("items")
|
||||
@@ -175,4 +175,18 @@ describe("Mutator simple", async () => {
|
||||
{ id: 8, label: "keep", count: 0 }
|
||||
]);
|
||||
});
|
||||
|
||||
test("insertMany", async () => {
|
||||
const oldCount = (await em.repo(items).count()).count;
|
||||
const inserts = [{ label: "insert 1" }, { label: "insert 2" }];
|
||||
const { data } = await em.mutator(items).insertMany(inserts);
|
||||
|
||||
expect(data.length).toBe(2);
|
||||
expect(data.map((d) => ({ label: d.label }))).toEqual(inserts);
|
||||
const newCount = (await em.repo(items).count()).count;
|
||||
expect(newCount).toBe(oldCount + inserts.length);
|
||||
|
||||
const { data: data2 } = await em.repo(items).findMany({ offset: oldCount });
|
||||
expect(data2).toEqual(data);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
BooleanField,
|
||||
DateField,
|
||||
Entity,
|
||||
EntityIndex,
|
||||
EntityManager,
|
||||
EnumField,
|
||||
JsonField,
|
||||
ManyToManyRelation,
|
||||
@@ -12,6 +14,7 @@ import {
|
||||
PolymorphicRelation,
|
||||
TextField
|
||||
} from "../../src/data";
|
||||
import { DummyConnection } from "../../src/data/connection/DummyConnection";
|
||||
import {
|
||||
FieldPrototype,
|
||||
type FieldSchema,
|
||||
@@ -20,6 +23,7 @@ import {
|
||||
boolean,
|
||||
date,
|
||||
datetime,
|
||||
em,
|
||||
entity,
|
||||
enumm,
|
||||
json,
|
||||
@@ -46,12 +50,17 @@ describe("prototype", () => {
|
||||
});
|
||||
|
||||
test("...2", async () => {
|
||||
const user = entity("users", {
|
||||
name: text().required(),
|
||||
const users = entity("users", {
|
||||
name: text(),
|
||||
bio: text(),
|
||||
age: number(),
|
||||
some: number().required()
|
||||
some: number()
|
||||
});
|
||||
type db = {
|
||||
users: Schema<typeof users>;
|
||||
};
|
||||
|
||||
const obj: Schema<typeof users> = {} as any;
|
||||
|
||||
//console.log("user", user.toJSON());
|
||||
});
|
||||
@@ -266,4 +275,38 @@ describe("prototype", () => {
|
||||
|
||||
const obj: Schema<typeof test> = {} as any;
|
||||
});
|
||||
|
||||
test("schema", async () => {
|
||||
const _em = em(
|
||||
{
|
||||
posts: entity("posts", { name: text(), slug: text().required() }),
|
||||
comments: entity("comments", { some: text() }),
|
||||
users: entity("users", { email: text() })
|
||||
},
|
||||
({ relation, index }, { posts, comments, users }) => {
|
||||
relation(posts).manyToOne(comments).manyToOne(users);
|
||||
index(posts).on(["name"]).on(["slug"], true);
|
||||
}
|
||||
);
|
||||
|
||||
type LocalDb = (typeof _em)["DB"];
|
||||
|
||||
const es = [
|
||||
new Entity("posts", [new TextField("name"), new TextField("slug", { required: true })]),
|
||||
new Entity("comments", [new TextField("some")]),
|
||||
new Entity("users", [new TextField("email")])
|
||||
];
|
||||
const _em2 = new EntityManager(
|
||||
es,
|
||||
new DummyConnection(),
|
||||
[new ManyToOneRelation(es[0], es[1]), new ManyToOneRelation(es[0], es[2])],
|
||||
[
|
||||
new EntityIndex(es[0], [es[0].field("name")!]),
|
||||
new EntityIndex(es[0], [es[0].field("slug")!], true)
|
||||
]
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
expect(_em2.toJSON()).toEqual(_em.toJSON());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@ describe("[data] Mutator (base)", async () => {
|
||||
new TextField("hidden", { hidden: true }),
|
||||
new TextField("not_fillable", { fillable: false })
|
||||
]);
|
||||
const em = new EntityManager([entity], dummyConnection);
|
||||
const em = new EntityManager<any>([entity], dummyConnection);
|
||||
await em.schema().sync({ force: true });
|
||||
|
||||
const payload = { label: "item 1", count: 1 };
|
||||
@@ -61,7 +61,7 @@ describe("[data] Mutator (ManyToOne)", async () => {
|
||||
const posts = new Entity("posts", [new TextField("title")]);
|
||||
const users = new Entity("users", [new TextField("username")]);
|
||||
const relations = [new ManyToOneRelation(posts, users)];
|
||||
const em = new EntityManager([posts, users], dummyConnection, relations);
|
||||
const em = new EntityManager<any>([posts, users], dummyConnection, relations);
|
||||
await em.schema().sync({ force: true });
|
||||
|
||||
test("RelationMutator", async () => {
|
||||
@@ -192,7 +192,7 @@ describe("[data] Mutator (OneToOne)", async () => {
|
||||
const users = new Entity("users", [new TextField("username")]);
|
||||
const settings = new Entity("settings", [new TextField("theme")]);
|
||||
const relations = [new OneToOneRelation(users, settings)];
|
||||
const em = new EntityManager([users, settings], dummyConnection, relations);
|
||||
const em = new EntityManager<any>([users, settings], dummyConnection, relations);
|
||||
await em.schema().sync({ force: true });
|
||||
|
||||
test("insertOne: missing ref", async () => {
|
||||
@@ -276,7 +276,7 @@ describe("[data] Mutator (ManyToMany)", async () => {
|
||||
|
||||
describe("[data] Mutator (Events)", async () => {
|
||||
const entity = new Entity("test", [new TextField("label")]);
|
||||
const em = new EntityManager([entity], dummyConnection);
|
||||
const em = new EntityManager<any>([entity], dummyConnection);
|
||||
await em.schema().sync({ force: true });
|
||||
const events = new Map<string, any>();
|
||||
|
||||
|
||||
37
app/__test__/media/mime-types.spec.ts
Normal file
37
app/__test__/media/mime-types.spec.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
50
app/build.ts
50
app/build.ts
@@ -9,16 +9,44 @@ const watch = args.includes("--watch");
|
||||
const minify = args.includes("--minify");
|
||||
const types = args.includes("--types");
|
||||
const sourcemap = args.includes("--sourcemap");
|
||||
const clean = args.includes("--clean");
|
||||
|
||||
if (clean) {
|
||||
console.log("Cleaning dist");
|
||||
await $`rm -rf dist`;
|
||||
}
|
||||
|
||||
let types_running = false;
|
||||
function buildTypes() {
|
||||
if (types_running) return;
|
||||
types_running = true;
|
||||
|
||||
await $`rm -rf dist`;
|
||||
if (types) {
|
||||
Bun.spawn(["bun", "build:types"], {
|
||||
onExit: () => {
|
||||
console.log("Types built");
|
||||
Bun.spawn(["bun", "tsc-alias"], {
|
||||
onExit: () => {
|
||||
console.log("Types aliased");
|
||||
types_running = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let watcher_timeout: any;
|
||||
function delayTypes() {
|
||||
if (!watch) return;
|
||||
if (watcher_timeout) {
|
||||
clearTimeout(watcher_timeout);
|
||||
}
|
||||
watcher_timeout = setTimeout(buildTypes, 1000);
|
||||
}
|
||||
|
||||
if (types && !watch) {
|
||||
buildTypes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build static assets
|
||||
* Using esbuild because tsup doesn't include "react"
|
||||
@@ -46,7 +74,8 @@ const result = await esbuild.build({
|
||||
__isDev: "0",
|
||||
"process.env.NODE_ENV": '"production"'
|
||||
},
|
||||
chunkNames: "chunks/[name]-[hash]"
|
||||
chunkNames: "chunks/[name]-[hash]",
|
||||
logLevel: "error"
|
||||
});
|
||||
|
||||
// Write manifest
|
||||
@@ -96,6 +125,9 @@ await tsup.build({
|
||||
treeshake: true,
|
||||
loader: {
|
||||
".svg": "dataurl"
|
||||
},
|
||||
onSuccess: async () => {
|
||||
delayTypes();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -117,11 +149,12 @@ await tsup.build({
|
||||
loader: {
|
||||
".svg": "dataurl"
|
||||
},
|
||||
onSuccess: async () => {
|
||||
console.log("--- ui built");
|
||||
},
|
||||
esbuildOptions: (options) => {
|
||||
options.logLevel = "silent";
|
||||
options.chunkNames = "chunks/[name]-[hash]";
|
||||
},
|
||||
onSuccess: async () => {
|
||||
delayTypes();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -148,7 +181,10 @@ function baseConfig(adapter: string): tsup.Options {
|
||||
],
|
||||
metafile: true,
|
||||
splitting: false,
|
||||
treeshake: true
|
||||
treeshake: true,
|
||||
onSuccess: async () => {
|
||||
delayTypes();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"bin": "./dist/cli/index.js",
|
||||
"version": "0.3.4-alpha1",
|
||||
"version": "0.4.0",
|
||||
"scripts": {
|
||||
"build:all": "bun run build && bun run build:cli",
|
||||
"build:all": "NODE_ENV=production bun run build.ts --minify --types --clean && bun run build:cli",
|
||||
"dev": "vite",
|
||||
"test": "ALL_TESTS=1 bun test --bail",
|
||||
"build": "NODE_ENV=production bun run build.ts --minify --types",
|
||||
"watch": "bun run build.ts --types --watch",
|
||||
"types": "bun tsc --noEmit",
|
||||
"clean:types": "find ./dist -name '*.d.ts' -delete && rm -f ./dist/tsconfig.tsbuildinfo",
|
||||
"build:types": "tsc --emitDeclarationOnly",
|
||||
"build:types": "tsc --emitDeclarationOnly && tsc-alias",
|
||||
"build:css": "bun tailwindcss -i src/ui/main.css -o ./dist/static/styles.css",
|
||||
"watch:css": "bun tailwindcss --watch -i src/ui/main.css -o ./dist/styles.css",
|
||||
"updater": "bun x npm-check-updates -ui",
|
||||
@@ -75,6 +75,7 @@
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tsc-alias": "^1.8.10",
|
||||
"tsup": "^8.3.5",
|
||||
"vite": "^5.4.10",
|
||||
"vite-plugin-static-copy": "^2.0.0",
|
||||
@@ -90,75 +91,75 @@
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"types": "./dist/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"types": "./dist/types/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./ui": {
|
||||
"types": "./dist/ui/index.d.ts",
|
||||
"types": "./dist/types/ui/index.d.ts",
|
||||
"import": "./dist/ui/index.js",
|
||||
"require": "./dist/ui/index.cjs"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./dist/ui/client/index.d.ts",
|
||||
"types": "./dist/types/ui/client/index.d.ts",
|
||||
"import": "./dist/ui/client/index.js",
|
||||
"require": "./dist/ui/client/index.cjs"
|
||||
},
|
||||
"./data": {
|
||||
"types": "./dist/data/index.d.ts",
|
||||
"types": "./dist/types/data/index.d.ts",
|
||||
"import": "./dist/data/index.js",
|
||||
"require": "./dist/data/index.cjs"
|
||||
},
|
||||
"./core": {
|
||||
"types": "./dist/core/index.d.ts",
|
||||
"types": "./dist/types/core/index.d.ts",
|
||||
"import": "./dist/core/index.js",
|
||||
"require": "./dist/core/index.cjs"
|
||||
},
|
||||
"./utils": {
|
||||
"types": "./dist/core/utils/index.d.ts",
|
||||
"types": "./dist/types/core/utils/index.d.ts",
|
||||
"import": "./dist/core/utils/index.js",
|
||||
"require": "./dist/core/utils/index.cjs"
|
||||
},
|
||||
"./cli": {
|
||||
"types": "./dist/cli/index.d.ts",
|
||||
"types": "./dist/types/cli/index.d.ts",
|
||||
"import": "./dist/cli/index.js",
|
||||
"require": "./dist/cli/index.cjs"
|
||||
},
|
||||
"./adapter/cloudflare": {
|
||||
"types": "./dist/adapter/cloudflare/index.d.ts",
|
||||
"types": "./dist/types/adapter/cloudflare/index.d.ts",
|
||||
"import": "./dist/adapter/cloudflare/index.js",
|
||||
"require": "./dist/adapter/cloudflare/index.cjs"
|
||||
},
|
||||
"./adapter/vite": {
|
||||
"types": "./dist/adapter/vite/index.d.ts",
|
||||
"types": "./dist/types/adapter/vite/index.d.ts",
|
||||
"import": "./dist/adapter/vite/index.js",
|
||||
"require": "./dist/adapter/vite/index.cjs"
|
||||
},
|
||||
"./adapter/nextjs": {
|
||||
"types": "./dist/adapter/nextjs/index.d.ts",
|
||||
"types": "./dist/types/adapter/nextjs/index.d.ts",
|
||||
"import": "./dist/adapter/nextjs/index.js",
|
||||
"require": "./dist/adapter/nextjs/index.cjs"
|
||||
},
|
||||
"./adapter/remix": {
|
||||
"types": "./dist/adapter/remix/index.d.ts",
|
||||
"types": "./dist/types/adapter/remix/index.d.ts",
|
||||
"import": "./dist/adapter/remix/index.js",
|
||||
"require": "./dist/adapter/remix/index.cjs"
|
||||
},
|
||||
"./adapter/bun": {
|
||||
"types": "./dist/adapter/bun/index.d.ts",
|
||||
"types": "./dist/types/adapter/bun/index.d.ts",
|
||||
"import": "./dist/adapter/bun/index.js",
|
||||
"require": "./dist/adapter/bun/index.cjs"
|
||||
},
|
||||
"./adapter/node": {
|
||||
"types": "./dist/adapter/node/index.d.ts",
|
||||
"types": "./dist/types/adapter/node/index.d.ts",
|
||||
"import": "./dist/adapter/node/index.js",
|
||||
"require": "./dist/adapter/node/index.cjs"
|
||||
},
|
||||
"./adapter/astro": {
|
||||
"types": "./dist/adapter/astro/index.d.ts",
|
||||
"types": "./dist/types/adapter/astro/index.d.ts",
|
||||
"import": "./dist/adapter/astro/index.js",
|
||||
"require": "./dist/adapter/astro/index.cjs"
|
||||
},
|
||||
|
||||
@@ -128,6 +128,14 @@ export class Api {
|
||||
};
|
||||
}
|
||||
|
||||
async getVerifiedAuthState(force?: boolean): Promise<AuthState> {
|
||||
if (force === true || !this.verified) {
|
||||
await this.verifyAuth();
|
||||
}
|
||||
|
||||
return this.getAuthState();
|
||||
}
|
||||
|
||||
async verifyAuth() {
|
||||
try {
|
||||
const res = await this.auth.me();
|
||||
|
||||
@@ -10,15 +10,19 @@ import * as SystemPermissions from "modules/permissions";
|
||||
import { AdminController, type AdminControllerOptions } from "modules/server/AdminController";
|
||||
import { SystemController } from "modules/server/SystemController";
|
||||
|
||||
export type AppPlugin<DB> = (app: App<DB>) => void;
|
||||
export type AppPlugin = (app: App) => Promise<void> | void;
|
||||
|
||||
export class AppConfigUpdatedEvent extends Event<{ app: App }> {
|
||||
abstract class AppEvent<A = {}> extends Event<{ app: App } & A> {}
|
||||
export class AppConfigUpdatedEvent extends AppEvent {
|
||||
static override slug = "app-config-updated";
|
||||
}
|
||||
export class AppBuiltEvent extends Event<{ app: App }> {
|
||||
export class AppBuiltEvent extends AppEvent {
|
||||
static override slug = "app-built";
|
||||
}
|
||||
export const AppEvents = { AppConfigUpdatedEvent, AppBuiltEvent } as const;
|
||||
export class AppFirstBoot extends AppEvent {
|
||||
static override slug = "app-first-boot";
|
||||
}
|
||||
export const AppEvents = { AppConfigUpdatedEvent, AppBuiltEvent, AppFirstBoot } as const;
|
||||
|
||||
export type CreateAppConfig = {
|
||||
connection?:
|
||||
@@ -28,29 +32,42 @@ export type CreateAppConfig = {
|
||||
config: LibSqlCredentials;
|
||||
};
|
||||
initialConfig?: InitialModuleConfigs;
|
||||
plugins?: AppPlugin<any>[];
|
||||
plugins?: AppPlugin[];
|
||||
options?: Omit<ModuleManagerOptions, "initial" | "onUpdated">;
|
||||
};
|
||||
|
||||
export type AppConfig = InitialModuleConfigs;
|
||||
|
||||
export class App<DB = any> {
|
||||
export class App {
|
||||
modules: ModuleManager;
|
||||
static readonly Events = AppEvents;
|
||||
adminController?: AdminController;
|
||||
private trigger_first_boot = false;
|
||||
|
||||
constructor(
|
||||
private connection: Connection,
|
||||
_initialConfig?: InitialModuleConfigs,
|
||||
private plugins: AppPlugin<DB>[] = [],
|
||||
private plugins: AppPlugin[] = [],
|
||||
moduleManagerOptions?: ModuleManagerOptions
|
||||
) {
|
||||
this.modules = new ModuleManager(connection, {
|
||||
...moduleManagerOptions,
|
||||
initial: _initialConfig,
|
||||
onUpdated: async (key, config) => {
|
||||
//console.log("[APP] config updated", key, config);
|
||||
// if the EventManager was disabled, we assume we shouldn't
|
||||
// respond to events, such as "onUpdated".
|
||||
if (!this.emgr.enabled) {
|
||||
console.warn("[APP] config updated, but event manager is disabled, skip.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[APP] config updated", key);
|
||||
await this.build({ sync: true, save: true });
|
||||
await this.emgr.emit(new AppConfigUpdatedEvent({ app: this }));
|
||||
},
|
||||
onFirstBoot: async () => {
|
||||
console.log("[APP] first boot");
|
||||
this.trigger_first_boot = true;
|
||||
}
|
||||
});
|
||||
this.modules.ctx().emgr.registerEvents(AppEvents);
|
||||
@@ -76,7 +93,7 @@ export class App<DB = any> {
|
||||
|
||||
// load plugins
|
||||
if (this.plugins.length > 0) {
|
||||
this.plugins.forEach((plugin) => plugin(this));
|
||||
await Promise.all(this.plugins.map((plugin) => plugin(this)));
|
||||
}
|
||||
|
||||
//console.log("emitting built", options);
|
||||
@@ -88,14 +105,24 @@ export class App<DB = any> {
|
||||
if (options?.save) {
|
||||
await this.modules.save();
|
||||
}
|
||||
|
||||
// first boot is set from ModuleManager when there wasn't a config table
|
||||
if (this.trigger_first_boot) {
|
||||
this.trigger_first_boot = false;
|
||||
await this.emgr.emit(new AppFirstBoot({ app: this }));
|
||||
}
|
||||
}
|
||||
|
||||
mutateConfig<Module extends keyof Modules>(module: Module) {
|
||||
return this.modules.get(module).schema();
|
||||
}
|
||||
|
||||
get server() {
|
||||
return this.modules.server;
|
||||
}
|
||||
|
||||
get fetch(): any {
|
||||
return this.modules.server.fetch;
|
||||
return this.server.fetch;
|
||||
}
|
||||
|
||||
get module() {
|
||||
@@ -119,7 +146,8 @@ export class App<DB = any> {
|
||||
|
||||
registerAdminController(config?: AdminControllerOptions) {
|
||||
// register admin
|
||||
this.modules.server.route("/", new AdminController(this, config).getController());
|
||||
this.adminController = new AdminController(this, config);
|
||||
this.modules.server.route("/", this.adminController.getController());
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { Api, type ApiOptions, App, type CreateAppConfig } from "bknd";
|
||||
import { type FrameworkBkndConfig, createFrameworkApp } from "adapter";
|
||||
import { Api, type ApiOptions, type App } from "bknd";
|
||||
|
||||
export type AstroBkndConfig = FrameworkBkndConfig;
|
||||
|
||||
type TAstro = {
|
||||
request: Request;
|
||||
@@ -18,12 +21,10 @@ export function getApi(Astro: TAstro, options: Options = { mode: "static" }) {
|
||||
}
|
||||
|
||||
let app: App;
|
||||
export function serve(config: CreateAppConfig) {
|
||||
export function serve(config: AstroBkndConfig = {}) {
|
||||
return async (args: TAstro) => {
|
||||
if (!app) {
|
||||
app = App.create(config);
|
||||
|
||||
await app.build();
|
||||
app = await createFrameworkApp(config);
|
||||
}
|
||||
return app.fetch(args.request);
|
||||
};
|
||||
|
||||
@@ -1,56 +1,60 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import path from "node:path";
|
||||
import { App, type CreateAppConfig } from "bknd";
|
||||
import type { Serve, ServeOptions } from "bun";
|
||||
import type { App } from "bknd";
|
||||
import type { ServeOptions } from "bun";
|
||||
import { config } from "core";
|
||||
import { serveStatic } from "hono/bun";
|
||||
import { type RuntimeBkndConfig, createRuntimeApp } from "../index";
|
||||
|
||||
let app: App;
|
||||
export async function createApp(_config: Partial<CreateAppConfig> = {}, distPath?: string) {
|
||||
|
||||
export type BunBkndConfig = RuntimeBkndConfig & Omit<ServeOptions, "fetch">;
|
||||
|
||||
export async function createApp({
|
||||
distPath,
|
||||
onBuilt,
|
||||
buildConfig,
|
||||
beforeBuild,
|
||||
...config
|
||||
}: RuntimeBkndConfig = {}) {
|
||||
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
|
||||
|
||||
if (!app) {
|
||||
app = App.create(_config);
|
||||
|
||||
app.emgr.on(
|
||||
"app-built",
|
||||
async () => {
|
||||
app.modules.server.get(
|
||||
"/*",
|
||||
serveStatic({
|
||||
root
|
||||
})
|
||||
);
|
||||
app.registerAdminController();
|
||||
},
|
||||
"sync"
|
||||
);
|
||||
|
||||
await app.build();
|
||||
app = await createRuntimeApp({
|
||||
...config,
|
||||
registerLocalMedia: true,
|
||||
serveStatic: serveStatic({ root })
|
||||
});
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
export type BunAdapterOptions = Omit<ServeOptions, "fetch"> &
|
||||
CreateAppConfig & {
|
||||
distPath?: string;
|
||||
};
|
||||
|
||||
export function serve({
|
||||
distPath,
|
||||
connection,
|
||||
initialConfig,
|
||||
plugins,
|
||||
options,
|
||||
port = 1337,
|
||||
port = config.server.default_port,
|
||||
onBuilt,
|
||||
buildConfig,
|
||||
...serveOptions
|
||||
}: BunAdapterOptions = {}) {
|
||||
}: BunBkndConfig = {}) {
|
||||
Bun.serve({
|
||||
...serveOptions,
|
||||
port,
|
||||
fetch: async (request: Request) => {
|
||||
const app = await createApp({ connection, initialConfig, plugins, options }, distPath);
|
||||
const app = await createApp({
|
||||
connection,
|
||||
initialConfig,
|
||||
plugins,
|
||||
options,
|
||||
onBuilt,
|
||||
buildConfig,
|
||||
distPath
|
||||
});
|
||||
return app.fetch(request);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,21 +1,37 @@
|
||||
import { DurableObject } from "cloudflare:workers";
|
||||
import { App, type CreateAppConfig } from "bknd";
|
||||
import type { CreateAppConfig } from "bknd";
|
||||
import { Hono } from "hono";
|
||||
import { serveStatic } from "hono/cloudflare-workers";
|
||||
import type { BkndConfig, CfBkndModeCache } from "../index";
|
||||
import type { FrameworkBkndConfig } from "../index";
|
||||
import { getCached } from "./modes/cached";
|
||||
import { getDurable } from "./modes/durable";
|
||||
import { getFresh, getWarm } from "./modes/fresh";
|
||||
|
||||
type Context = {
|
||||
request: Request;
|
||||
env: any;
|
||||
ctx: ExecutionContext;
|
||||
manifest: any;
|
||||
export type CloudflareBkndConfig<Env = any> = Omit<FrameworkBkndConfig, "app"> & {
|
||||
app: CreateAppConfig | ((env: Env) => CreateAppConfig);
|
||||
mode?: "warm" | "fresh" | "cache" | "durable";
|
||||
bindings?: (env: Env) => {
|
||||
kv?: KVNamespace;
|
||||
dobj?: DurableObjectNamespace;
|
||||
};
|
||||
key?: string;
|
||||
keepAliveSeconds?: number;
|
||||
forceHttps?: boolean;
|
||||
manifest?: string;
|
||||
setAdminHtml?: boolean;
|
||||
html?: string;
|
||||
};
|
||||
|
||||
export function serve(_config: BkndConfig, manifest?: string, html?: string) {
|
||||
export type Context = {
|
||||
request: Request;
|
||||
env: any;
|
||||
ctx: ExecutionContext;
|
||||
};
|
||||
|
||||
export function serve(config: CloudflareBkndConfig) {
|
||||
return {
|
||||
async fetch(request: Request, env: any, ctx: ExecutionContext) {
|
||||
const url = new URL(request.url);
|
||||
const manifest = config.manifest;
|
||||
|
||||
if (manifest) {
|
||||
const pathname = url.pathname.slice(1);
|
||||
@@ -26,13 +42,10 @@ export function serve(_config: BkndConfig, manifest?: string, html?: string) {
|
||||
hono.all("*", async (c, next) => {
|
||||
const res = await serveStatic({
|
||||
path: `./${pathname}`,
|
||||
manifest,
|
||||
onNotFound: (path) => console.log("not found", path)
|
||||
manifest
|
||||
})(c as any, next);
|
||||
if (res instanceof Response) {
|
||||
const ttl = pathname.startsWith("assets/")
|
||||
? 60 * 60 * 24 * 365 // 1 year
|
||||
: 60 * 5; // 5 minutes
|
||||
const ttl = 60 * 60 * 24 * 365;
|
||||
res.headers.set("Cache-Control", `public, max-age=${ttl}`);
|
||||
return res;
|
||||
}
|
||||
@@ -44,218 +57,23 @@ export function serve(_config: BkndConfig, manifest?: string, html?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
const config = {
|
||||
..._config,
|
||||
setAdminHtml: _config.setAdminHtml ?? !!manifest
|
||||
};
|
||||
const context = { request, env, ctx, manifest, html };
|
||||
const mode = config.cloudflare?.mode?.(env);
|
||||
config.setAdminHtml = config.setAdminHtml && !!config.manifest;
|
||||
|
||||
if (!mode) {
|
||||
console.log("serving fresh...");
|
||||
const app = await getFresh(config, context);
|
||||
return app.fetch(request, env);
|
||||
} else if ("cache" in mode) {
|
||||
console.log("serving cached...");
|
||||
const app = await getCached(config as any, context);
|
||||
return app.fetch(request, env);
|
||||
} else if ("durableObject" in mode) {
|
||||
console.log("serving durable...");
|
||||
const context = { request, env, ctx } as Context;
|
||||
const mode = config.mode ?? "warm";
|
||||
|
||||
if (config.onBuilt) {
|
||||
console.log("onBuilt() is not supported with DurableObject mode");
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
|
||||
const durable = mode.durableObject;
|
||||
const id = durable.idFromName(mode.key);
|
||||
const stub = durable.get(id) as unknown as DurableBkndApp;
|
||||
|
||||
const create_config = typeof config.app === "function" ? config.app(env) : config.app;
|
||||
|
||||
const res = await stub.fire(request, {
|
||||
config: create_config,
|
||||
html,
|
||||
keepAliveSeconds: mode.keepAliveSeconds,
|
||||
setAdminHtml: config.setAdminHtml
|
||||
});
|
||||
|
||||
const headers = new Headers(res.headers);
|
||||
headers.set("X-TTDO", String(performance.now() - start));
|
||||
|
||||
return new Response(res.body, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers
|
||||
});
|
||||
switch (mode) {
|
||||
case "fresh":
|
||||
return await getFresh(config, context);
|
||||
case "warm":
|
||||
return await getWarm(config, context);
|
||||
case "cache":
|
||||
return await getCached(config, context);
|
||||
case "durable":
|
||||
return await getDurable(config, context);
|
||||
default:
|
||||
throw new Error(`Unknown mode ${mode}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function getFresh(config: BkndConfig, { env, html }: Context) {
|
||||
const create_config = typeof config.app === "function" ? config.app(env) : config.app;
|
||||
const app = App.create(create_config);
|
||||
|
||||
if (config.onBuilt) {
|
||||
app.emgr.onEvent(
|
||||
App.Events.AppBuiltEvent,
|
||||
async ({ params: { app } }) => {
|
||||
config.onBuilt!(app);
|
||||
},
|
||||
"sync"
|
||||
);
|
||||
}
|
||||
await app.build();
|
||||
|
||||
if (config.setAdminHtml) {
|
||||
app.registerAdminController({ html });
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
async function getCached(
|
||||
config: BkndConfig & { cloudflare: { mode: CfBkndModeCache } },
|
||||
{ env, html, ctx }: Context
|
||||
) {
|
||||
const { cache, key } = config.cloudflare.mode(env) as ReturnType<CfBkndModeCache>;
|
||||
const create_config = typeof config.app === "function" ? config.app(env) : config.app;
|
||||
|
||||
const cachedConfig = await cache.get(key);
|
||||
const initialConfig = cachedConfig ? JSON.parse(cachedConfig) : undefined;
|
||||
|
||||
const app = App.create({ ...create_config, initialConfig });
|
||||
|
||||
async function saveConfig(__config: any) {
|
||||
ctx.waitUntil(cache.put(key, JSON.stringify(__config)));
|
||||
}
|
||||
|
||||
if (config.onBuilt) {
|
||||
app.emgr.onEvent(
|
||||
App.Events.AppBuiltEvent,
|
||||
async ({ params: { app } }) => {
|
||||
app.module.server.client.get("/__bknd/cache", async (c) => {
|
||||
await cache.delete(key);
|
||||
return c.json({ message: "Cache cleared" });
|
||||
});
|
||||
app.registerAdminController({ html });
|
||||
|
||||
config.onBuilt!(app);
|
||||
},
|
||||
"sync"
|
||||
);
|
||||
}
|
||||
|
||||
app.emgr.onEvent(
|
||||
App.Events.AppConfigUpdatedEvent,
|
||||
async ({ params: { app } }) => {
|
||||
saveConfig(app.toJSON(true));
|
||||
},
|
||||
"sync"
|
||||
);
|
||||
|
||||
await app.build();
|
||||
|
||||
if (config.setAdminHtml) {
|
||||
app.registerAdminController({ html });
|
||||
}
|
||||
|
||||
if (!cachedConfig) {
|
||||
saveConfig(app.toJSON(true));
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
export class DurableBkndApp extends DurableObject {
|
||||
protected id = Math.random().toString(36).slice(2);
|
||||
protected app?: App;
|
||||
protected interval?: any;
|
||||
|
||||
async fire(
|
||||
request: Request,
|
||||
options: {
|
||||
config: CreateAppConfig;
|
||||
html?: string;
|
||||
keepAliveSeconds?: number;
|
||||
setAdminHtml?: boolean;
|
||||
}
|
||||
) {
|
||||
let buildtime = 0;
|
||||
if (!this.app) {
|
||||
const start = performance.now();
|
||||
const config = options.config;
|
||||
|
||||
// change protocol to websocket if libsql
|
||||
if (
|
||||
config?.connection &&
|
||||
"type" in config.connection &&
|
||||
config.connection.type === "libsql"
|
||||
) {
|
||||
config.connection.config.protocol = "wss";
|
||||
}
|
||||
|
||||
this.app = App.create(config);
|
||||
this.app.emgr.onEvent(
|
||||
App.Events.AppBuiltEvent,
|
||||
async ({ params: { app } }) => {
|
||||
app.modules.server.get("/__do", async (c) => {
|
||||
// @ts-ignore
|
||||
const context: any = c.req.raw.cf ? c.req.raw.cf : c.env.cf;
|
||||
return c.json({
|
||||
id: this.id,
|
||||
keepAlive: options?.keepAliveSeconds,
|
||||
colo: context.colo
|
||||
});
|
||||
});
|
||||
},
|
||||
"sync"
|
||||
);
|
||||
|
||||
await this.app.build();
|
||||
|
||||
buildtime = performance.now() - start;
|
||||
}
|
||||
|
||||
if (options?.keepAliveSeconds) {
|
||||
this.keepAlive(options.keepAliveSeconds);
|
||||
}
|
||||
|
||||
console.log("id", this.id);
|
||||
const res = await this.app!.fetch(request);
|
||||
const headers = new Headers(res.headers);
|
||||
headers.set("X-BuildTime", buildtime.toString());
|
||||
headers.set("X-DO-ID", this.id);
|
||||
|
||||
return new Response(res.body, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers
|
||||
});
|
||||
}
|
||||
|
||||
protected keepAlive(seconds: number) {
|
||||
console.log("keep alive for", seconds);
|
||||
if (this.interval) {
|
||||
console.log("clearing, there is a new");
|
||||
clearInterval(this.interval);
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
this.interval = setInterval(() => {
|
||||
i += 1;
|
||||
//console.log("keep-alive", i);
|
||||
if (i === seconds) {
|
||||
console.log("cleared");
|
||||
clearInterval(this.interval);
|
||||
|
||||
// ping every 30 seconds
|
||||
} else if (i % 30 === 0) {
|
||||
console.log("ping");
|
||||
this.app?.modules.ctx().connection.ping();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
export * from "./cloudflare-workers.adapter";
|
||||
export { makeApp, getFresh, getWarm } from "./modes/fresh";
|
||||
export { getCached } from "./modes/cached";
|
||||
export { DurableBkndApp, getDurable } from "./modes/durable";
|
||||
|
||||
48
app/src/adapter/cloudflare/modes/cached.ts
Normal file
48
app/src/adapter/cloudflare/modes/cached.ts
Normal 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;
|
||||
}
|
||||
133
app/src/adapter/cloudflare/modes/durable.ts
Normal file
133
app/src/adapter/cloudflare/modes/durable.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
27
app/src/adapter/cloudflare/modes/fresh.ts
Normal file
27
app/src/adapter/cloudflare/modes/fresh.ts
Normal 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);
|
||||
}
|
||||
@@ -1,40 +1,20 @@
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import type { App, CreateAppConfig } from "bknd";
|
||||
import { App, type CreateAppConfig, registries } from "bknd";
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import { StorageLocalAdapter } from "media/storage/adapters/StorageLocalAdapter";
|
||||
import type { AdminControllerOptions } from "modules/server/AdminController";
|
||||
|
||||
export type CfBkndModeCache<Env = any> = (env: Env) => {
|
||||
cache: KVNamespace;
|
||||
key: string;
|
||||
};
|
||||
|
||||
export type CfBkndModeDurableObject<Env = any> = (env: Env) => {
|
||||
durableObject: DurableObjectNamespace;
|
||||
key: string;
|
||||
keepAliveSeconds?: number;
|
||||
};
|
||||
|
||||
export type CloudflareBkndConfig<Env = any> = {
|
||||
mode?: CfBkndModeCache | CfBkndModeDurableObject;
|
||||
forceHttps?: boolean;
|
||||
};
|
||||
|
||||
// @todo: move to App
|
||||
export type BkndConfig<Env = any> = {
|
||||
app: CreateAppConfig | ((env: Env) => CreateAppConfig);
|
||||
setAdminHtml?: boolean;
|
||||
server?: {
|
||||
port?: number;
|
||||
platform?: "node" | "bun";
|
||||
};
|
||||
cloudflare?: CloudflareBkndConfig<Env>;
|
||||
export type BkndConfig<Env = any> = CreateAppConfig & {
|
||||
app?: CreateAppConfig | ((env: Env) => CreateAppConfig);
|
||||
onBuilt?: (app: App) => Promise<void>;
|
||||
beforeBuild?: (app: App) => Promise<void>;
|
||||
buildConfig?: Parameters<App["build"]>[0];
|
||||
};
|
||||
|
||||
export type BkndConfigJson = {
|
||||
app: CreateAppConfig;
|
||||
setAdminHtml?: boolean;
|
||||
server?: {
|
||||
port?: number;
|
||||
};
|
||||
export type FrameworkBkndConfig<Env = any> = BkndConfig<Env>;
|
||||
|
||||
export type RuntimeBkndConfig<Env = any> = BkndConfig<Env> & {
|
||||
distPath?: string;
|
||||
};
|
||||
|
||||
export function nodeRequestToRequest(req: IncomingMessage): Request {
|
||||
@@ -60,3 +40,90 @@ export function nodeRequestToRequest(req: IncomingMessage): Request {
|
||||
headers
|
||||
});
|
||||
}
|
||||
|
||||
export function registerLocalMediaAdapter() {
|
||||
registries.media.register("local", StorageLocalAdapter);
|
||||
}
|
||||
|
||||
export function makeConfig<Env = any>(config: BkndConfig<Env>, env?: Env): CreateAppConfig {
|
||||
let additionalConfig: CreateAppConfig = {};
|
||||
if ("app" in config && config.app) {
|
||||
if (typeof config.app === "function") {
|
||||
if (!env) {
|
||||
throw new Error("env is required when config.app is a function");
|
||||
}
|
||||
additionalConfig = config.app(env);
|
||||
} else {
|
||||
additionalConfig = config.app;
|
||||
}
|
||||
}
|
||||
|
||||
return { ...config, ...additionalConfig };
|
||||
}
|
||||
|
||||
export async function createFrameworkApp<Env = any>(
|
||||
config: FrameworkBkndConfig,
|
||||
env?: Env
|
||||
): Promise<App> {
|
||||
const app = App.create(makeConfig(config, env));
|
||||
|
||||
if (config.onBuilt) {
|
||||
app.emgr.onEvent(
|
||||
App.Events.AppBuiltEvent,
|
||||
async () => {
|
||||
await config.onBuilt?.(app);
|
||||
},
|
||||
"sync"
|
||||
);
|
||||
}
|
||||
|
||||
await config.beforeBuild?.(app);
|
||||
await app.build(config.buildConfig);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
export async function createRuntimeApp<Env = any>(
|
||||
{
|
||||
serveStatic,
|
||||
registerLocalMedia,
|
||||
adminOptions,
|
||||
...config
|
||||
}: RuntimeBkndConfig & {
|
||||
serveStatic?: MiddlewareHandler | [string, MiddlewareHandler];
|
||||
registerLocalMedia?: boolean;
|
||||
adminOptions?: AdminControllerOptions | false;
|
||||
},
|
||||
env?: Env
|
||||
): Promise<App> {
|
||||
if (registerLocalMedia) {
|
||||
registerLocalMediaAdapter();
|
||||
}
|
||||
|
||||
const app = App.create(makeConfig(config, env));
|
||||
|
||||
app.emgr.onEvent(
|
||||
App.Events.AppBuiltEvent,
|
||||
async () => {
|
||||
if (serveStatic) {
|
||||
if (Array.isArray(serveStatic)) {
|
||||
const [path, handler] = serveStatic;
|
||||
app.modules.server.get(path, handler);
|
||||
} else {
|
||||
app.modules.server.get("/*", serveStatic);
|
||||
}
|
||||
}
|
||||
|
||||
await config.onBuilt?.(app);
|
||||
if (adminOptions !== false) {
|
||||
app.registerAdminController(adminOptions);
|
||||
}
|
||||
},
|
||||
"sync"
|
||||
);
|
||||
|
||||
await config.beforeBuild?.(app);
|
||||
await app.build(config.buildConfig);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { Api, App, type CreateAppConfig } from "bknd";
|
||||
import { nodeRequestToRequest } from "../index";
|
||||
import { Api, type App } from "bknd";
|
||||
import { type FrameworkBkndConfig, createFrameworkApp, nodeRequestToRequest } from "../index";
|
||||
|
||||
export type NextjsBkndConfig = FrameworkBkndConfig;
|
||||
|
||||
type GetServerSidePropsContext = {
|
||||
req: IncomingMessage;
|
||||
@@ -18,7 +20,6 @@ type GetServerSidePropsContext = {
|
||||
|
||||
export function createApi({ req }: GetServerSidePropsContext) {
|
||||
const request = nodeRequestToRequest(req);
|
||||
//console.log("createApi:request.headers", request.headers);
|
||||
return new Api({
|
||||
host: new URL(request.url).origin,
|
||||
headers: request.headers
|
||||
@@ -43,11 +44,10 @@ function getCleanRequest(req: Request) {
|
||||
}
|
||||
|
||||
let app: App;
|
||||
export function serve(config: CreateAppConfig) {
|
||||
export function serve(config: NextjsBkndConfig = {}) {
|
||||
return async (req: Request) => {
|
||||
if (!app) {
|
||||
app = App.create(config);
|
||||
await app.build();
|
||||
app = await createFrameworkApp(config);
|
||||
}
|
||||
const request = getCleanRequest(req);
|
||||
return app.fetch(request, process.env);
|
||||
|
||||
@@ -1,59 +1,6 @@
|
||||
import path from "node:path";
|
||||
import { serve as honoServe } from "@hono/node-server";
|
||||
import { serveStatic } from "@hono/node-server/serve-static";
|
||||
import { App, type CreateAppConfig } from "bknd";
|
||||
|
||||
export type NodeAdapterOptions = CreateAppConfig & {
|
||||
relativeDistPath?: string;
|
||||
port?: number;
|
||||
hostname?: string;
|
||||
listener?: Parameters<typeof honoServe>[1];
|
||||
};
|
||||
|
||||
export function serve({
|
||||
relativeDistPath,
|
||||
port = 1337,
|
||||
hostname,
|
||||
listener,
|
||||
...config
|
||||
}: NodeAdapterOptions = {}) {
|
||||
const root = path.relative(
|
||||
process.cwd(),
|
||||
path.resolve(relativeDistPath ?? "./node_modules/bknd/dist", "static")
|
||||
);
|
||||
let app: App;
|
||||
|
||||
honoServe(
|
||||
{
|
||||
port,
|
||||
hostname,
|
||||
fetch: async (req: Request) => {
|
||||
if (!app) {
|
||||
app = App.create(config);
|
||||
|
||||
app.emgr.on(
|
||||
"app-built",
|
||||
async () => {
|
||||
app.modules.server.get(
|
||||
"/*",
|
||||
serveStatic({
|
||||
root
|
||||
})
|
||||
);
|
||||
app.registerAdminController();
|
||||
},
|
||||
"sync"
|
||||
);
|
||||
|
||||
await app.build();
|
||||
}
|
||||
|
||||
return app.fetch(req);
|
||||
}
|
||||
},
|
||||
(connInfo) => {
|
||||
console.log(`Server is running on http://localhost:${connInfo.port}`);
|
||||
listener?.(connInfo);
|
||||
}
|
||||
);
|
||||
}
|
||||
export * from "./node.adapter";
|
||||
export {
|
||||
StorageLocalAdapter,
|
||||
type LocalAdapterConfig
|
||||
} from "../../media/storage/adapters/StorageLocalAdapter";
|
||||
export { registerLocalMediaAdapter } from "../index";
|
||||
|
||||
58
app/src/adapter/node/node.adapter.ts
Normal file
58
app/src/adapter/node/node.adapter.ts
Normal 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);
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { App, type CreateAppConfig } from "bknd";
|
||||
import { type FrameworkBkndConfig, createFrameworkApp } from "adapter";
|
||||
import type { App } from "bknd";
|
||||
|
||||
export type RemixBkndConfig = FrameworkBkndConfig;
|
||||
|
||||
let app: App;
|
||||
export function serve(config: CreateAppConfig) {
|
||||
export function serve(config: RemixBkndConfig = {}) {
|
||||
return async (args: { request: Request }) => {
|
||||
if (!app) {
|
||||
app = App.create(config);
|
||||
await app.build();
|
||||
app = await createFrameworkApp(config);
|
||||
}
|
||||
return app.fetch(args.request);
|
||||
};
|
||||
|
||||
@@ -1,47 +1,57 @@
|
||||
import { serveStatic } from "@hono/node-server/serve-static";
|
||||
import type { BkndConfig } from "bknd";
|
||||
import { App } from "bknd";
|
||||
import { type RuntimeBkndConfig, createRuntimeApp } from "adapter";
|
||||
import type { App } from "bknd";
|
||||
|
||||
function createApp(config: BkndConfig, env: any) {
|
||||
const create_config = typeof config.app === "function" ? config.app(env) : config.app;
|
||||
return App.create(create_config);
|
||||
}
|
||||
export type ViteBkndConfig<Env = any> = RuntimeBkndConfig<Env> & {
|
||||
setAdminHtml?: boolean;
|
||||
forceDev?: boolean;
|
||||
html?: string;
|
||||
};
|
||||
|
||||
function setAppBuildListener(app: App, config: BkndConfig, html?: string) {
|
||||
app.emgr.on(
|
||||
"app-built",
|
||||
async () => {
|
||||
await config.onBuilt?.(app);
|
||||
if (config.setAdminHtml) {
|
||||
app.registerAdminController({ html, forceDev: true });
|
||||
app.module.server.client.get("/assets/*", serveStatic({ root: "./" }));
|
||||
}
|
||||
},
|
||||
"sync"
|
||||
export function addViteScript(html: string, addBkndContext: boolean = true) {
|
||||
return html.replace(
|
||||
"</head>",
|
||||
`<script type="module">
|
||||
import RefreshRuntime from "/@react-refresh"
|
||||
RefreshRuntime.injectIntoGlobalHook(window)
|
||||
window.$RefreshReg$ = () => {}
|
||||
window.$RefreshSig$ = () => (type) => type
|
||||
window.__vite_plugin_react_preamble_installed__ = true
|
||||
</script>
|
||||
<script type="module" src="/@vite/client"></script>
|
||||
${addBkndContext ? "<!-- BKND_CONTEXT -->" : ""}
|
||||
</head>`
|
||||
);
|
||||
}
|
||||
|
||||
export async function serveFresh(config: BkndConfig, _html?: string) {
|
||||
async function createApp(config: ViteBkndConfig, env?: any) {
|
||||
return await createRuntimeApp(
|
||||
{
|
||||
...config,
|
||||
adminOptions: config.setAdminHtml
|
||||
? { html: config.html, forceDev: config.forceDev }
|
||||
: undefined,
|
||||
serveStatic: ["/assets/*", serveStatic({ root: config.distPath ?? "./" })]
|
||||
},
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
export async function serveFresh(config: ViteBkndConfig) {
|
||||
return {
|
||||
async fetch(request: Request, env: any, ctx: ExecutionContext) {
|
||||
const app = createApp(config, env);
|
||||
|
||||
setAppBuildListener(app, config, _html);
|
||||
await app.build();
|
||||
|
||||
const app = await createApp(config, env);
|
||||
return app.fetch(request, env, ctx);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let app: App;
|
||||
export async function serveCached(config: BkndConfig, _html?: string) {
|
||||
export async function serveCached(config: ViteBkndConfig) {
|
||||
return {
|
||||
async fetch(request: Request, env: any, ctx: ExecutionContext) {
|
||||
if (!app) {
|
||||
app = createApp(config, env);
|
||||
setAppBuildListener(app, config, _html);
|
||||
await app.build();
|
||||
app = await createApp(config, env);
|
||||
}
|
||||
|
||||
return app.fetch(request, env, ctx);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type AuthAction, Authenticator, type ProfileExchange, Role, type Strategy } from "auth";
|
||||
import { Exception } from "core";
|
||||
import type { PasswordStrategy } from "auth/authenticate/strategies";
|
||||
import { Exception, type PrimaryFieldType } from "core";
|
||||
import { type Static, secureRandomString, transformObject } from "core/utils";
|
||||
import { type Entity, EntityIndex, type EntityManager } from "data";
|
||||
import { type FieldSchema, entity, enumm, make, text } from "data/prototype";
|
||||
@@ -9,9 +10,9 @@ import { AuthController } from "./api/AuthController";
|
||||
import { type AppAuthSchema, STRATEGIES, authConfigSchema } from "./auth-schema";
|
||||
|
||||
export type UserFieldSchema = FieldSchema<typeof AppAuth.usersFields>;
|
||||
declare global {
|
||||
declare module "core" {
|
||||
interface DB {
|
||||
users: UserFieldSchema;
|
||||
users: { id: PrimaryFieldType } & UserFieldSchema;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +101,7 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
||||
return this._authenticator!;
|
||||
}
|
||||
|
||||
get em(): EntityManager<DB> {
|
||||
get em(): EntityManager {
|
||||
return this.ctx.em as any;
|
||||
}
|
||||
|
||||
@@ -160,7 +161,9 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
||||
|
||||
const users = this.getUsersEntity();
|
||||
this.toggleStrategyValueVisibility(true);
|
||||
const result = await this.em.repo(users).findOne({ email: profile.email! });
|
||||
const result = await this.em
|
||||
.repo(users as unknown as "users")
|
||||
.findOne({ email: profile.email! });
|
||||
this.toggleStrategyValueVisibility(false);
|
||||
if (!result.data) {
|
||||
throw new Exception("User not found", 404);
|
||||
@@ -197,7 +200,7 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
||||
throw new Exception("User already exists");
|
||||
}
|
||||
|
||||
const payload = {
|
||||
const payload: any = {
|
||||
...profile,
|
||||
strategy: strategy.getName(),
|
||||
strategy_value: identifier
|
||||
@@ -284,6 +287,25 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async createUser({
|
||||
email,
|
||||
password,
|
||||
...additional
|
||||
}: { email: string; password: string; [key: string]: any }) {
|
||||
const strategy = "password";
|
||||
const pw = this.authenticator.strategy(strategy) as PasswordStrategy;
|
||||
const strategy_value = await pw.hash(password);
|
||||
const mutator = this.em.mutator(this.config.entity_name as "users");
|
||||
mutator.__unstable_toggleSystemEntityCreation(false);
|
||||
const { data: created } = await mutator.insertOne({
|
||||
...(additional as any),
|
||||
strategy,
|
||||
strategy_value
|
||||
});
|
||||
mutator.__unstable_toggleSystemEntityCreation(true);
|
||||
return created;
|
||||
}
|
||||
|
||||
override toJSON(secrets?: boolean): AppAuthSchema {
|
||||
if (!this.config.enabled) {
|
||||
return this.configDefault;
|
||||
|
||||
@@ -220,15 +220,23 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
||||
}
|
||||
|
||||
private async getAuthCookie(c: Context): Promise<string | undefined> {
|
||||
const secret = this.config.jwt.secret;
|
||||
try {
|
||||
const secret = this.config.jwt.secret;
|
||||
|
||||
const token = await getSignedCookie(c, secret, "auth");
|
||||
if (typeof token !== "string") {
|
||||
await deleteCookie(c, "auth", this.cookieOptions);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return token;
|
||||
} catch (e: any) {
|
||||
if (e instanceof Error) {
|
||||
console.error("[Error:getAuthCookie]", e.message);
|
||||
}
|
||||
|
||||
const token = await getSignedCookie(c, secret, "auth");
|
||||
if (typeof token !== "string") {
|
||||
await deleteCookie(c, "auth", this.cookieOptions);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
async requestCookieRefresh(c: Context) {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { Config } from "@libsql/client/node";
|
||||
import { App, type CreateAppConfig } from "App";
|
||||
import type { BkndConfig } from "adapter";
|
||||
import type { CliCommand } from "cli/types";
|
||||
import { StorageLocalAdapter } from "adapter/node";
|
||||
import type { CliBkndConfig, CliCommand } from "cli/types";
|
||||
import { Option } from "commander";
|
||||
import { config } from "core";
|
||||
import { registries } from "modules/registries";
|
||||
import {
|
||||
PLATFORMS,
|
||||
type Platform,
|
||||
@@ -19,7 +21,7 @@ export const run: CliCommand = (program) => {
|
||||
.addOption(
|
||||
new Option("-p, --port <port>", "port to run on")
|
||||
.env("PORT")
|
||||
.default(1337)
|
||||
.default(config.server.default_port)
|
||||
.argParser((v) => Number.parseInt(v))
|
||||
)
|
||||
.addOption(new Option("-c, --config <config>", "config file"))
|
||||
@@ -37,6 +39,12 @@ export const run: CliCommand = (program) => {
|
||||
.action(action);
|
||||
};
|
||||
|
||||
// automatically register local adapter
|
||||
const local = StorageLocalAdapter.prototype.getName();
|
||||
if (!registries.media.has(local)) {
|
||||
registries.media.register(local, StorageLocalAdapter);
|
||||
}
|
||||
|
||||
type MakeAppConfig = {
|
||||
connection?: CreateAppConfig["connection"];
|
||||
server?: { platform?: Platform };
|
||||
@@ -47,8 +55,8 @@ type MakeAppConfig = {
|
||||
async function makeApp(config: MakeAppConfig) {
|
||||
const app = App.create({ connection: config.connection });
|
||||
|
||||
app.emgr.on(
|
||||
"app-built",
|
||||
app.emgr.onEvent(
|
||||
App.Events.AppBuiltEvent,
|
||||
async () => {
|
||||
await attachServeStatic(app, config.server?.platform ?? "node");
|
||||
app.registerAdminController();
|
||||
@@ -64,24 +72,23 @@ async function makeApp(config: MakeAppConfig) {
|
||||
return app;
|
||||
}
|
||||
|
||||
export async function makeConfigApp(config: BkndConfig, platform?: Platform) {
|
||||
export async function makeConfigApp(config: CliBkndConfig, platform?: Platform) {
|
||||
const appConfig = typeof config.app === "function" ? config.app(process.env) : config.app;
|
||||
const app = App.create(appConfig);
|
||||
|
||||
app.emgr.on(
|
||||
"app-built",
|
||||
app.emgr.onEvent(
|
||||
App.Events.AppBuiltEvent,
|
||||
async () => {
|
||||
await attachServeStatic(app, platform ?? "node");
|
||||
app.registerAdminController();
|
||||
|
||||
if (config.onBuilt) {
|
||||
await config.onBuilt(app);
|
||||
}
|
||||
await config.onBuilt?.(app);
|
||||
},
|
||||
"sync"
|
||||
);
|
||||
|
||||
await app.build();
|
||||
await config.beforeBuild?.(app);
|
||||
await app.build(config.buildConfig);
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -102,7 +109,7 @@ async function action(options: {
|
||||
app = await makeApp({ connection, server: { platform: options.server } });
|
||||
} else {
|
||||
console.log("Using config from:", configFilePath);
|
||||
const config = (await import(configFilePath).then((m) => m.default)) as BkndConfig;
|
||||
const config = (await import(configFilePath).then((m) => m.default)) as CliBkndConfig;
|
||||
app = await makeConfigApp(config, options.server);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { password as $password, text as $text } from "@clack/prompts";
|
||||
import type { App } from "App";
|
||||
import type { PasswordStrategy } from "auth/authenticate/strategies";
|
||||
import type { App, BkndConfig } from "bknd";
|
||||
import { makeConfigApp } from "cli/commands/run";
|
||||
import { getConfigPath } from "cli/commands/run/platform";
|
||||
import type { CliCommand } from "cli/types";
|
||||
import type { CliBkndConfig, CliCommand } from "cli/types";
|
||||
import { Argument } from "commander";
|
||||
|
||||
export const user: CliCommand = (program) => {
|
||||
@@ -21,7 +21,7 @@ async function action(action: "create" | "update", options: any) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = (await import(configFilePath).then((m) => m.default)) as BkndConfig;
|
||||
const config = (await import(configFilePath).then((m) => m.default)) as CliBkndConfig;
|
||||
const app = await makeConfigApp(config, options.server);
|
||||
|
||||
switch (action) {
|
||||
@@ -37,7 +37,7 @@ async function action(action: "create" | "update", options: any) {
|
||||
async function create(app: App, options: any) {
|
||||
const config = app.module.auth.toJSON(true);
|
||||
const strategy = app.module.auth.authenticator.strategy("password") as PasswordStrategy;
|
||||
const users_entity = config.entity_name;
|
||||
const users_entity = config.entity_name as "users";
|
||||
|
||||
const email = await $text({
|
||||
message: "Enter email",
|
||||
@@ -83,7 +83,7 @@ async function create(app: App, options: any) {
|
||||
async function update(app: App, options: any) {
|
||||
const config = app.module.auth.toJSON(true);
|
||||
const strategy = app.module.auth.authenticator.strategy("password") as PasswordStrategy;
|
||||
const users_entity = config.entity_name;
|
||||
const users_entity = config.entity_name as "users";
|
||||
const em = app.modules.ctx().em;
|
||||
|
||||
const email = (await $text({
|
||||
|
||||
11
app/src/cli/types.d.ts
vendored
11
app/src/cli/types.d.ts
vendored
@@ -1,3 +1,14 @@
|
||||
import type { CreateAppConfig } from "App";
|
||||
import type { FrameworkBkndConfig } from "adapter";
|
||||
import type { Command } from "commander";
|
||||
|
||||
export type CliCommand = (program: Command) => void;
|
||||
|
||||
export type CliBkndConfig<Env = any> = FrameworkBkndConfig & {
|
||||
app: CreateAppConfig | ((env: Env) => CreateAppConfig);
|
||||
setAdminHtml?: boolean;
|
||||
server?: {
|
||||
port?: number;
|
||||
platform?: "node" | "bun";
|
||||
};
|
||||
};
|
||||
|
||||
@@ -5,7 +5,13 @@ import type { Generated } from "kysely";
|
||||
|
||||
export type PrimaryFieldType = number | Generated<number>;
|
||||
|
||||
// biome-ignore lint/suspicious/noEmptyInterface: <explanation>
|
||||
export interface DB {}
|
||||
|
||||
export const config = {
|
||||
server: {
|
||||
default_port: 1337
|
||||
},
|
||||
data: {
|
||||
default_primary_field: "id"
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export class EventManager<
|
||||
> {
|
||||
protected events: EventClass[] = [];
|
||||
protected listeners: EventListener[] = [];
|
||||
enabled: boolean = true;
|
||||
|
||||
constructor(events?: RegisteredEvents, listeners?: EventListener[]) {
|
||||
if (events) {
|
||||
@@ -28,6 +29,16 @@ export class EventManager<
|
||||
}
|
||||
}
|
||||
|
||||
enable() {
|
||||
this.enabled = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
disable() {
|
||||
this.enabled = false;
|
||||
return this;
|
||||
}
|
||||
|
||||
clearEvents() {
|
||||
this.events = [];
|
||||
return this;
|
||||
@@ -39,6 +50,10 @@ export class EventManager<
|
||||
return this;
|
||||
}
|
||||
|
||||
getListeners(): EventListener[] {
|
||||
return [...this.listeners];
|
||||
}
|
||||
|
||||
get Events(): { [K in keyof RegisteredEvents]: RegisteredEvents[K] } {
|
||||
// proxy class to access events
|
||||
return new Proxy(this, {
|
||||
@@ -133,6 +148,11 @@ export class EventManager<
|
||||
async emit(event: Event) {
|
||||
// @ts-expect-error slug is static
|
||||
const slug = event.constructor.slug;
|
||||
if (!this.enabled) {
|
||||
console.log("EventManager disabled, not emitting", slug);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.eventExists(event)) {
|
||||
throw new Error(`Event "${slug}" not registered`);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Hono, MiddlewareHandler } from "hono";
|
||||
export { tbValidator } from "./server/lib/tbValidator";
|
||||
export { Exception, BkndError } from "./errors";
|
||||
export { isDebug } from "./env";
|
||||
export { type PrimaryFieldType, config } from "./config";
|
||||
export { type PrimaryFieldType, config, type DB } from "./config";
|
||||
export { AwsClient } from "./clients/aws/AwsClient";
|
||||
export {
|
||||
SimpleRenderer,
|
||||
|
||||
@@ -69,7 +69,8 @@ export class SchemaObject<Schema extends TObject> {
|
||||
forceParse: true,
|
||||
skipMark: this.isForceParse()
|
||||
});
|
||||
const updatedConfig = noEmit ? valid : await this.onBeforeUpdate(this._config, valid);
|
||||
// regardless of "noEmit" – this should always be triggered
|
||||
const updatedConfig = await this.onBeforeUpdate(this._config, valid);
|
||||
|
||||
this._value = updatedConfig;
|
||||
this._config = Object.freeze(updatedConfig);
|
||||
|
||||
@@ -1,29 +1,50 @@
|
||||
export type Constructor<T> = new (...args: any[]) => T;
|
||||
export class Registry<Item, Items extends Record<string, object> = Record<string, object>> {
|
||||
|
||||
export type RegisterFn<Item> = (unknown: any) => Item;
|
||||
|
||||
export class Registry<
|
||||
Item,
|
||||
Items extends Record<string, Item> = Record<string, Item>,
|
||||
Fn extends RegisterFn<Item> = RegisterFn<Item>
|
||||
> {
|
||||
private is_set: boolean = false;
|
||||
private items: Items = {} as Items;
|
||||
|
||||
set<Actual extends Record<string, object>>(items: Actual) {
|
||||
constructor(private registerFn?: Fn) {}
|
||||
|
||||
set<Actual extends Record<string, Item>>(items: Actual) {
|
||||
if (this.is_set) {
|
||||
throw new Error("Registry is already set");
|
||||
}
|
||||
// @ts-ignore
|
||||
this.items = items;
|
||||
this.items = items as unknown as Items;
|
||||
this.is_set = true;
|
||||
|
||||
return this as unknown as Registry<Item, Actual>;
|
||||
return this as unknown as Registry<Item, Actual, Fn>;
|
||||
}
|
||||
|
||||
add(name: string, item: Item) {
|
||||
// @ts-ignore
|
||||
this.items[name] = item;
|
||||
this.items[name as keyof Items] = item as Items[keyof Items];
|
||||
return this;
|
||||
}
|
||||
|
||||
register(name: string, specific: Parameters<Fn>[0]) {
|
||||
if (this.registerFn) {
|
||||
const item = this.registerFn(specific);
|
||||
this.items[name as keyof Items] = item as Items[keyof Items];
|
||||
return this;
|
||||
}
|
||||
|
||||
return this.add(name, specific);
|
||||
}
|
||||
|
||||
get<Name extends keyof Items>(name: Name): Items[Name] {
|
||||
return this.items[name];
|
||||
}
|
||||
|
||||
has(name: keyof Items): boolean {
|
||||
return name in this.items;
|
||||
}
|
||||
|
||||
all() {
|
||||
return this.items;
|
||||
}
|
||||
|
||||
@@ -20,11 +20,16 @@ export class DebugLogger {
|
||||
return this;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.last = 0;
|
||||
return this;
|
||||
}
|
||||
|
||||
log(...args: any[]) {
|
||||
if (!this._enabled) return this;
|
||||
|
||||
const now = performance.now();
|
||||
const time = Number.parseInt(String(now - this.last));
|
||||
const time = this.last === 0 ? 0 : Number.parseInt(String(now - this.last));
|
||||
const indents = " ".repeat(this._context.length);
|
||||
const context =
|
||||
this._context.length > 0 ? `[${this._context[this._context.length - 1]}]` : "";
|
||||
|
||||
@@ -9,10 +9,25 @@ export async function withDisabledConsole<R>(
|
||||
fn: () => Promise<R>,
|
||||
severities: ConsoleSeverity[] = ["log"]
|
||||
): Promise<R> {
|
||||
const enable = disableConsoleLog(severities);
|
||||
const result = await fn();
|
||||
enable();
|
||||
return result;
|
||||
const _oldConsoles = {
|
||||
log: console.log,
|
||||
warn: console.warn,
|
||||
error: console.error
|
||||
};
|
||||
disableConsoleLog(severities);
|
||||
const enable = () => {
|
||||
Object.entries(_oldConsoles).forEach(([severity, fn]) => {
|
||||
console[severity as ConsoleSeverity] = fn;
|
||||
});
|
||||
};
|
||||
try {
|
||||
const result = await fn();
|
||||
enable();
|
||||
return result;
|
||||
} catch (e) {
|
||||
enable();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export function disableConsoleLog(severities: ConsoleSeverity[] = ["log"]) {
|
||||
|
||||
@@ -1,52 +1,20 @@
|
||||
import { transformObject } from "core/utils";
|
||||
import { DataPermissions, Entity, EntityIndex, type EntityManager, type Field } from "data";
|
||||
import {
|
||||
DataPermissions,
|
||||
type Entity,
|
||||
EntityIndex,
|
||||
type EntityManager,
|
||||
constructEntity,
|
||||
constructRelation
|
||||
} from "data";
|
||||
import { Module } from "modules/Module";
|
||||
import { DataController } from "./api/DataController";
|
||||
import {
|
||||
type AppDataConfig,
|
||||
FIELDS,
|
||||
RELATIONS,
|
||||
type TAppDataEntity,
|
||||
type TAppDataRelation,
|
||||
dataConfigSchema
|
||||
} from "./data-schema";
|
||||
|
||||
export class AppData<DB> extends Module<typeof dataConfigSchema> {
|
||||
static constructEntity(name: string, entityConfig: TAppDataEntity) {
|
||||
const fields = transformObject(entityConfig.fields ?? {}, (fieldConfig, name) => {
|
||||
const { type } = fieldConfig;
|
||||
if (!(type in FIELDS)) {
|
||||
throw new Error(`Field type "${type}" not found`);
|
||||
}
|
||||
|
||||
const { field } = FIELDS[type as any];
|
||||
const returnal = new field(name, fieldConfig.config) as Field;
|
||||
return returnal;
|
||||
});
|
||||
|
||||
// @todo: entity must be migrated to typebox
|
||||
return new Entity(
|
||||
name,
|
||||
Object.values(fields),
|
||||
entityConfig.config as any,
|
||||
entityConfig.type as any
|
||||
);
|
||||
}
|
||||
|
||||
static constructRelation(
|
||||
relationConfig: TAppDataRelation,
|
||||
resolver: (name: Entity | string) => Entity
|
||||
) {
|
||||
return new RELATIONS[relationConfig.type].cls(
|
||||
resolver(relationConfig.source),
|
||||
resolver(relationConfig.target),
|
||||
relationConfig.config
|
||||
);
|
||||
}
|
||||
import { type AppDataConfig, dataConfigSchema } from "./data-schema";
|
||||
|
||||
export class AppData extends Module<typeof dataConfigSchema> {
|
||||
override async build() {
|
||||
const entities = transformObject(this.config.entities ?? {}, (entityConfig, name) => {
|
||||
return AppData.constructEntity(name, entityConfig);
|
||||
return constructEntity(name, entityConfig);
|
||||
});
|
||||
|
||||
const _entity = (_e: Entity | string): Entity => {
|
||||
@@ -57,7 +25,7 @@ export class AppData<DB> extends Module<typeof dataConfigSchema> {
|
||||
};
|
||||
|
||||
const relations = transformObject(this.config.relations ?? {}, (relation) =>
|
||||
AppData.constructRelation(relation, _entity)
|
||||
constructRelation(relation, _entity)
|
||||
);
|
||||
|
||||
const indices = transformObject(this.config.indices ?? {}, (index, name) => {
|
||||
@@ -91,7 +59,7 @@ export class AppData<DB> extends Module<typeof dataConfigSchema> {
|
||||
return dataConfigSchema;
|
||||
}
|
||||
|
||||
get em(): EntityManager<DB> {
|
||||
get em(): EntityManager {
|
||||
this.throwIfNotBuilt();
|
||||
return this.ctx.em;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DB } from "core";
|
||||
import type { EntityData, RepoQuery, RepositoryResponse } from "data";
|
||||
import { type BaseModuleApiOptions, ModuleApi, type PrimaryFieldType } from "modules";
|
||||
|
||||
@@ -15,48 +16,60 @@ export class DataApi extends ModuleApi<DataApiOptions> {
|
||||
};
|
||||
}
|
||||
|
||||
readOne(
|
||||
entity: string,
|
||||
readOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||
entity: E,
|
||||
id: PrimaryFieldType,
|
||||
query: Partial<Omit<RepoQuery, "where" | "limit" | "offset">> = {}
|
||||
) {
|
||||
return this.get<RepositoryResponse<EntityData>>([entity, id], query);
|
||||
return this.get<Pick<RepositoryResponse<Data>, "meta" | "data">>([entity as any, id], query);
|
||||
}
|
||||
|
||||
readMany(entity: string, query: Partial<RepoQuery> = {}) {
|
||||
return this.get<Pick<RepositoryResponse, "meta" | "data">>(
|
||||
[entity],
|
||||
query ?? this.options.defaultQuery
|
||||
);
|
||||
}
|
||||
|
||||
readManyByReference(
|
||||
entity: string,
|
||||
id: PrimaryFieldType,
|
||||
reference: string,
|
||||
readMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||
entity: E,
|
||||
query: Partial<RepoQuery> = {}
|
||||
) {
|
||||
return this.get<Pick<RepositoryResponse, "meta" | "data">>(
|
||||
[entity, id, reference],
|
||||
return this.get<Pick<RepositoryResponse<Data[]>, "meta" | "data">>(
|
||||
[entity as any],
|
||||
query ?? this.options.defaultQuery
|
||||
);
|
||||
}
|
||||
|
||||
createOne(entity: string, input: EntityData) {
|
||||
return this.post<RepositoryResponse<EntityData>>([entity], input);
|
||||
readManyByReference<
|
||||
E extends keyof DB | string,
|
||||
R extends keyof DB | string,
|
||||
Data = R extends keyof DB ? DB[R] : EntityData
|
||||
>(entity: E, id: PrimaryFieldType, reference: R, query: Partial<RepoQuery> = {}) {
|
||||
return this.get<Pick<RepositoryResponse<Data[]>, "meta" | "data">>(
|
||||
[entity as any, id, reference],
|
||||
query ?? this.options.defaultQuery
|
||||
);
|
||||
}
|
||||
|
||||
updateOne(entity: string, id: PrimaryFieldType, input: EntityData) {
|
||||
return this.patch<RepositoryResponse<EntityData>>([entity, id], input);
|
||||
createOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||
entity: E,
|
||||
input: Omit<Data, "id">
|
||||
) {
|
||||
return this.post<RepositoryResponse<Data>>([entity as any], input);
|
||||
}
|
||||
|
||||
deleteOne(entity: string, id: PrimaryFieldType) {
|
||||
return this.delete<RepositoryResponse<EntityData>>([entity, id]);
|
||||
updateOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||
entity: E,
|
||||
id: PrimaryFieldType,
|
||||
input: Partial<Omit<Data, "id">>
|
||||
) {
|
||||
return this.patch<RepositoryResponse<Data>>([entity as any, id], input);
|
||||
}
|
||||
|
||||
count(entity: string, where: RepoQuery["where"] = {}) {
|
||||
return this.post<RepositoryResponse<{ entity: string; count: number }>>(
|
||||
[entity, "fn", "count"],
|
||||
deleteOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||
entity: E,
|
||||
id: PrimaryFieldType
|
||||
) {
|
||||
return this.delete<RepositoryResponse<Data>>([entity as any, id]);
|
||||
}
|
||||
|
||||
count<E extends keyof DB | string>(entity: E, where: RepoQuery["where"] = {}) {
|
||||
return this.post<RepositoryResponse<{ entity: E; count: number }>>(
|
||||
[entity as any, "fn", "count"],
|
||||
where
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type ClassController, isDebug, tbValidator as tb } from "core";
|
||||
import { Type, objectCleanEmpty, objectTransform } from "core/utils";
|
||||
import { StringEnum, Type, objectCleanEmpty, objectTransform } from "core/utils";
|
||||
import {
|
||||
DataPermissions,
|
||||
type EntityData,
|
||||
@@ -165,13 +165,12 @@ export class DataController implements ClassController {
|
||||
// read entity schema
|
||||
.get("/schema.json", async (c) => {
|
||||
this.guard.throwUnlessGranted(DataPermissions.entityRead);
|
||||
const url = new URL(c.req.url);
|
||||
const $id = `${url.origin}${this.config.basepath}/schema.json`;
|
||||
const $id = `${this.config.basepath}/schema.json`;
|
||||
const schemas = Object.fromEntries(
|
||||
this.em.entities.map((e) => [
|
||||
e.name,
|
||||
{
|
||||
$ref: `schemas/${e.name}`
|
||||
$ref: `${this.config.basepath}/schemas/${e.name}`
|
||||
}
|
||||
])
|
||||
);
|
||||
@@ -183,22 +182,28 @@ export class DataController implements ClassController {
|
||||
})
|
||||
// read schema
|
||||
.get(
|
||||
"/schemas/:entity",
|
||||
tb("param", Type.Object({ entity: Type.String() })),
|
||||
"/schemas/:entity/:context?",
|
||||
tb(
|
||||
"param",
|
||||
Type.Object({
|
||||
entity: Type.String(),
|
||||
context: Type.Optional(StringEnum(["create", "update"]))
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
this.guard.throwUnlessGranted(DataPermissions.entityRead);
|
||||
|
||||
//console.log("request", c.req.raw);
|
||||
const { entity } = c.req.param();
|
||||
const { entity, context } = c.req.param();
|
||||
if (!this.entityExists(entity)) {
|
||||
console.log("not found", entity, definedEntities);
|
||||
return c.notFound();
|
||||
}
|
||||
const _entity = this.em.entity(entity);
|
||||
const schema = _entity.toSchema();
|
||||
const schema = _entity.toSchema({ context } as any);
|
||||
const url = new URL(c.req.url);
|
||||
const base = `${url.origin}${this.config.basepath}`;
|
||||
const $id = `${base}/schemas/${entity}`;
|
||||
const $id = `${this.config.basepath}/schemas/${entity}`;
|
||||
return c.json({
|
||||
$schema: `${base}/schema.json`,
|
||||
$id,
|
||||
|
||||
7
app/src/data/connection/DummyConnection.ts
Normal file
7
app/src/data/connection/DummyConnection.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Connection } from "./Connection";
|
||||
|
||||
export class DummyConnection extends Connection {
|
||||
constructor() {
|
||||
super(undefined as any);
|
||||
}
|
||||
}
|
||||
@@ -158,7 +158,7 @@ export class Entity<
|
||||
}
|
||||
|
||||
get label(): string {
|
||||
return snakeToPascalWithSpaces(this.config.name || this.name);
|
||||
return this.config.name ?? snakeToPascalWithSpaces(this.name);
|
||||
}
|
||||
|
||||
field(name: string): Field | undefined {
|
||||
@@ -210,20 +210,34 @@ export class Entity<
|
||||
return true;
|
||||
}
|
||||
|
||||
toSchema(clean?: boolean): object {
|
||||
const fields = Object.fromEntries(this.fields.map((field) => [field.name, field]));
|
||||
toSchema(options?: { clean: boolean; context?: "create" | "update" }): object {
|
||||
let fields: Field[];
|
||||
switch (options?.context) {
|
||||
case "create":
|
||||
case "update":
|
||||
fields = this.getFillableFields(options.context);
|
||||
break;
|
||||
default:
|
||||
fields = this.getFields(true);
|
||||
}
|
||||
|
||||
const _fields = Object.fromEntries(fields.map((field) => [field.name, field]));
|
||||
const schema = Type.Object(
|
||||
transformObject(fields, (field) => ({
|
||||
title: field.config.label,
|
||||
$comment: field.config.description,
|
||||
$field: field.type,
|
||||
readOnly: !field.isFillable("update") ? true : undefined,
|
||||
writeOnly: !field.isFillable("create") ? true : undefined,
|
||||
...field.toJsonSchema()
|
||||
}))
|
||||
transformObject(_fields, (field) => {
|
||||
//const hidden = field.isHidden(options?.context);
|
||||
const fillable = field.isFillable(options?.context);
|
||||
return {
|
||||
title: field.config.label,
|
||||
$comment: field.config.description,
|
||||
$field: field.type,
|
||||
readOnly: !fillable ? true : undefined,
|
||||
...field.toJsonSchema()
|
||||
};
|
||||
}),
|
||||
{ additionalProperties: false }
|
||||
);
|
||||
|
||||
return clean ? JSON.parse(JSON.stringify(schema)) : schema;
|
||||
return options?.clean ? JSON.parse(JSON.stringify(schema)) : schema;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DB as DefaultDB } from "core";
|
||||
import { EventManager } from "core/events";
|
||||
import { sql } from "kysely";
|
||||
import { Connection } from "../connection/Connection";
|
||||
@@ -14,7 +15,18 @@ import { SchemaManager } from "../schema/SchemaManager";
|
||||
import { Entity } from "./Entity";
|
||||
import { type EntityData, Mutator, Repository } from "./index";
|
||||
|
||||
export class EntityManager<DB> {
|
||||
type EntitySchema<
|
||||
TBD extends object = DefaultDB,
|
||||
E extends Entity | keyof TBD | string = string
|
||||
> = E extends Entity<infer Name>
|
||||
? Name extends keyof TBD
|
||||
? Name
|
||||
: never
|
||||
: E extends keyof TBD
|
||||
? E
|
||||
: never;
|
||||
|
||||
export class EntityManager<TBD extends object = DefaultDB> {
|
||||
connection: Connection;
|
||||
|
||||
private _entities: Entity[] = [];
|
||||
@@ -50,7 +62,7 @@ export class EntityManager<DB> {
|
||||
* Forks the EntityManager without the EventManager.
|
||||
* This is useful when used inside an event handler.
|
||||
*/
|
||||
fork(): EntityManager<DB> {
|
||||
fork(): EntityManager {
|
||||
return new EntityManager(this._entities, this.connection, this._relations, this._indices);
|
||||
}
|
||||
|
||||
@@ -87,10 +99,17 @@ export class EntityManager<DB> {
|
||||
this.entities.push(entity);
|
||||
}
|
||||
|
||||
entity(name: string): Entity {
|
||||
const entity = this.entities.find((e) => e.name === name);
|
||||
entity(e: Entity | keyof TBD | string): Entity {
|
||||
let entity: Entity | undefined;
|
||||
if (typeof e === "string") {
|
||||
entity = this.entities.find((entity) => entity.name === e);
|
||||
} else if (e instanceof Entity) {
|
||||
entity = e;
|
||||
}
|
||||
|
||||
if (!entity) {
|
||||
throw new EntityNotDefinedException(name);
|
||||
// @ts-ignore
|
||||
throw new EntityNotDefinedException(e instanceof Entity ? e.name : e);
|
||||
}
|
||||
|
||||
return entity;
|
||||
@@ -162,28 +181,18 @@ export class EntityManager<DB> {
|
||||
return this.relations.relationReferencesOf(this.entity(entity_name));
|
||||
}
|
||||
|
||||
repository(_entity: Entity | string) {
|
||||
const entity = _entity instanceof Entity ? _entity : this.entity(_entity);
|
||||
return new Repository(this, entity, this.emgr);
|
||||
repository<E extends Entity | keyof TBD | string>(
|
||||
entity: E
|
||||
): Repository<TBD, EntitySchema<TBD, E>> {
|
||||
return this.repo(entity);
|
||||
}
|
||||
|
||||
repo<E extends Entity>(
|
||||
_entity: E
|
||||
): Repository<
|
||||
DB,
|
||||
E extends Entity<infer Name> ? (Name extends keyof DB ? Name : never) : never
|
||||
> {
|
||||
return new Repository(this, _entity, this.emgr);
|
||||
repo<E extends Entity | keyof TBD | string>(entity: E): Repository<TBD, EntitySchema<TBD, E>> {
|
||||
return new Repository(this, this.entity(entity), this.emgr);
|
||||
}
|
||||
|
||||
_repo<TB extends keyof DB>(_entity: TB): Repository<DB, TB> {
|
||||
const entity = this.entity(_entity as any);
|
||||
return new Repository(this, entity, this.emgr);
|
||||
}
|
||||
|
||||
mutator(_entity: Entity | string) {
|
||||
const entity = _entity instanceof Entity ? _entity : this.entity(_entity);
|
||||
return new Mutator(this, entity, this.emgr);
|
||||
mutator<E extends Entity | keyof TBD | string>(entity: E): Mutator<TBD, EntitySchema<TBD, E>> {
|
||||
return new Mutator(this, this.entity(entity), this.emgr);
|
||||
}
|
||||
|
||||
addIndex(index: EntityIndex, force = false) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PrimaryFieldType } from "core";
|
||||
import type { DB as DefaultDB, PrimaryFieldType } from "core";
|
||||
import { type EmitsEvents, EventManager } from "core/events";
|
||||
import type { DeleteQueryBuilder, InsertQueryBuilder, UpdateQueryBuilder } from "kysely";
|
||||
import { type TActionContext, WhereBuilder } from "..";
|
||||
@@ -25,8 +25,14 @@ export type MutatorResponse<T = EntityData[]> = {
|
||||
data: T;
|
||||
};
|
||||
|
||||
export class Mutator<DB> implements EmitsEvents {
|
||||
em: EntityManager<DB>;
|
||||
export class Mutator<
|
||||
TBD extends object = DefaultDB,
|
||||
TB extends keyof TBD = any,
|
||||
Output = TBD[TB],
|
||||
Input = Omit<Output, "id">
|
||||
> implements EmitsEvents
|
||||
{
|
||||
em: EntityManager<TBD>;
|
||||
entity: Entity;
|
||||
static readonly Events = MutatorEvents;
|
||||
emgr: EventManager<typeof MutatorEvents>;
|
||||
@@ -37,7 +43,7 @@ export class Mutator<DB> implements EmitsEvents {
|
||||
this.__unstable_disable_system_entity_creation = value;
|
||||
}
|
||||
|
||||
constructor(em: EntityManager<DB>, entity: Entity, emgr?: EventManager<any>) {
|
||||
constructor(em: EntityManager<TBD>, entity: Entity, emgr?: EventManager<any>) {
|
||||
this.em = em;
|
||||
this.entity = entity;
|
||||
this.emgr = emgr ?? new EventManager(MutatorEvents);
|
||||
@@ -47,13 +53,13 @@ export class Mutator<DB> implements EmitsEvents {
|
||||
return this.em.connection.kysely;
|
||||
}
|
||||
|
||||
async getValidatedData(data: EntityData, context: TActionContext): Promise<EntityData> {
|
||||
async getValidatedData<Given = any>(data: Given, context: TActionContext): Promise<Given> {
|
||||
const entity = this.entity;
|
||||
if (!context) {
|
||||
throw new Error("Context must be provided for validation");
|
||||
}
|
||||
|
||||
const keys = Object.keys(data);
|
||||
const keys = Object.keys(data as any);
|
||||
const validatedData: EntityData = {};
|
||||
|
||||
// get relational references/keys
|
||||
@@ -95,7 +101,7 @@ export class Mutator<DB> implements EmitsEvents {
|
||||
throw new Error(`No data left to update "${entity.name}"`);
|
||||
}
|
||||
|
||||
return validatedData;
|
||||
return validatedData as Given;
|
||||
}
|
||||
|
||||
protected async many(qb: MutatorQB): Promise<MutatorResponse> {
|
||||
@@ -120,7 +126,7 @@ export class Mutator<DB> implements EmitsEvents {
|
||||
return { ...response, data: data[0]! };
|
||||
}
|
||||
|
||||
async insertOne(data: EntityData): Promise<MutatorResponse<EntityData>> {
|
||||
async insertOne(data: Input): Promise<MutatorResponse<Output>> {
|
||||
const entity = this.entity;
|
||||
if (entity.type === "system" && this.__unstable_disable_system_entity_creation) {
|
||||
throw new Error(`Creation of system entity "${entity.name}" is disabled`);
|
||||
@@ -154,10 +160,10 @@ export class Mutator<DB> implements EmitsEvents {
|
||||
|
||||
await this.emgr.emit(new Mutator.Events.MutatorInsertAfter({ entity, data: res.data }));
|
||||
|
||||
return res;
|
||||
return res as any;
|
||||
}
|
||||
|
||||
async updateOne(id: PrimaryFieldType, data: EntityData): Promise<MutatorResponse<EntityData>> {
|
||||
async updateOne(id: PrimaryFieldType, data: Partial<Input>): Promise<MutatorResponse<Output>> {
|
||||
const entity = this.entity;
|
||||
if (!Number.isInteger(id)) {
|
||||
throw new Error("ID must be provided for update");
|
||||
@@ -166,12 +172,16 @@ export class Mutator<DB> implements EmitsEvents {
|
||||
const validatedData = await this.getValidatedData(data, "update");
|
||||
|
||||
await this.emgr.emit(
|
||||
new Mutator.Events.MutatorUpdateBefore({ entity, entityId: id, data: validatedData })
|
||||
new Mutator.Events.MutatorUpdateBefore({
|
||||
entity,
|
||||
entityId: id,
|
||||
data: validatedData as any
|
||||
})
|
||||
);
|
||||
|
||||
const query = this.conn
|
||||
.updateTable(entity.name)
|
||||
.set(validatedData)
|
||||
.set(validatedData as any)
|
||||
.where(entity.id().name, "=", id)
|
||||
.returning(entity.getSelect());
|
||||
|
||||
@@ -181,10 +191,10 @@ export class Mutator<DB> implements EmitsEvents {
|
||||
new Mutator.Events.MutatorUpdateAfter({ entity, entityId: id, data: res.data })
|
||||
);
|
||||
|
||||
return res;
|
||||
return res as any;
|
||||
}
|
||||
|
||||
async deleteOne(id: PrimaryFieldType): Promise<MutatorResponse<EntityData>> {
|
||||
async deleteOne(id: PrimaryFieldType): Promise<MutatorResponse<Output>> {
|
||||
const entity = this.entity;
|
||||
if (!Number.isInteger(id)) {
|
||||
throw new Error("ID must be provided for deletion");
|
||||
@@ -203,7 +213,7 @@ export class Mutator<DB> implements EmitsEvents {
|
||||
new Mutator.Events.MutatorDeleteAfter({ entity, entityId: id, data: res.data })
|
||||
);
|
||||
|
||||
return res;
|
||||
return res as any;
|
||||
}
|
||||
|
||||
private getValidOptions(options?: Partial<RepoQuery>): Partial<RepoQuery> {
|
||||
@@ -250,47 +260,62 @@ export class Mutator<DB> implements EmitsEvents {
|
||||
}
|
||||
|
||||
// @todo: decide whether entries should be deleted all at once or one by one (for events)
|
||||
async deleteWhere(where?: RepoQuery["where"]): Promise<MutatorResponse<EntityData>> {
|
||||
async deleteWhere(where?: RepoQuery["where"]): Promise<MutatorResponse<Output[]>> {
|
||||
const entity = this.entity;
|
||||
|
||||
const qb = this.appendWhere(this.conn.deleteFrom(entity.name), where).returning(
|
||||
entity.getSelect()
|
||||
);
|
||||
|
||||
//await this.emgr.emit(new Mutator.Events.MutatorDeleteBefore({ entity, entityId: id }));
|
||||
|
||||
const res = await this.many(qb);
|
||||
|
||||
/*await this.emgr.emit(
|
||||
new Mutator.Events.MutatorDeleteAfter({ entity, entityId: id, data: res.data })
|
||||
);*/
|
||||
|
||||
return res;
|
||||
return (await this.many(qb)) as any;
|
||||
}
|
||||
|
||||
async updateWhere(
|
||||
data: EntityData,
|
||||
data: Partial<Input>,
|
||||
where?: RepoQuery["where"]
|
||||
): Promise<MutatorResponse<EntityData>> {
|
||||
): Promise<MutatorResponse<Output[]>> {
|
||||
const entity = this.entity;
|
||||
|
||||
const validatedData = await this.getValidatedData(data, "update");
|
||||
|
||||
/*await this.emgr.emit(
|
||||
new Mutator.Events.MutatorUpdateBefore({ entity, entityId: id, data: validatedData })
|
||||
);*/
|
||||
|
||||
const query = this.appendWhere(this.conn.updateTable(entity.name), where)
|
||||
.set(validatedData)
|
||||
//.where(entity.id().name, "=", id)
|
||||
.set(validatedData as any)
|
||||
.returning(entity.getSelect());
|
||||
|
||||
const res = await this.many(query);
|
||||
return (await this.many(query)) as any;
|
||||
}
|
||||
|
||||
/*await this.emgr.emit(
|
||||
new Mutator.Events.MutatorUpdateAfter({ entity, entityId: id, data: res.data })
|
||||
);*/
|
||||
async insertMany(data: Input[]): Promise<MutatorResponse<Output[]>> {
|
||||
const entity = this.entity;
|
||||
if (entity.type === "system" && this.__unstable_disable_system_entity_creation) {
|
||||
throw new Error(`Creation of system entity "${entity.name}" is disabled`);
|
||||
}
|
||||
|
||||
return res;
|
||||
const validated: any[] = [];
|
||||
for (const row of data) {
|
||||
const validatedData = {
|
||||
...entity.getDefaultObject(),
|
||||
...(await this.getValidatedData(row, "create"))
|
||||
};
|
||||
|
||||
// check if required fields are present
|
||||
const required = entity.getRequiredFields();
|
||||
for (const field of required) {
|
||||
if (
|
||||
typeof validatedData[field.name] === "undefined" ||
|
||||
validatedData[field.name] === null
|
||||
) {
|
||||
throw new Error(`Field "${field.name}" is required`);
|
||||
}
|
||||
}
|
||||
|
||||
validated.push(validatedData);
|
||||
}
|
||||
|
||||
const query = this.conn
|
||||
.insertInto(entity.name)
|
||||
.values(validated)
|
||||
.returning(entity.getSelect());
|
||||
|
||||
return (await this.many(query)) as any;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PrimaryFieldType } from "core";
|
||||
import type { DB as DefaultDB, PrimaryFieldType } from "core";
|
||||
import { type EmitsEvents, EventManager } from "core/events";
|
||||
import { type SelectQueryBuilder, sql } from "kysely";
|
||||
import { cloneDeep } from "lodash-es";
|
||||
@@ -43,13 +43,15 @@ export type RepositoryExistsResponse = RepositoryRawResponse & {
|
||||
exists: boolean;
|
||||
};
|
||||
|
||||
export class Repository<DB = any, TB extends keyof DB = any> implements EmitsEvents {
|
||||
em: EntityManager<DB>;
|
||||
export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = any>
|
||||
implements EmitsEvents
|
||||
{
|
||||
em: EntityManager<TBD>;
|
||||
entity: Entity;
|
||||
static readonly Events = RepositoryEvents;
|
||||
emgr: EventManager<typeof Repository.Events>;
|
||||
|
||||
constructor(em: EntityManager<DB>, entity: Entity, emgr?: EventManager<any>) {
|
||||
constructor(em: EntityManager<TBD>, entity: Entity, emgr?: EventManager<any>) {
|
||||
this.em = em;
|
||||
this.entity = entity;
|
||||
this.emgr = emgr ?? new EventManager(MutatorEvents);
|
||||
@@ -272,7 +274,7 @@ export class Repository<DB = any, TB extends keyof DB = any> implements EmitsEve
|
||||
async findId(
|
||||
id: PrimaryFieldType,
|
||||
_options?: Partial<Omit<RepoQuery, "where" | "limit" | "offset">>
|
||||
): Promise<RepositoryResponse<DB[TB]>> {
|
||||
): Promise<RepositoryResponse<TBD[TB] | undefined>> {
|
||||
const { qb, options } = this.buildQuery(
|
||||
{
|
||||
..._options,
|
||||
@@ -288,7 +290,7 @@ export class Repository<DB = any, TB extends keyof DB = any> implements EmitsEve
|
||||
async findOne(
|
||||
where: RepoQuery["where"],
|
||||
_options?: Partial<Omit<RepoQuery, "where" | "limit" | "offset">>
|
||||
): Promise<RepositoryResponse<DB[TB] | undefined>> {
|
||||
): Promise<RepositoryResponse<TBD[TB] | undefined>> {
|
||||
const { qb, options } = this.buildQuery({
|
||||
..._options,
|
||||
where,
|
||||
@@ -298,7 +300,7 @@ export class Repository<DB = any, TB extends keyof DB = any> implements EmitsEve
|
||||
return this.single(qb, options) as any;
|
||||
}
|
||||
|
||||
async findMany(_options?: Partial<RepoQuery>): Promise<RepositoryResponse<DB[TB][]>> {
|
||||
async findMany(_options?: Partial<RepoQuery>): Promise<RepositoryResponse<TBD[TB][]>> {
|
||||
const { qb, options } = this.buildQuery(_options);
|
||||
//console.log("findMany:options", options);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ export { LibsqlConnection, type LibSqlCredentials } from "./connection/LibsqlCon
|
||||
export { SqliteConnection } from "./connection/SqliteConnection";
|
||||
export { SqliteLocalConnection } from "./connection/SqliteLocalConnection";
|
||||
|
||||
export { constructEntity, constructRelation } from "./schema/constructor";
|
||||
|
||||
export const DatabaseEvents = {
|
||||
...MutatorEvents,
|
||||
...RepositoryEvents
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import { DummyConnection } from "data/connection/DummyConnection";
|
||||
import { EntityManager } from "data/entities/EntityManager";
|
||||
import type { Generated } from "kysely";
|
||||
import { MediaField, type MediaFieldConfig, type MediaItem } from "media/MediaField";
|
||||
import type { ModuleConfigs } from "modules";
|
||||
import {
|
||||
BooleanField,
|
||||
type BooleanFieldConfig,
|
||||
@@ -5,6 +10,8 @@ import {
|
||||
type DateFieldConfig,
|
||||
Entity,
|
||||
type EntityConfig,
|
||||
EntityIndex,
|
||||
type EntityRelation,
|
||||
EnumField,
|
||||
type EnumFieldConfig,
|
||||
type Field,
|
||||
@@ -25,15 +32,14 @@ import {
|
||||
type TEntityType,
|
||||
TextField,
|
||||
type TextFieldConfig
|
||||
} from "data";
|
||||
import type { Generated } from "kysely";
|
||||
import { MediaField, type MediaFieldConfig, type MediaItem } from "media/MediaField";
|
||||
} from "../index";
|
||||
|
||||
type Options<Config = any> = {
|
||||
entity: { name: string; fields: Record<string, Field<any, any, any>> };
|
||||
field_name: string;
|
||||
config: Config;
|
||||
is_required: boolean;
|
||||
another?: string;
|
||||
};
|
||||
|
||||
const FieldMap = {
|
||||
@@ -239,7 +245,89 @@ export function relation<Local extends Entity>(local: Local) {
|
||||
};
|
||||
}
|
||||
|
||||
type InferEntityFields<T> = T extends Entity<infer _N, infer Fields>
|
||||
export function index<E extends Entity>(entity: E) {
|
||||
return {
|
||||
on: (fields: (keyof InsertSchema<E>)[], unique?: boolean) => {
|
||||
const _fields = fields.map((f) => {
|
||||
const field = entity.field(f as any);
|
||||
if (!field) {
|
||||
throw new Error(`Field "${String(f)}" not found on entity "${entity.name}"`);
|
||||
}
|
||||
return field;
|
||||
});
|
||||
return new EntityIndex(entity, _fields, unique);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class EntityManagerPrototype<Entities extends Record<string, Entity>> extends EntityManager<
|
||||
Schema<Entities>
|
||||
> {
|
||||
constructor(
|
||||
public __entities: Entities,
|
||||
relations: EntityRelation[] = [],
|
||||
indices: EntityIndex[] = []
|
||||
) {
|
||||
super(Object.values(__entities), new DummyConnection(), relations, indices);
|
||||
}
|
||||
}
|
||||
|
||||
type Chained<Fn extends (...args: any[]) => any, Rt = ReturnType<Fn>> = <E extends Entity>(
|
||||
e: E
|
||||
) => {
|
||||
[K in keyof Rt]: Rt[K] extends (...args: any[]) => any
|
||||
? (...args: Parameters<Rt[K]>) => Rt
|
||||
: never;
|
||||
};
|
||||
|
||||
export function em<Entities extends Record<string, Entity>>(
|
||||
entities: Entities,
|
||||
schema?: (
|
||||
fns: { relation: Chained<typeof relation>; index: Chained<typeof index> },
|
||||
entities: Entities
|
||||
) => void
|
||||
) {
|
||||
const relations: EntityRelation[] = [];
|
||||
const indices: EntityIndex[] = [];
|
||||
|
||||
const relationProxy = (e: Entity) => {
|
||||
return new Proxy(relation(e), {
|
||||
get(target, prop) {
|
||||
return (...args: any[]) => {
|
||||
relations.push(target[prop](...args));
|
||||
return relationProxy(e);
|
||||
};
|
||||
}
|
||||
}) as any;
|
||||
};
|
||||
|
||||
const indexProxy = (e: Entity) => {
|
||||
return new Proxy(index(e), {
|
||||
get(target, prop) {
|
||||
return (...args: any[]) => {
|
||||
indices.push(target[prop](...args));
|
||||
return indexProxy(e);
|
||||
};
|
||||
}
|
||||
}) as any;
|
||||
};
|
||||
|
||||
if (schema) {
|
||||
schema({ relation: relationProxy, index: indexProxy }, entities);
|
||||
}
|
||||
|
||||
const e = new EntityManagerPrototype(entities, relations, indices);
|
||||
return {
|
||||
DB: e.__entities as unknown as Schemas<Entities>,
|
||||
entities: e.__entities,
|
||||
relations,
|
||||
indices,
|
||||
toJSON: () =>
|
||||
e.toJSON() as unknown as Pick<ModuleConfigs["data"], "entities" | "relations" | "indices">
|
||||
};
|
||||
}
|
||||
|
||||
export type InferEntityFields<T> = T extends Entity<infer _N, infer Fields>
|
||||
? {
|
||||
[K in keyof Fields]: Fields[K] extends { _type: infer Type; _required: infer Required }
|
||||
? Required extends true
|
||||
@@ -284,12 +372,16 @@ type OptionalUndefined<
|
||||
}
|
||||
>;
|
||||
|
||||
type InferField<Field> = Field extends { _type: infer Type; _required: infer Required }
|
||||
export type InferField<Field> = Field extends { _type: infer Type; _required: infer Required }
|
||||
? Required extends true
|
||||
? Type
|
||||
: Type | undefined
|
||||
: never;
|
||||
|
||||
export type Schemas<T extends Record<string, Entity>> = {
|
||||
[K in keyof T]: Schema<T[K]>;
|
||||
};
|
||||
|
||||
export type InsertSchema<T> = Simplify<OptionalUndefined<InferEntityFields<T>>>;
|
||||
export type Schema<T> = { id: Generated<number> } & InsertSchema<T>;
|
||||
export type Schema<T> = Simplify<{ id: Generated<number> } & InsertSchema<T>>;
|
||||
export type FieldSchema<T> = Simplify<OptionalUndefined<InferFields<T>>>;
|
||||
|
||||
34
app/src/data/schema/constructor.ts
Normal file
34
app/src/data/schema/constructor.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,12 @@ export {
|
||||
getDefaultConfig,
|
||||
getDefaultSchema,
|
||||
type ModuleConfigs,
|
||||
type ModuleSchemas
|
||||
} from "modules/ModuleManager";
|
||||
type ModuleSchemas,
|
||||
type ModuleManagerOptions,
|
||||
type ModuleBuildContext
|
||||
} from "./modules/ModuleManager";
|
||||
|
||||
export { registries } from "modules/registries";
|
||||
|
||||
export type * from "./adapter";
|
||||
export { Api, type ApiOptions } from "./Api";
|
||||
|
||||
@@ -1,24 +1,15 @@
|
||||
import type { PrimaryFieldType } from "core";
|
||||
import { EntityIndex, type EntityManager } from "data";
|
||||
import { type FileUploadedEventData, Storage, type StorageAdapter } from "media";
|
||||
import { Module } from "modules/Module";
|
||||
import {
|
||||
type FieldSchema,
|
||||
type InferFields,
|
||||
type Schema,
|
||||
boolean,
|
||||
datetime,
|
||||
entity,
|
||||
json,
|
||||
number,
|
||||
text
|
||||
} from "../data/prototype";
|
||||
import { type FieldSchema, boolean, datetime, entity, json, number, text } from "../data/prototype";
|
||||
import { MediaController } from "./api/MediaController";
|
||||
import { ADAPTERS, buildMediaSchema, type mediaConfigSchema, registry } from "./media-schema";
|
||||
|
||||
export type MediaFieldSchema = FieldSchema<typeof AppMedia.mediaFields>;
|
||||
declare global {
|
||||
declare module "core" {
|
||||
interface DB {
|
||||
media: MediaFieldSchema;
|
||||
media: { id: PrimaryFieldType } & MediaFieldSchema;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,14 +103,14 @@ export class AppMedia extends Module<typeof mediaConfigSchema> {
|
||||
return this.em.entity(entity_name);
|
||||
}
|
||||
|
||||
get em(): EntityManager<DB> {
|
||||
get em(): EntityManager {
|
||||
return this.ctx.em;
|
||||
}
|
||||
|
||||
private setupListeners() {
|
||||
//const media = this._entity;
|
||||
const { emgr, em } = this.ctx;
|
||||
const media = this.getMediaEntity();
|
||||
const media = this.getMediaEntity().name as "media";
|
||||
|
||||
// when file is uploaded, sync with media entity
|
||||
// @todo: need a way for singleton events!
|
||||
@@ -140,10 +131,10 @@ export class AppMedia extends Module<typeof mediaConfigSchema> {
|
||||
Storage.Events.FileDeletedEvent,
|
||||
async (e) => {
|
||||
// simple file deletion sync
|
||||
const item = await em.repo(media).findOne({ path: e.params.name });
|
||||
if (item.data) {
|
||||
console.log("item.data", item.data);
|
||||
await em.mutator(media).deleteOne(item.data.id);
|
||||
const { data } = await em.repo(media).findOne({ path: e.params.name });
|
||||
if (data) {
|
||||
console.log("item.data", data);
|
||||
await em.mutator(media).deleteOne(data.id);
|
||||
}
|
||||
|
||||
console.log("App:storage:file deleted", e);
|
||||
|
||||
@@ -174,7 +174,7 @@ export class MediaController implements ClassController {
|
||||
const result = await mutator.insertOne({
|
||||
...this.media.uploadedEventDataToMediaPayload(info),
|
||||
...mediaRef
|
||||
});
|
||||
} as any);
|
||||
mutator.__unstable_toggleSystemEntityCreation(true);
|
||||
|
||||
// delete items if needed
|
||||
|
||||
@@ -17,10 +17,6 @@ import {
|
||||
import { type S3AdapterConfig, StorageS3Adapter } from "./storage/adapters/StorageS3Adapter";
|
||||
|
||||
export { StorageS3Adapter, type S3AdapterConfig, StorageCloudinaryAdapter, type CloudinaryConfig };
|
||||
/*export {
|
||||
StorageLocalAdapter,
|
||||
type LocalAdapterConfig
|
||||
} from "./storage/adapters/StorageLocalAdapter";*/
|
||||
|
||||
export * as StorageEvents from "./storage/events";
|
||||
export { type FileUploadedEventData } from "./storage/events";
|
||||
@@ -31,16 +27,12 @@ type ClassThatImplements<T> = Constructor<T> & { prototype: T };
|
||||
export const MediaAdapterRegistry = new Registry<{
|
||||
cls: ClassThatImplements<StorageAdapter>;
|
||||
schema: TObject;
|
||||
}>().set({
|
||||
s3: {
|
||||
cls: StorageS3Adapter,
|
||||
schema: StorageS3Adapter.prototype.getSchema()
|
||||
},
|
||||
cloudinary: {
|
||||
cls: StorageCloudinaryAdapter,
|
||||
schema: StorageCloudinaryAdapter.prototype.getSchema()
|
||||
}
|
||||
});
|
||||
}>((cls: ClassThatImplements<StorageAdapter>) => ({
|
||||
cls,
|
||||
schema: cls.prototype.getSchema() as TObject
|
||||
}))
|
||||
.register("s3", StorageS3Adapter)
|
||||
.register("cloudinary", StorageCloudinaryAdapter);
|
||||
|
||||
export const Adapters = {
|
||||
s3: {
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import { readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
|
||||
import { type Static, Type, parse } from "core/utils";
|
||||
import type {
|
||||
FileBody,
|
||||
FileListObject,
|
||||
FileMeta,
|
||||
FileUploadPayload,
|
||||
StorageAdapter
|
||||
} from "../../Storage";
|
||||
import { guessMimeType } from "../../mime-types";
|
||||
import type { FileBody, FileListObject, FileMeta, StorageAdapter } from "../../Storage";
|
||||
import { guess } from "../../mime-types-tiny";
|
||||
|
||||
export const localAdapterConfig = Type.Object(
|
||||
{
|
||||
path: Type.String()
|
||||
path: Type.String({ default: "./" })
|
||||
},
|
||||
{ title: "Local" }
|
||||
);
|
||||
@@ -89,7 +83,7 @@ export class StorageLocalAdapter implements StorageAdapter {
|
||||
async getObject(key: string, headers: Headers): Promise<Response> {
|
||||
try {
|
||||
const content = await readFile(`${this.config.path}/${key}`);
|
||||
const mimeType = guessMimeType(key);
|
||||
const mimeType = guess(key);
|
||||
|
||||
return new Response(content, {
|
||||
status: 200,
|
||||
@@ -111,7 +105,7 @@ export class StorageLocalAdapter implements StorageAdapter {
|
||||
async getObjectMeta(key: string): Promise<FileMeta> {
|
||||
const stats = await stat(`${this.config.path}/${key}`);
|
||||
return {
|
||||
type: guessMimeType(key) || "application/octet-stream",
|
||||
type: guess(key) || "application/octet-stream",
|
||||
size: stats.size
|
||||
};
|
||||
}
|
||||
|
||||
77
app/src/media/storage/mime-types-tiny.ts
Normal file
77
app/src/media/storage/mime-types-tiny.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import type { Hono } from "hono";
|
||||
export type ModuleBuildContext = {
|
||||
connection: Connection;
|
||||
server: Hono<any>;
|
||||
em: EntityManager<any>;
|
||||
em: EntityManager;
|
||||
emgr: EventManager<any>;
|
||||
guard: Guard;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Guard } from "auth";
|
||||
import { BkndError, DebugLogger, Exception, isDebug } from "core";
|
||||
import { BkndError, DebugLogger } from "core";
|
||||
import { EventManager } from "core/events";
|
||||
import { clone, diff } from "core/object/diff";
|
||||
import {
|
||||
@@ -35,9 +35,11 @@ import { AppFlows } from "../flows/AppFlows";
|
||||
import { AppMedia } from "../media/AppMedia";
|
||||
import type { Module, ModuleBuildContext } from "./Module";
|
||||
|
||||
export type { ModuleBuildContext };
|
||||
|
||||
export const MODULES = {
|
||||
server: AppServer,
|
||||
data: AppData<any>,
|
||||
data: AppData,
|
||||
auth: AppAuth,
|
||||
media: AppMedia,
|
||||
flows: AppFlows
|
||||
@@ -73,9 +75,14 @@ export type ModuleManagerOptions = {
|
||||
module: Module,
|
||||
config: ModuleConfigs[Module]
|
||||
) => Promise<void>;
|
||||
// triggered when no config table existed
|
||||
onFirstBoot?: () => Promise<void>;
|
||||
// base path for the hono instance
|
||||
basePath?: string;
|
||||
// doesn't perform validity checks for given/fetched config
|
||||
trustFetched?: boolean;
|
||||
// runs when initial config provided on a fresh database
|
||||
seed?: (ctx: ModuleBuildContext) => Promise<void>;
|
||||
};
|
||||
|
||||
type ConfigTable<Json = ModuleConfigs> = {
|
||||
@@ -105,9 +112,9 @@ const __bknd = entity(TABLE_NAME, {
|
||||
updated_at: datetime()
|
||||
});
|
||||
type ConfigTable2 = Schema<typeof __bknd>;
|
||||
type T_INTERNAL_EM = {
|
||||
interface T_INTERNAL_EM {
|
||||
__bknd: ConfigTable2;
|
||||
};
|
||||
}
|
||||
|
||||
// @todo: cleanup old diffs on upgrade
|
||||
// @todo: cleanup multiple backups on upgrade
|
||||
@@ -116,7 +123,7 @@ export class ModuleManager {
|
||||
// internal em for __bknd config table
|
||||
__em!: EntityManager<T_INTERNAL_EM>;
|
||||
// ctx for modules
|
||||
em!: EntityManager<any>;
|
||||
em!: EntityManager;
|
||||
server!: Hono;
|
||||
emgr!: EventManager;
|
||||
guard!: Guard;
|
||||
@@ -294,7 +301,7 @@ export class ModuleManager {
|
||||
version,
|
||||
json: configs,
|
||||
updated_at: new Date()
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
type: "config",
|
||||
version
|
||||
@@ -448,6 +455,9 @@ export class ModuleManager {
|
||||
await this.buildModules();
|
||||
await this.save();
|
||||
|
||||
// run initial setup
|
||||
await this.setupInitial();
|
||||
|
||||
this.logger.clear();
|
||||
return this;
|
||||
}
|
||||
@@ -462,6 +472,21 @@ export class ModuleManager {
|
||||
return this;
|
||||
}
|
||||
|
||||
protected async setupInitial() {
|
||||
const ctx = {
|
||||
...this.ctx(),
|
||||
// disable events for initial setup
|
||||
em: this.ctx().em.fork()
|
||||
};
|
||||
|
||||
// perform a sync
|
||||
await ctx.em.schema().sync({ force: true });
|
||||
await this.options?.seed?.(ctx);
|
||||
|
||||
// run first boot event
|
||||
await this.options?.onFirstBoot?.();
|
||||
}
|
||||
|
||||
get<K extends keyof Modules>(key: K): Modules[K] {
|
||||
if (!(key in this.modules)) {
|
||||
throw new Error(`Module "${key}" doesn't exist, cannot get`);
|
||||
|
||||
@@ -74,6 +74,21 @@ export class AppServer extends Module<typeof serverConfigSchema> {
|
||||
})
|
||||
);
|
||||
|
||||
// add an initial fallback route
|
||||
this.client.use("/", async (c, next) => {
|
||||
await next();
|
||||
// if not finalized or giving a 404
|
||||
if (!c.finalized || c.res.status === 404) {
|
||||
// double check it's root
|
||||
if (new URL(c.req.url).pathname === "/") {
|
||||
c.res = undefined;
|
||||
c.res = Response.json({
|
||||
bknd: "hello world!"
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.client.onError((err, c) => {
|
||||
//throw err;
|
||||
console.error(err);
|
||||
@@ -82,21 +97,6 @@ export class AppServer extends Module<typeof serverConfigSchema> {
|
||||
return err;
|
||||
}
|
||||
|
||||
/*if (isDebug()) {
|
||||
console.log("accept", c.req.header("Accept"));
|
||||
if (c.req.header("Accept") === "application/json") {
|
||||
const stack = err.stack;
|
||||
|
||||
if ("toJSON" in err && typeof err.toJSON === "function") {
|
||||
return c.json({ ...err.toJSON(), stack }, 500);
|
||||
}
|
||||
|
||||
return c.json({ message: String(err), stack }, 500);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}*/
|
||||
|
||||
if (err instanceof Exception) {
|
||||
console.log("---is exception", err.code);
|
||||
return c.json(err.toJSON(), err.code as any);
|
||||
@@ -107,32 +107,6 @@ export class AppServer extends Module<typeof serverConfigSchema> {
|
||||
this.setBuilt();
|
||||
}
|
||||
|
||||
/*setAdminHtml(html: string) {
|
||||
this.admin_html = html;
|
||||
const basepath = (String(this.config.admin.basepath) + "/").replace(/\/+$/, "/");
|
||||
|
||||
const allowed_prefix = basepath + "auth";
|
||||
const login_path = basepath + "auth/login";
|
||||
|
||||
this.client.get(basepath + "*", async (c, next) => {
|
||||
const path = new URL(c.req.url).pathname;
|
||||
if (!path.startsWith(allowed_prefix)) {
|
||||
console.log("guard check permissions");
|
||||
try {
|
||||
this.ctx.guard.throwUnlessGranted(SystemPermissions.admin);
|
||||
} catch (e) {
|
||||
return c.redirect(login_path);
|
||||
}
|
||||
}
|
||||
|
||||
return c.html(this.admin_html!);
|
||||
});
|
||||
}
|
||||
|
||||
getAdminHtml() {
|
||||
return this.admin_html;
|
||||
}*/
|
||||
|
||||
override toJSON(secrets?: boolean) {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ const Skeleton = ({ theme = "light" }: { theme?: string }) => {
|
||||
className="flex flex-row w-full h-16 gap-2.5 border-muted border-b justify-start bg-muted/10"
|
||||
>
|
||||
<div className="max-h-full flex hover:bg-primary/5 link p-2.5 w-[134px] outline-none">
|
||||
<Logo />
|
||||
<Logo theme={theme} />
|
||||
</div>
|
||||
<nav className="hidden md:flex flex-row gap-2.5 pl-0 p-2.5 items-center">
|
||||
{[...new Array(5)].map((item, key) => (
|
||||
|
||||
@@ -5,14 +5,14 @@ import { useApi } from "ui/client";
|
||||
|
||||
export const useApiQuery = <
|
||||
Data,
|
||||
RefineFn extends (data: ResponseObject<Data>) => any = (data: ResponseObject<Data>) => Data
|
||||
RefineFn extends (data: ResponseObject<Data>) => unknown = (data: ResponseObject<Data>) => Data
|
||||
>(
|
||||
fn: (api: Api) => FetchPromise<Data>,
|
||||
options?: SWRConfiguration & { enabled?: boolean; refine?: RefineFn }
|
||||
) => {
|
||||
const api = useApi();
|
||||
const promise = fn(api);
|
||||
const refine = options?.refine ?? ((data: ResponseObject<Data>) => data);
|
||||
const refine = options?.refine ?? ((data: any) => data);
|
||||
const fetcher = () => promise.execute().then(refine);
|
||||
const key = promise.key();
|
||||
|
||||
|
||||
@@ -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]>;
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -1,23 +1,40 @@
|
||||
import type { PrimaryFieldType } from "core";
|
||||
import { objectTransform } from "core/utils";
|
||||
import type { DB, PrimaryFieldType } from "core";
|
||||
import { encodeSearch, objectTransform } from "core/utils";
|
||||
import type { EntityData, RepoQuery } from "data";
|
||||
import type { ResponseObject } from "modules/ModuleApi";
|
||||
import useSWR, { type SWRConfiguration } from "swr";
|
||||
import { useApi } from "ui/client";
|
||||
import type { ModuleApi, ResponseObject } from "modules/ModuleApi";
|
||||
import useSWR, { type SWRConfiguration, mutate } from "swr";
|
||||
import { type Api, useApi } from "ui/client";
|
||||
|
||||
export class UseEntityApiError<Payload = any> extends Error {
|
||||
constructor(
|
||||
public payload: Payload,
|
||||
public response: Response,
|
||||
message?: string
|
||||
public response: ResponseObject<Payload>,
|
||||
fallback?: string
|
||||
) {
|
||||
let message = fallback;
|
||||
if ("error" in response) {
|
||||
message = response.error as string;
|
||||
if (fallback) {
|
||||
message = `${fallback}: ${message}`;
|
||||
}
|
||||
}
|
||||
|
||||
super(message ?? "UseEntityApiError");
|
||||
}
|
||||
}
|
||||
|
||||
function Test() {
|
||||
const { read } = useEntity("users");
|
||||
async () => {
|
||||
const data = await read();
|
||||
};
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const useEntity = <
|
||||
Entity extends string,
|
||||
Id extends PrimaryFieldType | undefined = undefined
|
||||
Entity extends keyof DB | string,
|
||||
Id extends PrimaryFieldType | undefined = undefined,
|
||||
Data = Entity extends keyof DB ? DB[Entity] : EntityData
|
||||
>(
|
||||
entity: Entity,
|
||||
id?: Id
|
||||
@@ -25,27 +42,30 @@ export const useEntity = <
|
||||
const api = useApi().data;
|
||||
|
||||
return {
|
||||
create: async (input: EntityData) => {
|
||||
create: async (input: Omit<Data, "id">) => {
|
||||
const res = await api.createOne(entity, input);
|
||||
if (!res.ok) {
|
||||
throw new UseEntityApiError(res.data, res.res, "Failed to create entity");
|
||||
throw new UseEntityApiError(res, `Failed to create entity "${entity}"`);
|
||||
}
|
||||
return res;
|
||||
},
|
||||
read: async (query: Partial<RepoQuery> = {}) => {
|
||||
const res = id ? await api.readOne(entity, id!, query) : await api.readMany(entity, query);
|
||||
if (!res.ok) {
|
||||
throw new UseEntityApiError(res.data, res.res, "Failed to read entity");
|
||||
throw new UseEntityApiError(res as any, `Failed to read entity "${entity}"`);
|
||||
}
|
||||
return res;
|
||||
// must be manually typed
|
||||
return res as unknown as Id extends undefined
|
||||
? ResponseObject<Data[]>
|
||||
: ResponseObject<Data>;
|
||||
},
|
||||
update: async (input: Partial<EntityData>, _id: PrimaryFieldType | undefined = id) => {
|
||||
update: async (input: Partial<Omit<Data, "id">>, _id: PrimaryFieldType | undefined = id) => {
|
||||
if (!_id) {
|
||||
throw new Error("id is required");
|
||||
}
|
||||
const res = await api.updateOne(entity, _id, input);
|
||||
if (!res.ok) {
|
||||
throw new UseEntityApiError(res.data, res.res, "Failed to update entity");
|
||||
throw new UseEntityApiError(res, `Failed to update entity "${entity}"`);
|
||||
}
|
||||
return res;
|
||||
},
|
||||
@@ -56,44 +76,67 @@ export const useEntity = <
|
||||
|
||||
const res = await api.deleteOne(entity, _id);
|
||||
if (!res.ok) {
|
||||
throw new UseEntityApiError(res.data, res.res, "Failed to delete entity");
|
||||
throw new UseEntityApiError(res, `Failed to delete entity "${entity}"`);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// @todo: try to get from ModuleApi directly
|
||||
export function makeKey(
|
||||
api: ModuleApi,
|
||||
entity: string,
|
||||
id?: PrimaryFieldType,
|
||||
query?: Partial<RepoQuery>
|
||||
) {
|
||||
return (
|
||||
"/" +
|
||||
[...(api.options?.basepath?.split("/") ?? []), entity, ...(id ? [id] : [])]
|
||||
.filter(Boolean)
|
||||
.join("/") +
|
||||
(query ? "?" + encodeSearch(query) : "")
|
||||
);
|
||||
}
|
||||
|
||||
export const useEntityQuery = <
|
||||
Entity extends string,
|
||||
Entity extends keyof DB | string,
|
||||
Id extends PrimaryFieldType | undefined = undefined
|
||||
>(
|
||||
entity: Entity,
|
||||
id?: Id,
|
||||
query?: Partial<RepoQuery>,
|
||||
options?: SWRConfiguration & { enabled?: boolean }
|
||||
options?: SWRConfiguration & { enabled?: boolean; revalidateOnMutate?: boolean }
|
||||
) => {
|
||||
const api = useApi().data;
|
||||
const key =
|
||||
options?.enabled !== false
|
||||
? [...(api.options?.basepath?.split("/") ?? []), entity, ...(id ? [id] : [])].filter(
|
||||
Boolean
|
||||
)
|
||||
: null;
|
||||
const { read, ...actions } = useEntity(entity, id) as any;
|
||||
const key = makeKey(api, entity, id, query);
|
||||
const { read, ...actions } = useEntity<Entity, Id>(entity, id);
|
||||
const fetcher = () => read(query);
|
||||
|
||||
type T = Awaited<ReturnType<(typeof api)[Id extends undefined ? "readMany" : "readOne"]>>;
|
||||
const swr = useSWR<T>(key, fetcher, {
|
||||
type T = Awaited<ReturnType<typeof fetcher>>;
|
||||
const swr = useSWR<T>(options?.enabled === false ? null : key, fetcher as any, {
|
||||
revalidateOnFocus: false,
|
||||
keepPreviousData: false,
|
||||
keepPreviousData: true,
|
||||
...options
|
||||
});
|
||||
|
||||
const mapped = objectTransform(actions, (action) => {
|
||||
if (action === "read") return;
|
||||
const mutateAll = async () => {
|
||||
const entityKey = makeKey(api, entity);
|
||||
return mutate((key) => typeof key === "string" && key.startsWith(entityKey), undefined, {
|
||||
revalidate: true
|
||||
});
|
||||
};
|
||||
|
||||
return async (...args) => {
|
||||
return swr.mutate(action(...args)) as any;
|
||||
const mapped = objectTransform(actions, (action) => {
|
||||
return async (...args: any) => {
|
||||
// @ts-ignore
|
||||
const res = await action(...args);
|
||||
|
||||
// mutate all keys of entity by default
|
||||
if (options?.revalidateOnMutate !== false) {
|
||||
await mutateAll();
|
||||
}
|
||||
return res;
|
||||
};
|
||||
}) as Omit<ReturnType<typeof useEntity<Entity, Id>>, "read">;
|
||||
|
||||
@@ -105,17 +148,62 @@ export const useEntityQuery = <
|
||||
};
|
||||
};
|
||||
|
||||
export async function mutateEntityCache<
|
||||
Entity extends keyof DB | string,
|
||||
Data = Entity extends keyof DB ? Omit<DB[Entity], "id"> : EntityData
|
||||
>(api: Api["data"], entity: Entity, id: PrimaryFieldType, partialData: Partial<Data>) {
|
||||
function update(prev: any, partialNext: any) {
|
||||
if (
|
||||
typeof prev !== "undefined" &&
|
||||
typeof partialNext !== "undefined" &&
|
||||
"id" in prev &&
|
||||
prev.id === id
|
||||
) {
|
||||
return { ...prev, ...partialNext };
|
||||
}
|
||||
|
||||
return prev;
|
||||
}
|
||||
|
||||
const entityKey = makeKey(api, entity);
|
||||
|
||||
return mutate(
|
||||
(key) => typeof key === "string" && key.startsWith(entityKey),
|
||||
async (data) => {
|
||||
if (typeof data === "undefined") return;
|
||||
if (Array.isArray(data)) {
|
||||
return data.map((item) => update(item, partialData));
|
||||
}
|
||||
return update(data, partialData);
|
||||
},
|
||||
{
|
||||
revalidate: false
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export const useEntityMutate = <
|
||||
Entity extends string,
|
||||
Id extends PrimaryFieldType | undefined = undefined
|
||||
Entity extends keyof DB | string,
|
||||
Id extends PrimaryFieldType | undefined = undefined,
|
||||
Data = Entity extends keyof DB ? Omit<DB[Entity], "id"> : EntityData
|
||||
>(
|
||||
entity: Entity,
|
||||
id?: Id,
|
||||
options?: SWRConfiguration
|
||||
) => {
|
||||
const { data, ...$q } = useEntityQuery(entity, id, undefined, {
|
||||
const { data, ...$q } = useEntityQuery<Entity, Id>(entity, id, undefined, {
|
||||
...options,
|
||||
enabled: false
|
||||
});
|
||||
return $q;
|
||||
|
||||
const _mutate = id
|
||||
? (data) => mutateEntityCache($q.api, entity, id, data)
|
||||
: (id, data) => mutateEntityCache($q.api, entity, id, data);
|
||||
|
||||
return {
|
||||
...$q,
|
||||
mutate: _mutate as unknown as Id extends undefined
|
||||
? (id: PrimaryFieldType, data: Partial<Data>) => Promise<void>
|
||||
: (data: Partial<Data>) => Promise<void>
|
||||
};
|
||||
};
|
||||
|
||||
@@ -7,7 +7,6 @@ export {
|
||||
} from "./ClientProvider";
|
||||
|
||||
export * from "./api/use-api";
|
||||
export * from "./api/use-data";
|
||||
export * from "./api/use-entity";
|
||||
export { useAuth } from "./schema/auth/use-auth";
|
||||
export { Api } from "../../Api";
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Type, TypeInvalidError, parse, transformObject } from "core/utils";
|
||||
import type { Entity } from "data";
|
||||
import { AppData } from "data/AppData";
|
||||
import { constructEntity } from "data";
|
||||
import {
|
||||
type TAppDataEntity,
|
||||
type TAppDataEntityFields,
|
||||
@@ -19,7 +18,7 @@ export function useBkndData() {
|
||||
|
||||
// @todo: potentially store in ref, so it doesn't get recomputed? or use memo?
|
||||
const entities = transformObject(config.data.entities ?? {}, (entity, name) => {
|
||||
return AppData.constructEntity(name, entity);
|
||||
return constructEntity(name, entity);
|
||||
});
|
||||
|
||||
const actions = {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { App } from "App";
|
||||
import type { Entity, EntityRelation } from "data";
|
||||
import { AppData } from "data/AppData";
|
||||
import { type Entity, type EntityRelation, constructEntity, constructRelation } from "data";
|
||||
import { RelationAccessor } from "data/relations/RelationAccessor";
|
||||
import { Flow, TaskMap } from "flows";
|
||||
|
||||
@@ -20,11 +19,11 @@ export class AppReduced {
|
||||
//console.log("received appjson", appJson);
|
||||
|
||||
this._entities = Object.entries(this.appJson.data.entities ?? {}).map(([name, entity]) => {
|
||||
return AppData.constructEntity(name, entity);
|
||||
return constructEntity(name, entity);
|
||||
});
|
||||
|
||||
this._relations = Object.entries(this.appJson.data.relations ?? {}).map(([, relation]) => {
|
||||
return AppData.constructRelation(relation, this.entity.bind(this));
|
||||
return constructRelation(relation, this.entity.bind(this));
|
||||
});
|
||||
|
||||
for (const [name, obj] of Object.entries(this.appJson.flows.flows ?? {})) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { ReactCodeMirrorProps } from "@uiw/react-codemirror";
|
||||
import { Suspense, lazy } from "react";
|
||||
import { default as CodeMirror, type ReactCodeMirrorProps } from "@uiw/react-codemirror";
|
||||
|
||||
import { useBknd } from "ui/client/bknd";
|
||||
const CodeMirror = lazy(() => import("@uiw/react-codemirror"));
|
||||
|
||||
export default function CodeEditor({ editable, basicSetup, ...props }: ReactCodeMirrorProps) {
|
||||
const b = useBknd();
|
||||
@@ -15,13 +14,11 @@ export default function CodeEditor({ editable, basicSetup, ...props }: ReactCode
|
||||
: basicSetup;
|
||||
|
||||
return (
|
||||
<Suspense>
|
||||
<CodeMirror
|
||||
theme={theme === "dark" ? "dark" : "light"}
|
||||
editable={editable}
|
||||
basicSetup={_basicSetup}
|
||||
{...props}
|
||||
/>
|
||||
</Suspense>
|
||||
<CodeMirror
|
||||
theme={theme === "dark" ? "dark" : "light"}
|
||||
editable={editable}
|
||||
basicSetup={_basicSetup}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import type { Schema } from "@cfworker/json-schema";
|
||||
import Form from "@rjsf/core";
|
||||
import type { RJSFSchema, UiSchema } from "@rjsf/utils";
|
||||
import { cloneDeep } from "lodash-es";
|
||||
import { forwardRef, useId, useImperativeHandle, useRef, useState } from "react";
|
||||
//import { JsonSchemaValidator } from "./JsonSchemaValidator";
|
||||
import { fields as Fields } from "./fields";
|
||||
import { templates as Templates } from "./templates";
|
||||
import { widgets as Widgets } from "./widgets";
|
||||
import "./styles.css";
|
||||
import { filterKeys } from "core/utils";
|
||||
import { cloneDeep } from "lodash-es";
|
||||
import { RJSFTypeboxValidator } from "./typebox/RJSFTypeboxValidator";
|
||||
import { widgets as Widgets } from "./widgets";
|
||||
|
||||
const validator = new RJSFTypeboxValidator();
|
||||
|
||||
|
||||
18
app/src/ui/components/form/json-schema/index.tsx
Normal file
18
app/src/ui/components/form/json-schema/index.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -42,7 +42,11 @@ const useLocationFromRouter = (router) => {
|
||||
];
|
||||
};
|
||||
|
||||
export function Link({ className, ...props }: { className?: string } & LinkProps) {
|
||||
export function Link({
|
||||
className,
|
||||
native,
|
||||
...props
|
||||
}: { className?: string; native?: boolean } & LinkProps) {
|
||||
const router = useRouter();
|
||||
const [path, navigate] = useLocationFromRouter(router);
|
||||
|
||||
@@ -55,8 +59,6 @@ export function Link({ className, ...props }: { className?: string } & LinkProps
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleClick(e) {}
|
||||
|
||||
const _href = props.href ?? props.to;
|
||||
const href = router
|
||||
.hrefs(
|
||||
@@ -72,6 +74,10 @@ export function Link({ className, ...props }: { className?: string } & LinkProps
|
||||
/*if (active) {
|
||||
console.log("link", { a, path, absPath, href, to, active, router });
|
||||
}*/
|
||||
if (native) {
|
||||
return <a className={`${active ? "active " : ""}${className}`} {...props} />;
|
||||
}
|
||||
|
||||
return (
|
||||
// @ts-expect-error className is not typed on WouterLink
|
||||
<WouterLink className={`${active ? "active " : ""}${className}`} {...props} />
|
||||
|
||||
@@ -116,7 +116,7 @@ function SidebarToggler() {
|
||||
export function Header({ hasSidebar = true }) {
|
||||
//const logoReturnPath = "";
|
||||
const { app } = useBknd();
|
||||
const logoReturnPath = app.getAdminConfig().logo_return_path ?? "/";
|
||||
const { logo_return_path = "/", color_scheme = "light" } = app.getAdminConfig();
|
||||
|
||||
return (
|
||||
<header
|
||||
@@ -124,11 +124,11 @@ export function Header({ hasSidebar = true }) {
|
||||
className="flex flex-row w-full h-16 gap-2.5 border-muted border-b justify-start bg-muted/10"
|
||||
>
|
||||
<Link
|
||||
href={logoReturnPath}
|
||||
replace
|
||||
href={logo_return_path}
|
||||
native={logo_return_path !== "/"}
|
||||
className="max-h-full flex hover:bg-primary/5 link p-2.5 w-[134px] outline-none"
|
||||
>
|
||||
<Logo />
|
||||
<Logo theme={color_scheme} />
|
||||
</Link>
|
||||
<HeaderNavigation />
|
||||
<div className="flex flex-grow" />
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
JsonSchemaForm,
|
||||
type JsonSchemaFormProps,
|
||||
type JsonSchemaFormRef
|
||||
} from "ui/components/form/json-schema/JsonSchemaForm";
|
||||
} from "ui/components/form/json-schema";
|
||||
|
||||
import type { ContextModalProps } from "@mantine/modals";
|
||||
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import type { FieldApi } from "@tanstack/react-form";
|
||||
import type { EntityData, JsonSchemaField } from "data";
|
||||
import { Suspense, lazy } from "react";
|
||||
import * as Formy from "ui/components/form/Formy";
|
||||
import { FieldLabel } from "ui/components/form/Formy";
|
||||
|
||||
const JsonSchemaForm = lazy(() =>
|
||||
import("ui/components/form/json-schema/JsonSchemaForm").then((m) => ({
|
||||
default: m.JsonSchemaForm
|
||||
}))
|
||||
);
|
||||
import { JsonSchemaForm } from "ui/components/form/json-schema";
|
||||
|
||||
export function EntityJsonSchemaFormField({
|
||||
fieldApi,
|
||||
@@ -34,23 +28,21 @@ export function EntityJsonSchemaFormField({
|
||||
return (
|
||||
<Formy.Group>
|
||||
<FieldLabel htmlFor={fieldApi.name} field={field} />
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<div
|
||||
data-disabled={disabled ? 1 : undefined}
|
||||
className="data-[disabled]:opacity-70 data-[disabled]:pointer-events-none"
|
||||
>
|
||||
<JsonSchemaForm
|
||||
schema={field.getJsonSchema()}
|
||||
onChange={handleChange}
|
||||
direction="horizontal"
|
||||
formData={formData}
|
||||
uiSchema={{
|
||||
"ui:globalOptions": { flexDirection: "row" },
|
||||
...field.getJsonUiSchema()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Suspense>
|
||||
<div
|
||||
data-disabled={disabled ? 1 : undefined}
|
||||
className="data-[disabled]:opacity-70 data-[disabled]:pointer-events-none"
|
||||
>
|
||||
<JsonSchemaForm
|
||||
schema={field.getJsonSchema()}
|
||||
onChange={handleChange}
|
||||
direction="horizontal"
|
||||
formData={formData}
|
||||
uiSchema={{
|
||||
"ui:globalOptions": { flexDirection: "row" },
|
||||
...field.getJsonUiSchema()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Formy.Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { Handle, type Node, type NodeProps, Position } from "@xyflow/react";
|
||||
import { Const, Type, transformObject } from "core/utils";
|
||||
import { type TaskRenderProps, type Trigger, TriggerMap } from "flows";
|
||||
import { Suspense, lazy } from "react";
|
||||
import { type Trigger, TriggerMap } from "flows";
|
||||
import type { IconType } from "react-icons";
|
||||
import { TbCircleLetterT } from "react-icons/tb";
|
||||
const JsonSchemaForm = lazy(() =>
|
||||
import("ui/components/form/json-schema/JsonSchemaForm").then((m) => ({
|
||||
default: m.JsonSchemaForm
|
||||
}))
|
||||
);
|
||||
import { JsonSchemaForm } from "ui/components/form/json-schema";
|
||||
|
||||
export type TaskComponentProps = NodeProps<Node<{ trigger: Trigger }>> & {
|
||||
Icon?: IconType;
|
||||
@@ -48,17 +43,15 @@ export function TriggerComponent({
|
||||
</div>
|
||||
<div className="w-full h-px bg-primary/10" />
|
||||
<div className="flex flex-col gap-2 px-3 py-2">
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<JsonSchemaForm
|
||||
className="legacy"
|
||||
schema={Type.Union(triggerSchemas)}
|
||||
onChange={console.log}
|
||||
formData={trigger}
|
||||
{...props}
|
||||
/*uiSchema={uiSchema}*/
|
||||
/*fields={{ template: TemplateField }}*/
|
||||
/>
|
||||
</Suspense>
|
||||
<JsonSchemaForm
|
||||
className="legacy"
|
||||
schema={Type.Union(triggerSchemas)}
|
||||
onChange={console.log}
|
||||
formData={trigger}
|
||||
{...props}
|
||||
/*uiSchema={uiSchema}*/
|
||||
/*fields={{ template: TemplateField }}*/
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Handle
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import type { Task } from "flows";
|
||||
import { Suspense, lazy } from "react";
|
||||
import { TemplateField } from "./TemplateField";
|
||||
|
||||
const JsonSchemaForm = lazy(() =>
|
||||
import("ui/components/form/json-schema/JsonSchemaForm").then((m) => ({
|
||||
default: m.JsonSchemaForm
|
||||
}))
|
||||
);
|
||||
import { JsonSchemaForm } from "ui/components/form/json-schema";
|
||||
|
||||
export type TaskFormProps = {
|
||||
task: Task;
|
||||
@@ -26,16 +19,14 @@ export function TaskForm({ task, onChange, ...props }: TaskFormProps) {
|
||||
//console.log("uiSchema", uiSchema);
|
||||
|
||||
return (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<JsonSchemaForm
|
||||
className="legacy"
|
||||
schema={schema}
|
||||
onChange={onChange}
|
||||
formData={params}
|
||||
{...props}
|
||||
/*uiSchema={uiSchema}*/
|
||||
/*fields={{ template: TemplateField }}*/
|
||||
/>
|
||||
</Suspense>
|
||||
<JsonSchemaForm
|
||||
className="legacy"
|
||||
schema={schema}
|
||||
onChange={onChange}
|
||||
formData={params}
|
||||
{...props}
|
||||
/*uiSchema={uiSchema}*/
|
||||
/*fields={{ template: TemplateField }}*/
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,10 +5,7 @@ import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
|
||||
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { Alert } from "ui/components/display/Alert";
|
||||
import {
|
||||
JsonSchemaForm,
|
||||
type JsonSchemaFormRef
|
||||
} from "ui/components/form/json-schema/JsonSchemaForm";
|
||||
import { JsonSchemaForm, type JsonSchemaFormRef } from "ui/components/form/json-schema";
|
||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||
import { useNavigate } from "ui/lib/routes";
|
||||
import { extractSchema } from "../settings/utils/schema";
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { cloneDeep, omit } from "lodash-es";
|
||||
import { useBknd } from "ui/client/bknd";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { JsonSchemaForm } from "ui/components/form/json-schema/JsonSchemaForm";
|
||||
import * as AppShell from "../../layouts/AppShell/AppShell";
|
||||
import { extractSchema } from "../settings/utils/schema";
|
||||
|
||||
export function AuthStrategiesList() {
|
||||
useBknd({ withSecrets: true });
|
||||
|
||||
@@ -101,7 +101,7 @@ export function DataEntityUpdate({ params }) {
|
||||
data: {
|
||||
data: data as any,
|
||||
entity: entity.toJSON(),
|
||||
schema: entity.toSchema(true),
|
||||
schema: entity.toSchema({ clean: true }),
|
||||
form: Form.state.values,
|
||||
state: Form.state
|
||||
}
|
||||
|
||||
@@ -13,10 +13,7 @@ import { useBkndData } from "ui/client/schema/data/use-bknd-data";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { Empty } from "ui/components/display/Empty";
|
||||
import {
|
||||
JsonSchemaForm,
|
||||
type JsonSchemaFormRef
|
||||
} from "ui/components/form/json-schema/JsonSchemaForm";
|
||||
import { JsonSchemaForm, type JsonSchemaFormRef } from "ui/components/form/json-schema";
|
||||
import { Dropdown } from "ui/components/overlay/Dropdown";
|
||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||
import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2";
|
||||
|
||||
@@ -22,7 +22,7 @@ import { Button } from "ui/components/buttons/Button";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { JsonViewer } from "ui/components/code/JsonViewer";
|
||||
import { MantineSwitch } from "ui/components/form/hook-form-mantine/MantineSwitch";
|
||||
import { JsonSchemaForm } from "ui/components/form/json-schema/JsonSchemaForm";
|
||||
import { JsonSchemaForm } from "ui/components/form/json-schema";
|
||||
import { type SortableItemProps, SortableList } from "ui/components/list/SortableList";
|
||||
import { Popover } from "ui/components/overlay/Popover";
|
||||
import { fieldSpecs } from "ui/modules/data/components/fields-specs";
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { Suspense, lazy } from "react";
|
||||
import { useBknd } from "ui/client/bknd";
|
||||
import { Route, Router, Switch } from "wouter";
|
||||
import AuthRoutes from "./auth";
|
||||
import { AuthLogin } from "./auth/auth.login";
|
||||
import DataRoutes from "./data";
|
||||
import FlowRoutes from "./flows";
|
||||
import MediaRoutes from "./media";
|
||||
import { Root, RootEmpty } from "./root";
|
||||
import SettingsRoutes from "./settings";
|
||||
|
||||
const DataRoutes = lazy(() => import("./data"));
|
||||
/*const DataRoutes = lazy(() => import("./data"));
|
||||
const AuthRoutes = lazy(() => import("./auth"));
|
||||
const MediaRoutes = lazy(() => import("./media"));
|
||||
const FlowRoutes = lazy(() => import("./flows"));
|
||||
const SettingsRoutes = lazy(() => import("./settings"));
|
||||
const SettingsRoutes = lazy(() => import("./settings"));*/
|
||||
|
||||
// @ts-ignore
|
||||
const TestRoutes = lazy(() => import("./test"));
|
||||
|
||||
@@ -8,10 +8,7 @@ import { Button } from "ui/components/buttons/Button";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { Alert } from "ui/components/display/Alert";
|
||||
import { Empty } from "ui/components/display/Empty";
|
||||
import {
|
||||
JsonSchemaForm,
|
||||
type JsonSchemaFormRef
|
||||
} from "ui/components/form/json-schema/JsonSchemaForm";
|
||||
import { JsonSchemaForm, type JsonSchemaFormRef } from "ui/components/form/json-schema";
|
||||
import { Dropdown } from "ui/components/overlay/Dropdown";
|
||||
import { DataTable } from "ui/components/table/DataTable";
|
||||
import { useEvent } from "ui/hooks/use-event";
|
||||
|
||||
@@ -3,16 +3,13 @@ import type { TObject } from "core/utils";
|
||||
import { omit } from "lodash-es";
|
||||
import { useRef, useState } from "react";
|
||||
import { TbCirclePlus, TbVariable } from "react-icons/tb";
|
||||
import { useBknd } from "ui/client/BkndProvider";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import * as Formy from "ui/components/form/Formy";
|
||||
import { JsonSchemaForm, type JsonSchemaFormRef } from "ui/components/form/json-schema";
|
||||
import { Dropdown } from "ui/components/overlay/Dropdown";
|
||||
import { Modal } from "ui/components/overlay/Modal";
|
||||
import { useLocation } from "wouter";
|
||||
import { useBknd } from "../../../client/BkndProvider";
|
||||
import { Button } from "../../../components/buttons/Button";
|
||||
import * as Formy from "../../../components/form/Formy";
|
||||
import {
|
||||
JsonSchemaForm,
|
||||
type JsonSchemaFormRef
|
||||
} from "../../../components/form/json-schema/JsonSchemaForm";
|
||||
import { Dropdown } from "../../../components/overlay/Dropdown";
|
||||
import { Modal } from "../../../components/overlay/Modal";
|
||||
|
||||
export type SettingsNewModalProps = {
|
||||
schema: TObject;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { parse } from "core/utils";
|
||||
import { AppFlows } from "flows/AppFlows";
|
||||
import { useState } from "react";
|
||||
import { JsonViewer } from "../../../components/code/JsonViewer";
|
||||
import { JsonSchemaForm } from "../../../components/form/json-schema/JsonSchemaForm";
|
||||
import { JsonSchemaForm } from "../../../components/form/json-schema";
|
||||
import { Scrollable } from "../../../layouts/AppShell/AppShell";
|
||||
|
||||
export default function FlowCreateSchemaTest() {
|
||||
|
||||
@@ -2,12 +2,9 @@ import Form from "@rjsf/core";
|
||||
import type { RJSFSchema, UiSchema } from "@rjsf/utils";
|
||||
import { useRef } from "react";
|
||||
import { TbPlus, TbTrash } from "react-icons/tb";
|
||||
import { Button } from "../../../../components/buttons/Button";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { JsonSchemaForm, type JsonSchemaFormRef } from "ui/components/form/json-schema";
|
||||
import * as Formy from "../../../../components/form/Formy";
|
||||
import {
|
||||
JsonSchemaForm,
|
||||
type JsonSchemaFormRef
|
||||
} from "../../../../components/form/json-schema/JsonSchemaForm";
|
||||
import * as AppShell from "../../../../layouts/AppShell/AppShell";
|
||||
|
||||
class CfJsonSchemaValidator {}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Schema } from "@cfworker/json-schema";
|
||||
import { useState } from "react";
|
||||
import { JsonSchemaForm } from "../../../components/form/json-schema/JsonSchemaForm";
|
||||
import { Scrollable } from "../../../layouts/AppShell/AppShell";
|
||||
import { JsonSchemaForm } from "ui/components/form/json-schema";
|
||||
import { Scrollable } from "ui/layouts/AppShell/AppShell";
|
||||
|
||||
const schema: Schema = {
|
||||
definitions: {
|
||||
@@ -9,52 +9,52 @@ const schema: Schema = {
|
||||
anyOf: [
|
||||
{
|
||||
title: "String",
|
||||
type: "string",
|
||||
type: "string"
|
||||
},
|
||||
{
|
||||
title: "Number",
|
||||
type: "number",
|
||||
type: "number"
|
||||
},
|
||||
{
|
||||
title: "Boolean",
|
||||
type: "boolean",
|
||||
},
|
||||
],
|
||||
type: "boolean"
|
||||
}
|
||||
]
|
||||
},
|
||||
numeric: {
|
||||
anyOf: [
|
||||
{
|
||||
title: "Number",
|
||||
type: "number",
|
||||
type: "number"
|
||||
},
|
||||
{
|
||||
title: "Datetime",
|
||||
type: "string",
|
||||
format: "date-time",
|
||||
format: "date-time"
|
||||
},
|
||||
{
|
||||
title: "Date",
|
||||
type: "string",
|
||||
format: "date",
|
||||
format: "date"
|
||||
},
|
||||
{
|
||||
title: "Time",
|
||||
type: "string",
|
||||
format: "time",
|
||||
},
|
||||
],
|
||||
format: "time"
|
||||
}
|
||||
]
|
||||
},
|
||||
boolean: {
|
||||
title: "Boolean",
|
||||
type: "boolean",
|
||||
},
|
||||
type: "boolean"
|
||||
}
|
||||
},
|
||||
type: "object",
|
||||
properties: {
|
||||
operand: {
|
||||
enum: ["$and", "$or"],
|
||||
default: "$and",
|
||||
type: "string",
|
||||
type: "string"
|
||||
},
|
||||
conditions: {
|
||||
type: "array",
|
||||
@@ -64,10 +64,10 @@ const schema: Schema = {
|
||||
operand: {
|
||||
enum: ["$and", "$or"],
|
||||
default: "$and",
|
||||
type: "string",
|
||||
type: "string"
|
||||
},
|
||||
key: {
|
||||
type: "string",
|
||||
type: "string"
|
||||
},
|
||||
operator: {
|
||||
type: "array",
|
||||
@@ -78,30 +78,30 @@ const schema: Schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
$eq: {
|
||||
$ref: "#/definitions/primitive",
|
||||
},
|
||||
$ref: "#/definitions/primitive"
|
||||
}
|
||||
},
|
||||
required: ["$eq"],
|
||||
required: ["$eq"]
|
||||
},
|
||||
{
|
||||
title: "Lower than",
|
||||
type: "object",
|
||||
properties: {
|
||||
$lt: {
|
||||
$ref: "#/definitions/numeric",
|
||||
},
|
||||
$ref: "#/definitions/numeric"
|
||||
}
|
||||
},
|
||||
required: ["$lt"],
|
||||
required: ["$lt"]
|
||||
},
|
||||
{
|
||||
title: "Greather than",
|
||||
type: "object",
|
||||
properties: {
|
||||
$gt: {
|
||||
$ref: "#/definitions/numeric",
|
||||
},
|
||||
$ref: "#/definitions/numeric"
|
||||
}
|
||||
},
|
||||
required: ["$gt"],
|
||||
required: ["$gt"]
|
||||
},
|
||||
{
|
||||
title: "Between",
|
||||
@@ -110,13 +110,13 @@ const schema: Schema = {
|
||||
$between: {
|
||||
type: "array",
|
||||
items: {
|
||||
$ref: "#/definitions/numeric",
|
||||
$ref: "#/definitions/numeric"
|
||||
},
|
||||
minItems: 2,
|
||||
maxItems: 2,
|
||||
},
|
||||
maxItems: 2
|
||||
}
|
||||
},
|
||||
required: ["$between"],
|
||||
required: ["$between"]
|
||||
},
|
||||
{
|
||||
title: "In",
|
||||
@@ -125,23 +125,23 @@ const schema: Schema = {
|
||||
$in: {
|
||||
type: "array",
|
||||
items: {
|
||||
$ref: "#/definitions/primitive",
|
||||
$ref: "#/definitions/primitive"
|
||||
},
|
||||
minItems: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
minItems: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
minItems: 1,
|
||||
},
|
||||
minItems: 1
|
||||
}
|
||||
},
|
||||
required: ["key", "operator"],
|
||||
required: ["key", "operator"]
|
||||
},
|
||||
minItems: 1,
|
||||
},
|
||||
minItems: 1
|
||||
}
|
||||
},
|
||||
required: ["operand", "conditions"],
|
||||
required: ["operand", "conditions"]
|
||||
};
|
||||
|
||||
export default function QueryJsonFormTest() {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useBknd } from "../../../client/BkndProvider";
|
||||
import { JsonSchemaForm } from "../../../components/form/json-schema/JsonSchemaForm";
|
||||
import { Scrollable } from "../../../layouts/AppShell/AppShell";
|
||||
import { useBknd } from "ui/client/BkndProvider";
|
||||
import { JsonSchemaForm } from "ui/components/form/json-schema";
|
||||
import { Scrollable } from "ui/layouts/AppShell/AppShell";
|
||||
|
||||
function useSchema() {
|
||||
const [schema, setSchema] = useState<any>();
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useApiQuery } from "ui/client";
|
||||
import { useApi, useApiQuery } from "ui/client";
|
||||
import { Scrollable } from "ui/layouts/AppShell/AppShell";
|
||||
|
||||
function Bla() {
|
||||
const api = useApi();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const one = await api.data.readOne("users", 1);
|
||||
const many = await api.data.readMany("users");
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function SWRAndAPI() {
|
||||
const [text, setText] = useState("");
|
||||
const { data, ...r } = useApiQuery((api) => api.data.readOne("comments", 1), {
|
||||
@@ -16,7 +29,7 @@ export default function SWRAndAPI() {
|
||||
|
||||
return (
|
||||
<Scrollable>
|
||||
<pre>{JSON.stringify(r.promise.keyArray({ search: false }))}</pre>
|
||||
<pre>{JSON.stringify(r.key)}</pre>
|
||||
{r.error && <div>failed to load</div>}
|
||||
{r.isLoading && <div>loading...</div>}
|
||||
{data && <pre>{JSON.stringify(data, null, 2)}</pre>}
|
||||
@@ -26,12 +39,12 @@ export default function SWRAndAPI() {
|
||||
e.preventDefault();
|
||||
if (!comment) return;
|
||||
|
||||
await r.mutate(async () => {
|
||||
/*await r.mutate(async () => {
|
||||
const res = await r.api.data.updateOne("comments", comment.id, {
|
||||
content: text
|
||||
});
|
||||
return res.data;
|
||||
});
|
||||
});*/
|
||||
|
||||
return false;
|
||||
}}
|
||||
|
||||
@@ -1,54 +1,72 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEntity, useEntityQuery } from "ui/client/api/use-entity";
|
||||
import { useEntity, useEntityMutate, useEntityQuery } from "ui/client/api/use-entity";
|
||||
import { Scrollable } from "ui/layouts/AppShell/AppShell";
|
||||
|
||||
export default function SwrAndDataApi() {
|
||||
return (
|
||||
<div>
|
||||
<Scrollable>
|
||||
asdf
|
||||
<DirectDataApi />
|
||||
<QueryDataApi />
|
||||
</div>
|
||||
<QueryMutateDataApi />
|
||||
</Scrollable>
|
||||
);
|
||||
}
|
||||
|
||||
function QueryDataApi() {
|
||||
const [text, setText] = useState("");
|
||||
const { data, update, ...r } = useEntityQuery("comments", 1, {});
|
||||
const comment = data ? data : null;
|
||||
|
||||
useEffect(() => {
|
||||
setText(comment?.content ?? "");
|
||||
}, [comment]);
|
||||
function QueryMutateDataApi() {
|
||||
const { mutate } = useEntityMutate("comments");
|
||||
const { data, ...r } = useEntityQuery("comments", undefined, {
|
||||
limit: 2
|
||||
});
|
||||
|
||||
return (
|
||||
<Scrollable>
|
||||
<div>
|
||||
bla
|
||||
<pre>{JSON.stringify(r.key)}</pre>
|
||||
{r.error && <div>failed to load</div>}
|
||||
{r.isLoading && <div>loading...</div>}
|
||||
{data && <pre>{JSON.stringify(data, null, 2)}</pre>}
|
||||
{data && (
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
if (!comment) return;
|
||||
await update({ content: text });
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<input type="text" value={text} onChange={(e) => setText(e.target.value)} />
|
||||
<button type="submit">submit</button>
|
||||
</form>
|
||||
<div>
|
||||
{data.map((comment) => (
|
||||
<input
|
||||
key={String(comment.id)}
|
||||
type="text"
|
||||
value={comment.content}
|
||||
onChange={async (e) => {
|
||||
await mutate(comment.id, { content: e.target.value });
|
||||
}}
|
||||
className="border border-black"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Scrollable>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QueryDataApi() {
|
||||
const { data, update, ...r } = useEntityQuery("comments", undefined, {
|
||||
sort: { by: "id", dir: "asc" },
|
||||
limit: 3
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<pre>{JSON.stringify(r.key)}</pre>
|
||||
{r.error && <div>failed to load</div>}
|
||||
{r.isLoading && <div>loading...</div>}
|
||||
{data && <pre>{JSON.stringify(data, null, 2)}</pre>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DirectDataApi() {
|
||||
const [data, setData] = useState<any>();
|
||||
const { create, read, update, _delete } = useEntity("comments", 1);
|
||||
const { create, read, update, _delete } = useEntity("comments");
|
||||
|
||||
useEffect(() => {
|
||||
read().then(setData);
|
||||
read().then((data) => setData(data));
|
||||
}, []);
|
||||
|
||||
return <pre>{JSON.stringify(data, null, 2)}</pre>;
|
||||
|
||||
@@ -26,14 +26,13 @@
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo",
|
||||
"outDir": "./dist/types",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"*": ["./src/*"],
|
||||
"bknd": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["./src/**/*.ts", "./src/**/*.tsx", "./env.d.ts"],
|
||||
"exclude": ["node_modules", "dist/**/*", "../examples/bun"]
|
||||
"include": ["./src/**/*.ts", "./src/**/*.tsx"],
|
||||
"exclude": ["node_modules", "dist", "dist/types", "**/*.d.ts"]
|
||||
}
|
||||
@@ -1,14 +1,10 @@
|
||||
import { serveStatic } from "@hono/node-server/serve-static";
|
||||
import { createClient } from "@libsql/client/node";
|
||||
import { App } from "./src";
|
||||
import { App, registries } from "./src";
|
||||
import { LibsqlConnection } from "./src/data";
|
||||
import { StorageLocalAdapter } from "./src/media/storage/adapters/StorageLocalAdapter";
|
||||
import { registries } from "./src/modules/registries";
|
||||
|
||||
registries.media.add("local", {
|
||||
cls: StorageLocalAdapter,
|
||||
schema: StorageLocalAdapter.prototype.getSchema()
|
||||
});
|
||||
registries.media.register("local", StorageLocalAdapter);
|
||||
|
||||
const credentials = {
|
||||
url: import.meta.env.VITE_DB_URL!,
|
||||
@@ -24,8 +20,8 @@ export default {
|
||||
async fetch(request: Request) {
|
||||
const app = App.create({ connection });
|
||||
|
||||
app.emgr.on(
|
||||
"app-built",
|
||||
app.emgr.onEvent(
|
||||
App.Events.AppBuiltEvent,
|
||||
async () => {
|
||||
app.registerAdminController({ forceDev: true });
|
||||
app.module.server.client.get("/assets/*", serveStatic({ root: "./" }));
|
||||
|
||||
@@ -45,12 +45,14 @@ export const ALL = serve({
|
||||
connection: {
|
||||
type: "libsql",
|
||||
config: {
|
||||
url: "file:data.db"
|
||||
// location of your local Astro DB
|
||||
// make sure to use a remote URL in production
|
||||
url: "file:.astro/content.db"
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
For more information about the connection object, refer to the [Setup](/setup) guide. In the
|
||||
For more information about the connection object, refer to the [Setup](/setup/introduction) guide. In the
|
||||
special case of astro, you may also use your Astro DB credentials since it's also using LibSQL
|
||||
under the hood. Refer to the [Astro DB documentation](https://docs.astro.build/en/guides/astro-db/) for more information.
|
||||
|
||||
@@ -73,7 +75,11 @@ export const prerender = false;
|
||||
<body>
|
||||
<Admin
|
||||
withProvider={{ user }}
|
||||
config={{ basepath: "/admin", color_scheme: "dark" }}
|
||||
config={{
|
||||
basepath: "/admin",
|
||||
color_scheme: "dark",
|
||||
logo_return_path: "/../"
|
||||
}}
|
||||
client:only
|
||||
/>
|
||||
</body>
|
||||
|
||||
@@ -27,7 +27,7 @@ serve({
|
||||
}
|
||||
});
|
||||
```
|
||||
For more information about the connection object, refer to the [Setup](/setup) guide.
|
||||
For more information about the connection object, refer to the [Setup](/setup/introduction) guide.
|
||||
|
||||
Run the application using Bun by executing:
|
||||
```bash
|
||||
|
||||
@@ -13,24 +13,25 @@ and then install bknd as a dependency:
|
||||
|
||||
|
||||
## Serve the API
|
||||
If you don't choose anything specific, the following code will use the `warm` mode. See the
|
||||
chapter [Using a different mode](#using-a-different-mode) for available modes.
|
||||
|
||||
``` ts
|
||||
import { serve } from "bknd/adapter/cloudflare";
|
||||
|
||||
export default serve(
|
||||
{
|
||||
app: (env: Env) => ({
|
||||
connection: {
|
||||
type: "libsql",
|
||||
config: {
|
||||
url: env.DB_URL,
|
||||
authToken: env.DB_TOKEN
|
||||
}
|
||||
export default serve({
|
||||
app: (env: Env) => ({
|
||||
connection: {
|
||||
type: "libsql",
|
||||
config: {
|
||||
url: env.DB_URL,
|
||||
authToken: env.DB_TOKEN
|
||||
}
|
||||
})
|
||||
}
|
||||
);
|
||||
}
|
||||
})
|
||||
});
|
||||
```
|
||||
For more information about the connection object, refer to the [Setup](/setup) guide.
|
||||
For more information about the connection object, refer to the [Setup](/setup/introduction) guide.
|
||||
|
||||
Now run the worker:
|
||||
```bash
|
||||
@@ -49,49 +50,145 @@ bucket = "node_modules/bknd/dist/static"
|
||||
```
|
||||
|
||||
And then modify the worker entry as follows:
|
||||
``` ts {2, 15, 17}
|
||||
``` ts {2, 14, 15}
|
||||
import { serve } from "bknd/adapter/cloudflare";
|
||||
import manifest from "__STATIC_CONTENT_MANIFEST";
|
||||
|
||||
export default serve(
|
||||
{
|
||||
app: (env: Env) => ({
|
||||
connection: {
|
||||
type: "libsql",
|
||||
config: {
|
||||
url: env.DB_URL,
|
||||
authToken: env.DB_TOKEN
|
||||
}
|
||||
export default serve({
|
||||
app: (env: Env) => ({
|
||||
connection: {
|
||||
type: "libsql",
|
||||
config: {
|
||||
url: env.DB_URL,
|
||||
authToken: env.DB_TOKEN
|
||||
}
|
||||
}),
|
||||
setAdminHtml: true
|
||||
},
|
||||
manifest
|
||||
);
|
||||
}
|
||||
}),
|
||||
manifest,
|
||||
setAdminHtml: true
|
||||
});
|
||||
```
|
||||
|
||||
## Adding custom routes
|
||||
You can also add custom routes by defining them after the app has been built, like so:
|
||||
```ts {15-17}
|
||||
```ts {14-16}
|
||||
import { serve } from "bknd/adapter/cloudflare";
|
||||
import manifest from "__STATIC_CONTENT_MANIFEST";
|
||||
|
||||
export default serve(
|
||||
{
|
||||
app: (env: Env) => ({
|
||||
connection: {
|
||||
type: "libsql",
|
||||
config: {
|
||||
url: env.DB_URL,
|
||||
authToken: env.DB_TOKEN
|
||||
}
|
||||
export default serve({
|
||||
app: (env: Env) => ({
|
||||
connection: {
|
||||
type: "libsql",
|
||||
config: {
|
||||
url: env.DB_URL,
|
||||
authToken: env.DB_TOKEN
|
||||
}
|
||||
}),
|
||||
onBuilt: async (app) => {
|
||||
app.modules.server.get("/hello", (c) => c.json({ hello: "world" }));
|
||||
},
|
||||
setAdminHtml: true
|
||||
}
|
||||
}),
|
||||
onBuilt: async (app) => {
|
||||
app.modules.server.get("/hello", (c) => c.json({ hello: "world" }));
|
||||
},
|
||||
manifest
|
||||
);
|
||||
manifest,
|
||||
setAdminHtml: true
|
||||
});
|
||||
```
|
||||
|
||||
## Using a different mode
|
||||
With the Cloudflare Workers adapter, you're being offered to 4 modes to choose from (default:
|
||||
`warm`):
|
||||
|
||||
| Mode | Description | Use Case |
|
||||
|:----------|:-------------------------------------------------------------------------------------------|:----------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `fresh` | On every request, the configuration gets refetched, app built and then served. | Ideal if you don't want to deal with eviction, KV or Durable Objects. |
|
||||
| `warm` | It tries to keep the built app in memory for as long as possible, and rebuilds if evicted. | Better response times, should be the default choice. |
|
||||
| `cache` | The configuration is fetched from KV to reduce the initial roundtrip to the database. | Generally faster response times with irregular access patterns. |
|
||||
| `durable` | The bknd app is ran inside a Durable Object and can be configured to stay alive. | Slowest boot time, but fastest responses. Can be kept alive for as long as you want, giving similar response times as server instances. |
|
||||
|
||||
### Modes: `fresh` and `warm`
|
||||
To use either `fresh` or `warm`, all you have to do is adding the desired mode to `cloudflare.
|
||||
mode`, like so:
|
||||
```ts
|
||||
import { serve } from "bknd/adapter/cloudflare";
|
||||
|
||||
export default serve({
|
||||
/* ... */,
|
||||
mode: "fresh" // mode: "fresh" | "warm" | "cache" | "durable"
|
||||
});
|
||||
```
|
||||
|
||||
### Mode: `cache`
|
||||
For the cache mode to work, you also need to specify the KV to be used. For this, use the
|
||||
`bindings` property:
|
||||
```ts
|
||||
import { serve } from "bknd/adapter/cloudflare";
|
||||
|
||||
export default serve({
|
||||
/* ... */,
|
||||
mode: "cache",
|
||||
bindings: (env: Env) => ({ kv: env.KV })
|
||||
});
|
||||
```
|
||||
|
||||
### Mode: `durable` (advanced)
|
||||
To use the `durable` mode, you have to specify the Durable Object to extract from your
|
||||
environment, and additionally export the `DurableBkndApp` class:
|
||||
```ts
|
||||
import { serve, DurableBkndApp } from "bknd/adapter/cloudflare";
|
||||
|
||||
export { DurableBkndApp };
|
||||
export default serve({
|
||||
/* ... */,
|
||||
mode: "durable",
|
||||
bindings: (env: Env) => ({ dobj: env.DOBJ }),
|
||||
keepAliveSeconds: 60 // optional
|
||||
});
|
||||
```
|
||||
|
||||
Next, you need to define the Durable Object in your `wrangler.toml` file (refer to the [Durable
|
||||
Objects](https://developers.cloudflare.com/durable-objects/) documentation):
|
||||
```toml
|
||||
[[durable_objects.bindings]]
|
||||
name = "DOBJ"
|
||||
class_name = "DurableBkndApp"
|
||||
|
||||
[[migrations]]
|
||||
tag = "v1"
|
||||
new_classes = ["DurableBkndApp"]
|
||||
```
|
||||
|
||||
Since the communication between the Worker and Durable Object is serialized, the `onBuilt`
|
||||
property won't work. To use it (e.g. to specify special routes), you need to extend from the
|
||||
`DurableBkndApp`:
|
||||
```ts
|
||||
import type { App } from "bknd";
|
||||
import { serve, DurableBkndApp } from "bknd/adapter/cloudflare";
|
||||
|
||||
export default serve({
|
||||
/* ... */,
|
||||
mode: "durable",
|
||||
bindings: (env: Env) => ({ dobj: env.DOBJ }),
|
||||
keepAliveSeconds: 60 // optional
|
||||
});
|
||||
|
||||
export class CustomDurableBkndApp extends DurableBkndApp {
|
||||
async onBuilt(app: App) {
|
||||
app.modules.server.get("/custom/endpoint", (c) => c.text("Custom"));
|
||||
}
|
||||
}
|
||||
```
|
||||
In case you've already deployed your Worker, the deploy command may complain about a new class
|
||||
being used. To fix this issue, you need to add a "rename migration":
|
||||
```toml
|
||||
[[durable_objects.bindings]]
|
||||
name = "DOBJ"
|
||||
class_name = "CustomDurableBkndApp"
|
||||
|
||||
[[migrations]]
|
||||
tag = "v1"
|
||||
new_classes = ["DurableBkndApp"]
|
||||
|
||||
[[migrations]]
|
||||
tag = "v2"
|
||||
renamed_classes = [{from = "DurableBkndApp", to = "CustomDurableBkndApp"}]
|
||||
deleted_classes = ["DurableBkndApp"]
|
||||
```
|
||||
@@ -14,7 +14,7 @@ Install bknd as a dependency:
|
||||
import { serve } from "bknd/adapter/nextjs";
|
||||
|
||||
export const config = {
|
||||
runtime: "experimental-edge",
|
||||
runtime: "experimental-edge", // or "edge", depending on your nextjs version
|
||||
unstable_allowDynamic: ["**/*.js"]
|
||||
};
|
||||
|
||||
@@ -28,12 +28,13 @@ export default serve({
|
||||
}
|
||||
});
|
||||
```
|
||||
For more information about the connection object, refer to the [Setup](/setup) guide.
|
||||
For more information about the connection object, refer to the [Setup](/setup/introduction) guide.
|
||||
|
||||
## Enabling the Admin UI
|
||||
Create a file `[[...admin]].tsx` inside the `pages/admin` folder:
|
||||
```tsx
|
||||
// pages/admin/[[...admin]].tsx
|
||||
import type { InferGetServerSidePropsType as InferProps } from "next";
|
||||
import { withApi } from "bknd/adapter/nextjs";
|
||||
import dynamic from "next/dynamic";
|
||||
import "bknd/dist/styles.css";
|
||||
@@ -50,9 +51,12 @@ export const getServerSideProps = withApi(async (context) => {
|
||||
};
|
||||
});
|
||||
|
||||
export default function AdminPage() {
|
||||
export default function AdminPage({ user }: InferProps<typeof getServerSideProps>) {
|
||||
if (typeof document === "undefined") return null;
|
||||
return <Admin withProvider config={{ basepath: "/admin" }} />;
|
||||
return <Admin
|
||||
withProvider={{ user }}
|
||||
config={{ basepath: "/admin", logo_return_path: "/../" }}
|
||||
/>;
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ const config = {
|
||||
|
||||
serve(config);
|
||||
```
|
||||
For more information about the connection object, refer to the [Setup](/setup) guide.
|
||||
For more information about the connection object, refer to the [Setup](/setup/introduction) guide.
|
||||
|
||||
Run the application using node by executing:
|
||||
```bash
|
||||
|
||||
@@ -26,7 +26,7 @@ const handler = serve({
|
||||
export const loader = handler;
|
||||
export const action = handler;
|
||||
```
|
||||
For more information about the connection object, refer to the [Setup](/setup) guide.
|
||||
For more information about the connection object, refer to the [Setup](/setup/introduction) guide.
|
||||
|
||||
Now make sure that you wrap your root layout with the `ClientProvider` so that all components
|
||||
share the same context:
|
||||
|
||||
@@ -61,7 +61,11 @@
|
||||
"navigation": [
|
||||
{
|
||||
"group": "Getting Started",
|
||||
"pages": ["introduction", "setup", "sdk", "react", "cli"]
|
||||
"pages": ["introduction", "sdk", "react", "cli"]
|
||||
},
|
||||
{
|
||||
"group": "Setup",
|
||||
"pages": ["setup/introduction", "setup/database"]
|
||||
},
|
||||
{
|
||||
"group": "Modules",
|
||||
@@ -74,39 +78,17 @@
|
||||
"modules/flows"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Configuration",
|
||||
"pages": [
|
||||
"config/overview",
|
||||
"config/migration",
|
||||
{
|
||||
"group": "Modules",
|
||||
"pages": [
|
||||
"config/modules/overview",
|
||||
"config/modules/server",
|
||||
"config/modules/data",
|
||||
"config/modules/auth",
|
||||
"config/modules/flows",
|
||||
"config/modules/media"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Integration",
|
||||
"pages": [
|
||||
"integration/extending",
|
||||
"integration/hono",
|
||||
"integration/nextjs",
|
||||
"integration/remix",
|
||||
"integration/cloudflare",
|
||||
"integration/bun",
|
||||
"integration/vite",
|
||||
"integration/express",
|
||||
"integration/astro",
|
||||
"integration/node",
|
||||
"integration/deno",
|
||||
"integration/browser",
|
||||
"integration/docker"
|
||||
]
|
||||
},
|
||||
|
||||
183
docs/setup/database.mdx
Normal file
183
docs/setup/database.mdx
Normal 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
205
docs/setup/introduction.mdx
Normal 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;
|
||||
};
|
||||
```
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"_variables": {
|
||||
"lastUpdateCheck": 1732785435939
|
||||
"lastUpdateCheck": 1734966049246
|
||||
}
|
||||
}
|
||||
1
examples/astro/.gitignore
vendored
1
examples/astro/.gitignore
vendored
@@ -22,3 +22,4 @@ pnpm-debug.log*
|
||||
|
||||
# jetbrains setting folder
|
||||
.idea/
|
||||
*.db
|
||||
@@ -14,7 +14,7 @@ export const prerender = false;
|
||||
<body>
|
||||
<Admin
|
||||
withProvider={{ user }}
|
||||
config={{ basepath: "/admin", color_scheme: "dark" }}
|
||||
config={{ basepath: "/admin", color_scheme: "dark", logo_return_path: "/../" }}
|
||||
client:only
|
||||
/>
|
||||
</body>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user