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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { type ClassController, isDebug, tbValidator as tb } from "core";
import { Type, objectCleanEmpty, objectTransform } from "core/utils";
import { StringEnum, Type, objectCleanEmpty, objectTransform } from "core/utils";
import {
DataPermissions,
type EntityData,
@@ -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}`;

View File

@@ -158,7 +158,7 @@ export class Entity<
}
get label(): string {
return snakeToPascalWithSpaces(this.config.name || this.name);
return this.config.name ?? snakeToPascalWithSpaces(this.name);
}
field(name: string): Field | undefined {
@@ -210,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() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: "./" }));