diff --git a/app/src/core/config.ts b/app/src/core/config.ts index a99d549..2f2cf06 100644 --- a/app/src/core/config.ts +++ b/app/src/core/config.ts @@ -5,8 +5,13 @@ import type { Generated } from "kysely"; export type PrimaryFieldType = number | Generated; -// biome-ignore lint/suspicious/noEmptyInterface: -export interface DB {} +export interface DB { + // make sure to make unknown as "any" + [key: string]: { + id: PrimaryFieldType; + [key: string]: any; + }; +} export const config = { server: { diff --git a/app/src/core/events/Event.ts b/app/src/core/events/Event.ts index 734c7c2..8073c12 100644 --- a/app/src/core/events/Event.ts +++ b/app/src/core/events/Event.ts @@ -1,3 +1,8 @@ +export type EventClass = { + new (params: any): Event; + slug: string; +}; + export abstract class Event { _returning!: Returning; @@ -9,7 +14,9 @@ export abstract class Event { params: Params; returned: boolean = false; - validate(value: Returning): Event | void {} + validate(value: Returning): Event | void { + throw new EventReturnedWithoutValidation(this as any, value); + } protected clone = Event>( this: This, @@ -39,3 +46,13 @@ export class InvalidEventReturn extends Error { super(`Expected "${expected}", got "${given}"`); } } + +export class EventReturnedWithoutValidation extends Error { + constructor( + event: EventClass, + public data: any + ) { + // @ts-expect-error slug is static + super(`Event "${event.constructor.slug}" returned without validation`); + } +} diff --git a/app/src/core/events/EventManager.ts b/app/src/core/events/EventManager.ts index ec271a4..73764ea 100644 --- a/app/src/core/events/EventManager.ts +++ b/app/src/core/events/EventManager.ts @@ -1,4 +1,4 @@ -import { type Event, InvalidEventReturn } from "./Event"; +import { type Event, type EventClass, InvalidEventReturn } from "./Event"; import { EventListener, type ListenerHandler, type ListenerMode } from "./EventListener"; export type RegisterListenerConfig = @@ -12,10 +12,8 @@ export interface EmitsEvents { emgr: EventManager; } -export type EventClass = { - new (params: any): Event; - slug: string; -}; +// for compatibility, moved it to Event.ts +export type { EventClass }; export class EventManager< RegisteredEvents extends Record = Record diff --git a/app/src/data/entities/Entity.ts b/app/src/data/entities/Entity.ts index a87d609..4665322 100644 --- a/app/src/data/entities/Entity.ts +++ b/app/src/data/entities/Entity.ts @@ -192,10 +192,26 @@ export class Entity< this.data = data; } + // @todo: add tests isValidData(data: EntityData, context: TActionContext, explain?: boolean): boolean { + if (typeof data !== "object") { + if (explain) { + throw new Error(`Entity "${this.name}" data must be an object`); + } + } + const fields = this.getFillableFields(context, false); - //const fields = this.fields; - //console.log("data", data); + const field_names = fields.map((f) => f.name); + const given_keys = Object.keys(data); + + if (given_keys.some((key) => !field_names.includes(key))) { + if (explain) { + throw new Error( + `Entity "${this.name}" data must only contain known keys, got: "${given_keys}"` + ); + } + } + for (const field of fields) { if (!field.isValid(data[field.name], context)) { console.log("Entity.isValidData:invalid", context, field.name, data[field.name]); diff --git a/app/src/data/entities/Mutator.ts b/app/src/data/entities/Mutator.ts index d9bff38..15760bc 100644 --- a/app/src/data/entities/Mutator.ts +++ b/app/src/data/entities/Mutator.ts @@ -132,14 +132,17 @@ export class Mutator< throw new Error(`Creation of system entity "${entity.name}" is disabled`); } - // @todo: establish the original order from "data" + const result = await this.emgr.emit( + new Mutator.Events.MutatorInsertBefore({ entity, data: data as any }) + ); + + // if listener returned, take what's returned + const _data = result.returned ? result.params.data : data; const validatedData = { ...entity.getDefaultObject(), - ...(await this.getValidatedData(data, "create")) + ...(await this.getValidatedData(_data, "create")) }; - await this.emgr.emit(new Mutator.Events.MutatorInsertBefore({ entity, data: validatedData })); - // check if required fields are present const required = entity.getRequiredFields(); for (const field of required) { @@ -169,16 +172,17 @@ export class Mutator< throw new Error("ID must be provided for update"); } - const validatedData = await this.getValidatedData(data, "update"); - - await this.emgr.emit( + const result = await this.emgr.emit( new Mutator.Events.MutatorUpdateBefore({ entity, entityId: id, - data: validatedData as any + data }) ); + const _data = result.returned ? result.params.data : data; + const validatedData = await this.getValidatedData(_data, "update"); + const query = this.conn .updateTable(entity.name) .set(validatedData as any) diff --git a/app/src/data/events/index.ts b/app/src/data/events/index.ts index 01311d8..3ea038c 100644 --- a/app/src/data/events/index.ts +++ b/app/src/data/events/index.ts @@ -1,20 +1,48 @@ import type { PrimaryFieldType } from "core"; -import { Event } from "core/events"; +import { Event, InvalidEventReturn } from "core/events"; import type { Entity, EntityData } from "../entities"; import type { RepoQuery } from "../server/data-query-impl"; -export class MutatorInsertBefore extends Event<{ entity: Entity; data: EntityData }> { +export class MutatorInsertBefore extends Event<{ entity: Entity; data: EntityData }, EntityData> { static override slug = "mutator-insert-before"; + + override validate(data: EntityData) { + const { entity } = this.params; + if (!entity.isValidData(data, "create")) { + throw new InvalidEventReturn("EntityData", "invalid"); + } + + return this.clone({ + entity, + data + }); + } } export class MutatorInsertAfter extends Event<{ entity: Entity; data: EntityData }> { static override slug = "mutator-insert-after"; } -export class MutatorUpdateBefore extends Event<{ - entity: Entity; - entityId: PrimaryFieldType; - data: EntityData; -}> { +export class MutatorUpdateBefore extends Event< + { + entity: Entity; + entityId: PrimaryFieldType; + data: EntityData; + }, + EntityData +> { static override slug = "mutator-update-before"; + + override validate(data: EntityData) { + const { entity, ...rest } = this.params; + if (!entity.isValidData(data, "update")) { + throw new InvalidEventReturn("EntityData", "invalid"); + } + + return this.clone({ + ...rest, + entity, + data + }); + } } export class MutatorUpdateAfter extends Event<{ entity: Entity; diff --git a/app/src/ui/client/api/use-entity.ts b/app/src/ui/client/api/use-entity.ts index 5f4da99..fba6a45 100644 --- a/app/src/ui/client/api/use-entity.ts +++ b/app/src/ui/client/api/use-entity.ts @@ -109,7 +109,7 @@ export const useEntityQuery = < options?: SWRConfiguration & { enabled?: boolean; revalidateOnMutate?: boolean } ) => { const api = useApi().data; - const key = makeKey(api, entity, id, query); + const key = makeKey(api, entity as string, id, query); const { read, ...actions } = useEntity(entity, id); const fetcher = () => read(query); @@ -121,7 +121,7 @@ export const useEntityQuery = < }); const mutateAll = async () => { - const entityKey = makeKey(api, entity); + const entityKey = makeKey(api, entity as string); return mutate((key) => typeof key === "string" && key.startsWith(entityKey), undefined, { revalidate: true }); @@ -167,7 +167,7 @@ export async function mutateEntityCache< return prev; } - const entityKey = makeKey(api, entity); + const entityKey = makeKey(api, entity as string); return mutate( (key) => typeof key === "string" && key.startsWith(entityKey),