diff --git a/app/src/App.ts b/app/src/App.ts index af68f58..45c6ef7 100644 --- a/app/src/App.ts +++ b/app/src/App.ts @@ -12,13 +12,17 @@ import { SystemController } from "modules/server/SystemController"; export type AppPlugin = (app: App) => void; -export class AppConfigUpdatedEvent extends Event<{ app: App }> { +abstract class AppEvent 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 { modules: ModuleManager; static readonly Events = AppEvents; adminController?: AdminController; + private trigger_first_boot = false; constructor( private connection: Connection, @@ -49,9 +54,20 @@ export class App { ...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 { 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: Module) { diff --git a/app/src/core/events/EventManager.ts b/app/src/core/events/EventManager.ts index 3b85d23..9233666 100644 --- a/app/src/core/events/EventManager.ts +++ b/app/src/core/events/EventManager.ts @@ -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`); } diff --git a/app/src/data/api/DataApi.ts b/app/src/data/api/DataApi.ts index caf6a2a..ad7c670 100644 --- a/app/src/data/api/DataApi.ts +++ b/app/src/data/api/DataApi.ts @@ -46,7 +46,7 @@ export class DataApi extends ModuleApi { createOne( entity: E, - input: Data + input: Omit ) { return this.post>([entity as any], input); } @@ -54,7 +54,7 @@ export class DataApi extends ModuleApi { updateOne( entity: E, id: PrimaryFieldType, - input: Partial + input: Partial> ) { return this.patch>([entity as any, id], input); } diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index 86f3e42..68a5417 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -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}`; diff --git a/app/src/data/entities/Entity.ts b/app/src/data/entities/Entity.ts index 579ffb2..aa3d75c 100644 --- a/app/src/data/entities/Entity.ts +++ b/app/src/data/entities/Entity.ts @@ -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() { diff --git a/app/src/data/entities/Mutator.ts b/app/src/data/entities/Mutator.ts index 3f81c6e..51a1d12 100644 --- a/app/src/data/entities/Mutator.ts +++ b/app/src/data/entities/Mutator.ts @@ -25,8 +25,12 @@ export type MutatorResponse = { data: T; }; -export class Mutator> - implements EmitsEvents +export class Mutator< + DB = any, + TB extends keyof DB = any, + Output = DB[TB], + Input = Omit +> implements EmitsEvents { em: EntityManager; entity: Entity; @@ -122,7 +126,7 @@ export class Mutator> { + async insertOne(data: Input): Promise> { 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> { + async updateOne(id: PrimaryFieldType, data: Input): Promise> { const entity = this.entity; if (!Number.isInteger(id)) { throw new Error("ID must be provided for update"); @@ -190,7 +194,7 @@ export class Mutator> { + async deleteOne(id: PrimaryFieldType): Promise> { const entity = this.entity; if (!Number.isInteger(id)) { throw new Error("ID must be provided for deletion"); @@ -256,7 +260,7 @@ export class Mutator> { + async deleteWhere(where?: RepoQuery["where"]): Promise> { const entity = this.entity; const qb = this.appendWhere(this.conn.deleteFrom(entity.name), where).returning( @@ -266,7 +270,7 @@ export class Mutator> { + async updateWhere(data: Input, where?: RepoQuery["where"]): Promise> { const entity = this.entity; const validatedData = await this.getValidatedData(data, "update"); @@ -277,7 +281,7 @@ export class Mutator> { + async insertMany(data: Input[]): Promise> { 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`); diff --git a/app/src/modules/ModuleManager.ts b/app/src/modules/ModuleManager.ts index bed6c18..e5b6c9c 100644 --- a/app/src/modules/ModuleManager.ts +++ b/app/src/modules/ModuleManager.ts @@ -75,6 +75,8 @@ export type ModuleManagerOptions = { module: Module, config: ModuleConfigs[Module] ) => Promise; + // triggered when no config table existed + onFirstBoot?: () => Promise; // 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(key: K): Modules[K] { diff --git a/app/src/ui/client/api/use-entity.ts b/app/src/ui/client/api/use-entity.ts index 3c57bad..dd93455 100644 --- a/app/src/ui/client/api/use-entity.ts +++ b/app/src/ui/client/api/use-entity.ts @@ -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 extends Error { constructor( - public payload: Payload, - public response: Response, - message?: string + public response: ResponseObject, + 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) => { 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 = {}) => { 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 +) { 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, - 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); const fetcher = () => read(query); type T = Awaited>; const swr = useSWR(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>, "read">; diff --git a/app/src/ui/routes/data/data.$entity.$id.tsx b/app/src/ui/routes/data/data.$entity.$id.tsx index 09cd38a..a641187 100644 --- a/app/src/ui/routes/data/data.$entity.$id.tsx +++ b/app/src/ui/routes/data/data.$entity.$id.tsx @@ -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 } diff --git a/app/src/ui/routes/test/tests/swr-and-api.tsx b/app/src/ui/routes/test/tests/swr-and-api.tsx index 4d4d88f..45807eb 100644 --- a/app/src/ui/routes/test/tests/swr-and-api.tsx +++ b/app/src/ui/routes/test/tests/swr-and-api.tsx @@ -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; }} diff --git a/app/src/ui/routes/test/tests/swr-and-data-api.tsx b/app/src/ui/routes/test/tests/swr-and-data-api.tsx index ab82e24..fe8d7b1 100644 --- a/app/src/ui/routes/test/tests/swr-and-data-api.tsx +++ b/app/src/ui/routes/test/tests/swr-and-data-api.tsx @@ -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(() => { diff --git a/app/vite.dev.ts b/app/vite.dev.ts index 608e24e..1fdcdbe 100644 --- a/app/vite.dev.ts +++ b/app/vite.dev.ts @@ -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: "./" }));