From 1215a98afcf4e763b65746fac634617a730ed33a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoffer=20R=C3=B8dvik?= Date: Mon, 11 Aug 2025 20:45:41 +0200 Subject: [PATCH] feat: initial boilerplate --- .github/workflows/publish.yml | 46 - .github/workflows/test.yml | 38 - .gitignore | 1 + .prettierrc | 14 + .vscode/settings.json | 28 +- Dockerfile | 19 + LICENSE | 16 - README.md | 95 +- adapters/http.ts | 51 - api/config.ts | 9 + api/deno.json | 6 + api/libraries/auth/.keys/private | 28 + api/libraries/auth/.keys/public | 9 + api/libraries/auth/auth.ts | 87 ++ api/libraries/auth/config.ts | 25 + api/libraries/auth/mod.ts | 6 + api/libraries/config/libraries/args.ts | 21 + api/libraries/config/libraries/environment.ts | 79 + api/libraries/config/libraries/parsers.ts | 98 ++ api/libraries/config/mod.ts | 3 + api/libraries/crypto/mod.ts | 1 + api/libraries/crypto/password.ts | 11 + api/libraries/database/accessor.ts | 48 + api/libraries/database/config.ts | 10 + api/libraries/database/connection.ts | 24 + api/libraries/database/container.ts | 6 + api/libraries/database/id.ts | 3 + api/libraries/database/registrar.ts | 30 + api/libraries/database/tasks/bootstrap.ts | 5 + api/libraries/database/utilities.ts | 37 + .../event-store/aggregates/account.ts | 268 ++++ api/libraries/event-store/aggregates/code.ts | 78 + api/libraries/event-store/aggregates/mod.ts | 8 + .../event-store/aggregates/organization.ts | 65 + api/libraries/event-store/aggregates/role.ts | 118 ++ api/libraries/event-store/event-store.ts | 25 + api/libraries/event-store/events/account.ts | 29 + api/libraries/event-store/events/auditor.ts | 7 + api/libraries/event-store/events/code.ts | 30 + api/libraries/event-store/events/mod.ts | 11 + .../event-store/events/organization.ts | 11 + api/libraries/event-store/events/role.ts | 37 + api/libraries/event-store/events/strategy.ts | 13 + api/libraries/event-store/mod.ts | 2 + api/libraries/event-store/projector.ts | 5 + api/libraries/logger/chalk.ts | 48 + api/libraries/logger/color/hex.ts | 28 + api/libraries/logger/color/rgb.ts | 24 + api/libraries/logger/color/styles.ts | 76 + api/libraries/logger/color/utilities.ts | 3 + api/libraries/logger/config.ts | 5 + api/libraries/logger/format/event-store.ts | 19 + api/libraries/logger/format/server.ts | 18 + api/libraries/logger/level.ts | 8 + api/libraries/logger/logger.ts | 95 ++ api/libraries/logger/mod.ts | 7 + api/libraries/logger/stack.ts | 20 + api/libraries/read-store/.tasks/bootstrap.ts | 11 + api/libraries/read-store/account/methods.ts | 6 + api/libraries/read-store/account/schema.ts | 36 + api/libraries/read-store/database.ts | 12 + api/libraries/read-store/mod.ts | 3 + api/libraries/server/api.ts | 415 +++++ api/libraries/server/context.ts | 16 + api/libraries/server/mod.ts | 5 + api/libraries/server/modules.ts | 40 + api/libraries/server/request.ts | 57 + api/libraries/server/storage.ts | 16 + api/libraries/socket/channels.ts | 81 + api/libraries/socket/mod.ts | 1 + api/libraries/socket/sockets.ts | 49 + api/libraries/socket/upgrade.ts | 60 + api/libraries/testing/config.ts | 4 + .../testing/containers/api-container.ts | 154 ++ .../testing/containers/database-container.ts | 41 + .../testing/containers/test-container.ts | 178 +++ api/libraries/testing/describe.ts | 24 + api/libraries/testing/utilities/account.ts | 68 + api/libraries/utilities/dedent.ts | 62 + api/libraries/utilities/generate.ts | 41 + api/modules/auth/routes/authenticate.ts | 5 + api/package.json | 23 + api/server.ts | 93 ++ api/tasks/bootstrap.ts | 68 + api/tasks/migrate.ts | 66 + api/tasks/migrations/meta/_journal.json | 4 + apps/README.md | 1 + apps/react/.gitignore | 24 + apps/react/.npmrc | 1 + apps/react/README.md | 69 + apps/react/index.html | 13 + apps/react/package.json | 34 + apps/react/public/vite.svg | 1 + apps/react/src/App.css | 42 + apps/react/src/App.tsx | 36 + apps/react/src/adapters/http.ts | 267 ++++ apps/react/src/assets/react.svg | 1 + apps/react/src/components/Session.tsx | 9 + .../src/components/session.controller.ts | 24 + apps/react/src/index.css | 68 + apps/react/src/libraries/controller.ts | 372 +++++ apps/react/src/libraries/debounce.ts | 14 + apps/react/src/libraries/refs.ts | 50 + apps/react/src/libraries/types.ts | 22 + apps/react/src/libraries/view.ts | 199 +++ apps/react/src/main.tsx | 26 + apps/react/src/routes.tsx | 9 + apps/react/src/services/api.ts | 14 + apps/react/src/vite-env.d.ts | 1 + apps/react/tsconfig.app.json | 27 + apps/react/tsconfig.json | 7 + apps/react/tsconfig.node.json | 25 + apps/react/vite.config.ts | 13 + deno.json | 59 +- deno.lock | 1354 +++++++++++++++-- docker-compose.yml | 17 + eslint.config.mjs | 22 +- libraries/action.ts | 71 - libraries/adapter.ts | 32 - libraries/api.ts | 434 ------ libraries/client.ts | 104 -- libraries/procedure.ts | 153 -- libraries/relay.ts | 158 -- libraries/request.ts | 37 - libraries/route.ts | 380 ----- mod.ts | 7 - package.json | 15 +- spec/README.md | 3 + spec/modules/README.md | 1 + spec/modules/auth/errors.ts | 7 + spec/modules/auth/mod.ts | 8 + spec/modules/auth/routes/authenticate.ts | 9 + spec/modules/auth/strategies.ts | 51 + spec/modules/package.json | 11 + spec/relay/libraries/adapter.ts | 92 ++ spec/relay/libraries/client.ts | 179 +++ {libraries => spec/relay/libraries}/errors.ts | 81 +- spec/relay/libraries/hooks.ts | 10 + spec/relay/libraries/route.ts | 488 ++++++ spec/relay/mod.ts | 5 + spec/relay/package.json | 14 + spec/shared/mod.ts | 0 spec/shared/package.json | 13 + tests/mocks/actions.ts | 15 - tests/mocks/relay.ts | 59 - tests/mocks/server.ts | 69 - tests/mocks/user.ts | 10 - tests/procedure.test.ts | 107 -- 148 files changed, 6935 insertions(+), 2060 deletions(-) delete mode 100644 .github/workflows/publish.yml delete mode 100644 .github/workflows/test.yml create mode 100644 .prettierrc create mode 100644 Dockerfile delete mode 100644 LICENSE delete mode 100644 adapters/http.ts create mode 100644 api/config.ts create mode 100644 api/deno.json create mode 100644 api/libraries/auth/.keys/private create mode 100644 api/libraries/auth/.keys/public create mode 100644 api/libraries/auth/auth.ts create mode 100644 api/libraries/auth/config.ts create mode 100644 api/libraries/auth/mod.ts create mode 100644 api/libraries/config/libraries/args.ts create mode 100644 api/libraries/config/libraries/environment.ts create mode 100644 api/libraries/config/libraries/parsers.ts create mode 100644 api/libraries/config/mod.ts create mode 100644 api/libraries/crypto/mod.ts create mode 100644 api/libraries/crypto/password.ts create mode 100644 api/libraries/database/accessor.ts create mode 100644 api/libraries/database/config.ts create mode 100644 api/libraries/database/connection.ts create mode 100644 api/libraries/database/container.ts create mode 100644 api/libraries/database/id.ts create mode 100644 api/libraries/database/registrar.ts create mode 100644 api/libraries/database/tasks/bootstrap.ts create mode 100644 api/libraries/database/utilities.ts create mode 100644 api/libraries/event-store/aggregates/account.ts create mode 100644 api/libraries/event-store/aggregates/code.ts create mode 100644 api/libraries/event-store/aggregates/mod.ts create mode 100644 api/libraries/event-store/aggregates/organization.ts create mode 100644 api/libraries/event-store/aggregates/role.ts create mode 100644 api/libraries/event-store/event-store.ts create mode 100644 api/libraries/event-store/events/account.ts create mode 100644 api/libraries/event-store/events/auditor.ts create mode 100644 api/libraries/event-store/events/code.ts create mode 100644 api/libraries/event-store/events/mod.ts create mode 100644 api/libraries/event-store/events/organization.ts create mode 100644 api/libraries/event-store/events/role.ts create mode 100644 api/libraries/event-store/events/strategy.ts create mode 100644 api/libraries/event-store/mod.ts create mode 100644 api/libraries/event-store/projector.ts create mode 100644 api/libraries/logger/chalk.ts create mode 100644 api/libraries/logger/color/hex.ts create mode 100644 api/libraries/logger/color/rgb.ts create mode 100644 api/libraries/logger/color/styles.ts create mode 100644 api/libraries/logger/color/utilities.ts create mode 100644 api/libraries/logger/config.ts create mode 100644 api/libraries/logger/format/event-store.ts create mode 100644 api/libraries/logger/format/server.ts create mode 100644 api/libraries/logger/level.ts create mode 100644 api/libraries/logger/logger.ts create mode 100644 api/libraries/logger/mod.ts create mode 100644 api/libraries/logger/stack.ts create mode 100644 api/libraries/read-store/.tasks/bootstrap.ts create mode 100644 api/libraries/read-store/account/methods.ts create mode 100644 api/libraries/read-store/account/schema.ts create mode 100644 api/libraries/read-store/database.ts create mode 100644 api/libraries/read-store/mod.ts create mode 100644 api/libraries/server/api.ts create mode 100644 api/libraries/server/context.ts create mode 100644 api/libraries/server/mod.ts create mode 100644 api/libraries/server/modules.ts create mode 100644 api/libraries/server/request.ts create mode 100644 api/libraries/server/storage.ts create mode 100644 api/libraries/socket/channels.ts create mode 100644 api/libraries/socket/mod.ts create mode 100644 api/libraries/socket/sockets.ts create mode 100644 api/libraries/socket/upgrade.ts create mode 100644 api/libraries/testing/config.ts create mode 100644 api/libraries/testing/containers/api-container.ts create mode 100644 api/libraries/testing/containers/database-container.ts create mode 100644 api/libraries/testing/containers/test-container.ts create mode 100644 api/libraries/testing/describe.ts create mode 100644 api/libraries/testing/utilities/account.ts create mode 100644 api/libraries/utilities/dedent.ts create mode 100644 api/libraries/utilities/generate.ts create mode 100644 api/modules/auth/routes/authenticate.ts create mode 100644 api/package.json create mode 100644 api/server.ts create mode 100644 api/tasks/bootstrap.ts create mode 100644 api/tasks/migrate.ts create mode 100644 api/tasks/migrations/meta/_journal.json create mode 100644 apps/README.md create mode 100644 apps/react/.gitignore create mode 100644 apps/react/.npmrc create mode 100644 apps/react/README.md create mode 100644 apps/react/index.html create mode 100644 apps/react/package.json create mode 100644 apps/react/public/vite.svg create mode 100644 apps/react/src/App.css create mode 100644 apps/react/src/App.tsx create mode 100644 apps/react/src/adapters/http.ts create mode 100644 apps/react/src/assets/react.svg create mode 100644 apps/react/src/components/Session.tsx create mode 100644 apps/react/src/components/session.controller.ts create mode 100644 apps/react/src/index.css create mode 100644 apps/react/src/libraries/controller.ts create mode 100644 apps/react/src/libraries/debounce.ts create mode 100644 apps/react/src/libraries/refs.ts create mode 100644 apps/react/src/libraries/types.ts create mode 100644 apps/react/src/libraries/view.ts create mode 100644 apps/react/src/main.tsx create mode 100644 apps/react/src/routes.tsx create mode 100644 apps/react/src/services/api.ts create mode 100644 apps/react/src/vite-env.d.ts create mode 100644 apps/react/tsconfig.app.json create mode 100644 apps/react/tsconfig.json create mode 100644 apps/react/tsconfig.node.json create mode 100644 apps/react/vite.config.ts create mode 100644 docker-compose.yml delete mode 100644 libraries/action.ts delete mode 100644 libraries/adapter.ts delete mode 100644 libraries/api.ts delete mode 100644 libraries/client.ts delete mode 100644 libraries/procedure.ts delete mode 100644 libraries/relay.ts delete mode 100644 libraries/request.ts delete mode 100644 libraries/route.ts delete mode 100644 mod.ts create mode 100644 spec/README.md create mode 100644 spec/modules/README.md create mode 100644 spec/modules/auth/errors.ts create mode 100644 spec/modules/auth/mod.ts create mode 100644 spec/modules/auth/routes/authenticate.ts create mode 100644 spec/modules/auth/strategies.ts create mode 100644 spec/modules/package.json create mode 100644 spec/relay/libraries/adapter.ts create mode 100644 spec/relay/libraries/client.ts rename {libraries => spec/relay/libraries}/errors.ts (80%) create mode 100644 spec/relay/libraries/hooks.ts create mode 100644 spec/relay/libraries/route.ts create mode 100644 spec/relay/mod.ts create mode 100644 spec/relay/package.json create mode 100644 spec/shared/mod.ts create mode 100644 spec/shared/package.json delete mode 100644 tests/mocks/actions.ts delete mode 100644 tests/mocks/relay.ts delete mode 100644 tests/mocks/server.ts delete mode 100644 tests/mocks/user.ts delete mode 100644 tests/procedure.test.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index 64a9cd2..0000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Publish - -on: - workflow_dispatch: - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - name: Setup Deno - uses: maximousblk/setup-deno@v2 - - - name: Setup Node.JS - uses: actions/setup-node@v4 - with: - node-version: 22 - - - run: deno install - - run: deno task lint - - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - name: Setup Deno - uses: maximousblk/setup-deno@v2 - - - run: deno install - - run: deno task test - - publish: - runs-on: ubuntu-latest - needs: [lint, test] - - permissions: - contents: read - id-token: write - - steps: - - uses: actions/checkout@v4 - - - name: Publish package - run: npx jsr publish \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index fb101be..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Test - -on: - pull_request: - branches: - - main - push: - branches: - - main - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - name: Setup Deno - uses: maximousblk/setup-deno@v2 - - - name: Setup Node.JS - uses: actions/setup-node@v4 - with: - node-version: 20 - - - run: deno install - - run: deno task lint - - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - name: Setup Deno - uses: maximousblk/setup-deno@v2 - - - run: deno install - - run: deno task test - - run: deno task test:publish \ No newline at end of file diff --git a/.gitignore b/.gitignore index b512c09..7625c09 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ +.volumes node_modules \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..b5afb12 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,14 @@ +{ + "trailingComma": "all", + "tabWidth": 2, + "printWidth": 120, + "singleQuote": false, + "overrides": [ + { + "files": "*.ts", + "options": { + "parser": "typescript" + } + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index b2bb4f6..52da05f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,10 +1,32 @@ { "deno.enable": true, - "editor.formatOnSave": true, + "deno.lint": false, "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" }, "[typescript]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" - } + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + }, + "[typescriptreact]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + }, + "[markdown]": { + "editor.defaultFormatter": null, + "editor.wordWrap": "off" + }, + "eslint.options": { + "ignorePatterns": ["**/*.md"] + }, + "files.exclude": { + "**/.git": true, + "**/.svn": true, + "**/.hg": true, + "**/CVS": true, + "**/.DS_Store": true, + "**/Thumbs.db": true + }, + "vue.format.style.initialIndent": true, + "vue.format.script.initialIndent": true } diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bd147bd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM denoland/deno:2.3.1 +ENV TZ=UTC +ENV PORT=8370 +EXPOSE 8370 + +WORKDIR /app + +COPY api/ ./api/ +COPY relay/ ./relay/ +COPY .npmrc . +COPY deno-docker.json ./deno.json + +RUN chown -R deno:deno /app/ + +USER deno + +RUN deno install --allow-scripts + +CMD ["sh", "-c", "deno run --allow-all ./api/.tasks/migrate.ts && deno run --allow-all ./api/server.ts"] \ No newline at end of file diff --git a/LICENSE b/LICENSE deleted file mode 100644 index ed5c97f..0000000 --- a/LICENSE +++ /dev/null @@ -1,16 +0,0 @@ -MIT License - -Copyright 2025 Christoffer Rødvik. - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the -Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the -Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -Commercial use is permitted, provided the Software is not sold, relicensed, or distributed as a stand-alone solution, whether in original or minimally modified form. -Use as part of a larger work, integrated product, or service is allowed. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index 8803f5c..bd2198e 100644 --- a/README.md +++ b/README.md @@ -1,94 +1 @@ -

- -

- -# Relay - -Relay is a full stack protocol for communicating between client and server. It is also built around the major HTTP methods allowing for creating public API endpoints. - -## Quick Start - -Following quick start guide gives a three part setup approach for `Relay`, `RelayApi`, and `RelayClient`. - -### Relay - -First thing we need is a relay instance, this is where our base procedure configuration is defined. This space should be environment agnostic meaning we should be able to import our relay instance in both back end front end environments. - -```ts -import { Relay, rpc, route } from "@valkyr/relay"; - -export const relay = new Relay({ - user: { - create: rpc - .method("user:create") - .params( - z.object({ - name: z.string(), - email: z.string().check(z.email()), - }), - ) - .result(z.string()), - update: route - .put("/users/:userId") - .params({ userId: z.uuid() }) - .body( - z.object({ - name: z.string().optional(), - email: z.string().optional() - }), - ), - } -}); -``` - -As we can see in the above example we are defining a new `method` procedure with an expected `params` and `result` contracts defined using zod schemas. - -### API - -To be able to process relay requests on our server we create a `RelayApi` instance which consumes our relay routes. We do this by retrieving the procedure from the relay and attaching a handler to it. When we define new procedure methods we get a new instance which we apply to the api, this ensures that the changes to the procedure on the server only affects the relay on the server. - -```ts -import { NotFoundError } from "@valkyr/relay"; - -import { relay } from "@project/relay"; - -export const api = relay.api([ - relay - .method("user:create") - .handle(async ({ name, email }) => { - const user = await db.users.insert({ name, email }); - if (user === undefined) { - return new NotFoundError(); - } - return user.id; - }), - relay - .put("/users/:userId") - .handle(async ({ userId }, { name, email }) => { - await db.users.update({ name, email }).where({ id: userId }); - }), -]); -``` - -With the above example we now have a `method` handler for the `user:create` method, and a `put` handler for the `/users/:userId` route path. - -### Web - -Now that we have both our relay and api ready to recieve requests we can trigger a user creation request in our web application by creating a new `client` instance. - -```ts -import { HttpAdapter } from "@valkyr/relay/http"; - -import { relay } from "@project/relay"; - -const client = relay.client({ - adapter: new HttpAdapter("http://localhost:8080") -}); - -const userId = await client.user.create({ - name: "John Doe", - email: "john.doe@fixture.none" -}); - -await client.user.update({ userId }, { name: "Jane Doe", email: "jane.doe@fixture.none" }); -``` +# Boilerplate \ No newline at end of file diff --git a/adapters/http.ts b/adapters/http.ts deleted file mode 100644 index 07d7e85..0000000 --- a/adapters/http.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { RelayAdapter, RelayRESTInput } from "../libraries/adapter.ts"; -import { RelayError, UnprocessableContentError } from "../libraries/errors.ts"; -import { RelayProcedureInput, RelayProcedureResponse } from "../mod.ts"; - -export class HttpAdapter implements RelayAdapter { - #id: number = 0; - - constructor(readonly url: string) {} - - async send({ method, params }: RelayProcedureInput): Promise { - const id = this.#id++; - const res = await fetch(this.url, { - method: "POST", - headers: { "x-relay-type": "rpc", "content-type": "application/json" }, - body: JSON.stringify({ relay: "1.0", method, params, id }), - }); - const contentType = res.headers.get("content-type"); - if (contentType !== "application/json") { - return { - relay: "1.0", - error: new UnprocessableContentError(`Invalid 'content-type' in header header, expected 'application/json', received '${contentType}'`), - id, - }; - } - const json = await res.json(); - if ("error" in json) { - return { - relay: "1.0", - error: RelayError.fromJSON(json.error), - id, - }; - } - return json; - } - - async fetch({ method, url, query, body }: RelayRESTInput): Promise { - const res = await fetch(`${url}${query}`, { - method, - headers: { "x-relay-type": "rest", "content-type": "application/json" }, - body, - }); - const data = await res.text(); - if (res.status >= 400) { - throw new Error(data); - } - if (res.headers.get("content-type")?.includes("json")) { - return JSON.parse(data); - } - return data; - } -} diff --git a/api/config.ts b/api/config.ts new file mode 100644 index 0000000..12556de --- /dev/null +++ b/api/config.ts @@ -0,0 +1,9 @@ +import { config as auth } from "~libraries/auth/config.ts"; +import { getEnvironmentVariable, toNumber } from "~libraries/config/mod.ts"; + +export const config = { + name: "valkyr", + host: getEnvironmentVariable("API_HOST", "0.0.0.0"), + port: getEnvironmentVariable("API_PORT", toNumber, "8370"), + ...auth, +}; diff --git a/api/deno.json b/api/deno.json new file mode 100644 index 0000000..45c14aa --- /dev/null +++ b/api/deno.json @@ -0,0 +1,6 @@ +{ + "imports": { + "~config": "./config.ts", + "~libraries/": "./libraries/" + } +} \ No newline at end of file diff --git a/api/libraries/auth/.keys/private b/api/libraries/auth/.keys/private new file mode 100644 index 0000000..42062bf --- /dev/null +++ b/api/libraries/auth/.keys/private @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEuwIBADANBgkqhkiG9w0BAQEFAASCBKUwggShAgEAAoIBAQCy5ZoXkKP9mZTk +sKbQdSwspHZqyMH33Gby23+9ycNHMIww7djcWFfPRW4s7tu3SNaac6qVg9OI43+Z +6BPXxuh4nhQ4LX5No9iVEmcWvZtKE4ghwzsoU0llT7+aKl9UYvgqU1YX4zyfiyo2 +bW0nVPasEHTyjLCVPK5BKlq+UmuyJTVcduALDnVETpUefu5Vca6tIRXsOovvAf5b +zmcxPccaXIatR/AeipxT0YWoInn8dxD3kyFgTPXtinuBZxvp6MUeSs5IE8OJRJRP +PEo1MQ9HFw9aYRIn9uIkbARbNZMGz77zB1+0TrPGyKOB5lLReWGMUFAJhjLrnTsY +z19se4kNAgMBAAECgf9QkG6A6ViiHIMnUskIDeP5Xir19d9kbGwrcn0F2OXYaX+l +Oot9w3KM6loRJx380/zk/e0Uch1MeZ2fyqQRUmAGQIzkXUm6LUWIekYQN6vZ3JlP +YA2/M+otdd8Tpws9hFSDMUlx0SP3GAi0cE48xdBkVAT0NjZ3Jjor7Wv6GLe//Kzg +1OVrbPAA/+RrPB+BQn5nmZFT0aLuLpyxB4f4ArHG/8DEBY49Syy7/3Ke0kfHMnhl +5Eg5Yau89wSLqEoUSuQvNixu/5nTTQ6v1VYPVG8D1hn773SbNoY9o5vZOPRl1P0q +9YC/qpzPJkm/A5TZLsoalIxuGTdwts+DaEeoKmECgYEA5CddLQbMNu9kYElxpSA3 +xXoTL71ZBCQsWExmJrcGe2lQhGO40lF8jE6QnEvMt0mp8Dg9n2ih4J87+2Ozb0fp +2G2ilNeMxM7keywA/+Cwg71QyImppU0lQ5PYLv+pllfxN8FPpLBluy7rDahzphkn +1rijqI5d4bHNG6IgD2ynteECgYEAyLs2eBWxX39Jff3OdpSVmHf7NtacbtsUf1qM +RJSvLsiSwKn39n1+Y6ebzftxm/XD/j8FbN8XvMZMI4OrlfzP+YJaTybIbHrLzCE2 +B5E9j0GbJRhJ/D3l9FQBGdY4g5yC4mgbncXURQqqQTtKk2d+ixZSrw8iyDGN+aMJ +ybqZoK0CgYALb6GvARk5Y7R/Uw8cPMou3tiZWv9cQsfqQSIZrLDpfLTpfeokuKrq +iYGcI/yF725SOS91jxQWI0Upa6zx1gP1skEk/szyjIBNYD5IlSWj5NhoxOW5AG3u +vjlm2a/RdmUD62+njKP8xvRHQftSBw7FJ4okh8ZS6suiJ/U9cK/TYQKBgFg+jTyP +dNGhuKJN0NUqjvVfUa4S/ORzJXizStTfdIAhpvpR/nN7SfPvfDw6nQBOM+JyvCTX +kqznlBNM0EL4yElNN/xx9UxTU4Ki2wjKngB7fAP7wJLGd3BI+c7s8R1S0etMj091 +59KOVLimoytYJTZqEuFoywatWlfzh9sKUH1lAoGBAID6mqGL3SZhh+i2/kAytfzw +UswTQqA0CCBTzN/Eo1QozmUVTLQPj8rBchNSoiSc92y+lPIL8ePdU7imRB77i+9D +9MSmc5u3ACACOSkwF0JCEGN+Rju4HR5wwm3h6Kvf/FQ3yvSEOKAWhqXIY95qtYTU +j3O+iJbY32pbQsawIAkw +-----END PRIVATE KEY----- \ No newline at end of file diff --git a/api/libraries/auth/.keys/public b/api/libraries/auth/.keys/public new file mode 100644 index 0000000..a228182 --- /dev/null +++ b/api/libraries/auth/.keys/public @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsuWaF5Cj/ZmU5LCm0HUs +LKR2asjB99xm8tt/vcnDRzCMMO3Y3FhXz0VuLO7bt0jWmnOqlYPTiON/megT18bo +eJ4UOC1+TaPYlRJnFr2bShOIIcM7KFNJZU+/mipfVGL4KlNWF+M8n4sqNm1tJ1T2 +rBB08oywlTyuQSpavlJrsiU1XHbgCw51RE6VHn7uVXGurSEV7DqL7wH+W85nMT3H +GlyGrUfwHoqcU9GFqCJ5/HcQ95MhYEz17Yp7gWcb6ejFHkrOSBPDiUSUTzxKNTEP +RxcPWmESJ/biJGwEWzWTBs++8wdftE6zxsijgeZS0XlhjFBQCYYy6507GM9fbHuJ +DQIDAQAB +-----END PUBLIC KEY----- \ No newline at end of file diff --git a/api/libraries/auth/auth.ts b/api/libraries/auth/auth.ts new file mode 100644 index 0000000..b6a5872 --- /dev/null +++ b/api/libraries/auth/auth.ts @@ -0,0 +1,87 @@ +import { Auth, ResolvedSession } from "@valkyr/auth"; +import z from "zod"; + +import { db } from "~libraries/read-store/database.ts"; + +import { config } from "./config.ts"; + +export const auth = new Auth( + { + settings: { + algorithm: "RS256", + privateKey: config.privateKey, + publicKey: config.publicKey, + issuer: "https://balto.health", + audience: "https://balto.health", + }, + session: z.object({ + accountId: z.string(), + }), + permissions: { + admin: ["create", "read", "update", "delete"], + organization: ["create", "read", "update", "delete"], + consultant: ["create", "read", "update", "delete"], + task: ["create", "update", "read", "delete"], + } as const, + guards: [], + }, + { + roles: { + async add(role) { + await db.collection("roles").insertOne(role); + }, + + async getById(id) { + const role = await db.collection("roles").findOne({ id }); + if (role === null) { + return undefined; + } + return role; + }, + + async getBySession({ accountId }) { + const account = await db.collection("accounts").findOne({ id: accountId }); + if (account === null) { + return []; + } + return db + .collection("roles") + .find({ id: { $in: account.roles } }) + .toArray(); + }, + + async setPermissions() { + throw new Error("MongoRolesProvider > .setPermissions is managed by Role aggregate projections"); + }, + + async delete(id) { + await db.collection("roles").deleteOne({ id }); + }, + + async assignAccount(roleId: string, accountId: string): Promise { + await db.collection("accounts").updateOne( + { id: accountId }, + { + $push: { + roles: roleId, + }, + }, + ); + }, + + async removeAccount(roleId: string, accountId: string): Promise { + await db.collection("roles").updateOne( + { id: accountId }, + { + $pull: { + roles: roleId, + }, + }, + ); + }, + }, + }, +); + +export type Session = ResolvedSession; +export type Permissions = (typeof auth)["$permissions"]; diff --git a/api/libraries/auth/config.ts b/api/libraries/auth/config.ts new file mode 100644 index 0000000..edd734b --- /dev/null +++ b/api/libraries/auth/config.ts @@ -0,0 +1,25 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +import type { SerializeOptions } from "cookie"; + +import { getEnvironmentVariable, toBoolean } from "~libraries/config/mod.ts"; + +export const config = { + privateKey: getEnvironmentVariable( + "AUTH_PRIVATE_KEY", + await readFile(resolve(import.meta.dirname!, ".keys", "private"), "utf-8"), + ), + publicKey: getEnvironmentVariable( + "AUTH_PUBLIC_KEY", + await readFile(resolve(import.meta.dirname!, ".keys", "public"), "utf-8"), + ), + cookie: (maxAge: number) => + ({ + httpOnly: true, + secure: getEnvironmentVariable("AUTH_COOKIE_SECURE", toBoolean, "false"), // Set to true for HTTPS in production + maxAge, + path: "/", + sameSite: "strict", + }) satisfies SerializeOptions, +}; diff --git a/api/libraries/auth/mod.ts b/api/libraries/auth/mod.ts new file mode 100644 index 0000000..000354c --- /dev/null +++ b/api/libraries/auth/mod.ts @@ -0,0 +1,6 @@ +import { auth } from "./auth.ts"; + +export * from "./auth.ts"; +export * from "./config.ts"; + +export type Auth = typeof auth; diff --git a/api/libraries/config/libraries/args.ts b/api/libraries/config/libraries/args.ts new file mode 100644 index 0000000..52c3fe1 --- /dev/null +++ b/api/libraries/config/libraries/args.ts @@ -0,0 +1,21 @@ +import { parseArgs } from "@std/cli"; + +import { Parser, toString } from "./parsers.ts"; + +export function getArgsVariable(key: string, fallback?: string): string; +export function getArgsVariable(key: string, parse: T, fallback?: string): ReturnType; +export function getArgsVariable(key: string, parse?: T, fallback?: string): ReturnType { + if (typeof parse === "string") { + fallback = parse; + parse = undefined; + } + const flags = parseArgs(Deno.args); + const value = flags[key]; + if (value === undefined) { + if (fallback !== undefined) { + return parse ? parse(fallback) : fallback; + } + throw new Error(`Config Exception: Missing ${key} variable in arguments`); + } + return parse ? parse(value) : toString(value); +} diff --git a/api/libraries/config/libraries/environment.ts b/api/libraries/config/libraries/environment.ts new file mode 100644 index 0000000..96920f9 --- /dev/null +++ b/api/libraries/config/libraries/environment.ts @@ -0,0 +1,79 @@ +import { load } from "@std/dotenv"; +import type { z } from "zod"; + +import { Env, Parser, toServiceEnv, toString } from "./parsers.ts"; + +const env = await load(); + +/** + * Get an environment variable and parse it to the desired type. + * + * @param key - Environment key to resolve. + * @param parse - Parser function to convert the value to the desired type. Default: `string`. + */ +export function getEnvironmentVariable(key: string, fallback?: string): string; +export function getEnvironmentVariable(key: string, parse: T, fallback?: string): ReturnType; +export function getEnvironmentVariable(key: string, parse?: T, fallback?: string): ReturnType { + if (typeof parse === "string") { + fallback = parse; + parse = undefined; + } + const value = env[key] ?? Deno.env.get(key); + if (value === undefined) { + if (fallback !== undefined) { + return parse ? parse(fallback) : fallback; + } + throw new Error(`Config Exception: Missing ${key} variable in configuration`); + } + return parse ? parse(value) : toString(value); +} + +/** + * Get an environment variable, select value based on ENV map and parse it to the desired type. Can be used with simple primitives or objects / arrays + * + * @export + * @param {{ + * key: string; + * envFallback?: FallbackEnvMap; + * fallback: string; + * validation: z.ZodTypeAny, + * }} options + * @param {string} options.key - the name of the env variable + * @param {object} options.envFallback - map with env specific fallbacks that will be used if none value provided + * @param {string} options.envFallback.local - example "local" SERVICE_ENV target fallback value + * @param {string} options.fallback - string fallback that will be used if no env variable found + * @param {z.ZodTypeAny} options.validation - Zod validation object or validation primitive + * @returns {z.infer} - Returns the inferred type of the validation provided + */ +export function validateEnvVariable({ + key, + envFallback, + fallback, + validation, +}: { + key: string; + validation: z.ZodTypeAny; + envFallback?: FallbackEnvMap; + fallback?: string; +}): z.infer { + const serviceEnv = getEnvironmentVariable("SERVICE_ENV", toServiceEnv, "local"); + const providedValue = env[key] ?? Deno.env.get(key); + const fallbackValue = typeof envFallback === "object" ? (envFallback[serviceEnv] ?? fallback) : fallback; + const toBeUsed = providedValue ?? fallbackValue; + try { + if (typeof toBeUsed === "string" && (toBeUsed.trim().startsWith("{") || toBeUsed.trim().startsWith("["))) { + return validation.parse(JSON.parse(toBeUsed)); + } + return validation.parse(toBeUsed); + } catch (e) { + throw new Deno.errors.InvalidData(`Config Exception: Missing valid ${key} variable in configuration`, { cause: e }); + } +} + +type FallbackEnvMap = Partial> & { + testing?: string; + local?: string; + stg?: string; + demo?: string; + prod?: string; +}; diff --git a/api/libraries/config/libraries/parsers.ts b/api/libraries/config/libraries/parsers.ts new file mode 100644 index 0000000..1789b02 --- /dev/null +++ b/api/libraries/config/libraries/parsers.ts @@ -0,0 +1,98 @@ +const SERVICE_ENV = ["testing", "local", "stg", "demo", "prod"] as const; + +/** + * Convert an variable to a string. + * + * @param value - Value to convert. + */ +export function toString(value: unknown): string { + if (typeof value === "string") { + return value; + } + if (typeof value === "number") { + return value.toString(); + } + throw new Error(`Config Exception: Cannot convert ${value} to string`); +} + +/** + * Convert an variable to a number. + * + * @param value - Value to convert. + */ +export function toNumber(value: unknown): number { + if (typeof value === "number") { + return value; + } + if (typeof value === "string") { + return parseInt(value); + } + throw new Error(`Config Exception: Cannot convert ${value} to number`); +} + +/** + * Convert an variable to a boolean. + * + * @param value - Value to convert. + */ +export function toBoolean(value: unknown): boolean { + if (typeof value === "boolean") { + return value; + } + if (typeof value === "string") { + return value === "true" || value === "1"; + } + throw new Error(`Config Exception: Cannot convert ${value} to boolean`); +} + +/** + * Convert a variable to an array of strings. + * + * Expects a comma seprated, eg. foo,bar,foobar + * + * @param value - Value to convert. + */ +export function toArray(value: unknown): string[] { + if (typeof value === "string") { + if (value === "") { + return []; + } + return value.split(","); + } + throw new Error(`Config Exception: Cannot convert ${value} to array`); +} + +/** + * Ensure the given value is a valid SERVICE_ENV variable. + * + * @param value - Value to validate. + */ +export function toServiceEnv(value: unknown): Env { + assertServiceEnv(value); + return value; +} + +/* + |-------------------------------------------------------------------------------- + | Assertions + |-------------------------------------------------------------------------------- + */ + +function assertServiceEnv(value: unknown): asserts value is Env { + if (typeof value !== "string") { + throw new Error(`Config Exception: Env ${value} is not a string`); + } + if ((SERVICE_ENV as unknown as string[]).includes(value) === false) { + throw new Error(`Config Exception: Invalid env ${value} provided`); + } +} + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +export type Parser = (value: unknown) => any; + +export type Env = (typeof SERVICE_ENV)[number]; diff --git a/api/libraries/config/mod.ts b/api/libraries/config/mod.ts new file mode 100644 index 0000000..a8b8c39 --- /dev/null +++ b/api/libraries/config/mod.ts @@ -0,0 +1,3 @@ +export * from "./libraries/args.ts"; +export * from "./libraries/environment.ts"; +export * from "./libraries/parsers.ts"; diff --git a/api/libraries/crypto/mod.ts b/api/libraries/crypto/mod.ts new file mode 100644 index 0000000..4885382 --- /dev/null +++ b/api/libraries/crypto/mod.ts @@ -0,0 +1 @@ +export * from "./password.ts"; diff --git a/api/libraries/crypto/password.ts b/api/libraries/crypto/password.ts new file mode 100644 index 0000000..ad83a79 --- /dev/null +++ b/api/libraries/crypto/password.ts @@ -0,0 +1,11 @@ +import * as bcrypt from "@felix/bcrypt"; + +export const password = { hash, verify }; + +async function hash(password: string): Promise { + return bcrypt.hash(password); +} + +async function verify(password: string, hash: string): Promise { + return bcrypt.verify(password, hash); +} diff --git a/api/libraries/database/accessor.ts b/api/libraries/database/accessor.ts new file mode 100644 index 0000000..77cc614 --- /dev/null +++ b/api/libraries/database/accessor.ts @@ -0,0 +1,48 @@ +import { Collection, type CollectionOptions, type Db, type Document, type MongoClient } from "mongodb"; + +import { container } from "./container.ts"; + +export function getDatabaseAccessor>( + database: string, +): DatabaseAccessor { + let instance: Db | undefined; + return { + get db(): Db { + if (instance === undefined) { + instance = this.client.db(database); + } + return instance; + }, + get client(): MongoClient { + return container.get("client"); + }, + collection( + name: TSchema, + options?: CollectionOptions, + ): Collection { + return this.db.collection(name.toString(), options); + }, + }; +} + +export type DatabaseAccessor> = { + /** + * Database for given accessor. + */ + db: Db; + + /** + * Lazy loaded mongo client. + */ + client: MongoClient; + + /** + * Returns a reference to a MongoDB Collection. If it does not exist it will be created implicitly. + * + * Collection namespace validation is performed server-side. + * + * @param name - Collection name we wish to access. + * @param options - Optional settings for the command. + */ + collection(name: TSchema, options?: CollectionOptions): Collection; +}; diff --git a/api/libraries/database/config.ts b/api/libraries/database/config.ts new file mode 100644 index 0000000..ed9e973 --- /dev/null +++ b/api/libraries/database/config.ts @@ -0,0 +1,10 @@ +import { getEnvironmentVariable, toNumber } from "~libraries/config/mod.ts"; + +export const config = { + mongo: { + host: getEnvironmentVariable("DB_MONGO_HOST", "localhost"), + port: getEnvironmentVariable("DB_MONGO_PORT", toNumber, "27017"), + user: getEnvironmentVariable("DB_MONGO_USER", "root"), + pass: getEnvironmentVariable("DB_MONGO_PASSWORD", "password"), + }, +}; diff --git a/api/libraries/database/connection.ts b/api/libraries/database/connection.ts new file mode 100644 index 0000000..13c523a --- /dev/null +++ b/api/libraries/database/connection.ts @@ -0,0 +1,24 @@ +import { MongoClient } from "mongodb"; + +export function getMongoClient(config: MongoConnectionInfo) { + return new MongoClient(getConnectionUrl(config)); +} + +export function getConnectionUrl({ host, port, user, pass }: MongoConnectionInfo): MongoConnectionUrl { + return `mongodb://${user}:${pass}@${host}:${port}`; +} + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +export type MongoConnectionUrl = `mongodb://${string}:${string}@${string}:${number}`; + +export type MongoConnectionInfo = { + host: string; + port: number; + user: string; + pass: string; +}; diff --git a/api/libraries/database/container.ts b/api/libraries/database/container.ts new file mode 100644 index 0000000..9c5d17c --- /dev/null +++ b/api/libraries/database/container.ts @@ -0,0 +1,6 @@ +import { Container } from "@valkyr/inverse"; +import { MongoClient } from "mongodb"; + +export const container = new Container<{ + client: MongoClient; +}>("database"); diff --git a/api/libraries/database/id.ts b/api/libraries/database/id.ts new file mode 100644 index 0000000..1912950 --- /dev/null +++ b/api/libraries/database/id.ts @@ -0,0 +1,3 @@ +import type { CreateIndexesOptions, IndexSpecification } from "mongodb"; + +export const idIndex: [IndexSpecification, CreateIndexesOptions] = [{ id: 1 }, { unique: true }]; diff --git a/api/libraries/database/registrar.ts b/api/libraries/database/registrar.ts new file mode 100644 index 0000000..ecf2c99 --- /dev/null +++ b/api/libraries/database/registrar.ts @@ -0,0 +1,30 @@ +import type { CreateIndexesOptions, Db, IndexSpecification } from "mongodb"; + +import { getCollectionsSet } from "./utilities.ts"; + +/** + * Takes a mongo database and registers the event store collections and + * indexes defined internally. + * + * @param db - Mongo database to register event store collections against. + * @param registrars - List of registrars to register with the database. + * @param logger - Logger method to print internal logs. + */ +export async function register(db: Db, registrars: Registrar[], logger?: (...args: any[]) => any) { + const list = await getCollectionsSet(db); + for (const { name, indexes } of registrars) { + if (list.has(name) === false) { + await db.createCollection(name); + } + for (const [indexSpec, options] of indexes) { + await db.collection(name).createIndex(indexSpec, options); + logger?.("Mongo Event Store > Collection '%s' is indexed [%O] with options %O", name, indexSpec, options ?? {}); + } + logger?.("Mongo Event Store > Collection '%s' is registered", name); + } +} + +export type Registrar = { + name: string; + indexes: [IndexSpecification, CreateIndexesOptions?][]; +}; diff --git a/api/libraries/database/tasks/bootstrap.ts b/api/libraries/database/tasks/bootstrap.ts new file mode 100644 index 0000000..eee8dac --- /dev/null +++ b/api/libraries/database/tasks/bootstrap.ts @@ -0,0 +1,5 @@ +import { config } from "../config.ts"; +import { getMongoClient } from "../connection.ts"; +import { container } from "../container.ts"; + +container.set("client", getMongoClient(config.mongo)); diff --git a/api/libraries/database/utilities.ts b/api/libraries/database/utilities.ts new file mode 100644 index 0000000..aa87878 --- /dev/null +++ b/api/libraries/database/utilities.ts @@ -0,0 +1,37 @@ +import type { Db } from "mongodb"; +import z, { ZodType } from "zod"; + +/** + * Get a Set of collections that exists on a given mongo database instance. + * + * @param db - Mongo database to fetch collection list for. + */ +export async function getCollectionsSet(db: Db) { + return db + .listCollections() + .toArray() + .then((collections) => new Set(collections.map((c) => c.name))); +} + +export function toParsedDocuments( + schema: TSchema, +): (documents: unknown[]) => Promise[]> { + return async function (documents: unknown[]) { + const parsed = []; + for (const document of documents) { + parsed.push(await schema.parseAsync(document)); + } + return parsed; + }; +} + +export function toParsedDocument( + schema: TSchema, +): (document?: unknown) => Promise | undefined> { + return async function (document: unknown) { + if (document === undefined || document === null) { + return undefined; + } + return schema.parseAsync(document); + }; +} diff --git a/api/libraries/event-store/aggregates/account.ts b/api/libraries/event-store/aggregates/account.ts new file mode 100644 index 0000000..51abb57 --- /dev/null +++ b/api/libraries/event-store/aggregates/account.ts @@ -0,0 +1,268 @@ +import { AggregateRoot, getDate, makeAggregateReducer } from "@valkyr/event-store"; +import { Avatar, Contact, Email, Name, Phone, Strategy } from "relay/schemas"; + +import { db, toAccountDriver } from "~libraries/read-store/mod.ts"; + +import { eventStore } from "../event-store.ts"; +import { AccountCreatedData } from "../events/account.ts"; +import { Auditor } from "../events/auditor.ts"; +import { EventStoreFactory } from "../events/mod.ts"; +import { projector } from "../projector.ts"; + +export class Account extends AggregateRoot { + static override readonly name = "account"; + + id!: string; + organizationId?: string; + + type!: "admin" | "consultant" | "organization"; + + avatar?: Avatar; + name?: Name; + contact: Contact = { + emails: [], + phones: [], + }; + strategies: Strategy[] = []; + + createdAt!: Date; + updatedAt!: Date; + + // ------------------------------------------------------------------------- + // Factories + // ------------------------------------------------------------------------- + + static #reducer = makeAggregateReducer(Account); + + static create(data: AccountCreatedData, meta: Auditor): Account { + return new Account().push({ + type: "account:created", + data, + meta, + }); + } + + static async getById(stream: string): Promise { + return this.$store.reduce({ name: "account", stream, reducer: this.#reducer }); + } + + static async getByEmail(email: string): Promise { + return this.$store.reduce({ name: "account", relation: Account.emailRelation(email), reducer: this.#reducer }); + } + + // ------------------------------------------------------------------------- + // Relations + // ------------------------------------------------------------------------- + + static emailRelation(email: string): `account:email:${string}` { + return `account:email:${email}`; + } + + static passwordRelation(alias: string): `account:password:${string}` { + return `account:password:${alias}`; + } + + // ------------------------------------------------------------------------- + // Reducer + // ------------------------------------------------------------------------- + + with(event: EventStoreFactory["$events"][number]["$record"]): void { + switch (event.type) { + case "account:created": { + this.id = event.stream; + this.organizationId = event.data.type === "organization" ? event.data.organizationId : undefined; + this.type = event.data.type; + this.createdAt = getDate(event.created); + break; + } + case "account:avatar:added": { + this.avatar = { url: event.data }; + this.updatedAt = getDate(event.created); + break; + } + case "account:name:added": { + this.name = event.data; + this.updatedAt = getDate(event.created); + break; + } + case "account:email:added": { + this.contact.emails.push(event.data); + this.updatedAt = getDate(event.created); + break; + } + case "account:phone:added": { + this.contact.phones.push(event.data); + this.updatedAt = getDate(event.created); + break; + } + case "strategy:email:added": { + this.strategies.push({ type: "email", value: event.data }); + this.updatedAt = getDate(event.created); + break; + } + case "strategy:password:added": { + this.strategies.push({ type: "password", ...event.data }); + this.updatedAt = getDate(event.created); + break; + } + } + } + + // ------------------------------------------------------------------------- + // Actions + // ------------------------------------------------------------------------- + + addAvatar(url: string, meta: Auditor): this { + return this.push({ + stream: this.id, + type: "account:avatar:added", + data: url, + meta, + }); + } + + addName(name: Name, meta: Auditor): this { + return this.push({ + stream: this.id, + type: "account:name:added", + data: name, + meta, + }); + } + + addEmail(email: Email, meta: Auditor): this { + return this.push({ + stream: this.id, + type: "account:email:added", + data: email, + meta, + }); + } + + addPhone(phone: Phone, meta: Auditor): this { + return this.push({ + stream: this.id, + type: "account:phone:added", + data: phone, + meta, + }); + } + + addRole(roleId: string, meta: Auditor): this { + return this.push({ + stream: this.id, + type: "account:role:added", + data: roleId, + meta, + }); + } + + addEmailStrategy(email: string, meta: Auditor): this { + return this.push({ + stream: this.id, + type: "strategy:email:added", + data: email, + meta, + }); + } + + addPasswordStrategy(alias: string, password: string, meta: Auditor): this { + return this.push({ + stream: this.id, + type: "strategy:password:added", + data: { alias, password }, + meta, + }); + } + + // ------------------------------------------------------------------------- + // Utilities + // ------------------------------------------------------------------------- + + toSession(): Session { + if (this.type === "organization") { + if (this.organizationId === undefined) { + throw new Error("Account .toSession failed, no organization id present"); + } + return { + type: this.type, + accountId: this.id, + organizationId: this.organizationId, + }; + } + return { + type: this.type, + accountId: this.id, + }; + } +} + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +type Session = + | { + type: "organization"; + accountId: string; + organizationId: string; + } + | { + type: "admin" | "consultant"; + accountId: string; + }; + +/* + |-------------------------------------------------------------------------------- + | Projectors + |-------------------------------------------------------------------------------- + */ + +projector.on("account:created", async ({ stream, data }) => { + const schema: any = { + id: stream, + type: data.type, + contact: { + emails: [], + phones: [], + }, + strategies: [], + roles: [], + }; + if (data.type === "organization") { + schema.organizationId = data.organizationId; + } + await db.collection("accounts").insertOne(toAccountDriver(schema)); +}); + +projector.on("account:avatar:added", async ({ stream: id, data: url }) => { + await db.collection("accounts").updateOne({ id }, { $set: { avatar: { url } } }); +}); + +projector.on("account:name:added", async ({ stream: id, data: name }) => { + await db.collection("accounts").updateOne({ id }, { $set: { name } }); +}); + +projector.on("account:email:added", async ({ stream: id, data: email }) => { + await db.collection("accounts").updateOne({ id }, { $push: { "contact.emails": email } }); +}); + +projector.on("account:phone:added", async ({ stream: id, data: phone }) => { + await db.collection("accounts").updateOne({ id }, { $push: { "contact.phones": phone } }); +}); + +projector.on("account:role:added", async ({ stream: id, data: roleId }) => { + await db.collection("accounts").updateOne({ id }, { $push: { roles: roleId } }); +}); + +projector.on("strategy:email:added", async ({ stream: id, data: email }) => { + await eventStore.relations.insert(Account.emailRelation(email), id); + await db.collection("accounts").updateOne({ id }, { $push: { strategies: { type: "email", value: email } } }); +}); + +projector.on("strategy:password:added", async ({ stream: id, data: strategy }) => { + await eventStore.relations.insert(Account.passwordRelation(strategy.alias), id); + await db.collection("accounts").updateOne({ id }, { $push: { strategies: { type: "password", ...strategy } } }); +}); diff --git a/api/libraries/event-store/aggregates/code.ts b/api/libraries/event-store/aggregates/code.ts new file mode 100644 index 0000000..efd315a --- /dev/null +++ b/api/libraries/event-store/aggregates/code.ts @@ -0,0 +1,78 @@ +import { AggregateRoot, getDate, makeAggregateReducer } from "@valkyr/event-store"; + +import { CodeIdentity } from "../events/code.ts"; +import { EventStoreFactory } from "../events/mod.ts"; + +export class Code extends AggregateRoot { + static override readonly name = "code"; + + id!: string; + + identity!: CodeIdentity; + value!: string; + + createdAt!: Date; + claimedAt?: Date; + + // ------------------------------------------------------------------------- + // Factories + // ------------------------------------------------------------------------- + + static #reducer = makeAggregateReducer(Code); + + static create(identity: CodeIdentity): Code { + return new Code().push({ + type: "code:created", + data: { + identity, + value: crypto + .getRandomValues(new Uint8Array(5)) + .map((v) => v % 10) + .join(""), + }, + }); + } + + static async getById(stream: string): Promise { + return this.$store.reduce({ + name: "code", + stream, + reducer: this.#reducer, + }); + } + + get isClaimed(): boolean { + return this.claimedAt !== undefined; + } + + // ------------------------------------------------------------------------- + // Folder + // ------------------------------------------------------------------------- + + with(event: EventStoreFactory["$events"][number]["$record"]): void { + switch (event.type) { + case "code:created": { + this.id = event.stream; + this.value = event.data.value; + this.identity = event.data.identity; + this.createdAt = getDate(event.created); + break; + } + case "code:claimed": { + this.claimedAt = getDate(event.created); + break; + } + } + } + + // ------------------------------------------------------------------------- + // Actions + // ------------------------------------------------------------------------- + + claim(): this { + return this.push({ + type: "code:claimed", + stream: this.id, + }); + } +} diff --git a/api/libraries/event-store/aggregates/mod.ts b/api/libraries/event-store/aggregates/mod.ts new file mode 100644 index 0000000..fad22fc --- /dev/null +++ b/api/libraries/event-store/aggregates/mod.ts @@ -0,0 +1,8 @@ +import { AggregateFactory } from "@valkyr/event-store"; + +import { Account } from "./account.ts"; +import { Code } from "./code.ts"; +import { Organization } from "./organization.ts"; +import { Role } from "./role.ts"; + +export const aggregates = new AggregateFactory([Account, Code, Organization, Role]); diff --git a/api/libraries/event-store/aggregates/organization.ts b/api/libraries/event-store/aggregates/organization.ts new file mode 100644 index 0000000..d561cff --- /dev/null +++ b/api/libraries/event-store/aggregates/organization.ts @@ -0,0 +1,65 @@ +import { AggregateRoot, getDate, makeAggregateReducer } from "@valkyr/event-store"; + +import { db } from "~libraries/read-store/mod.ts"; + +import { Auditor } from "../events/auditor.ts"; +import { EventStoreFactory } from "../events/mod.ts"; +import { projector } from "../projector.ts"; + +export class Organization extends AggregateRoot { + static override readonly name = "organization"; + + id!: string; + + name!: string; + + createdAt!: Date; + updatedAt!: Date; + + // ------------------------------------------------------------------------- + // Factories + // ------------------------------------------------------------------------- + + static #reducer = makeAggregateReducer(Organization); + + static create(name: string, meta: Auditor): Organization { + return new Organization().push({ + type: "organization:created", + data: { name }, + meta, + }); + } + + static async getById(stream: string): Promise { + return this.$store.reduce({ name: "organization", stream, reducer: this.#reducer }); + } + + // ------------------------------------------------------------------------- + // Reducer + // ------------------------------------------------------------------------- + + with(event: EventStoreFactory["$events"][number]["$record"]): void { + switch (event.type) { + case "organization:created": { + this.id = event.stream; + this.name = event.data.name; + this.createdAt = getDate(event.created); + break; + } + } + } +} + +/* + |-------------------------------------------------------------------------------- + | Projectors + |-------------------------------------------------------------------------------- + */ + +projector.on("organization:created", async ({ stream: id, data: { name }, created }) => { + await db.collection("organizations").insertOne({ + id, + name, + createdAt: getDate(created), + }); +}); diff --git a/api/libraries/event-store/aggregates/role.ts b/api/libraries/event-store/aggregates/role.ts new file mode 100644 index 0000000..768c209 --- /dev/null +++ b/api/libraries/event-store/aggregates/role.ts @@ -0,0 +1,118 @@ +import { AggregateRoot, getDate, makeAggregateReducer } from "@valkyr/event-store"; + +import { db } from "~libraries/read-store/database.ts"; + +import type { Auditor } from "../events/auditor.ts"; +import { EventStoreFactory } from "../events/mod.ts"; +import type { RoleCreatedData, RolePermissionOperation } from "../events/role.ts"; +import { projector } from "../projector.ts"; + +export class Role extends AggregateRoot { + static override readonly name = "role"; + + id!: string; + + name!: string; + permissions: { [resource: string]: Set } = {}; + + createdAt!: Date; + updatedAt!: Date; + + // ------------------------------------------------------------------------- + // Factories + // ------------------------------------------------------------------------- + + static #reducer = makeAggregateReducer(Role); + + static create(data: RoleCreatedData, meta: Auditor): Role { + return new Role().push({ + type: "role:created", + data, + meta, + }); + } + + static async getById(stream: string): Promise { + return this.$store.reduce({ name: "role", stream, reducer: this.#reducer }); + } + + // ------------------------------------------------------------------------- + // Reducer + // ------------------------------------------------------------------------- + + override with(event: EventStoreFactory["$events"][number]["$record"]): void { + switch (event.type) { + case "role:created": { + this.id = event.stream; + this.createdAt = getDate(event.created); + this.updatedAt = getDate(event.created); + break; + } + case "role:name-set": { + this.name = event.data; + this.updatedAt = getDate(event.created); + break; + } + case "role:permissions-set": { + for (const operation of event.data) { + if (operation.type === "grant") { + if (this.permissions[operation.resource] === undefined) { + this.permissions[operation.resource] = new Set(); + } + this.permissions[operation.resource].add(operation.action); + } + if (operation.type === "deny") { + if (operation.action === undefined) { + delete this.permissions[operation.resource]; + } else { + this.permissions[operation.resource]?.delete(operation.action); + } + } + } + break; + } + } + } + + // ------------------------------------------------------------------------- + // Actions + // ------------------------------------------------------------------------- + + setName(name: string, meta: Auditor): this { + return this.push({ + type: "role:name-set", + stream: this.id, + data: name, + meta, + }); + } + + setPermissions(operations: RolePermissionOperation[], meta: Auditor): this { + return this.push({ + type: "role:permissions-set", + stream: this.id, + data: operations, + meta, + }); + } +} + +/* + |-------------------------------------------------------------------------------- + | Projectors + |-------------------------------------------------------------------------------- + */ + +projector.on("role:created", async ({ stream, data: { name, permissions } }) => { + await db.collection("roles").insertOne({ + id: stream, + name, + permissions: permissions.reduce( + (map, permission) => { + map[permission.resource] = permission.actions; + return map; + }, + {} as Record, + ), + }); +}); diff --git a/api/libraries/event-store/event-store.ts b/api/libraries/event-store/event-store.ts new file mode 100644 index 0000000..5c5a315 --- /dev/null +++ b/api/libraries/event-store/event-store.ts @@ -0,0 +1,25 @@ +import { EventStore } from "@valkyr/event-store"; +import { MongoAdapter } from "@valkyr/event-store/mongo"; + +import { container } from "~libraries/database/container.ts"; + +import { aggregates } from "./aggregates/mod.ts"; +import { events } from "./events/mod.ts"; +import { projector } from "./projector.ts"; + +export const eventStore = new EventStore({ + adapter: new MongoAdapter(() => container.get("client"), "balto:event-store"), + events, + aggregates, + snapshot: "auto", +}); + +eventStore.onEventsInserted(async (records, { batch }) => { + if (batch !== undefined) { + await projector.pushMany(batch, records); + } else { + for (const record of records) { + await projector.push(record, { hydrated: false, outdated: false }); + } + } +}); diff --git a/api/libraries/event-store/events/account.ts b/api/libraries/event-store/events/account.ts new file mode 100644 index 0000000..ff00930 --- /dev/null +++ b/api/libraries/event-store/events/account.ts @@ -0,0 +1,29 @@ +import { event } from "@valkyr/event-store"; +import { email, name, phone } from "relay/schemas"; +import z from "zod"; + +import { auditor } from "./auditor.ts"; + +const created = z.discriminatedUnion([ + z.object({ + type: z.literal("admin"), + }), + z.object({ + type: z.literal("consultant"), + }), + z.object({ + type: z.literal("organization"), + organizationId: z.string(), + }), +]); + +export default [ + event.type("account:created").data(created).meta(auditor), + event.type("account:avatar:added").data(z.string()).meta(auditor), + event.type("account:name:added").data(name).meta(auditor), + event.type("account:email:added").data(email).meta(auditor), + event.type("account:phone:added").data(phone).meta(auditor), + event.type("account:role:added").data(z.string()).meta(auditor), +]; + +export type AccountCreatedData = z.infer; diff --git a/api/libraries/event-store/events/auditor.ts b/api/libraries/event-store/events/auditor.ts new file mode 100644 index 0000000..9819416 --- /dev/null +++ b/api/libraries/event-store/events/auditor.ts @@ -0,0 +1,7 @@ +import z from "zod"; + +export const auditor = z.object({ + accountId: z.string(), +}); + +export type Auditor = z.infer; diff --git a/api/libraries/event-store/events/code.ts b/api/libraries/event-store/events/code.ts new file mode 100644 index 0000000..cdca4d7 --- /dev/null +++ b/api/libraries/event-store/events/code.ts @@ -0,0 +1,30 @@ +import { event } from "@valkyr/event-store"; +import z from "zod"; + +const identity = z.discriminatedUnion([ + z.object({ + type: z.literal("admin"), + accountId: z.string(), + }), + z.object({ + type: z.literal("consultant"), + accountId: z.string(), + }), + z.object({ + type: z.literal("organization"), + organizationId: z.string(), + accountId: z.string(), + }), +]); + +export default [ + event.type("code:created").data( + z.object({ + value: z.string(), + identity, + }), + ), + event.type("code:claimed"), +]; + +export type CodeIdentity = z.infer; diff --git a/api/libraries/event-store/events/mod.ts b/api/libraries/event-store/events/mod.ts new file mode 100644 index 0000000..3c5f0dc --- /dev/null +++ b/api/libraries/event-store/events/mod.ts @@ -0,0 +1,11 @@ +import { EventFactory } from "@valkyr/event-store"; + +import account from "./account.ts"; +import code from "./code.ts"; +import organization from "./organization.ts"; +import role from "./role.ts"; +import strategy from "./strategy.ts"; + +export const events = new EventFactory([...account, ...code, ...organization, ...role, ...strategy]); + +export type EventStoreFactory = typeof events; diff --git a/api/libraries/event-store/events/organization.ts b/api/libraries/event-store/events/organization.ts new file mode 100644 index 0000000..cea6003 --- /dev/null +++ b/api/libraries/event-store/events/organization.ts @@ -0,0 +1,11 @@ +import { event } from "@valkyr/event-store"; +import z from "zod"; + +import { auditor } from "./auditor.ts"; + +export default [ + event + .type("organization:created") + .data(z.object({ name: z.string() })) + .meta(auditor), +]; diff --git a/api/libraries/event-store/events/role.ts b/api/libraries/event-store/events/role.ts new file mode 100644 index 0000000..6635512 --- /dev/null +++ b/api/libraries/event-store/events/role.ts @@ -0,0 +1,37 @@ +import { event } from "@valkyr/event-store"; +import z from "zod"; + +import { auditor } from "./auditor.ts"; + +const created = z.object({ + name: z.string(), + permissions: z.array( + z.object({ + resource: z.string(), + actions: z.array(z.string()), + }), + ), +}); + +const operation = z.discriminatedUnion([ + z.object({ + type: z.literal("grant"), + resource: z.string(), + action: z.string(), + }), + z.object({ + type: z.literal("deny"), + resource: z.string(), + action: z.string().optional(), + }), +]); + +export default [ + event.type("role:created").data(created).meta(auditor), + event.type("role:name-set").data(z.string()).meta(auditor), + event.type("role:permissions-set").data(z.array(operation)).meta(auditor), +]; + +export type RoleCreatedData = z.infer; + +export type RolePermissionOperation = z.infer; diff --git a/api/libraries/event-store/events/strategy.ts b/api/libraries/event-store/events/strategy.ts new file mode 100644 index 0000000..b21d12e --- /dev/null +++ b/api/libraries/event-store/events/strategy.ts @@ -0,0 +1,13 @@ +import { event } from "@valkyr/event-store"; +import z from "zod"; + +import { auditor } from "./auditor.ts"; + +export default [ + event.type("strategy:email:added").data(z.string()).meta(auditor), + event.type("strategy:passkey:added").meta(auditor), + event + .type("strategy:password:added") + .data(z.object({ alias: z.string(), password: z.string() })) + .meta(auditor), +]; diff --git a/api/libraries/event-store/mod.ts b/api/libraries/event-store/mod.ts new file mode 100644 index 0000000..23cc215 --- /dev/null +++ b/api/libraries/event-store/mod.ts @@ -0,0 +1,2 @@ +export * from "./event-store.ts"; +export * from "./projector.ts"; diff --git a/api/libraries/event-store/projector.ts b/api/libraries/event-store/projector.ts new file mode 100644 index 0000000..4a7e831 --- /dev/null +++ b/api/libraries/event-store/projector.ts @@ -0,0 +1,5 @@ +import { Projector } from "@valkyr/event-store"; + +import { EventStoreFactory } from "./events/mod.ts"; + +export const projector = new Projector(); diff --git a/api/libraries/logger/chalk.ts b/api/libraries/logger/chalk.ts new file mode 100644 index 0000000..4a5d72d --- /dev/null +++ b/api/libraries/logger/chalk.ts @@ -0,0 +1,48 @@ +import { HexValue } from "./color/hex.ts"; +import { type BGColor, type Color, hexToBgColor, hexToColor, type Modifier, styles } from "./color/styles.ts"; + +export const chalk = { + color(hex: HexValue): (value: string) => string { + const color = hexToColor(hex); + return (value: string) => `${color}${value}${styles.modifier.reset}`; + }, + bgColor(hex: HexValue): (value: string) => string { + const color = hexToBgColor(hex); + return (value: string) => `${color}${value}${styles.modifier.reset}`; + }, +} as Chalk; + +for (const key in styles.modifier) { + chalk[key as Modifier] = function (value: string) { + return toModifiedValue(key as Modifier, value); + }; +} + +for (const key in styles.color) { + chalk[key as Color] = function (value: string) { + return toColorValue(key as Color, value); + }; +} + +for (const key in styles.bgColor) { + chalk[key as BGColor] = function (value: string) { + return toBGColorValue(key as BGColor, value); + }; +} + +function toModifiedValue(key: Modifier, value: string): string { + return `${styles.modifier[key]}${value}${styles.modifier.reset}`; +} + +function toColorValue(key: Color, value: string): string { + return `${styles.color[key]}${value}${styles.modifier.reset}`; +} + +function toBGColorValue(key: BGColor, value: string): string { + return `${styles.bgColor[key]}${value}${styles.modifier.reset}`; +} + +type Chalk = Record string> & { + color(hex: HexValue): (value: string) => string; + bgColor(hex: HexValue): (value: string) => string; +}; diff --git a/api/libraries/logger/color/hex.ts b/api/libraries/logger/color/hex.ts new file mode 100644 index 0000000..8ace2db --- /dev/null +++ b/api/libraries/logger/color/hex.ts @@ -0,0 +1,28 @@ +import { rgbToAnsi256 } from "./rgb.ts"; + +/** + * Convert provided hex value to closest 256-Color value. + * + * @param hex - Hex to convert. + */ +export function hexToAnsi256(hex: HexValue) { + const { r, g, b } = hexToRGB(hex); + return rgbToAnsi256(r, g, b); +} + +/** + * Take a hex value and return its RGB values. + * + * @param hex - Hex to convert to RGB + * @returns + */ +export function hexToRGB(hex: HexValue): { r: number; g: number; b: number } { + return { + r: parseInt(hex.slice(1, 3), 16), + g: parseInt(hex.slice(3, 5), 16), + b: parseInt(hex.slice(5, 7), 16), + }; +} + +export type HexValue = + `#${string | number}${string | number}${string | number}${string | number}${string | number}${string | number}`; diff --git a/api/libraries/logger/color/rgb.ts b/api/libraries/logger/color/rgb.ts new file mode 100644 index 0000000..f28f096 --- /dev/null +++ b/api/libraries/logger/color/rgb.ts @@ -0,0 +1,24 @@ +/** + * Convert RGB to the nearest 256-color ANSI value + * + * @param r - Red value. + * @param g - Green value. + * @param b - Blue value. + */ +export function rgbToAnsi256(r: number, g: number, b: number): number { + if (r === g && g === b) { + if (r < 8) return 16; + if (r > 248) return 231; + return Math.round(((r - 8) / 247) * 24) + 232; + } + + // Map RGB to 6×6×6 color cube (16–231) + const conv = (val: number) => Math.round(val / 51); + const ri = conv(r); + const gi = conv(g); + const bi = conv(b); + + return 16 + 36 * ri + 6 * gi + bi; +} + +export type RGB = { r: number; g: number; b: number }; diff --git a/api/libraries/logger/color/styles.ts b/api/libraries/logger/color/styles.ts new file mode 100644 index 0000000..773f53a --- /dev/null +++ b/api/libraries/logger/color/styles.ts @@ -0,0 +1,76 @@ +import { hexToAnsi256, HexValue } from "./hex.ts"; +import { toEscapeSequence } from "./utilities.ts"; + +export const styles = { + modifier: { + reset: toEscapeSequence(0), // Reset to normal + bold: toEscapeSequence(1), // Bold text + dim: toEscapeSequence(2), // Dim text + italic: toEscapeSequence(3), // Italic text + underline: toEscapeSequence(4), // Underlined text + overline: toEscapeSequence(53), // Overline text + inverse: toEscapeSequence(7), // Inverse + hidden: toEscapeSequence(8), // Hidden text + strikethrough: toEscapeSequence(9), // Strikethrough + }, + + color: { + black: toEscapeSequence(30), // Black color + red: toEscapeSequence(31), // Red color + green: toEscapeSequence(32), // Green color + yellow: toEscapeSequence(33), // Yellow color + blue: toEscapeSequence(34), // Blue color + magenta: toEscapeSequence(35), // Magenta color + cyan: toEscapeSequence(36), // Cyan color + white: toEscapeSequence(37), // White color + orange: hexToColor("#FFA500"), + + // Bright colors + blackBright: toEscapeSequence(90), + gray: toEscapeSequence(90), // Alias for blackBright + grey: toEscapeSequence(90), // Alias for blackBright + redBright: toEscapeSequence(91), + greenBright: toEscapeSequence(92), + yellowBright: toEscapeSequence(93), + blueBright: toEscapeSequence(94), + magentaBright: toEscapeSequence(95), + cyanBright: toEscapeSequence(96), + whiteBright: toEscapeSequence(97), + }, + + bgColor: { + bgBlack: toEscapeSequence(40), + bgRed: toEscapeSequence(41), + bgGreen: toEscapeSequence(42), + bgYellow: toEscapeSequence(43), + bgBlue: toEscapeSequence(44), + bgMagenta: toEscapeSequence(45), + bgCyan: toEscapeSequence(46), + bgWhite: toEscapeSequence(47), + bgOrange: hexToBgColor("#FFA500"), + + // Bright background colors + bgBlackBright: toEscapeSequence(100), + bgGray: toEscapeSequence(100), // Alias for bgBlackBright + bgGrey: toEscapeSequence(100), // Alias for bgBlackBright + bgRedBright: toEscapeSequence(101), + bgGreenBright: toEscapeSequence(102), + bgYellowBright: toEscapeSequence(103), + bgBlueBright: toEscapeSequence(104), + bgMagentaBright: toEscapeSequence(105), + bgCyanBright: toEscapeSequence(106), + bgWhiteBright: toEscapeSequence(107), + }, +}; + +export function hexToColor(hex: HexValue): string { + return toEscapeSequence(`38;5;${hexToAnsi256(hex)}`); // Foreground color +} + +export function hexToBgColor(hex: HexValue): string { + return toEscapeSequence(`48;5;${hexToAnsi256(hex)}`); // Background color +} + +export type Modifier = keyof typeof styles.modifier; +export type Color = keyof typeof styles.color; +export type BGColor = keyof typeof styles.bgColor; diff --git a/api/libraries/logger/color/utilities.ts b/api/libraries/logger/color/utilities.ts new file mode 100644 index 0000000..50a9fbd --- /dev/null +++ b/api/libraries/logger/color/utilities.ts @@ -0,0 +1,3 @@ +export function toEscapeSequence(value: string | number): `\x1b[${string}m` { + return `\x1b[${value}m`; +} diff --git a/api/libraries/logger/config.ts b/api/libraries/logger/config.ts new file mode 100644 index 0000000..72816f5 --- /dev/null +++ b/api/libraries/logger/config.ts @@ -0,0 +1,5 @@ +import { getArgsVariable } from "~libraries/config/mod.ts"; + +export const config = { + level: getArgsVariable("LOG_LEVEL", "info"), +}; diff --git a/api/libraries/logger/format/event-store.ts b/api/libraries/logger/format/event-store.ts new file mode 100644 index 0000000..a78b827 --- /dev/null +++ b/api/libraries/logger/format/event-store.ts @@ -0,0 +1,19 @@ +import { EventValidationError } from "@valkyr/event-store"; + +import type { Level } from "../level.ts"; +import { getTracedAt } from "../stack.ts"; + +export function toEventStoreLog(arg: any, level: Level): any { + if (arg instanceof EventValidationError) { + const obj: any = { + origin: "EventStore", + message: arg.message, + at: getTracedAt(arg.stack, "/api/domains"), + data: arg.errors, + }; + if (level === "debug") { + obj.stack = arg.stack; + } + return obj; + } +} diff --git a/api/libraries/logger/format/server.ts b/api/libraries/logger/format/server.ts new file mode 100644 index 0000000..ba8e8ea --- /dev/null +++ b/api/libraries/logger/format/server.ts @@ -0,0 +1,18 @@ +import { ServerError } from "@spec/relay"; + +import type { Level } from "../level.ts"; +import { getTracedAt } from "../stack.ts"; + +export function toServerLog(arg: any, level: Level): any { + if (arg instanceof ServerError) { + const obj: any = { + message: arg.message, + data: arg.data, + at: getTracedAt(arg.stack, "/api/domains"), + }; + if (level === "debug") { + obj.stack = arg.stack; + } + return obj; + } +} diff --git a/api/libraries/logger/level.ts b/api/libraries/logger/level.ts new file mode 100644 index 0000000..095af5b --- /dev/null +++ b/api/libraries/logger/level.ts @@ -0,0 +1,8 @@ +export const logLevel = { + debug: 0, + info: 1, + warning: 2, + error: 3, +}; + +export type Level = "debug" | "error" | "warning" | "info"; diff --git a/api/libraries/logger/logger.ts b/api/libraries/logger/logger.ts new file mode 100644 index 0000000..0a8f115 --- /dev/null +++ b/api/libraries/logger/logger.ts @@ -0,0 +1,95 @@ +import { chalk } from "./chalk.ts"; +import { type Level, logLevel } from "./level.ts"; + +export class Logger { + #level: Level = "info"; + #config: Config; + + constructor(config: Config) { + this.#config = config; + } + + get #prefix(): [string?] { + if (this.#config.prefix !== undefined) { + return [chalk.bold(chalk.green(this.#config.prefix))]; + } + return []; + } + + /** + * Set the highest logging level in the order of debug, info, warn, error. + * + * When value is 'info', info, warn and error will be logged and debug + * will be ignored. + * + * @param value Highest log level. + */ + level(value: Level): this { + this.#level = value; + return this; + } + + /** + * Returns a new logger instance with the given name as prefix. + * + * @param name - Prefix name. + */ + prefix(name: string): Logger { + return new Logger({ prefix: name, loggers: this.#config.loggers }).level(this.#level); + } + + /** + * Emit a debug message to terminal. + */ + debug(...args: any[]) { + if (this.#isLevelEnabled(0)) { + console.log(new Date(), chalk.bold("Debug"), ...this.#prefix, ...args.map(this.#toFormattedArg)); + } + } + + /** + * Emit a info message to terminal. + */ + info(...args: any[]) { + if (this.#isLevelEnabled(1)) { + console.log(new Date(), chalk.bold(chalk.blue("Info")), ...this.#prefix, ...args.map(this.#toFormattedArg)); + } + } + + /** + * Emit a warning message to terminal. + */ + warn(...args: any[]) { + if (this.#isLevelEnabled(2)) { + console.log(new Date(), chalk.bold(chalk.orange("Warning")), ...this.#prefix, ...args.map(this.#toFormattedArg)); + } + } + + /** + * Emit a errpr message to terminal. + */ + error(...args: any[]) { + if (this.#isLevelEnabled(3)) { + console.log(new Date(), chalk.bold(chalk.red("Error")), ...this.#prefix, ...args.map(this.#toFormattedArg)); + } + } + + #isLevelEnabled(level: 0 | 1 | 2 | 3): boolean { + return level >= logLevel[this.#level]; + } + + #toFormattedArg = (arg: any): string => { + for (const logger of this.#config.loggers) { + const res = logger(arg, this.#level); + if (res !== undefined) { + return res; + } + } + return arg; + }; +} + +type Config = { + prefix?: string; + loggers: ((arg: any, level: Level) => any)[]; +}; diff --git a/api/libraries/logger/mod.ts b/api/libraries/logger/mod.ts new file mode 100644 index 0000000..9fd6623 --- /dev/null +++ b/api/libraries/logger/mod.ts @@ -0,0 +1,7 @@ +import { toEventStoreLog } from "./format/event-store.ts"; +import { toServerLog } from "./format/server.ts"; +import { Logger } from "./logger.ts"; + +export const logger = new Logger({ + loggers: [toServerLog, toEventStoreLog], +}); diff --git a/api/libraries/logger/stack.ts b/api/libraries/logger/stack.ts new file mode 100644 index 0000000..f6d4501 --- /dev/null +++ b/api/libraries/logger/stack.ts @@ -0,0 +1,20 @@ +/** + * Fetch the most closest relevant error from the local code base so it can + * be more easily traced to its source. + * + * @param stack - Error stack. + * @param search - Relevant stack line search value. + */ +export function getTracedAt(stack: string | undefined, search: string): string | undefined { + if (stack === undefined) { + return undefined; + } + const firstMatch = stack.split("\n").find((line) => line.includes(search)); + if (firstMatch === undefined) { + return undefined; + } + return firstMatch + .replace(/^.*?(file:\/\/\/)/, "$1") + .replace(/\)$/, "") + .trim(); +} diff --git a/api/libraries/read-store/.tasks/bootstrap.ts b/api/libraries/read-store/.tasks/bootstrap.ts new file mode 100644 index 0000000..32ddac9 --- /dev/null +++ b/api/libraries/read-store/.tasks/bootstrap.ts @@ -0,0 +1,11 @@ +import { idIndex } from "~libraries/database/id.ts"; +import { register } from "~libraries/database/registrar.ts"; + +import { db } from "../database.ts"; + +await register(db.db, [ + { + name: "accounts", + indexes: [idIndex], + }, +]); diff --git a/api/libraries/read-store/account/methods.ts b/api/libraries/read-store/account/methods.ts new file mode 100644 index 0000000..510393b --- /dev/null +++ b/api/libraries/read-store/account/methods.ts @@ -0,0 +1,6 @@ +import { db, takeOne } from "../database.ts"; +import { type AccountSchema, fromAccountDriver } from "./schema.ts"; + +export async function getAccountById(id: string): Promise { + return db.collection("accounts").find({ id }).toArray().then(fromAccountDriver).then(takeOne); +} diff --git a/api/libraries/read-store/account/schema.ts b/api/libraries/read-store/account/schema.ts new file mode 100644 index 0000000..7e74c30 --- /dev/null +++ b/api/libraries/read-store/account/schema.ts @@ -0,0 +1,36 @@ +import { z } from "zod"; + +const account = z.object({ + id: z.uuid(), + name: z.object({ + given: z.string(), + family: z.string(), + }), + email: z.email(), +}); + +/* + |-------------------------------------------------------------------------------- + | Parsers + |-------------------------------------------------------------------------------- + */ + +const select = account; +const insert = account; + +export function toAccountDriver(documents: unknown): AccountInsert { + return insert.parse(documents); +} + +export function fromAccountDriver(documents: unknown[]): AccountSchema[] { + return documents.map((document) => select.parse(document)); +} + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +export type AccountSchema = z.infer; +export type AccountInsert = z.infer; diff --git a/api/libraries/read-store/database.ts b/api/libraries/read-store/database.ts new file mode 100644 index 0000000..f2d0452 --- /dev/null +++ b/api/libraries/read-store/database.ts @@ -0,0 +1,12 @@ +import { config } from "~config"; +import { getDatabaseAccessor } from "~libraries/database/accessor.ts"; + +import { AccountInsert } from "./account/schema.ts"; + +export const db = getDatabaseAccessor<{ + accounts: AccountInsert; +}>(`${config.name}:read-store`); + +export function takeOne(documents: TDocument[]): TDocument | undefined { + return documents[0]; +} diff --git a/api/libraries/read-store/mod.ts b/api/libraries/read-store/mod.ts new file mode 100644 index 0000000..739683a --- /dev/null +++ b/api/libraries/read-store/mod.ts @@ -0,0 +1,3 @@ +export * from "./account/methods.ts"; +export * from "./account/schema.ts"; +export * from "./database.ts"; diff --git a/api/libraries/server/api.ts b/api/libraries/server/api.ts new file mode 100644 index 0000000..0c36bfe --- /dev/null +++ b/api/libraries/server/api.ts @@ -0,0 +1,415 @@ +import { + BadRequestError, + ForbiddenError, + InternalServerError, + NotFoundError, + NotImplementedError, + Route, + RouteMethod, + ServerError, + type ServerErrorResponse, + UnauthorizedError, + ZodValidationError, +} from "@spec/relay"; +import { treeifyError } from "zod"; + +import { logger } from "~libraries/logger/mod.ts"; + +import { getRequestContext } from "./context.ts"; +import { req } from "./request.ts"; + +const SUPPORTED_MEHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"]; + +export class Api { + readonly #index = new Map(); + + /** + * Route maps funneling registered routes to the specific methods supported by + * the relay instance. + */ + readonly routes: Routes = { + POST: [], + GET: [], + PUT: [], + PATCH: [], + DELETE: [], + }; + + /** + * List of paths in the '${method} ${path}' format allowing us to quickly throw + * errors if a duplicate route path is being added. + */ + readonly #paths = new Set(); + + /** + * Instantiate a new Api instance. + * + * @param routes - Initial list of routes to register with the api. + */ + constructor(routes: Route[] = []) { + this.register(routes); + } + + /** + * Register relays with the API instance allowing for decoupled registration + * of server side handling of relay contracts. + * + * @param routes - Relays to register with the instance. + */ + register(routes: Route[]): this { + const methods: (keyof typeof this.routes)[] = []; + for (const route of routes) { + const path = `${route.method} ${route.path}`; + if (this.#paths.has(path)) { + throw new Error(`Router > Path ${path} already exists`); + } + this.#paths.add(path); + this.routes[route.method].push(route); + methods.push(route.method); + this.#index.set(`${route.method} ${route.path}`, route); + } + for (const method of methods) { + this.routes[method].sort(byStaticPriority); + } + return this; + } + + /** + * Executes request and returns a `Response` instance. + * + * @param request - REST request to pass to a route handler. + */ + async fetch(request: Request): Promise { + const url = new URL(request.url); + + // ### Route + // Locate a route matching the incoming request method and path. + + const resolved = this.#getResolvedRoute(request.method, url.pathname); + if (resolved === undefined) { + return toResponse( + new NotFoundError(`Invalid routing path provided for ${request.url}`, { + method: request.method, + url: request.url, + }), + request, + ); + } + + // ### Handle + // Execute request and return a response. + + const response = await this.#getRouteResponse(resolved, request).catch((error) => + this.#getErrorResponse(error, resolved.route, request), + ); + + return response; + } + + /** + * Attempt to resolve a route based on the given method and pathname. + * + * @param method - HTTP method. + * @param url - HTTP request url. + */ + #getResolvedRoute(method: string, url: string): ResolvedRoute | undefined { + assertMethod(method); + for (const route of this.routes[method]) { + if (route.match(url) === true) { + return { route, params: route.getParsedParams(url) }; + } + } + } + + /** + * Resolve the request on the given route and return a `Response` instance. + * + * @param resolved - Route and paramter details resolved for the request. + * @param request - Request instance to resolve. + */ + async #getRouteResponse({ route, params }: ResolvedRoute, request: Request): Promise { + const url = new URL(request.url); + + // ### Args + // Arguments is passed to every route handler and provides a suite of functionality + // and request data. + + const args: any[] = []; + + // ### Input + // Generate route input which contains a map fo params, query, and/or body. If + // none of these are present then the input is not added to the final argument + // context of the handler. + + const input: { + params?: object; + query?: object; + body?: unknown; + } = { + params: undefined, + query: undefined, + body: undefined, + }; + + // ### Access + // Check the access requirements of the route and run any additional checks + // if nessesary before proceeding with further request handling. + // 1. All routes needs access assignment, else we consider it an internal error. + // 2. If access requires a session we throw Unauthorized if the request is not authenticated. + // 3. If access is an array of access resources, we check that each resources can be + // accessed by the request. + + if (route.state.access === undefined) { + return toResponse( + new InternalServerError(`Route '${route.method} ${route.path}' is missing access assignment.`), + request, + ); + } + + if (route.state.access === "session" && req.isAuthenticated === false) { + return toResponse(new UnauthorizedError(), request); + } + + if (Array.isArray(route.state.access)) { + for (const hasAccess of route.state.access) { + if (hasAccess() === false) { + return toResponse(new ForbiddenError(), request); + } + } + } + + // ### Params + // If the route has params we want to coerce the values to the expected types. + + if (route.state.params !== undefined) { + const result = await route.state.params.safeParseAsync(params); + if (result.success === false) { + return toResponse(new ZodValidationError("Invalid request params", treeifyError(result.error)), request); + } + input.params = result.data; + } + + // ### Query + // If the route has a query schema we need to validate and parse the query. + + if (route.state.query !== undefined) { + const result = await route.state.query.safeParseAsync(toQuery(url.searchParams) ?? {}); + if (result.success === false) { + return toResponse(new ZodValidationError("Invalid request query", treeifyError(result.error)), request); + } + input.query = result.data; + } + + // ### Body + // If the route has a body schema we need to validate and parse the body. + + if (route.state.body !== undefined) { + const body = await this.#getRequestBody(request); + const result = await route.state.body.safeParseAsync(body); + if (result.success === false) { + return toResponse(new ZodValidationError("Invalid request body", treeifyError(result.error)), request); + } + input.body = result.data; + } + + if (input.params !== undefined || input.query !== undefined || input.body !== undefined) { + args.push(input); + } + + // ### Context + // Request context pass to every route as the last argument. + + args.push(getRequestContext(request)); + + // ### Handler + // Execute the route handler and apply the result. + + if (route.state.handle === undefined) { + return toResponse(new NotImplementedError(`Path '${route.method} ${route.path}' is not implemented.`), request); + } + + return toResponse(await route.state.handle(...args), request); + } + + #getErrorResponse(error: unknown, route: Route, request: Request): Response { + if (route?.state.hooks?.onError !== undefined) { + return route.state.hooks.onError(error); + } + if (error instanceof ServerError) { + return toResponse(error, request); + } + logger.error(error); + if (error instanceof Error) { + return toResponse(new InternalServerError(error.message), request); + } + return toResponse(new InternalServerError(), request); + } + + /** + * Resolves request body and returns it. + * + * @param request - Request to resolve body from. + * @param files - Files to populate if present. + */ + async #getRequestBody(request: Request): Promise> { + let body: Record = {}; + + const type = request.headers.get("content-type"); + if (!type || request.method === "GET") { + return body; + } + + if (type.includes("json")) { + body = await request.json(); + } + + if (type.includes("application/x-www-form-urlencoded") || type.includes("multipart/form-data")) { + try { + const formData = await request.formData(); + for (const [name, value] of Array.from(formData.entries())) { + body[name] = value; + } + } catch (error) { + logger.error(error); + throw new BadRequestError(`Malformed FormData`, { error }); + } + } + + return body; + } +} + +/* + |-------------------------------------------------------------------------------- + | Helpers + |-------------------------------------------------------------------------------- + */ + +/** + * Assert that the given method string is a valid routing method. + * + * @param candidate - Method candidate. + */ +function assertMethod(candidate: string): asserts candidate is RouteMethod { + if (!SUPPORTED_MEHODS.includes(candidate)) { + throw new Error(`Router > Unsupported method '${candidate}'`); + } +} + +/** + * Sorting method for routes to ensure that static properties takes precedence + * for when a route is matched against incoming requests. + * + * @param a - Route A + * @param b - Route B + */ +function byStaticPriority(a: Route, b: Route) { + const aSegments = a.path.split("/"); + const bSegments = b.path.split("/"); + + const maxLength = Math.max(aSegments.length, bSegments.length); + + for (let i = 0; i < maxLength; i++) { + const aSegment = aSegments[i] || ""; + const bSegment = bSegments[i] || ""; + + const isADynamic = aSegment.startsWith(":"); + const isBDynamic = bSegment.startsWith(":"); + + if (isADynamic !== isBDynamic) { + return isADynamic ? 1 : -1; + } + + if (isADynamic === false && aSegment !== bSegment) { + return aSegment.localeCompare(bSegment); + } + } + + return a.path.localeCompare(b.path); +} + +/** + * Resolve and return query object from the provided search parameters, or undefined + * if the search parameters does not have any entries. + * + * @param searchParams - Search params to create a query object from. + */ +function toQuery(searchParams: URLSearchParams): object | undefined { + if (searchParams.size === 0) { + return undefined; + } + const result: Record = {}; + for (const [key, value] of searchParams.entries()) { + result[key] = value; + } + return result; +} + +/** + * Takes a server side request result and returns a fetch Response. + * + * @param result - Result to send back as a Response. + * @param request - Request instance. + */ +export function toResponse(result: unknown, request: Request): Response { + const method = request.method; + + if (result instanceof Response) { + if (method === "HEAD") { + return new Response(null, { + status: result.status, + statusText: result.statusText, + headers: new Headers(result.headers), + }); + } + return result; + } + + if (result instanceof ServerError) { + const body = JSON.stringify({ + error: { + status: result.status, + message: result.message, + data: result.data, + }, + } satisfies ServerErrorResponse); + + return new Response(method === "HEAD" ? null : body, { + statusText: result.message || "Internal Server Error", + status: result.status || 500, + headers: { + "content-type": "application/json", + }, + }); + } + + const body = JSON.stringify({ + data: result ?? null, + }); + + return new Response(method === "HEAD" ? null : body, { + status: 200, + headers: { + "content-type": "application/json", + }, + }); +} + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +type Routes = { + POST: Route[]; + GET: Route[]; + PUT: Route[]; + PATCH: Route[]; + DELETE: Route[]; +}; + +type ResolvedRoute = { + route: Route; + params: any; +}; diff --git a/api/libraries/server/context.ts b/api/libraries/server/context.ts new file mode 100644 index 0000000..d77d389 --- /dev/null +++ b/api/libraries/server/context.ts @@ -0,0 +1,16 @@ +import { RouteContext } from "@spec/relay"; + +export function getRequestContext(request: Request): RouteContext { + return { + request, + }; +} + +declare module "@spec/relay" { + interface RouteContext { + /** + * Current request instance being handled. + */ + request: Request; + } +} diff --git a/api/libraries/server/mod.ts b/api/libraries/server/mod.ts new file mode 100644 index 0000000..316ad24 --- /dev/null +++ b/api/libraries/server/mod.ts @@ -0,0 +1,5 @@ +export * from "./api.ts"; +export * from "./context.ts"; +export * from "./modules.ts"; +export * from "./request.ts"; +export * from "./storage.ts"; diff --git a/api/libraries/server/modules.ts b/api/libraries/server/modules.ts new file mode 100644 index 0000000..bc3752e --- /dev/null +++ b/api/libraries/server/modules.ts @@ -0,0 +1,40 @@ +import { Route } from "@spec/relay"; + +/** + * Resolve and return all routes that has been created under any 'routes' + * folders that can be found under the given path. + * + * If the filter is empty, all paths are resolved, otherwise only paths + * declared in the array is resolved. + * + * @param path - Path to resolve routes from. + * @param filter - List of modules to include. + * @param routes - List of routes that has been resolved. + */ +export async function resolveRoutes(path: string, routes: Route[] = []): Promise { + for await (const entry of Deno.readDir(path)) { + if (entry.isDirectory === true) { + await loadRoutes(`${path}/${entry.name}/routes`, routes, [name]); + } + } + return routes; +} + +async function loadRoutes(path: string, routes: Route[], modules: string[]): Promise { + for await (const entry of Deno.readDir(path)) { + if (entry.isDirectory === true) { + await loadRoutes(`${path}/${entry.name}`, routes, [...modules, entry.name]); + } else { + if (!entry.name.endsWith(".ts") || entry.name.endsWith("i9n.ts")) { + continue; + } + const { default: route } = (await import(`${path}/${entry.name}`)) as { default: Route }; + if (route instanceof Route === false) { + throw new Error( + `Router Violation: Could not load '${path}/${entry.name}' as it does not export a default Route instance.`, + ); + } + routes.push(route); + } + } +} diff --git a/api/libraries/server/request.ts b/api/libraries/server/request.ts new file mode 100644 index 0000000..c6d4678 --- /dev/null +++ b/api/libraries/server/request.ts @@ -0,0 +1,57 @@ +import { asyncLocalStorage } from "./storage.ts"; + +export const req = { + get store() { + const store = asyncLocalStorage.getStore(); + if (store === undefined) { + throw new Error("Request > AsyncLocalStorage not defined."); + } + return store; + }, + + get socket() { + return this.store.socket; + }, + + /** + * Get store that is potentially undefined. + * Typically used when utility functions might run in and out of request scope. + */ + get unsafeStore() { + return asyncLocalStorage.getStore(); + }, + + /** + * Check if the request is authenticated. + */ + get isAuthenticated() { + return this.session !== undefined; + }, + + /** + * Get current session. + */ + get session() { + return this.store.session; + }, + + /** + * Gets the meta information stored in the request. + */ + get info() { + return this.store.info; + }, + + /** + * Sends a JSON-RPC 2.0 notification to the request if sent through a + * WebSocket connection. + * + * @param method - Method to send notification to. + * @param params - Params to pass to the method. + */ + notify(method: string, params: any): void { + this.socket?.send(JSON.stringify({ jsonrpc: "2.0", method, params })); + }, +} as const; + +export type ReqContext = typeof req; diff --git a/api/libraries/server/storage.ts b/api/libraries/server/storage.ts new file mode 100644 index 0000000..3cdf826 --- /dev/null +++ b/api/libraries/server/storage.ts @@ -0,0 +1,16 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +import type { Session } from "~libraries/auth/mod.ts"; + +export const asyncLocalStorage = new AsyncLocalStorage<{ + session?: Session; + info: { + method: string; + start: number; + end?: number; + }; + socket?: WebSocket; + response: { + headers: Headers; + }; +}>(); diff --git a/api/libraries/socket/channels.ts b/api/libraries/socket/channels.ts new file mode 100644 index 0000000..6653dd8 --- /dev/null +++ b/api/libraries/socket/channels.ts @@ -0,0 +1,81 @@ +import type { Params } from "@valkyr/json-rpc"; + +import { Sockets } from "./sockets.ts"; + +export class Channels { + readonly #channels = new Map(); + + /** + * Add a new channel. + * + * @param channel + */ + add(channel: string): this { + this.#channels.set(channel, new Sockets()); + return this; + } + + /** + * Deletes a channel. + * + * @param channel + */ + del(channel: string): this { + this.#channels.delete(channel); + return this; + } + + /** + * Add socket to the given channel. If the channel does not exist it is + * automatically created. + * + * @param channel - Channel to add socket to. + * @param socket - Socket to add to the channel. + */ + join(channel: string, socket: WebSocket): this { + const sockets = this.#channels.get(channel); + if (sockets === undefined) { + this.#channels.set(channel, new Sockets().add(socket)); + } else { + sockets.add(socket); + } + return this; + } + + /** + * Remove a socket from the given channel. + * + * @param channel - Channel to leave. + * @param socket - Socket to remove from the channel. + */ + leave(channel: string, socket: WebSocket): this { + this.#channels.get(channel)?.del(socket); + return this; + } + + /** + * Sends a JSON-RPC notification to all sockets in given channel. + * + * @param channel - Channel to emit method to. + * @param method - Method to send the notification to. + * @param params - Message data to send to the clients. + */ + notify(channel: string, method: string, params: Params): this { + this.#channels.get(channel)?.notify(method, params); + return this; + } + + /** + * Transmits data to all registered WebSocket connections in the given channel. + * Data can be a string, a Blob, an ArrayBuffer, or an ArrayBufferView. + * + * @param channel - Channel to emit message to. + * @param data - Data to send to each connected socket in the channel. + */ + send(channel: string, data: string | ArrayBufferLike | Blob | ArrayBufferView): this { + this.#channels.get(channel)?.send(data); + return this; + } +} + +export const channels = new Channels(); diff --git a/api/libraries/socket/mod.ts b/api/libraries/socket/mod.ts new file mode 100644 index 0000000..8496e56 --- /dev/null +++ b/api/libraries/socket/mod.ts @@ -0,0 +1 @@ +export { Sockets } from "./sockets.ts"; diff --git a/api/libraries/socket/sockets.ts b/api/libraries/socket/sockets.ts new file mode 100644 index 0000000..fc981e9 --- /dev/null +++ b/api/libraries/socket/sockets.ts @@ -0,0 +1,49 @@ +import type { Params } from "@valkyr/json-rpc"; + +export class Sockets { + readonly #sockets = new Set(); + + /** + * Add a socket to the pool. + * + * @param socket - WebSocket to add. + */ + add(socket: WebSocket): this { + this.#sockets.add(socket); + return this; + } + + /** + * Remove a socket from the pool. + * + * @param socket - WebSocket to remove. + */ + del(socket: WebSocket): this { + this.#sockets.delete(socket); + return this; + } + + /** + * Sends a JSON-RPC notification to all connected sockets. + * + * @param method - Method to send the notification to. + * @param params - Message data to send to the clients. + */ + notify(method: string, params: Params): this { + this.send(JSON.stringify({ jsonrpc: "2.0", method, params })); + return this; + } + + /** + * Transmits data to all registered WebSocket connections. Data can be a string, + * a Blob, an ArrayBuffer, or an ArrayBufferView. + * + * @param data - Data to send to each connected socket. + */ + send(data: string | ArrayBufferLike | Blob | ArrayBufferView): this { + this.#sockets.forEach((socket) => socket.send(data)); + return this; + } +} + +export const sockets = new Sockets(); diff --git a/api/libraries/socket/upgrade.ts b/api/libraries/socket/upgrade.ts new file mode 100644 index 0000000..3bc7962 --- /dev/null +++ b/api/libraries/socket/upgrade.ts @@ -0,0 +1,60 @@ +import { toJsonRpc } from "@valkyr/json-rpc"; + +import { Session } from "~libraries/auth/mod.ts"; +import { logger } from "~libraries/logger/mod.ts"; + +import { sockets } from "./sockets.ts"; + +export function upgrade(request: Request, session?: Session) { + const { socket, response } = Deno.upgradeWebSocket(request); + + socket.addEventListener("open", () => { + logger.prefix("Socket").info("socket connected", { session }); + sockets.add(socket); + }); + + socket.addEventListener("close", () => { + logger.prefix("Socket").info("socket disconnected", { session }); + sockets.del(socket); + }); + + socket.addEventListener("message", (event) => { + if (event.data === "ping") { + return; + } + + const body = toJsonRpc(event.data); + + logger.prefix("Socket").info(body); + + asyncLocalStorage.run( + { + session, + info: { + method: body.method!, + start: Date.now(), + }, + socket, + response: { + headers: new Headers(), + }, + }, + async () => { + api + .handleCommand(body) + .then((response) => { + if (response !== undefined) { + logger.info({ response }); + socket.send(JSON.stringify(response)); + } + }) + .catch((error) => { + logger.info({ error }); + socket.send(JSON.stringify(error)); + }); + }, + ); + }); + + return response; +} diff --git a/api/libraries/testing/config.ts b/api/libraries/testing/config.ts new file mode 100644 index 0000000..ec1583b --- /dev/null +++ b/api/libraries/testing/config.ts @@ -0,0 +1,4 @@ +export const config = { + mongodb: "mongo:8.0.3", + postgres: "postgres:17", +}; diff --git a/api/libraries/testing/containers/api-container.ts b/api/libraries/testing/containers/api-container.ts new file mode 100644 index 0000000..b82eff7 --- /dev/null +++ b/api/libraries/testing/containers/api-container.ts @@ -0,0 +1,154 @@ +import { getAvailablePort } from "@std/net"; +import cookie from "cookie"; + +import { auth, Session } from "~libraries/auth/mod.ts"; +import { Code } from "~libraries/code/aggregates/code.ts"; +import { handler } from "~libraries/server/handler.ts"; + +import { Api, QueryMethod } from "../.generated/api.ts"; + +export class ApiTestContainer { + #server?: Deno.HttpServer; + #client?: Api; + #cookie?: string; + #session?: Session; + + /* + |-------------------------------------------------------------------------------- + | Accessors + |-------------------------------------------------------------------------------- + */ + + get accountId(): string | undefined { + if (this.#session?.valid === true) { + return this.#session.accountId; + } + } + + get client() { + if (this.#client === undefined) { + throw new Error("ApiContainer > .start() has not been executed."); + } + return this.#client; + } + + /* + |-------------------------------------------------------------------------------- + | Lifecycle + |-------------------------------------------------------------------------------- + */ + + async start(): Promise { + const port = await getAvailablePort(); + this.#server = await Deno.serve({ port, hostname: "127.0.0.1" }, handler); + this.#client = makeApiClient(port, { + onBeforeRequest: (headers: Headers) => { + if (this.#cookie !== undefined) { + headers.set("cookie", this.#cookie); + } + }, + onAfterResponse: (response) => { + const cookie = response.headers.get("set-cookie"); + if (cookie !== null) { + this.#cookie = cookie; + } + }, + }); + return this; + } + + async stop() { + await this.#server?.shutdown(); + } + + /* + |-------------------------------------------------------------------------------- + | Utilities + |-------------------------------------------------------------------------------- + */ + + async authorize(accountId: string): Promise { + const code = await Code.create({ identity: { type: "admin", accountId } }).save(); + await this.client.auth.code(accountId, code.id, code.value, {}); + this.#session = await this.getSession(); + } + + async getSession(): Promise { + const token = cookie.parse(this.#cookie ?? "").token; + if (token !== undefined) { + const session = await auth.resolve(token); + if (session.valid === true) { + return session; + } + } + } + + unauthorize(): void { + this.#cookie = undefined; + } +} + +function makeApiClient( + port: number, + { + onBeforeRequest, + onAfterResponse, + }: { + onBeforeRequest: (headers: Headers) => void; + onAfterResponse: (response: Response) => void; + }, +): Api { + return new Api({ + async command(payload) { + const headers = new Headers(); + onBeforeRequest(headers); + headers.set("content-type", "application/json"); + const response = await fetch(`http://127.0.0.1:${port}/api/v1/command`, { + method: "POST", + headers, + body: JSON.stringify(payload), + }); + const text = await response.text(); + if (response.status >= 300) { + console.error( + `Command '${payload.method}' responded with error status '${response.status} ${response.statusText}'.`, + ); + } + if (response.headers.get("content-type")?.includes("json") === true) { + return JSON.parse(text); + } + }, + async query(method: QueryMethod, path: string, query: Record, body: any = {}) { + const headers = new Headers(); + onBeforeRequest(headers); + if (method !== "GET") { + headers.set("content-type", "application/json"); + } + const response = await fetch(`http://127.0.0.1:${port}${path}${getSearchQuery(query)}`, { + method, + headers, + body: method === "GET" ? undefined : JSON.stringify(body), + }); + onAfterResponse(response); + const text = await response.text(); + if (response.status >= 300) { + console.error(`Query '${path}' responded with error status '${response.status} ${response.statusText}'.`); + throw new Error(response.statusText); + } + if (response.headers.get("content-type")?.includes("json") === true) { + return JSON.parse(text); + } + }, + }); +} + +function getSearchQuery(query: Record): string { + const search: string[] = []; + for (const key in query) { + search.push(`${key}=${query[key]}`); + } + if (search.length === 0) { + return ""; + } + return `?${search.join("&")}`; +} diff --git a/api/libraries/testing/containers/database-container.ts b/api/libraries/testing/containers/database-container.ts new file mode 100644 index 0000000..da20da5 --- /dev/null +++ b/api/libraries/testing/containers/database-container.ts @@ -0,0 +1,41 @@ +import { MongoTestContainer } from "@valkyr/testcontainers/mongodb"; + +import { container } from "~database/container.ts"; +import { logger } from "~libraries/logger/mod.ts"; +import { bootstrap } from "~libraries/utilities/bootstrap.ts"; +import { API_DOMAINS_DIR, API_PACKAGES_DIR } from "~paths"; + +export class DatabaseTestContainer { + constructor(readonly mongo: MongoTestContainer) { + container.set("client", mongo.client); + } + + /* + |-------------------------------------------------------------------------------- + | Lifecycle + |-------------------------------------------------------------------------------- + */ + + async start(): Promise { + logger.prefix("Database").info("DatabaseTestContainer Started"); + + await bootstrap(API_DOMAINS_DIR); + await bootstrap(API_PACKAGES_DIR); + + return this; + } + + async truncate() { + const promises: Promise[] = []; + for (const dbName of ["balto:auth", "balto:code", "balto:consultant", "balto:task"]) { + const db = this.mongo.client.db(dbName); + const collections = await db.listCollections().toArray(); + promises.push(...collections.map(({ name }) => db.collection(name).deleteMany({}))); + } + await Promise.all(promises); + } + + async stop() { + logger.prefix("Database").info("DatabaseTestContainer stopped"); + } +} diff --git a/api/libraries/testing/containers/test-container.ts b/api/libraries/testing/containers/test-container.ts new file mode 100644 index 0000000..eb57dd1 --- /dev/null +++ b/api/libraries/testing/containers/test-container.ts @@ -0,0 +1,178 @@ +import { MongoTestContainer } from "@valkyr/testcontainers/mongodb"; + +import { config } from "../config.ts"; +import { ApiTestContainer } from "./api-container.ts"; +import { DatabaseTestContainer } from "./database-container.ts"; + +export class TestContainer { + readonly id = crypto.randomUUID(); + + // ### Enablers + // A map of services to enable when the TestContainer is started. These toggles + // must be toggled before the container is started. + + #with: With = { + mongodb: false, + database: false, + api: false, + }; + + // ### Needs + + #needs: Needs = { + mongodb: [], + database: ["mongodb"], + api: ["mongodb", "database"], + }; + + // ### Services + // Any services that has been enabled will be running under the following + // assignments. Make sure to .stop any running services to avoid shutdown + // leaks. + + #mongodb?: MongoTestContainer; + #database?: DatabaseTestContainer; + #api?: ApiTestContainer; + + /* + |-------------------------------------------------------------------------------- + | Accessors + |-------------------------------------------------------------------------------- + */ + + get accountId() { + if (this.#api === undefined) { + throw new Error("TestContainer > .withApi() must be called before starting the TestContainer."); + } + return this.#api.accountId; + } + + get mongodb(): MongoTestContainer { + if (this.#mongodb === undefined) { + throw new Error("TestContainer > .withMongo() must be called before starting the TestContainer."); + } + return this.#mongodb; + } + + get database(): DatabaseTestContainer { + if (this.#database === undefined) { + throw new Error("TestContainer > .withDatabase() must be called before starting the TestContainer."); + } + return this.#database; + } + + get api() { + if (this.#api === undefined) { + throw new Error("TestContainer > .withApi() must be called before starting the TestContainer."); + } + return this.#api.client; + } + + get authorize() { + if (this.#api === undefined) { + throw new Error("TestContainer > .withApi() must be called before starting the TestContainer."); + } + return this.#api.authorize.bind(this.#api); + } + + get unauthorize() { + if (this.#api === undefined) { + throw new Error("TestContainer > .withApi() must be called before starting the TestContainer."); + } + return this.#api.unauthorize.bind(this.#api); + } + + /* + |-------------------------------------------------------------------------------- + | Builder + |-------------------------------------------------------------------------------- + */ + + withMongo(): this { + this.#with.mongodb = true; + return this; + } + + withDatabase(): this { + this.#with.database = true; + return this; + } + + withApi(): this { + this.#with.api = true; + return this; + } + + /* + |-------------------------------------------------------------------------------- + | Lifecycle + |-------------------------------------------------------------------------------- + */ + + async start(): Promise { + const promises: Promise[] = []; + if (this.#isNeeded("mongodb") === true) { + promises.push( + (async () => { + this.#mongodb = await MongoTestContainer.start(config.mongodb); + if (this.#isNeeded("database") === true) { + this.#database = await new DatabaseTestContainer(this.mongodb).start(); + } + })(), + ); + } + if (this.#isNeeded("api") === true) { + promises.push( + (async () => { + this.#api = await new ApiTestContainer().start(); + })(), + ); + } + await Promise.all(promises); + return this; + } + + async stop(): Promise { + await this.#api?.stop(); + await this.#database?.stop(); + await this.#mongodb?.stop(); + + this.#api = undefined; + this.#database = undefined; + this.#mongodb = undefined; + + return this; + } + + /* + |-------------------------------------------------------------------------------- + | Helpers + |-------------------------------------------------------------------------------- + */ + + #isNeeded(target: keyof With): boolean { + if (this.#with[target] !== false) { + return true; + } + for (const key in this.#needs) { + if (this.#with[key as keyof With] !== false && this.#needs[key as keyof With].includes(target) === true) { + return true; + } + } + return false; + } +} + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +type Needs = Record; + +type With = { + mongodb: boolean; + database: boolean; + api: boolean; +}; diff --git a/api/libraries/testing/describe.ts b/api/libraries/testing/describe.ts new file mode 100644 index 0000000..622ea84 --- /dev/null +++ b/api/libraries/testing/describe.ts @@ -0,0 +1,24 @@ +import * as assertSuite from "@std/assert"; +import * as bddSuite from "@std/testing/bdd"; + +import type { TestContainer } from "~libraries/testing/containers/test-container.ts"; + +import { authorize } from "./utilities/account.ts"; + +export function describe(name: string, runner: TestRunner): (container: TestContainer) => void { + return (container: TestContainer) => + bddSuite.describe(name, () => runner(container, bddSuite, assertSuite, { authorize: authorize(container) })); +} + +export type TestRunner = ( + container: TestContainer, + bdd: { + [key in keyof typeof bddSuite]: (typeof bddSuite)[key]; + }, + assert: { + [key in keyof typeof assertSuite]: (typeof assertSuite)[key]; + }, + utils: { + authorize: ReturnType; + }, +) => void; diff --git a/api/libraries/testing/utilities/account.ts b/api/libraries/testing/utilities/account.ts new file mode 100644 index 0000000..057cbec --- /dev/null +++ b/api/libraries/testing/utilities/account.ts @@ -0,0 +1,68 @@ +import type { EventData } from "@valkyr/event-store"; + +import { AccountCreated, AccountEmailAdded } from "~libraries/auth/.generated/events.ts"; +import { Account } from "~libraries/auth/aggregates/account.ts"; +import { Role } from "~libraries/auth/aggregates/role.ts"; +import type { TestContainer } from "~libraries/testing/containers/test-container.ts"; + +type AuthorizationOptions = { + name?: { family?: string; given?: string }; + email?: Partial>; +}; + +/** + * Return a function which provides the ability to create a new account which + * is authorized and ready to use for testing authorized requests. + * + * @param container - Container to authorize against. + */ +export function authorize(container: TestContainer): AuthorizeFn { + return async (data: EventData, { name = {}, email = {} }: AuthorizationOptions = {}) => { + const role = await makeRole(data.type).save(); + const account = await Account.create(data, "test") + .addName(name?.family ?? "Doe", name?.given ?? "John", "test") + .addEmail({ value: "john.doe@fixture.none", type: "work", primary: true, verified: true, ...email }, "test") + .addRole(role.id, "test") + .save(); + await container.authorize(account.id); + return account; + }; +} + +function makeRole(type: "admin" | "consultant" | "organization"): Role { + switch (type) { + case "admin": { + return Role.create( + { + name: "Admin", + permissions: [ + { resource: "admin", actions: ["create", "update", "delete"] }, + { resource: "consultant", actions: ["create", "update", "delete"] }, + { resource: "organization", actions: ["create", "update", "delete"] }, + ], + }, + "test", + ); + } + case "consultant": { + return Role.create( + { + name: "Consultant", + permissions: [{ resource: "consultant", actions: ["create", "update", "delete"] }], + }, + "test", + ); + } + case "organization": { + return Role.create( + { + name: "Organization", + permissions: [{ resource: "organization", actions: ["create", "update", "delete"] }], + }, + "test", + ); + } + } +} + +type AuthorizeFn = (data: EventData, optional?: AuthorizationOptions) => Promise; diff --git a/api/libraries/utilities/dedent.ts b/api/libraries/utilities/dedent.ts new file mode 100644 index 0000000..e8da0a4 --- /dev/null +++ b/api/libraries/utilities/dedent.ts @@ -0,0 +1,62 @@ +/** + * Removes excess indentation caused by using multiline template strings. + * + * Ported from `dedent-js` solution. + * + * @see https://github.com/MartinKolarik/dedent-js + * + * @param templateStrings - Template strings to dedent. + * + * @example + * { + * nested: { + * examples: [ + * dedent(` + * I am 8 spaces off from the beginning of this file. + * But I will be 2 spaces based on the trimmed distance + * of the first line. + * `), + * ] + * } + * } + */ +export function dedent(templateStrings: TemplateStringsArray | string, ...values: any[]) { + const matches = []; + const strings = typeof templateStrings === "string" ? [templateStrings] : templateStrings.slice(); + + // Remove trailing whitespace. + + strings[strings.length - 1] = strings[strings.length - 1].replace(/\r?\n([\t ]*)$/, ""); + + // Find all line breaks to determine the highest common indentation level. + + for (let i = 0; i < strings.length; i++) { + const match = strings[i].match(/\n[\t ]+/g); + if (match) { + matches.push(...match); + } + } + + // Remove the common indentation from all strings. + + if (matches.length) { + const size = Math.min(...matches.map((value) => value.length - 1)); + const pattern = new RegExp(`\n[\t ]{${size}}`, "g"); + for (let i = 0; i < strings.length; i++) { + strings[i] = strings[i].replace(pattern, "\n"); + } + } + + // Remove leading whitespace. + + strings[0] = strings[0].replace(/^\r?\n/, ""); + + // Perform interpolation. + + let string = strings[0]; + for (let i = 0; i < values.length; i++) { + string += values[i] + strings[i + 1]; + } + + return string; +} diff --git a/api/libraries/utilities/generate.ts b/api/libraries/utilities/generate.ts new file mode 100644 index 0000000..7920ef2 --- /dev/null +++ b/api/libraries/utilities/generate.ts @@ -0,0 +1,41 @@ +/** + * Traverse path and look for a `generate.ts` file in each folder found under + * the given path. If a `generate.ts` file is found it is imported so its content + * is executed. + * + * @param path - Path to resolve `generate.ts` files. + * @param filter - Which folders found under the given path to ignore. + */ +export async function generate(path: string, filter: string[] = []): Promise { + const generate: string[] = []; + for await (const entry of Deno.readDir(path)) { + if (entry.isDirectory === true) { + const moduleName = path.split("/").pop(); + if (moduleName === undefined) { + continue; + } + if (filter.length > 0 && filter.includes(moduleName) === false) { + continue; + } + const filePath = `${path}/${entry.name}/.tasks/generate.ts`; + if (await hasFile(filePath)) { + generate.push(filePath); + } + } + } + for (const filePath of generate) { + await import(filePath); + } +} + +async function hasFile(filePath: string) { + try { + await Deno.lstat(filePath); + } catch (err) { + if (!(err instanceof Deno.errors.NotFound)) { + throw err; + } + return false; + } + return true; +} diff --git a/api/modules/auth/routes/authenticate.ts b/api/modules/auth/routes/authenticate.ts new file mode 100644 index 0000000..92ca136 --- /dev/null +++ b/api/modules/auth/routes/authenticate.ts @@ -0,0 +1,5 @@ +import { authenticate } from "@spec/modules/auth/routes/authenticate.ts"; + +export default authenticate.access("public").handle(async ({ body }) => { + console.log({ body }); +}); diff --git a/api/package.json b/api/package.json new file mode 100644 index 0000000..6adc53b --- /dev/null +++ b/api/package.json @@ -0,0 +1,23 @@ +{ + "private": true, + "scripts": { + "start": "deno --allow-all --watch-hmr=routes/ server.ts", + "migrate": "deno run --allow-all .tasks/migrate.ts" + }, + "dependencies": { + "@felix/bcrypt": "npm:@jsr/felix__bcrypt@1", + "@spec/modules": "workspace:*", + "@spec/relay": "workspace:*", + "@spec/shared": "workspace:*", + "@std/cli": "npm:@jsr/std__cli@1", + "@std/dotenv": "npm:@jsr/std__dotenv@0.225", + "@std/fs": "npm:@jsr/std__fs@1", + "@std/path": "npm:@jsr/std__path@1", + "@valkyr/auth": "npm:@jsr/valkyr__auth@2", + "@valkyr/event-store": "npm:@jsr/valkyr__event-store@2.0.0-beta.5", + "@valkyr/inverse": "npm:@jsr/valkyr__inverse@1", + "cookie": "1", + "mongodb": "6", + "zod": "4" + } +} \ No newline at end of file diff --git a/api/server.ts b/api/server.ts new file mode 100644 index 0000000..da850ab --- /dev/null +++ b/api/server.ts @@ -0,0 +1,93 @@ +import { resolve } from "@std/path"; +import cookie from "cookie"; + +import { auth, type Session } from "~libraries/auth/mod.ts"; +import { logger } from "~libraries/logger/mod.ts"; +import { asyncLocalStorage } from "~libraries/server/mod.ts"; +import { Api, resolveRoutes } from "~libraries/server/mod.ts"; + +import { config } from "./config.ts"; + +const MODULES_DIR = resolve(import.meta.dirname!, "modules"); + +const log = logger.prefix("Server"); + +/* + |-------------------------------------------------------------------------------- + | Bootstrap + |-------------------------------------------------------------------------------- + */ + +await import("./tasks/bootstrap.ts"); + +/* + |-------------------------------------------------------------------------------- + | Service + |-------------------------------------------------------------------------------- + */ + +const api = new Api(await resolveRoutes(MODULES_DIR)); + +/* + |-------------------------------------------------------------------------------- + | Server + |-------------------------------------------------------------------------------- + */ + +Deno.serve( + { + port: config.port, + hostname: config.host, + onListen({ port, hostname }) { + logger.prefix("Server").info(`Listening at http://${hostname}:${port}`); + }, + }, + async (request) => { + const url = new URL(request.url); + + // ### Session + + let session: Session | undefined; + + const token = cookie.parse(request.headers.get("cookie") ?? "").token; + if (token !== undefined) { + const resolved = await auth.resolve(token); + if (resolved.valid === false) { + return new Response(resolved.message, { + status: 401, + headers: { + "set-cookie": cookie.serialize("token", "", config.cookie(0)), + }, + }); + } + session = resolved; + } + + // ### Headers + // Set the default headers. + + const headers = new Headers(); + + // ### Handle + + const ts = performance.now(); + + return asyncLocalStorage.run( + { + session, + info: { + method: request.url, + start: Date.now(), + }, + response: { + headers, + }, + }, + async () => { + return api.fetch(request).finally(() => { + log.info(`${request.method} ${url.pathname} [${((performance.now() - ts) / 1000).toLocaleString()} seconds]`); + }); + }, + ); + }, +); diff --git a/api/tasks/bootstrap.ts b/api/tasks/bootstrap.ts new file mode 100644 index 0000000..c1501c0 --- /dev/null +++ b/api/tasks/bootstrap.ts @@ -0,0 +1,68 @@ +import { resolve } from "node:path"; + +import { logger } from "~libraries/logger/mod.ts"; + +const LIBRARIES_DIR = resolve(import.meta.dirname!, "..", "libraries"); + +const log = logger.prefix("Bootstrap"); + +/* + |-------------------------------------------------------------------------------- + | Database + |-------------------------------------------------------------------------------- + */ + +await import("~libraries/database/tasks/bootstrap.ts"); + +/* + |-------------------------------------------------------------------------------- + | Packages + |-------------------------------------------------------------------------------- + */ + +await bootstrap(LIBRARIES_DIR); + +/* + |-------------------------------------------------------------------------------- + | Helpers + |-------------------------------------------------------------------------------- + */ + +/** + * Traverse path and look for a `bootstrap.ts` file in each folder found under + * the given path. If a `boostrap.ts` file is found it is imported so its content + * is executed. + * + * @param path - Path to resolve `bootstrap.ts` files. + */ +export async function bootstrap(path: string): Promise { + const bootstrap: { name: string; path: string }[] = []; + for await (const entry of Deno.readDir(path)) { + if (entry.isDirectory === true) { + const moduleName = path.split("/").pop(); + if (moduleName === undefined) { + continue; + } + const filePath = `${path}/${entry.name}/.tasks/bootstrap.ts`; + if (await hasFile(filePath)) { + bootstrap.push({ name: entry.name, path: filePath }); + } + } + } + for (const entry of bootstrap) { + log.info(entry.name); + await import(entry.path); + } +} + +async function hasFile(filePath: string) { + try { + await Deno.lstat(filePath); + } catch (err) { + if (!(err instanceof Deno.errors.NotFound)) { + throw err; + } + return false; + } + return true; +} diff --git a/api/tasks/migrate.ts b/api/tasks/migrate.ts new file mode 100644 index 0000000..d2c6a37 --- /dev/null +++ b/api/tasks/migrate.ts @@ -0,0 +1,66 @@ +import { resolve } from "node:path"; +import process from "node:process"; + +import { exists } from "@std/fs"; + +import { config } from "~libraries/database/config.ts"; +import { getMongoClient } from "~libraries/database/connection.ts"; +import { container } from "~libraries/database/container.ts"; +import { logger } from "~libraries/logger/mod.ts"; + +/* + |-------------------------------------------------------------------------------- + | Dependencies + |-------------------------------------------------------------------------------- + */ + +const client = getMongoClient(config.mongo); + +container.set("client", client); + +/* +|-------------------------------------------------------------------------------- +| Migrate +|-------------------------------------------------------------------------------- +*/ + +const db = client.db("api:migrations"); +const collection = db.collection("migrations"); + +const { default: journal } = await import(resolve(import.meta.dirname!, "migrations", "meta", "_journal.json"), { + with: { type: "json" }, +}); + +const migrations = + (await collection.findOne({ name: journal.name })) ?? ({ name: journal.name, entries: [] } as MigrationDocument); + +for (const entry of journal.entries) { + const migrationFileName = `${String(entry.idx).padStart(4, "0")}_${entry.name}.ts`; + if (migrations.entries.includes(migrationFileName)) { + continue; + } + const migrationPath = resolve(import.meta.dirname!, "migrations", migrationFileName); + if (await exists(migrationPath)) { + await import(migrationPath); + await collection.updateOne( + { + name: journal.name, + }, + { + $set: { name: journal.name }, + $push: { entries: migrationFileName }, // Assuming 'entries' is an array + }, + { + upsert: true, + }, + ); + logger.info(`Migrated ${migrationPath}`); + } +} + +type MigrationDocument = { + name: string; + entries: string[]; +}; + +process.exit(0); diff --git a/api/tasks/migrations/meta/_journal.json b/api/tasks/migrations/meta/_journal.json new file mode 100644 index 0000000..e82d343 --- /dev/null +++ b/api/tasks/migrations/meta/_journal.json @@ -0,0 +1,4 @@ +{ + "name": "api", + "entries": [] +} \ No newline at end of file diff --git a/apps/README.md b/apps/README.md new file mode 100644 index 0000000..c7782ba --- /dev/null +++ b/apps/README.md @@ -0,0 +1 @@ +# Apps \ No newline at end of file diff --git a/apps/react/.gitignore b/apps/react/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/apps/react/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/apps/react/.npmrc b/apps/react/.npmrc new file mode 100644 index 0000000..41583e3 --- /dev/null +++ b/apps/react/.npmrc @@ -0,0 +1 @@ +@jsr:registry=https://npm.jsr.io diff --git a/apps/react/README.md b/apps/react/README.md new file mode 100644 index 0000000..7959ce4 --- /dev/null +++ b/apps/react/README.md @@ -0,0 +1,69 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default tseslint.config([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + ...tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + ...tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + ...tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default tseslint.config([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/apps/react/index.html b/apps/react/index.html new file mode 100644 index 0000000..e4b78ea --- /dev/null +++ b/apps/react/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + + +
+ + + diff --git a/apps/react/package.json b/apps/react/package.json new file mode 100644 index 0000000..5fe6378 --- /dev/null +++ b/apps/react/package.json @@ -0,0 +1,34 @@ +{ + "name": "react", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "5", + "@tanstack/react-router": "1", + "@valkyr/db": "1", + "@valkyr/event-emitter": "npm:@jsr/valkyr__event-emitter@1", + "fast-equals": "5", + "react": "19", + "react-dom": "19" + }, + "devDependencies": { + "@eslint/js": "9", + "@types/react": "19", + "@types/react-dom": "19", + "@vitejs/plugin-react": "4", + "eslint": "9", + "eslint-plugin-react-hooks": "5", + "eslint-plugin-react-refresh": "0.4", + "globals": "16", + "typescript": "5", + "typescript-eslint": "8", + "vite": "7" + } +} diff --git a/apps/react/public/vite.svg b/apps/react/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/apps/react/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/react/src/App.css b/apps/react/src/App.css new file mode 100644 index 0000000..b9d355d --- /dev/null +++ b/apps/react/src/App.css @@ -0,0 +1,42 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/apps/react/src/App.tsx b/apps/react/src/App.tsx new file mode 100644 index 0000000..cfff5c9 --- /dev/null +++ b/apps/react/src/App.tsx @@ -0,0 +1,36 @@ +import "./App.css"; + +import { useState } from "react"; + +import viteLogo from "/vite.svg"; + +import reactLogo from "./assets/react.svg"; +import { Session } from "./components/Session.tsx"; + +function App() { + const [count, setCount] = useState(0); + + return ( + <> + +

Vite + React

+
+ +

+ Edit src/App.tsx and save to test HMR +

+
+

Click on the Vite and React logos to learn more

+ + + ); +} + +export default App; diff --git a/apps/react/src/adapters/http.ts b/apps/react/src/adapters/http.ts new file mode 100644 index 0000000..1135e02 --- /dev/null +++ b/apps/react/src/adapters/http.ts @@ -0,0 +1,267 @@ +import { + assertServerErrorResponse, + type RelayAdapter, + type RelayInput, + type RelayResponse, + ServerError, + type ServerErrorResponse, + type ServerErrorType, +} from "@spec/relay"; + +export class HttpAdapter implements RelayAdapter { + /** + * Instantiate a new HttpAdapter instance. + * + * @param options - Adapter options. + */ + constructor(readonly options: HttpAdapterOptions) {} + + /** + * Override the initial url value set by instantiator. + */ + set url(value: string) { + this.options.url = value; + } + + /** + * Retrieve the URL value from options object. + */ + get url() { + return this.options.url; + } + + /** + * Return the full URL from given endpoint. + * + * @param endpoint - Endpoint to get url for. + */ + getUrl(endpoint: string): string { + return `${this.url}${endpoint}`; + } + + /** + * Send fetch request to the configured endpoint. + * + * @param input - Relay input parameters to use for the request. + */ + async json({ method, endpoint, query, body, headers = new Headers() }: RelayInput): Promise { + const init: RequestInit = { method, headers }; + + // ### Before Request + // If any before request hooks has been defined, we run them here passing in the + // request headers for further modification. + + await this.#beforeRequest(headers); + + // ### Content Type + // JSON requests are always of the type 'application/json' and this ensures that + // we override any custom pre-hook values for 'content-type' when executing the + // request via the 'json' method. + + headers.set("content-type", "application/json"); + + // ### Body + + if (body !== undefined) { + init.body = JSON.stringify(body); + } + + // ### Response + + return this.request(`${endpoint}${query}`, init); + } + + async data({ method, endpoint, query, body, headers = new Headers() }: RelayInput): Promise { + const init: RequestInit = { method, headers }; + + // ### Before Request + // If any before request hooks has been defined, we run them here passing in the + // request headers for further modification. + + await this.#beforeRequest(headers); + + // ### Content Type + // For multipart uploads we let the browser set the correct boundaries. + + headers.delete("content-type"); + + // ### Body + + const formData = new FormData(); + + if (body !== undefined) { + for (const key in body) { + const entity = body[key]; + if (entity === undefined) { + continue; + } + if (Array.isArray(entity)) { + const isFileArray = entity.length > 0 && entity.every((candidate) => candidate instanceof File); + if (isFileArray) { + for (const file of entity) { + formData.append(key, file, file.name); + } + } else { + formData.append(key, JSON.stringify(entity)); + } + } else { + if (entity instanceof File) { + formData.append(key, entity, entity.name); + } else { + formData.append(key, typeof entity === "string" ? entity : JSON.stringify(entity)); + } + } + } + init.body = formData; + } + + // ### Response + + return this.request(`${endpoint}${query}`, init); + } + + /** + * Send a fetch request using the given fetch options and returns + * a relay formatted response. + * + * @param endpoint - Which endpoint to submit request to. + * @param init - Request init details to submit with the request. + */ + async request(endpoint: string, init?: RequestInit): Promise { + return this.#toResponse(await fetch(this.getUrl(endpoint), init)); + } + + /** + * Run before request operations. + * + * @param headers - Headers to pass to hooks. + */ + async #beforeRequest(headers: Headers) { + if (this.options.hooks?.beforeRequest !== undefined) { + for (const hook of this.options.hooks.beforeRequest) { + await hook(headers); + } + } + } + + /** + * Convert a fetch response to a compliant relay response. + * + * @param response - Fetch response to convert. + */ + async #toResponse(response: Response): Promise { + const type = response.headers.get("content-type"); + + // ### Content Type + // Ensure that the server responds with a 'content-type' definition. We should + // always expect the server to respond with a type. + + if (type === null) { + return { + result: "error", + headers: response.headers, + error: { + status: response.status, + message: "Missing 'content-type' in header returned from server.", + }, + }; + } + + // ### Empty Response + // If the response comes back with empty response status 204 we simply return a + // empty success. + + if (response.status === 204) { + return { + result: "success", + headers: response.headers, + data: null, + }; + } + + // ### SCIM + // If the 'content-type' is of type 'scim' we need to convert the SCIM compliant + // response to a valid relay response. + + if (type === "application/scim+json") { + const parsed = await response.json(); + if (response.status >= 400) { + return { + result: "error", + headers: response.headers, + error: { + status: response.status, + message: parsed.detail, + }, + }; + } + return { + result: "success", + headers: response.headers, + data: parsed, + }; + } + + // ### JSON + // If the 'content-type' contains 'json' we treat it as a 'json' compliant response + // and attempt to resolve it as such. + + if (type.includes("json") === true) { + const parsed = await response.json(); + if ("data" in parsed) { + return { + result: "success", + headers: response.headers, + data: parsed.data, + }; + } + if ("error" in parsed) { + return { + result: "error", + headers: response.headers, + error: this.#toError(parsed), + }; + } + return { + result: "error", + headers: response.headers, + error: { + status: response.status, + message: "Unsupported 'json' body returned from server, missing 'data' or 'error' key.", + }, + }; + } + + return { + result: "error", + headers: response.headers, + error: { + status: response.status, + message: "Unsupported 'content-type' in header returned from server.", + }, + }; + } + + #toError(candidate: unknown, status: number = 500): ServerErrorType | ServerErrorResponse["error"] { + if (assertServerErrorResponse(candidate)) { + return ServerError.fromJSON({ type: "relay", ...candidate.error }); + } + if (typeof candidate === "string") { + return { + status, + message: candidate, + }; + } + return { + status, + message: "Unsupported 'error' returned from server.", + }; + } +} + +export type HttpAdapterOptions = { + url: string; + hooks?: { + beforeRequest?: ((headers: Headers) => Promise)[]; + }; +}; diff --git a/apps/react/src/assets/react.svg b/apps/react/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/apps/react/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/react/src/components/Session.tsx b/apps/react/src/components/Session.tsx new file mode 100644 index 0000000..efcf127 --- /dev/null +++ b/apps/react/src/components/Session.tsx @@ -0,0 +1,9 @@ +import { makeControllerView } from "../libraries/view.ts"; +import { SessionController } from "./session.controller.ts"; + +export const Session = makeControllerView(SessionController, ({ state: { error } }) => { + if (error !== undefined) { + return "Failed to fetch session"; + } + return
Session OK!
; +}); diff --git a/apps/react/src/components/session.controller.ts b/apps/react/src/components/session.controller.ts new file mode 100644 index 0000000..9c83254 --- /dev/null +++ b/apps/react/src/components/session.controller.ts @@ -0,0 +1,24 @@ +import { Controller } from "../libraries/controller.ts"; +import { api } from "../services/api.ts"; + +export class SessionController extends Controller<{ + error?: string; +}> { + async onInit() { + await this.getSessionCookie(); + } + + async getSessionCookie() { + const response = await api.auth.authenticate({ + body: { + type: "email", + payload: { + email: "john.doe@fixture.none", + }, + }, + }); + if ("error" in response) { + this.setState("error", undefined); + } + } +} diff --git a/apps/react/src/index.css b/apps/react/src/index.css new file mode 100644 index 0000000..08a3ac9 --- /dev/null +++ b/apps/react/src/index.css @@ -0,0 +1,68 @@ +:root { + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/apps/react/src/libraries/controller.ts b/apps/react/src/libraries/controller.ts new file mode 100644 index 0000000..4ab1e7c --- /dev/null +++ b/apps/react/src/libraries/controller.ts @@ -0,0 +1,372 @@ +import type { ChangeEvent, Collection, SubscribeToMany, SubscribeToSingle, SubscriptionOptions } from "@valkyr/db"; +import type { Subscription } from "@valkyr/event-emitter"; + +import { Debounce } from "./debounce.ts"; +import { ControllerRefs } from "./refs.ts"; +import type { ControllerClass, Empty, ReactComponent, ReservedPropertyMembers, Unknown } from "./types.ts"; + +export class Controller { + state: TState = {} as TState; + props: TProps = {} as TProps; + + /** + * Stores a list of referenced elements identifies by a unique key. + */ + readonly refs = new ControllerRefs(); + + /** + * Records of event emitter subscriptions. They are keyed to a subscription name + * for easier identification when unsubscribing. + */ + readonly subscriptions = new Map(); + + /** + * Has the controller fully resolved the .onInit lifecycle method? + */ + #resolved = false; + + /** + * Internal debounce instance used to ensure that we aren't triggering state + * updates too frequently when updates are happening in quick succession. + */ + #debounce = new Debounce(); + + /** + * Creates a new controller instance with given default state and pushState + * handler method. + * + * @param state - Default state to assign to controller. + * @param pushData - Push data handler method. + */ + constructor( + readonly view: ReactComponent, + readonly setView: any, + ) { + this.query = this.query.bind(this); + this.subscribe = this.subscribe.bind(this); + this.setState = this.setState.bind(this); + } + + /* + |-------------------------------------------------------------------------------- + | Factories + |-------------------------------------------------------------------------------- + */ + + /** + * Creates a new controller instance using the given component and setView handler. + * + * @param component - Component to render. + * @param setView - Method to provide a resolved view component. + */ + static make( + this: TController, + component: ReactComponent, + setView: any, + ): InstanceType { + return new this(component, setView); + } + + /* + |-------------------------------------------------------------------------------- + | Bootstrap & Teardown + |-------------------------------------------------------------------------------- + */ + + async $resolve(props: TProps): Promise { + this.props = props; + let state: Partial = {}; + try { + if (this.#resolved === false) { + state = { + ...state, + ...((await this.onInit()) ?? {}), + }; + } + state = { + ...state, + ...((await this.onResolve()) ?? {}), + }; + } catch (err) { + console.error(err); + throw err; + } + this.#resolved = true; + this.setState(state); + } + + async $destroy(): Promise { + for (const subscription of this.subscriptions.values()) { + subscription.unsubscribe(); + } + await this.onDestroy(); + this.refs.destroy(); + } + + /* + |-------------------------------------------------------------------------------- + | Lifecycle Methods + |-------------------------------------------------------------------------------- + */ + + /** + * Method runs once per controller view lifecycle. This is where you should + * subscribe to and return initial controller state. A component is kept in + * loading state until the initial resolve is completed. + * + * Once the initial resolve is completed the controller will not run the onInit + * method again unless the controller is destroyed and re-created. + * + * @example + * ```ts + * async onInit() { + * return { + * foos: this.query(foos, {}, "foos") + * } + * } + * ``` + */ + async onInit(): Promise | void> { + return {}; + } + + /** + * Method runs every time the controller is resolved. This is where you should + * subscribe to and return state that is reflecting changes to the parent view + * properties. + * + * @example + * ```ts + * async onResolve() { + * return { + * foos: this.query(foos, { tenantId: this.props.tenantId }, "foos") + * } + * } + * ``` + */ + async onResolve(): Promise | void> { + return {}; + } + + /** + * Method runs when the controller parent view is destroyed. + */ + async onDestroy(): Promise {} + + /* + |-------------------------------------------------------------------------------- + | Query Methods + |-------------------------------------------------------------------------------- + */ + + /** + * Executes a query on a given collection and returns the initial result. A + * subsequent internal subscription is also created, which automatically updates + * the controller state when changes are made to the data in which the query + * subscribes. + * + * @param collection - Collection to query against. + * @param query - Query to execute. + * @param stateKey - State key to assign the results to, or state handler method. + * + * @example + * ```ts + * async onInit() { + * return { + * foo: await this.query(db.collection("foos"), { limit: 1 }, "foo") + * } + * } + * ``` + */ + async query, TSchema = CollectionSchema, TStateKey = keyof TState>( + collection: TCollection, + query: QuerySingle, + next: TStateKey | ((document: TSchema | undefined) => Promise>), + ): Promise; + + /** + * Executes a query on a given collection and returns the initial result. A + * subsequent internal subscription is also created, which automatically updates + * the controller state when changes are made to the data in which the query + * subscribes. + * + * @param collection - Collection to query against. + * @param query - Query to execute. + * @param next - State key to assign the results to, or state handler method. + * + * @example + * ```ts + * async onInit() { + * return { + * foos: await this.query(db.collection("foos"), {}, "foos") + * } + * } + * ``` + */ + async query, TSchema = CollectionSchema, TStateKey = keyof TState>( + collection: TCollection, + query: QueryMany, + next: + | TStateKey + | ((documents: TSchema[], changed: TSchema[], type: ChangeEvent["type"]) => Promise>), + ): Promise; + + /** + * Executes a query on a given collection and returns the initial result. A + * subsequent internal subscription is also created, which automatically updates + * the controller state when changes are made to the data in which the query + * subscribes. + * + * @param collection - Collection to query against. + * @param query - Query to execute. + * @param stateKey - State key to assign the results to, or state handler method. + */ + async query, TSchema = CollectionSchema, TStateKey = keyof TState>( + collection: TCollection, + query: Query, + next: TStateKey | ((...args: any[]) => Promise>), + ): Promise { + let resolved = false; + this.subscriptions.get(collection.name)?.unsubscribe(); + return new Promise[] | CollectionSchema | undefined>((resolve) => { + const { where, ...options } = query; + this.subscriptions.set( + collection.name, + collection.subscribe(where, options, (...args: any[]) => { + if (this.#isStateKey(next)) { + if (resolved === true) { + this.setState(next, args[0]); + } + } else { + (next as any)(...args).then(this.setState); + } + setTimeout(() => { + resolve(args[0]); + resolved = true; + }, 0); + }), + ); + }); + } + + /* + |-------------------------------------------------------------------------------- + | Event Methods + |-------------------------------------------------------------------------------- + */ + + /** + * Consumes a subscription under a given event key that is unsubscribed + * automatically when the controller is unmounted. + * + * @param key - Unique identifier used to unsusbcribe duplicate subs. + * @param sub - Subscription to unsubscribe on controller unmount. + */ + subscribe(key: string, sub: { unsubscribe: () => void }): void { + this.subscriptions.get(key)?.unsubscribe(); + this.subscriptions.set(key, sub); + } + + /* + |-------------------------------------------------------------------------------- + | State Methods + |-------------------------------------------------------------------------------- + */ + + /** + * Updates the state of the controller and triggers a state update via the push + * state handler. This method will debounce state updates to prevent excessive + * state updates. + * + * @param key - State key to assign data to. + * @param value - State value to assign. + */ + setState(state: Partial): void; + setState(key: K): (state: TState[K]) => void; + setState(key: K, value: TState[K]): void; + setState(...args: [K | TState, TState[K]?]): void | ((state: TState[K]) => void) { + const [target, value] = args; + + if (this.#isStateKey(target) && args.length === 1) { + return (value: TState[K]) => { + this.setState(target, value); + }; + } + + this.state = this.#isStateKey(target) + ? { + ...this.state, + [target]: value, + } + : { + ...this.state, + ...(target as Partial), + }; + + if (this.#resolved === true) { + this.#debounce.run(() => { + this.setView( + this.view({ + props: this.props, + state: this.state, + actions: this.toActions(), + refs: this.refs, + }), + ); + }, 0); + } + } + + /* + |-------------------------------------------------------------------------------- + | Resolvers + |-------------------------------------------------------------------------------- + */ + + /** + * Returns all the prototype methods defined on the controller as a list of + * actions bound to the controller instance to be used in the view. + * + * @returns List of actions. + */ + toActions(): Omit { + const actions: any = {}; + for (const name of Object.getOwnPropertyNames(this.constructor.prototype)) { + if (name !== "constructor" && name !== "resolve") { + const action = (this as any)[name]; + if (typeof action === "function") { + actions[name] = action.bind(this); + } + } + } + return actions; + } + + /* + |-------------------------------------------------------------------------------- + | Utilities + |-------------------------------------------------------------------------------- + */ + + #isStateKey(key: unknown): key is keyof TState { + return typeof key === "string"; + } +} + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +type Query = Where & SubscriptionOptions; + +type QuerySingle = Where & SubscribeToSingle; + +type QueryMany = Where & SubscribeToMany; + +type Where = { + where?: Record; +}; + +type CollectionSchema = TCollection extends Collection ? TSchema : never; diff --git a/apps/react/src/libraries/debounce.ts b/apps/react/src/libraries/debounce.ts new file mode 100644 index 0000000..0aa2893 --- /dev/null +++ b/apps/react/src/libraries/debounce.ts @@ -0,0 +1,14 @@ +export class Debounce { + #timeout?: number; + + run(fn: (...args: any[]) => void, ms: number): void { + this.#clear(); + this.#timeout = setTimeout(fn, ms); + } + + #clear() { + if (this.#timeout !== undefined) { + clearTimeout(this.#timeout); + } + } +} diff --git a/apps/react/src/libraries/refs.ts b/apps/react/src/libraries/refs.ts new file mode 100644 index 0000000..5b5b9d1 --- /dev/null +++ b/apps/react/src/libraries/refs.ts @@ -0,0 +1,50 @@ +const refs = new Map(); + +export class ControllerRefs { + #refs = new Map(); + + #forwarded: string[] = []; + + set(name: string) { + return (element: HTMLElement | null) => { + if (element !== null) { + refs.set(name, element); + } + }; + } + + forward(name: string) { + return (element: HTMLElement | null) => { + if (element !== null) { + refs.set(name, element); + this.#forwarded.push(name); + } + }; + } + + get(name: string) { + const element = this.#refs.get(name) ?? refs.get(name); + if (element === undefined) { + throw new Error(`Reference Exception: ${name} is not defined.`); + } + return element; + } + + async on(name: string, count = 0): Promise { + if (count > 20) { + return undefined; + } + const element = this.#refs.get(name) ?? refs.get(name); + if (element === undefined) { + await new Promise((resolve) => setTimeout(resolve, 50)); + return this.on(name, count + 1); + } + return element; + } + + destroy() { + this.#forwarded.forEach((name) => { + refs.delete(name); + }); + } +} diff --git a/apps/react/src/libraries/types.ts b/apps/react/src/libraries/types.ts new file mode 100644 index 0000000..cea0c80 --- /dev/null +++ b/apps/react/src/libraries/types.ts @@ -0,0 +1,22 @@ +import React, { type FunctionComponent } from "react"; + +import { ControllerRefs } from "./refs.ts"; + +export type ReactComponent = FunctionComponent<{ + props: TProps; + state: InstanceType["state"]; + actions: Omit, ReservedPropertyMembers>; + refs: ControllerRefs; + component?: React.FC; +}>; + +export type ControllerClass = { + new (state: any, pushState: any): any; + make(component: ReactComponent, pushState: any): any; +}; + +export type ReservedPropertyMembers = "state" | "pushState" | "init" | "destroy" | "setNext" | "setState" | "toActions"; + +export type Unknown = Record; + +export type Empty = Record; diff --git a/apps/react/src/libraries/view.ts b/apps/react/src/libraries/view.ts new file mode 100644 index 0000000..3238c64 --- /dev/null +++ b/apps/react/src/libraries/view.ts @@ -0,0 +1,199 @@ +import { deepEqual } from "fast-equals"; +import React, { createElement, type FunctionComponent, memo, type PropsWithChildren, useEffect, useState } from "react"; + +import type { ControllerClass, ReactComponent, Unknown } from "./types.ts"; + +/* + |-------------------------------------------------------------------------------- + | Options + |-------------------------------------------------------------------------------- + */ + +const options: Partial> = { + memoize: defaultMemoizeHandler, +}; + +/* + |-------------------------------------------------------------------------------- + | Factory + |-------------------------------------------------------------------------------- + */ + +export function makeControllerView( + controller: TController, + component: ReactComponent, + options?: Partial>, +): FunctionComponent { + const memoize = getMemoizeHandler(options?.memoize); + const render = { + loading: getLoadingComponent(options), + error: getErrorComponent(options), + }; + + const container: FunctionComponent> = (props: any) => { + const { error, view } = useView(controller, component, props); + if (view === undefined) { + return render.loading(props); + } + if (error !== undefined) { + return render.error({ ...props, error }); + } + return view; + }; + + container.displayName = component.displayName = options?.name ?? `${controller.name}View`; + + // ### Memoize + // By default run component through react memoization using stringify + // matching to determine changes to props. + + if (memoize !== false) { + return memo(container, memoize); + } + + return container; +} + +/* + |-------------------------------------------------------------------------------- + | Hooks + |-------------------------------------------------------------------------------- + */ + +function useView( + instance: InstanceType | undefined, + component: ReactComponent, + props: any, +) { + const [view, setView] = useState(); + + const error = useController(instance, component, props, setView); + + return { error, view }; +} + +function useController(controller: ControllerClass, component: any, props: any, setView: any) { + const [instance, setInstance] = useState | undefined>(undefined); + const error = useProps(instance, props); + + useEffect(() => { + const instance = controller.make(component, setView); + setInstance(instance); + return () => { + instance.$destroy(); + }; + }, []); + + return error; +} + +function useProps(controller: InstanceType | undefined, props: any) { + const [error, setError] = useState(); + + useEffect(() => { + if (controller === undefined) { + return; + } + let isMounted = true; + controller.$resolve(props).catch((error: Error) => { + if (isMounted === true) { + setError(error); + } + }); + return () => { + isMounted = false; + }; + }, [controller, props]); + + return error; +} + +/* + |-------------------------------------------------------------------------------- + | Components + |-------------------------------------------------------------------------------- + */ + +export function setLoadingComponent(component: React.FC) { + options.loading = component; +} + +function getLoadingComponent({ loading }: Partial> = {}) { + const component = loading ?? options.loading; + if (component === undefined) { + return () => null; + } + return (props: TProps) => createElement(component, props); +} + +export function setErrorComponent(component: React.FC) { + options.error = component; +} + +function getErrorComponent({ error }: Partial> = {}) { + const component = error ?? options.loading; + if (component === undefined) { + return () => null; + } + return (props: TProps) => createElement(component, props); +} + +/* + |-------------------------------------------------------------------------------- + | Memoize + |-------------------------------------------------------------------------------- + */ + +export function setMemoizeHandler(value: boolean | Memoize) { + if (typeof value === "function") { + options.memoize = value; + } else if (value === false) { + options.memoize = false; + } else { + options.memoize = defaultMemoizeHandler; + } +} + +function getMemoizeHandler(memoize?: ViewOptions["memoize"]): false | Memoize | undefined { + if (typeof memoize === "function") { + return memoize; + } + if (memoize !== false) { + return options.memoize; + } + return false; +} + +/* + |-------------------------------------------------------------------------------- + | Defaults + |-------------------------------------------------------------------------------- + */ + +function defaultMemoizeHandler(prev: any, next: any): boolean { + if (prev.children !== undefined && next.children !== undefined) { + if (prev.children.type.type.displayName !== next.children.type.type.displayName) { + return false; + } + } + return deepEqual(prev, next); +} + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +export type ViewOptions = { + name?: string; + loading: React.FC; + error: React.FC; + memoize: false | Memoize; +}; + +type Memoize = (prevProps: Readonly, nextProps: Readonly) => boolean; + +type Readonly = { + readonly [P in keyof T]: T[P]; +}; diff --git a/apps/react/src/main.tsx b/apps/react/src/main.tsx new file mode 100644 index 0000000..33bb9dd --- /dev/null +++ b/apps/react/src/main.tsx @@ -0,0 +1,26 @@ +import "./index.css"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createRouter, RouterProvider } from "@tanstack/react-router"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; + +import { routeTree } from "./routes.tsx"; + +const queryClient = new QueryClient(); + +const router = createRouter({ routeTree }); + +declare module "@tanstack/react-router" { + interface Register { + router: typeof router; + } +} + +createRoot(document.getElementById("root")!).render( + + + + + , +); diff --git a/apps/react/src/routes.tsx b/apps/react/src/routes.tsx new file mode 100644 index 0000000..a3356be --- /dev/null +++ b/apps/react/src/routes.tsx @@ -0,0 +1,9 @@ +import { createRootRoute } from "@tanstack/react-router"; + +import App from "./App.tsx"; + +const rootRoute = createRootRoute({ + component: App, +}); + +export const routeTree = rootRoute.addChildren([]); diff --git a/apps/react/src/services/api.ts b/apps/react/src/services/api.ts new file mode 100644 index 0000000..197b702 --- /dev/null +++ b/apps/react/src/services/api.ts @@ -0,0 +1,14 @@ +import { makeClient } from "@spec/relay"; + +import { HttpAdapter } from "../adapters/http.ts"; + +export const api = makeClient( + { + adapter: new HttpAdapter({ + url: window.location.origin, + }), + }, + { + auth: (await import("@spec/modules/auth/mod.ts")).routes, + }, +); diff --git a/apps/react/src/vite-env.d.ts b/apps/react/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/apps/react/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/react/tsconfig.app.json b/apps/react/tsconfig.app.json new file mode 100644 index 0000000..126d126 --- /dev/null +++ b/apps/react/tsconfig.app.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + // "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/apps/react/tsconfig.json b/apps/react/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/apps/react/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/apps/react/tsconfig.node.json b/apps/react/tsconfig.node.json new file mode 100644 index 0000000..99f7cb0 --- /dev/null +++ b/apps/react/tsconfig.node.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + // "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/apps/react/vite.config.ts b/apps/react/vite.config.ts new file mode 100644 index 0000000..6d54618 --- /dev/null +++ b/apps/react/vite.config.ts @@ -0,0 +1,13 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + "/api/v1": { + target: "http://localhost:8370", + }, + }, + }, +}); diff --git a/deno.json b/deno.json index 7b006b4..d87e2a5 100644 --- a/deno.json +++ b/deno.json @@ -1,24 +1,41 @@ { - "name": "@valkyr/relay", - "version": "0.4.0", - "exports": { - ".": "./mod.ts", - "./http": "./adapters/http.ts" - }, - "publish": { - "exclude": [ - ".github", - ".vscode", - ".gitignore", - "tests" - ] - }, + "unstable": ["fmt-component"], + "nodeModulesDir": "auto", + "workspace": [ + "api", + "apps/react", + "spec/modules", + "spec/relay", + "spec/shared" + ], "tasks": { - "check": "deno check ./mod.ts", - "lint": "npx eslint -c eslint.config.mjs .", - "test": "deno test --allow-all", - "test:publish": "deno publish --dry-run", - "ncu": "npx ncu -u -p npm" - }, - "nodeModulesDir": "auto" + "start:api": { + "command": "cd ./api && deno run start", + "description": "Start api server instance." + }, + "start:react": { + "command": "cd ./apps/react && deno run dev", + "description": "Start react application instance." + }, + "check": { + "command": "deno check ./mod.ts", + "description": "Runs a check on all the projects main entry files." + }, + "lint": { + "command": "npx eslint -c eslint.config.mjs .", + "description": "Runs eslint across the entire project." + }, + "fmt": { + "command": "npx prettier --write .", + "description": "Runs prettier formatting across the entire project." + }, + "test": { + "command": "deno test --allow-all", + "description": "Runs all defined tests across the entire project." + }, + "ncu": { + "command": "npx ncu -u -p npm", + "description": "Updates all the dependencies in package.json to their latest versions." + } + } } diff --git a/deno.lock b/deno.lock index a54e3c5..5d8a21f 100644 --- a/deno.lock +++ b/deno.lock @@ -1,17 +1,320 @@ { - "version": "4", + "version": "5", "specifiers": { - "npm:@jsr/std__assert@1.0.12": "1.0.12", - "npm:@jsr/std__testing@1.0.11": "1.0.11", - "npm:eslint-plugin-simple-import-sort@12.1.1": "12.1.1_eslint@9.24.0", - "npm:eslint@9.24.0": "9.24.0", - "npm:prettier@3.5.3": "3.5.3", - "npm:typescript-eslint@8.30.1": "8.30.1_eslint@9.24.0_typescript@5.8.3_@typescript-eslint+parser@8.30.1__eslint@9.24.0__typescript@5.8.3", - "npm:zod@next": "4.0.0-beta.20250417T043022" + "npm:@eslint/js@9": "9.33.0", + "npm:@jsr/felix__bcrypt@1": "1.0.5", + "npm:@jsr/std__assert@1": "1.0.13", + "npm:@jsr/std__cli@1": "1.0.21", + "npm:@jsr/std__dotenv@0.225": "0.225.5", + "npm:@jsr/std__fs@1": "1.0.19", + "npm:@jsr/std__path@1": "1.1.1", + "npm:@jsr/std__testing@1": "1.0.15", + "npm:@jsr/valkyr__auth@2": "2.0.2", + "npm:@jsr/valkyr__event-emitter@1": "1.0.1", + "npm:@jsr/valkyr__event-store@2.0.0-beta.5": "2.0.0-beta.5", + "npm:@jsr/valkyr__inverse@1": "1.0.1", + "npm:@tanstack/react-query@5": "5.84.2_react@19.1.1", + "npm:@tanstack/react-router@1": "1.131.5_react@19.1.1_react-dom@19.1.1__react@19.1.1", + "npm:@types/node@*": "22.15.15", + "npm:@types/react-dom@19": "19.1.7_@types+react@19.1.9", + "npm:@types/react@19": "19.1.9", + "npm:@valkyr/db@1": "1.0.1", + "npm:@vitejs/plugin-react@4": "4.7.0_vite@7.1.1__picomatch@4.0.3_@babel+core@7.28.0_@types+node@22.15.15", + "npm:cookie@1": "1.0.2", + "npm:eslint-plugin-react-hooks@5": "5.2.0_eslint@9.33.0", + "npm:eslint-plugin-react-refresh@0.4": "0.4.20_eslint@9.33.0", + "npm:eslint-plugin-simple-import-sort@12": "12.1.1_eslint@9.33.0", + "npm:eslint@9": "9.33.0", + "npm:fast-equals@5": "5.2.2", + "npm:globals@16": "16.3.0", + "npm:mongodb@6": "6.18.0", + "npm:path-to-regexp@8": "8.2.0", + "npm:prettier@3": "3.6.2", + "npm:react-dom@19": "19.1.1_react@19.1.1", + "npm:react@19": "19.1.1", + "npm:typescript-eslint@8": "8.39.0_eslint@9.33.0_typescript@5.9.2_@typescript-eslint+parser@8.39.0__eslint@9.33.0__typescript@5.9.2", + "npm:typescript@5": "5.9.2", + "npm:vite@7": "7.1.1_picomatch@4.0.3_@types+node@22.15.15", + "npm:vite@7.1.1": "7.1.1_picomatch@4.0.3_@types+node@22.15.15", + "npm:zod@4": "4.0.17" }, "npm": { - "@eslint-community/eslint-utils@4.6.1_eslint@9.24.0": { - "integrity": "sha512-KTsJMmobmbrFLe3LDh0PC2FXpcSYJt/MLjlkh/9LEnmKYLSYmT/0EW9JWANjeoemiuZrmogti0tW5Ch+qNUYDw==", + "@ampproject/remapping@2.3.0": { + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dependencies": [ + "@jridgewell/gen-mapping", + "@jridgewell/trace-mapping" + ] + }, + "@babel/code-frame@7.27.1": { + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dependencies": [ + "@babel/helper-validator-identifier", + "js-tokens", + "picocolors" + ] + }, + "@babel/compat-data@7.28.0": { + "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==" + }, + "@babel/core@7.28.0": { + "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", + "dependencies": [ + "@ampproject/remapping", + "@babel/code-frame", + "@babel/generator", + "@babel/helper-compilation-targets", + "@babel/helper-module-transforms", + "@babel/helpers", + "@babel/parser", + "@babel/template", + "@babel/traverse", + "@babel/types", + "convert-source-map", + "debug", + "gensync", + "json5", + "semver@6.3.1" + ] + }, + "@babel/generator@7.28.0": { + "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", + "dependencies": [ + "@babel/parser", + "@babel/types", + "@jridgewell/gen-mapping", + "@jridgewell/trace-mapping", + "jsesc" + ] + }, + "@babel/helper-compilation-targets@7.27.2": { + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dependencies": [ + "@babel/compat-data", + "@babel/helper-validator-option", + "browserslist", + "lru-cache", + "semver@6.3.1" + ] + }, + "@babel/helper-globals@7.28.0": { + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==" + }, + "@babel/helper-module-imports@7.27.1": { + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dependencies": [ + "@babel/traverse", + "@babel/types" + ] + }, + "@babel/helper-module-transforms@7.27.3_@babel+core@7.28.0": { + "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", + "dependencies": [ + "@babel/core", + "@babel/helper-module-imports", + "@babel/helper-validator-identifier", + "@babel/traverse" + ] + }, + "@babel/helper-plugin-utils@7.27.1": { + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==" + }, + "@babel/helper-string-parser@7.27.1": { + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==" + }, + "@babel/helper-validator-identifier@7.27.1": { + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==" + }, + "@babel/helper-validator-option@7.27.1": { + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==" + }, + "@babel/helpers@7.28.2": { + "integrity": "sha512-/V9771t+EgXz62aCcyofnQhGM8DQACbRhvzKFsXKC9QM+5MadF8ZmIm0crDMaz3+o0h0zXfJnd4EhbYbxsrcFw==", + "dependencies": [ + "@babel/template", + "@babel/types" + ] + }, + "@babel/parser@7.28.0": { + "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", + "dependencies": [ + "@babel/types" + ], + "bin": true + }, + "@babel/plugin-transform-react-jsx-self@7.27.1_@babel+core@7.28.0": { + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dependencies": [ + "@babel/core", + "@babel/helper-plugin-utils" + ] + }, + "@babel/plugin-transform-react-jsx-source@7.27.1_@babel+core@7.28.0": { + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dependencies": [ + "@babel/core", + "@babel/helper-plugin-utils" + ] + }, + "@babel/template@7.27.2": { + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dependencies": [ + "@babel/code-frame", + "@babel/parser", + "@babel/types" + ] + }, + "@babel/traverse@7.28.0": { + "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==", + "dependencies": [ + "@babel/code-frame", + "@babel/generator", + "@babel/helper-globals", + "@babel/parser", + "@babel/template", + "@babel/types", + "debug" + ] + }, + "@babel/types@7.28.2": { + "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", + "dependencies": [ + "@babel/helper-string-parser", + "@babel/helper-validator-identifier" + ] + }, + "@esbuild/aix-ppc64@0.25.8": { + "integrity": "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==", + "os": ["aix"], + "cpu": ["ppc64"] + }, + "@esbuild/android-arm64@0.25.8": { + "integrity": "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==", + "os": ["android"], + "cpu": ["arm64"] + }, + "@esbuild/android-arm@0.25.8": { + "integrity": "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==", + "os": ["android"], + "cpu": ["arm"] + }, + "@esbuild/android-x64@0.25.8": { + "integrity": "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==", + "os": ["android"], + "cpu": ["x64"] + }, + "@esbuild/darwin-arm64@0.25.8": { + "integrity": "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@esbuild/darwin-x64@0.25.8": { + "integrity": "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@esbuild/freebsd-arm64@0.25.8": { + "integrity": "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==", + "os": ["freebsd"], + "cpu": ["arm64"] + }, + "@esbuild/freebsd-x64@0.25.8": { + "integrity": "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==", + "os": ["freebsd"], + "cpu": ["x64"] + }, + "@esbuild/linux-arm64@0.25.8": { + "integrity": "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@esbuild/linux-arm@0.25.8": { + "integrity": "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@esbuild/linux-ia32@0.25.8": { + "integrity": "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==", + "os": ["linux"], + "cpu": ["ia32"] + }, + "@esbuild/linux-loong64@0.25.8": { + "integrity": "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==", + "os": ["linux"], + "cpu": ["loong64"] + }, + "@esbuild/linux-mips64el@0.25.8": { + "integrity": "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==", + "os": ["linux"], + "cpu": ["mips64el"] + }, + "@esbuild/linux-ppc64@0.25.8": { + "integrity": "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==", + "os": ["linux"], + "cpu": ["ppc64"] + }, + "@esbuild/linux-riscv64@0.25.8": { + "integrity": "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==", + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@esbuild/linux-s390x@0.25.8": { + "integrity": "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==", + "os": ["linux"], + "cpu": ["s390x"] + }, + "@esbuild/linux-x64@0.25.8": { + "integrity": "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@esbuild/netbsd-arm64@0.25.8": { + "integrity": "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==", + "os": ["netbsd"], + "cpu": ["arm64"] + }, + "@esbuild/netbsd-x64@0.25.8": { + "integrity": "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==", + "os": ["netbsd"], + "cpu": ["x64"] + }, + "@esbuild/openbsd-arm64@0.25.8": { + "integrity": "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==", + "os": ["openbsd"], + "cpu": ["arm64"] + }, + "@esbuild/openbsd-x64@0.25.8": { + "integrity": "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==", + "os": ["openbsd"], + "cpu": ["x64"] + }, + "@esbuild/openharmony-arm64@0.25.8": { + "integrity": "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==", + "os": ["openharmony"], + "cpu": ["arm64"] + }, + "@esbuild/sunos-x64@0.25.8": { + "integrity": "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==", + "os": ["sunos"], + "cpu": ["x64"] + }, + "@esbuild/win32-arm64@0.25.8": { + "integrity": "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "@esbuild/win32-ia32@0.25.8": { + "integrity": "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==", + "os": ["win32"], + "cpu": ["ia32"] + }, + "@esbuild/win32-x64@0.25.8": { + "integrity": "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@eslint-community/eslint-utils@4.7.0_eslint@9.33.0": { + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", "dependencies": [ "eslint", "eslint-visitor-keys@3.4.3" @@ -20,25 +323,19 @@ "@eslint-community/regexpp@4.12.1": { "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==" }, - "@eslint/config-array@0.20.0": { - "integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==", + "@eslint/config-array@0.21.0": { + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", "dependencies": [ "@eslint/object-schema", "debug", "minimatch@3.1.2" ] }, - "@eslint/config-helpers@0.2.1": { - "integrity": "sha512-RI17tsD2frtDu/3dmI7QRrD4bedNKPM08ziRYaC5AhkGrzIAJelm9kJU1TznK+apx6V+cqRz8tfpEeG3oIyjxw==" + "@eslint/config-helpers@0.3.1": { + "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==" }, - "@eslint/core@0.12.0": { - "integrity": "sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==", - "dependencies": [ - "@types/json-schema" - ] - }, - "@eslint/core@0.13.0": { - "integrity": "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==", + "@eslint/core@0.15.2": { + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", "dependencies": [ "@types/json-schema" ] @@ -49,24 +346,24 @@ "ajv", "debug", "espree", - "globals", - "ignore", + "globals@14.0.0", + "ignore@5.3.2", "import-fresh", "js-yaml", "minimatch@3.1.2", "strip-json-comments" ] }, - "@eslint/js@9.24.0": { - "integrity": "sha512-uIY/y3z0uvOGX8cp1C2fiC4+ZmBhp6yZWkojtHL1YEMnRt1Y63HB9TM17proGEmeG7HeUY+UP36F0aknKYTpYA==" + "@eslint/js@9.33.0": { + "integrity": "sha512-5K1/mKhWaMfreBGJTwval43JJmkip0RmM+3+IuqupeSKNC/Th2Kc7ucaq5ovTSra/OOKB9c58CGSz3QMVbWt0A==" }, "@eslint/object-schema@2.1.6": { "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==" }, - "@eslint/plugin-kit@0.2.8": { - "integrity": "sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==", + "@eslint/plugin-kit@0.3.5": { + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", "dependencies": [ - "@eslint/core@0.13.0", + "@eslint/core", "levn" ] }, @@ -86,35 +383,105 @@ "@humanwhocodes/retry@0.3.1": { "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==" }, - "@humanwhocodes/retry@0.4.2": { - "integrity": "sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==" + "@humanwhocodes/retry@0.4.3": { + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==" }, - "@jsr/std__assert@1.0.12": { - "integrity": "sha512-9pmgjJhuljZCmLlbvsRV6aLT5+YCmhX/yIjaWYav7R7Vup2DOLAgpUOs4JkzRbwn7fdKYrwHT8+DjqPr7Ti8mg==", + "@jridgewell/gen-mapping@0.3.12": { + "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", + "dependencies": [ + "@jridgewell/sourcemap-codec", + "@jridgewell/trace-mapping" + ] + }, + "@jridgewell/resolve-uri@3.1.2": { + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==" + }, + "@jridgewell/sourcemap-codec@1.5.4": { + "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==" + }, + "@jridgewell/trace-mapping@0.3.29": { + "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", + "dependencies": [ + "@jridgewell/resolve-uri", + "@jridgewell/sourcemap-codec" + ] + }, + "@jsr/denosaurs__plug@1.1.0": { + "integrity": "sha512-GNRMr8XcYWbv8C1B5OjDa5u8q3p2lz7YVWQLhH5HAy0pkpb0+Y3npSxzjM49v5ajTFIzUCwIKv1gQukPm9q7qw==", + "dependencies": [ + "@jsr/std__encoding", + "@jsr/std__fmt", + "@jsr/std__fs", + "@jsr/std__path" + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/denosaurs__plug/1.1.0.tgz" + }, + "@jsr/felix__bcrypt@1.0.5": { + "integrity": "sha512-XJAQ+NIs23r5YNUgFMtMbl6lhzn/Ms2x1fDO5qJdcVwHKcTFebc5ZH/EQlJss/YfVYdYC6Ng6QQQLzjhrLD/aw==", + "dependencies": [ + "@jsr/denosaurs__plug" + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/felix__bcrypt/1.0.5.tgz" + }, + "@jsr/std__assert@1.0.13": { + "integrity": "sha512-rZ44REoi2/p+gqu8OfkcNeaTOSiG1kD6v8gyA0YjkXsOkDsiGw9g8h7JuGC/OD7GgOVgTEY+9Cih49Y18rkrCQ==", "dependencies": [ "@jsr/std__internal" - ] + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/std__assert/1.0.13.tgz" }, - "@jsr/std__async@1.0.12": { - "integrity": "sha512-NUaSOcwMetVeVkIqet2Ammy2A5YxG8ViFxryBbTaC4h7l/cgAkU59U3zF58ek4Y8HZ0Nx5De7qBptPfp62kcgw==" + "@jsr/std__async@1.0.14": { + "integrity": "sha512-aIG8W3TOmW+lKdAJA5w56qASu9EiUmBXbhW6eAlSEUBid+KVESGqQygFFg+awt/c8K+qobVM6M/u3SbIy0NyUQ==", + "tarball": "https://npm.jsr.io/~/11/@jsr/std__async/1.0.14.tgz" }, - "@jsr/std__data-structures@1.0.6": { - "integrity": "sha512-Ejc8mHLuoYxXLu2zPquvqijdgQ19OV+1DdVDrLc/Cg+tiuGh4Dq2FSnLiPINh4lO1AJ3XcZcYPx38RxdsZcCOg==" + "@jsr/std__cli@1.0.21": { + "integrity": "sha512-sx/iCW12GUITEkiNmdj7LbM6q/oWq9JoHz24Q/VxPMlLSXKeS5y7teBEDbWSqxFGIevKfgYJYlsbcHWNumd7fw==", + "tarball": "https://npm.jsr.io/~/11/@jsr/std__cli/1.0.21.tgz" }, - "@jsr/std__fs@1.0.16": { - "integrity": "sha512-xnqp8XqEFN+ttkERg9GG+AxyipSd+rfCquLPviF5ZSwN6oCV1TM0ZNoKHXNk/EJAsz28YjF4sfgdJt8XwTV2UQ==", + "@jsr/std__data-structures@1.0.9": { + "integrity": "sha512-+mT4Nll6fx+CPNqrlC+huhIOYNSMS+KUdJ4B8NujiQrh/bq++ds5PXpEsfV5EPR+YuWcuDGG0P1DE+Rednd7Wg==", "dependencies": [ + "@jsr/std__assert" + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/std__data-structures/1.0.9.tgz" + }, + "@jsr/std__dotenv@0.225.5": { + "integrity": "sha512-qrBt3wfQgvXbjo+Up6lyzBGxk0IPhDqW9Jx7CJQUQpsxqhoqnBmD8gn0Mt8i+RHHI9uZFCO+FP122ClAC8yljg==", + "tarball": "https://npm.jsr.io/~/11/@jsr/std__dotenv/0.225.5.tgz" + }, + "@jsr/std__encoding@1.0.10": { + "integrity": "sha512-WK2njnDTyKefroRNk2Ooq7GStp6Y0ccAvr4To+Z/zecRAGe7+OSvH9DbiaHpAKwEi2KQbmpWMOYsdNt+TsdmSw==", + "tarball": "https://npm.jsr.io/~/11/@jsr/std__encoding/1.0.10.tgz" + }, + "@jsr/std__fmt@1.0.8": { + "integrity": "sha512-miZHzj9OgjuajrcMKzpqNVwFb9O71UHZzV/FHVq0E0Uwmv/1JqXgmXAoBNPrn+MP0fHT3mMgaZ6XvQO7dam67Q==", + "tarball": "https://npm.jsr.io/~/11/@jsr/std__fmt/1.0.8.tgz" + }, + "@jsr/std__fs@1.0.19": { + "integrity": "sha512-TEjyE8g+46jPlu7dJHLrwc8NMGl8zfG+JjWxyNQyDbxP0RtqZ4JmYZfR9vy4RWYWJQbLpw6Kbt2n+K/2zAO/JA==", + "dependencies": [ + "@jsr/std__internal", "@jsr/std__path" - ] + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/std__fs/1.0.19.tgz" }, - "@jsr/std__internal@1.0.6": { - "integrity": "sha512-1NLtCx9XAL44nt56gzmRSCgXjIthHVzK62fTkJdq8/XsP7eN9a21AZDpc0EGJ/cgvmmOB52UGh46OuKrrY7eVg==" + "@jsr/std__internal@1.0.10": { + "integrity": "sha512-fmD6yKep/sMnB2yPQU/REZG7Z4N9SZwcUBNnceo4QkXk67l3JEfxHoROQ/YHeVSOmq6x55Ra6nuMjz2ib3nj3g==", + "tarball": "https://npm.jsr.io/~/11/@jsr/std__internal/1.0.10.tgz" }, - "@jsr/std__path@1.0.8": { - "integrity": "sha512-eNBGlh/8ZVkMxtFH4bwIzlAeKoHYk5in4wrBZhi20zMdOiuX4QozP4+19mIXBT2lzHDjhuVLyECbhFeR304iDg==" + "@jsr/std__net@1.0.4": { + "integrity": "sha512-KJGU8ZpQ70sMW2Zk+wU3wFUkggS9lTLfRFBygnV9VaK8KI+1ggiqtB06rH4a14CNRGM9y46Mn/ZCbQUd4Q45Jg==", + "tarball": "https://npm.jsr.io/~/11/@jsr/std__net/1.0.4.tgz" }, - "@jsr/std__testing@1.0.11": { - "integrity": "sha512-pqQDYtIsaDf+x4NHQ+WiixRJ8DfhgFQRdlHWWssFAzIYwleR+VHLTNlgsgg+AH3mIIR+gTkBmKk21hTkM/WbMQ==", + "@jsr/std__path@1.1.1": { + "integrity": "sha512-+x5LgcNUSpMzOZIRmFSjqrMTCxHlgXjWzK8ZFr7lwgHfWZxoVXeis3MFQlkR5mN5uQ61Y1P30Li1PU0yx9uluA==", + "dependencies": [ + "@jsr/std__internal" + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/std__path/1.1.1.tgz" + }, + "@jsr/std__testing@1.0.15": { + "integrity": "sha512-NgQuXxTEG4ecbh2fzYbkJWJoBgPXwbv6bdsrAYSOeLpX2d+TROEzpErbWQXHi/yxZy/FNn9IF548ZDAqMZxi/g==", "dependencies": [ "@jsr/std__assert", "@jsr/std__async", @@ -122,6 +489,55 @@ "@jsr/std__fs", "@jsr/std__internal", "@jsr/std__path" + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/std__testing/1.0.15.tgz" + }, + "@jsr/valkyr__auth@2.0.2": { + "integrity": "sha512-wxSWL0BUTXeVamCcpSYoMFceUMl/IKa/52aFtbtvMaprZiS6e4JHHU/tsFR72RjHn8RBGFLRnS/ttBIZlQM/Yg==", + "dependencies": [ + "jose", + "zod@3.25.0-beta.20250519T094321" + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/valkyr__auth/2.0.2.tgz" + }, + "@jsr/valkyr__event-emitter@1.0.1": { + "integrity": "sha512-mre5tWJddz8LylSQWuLOw3zgIxd2JmhGRV46jKXNPCGzY2NKJwGGT9H7SBw36RV4dW7jnnH2U1aCJkh8IS/pzA==", + "dependencies": [ + "eventemitter3" + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/valkyr__event-emitter/1.0.1.tgz" + }, + "@jsr/valkyr__event-store@2.0.0-beta.5": { + "integrity": "sha512-+xScdSFcIXbQUSofgQJJUdwJWssRzu42oHm8acsmbIStmYa0docCFTPtUQlUrRewND4lmFXvMlidsTb4tS7jww==", + "dependencies": [ + "@jsr/valkyr__testcontainers", + "@valkyr/db", + "mongodb", + "nanoid@5.1.5", + "postgres", + "zod@4.0.17" + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/valkyr__event-store/2.0.0-beta.5.tgz" + }, + "@jsr/valkyr__inverse@1.0.1": { + "integrity": "sha512-uZpzPct9FGobgl6H+iR3VJlzZbTFVmJSrB4z5In8zHgIJCkmgYj0diU3soU6MuiKR7SFBfD4PGSuUpTTJHNMlg==", + "tarball": "https://npm.jsr.io/~/11/@jsr/valkyr__inverse/1.0.1.tgz" + }, + "@jsr/valkyr__testcontainers@2.0.2": { + "integrity": "sha512-YnmfraYFr3msoUGrIFeElm03nbQqXOaPu0QUT6JI3w6/mIYpVfzPxghkB7gn2RIc81QgrqjwKJE/AL3dltlR1w==", + "dependencies": [ + "@jsr/std__async", + "@jsr/std__fs", + "@jsr/std__net", + "mongodb", + "postgres" + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/valkyr__testcontainers/2.0.2.tgz" + }, + "@mongodb-js/saslprep@1.3.0": { + "integrity": "sha512-zlayKCsIjYb7/IdfqxorK5+xUMyi4vOKcFy10wKJYc63NSdKI8mNME+uJqfatkPmOSMMUiojrL58IePKBm3gvQ==", + "dependencies": [ + "sparse-bitfield" ] }, "@nodelib/fs.scandir@2.1.5": { @@ -141,14 +557,223 @@ "fastq" ] }, - "@types/estree@1.0.7": { - "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==" + "@rolldown/pluginutils@1.0.0-beta.27": { + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==" + }, + "@rollup/rollup-android-arm-eabi@4.46.2": { + "integrity": "sha512-Zj3Hl6sN34xJtMv7Anwb5Gu01yujyE/cLBDB2gnHTAHaWS1Z38L7kuSG+oAh0giZMqG060f/YBStXtMH6FvPMA==", + "os": ["android"], + "cpu": ["arm"] + }, + "@rollup/rollup-android-arm64@4.46.2": { + "integrity": "sha512-nTeCWY83kN64oQ5MGz3CgtPx8NSOhC5lWtsjTs+8JAJNLcP3QbLCtDDgUKQc/Ro/frpMq4SHUaHN6AMltcEoLQ==", + "os": ["android"], + "cpu": ["arm64"] + }, + "@rollup/rollup-darwin-arm64@4.46.2": { + "integrity": "sha512-HV7bW2Fb/F5KPdM/9bApunQh68YVDU8sO8BvcW9OngQVN3HHHkw99wFupuUJfGR9pYLLAjcAOA6iO+evsbBaPQ==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@rollup/rollup-darwin-x64@4.46.2": { + "integrity": "sha512-SSj8TlYV5nJixSsm/y3QXfhspSiLYP11zpfwp6G/YDXctf3Xkdnk4woJIF5VQe0of2OjzTt8EsxnJDCdHd2xMA==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@rollup/rollup-freebsd-arm64@4.46.2": { + "integrity": "sha512-ZyrsG4TIT9xnOlLsSSi9w/X29tCbK1yegE49RYm3tu3wF1L/B6LVMqnEWyDB26d9Ecx9zrmXCiPmIabVuLmNSg==", + "os": ["freebsd"], + "cpu": ["arm64"] + }, + "@rollup/rollup-freebsd-x64@4.46.2": { + "integrity": "sha512-pCgHFoOECwVCJ5GFq8+gR8SBKnMO+xe5UEqbemxBpCKYQddRQMgomv1104RnLSg7nNvgKy05sLsY51+OVRyiVw==", + "os": ["freebsd"], + "cpu": ["x64"] + }, + "@rollup/rollup-linux-arm-gnueabihf@4.46.2": { + "integrity": "sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@rollup/rollup-linux-arm-musleabihf@4.46.2": { + "integrity": "sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@rollup/rollup-linux-arm64-gnu@4.46.2": { + "integrity": "sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@rollup/rollup-linux-arm64-musl@4.46.2": { + "integrity": "sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@rollup/rollup-linux-loongarch64-gnu@4.46.2": { + "integrity": "sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA==", + "os": ["linux"], + "cpu": ["loong64"] + }, + "@rollup/rollup-linux-ppc64-gnu@4.46.2": { + "integrity": "sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw==", + "os": ["linux"], + "cpu": ["ppc64"] + }, + "@rollup/rollup-linux-riscv64-gnu@4.46.2": { + "integrity": "sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ==", + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@rollup/rollup-linux-riscv64-musl@4.46.2": { + "integrity": "sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw==", + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@rollup/rollup-linux-s390x-gnu@4.46.2": { + "integrity": "sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA==", + "os": ["linux"], + "cpu": ["s390x"] + }, + "@rollup/rollup-linux-x64-gnu@4.46.2": { + "integrity": "sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@rollup/rollup-linux-x64-musl@4.46.2": { + "integrity": "sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@rollup/rollup-win32-arm64-msvc@4.46.2": { + "integrity": "sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "@rollup/rollup-win32-ia32-msvc@4.46.2": { + "integrity": "sha512-gBgaUDESVzMgWZhcyjfs9QFK16D8K6QZpwAaVNJxYDLHWayOta4ZMjGm/vsAEy3hvlS2GosVFlBlP9/Wb85DqQ==", + "os": ["win32"], + "cpu": ["ia32"] + }, + "@rollup/rollup-win32-x64-msvc@4.46.2": { + "integrity": "sha512-CvUo2ixeIQGtF6WvuB87XWqPQkoFAFqW+HUo/WzHwuHDvIwZCtjdWXoYCcr06iKGydiqTclC4jU/TNObC/xKZg==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@tanstack/history@1.131.2": { + "integrity": "sha512-cs1WKawpXIe+vSTeiZUuSBy8JFjEuDgdMKZFRLKwQysKo8y2q6Q1HvS74Yw+m5IhOW1nTZooa6rlgdfXcgFAaw==" + }, + "@tanstack/query-core@5.83.1": { + "integrity": "sha512-OG69LQgT7jSp+5pPuCfzltq/+7l2xoweggjme9vlbCPa/d7D7zaqv5vN/S82SzSYZ4EDLTxNO1PWrv49RAS64Q==" + }, + "@tanstack/react-query@5.84.2_react@19.1.1": { + "integrity": "sha512-cZadySzROlD2+o8zIfbD978p0IphuQzRWiiH3I2ugnTmz4jbjc0+TdibpwqxlzynEen8OulgAg+rzdNF37s7XQ==", + "dependencies": [ + "@tanstack/query-core", + "react" + ] + }, + "@tanstack/react-router@1.131.5_react@19.1.1_react-dom@19.1.1__react@19.1.1": { + "integrity": "sha512-71suJGuCmrHN9PLLRUDB3CGnW5RNcEEfgfX616TOpKamHs977H8P4/75BgWPRWcLHCga/1kkA6c7bddCwZ35Fw==", + "dependencies": [ + "@tanstack/history", + "@tanstack/react-store", + "@tanstack/router-core", + "isbot", + "react", + "react-dom", + "tiny-invariant", + "tiny-warning" + ] + }, + "@tanstack/react-store@0.7.3_react@19.1.1_react-dom@19.1.1__react@19.1.1": { + "integrity": "sha512-3Dnqtbw9P2P0gw8uUM8WP2fFfg8XMDSZCTsywRPZe/XqqYW8PGkXKZTvP0AHkE4mpqP9Y43GpOg9vwO44azu6Q==", + "dependencies": [ + "@tanstack/store", + "react", + "react-dom", + "use-sync-external-store" + ] + }, + "@tanstack/router-core@1.131.5_seroval@1.3.2": { + "integrity": "sha512-XVfZdnKNQbWfkQ6G7I9ml2wHp98Wy7wgTboP5SfrJHfOE+kPeHeZRJqF/pp5oqLZ2feBJqsDDKNWo9323L7sWQ==", + "dependencies": [ + "@tanstack/history", + "@tanstack/store", + "cookie-es", + "seroval", + "seroval-plugins", + "tiny-invariant", + "tiny-warning" + ] + }, + "@tanstack/store@0.7.2": { + "integrity": "sha512-RP80Z30BYiPX2Pyo0Nyw4s1SJFH2jyM6f9i3HfX4pA+gm5jsnYryscdq2aIQLnL4TaGuQMO+zXmN9nh1Qck+Pg==" + }, + "@types/babel__core@7.20.5": { + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dependencies": [ + "@babel/parser", + "@babel/types", + "@types/babel__generator", + "@types/babel__template", + "@types/babel__traverse" + ] + }, + "@types/babel__generator@7.27.0": { + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dependencies": [ + "@babel/types" + ] + }, + "@types/babel__template@7.4.4": { + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dependencies": [ + "@babel/parser", + "@babel/types" + ] + }, + "@types/babel__traverse@7.28.0": { + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dependencies": [ + "@babel/types" + ] + }, + "@types/estree@1.0.8": { + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==" }, "@types/json-schema@7.0.15": { "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" }, - "@typescript-eslint/eslint-plugin@8.30.1_@typescript-eslint+parser@8.30.1__eslint@9.24.0__typescript@5.8.3_eslint@9.24.0_typescript@5.8.3": { - "integrity": "sha512-v+VWphxMjn+1t48/jO4t950D6KR8JaJuNXzi33Ve6P8sEmPr5k6CEXjdGwT6+LodVnEa91EQCtwjWNUCPweo+Q==", + "@types/node@22.15.15": { + "integrity": "sha512-R5muMcZob3/Jjchn5LcO8jdKwSCbzqmPB6ruBxMcf9kbxtniZHP327s6C37iOfuw8mbKK3cAQa7sEl7afLrQ8A==", + "dependencies": [ + "undici-types" + ] + }, + "@types/react-dom@19.1.7_@types+react@19.1.9": { + "integrity": "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==", + "dependencies": [ + "@types/react" + ] + }, + "@types/react@19.1.9": { + "integrity": "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==", + "dependencies": [ + "csstype" + ] + }, + "@types/webidl-conversions@7.0.3": { + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==" + }, + "@types/whatwg-url@11.0.5": { + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "dependencies": [ + "@types/webidl-conversions" + ] + }, + "@typescript-eslint/eslint-plugin@8.39.0_@typescript-eslint+parser@8.39.0__eslint@9.33.0__typescript@5.9.2_eslint@9.33.0_typescript@5.9.2": { + "integrity": "sha512-bhEz6OZeUR+O/6yx9Jk6ohX6H9JSFTaiY0v9/PuKT3oGK0rn0jNplLmyFUGV+a9gfYnVNwGDwS/UkLIuXNb2Rw==", "dependencies": [ "@eslint-community/regexpp", "@typescript-eslint/parser", @@ -158,14 +783,14 @@ "@typescript-eslint/visitor-keys", "eslint", "graphemer", - "ignore", + "ignore@7.0.5", "natural-compare", "ts-api-utils", "typescript" ] }, - "@typescript-eslint/parser@8.30.1_eslint@9.24.0_typescript@5.8.3": { - "integrity": "sha512-H+vqmWwT5xoNrXqWs/fesmssOW70gxFlgcMlYcBaWNPIEWDgLa4W9nkSPmhuOgLnXq9QYgkZ31fhDyLhleCsAg==", + "@typescript-eslint/parser@8.39.0_eslint@9.33.0_typescript@5.9.2": { + "integrity": "sha512-g3WpVQHngx0aLXn6kfIYCZxM6rRJlWzEkVpqEFLT3SgEDsp9cpCbxxgwnE504q4H+ruSDh/VGS6nqZIDynP+vg==", "dependencies": [ "@typescript-eslint/scope-manager", "@typescript-eslint/types", @@ -176,16 +801,32 @@ "typescript" ] }, - "@typescript-eslint/scope-manager@8.30.1": { - "integrity": "sha512-+C0B6ChFXZkuaNDl73FJxRYT0G7ufVPOSQkqkpM/U198wUwUFOtgo1k/QzFh1KjpBitaK7R1tgjVz6o9HmsRPg==", + "@typescript-eslint/project-service@8.39.0_typescript@5.9.2": { + "integrity": "sha512-CTzJqaSq30V/Z2Og9jogzZt8lJRR5TKlAdXmWgdu4hgcC9Kww5flQ+xFvMxIBWVNdxJO7OifgdOK4PokMIWPew==", + "dependencies": [ + "@typescript-eslint/tsconfig-utils", + "@typescript-eslint/types", + "debug", + "typescript" + ] + }, + "@typescript-eslint/scope-manager@8.39.0": { + "integrity": "sha512-8QOzff9UKxOh6npZQ/4FQu4mjdOCGSdO3p44ww0hk8Vu+IGbg0tB/H1LcTARRDzGCC8pDGbh2rissBuuoPgH8A==", "dependencies": [ "@typescript-eslint/types", "@typescript-eslint/visitor-keys" ] }, - "@typescript-eslint/type-utils@8.30.1_eslint@9.24.0_typescript@5.8.3": { - "integrity": "sha512-64uBF76bfQiJyHgZISC7vcNz3adqQKIccVoKubyQcOnNcdJBvYOILV1v22Qhsw3tw3VQu5ll8ND6hycgAR5fEA==", + "@typescript-eslint/tsconfig-utils@8.39.0_typescript@5.9.2": { + "integrity": "sha512-Fd3/QjmFV2sKmvv3Mrj8r6N8CryYiCS8Wdb/6/rgOXAWGcFuc+VkQuG28uk/4kVNVZBQuuDHEDUpo/pQ32zsIQ==", "dependencies": [ + "typescript" + ] + }, + "@typescript-eslint/type-utils@8.39.0_eslint@9.33.0_typescript@5.9.2": { + "integrity": "sha512-6B3z0c1DXVT2vYA9+z9axjtc09rqKUPRmijD5m9iv8iQpHBRYRMBcgxSiKTZKm6FwWw1/cI4v6em35OsKCiN5Q==", + "dependencies": [ + "@typescript-eslint/types", "@typescript-eslint/typescript-estree", "@typescript-eslint/utils", "debug", @@ -194,25 +835,27 @@ "typescript" ] }, - "@typescript-eslint/types@8.30.1": { - "integrity": "sha512-81KawPfkuulyWo5QdyG/LOKbspyyiW+p4vpn4bYO7DM/hZImlVnFwrpCTnmNMOt8CvLRr5ojI9nU1Ekpw4RcEw==" + "@typescript-eslint/types@8.39.0": { + "integrity": "sha512-ArDdaOllnCj3yn/lzKn9s0pBQYmmyme/v1HbGIGB0GB/knFI3fWMHloC+oYTJW46tVbYnGKTMDK4ah1sC2v0Kg==" }, - "@typescript-eslint/typescript-estree@8.30.1_typescript@5.8.3": { - "integrity": "sha512-kQQnxymiUy9tTb1F2uep9W6aBiYODgq5EMSk6Nxh4Z+BDUoYUSa029ISs5zTzKBFnexQEh71KqwjKnRz58lusQ==", + "@typescript-eslint/typescript-estree@8.39.0_typescript@5.9.2": { + "integrity": "sha512-ndWdiflRMvfIgQRpckQQLiB5qAKQ7w++V4LlCHwp62eym1HLB/kw7D9f2e8ytONls/jt89TEasgvb+VwnRprsw==", "dependencies": [ + "@typescript-eslint/project-service", + "@typescript-eslint/tsconfig-utils", "@typescript-eslint/types", "@typescript-eslint/visitor-keys", "debug", "fast-glob", "is-glob", "minimatch@9.0.5", - "semver", + "semver@7.7.2", "ts-api-utils", "typescript" ] }, - "@typescript-eslint/utils@8.30.1_eslint@9.24.0_typescript@5.8.3": { - "integrity": "sha512-T/8q4R9En2tcEsWPQgB5BQ0XJVOtfARcUvOa8yJP3fh9M/mXraLxZrkCfGb6ChrO/V3W+Xbd04RacUEqk1CFEQ==", + "@typescript-eslint/utils@8.39.0_eslint@9.33.0_typescript@5.9.2": { + "integrity": "sha512-4GVSvNA0Vx1Ktwvf4sFE+exxJ3QGUorQG1/A5mRfRNZtkBT2xrA/BCO2H0eALx/PnvCS6/vmYwRdDA41EoffkQ==", "dependencies": [ "@eslint-community/eslint-utils", "@typescript-eslint/scope-manager", @@ -222,24 +865,58 @@ "typescript" ] }, - "@typescript-eslint/visitor-keys@8.30.1": { - "integrity": "sha512-aEhgas7aJ6vZnNFC7K4/vMGDGyOiqWcYZPpIWrTKuTAlsvDNKy2GFDqh9smL+iq069ZvR0YzEeq0B8NJlLzjFA==", + "@typescript-eslint/visitor-keys@8.39.0": { + "integrity": "sha512-ldgiJ+VAhQCfIjeOgu8Kj5nSxds0ktPOSO9p4+0VDH2R2pLvQraaM5Oen2d7NxzMCm+Sn/vJT+mv2H5u6b/3fA==", "dependencies": [ "@typescript-eslint/types", - "eslint-visitor-keys@4.2.0" + "eslint-visitor-keys@4.2.1" ] }, - "@zod/core@0.6.2": { - "integrity": "sha512-KdH7bT0BRG1CvJ1LWH8oyNnkvLpjVZ5qVGpRu7Vq8WsFTKRDWfdr3rFfBYh8atZJSWDgD0ibhOyff1AyRvG1DA==" + "@valkyr/db@1.0.1": { + "integrity": "sha512-zOvf0jbTSOtjzAgWKeD6S3/QQdtodPy+LkxfnhoggOzYhthkmZ1A8SauucFgkvIrzEp8e3IfNBHy0qQUHJRTog==", + "dependencies": [ + "dot-prop", + "fast-equals@5.0.1", + "idb", + "mingo", + "nanoid@5.0.2", + "rfdc", + "rxjs" + ] }, - "acorn-jsx@5.3.2_acorn@8.14.1": { + "@vitejs/plugin-react@4.7.0_vite@7.1.1__picomatch@4.0.3_@babel+core@7.28.0": { + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dependencies": [ + "@babel/core", + "@babel/plugin-transform-react-jsx-self", + "@babel/plugin-transform-react-jsx-source", + "@rolldown/pluginutils", + "@types/babel__core", + "react-refresh", + "vite@7.1.1_picomatch@4.0.3" + ] + }, + "@vitejs/plugin-react@4.7.0_vite@7.1.1__picomatch@4.0.3_@babel+core@7.28.0_@types+node@22.15.15": { + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dependencies": [ + "@babel/core", + "@babel/plugin-transform-react-jsx-self", + "@babel/plugin-transform-react-jsx-source", + "@rolldown/pluginutils", + "@types/babel__core", + "react-refresh", + "vite@7.1.1_picomatch@4.0.3_@types+node@22.15.15" + ] + }, + "acorn-jsx@5.3.2_acorn@8.15.0": { "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dependencies": [ "acorn" ] }, - "acorn@8.14.1": { - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==" + "acorn@8.15.0": { + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "bin": true }, "ajv@6.12.6": { "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", @@ -262,15 +939,15 @@ "balanced-match@1.0.2": { "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, - "brace-expansion@1.1.11": { - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "brace-expansion@1.1.12": { + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dependencies": [ "balanced-match", "concat-map" ] }, - "brace-expansion@2.0.1": { - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "brace-expansion@2.0.2": { + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dependencies": [ "balanced-match" ] @@ -281,9 +958,25 @@ "fill-range" ] }, + "browserslist@4.25.2": { + "integrity": "sha512-0si2SJK3ooGzIawRu61ZdPCO1IncZwS8IzuX73sPZsXW6EQ/w/DAfPyKI8l1ETTCr2MnvqWitmlCUxgdul45jA==", + "dependencies": [ + "caniuse-lite", + "electron-to-chromium", + "node-releases", + "update-browserslist-db" + ], + "bin": true + }, + "bson@6.10.4": { + "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==" + }, "callsites@3.1.0": { "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==" }, + "caniuse-lite@1.0.30001734": { + "integrity": "sha512-uhE1Ye5vgqju6OI71HTQqcBCZrvHugk0MjLak7Q+HfoBgoq5Bi+5YnwjP4fjDgrtYr/l8MVRBvzz9dPD4KyK0A==" + }, "chalk@4.1.2": { "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dependencies": [ @@ -303,6 +996,15 @@ "concat-map@0.0.1": { "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, + "convert-source-map@2.0.0": { + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" + }, + "cookie-es@1.2.2": { + "integrity": "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==" + }, + "cookie@1.0.2": { + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==" + }, "cross-spawn@7.0.6": { "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dependencies": [ @@ -311,8 +1013,11 @@ "which" ] }, - "debug@4.4.0": { - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "csstype@3.1.3": { + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + }, + "debug@4.4.1": { + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "dependencies": [ "ms" ] @@ -320,17 +1025,74 @@ "deep-is@0.1.4": { "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==" }, + "dot-prop@8.0.2": { + "integrity": "sha512-xaBe6ZT4DHPkg0k4Ytbvn5xoxgpG0jOS1dYxSOwAHPuNLjP3/OzN0gH55SrLqpx8cBfSaVt91lXYkApjb+nYdQ==", + "dependencies": [ + "type-fest" + ] + }, + "electron-to-chromium@1.5.199": { + "integrity": "sha512-3gl0S7zQd88kCAZRO/DnxtBKuhMO4h0EaQIN3YgZfV6+pW+5+bf2AdQeHNESCoaQqo/gjGVYEf2YM4O5HJQqpQ==" + }, + "esbuild@0.25.8": { + "integrity": "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==", + "optionalDependencies": [ + "@esbuild/aix-ppc64", + "@esbuild/android-arm", + "@esbuild/android-arm64", + "@esbuild/android-x64", + "@esbuild/darwin-arm64", + "@esbuild/darwin-x64", + "@esbuild/freebsd-arm64", + "@esbuild/freebsd-x64", + "@esbuild/linux-arm", + "@esbuild/linux-arm64", + "@esbuild/linux-ia32", + "@esbuild/linux-loong64", + "@esbuild/linux-mips64el", + "@esbuild/linux-ppc64", + "@esbuild/linux-riscv64", + "@esbuild/linux-s390x", + "@esbuild/linux-x64", + "@esbuild/netbsd-arm64", + "@esbuild/netbsd-x64", + "@esbuild/openbsd-arm64", + "@esbuild/openbsd-x64", + "@esbuild/openharmony-arm64", + "@esbuild/sunos-x64", + "@esbuild/win32-arm64", + "@esbuild/win32-ia32", + "@esbuild/win32-x64" + ], + "scripts": true, + "bin": true + }, + "escalade@3.2.0": { + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==" + }, "escape-string-regexp@4.0.0": { "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==" }, - "eslint-plugin-simple-import-sort@12.1.1_eslint@9.24.0": { + "eslint-plugin-react-hooks@5.2.0_eslint@9.33.0": { + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dependencies": [ + "eslint" + ] + }, + "eslint-plugin-react-refresh@0.4.20_eslint@9.33.0": { + "integrity": "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA==", + "dependencies": [ + "eslint" + ] + }, + "eslint-plugin-simple-import-sort@12.1.1_eslint@9.33.0": { "integrity": "sha512-6nuzu4xwQtE3332Uz0to+TxDQYRLTKRESSc2hefVT48Zc8JthmN23Gx9lnYhu0FtkRSL1oxny3kJ2aveVhmOVA==", "dependencies": [ "eslint" ] }, - "eslint-scope@8.3.0": { - "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", + "eslint-scope@8.4.0": { + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dependencies": [ "esrecurse", "estraverse" @@ -339,23 +1101,23 @@ "eslint-visitor-keys@3.4.3": { "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==" }, - "eslint-visitor-keys@4.2.0": { - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==" + "eslint-visitor-keys@4.2.1": { + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==" }, - "eslint@9.24.0": { - "integrity": "sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ==", + "eslint@9.33.0": { + "integrity": "sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA==", "dependencies": [ "@eslint-community/eslint-utils", "@eslint-community/regexpp", "@eslint/config-array", "@eslint/config-helpers", - "@eslint/core@0.12.0", + "@eslint/core", "@eslint/eslintrc", "@eslint/js", "@eslint/plugin-kit", "@humanfs/node", "@humanwhocodes/module-importer", - "@humanwhocodes/retry@0.4.2", + "@humanwhocodes/retry@0.4.3", "@types/estree", "@types/json-schema", "ajv", @@ -364,7 +1126,7 @@ "debug", "escape-string-regexp", "eslint-scope", - "eslint-visitor-keys@4.2.0", + "eslint-visitor-keys@4.2.1", "espree", "esquery", "esutils", @@ -372,7 +1134,7 @@ "file-entry-cache", "find-up", "glob-parent@6.0.2", - "ignore", + "ignore@5.3.2", "imurmurhash", "is-glob", "json-stable-stringify-without-jsonify", @@ -380,14 +1142,15 @@ "minimatch@3.1.2", "natural-compare", "optionator" - ] + ], + "bin": true }, - "espree@10.3.0_acorn@8.14.1": { - "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "espree@10.4.0_acorn@8.15.0": { + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dependencies": [ "acorn", "acorn-jsx", - "eslint-visitor-keys@4.2.0" + "eslint-visitor-keys@4.2.1" ] }, "esquery@1.6.0": { @@ -408,9 +1171,18 @@ "esutils@2.0.3": { "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==" }, + "eventemitter3@5.0.1": { + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==" + }, "fast-deep-equal@3.1.3": { "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, + "fast-equals@5.0.1": { + "integrity": "sha512-WF1Wi8PwwSY7/6Kx0vKXtw8RwuSGoM1bvDaJbu7MxDlR1vovZjIAKrnzyrThgAjm6JDTu0fVgWXDlMGspodfoQ==" + }, + "fast-equals@5.2.2": { + "integrity": "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==" + }, "fast-glob@3.3.3": { "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", "dependencies": [ @@ -433,6 +1205,15 @@ "reusify" ] }, + "fdir@6.4.6_picomatch@4.0.3": { + "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "dependencies": [ + "picomatch@4.0.3" + ], + "optionalPeers": [ + "picomatch@4.0.3" + ] + }, "file-entry-cache@8.0.0": { "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dependencies": [ @@ -462,6 +1243,14 @@ "flatted@3.3.3": { "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==" }, + "fsevents@2.3.3": { + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "os": ["darwin"], + "scripts": true + }, + "gensync@1.0.0-beta.2": { + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==" + }, "glob-parent@5.1.2": { "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dependencies": [ @@ -477,15 +1266,24 @@ "globals@14.0.0": { "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==" }, + "globals@16.3.0": { + "integrity": "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==" + }, "graphemer@1.4.0": { "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==" }, "has-flag@4.0.0": { "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" }, + "idb@7.1.1": { + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==" + }, "ignore@5.3.2": { "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==" }, + "ignore@7.0.5": { + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==" + }, "import-fresh@3.3.1": { "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dependencies": [ @@ -508,14 +1306,28 @@ "is-number@7.0.0": { "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" }, + "isbot@5.1.29": { + "integrity": "sha512-DelDWWoa3mBoyWTq3wjp+GIWx/yZdN7zLUE7NFhKjAiJ+uJVRkbLlwykdduCE4sPUUy8mlTYTmdhBUYu91F+sw==" + }, "isexe@2.0.0": { "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, + "jose@6.0.10": { + "integrity": "sha512-skIAxZqcMkOrSwjJvplIPYrlXGpxTPnro2/QWTDCxAdWQrSTV5/KqspMWmi5WAx5+ULswASJiZ0a+1B/Lxt9cw==" + }, + "js-tokens@4.0.0": { + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, "js-yaml@4.1.0": { "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "dependencies": [ "argparse" - ] + ], + "bin": true + }, + "jsesc@3.1.0": { + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "bin": true }, "json-buffer@3.0.1": { "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==" @@ -526,6 +1338,10 @@ "json-stable-stringify-without-jsonify@1.0.1": { "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==" }, + "json5@2.2.3": { + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "bin": true + }, "keyv@4.5.4": { "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dependencies": [ @@ -548,6 +1364,15 @@ "lodash.merge@4.6.2": { "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" }, + "lru-cache@5.1.1": { + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dependencies": [ + "yallist" + ] + }, + "memory-pager@1.5.0": { + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==" + }, "merge2@1.4.1": { "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==" }, @@ -555,27 +1380,60 @@ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dependencies": [ "braces", - "picomatch" + "picomatch@2.3.1" ] }, + "mingo@6.4.6": { + "integrity": "sha512-SMp06Eo5iEthCPpKXgEZ6DTZKxknpTqj49YN6iHpapj9DKltBCv0RFu+0mBBjMU0SiHR9pYkurkk74+VFGTqxw==" + }, "minimatch@3.1.2": { "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dependencies": [ - "brace-expansion@1.1.11" + "brace-expansion@1.1.12" ] }, "minimatch@9.0.5": { "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dependencies": [ - "brace-expansion@2.0.1" + "brace-expansion@2.0.2" + ] + }, + "mongodb-connection-string-url@3.0.2": { + "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==", + "dependencies": [ + "@types/whatwg-url", + "whatwg-url" + ] + }, + "mongodb@6.18.0": { + "integrity": "sha512-fO5ttN9VC8P0F5fqtQmclAkgXZxbIkYRTUi1j8JO6IYwvamkhtYDilJr35jOPELR49zqCJgXZWwCtW7B+TM8vQ==", + "dependencies": [ + "@mongodb-js/saslprep", + "bson", + "mongodb-connection-string-url" ] }, "ms@2.1.3": { "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "nanoid@3.3.11": { + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "bin": true + }, + "nanoid@5.0.2": { + "integrity": "sha512-2ustYUX1R2rL/Br5B/FMhi8d5/QzvkJ912rBYxskcpu0myTHzSZfTr1LAS2Sm7jxRUObRrSBFoyzwAhL49aVSg==", + "bin": true + }, + "nanoid@5.1.5": { + "integrity": "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==", + "bin": true + }, "natural-compare@1.4.0": { "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==" }, + "node-releases@2.0.19": { + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==" + }, "optionator@0.9.4": { "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dependencies": [ @@ -611,14 +1469,35 @@ "path-key@3.1.1": { "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==" }, + "path-to-regexp@8.2.0": { + "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==" + }, + "picocolors@1.1.1": { + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, "picomatch@2.3.1": { "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==" }, + "picomatch@4.0.3": { + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==" + }, + "postcss@8.5.6": { + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dependencies": [ + "nanoid@3.3.11", + "picocolors", + "source-map-js" + ] + }, + "postgres@3.4.7": { + "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==" + }, "prelude-ls@1.2.1": { "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==" }, - "prettier@3.5.3": { - "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==" + "prettier@3.6.2": { + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "bin": true }, "punycode@2.3.1": { "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==" @@ -626,20 +1505,89 @@ "queue-microtask@1.2.3": { "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==" }, + "react-dom@19.1.1_react@19.1.1": { + "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", + "dependencies": [ + "react", + "scheduler" + ] + }, + "react-refresh@0.17.0": { + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==" + }, + "react@19.1.1": { + "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==" + }, "resolve-from@4.0.0": { "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==" }, "reusify@1.1.0": { "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==" }, + "rfdc@1.3.0": { + "integrity": "sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==" + }, + "rollup@4.46.2": { + "integrity": "sha512-WMmLFI+Boh6xbop+OAGo9cQ3OgX9MIg7xOQjn+pTCwOkk+FNDAeAemXkJ3HzDJrVXleLOFVa1ipuc1AmEx1Dwg==", + "dependencies": [ + "@types/estree" + ], + "optionalDependencies": [ + "@rollup/rollup-android-arm-eabi", + "@rollup/rollup-android-arm64", + "@rollup/rollup-darwin-arm64", + "@rollup/rollup-darwin-x64", + "@rollup/rollup-freebsd-arm64", + "@rollup/rollup-freebsd-x64", + "@rollup/rollup-linux-arm-gnueabihf", + "@rollup/rollup-linux-arm-musleabihf", + "@rollup/rollup-linux-arm64-gnu", + "@rollup/rollup-linux-arm64-musl", + "@rollup/rollup-linux-loongarch64-gnu", + "@rollup/rollup-linux-ppc64-gnu", + "@rollup/rollup-linux-riscv64-gnu", + "@rollup/rollup-linux-riscv64-musl", + "@rollup/rollup-linux-s390x-gnu", + "@rollup/rollup-linux-x64-gnu", + "@rollup/rollup-linux-x64-musl", + "@rollup/rollup-win32-arm64-msvc", + "@rollup/rollup-win32-ia32-msvc", + "@rollup/rollup-win32-x64-msvc", + "fsevents" + ], + "bin": true + }, "run-parallel@1.2.0": { "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", "dependencies": [ "queue-microtask" ] }, - "semver@7.7.1": { - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==" + "rxjs@7.8.1": { + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dependencies": [ + "tslib" + ] + }, + "scheduler@0.26.0": { + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==" + }, + "semver@6.3.1": { + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "bin": true + }, + "semver@7.7.2": { + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "bin": true + }, + "seroval-plugins@1.3.2_seroval@1.3.2": { + "integrity": "sha512-0QvCV2lM3aj/U3YozDiVwx9zpH0q8A60CTWIv4Jszj/givcudPb48B+rkU5D51NJ0pTpweGMttHjboPa9/zoIQ==", + "dependencies": [ + "seroval" + ] + }, + "seroval@1.3.2": { + "integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==" }, "shebang-command@2.0.0": { "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", @@ -650,6 +1598,15 @@ "shebang-regex@3.0.0": { "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==" }, + "source-map-js@1.2.1": { + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" + }, + "sparse-bitfield@3.0.3": { + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "dependencies": [ + "memory-pager" + ] + }, "strip-json-comments@3.1.1": { "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==" }, @@ -659,36 +1616,75 @@ "has-flag" ] }, + "tiny-invariant@1.3.3": { + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" + }, + "tiny-warning@1.0.3": { + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" + }, + "tinyglobby@0.2.14_picomatch@4.0.3": { + "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "dependencies": [ + "fdir", + "picomatch@4.0.3" + ] + }, "to-regex-range@5.0.1": { "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dependencies": [ "is-number" ] }, - "ts-api-utils@2.1.0_typescript@5.8.3": { + "tr46@5.1.1": { + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dependencies": [ + "punycode" + ] + }, + "ts-api-utils@2.1.0_typescript@5.9.2": { "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", "dependencies": [ "typescript" ] }, + "tslib@2.8.1": { + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, "type-check@0.4.0": { "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dependencies": [ "prelude-ls" ] }, - "typescript-eslint@8.30.1_eslint@9.24.0_typescript@5.8.3_@typescript-eslint+parser@8.30.1__eslint@9.24.0__typescript@5.8.3": { - "integrity": "sha512-D7lC0kcehVH7Mb26MRQi64LMyRJsj3dToJxM1+JVTl53DQSV5/7oUGWQLcKl1C1KnoVHxMMU2FNQMffr7F3Row==", + "type-fest@3.13.1": { + "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==" + }, + "typescript-eslint@8.39.0_eslint@9.33.0_typescript@5.9.2_@typescript-eslint+parser@8.39.0__eslint@9.33.0__typescript@5.9.2": { + "integrity": "sha512-lH8FvtdtzcHJCkMOKnN73LIn6SLTpoojgJqDAxPm1jCR14eWSGPX8ul/gggBdPMk/d5+u9V854vTYQ8T5jF/1Q==", "dependencies": [ "@typescript-eslint/eslint-plugin", "@typescript-eslint/parser", + "@typescript-eslint/typescript-estree", "@typescript-eslint/utils", "eslint", "typescript" ] }, - "typescript@5.8.3": { - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==" + "typescript@5.9.2": { + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "bin": true + }, + "undici-types@6.21.0": { + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" + }, + "update-browserslist-db@1.1.3_browserslist@4.25.2": { + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dependencies": [ + "browserslist", + "escalade", + "picocolors" + ], + "bin": true }, "uri-js@4.4.1": { "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", @@ -696,36 +1692,154 @@ "punycode" ] }, + "use-sync-external-store@1.5.0_react@19.1.1": { + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "dependencies": [ + "react" + ] + }, + "vite@7.1.1_picomatch@4.0.3": { + "integrity": "sha512-yJ+Mp7OyV+4S+afWo+QyoL9jFWD11QFH0i5i7JypnfTcA1rmgxCbiA8WwAICDEtZ1Z1hzrVhN8R8rGTqkTY8ZQ==", + "dependencies": [ + "esbuild", + "fdir", + "picomatch@4.0.3", + "postcss", + "rollup", + "tinyglobby" + ], + "optionalDependencies": [ + "fsevents" + ], + "bin": true + }, + "vite@7.1.1_picomatch@4.0.3_@types+node@22.15.15": { + "integrity": "sha512-yJ+Mp7OyV+4S+afWo+QyoL9jFWD11QFH0i5i7JypnfTcA1rmgxCbiA8WwAICDEtZ1Z1hzrVhN8R8rGTqkTY8ZQ==", + "dependencies": [ + "@types/node", + "esbuild", + "fdir", + "picomatch@4.0.3", + "postcss", + "rollup", + "tinyglobby" + ], + "optionalDependencies": [ + "fsevents" + ], + "optionalPeers": [ + "@types/node" + ], + "bin": true + }, + "webidl-conversions@7.0.0": { + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==" + }, + "whatwg-url@14.2.0": { + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dependencies": [ + "tr46", + "webidl-conversions" + ] + }, "which@2.0.2": { "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dependencies": [ "isexe" - ] + ], + "bin": true }, "word-wrap@1.2.5": { "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==" }, + "yallist@3.1.1": { + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" + }, "yocto-queue@0.1.0": { "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" }, - "zod@4.0.0-beta.20250417T043022": { - "integrity": "sha512-zjfYudLXPgHvRdCWzy/iJqhB6suE8tBqnGubbFHSkMvcknI4iexEP53QCO13FoC/EIALseuZReVykCY8yd/skA==", - "dependencies": [ - "@zod/core" - ] + "zod@3.25.0-beta.20250519T094321": { + "integrity": "sha512-FvDMTcBUhM/CZjeT0HJQ8M6KbSGRPHqEx2yLWx9kDU3ufoTiq7tQAI8UyBJ/82CBp1mv6tKVWp00ll6zV/WxmA==" + }, + "zod@4.0.17": { + "integrity": "sha512-1PHjlYRevNxxdy2JZ8JcNAw7rX8V9P1AKkP+x/xZfxB0K5FYfuV+Ug6P/6NVSR2jHQ+FzDDoDHS04nYUsOIyLQ==" } }, "workspace": { "packageJson": { "dependencies": [ - "npm:@jsr/std__assert@1.0.12", - "npm:@jsr/std__testing@1.0.11", - "npm:eslint-plugin-simple-import-sort@12.1.1", - "npm:eslint@9.24.0", - "npm:prettier@3.5.3", - "npm:typescript-eslint@8.30.1", - "npm:zod@next" + "npm:@jsr/std__assert@1", + "npm:@jsr/std__testing@1", + "npm:eslint-plugin-simple-import-sort@12", + "npm:eslint@9", + "npm:prettier@3", + "npm:typescript-eslint@8" ] + }, + "members": { + "api": { + "packageJson": { + "dependencies": [ + "npm:@jsr/felix__bcrypt@1", + "npm:@jsr/std__cli@1", + "npm:@jsr/std__dotenv@0.225", + "npm:@jsr/std__fs@1", + "npm:@jsr/std__path@1", + "npm:@jsr/valkyr__auth@2", + "npm:@jsr/valkyr__event-store@2.0.0-beta.5", + "npm:@jsr/valkyr__inverse@1", + "npm:cookie@1", + "npm:mongodb@6", + "npm:zod@4" + ] + } + }, + "apps/react": { + "packageJson": { + "dependencies": [ + "npm:@eslint/js@9", + "npm:@jsr/valkyr__event-emitter@1", + "npm:@tanstack/react-query@5", + "npm:@tanstack/react-router@1", + "npm:@types/react-dom@19", + "npm:@types/react@19", + "npm:@valkyr/db@1", + "npm:@vitejs/plugin-react@4", + "npm:eslint-plugin-react-hooks@5", + "npm:eslint-plugin-react-refresh@0.4", + "npm:eslint@9", + "npm:fast-equals@5", + "npm:globals@16", + "npm:react-dom@19", + "npm:react@19", + "npm:typescript-eslint@8", + "npm:typescript@5", + "npm:vite@7" + ] + } + }, + "spec/modules": { + "packageJson": { + "dependencies": [ + "npm:zod@4" + ] + } + }, + "spec/relay": { + "packageJson": { + "dependencies": [ + "npm:path-to-regexp@8", + "npm:zod@4" + ] + } + }, + "spec/shared": { + "packageJson": { + "dependencies": [ + "npm:zod@4" + ] + } + } } } } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..58c43f8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +services: + mongo: + image: mongo:8 + restart: unless-stopped + ports: + - "27017:27017" + environment: + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: password + volumes: + - ./.volumes/mongo/local:/data/db + networks: + - localdev + +networks: + localdev: + driver: bridge diff --git a/eslint.config.mjs b/eslint.config.mjs index ff5356c..503f24c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -15,16 +15,22 @@ export default [ { files: ["**/*.ts"], rules: { - "@typescript-eslint/ban-ts-comment": ["error", { - "ts-expect-error": "allow-with-description", - minimumDescriptionLength: 10, - }], + "@typescript-eslint/ban-ts-comment": [ + "error", + { + "ts-expect-error": "allow-with-description", + minimumDescriptionLength: 10, + }, + ], "@typescript-eslint/ban-types": "off", "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-unused-vars": ["error", { - argsIgnorePattern: "^_", - varsIgnorePattern: "^_", - }], + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + }, + ], }, }, ]; diff --git a/libraries/action.ts b/libraries/action.ts deleted file mode 100644 index ef592cf..0000000 --- a/libraries/action.ts +++ /dev/null @@ -1,71 +0,0 @@ -import z, { ZodObject, ZodRawShape, ZodType } from "zod"; - -import type { RelayError } from "./errors.ts"; - -export class Action { - constructor(readonly state: TActionState) {} - - /** - * Input object required by the action to fulfill its function. - * - * @param input - Schema defining the input requirements of the action. - */ - input(input: TInput): Action & { input: TInput }> { - return new Action({ ...this.state, input }); - } - - /** - * Output object defining the result shape of the action. - * - * @param output - Schema defining the result shape. - */ - output(output: TOutput): Action & { output: ZodObject }> { - return new Action({ ...this.state, output: z.object(output) as any }); - } - - /** - * Add handler method to the action. - * - * @param handle - Handler method. - */ - handle>( - handle: THandleFn, - ): Action & { handle: THandleFn }> { - return new Action({ ...this.state, handle }); - } -} - -/* - |-------------------------------------------------------------------------------- - | Factory - |-------------------------------------------------------------------------------- - */ - -export const action: { - make(name: string): Action; -} = { - make(name: string) { - return new Action({ name }); - }, -}; - -/* - |-------------------------------------------------------------------------------- - | Types - |-------------------------------------------------------------------------------- - */ - -type ActionState = { - name: string; - input?: ZodType; - output?: ZodObject; - handle?: ActionHandlerFn; -}; - -export type ActionPrepareFn = ( - params: z.infer, -) => TAction["state"]["input"] extends ZodType ? z.infer : void; - -type ActionHandlerFn = TInput extends ZodType - ? (input: z.infer) => TOutput extends ZodObject ? Promise | RelayError> : Promise - : () => TOutput extends ZodObject ? Promise | RelayError> : Promise; diff --git a/libraries/adapter.ts b/libraries/adapter.ts deleted file mode 100644 index 1c606a8..0000000 --- a/libraries/adapter.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { RelayError } from "./errors.ts"; -import type { RouteMethod } from "./route.ts"; - -export type RelayAdapter = { - readonly url: string; - fetch(input: RelayRESTInput): Promise; - send(input: RelayProcedureInput): Promise; -}; - -export type RelayRESTInput = { - method: RouteMethod; - url: string; - query?: string; - body?: string; -}; - -export type RelayProcedureInput = { - method: string; - params: any; -}; - -export type RelayProcedureResponse = - | { - relay: "1.0"; - result: unknown; - id: string | number; - } - | { - relay: "1.0"; - error: RelayError; - id: string | number; - }; diff --git a/libraries/api.ts b/libraries/api.ts deleted file mode 100644 index c0975ac..0000000 --- a/libraries/api.ts +++ /dev/null @@ -1,434 +0,0 @@ -import z from "zod"; - -import { BadRequestError, InternalServerError, NotFoundError, RelayError } from "./errors.ts"; -import { Procedure } from "./procedure.ts"; -import { RelayRequest, request } from "./request.ts"; -import { Route, RouteMethod } from "./route.ts"; - -const SUPPORTED_MEHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"]; - -export class Api { - readonly #index = { - rest: new Map(), - rpc: new Map(), - }; - - /** - * Route maps funneling registered routes to the specific methods supported by - * the relay instance. - */ - readonly routes: Routes = { - POST: [], - GET: [], - PUT: [], - PATCH: [], - DELETE: [], - }; - - /** - * List of paths in the '${method} ${path}' format allowing us to quickly throw - * errors if a duplicate route path is being added. - */ - readonly #paths = new Set(); - - /** - * Instantiate a new Server instance. - * - * @param routes - Routes to register with the instance. - */ - constructor(relays: TRelays) { - const methods: (keyof typeof this.routes)[] = []; - for (const relay of relays) { - if (relay instanceof Procedure === true) { - this.#index.rpc.set(relay.method, relay); - } - if (relay instanceof Route === true) { - this.#validateRoutePath(relay); - this.routes[relay.method].push(relay); - methods.push(relay.method); - this.#index.rest.set(`${relay.method} ${relay.path}`, relay); - } - } - for (const method of methods) { - this.routes[method].sort(byStaticPriority); - } - } - - /** - * Takes a request candidate and parses its json body. - * - * @param candidate - Request candidate to parse. - */ - async parse(candidate: Request): Promise { - return request.parseAsync(await candidate.json()); - } - - /** - * Handle a incoming REST request. - * - * @param request - REST request to pass to a route handler. - */ - async rest(request: Request): Promise { - const url = new URL(request.url); - - const matched = this.#resolve(request.method, request.url); - if (matched === undefined) { - return toRestResponse( - new NotFoundError(`Invalid routing path provided for ${request.url}`, { - method: request.method, - url: request.url, - }), - ); - } - - const { route, params } = matched; - - // ### Context - // Context is passed to every route handler and provides a suite of functionality - // and request data. - - const context: any[] = []; - - // ### Params - // If the route has params we want to coerce the values to the expected types. - - if (route.state.params !== undefined) { - const result = await route.state.params.safeParseAsync(params); - if (result.success === false) { - return toRestResponse(new BadRequestError("Invalid request params", z.prettifyError(result.error))); - } - context.push(result.data); - } - - // ### Query - // If the route has a query schema we need to validate and parse the query. - - if (route.state.query !== undefined) { - const result = await route.state.query.safeParseAsync(toQuery(url.searchParams) ?? {}); - if (result.success === false) { - return toRestResponse(new BadRequestError("Invalid request query", z.prettifyError(result.error))); - } - context.push(result.data); - } - - // ### Body - // If the route has a body schema we need to validate and parse the body. - - if (route.state.body !== undefined) { - let body: Record = {}; - if (request.headers.get("content-type")?.includes("json")) { - body = await request.json(); - } - const result = await route.state.body.safeParseAsync(body); - if (result.success === false) { - return toRestResponse(new BadRequestError("Invalid request body", z.prettifyError(result.error))); - } - context.push(result.data); - } - - // ### Actions - // Run through all assigned actions for the route. - - const data: Record = {}; - - if (route.state.actions !== undefined) { - for (const entry of route.state.actions) { - let action = entry; - let input: any; - - if (Array.isArray(entry)) { - action = entry[0]; - input = entry[1](...context); - } - - const result = (await action.state.input?.safeParseAsync(input)) ?? { success: true, data: {} }; - if (result.success === false) { - return toRestResponse(new BadRequestError("Invalid action input", z.prettifyError(result.error))); - } - - if (action.state.handle === undefined) { - return toRestResponse(new InternalServerError(`Action '${action.state.name}' is missing handler.`)); - } - - const output = await action.state.handle(result.data); - if (output instanceof RelayError) { - return toRestResponse(output); - } - - for (const key in output) { - data[key] = output[key]; - } - } - context.push(data); - } - - // ### Handler - // Execute the route handler and apply the result. - - if (route.state.handle === undefined) { - return toRestResponse(new InternalServerError(`Path '${route.method} ${route.path}' is missing request handler.`)); - } - return toRestResponse(await route.state.handle(...context).catch((error) => error)); - } - - /** - * Handle a incoming RPC request. - * - * @param method - Method name being executed. - * @param params - Parameters provided with the method request. - * @param id - Request id used for response identification. - */ - async rpc({ method, params, id }: RelayRequest): Promise { - const procedure = this.#index.rpc.get(method); - if (procedure === undefined) { - return toResponse(new NotFoundError(`Method '' does not exist`), id); - } - - // ### Context - // Context is passed to every route handler and provides a suite of functionality - // and request data. - - const args: any[] = []; - - // ### Params - // If the route has a body schema we need to validate and parse the body. - - if (procedure.state.params !== undefined) { - if (params === undefined) { - return toResponse(new BadRequestError("Procedure expected 'params' but got 'undefined'."), id); - } - const result = await procedure.state.params.safeParseAsync(params); - if (result.success === false) { - return toResponse(new BadRequestError("Invalid request body", z.prettifyError(result.error)), id); - } - args.push(result.data); - } - - // ### Actions - // Run through all assigned actions for the route. - - const data: Record = {}; - - if (procedure.state.actions !== undefined) { - for (const entry of procedure.state.actions) { - let action = entry; - let input: any; - - if (Array.isArray(entry)) { - action = entry[0]; - input = entry[1](args[0]); - } - - const result = (await action.state.input?.safeParseAsync(input)) ?? { success: true, data: {} }; - if (result.success === false) { - return toResponse(new BadRequestError("Invalid action input", z.prettifyError(result.error)), id); - } - - if (action.state.handle === undefined) { - return toResponse(new InternalServerError(`Action '${action.state.name}' is missing handler.`), id); - } - - const output = await action.state.handle(result.data); - if (output instanceof RelayError) { - return toResponse(output, id); - } - - for (const key in output) { - data[key] = output[key]; - } - } - args.push(data); - } - - // ### Handler - // Execute the route handler and apply the result. - - if (procedure.state.handle === undefined) { - return toResponse(new InternalServerError(`Path '${procedure.method}' is missing request handler.`), id); - } - return toResponse(await procedure.state.handle(...args).catch((error) => error), id); - } - - /** - * Attempt to resolve a route based on the given method and pathname. - * - * @param method - HTTP method. - * @param url - HTTP request url. - */ - #resolve(method: string, url: string): ResolvedRoute | undefined { - this.#assertMethod(method); - for (const route of this.routes[method]) { - if (route.match(url) === true) { - return { route, params: route.getParsedParams(url) }; - } - } - } - - #validateRoutePath(route: Route): void { - const path = `${route.method} ${route.path}`; - if (this.#paths.has(path)) { - throw new Error(`Router > Path ${path} already exists`); - } - this.#paths.add(path); - } - - #assertMethod(method: string): asserts method is RouteMethod { - if (!SUPPORTED_MEHODS.includes(method)) { - throw new Error(`Router > Unsupported method '${method}'`); - } - } -} - -/* - |-------------------------------------------------------------------------------- - | Helpers - |-------------------------------------------------------------------------------- - */ - -/** - * Sorting method for routes to ensure that static properties takes precedence - * for when a route is matched against incoming requests. - * - * @param a - Route A - * @param b - Route B - */ -function byStaticPriority(a: Route, b: Route) { - const aSegments = a.path.split("/"); - const bSegments = b.path.split("/"); - - const maxLength = Math.max(aSegments.length, bSegments.length); - - for (let i = 0; i < maxLength; i++) { - const aSegment = aSegments[i] || ""; - const bSegment = bSegments[i] || ""; - - const isADynamic = aSegment.startsWith(":"); - const isBDynamic = bSegment.startsWith(":"); - - if (isADynamic !== isBDynamic) { - return isADynamic ? 1 : -1; - } - - if (isADynamic === false && aSegment !== bSegment) { - return aSegment.localeCompare(bSegment); - } - } - - return a.path.localeCompare(b.path); -} - -/** - * Resolve and return query object from the provided search parameters, or undefined - * if the search parameters does not have any entries. - * - * @param searchParams - Search params to create a query object from. - */ -function toQuery(searchParams: URLSearchParams): object | undefined { - if (searchParams.size === 0) { - return undefined; - } - const result: Record = {}; - for (const [key, value] of searchParams.entries()) { - result[key] = value; - } - return result; -} - -/** - * Takes a server side request result and returns a fetch Response. - * - * @param result - Result to send back as a Response. - */ -function toRestResponse(result: object | RelayError | Response | void): Response { - if (result instanceof Response) { - return result; - } - if (result instanceof RelayError) { - return new Response(result.message, { - status: result.status, - }); - } - if (result === undefined) { - return new Response(null, { status: 204 }); - } - return new Response(JSON.stringify(result), { - status: 200, - headers: { - "content-type": "application/json", - }, - }); -} - -/** - * Takes a server side request result and returns a fetch Response. - * - * @param result - Result to send back as a Response. - * @param id - Request id which can be used to identify the response. - */ -export function toResponse(result: object | RelayError | Response | void, id: string | number): Response { - if (result === undefined) { - return new Response( - JSON.stringify({ - relay: "1.0", - result: null, - id, - }), - { - status: 200, - headers: { - "content-type": "application/json", - }, - }, - ); - } - if (result instanceof Response) { - return result; - } - if (result instanceof RelayError) { - return new Response( - JSON.stringify({ - relay: "1.0", - error: result, - id, - }), - { - status: result.status, - headers: { - "content-type": "application/json", - }, - }, - ); - } - return new Response( - JSON.stringify({ - relay: "1.0", - result, - id, - }), - { - status: 200, - headers: { - "content-type": "application/json", - }, - }, - ); -} - -/* - |-------------------------------------------------------------------------------- - | Types - |-------------------------------------------------------------------------------- - */ - -type Routes = { - POST: Route[]; - GET: Route[]; - PUT: Route[]; - PATCH: Route[]; - DELETE: Route[]; -}; - -type ResolvedRoute = { - route: Route; - params: any; -}; diff --git a/libraries/client.ts b/libraries/client.ts deleted file mode 100644 index 3700eb7..0000000 --- a/libraries/client.ts +++ /dev/null @@ -1,104 +0,0 @@ -import z, { ZodType } from "zod"; - -import type { RelayAdapter, RelayRESTInput } from "./adapter.ts"; -import { Procedure } from "./procedure.ts"; -import type { Relays } from "./relay.ts"; -import { Route } from "./route.ts"; - -/** - * Make a new relay client instance. - * - * @param config - Client configuration. - * @param procedures - Map of procedures to make available to the client. - */ -export function makeRelayClient(config: RelayClientConfig, relays: TRelays): RelayClient { - return mapRelays(relays, config.adapter); -} - -/* - |-------------------------------------------------------------------------------- - | Helpers - |-------------------------------------------------------------------------------- - */ - -function mapRelays(relays: TRelays, adapter: RelayAdapter): RelayClient { - const client: any = {}; - for (const key in relays) { - const relay = relays[key]; - if (relay instanceof Procedure) { - client[key] = async (params: unknown) => { - const response = await adapter.send({ method: relay.method, params }); - if ("error" in response) { - throw new Error(response.error.message); - } - if ("result" in response && relay.state.result !== undefined) { - return relay.state.result.parseAsync(response.result); - } - return response.result; - }; - } else if (relay instanceof Route) { - client[key] = async (...args: any[]) => { - const input: RelayRESTInput = { method: relay.state.method, url: `${adapter.url}${relay.state.path}`, query: "" }; - - let index = 0; // argument incrementor - - if (relay.state.params !== undefined) { - const params = args[index++] as { [key: string]: string }; - for (const key in params) { - input.url = input.url.replace(`:${key}`, params[key]); - } - } - - if (relay.state.query !== undefined) { - const query = args[index++] as { [key: string]: string }; - const pieces: string[] = []; - for (const key in query) { - pieces.push(`${key}=${query[key]}`); - } - if (pieces.length > 0) { - input.query = `?${pieces.join("&")}`; - } - } - - if (relay.state.body !== undefined) { - input.body = JSON.stringify(args[index++]); - } - - // ### Fetch - - const data = await adapter.fetch(input); - if (relay.state.output !== undefined) { - return relay.state.output.parse(data); - } - return data; - }; - } else { - client[key] = mapRelays(relay, adapter); - } - } - return client; -} - -/* - |-------------------------------------------------------------------------------- - | Types - |-------------------------------------------------------------------------------- - */ - -export type RelayClient = { - [TKey in keyof TRelays]: TRelays[TKey] extends Procedure - ? TState["params"] extends ZodType - ? (params: z.infer) => Promise : void> - : () => Promise : void> - : TRelays[TKey] extends Route - ? (...args: TRelays[TKey]["args"]) => Promise> - : TRelays[TKey] extends Relays - ? RelayClient - : never; -}; - -type RelayRouteResponse = TRoute["state"]["output"] extends ZodType ? z.infer : void; - -export type RelayClientConfig = { - adapter: RelayAdapter; -}; diff --git a/libraries/procedure.ts b/libraries/procedure.ts deleted file mode 100644 index 268fd36..0000000 --- a/libraries/procedure.ts +++ /dev/null @@ -1,153 +0,0 @@ -import z, { ZodObject, ZodType } from "zod"; - -import { Action } from "./action.ts"; -import { RelayError } from "./errors.ts"; - -export class Procedure { - readonly type = "rpc" as const; - - declare readonly args: Args; - - constructor(readonly state: TState) {} - - get method(): TState["method"] { - return this.state.method; - } - - /** - * Params allows for custom casting of URL parameters. If a parameter does not - * have a corresponding zod schema the default param type is "string". - * - * @param params - URL params. - * - * @examples - * - * ```ts - * relay - * .method("user:create") - * .params( - * z.object({ - * bar: z.number() - * }) - * ) - * .handle(async ({ bar }) => { - * console.log(typeof bar); // => number - * }); - * ``` - */ - params(params: TParams): Procedure & { params: TParams }> { - return new Procedure({ ...this.state, params }); - } - - /** - * List of route level middleware action to execute before running the - * route handler. - * - * @param actions - Actions to execute on this route. - * - * @examples - * - * ```ts - * const hasFooBar = action - * .make("hasFooBar") - * .output(z.object({ foobar: z.number() })) - * .handle(async () => { - * return { - * foobar: 1, - * }; - * }); - * - * relay - * .method("foo") - * .actions([hasFooBar]) - * .handle(async ({ foobar }) => { - * console.log(typeof foobar); // => number - * }); - * ``` - */ - actions>( - actions: (TAction | [TAction, TActionFn])[], - ): Procedure & { actions: TAction[] }> { - return new Procedure({ ...this.state, actions: actions as TAction[] }); - } - - /** - * Shape of the response this route produces. This is used by the transform - * tools to ensure the client receives parsed data. - * - * @param response - Response shape of the route. - * - * @examples - * - * ```ts - * relay - * .method("foo") - * .result( - * z.object({ - * bar: z.number() - * }) - * ) - * .handle(async () => { - * return { bar: 1 }; - * }); - * ``` - */ - result(result: TResult): Procedure & { result: TResult }> { - return new Procedure({ ...this.state, result }); - } - - /** - * Server handler callback method. - * - * @param handle - Handle function to trigger when the route is executed. - */ - handle>(handle: THandleFn): Procedure & { handle: THandleFn }> { - return new Procedure({ ...this.state, handle }); - } -} - -/* - |-------------------------------------------------------------------------------- - | Factories - |-------------------------------------------------------------------------------- - */ - -export const rpc: { - method(method: TMethod): Procedure<{ type: "rpc"; method: TMethod }>; -} = { - method(method: TMethod): Procedure<{ type: "rpc"; method: TMethod }> { - return new Procedure({ type: "rpc", method }); - }, -}; - -/* - |-------------------------------------------------------------------------------- - | Types - |-------------------------------------------------------------------------------- - */ - -type State = { - method: string; - params?: ZodType; - actions?: Array; - result?: ZodType; - handle?: HandleFn; -}; - -type ActionFn = TState["params"] extends ZodType - ? (params: z.infer) => TAction["state"]["input"] extends ZodType ? z.infer : void - : () => TAction["state"]["input"] extends ZodType ? z.infer : void; - -type HandleFn = any[], TResponse = any> = ( - ...args: TArgs -) => TResponse extends ZodType ? Promise | Response | RelayError> : Promise; - -type Args = [ - ...(TState["params"] extends ZodType ? [z.infer] : []), - ...(TState["actions"] extends Array ? [UnionToIntersection>] : []), -]; - -type MergeAction> = - TActions[number] extends Action ? (TState["output"] extends ZodObject ? z.infer : object) : object; - -type UnionToIntersection = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never; diff --git a/libraries/relay.ts b/libraries/relay.ts deleted file mode 100644 index dcb5f24..0000000 --- a/libraries/relay.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { Api } from "./api.ts"; -import { makeRelayClient, RelayClient, RelayClientConfig } from "./client.ts"; -import { Procedure } from "./procedure.ts"; -import { Route, RouteMethod } from "./route.ts"; - -export class Relay< - TRelays extends Relays, - TRPCIndex = RPCIndex, - TPostIndex = RouteIndex<"POST", TRelays>, - TGetIndex = RouteIndex<"GET", TRelays>, - TPutIndex = RouteIndex<"PUT", TRelays>, - TPatchIndex = RouteIndex<"PATCH", TRelays>, - TDeleteIndex = RouteIndex<"DELETE", TRelays>, -> { - readonly #index = new Map(); - - declare readonly $inferClient: RelayClient; - - /** - * Instantiate a new Relay instance. - * - * @param procedures - Procedures to register with the instance. - */ - constructor(readonly relays: TRelays) { - indexRelays(relays, this.#index); - } - - /** - * Create a new relay api instance with the given relays. - * - * @param relays - List of relays to handle. - */ - api(relays: TRelays): Api { - return new Api(relays); - } - - /** - * Create a new relay client instance from the instance procedures. - * - * @param config - Client configuration. - */ - client(config: RelayClientConfig): this["$inferClient"] { - return makeRelayClient(config, this.relays) as any; - } - - /** - * Retrieve a registered procedure registered with the relay instance. - * - * @param method - Method name assigned to the procedure. - */ - method(method: TMethod): TRPCIndex[TMethod] { - return this.#index.get(method as string) as TRPCIndex[TMethod]; - } - - /** - * Retrieve a registered 'POST' route registered with the relay instance. - * - * @param path - Route path to retrieve. - */ - post(path: TPath): TPostIndex[TPath] { - return this.#index.get(`POST ${path as string}`) as TPostIndex[TPath]; - } - - /** - * Retrieve a registered 'GET' route registered with the relay instance. - * - * @param path - Route path to retrieve. - */ - get(path: TPath): TGetIndex[TPath] { - return this.#index.get(`GET ${path as string}`) as TGetIndex[TPath]; - } - - /** - * Retrieve a registered 'PUT' route registered with the relay instance. - * - * @param path - Route path to retrieve. - */ - put(path: TPath): TPutIndex[TPath] { - return this.#index.get(`PUT ${path as string}`) as TPutIndex[TPath]; - } - - /** - * Retrieve a registered 'PATCH' route registered with the relay instance. - * - * @param path - Route path to retrieve. - */ - patch(path: TPath): TPatchIndex[TPath] { - return this.#index.get(`PATCH ${path as string}`) as TPatchIndex[TPath]; - } - - /** - * Retrieve a registered 'DELETE' route registered with the relay instance. - * - * @param path - Route path to retrieve. - */ - delete(path: TPath): TDeleteIndex[TPath] { - return this.#index.get(`DELETE ${path as string}`) as TDeleteIndex[TPath]; - } -} - -/* - |-------------------------------------------------------------------------------- - | Helpers - |-------------------------------------------------------------------------------- - */ - -function indexRelays(relays: Relays, index: Map) { - for (const key in relays) { - const relay = relays[key]; - if (relay instanceof Procedure) { - const method = relay.method; - if (index.has(method)) { - throw new Error(`Relay > Procedure with method '${method}' already exists!`); - } - index.set(method, relay); - } else if (relay instanceof Route) { - const path = `${relay.method} ${relay.path}`; - if (index.has(path)) { - throw new Error(`Relay > Procedure with path 'path' already exists!`); - } - index.set(path, relay); - } else { - indexRelays(relay, index); - } - } -} - -/* - |-------------------------------------------------------------------------------- - | Types - |-------------------------------------------------------------------------------- - */ - -export type Relays = { - [key: string]: Relays | Procedure | Route; -}; - -type RPCIndex = MergeUnion>; - -type RouteIndex = MergeUnion>; - -type FlattenRPCRelays = { - [TKey in keyof TRelays]: TRelays[TKey] extends Procedure - ? Record - : TRelays[TKey] extends Relays - ? FlattenRPCRelays - : never; -}[keyof TRelays]; - -type FlattenRouteRelays = { - [TKey in keyof TRelays]: TRelays[TKey] extends { state: { method: TMethod; path: infer TPath extends string } } - ? Record - : TRelays[TKey] extends Relays - ? FlattenRouteRelays - : never; -}[keyof TRelays]; - -type MergeUnion = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? { [K in keyof I]: I[K] } : never; diff --git a/libraries/request.ts b/libraries/request.ts deleted file mode 100644 index 097d05f..0000000 --- a/libraries/request.ts +++ /dev/null @@ -1,37 +0,0 @@ -import z, { ZodLiteral, ZodNumber, ZodObject, ZodOptional, ZodString, ZodUnion, ZodUnknown } from "zod"; - -export const request: ZodObject<{ - relay: ZodLiteral<"1.0">; - method: ZodString; - params: ZodOptional; - id: ZodUnion<[ZodString, ZodNumber]>; -}> = z.object({ - relay: z.literal("1.0"), - method: z.string(), - params: z.unknown().optional(), - id: z.string().or(z.number()), -}); - -export type RelayRequest = { - /** - * String specifying the version of the relay protocol. MUST be exactly "1.0". - */ - relay: "1.0"; - - /** - * String containing the name of the method to be invoked. - */ - method: string; - - /** - * Structured value that holds the parameter values to be used during the - * invocation of the method. - */ - params?: unknown; - - /** - * An identifier established by the Client that MUST contain a String, or non - * fractional Number value. - */ - id: string | number; -}; diff --git a/libraries/route.ts b/libraries/route.ts deleted file mode 100644 index 4b58c5f..0000000 --- a/libraries/route.ts +++ /dev/null @@ -1,380 +0,0 @@ -import z, { ZodObject, ZodRawShape, ZodType } from "zod"; - -import { Action } from "./action.ts"; -import { RelayError } from "./errors.ts"; - -export class Route { - readonly type = "route" as const; - - #pattern?: URLPattern; - - declare readonly args: Args; - declare readonly context: RouteContext; - - constructor(readonly state: TState) {} - - /** - * HTTP Method - */ - get method(): RouteMethod { - return this.state.method; - } - - /** - * URL pattern of the route. - */ - get pattern(): URLPattern { - if (this.#pattern === undefined) { - this.#pattern = new URLPattern({ pathname: this.path }); - } - return this.#pattern; - } - - /** - * URL path - */ - get path(): string { - return this.state.path; - } - - /** - * Check if the provided URL matches the route pattern. - * - * @param url - HTTP request.url - */ - match(url: string): boolean { - return this.pattern.test(url); - } - - /** - * Extract parameters from the provided URL based on the route pattern. - * - * @param url - HTTP request.url - */ - getParsedParams(url: string): object { - const params = this.pattern.exec(url)?.pathname.groups; - if (params === undefined) { - return {}; - } - return params; - } - - /** - * Params allows for custom casting of URL parameters. If a parameter does not - * have a corresponding zod schema the default param type is "string". - * - * @param params - URL params. - * - * @examples - * - * ```ts - * route - * .post("/foo/:bar") - * .params({ - * bar: z.number({ coerce: true }) - * }) - * .handle(async ({ bar }) => { - * console.log(typeof bar); // => number - * }); - * ``` - */ - params(params: TParams): Route & { params: ZodObject }> { - return new Route({ ...this.state, params: z.object(params) as any }); - } - - /** - * Search allows for custom casting of URL query parameters. If a parameter does - * not have a corresponding zod schema the default param type is "string". - * - * @param query - URL query arguments. - * - * @examples - * - * ```ts - * route - * .post("/foo") - * .query({ - * bar: z.number({ coerce: true }) - * }) - * .handle(async ({ bar }) => { - * console.log(typeof bar); // => number - * }); - * ``` - */ - query(query: TQuery): Route & { query: ZodObject }> { - return new Route({ ...this.state, query: z.object(query) as any }); - } - - /** - * Shape of the body this route expects to receive. This is used by all - * mutator routes and has no effect when defined on "GET" methods. - * - * @param body - Body the route expects. - * - * @examples - * - * ```ts - * route - * .post("/foo") - * .body( - * z.object({ - * bar: z.number() - * }) - * ) - * .handle(async ({ bar }) => { - * console.log(typeof bar); // => number - * }); - * ``` - */ - body(body: TBody): Route & { body: TBody }> { - return new Route({ ...this.state, body }); - } - - /** - * List of route level middleware action to execute before running the - * route handler. - * - * @param actions - Actions to execute on this route. - * - * @examples - * - * ```ts - * const hasFooBar = action - * .make("hasFooBar") - * .response(z.object({ foobar: z.number() })) - * .handle(async () => { - * return { - * foobar: 1, - * }; - * }); - * - * route - * .post("/foo") - * .actions([hasFooBar]) - * .handle(async ({ foobar }) => { - * console.log(typeof foobar); // => number - * }); - * ``` - */ - actions>( - actions: (TAction | [TAction, TActionFn])[], - ): Route & { actions: TAction[] }> { - return new Route({ ...this.state, actions: actions as TAction[] }); - } - - /** - * Shape of the response this route produces. This is used by the transform - * tools to ensure the client receives parsed data. - * - * @param response - Response shape of the route. - * - * @examples - * - * ```ts - * route - * .post("/foo") - * .response( - * z.object({ - * bar: z.number() - * }) - * ) - * .handle(async () => { - * return { - * bar: 1 - * } - * }); - * ``` - */ - response(output: TResponse): Route & { output: TResponse }> { - return new Route({ ...this.state, output }); - } - - /** - * Server handler callback method. - * - * Handler receives the params, query, body, actions in order of definition. - * So if your route has params, and body the route handle method will - * receive (params, body) as arguments. - * - * @param handle - Handle function to trigger when the route is executed. - * - * @examples - * - * ```ts - * relay - * .post("/foo/:bar") - * .params({ bar: z.string() }) - * .body(z.tuple([z.string(), z.number()])) - * .handle(async ({ bar }, [ "string", number ]) => {}); - * ``` - * - * ```ts - * const prefix = actions - * .make("prefix") - * .input(z.string()) - * .output({ prefixed: z.string() }) - * .handle(async (value) => ({ - * prefixed: `prefix_${value}`; - * })) - * - * relay - * .post("/foo") - * .body(z.object({ bar: z.string() })) - * .actions([prefix, (body) => body.bar]) - * .handle(async ({ bar }, { prefixed }) => { - * console.log(prefixed); => prefixed_${bar} - * }); - * ``` - */ - handle>(handle: THandleFn): Route & { handle: THandleFn }> { - return new Route({ ...this.state, handle }); - } -} - -/* - |-------------------------------------------------------------------------------- - | Factories - |-------------------------------------------------------------------------------- - */ - -/** - * Route factories allowing for easy generation of relay compliant routes. - */ -export const route: { - post(path: TPath): Route<{ method: "POST"; path: TPath }>; - get(path: TPath): Route<{ method: "GET"; path: TPath }>; - put(path: TPath): Route<{ method: "PUT"; path: TPath }>; - patch(path: TPath): Route<{ method: "PATCH"; path: TPath }>; - delete(path: TPath): Route<{ method: "DELETE"; path: TPath }>; -} = { - /** - * Create a new "POST" route for the given path. - * - * @param path - Path to generate route for. - * - * @examples - * - * ```ts - * route - * .post("/foo") - * .body( - * z.object({ bar: z.string() }) - * ); - * ``` - */ - post(path: TPath) { - return new Route({ method: "POST", path }); - }, - - /** - * Create a new "GET" route for the given path. - * - * @param path - Path to generate route for. - * - * @examples - * - * ```ts - * route.get("/foo"); - * ``` - */ - get(path: TPath) { - return new Route({ method: "GET", path }); - }, - - /** - * Create a new "PUT" route for the given path. - * - * @param path - Path to generate route for. - * - * @examples - * - * ```ts - * route - * .put("/foo") - * .body( - * z.object({ bar: z.string() }) - * ); - * ``` - */ - put(path: TPath) { - return new Route({ method: "PUT", path }); - }, - - /** - * Create a new "PATCH" route for the given path. - * - * @param path - Path to generate route for. - * - * @examples - * - * ```ts - * route - * .patch("/foo") - * .body( - * z.object({ bar: z.string() }) - * ); - * ``` - */ - patch(path: TPath) { - return new Route({ method: "PATCH", path }); - }, - - /** - * Create a new "DELETE" route for the given path. - * - * @param path - Path to generate route for. - * - * @examples - * - * ```ts - * route.delete("/foo"); - * ``` - */ - delete(path: TPath) { - return new Route({ method: "DELETE", path }); - }, -}; - -/* - |-------------------------------------------------------------------------------- - | Types - |-------------------------------------------------------------------------------- - */ - -type State = { - method: RouteMethod; - path: string; - params?: ZodObject; - query?: ZodObject; - body?: ZodType; - actions?: Array; - output?: ZodType; - handle?: HandleFn; -}; - -export type RouteMethod = "POST" | "GET" | "PUT" | "PATCH" | "DELETE"; - -type ActionFn = ( - ...args: Args -) => TAction["state"]["input"] extends ZodType ? z.infer : void; - -export type HandleFn = any[], TResponse = any> = ( - ...args: TArgs -) => TResponse extends ZodType ? Promise | Response | RelayError> : Promise; - -type RouteContext = (TState["params"] extends ZodObject ? z.infer : object) & - (TState["query"] extends ZodObject ? z.infer : object) & - (TState["body"] extends ZodType ? z.infer : object) & - (TState["actions"] extends Array ? UnionToIntersection> : object); - -type Args = [ - ...(TState["params"] extends ZodObject ? [z.infer] : []), - ...(TState["query"] extends ZodObject ? [z.infer] : []), - ...(TState["body"] extends ZodType ? [z.infer] : []), - ...(TState["actions"] extends Array ? [UnionToIntersection>] : []), -]; - -type MergeAction> = - TActions[number] extends Action ? (TActionState["output"] extends ZodObject ? z.infer : object) : object; - -type UnionToIntersection = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never; diff --git a/mod.ts b/mod.ts deleted file mode 100644 index fc5b40e..0000000 --- a/mod.ts +++ /dev/null @@ -1,7 +0,0 @@ -export * from "./libraries/action.ts"; -export * from "./libraries/adapter.ts"; -export * from "./libraries/errors.ts"; -export * from "./libraries/procedure.ts"; -export * from "./libraries/relay.ts"; -export * from "./libraries/request.ts"; -export * from "./libraries/route.ts"; diff --git a/package.json b/package.json index c7dee05..1a58946 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,10 @@ { - "dependencies": { - "zod": "next" - }, "devDependencies": { - "@std/assert": "npm:@jsr/std__assert@1.0.12", - "@std/testing": "npm:@jsr/std__testing@1.0.11", - "eslint": "9.24.0", - "eslint-plugin-simple-import-sort": "12.1.1", - "prettier": "3.5.3", - "typescript-eslint": "8.30.1" + "@std/assert": "npm:@jsr/std__assert@1", + "@std/testing": "npm:@jsr/std__testing@1", + "eslint": "9", + "eslint-plugin-simple-import-sort": "12", + "prettier": "3", + "typescript-eslint": "8" } } diff --git a/spec/README.md b/spec/README.md new file mode 100644 index 0000000..4126644 --- /dev/null +++ b/spec/README.md @@ -0,0 +1,3 @@ +# Spec + +Todo ... diff --git a/spec/modules/README.md b/spec/modules/README.md new file mode 100644 index 0000000..016d434 --- /dev/null +++ b/spec/modules/README.md @@ -0,0 +1 @@ +# Modules \ No newline at end of file diff --git a/spec/modules/auth/errors.ts b/spec/modules/auth/errors.ts new file mode 100644 index 0000000..e6d732e --- /dev/null +++ b/spec/modules/auth/errors.ts @@ -0,0 +1,7 @@ +import { BadRequestError } from "@spec/relay"; + +export class AuthenticationStrategyPayloadError extends BadRequestError { + constructor() { + super("Provided authentication payload is not recognized."); + } +} diff --git a/spec/modules/auth/mod.ts b/spec/modules/auth/mod.ts new file mode 100644 index 0000000..1412c22 --- /dev/null +++ b/spec/modules/auth/mod.ts @@ -0,0 +1,8 @@ +import { authenticate } from "./routes/authenticate.ts"; + +export * from "./errors.ts"; +export * from "./strategies.ts"; + +export const routes = { + authenticate, +}; diff --git a/spec/modules/auth/routes/authenticate.ts b/spec/modules/auth/routes/authenticate.ts new file mode 100644 index 0000000..ceb0fbc --- /dev/null +++ b/spec/modules/auth/routes/authenticate.ts @@ -0,0 +1,9 @@ +import { route } from "@spec/relay"; + +import { AuthenticationStrategyPayloadError } from "../errors.ts"; +import { StrategyPayloadSchema } from "../strategies.ts"; + +export const authenticate = route + .post("/api/v1/authenticate") + .body(StrategyPayloadSchema) + .errors([AuthenticationStrategyPayloadError]); diff --git a/spec/modules/auth/strategies.ts b/spec/modules/auth/strategies.ts new file mode 100644 index 0000000..0e23334 --- /dev/null +++ b/spec/modules/auth/strategies.ts @@ -0,0 +1,51 @@ +import { z } from "zod"; + +export const PasskeyStrategySchema = z.object({ + type: z.literal("passkey").describe("Authentication strategy type for WebAuthn/Passkey"), + payload: z + .object({ + id: z.string().describe("Base64URL encoded credential ID"), + rawId: z.string().describe("Raw credential ID as base64URL encoded string"), + response: z + .object({ + clientDataJSON: z.string().describe("Base64URL encoded client data JSON"), + authenticatorData: z.string().describe("Base64URL encoded authenticator data"), + signature: z.string().optional().describe("Signature for authentication responses"), + userHandle: z.string().optional().describe("Optional user handle identifier"), + attestationObject: z.string().optional().describe("Attestation object for registration responses"), + }) + .describe("WebAuthn response data"), + clientExtensionResults: z + .record(z.string(), z.unknown()) + .default({}) + .describe("Results from WebAuthn extension inputs"), + authenticatorAttachment: z + .enum(["platform", "cross-platform"]) + .optional() + .describe("Type of authenticator used (platform or cross-platform)"), + }) + .describe("WebAuthn credential payload"), +}); + +export const EmailStrategySchema = z.object({ + type: z.literal("email").describe("Authentication strategy type for email"), + payload: z + .object({ + email: z.email().describe("User's email address for authentication"), + }) + .describe("Email authentication payload"), +}); + +export const PasswordStrategySchema = z.object({ + type: z.literal("password").describe("Authentication strategy type for password"), + payload: z + .object({ + identifier: z.string().describe("User identifier (username or email)"), + password: z.string().describe("User's password"), + }) + .describe("Password authentication payload"), +}); + +export const StrategyPayloadSchema = z + .union([PasskeyStrategySchema, EmailStrategySchema, PasswordStrategySchema]) + .describe("Union of all available authentication strategy schemas"); diff --git a/spec/modules/package.json b/spec/modules/package.json new file mode 100644 index 0000000..c1982ad --- /dev/null +++ b/spec/modules/package.json @@ -0,0 +1,11 @@ +{ + "name": "@spec/modules", + "version": "0.0.0", + "private": true, + "type": "module", + "dependencies": { + "@spec/relay": "workspace:*", + "@spec/shared": "workspace:*", + "zod": "4" + } +} \ No newline at end of file diff --git a/spec/relay/libraries/adapter.ts b/spec/relay/libraries/adapter.ts new file mode 100644 index 0000000..2aec300 --- /dev/null +++ b/spec/relay/libraries/adapter.ts @@ -0,0 +1,92 @@ +import z from "zod"; + +import type { ServerErrorType } from "./errors.ts"; +import type { RouteMethod } from "./route.ts"; + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +const ServerErrorResponseSchema = z.object({ + error: z.object({ + status: z.number(), + message: z.string(), + data: z.any().optional(), + }), +}); + +/* + |-------------------------------------------------------------------------------- + | Utilities + |-------------------------------------------------------------------------------- + */ + +/** + * Check if the given candidate is a valid relay error response. + * + * @param candidate - Candidate to check. + */ +export function assertServerErrorResponse(candidate: unknown): candidate is ServerErrorResponse { + return ServerErrorResponseSchema.safeParse(candidate).success; +} + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +export type RelayAdapter = { + readonly url: string; + + /** + * Return the full URL from given endpoint. + * + * @param endpoint - Endpoint to get url for. + */ + getUrl(endpoint: string): string; + + /** + * Send a 'application/json' request to the configured relay url. + * + * @param input - Request input parameters. + */ + json(input: RelayInput): Promise; + + /** + * Send a form data request to the configured relay url. + * + * @param input - Request input parameters. + */ + data(input: RelayInput): Promise; + + /** + * Sends a fetch request using the given options and returns a + * raw response. + * + * @param options - Relay request options. + */ + request(input: RequestInfo | URL, init?: RequestInit): Promise; +}; + +export type RelayInput = { + method: RouteMethod; + endpoint: string; + query?: string; + body?: Record; + headers?: Headers; +}; + +export type RelayResponse = + | { + result: "success"; + data: TData; + } + | { + result: "error"; + error: TError; + }; + +export type ServerErrorResponse = z.infer; diff --git a/spec/relay/libraries/client.ts b/spec/relay/libraries/client.ts new file mode 100644 index 0000000..34c1b98 --- /dev/null +++ b/spec/relay/libraries/client.ts @@ -0,0 +1,179 @@ +import z, { ZodType } from "zod"; + +import type { RelayAdapter, RelayInput, RelayResponse } from "./adapter.ts"; +import { Route, type Routes } from "./route.ts"; + +/** + * Factory method for generating a new relay client instance. + * + * @param config - Client configuration. + * @param procedures - Map of routes to make available to the client. + */ +export function makeClient(config: Config, routes: TRoutes): RelayClient { + const client: any = { + getUrl: config.adapter.getUrl.bind(config.adapter), + request: config.adapter.request.bind(config.adapter), + }; + for (const key in routes) { + const route = routes[key]; + if (route instanceof Route) { + client[key] = getRouteFn(route, config); + } else { + client[key] = getNestedRoute(config, route); + } + } + return client; +} + +/* + |-------------------------------------------------------------------------------- + | Helpers + |-------------------------------------------------------------------------------- + */ + +function getNestedRoute(config: Config, routes: TRoutes): RelayClient { + const nested: any = {}; + for (const key in routes) { + const route = routes[key]; + if (route instanceof Route) { + nested[key] = getRouteFn(route, config); + } else { + nested[key] = getNestedRoute(config, route); + } + } + return nested; +} + +function getRouteFn(route: Route, { adapter }: Config) { + return async (options: any) => { + const input: RelayInput = { + method: route.state.method, + endpoint: route.state.path, + query: "", + }; + + // ### Params + // Prepare request parameters by replacing :param notations with the + // parameter argument provided. + + if (route.state.params !== undefined) { + const params = await toParsedArgs( + route.state.params, + options.params, + `Invalid 'params' passed to ${route.state.path} handler.`, + ); + for (const key in params) { + input.endpoint = input.endpoint.replace(`:${key}`, encodeURIComponent(params[key])); + } + } + + // ### Query + // Prepare request query by looping through the query argument and + // creating a query string to pass onto the fetch request. + + if (route.state.query !== undefined) { + const query = await toParsedArgs( + route.state.query, + options.query, + `Invalid 'query' passed to ${route.state.path} handler.`, + ); + const pieces: string[] = []; + for (const key in query) { + pieces.push(`${encodeURIComponent(key)}=${encodeURIComponent(query[key])}`); + } + if (pieces.length > 0) { + input.query = `?${pieces.join("&")}`; + } + } + + // ### Body + // Attach the body to the input which is handled internally based on the + // type of fetch body is submitted. + + if (route.state.body !== undefined) { + input.body = await toParsedArgs( + route.state.body, + options.body, + `Invalid 'body' passed to ${route.state.path} handler.`, + ); + } + + // ### Request Init + // List of request init options that we can extract and forward to the + // request adapter. + + if (options.headers !== undefined) { + input.headers = new Headers(options.headers); + } + + // ### Fetch + + const response = route.state.content === "json" ? await adapter.json(input) : await adapter.data(input); + + if ("data" in response && route.state.output !== undefined) { + response.data = route.state.output.parse(response.data); + } + + return response; + }; +} + +async function toParsedArgs( + zod: ZodType, + args: unknown, + msg: string, +): Promise> { + const result = await zod.safeParseAsync(args); + if (result.success === false) { + throw new Error(msg); + } + return result.data as Record; +} + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +export type RelayClient = RelayRequest & RelayRoutes; + +type RelayRequest = { + url: string; + getUrl: (endpoint: string) => string; + request: (input: RequestInfo | URL, init?: RequestInit) => Promise>; +}; + +type RelayRoutes = { + [TKey in keyof TRoutes]: TRoutes[TKey] extends Route + ? (( + payload: OmitNever<{ + params: TRoutes[TKey]["$params"]; + query: TRoutes[TKey]["$query"]; + body: TRoutes[TKey]["$body"]; + headers?: Headers; + }>, + ) => Promise, RelayRouteErrors>>) & { + $params: TRoutes[TKey]["$params"]; + $query: TRoutes[TKey]["$query"]; + $body: TRoutes[TKey]["$body"]; + $response: TRoutes[TKey]["$response"]; + } + : TRoutes[TKey] extends Routes + ? RelayClient + : never; +}; + +type RelayRouteResponse = TRoute["state"]["output"] extends ZodType + ? z.infer + : null; + +type RelayRouteErrors = InstanceType; + +type OmitNever = { + [K in keyof T as T[K] extends never ? never : K]: T[K]; +}; + +type Config = { + adapter: RelayAdapter; +}; diff --git a/libraries/errors.ts b/spec/relay/libraries/errors.ts similarity index 80% rename from libraries/errors.ts rename to spec/relay/libraries/errors.ts index c51b145..f3e7286 100644 --- a/libraries/errors.ts +++ b/spec/relay/libraries/errors.ts @@ -1,4 +1,6 @@ -export abstract class RelayError extends Error { +import type { $ZodErrorTree } from "zod/v4/core"; + +export abstract class ServerError extends Error { constructor( message: string, readonly status: number, @@ -12,7 +14,7 @@ export abstract class RelayError extends Error { * * @param value - Error JSON. */ - static fromJSON(value: RelayErrorJSON): RelayErrorType { + static fromJSON(value: ServerErrorJSON): ServerErrorType { switch (value.status) { case 400: return new BadRequestError(value.message, value.data); @@ -34,6 +36,12 @@ export abstract class RelayError extends Error { return new UnsupportedMediaTypeError(value.message, value.data); case 422: return new UnprocessableContentError(value.message, value.data); + case 432: + return new ZodValidationError(value.message, value.data); + case 500: + return new InternalServerError(value.message, value.data); + case 501: + return new NotImplementedError(value.message, value.data); case 503: return new ServiceUnavailableError(value.message, value.data); default: @@ -44,8 +52,9 @@ export abstract class RelayError extends Error { /** * Convert error instance to a JSON object. */ - toJSON(): RelayErrorJSON { + toJSON(): ServerErrorJSON { return { + type: "relay", status: this.status, message: this.message, data: this.data, @@ -53,7 +62,7 @@ export abstract class RelayError extends Error { } } -export class BadRequestError extends RelayError { +export class BadRequestError extends ServerError { /** * Instantiate a new BadRequestError. * @@ -68,7 +77,7 @@ export class BadRequestError extends RelayError { } } -export class UnauthorizedError extends RelayError { +export class UnauthorizedError extends ServerError { /** * Instantiate a new UnauthorizedError. * @@ -94,7 +103,7 @@ export class UnauthorizedError extends RelayError { } } -export class ForbiddenError extends RelayError { +export class ForbiddenError extends ServerError { /** * Instantiate a new ForbiddenError. * @@ -115,7 +124,7 @@ export class ForbiddenError extends RelayError { } } -export class NotFoundError extends RelayError { +export class NotFoundError extends ServerError { /** * Instantiate a new NotFoundError. * @@ -137,7 +146,7 @@ export class NotFoundError extends RelayError { } } -export class MethodNotAllowedError extends RelayError { +export class MethodNotAllowedError extends ServerError { /** * Instantiate a new MethodNotAllowedError. * @@ -154,7 +163,7 @@ export class MethodNotAllowedError extends RelayError { } } -export class NotAcceptableError extends RelayError { +export class NotAcceptableError extends ServerError { /** * Instantiate a new NotAcceptableError. * @@ -171,7 +180,7 @@ export class NotAcceptableError extends RelayError { } } -export class ConflictError extends RelayError { +export class ConflictError extends ServerError { /** * Instantiate a new ConflictError. * @@ -192,7 +201,7 @@ export class ConflictError extends RelayError { } } -export class GoneError extends RelayError { +export class GoneError extends ServerError { /** * Instantiate a new GoneError. * @@ -215,7 +224,7 @@ export class GoneError extends RelayError { } } -export class UnsupportedMediaTypeError extends RelayError { +export class UnsupportedMediaTypeError extends ServerError { /** * Instantiate a new UnsupportedMediaTypeError. * @@ -232,7 +241,7 @@ export class UnsupportedMediaTypeError extends RelayError extends RelayError { +export class UnprocessableContentError extends ServerError { /** * Instantiate a new UnprocessableContentError. * @@ -254,7 +263,22 @@ export class UnprocessableContentError extends RelayError extends RelayError { +export class ZodValidationError> extends ServerError { + /** + * Instantiate a new ZodValidationError. + * + * This indicates that the server understood the request body, but the structure + * failed validation against the expected schema. + * + * @param message - Optional message to send with the error. Default: "Unprocessable Content". + * @param data - ZodError instance to pass through. + */ + constructor(message: string, data: TData) { + super(message, 432, data); + } +} + +export class InternalServerError extends ServerError { /** * Instantiate a new InternalServerError. * @@ -277,7 +301,24 @@ export class InternalServerError extends RelayError { } } -export class ServiceUnavailableError extends RelayError { +export class NotImplementedError extends ServerError { + /** + * Instantiate a new NotImplementedError. + * + * The **HTTP 501 Not Implemented** server error response status code means that + * the server does not support the functionality required to fulfill the request. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/501 + * + * @param message - Optional message to send with the error. Default: "Service Unavailable". + * @param data - Optional data to send with the error. + */ + constructor(message = "Not Implemented", data?: TData) { + super(message, 501, data); + } +} + +export class ServiceUnavailableError extends ServerError { /** * Instantiate a new ServiceUnavailableError. * @@ -297,13 +338,16 @@ export class ServiceUnavailableError extends RelayError } } -export type RelayErrorJSON = { +export type ServerErrorClass = typeof ServerError; + +export type ServerErrorJSON = { + type: "relay"; status: number; message: string; - data: any; + data?: any; }; -export type RelayErrorType = +export type ServerErrorType = | BadRequestError | UnauthorizedError | ForbiddenError @@ -314,5 +358,6 @@ export type RelayErrorType = | GoneError | UnsupportedMediaTypeError | UnprocessableContentError + | NotImplementedError | ServiceUnavailableError | InternalServerError; diff --git a/spec/relay/libraries/hooks.ts b/spec/relay/libraries/hooks.ts new file mode 100644 index 0000000..1356d17 --- /dev/null +++ b/spec/relay/libraries/hooks.ts @@ -0,0 +1,10 @@ +export type Hooks = { + /** + * Executes when any error is thrown before or during the lifetime + * of the route. This allows for custom handling of errors if the + * route has unique requirements to error handling. + * + * @param error - Error which has been thrown. + */ + onError?: (error: unknown) => Response; +}; diff --git a/spec/relay/libraries/route.ts b/spec/relay/libraries/route.ts new file mode 100644 index 0000000..d4ae799 --- /dev/null +++ b/spec/relay/libraries/route.ts @@ -0,0 +1,488 @@ +import { match, type MatchFunction } from "path-to-regexp"; +import z, { ZodObject, ZodRawShape, ZodType } from "zod"; + +import { ServerError, ServerErrorClass } from "./errors.ts"; +import { Hooks } from "./hooks.ts"; + +export class Route { + readonly type = "route" as const; + + declare readonly $params: TState["params"] extends ZodObject ? z.input : never; + declare readonly $query: TState["query"] extends ZodObject ? z.input : never; + declare readonly $body: TState["body"] extends ZodType ? z.input : never; + declare readonly $response: TState["output"] extends ZodType ? z.output : never; + + #matchFn?: MatchFunction; + + /** + * Instantiate a new Route instance. + * + * @param state - Route state. + */ + constructor(readonly state: TState) {} + + /** + * HTTP Method + */ + get method(): RouteMethod { + return this.state.method; + } + + /** + * URL pattern of the route. + */ + get matchFn(): MatchFunction { + if (this.#matchFn === undefined) { + this.#matchFn = match(this.path); + } + return this.#matchFn; + } + + /** + * URL path + */ + get path(): string { + return this.state.path; + } + + /** + * Check if the provided URL matches the route pattern. + * + * @param url - HTTP request.url + */ + match(url: string): boolean { + return this.matchFn(url) !== false; + } + + /** + * Extract parameters from the provided URL based on the route pattern. + * + * @param url - HTTP request.url + */ + getParsedParams : object>( + url: string, + ): TParams { + const result = match(this.path)(url); + if (result === false) { + return {} as TParams; + } + return result.params as TParams; + } + + /** + * Set the content the route expects, 'json' or 'form-data' which the client uses + * to determine which adapter operation to execute on requests. + * + * @param content - Content expected during transfers. + */ + content(content: TContent): Route & { content: TContent }> { + return new Route({ ...this.state, content }); + } + + /** + * Set the meta data for this route which can be used in e.g. OpenAPI generation + * + * @param meta - Meta object + * + * @examples + * + * ```ts + * route.post("/foo").meta({ description: "Super route" }); + * ``` + */ + meta(meta: TRouteMeta): Route & { meta: TRouteMeta }> { + return new Route({ ...this.state, meta }); + } + + /** + * Access level of the route which acts as the first barrier of entry + * to ensure that requests are valid. + * + * By default on the server the lack of access definition will result + * in an error as all routes needs an access definition. + * + * @param access - Access level of the route. + * + * @examples + * + * ```ts + * const hasFooBar = action + * .make("hasFooBar") + * .response(z.object({ foobar: z.number() })) + * .handle(async () => { + * return { + * foobar: 1, + * }; + * }); + * + * // ### Public Endpoint + * + * route + * .post("/foo") + * .access("public") + * .handle(async ({ foobar }) => { + * console.log(typeof foobar); // => number + * }); + * + * // ### Require Session + * + * route + * .post("/foo") + * .access("session") + * .handle(async ({ foobar }) => { + * console.log(typeof foobar); // => number + * }); + * + * // ### Require Session & Resource Assignment + * + * route + * .post("/foo") + * .access([resource("foo", "create")]) + * .handle(async ({ foobar }) => { + * console.log(typeof foobar); // => number + * }); + * ``` + */ + access(access: TAccess): Route & { access: TAccess }> { + return new Route({ ...this.state, access: access as TAccess }); + } + + /** + * Params allows for custom casting of URL parameters. If a parameter does not + * have a corresponding zod schema the default param type is "string". + * + * @param params - URL params. + * + * @examples + * + * ```ts + * route + * .post("/foo/:bar") + * .params({ + * bar: z.coerce.number() + * }) + * .handle(async ({ bar }) => { + * console.log(typeof bar); // => number + * }); + * ``` + */ + params(params: TParams): Route & { params: ZodObject }> { + return new Route({ ...this.state, params: z.object(params) as any }); + } + + /** + * Search allows for custom casting of URL query parameters. If a parameter does + * not have a corresponding zod schema the default param type is "string". + * + * @param query - URL query arguments. + * + * @examples + * + * ```ts + * route + * .post("/foo") + * .query({ + * bar: z.number({ coerce: true }) + * }) + * .handle(async ({ bar }) => { + * console.log(typeof bar); // => number + * }); + * ``` + */ + query(query: TQuery): Route & { query: ZodObject }> { + return new Route({ ...this.state, query: z.object(query) as any }); + } + + /** + * Shape of the body this route expects to receive. This is used by all + * mutator routes and has no effect when defined on "GET" methods. + * + * @param body - Body the route expects. + * + * @examples + * + * ```ts + * route + * .post("/foo") + * .body( + * z.object({ + * bar: z.number() + * }) + * ) + * .handle(async ({ body: { bar } }) => { + * console.log(typeof bar); // => number + * }); + * ``` + */ + body(body: TBody): Route & { body: TBody }> { + return new Route({ ...this.state, body }); + } + + /** + * Shape of the success response this route produces. This is used by the transform + * tools to ensure the client receives parsed data. + * + * @param response - Response shape of the route. + * + * @examples + * + * ```ts + * route + * .post("/foo") + * .response( + * z.object({ + * bar: z.number() + * }) + * ) + * .handle(async () => { + * return { + * bar: 1 + * } + * }); + * ``` + */ + response(output: TResponse): Route & { output: TResponse }> { + return new Route({ ...this.state, output }); + } + + /** + * Instances of the possible error responses this route produces. + * + * @param errors - Error shapes of the route. + * + * @examples + * + * ```ts + * route + * .post("/foo") + * .errors([ + * BadRequestError + * ]) + * .handle(async () => { + * return new BadRequestError(); + * }); + * ``` + */ + errors(errors: TErrors): Route & { errors: TErrors }> { + return new Route({ ...this.state, errors }); + } + + /** + * Server handler callback method. + * + * Handler receives the params, query, body, actions in order of definition. + * So if your route has params, and body the route handle method will + * receive (params, body) as arguments. + * + * @param handle - Handle function to trigger when the route is executed. + * + * @examples + * + * ```ts + * relay + * .post("/api/v1/foo/:bar") + * .params({ bar: z.string() }) + * .body(z.tuple([z.string(), z.number()])) + * .handle(async ({ bar }, [ "string", number ]) => {}); + * ``` + */ + handle, TState["output"]>>( + handle: THandleFn, + ): Route & { handle: THandleFn }> { + return new Route({ ...this.state, handle }); + } + + /** + * Assign lifetime hooks to a route allowing for custom handling of + * events that can occur during a request or response. + * + * Can be used on both server and client with the appropriate + * implementation. + * + * @param hooks - Hooks to register with the route. + */ + hooks(hooks: THooks): Route & { hooks: THooks }> { + return new Route({ ...this.state, hooks }); + } +} + +/* + |-------------------------------------------------------------------------------- + | Factories + |-------------------------------------------------------------------------------- + */ + +/** + * Route factories allowing for easy generation of relay compliant routes. + */ +export const route: { + post( + path: TPath, + ): Route<{ method: "POST"; path: TPath; content: "json"; errors: [ServerErrorClass] }>; + get( + path: TPath, + ): Route<{ method: "GET"; path: TPath; content: "json"; errors: [ServerErrorClass] }>; + put( + path: TPath, + ): Route<{ method: "PUT"; path: TPath; content: "json"; errors: [ServerErrorClass] }>; + patch( + path: TPath, + ): Route<{ method: "PATCH"; path: TPath; content: "json"; errors: [ServerErrorClass] }>; + delete( + path: TPath, + ): Route<{ method: "DELETE"; path: TPath; content: "json"; errors: [ServerErrorClass] }>; +} = { + /** + * Create a new "POST" route for the given path. + * + * @param path - Path to generate route for. + * + * @examples + * + * ```ts + * route + * .post("/foo") + * .body( + * z.object({ bar: z.string() }) + * ); + * ``` + */ + post(path: TPath) { + return new Route({ method: "POST", path, content: "json", errors: [ServerError] }); + }, + + /** + * Create a new "GET" route for the given path. + * + * @param path - Path to generate route for. + * + * @examples + * + * ```ts + * route.get("/foo"); + * ``` + */ + get(path: TPath) { + return new Route({ method: "GET", path, content: "json", errors: [ServerError] }); + }, + + /** + * Create a new "PUT" route for the given path. + * + * @param path - Path to generate route for. + * + * @examples + * + * ```ts + * route + * .put("/foo") + * .body( + * z.object({ bar: z.string() }) + * ); + * ``` + */ + put(path: TPath) { + return new Route({ method: "PUT", path, content: "json", errors: [ServerError] }); + }, + + /** + * Create a new "PATCH" route for the given path. + * + * @param path - Path to generate route for. + * + * @examples + * + * ```ts + * route + * .patch("/foo") + * .body( + * z.object({ bar: z.string() }) + * ); + * ``` + */ + patch(path: TPath) { + return new Route({ method: "PATCH", path, content: "json", errors: [ServerError] }); + }, + + /** + * Create a new "DELETE" route for the given path. + * + * @param path - Path to generate route for. + * + * @examples + * + * ```ts + * route.delete("/foo"); + * ``` + */ + delete(path: TPath) { + return new Route({ method: "DELETE", path, content: "json", errors: [ServerError] }); + }, +}; + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +export type Routes = { + [key: string]: Routes | Route; +}; + +type RouteState = { + method: RouteMethod; + path: string; + content: RouteContent; + meta?: RouteMeta; + access?: RouteAccess; + params?: ZodObject; + query?: ZodObject; + body?: ZodType; + output?: ZodType; + errors: ServerErrorClass[]; + handle?: HandleFn; + hooks?: Hooks; +}; + +export type RouteMeta = { + openapi?: "internal" | "external"; + description?: string; + summary?: string; + tags?: string[]; +} & Record; + +export type RouteMethod = "POST" | "GET" | "PUT" | "PATCH" | "DELETE"; + +export type RouteContent = "json" | "form-data"; + +export type RouteAccess = "public" | "session" | (() => boolean)[]; + +export type AccessFn = (resource: string, action: string) => () => boolean; + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface ServerContext {} + +type HandleFn = any[], TResponse = any> = ( + ...args: TArgs +) => TResponse extends ZodType + ? Promise | Response | ServerError | unknown> + : Promise; + +type ServerArgs = + HasInputArgs extends true + ? [ + (TState["params"] extends ZodObject ? { params: z.output } : unknown) & + (TState["query"] extends ZodObject ? { query: z.output } : unknown) & + (TState["body"] extends ZodType ? { body: z.output } : unknown), + ServerContext, + ] + : [ServerContext]; + +type HasInputArgs = TState["params"] extends ZodObject + ? true + : TState["query"] extends ZodObject + ? true + : TState["body"] extends ZodType + ? true + : false; diff --git a/spec/relay/mod.ts b/spec/relay/mod.ts new file mode 100644 index 0000000..ad6275c --- /dev/null +++ b/spec/relay/mod.ts @@ -0,0 +1,5 @@ +export * from "./libraries/adapter.ts"; +export * from "./libraries/client.ts"; +export * from "./libraries/errors.ts"; +export * from "./libraries/hooks.ts"; +export * from "./libraries/route.ts"; diff --git a/spec/relay/package.json b/spec/relay/package.json new file mode 100644 index 0000000..beea7f4 --- /dev/null +++ b/spec/relay/package.json @@ -0,0 +1,14 @@ +{ + "name": "@spec/relay", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./mod.ts", + "exports": { + ".": "./mod.ts" + }, + "dependencies": { + "path-to-regexp": "8", + "zod": "4" + } +} \ No newline at end of file diff --git a/spec/shared/mod.ts b/spec/shared/mod.ts new file mode 100644 index 0000000..e69de29 diff --git a/spec/shared/package.json b/spec/shared/package.json new file mode 100644 index 0000000..d11b594 --- /dev/null +++ b/spec/shared/package.json @@ -0,0 +1,13 @@ +{ + "name": "@spec/shared", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./mod.ts", + "exports": { + ".": "./mod.ts" + }, + "dependencies": { + "zod": "4" + } +} \ No newline at end of file diff --git a/tests/mocks/actions.ts b/tests/mocks/actions.ts deleted file mode 100644 index 698dd2a..0000000 --- a/tests/mocks/actions.ts +++ /dev/null @@ -1,15 +0,0 @@ -import z from "zod"; - -import { action } from "../../libraries/action.ts"; -import { BadRequestError } from "../../mod.ts"; - -export const addNumbers = action - .make("number:add") - .input(z.tuple([z.number(), z.number()])) - .output({ sum: z.number() }) - .handle(async ([a, b]) => { - if (a < 0 || b < 0) { - return new BadRequestError("Invalid numbers provided"); - } - return { sum: a + b }; - }); diff --git a/tests/mocks/relay.ts b/tests/mocks/relay.ts deleted file mode 100644 index 0cd16c6..0000000 --- a/tests/mocks/relay.ts +++ /dev/null @@ -1,59 +0,0 @@ -import z from "zod"; - -import { rpc } from "../../libraries/procedure.ts"; -import { Relay } from "../../libraries/relay.ts"; -import { route } from "../../libraries/route.ts"; -import { UserSchema } from "./user.ts"; - -export const relay = new Relay({ - rpc: { - user: { - create: rpc - .method("user:create") - .params(UserSchema.omit({ id: true, createdAt: true })) - .result(z.string()), - get: rpc.method("user:get").params(z.uuid()).result(UserSchema), - update: rpc.method("user:update").params( - z.tuple([ - z.string(), - z.object({ - name: z.string().optional(), - email: z.string().check(z.email()).optional(), - }), - ]), - ), - delete: rpc.method("user:delete").params(z.uuid()), - }, - numbers: { - add: rpc - .method("number:add") - .params(z.tuple([z.number(), z.number()])) - .result(z.number()), - }, - }, - rest: { - user: { - create: route - .post("/users") - .body(UserSchema.omit({ id: true, createdAt: true })) - .response(z.string()), - get: route.get("/users/:userId").params({ userId: z.string() }).response(UserSchema), - update: route - .put("/users/:userId") - .params({ userId: z.string() }) - .body( - z.object({ - name: z.string().optional(), - email: z.string().check(z.email()).optional(), - }), - ), - delete: route.delete("/users/:userId").params({ userId: z.uuid() }), - }, - numbers: { - add: route - .post("/numbers/add") - .body(z.tuple([z.number(), z.number()])) - .response(z.number()), - }, - }, -}); diff --git a/tests/mocks/server.ts b/tests/mocks/server.ts deleted file mode 100644 index a6c2f55..0000000 --- a/tests/mocks/server.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { NotFoundError } from "../../mod.ts"; -import { addNumbers } from "./actions.ts"; -import { relay } from "./relay.ts"; -import { User } from "./user.ts"; - -export let users: User[] = []; - -export const api = relay.api([ - relay.method("user:create").handle(async ({ name, email }) => { - const id = crypto.randomUUID(); - users.push({ id, name, email, createdAt: new Date() }); - return id; - }), - relay.method("user:get").handle(async (userId) => { - const user = users.find((user) => user.id === userId); - if (user === undefined) { - return new NotFoundError(); - } - return user; - }), - relay.method("user:update").handle(async ([userId, { name, email }]) => { - for (const user of users) { - if (user.id === userId) { - user.name = name ?? user.name; - user.email = email ?? user.email; - break; - } - } - }), - relay.method("user:delete").handle(async (userId) => { - users = users.filter((user) => user.id !== userId); - }), - relay - .method("number:add") - .actions([[addNumbers, (params) => params]]) - .handle(async (_, { sum }) => { - return sum; - }), - relay.post("/users").handle(async ({ name, email }) => { - const id = crypto.randomUUID(); - users.push({ id, name, email, createdAt: new Date() }); - return id; - }), - relay.get("/users/:userId").handle(async ({ userId }) => { - const user = users.find((user) => user.id === userId); - if (user === undefined) { - return new NotFoundError(); - } - return user; - }), - relay.put("/users/:userId").handle(async ({ userId }, { name, email }) => { - for (const user of users) { - if (user.id === userId) { - user.name = name ?? user.name; - user.email = email ?? user.email; - break; - } - } - }), - relay.delete("/users/:userId").handle(async ({ userId }) => { - users = users.filter((user) => user.id !== userId); - }), - relay - .post("/numbers/add") - .actions([[addNumbers, (body) => body]]) - .handle(async (_, { sum }) => { - return sum; - }), -]); diff --git a/tests/mocks/user.ts b/tests/mocks/user.ts deleted file mode 100644 index 1586833..0000000 --- a/tests/mocks/user.ts +++ /dev/null @@ -1,10 +0,0 @@ -import z from "zod"; - -export const UserSchema = z.object({ - id: z.string().check(z.uuid()), - name: z.string(), - email: z.string().check(z.email()), - createdAt: z.coerce.date(), -}); - -export type User = z.infer; diff --git a/tests/procedure.test.ts b/tests/procedure.test.ts deleted file mode 100644 index e1eaa50..0000000 --- a/tests/procedure.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { assertEquals, assertObjectMatch, assertRejects } from "@std/assert"; -import { afterAll, beforeAll, describe, it } from "@std/testing/bdd"; - -import { HttpAdapter } from "../adapters/http.ts"; -import { relay } from "./mocks/relay.ts"; -import { api, users } from "./mocks/server.ts"; - -describe("Procedure", () => { - let server: Deno.HttpServer; - let client: typeof relay.$inferClient; - - beforeAll(async () => { - server = Deno.serve( - { - port: 8080, - hostname: "localhost", - onListen({ port, hostname }) { - console.log(`Listening at http://${hostname}:${port}`); - }, - }, - async (request) => { - switch (request.headers.get("x-relay-type")) { - case "rest": { - return api.rest(request); - } - case "rpc": { - return api.rpc(await api.parse(request)); - } - } - return new Response("Missing required 'x-relay-type' type in header.", { status: 400 }); - }, - ); - client = relay.client({ - adapter: new HttpAdapter("http://localhost:8080"), - }); - }); - - afterAll(async () => { - await server.shutdown(); - }); - - describe("RPC", () => { - it("should successfully relay users", async () => { - const userId = await client.rpc.user.create({ name: "John Doe", email: "john.doe@fixture.none" }); - - assertEquals(typeof userId, "string"); - assertEquals(users.length, 1); - - const user = await client.rpc.user.get(userId); - - assertEquals(user.createdAt instanceof Date, true); - - await client.rpc.user.update([userId, { name: "Jane Doe", email: "jane.doe@fixture.none" }]); - - assertEquals(users.length, 1); - assertObjectMatch(users[0], { - name: "Jane Doe", - email: "jane.doe@fixture.none", - }); - - await client.rpc.user.delete(userId); - - assertEquals(users.length, 0); - }); - - it("should successfully run .actions", async () => { - assertEquals(await client.rpc.numbers.add([1, 1]), 2); - }); - - it("should reject .actions with error", async () => { - await assertRejects(() => client.rpc.numbers.add([-1, 1]), "Invalid input numbers added"); - }); - }); - - describe("REST", () => { - it("should successfully relay users", async () => { - const userId = await client.rest.user.create({ name: "John Doe", email: "john.doe@fixture.none" }); - - assertEquals(typeof userId, "string"); - assertEquals(users.length, 1); - - const user = await client.rest.user.get({ userId }); - - assertEquals(user.createdAt instanceof Date, true); - - await client.rest.user.update({ userId }, { name: "Jane Doe", email: "jane.doe@fixture.none" }); - - assertEquals(users.length, 1); - assertObjectMatch(users[0], { - name: "Jane Doe", - email: "jane.doe@fixture.none", - }); - - await client.rest.user.delete({ userId }); - - assertEquals(users.length, 0); - }); - - it("should successfully run .actions", async () => { - assertEquals(await client.rest.numbers.add([1, 1]), 2); - }); - - it("should reject .actions with error", async () => { - await assertRejects(() => client.rest.numbers.add([-1, 1]), "Invalid input numbers added"); - }); - }); -});