diff --git a/app/__test__/api/DataApi.spec.ts b/app/__test__/api/DataApi.spec.ts index b0e19fd2..37da69d4 100644 --- a/app/__test__/api/DataApi.spec.ts +++ b/app/__test__/api/DataApi.spec.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { afterAll, beforeAll, describe, expect, it, expectTypeOf } from "bun:test"; import { Guard } from "../../src/auth/authorize/Guard"; import { DataApi } from "../../src/data/api/DataApi"; import { DataController } from "../../src/data/api/DataController"; @@ -7,6 +7,7 @@ import * as proto from "../../src/data/prototype"; import { schemaToEm } from "../helper"; import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; import { parse } from "core/utils/schema"; +import type { Generated } from "kysely"; beforeAll(disableConsoleLog); afterAll(enableConsoleLog); @@ -219,4 +220,147 @@ describe("DataApi", () => { expect(() => api.createMany("posts", [])).toThrow(); } }); + + describe("types", async () => { + const schema = proto.em( + { + posts: proto.entity("posts", { title: proto.text(), count: proto.number() }), + comments: proto.entity("comments", { text: proto.text() }), + }, + (fn, s) => { + fn.relation(s.comments).manyToOne(s.posts); + }, + ); + const em = schemaToEm(schema); + await em.schema().sync({ force: true }); + + const data = { + posts: [ + { title: "foo", count: 0 }, + { title: "bar", count: 0 }, + { title: "baz", count: 0 }, + { title: "bla", count: 2 }, + ], + comments: [ + { text: "comment1", posts_id: 1 }, + { text: "comment2", posts_id: 1 }, + { text: "comment3", posts_id: 2 }, + ], + }; + + const ctx: any = { em, guard: new Guard() }; + const controller = new DataController(ctx, dataConfig); + const app = controller.getController(); + + type Posts = { + id: Generated; + title?: string; + count?: number; + comments?: Comments[]; + }; + type Comments = { + id: Generated; + text?: string; + posts_id?: number; + posts?: Posts; + }; + type DB = { + posts: Posts; + comments: Comments; + }; + + const api = new DataApi({ basepath: "/" }, app.request); + for (const [entity, payload] of Object.entries(data)) { + await api.createMany(entity as any, payload); + } + + it("readOne", async () => { + const result = await api.readOne("posts", 1); + const expected = { id: 1, title: "foo", count: 0 } as any; + expect(result.res).toBeInstanceOf(Response); + expect(result.data).toEqual(expected); + expect(result.body.meta.items).toEqual(1); + + expectTypeOf<(typeof result)["data"]>().toEqualTypeOf(); + + { + // not found + const result = await api.readOne("posts", 0); + expect(result.res.status).toEqual(404); + expect(result.data).toBeNull(); + expect(result.body.meta.items).toEqual(0); + expectTypeOf<(typeof result)["data"]>().toEqualTypeOf(); + } + }); + + it("readOneBy", async () => { + const result = await api.readOneBy("posts", { where: { title: "foo" } }); + const expected = { id: 1, title: "foo", count: 0 } as any; + expect(result.res.status).toEqual(200); + expect(result.data).toEqual(expected); + // @ts-expect-error body data is typed same as data... + expect(result.body.data).toEqual([expected]); // should be array + expect(result.body.meta.items).toEqual(1); + expectTypeOf<(typeof result)["data"]>().toEqualTypeOf(); + + { + // not found + const result = await api.readOneBy("posts", { where: { title: "not found" } }); + // since we're filtering, the result is okay, but empty + expect(result.res.status).toEqual(200); + expect(result.data).toBeNull(); + // @ts-expect-error body data is typed same as data... + expect(result.body.data).toEqual([]); + expect(result.body.meta.items).toEqual(0); + expectTypeOf<(typeof result)["data"]>().toEqualTypeOf(); + } + }); + + it("readMany", async () => { + const result = await api.readMany("posts", { where: { title: "foo" } }); + const expected = [{ id: 1, title: "foo", count: 0 }] as any; + expect(result.res.status).toEqual(200); + expect(result.data).toEqual(expected); + expect(result.body.data).toEqual(expected); + expect(result.body.meta.items).toEqual(1); + expectTypeOf<(typeof result)["data"]>().toEqualTypeOf(); + + { + // not found + const result = await api.readMany("posts", { where: { title: "not found" } }); + expect(result.res.status).toEqual(200); + expect(result.data).toEqual([]); + expect(result.body.meta.items).toEqual(0); + expectTypeOf<(typeof result)["data"]>().toEqualTypeOf(); + } + }); + + it("readManyByReference", async () => { + const result = await api.readManyByReference("posts", 1, "comments"); + const expected = [ + { id: 1, text: "comment1", posts_id: 1 }, + { id: 2, text: "comment2", posts_id: 1 }, + ] as any; + expect(result.res.status).toEqual(200); + expect(result.data).toEqual(expected); + expect(result.body.meta.items).toEqual(2); + expectTypeOf<(typeof result)["data"]>().toEqualTypeOf(); + + { + // empty + const result = await api.readManyByReference("posts", 3, "comments"); + expect(result.res.status).toEqual(200); + expect(result.data).toEqual([]); + expect(result.body.meta.items).toEqual(0); + expectTypeOf<(typeof result)["data"]>().toEqualTypeOf(); + } + + { + // non existing (expected, since only 1 query is performed) + const result = await api.readManyByReference("posts", 100, "comments"); + expect(result.res.status).toEqual(200); + expect(result.data).toEqual([]); + } + }); + }); }); diff --git a/app/src/core/config.ts b/app/src/core/config.ts index 581bea1d..c3ee3a1d 100644 --- a/app/src/core/config.ts +++ b/app/src/core/config.ts @@ -1,6 +1,7 @@ /** * These are package global defaults. */ +import type { EntityData } from "data/entities"; import type { Generated } from "kysely"; export type PrimaryFieldType = IdType | Generated; @@ -16,7 +17,7 @@ export interface DB { [key: string]: any; }; */ // @todo: that's not good, but required for admin options - [key: string]: any; + [key: string]: EntityData; } export const config = { diff --git a/app/src/data/api/DataApi.ts b/app/src/data/api/DataApi.ts index de888126..61b701a1 100644 --- a/app/src/data/api/DataApi.ts +++ b/app/src/data/api/DataApi.ts @@ -1,4 +1,4 @@ -import type { DB, EntityData, RepoQueryIn } from "bknd"; +import type { DB as DefaultDB, EntityData, RepoQueryIn } from "bknd"; import type { Insertable, Selectable, Updateable } from "kysely"; import { type BaseModuleApiOptions, ModuleApi, type PrimaryFieldType } from "modules"; @@ -10,7 +10,9 @@ export type DataApiOptions = BaseModuleApiOptions & { defaultQuery: Partial; }; -export class DataApi extends ModuleApi { +type EntityObject = E extends keyof DB ? DB[E] : EntityData; + +export class DataApi extends ModuleApi { protected override getDefaultOptions(): Partial { return { basepath: "/api/data", @@ -32,29 +34,26 @@ export class DataApi extends ModuleApi { id: PrimaryFieldType, query: Omit = {}, ) { - type Data = E extends keyof DB ? Selectable : EntityData; - return this.get>(["entity", entity as any, id], query); + type T = RepositoryResultJSON | null>; + return this.get(["entity", entity as any, id], query); } readOneBy( entity: E, query: Omit = {}, ) { - type Data = E extends keyof DB ? Selectable : EntityData; - type T = RepositoryResultJSON; - - // @todo: if none found, still returns meta... - + // @todo: if none found, it still returns 200, since it's readMany return this.readMany(entity, { ...query, limit: 1, offset: 0, - }).refine((data) => data[0]) as unknown as FetchPromise>; + }).refine((data) => data[0] ?? null) as unknown as FetchPromise< + ResponseObject | null>> + >; } readMany(entity: E, query: RepoQueryIn = {}) { - type Data = E extends keyof DB ? Selectable : EntityData; - type T = RepositoryResultJSON; + type T = RepositoryResultJSON[]>; const input = query ?? this.options.defaultQuery; const req = this.get(["entity", entity as any], input); @@ -72,8 +71,7 @@ export class DataApi extends ModuleApi { reference: R, query: RepoQueryIn = {}, ) { - type Data = R extends keyof DB ? Selectable : EntityData; - return this.get>( + return this.get[]>>( ["entity", entity as any, id, reference], query ?? this.options.defaultQuery, ); @@ -83,8 +81,7 @@ export class DataApi extends ModuleApi { entity: E, input: Insertable, ) { - type Data = E extends keyof DB ? Selectable : EntityData; - return this.post>(["entity", entity as any], input); + return this.post>>(["entity", entity as any], input); } createMany( @@ -94,8 +91,10 @@ export class DataApi extends ModuleApi { if (!input || !Array.isArray(input) || input.length === 0) { throw new Error("input is required"); } - type Data = E extends keyof DB ? Selectable : EntityData; - return this.post>(["entity", entity as any], input); + return this.post[]>>( + ["entity", entity as any], + input, + ); } updateOne( @@ -104,8 +103,10 @@ export class DataApi extends ModuleApi { input: Updateable, ) { if (!id) throw new Error("ID is required"); - type Data = E extends keyof DB ? Selectable : EntityData; - return this.patch>(["entity", entity as any, id], input); + return this.patch>>( + ["entity", entity as any, id], + input, + ); } updateMany( @@ -114,8 +115,7 @@ export class DataApi extends ModuleApi { update: Updateable, ) { this.requireObjectSet(where); - type Data = E extends keyof DB ? Selectable : EntityData; - return this.patch>(["entity", entity as any], { + return this.patch[]>>(["entity", entity as any], { update, where, }); @@ -123,14 +123,15 @@ export class DataApi extends ModuleApi { deleteOne(entity: E, id: PrimaryFieldType) { if (!id) throw new Error("ID is required"); - type Data = E extends keyof DB ? Selectable : EntityData; - return this.delete>(["entity", entity as any, id]); + return this.delete>>(["entity", entity as any, id]); } deleteMany(entity: E, where: RepoQueryIn["where"]) { this.requireObjectSet(where); - type Data = E extends keyof DB ? Selectable : EntityData; - return this.delete>(["entity", entity as any], where); + return this.delete[]>>( + ["entity", entity as any], + where, + ); } count(entity: E, where: RepoQueryIn["where"] = {}) { diff --git a/app/src/data/entities/Entity.ts b/app/src/data/entities/Entity.ts index fcbe0929..acb0eda0 100644 --- a/app/src/data/entities/Entity.ts +++ b/app/src/data/entities/Entity.ts @@ -1,4 +1,4 @@ -import { config } from "core/config"; +import { config, type PrimaryFieldType } from "core/config"; import { snakeToPascalWithSpaces, transformObject, $console, s, parse } from "bknd/utils"; import { type Field, @@ -25,7 +25,10 @@ export const entityConfigSchema = s export type EntityConfig = s.Static; -export type EntityData = Record; +export type EntityData = { + id: PrimaryFieldType; + [key: string]: any; +}; export type EntityJSON = ReturnType; /** diff --git a/app/src/data/entities/Result.ts b/app/src/data/entities/Result.ts index b637e1a4..978cb028 100644 --- a/app/src/data/entities/Result.ts +++ b/app/src/data/entities/Result.ts @@ -119,7 +119,7 @@ export class Result { const keys = isDebug() ? ["items", "time", "sql", "parameters"] : ["items", "time"]; const meta = pick(metaRaw, [...keys, ...this.additionalMetaKeys()] as any); return { - data: this.data, + data: this.data ? this.data : ((this.options.single ? null : []) as T), meta, }; } diff --git a/app/src/data/entities/mutation/Mutator.ts b/app/src/data/entities/mutation/Mutator.ts index 84389c3d..0bd104c2 100644 --- a/app/src/data/entities/mutation/Mutator.ts +++ b/app/src/data/entities/mutation/Mutator.ts @@ -54,7 +54,7 @@ export class Mutator< } const keys = Object.keys(data as any); - const validatedData: EntityData = {}; + const validatedData: Partial = {}; // get relational references/keys const relationMutator = new RelationMutator(entity, this.em); @@ -101,10 +101,10 @@ export class Mutator< return validatedData as Given; } - protected async performQuery( + protected async performQuery( qb: MutatorQB, - opts?: MutatorResultOptions, - ): Promise> { + opts?: O, + ): Promise> { const result = new MutatorResult(this.em, this.entity, { silent: false, ...opts, @@ -282,7 +282,7 @@ export class Mutator< entity.getSelect(), ); - return await this.performQuery(qb); + return (await this.performQuery(qb)) as any; } async updateWhere( @@ -301,7 +301,7 @@ export class Mutator< .set(validatedData as any) .returning(entity.getSelect()); - return await this.performQuery(query); + return (await this.performQuery(query)) as any; } async insertMany(data: Input[]): Promise> { @@ -336,6 +336,6 @@ export class Mutator< .values(validated) .returning(entity.getSelect()); - return await this.performQuery(query); + return (await this.performQuery(query)) as any; } } diff --git a/app/src/data/entities/query/Repository.ts b/app/src/data/entities/query/Repository.ts index 13554a6f..a5e898f5 100644 --- a/app/src/data/entities/query/Repository.ts +++ b/app/src/data/entities/query/Repository.ts @@ -180,15 +180,23 @@ export class Repository { if (options.limit === 1) { await this.emgr.emit( - new Repository.Events.RepositoryFindOneAfter({ entity, options, data }), + new Repository.Events.RepositoryFindOneAfter({ + entity, + options, + data: data as EntityData, + }), ); } else { await this.emgr.emit( - new Repository.Events.RepositoryFindManyAfter({ entity, options, data }), + new Repository.Events.RepositoryFindManyAfter({ + entity, + options, + data: data as EntityData[], + }), ); } } diff --git a/app/src/data/events/index.ts b/app/src/data/events/index.ts index beb44938..d62abd8b 100644 --- a/app/src/data/events/index.ts +++ b/app/src/data/events/index.ts @@ -4,6 +4,8 @@ import { Event, InvalidEventReturn } from "core/events"; import type { Entity, EntityData } from "../entities"; import type { RepoQuery } from "data/server/query"; +type PartialEntityData = Omit; + export class MutatorInsertBefore extends Event<{ entity: Entity; data: EntityData }, EntityData> { static override slug = "mutator-insert-before"; @@ -26,7 +28,7 @@ export class MutatorInsertBefore extends Event<{ entity: Entity; data: EntityDat export class MutatorInsertAfter extends Event<{ entity: Entity; data: EntityData; - changed: EntityData; + changed: PartialEntityData; }> { static override slug = "mutator-insert-after"; } @@ -34,7 +36,7 @@ export class MutatorUpdateBefore extends Event< { entity: Entity; entityId: PrimaryFieldType; - data: EntityData; + data: PartialEntityData; }, EntityData > { @@ -61,8 +63,8 @@ export class MutatorUpdateBefore extends Event< export class MutatorUpdateAfter extends Event<{ entity: Entity; entityId: PrimaryFieldType; - data: EntityData; - changed: EntityData; + data: PartialEntityData; + changed: PartialEntityData; }> { static override slug = "mutator-update-after"; } @@ -104,7 +106,7 @@ export class RepositoryFindManyBefore extends Event<{ entity: Entity; options: R export class RepositoryFindManyAfter extends Event<{ entity: Entity; options: RepoQuery; - data: EntityData; + data: EntityData[]; }> { static override slug = "repository-find-many-after"; } diff --git a/app/src/data/helper.ts b/app/src/data/helper.ts index 12c531bb..6b593e26 100644 --- a/app/src/data/helper.ts +++ b/app/src/data/helper.ts @@ -16,7 +16,7 @@ export function getDefaultValues(fields: Field[], data: EntityData): EntityData export function getChangeSet( action: string, formData: EntityData, - data: EntityData, + data: EntityData | object, fields: Field[], ): EntityData { return transform( diff --git a/app/src/modules/ModuleApi.ts b/app/src/modules/ModuleApi.ts index f89fb999..a781d996 100644 --- a/app/src/modules/ModuleApi.ts +++ b/app/src/modules/ModuleApi.ts @@ -167,7 +167,6 @@ export type ResponseObject( data?: Data, ): ResponseObject { let actualData: any = typeof data !== "undefined" ? data : body; - const _props = ["raw", "body", "ok", "status", "res", "data", "toJSON"]; + const _props = ["raw", "body", "ok", "res", "data", "toJSON"]; // that's okay, since you have to check res.ok anyway - if (typeof actualData !== "object") { + if (typeof actualData !== "object" || actualData === null) { actualData = {}; } @@ -190,7 +189,6 @@ export function createResponseProxy( if (prop === "body") return body; if (prop === "data") return data; if (prop === "ok") return raw.ok; - if (prop === "status") return raw.status; if (prop === "toJSON") { return () => target; } diff --git a/app/src/ui/client/api/use-entity.ts b/app/src/ui/client/api/use-entity.ts index f53798c4..e3103bc7 100644 --- a/app/src/ui/client/api/use-entity.ts +++ b/app/src/ui/client/api/use-entity.ts @@ -3,13 +3,12 @@ import type { PrimaryFieldType, EntityData, RepoQueryIn, - RepositoryResult, ResponseObject, ModuleApi, } from "bknd"; import { objectTransform, encodeSearch } from "bknd/utils"; -import type { Insertable, Selectable, Updateable } from "kysely"; -import useSWR, { type SWRConfiguration, type SWRResponse, mutate } from "swr"; +import type { Insertable, Updateable } from "kysely"; +import useSWR, { type SWRConfiguration, type SWRResponse, mutate, type KeyedMutator } from "swr"; import { type Api, useApi } from "ui/client"; export class UseEntityApiError extends Error { @@ -33,18 +32,16 @@ interface UseEntityReturn< Entity extends keyof DB | string, Id extends PrimaryFieldType | undefined, Data = Entity extends keyof DB ? DB[Entity] : EntityData, - Response = ResponseObject>>, + Response = ResponseObject, > { create: (input: Insertable) => Promise; - read: ( - query?: RepoQueryIn, - ) => Promise< - ResponseObject[] : Selectable>> - >; + read: (query?: RepoQueryIn) => Promise>; update: Id extends undefined - ? (input: Updateable, id: Id) => Promise + ? (input: Updateable, id: PrimaryFieldType) => Promise : (input: Updateable) => Promise; - _delete: Id extends undefined ? (id: Id) => Promise : () => Promise; + _delete: Id extends undefined + ? (id: PrimaryFieldType) => Promise + : () => Promise; } export const useEntity = < @@ -60,14 +57,14 @@ export const useEntity = < return { create: async (input: Insertable) => { const res = await api.createOne(entity, input as any); - if (!res.ok) { + if (!res.res.ok) { throw new UseEntityApiError(res, `Failed to create entity "${entity}"`); } return res as any; }, read: async (query?: RepoQueryIn) => { const res = id ? await api.readOne(entity, id!, query) : await api.readMany(entity, query); - if (!res.ok) { + if (!res.res.ok) { throw new UseEntityApiError(res as any, `Failed to read entity "${entity}"`); } return res as any; @@ -78,7 +75,7 @@ export const useEntity = < throw new Error("id is required"); } const res = await api.updateOne(entity, _id, input); - if (!res.ok) { + if (!res.res.ok) { throw new UseEntityApiError(res, `Failed to update entity "${entity}"`); } return res as any; @@ -90,7 +87,7 @@ export const useEntity = < } const res = await api.deleteOne(entity, _id); - if (!res.ok) { + if (!res.res.ok) { throw new UseEntityApiError(res, `Failed to delete entity "${entity}"`); } return res as any; @@ -117,12 +114,12 @@ export function makeKey( export interface UseEntityQueryReturn< Entity extends keyof DB | string, Id extends PrimaryFieldType | undefined = undefined, - Data = Entity extends keyof DB ? Selectable : EntityData, - Return = Id extends undefined ? ResponseObject : ResponseObject, + Data = DB[Entity], + Return = Id extends undefined ? Data[] : Data, > extends Omit, "mutate">, Omit>, "read"> { mutate: (id?: PrimaryFieldType) => Promise; - mutateRaw: SWRResponse["mutate"]; + mutateRaw: KeyedMutator; api: Api["data"]; key: string; } @@ -169,21 +166,23 @@ export const useEntityQuery = < }; }) as Omit>, "read">; + const data = swr.data ? swr.data.data : id ? null : []; + return { ...swr, ...mapped, + data, mutate: mutateFn, - // @ts-ignore mutateRaw: swr.mutate, api, key, - }; + } as any; }; export async function mutateEntityCache< Entity extends keyof DB | string, Data = Entity extends keyof DB ? DB[Entity] : EntityData, ->(api: Api["data"], entity: Entity, id: PrimaryFieldType, partialData: Partial>) { +>(api: Api["data"], entity: Entity, id: PrimaryFieldType, partialData: Partial) { function update(prev: any, partialNext: any) { if ( typeof prev !== "undefined" && @@ -220,8 +219,8 @@ interface UseEntityMutateReturn< Data = Entity extends keyof DB ? DB[Entity] : EntityData, > extends Omit>, "mutate"> { mutate: Id extends undefined - ? (id: PrimaryFieldType, data: Partial>) => Promise - : (data: Partial>) => Promise; + ? (id: PrimaryFieldType, data: Partial) => Promise + : (data: Partial) => Promise; } export const useEntityMutate = < @@ -239,9 +238,8 @@ export const useEntityMutate = < }); const _mutate = id - ? (data: Partial>) => mutateEntityCache($q.api, entity, id, data) - : (id: PrimaryFieldType, data: Partial>) => - mutateEntityCache($q.api, entity, id, data); + ? (data: Partial) => mutateEntityCache($q.api, entity, id, data) + : (id: PrimaryFieldType, data: Partial) => mutateEntityCache($q.api, entity, id, data); return { ...$q, diff --git a/app/src/ui/modules/data/hooks/useEntityForm.tsx b/app/src/ui/modules/data/hooks/useEntityForm.tsx index 1d9411c4..6a581363 100644 --- a/app/src/ui/modules/data/hooks/useEntityForm.tsx +++ b/app/src/ui/modules/data/hooks/useEntityForm.tsx @@ -5,7 +5,7 @@ import { getChangeSet, getDefaultValues } from "data/helper"; type EntityFormProps = { action: "create" | "update"; entity: Entity; - initialData?: EntityData | null; + initialData?: EntityData | object | null; onSubmitted?: (changeSet?: EntityData) => Promise; }; @@ -20,7 +20,7 @@ export function useEntityForm({ const fields = entity.getFillableFields(action, true); // filter defaultValues to only contain fillable fields - const defaultValues = getDefaultValues(fields, data); + const defaultValues = getDefaultValues(fields, data as any); //console.log("useEntityForm", { data, defaultValues }); const Form = useForm({ diff --git a/app/src/ui/routes/data/data.$entity.create.tsx b/app/src/ui/routes/data/data.$entity.create.tsx index 547502f9..d4cfde4f 100644 --- a/app/src/ui/routes/data/data.$entity.create.tsx +++ b/app/src/ui/routes/data/data.$entity.create.tsx @@ -120,7 +120,7 @@ export function DataEntityCreate({ params }) { entity={entity} handleSubmit={handleSubmit} fieldsDisabled={fieldsDisabled} - data={search.value} + data={search.value as any} Form={Form} action="create" className="flex flex-grow flex-col gap-3 p-3"