mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 12:37:20 +00:00
added pausing to event manager, added context aware entity schemas, fixed typings, first boot event, improved useEntityQuery mutation behavior
This commit is contained in:
@@ -12,13 +12,17 @@ import { SystemController } from "modules/server/SystemController";
|
||||
|
||||
export type AppPlugin<DB> = (app: App<DB>) => 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?:
|
||||
@@ -38,6 +42,7 @@ export class App<DB = any> {
|
||||
modules: ModuleManager;
|
||||
static readonly Events = AppEvents;
|
||||
adminController?: AdminController;
|
||||
private trigger_first_boot = false;
|
||||
|
||||
constructor(
|
||||
private connection: Connection,
|
||||
@@ -49,9 +54,20 @@ export class App<DB = any> {
|
||||
...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);
|
||||
@@ -89,6 +105,12 @@ 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) {
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ export class DataApi<DB> extends ModuleApi<DataApiOptions> {
|
||||
|
||||
createOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||
entity: E,
|
||||
input: Data
|
||||
input: Omit<Data, "id">
|
||||
) {
|
||||
return this.post<RepositoryResponse<Data>>([entity as any], input);
|
||||
}
|
||||
@@ -54,7 +54,7 @@ export class DataApi<DB> extends ModuleApi<DataApiOptions> {
|
||||
updateOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||
entity: E,
|
||||
id: PrimaryFieldType,
|
||||
input: Partial<Data>
|
||||
input: Partial<Omit<Data, "id">>
|
||||
) {
|
||||
return this.patch<RepositoryResponse<Data>>([entity as any, id], input);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -182,19 +182,25 @@ 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 = `${this.config.basepath}/schemas/${entity}`;
|
||||
|
||||
@@ -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,21 +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() {
|
||||
|
||||
@@ -25,8 +25,12 @@ export type MutatorResponse<T = EntityData[]> = {
|
||||
data: T;
|
||||
};
|
||||
|
||||
export class Mutator<DB = any, TB extends keyof DB = any, Data = Omit<DB[TB], "id">>
|
||||
implements EmitsEvents
|
||||
export class Mutator<
|
||||
DB = any,
|
||||
TB extends keyof DB = any,
|
||||
Output = DB[TB],
|
||||
Input = Omit<Output, "id">
|
||||
> implements EmitsEvents
|
||||
{
|
||||
em: EntityManager<DB>;
|
||||
entity: Entity;
|
||||
@@ -122,7 +126,7 @@ export class Mutator<DB = any, TB extends keyof DB = any, Data = Omit<DB[TB], "i
|
||||
return { ...response, data: data[0]! };
|
||||
}
|
||||
|
||||
async insertOne(data: Data): Promise<MutatorResponse<DB[TB]>> {
|
||||
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`);
|
||||
@@ -159,7 +163,7 @@ export class Mutator<DB = any, TB extends keyof DB = any, Data = Omit<DB[TB], "i
|
||||
return res as any;
|
||||
}
|
||||
|
||||
async updateOne(id: PrimaryFieldType, data: Data): Promise<MutatorResponse<DB[TB]>> {
|
||||
async updateOne(id: PrimaryFieldType, data: Input): Promise<MutatorResponse<Output>> {
|
||||
const entity = this.entity;
|
||||
if (!Number.isInteger(id)) {
|
||||
throw new Error("ID must be provided for update");
|
||||
@@ -190,7 +194,7 @@ export class Mutator<DB = any, TB extends keyof DB = any, Data = Omit<DB[TB], "i
|
||||
return res as any;
|
||||
}
|
||||
|
||||
async deleteOne(id: PrimaryFieldType): Promise<MutatorResponse<DB[TB]>> {
|
||||
async deleteOne(id: PrimaryFieldType): Promise<MutatorResponse<Output>> {
|
||||
const entity = this.entity;
|
||||
if (!Number.isInteger(id)) {
|
||||
throw new Error("ID must be provided for deletion");
|
||||
@@ -256,7 +260,7 @@ export class Mutator<DB = any, TB extends keyof DB = any, Data = Omit<DB[TB], "i
|
||||
}
|
||||
|
||||
// @todo: decide whether entries should be deleted all at once or one by one (for events)
|
||||
async deleteWhere(where?: RepoQuery["where"]): Promise<MutatorResponse<DB[TB][]>> {
|
||||
async deleteWhere(where?: RepoQuery["where"]): Promise<MutatorResponse<Output[]>> {
|
||||
const entity = this.entity;
|
||||
|
||||
const qb = this.appendWhere(this.conn.deleteFrom(entity.name), where).returning(
|
||||
@@ -266,7 +270,7 @@ export class Mutator<DB = any, TB extends keyof DB = any, Data = Omit<DB[TB], "i
|
||||
return (await this.many(qb)) as any;
|
||||
}
|
||||
|
||||
async updateWhere(data: Data, where?: RepoQuery["where"]): Promise<MutatorResponse<DB[TB][]>> {
|
||||
async updateWhere(data: Input, where?: RepoQuery["where"]): Promise<MutatorResponse<Output[]>> {
|
||||
const entity = this.entity;
|
||||
const validatedData = await this.getValidatedData(data, "update");
|
||||
|
||||
@@ -277,7 +281,7 @@ export class Mutator<DB = any, TB extends keyof DB = any, Data = Omit<DB[TB], "i
|
||||
return (await this.many(query)) as any;
|
||||
}
|
||||
|
||||
async insertMany(data: Data[]): Promise<MutatorResponse<DB[TB][]>> {
|
||||
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`);
|
||||
|
||||
@@ -75,6 +75,8 @@ 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
|
||||
@@ -480,6 +482,9 @@ export class ModuleManager {
|
||||
// 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] {
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import type { PrimaryFieldType } from "core";
|
||||
import { objectTransform } from "core/utils";
|
||||
import { encodeSearch, objectTransform } from "core/utils";
|
||||
import type { EntityData, RepoQuery } from "data";
|
||||
import type { ModuleApi, ResponseObject } from "modules/ModuleApi";
|
||||
import useSWR, { type SWRConfiguration, useSWRConfig } from "swr";
|
||||
import useSWR, { type SWRConfiguration, mutate } from "swr";
|
||||
import { 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");
|
||||
}
|
||||
}
|
||||
@@ -38,14 +45,14 @@ export const useEntity = <
|
||||
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}"`);
|
||||
}
|
||||
// must be manually typed
|
||||
return res as unknown as Id extends undefined
|
||||
@@ -58,7 +65,7 @@ export const useEntity = <
|
||||
}
|
||||
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;
|
||||
},
|
||||
@@ -69,19 +76,26 @@ 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;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export function makeKey(api: ModuleApi, entity: string, id?: PrimaryFieldType) {
|
||||
// @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("/")
|
||||
.join("/") +
|
||||
(query ? "?" + encodeSearch(query) : "")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,29 +106,36 @@ export const useEntityQuery = <
|
||||
entity: Entity,
|
||||
id?: Id,
|
||||
query?: Partial<RepoQuery>,
|
||||
options?: SWRConfiguration & { enabled?: boolean }
|
||||
options?: SWRConfiguration & { enabled?: boolean; revalidateOnMutate?: boolean }
|
||||
) => {
|
||||
const { mutate } = useSWRConfig();
|
||||
const api = useApi().data;
|
||||
const key = makeKey(api, entity, id);
|
||||
const key = makeKey(api, entity, id, query);
|
||||
const { read, ...actions } = useEntity<Entity, Id>(entity, id);
|
||||
const fetcher = () => read(query);
|
||||
|
||||
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 mutateAll = async () => {
|
||||
const entityKey = makeKey(api, entity);
|
||||
return mutate((key) => typeof key === "string" && key.startsWith(entityKey), undefined, {
|
||||
revalidate: true
|
||||
});
|
||||
};
|
||||
|
||||
const mapped = objectTransform(actions, (action) => {
|
||||
return async (...args: any) => {
|
||||
// @ts-ignore
|
||||
const res = await action(...args);
|
||||
|
||||
// mutate the key + list key
|
||||
mutate(key);
|
||||
if (id) mutate(makeKey(api, entity));
|
||||
// mutate all keys of entity by default
|
||||
if (options?.revalidateOnMutate !== false) {
|
||||
await mutateAll();
|
||||
}
|
||||
return res;
|
||||
};
|
||||
}) as Omit<ReturnType<typeof useEntity<Entity, Id>>, "read">;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -39,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;
|
||||
}}
|
||||
|
||||
@@ -13,7 +13,9 @@ export default function SwrAndDataApi() {
|
||||
|
||||
function QueryDataApi() {
|
||||
const [text, setText] = useState("");
|
||||
const { data, update, ...r } = useEntityQuery("comments", 2);
|
||||
const { data, update, ...r } = useEntityQuery("comments", 2, {
|
||||
sort: { by: "id", dir: "desc" }
|
||||
});
|
||||
const comment = data ? data : null;
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -24,8 +24,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: "./" }));
|
||||
|
||||
Reference in New Issue
Block a user