From 372f94d22a9a5d5c68b1877be2ee0baa225d6293 Mon Sep 17 00:00:00 2001 From: dswbx Date: Thu, 1 May 2025 10:12:18 +0200 Subject: [PATCH] Release 0.12 (#143) * changed tb imports * cleanup: replace console.log/warn with $console, remove commented-out code Removed various commented-out code and replaced direct `console.log` and `console.warn` usage across the codebase with `$console` from "core" for standardized logging. Also adjusted linting rules in biome.json to enable warnings for `console.log` usage. * ts: enable incremental * fix imports in test files reorganize imports to use "@sinclair/typebox" directly, replacing local utility references, and add missing "override" keywords in test classes. * added media permissions (#142) * added permissions support for media module introduced `MediaPermissions` for fine-grained access control in the media module, updated routes to enforce these permissions, and adjusted permission registration logic. * fix: handle token absence in getUploadHeaders and add tests for transport modes ensure getUploadHeaders does not set Authorization header when token is missing. Add unit tests to validate behavior for different token_transport options. * remove console.log on DropzoneContainer.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * add bcrypt and refactored auth resolve (#147) * reworked auth architecture with improved password handling and claims Refactored password strategy to prepare supporting bcrypt, improving hashing/encryption flexibility. Updated authentication flow with enhanced user resolution mechanisms, safe JWT generation, and consistent profile handling. Adjusted dependencies to include bcryptjs and updated lock files accordingly. * fix strategy forms handling, add register route and hidden fields Refactored strategy forms to include hidden fields for type and name. Added a registration route with necessary adjustments to the admin controller and routes. Corrected field handling within relevant forms and components. * refactored auth handling to support bcrypt, extracted user pool * update email regex to allow '+' and '_' characters * update test stub password for AppAuth spec * update data exceptions to use HttpStatus constants, adjust logging level in AppUserPool * rework strategies to extend a base class instead of interface * added simple bcrypt test * add validation logs and improve data validation handling (#157) Added warning logs for invalid data during mutator validation, refined field validation logic to handle undefined values, and adjusted event validation comments for clarity. Minor improvements include exporting events from core and handling optional chaining in entity field validation. * modify MediaApi to support custom fetch implementation, defaults to native fetch (#158) * modify MediaApi to support custom fetch implementation, defaults to native fetch added an optional `fetcher` parameter to allow usage of a custom fetch function in both `upload` and `fetcher` methods. Defaults to the standard `fetch` if none is provided. * fix tests and improve api fetcher types * update admin basepath handling and window context integration (#155) Refactored `useBkndWindowContext` to include `admin_basepath` and updated its usage in routing. Improved type consistency with `AdminBkndWindowContext` and ensured default values are applied for window context. * trigger `repository-find-[one|many]-[before|after]` based on `limit` (#160) * refactor error handling in authenticator and password strategy (#161) made `respondWithError` method public, updated login and register routes in `PasswordStrategy` to handle errors using `respondWithError` for consistency. * add disableSubmitOnError prop to NativeForm and export getFlashMessage (#162) Introduced a `disableSubmitOnError` prop to NativeForm to control submit button behavior when errors are present. Also exported `getFlashMessage` from the core for external usage. * update dependencies in package.json (#156) moved several dependencies between devDependencies and dependencies for better categorization and removed redundant entries. * update imports to adjust nodeTestRunner path and remove unused export (#163) updated imports in test files to reflect the correct path for nodeTestRunner. removed redundant export of nodeTestRunner from index file to clean up module structure. In some environments this could cause issues requiring to exclude `node:test`, just removing it for now. * fix sync events not awaited (#164) * refactor(dropzone): extract DropzoneInner and unify state management with zustand (#165) Simplified Dropzone implementation by extracting inner logic to a new component, `DropzoneInner`. Replaced local dropzone state logic with centralized state management using zustand. Adjusted API exports and props accordingly for consistency and maintainability. * replace LiquidJs rendering with simplified renderer (#167) * replace LiquidJs rendering with simplified renderer Removed dependency on LiquidJS and replaced it with a custom templating solution using lodash `get`. Updated corresponding components, editors, and tests to align with the new rendering approach. Removed unused filters and tags. * remove liquid js from package json * feat/cli-generate-types (#166) * init types generation * update type generation for entities and fields Refactored `EntityTypescript` to support improved field types and relations. Added `toType` method overrides for various fields to define accurate TypeScript types. Enhanced CLI `types` command with new options for output style and file handling. Removed redundant test files. * update type generation code and CLI option description removed unused imports definition, adjusted formatting in EntityTypescript, and clarified the CLI style option description. * fix json schema field type generation * reworked system entities to prevent recursive types * reworked system entities to prevent recursive types * remove unused object function * types: use number instead of Generated * update data hooks and api types * update data hooks and api types * update data hooks and api types * update data hooks and api types --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- app/__test__/api/MediaApi.spec.ts | 30 +- app/__test__/core/Registry.spec.ts | 5 +- app/__test__/core/object/SchemaObject.spec.ts | 2 +- app/__test__/data/specs/Entity.spec.ts | 16 +- app/__test__/data/specs/EntityManager.spec.ts | 4 +- app/__test__/data/specs/Repository.spec.ts | 7 + .../data/specs/fields/FieldIndex.spec.ts | 2 +- app/__test__/flows/SubWorkflowTask.spec.ts | 63 +-- app/__test__/flows/Task.spec.ts | 60 +-- app/__test__/flows/inputs.test.ts | 3 +- app/__test__/flows/workflow-basic.test.ts | 3 +- app/__test__/modules/AppAuth.spec.ts | 61 ++- app/__test__/modules/Module.spec.ts | 7 +- app/__test__/modules/ModuleManager.spec.ts | 11 +- app/package.json | 12 +- app/src/Api.ts | 9 +- app/src/App.ts | 2 +- .../cloudflare/cloudflare-workers.adapter.ts | 3 +- app/src/adapter/cloudflare/config.ts | 5 +- app/src/adapter/cloudflare/modes/durable.ts | 3 +- app/src/adapter/cloudflare/modes/fresh.ts | 2 +- .../storage/StorageR2Adapter.native-spec.ts | 2 +- .../cloudflare/storage/StorageR2Adapter.ts | 6 +- app/src/adapter/node/index.ts | 1 - .../adapter/node/node.adapter.native-spec.ts | 2 +- app/src/adapter/node/node.adapter.ts | 3 +- .../StorageLocalAdapter.native-spec.ts | 3 +- .../node/storage/StorageLocalAdapter.ts | 4 +- app/src/auth/AppAuth.ts | 151 +----- app/src/auth/AppUserPool.ts | 83 ++++ app/src/auth/api/AuthController.ts | 4 +- app/src/auth/auth-schema.ts | 4 +- app/src/auth/authenticate/Authenticator.ts | 216 +++++--- .../strategies/PasswordStrategy.ts | 245 +++++---- .../auth/authenticate/strategies/Strategy.ts | 63 +++ app/src/auth/authenticate/strategies/index.ts | 2 +- .../strategies/oauth/CustomOAuthStrategy.ts | 55 +-- .../strategies/oauth/OAuthStrategy.ts | 131 ++--- .../strategies/oauth/issuers/github.ts | 3 - app/src/auth/authorize/Guard.ts | 42 +- app/src/auth/errors.ts | 52 +- app/src/auth/index.ts | 1 - app/src/auth/utils/hash.ts | 13 - app/src/cli/commands/config.ts | 2 + app/src/cli/commands/copy-assets.ts | 1 + app/src/cli/commands/create/create.ts | 31 +- app/src/cli/commands/debug.ts | 2 + app/src/cli/commands/index.ts | 1 + app/src/cli/commands/run/platform.ts | 7 +- app/src/cli/commands/schema.ts | 1 + app/src/cli/commands/types/index.ts | 1 + app/src/cli/commands/types/types.ts | 37 ++ app/src/cli/commands/user.ts | 1 + app/src/cli/utils/sys.ts | 13 +- app/src/core/clients/aws/AwsClient.ts | 3 - app/src/core/config.ts | 6 +- app/src/core/errors.ts | 7 +- app/src/core/events/Event.ts | 4 + app/src/core/events/EventManager.ts | 12 +- app/src/core/index.ts | 4 +- app/src/core/object/SchemaObject.ts | 14 +- app/src/core/template/SimpleRenderer.spec.ts | 70 +++ app/src/core/template/SimpleRenderer.ts | 59 +-- app/src/core/utils/DebugLogger.ts | 4 +- app/src/core/utils/dates.ts | 1 - app/src/core/utils/file.ts | 5 +- app/src/core/utils/objects.ts | 47 ++ app/src/core/utils/reqres.ts | 22 +- app/src/core/utils/strings.ts | 5 + app/src/core/utils/typebox/index.ts | 107 ++-- app/src/data/api/DataApi.ts | 57 ++- app/src/data/api/DataController.ts | 17 +- .../connection/sqlite/LibsqlConnection.ts | 2 +- app/src/data/data-schema.ts | 51 +- app/src/data/entities/Entity.ts | 31 +- app/src/data/entities/EntityManager.ts | 9 +- app/src/data/entities/EntityTypescript.ts | 228 +++++++++ app/src/data/entities/Mutator.ts | 5 +- app/src/data/entities/query/Repository.ts | 54 +- app/src/data/errors.ts | 16 +- app/src/data/events/index.ts | 15 +- app/src/data/fields/BooleanField.ts | 12 +- app/src/data/fields/DateField.ts | 26 +- app/src/data/fields/EnumField.ts | 25 +- app/src/data/fields/Field.ts | 18 +- app/src/data/fields/JsonField.ts | 13 +- app/src/data/fields/JsonSchemaField.ts | 24 +- app/src/data/fields/NumberField.ts | 12 +- app/src/data/fields/PrimaryField.ts | 14 +- app/src/data/fields/TextField.ts | 11 +- app/src/data/fields/VirtualField.ts | 4 +- app/src/data/fields/field-test-suite.ts | 9 - app/src/data/fields/indices/EntityIndex.ts | 1 - app/src/data/helper.ts | 12 - app/src/data/relations/EntityRelation.ts | 23 +- app/src/data/relations/ManyToManyRelation.ts | 9 +- app/src/data/relations/ManyToOneRelation.ts | 7 +- app/src/data/relations/PolymorphicRelation.ts | 4 +- app/src/data/relations/RelationField.ts | 29 +- app/src/data/relations/RelationMutator.ts | 1 - app/src/data/server/data-query-impl.ts | 12 +- app/src/data/test-types.ts | 78 --- app/src/flows/AppFlows.ts | 1 - app/src/flows/flows-schema.ts | 4 +- app/src/flows/flows/Execution.ts | 4 +- app/src/flows/flows/Flow.ts | 14 +- app/src/flows/flows/FlowTaskConnector.ts | 34 -- .../flows/flows/executors/RuntimeExecutor.ts | 4 +- app/src/flows/flows/triggers/EventTrigger.ts | 10 +- app/src/flows/flows/triggers/HttpTrigger.ts | 19 +- app/src/flows/flows/triggers/Trigger.ts | 13 +- app/src/flows/flows/triggers/index.ts | 1 - app/src/flows/index.ts | 6 +- app/src/flows/tasks/Task.tsx | 42 +- app/src/flows/tasks/TaskConnection.ts | 1 - app/src/flows/tasks/presets/FetchTask.ts | 17 +- app/src/flows/tasks/presets/LogTask.ts | 6 +- app/src/flows/tasks/presets/RenderTask.ts | 3 +- app/src/flows/tasks/presets/SubFlowTask.ts | 3 +- app/src/media/AppMedia.ts | 15 +- app/src/media/MediaField.ts | 4 +- app/src/media/api/MediaApi.ts | 22 +- app/src/media/api/MediaController.ts | 18 +- app/src/media/index.ts | 2 +- app/src/media/media-permissions.ts | 6 + app/src/media/media-schema.ts | 4 +- .../cloudinary/StorageCloudinaryAdapter.ts | 4 +- .../storage/adapters/s3/StorageS3Adapter.ts | 4 +- app/src/modules/ModuleApi.ts | 14 +- app/src/modules/ModuleManager.ts | 5 +- app/src/modules/middlewares.ts | 1 - app/src/modules/migrations.ts | 11 +- app/src/modules/server/AdminController.tsx | 18 +- app/src/modules/server/AppServer.ts | 16 +- app/src/modules/server/SystemController.ts | 5 +- app/src/modules/server/openapi.ts | 3 +- app/src/ui/Admin.tsx | 1 - app/src/ui/client/ClientProvider.tsx | 19 +- app/src/ui/client/api/use-entity.ts | 105 ++-- .../ui/client/schema/data/use-bknd-data.ts | 4 +- app/src/ui/components/canvas/panels/index.tsx | 18 +- app/src/ui/components/code/CodeEditor.tsx | 7 +- app/src/ui/components/code/HtmlEditor.tsx | 23 + app/src/ui/components/code/LiquidJsEditor.tsx | 125 ----- .../{LiquidJsField.tsx => HtmlField.tsx} | 6 +- .../form/json-schema/fields/index.ts | 4 +- .../templates/BaseInputTemplate.tsx | 6 +- .../form/native-form/NativeForm.tsx | 40 +- app/src/ui/elements/auth/AuthForm.tsx | 3 +- app/src/ui/elements/media/Dropzone.tsx | 466 ++++++------------ .../ui/elements/media/DropzoneContainer.tsx | 52 +- app/src/ui/elements/media/DropzoneInner.tsx | 276 +++++++++++ app/src/ui/elements/media/dropzone-state.ts | 42 ++ app/src/ui/elements/media/helper.spec.ts | 63 +++ app/src/ui/elements/media/helper.ts | 23 + app/src/ui/elements/media/index.ts | 24 +- app/src/ui/hooks/use-render-count.ts | 7 + app/src/ui/hooks/use-search.ts | 1 - .../ui/modules/data/components/EntityForm.tsx | 3 + .../components/canvas/EntityTableNode.tsx | 20 +- .../schema/create-modal/CreateModal.tsx | 4 +- .../schema/create-modal/step.relation.tsx | 12 +- .../media/template.media.component.tsx | 11 +- .../flows/components/TriggerComponent.tsx | 4 +- .../components/tasks/RenderTaskComponent.tsx | 2 +- .../components2/nodes/tasks/FetchTaskNode.tsx | 3 +- .../components2/nodes/tasks/RenderNode.tsx | 4 +- .../nodes/triggers/TriggerNode.tsx | 17 +- app/src/ui/routes/auth/auth.settings.tsx | 2 +- app/src/ui/routes/data/data.$entity.$id.tsx | 1 - .../ui/routes/data/data.$entity.create.tsx | 3 +- app/src/ui/routes/data/data.$entity.index.tsx | 3 +- .../routes/data/forms/entity.fields.form.tsx | 3 +- .../flows/components/FlowCreateModal.tsx | 10 +- app/src/ui/routes/index.tsx | 5 +- app/src/ui/routes/media/media.index.tsx | 2 +- .../routes/settings/routes/flows.settings.tsx | 2 +- .../test/tests/appshell-accordions-test.tsx | 22 +- .../test/tests/dropzone-element-test.tsx | 73 ++- .../ui/routes/test/tests/liquid-js-test.tsx | 4 +- .../ui/routes/test/tests/swr-and-data-api.tsx | 34 +- app/tsconfig.json | 1 + app/vite.dev.ts | 6 +- biome.json | 3 +- bun.lock | 12 +- docs/usage/elements.mdx | 7 +- 186 files changed, 2617 insertions(+), 1997 deletions(-) create mode 100644 app/src/auth/AppUserPool.ts create mode 100644 app/src/auth/authenticate/strategies/Strategy.ts delete mode 100644 app/src/auth/utils/hash.ts create mode 100644 app/src/cli/commands/types/index.ts create mode 100644 app/src/cli/commands/types/types.ts create mode 100644 app/src/core/template/SimpleRenderer.spec.ts create mode 100644 app/src/data/entities/EntityTypescript.ts delete mode 100644 app/src/data/test-types.ts create mode 100644 app/src/media/media-permissions.ts delete mode 100644 app/src/modules/middlewares.ts create mode 100644 app/src/ui/components/code/HtmlEditor.tsx delete mode 100644 app/src/ui/components/code/LiquidJsEditor.tsx rename app/src/ui/components/form/json-schema/fields/{LiquidJsField.tsx => HtmlField.tsx} (71%) create mode 100644 app/src/ui/elements/media/DropzoneInner.tsx create mode 100644 app/src/ui/elements/media/dropzone-state.ts create mode 100644 app/src/ui/elements/media/helper.spec.ts create mode 100644 app/src/ui/hooks/use-render-count.ts diff --git a/app/__test__/api/MediaApi.spec.ts b/app/__test__/api/MediaApi.spec.ts index a479d19..95cac8e 100644 --- a/app/__test__/api/MediaApi.spec.ts +++ b/app/__test__/api/MediaApi.spec.ts @@ -1,8 +1,8 @@ /// import { describe, expect, it } from "bun:test"; import { Hono } from "hono"; -import { getFileFromContext, isFile, isReadableStream } from "../../src/core/utils"; -import { MediaApi } from "../../src/media/api/MediaApi"; +import { getFileFromContext, isFile, isReadableStream } from "core/utils"; +import { MediaApi } from "media/api/MediaApi"; import { assetsPath, assetsTmpPath } from "../helper"; const mockedBackend = new Hono() @@ -39,10 +39,28 @@ describe("MediaApi", () => { // @ts-ignore tests const api = new MediaApi({ token: "token", + token_transport: "header", }); expect(api.getUploadHeaders().get("Authorization")).toBe("Bearer token"); }); + it("should return empty headers if not using `header` transport", () => { + expect( + new MediaApi({ + token_transport: "cookie", + }) + .getUploadHeaders() + .has("Authorization"), + ).toBe(false); + expect( + new MediaApi({ + token_transport: "none", + }) + .getUploadHeaders() + .has("Authorization"), + ).toBe(false); + }); + it("should get file: native", async () => { const name = "image.png"; const path = `${assetsTmpPath}/${name}`; @@ -103,8 +121,12 @@ describe("MediaApi", () => { }); it("should upload file in various ways", async () => { - // @ts-ignore tests - const api = new MediaApi({}, mockedBackend.request); + const api = new MediaApi( + { + upload_fetcher: mockedBackend.request, + }, + mockedBackend.request, + ); const file = Bun.file(`${assetsPath}/image.png`); async function matches(req: Promise, filename: string) { diff --git a/app/__test__/core/Registry.spec.ts b/app/__test__/core/Registry.spec.ts index 637a836..2a11250 100644 --- a/app/__test__/core/Registry.spec.ts +++ b/app/__test__/core/Registry.spec.ts @@ -1,7 +1,6 @@ import { describe, expect, test } from "bun:test"; -import type { TObject, TString } from "@sinclair/typebox"; -import { Registry } from "../../src/core/registry/Registry"; -import { type TSchema, Type } from "../../src/core/utils"; +import { type TObject, type TString, Type } from "@sinclair/typebox"; +import { Registry } from "core"; type Constructor = new (...args: any[]) => T; diff --git a/app/__test__/core/object/SchemaObject.spec.ts b/app/__test__/core/object/SchemaObject.spec.ts index 0c745e4..580ab57 100644 --- a/app/__test__/core/object/SchemaObject.spec.ts +++ b/app/__test__/core/object/SchemaObject.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { SchemaObject } from "../../../src/core"; -import { Type } from "../../../src/core/utils"; +import { Type } from "@sinclair/typebox"; describe("SchemaObject", async () => { test("basic", async () => { diff --git a/app/__test__/data/specs/Entity.spec.ts b/app/__test__/data/specs/Entity.spec.ts index 358c106..033d10b 100644 --- a/app/__test__/data/specs/Entity.spec.ts +++ b/app/__test__/data/specs/Entity.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { Entity, NumberField, TextField } from "../../../src/data"; +import { Entity, NumberField, TextField } from "data"; +import * as p from "data/prototype"; describe("[data] Entity", async () => { const entity = new Entity("test", [ @@ -47,14 +48,7 @@ describe("[data] Entity", async () => { expect(entity.getField("new_field")).toBe(field); }); - // @todo: move this to ClientApp - /*test("serialize and deserialize", async () => { - const json = entity.toJSON(); - //sconsole.log("json", json.fields); - const newEntity = Entity.deserialize(json); - //console.log("newEntity", newEntity.toJSON().fields); - expect(newEntity).toBeInstanceOf(Entity); - expect(json).toEqual(newEntity.toJSON()); - expect(json.fields).toEqual(newEntity.toJSON().fields); - });*/ + test.only("types", async () => { + console.log(entity.toTypes()); + }); }); diff --git a/app/__test__/data/specs/EntityManager.spec.ts b/app/__test__/data/specs/EntityManager.spec.ts index c9b5dba..6401995 100644 --- a/app/__test__/data/specs/EntityManager.spec.ts +++ b/app/__test__/data/specs/EntityManager.spec.ts @@ -47,8 +47,8 @@ describe("[data] EntityManager", async () => { em.addRelation(new ManyToOneRelation(posts, users)); expect(em.relations.all.length).toBe(1); expect(em.relations.all[0]).toBeInstanceOf(ManyToOneRelation); - expect(em.relationsOf("users")).toEqual([em.relations.all[0]]); - expect(em.relationsOf("posts")).toEqual([em.relations.all[0]]); + expect(em.relationsOf("users")).toEqual([em.relations.all[0]!]); + expect(em.relationsOf("posts")).toEqual([em.relations.all[0]!]); expect(em.hasRelations("users")).toBe(true); expect(em.hasRelations("posts")).toBe(true); expect(em.relatedEntitiesOf("users")).toEqual([posts]); diff --git a/app/__test__/data/specs/Repository.spec.ts b/app/__test__/data/specs/Repository.spec.ts index f50ca83..54254d4 100644 --- a/app/__test__/data/specs/Repository.spec.ts +++ b/app/__test__/data/specs/Repository.spec.ts @@ -266,5 +266,12 @@ describe("[data] Repository (Events)", async () => { expect(events.has(RepositoryEvents.RepositoryFindManyBefore.slug)).toBeTrue(); expect(events.has(RepositoryEvents.RepositoryFindManyAfter.slug)).toBeTrue(); events.clear(); + + // check find one on findMany with limit 1 + await repo.findMany({ where: { id: 1 }, limit: 1 }); + await repo.emgr.executeAsyncs(); + expect(events.has(RepositoryEvents.RepositoryFindOneBefore.slug)).toBeTrue(); + expect(events.has(RepositoryEvents.RepositoryFindOneAfter.slug)).toBeTrue(); + events.clear(); }); }); diff --git a/app/__test__/data/specs/fields/FieldIndex.spec.ts b/app/__test__/data/specs/fields/FieldIndex.spec.ts index 0dd656c..1337f63 100644 --- a/app/__test__/data/specs/fields/FieldIndex.spec.ts +++ b/app/__test__/data/specs/fields/FieldIndex.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { Type } from "../../../../src/core/utils"; +import { Type } from "@sinclair/typebox"; import { Entity, EntityIndex, Field } from "../../../../src/data"; class TestField extends Field { diff --git a/app/__test__/flows/SubWorkflowTask.spec.ts b/app/__test__/flows/SubWorkflowTask.spec.ts index c52a0a2..e43473a 100644 --- a/app/__test__/flows/SubWorkflowTask.spec.ts +++ b/app/__test__/flows/SubWorkflowTask.spec.ts @@ -1,5 +1,23 @@ import { describe, expect, test } from "bun:test"; -import { Flow, LogTask, RenderTask, SubFlowTask } from "../../src/flows"; +import { Flow, LogTask, SubFlowTask, RenderTask, Task } from "../../src/flows"; +import { Type } from "@sinclair/typebox"; + +export class StringifyTask extends Task< + typeof StringifyTask.schema, + Output +> { + type = "stringify"; + + static override schema = Type.Optional( + Type.Object({ + input: Type.Optional(Type.String()), + }), + ); + + async execute() { + return JSON.stringify(this.params.input) as Output; + } +} describe("SubFlowTask", async () => { test("Simple Subflow", async () => { @@ -22,8 +40,6 @@ describe("SubFlowTask", async () => { const execution = flow.createExecution(); await execution.start(); - /*console.log(execution.logs); - console.log(execution.getResponse());*/ expect(execution.getResponse()).toEqual("Subflow output: subflow"); }); @@ -40,8 +56,8 @@ describe("SubFlowTask", async () => { loop: true, input: [1, 2, 3], }); - const task3 = new RenderTask("render2", { - render: `Subflow output: {{ sub.output | join: ", " }}`, + const task3 = new StringifyTask("stringify", { + input: "{{ sub.output }}", }); const flow = new Flow("test", [task, task2, task3], []); @@ -51,41 +67,6 @@ describe("SubFlowTask", async () => { const execution = flow.createExecution(); await execution.start(); - console.log("errors", execution.getErrors()); - - /*console.log(execution.logs); - console.log(execution.getResponse());*/ - - expect(execution.getResponse()).toEqual("Subflow output: run 1, run 2, run 3"); - }); - - test("Simple loop from flow input", async () => { - const subTask = new RenderTask("render", { - render: "run {{ flow.output }}", - }); - - const subflow = new Flow("subflow", [subTask]); - - const task = new LogTask("log"); - const task2 = new SubFlowTask("sub", { - flow: subflow, - loop: true, - input: "{{ flow.output | json }}", - }); - const task3 = new RenderTask("render2", { - render: `Subflow output: {{ sub.output | join: ", " }}`, - }); - - const flow = new Flow("test", [task, task2, task3], []); - flow.task(task).asInputFor(task2); - flow.task(task2).asInputFor(task3); - - const execution = flow.createExecution(); - await execution.start([4, 5, 6]); - - /*console.log(execution.logs); - console.log(execution.getResponse());*/ - - expect(execution.getResponse()).toEqual("Subflow output: run 4, run 5, run 6"); + expect(execution.getResponse()).toEqual('"run 1,run 2,run 3"'); }); }); diff --git a/app/__test__/flows/Task.spec.ts b/app/__test__/flows/Task.spec.ts index 2016478..4acbe78 100644 --- a/app/__test__/flows/Task.spec.ts +++ b/app/__test__/flows/Task.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { Type } from "../../src/core/utils"; +import { Type } from "@sinclair/typebox"; import { Task } from "../../src/flows"; import { dynamic } from "../../src/flows/tasks/Task"; @@ -51,62 +51,4 @@ describe("Task", async () => { expect(result.test).toEqual({ key: "path", value: "1/1" }); }); - - test("resolveParams: with json", async () => { - const result = await Task.resolveParams( - Type.Object({ - test: dynamic(Type.Object({ key: Type.String(), value: Type.String() })), - }), - { - test: "{{ some | json }}", - }, - { - some: { - key: "path", - value: "1/1", - }, - }, - ); - - expect(result.test).toEqual({ key: "path", value: "1/1" }); - }); - - test("resolveParams: with array", async () => { - const result = await Task.resolveParams( - Type.Object({ - test: dynamic(Type.Array(Type.String())), - }), - { - test: '{{ "1,2,3" | split: "," | json }}', - }, - ); - - expect(result.test).toEqual(["1", "2", "3"]); - }); - - test("resolveParams: boolean", async () => { - const result = await Task.resolveParams( - Type.Object({ - test: dynamic(Type.Boolean()), - }), - { - test: "{{ true }}", - }, - ); - - expect(result.test).toEqual(true); - }); - - test("resolveParams: float", async () => { - const result = await Task.resolveParams( - Type.Object({ - test: dynamic(Type.Number(), Number.parseFloat), - }), - { - test: "{{ 3.14 }}", - }, - ); - - expect(result.test).toEqual(3.14); - }); }); diff --git a/app/__test__/flows/inputs.test.ts b/app/__test__/flows/inputs.test.ts index 25a1c82..d1801f4 100644 --- a/app/__test__/flows/inputs.test.ts +++ b/app/__test__/flows/inputs.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test } from "bun:test"; import { Hono } from "hono"; import { Event, EventManager } from "../../src/core/events"; -import { type Static, type StaticDecode, Type, parse } from "../../src/core/utils"; +import { parse } from "../../src/core/utils"; +import { type Static, type StaticDecode, Type } from "@sinclair/typebox"; import { EventTrigger, Flow, HttpTrigger, type InputsMap, Task } from "../../src/flows"; import { dynamic } from "../../src/flows/tasks/Task"; diff --git a/app/__test__/flows/workflow-basic.test.ts b/app/__test__/flows/workflow-basic.test.ts index 56579f5..9df12e5 100644 --- a/app/__test__/flows/workflow-basic.test.ts +++ b/app/__test__/flows/workflow-basic.test.ts @@ -1,7 +1,8 @@ // eslint-disable-next-line import/no-unresolved import { describe, expect, test } from "bun:test"; import { isEqual } from "lodash-es"; -import { type Static, Type, _jsonp, withDisabledConsole } from "../../src/core/utils"; +import { _jsonp, withDisabledConsole } from "../../src/core/utils"; +import { type Static, Type } from "@sinclair/typebox"; import { Condition, ExecutionEvent, FetchTask, Flow, LogTask, Task } from "../../src/flows"; /*beforeAll(disableConsoleLog); diff --git a/app/__test__/modules/AppAuth.spec.ts b/app/__test__/modules/AppAuth.spec.ts index 9091fbb..ede12f6 100644 --- a/app/__test__/modules/AppAuth.spec.ts +++ b/app/__test__/modules/AppAuth.spec.ts @@ -69,7 +69,7 @@ describe("AppAuth", () => { }, body: JSON.stringify({ email: "some@body.com", - password: "123456", + password: "12345678", }), }); enableConsoleLog(); @@ -81,6 +81,65 @@ describe("AppAuth", () => { } }); + test("creates user on register (bcrypt)", async () => { + const auth = new AppAuth( + { + enabled: true, + strategies: { + password: { + type: "password", + config: { + hashing: "bcrypt", + }, + }, + }, + // @ts-ignore + jwt: { + secret: "123456", + }, + }, + ctx, + ); + + await auth.build(); + await ctx.em.schema().sync({ force: true }); + + // expect no users, but the query to pass + const res = await ctx.em.repository("users").findMany(); + expect(res.data.length).toBe(0); + + const app = new AuthController(auth).getController(); + + { + disableConsoleLog(); + const res = await app.request("/password/register", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: "some@body.com", + password: "12345678", + }), + }); + enableConsoleLog(); + expect(res.status).toBe(200); + + const { data: users } = await ctx.em.repository("users").findMany(); + expect(users.length).toBe(1); + expect(users[0]?.email).toBe("some@body.com"); + } + + { + // check user in database + const rawUser = await ctx.connection.kysely + .selectFrom("users") + .selectAll() + .executeTakeFirstOrThrow(); + expect(rawUser.strategy_value).toStartWith("$"); + } + }); + test("registers auth middleware for bknd routes only", async () => { const app = createApp({ initialConfig: { diff --git a/app/__test__/modules/Module.spec.ts b/app/__test__/modules/Module.spec.ts index bcb6aa8..8cca811 100644 --- a/app/__test__/modules/Module.spec.ts +++ b/app/__test__/modules/Module.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { type TSchema, Type, stripMark } from "../../src/core/utils"; +import { stripMark } from "../../src/core/utils"; +import { type TSchema, Type } from "@sinclair/typebox"; import { EntityManager, em, entity, index, text } from "../../src/data"; import { DummyConnection } from "../../src/data/connection/DummyConnection"; import { Module } from "../../src/modules/Module"; @@ -9,10 +10,10 @@ function createModule(schema: Schema) { getSchema() { return schema; } - toJSON() { + override toJSON() { return this.config; } - useForceParse() { + override useForceParse() { return true; } } diff --git a/app/__test__/modules/ModuleManager.spec.ts b/app/__test__/modules/ModuleManager.spec.ts index 461d11e..0ea0194 100644 --- a/app/__test__/modules/ModuleManager.spec.ts +++ b/app/__test__/modules/ModuleManager.spec.ts @@ -1,9 +1,10 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; -import { disableConsoleLog, enableConsoleLog, stripMark, Type } from "../../src/core/utils"; -import { Connection, entity, text } from "../../src/data"; -import { Module } from "../../src/modules/Module"; -import { type ConfigTable, getDefaultConfig, ModuleManager } from "../../src/modules/ModuleManager"; -import { CURRENT_VERSION, TABLE_NAME } from "../../src/modules/migrations"; +import { disableConsoleLog, enableConsoleLog, stripMark } from "core/utils"; +import { Type } from "@sinclair/typebox"; +import { Connection, entity, text } from "data"; +import { Module } from "modules/Module"; +import { type ConfigTable, getDefaultConfig, ModuleManager } from "modules/ModuleManager"; +import { CURRENT_VERSION, TABLE_NAME } from "modules/migrations"; import { getDummyConnection } from "../helper"; import { diff } from "core/object/diff"; import type { Static } from "@sinclair/typebox"; diff --git a/app/package.json b/app/package.json index fc7aa99..0101d93 100644 --- a/app/package.json +++ b/app/package.json @@ -14,7 +14,7 @@ "url": "https://github.com/bknd-io/bknd/issues" }, "scripts": { - "dev": "vite", + "dev": "BKND_CLI_LOG_LEVEL=debug vite", "build": "NODE_ENV=production bun run build.ts --minify --types", "build:all": "rm -rf dist && bun run build:static && NODE_ENV=production bun run build.ts --minify --types --clean && bun run build:cli", "build:ci": "mkdir -p dist/static/.vite && echo '{}' > dist/static/.vite/manifest.json && NODE_ENV=production bun run build.ts", @@ -48,16 +48,15 @@ "@cfworker/json-schema": "^4.1.1", "@codemirror/lang-html": "^6.4.9", "@codemirror/lang-json": "^6.0.1", - "@codemirror/lang-liquid": "^6.2.2", "@hello-pangea/dnd": "^18.0.1", "@libsql/client": "^0.15.2", "@mantine/core": "^7.17.1", "@mantine/hooks": "^7.17.1", - "@sinclair/typebox": "^0.34.30", "@tanstack/react-form": "^1.0.5", "@uiw/react-codemirror": "^4.23.10", "@xyflow/react": "^12.4.4", "aws4fetch": "^1.0.20", + "bcryptjs": "^3.0.2", "dayjs": "^1.11.13", "fast-xml-parser": "^5.0.8", "hono": "^4.7.4", @@ -65,14 +64,12 @@ "json-schema-library": "10.0.0-rc7", "json-schema-to-ts": "^3.1.1", "kysely": "^0.27.6", - "liquidjs": "^10.21.0", - "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", "object-path-immutable": "^4.1.2", - "picocolors": "^1.1.1", "radix-ui": "^1.1.3", "swr": "^2.3.3", - "wrangler": "^4.4.1" + "lodash-es": "^4.17.21", + "@sinclair/typebox": "0.34.30" }, "devDependencies": { "@aws-sdk/client-s3": "^3.758.0", @@ -108,6 +105,7 @@ "postcss-preset-mantine": "^1.17.0", "postcss-simple-vars": "^7.0.1", "posthog-js-lite": "^3.4.2", + "picocolors": "^1.1.1", "react": "^19.0.0", "react-dom": "^19.0.0", "react-hook-form": "^7.54.2", diff --git a/app/src/Api.ts b/app/src/Api.ts index 593979e..3b356f6 100644 --- a/app/src/Api.ts +++ b/app/src/Api.ts @@ -8,6 +8,11 @@ import { omitKeys } from "core/utils"; export type TApiUser = SafeUser; +export type ApiFetcher = ( + input: RequestInfo | URL, + init?: RequestInit, +) => Response | Promise; + declare global { interface Window { __BKND__: { @@ -21,7 +26,7 @@ export type ApiOptions = { headers?: Headers; key?: string; localStorage?: boolean; - fetcher?: typeof fetch; + fetcher?: ApiFetcher; verbose?: boolean; verified?: boolean; } & ( @@ -117,8 +122,6 @@ export class Api { this.updateToken(token); } } - - //console.warn("Couldn't extract token"); } updateToken(token?: string, rebuild?: boolean) { diff --git a/app/src/App.ts b/app/src/App.ts index 1e7b52a..9683616 100644 --- a/app/src/App.ts +++ b/app/src/App.ts @@ -15,7 +15,7 @@ import * as SystemPermissions from "modules/permissions"; import { AdminController, type AdminControllerOptions } from "modules/server/AdminController"; import { SystemController } from "modules/server/SystemController"; -// biome-ignore format: must be there +// biome-ignore format: must be here import { Api, type ApiOptions } from "Api"; import type { ServerEnv } from "modules/Controller"; diff --git a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts index 523372f..8ff1d08 100644 --- a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts +++ b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts @@ -7,6 +7,7 @@ import { getFresh } from "./modes/fresh"; import { getCached } from "./modes/cached"; import { getDurable } from "./modes/durable"; import type { App } from "bknd"; +import { $console } from "core"; export type CloudflareEnv = object; export type CloudflareBkndConfig = RuntimeBkndConfig & { @@ -37,7 +38,7 @@ export function serve( const url = new URL(request.url); if (config.manifest && config.static === "assets") { - console.warn("manifest is not useful with static 'assets'"); + $console.warn("manifest is not useful with static 'assets'"); } else if (!config.manifest && config.static === "kv") { throw new Error("manifest is required with static 'kv'"); } diff --git a/app/src/adapter/cloudflare/config.ts b/app/src/adapter/cloudflare/config.ts index 4b9f3d7..0c97293 100644 --- a/app/src/adapter/cloudflare/config.ts +++ b/app/src/adapter/cloudflare/config.ts @@ -5,6 +5,7 @@ import type { CloudflareBkndConfig, CloudflareEnv } from "."; import { App } from "bknd"; import { makeConfig as makeAdapterConfig } from "bknd/adapter"; import type { ExecutionContext } from "hono"; +import { $console } from "core"; export const constants = { exec_async_event_id: "cf_register_waituntil", @@ -27,12 +28,12 @@ export function makeConfig( if (!appConfig.connection) { let db: D1Database | undefined; if (bindings?.db) { - console.log("Using database from bindings"); + $console.log("Using database from bindings"); db = bindings.db; } else if (Object.keys(args).length > 0) { const binding = getBinding(args, "D1Database"); if (binding) { - console.log(`Using database from env "${binding.key}"`); + $console.log(`Using database from env "${binding.key}"`); db = binding.value; } } diff --git a/app/src/adapter/cloudflare/modes/durable.ts b/app/src/adapter/cloudflare/modes/durable.ts index 414c197..310fd24 100644 --- a/app/src/adapter/cloudflare/modes/durable.ts +++ b/app/src/adapter/cloudflare/modes/durable.ts @@ -3,6 +3,7 @@ import type { App, CreateAppConfig } from "bknd"; import { createRuntimeApp, makeConfig } from "bknd/adapter"; import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index"; import { constants, registerAsyncsExecutionContext } from "../config"; +import { $console } from "core"; export async function getDurable( config: CloudflareBkndConfig, @@ -13,7 +14,7 @@ export async function getDurable( const key = config.key ?? "app"; if ([config.onBuilt, config.beforeBuild].some((x) => x)) { - console.log("onBuilt and beforeBuild are not supported with DurableObject mode"); + $console.warn("onBuilt and beforeBuild are not supported with DurableObject mode"); } const start = performance.now(); diff --git a/app/src/adapter/cloudflare/modes/fresh.ts b/app/src/adapter/cloudflare/modes/fresh.ts index d894065..af085d6 100644 --- a/app/src/adapter/cloudflare/modes/fresh.ts +++ b/app/src/adapter/cloudflare/modes/fresh.ts @@ -20,7 +20,7 @@ export async function getFresh( ...config, onBuilt: async (app) => { registerAsyncsExecutionContext(app, ctx.ctx); - config.onBuilt?.(app); + await config.onBuilt?.(app); }, }, ctx.env, diff --git a/app/src/adapter/cloudflare/storage/StorageR2Adapter.native-spec.ts b/app/src/adapter/cloudflare/storage/StorageR2Adapter.native-spec.ts index 16c4fa5..ffa98af 100644 --- a/app/src/adapter/cloudflare/storage/StorageR2Adapter.native-spec.ts +++ b/app/src/adapter/cloudflare/storage/StorageR2Adapter.native-spec.ts @@ -3,7 +3,7 @@ import { test } from "node:test"; import { Miniflare } from "miniflare"; import { StorageR2Adapter } from "./StorageR2Adapter"; import { adapterTestSuite } from "media"; -import { nodeTestRunner } from "adapter/node"; +import { nodeTestRunner } from "adapter/node/test"; import path from "node:path"; // https://github.com/nodejs/node/issues/44372#issuecomment-1736530480 diff --git a/app/src/adapter/cloudflare/storage/StorageR2Adapter.ts b/app/src/adapter/cloudflare/storage/StorageR2Adapter.ts index 62c41f6..030855a 100644 --- a/app/src/adapter/cloudflare/storage/StorageR2Adapter.ts +++ b/app/src/adapter/cloudflare/storage/StorageR2Adapter.ts @@ -1,8 +1,10 @@ import { registries } from "bknd"; import { isDebug } from "bknd/core"; -import { StringEnum, Type } from "bknd/utils"; +import { StringEnum } from "bknd/utils"; import { guessMimeType as guess, StorageAdapter, type FileBody } from "bknd/media"; import { getBindings } from "../bindings"; +import * as tb from "@sinclair/typebox"; +const { Type } = tb; export function makeSchema(bindings: string[] = []) { return Type.Object( @@ -122,12 +124,10 @@ export class StorageR2Adapter extends StorageAdapter { } } - //console.log("response headers:before", headersToObject(responseHeaders)); this.writeHttpMetadata(responseHeaders, object); responseHeaders.set("etag", object.httpEtag); responseHeaders.set("Content-Length", String(object.size)); responseHeaders.set("Last-Modified", object.uploaded.toUTCString()); - //console.log("response headers:after", headersToObject(responseHeaders)); return new Response(object.body, { status: object.range ? 206 : 200, diff --git a/app/src/adapter/node/index.ts b/app/src/adapter/node/index.ts index c009c07..16a6b2a 100644 --- a/app/src/adapter/node/index.ts +++ b/app/src/adapter/node/index.ts @@ -3,7 +3,6 @@ import { type LocalAdapterConfig, StorageLocalAdapter } from "./storage/StorageL export * from "./node.adapter"; export { StorageLocalAdapter, type LocalAdapterConfig }; -export { nodeTestRunner } from "./test"; let registered = false; export function registerLocalMediaAdapter() { diff --git a/app/src/adapter/node/node.adapter.native-spec.ts b/app/src/adapter/node/node.adapter.native-spec.ts index c4ece3b..62dcc1b 100644 --- a/app/src/adapter/node/node.adapter.native-spec.ts +++ b/app/src/adapter/node/node.adapter.native-spec.ts @@ -1,7 +1,7 @@ import { describe, before, after } from "node:test"; import * as node from "./node.adapter"; import { adapterTestSuite } from "adapter/adapter-test-suite"; -import { nodeTestRunner } from "adapter/node"; +import { nodeTestRunner } from "adapter/node/test"; import { disableConsoleLog, enableConsoleLog } from "core/utils"; before(() => disableConsoleLog()); diff --git a/app/src/adapter/node/node.adapter.ts b/app/src/adapter/node/node.adapter.ts index 816eb92..ed07800 100644 --- a/app/src/adapter/node/node.adapter.ts +++ b/app/src/adapter/node/node.adapter.ts @@ -4,6 +4,7 @@ import { serveStatic } from "@hono/node-server/serve-static"; import { registerLocalMediaAdapter } from "adapter/node/index"; import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter"; import { config as $config } from "bknd/core"; +import { $console } from "core"; type NodeEnv = NodeJS.ProcessEnv; export type NodeBkndConfig = RuntimeBkndConfig & { @@ -62,7 +63,7 @@ export function serve( fetch: createHandler(config, args, opts), }, (connInfo) => { - console.log(`Server is running on http://localhost:${connInfo.port}`); + $console.log(`Server is running on http://localhost:${connInfo.port}`); listener?.(connInfo); }, ); diff --git a/app/src/adapter/node/storage/StorageLocalAdapter.native-spec.ts b/app/src/adapter/node/storage/StorageLocalAdapter.native-spec.ts index 2177ce8..d237a53 100644 --- a/app/src/adapter/node/storage/StorageLocalAdapter.native-spec.ts +++ b/app/src/adapter/node/storage/StorageLocalAdapter.native-spec.ts @@ -1,5 +1,6 @@ import { describe } from "node:test"; -import { StorageLocalAdapter, nodeTestRunner } from "adapter/node"; +import { nodeTestRunner } from "adapter/node/test"; +import { StorageLocalAdapter } from "adapter/node"; import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite"; import { readFileSync } from "node:fs"; import path from "node:path"; diff --git a/app/src/adapter/node/storage/StorageLocalAdapter.ts b/app/src/adapter/node/storage/StorageLocalAdapter.ts index 8a44f40..88bb395 100644 --- a/app/src/adapter/node/storage/StorageLocalAdapter.ts +++ b/app/src/adapter/node/storage/StorageLocalAdapter.ts @@ -1,7 +1,9 @@ import { readFile, readdir, stat, unlink, writeFile } from "node:fs/promises"; -import { type Static, Type, isFile, parse } from "bknd/utils"; +import { type Static, isFile, parse } from "bknd/utils"; import type { FileBody, FileListObject, FileMeta, FileUploadPayload } from "bknd/media"; import { StorageAdapter, guessMimeType as guess } from "bknd/media"; +import * as tb from "@sinclair/typebox"; +const { Type } = tb; export const localAdapterConfig = Type.Object( { diff --git a/app/src/auth/AppAuth.ts b/app/src/auth/AppAuth.ts index 9739566..898c9f2 100644 --- a/app/src/auth/AppAuth.ts +++ b/app/src/auth/AppAuth.ts @@ -1,29 +1,23 @@ -import { - type AuthAction, - AuthPermissions, - Authenticator, - type ProfileExchange, - Role, - type Strategy, -} from "auth"; +import { Authenticator, AuthPermissions, Role, type Strategy } from "auth"; import type { PasswordStrategy } from "auth/authenticate/strategies"; -import { $console, type DB, Exception, type PrimaryFieldType } from "core"; -import { type Static, secureRandomString, transformObject } from "core/utils"; +import { $console, type DB } from "core"; +import { secureRandomString, transformObject } from "core/utils"; import type { Entity, EntityManager } from "data"; -import { type FieldSchema, em, entity, enumm, text } from "data/prototype"; -import { pick } from "lodash-es"; +import { em, entity, enumm, type FieldSchema, text } from "data/prototype"; import { Module } from "modules/Module"; import { AuthController } from "./api/AuthController"; -import { type AppAuthSchema, STRATEGIES, authConfigSchema } from "./auth-schema"; +import { type AppAuthSchema, authConfigSchema, STRATEGIES } from "./auth-schema"; +import { AppUserPool } from "auth/AppUserPool"; +import type { AppEntity } from "core/config"; export type UserFieldSchema = FieldSchema; declare module "core" { + interface Users extends AppEntity, UserFieldSchema {} interface DB { - users: { id: PrimaryFieldType } & UserFieldSchema; + users: Users; } } -type AuthSchema = Static; export type CreateUserPayload = { email: string; password: string; [key: string]: any }; export class AppAuth extends Module { @@ -31,12 +25,12 @@ export class AppAuth extends Module { cache: Record = {}; _controller!: AuthController; - override async onBeforeUpdate(from: AuthSchema, to: AuthSchema) { + override async onBeforeUpdate(from: AppAuthSchema, to: AppAuthSchema) { const defaultSecret = authConfigSchema.properties.jwt.properties.secret.default; if (!from.enabled && to.enabled) { if (to.jwt.secret === defaultSecret) { - console.warn("No JWT secret provided, generating a random one"); + $console.warn("No JWT secret provided, generating a random one"); to.jwt.secret = secureRandomString(64); } } @@ -80,7 +74,7 @@ export class AppAuth extends Module { } }); - this._authenticator = new Authenticator(strategies, this.resolveUser.bind(this), { + this._authenticator = new Authenticator(strategies, new AppUserPool(this), { jwt: this.config.jwt, cookie: this.config.cookie, }); @@ -90,7 +84,7 @@ export class AppAuth extends Module { this._controller = new AuthController(this); this.ctx.server.route(this.config.basepath, this._controller.getController()); - this.ctx.guard.registerPermissions(Object.values(AuthPermissions)); + this.ctx.guard.registerPermissions(AuthPermissions); } isStrategyEnabled(strategy: Strategy | string) { @@ -122,120 +116,6 @@ export class AppAuth extends Module { return this.ctx.em as any; } - private async resolveUser( - action: AuthAction, - strategy: Strategy, - identifier: string, - profile: ProfileExchange, - ): Promise { - if (!this.config.allow_register && action === "register") { - throw new Exception("Registration is not allowed", 403); - } - - const fields = this.getUsersEntity() - .getFillableFields("create") - .map((f) => f.name); - const filteredProfile = Object.fromEntries( - Object.entries(profile).filter(([key]) => fields.includes(key)), - ); - - switch (action) { - case "login": - return this.login(strategy, identifier, filteredProfile); - case "register": - return this.register(strategy, identifier, filteredProfile); - } - } - - private filterUserData(user: any) { - return pick(user, this.config.jwt.fields); - } - - private async login(strategy: Strategy, identifier: string, profile: ProfileExchange) { - if (!("email" in profile)) { - throw new Exception("Profile must have email"); - } - if (typeof identifier !== "string" || identifier.length === 0) { - throw new Exception("Identifier must be a string"); - } - - const users = this.getUsersEntity(); - this.toggleStrategyValueVisibility(true); - const result = await this.em - .repo(users as unknown as "users") - .findOne({ email: profile.email! }); - this.toggleStrategyValueVisibility(false); - if (!result.data) { - throw new Exception("User not found", 404); - } - - // compare strategy and identifier - if (result.data.strategy !== strategy.getName()) { - //console.log("!!! User registered with different strategy"); - throw new Exception("User registered with different strategy"); - } - - if (result.data.strategy_value !== identifier) { - throw new Exception("Invalid credentials"); - } - - return this.filterUserData(result.data); - } - - private async register(strategy: Strategy, identifier: string, profile: ProfileExchange) { - if (!("email" in profile)) { - throw new Exception("Profile must have an email"); - } - if (typeof identifier !== "string" || identifier.length === 0) { - throw new Exception("Identifier must be a string"); - } - - const users = this.getUsersEntity(); - const { data } = await this.em.repo(users).findOne({ email: profile.email! }); - if (data) { - throw new Exception("User already exists"); - } - - const payload: any = { - ...profile, - strategy: strategy.getName(), - strategy_value: identifier, - }; - - const mutator = this.em.mutator(users); - mutator.__unstable_toggleSystemEntityCreation(false); - this.toggleStrategyValueVisibility(true); - const createResult = await mutator.insertOne(payload); - mutator.__unstable_toggleSystemEntityCreation(true); - this.toggleStrategyValueVisibility(false); - if (!createResult.data) { - throw new Error("Could not create user"); - } - - return this.filterUserData(createResult.data); - } - - private toggleStrategyValueVisibility(visible: boolean) { - const toggle = (name: string, visible: boolean) => { - const field = this.getUsersEntity().field(name)!; - - if (visible) { - field.config.hidden = false; - field.config.fillable = true; - } else { - // reset to normal - const template = AppAuth.usersFields.strategy_value.config; - field.config.hidden = template.hidden; - field.config.fillable = template.fillable; - } - }; - - toggle("strategy_value", visible); - toggle("strategy", visible); - - // @todo: think about a PasswordField that automatically hashes on save? - } - getUsersEntity(forceCreate?: boolean): Entity<"users", typeof AppAuth.usersFields> { const entity_name = this.config.entity_name; if (forceCreate || !this.em.hasEntity(entity_name)) { @@ -288,7 +168,7 @@ export class AppAuth extends Module { throw new Error("Cannot create user, auth not enabled"); } - const strategy = "password"; + const strategy = "password" as const; const pw = this.authenticator.strategy(strategy) as PasswordStrategy; const strategy_value = await pw.hash(password); const mutator = this.em.mutator(this.config.entity_name as "users"); @@ -315,8 +195,7 @@ export class AppAuth extends Module { ...this.authenticator.toJSON(secrets), strategies: transformObject(strategies, (strategy) => ({ enabled: this.isStrategyEnabled(strategy), - type: strategy.getType(), - config: strategy.toJSON(secrets), + ...strategy.toJSON(secrets), })), }; } diff --git a/app/src/auth/AppUserPool.ts b/app/src/auth/AppUserPool.ts new file mode 100644 index 0000000..23f24d0 --- /dev/null +++ b/app/src/auth/AppUserPool.ts @@ -0,0 +1,83 @@ +import { AppAuth } from "auth/AppAuth"; +import type { CreateUser, SafeUser, User, UserPool } from "auth/authenticate/Authenticator"; +import { $console } from "core"; +import { pick } from "lodash-es"; +import { + InvalidConditionsException, + UnableToCreateUserException, + UserNotFoundException, +} from "auth/errors"; + +export class AppUserPool implements UserPool { + constructor(private appAuth: AppAuth) {} + + get em() { + return this.appAuth.em; + } + + get users() { + return this.appAuth.getUsersEntity(); + } + + async findBy(strategy: string, prop: keyof SafeUser, value: any) { + $console.debug("[AppUserPool:findBy]", { strategy, prop, value }); + this.toggleStrategyValueVisibility(true); + const result = await this.em.repo(this.users).findOne({ [prop]: value, strategy }); + this.toggleStrategyValueVisibility(false); + + if (!result.data) { + $console.debug("[AppUserPool]: User not found"); + throw new UserNotFoundException(); + } + + return result.data; + } + + async create(strategy: string, payload: CreateUser & Partial>) { + $console.debug("[AppUserPool:create]", { strategy, payload }); + if (!("strategy_value" in payload)) { + throw new InvalidConditionsException("Profile must have a strategy_value value"); + } + + const fields = this.users.getSelect(undefined, "create"); + const safeProfile = pick(payload, fields) as any; + const createPayload: Omit = { + ...safeProfile, + strategy, + }; + + const mutator = this.em.mutator(this.users); + mutator.__unstable_toggleSystemEntityCreation(false); + this.toggleStrategyValueVisibility(true); + const createResult = await mutator.insertOne(createPayload); + mutator.__unstable_toggleSystemEntityCreation(true); + this.toggleStrategyValueVisibility(false); + if (!createResult.data) { + throw new UnableToCreateUserException(); + } + + $console.debug("[AppUserPool]: User created", createResult.data); + return createResult.data; + } + + private toggleStrategyValueVisibility(visible: boolean) { + const toggle = (name: string, visible: boolean) => { + const field = this.users.field(name)!; + + if (visible) { + field.config.hidden = false; + field.config.fillable = true; + } else { + // reset to normal + const template = AppAuth.usersFields.strategy_value.config; + field.config.hidden = template.hidden; + field.config.fillable = template.fillable; + } + }; + + toggle("strategy_value", visible); + toggle("strategy", visible); + + // @todo: think about a PasswordField that automatically hashes on save? + } +} diff --git a/app/src/auth/api/AuthController.ts b/app/src/auth/api/AuthController.ts index 536f93b..1597888 100644 --- a/app/src/auth/api/AuthController.ts +++ b/app/src/auth/api/AuthController.ts @@ -1,9 +1,11 @@ import { type AppAuth, AuthPermissions, type SafeUser, type Strategy } from "auth"; import { tbValidator as tb } from "core"; -import { Type, TypeInvalidError, parse, transformObject } from "core/utils"; +import { TypeInvalidError, parse, transformObject } from "core/utils"; import { DataPermissions } from "data"; import type { Hono } from "hono"; import { Controller, type ServerEnv } from "modules/Controller"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; export type AuthActionResponse = { success: boolean; diff --git a/app/src/auth/auth-schema.ts b/app/src/auth/auth-schema.ts index ce45ea5..e607d97 100644 --- a/app/src/auth/auth-schema.ts +++ b/app/src/auth/auth-schema.ts @@ -1,6 +1,8 @@ import { cookieConfig, jwtConfig } from "auth/authenticate/Authenticator"; import { CustomOAuthStrategy, OAuthStrategy, PasswordStrategy } from "auth/authenticate/strategies"; -import { type Static, StringRecord, Type, objectTransform } from "core/utils"; +import { type Static, StringRecord, objectTransform } from "core/utils"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; export const Strategies = { password: { diff --git a/app/src/auth/authenticate/Authenticator.ts b/app/src/auth/authenticate/Authenticator.ts index 78c2db7..894f09a 100644 --- a/app/src/auth/authenticate/Authenticator.ts +++ b/app/src/auth/authenticate/Authenticator.ts @@ -1,13 +1,12 @@ -import { type DB, Exception, type PrimaryFieldType } from "core"; +import { $console, type DB, Exception } from "core"; import { addFlashMessage } from "core/server/flash"; import { type Static, StringEnum, type TObject, - Type, parse, runtimeSupports, - transformObject, + truncate, } from "core/utils"; import type { Context, Hono } from "hono"; import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie"; @@ -15,6 +14,9 @@ import { sign, verify } from "hono/jwt"; import type { CookieOptions } from "hono/utils/cookie"; import type { ServerEnv } from "modules/Controller"; import { pick } from "lodash-es"; +import * as tbbox from "@sinclair/typebox"; +import { InvalidConditionsException } from "auth/errors"; +const { Type } = tbbox; type Input = any; // workaround export type JWTPayload = Parameters[0]; @@ -23,11 +25,12 @@ export const strategyActions = ["create", "change"] as const; export type StrategyActionName = (typeof strategyActions)[number]; export type StrategyAction = { schema: S; - preprocess: (input: unknown) => Promise>; + preprocess: (input: Static) => Promise>; }; export type StrategyActions = Partial>; // @todo: add schema to interface to ensure proper inference +// @todo: add tests (e.g. invalid strategy_value) export interface Strategy { getController: (auth: Authenticator) => Hono; getType: () => string; @@ -37,28 +40,22 @@ export interface Strategy { getActions?: () => StrategyActions; } -export type User = { - id: PrimaryFieldType; - email: string; - password: string; - role?: string | null; -}; +export type User = DB["users"]; export type ProfileExchange = { email?: string; - username?: string; - sub?: string; - password?: string; + strategy?: string; + strategy_value?: string; [key: string]: any; }; -export type SafeUser = Omit; +export type SafeUser = Omit; export type CreateUser = Pick & { [key: string]: any }; export type AuthResponse = { user: SafeUser; token: string }; -export interface UserPool { - findBy: (prop: Fields, value: string | number) => Promise; - create: (user: CreateUser) => Promise; +export interface UserPool { + findBy: (strategy: string, prop: keyof SafeUser, value: string | number) => Promise; + create: (strategy: string, user: CreateUser) => Promise; } const defaultCookieExpires = 60 * 60 * 24 * 7; // 1 week in seconds @@ -100,12 +97,17 @@ export const authenticatorConfig = Type.Object({ type AuthConfig = Static; export type AuthAction = "login" | "register"; +export type AuthResolveOptions = { + identifier?: "email" | string; + redirect?: string; + forceJsonResponse?: boolean; +}; export type AuthUserResolver = ( action: AuthAction, strategy: Strategy, - identifier: string, profile: ProfileExchange, -) => Promise; + opts?: AuthResolveOptions, +) => Promise; type AuthClaims = SafeUser & { iat: number; iss?: string; @@ -113,33 +115,117 @@ type AuthClaims = SafeUser & { }; export class Authenticator = Record> { - private readonly strategies: Strategies; private readonly config: AuthConfig; - private readonly userResolver: AuthUserResolver; - constructor(strategies: Strategies, userResolver?: AuthUserResolver, config?: AuthConfig) { - this.userResolver = userResolver ?? (async (a, s, i, p) => p as any); - this.strategies = strategies as Strategies; + constructor( + private readonly strategies: Strategies, + private readonly userPool: UserPool, + config?: AuthConfig, + ) { this.config = parse(authenticatorConfig, config ?? {}); } - async resolve( - action: AuthAction, + async resolveLogin( + c: Context, strategy: Strategy, - identifier: string, - profile: ProfileExchange, - ): Promise { - //console.log("resolve", { action, strategy: strategy.getName(), profile }); - const user = await this.userResolver(action, strategy, identifier, profile); + profile: Partial, + verify: (user: User) => Promise, + opts?: AuthResolveOptions, + ) { + try { + // @todo: centralize identifier and checks + // @todo: check identifier value (if allowed) + const identifier = opts?.identifier || "email"; + if (typeof identifier !== "string" || identifier.length === 0) { + throw new InvalidConditionsException("Identifier must be a string"); + } + if (!(identifier in profile)) { + throw new InvalidConditionsException(`Profile must have identifier "${identifier}"`); + } - if (user) { - return { - user, - token: await this.jwt(user), - }; + const user = await this.userPool.findBy( + strategy.getName(), + identifier as any, + profile[identifier], + ); + + if (!user.strategy_value) { + throw new InvalidConditionsException("User must have a strategy value"); + } else if (user.strategy !== strategy.getName()) { + throw new InvalidConditionsException("User signed up with a different strategy"); + } + + await verify(user); + const data = await this.safeAuthResponse(user); + return this.respondWithUser(c, data, opts); + } catch (e) { + return this.respondWithError(c, e as Error, opts); + } + } + + async resolveRegister( + c: Context, + strategy: Strategy, + profile: CreateUser, + verify: (user: User) => Promise, + opts?: AuthResolveOptions, + ) { + try { + const identifier = opts?.identifier || "email"; + if (typeof identifier !== "string" || identifier.length === 0) { + throw new InvalidConditionsException("Identifier must be a string"); + } + if (!(identifier in profile)) { + throw new InvalidConditionsException(`Profile must have identifier "${identifier}"`); + } + if (!("strategy_value" in profile)) { + throw new InvalidConditionsException("Profile must have a strategy value"); + } + + const user = await this.userPool.create(strategy.getName(), { + ...profile, + strategy_value: profile.strategy_value, + }); + + await verify(user); + const data = await this.safeAuthResponse(user); + return this.respondWithUser(c, data, opts); + } catch (e) { + return this.respondWithError(c, e as Error, opts); + } + } + + private async respondWithUser(c: Context, data: AuthResponse, opts?: AuthResolveOptions) { + const successUrl = this.getSafeUrl( + c, + opts?.redirect ?? this.config.cookie.pathSuccess ?? "/", + ); + + if ("token" in data) { + await this.setAuthCookie(c, data.token); + + if (this.isJsonRequest(c) || opts?.forceJsonResponse) { + return c.json(data); + } + + // can't navigate to "/" – doesn't work on nextjs + return c.redirect(successUrl); } - throw new Error("User could not be resolved"); + throw new Exception("Invalid response"); + } + + async respondWithError(c: Context, error: Error, opts?: AuthResolveOptions) { + $console.error("respondWithError", error); + if (this.isJsonRequest(c) || opts?.forceJsonResponse) { + // let the server handle it + throw error; + } + + await addFlashMessage(c, String(error), "error"); + + const referer = this.getSafeUrl(c, opts?.redirect ?? c.req.header("Referer") ?? "/"); + return c.redirect(referer); } getStrategies(): Strategies { @@ -158,7 +244,7 @@ export class Authenticator = Record< } // @todo: add jwt tests - async jwt(_user: Omit): Promise { + async jwt(_user: SafeUser | ProfileExchange): Promise { const user = pick(_user, this.config.jwt.fields); const payload: JWTPayload = { @@ -184,6 +270,14 @@ export class Authenticator = Record< return sign(payload, secret, this.config.jwt?.alg ?? "HS256"); } + async safeAuthResponse(_user: User): Promise { + const user = pick(_user, this.config.jwt.fields) as SafeUser; + return { + user, + token: await this.jwt(user), + }; + } + async verify(jwt: string): Promise { try { const payload = await verify( @@ -225,7 +319,7 @@ export class Authenticator = Record< return token; } catch (e: any) { if (e instanceof Error) { - console.error("[Error:getAuthCookie]", e.message); + $console.error("[getAuthCookie]", e.message); } return undefined; @@ -242,11 +336,13 @@ export class Authenticator = Record< } private async setAuthCookie(c: Context, token: string) { + $console.debug("setting auth cookie", truncate(token)); const secret = this.config.jwt.secret; await setSignedCookie(c, "auth", token, secret, this.cookieOptions); } private async deleteAuthCookie(c: Context) { + $console.debug("deleting auth cookie"); await deleteCookie(c, "auth", this.cookieOptions); } @@ -262,7 +358,6 @@ export class Authenticator = Record< // @todo: move this to a server helper isJsonRequest(c: Context): boolean { - //return c.req.header("Content-Type") === "application/x-www-form-urlencoded"; return c.req.header("Content-Type") === "application/json"; } @@ -286,37 +381,6 @@ export class Authenticator = Record< return p; } - async respond(c: Context, data: AuthResponse | Error | any, redirect?: string) { - const successUrl = this.getSafeUrl(c, redirect ?? this.config.cookie.pathSuccess ?? "/"); - const referer = redirect ?? c.req.header("Referer") ?? successUrl; - //console.log("auth respond", { redirect, successUrl, successPath }); - - if ("token" in data) { - await this.setAuthCookie(c, data.token); - - if (this.isJsonRequest(c)) { - return c.json(data); - } - - // can't navigate to "/" – doesn't work on nextjs - //console.log("auth success, redirecting to", successUrl); - return c.redirect(successUrl); - } - - if (this.isJsonRequest(c)) { - return c.json(data, 400); - } - - let message = "An error occured"; - if (data instanceof Exception) { - message = data.message; - } - - await addFlashMessage(c, message, "error"); - //console.log("auth failed, redirecting to", referer); - return c.redirect(referer); - } - // @todo: don't extract user from token, but from the database or cache async resolveAuthFromRequest(c: Context): Promise { let token: string | undefined; @@ -341,13 +405,3 @@ export class Authenticator = Record< }; } } - -export function createStrategyAction( - schema: S, - preprocess: (input: Static) => Promise>, -) { - return { - schema, - preprocess, - } as StrategyAction; -} diff --git a/app/src/auth/authenticate/strategies/PasswordStrategy.ts b/app/src/auth/authenticate/strategies/PasswordStrategy.ts index 8f30c58..706e14b 100644 --- a/app/src/auth/authenticate/strategies/PasswordStrategy.ts +++ b/app/src/auth/authenticate/strategies/PasswordStrategy.ts @@ -1,152 +1,135 @@ -import type { Authenticator, Strategy } from "auth"; -import { isDebug, tbValidator as tb } from "core"; -import { type Static, StringEnum, Type, parse } from "core/utils"; -import { hash } from "core/utils"; -import { type Context, Hono } from "hono"; -import { type StrategyAction, type StrategyActions, createStrategyAction } from "../Authenticator"; +import { type Authenticator, InvalidCredentialsException, type User } from "auth"; +import { $console, tbValidator as tb } from "core"; +import { hash, parse, type Static, StrictObject, StringEnum } from "core/utils"; +import { Hono } from "hono"; +import { compare as bcryptCompare, genSalt as bcryptGenSalt, hash as bcryptHash } from "bcryptjs"; +import * as tbbox from "@sinclair/typebox"; +import { Strategy } from "./Strategy"; -type LoginSchema = { username: string; password: string } | { email: string; password: string }; -type RegisterSchema = { email: string; password: string; [key: string]: any }; +const { Type } = tbbox; -const schema = Type.Object({ - hashing: StringEnum(["plain", "sha256" /*, "bcrypt"*/] as const, { default: "sha256" }), +const schema = StrictObject({ + hashing: StringEnum(["plain", "sha256", "bcrypt"], { default: "sha256" }), + rounds: Type.Optional(Type.Number({ minimum: 1, maximum: 10 })), }); export type PasswordStrategyOptions = Static; -/*export type PasswordStrategyOptions2 = { - hashing?: "plain" | "bcrypt" | "sha256"; -};*/ -export class PasswordStrategy implements Strategy { - private options: PasswordStrategyOptions; +export class PasswordStrategy extends Strategy { + constructor(config: Partial = {}) { + super(config as any, "password", "password", "form"); - constructor(options: Partial = {}) { - this.options = parse(schema, options); - } - - async hash(password: string) { - switch (this.options.hashing) { - case "sha256": - return hash.sha256(password); - default: - return password; - } - } - - async login(input: LoginSchema) { - if (!("email" in input) || !("password" in input)) { - throw new Error("Invalid input: Email and password must be provided"); - } - - const hashedPassword = await this.hash(input.password); - return { ...input, password: hashedPassword }; - } - - async register(input: RegisterSchema) { - if (!input.email || !input.password) { - throw new Error("Invalid input: Email and password must be provided"); - } - - return { - ...input, - password: await this.hash(input.password), - }; - } - - getController(authenticator: Authenticator): Hono { - const hono = new Hono(); - - return hono - .post( - "/login", - tb( - "query", - Type.Object({ - redirect: Type.Optional(Type.String()), - }), - ), - async (c) => { - const body = await authenticator.getBody(c); - const { redirect } = c.req.valid("query"); - - try { - const payload = await this.login(body); - const data = await authenticator.resolve( - "login", - this, - payload.password, - payload, - ); - - return await authenticator.respond(c, data, redirect); - } catch (e) { - return await authenticator.respond(c, e); - } - }, - ) - .post( - "/register", - tb( - "query", - Type.Object({ - redirect: Type.Optional(Type.String()), - }), - ), - async (c) => { - const body = await authenticator.getBody(c); - const { redirect } = c.req.valid("query"); - - const payload = await this.register(body); - const data = await authenticator.resolve( - "register", - this, - payload.password, - payload, - ); - - return await authenticator.respond(c, data, redirect); - }, - ); - } - - getActions(): StrategyActions { - return { - create: createStrategyAction( - Type.Object({ - email: Type.String({ - pattern: "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$", - }), - password: Type.String({ - minLength: 8, // @todo: this should be configurable - }), - }), - async ({ password, ...input }) => { - return { - ...input, - strategy_value: await this.hash(password), - }; - }, - ), - }; + this.registerAction("create", this.getPayloadSchema(), async ({ password, ...input }) => { + return { + ...input, + strategy_value: await this.hash(password), + }; + }); } getSchema() { return schema; } - getType() { - return "password"; + private getPayloadSchema() { + return Type.Object({ + email: Type.String({ + pattern: "^[\\w-\\.\\+_]+@([\\w-]+\\.)+[\\w-]{2,4}$", + }), + password: Type.String({ + minLength: 8, // @todo: this should be configurable + }), + }); } - getMode() { - return "form" as const; + async hash(password: string) { + switch (this.config.hashing) { + case "sha256": + return hash.sha256(password); + case "bcrypt": { + const salt = await bcryptGenSalt(this.config.rounds ?? 4); + return bcryptHash(password, salt); + } + default: + return password; + } } - getName() { - return "password" as const; + async compare(actual: string, compare: string): Promise { + switch (this.config.hashing) { + case "sha256": { + const compareHashed = await this.hash(compare); + return actual === compareHashed; + } + case "bcrypt": + return await bcryptCompare(compare, actual); + } + + return false; } - toJSON(secrets?: boolean) { - return secrets ? this.options : undefined; + verify(password: string) { + return async (user: User) => { + const compare = await this.compare(user?.strategy_value!, password); + if (compare !== true) { + throw new InvalidCredentialsException(); + } + }; + } + + getController(authenticator: Authenticator): Hono { + const hono = new Hono(); + const redirectQuerySchema = Type.Object({ + redirect: Type.Optional(Type.String()), + }); + const payloadSchema = this.getPayloadSchema(); + + hono.post("/login", tb("query", redirectQuerySchema), async (c) => { + try { + const body = parse(payloadSchema, await authenticator.getBody(c), { + onError: (errors) => { + $console.error("Invalid login payload", [...errors]); + throw new InvalidCredentialsException(); + }, + }); + const { redirect } = c.req.valid("query"); + + return await authenticator.resolveLogin(c, this, body, this.verify(body.password), { + redirect, + }); + } catch (e) { + return authenticator.respondWithError(c, e as any); + } + }); + + hono.post("/register", tb("query", redirectQuerySchema), async (c) => { + try { + const { redirect } = c.req.valid("query"); + const { password, email, ...body } = parse( + payloadSchema, + await authenticator.getBody(c), + { + onError: (errors) => { + $console.error("Invalid register payload", [...errors]); + new InvalidCredentialsException(); + }, + }, + ); + + const profile = { + ...body, + email, + strategy_value: await this.hash(password), + }; + + return await authenticator.resolveRegister(c, this, profile, async () => void 0, { + redirect, + }); + } catch (e) { + return authenticator.respondWithError(c, e as any); + } + }); + + return hono; } } diff --git a/app/src/auth/authenticate/strategies/Strategy.ts b/app/src/auth/authenticate/strategies/Strategy.ts new file mode 100644 index 0000000..28fb95c --- /dev/null +++ b/app/src/auth/authenticate/strategies/Strategy.ts @@ -0,0 +1,63 @@ +import type { + Authenticator, + StrategyAction, + StrategyActionName, + StrategyActions, +} from "../Authenticator"; +import type { Hono } from "hono"; +import type { Static, TSchema } from "@sinclair/typebox"; +import { parse, type TObject } from "core/utils"; + +export type StrategyMode = "form" | "external"; + +export abstract class Strategy { + protected actions: StrategyActions = {}; + + constructor( + protected config: Static, + public type: string, + public name: string, + public mode: StrategyMode, + ) { + // don't worry about typing, it'll throw if invalid + this.config = parse(this.getSchema(), (config ?? {}) as any) as Static; + } + + protected registerAction( + name: StrategyActionName, + schema: S, + preprocess: StrategyAction["preprocess"], + ): void { + this.actions[name] = { + schema, + preprocess, + } as const; + } + + protected abstract getSchema(): Schema; + + abstract getController(auth: Authenticator): Hono; + + getType(): string { + return this.type; + } + + getMode() { + return this.mode; + } + + getName(): string { + return this.name; + } + + toJSON(secrets?: boolean): { type: string; config: Static | {} | undefined } { + return { + type: this.getType(), + config: secrets ? this.config : undefined, + }; + } + + getActions(): StrategyActions { + return this.actions; + } +} diff --git a/app/src/auth/authenticate/strategies/index.ts b/app/src/auth/authenticate/strategies/index.ts index 86c3ba2..2ca487e 100644 --- a/app/src/auth/authenticate/strategies/index.ts +++ b/app/src/auth/authenticate/strategies/index.ts @@ -5,8 +5,8 @@ import { OAuthCallbackException, OAuthStrategy } from "./oauth/OAuthStrategy"; export * as issuers from "./oauth/issuers"; export { - PasswordStrategy, type PasswordStrategyOptions, + PasswordStrategy, OAuthStrategy, OAuthCallbackException, CustomOAuthStrategy, diff --git a/app/src/auth/authenticate/strategies/oauth/CustomOAuthStrategy.ts b/app/src/auth/authenticate/strategies/oauth/CustomOAuthStrategy.ts index 6e7f458..9e9c3b8 100644 --- a/app/src/auth/authenticate/strategies/oauth/CustomOAuthStrategy.ts +++ b/app/src/auth/authenticate/strategies/oauth/CustomOAuthStrategy.ts @@ -1,43 +1,35 @@ -import { type Static, StringEnum, Type } from "core/utils"; +import { type Static, StrictObject, StringEnum } from "core/utils"; +import * as tbbox from "@sinclair/typebox"; import type * as oauth from "oauth4webapi"; import { OAuthStrategy } from "./OAuthStrategy"; +const { Type } = tbbox; type SupportedTypes = "oauth2" | "oidc"; type RequireKeys = Required> & Omit; const UrlString = Type.String({ pattern: "^(https?|wss?)://[^\\s/$.?#].[^\\s]*$" }); -const oauthSchemaCustom = Type.Object( +const oauthSchemaCustom = StrictObject( { type: StringEnum(["oidc", "oauth2"] as const, { default: "oidc" }), name: Type.String(), - client: Type.Object( - { - client_id: Type.String(), - client_secret: Type.String(), - token_endpoint_auth_method: StringEnum(["client_secret_basic"]), - }, - { - additionalProperties: false, - }, - ), - as: Type.Object( - { - issuer: Type.String(), - code_challenge_methods_supported: Type.Optional(StringEnum(["S256"])), - scopes_supported: Type.Optional(Type.Array(Type.String())), - scope_separator: Type.Optional(Type.String({ default: " " })), - authorization_endpoint: Type.Optional(UrlString), - token_endpoint: Type.Optional(UrlString), - userinfo_endpoint: Type.Optional(UrlString), - }, - { - additionalProperties: false, - }, - ), + client: StrictObject({ + client_id: Type.String(), + client_secret: Type.String(), + token_endpoint_auth_method: StringEnum(["client_secret_basic"]), + }), + as: StrictObject({ + issuer: Type.String(), + code_challenge_methods_supported: Type.Optional(StringEnum(["S256"])), + scopes_supported: Type.Optional(Type.Array(Type.String())), + scope_separator: Type.Optional(Type.String({ default: " " })), + authorization_endpoint: Type.Optional(UrlString), + token_endpoint: Type.Optional(UrlString), + userinfo_endpoint: Type.Optional(UrlString), + }), // @todo: profile mapping }, - { title: "Custom OAuth", additionalProperties: false }, + { title: "Custom OAuth" }, ); type OAuthConfigCustom = Static; @@ -62,6 +54,11 @@ export type IssuerConfig = { }; export class CustomOAuthStrategy extends OAuthStrategy { + constructor(config: OAuthConfigCustom) { + super(config as any); + this.type = "custom_oauth"; + } + override getIssuerConfig(): IssuerConfig { return { ...this.config, profile: async (info) => info } as any; } @@ -70,8 +67,4 @@ export class CustomOAuthStrategy extends OAuthStrategy { override getSchema() { return oauthSchemaCustom; } - - override getType() { - return "custom_oauth"; - } } diff --git a/app/src/auth/authenticate/strategies/oauth/OAuthStrategy.ts b/app/src/auth/authenticate/strategies/oauth/OAuthStrategy.ts index b3177b5..2055d17 100644 --- a/app/src/auth/authenticate/strategies/oauth/OAuthStrategy.ts +++ b/app/src/auth/authenticate/strategies/oauth/OAuthStrategy.ts @@ -1,10 +1,13 @@ -import type { AuthAction, Authenticator, Strategy } from "auth"; +import type { AuthAction, Authenticator } from "auth"; import { Exception, isDebug } from "core"; -import { type Static, StringEnum, type TSchema, Type, filterKeys, parse } from "core/utils"; +import { type Static, StringEnum, filterKeys, StrictObject } from "core/utils"; import { type Context, Hono } from "hono"; import { getSignedCookie, setSignedCookie } from "hono/cookie"; import * as oauth from "oauth4webapi"; import * as issuers from "./issuers"; +import * as tbbox from "@sinclair/typebox"; +import { Strategy } from "auth/authenticate/strategies/Strategy"; +const { Type } = tbbox; type ConfiguredIssuers = keyof typeof issuers; type SupportedTypes = "oauth2" | "oidc"; @@ -13,17 +16,12 @@ type RequireKeys = Required> & O const schemaProvided = Type.Object( { - //type: StringEnum(["oidc", "oauth2"] as const, { default: "oidc" }), name: StringEnum(Object.keys(issuers) as ConfiguredIssuers[]), - client: Type.Object( - { - client_id: Type.String(), - client_secret: Type.String(), - }, - { - additionalProperties: false, - }, - ), + type: StringEnum(["oidc", "oauth2"] as const, { default: "oauth2" }), + client: StrictObject({ + client_id: Type.String(), + client_secret: Type.String(), + }), }, { title: "OAuth" }, ); @@ -71,11 +69,13 @@ export class OAuthCallbackException extends Exception { } } -export class OAuthStrategy implements Strategy { - constructor(private _config: OAuthConfig) {} +export class OAuthStrategy extends Strategy { + constructor(config: ProvidedOAuthConfig) { + super(config, "oauth", config.name, "external"); + } - get config() { - return this._config; + getSchema() { + return schemaProvided; } getIssuerConfig(): IssuerConfig { @@ -103,7 +103,7 @@ export class OAuthStrategy implements Strategy { type: info.type, client: { ...info.client, - ...this._config.client, + ...this.config.client, }, }; } @@ -172,8 +172,7 @@ export class OAuthStrategy implements Strategy { ) { const config = await this.getConfig(); const { client, as, type } = config; - //console.log("config", config); - console.log("callbackParams", callbackParams, options); + const parameters = oauth.validateAuthResponse( as, client, // no client_secret required @@ -181,13 +180,9 @@ export class OAuthStrategy implements Strategy { oauth.expectNoState, ); if (oauth.isOAuth2Error(parameters)) { - //console.log("callback.error", parameters); throw new OAuthCallbackException(parameters, "validateAuthResponse"); } - /*console.log( - "callback.parameters", - JSON.stringify(Object.fromEntries(parameters.entries()), null, 2), - );*/ + const response = await oauth.authorizationCodeGrantRequest( as, client, @@ -195,13 +190,9 @@ export class OAuthStrategy implements Strategy { options.redirect_uri, options.state, ); - //console.log("callback.response", response); const challenges = oauth.parseWwwAuthenticateChallenges(response); if (challenges) { - for (const challenge of challenges) { - //console.log("callback.challenge", challenge); - } // @todo: Handle www-authenticate challenges as needed throw new OAuthCallbackException(challenges, "www-authenticate"); } @@ -216,20 +207,13 @@ export class OAuthStrategy implements Strategy { expectedNonce, ); if (oauth.isOAuth2Error(result)) { - console.log("callback.error", result); // @todo: Handle OAuth 2.0 response body error throw new OAuthCallbackException(result, "processAuthorizationCodeOpenIDResponse"); } - //console.log("callback.result", result); - const claims = oauth.getValidatedIdTokenClaims(result); - //console.log("callback.IDTokenClaims", claims); - const infoRequest = await oauth.userInfoRequest(as, client, result.access_token!); - const resultUser = await oauth.processUserInfoResponse(as, client, claims.sub, infoRequest); - //console.log("callback.resultUser", resultUser); return await config.profile(resultUser, config, claims); // @todo: check claims } @@ -240,8 +224,7 @@ export class OAuthStrategy implements Strategy { ) { const config = await this.getConfig(); const { client, type, as, profile } = config; - console.log("config", { client, as, type }); - console.log("callbackParams", callbackParams, options); + const parameters = oauth.validateAuthResponse( as, client, // no client_secret required @@ -249,13 +232,9 @@ export class OAuthStrategy implements Strategy { oauth.expectNoState, ); if (oauth.isOAuth2Error(parameters)) { - console.log("callback.error", parameters); throw new OAuthCallbackException(parameters, "validateAuthResponse"); } - console.log( - "callback.parameters", - JSON.stringify(Object.fromEntries(parameters.entries()), null, 2), - ); + const response = await oauth.authorizationCodeGrantRequest( as, client, @@ -266,9 +245,6 @@ export class OAuthStrategy implements Strategy { const challenges = oauth.parseWwwAuthenticateChallenges(response); if (challenges) { - for (const challenge of challenges) { - //console.log("callback.challenge", challenge); - } // @todo: Handle www-authenticate challenges as needed throw new OAuthCallbackException(challenges, "www-authenticate"); } @@ -279,19 +255,15 @@ export class OAuthStrategy implements Strategy { try { result = await oauth.processAuthorizationCodeOAuth2Response(as, client, response); if (oauth.isOAuth2Error(result)) { - console.log("error", result); throw new Error(); // Handle OAuth 2.0 response body error } } catch (e) { result = (await copy.json()) as any; - console.log("failed", result); } const res2 = await oauth.userInfoRequest(as, client, result.access_token!); const user = await res2.json(); - console.log("res2", res2, user); - console.log("result", result); return await config.profile(user, config, result); } @@ -301,7 +273,6 @@ export class OAuthStrategy implements Strategy { ): Promise { const type = this.getIssuerConfig().type; - console.log("type", type); switch (type) { case "oidc": return await this.oidc(callbackParams, options); @@ -325,7 +296,6 @@ export class OAuthStrategy implements Strategy { }; const setState = async (c: Context, config: TState): Promise => { - console.log("--- setting state", config); await setSignedCookie(c, cookie_name, JSON.stringify(config), secret, { secure: true, httpOnly: true, @@ -356,7 +326,6 @@ export class OAuthStrategy implements Strategy { const params = new URLSearchParams(url.search); const state = await getState(c); - console.log("state", state); // @todo: add config option to determine if state.action is allowed const redirect_uri = @@ -369,21 +338,28 @@ export class OAuthStrategy implements Strategy { state: state.state, }); - try { - const data = await auth.resolve(state.action, this, profile.sub, profile); - console.log("******** RESOLVED ********", data); + const safeProfile = { + email: profile.email, + strategy_value: profile.sub, + } as const; - if (state.mode === "cookie") { - return await auth.respond(c, data, state.redirect); + const verify = async (user) => { + if (user.strategy_value !== profile.sub) { + throw new Exception("Invalid credentials"); } + }; + const opts = { + redirect: state.redirect, + forceJsonResponse: state.mode !== "cookie", + } as const; - return c.json(data); - } catch (e) { - if (state.mode === "cookie") { - return await auth.respond(c, e, state.redirect); - } - - throw e; + switch (state.action) { + case "login": + return auth.resolveLogin(c, this, safeProfile, verify, opts); + case "register": + return auth.resolveRegister(c, this, safeProfile, verify, opts); + default: + throw new Error("Invalid action"); } }); @@ -412,10 +388,8 @@ export class OAuthStrategy implements Strategy { redirect_uri, state, }); - //console.log("_state", state); await setState(c, { state, action, redirect: referer.toString(), mode: "cookie" }); - console.log("--redirecting to", response.url); return c.redirect(response.url); }); @@ -456,28 +430,15 @@ export class OAuthStrategy implements Strategy { return hono; } - getType() { - return "oauth"; - } - - getMode() { - return "external" as const; - } - - getName() { - return this.config.name; - } - - getSchema() { - return schemaProvided; - } - - toJSON(secrets?: boolean) { + override toJSON(secrets?: boolean) { const config = secrets ? this.config : filterKeys(this.config, ["secret", "client_id"]); return { - type: this.getIssuerConfig().type, - ...config, + ...super.toJSON(secrets), + config: { + ...config, + type: this.getIssuerConfig().type, + }, }; } } diff --git a/app/src/auth/authenticate/strategies/oauth/issuers/github.ts b/app/src/auth/authenticate/strategies/oauth/issuers/github.ts index 4f98b9f..2874761 100644 --- a/app/src/auth/authenticate/strategies/oauth/issuers/github.ts +++ b/app/src/auth/authenticate/strategies/oauth/issuers/github.ts @@ -34,8 +34,6 @@ export const github: IssuerConfig = { config: Omit, tokenResponse: any, ) => { - console.log("github info", info, config, tokenResponse); - try { const res = await fetch("https://api.github.com/user/emails", { headers: { @@ -45,7 +43,6 @@ export const github: IssuerConfig = { }, }); const data = (await res.json()) as GithubUserEmailResponse; - console.log("data", data); const email = data.find((e: any) => e.primary)?.email; if (!email) { throw new Error("No primary email found"); diff --git a/app/src/auth/authorize/Guard.ts b/app/src/auth/authorize/Guard.ts index 8a1da25..a45c160 100644 --- a/app/src/auth/authorize/Guard.ts +++ b/app/src/auth/authorize/Guard.ts @@ -1,4 +1,4 @@ -import { Exception, Permission } from "core"; +import { $console, Exception, Permission } from "core"; import { objectTransform } from "core/utils"; import type { Context } from "hono"; import type { ServerEnv } from "modules/Controller"; @@ -14,8 +14,6 @@ export type GuardConfig = { }; export type GuardContext = Context | GuardUserContext; -const debug = false; - export class Guard { permissions: Permission[]; roles?: Role[]; @@ -83,8 +81,12 @@ export class Guard { return this; } - registerPermissions(permissions: Permission[]) { - for (const permission of permissions) { + registerPermissions(permissions: Record); + registerPermissions(permissions: Permission[]); + registerPermissions(permissions: Permission[] | Record) { + const p = Array.isArray(permissions) ? permissions : Object.values(permissions); + + for (const permission of p) { this.registerPermission(permission); } @@ -95,16 +97,14 @@ export class Guard { if (user && typeof user.role === "string") { const role = this.roles?.find((role) => role.name === user?.role); if (role) { - debug && console.log("guard: role found", [user.role]); + $console.debug(`guard: role "${user.role}" found`); return role; } } - debug && - console.log("guard: role not found", { - user: user, - role: user?.role, - }); + $console.debug("guard: role not found", { + user, + }); return this.getDefaultRole(); } @@ -120,11 +120,14 @@ export class Guard { hasPermission(name: string, user?: GuardUserContext): boolean; hasPermission(permissionOrName: Permission | string, user?: GuardUserContext): boolean { if (!this.isEnabled()) { - //console.log("guard not enabled, allowing"); return true; } const name = typeof permissionOrName === "string" ? permissionOrName : permissionOrName.name; + $console.debug("guard: checking permission", { + name, + user: { id: user?.id, role: user?.role }, + }); const exists = this.permissionExists(name); if (!exists) { throw new Error(`Permission ${name} does not exist`); @@ -133,10 +136,10 @@ export class Guard { const role = this.getUserRole(user); if (!role) { - debug && console.log("guard: role not found, denying"); + $console.debug("guard: user has no role, denying"); return false; } else if (role.implicit_allow === true) { - debug && console.log("guard: role implicit allow, allowing"); + $console.debug(`guard: role "${role.name}" has implicit allow, allowing`); return true; } @@ -144,12 +147,11 @@ export class Guard { (rolePermission) => rolePermission.permission.name === name, ); - debug && - console.log("guard: rolePermission, allowing?", { - permission: name, - role: role.name, - allowing: !!rolePermission, - }); + $console.debug("guard: rolePermission, allowing?", { + permission: name, + role: role.name, + allowing: !!rolePermission, + }); return !!rolePermission; } diff --git a/app/src/auth/errors.ts b/app/src/auth/errors.ts index e10266e..7b725af 100644 --- a/app/src/auth/errors.ts +++ b/app/src/auth/errors.ts @@ -1,28 +1,66 @@ -import { Exception } from "core"; +import { Exception, isDebug } from "core"; +import { HttpStatus } from "core/utils"; -export class UserExistsException extends Exception { +export class AuthException extends Exception { + getSafeErrorAndCode() { + return { + error: "Invalid credentials", + code: HttpStatus.UNAUTHORIZED, + }; + } + + override toJSON(): any { + if (isDebug()) { + return super.toJSON(); + } + + return { + error: this.getSafeErrorAndCode().error, + type: "AuthException", + }; + } +} + +export class UserExistsException extends AuthException { override name = "UserExistsException"; - override code = 422; + override code = HttpStatus.UNPROCESSABLE_ENTITY; constructor() { super("User already exists"); } } -export class UserNotFoundException extends Exception { +export class UserNotFoundException extends AuthException { override name = "UserNotFoundException"; - override code = 404; + override code = HttpStatus.NOT_FOUND; constructor() { super("User not found"); } } -export class InvalidCredentialsException extends Exception { +export class InvalidCredentialsException extends AuthException { override name = "InvalidCredentialsException"; - override code = 401; + override code = HttpStatus.UNAUTHORIZED; constructor() { super("Invalid credentials"); } } + +export class UnableToCreateUserException extends AuthException { + override name = "UnableToCreateUserException"; + override code = HttpStatus.INTERNAL_SERVER_ERROR; + + constructor() { + super("Unable to create user"); + } +} + +export class InvalidConditionsException extends AuthException { + override code = HttpStatus.UNPROCESSABLE_ENTITY; + + constructor(message: string) { + super(message ?? "Invalid conditions"); + } +} diff --git a/app/src/auth/index.ts b/app/src/auth/index.ts index b99de5a..513eb9a 100644 --- a/app/src/auth/index.ts +++ b/app/src/auth/index.ts @@ -1,5 +1,4 @@ export { UserExistsException, UserNotFoundException, InvalidCredentialsException } from "./errors"; -export { sha256 } from "./utils/hash"; export { type ProfileExchange, type Strategy, diff --git a/app/src/auth/utils/hash.ts b/app/src/auth/utils/hash.ts deleted file mode 100644 index 5056938..0000000 --- a/app/src/auth/utils/hash.ts +++ /dev/null @@ -1,13 +0,0 @@ -// @deprecated: moved to @bknd/core -export async function sha256(password: string, salt?: string) { - // 1. Convert password to Uint8Array - const encoder = new TextEncoder(); - const data = encoder.encode((salt ?? "") + password); - - // 2. Hash the data using SHA-256 - const hashBuffer = await crypto.subtle.digest("SHA-256", data); - - // 3. Convert hash to hex string for easier display - const hashArray = Array.from(new Uint8Array(hashBuffer)); - return hashArray.map((byte) => byte.toString(16).padStart(2, "0")).join(""); -} diff --git a/app/src/cli/commands/config.ts b/app/src/cli/commands/config.ts index 3d853ab..81e6cb7 100644 --- a/app/src/cli/commands/config.ts +++ b/app/src/cli/commands/config.ts @@ -8,6 +8,8 @@ export const config: CliCommand = (program) => { .option("--pretty", "pretty print") .action((options) => { const config = getDefaultConfig(); + + // biome-ignore lint/suspicious/noConsoleLog: console.log(options.pretty ? JSON.stringify(config, null, 2) : JSON.stringify(config)); }); }; diff --git a/app/src/cli/commands/copy-assets.ts b/app/src/cli/commands/copy-assets.ts index 813288c..d9d1d12 100644 --- a/app/src/cli/commands/copy-assets.ts +++ b/app/src/cli/commands/copy-assets.ts @@ -32,5 +32,6 @@ async function action(options: { out?: string; clean?: boolean }) { // delete ".vite" directory in out await fs.rm(path.resolve(out, ".vite"), { recursive: true }); + // biome-ignore lint/suspicious/noConsoleLog: console.log(c.green(`Assets copied to: ${c.bold(out)}`)); } diff --git a/app/src/cli/commands/create/create.ts b/app/src/cli/commands/create/create.ts index 3c5233d..e3d7fc4 100644 --- a/app/src/cli/commands/create/create.ts +++ b/app/src/cli/commands/create/create.ts @@ -43,10 +43,12 @@ export const create: CliCommand = (program) => { function errorOutro() { $p.outro(color.red("Failed to create project.")); + // biome-ignore lint/suspicious/noConsoleLog: console.log( color.yellow("Sorry that this happened. If you think this is a bug, please report it at: ") + color.cyan("https://github.com/bknd-io/bknd/issues"), ); + // biome-ignore lint/suspicious/noConsoleLog: console.log(""); process.exit(1); } @@ -55,7 +57,14 @@ async function onExit() { await flush(); } -async function action(options: { template?: string; dir?: string; integration?: string, yes?: boolean, clean?: boolean }) { +async function action(options: { + template?: string; + dir?: string; + integration?: string; + yes?: boolean; + clean?: boolean; +}) { + // biome-ignore lint/suspicious/noConsoleLog: console.log(""); const $t = createScoped("create"); $t.capture("start", { @@ -96,10 +105,12 @@ async function action(options: { template?: string; dir?: string; integration?: $t.properties.at = "dir"; if (fs.existsSync(downloadOpts.dir)) { - const clean = options.clean ?? await $p.confirm({ - message: `Directory ${color.cyan(downloadOpts.dir)} exists. Clean it?`, - initialValue: false, - }); + const clean = + options.clean ?? + (await $p.confirm({ + message: `Directory ${color.cyan(downloadOpts.dir)} exists. Clean it?`, + initialValue: false, + })); if ($p.isCancel(clean)) { await onExit(); process.exit(1); @@ -174,8 +185,6 @@ async function action(options: { template?: string; dir?: string; integration?: process.exit(1); } - //console.log("integration", { type, integration }); - const choices = templates.filter((t) => t.integration === integration); if (choices.length === 0) { await onExit(); @@ -261,9 +270,11 @@ async function action(options: { template?: string; dir?: string; integration?: $p.log.success(`Updated package name to ${color.cyan(ctx.name)}`); { - const install = options.yes ?? await $p.confirm({ - message: "Install dependencies?", - }); + const install = + options.yes ?? + (await $p.confirm({ + message: "Install dependencies?", + })); if ($p.isCancel(install)) { await onExit(); diff --git a/app/src/cli/commands/debug.ts b/app/src/cli/commands/debug.ts index 9f817c3..a9a1e6f 100644 --- a/app/src/cli/commands/debug.ts +++ b/app/src/cli/commands/debug.ts @@ -17,6 +17,7 @@ export const debug: CliCommand = (program) => { const subjects = { paths: async () => { + // biome-ignore lint/suspicious/noConsoleLog: console.log("[PATHS]", { rootpath: getRootPath(), distPath: getDistPath(), @@ -27,6 +28,7 @@ const subjects = { }); }, routes: async () => { + // biome-ignore lint/suspicious/noConsoleLog: console.log("[APP ROUTES]"); const credentials = getConnectionCredentialsFromEnv(); const app = createApp({ connection: credentials }); diff --git a/app/src/cli/commands/index.ts b/app/src/cli/commands/index.ts index cc1b2ae..9f63382 100644 --- a/app/src/cli/commands/index.ts +++ b/app/src/cli/commands/index.ts @@ -5,3 +5,4 @@ export { debug } from "./debug"; export { user } from "./user"; export { create } from "./create"; export { copyAssets } from "./copy-assets"; +export { types } from "./types"; diff --git a/app/src/cli/commands/run/platform.ts b/app/src/cli/commands/run/platform.ts index 808458e..caccc6c 100644 --- a/app/src/cli/commands/run/platform.ts +++ b/app/src/cli/commands/run/platform.ts @@ -1,6 +1,6 @@ import path from "node:path"; import type { Config } from "@libsql/client/node"; -import { config } from "core"; +import { $console, config } from "core"; import type { MiddlewareHandler } from "hono"; import open from "open"; import { fileExists, getRelativeDistPath } from "../../utils/sys"; @@ -37,7 +37,7 @@ export async function startServer( options: { port: number; open?: boolean }, ) { const port = options.port; - console.log(`Using ${server} serve`); + $console.log(`Using ${server} serve`); switch (server) { case "node": { @@ -59,7 +59,8 @@ export async function startServer( } const url = `http://localhost:${port}`; - console.info("Server listening on", url); + $console.info("Server listening on", url); + if (options.open) { await open(url); } diff --git a/app/src/cli/commands/schema.ts b/app/src/cli/commands/schema.ts index 13c1c1e..5dceee9 100644 --- a/app/src/cli/commands/schema.ts +++ b/app/src/cli/commands/schema.ts @@ -8,6 +8,7 @@ export const schema: CliCommand = (program) => { .option("--pretty", "pretty print") .action((options) => { const schema = getDefaultSchema(); + // biome-ignore lint/suspicious/noConsoleLog: console.log(options.pretty ? JSON.stringify(schema, null, 2) : JSON.stringify(schema)); }); }; diff --git a/app/src/cli/commands/types/index.ts b/app/src/cli/commands/types/index.ts new file mode 100644 index 0000000..eea524d --- /dev/null +++ b/app/src/cli/commands/types/index.ts @@ -0,0 +1 @@ +export * from "./types"; diff --git a/app/src/cli/commands/types/types.ts b/app/src/cli/commands/types/types.ts new file mode 100644 index 0000000..3d53618 --- /dev/null +++ b/app/src/cli/commands/types/types.ts @@ -0,0 +1,37 @@ +import type { CliCommand } from "cli/types"; +import { Option } from "commander"; +import { makeAppFromEnv } from "cli/commands/run"; +import { EntityTypescript } from "data/entities/EntityTypescript"; +import { writeFile } from "cli/utils/sys"; +import c from "picocolors"; + +export const types: CliCommand = (program) => { + program + .command("types") + .description("generate types") + .addOption(new Option("-o, --outfile ", "output file").default("bknd-types.d.ts")) + .addOption(new Option("--no-write", "do not write to file").default(true)) + .action(action); +}; + +async function action({ + outfile, + write, +}: { + outfile: string; + write: boolean; +}) { + const app = await makeAppFromEnv({ + server: "node", + }); + await app.build(); + + const et = new EntityTypescript(app.em); + + if (write) { + await writeFile(outfile, et.toString()); + console.info(`\nTypes written to ${c.cyan(outfile)}`); + } else { + console.info(et.toString()); + } +} diff --git a/app/src/cli/commands/user.ts b/app/src/cli/commands/user.ts index a726062..6c66acc 100644 --- a/app/src/cli/commands/user.ts +++ b/app/src/cli/commands/user.ts @@ -169,6 +169,7 @@ async function token(app: App, options: any) { } $log.info(`User found: ${c.cyan(user.email)}`); + // biome-ignore lint/suspicious/noConsoleLog: console.log( `\n${c.dim("Token:")}\n${c.yellow(await app.module.auth.authenticator.jwt(user))}\n`, ); diff --git a/app/src/cli/utils/sys.ts b/app/src/cli/utils/sys.ts index 0d2e152..381937f 100644 --- a/app/src/cli/utils/sys.ts +++ b/app/src/cli/utils/sys.ts @@ -1,5 +1,6 @@ +import { $console } from "core"; import { execSync, exec as nodeExec } from "node:child_process"; -import { readFile } from "node:fs/promises"; +import { readFile, writeFile as nodeWriteFile } from "node:fs/promises"; import path from "node:path"; import url from "node:url"; @@ -48,6 +49,16 @@ export async function fileExists(filePath: string) { } } +export async function writeFile(filePath: string, content: string) { + try { + await nodeWriteFile(path.resolve(process.cwd(), filePath), content); + return true; + } catch (e) { + $console.error("Failed to write file", e); + return false; + } +} + export function exec(command: string, opts?: { silent?: boolean; env?: Record }) { const stdio = opts?.silent ? "pipe" : "inherit"; const output = execSync(command, { diff --git a/app/src/core/clients/aws/AwsClient.ts b/app/src/core/clients/aws/AwsClient.ts index 838367f..b5c33cf 100644 --- a/app/src/core/clients/aws/AwsClient.ts +++ b/app/src/core/clients/aws/AwsClient.ts @@ -29,7 +29,6 @@ export class AwsClient extends Aws4fetchClient { } getUrl(path: string = "/", searchParamsObj: Record = {}): string { - //console.log("super:getUrl", path, searchParamsObj); const url = new URL(path); const converted = this.convertParams(searchParamsObj); Object.entries(converted).forEach(([key, value]) => { @@ -76,8 +75,6 @@ export class AwsClient extends Aws4fetchClient { } const raw = await response.text(); - //console.log("raw", raw); - //console.log(JSON.stringify(xmlToObject(raw), null, 2)); return xmlToObject(raw) as T; } diff --git a/app/src/core/config.ts b/app/src/core/config.ts index f48385f..189b6c9 100644 --- a/app/src/core/config.ts +++ b/app/src/core/config.ts @@ -3,7 +3,11 @@ */ import type { Generated } from "kysely"; -export type PrimaryFieldType = number | Generated; +export type PrimaryFieldType = IdType | Generated; + +export interface AppEntity { + id: PrimaryFieldType; +} export interface DB { // make sure to make unknown as "any" diff --git a/app/src/core/errors.ts b/app/src/core/errors.ts index 9173b4b..4b64c34 100644 --- a/app/src/core/errors.ts +++ b/app/src/core/errors.ts @@ -1,9 +1,12 @@ +import type { ContentfulStatusCode } from "hono/utils/http-status"; +import { HttpStatus } from "./utils/reqres"; + export class Exception extends Error { - code = 400; + code: ContentfulStatusCode = HttpStatus.BAD_REQUEST; override name = "Exception"; protected _context = undefined; - constructor(message: string, code?: number) { + constructor(message: string, code?: ContentfulStatusCode) { super(message); if (code) { this.code = code; diff --git a/app/src/core/events/Event.ts b/app/src/core/events/Event.ts index 9defb4c..009556b 100644 --- a/app/src/core/events/Event.ts +++ b/app/src/core/events/Event.ts @@ -14,6 +14,10 @@ export abstract class Event { params: Params; returned: boolean = false; + /** + * Shallow validation of the event return + * It'll be deeply validated on the place where it is called + */ validate(value: Returning): Event | void { throw new EventReturnedWithoutValidation(this as any, value); } diff --git a/app/src/core/events/EventManager.ts b/app/src/core/events/EventManager.ts index 1c20e58..59efc8f 100644 --- a/app/src/core/events/EventManager.ts +++ b/app/src/core/events/EventManager.ts @@ -1,5 +1,6 @@ import { type Event, type EventClass, InvalidEventReturn } from "./Event"; import { EventListener, type ListenerHandler, type ListenerMode } from "./EventListener"; +import { $console } from "core"; export type RegisterListenerConfig = | ListenerMode @@ -83,10 +84,6 @@ export class EventManager< } else { // @ts-expect-error slug = eventOrSlug.constructor?.slug ?? eventOrSlug.slug; - /*eventOrSlug instanceof Event - ? // @ts-expect-error slug is static - eventOrSlug.constructor.slug - : eventOrSlug.slug;*/ } return !!this.events.find((e) => slug === e.slug); @@ -128,8 +125,7 @@ export class EventManager< if (listener.id) { const existing = this.listeners.find((l) => l.id === listener.id); if (existing) { - // @todo: add a verbose option? - //console.warn(`Listener with id "${listener.id}" already exists.`); + $console.debug(`Listener with id "${listener.id}" already exists.`); return this; } } @@ -191,7 +187,7 @@ export class EventManager< // @ts-expect-error slug is static const slug = event.constructor.slug; if (!this.enabled) { - console.log("EventManager disabled, not emitting", slug); + $console.debug("EventManager disabled, not emitting", slug); return event; } @@ -240,7 +236,7 @@ export class EventManager< } catch (e) { if (e instanceof InvalidEventReturn) { this.options?.onInvalidReturn?.(_event, e); - console.warn(`Invalid return of event listener for "${slug}": ${e.message}`); + $console.warn(`Invalid return of event listener for "${slug}": ${e.message}`); } else if (this.options?.onError) { this.options.onError(_event, e); } else { diff --git a/app/src/core/index.ts b/app/src/core/index.ts index 5c63a4b..a0f572d 100644 --- a/app/src/core/index.ts +++ b/app/src/core/index.ts @@ -3,7 +3,7 @@ import type { Hono, MiddlewareHandler } from "hono"; export { tbValidator } from "./server/lib/tbValidator"; export { Exception, BkndError } from "./errors"; export { isDebug, env } from "./env"; -export { type PrimaryFieldType, config, type DB } from "./config"; +export { type PrimaryFieldType, config, type DB, type AppEntity } from "./config"; export { AwsClient } from "./clients/aws/AwsClient"; export { SimpleRenderer, @@ -25,8 +25,10 @@ export { isBooleanLike, } from "./object/query/query"; export { Registry, type Constructor } from "./registry/Registry"; +export { getFlashMessage } from "./server/flash"; export * from "./console"; +export * from "./events"; // compatibility export type Middleware = MiddlewareHandler; diff --git a/app/src/core/object/SchemaObject.ts b/app/src/core/object/SchemaObject.ts index 8151c8a..2119978 100644 --- a/app/src/core/object/SchemaObject.ts +++ b/app/src/core/object/SchemaObject.ts @@ -73,6 +73,7 @@ export class SchemaObject { forceParse: true, skipMark: this.isForceParse(), }); + // regardless of "noEmit" – this should always be triggered const updatedConfig = await this.onBeforeUpdate(this._config, valid); @@ -122,19 +123,15 @@ export class SchemaObject { const partial = path.length > 0 ? (set({}, path, value) as Partial>) : value; this.throwIfRestricted(partial); - //console.log(getFullPathKeys(value).map((k) => path + "." + k)); // overwrite arrays and primitives, only deep merge objects // @ts-ignore - //console.log("---alt:new", _jsonp(mergeObject(current, partial))); const config = mergeObjectWith(current, partial, (objValue, srcValue) => { if (Array.isArray(objValue) && Array.isArray(srcValue)) { return srcValue; } }); - //console.log("---new", _jsonp(config)); - //console.log("overwritePaths", this.options?.overwritePaths); if (this.options?.overwritePaths) { const keys = getFullPathKeys(value).map((k) => { // only prepend path if given @@ -149,7 +146,6 @@ export class SchemaObject { } }); }); - //console.log("overwritePaths", keys, overwritePaths); if (overwritePaths.length > 0) { // filter out less specific paths (but only if more than 1) @@ -157,12 +153,10 @@ export class SchemaObject { overwritePaths.length > 1 ? overwritePaths.filter((k) => overwritePaths.some((k2) => { - //console.log("keep?", { k, k2 }, k2 !== k && k2.startsWith(k)); return k2 !== k && k2.startsWith(k); }), ) : overwritePaths; - //console.log("specific", specific); for (const p of specific) { set(config, p, get(partial, p)); @@ -170,8 +164,6 @@ export class SchemaObject { } } - //console.log("patch", _jsonp({ path, value, partial, config, current })); - const newConfig = await this.set(config); return [partial, newConfig]; } @@ -181,14 +173,11 @@ export class SchemaObject { const partial = path.length > 0 ? (set({}, path, value) as Partial>) : value; this.throwIfRestricted(partial); - //console.log(getFullPathKeys(value).map((k) => path + "." + k)); // overwrite arrays and primitives, only deep merge objects // @ts-ignore const config = set(current, path, value); - //console.log("overwrite", { path, value, partial, config, current }); - const newConfig = await this.set(config); return [partial, newConfig]; } @@ -198,7 +187,6 @@ export class SchemaObject { if (p.length > 1) { const parent = p.slice(0, -1).join("."); if (!has(this._config, parent)) { - //console.log("parent", parent, JSON.stringify(this._config, null, 2)); throw new Error(`Parent path "${parent}" does not exist`); } } diff --git a/app/src/core/template/SimpleRenderer.spec.ts b/app/src/core/template/SimpleRenderer.spec.ts new file mode 100644 index 0000000..6f922f3 --- /dev/null +++ b/app/src/core/template/SimpleRenderer.spec.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "bun:test"; +import { SimpleRenderer } from "core"; + +describe(SimpleRenderer, () => { + const renderer = new SimpleRenderer( + { + name: "World", + views: 123, + nested: { + foo: "bar", + baz: ["quz", "foo"], + }, + someArray: [1, 2, 3], + enabled: true, + }, + { + renderKeys: true, + }, + ); + + test("strings", async () => { + const tests = [ + ["Hello {{ name }}, count: {{views}}", "Hello World, count: 123"], + ["Nested: {{nested.foo}}", "Nested: bar"], + ["Nested: {{nested.baz[0]}}", "Nested: quz"], + ] as const; + + for (const [template, expected] of tests) { + expect(await renderer.renderString(template)).toEqual(expected); + } + }); + + test("arrays", async () => { + const tests = [ + [ + ["{{someArray[0]}}", "{{someArray[1]}}", "{{someArray[2]}}"], + ["1", "2", "3"], + ], + ] as const; + + for (const [template, expected] of tests) { + const result = await renderer.render(template); + expect(result).toEqual(expected as any); + } + }); + + test("objects", async () => { + const tests = [ + [ + { + foo: "{{name}}", + bar: "{{views}}", + baz: "{{nested.foo}}", + quz: "{{nested.baz[0]}}", + }, + { + foo: "World", + bar: "123", + baz: "bar", + quz: "quz", + }, + ], + ] as const; + + for (const [template, expected] of tests) { + const result = await renderer.render(template); + expect(result).toEqual(expected as any); + } + }); +}); diff --git a/app/src/core/template/SimpleRenderer.ts b/app/src/core/template/SimpleRenderer.ts index 1a16d1d..b3bf6aa 100644 --- a/app/src/core/template/SimpleRenderer.ts +++ b/app/src/core/template/SimpleRenderer.ts @@ -1,17 +1,13 @@ -import { Liquid, LiquidError } from "liquidjs"; -import type { RenderOptions } from "liquidjs/dist/liquid-options"; -import { BkndError } from "../errors"; +import { get } from "lodash-es"; export type TemplateObject = Record>; -export type TemplateTypes = string | TemplateObject; +export type TemplateTypes = string | TemplateObject | any; -export type SimpleRendererOptions = RenderOptions & { +export type SimpleRendererOptions = { renderKeys?: boolean; }; export class SimpleRenderer { - private engine = new Liquid(); - constructor( private variables: Record = {}, private options: SimpleRendererOptions = {}, @@ -22,7 +18,6 @@ export class SimpleRenderer { } static hasMarkup(template: string | object): boolean { - //console.log("has markup?", template); let flat: string = ""; if (Array.isArray(template) || typeof template === "object") { @@ -34,49 +29,29 @@ export class SimpleRenderer { flat = String(template); } - //console.log("** flat", flat); - - const checks = ["{{", "{%", "{#", "{:"]; - const hasMarkup = checks.some((check) => flat.includes(check)); - //console.log("--has markup?", hasMarkup); - return hasMarkup; + const checks = ["{{"]; + return checks.some((check) => flat.includes(check)); } - async render(template: Given): Promise { - try { - if (typeof template === "string") { - return (await this.renderString(template)) as unknown as Given; - } else if (Array.isArray(template)) { - return (await Promise.all( - template.map((item) => this.render(item)), - )) as unknown as Given; - } else if (typeof template === "object") { - return (await this.renderObject(template)) as unknown as Given; - } - } catch (e) { - if (e instanceof LiquidError) { - const details = { - name: e.name, - token: { - kind: e.token.kind, - input: e.token.input, - begin: e.token.begin, - end: e.token.end, - }, - }; + async render(template: Given): Promise { + if (typeof template === "undefined" || template === null) return template; - throw new BkndError(e.message, details, "liquid"); - } - - throw e; + if (typeof template === "string") { + return (await this.renderString(template)) as unknown as Given; + } else if (Array.isArray(template)) { + return (await Promise.all(template.map((item) => this.render(item)))) as unknown as Given; + } else if (typeof template === "object") { + return (await this.renderObject(template as any)) as unknown as Given; } throw new Error("Invalid template type"); } async renderString(template: string): Promise { - //console.log("*** renderString", template, this.variables); - return this.engine.parseAndRender(template, this.variables, this.options); + return template.replace(/{{\s*([^{}]+?)\s*}}/g, (_, expr: string) => { + const value = get(this.variables, expr.trim()); + return value == null ? "" : String(value); + }); } async renderObject(template: TemplateObject): Promise { diff --git a/app/src/core/utils/DebugLogger.ts b/app/src/core/utils/DebugLogger.ts index 23a1b44..febab17 100644 --- a/app/src/core/utils/DebugLogger.ts +++ b/app/src/core/utils/DebugLogger.ts @@ -9,13 +9,11 @@ export class DebugLogger { } context(context: string) { - //console.log("[ settings context ]", context, this._context); this._context.push(context); return this; } clear() { - //console.log("[ clear context ]", this._context.pop(), this._context); this._context.pop(); return this; } @@ -33,6 +31,8 @@ export class DebugLogger { const indents = " ".repeat(Math.max(this._context.length - 1, 0)); const context = this._context.length > 0 ? `[${this._context[this._context.length - 1]}]` : ""; + + // biome-ignore lint/suspicious/noConsoleLog: console.log(indents, context, time, ...args); this.last = now; diff --git a/app/src/core/utils/dates.ts b/app/src/core/utils/dates.ts index 4628004..1d37cb3 100644 --- a/app/src/core/utils/dates.ts +++ b/app/src/core/utils/dates.ts @@ -4,7 +4,6 @@ import weekOfYear from "dayjs/plugin/weekOfYear.js"; declare module "dayjs" { interface Dayjs { week(): number; - week(value: number): dayjs.Dayjs; } } diff --git a/app/src/core/utils/file.ts b/app/src/core/utils/file.ts index 96970a7..c3db78f 100644 --- a/app/src/core/utils/file.ts +++ b/app/src/core/utils/file.ts @@ -2,6 +2,7 @@ import { extension, guess, isMimeType } from "media/storage/mime-types-tiny"; import { randomString } from "core/utils/strings"; import type { Context } from "hono"; import { invariant } from "core/utils/runtime"; +import { $console } from "../console"; export function getContentName(request: Request): string | undefined; export function getContentName(contentDisposition: string): string | undefined; @@ -130,7 +131,7 @@ export async function getFileFromContext(c: Context): Promise { return await blobToFile(v); } } catch (e) { - console.warn("Error parsing form data", e); + $console.warn("Error parsing form data", e); } } else { try { @@ -141,7 +142,7 @@ export async function getFileFromContext(c: Context): Promise { return await blobToFile(blob, { name: getContentName(c.req.raw), type: contentType }); } } catch (e) { - console.warn("Error parsing blob", e); + $console.warn("Error parsing blob", e); } } diff --git a/app/src/core/utils/objects.ts b/app/src/core/utils/objects.ts index e3a0135..a8469c7 100644 --- a/app/src/core/utils/objects.ts +++ b/app/src/core/utils/objects.ts @@ -359,3 +359,50 @@ export function getPath( throw new Error(`Invalid path: ${path.join(".")}`); } } + +export function objectToJsLiteral(value: object, indent: number = 0, _level: number = 0): string { + const nl = indent ? "\n" : ""; + const pad = (lvl: number) => (indent ? " ".repeat(indent * lvl) : ""); + const openPad = pad(_level + 1); + const closePad = pad(_level); + + // primitives + if (value === null) return "null"; + if (value === undefined) return "undefined"; + const t = typeof value; + if (t === "string") return JSON.stringify(value); // handles escapes + if (t === "number" || t === "boolean") return String(value); + + // arrays + if (Array.isArray(value)) { + const out = value + .map((v) => objectToJsLiteral(v, indent, _level + 1)) + .join(", " + (indent ? nl + openPad : "")); + return ( + "[" + + (indent && value.length ? nl + openPad : "") + + out + + (indent && value.length ? nl + closePad : "") + + "]" + ); + } + + // objects + if (t === "object") { + const entries = Object.entries(value).map(([k, v]) => { + const idOk = /^[A-Za-z_$][\w$]*$/.test(k); // valid identifier? + const key = idOk ? k : JSON.stringify(k); // quote if needed + return key + ": " + objectToJsLiteral(v, indent, _level + 1); + }); + const out = entries.join(", " + (indent ? nl + openPad : "")); + return ( + "{" + + (indent && entries.length ? nl + openPad : "") + + out + + (indent && entries.length ? nl + closePad : "") + + "}" + ); + } + + throw new TypeError(`Unsupported data type: ${t}`); +} diff --git a/app/src/core/utils/reqres.ts b/app/src/core/utils/reqres.ts index 3e9ec12..0ba600f 100644 --- a/app/src/core/utils/reqres.ts +++ b/app/src/core/utils/reqres.ts @@ -1,7 +1,3 @@ -import { randomString } from "core/utils/strings"; -import type { Context } from "hono"; -import { extension, guess, isMimeType } from "media/storage/mime-types-tiny"; - export function headersToObject(headers: Headers): Record { if (!headers) return {}; return { ...Object.fromEntries(headers.entries()) }; @@ -102,7 +98,7 @@ export function decodeSearch(str) { export const enum HttpStatus { // Informational responses (100–199) CONTINUE = 100, - SWITCHING_PROTOCOLS = 101, + //SWITCHING_PROTOCOLS = 101, PROCESSING = 102, EARLY_HINTS = 103, @@ -111,8 +107,8 @@ export const enum HttpStatus { CREATED = 201, ACCEPTED = 202, NON_AUTHORITATIVE_INFORMATION = 203, - NO_CONTENT = 204, - RESET_CONTENT = 205, + //NO_CONTENT = 204, + //RESET_CONTENT = 205, PARTIAL_CONTENT = 206, MULTI_STATUS = 207, ALREADY_REPORTED = 208, @@ -123,7 +119,7 @@ export const enum HttpStatus { MOVED_PERMANENTLY = 301, FOUND = 302, SEE_OTHER = 303, - NOT_MODIFIED = 304, + //NOT_MODIFIED = 304, USE_PROXY = 305, TEMPORARY_REDIRECT = 307, PERMANENT_REDIRECT = 308, @@ -172,3 +168,13 @@ export const enum HttpStatus { NOT_EXTENDED = 510, NETWORK_AUTHENTICATION_REQUIRED = 511, } +// biome-ignore lint/suspicious/noConstEnum: +export const enum HttpStatusEmpty { + // Informational responses (100–199) + SWITCHING_PROTOCOLS = 101, + // Successful responses (200–299) + NO_CONTENT = 204, + RESET_CONTENT = 205, + // Redirection messages (300–399) + NOT_MODIFIED = 304, +} diff --git a/app/src/core/utils/strings.ts b/app/src/core/utils/strings.ts index be115f5..c052331 100644 --- a/app/src/core/utils/strings.ts +++ b/app/src/core/utils/strings.ts @@ -132,3 +132,8 @@ export function slugify(str: string): string { .replace(/-+/g, "-") // remove consecutive hyphens ); } + +export function truncate(str: string, length = 50, end = "..."): string { + if (str.length <= length) return str; + return str.substring(0, length) + end; +} diff --git a/app/src/core/utils/typebox/index.ts b/app/src/core/utils/typebox/index.ts index ee44a5b..1267afc 100644 --- a/app/src/core/utils/typebox/index.ts +++ b/app/src/core/utils/typebox/index.ts @@ -1,18 +1,11 @@ -import { - Kind, - type ObjectOptions, - type SchemaOptions, - type Static, - type StaticDecode, - type StringOptions, - type TLiteral, - type TLiteralValue, - type TObject, - type TRecord, - type TSchema, - type TString, - Type, +import * as tb from "@sinclair/typebox"; +import type { TypeRegistry, + Static, + StaticDecode, + TSchema, + SchemaOptions, + TObject, } from "@sinclair/typebox"; import { DefaultErrorFunction, @@ -43,7 +36,7 @@ const validationSymbol = Symbol("tb-parse-validation"); export class TypeInvalidError extends Error { errors: ValueError[]; constructor( - public schema: TSchema, + public schema: tb.TSchema, public data: unknown, message?: string, ) { @@ -92,29 +85,28 @@ export function mark(obj: any, validated = true) { } } -export function parse( +export function parse( schema: Schema, - data: RecursivePartial>, + data: RecursivePartial>, options?: ParseOptions, -): Static { +): tb.Static { if (!options?.forceParse && typeof data === "object" && validationSymbol in data) { if (options?.useDefaults === false) { - return data as Static; + return data as tb.Static; } // this is important as defaults are expected - return Default(schema, data as any) as Static; + return Default(schema, data as any) as tb.Static; } const parsed = options?.useDefaults === false ? data : Default(schema, data); if (Check(schema, parsed)) { options?.skipMark !== true && mark(parsed, true); - return parsed as Static; + return parsed as tb.Static; } else if (options?.onError) { options.onError(Errors(schema, data)); } else { - //console.warn("errors", JSON.stringify([...Errors(schema, data)], null, 2)); throw new TypeInvalidError(schema, data); } @@ -122,26 +114,24 @@ export function parse( return undefined as any; } -export function parseDecode( +export function parseDecode( schema: Schema, - data: RecursivePartial>, -): StaticDecode { - //console.log("parseDecode", schema, data); + data: RecursivePartial>, +): tb.StaticDecode { const parsed = Default(schema, data); if (Check(schema, parsed)) { - return parsed as StaticDecode; + return parsed as tb.StaticDecode; } - //console.log("errors", ...Errors(schema, data)); throw new TypeInvalidError(schema, data); } -export function strictParse( +export function strictParse( schema: Schema, - data: Static, + data: tb.Static, options?: ParseOptions, -): Static { +): tb.Static { return parse(schema, data as any, options); } @@ -150,11 +140,14 @@ export function registerCustomTypeboxKinds(registry: typeof TypeRegistry) { return typeof value === "string" && schema.enum.includes(value); }); } -registerCustomTypeboxKinds(TypeRegistry); +registerCustomTypeboxKinds(tb.TypeRegistry); -export const StringEnum = (values: T, options?: StringOptions) => - Type.Unsafe({ - [Kind]: "StringEnum", +export const StringEnum = ( + values: T, + options?: tb.StringOptions, +) => + tb.Type.Unsafe({ + [tb.Kind]: "StringEnum", type: "string", enum: values, ...options, @@ -162,45 +155,47 @@ export const StringEnum = (values: T, options // key value record compatible with RJSF and typebox inference // acting like a Record, but using an Object with additionalProperties -export const StringRecord = (properties: T, options?: ObjectOptions) => - Type.Object({}, { ...options, additionalProperties: properties }) as unknown as TRecord< - TString, +export const StringRecord = (properties: T, options?: tb.ObjectOptions) => + tb.Type.Object({}, { ...options, additionalProperties: properties }) as unknown as tb.TRecord< + tb.TString, typeof properties >; // fixed value that only be what is given + prefilled -export const Const = (value: T, options?: SchemaOptions) => - Type.Literal(value, { ...options, default: value, const: value, readOnly: true }) as TLiteral; +export const Const = ( + value: T, + options?: tb.SchemaOptions, +) => + tb.Type.Literal(value, { + ...options, + default: value, + const: value, + readOnly: true, + }) as tb.TLiteral; -export const StringIdentifier = Type.String({ +export const StringIdentifier = tb.Type.String({ pattern: "^[a-zA-Z_][a-zA-Z0-9_]*$", minLength: 2, maxLength: 150, }); +export const StrictObject = ( + properties: T, + options?: tb.ObjectOptions, +): tb.TObject => tb.Type.Object(properties, { ...options, additionalProperties: false }); + SetErrorFunction((error) => { if (error?.schema?.errorMessage) { return error.schema.errorMessage; } - if (error?.schema?.[Kind] === "StringEnum") { + if (error?.schema?.[tb.Kind] === "StringEnum") { return `Expected: ${error.schema.enum.map((e) => `"${e}"`).join(", ")}`; } return DefaultErrorFunction(error); }); -export { - Type, - type Static, - type StaticDecode, - type TSchema, - Kind, - type TObject, - type ValueError, - type SchemaOptions, - Value, - Default, - Errors, - Check, -}; +export type { Static, StaticDecode, TSchema, TObject, ValueError, SchemaOptions }; + +export { Value, Default, Errors, Check }; diff --git a/app/src/data/api/DataApi.ts b/app/src/data/api/DataApi.ts index 0819b08..6103220 100644 --- a/app/src/data/api/DataApi.ts +++ b/app/src/data/api/DataApi.ts @@ -1,5 +1,6 @@ import type { DB } from "core"; import type { EntityData, RepoQueryIn, RepositoryResponse } from "data"; +import type { Insertable, Selectable, Updateable } from "kysely"; import { type BaseModuleApiOptions, ModuleApi, type PrimaryFieldType } from "modules"; import type { FetchPromise, ResponseObject } from "modules/ModuleApi"; @@ -25,21 +26,23 @@ export class DataApi extends ModuleApi { } } - readOne( + readOne( entity: E, id: PrimaryFieldType, query: Omit = {}, ) { + type Data = E extends keyof DB ? Selectable : EntityData; return this.get, "meta" | "data">>( ["entity", entity as any, id], query, ); } - readOneBy( + readOneBy( entity: E, query: Omit = {}, ) { + type Data = E extends keyof DB ? Selectable : EntityData; type T = Pick, "meta" | "data">; return this.readMany(entity, { ...query, @@ -48,10 +51,8 @@ export class DataApi extends ModuleApi { }).refine((data) => data[0]) as unknown as FetchPromise>; } - readMany( - entity: E, - query: RepoQueryIn = {}, - ) { + readMany(entity: E, query: RepoQueryIn = {}) { + type Data = E extends keyof DB ? Selectable : EntityData; type T = Pick, "meta" | "data">; const input = query ?? this.options.defaultQuery; @@ -64,68 +65,70 @@ export class DataApi extends ModuleApi { return this.post(["entity", entity as any, "query"], 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: RepoQueryIn = {}) { + readManyByReference( + entity: E, + id: PrimaryFieldType, + reference: R, + query: RepoQueryIn = {}, + ) { + type Data = R extends keyof DB ? Selectable : EntityData; return this.get, "meta" | "data">>( ["entity", entity as any, id, reference], query ?? this.options.defaultQuery, ); } - createOne( + createOne( entity: E, - input: Omit, + input: Insertable, ) { + type Data = E extends keyof DB ? Selectable : EntityData; return this.post>(["entity", entity as any], input); } - createMany( + createMany( entity: E, - input: Omit[], + input: Insertable[], ) { 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); } - updateOne( + updateOne( entity: E, id: PrimaryFieldType, - input: Partial>, + 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); } - updateMany( + updateMany( entity: E, where: RepoQueryIn["where"], - update: Partial>, + update: Updateable, ) { this.requireObjectSet(where); + type Data = E extends keyof DB ? Selectable : EntityData; return this.patch>(["entity", entity as any], { update, where, }); } - deleteOne( - entity: E, - id: PrimaryFieldType, - ) { + 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]); } - deleteMany( - entity: E, - where: RepoQueryIn["where"], - ) { + 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); } diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index 5aae943..333b9a3 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -1,5 +1,6 @@ -import { isDebug, tbValidator as tb } from "core"; -import { StringEnum, Type } from "core/utils"; +import { $console, isDebug, tbValidator as tb } from "core"; +import { StringEnum } from "core/utils"; +import * as tbbox from "@sinclair/typebox"; import { DataPermissions, type EntityData, @@ -14,6 +15,7 @@ import type { ModuleBuildContext } from "modules"; import { Controller } from "modules/Controller"; import * as SystemPermissions from "modules/permissions"; import type { AppDataConfig } from "../data-schema"; +const { Type } = tbbox; export class DataController extends Controller { constructor( @@ -45,7 +47,6 @@ export class DataController extends Controller { const template = { data: res.data, meta }; // @todo: this works but it breaks in FE (need to improve DataTable) - //return objectCleanEmpty(template) as any; // filter empty return Object.fromEntries( Object.entries(template).filter(([_, v]) => typeof v !== "undefined" && v !== null), @@ -56,7 +57,6 @@ export class DataController extends Controller { const template = { data: res.data }; // filter empty - //return objectCleanEmpty(template); return Object.fromEntries(Object.entries(template).filter(([_, v]) => v !== undefined)); } @@ -72,11 +72,6 @@ export class DataController extends Controller { const { permission, auth } = this.middlewares; const hono = this.create().use(auth(), permission(SystemPermissions.accessApi)); - const definedEntities = this.em.entities.map((e) => e.name); - const tbNumber = Type.Transform(Type.String({ pattern: "^[1-9][0-9]{0,}$" })) - .Decode(Number.parseInt) - .Encode(String); - // @todo: sample implementation how to augment handler with additional info function handler(name: string, h: HH): any { const func = h; @@ -141,10 +136,8 @@ export class DataController extends Controller { }), ), async (c) => { - //console.log("request", c.req.raw); const { entity, context } = c.req.param(); if (!this.entityExists(entity)) { - console.warn("not found:", entity, definedEntities); return this.notFound(c); } const _entity = this.em.entity(entity); @@ -254,7 +247,6 @@ export class DataController extends Controller { async (c) => { const { entity } = c.req.param(); if (!this.entityExists(entity)) { - console.warn("not found:", entity, definedEntities); return this.notFound(c); } const options = c.req.valid("query") as RepoQuery; @@ -328,7 +320,6 @@ export class DataController extends Controller { return this.notFound(c); } const options = (await c.req.valid("json")) as RepoQuery; - //console.log("options", options); const result = await this.em.repository(entity).findMany(options); return c.json(this.repoResult(result), { status: result.data ? 200 : 404 }); diff --git a/app/src/data/connection/sqlite/LibsqlConnection.ts b/app/src/data/connection/sqlite/LibsqlConnection.ts index 7518357..c8b4441 100644 --- a/app/src/data/connection/sqlite/LibsqlConnection.ts +++ b/app/src/data/connection/sqlite/LibsqlConnection.ts @@ -38,7 +38,7 @@ export class LibsqlConnection extends SqliteConnection { if (clientOrCredentials && "url" in clientOrCredentials) { let { url, authToken, protocol } = clientOrCredentials; if (protocol && LIBSQL_PROTOCOLS.includes(protocol)) { - console.log("changing protocol to", protocol); + $console.log("changing protocol to", protocol); const [, rest] = url.split("://"); url = `${protocol}://${rest}`; } diff --git a/app/src/data/data-schema.ts b/app/src/data/data-schema.ts index cec6b61..38f272f 100644 --- a/app/src/data/data-schema.ts +++ b/app/src/data/data-schema.ts @@ -1,4 +1,5 @@ -import { type Static, StringRecord, Type, objectTransform } from "core/utils"; +import { type Static, StringRecord, objectTransform } from "core/utils"; +import * as tb from "@sinclair/typebox"; import { FieldClassMap, RelationClassMap, @@ -18,36 +19,37 @@ export type FieldType = keyof typeof FIELDS; export const RELATIONS = RelationClassMap; export const fieldsSchemaObject = objectTransform(FIELDS, (field, name) => { - return Type.Object( + return tb.Type.Object( { - type: Type.Const(name, { default: name, readOnly: true }), - config: Type.Optional(field.schema), + type: tb.Type.Const(name, { default: name, readOnly: true }), + config: tb.Type.Optional(field.schema), }, { title: name, }, ); }); -export const fieldsSchema = Type.Union(Object.values(fieldsSchemaObject)); +export const fieldsSchema = tb.Type.Union(Object.values(fieldsSchemaObject)); export const entityFields = StringRecord(fieldsSchema); export type TAppDataField = Static; export type TAppDataEntityFields = Static; -export const entitiesSchema = Type.Object({ - //name: Type.String(), - type: Type.Optional(Type.String({ enum: entityTypes, default: "regular", readOnly: true })), - config: Type.Optional(entityConfigSchema), - fields: Type.Optional(entityFields), +export const entitiesSchema = tb.Type.Object({ + type: tb.Type.Optional( + tb.Type.String({ enum: entityTypes, default: "regular", readOnly: true }), + ), + config: tb.Type.Optional(entityConfigSchema), + fields: tb.Type.Optional(entityFields), }); export type TAppDataEntity = Static; export const relationsSchema = Object.entries(RelationClassMap).map(([name, relationClass]) => { - return Type.Object( + return tb.Type.Object( { - type: Type.Const(name, { default: name, readOnly: true }), - source: Type.String(), - target: Type.String(), - config: Type.Optional(relationClass.schema), + type: tb.Type.Const(name, { default: name, readOnly: true }), + source: tb.Type.String(), + target: tb.Type.String(), + config: tb.Type.Optional(relationClass.schema), }, { title: name, @@ -56,24 +58,23 @@ export const relationsSchema = Object.entries(RelationClassMap).map(([name, rela }); export type TAppDataRelation = Static<(typeof relationsSchema)[number]>; -export const indicesSchema = Type.Object( +export const indicesSchema = tb.Type.Object( { - entity: Type.String(), - fields: Type.Array(Type.String(), { minItems: 1 }), - //name: Type.Optional(Type.String()), - unique: Type.Optional(Type.Boolean({ default: false })), + entity: tb.Type.String(), + fields: tb.Type.Array(tb.Type.String(), { minItems: 1 }), + unique: tb.Type.Optional(tb.Type.Boolean({ default: false })), }, { additionalProperties: false, }, ); -export const dataConfigSchema = Type.Object( +export const dataConfigSchema = tb.Type.Object( { - basepath: Type.Optional(Type.String({ default: "/api/data" })), - entities: Type.Optional(StringRecord(entitiesSchema, { default: {} })), - relations: Type.Optional(StringRecord(Type.Union(relationsSchema), { default: {} })), - indices: Type.Optional(StringRecord(indicesSchema, { default: {} })), + basepath: tb.Type.Optional(tb.Type.String({ default: "/api/data" })), + entities: tb.Type.Optional(StringRecord(entitiesSchema, { default: {} })), + relations: tb.Type.Optional(StringRecord(tb.Type.Union(relationsSchema), { default: {} })), + indices: tb.Type.Optional(StringRecord(indicesSchema, { default: {} })), }, { additionalProperties: false, diff --git a/app/src/data/entities/Entity.ts b/app/src/data/entities/Entity.ts index 5706832..b097557 100644 --- a/app/src/data/entities/Entity.ts +++ b/app/src/data/entities/Entity.ts @@ -1,13 +1,14 @@ -import { config } from "core"; +import { $console, config } from "core"; import { type Static, StringEnum, - Type, parse, snakeToPascalWithSpaces, transformObject, } from "core/utils"; import { type Field, PrimaryField, type TActionContext, type TRenderContext } from "../fields"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; // @todo: entity must be migrated to typebox export const entityConfigSchema = Type.Object( @@ -183,9 +184,9 @@ export class Entity< if (existing) { // @todo: for now adding a graceful method if (JSON.stringify(existing) === JSON.stringify(field)) { - /*console.warn( + $console.warn( `Field "${field.name}" already exists on entity "${this.name}", but it's the same, so skipping.`, - );*/ + ); return; } @@ -231,8 +232,14 @@ export class Entity< } for (const field of fields) { - if (!field.isValid(data[field.name], context)) { - console.log("Entity.isValidData:invalid", context, field.name, data[field.name]); + if (!field.isValid(data?.[field.name], context)) { + $console.warn( + "invalid data given for", + this.name, + context, + field.name, + data[field.name], + ); if (options?.explain) { throw new Error(`Field "${field.name}" has invalid data: "${data[field.name]}"`); } @@ -258,7 +265,6 @@ export class Entity< const _fields = Object.fromEntries(fields.map((field) => [field.name, field])); const schema = Type.Object( transformObject(_fields, (field) => { - //const hidden = field.isHidden(options?.context); const fillable = field.isFillable(options?.context); return { title: field.config.label, @@ -274,11 +280,18 @@ export class Entity< return options?.clean ? JSON.parse(JSON.stringify(schema)) : schema; } + toTypes() { + return { + name: this.name, + type: this.type, + comment: this.config.description, + fields: Object.fromEntries(this.getFields().map((field) => [field.name, field.toType()])), + }; + } + toJSON() { return { - //name: this.name, type: this.type, - //fields: transformObject(this.fields, (field) => field.toJSON()), fields: Object.fromEntries(this.fields.map((field) => [field.name, field.toJSON()])), config: this.config, }; diff --git a/app/src/data/entities/EntityManager.ts b/app/src/data/entities/EntityManager.ts index 8d36cf3..e4e71c2 100644 --- a/app/src/data/entities/EntityManager.ts +++ b/app/src/data/entities/EntityManager.ts @@ -1,4 +1,4 @@ -import type { DB as DefaultDB } from "core"; +import { $console, type DB as DefaultDB } from "core"; import { EventManager } from "core/events"; import { sql } from "kysely"; import { Connection } from "../connection/Connection"; @@ -55,7 +55,6 @@ export class EntityManager { this.connection = connection; this.emgr = emgr ?? new EventManager(); - //console.log("registering events", EntityManager.Events); this.emgr.registerEvents(EntityManager.Events); } @@ -90,7 +89,9 @@ export class EntityManager { if (existing) { // @todo: for now adding a graceful method if (JSON.stringify(existing) === JSON.stringify(entity)) { - //console.warn(`Entity "${entity.name}" already exists, but it's the same, so skipping.`); + $console.warn( + `Entity "${entity.name}" already exists, but it's the same, skipping adding it.`, + ); return; } @@ -108,7 +109,6 @@ export class EntityManager { } this._entities[entityIndex] = entity; - // caused issues because this.entity() was using a reference (for when initial config was given) } @@ -295,7 +295,6 @@ export class EntityManager { return { entities: Object.fromEntries(this.entities.map((e) => [e.name, e.toJSON()])), relations: Object.fromEntries(this.relations.all.map((r) => [r.getName(), r.toJSON()])), - //relations: this.relations.all.map((r) => r.toJSON()), indices: Object.fromEntries(this.indices.map((i) => [i.name, i.toJSON()])), }; } diff --git a/app/src/data/entities/EntityTypescript.ts b/app/src/data/entities/EntityTypescript.ts new file mode 100644 index 0000000..26c1df9 --- /dev/null +++ b/app/src/data/entities/EntityTypescript.ts @@ -0,0 +1,228 @@ +import type { Entity, EntityManager, EntityRelation, TEntityType } from "data"; +import { autoFormatString } from "core/utils"; +import { AppAuth, AppMedia } from "modules"; + +export type TEntityTSType = { + name: string; + type: TEntityType; + comment?: string; + fields: Record; +}; + +// [select, insert, update] +type TFieldContextType = boolean | [boolean, boolean, boolean]; + +export type TFieldTSType = { + required?: TFieldContextType; + fillable?: TFieldContextType; + type: "PrimaryFieldType" | string; + comment?: string; + import?: { + package: string; + name: string; + }[]; +}; + +export type EntityTypescriptOptions = { + indentWidth?: number; + indentChar?: string; + entityCommentMultiline?: boolean; + fieldCommentMultiline?: boolean; +}; + +// keep a local copy here until properties have a type +const systemEntities = { + users: AppAuth.usersFields, + media: AppMedia.mediaFields, +}; + +export class EntityTypescript { + constructor( + protected em: EntityManager, + protected _options: EntityTypescriptOptions = {}, + ) {} + + get options() { + return { + ...this._options, + indentWidth: 2, + indentChar: " ", + entityCommentMultiline: true, + fieldCommentMultiline: false, + }; + } + + toTypes() { + return this.em.entities.map((e) => e.toTypes()); + } + + protected getTab(count = 1) { + return this.options.indentChar.repeat(this.options.indentWidth).repeat(count); + } + + collectImports( + type: TEntityTSType, + imports: Record = {}, + ): Record { + for (const [, entity_type] of Object.entries(type.fields)) { + for (const imp of entity_type.import ?? []) { + const name = imp.name; + const pkg = imp.package; + if (!imports[pkg]) { + imports[pkg] = []; + } + if (!imports[pkg].includes(name)) { + imports[pkg].push(name); + } + } + } + return imports; + } + + typeName(name: string) { + return autoFormatString(name); + } + + fieldTypesToString(type: TEntityTSType, opts?: { ignore_fields?: string[]; indent?: number }) { + let string = ""; + const coment_multiline = this.options.fieldCommentMultiline; + const indent = opts?.indent ?? 1; + for (const [field_name, field_type] of Object.entries(type.fields)) { + if (opts?.ignore_fields?.includes(field_name)) continue; + + let f = ""; + f += this.commentString(field_type.comment, indent, coment_multiline); + f += `${this.getTab(indent)}${field_name}${field_type.required ? "" : "?"}: `; + f += field_type.type + ";"; + f += "\n"; + string += f; + } + + return string; + } + + relationToFieldType(relation: EntityRelation, entity: Entity) { + const other = relation.other(entity); + const listable = relation.isListableFor(entity); + const name = this.typeName(other.entity.name); + + let type = name; + if (other.entity.type === "system") { + type = `DB["${other.entity.name}"]`; + } + + return { + fields: { + [other.reference]: { + required: false, + type: `${type}${listable ? "[]" : ""}`, + }, + }, + }; + } + + importsToString(imports: Record) { + const strings: string[] = []; + for (const [pkg, names] of Object.entries(imports)) { + strings.push(`import type { ${names.join(", ")} } from "${pkg}";`); + } + return strings; + } + + commentString(comment?: string, indents = 0, multiline = true) { + if (!comment) return ""; + const indent = this.getTab(indents); + if (!multiline) return `${indent}// ${comment}\n`; + return `${indent}/**\n${indent} * ${comment}\n${indent} */\n`; + } + + entityToTypeString( + entity: Entity, + opts?: { ignore_fields?: string[]; indent?: number; export?: boolean }, + ) { + const type = entity.toTypes(); + const name = this.typeName(type.name); + const indent = opts?.indent ?? 1; + const min_indent = Math.max(0, indent - 1); + + let s = this.commentString(type.comment, min_indent, this.options.entityCommentMultiline); + s += `${opts?.export ? "export " : ""}interface ${name} {\n`; + s += this.fieldTypesToString(type, opts); + + // add listable relations + const relations = this.em.relations.relationsOf(entity); + const rel_types = relations.map((r) => + this.relationToFieldType(r, entity), + ) as TEntityTSType[]; + for (const rel_type of rel_types) { + s += this.fieldTypesToString(rel_type, { + indent, + }); + } + s += `${this.getTab(min_indent)}}`; + + return s; + } + + toString() { + const strings: string[] = []; + const tables: Record = {}; + const imports: Record = { + "bknd/core": ["DB"], + kysely: ["Insertable", "Selectable", "Updateable", "Generated"], + }; + + // add global types + let g = "declare global {\n"; + g += `${this.getTab(1)}type BkndEntity = Selectable;\n`; + g += `${this.getTab(1)}type BkndEntityCreate = Insertable;\n`; + g += `${this.getTab(1)}type BkndEntityUpdate = Updateable;\n`; + g += "}"; + strings.push(g); + + const system_entities = this.em.entities.filter((e) => e.type === "system"); + + for (const entity of this.em.entities) { + // skip system entities, declare addtional props in the DB interface + if (system_entities.includes(entity)) continue; + + const type = entity.toTypes(); + if (!type) continue; + this.collectImports(type, imports); + tables[type.name] = this.typeName(type.name); + const s = this.entityToTypeString(entity, { + export: true, + }); + strings.push(s); + } + + // write tables + let tables_string = "interface Database {\n"; + for (const [name, type] of Object.entries(tables)) { + tables_string += `${this.getTab(1)}${name}: ${type};\n`; + } + tables_string += "}"; + strings.push(tables_string); + + // merge + let merge = `declare module "bknd/core" {\n`; + for (const systemEntity of system_entities) { + const system_fields = Object.keys(systemEntities[systemEntity.name]); + const additional_fields = systemEntity.fields + .filter((f) => !system_fields.includes(f.name) && f.type !== "primary") + .map((f) => f.name); + if (additional_fields.length === 0) continue; + + merge += `${this.getTab(1)}${this.entityToTypeString(systemEntity, { + ignore_fields: ["id", ...system_fields], + indent: 2, + })}\n\n`; + } + + merge += `${this.getTab(1)}interface DB extends Database {}\n}`; + strings.push(merge); + + const final = [this.importsToString(imports).join("\n"), strings.join("\n\n")]; + return final.join("\n\n"); + } +} diff --git a/app/src/data/entities/Mutator.ts b/app/src/data/entities/Mutator.ts index ce6330a..0e4dae4 100644 --- a/app/src/data/entities/Mutator.ts +++ b/app/src/data/entities/Mutator.ts @@ -1,4 +1,4 @@ -import type { DB as DefaultDB, PrimaryFieldType } from "core"; +import { $console, type DB as DefaultDB, type PrimaryFieldType } from "core"; import { type EmitsEvents, EventManager } from "core/events"; import type { DeleteQueryBuilder, InsertQueryBuilder, UpdateQueryBuilder } from "kysely"; import { type TActionContext, WhereBuilder } from ".."; @@ -72,7 +72,6 @@ export class Mutator< // if relation field (include key and value in validatedData) if (Array.isArray(result)) { - //console.log("--- (instructions)", result); const [relation_key, relation_value] = result; validatedData[relation_key] = relation_value; } @@ -122,7 +121,7 @@ export class Mutator< }; } catch (e) { // @todo: redact - console.log("[Error in query]", sql); + $console.error("[Error in query]", sql); throw e; } } diff --git a/app/src/data/entities/query/Repository.ts b/app/src/data/entities/query/Repository.ts index d16b7b1..1d2897a 100644 --- a/app/src/data/entities/query/Repository.ts +++ b/app/src/data/entities/query/Repository.ts @@ -302,24 +302,38 @@ export class Repository { + const event = + options.limit === 1 + ? Repository.Events.RepositoryFindOneBefore + : Repository.Events.RepositoryFindManyBefore; + await this.emgr.emit(new event({ entity, options })); + } + + private async triggerFindAfter( + entity: Entity, + options: RepoQuery, + data: EntityData[], + ): Promise { + if (options.limit === 1) { + await this.emgr.emit( + new Repository.Events.RepositoryFindOneAfter({ entity, options, data: data[0]! }), + ); + } else { + await this.emgr.emit( + new Repository.Events.RepositoryFindManyAfter({ entity, options, data }), + ); + } + } + protected async single( qb: RepositoryQB, options: RepoQuery, ): Promise> { - await this.emgr.emit( - new Repository.Events.RepositoryFindOneBefore({ entity: this.entity, options }), - ); - + await this.triggerFindBefore(this.entity, options); const { data, ...response } = await this.performQuery(qb); - await this.emgr.emit( - new Repository.Events.RepositoryFindOneAfter({ - entity: this.entity, - options, - data: data[0]!, - }), - ); - + await this.triggerFindAfter(this.entity, options, data); return { ...response, data: data[0]! }; } @@ -420,26 +434,16 @@ export class Repository): Promise> { const { qb, options } = this.buildQuery(_options); - - await this.emgr.emit( - new Repository.Events.RepositoryFindManyBefore({ entity: this.entity, options }), - ); + await this.triggerFindBefore(this.entity, options); const res = await this.performQuery(qb); - await this.emgr.emit( - new Repository.Events.RepositoryFindManyAfter({ - entity: this.entity, - options, - data: res.data, - }), - ); - + await this.triggerFindAfter(this.entity, options, res.data); return res as any; } diff --git a/app/src/data/errors.ts b/app/src/data/errors.ts index 9f159f7..12912a0 100644 --- a/app/src/data/errors.ts +++ b/app/src/data/errors.ts @@ -1,26 +1,26 @@ import { Exception } from "core"; -import type { TypeInvalidError } from "core/utils"; +import { HttpStatus, type TypeInvalidError } from "core/utils"; import type { Entity } from "./entities"; import type { Field } from "./fields"; export class UnableToConnectException extends Exception { override name = "UnableToConnectException"; - override code = 500; + override code = HttpStatus.INTERNAL_SERVER_ERROR; } export class InvalidSearchParamsException extends Exception { override name = "InvalidSearchParamsException"; - override code = 422; + override code = HttpStatus.UNPROCESSABLE_ENTITY; } export class TransformRetrieveFailedException extends Exception { override name = "TransformRetrieveFailedException"; - override code = 422; + override code = HttpStatus.UNPROCESSABLE_ENTITY; } export class TransformPersistFailedException extends Exception { override name = "TransformPersistFailedException"; - override code = 422; + override code = HttpStatus.UNPROCESSABLE_ENTITY; static invalidType(property: string, expected: string, given: any) { const givenValue = typeof given === "object" ? JSON.stringify(given) : given; @@ -37,7 +37,7 @@ export class TransformPersistFailedException extends Exception { export class InvalidFieldConfigException extends Exception { override name = "InvalidFieldConfigException"; - override code = 400; + override code = HttpStatus.BAD_REQUEST; constructor( field: Field, @@ -54,7 +54,7 @@ export class InvalidFieldConfigException extends Exception { export class EntityNotDefinedException extends Exception { override name = "EntityNotDefinedException"; - override code = 400; + override code = HttpStatus.BAD_REQUEST; constructor(entity?: Entity | string) { if (!entity) { @@ -67,7 +67,7 @@ export class EntityNotDefinedException extends Exception { export class EntityNotFoundException extends Exception { override name = "EntityNotFoundException"; - override code = 404; + override code = HttpStatus.NOT_FOUND; constructor(entity: Entity | string, id: any) { super( diff --git a/app/src/data/events/index.ts b/app/src/data/events/index.ts index b9d7559..8549750 100644 --- a/app/src/data/events/index.ts +++ b/app/src/data/events/index.ts @@ -1,4 +1,4 @@ -import type { PrimaryFieldType } from "core"; +import { $console, type PrimaryFieldType } from "core"; import { Event, InvalidEventReturn } from "core/events"; import type { Entity, EntityData } from "../entities"; import type { RepoQuery } from "../server/data-query-impl"; @@ -9,6 +9,10 @@ export class MutatorInsertBefore extends Event<{ entity: Entity; data: EntityDat override validate(data: EntityData) { const { entity } = this.params; if (!entity.isValidData(data, "create")) { + $console.warn("MutatorInsertBefore.validate: invalid", { + entity: entity.name, + data, + }); throw new InvalidEventReturn("EntityData", "invalid"); } @@ -36,13 +40,18 @@ export class MutatorUpdateBefore extends Event< static override slug = "mutator-update-before"; override validate(data: EntityData) { - const { entity, ...rest } = this.params; + const { entity, entityId } = this.params; if (!entity.isValidData(data, "update")) { + $console.warn("MutatorUpdateBefore.validate: invalid", { + entity: entity.name, + entityId, + data, + }); throw new InvalidEventReturn("EntityData", "invalid"); } return this.clone({ - ...rest, + entityId, entity, data, }); diff --git a/app/src/data/fields/BooleanField.ts b/app/src/data/fields/BooleanField.ts index 19d2978..35dfa2d 100644 --- a/app/src/data/fields/BooleanField.ts +++ b/app/src/data/fields/BooleanField.ts @@ -1,7 +1,9 @@ -import { type Static, Type } from "core/utils"; +import type { Static } from "core/utils"; import type { EntityManager } from "data"; import { TransformPersistFailedException } from "../errors"; import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field"; +import * as tb from "@sinclair/typebox"; +const { Type } = tb; export const booleanFieldConfigSchema = Type.Composite([ Type.Object({ @@ -47,7 +49,6 @@ export class BooleanField extends Field< } override transformRetrieve(value: unknown): boolean | null { - //console.log("Boolean:transformRetrieve:value", value); if (typeof value === "undefined" || value === null) { if (this.isRequired()) return false; if (this.hasDefault()) return this.getDefault(); @@ -87,4 +88,11 @@ export class BooleanField extends Field< override toJsonSchema() { return this.toSchemaWrapIfRequired(Type.Boolean({ default: this.getDefault() })); } + + override toType() { + return { + ...super.toType(), + type: "boolean", + }; + } } diff --git a/app/src/data/fields/DateField.ts b/app/src/data/fields/DateField.ts index c7ba901..0fdf91e 100644 --- a/app/src/data/fields/DateField.ts +++ b/app/src/data/fields/DateField.ts @@ -1,11 +1,14 @@ -import { type Static, StringEnum, Type, dayjs } from "core/utils"; +import { type Static, StringEnum, dayjs } from "core/utils"; import type { EntityManager } from "../entities"; import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field"; +import { $console } from "core"; +import * as tbbox from "@sinclair/typebox"; +import type { TFieldTSType } from "data/entities/EntityTypescript"; +const { Type } = tbbox; export const dateFieldConfigSchema = Type.Composite( [ Type.Object({ - //default_value: Type.Optional(Type.Date()), type: StringEnum(["date", "datetime", "week"] as const, { default: "date" }), timezone: Type.Optional(Type.String()), min_date: Type.Optional(Type.String()), @@ -51,13 +54,11 @@ export class DateField extends Field< } private parseDateFromString(value: string): Date { - //console.log("parseDateFromString", value); if (this.config.type === "week" && value.includes("-W")) { const [year, week] = value.split("-W").map((n) => Number.parseInt(n, 10)) as [ number, number, ]; - //console.log({ year, week }); // @ts-ignore causes errors on build? return dayjs().year(year).week(week).toDate(); } @@ -67,15 +68,12 @@ export class DateField extends Field< override getValue(value: string, context?: TRenderContext): string | undefined { if (value === null || !value) return; - //console.log("getValue", { value, context }); const date = this.parseDateFromString(value); - //console.log("getValue.date", date); if (context === "submit") { try { return date.toISOString(); } catch (e) { - //console.warn("DateField.getValue:value/submit", value, e); return undefined; } } @@ -84,7 +82,7 @@ export class DateField extends Field< try { return `${date.getFullYear()}-W${dayjs(date).week()}`; } catch (e) { - console.warn("error - DateField.getValue:week", value, e); + $console.warn("DateField.getValue:week error", value, String(e)); return; } } @@ -97,8 +95,7 @@ export class DateField extends Field< return this.formatDate(local); } catch (e) { - console.warn("DateField.getValue:value", value); - console.warn("DateField.getValue:e", e); + $console.warn("DateField.getValue error", this.config.type, value, String(e)); return; } } @@ -117,7 +114,6 @@ export class DateField extends Field< } override transformRetrieve(_value: string): Date | null { - //console.log("transformRetrieve DateField", _value); const value = super.transformRetrieve(_value); if (value === null) return null; @@ -136,7 +132,6 @@ export class DateField extends Field< const value = await super.transformPersist(_value, em, context); if (this.nullish(value)) return value; - //console.log("transformPersist DateField", value); switch (this.config.type) { case "date": case "week": @@ -150,4 +145,11 @@ export class DateField extends Field< override toJsonSchema() { return this.toSchemaWrapIfRequired(Type.String({ default: this.getDefault() })); } + + override toType(): TFieldTSType { + return { + ...super.toType(), + type: "Date | string", + }; + } } diff --git a/app/src/data/fields/EnumField.ts b/app/src/data/fields/EnumField.ts index e8e8772..2bfad08 100644 --- a/app/src/data/fields/EnumField.ts +++ b/app/src/data/fields/EnumField.ts @@ -1,7 +1,10 @@ -import { Const, type Static, StringEnum, StringRecord, Type } from "core/utils"; +import { Const, type Static, StringEnum } from "core/utils"; import type { EntityManager } from "data"; import { TransformPersistFailedException } from "../errors"; -import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field"; +import { baseFieldConfigSchema, Field, type TActionContext, type TRenderContext } from "./Field"; +import * as tbbox from "@sinclair/typebox"; +import type { TFieldTSType } from "data/entities/EntityTypescript"; +const { Type } = tbbox; export const enumFieldConfigSchema = Type.Composite( [ @@ -53,10 +56,6 @@ export class EnumField) { super(name, config); - /*if (this.config.options.values.length === 0) { - throw new Error(`Enum field "${this.name}" requires at least one option`); - }*/ - if (this.config.default_value && !this.isValidValue(this.config.default_value)) { throw new Error(`Default value "${this.config.default_value}" is not a valid option`); } @@ -69,10 +68,6 @@ export class EnumField ({ label: option, value: option })); } @@ -146,4 +141,14 @@ export class EnumField + typeof value === "string" ? `"${value}"` : value, + ); + return { + ...super.toType(), + type: union.length > 0 ? union.join(" | ") : "string", + }; + } } diff --git a/app/src/data/fields/Field.ts b/app/src/data/fields/Field.ts index d8eba4f..5c787eb 100644 --- a/app/src/data/fields/Field.ts +++ b/app/src/data/fields/Field.ts @@ -4,13 +4,15 @@ import { type Static, StringEnum, type TSchema, - Type, TypeInvalidError, } from "core/utils"; import type { HTMLInputTypeAttribute, InputHTMLAttributes } from "react"; import type { EntityManager } from "../entities"; import { InvalidFieldConfigException, TransformPersistFailedException } from "../errors"; import type { FieldSpec } from "data/connection/Connection"; +import * as tbbox from "@sinclair/typebox"; +import type { TFieldTSType } from "data/entities/EntityTypescript"; +const { Type } = tbbox; // @todo: contexts need to be reworked // e.g. "table" is irrelevant, because if read is not given, it fails @@ -184,12 +186,14 @@ export abstract class Field< }; } + // @todo: add field level validation isValid(value: any, context: TActionContext): boolean { - if (value) { + if (typeof value !== "undefined") { return this.isFillable(context); - } else { + } else if (context === "create") { return !this.isRequired(); } + return true; } /** @@ -232,6 +236,14 @@ export abstract class Field< return this.toSchemaWrapIfRequired(Type.Any()); } + toType(): TFieldTSType { + return { + required: this.isRequired(), + comment: this.getDescription(), + type: "any", + }; + } + toJSON() { return { // @todo: current workaround because of fixed string type diff --git a/app/src/data/fields/JsonField.ts b/app/src/data/fields/JsonField.ts index b25df60..db1a6f6 100644 --- a/app/src/data/fields/JsonField.ts +++ b/app/src/data/fields/JsonField.ts @@ -1,7 +1,10 @@ -import { type Static, Type } from "core/utils"; +import type { Static } from "core/utils"; import type { EntityManager } from "data"; import { TransformPersistFailedException } from "../errors"; import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field"; +import * as tbbox from "@sinclair/typebox"; +import type { TFieldTSType } from "data/entities/EntityTypescript"; +const { Type } = tbbox; export const jsonFieldConfigSchema = Type.Composite([baseFieldConfigSchema, Type.Object({})]); @@ -82,7 +85,6 @@ export class JsonField { const value = await super.transformPersist(_value, em, context); - //console.log("value", value); if (this.nullish(value)) return value; if (!this.isSerializable(value)) { @@ -97,4 +99,11 @@ export class JsonField { const value = await super.transformPersist(_value, em, context); if (this.nullish(value)) return value; - //console.log("jsonschema:transformPersist", this.name, _value, context); if (!this.isValid(value)) { - //console.error("jsonschema:transformPersist:invalid", this.name, value); throw new TransformPersistFailedException(this.name, value); - } else { - //console.log("jsonschema:transformPersist:valid", this.name, value); } if (!value || typeof value !== "object") return this.getDefault(); @@ -130,4 +122,12 @@ export class JsonSchemaField< }), ); } + + override toType(): TFieldTSType { + return { + ...super.toType(), + import: [{ package: "json-schema-to-ts", name: "FromSchema" }], + type: `FromSchema<${objectToJsLiteral(this.getJsonSchema(), 2, 1)}>`, + }; + } } diff --git a/app/src/data/fields/NumberField.ts b/app/src/data/fields/NumberField.ts index e0468a8..4184560 100644 --- a/app/src/data/fields/NumberField.ts +++ b/app/src/data/fields/NumberField.ts @@ -1,7 +1,10 @@ -import { type Static, Type } from "core/utils"; +import type { Static } from "core/utils"; import type { EntityManager } from "data"; import { TransformPersistFailedException } from "../errors"; import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field"; +import * as tbbox from "@sinclair/typebox"; +import type { TFieldTSType } from "data/entities/EntityTypescript"; +const { Type } = tbbox; export const numberFieldConfigSchema = Type.Composite( [ @@ -100,4 +103,11 @@ export class NumberField extends Field< }), ); } + + override toType(): TFieldTSType { + return { + ...super.toType(), + type: "number", + }; + } } diff --git a/app/src/data/fields/PrimaryField.ts b/app/src/data/fields/PrimaryField.ts index dd3463f..2cc2983 100644 --- a/app/src/data/fields/PrimaryField.ts +++ b/app/src/data/fields/PrimaryField.ts @@ -1,6 +1,9 @@ import { config } from "core"; -import { type Static, Type } from "core/utils"; +import type { Static } from "core/utils"; import { Field, baseFieldConfigSchema } from "./Field"; +import * as tbbox from "@sinclair/typebox"; +import type { TFieldTSType } from "data/entities/EntityTypescript"; +const { Type } = tbbox; export const primaryFieldConfigSchema = Type.Composite([ Type.Omit(baseFieldConfigSchema, ["required"]), @@ -46,4 +49,13 @@ export class PrimaryField extends Field< override toJsonSchema() { return this.toSchemaWrapIfRequired(Type.Number({ writeOnly: undefined })); } + + override toType(): TFieldTSType { + return { + ...super.toType(), + required: true, + import: [{ package: "kysely", name: "Generated" }], + type: "Generated", + }; + } } diff --git a/app/src/data/fields/TextField.ts b/app/src/data/fields/TextField.ts index 8c318ec..5a439ad 100644 --- a/app/src/data/fields/TextField.ts +++ b/app/src/data/fields/TextField.ts @@ -1,7 +1,9 @@ -import { type Static, Type } from "core/utils"; import type { EntityManager } from "data"; +import type { Static } from "core/utils"; import { TransformPersistFailedException } from "../errors"; import { Field, type TActionContext, baseFieldConfigSchema } from "./Field"; +import * as tb from "@sinclair/typebox"; +const { Type } = tb; export const textFieldConfigSchema = Type.Composite( [ @@ -119,4 +121,11 @@ export class TextField extends Field< }), ); } + + override toType() { + return { + ...super.toType(), + type: "string", + }; + } } diff --git a/app/src/data/fields/VirtualField.ts b/app/src/data/fields/VirtualField.ts index c03db19..ec59ca3 100644 --- a/app/src/data/fields/VirtualField.ts +++ b/app/src/data/fields/VirtualField.ts @@ -1,5 +1,7 @@ -import { type Static, Type } from "core/utils"; +import type { Static } from "core/utils"; import { Field, baseFieldConfigSchema } from "./Field"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; export const virtualFieldConfigSchema = Type.Composite([baseFieldConfigSchema, Type.Object({})]); diff --git a/app/src/data/fields/field-test-suite.ts b/app/src/data/fields/field-test-suite.ts index 4a2394d..f32e58c 100644 --- a/app/src/data/fields/field-test-suite.ts +++ b/app/src/data/fields/field-test-suite.ts @@ -98,12 +98,9 @@ export function fieldTestSuite( test("toJSON", async () => { const _config = { ..._requiredConfig, - //order: 1, fillable: true, required: false, hidden: false, - //virtual: false, - //default_value: undefined }; function fieldJson(field: Field) { @@ -115,19 +112,16 @@ export function fieldTestSuite( } expect(fieldJson(noConfigField)).toEqual({ - //name: "no_config", type: noConfigField.type, config: _config, }); expect(fieldJson(fillable)).toEqual({ - //name: "fillable", type: noConfigField.type, config: _config, }); expect(fieldJson(required)).toEqual({ - //name: "required", type: required.type, config: { ..._config, @@ -136,7 +130,6 @@ export function fieldTestSuite( }); expect(fieldJson(hidden)).toEqual({ - //name: "hidden", type: required.type, config: { ..._config, @@ -145,7 +138,6 @@ export function fieldTestSuite( }); expect(fieldJson(dflt)).toEqual({ - //name: "dflt", type: dflt.type, config: { ..._config, @@ -154,7 +146,6 @@ export function fieldTestSuite( }); expect(fieldJson(requiredAndDefault)).toEqual({ - //name: "full", type: requiredAndDefault.type, config: { ..._config, diff --git a/app/src/data/fields/indices/EntityIndex.ts b/app/src/data/fields/indices/EntityIndex.ts index 239510b..e8af2e6 100644 --- a/app/src/data/fields/indices/EntityIndex.ts +++ b/app/src/data/fields/indices/EntityIndex.ts @@ -39,7 +39,6 @@ export class EntityIndex { return { entity: this.entity.name, fields: this.fields.map((f) => f.name), - //name: this.name, unique: this.unique, }; } diff --git a/app/src/data/helper.ts b/app/src/data/helper.ts index 838b3b0..79dfeb0 100644 --- a/app/src/data/helper.ts +++ b/app/src/data/helper.ts @@ -18,7 +18,6 @@ export function getChangeSet( data: EntityData, fields: Field[], ): EntityData { - //console.log("getChangeSet", formData, data); return transform( formData, (acc, _value, key) => { @@ -32,17 +31,6 @@ export function getChangeSet( // @todo: add typing for "action" if (action === "create" || newValue !== data[key]) { acc[key] = newValue; - /*console.log("changed", { - key, - value, - valueType: typeof value, - prev: data[key], - newValue, - new: value, - sent: acc[key] - });*/ - } else { - //console.log("no change", key, value, data[key]); } }, {} as typeof formData, diff --git a/app/src/data/relations/EntityRelation.ts b/app/src/data/relations/EntityRelation.ts index 884fb23..232476a 100644 --- a/app/src/data/relations/EntityRelation.ts +++ b/app/src/data/relations/EntityRelation.ts @@ -1,4 +1,4 @@ -import { type Static, Type, parse } from "core/utils"; +import { type Static, parse } from "core/utils"; import type { ExpressionBuilder, SelectQueryBuilder } from "kysely"; import type { Entity, EntityData, EntityManager } from "../entities"; import { @@ -8,19 +8,15 @@ import { } from "../relations"; import type { RepoQuery } from "../server/data-query-impl"; import type { RelationType } from "./relation-types"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; + +const directions = ["source", "target"] as const; +export type TDirection = (typeof directions)[number]; export type KyselyJsonFrom = any; export type KyselyQueryBuilder = SelectQueryBuilder; -/*export type RelationConfig = { - mappedBy?: string; - inversedBy?: string; - sourceCardinality?: number; - connectionTable?: string; - connectionTableMappedName?: string; - required?: boolean; -};*/ - export type BaseRelationConfig = Static; // @todo: add generic type for relation config @@ -34,7 +30,7 @@ export abstract class EntityRelation< // @todo: add unit tests // allowed directions, used in RelationAccessor for visibility - directions: ("source" | "target")[] = ["source", "target"]; + directions: TDirection[] = ["source", "target"]; static schema = Type.Object({ mappedBy: Type.Optional(Type.String()), @@ -109,6 +105,10 @@ export abstract class EntityRelation< ); } + self(entity: Entity | string): EntityRelationAnchor { + return this.other(entity).entity.name === this.source.entity.name ? this.target : this.source; + } + ref(reference: string): EntityRelationAnchor { return this.source.reference === reference ? this.source : this.target; } @@ -165,7 +165,6 @@ export abstract class EntityRelation< * @param entity */ isListableFor(entity: Entity): boolean { - //console.log("isListableFor", entity.name, this.source.entity.name, this.target.entity.name); return this.target.entity.name === entity.name; } diff --git a/app/src/data/relations/ManyToManyRelation.ts b/app/src/data/relations/ManyToManyRelation.ts index aa7732a..4767432 100644 --- a/app/src/data/relations/ManyToManyRelation.ts +++ b/app/src/data/relations/ManyToManyRelation.ts @@ -1,12 +1,14 @@ -import { type Static, Type } from "core/utils"; +import type { Static } from "core/utils"; import type { ExpressionBuilder } from "kysely"; import { Entity, type EntityManager } from "../entities"; -import { type Field, PrimaryField, VirtualField } from "../fields"; +import { type Field, PrimaryField } from "../fields"; import type { RepoQuery } from "../server/data-query-impl"; -import { EntityRelation, type KyselyJsonFrom, type KyselyQueryBuilder } from "./EntityRelation"; +import { EntityRelation, type KyselyQueryBuilder } from "./EntityRelation"; import { EntityRelationAnchor } from "./EntityRelationAnchor"; import { RelationField } from "./RelationField"; import { type RelationType, RelationTypes } from "./relation-types"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; export type ManyToManyRelationConfig = Static; @@ -46,7 +48,6 @@ export class ManyToManyRelation extends EntityRelation; diff --git a/app/src/data/relations/RelationField.ts b/app/src/data/relations/RelationField.ts index 6868797..0623987 100644 --- a/app/src/data/relations/RelationField.ts +++ b/app/src/data/relations/RelationField.ts @@ -1,8 +1,11 @@ -import { type Static, StringEnum, Type } from "core/utils"; +import { type Static, StringEnum } from "core/utils"; import type { EntityManager } from "../entities"; import { Field, baseFieldConfigSchema } from "../fields"; import type { EntityRelation } from "./EntityRelation"; import type { EntityRelationAnchor } from "./EntityRelationAnchor"; +import * as tbbox from "@sinclair/typebox"; +import type { TFieldTSType } from "data/entities/EntityTypescript"; +const { Type } = tbbox; const CASCADES = ["cascade", "set null", "set default", "restrict", "no action"] as const; @@ -15,11 +18,6 @@ export const relationFieldConfigSchema = Type.Composite([ on_delete: Type.Optional(StringEnum(CASCADES, { default: "set null" })), }), ]); -/*export const relationFieldConfigSchema = baseFieldConfigSchema.extend({ - reference: z.string(), - target: z.string(), - target_field: z.string().catch("id"), -});*/ export type RelationFieldConfig = Static; export type RelationFieldBaseConfig = { label?: string }; @@ -31,16 +29,6 @@ export class RelationField extends Field { return relationFieldConfigSchema; } - /*constructor(name: string, config?: Partial) { - //relation_name = relation_name || target.name; - //const name = [relation_name, target.getPrimaryField().name].join("_"); - super(name, config); - - //console.log(this.config); - //this.relation.target = target; - //this.relation.name = relation_name; - }*/ - static create( relation: EntityRelation, target: EntityRelationAnchor, @@ -50,7 +38,7 @@ export class RelationField extends Field { target.reference ?? target.entity.name, target.entity.getPrimaryField().name, ].join("_"); - //console.log('name', name); + return new RelationField(name, { ...config, required: relation.required, @@ -96,4 +84,11 @@ export class RelationField extends Field { }), ); } + + override toType(): TFieldTSType { + return { + ...super.toType(), + type: "number", + }; + } } diff --git a/app/src/data/relations/RelationMutator.ts b/app/src/data/relations/RelationMutator.ts index 60366e9..225885b 100644 --- a/app/src/data/relations/RelationMutator.ts +++ b/app/src/data/relations/RelationMutator.ts @@ -63,7 +63,6 @@ export class RelationMutator { // make sure it's a primitive value // @todo: this is not a good way of checking primitives. Null is also an object if (typeof value === "object") { - console.log("value", value); throw new Error(`Invalid value for relation field "${key}" given, expected primitive.`); } diff --git a/app/src/data/server/data-query-impl.ts b/app/src/data/server/data-query-impl.ts index 581d91e..72ef50f 100644 --- a/app/src/data/server/data-query-impl.ts +++ b/app/src/data/server/data-query-impl.ts @@ -1,14 +1,8 @@ import type { TThis } from "@sinclair/typebox"; -import { - type SchemaOptions, - type Static, - type StaticDecode, - StringEnum, - Type, - Value, - isObject, -} from "core/utils"; +import { type SchemaOptions, type StaticDecode, StringEnum, Value, isObject } from "core/utils"; import { WhereBuilder, type WhereQuery } from "../entities"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; const NumberOrString = (options: SchemaOptions = {}) => Type.Transform(Type.Union([Type.Number(), Type.String()], options)) diff --git a/app/src/data/test-types.ts b/app/src/data/test-types.ts deleted file mode 100644 index 1c23fb9..0000000 --- a/app/src/data/test-types.ts +++ /dev/null @@ -1,78 +0,0 @@ -type Field = { - _type: Type; - _required: Required; -}; -type TextField = Field & { - _type: string; - required: () => TextField; -}; -type NumberField = Field & { - _type: number; - required: () => NumberField; -}; - -type Entity> = {}> = { name: string; fields: Fields }; - -function entity>>( - name: string, - fields: Fields, -): Entity { - return { name, fields }; -} - -function text(): TextField { - return {} as any; -} -function number(): NumberField { - return {} as any; -} - -const field1 = text(); -const field1_req = text().required(); -const field2 = number(); -const user = entity("users", { - name: text().required(), - bio: text(), - age: number(), - some: number().required(), -}); - -type InferEntityFields = T extends Entity - ? { - [K in keyof Fields]: Fields[K] extends { _type: infer Type; _required: infer Required } - ? Required extends true - ? Type - : Type | undefined - : never; - } - : never; - -type Prettify = { - [K in keyof T]: T[K]; -}; -export type Simplify = { [KeyType in keyof T]: T[KeyType] } & {}; - -// from https://github.com/type-challenges/type-challenges/issues/28200 -type Merge = { - [K in keyof T]: T[K]; -}; -type OptionalUndefined< - T, - Props extends keyof T = keyof T, - OptionsProps extends keyof T = Props extends keyof T - ? undefined extends T[Props] - ? Props - : never - : never, -> = Merge< - { - [K in OptionsProps]?: T[K]; - } & { - [K in Exclude]: T[K]; - } ->; - -type UserFields = InferEntityFields; -type UserFields2 = Simplify>; - -const obj: UserFields2 = { name: "h", age: 1, some: 1 }; diff --git a/app/src/flows/AppFlows.ts b/app/src/flows/AppFlows.ts index a75defe..6e9ac36 100644 --- a/app/src/flows/AppFlows.ts +++ b/app/src/flows/AppFlows.ts @@ -25,7 +25,6 @@ export class AppFlows extends Module { } override async build() { - //console.log("building flows", this.config); const flows = transformObject(this.config.flows, (flowConfig, name) => { return Flow.fromObject(name, flowConfig as any, TASKS); }); diff --git a/app/src/flows/flows-schema.ts b/app/src/flows/flows-schema.ts index 15365c7..e5d029b 100644 --- a/app/src/flows/flows-schema.ts +++ b/app/src/flows/flows-schema.ts @@ -1,5 +1,7 @@ -import { Const, type Static, StringRecord, Type, transformObject } from "core/utils"; +import { Const, type Static, StringRecord, transformObject } from "core/utils"; import { TaskMap, TriggerMap } from "flows"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; export const TASKS = { ...TaskMap, diff --git a/app/src/flows/flows/Execution.ts b/app/src/flows/flows/Execution.ts index fc69f74..61bf05e 100644 --- a/app/src/flows/flows/Execution.ts +++ b/app/src/flows/flows/Execution.ts @@ -2,6 +2,7 @@ import { Event, EventManager, type ListenerHandler } from "core/events"; import type { EmitsEvents } from "core/events"; import type { Task, TaskResult } from "../tasks/Task"; import type { Flow } from "./Flow"; +import { $console } from "core"; export type TaskLog = TaskResult & { task: Task; @@ -185,10 +186,9 @@ export class Execution implements EmitsEvents { await Promise.all(promises); return this.run(); } catch (e) { - console.log("RuntimeExecutor: error", e); + $console.error("RuntimeExecutor: error", e); // for now just throw - // biome-ignore lint/complexity/noUselessCatch: @todo: add error task on flow throw e; } } diff --git a/app/src/flows/flows/Flow.ts b/app/src/flows/flows/Flow.ts index 287a46e..c924756 100644 --- a/app/src/flows/flows/Flow.ts +++ b/app/src/flows/flows/Flow.ts @@ -5,6 +5,7 @@ import { Condition, TaskConnection } from "../tasks/TaskConnection"; import { Execution } from "./Execution"; import { FlowTaskConnector } from "./FlowTaskConnector"; import { Trigger } from "./triggers/Trigger"; +import { $console } from "core"; type Jsoned object }> = ReturnType; @@ -53,8 +54,6 @@ export class Flow { } getSequence(sequence: Task[][] = []): Task[][] { - //console.log("queue", queue.map((step) => step.map((t) => t.name))); - // start task if (sequence.length === 0) { sequence.push([this.startTask]); @@ -69,7 +68,6 @@ export class Flow { // check if task already in one of queue steps // this is when we have a circle back if (sequence.some((step) => step.includes(outTask))) { - //console.log("Task already in queue", outTask.name); return; } nextStep.push(outTask); @@ -110,14 +108,6 @@ export class Flow { return this; } - /*getResponse() { - if (!this.respondingTask) { - return; - } - - return this.respondingTask.log.output; - }*/ - // @todo: check for existence addConnection(connection: TaskConnection) { // check if connection already exists @@ -179,7 +169,7 @@ export class Flow { // @ts-ignore return new cls(name, obj.params); } catch (e: any) { - console.log("Error creating task", name, obj.type, obj, taskClass); + $console.error("Error creating task", name, obj.type, obj, taskClass); throw new Error(`Error creating task ${obj.type}: ${e.message}`); } }); diff --git a/app/src/flows/flows/FlowTaskConnector.ts b/app/src/flows/flows/FlowTaskConnector.ts index cd11ff5..0622db1 100644 --- a/app/src/flows/flows/FlowTaskConnector.ts +++ b/app/src/flows/flows/FlowTaskConnector.ts @@ -31,37 +31,11 @@ export class FlowTaskConnector { } } - /*const targetDepth = this.task(target).getDepth(); - console.log("depth", ownDepth, targetDepth); - - // if target has a lower depth - if (targetDepth > 0 && ownDepth >= targetDepth) { - // check for unique out conditions - console.log( - "out conditions", - this.source.name, - this.getOutConnections().map((c) => [c.target.name, c.condition]) - ); - if ( - this.getOutConnections().some( - (c) => - c.condition[0] === condition[0] && - c.condition[1] === condition[1] - ) - ) { - throw new Error( - "Task cannot be connected to a deeper task with the same condition" - ); - } - }*/ - this.flow.addConnection(new TaskConnection(this.source, target, { condition, max_retries })); } asOutputFor(target: Task, condition?: Condition) { this.task(target).asInputFor(this.source, condition); - //new FlowTaskConnector(this.flow, target).asInputFor(this.source); - //this.flow.addConnection(new TaskConnection(target, this.source)); } getNext() { @@ -107,12 +81,4 @@ export class FlowTaskConnector { getOutTasks(result?: TaskResult): Task[] { return this.getOutConnections(result).map((c) => c.target); } - - /*getNextRunnableConnections() { - return this.getOutConnections().filter((c) => c.source.log.success); - } - - getNextRunnableTasks() { - return this.getNextRunnableConnections().map((c) => c.target); - }*/ } diff --git a/app/src/flows/flows/executors/RuntimeExecutor.ts b/app/src/flows/flows/executors/RuntimeExecutor.ts index 4f8ec31..46ea105 100644 --- a/app/src/flows/flows/executors/RuntimeExecutor.ts +++ b/app/src/flows/flows/executors/RuntimeExecutor.ts @@ -1,4 +1,5 @@ import type { Task } from "../../tasks/Task"; +import { $console } from "core"; export class RuntimeExecutor { async run( @@ -10,7 +11,6 @@ export class RuntimeExecutor { return; } - //const promises = tasks.map((t) => t.run()); const promises = tasks.map(async (t) => { const result = await t.run(); onDone?.(t, result); @@ -20,7 +20,7 @@ export class RuntimeExecutor { try { await Promise.all(promises); } catch (e) { - console.log("RuntimeExecutor: error", e); + $console.error("RuntimeExecutor: error", e); } return this.run(nextTasks, onDone); diff --git a/app/src/flows/flows/triggers/EventTrigger.ts b/app/src/flows/flows/triggers/EventTrigger.ts index 7426075..3924840 100644 --- a/app/src/flows/flows/triggers/EventTrigger.ts +++ b/app/src/flows/flows/triggers/EventTrigger.ts @@ -1,7 +1,9 @@ import type { EventManager } from "core/events"; -import { Type } from "core/utils"; import type { Flow } from "../Flow"; import { Trigger } from "./Trigger"; +import { $console } from "core"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; export class EventTrigger extends Trigger { override type = "event"; @@ -22,17 +24,13 @@ export class EventTrigger extends Trigger { emgr.on( this.config.event, async (event) => { - console.log("event", event); - /*if (!this.match(event)) { - return; - }*/ const execution = flow.createExecution(); this.executions.push(execution); try { await execution.start(event.params); } catch (e) { - console.error(e); + $console.error(e); } }, this.config.mode, diff --git a/app/src/flows/flows/triggers/HttpTrigger.ts b/app/src/flows/flows/triggers/HttpTrigger.ts index 442f9de..6dc66d9 100644 --- a/app/src/flows/flows/triggers/HttpTrigger.ts +++ b/app/src/flows/flows/triggers/HttpTrigger.ts @@ -1,7 +1,9 @@ -import { StringEnum, Type } from "core/utils"; +import { StringEnum } from "core/utils"; import type { Context, Hono } from "hono"; import type { Flow } from "../Flow"; import { Trigger } from "./Trigger"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; const httpMethods = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const; @@ -10,14 +12,11 @@ export class HttpTrigger extends Trigger { static override schema = Type.Composite([ Trigger.schema, - Type.Object( - { - path: Type.String({ pattern: "^/.*$" }), - method: StringEnum(httpMethods, { default: "GET" }), - response_type: StringEnum(["json", "text", "html"], { default: "json" }), - }, - //{ additionalProperties: false } - ), + Type.Object({ + path: Type.String({ pattern: "^/.*$" }), + method: StringEnum(httpMethods, { default: "GET" }), + response_type: StringEnum(["json", "text", "html"], { default: "json" }), + }), ]); override async register(flow: Flow, hono: Hono) { @@ -43,7 +42,5 @@ export class HttpTrigger extends Trigger { execution.start(params); return c.json({ success: true }); }); - - //console.log("--registered flow", flow.name, "on", method, this.config.path); } } diff --git a/app/src/flows/flows/triggers/Trigger.ts b/app/src/flows/flows/triggers/Trigger.ts index 6879ccc..a8e2215 100644 --- a/app/src/flows/flows/triggers/Trigger.ts +++ b/app/src/flows/flows/triggers/Trigger.ts @@ -1,6 +1,8 @@ -import { type Static, StringEnum, Type, parse } from "core/utils"; +import { type Static, StringEnum, parse } from "core/utils"; import type { Execution } from "../Execution"; import type { Flow } from "../Flow"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; export class Trigger { // @todo: remove this @@ -8,12 +10,9 @@ export class Trigger; - static schema = Type.Object( - { - mode: StringEnum(["sync", "async"], { default: "async" }), - }, - //{ additionalProperties: false } - ); + static schema = Type.Object({ + mode: StringEnum(["sync", "async"], { default: "async" }), + }); constructor(config?: Partial>) { const schema = (this.constructor as typeof Trigger).schema; diff --git a/app/src/flows/flows/triggers/index.ts b/app/src/flows/flows/triggers/index.ts index bdd8f19..6ae8ddc 100644 --- a/app/src/flows/flows/triggers/index.ts +++ b/app/src/flows/flows/triggers/index.ts @@ -4,7 +4,6 @@ import { Trigger } from "./Trigger"; export { Trigger, EventTrigger, HttpTrigger }; -//export type TriggerMapType = { [key: string]: { cls: typeof Trigger } }; export const TriggerMap = { manual: { cls: Trigger }, event: { cls: EventTrigger }, diff --git a/app/src/flows/index.ts b/app/src/flows/index.ts index 4ca0de4..9f7546b 100644 --- a/app/src/flows/index.ts +++ b/app/src/flows/index.ts @@ -23,13 +23,9 @@ export { } from "./flows/triggers"; import { Task } from "./tasks/Task"; -export { type TaskResult, type TaskRenderProps } from "./tasks/Task"; +export type { TaskResult, TaskRenderProps } from "./tasks/Task"; export { TaskConnection, Condition } from "./tasks/TaskConnection"; -// test -//export { simpleFetch } from "./examples/simple-fetch"; - -//export type TaskMapType = { [key: string]: { cls: typeof Task } }; export const TaskMap = { fetch: { cls: FetchTask }, log: { cls: LogTask }, diff --git a/app/src/flows/tasks/Task.tsx b/app/src/flows/tasks/Task.tsx index 314d1a7..e035af9 100644 --- a/app/src/flows/tasks/Task.tsx +++ b/app/src/flows/tasks/Task.tsx @@ -1,8 +1,9 @@ import type { StaticDecode, TSchema } from "@sinclair/typebox"; -import type { NodeProps } from "@xyflow/react"; import { BkndError, SimpleRenderer } from "core"; -import { type Static, type TObject, Type, Value, parse, ucFirst } from "core/utils"; -import type { ExecutionEvent, InputsMap } from "../flows/Execution"; +import { type Static, type TObject, Value, parse, ucFirst } from "core/utils"; +import type { InputsMap } from "../flows/Execution"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; //type InstanceOf = T extends new (...args: any) => infer R ? R : never; export type TaskResult = { @@ -13,10 +14,6 @@ export type TaskResult = { params: any; }; -/*export type TaskRenderProps = NodeProps<{ - task: T; - state: { i: number; isStartTask: boolean; isRespondingTask; event: ExecutionEvent | undefined }; -}>;*/ export type TaskRenderProps = any; export function dynamic( @@ -93,21 +90,6 @@ export abstract class Task { // @todo: string enums fail to validate this._params = parse(schema, params || {}); - - /*const validator = new Validator(schema as any); - const _params = Default(schema, params || {}); - const result = validator.validate(_params); - if (!result.valid) { - //console.log("---errors", result, { params, _params }); - const error = result.errors[0]!; - throw new Error( - `Invalid params for task "${name}.${error.keyword}": "${ - error.error - }". Params given: ${JSON.stringify(params)}` - ); - } - - this._params = _params as Static;*/ } get params() { @@ -124,13 +106,10 @@ export abstract class Task { inputs: object = {}, ): Promise> { const newParams: any = {}; - const renderer = new SimpleRenderer(inputs, { strictVariables: true, renderKeys: true }); - - //console.log("--resolveParams", params); + const renderer = new SimpleRenderer(inputs, { renderKeys: true }); for (const [key, value] of Object.entries(params)) { if (value && SimpleRenderer.hasMarkup(value)) { - //console.log("--- has markup", value); try { newParams[key] = await renderer.render(value as string); } catch (e: any) { @@ -150,29 +129,21 @@ export abstract class Task { throw e; } continue; - } else { - //console.log("-- no markup", key, value); } newParams[key] = value; } - //console.log("--beforeDecode", newParams); - const v = Value.Decode(schema, newParams); - //console.log("--afterDecode", v); - //process.exit(); - return v; + return Value.Decode(schema, newParams); } private async cloneWithResolvedParams(_inputs: Map) { const inputs = Object.fromEntries(_inputs.entries()); - //console.log("--clone:inputs", inputs, this.params); const newParams = await Task.resolveParams( (this.constructor as any).schema, this._params, inputs, ); - //console.log("--clone:newParams", this.name, newParams); return this.clone(this.name, newParams as any); } @@ -200,7 +171,6 @@ export abstract class Task { success = true; } catch (e: any) { success = false; - //status.output = undefined; if (e instanceof BkndError) { error = e.toJSON(); diff --git a/app/src/flows/tasks/TaskConnection.ts b/app/src/flows/tasks/TaskConnection.ts index 54efa3e..186eb28 100644 --- a/app/src/flows/tasks/TaskConnection.ts +++ b/app/src/flows/tasks/TaskConnection.ts @@ -80,7 +80,6 @@ export class Condition { return result.success === false; case "matches": return get(result.output, this.path) === this.value; - //return this.value === output[this.path]; } } diff --git a/app/src/flows/tasks/presets/FetchTask.ts b/app/src/flows/tasks/presets/FetchTask.ts index b590c26..17b17a3 100644 --- a/app/src/flows/tasks/presets/FetchTask.ts +++ b/app/src/flows/tasks/presets/FetchTask.ts @@ -1,6 +1,7 @@ -import { StringEnum, Type } from "core/utils"; -import type { InputsMap } from "../../flows/Execution"; +import { StringEnum } from "core/utils"; import { Task, dynamic } from "../Task"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; const FetchMethods = ["GET", "POST", "PUT", "PATCH", "DELETE"]; @@ -14,8 +15,6 @@ export class FetchTask> extends Task< url: Type.String({ pattern: "^(http|https)://", }), - //method: Type.Optional(Type.Enum(FetchMethodsEnum)), - //method: Type.Optional(dynamic(Type.String({ enum: FetchMethods, default: "GET" }))), method: Type.Optional(dynamic(StringEnum(FetchMethods, { default: "GET" }))), headers: Type.Optional( dynamic( @@ -42,7 +41,6 @@ export class FetchTask> extends Task< } async execute() { - //console.log(`method: (${this.params.method})`); if (!FetchMethods.includes(this.params.method ?? "GET")) { throw this.error("Invalid method", { given: this.params.method, @@ -53,19 +51,12 @@ export class FetchTask> extends Task< const body = this.getBody(); const headers = new Headers(this.params.headers?.map((h) => [h.key, h.value])); - /*console.log("[FETCH]", { - url: this.params.url, - method: this.params.method ?? "GET", - headers, - body - });*/ const result = await fetch(this.params.url, { method: this.params.method ?? "GET", headers, body, }); - //console.log("fetch:response", result); if (!result.ok) { throw this.error("Failed to fetch", { status: result.status, @@ -74,8 +65,6 @@ export class FetchTask> extends Task< } const data = (await result.json()) as Output; - //console.log("fetch:response:data", data); - return data; } } diff --git a/app/src/flows/tasks/presets/LogTask.ts b/app/src/flows/tasks/presets/LogTask.ts index fec93b7..fda6d12 100644 --- a/app/src/flows/tasks/presets/LogTask.ts +++ b/app/src/flows/tasks/presets/LogTask.ts @@ -1,5 +1,7 @@ -import { Type } from "core/utils"; import { Task } from "../Task"; +import { $console } from "core"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; export class LogTask extends Task { type = "log"; @@ -10,7 +12,7 @@ export class LogTask extends Task { async execute() { await new Promise((resolve) => setTimeout(resolve, this.params.delay)); - console.log(`[DONE] LogTask: ${this.name}`); + $console.log(`[DONE] LogTask: ${this.name}`); return true; } } diff --git a/app/src/flows/tasks/presets/RenderTask.ts b/app/src/flows/tasks/presets/RenderTask.ts index fd3801b..4ab0a23 100644 --- a/app/src/flows/tasks/presets/RenderTask.ts +++ b/app/src/flows/tasks/presets/RenderTask.ts @@ -1,5 +1,6 @@ -import { Type } from "core/utils"; import { Task } from "../Task"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; export class RenderTask> extends Task< typeof RenderTask.schema, diff --git a/app/src/flows/tasks/presets/SubFlowTask.ts b/app/src/flows/tasks/presets/SubFlowTask.ts index 813ab3d..7832d48 100644 --- a/app/src/flows/tasks/presets/SubFlowTask.ts +++ b/app/src/flows/tasks/presets/SubFlowTask.ts @@ -1,6 +1,7 @@ -import { Type } from "core/utils"; import { Flow } from "../../flows/Flow"; import { Task, dynamic } from "../Task"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; export class SubFlowTask> extends Task< typeof SubFlowTask.schema, diff --git a/app/src/media/AppMedia.ts b/app/src/media/AppMedia.ts index 4c73b9d..51c7586 100644 --- a/app/src/media/AppMedia.ts +++ b/app/src/media/AppMedia.ts @@ -1,6 +1,6 @@ -import type { PrimaryFieldType } from "core"; -import { type Entity, EntityIndex, type EntityManager } from "data"; -import { type FileUploadedEventData, Storage, type StorageAdapter } from "media"; +import { $console, type AppEntity } from "core"; +import type { Entity, EntityManager } from "data"; +import { type FileUploadedEventData, Storage, type StorageAdapter, MediaPermissions } from "media"; import { Module } from "modules/Module"; import { type FieldSchema, @@ -13,12 +13,13 @@ import { text, } from "../data/prototype"; import { MediaController } from "./api/MediaController"; -import { ADAPTERS, buildMediaSchema, type mediaConfigSchema, registry } from "./media-schema"; +import { buildMediaSchema, type mediaConfigSchema, registry } from "./media-schema"; export type MediaFieldSchema = FieldSchema; declare module "core" { + interface Media extends AppEntity, MediaFieldSchema {} interface DB { - media: { id: PrimaryFieldType } & MediaFieldSchema; + media: Media; } } @@ -46,6 +47,7 @@ export class AppMedia extends Module { this._storage = new Storage(adapter, this.config.storage, this.ctx.emgr); this.setBuilt(); this.setupListeners(); + this.ctx.guard.registerPermissions(MediaPermissions); this.ctx.server.route(this.basepath, new MediaController(this).getController()); const media = this.getMediaEntity(true); @@ -145,11 +147,10 @@ export class AppMedia extends Module { // simple file deletion sync const { data } = await em.repo(media).findOne({ path: e.params.name }); if (data) { - console.log("item.data", data); await em.mutator(media).deleteOne(data.id); } - console.log("App:storage:file deleted", e); + $console.log("App:storage:file deleted", e.params); }, { mode: "sync", id: "delete-data-media" }, ); diff --git a/app/src/media/MediaField.ts b/app/src/media/MediaField.ts index 66fd0b6..d7760d3 100644 --- a/app/src/media/MediaField.ts +++ b/app/src/media/MediaField.ts @@ -1,5 +1,7 @@ -import { type Static, Type } from "core/utils"; +import type { Static } from "core/utils"; import { Field, baseFieldConfigSchema } from "data"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; export const mediaFieldConfigSchema = Type.Composite([ Type.Object({ diff --git a/app/src/media/api/MediaApi.ts b/app/src/media/api/MediaApi.ts index 2c3bc7e..956f2aa 100644 --- a/app/src/media/api/MediaApi.ts +++ b/app/src/media/api/MediaApi.ts @@ -6,13 +6,17 @@ import { type TInput, } from "modules/ModuleApi"; import type { FileWithPath } from "ui/elements/media/file-selector"; +import type { ApiFetcher } from "Api"; -export type MediaApiOptions = BaseModuleApiOptions & {}; +export type MediaApiOptions = BaseModuleApiOptions & { + upload_fetcher: ApiFetcher; +}; export class MediaApi extends ModuleApi { protected override getDefaultOptions(): Partial { return { basepath: "/api/media", + upload_fetcher: fetch, }; } @@ -44,7 +48,7 @@ export class MediaApi extends ModuleApi { return (await res.blob()) as File; } - getFileUploadUrl(file?: FileWithPath): string { + getFileUploadUrl(file?: { path: string }): string { if (!file) return this.getUrl("/upload"); return this.getUrl(`/upload/${file.path}`); } @@ -54,9 +58,12 @@ export class MediaApi extends ModuleApi { } getUploadHeaders(): Headers { - return new Headers({ - Authorization: `Bearer ${this.options.token}`, - }); + if (this.options.token_transport === "header" && this.options.token) { + return new Headers({ + Authorization: `Bearer ${this.options.token}`, + }); + } + return new Headers(); } protected uploadFile( @@ -106,10 +113,12 @@ export class MediaApi extends ModuleApi { filename?: string; _init?: Omit; path?: TInput; + fetcher?: ApiFetcher; } = {}, ) { if (item instanceof Request || typeof item === "string") { - const res = await this.fetcher(item); + const fetcher = opts.fetcher ?? this.options.upload_fetcher; + const res = await fetcher(item); if (!res.ok || !res.body) { throw new Error("Failed to fetch file"); } @@ -140,6 +149,7 @@ export class MediaApi extends ModuleApi { item: Request | Response | string | File | ReadableStream, opts?: { _init?: Omit; + fetcher?: typeof fetch; }, ) { return this.upload(item, { diff --git a/app/src/media/api/MediaController.ts b/app/src/media/api/MediaController.ts index 415b3a3..4783be9 100644 --- a/app/src/media/api/MediaController.ts +++ b/app/src/media/api/MediaController.ts @@ -1,10 +1,13 @@ import { isDebug, tbValidator as tb } from "core"; -import { HttpStatus, Type, getFileFromContext, headersToObject } from "core/utils"; +import { HttpStatus, getFileFromContext } from "core/utils"; import type { StorageAdapter } from "media"; -import { StorageEvents, getRandomizedFilename } from "media"; +import { StorageEvents, getRandomizedFilename, MediaPermissions } from "media"; +import { DataPermissions } from "data"; import { Controller } from "modules/Controller"; import type { AppMedia } from "../AppMedia"; import { MediaField } from "../MediaField"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; const booleanLike = Type.Transform(Type.String()) .Decode((v) => v === "1") @@ -26,18 +29,18 @@ export class MediaController extends Controller { override getController() { // @todo: multiple providers? // @todo: implement range requests - const { auth } = this.middlewares; + const { auth, permission } = this.middlewares; const hono = this.create().use(auth()); // get files list (temporary) - hono.get("/files", async (c) => { + hono.get("/files", permission(MediaPermissions.listFiles), async (c) => { const files = await this.getStorageAdapter().listObjects(); return c.json(files); }); // get file by name // @todo: implement more aggressive cache? (configurable) - hono.get("/file/:filename", async (c) => { + hono.get("/file/:filename", permission(MediaPermissions.readFile), async (c) => { const { filename } = c.req.param(); if (!filename) { throw new Error("No file name provided"); @@ -57,7 +60,7 @@ export class MediaController extends Controller { }); // delete a file by name - hono.delete("/file/:filename", async (c) => { + hono.delete("/file/:filename", permission(MediaPermissions.deleteFile), async (c) => { const { filename } = c.req.param(); if (!filename) { throw new Error("No file name provided"); @@ -82,7 +85,7 @@ export class MediaController extends Controller { // upload file // @todo: add required type for "upload endpoints" - hono.post("/upload/:filename?", async (c) => { + hono.post("/upload/:filename?", permission(MediaPermissions.uploadFile), async (c) => { const reqname = c.req.param("filename"); const body = await getFileFromContext(c); @@ -112,6 +115,7 @@ export class MediaController extends Controller { overwrite: Type.Optional(booleanLike), }), ), + permission([DataPermissions.entityCreate, MediaPermissions.uploadFile]), async (c) => { const entity_name = c.req.param("entity"); const field_name = c.req.param("field"); diff --git a/app/src/media/index.ts b/app/src/media/index.ts index a49a4f9..d9451ad 100644 --- a/app/src/media/index.ts +++ b/app/src/media/index.ts @@ -1,7 +1,6 @@ import type { TObject } from "@sinclair/typebox"; import { type Constructor, Registry } from "core"; -//export { MIME_TYPES } from "./storage/mime-types"; export { guess as guessMimeType } from "./storage/mime-types-tiny"; export { Storage, @@ -22,6 +21,7 @@ export { StorageAdapter }; export { StorageS3Adapter, type S3AdapterConfig, StorageCloudinaryAdapter, type CloudinaryConfig }; export * as StorageEvents from "./storage/events"; +export * as MediaPermissions from "./media-permissions"; export type { FileUploadedEventData } from "./storage/events"; export * from "./utils"; diff --git a/app/src/media/media-permissions.ts b/app/src/media/media-permissions.ts new file mode 100644 index 0000000..714cc2d --- /dev/null +++ b/app/src/media/media-permissions.ts @@ -0,0 +1,6 @@ +import { Permission } from "core"; + +export const readFile = new Permission("media.file.read"); +export const listFiles = new Permission("media.file.list"); +export const uploadFile = new Permission("media.file.upload"); +export const deleteFile = new Permission("media.file.delete"); diff --git a/app/src/media/media-schema.ts b/app/src/media/media-schema.ts index 7716077..f02e2a6 100644 --- a/app/src/media/media-schema.ts +++ b/app/src/media/media-schema.ts @@ -1,6 +1,8 @@ -import { Const, type Static, Type, objectTransform } from "core/utils"; +import { Const, type Static, objectTransform } from "core/utils"; import { Adapters } from "media"; import { registries } from "modules/registries"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; export const ADAPTERS = { ...Adapters, diff --git a/app/src/media/storage/adapters/cloudinary/StorageCloudinaryAdapter.ts b/app/src/media/storage/adapters/cloudinary/StorageCloudinaryAdapter.ts index 71ee9e6..6335150 100644 --- a/app/src/media/storage/adapters/cloudinary/StorageCloudinaryAdapter.ts +++ b/app/src/media/storage/adapters/cloudinary/StorageCloudinaryAdapter.ts @@ -1,7 +1,9 @@ import { hash, pickHeaders } from "core/utils"; -import { type Static, Type, parse } from "core/utils"; +import { type Static, parse } from "core/utils"; import type { FileBody, FileListObject, FileMeta } from "../../Storage"; import { StorageAdapter } from "../../StorageAdapter"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; export const cloudinaryAdapterConfig = Type.Object( { diff --git a/app/src/media/storage/adapters/s3/StorageS3Adapter.ts b/app/src/media/storage/adapters/s3/StorageS3Adapter.ts index 4154126..6462e83 100644 --- a/app/src/media/storage/adapters/s3/StorageS3Adapter.ts +++ b/app/src/media/storage/adapters/s3/StorageS3Adapter.ts @@ -7,10 +7,12 @@ import type { PutObjectRequest, } from "@aws-sdk/client-s3"; import { AwsClient, isDebug } from "core"; -import { type Static, Type, isFile, parse, pickHeaders2 } from "core/utils"; +import { type Static, isFile, parse, pickHeaders2 } from "core/utils"; import { transform } from "lodash-es"; import type { FileBody, FileListObject } from "../../Storage"; import { StorageAdapter } from "../../StorageAdapter"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; export const s3AdapterConfig = Type.Object( { diff --git a/app/src/modules/ModuleApi.ts b/app/src/modules/ModuleApi.ts index 6c453af..ebb6403 100644 --- a/app/src/modules/ModuleApi.ts +++ b/app/src/modules/ModuleApi.ts @@ -1,6 +1,7 @@ -import type { PrimaryFieldType } from "core"; +import { $console, type PrimaryFieldType } from "core"; import { isDebug } from "core/env"; import { encodeSearch } from "core/utils/reqres"; +import type { ApiFetcher } from "Api"; export type { PrimaryFieldType }; export type BaseModuleApiOptions = { @@ -24,11 +25,11 @@ export type ApiResponse = { export type TInput = string | (string | number | PrimaryFieldType)[]; export abstract class ModuleApi { - protected fetcher: typeof fetch; + protected fetcher: ApiFetcher; constructor( protected readonly _options: Partial = {}, - fetcher?: typeof fetch, + fetcher?: ApiFetcher, ) { this.fetcher = fetcher ?? fetch; } @@ -87,7 +88,6 @@ export abstract class ModuleApi> implements Promise { constructor( public request: Request, protected options?: { - fetcher?: typeof fetch; + fetcher?: ApiFetcher; verbose?: boolean; }, // keep "any" here, it gets inferred correctly with the "refine" fn @@ -245,7 +245,7 @@ export class FetchPromise> implements Promise { const fetcher = this.options?.fetcher ?? fetch; if (this.verbose) { - console.log("[FetchPromise] Request", { + $console.debug("[FetchPromise] Request", { method: this.request.method, url: this.request.url, }); @@ -253,7 +253,7 @@ export class FetchPromise> implements Promise { const res = await fetcher(this.request); if (this.verbose) { - console.log("[FetchPromise] Response", { + $console.debug("[FetchPromise] Response", { res: res, ok: res.ok, status: res.status, diff --git a/app/src/modules/ModuleManager.ts b/app/src/modules/ModuleManager.ts index fafc54f..8c1b554 100644 --- a/app/src/modules/ModuleManager.ts +++ b/app/src/modules/ModuleManager.ts @@ -6,7 +6,6 @@ import { Default, type Static, StringEnum, - Type, mark, objectEach, stripMark, @@ -34,6 +33,8 @@ import { AppFlows } from "../flows/AppFlows"; import { AppMedia } from "../media/AppMedia"; import type { ServerEnv } from "./Controller"; import { Module, type ModuleBuildContext } from "./Module"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; export type { ModuleBuildContext }; @@ -644,7 +645,7 @@ export class ModuleManager { // revert to previous config & rebuild using original listener this.revertModules(); await this.onModuleConfigUpdated(name, module.config as any); - $console.log(`[Safe Mutate] reverted "${name}":`); + $console.warn(`[Safe Mutate] reverted "${name}":`); // make sure to throw the error throw e; diff --git a/app/src/modules/middlewares.ts b/app/src/modules/middlewares.ts deleted file mode 100644 index be1ad59..0000000 --- a/app/src/modules/middlewares.ts +++ /dev/null @@ -1 +0,0 @@ -export { auth, permission } from "auth/middlewares"; diff --git a/app/src/modules/migrations.ts b/app/src/modules/migrations.ts index 59911b1..3ce4ffb 100644 --- a/app/src/modules/migrations.ts +++ b/app/src/modules/migrations.ts @@ -1,7 +1,6 @@ -import { _jsonp, transformObject } from "core/utils"; -import { type Kysely, sql } from "kysely"; +import { transformObject } from "core/utils"; +import type { Kysely } from "kysely"; import { set } from "lodash-es"; -import type { InitialModuleConfigs } from "modules/ModuleManager"; export type MigrationContext = { db: Kysely; @@ -17,7 +16,6 @@ export type Migration = { export const migrations: Migration[] = [ { version: 1, - //schema: true, up: async (config) => config, }, { @@ -28,7 +26,6 @@ export const migrations: Migration[] = [ }, { version: 3, - //schema: true, up: async (config) => config, }, { @@ -46,7 +43,6 @@ export const migrations: Migration[] = [ { version: 5, up: async (config, { db }) => { - //console.log("config", _jsonp(config)); const cors = config.server.cors?.allow_methods ?? []; set(config.server, "cors.allow_methods", [...new Set([...cors, "PATCH"])]); return config; @@ -114,15 +110,12 @@ export async function migrateTo( config: GenericConfigObject, ctx: MigrationContext, ): Promise<[number, GenericConfigObject]> { - //console.log("migrating from", current, "to", CURRENT_VERSION, config); const todo = migrations.filter((m) => m.version > current && m.version <= to); - //console.log("todo", todo.length); let updated = Object.assign({}, config); let i = 0; let version = current; for (const migration of todo) { - //console.log("-- running migration", i + 1, "of", todo.length, { version: migration.version }); try { updated = await migration.up(updated, ctx); version = migration.version; diff --git a/app/src/modules/server/AdminController.tsx b/app/src/modules/server/AdminController.tsx index 6f2752f..6105a4b 100644 --- a/app/src/modules/server/AdminController.tsx +++ b/app/src/modules/server/AdminController.tsx @@ -1,16 +1,23 @@ /** @jsxImportSource hono/jsx */ import type { App } from "App"; -import { config, isDebug } from "core"; +import { $console, config, isDebug } from "core"; import { addFlashMessage } from "core/server/flash"; import { html } from "hono/html"; import { Fragment } from "hono/jsx"; import { css, Style } from "hono/css"; import { Controller } from "modules/Controller"; import * as SystemPermissions from "modules/permissions"; +import type { TApiUser } from "Api"; const htmlBkndContextReplace = ""; +export type AdminBkndWindowContext = { + user?: TApiUser; + logout_route: string; + admin_basepath: string; +}; + // @todo: add migration to remove admin path from config export type AdminControllerOptions = { basepath?: string; @@ -80,6 +87,7 @@ export class AdminController extends Controller { const obj = { user: c.get("auth")?.user, logout_route: this.withAdminBasePath(authRoutes.logout), + admin_basepath: this.options.adminBasepath, }; const html = await this.getHtml(obj); if (!html) { @@ -99,7 +107,7 @@ export class AdminController extends Controller { onGranted: async (c) => { // @todo: add strict test to permissions middleware? if (c.get("auth")?.user) { - console.log("redirecting to success"); + $console.log("redirecting to success"); return c.redirect(authRoutes.success); } }, @@ -125,7 +133,7 @@ export class AdminController extends Controller { onDenied: async (c) => { addFlashMessage(c, "You are not authorized to access the Admin UI", "error"); - console.log("redirecting"); + $console.log("redirecting"); return c.redirect(authRoutes.login); }, }), @@ -142,7 +150,7 @@ export class AdminController extends Controller { return hono; } - private async getHtml(obj: any = {}) { + private async getHtml(obj: AdminBkndWindowContext) { const bknd_context = `window.__BKND__ = JSON.parse('${JSON.stringify(obj)}');`; if (this.options.html) { @@ -153,7 +161,7 @@ export class AdminController extends Controller { ); } - console.warn( + $console.warn( `Custom HTML needs to include '${htmlBkndContextReplace}' to inject BKND context`, ); return this.options.html as string; diff --git a/app/src/modules/server/AppServer.ts b/app/src/modules/server/AppServer.ts index 2ccb208..ba1c566 100644 --- a/app/src/modules/server/AppServer.ts +++ b/app/src/modules/server/AppServer.ts @@ -1,7 +1,10 @@ -import { Exception, isDebug } from "core"; -import { type Static, StringEnum, Type } from "core/utils"; +import { Exception, isDebug, $console } from "core"; +import { type Static, StringEnum } from "core/utils"; import { cors } from "hono/cors"; import { Module } from "modules/Module"; +import * as tbbox from "@sinclair/typebox"; +import { AuthException } from "auth/errors"; +const { Type } = tbbox; const serverMethods = ["GET", "POST", "PATCH", "PUT", "DELETE"]; @@ -29,8 +32,6 @@ export const serverConfigSchema = Type.Object( export type AppServerConfig = Static; export class AppServer extends Module { - //private admin_html?: string; - override getRestrictedPaths() { return []; } @@ -70,14 +71,17 @@ export class AppServer extends Module { this.client.onError((err, c) => { //throw err; - console.error(err); + $console.error("[AppServer:onError]", err); if (err instanceof Response) { return err; } + if (err instanceof AuthException) { + return c.json(err.toJSON(), err.getSafeErrorAndCode().code); + } + if (err instanceof Exception) { - console.log("---is exception", err.code); return c.json(err.toJSON(), err.code as any); } diff --git a/app/src/modules/server/SystemController.ts b/app/src/modules/server/SystemController.ts index d02b55d..7ade0e8 100644 --- a/app/src/modules/server/SystemController.ts +++ b/app/src/modules/server/SystemController.ts @@ -4,7 +4,6 @@ import type { App } from "App"; import { $console, tbValidator as tb } from "core"; import { StringEnum, - Type, TypeInvalidError, datetimeStringLocal, datetimeStringUTC, @@ -14,6 +13,8 @@ import { import { getRuntimeKey } from "core/utils"; import type { Context, Hono } from "hono"; import { Controller } from "modules/Controller"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; import { MODULE_NAMES, @@ -99,7 +100,7 @@ export class SystemController extends Controller { try { return c.json(await cb(), { status: 202 }); } catch (e) { - console.error(e); + $console.error("config update error", e); if (e instanceof TypeInvalidError) { return c.json( diff --git a/app/src/modules/server/openapi.ts b/app/src/modules/server/openapi.ts index 12d59ef..f551467 100644 --- a/app/src/modules/server/openapi.ts +++ b/app/src/modules/server/openapi.ts @@ -1,6 +1,7 @@ -import { Type } from "core/utils"; import type { ModuleConfigs } from "modules/ModuleManager"; import type { OpenAPIV3 as OAS } from "openapi-types"; +import * as tbbox from "@sinclair/typebox"; +const { Type } = tbbox; function prefixPaths(paths: OAS.PathsObject, prefix: string): OAS.PathsObject { const result: OAS.PathsObject = {}; diff --git a/app/src/ui/Admin.tsx b/app/src/ui/Admin.tsx index d69738f..1c73a58 100644 --- a/app/src/ui/Admin.tsx +++ b/app/src/ui/Admin.tsx @@ -7,7 +7,6 @@ import { Logo } from "ui/components/display/Logo"; import * as AppShell from "ui/layouts/AppShell/AppShell"; import { ClientProvider, type ClientProviderProps } from "./client"; import { createMantineTheme } from "./lib/mantine/theme"; -import { BkndModalsProvider } from "./modals"; import { Routes } from "./routes"; export type BkndAdminProps = { diff --git a/app/src/ui/client/ClientProvider.tsx b/app/src/ui/client/ClientProvider.tsx index 20eb9a1..18926eb 100644 --- a/app/src/ui/client/ClientProvider.tsx +++ b/app/src/ui/client/ClientProvider.tsx @@ -1,6 +1,7 @@ import { Api, type ApiOptions, type TApiUser } from "Api"; import { isDebug } from "core"; import { createContext, type ReactNode, useContext } from "react"; +import type { AdminBkndWindowContext } from "modules/server/AdminController"; const ClientContext = createContext<{ baseUrl: string; api: Api }>({ baseUrl: undefined, @@ -68,16 +69,18 @@ export const useBaseUrl = () => { return context.baseUrl; }; -type BkndWindowContext = { - user?: TApiUser; - logout_route: string; -}; -export function useBkndWindowContext(): BkndWindowContext { +export function useBkndWindowContext(): AdminBkndWindowContext { + const defaults = { + logout_route: "/api/auth/logout", + admin_basepath: "", + }; + if (typeof window !== "undefined" && window.__BKND__) { - return window.__BKND__ as any; - } else { return { - logout_route: "/api/auth/logout", + ...defaults, + ...window.__BKND__, }; } + + return defaults; } diff --git a/app/src/ui/client/api/use-entity.ts b/app/src/ui/client/api/use-entity.ts index b7e253d..3995e56 100644 --- a/app/src/ui/client/api/use-entity.ts +++ b/app/src/ui/client/api/use-entity.ts @@ -1,9 +1,10 @@ import type { DB, PrimaryFieldType } from "core"; import { objectTransform } from "core/utils/objects"; import { encodeSearch } from "core/utils/reqres"; -import type { EntityData, RepoQueryIn } from "data"; -import type { ModuleApi, ResponseObject } from "modules/ModuleApi"; -import useSWR, { type SWRConfiguration, mutate } from "swr"; +import type { EntityData, RepoQueryIn, RepositoryResponse } from "data"; +import type { Insertable, Selectable, Updateable } from "kysely"; +import type { FetchPromise, ModuleApi, ResponseObject } from "modules/ModuleApi"; +import useSWR, { type SWRConfiguration, type SWRResponse, mutate } from "swr"; import { type Api, useApi } from "ui/client"; export class UseEntityApiError extends Error { @@ -23,6 +24,26 @@ export class UseEntityApiError extends Error { } } +interface UseEntityReturn< + Entity extends keyof DB | string, + Id extends PrimaryFieldType | undefined, + Data = Entity extends keyof DB ? DB[Entity] : EntityData, + Response = ResponseObject>>, +> { + create: (input: Insertable) => Promise; + read: ( + query?: RepoQueryIn, + ) => Promise< + ResponseObject< + RepositoryResponse[] : Selectable> + > + >; + update: Id extends undefined + ? (input: Updateable, id: Id) => Promise + : (input: Updateable) => Promise; + _delete: Id extends undefined ? (id: Id) => Promise : () => Promise; +} + export const useEntity = < Entity extends keyof DB | string, Id extends PrimaryFieldType | undefined = undefined, @@ -30,28 +51,26 @@ export const useEntity = < >( entity: Entity, id?: Id, -) => { +): UseEntityReturn => { const api = useApi().data; return { - create: async (input: Omit) => { - const res = await api.createOne(entity, input); + create: async (input: Insertable) => { + const res = await api.createOne(entity, input as any); if (!res.ok) { throw new UseEntityApiError(res, `Failed to create entity "${entity}"`); } - return res; + return res as any; }, - read: async (query: RepoQueryIn = {}) => { + read: async (query?: RepoQueryIn) => { const res = id ? await api.readOne(entity, id!, query) : await api.readMany(entity, query); if (!res.ok) { throw new UseEntityApiError(res as any, `Failed to read entity "${entity}"`); } - // must be manually typed - return res as unknown as Id extends undefined - ? ResponseObject - : ResponseObject; + return res as any; }, - update: async (input: Partial>, _id: PrimaryFieldType | undefined = id) => { + // @ts-ignore + update: async (input: Updateable, _id: PrimaryFieldType | undefined = id) => { if (!_id) { throw new Error("id is required"); } @@ -59,8 +78,9 @@ export const useEntity = < if (!res.ok) { throw new UseEntityApiError(res, `Failed to update entity "${entity}"`); } - return res; + return res as any; }, + // @ts-ignore _delete: async (_id: PrimaryFieldType | undefined = id) => { if (!_id) { throw new Error("id is required"); @@ -70,7 +90,7 @@ export const useEntity = < if (!res.ok) { throw new UseEntityApiError(res, `Failed to delete entity "${entity}"`); } - return res; + return res as any; }, }; }; @@ -91,6 +111,19 @@ export function makeKey( ); } +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, +> extends Omit, "mutate">, + Omit>, "read"> { + mutate: (id?: PrimaryFieldType) => Promise; + mutateRaw: SWRResponse["mutate"]; + api: Api["data"]; + key: string; +} + export const useEntityQuery = < Entity extends keyof DB | string, Id extends PrimaryFieldType | undefined = undefined, @@ -99,11 +132,11 @@ export const useEntityQuery = < id?: Id, query?: RepoQueryIn, options?: SWRConfiguration & { enabled?: boolean; revalidateOnMutate?: boolean }, -) => { +): UseEntityQueryReturn => { const api = useApi().data; const key = makeKey(api, entity as string, id, query); const { read, ...actions } = useEntity(entity, id); - const fetcher = () => read(query); + const fetcher = () => read(query ?? {}); type T = Awaited>; const swr = useSWR(options?.enabled === false ? null : key, fetcher as any, { @@ -112,8 +145,8 @@ export const useEntityQuery = < ...options, }); - const mutateAll = async () => { - const entityKey = makeKey(api, entity as string); + const mutateFn = async (id?: PrimaryFieldType) => { + const entityKey = makeKey(api, entity as string, id); return mutate((key) => typeof key === "string" && key.startsWith(entityKey), undefined, { revalidate: true, }); @@ -126,7 +159,7 @@ export const useEntityQuery = < // mutate all keys of entity by default if (options?.revalidateOnMutate !== false) { - await mutateAll(); + await mutateFn(); } return res; }; @@ -135,7 +168,8 @@ export const useEntityQuery = < return { ...swr, ...mapped, - mutate: mutateAll, + mutate: mutateFn, + // @ts-ignore mutateRaw: swr.mutate, api, key, @@ -144,8 +178,8 @@ 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) { + Data = Entity extends keyof DB ? DB[Entity] : EntityData, +>(api: Api["data"], entity: Entity, id: PrimaryFieldType, partialData: Partial>) { function update(prev: any, partialNext: any) { if ( typeof prev !== "undefined" && @@ -176,28 +210,37 @@ export async function mutateEntityCache< ); } +interface UseEntityMutateReturn< + Entity extends keyof DB | string, + Id extends PrimaryFieldType | undefined = undefined, + Data = Entity extends keyof DB ? DB[Entity] : EntityData, +> extends Omit>, "mutate"> { + mutate: Id extends undefined + ? (id: PrimaryFieldType, data: Partial>) => Promise + : (data: Partial>) => Promise; +} + export const useEntityMutate = < Entity extends keyof DB | string, Id extends PrimaryFieldType | undefined = undefined, - Data = Entity extends keyof DB ? Omit : EntityData, + Data = Entity extends keyof DB ? DB[Entity] : EntityData, >( entity: Entity, id?: Id, options?: SWRConfiguration, -) => { +): UseEntityMutateReturn => { const { data, ...$q } = useEntityQuery(entity, id, undefined, { ...options, enabled: false, }); const _mutate = id - ? (data) => mutateEntityCache($q.api, entity, id, data) - : (id, data) => 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, - mutate: _mutate as unknown as Id extends undefined - ? (id: PrimaryFieldType, data: Partial) => Promise - : (data: Partial) => Promise, - }; + mutate: _mutate, + } as any; }; 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 391b62a..d9c4ca0 100644 --- a/app/src/ui/client/schema/data/use-bknd-data.ts +++ b/app/src/ui/client/schema/data/use-bknd-data.ts @@ -1,4 +1,4 @@ -import { Type, TypeInvalidError, parse, transformObject } from "core/utils"; +import { TypeInvalidError, parse, transformObject } from "core/utils"; import { constructEntity } from "data"; import { type TAppDataEntity, @@ -13,6 +13,8 @@ import { import { useBknd } from "ui/client/bknd"; import type { TSchemaActions } from "ui/client/schema/actions"; import { bkndModals } from "ui/modals"; +import * as tb from "@sinclair/typebox"; +const { Type } = tb; export function useBkndData() { const { config, app, schema, actions: bkndActions } = useBknd(); diff --git a/app/src/ui/components/canvas/panels/index.tsx b/app/src/ui/components/canvas/panels/index.tsx index 8d423ff..7fedb11 100644 --- a/app/src/ui/components/canvas/panels/index.tsx +++ b/app/src/ui/components/canvas/panels/index.tsx @@ -35,16 +35,14 @@ export function Panels({ children, ...props }: PanelsProps) { )} {props.zoom && ( - <> - - - - {percent}% - - - - - + + + + {percent}% + + + + )} {props.minimap && ( <> diff --git a/app/src/ui/components/code/CodeEditor.tsx b/app/src/ui/components/code/CodeEditor.tsx index 480cb79..6c81434 100644 --- a/app/src/ui/components/code/CodeEditor.tsx +++ b/app/src/ui/components/code/CodeEditor.tsx @@ -1,12 +1,12 @@ import { default as CodeMirror, type ReactCodeMirrorProps } from "@uiw/react-codemirror"; import { json } from "@codemirror/lang-json"; -import { type LiquidCompletionConfig, liquid } from "@codemirror/lang-liquid"; +import { html } from "@codemirror/lang-html"; import { useTheme } from "ui/client/use-theme"; export type CodeEditorProps = ReactCodeMirrorProps & { _extensions?: Partial<{ json: boolean; - liquid: LiquidCompletionConfig; + html: boolean; }>; }; @@ -31,7 +31,8 @@ export default function CodeEditor({ case "json": return json(); case "liquid": - return liquid(config); + case "html": + return html(config); } return undefined; }) diff --git a/app/src/ui/components/code/HtmlEditor.tsx b/app/src/ui/components/code/HtmlEditor.tsx new file mode 100644 index 0000000..1716f1c --- /dev/null +++ b/app/src/ui/components/code/HtmlEditor.tsx @@ -0,0 +1,23 @@ +import { Suspense, lazy } from "react"; +import { twMerge } from "tailwind-merge"; + +import type { CodeEditorProps } from "./CodeEditor"; +const CodeEditor = lazy(() => import("./CodeEditor")); + +export function HtmlEditor({ editable, ...props }: CodeEditorProps) { + return ( + + + + ); +} diff --git a/app/src/ui/components/code/LiquidJsEditor.tsx b/app/src/ui/components/code/LiquidJsEditor.tsx deleted file mode 100644 index d73c6f1..0000000 --- a/app/src/ui/components/code/LiquidJsEditor.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { Suspense, lazy } from "react"; -import { twMerge } from "tailwind-merge"; - -import type { CodeEditorProps } from "./CodeEditor"; -const CodeEditor = lazy(() => import("./CodeEditor")); - -const filters = [ - { label: "abs" }, - { label: "append" }, - { label: "array_to_sentence_string" }, - { label: "at_least" }, - { label: "at_most" }, - { label: "capitalize" }, - { label: "ceil" }, - { label: "cgi_escape" }, - { label: "compact" }, - { label: "concat" }, - { label: "date" }, - { label: "date_to_long_string" }, - { label: "date_to_rfc822" }, - { label: "date_to_string" }, - { label: "date_to_xmlschema" }, - { label: "default" }, - { label: "divided_by" }, - { label: "downcase" }, - { label: "escape" }, - { label: "escape_once" }, - { label: "find" }, - { label: "find_exp" }, - { label: "first" }, - { label: "floor" }, - { label: "group_by" }, - { label: "group_by_exp" }, - { label: "inspect" }, - { label: "join" }, - { label: "json" }, - { label: "jsonify" }, - { label: "last" }, - { label: "lstrip" }, - { label: "map" }, - { label: "minus" }, - { label: "modulo" }, - { label: "newline_to_br" }, - { label: "normalize_whitespace" }, - { label: "number_of_words" }, - { label: "plus" }, - { label: "pop" }, - { label: "push" }, - { label: "prepend" }, - { label: "raw" }, - { label: "remove" }, - { label: "remove_first" }, - { label: "remove_last" }, - { label: "replace" }, - { label: "replace_first" }, - { label: "replace_last" }, - { label: "reverse" }, - { label: "round" }, - { label: "rstrip" }, - { label: "shift" }, - { label: "size" }, - { label: "slice" }, - { label: "slugify" }, - { label: "sort" }, - { label: "sort_natural" }, - { label: "split" }, - { label: "strip" }, - { label: "strip_html" }, - { label: "strip_newlines" }, - { label: "sum" }, - { label: "times" }, - { label: "to_integer" }, - { label: "truncate" }, - { label: "truncatewords" }, - { label: "uniq" }, - { label: "unshift" }, - { label: "upcase" }, - { label: "uri_escape" }, - { label: "url_decode" }, - { label: "url_encode" }, - { label: "where" }, - { label: "where_exp" }, - { label: "xml_escape" }, -]; - -const tags = [ - { label: "assign" }, - { label: "capture" }, - { label: "case" }, - { label: "comment" }, - { label: "cycle" }, - { label: "decrement" }, - { label: "echo" }, - { label: "else" }, - { label: "elsif" }, - { label: "for" }, - { label: "if" }, - { label: "include" }, - { label: "increment" }, - { label: "layout" }, - { label: "liquid" }, - { label: "raw" }, - { label: "render" }, - { label: "tablerow" }, - { label: "unless" }, - { label: "when" }, -]; - -export function LiquidJsEditor({ editable, ...props }: CodeEditorProps) { - return ( - - - - ); -} diff --git a/app/src/ui/components/form/json-schema/fields/LiquidJsField.tsx b/app/src/ui/components/form/json-schema/fields/HtmlField.tsx similarity index 71% rename from app/src/ui/components/form/json-schema/fields/LiquidJsField.tsx rename to app/src/ui/components/form/json-schema/fields/HtmlField.tsx index 830bb6b..0bb3a46 100644 --- a/app/src/ui/components/form/json-schema/fields/LiquidJsField.tsx +++ b/app/src/ui/components/form/json-schema/fields/HtmlField.tsx @@ -1,9 +1,9 @@ import type { FieldProps } from "@rjsf/utils"; -import { LiquidJsEditor } from "../../../code/LiquidJsEditor"; import { Label } from "../templates/FieldTemplate"; +import { HtmlEditor } from "ui/components/code/HtmlEditor"; // @todo: move editor to lazy loading component -export default function LiquidJsField({ +export default function HtmlField({ formData, onChange, disabled, @@ -20,7 +20,7 @@ export default function LiquidJsField({ return (
); } diff --git a/app/src/ui/components/form/json-schema/fields/index.ts b/app/src/ui/components/form/json-schema/fields/index.ts index 6b19481..6c499c6 100644 --- a/app/src/ui/components/form/json-schema/fields/index.ts +++ b/app/src/ui/components/form/json-schema/fields/index.ts @@ -1,10 +1,10 @@ import JsonField from "./JsonField"; -import LiquidJsField from "./LiquidJsField"; import MultiSchemaField from "./MultiSchemaField"; +import HtmlField from "./HtmlField"; export const fields = { AnyOfField: MultiSchemaField, OneOfField: MultiSchemaField, JsonField, - LiquidJsField, + HtmlField, }; diff --git a/app/src/ui/components/form/json-schema/templates/BaseInputTemplate.tsx b/app/src/ui/components/form/json-schema/templates/BaseInputTemplate.tsx index cc384dd..1e5e80f 100644 --- a/app/src/ui/components/form/json-schema/templates/BaseInputTemplate.tsx +++ b/app/src/ui/components/form/json-schema/templates/BaseInputTemplate.tsx @@ -55,7 +55,7 @@ export default function BaseInputTemplate< ...getInputProps(schema, type, options), }; - let inputValue; + let inputValue: any; if (inputProps.type === "number" || inputProps.type === "integer") { inputValue = value || value === 0 ? value : ""; } else { @@ -68,11 +68,11 @@ export default function BaseInputTemplate< [onChange, options], ); const _onBlur = useCallback( - ({ target }: FocusEvent) => onBlur(id, target && target.value), + ({ target }: FocusEvent) => onBlur(id, target?.value), [onBlur, id], ); const _onFocus = useCallback( - ({ target }: FocusEvent) => onFocus(id, target && target.value), + ({ target }: FocusEvent) => onFocus(id, target?.value), [onFocus, id], ); diff --git a/app/src/ui/components/form/native-form/NativeForm.tsx b/app/src/ui/components/form/native-form/NativeForm.tsx index 066bbee..15b6d5c 100644 --- a/app/src/ui/components/form/native-form/NativeForm.tsx +++ b/app/src/ui/components/form/native-form/NativeForm.tsx @@ -28,6 +28,7 @@ export type NativeFormProps = { ctx: { event: FormEvent }, ) => Promise | void; onError?: (errors: InputError[]) => void; + disableSubmitOnError?: boolean; onChange?: ( data: any, ctx: { event: ChangeEvent; key: string; value: any; errors: InputError[] }, @@ -50,6 +51,7 @@ export function NativeForm({ onSubmitInvalid, onError, clean, + disableSubmitOnError = true, ...props }: NativeFormProps) { const formRef = useRef(null); @@ -74,7 +76,7 @@ export function NativeForm({ onError?.(errors); }, [errors]); - const validateElement = useEvent((el: InputElement | null, opts?: { report?: boolean }) => { + const validateElement = (el: InputElement | null, opts?: { report?: boolean }) => { if (props.noValidate || !el || !("name" in el)) return; const errorElement = formRef.current?.querySelector( errorFieldSelector?.(el.name) ?? `[data-role="input-error"][data-name="${el.name}"]`, @@ -104,9 +106,9 @@ export function NativeForm({ } return; - }); + }; - const validate = useEvent((opts?: { report?: boolean }) => { + const validate = (opts?: { report?: boolean }) => { if (!formRef.current || props.noValidate) return []; const errors: InputError[] = []; @@ -118,10 +120,20 @@ export function NativeForm({ } }); - return errors; - }); + if (disableSubmitOnError) { + formRef.current.querySelectorAll("[type=submit]").forEach((submit) => { + if (errors.length > 0) { + submit.setAttribute("disabled", "disabled"); + } else { + submit.removeAttribute("disabled"); + } + }); + } - const getFormValues = useEvent(() => { + return errors; + }; + + const getFormValues = () => { if (!formRef.current) return {}; const formData = new FormData(formRef.current); @@ -148,15 +160,15 @@ export function NativeForm({ if (typeof clean === "undefined") return obj; return cleanObject(obj, clean === true ? undefined : clean); - }); + }; - const handleChange = useEvent(async (e: ChangeEvent) => { + const handleChange = async (e: ChangeEvent) => { const form = formRef.current; if (!form) return; const target = getFormTarget(e); if (!target) return; - if (validateOn === "change") { + if (validateOn === "change" || errors.length > 0) { validateElement(target, { report: true }); } @@ -168,9 +180,9 @@ export function NativeForm({ errors, }); } - }); + }; - const handleSubmit = useEvent(async (e: FormEvent) => { + const handleSubmit = async (e: FormEvent) => { e.preventDefault(); const form = formRef.current; if (!form) return; @@ -186,9 +198,9 @@ export function NativeForm({ } else { form.submit(); } - }); + }; - const handleKeyDown = useEvent((e: KeyboardEvent) => { + const handleKeyDown = (e: KeyboardEvent) => { if (!formRef.current) return; // if is enter key, submit is disabled, report errors @@ -198,7 +210,7 @@ export function NativeForm({ formRef.current.reportValidity(); } } - }); + }; return (
{ async validate(schema: TSchema, data: any) { diff --git a/app/src/ui/elements/media/Dropzone.tsx b/app/src/ui/elements/media/Dropzone.tsx index 57d6c48..bc2cb08 100644 --- a/app/src/ui/elements/media/Dropzone.tsx +++ b/app/src/ui/elements/media/Dropzone.tsx @@ -1,20 +1,21 @@ import type { DB } from "core"; import { type ComponentPropsWithRef, - type ComponentPropsWithoutRef, + createContext, type ReactNode, type RefObject, - memo, + useCallback, + useContext, useEffect, + useMemo, useRef, useState, } from "react"; -import { TbDots, TbExternalLink, TbTrash, TbUpload } from "react-icons/tb"; -import { twMerge } from "tailwind-merge"; -import { IconButton } from "ui/components/buttons/IconButton"; -import { Dropdown, type DropdownItem } from "ui/components/overlay/Dropdown"; import { type FileWithPath, useDropzone } from "./use-dropzone"; -import { formatNumber } from "core/utils"; +import { checkMaxReached } from "./helper"; +import { DropzoneInner } from "./DropzoneInner"; +import { createDropzoneStore } from "ui/elements/media/dropzone-state"; +import { useStore } from "zustand"; export type FileState = { body: FileWithPath | string; @@ -29,27 +30,23 @@ export type FileState = { export type FileStateWithData = FileState & { data: DB["media"] }; export type DropzoneRenderProps = { + store: ReturnType; wrapperRef: RefObject; inputProps: ComponentPropsWithRef<"input">; - state: { - files: FileState[]; - isOver: boolean; - isOverAccepted: boolean; - showPlaceholder: boolean; - }; actions: { - uploadFile: (file: FileState) => Promise; - deleteFile: (file: FileState) => Promise; + uploadFile: (file: { path: string }) => Promise; + deleteFile: (file: { path: string }) => Promise; openFileInput: () => void; }; - onClick?: (file: FileState) => void; + showPlaceholder: boolean; + onClick?: (file: { path: string }) => void; footer?: ReactNode; dropzoneProps: Pick; }; export type DropzoneProps = { - getUploadInfo: (file: FileWithPath) => { url: string; headers?: Headers; method?: string }; - handleDelete: (file: FileState) => Promise; + getUploadInfo: (file: { path: string }) => { url: string; headers?: Headers; method?: string }; + handleDelete: (file: { path: string }) => Promise; initialItems?: FileState[]; flow?: "start" | "end"; maxItems?: number; @@ -57,7 +54,7 @@ export type DropzoneProps = { overwrite?: boolean; autoUpload?: boolean; onRejected?: (files: FileWithPath[]) => void; - onDeleted?: (file: FileState) => void; + onDeleted?: (file: { path: string }) => void; onUploaded?: (files: FileStateWithData[]) => void; onClick?: (file: FileState) => void; placeholder?: { @@ -65,7 +62,7 @@ export type DropzoneProps = { text?: string; }; footer?: ReactNode; - children?: (props: DropzoneRenderProps) => ReactNode; + children?: ReactNode | ((props: DropzoneRenderProps) => ReactNode); }; function handleUploadError(e: unknown) { @@ -94,30 +91,21 @@ export function Dropzone({ onClick, footer, }: DropzoneProps) { - const [files, setFiles] = useState(initialItems); - const [uploading, setUploading] = useState(false); + const store = useRef(createDropzoneStore()).current; + const files = useStore(store, (state) => state.files); + const setFiles = useStore(store, (state) => state.setFiles); + const getFilesLength = useStore(store, (state) => state.getFilesLength); + const setUploading = useStore(store, (state) => state.setUploading); + const setIsOver = useStore(store, (state) => state.setIsOver); + const uploading = useStore(store, (state) => state.uploading); + const setFileState = useStore(store, (state) => state.setFileState); + const removeFile = useStore(store, (state) => state.removeFile); const inputRef = useRef(null); - const [isOverAccepted, setIsOverAccepted] = useState(false); - function isMaxReached(added: number): boolean { - if (!maxItems) { - console.log("maxItems is undefined, never reached"); - return false; - } - - const current = files.length; - const remaining = maxItems - current; - console.log("isMaxReached", { added, current, remaining, maxItems, overwrite }); - - // if overwrite is set, but added is bigger than max items - if (overwrite) { - console.log("added > maxItems, stop?", added > maxItems); - return added > maxItems; - } - console.log("remaining > added, stop?", remaining > added); - // or remaining doesn't suffice, stop - return added > remaining; - } + useEffect(() => { + // @todo: potentially keep pending ones + setFiles(() => initialItems); + }, [initialItems.length]); function isAllowed(i: DataTransferItem | DataTransferItem[] | File | File[]): boolean { const items = Array.isArray(i) ? i : [i]; @@ -135,31 +123,41 @@ export function Dropzone({ }); } - const { isOver, handleFileInputChange, ref } = useDropzone({ + const { handleFileInputChange, ref } = useDropzone({ onDropped: (newFiles: FileWithPath[]) => { + console.log("onDropped", newFiles); if (!isAllowed(newFiles)) return; - let to_drop = 0; const added = newFiles.length; - if (maxItems) { - if (isMaxReached(added)) { - if (onRejected) { - onRejected(newFiles); - } else { - console.warn("maxItems reached"); + // Check max files using the current state, not a stale closure + setFiles((currentFiles) => { + let to_drop = 0; + + if (maxItems) { + const $max = checkMaxReached({ + maxItems, + overwrite, + added, + current: currentFiles.length, + }); + + if ($max.reject) { + if (onRejected) { + onRejected(newFiles); + } else { + console.warn("maxItems reached"); + } + + // Return current state unchanged if rejected + return currentFiles; } - return; + to_drop = $max.to_drop; } - to_drop = added; - } - - console.log("files", newFiles, { to_drop }); - setFiles((prev) => { // drop amount calculated - const _prev = prev.slice(to_drop); + const _prev = currentFiles.slice(to_drop); // prep new files const currentPaths = _prev.map((f) => f.path); @@ -175,24 +173,35 @@ export function Dropzone({ progress: 0, })); - return flow === "start" ? [...filteredFiles, ..._prev] : [..._prev, ...filteredFiles]; - }); + const updatedFiles = + flow === "start" ? [...filteredFiles, ..._prev] : [..._prev, ...filteredFiles]; - if (autoUpload) { - setUploading(true); - } + if (autoUpload && filteredFiles.length > 0) { + // Schedule upload for the next tick to ensure state is updated + setTimeout(() => setUploading(true), 0); + } + + return updatedFiles; + }); }, onOver: (items) => { if (!isAllowed(items)) { - setIsOverAccepted(false); + setIsOver(true, false); return; } - const max_reached = isMaxReached(items.length); - setIsOverAccepted(!max_reached); + const current = getFilesLength(); + const $max = checkMaxReached({ + maxItems, + overwrite, + added: items.length, + current, + }); + console.log("--files in onOver", current, $max); + setIsOver(true, !$max.reject); }, onLeave: () => { - setIsOverAccepted(false); + setIsOver(false, false); }, }); @@ -223,40 +232,6 @@ export function Dropzone({ } }, [uploading]); - function setFileState(path: string, state: FileState["state"], progress?: number) { - setFiles((prev) => - prev.map((f) => { - //console.log("compare", f.path, path, f.path === path); - if (f.path === path) { - return { - ...f, - state, - progress: progress ?? f.progress, - }; - } - return f; - }), - ); - } - - function replaceFileState(prevPath: string, newState: Partial) { - setFiles((prev) => - prev.map((f) => { - if (f.path === prevPath) { - return { - ...f, - ...newState, - }; - } - return f; - }), - ); - } - - function removeFileFromState(path: string) { - setFiles((prev) => prev.filter((f) => f.path !== path)); - } - function uploadFileProgress(file: FileState): Promise { return new Promise((resolve, reject) => { if (!file.body) { @@ -273,7 +248,7 @@ export function Dropzone({ return; } - const uploadInfo = getUploadInfo(file.body); + const uploadInfo = getUploadInfo({ path: file.body.path! }); console.log("dropzone:uploadInfo", uploadInfo); const { url, headers, method = "POST" } = uploadInfo; @@ -322,7 +297,7 @@ export function Dropzone({ state: "uploaded", }; - replaceFileState(file.path, newState); + setFileState(file.path, newState.state); resolve({ ...response, ...file, ...newState }); } catch (e) { setFileState(file.path, "uploaded", 1); @@ -349,7 +324,7 @@ export function Dropzone({ }); } - async function deleteFile(file: FileState) { + const deleteFile = useCallback(async (file: FileState) => { console.log("deleteFile", file); switch (file.state) { case "uploaded": @@ -358,232 +333,97 @@ export function Dropzone({ console.log('setting state to "deleting"', file); setFileState(file.path, "deleting"); await handleDelete(file); - removeFileFromState(file.path); + removeFile(file.path); onDeleted?.(file); } break; } - } + }, []); - async function uploadFile(file: FileState) { + const uploadFile = useCallback(async (file: FileState) => { const result = await uploadFileProgress(file); onUploaded?.([result]); - } + }, []); - const openFileInput = () => inputRef.current?.click(); - const showPlaceholder = Boolean( - placeholder?.show === true || !maxItems || (maxItems && files.length < maxItems), + const openFileInput = useCallback(() => inputRef.current?.click(), [inputRef]); + const showPlaceholder = useMemo( + () => + Boolean(placeholder?.show === true || !maxItems || (maxItems && files.length < maxItems)), + [placeholder, maxItems, files.length], ); - const renderProps: DropzoneRenderProps = { - wrapperRef: ref, - inputProps: { - ref: inputRef, - type: "file", - multiple: !maxItems || maxItems > 1, - onChange: handleFileInputChange, - }, - state: { - files, - isOver, - isOverAccepted, + const renderProps = useMemo( + () => ({ + store, + wrapperRef: ref, + inputProps: { + ref: inputRef, + type: "file", + multiple: !maxItems || maxItems > 1, + onChange: handleFileInputChange, + }, showPlaceholder, - }, - actions: { - uploadFile, - deleteFile, - openFileInput, - }, - dropzoneProps: { - maxItems, - placeholder, - autoUpload, - flow, - }, - onClick, - footer, - }; + actions: { + uploadFile, + deleteFile, + openFileInput, + }, + dropzoneProps: { + maxItems, + placeholder, + autoUpload, + flow, + }, + onClick, + footer, + }), + [maxItems, flow, placeholder, autoUpload, footer], + ) as unknown as DropzoneRenderProps; - return children ? children(renderProps) : ; + return ( + + {children ? ( + typeof children === "function" ? ( + children(renderProps) + ) : ( + children + ) + ) : ( + + )} + + ); } -const DropzoneInner = ({ - wrapperRef, - inputProps, - state: { files, isOver, isOverAccepted, showPlaceholder }, - actions: { uploadFile, deleteFile, openFileInput }, - dropzoneProps: { placeholder, flow }, - onClick, - footer, -}: DropzoneRenderProps) => { - const Placeholder = showPlaceholder && ( - - ); +const DropzoneContext = createContext(undefined!); - async function uploadHandler(file: FileState) { - try { - return await uploadFile(file); - } catch (e) { - handleUploadError(e); - } - } +export function useDropzoneContext() { + return useContext(DropzoneContext); +} - return ( -
-
- -
-
-
- {flow === "start" && Placeholder} - {files.map((file) => ( - - ))} - {flow === "end" && Placeholder} - {footer} -
-
-
- ); +export const useDropzoneState = () => { + const { store } = useDropzoneContext(); + const files = useStore(store, (state) => state.files); + const isOver = useStore(store, (state) => state.isOver); + const isOverAccepted = useStore(store, (state) => state.isOverAccepted); + + return { + files, + isOver, + isOverAccepted, + }; }; -const UploadPlaceholder = ({ onClick, text = "Upload files" }) => { - return ( -
- {text} -
- ); -}; - -export type PreviewComponentProps = { - file: FileState; - fallback?: (props: { file: FileState }) => ReactNode; - className?: string; - onClick?: () => void; - onTouchStart?: () => void; -}; - -const Wrapper = ({ file, fallback, ...props }: PreviewComponentProps) => { - if (file.type.startsWith("image/")) { - return ; - } - - if (file.type.startsWith("video/")) { - return ; - } - - return fallback ? fallback({ file }) : null; -}; -export const PreviewWrapperMemoized = memo( - Wrapper, - (prev, next) => prev.file.path === next.file.path, -); - -type PreviewProps = { - file: FileState; - handleUpload: (file: FileState) => Promise; - handleDelete: (file: FileState) => Promise; - onClick?: (file: FileState) => void; -}; -const Preview = ({ file, handleUpload, handleDelete, onClick }: PreviewProps) => { - const dropdownItems = [ - file.state === "uploaded" && - typeof file.body === "string" && { - label: "Open", - icon: TbExternalLink, - onClick: () => { - window.open(file.body as string, "_blank"); - }, - }, - ["initial", "uploaded"].includes(file.state) && { - label: "Delete", - destructive: true, - icon: TbTrash, - onClick: () => handleDelete(file), - }, - ["initial", "pending"].includes(file.state) && { - label: "Upload", - icon: TbUpload, - onClick: () => handleUpload(file), - }, - ] satisfies (DropdownItem | boolean)[]; - - return ( -
{ - if (onClick) { - onClick(file); - } - }} - > -
- - - -
- {file.state === "uploading" && ( -
-
-
- )} -
- -
-
-

{file.name}

-
- {file.type} - {formatNumber.fileSize(file.size)} -
-
-
- ); -}; - -const ImagePreview = ({ - file, - ...props -}: { file: FileState } & ComponentPropsWithoutRef<"img">) => { - const objectUrl = typeof file.body === "string" ? file.body : URL.createObjectURL(file.body); - return ; -}; - -const VideoPreview = ({ - file, - ...props -}: { file: FileState } & ComponentPropsWithoutRef<"video">) => { - const objectUrl = typeof file.body === "string" ? file.body : URL.createObjectURL(file.body); - return