added pausing to event manager, added context aware entity schemas, fixed typings, first boot event, improved useEntityQuery mutation behavior

This commit is contained in:
dswbx
2024-12-20 20:11:49 +01:00
parent a7e3ce878a
commit deddf00c38
12 changed files with 148 additions and 55 deletions

View File

@@ -12,13 +12,17 @@ import { SystemController } from "modules/server/SystemController";
export type AppPlugin<DB> = (app: App<DB>) => void; 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"; static override slug = "app-config-updated";
} }
export class AppBuiltEvent extends Event<{ app: App }> { export class AppBuiltEvent extends AppEvent {
static override slug = "app-built"; static override slug = "app-built";
} }
export const AppEvents = { AppConfigUpdatedEvent, AppBuiltEvent } as const; export class AppFirstBoot extends AppEvent {
static override slug = "app-first-boot";
}
export const AppEvents = { AppConfigUpdatedEvent, AppBuiltEvent, AppFirstBoot } as const;
export type CreateAppConfig = { export type CreateAppConfig = {
connection?: connection?:
@@ -38,6 +42,7 @@ export class App<DB = any> {
modules: ModuleManager; modules: ModuleManager;
static readonly Events = AppEvents; static readonly Events = AppEvents;
adminController?: AdminController; adminController?: AdminController;
private trigger_first_boot = false;
constructor( constructor(
private connection: Connection, private connection: Connection,
@@ -49,9 +54,20 @@ export class App<DB = any> {
...moduleManagerOptions, ...moduleManagerOptions,
initial: _initialConfig, initial: _initialConfig,
onUpdated: async (key, config) => { onUpdated: async (key, config) => {
//console.log("[APP] config updated", key, config); // if the EventManager was disabled, we assume we shouldn't
// respond to events, such as "onUpdated".
if (!this.emgr.enabled) {
console.warn("[APP] config updated, but event manager is disabled, skip.");
return;
}
console.log("[APP] config updated", key);
await this.build({ sync: true, save: true }); await this.build({ sync: true, save: true });
await this.emgr.emit(new AppConfigUpdatedEvent({ app: this })); await this.emgr.emit(new AppConfigUpdatedEvent({ app: this }));
},
onFirstBoot: async () => {
console.log("[APP] first boot");
this.trigger_first_boot = true;
} }
}); });
this.modules.ctx().emgr.registerEvents(AppEvents); this.modules.ctx().emgr.registerEvents(AppEvents);
@@ -89,6 +105,12 @@ export class App<DB = any> {
if (options?.save) { if (options?.save) {
await this.modules.save(); await this.modules.save();
} }
// first boot is set from ModuleManager when there wasn't a config table
if (this.trigger_first_boot) {
this.trigger_first_boot = false;
await this.emgr.emit(new AppFirstBoot({ app: this }));
}
} }
mutateConfig<Module extends keyof Modules>(module: Module) { mutateConfig<Module extends keyof Modules>(module: Module) {

View File

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

View File

@@ -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>( createOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: E, entity: E,
input: Data input: Omit<Data, "id">
) { ) {
return this.post<RepositoryResponse<Data>>([entity as any], input); 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>( updateOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: E, entity: E,
id: PrimaryFieldType, id: PrimaryFieldType,
input: Partial<Data> input: Partial<Omit<Data, "id">>
) { ) {
return this.patch<RepositoryResponse<Data>>([entity as any, id], input); return this.patch<RepositoryResponse<Data>>([entity as any, id], input);
} }

View File

@@ -1,5 +1,5 @@
import { type ClassController, isDebug, tbValidator as tb } from "core"; import { type ClassController, isDebug, tbValidator as tb } from "core";
import { Type, objectCleanEmpty, objectTransform } from "core/utils"; import { StringEnum, Type, objectCleanEmpty, objectTransform } from "core/utils";
import { import {
DataPermissions, DataPermissions,
type EntityData, type EntityData,
@@ -182,19 +182,25 @@ export class DataController implements ClassController {
}) })
// read schema // read schema
.get( .get(
"/schemas/:entity", "/schemas/:entity/:context?",
tb("param", Type.Object({ entity: Type.String() })), tb(
"param",
Type.Object({
entity: Type.String(),
context: Type.Optional(StringEnum(["create", "update"]))
})
),
async (c) => { async (c) => {
this.guard.throwUnlessGranted(DataPermissions.entityRead); this.guard.throwUnlessGranted(DataPermissions.entityRead);
//console.log("request", c.req.raw); //console.log("request", c.req.raw);
const { entity } = c.req.param(); const { entity, context } = c.req.param();
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
console.log("not found", entity, definedEntities); console.log("not found", entity, definedEntities);
return c.notFound(); return c.notFound();
} }
const _entity = this.em.entity(entity); const _entity = this.em.entity(entity);
const schema = _entity.toSchema(); const schema = _entity.toSchema({ context } as any);
const url = new URL(c.req.url); const url = new URL(c.req.url);
const base = `${url.origin}${this.config.basepath}`; const base = `${url.origin}${this.config.basepath}`;
const $id = `${this.config.basepath}/schemas/${entity}`; const $id = `${this.config.basepath}/schemas/${entity}`;

View File

@@ -158,7 +158,7 @@ export class Entity<
} }
get label(): string { get label(): string {
return snakeToPascalWithSpaces(this.config.name || this.name); return this.config.name ?? snakeToPascalWithSpaces(this.name);
} }
field(name: string): Field | undefined { field(name: string): Field | undefined {
@@ -210,21 +210,34 @@ export class Entity<
return true; return true;
} }
toSchema(clean?: boolean): object { toSchema(options?: { clean: boolean; context?: "create" | "update" }): object {
const fields = Object.fromEntries(this.fields.map((field) => [field.name, field])); let fields: Field[];
switch (options?.context) {
case "create":
case "update":
fields = this.getFillableFields(options.context);
break;
default:
fields = this.getFields(true);
}
const _fields = Object.fromEntries(fields.map((field) => [field.name, field]));
const schema = Type.Object( const schema = Type.Object(
transformObject(fields, (field) => ({ transformObject(_fields, (field) => {
title: field.config.label, //const hidden = field.isHidden(options?.context);
$comment: field.config.description, const fillable = field.isFillable(options?.context);
$field: field.type, return {
readOnly: !field.isFillable("update") ? true : undefined, title: field.config.label,
writeOnly: !field.isFillable("create") ? true : undefined, $comment: field.config.description,
...field.toJsonSchema() $field: field.type,
})), readOnly: !fillable ? true : undefined,
...field.toJsonSchema()
};
}),
{ additionalProperties: false } { additionalProperties: false }
); );
return clean ? JSON.parse(JSON.stringify(schema)) : schema; return options?.clean ? JSON.parse(JSON.stringify(schema)) : schema;
} }
toJSON() { toJSON() {

View File

@@ -25,8 +25,12 @@ export type MutatorResponse<T = EntityData[]> = {
data: T; data: T;
}; };
export class Mutator<DB = any, TB extends keyof DB = any, Data = Omit<DB[TB], "id">> export class Mutator<
implements EmitsEvents DB = any,
TB extends keyof DB = any,
Output = DB[TB],
Input = Omit<Output, "id">
> implements EmitsEvents
{ {
em: EntityManager<DB>; em: EntityManager<DB>;
entity: Entity; 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]! }; return { ...response, data: data[0]! };
} }
async insertOne(data: Data): Promise<MutatorResponse<DB[TB]>> { async insertOne(data: Input): Promise<MutatorResponse<Output>> {
const entity = this.entity; const entity = this.entity;
if (entity.type === "system" && this.__unstable_disable_system_entity_creation) { if (entity.type === "system" && this.__unstable_disable_system_entity_creation) {
throw new Error(`Creation of system entity "${entity.name}" is disabled`); throw new Error(`Creation of system entity "${entity.name}" is disabled`);
@@ -159,7 +163,7 @@ export class Mutator<DB = any, TB extends keyof DB = any, Data = Omit<DB[TB], "i
return res as any; 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; const entity = this.entity;
if (!Number.isInteger(id)) { if (!Number.isInteger(id)) {
throw new Error("ID must be provided for update"); throw new Error("ID must be provided for update");
@@ -190,7 +194,7 @@ export class Mutator<DB = any, TB extends keyof DB = any, Data = Omit<DB[TB], "i
return res as any; return res as any;
} }
async deleteOne(id: PrimaryFieldType): Promise<MutatorResponse<DB[TB]>> { async deleteOne(id: PrimaryFieldType): Promise<MutatorResponse<Output>> {
const entity = this.entity; const entity = this.entity;
if (!Number.isInteger(id)) { if (!Number.isInteger(id)) {
throw new Error("ID must be provided for deletion"); throw new Error("ID must be provided for deletion");
@@ -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) // @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 entity = this.entity;
const qb = this.appendWhere(this.conn.deleteFrom(entity.name), where).returning( const qb = this.appendWhere(this.conn.deleteFrom(entity.name), where).returning(
@@ -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; 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 entity = this.entity;
const validatedData = await this.getValidatedData(data, "update"); 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; 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; const entity = this.entity;
if (entity.type === "system" && this.__unstable_disable_system_entity_creation) { if (entity.type === "system" && this.__unstable_disable_system_entity_creation) {
throw new Error(`Creation of system entity "${entity.name}" is disabled`); throw new Error(`Creation of system entity "${entity.name}" is disabled`);

View File

@@ -75,6 +75,8 @@ export type ModuleManagerOptions = {
module: Module, module: Module,
config: ModuleConfigs[Module] config: ModuleConfigs[Module]
) => Promise<void>; ) => Promise<void>;
// triggered when no config table existed
onFirstBoot?: () => Promise<void>;
// base path for the hono instance // base path for the hono instance
basePath?: string; basePath?: string;
// doesn't perform validity checks for given/fetched config // doesn't perform validity checks for given/fetched config
@@ -480,6 +482,9 @@ export class ModuleManager {
// perform a sync // perform a sync
await ctx.em.schema().sync({ force: true }); await ctx.em.schema().sync({ force: true });
await this.options?.seed?.(ctx); await this.options?.seed?.(ctx);
// run first boot event
await this.options?.onFirstBoot?.();
} }
get<K extends keyof Modules>(key: K): Modules[K] { get<K extends keyof Modules>(key: K): Modules[K] {

View File

@@ -1,16 +1,23 @@
import type { PrimaryFieldType } from "core"; import type { PrimaryFieldType } from "core";
import { objectTransform } from "core/utils"; import { encodeSearch, objectTransform } from "core/utils";
import type { EntityData, RepoQuery } from "data"; import type { EntityData, RepoQuery } from "data";
import type { ModuleApi, ResponseObject } from "modules/ModuleApi"; 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"; import { useApi } from "ui/client";
export class UseEntityApiError<Payload = any> extends Error { export class UseEntityApiError<Payload = any> extends Error {
constructor( constructor(
public payload: Payload, public response: ResponseObject<Payload>,
public response: Response, fallback?: string
message?: string
) { ) {
let message = fallback;
if ("error" in response) {
message = response.error as string;
if (fallback) {
message = `${fallback}: ${message}`;
}
}
super(message ?? "UseEntityApiError"); super(message ?? "UseEntityApiError");
} }
} }
@@ -38,14 +45,14 @@ export const useEntity = <
create: async (input: Omit<Data, "id">) => { create: async (input: Omit<Data, "id">) => {
const res = await api.createOne(entity, input); const res = await api.createOne(entity, input);
if (!res.ok) { if (!res.ok) {
throw new UseEntityApiError(res.data, res.res, "Failed to create entity"); throw new UseEntityApiError(res, `Failed to create entity "${entity}"`);
} }
return res; return res;
}, },
read: async (query: Partial<RepoQuery> = {}) => { read: async (query: Partial<RepoQuery> = {}) => {
const res = id ? await api.readOne(entity, id!, query) : await api.readMany(entity, query); const res = id ? await api.readOne(entity, id!, query) : await api.readMany(entity, query);
if (!res.ok) { if (!res.ok) {
throw new UseEntityApiError(res.data, res.res, "Failed to read entity"); throw new UseEntityApiError(res as any, `Failed to read entity "${entity}"`);
} }
// must be manually typed // must be manually typed
return res as unknown as Id extends undefined return res as unknown as Id extends undefined
@@ -58,7 +65,7 @@ export const useEntity = <
} }
const res = await api.updateOne(entity, _id, input); const res = await api.updateOne(entity, _id, input);
if (!res.ok) { if (!res.ok) {
throw new UseEntityApiError(res.data, res.res, "Failed to update entity"); throw new UseEntityApiError(res, `Failed to update entity "${entity}"`);
} }
return res; return res;
}, },
@@ -69,19 +76,26 @@ export const useEntity = <
const res = await api.deleteOne(entity, _id); const res = await api.deleteOne(entity, _id);
if (!res.ok) { if (!res.ok) {
throw new UseEntityApiError(res.data, res.res, "Failed to delete entity"); throw new UseEntityApiError(res, `Failed to delete entity "${entity}"`);
} }
return res; return res;
} }
}; };
}; };
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 ( return (
"/" + "/" +
[...(api.options?.basepath?.split("/") ?? []), entity, ...(id ? [id] : [])] [...(api.options?.basepath?.split("/") ?? []), entity, ...(id ? [id] : [])]
.filter(Boolean) .filter(Boolean)
.join("/") .join("/") +
(query ? "?" + encodeSearch(query) : "")
); );
} }
@@ -92,29 +106,36 @@ export const useEntityQuery = <
entity: Entity, entity: Entity,
id?: Id, id?: Id,
query?: Partial<RepoQuery>, query?: Partial<RepoQuery>,
options?: SWRConfiguration & { enabled?: boolean } options?: SWRConfiguration & { enabled?: boolean; revalidateOnMutate?: boolean }
) => { ) => {
const { mutate } = useSWRConfig();
const api = useApi().data; 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 { read, ...actions } = useEntity<Entity, Id>(entity, id);
const fetcher = () => read(query); const fetcher = () => read(query);
type T = Awaited<ReturnType<typeof fetcher>>; type T = Awaited<ReturnType<typeof fetcher>>;
const swr = useSWR<T>(options?.enabled === false ? null : key, fetcher as any, { const swr = useSWR<T>(options?.enabled === false ? null : key, fetcher as any, {
revalidateOnFocus: false, revalidateOnFocus: false,
keepPreviousData: false, keepPreviousData: true,
...options ...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) => { const mapped = objectTransform(actions, (action) => {
return async (...args: any) => { return async (...args: any) => {
// @ts-ignore // @ts-ignore
const res = await action(...args); const res = await action(...args);
// mutate the key + list key // mutate all keys of entity by default
mutate(key); if (options?.revalidateOnMutate !== false) {
if (id) mutate(makeKey(api, entity)); await mutateAll();
}
return res; return res;
}; };
}) as Omit<ReturnType<typeof useEntity<Entity, Id>>, "read">; }) as Omit<ReturnType<typeof useEntity<Entity, Id>>, "read">;

View File

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

View File

@@ -39,12 +39,12 @@ export default function SWRAndAPI() {
e.preventDefault(); e.preventDefault();
if (!comment) return; if (!comment) return;
await r.mutate(async () => { /*await r.mutate(async () => {
const res = await r.api.data.updateOne("comments", comment.id, { const res = await r.api.data.updateOne("comments", comment.id, {
content: text content: text
}); });
return res.data; return res.data;
}); });*/
return false; return false;
}} }}

View File

@@ -13,7 +13,9 @@ export default function SwrAndDataApi() {
function QueryDataApi() { function QueryDataApi() {
const [text, setText] = useState(""); 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; const comment = data ? data : null;
useEffect(() => { useEffect(() => {

View File

@@ -24,8 +24,8 @@ export default {
async fetch(request: Request) { async fetch(request: Request) {
const app = App.create({ connection }); const app = App.create({ connection });
app.emgr.on( app.emgr.onEvent(
"app-built", App.Events.AppBuiltEvent,
async () => { async () => {
app.registerAdminController({ forceDev: true }); app.registerAdminController({ forceDev: true });
app.module.server.client.get("/assets/*", serveStatic({ root: "./" })); app.module.server.client.get("/assets/*", serveStatic({ root: "./" }));