diff --git a/app/__test__/data/mutation.simple.test.ts b/app/__test__/data/mutation.simple.test.ts index dd385af..ac19935 100644 --- a/app/__test__/data/mutation.simple.test.ts +++ b/app/__test__/data/mutation.simple.test.ts @@ -16,7 +16,7 @@ describe("Mutator simple", async () => { new TextField("label", { required: true, minLength: 1 }), new NumberField("count", { default_value: 0 }) ]); - const em = new EntityManager([items], connection); + const em = new EntityManager([items], connection); await em.connection.kysely.schema .createTable("items") @@ -175,4 +175,18 @@ describe("Mutator simple", async () => { { id: 8, label: "keep", count: 0 } ]); }); + + test("insertMany", async () => { + const oldCount = (await em.repo(items).count()).count; + const inserts = [{ label: "insert 1" }, { label: "insert 2" }]; + const { data } = await em.mutator(items).insertMany(inserts); + + expect(data.length).toBe(2); + expect(data.map((d) => ({ label: d.label }))).toEqual(inserts); + const newCount = (await em.repo(items).count()).count; + expect(newCount).toBe(oldCount + inserts.length); + + const { data: data2 } = await em.repo(items).findMany({ offset: oldCount }); + expect(data2).toEqual(data); + }); }); diff --git a/app/__test__/data/prototype.test.ts b/app/__test__/data/prototype.test.ts index e5d3753..8b12aa4 100644 --- a/app/__test__/data/prototype.test.ts +++ b/app/__test__/data/prototype.test.ts @@ -3,6 +3,8 @@ import { BooleanField, DateField, Entity, + EntityIndex, + EntityManager, EnumField, JsonField, ManyToManyRelation, @@ -12,6 +14,7 @@ import { PolymorphicRelation, TextField } from "../../src/data"; +import { DummyConnection } from "../../src/data/connection/DummyConnection"; import { FieldPrototype, type FieldSchema, @@ -20,6 +23,7 @@ import { boolean, date, datetime, + em, entity, enumm, json, @@ -46,12 +50,17 @@ describe("prototype", () => { }); test("...2", async () => { - const user = entity("users", { - name: text().required(), + const users = entity("users", { + name: text(), bio: text(), age: number(), - some: number().required() + some: number() }); + type db = { + users: Schema; + }; + + const obj: Schema = {} as any; //console.log("user", user.toJSON()); }); @@ -266,4 +275,38 @@ describe("prototype", () => { const obj: Schema = {} as any; }); + + test("schema", async () => { + const _em = em( + { + posts: entity("posts", { name: text(), slug: text().required() }), + comments: entity("comments", { some: text() }), + users: entity("users", { email: text() }) + }, + ({ relation, index }, { posts, comments, users }) => { + relation(posts).manyToOne(comments).manyToOne(users); + index(posts).on(["name"]).on(["slug"], true); + } + ); + + type LocalDb = (typeof _em)["DB"]; + + const es = [ + new Entity("posts", [new TextField("name"), new TextField("slug", { required: true })]), + new Entity("comments", [new TextField("some")]), + new Entity("users", [new TextField("email")]) + ]; + const _em2 = new EntityManager( + es, + new DummyConnection(), + [new ManyToOneRelation(es[0], es[1]), new ManyToOneRelation(es[0], es[2])], + [ + new EntityIndex(es[0], [es[0].field("name")!]), + new EntityIndex(es[0], [es[0].field("slug")!], true) + ] + ); + + // @ts-ignore + expect(_em2.toJSON()).toEqual(_em.toJSON()); + }); }); diff --git a/app/__test__/data/specs/Mutator.spec.ts b/app/__test__/data/specs/Mutator.spec.ts index 04bd8a3..5552543 100644 --- a/app/__test__/data/specs/Mutator.spec.ts +++ b/app/__test__/data/specs/Mutator.spec.ts @@ -22,7 +22,7 @@ describe("[data] Mutator (base)", async () => { new TextField("hidden", { hidden: true }), new TextField("not_fillable", { fillable: false }) ]); - const em = new EntityManager([entity], dummyConnection); + const em = new EntityManager([entity], dummyConnection); await em.schema().sync({ force: true }); const payload = { label: "item 1", count: 1 }; @@ -61,7 +61,7 @@ describe("[data] Mutator (ManyToOne)", async () => { const posts = new Entity("posts", [new TextField("title")]); const users = new Entity("users", [new TextField("username")]); const relations = [new ManyToOneRelation(posts, users)]; - const em = new EntityManager([posts, users], dummyConnection, relations); + const em = new EntityManager([posts, users], dummyConnection, relations); await em.schema().sync({ force: true }); test("RelationMutator", async () => { @@ -192,7 +192,7 @@ describe("[data] Mutator (OneToOne)", async () => { const users = new Entity("users", [new TextField("username")]); const settings = new Entity("settings", [new TextField("theme")]); const relations = [new OneToOneRelation(users, settings)]; - const em = new EntityManager([users, settings], dummyConnection, relations); + const em = new EntityManager([users, settings], dummyConnection, relations); await em.schema().sync({ force: true }); test("insertOne: missing ref", async () => { @@ -276,7 +276,7 @@ describe("[data] Mutator (ManyToMany)", async () => { describe("[data] Mutator (Events)", async () => { const entity = new Entity("test", [new TextField("label")]); - const em = new EntityManager([entity], dummyConnection); + const em = new EntityManager([entity], dummyConnection); await em.schema().sync({ force: true }); const events = new Map(); diff --git a/app/build.ts b/app/build.ts index 60251f8..6511124 100644 --- a/app/build.ts +++ b/app/build.ts @@ -9,16 +9,44 @@ const watch = args.includes("--watch"); const minify = args.includes("--minify"); const types = args.includes("--types"); const sourcemap = args.includes("--sourcemap"); +const clean = args.includes("--clean"); + +if (clean) { + console.log("Cleaning dist"); + await $`rm -rf dist`; +} + +let types_running = false; +function buildTypes() { + if (types_running) return; + types_running = true; -await $`rm -rf dist`; -if (types) { Bun.spawn(["bun", "build:types"], { onExit: () => { console.log("Types built"); + Bun.spawn(["bun", "tsc-alias"], { + onExit: () => { + console.log("Types aliased"); + types_running = false; + } + }); } }); } +let watcher_timeout: any; +function delayTypes() { + if (!watch) return; + if (watcher_timeout) { + clearTimeout(watcher_timeout); + } + watcher_timeout = setTimeout(buildTypes, 1000); +} + +if (types && !watch) { + buildTypes(); +} + /** * Build static assets * Using esbuild because tsup doesn't include "react" @@ -46,7 +74,8 @@ const result = await esbuild.build({ __isDev: "0", "process.env.NODE_ENV": '"production"' }, - chunkNames: "chunks/[name]-[hash]" + chunkNames: "chunks/[name]-[hash]", + logLevel: "error" }); // Write manifest @@ -96,6 +125,9 @@ await tsup.build({ treeshake: true, loader: { ".svg": "dataurl" + }, + onSuccess: async () => { + delayTypes(); } }); @@ -117,11 +149,12 @@ await tsup.build({ loader: { ".svg": "dataurl" }, - onSuccess: async () => { - console.log("--- ui built"); - }, esbuildOptions: (options) => { + options.logLevel = "silent"; options.chunkNames = "chunks/[name]-[hash]"; + }, + onSuccess: async () => { + delayTypes(); } }); @@ -148,7 +181,10 @@ function baseConfig(adapter: string): tsup.Options { ], metafile: true, splitting: false, - treeshake: true + treeshake: true, + onSuccess: async () => { + delayTypes(); + } }; } diff --git a/app/package.json b/app/package.json index fb02b8e..4e188a1 100644 --- a/app/package.json +++ b/app/package.json @@ -5,14 +5,14 @@ "bin": "./dist/cli/index.js", "version": "0.3.4-alpha1", "scripts": { - "build:all": "bun run build && bun run build:cli", + "build:all": "NODE_ENV=production bun run build.ts --minify --types --clean && bun run build:cli", "dev": "vite", "test": "ALL_TESTS=1 bun test --bail", "build": "NODE_ENV=production bun run build.ts --minify --types", "watch": "bun run build.ts --types --watch", "types": "bun tsc --noEmit", "clean:types": "find ./dist -name '*.d.ts' -delete && rm -f ./dist/tsconfig.tsbuildinfo", - "build:types": "tsc --emitDeclarationOnly", + "build:types": "tsc --emitDeclarationOnly && tsc-alias", "build:css": "bun tailwindcss -i src/ui/main.css -o ./dist/static/styles.css", "watch:css": "bun tailwindcss --watch -i src/ui/main.css -o ./dist/styles.css", "updater": "bun x npm-check-updates -ui", @@ -75,6 +75,7 @@ "tailwind-merge": "^2.5.4", "tailwindcss": "^3.4.14", "tailwindcss-animate": "^1.0.7", + "tsc-alias": "^1.8.10", "tsup": "^8.3.5", "vite": "^5.4.10", "vite-plugin-static-copy": "^2.0.0", @@ -90,75 +91,75 @@ }, "main": "./dist/index.js", "module": "./dist/index.js", - "types": "./dist/index.d.ts", + "types": "./dist/types/index.d.ts", "exports": { ".": { - "types": "./dist/index.d.ts", + "types": "./dist/types/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" }, "./ui": { - "types": "./dist/ui/index.d.ts", + "types": "./dist/types/ui/index.d.ts", "import": "./dist/ui/index.js", "require": "./dist/ui/index.cjs" }, "./client": { - "types": "./dist/ui/client/index.d.ts", + "types": "./dist/types/ui/client/index.d.ts", "import": "./dist/ui/client/index.js", "require": "./dist/ui/client/index.cjs" }, "./data": { - "types": "./dist/data/index.d.ts", + "types": "./dist/types/data/index.d.ts", "import": "./dist/data/index.js", "require": "./dist/data/index.cjs" }, "./core": { - "types": "./dist/core/index.d.ts", + "types": "./dist/types/core/index.d.ts", "import": "./dist/core/index.js", "require": "./dist/core/index.cjs" }, "./utils": { - "types": "./dist/core/utils/index.d.ts", + "types": "./dist/types/core/utils/index.d.ts", "import": "./dist/core/utils/index.js", "require": "./dist/core/utils/index.cjs" }, "./cli": { - "types": "./dist/cli/index.d.ts", + "types": "./dist/types/cli/index.d.ts", "import": "./dist/cli/index.js", "require": "./dist/cli/index.cjs" }, "./adapter/cloudflare": { - "types": "./dist/adapter/cloudflare/index.d.ts", + "types": "./dist/types/adapter/cloudflare/index.d.ts", "import": "./dist/adapter/cloudflare/index.js", "require": "./dist/adapter/cloudflare/index.cjs" }, "./adapter/vite": { - "types": "./dist/adapter/vite/index.d.ts", + "types": "./dist/types/adapter/vite/index.d.ts", "import": "./dist/adapter/vite/index.js", "require": "./dist/adapter/vite/index.cjs" }, "./adapter/nextjs": { - "types": "./dist/adapter/nextjs/index.d.ts", + "types": "./dist/types/adapter/nextjs/index.d.ts", "import": "./dist/adapter/nextjs/index.js", "require": "./dist/adapter/nextjs/index.cjs" }, "./adapter/remix": { - "types": "./dist/adapter/remix/index.d.ts", + "types": "./dist/types/adapter/remix/index.d.ts", "import": "./dist/adapter/remix/index.js", "require": "./dist/adapter/remix/index.cjs" }, "./adapter/bun": { - "types": "./dist/adapter/bun/index.d.ts", + "types": "./dist/types/adapter/bun/index.d.ts", "import": "./dist/adapter/bun/index.js", "require": "./dist/adapter/bun/index.cjs" }, "./adapter/node": { - "types": "./dist/adapter/node/index.d.ts", + "types": "./dist/types/adapter/node/index.d.ts", "import": "./dist/adapter/node/index.js", "require": "./dist/adapter/node/index.cjs" }, "./adapter/astro": { - "types": "./dist/adapter/astro/index.d.ts", + "types": "./dist/types/adapter/astro/index.d.ts", "import": "./dist/adapter/astro/index.js", "require": "./dist/adapter/astro/index.cjs" }, diff --git a/app/src/Api.ts b/app/src/Api.ts index 5196622..d6c99fe 100644 --- a/app/src/Api.ts +++ b/app/src/Api.ts @@ -38,7 +38,7 @@ export class Api { private token_transport: "header" | "cookie" | "none" = "header"; public system!: SystemApi; - public data!: DataApi; + public data!: DataApi; public auth!: AuthApi; public media!: MediaApi; diff --git a/app/src/App.ts b/app/src/App.ts index d180a51..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?: @@ -37,6 +41,8 @@ export type AppConfig = InitialModuleConfigs; export class App { modules: ModuleManager; static readonly Events = AppEvents; + adminController?: AdminController; + private trigger_first_boot = false; constructor( private connection: Connection, @@ -48,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); @@ -88,14 +105,24 @@ 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) { return this.modules.get(module).schema(); } + get server() { + return this.modules.server; + } + get fetch(): any { - return this.modules.server.fetch; + return this.server.fetch; } get module() { @@ -119,7 +146,8 @@ export class App { registerAdminController(config?: AdminControllerOptions) { // register admin - this.modules.server.route("/", new AdminController(this, config).getController()); + this.adminController = new AdminController(this, config); + this.modules.server.route("/", this.adminController.getController()); return this; } diff --git a/app/src/auth/AppAuth.ts b/app/src/auth/AppAuth.ts index ba7b00d..9a1c708 100644 --- a/app/src/auth/AppAuth.ts +++ b/app/src/auth/AppAuth.ts @@ -197,7 +197,7 @@ export class AppAuth extends Module { throw new Exception("User already exists"); } - const payload = { + const payload: any = { ...profile, strategy: strategy.getName(), strategy_value: identifier 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/core/utils/test.ts b/app/src/core/utils/test.ts index cf33e1a..b06ac55 100644 --- a/app/src/core/utils/test.ts +++ b/app/src/core/utils/test.ts @@ -9,10 +9,25 @@ export async function withDisabledConsole( fn: () => Promise, severities: ConsoleSeverity[] = ["log"] ): Promise { - const enable = disableConsoleLog(severities); - const result = await fn(); - enable(); - return result; + const _oldConsoles = { + log: console.log, + warn: console.warn, + error: console.error + }; + disableConsoleLog(severities); + const enable = () => { + Object.entries(_oldConsoles).forEach(([severity, fn]) => { + console[severity as ConsoleSeverity] = fn; + }); + }; + try { + const result = await fn(); + enable(); + return result; + } catch (e) { + enable(); + throw e; + } } export function disableConsoleLog(severities: ConsoleSeverity[] = ["log"]) { diff --git a/app/src/data/AppData.ts b/app/src/data/AppData.ts index 821a3c6..6f885f0 100644 --- a/app/src/data/AppData.ts +++ b/app/src/data/AppData.ts @@ -1,52 +1,20 @@ import { transformObject } from "core/utils"; -import { DataPermissions, Entity, EntityIndex, type EntityManager, type Field } from "data"; +import { + DataPermissions, + type Entity, + EntityIndex, + type EntityManager, + constructEntity, + constructRelation +} from "data"; import { Module } from "modules/Module"; import { DataController } from "./api/DataController"; -import { - type AppDataConfig, - FIELDS, - RELATIONS, - type TAppDataEntity, - type TAppDataRelation, - dataConfigSchema -} from "./data-schema"; +import { type AppDataConfig, dataConfigSchema } from "./data-schema"; export class AppData extends Module { - static constructEntity(name: string, entityConfig: TAppDataEntity) { - const fields = transformObject(entityConfig.fields ?? {}, (fieldConfig, name) => { - const { type } = fieldConfig; - if (!(type in FIELDS)) { - throw new Error(`Field type "${type}" not found`); - } - - const { field } = FIELDS[type as any]; - const returnal = new field(name, fieldConfig.config) as Field; - return returnal; - }); - - // @todo: entity must be migrated to typebox - return new Entity( - name, - Object.values(fields), - entityConfig.config as any, - entityConfig.type as any - ); - } - - static constructRelation( - relationConfig: TAppDataRelation, - resolver: (name: Entity | string) => Entity - ) { - return new RELATIONS[relationConfig.type].cls( - resolver(relationConfig.source), - resolver(relationConfig.target), - relationConfig.config - ); - } - override async build() { const entities = transformObject(this.config.entities ?? {}, (entityConfig, name) => { - return AppData.constructEntity(name, entityConfig); + return constructEntity(name, entityConfig); }); const _entity = (_e: Entity | string): Entity => { @@ -57,7 +25,7 @@ export class AppData extends Module { }; const relations = transformObject(this.config.relations ?? {}, (relation) => - AppData.constructRelation(relation, _entity) + constructRelation(relation, _entity) ); const indices = transformObject(this.config.indices ?? {}, (index, name) => { diff --git a/app/src/data/api/DataApi.ts b/app/src/data/api/DataApi.ts index 967a5f1..ad7c670 100644 --- a/app/src/data/api/DataApi.ts +++ b/app/src/data/api/DataApi.ts @@ -5,7 +5,7 @@ export type DataApiOptions = BaseModuleApiOptions & { defaultQuery?: Partial; }; -export class DataApi extends ModuleApi { +export class DataApi extends ModuleApi { protected override getDefaultOptions(): Partial { return { basepath: "/api/data", @@ -15,48 +15,60 @@ export class DataApi extends ModuleApi { }; } - readOne( - entity: string, + readOne( + entity: E, id: PrimaryFieldType, query: Partial> = {} ) { - return this.get>([entity, id], query); + return this.get, "meta" | "data">>([entity as any, id], query); } - readMany(entity: string, query: Partial = {}) { - return this.get>( - [entity], - query ?? this.options.defaultQuery - ); - } - - readManyByReference( - entity: string, - id: PrimaryFieldType, - reference: string, + readMany( + entity: E, query: Partial = {} ) { - return this.get>( - [entity, id, reference], + return this.get, "meta" | "data">>( + [entity as any], query ?? this.options.defaultQuery ); } - createOne(entity: string, input: EntityData) { - return this.post>([entity], input); + readManyByReference< + E extends keyof DB | string, + R extends keyof DB | string, + Data = R extends keyof DB ? DB[R] : EntityData + >(entity: E, id: PrimaryFieldType, reference: R, query: Partial = {}) { + return this.get, "meta" | "data">>( + [entity as any, id, reference], + query ?? this.options.defaultQuery + ); } - updateOne(entity: string, id: PrimaryFieldType, input: EntityData) { - return this.patch>([entity, id], input); + createOne( + entity: E, + input: Omit + ) { + return this.post>([entity as any], input); } - deleteOne(entity: string, id: PrimaryFieldType) { - return this.delete>([entity, id]); + updateOne( + entity: E, + id: PrimaryFieldType, + input: Partial> + ) { + return this.patch>([entity as any, id], input); } - count(entity: string, where: RepoQuery["where"] = {}) { - return this.post>( - [entity, "fn", "count"], + deleteOne( + entity: E, + id: PrimaryFieldType + ) { + return this.delete>([entity as any, id]); + } + + count(entity: E, where: RepoQuery["where"] = {}) { + return this.post>( + [entity as any, "fn", "count"], where ); } diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index 3f459dc..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, @@ -165,13 +165,12 @@ export class DataController implements ClassController { // read entity schema .get("/schema.json", async (c) => { this.guard.throwUnlessGranted(DataPermissions.entityRead); - const url = new URL(c.req.url); - const $id = `${url.origin}${this.config.basepath}/schema.json`; + const $id = `${this.config.basepath}/schema.json`; const schemas = Object.fromEntries( this.em.entities.map((e) => [ e.name, { - $ref: `schemas/${e.name}` + $ref: `${this.config.basepath}/schemas/${e.name}` } ]) ); @@ -183,22 +182,28 @@ 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 = `${base}/schemas/${entity}`; + const $id = `${this.config.basepath}/schemas/${entity}`; return c.json({ $schema: `${base}/schema.json`, $id, diff --git a/app/src/data/connection/DummyConnection.ts b/app/src/data/connection/DummyConnection.ts new file mode 100644 index 0000000..451575d --- /dev/null +++ b/app/src/data/connection/DummyConnection.ts @@ -0,0 +1,7 @@ +import { Connection } from "./Connection"; + +export class DummyConnection extends Connection { + constructor() { + super(undefined as any); + } +} diff --git a/app/src/data/entities/Entity.ts b/app/src/data/entities/Entity.ts index 0a285e7..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,20 +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/EntityManager.ts b/app/src/data/entities/EntityManager.ts index 674d7e2..d34c728 100644 --- a/app/src/data/entities/EntityManager.ts +++ b/app/src/data/entities/EntityManager.ts @@ -14,6 +14,14 @@ import { SchemaManager } from "../schema/SchemaManager"; import { Entity } from "./Entity"; import { type EntityData, Mutator, Repository } from "./index"; +type EntitySchema = E extends Entity + ? Name extends keyof DB + ? Name + : never + : E extends keyof DB + ? E + : never; + export class EntityManager { connection: Connection; @@ -87,10 +95,16 @@ export class EntityManager { this.entities.push(entity); } - entity(name: string): Entity { - const entity = this.entities.find((e) => e.name === name); + entity(e: Entity | string): Entity { + let entity: Entity | undefined; + if (typeof e === "string") { + entity = this.entities.find((entity) => entity.name === e); + } else { + entity = e; + } + if (!entity) { - throw new EntityNotDefinedException(name); + throw new EntityNotDefinedException(typeof e === "string" ? e : e.name); } return entity; @@ -162,28 +176,16 @@ export class EntityManager { return this.relations.relationReferencesOf(this.entity(entity_name)); } - repository(_entity: Entity | string) { - const entity = _entity instanceof Entity ? _entity : this.entity(_entity); - return new Repository(this, entity, this.emgr); + repository(entity: E): Repository> { + return this.repo(entity); } - repo( - _entity: E - ): Repository< - DB, - E extends Entity ? (Name extends keyof DB ? Name : never) : never - > { - return new Repository(this, _entity, this.emgr); + repo(entity: E): Repository> { + return new Repository(this, this.entity(entity), this.emgr); } - _repo(_entity: TB): Repository { - const entity = this.entity(_entity as any); - return new Repository(this, entity, this.emgr); - } - - mutator(_entity: Entity | string) { - const entity = _entity instanceof Entity ? _entity : this.entity(_entity); - return new Mutator(this, entity, this.emgr); + mutator(entity: E): Mutator> { + return new Mutator(this, this.entity(entity), this.emgr); } addIndex(index: EntityIndex, force = false) { diff --git a/app/src/data/entities/Mutator.ts b/app/src/data/entities/Mutator.ts index ed7f9ef..cb25ddf 100644 --- a/app/src/data/entities/Mutator.ts +++ b/app/src/data/entities/Mutator.ts @@ -25,7 +25,13 @@ 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; static readonly Events = MutatorEvents; @@ -47,13 +53,13 @@ export class Mutator implements EmitsEvents { return this.em.connection.kysely; } - async getValidatedData(data: EntityData, context: TActionContext): Promise { + async getValidatedData(data: Given, context: TActionContext): Promise { const entity = this.entity; if (!context) { throw new Error("Context must be provided for validation"); } - const keys = Object.keys(data); + const keys = Object.keys(data as any); const validatedData: EntityData = {}; // get relational references/keys @@ -95,7 +101,7 @@ export class Mutator implements EmitsEvents { throw new Error(`No data left to update "${entity.name}"`); } - return validatedData; + return validatedData as Given; } protected async many(qb: MutatorQB): Promise { @@ -120,7 +126,7 @@ export class Mutator implements EmitsEvents { return { ...response, data: data[0]! }; } - async insertOne(data: EntityData): Promise> { + 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`); @@ -154,10 +160,10 @@ export class Mutator implements EmitsEvents { await this.emgr.emit(new Mutator.Events.MutatorInsertAfter({ entity, data: res.data })); - return res; + return res as any; } - async updateOne(id: PrimaryFieldType, data: EntityData): Promise> { + async updateOne(id: PrimaryFieldType, data: Input): Promise> { const entity = this.entity; if (!Number.isInteger(id)) { throw new Error("ID must be provided for update"); @@ -166,12 +172,16 @@ export class Mutator implements EmitsEvents { const validatedData = await this.getValidatedData(data, "update"); await this.emgr.emit( - new Mutator.Events.MutatorUpdateBefore({ entity, entityId: id, data: validatedData }) + new Mutator.Events.MutatorUpdateBefore({ + entity, + entityId: id, + data: validatedData as any + }) ); const query = this.conn .updateTable(entity.name) - .set(validatedData) + .set(validatedData as any) .where(entity.id().name, "=", id) .returning(entity.getSelect()); @@ -181,10 +191,10 @@ export class Mutator implements EmitsEvents { new Mutator.Events.MutatorUpdateAfter({ entity, entityId: id, data: res.data }) ); - return res; + return res as any; } - async deleteOne(id: PrimaryFieldType): Promise> { + async deleteOne(id: PrimaryFieldType): Promise> { const entity = this.entity; if (!Number.isInteger(id)) { throw new Error("ID must be provided for deletion"); @@ -203,7 +213,7 @@ export class Mutator implements EmitsEvents { new Mutator.Events.MutatorDeleteAfter({ entity, entityId: id, data: res.data }) ); - return res; + return res as any; } private getValidOptions(options?: Partial): Partial { @@ -250,47 +260,59 @@ export class Mutator implements EmitsEvents { } // @todo: decide whether entries should be deleted all at once or one by one (for events) - async deleteWhere(where?: RepoQuery["where"]): Promise> { + async deleteWhere(where?: RepoQuery["where"]): Promise> { const entity = this.entity; const qb = this.appendWhere(this.conn.deleteFrom(entity.name), where).returning( entity.getSelect() ); - //await this.emgr.emit(new Mutator.Events.MutatorDeleteBefore({ entity, entityId: id })); - - const res = await this.many(qb); - - /*await this.emgr.emit( - new Mutator.Events.MutatorDeleteAfter({ entity, entityId: id, data: res.data }) - );*/ - - return res; + return (await this.many(qb)) as any; } - async updateWhere( - data: EntityData, - where?: RepoQuery["where"] - ): Promise> { + async updateWhere(data: Partial, where?: RepoQuery["where"]): Promise> { const entity = this.entity; - const validatedData = await this.getValidatedData(data, "update"); - /*await this.emgr.emit( - new Mutator.Events.MutatorUpdateBefore({ entity, entityId: id, data: validatedData }) - );*/ - const query = this.appendWhere(this.conn.updateTable(entity.name), where) - .set(validatedData) - //.where(entity.id().name, "=", id) + .set(validatedData as any) .returning(entity.getSelect()); - const res = await this.many(query); + return (await this.many(query)) as any; + } - /*await this.emgr.emit( - new Mutator.Events.MutatorUpdateAfter({ entity, entityId: id, data: res.data }) - );*/ + 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`); + } - return res; + const validated: any[] = []; + for (const row of data) { + const validatedData = { + ...entity.getDefaultObject(), + ...(await this.getValidatedData(row, "create")) + }; + + // check if required fields are present + const required = entity.getRequiredFields(); + for (const field of required) { + if ( + typeof validatedData[field.name] === "undefined" || + validatedData[field.name] === null + ) { + throw new Error(`Field "${field.name}" is required`); + } + } + + validated.push(validatedData); + } + + const query = this.conn + .insertInto(entity.name) + .values(validated) + .returning(entity.getSelect()); + + return (await this.many(query)) as any; } } diff --git a/app/src/data/entities/query/Repository.ts b/app/src/data/entities/query/Repository.ts index f5b576c..4391b32 100644 --- a/app/src/data/entities/query/Repository.ts +++ b/app/src/data/entities/query/Repository.ts @@ -272,7 +272,7 @@ export class Repository implements EmitsEve async findId( id: PrimaryFieldType, _options?: Partial> - ): Promise> { + ): Promise> { const { qb, options } = this.buildQuery( { ..._options, diff --git a/app/src/data/fields/TextField.ts b/app/src/data/fields/TextField.ts index 6314618..6dc17d3 100644 --- a/app/src/data/fields/TextField.ts +++ b/app/src/data/fields/TextField.ts @@ -104,6 +104,12 @@ export class TextField extends Field< ); } + if (this.config.pattern && value && !new RegExp(this.config.pattern).test(value)) { + throw new TransformPersistFailedException( + `Field "${this.name}" must match the pattern ${this.config.pattern}` + ); + } + return value; } diff --git a/app/src/data/index.ts b/app/src/data/index.ts index 284c653..3a287e6 100644 --- a/app/src/data/index.ts +++ b/app/src/data/index.ts @@ -18,6 +18,8 @@ export { LibsqlConnection, type LibSqlCredentials } from "./connection/LibsqlCon export { SqliteConnection } from "./connection/SqliteConnection"; export { SqliteLocalConnection } from "./connection/SqliteLocalConnection"; +export { constructEntity, constructRelation } from "./schema/constructor"; + export const DatabaseEvents = { ...MutatorEvents, ...RepositoryEvents diff --git a/app/src/data/prototype/index.ts b/app/src/data/prototype/index.ts index d526c2e..e9e868f 100644 --- a/app/src/data/prototype/index.ts +++ b/app/src/data/prototype/index.ts @@ -1,3 +1,8 @@ +import { DummyConnection } from "data/connection/DummyConnection"; +import { EntityManager } from "data/entities/EntityManager"; +import type { Generated } from "kysely"; +import { MediaField, type MediaFieldConfig, type MediaItem } from "media/MediaField"; +import type { ModuleConfigs } from "modules"; import { BooleanField, type BooleanFieldConfig, @@ -5,6 +10,8 @@ import { type DateFieldConfig, Entity, type EntityConfig, + EntityIndex, + type EntityRelation, EnumField, type EnumFieldConfig, type Field, @@ -25,15 +32,14 @@ import { type TEntityType, TextField, type TextFieldConfig -} from "data"; -import type { Generated } from "kysely"; -import { MediaField, type MediaFieldConfig, type MediaItem } from "media/MediaField"; +} from "../index"; type Options = { entity: { name: string; fields: Record> }; field_name: string; config: Config; is_required: boolean; + another?: string; }; const FieldMap = { @@ -239,7 +245,89 @@ export function relation(local: Local) { }; } -type InferEntityFields = T extends Entity +export function index(entity: E) { + return { + on: (fields: (keyof InsertSchema)[], unique?: boolean) => { + const _fields = fields.map((f) => { + const field = entity.field(f as any); + if (!field) { + throw new Error(`Field "${String(f)}" not found on entity "${entity.name}"`); + } + return field; + }); + return new EntityIndex(entity, _fields, unique); + } + }; +} + +class EntityManagerPrototype> extends EntityManager< + Schema +> { + constructor( + public __entities: Entities, + relations: EntityRelation[] = [], + indices: EntityIndex[] = [] + ) { + super(Object.values(__entities), new DummyConnection(), relations, indices); + } +} + +type Chained any, Rt = ReturnType> = ( + e: E +) => { + [K in keyof Rt]: Rt[K] extends (...args: any[]) => any + ? (...args: Parameters) => Rt + : never; +}; + +export function em>( + entities: Entities, + schema?: ( + fns: { relation: Chained; index: Chained }, + entities: Entities + ) => void +) { + const relations: EntityRelation[] = []; + const indices: EntityIndex[] = []; + + const relationProxy = (e: Entity) => { + return new Proxy(relation(e), { + get(target, prop) { + return (...args: any[]) => { + relations.push(target[prop](...args)); + return relationProxy(e); + }; + } + }) as any; + }; + + const indexProxy = (e: Entity) => { + return new Proxy(index(e), { + get(target, prop) { + return (...args: any[]) => { + indices.push(target[prop](...args)); + return indexProxy(e); + }; + } + }) as any; + }; + + if (schema) { + schema({ relation: relationProxy, index: indexProxy }, entities); + } + + const e = new EntityManagerPrototype(entities, relations, indices); + return { + DB: e.__entities as unknown as Schemas, + entities: e.__entities, + relations, + indices, + toJSON: () => + e.toJSON() as unknown as Pick + }; +} + +export type InferEntityFields = T extends Entity ? { [K in keyof Fields]: Fields[K] extends { _type: infer Type; _required: infer Required } ? Required extends true @@ -284,12 +372,16 @@ type OptionalUndefined< } >; -type InferField = Field extends { _type: infer Type; _required: infer Required } +export type InferField = Field extends { _type: infer Type; _required: infer Required } ? Required extends true ? Type : Type | undefined : never; +export type Schemas> = { + [K in keyof T]: Schema; +}; + export type InsertSchema = Simplify>>; -export type Schema = { id: Generated } & InsertSchema; +export type Schema = Simplify<{ id: Generated } & InsertSchema>; export type FieldSchema = Simplify>>; diff --git a/app/src/data/schema/constructor.ts b/app/src/data/schema/constructor.ts new file mode 100644 index 0000000..a88ba5e --- /dev/null +++ b/app/src/data/schema/constructor.ts @@ -0,0 +1,34 @@ +import { transformObject } from "core/utils"; +import { Entity, type Field } from "data"; +import { FIELDS, RELATIONS, type TAppDataEntity, type TAppDataRelation } from "data/data-schema"; + +export function constructEntity(name: string, entityConfig: TAppDataEntity) { + const fields = transformObject(entityConfig.fields ?? {}, (fieldConfig, name) => { + const { type } = fieldConfig; + if (!(type in FIELDS)) { + throw new Error(`Field type "${type}" not found`); + } + + const { field } = FIELDS[type as any]; + const returnal = new field(name, fieldConfig.config) as Field; + return returnal; + }); + + return new Entity( + name, + Object.values(fields), + entityConfig.config as any, + entityConfig.type as any + ); +} + +export function constructRelation( + relationConfig: TAppDataRelation, + resolver: (name: Entity | string) => Entity +) { + return new RELATIONS[relationConfig.type].cls( + resolver(relationConfig.source), + resolver(relationConfig.target), + relationConfig.config + ); +} diff --git a/app/src/index.ts b/app/src/index.ts index 78e9f70..1e5b71d 100644 --- a/app/src/index.ts +++ b/app/src/index.ts @@ -4,8 +4,10 @@ export { getDefaultConfig, getDefaultSchema, type ModuleConfigs, - type ModuleSchemas -} from "modules/ModuleManager"; + type ModuleSchemas, + type ModuleManagerOptions, + type ModuleBuildContext +} from "./modules/ModuleManager"; export { registries } from "modules/registries"; diff --git a/app/src/media/api/MediaController.ts b/app/src/media/api/MediaController.ts index 9597759..2a1a304 100644 --- a/app/src/media/api/MediaController.ts +++ b/app/src/media/api/MediaController.ts @@ -174,7 +174,7 @@ export class MediaController implements ClassController { const result = await mutator.insertOne({ ...this.media.uploadedEventDataToMediaPayload(info), ...mediaRef - }); + } as any); mutator.__unstable_toggleSystemEntityCreation(true); // delete items if needed diff --git a/app/src/modules/Module.ts b/app/src/modules/Module.ts index ecdf4ce..704e420 100644 --- a/app/src/modules/Module.ts +++ b/app/src/modules/Module.ts @@ -5,10 +5,10 @@ import type { Static, TSchema } from "core/utils"; import type { Connection, EntityManager } from "data"; import type { Hono } from "hono"; -export type ModuleBuildContext = { +export type ModuleBuildContext = { connection: Connection; server: Hono; - em: EntityManager; + em: EntityManager; emgr: EventManager; guard: Guard; }; diff --git a/app/src/modules/ModuleManager.ts b/app/src/modules/ModuleManager.ts index db7b285..e5b6c9c 100644 --- a/app/src/modules/ModuleManager.ts +++ b/app/src/modules/ModuleManager.ts @@ -35,6 +35,8 @@ import { AppFlows } from "../flows/AppFlows"; import { AppMedia } from "../media/AppMedia"; import type { Module, ModuleBuildContext } from "./Module"; +export type { ModuleBuildContext }; + export const MODULES = { server: AppServer, data: AppData, @@ -73,9 +75,14 @@ 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 trustFetched?: boolean; + // runs when initial config provided on a fresh database + seed?: (ctx: ModuleBuildContext) => Promise; }; type ConfigTable = { @@ -294,7 +301,7 @@ export class ModuleManager { version, json: configs, updated_at: new Date() - }, + } as any, { type: "config", version @@ -448,6 +455,9 @@ export class ModuleManager { await this.buildModules(); await this.save(); + // run initial setup + await this.setupInitial(); + this.logger.clear(); return this; } @@ -462,6 +472,21 @@ export class ModuleManager { return this; } + protected async setupInitial() { + const ctx = { + ...this.ctx(), + // disable events for initial setup + em: this.ctx().em.fork() + }; + + // 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] { if (!(key in this.modules)) { throw new Error(`Module "${key}" doesn't exist, cannot get`); diff --git a/app/src/ui/client/api/use-api.ts b/app/src/ui/client/api/use-api.ts index 6d75f82..eab68d4 100644 --- a/app/src/ui/client/api/use-api.ts +++ b/app/src/ui/client/api/use-api.ts @@ -5,14 +5,14 @@ import { useApi } from "ui/client"; export const useApiQuery = < Data, - RefineFn extends (data: ResponseObject) => any = (data: ResponseObject) => Data + RefineFn extends (data: ResponseObject) => unknown = (data: ResponseObject) => Data >( fn: (api: Api) => FetchPromise, options?: SWRConfiguration & { enabled?: boolean; refine?: RefineFn } ) => { const api = useApi(); const promise = fn(api); - const refine = options?.refine ?? ((data: ResponseObject) => data); + const refine = options?.refine ?? ((data: any) => data); const fetcher = () => promise.execute().then(refine); const key = promise.key(); diff --git a/app/src/ui/client/api/use-data.ts b/app/src/ui/client/api/use-data.ts index 46cc81a..cd4ff22 100644 --- a/app/src/ui/client/api/use-data.ts +++ b/app/src/ui/client/api/use-data.ts @@ -10,7 +10,7 @@ type OmitFirstArg = F extends (x: any, ...args: infer P) => any * the first argument "entity" for convenience * @param entity */ -export const useData = (entity: string) => { +export const useData = >(entity: string) => { const api = useApi().data; const methods = [ "readOne", diff --git a/app/src/ui/client/api/use-entity.ts b/app/src/ui/client/api/use-entity.ts index 23be395..89ff6e0 100644 --- a/app/src/ui/client/api/use-entity.ts +++ b/app/src/ui/client/api/use-entity.ts @@ -1,23 +1,40 @@ import type { PrimaryFieldType } from "core"; -import { objectTransform } from "core/utils"; +import { encodeSearch, objectTransform } from "core/utils"; import type { EntityData, RepoQuery } from "data"; -import type { ResponseObject } from "modules/ModuleApi"; -import useSWR, { type SWRConfiguration } from "swr"; -import { useApi } from "ui/client"; +import type { ModuleApi, ResponseObject } from "modules/ModuleApi"; +import useSWR, { type SWRConfiguration, mutate } from "swr"; +import { type Api, 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"); } } +function Test() { + const { read } = useEntity("users"); + async () => { + const data = await read(); + }; + + return null; +} + export const useEntity = < - Entity extends string, - Id extends PrimaryFieldType | undefined = undefined + Entity extends keyof DB | string, + Id extends PrimaryFieldType | undefined = undefined, + Data = Entity extends keyof DB ? DB[Entity] : EntityData >( entity: Entity, id?: Id @@ -25,27 +42,30 @@ export const useEntity = < const api = useApi().data; return { - create: async (input: EntityData) => { + 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}"`); } - return res; + // must be manually typed + return res as unknown as Id extends undefined + ? ResponseObject + : ResponseObject; }, - update: async (input: Partial, _id: PrimaryFieldType | undefined = id) => { + update: async (input: Partial>, _id: PrimaryFieldType | undefined = id) => { if (!_id) { throw new Error("id is required"); } 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; }, @@ -56,44 +76,67 @@ 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; } }; }; +// @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("/") + + (query ? "?" + encodeSearch(query) : "") + ); +} + export const useEntityQuery = < - Entity extends string, + Entity extends keyof DB | string, Id extends PrimaryFieldType | undefined = undefined >( entity: Entity, id?: Id, query?: Partial, - options?: SWRConfiguration & { enabled?: boolean } + options?: SWRConfiguration & { enabled?: boolean; revalidateOnMutate?: boolean } ) => { const api = useApi().data; - const key = - options?.enabled !== false - ? [...(api.options?.basepath?.split("/") ?? []), entity, ...(id ? [id] : [])].filter( - Boolean - ) - : null; - const { read, ...actions } = useEntity(entity, id) as any; + const key = makeKey(api, entity, id, query); + const { read, ...actions } = useEntity(entity, id); const fetcher = () => read(query); - type T = Awaited>; - const swr = useSWR(key, fetcher, { + type T = Awaited>; + const swr = useSWR(options?.enabled === false ? null : key, fetcher as any, { revalidateOnFocus: false, - keepPreviousData: false, + keepPreviousData: true, ...options }); - const mapped = objectTransform(actions, (action) => { - if (action === "read") return; + const mutateAll = async () => { + const entityKey = makeKey(api, entity); + return mutate((key) => typeof key === "string" && key.startsWith(entityKey), undefined, { + revalidate: true + }); + }; - return async (...args) => { - return swr.mutate(action(...args)) as any; + const mapped = objectTransform(actions, (action) => { + return async (...args: any) => { + // @ts-ignore + const res = await action(...args); + + // mutate all keys of entity by default + if (options?.revalidateOnMutate !== false) { + await mutateAll(); + } + return res; }; }) as Omit>, "read">; @@ -105,17 +148,62 @@ export const useEntityQuery = < }; }; +export async function mutateEntityCache< + Entity extends keyof DB | string, + Data = Entity extends keyof DB ? Omit : EntityData +>(api: Api["data"], entity: Entity, id: PrimaryFieldType, partialData: Partial) { + function update(prev: any, partialNext: any) { + if ( + typeof prev !== "undefined" && + typeof partialNext !== "undefined" && + "id" in prev && + prev.id === id + ) { + return { ...prev, ...partialNext }; + } + + return prev; + } + + const entityKey = makeKey(api, entity); + + return mutate( + (key) => typeof key === "string" && key.startsWith(entityKey), + async (data) => { + if (typeof data === "undefined") return; + if (Array.isArray(data)) { + return data.map((item) => update(item, partialData)); + } + return update(data, partialData); + }, + { + revalidate: false + } + ); +} + export const useEntityMutate = < - Entity extends string, - Id extends PrimaryFieldType | undefined = undefined + Entity extends keyof DB | string, + Id extends PrimaryFieldType | undefined = undefined, + Data = Entity extends keyof DB ? Omit : EntityData >( entity: Entity, id?: Id, options?: SWRConfiguration ) => { - const { data, ...$q } = useEntityQuery(entity, id, undefined, { + const { data, ...$q } = useEntityQuery(entity, id, undefined, { ...options, enabled: false }); - return $q; + + const _mutate = id + ? (data) => mutateEntityCache($q.api, entity, id, data) + : (id, data) => mutateEntityCache($q.api, entity, id, data); + + return { + ...$q, + mutate: _mutate as unknown as Id extends undefined + ? (id: PrimaryFieldType, data: Partial) => Promise + : (data: Partial) => Promise + }; }; diff --git a/app/src/ui/client/schema/data/use-bknd-data.ts b/app/src/ui/client/schema/data/use-bknd-data.ts index 9de8636..36db148 100644 --- a/app/src/ui/client/schema/data/use-bknd-data.ts +++ b/app/src/ui/client/schema/data/use-bknd-data.ts @@ -1,6 +1,5 @@ import { Type, TypeInvalidError, parse, transformObject } from "core/utils"; -import type { Entity } from "data"; -import { AppData } from "data/AppData"; +import { constructEntity } from "data"; import { type TAppDataEntity, type TAppDataEntityFields, @@ -19,7 +18,7 @@ export function useBkndData() { // @todo: potentially store in ref, so it doesn't get recomputed? or use memo? const entities = transformObject(config.data.entities ?? {}, (entity, name) => { - return AppData.constructEntity(name, entity); + return constructEntity(name, entity); }); const actions = { diff --git a/app/src/ui/client/utils/AppReduced.ts b/app/src/ui/client/utils/AppReduced.ts index 0abc27b..8fa684f 100644 --- a/app/src/ui/client/utils/AppReduced.ts +++ b/app/src/ui/client/utils/AppReduced.ts @@ -1,6 +1,5 @@ import type { App } from "App"; -import type { Entity, EntityRelation } from "data"; -import { AppData } from "data/AppData"; +import { type Entity, type EntityRelation, constructEntity, constructRelation } from "data"; import { RelationAccessor } from "data/relations/RelationAccessor"; import { Flow, TaskMap } from "flows"; @@ -20,11 +19,11 @@ export class AppReduced { //console.log("received appjson", appJson); this._entities = Object.entries(this.appJson.data.entities ?? {}).map(([name, entity]) => { - return AppData.constructEntity(name, entity); + return constructEntity(name, entity); }); this._relations = Object.entries(this.appJson.data.relations ?? {}).map(([, relation]) => { - return AppData.constructRelation(relation, this.entity.bind(this)); + return constructRelation(relation, this.entity.bind(this)); }); for (const [name, obj] of Object.entries(this.appJson.flows.flows ?? {})) { diff --git a/app/src/ui/components/code/CodeEditor.tsx b/app/src/ui/components/code/CodeEditor.tsx index 8dcca60..55d119b 100644 --- a/app/src/ui/components/code/CodeEditor.tsx +++ b/app/src/ui/components/code/CodeEditor.tsx @@ -1,7 +1,6 @@ -import type { ReactCodeMirrorProps } from "@uiw/react-codemirror"; -import { Suspense, lazy } from "react"; +import { default as CodeMirror, type ReactCodeMirrorProps } from "@uiw/react-codemirror"; + import { useBknd } from "ui/client/bknd"; -const CodeMirror = lazy(() => import("@uiw/react-codemirror")); export default function CodeEditor({ editable, basicSetup, ...props }: ReactCodeMirrorProps) { const b = useBknd(); @@ -15,13 +14,11 @@ export default function CodeEditor({ editable, basicSetup, ...props }: ReactCode : basicSetup; return ( - - - + ); } diff --git a/app/src/ui/components/form/json-schema/JsonSchemaForm.tsx b/app/src/ui/components/form/json-schema/JsonSchemaForm.tsx index 73330d3..d722dde 100644 --- a/app/src/ui/components/form/json-schema/JsonSchemaForm.tsx +++ b/app/src/ui/components/form/json-schema/JsonSchemaForm.tsx @@ -1,15 +1,12 @@ import type { Schema } from "@cfworker/json-schema"; import Form from "@rjsf/core"; import type { RJSFSchema, UiSchema } from "@rjsf/utils"; +import { cloneDeep } from "lodash-es"; import { forwardRef, useId, useImperativeHandle, useRef, useState } from "react"; -//import { JsonSchemaValidator } from "./JsonSchemaValidator"; import { fields as Fields } from "./fields"; import { templates as Templates } from "./templates"; -import { widgets as Widgets } from "./widgets"; -import "./styles.css"; -import { filterKeys } from "core/utils"; -import { cloneDeep } from "lodash-es"; import { RJSFTypeboxValidator } from "./typebox/RJSFTypeboxValidator"; +import { widgets as Widgets } from "./widgets"; const validator = new RJSFTypeboxValidator(); diff --git a/app/src/ui/components/form/json-schema/index.tsx b/app/src/ui/components/form/json-schema/index.tsx new file mode 100644 index 0000000..4af8a4e --- /dev/null +++ b/app/src/ui/components/form/json-schema/index.tsx @@ -0,0 +1,18 @@ +import { Suspense, forwardRef, lazy } from "react"; +import type { JsonSchemaFormProps, JsonSchemaFormRef } from "./JsonSchemaForm"; + +export type { JsonSchemaFormProps, JsonSchemaFormRef }; + +const Module = lazy(() => + import("./JsonSchemaForm").then((m) => ({ + default: m.JsonSchemaForm + })) +); + +export const JsonSchemaForm = forwardRef((props, ref) => { + return ( + + + + ); +}); diff --git a/app/src/ui/modals/debug/SchemaFormModal.tsx b/app/src/ui/modals/debug/SchemaFormModal.tsx index 0bfab66..72c1c89 100644 --- a/app/src/ui/modals/debug/SchemaFormModal.tsx +++ b/app/src/ui/modals/debug/SchemaFormModal.tsx @@ -4,7 +4,7 @@ import { JsonSchemaForm, type JsonSchemaFormProps, type JsonSchemaFormRef -} from "ui/components/form/json-schema/JsonSchemaForm"; +} from "ui/components/form/json-schema"; import type { ContextModalProps } from "@mantine/modals"; diff --git a/app/src/ui/modules/data/components/fields/EntityJsonSchemaFormField.tsx b/app/src/ui/modules/data/components/fields/EntityJsonSchemaFormField.tsx index 7b830d2..82a55a9 100644 --- a/app/src/ui/modules/data/components/fields/EntityJsonSchemaFormField.tsx +++ b/app/src/ui/modules/data/components/fields/EntityJsonSchemaFormField.tsx @@ -1,14 +1,8 @@ import type { FieldApi } from "@tanstack/react-form"; import type { EntityData, JsonSchemaField } from "data"; -import { Suspense, lazy } from "react"; import * as Formy from "ui/components/form/Formy"; import { FieldLabel } from "ui/components/form/Formy"; - -const JsonSchemaForm = lazy(() => - import("ui/components/form/json-schema/JsonSchemaForm").then((m) => ({ - default: m.JsonSchemaForm - })) -); +import { JsonSchemaForm } from "ui/components/form/json-schema"; export function EntityJsonSchemaFormField({ fieldApi, @@ -34,23 +28,21 @@ export function EntityJsonSchemaFormField({ return ( - Loading...}> -
- -
-
+
+ +
); } diff --git a/app/src/ui/modules/flows/components/TriggerComponent.tsx b/app/src/ui/modules/flows/components/TriggerComponent.tsx index 7497234..87e906b 100644 --- a/app/src/ui/modules/flows/components/TriggerComponent.tsx +++ b/app/src/ui/modules/flows/components/TriggerComponent.tsx @@ -1,14 +1,9 @@ import { Handle, type Node, type NodeProps, Position } from "@xyflow/react"; import { Const, Type, transformObject } from "core/utils"; -import { type TaskRenderProps, type Trigger, TriggerMap } from "flows"; -import { Suspense, lazy } from "react"; +import { type Trigger, TriggerMap } from "flows"; import type { IconType } from "react-icons"; import { TbCircleLetterT } from "react-icons/tb"; -const JsonSchemaForm = lazy(() => - import("ui/components/form/json-schema/JsonSchemaForm").then((m) => ({ - default: m.JsonSchemaForm - })) -); +import { JsonSchemaForm } from "ui/components/form/json-schema"; export type TaskComponentProps = NodeProps> & { Icon?: IconType; @@ -48,17 +43,15 @@ export function TriggerComponent({
- Loading...
}> - - +
- import("ui/components/form/json-schema/JsonSchemaForm").then((m) => ({ - default: m.JsonSchemaForm - })) -); +import { JsonSchemaForm } from "ui/components/form/json-schema"; export type TaskFormProps = { task: Task; @@ -26,16 +19,14 @@ export function TaskForm({ task, onChange, ...props }: TaskFormProps) { //console.log("uiSchema", uiSchema); return ( - Loading...}> - - + ); } diff --git a/app/src/ui/routes/auth/auth.settings.tsx b/app/src/ui/routes/auth/auth.settings.tsx index 2716d3c..5b19651 100644 --- a/app/src/ui/routes/auth/auth.settings.tsx +++ b/app/src/ui/routes/auth/auth.settings.tsx @@ -5,10 +5,7 @@ import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth"; import { useBkndData } from "ui/client/schema/data/use-bknd-data"; import { Button } from "ui/components/buttons/Button"; import { Alert } from "ui/components/display/Alert"; -import { - JsonSchemaForm, - type JsonSchemaFormRef -} from "ui/components/form/json-schema/JsonSchemaForm"; +import { JsonSchemaForm, type JsonSchemaFormRef } from "ui/components/form/json-schema"; import * as AppShell from "ui/layouts/AppShell/AppShell"; import { useNavigate } from "ui/lib/routes"; import { extractSchema } from "../settings/utils/schema"; diff --git a/app/src/ui/routes/auth/auth.strategies.tsx b/app/src/ui/routes/auth/auth.strategies.tsx index ebf18c0..2792767 100644 --- a/app/src/ui/routes/auth/auth.strategies.tsx +++ b/app/src/ui/routes/auth/auth.strategies.tsx @@ -1,9 +1,7 @@ import { cloneDeep, omit } from "lodash-es"; import { useBknd } from "ui/client/bknd"; import { Button } from "ui/components/buttons/Button"; -import { JsonSchemaForm } from "ui/components/form/json-schema/JsonSchemaForm"; import * as AppShell from "../../layouts/AppShell/AppShell"; -import { extractSchema } from "../settings/utils/schema"; export function AuthStrategiesList() { useBknd({ withSecrets: true }); 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/data/data.schema.$entity.tsx b/app/src/ui/routes/data/data.schema.$entity.tsx index 838851c..5d797c7 100644 --- a/app/src/ui/routes/data/data.schema.$entity.tsx +++ b/app/src/ui/routes/data/data.schema.$entity.tsx @@ -13,10 +13,7 @@ import { useBkndData } from "ui/client/schema/data/use-bknd-data"; import { Button } from "ui/components/buttons/Button"; import { IconButton } from "ui/components/buttons/IconButton"; import { Empty } from "ui/components/display/Empty"; -import { - JsonSchemaForm, - type JsonSchemaFormRef -} from "ui/components/form/json-schema/JsonSchemaForm"; +import { JsonSchemaForm, type JsonSchemaFormRef } from "ui/components/form/json-schema"; import { Dropdown } from "ui/components/overlay/Dropdown"; import * as AppShell from "ui/layouts/AppShell/AppShell"; import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2"; diff --git a/app/src/ui/routes/data/forms/entity.fields.form.tsx b/app/src/ui/routes/data/forms/entity.fields.form.tsx index 3f70848..bcc315d 100644 --- a/app/src/ui/routes/data/forms/entity.fields.form.tsx +++ b/app/src/ui/routes/data/forms/entity.fields.form.tsx @@ -22,7 +22,7 @@ import { Button } from "ui/components/buttons/Button"; import { IconButton } from "ui/components/buttons/IconButton"; import { JsonViewer } from "ui/components/code/JsonViewer"; import { MantineSwitch } from "ui/components/form/hook-form-mantine/MantineSwitch"; -import { JsonSchemaForm } from "ui/components/form/json-schema/JsonSchemaForm"; +import { JsonSchemaForm } from "ui/components/form/json-schema"; import { type SortableItemProps, SortableList } from "ui/components/list/SortableList"; import { Popover } from "ui/components/overlay/Popover"; import { fieldSpecs } from "ui/modules/data/components/fields-specs"; diff --git a/app/src/ui/routes/index.tsx b/app/src/ui/routes/index.tsx index a2c3771..aedfc58 100644 --- a/app/src/ui/routes/index.tsx +++ b/app/src/ui/routes/index.tsx @@ -1,14 +1,19 @@ import { Suspense, lazy } from "react"; import { useBknd } from "ui/client/bknd"; import { Route, Router, Switch } from "wouter"; +import AuthRoutes from "./auth"; import { AuthLogin } from "./auth/auth.login"; +import DataRoutes from "./data"; +import FlowRoutes from "./flows"; +import MediaRoutes from "./media"; import { Root, RootEmpty } from "./root"; +import SettingsRoutes from "./settings"; -const DataRoutes = lazy(() => import("./data")); +/*const DataRoutes = lazy(() => import("./data")); const AuthRoutes = lazy(() => import("./auth")); const MediaRoutes = lazy(() => import("./media")); const FlowRoutes = lazy(() => import("./flows")); -const SettingsRoutes = lazy(() => import("./settings")); +const SettingsRoutes = lazy(() => import("./settings"));*/ // @ts-ignore const TestRoutes = lazy(() => import("./test")); diff --git a/app/src/ui/routes/settings/components/Setting.tsx b/app/src/ui/routes/settings/components/Setting.tsx index 9c02c10..20a852b 100644 --- a/app/src/ui/routes/settings/components/Setting.tsx +++ b/app/src/ui/routes/settings/components/Setting.tsx @@ -8,10 +8,7 @@ import { Button } from "ui/components/buttons/Button"; import { IconButton } from "ui/components/buttons/IconButton"; import { Alert } from "ui/components/display/Alert"; import { Empty } from "ui/components/display/Empty"; -import { - JsonSchemaForm, - type JsonSchemaFormRef -} from "ui/components/form/json-schema/JsonSchemaForm"; +import { JsonSchemaForm, type JsonSchemaFormRef } from "ui/components/form/json-schema"; import { Dropdown } from "ui/components/overlay/Dropdown"; import { DataTable } from "ui/components/table/DataTable"; import { useEvent } from "ui/hooks/use-event"; diff --git a/app/src/ui/routes/settings/components/SettingNewModal.tsx b/app/src/ui/routes/settings/components/SettingNewModal.tsx index a8bc93c..5268f53 100644 --- a/app/src/ui/routes/settings/components/SettingNewModal.tsx +++ b/app/src/ui/routes/settings/components/SettingNewModal.tsx @@ -3,16 +3,13 @@ import type { TObject } from "core/utils"; import { omit } from "lodash-es"; import { useRef, useState } from "react"; import { TbCirclePlus, TbVariable } from "react-icons/tb"; +import { useBknd } from "ui/client/BkndProvider"; +import { Button } from "ui/components/buttons/Button"; +import * as Formy from "ui/components/form/Formy"; +import { JsonSchemaForm, type JsonSchemaFormRef } from "ui/components/form/json-schema"; +import { Dropdown } from "ui/components/overlay/Dropdown"; +import { Modal } from "ui/components/overlay/Modal"; import { useLocation } from "wouter"; -import { useBknd } from "../../../client/BkndProvider"; -import { Button } from "../../../components/buttons/Button"; -import * as Formy from "../../../components/form/Formy"; -import { - JsonSchemaForm, - type JsonSchemaFormRef -} from "../../../components/form/json-schema/JsonSchemaForm"; -import { Dropdown } from "../../../components/overlay/Dropdown"; -import { Modal } from "../../../components/overlay/Modal"; export type SettingsNewModalProps = { schema: TObject; diff --git a/app/src/ui/routes/test/tests/flow-create-schema-test.tsx b/app/src/ui/routes/test/tests/flow-create-schema-test.tsx index e27d170..2301240 100644 --- a/app/src/ui/routes/test/tests/flow-create-schema-test.tsx +++ b/app/src/ui/routes/test/tests/flow-create-schema-test.tsx @@ -2,7 +2,7 @@ import { parse } from "core/utils"; import { AppFlows } from "flows/AppFlows"; import { useState } from "react"; import { JsonViewer } from "../../../components/code/JsonViewer"; -import { JsonSchemaForm } from "../../../components/form/json-schema/JsonSchemaForm"; +import { JsonSchemaForm } from "../../../components/form/json-schema"; import { Scrollable } from "../../../layouts/AppShell/AppShell"; export default function FlowCreateSchemaTest() { diff --git a/app/src/ui/routes/test/tests/jsonform-test/index.tsx b/app/src/ui/routes/test/tests/jsonform-test/index.tsx index 2909887..dd6ec4c 100644 --- a/app/src/ui/routes/test/tests/jsonform-test/index.tsx +++ b/app/src/ui/routes/test/tests/jsonform-test/index.tsx @@ -2,12 +2,9 @@ import Form from "@rjsf/core"; import type { RJSFSchema, UiSchema } from "@rjsf/utils"; import { useRef } from "react"; import { TbPlus, TbTrash } from "react-icons/tb"; -import { Button } from "../../../../components/buttons/Button"; +import { Button } from "ui/components/buttons/Button"; +import { JsonSchemaForm, type JsonSchemaFormRef } from "ui/components/form/json-schema"; import * as Formy from "../../../../components/form/Formy"; -import { - JsonSchemaForm, - type JsonSchemaFormRef -} from "../../../../components/form/json-schema/JsonSchemaForm"; import * as AppShell from "../../../../layouts/AppShell/AppShell"; class CfJsonSchemaValidator {} diff --git a/app/src/ui/routes/test/tests/query-jsonform.tsx b/app/src/ui/routes/test/tests/query-jsonform.tsx index 204785d..41b63a6 100644 --- a/app/src/ui/routes/test/tests/query-jsonform.tsx +++ b/app/src/ui/routes/test/tests/query-jsonform.tsx @@ -1,7 +1,7 @@ import type { Schema } from "@cfworker/json-schema"; import { useState } from "react"; -import { JsonSchemaForm } from "../../../components/form/json-schema/JsonSchemaForm"; -import { Scrollable } from "../../../layouts/AppShell/AppShell"; +import { JsonSchemaForm } from "ui/components/form/json-schema"; +import { Scrollable } from "ui/layouts/AppShell/AppShell"; const schema: Schema = { definitions: { @@ -9,52 +9,52 @@ const schema: Schema = { anyOf: [ { title: "String", - type: "string", + type: "string" }, { title: "Number", - type: "number", + type: "number" }, { title: "Boolean", - type: "boolean", - }, - ], + type: "boolean" + } + ] }, numeric: { anyOf: [ { title: "Number", - type: "number", + type: "number" }, { title: "Datetime", type: "string", - format: "date-time", + format: "date-time" }, { title: "Date", type: "string", - format: "date", + format: "date" }, { title: "Time", type: "string", - format: "time", - }, - ], + format: "time" + } + ] }, boolean: { title: "Boolean", - type: "boolean", - }, + type: "boolean" + } }, type: "object", properties: { operand: { enum: ["$and", "$or"], default: "$and", - type: "string", + type: "string" }, conditions: { type: "array", @@ -64,10 +64,10 @@ const schema: Schema = { operand: { enum: ["$and", "$or"], default: "$and", - type: "string", + type: "string" }, key: { - type: "string", + type: "string" }, operator: { type: "array", @@ -78,30 +78,30 @@ const schema: Schema = { type: "object", properties: { $eq: { - $ref: "#/definitions/primitive", - }, + $ref: "#/definitions/primitive" + } }, - required: ["$eq"], + required: ["$eq"] }, { title: "Lower than", type: "object", properties: { $lt: { - $ref: "#/definitions/numeric", - }, + $ref: "#/definitions/numeric" + } }, - required: ["$lt"], + required: ["$lt"] }, { title: "Greather than", type: "object", properties: { $gt: { - $ref: "#/definitions/numeric", - }, + $ref: "#/definitions/numeric" + } }, - required: ["$gt"], + required: ["$gt"] }, { title: "Between", @@ -110,13 +110,13 @@ const schema: Schema = { $between: { type: "array", items: { - $ref: "#/definitions/numeric", + $ref: "#/definitions/numeric" }, minItems: 2, - maxItems: 2, - }, + maxItems: 2 + } }, - required: ["$between"], + required: ["$between"] }, { title: "In", @@ -125,23 +125,23 @@ const schema: Schema = { $in: { type: "array", items: { - $ref: "#/definitions/primitive", + $ref: "#/definitions/primitive" }, - minItems: 1, - }, - }, - }, - ], + minItems: 1 + } + } + } + ] }, - minItems: 1, - }, + minItems: 1 + } }, - required: ["key", "operator"], + required: ["key", "operator"] }, - minItems: 1, - }, + minItems: 1 + } }, - required: ["operand", "conditions"], + required: ["operand", "conditions"] }; export default function QueryJsonFormTest() { diff --git a/app/src/ui/routes/test/tests/schema-test.tsx b/app/src/ui/routes/test/tests/schema-test.tsx index 8865ed6..496fe70 100644 --- a/app/src/ui/routes/test/tests/schema-test.tsx +++ b/app/src/ui/routes/test/tests/schema-test.tsx @@ -1,8 +1,8 @@ import { useEffect, useState } from "react"; import { twMerge } from "tailwind-merge"; -import { useBknd } from "../../../client/BkndProvider"; -import { JsonSchemaForm } from "../../../components/form/json-schema/JsonSchemaForm"; -import { Scrollable } from "../../../layouts/AppShell/AppShell"; +import { useBknd } from "ui/client/BkndProvider"; +import { JsonSchemaForm } from "ui/components/form/json-schema"; +import { Scrollable } from "ui/layouts/AppShell/AppShell"; function useSchema() { const [schema, setSchema] = useState(); 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 53c632e..45807eb 100644 --- a/app/src/ui/routes/test/tests/swr-and-api.tsx +++ b/app/src/ui/routes/test/tests/swr-and-api.tsx @@ -1,7 +1,20 @@ import { useEffect, useState } from "react"; -import { useApiQuery } from "ui/client"; +import { useApi, useApiQuery } from "ui/client"; import { Scrollable } from "ui/layouts/AppShell/AppShell"; +function Bla() { + const api = useApi(); + + useEffect(() => { + (async () => { + const one = await api.data.readOne("users", 1); + const many = await api.data.readMany("users"); + })(); + }, []); + + return null; +} + export default function SWRAndAPI() { const [text, setText] = useState(""); const { data, ...r } = useApiQuery((api) => api.data.readOne("comments", 1), { @@ -16,7 +29,7 @@ export default function SWRAndAPI() { return ( -
{JSON.stringify(r.promise.keyArray({ search: false }))}
+
{JSON.stringify(r.key)}
{r.error &&
failed to load
} {r.isLoading &&
loading...
} {data &&
{JSON.stringify(data, null, 2)}
} @@ -26,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 7c2e2a6..ebc6d59 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 @@ -1,54 +1,72 @@ import { useEffect, useState } from "react"; -import { useEntity, useEntityQuery } from "ui/client/api/use-entity"; +import { useEntity, useEntityMutate, useEntityQuery } from "ui/client/api/use-entity"; import { Scrollable } from "ui/layouts/AppShell/AppShell"; export default function SwrAndDataApi() { return ( -
+ + asdf -
+ +
); } -function QueryDataApi() { - const [text, setText] = useState(""); - const { data, update, ...r } = useEntityQuery("comments", 1, {}); - const comment = data ? data : null; - - useEffect(() => { - setText(comment?.content ?? ""); - }, [comment]); +function QueryMutateDataApi() { + const { mutate } = useEntityMutate("comments"); + const { data, ...r } = useEntityQuery("comments", undefined, { + limit: 2 + }); return ( - +
+ bla
{JSON.stringify(r.key)}
{r.error &&
failed to load
} {r.isLoading &&
loading...
} {data &&
{JSON.stringify(data, null, 2)}
} {data && ( -
{ - e.preventDefault(); - if (!comment) return; - await update({ content: text }); - return false; - }} - > - setText(e.target.value)} /> - -
+
+ {data.map((comment) => ( + { + await mutate(comment.id, { content: e.target.value }); + }} + className="border border-black" + /> + ))} +
)} - +
+ ); +} + +function QueryDataApi() { + const { data, update, ...r } = useEntityQuery("comments", undefined, { + sort: { by: "id", dir: "asc" }, + limit: 3 + }); + + return ( +
+
{JSON.stringify(r.key)}
+ {r.error &&
failed to load
} + {r.isLoading &&
loading...
} + {data &&
{JSON.stringify(data, null, 2)}
} +
); } function DirectDataApi() { const [data, setData] = useState(); - const { create, read, update, _delete } = useEntity("comments", 1); + const { create, read, update, _delete } = useEntity("comments"); useEffect(() => { - read().then(setData); + read().then((data) => setData(data)); }, []); return
{JSON.stringify(data, null, 2)}
; diff --git a/app/tsconfig.json b/app/tsconfig.json index 764dacd..abc39f6 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -26,14 +26,13 @@ "esModuleInterop": true, "skipLibCheck": true, "rootDir": "./src", - "outDir": "./dist", - "tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo", + "outDir": "./dist/types", "baseUrl": ".", "paths": { "*": ["./src/*"], "bknd": ["./src/*"] } }, - "include": ["./src/**/*.ts", "./src/**/*.tsx", "./env.d.ts"], - "exclude": ["node_modules", "dist/**/*", "../examples/bun"] + "include": ["./src/**/*.ts", "./src/**/*.tsx"], + "exclude": ["node_modules", "dist", "dist/types", "**/*.d.ts"] } \ No newline at end of file diff --git a/bun.lockb b/bun.lockb index 26c6843..7b73841 100755 Binary files a/bun.lockb and b/bun.lockb differ